@object-ui/auth 17.6.0 → 18.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +598 -0
- package/README.md +124 -19
- package/dist/ActiveOrganizationStorage.d.ts +32 -1
- package/dist/ActiveOrganizationStorage.d.ts.map +1 -1
- package/dist/ActiveOrganizationStorage.js +72 -3
- package/dist/AuthContext.d.ts +10 -3
- package/dist/AuthContext.d.ts.map +1 -1
- package/dist/AuthProvider.d.ts.map +1 -1
- package/dist/AuthProvider.js +101 -18
- package/dist/LoginForm.d.ts +35 -4
- package/dist/LoginForm.d.ts.map +1 -1
- package/dist/LoginForm.js +16 -4
- package/dist/RegisterForm.d.ts +21 -2
- package/dist/RegisterForm.d.ts.map +1 -1
- package/dist/RegisterForm.js +3 -4
- package/dist/SocialSignInButtons.d.ts +15 -2
- package/dist/SocialSignInButtons.d.ts.map +1 -1
- package/dist/SocialSignInButtons.js +3 -3
- package/dist/UserMenu.d.ts +1 -1
- package/dist/UserMenu.js +1 -1
- package/dist/auth-gate-events.d.ts +6 -4
- package/dist/auth-gate-events.d.ts.map +1 -1
- package/dist/auth-gate-events.js +11 -1
- package/dist/authStyles.d.ts +8 -3
- package/dist/authStyles.d.ts.map +1 -1
- package/dist/authStyles.js +8 -3
- package/dist/createAuthClient.d.ts +15 -1
- package/dist/createAuthClient.d.ts.map +1 -1
- package/dist/createAuthClient.js +54 -1
- package/dist/createAuthenticatedFetch.d.ts +5 -4
- package/dist/createAuthenticatedFetch.d.ts.map +1 -1
- package/dist/createAuthenticatedFetch.js +3 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/types.d.ts +53 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/useWorkspaceAdminStatus.d.ts +76 -27
- package/dist/useWorkspaceAdminStatus.d.ts.map +1 -1
- package/dist/useWorkspaceAdminStatus.js +146 -40
- package/package.json +6 -7
package/dist/AuthProvider.js
CHANGED
|
@@ -9,7 +9,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
9
9
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
10
10
|
import { authGateEvents } from './auth-gate-events.js';
|
|
11
11
|
import { AuthCtx } from './AuthContext.js';
|
|
12
|
-
import { createAuthClient, TokenStorage } from './createAuthClient.js';
|
|
12
|
+
import { createAuthClient, takePreflightSession, TokenStorage } from './createAuthClient.js';
|
|
13
13
|
import { ActiveOrganizationStorage, SessionUserScope } from './ActiveOrganizationStorage.js';
|
|
14
14
|
/**
|
|
15
15
|
* Prefix of every `MetadataProvider` seed entry in `sessionStorage`
|
|
@@ -29,8 +29,8 @@ const METADATA_SEED_CACHE_PREFIX = 'objectui:metadata:';
|
|
|
29
29
|
* (objectui#5198).
|
|
30
30
|
*
|
|
31
31
|
* `sessionStorage` is per-TAB, not per-session, and no sign-out call site
|
|
32
|
-
* reloads the page — `
|
|
33
|
-
*
|
|
32
|
+
* reloads the page — `AppHeader`, `UserMenu` and `RemediationOverlay` all
|
|
33
|
+
* just call `signOut()` and let the SPA keep running.
|
|
34
34
|
* So without this the next person to sign in in the same tab (a shared or
|
|
35
35
|
* kiosk browser, a handover, a support session) is SEEDED from the previous
|
|
36
36
|
* user's entry, and what is in it is that user's PERMISSION-FILTERED app list —
|
|
@@ -141,9 +141,20 @@ function purgeSignedOutClientCaches() {
|
|
|
141
141
|
*/
|
|
142
142
|
export function AuthProvider({ authUrl, client: externalClient, onAuthStateChange, enabled = true, previewMode, children, }) {
|
|
143
143
|
const client = useMemo(() => externalClient ?? createAuthClient({ baseURL: authUrl }), [externalClient, authUrl]);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
144
|
+
// objectui#12808 — the console's pre-React preflight already asked
|
|
145
|
+
// `get-session` with the stored Bearer. When that answer is still the one we
|
|
146
|
+
// would get, it IS the first state, and the mount below does not ask again.
|
|
147
|
+
// Taken once per page load, in an initializer so it runs once per instance
|
|
148
|
+
// (StrictMode keeps the first call's result). Only for a provider that builds
|
|
149
|
+
// its own client from `authUrl`: an injected client may talk to another server.
|
|
150
|
+
const [bootSession] = useState(() => (enabled && previewMode == null && !externalClient ? takePreflightSession(authUrl) : null));
|
|
151
|
+
const [user, setUser] = useState(() => bootSession?.user ?? null);
|
|
152
|
+
const [session, setSession] = useState(() => bootSession?.session ?? null);
|
|
153
|
+
const [isLoading, setIsLoading] = useState(() => bootSession === null);
|
|
154
|
+
// What the boot answer was taken for. The mount effect skips its load while
|
|
155
|
+
// it still holds (a StrictMode effect replay included); the first run that
|
|
156
|
+
// finds anything else drops it for good, so every later run loads as before.
|
|
157
|
+
const bootSessionFor = useRef(bootSession ? authUrl : null);
|
|
147
158
|
const [error, setError] = useState(null);
|
|
148
159
|
const [remediationRequired, setRemediationRequired] = useState(null);
|
|
149
160
|
// Organization / workspace state
|
|
@@ -181,6 +192,16 @@ export function AuthProvider({ authUrl, client: externalClient, onAuthStateChang
|
|
|
181
192
|
// stale, previous-organization data over the newer one (see
|
|
182
193
|
// `switchOrganization`).
|
|
183
194
|
const sessionLoadGeneration = useRef(0);
|
|
195
|
+
// objectui#12809 — the member row asked for at the same time as
|
|
196
|
+
// `organization/list`, when the session already names its active
|
|
197
|
+
// organization. `get-active-member` answers for the session's active
|
|
198
|
+
// organization, so it does not have to wait for the list. Held with the org
|
|
199
|
+
// and user it was asked for and taken once, by the first
|
|
200
|
+
// `refreshActiveMember` for that same organization; anything else drops it
|
|
201
|
+
// (a switch, a sign-out, an active organization the list did not confirm).
|
|
202
|
+
// Not a cache: the answer is used for the moment it was asked in, never for
|
|
203
|
+
// a later question.
|
|
204
|
+
const bootMemberRef = useRef(null);
|
|
184
205
|
/**
|
|
185
206
|
* The ONE session loader. Runs on mount (below) and on every later
|
|
186
207
|
* re-resolution — `refreshSession` on the context, and the rotation
|
|
@@ -260,6 +281,9 @@ export function AuthProvider({ authUrl, client: externalClient, onAuthStateChang
|
|
|
260
281
|
}, [enabled, isPreviewMode, loadSession]);
|
|
261
282
|
// Load session on mount (only if auth is enabled and not in preview mode)
|
|
262
283
|
useEffect(() => {
|
|
284
|
+
if (isPreviewMode || !enabled || externalClient || bootSessionFor.current !== authUrl) {
|
|
285
|
+
bootSessionFor.current = null;
|
|
286
|
+
}
|
|
263
287
|
if (isPreviewMode) {
|
|
264
288
|
// Preview mode: simulate a user based on previewMode config
|
|
265
289
|
const role = previewMode.simulatedRole ?? 'admin';
|
|
@@ -303,10 +327,13 @@ export function AuthProvider({ authUrl, client: externalClient, onAuthStateChang
|
|
|
303
327
|
setIsLoading(false);
|
|
304
328
|
return;
|
|
305
329
|
}
|
|
330
|
+
// The first state already holds the preflight's answer (see `bootSession`).
|
|
331
|
+
if (bootSessionFor.current !== null)
|
|
332
|
+
return;
|
|
306
333
|
let cancelled = false;
|
|
307
334
|
loadSession(() => cancelled);
|
|
308
335
|
return () => { cancelled = true; };
|
|
309
|
-
}, [client, enabled, isPreviewMode, previewMode, loadSession]);
|
|
336
|
+
}, [client, enabled, isPreviewMode, previewMode, loadSession, authUrl, externalClient]);
|
|
310
337
|
// Notify on auth state changes
|
|
311
338
|
useEffect(() => {
|
|
312
339
|
onAuthStateChange?.({
|
|
@@ -334,11 +361,11 @@ export function AuthProvider({ authUrl, client: externalClient, onAuthStateChang
|
|
|
334
361
|
setIsLoading(false);
|
|
335
362
|
}
|
|
336
363
|
}, [client]);
|
|
337
|
-
const signUp = useCallback(async (name, email, password) => {
|
|
364
|
+
const signUp = useCallback(async (name, email, password, callbackURL) => {
|
|
338
365
|
setIsLoading(true);
|
|
339
366
|
setError(null);
|
|
340
367
|
try {
|
|
341
|
-
const result = await client.signUp({ name, email, password });
|
|
368
|
+
const result = await client.signUp({ name, email, password, ...(callbackURL ? { callbackURL } : {}) });
|
|
342
369
|
if (result.requiresVerification) {
|
|
343
370
|
// No session was created — the user must verify their email before
|
|
344
371
|
// they can sign in. Leave auth state untouched so the caller can
|
|
@@ -377,6 +404,7 @@ export function AuthProvider({ authUrl, client: externalClient, onAuthStateChang
|
|
|
377
404
|
// this file: drop the in-memory reference and the stored id together.
|
|
378
405
|
setOrganizations([]);
|
|
379
406
|
setActiveOrganization(null);
|
|
407
|
+
bootMemberRef.current = null;
|
|
380
408
|
}
|
|
381
409
|
catch (err) {
|
|
382
410
|
setError(err instanceof Error ? err : new Error(String(err)));
|
|
@@ -547,7 +575,17 @@ export function AuthProvider({ authUrl, client: externalClient, onAuthStateChang
|
|
|
547
575
|
}
|
|
548
576
|
}, [client]);
|
|
549
577
|
// --- Organization methods ---
|
|
550
|
-
|
|
578
|
+
/**
|
|
579
|
+
* `sessionActiveOrgId` — objectui#12809. Passed only by the load that follows
|
|
580
|
+
* a `get-session` answer (the effect below), and only when that answer
|
|
581
|
+
* carries the organization plugin's `activeOrganizationId`. The session
|
|
582
|
+
* already names the server's active organization, so it is named from that
|
|
583
|
+
* id and the list answer: no `get-full-organization` round trip, whose extra
|
|
584
|
+
* fields (members, invitations) nothing the console reads. Every other caller
|
|
585
|
+
* (a mutation, `refreshOrganizations()` on the context) asks the server, as
|
|
586
|
+
* before: the session in hand may predate what that mutation changed.
|
|
587
|
+
*/
|
|
588
|
+
const refreshOrganizations = useCallback(async (isCancelled, sessionActiveOrgId) => {
|
|
551
589
|
if (!enabled || isPreviewMode)
|
|
552
590
|
return;
|
|
553
591
|
setIsOrganizationsLoading(true);
|
|
@@ -559,12 +597,19 @@ export function AuthProvider({ authUrl, client: externalClient, onAuthStateChang
|
|
|
559
597
|
// If no active org is set but orgs exist, try to get active from server
|
|
560
598
|
if (orgs.length > 0 && !activeOrganization) {
|
|
561
599
|
let active = null;
|
|
562
|
-
|
|
563
|
-
|
|
600
|
+
if (sessionActiveOrgId !== undefined) {
|
|
601
|
+
// An id the list does not carry is no membership of this user's: the
|
|
602
|
+
// server's full-organization read refuses it the same way.
|
|
603
|
+
active = (sessionActiveOrgId && orgs.find((o) => o.id === sessionActiveOrgId)) || null;
|
|
564
604
|
}
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
605
|
+
else {
|
|
606
|
+
try {
|
|
607
|
+
active = await client.getActiveOrganization();
|
|
608
|
+
}
|
|
609
|
+
catch {
|
|
610
|
+
// No active org set — that's fine
|
|
611
|
+
active = null;
|
|
612
|
+
}
|
|
568
613
|
}
|
|
569
614
|
// Single-membership repair (ADR-0081): a session created before the
|
|
570
615
|
// server-side active-org stamp existed carries no active org even
|
|
@@ -579,10 +624,27 @@ export function AuthProvider({ authUrl, client: externalClient, onAuthStateChang
|
|
|
579
624
|
active = null;
|
|
580
625
|
}
|
|
581
626
|
}
|
|
582
|
-
if (
|
|
627
|
+
if (isCancelled?.())
|
|
628
|
+
return orgs;
|
|
629
|
+
if (bootMemberRef.current && bootMemberRef.current.orgId !== active?.id) {
|
|
630
|
+
bootMemberRef.current = null;
|
|
631
|
+
}
|
|
632
|
+
if (active) {
|
|
583
633
|
setActiveOrganization(active);
|
|
584
634
|
ActiveOrganizationStorage.set(active.id);
|
|
585
635
|
}
|
|
636
|
+
else if (activeOrgIdRef.current === null) {
|
|
637
|
+
// objectui#12698 — the server answered "no active organization"
|
|
638
|
+
// (and, at one membership, refused the repair above). The stored id
|
|
639
|
+
// is a cache of a SERVER-owned fact, so it goes with that answer:
|
|
640
|
+
// left in place it keeps stamping `X-Tenant-ID` and keying the
|
|
641
|
+
// metadata seed cache for an organization this session does not
|
|
642
|
+
// hold — e.g. the one a refused `set-active` cleared server-side
|
|
643
|
+
// (better-auth nulls the session's active org before it refuses a
|
|
644
|
+
// non-member). The `activeOrgIdRef` guard keeps a user switch that
|
|
645
|
+
// landed while this request was in flight from being wiped.
|
|
646
|
+
ActiveOrganizationStorage.clear();
|
|
647
|
+
}
|
|
586
648
|
}
|
|
587
649
|
return orgs;
|
|
588
650
|
}
|
|
@@ -634,8 +696,12 @@ export function AuthProvider({ authUrl, client: externalClient, onAuthStateChang
|
|
|
634
696
|
// A switch re-opens the question: until `getActiveMember()` answers for the
|
|
635
697
|
// NEW org, `activeMember` still holds the OLD org's row.
|
|
636
698
|
setActiveMemberResolved(false);
|
|
699
|
+
const boot = bootMemberRef.current;
|
|
700
|
+
bootMemberRef.current = null;
|
|
637
701
|
try {
|
|
638
|
-
const member =
|
|
702
|
+
const member = boot && boot.orgId === activeOrganization.id
|
|
703
|
+
? await boot.answer
|
|
704
|
+
: await client.getActiveMember();
|
|
639
705
|
setActiveMember(member);
|
|
640
706
|
}
|
|
641
707
|
catch (err) {
|
|
@@ -682,11 +748,27 @@ export function AuthProvider({ authUrl, client: externalClient, onAuthStateChang
|
|
|
682
748
|
refreshActiveMember();
|
|
683
749
|
}, [refreshActiveMember]);
|
|
684
750
|
// Load organizations once user is authenticated
|
|
751
|
+
//
|
|
752
|
+
// objectui#12809 — a session answer that carries the organization plugin's
|
|
753
|
+
// `activeOrganizationId` already names the server's active organization, so
|
|
754
|
+
// the member row is asked for now, beside `organization/list`, instead of
|
|
755
|
+
// after the active organization lands. A StrictMode effect replay finds the
|
|
756
|
+
// same question already asked and does not ask again.
|
|
685
757
|
useEffect(() => {
|
|
686
758
|
if (!(user && enabled && !isPreviewMode))
|
|
687
759
|
return;
|
|
688
760
|
let cancelled = false;
|
|
689
|
-
|
|
761
|
+
const sessionActiveOrgId = session?.activeOrganizationId;
|
|
762
|
+
if (typeof sessionActiveOrgId === 'string' && !activeOrganization) {
|
|
763
|
+
const asked = bootMemberRef.current;
|
|
764
|
+
if (!asked || asked.orgId !== sessionActiveOrgId || asked.userId !== user.id) {
|
|
765
|
+
const answer = client.getActiveMember();
|
|
766
|
+
// Taken later, or dropped: either way it must not surface as unhandled.
|
|
767
|
+
answer.catch(() => undefined);
|
|
768
|
+
bootMemberRef.current = { orgId: sessionActiveOrgId, userId: user.id, answer };
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
refreshOrganizations(() => cancelled, sessionActiveOrgId);
|
|
690
772
|
return () => { cancelled = true; };
|
|
691
773
|
}, [user, enabled, isPreviewMode]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
692
774
|
// objectui#5750 — keep `activeOrgIdRef` current for every path that can
|
|
@@ -728,6 +810,7 @@ export function AuthProvider({ authUrl, client: externalClient, onAuthStateChang
|
|
|
728
810
|
const switchOrganization = useCallback(async (orgId) => {
|
|
729
811
|
setError(null);
|
|
730
812
|
const previousOrgId = activeOrgIdRef.current;
|
|
813
|
+
bootMemberRef.current = null;
|
|
731
814
|
sessionLoadInFlight.current = true;
|
|
732
815
|
try {
|
|
733
816
|
const org = await client.setActiveOrganization(orgId);
|
package/dist/LoginForm.d.ts
CHANGED
|
@@ -22,8 +22,18 @@ export interface LoginFormLabels {
|
|
|
22
22
|
submittingButton?: string;
|
|
23
23
|
noAccountText?: string;
|
|
24
24
|
signUpText?: string;
|
|
25
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Divider label between the social provider buttons and the email/password
|
|
27
|
+
* form (defaults to "or continue with email"). Rendered only when the server
|
|
28
|
+
* reports at least one social provider, and only while that form is shown:
|
|
29
|
+
* under SSO-only mode it appears once "Use a password instead" reveals it.
|
|
30
|
+
*/
|
|
26
31
|
orText?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Social provider button label (defaults to "Continue with {provider}");
|
|
34
|
+
* `{provider}` is replaced with the provider's display name.
|
|
35
|
+
*/
|
|
36
|
+
socialButton?: string;
|
|
27
37
|
/** Label for the SSO sign-in button (defaults to "Sign in with SSO") */
|
|
28
38
|
ssoButton?: string;
|
|
29
39
|
/**
|
|
@@ -52,19 +62,32 @@ export interface LoginFormLabels {
|
|
|
52
62
|
usePasswordSignInText?: string;
|
|
53
63
|
/** Description shown above the form in SSO-only mode (defaults to "Sign in with your organization's single sign-on"). */
|
|
54
64
|
ssoOnlyDescription?: string;
|
|
65
|
+
/** Accessible name of the password field's reveal toggle while the password is hidden (defaults to "Show password"). */
|
|
66
|
+
showPasswordText?: string;
|
|
67
|
+
/** Accessible name of the password field's reveal toggle while the password is shown (defaults to "Hide password"). */
|
|
68
|
+
hidePasswordText?: string;
|
|
55
69
|
}
|
|
56
70
|
export interface LoginFormProps {
|
|
57
71
|
/** Callback on successful login */
|
|
58
72
|
onSuccess?: () => void;
|
|
59
73
|
/** Callback on login error */
|
|
60
74
|
onError?: (error: Error) => void;
|
|
61
|
-
/**
|
|
75
|
+
/**
|
|
76
|
+
* Link to registration page. The "Sign up" link renders only when this is
|
|
77
|
+
* set: there is no default, so leaving it out (or passing `undefined`, as a
|
|
78
|
+
* caller does when the server reports `emailPassword.disableSignUp`) renders
|
|
79
|
+
* no link.
|
|
80
|
+
*/
|
|
62
81
|
registerUrl?: string;
|
|
63
82
|
/** Link to forgot password page */
|
|
64
83
|
forgotPasswordUrl?: string;
|
|
65
84
|
/** Custom title */
|
|
66
85
|
title?: string;
|
|
67
|
-
/**
|
|
86
|
+
/**
|
|
87
|
+
* Custom description. Not shown while the server's auth config is still
|
|
88
|
+
* resolving, since it may describe a password form the server does not
|
|
89
|
+
* offer; under SSO-only mode `labels.ssoOnlyDescription` replaces it.
|
|
90
|
+
*/
|
|
68
91
|
description?: string;
|
|
69
92
|
/** Custom icon shown above the title (defaults to a small lock disc) */
|
|
70
93
|
icon?: React.ReactNode;
|
|
@@ -81,6 +104,14 @@ export interface LoginFormProps {
|
|
|
81
104
|
errorMessages?: Record<string, string>;
|
|
82
105
|
/** Hide the icon disc above the form title. Defaults to false. */
|
|
83
106
|
hideIcon?: boolean;
|
|
107
|
+
/**
|
|
108
|
+
* Render no header — no icon disc, no title, no description — because the
|
|
109
|
+
* host draws its own above the form. Defaults to false, which renders the
|
|
110
|
+
* header exactly as before. Under SSO-only ("enforced") mode the form still
|
|
111
|
+
* says how to sign in: the SSO-only description, which the header would
|
|
112
|
+
* otherwise carry, renders as one muted line above the federated buttons.
|
|
113
|
+
*/
|
|
114
|
+
hideHeader?: boolean;
|
|
84
115
|
}
|
|
85
116
|
/**
|
|
86
117
|
* Login form component with email/password authentication.
|
|
@@ -96,5 +127,5 @@ export interface LoginFormProps {
|
|
|
96
127
|
* />
|
|
97
128
|
* ```
|
|
98
129
|
*/
|
|
99
|
-
export declare function LoginForm({ onSuccess, onError, registerUrl, forgotPasswordUrl, title, description, icon, hideIcon, linkComponent: LinkComp, labels, errorMessages, }: LoginFormProps): React.JSX.Element;
|
|
130
|
+
export declare function LoginForm({ onSuccess, onError, registerUrl, forgotPasswordUrl, title, description, icon, hideIcon, hideHeader, linkComponent: LinkComp, labels, errorMessages, }: LoginFormProps): React.JSX.Element;
|
|
100
131
|
//# sourceMappingURL=LoginForm.d.ts.map
|
package/dist/LoginForm.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"LoginForm.d.ts","sourceRoot":"","sources":["../src/LoginForm.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAA8B,MAAM,OAAO,CAAC;AAInD,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"LoginForm.d.ts","sourceRoot":"","sources":["../src/LoginForm.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAA8B,MAAM,OAAO,CAAC;AAInD,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,YAAY,CAAC;AAWzD,4CAA4C;AAC5C,MAAM,WAAW,eAAe;IAC9B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,6FAA6F;IAC7F,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,6DAA6D;IAC7D,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,wEAAwE;IACxE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,8GAA8G;IAC9G,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,wFAAwF;IACxF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gFAAgF;IAChF,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,2EAA2E;IAC3E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,4EAA4E;IAC5E,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,oEAAoE;IACpE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,gGAAgG;IAChG,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,8FAA8F;IAC9F,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,iGAAiG;IACjG,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,yHAAyH;IACzH,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,wHAAwH;IACxH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,uHAAuH;IACvH,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,cAAc;IAC7B,mCAAmC;IACnC,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB,8BAA8B;IAC9B,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;IACjC;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,mCAAmC;IACnC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,mBAAmB;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,wEAAwE;IACxE,IAAI,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACvB,0EAA0E;IAC1E,aAAa,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC,sBAAsB,CAAC,CAAC;IAC5D,uCAAuC;IACvC,MAAM,CAAC,EAAE,eAAe,CAAC;IACzB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACvC,kEAAkE;IAClE,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAwDD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,SAAS,CAAC,EACxB,SAAS,EACT,OAAO,EACP,WAAW,EACX,iBAAsC,EACtC,KAAiC,EACjC,WAAyD,EACzD,IAAI,EACJ,QAAgB,EAChB,UAAkB,EAClB,aAAa,EAAE,QAAsB,EACrC,MAAW,EACX,aAAa,GACd,EAAE,cAAc,qBAkehB"}
|
package/dist/LoginForm.js
CHANGED
|
@@ -12,6 +12,8 @@ import { SocialSignInButtons } from './SocialSignInButtons.js';
|
|
|
12
12
|
import { looksLikePhoneIdentifier, normalizePhoneIdentifier } from './phone-identifier.js';
|
|
13
13
|
import { AUTH_FIELD_LABEL_CLASS, AUTH_INPUT_CLASS, AUTH_LINK_CLASS, AUTH_PRIMARY_BUTTON_CLASS, AuthErrorBanner, AuthFormHeader, AuthSpinner, } from './authStyles.js';
|
|
14
14
|
const DefaultLink = ({ href, className, children }) => (_jsx("a", { href: href, className: className, children: children }));
|
|
15
|
+
const EyeIcon = () => (_jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", className: "h-4 w-4", "aria-hidden": "true", children: [_jsx("path", { d: "M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" }), _jsx("circle", { cx: "12", cy: "12", r: "3" })] }));
|
|
16
|
+
const EyeOffIcon = () => (_jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", className: "h-4 w-4", "aria-hidden": "true", children: [_jsx("path", { d: "M10.73 5.08A10.4 10.4 0 0 1 12 5c7 0 10 7 10 7a13.2 13.2 0 0 1-1.67 2.68" }), _jsx("path", { d: "M6.61 6.61A13.5 13.5 0 0 0 2 12s3 7 10 7a9.7 9.7 0 0 0 5.39-1.61" }), _jsx("path", { d: "M9.88 9.88a3 3 0 1 0 4.24 4.24" }), _jsx("path", { d: "m2 2 20 20" })] }));
|
|
15
17
|
const DefaultLockIcon = () => (_jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round", className: "h-6 w-6", "aria-hidden": "true", children: [_jsx("rect", { x: "3", y: "11", width: "18", height: "11", rx: "2" }), _jsx("path", { d: "M7 11V7a5 5 0 0 1 10 0v4" })] }));
|
|
16
18
|
/**
|
|
17
19
|
* Login form component with email/password authentication.
|
|
@@ -27,10 +29,13 @@ const DefaultLockIcon = () => (_jsxs("svg", { viewBox: "0 0 24 24", fill: "none"
|
|
|
27
29
|
* />
|
|
28
30
|
* ```
|
|
29
31
|
*/
|
|
30
|
-
export function LoginForm({ onSuccess, onError, registerUrl
|
|
32
|
+
export function LoginForm({ onSuccess, onError, registerUrl, forgotPasswordUrl = '/forgot-password', title = 'Sign in to your account', description = 'Enter your email and password to continue', icon, hideIcon = false, hideHeader = false, linkComponent: LinkComp = DefaultLink, labels = {}, errorMessages, }) {
|
|
31
33
|
const { signIn, isLoading, getAuthConfig, sendPhoneOtp, signInWithPhoneOtp, signInWithPhonePassword } = useAuth();
|
|
32
34
|
const [email, setEmail] = useState('');
|
|
33
35
|
const [password, setPassword] = useState('');
|
|
36
|
+
// The password field's reveal toggle. Local and ephemeral on purpose: a
|
|
37
|
+
// revealed password must not survive a remount or reach any URL or storage.
|
|
38
|
+
const [passwordVisible, setPasswordVisible] = useState(false);
|
|
34
39
|
const [error, setError] = useState(null);
|
|
35
40
|
// Phone-OTP sign-in (framework#2780). Only offered when the server reports
|
|
36
41
|
// `features.phoneNumberOtp` — i.e. the phoneNumber plugin is on AND a
|
|
@@ -117,7 +122,6 @@ export function LoginForm({ onSuccess, onError, registerUrl = '/register', forgo
|
|
|
117
122
|
submittingButton: labels.submittingButton ?? 'Signing in…',
|
|
118
123
|
noAccountText: labels.noAccountText ?? "Don't have an account?",
|
|
119
124
|
signUpText: labels.signUpText ?? 'Sign up',
|
|
120
|
-
orText: labels.orText ?? 'or',
|
|
121
125
|
ssoButton: labels.ssoButton ?? 'Sign in with SSO',
|
|
122
126
|
usePasswordText: labels.usePasswordText ?? 'Use a password instead',
|
|
123
127
|
backToSsoText: labels.backToSsoText ?? 'Back to single sign-on',
|
|
@@ -130,6 +134,8 @@ export function LoginForm({ onSuccess, onError, registerUrl = '/register', forgo
|
|
|
130
134
|
resendOtpCountdownText: labels.resendOtpCountdownText ?? 'Resend in {seconds}s',
|
|
131
135
|
usePhoneOtpText: labels.usePhoneOtpText ?? 'Sign in with verification code',
|
|
132
136
|
usePasswordSignInText: labels.usePasswordSignInText ?? 'Sign in with password instead',
|
|
137
|
+
showPasswordText: labels.showPasswordText ?? 'Show password',
|
|
138
|
+
hidePasswordText: labels.hidePasswordText ?? 'Hide password',
|
|
133
139
|
};
|
|
134
140
|
// Tick the resend cooldown down once per second while it's armed.
|
|
135
141
|
useEffect(() => {
|
|
@@ -234,10 +240,16 @@ export function LoginForm({ onSuccess, onError, registerUrl = '/register', forgo
|
|
|
234
240
|
setSsoSubmitting(false);
|
|
235
241
|
}
|
|
236
242
|
};
|
|
237
|
-
|
|
243
|
+
// The SSO-only view: enforced mode with the break-glass form still closed.
|
|
244
|
+
const ssoOnlyView = ssoEnforced && !showPasswordFallback;
|
|
245
|
+
return (_jsxs("div", { className: `mx-auto flex w-full flex-col justify-center sm:max-w-[400px] ${hideHeader ? 'space-y-4' : 'space-y-7'}`, children: [hideHeader ? (ssoOnlyView ? (_jsx("p", { className: "text-[13px] leading-relaxed text-muted-foreground", children: l.ssoOnlyDescription })) : null) : (_jsx(AuthFormHeader, { icon: hideIcon ? undefined : (icon ?? _jsx(DefaultLockIcon, {})), title: title,
|
|
246
|
+
// No description while the config resolves: `ssoEnforced` is not
|
|
247
|
+
// known yet, and the password form's copy would greet an SSO-only
|
|
248
|
+
// deployment (objectui#12682).
|
|
249
|
+
description: configPending ? undefined : ssoOnlyView ? l.ssoOnlyDescription : description })), _jsx("div", { className: "space-y-5", children: configPending ? (_jsx("div", { className: "flex justify-center py-10", role: "status", "aria-live": "polite", "data-testid": "login-config-loading", children: _jsx(AuthSpinner, {}) })) : (_jsxs(_Fragment, { children: [_jsx(SocialSignInButtons, { mode: "sign-in", dividerText: labels.orText, hideDivider: !passwordFormVisible, buttonText: labels.socialButton, onProvidersResolved: (hasProviders) => setHasSocialProviders(hasProviders) }), passwordFormVisible ? (mode === 'phone-otp' ? (_jsxs("form", { onSubmit: handleSubmit, className: "space-y-4", children: [error && _jsx(AuthErrorBanner, { message: error }), _jsxs("div", { className: "space-y-2", children: [_jsx("label", { htmlFor: "login-phone", className: AUTH_FIELD_LABEL_CLASS, children: l.phoneLabel }), _jsx("input", { id: "login-phone", type: "tel", placeholder: l.phonePlaceholder, value: phone, onChange: (e) => setPhone(e.target.value), required: true, autoComplete: "tel", disabled: isLoading, className: AUTH_INPUT_CLASS })] }), _jsxs("div", { className: "space-y-2", children: [_jsx("label", { htmlFor: "login-otp", className: AUTH_FIELD_LABEL_CLASS, children: l.otpCodeLabel }), _jsxs("div", { className: "flex gap-2", children: [_jsx("input", { id: "login-otp", type: "text", inputMode: "numeric", placeholder: l.otpCodePlaceholder, value: otpCode, onChange: (e) => setOtpCode(e.target.value), required: true, autoComplete: "one-time-code", disabled: isLoading, className: AUTH_INPUT_CLASS }), _jsxs("button", { type: "button", onClick: handleSendOtp, disabled: otpSending || otpCooldown > 0 || !phone.trim() || isLoading, className: "shrink-0 rounded-md border border-input bg-background px-3 py-2 text-sm font-medium transition-colors hover:bg-accent disabled:opacity-50", children: [otpSending && _jsx(AuthSpinner, {}), otpCooldown > 0
|
|
238
250
|
? l.resendOtpCountdownText.replace('{seconds}', String(otpCooldown))
|
|
239
251
|
: l.sendOtpButton] })] })] }), _jsxs("button", { type: "submit", disabled: isLoading || !otpSent, className: AUTH_PRIMARY_BUTTON_CLASS, children: [isLoading && _jsx(AuthSpinner, {}), isLoading ? l.submittingButton : l.submitButton] }), _jsx("button", { type: "button", onClick: () => switchMode('password'), className: "w-full text-center text-xs text-muted-foreground underline-offset-4 transition-colors hover:text-primary hover:underline", children: l.usePasswordSignInText })] })) : (_jsxs("form", { onSubmit: handleSubmit, className: "space-y-4", children: [error && _jsx(AuthErrorBanner, { message: error }), _jsxs("div", { className: "space-y-2", children: [_jsx("label", { htmlFor: "login-email", className: AUTH_FIELD_LABEL_CLASS, children: phonePasswordEnabled ? l.emailOrPhoneLabel : l.emailLabel }), _jsx("input", { id: "login-email",
|
|
240
252
|
// `text` (not `email`) when a phone number is also accepted, so the
|
|
241
253
|
// browser doesn't reject a phone-shaped value on native validation.
|
|
242
|
-
type: phonePasswordEnabled ? 'text' : 'email', inputMode: phonePasswordEnabled ? 'text' : 'email', placeholder: phonePasswordEnabled ? l.emailOrPhonePlaceholder : l.emailPlaceholder, value: email, onChange: (e) => setEmail(e.target.value), required: true, autoComplete: "username", disabled: isLoading, className: AUTH_INPUT_CLASS })] }), _jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("label", { htmlFor: "login-password", className: AUTH_FIELD_LABEL_CLASS, children: l.passwordLabel }), forgotPasswordUrl && (_jsx(LinkComp, { href: forgotPasswordUrl, className: "text-xs text-muted-foreground underline-offset-4 transition-colors hover:text-primary hover:underline", children: l.forgotPasswordText }))] }), _jsx("input", { id: "login-password", type:
|
|
254
|
+
type: phonePasswordEnabled ? 'text' : 'email', inputMode: phonePasswordEnabled ? 'text' : 'email', placeholder: phonePasswordEnabled ? l.emailOrPhonePlaceholder : l.emailPlaceholder, value: email, onChange: (e) => setEmail(e.target.value), required: true, autoComplete: "username", disabled: isLoading, className: AUTH_INPUT_CLASS })] }), _jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("label", { htmlFor: "login-password", className: AUTH_FIELD_LABEL_CLASS, children: l.passwordLabel }), forgotPasswordUrl && (_jsx(LinkComp, { href: forgotPasswordUrl, className: "text-xs text-muted-foreground underline-offset-4 transition-colors hover:text-primary hover:underline", children: l.forgotPasswordText }))] }), _jsxs("div", { className: "relative", children: [_jsx("input", { id: "login-password", type: passwordVisible ? 'text' : 'password', placeholder: l.passwordPlaceholder, value: password, onChange: (e) => setPassword(e.target.value), required: true, autoComplete: "current-password", disabled: isLoading, className: `${AUTH_INPUT_CLASS} pr-10` }), _jsx("button", { type: "button", "data-testid": "login-password-toggle", onClick: () => setPasswordVisible((v) => !v), "aria-label": passwordVisible ? l.hidePasswordText : l.showPasswordText, "aria-controls": "login-password", disabled: isLoading, className: "absolute right-1.5 top-1/2 inline-flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 disabled:pointer-events-none disabled:opacity-50", children: passwordVisible ? _jsx(EyeOffIcon, {}) : _jsx(EyeIcon, {}) })] })] }), _jsxs("button", { type: "submit", disabled: isLoading, className: AUTH_PRIMARY_BUTTON_CLASS, children: [isLoading && _jsx(AuthSpinner, {}), isLoading ? l.submittingButton : l.submitButton] }), ssoEnabled && (_jsxs("button", { type: "button", onClick: handleSso, disabled: isLoading || ssoSubmitting, "aria-busy": ssoSubmitting, className: "flex h-10 w-full items-center justify-center gap-2 rounded-lg border border-input bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent disabled:opacity-50", children: [ssoSubmitting && _jsx(AuthSpinner, {}), l.ssoButton] })), phoneOtpEnabled && (_jsx("button", { type: "button", onClick: () => switchMode('phone-otp'), className: "w-full text-center text-xs text-muted-foreground underline-offset-4 transition-colors hover:text-primary hover:underline", children: l.usePhoneOtpText })), ssoEnforced && showPasswordFallback && (_jsx("button", { type: "button", onClick: () => setShowPasswordFallback(false), className: "w-full text-center text-xs text-muted-foreground underline-offset-4 transition-colors hover:text-primary hover:underline", children: l.backToSsoText }))] }))) : (_jsxs("div", { className: "space-y-4", children: [error && _jsx(AuthErrorBanner, { message: error }), _jsx("button", { type: "button", onClick: () => setShowPasswordFallback(true), className: "w-full text-center text-xs text-muted-foreground underline-offset-4 transition-colors hover:text-primary hover:underline", children: l.usePasswordText })] }))] })) }), registerUrl && !ssoEnforced && (_jsxs("p", { className: "px-8 text-center text-[13px] text-muted-foreground", children: [l.noAccountText, ' ', _jsx(LinkComp, { href: registerUrl, className: AUTH_LINK_CLASS, children: l.signUpText })] }))] }));
|
|
243
255
|
}
|
package/dist/RegisterForm.d.ts
CHANGED
|
@@ -23,8 +23,17 @@ export interface RegisterFormLabels {
|
|
|
23
23
|
submittingButton?: string;
|
|
24
24
|
hasAccountText?: string;
|
|
25
25
|
signInText?: string;
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Divider label between the social provider buttons and the email/password
|
|
28
|
+
* form (defaults to "or continue with email"). Rendered only when the server
|
|
29
|
+
* reports at least one social provider.
|
|
30
|
+
*/
|
|
27
31
|
orText?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Social provider button label (defaults to "Sign up with {provider}");
|
|
34
|
+
* `{provider}` is replaced with the provider's display name.
|
|
35
|
+
*/
|
|
36
|
+
socialButton?: string;
|
|
28
37
|
}
|
|
29
38
|
export interface RegisterFormProps {
|
|
30
39
|
/** Callback on successful registration AND auto sign-in. Not called when the
|
|
@@ -34,6 +43,16 @@ export interface RegisterFormProps {
|
|
|
34
43
|
* sign-in on email verification. The page should swap the form for a
|
|
35
44
|
* "check your inbox" confirmation. */
|
|
36
45
|
onVerificationRequired?: (email: string) => void;
|
|
46
|
+
/**
|
|
47
|
+
* Where the email-verification link should land once the address is
|
|
48
|
+
* verified — e.g. the invitation page the user registered from
|
|
49
|
+
* (objectui#10893). Forwarded to `signUp` as better-auth's sign-up
|
|
50
|
+
* `callbackURL`; see `SignUpData.callbackURL` for the only shapes the server
|
|
51
|
+
* accepts (a document-relative `./…` fails the whole sign-up). Omitted, the
|
|
52
|
+
* server default (`/`) applies. Only matters when the server requires email
|
|
53
|
+
* verification — an auto-signed-in registration calls `onSuccess` instead.
|
|
54
|
+
*/
|
|
55
|
+
verificationCallbackURL?: string;
|
|
37
56
|
/** Callback on registration error */
|
|
38
57
|
onError?: (error: Error) => void;
|
|
39
58
|
/** Link to login page */
|
|
@@ -70,5 +89,5 @@ export interface RegisterFormProps {
|
|
|
70
89
|
* />
|
|
71
90
|
* ```
|
|
72
91
|
*/
|
|
73
|
-
export declare function RegisterForm({ onSuccess, onVerificationRequired, onError, loginUrl, title, description, icon, hideIcon, linkComponent: LinkComp, labels, errorMessages, }: RegisterFormProps): React.JSX.Element;
|
|
92
|
+
export declare function RegisterForm({ onSuccess, onVerificationRequired, verificationCallbackURL, onError, loginUrl, title, description, icon, hideIcon, linkComponent: LinkComp, labels, errorMessages, }: RegisterFormProps): React.JSX.Element;
|
|
74
93
|
//# sourceMappingURL=RegisterForm.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"RegisterForm.d.ts","sourceRoot":"","sources":["../src/RegisterForm.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAmB,MAAM,OAAO,CAAC;AAGxC,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,YAAY,CAAC;AAWzD,+CAA+C;AAC/C,MAAM,WAAW,kBAAkB;IACjC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,0BAA0B,CAAC,EAAE,MAAM,CAAC;IACpC,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB
|
|
1
|
+
{"version":3,"file":"RegisterForm.d.ts","sourceRoot":"","sources":["../src/RegisterForm.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAmB,MAAM,OAAO,CAAC;AAGxC,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,YAAY,CAAC;AAWzD,+CAA+C;AAC/C,MAAM,WAAW,kBAAkB;IACjC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,0BAA0B,CAAC,EAAE,MAAM,CAAC;IACpC,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,iBAAiB;IAChC;sFACkF;IAClF,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB;;2CAEuC;IACvC,sBAAsB,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACjD;;;;;;;;OAQG;IACH,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,qCAAqC;IACrC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;IACjC,yBAAyB;IACzB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,mBAAmB;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yBAAyB;IACzB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6EAA6E;IAC7E,IAAI,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACvB,0EAA0E;IAC1E,aAAa,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC,sBAAsB,CAAC,CAAC;IAC5D,uCAAuC;IACvC,MAAM,CAAC,EAAE,kBAAkB,CAAC;IAC5B;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACvC,kEAAkE;IAClE,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAwBD;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,EAC3B,SAAS,EACT,sBAAsB,EACtB,uBAAuB,EACvB,OAAO,EACP,QAAmB,EACnB,KAA2B,EAC3B,WAAqD,EACrD,IAAI,EACJ,QAAgB,EAChB,aAAa,EAAE,QAAsB,EACrC,MAAW,EACX,aAAa,GACd,EAAE,iBAAiB,qBA4KnB"}
|
package/dist/RegisterForm.js
CHANGED
|
@@ -25,7 +25,7 @@ const DefaultUserPlusIcon = () => (_jsxs("svg", { viewBox: "0 0 24 24", fill: "n
|
|
|
25
25
|
* />
|
|
26
26
|
* ```
|
|
27
27
|
*/
|
|
28
|
-
export function RegisterForm({ onSuccess, onVerificationRequired, onError, loginUrl = '/login', title = 'Create an account', description = 'Enter your information to get started', icon, hideIcon = false, linkComponent: LinkComp = DefaultLink, labels = {}, errorMessages, }) {
|
|
28
|
+
export function RegisterForm({ onSuccess, onVerificationRequired, verificationCallbackURL, onError, loginUrl = '/login', title = 'Create an account', description = 'Enter your information to get started', icon, hideIcon = false, linkComponent: LinkComp = DefaultLink, labels = {}, errorMessages, }) {
|
|
29
29
|
const { signUp, isLoading } = useAuth();
|
|
30
30
|
const [name, setName] = useState('');
|
|
31
31
|
const [email, setEmail] = useState('');
|
|
@@ -47,7 +47,6 @@ export function RegisterForm({ onSuccess, onVerificationRequired, onError, login
|
|
|
47
47
|
submittingButton: labels.submittingButton ?? 'Creating account…',
|
|
48
48
|
hasAccountText: labels.hasAccountText ?? 'Already have an account?',
|
|
49
49
|
signInText: labels.signInText ?? 'Sign in',
|
|
50
|
-
orText: labels.orText ?? 'or',
|
|
51
50
|
};
|
|
52
51
|
const handleSubmit = async (e) => {
|
|
53
52
|
e.preventDefault();
|
|
@@ -64,7 +63,7 @@ export function RegisterForm({ onSuccess, onVerificationRequired, onError, login
|
|
|
64
63
|
// that the server rejects as "Invalid email" (#3238) — trim before use.
|
|
65
64
|
const trimmedEmail = email.trim();
|
|
66
65
|
try {
|
|
67
|
-
const result = await signUp(name, trimmedEmail, password);
|
|
66
|
+
const result = await signUp(name, trimmedEmail, password, verificationCallbackURL);
|
|
68
67
|
if (result?.requiresVerification) {
|
|
69
68
|
onVerificationRequired?.(trimmedEmail);
|
|
70
69
|
return;
|
|
@@ -78,5 +77,5 @@ export function RegisterForm({ onSuccess, onVerificationRequired, onError, login
|
|
|
78
77
|
onError?.(authError);
|
|
79
78
|
}
|
|
80
79
|
};
|
|
81
|
-
return (_jsxs("div", { className: "mx-auto flex w-full flex-col justify-center space-y-7 sm:w-[400px]", children: [_jsx(AuthFormHeader, { icon: hideIcon ? undefined : (icon ?? _jsx(DefaultUserPlusIcon, {})), title: title, description: description }), _jsxs("div", { className: "space-y-5", children: [_jsx(SocialSignInButtons, { mode: "sign-up" }), _jsxs("form", { onSubmit: handleSubmit, className: "space-y-4", children: [error && _jsx(AuthErrorBanner, { message: error }), _jsxs("div", { className: "space-y-2", children: [_jsx("label", { htmlFor: "register-name", className: AUTH_FIELD_LABEL_CLASS, children: l.nameLabel }), _jsx("input", { id: "register-name", type: "text", placeholder: l.namePlaceholder, value: name, onChange: (e) => setName(e.target.value), required: true, autoComplete: "name", disabled: isLoading, className: AUTH_INPUT_CLASS })] }), _jsxs("div", { className: "space-y-2", children: [_jsx("label", { htmlFor: "register-email", className: AUTH_FIELD_LABEL_CLASS, children: l.emailLabel }), _jsx("input", { id: "register-email", type: "email", placeholder: l.emailPlaceholder, value: email, onChange: (e) => setEmail(e.target.value), required: true, autoComplete: "email", disabled: isLoading, className: AUTH_INPUT_CLASS })] }), _jsxs("div", { className: "space-y-2", children: [_jsx("label", { htmlFor: "register-password", className: AUTH_FIELD_LABEL_CLASS, children: l.passwordLabel }), _jsx("input", { id: "register-password", type: "password", placeholder: l.passwordPlaceholder, value: password, onChange: (e) => setPassword(e.target.value), required: true, minLength: 8, autoComplete: "new-password", disabled: isLoading, className: AUTH_INPUT_CLASS })] }), _jsxs("div", { className: "space-y-2", children: [_jsx("label", { htmlFor: "register-confirm-password", className: AUTH_FIELD_LABEL_CLASS, children: l.confirmPasswordLabel }), _jsx("input", { id: "register-confirm-password", type: "password", placeholder: l.confirmPasswordPlaceholder, value: confirmPassword, onChange: (e) => setConfirmPassword(e.target.value), required: true, minLength: 8, autoComplete: "new-password", disabled: isLoading, className: AUTH_INPUT_CLASS })] }), _jsxs("button", { type: "submit", disabled: isLoading, className: AUTH_PRIMARY_BUTTON_CLASS, children: [isLoading && _jsx(AuthSpinner, {}), isLoading ? l.submittingButton : l.submitButton] })] })] }), loginUrl && (_jsxs("p", { className: "px-8 text-center text-sm text-muted-foreground", children: [l.hasAccountText, ' ', _jsx(LinkComp, { href: loginUrl, className: AUTH_LINK_CLASS, children: l.signInText })] }))] }));
|
|
80
|
+
return (_jsxs("div", { className: "mx-auto flex w-full flex-col justify-center space-y-7 sm:w-[400px]", children: [_jsx(AuthFormHeader, { icon: hideIcon ? undefined : (icon ?? _jsx(DefaultUserPlusIcon, {})), title: title, description: description }), _jsxs("div", { className: "space-y-5", children: [_jsx(SocialSignInButtons, { mode: "sign-up", dividerText: labels.orText, buttonText: labels.socialButton }), _jsxs("form", { onSubmit: handleSubmit, className: "space-y-4", children: [error && _jsx(AuthErrorBanner, { message: error }), _jsxs("div", { className: "space-y-2", children: [_jsx("label", { htmlFor: "register-name", className: AUTH_FIELD_LABEL_CLASS, children: l.nameLabel }), _jsx("input", { id: "register-name", type: "text", placeholder: l.namePlaceholder, value: name, onChange: (e) => setName(e.target.value), required: true, autoComplete: "name", disabled: isLoading, className: AUTH_INPUT_CLASS })] }), _jsxs("div", { className: "space-y-2", children: [_jsx("label", { htmlFor: "register-email", className: AUTH_FIELD_LABEL_CLASS, children: l.emailLabel }), _jsx("input", { id: "register-email", type: "email", placeholder: l.emailPlaceholder, value: email, onChange: (e) => setEmail(e.target.value), required: true, autoComplete: "email", disabled: isLoading, className: AUTH_INPUT_CLASS })] }), _jsxs("div", { className: "space-y-2", children: [_jsx("label", { htmlFor: "register-password", className: AUTH_FIELD_LABEL_CLASS, children: l.passwordLabel }), _jsx("input", { id: "register-password", type: "password", placeholder: l.passwordPlaceholder, value: password, onChange: (e) => setPassword(e.target.value), required: true, minLength: 8, autoComplete: "new-password", disabled: isLoading, className: AUTH_INPUT_CLASS })] }), _jsxs("div", { className: "space-y-2", children: [_jsx("label", { htmlFor: "register-confirm-password", className: AUTH_FIELD_LABEL_CLASS, children: l.confirmPasswordLabel }), _jsx("input", { id: "register-confirm-password", type: "password", placeholder: l.confirmPasswordPlaceholder, value: confirmPassword, onChange: (e) => setConfirmPassword(e.target.value), required: true, minLength: 8, autoComplete: "new-password", disabled: isLoading, className: AUTH_INPUT_CLASS })] }), _jsxs("button", { type: "submit", disabled: isLoading, className: AUTH_PRIMARY_BUTTON_CLASS, children: [isLoading && _jsx(AuthSpinner, {}), isLoading ? l.submittingButton : l.submitButton] })] })] }), loginUrl && (_jsxs("p", { className: "px-8 text-center text-sm text-muted-foreground", children: [l.hasAccountText, ' ', _jsx(LinkComp, { href: loginUrl, className: AUTH_LINK_CLASS, children: l.signInText })] }))] }));
|
|
82
81
|
}
|
|
@@ -13,8 +13,21 @@ export interface SocialSignInButtonsProps {
|
|
|
13
13
|
callbackURL?: string;
|
|
14
14
|
/** Where the provider should redirect on error. Defaults to current page. */
|
|
15
15
|
errorCallbackURL?: string;
|
|
16
|
-
/** Divider text shown between social buttons and the email form */
|
|
16
|
+
/** Divider text shown between social buttons and the email form (defaults to "or continue with email") */
|
|
17
17
|
dividerText?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Omit the divider under the buttons. For a host that draws no email form
|
|
20
|
+
* beneath them at that moment, e.g. `LoginForm` under SSO-only mode until
|
|
21
|
+
* the password form is revealed. Defaults to false: the divider renders.
|
|
22
|
+
*/
|
|
23
|
+
hideDivider?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Provider button label, for i18n. `{provider}` is replaced with the
|
|
26
|
+
* provider's display name, so a translation places the name where its
|
|
27
|
+
* grammar needs it. Defaults to "Continue with {provider}" in `sign-in`
|
|
28
|
+
* mode and "Sign up with {provider}" in `sign-up` mode.
|
|
29
|
+
*/
|
|
30
|
+
buttonText?: string;
|
|
18
31
|
/** Called once after config resolves with whether any social providers are available. */
|
|
19
32
|
onProvidersResolved?: (hasProviders: boolean) => void;
|
|
20
33
|
}
|
|
@@ -25,5 +38,5 @@ export interface SocialSignInButtonsProps {
|
|
|
25
38
|
*
|
|
26
39
|
* Returns `null` while loading or when the server reports no providers.
|
|
27
40
|
*/
|
|
28
|
-
export declare function SocialSignInButtons({ mode, callbackURL, errorCallbackURL, dividerText, onProvidersResolved, }: SocialSignInButtonsProps): React.JSX.Element | null;
|
|
41
|
+
export declare function SocialSignInButtons({ mode, callbackURL, errorCallbackURL, dividerText, hideDivider, buttonText, onProvidersResolved, }: SocialSignInButtonsProps): React.JSX.Element | null;
|
|
29
42
|
//# sourceMappingURL=SocialSignInButtons.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SocialSignInButtons.d.ts","sourceRoot":"","sources":["../src/SocialSignInButtons.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAA8B,MAAM,OAAO,CAAC;AAkFnD,MAAM,WAAW,wBAAwB;IACvC,sFAAsF;IACtF,IAAI,CAAC,EAAE,SAAS,GAAG,SAAS,CAAC;IAC7B,kFAAkF;IAClF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6EAA6E;IAC7E,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,
|
|
1
|
+
{"version":3,"file":"SocialSignInButtons.d.ts","sourceRoot":"","sources":["../src/SocialSignInButtons.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAA8B,MAAM,OAAO,CAAC;AAkFnD,MAAM,WAAW,wBAAwB;IACvC,sFAAsF;IACtF,IAAI,CAAC,EAAE,SAAS,GAAG,SAAS,CAAC;IAC7B,kFAAkF;IAClF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6EAA6E;IAC7E,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,0GAA0G;IAC1G,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,yFAAyF;IACzF,mBAAmB,CAAC,EAAE,CAAC,YAAY,EAAE,OAAO,KAAK,IAAI,CAAC;CACvD;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,EAClC,IAAgB,EAChB,WAAW,EACX,gBAAgB,EAChB,WAAsC,EACtC,WAAmB,EACnB,UAAU,EACV,mBAAmB,GACpB,EAAE,wBAAwB,4BA6F1B"}
|
|
@@ -48,7 +48,7 @@ function ProviderIcon({ id }) {
|
|
|
48
48
|
*
|
|
49
49
|
* Returns `null` while loading or when the server reports no providers.
|
|
50
50
|
*/
|
|
51
|
-
export function SocialSignInButtons({ mode = 'sign-in', callbackURL, errorCallbackURL, dividerText = 'or continue with email', onProvidersResolved, }) {
|
|
51
|
+
export function SocialSignInButtons({ mode = 'sign-in', callbackURL, errorCallbackURL, dividerText = 'or continue with email', hideDivider = false, buttonText, onProvidersResolved, }) {
|
|
52
52
|
const { getAuthConfig, signInWithProvider } = useAuth();
|
|
53
53
|
const [providers, setProviders] = useState([]);
|
|
54
54
|
const [loading, setLoading] = useState(true);
|
|
@@ -86,7 +86,7 @@ export function SocialSignInButtons({ mode = 'sign-in', callbackURL, errorCallba
|
|
|
86
86
|
}, [getAuthConfig]);
|
|
87
87
|
if (loading || providers.length === 0)
|
|
88
88
|
return null;
|
|
89
|
-
const
|
|
89
|
+
const labelTemplate = buttonText ?? (mode === 'sign-in' ? 'Continue with {provider}' : 'Sign up with {provider}');
|
|
90
90
|
const defaultCallback = typeof window !== 'undefined' ? window.location.href : undefined;
|
|
91
91
|
const onClick = async (provider) => {
|
|
92
92
|
if (pendingId)
|
|
@@ -107,5 +107,5 @@ export function SocialSignInButtons({ mode = 'sign-in', callbackURL, errorCallba
|
|
|
107
107
|
setPendingId(null);
|
|
108
108
|
}
|
|
109
109
|
};
|
|
110
|
-
return (_jsxs("div", { className: "flex flex-col gap-2", children: [error && (_jsx("div", { className: "rounded-md bg-destructive/15 p-3 text-sm text-destructive", role: "alert", children: error })), providers.map((p) => (_jsxs("button", { type: "button", onClick: () => onClick(p), disabled: pendingId !== null, "aria-busy": pendingId === p.id, className: "inline-flex h-10 w-full items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", children: [pendingId === p.id ? _jsx(AuthSpinner, {}) : _jsx(ProviderIcon, { id: p.id }),
|
|
110
|
+
return (_jsxs("div", { className: "flex flex-col gap-2", children: [error && (_jsx("div", { className: "rounded-md bg-destructive/15 p-3 text-sm text-destructive", role: "alert", children: error })), providers.map((p) => (_jsxs("button", { type: "button", onClick: () => onClick(p), disabled: pendingId !== null, "aria-busy": pendingId === p.id, className: "inline-flex h-10 w-full items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", children: [pendingId === p.id ? _jsx(AuthSpinner, {}) : _jsx(ProviderIcon, { id: p.id }), labelTemplate.replace('{provider}', () => PROVIDER_LABEL[p.id] ?? p.name)] }, p.id))), hideDivider ? null : (_jsxs("div", { className: "relative my-1", children: [_jsx("div", { className: "absolute inset-0 flex items-center", children: _jsx("span", { className: "w-full border-t" }) }), _jsx("div", { className: "relative flex justify-center text-xs uppercase", children: _jsx("span", { className: "bg-background px-2 text-muted-foreground", children: dividerText }) })] }))] }));
|
|
111
111
|
}
|
package/dist/UserMenu.d.ts
CHANGED
|
@@ -21,7 +21,7 @@ export interface UserMenuProps {
|
|
|
21
21
|
* Shows avatar, name, email, and common actions (profile, settings, sign out).
|
|
22
22
|
*
|
|
23
23
|
* This is a headless component that provides the user data and actions.
|
|
24
|
-
* The actual dropdown rendering is handled by the consumer
|
|
24
|
+
* The actual dropdown rendering is handled by the consumer.
|
|
25
25
|
*
|
|
26
26
|
* @example
|
|
27
27
|
* ```tsx
|
package/dist/UserMenu.js
CHANGED
|
@@ -6,7 +6,7 @@ import { getUserInitials } from './types.js';
|
|
|
6
6
|
* Shows avatar, name, email, and common actions (profile, settings, sign out).
|
|
7
7
|
*
|
|
8
8
|
* This is a headless component that provides the user data and actions.
|
|
9
|
-
* The actual dropdown rendering is handled by the consumer
|
|
9
|
+
* The actual dropdown rendering is handled by the consumer.
|
|
10
10
|
*
|
|
11
11
|
* @example
|
|
12
12
|
* ```tsx
|
|
@@ -9,16 +9,18 @@
|
|
|
9
9
|
* ADR-0069 — client side of the authentication-policy session gate.
|
|
10
10
|
*
|
|
11
11
|
* The ObjectStack backend returns `403 { error: { code: 'MFA_REQUIRED' |
|
|
12
|
-
* 'PASSWORD_EXPIRED', message } }` from protected
|
|
13
|
-
* user must remediate.
|
|
12
|
+
* 'PASSWORD_EXPIRED' | 'PASSWORD_RESET_REQUIRED', message } }` from protected
|
|
13
|
+
* endpoints when a logged-in user must remediate. `PASSWORD_RESET_REQUIRED` is
|
|
14
|
+
* an administrator-forced password change, distinct on the wire from natural
|
|
15
|
+
* expiry (objectstack#23287 ruling C′). The authenticated fetch wrapper detects this on EVERY
|
|
14
16
|
* API response and emits it here; `AuthProvider` subscribes and raises a
|
|
15
17
|
* full-screen remediation overlay. Decoupling via a tiny module-level emitter
|
|
16
18
|
* keeps the plain fetch wrapper free of React.
|
|
17
19
|
*/
|
|
18
20
|
/** A triggered auth-policy gate the user must clear before continuing. */
|
|
19
21
|
export interface AuthGateInfo {
|
|
20
|
-
/** Stable machine code
|
|
21
|
-
code: 'MFA_REQUIRED' | 'PASSWORD_EXPIRED' | string;
|
|
22
|
+
/** Stable machine code: one of the codes `detectAuthGate` recognises. */
|
|
23
|
+
code: 'MFA_REQUIRED' | 'PASSWORD_EXPIRED' | 'PASSWORD_RESET_REQUIRED' | string;
|
|
22
24
|
/** Human-facing message from the server (may be empty). */
|
|
23
25
|
message: string;
|
|
24
26
|
}
|