@sjolystinnovation/app-kit 0.3.0 → 0.4.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/README.md +147 -2
- package/package.json +4 -1
- package/src/config.ts +22 -2
- package/src/forms/index.ts +2 -0
- package/src/forms/useFormDraft.ts +133 -0
- package/src/session/expiry.ts +66 -0
- package/src/session/index.ts +9 -0
- package/src/session/react/index.ts +2 -0
- package/src/session/react/useSessionGate.ts +63 -0
package/README.md
CHANGED
|
@@ -64,14 +64,14 @@ const secret = resolveJwtSecret(sessionConfig, process.env);
|
|
|
64
64
|
| --- | --- | --- |
|
|
65
65
|
| `jwtSecretEnvVar` | `string` | Name of the environment variable holding the API JWT secret, such as `API_JWT_SECRET`. |
|
|
66
66
|
| `loginRoute` | `string` | Where an expired session sends the browser, such as `/en/login` or `/login`. |
|
|
67
|
-
| `draftStoragePrefix` | `string` |
|
|
67
|
+
| `draftStoragePrefix` | `string` | Bare name for the `localStorage` keys holding form drafts, such as `example`. Drafts are stored under `example:draft:<owner>:<scope>`. |
|
|
68
68
|
|
|
69
69
|
### `defineSessionConfig(config)`
|
|
70
70
|
|
|
71
71
|
`(config: SessionConfig) => SessionConfig`
|
|
72
72
|
|
|
73
73
|
Returns the config unchanged, so TypeScript checks its shape where it is declared rather than at
|
|
74
|
-
every call site.
|
|
74
|
+
every call site. Throws when `draftStoragePrefix` is empty or contains a colon.
|
|
75
75
|
|
|
76
76
|
### `resolveJwtSecret(config, env)`
|
|
77
77
|
|
|
@@ -124,6 +124,151 @@ try {
|
|
|
124
124
|
}
|
|
125
125
|
```
|
|
126
126
|
|
|
127
|
+
### `@sjolystinnovation/app-kit/session`
|
|
128
|
+
|
|
129
|
+
Decides when a user has to sign in again. Safe to import from server code, such as the next-auth
|
|
130
|
+
callbacks.
|
|
131
|
+
|
|
132
|
+
The gate reads `session.error`. The app's `jwt` callback sets `token.error`, and its `session`
|
|
133
|
+
callback copies it across:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
callbacks: {
|
|
137
|
+
session({ session, token }) {
|
|
138
|
+
session.error = token.error;
|
|
139
|
+
return session;
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
For that to compile, `error` has to be on next-auth's `Session` and `JWT` types. Add it to
|
|
145
|
+
the app's existing next-auth type declarations, or create them:
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
// src/types/next-auth.d.ts
|
|
149
|
+
import "next-auth";
|
|
150
|
+
import "next-auth/jwt";
|
|
151
|
+
|
|
152
|
+
declare module "next-auth" {
|
|
153
|
+
interface Session {
|
|
154
|
+
error?: string | undefined;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
declare module "next-auth/jwt" {
|
|
159
|
+
interface JWT {
|
|
160
|
+
error?: string | undefined;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`SESSION_ERRORS` holds the values the jwt callback puts in `token.error` when the session cannot be
|
|
166
|
+
recovered: `AbsoluteSessionExpired`, `RefreshTokenRejected` and `NoRefreshToken`. `SessionError`
|
|
167
|
+
is their union type. `isTerminalSessionError(error)` is true for those three only, so a transient
|
|
168
|
+
refresh failure keeps the session.
|
|
169
|
+
|
|
170
|
+
The re-sign-in prompt is a flag outside React, so an axios interceptor can raise it:
|
|
171
|
+
|
|
172
|
+
| Function | Description |
|
|
173
|
+
| --- | --- |
|
|
174
|
+
| `openSessionPrompt()` | Asks the user to sign in again without leaving the page. |
|
|
175
|
+
| `closeSessionPrompt()` | Hides the prompt. Call it when the component showing the prompt unmounts, or the flag stays open and the gate never redirects. |
|
|
176
|
+
| `subscribeSessionPrompt(listener)` | Calls `listener` when the prompt opens or closes. Returns an unsubscribe function. |
|
|
177
|
+
| `getSessionPromptOpen()` | Whether the prompt is showing. |
|
|
178
|
+
|
|
179
|
+
Copies of the package installed side by side share the one flag, as long as their releases use the
|
|
180
|
+
same prompt state format. A release that changes the format says so in its changelog. An app's own
|
|
181
|
+
copy of these functions does not, so an app moving to them moves every import at once: the
|
|
182
|
+
interceptor, the prompt and every gate.
|
|
183
|
+
|
|
184
|
+
### `useSessionGate()`
|
|
185
|
+
|
|
186
|
+
From `@sjolystinnovation/app-kit/session/react`. Tells a protected page whether it may render. It
|
|
187
|
+
reads `useSession`, so it needs a `SessionProvider` above it.
|
|
188
|
+
|
|
189
|
+
A page that rendered on a healthy session stays rendered when the session gets a terminal error,
|
|
190
|
+
while the session is re-read and while the prompt recovers it, so an open form keeps its values. A
|
|
191
|
+
session that turns signed out with no prompt open blanks the page. A page the user opens
|
|
192
|
+
on a dead session does not render. Use `mayRender` in every gate and layout on the way down, so
|
|
193
|
+
none of them blanks a page that another one kept.
|
|
194
|
+
|
|
195
|
+
The hook does not navigate. The gate sends the user to the login page itself, and never while the
|
|
196
|
+
prompt is open. The app passes the login URL in, so an app with a locale in the path can build it
|
|
197
|
+
for the current locale:
|
|
198
|
+
|
|
199
|
+
```tsx
|
|
200
|
+
"use client";
|
|
201
|
+
|
|
202
|
+
import { useSessionGate } from "@sjolystinnovation/app-kit/session/react";
|
|
203
|
+
import { useRouter } from "next/navigation";
|
|
204
|
+
import { useEffect } from "react";
|
|
205
|
+
|
|
206
|
+
export function ProtectedGate({
|
|
207
|
+
loginHref,
|
|
208
|
+
children,
|
|
209
|
+
}: {
|
|
210
|
+
loginHref: string;
|
|
211
|
+
children: React.ReactNode;
|
|
212
|
+
}) {
|
|
213
|
+
const router = useRouter();
|
|
214
|
+
const { status, promptOpen, usable, wasUsable, mayRender } = useSessionGate();
|
|
215
|
+
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
if (promptOpen) return;
|
|
218
|
+
if (status === "unauthenticated" || (status === "authenticated" && !usable && !wasUsable)) {
|
|
219
|
+
router.push(loginHref);
|
|
220
|
+
}
|
|
221
|
+
}, [status, usable, wasUsable, promptOpen, router, loginHref]);
|
|
222
|
+
|
|
223
|
+
return mayRender ? children : <p>Loading…</p>;
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
| Field | Description |
|
|
228
|
+
| --- | --- |
|
|
229
|
+
| `mayRender` | Whether the page may render. |
|
|
230
|
+
| `session`, `status` | As `useSession` returns them. |
|
|
231
|
+
| `usable` | The session is signed in and has no terminal error. |
|
|
232
|
+
| `wasUsable` | This page has rendered on a usable session. |
|
|
233
|
+
| `promptOpen` | The re-sign-in prompt is showing. |
|
|
234
|
+
| `recovering` | The prompt is showing over a page that was usable. |
|
|
235
|
+
|
|
236
|
+
`SessionGate` is the type of the result.
|
|
237
|
+
|
|
238
|
+
### `useFormDraft(config, options)`
|
|
239
|
+
|
|
240
|
+
From `@sjolystinnovation/app-kit/forms`. Keeps a form's values in `localStorage`, so a sign-out,
|
|
241
|
+
reload or closed tab does not lose them. Values are written half a second after the last change, so
|
|
242
|
+
only that last half second is at risk.
|
|
243
|
+
|
|
244
|
+
```tsx
|
|
245
|
+
// userId is the signed-in user's id, from wherever the app's session keeps it.
|
|
246
|
+
const draft = useFormDraft(sessionConfig, {
|
|
247
|
+
owner: userId,
|
|
248
|
+
scope: recipe ? `recipe:${recipe.id}` : "recipe:new",
|
|
249
|
+
value: form,
|
|
250
|
+
});
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
| Option | Type | Description |
|
|
254
|
+
| --- | --- | --- |
|
|
255
|
+
| `owner` | `string \| undefined` | The signed-in user's id. Drafts are stored per user, and nothing is stored until it is known. The last known owner is kept when the session stops reporting one. |
|
|
256
|
+
| `scope` | `string` | The form and the entity it edits, such as `recipe:42`. |
|
|
257
|
+
| `value` | `T` | The form's current values. They must survive `JSON.stringify`. |
|
|
258
|
+
|
|
259
|
+
Mount an edit form once the entity has loaded, with its values already in `value`. The hook treats
|
|
260
|
+
the values it first sees under a key as the untouched form, so values that arrive later count as an
|
|
261
|
+
edit and are written over the draft being offered.
|
|
262
|
+
|
|
263
|
+
It returns `pending`, the stored draft, which the page offers instead of applying. It is read when the
|
|
264
|
+
owner is first known and again whenever the owner or `scope` changes, so it can appear after the form mounts. It
|
|
265
|
+
also returns `dismiss()` to stop offering it, and `clear()` to drop the stored draft after a
|
|
266
|
+
successful save.
|
|
267
|
+
|
|
268
|
+
Drafts are stored under `<draftStoragePrefix>:draft:<owner>:<scope>`. The hook throws when
|
|
269
|
+
`draftStoragePrefix` is empty or contains a colon. A draft older than 7 days is not offered, and is
|
|
270
|
+
removed when its form next reads it.
|
|
271
|
+
|
|
127
272
|
### `Alert`
|
|
128
273
|
|
|
129
274
|
```tsx
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sjolystinnovation/app-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Building blocks for Next.js apps that sign in against a JWT API through next-auth",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Sjølyst Innovation AS",
|
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
],
|
|
17
17
|
"exports": {
|
|
18
18
|
".": "./src/index.ts",
|
|
19
|
+
"./session": "./src/session/index.ts",
|
|
20
|
+
"./session/react": "./src/session/react/index.ts",
|
|
21
|
+
"./forms": "./src/forms/index.ts",
|
|
19
22
|
"./ui": "./src/ui/index.ts",
|
|
20
23
|
"./styles.css": "./src/styles.css"
|
|
21
24
|
},
|
package/src/config.ts
CHANGED
|
@@ -13,15 +13,35 @@ export interface SessionConfig {
|
|
|
13
13
|
* something like "/en/login", one without uses "/login".
|
|
14
14
|
*/
|
|
15
15
|
loginRoute: string;
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Prefix for the localStorage keys holding form drafts, usually the app's name, such as
|
|
18
|
+
* "example". Drafts are stored under `<prefix>:draft:<owner>:<scope>`, so the prefix itself
|
|
19
|
+
* has no trailing colon.
|
|
20
|
+
*/
|
|
17
21
|
draftStoragePrefix: string;
|
|
18
22
|
}
|
|
19
23
|
|
|
20
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* Declares an app's config as a typed constant. Throws when `draftStoragePrefix` is not a bare name,
|
|
26
|
+
* so a bad value fails when the app starts rather than when a form first opens.
|
|
27
|
+
*/
|
|
21
28
|
export function defineSessionConfig(config: SessionConfig): SessionConfig {
|
|
29
|
+
draftPrefix(config);
|
|
22
30
|
return config;
|
|
23
31
|
}
|
|
24
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The key prefix for form drafts. A colon or an empty name builds keys such as "app:draft::draft:",
|
|
35
|
+
* which match none of the drafts already saved under "app:draft:".
|
|
36
|
+
*/
|
|
37
|
+
export function draftPrefix(config: Pick<SessionConfig, "draftStoragePrefix">): string {
|
|
38
|
+
const name = config.draftStoragePrefix;
|
|
39
|
+
if (!name || name.includes(":")) {
|
|
40
|
+
throw new Error(`draftStoragePrefix must be a bare name such as "example", not "${name}".`);
|
|
41
|
+
}
|
|
42
|
+
return `${name}:draft:`;
|
|
43
|
+
}
|
|
44
|
+
|
|
25
45
|
/**
|
|
26
46
|
* Reads the API JWT secret named by the config. Throws rather than returning undefined, because a
|
|
27
47
|
* missing secret silently breaks token verification at runtime instead of at boot.
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { draftPrefix, type SessionConfig } from "../config";
|
|
5
|
+
|
|
6
|
+
const WRITE_DELAY_MS = 500;
|
|
7
|
+
|
|
8
|
+
// How long a draft is offered. Long enough to outlive an expired session or a closed tab, short
|
|
9
|
+
// enough that week-old work is not offered as if it were current. An expired draft is removed when
|
|
10
|
+
// its form next reads it.
|
|
11
|
+
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
12
|
+
|
|
13
|
+
interface Envelope<T> {
|
|
14
|
+
savedAt: number;
|
|
15
|
+
value: T;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function read<T>(storageKey: string): T | null {
|
|
19
|
+
try {
|
|
20
|
+
const raw = window.localStorage.getItem(storageKey);
|
|
21
|
+
if (raw == null) return null;
|
|
22
|
+
|
|
23
|
+
const envelope = JSON.parse(raw) as Envelope<T> | null;
|
|
24
|
+
const fresh =
|
|
25
|
+
typeof envelope?.savedAt === "number" && Date.now() - envelope.savedAt <= MAX_AGE_MS;
|
|
26
|
+
if (!fresh || envelope?.value === undefined) {
|
|
27
|
+
window.localStorage.removeItem(storageKey);
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return envelope.value;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface FormDraftOptions<T> {
|
|
37
|
+
/**
|
|
38
|
+
* The signed-in user's id. A browser profile can be shared, so the draft is stored per user.
|
|
39
|
+
* Without that, the next person to sign in is offered the previous one's work. The last known
|
|
40
|
+
* owner is kept, because a lost cookie reports nobody and that is the moment the draft matters
|
|
41
|
+
* most. Nothing is stored until an owner is known.
|
|
42
|
+
*/
|
|
43
|
+
owner: string | undefined;
|
|
44
|
+
/** Identifies the form and the entity it edits, for example `recipe:new` or `recipe:42`. */
|
|
45
|
+
scope: string;
|
|
46
|
+
/** The form's current values. They must survive `JSON.stringify`. */
|
|
47
|
+
value: T;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface FormDraft<T> {
|
|
51
|
+
/**
|
|
52
|
+
* The stored draft, while it is still waiting to be restored or dismissed. Read once the owner is
|
|
53
|
+
* known and again whenever the owner or scope changes, so it can appear after mount.
|
|
54
|
+
*/
|
|
55
|
+
pending: T | null;
|
|
56
|
+
/** Stop offering the draft. The caller applies the values it wants. */
|
|
57
|
+
dismiss: () => void;
|
|
58
|
+
/** Drop the stored draft after a successful save. */
|
|
59
|
+
clear: () => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Keeps a form's values in localStorage so a sign-out, reload or closed tab does not lose them.
|
|
64
|
+
* Values are written half a second after the last change, so only that last half second is at risk.
|
|
65
|
+
*
|
|
66
|
+
* A draft found at mount is offered for restore rather than applied, so an edit form never silently
|
|
67
|
+
* overwrites what the API returned. Saving starts immediately either way. The offer is held in
|
|
68
|
+
* memory, so the newest work is always the thing in storage.
|
|
69
|
+
*/
|
|
70
|
+
export function useFormDraft<T>(
|
|
71
|
+
config: Pick<SessionConfig, "draftStoragePrefix">,
|
|
72
|
+
{ owner, scope, value }: FormDraftOptions<T>,
|
|
73
|
+
): FormDraft<T> {
|
|
74
|
+
// Adjusted during render rather than in an effect, which is the supported way to derive state
|
|
75
|
+
// from changing inputs.
|
|
76
|
+
const [lastOwner, setLastOwner] = useState<string | undefined>(undefined);
|
|
77
|
+
if (owner && owner !== lastOwner) setLastOwner(owner);
|
|
78
|
+
|
|
79
|
+
// Checked before the owner is known, so a bad prefix fails on the first render, signed in or not.
|
|
80
|
+
const prefix = draftPrefix(config);
|
|
81
|
+
const key = lastOwner ? `${prefix}${lastOwner}:${scope}` : null;
|
|
82
|
+
|
|
83
|
+
const [pending, setPending] = useState<T | null>(null);
|
|
84
|
+
const json = JSON.stringify(value);
|
|
85
|
+
const untouched = useRef(json);
|
|
86
|
+
const writeTimer = useRef<number | null>(null);
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
// Re-base on a key change too. The values on screen belong to the old key, and writing them
|
|
90
|
+
// under the new one would put this form's work in another form's draft.
|
|
91
|
+
untouched.current = json;
|
|
92
|
+
// Reading the store is an external-system read, which is what an effect is for.
|
|
93
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
94
|
+
setPending(key == null ? null : read<T>(key));
|
|
95
|
+
// Keyed on the draft key alone on purpose. Re-reading whenever the values change would
|
|
96
|
+
// re-offer a draft the user has already answered.
|
|
97
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
98
|
+
}, [key]);
|
|
99
|
+
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (key == null) return;
|
|
102
|
+
// Write only once the form differs from how it opened. Writing the pristine form would
|
|
103
|
+
// overwrite the draft being offered before the user has answered.
|
|
104
|
+
if (json === untouched.current) return;
|
|
105
|
+
const timer = window.setTimeout(() => {
|
|
106
|
+
try {
|
|
107
|
+
window.localStorage.setItem(key, JSON.stringify({ savedAt: Date.now(), value }));
|
|
108
|
+
} catch {
|
|
109
|
+
// A full or blocked store must not break the form.
|
|
110
|
+
}
|
|
111
|
+
}, WRITE_DELAY_MS);
|
|
112
|
+
writeTimer.current = timer;
|
|
113
|
+
return () => window.clearTimeout(timer);
|
|
114
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
115
|
+
}, [key, json]);
|
|
116
|
+
|
|
117
|
+
const clear = useCallback(() => {
|
|
118
|
+
if (key == null) return;
|
|
119
|
+
// Cancel a write that is already scheduled, and treat the current values as the new baseline.
|
|
120
|
+
// Otherwise a save is followed by the draft being written back.
|
|
121
|
+
if (writeTimer.current != null) window.clearTimeout(writeTimer.current);
|
|
122
|
+
untouched.current = json;
|
|
123
|
+
try {
|
|
124
|
+
window.localStorage.removeItem(key);
|
|
125
|
+
} catch {
|
|
126
|
+
// The draft is a convenience, not state anything depends on.
|
|
127
|
+
}
|
|
128
|
+
}, [key, json]);
|
|
129
|
+
|
|
130
|
+
const dismiss = useCallback(() => setPending(null), []);
|
|
131
|
+
|
|
132
|
+
return { pending, dismiss, clear };
|
|
133
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Errors the jwt callback sets when the session cannot be recovered without a new sign-in. A
|
|
3
|
+
* transient refresh failure, such as a network error or an API restart, sets no error, so the
|
|
4
|
+
* session keeps working and a later session read retries the refresh.
|
|
5
|
+
*/
|
|
6
|
+
export const SESSION_ERRORS = {
|
|
7
|
+
absoluteExpiry: "AbsoluteSessionExpired",
|
|
8
|
+
refreshRejected: "RefreshTokenRejected",
|
|
9
|
+
noRefreshToken: "NoRefreshToken",
|
|
10
|
+
} as const;
|
|
11
|
+
|
|
12
|
+
/** One of the values in `SESSION_ERRORS`. */
|
|
13
|
+
export type SessionError = (typeof SESSION_ERRORS)[keyof typeof SESSION_ERRORS];
|
|
14
|
+
|
|
15
|
+
const TERMINAL: ReadonlySet<string> = new Set(Object.values(SESSION_ERRORS));
|
|
16
|
+
|
|
17
|
+
/** Whether a session error means the user has to sign in again. */
|
|
18
|
+
export function isTerminalSessionError(error: string | undefined): boolean {
|
|
19
|
+
return error != null && TERMINAL.has(error);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Whether the re-sign-in prompt is showing. It lives outside React because the axios interceptor
|
|
23
|
+
// that raises it is a plain module, and because the page gate has to know: it must not redirect to
|
|
24
|
+
// the login page while the prompt is recovering the session in place.
|
|
25
|
+
//
|
|
26
|
+
// Kept on globalThis under a registered symbol, so two copies of this module, such as two installed
|
|
27
|
+
// versions of the package, still share one prompt. Separate state would let a prompt raised through
|
|
28
|
+
// one copy go unseen by a gate reading the other, and the gate would redirect away from the form.
|
|
29
|
+
interface PromptState {
|
|
30
|
+
open: boolean;
|
|
31
|
+
listeners: Set<() => void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The shape of PromptState is a contract between releases. A release that changes it must also
|
|
35
|
+
// change this key, so it never reads state in a shape it does not expect.
|
|
36
|
+
const STATE_KEY = Symbol.for("@sjolystinnovation/app-kit/session-prompt/v1");
|
|
37
|
+
const shared = globalThis as typeof globalThis & { [STATE_KEY]?: PromptState };
|
|
38
|
+
const state: PromptState = (shared[STATE_KEY] ??= { open: false, listeners: new Set() });
|
|
39
|
+
|
|
40
|
+
function emit(): void {
|
|
41
|
+
for (const listener of [...state.listeners]) listener();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Ask the user to sign in again without leaving the page. */
|
|
45
|
+
export function openSessionPrompt(): void {
|
|
46
|
+
if (state.open) return;
|
|
47
|
+
state.open = true;
|
|
48
|
+
emit();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function closeSessionPrompt(): void {
|
|
52
|
+
if (!state.open) return;
|
|
53
|
+
state.open = false;
|
|
54
|
+
emit();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function subscribeSessionPrompt(listener: () => void): () => void {
|
|
58
|
+
state.listeners.add(listener);
|
|
59
|
+
return () => {
|
|
60
|
+
state.listeners.delete(listener);
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function getSessionPromptOpen(): boolean {
|
|
65
|
+
return state.open;
|
|
66
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useSession } from "next-auth/react";
|
|
4
|
+
import { usePathname } from "next/navigation";
|
|
5
|
+
import { useState, useSyncExternalStore } from "react";
|
|
6
|
+
import { getSessionPromptOpen, isTerminalSessionError, subscribeSessionPrompt } from "../expiry";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Whether a protected page may render, and whether it must stay rendered.
|
|
10
|
+
*
|
|
11
|
+
* Every gate on the way down should use this, so they agree. A nested layout that decides on its
|
|
12
|
+
* own to blank the page undoes the in-place recovery the re-sign-in prompt exists for, and the user
|
|
13
|
+
* loses the form anyway.
|
|
14
|
+
*/
|
|
15
|
+
export function useSessionGate() {
|
|
16
|
+
const { data: session, status } = useSession();
|
|
17
|
+
const pathname = usePathname();
|
|
18
|
+
const promptOpen = useSyncExternalStore(
|
|
19
|
+
subscribeSessionPrompt,
|
|
20
|
+
getSessionPromptOpen,
|
|
21
|
+
() => false,
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
// The jwt callback copies its error onto the session. Read it without requiring the app to have
|
|
25
|
+
// added `error` to next-auth's Session type.
|
|
26
|
+
const error = (session as { error?: string } | null)?.error;
|
|
27
|
+
const usable = status === "authenticated" && !isTerminalSessionError(error);
|
|
28
|
+
|
|
29
|
+
// Which page last rendered on a healthy session. Keyed by path, so each page starts the check
|
|
30
|
+
// again and a session that died on the previous page cannot carry a stale pass into a fresh form.
|
|
31
|
+
// Adjusted during render rather than in an effect, which is the supported way to derive state
|
|
32
|
+
// from changing inputs.
|
|
33
|
+
// Undefined until then rather than null, because usePathname can itself return null.
|
|
34
|
+
const [usableAt, setUsableAt] = useState<string | null | undefined>(undefined);
|
|
35
|
+
if (usable && usableAt !== pathname) setUsableAt(pathname);
|
|
36
|
+
const wasUsable = usableAt === pathname;
|
|
37
|
+
|
|
38
|
+
// The page is already open and the prompt is recovering the session in place.
|
|
39
|
+
const recovering = promptOpen && wasUsable;
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
session,
|
|
43
|
+
status,
|
|
44
|
+
promptOpen,
|
|
45
|
+
usable,
|
|
46
|
+
wasUsable,
|
|
47
|
+
recovering,
|
|
48
|
+
/**
|
|
49
|
+
* The one answer every gate on the way down uses. A layout that decides this again can blank
|
|
50
|
+
* the page that the gate above it chose to keep.
|
|
51
|
+
*/
|
|
52
|
+
mayRender:
|
|
53
|
+
recovering ||
|
|
54
|
+
!(
|
|
55
|
+
(status === "loading" && !wasUsable) ||
|
|
56
|
+
status === "unauthenticated" ||
|
|
57
|
+
(!usable && !wasUsable)
|
|
58
|
+
),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** What `useSessionGate` returns. */
|
|
63
|
+
export type SessionGate = ReturnType<typeof useSessionGate>;
|