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