@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 +181 -33
- package/package.json +10 -4
- package/src/api/createApiClient.ts +43 -0
- package/src/api/index.ts +2 -0
- package/src/api/request.ts +22 -0
- package/src/auth/createAuthOptions.ts +313 -0
- package/src/auth/index.ts +2 -0
- package/src/config.ts +20 -9
- package/src/errors.ts +12 -8
- package/src/forms/useFormDraft.ts +20 -22
- package/src/index.ts +1 -1
- package/src/session/expiry.ts +17 -6
- package/src/session/index.ts +1 -0
- package/src/session/react/useSessionGate.ts +6 -8
- package/src/styles.css +3 -3
- package/src/ui/Alert.tsx +2 -3
- package/src/ui/PasswordInput.tsx +3 -4
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:
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
74
|
-
|
|
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,
|
|
81
|
-
|
|
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
|
|
92
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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`.
|
|
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
|
|
182
|
-
|
|
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
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
369
|
-
|
|
370
|
-
`
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/src/api/index.ts
ADDED
|
@@ -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
|
+
}
|
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
|
|
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"
|
|
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
|
|
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.
|
|
35
|
-
* which match
|
|
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
|
-
*
|
|
47
|
-
*
|
|
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
|
|
50
|
-
*
|
|
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
|
|
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
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
|
37
|
-
*
|
|
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
|
|
9
|
-
// enough that
|
|
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
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
|
67
|
-
* overwrites what the API returned. Saving starts
|
|
68
|
-
* memory, so
|
|
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
|
-
//
|
|
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
|
|
90
|
-
//
|
|
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
|
|
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
|
-
//
|
|
96
|
-
//
|
|
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
|
|
103
|
-
//
|
|
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
|
|
120
|
-
//
|
|
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
|
|
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";
|
package/src/session/expiry.ts
CHANGED
|
@@ -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
|
|
23
|
-
// that raises it is a plain module
|
|
24
|
-
//
|
|
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
|
-
//
|
|
27
|
-
// versions of the package
|
|
28
|
-
//
|
|
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>;
|
package/src/session/index.ts
CHANGED
|
@@ -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
|
|
12
|
-
* own
|
|
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
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
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.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
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;
|
package/src/ui/PasswordInput.tsx
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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 (
|