@sjolystinnovation/app-kit 0.21.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 +184 -265
- package/package.json +1 -1
- package/src/admin/AdminShell.tsx +2 -1
- package/src/admin/AdminUsersPanel.tsx +9 -3
- package/src/admin/CompanySettingsForm.tsx +2 -1
- package/src/admin/LegalPagesEditor.tsx +5 -4
- package/src/admin/LocaleTabs.tsx +4 -3
- package/src/admin/StatHistoryChart.tsx +4 -3
- package/src/admin/adminUsersPanelStrings.ts +3 -3
- package/src/admin/legalPagesEditorStrings.ts +1 -1
- package/src/admin/statHistoryChartStrings.ts +3 -2
- package/src/api/apiFieldErrors.ts +4 -4
- package/src/api/company.ts +4 -3
- package/src/api/createApiClient.ts +3 -2
- package/src/{errors.ts → api/formatApiError.ts} +12 -12
- package/src/api/index.ts +2 -1
- package/src/api/request.ts +2 -2
- package/src/api/revalidateTargets.ts +7 -5
- 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 +6 -5
- 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 +0 -1
- package/src/seo/og/index.tsx +5 -4
- package/src/seo/schema.ts +6 -2
- package/src/seo/site.ts +44 -32
- 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/testing/index.ts +5 -5
- 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";
|
|
@@ -254,29 +236,34 @@ and gives an empty string without a number.
|
|
|
254
236
|
export const getCompanyInfo = async () => mergeCompanyInfo(await publicGetOptional<CompanyInfo>("/company"), COMPANY);
|
|
255
237
|
```
|
|
256
238
|
|
|
257
|
-
`requestRevalidate(target, endpoint?)` asks the app's revalidate route to purge the pages behind
|
|
258
|
-
`target`, so an edit shows at once. It posts to `/api/revalidate` unless `endpoint` says otherwise.
|
|
259
|
-
It never throws. On failure the pages' own revalidate window is the fallback.
|
|
260
|
-
|
|
261
239
|
`apiFieldErrors(error)` reads the per-field messages of an ASP.NET validation problem from a failed
|
|
262
240
|
call or its body, keyed in camelCase to match the form: `{ errors: { OrgNumber: ["..."] } }` becomes
|
|
263
|
-
`{ orgNumber: ["..."] }`. It
|
|
241
|
+
`{ orgNumber: ["..."] }`. It returns `null` when there are none, so the form falls back to one
|
|
242
|
+
message.
|
|
264
243
|
|
|
265
244
|
`createPublicClient(baseURL)` is the same without the session, for the API's anonymous endpoints. An
|
|
266
245
|
empty `baseURL` throws, and requests only go to `baseURL`.
|
|
267
246
|
|
|
268
|
-
The services below call the endpoints the SjolystInnovation.AppKit .NET packages map, so
|
|
269
|
-
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.
|
|
270
250
|
Failures throw an `Error` with the API's message, or with the service's own English fallback.
|
|
271
|
-
`onSaved` runs after a save succeeds, such as `() =>
|
|
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 }`.
|
|
272
259
|
|
|
273
260
|
| Factory | Methods | .NET package |
|
|
274
261
|
| --- | --- | --- |
|
|
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 |
|
|
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 |
|
|
277
264
|
| `createSettingsService(api, { onSaved? })` | `get`, `update` | SiteSettings |
|
|
278
265
|
| `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 |
|
|
266
|
+
| `createLegalService<Key>(api, publicApi, { onSaved? })` | `get(key, locale)`, which answers `null` for a missing page, `listAll`, and `save` | SiteSettings |
|
|
280
267
|
|
|
281
268
|
```ts
|
|
282
269
|
import { createApiClient, createPublicClient, createUserService } from "@sjolystinnovation/app-kit/api";
|
|
@@ -285,7 +272,8 @@ const apiUrl = requireEnv("NEXT_PUBLIC_API_URL", process.env.NEXT_PUBLIC_API_URL
|
|
|
285
272
|
export default createUserService(createApiClient(apiUrl), createPublicClient(apiUrl));
|
|
286
273
|
```
|
|
287
274
|
|
|
288
|
-
The
|
|
275
|
+
The package also exports the response types: `UserProfile`, `AdminUser`, `SiteSettings`, and
|
|
276
|
+
`LegalPage`.
|
|
289
277
|
|
|
290
278
|
### `@sjolystinnovation/app-kit/server`
|
|
291
279
|
|
|
@@ -293,15 +281,15 @@ For server components and route handlers only. It imports `next/cache` and `next
|
|
|
293
281
|
|
|
294
282
|
`createPublicApi(baseUrl, options?)` returns cached GETs against the API's anonymous endpoints:
|
|
295
283
|
|
|
296
|
-
- `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
|
|
297
285
|
failing API throws `PublicApiError`, so a failed fetch is never cached as an empty page in place
|
|
298
286
|
of a working one.
|
|
299
|
-
- `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.
|
|
300
288
|
- `publicGetWithMeta(path, { tags? })` also gives `lastModified`, from the `Last-Modified` header.
|
|
301
289
|
|
|
302
290
|
Responses stay fresh for 60 seconds unless `options.revalidate` says otherwise. An empty `baseUrl`
|
|
303
291
|
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
|
|
292
|
+
encoded or not, gives `null` without a request, the same as a 404. Run a slug or other user input
|
|
305
293
|
through `encodeURIComponent` before putting it in a path.
|
|
306
294
|
|
|
307
295
|
```ts
|
|
@@ -316,11 +304,11 @@ export const { publicGet, publicGetOptional, publicGetWithMeta } = createPublicA
|
|
|
316
304
|
|
|
317
305
|
`createRevalidateRoute({ targets, role })` returns a `POST` handler for `app/api/revalidate/route.ts`.
|
|
318
306
|
Each target maps to the page paths it purges. A path with a dynamic segment, such as
|
|
319
|
-
`/builds/[slug]`, purges every page of that route. The target name
|
|
320
|
-
tag the fetches behind it with the same name. The handler answers 403 unless the caller has
|
|
321
|
-
session with `role`: signed in within the last seven days, with no refused refresh and
|
|
322
|
-
token that has not expired. It answers 415 unless the
|
|
323
|
-
`
|
|
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.
|
|
324
312
|
|
|
325
313
|
```ts
|
|
326
314
|
// src/app/api/revalidate/route.ts
|
|
@@ -333,7 +321,7 @@ export const POST = createRevalidateRoute({
|
|
|
333
321
|
```
|
|
334
322
|
|
|
335
323
|
`defineRevalidateTargets({ locales, targets })`, from `@sjolystinnovation/app-kit/api`, keeps the
|
|
336
|
-
targets in one file that both sides import. `pages` are below each locale segment and `root` paths
|
|
324
|
+
targets in one file that both sides import. `pages` are below each locale segment, and `root` paths
|
|
337
325
|
are not. It returns `targets` for fetch tags, `paths` for the route, and `revalidate(target)` for
|
|
338
326
|
the admin services.
|
|
339
327
|
|
|
@@ -358,8 +346,9 @@ For `next.config.ts`. It imports nothing but Next's types, so Next loads it with
|
|
|
358
346
|
|
|
359
347
|
`defineAppConfig(options, extra?)` gives the config every app shares: the standalone build, app-kit
|
|
360
348
|
in `transpilePackages`, no `X-Powered-By`, `expireTime` of five minutes, image qualities 75 and 100,
|
|
361
|
-
the incremental cache in memory, and the security headers on every page. `extra`
|
|
362
|
-
|
|
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.
|
|
363
352
|
|
|
364
353
|
```ts
|
|
365
354
|
// next.config.ts
|
|
@@ -386,17 +375,14 @@ export default defineAppConfig({
|
|
|
386
375
|
| `expireTime` | `number` | Seconds a cache may serve a stale page. Defaults to 300. Next's default is a year. |
|
|
387
376
|
| `isrFlushToDisk` | `boolean` | Write revalidated pages to disk. Off by default, for a read-only root filesystem. |
|
|
388
377
|
|
|
389
|
-
|
|
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.
|
|
378
|
+
Scripts allow `'unsafe-inline'`, since a nonce would make every statically rendered page dynamic.
|
|
393
379
|
|
|
394
380
|
### `@sjolystinnovation/app-kit/markdown`
|
|
395
381
|
|
|
396
|
-
Renders Markdown from an editor or the API, with GitHub tables, task lists and footnotes.
|
|
397
|
-
|
|
398
|
-
handlers or `javascript:` links. It works in a server component. It needs `react-markdown`,
|
|
399
|
-
`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:
|
|
400
386
|
|
|
401
387
|
```sh
|
|
402
388
|
npm install react-markdown remark-gfm rehype-sanitize
|
|
@@ -412,20 +398,19 @@ import { Markdown } from "@sjolystinnovation/app-kit/markdown";
|
|
|
412
398
|
|
|
413
399
|
| Prop | Type | Default | Description |
|
|
414
400
|
| --- | --- | --- | --- |
|
|
415
|
-
| `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. |
|
|
416
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. |
|
|
417
403
|
| `externalLinks` | `boolean` | `false` | Opens `http` and `https` links in a new tab with `rel="noopener noreferrer"`. Site-relative links stay in the tab. |
|
|
418
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. |
|
|
419
405
|
|
|
420
|
-
The text takes its
|
|
421
|
-
the page has its own `h1`.
|
|
422
|
-
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`.
|
|
423
408
|
|
|
424
409
|
### `@sjolystinnovation/app-kit/seo`
|
|
425
410
|
|
|
426
411
|
Search engine helpers for one site: canonical URLs with hreflang, page metadata with Open Graph,
|
|
427
|
-
sitemap entries, robots.txt and schema.org structured data. It works in a server component.
|
|
428
|
-
`defineSite` binds them to the site's name, canonical origin and locales:
|
|
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:
|
|
429
414
|
|
|
430
415
|
```ts
|
|
431
416
|
// src/lib/seo/site.ts
|
|
@@ -444,7 +429,7 @@ export const site = defineSite({
|
|
|
444
429
|
|
|
445
430
|
| Option | Type | Description |
|
|
446
431
|
| --- | --- | --- |
|
|
447
|
-
| `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. |
|
|
448
433
|
| `url` | `string` | The canonical origin, without a trailing slash. |
|
|
449
434
|
| `titleSeparator` | `string` | Between a page title and the site name. |
|
|
450
435
|
| `locales` | `string[]` | The locale segments every page is under. A site without them leaves it out, and its paths have no segment. |
|
|
@@ -453,24 +438,24 @@ export const site = defineSite({
|
|
|
453
438
|
| `tagline` | `(locale) => string` | The line after the name in the default title and the share image's alt text. |
|
|
454
439
|
| `shareImagePath` | `string` | The share image route below each locale. Defaults to `/og`. |
|
|
455
440
|
|
|
456
|
-
The site
|
|
441
|
+
The site provides:
|
|
457
442
|
|
|
458
443
|
- `localePath(locale, path)`, `absoluteUrl(path)`, `absoluteLocaleUrl(locale, path)` and
|
|
459
444
|
`languageAlternates(path)`. Paths get a leading slash and no trailing slash.
|
|
460
445
|
- `siteMetadata({ locale, description, manifest })` for the locale root layout: the title
|
|
461
|
-
template, `metadataBase`, the index directive and the front page's canonical.
|
|
446
|
+
template, `metadataBase`, the index directive, and the front page's canonical.
|
|
462
447
|
- `pageMetadata({ locale, path, title, description, images })` for a page: the canonical URL,
|
|
463
448
|
hreflang, and Open Graph with the locale's share image unless `images` is given.
|
|
464
449
|
- `localeEntries(path, { locales, lastModified })` for `sitemap.ts`: one entry per locale, each
|
|
465
|
-
listing the others. `lastModified`
|
|
466
|
-
entry per page.
|
|
450
|
+
listing the others. It leaves out `lastModified` when that is unknown. A site without locales
|
|
451
|
+
gets one entry per page.
|
|
467
452
|
- `forEachLocale(load)` runs `load` for every locale at once and keys the results by locale.
|
|
468
453
|
- `legalEntries({ keys, updatedAt })` gives the legal pages' entries, `/terms`, `/privacy` and
|
|
469
454
|
`/cookies` unless `keys` says otherwise, each dated by the timestamp `updatedAt` returns for it.
|
|
470
|
-
- `robots({ privatePaths, privateRootPaths, indexable })` for `robots.ts`.
|
|
471
|
-
|
|
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.
|
|
472
457
|
- `organizationNode(company, { type, sameAs, country, image, logo })`, `webSiteNode(locale)`,
|
|
473
|
-
`breadcrumbNode(crumbs, pageUrl)`, `itemListNode(paths, pageUrl)`, `siteId(fragment)
|
|
458
|
+
`breadcrumbNode(crumbs, pageUrl)`, `itemListNode(paths, pageUrl)`, `siteId(fragment)`, and `ids`
|
|
474
459
|
for structured data.
|
|
475
460
|
|
|
476
461
|
```tsx
|
|
@@ -491,16 +476,16 @@ export default async () => [
|
|
|
491
476
|
<JsonLd nodes={[site.organizationNode(company), site.webSiteNode(locale)]} />
|
|
492
477
|
```
|
|
493
478
|
|
|
494
|
-
`isIndexableEnvironment()` reads `SITE_ENV`. `prod`, `production
|
|
479
|
+
`isIndexableEnvironment()` reads `SITE_ENV`. `prod`, `production`, and unset or blank count as
|
|
495
480
|
production, so a missing variable never deindexes the live site. `robots.ts` needs
|
|
496
481
|
`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
|
-
`
|
|
482
|
+
`JsonLd`, `serializeGraph`, `ref`, `NOINDEX`, `LEGAL_PAGE_KEYS`, `SHARE_IMAGE_SIZE`, `latest`, and
|
|
483
|
+
`parseTimestamp`.
|
|
499
484
|
|
|
500
485
|
### `@sjolystinnovation/app-kit/seo/og`
|
|
501
486
|
|
|
502
487
|
`shareImage({ name, tagline, colors })` renders the default share preview, a PNG at
|
|
503
|
-
`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,
|
|
504
489
|
since the image renders outside the browser. Return it from the route `pageMetadata` points at:
|
|
505
490
|
|
|
506
491
|
```tsx
|
|
@@ -523,7 +508,7 @@ The logo and favicon an admin uploads, as the API's `/branding` endpoint returns
|
|
|
523
508
|
`BrandingProvider` holds them for the components below it. It takes the branding the server
|
|
524
509
|
rendered with, loads it on mount when there is none, and loads it again every five minutes, so an
|
|
525
510
|
open tab picks up a change. A failed load keeps what it has. `useBranding()` gives `branding`,
|
|
526
|
-
`logoUrl` as a `data:` URL or null
|
|
511
|
+
`logoUrl` as a `data:` URL or `null`, and `refresh()`.
|
|
527
512
|
|
|
528
513
|
```tsx
|
|
529
514
|
"use client";
|
|
@@ -547,16 +532,14 @@ above it, and refreshes it after a save.
|
|
|
547
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. |
|
|
548
533
|
| `strings` | `TextOverrides<BrandingManagerStrings>` | The form's text. |
|
|
549
534
|
|
|
550
|
-
`toDataUrl(data, contentType)` turns base64 bytes into a `data:` URL.
|
|
551
|
-
|
|
552
535
|
### `@sjolystinnovation/app-kit/branding/server`
|
|
553
536
|
|
|
554
537
|
`brandingIcon({ load, fallbackPath, fallbackType })` is the response for an `app/icon.ts` route:
|
|
555
|
-
the favicon set in admin settings when it is a PNG, JPEG, WebP, GIF or ICO, or the bundled file
|
|
538
|
+
the favicon set in admin settings when it is a PNG, JPEG, WebP, GIF, or ICO, or the bundled file
|
|
556
539
|
otherwise, since an SVG or HTML upload could run script on the site's origin. Both carry `nosniff`
|
|
557
|
-
and a sandbox CSP, which a CSP header from the app's `next.config` replaces.
|
|
558
|
-
|
|
559
|
-
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.
|
|
560
543
|
|
|
561
544
|
```ts
|
|
562
545
|
// src/app/icon.ts
|
|
@@ -590,7 +573,7 @@ most, and stops on a failed fetch and on unmount. It gives `data`, `loading` and
|
|
|
590
573
|
each fetch uses the latest `fetcher` and `isFinal`.
|
|
591
574
|
|
|
592
575
|
`PaymentReturn` shows the outcome: confirmed, still pending, or failed. It takes the text and the way
|
|
593
|
-
on for each state
|
|
576
|
+
on for each state. When you pass `onRefresh`, it shows a refresh button while pending.
|
|
594
577
|
|
|
595
578
|
```tsx
|
|
596
579
|
"use client";
|
|
@@ -614,7 +597,7 @@ if (loading) return <Spinner />;
|
|
|
614
597
|
| Prop | Type | Description |
|
|
615
598
|
| --- | --- | --- |
|
|
616
599
|
| `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. |
|
|
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. |
|
|
618
601
|
| `onRefresh` | `() => void` | Shows a refresh button while pending. |
|
|
619
602
|
| `refreshLabel` | `string` | Its text. Defaults to `Check again`. |
|
|
620
603
|
| `className` | `string` | Classes for placing it, on the outer element. |
|
|
@@ -625,7 +608,7 @@ if (loading) return <Spinner />;
|
|
|
625
608
|
|
|
626
609
|
`CompanySettingsForm` is the admin form for the business details the public pages show, saved
|
|
627
610
|
through `createSettingsService`. Its children show below the form once the settings have loaded,
|
|
628
|
-
such as the `BrandingManager`. `strings` holds every label, hint and message, English by default.
|
|
611
|
+
such as the `BrandingManager`. `strings` holds every label, hint, and message, English by default.
|
|
629
612
|
|
|
630
613
|
```tsx
|
|
631
614
|
<CompanySettingsForm service={SettingsService} strings={dict.companySettings}>
|
|
@@ -659,7 +642,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|
|
659
642
|
`StatHistoryChart` draws the admin stats over time: a line per series, a scale from zero to the
|
|
660
643
|
highest value shown, and legend buttons that hide and show each line. `data` is one row per day,
|
|
661
644
|
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
|
|
645
|
+
field it plots and its label, and takes the next color in `STAT_SERIES_COLORS` unless it has its
|
|
663
646
|
own. `strings` holds `empty`, shown when there are no days, and `chart`, the accessible name.
|
|
664
647
|
|
|
665
648
|
```tsx
|
|
@@ -676,8 +659,8 @@ own. `strings` holds `empty`, shown when there are no days, and `chart`, the acc
|
|
|
676
659
|
`AdminUsersPanel` is the users page. It lists every user with their roles, shows deleted users
|
|
677
660
|
behind a toggle, gives and takes roles, deletes an account after a confirm, and invites a user by
|
|
678
661
|
email. It calls the API through `createAdminUsersService`. `getRoles` returns the roles an admin
|
|
679
|
-
may give.
|
|
680
|
-
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`.
|
|
681
664
|
|
|
682
665
|
```tsx
|
|
683
666
|
import { AdminUsersPanel } from "@sjolystinnovation/app-kit/admin";
|
|
@@ -690,7 +673,7 @@ import { AdminUsersPanel } from "@sjolystinnovation/app-kit/admin";
|
|
|
690
673
|
| `service` | `ReturnType<typeof createAdminUsersService>` | The user admin calls. |
|
|
691
674
|
| `getRoles` | `() => Promise<string[]>` | The roles an admin may give. |
|
|
692
675
|
| `defaultInviteRole` | `string` | The role a new invite starts with. Defaults to `Admin`. |
|
|
693
|
-
| `strings` | `TextOverrides<AdminUsersPanelStrings>` | The text, English by default. `{name}` and `{role}`
|
|
676
|
+
| `strings` | `TextOverrides<AdminUsersPanelStrings>` | The text, English by default. The panel fills in `{name}` and `{role}` where a text names them. |
|
|
694
677
|
|
|
695
678
|
`LegalPagesEditor` edits the legal pages one page and one locale at a time, through
|
|
696
679
|
`createLegalService`. A locale tab whose page has no content yet gets a dot. `LocaleTabs` is the
|
|
@@ -710,9 +693,9 @@ same tab bar on its own, for other translated forms.
|
|
|
710
693
|
### `@sjolystinnovation/app-kit/legal`
|
|
711
694
|
|
|
712
695
|
`LegalPageView` renders a terms, privacy or cookies page as an admin wrote it: the title, the date
|
|
713
|
-
it last changed in `dateLocale`, and the Markdown body.
|
|
714
|
-
page is missing. It is a server component and needs the same peers as `markdown`. The
|
|
715
|
-
stays in the app, since Next reads `revalidate` and the metadata from it.
|
|
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.
|
|
716
699
|
|
|
717
700
|
```tsx
|
|
718
701
|
// src/app/[locale]/terms/page.tsx
|
|
@@ -727,60 +710,14 @@ export default async function TermsPage({ params }: { params: Promise<{ locale:
|
|
|
727
710
|
|
|
728
711
|
### `@sjolystinnovation/app-kit/session`
|
|
729
712
|
|
|
730
|
-
Decides when a user has to sign in again.
|
|
731
|
-
callbacks.
|
|
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.
|
|
732
715
|
|
|
733
716
|
The gate reads `session.error`. `SessionExpiryGuard` also reads `session.absoluteExpiresAt` for its
|
|
734
717
|
countdown, `session.user.id` to keep the page with the user who opened it, and `session.user.email`
|
|
735
|
-
to fill in the prompt. `createAuthOptions` sets all of these.
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
```ts
|
|
739
|
-
const SESSION_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000;
|
|
740
|
-
|
|
741
|
-
callbacks: {
|
|
742
|
-
jwt({ token, user }) {
|
|
743
|
-
if (user) token.loginAt = Date.now();
|
|
744
|
-
// A token without a sign-in time counts as expired.
|
|
745
|
-
if (!token.loginAt || Date.now() - token.loginAt > SESSION_LIFETIME_MS) {
|
|
746
|
-
token.error = SESSION_ERRORS.absoluteExpiry;
|
|
747
|
-
}
|
|
748
|
-
// Also set token.error when a refresh is refused.
|
|
749
|
-
return token;
|
|
750
|
-
},
|
|
751
|
-
session({ session, token }) {
|
|
752
|
-
session.user.id = token.sub ?? "";
|
|
753
|
-
session.error = token.error;
|
|
754
|
-
session.absoluteExpiresAt = token.loginAt ? token.loginAt + SESSION_LIFETIME_MS : undefined;
|
|
755
|
-
return session;
|
|
756
|
-
},
|
|
757
|
-
},
|
|
758
|
-
```
|
|
759
|
-
|
|
760
|
-
For that to compile, these fields have to be on next-auth's `Session` and `JWT` types. Add them to
|
|
761
|
-
the app's existing next-auth type declarations, or create them:
|
|
762
|
-
|
|
763
|
-
```ts
|
|
764
|
-
// src/types/next-auth.d.ts
|
|
765
|
-
import "next-auth";
|
|
766
|
-
import "next-auth/jwt";
|
|
767
|
-
import type { DefaultSession } from "next-auth";
|
|
768
|
-
|
|
769
|
-
declare module "next-auth" {
|
|
770
|
-
interface Session {
|
|
771
|
-
user: { id: string } & DefaultSession["user"];
|
|
772
|
-
error?: string | undefined;
|
|
773
|
-
absoluteExpiresAt?: number | undefined;
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
declare module "next-auth/jwt" {
|
|
778
|
-
interface JWT {
|
|
779
|
-
error?: string | undefined;
|
|
780
|
-
loginAt?: number | undefined;
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
```
|
|
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.
|
|
784
721
|
|
|
785
722
|
`SIGN_IN_ERRORS` holds the two codes a failed sign-in reports: `InvalidCredentials` and
|
|
786
723
|
`SignInUnavailable`. Map them to the app's own messages.
|
|
@@ -801,37 +738,8 @@ The re-sign-in prompt is a flag outside React, so an axios interceptor can raise
|
|
|
801
738
|
|
|
802
739
|
Copies of the package installed side by side share the one flag, as long as their releases use the
|
|
803
740
|
same prompt state format. A release that changes the format says so in its changelog. An app's own
|
|
804
|
-
copy of these functions does not share the flag
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
### `useSessionGate()`
|
|
808
|
-
|
|
809
|
-
From `@sjolystinnovation/app-kit/session/react`. Tells a protected page whether it may render. It
|
|
810
|
-
reads `useSession`, so it needs a `SessionProvider` above it.
|
|
811
|
-
|
|
812
|
-
A page that rendered on a healthy session stays rendered, so an open form keeps its values:
|
|
813
|
-
|
|
814
|
-
- when the session gets a terminal error
|
|
815
|
-
- while the session is re-read
|
|
816
|
-
- while the prompt recovers it
|
|
817
|
-
|
|
818
|
-
A session that turns signed out with no prompt open blanks the page. A page the user opens on a
|
|
819
|
-
dead session does not render. Use `mayRender` in every gate and layout on the way down, so none of
|
|
820
|
-
them blanks a page that another one kept.
|
|
821
|
-
|
|
822
|
-
The hook does not navigate. `ProtectedGate` below adds the redirect. Use the hook directly in a
|
|
823
|
-
layout that needs its own decision, such as a role check, so it agrees with the gate.
|
|
824
|
-
|
|
825
|
-
| Field | Description |
|
|
826
|
-
| --- | --- |
|
|
827
|
-
| `mayRender` | Whether the page may render. |
|
|
828
|
-
| `session`, `status` | As `useSession` returns them. |
|
|
829
|
-
| `usable` | The session is signed in and has no terminal error. |
|
|
830
|
-
| `wasUsable` | This page has rendered on a usable session. |
|
|
831
|
-
| `promptOpen` | The re-sign-in prompt is showing. |
|
|
832
|
-
| `recovering` | The prompt is showing over a page that was usable. |
|
|
833
|
-
|
|
834
|
-
`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.
|
|
835
743
|
|
|
836
744
|
### `AppSessionProvider`
|
|
837
745
|
|
|
@@ -850,9 +758,19 @@ reports in this tab up to date. next-auth polls only once a session exists.
|
|
|
850
758
|
|
|
851
759
|
### `ProtectedGate`
|
|
852
760
|
|
|
853
|
-
From `@sjolystinnovation/app-kit/session/react`. Wraps the protected part of the app. It
|
|
854
|
-
|
|
855
|
-
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.
|
|
856
774
|
|
|
857
775
|
```tsx
|
|
858
776
|
<ProtectedGate loginHref="/login" fallback={<p>Loading…</p>}>
|
|
@@ -868,7 +786,7 @@ arrival to `loginHref`. It never redirects while the re-sign-in prompt is open.
|
|
|
868
786
|
### `RoleGate`
|
|
869
787
|
|
|
870
788
|
From `@sjolystinnovation/app-kit/session/react`. Renders its children only for a user with `role`.
|
|
871
|
-
Place it inside `ProtectedGate`. While the re-sign-in prompt recovers a signed-out session there is
|
|
789
|
+
Place it inside `ProtectedGate`. While the re-sign-in prompt recovers a signed-out session, there is
|
|
872
790
|
no user and so no roles. The page stays up only on the path where the session last had the role.
|
|
873
791
|
|
|
874
792
|
```tsx
|
|
@@ -894,8 +812,9 @@ shows:
|
|
|
894
812
|
- a banner when the session is dead
|
|
895
813
|
- the re-sign-in prompt, a dialog with `CredentialsForm`, when a request or the banner opens it
|
|
896
814
|
|
|
897
|
-
A sign-in in the prompt keeps the page and its form.
|
|
898
|
-
|
|
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.
|
|
899
818
|
|
|
900
819
|
```tsx
|
|
901
820
|
<SessionExpiryGuard
|
|
@@ -906,7 +825,7 @@ signed out and sent to `loginHref`, so the prompt never hands the page to anothe
|
|
|
906
825
|
|
|
907
826
|
| Prop | Type | Description |
|
|
908
827
|
| --- | --- | --- |
|
|
909
|
-
| `loginHref` | `string` | Where
|
|
828
|
+
| `loginHref` | `string` | Where the guard sends a different user after it signs them out. |
|
|
910
829
|
| `onRestored` | `() => void` | Runs after the prompt signs the user back in, such as to show a toast. |
|
|
911
830
|
| `strings` | `TextOverrides<SessionExpiryGuardStrings>` | The guard's text. `{minutes}` in `expiringSoon` becomes the minutes left. The defaults are English. |
|
|
912
831
|
| `formStrings` | `TextOverrides<CredentialsFormStrings>` | The text of the form in the prompt. |
|
|
@@ -914,8 +833,8 @@ signed out and sent to `loginHref`, so the prompt never hands the page to anothe
|
|
|
914
833
|
### `useFormDraft(config, options)`
|
|
915
834
|
|
|
916
835
|
From `@sjolystinnovation/app-kit/forms`. Keeps a form's values in `localStorage`, so a sign-out,
|
|
917
|
-
reload or closed tab does not lose them.
|
|
918
|
-
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.
|
|
919
838
|
|
|
920
839
|
```tsx
|
|
921
840
|
// userId is the signed-in user's id, from wherever the app's session keeps it.
|
|
@@ -928,29 +847,29 @@ const draft = useFormDraft(sessionConfig, {
|
|
|
928
847
|
|
|
929
848
|
| Option | Type | Description |
|
|
930
849
|
| --- | --- | --- |
|
|
931
|
-
| `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. |
|
|
932
851
|
| `scope` | `string` | The form and the entity it edits, such as `recipe:42`. |
|
|
933
852
|
| `value` | `T` | The form's current values. They must survive `JSON.stringify`. |
|
|
934
853
|
|
|
935
854
|
Mount an edit form once the entity has loaded, with its values already in `value`. The hook treats
|
|
936
855
|
the values it first sees under a key as the untouched form, so values that arrive later count as an
|
|
937
|
-
edit and
|
|
856
|
+
edit and overwrite the draft on offer.
|
|
938
857
|
|
|
939
858
|
It returns:
|
|
940
859
|
|
|
941
|
-
- `pending`, the stored draft. The page offers it to the user and does not apply it.
|
|
942
|
-
when the owner is first known and again whenever the owner or `scope` changes, so it can
|
|
943
|
-
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.
|
|
944
863
|
- `dismiss()`, to stop offering it.
|
|
945
864
|
- `clear()`, to drop the stored draft after a successful save.
|
|
946
865
|
|
|
947
|
-
|
|
948
|
-
`draftStoragePrefix` is empty or contains a colon.
|
|
949
|
-
|
|
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.
|
|
950
869
|
|
|
951
870
|
### `CredentialsForm`
|
|
952
871
|
|
|
953
|
-
From `@sjolystinnovation/app-kit/
|
|
872
|
+
From `@sjolystinnovation/app-kit/session/react`. Email and password sign-in through next-auth's credentials
|
|
954
873
|
provider, with `redirect: false`, so the page stays where it is. Use it on the login page and in
|
|
955
874
|
the prompt.
|
|
956
875
|
|
|
@@ -960,7 +879,7 @@ the prompt.
|
|
|
960
879
|
|
|
961
880
|
| Prop | Type | Description |
|
|
962
881
|
| --- | --- | --- |
|
|
963
|
-
| `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. |
|
|
964
883
|
| `initialEmail` | `string` | The email the field starts with. |
|
|
965
884
|
| `children` | `ReactNode` | Extra buttons next to the submit button. |
|
|
966
885
|
| `strings` | `TextOverrides<CredentialsFormStrings>` | The form's text. The defaults are English. |
|
|
@@ -972,9 +891,9 @@ or one that throws, shows `somethingWentWrong`.
|
|
|
972
891
|
|
|
973
892
|
### `LoginPage`
|
|
974
893
|
|
|
975
|
-
From `@sjolystinnovation/app-kit/
|
|
976
|
-
the password and
|
|
977
|
-
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`.
|
|
978
897
|
|
|
979
898
|
```tsx
|
|
980
899
|
<LoginPage redirectTo="/admin" resetHref="/reset-password" strings={{ title: "Logg inn" }} formStrings={{ signIn: "Logg inn" }} />
|
|
@@ -985,17 +904,17 @@ the page's server data. It uses the same `auth` theme variables as `ResetPasswor
|
|
|
985
904
|
| `redirectTo` | `string` | Where a signed-in user goes. |
|
|
986
905
|
| `resetHref` | `string` | The password reset page. |
|
|
987
906
|
| `registerHref` | `string` | The sign-up page. Without it there is no register link. |
|
|
988
|
-
| `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. |
|
|
989
908
|
| `formStrings` | `TextOverrides<CredentialsFormStrings>` | The form's text. |
|
|
990
909
|
| `className` | `string` | Replaces the outer `max-w-md mx-auto px-4 py-16`. |
|
|
991
910
|
|
|
992
911
|
### `ResetPasswordFlow`
|
|
993
912
|
|
|
994
913
|
From `@sjolystinnovation/app-kit/ui`. The password reset page in two steps: the email asks for a
|
|
995
|
-
code, then the code and a new password set it.
|
|
996
|
-
|
|
997
|
-
such as an invite, opens it on the second step. Its
|
|
998
|
-
`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).
|
|
999
918
|
|
|
1000
919
|
```tsx
|
|
1001
920
|
const params = useSearchParams();
|
|
@@ -1013,11 +932,11 @@ const params = useSearchParams();
|
|
|
1013
932
|
| Prop | Type | Description |
|
|
1014
933
|
| --- | --- | --- |
|
|
1015
934
|
| `requestReset` | `(data: { email }) => Promise<unknown>` | Asks the API to email a code. |
|
|
1016
|
-
| `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. |
|
|
1017
936
|
| `loginHref` | `string` | Where the sign-in links go. |
|
|
1018
937
|
| `initialEmail`, `initialCode` | `string` | From the emailed link. A code opens the second step. |
|
|
1019
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. |
|
|
1020
|
-
| `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. |
|
|
1021
940
|
| `passwordStrings` | `TextOverrides<PasswordStrings>` | The rule messages, in the app's language. |
|
|
1022
941
|
|
|
1023
942
|
A failure shows the error's message, or `somethingWentWrong` when it has none.
|
|
@@ -1027,7 +946,7 @@ key is optional and may be `undefined`, and a missing or undefined key shows the
|
|
|
1027
946
|
|
|
1028
947
|
### `TextInput`
|
|
1029
948
|
|
|
1030
|
-
From `@sjolystinnovation/app-kit/ui`. A
|
|
949
|
+
From `@sjolystinnovation/app-kit/ui`. A labeled text field with the same look and props as
|
|
1031
950
|
`PasswordInput`, apart from the show and hide toggle. It takes the other `<input>` attributes too.
|
|
1032
951
|
|
|
1033
952
|
```tsx
|
|
@@ -1036,7 +955,7 @@ From `@sjolystinnovation/app-kit/ui`. A labelled text field with the same look a
|
|
|
1036
955
|
|
|
1037
956
|
### `TextArea`
|
|
1038
957
|
|
|
1039
|
-
From `@sjolystinnovation/app-kit/ui`. A
|
|
958
|
+
From `@sjolystinnovation/app-kit/ui`. A labeled multi-line field with the same look, label, and
|
|
1040
959
|
error handling as `TextInput`. It takes the other `<textarea>` attributes too, and works controlled
|
|
1041
960
|
or uncontrolled.
|
|
1042
961
|
|
|
@@ -1069,7 +988,7 @@ It takes the other `<button>` attributes too.
|
|
|
1069
988
|
| `onChange` | `(checked: boolean) => void` | Called with the new state. A callback that ignores it also fits. |
|
|
1070
989
|
| `label` | `string` | Visible text after the switch. It names the switch, and a click on it flips the switch. |
|
|
1071
990
|
| `aria-label` | `string` | The switch's name when there is no `label`. One of the two is required. |
|
|
1072
|
-
| `disabled` | `boolean` |
|
|
991
|
+
| `disabled` | `boolean` | Grays the switch out and ignores clicks. |
|
|
1073
992
|
| `className` | `string` | Classes for placing it, on the outer element. |
|
|
1074
993
|
|
|
1075
994
|
An `onClick` that calls `event.preventDefault()` stops the change.
|
|
@@ -1084,7 +1003,7 @@ import { Alert } from "@sjolystinnovation/app-kit/ui";
|
|
|
1084
1003
|
|
|
1085
1004
|
| Prop | Type | Default | Description |
|
|
1086
1005
|
| --- | --- | --- | --- |
|
|
1087
|
-
| `variant` | `"error" \| "success" \| "info" \| "warning"` | `"info"` |
|
|
1006
|
+
| `variant` | `"error" \| "success" \| "info" \| "warning"` | `"info"` | Color of the box. |
|
|
1088
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. |
|
|
1089
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. |
|
|
1090
1009
|
| `children` | `ReactNode` | | Content. |
|
|
@@ -1103,7 +1022,7 @@ a server component.
|
|
|
1103
1022
|
|
|
1104
1023
|
| Prop | Type | Default | Description |
|
|
1105
1024
|
| --- | --- | --- | --- |
|
|
1106
|
-
| `imageId` | `string \| null` | | Uploaded image id.
|
|
1025
|
+
| `imageId` | `string \| null` | | Uploaded image id. `null` or empty shows `fallbackSrc` as it is, with no `srcset`. |
|
|
1107
1026
|
| `alt` | `string` | | Empty for a decorative image. |
|
|
1108
1027
|
| `sizes` | `string` | | Widths the image is shown at. Required, since a browser assumes `100vw` without it. |
|
|
1109
1028
|
| `fallbackSrc` | `string` | | A static path for when there is no uploaded image. With neither, nothing renders. |
|
|
@@ -1133,10 +1052,10 @@ export default function Error({ reset }: { error: Error; reset: () => void }) {
|
|
|
1133
1052
|
| --- | --- | --- |
|
|
1134
1053
|
| `reset` | `() => void` | The `reset` Next passes to `error.tsx`. The try again button calls it. |
|
|
1135
1054
|
| `homeHref` | `string` | Where the front page link goes, such as `/` or `/en`. |
|
|
1136
|
-
| `strings` | `TextOverrides<ErrorStateStrings>` | `title`, `body`, `tryAgain
|
|
1055
|
+
| `strings` | `TextOverrides<ErrorStateStrings>` | `title`, `body`, `tryAgain`, and `home`. |
|
|
1137
1056
|
| `className` | `string` | Classes for placing it, on the outer element. |
|
|
1138
1057
|
|
|
1139
|
-
|
|
1058
|
+
It never shows the error itself.
|
|
1140
1059
|
|
|
1141
1060
|
### `NotFoundState`
|
|
1142
1061
|
|
|
@@ -1150,14 +1069,14 @@ the page's heading, and a link home. It works in a server component.
|
|
|
1150
1069
|
| Prop | Type | Description |
|
|
1151
1070
|
| --- | --- | --- |
|
|
1152
1071
|
| `homeHref` | `string` | Where the home link goes. |
|
|
1153
|
-
| `strings` | `TextOverrides<
|
|
1072
|
+
| `strings` | `TextOverrides<NotFoundStateStrings>` | `code`, `body`, and `home`. |
|
|
1154
1073
|
| `className` | `string` | Classes for placing it, on the outer element. |
|
|
1155
1074
|
|
|
1156
1075
|
### `PasswordInput`
|
|
1157
1076
|
|
|
1158
|
-
A
|
|
1077
|
+
A labeled password field with a button that shows and hides the value. It also takes the other
|
|
1159
1078
|
`<input>` attributes, apart from `type`, which the component switches itself. `className` goes on
|
|
1160
|
-
the outer element,
|
|
1079
|
+
the outer element, as the table below describes.
|
|
1161
1080
|
|
|
1162
1081
|
```tsx
|
|
1163
1082
|
import { PasswordInput } from "@sjolystinnovation/app-kit/ui";
|
|
@@ -1188,11 +1107,11 @@ import { toast } from "@sjolystinnovation/app-kit/toast";
|
|
|
1188
1107
|
### `passwordSchema(strings?)`
|
|
1189
1108
|
|
|
1190
1109
|
From `@sjolystinnovation/app-kit/validation`. A zod schema for a new password, with the rules the APIs
|
|
1191
|
-
enforce through ASP.NET Identity: at least 8 characters, with a lowercase letter, an uppercase
|
|
1192
|
-
and a digit. No symbol is needed. Identity counts only ASCII, so the letters are A to Z and
|
|
1193
|
-
0 to 9
|
|
1194
|
-
it with `.max()`. A failing password gets one issue per rule it breaks, each with its
|
|
1195
|
-
`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.
|
|
1196
1115
|
|
|
1197
1116
|
```ts
|
|
1198
1117
|
import { passwordSchema } from "@sjolystinnovation/app-kit/validation";
|
|
@@ -1209,7 +1128,7 @@ message of every rule a password breaks, in the same order, without zod.
|
|
|
1209
1128
|
From `@sjolystinnovation/app-kit/validation`. A failed parse's issues by the top-level field they
|
|
1210
1129
|
belong to, for showing beside each input. Each field gets its first issue's message, or every message
|
|
1211
1130
|
in order with `all: true`. `message` replaces zod's text, such as with the app's own translations.
|
|
1212
|
-
|
|
1131
|
+
It leaves out issues that belong to no field.
|
|
1213
1132
|
|
|
1214
1133
|
```ts
|
|
1215
1134
|
import { zodFieldErrors } from "@sjolystinnovation/app-kit/validation";
|
|
@@ -1253,7 +1172,7 @@ it("sends a user without the role away", () => {
|
|
|
1253
1172
|
|
|
1254
1173
|
## Theming
|
|
1255
1174
|
|
|
1256
|
-
The components draw every
|
|
1175
|
+
The components draw every color, corner radius, and label size from theme variables with a light
|
|
1257
1176
|
default. Set any of them in the app's own `@theme` to restyle every component at once, or in a
|
|
1258
1177
|
selector such as `.dark` for a scoped theme:
|
|
1259
1178
|
|
|
@@ -1349,18 +1268,18 @@ with it and has to set the ones it uses itself.
|
|
|
1349
1268
|
## Requirements
|
|
1350
1269
|
|
|
1351
1270
|
- Next.js 16 and React 19, as peer dependencies the app installs itself.
|
|
1352
|
-
- For
|
|
1353
|
-
or later
|
|
1354
|
-
|
|
1355
|
-
out.
|
|
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.
|
|
1356
1274
|
- For `@sjolystinnovation/app-kit/validation`: zod 4, as an optional peer dependency.
|
|
1357
1275
|
- For `@sjolystinnovation/app-kit/testing`: vitest 5, as an optional peer dependency.
|
|
1358
|
-
- For `@sjolystinnovation/app-kit/toast`: sonner 2, as an optional peer
|
|
1359
|
-
copy, because two copies keep two separate toast stores.
|
|
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.
|
|
1360
1278
|
- `jsonwebtoken` comes with the package. `@sjolystinnovation/app-kit/auth` uses it on the server.
|
|
1361
|
-
- For
|
|
1362
|
-
`session/react`: Tailwind CSS 4
|
|
1363
|
-
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.
|
|
1364
1283
|
- TypeScript with `"moduleResolution": "bundler"`. Relative imports here are extensionless, which
|
|
1365
1284
|
`node16` and `nodenext` reject.
|
|
1366
1285
|
|