index.ts1745 lines · main
1'use client';
2
3/**
4 * @briven/auth/react — React bindings for `@briven/auth`.
5 *
6 * Provider wraps the customer app; hooks pull state from the provider's
7 * context; the prebuilt component is opt-in. Zero hard dependency on
8 * Next.js — works in any React 19 environment.
9 *
10 * import { BrivenAuthProvider, useSession, useUser, BrivenSignIn } from '@briven/auth/react';
11 *
12 * <BrivenAuthProvider value={auth}>
13 * <App />
14 * </BrivenAuthProvider>
15 *
16 * function App() {
17 * const { session, isLoading } = useSession();
18 * return session ? <Home /> : <BrivenSignIn />;
19 * }
20 *
21 * Bundle target (§5): `<BrivenSignIn />` gzipped < 35 KB. The component
22 * carries no icon library + no css framework; the customer's app styles
23 * apply via the `className` prop set on every interactive element.
24 */
25
26import {
27 createContext,
28 createElement,
29 type FormEvent,
30 type ReactNode,
31 useCallback,
32 useContext,
33 useEffect,
34 useMemo,
35 useState,
36} from 'react';
37
38import {
39 type BrivenAuthClient,
40 type ClientSession,
41 type MembershipRequest,
42 type OAuthProvider,
43 type Org,
44 type OrgDomain,
45 type OrgInvite,
46 type OrgMember,
47 type OrgPermission,
48 type OrgRole,
49 type Passkey,
50 providerLogoDataUri,
51 type SessionResponse,
52 type SignInResult,
53 type SimpleResult,
54 type SsoConnection,
55 type SsoProviderType,
56 type User,
57 type UserEmail,
58} from '../index.js';
59
60const BrivenAuthContext = createContext<BrivenAuthClient | null>(null);
61
62export interface BrivenAuthProviderProps {
63 value: BrivenAuthClient;
64 children: ReactNode;
65}
66
67/**
68 * Provider for the SDK client. Re-render-safe — the client is stateless,
69 * so passing a new instance is allowed but unnecessary.
70 */
71export function BrivenAuthProvider({ value, children }: BrivenAuthProviderProps) {
72 return createElement(BrivenAuthContext.Provider, { value }, children);
73}
74
75/** Throws when called outside a `<BrivenAuthProvider>`. */
76export function useBrivenAuth(): BrivenAuthClient {
77 const client = useContext(BrivenAuthContext);
78 if (!client) {
79 throw new Error('useBrivenAuth must be called inside <BrivenAuthProvider>');
80 }
81 return client;
82}
83
84export interface UseSessionResult {
85 session: SessionResponse | null;
86 isLoading: boolean;
87 refresh: () => Promise<void>;
88}
89
90/**
91 * Subscribe to the current session. Fetches once on mount; the caller
92 * can re-fetch via `refresh()` after sign-in / sign-out actions.
93 */
94export function useSession(): UseSessionResult {
95 const client = useBrivenAuth();
96 const [session, setSession] = useState<SessionResponse | null>(null);
97 const [isLoading, setLoading] = useState(true);
98
99 const refresh = useCallback(async () => {
100 setLoading(true);
101 const next = await client.getSession();
102 setSession(next);
103 setLoading(false);
104 }, [client]);
105
106 useEffect(() => {
107 let cancelled = false;
108 void (async () => {
109 const next = await client.getSession();
110 if (!cancelled) {
111 setSession(next);
112 setLoading(false);
113 }
114 })();
115 return () => {
116 cancelled = true;
117 };
118 }, [client]);
119
120 return { session, isLoading, refresh };
121}
122
123export interface UseUserResult {
124 user: User | null;
125 isLoading: boolean;
126 refresh: () => Promise<void>;
127}
128
129/**
130 * Subscribe to the current user. Same fetch lifecycle as `useSession`.
131 * Returned `User` carries `email` for the account holder — never echo
132 * it back into a list view or analytics event (CLAUDE.md §5.1 applies
133 * to consumer apps too).
134 */
135export function useUser(): UseUserResult {
136 const client = useBrivenAuth();
137 const [user, setUser] = useState<User | null>(null);
138 const [isLoading, setLoading] = useState(true);
139
140 const refresh = useCallback(async () => {
141 setLoading(true);
142 const next = await client.getUser();
143 setUser(next);
144 setLoading(false);
145 }, [client]);
146
147 useEffect(() => {
148 let cancelled = false;
149 void (async () => {
150 const next = await client.getUser();
151 if (!cancelled) {
152 setUser(next);
153 setLoading(false);
154 }
155 })();
156 return () => {
157 cancelled = true;
158 };
159 }, [client]);
160
161 return { user, isLoading, refresh };
162}
163
164export interface UseUserMetadataResult {
165 metadata: Record<string, unknown> | null;
166 isLoading: boolean;
167 refresh: () => Promise<void>;
168 set: (patch: Record<string, unknown>) => Promise<void>;
169}
170
171/**
172 * Subscribe to the current user's public metadata.
173 * Fetches once on mount; caller can re-fetch via `refresh()`.
174 */
175export function useUserMetadata(): UseUserMetadataResult {
176 const client = useBrivenAuth();
177 const [metadata, setMetadata] = useState<Record<string, unknown> | null>(null);
178 const [isLoading, setLoading] = useState(true);
179
180 const refresh = useCallback(async () => {
181 setLoading(true);
182 const result = await client.user.getMetadata();
183 setMetadata(result.ok ? result.publicMetadata : null);
184 setLoading(false);
185 }, [client]);
186
187 useEffect(() => {
188 let cancelled = false;
189 void (async () => {
190 const result = await client.user.getMetadata();
191 if (!cancelled) {
192 setMetadata(result.ok ? result.publicMetadata : null);
193 setLoading(false);
194 }
195 })();
196 return () => {
197 cancelled = true;
198 };
199 }, [client]);
200
201 const set = useCallback(
202 async (patch: Record<string, unknown>) => {
203 const result = await client.user.setMetadata(patch);
204 if (result.ok) {
205 setMetadata(result.publicMetadata);
206 }
207 },
208 [client],
209 );
210
211 return { metadata, isLoading, refresh, set };
212}
213
214export interface UseUserEmailsResult {
215 emails: UserEmail[] | null;
216 isLoading: boolean;
217 refresh: () => Promise<void>;
218 add: (email: string) => Promise<void>;
219 remove: (emailId: string) => Promise<void>;
220}
221
222/**
223 * Subscribe to the current user's additional email addresses.
224 * Fetches once on mount; caller can re-fetch via `refresh()`.
225 */
226export function useUserEmails(): UseUserEmailsResult {
227 const client = useBrivenAuth();
228 const [emails, setEmails] = useState<UserEmail[] | null>(null);
229 const [isLoading, setLoading] = useState(true);
230
231 const refresh = useCallback(async () => {
232 setLoading(true);
233 const result = await client.user.listEmails();
234 setEmails(result.ok ? result.emails : null);
235 setLoading(false);
236 }, [client]);
237
238 useEffect(() => {
239 let cancelled = false;
240 void (async () => {
241 const result = await client.user.listEmails();
242 if (!cancelled) {
243 setEmails(result.ok ? result.emails : null);
244 setLoading(false);
245 }
246 })();
247 return () => {
248 cancelled = true;
249 };
250 }, [client]);
251
252 const add = useCallback(
253 async (email: string) => {
254 const result = await client.user.addEmail(email);
255 if (result.ok) await refresh();
256 },
257 [client, refresh],
258 );
259
260 const remove = useCallback(
261 async (emailId: string) => {
262 const result = await client.user.removeEmail(emailId);
263 if (result.ok) await refresh();
264 },
265 [client, refresh],
266 );
267
268 return { emails, isLoading, refresh, add, remove };
269}
270
271export interface UseActiveOrganizationResult {
272 activeOrg: Org | null;
273 isLoading: boolean;
274 refresh: () => Promise<void>;
275 setActive: (orgId: string) => Promise<void>;
276}
277
278/**
279 * Subscribe to the currently-active organization for this session.
280 * The active org is set via `organization.setActive(orgId)` and persists
281 * per-session, so org switching does not require re-authentication.
282 */
283export function useActiveOrganization(): UseActiveOrganizationResult {
284 const client = useBrivenAuth();
285 const [activeOrg, setActiveOrg] = useState<Org | null>(null);
286 const [isLoading, setLoading] = useState(true);
287
288 const refresh = useCallback(async () => {
289 setLoading(true);
290 const result = await client.organization.getActive();
291 if (result.ok) setActiveOrg(result.data);
292 setLoading(false);
293 }, [client]);
294
295 useEffect(() => {
296 let cancelled = false;
297 void (async () => {
298 const result = await client.organization.getActive();
299 if (!cancelled) {
300 setActiveOrg(result.ok ? result.data : null);
301 setLoading(false);
302 }
303 })();
304 return () => {
305 cancelled = true;
306 };
307 }, [client]);
308
309 const setActive = useCallback(
310 async (orgId: string) => {
311 const result = await client.organization.setActive(orgId);
312 if (result.ok) await refresh();
313 },
314 [client, refresh],
315 );
316
317 return { activeOrg, isLoading, refresh, setActive };
318}
319
320// ─── Shared helpers ────────────────────────────────────────────────────────
321
322function useRedirectToHosted(auth: BrivenAuthClient, redirectTo?: string, locale?: string) {
323 return useCallback(
324 (flow: 'sign-in' | 'sign-up' | 'magic-link') => {
325 const url = auth.hostedPageURL(flow, redirectTo, locale);
326 if (typeof window !== 'undefined') {
327 window.location.assign(url);
328 }
329 },
330 [auth, redirectTo, locale],
331 );
332}
333
334// ─── BrivenSignIn ──────────────────────────────────────────────────────────
335
336export interface BrivenSignInProps {
337 /** Providers to render as OAuth buttons. Empty array hides the OAuth section. */
338 providers?: ReadonlyArray<OAuthProvider>;
339 /** Render the email + password form. Default true. */
340 showEmailPassword?: boolean;
341 /** Render the magic-link section. Default true. */
342 showMagicLink?: boolean;
343 /** Post-sign-in URL the customer's app wants users to land on. */
344 redirectTo?: string;
345 /** Called when sign-in completes successfully. */
346 onSuccess?: (result: { userId: string }) => void;
347 /** Optional className applied to the root container. */
348 className?: string;
349 /**
350 * 'direct' (default) — make cross-origin API calls from the component.
351 * 'hosted' — redirect to Briven's hosted auth pages. Eliminates CORS
352 * and origin-allowlist issues; recommended for production.
353 */
354 mode?: 'direct' | 'hosted';
355 /** BCP 47 locale for hosted-page redirects (e.g. 'nl', 'fr-FR'). */
356 locale?: string;
357}
358
359const DEFAULT_PROVIDERS: ReadonlyArray<OAuthProvider> = [
360 'konnos',
361 'google',
362 'github',
363 'discord',
364 'microsoft',
365 'apple',
366 'twitter',
367 'linkedin',
368 'gitlab',
369];
370
371/** OAuth button children: official logo (when we ship one) + label. */
372function oauthButtonChildren(provider: OAuthProvider): ReactNode {
373 const logo = providerLogoDataUri(provider);
374 const label = `continue with ${provider}`;
375 if (!logo) return label;
376 return createElement(
377 'span',
378 {
379 className: 'briven-auth-oauth-button-inner',
380 style: {
381 display: 'inline-flex',
382 alignItems: 'center',
383 justifyContent: 'center',
384 gap: 8,
385 },
386 },
387 createElement('img', {
388 src: logo,
389 alt: '',
390 width: 20,
391 height: 20,
392 'aria-hidden': true,
393 className: 'briven-auth-oauth-logo',
394 style: { width: 20, height: 20, objectFit: 'contain', flexShrink: 0 },
395 }),
396 label,
397 );
398}
399
400/**
401 * Drop-in sign-in component. Renders email+password, magic-link, and the
402 * configured OAuth providers in a single panel. No CSS framework — the
403 * caller styles via the standard `class` attribute on the elements
404 * via the `className` prop (root) and the cascaded element styles.
405 *
406 * Customer can compose their own UI by wiring the hooks directly:
407 * `const auth = useBrivenAuth(); await auth.signIn.email({...})`.
408 */
409export function BrivenSignIn(props: BrivenSignInProps) {
410 const auth = useBrivenAuth();
411 const providers = props.providers ?? DEFAULT_PROVIDERS;
412 const showEmailPassword = props.showEmailPassword ?? true;
413 const showMagicLink = props.showMagicLink ?? true;
414 const mode = props.mode ?? 'direct';
415
416 const [email, setEmail] = useState('');
417 const [password, setPassword] = useState('');
418 const [magicEmail, setMagicEmail] = useState('');
419 const [pending, setPending] = useState<'password' | 'magic' | null>(null);
420 const [error, setError] = useState<string | null>(null);
421 const [magicSent, setMagicSent] = useState(false);
422
423 const redirectToHosted = useRedirectToHosted(auth, props.redirectTo, props.locale);
424
425 const handlePassword = useCallback(
426 async (e: FormEvent<HTMLFormElement>): Promise<void> => {
427 e.preventDefault();
428 if (mode === 'hosted') {
429 redirectToHosted('sign-in');
430 return;
431 }
432 setPending('password');
433 setError(null);
434 const result: SignInResult = await auth.signIn.email({ email, password });
435 if (result.ok && 'userId' in result) {
436 props.onSuccess?.({ userId: result.userId });
437 } else if (result.ok && 'twoFactorRequired' in result) {
438 setError('two-factor required — complete the challenge');
439 } else if (!result.ok) {
440 setError(result.message);
441 }
442 setPending(null);
443 },
444 [auth, email, mode, password, props, redirectToHosted],
445 );
446
447 const handleMagic = useCallback(
448 async (e: FormEvent<HTMLFormElement>): Promise<void> => {
449 e.preventDefault();
450 if (mode === 'hosted') {
451 redirectToHosted('magic-link');
452 return;
453 }
454 setPending('magic');
455 setError(null);
456 const result = await auth.signIn.magicLink({
457 email: magicEmail,
458 redirectTo: props.redirectTo,
459 });
460 if (result.ok) {
461 setMagicSent(true);
462 } else {
463 setError(result.message);
464 }
465 setPending(null);
466 },
467 [auth, magicEmail, mode, props.redirectTo, redirectToHosted],
468 );
469
470 const handleOAuth = useCallback(
471 (provider: OAuthProvider): void => {
472 const { redirectUrl } = auth.signIn.social({
473 provider,
474 redirectTo: props.redirectTo,
475 });
476 if (typeof window !== 'undefined') {
477 window.location.assign(redirectUrl);
478 }
479 },
480 [auth, props.redirectTo],
481 );
482
483 const oauthButtons = useMemo(
484 () =>
485 providers.map((provider) =>
486 createElement(
487 'button',
488 {
489 key: provider,
490 type: 'button',
491 'data-briven-auth-provider': provider,
492 onClick: () => handleOAuth(provider),
493 className: 'briven-auth-oauth-button',
494 style: {
495 display: 'inline-flex',
496 alignItems: 'center',
497 justifyContent: 'center',
498 gap: 8,
499 },
500 },
501 oauthButtonChildren(provider),
502 ),
503 ),
504 [providers, handleOAuth],
505 );
506
507 return createElement(
508 'div',
509 {
510 className: props.className ?? 'briven-auth-signin',
511 'data-briven-auth': 'signin',
512 },
513 showEmailPassword
514 ? createElement(
515 'form',
516 {
517 key: 'password',
518 onSubmit: handlePassword,
519 className: 'briven-auth-form',
520 'data-briven-auth-flow': 'password',
521 },
522 createElement('input', {
523 key: 'email',
524 type: 'email',
525 required: true,
526 placeholder: 'email',
527 value: email,
528 onChange: (e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value),
529 autoComplete: 'email',
530 className: 'briven-auth-input',
531 }),
532 createElement('input', {
533 key: 'password',
534 type: 'password',
535 required: true,
536 placeholder: 'password',
537 value: password,
538 onChange: (e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value),
539 autoComplete: 'current-password',
540 className: 'briven-auth-input',
541 }),
542 createElement(
543 'button',
544 {
545 key: 'submit',
546 type: 'submit',
547 disabled: pending !== null,
548 className: 'briven-auth-submit',
549 },
550 pending === 'password' ? 'signing in…' : 'sign in',
551 ),
552 )
553 : null,
554 showMagicLink
555 ? magicSent
556 ? createElement(
557 'p',
558 { key: 'magic-sent', className: 'briven-auth-message' },
559 'check your inbox for the sign-in link.',
560 )
561 : createElement(
562 'form',
563 {
564 key: 'magic',
565 onSubmit: handleMagic,
566 className: 'briven-auth-form',
567 'data-briven-auth-flow': 'magic-link',
568 },
569 createElement('input', {
570 key: 'email',
571 type: 'email',
572 required: true,
573 placeholder: 'email for magic link',
574 value: magicEmail,
575 onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
576 setMagicEmail(e.target.value),
577 autoComplete: 'email',
578 className: 'briven-auth-input',
579 }),
580 createElement(
581 'button',
582 {
583 key: 'submit',
584 type: 'submit',
585 disabled: pending !== null,
586 className: 'briven-auth-submit',
587 },
588 pending === 'magic' ? 'sending…' : 'send magic link',
589 ),
590 )
591 : null,
592 providers.length > 0
593 ? createElement(
594 'div',
595 {
596 key: 'oauth',
597 className: 'briven-auth-oauth',
598 'data-briven-auth-flow': 'oauth',
599 },
600 oauthButtons,
601 )
602 : null,
603 error
604 ? createElement(
605 'p',
606 {
607 key: 'error',
608 className: 'briven-auth-error',
609 role: 'alert',
610 },
611 error,
612 )
613 : null,
614 );
615}
616
617// ─── BrivenSignUp ──────────────────────────────────────────────────────────
618
619export interface BrivenSignUpProps {
620 /** Providers to render as OAuth buttons. Empty array hides the OAuth section. */
621 providers?: ReadonlyArray<OAuthProvider>;
622 /** Render the email + password form. Default true. */
623 showEmailPassword?: boolean;
624 /** Post-sign-up URL the customer's app wants users to land on. */
625 redirectTo?: string;
626 /** Called when sign-up completes successfully. */
627 onSuccess?: (result: { userId: string }) => void;
628 /** Optional className applied to the root container. */
629 className?: string;
630 /**
631 * 'direct' (default) — make cross-origin API calls from the component.
632 * 'hosted' — redirect to Briven's hosted auth pages. Eliminates CORS
633 * and origin-allowlist issues; recommended for production.
634 */
635 mode?: 'direct' | 'hosted';
636 /** BCP 47 locale for hosted-page redirects (e.g. 'nl', 'fr-FR'). */
637 locale?: string;
638}
639
640/**
641 * Drop-in sign-up component. Renders email+password + OAuth providers.
642 * Mirrors BrivenSignIn's contract and styling approach.
643 */
644export function BrivenSignUp(props: BrivenSignUpProps) {
645 const auth = useBrivenAuth();
646 const providers = props.providers ?? DEFAULT_PROVIDERS;
647 const showEmailPassword = props.showEmailPassword ?? true;
648 const mode = props.mode ?? 'direct';
649
650 const [name, setName] = useState('');
651 const [email, setEmail] = useState('');
652 const [password, setPassword] = useState('');
653 const [pending, setPending] = useState(false);
654 const [error, setError] = useState<string | null>(null);
655
656 const redirectToHosted = useRedirectToHosted(auth, props.redirectTo, props.locale);
657
658 const handleSubmit = useCallback(
659 async (e: FormEvent<HTMLFormElement>): Promise<void> => {
660 e.preventDefault();
661 if (mode === 'hosted') {
662 redirectToHosted('sign-up');
663 return;
664 }
665 setPending(true);
666 setError(null);
667 const result: SignInResult = await auth.signUp.email({
668 email,
669 password,
670 name: name || undefined,
671 });
672 if (result.ok && 'userId' in result) {
673 props.onSuccess?.({ userId: result.userId });
674 } else if (result.ok && 'twoFactorRequired' in result) {
675 setError('two-factor required — complete the challenge');
676 } else if (!result.ok) {
677 setError(result.message);
678 }
679 setPending(false);
680 },
681 [auth, email, mode, name, password, props, redirectToHosted],
682 );
683
684 const handleOAuth = useCallback(
685 (provider: OAuthProvider): void => {
686 const { redirectUrl } = auth.signIn.social({
687 provider,
688 redirectTo: props.redirectTo,
689 });
690 if (typeof window !== 'undefined') {
691 window.location.assign(redirectUrl);
692 }
693 },
694 [auth, props.redirectTo],
695 );
696
697 const oauthButtons = useMemo(
698 () =>
699 providers.map((provider) =>
700 createElement(
701 'button',
702 {
703 key: provider,
704 type: 'button',
705 'data-briven-auth-provider': provider,
706 onClick: () => handleOAuth(provider),
707 className: 'briven-auth-oauth-button',
708 style: {
709 display: 'inline-flex',
710 alignItems: 'center',
711 justifyContent: 'center',
712 gap: 8,
713 },
714 },
715 oauthButtonChildren(provider),
716 ),
717 ),
718 [providers, handleOAuth],
719 );
720
721 return createElement(
722 'div',
723 {
724 className: props.className ?? 'briven-auth-signup',
725 'data-briven-auth': 'signup',
726 },
727 showEmailPassword
728 ? createElement(
729 'form',
730 {
731 key: 'password',
732 onSubmit: handleSubmit,
733 className: 'briven-auth-form',
734 'data-briven-auth-flow': 'password',
735 },
736 createElement('input', {
737 key: 'name',
738 type: 'text',
739 placeholder: 'name (optional)',
740 value: name,
741 onChange: (e: React.ChangeEvent<HTMLInputElement>) => setName(e.target.value),
742 autoComplete: 'name',
743 className: 'briven-auth-input',
744 }),
745 createElement('input', {
746 key: 'email',
747 type: 'email',
748 required: true,
749 placeholder: 'email',
750 value: email,
751 onChange: (e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value),
752 autoComplete: 'email',
753 className: 'briven-auth-input',
754 }),
755 createElement('input', {
756 key: 'password',
757 type: 'password',
758 required: true,
759 placeholder: 'password',
760 value: password,
761 onChange: (e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value),
762 autoComplete: 'new-password',
763 className: 'briven-auth-input',
764 }),
765 createElement(
766 'button',
767 {
768 key: 'submit',
769 type: 'submit',
770 disabled: pending,
771 className: 'briven-auth-submit',
772 },
773 pending ? 'creating account…' : 'create account',
774 ),
775 )
776 : null,
777 providers.length > 0
778 ? createElement(
779 'div',
780 {
781 key: 'oauth',
782 className: 'briven-auth-oauth',
783 'data-briven-auth-flow': 'oauth',
784 },
785 oauthButtons,
786 )
787 : null,
788 error
789 ? createElement(
790 'p',
791 {
792 key: 'error',
793 className: 'briven-auth-error',
794 role: 'alert',
795 },
796 error,
797 )
798 : null,
799 );
800}
801
802// ─── UserButton ────────────────────────────────────────────────────────────
803
804export interface UserButtonProps {
805 /** Optional className applied to the root container. */
806 className?: string;
807 /** URL to redirect to when "Profile" is clicked. Defaults to hosted profile page. */
808 profileUrl?: string;
809}
810
811/**
812 * Drop-in user button. Shows the current user's name (or email) in a
813 * dropdown with profile + sign-out actions. Renders nothing while loading
814 * or when unauthenticated.
815 */
816export function UserButton(props: UserButtonProps) {
817 const auth = useBrivenAuth();
818 const { user, isLoading } = useUser();
819 const [open, setOpen] = useState(false);
820
821 const handleSignOut = useCallback(async () => {
822 await auth.signOut();
823 if (typeof window !== 'undefined') {
824 window.location.reload();
825 }
826 }, [auth]);
827
828 const handleProfile = useCallback(() => {
829 const url = props.profileUrl ?? auth.hostedPageURL('profile');
830 if (typeof window !== 'undefined') {
831 window.location.assign(url);
832 }
833 }, [auth, props.profileUrl]);
834
835 if (isLoading || !user) return null;
836
837 const label = user.name ?? user.email;
838
839 return createElement(
840 'div',
841 {
842 className: props.className ?? 'briven-auth-userbutton',
843 'data-briven-auth': 'userbutton',
844 },
845 createElement(
846 'button',
847 {
848 type: 'button',
849 onClick: () => setOpen((v) => !v),
850 className: 'briven-auth-userbutton-trigger',
851 },
852 label,
853 ),
854 open
855 ? createElement(
856 'div',
857 {
858 className: 'briven-auth-userbutton-dropdown',
859 'data-briven-auth-dropdown': 'open',
860 },
861 createElement(
862 'button',
863 {
864 type: 'button',
865 onClick: handleProfile,
866 className: 'briven-auth-userbutton-item',
867 },
868 'profile',
869 ),
870 createElement(
871 'button',
872 {
873 type: 'button',
874 onClick: handleSignOut,
875 className: 'briven-auth-userbutton-item',
876 },
877 'sign out',
878 ),
879 )
880 : null,
881 );
882}
883
884// ─── UserProfile ───────────────────────────────────────────────────────────
885
886export interface UserProfileProps {
887 /** Optional className applied to the root container. */
888 className?: string;
889 /** Called after the user is updated successfully. */
890 onUpdate?: () => void;
891}
892
893/**
894 * Drop-in user profile component. Renders name, email, password change
895 * form, and account deletion. No CSS framework — caller styles via
896 * `className` and cascading element classes.
897 */
898export function UserProfile(props: UserProfileProps) {
899 const auth = useBrivenAuth();
900 const { user, refresh } = useUser();
901
902 const [name, setName] = useState('');
903 const [currentPassword, setCurrentPassword] = useState('');
904 const [newPassword, setNewPassword] = useState('');
905 const [updatePending, setUpdatePending] = useState(false);
906 const [pwPending, setPwPending] = useState(false);
907 const [deletePending, setDeletePending] = useState(false);
908 const [message, setMessage] = useState<string | null>(null);
909 const [error, setError] = useState<string | null>(null);
910
911 useEffect(() => {
912 if (user?.name) setName(user.name);
913 }, [user?.name]);
914
915 const handleUpdate = useCallback(
916 async (e: FormEvent<HTMLFormElement>) => {
917 e.preventDefault();
918 setUpdatePending(true);
919 setError(null);
920 setMessage(null);
921 const result = await auth.user.update({ name: name || undefined });
922 if (result.ok) {
923 setMessage('profile updated');
924 await refresh();
925 props.onUpdate?.();
926 } else {
927 setError(result.message);
928 }
929 setUpdatePending(false);
930 },
931 [auth.user, name, props, refresh],
932 );
933
934 const handleChangePassword = useCallback(
935 async (e: FormEvent<HTMLFormElement>) => {
936 e.preventDefault();
937 setPwPending(true);
938 setError(null);
939 setMessage(null);
940 const result = await auth.user.changePassword({ currentPassword, newPassword });
941 if (result.ok) {
942 setMessage('password changed');
943 setCurrentPassword('');
944 setNewPassword('');
945 } else {
946 setError(result.message);
947 }
948 setPwPending(false);
949 },
950 [auth.user, currentPassword, newPassword],
951 );
952
953 const handleDelete = useCallback(async () => {
954 if (!window.confirm('Delete your account? This cannot be undone.')) return;
955 setDeletePending(true);
956 setError(null);
957 setMessage(null);
958 const result = await auth.user.delete();
959 if (result.ok) {
960 if (typeof window !== 'undefined') {
961 window.location.reload();
962 }
963 } else {
964 setError(result.message);
965 setDeletePending(false);
966 }
967 }, [auth.user]);
968
969 if (!user) {
970 return createElement(
971 'p',
972 { className: 'briven-auth-message' },
973 'not authenticated',
974 );
975 }
976
977 return createElement(
978 'div',
979 {
980 className: props.className ?? 'briven-auth-userprofile',
981 'data-briven-auth': 'userprofile',
982 },
983 createElement(
984 'form',
985 {
986 key: 'profile',
987 onSubmit: handleUpdate,
988 className: 'briven-auth-form',
989 'data-briven-auth-flow': 'profile-update',
990 },
991 createElement('h3', { className: 'briven-auth-heading' }, 'profile'),
992 createElement('input', {
993 key: 'name',
994 type: 'text',
995 placeholder: 'name',
996 value: name,
997 onChange: (e: React.ChangeEvent<HTMLInputElement>) => setName(e.target.value),
998 className: 'briven-auth-input',
999 }),
1000 createElement('input', {
1001 key: 'email',
1002 type: 'email',
1003 disabled: true,
1004 value: user.email,
1005 className: 'briven-auth-input',
1006 }),
1007 createElement(
1008 'button',
1009 {
1010 key: 'submit',
1011 type: 'submit',
1012 disabled: updatePending,
1013 className: 'briven-auth-submit',
1014 },
1015 updatePending ? 'saving…' : 'save profile',
1016 ),
1017 ),
1018 createElement(
1019 'form',
1020 {
1021 key: 'password',
1022 onSubmit: handleChangePassword,
1023 className: 'briven-auth-form',
1024 'data-briven-auth-flow': 'change-password',
1025 },
1026 createElement('h3', { className: 'briven-auth-heading' }, 'change password'),
1027 createElement('input', {
1028 key: 'current',
1029 type: 'password',
1030 required: true,
1031 placeholder: 'current password',
1032 value: currentPassword,
1033 onChange: (e: React.ChangeEvent<HTMLInputElement>) => setCurrentPassword(e.target.value),
1034 autoComplete: 'current-password',
1035 className: 'briven-auth-input',
1036 }),
1037 createElement('input', {
1038 key: 'new',
1039 type: 'password',
1040 required: true,
1041 placeholder: 'new password',
1042 value: newPassword,
1043 onChange: (e: React.ChangeEvent<HTMLInputElement>) => setNewPassword(e.target.value),
1044 autoComplete: 'new-password',
1045 className: 'briven-auth-input',
1046 }),
1047 createElement(
1048 'button',
1049 {
1050 key: 'submit',
1051 type: 'submit',
1052 disabled: pwPending,
1053 className: 'briven-auth-submit',
1054 },
1055 pwPending ? 'changing…' : 'change password',
1056 ),
1057 ),
1058 createElement(
1059 'div',
1060 {
1061 key: 'danger',
1062 className: 'briven-auth-danger-zone',
1063 'data-briven-auth-flow': 'delete-account',
1064 },
1065 createElement('h3', { className: 'briven-auth-heading' }, 'danger zone'),
1066 createElement(
1067 'button',
1068 {
1069 type: 'button',
1070 onClick: handleDelete,
1071 disabled: deletePending,
1072 className: 'briven-auth-danger-button',
1073 },
1074 deletePending ? 'deleting…' : 'delete account',
1075 ),
1076 ),
1077 message
1078 ? createElement('p', { key: 'message', className: 'briven-auth-message' }, message)
1079 : null,
1080 error
1081 ? createElement('p', { key: 'error', className: 'briven-auth-error', role: 'alert' }, error)
1082 : null,
1083 );
1084}
1085
1086// ─── SessionManager ────────────────────────────────────────────────────────
1087
1088export interface SessionManagerProps {
1089 /** Optional className applied to the root container. */
1090 className?: string;
1091}
1092
1093/**
1094 * Drop-in session manager. Lists active sessions with a revoke button
1095 * for each. No CSS framework — caller styles via `className` and
1096 * cascading element classes.
1097 */
1098export function SessionManager(props: SessionManagerProps) {
1099 const auth = useBrivenAuth();
1100 const [sessions, setSessions] = useState<ClientSession[]>([]);
1101 const [isLoading, setLoading] = useState(true);
1102 const [error, setError] = useState<string | null>(null);
1103
1104 const load = useCallback(async () => {
1105 setLoading(true);
1106 setError(null);
1107 const result = await auth.sessions.list();
1108 if (result.ok) {
1109 setSessions(result.sessions);
1110 } else {
1111 setError(result.message);
1112 }
1113 setLoading(false);
1114 }, [auth]);
1115
1116 useEffect(() => {
1117 void load();
1118 }, [load]);
1119
1120 const handleRevoke = useCallback(
1121 async (sessionId: string) => {
1122 const result = await auth.sessions.revoke(sessionId);
1123 if (result.ok) {
1124 await load();
1125 } else {
1126 setError(result.message);
1127 }
1128 },
1129 [auth, load],
1130 );
1131
1132 return createElement(
1133 'div',
1134 {
1135 className: props.className ?? 'briven-auth-sessionmanager',
1136 'data-briven-auth': 'sessionmanager',
1137 },
1138 createElement('h3', { className: 'briven-auth-heading' }, 'active sessions'),
1139 isLoading
1140 ? createElement('p', { className: 'briven-auth-message' }, 'loading…')
1141 : sessions.length === 0
1142 ? createElement('p', { className: 'briven-auth-message' }, 'no active sessions')
1143 : createElement(
1144 'ul',
1145 { className: 'briven-auth-session-list' },
1146 sessions.map((s) =>
1147 createElement(
1148 'li',
1149 { key: s.id, className: 'briven-auth-session-item' },
1150 createElement(
1151 'span',
1152 { className: 'briven-auth-session-info' },
1153 s.userAgent ?? 'unknown device',
1154 ),
1155 createElement(
1156 'button',
1157 {
1158 type: 'button',
1159 onClick: () => handleRevoke(s.id),
1160 className: 'briven-auth-session-revoke',
1161 },
1162 'revoke',
1163 ),
1164 ),
1165 ),
1166 ),
1167 error
1168 ? createElement('p', { className: 'briven-auth-error', role: 'alert' }, error)
1169 : null,
1170 );
1171}
1172
1173// ─── OrganizationSwitcher ─────────────────────────────────────────────────
1174
1175export interface OrganizationSwitcherProps {
1176 /** Optional className applied to the root container. */
1177 className?: string;
1178}
1179
1180/**
1181 * Drop-in organization switcher. Shows the active organization (if any)
1182 * and a dropdown to switch between orgs or create a new one. Renders
1183 * nothing while loading or unauthenticated.
1184 *
1185 * Uses the same `briven-auth-*` CSS class convention as the rest of the
1186 * SDK — no Clerk UI cloning.
1187 */
1188export function OrganizationSwitcher(props: OrganizationSwitcherProps) {
1189 const auth = useBrivenAuth();
1190 const { activeOrg, setActive } = useActiveOrganization();
1191 const [orgs, setOrgs] = useState<Org[]>([]);
1192 const [isLoading, setLoading] = useState(true);
1193 const [open, setOpen] = useState(false);
1194 const [showCreate, setShowCreate] = useState(false);
1195
1196 const load = useCallback(async () => {
1197 setLoading(true);
1198 const result = await auth.organization.list();
1199 if (result.ok) setOrgs(result.data);
1200 setLoading(false);
1201 }, [auth]);
1202
1203 useEffect(() => {
1204 void load();
1205 }, [load]);
1206
1207 const handleCreate = useCallback(async (name: string, slug: string) => {
1208 const result = await auth.organization.create({ name, slug });
1209 if (result.ok) {
1210 setShowCreate(false);
1211 await load();
1212 }
1213 return result;
1214 }, [auth, load]);
1215
1216 const handleSwitch = useCallback(
1217 async (orgId: string) => {
1218 await setActive(orgId);
1219 setOpen(false);
1220 },
1221 [setActive],
1222 );
1223
1224 if (isLoading) return null;
1225
1226 if (orgs.length === 0) {
1227 return createElement(
1228 'button',
1229 {
1230 type: 'button',
1231 onClick: () => setShowCreate(true),
1232 className: props.className ?? 'briven-auth-org-switcher',
1233 },
1234 'create organization',
1235 );
1236 }
1237
1238 return createElement(
1239 'div',
1240 {
1241 className: props.className ?? 'briven-auth-org-switcher',
1242 'data-briven-auth': 'org-switcher',
1243 },
1244 createElement(
1245 'button',
1246 {
1247 type: 'button',
1248 onClick: () => setOpen((v) => !v),
1249 className: 'briven-auth-org-switcher-trigger',
1250 },
1251 activeOrg?.name ?? 'switch organization',
1252 ),
1253 open
1254 ? createElement(
1255 'div',
1256 { className: 'briven-auth-org-switcher-dropdown' },
1257 orgs.map((org) =>
1258 createElement(
1259 'button',
1260 {
1261 key: org.id,
1262 type: 'button',
1263 onClick: () => handleSwitch(org.id),
1264 className:
1265 org.id === activeOrg?.id
1266 ? 'briven-auth-org-switcher-item briven-auth-org-switcher-item-active'
1267 : 'briven-auth-org-switcher-item',
1268 },
1269 org.name,
1270 ),
1271 ),
1272 createElement(
1273 'button',
1274 {
1275 type: 'button',
1276 onClick: () => setShowCreate(true),
1277 className: 'briven-auth-org-switcher-create',
1278 },
1279 '+ create organization',
1280 ),
1281 )
1282 : null,
1283 showCreate
1284 ? createElement(CreateOrganization, {
1285 key: 'create',
1286 onCreate: handleCreate,
1287 onCancel: () => setShowCreate(false),
1288 })
1289 : null,
1290 );
1291}
1292
1293// ─── CreateOrganization ───────────────────────────────────────────────────
1294
1295export interface CreateOrganizationProps {
1296 onCreate(name: string, slug: string): Promise<unknown>;
1297 onCancel(): void;
1298}
1299
1300export function CreateOrganization(props: CreateOrganizationProps) {
1301 const [name, setName] = useState('');
1302 const [slug, setSlug] = useState('');
1303 const [pending, setPending] = useState(false);
1304 const [error, setError] = useState<string | null>(null);
1305
1306 const handleSubmit = useCallback(
1307 async (e: FormEvent<HTMLFormElement>) => {
1308 e.preventDefault();
1309 setPending(true);
1310 setError(null);
1311 const result = await props.onCreate(name, slug);
1312 if (result && typeof result === 'object' && 'ok' in result && !result.ok) {
1313 setError((result as { message?: string }).message ?? 'create failed');
1314 }
1315 setPending(false);
1316 },
1317 [name, props, slug],
1318 );
1319
1320 return createElement(
1321 'div',
1322 { className: 'briven-auth-create-org' },
1323 createElement('h3', { className: 'briven-auth-heading' }, 'create organization'),
1324 createElement(
1325 'form',
1326 { className: 'briven-auth-form', onSubmit: handleSubmit },
1327 createElement('input', {
1328 type: 'text',
1329 required: true,
1330 placeholder: 'organization name',
1331 value: name,
1332 onChange: (e: React.ChangeEvent<HTMLInputElement>) => setName(e.target.value),
1333 className: 'briven-auth-input',
1334 }),
1335 createElement('input', {
1336 type: 'text',
1337 required: true,
1338 placeholder: 'slug (lowercase-hyphens)',
1339 value: slug,
1340 onChange: (e: React.ChangeEvent<HTMLInputElement>) => setSlug(e.target.value),
1341 pattern: '[a-z0-9-]{1,64}',
1342 className: 'briven-auth-input',
1343 }),
1344 createElement(
1345 'button',
1346 { type: 'submit', disabled: pending, className: 'briven-auth-submit' },
1347 pending ? 'creating…' : 'create',
1348 ),
1349 ),
1350 error ? createElement('p', { className: 'briven-auth-error', role: 'alert' }, error) : null,
1351 createElement(
1352 'button',
1353 { type: 'button', onClick: props.onCancel, className: 'briven-auth-cancel' },
1354 'cancel',
1355 ),
1356 );
1357}
1358
1359// ─── OrganizationProfile ──────────────────────────────────────────────────
1360
1361export interface OrganizationProfileProps {
1362 orgId: string;
1363 className?: string;
1364}
1365
1366export function OrganizationProfile(props: OrganizationProfileProps) {
1367 const auth = useBrivenAuth();
1368 const [members, setMembers] = useState<OrgMember[]>([]);
1369 const [invites, setInvites] = useState<OrgInvite[]>([]);
1370 const [inviteEmail, setInviteEmail] = useState('');
1371 const [isLoading, setLoading] = useState(true);
1372 const [error, setError] = useState<string | null>(null);
1373
1374 const load = useCallback(async () => {
1375 setLoading(true);
1376 const [mResult, iResult] = await Promise.all([
1377 auth.organization.listMembers(props.orgId),
1378 auth.organization.listInvites(props.orgId),
1379 ]);
1380 if (mResult.ok) setMembers(mResult.data);
1381 if (iResult.ok) setInvites(iResult.data);
1382 setLoading(false);
1383 }, [auth, props.orgId]);
1384
1385 useEffect(() => {
1386 void load();
1387 }, [load]);
1388
1389 const handleInvite = useCallback(
1390 async (e: FormEvent<HTMLFormElement>) => {
1391 e.preventDefault();
1392 setError(null);
1393 const result = await auth.organization.createInvite(props.orgId, { email: inviteEmail });
1394 if (result.ok) {
1395 setInviteEmail('');
1396 await load();
1397 } else {
1398 setError(result.message);
1399 }
1400 },
1401 [auth, inviteEmail, load, props.orgId],
1402 );
1403
1404 const handleRemove = useCallback(
1405 async (userId: string) => {
1406 const result = await auth.organization.removeMember(props.orgId, userId);
1407 if (result.ok) await load();
1408 else setError(result.message);
1409 },
1410 [auth, load, props.orgId],
1411 );
1412
1413 return createElement(
1414 'div',
1415 {
1416 className: props.className ?? 'briven-auth-org-profile',
1417 'data-briven-auth': 'org-profile',
1418 },
1419 createElement('h3', { className: 'briven-auth-heading' }, 'members'),
1420 isLoading
1421 ? createElement('p', { className: 'briven-auth-message' }, 'loading…')
1422 : createElement(
1423 'ul',
1424 { className: 'briven-auth-member-list' },
1425 members.map((m) =>
1426 createElement(
1427 'li',
1428 { key: m.id, className: 'briven-auth-member-item' },
1429 createElement('span', { className: 'briven-auth-member-role' }, m.role),
1430 createElement('span', { className: 'briven-auth-member-id' }, m.userId),
1431 m.role !== 'owner'
1432 ? createElement(
1433 'button',
1434 {
1435 type: 'button',
1436 onClick: () => handleRemove(m.userId),
1437 className: 'briven-auth-member-remove',
1438 },
1439 'remove',
1440 )
1441 : null,
1442 ),
1443 ),
1444 ),
1445 createElement('h3', { className: 'briven-auth-heading' }, 'invites'),
1446 createElement(
1447 'form',
1448 { className: 'briven-auth-form', onSubmit: handleInvite },
1449 createElement('input', {
1450 type: 'email',
1451 required: true,
1452 placeholder: 'email to invite',
1453 value: inviteEmail,
1454 onChange: (e: React.ChangeEvent<HTMLInputElement>) => setInviteEmail(e.target.value),
1455 className: 'briven-auth-input',
1456 }),
1457 createElement(
1458 'button',
1459 { type: 'submit', className: 'briven-auth-submit' },
1460 'send invite',
1461 ),
1462 ),
1463 invites.length > 0
1464 ? createElement(
1465 'ul',
1466 { className: 'briven-auth-invite-list' },
1467 invites.map((i) =>
1468 createElement(
1469 'li',
1470 { key: i.id, className: 'briven-auth-invite-item' },
1471 i.email,
1472 ' · ',
1473 i.role,
1474 ),
1475 ),
1476 )
1477 : null,
1478 error ? createElement('p', { className: 'briven-auth-error', role: 'alert' }, error) : null,
1479 );
1480}
1481
1482// ─── TwoFactorSetup ───────────────────────────────────────────────────────
1483
1484export interface TwoFactorSetupProps {
1485 className?: string;
1486 onEnabled?: () => void;
1487}
1488
1489export function TwoFactorSetup(props: TwoFactorSetupProps) {
1490 const auth = useBrivenAuth();
1491 const [step, setStep] = useState<'idle' | 'enabling' | 'verify' | 'done'>('idle');
1492 const [code, setCode] = useState('');
1493 const [password, setPassword] = useState('');
1494 const [backupCodes, setBackupCodes] = useState<string[]>([]);
1495 const [error, setError] = useState<string | null>(null);
1496
1497 const handleEnable = useCallback(async () => {
1498 setStep('enabling');
1499 setError(null);
1500 const result = await auth.twoFactor.enable(password || undefined);
1501 if (result.ok) {
1502 setStep('verify');
1503 } else {
1504 setError(result.message);
1505 setStep('idle');
1506 }
1507 }, [auth, password]);
1508
1509 const handleVerify = useCallback(
1510 async (e: FormEvent<HTMLFormElement>) => {
1511 e.preventDefault();
1512 setError(null);
1513 const result = await auth.twoFactor.verify(code);
1514 if (result.ok && !('twoFactorRequired' in result && result.twoFactorRequired)) {
1515 const codes = await auth.twoFactor.generateBackupCodes(password || undefined);
1516 if (codes.ok) setBackupCodes(codes.codes);
1517 setStep('done');
1518 props.onEnabled?.();
1519 } else if (result.ok) {
1520 // Unexpected intermediate challenge during enroll — still show verify UI.
1521 setError('enter the authenticator code again');
1522 } else {
1523 setError(result.message);
1524 }
1525 },
1526 [auth, code, password, props],
1527 );
1528
1529 return createElement(
1530 'div',
1531 { className: props.className ?? 'briven-auth-2fa-setup' },
1532 step === 'idle'
1533 ? createElement(
1534 'div',
1535 { className: 'briven-auth-form' },
1536 createElement('p', { className: 'briven-auth-message' }, 'confirm your password, then scan the authenticator setup'),
1537 createElement('input', {
1538 type: 'password',
1539 required: true,
1540 placeholder: 'password',
1541 value: password,
1542 onChange: (e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value),
1543 className: 'briven-auth-input',
1544 autoComplete: 'current-password',
1545 }),
1546 createElement(
1547 'button',
1548 { type: 'button', onClick: handleEnable, className: 'briven-auth-submit' },
1549 'enable two-factor',
1550 ),
1551 )
1552 : null,
1553 step === 'verify'
1554 ? createElement(
1555 'form',
1556 { onSubmit: handleVerify, className: 'briven-auth-form' },
1557 createElement(
1558 'p',
1559 { className: 'briven-auth-message' },
1560 'enter the 6-digit code from your authenticator app',
1561 ),
1562 createElement('input', {
1563 type: 'text',
1564 required: true,
1565 placeholder: '6-digit code',
1566 value: code,
1567 onChange: (e: React.ChangeEvent<HTMLInputElement>) => setCode(e.target.value),
1568 pattern: '\\d{6}',
1569 maxLength: 6,
1570 className: 'briven-auth-input',
1571 autoComplete: 'one-time-code',
1572 }),
1573 createElement('button', { type: 'submit', className: 'briven-auth-submit' }, 'verify'),
1574 )
1575 : null,
1576 backupCodes.length > 0
1577 ? createElement(
1578 'div',
1579 { className: 'briven-auth-backup-codes' },
1580 createElement(
1581 'p',
1582 { className: 'briven-auth-message' },
1583 'save these backup codes now — each works once if you lose your phone:',
1584 ),
1585 createElement(
1586 'ul',
1587 {},
1588 backupCodes.map((c) => createElement('li', { key: c, className: 'briven-auth-code' }, c)),
1589 ),
1590 )
1591 : null,
1592 error ? createElement('p', { className: 'briven-auth-error', role: 'alert' }, error) : null,
1593 );
1594}
1595
1596// ─── TwoFactorChallenge (sign-in recovery) ───────────────────────────────
1597
1598export interface TwoFactorChallengeProps {
1599 className?: string;
1600 onSuccess?: (userId: string) => void;
1601}
1602
1603/**
1604 * Shown after password sign-in when the account has 2FA enabled.
1605 * Accepts either a TOTP app code or a single-use backup recovery code.
1606 */
1607export function TwoFactorChallenge(props: TwoFactorChallengeProps) {
1608 const auth = useBrivenAuth();
1609 const [mode, setMode] = useState<'totp' | 'backup'>('totp');
1610 const [code, setCode] = useState('');
1611 const [pending, setPending] = useState(false);
1612 const [error, setError] = useState<string | null>(null);
1613
1614 const handleSubmit = useCallback(
1615 async (e: FormEvent<HTMLFormElement>) => {
1616 e.preventDefault();
1617 setPending(true);
1618 setError(null);
1619 try {
1620 const result =
1621 mode === 'totp'
1622 ? await auth.twoFactor.verify(code)
1623 : await auth.twoFactor.verifyBackupCode(code);
1624 if (result.ok && 'userId' in result) {
1625 props.onSuccess?.(result.userId);
1626 } else if (result.ok) {
1627 setError('still needs another step — try again');
1628 } else {
1629 setError(result.message);
1630 }
1631 } finally {
1632 setPending(false);
1633 }
1634 },
1635 [auth, code, mode, props],
1636 );
1637
1638 return createElement(
1639 'div',
1640 { className: props.className ?? 'briven-auth-2fa-challenge' },
1641 createElement(
1642 'form',
1643 { onSubmit: handleSubmit, className: 'briven-auth-form' },
1644 createElement(
1645 'p',
1646 { className: 'briven-auth-message' },
1647 mode === 'totp'
1648 ? 'enter the 6-digit code from your authenticator app'
1649 : 'enter one of your single-use backup codes (lost phone recovery)',
1650 ),
1651 createElement('input', {
1652 type: 'text',
1653 required: true,
1654 placeholder: mode === 'totp' ? '6-digit code' : 'backup code',
1655 value: code,
1656 onChange: (e: React.ChangeEvent<HTMLInputElement>) => setCode(e.target.value),
1657 className: 'briven-auth-input',
1658 autoComplete: mode === 'totp' ? 'one-time-code' : 'off',
1659 ...(mode === 'totp' ? { pattern: '\\d{6}', maxLength: 6 } : {}),
1660 }),
1661 createElement(
1662 'button',
1663 { type: 'submit', className: 'briven-auth-submit', disabled: pending },
1664 pending ? 'checking…' : mode === 'totp' ? 'verify' : 'use backup code',
1665 ),
1666 ),
1667 createElement(
1668 'button',
1669 {
1670 type: 'button',
1671 className: 'briven-auth-link',
1672 onClick: () => {
1673 setMode(mode === 'totp' ? 'backup' : 'totp');
1674 setCode('');
1675 setError(null);
1676 },
1677 },
1678 mode === 'totp' ? 'lost your phone? use a backup code' : 'use authenticator code instead',
1679 ),
1680 error ? createElement('p', { className: 'briven-auth-error', role: 'alert' }, error) : null,
1681 );
1682}
1683
1684// ─── PasskeyButton ────────────────────────────────────────────────────────
1685
1686export interface PasskeyButtonProps {
1687 className?: string;
1688 mode?: 'register' | 'sign-in';
1689}
1690
1691export function PasskeyButton(props: PasskeyButtonProps) {
1692 const auth = useBrivenAuth();
1693 const [pending, setPending] = useState(false);
1694 const [error, setError] = useState<string | null>(null);
1695
1696 const handleClick = useCallback(async () => {
1697 setPending(true);
1698 setError(null);
1699 if (props.mode === 'register') {
1700 const result = await auth.passkey.register();
1701 if (!result.ok) setError(result.message);
1702 } else {
1703 const result = await auth.passkey.signIn();
1704 if (!result.ok) setError(result.message);
1705 }
1706 setPending(false);
1707 }, [auth, props.mode]);
1708
1709 return createElement(
1710 'div',
1711 { className: props.className ?? 'briven-auth-passkey' },
1712 createElement(
1713 'button',
1714 {
1715 type: 'button',
1716 onClick: handleClick,
1717 disabled: pending,
1718 className: 'briven-auth-passkey-button',
1719 },
1720 props.mode === 'register' ? 'register passkey' : 'sign in with passkey',
1721 ),
1722 error ? createElement('p', { className: 'briven-auth-error', role: 'alert' }, error) : null,
1723 );
1724}
1725
1726export type {
1727 BrivenAuthClient,
1728 ClientSession,
1729 MembershipRequest,
1730 OAuthProvider,
1731 Org,
1732 OrgDomain,
1733 OrgInvite,
1734 OrgMember,
1735 OrgPermission,
1736 OrgRole,
1737 Passkey,
1738 SessionResponse,
1739 SignInResult,
1740 SimpleResult,
1741 SsoConnection,
1742 SsoProviderType,
1743 User,
1744 UserEmail,
1745};