@sjolystinnovation/app-kit 0.21.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 (99) hide show
  1. package/README.md +1287 -860
  2. package/package.json +21 -4
  3. package/src/admin/AdminShell.tsx +2 -1
  4. package/src/admin/AdminUsersPanel.tsx +93 -37
  5. package/src/admin/CompanySettingsForm.tsx +35 -14
  6. package/src/admin/EmailStatsPanel.tsx +76 -0
  7. package/src/admin/LegalPagesEditor.tsx +45 -17
  8. package/src/admin/LocaleTabs.tsx +4 -3
  9. package/src/admin/StatHistoryChart.tsx +4 -3
  10. package/src/admin/adminUsersPanelStrings.ts +70 -3
  11. package/src/admin/companySettingsFormStrings.ts +26 -0
  12. package/src/admin/emailStatsPanelStrings.ts +29 -0
  13. package/src/admin/index.ts +14 -5
  14. package/src/admin/legalPagesEditorStrings.ts +26 -1
  15. package/src/admin/statHistoryChartStrings.ts +3 -2
  16. package/src/api/apiErrorCode.ts +28 -0
  17. package/src/api/apiFieldErrors.ts +84 -13
  18. package/src/api/company.ts +4 -3
  19. package/src/api/createApiClient.ts +3 -2
  20. package/src/api/failureBody.ts +10 -0
  21. package/src/api/fieldErrorText.ts +32 -0
  22. package/src/{errors.ts → api/formatApiError.ts} +12 -12
  23. package/src/api/index.ts +7 -2
  24. package/src/api/legalPage.ts +10 -0
  25. package/src/api/request.ts +14 -4
  26. package/src/api/revalidateTargets.ts +7 -5
  27. package/src/api/services.ts +53 -22
  28. package/src/auth/createAuthOptions.ts +16 -8
  29. package/src/branding/BrandingManager.tsx +37 -37
  30. package/src/branding/BrandingProvider.tsx +5 -3
  31. package/src/branding/brandingManagerStrings.ts +49 -0
  32. package/src/branding/index.ts +4 -5
  33. package/src/branding/server.ts +10 -9
  34. package/src/branding/types.ts +3 -3
  35. package/src/config.ts +10 -10
  36. package/src/forms/useFormDraft.ts +9 -7
  37. package/src/index.ts +1 -3
  38. package/src/legal/LegalPageView.tsx +5 -19
  39. package/src/legal/index.ts +4 -1
  40. package/src/legal/keys.ts +10 -0
  41. package/src/legal/legalPageViewStrings.ts +17 -0
  42. package/src/locales/nb.ts +330 -0
  43. package/src/markdown/Markdown.tsx +12 -10
  44. package/src/markdown/index.ts +1 -1
  45. package/src/next-auth.d.ts +11 -9
  46. package/src/next-config/headers.ts +96 -0
  47. package/src/next-config/index.ts +18 -107
  48. package/src/payments/PaymentReturn.tsx +9 -7
  49. package/src/payments/formatNok.ts +1 -1
  50. package/src/payments/usePollUntilFinal.ts +7 -5
  51. package/src/sections/SectionRenderer.tsx +275 -0
  52. package/src/sections/SectionsEditor.tsx +151 -0
  53. package/src/sections/SmartLink.tsx +42 -0
  54. package/src/sections/editors.tsx +311 -0
  55. package/src/sections/factory.ts +26 -0
  56. package/src/sections/index.ts +69 -0
  57. package/src/sections/sectionsEditorStrings.ts +108 -0
  58. package/src/sections/types.ts +107 -0
  59. package/src/seo/JsonLd.tsx +1 -1
  60. package/src/seo/environment.ts +3 -3
  61. package/src/seo/index.ts +1 -1
  62. package/src/seo/og/index.tsx +5 -4
  63. package/src/seo/schema.ts +6 -2
  64. package/src/seo/site.ts +49 -37
  65. package/src/seo/sitemap.ts +2 -2
  66. package/src/server/publicApi.ts +13 -9
  67. package/src/server/revalidateRoute.ts +10 -8
  68. package/src/session/expiry.ts +11 -6
  69. package/src/session/index.ts +0 -1
  70. package/src/session/react/AppSessionProvider.tsx +3 -2
  71. package/src/{ui → session/react}/CredentialsForm.tsx +17 -10
  72. package/src/{ui → session/react}/LoginPage.tsx +7 -6
  73. package/src/session/react/ProtectedGate.tsx +3 -2
  74. package/src/session/react/RoleGate.tsx +4 -3
  75. package/src/session/react/SessionExpiryGuard.tsx +5 -4
  76. package/src/{ui → session/react}/credentialsFormStrings.ts +4 -1
  77. package/src/session/react/index.ts +8 -2
  78. package/src/{ui → session/react}/loginPageStrings.ts +1 -1
  79. package/src/session/react/sessionExpiryGuardStrings.ts +1 -1
  80. package/src/session/react/useSessionGate.ts +3 -3
  81. package/src/styles.css +2 -0
  82. package/src/testing/index.ts +5 -13
  83. package/src/ui/Alert.tsx +6 -4
  84. package/src/ui/ContentImage.tsx +12 -11
  85. package/src/ui/ErrorState.tsx +5 -17
  86. package/src/ui/NotFoundState.tsx +7 -17
  87. package/src/ui/PasswordInput.tsx +6 -5
  88. package/src/ui/ResetPasswordFlow.tsx +64 -19
  89. package/src/ui/TextArea.tsx +6 -4
  90. package/src/ui/TextInput.tsx +4 -2
  91. package/src/ui/Toggle.tsx +3 -3
  92. package/src/ui/errorStateStrings.ts +16 -0
  93. package/src/ui/field.tsx +5 -3
  94. package/src/ui/index.ts +10 -14
  95. package/src/ui/notFoundStateStrings.ts +14 -0
  96. package/src/ui/resetPasswordFlowStrings.ts +51 -7
  97. package/src/validation/fieldErrors.ts +4 -3
  98. package/src/validation/password.ts +5 -5
  99. package/src/validation/passwordRules.ts +2 -2
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`, `markdown`, `branding`, `payments` and `session/react` are styled with Tailwind CSS.
32
- 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 @@ Import the package's stylesheet after Tailwind:
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 it is needed. 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,98 +144,61 @@ export const sessionConfig = defineSessionConfig({
53
144
  });
54
145
  ```
55
146
 
56
- ```ts
57
- import { resolveJwtSecret } from "@sjolystinnovation/app-kit";
58
- import { sessionConfig } from "@/lib/session";
59
-
60
- const secret = resolveJwtSecret(sessionConfig, process.env);
61
- ```
62
-
63
- ## API
64
-
65
- ### `SessionConfig`
66
-
67
147
  | Property | Type | Description |
68
148
  | --- | --- | --- |
69
- | `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`. |
70
150
  | `loginRoute` | `string` | Where an expired session sends the browser, such as `/en/login` or `/login`. |
71
- | `draftStoragePrefix` | `string` | Bare name for the `localStorage` keys holding form drafts, such as `example`. Drafts are stored under `example:draft:<owner>:<scope>`. |
72
-
73
- ### `defineSessionConfig(config)`
74
-
75
- `(config: SessionConfig) => SessionConfig`
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>`. |
76
152
 
77
- Returns the config unchanged, so TypeScript checks its shape once, where it is declared. Throws
78
- when `draftStoragePrefix` is empty or contains a colon.
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.
79
155
 
80
- ### `requireEnv(name, value)`
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.
81
159
 
82
- `(name: string, value: string | undefined) => string`
160
+ ## `@sjolystinnovation/app-kit/next-config`
83
161
 
84
- Returns `value`, or throws `Missing <name>.` when it is unset or empty. Pass the value read in
85
- place, such as `process.env.NEXT_PUBLIC_API_URL`, so Next.js can inline a public variable at build
86
- time.
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.
87
164
 
88
- ### `resolveJwtSecret(config, env)`
89
-
90
- `(config: SessionConfig, env: Record<string, string | undefined>) => string`
91
-
92
- Reads the secret named by `config.jwtSecretEnvVar`. Throws when that variable is unset or empty,
93
- with the variable's name in the message.
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.
94
169
 
95
170
  ```ts
96
- resolveJwtSecret(sessionConfig, { API_JWT_SECRET: "s3cret" });
97
- // "s3cret"
171
+ // next.config.ts
172
+ import { defineAppConfig } from "@sjolystinnovation/app-kit/next-config";
98
173
 
99
- resolveJwtSecret(sessionConfig, {});
100
- // Error: Missing API_JWT_SECRET. The app verifies API session tokens with it.
174
+ export default defineAppConfig({
175
+ apiUrl: process.env.NEXT_PUBLIC_API_URL,
176
+ dev: process.env.NODE_ENV !== "production",
177
+ contentImages: true,
178
+ });
101
179
  ```
102
180
 
103
- The environment is a parameter, so the root entry point needs no Node types and works in browser
104
- code. `@sjolystinnovation/app-kit/auth` is server only and does load Node types.
105
-
106
- ### `formatApiError(error, fallback, statusMessages?)`
107
-
108
- `(error: unknown, fallback: string, statusMessages?: StatusMessages) => string`
109
-
110
- Turns a failed request into one line a user can read.
111
-
112
- On a 4xx response it uses the first of:
113
-
114
- 1. The body's own message: a text body, such as the one `BadRequest("...")` sends from ASP.NET Core,
115
- or `message`, the first validation error or `detail` from a JSON body.
116
- 2. The entry in `statusMessages` for the status.
117
- 3. `fallback`.
118
-
119
- On a 5xx response the entry in `statusMessages` comes first, then the body, then `fallback`. A 5xx
120
- body can come from a proxy, such as "Bad Gateway", so the caller's own words win when it has them.
121
-
122
- A problem details `title` is never shown. ASP.NET Core fills it with generic text, such as "Bad
123
- Request" or "An error occurred while processing your request.", which says less than `fallback`.
124
-
125
- A few more rules:
126
-
127
- - Key `0` in `statusMessages` covers a request that got no response, because of a dropped
128
- connection or a timeout.
129
- - Only text on a single line counts as a message.
130
- - Axios's own message, such as "Request failed with status code 500", is never shown.
131
- - A cancelled request gives `fallback`.
132
- - Any other `Error` gives its own message.
133
- - A body requested as an `arraybuffer` or a `blob`, as for a file download, is not read. A failed
134
- download gets `statusMessages` or `fallback`.
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. |
135
194
 
136
- ```ts
137
- try {
138
- await api.post("/users", form);
139
- } catch (error) {
140
- setError(formatApiError(error, "Could not create the user.", { 409: "That email is taken." }));
141
- }
142
- ```
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.
143
197
 
144
- ### `createAuthOptions(config, settings)`
198
+ ## `@sjolystinnovation/app-kit/auth`
145
199
 
146
- From `@sjolystinnovation/app-kit/auth`. Server only. Builds the next-auth options for an app that
147
- signs in against the API with email and password:
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.
148
202
 
149
203
  ```ts
150
204
  // src/lib/authOptions.ts
@@ -170,27 +224,30 @@ export { handler as GET, handler as POST };
170
224
 
171
225
  | Setting | Type | Description |
172
226
  | --- | --- | --- |
173
- | `apiUrl` | `string` | Base URL of the API. Sign-in posts to `/auth/login`, refresh to `/auth/refresh-token` and sign-out to `/auth/logout` under it. An empty value throws. |
174
- | `env` | `Record<string, string \| undefined>` | The server environment, usually `process.env`. It supplies the secret named by `jwtSecretEnvVar`. |
175
- | `http` | `AxiosInstance` | In place of `apiUrl`, for tests. The client for sign-in and refresh, with its own base URL. |
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. |
229
+ | `http` | `AxiosInstance` | The client for sign-in and refresh, with its own base URL. Use it in place of `apiUrl`, for tests. |
176
230
 
177
231
  next-auth reads `NEXTAUTH_SECRET` itself and refuses to run in production without it.
178
232
 
179
233
  What the options do:
180
234
 
181
- - **Sign-in** posts `{ email, password }` and expects `{ token, refreshToken }`. The access token is
182
- verified with the API JWT secret, allowing 60 seconds of clock difference between the server and
183
- the API. Its `sub`, `unique_name` and `role` claims become the user's `id`, `name` and `roles`.
184
- The log gets the status, error code and message, never the request. A failed sign-in reports one
185
- of these in next-auth's `error`:
186
- - `SIGN_IN_ERRORS.unavailable` when the API does not answer, or a gateway returns 502, 503 or 504.
235
+ - **Sign-in** posts `{ email, password }` and expects `{ token, refreshToken }`. It verifies the
236
+ access token with the API JWT secret and allows 60 seconds of clock difference between the server
237
+ and the API. Its `sub`, `unique_name`, and `role` claims become the user's `id`, `name`, and
238
+ `roles`. The log gets the status, error code, and message, never the request. If sign-in fails,
239
+ next-auth's `error` holds one of these:
240
+ - `SIGN_IN_ERRORS.unavailable` when the API does not answer, or a gateway returns 502, 503, or
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.
187
244
  - `SIGN_IN_ERRORS.invalidCredentials` for every other failure, including an unreadable token and
188
245
  an answer without both tokens.
189
246
  - **Refresh** runs ahead of expiry: five minutes before it, or a quarter of the token's lifetime
190
- before it when that is shorter. It is never scheduled sooner than 30 seconds out, so a token that
191
- lives under 30 seconds is refreshed after it expires. Parallel reads of one session in one server
192
- process share a single refresh. Its answer is kept for a minute, because the API rotates the
193
- refresh token on every call.
247
+ before it when that is shorter. The options never schedule it sooner than 30 seconds out, so a
248
+ token that lives under 30 seconds gets its refresh after it expires. Parallel reads of one session
249
+ in one server process share a single refresh. The options keep its answer for a minute, because
250
+ the API rotates the refresh token on every call.
194
251
  - **Failures:** a 400 or 401 from the refresh ends the session with `RefreshTokenRejected`. Any
195
252
  other failure keeps it and tries again a minute later. A session without a refresh token ends with
196
253
  `NoRefreshToken` when its refresh falls due.
@@ -200,1031 +257,1413 @@ What the options do:
200
257
  seven days in milliseconds.
201
258
  - **Sign-in page:** `pages.signIn` is `config.loginRoute`.
202
259
 
203
- Two rules keep a refresh from being lost:
260
+ Follow two rules so that you never lose a refresh:
204
261
 
205
262
  - Read the session on the client, through `useSession` or `getSession`. In the App Router,
206
- `getServerSession(authOptions)` cannot store a cookie, in server components, route handlers and
207
- server actions alike. A refresh there replaces the tokens at the API and loses the new ones. The
208
- next refresh then reuses the old token, and the API revokes every session the user has. On the
209
- server, read the token with `getToken` from `next-auth/jwt`, which never refreshes.
210
- - Run the app as one server process. Parallel session reads share one refresh only within a process.
211
- Two replicas can refresh the same token at once, with the same result.
263
+ `getServerSession(authOptions)` cannot store a cookie in server components, route handlers, or
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.
269
+
270
+ The package also exports the `AuthOptionsSettings` and `SessionUser` types.
212
271
 
213
- The package declares these fields on next-auth's types in `@sjolystinnovation/app-kit/next-auth`. The
214
- app loads the declarations once, from one of its own `.d.ts` files, so its code can read them:
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:
215
276
 
216
277
  ```ts
217
278
  // src/types/next-auth.d.ts
218
279
  import "@sjolystinnovation/app-kit/next-auth";
219
280
  ```
220
281
 
221
- The session carries `user` (`id`, `name`, `email`, `roles`), `accessToken`, `error` and
222
- `absoluteExpiresAt`. The token carries `accessToken`, `refreshToken`, `refreshAt`, `loginAt`, `user`
223
- and `error`.
282
+ The session carries `user` (`id`, `name`, `email`, `roles`), `accessToken`, `error`, and
283
+ `absoluteExpiresAt`. The token carries `accessToken`, `refreshToken`, `refreshAt`, `loginAt`,
284
+ `user`, and `error`.
224
285
 
225
- ### `@sjolystinnovation/app-kit/api`
286
+ ## `@sjolystinnovation/app-kit/session`
226
287
 
227
- `createApiClient(baseURL)` returns an axios instance for the signed-in API, for code that runs in
228
- the browser. It reads the session through next-auth's `getSession`, which has no cookies to read on
229
- the server. An empty `baseURL` throws. Each request carries the session's access token. A 401
230
- opens the re-sign-in prompt when the session is gone or has a terminal error. It does not sign
231
- the user out, so the page keeps its state. The error still rejects as usual.
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.
232
290
 
233
- `request(call, fallback, statusMessages?)` runs a call and returns its `data`. A failure becomes an
234
- `Error` with the message `formatApiError` gives it. A cancelled call rethrows the cancel unchanged:
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.
235
298
 
236
299
  ```ts
237
- import { requireEnv } from "@sjolystinnovation/app-kit";
238
- import { createApiClient, request } from "@sjolystinnovation/app-kit/api";
300
+ import { SIGN_IN_ERRORS, isTerminalSessionError } from "@sjolystinnovation/app-kit/session";
239
301
 
240
- 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.";
241
304
 
242
- export const getRecipe = (id: number) =>
243
- request(() => api.get<Recipe>(`/recipes/${id}`), "Could not load the recipe.");
305
+ const mustSignInAgain = (session: { error?: string | undefined } | null) => isTerminalSessionError(session?.error);
244
306
  ```
245
307
 
246
- `mergeCompanyInfo(data, fallback)` gives the company details for public pages: the API's
247
- `/company` answer, with the app's own value for each field it left blank or could not give. Only
248
- the fallback's name is required. The legal name falls back to the name, and VAT to not registered.
249
- `formatOrgNumber({ orgNumber, vatRegistered })` adds ` MVA` once the business is VAT registered,
250
- and gives an empty string without a number.
251
-
252
- ```ts
253
- // src/lib/companyInfo.ts
254
- export const getCompanyInfo = async () => mergeCompanyInfo(await publicGetOptional<CompanyInfo>("/company"), COMPANY);
255
- ```
308
+ The re-sign-in prompt is a flag outside React, so an axios interceptor can raise it:
256
309
 
257
- `requestRevalidate(target, endpoint?)` asks the app's revalidate route to purge the pages behind
258
- `target`, so an edit shows at once. It posts to `/api/revalidate` unless `endpoint` says otherwise.
259
- It never throws. On failure the pages' own revalidate window is the fallback.
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. |
260
315
 
261
- `apiFieldErrors(error)` reads the per-field messages of an ASP.NET validation problem from a failed
262
- call or its body, keyed in camelCase to match the form: `{ errors: { OrgNumber: ["..."] } }` becomes
263
- `{ orgNumber: ["..."] }`. It answers null when there are none, so the form falls back to one message.
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.
264
320
 
265
- `createPublicClient(baseURL)` is the same without the session, for the API's anonymous endpoints. An
266
- empty `baseURL` throws, and requests only go to `baseURL`.
321
+ ## `@sjolystinnovation/app-kit/session/react`
267
322
 
268
- The services below call the endpoints the SjolystInnovation.AppKit .NET packages map, so an app does
269
- not write them. `api` is a `createApiClient` instance and `publicApi` a `createPublicClient` one.
270
- Failures throw an `Error` with the API's message, or with the service's own English fallback.
271
- `onSaved` runs after a save succeeds, such as `() => requestRevalidate("branding")`.
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).
272
327
 
273
- | Factory | Methods | .NET package |
274
- | --- | --- | --- |
275
- | `createUserService(api, publicApi)` | `requestPasswordReset`, `resetPassword`, and for the signed-in user `getProfile`, `updateProfile`, `changePassword`, `exportData`, `deleteAccount` | Auth |
276
- | `createAdminUsersService(api)` | `getUsers(includeDeleted?)`, `addRole`, `removeRole`, `deleteUser`, `invite` | Auth |
277
- | `createSettingsService(api, { onSaved? })` | `get`, `update` | SiteSettings |
278
- | `createBrandingService(api, publicApi, { onSaved? })` | `get`, which answers `{}` when the API cannot be read, and `update(form)` | SiteSettings |
279
- | `createLegalService<Key>(api, publicApi, { onSaved? })` | `get(key, locale)`, which answers `null` for a missing page, `listAll`, `save` | SiteSettings |
328
+ A typical layout for the signed-in part of an app:
280
329
 
281
- ```ts
282
- 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";
283
335
 
284
- const apiUrl = requireEnv("NEXT_PUBLIC_API_URL", process.env.NEXT_PUBLIC_API_URL);
285
- 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
+ }
286
346
  ```
287
347
 
288
- The response types are exported too: `UserProfile`, `AdminUser`, `SiteSettings` and `LegalPage`.
348
+ ### `AppSessionProvider`
289
349
 
290
- ### `@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.
291
354
 
292
- 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. |
293
358
 
294
- `createPublicApi(baseUrl, options?)` returns cached GETs against the API's anonymous endpoints:
359
+ ### `ProtectedGate`
295
360
 
296
- - `publicGet(path, { tags? })` gives the data, or null when the API answers 404. An unreachable or
297
- failing API throws `PublicApiError`, so a failed fetch is never cached as an empty page in place
298
- of a working one.
299
- - `publicGetOptional(path, { tags? })` gives null on any failure, for data a page can do without.
300
- - `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.
301
364
 
302
- Responses stay fresh for 60 seconds unless `options.revalidate` says otherwise. An empty `baseUrl`
303
- throws. Each path starts with a single `/`, or the call throws. A path with a `.` or `..` segment,
304
- encoded or not, gives null without a request, the same as a 404. Run a slug or other user input
305
- 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:
306
366
 
307
- ```ts
308
- // src/lib/publicApi.ts
309
- import { requireEnv } from "@sjolystinnovation/app-kit";
310
- 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
311
370
 
312
- export const { publicGet, publicGetOptional, publicGetWithMeta } = createPublicApi(
313
- requireEnv("NEXT_PUBLIC_API_URL", process.env.NEXT_PUBLIC_API_URL),
314
- );
315
- ```
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.
316
374
 
317
- `createRevalidateRoute({ targets, role })` returns a `POST` handler for `app/api/revalidate/route.ts`.
318
- Each target maps to the page paths it purges. A path with a dynamic segment, such as
319
- `/builds/[slug]`, purges every page of that route. The target name is purged as a cache tag too, so
320
- tag the fetches behind it with the same name. The handler answers 403 unless the caller has a live
321
- session with `role`: signed in within the last seven days, with no refused refresh and an access
322
- token that has not expired. It answers 415 unless the body is sent as JSON, which
323
- `requestRevalidate` does, and 400 for a target it does not know.
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. |
324
379
 
325
- ```ts
326
- // src/app/api/revalidate/route.ts
327
- import { createRevalidateRoute } from "@sjolystinnovation/app-kit/server";
380
+ ### `RoleGate`
328
381
 
329
- export const POST = createRevalidateRoute({
330
- role: "Admin",
331
- targets: { builds: ["/builds", "/builds/[slug]", "/sitemap.xml"], legal: ["/terms", "/privacy"] },
332
- });
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>
333
390
  ```
334
391
 
335
- `defineRevalidateTargets({ locales, targets })`, from `@sjolystinnovation/app-kit/api`, keeps the
336
- targets in one file that both sides import. `pages` are below each locale segment and `root` paths
337
- are not. It returns `targets` for fetch tags, `paths` for the route, and `revalidate(target)` for
338
- 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. |
339
398
 
340
- ```ts
341
- // src/lib/cacheTags.ts
342
- export const { targets: REVALIDATE_TARGETS, paths: TARGET_PATHS, revalidate: revalidateTarget } = defineRevalidateTargets({
343
- locales: LOCALES,
344
- targets: {
345
- legal: { pages: ["/terms", "/privacy", "/cookies"], root: ["/sitemap.xml"] },
346
- // Every page renders branding through the layout, so its tag reaches them all.
347
- branding: {},
348
- },
349
- });
399
+ ### `SessionExpiryGuard`
350
400
 
351
- // src/app/api/revalidate/route.ts
352
- export const POST = createRevalidateRoute({ targets: TARGET_PATHS, role: ADMIN_ROLE });
353
- ```
401
+ Render it once, inside the `SessionProvider`. It shows:
354
402
 
355
- ### `@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
356
407
 
357
- 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.
358
411
 
359
- `defineAppConfig(options, extra?)` gives the config every app shares: the standalone build, app-kit
360
- in `transpilePackages`, no `X-Powered-By`, `expireTime` of five minutes, image qualities 75 and 100,
361
- the incremental cache in memory, and the security headers on every page. `extra` is merged over it,
362
- with `transpilePackages`, `images`, `headers` and `rewrites` combined rather than replaced.
412
+ ```tsx
413
+ <SessionExpiryGuard loginHref="/login" onRestored={() => toast.success("You are signed in again.")} />
414
+ ```
363
415
 
364
- ```ts
365
- // next.config.ts
366
- 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. |
367
422
 
368
- export default defineAppConfig({
369
- apiUrl: process.env.NEXT_PUBLIC_API_URL,
370
- dev: process.env.NODE_ENV !== "production",
371
- contentImages: true,
372
- });
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" }} />
373
430
  ```
374
431
 
375
- | Option | Type | Description |
432
+ | Prop | Type | Description |
376
433
  | --- | --- | --- |
377
- | `apiUrl` | `string \| undefined` | The API URL. Its origin is allowed in `connect-src`. |
378
- | `dev` | `boolean` | Development builds also allow `'unsafe-eval'`, which hot reload needs. |
379
- | `websocket` | `boolean` | Also allow `ws:` and `wss:` to the API origin, for SignalR. |
380
- | `imgFromApi` | `boolean` | Also allow images from the API origin. Not needed with `contentImages`. |
381
- | `contentImages` | `boolean` | Proxy `/content-images/*` to the API's `/content-images/*`, so the browser never contacts the API host. |
382
- | `connectSrc`, `imgSrc` | `string[]` | Extra sources for those directives. |
383
- | `frameSrc`, `objectSrc` | `string[]` | Sources for those directives. Both default to `'none'`. |
384
- | `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. |
385
- | `permissionsPolicy` | `string` | Replaces `camera=(), microphone=(), geolocation=()`. |
386
- | `expireTime` | `number` | Seconds a cache may serve a stale page. Defaults to 300. Next's default is a year. |
387
- | `isrFlushToDisk` | `boolean` | Write revalidated pages to disk. Off by default, for a read-only root filesystem. |
388
-
389
- The pieces are exported too: `securityHeaders(options)`, `contentSecurityPolicy(options)`,
390
- `contentImagesRewrite(apiUrl, { source?, upstreamPath? })`, `apiOrigin(apiUrl)` and
391
- `apiBaseUrl(apiUrl)`. Scripts allow `'unsafe-inline'`, since a nonce would make every statically
392
- rendered page dynamic.
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. |
393
439
 
394
- ### `@sjolystinnovation/app-kit/markdown`
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`.
395
443
 
396
- Renders Markdown from an editor or the API, with GitHub tables, task lists and footnotes. Raw HTML is
397
- never rendered and the output is sanitized, so the text cannot inject scripts, styles, event
398
- handlers or `javascript:` links. It works in a server component. It needs `react-markdown`,
399
- `remark-gfm` and `rehype-sanitize`, which are optional peer dependencies:
444
+ ### `LoginPage`
400
445
 
401
- ```sh
402
- npm install react-markdown remark-gfm rehype-sanitize
403
- ```
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`.
404
449
 
405
450
  ```tsx
406
- import { Markdown } from "@sjolystinnovation/app-kit/markdown";
407
-
408
- <div className="space-y-4 text-sm text-gray-700">
409
- <Markdown>{build.description}</Markdown>
410
- </div>;
451
+ <LoginPage redirectTo="/admin" resetHref="/reset-password" strings={{ title: "Logg inn" }} formStrings={{ signIn: "Logg inn" }} />
411
452
  ```
412
453
 
413
- | Prop | Type | Default | Description |
414
- | --- | --- | --- | --- |
415
- | `children` | `string \| null \| undefined` | | The Markdown. Empty, null or undefined renders nothing. |
416
- | `components` | `Components` | | Replaces the element for a tag, such as `{ p: MyParagraph }`. Tags not named keep the default. It gets the props without `node`, so it can spread them onto an element. |
417
- | `externalLinks` | `boolean` | `false` | Opens `http` and `https` links in a new tab with `rel="noopener noreferrer"`. Site-relative links stay in the tab. |
418
- | `className` | `string` | | Wraps the output in a `div` with these classes. Without it the elements render bare, so a parent's `space-y-*` spaces them. |
419
-
420
- The text takes its colour and size from the parent. A level one heading becomes a level two, since
421
- the page has its own `h1`. `markdownComponents` holds the default elements, for an app that builds
422
- on them.
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`. |
423
462
 
424
- ### `@sjolystinnovation/app-kit/seo`
463
+ ## `@sjolystinnovation/app-kit/api`
425
464
 
426
- Search engine helpers for one site: canonical URLs with hreflang, page metadata with Open Graph,
427
- sitemap entries, robots.txt and schema.org structured data. It works in a server component.
428
- `defineSite` binds them to the site's name, canonical origin and locales:
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.
429
467
 
430
- ```ts
431
- // src/lib/seo/site.ts
432
- import { defineSite } from "@sjolystinnovation/app-kit/seo";
468
+ ### `createApiClient` and `createPublicClient`
433
469
 
434
- export const site = defineSite({
435
- name: "Altinnendata",
436
- url: "https://www.altinnendata.no",
437
- titleSeparator: " | ",
438
- locales: ["no", "en"],
439
- defaultLocale: "no",
440
- localeTags: { no: "no", en: "en" },
441
- tagline: locale => getDictionary(locale).meta.tagline,
442
- });
443
- ```
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.
444
475
 
445
- | Option | Type | Description |
446
- | --- | --- | --- |
447
- | `name` | `string` | The site's name, in titles, Open Graph and structured data. |
448
- | `url` | `string` | The canonical origin, without a trailing slash. |
449
- | `titleSeparator` | `string` | Between a page title and the site name. |
450
- | `locales` | `string[]` | The locale segments every page is under. A site without them leaves it out, and its paths have no segment. |
451
- | `defaultLocale` | `string` | The locale `x-default` points at. Defaults to the first. |
452
- | `localeTags` | `Record<locale, string>` | The BCP 47 tag per locale. Defaults to the locale. |
453
- | `tagline` | `(locale) => string` | The line after the name in the default title and the share image's alt text. |
454
- | `shareImagePath` | `string` | The share image route below each locale. Defaults to `/og`. |
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`.
455
478
 
456
- The site gives:
479
+ ### `request`
457
480
 
458
- - `localePath(locale, path)`, `absoluteUrl(path)`, `absoluteLocaleUrl(locale, path)` and
459
- `languageAlternates(path)`. Paths get a leading slash and no trailing slash.
460
- - `siteMetadata({ locale, description, manifest })` for the locale root layout: the title
461
- template, `metadataBase`, the index directive and the front page's canonical.
462
- - `pageMetadata({ locale, path, title, description, images })` for a page: the canonical URL,
463
- hreflang, and Open Graph with the locale's share image unless `images` is given.
464
- - `localeEntries(path, { locales, lastModified })` for `sitemap.ts`: one entry per locale, each
465
- listing the others. `lastModified` is left out when unknown. A site without locales gets one
466
- entry per page.
467
- - `forEachLocale(load)` runs `load` for every locale at once and keys the results by locale.
468
- - `legalEntries({ keys, updatedAt })` gives the legal pages' entries, `/terms`, `/privacy` and
469
- `/cookies` unless `keys` says otherwise, each dated by the timestamp `updatedAt` returns for it.
470
- - `robots({ privatePaths, privateRootPaths, indexable })` for `robots.ts`. The private paths are
471
- disallowed under every locale segment. A deployment that is not production disallows everything.
472
- - `organizationNode(company, { type, sameAs, country, image, logo })`, `webSiteNode(locale)`,
473
- `breadcrumbNode(crumbs, pageUrl)`, `itemListNode(paths, pageUrl)`, `siteId(fragment)` and `ids`
474
- for structured data.
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.
475
487
 
476
- ```tsx
477
- // src/app/robots.ts
478
- export const dynamic = "force-dynamic";
479
- export default () => site.robots({ privatePaths: ["/admin", "/login"], privateRootPaths: ["/api/"] });
488
+ ```ts
489
+ import { requireEnv } from "@sjolystinnovation/app-kit";
490
+ import { createApiClient, request } from "@sjolystinnovation/app-kit/api";
480
491
 
481
- // src/app/sitemap.ts
482
- export const revalidate = 3600;
483
- export default async () => [
484
- ...site.localeEntries(""),
485
- ...(await site.legalEntries({
486
- updatedAt: async (key, locale) => (await publicGet<LegalPage>(`/content/legal/${key}?locale=${locale}`))?.updatedAt,
487
- })),
488
- ];
492
+ const api = createApiClient(requireEnv("NEXT_PUBLIC_API_URL", process.env.NEXT_PUBLIC_API_URL));
489
493
 
490
- // in a page
491
- <JsonLd nodes={[site.organizationNode(company), site.webSiteNode(locale)]} />
494
+ export const getRecipe = (id: number) => request(() => api.get<Recipe>(`/recipes/${id}`), "Could not load the recipe.");
492
495
  ```
493
496
 
494
- `isIndexableEnvironment()` reads `SITE_ENV`. `prod`, `production` and unset or blank count as
495
- production, so a missing variable never deindexes the live site. `robots.ts` needs
496
- `dynamic = "force-dynamic"`, so it reads the variable per request. The package also exports
497
- `JsonLd`, `serializeGraph`, `ref`, `NOINDEX`, `LEGAL_PAGE_KEYS`, `SHARE_IMAGE_SIZE`,
498
- `normalizePath`, `latest` and `parseTimestamp`.
497
+ ### `formatApiError`
499
498
 
500
- ### `@sjolystinnovation/app-kit/seo/og`
499
+ `formatApiError(error, fallback, statusMessages?)` turns a failed request into one line a user can
500
+ read.
501
501
 
502
- `shareImage({ name, tagline, colors })` renders the default share preview, a PNG at
503
- `SHARE_IMAGE_SIZE`: the name, a bar in `colors.primary` and the tagline. The colours are literals,
504
- since the image renders outside the browser. Return it from the route `pageMetadata` points at:
502
+ On a 4xx response, it uses the first of these:
505
503
 
506
- ```tsx
507
- // src/app/[locale]/og/route.tsx
508
- import { shareImage } from "@sjolystinnovation/app-kit/seo/og";
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`.
509
508
 
510
- export async function GET(_request: Request, { params }: { params: Promise<{ locale: string }> }) {
511
- const { locale } = await params;
512
- return shareImage({ name: site.name, tagline: getDictionary(locale).meta.tagline, colors: BRAND });
513
- }
514
- ```
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.
515
511
 
516
- It is a route rather than the `opengraph-image` file convention, since a page's own `openGraph`
517
- block replaces that image.
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`.
518
515
 
519
- ### `@sjolystinnovation/app-kit/branding`
516
+ A few more rules:
520
517
 
521
- The logo and favicon an admin uploads, as the API's `/branding` endpoint returns them.
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`.
522
526
 
523
- `BrandingProvider` holds them for the components below it. It takes the branding the server
524
- rendered with, loads it on mount when there is none, and loads it again every five minutes, so an
525
- open tab picks up a change. A failed load keeps what it has. `useBranding()` gives `branding`,
526
- `logoUrl` as a `data:` URL or null, and `refresh()`.
527
+ ```ts
528
+ import { formatApiError } from "@sjolystinnovation/app-kit/api";
527
529
 
528
- ```tsx
529
- "use client";
530
- import { BrandingProvider, useBranding } from "@sjolystinnovation/app-kit/branding";
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
+ ```
531
536
 
532
- <BrandingProvider load={BrandingService.get} initial={initialBranding}>{children}</BrandingProvider>;
537
+ ### `apiFieldErrors`
533
538
 
534
- const { logoUrl } = useBranding();
535
- <img src={logoUrl ?? "/logo.png"} alt="" />;
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.");
550
+ }
536
551
  ```
537
552
 
538
- `BrandingManager` is the admin form for uploading or removing them. It needs a `BrandingProvider`
539
- above it, and refreshes it after a save.
553
+ ### `apiErrorCode`
540
554
 
541
- ```tsx
542
- <BrandingManager save={form => BrandingService.update(form)} />
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`.
560
+
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
+ }
543
569
  ```
544
570
 
545
- | Prop | Type | Description |
571
+ ### Services
572
+
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 |
546
582
  | --- | --- | --- |
547
- | `save` | `(form: FormData) => Promise<unknown>` | Sends `logo` and `icon` files, and `removeLogo` and `removeIcon` set to `true`. Throw to show the error's message. |
548
- | `strings` | `TextOverrides<BrandingManagerStrings>` | The form's text. |
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 |
549
589
 
550
- `toDataUrl(data, contentType)` turns base64 bytes into a `data:` URL.
590
+ `roles` gives the roles an admin may give, from `GET /users/roles`, in the order the API configures
591
+ them.
551
592
 
552
- ### `@sjolystinnovation/app-kit/branding/server`
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`:
553
597
 
554
- `brandingIcon({ load, fallbackPath, fallbackType })` is the response for an `app/icon.ts` route:
555
- the favicon set in admin settings when it is a PNG, JPEG, WebP, GIF or ICO, or the bundled file
556
- otherwise, since an SVG or HTML upload could run script on the site's origin. Both carry `nosniff`
557
- and a sandbox CSP, which a CSP header from the app's `next.config` replaces. When the branding cannot be
558
- loaded it answers 503 with `no-store`, since a browser would cache the bundled icon in place of the
559
- one set in settings.
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 });
601
+ ```
560
602
 
561
603
  ```ts
562
- // src/app/icon.ts
563
- import { join } from "node:path";
564
- import { brandingIcon } from "@sjolystinnovation/app-kit/branding/server";
604
+ // src/services/userService.ts
605
+ import { requireEnv } from "@sjolystinnovation/app-kit";
606
+ import { createApiClient, createPublicClient, createUserService } from "@sjolystinnovation/app-kit/api";
565
607
 
566
- export const dynamic = "force-dynamic";
608
+ const apiUrl = requireEnv("NEXT_PUBLIC_API_URL", process.env.NEXT_PUBLIC_API_URL);
567
609
 
568
- export default function Icon() {
569
- return brandingIcon({
570
- load: () => publicGet<Branding>("/branding", { tags: ["branding"] }),
571
- fallbackPath: join(process.cwd(), "public", "icon.png"),
572
- fallbackType: "image/png",
573
- });
574
- }
610
+ export default createUserService(createApiClient(apiUrl), createPublicClient(apiUrl));
575
611
  ```
576
612
 
577
- The route needs `dynamic = "force-dynamic"`, or the build bakes in the icon of the moment. Tag the
578
- fetch and revalidate the tag after a save, so a new icon shows at once. `iconVersion(data)` is a
579
- short digest of the icon bytes, for a manifest icon URL such as `/icon?v=<digest>`, since Android
580
- refreshes a home screen icon only when the manifest changes.
613
+ The package also exports the response types `UserProfile`, `AdminUser`, `SiteSettings`,
614
+ `LegalPage`, and `EmailStats`, the `OnSaved` callback type, and the `LegalServiceOptions` type.
581
615
 
582
- ### `@sjolystinnovation/app-kit/payments`
616
+ ### `mergeCompanyInfo` and `formatOrgNumber`
583
617
 
584
- The page a user comes back to from a payment provider, such as after approving a Vipps payment or
585
- agreement. The .NET side of the Vipps calls is `SjolystInnovation.AppKit.Vipps` on NuGet.
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.
586
621
 
587
- `usePollUntilFinal(fetcher, isFinal, { retries, delayMs })` fetches until the value is final, since
588
- the provider may confirm a moment after the user is back. It polls every 4 seconds, 8 more times at
589
- most, and stops on a failed fetch and on unmount. It gives `data`, `loading` and `refresh()`, and
590
- each fetch uses the latest `fetcher` and `isFinal`.
622
+ `formatOrgNumber({ orgNumber, vatRegistered })` adds ` MVA` once the business is VAT registered, and
623
+ gives an empty string without a number.
591
624
 
592
- `PaymentReturn` shows the outcome: confirmed, still pending, or failed. It takes the text and the way
593
- on for each state, and a refresh button while pending when given `onRefresh`.
625
+ ```ts
626
+ // src/lib/companyInfo.ts
627
+ import { formatOrgNumber, mergeCompanyInfo, type CompanyInfo } from "@sjolystinnovation/app-kit/api";
594
628
 
595
- ```tsx
596
- "use client";
597
- import { PaymentReturn, usePollUntilFinal } from "@sjolystinnovation/app-kit/payments";
629
+ export const getCompanyInfo = async () =>
630
+ mergeCompanyInfo(await publicGetOptional<CompanyInfo>("/company"), { name: "Example AS" });
598
631
 
599
- const { data: order, loading, refresh } = usePollUntilFinal(
600
- () => ShopService.getOrder(orderId),
601
- o => o != null && o.status !== "Pending",
602
- );
603
- if (loading) return <Spinner />;
604
-
605
- <PaymentReturn
606
- state={order?.status === "Paid" ? "success" : order?.status === "Pending" ? "pending" : "failed"}
607
- success={{ title: "Payment received!", body: "Your order is confirmed.", href: "/shop", cta: "Back to shop" }}
608
- pending={{ title: "Payment pending", body: "Vipps is confirming it.", note: "We'll email a receipt.", href: "/profile/billing", cta: "View billing" }}
609
- failed={{ title: "Payment failed", body: "No charge was made.", href: "/shop", cta: "Try again" }}
610
- onRefresh={refresh}
611
- />;
632
+ export const orgLine = (company: CompanyInfo) => formatOrgNumber(company);
612
633
  ```
613
634
 
614
- | Prop | Type | Description |
615
- | --- | --- | --- |
616
- | `state` | `"success" \| "pending" \| "failed"` | Which content to show. |
617
- | `success`, `pending`, `failed` | `{ title, body, href, cta, note?, extra? }` | The text, the button's target and text, a smaller note line and anything more, per state. |
618
- | `onRefresh` | `() => void` | Shows a refresh button while pending. |
619
- | `refreshLabel` | `string` | Its text. Defaults to `Check again`. |
620
- | `className` | `string` | Classes for placing it, on the outer element. |
635
+ The `CompanyInfo` and `CompanyFallback` types describe the two shapes.
621
636
 
622
- `formatNok(ore)` formats øre as kroner, such as `NOK 373,75`.
637
+ ### `defineRevalidateTargets`
623
638
 
624
- ### `@sjolystinnovation/app-kit/admin`
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.
625
645
 
626
- `CompanySettingsForm` is the admin form for the business details the public pages show, saved
627
- through `createSettingsService`. Its children show below the form once the settings have loaded,
628
- such as the `BrandingManager`. `strings` holds every label, hint and message, English by default.
646
+ ```ts
647
+ // src/lib/cacheTags.ts
648
+ import { defineRevalidateTargets } from "@sjolystinnovation/app-kit/api";
629
649
 
630
- ```tsx
631
- <CompanySettingsForm service={SettingsService} strings={dict.companySettings}>
632
- <BrandingManager save={BrandingService.update} />
633
- </CompanySettingsForm>
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
+ });
634
658
  ```
635
659
 
636
- The admin area. `AdminShell` is the frame: a title and a tab bar above the page, behind a
637
- `RoleGate`. It takes `RoleGate`'s `role`, `fallback`, and `denied` or `deniedHref`. The tab whose
638
- `href` is the current path is active, with `aria-current="page"`.
660
+ The package also exports the `RevalidateTargetsConfig` and `RevalidateTargetPaths` types.
639
661
 
640
- ```tsx
641
- // src/app/[locale]/(protected)/admin/layout.tsx
642
- "use client";
643
- import { AdminShell } from "@sjolystinnovation/app-kit/admin";
662
+ ## `@sjolystinnovation/app-kit/server`
644
663
 
645
- export default function AdminLayout({ children }: { children: React.ReactNode }) {
646
- const { dict, locale } = useDictionary();
647
- const tabs = [
648
- { href: `/${locale}/admin/users`, label: dict.admin.users },
649
- { href: `/${locale}/admin/legal`, label: dict.admin.legal },
650
- ];
651
- return (
652
- <AdminShell role="Admin" title={dict.admin.title} tabs={tabs} deniedHref={`/${locale}`}>
653
- {children}
654
- </AdminShell>
655
- );
656
- }
657
- ```
664
+ For server components and route handlers only. It imports `next/cache` and `next/server`.
658
665
 
659
- `StatHistoryChart` draws the admin stats over time: a line per series, a scale from zero to the
660
- highest value shown, and legend buttons that hide and show each line. `data` is one row per day,
661
- oldest first, with `date` as `YYYY-MM-DD`. A missing value counts as zero. Each series names the
662
- field it plots and its label, and takes the next colour in `STAT_SERIES_COLORS` unless it has its
663
- own. `strings` holds `empty`, shown when there are no days, and `chart`, the accessible name.
666
+ ### `createPublicApi`
664
667
 
665
- ```tsx
666
- <StatHistoryChart
667
- data={history}
668
- series={[
669
- { key: "totalUsers", label: dict.stats.users },
670
- { key: "contentImages", label: dict.stats.images },
671
- ]}
672
- strings={{ empty: dict.admin.noHistory, chart: dict.admin.overTime }}
673
- />
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),
688
+ );
674
689
  ```
675
690
 
676
- `AdminUsersPanel` is the users page. It lists every user with their roles, shows deleted users
677
- behind a toggle, gives and takes roles, deletes an account after a confirm, and invites a user by
678
- email. It calls the API through `createAdminUsersService`. `getRoles` returns the roles an admin
679
- may give. Inline functions are fine. The panel does not fetch again when they change. It reports
680
- with `toast`, so the app needs sonner and a `Toaster`.
691
+ The package also exports the `PublicApiOptions`, `PublicGetOptions`, and `PublicResponse` types.
681
692
 
682
- ```tsx
683
- import { AdminUsersPanel } from "@sjolystinnovation/app-kit/admin";
693
+ ### `createRevalidateRoute`
684
694
 
685
- <AdminUsersPanel service={adminUsers} getRoles={AdminService.getRoles} strings={dict.adminUsers} />
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.
699
+
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.
704
+
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" });
686
711
  ```
687
712
 
688
- | Prop | Type | Description |
689
- | --- | --- | --- |
690
- | `service` | `ReturnType<typeof createAdminUsersService>` | The user admin calls. |
691
- | `getRoles` | `() => Promise<string[]>` | The roles an admin may give. |
692
- | `defaultInviteRole` | `string` | The role a new invite starts with. Defaults to `Admin`. |
693
- | `strings` | `TextOverrides<AdminUsersPanelStrings>` | The text, English by default. `{name}` and `{role}` are filled in where a text names them. |
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.
694
716
 
695
- `LegalPagesEditor` edits the legal pages one page and one locale at a time, through
696
- `createLegalService`. A locale tab whose page has no content yet gets a dot. `LocaleTabs` is the
697
- same tab bar on its own, for other translated forms.
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.
698
722
 
699
723
  ```tsx
700
- <LegalPagesEditor
701
- service={LegalService}
702
- keys={LEGAL_PAGE_KEYS}
703
- keyLabels={{ terms: dict.footer.terms, privacy: dict.footer.privacy, cookies: dict.footer.cookies }}
704
- locales={LOCALES}
705
- localeLabels={LOCALE_LABELS}
706
- strings={dict.legalEditor}
707
- />
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
+ });
708
730
  ```
709
731
 
710
- ### `@sjolystinnovation/app-kit/legal`
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`. |
711
737
 
712
- `LegalPageView` renders a terms, privacy or cookies page as an admin wrote it: the title, the date
713
- it last changed in `dateLocale`, and the Markdown body. With no page it shows `title` and says the
714
- page is missing. It is a server component and needs the same peers as `markdown`. The route file
715
- stays in the app, since Next reads `revalidate` and the metadata from it.
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.
716
741
 
717
- ```tsx
718
- // src/app/[locale]/terms/page.tsx
719
- export const revalidate = 3600;
742
+ It returns a `FormDraft`:
720
743
 
721
- export default async function TermsPage({ params }: { params: Promise<{ locale: Locale }> }) {
722
- const { locale } = await params;
723
- const page = await publicGet<LegalPage>(`/content/legal/terms?locale=${locale}`, { tags: [REVALIDATE_TARGETS.legal] });
724
- return <LegalPageView page={page} title={dict.footer.terms} homeHref={`/${locale}`} dateLocale="nb-NO" strings={dict.legal} />;
725
- }
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.");
726
764
  ```
727
765
 
728
- ### `@sjolystinnovation/app-kit/session`
766
+ ## `@sjolystinnovation/app-kit/validation`
729
767
 
730
- Decides when a user has to sign in again. Safe to import from server code, such as the next-auth
731
- callbacks.
768
+ ### `passwordSchema`
732
769
 
733
- The gate reads `session.error`. `SessionExpiryGuard` also reads `session.absoluteExpiresAt` for its
734
- countdown, `session.user.id` to keep the page with the user who opened it, and `session.user.email`
735
- to fill in the prompt. `createAuthOptions` sets all of these. An app with its own next-auth options sets them in its
736
- callbacks:
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.
737
776
 
738
777
  ```ts
739
- const SESSION_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000;
740
-
741
- callbacks: {
742
- jwt({ token, user }) {
743
- if (user) token.loginAt = Date.now();
744
- // A token without a sign-in time counts as expired.
745
- if (!token.loginAt || Date.now() - token.loginAt > SESSION_LIFETIME_MS) {
746
- token.error = SESSION_ERRORS.absoluteExpiry;
747
- }
748
- // Also set token.error when a refresh is refused.
749
- return token;
750
- },
751
- session({ session, token }) {
752
- session.user.id = token.sub ?? "";
753
- session.error = token.error;
754
- session.absoluteExpiresAt = token.loginAt ? token.loginAt + SESSION_LIFETIME_MS : undefined;
755
- return session;
756
- },
757
- },
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.");
758
782
  ```
759
783
 
760
- For that to compile, these fields have to be on next-auth's `Session` and `JWT` types. Add them to
761
- the app's existing next-auth type declarations, or create them:
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`.
787
+
788
+ ### `zodFieldErrors`
789
+
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.
762
794
 
763
795
  ```ts
764
- // src/types/next-auth.d.ts
765
- import "next-auth";
766
- import "next-auth/jwt";
767
- import type { DefaultSession } from "next-auth";
768
-
769
- declare module "next-auth" {
770
- interface Session {
771
- user: { id: string } & DefaultSession["user"];
772
- error?: string | undefined;
773
- absoluteExpiresAt?: number | undefined;
774
- }
775
- }
796
+ import { zodFieldErrors } from "@sjolystinnovation/app-kit/validation";
776
797
 
777
- declare module "next-auth/jwt" {
778
- interface JWT {
779
- error?: string | undefined;
780
- loginAt?: number | undefined;
781
- }
782
- }
798
+ const parsed = contactSchema.safeParse(form);
799
+ if (!parsed.success) setErrors(zodFieldErrors(parsed.error, { message: issue => issueMessage(issue, dict) }));
783
800
  ```
784
801
 
785
- `SIGN_IN_ERRORS` holds the two codes a failed sign-in reports: `InvalidCredentials` and
786
- `SignInUnavailable`. Map them to the app's own messages.
802
+ ## `@sjolystinnovation/app-kit/ui`
787
803
 
788
- `SESSION_ERRORS` holds the values the jwt callback puts in `token.error` when the session cannot be
789
- recovered: `AbsoluteSessionExpired`, `RefreshTokenRejected` and `NoRefreshToken`. `SessionError`
790
- is their union type. `isTerminalSessionError(error)` is true for those three only, so a transient
791
- refresh failure keeps the session.
804
+ Form controls, alerts, images, and error pages. The components use Tailwind CSS and take their look
805
+ from the [theme variables](#theming).
792
806
 
793
- The re-sign-in prompt is a flag outside React, so an axios interceptor can raise it:
807
+ ### `TextInput`
794
808
 
795
- | Function | Description |
796
- | --- | --- |
797
- | `openSessionPrompt()` | Asks the user to sign in again without leaving the page. |
798
- | `closeSessionPrompt()` | Hides the prompt. Call it when the component showing the prompt unmounts, or the flag stays open and the gate never redirects. |
799
- | `subscribeSessionPrompt(listener)` | Calls `listener` when the prompt opens or closes. Returns an unsubscribe function. |
800
- | `getSessionPromptOpen()` | Whether the prompt is showing. |
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.
801
811
 
802
- Copies of the package installed side by side share the one flag, as long as their releases use the
803
- same prompt state format. A release that changes the format says so in its changelog. An app's own
804
- copy of these functions does not share the flag. So the interceptor, the prompt and every gate all
805
- import them from this package.
812
+ ```tsx
813
+ <TextInput label="Email" name="email" type="email" required error={errors.email} />
814
+ ```
806
815
 
807
- ### `useSessionGate()`
816
+ ### `PasswordInput`
808
817
 
809
- From `@sjolystinnovation/app-kit/session/react`. Tells a protected page whether it may render. It
810
- reads `useSession`, so it needs a `SessionProvider` above it.
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.
811
820
 
812
- A page that rendered on a healthy session stays rendered, so an open form keeps its values:
821
+ ```tsx
822
+ <PasswordInput label="Password" name="password" required error={errors.password} />
823
+ ```
813
824
 
814
- - when the session gets a terminal error
815
- - while the session is re-read
816
- - while the prompt recovers it
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. |
817
832
 
818
- A session that turns signed out with no prompt open blanks the page. A page the user opens on a
819
- dead session does not render. Use `mayRender` in every gate and layout on the way down, so none of
820
- them blanks a page that another one kept.
833
+ Pass the two toggle labels in the app's language. The defaults are English.
821
834
 
822
- The hook does not navigate. `ProtectedGate` below adds the redirect. Use the hook directly in a
823
- layout that needs its own decision, such as a role check, so it agrees with the gate.
835
+ ### `TextArea`
824
836
 
825
- | Field | Description |
826
- | --- | --- |
827
- | `mayRender` | Whether the page may render. |
828
- | `session`, `status` | As `useSession` returns them. |
829
- | `usable` | The session is signed in and has no terminal error. |
830
- | `wasUsable` | This page has rendered on a usable session. |
831
- | `promptOpen` | The re-sign-in prompt is showing. |
832
- | `recovering` | The prompt is showing over a page that was usable. |
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.
833
839
 
834
- `SessionGate` is the type of the result.
840
+ ```tsx
841
+ <TextArea label="Description" name="description" rows={4} maxRows={10} error={errors.description} />
842
+ ```
835
843
 
836
- ### `AppSessionProvider`
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. |
852
+
853
+ ### `Toggle`
837
854
 
838
- From `@sjolystinnovation/app-kit/session/react`. next-auth's `SessionProvider`, reading the session
839
- again every four minutes. Each read lets the jwt callback renew the access token before it expires,
840
- so an open tab does not find a dead session on the next save. It also keeps what `useSession`
841
- reports in this tab up to date. next-auth polls only once a session exists.
855
+ An on and off switch for a setting that takes effect at once. It takes the other `<button>`
856
+ attributes too.
842
857
 
843
858
  ```tsx
844
- <AppSessionProvider>{children}</AppSessionProvider>
859
+ <>
860
+ <Toggle label="Email notifications" checked={enabled} onChange={setEnabled} />
861
+ <Toggle aria-label="VAT" checked={vat} onChange={setVat} disabled={saving} />
862
+ </>
845
863
  ```
846
864
 
847
865
  | Prop | Type | Description |
848
866
  | --- | --- | --- |
849
- | `refetchInterval` | `number` | Seconds between reads. Defaults to 240. Keep it under five minutes, the time before expiry when a refresh falls due. |
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. |
850
873
 
851
- ### `ProtectedGate`
874
+ An `onClick` that calls `event.preventDefault()` stops the change.
875
+
876
+ ### `Alert`
852
877
 
853
- From `@sjolystinnovation/app-kit/session/react`. Wraps the protected part of the app. It renders the
854
- page only when `useSessionGate` allows it, and sends a signed-out visitor or a session dead on
855
- arrival to `loginHref`. It never redirects while the re-sign-in prompt is open.
878
+ A colored message box.
856
879
 
857
880
  ```tsx
858
- <ProtectedGate loginHref="/login" fallback={<p>Loading…</p>}>
859
- {children}
860
- </ProtectedGate>
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. |
890
+
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.
896
+
897
+ ```tsx
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
+ </>
902
+ ```
903
+
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>`. |
915
+
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.
918
+
919
+ ### `ErrorState`
920
+
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.
923
+
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
+ }
861
932
  ```
862
933
 
863
934
  | Prop | Type | Description |
864
935
  | --- | --- | --- |
865
- | `loginHref` | `string` | Where the redirect goes. An app with a locale in the path builds it for the current locale. |
866
- | `fallback` | `ReactNode` | What shows while the page may not render yet. Defaults to nothing. |
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. |
867
940
 
868
- ### `RoleGate`
941
+ ### `NotFoundState`
869
942
 
870
- From `@sjolystinnovation/app-kit/session/react`. Renders its children only for a user with `role`.
871
- Place it inside `ProtectedGate`. While the re-sign-in prompt recovers a signed-out session there is
872
- no user and so no roles. The page stays up only on the path where the session last had the role.
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.
873
945
 
874
946
  ```tsx
875
- <RoleGate role="Admin" fallback={<p>Loading…</p>} denied={<p>You do not have access.</p>}>
876
- {children}
877
- </RoleGate>
947
+ <NotFoundState homeHref="/no" strings={{ body: dict.notFound, home: dict.toFrontPage }} />
878
948
  ```
879
949
 
880
950
  | Prop | Type | Description |
881
951
  | --- | --- | --- |
882
- | `role` | `string` | The role the page needs, as it appears in `session.user.roles`. |
883
- | `fallback` | `ReactNode` | What shows while the session or its roles are not known yet. Defaults to nothing. |
884
- | `denied` | `ReactNode` | What a signed-in user without the role sees. Give this or `deniedHref`. |
885
- | `deniedHref` | `string` | Where a signed-in user without the role is sent instead. |
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. |
886
955
 
887
- ### `SessionExpiryGuard`
956
+ ### `ResetPasswordFlow`
888
957
 
889
- From `@sjolystinnovation/app-kit/session/react`. Render it once, inside the `SessionProvider`. It
890
- shows:
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.
891
963
 
892
- - a banner in the last 30 minutes before `session.absoluteExpiresAt`, with the minutes left. That is
893
- seven days after sign-in with `createAuthOptions`.
894
- - a banner when the session is dead
895
- - the re-sign-in prompt, a dialog with `CredentialsForm`, when a request or the banner opens it
964
+ ```tsx
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
+ />;
975
+ ```
976
+
977
+ | Prop | Type | Description |
978
+ | --- | --- | --- |
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. |
986
+
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:
991
+
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`.
997
+
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`.
896
1002
 
897
- A sign-in in the prompt keeps the page and its form. A sign-in in the prompt as a different user is
898
- signed out and sent to `loginHref`, so the prompt never hands the page to another user.
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 }`.
899
1012
 
900
1013
  ```tsx
901
- <SessionExpiryGuard
902
- loginHref="/login"
903
- onRestored={() => toast.success("You are signed in again.")}
904
- />
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>
905
1067
  ```
906
1068
 
907
1069
  | Prop | Type | Description |
908
1070
  | --- | --- | --- |
909
- | `loginHref` | `string` | Where a sign-in as a different user is sent after it is signed out. |
910
- | `onRestored` | `() => void` | Runs after the prompt signs the user back in, such as to show a toast. |
911
- | `strings` | `TextOverrides<SessionExpiryGuardStrings>` | The guard's text. `{minutes}` in `expiringSoon` becomes the minutes left. The defaults are English. |
912
- | `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. |
913
1074
 
914
- ### `useFormDraft(config, options)`
1075
+ ### `LegalPagesEditor` and `LocaleTabs`
915
1076
 
916
- From `@sjolystinnovation/app-kit/forms`. Keeps a form's values in `localStorage`, so a sign-out,
917
- reload or closed tab does not lose them. Values are written half a second after the last change, so
918
- only that last half second is at risk.
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.
919
1084
 
920
1085
  ```tsx
921
- // userId is the signed-in user's id, from wherever the app's session keeps it.
922
- const draft = useFormDraft(sessionConfig, {
923
- owner: userId,
924
- scope: recipe ? `recipe:${recipe.id}` : "recipe:new",
925
- value: form,
926
- });
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
+ />
927
1093
  ```
928
1094
 
929
- | Option | Type | Description |
1095
+ | Prop | Type | Description |
930
1096
  | --- | --- | --- |
931
- | `owner` | `string \| undefined` | The signed-in user's id. Drafts are stored per user, and nothing is stored until it is known. The last known owner is kept when the session stops reporting one. |
932
- | `scope` | `string` | The form and the entity it edits, such as `recipe:42`. |
933
- | `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. |
934
1103
 
935
- Mount an edit form once the entity has loaded, with its values already in `value`. The hook treats
936
- the values it first sees under a key as the untouched form, so values that arrive later count as an
937
- edit and are written over the draft being offered.
1104
+ `LocaleTabs` is the same tab bar on its own, for other translated forms:
938
1105
 
939
- It returns:
1106
+ ```tsx
1107
+ <LocaleTabs locales={["no", "en"]} labels={{ no: "Norsk", en: "English" }} active={locale} onChange={setLocale} filled={{ no: true, en: false }} />
1108
+ ```
940
1109
 
941
- - `pending`, the stored draft. The page offers it to the user and does not apply it. It is read
942
- when the owner is first known and again whenever the owner or `scope` changes, so it can appear
943
- after the form mounts.
944
- - `dismiss()`, to stop offering it.
945
- - `clear()`, to drop the stored draft after a successful save.
1110
+ ### `AdminUsersPanel`
946
1111
 
947
- Drafts are stored under `<draftStoragePrefix>:draft:<owner>:<scope>`. The hook throws when
948
- `draftStoragePrefix` is empty or contains a colon. A draft older than 7 days is not offered, and is
949
- removed when its form next reads it.
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.
950
1117
 
951
- ### `CredentialsForm`
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:
1121
+
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.
952
1128
 
953
- From `@sjolystinnovation/app-kit/ui`. Email and password sign-in through next-auth's credentials
954
- provider, with `redirect: false`, so the page stays where it is. Use it on the login page and in
955
- the prompt.
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`.
956
1132
 
957
1133
  ```tsx
958
- <CredentialsForm onSignedIn={() => router.push("/")} strings={{ signIn: "Logg inn" }} />
1134
+ <AdminUsersPanel service={adminUsers} strings={dict.adminUsers} />
959
1135
  ```
960
1136
 
961
1137
  | Prop | Type | Description |
962
1138
  | --- | --- | --- |
963
- | `onSignedIn` | `() => void \| Promise<void>` | Runs after next-auth accepts the credentials. Throw `SignInRejected` with a message to refuse the sign-in. |
964
- | `initialEmail` | `string` | The email the field starts with. |
965
- | `children` | `ReactNode` | Extra buttons next to the submit button. |
966
- | `strings` | `TextOverrides<CredentialsFormStrings>` | The form's text. The defaults are English. |
967
- | `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. |
968
1143
 
969
- A failed sign-in shows `signInUnavailable` for `SIGN_IN_ERRORS.unavailable` and
970
- `invalidCredentials` for any other error code. A request next-auth refuses without an error code,
971
- or one that throws, shows `somethingWentWrong`.
1144
+ ### `StatHistoryChart`
972
1145
 
973
- ### `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
+ ```
1162
+
1163
+ ### `EmailStatsPanel`
974
1164
 
975
- From `@sjolystinnovation/app-kit/ui`. The sign-in page: a title, `CredentialsForm`, a link to reset
976
- the password and, when the app has sign-up, a link to it. A sign-in goes to `redirectTo` and refreshes
977
- the page's server data. It uses the same `auth` theme variables as `ResetPasswordFlow`.
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.
978
1171
 
979
1172
  ```tsx
980
- <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)" }} />
981
1174
  ```
982
1175
 
983
1176
  | Prop | Type | Description |
984
1177
  | --- | --- | --- |
985
- | `redirectTo` | `string` | Where a signed-in user goes. |
986
- | `resetHref` | `string` | The password reset page. |
987
- | `registerHref` | `string` | The sign-up page. Without it there is no register link. |
988
- | `strings` | `TextOverrides<LoginPageStrings>` | The page's text: `title`, `forgotPassword`, `registerPrompt` (null leaves it out) and `register`. |
989
- | `formStrings` | `TextOverrides<CredentialsFormStrings>` | The form's text. |
990
- | `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. |
991
1182
 
992
- ### `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.
993
1187
 
994
- From `@sjolystinnovation/app-kit/ui`. The password reset page in two steps: the email asks for a
995
- code, then the code and a new password set it. The password is checked against the same rules as
996
- `passwordSchema` before it is sent, without loading zod. An emailed link with the email and code,
997
- such as an invite, opens it on the second step. Its colours and title size come from the `auth` and
998
- `button` theme variables, see [Theming](#theming).
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()`.
999
1194
 
1000
1195
  ```tsx
1001
- const params = useSearchParams();
1196
+ "use client";
1197
+ import type { ReactNode } from "react";
1198
+ import { BrandingProvider, useBranding, type Branding } from "@sjolystinnovation/app-kit/branding";
1002
1199
 
1003
- <ResetPasswordFlow
1004
- requestReset={UserService.requestPasswordReset}
1005
- reset={UserService.resetPassword}
1006
- loginHref="/login"
1007
- initialEmail={params.get("email") ?? ""}
1008
- initialCode={params.get("code") ?? ""}
1009
- 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." } }}
1010
1225
  />
1011
1226
  ```
1012
1227
 
1013
1228
  | Prop | Type | Description |
1014
1229
  | --- | --- | --- |
1015
- | `requestReset` | `(data: { email }) => Promise<unknown>` | Asks the API to email a code. |
1016
- | `reset` | `(data: { email, code, newPassword }) => Promise<unknown>` | Sets the password. A `message` in its answer is shown when it succeeds. |
1017
- | `loginHref` | `string` | Where the sign-in links go. |
1018
- | `initialEmail`, `initialCode` | `string` | From the emailed link. A code opens the second step. |
1019
- | `className` | `string` | Replaces the outer `max-w-md mx-auto px-4 py-16`, such as to sit in the app's own card. |
1020
- | `strings` | `TextOverrides<ResetPasswordFlowStrings>` | The page's text. `signInPrompt`, `intro`, `codeSent` and `passwordHint` set to null are left out. `done` set to null shows the API's message, or only the sign-in link when it sends none. `passwordInvalid` set to null lists every broken rule. |
1021
- | `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. |
1022
1232
 
1023
- A failure shows the error's message, or `somethingWentWrong` when it has none.
1233
+ ## `@sjolystinnovation/app-kit/branding/server`
1024
1234
 
1025
- `TextOverrides<T>`, from `@sjolystinnovation/app-kit/ui`, is the type of every `strings` prop. Each
1026
- 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.
1027
1241
 
1028
- ### `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";
1029
1247
 
1030
- From `@sjolystinnovation/app-kit/ui`. A labelled text field with the same look and props as
1031
- `PasswordInput`, apart from the show and hide toggle. It takes the other `<input>` attributes too.
1248
+ export const dynamic = "force-dynamic";
1032
1249
 
1033
- ```tsx
1034
- <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
+ }
1035
1257
  ```
1036
1258
 
1037
- ### `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`.
1265
+
1266
+ ## `@sjolystinnovation/app-kit/markdown`
1038
1267
 
1039
- From `@sjolystinnovation/app-kit/ui`. A labelled multi-line field with the same look, label and
1040
- error handling as `TextInput`. It takes the other `<textarea>` attributes too, and works controlled
1041
- or uncontrolled.
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`.
1042
1272
 
1043
1273
  ```tsx
1044
- <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>;
1045
1279
  ```
1046
1280
 
1047
1281
  | Prop | Type | Default | Description |
1048
1282
  | --- | --- | --- | --- |
1049
- | `label` | `string` | | Visible label, tied to the text area. |
1050
- | `error` | `string` | | Message shown under the field, read out with it by screen readers. |
1051
- | `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. |
1052
- | `rows` | `number` | `3` | Lines shown when empty. |
1053
- | `maxRows` | `number` | `12` | The most lines an auto-growing box shows before it scrolls. |
1054
- | `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. |
1055
1287
 
1056
- ### `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`.
1290
+
1291
+ ## `@sjolystinnovation/app-kit/legal`
1057
1292
 
1058
- From `@sjolystinnovation/app-kit/ui`. An on and off switch for a setting that takes effect at once.
1059
- It takes the other `<button>` attributes too.
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`.
1060
1302
 
1061
1303
  ```tsx
1062
- <Toggle label="Email notifications" checked={enabled} onChange={setEnabled} />
1063
- <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
+ }
1064
1314
  ```
1065
1315
 
1066
1316
  | Prop | Type | Description |
1067
1317
  | --- | --- | --- |
1068
- | `checked` | `boolean` | Whether the switch is on. |
1069
- | `onChange` | `(checked: boolean) => void` | Called with the new state. A callback that ignores it also fits. |
1070
- | `label` | `string` | Visible text after the switch. It names the switch, and a click on it flips the switch. |
1071
- | `aria-label` | `string` | The switch's name when there is no `label`. One of the two is required. |
1072
- | `disabled` | `boolean` | Greys the switch out and ignores clicks. |
1073
- | `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. |
1074
1323
 
1075
- An `onClick` that calls `event.preventDefault()` stops the change.
1324
+ ## `@sjolystinnovation/app-kit/sections`
1076
1325
 
1077
- ### `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.
1078
1330
 
1079
- ```tsx
1080
- import { Alert } from "@sjolystinnovation/app-kit/ui";
1331
+ ### Section types and `createSectionFactory`
1332
+
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.
1081
1337
 
1082
- <Alert variant="error">{message}</Alert>;
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
+ });
1083
1358
  ```
1084
1359
 
1085
- | Prop | Type | Default | Description |
1086
- | --- | --- | --- | --- |
1087
- | `variant` | `"error" \| "success" \| "info" \| "warning"` | `"info"` | Colour of the box. |
1088
- | `role` | `"alert" \| "status" \| "none"` | `"alert"` | A screen reader announces `status` without interrupting, on every change of its text. Use `none` when a separate live region announces the text. |
1089
- | `className` | `string` | | Classes for placing the alert, such as a margin. Restyle it through the theme variables. A clashing class wins or loses by the order Tailwind emits them in. |
1090
- | `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.
1091
1364
 
1092
- ### `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`. |
1093
1388
 
1094
- From `@sjolystinnovation/app-kit/ui`. An uploaded image from the content API, as a plain `<img>`
1095
- with a `srcset` of the widths the API serves. It goes through the content images rewrite from
1096
- `defineAppConfig({ contentImages: true })`, so the browser never contacts the API host. It works in
1097
- 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.
1098
1394
 
1099
1395
  ```tsx
1100
- <ContentImage imageId={post.imageId} alt={post.title} sizes="(min-width: 768px) 50vw, 100vw" />
1101
- <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
+ }
1102
1413
  ```
1103
1414
 
1104
- | Prop | Type | Default | Description |
1105
- | --- | --- | --- | --- |
1106
- | `imageId` | `string \| null` | | Uploaded image id. Null or empty shows `fallbackSrc` as it is, with no `srcset`. |
1107
- | `alt` | `string` | | Empty for a decorative image. |
1108
- | `sizes` | `string` | | Widths the image is shown at. Required, since a browser assumes `100vw` without it. |
1109
- | `fallbackSrc` | `string` | | A static path for when there is no uploaded image. With neither, nothing renders. |
1110
- | `priority` | `boolean` | `false` | For the one image visible without scrolling. It loads eagerly, at high priority. |
1111
- | `width`, `height` | `number` | | Intrinsic size, to reserve space where the layout fixes no ratio. |
1112
- | `base` | `string` | `"/content-images"` | Path the content images rewrite serves. |
1113
- | `widths` | `number[]` | `CONTENT_IMAGE_WIDTHS` | Widths in the `srcset`. |
1114
- | `className` | `string` | | Classes on the `<img>`. |
1415
+ ### `SectionsEditor`
1115
1416
 
1116
- `contentImagePath(id, base?)` and `contentImageSrcSet(id, widths?, base?)` build the same URLs for
1117
- 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.
1118
1419
 
1119
- ### `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.
1120
1424
 
1121
- From `@sjolystinnovation/app-kit/ui`. What an `error.tsx` shows. It is a client component, as an
1122
- 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.
1123
1444
 
1124
1445
  ```tsx
1125
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";
1126
1450
 
1127
- export default function Error({ reset }: { error: Error; reset: () => void }) {
1128
- 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
+ );
1129
1466
  }
1130
1467
  ```
1131
1468
 
1132
- | Prop | Type | Description |
1133
- | --- | --- | --- |
1134
- | `reset` | `() => void` | The `reset` Next passes to `error.tsx`. The try again button calls it. |
1135
- | `homeHref` | `string` | Where the front page link goes, such as `/` or `/en`. |
1136
- | `strings` | `TextOverrides<ErrorStateStrings>` | `title`, `body`, `tryAgain` and `home`. |
1137
- | `className` | `string` | Classes for placing it, on the outer element. |
1138
-
1139
- The error itself is never shown.
1469
+ ## `@sjolystinnovation/app-kit/seo`
1140
1470
 
1141
- ### `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:
1142
1474
 
1143
- From `@sjolystinnovation/app-kit/ui`. What a `not-found.tsx` shows: a large code, the message as
1144
- 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";
1145
1478
 
1146
- ```tsx
1147
- <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
+ });
1148
1488
  ```
1149
1489
 
1150
- | Prop | Type | Description |
1490
+ | Option | Type | Description |
1151
1491
  | --- | --- | --- |
1152
- | `homeHref` | `string` | Where the home link goes. |
1153
- | `strings` | `TextOverrides<NotFoundStrings>` | `code`, `body` and `home`. |
1154
- | `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`. |
1155
1500
 
1156
- ### `PasswordInput`
1501
+ The site provides:
1502
+
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.
1157
1521
 
1158
- A labelled password field with a button that shows and hides the value. It also takes the other
1159
- `<input>` attributes, apart from `type`, which the component switches itself. `className` goes on
1160
- the outer element, see below.
1522
+ ```tsx
1523
+ // src/app/robots.ts
1524
+ export const dynamic = "force-dynamic";
1525
+ export default () => site.robots({ privatePaths: ["/admin", "/login"], privateRootPaths: ["/api/"] });
1526
+ ```
1161
1527
 
1162
1528
  ```tsx
1163
- import { PasswordInput } from "@sjolystinnovation/app-kit/ui";
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
+ ];
1537
+ ```
1164
1538
 
1165
- <PasswordInput label="Password" name="password" required error={errors.password} />;
1539
+ ```tsx
1540
+ // in a page
1541
+ <JsonLd nodes={[site.organizationNode(company), site.webSiteNode(locale)]} />
1166
1542
  ```
1167
1543
 
1168
- | Prop | Type | Default | Description |
1169
- | --- | --- | --- | --- |
1170
- | `label` | `string` | | Visible label, tied to the input. |
1171
- | `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. |
1172
- | `showPasswordLabel` | `string` | `"Show password"` | Accessible name of the toggle while the value is hidden. |
1173
- | `hidePasswordLabel` | `string` | `"Hide password"` | Accessible name of the toggle while the value is visible. |
1174
- | `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. |
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.
1175
1547
 
1176
- Pass the two toggle labels in the app's language. The defaults are English.
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`.
1552
+
1553
+ ## `@sjolystinnovation/app-kit/seo/og`
1177
1554
 
1178
- ### `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.
1179
1560
 
1180
- From `@sjolystinnovation/app-kit/toast`. sonner's `toast`, except that `toast.error` shows nothing
1181
- while the re-sign-in prompt is open. The prompt already says why the request failed. Every other
1182
- toast passes through, and so do the error states of `toast.promise`.
1561
+ ```tsx
1562
+ // src/app/[locale]/og/route.tsx
1563
+ import { shareImage } from "@sjolystinnovation/app-kit/seo/og";
1183
1564
 
1184
- ```ts
1185
- import { toast } from "@sjolystinnovation/app-kit/toast";
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
+ }
1186
1573
  ```
1187
1574
 
1188
- ### `passwordSchema(strings?)`
1575
+ The options type is `ShareImageInput`.
1189
1576
 
1190
- From `@sjolystinnovation/app-kit/validation`. A zod schema for a new password, with the rules the APIs
1191
- enforce through ASP.NET Identity: at least 8 characters, with a lowercase letter, an uppercase letter
1192
- and a digit. No symbol is needed. Identity counts only ASCII, so the letters are A to Z and the digits
1193
- 0 to 9, and "Å" does not count as uppercase. Identity sets no upper limit. An app whose API does adds
1194
- it with `.max()`. A failing password gets one issue per rule it breaks, each with its message from
1195
- `strings`. Missing messages fall back to `defaultPasswordStrings`, which are English.
1577
+ ## `@sjolystinnovation/app-kit/payments`
1196
1578
 
1197
- ```ts
1198
- 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.
1199
1581
 
1200
- const result = passwordSchema({ uppercase: "Ta med en stor bokstav fra A til Z." }).safeParse(newPassword);
1201
- 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
+ }
1202
1611
  ```
1203
1612
 
1204
- `PASSWORD_MIN_LENGTH` holds the minimum, for hint text. `passwordProblems(password, strings?)` gives the
1205
- 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`.
1206
1622
 
1207
- ### `zodFieldErrors(error, options?)`
1623
+ ## `@sjolystinnovation/app-kit/locales/nb`
1208
1624
 
1209
- From `@sjolystinnovation/app-kit/validation`. A failed parse's issues by the top-level field they
1210
- belong to, for showing beside each input. Each field gets its first issue's message, or every message
1211
- in order with `all: true`. `message` replaces zod's text, such as with the app's own translations.
1212
- Issues that belong to no field are left out.
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.
1213
1631
 
1214
- ```ts
1215
- 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` |
1216
1648
 
1217
- const parsed = contactSchema.safeParse(form);
1218
- 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
+ </>;
1219
1658
  ```
1220
1659
 
1221
- ### `@sjolystinnovation/app-kit/testing`
1660
+ ## `@sjolystinnovation/app-kit/testing`
1222
1661
 
1223
1662
  Test doubles for the modules every app's tests mock: `next-auth/react`, `next/navigation`, `sonner`
1224
- 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
1225
1664
  the file's imports, so the factory imports the helpers itself. The test imports the same module to
1226
1665
  set the state and read the spies. The state lasts for the whole file, so call `resetTestMocks`
1227
- before each test. It needs vitest 5, as an optional peer dependency.
1666
+ before each test. It needs vitest 5.
1228
1667
 
1229
1668
  ```tsx
1230
1669
  import { makeSession, navigation, resetTestMocks, setSession } from "@sjolystinnovation/app-kit/testing";
@@ -1243,19 +1682,17 @@ it("sends a user without the role away", () => {
1243
1682
  ```
1244
1683
 
1245
1684
  - `setSession(session | null | "loading")` and `makeSession({ roles, ...overrides })` set what
1246
- `useSession` returns. `auth.signIn`, `auth.signOut` and `auth.getSession` are the spies behind
1247
- next-auth's calls.
1248
- - `navigation.pathname`, `navigation.searchParams` and `navigation.router` feed `usePathname`,
1249
- `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`.
1250
1689
  - `toastMock()` records every toast in `toastSpies`.
1251
- - `axiosInstanceMock()` is a module whose default export is `axiosSpies`, for
1252
- `vi.mock("@/services/axiosInstance", ...)`.
1253
1690
 
1254
1691
  ## Theming
1255
1692
 
1256
- The components draw every colour, corner radius and label size from theme variables with a light
1257
- default. Set any of them in the app's own `@theme` to restyle every component at once, or in a
1258
- 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:
1259
1696
 
1260
1697
  ```css
1261
1698
  /* src/app/globals.css */
@@ -1279,7 +1716,7 @@ selector such as `.dark` for a scoped theme:
1279
1716
  | `--color-alert-warning-bg`, `-border`, `-text` | amber 50, 200, 900 |
1280
1717
  | `--text-field-label`, `--text-field-label--line-height` | `var(--text-sm)` and its line height. Set both together. |
1281
1718
  | `--color-field-label-text` | gray 700 |
1282
- | `--spacing-field-label` | gap under the label, `calc(var(--spacing) * 1)` |
1719
+ | `--spacing-field-label` | the gap under the label, `calc(var(--spacing) * 1)` |
1283
1720
  | `--color-field-required` | red 600, the asterisk on a required field |
1284
1721
  | `--color-field-error` | red 600, the message under a field |
1285
1722
  | `--radius-input` | `var(--radius-lg)` |
@@ -1312,6 +1749,8 @@ selector such as `.dark` for a scoped theme:
1312
1749
  | `--color-admin-tab-indicator` | `var(--color-primary)`, the line under the active tab |
1313
1750
  | `--color-chart-grid` | gray 200, the scale lines of `StatHistoryChart` |
1314
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` |
1315
1754
  | `--radius-button` | `var(--radius-lg)` |
1316
1755
  | `--color-button-primary-bg` | `var(--color-primary)` |
1317
1756
  | `--color-button-primary-text` | `var(--color-primary-foreground)` |
@@ -1331,7 +1770,7 @@ selector such as `.dark` for a scoped theme:
1331
1770
  | `--color-markdown-heading`, `-strong` | gray 900, headings and bold text in `Markdown` |
1332
1771
  | `--color-markdown-link` | gray 900 |
1333
1772
  | `--color-markdown-link-hover` | gray 600 |
1334
- | `--color-markdown-border` | gray 200, rules, quotes and table lines |
1773
+ | `--color-markdown-border` | gray 200, rules, quotes, and table lines |
1335
1774
  | `--color-markdown-code-bg`, `-code-text` | gray 100, gray 900 |
1336
1775
  | `--radius-payment-button` | `var(--radius-button)`, the buttons on a `PaymentReturn` |
1337
1776
  | `--color-payment-success`, `-pending`, `-failed` | green 600, amber 500, red 600, the icon on a `PaymentReturn` |
@@ -1340,7 +1779,7 @@ selector such as `.dark` for a scoped theme:
1340
1779
 
1341
1780
  A default that points at another variable is read once for the whole page. The focus ring and the
1342
1781
  primary button point at `--color-primary` and `--color-primary-foreground`. A scoped theme that
1343
- 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
1344
1783
  `--color-button-primary-text` in the same selector.
1345
1784
 
1346
1785
  An app that clears a whole Tailwind namespace, such as `--color-*: initial`, clears these variables
@@ -1348,26 +1787,14 @@ with it and has to set the ones it uses itself.
1348
1787
 
1349
1788
  ## Requirements
1350
1789
 
1351
- - Next.js 16 and React 19, as peer dependencies the app installs itself.
1352
- - For the root entry, `auth`, `api`, `server`, `session/react`, `ui` and `admin`: next-auth 4.24
1353
- or later and axios 1.20 or later, as optional peer dependencies. A site without sign-in, which
1354
- uses only entries such as `seo`, `next-config`, `markdown`, `toast` or `validation`, leaves them
1355
- out.
1356
- - For `@sjolystinnovation/app-kit/validation`: zod 4, as an optional peer dependency.
1357
- - For `@sjolystinnovation/app-kit/testing`: vitest 5, as an optional peer dependency.
1358
- - For `@sjolystinnovation/app-kit/toast`: sonner 2, as an optional peer dependency. Install a single
1359
- copy, because two copies keep two separate toast stores.
1360
- - `jsonwebtoken` comes with the package. `@sjolystinnovation/app-kit/auth` uses it on the server.
1361
- - For `@sjolystinnovation/app-kit/ui`, `@sjolystinnovation/app-kit/admin` and the components in
1362
- `session/react`: Tailwind CSS 4 and `@heroicons/react` 2. The focus ring and the primary button
1363
- use the theme's `primary` and `primary-foreground` colours, unless their own variables are set.
1364
- - TypeScript with `"moduleResolution": "bundler"`. Relative imports here are extensionless, which
1365
- `node16` and `nodenext` reject.
1366
-
1367
- There is no build output. Bundlers strip or relocate `"use client"` directives, and shipping source
1368
- keeps them intact. The consumer compiles this source with its own `compilerOptions`, and
1369
- `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.
1370
1797
 
1371
1798
  ## License
1372
1799
 
1373
- [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