hosted-flow.tsx500 lines · main
1'use client';
2
3import Link from 'next/link';
4import { useRouter } from 'next/navigation';
5import { useEffect, useRef, useState } from 'react';
6
7type FormFlow = 'sign-in' | 'sign-up' | 'magic-link' | 'otp' | 'new-password' | 'two-factor';
8
9interface Props {
10 projectId: string;
11 flow: FormFlow;
12 /** Where to send the user after successful authentication. */
13 callbackURL: string;
14 /** Password-reset token (only for new-password flow). */
15 token?: string;
16 /** Cloudflare Turnstile site key, or null when disabled. */
17 turnstileSiteKey: string | null;
18}
19
20const OAUTH_PROVIDERS: ReadonlyArray<string> = [
21 'google',
22 'github',
23 'discord',
24 'microsoft',
25 'apple',
26 'twitter',
27 'linkedin',
28 'gitlab',
29 'bitbucket',
30 'dropbox',
31 'facebook',
32 'spotify',
33];
34
35const TITLES: Record<FormFlow, string> = {
36 'sign-in': 'sign in',
37 'sign-up': 'create account',
38 'magic-link': 'sign in with magic link',
39 otp: 'sign in with one-time code',
40 'new-password': 'choose a new password',
41 'two-factor': 'two-factor check',
42};
43
44const SUB_TITLES: Record<FormFlow, string> = {
45 'sign-in': 'welcome back',
46 'sign-up': 'no account yet',
47 'magic-link': "we'll email you a one-shot sign-in link",
48 otp: "we'll email you a 6-digit code",
49 'new-password': 'enter your new password below',
50 'two-factor': 'authenticator code or backup recovery code',
51};
52
53interface ErrorBody {
54 code?: string;
55 message?: string;
56 error?: { code?: string; message?: string };
57}
58
59/**
60 * Hosted-pages flow forms. Same origin as the dashboard's api proxy
61 * (`/api/v1/...`), so cookies set on `briven.tech` are forwarded
62 * automatically. Subdomain split (`<tenant>.auth.briven.tech`) lands
63 * with the CNAME orchestration runbook (BUILD_PLAN.md "Decisions
64 * locked" Q7); the form contract here doesn't change.
65 */
66export function HostedFlow({ projectId, flow, callbackURL, token, turnstileSiteKey }: Props) {
67 const router = useRouter();
68 const [email, setEmail] = useState('');
69 const [password, setPassword] = useState('');
70 const [name, setName] = useState('');
71 const [otp, setOtp] = useState('');
72 const [newPassword, setNewPassword] = useState('');
73 const [pending, setPending] = useState(false);
74 const [error, setError] = useState<string | null>(null);
75 const [magicSent, setMagicSent] = useState(false);
76 const [otpRequested, setOtpRequested] = useState(false);
77 const [resetDone, setResetDone] = useState(false);
78 const [twoFactorMode, setTwoFactorMode] = useState<'totp' | 'backup'>('totp');
79 const [twoFactorCode, setTwoFactorCode] = useState('');
80 const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
81 const turnstileRef = useRef<HTMLDivElement>(null);
82
83 // Load Cloudflare Turnstile script when a site key is provided.
84 useEffect(() => {
85 if (!turnstileSiteKey || !turnstileRef.current) return;
86 if (document.querySelector('script[data-turnstile-loaded]')) {
87 renderTurnstile();
88 return;
89 }
90 const script = document.createElement('script');
91 script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
92 script.async = true;
93 script.defer = true;
94 script.setAttribute('data-turnstile-loaded', 'true');
95 script.onload = () => renderTurnstile();
96 document.body.appendChild(script);
97 return () => {
98 // Cleanup handled by Turnstile internally on re-render.
99 };
100 }, [turnstileSiteKey]);
101
102 function renderTurnstile() {
103 const win = window as unknown as {
104 turnstile?: {
105 render: (
106 el: HTMLElement,
107 opts: {
108 sitekey: string;
109 callback: (token: string) => void;
110 'error-callback'?: () => void;
111 },
112 ) => string;
113 };
114 };
115 if (!win.turnstile || !turnstileRef.current) return;
116 win.turnstile.render(turnstileRef.current, {
117 sitekey: turnstileSiteKey!,
118 callback: (t) => setTurnstileToken(t),
119 'error-callback': () => setTurnstileToken(null),
120 });
121 }
122
123 async function post(path: string, body: Record<string, unknown>): Promise<unknown> {
124 setPending(true);
125 setError(null);
126 try {
127 const payload = turnstileToken ? { ...body, turnstileToken } : body;
128 const res = await fetch(`/api/v1/auth-tenant${path}`, {
129 method: 'POST',
130 credentials: 'include',
131 headers: {
132 'content-type': 'application/json',
133 'x-briven-project-id': projectId,
134 },
135 body: JSON.stringify(payload),
136 });
137 if (!res.ok) {
138 const err = (await res.json().catch(() => ({}))) as ErrorBody;
139 throw new Error(err.error?.message ?? err.message ?? err.code ?? `http ${res.status}`);
140 }
141 return await res.json();
142 } finally {
143 setPending(false);
144 }
145 }
146
147 async function handleSignIn(e: React.FormEvent): Promise<void> {
148 e.preventDefault();
149 try {
150 const body = (await post('/sign-in/email', { email, password })) as {
151 twoFactorRedirect?: boolean;
152 user?: { id?: string };
153 };
154 // Password ok but account has 2FA — continue on the challenge page.
155 if (body?.twoFactorRedirect === true) {
156 router.push(
157 `/auth/${projectId}/two-factor?callbackURL=${encodeURIComponent(callbackURL)}`,
158 );
159 return;
160 }
161 router.push(callbackURL);
162 } catch (err) {
163 setError(err instanceof Error ? err.message : 'sign-in failed');
164 }
165 }
166
167 async function handleTwoFactor(e: React.FormEvent): Promise<void> {
168 e.preventDefault();
169 try {
170 if (twoFactorMode === 'totp') {
171 await post('/two-factor/verify-totp', { code: twoFactorCode });
172 } else {
173 await post('/two-factor/verify-backup-code', { code: twoFactorCode });
174 }
175 router.push(callbackURL);
176 } catch (err) {
177 setError(
178 err instanceof Error
179 ? err.message
180 : twoFactorMode === 'totp'
181 ? 'authenticator code failed'
182 : 'backup code failed',
183 );
184 }
185 }
186
187 async function handleSignUp(e: React.FormEvent): Promise<void> {
188 e.preventDefault();
189 try {
190 await post('/sign-up/email', { email, password, name: name || undefined });
191 router.push(callbackURL);
192 } catch (err) {
193 setError(err instanceof Error ? err.message : 'sign-up failed');
194 }
195 }
196
197 async function handleMagic(e: React.FormEvent): Promise<void> {
198 e.preventDefault();
199 try {
200 // Better Auth expects callbackURL (post-click land), not a bare email-only body.
201 await post('/sign-in/magic-link', { email, callbackURL });
202 setMagicSent(true);
203 } catch (err) {
204 setError(err instanceof Error ? err.message : 'magic-link request failed');
205 }
206 }
207
208 async function handleOtpRequest(e: React.FormEvent): Promise<void> {
209 e.preventDefault();
210 try {
211 // Better Auth: POST /email-otp/send-verification-otp (NOT under /sign-in/…).
212 await post('/email-otp/send-verification-otp', { email, type: 'sign-in' });
213 setOtpRequested(true);
214 } catch (err) {
215 setError(err instanceof Error ? err.message : 'otp request failed');
216 }
217 }
218
219 async function handleOtpVerify(e: React.FormEvent): Promise<void> {
220 e.preventDefault();
221 try {
222 // Better Auth: POST /sign-in/email-otp with { email, otp }.
223 await post('/sign-in/email-otp', { email, otp });
224 router.push(callbackURL);
225 } catch (err) {
226 setError(err instanceof Error ? err.message : 'otp verify failed');
227 }
228 }
229
230 async function handleNewPassword(e: React.FormEvent): Promise<void> {
231 e.preventDefault();
232 if (!token) {
233 setError('missing reset token');
234 return;
235 }
236 try {
237 await post('/reset-password', { token, newPassword });
238 setResetDone(true);
239 } catch (err) {
240 setError(err instanceof Error ? err.message : 'password reset failed');
241 }
242 }
243
244 function oauthHref(provider: string): string {
245 const params = new URLSearchParams({
246 provider,
247 callbackURL,
248 projectId,
249 });
250 return `/api/v1/auth-tenant/sign-in/social?${params.toString()}`;
251 }
252
253 return (
254 <article className="flex flex-col gap-5 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-6">
255 <header>
256 <h1 className="font-mono text-base text-[var(--color-text)]">{TITLES[flow]}</h1>
257 <p className="mt-1 font-mono text-xs text-[var(--color-text-muted)]">
258 {SUB_TITLES[flow]}
259 </p>
260 </header>
261
262 {flow === 'sign-in' ? (
263 <form className="flex flex-col gap-3" onSubmit={handleSignIn}>
264 <Field label="email" type="email" value={email} onChange={setEmail} autoComplete="email" />
265 <Field
266 label="password"
267 type="password"
268 value={password}
269 onChange={setPassword}
270 autoComplete="current-password"
271 />
272 {turnstileSiteKey ? <div ref={turnstileRef} className="min-h-[65px]" /> : null}
273 <Submit pending={pending} idle="sign in" busy="signing in…" />
274 </form>
275 ) : null}
276
277 {flow === 'sign-up' ? (
278 <form className="flex flex-col gap-3" onSubmit={handleSignUp}>
279 <Field label="name" type="text" value={name} onChange={setName} autoComplete="name" />
280 <Field label="email" type="email" value={email} onChange={setEmail} autoComplete="email" />
281 <Field
282 label="password"
283 type="password"
284 value={password}
285 onChange={setPassword}
286 autoComplete="new-password"
287 />
288 {turnstileSiteKey ? <div ref={turnstileRef} className="min-h-[65px]" /> : null}
289 <Submit pending={pending} idle="create account" busy="creating…" />
290 </form>
291 ) : null}
292
293 {flow === 'magic-link' ? (
294 magicSent ? (
295 <p className="font-mono text-xs text-[var(--color-text-muted)]">
296 check your inbox for the sign-in link. close this tab when done.
297 </p>
298 ) : (
299 <form className="flex flex-col gap-3" onSubmit={handleMagic}>
300 <Field label="email" type="email" value={email} onChange={setEmail} autoComplete="email" />
301 {turnstileSiteKey ? <div ref={turnstileRef} className="min-h-[65px]" /> : null}
302 <Submit pending={pending} idle="send magic link" busy="sending…" />
303 </form>
304 )
305 ) : null}
306
307 {flow === 'otp' ? (
308 otpRequested ? (
309 <form className="flex flex-col gap-3" onSubmit={handleOtpVerify}>
310 <Field
311 label="6-digit code"
312 type="text"
313 value={otp}
314 onChange={setOtp}
315 autoComplete="one-time-code"
316 inputMode="numeric"
317 pattern="\d{6}"
318 maxLength={6}
319 />
320 <Submit pending={pending} idle="verify" busy="verifying…" />
321 </form>
322 ) : (
323 <form className="flex flex-col gap-3" onSubmit={handleOtpRequest}>
324 <Field label="email" type="email" value={email} onChange={setEmail} autoComplete="email" />
325 {turnstileSiteKey ? <div ref={turnstileRef} className="min-h-[65px]" /> : null}
326 <Submit pending={pending} idle="send code" busy="sending…" />
327 </form>
328 )
329 ) : null}
330
331 {flow === 'new-password' ? (
332 resetDone ? (
333 <div className="flex flex-col gap-3">
334 <p className="font-mono text-xs text-[var(--color-text-muted)]">
335 password updated. you can now sign in with your new password.
336 </p>
337 <Link
338 href={`/auth/${projectId}/sign-in`}
339 className="rounded-md bg-[var(--color-primary)] px-4 py-2 text-center font-mono text-xs font-medium text-[var(--color-text-inverse)] transition hover:bg-[var(--color-primary-hover)]"
340 >
341 sign in
342 </Link>
343 </div>
344 ) : (
345 <form className="flex flex-col gap-3" onSubmit={handleNewPassword}>
346 <Field
347 label="new password"
348 type="password"
349 value={newPassword}
350 onChange={setNewPassword}
351 autoComplete="new-password"
352 />
353 <Submit pending={pending} idle="reset password" busy="resetting…" />
354 </form>
355 )
356 ) : null}
357
358 {flow === 'two-factor' ? (
359 <form className="flex flex-col gap-3" onSubmit={handleTwoFactor}>
360 <Field
361 label={twoFactorMode === 'totp' ? '6-digit code' : 'backup recovery code'}
362 type="text"
363 value={twoFactorCode}
364 onChange={setTwoFactorCode}
365 autoComplete={twoFactorMode === 'totp' ? 'one-time-code' : undefined}
366 inputMode={twoFactorMode === 'totp' ? 'numeric' : 'text'}
367 pattern={twoFactorMode === 'totp' ? '\\d{6}' : undefined}
368 maxLength={twoFactorMode === 'totp' ? 6 : undefined}
369 />
370 <Submit
371 pending={pending}
372 idle={twoFactorMode === 'totp' ? 'verify' : 'use backup code'}
373 busy="checking…"
374 />
375 <button
376 type="button"
377 className="font-mono text-[11px] text-[var(--color-text-muted)] hover:text-[var(--color-primary)]"
378 onClick={() => {
379 setTwoFactorMode(twoFactorMode === 'totp' ? 'backup' : 'totp');
380 setTwoFactorCode('');
381 setError(null);
382 }}
383 >
384 {twoFactorMode === 'totp'
385 ? 'lost your phone? use a backup code'
386 : 'use authenticator code instead'}
387 </button>
388 </form>
389 ) : null}
390
391 {error ? (
392 <p className="font-mono text-xs text-[var(--color-error)]" role="alert">
393 {error}
394 </p>
395 ) : null}
396
397 {flow !== 'new-password' && flow !== 'two-factor' ? (
398 <div className="flex flex-col gap-2 border-t border-[var(--color-border-subtle)] pt-4">
399 <p className="text-center font-mono text-[11px] text-[var(--color-text-subtle)]">
400 or continue with
401 </p>
402 <div className="grid grid-cols-2 gap-2">
403 {OAUTH_PROVIDERS.map((p) => (
404 <a
405 key={p}
406 href={oauthHref(p)}
407 className="rounded-md border border-[var(--color-border)] px-3 py-2 text-center font-mono text-xs text-[var(--color-text-muted)] hover:border-[var(--color-primary)] hover:text-[var(--color-primary)]"
408 >
409 {p}
410 </a>
411 ))}
412 </div>
413 </div>
414 ) : null}
415
416 <nav className="flex flex-wrap justify-center gap-3 font-mono text-[11px]">
417 {flow !== 'sign-in' ? (
418 <Link
419 href={`/auth/${projectId}/sign-in`}
420 className="text-[var(--color-text-muted)] hover:text-[var(--color-primary)]"
421 >
422 password sign-in
423 </Link>
424 ) : null}
425 {flow !== 'sign-up' && flow !== 'two-factor' ? (
426 <Link
427 href={`/auth/${projectId}/sign-up`}
428 className="text-[var(--color-text-muted)] hover:text-[var(--color-primary)]"
429 >
430 create account
431 </Link>
432 ) : null}
433 {flow !== 'magic-link' && flow !== 'two-factor' ? (
434 <Link
435 href={`/auth/${projectId}/magic-link`}
436 className="text-[var(--color-text-muted)] hover:text-[var(--color-primary)]"
437 >
438 magic link
439 </Link>
440 ) : null}
441 {flow !== 'otp' && flow !== 'two-factor' ? (
442 <Link
443 href={`/auth/${projectId}/otp`}
444 className="text-[var(--color-text-muted)] hover:text-[var(--color-primary)]"
445 >
446 email code
447 </Link>
448 ) : null}
449 </nav>
450 </article>
451 );
452}
453
454interface FieldProps {
455 label: string;
456 type: string;
457 value: string;
458 onChange: (value: string) => void;
459 autoComplete?: string;
460 inputMode?: 'numeric' | 'text';
461 pattern?: string;
462 maxLength?: number;
463}
464
465function Field(props: FieldProps) {
466 return (
467 <label className="flex flex-col gap-1 font-mono text-xs text-[var(--color-text-muted)]">
468 <span>{props.label}</span>
469 <input
470 type={props.type}
471 value={props.value}
472 onChange={(e) => props.onChange(e.target.value)}
473 required
474 autoComplete={props.autoComplete}
475 inputMode={props.inputMode}
476 pattern={props.pattern}
477 maxLength={props.maxLength}
478 className="rounded-sm border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] px-2 py-1.5 font-mono text-xs text-[var(--color-text)] outline-none focus:border-[var(--color-primary)]"
479 />
480 </label>
481 );
482}
483
484interface SubmitProps {
485 pending: boolean;
486 idle: string;
487 busy: string;
488}
489
490function Submit({ pending, idle, busy }: SubmitProps) {
491 return (
492 <button
493 type="submit"
494 disabled={pending}
495 className="rounded-md bg-[var(--color-primary)] px-4 py-2 font-mono text-xs font-medium text-[var(--color-text-inverse)] transition hover:bg-[var(--color-primary-hover)] disabled:opacity-50"
496 >
497 {pending ? busy : idle}
498 </button>
499 );
500}