@sjolystinnovation/app-kit 0.22.0 → 1.0.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 (52) hide show
  1. package/README.md +1281 -773
  2. package/package.json +21 -4
  3. package/src/admin/AdminUsersPanel.tsx +85 -35
  4. package/src/admin/CompanySettingsForm.tsx +33 -13
  5. package/src/admin/EmailStatsPanel.tsx +76 -0
  6. package/src/admin/LegalPagesEditor.tsx +41 -14
  7. package/src/admin/adminUsersPanelStrings.ts +67 -0
  8. package/src/admin/companySettingsFormStrings.ts +26 -0
  9. package/src/admin/emailStatsPanelStrings.ts +29 -0
  10. package/src/admin/index.ts +14 -5
  11. package/src/admin/legalPagesEditorStrings.ts +25 -0
  12. package/src/api/apiErrorCode.ts +28 -0
  13. package/src/api/apiFieldErrors.ts +82 -11
  14. package/src/api/failureBody.ts +10 -0
  15. package/src/api/fieldErrorText.ts +32 -0
  16. package/src/api/index.ts +5 -1
  17. package/src/api/legalPage.ts +10 -0
  18. package/src/api/request.ts +13 -3
  19. package/src/api/services.ts +42 -12
  20. package/src/auth/createAuthOptions.ts +9 -3
  21. package/src/branding/BrandingManager.tsx +31 -36
  22. package/src/branding/brandingManagerStrings.ts +49 -0
  23. package/src/branding/index.ts +3 -3
  24. package/src/legal/LegalPageView.tsx +2 -17
  25. package/src/legal/index.ts +4 -1
  26. package/src/legal/keys.ts +10 -0
  27. package/src/legal/legalPageViewStrings.ts +17 -0
  28. package/src/locales/nb.ts +330 -0
  29. package/src/sections/SectionRenderer.tsx +275 -0
  30. package/src/sections/SectionsEditor.tsx +151 -0
  31. package/src/sections/SmartLink.tsx +42 -0
  32. package/src/sections/editors.tsx +311 -0
  33. package/src/sections/factory.ts +26 -0
  34. package/src/sections/index.ts +69 -0
  35. package/src/sections/sectionsEditorStrings.ts +108 -0
  36. package/src/sections/types.ts +107 -0
  37. package/src/seo/index.ts +1 -0
  38. package/src/seo/site.ts +5 -5
  39. package/src/session/expiry.ts +3 -1
  40. package/src/session/index.ts +0 -1
  41. package/src/session/react/CredentialsForm.tsx +8 -2
  42. package/src/session/react/credentialsFormStrings.ts +4 -1
  43. package/src/session/react/index.ts +1 -1
  44. package/src/styles.css +2 -0
  45. package/src/testing/index.ts +1 -9
  46. package/src/ui/ErrorState.tsx +1 -15
  47. package/src/ui/NotFoundState.tsx +1 -13
  48. package/src/ui/ResetPasswordFlow.tsx +55 -12
  49. package/src/ui/errorStateStrings.ts +16 -0
  50. package/src/ui/index.ts +10 -6
  51. package/src/ui/notFoundStateStrings.ts +14 -0
  52. package/src/ui/resetPasswordFlowStrings.ts +44 -0
package/README.md CHANGED
@@ -1,35 +1,88 @@
1
1
  # @sjolystinnovation/app-kit
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/@sjolystinnovation/app-kit.svg)](https://www.npmjs.com/package/@sjolystinnovation/app-kit)
4
- [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](./LICENSE)
4
+ [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](https://github.com/sondresjolyst/si-web-packages/blob/main/packages/app-kit/LICENSE)
5
+
6
+ Building blocks for Next.js apps that sign in against a JWT API through next-auth.
7
+
8
+ The package is for Next.js App Router apps that share one ASP.NET Core API, such as one built on the
9
+ SjolystInnovation.AppKit .NET packages. It holds the code those apps have in common: next-auth
10
+ sign-in and token refresh, an axios client, error messages a user can read, session gates and the
11
+ re-sign-in prompt, form drafts, admin forms, page sections, SEO helpers, and the Next config with
12
+ security headers. Each part is a separate entry point, so an app imports only what it uses.
13
+
14
+ ## Contents
15
+
16
+ - [Install](#install)
17
+ - [Entry points](#entry-points)
18
+ - [Root entry point](#root-entry-point)
19
+ - [`next-config`](#sjolystinnovationapp-kitnext-config)
20
+ - [`auth`](#sjolystinnovationapp-kitauth) and [`next-auth`](#sjolystinnovationapp-kitnext-auth)
21
+ - [`session`](#sjolystinnovationapp-kitsession) and [`session/react`](#sjolystinnovationapp-kitsessionreact)
22
+ - [`api`](#sjolystinnovationapp-kitapi) and [`server`](#sjolystinnovationapp-kitserver)
23
+ - [`forms`](#sjolystinnovationapp-kitforms), [`toast`](#sjolystinnovationapp-kittoast), and [`validation`](#sjolystinnovationapp-kitvalidation)
24
+ - [`ui`](#sjolystinnovationapp-kitui)
25
+ - [`admin`](#sjolystinnovationapp-kitadmin)
26
+ - [`branding`](#sjolystinnovationapp-kitbranding) and [`branding/server`](#sjolystinnovationapp-kitbrandingserver)
27
+ - [`markdown`](#sjolystinnovationapp-kitmarkdown), [`legal`](#sjolystinnovationapp-kitlegal), and [`sections`](#sjolystinnovationapp-kitsections)
28
+ - [`seo`](#sjolystinnovationapp-kitseo) and [`seo/og`](#sjolystinnovationapp-kitseoog)
29
+ - [`payments`](#sjolystinnovationapp-kitpayments)
30
+ - [`locales/nb`](#sjolystinnovationapp-kitlocalesnb)
31
+ - [`testing`](#sjolystinnovationapp-kittesting)
32
+ - [Theming](#theming)
33
+ - [Requirements](#requirements)
5
34
 
6
- > Building blocks for Next.js apps that sign in against a JWT API through next-auth
35
+ ## Install
36
+
37
+ ```sh
38
+ npm install @sjolystinnovation/app-kit
39
+ ```
7
40
 
8
- Several apps sharing one API also share the code around it:
41
+ Next.js 16 and React 19 are required peer dependencies. The other peers are optional. Install the
42
+ ones the entry points you import need:
9
43
 
10
- - the session plumbing
11
- - how a failed request becomes a message a user can read
12
- - the form controls on the sign-in page
44
+ | Peer dependency | Needed by |
45
+ | --- | --- |
46
+ | `@heroicons/react` 2 | `ui`, `admin`, `branding`, `sections`, `payments`, and `session/react` |
47
+ | `next-auth` 4.24 or later | `auth`, `api`, `server`, `session/react`, `admin`, and `testing` |
48
+ | `axios` 1.20 or later | `auth`, `api`, `admin`, and `branding` |
49
+ | `sonner` 2 | `toast`, `admin`, `branding`, and `sections` |
50
+ | `zod` 4 | `validation` |
51
+ | `react-markdown` 10, `remark-gfm` 4, and `rehype-sanitize` 6 | `markdown`, `legal`, and `sections` |
52
+ | `vitest` 5 | `testing` |
13
53
 
14
- This package holds that code once, and each app installs it.
54
+ Every other entry point, such as the root entry, `next-config`, `seo`, `forms`, `legal`, or
55
+ `locales/nb`, works without `@heroicons/react`. Install a single copy of `sonner`, because two
56
+ copies keep two separate toast stores. `jsonwebtoken` comes with the package.
15
57
 
16
- ## Install
58
+ For a site without sign-in, which uses only entries such as the root entry, `ui`, `seo`, or
59
+ `next-config`:
17
60
 
18
61
  ```sh
19
- npm install @sjolystinnovation/app-kit
62
+ npm install @sjolystinnovation/app-kit next react react-dom @heroicons/react
20
63
  ```
21
64
 
22
- The package ships TypeScript source, so add it to `transpilePackages`:
65
+ For an app with sign-in, the admin area, and Markdown:
66
+
67
+ ```sh
68
+ npm install @sjolystinnovation/app-kit next react react-dom @heroicons/react next-auth axios sonner zod react-markdown remark-gfm rehype-sanitize
69
+ ```
70
+
71
+ The package ships TypeScript source. Add it to `transpilePackages`, or use `defineAppConfig` from
72
+ [`next-config`](#sjolystinnovationapp-kitnext-config), which adds it for you:
23
73
 
24
74
  ```ts
25
75
  // next.config.ts
76
+ import type { NextConfig } from "next";
77
+
26
78
  const nextConfig: NextConfig = {
27
79
  transpilePackages: ["@sjolystinnovation/app-kit"],
28
80
  };
81
+
82
+ export default nextConfig;
29
83
  ```
30
84
 
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:
85
+ The components use Tailwind CSS 4. Import the package's stylesheet after Tailwind:
33
86
 
34
87
  ```css
35
88
  /* src/app/globals.css */
@@ -37,10 +90,48 @@ The components in `@sjolystinnovation/app-kit/ui`, `admin`, `legal`, `markdown`,
37
90
  @import "@sjolystinnovation/app-kit/styles.css";
38
91
  ```
39
92
 
40
- ## Usage
93
+ ## Entry points
41
94
 
42
- Declare the values that differ between apps once, then pass that config wherever you need it. One
43
- app can define it, or several apps can each define their own.
95
+ | Import | What it holds |
96
+ | --- | --- |
97
+ | [`@sjolystinnovation/app-kit`](#root-entry-point) | The session config and `requireEnv`. Imports no peer dependency. |
98
+ | [`@sjolystinnovation/app-kit/next-config`](#sjolystinnovationapp-kitnext-config) | The shared `next.config.ts` with the security headers. |
99
+ | [`@sjolystinnovation/app-kit/auth`](#sjolystinnovationapp-kitauth) | The next-auth options for email and password sign-in against the API. Server only. |
100
+ | [`@sjolystinnovation/app-kit/next-auth`](#sjolystinnovationapp-kitnext-auth) | Type declarations for the fields the auth options add to the session and the token. |
101
+ | [`@sjolystinnovation/app-kit/session`](#sjolystinnovationapp-kitsession) | Session error codes and the re-sign-in prompt flag. Safe on the server. |
102
+ | [`@sjolystinnovation/app-kit/session/react`](#sjolystinnovationapp-kitsessionreact) | The session provider, gates, expiry guard, sign-in form, and login page. |
103
+ | [`@sjolystinnovation/app-kit/api`](#sjolystinnovationapp-kitapi) | The axios clients, error messages, field errors, and services for the API's endpoints. |
104
+ | [`@sjolystinnovation/app-kit/server`](#sjolystinnovationapp-kitserver) | Cached public GETs and the revalidate route. Server only. |
105
+ | [`@sjolystinnovation/app-kit/forms`](#sjolystinnovationapp-kitforms) | `useFormDraft`, which keeps form values in `localStorage`. |
106
+ | [`@sjolystinnovation/app-kit/toast`](#sjolystinnovationapp-kittoast) | sonner's `toast`, quiet on errors while the re-sign-in prompt is open. |
107
+ | [`@sjolystinnovation/app-kit/validation`](#sjolystinnovationapp-kitvalidation) | The password schema and zod field errors. |
108
+ | [`@sjolystinnovation/app-kit/ui`](#sjolystinnovationapp-kitui) | Form controls, alerts, content images, error pages, and the password reset flow. |
109
+ | [`@sjolystinnovation/app-kit/admin`](#sjolystinnovationapp-kitadmin) | The admin frame, company settings, users, legal pages editor, stats chart, and email stats panel. |
110
+ | [`@sjolystinnovation/app-kit/branding`](#sjolystinnovationapp-kitbranding) | The uploaded logo and favicon: provider, hook, and admin form. |
111
+ | [`@sjolystinnovation/app-kit/branding/server`](#sjolystinnovationapp-kitbrandingserver) | The `app/icon.ts` response for the uploaded favicon. Server only. |
112
+ | [`@sjolystinnovation/app-kit/markdown`](#sjolystinnovationapp-kitmarkdown) | Sanitized Markdown rendering. |
113
+ | [`@sjolystinnovation/app-kit/legal`](#sjolystinnovationapp-kitlegal) | The legal page view, the `LegalPage` type, and the default legal page keys. Needs neither axios nor next-auth. |
114
+ | [`@sjolystinnovation/app-kit/sections`](#sjolystinnovationapp-kitsections) | Page sections with a renderer registry and an editor registry. |
115
+ | [`@sjolystinnovation/app-kit/seo`](#sjolystinnovationapp-kitseo) | Canonical URLs, metadata, sitemaps, `robots.txt`, and structured data. |
116
+ | [`@sjolystinnovation/app-kit/seo/og`](#sjolystinnovationapp-kitseoog) | The default Open Graph share image. |
117
+ | [`@sjolystinnovation/app-kit/payments`](#sjolystinnovationapp-kitpayments) | The page a user returns to from a payment provider. |
118
+ | [`@sjolystinnovation/app-kit/locales/nb`](#sjolystinnovationapp-kitlocalesnb) | Norwegian (bokmål) text for every component that takes `strings`. Imports no peer dependency. |
119
+ | [`@sjolystinnovation/app-kit/testing`](#sjolystinnovationapp-kittesting) | vitest doubles for next-auth, next/navigation, and sonner. |
120
+ | [`@sjolystinnovation/app-kit/styles.css`](#theming) | The theme variables and styles the components use. |
121
+
122
+ Most components export their props type as `<Component>Props`. Every `strings` prop has a
123
+ `<Component>Strings` type and a `default<Component>Strings` constant with the English text.
124
+ `strings` takes `TextOverrides<T>` from `ui`: each key is optional, and a missing or `undefined` key
125
+ shows the English default. [`locales/nb`](#sjolystinnovationapp-kitlocalesnb) has the Norwegian
126
+ text for every one.
127
+
128
+ ## Root entry point
129
+
130
+ `@sjolystinnovation/app-kit` holds `defineSessionConfig`, `requireEnv`, and the `SessionConfig`
131
+ type. It imports no peer dependency, so it works in browser code, server code, and
132
+ `next.config.ts`.
133
+
134
+ Declare the values that differ between apps once, then pass that config wherever you need it:
44
135
 
45
136
  ```ts
46
137
  // src/lib/session.ts
@@ -53,79 +144,61 @@ export const sessionConfig = defineSessionConfig({
53
144
  });
54
145
  ```
55
146
 
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`.
58
-
59
- ## API
60
-
61
- ### `SessionConfig`
62
-
63
147
  | Property | Type | Description |
64
148
  | --- | --- | --- |
65
- | `jwtSecretEnvVar` | `string` | Name of the environment variable holding the API JWT secret, such as `API_JWT_SECRET`. |
149
+ | `jwtSecretEnvVar` | `string` | The name of the environment variable that holds the API JWT secret, such as `API_JWT_SECRET`. |
66
150
  | `loginRoute` | `string` | Where an expired session sends the browser, such as `/en/login` or `/login`. |
67
- | `draftStoragePrefix` | `string` | Bare name for the `localStorage` keys holding form drafts, such as `example`. Drafts are stored under `example:draft:<owner>:<scope>`. |
68
-
69
- ### `defineSessionConfig(config)`
70
-
71
- `(config: SessionConfig) => SessionConfig`
72
-
73
- Returns the config unchanged, so TypeScript checks its shape once, where it is declared. Throws
74
- when `draftStoragePrefix` is empty or contains a colon.
75
-
76
- ### `requireEnv(name, value)`
77
-
78
- `(name: string, value: string | undefined) => string`
79
-
80
- Returns `value`, or throws `Missing <name>.` when it is unset or empty. Pass the value read in
81
- place, such as `process.env.NEXT_PUBLIC_API_URL`, so Next.js can inline a public variable at build
82
- time.
151
+ | `draftStoragePrefix` | `string` | The bare name for the `localStorage` keys that hold form drafts, such as `example`. Drafts are stored under `example:draft:<owner>:<scope>`. |
83
152
 
84
- ### `formatApiError(error, fallback, statusMessages?)`
85
-
86
- `(error: unknown, fallback: string, statusMessages?: StatusMessages) => string`
87
-
88
- From `@sjolystinnovation/app-kit/api`. Turns a failed request into one line a user can read.
89
-
90
- On a 4xx response, it uses the first of these:
91
-
92
- 1. The body's own message: a text body, such as the one `BadRequest("...")` sends from ASP.NET Core,
93
- or `message`, the first validation error, or `detail` from a JSON body.
94
- 2. The entry in `statusMessages` for the status.
95
- 3. `fallback`.
153
+ `defineSessionConfig(config)` returns the config unchanged, so TypeScript checks its shape where it
154
+ is declared. It throws when `draftStoragePrefix` is empty or contains a colon.
96
155
 
97
- On a 5xx response, the entry in `statusMessages` comes first, then the body, then `fallback`. A 5xx
98
- body can come from a proxy, such as "Bad Gateway", so the caller's own words win when it has them.
156
+ `requireEnv(name, value)` returns `value`, or throws `Missing <name>.` when it is unset or empty.
157
+ Pass the value read in place, such as `process.env.NEXT_PUBLIC_API_URL`, so Next.js can inline a
158
+ public variable at build time.
99
159
 
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`.
160
+ ## `@sjolystinnovation/app-kit/next-config`
103
161
 
104
- A few more rules:
162
+ The config every app shares, for `next.config.ts`. It imports nothing but Next's types, so Next
163
+ loads it with no build step.
105
164
 
106
- - Key `0` in `statusMessages` covers a request that got no response, because of a dropped
107
- connection or a timeout.
108
- - Only text on a single line counts as a message.
109
- - The function never shows Axios's own message, such as "Request failed with status code 500".
110
- - A canceled request gives `fallback`.
111
- - Any other `Error` gives its own message.
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`.
165
+ `defineAppConfig(options, extra?)` gives the standalone build, app-kit in `transpilePackages`, no
166
+ `X-Powered-By`, an `expireTime` of five minutes, image qualities 75 and 100, the incremental cache
167
+ in memory, and the security headers on every page. It merges `extra` over that config. It combines
168
+ `transpilePackages`, `images`, `experimental`, `headers`, and `rewrites` instead of replacing them.
114
169
 
115
170
  ```ts
116
- import { formatApiError } from "@sjolystinnovation/app-kit/api";
171
+ // next.config.ts
172
+ import { defineAppConfig } from "@sjolystinnovation/app-kit/next-config";
117
173
 
118
- try {
119
- await api.post("/users", form);
120
- } catch (error) {
121
- setError(formatApiError(error, "Could not create the user.", { 409: "That email is taken." }));
122
- }
174
+ export default defineAppConfig({
175
+ apiUrl: process.env.NEXT_PUBLIC_API_URL,
176
+ dev: process.env.NODE_ENV !== "production",
177
+ contentImages: true,
178
+ });
123
179
  ```
124
180
 
125
- ### `createAuthOptions(config, settings)`
181
+ | Option | Type | Description |
182
+ | --- | --- | --- |
183
+ | `apiUrl` | `string \| undefined` | The API URL. Its origin is allowed in `connect-src`. |
184
+ | `dev` | `boolean` | Whether this is a development build. Development builds also allow `'unsafe-eval'`, which hot reload needs. |
185
+ | `websocket` | `boolean` | Whether to allow `ws:` and `wss:` to the API origin, for SignalR. |
186
+ | `imgFromApi` | `boolean` | Whether to allow images from the API origin. Not needed with `contentImages`. |
187
+ | `contentImages` | `boolean` | Whether to proxy `/content-images/*` to the API's `/content-images/*`, so the browser never contacts the API host. |
188
+ | `connectSrc`, `imgSrc` | `string[]` | Extra sources for those directives. |
189
+ | `frameSrc`, `objectSrc` | `string[]` | Sources for those directives. Both default to `'none'`. |
190
+ | `pathHeaders` | `{ source, headers }[]` | Headers for particular paths, such as a file download. They come after the page headers and replace a page header of the same name. Every path keeps the rest. |
191
+ | `permissionsPolicy` | `string` | Replaces `camera=(), microphone=(), geolocation=()`. |
192
+ | `expireTime` | `number` | The seconds a cache may serve a stale page. Defaults to 300. Next's default is a year. |
193
+ | `isrFlushToDisk` | `boolean` | Whether to write revalidated pages to disk. Defaults to `false`, for a read-only root filesystem. |
194
+
195
+ Scripts allow `'unsafe-inline'`, since a nonce would make every statically rendered page dynamic.
196
+ The package also exports the `AppConfigOptions` and `CspOptions` types.
197
+
198
+ ## `@sjolystinnovation/app-kit/auth`
126
199
 
127
- From `@sjolystinnovation/app-kit/auth`. Server only. Builds the next-auth options for an app that
128
- signs in against the API with email and password:
200
+ Server only. `createAuthOptions(config, settings)` builds the next-auth options for an app that
201
+ signs in against the API with email and password.
129
202
 
130
203
  ```ts
131
204
  // src/lib/authOptions.ts
@@ -151,8 +224,8 @@ export { handler as GET, handler as POST };
151
224
 
152
225
  | Setting | Type | Description |
153
226
  | --- | --- | --- |
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. |
155
- | `env` | `Record<string, string \| undefined>` | The server environment, usually `process.env`. It supplies the secret named by `jwtSecretEnvVar`. |
227
+ | `apiUrl` | `string` | The 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. |
228
+ | `env` | `Record<string, string \| undefined>` | The server environment, usually `process.env`. It supplies the secret that `jwtSecretEnvVar` names. |
156
229
  | `http` | `AxiosInstance` | The client for sign-in and refresh, with its own base URL. Use it in place of `apiUrl`, for tests. |
157
230
 
158
231
  next-auth reads `NEXTAUTH_SECRET` itself and refuses to run in production without it.
@@ -166,6 +239,8 @@ What the options do:
166
239
  next-auth's `error` holds one of these:
167
240
  - `SIGN_IN_ERRORS.unavailable` when the API does not answer, or a gateway returns 502, 503, or
168
241
  504.
242
+ - `SIGN_IN_ERRORS.lockedOut` when the API answers with the code `lockedOut`, after too many failed
243
+ sign-ins. The API gives it whether the password was right or wrong.
169
244
  - `SIGN_IN_ERRORS.invalidCredentials` for every other failure, including an unreadable token and
170
245
  an answer without both tokens.
171
246
  - **Refresh** runs ahead of expiry: five minutes before it, or a quarter of the token's lifetime
@@ -186,14 +261,18 @@ Follow two rules so that you never lose a refresh:
186
261
 
187
262
  - Read the session on the client, through `useSession` or `getSession`. In the App Router,
188
263
  `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
190
- next refresh then reuses the old token, and the API revokes every session the user has. On the
191
- server, read the token with `getToken` from `next-auth/jwt`, which never refreshes.
192
- - Run the app as one server process. Parallel session reads share one refresh only within a process.
193
- Two replicas can refresh the same token at once, with the same result.
264
+ server actions. A refresh there replaces the tokens at the API and loses the new ones. The next
265
+ refresh then reuses the old token, and the API revokes every session the user has. On the server,
266
+ read the token with `getToken` from `next-auth/jwt`, which never refreshes.
267
+ - Run the app as one server process. Parallel session reads share one refresh only within a
268
+ process. Two replicas can refresh the same token at once, with the same result.
194
269
 
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:
270
+ The package also exports the `AuthOptionsSettings` and `SessionUser` types.
271
+
272
+ ## `@sjolystinnovation/app-kit/next-auth`
273
+
274
+ Type declarations only. They add the fields that `createAuthOptions` sets to next-auth's `Session`
275
+ and `JWT` types. Load them once, from one of the app's own `.d.ts` files:
197
276
 
198
277
  ```ts
199
278
  // src/types/next-auth.d.ts
@@ -204,946 +283,1387 @@ The session carries `user` (`id`, `name`, `email`, `roles`), `accessToken`, `err
204
283
  `absoluteExpiresAt`. The token carries `accessToken`, `refreshToken`, `refreshAt`, `loginAt`,
205
284
  `user`, and `error`.
206
285
 
207
- ### `@sjolystinnovation/app-kit/api`
286
+ ## `@sjolystinnovation/app-kit/session`
208
287
 
209
- `createApiClient(baseURL)` returns an axios instance for the signed-in API, for code that runs in
210
- the browser. It reads the session through next-auth's `getSession`, which has no cookies to read on
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.
288
+ Decides when a user has to sign in again. It is safe to import from server code, such as the
289
+ next-auth callbacks.
214
290
 
215
- `request(call, fallback, statusMessages?)` runs a call and returns its `data`. A failure becomes an
216
- `Error` with the message `formatApiError` gives it. A canceled call rethrows the cancel unchanged:
291
+ - `SIGN_IN_ERRORS` holds the three codes a failed sign-in reports: `InvalidCredentials`,
292
+ `SignInUnavailable`, and `LockedOut`. Map them to the app's own messages.
293
+ - `SESSION_ERRORS` holds the values the jwt callback puts in `token.error` when the session cannot
294
+ be recovered: `AbsoluteSessionExpired`, `RefreshTokenRejected`, and `NoRefreshToken`.
295
+ `SessionError` is their union type.
296
+ - `isTerminalSessionError(error)` is true for those three only, so a transient refresh failure
297
+ keeps the session.
217
298
 
218
299
  ```ts
219
- import { requireEnv } from "@sjolystinnovation/app-kit";
220
- import { createApiClient, request } from "@sjolystinnovation/app-kit/api";
300
+ import { SIGN_IN_ERRORS, isTerminalSessionError } from "@sjolystinnovation/app-kit/session";
221
301
 
222
- const api = createApiClient(requireEnv("NEXT_PUBLIC_API_URL", process.env.NEXT_PUBLIC_API_URL));
302
+ const signInMessage = (code: string | undefined) =>
303
+ code === SIGN_IN_ERRORS.unavailable ? "The service is down. Try again soon." : "Wrong email or password.";
223
304
 
224
- export const getRecipe = (id: number) =>
225
- request(() => api.get<Recipe>(`/recipes/${id}`), "Could not load the recipe.");
305
+ const mustSignInAgain = (session: { error?: string | undefined } | null) => isTerminalSessionError(session?.error);
226
306
  ```
227
307
 
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
- ```
308
+ The re-sign-in prompt is a flag outside React, so an axios interceptor can raise it:
238
309
 
239
- `apiFieldErrors(error)` reads the per-field messages of an ASP.NET validation problem from a failed
240
- call or its body, keyed in camelCase to match the form: `{ errors: { OrgNumber: ["..."] } }` becomes
241
- `{ orgNumber: ["..."] }`. It returns `null` when there are none, so the form falls back to one
242
- message.
310
+ | Function | Description |
311
+ | --- | --- |
312
+ | `openSessionPrompt()` | Asks the user to sign in again without leaving the page. |
313
+ | `closeSessionPrompt()` | Hides the prompt. Call it when the component that shows the prompt unmounts, or the flag stays open and the gate never redirects. |
314
+ | `getSessionPromptOpen()` | Whether the prompt is showing. |
243
315
 
244
- `createPublicClient(baseURL)` is the same without the session, for the API's anonymous endpoints. An
245
- empty `baseURL` throws, and requests only go to `baseURL`.
316
+ Copies of the package installed side by side share the one flag, as long as their releases use the
317
+ same prompt state format. A release that changes the format says so in its changelog. An app's own
318
+ copy of these functions does not share the flag, so import them from this package in the
319
+ interceptor, the prompt, and every gate.
246
320
 
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.
250
- Failures throw an `Error` with the API's message, or with the service's own English fallback.
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.
321
+ ## `@sjolystinnovation/app-kit/session/react`
256
322
 
257
- `invite` takes `{ email, firstName, lastName, role }`. `save` takes
258
- `{ key, locale, title, bodyMarkdown }`.
323
+ The client side of the session. The gates and the guard read `session.error`. `SessionExpiryGuard`
324
+ also reads `session.absoluteExpiresAt` for its countdown, `session.user.id` to keep the page with
325
+ the user who opened it, and `session.user.email` to fill in the prompt. `createAuthOptions` sets all
326
+ of these. Load their types from [`next-auth`](#sjolystinnovationapp-kitnext-auth).
259
327
 
260
- | Factory | Methods | .NET package |
261
- | --- | --- | --- |
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 |
264
- | `createSettingsService(api, { onSaved? })` | `get`, `update` | SiteSettings |
265
- | `createBrandingService(api, publicApi, { onSaved? })` | `get`, which answers `{}` when the API cannot be read, and `update(form)` | SiteSettings |
266
- | `createLegalService<Key>(api, publicApi, { onSaved? })` | `get(key, locale)`, which answers `null` for a missing page, `listAll`, and `save` | SiteSettings |
328
+ A typical layout for the signed-in part of an app:
267
329
 
268
- ```ts
269
- import { createApiClient, createPublicClient, createUserService } from "@sjolystinnovation/app-kit/api";
330
+ ```tsx
331
+ // src/app/(protected)/layout.tsx
332
+ "use client";
333
+ import type { ReactNode } from "react";
334
+ import { AppSessionProvider, ProtectedGate, SessionExpiryGuard } from "@sjolystinnovation/app-kit/session/react";
270
335
 
271
- const apiUrl = requireEnv("NEXT_PUBLIC_API_URL", process.env.NEXT_PUBLIC_API_URL);
272
- export default createUserService(createApiClient(apiUrl), createPublicClient(apiUrl));
336
+ export default function ProtectedLayout({ children }: { children: ReactNode }) {
337
+ return (
338
+ <AppSessionProvider>
339
+ <SessionExpiryGuard loginHref="/login" />
340
+ <ProtectedGate loginHref="/login" fallback={<p>Loading…</p>}>
341
+ {children}
342
+ </ProtectedGate>
343
+ </AppSessionProvider>
344
+ );
345
+ }
273
346
  ```
274
347
 
275
- The package also exports the response types: `UserProfile`, `AdminUser`, `SiteSettings`, and
276
- `LegalPage`.
348
+ ### `AppSessionProvider`
277
349
 
278
- ### `@sjolystinnovation/app-kit/server`
350
+ next-auth's `SessionProvider`, reading the session again every four minutes. Each read lets the jwt
351
+ callback renew the access token before it expires, so an open tab does not find a dead session on
352
+ the next save. It also keeps what `useSession` reports in this tab up to date. next-auth polls only
353
+ once a session exists.
279
354
 
280
- For server components and route handlers only. It imports `next/cache` and `next/server`.
355
+ | Prop | Type | Description |
356
+ | --- | --- | --- |
357
+ | `refetchInterval` | `number` | The seconds between reads. Defaults to 240. Keep it under five minutes, the time before expiry when a refresh falls due. |
281
358
 
282
- `createPublicApi(baseUrl, options?)` returns cached GETs against the API's anonymous endpoints:
359
+ ### `ProtectedGate`
283
360
 
284
- - `publicGet(path, { tags? })` gives the data, or `null` when the API answers 404. An unreachable or
285
- failing API throws `PublicApiError`, so a failed fetch is never cached as an empty page in place
286
- of a working one.
287
- - `publicGetOptional(path, { tags? })` gives `null` on any failure, for data a page can do without.
288
- - `publicGetWithMeta(path, { tags? })` also gives `lastModified`, from the `Last-Modified` header.
361
+ Wraps the protected part of the app. It reads `useSession`, so it needs a `SessionProvider` above
362
+ it. It sends a signed-out visitor, or a session dead on arrival, to `loginHref`. It never redirects
363
+ while the re-sign-in prompt is open.
289
364
 
290
- Responses stay fresh for 60 seconds unless `options.revalidate` says otherwise. An empty `baseUrl`
291
- throws. Each path starts with a single `/`, or the call throws. A path with a `.` or `..` segment,
292
- encoded or not, gives `null` without a request, the same as a 404. Run a slug or other user input
293
- through `encodeURIComponent` before putting it in a path.
365
+ A page that rendered on a healthy session stays rendered, so an open form keeps its values:
294
366
 
295
- ```ts
296
- // src/lib/publicApi.ts
297
- import { requireEnv } from "@sjolystinnovation/app-kit";
298
- import { createPublicApi } from "@sjolystinnovation/app-kit/server";
367
+ - when the session gets a terminal error
368
+ - while the session is read again
369
+ - while the prompt recovers it
299
370
 
300
- export const { publicGet, publicGetOptional, publicGetWithMeta } = createPublicApi(
301
- requireEnv("NEXT_PUBLIC_API_URL", process.env.NEXT_PUBLIC_API_URL),
302
- );
303
- ```
371
+ A session that turns signed out with no prompt open blanks the page. A page the user opens on a dead
372
+ session does not render. `RoleGate` and `AdminShell` make the same decision, so none of them blanks
373
+ a page that another one kept.
304
374
 
305
- `createRevalidateRoute({ targets, role })` returns a `POST` handler for `app/api/revalidate/route.ts`.
306
- Each target maps to the page paths it purges. A path with a dynamic segment, such as
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.
375
+ | Prop | Type | Description |
376
+ | --- | --- | --- |
377
+ | `loginHref` | `string` | Where the redirect goes. An app with a locale in the path builds it for the current locale. |
378
+ | `fallback` | `ReactNode` | What shows while the page may not render yet. Defaults to nothing. |
312
379
 
313
- ```ts
314
- // src/app/api/revalidate/route.ts
315
- import { createRevalidateRoute } from "@sjolystinnovation/app-kit/server";
380
+ ### `RoleGate`
316
381
 
317
- export const POST = createRevalidateRoute({
318
- role: "Admin",
319
- targets: { builds: ["/builds", "/builds/[slug]", "/sitemap.xml"], legal: ["/terms", "/privacy"] },
320
- });
382
+ Renders its children only for a user with `role`. Place it inside `ProtectedGate`. While the
383
+ re-sign-in prompt recovers a signed-out session, there is no user and so no roles. The page stays
384
+ up only on the path where the session last had the role.
385
+
386
+ ```tsx
387
+ <RoleGate role="Admin" fallback={<p>Loading…</p>} denied={<p>You do not have access.</p>}>
388
+ {children}
389
+ </RoleGate>
321
390
  ```
322
391
 
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.
392
+ | Prop | Type | Description |
393
+ | --- | --- | --- |
394
+ | `role` | `string` | The role the page needs, as it appears in `session.user.roles`. |
395
+ | `fallback` | `ReactNode` | What shows while the session or its roles are not known yet. Defaults to nothing. |
396
+ | `denied` | `ReactNode` | What a signed-in user without the role sees. Give this or `deniedHref`. |
397
+ | `deniedHref` | `string` | Where a signed-in user without the role goes instead. |
327
398
 
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
- });
399
+ ### `SessionExpiryGuard`
338
400
 
339
- // src/app/api/revalidate/route.ts
340
- export const POST = createRevalidateRoute({ targets: TARGET_PATHS, role: ADMIN_ROLE });
341
- ```
401
+ Render it once, inside the `SessionProvider`. It shows:
342
402
 
343
- ### `@sjolystinnovation/app-kit/next-config`
403
+ - a banner in the last 30 minutes before `session.absoluteExpiresAt`, with the minutes left. That is
404
+ seven days after sign-in with `createAuthOptions`.
405
+ - a banner when the session is dead
406
+ - the re-sign-in prompt, a dialog with `CredentialsForm`, when a request or the banner opens it
344
407
 
345
- For `next.config.ts`. It imports nothing but Next's types, so Next loads it with no build step.
408
+ A sign-in in the prompt keeps the page and its form. If someone signs in to the prompt as a
409
+ different user, the guard signs them out and sends them to `loginHref`, so the prompt never hands
410
+ the page to another user.
346
411
 
347
- `defineAppConfig(options, extra?)` gives the config every app shares: the standalone build, app-kit
348
- in `transpilePackages`, no `X-Powered-By`, `expireTime` of five minutes, image qualities 75 and 100,
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.
412
+ ```tsx
413
+ <SessionExpiryGuard loginHref="/login" onRestored={() => toast.success("You are signed in again.")} />
414
+ ```
352
415
 
353
- ```ts
354
- // next.config.ts
355
- import { defineAppConfig } from "@sjolystinnovation/app-kit/next-config";
416
+ | Prop | Type | Description |
417
+ | --- | --- | --- |
418
+ | `loginHref` | `string` | Where the guard sends a different user after it signs them out. |
419
+ | `onRestored` | `() => void` | Runs after the prompt signs the user back in, such as to show a toast. |
420
+ | `strings` | `TextOverrides<SessionExpiryGuardStrings>` | The guard's text. `{minutes}` in `expiringSoon` becomes the minutes left. |
421
+ | `formStrings` | `TextOverrides<CredentialsFormStrings>` | The text of the form in the prompt. |
356
422
 
357
- export default defineAppConfig({
358
- apiUrl: process.env.NEXT_PUBLIC_API_URL,
359
- dev: process.env.NODE_ENV !== "production",
360
- contentImages: true,
361
- });
423
+ ### `CredentialsForm`
424
+
425
+ Email and password sign-in through next-auth's credentials provider, with `redirect: false`, so the
426
+ page stays where it is. Use it on the login page and in the prompt.
427
+
428
+ ```tsx
429
+ <CredentialsForm onSignedIn={() => router.push("/")} strings={{ signIn: "Logg inn" }} />
362
430
  ```
363
431
 
364
- | Option | Type | Description |
432
+ | Prop | Type | Description |
365
433
  | --- | --- | --- |
366
- | `apiUrl` | `string \| undefined` | The API URL. Its origin is allowed in `connect-src`. |
367
- | `dev` | `boolean` | Development builds also allow `'unsafe-eval'`, which hot reload needs. |
368
- | `websocket` | `boolean` | Also allow `ws:` and `wss:` to the API origin, for SignalR. |
369
- | `imgFromApi` | `boolean` | Also allow images from the API origin. Not needed with `contentImages`. |
370
- | `contentImages` | `boolean` | Proxy `/content-images/*` to the API's `/content-images/*`, so the browser never contacts the API host. |
371
- | `connectSrc`, `imgSrc` | `string[]` | Extra sources for those directives. |
372
- | `frameSrc`, `objectSrc` | `string[]` | Sources for those directives. Both default to `'none'`. |
373
- | `pathHeaders` | `{ source, headers }[]` | Headers for particular paths, such as a file download. They come after the page headers and replace a page header of the same name. Every path keeps the rest. |
374
- | `permissionsPolicy` | `string` | Replaces `camera=(), microphone=(), geolocation=()`. |
375
- | `expireTime` | `number` | Seconds a cache may serve a stale page. Defaults to 300. Next's default is a year. |
376
- | `isrFlushToDisk` | `boolean` | Write revalidated pages to disk. Off by default, for a read-only root filesystem. |
434
+ | `onSignedIn` | `() => void \| Promise<void>` | Runs after next-auth accepts the credentials. If it throws, the form shows `somethingWentWrong`. |
435
+ | `initialEmail` | `string` | The email the field starts with. |
436
+ | `children` | `ReactNode` | Extra buttons next to the submit button. |
437
+ | `strings` | `TextOverrides<CredentialsFormStrings>` | The form's text. |
438
+ | `onPendingChange` | `(pending: boolean) => void` | Runs when a sign-in starts and ends, such as to keep a dialog open until it finishes. |
377
439
 
378
- Scripts allow `'unsafe-inline'`, since a nonce would make every statically rendered page dynamic.
440
+ A failed sign-in shows `signInUnavailable` for `SIGN_IN_ERRORS.unavailable`, `lockedOut` for
441
+ `SIGN_IN_ERRORS.lockedOut`, and `invalidCredentials` for any other error code. A request that next-auth refuses without an error
442
+ code, or one that throws, shows `somethingWentWrong`.
379
443
 
380
- ### `@sjolystinnovation/app-kit/markdown`
444
+ ### `LoginPage`
381
445
 
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:
446
+ The sign-in page: a title, `CredentialsForm`, a link to reset the password, and a sign-up link when
447
+ the app has sign-up. A sign-in goes to `redirectTo` and refreshes the page's server data. It uses the
448
+ same `auth` theme variables as `ResetPasswordFlow`.
386
449
 
387
- ```sh
388
- npm install react-markdown remark-gfm rehype-sanitize
450
+ ```tsx
451
+ <LoginPage redirectTo="/admin" resetHref="/reset-password" strings={{ title: "Logg inn" }} formStrings={{ signIn: "Logg inn" }} />
389
452
  ```
390
453
 
391
- ```tsx
392
- import { Markdown } from "@sjolystinnovation/app-kit/markdown";
454
+ | Prop | Type | Description |
455
+ | --- | --- | --- |
456
+ | `redirectTo` | `string` | Where a signed-in user goes. |
457
+ | `resetHref` | `string` | The password reset page. |
458
+ | `registerHref` | `string` | The sign-up page. Without it there is no register link. |
459
+ | `strings` | `TextOverrides<LoginPageStrings>` | The page's text: `title`, `forgotPassword`, `registerPrompt`, and `register`. Set `registerPrompt` to `null` to leave it out. |
460
+ | `formStrings` | `TextOverrides<CredentialsFormStrings>` | The form's text. |
461
+ | `className` | `string` | Replaces the outer `max-w-md mx-auto px-4 py-16`. |
393
462
 
394
- <div className="space-y-4 text-sm text-gray-700">
395
- <Markdown>{build.description}</Markdown>
396
- </div>;
397
- ```
463
+ ## `@sjolystinnovation/app-kit/api`
398
464
 
399
- | Prop | Type | Default | Description |
400
- | --- | --- | --- | --- |
401
- | `children` | `string \| null \| undefined` | | The Markdown. Empty, `null`, or `undefined` renders nothing. |
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. |
403
- | `externalLinks` | `boolean` | `false` | Opens `http` and `https` links in a new tab with `rel="noopener noreferrer"`. Site-relative links stay in the tab. |
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. |
465
+ The browser side of the API: the axios clients, error messages, field errors, and services for the
466
+ endpoints that the SjolystInnovation.AppKit .NET packages map.
405
467
 
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`.
468
+ ### `createApiClient` and `createPublicClient`
408
469
 
409
- ### `@sjolystinnovation/app-kit/seo`
470
+ `createApiClient(baseURL)` returns an axios instance for the signed-in API, for code that runs in the
471
+ browser. It reads the session through next-auth's `getSession`, which has no cookies to read on the
472
+ server. An empty `baseURL` throws. Each request carries the session's access token. When the session
473
+ is gone or has a terminal error, a 401 opens the re-sign-in prompt. It does not sign the user out, so
474
+ the page keeps its state. The error still rejects as usual.
410
475
 
411
- Search engine helpers for one site: canonical URLs with hreflang, page metadata with Open Graph,
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:
476
+ `createPublicClient(baseURL)` is the same without the session, for the API's anonymous endpoints. An
477
+ empty `baseURL` throws, and requests only go to `baseURL`.
478
+
479
+ ### `request`
480
+
481
+ `request(call, fallback, statusMessages?)` runs a call and returns its `data`. A failure becomes an
482
+ `Error` with the message that `formatApiError` gives it. When the API answers with a validation
483
+ problem, the error also keeps its per-field messages and their codes for `apiFieldErrors` and for
484
+ the field errors of the shared forms. When the API answers with a code for the whole call, such as
485
+ `{ "message": "...", "code": "invalidResetCode" }`, the error keeps the code for `apiErrorCode`. It
486
+ keeps nothing else from the response. A canceled call rethrows the cancel unchanged.
414
487
 
415
488
  ```ts
416
- // src/lib/seo/site.ts
417
- import { defineSite } from "@sjolystinnovation/app-kit/seo";
489
+ import { requireEnv } from "@sjolystinnovation/app-kit";
490
+ import { createApiClient, request } from "@sjolystinnovation/app-kit/api";
418
491
 
419
- export const site = defineSite({
420
- name: "Altinnendata",
421
- url: "https://www.altinnendata.no",
422
- titleSeparator: " | ",
423
- locales: ["no", "en"],
424
- defaultLocale: "no",
425
- localeTags: { no: "no", en: "en" },
426
- tagline: locale => getDictionary(locale).meta.tagline,
427
- });
492
+ const api = createApiClient(requireEnv("NEXT_PUBLIC_API_URL", process.env.NEXT_PUBLIC_API_URL));
493
+
494
+ export const getRecipe = (id: number) => request(() => api.get<Recipe>(`/recipes/${id}`), "Could not load the recipe.");
428
495
  ```
429
496
 
430
- | Option | Type | Description |
431
- | --- | --- | --- |
432
- | `name` | `string` | The site's name, in titles, Open Graph, and structured data. |
433
- | `url` | `string` | The canonical origin, without a trailing slash. |
434
- | `titleSeparator` | `string` | Between a page title and the site name. |
435
- | `locales` | `string[]` | The locale segments every page is under. A site without them leaves it out, and its paths have no segment. |
436
- | `defaultLocale` | `string` | The locale `x-default` points at. Defaults to the first. |
437
- | `localeTags` | `Record<locale, string>` | The BCP 47 tag per locale. Defaults to the locale. |
438
- | `tagline` | `(locale) => string` | The line after the name in the default title and the share image's alt text. |
439
- | `shareImagePath` | `string` | The share image route below each locale. Defaults to `/og`. |
497
+ ### `formatApiError`
440
498
 
441
- The site provides:
499
+ `formatApiError(error, fallback, statusMessages?)` turns a failed request into one line a user can
500
+ read.
442
501
 
443
- - `localePath(locale, path)`, `absoluteUrl(path)`, `absoluteLocaleUrl(locale, path)` and
444
- `languageAlternates(path)`. Paths get a leading slash and no trailing slash.
445
- - `siteMetadata({ locale, description, manifest })` for the locale root layout: the title
446
- template, `metadataBase`, the index directive, and the front page's canonical.
447
- - `pageMetadata({ locale, path, title, description, images })` for a page: the canonical URL,
448
- hreflang, and Open Graph with the locale's share image unless `images` is given.
449
- - `localeEntries(path, { locales, lastModified })` for `sitemap.ts`: one entry per locale, each
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`
459
- for structured data.
502
+ On a 4xx response, it uses the first of these:
460
503
 
461
- ```tsx
462
- // src/app/robots.ts
463
- export const dynamic = "force-dynamic";
464
- export default () => site.robots({ privatePaths: ["/admin", "/login"], privateRootPaths: ["/api/"] });
504
+ 1. The body's own message: a text body, such as the one `BadRequest("...")` sends from ASP.NET Core,
505
+ or `message`, the first validation error, or `detail` from a JSON body.
506
+ 2. The entry in `statusMessages` for the status.
507
+ 3. `fallback`.
465
508
 
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
- ];
509
+ On a 5xx response, the entry in `statusMessages` comes first, then the body, then `fallback`. A 5xx
510
+ body can come from a proxy, such as "Bad Gateway", so the caller's own words win when it has them.
474
511
 
475
- // in a page
476
- <JsonLd nodes={[site.organizationNode(company), site.webSiteNode(locale)]} />
477
- ```
512
+ The function never shows a problem details `title`. ASP.NET Core fills it with generic text, such as
513
+ "Bad Request" or "An error occurred while processing your request.", which says less than
514
+ `fallback`.
478
515
 
479
- `isIndexableEnvironment()` reads `SITE_ENV`. `prod`, `production`, and unset or blank count as
480
- production, so a missing variable never deindexes the live site. `robots.ts` needs
481
- `dynamic = "force-dynamic"`, so it reads the variable per request. The package also exports
482
- `JsonLd`, `serializeGraph`, `ref`, `NOINDEX`, `LEGAL_PAGE_KEYS`, `SHARE_IMAGE_SIZE`, `latest`, and
483
- `parseTimestamp`.
516
+ A few more rules:
484
517
 
485
- ### `@sjolystinnovation/app-kit/seo/og`
518
+ - Key `0` in `statusMessages` covers a request that got no response, because of a dropped
519
+ connection or a timeout.
520
+ - Only text on a single line counts as a message.
521
+ - The function never shows Axios's own message, such as "Request failed with status code 500".
522
+ - A canceled request gives `fallback`.
523
+ - Any other `Error` gives its own message.
524
+ - The function does not read a body requested as an `arraybuffer` or a `blob`, as for a file
525
+ download. A failed download gets `statusMessages` or `fallback`.
486
526
 
487
- `shareImage({ name, tagline, colors })` renders the default share preview, a PNG at
488
- `SHARE_IMAGE_SIZE`: the name, a bar in `colors.primary`, and the tagline. The colors are literals,
489
- since the image renders outside the browser. Return it from the route `pageMetadata` points at:
527
+ ```ts
528
+ import { formatApiError } from "@sjolystinnovation/app-kit/api";
490
529
 
491
- ```tsx
492
- // src/app/[locale]/og/route.tsx
493
- import { shareImage } from "@sjolystinnovation/app-kit/seo/og";
530
+ try {
531
+ await api.post("/users", form);
532
+ } catch (error) {
533
+ setError(formatApiError(error, "Could not create the user.", { 409: "That email is taken." }));
534
+ }
535
+ ```
494
536
 
495
- export async function GET(_request: Request, { params }: { params: Promise<{ locale: string }> }) {
496
- const { locale } = await params;
497
- return shareImage({ name: site.name, tagline: getDictionary(locale).meta.tagline, colors: BRAND });
537
+ ### `apiFieldErrors`
538
+
539
+ `apiFieldErrors(error)` reads the per-field messages of an ASP.NET validation problem from a failed
540
+ call, its body, or the `Error` that `request` and the services throw. It keys them in camelCase to
541
+ match the form: `{ errors: { OrgNumber: ["..."] } }` becomes `{ orgNumber: ["..."] }`. It returns
542
+ `null` when there are none, so the form falls back to one message.
543
+
544
+ ```ts
545
+ try {
546
+ await SettingsService.update(settings);
547
+ } catch (err) {
548
+ setFieldErrors(apiFieldErrors(err) ?? {});
549
+ toast.error(err instanceof Error ? err.message : "Could not save.");
498
550
  }
499
551
  ```
500
552
 
501
- It is a route rather than the `opengraph-image` file convention, since a page's own `openGraph`
502
- block replaces that image.
553
+ ### `apiErrorCode`
503
554
 
504
- ### `@sjolystinnovation/app-kit/branding`
555
+ `apiErrorCode(error)` reads the code of a failure that is not about one field, from a failed call,
556
+ its body, or the `Error` that `request` and the services throw. It answers `undefined` when there is
557
+ none, and never takes axios's own code, such as `ERR_NETWORK`. Use it to show the app's own text for
558
+ a code and the API's message for any other failure. The components in this package do that through
559
+ their `strings`.
505
560
 
506
- The logo and favicon an admin uploads, as the API's `/branding` endpoint returns them.
561
+ ```ts
562
+ try {
563
+ await UserService.deleteAccount();
564
+ } catch (err) {
565
+ const own = { lastAdmin: "Gi administratorrollen til noen andre først." };
566
+ const code = apiErrorCode(err);
567
+ toast.error(code && Object.hasOwn(own, code) ? own[code as keyof typeof own] : formatApiError(err, "Kunne ikke slette kontoen."));
568
+ }
569
+ ```
507
570
 
508
- `BrandingProvider` holds them for the components below it. It takes the branding the server
509
- rendered with, loads it on mount when there is none, and loads it again every five minutes, so an
510
- open tab picks up a change. A failed load keeps what it has. `useBranding()` gives `branding`,
511
- `logoUrl` as a `data:` URL or `null`, and `refresh()`.
571
+ ### Services
512
572
 
513
- ```tsx
514
- "use client";
515
- import { BrandingProvider, useBranding } from "@sjolystinnovation/app-kit/branding";
573
+ The services call the endpoints that the SjolystInnovation.AppKit .NET packages map, so you do not
574
+ write them in the app. `api` is a `createApiClient` instance, and `publicApi` is a
575
+ `createPublicClient` one. A failure throws an `Error` with the API's message, or with the service's
576
+ own English fallback. The error keeps the field errors for `apiFieldErrors` and the code for
577
+ `apiErrorCode`. `onSaved` runs after a save succeeds, such as
578
+ `() => revalidateTarget("branding")`, with the `revalidate` that
579
+ [`defineRevalidateTargets`](#definerevalidatetargets) returns.
580
+
581
+ | Factory | Methods | .NET package |
582
+ | --- | --- | --- |
583
+ | `createUserService(api, publicApi)` | `requestPasswordReset`, `resetPassword`, and for the signed-in user `getProfile`, `updateProfile`, `changePassword`, `exportData`, and `deleteAccount` | Auth |
584
+ | `createAdminUsersService(api)` | `getUsers(includeDeleted?)`, `roles`, `addRole`, `removeRole`, `deleteUser`, and `invite({ email, firstName, lastName, role })` | Auth |
585
+ | `createSettingsService(api, { onSaved? })` | `get` and `update` | SiteSettings |
586
+ | `createBrandingService(api, publicApi, { onSaved? })` | `get`, which answers `{}` when the API cannot be read, and `update(form)` | SiteSettings |
587
+ | `createLegalService(api, publicApi, { keys?, onSaved? })` | `keys`, `get(key, locale)`, which answers `null` for a missing page, `listAll`, and `save({ key, locale, title, bodyMarkdown })` | SiteSettings |
588
+ | `createEmailStatsService(api)` | `get(days?)`, the email stats for the last `days` days, 30 by default | Email |
589
+
590
+ `roles` gives the roles an admin may give, from `GET /users/roles`, in the order the API configures
591
+ them.
516
592
 
517
- <BrandingProvider load={BrandingService.get} initial={initialBranding}>{children}</BrandingProvider>;
593
+ The legal pages follow the API's model. `MapAppKitLegalPages` has a `Keys` option that defaults to
594
+ `terms`, `privacy`, and `cookies`. `createLegalService` takes the same keys as `keys`, with the same
595
+ default, `LEGAL_PAGE_KEYS`. The key type of `get`, `save`, and `LegalPage` comes from them, and the
596
+ service's `keys` feeds [`LegalPagesEditor`](#legalpageseditor-and-localetabs) and `legalEntries`:
518
597
 
519
- const { logoUrl } = useBranding();
520
- <img src={logoUrl ?? "/logo.png"} alt="" />;
598
+ ```ts
599
+ // The API maps the same keys: app.MapAppKitLegalPages<AppDbContext>(o => o.Keys = ["terms", "privacy", "refunds"]);
600
+ export default createLegalService(api, publicApi, { keys: ["terms", "privacy", "refunds"], onSaved });
521
601
  ```
522
602
 
523
- `BrandingManager` is the admin form for uploading or removing them. It needs a `BrandingProvider`
524
- above it, and refreshes it after a save.
603
+ ```ts
604
+ // src/services/userService.ts
605
+ import { requireEnv } from "@sjolystinnovation/app-kit";
606
+ import { createApiClient, createPublicClient, createUserService } from "@sjolystinnovation/app-kit/api";
525
607
 
526
- ```tsx
527
- <BrandingManager save={form => BrandingService.update(form)} />
608
+ const apiUrl = requireEnv("NEXT_PUBLIC_API_URL", process.env.NEXT_PUBLIC_API_URL);
609
+
610
+ export default createUserService(createApiClient(apiUrl), createPublicClient(apiUrl));
528
611
  ```
529
612
 
530
- | Prop | Type | Description |
531
- | --- | --- | --- |
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. |
533
- | `strings` | `TextOverrides<BrandingManagerStrings>` | The form's text. |
613
+ The package also exports the response types `UserProfile`, `AdminUser`, `SiteSettings`,
614
+ `LegalPage`, and `EmailStats`, the `OnSaved` callback type, and the `LegalServiceOptions` type.
534
615
 
535
- ### `@sjolystinnovation/app-kit/branding/server`
616
+ ### `mergeCompanyInfo` and `formatOrgNumber`
536
617
 
537
- `brandingIcon({ load, fallbackPath, fallbackType })` is the response for an `app/icon.ts` route:
538
- the favicon set in admin settings when it is a PNG, JPEG, WebP, GIF, or ICO, or the bundled file
539
- otherwise, since an SVG or HTML upload could run script on the site's origin. Both carry `nosniff`
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.
618
+ `mergeCompanyInfo(data, fallback)` gives the company details for public pages: the API's `/company`
619
+ answer, with the app's own value for each field it left blank or could not give. Only the
620
+ fallback's name is required. The legal name falls back to the name, and VAT to not registered.
621
+
622
+ `formatOrgNumber({ orgNumber, vatRegistered })` adds ` MVA` once the business is VAT registered, and
623
+ gives an empty string without a number.
543
624
 
544
625
  ```ts
545
- // src/app/icon.ts
546
- import { join } from "node:path";
547
- import { brandingIcon } from "@sjolystinnovation/app-kit/branding/server";
626
+ // src/lib/companyInfo.ts
627
+ import { formatOrgNumber, mergeCompanyInfo, type CompanyInfo } from "@sjolystinnovation/app-kit/api";
548
628
 
549
- export const dynamic = "force-dynamic";
629
+ export const getCompanyInfo = async () =>
630
+ mergeCompanyInfo(await publicGetOptional<CompanyInfo>("/company"), { name: "Example AS" });
550
631
 
551
- export default function Icon() {
552
- return brandingIcon({
553
- load: () => publicGet<Branding>("/branding", { tags: ["branding"] }),
554
- fallbackPath: join(process.cwd(), "public", "icon.png"),
555
- fallbackType: "image/png",
556
- });
557
- }
632
+ export const orgLine = (company: CompanyInfo) => formatOrgNumber(company);
558
633
  ```
559
634
 
560
- The route needs `dynamic = "force-dynamic"`, or the build bakes in the icon of the moment. Tag the
561
- fetch and revalidate the tag after a save, so a new icon shows at once. `iconVersion(data)` is a
562
- short digest of the icon bytes, for a manifest icon URL such as `/icon?v=<digest>`, since Android
563
- refreshes a home screen icon only when the manifest changes.
635
+ The `CompanyInfo` and `CompanyFallback` types describe the two shapes.
564
636
 
565
- ### `@sjolystinnovation/app-kit/payments`
637
+ ### `defineRevalidateTargets`
566
638
 
567
- The page a user comes back to from a payment provider, such as after approving a Vipps payment or
568
- agreement. The .NET side of the Vipps calls is `SjolystInnovation.AppKit.Vipps` on NuGet.
639
+ `defineRevalidateTargets({ locales, targets })` keeps the revalidate targets in one file that the
640
+ admin services and the revalidate route both import. `pages` are below each locale segment, and
641
+ `root` paths are not. It returns `targets` for fetch tags, `paths` for
642
+ [`createRevalidateRoute`](#createrevalidateroute), and `revalidate(target)` for the services'
643
+ `onSaved`. `revalidate` posts the target to `/api/revalidate` and never throws. On failure, the
644
+ pages' own revalidate window is the fallback.
569
645
 
570
- `usePollUntilFinal(fetcher, isFinal, { retries, delayMs })` fetches until the value is final, since
571
- the provider may confirm a moment after the user is back. It polls every 4 seconds, 8 more times at
572
- most, and stops on a failed fetch and on unmount. It gives `data`, `loading` and `refresh()`, and
573
- each fetch uses the latest `fetcher` and `isFinal`.
646
+ ```ts
647
+ // src/lib/cacheTags.ts
648
+ import { defineRevalidateTargets } from "@sjolystinnovation/app-kit/api";
574
649
 
575
- `PaymentReturn` shows the outcome: confirmed, still pending, or failed. It takes the text and the way
576
- on for each state. When you pass `onRefresh`, it shows a refresh button while pending.
650
+ export const { targets: REVALIDATE_TARGETS, paths: TARGET_PATHS, revalidate: revalidateTarget } = defineRevalidateTargets({
651
+ locales: ["no", "en"],
652
+ targets: {
653
+ legal: { pages: ["/terms", "/privacy", "/cookies"], root: ["/sitemap.xml"] },
654
+ // Every page renders branding through the layout, so its tag reaches them all.
655
+ branding: {},
656
+ },
657
+ });
658
+ ```
577
659
 
578
- ```tsx
579
- "use client";
580
- import { PaymentReturn, usePollUntilFinal } from "@sjolystinnovation/app-kit/payments";
660
+ The package also exports the `RevalidateTargetsConfig` and `RevalidateTargetPaths` types.
661
+
662
+ ## `@sjolystinnovation/app-kit/server`
581
663
 
582
- const { data: order, loading, refresh } = usePollUntilFinal(
583
- () => ShopService.getOrder(orderId),
584
- o => o != null && o.status !== "Pending",
664
+ For server components and route handlers only. It imports `next/cache` and `next/server`.
665
+
666
+ ### `createPublicApi`
667
+
668
+ `createPublicApi(baseUrl, options?)` returns cached GETs against the API's anonymous endpoints:
669
+
670
+ - `publicGet(path, { tags? })` gives the data, or `null` when the API answers 404. An unreachable or
671
+ failing API throws `PublicApiError`, so a failed fetch is never cached as an empty page in place
672
+ of a working one.
673
+ - `publicGetOptional(path, { tags? })` gives `null` on any failure, for data a page can do without.
674
+ - `publicGetWithMeta(path, { tags? })` also gives `lastModified`, from the `Last-Modified` header.
675
+
676
+ Responses stay fresh for 60 seconds unless `options.revalidate` says otherwise. An empty `baseUrl`
677
+ throws. Each path starts with a single `/`, or the call throws. A path with a `.` or `..` segment,
678
+ encoded or not, gives `null` without a request, the same as a 404. Run a slug or other user input
679
+ through `encodeURIComponent` before you put it in a path.
680
+
681
+ ```ts
682
+ // src/lib/publicApi.ts
683
+ import { requireEnv } from "@sjolystinnovation/app-kit";
684
+ import { createPublicApi } from "@sjolystinnovation/app-kit/server";
685
+
686
+ export const { publicGet, publicGetOptional, publicGetWithMeta } = createPublicApi(
687
+ requireEnv("NEXT_PUBLIC_API_URL", process.env.NEXT_PUBLIC_API_URL),
585
688
  );
586
- if (loading) return <Spinner />;
587
-
588
- <PaymentReturn
589
- state={order?.status === "Paid" ? "success" : order?.status === "Pending" ? "pending" : "failed"}
590
- success={{ title: "Payment received!", body: "Your order is confirmed.", href: "/shop", cta: "Back to shop" }}
591
- pending={{ title: "Payment pending", body: "Vipps is confirming it.", note: "We'll email a receipt.", href: "/profile/billing", cta: "View billing" }}
592
- failed={{ title: "Payment failed", body: "No charge was made.", href: "/shop", cta: "Try again" }}
593
- onRefresh={refresh}
594
- />;
595
689
  ```
596
690
 
597
- | Prop | Type | Description |
598
- | --- | --- | --- |
599
- | `state` | `"success" \| "pending" \| "failed"` | Which content to show. |
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. |
601
- | `onRefresh` | `() => void` | Shows a refresh button while pending. |
602
- | `refreshLabel` | `string` | Its text. Defaults to `Check again`. |
603
- | `className` | `string` | Classes for placing it, on the outer element. |
691
+ The package also exports the `PublicApiOptions`, `PublicGetOptions`, and `PublicResponse` types.
604
692
 
605
- `formatNok(ore)` formats øre as kroner, such as `NOK 373,75`.
693
+ ### `createRevalidateRoute`
606
694
 
607
- ### `@sjolystinnovation/app-kit/admin`
695
+ `createRevalidateRoute({ targets, role })` returns a `POST` handler for `app/api/revalidate/route.ts`.
696
+ Each target maps to the page paths it purges. A path with a dynamic segment, such as
697
+ `/builds/[slug]`, purges every page of that route. The route also purges the target name as a cache
698
+ tag, so tag the fetches behind it with the same name.
608
699
 
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.
700
+ The handler answers 403 unless the caller has a live session with `role`: signed in within the last
701
+ seven days, with no refused refresh, and with an access token that has not expired. It answers 415
702
+ unless the caller sends the body as JSON, which the `revalidate` from `defineRevalidateTargets`
703
+ does. It answers 400 for a target it does not know.
612
704
 
613
- ```tsx
614
- <CompanySettingsForm service={SettingsService} strings={dict.companySettings}>
615
- <BrandingManager save={BrandingService.update} />
616
- </CompanySettingsForm>
705
+ ```ts
706
+ // src/app/api/revalidate/route.ts
707
+ import { createRevalidateRoute } from "@sjolystinnovation/app-kit/server";
708
+ import { TARGET_PATHS } from "@/lib/cacheTags";
709
+
710
+ export const POST = createRevalidateRoute({ targets: TARGET_PATHS, role: "Admin" });
617
711
  ```
618
712
 
619
- The admin area. `AdminShell` is the frame: a title and a tab bar above the page, behind a
620
- `RoleGate`. It takes `RoleGate`'s `role`, `fallback`, and `denied` or `deniedHref`. The tab whose
621
- `href` is the current path is active, with `aria-current="page"`.
713
+ `targets` can also be a plain object, such as
714
+ `{ builds: ["/builds", "/builds/[slug]", "/sitemap.xml"] }`. The package also exports the
715
+ `RevalidateRouteOptions` type.
716
+
717
+ ## `@sjolystinnovation/app-kit/forms`
718
+
719
+ `useFormDraft(config, options)` keeps a form's values in `localStorage`, so a sign-out, reload, or
720
+ closed tab does not lose them. The hook writes values half a second after the last change, so only
721
+ that last half second is at risk.
622
722
 
623
723
  ```tsx
624
- // src/app/[locale]/(protected)/admin/layout.tsx
625
- "use client";
626
- import { AdminShell } from "@sjolystinnovation/app-kit/admin";
724
+ // userId is the signed-in user's id, from wherever the app's session keeps it.
725
+ const draft = useFormDraft(sessionConfig, {
726
+ owner: userId,
727
+ scope: recipe ? `recipe:${recipe.id}` : "recipe:new",
728
+ value: form,
729
+ });
730
+ ```
627
731
 
628
- export default function AdminLayout({ children }: { children: React.ReactNode }) {
629
- const { dict, locale } = useDictionary();
630
- const tabs = [
631
- { href: `/${locale}/admin/users`, label: dict.admin.users },
632
- { href: `/${locale}/admin/legal`, label: dict.admin.legal },
633
- ];
634
- return (
635
- <AdminShell role="Admin" title={dict.admin.title} tabs={tabs} deniedHref={`/${locale}`}>
636
- {children}
637
- </AdminShell>
638
- );
639
- }
732
+ | Option | Type | Description |
733
+ | --- | --- | --- |
734
+ | `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. |
735
+ | `scope` | `string` | The form and the entity it edits, such as `recipe:42`. |
736
+ | `value` | `T` | The form's current values. They must survive `JSON.stringify`. |
737
+
738
+ Mount an edit form once the entity has loaded, with its values already in `value`. The hook treats
739
+ the values it first sees under a key as the untouched form, so values that arrive later count as an
740
+ edit and overwrite the draft on offer.
741
+
742
+ It returns a `FormDraft`:
743
+
744
+ - `pending`, the stored draft. The page offers it to the user and does not apply it. The hook reads
745
+ it when the owner is first known, and again whenever the owner or `scope` changes, so it can
746
+ appear after the form mounts.
747
+ - `dismiss()`, to stop offering it.
748
+ - `clear()`, to drop the stored draft after a successful save.
749
+
750
+ The hook stores drafts under `<draftStoragePrefix>:draft:<owner>:<scope>`. It throws when
751
+ `draftStoragePrefix` is empty or contains a colon. It does not offer a draft older than 7 days, and
752
+ removes that draft when its form next reads it. The options type is `FormDraftOptions`.
753
+
754
+ ## `@sjolystinnovation/app-kit/toast`
755
+
756
+ sonner's `toast`, except that `toast.error` shows nothing while the re-sign-in prompt is open. The
757
+ prompt already says why the request failed. Every other toast passes through, and so do the error
758
+ states of `toast.promise`. Render sonner's `Toaster` in the app as usual.
759
+
760
+ ```ts
761
+ import { toast } from "@sjolystinnovation/app-kit/toast";
762
+
763
+ toast.error("Could not save.");
640
764
  ```
641
765
 
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.
766
+ ## `@sjolystinnovation/app-kit/validation`
647
767
 
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
- />
768
+ ### `passwordSchema`
769
+
770
+ `passwordSchema(strings?)` is a zod schema for a new password, with the rules the APIs enforce
771
+ through ASP.NET Identity: at least 8 characters, with a lowercase letter, an uppercase letter, and a
772
+ digit. No symbol is needed. Identity counts only ASCII, so the letters are A to Z and the digits 0 to
773
+ 9. "Å" does not count as uppercase. Identity sets no upper limit. If the app's API sets one, add it
774
+ with `.max()`. A failing password gets one issue per rule it breaks, each with its message from
775
+ `strings`. Missing messages fall back to `defaultPasswordStrings`, which are English.
776
+
777
+ ```ts
778
+ import { passwordSchema } from "@sjolystinnovation/app-kit/validation";
779
+
780
+ const result = passwordSchema({ uppercase: "Ta med en stor bokstav fra A til Z." }).safeParse(newPassword);
781
+ const withLimit = passwordSchema().max(128, "Be at most 128 characters long.");
657
782
  ```
658
783
 
659
- `AdminUsersPanel` is the users page. It lists every user with their roles, shows deleted users
660
- behind a toggle, gives and takes roles, deletes an account after a confirm, and invites a user by
661
- email. It calls the API through `createAdminUsersService`. `getRoles` returns the roles an admin
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`.
784
+ `PASSWORD_MIN_LENGTH` holds the minimum, for hint text. `passwordProblems(password, strings?)` gives
785
+ the message of every rule a password breaks, in the same order, without zod. The messages type is
786
+ `PasswordStrings`.
664
787
 
665
- ```tsx
666
- import { AdminUsersPanel } from "@sjolystinnovation/app-kit/admin";
788
+ ### `zodFieldErrors`
667
789
 
668
- <AdminUsersPanel service={adminUsers} getRoles={AdminService.getRoles} strings={dict.adminUsers} />
790
+ `zodFieldErrors(error, options?)` groups a failed parse's issues by the top-level field they belong
791
+ to, for showing beside each input. Each field gets its first issue's message, or every message in
792
+ order with `all: true`. `message` replaces zod's text, such as with the app's own translations. Its
793
+ type is `IssueMessage`. Issues that belong to no field are left out.
794
+
795
+ ```ts
796
+ import { zodFieldErrors } from "@sjolystinnovation/app-kit/validation";
797
+
798
+ const parsed = contactSchema.safeParse(form);
799
+ if (!parsed.success) setErrors(zodFieldErrors(parsed.error, { message: issue => issueMessage(issue, dict) }));
669
800
  ```
670
801
 
671
- | Prop | Type | Description |
672
- | --- | --- | --- |
673
- | `service` | `ReturnType<typeof createAdminUsersService>` | The user admin calls. |
674
- | `getRoles` | `() => Promise<string[]>` | The roles an admin may give. |
675
- | `defaultInviteRole` | `string` | The role a new invite starts with. Defaults to `Admin`. |
676
- | `strings` | `TextOverrides<AdminUsersPanelStrings>` | The text, English by default. The panel fills in `{name}` and `{role}` where a text names them. |
802
+ ## `@sjolystinnovation/app-kit/ui`
677
803
 
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.
804
+ Form controls, alerts, images, and error pages. The components use Tailwind CSS and take their look
805
+ from the [theme variables](#theming).
806
+
807
+ ### `TextInput`
808
+
809
+ A labeled text field with the same look and props as `PasswordInput`, apart from the show and hide
810
+ toggle. It takes the other `<input>` attributes too.
681
811
 
682
812
  ```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
- />
813
+ <TextInput label="Email" name="email" type="email" required error={errors.email} />
691
814
  ```
692
815
 
693
- ### `@sjolystinnovation/app-kit/legal`
816
+ ### `PasswordInput`
694
817
 
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.
818
+ A labeled password field with a button that shows and hides the value. It also takes the other
819
+ `<input>` attributes, apart from `type`, which the component switches itself.
699
820
 
700
821
  ```tsx
701
- // src/app/[locale]/terms/page.tsx
702
- export const revalidate = 3600;
822
+ <PasswordInput label="Password" name="password" required error={errors.password} />
823
+ ```
703
824
 
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} />;
708
- }
825
+ | Prop | Type | Default | Description |
826
+ | --- | --- | --- | --- |
827
+ | `label` | `string` | | The visible label, tied to the input. |
828
+ | `error` | `string` | | The message under the field. It turns the border red, marks the input invalid, and screen readers read it out with the field. |
829
+ | `showPasswordLabel` | `string` | `"Show password"` | The toggle's accessible name while the value is hidden. |
830
+ | `hidePasswordLabel` | `string` | `"Hide password"` | The toggle's accessible name while the value is visible. |
831
+ | `className` | `string` | | Classes for placing the field, such as a margin. They go on the outer element around the label, input, and error. Restyle the field through the theme variables. |
832
+
833
+ Pass the two toggle labels in the app's language. The defaults are English.
834
+
835
+ ### `TextArea`
836
+
837
+ A labeled multi-line field with the same look, label, and error handling as `TextInput`. It takes
838
+ the other `<textarea>` attributes too, and works controlled or uncontrolled.
839
+
840
+ ```tsx
841
+ <TextArea label="Description" name="description" rows={4} maxRows={10} error={errors.description} />
709
842
  ```
710
843
 
711
- ### `@sjolystinnovation/app-kit/session`
844
+ | Prop | Type | Default | Description |
845
+ | --- | --- | --- | --- |
846
+ | `label` | `string` | | The visible label, tied to the text area. |
847
+ | `error` | `string` | | The message under the field, which screen readers read out with it. |
848
+ | `autoGrow` | `boolean` | `true` | Whether the box grows with its text from `rows` lines up to `maxRows`, with no resize handle. With `false` it keeps its size and shows the handle. |
849
+ | `rows` | `number` | `3` | The lines shown when empty. |
850
+ | `maxRows` | `number` | `12` | The most lines an auto-growing box shows before it scrolls. |
851
+ | `className` | `string` | | Classes for placing the field, on the outer element. |
712
852
 
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.
853
+ ### `Toggle`
715
854
 
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.
855
+ An on and off switch for a setting that takes effect at once. It takes the other `<button>`
856
+ attributes too.
721
857
 
722
- `SIGN_IN_ERRORS` holds the two codes a failed sign-in reports: `InvalidCredentials` and
723
- `SignInUnavailable`. Map them to the app's own messages.
858
+ ```tsx
859
+ <>
860
+ <Toggle label="Email notifications" checked={enabled} onChange={setEnabled} />
861
+ <Toggle aria-label="VAT" checked={vat} onChange={setVat} disabled={saving} />
862
+ </>
863
+ ```
724
864
 
725
- `SESSION_ERRORS` holds the values the jwt callback puts in `token.error` when the session cannot be
726
- recovered: `AbsoluteSessionExpired`, `RefreshTokenRejected` and `NoRefreshToken`. `SessionError`
727
- is their union type. `isTerminalSessionError(error)` is true for those three only, so a transient
728
- refresh failure keeps the session.
865
+ | Prop | Type | Description |
866
+ | --- | --- | --- |
867
+ | `checked` | `boolean` | Whether the switch is on. |
868
+ | `onChange` | `(checked: boolean) => void` | Called with the new state. A callback that ignores it also fits. |
869
+ | `label` | `string` | The visible text after the switch. It names the switch, and a click on it flips the switch. |
870
+ | `aria-label` | `string` | The switch's name when there is no `label`. One of the two is required. |
871
+ | `disabled` | `boolean` | Whether the switch is grayed out and ignores clicks. |
872
+ | `className` | `string` | Classes for placing it, on the outer element. |
729
873
 
730
- The re-sign-in prompt is a flag outside React, so an axios interceptor can raise it:
874
+ An `onClick` that calls `event.preventDefault()` stops the change.
731
875
 
732
- | Function | Description |
733
- | --- | --- |
734
- | `openSessionPrompt()` | Asks the user to sign in again without leaving the page. |
735
- | `closeSessionPrompt()` | Hides the prompt. Call it when the component showing the prompt unmounts, or the flag stays open and the gate never redirects. |
736
- | `subscribeSessionPrompt(listener)` | Calls `listener` when the prompt opens or closes. Returns an unsubscribe function. |
737
- | `getSessionPromptOpen()` | Whether the prompt is showing. |
876
+ ### `Alert`
738
877
 
739
- Copies of the package installed side by side share the one flag, as long as their releases use the
740
- same prompt state format. A release that changes the format says so in its changelog. An app's own
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.
878
+ A colored message box.
743
879
 
744
- ### `AppSessionProvider`
880
+ ```tsx
881
+ <Alert variant="error">{message}</Alert>
882
+ ```
883
+
884
+ | Prop | Type | Default | Description |
885
+ | --- | --- | --- | --- |
886
+ | `variant` | `"error" \| "success" \| "info" \| "warning"` | `"info"` | The color of the box. |
887
+ | `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. |
888
+ | `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. |
889
+ | `children` | `ReactNode` | | The content. |
745
890
 
746
- From `@sjolystinnovation/app-kit/session/react`. next-auth's `SessionProvider`, reading the session
747
- again every four minutes. Each read lets the jwt callback renew the access token before it expires,
748
- so an open tab does not find a dead session on the next save. It also keeps what `useSession`
749
- reports in this tab up to date. next-auth polls only once a session exists.
891
+ ### `ContentImage`
892
+
893
+ An uploaded image from the content API, as a plain `<img>` with a `srcset` of the widths the API
894
+ serves. It goes through the content images rewrite from `defineAppConfig({ contentImages: true })`,
895
+ so the browser never contacts the API host. It works in a server component.
750
896
 
751
897
  ```tsx
752
- <AppSessionProvider>{children}</AppSessionProvider>
898
+ <>
899
+ <ContentImage imageId={post.imageId} alt={post.title} sizes="(min-width: 768px) 50vw, 100vw" />
900
+ <ContentImage imageId={hero.imageId} fallbackSrc="/hero.jpg" alt="" sizes="100vw" priority aria-hidden />
901
+ </>
753
902
  ```
754
903
 
755
- | Prop | Type | Description |
756
- | --- | --- | --- |
757
- | `refetchInterval` | `number` | Seconds between reads. Defaults to 240. Keep it under five minutes, the time before expiry when a refresh falls due. |
904
+ | Prop | Type | Default | Description |
905
+ | --- | --- | --- | --- |
906
+ | `imageId` | `string \| null` | | The uploaded image's id. `null` or empty shows `fallbackSrc` as it is, with no `srcset`. |
907
+ | `alt` | `string` | | The alternative text. Empty for a decorative image. |
908
+ | `sizes` | `string` | | The widths the image is shown at. Required, since a browser assumes `100vw` without it. |
909
+ | `fallbackSrc` | `string` | | A static path for when there is no uploaded image. With neither, nothing renders. |
910
+ | `priority` | `boolean` | `false` | Whether this is the one image visible without scrolling. It loads eagerly, at high priority. |
911
+ | `width`, `height` | `number` | | The intrinsic size, to reserve space where the layout fixes no ratio. |
912
+ | `base` | `string` | `"/content-images"` | The path the content images rewrite serves. |
913
+ | `widths` | `number[]` | `[384, 640, 768, 1024, 1366, 1600]` | The widths in the `srcset`. The default matches the API's default variant widths. |
914
+ | `className` | `string` | | Classes on the `<img>`. |
758
915
 
759
- ### `ProtectedGate`
916
+ `contentImagePath(id, base?)` and `contentImageSrcSet(id, widths?, base?)` build the same URLs for
917
+ other uses, such as a CSS background or an Open Graph image.
760
918
 
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.
919
+ ### `ErrorState`
764
920
 
765
- A page that rendered on a healthy session stays rendered, so an open form keeps its values:
921
+ What an `error.tsx` shows. It is a client component, as an error boundary has to be. It never shows
922
+ the error itself.
766
923
 
767
- - when the session gets a terminal error
768
- - while the session is re-read
769
- - while the prompt recovers it
924
+ ```tsx
925
+ // src/app/error.tsx
926
+ "use client";
927
+ import { ErrorState } from "@sjolystinnovation/app-kit/ui";
928
+
929
+ export default function Error({ reset }: { error: Error; reset: () => void }) {
930
+ return <ErrorState reset={reset} homeHref="/" />;
931
+ }
932
+ ```
770
933
 
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.
934
+ | Prop | Type | Description |
935
+ | --- | --- | --- |
936
+ | `reset` | `() => void` | The `reset` that Next passes to `error.tsx`. The try again button calls it. |
937
+ | `homeHref` | `string` | Where the front page link goes, such as `/` or `/en`. |
938
+ | `strings` | `TextOverrides<ErrorStateStrings>` | `title`, `body`, `tryAgain`, and `home`. |
939
+ | `className` | `string` | Classes for placing it, on the outer element. |
940
+
941
+ ### `NotFoundState`
942
+
943
+ What a `not-found.tsx` shows: a large code, the message as the page's heading, and a link home. It
944
+ works in a server component.
774
945
 
775
946
  ```tsx
776
- <ProtectedGate loginHref="/login" fallback={<p>Loading…</p>}>
777
- {children}
778
- </ProtectedGate>
947
+ <NotFoundState homeHref="/no" strings={{ body: dict.notFound, home: dict.toFrontPage }} />
779
948
  ```
780
949
 
781
950
  | Prop | Type | Description |
782
951
  | --- | --- | --- |
783
- | `loginHref` | `string` | Where the redirect goes. An app with a locale in the path builds it for the current locale. |
784
- | `fallback` | `ReactNode` | What shows while the page may not render yet. Defaults to nothing. |
952
+ | `homeHref` | `string` | Where the home link goes. |
953
+ | `strings` | `TextOverrides<NotFoundStateStrings>` | `code`, `body`, and `home`. |
954
+ | `className` | `string` | Classes for placing it, on the outer element. |
785
955
 
786
- ### `RoleGate`
956
+ ### `ResetPasswordFlow`
787
957
 
788
- From `@sjolystinnovation/app-kit/session/react`. Renders its children only for a user with `role`.
789
- Place it inside `ProtectedGate`. While the re-sign-in prompt recovers a signed-out session, there is
790
- no user and so no roles. The page stays up only on the path where the session last had the role.
958
+ The password reset page in two steps: the email asks for a code, then the code and a new password
959
+ set it. Before it sends the password, the flow checks it against the same rules as
960
+ `passwordSchema`, without loading zod. An emailed link with the email and code, such as an invite,
961
+ opens it on the second step. Its colors and title size come from the `auth` and `button` theme
962
+ variables.
791
963
 
792
964
  ```tsx
793
- <RoleGate role="Admin" fallback={<p>Loading…</p>} denied={<p>You do not have access.</p>}>
794
- {children}
795
- </RoleGate>
965
+ const params = useSearchParams();
966
+
967
+ <ResetPasswordFlow
968
+ requestReset={UserService.requestPasswordReset}
969
+ reset={UserService.resetPassword}
970
+ loginHref="/login"
971
+ initialEmail={params.get("email") ?? ""}
972
+ initialCode={params.get("code") ?? ""}
973
+ strings={{ title: "Tilbakestill passord", signInPrompt: null }}
974
+ />;
796
975
  ```
797
976
 
798
977
  | Prop | Type | Description |
799
978
  | --- | --- | --- |
800
- | `role` | `string` | The role the page needs, as it appears in `session.user.roles`. |
801
- | `fallback` | `ReactNode` | What shows while the session or its roles are not known yet. Defaults to nothing. |
802
- | `denied` | `ReactNode` | What a signed-in user without the role sees. Give this or `deniedHref`. |
803
- | `deniedHref` | `string` | Where a signed-in user without the role is sent instead. |
979
+ | `requestReset` | `(data: { email }) => Promise<unknown>` | Asks the API to email a code. |
980
+ | `reset` | `(data: { email, code, newPassword }) => Promise<unknown>` | Sets the password. When it succeeds, the flow shows the `message` in its answer. |
981
+ | `loginHref` | `string` | Where the sign-in links go. |
982
+ | `initialEmail`, `initialCode` | `string` | The values from the emailed link. A code opens the second step. |
983
+ | `className` | `string` | Replaces the outer `max-w-md mx-auto px-4 py-16`, such as to sit in the app's own card. |
984
+ | `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. `validation` and `errors` may name only some codes. |
985
+ | `passwordStrings` | `TextOverrides<PasswordStrings>` | The rule messages, in the app's language. |
804
986
 
805
- ### `SessionExpiryGuard`
987
+ A password that breaks the rules is marked under the new password field, without a call to the API.
988
+ If the API refuses with a validation problem, the flow shows each error under its field. A field
989
+ error for a field the step does not show, and any other failure, goes in the message above the form.
990
+ The API's codes show in the app's language:
806
991
 
807
- From `@sjolystinnovation/app-kit/session/react`. Render it once, inside the `SessionProvider`. It
808
- shows:
992
+ - `strings.validation` holds the field errors: `required`, `invalidEmail`, `passwordTooShort`,
993
+ `passwordRequiresDigit`, `passwordRequiresLower`, `passwordRequiresUpper`, and
994
+ `passwordRequiresNonAlphanumeric`. `{max}` in `passwordTooShort` becomes the API's least number of
995
+ characters.
996
+ - `strings.errors` holds the other refusals: `invalidResetCode` and `tooManyResetAttempts`.
809
997
 
810
- - a banner in the last 30 minutes before `session.absoluteExpiresAt`, with the minutes left. That is
811
- seven days after sign-in with `createAuthOptions`.
812
- - a banner when the session is dead
813
- - the re-sign-in prompt, a dialog with `CredentialsForm`, when a request or the banner opens it
998
+ A code the app leaves out keeps its English default, which is the API's English message. For an
999
+ unknown code, such as `weakPassword` from a password rule the app adds, the flow shows the API's
1000
+ message. A failure without a message shows `somethingWentWrong`. The types of these objects are
1001
+ `ResetPasswordValidationStrings` and `ResetPasswordErrorStrings`.
814
1002
 
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.
1003
+ ## `@sjolystinnovation/app-kit/admin`
1004
+
1005
+ The admin area. The forms report with `toast`, so the app needs sonner and a `Toaster`.
1006
+
1007
+ ### `AdminShell`
1008
+
1009
+ The frame: a title and a tab bar above the page, behind a `RoleGate`. It takes `RoleGate`'s `role`,
1010
+ `fallback`, and `denied` or `deniedHref`. The tab whose `href` is the current path is active, with
1011
+ `aria-current="page"`. Each tab is an `AdminTab`, `{ href, label }`.
818
1012
 
819
1013
  ```tsx
820
- <SessionExpiryGuard
821
- loginHref="/login"
822
- onRestored={() => toast.success("You are signed in again.")}
823
- />
1014
+ // src/app/[locale]/(protected)/admin/layout.tsx
1015
+ "use client";
1016
+ import type { ReactNode } from "react";
1017
+ import { AdminShell } from "@sjolystinnovation/app-kit/admin";
1018
+
1019
+ export default function AdminLayout({ children }: { children: ReactNode }) {
1020
+ const { dict, locale } = useDictionary();
1021
+ const tabs = [
1022
+ { href: `/${locale}/admin/users`, label: dict.admin.users },
1023
+ { href: `/${locale}/admin/legal`, label: dict.admin.legal },
1024
+ ];
1025
+ return (
1026
+ <AdminShell role="Admin" title={dict.admin.title} tabs={tabs} deniedHref={`/${locale}`}>
1027
+ {children}
1028
+ </AdminShell>
1029
+ );
1030
+ }
1031
+ ```
1032
+
1033
+ ### Field errors in the admin forms
1034
+
1035
+ `CompanySettingsForm`, `LegalPagesEditor`, and [`BrandingManager`](#brandingmanager) show an API
1036
+ validation error under the field it belongs to, and toast the first one. A successful save clears
1037
+ them. Each form's `strings.validation` holds the text for each error code that
1038
+ SjolystInnovation.AppKit.SiteSettings sends. `validation` may name only some codes, and a code the
1039
+ app leaves out keeps its English default. `{max}` in a text becomes the field's limit. For an
1040
+ unknown code, or an API that sends no codes, the form shows the API's message.
1041
+
1042
+ | Form | Codes in `strings.validation` |
1043
+ | --- | --- |
1044
+ | `CompanySettingsForm` | `required`, `tooLong`, `invalidEmail`, `orgNumberRequiredForVat`, `invalidOrgNumber`, `invalidPostalCode`, and `placeRequired` |
1045
+ | `LegalPagesEditor` | `required` and `tooLong` |
1046
+ | `BrandingManager` | `invalidImage` and `imageTooLarge` |
1047
+
1048
+ The types of these objects are `CompanySettingsValidationStrings`, `LegalPagesValidationStrings`, and
1049
+ `BrandingValidationStrings`.
1050
+
1051
+ ### `CompanySettingsForm`
1052
+
1053
+ The admin form for the business details that the public pages show, saved through
1054
+ `createSettingsService`. Its children show below the form once the settings have loaded, such as the
1055
+ `BrandingManager`. `strings` holds every label, hint, and message, English by default.
1056
+
1057
+ ```tsx
1058
+ <CompanySettingsForm
1059
+ service={SettingsService}
1060
+ strings={{
1061
+ save: "Lagre",
1062
+ validation: { invalidOrgNumber: "Ikke et gyldig organisasjonsnummer.", tooLong: "Maks {max} tegn." },
1063
+ }}
1064
+ >
1065
+ <BrandingManager save={BrandingService.update} />
1066
+ </CompanySettingsForm>
824
1067
  ```
825
1068
 
826
1069
  | Prop | Type | Description |
827
1070
  | --- | --- | --- |
828
- | `loginHref` | `string` | Where the guard sends a different user after it signs them out. |
829
- | `onRestored` | `() => void` | Runs after the prompt signs the user back in, such as to show a toast. |
830
- | `strings` | `TextOverrides<SessionExpiryGuardStrings>` | The guard's text. `{minutes}` in `expiringSoon` becomes the minutes left. The defaults are English. |
831
- | `formStrings` | `TextOverrides<CredentialsFormStrings>` | The text of the form in the prompt. |
1071
+ | `service` | `{ get, update }` | The settings calls, such as `createSettingsService(api)`. |
1072
+ | `strings` | `TextOverrides<CompanySettingsFormStrings>`, with `validation` as `TextOverrides<CompanySettingsValidationStrings>` | The form's text. |
1073
+ | `children` | `ReactNode` | The content below the form once the settings have loaded. |
832
1074
 
833
- ### `useFormDraft(config, options)`
1075
+ ### `LegalPagesEditor` and `LocaleTabs`
834
1076
 
835
- From `@sjolystinnovation/app-kit/forms`. Keeps a form's values in `localStorage`, so a sign-out,
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.
1077
+ `LegalPagesEditor` edits the legal pages one page and one locale at a time, through
1078
+ `createLegalService`. A locale tab whose page has no content yet gets a dot. A blank title or
1079
+ content is marked under its field with `strings.validation.required` and toasts `strings.required`,
1080
+ without a call to the API. An API validation error shows under its field while that page and locale
1081
+ are open. `{max}` in `tooLong` becomes the title's limit. The pages come from `keys`, else the
1082
+ service's `keys`, else `LEGAL_PAGE_KEYS`. A page missing from `keyLabels` takes its label from
1083
+ `defaultLegalPageKeyLabels`, the English labels of the three default pages, or else shows its key.
838
1084
 
839
1085
  ```tsx
840
- // userId is the signed-in user's id, from wherever the app's session keeps it.
841
- const draft = useFormDraft(sessionConfig, {
842
- owner: userId,
843
- scope: recipe ? `recipe:${recipe.id}` : "recipe:new",
844
- value: form,
845
- });
1086
+ <LegalPagesEditor
1087
+ service={LegalService}
1088
+ keyLabels={{ terms: "Vilkår", privacy: "Personvern", cookies: "Informasjonskapsler" }}
1089
+ locales={["no", "en"]}
1090
+ localeLabels={{ no: "Norsk", en: "English" }}
1091
+ strings={{ save: "Lagre", validation: { required: "Fyll ut feltet.", tooLong: "Maks {max} tegn." } }}
1092
+ />
846
1093
  ```
847
1094
 
848
- | Option | Type | Description |
1095
+ | Prop | Type | Description |
849
1096
  | --- | --- | --- |
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. |
851
- | `scope` | `string` | The form and the entity it edits, such as `recipe:42`. |
852
- | `value` | `T` | The form's current values. They must survive `JSON.stringify`. |
1097
+ | `service` | `{ keys?, listAll, save }` | The legal page calls, such as `createLegalService(api, publicApi)`. |
1098
+ | `keys` | `readonly K[]` | The pages, in tab order. The editor opens on the first. Defaults to the service's `keys`, else `LEGAL_PAGE_KEYS`. |
1099
+ | `keyLabels` | `Partial<Record<K, string>>` | Each page's tab label. A page left out uses `defaultLegalPageKeyLabels`, else its key. |
1100
+ | `locales` | `readonly L[]` | The locales each page is written in. The editor opens on the first. |
1101
+ | `localeLabels` | `Record<L, string>` | Each locale's tab label. |
1102
+ | `strings` | `TextOverrides<LegalPagesEditorStrings>`, with `validation` as `TextOverrides<LegalPagesValidationStrings>` | The editor's text. |
853
1103
 
854
- Mount an edit form once the entity has loaded, with its values already in `value`. The hook treats
855
- the values it first sees under a key as the untouched form, so values that arrive later count as an
856
- edit and overwrite the draft on offer.
1104
+ `LocaleTabs` is the same tab bar on its own, for other translated forms:
857
1105
 
858
- It returns:
859
-
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.
863
- - `dismiss()`, to stop offering it.
864
- - `clear()`, to drop the stored draft after a successful save.
1106
+ ```tsx
1107
+ <LocaleTabs locales={["no", "en"]} labels={{ no: "Norsk", en: "English" }} active={locale} onChange={setLocale} filled={{ no: true, en: false }} />
1108
+ ```
865
1109
 
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.
1110
+ ### `AdminUsersPanel`
869
1111
 
870
- ### `CredentialsForm`
1112
+ The users page. It lists every user with their roles, shows deleted users behind a toggle, gives
1113
+ and takes roles, deletes an account after a confirm, and invites a user by email. It calls the API
1114
+ through `createAdminUsersService`. The roles an admin may give come from the service's `roles`, or
1115
+ from `getRoles` when the app passes it. A service without `roles` needs `getRoles`. You can pass
1116
+ inline functions, because the panel does not fetch again when they change.
1117
+
1118
+ If the API refuses an invite with a validation problem, the panel shows each error under its field
1119
+ and toasts one of them. An error for a field the form does not show goes in the toast first. The
1120
+ API's codes show in the app's language:
871
1121
 
872
- From `@sjolystinnovation/app-kit/session/react`. Email and password sign-in through next-auth's credentials
873
- provider, with `redirect: false`, so the page stays where it is. Use it on the login page and in
874
- the prompt.
1122
+ - `strings.validation` holds the invite field errors: `required`, `tooLong`, `invalidEmail`,
1123
+ `unknownRole`, and `emailTaken`. `{max}` in `tooLong` becomes the field's limit.
1124
+ - `strings.errors` holds the role and invite refusals: `unknownRole`, `ownAdminRole`, `lastAdmin`,
1125
+ `roleAssignFailed`, `roleRemoveFailed`, and `emailTaken`.
1126
+ - `strings.deleteErrors` holds the delete refusals: `lastAdmin` and `accountChangeConflict`. The API
1127
+ sends `lastAdmin` for both a role and a delete, with a different message, so each has its own text.
1128
+
1129
+ A code the app leaves out keeps its English default, which is the API's English message. For an
1130
+ unknown code, the panel shows the API's message. The types of these objects are
1131
+ `AdminUsersValidationStrings`, `AdminUsersErrorStrings`, and `AdminUsersDeleteErrorStrings`.
875
1132
 
876
1133
  ```tsx
877
- <CredentialsForm onSignedIn={() => router.push("/")} strings={{ signIn: "Logg inn" }} />
1134
+ <AdminUsersPanel service={adminUsers} strings={dict.adminUsers} />
878
1135
  ```
879
1136
 
880
1137
  | Prop | Type | Description |
881
1138
  | --- | --- | --- |
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. |
883
- | `initialEmail` | `string` | The email the field starts with. |
884
- | `children` | `ReactNode` | Extra buttons next to the submit button. |
885
- | `strings` | `TextOverrides<CredentialsFormStrings>` | The form's text. The defaults are English. |
886
- | `onPendingChange` | `(pending: boolean) => void` | Runs when a sign-in starts and ends, such as to keep a dialog open until it finishes. |
1139
+ | `service` | `ReturnType<typeof createAdminUsersService>` | The user admin calls. |
1140
+ | `getRoles` | `() => Promise<string[]>` | The roles an admin may give. Defaults to the service's `roles`. |
1141
+ | `defaultInviteRole` | `string` | The role a new invite starts with. Defaults to `Admin`. |
1142
+ | `strings` | `TextOverrides<AdminUsersPanelStrings>` | The text. The panel fills in `{name}` and `{role}` where a text names them. `validation`, `errors`, and `deleteErrors` may name only some codes. |
887
1143
 
888
- A failed sign-in shows `signInUnavailable` for `SIGN_IN_ERRORS.unavailable` and
889
- `invalidCredentials` for any other error code. A request next-auth refuses without an error code,
890
- or one that throws, shows `somethingWentWrong`.
1144
+ ### `StatHistoryChart`
891
1145
 
892
- ### `LoginPage`
1146
+ Draws the admin stats over time: a line per series, a scale from zero to the highest value shown,
1147
+ and legend buttons that hide and show each line. `data` is one row per day, oldest first, with
1148
+ `date` as `YYYY-MM-DD`. A missing value counts as zero. Each series, a `StatSeries`, names the field
1149
+ it plots and its label. It takes the next color of a built-in palette of five unless it has its own
1150
+ `color`. `strings` holds `empty`, shown when there are no days, and `chart`, the accessible name.
1151
+
1152
+ ```tsx
1153
+ <StatHistoryChart
1154
+ data={history}
1155
+ series={[
1156
+ { key: "totalUsers", label: "Users" },
1157
+ { key: "contentImages", label: "Images" },
1158
+ ]}
1159
+ strings={{ empty: "No history yet.", chart: "Stats over time" }}
1160
+ />
1161
+ ```
893
1162
 
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`.
1163
+ ### `EmailStatsPanel`
1164
+
1165
+ The stats page card for the transactional email, from the `MapAppKitEmailStats` endpoint of
1166
+ SjolystInnovation.AppKit.Email through `createEmailStatsService`. It shows the emails sent,
1167
+ delivered, bounced, and blocked or reported as spam, for the last `days` days. Hard and soft bounces
1168
+ count together. The title names the days the API answered for. While the stats load, the panel shows
1169
+ `loading`. When they cannot load, such as when Brevo does not answer, it shows `unavailable` without
1170
+ a toast. The panel loads again when `days` changes, not when an inline `service` does.
897
1171
 
898
1172
  ```tsx
899
- <LoginPage redirectTo="/admin" resetHref="/reset-password" strings={{ title: "Logg inn" }} formStrings={{ signIn: "Logg inn" }} />
1173
+ <EmailStatsPanel service={createEmailStatsService(api)} strings={{ title: "E-post (siste {days} dager)" }} />
900
1174
  ```
901
1175
 
902
1176
  | Prop | Type | Description |
903
1177
  | --- | --- | --- |
904
- | `redirectTo` | `string` | Where a signed-in user goes. |
905
- | `resetHref` | `string` | The password reset page. |
906
- | `registerHref` | `string` | The sign-up page. Without it there is no register link. |
907
- | `strings` | `TextOverrides<LoginPageStrings>` | The page's text: `title`, `forgotPassword`, `registerPrompt`, and `register`. Set `registerPrompt` to `null` to leave it out. |
908
- | `formStrings` | `TextOverrides<CredentialsFormStrings>` | The form's text. |
909
- | `className` | `string` | Replaces the outer `max-w-md mx-auto px-4 py-16`. |
1178
+ | `service` | `{ get(days?) }` | The email stats call, such as `createEmailStatsService(api)`. |
1179
+ | `days` | `number` | How many days back the stats go. Defaults to 30. |
1180
+ | `strings` | `TextOverrides<EmailStatsPanelStrings>` | `title`, where `{days}` becomes the days, and `sent`, `delivered`, `bounces`, `blocked`, `loading`, and `unavailable`. |
1181
+ | `className` | `string` | Replaces the card's border and spacing, `rounded-2xl border border-admin-border p-5`, such as to sit in the app's own card. |
910
1182
 
911
- ### `ResetPasswordFlow`
1183
+ ## `@sjolystinnovation/app-kit/branding`
1184
+
1185
+ The logo and favicon an admin uploads, as the API's `/branding` endpoint returns them. The shape is
1186
+ the `Branding` type.
912
1187
 
913
- From `@sjolystinnovation/app-kit/ui`. The password reset page in two steps: the email asks for a
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).
1188
+ ### `BrandingProvider` and `useBranding`
1189
+
1190
+ `BrandingProvider` holds the branding for the components below it. It takes the branding the server
1191
+ rendered with, loads it on mount when there is none, and loads it again every five minutes, so an
1192
+ open tab picks up a change. A failed load keeps what it has. `useBranding()` gives a
1193
+ `BrandingContextValue`: `branding`, `logoUrl` as a `data:` URL or `null`, and `refresh()`.
918
1194
 
919
1195
  ```tsx
920
- const params = useSearchParams();
1196
+ "use client";
1197
+ import type { ReactNode } from "react";
1198
+ import { BrandingProvider, useBranding, type Branding } from "@sjolystinnovation/app-kit/branding";
921
1199
 
922
- <ResetPasswordFlow
923
- requestReset={UserService.requestPasswordReset}
924
- reset={UserService.resetPassword}
925
- loginHref="/login"
926
- initialEmail={params.get("email") ?? ""}
927
- initialCode={params.get("code") ?? ""}
928
- strings={{ title: "Tilbakestill passord", signInPrompt: null }}
1200
+ export function Providers({ initial, children }: { initial: Branding; children: ReactNode }) {
1201
+ return (
1202
+ <BrandingProvider load={BrandingService.get} initial={initial}>
1203
+ {children}
1204
+ </BrandingProvider>
1205
+ );
1206
+ }
1207
+
1208
+ export function Logo() {
1209
+ const { logoUrl } = useBranding();
1210
+ return <img src={logoUrl ?? "/logo.png"} alt="" />;
1211
+ }
1212
+ ```
1213
+
1214
+ ### `BrandingManager`
1215
+
1216
+ The admin form for uploading or removing the logo and favicon. It needs a `BrandingProvider` above
1217
+ it, and refreshes it after a save. If the API refuses a file, the form shows the reason under that
1218
+ image and toasts it. A successful save clears it. See
1219
+ [Field errors in the admin forms](#field-errors-in-the-admin-forms) for `strings.validation`.
1220
+
1221
+ ```tsx
1222
+ <BrandingManager
1223
+ save={form => BrandingService.update(form)}
1224
+ strings={{ validation: { invalidImage: "Bruk PNG, JPEG, WebP, GIF eller ICO.", imageTooLarge: "Bildet er for stort." } }}
929
1225
  />
930
1226
  ```
931
1227
 
932
1228
  | Prop | Type | Description |
933
1229
  | --- | --- | --- |
934
- | `requestReset` | `(data: { email }) => Promise<unknown>` | Asks the API to email a code. |
935
- | `reset` | `(data: { email, code, newPassword }) => Promise<unknown>` | Sets the password. When it succeeds, the flow shows the `message` in its answer. |
936
- | `loginHref` | `string` | Where the sign-in links go. |
937
- | `initialEmail`, `initialCode` | `string` | From the emailed link. A code opens the second step. |
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. |
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. |
940
- | `passwordStrings` | `TextOverrides<PasswordStrings>` | The rule messages, in the app's language. |
1230
+ | `save` | `(form: FormData) => Promise<unknown>` | Sends `logo` and `icon` files, and `removeLogo` and `removeIcon` set to `true`. Throw to show the error's message. |
1231
+ | `strings` | `TextOverrides<BrandingManagerStrings>`, with `validation` as `TextOverrides<BrandingValidationStrings>` | The form's text. |
941
1232
 
942
- A failure shows the error's message, or `somethingWentWrong` when it has none.
1233
+ ## `@sjolystinnovation/app-kit/branding/server`
943
1234
 
944
- `TextOverrides<T>`, from `@sjolystinnovation/app-kit/ui`, is the type of every `strings` prop. Each
945
- key is optional and may be `undefined`, and a missing or undefined key shows the English default.
1235
+ Server only. `brandingIcon({ load, fallbackPath, fallbackType })` is the response for an
1236
+ `app/icon.ts` route. It serves the favicon set in admin settings when it is a PNG, JPEG, WebP, GIF,
1237
+ or ICO, and the bundled file otherwise, since an SVG or HTML upload could run script on the site's
1238
+ origin. Both carry `nosniff` and a sandbox CSP, which a CSP header from the app's `next.config`
1239
+ replaces. If the route cannot load the branding, it answers 503 with `no-store`, since a browser
1240
+ would cache the bundled icon in place of the one set in settings.
946
1241
 
947
- ### `TextInput`
1242
+ ```ts
1243
+ // src/app/icon.ts
1244
+ import { join } from "node:path";
1245
+ import type { Branding } from "@sjolystinnovation/app-kit/branding";
1246
+ import { brandingIcon } from "@sjolystinnovation/app-kit/branding/server";
948
1247
 
949
- From `@sjolystinnovation/app-kit/ui`. A labeled text field with the same look and props as
950
- `PasswordInput`, apart from the show and hide toggle. It takes the other `<input>` attributes too.
1248
+ export const dynamic = "force-dynamic";
951
1249
 
952
- ```tsx
953
- <TextInput label="Email" name="email" type="email" required error={errors.email} />
1250
+ export default function Icon() {
1251
+ return brandingIcon({
1252
+ load: () => publicGet<Branding>("/branding", { tags: ["branding"] }),
1253
+ fallbackPath: join(process.cwd(), "public", "icon.png"),
1254
+ fallbackType: "image/png",
1255
+ });
1256
+ }
954
1257
  ```
955
1258
 
956
- ### `TextArea`
1259
+ The route needs `dynamic = "force-dynamic"`, or the build bakes in the icon of the moment. Tag the
1260
+ fetch and revalidate the tag after a save, so a new icon shows at once.
1261
+
1262
+ `iconVersion(data)` is a short digest of the icon bytes, for a manifest icon URL such as
1263
+ `/icon?v=<digest>`, since Android refreshes a home screen icon only when the manifest changes. The
1264
+ options type is `BrandingIconOptions`.
957
1265
 
958
- From `@sjolystinnovation/app-kit/ui`. A labeled multi-line field with the same look, label, and
959
- error handling as `TextInput`. It takes the other `<textarea>` attributes too, and works controlled
960
- or uncontrolled.
1266
+ ## `@sjolystinnovation/app-kit/markdown`
1267
+
1268
+ Renders Markdown from an editor or the API, with GitHub tables, task lists, and footnotes. It never
1269
+ renders raw HTML and sanitizes the output, so the text cannot inject scripts, styles, event
1270
+ handlers, or `javascript:` links. It works in a server component. It needs `react-markdown`,
1271
+ `remark-gfm`, and `rehype-sanitize`.
961
1272
 
962
1273
  ```tsx
963
- <TextArea label="Description" name="description" rows={4} maxRows={10} error={errors.description} />
1274
+ import { Markdown } from "@sjolystinnovation/app-kit/markdown";
1275
+
1276
+ <div className="space-y-4 text-sm text-gray-700">
1277
+ <Markdown>{build.description}</Markdown>
1278
+ </div>;
964
1279
  ```
965
1280
 
966
1281
  | Prop | Type | Default | Description |
967
1282
  | --- | --- | --- | --- |
968
- | `label` | `string` | | Visible label, tied to the text area. |
969
- | `error` | `string` | | Message shown under the field, read out with it by screen readers. |
970
- | `autoGrow` | `boolean` | `true` | Grows with its text from `rows` lines up to `maxRows`, and has no resize handle. With `false` it keeps its size and shows the handle. |
971
- | `rows` | `number` | `3` | Lines shown when empty. |
972
- | `maxRows` | `number` | `12` | The most lines an auto-growing box shows before it scrolls. |
973
- | `className` | `string` | | Classes for placing the field, on the outer element. |
1283
+ | `children` | `string \| null \| undefined` | | The Markdown. Empty, `null`, or `undefined` renders nothing. |
1284
+ | `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. |
1285
+ | `externalLinks` | `boolean` | `false` | Whether to open `http` and `https` links in a new tab with `rel="noopener noreferrer"`. Site-relative links stay in the tab. |
1286
+ | `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. |
974
1287
 
975
- ### `Toggle`
1288
+ The text takes its color and size from the parent. A level one heading becomes a level two, since
1289
+ the page has its own `h1`.
976
1290
 
977
- From `@sjolystinnovation/app-kit/ui`. An on and off switch for a setting that takes effect at once.
978
- It takes the other `<button>` attributes too.
1291
+ ## `@sjolystinnovation/app-kit/legal`
1292
+
1293
+ `LegalPageView` renders a legal page, such as terms, privacy, or cookies, as an admin wrote it: the
1294
+ title, the date it last changed in `dateLocale`, and the Markdown body. If there is no page, it shows
1295
+ `title` and says the page is missing. It is a server component and needs the same peers as
1296
+ `markdown`, but neither axios nor next-auth. The route file stays in the app, since Next reads
1297
+ `revalidate` and the metadata from it.
1298
+
1299
+ The entry point also exports the `LegalPage` type, the same one as `api` exports, so a site without
1300
+ sign-in can type the page without that entry. `LEGAL_PAGE_KEYS` and its `DefaultLegalPageKey` type
1301
+ are the default pages of the API and of `createLegalService`.
979
1302
 
980
1303
  ```tsx
981
- <Toggle label="Email notifications" checked={enabled} onChange={setEnabled} />
982
- <Toggle aria-label="VAT" checked={vat} onChange={setVat} disabled={saving} />
1304
+ // src/app/[locale]/terms/page.tsx
1305
+ import { LegalPageView, type LegalPage } from "@sjolystinnovation/app-kit/legal";
1306
+
1307
+ export const revalidate = 3600;
1308
+
1309
+ export default async function TermsPage({ params }: { params: Promise<{ locale: string }> }) {
1310
+ const { locale } = await params;
1311
+ const page = await publicGet<LegalPage>(`/content/legal/terms?locale=${locale}`, { tags: [REVALIDATE_TARGETS.legal] });
1312
+ return <LegalPageView page={page} title="Terms" homeHref={`/${locale}`} dateLocale="nb-NO" />;
1313
+ }
983
1314
  ```
984
1315
 
985
1316
  | Prop | Type | Description |
986
1317
  | --- | --- | --- |
987
- | `checked` | `boolean` | Whether the switch is on. |
988
- | `onChange` | `(checked: boolean) => void` | Called with the new state. A callback that ignores it also fits. |
989
- | `label` | `string` | Visible text after the switch. It names the switch, and a click on it flips the switch. |
990
- | `aria-label` | `string` | The switch's name when there is no `label`. One of the two is required. |
991
- | `disabled` | `boolean` | Grays the switch out and ignores clicks. |
992
- | `className` | `string` | Classes for placing it, on the outer element. |
1318
+ | `page` | `LegalPage \| null` | The page from the API, or `null` when nobody has written it yet. |
1319
+ | `title` | `string` | The heading while there is no page. |
1320
+ | `homeHref` | `string` | The front page's path, for the link at the bottom. |
1321
+ | `dateLocale` | `string` | The BCP 47 tag the date is written in, such as `nb-NO`. |
1322
+ | `strings` | `TextOverrides<LegalPageViewStrings>` | The page's text. |
993
1323
 
994
- An `onClick` that calls `event.preventDefault()` stops the change.
1324
+ ## `@sjolystinnovation/app-kit/sections`
995
1325
 
996
- ### `Alert`
1326
+ A page built from sections an admin edits: the section types, a renderer registry, and an editor
1327
+ registry. The shared types are `hero`, `feature`, `text`, `feed`, `contact`, `cta`, `stats`, and
1328
+ `image`, listed in `SHARED_SECTION_TYPES`. Each section has an `id`, a `type`, and `visible`. The
1329
+ renderer needs the same peers as `markdown`, and the editor needs sonner.
997
1330
 
998
- ```tsx
999
- import { Alert } from "@sjolystinnovation/app-kit/ui";
1331
+ ### Section types and `createSectionFactory`
1000
1332
 
1001
- <Alert variant="error">{message}</Alert>;
1333
+ `Sections<Extensions, LiveStatSources>` is the union of the shared section types. `Extensions` adds
1334
+ the app's own fields to a type. `LiveStatSources` names the live numbers a stats item can show
1335
+ besides a static value. An app adds its own section types by adding them to the union.
1336
+ `SectionOf<Section, "stats">` picks one member.
1337
+
1338
+ `createSectionFactory(templates)` builds the app's `createSection(type)` from one template per type.
1339
+ Each call gives a new id, `visible: true`, and its own copy of the template, so two sections never
1340
+ share an array. `cloneSection(section)` copies a section with a new id.
1341
+
1342
+ ```ts
1343
+ // src/types/content.ts
1344
+ import { createSectionFactory, type Sections } from "@sjolystinnovation/app-kit/sections";
1345
+
1346
+ export type Section = Sections<{ hero: { backgroundImageId: string | null } }, "builds" | "parts">;
1347
+
1348
+ export const createSection = createSectionFactory<Section>({
1349
+ hero: { heading: "Heading", subheading: "", primaryLabel: "", primaryHref: "#contact", secondaryLabel: "", secondaryHref: "", backgroundImageId: null },
1350
+ feature: { heading: "Feature", text: "", bullets: [] },
1351
+ text: { heading: "Heading", body: "" },
1352
+ feed: { heading: "Latest builds", limit: 3 },
1353
+ contact: { heading: "Contact us", text: "" },
1354
+ cta: { heading: "Get started", text: "", primaryLabel: "", primaryHref: "" },
1355
+ stats: { heading: "", items: [] },
1356
+ image: { imageId: null, alt: "", caption: "", layout: "standard", text: "" },
1357
+ });
1002
1358
  ```
1003
1359
 
1004
- | Prop | Type | Default | Description |
1005
- | --- | --- | --- | --- |
1006
- | `variant` | `"error" \| "success" \| "info" \| "warning"` | `"info"` | Color of the box. |
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. |
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. |
1009
- | `children` | `ReactNode` | | Content. |
1360
+ The member types are `HeroSection`, `FeatureSection`, `TextSection`, `FeedSection`,
1361
+ `ContactSection`, `CtaSection`, `StatsSection` with its `StatItem`, and `ImageSection` with its
1362
+ `ImageLayout`. `BaseSection`, `SharedSectionType`, `SectionExtensions`, and `SectionTemplates` are
1363
+ the building blocks.
1010
1364
 
1011
- ### `ContentImage`
1365
+ ### `SectionRenderer`
1366
+
1367
+ `SectionRenderer` renders one section through the renderer for its type. It holds no state, so it
1368
+ works in a server component. A hidden section, or a type with no renderer, renders nothing.
1369
+
1370
+ The built-in renderers cover `hero`, `feature`, `text`, `contact`, `cta`, and `image`. `feed` and
1371
+ `stats` have no default, since only the app knows its items and its live numbers. The `renderers`
1372
+ prop, a `SectionRenderers<Section>`, replaces a default for the same type and adds the app's own
1373
+ types. Each renderer is a `SectionRenderFn`: it gets the
1374
+ section and the render context.
1375
+
1376
+ | Prop | Type | Description |
1377
+ | --- | --- | --- |
1378
+ | `section` | `Section` | The section to render. |
1379
+ | `renderers` | `SectionRenderers<Section>` | The app's renderers by type. |
1380
+ | `context` | `Partial<SectionRenderContext>` | The app's pieces. Anything left out keeps its default. |
1381
+
1382
+ | `context` key | Default | Description |
1383
+ | --- | --- | --- |
1384
+ | `href` | the path as it is | Maps a button's path, such as `path => localeHref(locale, path)`. |
1385
+ | `Link` | `SmartLink` | The button link. It gets `SectionLinkProps`. `SmartLink` scrolls to a `#anchor` and uses next/link for the rest. |
1386
+ | `Image` | `ContentImage` | The image in image sections. It gets `SectionImageProps`: `imageId`, `alt`, `sizes`, `className`, and `width` and `height` where the layout keeps the ratio. |
1387
+ | `imageDimensions` | `{}` | The intrinsic sizes by image id, as `ImageDimensions`. |
1012
1388
 
1013
- From `@sjolystinnovation/app-kit/ui`. An uploaded image from the content API, as a plain `<img>`
1014
- with a `srcset` of the widths the API serves. It goes through the content images rewrite from
1015
- `defineAppConfig({ contentImages: true })`, so the browser never contacts the API host. It works in
1016
- a server component.
1389
+ The defaults that take options come from factories: `createTextRenderer({ bodyClassName })`,
1390
+ `createContactRenderer({ form, className })`, and `createImageRenderer({ intrinsicSize })`. Their
1391
+ option types are `TextRendererOptions`, `ContactRendererOptions`, and `ImageRendererOptions`.
1392
+ `heroRenderer`, `featureRenderer`, and `ctaRenderer` take no options. For a different hero, register
1393
+ your own.
1017
1394
 
1018
1395
  ```tsx
1019
- <ContentImage imageId={post.imageId} alt={post.title} sizes="(min-width: 768px) 50vw, 100vw" />
1020
- <ContentImage imageId={hero.imageId} fallbackSrc="/hero.jpg" alt="" sizes="100vw" priority aria-hidden />
1396
+ // src/components/SectionRenderer.tsx
1397
+ import {
1398
+ SectionRenderer as BaseSectionRenderer,
1399
+ createContactRenderer,
1400
+ type SectionRenderers,
1401
+ } from "@sjolystinnovation/app-kit/sections";
1402
+ import type { Section } from "@/types/content";
1403
+
1404
+ const renderers: SectionRenderers<Section> = {
1405
+ contact: createContactRenderer({ form: <ContactForm /> }),
1406
+ feed: section => <FeaturedBuilds heading={section.heading} limit={section.limit} />,
1407
+ stats: section => <StatsBand section={section} />,
1408
+ };
1409
+
1410
+ export default function SectionRenderer({ section, locale }: { section: Section; locale: string }) {
1411
+ return <BaseSectionRenderer section={section} renderers={renderers} context={{ href: path => `/${locale}${path}` }} />;
1412
+ }
1021
1413
  ```
1022
1414
 
1023
- | Prop | Type | Default | Description |
1024
- | --- | --- | --- | --- |
1025
- | `imageId` | `string \| null` | | Uploaded image id. `null` or empty shows `fallbackSrc` as it is, with no `srcset`. |
1026
- | `alt` | `string` | | Empty for a decorative image. |
1027
- | `sizes` | `string` | | Widths the image is shown at. Required, since a browser assumes `100vw` without it. |
1028
- | `fallbackSrc` | `string` | | A static path for when there is no uploaded image. With neither, nothing renders. |
1029
- | `priority` | `boolean` | `false` | For the one image visible without scrolling. It loads eagerly, at high priority. |
1030
- | `width`, `height` | `number` | | Intrinsic size, to reserve space where the layout fixes no ratio. |
1031
- | `base` | `string` | `"/content-images"` | Path the content images rewrite serves. |
1032
- | `widths` | `number[]` | `CONTENT_IMAGE_WIDTHS` | Widths in the `srcset`. |
1033
- | `className` | `string` | | Classes on the `<img>`. |
1415
+ ### `SectionsEditor`
1034
1416
 
1035
- `contentImagePath(id, base?)` and `contentImageSrcSet(id, widths?, base?)` build the same URLs for
1036
- other uses, such as a CSS background or an Open Graph image.
1417
+ `SectionsEditor` is the controlled admin editor: a card per section with hide, move, duplicate, and
1418
+ remove, the type's fields, and an add list.
1037
1419
 
1038
- ### `ErrorState`
1420
+ The editor has a built-in editor for each shared type. The `editors` prop, a
1421
+ `SectionEditors<Section>`, replaces an editor for the same type and adds the app's own types. Each
1422
+ editor is a component that gets `SectionEditorProps`: `section`, `patch(changes)`, `strings`, and
1423
+ `images`. The stats editor offers only the manual number until the app registers its live sources.
1039
1424
 
1040
- From `@sjolystinnovation/app-kit/ui`. What an `error.tsx` shows. It is a client component, as an
1041
- error boundary has to be.
1425
+ | Prop | Type | Description |
1426
+ | --- | --- | --- |
1427
+ | `sections` | `Section[]` | The sections, in page order. |
1428
+ | `onChange` | `(sections: Section[]) => void` | Called with the new list after every change. |
1429
+ | `createSection` | `(type) => Section` | Makes a new section of a type, such as the one from `createSectionFactory`. |
1430
+ | `images` | `SectionImages` | The app's `upload(file)`, which resolves to `{ id }`, and an optional preview `src(id)`. The preview defaults to `contentImagePath`. |
1431
+ | `editors` | `SectionEditors<Section>` | The app's editors by type. |
1432
+ | `types` | `readonly Section["type"][]` | The types the add list offers, in order. Defaults to `SHARED_SECTION_TYPES`. An app with a type of its own passes `[...SHARED_SECTION_TYPES, "gallery"]` with an editor for it in `editors`. |
1433
+ | `initialAddType` | `Section["type"]` | The add list's first choice. Defaults to the first of `types`. |
1434
+ | `cloneSection` | `(section) => Section` | Copies a section for duplicate. Defaults to `cloneSection`. |
1435
+ | `strings` | `TextOverrides<SectionsEditorStrings>` | The editor's text. |
1436
+ | `typeLabels` | `Partial<Record<Section["type"], string>>` | The name of each type, on its badge and in the add list. Defaults to `defaultSectionTypeLabels`. |
1437
+ | `unwrapped` | `boolean` | Whether to leave out the wrapping element, for a page that already spaces its children. |
1438
+
1439
+ Build an app editor from the exported ones. `HeroSectionEditor` takes `media` between the subheading
1440
+ and the buttons. `FeedSectionEditor` takes `children` and the grid `className`. `StatsSectionEditor`
1441
+ takes the live `sources`, each a `StatSource`. The other editors are `FeatureSectionEditor`,
1442
+ `TextSectionEditor`, `ContactSectionEditor`, `CtaSectionEditor`, and `ImageSectionEditor`.
1443
+ `ImagePicker` and `SectionField` work on their own in an app's editor.
1042
1444
 
1043
1445
  ```tsx
1044
1446
  "use client";
1447
+ import { useState } from "react";
1448
+ import { SectionsEditor, StatsSectionEditor, type SectionEditorProps, type SectionOf } from "@sjolystinnovation/app-kit/sections";
1449
+ import { createSection, type Section } from "@/types/content";
1045
1450
 
1046
- export default function Error({ reset }: { error: Error; reset: () => void }) {
1047
- return <ErrorState reset={reset} homeHref="/" />;
1451
+ const Stats = (props: SectionEditorProps<SectionOf<Section, "stats">>) => (
1452
+ <StatsSectionEditor {...props} sources={[{ value: "builds", label: "Published builds (live)", defaultItemLabel: "Builds" }]} />
1453
+ );
1454
+
1455
+ export function HomePageEditor({ initial }: { initial: Section[] }) {
1456
+ const [sections, setSections] = useState(initial);
1457
+ return (
1458
+ <SectionsEditor
1459
+ sections={sections}
1460
+ onChange={setSections}
1461
+ createSection={createSection}
1462
+ images={{ upload: ImageService.upload }}
1463
+ editors={{ stats: Stats }}
1464
+ />
1465
+ );
1048
1466
  }
1049
1467
  ```
1050
1468
 
1051
- | Prop | Type | Description |
1052
- | --- | --- | --- |
1053
- | `reset` | `() => void` | The `reset` Next passes to `error.tsx`. The try again button calls it. |
1054
- | `homeHref` | `string` | Where the front page link goes, such as `/` or `/en`. |
1055
- | `strings` | `TextOverrides<ErrorStateStrings>` | `title`, `body`, `tryAgain`, and `home`. |
1056
- | `className` | `string` | Classes for placing it, on the outer element. |
1057
-
1058
- It never shows the error itself.
1469
+ ## `@sjolystinnovation/app-kit/seo`
1059
1470
 
1060
- ### `NotFoundState`
1471
+ Search engine helpers for one site: canonical URLs with hreflang, page metadata with Open Graph,
1472
+ sitemap entries, `robots.txt`, and schema.org structured data. It works in a server component.
1473
+ `defineSite` binds them to the site's name, canonical origin, and locales:
1061
1474
 
1062
- From `@sjolystinnovation/app-kit/ui`. What a `not-found.tsx` shows: a large code, the message as
1063
- the page's heading, and a link home. It works in a server component.
1475
+ ```ts
1476
+ // src/lib/seo/site.ts
1477
+ import { defineSite } from "@sjolystinnovation/app-kit/seo";
1064
1478
 
1065
- ```tsx
1066
- <NotFoundState homeHref="/no" strings={{ body: dict.notFound, home: dict.toFrontPage }} />
1479
+ export const site = defineSite({
1480
+ name: "Example",
1481
+ url: "https://www.example.com",
1482
+ titleSeparator: " | ",
1483
+ locales: ["no", "en"],
1484
+ defaultLocale: "no",
1485
+ localeTags: { no: "no", en: "en" },
1486
+ tagline: locale => (locale === "no" ? "Et eksempel" : "An example"),
1487
+ });
1067
1488
  ```
1068
1489
 
1069
- | Prop | Type | Description |
1490
+ | Option | Type | Description |
1070
1491
  | --- | --- | --- |
1071
- | `homeHref` | `string` | Where the home link goes. |
1072
- | `strings` | `TextOverrides<NotFoundStateStrings>` | `code`, `body`, and `home`. |
1073
- | `className` | `string` | Classes for placing it, on the outer element. |
1492
+ | `name` | `string` | The site's name, in titles, Open Graph, and structured data. |
1493
+ | `url` | `string` | The canonical origin, without a trailing slash. |
1494
+ | `titleSeparator` | `string` | The text between a page title and the site name. |
1495
+ | `locales` | `string[]` | The locale segments every page is under. A site without them leaves it out, and its paths have no segment. |
1496
+ | `defaultLocale` | `string` | The locale `x-default` points at. Defaults to the first. |
1497
+ | `localeTags` | `Record<locale, string>` | The BCP 47 tag per locale. Defaults to the locale. |
1498
+ | `tagline` | `(locale) => string` | The line after the name in the default title and the share image's alt text. |
1499
+ | `shareImagePath` | `string` | The share image route below each locale. Defaults to `/og`. |
1074
1500
 
1075
- ### `PasswordInput`
1501
+ The site provides:
1076
1502
 
1077
- A labeled password field with a button that shows and hides the value. It also takes the other
1078
- `<input>` attributes, apart from `type`, which the component switches itself. `className` goes on
1079
- the outer element, as the table below describes.
1503
+ - `localePath(locale, path)`, `absoluteUrl(path)`, `absoluteLocaleUrl(locale, path)`, and
1504
+ `languageAlternates(path)`. Paths get a leading slash and no trailing slash.
1505
+ - `siteMetadata({ locale, description, manifest })` for the locale root layout: the title template,
1506
+ `metadataBase`, the index directive, and the front page's canonical.
1507
+ - `pageMetadata({ locale, path, title, description, images })` for a page: the canonical URL,
1508
+ hreflang, and Open Graph with the locale's share image unless `images` is given.
1509
+ - `localeEntries(path, { locales, lastModified })` for `sitemap.ts`: one entry per locale, each
1510
+ listing the others. It leaves out `lastModified` when that is unknown. A site without locales gets
1511
+ one entry per page.
1512
+ - `forEachLocale(load)` runs `load` for every locale at once and keys the results by locale.
1513
+ - `legalEntries({ keys, updatedAt })` gives the legal pages' entries, `/terms`, `/privacy`, and
1514
+ `/cookies` unless `keys` says otherwise, such as the legal service's `keys`. Each is dated by the
1515
+ timestamp `updatedAt` returns for it.
1516
+ - `robots({ privatePaths, privateRootPaths, indexable })` for `robots.ts`. It disallows the private
1517
+ paths under every locale segment. A deployment that is not production disallows everything.
1518
+ - `organizationNode(company, { type, sameAs, country, image, logo })`, `webSiteNode(locale)`,
1519
+ `breadcrumbNode(crumbs, pageUrl)`, `itemListNode(paths, pageUrl)`, `siteId(fragment)`, and `ids`
1520
+ for structured data.
1080
1521
 
1081
1522
  ```tsx
1082
- import { PasswordInput } from "@sjolystinnovation/app-kit/ui";
1523
+ // src/app/robots.ts
1524
+ export const dynamic = "force-dynamic";
1525
+ export default () => site.robots({ privatePaths: ["/admin", "/login"], privateRootPaths: ["/api/"] });
1526
+ ```
1083
1527
 
1084
- <PasswordInput label="Password" name="password" required error={errors.password} />;
1528
+ ```tsx
1529
+ // src/app/sitemap.ts
1530
+ export const revalidate = 3600;
1531
+ export default async () => [
1532
+ ...site.localeEntries(""),
1533
+ ...(await site.legalEntries({
1534
+ updatedAt: async (key, locale) => (await publicGet<LegalPage>(`/content/legal/${key}?locale=${locale}`))?.updatedAt,
1535
+ })),
1536
+ ];
1085
1537
  ```
1086
1538
 
1087
- | Prop | Type | Default | Description |
1088
- | --- | --- | --- | --- |
1089
- | `label` | `string` | | Visible label, tied to the input. |
1090
- | `error` | `string` | | Message shown under the field. It turns the border red, marks the input invalid and is read out with it by screen readers. |
1091
- | `showPasswordLabel` | `string` | `"Show password"` | Accessible name of the toggle while the value is hidden. |
1092
- | `hidePasswordLabel` | `string` | `"Hide password"` | Accessible name of the toggle while the value is visible. |
1093
- | `className` | `string` | | Classes for placing the field, such as a margin. They go on the outer element around the label, input and error. Restyle the field through the theme variables. |
1539
+ ```tsx
1540
+ // in a page
1541
+ <JsonLd nodes={[site.organizationNode(company), site.webSiteNode(locale)]} />
1542
+ ```
1094
1543
 
1095
- Pass the two toggle labels in the app's language. The defaults are English.
1544
+ `isIndexableEnvironment()` reads `SITE_ENV`. `prod`, `production`, and unset or blank count as
1545
+ production, so a missing variable never deindexes the live site. `robots.ts` needs
1546
+ `dynamic = "force-dynamic"`, so it reads the variable per request.
1096
1547
 
1097
- ### `toast`
1548
+ The package also exports `JsonLd`, `serializeGraph`, `ref`, `NOINDEX`, `LEGAL_PAGE_KEYS`,
1549
+ `SHARE_IMAGE_SIZE`, `latest`, and `parseTimestamp`, and the types `DefaultLegalPageKey`, `Site`, `SiteConfig`,
1550
+ `SiteMetadataInput`, `PageMetadataInput`, `LocaleEntryOptions`, `LegalEntriesInput`, `RobotsInput`,
1551
+ `OrganizationOptions`, `OrganizationInfo`, `Crumb`, `SchemaNode`, and `SchemaRef`.
1098
1552
 
1099
- From `@sjolystinnovation/app-kit/toast`. sonner's `toast`, except that `toast.error` shows nothing
1100
- while the re-sign-in prompt is open. The prompt already says why the request failed. Every other
1101
- toast passes through, and so do the error states of `toast.promise`.
1553
+ ## `@sjolystinnovation/app-kit/seo/og`
1102
1554
 
1103
- ```ts
1104
- import { toast } from "@sjolystinnovation/app-kit/toast";
1555
+ `shareImage({ name, tagline, colors })` renders the default share preview, a PNG at
1556
+ `SHARE_IMAGE_SIZE`: the name, a bar in `colors.primary`, and the tagline. The colors are literals,
1557
+ since the image renders outside the browser. Return it from the route that `pageMetadata` points at.
1558
+ It is a route, not the `opengraph-image` file convention, since a page's own `openGraph` block
1559
+ replaces that image.
1560
+
1561
+ ```tsx
1562
+ // src/app/[locale]/og/route.tsx
1563
+ import { shareImage } from "@sjolystinnovation/app-kit/seo/og";
1564
+
1565
+ export async function GET(_request: Request, { params }: { params: Promise<{ locale: string }> }) {
1566
+ const { locale } = await params;
1567
+ return shareImage({
1568
+ name: site.name,
1569
+ tagline: locale === "no" ? "Et eksempel" : "An example",
1570
+ colors: { background: "#0f172a", foreground: "#ffffff", primary: "#38bdf8" },
1571
+ });
1572
+ }
1105
1573
  ```
1106
1574
 
1107
- ### `passwordSchema(strings?)`
1575
+ The options type is `ShareImageInput`.
1108
1576
 
1109
- From `@sjolystinnovation/app-kit/validation`. A zod schema for a new password, with the rules the APIs
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.
1577
+ ## `@sjolystinnovation/app-kit/payments`
1115
1578
 
1116
- ```ts
1117
- import { passwordSchema } from "@sjolystinnovation/app-kit/validation";
1579
+ The page a user comes back to from a payment provider, such as after approving a Vipps payment or
1580
+ agreement. The .NET side of the Vipps calls is `SjolystInnovation.AppKit.Vipps` on NuGet.
1118
1581
 
1119
- const result = passwordSchema({ uppercase: "Ta med en stor bokstav fra A til Z." }).safeParse(newPassword);
1120
- const gargeRule = passwordSchema().max(128, "Be at most 128 characters long.");
1582
+ `usePollUntilFinal(fetcher, isFinal, { retries, delayMs })` fetches until the value is final, since
1583
+ the provider may confirm a moment after the user is back. It polls every 4 seconds, 8 more times at
1584
+ most, and stops on a failed fetch and on unmount. It gives `data`, `loading`, and `refresh()`, and
1585
+ each fetch uses the latest `fetcher` and `isFinal`. Its types are `PollOptions` and `PollResult`.
1586
+
1587
+ `PaymentReturn` shows the outcome: confirmed, still pending, or failed. It takes the text and the
1588
+ way on for each state. When you pass `onRefresh`, it shows a refresh button while pending.
1589
+
1590
+ ```tsx
1591
+ "use client";
1592
+ import { PaymentReturn, usePollUntilFinal } from "@sjolystinnovation/app-kit/payments";
1593
+
1594
+ export function OrderReturn({ orderId }: { orderId: string }) {
1595
+ const { data: order, loading, refresh } = usePollUntilFinal(
1596
+ () => ShopService.getOrder(orderId),
1597
+ o => o != null && o.status !== "Pending",
1598
+ );
1599
+ if (loading) return <p>Loading…</p>;
1600
+
1601
+ return (
1602
+ <PaymentReturn
1603
+ state={order?.status === "Paid" ? "success" : order?.status === "Pending" ? "pending" : "failed"}
1604
+ success={{ title: "Payment received!", body: "Your order is confirmed.", href: "/shop", cta: "Back to shop" }}
1605
+ pending={{ title: "Payment pending", body: "Vipps is confirming it.", note: "You get a receipt by email.", href: "/profile/billing", cta: "View billing" }}
1606
+ failed={{ title: "Payment failed", body: "No charge was made.", href: "/shop", cta: "Try again" }}
1607
+ onRefresh={refresh}
1608
+ />
1609
+ );
1610
+ }
1121
1611
  ```
1122
1612
 
1123
- `PASSWORD_MIN_LENGTH` holds the minimum, for hint text. `passwordProblems(password, strings?)` gives the
1124
- message of every rule a password breaks, in the same order, without zod.
1613
+ | Prop | Type | Description |
1614
+ | --- | --- | --- |
1615
+ | `state` | `"success" \| "pending" \| "failed"` | Which content to show. The type is `PaymentReturnState`. |
1616
+ | `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. The type is `PaymentReturnContent`. |
1617
+ | `onRefresh` | `() => void` | Shows a refresh button while pending. |
1618
+ | `refreshLabel` | `string` | The refresh button's text. Defaults to `Check again`. |
1619
+ | `className` | `string` | Classes for placing it, on the outer element. |
1620
+
1621
+ `formatNok(ore)` formats øre as kroner, such as `NOK 373,75`.
1125
1622
 
1126
- ### `zodFieldErrors(error, options?)`
1623
+ ## `@sjolystinnovation/app-kit/locales/nb`
1127
1624
 
1128
- From `@sjolystinnovation/app-kit/validation`. A failed parse's issues by the top-level field they
1129
- belong to, for showing beside each input. Each field gets its first issue's message, or every message
1130
- in order with `all: true`. `message` replaces zod's text, such as with the app's own translations.
1131
- It leaves out issues that belong to no field.
1625
+ Norwegian (bokmål) text for every component that takes `strings`, one complete object per
1626
+ component, typed against its strings type. Pass an object as it is, or spread it and change a few
1627
+ keys. The field errors follow Norwegian public services. They name the field or the problem first,
1628
+ say what is wrong and how to fix it, and use Altinn's phrasing, such as "Du må fylle ut" and "Bruk
1629
+ {max} eller færre tegn". The entry point imports no peer dependency, so a server component can use
1630
+ it.
1132
1631
 
1133
- ```ts
1134
- import { zodFieldErrors } from "@sjolystinnovation/app-kit/validation";
1632
+ | Export | For |
1633
+ | --- | --- |
1634
+ | `nbCredentialsFormStrings` | `CredentialsForm`, and `formStrings` of `LoginPage` and `SessionExpiryGuard` |
1635
+ | `nbLoginPageStrings` | `LoginPage` |
1636
+ | `nbSessionExpiryGuardStrings` | `SessionExpiryGuard` |
1637
+ | `nbResetPasswordFlowStrings` | `ResetPasswordFlow`, with its field error and refusal codes |
1638
+ | `nbPasswordStrings` | `passwordSchema`, `passwordProblems`, and `passwordStrings` of `ResetPasswordFlow` |
1639
+ | `nbErrorStateStrings`, `nbNotFoundStateStrings` | `ErrorState` and `NotFoundState` |
1640
+ | `nbCompanySettingsFormStrings` | `CompanySettingsForm`, with its field error codes |
1641
+ | `nbAdminUsersPanelStrings` | `AdminUsersPanel`, with its field error, refusal, and delete codes |
1642
+ | `nbLegalPagesEditorStrings`, `nbLegalPageKeyLabels` | `LegalPagesEditor` and its `keyLabels` for the default pages |
1643
+ | `nbStatHistoryChartStrings` | `StatHistoryChart` |
1644
+ | `nbEmailStatsPanelStrings` | `EmailStatsPanel` |
1645
+ | `nbBrandingManagerStrings` | `BrandingManager`, with its refusal codes |
1646
+ | `nbLegalPageViewStrings` | `LegalPageView` |
1647
+ | `nbSectionsEditorStrings`, `nbSectionTypeLabels` | `SectionsEditor` and its `typeLabels` |
1135
1648
 
1136
- const parsed = contactSchema.safeParse(form);
1137
- if (!parsed.success) setErrors(zodFieldErrors(parsed.error, { message: (issue) => issueMessage(issue, dict) }));
1649
+ ```tsx
1650
+ import { CompanySettingsForm } from "@sjolystinnovation/app-kit/admin";
1651
+ import { nbCompanySettingsFormStrings, nbCredentialsFormStrings, nbLoginPageStrings } from "@sjolystinnovation/app-kit/locales/nb";
1652
+ import { LoginPage } from "@sjolystinnovation/app-kit/session/react";
1653
+
1654
+ <>
1655
+ <LoginPage redirectTo="/admin" resetHref="/reset-password" strings={nbLoginPageStrings} formStrings={nbCredentialsFormStrings} />
1656
+ <CompanySettingsForm service={SettingsService} strings={{ ...nbCompanySettingsFormStrings, saved: "Lagret" }} />
1657
+ </>;
1138
1658
  ```
1139
1659
 
1140
- ### `@sjolystinnovation/app-kit/testing`
1660
+ ## `@sjolystinnovation/app-kit/testing`
1141
1661
 
1142
1662
  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
1663
+ or `@sjolystinnovation/app-kit/toast`. vitest hoists `vi.mock` above
1144
1664
  the file's imports, so the factory imports the helpers itself. The test imports the same module to
1145
1665
  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.
1666
+ before each test. It needs vitest 5.
1147
1667
 
1148
1668
  ```tsx
1149
1669
  import { makeSession, navigation, resetTestMocks, setSession } from "@sjolystinnovation/app-kit/testing";
@@ -1162,19 +1682,17 @@ it("sends a user without the role away", () => {
1162
1682
  ```
1163
1683
 
1164
1684
  - `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`.
1685
+ `useSession` returns. `auth.signIn`, `auth.signOut`, `auth.getSession`, and `auth.update` are the
1686
+ spies behind next-auth's calls.
1687
+ - `navigation.pathname`, `navigation.searchParams`, and `navigation.router` feed `usePathname`,
1688
+ `useSearchParams`, and `useRouter`.
1169
1689
  - `toastMock()` records every toast in `toastSpies`.
1170
- - `axiosInstanceMock()` is a module whose default export is `axiosSpies`, for
1171
- `vi.mock("@/services/axiosInstance", ...)`.
1172
1690
 
1173
1691
  ## Theming
1174
1692
 
1175
- The components draw every color, corner radius, and label size from theme variables with a light
1176
- default. Set any of them in the app's own `@theme` to restyle every component at once, or in a
1177
- selector such as `.dark` for a scoped theme:
1693
+ The components draw every color, corner radius, and label size from theme variables in
1694
+ `@sjolystinnovation/app-kit/styles.css`, with a light default. Set any of them in the app's own
1695
+ `@theme` to restyle every component at once, or in a selector such as `.dark` for a scoped theme:
1178
1696
 
1179
1697
  ```css
1180
1698
  /* src/app/globals.css */
@@ -1198,7 +1716,7 @@ selector such as `.dark` for a scoped theme:
1198
1716
  | `--color-alert-warning-bg`, `-border`, `-text` | amber 50, 200, 900 |
1199
1717
  | `--text-field-label`, `--text-field-label--line-height` | `var(--text-sm)` and its line height. Set both together. |
1200
1718
  | `--color-field-label-text` | gray 700 |
1201
- | `--spacing-field-label` | gap under the label, `calc(var(--spacing) * 1)` |
1719
+ | `--spacing-field-label` | the gap under the label, `calc(var(--spacing) * 1)` |
1202
1720
  | `--color-field-required` | red 600, the asterisk on a required field |
1203
1721
  | `--color-field-error` | red 600, the message under a field |
1204
1722
  | `--radius-input` | `var(--radius-lg)` |
@@ -1231,6 +1749,8 @@ selector such as `.dark` for a scoped theme:
1231
1749
  | `--color-admin-tab-indicator` | `var(--color-primary)`, the line under the active tab |
1232
1750
  | `--color-chart-grid` | gray 200, the scale lines of `StatHistoryChart` |
1233
1751
  | `--color-chart-label` | gray 400, the chart's numbers and dates |
1752
+ | `--color-admin-stat-label` | gray 500, the labels and the waiting text of `EmailStatsPanel` |
1753
+ | `--color-admin-stat-value` | gray 700, the numbers of `EmailStatsPanel` |
1234
1754
  | `--radius-button` | `var(--radius-lg)` |
1235
1755
  | `--color-button-primary-bg` | `var(--color-primary)` |
1236
1756
  | `--color-button-primary-text` | `var(--color-primary-foreground)` |
@@ -1250,7 +1770,7 @@ selector such as `.dark` for a scoped theme:
1250
1770
  | `--color-markdown-heading`, `-strong` | gray 900, headings and bold text in `Markdown` |
1251
1771
  | `--color-markdown-link` | gray 900 |
1252
1772
  | `--color-markdown-link-hover` | gray 600 |
1253
- | `--color-markdown-border` | gray 200, rules, quotes and table lines |
1773
+ | `--color-markdown-border` | gray 200, rules, quotes, and table lines |
1254
1774
  | `--color-markdown-code-bg`, `-code-text` | gray 100, gray 900 |
1255
1775
  | `--radius-payment-button` | `var(--radius-button)`, the buttons on a `PaymentReturn` |
1256
1776
  | `--color-payment-success`, `-pending`, `-failed` | green 600, amber 500, red 600, the icon on a `PaymentReturn` |
@@ -1259,7 +1779,7 @@ selector such as `.dark` for a scoped theme:
1259
1779
 
1260
1780
  A default that points at another variable is read once for the whole page. The focus ring and the
1261
1781
  primary button point at `--color-primary` and `--color-primary-foreground`. A scoped theme that
1262
- changes those also sets `--color-input-focus-ring`, `--color-button-primary-bg` and
1782
+ changes those also sets `--color-input-focus-ring`, `--color-button-primary-bg`, and
1263
1783
  `--color-button-primary-text` in the same selector.
1264
1784
 
1265
1785
  An app that clears a whole Tailwind namespace, such as `--color-*: initial`, clears these variables
@@ -1267,26 +1787,14 @@ with it and has to set the ones it uses itself.
1267
1787
 
1268
1788
  ## Requirements
1269
1789
 
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.
1274
- - For `@sjolystinnovation/app-kit/validation`: zod 4, as an optional peer dependency.
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.
1278
- - `jsonwebtoken` comes with the package. `@sjolystinnovation/app-kit/auth` uses it on the server.
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.
1283
- - TypeScript with `"moduleResolution": "bundler"`. Relative imports here are extensionless, which
1284
- `node16` and `nodenext` reject.
1285
-
1286
- There is no build output. Bundlers strip or relocate `"use client"` directives, and shipping source
1287
- keeps them intact. The consumer compiles this source with its own `compilerOptions`, and
1288
- `skipLibCheck` does not exempt it.
1790
+ - Next.js 16 and React 19.
1791
+ - Tailwind CSS 4 for the components. The focus ring and the primary button use the theme's
1792
+ `primary` and `primary-foreground` colors, unless you set their own variables.
1793
+ - TypeScript with `"moduleResolution": "bundler"`. Relative imports in the package are
1794
+ extensionless, which `node16` and `nodenext` reject.
1795
+ - The package has no build output. Your app compiles its source with your own `compilerOptions`,
1796
+ and `skipLibCheck` does not exempt it.
1289
1797
 
1290
1798
  ## License
1291
1799
 
1292
- [Apache-2.0](./LICENSE) © Sjølyst Innovation AS
1800
+ [Apache-2.0](https://github.com/sondresjolyst/si-web-packages/blob/main/packages/app-kit/LICENSE) © Sjølyst Innovation AS