consent-client.tsx173 lines · main
1'use client';
2
3import { useCallback, useEffect, useState } from 'react';
4
5type Challenge = {
6 client: { clientId: string; name: string; logoUrl: string | null };
7 scopes: string[];
8 scope: string;
9};
10
11const SCOPE_HELP: Record<string, string> = {
12 openid: 'confirm who you are',
13 profile: 'see your display name',
14 email: 'see your email address',
15 offline_access: 'stay signed in when you are offline (refresh)',
16};
17
18/**
19 * Allow / deny screen for a third-party app using Briven as IdP.
20 */
21export function ConsentClient({
22 projectId,
23 challenge,
24}: {
25 projectId: string;
26 challenge: string;
27}) {
28 const [data, setData] = useState<Challenge | null>(null);
29 const [err, setErr] = useState<string | null>(null);
30 const [pending, setPending] = useState(false);
31
32 const load = useCallback(async () => {
33 if (!challenge) {
34 setErr('missing challenge — start sign-in from the app again');
35 return;
36 }
37 setErr(null);
38 const res = await fetch(
39 `/api/v1/auth-core/oidc/challenge/${encodeURIComponent(challenge)}`,
40 { credentials: 'include', cache: 'no-store' },
41 );
42 if (!res.ok) {
43 const body = (await res.json().catch(() => ({}))) as {
44 error_description?: string;
45 };
46 setErr(body.error_description ?? `could not load app (${res.status})`);
47 return;
48 }
49 setData((await res.json()) as Challenge);
50 }, [challenge]);
51
52 useEffect(() => {
53 void load();
54 }, [load]);
55
56 async function decide(decision: 'allow' | 'deny'): Promise<void> {
57 if (!challenge) return;
58 setPending(true);
59 setErr(null);
60 try {
61 const res = await fetch('/api/v1/auth-core/oidc/consent', {
62 method: 'POST',
63 credentials: 'include',
64 headers: { 'content-type': 'application/json' },
65 body: JSON.stringify({ challenge, decision }),
66 });
67 const body = (await res.json().catch(() => ({}))) as {
68 redirectUrl?: string;
69 error_description?: string;
70 error?: string;
71 };
72 if (!res.ok) {
73 if (res.status === 401) {
74 // Send to login, then back here
75 const back = `/auth/${projectId}/oauth/consent?challenge=${encodeURIComponent(challenge)}`;
76 window.location.href = `/auth/${projectId}/otp?callbackURL=${encodeURIComponent(back)}`;
77 return;
78 }
79 throw new Error(body.error_description ?? body.error ?? `http ${res.status}`);
80 }
81 if (!body.redirectUrl) throw new Error('no redirect from server');
82 window.location.href = body.redirectUrl;
83 } catch (e) {
84 setErr(e instanceof Error ? e.message : 'consent failed');
85 setPending(false);
86 }
87 }
88
89 if (err && !data) {
90 return (
91 <div className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6 font-mono text-sm">
92 <p className="text-[var(--color-text)]">could not open consent</p>
93 <p className="mt-2 text-xs text-[var(--color-text-muted)]">{err}</p>
94 </div>
95 );
96 }
97
98 if (!data) {
99 return (
100 <p className="font-mono text-sm text-[var(--color-text-muted)]">loading…</p>
101 );
102 }
103
104 return (
105 <div className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6">
106 <div className="flex items-center gap-3">
107 {data.client.logoUrl ? (
108 // eslint-disable-next-line @next/next/no-img-element
109 <img
110 src={data.client.logoUrl}
111 alt=""
112 className="size-10 rounded object-contain"
113 />
114 ) : (
115 <div
116 className="flex size-10 items-center justify-center rounded font-mono text-sm text-black"
117 style={{ background: '#FFFD74' }}
118 >
119 {data.client.name.slice(0, 1).toUpperCase()}
120 </div>
121 )}
122 <div>
123 <h1 className="font-mono text-lg text-[var(--color-text)]">
124 allow {data.client.name}?
125 </h1>
126 <p className="mt-0.5 font-mono text-[11px] text-[var(--color-text-muted)]">
127 this app wants to use your Briven sign-in
128 </p>
129 </div>
130 </div>
131
132 <ul className="mt-6 space-y-2">
133 {data.scopes.map((s) => (
134 <li
135 key={s}
136 className="rounded border border-[var(--color-border-subtle)] px-3 py-2 font-mono text-xs text-[var(--color-text)]"
137 >
138 <span className="font-medium">{s}</span>
139 {SCOPE_HELP[s] ? (
140 <span className="ml-2 text-[var(--color-text-muted)]">
141 — {SCOPE_HELP[s]}
142 </span>
143 ) : null}
144 </li>
145 ))}
146 </ul>
147
148 {err ? (
149 <p className="mt-4 font-mono text-xs text-red-400">{err}</p>
150 ) : null}
151
152 <div className="mt-6 flex flex-wrap gap-3">
153 <button
154 type="button"
155 disabled={pending}
156 onClick={() => void decide('allow')}
157 className="rounded-md px-4 py-2 font-mono text-xs font-medium text-black disabled:opacity-50"
158 style={{ background: '#FFFD74' }}
159 >
160 {pending ? 'working…' : 'allow'}
161 </button>
162 <button
163 type="button"
164 disabled={pending}
165 onClick={() => void decide('deny')}
166 className="rounded-md border border-[var(--color-border-subtle)] px-4 py-2 font-mono text-xs text-[var(--color-text-muted)] disabled:opacity-50"
167 >
168 deny
169 </button>
170 </div>
171 </div>
172 );
173}