@sjolystinnovation/app-kit 0.20.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +315 -253
- package/package.json +13 -1
- package/src/admin/AdminShell.tsx +2 -1
- package/src/admin/AdminUsersPanel.tsx +9 -3
- package/src/admin/CompanySettingsForm.tsx +123 -0
- package/src/admin/LegalPagesEditor.tsx +132 -0
- package/src/admin/LocaleTabs.tsx +36 -0
- package/src/admin/StatHistoryChart.tsx +100 -0
- package/src/admin/adminUsersPanelStrings.ts +3 -3
- package/src/admin/companySettingsFormStrings.ts +50 -0
- package/src/admin/index.ts +14 -0
- package/src/admin/legalPagesEditorStrings.ts +32 -0
- package/src/admin/statHistoryChartStrings.ts +17 -0
- package/src/api/apiFieldErrors.ts +4 -4
- package/src/api/company.ts +47 -0
- package/src/api/createApiClient.ts +3 -2
- package/src/{errors.ts → api/formatApiError.ts} +12 -12
- package/src/api/index.ts +6 -1
- package/src/api/request.ts +2 -2
- package/src/api/revalidateTargets.ts +42 -0
- package/src/api/services.ts +14 -13
- package/src/auth/createAuthOptions.ts +7 -5
- package/src/branding/BrandingManager.tsx +10 -5
- package/src/branding/BrandingProvider.tsx +5 -3
- package/src/branding/index.ts +1 -2
- 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 +62 -0
- package/src/legal/index.ts +1 -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/seo/JsonLd.tsx +1 -1
- package/src/seo/environment.ts +3 -3
- package/src/seo/index.ts +2 -1
- package/src/seo/og/index.tsx +5 -4
- package/src/seo/schema.ts +6 -2
- package/src/seo/site.ts +90 -30
- 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 +8 -5
- package/src/session/react/AppSessionProvider.tsx +3 -2
- package/src/{ui → session/react}/CredentialsForm.tsx +10 -9
- 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 +1 -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 +113 -0
- package/src/ui/Alert.tsx +6 -4
- package/src/ui/ContentImage.tsx +12 -11
- package/src/ui/ErrorState.tsx +5 -3
- package/src/ui/NotFoundState.tsx +9 -7
- package/src/ui/PasswordInput.tsx +6 -5
- package/src/ui/ResetPasswordFlow.tsx +10 -8
- package/src/ui/TextArea.tsx +6 -4
- package/src/ui/TextInput.tsx +4 -2
- package/src/ui/Toggle.tsx +3 -3
- package/src/ui/field.tsx +5 -3
- package/src/ui/index.ts +2 -10
- package/src/ui/resetPasswordFlowStrings.ts +7 -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
|
@@ -9,7 +9,7 @@ Several apps sharing one API also share the code around it:
|
|
|
9
9
|
|
|
10
10
|
- the session plumbing
|
|
11
11
|
- how a failed request becomes a message a user can read
|
|
12
|
-
- the form controls on the sign
|
|
12
|
+
- the form controls on the sign-in page
|
|
13
13
|
|
|
14
14
|
This package holds that code once, and each app installs it.
|
|
15
15
|
|
|
@@ -28,8 +28,8 @@ const nextConfig: NextConfig = {
|
|
|
28
28
|
};
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
The components in `@sjolystinnovation/app-kit/ui`, `
|
|
32
|
-
Import the package's stylesheet after Tailwind:
|
|
31
|
+
The components in `@sjolystinnovation/app-kit/ui`, `admin`, `legal`, `markdown`, `branding`,
|
|
32
|
+
`payments`, and `session/react` use Tailwind CSS. Import the package's stylesheet after Tailwind:
|
|
33
33
|
|
|
34
34
|
```css
|
|
35
35
|
/* src/app/globals.css */
|
|
@@ -39,7 +39,7 @@ Import the package's stylesheet after Tailwind:
|
|
|
39
39
|
|
|
40
40
|
## Usage
|
|
41
41
|
|
|
42
|
-
Declare the values that differ between apps once, then pass that config wherever
|
|
42
|
+
Declare the values that differ between apps once, then pass that config wherever you need it. One
|
|
43
43
|
app can define it, or several apps can each define their own.
|
|
44
44
|
|
|
45
45
|
```ts
|
|
@@ -53,12 +53,8 @@ export const sessionConfig = defineSessionConfig({
|
|
|
53
53
|
});
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
import { sessionConfig } from "@/lib/session";
|
|
59
|
-
|
|
60
|
-
const secret = resolveJwtSecret(sessionConfig, process.env);
|
|
61
|
-
```
|
|
56
|
+
The root entry point holds only `defineSessionConfig`, `requireEnv`, and `SessionConfig`. It
|
|
57
|
+
imports no peer dependency, so it works in browser code, server code, and `next.config.ts`.
|
|
62
58
|
|
|
63
59
|
## API
|
|
64
60
|
|
|
@@ -85,55 +81,40 @@ Returns `value`, or throws `Missing <name>.` when it is unset or empty. Pass the
|
|
|
85
81
|
place, such as `process.env.NEXT_PUBLIC_API_URL`, so Next.js can inline a public variable at build
|
|
86
82
|
time.
|
|
87
83
|
|
|
88
|
-
### `resolveJwtSecret(config, env)`
|
|
89
|
-
|
|
90
|
-
`(config: SessionConfig, env: Record<string, string | undefined>) => string`
|
|
91
|
-
|
|
92
|
-
Reads the secret named by `config.jwtSecretEnvVar`. Throws when that variable is unset or empty,
|
|
93
|
-
with the variable's name in the message.
|
|
94
|
-
|
|
95
|
-
```ts
|
|
96
|
-
resolveJwtSecret(sessionConfig, { API_JWT_SECRET: "s3cret" });
|
|
97
|
-
// "s3cret"
|
|
98
|
-
|
|
99
|
-
resolveJwtSecret(sessionConfig, {});
|
|
100
|
-
// Error: Missing API_JWT_SECRET. The app verifies API session tokens with it.
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
The environment is a parameter, so the root entry point needs no Node types and works in browser
|
|
104
|
-
code. `@sjolystinnovation/app-kit/auth` is server only and does load Node types.
|
|
105
|
-
|
|
106
84
|
### `formatApiError(error, fallback, statusMessages?)`
|
|
107
85
|
|
|
108
86
|
`(error: unknown, fallback: string, statusMessages?: StatusMessages) => string`
|
|
109
87
|
|
|
110
|
-
Turns a failed request into one line a user can read.
|
|
88
|
+
From `@sjolystinnovation/app-kit/api`. Turns a failed request into one line a user can read.
|
|
111
89
|
|
|
112
|
-
On a 4xx response it uses the first of:
|
|
90
|
+
On a 4xx response, it uses the first of these:
|
|
113
91
|
|
|
114
92
|
1. The body's own message: a text body, such as the one `BadRequest("...")` sends from ASP.NET Core,
|
|
115
|
-
or `message`, the first validation error or `detail` from a JSON body.
|
|
93
|
+
or `message`, the first validation error, or `detail` from a JSON body.
|
|
116
94
|
2. The entry in `statusMessages` for the status.
|
|
117
95
|
3. `fallback`.
|
|
118
96
|
|
|
119
|
-
On a 5xx response the entry in `statusMessages` comes first, then the body, then `fallback`. A 5xx
|
|
97
|
+
On a 5xx response, the entry in `statusMessages` comes first, then the body, then `fallback`. A 5xx
|
|
120
98
|
body can come from a proxy, such as "Bad Gateway", so the caller's own words win when it has them.
|
|
121
99
|
|
|
122
|
-
|
|
123
|
-
Request" or "An error occurred while processing your request.", which says less than
|
|
100
|
+
The function never shows a problem details `title`. ASP.NET Core fills it with generic text, such
|
|
101
|
+
as "Bad Request" or "An error occurred while processing your request.", which says less than
|
|
102
|
+
`fallback`.
|
|
124
103
|
|
|
125
104
|
A few more rules:
|
|
126
105
|
|
|
127
106
|
- Key `0` in `statusMessages` covers a request that got no response, because of a dropped
|
|
128
107
|
connection or a timeout.
|
|
129
108
|
- Only text on a single line counts as a message.
|
|
130
|
-
- Axios's own message, such as "Request failed with status code 500"
|
|
131
|
-
- A
|
|
109
|
+
- The function never shows Axios's own message, such as "Request failed with status code 500".
|
|
110
|
+
- A canceled request gives `fallback`.
|
|
132
111
|
- Any other `Error` gives its own message.
|
|
133
|
-
-
|
|
134
|
-
download gets `statusMessages` or `fallback`.
|
|
112
|
+
- The function does not read a body requested as an `arraybuffer` or a `blob`, as for a file
|
|
113
|
+
download. A failed download gets `statusMessages` or `fallback`.
|
|
135
114
|
|
|
136
115
|
```ts
|
|
116
|
+
import { formatApiError } from "@sjolystinnovation/app-kit/api";
|
|
117
|
+
|
|
137
118
|
try {
|
|
138
119
|
await api.post("/users", form);
|
|
139
120
|
} catch (error) {
|
|
@@ -170,27 +151,28 @@ export { handler as GET, handler as POST };
|
|
|
170
151
|
|
|
171
152
|
| Setting | Type | Description |
|
|
172
153
|
| --- | --- | --- |
|
|
173
|
-
| `apiUrl` | `string` | Base URL of the API. Sign-in posts to `/auth/login`, refresh to `/auth/refresh-token
|
|
154
|
+
| `apiUrl` | `string` | Base URL of the API. Sign-in posts to `/auth/login`, refresh to `/auth/refresh-token`, and sign-out to `/auth/logout` under it. An empty value throws. |
|
|
174
155
|
| `env` | `Record<string, string \| undefined>` | The server environment, usually `process.env`. It supplies the secret named by `jwtSecretEnvVar`. |
|
|
175
|
-
| `http` | `AxiosInstance` |
|
|
156
|
+
| `http` | `AxiosInstance` | The client for sign-in and refresh, with its own base URL. Use it in place of `apiUrl`, for tests. |
|
|
176
157
|
|
|
177
158
|
next-auth reads `NEXTAUTH_SECRET` itself and refuses to run in production without it.
|
|
178
159
|
|
|
179
160
|
What the options do:
|
|
180
161
|
|
|
181
|
-
- **Sign-in** posts `{ email, password }` and expects `{ token, refreshToken }`.
|
|
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
|
|
162
|
+
- **Sign-in** posts `{ email, password }` and expects `{ token, refreshToken }`. It verifies the
|
|
163
|
+
access token with the API JWT secret and allows 60 seconds of clock difference between the server
|
|
164
|
+
and the API. Its `sub`, `unique_name`, and `role` claims become the user's `id`, `name`, and
|
|
165
|
+
`roles`. The log gets the status, error code, and message, never the request. If sign-in fails,
|
|
166
|
+
next-auth's `error` holds one of these:
|
|
167
|
+
- `SIGN_IN_ERRORS.unavailable` when the API does not answer, or a gateway returns 502, 503, or
|
|
168
|
+
504.
|
|
187
169
|
- `SIGN_IN_ERRORS.invalidCredentials` for every other failure, including an unreadable token and
|
|
188
170
|
an answer without both tokens.
|
|
189
171
|
- **Refresh** runs ahead of expiry: five minutes before it, or a quarter of the token's lifetime
|
|
190
|
-
before it when that is shorter.
|
|
191
|
-
lives under 30 seconds
|
|
192
|
-
process share a single refresh.
|
|
193
|
-
refresh token on every call.
|
|
172
|
+
before it when that is shorter. The options never schedule it sooner than 30 seconds out, so a
|
|
173
|
+
token that lives under 30 seconds gets its refresh after it expires. Parallel reads of one session
|
|
174
|
+
in one server process share a single refresh. The options keep its answer for a minute, because
|
|
175
|
+
the API rotates the refresh token on every call.
|
|
194
176
|
- **Failures:** a 400 or 401 from the refresh ends the session with `RefreshTokenRejected`. Any
|
|
195
177
|
other failure keeps it and tries again a minute later. A session without a refresh token ends with
|
|
196
178
|
`NoRefreshToken` when its refresh falls due.
|
|
@@ -200,38 +182,38 @@ What the options do:
|
|
|
200
182
|
seven days in milliseconds.
|
|
201
183
|
- **Sign-in page:** `pages.signIn` is `config.loginRoute`.
|
|
202
184
|
|
|
203
|
-
|
|
185
|
+
Follow two rules so that you never lose a refresh:
|
|
204
186
|
|
|
205
187
|
- Read the session on the client, through `useSession` or `getSession`. In the App Router,
|
|
206
|
-
`getServerSession(authOptions)` cannot store a cookie
|
|
207
|
-
server actions
|
|
188
|
+
`getServerSession(authOptions)` cannot store a cookie in server components, route handlers, or
|
|
189
|
+
server actions. A refresh there replaces the tokens at the API and loses the new ones. The
|
|
208
190
|
next refresh then reuses the old token, and the API revokes every session the user has. On the
|
|
209
191
|
server, read the token with `getToken` from `next-auth/jwt`, which never refreshes.
|
|
210
192
|
- Run the app as one server process. Parallel session reads share one refresh only within a process.
|
|
211
193
|
Two replicas can refresh the same token at once, with the same result.
|
|
212
194
|
|
|
213
|
-
The package declares these fields on next-auth's types in `@sjolystinnovation/app-kit/next-auth`.
|
|
214
|
-
|
|
195
|
+
The package declares these fields on next-auth's types in `@sjolystinnovation/app-kit/next-auth`.
|
|
196
|
+
Load the declarations once, from one of the app's own `.d.ts` files, so its code can read them:
|
|
215
197
|
|
|
216
198
|
```ts
|
|
217
199
|
// src/types/next-auth.d.ts
|
|
218
200
|
import "@sjolystinnovation/app-kit/next-auth";
|
|
219
201
|
```
|
|
220
202
|
|
|
221
|
-
The session carries `user` (`id`, `name`, `email`, `roles`), `accessToken`, `error
|
|
222
|
-
`absoluteExpiresAt`. The token carries `accessToken`, `refreshToken`, `refreshAt`, `loginAt`,
|
|
223
|
-
and `error`.
|
|
203
|
+
The session carries `user` (`id`, `name`, `email`, `roles`), `accessToken`, `error`, and
|
|
204
|
+
`absoluteExpiresAt`. The token carries `accessToken`, `refreshToken`, `refreshAt`, `loginAt`,
|
|
205
|
+
`user`, and `error`.
|
|
224
206
|
|
|
225
207
|
### `@sjolystinnovation/app-kit/api`
|
|
226
208
|
|
|
227
209
|
`createApiClient(baseURL)` returns an axios instance for the signed-in API, for code that runs in
|
|
228
210
|
the browser. It reads the session through next-auth's `getSession`, which has no cookies to read on
|
|
229
|
-
the server. An empty `baseURL` throws. Each request carries the session's access token.
|
|
230
|
-
|
|
231
|
-
|
|
211
|
+
the server. An empty `baseURL` throws. Each request carries the session's access token. When the
|
|
212
|
+
session is gone or has a terminal error, a 401 opens the re-sign-in prompt. It does not sign the
|
|
213
|
+
user out, so the page keeps its state. The error still rejects as usual.
|
|
232
214
|
|
|
233
215
|
`request(call, fallback, statusMessages?)` runs a call and returns its `data`. A failure becomes an
|
|
234
|
-
`Error` with the message `formatApiError` gives it. A
|
|
216
|
+
`Error` with the message `formatApiError` gives it. A canceled call rethrows the cancel unchanged:
|
|
235
217
|
|
|
236
218
|
```ts
|
|
237
219
|
import { requireEnv } from "@sjolystinnovation/app-kit";
|
|
@@ -243,29 +225,45 @@ export const getRecipe = (id: number) =>
|
|
|
243
225
|
request(() => api.get<Recipe>(`/recipes/${id}`), "Could not load the recipe.");
|
|
244
226
|
```
|
|
245
227
|
|
|
246
|
-
`
|
|
247
|
-
`
|
|
248
|
-
|
|
228
|
+
`mergeCompanyInfo(data, fallback)` gives the company details for public pages: the API's
|
|
229
|
+
`/company` answer, with the app's own value for each field it left blank or could not give. Only
|
|
230
|
+
the fallback's name is required. The legal name falls back to the name, and VAT to not registered.
|
|
231
|
+
`formatOrgNumber({ orgNumber, vatRegistered })` adds ` MVA` once the business is VAT registered,
|
|
232
|
+
and gives an empty string without a number.
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
// src/lib/companyInfo.ts
|
|
236
|
+
export const getCompanyInfo = async () => mergeCompanyInfo(await publicGetOptional<CompanyInfo>("/company"), COMPANY);
|
|
237
|
+
```
|
|
249
238
|
|
|
250
239
|
`apiFieldErrors(error)` reads the per-field messages of an ASP.NET validation problem from a failed
|
|
251
240
|
call or its body, keyed in camelCase to match the form: `{ errors: { OrgNumber: ["..."] } }` becomes
|
|
252
|
-
`{ orgNumber: ["..."] }`. It
|
|
241
|
+
`{ orgNumber: ["..."] }`. It returns `null` when there are none, so the form falls back to one
|
|
242
|
+
message.
|
|
253
243
|
|
|
254
244
|
`createPublicClient(baseURL)` is the same without the session, for the API's anonymous endpoints. An
|
|
255
245
|
empty `baseURL` throws, and requests only go to `baseURL`.
|
|
256
246
|
|
|
257
|
-
The services below call the endpoints the SjolystInnovation.AppKit .NET packages map, so
|
|
258
|
-
not write them. `api` is a `createApiClient` instance and `publicApi` a
|
|
247
|
+
The services below call the endpoints that the SjolystInnovation.AppKit .NET packages map, so you
|
|
248
|
+
do not write them in the app. `api` is a `createApiClient` instance, and `publicApi` is a
|
|
249
|
+
`createPublicClient` one.
|
|
259
250
|
Failures throw an `Error` with the API's message, or with the service's own English fallback.
|
|
260
|
-
`onSaved` runs after a save succeeds, such as `() =>
|
|
251
|
+
`onSaved` runs after a save succeeds, such as `() => revalidateTarget("branding")`. Here
|
|
252
|
+
`revalidateTarget` is the `revalidate` that
|
|
253
|
+
[`defineRevalidateTargets`](#sjolystinnovationapp-kitserver) returns. It posts the target to
|
|
254
|
+
the app's revalidate route and never throws. On failure, the pages' own revalidate window is the
|
|
255
|
+
fallback.
|
|
256
|
+
|
|
257
|
+
`invite` takes `{ email, firstName, lastName, role }`. `save` takes
|
|
258
|
+
`{ key, locale, title, bodyMarkdown }`.
|
|
261
259
|
|
|
262
260
|
| Factory | Methods | .NET package |
|
|
263
261
|
| --- | --- | --- |
|
|
264
|
-
| `createUserService(api, publicApi)` | `requestPasswordReset`, `resetPassword`, and for the signed-in user `getProfile`, `updateProfile`, `changePassword`, `exportData`, `deleteAccount` | Auth |
|
|
265
|
-
| `createAdminUsersService(api)` | `getUsers(includeDeleted?)`, `addRole`, `removeRole`, `deleteUser`, `invite` | Auth |
|
|
262
|
+
| `createUserService(api, publicApi)` | `requestPasswordReset`, `resetPassword`, and for the signed-in user `getProfile`, `updateProfile`, `changePassword`, `exportData`, and `deleteAccount` | Auth |
|
|
263
|
+
| `createAdminUsersService(api)` | `getUsers(includeDeleted?)`, `addRole`, `removeRole`, `deleteUser`, and `invite` | Auth |
|
|
266
264
|
| `createSettingsService(api, { onSaved? })` | `get`, `update` | SiteSettings |
|
|
267
265
|
| `createBrandingService(api, publicApi, { onSaved? })` | `get`, which answers `{}` when the API cannot be read, and `update(form)` | SiteSettings |
|
|
268
|
-
| `createLegalService<Key>(api, publicApi, { onSaved? })` | `get(key, locale)`, which answers `null` for a missing page, `listAll`, `save` | SiteSettings |
|
|
266
|
+
| `createLegalService<Key>(api, publicApi, { onSaved? })` | `get(key, locale)`, which answers `null` for a missing page, `listAll`, and `save` | SiteSettings |
|
|
269
267
|
|
|
270
268
|
```ts
|
|
271
269
|
import { createApiClient, createPublicClient, createUserService } from "@sjolystinnovation/app-kit/api";
|
|
@@ -274,7 +272,8 @@ const apiUrl = requireEnv("NEXT_PUBLIC_API_URL", process.env.NEXT_PUBLIC_API_URL
|
|
|
274
272
|
export default createUserService(createApiClient(apiUrl), createPublicClient(apiUrl));
|
|
275
273
|
```
|
|
276
274
|
|
|
277
|
-
The
|
|
275
|
+
The package also exports the response types: `UserProfile`, `AdminUser`, `SiteSettings`, and
|
|
276
|
+
`LegalPage`.
|
|
278
277
|
|
|
279
278
|
### `@sjolystinnovation/app-kit/server`
|
|
280
279
|
|
|
@@ -282,15 +281,15 @@ For server components and route handlers only. It imports `next/cache` and `next
|
|
|
282
281
|
|
|
283
282
|
`createPublicApi(baseUrl, options?)` returns cached GETs against the API's anonymous endpoints:
|
|
284
283
|
|
|
285
|
-
- `publicGet(path, { tags? })` gives the data, or null when the API answers 404. An unreachable or
|
|
284
|
+
- `publicGet(path, { tags? })` gives the data, or `null` when the API answers 404. An unreachable or
|
|
286
285
|
failing API throws `PublicApiError`, so a failed fetch is never cached as an empty page in place
|
|
287
286
|
of a working one.
|
|
288
|
-
- `publicGetOptional(path, { tags? })` gives null on any failure, for data a page can do without.
|
|
287
|
+
- `publicGetOptional(path, { tags? })` gives `null` on any failure, for data a page can do without.
|
|
289
288
|
- `publicGetWithMeta(path, { tags? })` also gives `lastModified`, from the `Last-Modified` header.
|
|
290
289
|
|
|
291
290
|
Responses stay fresh for 60 seconds unless `options.revalidate` says otherwise. An empty `baseUrl`
|
|
292
291
|
throws. Each path starts with a single `/`, or the call throws. A path with a `.` or `..` segment,
|
|
293
|
-
encoded or not, gives null without a request, the same as a 404. Run a slug or other user input
|
|
292
|
+
encoded or not, gives `null` without a request, the same as a 404. Run a slug or other user input
|
|
294
293
|
through `encodeURIComponent` before putting it in a path.
|
|
295
294
|
|
|
296
295
|
```ts
|
|
@@ -305,11 +304,11 @@ export const { publicGet, publicGetOptional, publicGetWithMeta } = createPublicA
|
|
|
305
304
|
|
|
306
305
|
`createRevalidateRoute({ targets, role })` returns a `POST` handler for `app/api/revalidate/route.ts`.
|
|
307
306
|
Each target maps to the page paths it purges. A path with a dynamic segment, such as
|
|
308
|
-
`/builds/[slug]`, purges every page of that route. The target name
|
|
309
|
-
tag the fetches behind it with the same name. The handler answers 403 unless the caller has
|
|
310
|
-
session with `role`: signed in within the last seven days, with no refused refresh and
|
|
311
|
-
token that has not expired. It answers 415 unless the
|
|
312
|
-
`
|
|
307
|
+
`/builds/[slug]`, purges every page of that route. The route also purges the target name as a cache
|
|
308
|
+
tag, so tag the fetches behind it with the same name. The handler answers 403 unless the caller has
|
|
309
|
+
a live session with `role`: signed in within the last seven days, with no refused refresh, and with
|
|
310
|
+
an access token that has not expired. It answers 415 unless the caller sends the body as JSON,
|
|
311
|
+
which the `revalidate` from `defineRevalidateTargets` does. It answers 400 for a target it does not know.
|
|
313
312
|
|
|
314
313
|
```ts
|
|
315
314
|
// src/app/api/revalidate/route.ts
|
|
@@ -321,14 +320,35 @@ export const POST = createRevalidateRoute({
|
|
|
321
320
|
});
|
|
322
321
|
```
|
|
323
322
|
|
|
323
|
+
`defineRevalidateTargets({ locales, targets })`, from `@sjolystinnovation/app-kit/api`, keeps the
|
|
324
|
+
targets in one file that both sides import. `pages` are below each locale segment, and `root` paths
|
|
325
|
+
are not. It returns `targets` for fetch tags, `paths` for the route, and `revalidate(target)` for
|
|
326
|
+
the admin services.
|
|
327
|
+
|
|
328
|
+
```ts
|
|
329
|
+
// src/lib/cacheTags.ts
|
|
330
|
+
export const { targets: REVALIDATE_TARGETS, paths: TARGET_PATHS, revalidate: revalidateTarget } = defineRevalidateTargets({
|
|
331
|
+
locales: LOCALES,
|
|
332
|
+
targets: {
|
|
333
|
+
legal: { pages: ["/terms", "/privacy", "/cookies"], root: ["/sitemap.xml"] },
|
|
334
|
+
// Every page renders branding through the layout, so its tag reaches them all.
|
|
335
|
+
branding: {},
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// src/app/api/revalidate/route.ts
|
|
340
|
+
export const POST = createRevalidateRoute({ targets: TARGET_PATHS, role: ADMIN_ROLE });
|
|
341
|
+
```
|
|
342
|
+
|
|
324
343
|
### `@sjolystinnovation/app-kit/next-config`
|
|
325
344
|
|
|
326
345
|
For `next.config.ts`. It imports nothing but Next's types, so Next loads it with no build step.
|
|
327
346
|
|
|
328
347
|
`defineAppConfig(options, extra?)` gives the config every app shares: the standalone build, app-kit
|
|
329
348
|
in `transpilePackages`, no `X-Powered-By`, `expireTime` of five minutes, image qualities 75 and 100,
|
|
330
|
-
the incremental cache in memory, and the security headers on every page. `extra`
|
|
331
|
-
|
|
349
|
+
the incremental cache in memory, and the security headers on every page. It merges `extra` over
|
|
350
|
+
that config and combines `transpilePackages`, `images`, `experimental`, `headers`, and `rewrites`
|
|
351
|
+
rather than replacing them.
|
|
332
352
|
|
|
333
353
|
```ts
|
|
334
354
|
// next.config.ts
|
|
@@ -355,17 +375,14 @@ export default defineAppConfig({
|
|
|
355
375
|
| `expireTime` | `number` | Seconds a cache may serve a stale page. Defaults to 300. Next's default is a year. |
|
|
356
376
|
| `isrFlushToDisk` | `boolean` | Write revalidated pages to disk. Off by default, for a read-only root filesystem. |
|
|
357
377
|
|
|
358
|
-
|
|
359
|
-
`contentImagesRewrite(apiUrl, { source?, upstreamPath? })`, `apiOrigin(apiUrl)` and
|
|
360
|
-
`apiBaseUrl(apiUrl)`. Scripts allow `'unsafe-inline'`, since a nonce would make every statically
|
|
361
|
-
rendered page dynamic.
|
|
378
|
+
Scripts allow `'unsafe-inline'`, since a nonce would make every statically rendered page dynamic.
|
|
362
379
|
|
|
363
380
|
### `@sjolystinnovation/app-kit/markdown`
|
|
364
381
|
|
|
365
|
-
Renders Markdown from an editor or the API, with GitHub tables, task lists and footnotes.
|
|
366
|
-
|
|
367
|
-
handlers or `javascript:` links. It works in a server component. It needs `react-markdown`,
|
|
368
|
-
`remark-gfm
|
|
382
|
+
Renders Markdown from an editor or the API, with GitHub tables, task lists, and footnotes. It never
|
|
383
|
+
renders raw HTML and sanitizes the output, so the text cannot inject scripts, styles, event
|
|
384
|
+
handlers, or `javascript:` links. It works in a server component. It needs `react-markdown`,
|
|
385
|
+
`remark-gfm`, and `rehype-sanitize`, which are optional peer dependencies:
|
|
369
386
|
|
|
370
387
|
```sh
|
|
371
388
|
npm install react-markdown remark-gfm rehype-sanitize
|
|
@@ -381,20 +398,19 @@ import { Markdown } from "@sjolystinnovation/app-kit/markdown";
|
|
|
381
398
|
|
|
382
399
|
| Prop | Type | Default | Description |
|
|
383
400
|
| --- | --- | --- | --- |
|
|
384
|
-
| `children` | `string \| null \| undefined` | | The Markdown. Empty, null or undefined renders nothing. |
|
|
401
|
+
| `children` | `string \| null \| undefined` | | The Markdown. Empty, `null`, or `undefined` renders nothing. |
|
|
385
402
|
| `components` | `Components` | | Replaces the element for a tag, such as `{ p: MyParagraph }`. Tags not named keep the default. It gets the props without `node`, so it can spread them onto an element. |
|
|
386
403
|
| `externalLinks` | `boolean` | `false` | Opens `http` and `https` links in a new tab with `rel="noopener noreferrer"`. Site-relative links stay in the tab. |
|
|
387
404
|
| `className` | `string` | | Wraps the output in a `div` with these classes. Without it the elements render bare, so a parent's `space-y-*` spaces them. |
|
|
388
405
|
|
|
389
|
-
The text takes its
|
|
390
|
-
the page has its own `h1`.
|
|
391
|
-
on them.
|
|
406
|
+
The text takes its color and size from the parent. A level one heading becomes a level two, since
|
|
407
|
+
the page has its own `h1`.
|
|
392
408
|
|
|
393
409
|
### `@sjolystinnovation/app-kit/seo`
|
|
394
410
|
|
|
395
411
|
Search engine helpers for one site: canonical URLs with hreflang, page metadata with Open Graph,
|
|
396
|
-
sitemap entries, robots.txt and schema.org structured data. It works in a server component.
|
|
397
|
-
`defineSite` binds them to the site's name, canonical origin and locales:
|
|
412
|
+
sitemap entries, `robots.txt`, and schema.org structured data. It works in a server component.
|
|
413
|
+
`defineSite` binds them to the site's name, canonical origin, and locales:
|
|
398
414
|
|
|
399
415
|
```ts
|
|
400
416
|
// src/lib/seo/site.ts
|
|
@@ -413,7 +429,7 @@ export const site = defineSite({
|
|
|
413
429
|
|
|
414
430
|
| Option | Type | Description |
|
|
415
431
|
| --- | --- | --- |
|
|
416
|
-
| `name` | `string` | The site's name, in titles, Open Graph and structured data. |
|
|
432
|
+
| `name` | `string` | The site's name, in titles, Open Graph, and structured data. |
|
|
417
433
|
| `url` | `string` | The canonical origin, without a trailing slash. |
|
|
418
434
|
| `titleSeparator` | `string` | Between a page title and the site name. |
|
|
419
435
|
| `locales` | `string[]` | The locale segments every page is under. A site without them leaves it out, and its paths have no segment. |
|
|
@@ -422,20 +438,24 @@ export const site = defineSite({
|
|
|
422
438
|
| `tagline` | `(locale) => string` | The line after the name in the default title and the share image's alt text. |
|
|
423
439
|
| `shareImagePath` | `string` | The share image route below each locale. Defaults to `/og`. |
|
|
424
440
|
|
|
425
|
-
The site
|
|
441
|
+
The site provides:
|
|
426
442
|
|
|
427
443
|
- `localePath(locale, path)`, `absoluteUrl(path)`, `absoluteLocaleUrl(locale, path)` and
|
|
428
444
|
`languageAlternates(path)`. Paths get a leading slash and no trailing slash.
|
|
429
445
|
- `siteMetadata({ locale, description, manifest })` for the locale root layout: the title
|
|
430
|
-
template, `metadataBase`, the index directive and the front page's canonical.
|
|
446
|
+
template, `metadataBase`, the index directive, and the front page's canonical.
|
|
431
447
|
- `pageMetadata({ locale, path, title, description, images })` for a page: the canonical URL,
|
|
432
448
|
hreflang, and Open Graph with the locale's share image unless `images` is given.
|
|
433
449
|
- `localeEntries(path, { locales, lastModified })` for `sitemap.ts`: one entry per locale, each
|
|
434
|
-
listing the others. `lastModified` is
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
- `
|
|
438
|
-
`
|
|
450
|
+
listing the others. It leaves out `lastModified` when that is unknown. A site without locales
|
|
451
|
+
gets one entry per page.
|
|
452
|
+
- `forEachLocale(load)` runs `load` for every locale at once and keys the results by locale.
|
|
453
|
+
- `legalEntries({ keys, updatedAt })` gives the legal pages' entries, `/terms`, `/privacy` and
|
|
454
|
+
`/cookies` unless `keys` says otherwise, each dated by the timestamp `updatedAt` returns for it.
|
|
455
|
+
- `robots({ privatePaths, privateRootPaths, indexable })` for `robots.ts`. It disallows the private
|
|
456
|
+
paths under every locale segment. A deployment that is not production disallows everything.
|
|
457
|
+
- `organizationNode(company, { type, sameAs, country, image, logo })`, `webSiteNode(locale)`,
|
|
458
|
+
`breadcrumbNode(crumbs, pageUrl)`, `itemListNode(paths, pageUrl)`, `siteId(fragment)`, and `ids`
|
|
439
459
|
for structured data.
|
|
440
460
|
|
|
441
461
|
```tsx
|
|
@@ -443,20 +463,29 @@ The site gives:
|
|
|
443
463
|
export const dynamic = "force-dynamic";
|
|
444
464
|
export default () => site.robots({ privatePaths: ["/admin", "/login"], privateRootPaths: ["/api/"] });
|
|
445
465
|
|
|
466
|
+
// src/app/sitemap.ts
|
|
467
|
+
export const revalidate = 3600;
|
|
468
|
+
export default async () => [
|
|
469
|
+
...site.localeEntries(""),
|
|
470
|
+
...(await site.legalEntries({
|
|
471
|
+
updatedAt: async (key, locale) => (await publicGet<LegalPage>(`/content/legal/${key}?locale=${locale}`))?.updatedAt,
|
|
472
|
+
})),
|
|
473
|
+
];
|
|
474
|
+
|
|
446
475
|
// in a page
|
|
447
476
|
<JsonLd nodes={[site.organizationNode(company), site.webSiteNode(locale)]} />
|
|
448
477
|
```
|
|
449
478
|
|
|
450
|
-
`isIndexableEnvironment()` reads `SITE_ENV`. `prod`, `production
|
|
479
|
+
`isIndexableEnvironment()` reads `SITE_ENV`. `prod`, `production`, and unset or blank count as
|
|
451
480
|
production, so a missing variable never deindexes the live site. `robots.ts` needs
|
|
452
481
|
`dynamic = "force-dynamic"`, so it reads the variable per request. The package also exports
|
|
453
|
-
`JsonLd`, `serializeGraph`, `ref`, `NOINDEX`, `
|
|
482
|
+
`JsonLd`, `serializeGraph`, `ref`, `NOINDEX`, `LEGAL_PAGE_KEYS`, `SHARE_IMAGE_SIZE`, `latest`, and
|
|
454
483
|
`parseTimestamp`.
|
|
455
484
|
|
|
456
485
|
### `@sjolystinnovation/app-kit/seo/og`
|
|
457
486
|
|
|
458
487
|
`shareImage({ name, tagline, colors })` renders the default share preview, a PNG at
|
|
459
|
-
`SHARE_IMAGE_SIZE`: the name, a bar in `colors.primary
|
|
488
|
+
`SHARE_IMAGE_SIZE`: the name, a bar in `colors.primary`, and the tagline. The colors are literals,
|
|
460
489
|
since the image renders outside the browser. Return it from the route `pageMetadata` points at:
|
|
461
490
|
|
|
462
491
|
```tsx
|
|
@@ -479,7 +508,7 @@ The logo and favicon an admin uploads, as the API's `/branding` endpoint returns
|
|
|
479
508
|
`BrandingProvider` holds them for the components below it. It takes the branding the server
|
|
480
509
|
rendered with, loads it on mount when there is none, and loads it again every five minutes, so an
|
|
481
510
|
open tab picks up a change. A failed load keeps what it has. `useBranding()` gives `branding`,
|
|
482
|
-
`logoUrl` as a `data:` URL or null
|
|
511
|
+
`logoUrl` as a `data:` URL or `null`, and `refresh()`.
|
|
483
512
|
|
|
484
513
|
```tsx
|
|
485
514
|
"use client";
|
|
@@ -503,16 +532,14 @@ above it, and refreshes it after a save.
|
|
|
503
532
|
| `save` | `(form: FormData) => Promise<unknown>` | Sends `logo` and `icon` files, and `removeLogo` and `removeIcon` set to `true`. Throw to show the error's message. |
|
|
504
533
|
| `strings` | `TextOverrides<BrandingManagerStrings>` | The form's text. |
|
|
505
534
|
|
|
506
|
-
`toDataUrl(data, contentType)` turns base64 bytes into a `data:` URL.
|
|
507
|
-
|
|
508
535
|
### `@sjolystinnovation/app-kit/branding/server`
|
|
509
536
|
|
|
510
537
|
`brandingIcon({ load, fallbackPath, fallbackType })` is the response for an `app/icon.ts` route:
|
|
511
|
-
the favicon set in admin settings when it is a PNG, JPEG, WebP, GIF or ICO, or the bundled file
|
|
538
|
+
the favicon set in admin settings when it is a PNG, JPEG, WebP, GIF, or ICO, or the bundled file
|
|
512
539
|
otherwise, since an SVG or HTML upload could run script on the site's origin. Both carry `nosniff`
|
|
513
|
-
and a sandbox CSP, which a CSP header from the app's `next.config` replaces.
|
|
514
|
-
|
|
515
|
-
one set in settings.
|
|
540
|
+
and a sandbox CSP, which a CSP header from the app's `next.config` replaces. If the route cannot
|
|
541
|
+
load the branding, it answers 503 with `no-store`, since a browser would cache the bundled icon in
|
|
542
|
+
place of the one set in settings.
|
|
516
543
|
|
|
517
544
|
```ts
|
|
518
545
|
// src/app/icon.ts
|
|
@@ -546,7 +573,7 @@ most, and stops on a failed fetch and on unmount. It gives `data`, `loading` and
|
|
|
546
573
|
each fetch uses the latest `fetcher` and `isFinal`.
|
|
547
574
|
|
|
548
575
|
`PaymentReturn` shows the outcome: confirmed, still pending, or failed. It takes the text and the way
|
|
549
|
-
on for each state
|
|
576
|
+
on for each state. When you pass `onRefresh`, it shows a refresh button while pending.
|
|
550
577
|
|
|
551
578
|
```tsx
|
|
552
579
|
"use client";
|
|
@@ -570,7 +597,7 @@ if (loading) return <Spinner />;
|
|
|
570
597
|
| Prop | Type | Description |
|
|
571
598
|
| --- | --- | --- |
|
|
572
599
|
| `state` | `"success" \| "pending" \| "failed"` | Which content to show. |
|
|
573
|
-
| `success`, `pending`, `failed` | `{ title, body, href, cta, note?, extra? }` | The text, the button's target and text, a smaller note line and anything more, per state. |
|
|
600
|
+
| `success`, `pending`, `failed` | `{ title, body, href, cta, note?, extra? }` | The text, the button's target and text, a smaller note line, and anything more, per state. |
|
|
574
601
|
| `onRefresh` | `() => void` | Shows a refresh button while pending. |
|
|
575
602
|
| `refreshLabel` | `string` | Its text. Defaults to `Check again`. |
|
|
576
603
|
| `className` | `string` | Classes for placing it, on the outer element. |
|
|
@@ -579,6 +606,16 @@ if (loading) return <Spinner />;
|
|
|
579
606
|
|
|
580
607
|
### `@sjolystinnovation/app-kit/admin`
|
|
581
608
|
|
|
609
|
+
`CompanySettingsForm` is the admin form for the business details the public pages show, saved
|
|
610
|
+
through `createSettingsService`. Its children show below the form once the settings have loaded,
|
|
611
|
+
such as the `BrandingManager`. `strings` holds every label, hint, and message, English by default.
|
|
612
|
+
|
|
613
|
+
```tsx
|
|
614
|
+
<CompanySettingsForm service={SettingsService} strings={dict.companySettings}>
|
|
615
|
+
<BrandingManager save={BrandingService.update} />
|
|
616
|
+
</CompanySettingsForm>
|
|
617
|
+
```
|
|
618
|
+
|
|
582
619
|
The admin area. `AdminShell` is the frame: a title and a tab bar above the page, behind a
|
|
583
620
|
`RoleGate`. It takes `RoleGate`'s `role`, `fallback`, and `denied` or `deniedHref`. The tab whose
|
|
584
621
|
`href` is the current path is active, with `aria-current="page"`.
|
|
@@ -602,11 +639,28 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|
|
602
639
|
}
|
|
603
640
|
```
|
|
604
641
|
|
|
642
|
+
`StatHistoryChart` draws the admin stats over time: a line per series, a scale from zero to the
|
|
643
|
+
highest value shown, and legend buttons that hide and show each line. `data` is one row per day,
|
|
644
|
+
oldest first, with `date` as `YYYY-MM-DD`. A missing value counts as zero. Each series names the
|
|
645
|
+
field it plots and its label, and takes the next color in `STAT_SERIES_COLORS` unless it has its
|
|
646
|
+
own. `strings` holds `empty`, shown when there are no days, and `chart`, the accessible name.
|
|
647
|
+
|
|
648
|
+
```tsx
|
|
649
|
+
<StatHistoryChart
|
|
650
|
+
data={history}
|
|
651
|
+
series={[
|
|
652
|
+
{ key: "totalUsers", label: dict.stats.users },
|
|
653
|
+
{ key: "contentImages", label: dict.stats.images },
|
|
654
|
+
]}
|
|
655
|
+
strings={{ empty: dict.admin.noHistory, chart: dict.admin.overTime }}
|
|
656
|
+
/>
|
|
657
|
+
```
|
|
658
|
+
|
|
605
659
|
`AdminUsersPanel` is the users page. It lists every user with their roles, shows deleted users
|
|
606
660
|
behind a toggle, gives and takes roles, deletes an account after a confirm, and invites a user by
|
|
607
661
|
email. It calls the API through `createAdminUsersService`. `getRoles` returns the roles an admin
|
|
608
|
-
may give.
|
|
609
|
-
with `toast`, so the app needs sonner and a `Toaster`.
|
|
662
|
+
may give. You can pass inline functions, because the panel does not fetch again when they change.
|
|
663
|
+
It reports with `toast`, so the app needs sonner and a `Toaster`.
|
|
610
664
|
|
|
611
665
|
```tsx
|
|
612
666
|
import { AdminUsersPanel } from "@sjolystinnovation/app-kit/admin";
|
|
@@ -619,65 +673,52 @@ import { AdminUsersPanel } from "@sjolystinnovation/app-kit/admin";
|
|
|
619
673
|
| `service` | `ReturnType<typeof createAdminUsersService>` | The user admin calls. |
|
|
620
674
|
| `getRoles` | `() => Promise<string[]>` | The roles an admin may give. |
|
|
621
675
|
| `defaultInviteRole` | `string` | The role a new invite starts with. Defaults to `Admin`. |
|
|
622
|
-
| `strings` | `TextOverrides<AdminUsersPanelStrings>` | The text, English by default. `{name}` and `{role}`
|
|
623
|
-
|
|
624
|
-
### `@sjolystinnovation/app-kit/session`
|
|
676
|
+
| `strings` | `TextOverrides<AdminUsersPanelStrings>` | The text, English by default. The panel fills in `{name}` and `{role}` where a text names them. |
|
|
625
677
|
|
|
626
|
-
|
|
627
|
-
|
|
678
|
+
`LegalPagesEditor` edits the legal pages one page and one locale at a time, through
|
|
679
|
+
`createLegalService`. A locale tab whose page has no content yet gets a dot. `LocaleTabs` is the
|
|
680
|
+
same tab bar on its own, for other translated forms.
|
|
628
681
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
jwt({ token, user }) {
|
|
639
|
-
if (user) token.loginAt = Date.now();
|
|
640
|
-
// A token without a sign-in time counts as expired.
|
|
641
|
-
if (!token.loginAt || Date.now() - token.loginAt > SESSION_LIFETIME_MS) {
|
|
642
|
-
token.error = SESSION_ERRORS.absoluteExpiry;
|
|
643
|
-
}
|
|
644
|
-
// Also set token.error when a refresh is refused.
|
|
645
|
-
return token;
|
|
646
|
-
},
|
|
647
|
-
session({ session, token }) {
|
|
648
|
-
session.user.id = token.sub ?? "";
|
|
649
|
-
session.error = token.error;
|
|
650
|
-
session.absoluteExpiresAt = token.loginAt ? token.loginAt + SESSION_LIFETIME_MS : undefined;
|
|
651
|
-
return session;
|
|
652
|
-
},
|
|
653
|
-
},
|
|
682
|
+
```tsx
|
|
683
|
+
<LegalPagesEditor
|
|
684
|
+
service={LegalService}
|
|
685
|
+
keys={LEGAL_PAGE_KEYS}
|
|
686
|
+
keyLabels={{ terms: dict.footer.terms, privacy: dict.footer.privacy, cookies: dict.footer.cookies }}
|
|
687
|
+
locales={LOCALES}
|
|
688
|
+
localeLabels={LOCALE_LABELS}
|
|
689
|
+
strings={dict.legalEditor}
|
|
690
|
+
/>
|
|
654
691
|
```
|
|
655
692
|
|
|
656
|
-
|
|
657
|
-
the app's existing next-auth type declarations, or create them:
|
|
693
|
+
### `@sjolystinnovation/app-kit/legal`
|
|
658
694
|
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
user: { id: string } & DefaultSession["user"];
|
|
668
|
-
error?: string | undefined;
|
|
669
|
-
absoluteExpiresAt?: number | undefined;
|
|
670
|
-
}
|
|
671
|
-
}
|
|
695
|
+
`LegalPageView` renders a terms, privacy or cookies page as an admin wrote it: the title, the date
|
|
696
|
+
it last changed in `dateLocale`, and the Markdown body. If there is no page, it shows `title` and
|
|
697
|
+
says the page is missing. It is a server component and needs the same peers as `markdown`. The
|
|
698
|
+
route file stays in the app, since Next reads `revalidate` and the metadata from it.
|
|
699
|
+
|
|
700
|
+
```tsx
|
|
701
|
+
// src/app/[locale]/terms/page.tsx
|
|
702
|
+
export const revalidate = 3600;
|
|
672
703
|
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
}
|
|
704
|
+
export default async function TermsPage({ params }: { params: Promise<{ locale: Locale }> }) {
|
|
705
|
+
const { locale } = await params;
|
|
706
|
+
const page = await publicGet<LegalPage>(`/content/legal/terms?locale=${locale}`, { tags: [REVALIDATE_TARGETS.legal] });
|
|
707
|
+
return <LegalPageView page={page} title={dict.footer.terms} homeHref={`/${locale}`} dateLocale="nb-NO" strings={dict.legal} />;
|
|
678
708
|
}
|
|
679
709
|
```
|
|
680
710
|
|
|
711
|
+
### `@sjolystinnovation/app-kit/session`
|
|
712
|
+
|
|
713
|
+
Decides when a user has to sign in again. It is safe to import from server code, such as the
|
|
714
|
+
next-auth callbacks.
|
|
715
|
+
|
|
716
|
+
The gate reads `session.error`. `SessionExpiryGuard` also reads `session.absoluteExpiresAt` for its
|
|
717
|
+
countdown, `session.user.id` to keep the page with the user who opened it, and `session.user.email`
|
|
718
|
+
to fill in the prompt. `createAuthOptions` sets all of these. Load their types from
|
|
719
|
+
`@sjolystinnovation/app-kit/next-auth`, as [`createAuthOptions`](#createauthoptionsconfig-settings)
|
|
720
|
+
shows.
|
|
721
|
+
|
|
681
722
|
`SIGN_IN_ERRORS` holds the two codes a failed sign-in reports: `InvalidCredentials` and
|
|
682
723
|
`SignInUnavailable`. Map them to the app's own messages.
|
|
683
724
|
|
|
@@ -697,37 +738,8 @@ The re-sign-in prompt is a flag outside React, so an axios interceptor can raise
|
|
|
697
738
|
|
|
698
739
|
Copies of the package installed side by side share the one flag, as long as their releases use the
|
|
699
740
|
same prompt state format. A release that changes the format says so in its changelog. An app's own
|
|
700
|
-
copy of these functions does not share the flag
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
### `useSessionGate()`
|
|
704
|
-
|
|
705
|
-
From `@sjolystinnovation/app-kit/session/react`. Tells a protected page whether it may render. It
|
|
706
|
-
reads `useSession`, so it needs a `SessionProvider` above it.
|
|
707
|
-
|
|
708
|
-
A page that rendered on a healthy session stays rendered, so an open form keeps its values:
|
|
709
|
-
|
|
710
|
-
- when the session gets a terminal error
|
|
711
|
-
- while the session is re-read
|
|
712
|
-
- while the prompt recovers it
|
|
713
|
-
|
|
714
|
-
A session that turns signed out with no prompt open blanks the page. A page the user opens on a
|
|
715
|
-
dead session does not render. Use `mayRender` in every gate and layout on the way down, so none of
|
|
716
|
-
them blanks a page that another one kept.
|
|
717
|
-
|
|
718
|
-
The hook does not navigate. `ProtectedGate` below adds the redirect. Use the hook directly in a
|
|
719
|
-
layout that needs its own decision, such as a role check, so it agrees with the gate.
|
|
720
|
-
|
|
721
|
-
| Field | Description |
|
|
722
|
-
| --- | --- |
|
|
723
|
-
| `mayRender` | Whether the page may render. |
|
|
724
|
-
| `session`, `status` | As `useSession` returns them. |
|
|
725
|
-
| `usable` | The session is signed in and has no terminal error. |
|
|
726
|
-
| `wasUsable` | This page has rendered on a usable session. |
|
|
727
|
-
| `promptOpen` | The re-sign-in prompt is showing. |
|
|
728
|
-
| `recovering` | The prompt is showing over a page that was usable. |
|
|
729
|
-
|
|
730
|
-
`SessionGate` is the type of the result.
|
|
741
|
+
copy of these functions does not share the flag, so import them from this package in the
|
|
742
|
+
interceptor, the prompt, and every gate.
|
|
731
743
|
|
|
732
744
|
### `AppSessionProvider`
|
|
733
745
|
|
|
@@ -746,9 +758,19 @@ reports in this tab up to date. next-auth polls only once a session exists.
|
|
|
746
758
|
|
|
747
759
|
### `ProtectedGate`
|
|
748
760
|
|
|
749
|
-
From `@sjolystinnovation/app-kit/session/react`. Wraps the protected part of the app. It
|
|
750
|
-
|
|
751
|
-
arrival to `loginHref
|
|
761
|
+
From `@sjolystinnovation/app-kit/session/react`. Wraps the protected part of the app. It reads
|
|
762
|
+
`useSession`, so it needs a `SessionProvider` above it. It sends a signed-out visitor or a session
|
|
763
|
+
dead on arrival to `loginHref`, and never redirects while the re-sign-in prompt is open.
|
|
764
|
+
|
|
765
|
+
A page that rendered on a healthy session stays rendered, so an open form keeps its values:
|
|
766
|
+
|
|
767
|
+
- when the session gets a terminal error
|
|
768
|
+
- while the session is re-read
|
|
769
|
+
- while the prompt recovers it
|
|
770
|
+
|
|
771
|
+
A session that turns signed out with no prompt open blanks the page. A page the user opens on a
|
|
772
|
+
dead session does not render. `RoleGate` and `AdminShell` make the same decision, so none of them
|
|
773
|
+
blanks a page that another one kept.
|
|
752
774
|
|
|
753
775
|
```tsx
|
|
754
776
|
<ProtectedGate loginHref="/login" fallback={<p>Loading…</p>}>
|
|
@@ -764,7 +786,7 @@ arrival to `loginHref`. It never redirects while the re-sign-in prompt is open.
|
|
|
764
786
|
### `RoleGate`
|
|
765
787
|
|
|
766
788
|
From `@sjolystinnovation/app-kit/session/react`. Renders its children only for a user with `role`.
|
|
767
|
-
Place it inside `ProtectedGate`. While the re-sign-in prompt recovers a signed-out session there is
|
|
789
|
+
Place it inside `ProtectedGate`. While the re-sign-in prompt recovers a signed-out session, there is
|
|
768
790
|
no user and so no roles. The page stays up only on the path where the session last had the role.
|
|
769
791
|
|
|
770
792
|
```tsx
|
|
@@ -790,8 +812,9 @@ shows:
|
|
|
790
812
|
- a banner when the session is dead
|
|
791
813
|
- the re-sign-in prompt, a dialog with `CredentialsForm`, when a request or the banner opens it
|
|
792
814
|
|
|
793
|
-
A sign-in in the prompt keeps the page and its form.
|
|
794
|
-
|
|
815
|
+
A sign-in in the prompt keeps the page and its form. If someone signs in to the prompt as a
|
|
816
|
+
different user, the guard signs them out and sends them to `loginHref`, so the prompt never hands
|
|
817
|
+
the page to another user.
|
|
795
818
|
|
|
796
819
|
```tsx
|
|
797
820
|
<SessionExpiryGuard
|
|
@@ -802,7 +825,7 @@ signed out and sent to `loginHref`, so the prompt never hands the page to anothe
|
|
|
802
825
|
|
|
803
826
|
| Prop | Type | Description |
|
|
804
827
|
| --- | --- | --- |
|
|
805
|
-
| `loginHref` | `string` | Where
|
|
828
|
+
| `loginHref` | `string` | Where the guard sends a different user after it signs them out. |
|
|
806
829
|
| `onRestored` | `() => void` | Runs after the prompt signs the user back in, such as to show a toast. |
|
|
807
830
|
| `strings` | `TextOverrides<SessionExpiryGuardStrings>` | The guard's text. `{minutes}` in `expiringSoon` becomes the minutes left. The defaults are English. |
|
|
808
831
|
| `formStrings` | `TextOverrides<CredentialsFormStrings>` | The text of the form in the prompt. |
|
|
@@ -810,8 +833,8 @@ signed out and sent to `loginHref`, so the prompt never hands the page to anothe
|
|
|
810
833
|
### `useFormDraft(config, options)`
|
|
811
834
|
|
|
812
835
|
From `@sjolystinnovation/app-kit/forms`. Keeps a form's values in `localStorage`, so a sign-out,
|
|
813
|
-
reload or closed tab does not lose them.
|
|
814
|
-
only that last half second is at risk.
|
|
836
|
+
reload or closed tab does not lose them. The hook writes values half a second after the last
|
|
837
|
+
change, so only that last half second is at risk.
|
|
815
838
|
|
|
816
839
|
```tsx
|
|
817
840
|
// userId is the signed-in user's id, from wherever the app's session keeps it.
|
|
@@ -824,29 +847,29 @@ const draft = useFormDraft(sessionConfig, {
|
|
|
824
847
|
|
|
825
848
|
| Option | Type | Description |
|
|
826
849
|
| --- | --- | --- |
|
|
827
|
-
| `owner` | `string \| undefined` | The signed-in user's id.
|
|
850
|
+
| `owner` | `string \| undefined` | The signed-in user's id. The hook stores drafts per user, and stores nothing until the id is known. When the session stops reporting an owner, the hook keeps the last known one. |
|
|
828
851
|
| `scope` | `string` | The form and the entity it edits, such as `recipe:42`. |
|
|
829
852
|
| `value` | `T` | The form's current values. They must survive `JSON.stringify`. |
|
|
830
853
|
|
|
831
854
|
Mount an edit form once the entity has loaded, with its values already in `value`. The hook treats
|
|
832
855
|
the values it first sees under a key as the untouched form, so values that arrive later count as an
|
|
833
|
-
edit and
|
|
856
|
+
edit and overwrite the draft on offer.
|
|
834
857
|
|
|
835
858
|
It returns:
|
|
836
859
|
|
|
837
|
-
- `pending`, the stored draft. The page offers it to the user and does not apply it.
|
|
838
|
-
when the owner is first known and again whenever the owner or `scope` changes, so it can
|
|
839
|
-
after the form mounts.
|
|
860
|
+
- `pending`, the stored draft. The page offers it to the user and does not apply it. The hook reads
|
|
861
|
+
it when the owner is first known, and again whenever the owner or `scope` changes, so it can
|
|
862
|
+
appear after the form mounts.
|
|
840
863
|
- `dismiss()`, to stop offering it.
|
|
841
864
|
- `clear()`, to drop the stored draft after a successful save.
|
|
842
865
|
|
|
843
|
-
|
|
844
|
-
`draftStoragePrefix` is empty or contains a colon.
|
|
845
|
-
|
|
866
|
+
The hook stores drafts under `<draftStoragePrefix>:draft:<owner>:<scope>`. It throws when
|
|
867
|
+
`draftStoragePrefix` is empty or contains a colon. It does not offer a draft older than 7 days, and
|
|
868
|
+
removes that draft when its form next reads it.
|
|
846
869
|
|
|
847
870
|
### `CredentialsForm`
|
|
848
871
|
|
|
849
|
-
From `@sjolystinnovation/app-kit/
|
|
872
|
+
From `@sjolystinnovation/app-kit/session/react`. Email and password sign-in through next-auth's credentials
|
|
850
873
|
provider, with `redirect: false`, so the page stays where it is. Use it on the login page and in
|
|
851
874
|
the prompt.
|
|
852
875
|
|
|
@@ -856,7 +879,7 @@ the prompt.
|
|
|
856
879
|
|
|
857
880
|
| Prop | Type | Description |
|
|
858
881
|
| --- | --- | --- |
|
|
859
|
-
| `onSignedIn` | `() => void \| Promise<void>` | Runs after next-auth accepts the credentials. Throw `SignInRejected
|
|
882
|
+
| `onSignedIn` | `() => void \| Promise<void>` | Runs after next-auth accepts the credentials. Throw `SignInRejected`, from the same entry point, with a message to refuse the sign-in. |
|
|
860
883
|
| `initialEmail` | `string` | The email the field starts with. |
|
|
861
884
|
| `children` | `ReactNode` | Extra buttons next to the submit button. |
|
|
862
885
|
| `strings` | `TextOverrides<CredentialsFormStrings>` | The form's text. The defaults are English. |
|
|
@@ -868,9 +891,9 @@ or one that throws, shows `somethingWentWrong`.
|
|
|
868
891
|
|
|
869
892
|
### `LoginPage`
|
|
870
893
|
|
|
871
|
-
From `@sjolystinnovation/app-kit/
|
|
872
|
-
the password and
|
|
873
|
-
the page's server data. It uses the same `auth` theme variables as `ResetPasswordFlow`.
|
|
894
|
+
From `@sjolystinnovation/app-kit/session/react`. The sign-in page: a title, `CredentialsForm`, a link to reset
|
|
895
|
+
the password, and a sign-up link when the app has sign-up. A sign-in goes to `redirectTo` and
|
|
896
|
+
refreshes the page's server data. It uses the same `auth` theme variables as `ResetPasswordFlow`.
|
|
874
897
|
|
|
875
898
|
```tsx
|
|
876
899
|
<LoginPage redirectTo="/admin" resetHref="/reset-password" strings={{ title: "Logg inn" }} formStrings={{ signIn: "Logg inn" }} />
|
|
@@ -881,17 +904,17 @@ the page's server data. It uses the same `auth` theme variables as `ResetPasswor
|
|
|
881
904
|
| `redirectTo` | `string` | Where a signed-in user goes. |
|
|
882
905
|
| `resetHref` | `string` | The password reset page. |
|
|
883
906
|
| `registerHref` | `string` | The sign-up page. Without it there is no register link. |
|
|
884
|
-
| `strings` | `TextOverrides<LoginPageStrings>` | The page's text: `title`, `forgotPassword`, `registerPrompt`
|
|
907
|
+
| `strings` | `TextOverrides<LoginPageStrings>` | The page's text: `title`, `forgotPassword`, `registerPrompt`, and `register`. Set `registerPrompt` to `null` to leave it out. |
|
|
885
908
|
| `formStrings` | `TextOverrides<CredentialsFormStrings>` | The form's text. |
|
|
886
909
|
| `className` | `string` | Replaces the outer `max-w-md mx-auto px-4 py-16`. |
|
|
887
910
|
|
|
888
911
|
### `ResetPasswordFlow`
|
|
889
912
|
|
|
890
913
|
From `@sjolystinnovation/app-kit/ui`. The password reset page in two steps: the email asks for a
|
|
891
|
-
code, then the code and a new password set it.
|
|
892
|
-
|
|
893
|
-
such as an invite, opens it on the second step. Its
|
|
894
|
-
`button` theme variables
|
|
914
|
+
code, then the code and a new password set it. Before it sends the password, the flow checks it
|
|
915
|
+
against the same rules as `passwordSchema`, without loading zod. An emailed link with the email and
|
|
916
|
+
code, such as an invite, opens it on the second step. Its colors and title size come from the
|
|
917
|
+
`auth` and `button` theme variables. See [Theming](#theming).
|
|
895
918
|
|
|
896
919
|
```tsx
|
|
897
920
|
const params = useSearchParams();
|
|
@@ -909,11 +932,11 @@ const params = useSearchParams();
|
|
|
909
932
|
| Prop | Type | Description |
|
|
910
933
|
| --- | --- | --- |
|
|
911
934
|
| `requestReset` | `(data: { email }) => Promise<unknown>` | Asks the API to email a code. |
|
|
912
|
-
| `reset` | `(data: { email, code, newPassword }) => Promise<unknown>` | Sets the password.
|
|
935
|
+
| `reset` | `(data: { email, code, newPassword }) => Promise<unknown>` | Sets the password. When it succeeds, the flow shows the `message` in its answer. |
|
|
913
936
|
| `loginHref` | `string` | Where the sign-in links go. |
|
|
914
937
|
| `initialEmail`, `initialCode` | `string` | From the emailed link. A code opens the second step. |
|
|
915
938
|
| `className` | `string` | Replaces the outer `max-w-md mx-auto px-4 py-16`, such as to sit in the app's own card. |
|
|
916
|
-
| `strings` | `TextOverrides<ResetPasswordFlowStrings>` | The page's text. `signInPrompt`, `intro`, `codeSent
|
|
939
|
+
| `strings` | `TextOverrides<ResetPasswordFlowStrings>` | The page's text. Set `signInPrompt`, `intro`, `codeSent`, or `passwordHint` to `null` to leave it out. `done` set to `null` shows the API's message, or only the sign-in link when it sends none. `passwordInvalid` set to `null` lists every broken rule. |
|
|
917
940
|
| `passwordStrings` | `TextOverrides<PasswordStrings>` | The rule messages, in the app's language. |
|
|
918
941
|
|
|
919
942
|
A failure shows the error's message, or `somethingWentWrong` when it has none.
|
|
@@ -923,7 +946,7 @@ key is optional and may be `undefined`, and a missing or undefined key shows the
|
|
|
923
946
|
|
|
924
947
|
### `TextInput`
|
|
925
948
|
|
|
926
|
-
From `@sjolystinnovation/app-kit/ui`. A
|
|
949
|
+
From `@sjolystinnovation/app-kit/ui`. A labeled text field with the same look and props as
|
|
927
950
|
`PasswordInput`, apart from the show and hide toggle. It takes the other `<input>` attributes too.
|
|
928
951
|
|
|
929
952
|
```tsx
|
|
@@ -932,7 +955,7 @@ From `@sjolystinnovation/app-kit/ui`. A labelled text field with the same look a
|
|
|
932
955
|
|
|
933
956
|
### `TextArea`
|
|
934
957
|
|
|
935
|
-
From `@sjolystinnovation/app-kit/ui`. A
|
|
958
|
+
From `@sjolystinnovation/app-kit/ui`. A labeled multi-line field with the same look, label, and
|
|
936
959
|
error handling as `TextInput`. It takes the other `<textarea>` attributes too, and works controlled
|
|
937
960
|
or uncontrolled.
|
|
938
961
|
|
|
@@ -965,7 +988,7 @@ It takes the other `<button>` attributes too.
|
|
|
965
988
|
| `onChange` | `(checked: boolean) => void` | Called with the new state. A callback that ignores it also fits. |
|
|
966
989
|
| `label` | `string` | Visible text after the switch. It names the switch, and a click on it flips the switch. |
|
|
967
990
|
| `aria-label` | `string` | The switch's name when there is no `label`. One of the two is required. |
|
|
968
|
-
| `disabled` | `boolean` |
|
|
991
|
+
| `disabled` | `boolean` | Grays the switch out and ignores clicks. |
|
|
969
992
|
| `className` | `string` | Classes for placing it, on the outer element. |
|
|
970
993
|
|
|
971
994
|
An `onClick` that calls `event.preventDefault()` stops the change.
|
|
@@ -980,7 +1003,7 @@ import { Alert } from "@sjolystinnovation/app-kit/ui";
|
|
|
980
1003
|
|
|
981
1004
|
| Prop | Type | Default | Description |
|
|
982
1005
|
| --- | --- | --- | --- |
|
|
983
|
-
| `variant` | `"error" \| "success" \| "info" \| "warning"` | `"info"` |
|
|
1006
|
+
| `variant` | `"error" \| "success" \| "info" \| "warning"` | `"info"` | Color of the box. |
|
|
984
1007
|
| `role` | `"alert" \| "status" \| "none"` | `"alert"` | A screen reader announces `status` without interrupting, on every change of its text. Use `none` when a separate live region announces the text. |
|
|
985
1008
|
| `className` | `string` | | Classes for placing the alert, such as a margin. Restyle it through the theme variables. A clashing class wins or loses by the order Tailwind emits them in. |
|
|
986
1009
|
| `children` | `ReactNode` | | Content. |
|
|
@@ -999,7 +1022,7 @@ a server component.
|
|
|
999
1022
|
|
|
1000
1023
|
| Prop | Type | Default | Description |
|
|
1001
1024
|
| --- | --- | --- | --- |
|
|
1002
|
-
| `imageId` | `string \| null` | | Uploaded image id.
|
|
1025
|
+
| `imageId` | `string \| null` | | Uploaded image id. `null` or empty shows `fallbackSrc` as it is, with no `srcset`. |
|
|
1003
1026
|
| `alt` | `string` | | Empty for a decorative image. |
|
|
1004
1027
|
| `sizes` | `string` | | Widths the image is shown at. Required, since a browser assumes `100vw` without it. |
|
|
1005
1028
|
| `fallbackSrc` | `string` | | A static path for when there is no uploaded image. With neither, nothing renders. |
|
|
@@ -1029,10 +1052,10 @@ export default function Error({ reset }: { error: Error; reset: () => void }) {
|
|
|
1029
1052
|
| --- | --- | --- |
|
|
1030
1053
|
| `reset` | `() => void` | The `reset` Next passes to `error.tsx`. The try again button calls it. |
|
|
1031
1054
|
| `homeHref` | `string` | Where the front page link goes, such as `/` or `/en`. |
|
|
1032
|
-
| `strings` | `TextOverrides<ErrorStateStrings>` | `title`, `body`, `tryAgain
|
|
1055
|
+
| `strings` | `TextOverrides<ErrorStateStrings>` | `title`, `body`, `tryAgain`, and `home`. |
|
|
1033
1056
|
| `className` | `string` | Classes for placing it, on the outer element. |
|
|
1034
1057
|
|
|
1035
|
-
|
|
1058
|
+
It never shows the error itself.
|
|
1036
1059
|
|
|
1037
1060
|
### `NotFoundState`
|
|
1038
1061
|
|
|
@@ -1046,14 +1069,14 @@ the page's heading, and a link home. It works in a server component.
|
|
|
1046
1069
|
| Prop | Type | Description |
|
|
1047
1070
|
| --- | --- | --- |
|
|
1048
1071
|
| `homeHref` | `string` | Where the home link goes. |
|
|
1049
|
-
| `strings` | `TextOverrides<
|
|
1072
|
+
| `strings` | `TextOverrides<NotFoundStateStrings>` | `code`, `body`, and `home`. |
|
|
1050
1073
|
| `className` | `string` | Classes for placing it, on the outer element. |
|
|
1051
1074
|
|
|
1052
1075
|
### `PasswordInput`
|
|
1053
1076
|
|
|
1054
|
-
A
|
|
1077
|
+
A labeled password field with a button that shows and hides the value. It also takes the other
|
|
1055
1078
|
`<input>` attributes, apart from `type`, which the component switches itself. `className` goes on
|
|
1056
|
-
the outer element,
|
|
1079
|
+
the outer element, as the table below describes.
|
|
1057
1080
|
|
|
1058
1081
|
```tsx
|
|
1059
1082
|
import { PasswordInput } from "@sjolystinnovation/app-kit/ui";
|
|
@@ -1084,11 +1107,11 @@ import { toast } from "@sjolystinnovation/app-kit/toast";
|
|
|
1084
1107
|
### `passwordSchema(strings?)`
|
|
1085
1108
|
|
|
1086
1109
|
From `@sjolystinnovation/app-kit/validation`. A zod schema for a new password, with the rules the APIs
|
|
1087
|
-
enforce through ASP.NET Identity: at least 8 characters, with a lowercase letter, an uppercase
|
|
1088
|
-
and a digit. No symbol is needed. Identity counts only ASCII, so the letters are A to Z and
|
|
1089
|
-
0 to 9
|
|
1090
|
-
it with `.max()`. A failing password gets one issue per rule it breaks, each with its
|
|
1091
|
-
`strings`. Missing messages fall back to `defaultPasswordStrings`, which are English.
|
|
1110
|
+
enforce through ASP.NET Identity: at least 8 characters, with a lowercase letter, an uppercase
|
|
1111
|
+
letter, and a digit. No symbol is needed. Identity counts only ASCII, so the letters are A to Z and
|
|
1112
|
+
the digits 0 to 9. "Å" does not count as uppercase. Identity sets no upper limit. If the app's API
|
|
1113
|
+
sets one, add it with `.max()`. A failing password gets one issue per rule it breaks, each with its
|
|
1114
|
+
message from `strings`. Missing messages fall back to `defaultPasswordStrings`, which are English.
|
|
1092
1115
|
|
|
1093
1116
|
```ts
|
|
1094
1117
|
import { passwordSchema } from "@sjolystinnovation/app-kit/validation";
|
|
@@ -1105,7 +1128,7 @@ message of every rule a password breaks, in the same order, without zod.
|
|
|
1105
1128
|
From `@sjolystinnovation/app-kit/validation`. A failed parse's issues by the top-level field they
|
|
1106
1129
|
belong to, for showing beside each input. Each field gets its first issue's message, or every message
|
|
1107
1130
|
in order with `all: true`. `message` replaces zod's text, such as with the app's own translations.
|
|
1108
|
-
|
|
1131
|
+
It leaves out issues that belong to no field.
|
|
1109
1132
|
|
|
1110
1133
|
```ts
|
|
1111
1134
|
import { zodFieldErrors } from "@sjolystinnovation/app-kit/validation";
|
|
@@ -1114,9 +1137,42 @@ const parsed = contactSchema.safeParse(form);
|
|
|
1114
1137
|
if (!parsed.success) setErrors(zodFieldErrors(parsed.error, { message: (issue) => issueMessage(issue, dict) }));
|
|
1115
1138
|
```
|
|
1116
1139
|
|
|
1140
|
+
### `@sjolystinnovation/app-kit/testing`
|
|
1141
|
+
|
|
1142
|
+
Test doubles for the modules every app's tests mock: `next-auth/react`, `next/navigation`, `sonner`
|
|
1143
|
+
or `@sjolystinnovation/app-kit/toast`, and the app's axios instance. vitest hoists `vi.mock` above
|
|
1144
|
+
the file's imports, so the factory imports the helpers itself. The test imports the same module to
|
|
1145
|
+
set the state and read the spies. The state lasts for the whole file, so call `resetTestMocks`
|
|
1146
|
+
before each test. It needs vitest 5, as an optional peer dependency.
|
|
1147
|
+
|
|
1148
|
+
```tsx
|
|
1149
|
+
import { makeSession, navigation, resetTestMocks, setSession } from "@sjolystinnovation/app-kit/testing";
|
|
1150
|
+
|
|
1151
|
+
vi.mock("next-auth/react", async () => (await import("@sjolystinnovation/app-kit/testing")).nextAuthMock());
|
|
1152
|
+
vi.mock("next/navigation", async () => (await import("@sjolystinnovation/app-kit/testing")).nextNavigationMock());
|
|
1153
|
+
|
|
1154
|
+
beforeEach(resetTestMocks);
|
|
1155
|
+
|
|
1156
|
+
it("sends a user without the role away", () => {
|
|
1157
|
+
navigation.pathname = "/no/admin";
|
|
1158
|
+
setSession(makeSession({ roles: [] }));
|
|
1159
|
+
render(<AdminLayout><p>admin work</p></AdminLayout>);
|
|
1160
|
+
expect(navigation.router.replace).toHaveBeenCalledWith("/no");
|
|
1161
|
+
});
|
|
1162
|
+
```
|
|
1163
|
+
|
|
1164
|
+
- `setSession(session | null | "loading")` and `makeSession({ roles, ...overrides })` set what
|
|
1165
|
+
`useSession` returns. `auth.signIn`, `auth.signOut` and `auth.getSession` are the spies behind
|
|
1166
|
+
next-auth's calls.
|
|
1167
|
+
- `navigation.pathname`, `navigation.searchParams` and `navigation.router` feed `usePathname`,
|
|
1168
|
+
`useSearchParams` and `useRouter`.
|
|
1169
|
+
- `toastMock()` records every toast in `toastSpies`.
|
|
1170
|
+
- `axiosInstanceMock()` is a module whose default export is `axiosSpies`, for
|
|
1171
|
+
`vi.mock("@/services/axiosInstance", ...)`.
|
|
1172
|
+
|
|
1117
1173
|
## Theming
|
|
1118
1174
|
|
|
1119
|
-
The components draw every
|
|
1175
|
+
The components draw every color, corner radius, and label size from theme variables with a light
|
|
1120
1176
|
default. Set any of them in the app's own `@theme` to restyle every component at once, or in a
|
|
1121
1177
|
selector such as `.dark` for a scoped theme:
|
|
1122
1178
|
|
|
@@ -1173,6 +1229,8 @@ selector such as `.dark` for a scoped theme:
|
|
|
1173
1229
|
| `--color-admin-tab`, `-hover` | gray 500 and gray 900 |
|
|
1174
1230
|
| `--color-admin-tab-active` | gray 900 |
|
|
1175
1231
|
| `--color-admin-tab-indicator` | `var(--color-primary)`, the line under the active tab |
|
|
1232
|
+
| `--color-chart-grid` | gray 200, the scale lines of `StatHistoryChart` |
|
|
1233
|
+
| `--color-chart-label` | gray 400, the chart's numbers and dates |
|
|
1176
1234
|
| `--radius-button` | `var(--radius-lg)` |
|
|
1177
1235
|
| `--color-button-primary-bg` | `var(--color-primary)` |
|
|
1178
1236
|
| `--color-button-primary-text` | `var(--color-primary-foreground)` |
|
|
@@ -1209,15 +1267,19 @@ with it and has to set the ones it uses itself.
|
|
|
1209
1267
|
|
|
1210
1268
|
## Requirements
|
|
1211
1269
|
|
|
1212
|
-
- Next.js 16
|
|
1213
|
-
|
|
1270
|
+
- Next.js 16 and React 19, as peer dependencies the app installs itself.
|
|
1271
|
+
- For `auth`, `api`, `server`, `session/react`, and `admin`: next-auth 4.24 or later and axios 1.20
|
|
1272
|
+
or later, as optional peer dependencies. A site without sign-in, which uses only entries such as
|
|
1273
|
+
the root entry, `ui`, `seo`, `next-config`, `markdown`, `toast`, or `validation`, leaves them out.
|
|
1214
1274
|
- For `@sjolystinnovation/app-kit/validation`: zod 4, as an optional peer dependency.
|
|
1215
|
-
- For `@sjolystinnovation/app-kit/
|
|
1216
|
-
|
|
1275
|
+
- For `@sjolystinnovation/app-kit/testing`: vitest 5, as an optional peer dependency.
|
|
1276
|
+
- For `@sjolystinnovation/app-kit/toast`, `admin`, and `branding`: sonner 2, as an optional peer
|
|
1277
|
+
dependency. Install a single copy, because two copies keep two separate toast stores.
|
|
1217
1278
|
- `jsonwebtoken` comes with the package. `@sjolystinnovation/app-kit/auth` uses it on the server.
|
|
1218
|
-
- For
|
|
1219
|
-
`session/react`: Tailwind CSS 4
|
|
1220
|
-
use the theme's `primary` and
|
|
1279
|
+
- For the components in `ui`, `admin`, `legal`, `markdown`, `branding`, `payments`, and
|
|
1280
|
+
`session/react`: Tailwind CSS 4. `ui`, `admin`, `branding`, `payments`, and `session/react` also
|
|
1281
|
+
need `@heroicons/react` 2. The focus ring and the primary button use the theme's `primary` and
|
|
1282
|
+
`primary-foreground` colors, unless you set their own variables.
|
|
1221
1283
|
- TypeScript with `"moduleResolution": "bundler"`. Relative imports here are extensionless, which
|
|
1222
1284
|
`node16` and `nodenext` reject.
|
|
1223
1285
|
|