@sjolystinnovation/app-kit 0.4.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
 
@@ -70,15 +74,23 @@ const secret = resolveJwtSecret(sessionConfig, process.env);
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. Throws when `draftStoragePrefix` is empty or contains a colon.
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,13 +141,132 @@ 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
+
127
263
  ### `@sjolystinnovation/app-kit/session`
128
264
 
129
265
  Decides when a user has to sign in again. Safe to import from server code, such as the next-auth
130
266
  callbacks.
131
267
 
132
- The gate reads `session.error`. The app's `jwt` callback sets `token.error`, and its `session`
133
- callback copies it across:
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:
134
270
 
135
271
  ```ts
136
272
  callbacks: {
@@ -162,6 +298,9 @@ declare module "next-auth/jwt" {
162
298
  }
163
299
  ```
164
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
+
165
304
  `SESSION_ERRORS` holds the values the jwt callback puts in `token.error` when the session cannot be
166
305
  recovered: `AbsoluteSessionExpired`, `RefreshTokenRejected` and `NoRefreshToken`. `SessionError`
167
306
  is their union type. `isTerminalSessionError(error)` is true for those three only, so a transient
@@ -178,19 +317,23 @@ The re-sign-in prompt is a flag outside React, so an axios interceptor can raise
178
317
 
179
318
  Copies of the package installed side by side share the one flag, as long as their releases use the
180
319
  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.
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.
183
322
 
184
323
  ### `useSessionGate()`
185
324
 
186
325
  From `@sjolystinnovation/app-kit/session/react`. Tells a protected page whether it may render. It
187
326
  reads `useSession`, so it needs a `SessionProvider` above it.
188
327
 
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.
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.
194
337
 
195
338
  The hook does not navigate. The gate sends the user to the login page itself, and never while the
196
339
  prompt is open. The app passes the login URL in, so an app with a locale in the path can build it
@@ -260,10 +403,13 @@ Mount an edit form once the entity has loaded, with its values already in `value
260
403
  the values it first sees under a key as the untouched form, so values that arrive later count as an
261
404
  edit and are written over the draft being offered.
262
405
 
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.
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.
267
413
 
268
414
  Drafts are stored under `<draftStoragePrefix>:draft:<owner>:<scope>`. The hook throws when
269
415
  `draftStoragePrefix` is empty or contains a colon. A draft older than 7 days is not offered, and is
@@ -281,7 +427,7 @@ import { Alert } from "@sjolystinnovation/app-kit/ui";
281
427
  | --- | --- | --- | --- |
282
428
  | `variant` | `"error" \| "success" \| "info" \| "warning"` | `"info"` | Colour of the box. |
283
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. |
284
- | `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. |
285
431
  | `children` | `ReactNode` | | Content. |
286
432
 
287
433
  ### `PasswordInput`
@@ -302,7 +448,7 @@ import { PasswordInput } from "@sjolystinnovation/app-kit/ui";
302
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. |
303
449
  | `showPasswordLabel` | `string` | `"Show password"` | Accessible name of the toggle while the value is hidden. |
304
450
  | `hidePasswordLabel` | `string` | `"Hide password"` | Accessible name of the toggle while the value is visible. |
305
- | `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. |
306
452
 
307
453
  Pass the two toggle labels in the app's language. The defaults are English.
308
454
 
@@ -359,15 +505,17 @@ with it and has to set the ones it uses itself.
359
505
 
360
506
  ## Requirements
361
507
 
362
- - 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.
363
511
  - For `@sjolystinnovation/app-kit/ui`: Tailwind CSS 4 and `@heroicons/react` 2. The focus ring uses
364
512
  the theme's `primary` colour unless `--color-input-focus-ring` is set.
365
513
  - TypeScript with `"moduleResolution": "bundler"`. Relative imports here are extensionless, which
366
514
  `node16` and `nodenext` reject.
367
515
 
368
- There is no build output. Shipping source is what keeps the `"use client"` directives intact, since
369
- bundlers strip or relocate them. The consumer therefore compiles this source with its own
370
- `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.
371
519
 
372
520
  ## License
373
521
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjolystinnovation/app-kit",
3
- "version": "0.4.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",
@@ -19,6 +19,8 @@
19
19
  "./session": "./src/session/index.ts",
20
20
  "./session/react": "./src/session/react/index.ts",
21
21
  "./forms": "./src/forms/index.ts",
22
+ "./auth": "./src/auth/index.ts",
23
+ "./api": "./src/api/index.ts",
22
24
  "./ui": "./src/ui/index.ts",
23
25
  "./styles.css": "./src/styles.css"
24
26
  },
@@ -35,7 +37,7 @@
35
37
  },
36
38
  "peerDependencies": {
37
39
  "@heroicons/react": "^2",
38
- "axios": "^1",
40
+ "axios": "^1.20.0",
39
41
  "next": "^16",
40
42
  "next-auth": "^4.24",
41
43
  "react": "^19",
@@ -49,7 +51,7 @@
49
51
  "@types/react": "^19",
50
52
  "@types/react-dom": "^19",
51
53
  "@vitejs/plugin-react": "^6.1.2",
52
- "axios": "^1.19.0",
54
+ "axios": "^1.20.0",
53
55
  "jsdom": "^30.1.1",
54
56
  "next": "^16.3.6",
55
57
  "next-auth": "^4.24.15",
@@ -65,5 +67,9 @@
65
67
  "jwt",
66
68
  "react",
67
69
  "tailwindcss"
68
- ]
70
+ ],
71
+ "dependencies": {
72
+ "@types/jsonwebtoken": "^9.0.8",
73
+ "jsonwebtoken": "^9.0.2"
74
+ }
69
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,7 +10,7 @@ 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
16
  /**
@@ -23,7 +23,7 @@ export interface SessionConfig {
23
23
 
24
24
  /**
25
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.
26
+ * so a bad value fails at app start, before any form opens.
27
27
  */
28
28
  export function defineSessionConfig(config: SessionConfig): SessionConfig {
29
29
  draftPrefix(config);
@@ -31,8 +31,8 @@ export function defineSessionConfig(config: SessionConfig): SessionConfig {
31
31
  }
32
32
 
33
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:".
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
36
  */
37
37
  export function draftPrefix(config: Pick<SessionConfig, "draftStoragePrefix">): string {
38
38
  const name = config.draftStoragePrefix;
@@ -43,11 +43,22 @@ export function draftPrefix(config: Pick<SessionConfig, "draftStoragePrefix">):
43
43
  }
44
44
 
45
45
  /**
46
- * Reads the API JWT secret named by the config. Throws rather than returning undefined, because a
47
- * missing secret silently breaks token verification at runtime instead of at boot.
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.
48
59
  *
49
- * The caller passes the environment, usually `process.env`. Reading it here would put
50
- * `@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.
51
62
  */
52
63
  export function resolveJwtSecret(
53
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,
@@ -5,9 +5,8 @@ import { draftPrefix, type SessionConfig } from "../config";
5
5
 
6
6
  const WRITE_DELAY_MS = 500;
7
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.
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.
11
10
  const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
12
11
 
13
12
  interface Envelope<T> {
@@ -35,10 +34,10 @@ function read<T>(storageKey: string): T | null {
35
34
 
36
35
  export interface FormDraftOptions<T> {
37
36
  /**
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.
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.
42
41
  */
43
42
  owner: string | undefined;
44
43
  /** Identifies the form and the entity it edits, for example `recipe:new` or `recipe:42`. */
@@ -63,16 +62,15 @@ export interface FormDraft<T> {
63
62
  * Keeps a form's values in localStorage so a sign-out, reload or closed tab does not lose them.
64
63
  * Values are written half a second after the last change, so only that last half second is at risk.
65
64
  *
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.
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.
69
68
  */
70
69
  export function useFormDraft<T>(
71
70
  config: Pick<SessionConfig, "draftStoragePrefix">,
72
71
  { owner, scope, value }: FormDraftOptions<T>,
73
72
  ): FormDraft<T> {
74
- // Adjusted during render rather than in an effect, which is the supported way to derive state
75
- // from changing inputs.
73
+ // Set during render, which is React's supported way to derive state from changing inputs.
76
74
  const [lastOwner, setLastOwner] = useState<string | undefined>(undefined);
77
75
  if (owner && owner !== lastOwner) setLastOwner(owner);
78
76
 
@@ -86,21 +84,21 @@ export function useFormDraft<T>(
86
84
  const writeTimer = useRef<number | null>(null);
87
85
 
88
86
  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.
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.
91
89
  untouched.current = json;
92
- // Reading the store is an external-system read, which is what an effect is for.
90
+ // Reading storage is an external-system read, which belongs in an effect.
93
91
  // eslint-disable-next-line react-hooks/set-state-in-effect
94
92
  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.
93
+ // Runs on a key change only. Re-reading on every value change would re-offer a draft the user
94
+ // has already answered.
97
95
  // eslint-disable-next-line react-hooks/exhaustive-deps
98
96
  }, [key]);
99
97
 
100
98
  useEffect(() => {
101
99
  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.
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.
104
102
  if (json === untouched.current) return;
105
103
  const timer = window.setTimeout(() => {
106
104
  try {
@@ -116,14 +114,14 @@ export function useFormDraft<T>(
116
114
 
117
115
  const clear = useCallback(() => {
118
116
  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.
117
+ // Cancel any scheduled write and make the current values the baseline, so a save is never
118
+ // followed by a fresh draft write.
121
119
  if (writeTimer.current != null) window.clearTimeout(writeTimer.current);
122
120
  untouched.current = json;
123
121
  try {
124
122
  window.localStorage.removeItem(key);
125
123
  } catch {
126
- // The draft is a convenience, not state anything depends on.
124
+ // The draft is a convenience. Nothing depends on it.
127
125
  }
128
126
  }, [key, json]);
129
127
 
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";
@@ -9,6 +9,17 @@ export const SESSION_ERRORS = {
9
9
  noRefreshToken: "NoRefreshToken",
10
10
  } as const;
11
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
+
12
23
  /** One of the values in `SESSION_ERRORS`. */
13
24
  export type SessionError = (typeof SESSION_ERRORS)[keyof typeof SESSION_ERRORS];
14
25
 
@@ -19,13 +30,13 @@ export function isTerminalSessionError(error: string | undefined): boolean {
19
30
  return error != null && TERMINAL.has(error);
20
31
  }
21
32
 
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.
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.
25
36
  //
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.
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.
29
40
  interface PromptState {
30
41
  open: boolean;
31
42
  listeners: Set<() => void>;
@@ -1,5 +1,6 @@
1
1
  export {
2
2
  SESSION_ERRORS,
3
+ SIGN_IN_ERRORS,
3
4
  closeSessionPrompt,
4
5
  getSessionPromptOpen,
5
6
  isTerminalSessionError,
@@ -8,9 +8,8 @@ import { getSessionPromptOpen, isTerminalSessionError, subscribeSessionPrompt }
8
8
  /**
9
9
  * Whether a protected page may render, and whether it must stay rendered.
10
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.
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.
14
13
  */
15
14
  export function useSessionGate() {
16
15
  const { data: session, status } = useSession();
@@ -26,11 +25,10 @@ export function useSessionGate() {
26
25
  const error = (session as { error?: string } | null)?.error;
27
26
  const usable = status === "authenticated" && !isTerminalSessionError(error);
28
27
 
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.
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.
34
32
  const [usableAt, setUsableAt] = useState<string | null | undefined>(undefined);
35
33
  if (usable && usableAt !== pathname) setUsableAt(pathname);
36
34
  const wasUsable = usableAt === pathname;
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 (