@ttt-productions/auth-core 1.6.11 → 1.6.12
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/dist/react/AuthProvider.d.ts.map +1 -1
- package/dist/react/AuthProvider.js +40 -32
- package/dist/react/AuthProvider.js.map +1 -1
- package/dist/react/redirect-storage.d.ts +4 -0
- package/dist/react/redirect-storage.d.ts.map +1 -0
- package/dist/react/redirect-storage.js +57 -0
- package/dist/react/redirect-storage.js.map +1 -0
- package/dist/react/useAuthGuard.d.ts +5 -1
- package/dist/react/useAuthGuard.d.ts.map +1 -1
- package/dist/react/useAuthGuard.js +27 -11
- package/dist/react/useAuthGuard.js.map +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AuthProvider.d.ts","sourceRoot":"","sources":["../../src/react/AuthProvider.tsx"],"names":[],"mappings":"AAEA,OAAO,EAOL,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"AuthProvider.d.ts","sourceRoot":"","sources":["../../src/react/AuthProvider.tsx"],"names":[],"mappings":"AAEA,OAAO,EAOL,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAIf,OAAO,KAAK,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAavE,eAAO,MAAM,WAAW,uDAAoD,CAAC;AAE7E,UAAU,iBAAiB,CAAC,OAAO;IACjC,MAAM,EAAE,kBAAkB,CAAC,OAAO,CAAC,CAAC;IACpC,QAAQ,EAAE,SAAS,CAAC;CACrB;AAED,wBAAgB,YAAY,CAAC,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC5D,EAAE,MAAM,EAAE,QAAQ,EAAE,EAAE,iBAAiB,CAAC,OAAO,CAAC,+BAoHjD"}
|
|
@@ -6,11 +6,40 @@ import { getIdTokenClaims } from "../claims.js";
|
|
|
6
6
|
export const AuthContext = createContext(null);
|
|
7
7
|
export function AuthProvider({ config, children }) {
|
|
8
8
|
const { user, loading: authLoading } = useAuthState(config.auth);
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
// Claims are held with the session they were read for and derived at render (below), so a
|
|
10
|
+
// render can never pair one account's `user` with another account's claims, and a read that
|
|
11
|
+
// settles after the session changed (a late refresh, a slow initial read) is dropped.
|
|
12
|
+
const [resolved, setResolved] = useState(null);
|
|
13
|
+
const generationRef = useRef(0);
|
|
14
|
+
const appliedGenerationRef = useRef(0);
|
|
15
|
+
const currentUserRef = useRef(user);
|
|
16
|
+
currentUserRef.current = user;
|
|
11
17
|
// Stable config ref so effects don't re-fire on config object identity changes
|
|
12
18
|
const configRef = useRef(config);
|
|
13
19
|
configRef.current = config;
|
|
20
|
+
/**
|
|
21
|
+
* Read `forUser`'s claims under a new generation and apply them only if `forUser` is still
|
|
22
|
+
* the signed-in session and no newer read has applied. A failed read leaves the session's
|
|
23
|
+
* claims as they are, or settles an unread session on the default claims.
|
|
24
|
+
*/
|
|
25
|
+
const readClaims = useCallback(async (forUser, read, context) => {
|
|
26
|
+
const generation = ++generationRef.current;
|
|
27
|
+
try {
|
|
28
|
+
const raw = await read();
|
|
29
|
+
if (currentUserRef.current !== forUser || generation <= appliedGenerationRef.current)
|
|
30
|
+
return;
|
|
31
|
+
appliedGenerationRef.current = generation;
|
|
32
|
+
setResolved({ user: forUser, generation, claims: configRef.current.parseClaims(raw ?? {}) });
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
if (currentUserRef.current !== forUser)
|
|
36
|
+
return;
|
|
37
|
+
configRef.current.onError?.(err, context);
|
|
38
|
+
setResolved((prev) => prev && prev.user === forUser
|
|
39
|
+
? prev
|
|
40
|
+
: { user: forUser, generation, claims: configRef.current.defaultClaims });
|
|
41
|
+
}
|
|
42
|
+
}, []);
|
|
14
43
|
// --- Optional readiness gate (see AuthProviderConfig.readyGate) ---
|
|
15
44
|
// Open immediately when no gate is configured; otherwise held closed until the
|
|
16
45
|
// gate settles. Fail-open: a rejected gate is reported and then treated as open.
|
|
@@ -36,44 +65,23 @@ export function AuthProvider({ config, children }) {
|
|
|
36
65
|
if (authLoading)
|
|
37
66
|
return;
|
|
38
67
|
if (!user) {
|
|
39
|
-
|
|
40
|
-
setClaimsLoading(false);
|
|
68
|
+
setResolved(null);
|
|
41
69
|
configRef.current.onAuthStateChange?.(null);
|
|
42
70
|
return;
|
|
43
71
|
}
|
|
44
|
-
let cancelled = false;
|
|
45
|
-
setClaimsLoading(true);
|
|
46
72
|
configRef.current.onAuthStateChange?.(user);
|
|
47
|
-
getIdTokenClaims(user)
|
|
48
|
-
|
|
49
|
-
if (cancelled)
|
|
50
|
-
return;
|
|
51
|
-
setClaims(configRef.current.parseClaims((raw ?? {})));
|
|
52
|
-
})
|
|
53
|
-
.catch((err) => {
|
|
54
|
-
if (cancelled)
|
|
55
|
-
return;
|
|
56
|
-
configRef.current.onError?.(err, "getIdTokenClaims");
|
|
57
|
-
setClaims(configRef.current.defaultClaims);
|
|
58
|
-
})
|
|
59
|
-
.finally(() => {
|
|
60
|
-
if (!cancelled)
|
|
61
|
-
setClaimsLoading(false);
|
|
62
|
-
});
|
|
63
|
-
return () => { cancelled = true; };
|
|
64
|
-
}, [user, authLoading]);
|
|
73
|
+
void readClaims(user, async () => ((await getIdTokenClaims(user)) ?? {}), "getIdTokenClaims");
|
|
74
|
+
}, [user, authLoading, readClaims]);
|
|
65
75
|
// --- refreshClaims ---
|
|
66
76
|
const refreshClaims = useCallback(async () => {
|
|
67
77
|
if (!user)
|
|
68
78
|
return;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
}, [user]);
|
|
79
|
+
await readClaims(user, async () => ((await user.getIdTokenResult(true)).claims ?? {}), "refreshClaims");
|
|
80
|
+
}, [user, readClaims]);
|
|
81
|
+
// --- Derived at render: only the current session's claims are ever exposed ---
|
|
82
|
+
const sessionClaims = user && resolved?.user === user ? resolved : null;
|
|
83
|
+
const claims = sessionClaims ? sessionClaims.claims : config.defaultClaims;
|
|
84
|
+
const claimsLoading = authLoading || (user !== null && sessionClaims === null);
|
|
77
85
|
// --- Context value ---
|
|
78
86
|
const loading = authLoading || claimsLoading || !gateOpen;
|
|
79
87
|
const value = useMemo(() => ({
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AuthProvider.js","sourceRoot":"","sources":["../../src/react/AuthProvider.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAC;;AAEb,OAAO,EACL,aAAa,EACb,SAAS,EACT,MAAM,EACN,QAAQ,EACR,WAAW,EACX,OAAO,GAER,MAAM,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"AuthProvider.js","sourceRoot":"","sources":["../../src/react/AuthProvider.tsx"],"names":[],"mappings":"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","sourcesContent":["\"use client\";\n\nimport {\n createContext,\n useEffect,\n useRef,\n useState,\n useCallback,\n useMemo,\n type ReactNode,\n} from \"react\";\nimport type { User } from \"firebase/auth\";\nimport { useAuthState } from \"./useAuthState.js\";\nimport { getIdTokenClaims } from \"../claims.js\";\nimport type { AuthProviderConfig, AuthContextValue } from \"./types.js\";\n\n/**\n * Claims resolved for one signed-in session. `user` is the Firebase `User` instance (it carries\n * the uid and is replaced on every sign-in), and `generation` orders the reads that produced it.\n */\ninterface ResolvedClaims<TClaims> {\n user: User;\n generation: number;\n claims: TClaims;\n}\n\n \nexport const AuthContext = createContext<AuthContextValue<any> | null>(null);\n\ninterface AuthProviderProps<TClaims> {\n config: AuthProviderConfig<TClaims>;\n children: ReactNode;\n}\n\nexport function AuthProvider<TClaims = Record<string, unknown>>(\n { config, children }: AuthProviderProps<TClaims>,\n) {\n const { user, loading: authLoading } = useAuthState(config.auth);\n\n // Claims are held with the session they were read for and derived at render (below), so a\n // render can never pair one account's `user` with another account's claims, and a read that\n // settles after the session changed (a late refresh, a slow initial read) is dropped.\n const [resolved, setResolved] = useState<ResolvedClaims<TClaims> | null>(null);\n const generationRef = useRef(0);\n const appliedGenerationRef = useRef(0);\n const currentUserRef = useRef<User | null>(user);\n currentUserRef.current = user;\n\n // Stable config ref so effects don't re-fire on config object identity changes\n const configRef = useRef(config);\n configRef.current = config;\n\n /**\n * Read `forUser`'s claims under a new generation and apply them only if `forUser` is still\n * the signed-in session and no newer read has applied. A failed read leaves the session's\n * claims as they are, or settles an unread session on the default claims.\n */\n const readClaims = useCallback(\n async (forUser: User, read: () => Promise<Record<string, unknown> | null>, context: string) => {\n const generation = ++generationRef.current;\n try {\n const raw = await read();\n if (currentUserRef.current !== forUser || generation <= appliedGenerationRef.current) return;\n appliedGenerationRef.current = generation;\n setResolved({ user: forUser, generation, claims: configRef.current.parseClaims(raw ?? {}) });\n } catch (err) {\n if (currentUserRef.current !== forUser) return;\n configRef.current.onError?.(err, context);\n setResolved((prev) =>\n prev && prev.user === forUser\n ? prev\n : { user: forUser, generation, claims: configRef.current.defaultClaims },\n );\n }\n },\n [],\n );\n\n // --- Optional readiness gate (see AuthProviderConfig.readyGate) ---\n // Open immediately when no gate is configured; otherwise held closed until the\n // gate settles. Fail-open: a rejected gate is reported and then treated as open.\n const [gateOpen, setGateOpen] = useState(() => !config.readyGate);\n useEffect(() => {\n const gate = configRef.current.readyGate;\n if (!gate) return;\n let cancelled = false;\n Promise.resolve()\n .then(() => gate())\n .catch((err) => configRef.current.onError?.(err, \"readyGate\"))\n .finally(() => {\n if (!cancelled) setGateOpen(true);\n });\n return () => { cancelled = true; };\n // Mount-only by design (reads only the stable configRef): the gate is a\n // one-time client readiness signal.\n }, []);\n\n // --- Claims fetching ---\n useEffect(() => {\n if (authLoading) return;\n\n if (!user) {\n setResolved(null);\n configRef.current.onAuthStateChange?.(null);\n return;\n }\n\n configRef.current.onAuthStateChange?.(user);\n void readClaims(\n user,\n async () => ((await getIdTokenClaims(user)) ?? {}) as Record<string, unknown>,\n \"getIdTokenClaims\",\n );\n }, [user, authLoading, readClaims]);\n\n // --- refreshClaims ---\n const refreshClaims = useCallback(async () => {\n if (!user) return;\n await readClaims(\n user,\n async () => ((await user.getIdTokenResult(true)).claims ?? {}) as Record<string, unknown>,\n \"refreshClaims\",\n );\n }, [user, readClaims]);\n\n // --- Derived at render: only the current session's claims are ever exposed ---\n const sessionClaims = user && resolved?.user === user ? resolved : null;\n const claims = sessionClaims ? sessionClaims.claims : config.defaultClaims;\n const claimsLoading = authLoading || (user !== null && sessionClaims === null);\n\n // --- Context value ---\n const loading = authLoading || claimsLoading || !gateOpen;\n\n const value = useMemo<AuthContextValue<TClaims>>(\n () => ({\n user,\n claims,\n loading,\n authLoading,\n claimsLoading,\n isAuthenticated: !!user,\n refreshClaims,\n }),\n [user, claims, loading, authLoading, claimsLoading, refreshClaims],\n );\n\n return (\n <AuthContext.Provider value={value}>\n {children}\n </AuthContext.Provider>\n );\n}\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"redirect-storage.d.ts","sourceRoot":"","sources":["../../src/react/redirect-storage.ts"],"names":[],"mappings":"AAmBA,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAS3D;AAED,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAahE;AAED,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAQnD"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The post-login redirect path lives in localStorage. A browser that blocks site data throws
|
|
2
|
+
// on the storage access itself (FRONTEND-010), so every access here is guarded: a blocked read
|
|
3
|
+
// is "nothing saved", and a blocked write keeps the value in memory for the page's life so a
|
|
4
|
+
// same-page sign-in still lands on it. Outside a browser every call is a no-op.
|
|
5
|
+
const memory = new Map();
|
|
6
|
+
function inBrowser() {
|
|
7
|
+
return typeof window !== "undefined";
|
|
8
|
+
}
|
|
9
|
+
function storage() {
|
|
10
|
+
try {
|
|
11
|
+
return window.localStorage;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function readRedirectPath(key) {
|
|
18
|
+
if (!inBrowser())
|
|
19
|
+
return null;
|
|
20
|
+
try {
|
|
21
|
+
const saved = storage()?.getItem(key) ?? null;
|
|
22
|
+
if (saved !== null)
|
|
23
|
+
return saved;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Blocked store: fall through to the page-life copy.
|
|
27
|
+
}
|
|
28
|
+
return memory.get(key) ?? null;
|
|
29
|
+
}
|
|
30
|
+
export function saveRedirectPath(key, path) {
|
|
31
|
+
if (!inBrowser())
|
|
32
|
+
return;
|
|
33
|
+
const store = storage();
|
|
34
|
+
if (store) {
|
|
35
|
+
try {
|
|
36
|
+
store.setItem(key, path);
|
|
37
|
+
memory.delete(key);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Blocked store: keep the page-life copy below.
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
memory.set(key, path);
|
|
45
|
+
}
|
|
46
|
+
export function clearRedirectPath(key) {
|
|
47
|
+
if (!inBrowser())
|
|
48
|
+
return;
|
|
49
|
+
memory.delete(key);
|
|
50
|
+
try {
|
|
51
|
+
storage()?.removeItem(key);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Blocked store: nothing was persisted there.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=redirect-storage.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"redirect-storage.js","sourceRoot":"","sources":["../../src/react/redirect-storage.ts"],"names":[],"mappings":"AAAA,6FAA6F;AAC7F,+FAA+F;AAC/F,6FAA6F;AAC7F,gFAAgF;AAEhF,MAAM,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAC;AAEzC,SAAS,SAAS;IAChB,OAAO,OAAO,MAAM,KAAK,WAAW,CAAC;AACvC,CAAC;AAED,SAAS,OAAO;IACd,IAAI,CAAC;QACH,OAAO,MAAM,CAAC,YAAY,CAAC;IAC7B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,GAAW;IAC1C,IAAI,CAAC,SAAS,EAAE;QAAE,OAAO,IAAI,CAAC;IAC9B,IAAI,CAAC;QACH,MAAM,KAAK,GAAG,OAAO,EAAE,EAAE,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC;QAC9C,IAAI,KAAK,KAAK,IAAI;YAAE,OAAO,KAAK,CAAC;IACnC,CAAC;IAAC,MAAM,CAAC;QACP,qDAAqD;IACvD,CAAC;IACD,OAAO,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC;AACjC,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,GAAW,EAAE,IAAY;IACxD,IAAI,CAAC,SAAS,EAAE;QAAE,OAAO;IACzB,MAAM,KAAK,GAAG,OAAO,EAAE,CAAC;IACxB,IAAI,KAAK,EAAE,CAAC;QACV,IAAI,CAAC;YACH,KAAK,CAAC,OAAO,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;YACzB,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;YACnB,OAAO;QACT,CAAC;QAAC,MAAM,CAAC;YACP,gDAAgD;QAClD,CAAC;IACH,CAAC;IACD,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;AACxB,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,GAAW;IAC3C,IAAI,CAAC,SAAS,EAAE;QAAE,OAAO;IACzB,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IACnB,IAAI,CAAC;QACH,OAAO,EAAE,EAAE,UAAU,CAAC,GAAG,CAAC,CAAC;IAC7B,CAAC;IAAC,MAAM,CAAC;QACP,8CAA8C;IAChD,CAAC;AACH,CAAC","sourcesContent":["// The post-login redirect path lives in localStorage. A browser that blocks site data throws\n// on the storage access itself (FRONTEND-010), so every access here is guarded: a blocked read\n// is \"nothing saved\", and a blocked write keeps the value in memory for the page's life so a\n// same-page sign-in still lands on it. Outside a browser every call is a no-op.\n\nconst memory = new Map<string, string>();\n\nfunction inBrowser(): boolean {\n return typeof window !== \"undefined\";\n}\n\nfunction storage(): Storage | null {\n try {\n return window.localStorage;\n } catch {\n return null;\n }\n}\n\nexport function readRedirectPath(key: string): string | null {\n if (!inBrowser()) return null;\n try {\n const saved = storage()?.getItem(key) ?? null;\n if (saved !== null) return saved;\n } catch {\n // Blocked store: fall through to the page-life copy.\n }\n return memory.get(key) ?? null;\n}\n\nexport function saveRedirectPath(key: string, path: string): void {\n if (!inBrowser()) return;\n const store = storage();\n if (store) {\n try {\n store.setItem(key, path);\n memory.delete(key);\n return;\n } catch {\n // Blocked store: keep the page-life copy below.\n }\n }\n memory.set(key, path);\n}\n\nexport function clearRedirectPath(key: string): void {\n if (!inBrowser()) return;\n memory.delete(key);\n try {\n storage()?.removeItem(key);\n } catch {\n // Blocked store: nothing was persisted there.\n }\n}\n"]}
|
|
@@ -35,7 +35,11 @@ export interface AuthGuardConfig {
|
|
|
35
35
|
* - Does nothing while loading.
|
|
36
36
|
* - Redirects unauthenticated users from protected routes to loginRoute.
|
|
37
37
|
* - Redirects authenticated users from authRedirectRoutes to defaultRoute.
|
|
38
|
-
* - Saves attempted path for post-login redirect.
|
|
38
|
+
* - Saves attempted path for post-login redirect — a visitor's deep link only. When a
|
|
39
|
+
* session ends (an observed signed-in → signed-out transition: a sign-out, a forced
|
|
40
|
+
* sign-out, an expiry, another tab), the path it ended on is NOT saved and any saved
|
|
41
|
+
* path is cleared, so the next account to sign in on this device never lands on the
|
|
42
|
+
* previous account's page.
|
|
39
43
|
*
|
|
40
44
|
* "Public" is decided ENTIRELY by `config.publicRoutes` (prefix match). The hook
|
|
41
45
|
* carries no built-in or implicit public route: every route a consumer wants
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useAuthGuard.d.ts","sourceRoot":"","sources":["../../src/react/useAuthGuard.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"useAuthGuard.d.ts","sourceRoot":"","sources":["../../src/react/useAuthGuard.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,eAAe;IAC9B;;;;;;OAMG;IACH,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,sEAAsE;IACtE,kBAAkB,EAAE,MAAM,EAAE,CAAC;IAC7B,qCAAqC;IACrC,UAAU,EAAE,MAAM,CAAC;IACnB,2DAA2D;IAC3D,YAAY,EAAE,MAAM,CAAC;IACrB,iCAAiC;IACjC,YAAY,CAAC,EAAE;QAAE,aAAa,EAAE,MAAM,CAAC;QAAC,eAAe,EAAE,MAAM,CAAA;KAAE,CAAC;IAClE,+CAA+C;IAC/C,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,gDAAgD;IAChD,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,mBAAmB;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,qBAAqB;IACrB,OAAO,EAAE,OAAO,CAAC;IACjB,qBAAqB;IACrB,eAAe,EAAE,OAAO,CAAC;CAC1B;AAID;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,eAAe,GAAG,IAAI,CAsF1D"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { useEffect, useRef } from "react";
|
|
3
|
+
import { clearRedirectPath, readRedirectPath, saveRedirectPath } from "./redirect-storage.js";
|
|
3
4
|
const DEFAULT_REDIRECT_KEY = "auth_redirect_path";
|
|
4
5
|
/**
|
|
5
6
|
* Handles route protection. Call in app shell component.
|
|
@@ -7,7 +8,11 @@ const DEFAULT_REDIRECT_KEY = "auth_redirect_path";
|
|
|
7
8
|
* - Does nothing while loading.
|
|
8
9
|
* - Redirects unauthenticated users from protected routes to loginRoute.
|
|
9
10
|
* - Redirects authenticated users from authRedirectRoutes to defaultRoute.
|
|
10
|
-
* - Saves attempted path for post-login redirect.
|
|
11
|
+
* - Saves attempted path for post-login redirect — a visitor's deep link only. When a
|
|
12
|
+
* session ends (an observed signed-in → signed-out transition: a sign-out, a forced
|
|
13
|
+
* sign-out, an expiry, another tab), the path it ended on is NOT saved and any saved
|
|
14
|
+
* path is cleared, so the next account to sign in on this device never lands on the
|
|
15
|
+
* previous account's page.
|
|
11
16
|
*
|
|
12
17
|
* "Public" is decided ENTIRELY by `config.publicRoutes` (prefix match). The hook
|
|
13
18
|
* carries no built-in or implicit public route: every route a consumer wants
|
|
@@ -25,9 +30,23 @@ export function useAuthGuard(config) {
|
|
|
25
30
|
// a deep link that routed through login then landed on defaultRoute instead of
|
|
26
31
|
// the saved path. The latch resets whenever the pathname is off the auth-route set.
|
|
27
32
|
const authRouteRedirected = useRef(false);
|
|
33
|
+
// The last settled authentication state this guard observed (null before the first), and
|
|
34
|
+
// the pathname a session ended on. The effect can re-run on that same pathname before the
|
|
35
|
+
// login navigation commits, so the "do not save" decision is held for that pathname rather
|
|
36
|
+
// than for one run.
|
|
37
|
+
const lastAuthenticated = useRef(null);
|
|
38
|
+
const sessionEndedOn = useRef(null);
|
|
28
39
|
useEffect(() => {
|
|
29
40
|
if (loading)
|
|
30
41
|
return;
|
|
42
|
+
if (lastAuthenticated.current === true && !isAuthenticated) {
|
|
43
|
+
sessionEndedOn.current = pathname;
|
|
44
|
+
clearRedirectPath(redirectKey);
|
|
45
|
+
}
|
|
46
|
+
else if (isAuthenticated || sessionEndedOn.current !== pathname) {
|
|
47
|
+
sessionEndedOn.current = null;
|
|
48
|
+
}
|
|
49
|
+
lastAuthenticated.current = isAuthenticated;
|
|
31
50
|
if (!authRedirectRoutes.includes(pathname)) {
|
|
32
51
|
authRouteRedirected.current = false;
|
|
33
52
|
}
|
|
@@ -38,11 +57,10 @@ export function useAuthGuard(config) {
|
|
|
38
57
|
}
|
|
39
58
|
const isPublic = publicRoutes.some((r) => pathname.startsWith(r));
|
|
40
59
|
const isAuthRedirect = authRedirectRoutes.includes(pathname);
|
|
41
|
-
// Unauthenticated on protected route -> save path, go to login
|
|
60
|
+
// Unauthenticated on protected route -> save a visitor's path, go to login
|
|
42
61
|
if (!isAuthenticated && !isPublic) {
|
|
43
|
-
if (
|
|
44
|
-
|
|
45
|
-
}
|
|
62
|
+
if (sessionEndedOn.current === null)
|
|
63
|
+
saveRedirectPath(redirectKey, pathname);
|
|
46
64
|
replace(loginRoute);
|
|
47
65
|
return;
|
|
48
66
|
}
|
|
@@ -52,13 +70,11 @@ export function useAuthGuard(config) {
|
|
|
52
70
|
return;
|
|
53
71
|
authRouteRedirected.current = true;
|
|
54
72
|
let target = defaultRoute;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
target = saved;
|
|
59
|
-
}
|
|
60
|
-
localStorage.removeItem(redirectKey);
|
|
73
|
+
const saved = readRedirectPath(redirectKey);
|
|
74
|
+
if (saved && !authRedirectRoutes.includes(saved)) {
|
|
75
|
+
target = saved;
|
|
61
76
|
}
|
|
77
|
+
clearRedirectPath(redirectKey);
|
|
62
78
|
replace(target);
|
|
63
79
|
}
|
|
64
80
|
}, [
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useAuthGuard.js","sourceRoot":"","sources":["../../src/react/useAuthGuard.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC;AA+
|
|
1
|
+
{"version":3,"file":"useAuthGuard.js","sourceRoot":"","sources":["../../src/react/useAuthGuard.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC;AAC1C,OAAO,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AA+B9F,MAAM,oBAAoB,GAAG,oBAAoB,CAAC;AAElD;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,YAAY,CAAC,MAAuB;IAClD,MAAM,EACJ,YAAY,EACZ,kBAAkB,EAClB,UAAU,EACV,YAAY,EACZ,YAAY,EACZ,WAAW,GAAG,oBAAoB,EAClC,OAAO,EACP,QAAQ,EACR,OAAO,EACP,eAAe,GAChB,GAAG,MAAM,CAAC;IAEX,iFAAiF;IACjF,2EAA2E;IAC3E,iFAAiF;IACjF,+EAA+E;IAC/E,2EAA2E;IAC3E,gFAAgF;IAChF,+EAA+E;IAC/E,oFAAoF;IACpF,MAAM,mBAAmB,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;IAE1C,yFAAyF;IACzF,0FAA0F;IAC1F,2FAA2F;IAC3F,oBAAoB;IACpB,MAAM,iBAAiB,GAAG,MAAM,CAAiB,IAAI,CAAC,CAAC;IACvD,MAAM,cAAc,GAAG,MAAM,CAAgB,IAAI,CAAC,CAAC;IAEnD,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,OAAO;YAAE,OAAO;QAEpB,IAAI,iBAAiB,CAAC,OAAO,KAAK,IAAI,IAAI,CAAC,eAAe,EAAE,CAAC;YAC3D,cAAc,CAAC,OAAO,GAAG,QAAQ,CAAC;YAClC,iBAAiB,CAAC,WAAW,CAAC,CAAC;QACjC,CAAC;aAAM,IAAI,eAAe,IAAI,cAAc,CAAC,OAAO,KAAK,QAAQ,EAAE,CAAC;YAClE,cAAc,CAAC,OAAO,GAAG,IAAI,CAAC;QAChC,CAAC;QACD,iBAAiB,CAAC,OAAO,GAAG,eAAe,CAAC;QAE5C,IAAI,CAAC,kBAAkB,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;YAC3C,mBAAmB,CAAC,OAAO,GAAG,KAAK,CAAC;QACtC,CAAC;QAED,6BAA6B;QAC7B,IAAI,YAAY,IAAI,QAAQ,KAAK,GAAG,EAAE,CAAC;YACrC,OAAO,CAAC,eAAe,CAAC,CAAC,CAAC,YAAY,CAAC,aAAa,CAAC,CAAC,CAAC,YAAY,CAAC,eAAe,CAAC,CAAC;YACrF,OAAO;QACT,CAAC;QAED,MAAM,QAAQ,GAAG,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC;QAClE,MAAM,cAAc,GAAG,kBAAkB,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;QAE7D,2EAA2E;QAC3E,IAAI,CAAC,eAAe,IAAI,CAAC,QAAQ,EAAE,CAAC;YAClC,IAAI,cAAc,CAAC,OAAO,KAAK,IAAI;gBAAE,gBAAgB,CAAC,WAAW,EAAE,QAAQ,CAAC,CAAC;YAC7E,OAAO,CAAC,UAAU,CAAC,CAAC;YACpB,OAAO;QACT,CAAC;QAED,gEAAgE;QAChE,IAAI,eAAe,IAAI,cAAc,EAAE,CAAC;YACtC,IAAI,mBAAmB,CAAC,OAAO;gBAAE,OAAO;YACxC,mBAAmB,CAAC,OAAO,GAAG,IAAI,CAAC;YACnC,IAAI,MAAM,GAAG,YAAY,CAAC;YAC1B,MAAM,KAAK,GAAG,gBAAgB,CAAC,WAAW,CAAC,CAAC;YAC5C,IAAI,KAAK,IAAI,CAAC,kBAAkB,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;gBACjD,MAAM,GAAG,KAAK,CAAC;YACjB,CAAC;YACD,iBAAiB,CAAC,WAAW,CAAC,CAAC;YAC/B,OAAO,CAAC,MAAM,CAAC,CAAC;QAClB,CAAC;IACH,CAAC,EAAE;QACD,OAAO;QACP,eAAe;QACf,QAAQ;QACR,YAAY;QACZ,kBAAkB;QAClB,UAAU;QACV,YAAY;QACZ,YAAY;QACZ,WAAW;QACX,OAAO;KACR,CAAC,CAAC;AACL,CAAC","sourcesContent":["\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { clearRedirectPath, readRedirectPath, saveRedirectPath } from \"./redirect-storage.js\";\n\nexport interface AuthGuardConfig {\n /**\n * Routes accessible without authentication, matched by PREFIX\n * (`pathname.startsWith(entry)`), so `'/share/'` covers every `/share/:id`\n * beneath it and `'/terms'` covers `/terms/anything`. This is the SOLE source of public\n * routes — the hook has no built-in defaults and no app-specific literals, so\n * a consumer that needs a dynamic public section must list its prefix here.\n */\n publicRoutes: string[];\n /** Routes that redirect authenticated users away (login, register) */\n authRedirectRoutes: string[];\n /** Where unauthenticated users go */\n loginRoute: string;\n /** Where authenticated users go from authRedirectRoutes */\n defaultRoute: string;\n /** Special root path handling */\n rootRedirect?: { authenticated: string; unauthenticated: string };\n /** localStorage key for post-login redirect */\n redirectKey?: string;\n /** Navigation function (e.g. router.replace) */\n replace: (path: string) => void;\n /** Current path */\n pathname: string;\n /** From useAuth() */\n loading: boolean;\n /** From useAuth() */\n isAuthenticated: boolean;\n}\n\nconst DEFAULT_REDIRECT_KEY = \"auth_redirect_path\";\n\n/**\n * Handles route protection. Call in app shell component.\n *\n * - Does nothing while loading.\n * - Redirects unauthenticated users from protected routes to loginRoute.\n * - Redirects authenticated users from authRedirectRoutes to defaultRoute.\n * - Saves attempted path for post-login redirect — a visitor's deep link only. When a\n * session ends (an observed signed-in → signed-out transition: a sign-out, a forced\n * sign-out, an expiry, another tab), the path it ended on is NOT saved and any saved\n * path is cleared, so the next account to sign in on this device never lands on the\n * previous account's page.\n *\n * \"Public\" is decided ENTIRELY by `config.publicRoutes` (prefix match). The hook\n * carries no built-in or implicit public route: every route a consumer wants\n * reachable while signed out — including a dynamic section like `/share/:id` — is\n * listed by that consumer as a prefix.\n */\nexport function useAuthGuard(config: AuthGuardConfig): void {\n const {\n publicRoutes,\n authRedirectRoutes,\n loginRoute,\n defaultRoute,\n rootRedirect,\n redirectKey = DEFAULT_REDIRECT_KEY,\n replace,\n pathname,\n loading,\n isAuthenticated,\n } = config;\n\n // The auth-route redirect below is SINGLE-SHOT per auth-route visit. That branch\n // CONSUMES redirectKey and issues a navigation, but the navigation commits\n // asynchronously (the router fetches the target route first), so this effect can\n // legitimately re-run while pathname still reads the auth route (an auth-state\n // re-render, a loading flap). A re-run then finds the key already gone and\n // re-replaces to defaultRoute, CLOBBERING the in-flight saved-path navigation —\n // a deep link that routed through login then landed on defaultRoute instead of\n // the saved path. The latch resets whenever the pathname is off the auth-route set.\n const authRouteRedirected = useRef(false);\n\n // The last settled authentication state this guard observed (null before the first), and\n // the pathname a session ended on. The effect can re-run on that same pathname before the\n // login navigation commits, so the \"do not save\" decision is held for that pathname rather\n // than for one run.\n const lastAuthenticated = useRef<boolean | null>(null);\n const sessionEndedOn = useRef<string | null>(null);\n\n useEffect(() => {\n if (loading) return;\n\n if (lastAuthenticated.current === true && !isAuthenticated) {\n sessionEndedOn.current = pathname;\n clearRedirectPath(redirectKey);\n } else if (isAuthenticated || sessionEndedOn.current !== pathname) {\n sessionEndedOn.current = null;\n }\n lastAuthenticated.current = isAuthenticated;\n\n if (!authRedirectRoutes.includes(pathname)) {\n authRouteRedirected.current = false;\n }\n\n // Root path special handling\n if (rootRedirect && pathname === \"/\") {\n replace(isAuthenticated ? rootRedirect.authenticated : rootRedirect.unauthenticated);\n return;\n }\n\n const isPublic = publicRoutes.some((r) => pathname.startsWith(r));\n const isAuthRedirect = authRedirectRoutes.includes(pathname);\n\n // Unauthenticated on protected route -> save a visitor's path, go to login\n if (!isAuthenticated && !isPublic) {\n if (sessionEndedOn.current === null) saveRedirectPath(redirectKey, pathname);\n replace(loginRoute);\n return;\n }\n\n // Authenticated user on auth-only route (login, register, etc.)\n if (isAuthenticated && isAuthRedirect) {\n if (authRouteRedirected.current) return;\n authRouteRedirected.current = true;\n let target = defaultRoute;\n const saved = readRedirectPath(redirectKey);\n if (saved && !authRedirectRoutes.includes(saved)) {\n target = saved;\n }\n clearRedirectPath(redirectKey);\n replace(target);\n }\n }, [\n loading,\n isAuthenticated,\n pathname,\n publicRoutes,\n authRedirectRoutes,\n loginRoute,\n defaultRoute,\n rootRedirect,\n redirectKey,\n replace,\n ]);\n}\n"]}
|