@sjolystinnovation/app-kit 0.3.0 → 0.5.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 CHANGED
@@ -5,9 +5,13 @@
5
5
 
6
6
  > Building blocks for Next.js apps that sign in against a JWT API through next-auth
7
7
 
8
- Several apps sharing one API also share the code around it: the session plumbing, how a failed
9
- request becomes a message a user can read, and the form controls on the sign in page. This package
10
- holds that code once, so the apps install it instead of each keeping a copy.
8
+ Several apps sharing one API also share the code around it:
9
+
10
+ - the session plumbing
11
+ - how a failed request becomes a message a user can read
12
+ - the form controls on the sign in page
13
+
14
+ This package holds that code once, and each app installs it.
11
15
 
12
16
  ## Install
13
17
 
@@ -64,21 +68,29 @@ const secret = resolveJwtSecret(sessionConfig, process.env);
64
68
  | --- | --- | --- |
65
69
  | `jwtSecretEnvVar` | `string` | Name of the environment variable holding the API JWT secret, such as `API_JWT_SECRET`. |
66
70
  | `loginRoute` | `string` | Where an expired session sends the browser, such as `/en/login` or `/login`. |
67
- | `draftStoragePrefix` | `string` | Prefix for the `localStorage` keys holding form drafts. |
71
+ | `draftStoragePrefix` | `string` | Bare name for the `localStorage` keys holding form drafts, such as `example`. Drafts are stored under `example:draft:<owner>:<scope>`. |
68
72
 
69
73
  ### `defineSessionConfig(config)`
70
74
 
71
75
  `(config: SessionConfig) => SessionConfig`
72
76
 
73
- Returns the config unchanged, so TypeScript checks its shape where it is declared rather than at
74
- every call site.
77
+ Returns the config unchanged, so TypeScript checks its shape once, where it is declared. Throws
78
+ when `draftStoragePrefix` is empty or contains a colon.
79
+
80
+ ### `requireEnv(name, value)`
81
+
82
+ `(name: string, value: string | undefined) => string`
83
+
84
+ Returns `value`, or throws `Missing <name>.` when it is unset or empty. Pass the value read in
85
+ place, such as `process.env.NEXT_PUBLIC_API_URL`, so Next.js can inline a public variable at build
86
+ time.
75
87
 
76
88
  ### `resolveJwtSecret(config, env)`
77
89
 
78
90
  `(config: SessionConfig, env: Record<string, string | undefined>) => string`
79
91
 
80
- Reads the secret named by `config.jwtSecretEnvVar`. Throws when that variable is unset or empty, so a
81
- missing secret fails at startup instead of at the first token verification.
92
+ Reads the secret named by `config.jwtSecretEnvVar`. Throws when that variable is unset or empty,
93
+ with the variable's name in the message.
82
94
 
83
95
  ```ts
84
96
  resolveJwtSecret(sessionConfig, { API_JWT_SECRET: "s3cret" });
@@ -88,8 +100,8 @@ resolveJwtSecret(sessionConfig, {});
88
100
  // Error: Missing API_JWT_SECRET. The app verifies API session tokens with it.
89
101
  ```
90
102
 
91
- The environment is a parameter rather than a read of `process.env`, which keeps Node types out of a
92
- package that also runs in the browser.
103
+ The environment is a parameter, so the root entry point needs no Node types and works in browser
104
+ code. `@sjolystinnovation/app-kit/auth` is server only and does load Node types.
93
105
 
94
106
  ### `formatApiError(error, fallback, statusMessages?)`
95
107
 
@@ -110,11 +122,16 @@ body can come from a proxy, such as "Bad Gateway", so the caller's own words win
110
122
  A problem details `title` is never shown. ASP.NET Core fills it with generic text, such as "Bad
111
123
  Request" or "An error occurred while processing your request.", which says less than `fallback`.
112
124
 
113
- Key `0` in `statusMessages` covers a request that got no response, because of a dropped connection
114
- or a timeout. Only text on a single line counts as a message. Axios's own message, such as "Request
115
- failed with status code 500", is never shown, and a cancelled request gives `fallback`. Any other
116
- `Error` gives its own message. A body requested as an `arraybuffer` or a `blob`, as for a file
117
- download, is not read, so a failed download gets `statusMessages` or `fallback`.
125
+ A few more rules:
126
+
127
+ - Key `0` in `statusMessages` covers a request that got no response, because of a dropped
128
+ connection or a timeout.
129
+ - Only text on a single line counts as a message.
130
+ - Axios's own message, such as "Request failed with status code 500", is never shown.
131
+ - A cancelled request gives `fallback`.
132
+ - Any other `Error` gives its own message.
133
+ - A body requested as an `arraybuffer` or a `blob`, as for a file download, is not read. A failed
134
+ download gets `statusMessages` or `fallback`.
118
135
 
119
136
  ```ts
120
137
  try {
@@ -124,6 +141,280 @@ try {
124
141
  }
125
142
  ```
126
143
 
144
+ ### `createAuthOptions(config, settings)`
145
+
146
+ From `@sjolystinnovation/app-kit/auth`. Server only. Builds the next-auth options for an app that
147
+ signs in against the API with email and password:
148
+
149
+ ```ts
150
+ // src/lib/authOptions.ts
151
+ import { requireEnv } from "@sjolystinnovation/app-kit";
152
+ import { createAuthOptions } from "@sjolystinnovation/app-kit/auth";
153
+ import { sessionConfig } from "@/lib/session";
154
+
155
+ export const authOptions = createAuthOptions(sessionConfig, {
156
+ apiUrl: requireEnv("NEXT_PUBLIC_API_URL", process.env.NEXT_PUBLIC_API_URL),
157
+ env: process.env,
158
+ });
159
+ ```
160
+
161
+ ```ts
162
+ // src/app/api/auth/[...nextauth]/route.ts
163
+ import NextAuth from "next-auth/next";
164
+ import { authOptions } from "@/lib/authOptions";
165
+
166
+ const handler = NextAuth(authOptions);
167
+
168
+ export { handler as GET, handler as POST };
169
+ ```
170
+
171
+ | Setting | Type | Description |
172
+ | --- | --- | --- |
173
+ | `apiUrl` | `string` | Base URL of the API. Sign-in posts to `/auth/login` and refresh to `/auth/refresh-token` under it. An empty value throws. |
174
+ | `env` | `Record<string, string \| undefined>` | The server environment, usually `process.env`. It supplies the secret named by `jwtSecretEnvVar`. |
175
+ | `http` | `AxiosInstance` | In place of `apiUrl`, for tests. The client for sign-in and refresh, with its own base URL. |
176
+
177
+ next-auth reads `NEXTAUTH_SECRET` itself and refuses to run in production without it.
178
+
179
+ What the options do:
180
+
181
+ - **Sign-in** posts `{ email, password }` and expects `{ token, refreshToken }`. The access token is
182
+ verified with the API JWT secret, allowing 60 seconds of clock difference between the server and
183
+ the API. Its `sub`, `unique_name` and `role` claims become the user's `id`, `name` and `roles`.
184
+ The log gets the status, error code and message, never the request. A failed sign-in reports one
185
+ of these in next-auth's `error`:
186
+ - `SIGN_IN_ERRORS.unavailable` when the API does not answer, or a gateway returns 502, 503 or 504.
187
+ - `SIGN_IN_ERRORS.invalidCredentials` for every other failure, including an unreadable token and
188
+ an answer without both tokens.
189
+ - **Refresh** runs ahead of expiry: five minutes before it, or a quarter of the token's lifetime
190
+ before it when that is shorter. It is never scheduled sooner than 30 seconds out, so a token that
191
+ lives under 30 seconds is refreshed after it expires. Parallel reads of one session in one server
192
+ process share a single refresh. Its answer is kept for a minute, because the API rotates the
193
+ refresh token on every call.
194
+ - **Failures:** a 400 or 401 from the refresh ends the session with `RefreshTokenRejected`. Any
195
+ other failure keeps it and tries again a minute later. A session without a refresh token ends with
196
+ `NoRefreshToken` when its refresh falls due.
197
+ - **Cap:** the session ends seven days after sign-in with `AbsoluteSessionExpired`, however recently
198
+ it was refreshed.
199
+ - **Session:** it carries `user`, `accessToken`, `error`, and `absoluteExpiresAt`, the end of the
200
+ seven days in milliseconds.
201
+ - **Sign-in page:** `pages.signIn` is `config.loginRoute`.
202
+
203
+ Two rules keep a refresh from being lost:
204
+
205
+ - Read the session on the client, through `useSession` or `getSession`. In the App Router,
206
+ `getServerSession(authOptions)` cannot store a cookie, in server components, route handlers and
207
+ server actions alike. A refresh there replaces the tokens at the API and loses the new ones. The
208
+ next refresh then reuses the old token, and the API revokes every session the user has. On the
209
+ server, read the token with `getToken` from `next-auth/jwt`, which never refreshes.
210
+ - Run the app as one server process. Parallel session reads share one refresh only within a process.
211
+ Two replicas can refresh the same token at once, with the same result.
212
+
213
+ The package does not declare these fields on next-auth's types. The app declares them, so its own
214
+ code can read them:
215
+
216
+ ```ts
217
+ // src/types/next-auth.d.ts
218
+ import "next-auth";
219
+ import "next-auth/jwt";
220
+
221
+ declare module "next-auth" {
222
+ interface Session {
223
+ user: { id: string; name: string; email: string; roles: string[] };
224
+ accessToken: string;
225
+ error?: string | undefined;
226
+ absoluteExpiresAt?: number;
227
+ }
228
+ }
229
+
230
+ declare module "next-auth/jwt" {
231
+ interface JWT {
232
+ accessToken?: string;
233
+ refreshToken?: string;
234
+ refreshAt?: number;
235
+ loginAt?: number;
236
+ user?: { id: string; name: string; email: string; roles: string[] };
237
+ error?: string | undefined;
238
+ }
239
+ }
240
+ ```
241
+
242
+ ### `@sjolystinnovation/app-kit/api`
243
+
244
+ `createApiClient(baseURL)` returns an axios instance for the signed-in API, for code that runs in
245
+ the browser. It reads the session through next-auth's `getSession`, which has no cookies to read on
246
+ the server. An empty `baseURL` throws. Each request carries the session's access token. A 401
247
+ opens the re-sign-in prompt when the session is gone or has a terminal error. It does not sign
248
+ the user out, so the page keeps its state. The error still rejects as usual.
249
+
250
+ `request(call, fallback, statusMessages?)` runs a call and returns its `data`. A failure becomes an
251
+ `Error` with the message `formatApiError` gives it. A cancelled call rethrows the cancel unchanged:
252
+
253
+ ```ts
254
+ import { requireEnv } from "@sjolystinnovation/app-kit";
255
+ import { createApiClient, request } from "@sjolystinnovation/app-kit/api";
256
+
257
+ const api = createApiClient(requireEnv("NEXT_PUBLIC_API_URL", process.env.NEXT_PUBLIC_API_URL));
258
+
259
+ export const getRecipe = (id: number) =>
260
+ request(() => api.get<Recipe>(`/recipes/${id}`), "Could not load the recipe.");
261
+ ```
262
+
263
+ ### `@sjolystinnovation/app-kit/session`
264
+
265
+ Decides when a user has to sign in again. Safe to import from server code, such as the next-auth
266
+ callbacks.
267
+
268
+ The gate reads `session.error`. `createAuthOptions` sets it already. An app with its own next-auth
269
+ options sets `token.error` in its `jwt` callback and copies it across in its `session` callback:
270
+
271
+ ```ts
272
+ callbacks: {
273
+ session({ session, token }) {
274
+ session.error = token.error;
275
+ return session;
276
+ },
277
+ },
278
+ ```
279
+
280
+ For that to compile, `error` has to be on next-auth's `Session` and `JWT` types. Add it to
281
+ the app's existing next-auth type declarations, or create them:
282
+
283
+ ```ts
284
+ // src/types/next-auth.d.ts
285
+ import "next-auth";
286
+ import "next-auth/jwt";
287
+
288
+ declare module "next-auth" {
289
+ interface Session {
290
+ error?: string | undefined;
291
+ }
292
+ }
293
+
294
+ declare module "next-auth/jwt" {
295
+ interface JWT {
296
+ error?: string | undefined;
297
+ }
298
+ }
299
+ ```
300
+
301
+ `SIGN_IN_ERRORS` holds the two codes a failed sign-in reports: `InvalidCredentials` and
302
+ `SignInUnavailable`. Map them to the app's own messages.
303
+
304
+ `SESSION_ERRORS` holds the values the jwt callback puts in `token.error` when the session cannot be
305
+ recovered: `AbsoluteSessionExpired`, `RefreshTokenRejected` and `NoRefreshToken`. `SessionError`
306
+ is their union type. `isTerminalSessionError(error)` is true for those three only, so a transient
307
+ refresh failure keeps the session.
308
+
309
+ The re-sign-in prompt is a flag outside React, so an axios interceptor can raise it:
310
+
311
+ | Function | Description |
312
+ | --- | --- |
313
+ | `openSessionPrompt()` | Asks the user to sign in again without leaving the page. |
314
+ | `closeSessionPrompt()` | Hides the prompt. Call it when the component showing the prompt unmounts, or the flag stays open and the gate never redirects. |
315
+ | `subscribeSessionPrompt(listener)` | Calls `listener` when the prompt opens or closes. Returns an unsubscribe function. |
316
+ | `getSessionPromptOpen()` | Whether the prompt is showing. |
317
+
318
+ Copies of the package installed side by side share the one flag, as long as their releases use the
319
+ same prompt state format. A release that changes the format says so in its changelog. An app's own
320
+ copy of these functions does not share the flag. So the interceptor, the prompt and every gate all
321
+ import them from this package.
322
+
323
+ ### `useSessionGate()`
324
+
325
+ From `@sjolystinnovation/app-kit/session/react`. Tells a protected page whether it may render. It
326
+ reads `useSession`, so it needs a `SessionProvider` above it.
327
+
328
+ A page that rendered on a healthy session stays rendered, so an open form keeps its values:
329
+
330
+ - when the session gets a terminal error
331
+ - while the session is re-read
332
+ - while the prompt recovers it
333
+
334
+ A session that turns signed out with no prompt open blanks the page. A page the user opens on a
335
+ dead session does not render. Use `mayRender` in every gate and layout on the way down, so none of
336
+ them blanks a page that another one kept.
337
+
338
+ The hook does not navigate. The gate sends the user to the login page itself, and never while the
339
+ prompt is open. The app passes the login URL in, so an app with a locale in the path can build it
340
+ for the current locale:
341
+
342
+ ```tsx
343
+ "use client";
344
+
345
+ import { useSessionGate } from "@sjolystinnovation/app-kit/session/react";
346
+ import { useRouter } from "next/navigation";
347
+ import { useEffect } from "react";
348
+
349
+ export function ProtectedGate({
350
+ loginHref,
351
+ children,
352
+ }: {
353
+ loginHref: string;
354
+ children: React.ReactNode;
355
+ }) {
356
+ const router = useRouter();
357
+ const { status, promptOpen, usable, wasUsable, mayRender } = useSessionGate();
358
+
359
+ useEffect(() => {
360
+ if (promptOpen) return;
361
+ if (status === "unauthenticated" || (status === "authenticated" && !usable && !wasUsable)) {
362
+ router.push(loginHref);
363
+ }
364
+ }, [status, usable, wasUsable, promptOpen, router, loginHref]);
365
+
366
+ return mayRender ? children : <p>Loading…</p>;
367
+ }
368
+ ```
369
+
370
+ | Field | Description |
371
+ | --- | --- |
372
+ | `mayRender` | Whether the page may render. |
373
+ | `session`, `status` | As `useSession` returns them. |
374
+ | `usable` | The session is signed in and has no terminal error. |
375
+ | `wasUsable` | This page has rendered on a usable session. |
376
+ | `promptOpen` | The re-sign-in prompt is showing. |
377
+ | `recovering` | The prompt is showing over a page that was usable. |
378
+
379
+ `SessionGate` is the type of the result.
380
+
381
+ ### `useFormDraft(config, options)`
382
+
383
+ From `@sjolystinnovation/app-kit/forms`. Keeps a form's values in `localStorage`, so a sign-out,
384
+ reload or closed tab does not lose them. Values are written half a second after the last change, so
385
+ only that last half second is at risk.
386
+
387
+ ```tsx
388
+ // userId is the signed-in user's id, from wherever the app's session keeps it.
389
+ const draft = useFormDraft(sessionConfig, {
390
+ owner: userId,
391
+ scope: recipe ? `recipe:${recipe.id}` : "recipe:new",
392
+ value: form,
393
+ });
394
+ ```
395
+
396
+ | Option | Type | Description |
397
+ | --- | --- | --- |
398
+ | `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. |
399
+ | `scope` | `string` | The form and the entity it edits, such as `recipe:42`. |
400
+ | `value` | `T` | The form's current values. They must survive `JSON.stringify`. |
401
+
402
+ Mount an edit form once the entity has loaded, with its values already in `value`. The hook treats
403
+ the values it first sees under a key as the untouched form, so values that arrive later count as an
404
+ edit and are written over the draft being offered.
405
+
406
+ It returns:
407
+
408
+ - `pending`, the stored draft. The page offers it to the user and does not apply it. It is read
409
+ when the owner is first known and again whenever the owner or `scope` changes, so it can appear
410
+ after the form mounts.
411
+ - `dismiss()`, to stop offering it.
412
+ - `clear()`, to drop the stored draft after a successful save.
413
+
414
+ Drafts are stored under `<draftStoragePrefix>:draft:<owner>:<scope>`. The hook throws when
415
+ `draftStoragePrefix` is empty or contains a colon. A draft older than 7 days is not offered, and is
416
+ removed when its form next reads it.
417
+
127
418
  ### `Alert`
128
419
 
129
420
  ```tsx
@@ -136,7 +427,7 @@ import { Alert } from "@sjolystinnovation/app-kit/ui";
136
427
  | --- | --- | --- | --- |
137
428
  | `variant` | `"error" \| "success" \| "info" \| "warning"` | `"info"` | Colour of the box. |
138
429
  | `role` | `"alert" \| "status"` | `"alert"` | Use `status` for text that keeps changing, such as a countdown, so a screen reader does not announce every change. |
139
- | `className` | `string` | | Classes for placing the alert, such as a margin. Restyle it through the theme variables instead, since whether a clashing class wins depends on the order Tailwind emits them in. |
430
+ | `className` | `string` | | Classes for placing the alert, such as a margin. Restyle it through the theme variables. A clashing class wins or loses by the order Tailwind emits them in. |
140
431
  | `children` | `ReactNode` | | Content. |
141
432
 
142
433
  ### `PasswordInput`
@@ -157,7 +448,7 @@ import { PasswordInput } from "@sjolystinnovation/app-kit/ui";
157
448
  | `error` | `string` | | Message shown under the field. It turns the border red, marks the input invalid and is read out with it by screen readers. |
158
449
  | `showPasswordLabel` | `string` | `"Show password"` | Accessible name of the toggle while the value is hidden. |
159
450
  | `hidePasswordLabel` | `string` | `"Hide password"` | Accessible name of the toggle while the value is visible. |
160
- | `className` | `string` | | Classes for placing the field, such as a margin. They go on the outer element around the label, input and error. Restyle the field through the theme variables instead. |
451
+ | `className` | `string` | | Classes for placing the field, such as a margin. They go on the outer element around the label, input and error. Restyle the field through the theme variables. |
161
452
 
162
453
  Pass the two toggle labels in the app's language. The defaults are English.
163
454
 
@@ -214,15 +505,17 @@ with it and has to set the ones it uses itself.
214
505
 
215
506
  ## Requirements
216
507
 
217
- - Next.js 16, React 19, next-auth 4 and axios 1, as peer dependencies the app installs itself.
508
+ - Next.js 16, React 19, next-auth 4.24 or later and axios 1.20 or later, as peer dependencies the
509
+ app installs itself.
510
+ - `jsonwebtoken` comes with the package. `@sjolystinnovation/app-kit/auth` uses it on the server.
218
511
  - For `@sjolystinnovation/app-kit/ui`: Tailwind CSS 4 and `@heroicons/react` 2. The focus ring uses
219
512
  the theme's `primary` colour unless `--color-input-focus-ring` is set.
220
513
  - TypeScript with `"moduleResolution": "bundler"`. Relative imports here are extensionless, which
221
514
  `node16` and `nodenext` reject.
222
515
 
223
- There is no build output. Shipping source is what keeps the `"use client"` directives intact, since
224
- bundlers strip or relocate them. The consumer therefore compiles this source with its own
225
- `compilerOptions`, and `skipLibCheck` does not exempt it.
516
+ There is no build output. Bundlers strip or relocate `"use client"` directives, and shipping source
517
+ keeps them intact. The consumer compiles this source with its own `compilerOptions`, and
518
+ `skipLibCheck` does not exempt it.
226
519
 
227
520
  ## License
228
521
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjolystinnovation/app-kit",
3
- "version": "0.3.0",
3
+ "version": "0.5.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,11 @@
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",
22
+ "./auth": "./src/auth/index.ts",
23
+ "./api": "./src/api/index.ts",
19
24
  "./ui": "./src/ui/index.ts",
20
25
  "./styles.css": "./src/styles.css"
21
26
  },
@@ -32,7 +37,7 @@
32
37
  },
33
38
  "peerDependencies": {
34
39
  "@heroicons/react": "^2",
35
- "axios": "^1",
40
+ "axios": "^1.20.0",
36
41
  "next": "^16",
37
42
  "next-auth": "^4.24",
38
43
  "react": "^19",
@@ -46,7 +51,7 @@
46
51
  "@types/react": "^19",
47
52
  "@types/react-dom": "^19",
48
53
  "@vitejs/plugin-react": "^6.1.2",
49
- "axios": "^1.19.0",
54
+ "axios": "^1.20.0",
50
55
  "jsdom": "^30.1.1",
51
56
  "next": "^16.3.6",
52
57
  "next-auth": "^4.24.15",
@@ -62,5 +67,9 @@
62
67
  "jwt",
63
68
  "react",
64
69
  "tailwindcss"
65
- ]
70
+ ],
71
+ "dependencies": {
72
+ "@types/jsonwebtoken": "^9.0.8",
73
+ "jsonwebtoken": "^9.0.2"
74
+ }
66
75
  }
@@ -0,0 +1,43 @@
1
+ import axios, { type AxiosInstance } from "axios";
2
+ import { getSession } from "next-auth/react";
3
+ import { isTerminalSessionError, openSessionPrompt } from "../session/expiry";
4
+
5
+ /**
6
+ * An axios instance for the signed-in API, for browser code. Each request carries the session's
7
+ * access token. A 401 on a dead session opens the re-sign-in prompt, so the page keeps its state.
8
+ */
9
+ export function createApiClient(baseURL: string): AxiosInstance {
10
+ if (!baseURL) {
11
+ throw new Error("createApiClient needs the API base URL, and baseURL is empty.");
12
+ }
13
+ // allowAbsoluteUrls: false joins every URL onto baseURL, so the token only goes to the API.
14
+ const client = axios.create({ baseURL, allowAbsoluteUrls: false });
15
+
16
+ // broadcast: false, so a request does not make every other open tab refetch its session.
17
+ client.interceptors.request.use(async (request) => {
18
+ const session = await getSession({ broadcast: false });
19
+ const accessToken = (session as { accessToken?: string } | null)?.accessToken;
20
+ if (accessToken) {
21
+ request.headers.Authorization = `Bearer ${accessToken}`;
22
+ }
23
+ return request;
24
+ });
25
+
26
+ client.interceptors.response.use(
27
+ (response) => response,
28
+ async (error) => {
29
+ if (axios.isAxiosError(error) && error.response?.status === 401) {
30
+ const session = await getSession({ broadcast: false });
31
+ // No session also opens the prompt. The cookie is gone, or the session fetch failed. Either
32
+ // way there is no error field to read, and signing in again recovers both.
33
+ const sessionError = (session as { error?: string } | null)?.error;
34
+ if (!session || isTerminalSessionError(sessionError)) {
35
+ openSessionPrompt();
36
+ }
37
+ }
38
+ return Promise.reject(error);
39
+ },
40
+ );
41
+
42
+ return client;
43
+ }
@@ -0,0 +1,2 @@
1
+ export { createApiClient } from "./createApiClient";
2
+ export { request } from "./request";
@@ -0,0 +1,22 @@
1
+ import axios from "axios";
2
+ import { formatApiError, type StatusMessages } from "../errors";
3
+
4
+ /**
5
+ * Runs an API call and returns its data. A failure throws an `Error` with a message for the user,
6
+ * from `formatApiError`. A cancelled call rethrows the cancel as it is, so the caller can ignore it.
7
+ */
8
+ export async function request<T>(
9
+ call: () => Promise<{ data: T }>,
10
+ fallback: string,
11
+ statusMessages?: StatusMessages,
12
+ ): Promise<T> {
13
+ try {
14
+ const response = await call();
15
+ return response.data;
16
+ } catch (error: unknown) {
17
+ if (axios.isCancel(error)) throw error;
18
+ // No cause. Errors get logged, and the axios error carries the access token and request body.
19
+ // eslint-disable-next-line preserve-caught-error
20
+ throw new Error(formatApiError(error, fallback, statusMessages));
21
+ }
22
+ }
@@ -0,0 +1,313 @@
1
+ import axios, { type AxiosInstance } from "axios";
2
+ import jwt from "jsonwebtoken";
3
+ import type { NextAuthOptions, Session, User } from "next-auth";
4
+ import CredentialsProvider from "next-auth/providers/credentials";
5
+ import type { JWT } from "next-auth/jwt";
6
+ import { resolveJwtSecret, type SessionConfig } from "../config";
7
+ import { SESSION_ERRORS, SIGN_IN_ERRORS, isTerminalSessionError } from "../session/expiry";
8
+
9
+ /** The signed-in user, as the session carries it. */
10
+ export interface SessionUser {
11
+ id: string;
12
+ name: string;
13
+ email: string;
14
+ roles: string[];
15
+ }
16
+
17
+ interface TokenPair {
18
+ token: string;
19
+ refreshToken: string;
20
+ }
21
+
22
+ interface ApiClaims {
23
+ sub: string;
24
+ unique_name: string;
25
+ role?: string | string[];
26
+ exp: number;
27
+ iat: number;
28
+ }
29
+
30
+ // The fields this module stores in next-auth's JWT. An intersection still compiles when the app
31
+ // declares the same fields itself.
32
+ type AppToken = JWT & {
33
+ accessToken?: string;
34
+ refreshToken?: string;
35
+ refreshAt?: number;
36
+ loginAt?: number;
37
+ user?: SessionUser;
38
+ error?: string;
39
+ };
40
+
41
+ interface AuthorizedUser extends User {
42
+ roles: string[];
43
+ accessToken: string;
44
+ refreshToken: string;
45
+ }
46
+
47
+ export type AuthOptionsSettings = {
48
+ /** The server environment, usually `process.env`. It holds the API JWT secret. */
49
+ env: Record<string, string | undefined>;
50
+ } & (
51
+ | {
52
+ /** Base URL of the API, such as `https://api.example.com/api`. */
53
+ apiUrl: string;
54
+ http?: never;
55
+ }
56
+ | {
57
+ /** An HTTP client with its own base URL, for tests. */
58
+ http: AxiosInstance;
59
+ apiUrl?: never;
60
+ }
61
+ );
62
+
63
+ const ABSOLUTE_SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
64
+
65
+ // Refresh ahead of expiry, so the token is still fresh when the user saves.
66
+ const MAX_REFRESH_SKEW_MS = 5 * 60 * 1000;
67
+
68
+ // The soonest a refresh can be due. The floor stops a short-lived token from refreshing on every
69
+ // session read.
70
+ const MIN_REFRESH_GAP_MS = 30 * 1000;
71
+
72
+ // How long a refresh result is remembered. It is also the wait before a failed refresh is retried.
73
+ const REMEMBER_ROTATION_MS = 60 * 1000;
74
+
75
+ // How far this server's clock may differ from the API's when a token's times are checked.
76
+ const CLOCK_TOLERANCE_S = 60;
77
+
78
+ // Sign-in and session reads wait for the API, so a hung connection must not hold them forever.
79
+ const API_TIMEOUT_MS = 10_000;
80
+
81
+ function rolesOf(claims: ApiClaims): string[] {
82
+ const raw = claims.role;
83
+ return Array.isArray(raw) ? raw : raw ? [raw] : [];
84
+ }
85
+
86
+ // The skew is capped at a quarter of the token's lifetime, so a short-lived token is not refreshed
87
+ // on every read. A token without a lifetime gets the floor.
88
+ function nextRefreshAt(claims: ApiClaims): number {
89
+ const expiresAt = claims.exp * 1000;
90
+ const lifetimeMs = Math.max(0, (claims.exp - claims.iat) * 1000);
91
+ const refreshAt = expiresAt - Math.min(MAX_REFRESH_SKEW_MS, lifetimeMs / 4);
92
+ if (!Number.isFinite(refreshAt)) return Date.now() + MIN_REFRESH_GAP_MS;
93
+ return Math.max(Date.now() + MIN_REFRESH_GAP_MS, refreshAt);
94
+ }
95
+
96
+ // No answer, or a gateway that could not reach the API. A plain 500 is left out, because the API
97
+ // may fail after it has checked the password.
98
+ function apiUnavailable(error: unknown): boolean {
99
+ if (!axios.isAxiosError(error)) return false;
100
+ const status = error.response?.status;
101
+ return status === undefined || status === 502 || status === 503 || status === 504;
102
+ }
103
+
104
+ // What a log line may contain. The error itself carries the request, which holds the password or
105
+ // tokens.
106
+ function failureShape(error: unknown) {
107
+ return {
108
+ status: axios.isAxiosError(error) ? error.response?.status : undefined,
109
+ code: axios.isAxiosError(error) ? error.code : undefined,
110
+ message: error instanceof Error ? error.message : String(error),
111
+ };
112
+ }
113
+
114
+ /**
115
+ * next-auth options for email and password sign-in against the API. The API issues a short-lived
116
+ * access token and a refresh token that changes on every refresh. The session refreshes ahead of
117
+ * expiry, rides out temporary API failures, and ends seven days after sign-in.
118
+ */
119
+ export function createAuthOptions(config: SessionConfig, settings: AuthOptionsSettings): NextAuthOptions {
120
+ const { env } = settings;
121
+ if (settings.http === undefined && !settings.apiUrl) {
122
+ throw new Error("createAuthOptions needs the API base URL, and apiUrl is empty.");
123
+ }
124
+ const http = settings.http ?? axios.create({ baseURL: settings.apiUrl, allowAbsoluteUrls: false });
125
+
126
+ const readClaims = (token: string) =>
127
+ jwt.verify(token, resolveJwtSecret(config, env), {
128
+ clockTolerance: CLOCK_TOLERANCE_S,
129
+ }) as unknown as ApiClaims;
130
+
131
+ async function postTokens(path: string, body: object): Promise<TokenPair> {
132
+ const response = await http.post<TokenPair>(path, body, { timeout: API_TIMEOUT_MS });
133
+ return response.data;
134
+ }
135
+
136
+ // Parallel session reads carry the same refresh token. The API accepts each refresh token once.
137
+ // It treats a second use as replay and revokes every session the user has. So all reads of one
138
+ // token share one refresh. The result is kept for a while, because a slow read can arrive with
139
+ // the old token after the refresh is done.
140
+ //
141
+ // The map lives in one server process. Replicas do not share it.
142
+ const inFlight = new Map<string, Promise<AppToken>>();
143
+
144
+ function refreshOnce(token: AppToken): Promise<AppToken> {
145
+ const key = token.refreshToken as string;
146
+ const existing = inFlight.get(key);
147
+ if (existing) return existing;
148
+
149
+ const pending = rotate(token);
150
+ inFlight.set(key, pending);
151
+ // The minute counts from the answer, so a slow refresh is remembered as long as a fast one.
152
+ void pending.then(() => {
153
+ // unref, so the timer cannot keep a serverless invocation alive.
154
+ const expiry = setTimeout(() => inFlight.delete(key), REMEMBER_ROTATION_MS) as unknown as {
155
+ unref?: () => void;
156
+ };
157
+ expiry.unref?.();
158
+ });
159
+ return pending;
160
+ }
161
+
162
+ async function rotate(token: AppToken): Promise<AppToken> {
163
+ let refreshed: TokenPair;
164
+ try {
165
+ refreshed = await postTokens(
166
+ "/auth/refresh-token",
167
+ { token: token.accessToken, refreshToken: token.refreshToken },
168
+ );
169
+ } catch (error) {
170
+ // The API refused the token pair: a bad access token, or a refresh token that is unknown,
171
+ // expired or already used. Only this ends the session.
172
+ const status = axios.isAxiosError(error) ? error.response?.status : undefined;
173
+ if (status === 400 || status === 401) {
174
+ return { ...token, error: SESSION_ERRORS.refreshRejected };
175
+ }
176
+ // A network error or an API restart keeps the session. A later read tries again.
177
+ console.error("Refresh token request failed, keeping the session", failureShape(error));
178
+ return { ...token, refreshAt: Date.now() + REMEMBER_ROTATION_MS };
179
+ }
180
+
181
+ // A success without tokens is treated as a temporary failure, and the session keeps its own.
182
+ if (!refreshed?.token || !refreshed.refreshToken) {
183
+ console.error("Refresh token response had no tokens, keeping the session");
184
+ return { ...token, refreshAt: Date.now() + REMEMBER_ROTATION_MS };
185
+ }
186
+
187
+ // The API has already replaced the old tokens. Keep the new ones whatever happens below,
188
+ // because the old refresh token now counts as replay.
189
+ const next: AppToken = {
190
+ ...token,
191
+ accessToken: refreshed.token,
192
+ refreshToken: refreshed.refreshToken,
193
+ };
194
+ delete next.error;
195
+
196
+ try {
197
+ const claims = readClaims(refreshed.token);
198
+ next.refreshAt = nextRefreshAt(claims);
199
+ next.user = { ...(token.user as SessionUser), roles: rolesOf(claims) };
200
+ } catch (error) {
201
+ // The token is unreadable, for example after a secret change. Retry soon with the new
202
+ // refresh token.
203
+ console.error("Could not read the refreshed access token, retrying shortly", failureShape(error));
204
+ next.refreshAt = Date.now() + REMEMBER_ROTATION_MS;
205
+ }
206
+
207
+ return next;
208
+ }
209
+
210
+ return {
211
+ providers: [
212
+ CredentialsProvider({
213
+ name: "Credentials",
214
+ credentials: {
215
+ email: { label: "Email", type: "email" },
216
+ password: { label: "Password", type: "password" },
217
+ },
218
+ async authorize(credentials) {
219
+ if (!credentials) {
220
+ throw new Error("Credentials are missing");
221
+ }
222
+ try {
223
+ const tokens = await postTokens(
224
+ "/auth/login",
225
+ { email: credentials.email, password: credentials.password },
226
+ );
227
+ if (!tokens?.token || !tokens.refreshToken) {
228
+ throw new Error("Login response had no tokens");
229
+ }
230
+ const claims = readClaims(tokens.token);
231
+ const user: AuthorizedUser = {
232
+ id: claims.sub,
233
+ name: claims.unique_name,
234
+ email: credentials.email,
235
+ roles: rolesOf(claims),
236
+ accessToken: tokens.token,
237
+ refreshToken: tokens.refreshToken,
238
+ };
239
+ return user;
240
+ } catch (error) {
241
+ console.error("Sign-in failed", failureShape(error));
242
+ // No cause: next-auth logs this error, and the cause carries the password.
243
+ // eslint-disable-next-line preserve-caught-error
244
+ throw new Error(
245
+ apiUnavailable(error) ? SIGN_IN_ERRORS.unavailable : SIGN_IN_ERRORS.invalidCredentials,
246
+ );
247
+ }
248
+ },
249
+ }),
250
+ ],
251
+ pages: {
252
+ signIn: config.loginRoute,
253
+ },
254
+ session: {
255
+ maxAge: ABSOLUTE_SESSION_MAX_AGE / 1000,
256
+ },
257
+ callbacks: {
258
+ async jwt({ token: current, user }) {
259
+ let token = current as AppToken;
260
+ if (user) {
261
+ const signedIn = user as AuthorizedUser;
262
+ const claims = readClaims(signedIn.accessToken);
263
+ token = {
264
+ ...token,
265
+ accessToken: signedIn.accessToken,
266
+ refreshToken: signedIn.refreshToken,
267
+ refreshAt: nextRefreshAt(claims),
268
+ loginAt: Date.now(),
269
+ user: {
270
+ id: signedIn.id,
271
+ name: signedIn.name ?? "",
272
+ email: signedIn.email ?? "",
273
+ roles: signedIn.roles,
274
+ },
275
+ };
276
+ delete token.error;
277
+ }
278
+
279
+ // A session without a sign-in time counts as expired.
280
+ if (token.loginAt === undefined || Date.now() - token.loginAt > ABSOLUTE_SESSION_MAX_AGE) {
281
+ return { ...token, error: SESSION_ERRORS.absoluteExpiry };
282
+ }
283
+
284
+ // Only a new sign-in recovers a terminal session, so the API is not asked again.
285
+ if (isTerminalSessionError(token.error)) {
286
+ return token;
287
+ }
288
+
289
+ if (token.refreshAt && Date.now() < token.refreshAt) {
290
+ return token;
291
+ }
292
+
293
+ if (!token.refreshToken) {
294
+ return { ...token, error: SESSION_ERRORS.noRefreshToken };
295
+ }
296
+
297
+ return refreshOnce(token);
298
+ },
299
+ async session({ session, token: current }) {
300
+ const token = current as AppToken;
301
+ // Cast, so it compiles against the app's own Session declaration.
302
+ return {
303
+ ...session,
304
+ user: token.user,
305
+ accessToken: token.accessToken,
306
+ error: token.error,
307
+ // Lets the UI warn before the seven days run out.
308
+ absoluteExpiresAt: token.loginAt ? token.loginAt + ABSOLUTE_SESSION_MAX_AGE : undefined,
309
+ } as Session;
310
+ },
311
+ },
312
+ };
313
+ }
@@ -0,0 +1,2 @@
1
+ export { createAuthOptions } from "./createAuthOptions";
2
+ export type { AuthOptionsSettings, SessionUser } from "./createAuthOptions";
package/src/config.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The values that differ between the apps using this package. Everything else in the session layer
3
- * is identical across them, so it stays hardcoded rather than becoming an option.
3
+ * is the same in every app, so it is hardcoded.
4
4
  */
5
5
  export interface SessionConfig {
6
6
  /**
@@ -10,24 +10,55 @@ export interface SessionConfig {
10
10
  jwtSecretEnvVar: string;
11
11
  /**
12
12
  * Where an expired or rejected session sends the browser. An app with a locale prefix uses
13
- * something like "/en/login", one without uses "/login".
13
+ * something like "/en/login". An app without one uses "/login".
14
14
  */
15
15
  loginRoute: string;
16
- /** Prefix for the localStorage keys holding form drafts. */
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
- /** Identity helper, so an app can declare its config as a typed constant. */
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 at app start, before any form opens.
27
+ */
21
28
  export function defineSessionConfig(config: SessionConfig): SessionConfig {
29
+ draftPrefix(config);
22
30
  return config;
23
31
  }
24
32
 
25
33
  /**
26
- * Reads the API JWT secret named by the config. Throws rather than returning undefined, because a
27
- * missing secret silently breaks token verification at runtime instead of at boot.
34
+ * The key prefix for form drafts. Throws on a colon or an empty name. Those build keys such as
35
+ * "app:draft::draft:", which match no draft 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
+
45
+ /**
46
+ * Returns `value`, or throws when it is unset or empty. Pass `process.env.NAME` directly, so Next.js
47
+ * can inline public variables at build time.
48
+ */
49
+ export function requireEnv(name: string, value: string | undefined): string {
50
+ if (!value) {
51
+ throw new Error(`Missing ${name}.`);
52
+ }
53
+ return value;
54
+ }
55
+
56
+ /**
57
+ * Reads the API JWT secret named by the config. Throws when it is missing, with a message that names
58
+ * the variable.
28
59
  *
29
- * The caller passes the environment, usually `process.env`. Reading it here would put
30
- * `@types/node` in the type surface of a package that ends up in a browser bundle.
60
+ * The caller passes the environment, usually `process.env`, so this entry point needs no Node
61
+ * types and stays safe to import in browser code.
31
62
  */
32
63
  export function resolveJwtSecret(
33
64
  config: SessionConfig,
package/src/errors.ts CHANGED
@@ -9,7 +9,7 @@ export type StatusMessages = Partial<Record<number, string>>;
9
9
 
10
10
  /**
11
11
  * Axios codes for a request that never got a response from the network. A browser reports every
12
- * network failure as ERR_NETWORK. On the server, Node reports the cause instead.
12
+ * network failure as ERR_NETWORK. On the server, Node reports the specific cause.
13
13
  */
14
14
  const NO_RESPONSE_CODES = new Set([
15
15
  "ERR_NETWORK",
@@ -26,15 +26,19 @@ const NO_RESPONSE_CODES = new Set([
26
26
  /**
27
27
  * Turns a failed request into one line a user can read.
28
28
  *
29
- * On a 4xx response the body's own message comes first: a text body, then `message`, the first
30
- * validation error and `detail` from a JSON body. After that come `statusMessages` for the status
31
- * and `fallback`. On a 5xx the caller's status message comes first, because the body may be a
32
- * proxy's "Bad Gateway" rather than the API's own words. A problem details `title` is never used:
33
- * ASP.NET Core fills it with generic text such as "Bad Request", which says less than `fallback`.
29
+ * On a 4xx response the order is:
30
+ * 1. The body's own message: a text body, or `message`, the first validation error or `detail` from
31
+ * a JSON body.
32
+ * 2. `statusMessages` for the status.
33
+ * 3. `fallback`.
34
+ *
35
+ * On a 5xx the caller's status message comes first, because the body may come from a proxy, such
36
+ * as "Bad Gateway". A problem details `title` is never used. ASP.NET Core fills it with generic text
37
+ * such as "Bad Request", which says less than `fallback`.
34
38
  *
35
39
  * Only single line text counts as a message. Axios's own message, such as "Request failed with
36
- * status code 500", is never shown, and a cancelled request gives `fallback`. Any other `Error`
37
- * gives its message.
40
+ * status code 500", is never shown. A cancelled request gives `fallback`. Any other `Error` gives
41
+ * its message.
38
42
  */
39
43
  export function formatApiError(
40
44
  error: unknown,
@@ -0,0 +1,2 @@
1
+ export { useFormDraft } from "./useFormDraft";
2
+ export type { FormDraft, FormDraftOptions } from "./useFormDraft";
@@ -0,0 +1,131 @@
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 stale work is not offered as current. A form removes its expired draft on next read.
10
+ const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
11
+
12
+ interface Envelope<T> {
13
+ savedAt: number;
14
+ value: T;
15
+ }
16
+
17
+ function read<T>(storageKey: string): T | null {
18
+ try {
19
+ const raw = window.localStorage.getItem(storageKey);
20
+ if (raw == null) return null;
21
+
22
+ const envelope = JSON.parse(raw) as Envelope<T> | null;
23
+ const fresh =
24
+ typeof envelope?.savedAt === "number" && Date.now() - envelope.savedAt <= MAX_AGE_MS;
25
+ if (!fresh || envelope?.value === undefined) {
26
+ window.localStorage.removeItem(storageKey);
27
+ return null;
28
+ }
29
+ return envelope.value;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ export interface FormDraftOptions<T> {
36
+ /**
37
+ * The signed-in user's id. A browser profile can be shared, so drafts are stored per user. That
38
+ * way nobody is offered another user's work. The hook keeps the last known owner. A lost cookie
39
+ * reports nobody, and that is when the draft matters most. Nothing is stored until an owner is
40
+ * known.
41
+ */
42
+ owner: string | undefined;
43
+ /** Identifies the form and the entity it edits, for example `recipe:new` or `recipe:42`. */
44
+ scope: string;
45
+ /** The form's current values. They must survive `JSON.stringify`. */
46
+ value: T;
47
+ }
48
+
49
+ export interface FormDraft<T> {
50
+ /**
51
+ * The stored draft, while it is still waiting to be restored or dismissed. Read once the owner is
52
+ * known and again whenever the owner or scope changes, so it can appear after mount.
53
+ */
54
+ pending: T | null;
55
+ /** Stop offering the draft. The caller applies the values it wants. */
56
+ dismiss: () => void;
57
+ /** Drop the stored draft after a successful save. */
58
+ clear: () => void;
59
+ }
60
+
61
+ /**
62
+ * Keeps a form's values in localStorage so a sign-out, reload or closed tab does not lose them.
63
+ * Values are written half a second after the last change, so only that last half second is at risk.
64
+ *
65
+ * A draft found at mount is offered for restore, not applied. So an edit form never silently
66
+ * overwrites what the API returned. Saving starts at once, whether or not the offer is answered.
67
+ * The offer is held in memory, so storage always holds the newest work.
68
+ */
69
+ export function useFormDraft<T>(
70
+ config: Pick<SessionConfig, "draftStoragePrefix">,
71
+ { owner, scope, value }: FormDraftOptions<T>,
72
+ ): FormDraft<T> {
73
+ // Set during render, which is React's supported way to derive state from changing inputs.
74
+ const [lastOwner, setLastOwner] = useState<string | undefined>(undefined);
75
+ if (owner && owner !== lastOwner) setLastOwner(owner);
76
+
77
+ // Checked before the owner is known, so a bad prefix fails on the first render, signed in or not.
78
+ const prefix = draftPrefix(config);
79
+ const key = lastOwner ? `${prefix}${lastOwner}:${scope}` : null;
80
+
81
+ const [pending, setPending] = useState<T | null>(null);
82
+ const json = JSON.stringify(value);
83
+ const untouched = useRef(json);
84
+ const writeTimer = useRef<number | null>(null);
85
+
86
+ useEffect(() => {
87
+ // Re-base on a key change too. The values on screen belong to the old key and must not land
88
+ // in the new key's draft.
89
+ untouched.current = json;
90
+ // Reading storage is an external-system read, which belongs in an effect.
91
+ // eslint-disable-next-line react-hooks/set-state-in-effect
92
+ setPending(key == null ? null : read<T>(key));
93
+ // Runs on a key change only. Re-reading on every value change would re-offer a draft the user
94
+ // has already answered.
95
+ // eslint-disable-next-line react-hooks/exhaustive-deps
96
+ }, [key]);
97
+
98
+ useEffect(() => {
99
+ if (key == null) return;
100
+ // Write only once the form differs from how it opened, so the pristine form never overwrites
101
+ // a draft that is still on offer.
102
+ if (json === untouched.current) return;
103
+ const timer = window.setTimeout(() => {
104
+ try {
105
+ window.localStorage.setItem(key, JSON.stringify({ savedAt: Date.now(), value }));
106
+ } catch {
107
+ // A full or blocked store must not break the form.
108
+ }
109
+ }, WRITE_DELAY_MS);
110
+ writeTimer.current = timer;
111
+ return () => window.clearTimeout(timer);
112
+ // eslint-disable-next-line react-hooks/exhaustive-deps
113
+ }, [key, json]);
114
+
115
+ const clear = useCallback(() => {
116
+ if (key == null) return;
117
+ // Cancel any scheduled write and make the current values the baseline, so a save is never
118
+ // followed by a fresh draft write.
119
+ if (writeTimer.current != null) window.clearTimeout(writeTimer.current);
120
+ untouched.current = json;
121
+ try {
122
+ window.localStorage.removeItem(key);
123
+ } catch {
124
+ // The draft is a convenience. Nothing depends on it.
125
+ }
126
+ }, [key, json]);
127
+
128
+ const dismiss = useCallback(() => setPending(null), []);
129
+
130
+ return { pending, dismiss, clear };
131
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { defineSessionConfig, resolveJwtSecret } from "./config";
1
+ export { defineSessionConfig, requireEnv, resolveJwtSecret } from "./config";
2
2
  export type { SessionConfig } from "./config";
3
3
  export { formatApiError } from "./errors";
4
4
  export type { StatusMessages } from "./errors";
@@ -0,0 +1,77 @@
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
+ /**
13
+ * The codes a failed sign-in reports in next-auth's `error`. Wrong credentials and every API answer
14
+ * look the same, so the message never hints that a password was right. Only an API that does not
15
+ * answer, or a gateway error, reports `unavailable`. That depends on the server, never on the
16
+ * password.
17
+ */
18
+ export const SIGN_IN_ERRORS = {
19
+ invalidCredentials: "InvalidCredentials",
20
+ unavailable: "SignInUnavailable",
21
+ } as const;
22
+
23
+ /** One of the values in `SESSION_ERRORS`. */
24
+ export type SessionError = (typeof SESSION_ERRORS)[keyof typeof SESSION_ERRORS];
25
+
26
+ const TERMINAL: ReadonlySet<string> = new Set(Object.values(SESSION_ERRORS));
27
+
28
+ /** Whether a session error means the user has to sign in again. */
29
+ export function isTerminalSessionError(error: string | undefined): boolean {
30
+ return error != null && TERMINAL.has(error);
31
+ }
32
+
33
+ // Whether the re-sign-in prompt is showing. It lives outside React for two reasons:
34
+ // 1. The axios interceptor that raises it is a plain module.
35
+ // 2. The page gate reads it, so it never redirects to login while the prompt recovers the session.
36
+ //
37
+ // It sits on globalThis under a registered symbol, so two copies of this module share one prompt.
38
+ // Two installed versions of the package are one example. A gate reading one copy then sees a
39
+ // prompt raised through the other and keeps the form on screen.
40
+ interface PromptState {
41
+ open: boolean;
42
+ listeners: Set<() => void>;
43
+ }
44
+
45
+ // The shape of PromptState is a contract between releases. A release that changes it must also
46
+ // change this key, so it never reads state in a shape it does not expect.
47
+ const STATE_KEY = Symbol.for("@sjolystinnovation/app-kit/session-prompt/v1");
48
+ const shared = globalThis as typeof globalThis & { [STATE_KEY]?: PromptState };
49
+ const state: PromptState = (shared[STATE_KEY] ??= { open: false, listeners: new Set() });
50
+
51
+ function emit(): void {
52
+ for (const listener of [...state.listeners]) listener();
53
+ }
54
+
55
+ /** Ask the user to sign in again without leaving the page. */
56
+ export function openSessionPrompt(): void {
57
+ if (state.open) return;
58
+ state.open = true;
59
+ emit();
60
+ }
61
+
62
+ export function closeSessionPrompt(): void {
63
+ if (!state.open) return;
64
+ state.open = false;
65
+ emit();
66
+ }
67
+
68
+ export function subscribeSessionPrompt(listener: () => void): () => void {
69
+ state.listeners.add(listener);
70
+ return () => {
71
+ state.listeners.delete(listener);
72
+ };
73
+ }
74
+
75
+ export function getSessionPromptOpen(): boolean {
76
+ return state.open;
77
+ }
@@ -0,0 +1,10 @@
1
+ export {
2
+ SESSION_ERRORS,
3
+ SIGN_IN_ERRORS,
4
+ closeSessionPrompt,
5
+ getSessionPromptOpen,
6
+ isTerminalSessionError,
7
+ openSessionPrompt,
8
+ subscribeSessionPrompt,
9
+ } from "./expiry";
10
+ export type { SessionError } from "./expiry";
@@ -0,0 +1,2 @@
1
+ export { useSessionGate } from "./useSessionGate";
2
+ export type { SessionGate } from "./useSessionGate";
@@ -0,0 +1,61 @@
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 all agree. A nested layout that decides on
12
+ * its own can blank the page. That undoes the in-place recovery the re-sign-in prompt exists for.
13
+ */
14
+ export function useSessionGate() {
15
+ const { data: session, status } = useSession();
16
+ const pathname = usePathname();
17
+ const promptOpen = useSyncExternalStore(
18
+ subscribeSessionPrompt,
19
+ getSessionPromptOpen,
20
+ () => false,
21
+ );
22
+
23
+ // The jwt callback copies its error onto the session. Read it without requiring the app to have
24
+ // added `error` to next-auth's Session type.
25
+ const error = (session as { error?: string } | null)?.error;
26
+ const usable = status === "authenticated" && !isTerminalSessionError(error);
27
+
28
+ // The path that last rendered on a healthy session. Keyed by path, so each page checks again. A
29
+ // session that died on the previous page cannot pass a fresh form.
30
+ // Set during render, which is React's supported way to derive state from changing inputs.
31
+ // It starts as undefined, not null, because usePathname can return null.
32
+ const [usableAt, setUsableAt] = useState<string | null | undefined>(undefined);
33
+ if (usable && usableAt !== pathname) setUsableAt(pathname);
34
+ const wasUsable = usableAt === pathname;
35
+
36
+ // The page is already open and the prompt is recovering the session in place.
37
+ const recovering = promptOpen && wasUsable;
38
+
39
+ return {
40
+ session,
41
+ status,
42
+ promptOpen,
43
+ usable,
44
+ wasUsable,
45
+ recovering,
46
+ /**
47
+ * The one answer every gate on the way down uses. A layout that decides this again can blank
48
+ * the page that the gate above it chose to keep.
49
+ */
50
+ mayRender:
51
+ recovering ||
52
+ !(
53
+ (status === "loading" && !wasUsable) ||
54
+ status === "unauthenticated" ||
55
+ (!usable && !wasUsable)
56
+ ),
57
+ };
58
+ }
59
+
60
+ /** What `useSessionGate` returns. */
61
+ export type SessionGate = ReturnType<typeof useSessionGate>;
package/src/styles.css CHANGED
@@ -2,9 +2,9 @@
2
2
  @source ".";
3
3
 
4
4
  /*
5
- * Design tokens for the components. Each default matches a stock Tailwind value, so an app that
6
- * sets none of them gets the look below. Set any of them in the app's own @theme to restyle, or in
7
- * a selector such as .dark for a scoped theme.
5
+ * Design tokens for the components. The defaults are stock Tailwind values, except the focus ring,
6
+ * which uses the theme's --color-primary. Set a token in the app's own @theme to restyle, or in a
7
+ * selector such as .dark for a scoped theme.
8
8
  */
9
9
  @theme default {
10
10
  --radius-alert: var(--radius-lg);
package/src/ui/Alert.tsx CHANGED
@@ -9,9 +9,8 @@ export interface AlertProps {
9
9
  */
10
10
  role?: "alert" | "status";
11
11
  /**
12
- * Classes for placing the alert, such as a margin. Restyle it through the theme variables
13
- * instead: whether a class that clashes with the alert's own wins depends on the order Tailwind
14
- * emits them in.
12
+ * Classes for placing the alert, such as a margin. Restyle it through the theme variables. A
13
+ * class that clashes with the alert's own wins or loses by the order Tailwind emits them in.
15
14
  */
16
15
  className?: string;
17
16
  children: ReactNode;
@@ -14,7 +14,7 @@ export interface PasswordInputProps extends Omit<InputHTMLAttributes<HTMLInputEl
14
14
  hidePasswordLabel?: string;
15
15
  /**
16
16
  * Classes for placing the field, such as a margin. They go on the outer element, which holds the
17
- * label, the input and the error. Restyle the field through the theme variables instead.
17
+ * label, the input and the error. Restyle the field through the theme variables.
18
18
  */
19
19
  className?: string;
20
20
  }
@@ -30,11 +30,10 @@ export function PasswordInput({
30
30
  ...props
31
31
  }: PasswordInputProps) {
32
32
  const [visible, setVisible] = useState(false);
33
- // Fall back to a generated id so the label is always tied to its own control. Not the name: two
34
- // fields named "password" on one page, such as sign in next to sign up, would share an id.
33
+ // A generated id ties the label to its own control, even when two fields on a page share a name.
35
34
  const generated = useId();
36
35
  const inputId = id ?? generated;
37
- // Built from useId rather than the name, since an id list in aria-describedby splits on spaces.
36
+ // From useId, which has no spaces. aria-describedby splits its ids on spaces.
38
37
  const errorId = `${generated}-error`;
39
38
 
40
39
  return (