@sjolystinnovation/app-kit 0.20.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 (78) hide show
  1. package/README.md +315 -253
  2. package/package.json +13 -1
  3. package/src/admin/AdminShell.tsx +2 -1
  4. package/src/admin/AdminUsersPanel.tsx +9 -3
  5. package/src/admin/CompanySettingsForm.tsx +123 -0
  6. package/src/admin/LegalPagesEditor.tsx +132 -0
  7. package/src/admin/LocaleTabs.tsx +36 -0
  8. package/src/admin/StatHistoryChart.tsx +100 -0
  9. package/src/admin/adminUsersPanelStrings.ts +3 -3
  10. package/src/admin/companySettingsFormStrings.ts +50 -0
  11. package/src/admin/index.ts +14 -0
  12. package/src/admin/legalPagesEditorStrings.ts +32 -0
  13. package/src/admin/statHistoryChartStrings.ts +17 -0
  14. package/src/api/apiFieldErrors.ts +4 -4
  15. package/src/api/company.ts +47 -0
  16. package/src/api/createApiClient.ts +3 -2
  17. package/src/{errors.ts → api/formatApiError.ts} +12 -12
  18. package/src/api/index.ts +6 -1
  19. package/src/api/request.ts +2 -2
  20. package/src/api/revalidateTargets.ts +42 -0
  21. package/src/api/services.ts +14 -13
  22. package/src/auth/createAuthOptions.ts +7 -5
  23. package/src/branding/BrandingManager.tsx +10 -5
  24. package/src/branding/BrandingProvider.tsx +5 -3
  25. package/src/branding/index.ts +1 -2
  26. package/src/branding/server.ts +10 -9
  27. package/src/branding/types.ts +3 -3
  28. package/src/config.ts +10 -10
  29. package/src/forms/useFormDraft.ts +9 -7
  30. package/src/index.ts +1 -3
  31. package/src/legal/LegalPageView.tsx +62 -0
  32. package/src/legal/index.ts +1 -0
  33. package/src/markdown/Markdown.tsx +12 -10
  34. package/src/markdown/index.ts +1 -1
  35. package/src/next-auth.d.ts +11 -9
  36. package/src/next-config/headers.ts +96 -0
  37. package/src/next-config/index.ts +18 -107
  38. package/src/payments/PaymentReturn.tsx +9 -7
  39. package/src/payments/formatNok.ts +1 -1
  40. package/src/payments/usePollUntilFinal.ts +7 -5
  41. package/src/seo/JsonLd.tsx +1 -1
  42. package/src/seo/environment.ts +3 -3
  43. package/src/seo/index.ts +2 -1
  44. package/src/seo/og/index.tsx +5 -4
  45. package/src/seo/schema.ts +6 -2
  46. package/src/seo/site.ts +90 -30
  47. package/src/seo/sitemap.ts +2 -2
  48. package/src/server/publicApi.ts +13 -9
  49. package/src/server/revalidateRoute.ts +10 -8
  50. package/src/session/expiry.ts +8 -5
  51. package/src/session/react/AppSessionProvider.tsx +3 -2
  52. package/src/{ui → session/react}/CredentialsForm.tsx +10 -9
  53. package/src/{ui → session/react}/LoginPage.tsx +7 -6
  54. package/src/session/react/ProtectedGate.tsx +3 -2
  55. package/src/session/react/RoleGate.tsx +4 -3
  56. package/src/session/react/SessionExpiryGuard.tsx +5 -4
  57. package/src/{ui → session/react}/credentialsFormStrings.ts +1 -1
  58. package/src/session/react/index.ts +8 -2
  59. package/src/{ui → session/react}/loginPageStrings.ts +1 -1
  60. package/src/session/react/sessionExpiryGuardStrings.ts +1 -1
  61. package/src/session/react/useSessionGate.ts +3 -3
  62. package/src/styles.css +2 -0
  63. package/src/testing/index.ts +113 -0
  64. package/src/ui/Alert.tsx +6 -4
  65. package/src/ui/ContentImage.tsx +12 -11
  66. package/src/ui/ErrorState.tsx +5 -3
  67. package/src/ui/NotFoundState.tsx +9 -7
  68. package/src/ui/PasswordInput.tsx +6 -5
  69. package/src/ui/ResetPasswordFlow.tsx +10 -8
  70. package/src/ui/TextArea.tsx +6 -4
  71. package/src/ui/TextInput.tsx +4 -2
  72. package/src/ui/Toggle.tsx +3 -3
  73. package/src/ui/field.tsx +5 -3
  74. package/src/ui/index.ts +2 -10
  75. package/src/ui/resetPasswordFlowStrings.ts +7 -7
  76. package/src/validation/fieldErrors.ts +4 -3
  77. package/src/validation/password.ts +5 -5
  78. 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";
@@ -243,29 +225,45 @@ export const getRecipe = (id: number) =>
243
225
  request(() => api.get<Recipe>(`/recipes/${id}`), "Could not load the recipe.");
244
226
  ```
245
227
 
246
- `requestRevalidate(target, endpoint?)` asks the app's revalidate route to purge the pages behind
247
- `target`, so an edit shows at once. It posts to `/api/revalidate` unless `endpoint` says otherwise.
248
- It never throws. On failure the pages' own revalidate window is the fallback.
228
+ `mergeCompanyInfo(data, fallback)` gives the company details for public pages: the API's
229
+ `/company` answer, with the app's own value for each field it left blank or could not give. Only
230
+ the fallback's name is required. The legal name falls back to the name, and VAT to not registered.
231
+ `formatOrgNumber({ orgNumber, vatRegistered })` adds ` MVA` once the business is VAT registered,
232
+ and gives an empty string without a number.
233
+
234
+ ```ts
235
+ // src/lib/companyInfo.ts
236
+ export const getCompanyInfo = async () => mergeCompanyInfo(await publicGetOptional<CompanyInfo>("/company"), COMPANY);
237
+ ```
249
238
 
250
239
  `apiFieldErrors(error)` reads the per-field messages of an ASP.NET validation problem from a failed
251
240
  call or its body, keyed in camelCase to match the form: `{ errors: { OrgNumber: ["..."] } }` becomes
252
- `{ 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.
253
243
 
254
244
  `createPublicClient(baseURL)` is the same without the session, for the API's anonymous endpoints. An
255
245
  empty `baseURL` throws, and requests only go to `baseURL`.
256
246
 
257
- The services below call the endpoints the SjolystInnovation.AppKit .NET packages map, so an app does
258
- 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.
259
250
  Failures throw an `Error` with the API's message, or with the service's own English fallback.
260
- `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 }`.
261
259
 
262
260
  | Factory | Methods | .NET package |
263
261
  | --- | --- | --- |
264
- | `createUserService(api, publicApi)` | `requestPasswordReset`, `resetPassword`, and for the signed-in user `getProfile`, `updateProfile`, `changePassword`, `exportData`, `deleteAccount` | Auth |
265
- | `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 |
266
264
  | `createSettingsService(api, { onSaved? })` | `get`, `update` | SiteSettings |
267
265
  | `createBrandingService(api, publicApi, { onSaved? })` | `get`, which answers `{}` when the API cannot be read, and `update(form)` | SiteSettings |
268
- | `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 |
269
267
 
270
268
  ```ts
271
269
  import { createApiClient, createPublicClient, createUserService } from "@sjolystinnovation/app-kit/api";
@@ -274,7 +272,8 @@ const apiUrl = requireEnv("NEXT_PUBLIC_API_URL", process.env.NEXT_PUBLIC_API_URL
274
272
  export default createUserService(createApiClient(apiUrl), createPublicClient(apiUrl));
275
273
  ```
276
274
 
277
- 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`.
278
277
 
279
278
  ### `@sjolystinnovation/app-kit/server`
280
279
 
@@ -282,15 +281,15 @@ For server components and route handlers only. It imports `next/cache` and `next
282
281
 
283
282
  `createPublicApi(baseUrl, options?)` returns cached GETs against the API's anonymous endpoints:
284
283
 
285
- - `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
286
285
  failing API throws `PublicApiError`, so a failed fetch is never cached as an empty page in place
287
286
  of a working one.
288
- - `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.
289
288
  - `publicGetWithMeta(path, { tags? })` also gives `lastModified`, from the `Last-Modified` header.
290
289
 
291
290
  Responses stay fresh for 60 seconds unless `options.revalidate` says otherwise. An empty `baseUrl`
292
291
  throws. Each path starts with a single `/`, or the call throws. A path with a `.` or `..` segment,
293
- 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
294
293
  through `encodeURIComponent` before putting it in a path.
295
294
 
296
295
  ```ts
@@ -305,11 +304,11 @@ export const { publicGet, publicGetOptional, publicGetWithMeta } = createPublicA
305
304
 
306
305
  `createRevalidateRoute({ targets, role })` returns a `POST` handler for `app/api/revalidate/route.ts`.
307
306
  Each target maps to the page paths it purges. A path with a dynamic segment, such as
308
- `/builds/[slug]`, purges every page of that route. The target name is purged as a cache tag too, so
309
- tag the fetches behind it with the same name. The handler answers 403 unless the caller has a live
310
- session with `role`: signed in within the last seven days, with no refused refresh and an access
311
- token that has not expired. It answers 415 unless the body is sent as JSON, which
312
- `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.
313
312
 
314
313
  ```ts
315
314
  // src/app/api/revalidate/route.ts
@@ -321,14 +320,35 @@ export const POST = createRevalidateRoute({
321
320
  });
322
321
  ```
323
322
 
323
+ `defineRevalidateTargets({ locales, targets })`, from `@sjolystinnovation/app-kit/api`, keeps the
324
+ targets in one file that both sides import. `pages` are below each locale segment, and `root` paths
325
+ are not. It returns `targets` for fetch tags, `paths` for the route, and `revalidate(target)` for
326
+ the admin services.
327
+
328
+ ```ts
329
+ // src/lib/cacheTags.ts
330
+ export const { targets: REVALIDATE_TARGETS, paths: TARGET_PATHS, revalidate: revalidateTarget } = defineRevalidateTargets({
331
+ locales: LOCALES,
332
+ targets: {
333
+ legal: { pages: ["/terms", "/privacy", "/cookies"], root: ["/sitemap.xml"] },
334
+ // Every page renders branding through the layout, so its tag reaches them all.
335
+ branding: {},
336
+ },
337
+ });
338
+
339
+ // src/app/api/revalidate/route.ts
340
+ export const POST = createRevalidateRoute({ targets: TARGET_PATHS, role: ADMIN_ROLE });
341
+ ```
342
+
324
343
  ### `@sjolystinnovation/app-kit/next-config`
325
344
 
326
345
  For `next.config.ts`. It imports nothing but Next's types, so Next loads it with no build step.
327
346
 
328
347
  `defineAppConfig(options, extra?)` gives the config every app shares: the standalone build, app-kit
329
348
  in `transpilePackages`, no `X-Powered-By`, `expireTime` of five minutes, image qualities 75 and 100,
330
- the incremental cache in memory, and the security headers on every page. `extra` is merged over it,
331
- 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.
332
352
 
333
353
  ```ts
334
354
  // next.config.ts
@@ -355,17 +375,14 @@ export default defineAppConfig({
355
375
  | `expireTime` | `number` | Seconds a cache may serve a stale page. Defaults to 300. Next's default is a year. |
356
376
  | `isrFlushToDisk` | `boolean` | Write revalidated pages to disk. Off by default, for a read-only root filesystem. |
357
377
 
358
- The pieces are exported too: `securityHeaders(options)`, `contentSecurityPolicy(options)`,
359
- `contentImagesRewrite(apiUrl, { source?, upstreamPath? })`, `apiOrigin(apiUrl)` and
360
- `apiBaseUrl(apiUrl)`. Scripts allow `'unsafe-inline'`, since a nonce would make every statically
361
- rendered page dynamic.
378
+ Scripts allow `'unsafe-inline'`, since a nonce would make every statically rendered page dynamic.
362
379
 
363
380
  ### `@sjolystinnovation/app-kit/markdown`
364
381
 
365
- Renders Markdown from an editor or the API, with GitHub tables, task lists and footnotes. Raw HTML is
366
- never rendered and the output is sanitized, so the text cannot inject scripts, styles, event
367
- handlers or `javascript:` links. It works in a server component. It needs `react-markdown`,
368
- `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:
369
386
 
370
387
  ```sh
371
388
  npm install react-markdown remark-gfm rehype-sanitize
@@ -381,20 +398,19 @@ import { Markdown } from "@sjolystinnovation/app-kit/markdown";
381
398
 
382
399
  | Prop | Type | Default | Description |
383
400
  | --- | --- | --- | --- |
384
- | `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. |
385
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. |
386
403
  | `externalLinks` | `boolean` | `false` | Opens `http` and `https` links in a new tab with `rel="noopener noreferrer"`. Site-relative links stay in the tab. |
387
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. |
388
405
 
389
- The text takes its colour and size from the parent. A level one heading becomes a level two, since
390
- the page has its own `h1`. `markdownComponents` holds the default elements, for an app that builds
391
- 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`.
392
408
 
393
409
  ### `@sjolystinnovation/app-kit/seo`
394
410
 
395
411
  Search engine helpers for one site: canonical URLs with hreflang, page metadata with Open Graph,
396
- sitemap entries, robots.txt and schema.org structured data. It works in a server component.
397
- `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:
398
414
 
399
415
  ```ts
400
416
  // src/lib/seo/site.ts
@@ -413,7 +429,7 @@ export const site = defineSite({
413
429
 
414
430
  | Option | Type | Description |
415
431
  | --- | --- | --- |
416
- | `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. |
417
433
  | `url` | `string` | The canonical origin, without a trailing slash. |
418
434
  | `titleSeparator` | `string` | Between a page title and the site name. |
419
435
  | `locales` | `string[]` | The locale segments every page is under. A site without them leaves it out, and its paths have no segment. |
@@ -422,20 +438,24 @@ export const site = defineSite({
422
438
  | `tagline` | `(locale) => string` | The line after the name in the default title and the share image's alt text. |
423
439
  | `shareImagePath` | `string` | The share image route below each locale. Defaults to `/og`. |
424
440
 
425
- The site gives:
441
+ The site provides:
426
442
 
427
443
  - `localePath(locale, path)`, `absoluteUrl(path)`, `absoluteLocaleUrl(locale, path)` and
428
444
  `languageAlternates(path)`. Paths get a leading slash and no trailing slash.
429
445
  - `siteMetadata({ locale, description, manifest })` for the locale root layout: the title
430
- template, `metadataBase`, the index directive and the front page's canonical.
446
+ template, `metadataBase`, the index directive, and the front page's canonical.
431
447
  - `pageMetadata({ locale, path, title, description, images })` for a page: the canonical URL,
432
448
  hreflang, and Open Graph with the locale's share image unless `images` is given.
433
449
  - `localeEntries(path, { locales, lastModified })` for `sitemap.ts`: one entry per locale, each
434
- listing the others. `lastModified` is left out when unknown.
435
- - `robots({ privatePaths, privateRootPaths, indexable })` for `robots.ts`. The private paths are
436
- disallowed under every locale segment. A deployment that is not production disallows everything.
437
- - `organizationNode(company, { type, country, image, logo })`, `webSiteNode(locale)`,
438
- `breadcrumbNode(crumbs, pageUrl)`, `itemListNode(paths, pageUrl)`, `siteId(fragment)` and `ids`
450
+ listing the others. It leaves out `lastModified` when that is unknown. A site without locales
451
+ gets one entry per page.
452
+ - `forEachLocale(load)` runs `load` for every locale at once and keys the results by locale.
453
+ - `legalEntries({ keys, updatedAt })` gives the legal pages' entries, `/terms`, `/privacy` and
454
+ `/cookies` unless `keys` says otherwise, each dated by the timestamp `updatedAt` returns for it.
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.
457
+ - `organizationNode(company, { type, sameAs, country, image, logo })`, `webSiteNode(locale)`,
458
+ `breadcrumbNode(crumbs, pageUrl)`, `itemListNode(paths, pageUrl)`, `siteId(fragment)`, and `ids`
439
459
  for structured data.
440
460
 
441
461
  ```tsx
@@ -443,20 +463,29 @@ The site gives:
443
463
  export const dynamic = "force-dynamic";
444
464
  export default () => site.robots({ privatePaths: ["/admin", "/login"], privateRootPaths: ["/api/"] });
445
465
 
466
+ // src/app/sitemap.ts
467
+ export const revalidate = 3600;
468
+ export default async () => [
469
+ ...site.localeEntries(""),
470
+ ...(await site.legalEntries({
471
+ updatedAt: async (key, locale) => (await publicGet<LegalPage>(`/content/legal/${key}?locale=${locale}`))?.updatedAt,
472
+ })),
473
+ ];
474
+
446
475
  // in a page
447
476
  <JsonLd nodes={[site.organizationNode(company), site.webSiteNode(locale)]} />
448
477
  ```
449
478
 
450
- `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
451
480
  production, so a missing variable never deindexes the live site. `robots.ts` needs
452
481
  `dynamic = "force-dynamic"`, so it reads the variable per request. The package also exports
453
- `JsonLd`, `serializeGraph`, `ref`, `NOINDEX`, `SHARE_IMAGE_SIZE`, `normalizePath`, `latest` and
482
+ `JsonLd`, `serializeGraph`, `ref`, `NOINDEX`, `LEGAL_PAGE_KEYS`, `SHARE_IMAGE_SIZE`, `latest`, and
454
483
  `parseTimestamp`.
455
484
 
456
485
  ### `@sjolystinnovation/app-kit/seo/og`
457
486
 
458
487
  `shareImage({ name, tagline, colors })` renders the default share preview, a PNG at
459
- `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,
460
489
  since the image renders outside the browser. Return it from the route `pageMetadata` points at:
461
490
 
462
491
  ```tsx
@@ -479,7 +508,7 @@ The logo and favicon an admin uploads, as the API's `/branding` endpoint returns
479
508
  `BrandingProvider` holds them for the components below it. It takes the branding the server
480
509
  rendered with, loads it on mount when there is none, and loads it again every five minutes, so an
481
510
  open tab picks up a change. A failed load keeps what it has. `useBranding()` gives `branding`,
482
- `logoUrl` as a `data:` URL or null, and `refresh()`.
511
+ `logoUrl` as a `data:` URL or `null`, and `refresh()`.
483
512
 
484
513
  ```tsx
485
514
  "use client";
@@ -503,16 +532,14 @@ above it, and refreshes it after a save.
503
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. |
504
533
  | `strings` | `TextOverrides<BrandingManagerStrings>` | The form's text. |
505
534
 
506
- `toDataUrl(data, contentType)` turns base64 bytes into a `data:` URL.
507
-
508
535
  ### `@sjolystinnovation/app-kit/branding/server`
509
536
 
510
537
  `brandingIcon({ load, fallbackPath, fallbackType })` is the response for an `app/icon.ts` route:
511
- 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
512
539
  otherwise, since an SVG or HTML upload could run script on the site's origin. Both carry `nosniff`
513
- and a sandbox CSP, which a CSP header from the app's `next.config` replaces. When the branding cannot be
514
- loaded it answers 503 with `no-store`, since a browser would cache the bundled icon in place of the
515
- 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.
516
543
 
517
544
  ```ts
518
545
  // src/app/icon.ts
@@ -546,7 +573,7 @@ most, and stops on a failed fetch and on unmount. It gives `data`, `loading` and
546
573
  each fetch uses the latest `fetcher` and `isFinal`.
547
574
 
548
575
  `PaymentReturn` shows the outcome: confirmed, still pending, or failed. It takes the text and the way
549
- 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.
550
577
 
551
578
  ```tsx
552
579
  "use client";
@@ -570,7 +597,7 @@ if (loading) return <Spinner />;
570
597
  | Prop | Type | Description |
571
598
  | --- | --- | --- |
572
599
  | `state` | `"success" \| "pending" \| "failed"` | Which content to show. |
573
- | `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. |
574
601
  | `onRefresh` | `() => void` | Shows a refresh button while pending. |
575
602
  | `refreshLabel` | `string` | Its text. Defaults to `Check again`. |
576
603
  | `className` | `string` | Classes for placing it, on the outer element. |
@@ -579,6 +606,16 @@ if (loading) return <Spinner />;
579
606
 
580
607
  ### `@sjolystinnovation/app-kit/admin`
581
608
 
609
+ `CompanySettingsForm` is the admin form for the business details the public pages show, saved
610
+ through `createSettingsService`. Its children show below the form once the settings have loaded,
611
+ such as the `BrandingManager`. `strings` holds every label, hint, and message, English by default.
612
+
613
+ ```tsx
614
+ <CompanySettingsForm service={SettingsService} strings={dict.companySettings}>
615
+ <BrandingManager save={BrandingService.update} />
616
+ </CompanySettingsForm>
617
+ ```
618
+
582
619
  The admin area. `AdminShell` is the frame: a title and a tab bar above the page, behind a
583
620
  `RoleGate`. It takes `RoleGate`'s `role`, `fallback`, and `denied` or `deniedHref`. The tab whose
584
621
  `href` is the current path is active, with `aria-current="page"`.
@@ -602,11 +639,28 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
602
639
  }
603
640
  ```
604
641
 
642
+ `StatHistoryChart` draws the admin stats over time: a line per series, a scale from zero to the
643
+ highest value shown, and legend buttons that hide and show each line. `data` is one row per day,
644
+ oldest first, with `date` as `YYYY-MM-DD`. A missing value counts as zero. Each series names the
645
+ field it plots and its label, and takes the next color in `STAT_SERIES_COLORS` unless it has its
646
+ own. `strings` holds `empty`, shown when there are no days, and `chart`, the accessible name.
647
+
648
+ ```tsx
649
+ <StatHistoryChart
650
+ data={history}
651
+ series={[
652
+ { key: "totalUsers", label: dict.stats.users },
653
+ { key: "contentImages", label: dict.stats.images },
654
+ ]}
655
+ strings={{ empty: dict.admin.noHistory, chart: dict.admin.overTime }}
656
+ />
657
+ ```
658
+
605
659
  `AdminUsersPanel` is the users page. It lists every user with their roles, shows deleted users
606
660
  behind a toggle, gives and takes roles, deletes an account after a confirm, and invites a user by
607
661
  email. It calls the API through `createAdminUsersService`. `getRoles` returns the roles an admin
608
- may give. Inline functions are fine. The panel does not fetch again when they change. It reports
609
- 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`.
610
664
 
611
665
  ```tsx
612
666
  import { AdminUsersPanel } from "@sjolystinnovation/app-kit/admin";
@@ -619,65 +673,52 @@ import { AdminUsersPanel } from "@sjolystinnovation/app-kit/admin";
619
673
  | `service` | `ReturnType<typeof createAdminUsersService>` | The user admin calls. |
620
674
  | `getRoles` | `() => Promise<string[]>` | The roles an admin may give. |
621
675
  | `defaultInviteRole` | `string` | The role a new invite starts with. Defaults to `Admin`. |
622
- | `strings` | `TextOverrides<AdminUsersPanelStrings>` | The text, English by default. `{name}` and `{role}` are filled in where a text names them. |
623
-
624
- ### `@sjolystinnovation/app-kit/session`
676
+ | `strings` | `TextOverrides<AdminUsersPanelStrings>` | The text, English by default. The panel fills in `{name}` and `{role}` where a text names them. |
625
677
 
626
- Decides when a user has to sign in again. Safe to import from server code, such as the next-auth
627
- callbacks.
678
+ `LegalPagesEditor` edits the legal pages one page and one locale at a time, through
679
+ `createLegalService`. A locale tab whose page has no content yet gets a dot. `LocaleTabs` is the
680
+ same tab bar on its own, for other translated forms.
628
681
 
629
- The gate reads `session.error`. `SessionExpiryGuard` also reads `session.absoluteExpiresAt` for its
630
- countdown, `session.user.id` to keep the page with the user who opened it, and `session.user.email`
631
- to fill in the prompt. `createAuthOptions` sets all of these. An app with its own next-auth options sets them in its
632
- callbacks:
633
-
634
- ```ts
635
- const SESSION_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000;
636
-
637
- callbacks: {
638
- jwt({ token, user }) {
639
- if (user) token.loginAt = Date.now();
640
- // A token without a sign-in time counts as expired.
641
- if (!token.loginAt || Date.now() - token.loginAt > SESSION_LIFETIME_MS) {
642
- token.error = SESSION_ERRORS.absoluteExpiry;
643
- }
644
- // Also set token.error when a refresh is refused.
645
- return token;
646
- },
647
- session({ session, token }) {
648
- session.user.id = token.sub ?? "";
649
- session.error = token.error;
650
- session.absoluteExpiresAt = token.loginAt ? token.loginAt + SESSION_LIFETIME_MS : undefined;
651
- return session;
652
- },
653
- },
682
+ ```tsx
683
+ <LegalPagesEditor
684
+ service={LegalService}
685
+ keys={LEGAL_PAGE_KEYS}
686
+ keyLabels={{ terms: dict.footer.terms, privacy: dict.footer.privacy, cookies: dict.footer.cookies }}
687
+ locales={LOCALES}
688
+ localeLabels={LOCALE_LABELS}
689
+ strings={dict.legalEditor}
690
+ />
654
691
  ```
655
692
 
656
- For that to compile, these fields have to be on next-auth's `Session` and `JWT` types. Add them to
657
- the app's existing next-auth type declarations, or create them:
693
+ ### `@sjolystinnovation/app-kit/legal`
658
694
 
659
- ```ts
660
- // src/types/next-auth.d.ts
661
- import "next-auth";
662
- import "next-auth/jwt";
663
- import type { DefaultSession } from "next-auth";
664
-
665
- declare module "next-auth" {
666
- interface Session {
667
- user: { id: string } & DefaultSession["user"];
668
- error?: string | undefined;
669
- absoluteExpiresAt?: number | undefined;
670
- }
671
- }
695
+ `LegalPageView` renders a terms, privacy or cookies page as an admin wrote it: the title, the date
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.
699
+
700
+ ```tsx
701
+ // src/app/[locale]/terms/page.tsx
702
+ export const revalidate = 3600;
672
703
 
673
- declare module "next-auth/jwt" {
674
- interface JWT {
675
- error?: string | undefined;
676
- loginAt?: number | undefined;
677
- }
704
+ export default async function TermsPage({ params }: { params: Promise<{ locale: Locale }> }) {
705
+ const { locale } = await params;
706
+ const page = await publicGet<LegalPage>(`/content/legal/terms?locale=${locale}`, { tags: [REVALIDATE_TARGETS.legal] });
707
+ return <LegalPageView page={page} title={dict.footer.terms} homeHref={`/${locale}`} dateLocale="nb-NO" strings={dict.legal} />;
678
708
  }
679
709
  ```
680
710
 
711
+ ### `@sjolystinnovation/app-kit/session`
712
+
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.
715
+
716
+ The gate reads `session.error`. `SessionExpiryGuard` also reads `session.absoluteExpiresAt` for its
717
+ countdown, `session.user.id` to keep the page with the user who opened it, and `session.user.email`
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.
721
+
681
722
  `SIGN_IN_ERRORS` holds the two codes a failed sign-in reports: `InvalidCredentials` and
682
723
  `SignInUnavailable`. Map them to the app's own messages.
683
724
 
@@ -697,37 +738,8 @@ The re-sign-in prompt is a flag outside React, so an axios interceptor can raise
697
738
 
698
739
  Copies of the package installed side by side share the one flag, as long as their releases use the
699
740
  same prompt state format. A release that changes the format says so in its changelog. An app's own
700
- copy of these functions does not share the flag. So the interceptor, the prompt and every gate all
701
- import them from this package.
702
-
703
- ### `useSessionGate()`
704
-
705
- From `@sjolystinnovation/app-kit/session/react`. Tells a protected page whether it may render. It
706
- reads `useSession`, so it needs a `SessionProvider` above it.
707
-
708
- A page that rendered on a healthy session stays rendered, so an open form keeps its values:
709
-
710
- - when the session gets a terminal error
711
- - while the session is re-read
712
- - while the prompt recovers it
713
-
714
- A session that turns signed out with no prompt open blanks the page. A page the user opens on a
715
- dead session does not render. Use `mayRender` in every gate and layout on the way down, so none of
716
- them blanks a page that another one kept.
717
-
718
- The hook does not navigate. `ProtectedGate` below adds the redirect. Use the hook directly in a
719
- layout that needs its own decision, such as a role check, so it agrees with the gate.
720
-
721
- | Field | Description |
722
- | --- | --- |
723
- | `mayRender` | Whether the page may render. |
724
- | `session`, `status` | As `useSession` returns them. |
725
- | `usable` | The session is signed in and has no terminal error. |
726
- | `wasUsable` | This page has rendered on a usable session. |
727
- | `promptOpen` | The re-sign-in prompt is showing. |
728
- | `recovering` | The prompt is showing over a page that was usable. |
729
-
730
- `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.
731
743
 
732
744
  ### `AppSessionProvider`
733
745
 
@@ -746,9 +758,19 @@ reports in this tab up to date. next-auth polls only once a session exists.
746
758
 
747
759
  ### `ProtectedGate`
748
760
 
749
- From `@sjolystinnovation/app-kit/session/react`. Wraps the protected part of the app. It renders the
750
- page only when `useSessionGate` allows it, and sends a signed-out visitor or a session dead on
751
- 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.
752
774
 
753
775
  ```tsx
754
776
  <ProtectedGate loginHref="/login" fallback={<p>Loading…</p>}>
@@ -764,7 +786,7 @@ arrival to `loginHref`. It never redirects while the re-sign-in prompt is open.
764
786
  ### `RoleGate`
765
787
 
766
788
  From `@sjolystinnovation/app-kit/session/react`. Renders its children only for a user with `role`.
767
- 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
768
790
  no user and so no roles. The page stays up only on the path where the session last had the role.
769
791
 
770
792
  ```tsx
@@ -790,8 +812,9 @@ shows:
790
812
  - a banner when the session is dead
791
813
  - the re-sign-in prompt, a dialog with `CredentialsForm`, when a request or the banner opens it
792
814
 
793
- A sign-in in the prompt keeps the page and its form. A sign-in in the prompt as a different user is
794
- 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.
795
818
 
796
819
  ```tsx
797
820
  <SessionExpiryGuard
@@ -802,7 +825,7 @@ signed out and sent to `loginHref`, so the prompt never hands the page to anothe
802
825
 
803
826
  | Prop | Type | Description |
804
827
  | --- | --- | --- |
805
- | `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. |
806
829
  | `onRestored` | `() => void` | Runs after the prompt signs the user back in, such as to show a toast. |
807
830
  | `strings` | `TextOverrides<SessionExpiryGuardStrings>` | The guard's text. `{minutes}` in `expiringSoon` becomes the minutes left. The defaults are English. |
808
831
  | `formStrings` | `TextOverrides<CredentialsFormStrings>` | The text of the form in the prompt. |
@@ -810,8 +833,8 @@ signed out and sent to `loginHref`, so the prompt never hands the page to anothe
810
833
  ### `useFormDraft(config, options)`
811
834
 
812
835
  From `@sjolystinnovation/app-kit/forms`. Keeps a form's values in `localStorage`, so a sign-out,
813
- reload or closed tab does not lose them. Values are written half a second after the last change, so
814
- 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.
815
838
 
816
839
  ```tsx
817
840
  // userId is the signed-in user's id, from wherever the app's session keeps it.
@@ -824,29 +847,29 @@ const draft = useFormDraft(sessionConfig, {
824
847
 
825
848
  | Option | Type | Description |
826
849
  | --- | --- | --- |
827
- | `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. |
828
851
  | `scope` | `string` | The form and the entity it edits, such as `recipe:42`. |
829
852
  | `value` | `T` | The form's current values. They must survive `JSON.stringify`. |
830
853
 
831
854
  Mount an edit form once the entity has loaded, with its values already in `value`. The hook treats
832
855
  the values it first sees under a key as the untouched form, so values that arrive later count as an
833
- edit and are written over the draft being offered.
856
+ edit and overwrite the draft on offer.
834
857
 
835
858
  It returns:
836
859
 
837
- - `pending`, the stored draft. The page offers it to the user and does not apply it. It is read
838
- when the owner is first known and again whenever the owner or `scope` changes, so it can appear
839
- 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.
840
863
  - `dismiss()`, to stop offering it.
841
864
  - `clear()`, to drop the stored draft after a successful save.
842
865
 
843
- Drafts are stored under `<draftStoragePrefix>:draft:<owner>:<scope>`. The hook throws when
844
- `draftStoragePrefix` is empty or contains a colon. A draft older than 7 days is not offered, and is
845
- 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.
846
869
 
847
870
  ### `CredentialsForm`
848
871
 
849
- 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
850
873
  provider, with `redirect: false`, so the page stays where it is. Use it on the login page and in
851
874
  the prompt.
852
875
 
@@ -856,7 +879,7 @@ the prompt.
856
879
 
857
880
  | Prop | Type | Description |
858
881
  | --- | --- | --- |
859
- | `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. |
860
883
  | `initialEmail` | `string` | The email the field starts with. |
861
884
  | `children` | `ReactNode` | Extra buttons next to the submit button. |
862
885
  | `strings` | `TextOverrides<CredentialsFormStrings>` | The form's text. The defaults are English. |
@@ -868,9 +891,9 @@ or one that throws, shows `somethingWentWrong`.
868
891
 
869
892
  ### `LoginPage`
870
893
 
871
- From `@sjolystinnovation/app-kit/ui`. The sign-in page: a title, `CredentialsForm`, a link to reset
872
- the password and, when the app has sign-up, a link to it. A sign-in goes to `redirectTo` and refreshes
873
- 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`.
874
897
 
875
898
  ```tsx
876
899
  <LoginPage redirectTo="/admin" resetHref="/reset-password" strings={{ title: "Logg inn" }} formStrings={{ signIn: "Logg inn" }} />
@@ -881,17 +904,17 @@ the page's server data. It uses the same `auth` theme variables as `ResetPasswor
881
904
  | `redirectTo` | `string` | Where a signed-in user goes. |
882
905
  | `resetHref` | `string` | The password reset page. |
883
906
  | `registerHref` | `string` | The sign-up page. Without it there is no register link. |
884
- | `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. |
885
908
  | `formStrings` | `TextOverrides<CredentialsFormStrings>` | The form's text. |
886
909
  | `className` | `string` | Replaces the outer `max-w-md mx-auto px-4 py-16`. |
887
910
 
888
911
  ### `ResetPasswordFlow`
889
912
 
890
913
  From `@sjolystinnovation/app-kit/ui`. The password reset page in two steps: the email asks for a
891
- code, then the code and a new password set it. The password is checked against the same rules as
892
- `passwordSchema` before it is sent, without loading zod. An emailed link with the email and code,
893
- such as an invite, opens it on the second step. Its colours and title size come from the `auth` and
894
- `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).
895
918
 
896
919
  ```tsx
897
920
  const params = useSearchParams();
@@ -909,11 +932,11 @@ const params = useSearchParams();
909
932
  | Prop | Type | Description |
910
933
  | --- | --- | --- |
911
934
  | `requestReset` | `(data: { email }) => Promise<unknown>` | Asks the API to email a code. |
912
- | `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. |
913
936
  | `loginHref` | `string` | Where the sign-in links go. |
914
937
  | `initialEmail`, `initialCode` | `string` | From the emailed link. A code opens the second step. |
915
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. |
916
- | `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. |
917
940
  | `passwordStrings` | `TextOverrides<PasswordStrings>` | The rule messages, in the app's language. |
918
941
 
919
942
  A failure shows the error's message, or `somethingWentWrong` when it has none.
@@ -923,7 +946,7 @@ key is optional and may be `undefined`, and a missing or undefined key shows the
923
946
 
924
947
  ### `TextInput`
925
948
 
926
- 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
927
950
  `PasswordInput`, apart from the show and hide toggle. It takes the other `<input>` attributes too.
928
951
 
929
952
  ```tsx
@@ -932,7 +955,7 @@ From `@sjolystinnovation/app-kit/ui`. A labelled text field with the same look a
932
955
 
933
956
  ### `TextArea`
934
957
 
935
- 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
936
959
  error handling as `TextInput`. It takes the other `<textarea>` attributes too, and works controlled
937
960
  or uncontrolled.
938
961
 
@@ -965,7 +988,7 @@ It takes the other `<button>` attributes too.
965
988
  | `onChange` | `(checked: boolean) => void` | Called with the new state. A callback that ignores it also fits. |
966
989
  | `label` | `string` | Visible text after the switch. It names the switch, and a click on it flips the switch. |
967
990
  | `aria-label` | `string` | The switch's name when there is no `label`. One of the two is required. |
968
- | `disabled` | `boolean` | Greys the switch out and ignores clicks. |
991
+ | `disabled` | `boolean` | Grays the switch out and ignores clicks. |
969
992
  | `className` | `string` | Classes for placing it, on the outer element. |
970
993
 
971
994
  An `onClick` that calls `event.preventDefault()` stops the change.
@@ -980,7 +1003,7 @@ import { Alert } from "@sjolystinnovation/app-kit/ui";
980
1003
 
981
1004
  | Prop | Type | Default | Description |
982
1005
  | --- | --- | --- | --- |
983
- | `variant` | `"error" \| "success" \| "info" \| "warning"` | `"info"` | Colour of the box. |
1006
+ | `variant` | `"error" \| "success" \| "info" \| "warning"` | `"info"` | Color of the box. |
984
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. |
985
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. |
986
1009
  | `children` | `ReactNode` | | Content. |
@@ -999,7 +1022,7 @@ a server component.
999
1022
 
1000
1023
  | Prop | Type | Default | Description |
1001
1024
  | --- | --- | --- | --- |
1002
- | `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`. |
1003
1026
  | `alt` | `string` | | Empty for a decorative image. |
1004
1027
  | `sizes` | `string` | | Widths the image is shown at. Required, since a browser assumes `100vw` without it. |
1005
1028
  | `fallbackSrc` | `string` | | A static path for when there is no uploaded image. With neither, nothing renders. |
@@ -1029,10 +1052,10 @@ export default function Error({ reset }: { error: Error; reset: () => void }) {
1029
1052
  | --- | --- | --- |
1030
1053
  | `reset` | `() => void` | The `reset` Next passes to `error.tsx`. The try again button calls it. |
1031
1054
  | `homeHref` | `string` | Where the front page link goes, such as `/` or `/en`. |
1032
- | `strings` | `TextOverrides<ErrorStateStrings>` | `title`, `body`, `tryAgain` and `home`. |
1055
+ | `strings` | `TextOverrides<ErrorStateStrings>` | `title`, `body`, `tryAgain`, and `home`. |
1033
1056
  | `className` | `string` | Classes for placing it, on the outer element. |
1034
1057
 
1035
- The error itself is never shown.
1058
+ It never shows the error itself.
1036
1059
 
1037
1060
  ### `NotFoundState`
1038
1061
 
@@ -1046,14 +1069,14 @@ the page's heading, and a link home. It works in a server component.
1046
1069
  | Prop | Type | Description |
1047
1070
  | --- | --- | --- |
1048
1071
  | `homeHref` | `string` | Where the home link goes. |
1049
- | `strings` | `TextOverrides<NotFoundStrings>` | `code`, `body` and `home`. |
1072
+ | `strings` | `TextOverrides<NotFoundStateStrings>` | `code`, `body`, and `home`. |
1050
1073
  | `className` | `string` | Classes for placing it, on the outer element. |
1051
1074
 
1052
1075
  ### `PasswordInput`
1053
1076
 
1054
- 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
1055
1078
  `<input>` attributes, apart from `type`, which the component switches itself. `className` goes on
1056
- the outer element, see below.
1079
+ the outer element, as the table below describes.
1057
1080
 
1058
1081
  ```tsx
1059
1082
  import { PasswordInput } from "@sjolystinnovation/app-kit/ui";
@@ -1084,11 +1107,11 @@ import { toast } from "@sjolystinnovation/app-kit/toast";
1084
1107
  ### `passwordSchema(strings?)`
1085
1108
 
1086
1109
  From `@sjolystinnovation/app-kit/validation`. A zod schema for a new password, with the rules the APIs
1087
- enforce through ASP.NET Identity: at least 8 characters, with a lowercase letter, an uppercase letter
1088
- and a digit. No symbol is needed. Identity counts only ASCII, so the letters are A to Z and the digits
1089
- 0 to 9, and "Å" does not count as uppercase. Identity sets no upper limit. An app whose API does adds
1090
- it with `.max()`. A failing password gets one issue per rule it breaks, each with its message from
1091
- `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.
1092
1115
 
1093
1116
  ```ts
1094
1117
  import { passwordSchema } from "@sjolystinnovation/app-kit/validation";
@@ -1105,7 +1128,7 @@ message of every rule a password breaks, in the same order, without zod.
1105
1128
  From `@sjolystinnovation/app-kit/validation`. A failed parse's issues by the top-level field they
1106
1129
  belong to, for showing beside each input. Each field gets its first issue's message, or every message
1107
1130
  in order with `all: true`. `message` replaces zod's text, such as with the app's own translations.
1108
- Issues that belong to no field are left out.
1131
+ It leaves out issues that belong to no field.
1109
1132
 
1110
1133
  ```ts
1111
1134
  import { zodFieldErrors } from "@sjolystinnovation/app-kit/validation";
@@ -1114,9 +1137,42 @@ const parsed = contactSchema.safeParse(form);
1114
1137
  if (!parsed.success) setErrors(zodFieldErrors(parsed.error, { message: (issue) => issueMessage(issue, dict) }));
1115
1138
  ```
1116
1139
 
1140
+ ### `@sjolystinnovation/app-kit/testing`
1141
+
1142
+ Test doubles for the modules every app's tests mock: `next-auth/react`, `next/navigation`, `sonner`
1143
+ or `@sjolystinnovation/app-kit/toast`, and the app's axios instance. vitest hoists `vi.mock` above
1144
+ the file's imports, so the factory imports the helpers itself. The test imports the same module to
1145
+ set the state and read the spies. The state lasts for the whole file, so call `resetTestMocks`
1146
+ before each test. It needs vitest 5, as an optional peer dependency.
1147
+
1148
+ ```tsx
1149
+ import { makeSession, navigation, resetTestMocks, setSession } from "@sjolystinnovation/app-kit/testing";
1150
+
1151
+ vi.mock("next-auth/react", async () => (await import("@sjolystinnovation/app-kit/testing")).nextAuthMock());
1152
+ vi.mock("next/navigation", async () => (await import("@sjolystinnovation/app-kit/testing")).nextNavigationMock());
1153
+
1154
+ beforeEach(resetTestMocks);
1155
+
1156
+ it("sends a user without the role away", () => {
1157
+ navigation.pathname = "/no/admin";
1158
+ setSession(makeSession({ roles: [] }));
1159
+ render(<AdminLayout><p>admin work</p></AdminLayout>);
1160
+ expect(navigation.router.replace).toHaveBeenCalledWith("/no");
1161
+ });
1162
+ ```
1163
+
1164
+ - `setSession(session | null | "loading")` and `makeSession({ roles, ...overrides })` set what
1165
+ `useSession` returns. `auth.signIn`, `auth.signOut` and `auth.getSession` are the spies behind
1166
+ next-auth's calls.
1167
+ - `navigation.pathname`, `navigation.searchParams` and `navigation.router` feed `usePathname`,
1168
+ `useSearchParams` and `useRouter`.
1169
+ - `toastMock()` records every toast in `toastSpies`.
1170
+ - `axiosInstanceMock()` is a module whose default export is `axiosSpies`, for
1171
+ `vi.mock("@/services/axiosInstance", ...)`.
1172
+
1117
1173
  ## Theming
1118
1174
 
1119
- 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
1120
1176
  default. Set any of them in the app's own `@theme` to restyle every component at once, or in a
1121
1177
  selector such as `.dark` for a scoped theme:
1122
1178
 
@@ -1173,6 +1229,8 @@ selector such as `.dark` for a scoped theme:
1173
1229
  | `--color-admin-tab`, `-hover` | gray 500 and gray 900 |
1174
1230
  | `--color-admin-tab-active` | gray 900 |
1175
1231
  | `--color-admin-tab-indicator` | `var(--color-primary)`, the line under the active tab |
1232
+ | `--color-chart-grid` | gray 200, the scale lines of `StatHistoryChart` |
1233
+ | `--color-chart-label` | gray 400, the chart's numbers and dates |
1176
1234
  | `--radius-button` | `var(--radius-lg)` |
1177
1235
  | `--color-button-primary-bg` | `var(--color-primary)` |
1178
1236
  | `--color-button-primary-text` | `var(--color-primary-foreground)` |
@@ -1209,15 +1267,19 @@ with it and has to set the ones it uses itself.
1209
1267
 
1210
1268
  ## Requirements
1211
1269
 
1212
- - Next.js 16, React 19, next-auth 4.24 or later and axios 1.20 or later, as peer dependencies the
1213
- app installs itself.
1270
+ - Next.js 16 and React 19, as peer dependencies the app installs itself.
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.
1214
1274
  - For `@sjolystinnovation/app-kit/validation`: zod 4, as an optional peer dependency.
1215
- - For `@sjolystinnovation/app-kit/toast`: sonner 2, as an optional peer dependency. Install a single
1216
- copy, because two copies keep two separate toast stores.
1275
+ - For `@sjolystinnovation/app-kit/testing`: vitest 5, as an optional peer dependency.
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.
1217
1278
  - `jsonwebtoken` comes with the package. `@sjolystinnovation/app-kit/auth` uses it on the server.
1218
- - For `@sjolystinnovation/app-kit/ui`, `@sjolystinnovation/app-kit/admin` and the components in
1219
- `session/react`: Tailwind CSS 4 and `@heroicons/react` 2. The focus ring and the primary button
1220
- 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.
1221
1283
  - TypeScript with `"moduleResolution": "bundler"`. Relative imports here are extensionless, which
1222
1284
  `node16` and `nodenext` reject.
1223
1285