@sjolystinnovation/app-kit 0.21.0 → 0.22.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.
Files changed (74) hide show
  1. package/README.md +184 -265
  2. package/package.json +1 -1
  3. package/src/admin/AdminShell.tsx +2 -1
  4. package/src/admin/AdminUsersPanel.tsx +9 -3
  5. package/src/admin/CompanySettingsForm.tsx +2 -1
  6. package/src/admin/LegalPagesEditor.tsx +5 -4
  7. package/src/admin/LocaleTabs.tsx +4 -3
  8. package/src/admin/StatHistoryChart.tsx +4 -3
  9. package/src/admin/adminUsersPanelStrings.ts +3 -3
  10. package/src/admin/legalPagesEditorStrings.ts +1 -1
  11. package/src/admin/statHistoryChartStrings.ts +3 -2
  12. package/src/api/apiFieldErrors.ts +4 -4
  13. package/src/api/company.ts +4 -3
  14. package/src/api/createApiClient.ts +3 -2
  15. package/src/{errors.ts → api/formatApiError.ts} +12 -12
  16. package/src/api/index.ts +2 -1
  17. package/src/api/request.ts +2 -2
  18. package/src/api/revalidateTargets.ts +7 -5
  19. package/src/api/services.ts +14 -13
  20. package/src/auth/createAuthOptions.ts +7 -5
  21. package/src/branding/BrandingManager.tsx +10 -5
  22. package/src/branding/BrandingProvider.tsx +5 -3
  23. package/src/branding/index.ts +1 -2
  24. package/src/branding/server.ts +10 -9
  25. package/src/branding/types.ts +3 -3
  26. package/src/config.ts +10 -10
  27. package/src/forms/useFormDraft.ts +9 -7
  28. package/src/index.ts +1 -3
  29. package/src/legal/LegalPageView.tsx +6 -5
  30. package/src/markdown/Markdown.tsx +12 -10
  31. package/src/markdown/index.ts +1 -1
  32. package/src/next-auth.d.ts +11 -9
  33. package/src/next-config/headers.ts +96 -0
  34. package/src/next-config/index.ts +18 -107
  35. package/src/payments/PaymentReturn.tsx +9 -7
  36. package/src/payments/formatNok.ts +1 -1
  37. package/src/payments/usePollUntilFinal.ts +7 -5
  38. package/src/seo/JsonLd.tsx +1 -1
  39. package/src/seo/environment.ts +3 -3
  40. package/src/seo/index.ts +0 -1
  41. package/src/seo/og/index.tsx +5 -4
  42. package/src/seo/schema.ts +6 -2
  43. package/src/seo/site.ts +44 -32
  44. package/src/seo/sitemap.ts +2 -2
  45. package/src/server/publicApi.ts +13 -9
  46. package/src/server/revalidateRoute.ts +10 -8
  47. package/src/session/expiry.ts +8 -5
  48. package/src/session/react/AppSessionProvider.tsx +3 -2
  49. package/src/{ui → session/react}/CredentialsForm.tsx +10 -9
  50. package/src/{ui → session/react}/LoginPage.tsx +7 -6
  51. package/src/session/react/ProtectedGate.tsx +3 -2
  52. package/src/session/react/RoleGate.tsx +4 -3
  53. package/src/session/react/SessionExpiryGuard.tsx +5 -4
  54. package/src/{ui → session/react}/credentialsFormStrings.ts +1 -1
  55. package/src/session/react/index.ts +8 -2
  56. package/src/{ui → session/react}/loginPageStrings.ts +1 -1
  57. package/src/session/react/sessionExpiryGuardStrings.ts +1 -1
  58. package/src/session/react/useSessionGate.ts +3 -3
  59. package/src/testing/index.ts +5 -5
  60. package/src/ui/Alert.tsx +6 -4
  61. package/src/ui/ContentImage.tsx +12 -11
  62. package/src/ui/ErrorState.tsx +5 -3
  63. package/src/ui/NotFoundState.tsx +9 -7
  64. package/src/ui/PasswordInput.tsx +6 -5
  65. package/src/ui/ResetPasswordFlow.tsx +10 -8
  66. package/src/ui/TextArea.tsx +6 -4
  67. package/src/ui/TextInput.tsx +4 -2
  68. package/src/ui/Toggle.tsx +3 -3
  69. package/src/ui/field.tsx +5 -3
  70. package/src/ui/index.ts +2 -10
  71. package/src/ui/resetPasswordFlowStrings.ts +7 -7
  72. package/src/validation/fieldErrors.ts +4 -3
  73. package/src/validation/password.ts +5 -5
  74. package/src/validation/passwordRules.ts +2 -2
package/README.md CHANGED
@@ -9,7 +9,7 @@ Several apps sharing one API also share the code around it:
9
9
 
10
10
  - the session plumbing
11
11
  - how a failed request becomes a message a user can read
12
- - the form controls on the sign in page
12
+ - the form controls on the sign-in page
13
13
 
14
14
  This package holds that code once, and each app installs it.
15
15
 
@@ -28,8 +28,8 @@ const nextConfig: NextConfig = {
28
28
  };
29
29
  ```
30
30
 
31
- The components in `@sjolystinnovation/app-kit/ui`, `markdown`, `branding`, `payments` and `session/react` are styled with Tailwind CSS.
32
- Import the package's stylesheet after Tailwind:
31
+ The components in `@sjolystinnovation/app-kit/ui`, `admin`, `legal`, `markdown`, `branding`,
32
+ `payments`, and `session/react` use Tailwind CSS. Import the package's stylesheet after Tailwind:
33
33
 
34
34
  ```css
35
35
  /* src/app/globals.css */
@@ -39,7 +39,7 @@ Import the package's stylesheet after Tailwind:
39
39
 
40
40
  ## Usage
41
41
 
42
- Declare the values that differ between apps once, then pass that config wherever it is needed. One
42
+ Declare the values that differ between apps once, then pass that config wherever you need it. One
43
43
  app can define it, or several apps can each define their own.
44
44
 
45
45
  ```ts
@@ -53,12 +53,8 @@ export const sessionConfig = defineSessionConfig({
53
53
  });
54
54
  ```
55
55
 
56
- ```ts
57
- import { resolveJwtSecret } from "@sjolystinnovation/app-kit";
58
- import { sessionConfig } from "@/lib/session";
59
-
60
- const secret = resolveJwtSecret(sessionConfig, process.env);
61
- ```
56
+ The root entry point holds only `defineSessionConfig`, `requireEnv`, and `SessionConfig`. It
57
+ imports no peer dependency, so it works in browser code, server code, and `next.config.ts`.
62
58
 
63
59
  ## API
64
60
 
@@ -85,55 +81,40 @@ Returns `value`, or throws `Missing <name>.` when it is unset or empty. Pass the
85
81
  place, such as `process.env.NEXT_PUBLIC_API_URL`, so Next.js can inline a public variable at build
86
82
  time.
87
83
 
88
- ### `resolveJwtSecret(config, env)`
89
-
90
- `(config: SessionConfig, env: Record<string, string | undefined>) => string`
91
-
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.
94
-
95
- ```ts
96
- resolveJwtSecret(sessionConfig, { API_JWT_SECRET: "s3cret" });
97
- // "s3cret"
98
-
99
- resolveJwtSecret(sessionConfig, {});
100
- // Error: Missing API_JWT_SECRET. The app verifies API session tokens with it.
101
- ```
102
-
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.
105
-
106
84
  ### `formatApiError(error, fallback, statusMessages?)`
107
85
 
108
86
  `(error: unknown, fallback: string, statusMessages?: StatusMessages) => string`
109
87
 
110
- Turns a failed request into one line a user can read.
88
+ From `@sjolystinnovation/app-kit/api`. Turns a failed request into one line a user can read.
111
89
 
112
- On a 4xx response it uses the first of:
90
+ On a 4xx response, it uses the first of these:
113
91
 
114
92
  1. The body's own message: a text body, such as the one `BadRequest("...")` sends from ASP.NET Core,
115
- or `message`, the first validation error or `detail` from a JSON body.
93
+ or `message`, the first validation error, or `detail` from a JSON body.
116
94
  2. The entry in `statusMessages` for the status.
117
95
  3. `fallback`.
118
96
 
119
- On a 5xx response the entry in `statusMessages` comes first, then the body, then `fallback`. A 5xx
97
+ On a 5xx response, the entry in `statusMessages` comes first, then the body, then `fallback`. A 5xx
120
98
  body can come from a proxy, such as "Bad Gateway", so the caller's own words win when it has them.
121
99
 
122
- A problem details `title` is never shown. ASP.NET Core fills it with generic text, such as "Bad
123
- Request" or "An error occurred while processing your request.", which says less than `fallback`.
100
+ The function never shows a problem details `title`. ASP.NET Core fills it with generic text, such
101
+ as "Bad Request" or "An error occurred while processing your request.", which says less than
102
+ `fallback`.
124
103
 
125
104
  A few more rules:
126
105
 
127
106
  - Key `0` in `statusMessages` covers a request that got no response, because of a dropped
128
107
  connection or a timeout.
129
108
  - 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`.
109
+ - The function never shows Axios's own message, such as "Request failed with status code 500".
110
+ - A canceled request gives `fallback`.
132
111
  - 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`.
112
+ - The function does not read a body requested as an `arraybuffer` or a `blob`, as for a file
113
+ download. A failed download gets `statusMessages` or `fallback`.
135
114
 
136
115
  ```ts
116
+ import { formatApiError } from "@sjolystinnovation/app-kit/api";
117
+
137
118
  try {
138
119
  await api.post("/users", form);
139
120
  } catch (error) {
@@ -170,27 +151,28 @@ export { handler as GET, handler as POST };
170
151
 
171
152
  | Setting | Type | Description |
172
153
  | --- | --- | --- |
173
- | `apiUrl` | `string` | Base URL of the API. Sign-in posts to `/auth/login`, refresh to `/auth/refresh-token` and sign-out to `/auth/logout` under it. An empty value throws. |
154
+ | `apiUrl` | `string` | Base URL of the API. Sign-in posts to `/auth/login`, refresh to `/auth/refresh-token`, and sign-out to `/auth/logout` under it. An empty value throws. |
174
155
  | `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. |
156
+ | `http` | `AxiosInstance` | The client for sign-in and refresh, with its own base URL. Use it in place of `apiUrl`, for tests. |
176
157
 
177
158
  next-auth reads `NEXTAUTH_SECRET` itself and refuses to run in production without it.
178
159
 
179
160
  What the options do:
180
161
 
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.
162
+ - **Sign-in** posts `{ email, password }` and expects `{ token, refreshToken }`. It verifies the
163
+ access token with the API JWT secret and allows 60 seconds of clock difference between the server
164
+ and the API. Its `sub`, `unique_name`, and `role` claims become the user's `id`, `name`, and
165
+ `roles`. The log gets the status, error code, and message, never the request. If sign-in fails,
166
+ next-auth's `error` holds one of these:
167
+ - `SIGN_IN_ERRORS.unavailable` when the API does not answer, or a gateway returns 502, 503, or
168
+ 504.
187
169
  - `SIGN_IN_ERRORS.invalidCredentials` for every other failure, including an unreadable token and
188
170
  an answer without both tokens.
189
171
  - **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.
172
+ before it when that is shorter. The options never schedule it sooner than 30 seconds out, so a
173
+ token that lives under 30 seconds gets its refresh after it expires. Parallel reads of one session
174
+ in one server process share a single refresh. The options keep its answer for a minute, because
175
+ the API rotates the refresh token on every call.
194
176
  - **Failures:** a 400 or 401 from the refresh ends the session with `RefreshTokenRejected`. Any
195
177
  other failure keeps it and tries again a minute later. A session without a refresh token ends with
196
178
  `NoRefreshToken` when its refresh falls due.
@@ -200,38 +182,38 @@ What the options do:
200
182
  seven days in milliseconds.
201
183
  - **Sign-in page:** `pages.signIn` is `config.loginRoute`.
202
184
 
203
- Two rules keep a refresh from being lost:
185
+ Follow two rules so that you never lose a refresh:
204
186
 
205
187
  - 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
188
+ `getServerSession(authOptions)` cannot store a cookie in server components, route handlers, or
189
+ server actions. A refresh there replaces the tokens at the API and loses the new ones. The
208
190
  next refresh then reuses the old token, and the API revokes every session the user has. On the
209
191
  server, read the token with `getToken` from `next-auth/jwt`, which never refreshes.
210
192
  - Run the app as one server process. Parallel session reads share one refresh only within a process.
211
193
  Two replicas can refresh the same token at once, with the same result.
212
194
 
213
- The package declares these fields on next-auth's types in `@sjolystinnovation/app-kit/next-auth`. The
214
- app loads the declarations once, from one of its own `.d.ts` files, so its code can read them:
195
+ The package declares these fields on next-auth's types in `@sjolystinnovation/app-kit/next-auth`.
196
+ Load the declarations once, from one of the app's own `.d.ts` files, so its code can read them:
215
197
 
216
198
  ```ts
217
199
  // src/types/next-auth.d.ts
218
200
  import "@sjolystinnovation/app-kit/next-auth";
219
201
  ```
220
202
 
221
- The session carries `user` (`id`, `name`, `email`, `roles`), `accessToken`, `error` and
222
- `absoluteExpiresAt`. The token carries `accessToken`, `refreshToken`, `refreshAt`, `loginAt`, `user`
223
- and `error`.
203
+ The session carries `user` (`id`, `name`, `email`, `roles`), `accessToken`, `error`, and
204
+ `absoluteExpiresAt`. The token carries `accessToken`, `refreshToken`, `refreshAt`, `loginAt`,
205
+ `user`, and `error`.
224
206
 
225
207
  ### `@sjolystinnovation/app-kit/api`
226
208
 
227
209
  `createApiClient(baseURL)` returns an axios instance for the signed-in API, for code that runs in
228
210
  the browser. It reads the session through next-auth's `getSession`, which has no cookies to read on
229
- the server. An empty `baseURL` throws. Each request carries the session's access token. A 401
230
- opens the re-sign-in prompt when the session is gone or has a terminal error. It does not sign
231
- the user out, so the page keeps its state. The error still rejects as usual.
211
+ the server. An empty `baseURL` throws. Each request carries the session's access token. When the
212
+ session is gone or has a terminal error, a 401 opens the re-sign-in prompt. It does not sign the
213
+ user out, so the page keeps its state. The error still rejects as usual.
232
214
 
233
215
  `request(call, fallback, statusMessages?)` runs a call and returns its `data`. A failure becomes an
234
- `Error` with the message `formatApiError` gives it. A cancelled call rethrows the cancel unchanged:
216
+ `Error` with the message `formatApiError` gives it. A canceled call rethrows the cancel unchanged:
235
217
 
236
218
  ```ts
237
219
  import { requireEnv } from "@sjolystinnovation/app-kit";
@@ -254,29 +236,34 @@ and gives an empty string without a number.
254
236
  export const getCompanyInfo = async () => mergeCompanyInfo(await publicGetOptional<CompanyInfo>("/company"), COMPANY);
255
237
  ```
256
238
 
257
- `requestRevalidate(target, endpoint?)` asks the app's revalidate route to purge the pages behind
258
- `target`, so an edit shows at once. It posts to `/api/revalidate` unless `endpoint` says otherwise.
259
- It never throws. On failure the pages' own revalidate window is the fallback.
260
-
261
239
  `apiFieldErrors(error)` reads the per-field messages of an ASP.NET validation problem from a failed
262
240
  call or its body, keyed in camelCase to match the form: `{ errors: { OrgNumber: ["..."] } }` becomes
263
- `{ orgNumber: ["..."] }`. It answers null when there are none, so the form falls back to one message.
241
+ `{ orgNumber: ["..."] }`. It returns `null` when there are none, so the form falls back to one
242
+ message.
264
243
 
265
244
  `createPublicClient(baseURL)` is the same without the session, for the API's anonymous endpoints. An
266
245
  empty `baseURL` throws, and requests only go to `baseURL`.
267
246
 
268
- The services below call the endpoints the SjolystInnovation.AppKit .NET packages map, so an app does
269
- not write them. `api` is a `createApiClient` instance and `publicApi` a `createPublicClient` one.
247
+ The services below call the endpoints that the SjolystInnovation.AppKit .NET packages map, so you
248
+ do not write them in the app. `api` is a `createApiClient` instance, and `publicApi` is a
249
+ `createPublicClient` one.
270
250
  Failures throw an `Error` with the API's message, or with the service's own English fallback.
271
- `onSaved` runs after a save succeeds, such as `() => requestRevalidate("branding")`.
251
+ `onSaved` runs after a save succeeds, such as `() => revalidateTarget("branding")`. Here
252
+ `revalidateTarget` is the `revalidate` that
253
+ [`defineRevalidateTargets`](#sjolystinnovationapp-kitserver) returns. It posts the target to
254
+ the app's revalidate route and never throws. On failure, the pages' own revalidate window is the
255
+ fallback.
256
+
257
+ `invite` takes `{ email, firstName, lastName, role }`. `save` takes
258
+ `{ key, locale, title, bodyMarkdown }`.
272
259
 
273
260
  | Factory | Methods | .NET package |
274
261
  | --- | --- | --- |
275
- | `createUserService(api, publicApi)` | `requestPasswordReset`, `resetPassword`, and for the signed-in user `getProfile`, `updateProfile`, `changePassword`, `exportData`, `deleteAccount` | Auth |
276
- | `createAdminUsersService(api)` | `getUsers(includeDeleted?)`, `addRole`, `removeRole`, `deleteUser`, `invite` | Auth |
262
+ | `createUserService(api, publicApi)` | `requestPasswordReset`, `resetPassword`, and for the signed-in user `getProfile`, `updateProfile`, `changePassword`, `exportData`, and `deleteAccount` | Auth |
263
+ | `createAdminUsersService(api)` | `getUsers(includeDeleted?)`, `addRole`, `removeRole`, `deleteUser`, and `invite` | Auth |
277
264
  | `createSettingsService(api, { onSaved? })` | `get`, `update` | SiteSettings |
278
265
  | `createBrandingService(api, publicApi, { onSaved? })` | `get`, which answers `{}` when the API cannot be read, and `update(form)` | SiteSettings |
279
- | `createLegalService<Key>(api, publicApi, { onSaved? })` | `get(key, locale)`, which answers `null` for a missing page, `listAll`, `save` | SiteSettings |
266
+ | `createLegalService<Key>(api, publicApi, { onSaved? })` | `get(key, locale)`, which answers `null` for a missing page, `listAll`, and `save` | SiteSettings |
280
267
 
281
268
  ```ts
282
269
  import { createApiClient, createPublicClient, createUserService } from "@sjolystinnovation/app-kit/api";
@@ -285,7 +272,8 @@ const apiUrl = requireEnv("NEXT_PUBLIC_API_URL", process.env.NEXT_PUBLIC_API_URL
285
272
  export default createUserService(createApiClient(apiUrl), createPublicClient(apiUrl));
286
273
  ```
287
274
 
288
- The response types are exported too: `UserProfile`, `AdminUser`, `SiteSettings` and `LegalPage`.
275
+ The package also exports the response types: `UserProfile`, `AdminUser`, `SiteSettings`, and
276
+ `LegalPage`.
289
277
 
290
278
  ### `@sjolystinnovation/app-kit/server`
291
279
 
@@ -293,15 +281,15 @@ For server components and route handlers only. It imports `next/cache` and `next
293
281
 
294
282
  `createPublicApi(baseUrl, options?)` returns cached GETs against the API's anonymous endpoints:
295
283
 
296
- - `publicGet(path, { tags? })` gives the data, or null when the API answers 404. An unreachable or
284
+ - `publicGet(path, { tags? })` gives the data, or `null` when the API answers 404. An unreachable or
297
285
  failing API throws `PublicApiError`, so a failed fetch is never cached as an empty page in place
298
286
  of a working one.
299
- - `publicGetOptional(path, { tags? })` gives null on any failure, for data a page can do without.
287
+ - `publicGetOptional(path, { tags? })` gives `null` on any failure, for data a page can do without.
300
288
  - `publicGetWithMeta(path, { tags? })` also gives `lastModified`, from the `Last-Modified` header.
301
289
 
302
290
  Responses stay fresh for 60 seconds unless `options.revalidate` says otherwise. An empty `baseUrl`
303
291
  throws. Each path starts with a single `/`, or the call throws. A path with a `.` or `..` segment,
304
- encoded or not, gives null without a request, the same as a 404. Run a slug or other user input
292
+ encoded or not, gives `null` without a request, the same as a 404. Run a slug or other user input
305
293
  through `encodeURIComponent` before putting it in a path.
306
294
 
307
295
  ```ts
@@ -316,11 +304,11 @@ export const { publicGet, publicGetOptional, publicGetWithMeta } = createPublicA
316
304
 
317
305
  `createRevalidateRoute({ targets, role })` returns a `POST` handler for `app/api/revalidate/route.ts`.
318
306
  Each target maps to the page paths it purges. A path with a dynamic segment, such as
319
- `/builds/[slug]`, purges every page of that route. The target name is purged as a cache tag too, so
320
- tag the fetches behind it with the same name. The handler answers 403 unless the caller has a live
321
- session with `role`: signed in within the last seven days, with no refused refresh and an access
322
- token that has not expired. It answers 415 unless the body is sent as JSON, which
323
- `requestRevalidate` does, and 400 for a target it does not know.
307
+ `/builds/[slug]`, purges every page of that route. The route also purges the target name as a cache
308
+ tag, so tag the fetches behind it with the same name. The handler answers 403 unless the caller has
309
+ a live session with `role`: signed in within the last seven days, with no refused refresh, and with
310
+ an access token that has not expired. It answers 415 unless the caller sends the body as JSON,
311
+ which the `revalidate` from `defineRevalidateTargets` does. It answers 400 for a target it does not know.
324
312
 
325
313
  ```ts
326
314
  // src/app/api/revalidate/route.ts
@@ -333,7 +321,7 @@ export const POST = createRevalidateRoute({
333
321
  ```
334
322
 
335
323
  `defineRevalidateTargets({ locales, targets })`, from `@sjolystinnovation/app-kit/api`, keeps the
336
- targets in one file that both sides import. `pages` are below each locale segment and `root` paths
324
+ targets in one file that both sides import. `pages` are below each locale segment, and `root` paths
337
325
  are not. It returns `targets` for fetch tags, `paths` for the route, and `revalidate(target)` for
338
326
  the admin services.
339
327
 
@@ -358,8 +346,9 @@ For `next.config.ts`. It imports nothing but Next's types, so Next loads it with
358
346
 
359
347
  `defineAppConfig(options, extra?)` gives the config every app shares: the standalone build, app-kit
360
348
  in `transpilePackages`, no `X-Powered-By`, `expireTime` of five minutes, image qualities 75 and 100,
361
- the incremental cache in memory, and the security headers on every page. `extra` is merged over it,
362
- with `transpilePackages`, `images`, `headers` and `rewrites` combined rather than replaced.
349
+ the incremental cache in memory, and the security headers on every page. It merges `extra` over
350
+ that config and combines `transpilePackages`, `images`, `experimental`, `headers`, and `rewrites`
351
+ rather than replacing them.
363
352
 
364
353
  ```ts
365
354
  // next.config.ts
@@ -386,17 +375,14 @@ export default defineAppConfig({
386
375
  | `expireTime` | `number` | Seconds a cache may serve a stale page. Defaults to 300. Next's default is a year. |
387
376
  | `isrFlushToDisk` | `boolean` | Write revalidated pages to disk. Off by default, for a read-only root filesystem. |
388
377
 
389
- The pieces are exported too: `securityHeaders(options)`, `contentSecurityPolicy(options)`,
390
- `contentImagesRewrite(apiUrl, { source?, upstreamPath? })`, `apiOrigin(apiUrl)` and
391
- `apiBaseUrl(apiUrl)`. Scripts allow `'unsafe-inline'`, since a nonce would make every statically
392
- rendered page dynamic.
378
+ Scripts allow `'unsafe-inline'`, since a nonce would make every statically rendered page dynamic.
393
379
 
394
380
  ### `@sjolystinnovation/app-kit/markdown`
395
381
 
396
- Renders Markdown from an editor or the API, with GitHub tables, task lists and footnotes. Raw HTML is
397
- never rendered and the output is sanitized, so the text cannot inject scripts, styles, event
398
- handlers or `javascript:` links. It works in a server component. It needs `react-markdown`,
399
- `remark-gfm` and `rehype-sanitize`, which are optional peer dependencies:
382
+ Renders Markdown from an editor or the API, with GitHub tables, task lists, and footnotes. It never
383
+ renders raw HTML and sanitizes the output, so the text cannot inject scripts, styles, event
384
+ handlers, or `javascript:` links. It works in a server component. It needs `react-markdown`,
385
+ `remark-gfm`, and `rehype-sanitize`, which are optional peer dependencies:
400
386
 
401
387
  ```sh
402
388
  npm install react-markdown remark-gfm rehype-sanitize
@@ -412,20 +398,19 @@ import { Markdown } from "@sjolystinnovation/app-kit/markdown";
412
398
 
413
399
  | Prop | Type | Default | Description |
414
400
  | --- | --- | --- | --- |
415
- | `children` | `string \| null \| undefined` | | The Markdown. Empty, null or undefined renders nothing. |
401
+ | `children` | `string \| null \| undefined` | | The Markdown. Empty, `null`, or `undefined` renders nothing. |
416
402
  | `components` | `Components` | | Replaces the element for a tag, such as `{ p: MyParagraph }`. Tags not named keep the default. It gets the props without `node`, so it can spread them onto an element. |
417
403
  | `externalLinks` | `boolean` | `false` | Opens `http` and `https` links in a new tab with `rel="noopener noreferrer"`. Site-relative links stay in the tab. |
418
404
  | `className` | `string` | | Wraps the output in a `div` with these classes. Without it the elements render bare, so a parent's `space-y-*` spaces them. |
419
405
 
420
- The text takes its colour and size from the parent. A level one heading becomes a level two, since
421
- the page has its own `h1`. `markdownComponents` holds the default elements, for an app that builds
422
- on them.
406
+ The text takes its color and size from the parent. A level one heading becomes a level two, since
407
+ the page has its own `h1`.
423
408
 
424
409
  ### `@sjolystinnovation/app-kit/seo`
425
410
 
426
411
  Search engine helpers for one site: canonical URLs with hreflang, page metadata with Open Graph,
427
- sitemap entries, robots.txt and schema.org structured data. It works in a server component.
428
- `defineSite` binds them to the site's name, canonical origin and locales:
412
+ sitemap entries, `robots.txt`, and schema.org structured data. It works in a server component.
413
+ `defineSite` binds them to the site's name, canonical origin, and locales:
429
414
 
430
415
  ```ts
431
416
  // src/lib/seo/site.ts
@@ -444,7 +429,7 @@ export const site = defineSite({
444
429
 
445
430
  | Option | Type | Description |
446
431
  | --- | --- | --- |
447
- | `name` | `string` | The site's name, in titles, Open Graph and structured data. |
432
+ | `name` | `string` | The site's name, in titles, Open Graph, and structured data. |
448
433
  | `url` | `string` | The canonical origin, without a trailing slash. |
449
434
  | `titleSeparator` | `string` | Between a page title and the site name. |
450
435
  | `locales` | `string[]` | The locale segments every page is under. A site without them leaves it out, and its paths have no segment. |
@@ -453,24 +438,24 @@ export const site = defineSite({
453
438
  | `tagline` | `(locale) => string` | The line after the name in the default title and the share image's alt text. |
454
439
  | `shareImagePath` | `string` | The share image route below each locale. Defaults to `/og`. |
455
440
 
456
- The site gives:
441
+ The site provides:
457
442
 
458
443
  - `localePath(locale, path)`, `absoluteUrl(path)`, `absoluteLocaleUrl(locale, path)` and
459
444
  `languageAlternates(path)`. Paths get a leading slash and no trailing slash.
460
445
  - `siteMetadata({ locale, description, manifest })` for the locale root layout: the title
461
- template, `metadataBase`, the index directive and the front page's canonical.
446
+ template, `metadataBase`, the index directive, and the front page's canonical.
462
447
  - `pageMetadata({ locale, path, title, description, images })` for a page: the canonical URL,
463
448
  hreflang, and Open Graph with the locale's share image unless `images` is given.
464
449
  - `localeEntries(path, { locales, lastModified })` for `sitemap.ts`: one entry per locale, each
465
- listing the others. `lastModified` is left out when unknown. A site without locales gets one
466
- entry per page.
450
+ listing the others. It leaves out `lastModified` when that is unknown. A site without locales
451
+ gets one entry per page.
467
452
  - `forEachLocale(load)` runs `load` for every locale at once and keys the results by locale.
468
453
  - `legalEntries({ keys, updatedAt })` gives the legal pages' entries, `/terms`, `/privacy` and
469
454
  `/cookies` unless `keys` says otherwise, each dated by the timestamp `updatedAt` returns for it.
470
- - `robots({ privatePaths, privateRootPaths, indexable })` for `robots.ts`. The private paths are
471
- disallowed under every locale segment. A deployment that is not production disallows everything.
455
+ - `robots({ privatePaths, privateRootPaths, indexable })` for `robots.ts`. It disallows the private
456
+ paths under every locale segment. A deployment that is not production disallows everything.
472
457
  - `organizationNode(company, { type, sameAs, country, image, logo })`, `webSiteNode(locale)`,
473
- `breadcrumbNode(crumbs, pageUrl)`, `itemListNode(paths, pageUrl)`, `siteId(fragment)` and `ids`
458
+ `breadcrumbNode(crumbs, pageUrl)`, `itemListNode(paths, pageUrl)`, `siteId(fragment)`, and `ids`
474
459
  for structured data.
475
460
 
476
461
  ```tsx
@@ -491,16 +476,16 @@ export default async () => [
491
476
  <JsonLd nodes={[site.organizationNode(company), site.webSiteNode(locale)]} />
492
477
  ```
493
478
 
494
- `isIndexableEnvironment()` reads `SITE_ENV`. `prod`, `production` and unset or blank count as
479
+ `isIndexableEnvironment()` reads `SITE_ENV`. `prod`, `production`, and unset or blank count as
495
480
  production, so a missing variable never deindexes the live site. `robots.ts` needs
496
481
  `dynamic = "force-dynamic"`, so it reads the variable per request. The package also exports
497
- `JsonLd`, `serializeGraph`, `ref`, `NOINDEX`, `LEGAL_PAGE_KEYS`, `SHARE_IMAGE_SIZE`,
498
- `normalizePath`, `latest` and `parseTimestamp`.
482
+ `JsonLd`, `serializeGraph`, `ref`, `NOINDEX`, `LEGAL_PAGE_KEYS`, `SHARE_IMAGE_SIZE`, `latest`, and
483
+ `parseTimestamp`.
499
484
 
500
485
  ### `@sjolystinnovation/app-kit/seo/og`
501
486
 
502
487
  `shareImage({ name, tagline, colors })` renders the default share preview, a PNG at
503
- `SHARE_IMAGE_SIZE`: the name, a bar in `colors.primary` and the tagline. The colours are literals,
488
+ `SHARE_IMAGE_SIZE`: the name, a bar in `colors.primary`, and the tagline. The colors are literals,
504
489
  since the image renders outside the browser. Return it from the route `pageMetadata` points at:
505
490
 
506
491
  ```tsx
@@ -523,7 +508,7 @@ The logo and favicon an admin uploads, as the API's `/branding` endpoint returns
523
508
  `BrandingProvider` holds them for the components below it. It takes the branding the server
524
509
  rendered with, loads it on mount when there is none, and loads it again every five minutes, so an
525
510
  open tab picks up a change. A failed load keeps what it has. `useBranding()` gives `branding`,
526
- `logoUrl` as a `data:` URL or null, and `refresh()`.
511
+ `logoUrl` as a `data:` URL or `null`, and `refresh()`.
527
512
 
528
513
  ```tsx
529
514
  "use client";
@@ -547,16 +532,14 @@ above it, and refreshes it after a save.
547
532
  | `save` | `(form: FormData) => Promise<unknown>` | Sends `logo` and `icon` files, and `removeLogo` and `removeIcon` set to `true`. Throw to show the error's message. |
548
533
  | `strings` | `TextOverrides<BrandingManagerStrings>` | The form's text. |
549
534
 
550
- `toDataUrl(data, contentType)` turns base64 bytes into a `data:` URL.
551
-
552
535
  ### `@sjolystinnovation/app-kit/branding/server`
553
536
 
554
537
  `brandingIcon({ load, fallbackPath, fallbackType })` is the response for an `app/icon.ts` route:
555
- the favicon set in admin settings when it is a PNG, JPEG, WebP, GIF or ICO, or the bundled file
538
+ the favicon set in admin settings when it is a PNG, JPEG, WebP, GIF, or ICO, or the bundled file
556
539
  otherwise, since an SVG or HTML upload could run script on the site's origin. Both carry `nosniff`
557
- and a sandbox CSP, which a CSP header from the app's `next.config` replaces. When the branding cannot be
558
- loaded it answers 503 with `no-store`, since a browser would cache the bundled icon in place of the
559
- one set in settings.
540
+ and a sandbox CSP, which a CSP header from the app's `next.config` replaces. If the route cannot
541
+ load the branding, it answers 503 with `no-store`, since a browser would cache the bundled icon in
542
+ place of the one set in settings.
560
543
 
561
544
  ```ts
562
545
  // src/app/icon.ts
@@ -590,7 +573,7 @@ most, and stops on a failed fetch and on unmount. It gives `data`, `loading` and
590
573
  each fetch uses the latest `fetcher` and `isFinal`.
591
574
 
592
575
  `PaymentReturn` shows the outcome: confirmed, still pending, or failed. It takes the text and the way
593
- on for each state, and a refresh button while pending when given `onRefresh`.
576
+ on for each state. When you pass `onRefresh`, it shows a refresh button while pending.
594
577
 
595
578
  ```tsx
596
579
  "use client";
@@ -614,7 +597,7 @@ if (loading) return <Spinner />;
614
597
  | Prop | Type | Description |
615
598
  | --- | --- | --- |
616
599
  | `state` | `"success" \| "pending" \| "failed"` | Which content to show. |
617
- | `success`, `pending`, `failed` | `{ title, body, href, cta, note?, extra? }` | The text, the button's target and text, a smaller note line and anything more, per state. |
600
+ | `success`, `pending`, `failed` | `{ title, body, href, cta, note?, extra? }` | The text, the button's target and text, a smaller note line, and anything more, per state. |
618
601
  | `onRefresh` | `() => void` | Shows a refresh button while pending. |
619
602
  | `refreshLabel` | `string` | Its text. Defaults to `Check again`. |
620
603
  | `className` | `string` | Classes for placing it, on the outer element. |
@@ -625,7 +608,7 @@ if (loading) return <Spinner />;
625
608
 
626
609
  `CompanySettingsForm` is the admin form for the business details the public pages show, saved
627
610
  through `createSettingsService`. Its children show below the form once the settings have loaded,
628
- such as the `BrandingManager`. `strings` holds every label, hint and message, English by default.
611
+ such as the `BrandingManager`. `strings` holds every label, hint, and message, English by default.
629
612
 
630
613
  ```tsx
631
614
  <CompanySettingsForm service={SettingsService} strings={dict.companySettings}>
@@ -659,7 +642,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
659
642
  `StatHistoryChart` draws the admin stats over time: a line per series, a scale from zero to the
660
643
  highest value shown, and legend buttons that hide and show each line. `data` is one row per day,
661
644
  oldest first, with `date` as `YYYY-MM-DD`. A missing value counts as zero. Each series names the
662
- field it plots and its label, and takes the next colour in `STAT_SERIES_COLORS` unless it has its
645
+ field it plots and its label, and takes the next color in `STAT_SERIES_COLORS` unless it has its
663
646
  own. `strings` holds `empty`, shown when there are no days, and `chart`, the accessible name.
664
647
 
665
648
  ```tsx
@@ -676,8 +659,8 @@ own. `strings` holds `empty`, shown when there are no days, and `chart`, the acc
676
659
  `AdminUsersPanel` is the users page. It lists every user with their roles, shows deleted users
677
660
  behind a toggle, gives and takes roles, deletes an account after a confirm, and invites a user by
678
661
  email. It calls the API through `createAdminUsersService`. `getRoles` returns the roles an admin
679
- may give. Inline functions are fine. The panel does not fetch again when they change. It reports
680
- with `toast`, so the app needs sonner and a `Toaster`.
662
+ may give. You can pass inline functions, because the panel does not fetch again when they change.
663
+ It reports with `toast`, so the app needs sonner and a `Toaster`.
681
664
 
682
665
  ```tsx
683
666
  import { AdminUsersPanel } from "@sjolystinnovation/app-kit/admin";
@@ -690,7 +673,7 @@ import { AdminUsersPanel } from "@sjolystinnovation/app-kit/admin";
690
673
  | `service` | `ReturnType<typeof createAdminUsersService>` | The user admin calls. |
691
674
  | `getRoles` | `() => Promise<string[]>` | The roles an admin may give. |
692
675
  | `defaultInviteRole` | `string` | The role a new invite starts with. Defaults to `Admin`. |
693
- | `strings` | `TextOverrides<AdminUsersPanelStrings>` | The text, English by default. `{name}` and `{role}` are filled in where a text names them. |
676
+ | `strings` | `TextOverrides<AdminUsersPanelStrings>` | The text, English by default. The panel fills in `{name}` and `{role}` where a text names them. |
694
677
 
695
678
  `LegalPagesEditor` edits the legal pages one page and one locale at a time, through
696
679
  `createLegalService`. A locale tab whose page has no content yet gets a dot. `LocaleTabs` is the
@@ -710,9 +693,9 @@ same tab bar on its own, for other translated forms.
710
693
  ### `@sjolystinnovation/app-kit/legal`
711
694
 
712
695
  `LegalPageView` renders a terms, privacy or cookies page as an admin wrote it: the title, the date
713
- it last changed in `dateLocale`, and the Markdown body. With no page it shows `title` and says the
714
- page is missing. It is a server component and needs the same peers as `markdown`. The route file
715
- stays in the app, since Next reads `revalidate` and the metadata from it.
696
+ it last changed in `dateLocale`, and the Markdown body. If there is no page, it shows `title` and
697
+ says the page is missing. It is a server component and needs the same peers as `markdown`. The
698
+ route file stays in the app, since Next reads `revalidate` and the metadata from it.
716
699
 
717
700
  ```tsx
718
701
  // src/app/[locale]/terms/page.tsx
@@ -727,60 +710,14 @@ export default async function TermsPage({ params }: { params: Promise<{ locale:
727
710
 
728
711
  ### `@sjolystinnovation/app-kit/session`
729
712
 
730
- Decides when a user has to sign in again. Safe to import from server code, such as the next-auth
731
- callbacks.
713
+ Decides when a user has to sign in again. It is safe to import from server code, such as the
714
+ next-auth callbacks.
732
715
 
733
716
  The gate reads `session.error`. `SessionExpiryGuard` also reads `session.absoluteExpiresAt` for its
734
717
  countdown, `session.user.id` to keep the page with the user who opened it, and `session.user.email`
735
- to fill in the prompt. `createAuthOptions` sets all of these. An app with its own next-auth options sets them in its
736
- callbacks:
737
-
738
- ```ts
739
- const SESSION_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000;
740
-
741
- callbacks: {
742
- jwt({ token, user }) {
743
- if (user) token.loginAt = Date.now();
744
- // A token without a sign-in time counts as expired.
745
- if (!token.loginAt || Date.now() - token.loginAt > SESSION_LIFETIME_MS) {
746
- token.error = SESSION_ERRORS.absoluteExpiry;
747
- }
748
- // Also set token.error when a refresh is refused.
749
- return token;
750
- },
751
- session({ session, token }) {
752
- session.user.id = token.sub ?? "";
753
- session.error = token.error;
754
- session.absoluteExpiresAt = token.loginAt ? token.loginAt + SESSION_LIFETIME_MS : undefined;
755
- return session;
756
- },
757
- },
758
- ```
759
-
760
- For that to compile, these fields have to be on next-auth's `Session` and `JWT` types. Add them to
761
- the app's existing next-auth type declarations, or create them:
762
-
763
- ```ts
764
- // src/types/next-auth.d.ts
765
- import "next-auth";
766
- import "next-auth/jwt";
767
- import type { DefaultSession } from "next-auth";
768
-
769
- declare module "next-auth" {
770
- interface Session {
771
- user: { id: string } & DefaultSession["user"];
772
- error?: string | undefined;
773
- absoluteExpiresAt?: number | undefined;
774
- }
775
- }
776
-
777
- declare module "next-auth/jwt" {
778
- interface JWT {
779
- error?: string | undefined;
780
- loginAt?: number | undefined;
781
- }
782
- }
783
- ```
718
+ to fill in the prompt. `createAuthOptions` sets all of these. Load their types from
719
+ `@sjolystinnovation/app-kit/next-auth`, as [`createAuthOptions`](#createauthoptionsconfig-settings)
720
+ shows.
784
721
 
785
722
  `SIGN_IN_ERRORS` holds the two codes a failed sign-in reports: `InvalidCredentials` and
786
723
  `SignInUnavailable`. Map them to the app's own messages.
@@ -801,37 +738,8 @@ The re-sign-in prompt is a flag outside React, so an axios interceptor can raise
801
738
 
802
739
  Copies of the package installed side by side share the one flag, as long as their releases use the
803
740
  same prompt state format. A release that changes the format says so in its changelog. An app's own
804
- copy of these functions does not share the flag. So the interceptor, the prompt and every gate all
805
- import them from this package.
806
-
807
- ### `useSessionGate()`
808
-
809
- From `@sjolystinnovation/app-kit/session/react`. Tells a protected page whether it may render. It
810
- reads `useSession`, so it needs a `SessionProvider` above it.
811
-
812
- A page that rendered on a healthy session stays rendered, so an open form keeps its values:
813
-
814
- - when the session gets a terminal error
815
- - while the session is re-read
816
- - while the prompt recovers it
817
-
818
- A session that turns signed out with no prompt open blanks the page. A page the user opens on a
819
- dead session does not render. Use `mayRender` in every gate and layout on the way down, so none of
820
- them blanks a page that another one kept.
821
-
822
- The hook does not navigate. `ProtectedGate` below adds the redirect. Use the hook directly in a
823
- layout that needs its own decision, such as a role check, so it agrees with the gate.
824
-
825
- | Field | Description |
826
- | --- | --- |
827
- | `mayRender` | Whether the page may render. |
828
- | `session`, `status` | As `useSession` returns them. |
829
- | `usable` | The session is signed in and has no terminal error. |
830
- | `wasUsable` | This page has rendered on a usable session. |
831
- | `promptOpen` | The re-sign-in prompt is showing. |
832
- | `recovering` | The prompt is showing over a page that was usable. |
833
-
834
- `SessionGate` is the type of the result.
741
+ copy of these functions does not share the flag, so import them from this package in the
742
+ interceptor, the prompt, and every gate.
835
743
 
836
744
  ### `AppSessionProvider`
837
745
 
@@ -850,9 +758,19 @@ reports in this tab up to date. next-auth polls only once a session exists.
850
758
 
851
759
  ### `ProtectedGate`
852
760
 
853
- From `@sjolystinnovation/app-kit/session/react`. Wraps the protected part of the app. It renders the
854
- page only when `useSessionGate` allows it, and sends a signed-out visitor or a session dead on
855
- arrival to `loginHref`. It never redirects while the re-sign-in prompt is open.
761
+ From `@sjolystinnovation/app-kit/session/react`. Wraps the protected part of the app. It reads
762
+ `useSession`, so it needs a `SessionProvider` above it. It sends a signed-out visitor or a session
763
+ dead on arrival to `loginHref`, and never redirects while the re-sign-in prompt is open.
764
+
765
+ A page that rendered on a healthy session stays rendered, so an open form keeps its values:
766
+
767
+ - when the session gets a terminal error
768
+ - while the session is re-read
769
+ - while the prompt recovers it
770
+
771
+ A session that turns signed out with no prompt open blanks the page. A page the user opens on a
772
+ dead session does not render. `RoleGate` and `AdminShell` make the same decision, so none of them
773
+ blanks a page that another one kept.
856
774
 
857
775
  ```tsx
858
776
  <ProtectedGate loginHref="/login" fallback={<p>Loading…</p>}>
@@ -868,7 +786,7 @@ arrival to `loginHref`. It never redirects while the re-sign-in prompt is open.
868
786
  ### `RoleGate`
869
787
 
870
788
  From `@sjolystinnovation/app-kit/session/react`. Renders its children only for a user with `role`.
871
- Place it inside `ProtectedGate`. While the re-sign-in prompt recovers a signed-out session there is
789
+ Place it inside `ProtectedGate`. While the re-sign-in prompt recovers a signed-out session, there is
872
790
  no user and so no roles. The page stays up only on the path where the session last had the role.
873
791
 
874
792
  ```tsx
@@ -894,8 +812,9 @@ shows:
894
812
  - a banner when the session is dead
895
813
  - the re-sign-in prompt, a dialog with `CredentialsForm`, when a request or the banner opens it
896
814
 
897
- A sign-in in the prompt keeps the page and its form. A sign-in in the prompt as a different user is
898
- signed out and sent to `loginHref`, so the prompt never hands the page to another user.
815
+ A sign-in in the prompt keeps the page and its form. If someone signs in to the prompt as a
816
+ different user, the guard signs them out and sends them to `loginHref`, so the prompt never hands
817
+ the page to another user.
899
818
 
900
819
  ```tsx
901
820
  <SessionExpiryGuard
@@ -906,7 +825,7 @@ signed out and sent to `loginHref`, so the prompt never hands the page to anothe
906
825
 
907
826
  | Prop | Type | Description |
908
827
  | --- | --- | --- |
909
- | `loginHref` | `string` | Where a sign-in as a different user is sent after it is signed out. |
828
+ | `loginHref` | `string` | Where the guard sends a different user after it signs them out. |
910
829
  | `onRestored` | `() => void` | Runs after the prompt signs the user back in, such as to show a toast. |
911
830
  | `strings` | `TextOverrides<SessionExpiryGuardStrings>` | The guard's text. `{minutes}` in `expiringSoon` becomes the minutes left. The defaults are English. |
912
831
  | `formStrings` | `TextOverrides<CredentialsFormStrings>` | The text of the form in the prompt. |
@@ -914,8 +833,8 @@ signed out and sent to `loginHref`, so the prompt never hands the page to anothe
914
833
  ### `useFormDraft(config, options)`
915
834
 
916
835
  From `@sjolystinnovation/app-kit/forms`. Keeps a form's values in `localStorage`, so a sign-out,
917
- reload or closed tab does not lose them. Values are written half a second after the last change, so
918
- only that last half second is at risk.
836
+ reload or closed tab does not lose them. The hook writes values half a second after the last
837
+ change, so only that last half second is at risk.
919
838
 
920
839
  ```tsx
921
840
  // userId is the signed-in user's id, from wherever the app's session keeps it.
@@ -928,29 +847,29 @@ const draft = useFormDraft(sessionConfig, {
928
847
 
929
848
  | Option | Type | Description |
930
849
  | --- | --- | --- |
931
- | `owner` | `string \| undefined` | The signed-in user's id. Drafts are stored per user, and nothing is stored until it is known. The last known owner is kept when the session stops reporting one. |
850
+ | `owner` | `string \| undefined` | The signed-in user's id. The hook stores drafts per user, and stores nothing until the id is known. When the session stops reporting an owner, the hook keeps the last known one. |
932
851
  | `scope` | `string` | The form and the entity it edits, such as `recipe:42`. |
933
852
  | `value` | `T` | The form's current values. They must survive `JSON.stringify`. |
934
853
 
935
854
  Mount an edit form once the entity has loaded, with its values already in `value`. The hook treats
936
855
  the values it first sees under a key as the untouched form, so values that arrive later count as an
937
- edit and are written over the draft being offered.
856
+ edit and overwrite the draft on offer.
938
857
 
939
858
  It returns:
940
859
 
941
- - `pending`, the stored draft. The page offers it to the user and does not apply it. It is read
942
- when the owner is first known and again whenever the owner or `scope` changes, so it can appear
943
- after the form mounts.
860
+ - `pending`, the stored draft. The page offers it to the user and does not apply it. The hook reads
861
+ it when the owner is first known, and again whenever the owner or `scope` changes, so it can
862
+ appear after the form mounts.
944
863
  - `dismiss()`, to stop offering it.
945
864
  - `clear()`, to drop the stored draft after a successful save.
946
865
 
947
- Drafts are stored under `<draftStoragePrefix>:draft:<owner>:<scope>`. The hook throws when
948
- `draftStoragePrefix` is empty or contains a colon. A draft older than 7 days is not offered, and is
949
- removed when its form next reads it.
866
+ The hook stores drafts under `<draftStoragePrefix>:draft:<owner>:<scope>`. It throws when
867
+ `draftStoragePrefix` is empty or contains a colon. It does not offer a draft older than 7 days, and
868
+ removes that draft when its form next reads it.
950
869
 
951
870
  ### `CredentialsForm`
952
871
 
953
- From `@sjolystinnovation/app-kit/ui`. Email and password sign-in through next-auth's credentials
872
+ From `@sjolystinnovation/app-kit/session/react`. Email and password sign-in through next-auth's credentials
954
873
  provider, with `redirect: false`, so the page stays where it is. Use it on the login page and in
955
874
  the prompt.
956
875
 
@@ -960,7 +879,7 @@ the prompt.
960
879
 
961
880
  | Prop | Type | Description |
962
881
  | --- | --- | --- |
963
- | `onSignedIn` | `() => void \| Promise<void>` | Runs after next-auth accepts the credentials. Throw `SignInRejected` with a message to refuse the sign-in. |
882
+ | `onSignedIn` | `() => void \| Promise<void>` | Runs after next-auth accepts the credentials. Throw `SignInRejected`, from the same entry point, with a message to refuse the sign-in. |
964
883
  | `initialEmail` | `string` | The email the field starts with. |
965
884
  | `children` | `ReactNode` | Extra buttons next to the submit button. |
966
885
  | `strings` | `TextOverrides<CredentialsFormStrings>` | The form's text. The defaults are English. |
@@ -972,9 +891,9 @@ or one that throws, shows `somethingWentWrong`.
972
891
 
973
892
  ### `LoginPage`
974
893
 
975
- From `@sjolystinnovation/app-kit/ui`. The sign-in page: a title, `CredentialsForm`, a link to reset
976
- the password and, when the app has sign-up, a link to it. A sign-in goes to `redirectTo` and refreshes
977
- the page's server data. It uses the same `auth` theme variables as `ResetPasswordFlow`.
894
+ From `@sjolystinnovation/app-kit/session/react`. The sign-in page: a title, `CredentialsForm`, a link to reset
895
+ the password, and a sign-up link when the app has sign-up. A sign-in goes to `redirectTo` and
896
+ refreshes the page's server data. It uses the same `auth` theme variables as `ResetPasswordFlow`.
978
897
 
979
898
  ```tsx
980
899
  <LoginPage redirectTo="/admin" resetHref="/reset-password" strings={{ title: "Logg inn" }} formStrings={{ signIn: "Logg inn" }} />
@@ -985,17 +904,17 @@ the page's server data. It uses the same `auth` theme variables as `ResetPasswor
985
904
  | `redirectTo` | `string` | Where a signed-in user goes. |
986
905
  | `resetHref` | `string` | The password reset page. |
987
906
  | `registerHref` | `string` | The sign-up page. Without it there is no register link. |
988
- | `strings` | `TextOverrides<LoginPageStrings>` | The page's text: `title`, `forgotPassword`, `registerPrompt` (null leaves it out) and `register`. |
907
+ | `strings` | `TextOverrides<LoginPageStrings>` | The page's text: `title`, `forgotPassword`, `registerPrompt`, and `register`. Set `registerPrompt` to `null` to leave it out. |
989
908
  | `formStrings` | `TextOverrides<CredentialsFormStrings>` | The form's text. |
990
909
  | `className` | `string` | Replaces the outer `max-w-md mx-auto px-4 py-16`. |
991
910
 
992
911
  ### `ResetPasswordFlow`
993
912
 
994
913
  From `@sjolystinnovation/app-kit/ui`. The password reset page in two steps: the email asks for a
995
- code, then the code and a new password set it. The password is checked against the same rules as
996
- `passwordSchema` before it is sent, without loading zod. An emailed link with the email and code,
997
- such as an invite, opens it on the second step. Its colours and title size come from the `auth` and
998
- `button` theme variables, see [Theming](#theming).
914
+ code, then the code and a new password set it. Before it sends the password, the flow checks it
915
+ against the same rules as `passwordSchema`, without loading zod. An emailed link with the email and
916
+ code, such as an invite, opens it on the second step. Its colors and title size come from the
917
+ `auth` and `button` theme variables. See [Theming](#theming).
999
918
 
1000
919
  ```tsx
1001
920
  const params = useSearchParams();
@@ -1013,11 +932,11 @@ const params = useSearchParams();
1013
932
  | Prop | Type | Description |
1014
933
  | --- | --- | --- |
1015
934
  | `requestReset` | `(data: { email }) => Promise<unknown>` | Asks the API to email a code. |
1016
- | `reset` | `(data: { email, code, newPassword }) => Promise<unknown>` | Sets the password. A `message` in its answer is shown when it succeeds. |
935
+ | `reset` | `(data: { email, code, newPassword }) => Promise<unknown>` | Sets the password. When it succeeds, the flow shows the `message` in its answer. |
1017
936
  | `loginHref` | `string` | Where the sign-in links go. |
1018
937
  | `initialEmail`, `initialCode` | `string` | From the emailed link. A code opens the second step. |
1019
938
  | `className` | `string` | Replaces the outer `max-w-md mx-auto px-4 py-16`, such as to sit in the app's own card. |
1020
- | `strings` | `TextOverrides<ResetPasswordFlowStrings>` | The page's text. `signInPrompt`, `intro`, `codeSent` and `passwordHint` set to null are left out. `done` set to null shows the API's message, or only the sign-in link when it sends none. `passwordInvalid` set to null lists every broken rule. |
939
+ | `strings` | `TextOverrides<ResetPasswordFlowStrings>` | The page's text. Set `signInPrompt`, `intro`, `codeSent`, or `passwordHint` to `null` to leave it out. `done` set to `null` shows the API's message, or only the sign-in link when it sends none. `passwordInvalid` set to `null` lists every broken rule. |
1021
940
  | `passwordStrings` | `TextOverrides<PasswordStrings>` | The rule messages, in the app's language. |
1022
941
 
1023
942
  A failure shows the error's message, or `somethingWentWrong` when it has none.
@@ -1027,7 +946,7 @@ key is optional and may be `undefined`, and a missing or undefined key shows the
1027
946
 
1028
947
  ### `TextInput`
1029
948
 
1030
- From `@sjolystinnovation/app-kit/ui`. A labelled text field with the same look and props as
949
+ From `@sjolystinnovation/app-kit/ui`. A labeled text field with the same look and props as
1031
950
  `PasswordInput`, apart from the show and hide toggle. It takes the other `<input>` attributes too.
1032
951
 
1033
952
  ```tsx
@@ -1036,7 +955,7 @@ From `@sjolystinnovation/app-kit/ui`. A labelled text field with the same look a
1036
955
 
1037
956
  ### `TextArea`
1038
957
 
1039
- From `@sjolystinnovation/app-kit/ui`. A labelled multi-line field with the same look, label and
958
+ From `@sjolystinnovation/app-kit/ui`. A labeled multi-line field with the same look, label, and
1040
959
  error handling as `TextInput`. It takes the other `<textarea>` attributes too, and works controlled
1041
960
  or uncontrolled.
1042
961
 
@@ -1069,7 +988,7 @@ It takes the other `<button>` attributes too.
1069
988
  | `onChange` | `(checked: boolean) => void` | Called with the new state. A callback that ignores it also fits. |
1070
989
  | `label` | `string` | Visible text after the switch. It names the switch, and a click on it flips the switch. |
1071
990
  | `aria-label` | `string` | The switch's name when there is no `label`. One of the two is required. |
1072
- | `disabled` | `boolean` | Greys the switch out and ignores clicks. |
991
+ | `disabled` | `boolean` | Grays the switch out and ignores clicks. |
1073
992
  | `className` | `string` | Classes for placing it, on the outer element. |
1074
993
 
1075
994
  An `onClick` that calls `event.preventDefault()` stops the change.
@@ -1084,7 +1003,7 @@ import { Alert } from "@sjolystinnovation/app-kit/ui";
1084
1003
 
1085
1004
  | Prop | Type | Default | Description |
1086
1005
  | --- | --- | --- | --- |
1087
- | `variant` | `"error" \| "success" \| "info" \| "warning"` | `"info"` | Colour of the box. |
1006
+ | `variant` | `"error" \| "success" \| "info" \| "warning"` | `"info"` | Color of the box. |
1088
1007
  | `role` | `"alert" \| "status" \| "none"` | `"alert"` | A screen reader announces `status` without interrupting, on every change of its text. Use `none` when a separate live region announces the text. |
1089
1008
  | `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. |
1090
1009
  | `children` | `ReactNode` | | Content. |
@@ -1103,7 +1022,7 @@ a server component.
1103
1022
 
1104
1023
  | Prop | Type | Default | Description |
1105
1024
  | --- | --- | --- | --- |
1106
- | `imageId` | `string \| null` | | Uploaded image id. Null or empty shows `fallbackSrc` as it is, with no `srcset`. |
1025
+ | `imageId` | `string \| null` | | Uploaded image id. `null` or empty shows `fallbackSrc` as it is, with no `srcset`. |
1107
1026
  | `alt` | `string` | | Empty for a decorative image. |
1108
1027
  | `sizes` | `string` | | Widths the image is shown at. Required, since a browser assumes `100vw` without it. |
1109
1028
  | `fallbackSrc` | `string` | | A static path for when there is no uploaded image. With neither, nothing renders. |
@@ -1133,10 +1052,10 @@ export default function Error({ reset }: { error: Error; reset: () => void }) {
1133
1052
  | --- | --- | --- |
1134
1053
  | `reset` | `() => void` | The `reset` Next passes to `error.tsx`. The try again button calls it. |
1135
1054
  | `homeHref` | `string` | Where the front page link goes, such as `/` or `/en`. |
1136
- | `strings` | `TextOverrides<ErrorStateStrings>` | `title`, `body`, `tryAgain` and `home`. |
1055
+ | `strings` | `TextOverrides<ErrorStateStrings>` | `title`, `body`, `tryAgain`, and `home`. |
1137
1056
  | `className` | `string` | Classes for placing it, on the outer element. |
1138
1057
 
1139
- The error itself is never shown.
1058
+ It never shows the error itself.
1140
1059
 
1141
1060
  ### `NotFoundState`
1142
1061
 
@@ -1150,14 +1069,14 @@ the page's heading, and a link home. It works in a server component.
1150
1069
  | Prop | Type | Description |
1151
1070
  | --- | --- | --- |
1152
1071
  | `homeHref` | `string` | Where the home link goes. |
1153
- | `strings` | `TextOverrides<NotFoundStrings>` | `code`, `body` and `home`. |
1072
+ | `strings` | `TextOverrides<NotFoundStateStrings>` | `code`, `body`, and `home`. |
1154
1073
  | `className` | `string` | Classes for placing it, on the outer element. |
1155
1074
 
1156
1075
  ### `PasswordInput`
1157
1076
 
1158
- A labelled password field with a button that shows and hides the value. It also takes the other
1077
+ A labeled password field with a button that shows and hides the value. It also takes the other
1159
1078
  `<input>` attributes, apart from `type`, which the component switches itself. `className` goes on
1160
- the outer element, see below.
1079
+ the outer element, as the table below describes.
1161
1080
 
1162
1081
  ```tsx
1163
1082
  import { PasswordInput } from "@sjolystinnovation/app-kit/ui";
@@ -1188,11 +1107,11 @@ import { toast } from "@sjolystinnovation/app-kit/toast";
1188
1107
  ### `passwordSchema(strings?)`
1189
1108
 
1190
1109
  From `@sjolystinnovation/app-kit/validation`. A zod schema for a new password, with the rules the APIs
1191
- enforce through ASP.NET Identity: at least 8 characters, with a lowercase letter, an uppercase letter
1192
- and a digit. No symbol is needed. Identity counts only ASCII, so the letters are A to Z and the digits
1193
- 0 to 9, and "Å" does not count as uppercase. Identity sets no upper limit. An app whose API does adds
1194
- it with `.max()`. A failing password gets one issue per rule it breaks, each with its message from
1195
- `strings`. Missing messages fall back to `defaultPasswordStrings`, which are English.
1110
+ enforce through ASP.NET Identity: at least 8 characters, with a lowercase letter, an uppercase
1111
+ letter, and a digit. No symbol is needed. Identity counts only ASCII, so the letters are A to Z and
1112
+ the digits 0 to 9. "Å" does not count as uppercase. Identity sets no upper limit. If the app's API
1113
+ sets one, add it with `.max()`. A failing password gets one issue per rule it breaks, each with its
1114
+ message from `strings`. Missing messages fall back to `defaultPasswordStrings`, which are English.
1196
1115
 
1197
1116
  ```ts
1198
1117
  import { passwordSchema } from "@sjolystinnovation/app-kit/validation";
@@ -1209,7 +1128,7 @@ message of every rule a password breaks, in the same order, without zod.
1209
1128
  From `@sjolystinnovation/app-kit/validation`. A failed parse's issues by the top-level field they
1210
1129
  belong to, for showing beside each input. Each field gets its first issue's message, or every message
1211
1130
  in order with `all: true`. `message` replaces zod's text, such as with the app's own translations.
1212
- Issues that belong to no field are left out.
1131
+ It leaves out issues that belong to no field.
1213
1132
 
1214
1133
  ```ts
1215
1134
  import { zodFieldErrors } from "@sjolystinnovation/app-kit/validation";
@@ -1253,7 +1172,7 @@ it("sends a user without the role away", () => {
1253
1172
 
1254
1173
  ## Theming
1255
1174
 
1256
- The components draw every colour, corner radius and label size from theme variables with a light
1175
+ The components draw every color, corner radius, and label size from theme variables with a light
1257
1176
  default. Set any of them in the app's own `@theme` to restyle every component at once, or in a
1258
1177
  selector such as `.dark` for a scoped theme:
1259
1178
 
@@ -1349,18 +1268,18 @@ with it and has to set the ones it uses itself.
1349
1268
  ## Requirements
1350
1269
 
1351
1270
  - Next.js 16 and React 19, as peer dependencies the app installs itself.
1352
- - For the root entry, `auth`, `api`, `server`, `session/react`, `ui` and `admin`: next-auth 4.24
1353
- or later and axios 1.20 or later, as optional peer dependencies. A site without sign-in, which
1354
- uses only entries such as `seo`, `next-config`, `markdown`, `toast` or `validation`, leaves them
1355
- out.
1271
+ - For `auth`, `api`, `server`, `session/react`, and `admin`: next-auth 4.24 or later and axios 1.20
1272
+ or later, as optional peer dependencies. A site without sign-in, which uses only entries such as
1273
+ the root entry, `ui`, `seo`, `next-config`, `markdown`, `toast`, or `validation`, leaves them out.
1356
1274
  - For `@sjolystinnovation/app-kit/validation`: zod 4, as an optional peer dependency.
1357
1275
  - For `@sjolystinnovation/app-kit/testing`: vitest 5, as an optional peer dependency.
1358
- - For `@sjolystinnovation/app-kit/toast`: sonner 2, as an optional peer dependency. Install a single
1359
- copy, because two copies keep two separate toast stores.
1276
+ - For `@sjolystinnovation/app-kit/toast`, `admin`, and `branding`: sonner 2, as an optional peer
1277
+ dependency. Install a single copy, because two copies keep two separate toast stores.
1360
1278
  - `jsonwebtoken` comes with the package. `@sjolystinnovation/app-kit/auth` uses it on the server.
1361
- - For `@sjolystinnovation/app-kit/ui`, `@sjolystinnovation/app-kit/admin` and the components in
1362
- `session/react`: Tailwind CSS 4 and `@heroicons/react` 2. The focus ring and the primary button
1363
- use the theme's `primary` and `primary-foreground` colours, unless their own variables are set.
1279
+ - For the components in `ui`, `admin`, `legal`, `markdown`, `branding`, `payments`, and
1280
+ `session/react`: Tailwind CSS 4. `ui`, `admin`, `branding`, `payments`, and `session/react` also
1281
+ need `@heroicons/react` 2. The focus ring and the primary button use the theme's `primary` and
1282
+ `primary-foreground` colors, unless you set their own variables.
1364
1283
  - TypeScript with `"moduleResolution": "bundler"`. Relative imports here are extensionless, which
1365
1284
  `node16` and `nodenext` reject.
1366
1285