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