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