hosted-flow.tsx630 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 'konnos',
22 'google',
23 'github',
24 'discord',
25 'microsoft',
26 'apple',
27 'twitter',
28 'linkedin',
29 'gitlab',
30 'bitbucket',
31 'dropbox',
32 'facebook',
33 'spotify',
34];
35
36/** Official Konnos mark — same asset as platform /signin (apps/web/public/konnos.svg). */
37function OAuthProviderMark({ provider }: { provider: string }) {
38 if (provider === 'konnos') {
39 return (
40 // eslint-disable-next-line @next/next/no-img-element -- tiny static mark, not LCP
41 <img
42 src="/konnos.svg"
43 alt=""
44 width={16}
45 height={16}
46 className="h-4 w-4 shrink-0 object-contain"
47 aria-hidden
48 />
49 );
50 }
51 return null;
52}
53
54const TITLES: Record<FormFlow, string> = {
55 'sign-in': 'sign in',
56 'sign-up': 'create account',
57 'magic-link': 'sign in with magic link',
58 otp: 'sign in with one-time code',
59 'new-password': 'choose a new password',
60 'two-factor': 'two-factor check',
61};
62
63const SUB_TITLES: Record<FormFlow, string> = {
64 'sign-in': 'welcome back',
65 'sign-up': 'no account yet',
66 'magic-link': "we'll email you a one-shot sign-in link",
67 otp: "we'll email you a 6-digit code",
68 'new-password': 'enter your new password below',
69 'two-factor': 'authenticator code or backup recovery code',
70};
71
72interface ErrorBody {
73 code?: string;
74 message?: string;
75 error?: { code?: string; message?: string };
76}
77
78/**
79 * Hosted-pages flow forms — briven-engine FDI via first-party `/api/auth/*`.
80 * Sets sAccessToken on briven.tech so OIDC consent (IdP) can see the session.
81 * Legacy /api/v1/auth-tenant is retired (410).
82 */
83export function HostedFlow({ projectId, flow, callbackURL, token, turnstileSiteKey }: Props) {
84 const router = useRouter();
85 const [email, setEmail] = useState('');
86 const [password, setPassword] = useState('');
87 const [name, setName] = useState('');
88 const [otp, setOtp] = useState('');
89 const [newPassword, setNewPassword] = useState('');
90 const [pending, setPending] = useState(false);
91 const [error, setError] = useState<string | null>(null);
92 const [magicSent, setMagicSent] = useState(false);
93 const [otpRequested, setOtpRequested] = useState(false);
94 const [preAuthSessionId, setPreAuthSessionId] = useState('');
95 const [deviceId, setDeviceId] = useState('');
96 const [mfaChallenge, setMfaChallenge] = useState('');
97 const [mfaUserId, setMfaUserId] = useState('');
98 const [resetDone, setResetDone] = useState(false);
99 const [twoFactorMode, setTwoFactorMode] = useState<'totp' | 'backup'>('totp');
100 const [twoFactorCode, setTwoFactorCode] = useState('');
101 const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
102 const turnstileRef = useRef<HTMLDivElement>(null);
103
104 const cbQ = `callbackURL=${encodeURIComponent(callbackURL)}`;
105
106 // Load Cloudflare Turnstile script when a site key is provided.
107 useEffect(() => {
108 if (!turnstileSiteKey || !turnstileRef.current) return;
109 if (document.querySelector('script[data-turnstile-loaded]')) {
110 renderTurnstile();
111 return;
112 }
113 const script = document.createElement('script');
114 script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
115 script.async = true;
116 script.defer = true;
117 script.setAttribute('data-turnstile-loaded', 'true');
118 script.onload = () => renderTurnstile();
119 document.body.appendChild(script);
120 return () => {
121 // Cleanup handled by Turnstile internally on re-render.
122 };
123 }, [turnstileSiteKey]);
124
125 function renderTurnstile() {
126 const win = window as unknown as {
127 turnstile?: {
128 render: (
129 el: HTMLElement,
130 opts: {
131 sitekey: string;
132 callback: (token: string) => void;
133 'error-callback'?: () => void;
134 },
135 ) => string;
136 };
137 };
138 if (!win.turnstile || !turnstileRef.current) return;
139 win.turnstile.render(turnstileRef.current, {
140 sitekey: turnstileSiteKey!,
141 callback: (t) => setTurnstileToken(t),
142 'error-callback': () => setTurnstileToken(null),
143 });
144 }
145
146 async function fdi(
147 path: string,
148 body: Record<string, unknown>,
149 rid?: string,
150 ): Promise<Record<string, unknown>> {
151 setPending(true);
152 setError(null);
153 try {
154 const payload = turnstileToken ? { ...body, turnstileToken } : body;
155 const headers: Record<string, string> = {
156 'content-type': 'application/json',
157 'x-briven-project-id': projectId,
158 'st-auth-mode': 'cookie',
159 };
160 if (rid) headers.rid = rid;
161 const res = await fetch(`/api/auth${path}`, {
162 method: 'POST',
163 credentials: 'include',
164 headers,
165 body: JSON.stringify(payload),
166 });
167 const data = (await res.json().catch(() => ({}))) as Record<string, unknown> &
168 ErrorBody;
169 if (!res.ok) {
170 throw new Error(
171 String(
172 data.message ??
173 data.error?.message ??
174 data.code ??
175 data.status ??
176 `http ${res.status}`,
177 ),
178 );
179 }
180 if (typeof data.status === 'string' && data.status !== 'OK' && data.status !== 'MFA_REQUIRED') {
181 throw new Error(String(data.message ?? data.status));
182 }
183 return data;
184 } finally {
185 setPending(false);
186 }
187 }
188
189 async function handleSignIn(e: React.FormEvent): Promise<void> {
190 e.preventDefault();
191 try {
192 const body = await fdi(
193 '/signin',
194 {
195 formFields: [
196 { id: 'email', value: email },
197 { id: 'password', value: password },
198 ],
199 },
200 'emailpassword',
201 );
202 if (body.status === 'MFA_REQUIRED') {
203 const ch = String(body.mfaChallenge ?? '');
204 const uid = String(body.userId ?? '');
205 try {
206 sessionStorage.setItem(
207 `briven_mfa_${projectId}`,
208 JSON.stringify({ mfaChallenge: ch, userId: uid }),
209 );
210 } catch {
211 /* private mode */
212 }
213 setMfaChallenge(ch);
214 setMfaUserId(uid);
215 router.push(`/auth/${projectId}/two-factor?${cbQ}`);
216 return;
217 }
218 router.push(callbackURL);
219 } catch (err) {
220 setError(err instanceof Error ? err.message : 'sign-in failed');
221 }
222 }
223
224 async function handleTwoFactor(e: React.FormEvent): Promise<void> {
225 e.preventDefault();
226 try {
227 let ch = mfaChallenge;
228 let uid = mfaUserId;
229 if (!ch || !uid) {
230 try {
231 const raw = sessionStorage.getItem(`briven_mfa_${projectId}`);
232 if (raw) {
233 const parsed = JSON.parse(raw) as {
234 mfaChallenge?: string;
235 userId?: string;
236 };
237 ch = parsed.mfaChallenge ?? '';
238 uid = parsed.userId ?? '';
239 }
240 } catch {
241 /* ignore */
242 }
243 }
244 if (!ch || !uid) {
245 throw new Error('sign in with password again — two-factor session expired');
246 }
247 if (twoFactorMode === 'totp') {
248 await fdi(
249 '/totp/verify',
250 {
251 userId: uid,
252 code: twoFactorCode,
253 mfaChallenge: ch,
254 },
255 'totp',
256 );
257 try {
258 sessionStorage.removeItem(`briven_mfa_${projectId}`);
259 } catch {
260 /* ignore */
261 }
262 } else {
263 throw new Error(
264 'backup recovery codes are not finished on hosted login yet — use authenticator',
265 );
266 }
267 router.push(callbackURL);
268 } catch (err) {
269 setError(
270 err instanceof Error
271 ? err.message
272 : twoFactorMode === 'totp'
273 ? 'authenticator code failed'
274 : 'backup code failed',
275 );
276 }
277 }
278
279 async function handleSignUp(e: React.FormEvent): Promise<void> {
280 e.preventDefault();
281 try {
282 await fdi(
283 '/signup',
284 {
285 formFields: [
286 { id: 'email', value: email },
287 { id: 'password', value: password },
288 ...(name ? [{ id: 'name', value: name }] : []),
289 ],
290 },
291 'emailpassword',
292 );
293 router.push(callbackURL);
294 } catch (err) {
295 setError(err instanceof Error ? err.message : 'sign-up failed');
296 }
297 }
298
299 async function handleMagic(e: React.FormEvent): Promise<void> {
300 e.preventDefault();
301 try {
302 await fdi(
303 '/signinup/code',
304 {
305 email,
306 flowType: 'MAGIC_LINK',
307 magicLinkBaseUrl: callbackURL.startsWith('http')
308 ? callbackURL
309 : `${window.location.origin}${callbackURL.startsWith('/') ? '' : '/'}${callbackURL}`,
310 },
311 'passwordless',
312 );
313 setMagicSent(true);
314 } catch (err) {
315 setError(err instanceof Error ? err.message : 'magic-link request failed');
316 }
317 }
318
319 async function handleOtpRequest(e: React.FormEvent): Promise<void> {
320 e.preventDefault();
321 try {
322 const body = await fdi(
323 '/signinup/code',
324 { email, flowType: 'USER_INPUT_CODE' },
325 'passwordless',
326 );
327 setPreAuthSessionId(String(body.preAuthSessionId ?? ''));
328 setDeviceId(String(body.deviceId ?? ''));
329 setOtpRequested(true);
330 } catch (err) {
331 setError(err instanceof Error ? err.message : 'otp request failed');
332 }
333 }
334
335 async function handleOtpVerify(e: React.FormEvent): Promise<void> {
336 e.preventDefault();
337 try {
338 await fdi(
339 '/signinup/code/consume',
340 {
341 preAuthSessionId,
342 deviceId,
343 userInputCode: otp,
344 },
345 'passwordless',
346 );
347 router.push(callbackURL);
348 } catch (err) {
349 setError(err instanceof Error ? err.message : 'otp verify failed');
350 }
351 }
352
353 async function handleNewPassword(e: React.FormEvent): Promise<void> {
354 e.preventDefault();
355 if (!token) {
356 setError('missing reset token');
357 return;
358 }
359 try {
360 // Password-reset FDI may not be wired on all projects yet.
361 await fdi('/user/password/reset', { token, newPassword }, 'emailpassword');
362 setResetDone(true);
363 } catch (err) {
364 setError(err instanceof Error ? err.message : 'password reset failed');
365 }
366 }
367
368 function oauthHref(provider: string): string {
369 // Server returns the provider authorize URL; open via client navigation.
370 // redirectURI must match what the project registered with the social provider.
371 const origin =
372 typeof window !== 'undefined' ? window.location.origin : 'https://briven.tech';
373 const redirectURIOnProviderDashboard = `${origin}/auth/${projectId}/sign-in?${cbQ}`;
374 const params = new URLSearchParams({
375 thirdPartyId: provider,
376 redirectURI: redirectURIOnProviderDashboard,
377 briven_project_id: projectId,
378 });
379 return `/api/auth/authorisationurl?${params.toString()}`;
380 }
381
382 return (
383 <article className="flex flex-col gap-5 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-6">
384 <header>
385 <h1 className="font-mono text-base text-[var(--color-text)]">{TITLES[flow]}</h1>
386 <p className="mt-1 font-mono text-xs text-[var(--color-text-muted)]">
387 {SUB_TITLES[flow]}
388 </p>
389 </header>
390
391 {flow === 'sign-in' ? (
392 <form className="flex flex-col gap-3" onSubmit={handleSignIn}>
393 <Field label="email" type="email" value={email} onChange={setEmail} autoComplete="email" />
394 <Field
395 label="password"
396 type="password"
397 value={password}
398 onChange={setPassword}
399 autoComplete="current-password"
400 />
401 {turnstileSiteKey ? <div ref={turnstileRef} className="min-h-[65px]" /> : null}
402 <Submit pending={pending} idle="sign in" busy="signing in…" />
403 </form>
404 ) : null}
405
406 {flow === 'sign-up' ? (
407 <form className="flex flex-col gap-3" onSubmit={handleSignUp}>
408 <Field label="name" type="text" value={name} onChange={setName} autoComplete="name" />
409 <Field label="email" type="email" value={email} onChange={setEmail} autoComplete="email" />
410 <Field
411 label="password"
412 type="password"
413 value={password}
414 onChange={setPassword}
415 autoComplete="new-password"
416 />
417 {turnstileSiteKey ? <div ref={turnstileRef} className="min-h-[65px]" /> : null}
418 <Submit pending={pending} idle="create account" busy="creating…" />
419 </form>
420 ) : null}
421
422 {flow === 'magic-link' ? (
423 magicSent ? (
424 <p className="font-mono text-xs text-[var(--color-text-muted)]">
425 check your inbox for the sign-in link. close this tab when done.
426 </p>
427 ) : (
428 <form className="flex flex-col gap-3" onSubmit={handleMagic}>
429 <Field label="email" type="email" value={email} onChange={setEmail} autoComplete="email" />
430 {turnstileSiteKey ? <div ref={turnstileRef} className="min-h-[65px]" /> : null}
431 <Submit pending={pending} idle="send magic link" busy="sending…" />
432 </form>
433 )
434 ) : null}
435
436 {flow === 'otp' ? (
437 otpRequested ? (
438 <form className="flex flex-col gap-3" onSubmit={handleOtpVerify}>
439 <Field
440 label="6-digit code"
441 type="text"
442 value={otp}
443 onChange={setOtp}
444 autoComplete="one-time-code"
445 inputMode="numeric"
446 pattern="\d{6}"
447 maxLength={6}
448 />
449 <Submit pending={pending} idle="verify" busy="verifying…" />
450 </form>
451 ) : (
452 <form className="flex flex-col gap-3" onSubmit={handleOtpRequest}>
453 <Field label="email" type="email" value={email} onChange={setEmail} autoComplete="email" />
454 {turnstileSiteKey ? <div ref={turnstileRef} className="min-h-[65px]" /> : null}
455 <Submit pending={pending} idle="send code" busy="sending…" />
456 </form>
457 )
458 ) : null}
459
460 {flow === 'new-password' ? (
461 resetDone ? (
462 <div className="flex flex-col gap-3">
463 <p className="font-mono text-xs text-[var(--color-text-muted)]">
464 password updated. you can now sign in with your new password.
465 </p>
466 <Link
467 href={`/auth/${projectId}/sign-in`}
468 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)]"
469 >
470 sign in
471 </Link>
472 </div>
473 ) : (
474 <form className="flex flex-col gap-3" onSubmit={handleNewPassword}>
475 <Field
476 label="new password"
477 type="password"
478 value={newPassword}
479 onChange={setNewPassword}
480 autoComplete="new-password"
481 />
482 <Submit pending={pending} idle="reset password" busy="resetting…" />
483 </form>
484 )
485 ) : null}
486
487 {flow === 'two-factor' ? (
488 <form className="flex flex-col gap-3" onSubmit={handleTwoFactor}>
489 <Field
490 label={twoFactorMode === 'totp' ? '6-digit code' : 'backup recovery code'}
491 type="text"
492 value={twoFactorCode}
493 onChange={setTwoFactorCode}
494 autoComplete={twoFactorMode === 'totp' ? 'one-time-code' : undefined}
495 inputMode={twoFactorMode === 'totp' ? 'numeric' : 'text'}
496 pattern={twoFactorMode === 'totp' ? '\\d{6}' : undefined}
497 maxLength={twoFactorMode === 'totp' ? 6 : undefined}
498 />
499 <Submit
500 pending={pending}
501 idle={twoFactorMode === 'totp' ? 'verify' : 'use backup code'}
502 busy="checking…"
503 />
504 <button
505 type="button"
506 className="font-mono text-[11px] text-[var(--color-text-muted)] hover:text-[var(--color-primary)]"
507 onClick={() => {
508 setTwoFactorMode(twoFactorMode === 'totp' ? 'backup' : 'totp');
509 setTwoFactorCode('');
510 setError(null);
511 }}
512 >
513 {twoFactorMode === 'totp'
514 ? 'lost your phone? use a backup code'
515 : 'use authenticator code instead'}
516 </button>
517 </form>
518 ) : null}
519
520 {error ? (
521 <p className="font-mono text-xs text-[var(--color-error)]" role="alert">
522 {error}
523 </p>
524 ) : null}
525
526 {flow !== 'new-password' && flow !== 'two-factor' ? (
527 <div className="flex flex-col gap-2 border-t border-[var(--color-border-subtle)] pt-4">
528 <p className="text-center font-mono text-[11px] text-[var(--color-text-subtle)]">
529 or continue with
530 </p>
531 <div className="grid grid-cols-2 gap-2">
532 {OAUTH_PROVIDERS.map((p) => (
533 <a
534 key={p}
535 href={oauthHref(p)}
536 className="inline-flex items-center justify-center gap-2 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)]"
537 >
538 <OAuthProviderMark provider={p} />
539 {p === 'konnos' ? 'continue with konnos' : p}
540 </a>
541 ))}
542 </div>
543 </div>
544 ) : null}
545
546 <nav className="flex flex-wrap justify-center gap-3 font-mono text-[11px]">
547 {flow !== 'sign-in' ? (
548 <Link
549 href={`/auth/${projectId}/sign-in?${cbQ}`}
550 className="text-[var(--color-text-muted)] hover:text-[var(--color-primary)]"
551 >
552 password sign-in
553 </Link>
554 ) : null}
555 {flow !== 'sign-up' && flow !== 'two-factor' ? (
556 <Link
557 href={`/auth/${projectId}/sign-up?${cbQ}`}
558 className="text-[var(--color-text-muted)] hover:text-[var(--color-primary)]"
559 >
560 create account
561 </Link>
562 ) : null}
563 {flow !== 'magic-link' && flow !== 'two-factor' ? (
564 <Link
565 href={`/auth/${projectId}/magic-link?${cbQ}`}
566 className="text-[var(--color-text-muted)] hover:text-[var(--color-primary)]"
567 >
568 magic link
569 </Link>
570 ) : null}
571 {flow !== 'otp' && flow !== 'two-factor' ? (
572 <Link
573 href={`/auth/${projectId}/otp?${cbQ}`}
574 className="text-[var(--color-text-muted)] hover:text-[var(--color-primary)]"
575 >
576 email code
577 </Link>
578 ) : null}
579 </nav>
580 </article>
581 );
582}
583
584interface FieldProps {
585 label: string;
586 type: string;
587 value: string;
588 onChange: (value: string) => void;
589 autoComplete?: string;
590 inputMode?: 'numeric' | 'text';
591 pattern?: string;
592 maxLength?: number;
593}
594
595function Field(props: FieldProps) {
596 return (
597 <label className="flex flex-col gap-1 font-mono text-xs text-[var(--color-text-muted)]">
598 <span>{props.label}</span>
599 <input
600 type={props.type}
601 value={props.value}
602 onChange={(e) => props.onChange(e.target.value)}
603 required
604 autoComplete={props.autoComplete}
605 inputMode={props.inputMode}
606 pattern={props.pattern}
607 maxLength={props.maxLength}
608 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)]"
609 />
610 </label>
611 );
612}
613
614interface SubmitProps {
615 pending: boolean;
616 idle: string;
617 busy: string;
618}
619
620function Submit({ pending, idle, busy }: SubmitProps) {
621 return (
622 <button
623 type="submit"
624 disabled={pending}
625 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"
626 >
627 {pending ? busy : idle}
628 </button>
629 );
630}