Skip to content

Commit 8ba9bb2

Browse files
feedback
1 parent 502472e commit 8ba9bb2

File tree

3 files changed

+195
-93
lines changed

3 files changed

+195
-93
lines changed
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
'use client';
2+
3+
import { approveAuthorization, denyAuthorization } from '@/ee/features/oauth/actions';
4+
import { LoadingButton } from '@/components/ui/loading-button';
5+
import { isServiceError } from '@/lib/utils';
6+
import { ClientIcon } from './clientIcon';
7+
import Image from 'next/image';
8+
import logo from '@/public/logo_512.png';
9+
import { useState } from 'react';
10+
11+
interface ConsentScreenProps {
12+
clientId: string;
13+
clientName: string;
14+
clientLogoUri: string | null;
15+
redirectUri: string;
16+
codeChallenge: string;
17+
resource: string | null;
18+
state: string | undefined;
19+
userEmail: string;
20+
}
21+
22+
export function ConsentScreen({
23+
clientId,
24+
clientName,
25+
clientLogoUri,
26+
redirectUri,
27+
codeChallenge,
28+
resource,
29+
state,
30+
userEmail,
31+
}: ConsentScreenProps) {
32+
const [pending, setPending] = useState<'approve' | 'deny' | null>(null);
33+
34+
const onApprove = async () => {
35+
setPending('approve');
36+
const result = await approveAuthorization({ clientId, redirectUri, codeChallenge, resource, state });
37+
if (isServiceError(result)) {
38+
setPending(null);
39+
return;
40+
}
41+
window.location.href = result;
42+
};
43+
44+
const onDeny = async () => {
45+
setPending('deny');
46+
const result = await denyAuthorization({ redirectUri, state });
47+
if (isServiceError(result)) {
48+
setPending(null);
49+
return;
50+
}
51+
window.location.href = result;
52+
};
53+
54+
return (
55+
<div className="w-full max-w-md rounded-lg border border-border bg-card p-8 shadow-sm">
56+
57+
{/* App icons */}
58+
<div className="flex items-center justify-center gap-3 mb-6">
59+
<ClientIcon name={clientName} logoUri={clientLogoUri} />
60+
<svg className="w-4 h-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
61+
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7h8m0 0-3-3m3 3-3 3M16 17H8m0 0 3 3m-3-3 3-3" />
62+
</svg>
63+
<Image
64+
src={logo}
65+
alt="Sourcebot"
66+
width={70}
67+
height={70}
68+
className="shrink-0 rounded-xl object-cover"
69+
/>
70+
</div>
71+
72+
{/* Title */}
73+
<h1 className="text-lg font-semibold text-foreground mb-2">
74+
<span className="font-bold">{clientName}</span> is requesting access to your Sourcebot account.
75+
</h1>
76+
<p className="text-sm text-muted-foreground text-center mb-6">
77+
Logged in as <span className="font-medium">{userEmail}</span>
78+
</p>
79+
80+
{/* Details table */}
81+
<div className="mb-6 text-sm">
82+
<p className="text-muted-foreground mb-2">Details</p>
83+
<div className="rounded-md border border-border divide-y divide-border">
84+
<div className="flex px-4 py-2.5">
85+
<span className="font-medium text-foreground w-32 shrink-0">Name:</span>
86+
<span>{clientName}</span>
87+
</div>
88+
<div className="flex px-4 py-2.5">
89+
<span className="font-medium text-foreground w-32 shrink-0">Redirect URI:</span>
90+
<span className="break-all">{redirectUri}</span>
91+
</div>
92+
</div>
93+
</div>
94+
95+
{/* Actions */}
96+
<div className="flex justify-end gap-3">
97+
<LoadingButton
98+
variant="outline"
99+
onClick={onDeny}
100+
loading={pending === 'deny'}
101+
disabled={pending !== null}
102+
>
103+
Cancel
104+
</LoadingButton>
105+
<LoadingButton
106+
onClick={onApprove}
107+
loading={pending === 'approve'}
108+
disabled={pending !== null}
109+
>
110+
Approve
111+
</LoadingButton>
112+
</div>
113+
114+
</div>
115+
);
116+
}

packages/web/src/app/oauth/authorize/page.tsx

Lines changed: 11 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
11
import { auth } from '@/auth';
2-
import { generateAndStoreAuthCode } from '@/ee/features/oauth/server';
32
import { LogoutEscapeHatch } from '@/app/components/logoutEscapeHatch';
4-
import { ClientIcon } from './components/clientIcon';
5-
import { Button } from '@/components/ui/button';
3+
import { ConsentScreen } from './components/consentScreen';
64
import { prisma } from '@/prisma';
75
import { hasEntitlement } from '@sourcebot/shared';
86
import { redirect } from 'next/navigation';
9-
import logo from '@/public/logo_512.png';
10-
import Image from 'next/image';
117

128
interface AuthorizePageProps {
139
searchParams: Promise<{
@@ -60,97 +56,19 @@ export default async function AuthorizePage({ searchParams }: AuthorizePageProps
6056
redirect(`/login?callbackUrl=${encodeURIComponent(callbackUrl)}`);
6157
}
6258

63-
// Server action: user approved the authorization request.
64-
async function handleAllow() {
65-
'use server';
66-
const rawCode = await generateAndStoreAuthCode({
67-
clientId: client_id!,
68-
userId: session!.user.id,
69-
redirectUri: redirect_uri!,
70-
codeChallenge: code_challenge!,
71-
resource: resource ?? null,
72-
});
73-
74-
const callbackUrl = new URL(redirect_uri!);
75-
callbackUrl.searchParams.set('code', rawCode);
76-
if (state) callbackUrl.searchParams.set('state', state);
77-
const isWebUrl = callbackUrl.protocol === 'http:' || callbackUrl.protocol === 'https:';
78-
if (isWebUrl) {
79-
redirect(callbackUrl.toString());
80-
} else {
81-
redirect(`/oauth/complete?url=${encodeURIComponent(callbackUrl.toString())}`);
82-
}
83-
}
84-
85-
// Server action: user denied the authorization request.
86-
async function handleDeny() {
87-
'use server';
88-
const callbackUrl = new URL(redirect_uri!);
89-
callbackUrl.searchParams.set('error', 'access_denied');
90-
callbackUrl.searchParams.set('error_description', 'The user denied the authorization request.');
91-
if (state) callbackUrl.searchParams.set('state', state);
92-
const isWebUrl = callbackUrl.protocol === 'http:' || callbackUrl.protocol === 'https:';
93-
if (isWebUrl) {
94-
redirect(callbackUrl.toString());
95-
} else {
96-
redirect(`/oauth/complete?url=${encodeURIComponent(callbackUrl.toString())}`);
97-
}
98-
}
99-
10059
return (
10160
<div className="relative min-h-screen flex items-center justify-center bg-background">
10261
<LogoutEscapeHatch className="absolute top-0 right-0 p-6" />
103-
<div className="w-full max-w-md rounded-lg border border-border bg-card p-8 shadow-sm">
104-
105-
{/* App icons */}
106-
<div className="flex items-center justify-center gap-3 mb-6">
107-
<ClientIcon name={client.name} logoUri={client.logoUri} />
108-
<svg className="w-4 h-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
109-
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7h8m0 0-3-3m3 3-3 3M16 17H8m0 0 3 3m-3-3 3-3" />
110-
</svg>
111-
<Image
112-
src={logo}
113-
alt="Sourcebot"
114-
width={70}
115-
height={70}
116-
className="shrink-0 rounded-xl object-cover"
117-
/>
118-
</div>
119-
120-
{/* Title */}
121-
<h1 className="text-lg font-semibold text-foreground mb-2">
122-
<span className="font-bold">{client.name}</span> is requesting access to your Sourcebot account.
123-
</h1>
124-
<p className="text-sm text-muted-foreground text-center mb-6">
125-
Logged in as <span className="font-medium">{session.user.email}</span>
126-
</p>
127-
128-
{/* Details table */}
129-
<div className="mb-6 text-sm">
130-
<p className="text-muted-foreground mb-2">Details</p>
131-
<div className="rounded-md border border-border divide-y divide-border">
132-
<div className="flex px-4 py-2.5">
133-
<span className="font-medium text-foreground w-32 shrink-0">Name:</span>
134-
<span>{client.name}</span>
135-
</div>
136-
<div className="flex px-4 py-2.5">
137-
<span className="font-medium text-foreground w-32 shrink-0">Redirect URI:</span>
138-
<span className="break-all">{redirect_uri}</span>
139-
</div>
140-
</div>
141-
</div>
142-
143-
{/* Actions */}
144-
<div className="flex justify-end gap-3">
145-
<form action={handleDeny}>
146-
<Button type="submit" variant="outline">Cancel</Button>
147-
</form>
148-
<form action={handleAllow}>
149-
<Button type="submit">Approve</Button>
150-
</form>
151-
</div>
152-
153-
</div>
62+
<ConsentScreen
63+
clientId={client_id!}
64+
clientName={client.name}
65+
clientLogoUri={client.logoUri}
66+
redirectUri={redirect_uri!}
67+
codeChallenge={code_challenge!}
68+
resource={resource ?? null}
69+
state={state}
70+
userEmail={session!.user.email!}
71+
/>
15472
</div>
15573
);
15674
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
'use server';
2+
3+
import { sew } from '@/actions';
4+
import { generateAndStoreAuthCode } from '@/ee/features/oauth/server';
5+
import { withAuthV2 } from '@/withAuthV2';
6+
7+
/**
8+
* Resolves the final URL to navigate to after an authorization decision.
9+
* Non-web redirect URIs (e.g. custom schemes like vscode://) are wrapped in
10+
* /oauth/complete so the browser can handle the handoff.
11+
*/
12+
function resolveCallbackUrl(callbackUrl: URL): string {
13+
const isWebUrl = callbackUrl.protocol === 'http:' || callbackUrl.protocol === 'https:';
14+
return isWebUrl
15+
? callbackUrl.toString()
16+
: `/oauth/complete?url=${encodeURIComponent(callbackUrl.toString())}`;
17+
}
18+
19+
/**
20+
* Called when the user approves the OAuth authorization request. Generates an
21+
* authorization code and returns the callback URL for the client to navigate to.
22+
*/
23+
export const approveAuthorization = async ({
24+
clientId,
25+
redirectUri,
26+
codeChallenge,
27+
resource,
28+
state,
29+
}: {
30+
clientId: string;
31+
redirectUri: string;
32+
codeChallenge: string;
33+
resource: string | null;
34+
state: string | undefined;
35+
}) => sew(() =>
36+
withAuthV2(async ({ user }) => {
37+
const rawCode = await generateAndStoreAuthCode({
38+
clientId,
39+
userId: user.id,
40+
redirectUri,
41+
codeChallenge,
42+
resource,
43+
});
44+
45+
const callbackUrl = new URL(redirectUri);
46+
callbackUrl.searchParams.set('code', rawCode);
47+
if (state) callbackUrl.searchParams.set('state', state);
48+
return resolveCallbackUrl(callbackUrl);
49+
}))
50+
51+
/**
52+
* Called when the user denies the OAuth authorization request. Returns the
53+
* callback URL with an access_denied error for the client to navigate to.
54+
*/
55+
export const denyAuthorization = async ({
56+
redirectUri,
57+
state,
58+
}: {
59+
redirectUri: string;
60+
state: string | undefined;
61+
}) => sew(() =>
62+
withAuthV2(async () => {
63+
const callbackUrl = new URL(redirectUri);
64+
callbackUrl.searchParams.set('error', 'access_denied');
65+
callbackUrl.searchParams.set('error_description', 'The user denied the authorization request.');
66+
if (state) callbackUrl.searchParams.set('state', state);
67+
return resolveCallbackUrl(callbackUrl);
68+
}))

0 commit comments

Comments
 (0)