@sjolystinnovation/app-kit 0.2.0 → 0.4.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 CHANGED
@@ -64,14 +64,14 @@ const secret = resolveJwtSecret(sessionConfig, process.env);
64
64
  | --- | --- | --- |
65
65
  | `jwtSecretEnvVar` | `string` | Name of the environment variable holding the API JWT secret, such as `API_JWT_SECRET`. |
66
66
  | `loginRoute` | `string` | Where an expired session sends the browser, such as `/en/login` or `/login`. |
67
- | `draftStoragePrefix` | `string` | Prefix for the `localStorage` keys holding form drafts. |
67
+ | `draftStoragePrefix` | `string` | Bare name for the `localStorage` keys holding form drafts, such as `example`. Drafts are stored under `example:draft:<owner>:<scope>`. |
68
68
 
69
69
  ### `defineSessionConfig(config)`
70
70
 
71
71
  `(config: SessionConfig) => SessionConfig`
72
72
 
73
73
  Returns the config unchanged, so TypeScript checks its shape where it is declared rather than at
74
- every call site.
74
+ every call site. Throws when `draftStoragePrefix` is empty or contains a colon.
75
75
 
76
76
  ### `resolveJwtSecret(config, env)`
77
77
 
@@ -91,23 +91,184 @@ resolveJwtSecret(sessionConfig, {});
91
91
  The environment is a parameter rather than a read of `process.env`, which keeps Node types out of a
92
92
  package that also runs in the browser.
93
93
 
94
- ### `formatApiError(error, fallback)`
94
+ ### `formatApiError(error, fallback, statusMessages?)`
95
95
 
96
- `(error: unknown, fallback: string) => string`
96
+ `(error: unknown, fallback: string, statusMessages?: StatusMessages) => string`
97
97
 
98
- Turns a failed request into one line a user can read. For an axios error it takes the first of
99
- `message`, the first validation error, `detail` and `title` from the response body, which covers
100
- plain JSON errors and ASP.NET Core problem details alike. Otherwise it uses the error's own message,
101
- then `fallback`.
98
+ Turns a failed request into one line a user can read.
99
+
100
+ On a 4xx response it uses the first of:
101
+
102
+ 1. The body's own message: a text body, such as the one `BadRequest("...")` sends from ASP.NET Core,
103
+ or `message`, the first validation error or `detail` from a JSON body.
104
+ 2. The entry in `statusMessages` for the status.
105
+ 3. `fallback`.
106
+
107
+ On a 5xx response the entry in `statusMessages` comes first, then the body, then `fallback`. A 5xx
108
+ body can come from a proxy, such as "Bad Gateway", so the caller's own words win when it has them.
109
+
110
+ A problem details `title` is never shown. ASP.NET Core fills it with generic text, such as "Bad
111
+ Request" or "An error occurred while processing your request.", which says less than `fallback`.
112
+
113
+ Key `0` in `statusMessages` covers a request that got no response, because of a dropped connection
114
+ or a timeout. Only text on a single line counts as a message. Axios's own message, such as "Request
115
+ failed with status code 500", is never shown, and a cancelled request gives `fallback`. Any other
116
+ `Error` gives its own message. A body requested as an `arraybuffer` or a `blob`, as for a file
117
+ download, is not read, so a failed download gets `statusMessages` or `fallback`.
102
118
 
103
119
  ```ts
104
120
  try {
105
121
  await api.post("/users", form);
106
122
  } catch (error) {
107
- setError(formatApiError(error, "Could not create the user."));
123
+ setError(formatApiError(error, "Could not create the user.", { 409: "That email is taken." }));
124
+ }
125
+ ```
126
+
127
+ ### `@sjolystinnovation/app-kit/session`
128
+
129
+ Decides when a user has to sign in again. Safe to import from server code, such as the next-auth
130
+ callbacks.
131
+
132
+ The gate reads `session.error`. The app's `jwt` callback sets `token.error`, and its `session`
133
+ callback copies it across:
134
+
135
+ ```ts
136
+ callbacks: {
137
+ session({ session, token }) {
138
+ session.error = token.error;
139
+ return session;
140
+ },
141
+ },
142
+ ```
143
+
144
+ For that to compile, `error` has to be on next-auth's `Session` and `JWT` types. Add it to
145
+ the app's existing next-auth type declarations, or create them:
146
+
147
+ ```ts
148
+ // src/types/next-auth.d.ts
149
+ import "next-auth";
150
+ import "next-auth/jwt";
151
+
152
+ declare module "next-auth" {
153
+ interface Session {
154
+ error?: string | undefined;
155
+ }
156
+ }
157
+
158
+ declare module "next-auth/jwt" {
159
+ interface JWT {
160
+ error?: string | undefined;
161
+ }
162
+ }
163
+ ```
164
+
165
+ `SESSION_ERRORS` holds the values the jwt callback puts in `token.error` when the session cannot be
166
+ recovered: `AbsoluteSessionExpired`, `RefreshTokenRejected` and `NoRefreshToken`. `SessionError`
167
+ is their union type. `isTerminalSessionError(error)` is true for those three only, so a transient
168
+ refresh failure keeps the session.
169
+
170
+ The re-sign-in prompt is a flag outside React, so an axios interceptor can raise it:
171
+
172
+ | Function | Description |
173
+ | --- | --- |
174
+ | `openSessionPrompt()` | Asks the user to sign in again without leaving the page. |
175
+ | `closeSessionPrompt()` | Hides the prompt. Call it when the component showing the prompt unmounts, or the flag stays open and the gate never redirects. |
176
+ | `subscribeSessionPrompt(listener)` | Calls `listener` when the prompt opens or closes. Returns an unsubscribe function. |
177
+ | `getSessionPromptOpen()` | Whether the prompt is showing. |
178
+
179
+ Copies of the package installed side by side share the one flag, as long as their releases use the
180
+ same prompt state format. A release that changes the format says so in its changelog. An app's own
181
+ copy of these functions does not, so an app moving to them moves every import at once: the
182
+ interceptor, the prompt and every gate.
183
+
184
+ ### `useSessionGate()`
185
+
186
+ From `@sjolystinnovation/app-kit/session/react`. Tells a protected page whether it may render. It
187
+ reads `useSession`, so it needs a `SessionProvider` above it.
188
+
189
+ A page that rendered on a healthy session stays rendered when the session gets a terminal error,
190
+ while the session is re-read and while the prompt recovers it, so an open form keeps its values. A
191
+ session that turns signed out with no prompt open blanks the page. A page the user opens
192
+ on a dead session does not render. Use `mayRender` in every gate and layout on the way down, so
193
+ none of them blanks a page that another one kept.
194
+
195
+ The hook does not navigate. The gate sends the user to the login page itself, and never while the
196
+ prompt is open. The app passes the login URL in, so an app with a locale in the path can build it
197
+ for the current locale:
198
+
199
+ ```tsx
200
+ "use client";
201
+
202
+ import { useSessionGate } from "@sjolystinnovation/app-kit/session/react";
203
+ import { useRouter } from "next/navigation";
204
+ import { useEffect } from "react";
205
+
206
+ export function ProtectedGate({
207
+ loginHref,
208
+ children,
209
+ }: {
210
+ loginHref: string;
211
+ children: React.ReactNode;
212
+ }) {
213
+ const router = useRouter();
214
+ const { status, promptOpen, usable, wasUsable, mayRender } = useSessionGate();
215
+
216
+ useEffect(() => {
217
+ if (promptOpen) return;
218
+ if (status === "unauthenticated" || (status === "authenticated" && !usable && !wasUsable)) {
219
+ router.push(loginHref);
220
+ }
221
+ }, [status, usable, wasUsable, promptOpen, router, loginHref]);
222
+
223
+ return mayRender ? children : <p>Loading…</p>;
108
224
  }
109
225
  ```
110
226
 
227
+ | Field | Description |
228
+ | --- | --- |
229
+ | `mayRender` | Whether the page may render. |
230
+ | `session`, `status` | As `useSession` returns them. |
231
+ | `usable` | The session is signed in and has no terminal error. |
232
+ | `wasUsable` | This page has rendered on a usable session. |
233
+ | `promptOpen` | The re-sign-in prompt is showing. |
234
+ | `recovering` | The prompt is showing over a page that was usable. |
235
+
236
+ `SessionGate` is the type of the result.
237
+
238
+ ### `useFormDraft(config, options)`
239
+
240
+ From `@sjolystinnovation/app-kit/forms`. Keeps a form's values in `localStorage`, so a sign-out,
241
+ reload or closed tab does not lose them. Values are written half a second after the last change, so
242
+ only that last half second is at risk.
243
+
244
+ ```tsx
245
+ // userId is the signed-in user's id, from wherever the app's session keeps it.
246
+ const draft = useFormDraft(sessionConfig, {
247
+ owner: userId,
248
+ scope: recipe ? `recipe:${recipe.id}` : "recipe:new",
249
+ value: form,
250
+ });
251
+ ```
252
+
253
+ | Option | Type | Description |
254
+ | --- | --- | --- |
255
+ | `owner` | `string \| undefined` | The signed-in user's id. Drafts are stored per user, and nothing is stored until it is known. The last known owner is kept when the session stops reporting one. |
256
+ | `scope` | `string` | The form and the entity it edits, such as `recipe:42`. |
257
+ | `value` | `T` | The form's current values. They must survive `JSON.stringify`. |
258
+
259
+ Mount an edit form once the entity has loaded, with its values already in `value`. The hook treats
260
+ the values it first sees under a key as the untouched form, so values that arrive later count as an
261
+ edit and are written over the draft being offered.
262
+
263
+ It returns `pending`, the stored draft, which the page offers instead of applying. It is read when the
264
+ owner is first known and again whenever the owner or `scope` changes, so it can appear after the form mounts. It
265
+ also returns `dismiss()` to stop offering it, and `clear()` to drop the stored draft after a
266
+ successful save.
267
+
268
+ Drafts are stored under `<draftStoragePrefix>:draft:<owner>:<scope>`. The hook throws when
269
+ `draftStoragePrefix` is empty or contains a colon. A draft older than 7 days is not offered, and is
270
+ removed when its form next reads it.
271
+
111
272
  ### `Alert`
112
273
 
113
274
  ```tsx
@@ -120,12 +281,14 @@ import { Alert } from "@sjolystinnovation/app-kit/ui";
120
281
  | --- | --- | --- | --- |
121
282
  | `variant` | `"error" \| "success" \| "info" \| "warning"` | `"info"` | Colour of the box. |
122
283
  | `role` | `"alert" \| "status"` | `"alert"` | Use `status` for text that keeps changing, such as a countdown, so a screen reader does not announce every change. |
284
+ | `className` | `string` | | Classes for placing the alert, such as a margin. Restyle it through the theme variables instead, since whether a clashing class wins depends on the order Tailwind emits them in. |
123
285
  | `children` | `ReactNode` | | Content. |
124
286
 
125
287
  ### `PasswordInput`
126
288
 
127
- A labelled password field with a button that shows and hides the value. Accepts every `<input>`
128
- attribute as well.
289
+ A labelled password field with a button that shows and hides the value. It also takes the other
290
+ `<input>` attributes, apart from `type`, which the component switches itself. `className` goes on
291
+ the outer element, see below.
129
292
 
130
293
  ```tsx
131
294
  import { PasswordInput } from "@sjolystinnovation/app-kit/ui";
@@ -136,17 +299,69 @@ import { PasswordInput } from "@sjolystinnovation/app-kit/ui";
136
299
  | Prop | Type | Default | Description |
137
300
  | --- | --- | --- | --- |
138
301
  | `label` | `string` | | Visible label, tied to the input. |
139
- | `error` | `string` | | Message shown under the field, which also turns the border red. |
302
+ | `error` | `string` | | Message shown under the field. It turns the border red, marks the input invalid and is read out with it by screen readers. |
140
303
  | `showPasswordLabel` | `string` | `"Show password"` | Accessible name of the toggle while the value is hidden. |
141
304
  | `hidePasswordLabel` | `string` | `"Hide password"` | Accessible name of the toggle while the value is visible. |
305
+ | `className` | `string` | | Classes for placing the field, such as a margin. They go on the outer element around the label, input and error. Restyle the field through the theme variables instead. |
142
306
 
143
307
  Pass the two toggle labels in the app's language. The defaults are English.
144
308
 
309
+ ## Theming
310
+
311
+ The components draw every colour, corner radius and label size from theme variables with a light
312
+ default. Set any of them in the app's own `@theme` to restyle every component at once, or in a
313
+ selector such as `.dark` for a scoped theme:
314
+
315
+ ```css
316
+ /* src/app/globals.css */
317
+ @import "tailwindcss";
318
+ @import "@sjolystinnovation/app-kit/styles.css";
319
+
320
+ @theme {
321
+ --color-alert-error-bg: color-mix(in oklab, var(--color-red-500) 10%, transparent);
322
+ --color-alert-error-text: var(--color-red-400);
323
+ --radius-alert: var(--radius-xl);
324
+ --color-input-focus-ring: var(--color-sky-500);
325
+ }
326
+ ```
327
+
328
+ | Variable | Default |
329
+ | --- | --- |
330
+ | `--radius-alert` | `var(--radius-lg)` |
331
+ | `--color-alert-error-bg`, `-border`, `-text` | red 50, 200, 700 |
332
+ | `--color-alert-success-bg`, `-border`, `-text` | green 50, 200, 700 |
333
+ | `--color-alert-info-bg`, `-border`, `-text` | gray 50, 200, 700 |
334
+ | `--color-alert-warning-bg`, `-border`, `-text` | amber 50, 200, 900 |
335
+ | `--text-field-label`, `--text-field-label--line-height` | `var(--text-sm)` and its line height. Set both together. |
336
+ | `--color-field-label-text` | gray 700 |
337
+ | `--spacing-field-label` | gap under the label, `calc(var(--spacing) * 1)` |
338
+ | `--color-field-required` | red 600, the asterisk on a required field |
339
+ | `--color-field-error` | red 600, the message under a field |
340
+ | `--radius-input` | `var(--radius-lg)` |
341
+ | `--spacing-input-y` | `calc(var(--spacing) * 2)` |
342
+ | `--color-input-bg` | `transparent` |
343
+ | `--color-input-border` | gray 300 |
344
+ | `--color-input-border-error` | red 400 |
345
+ | `--color-input-focus-border` | gray 300 |
346
+ | `--color-input-text` | gray 900 |
347
+ | `--color-input-placeholder` | gray 400 |
348
+ | `--color-input-focus-ring` | `var(--color-primary)` |
349
+ | `--input-focus-ring-width` | `2px` |
350
+ | `--color-input-toggle` | gray 400, the show password icon |
351
+ | `--color-input-toggle-hover` | gray 600 |
352
+
353
+ A default that points at another variable, such as `--color-input-focus-ring` at `--color-primary`,
354
+ is read once for the whole page. A scoped theme that changes `--color-primary` should set
355
+ `--color-input-focus-ring` in the same selector.
356
+
357
+ An app that clears a whole Tailwind namespace, such as `--color-*: initial`, clears these variables
358
+ with it and has to set the ones it uses itself.
359
+
145
360
  ## Requirements
146
361
 
147
362
  - Next.js 16, React 19, next-auth 4 and axios 1, as peer dependencies the app installs itself.
148
- - For `@sjolystinnovation/app-kit/ui`: Tailwind CSS 4 with a `primary` colour in the theme, which the
149
- focus ring uses, and `@heroicons/react` 2.
363
+ - For `@sjolystinnovation/app-kit/ui`: Tailwind CSS 4 and `@heroicons/react` 2. The focus ring uses
364
+ the theme's `primary` colour unless `--color-input-focus-ring` is set.
150
365
  - TypeScript with `"moduleResolution": "bundler"`. Relative imports here are extensionless, which
151
366
  `node16` and `nodenext` reject.
152
367
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjolystinnovation/app-kit",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Building blocks for Next.js apps that sign in against a JWT API through next-auth",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Sjølyst Innovation AS",
@@ -16,6 +16,9 @@
16
16
  ],
17
17
  "exports": {
18
18
  ".": "./src/index.ts",
19
+ "./session": "./src/session/index.ts",
20
+ "./session/react": "./src/session/react/index.ts",
21
+ "./forms": "./src/forms/index.ts",
19
22
  "./ui": "./src/ui/index.ts",
20
23
  "./styles.css": "./src/styles.css"
21
24
  },
package/src/config.ts CHANGED
@@ -13,15 +13,35 @@ export interface SessionConfig {
13
13
  * something like "/en/login", one without uses "/login".
14
14
  */
15
15
  loginRoute: string;
16
- /** Prefix for the localStorage keys holding form drafts. */
16
+ /**
17
+ * Prefix for the localStorage keys holding form drafts, usually the app's name, such as
18
+ * "example". Drafts are stored under `<prefix>:draft:<owner>:<scope>`, so the prefix itself
19
+ * has no trailing colon.
20
+ */
17
21
  draftStoragePrefix: string;
18
22
  }
19
23
 
20
- /** Identity helper, so an app can declare its config as a typed constant. */
24
+ /**
25
+ * Declares an app's config as a typed constant. Throws when `draftStoragePrefix` is not a bare name,
26
+ * so a bad value fails when the app starts rather than when a form first opens.
27
+ */
21
28
  export function defineSessionConfig(config: SessionConfig): SessionConfig {
29
+ draftPrefix(config);
22
30
  return config;
23
31
  }
24
32
 
33
+ /**
34
+ * The key prefix for form drafts. A colon or an empty name builds keys such as "app:draft::draft:",
35
+ * which match none of the drafts already saved under "app:draft:".
36
+ */
37
+ export function draftPrefix(config: Pick<SessionConfig, "draftStoragePrefix">): string {
38
+ const name = config.draftStoragePrefix;
39
+ if (!name || name.includes(":")) {
40
+ throw new Error(`draftStoragePrefix must be a bare name such as "example", not "${name}".`);
41
+ }
42
+ return `${name}:draft:`;
43
+ }
44
+
25
45
  /**
26
46
  * Reads the API JWT secret named by the config. Throws rather than returning undefined, because a
27
47
  * missing secret silently breaks token verification at runtime instead of at boot.
package/src/errors.ts CHANGED
@@ -1,29 +1,108 @@
1
- import axios from "axios";
1
+ import axios, { type AxiosResponse } from "axios";
2
2
 
3
- interface ApiErrorBody {
4
- message?: string;
5
- detail?: string;
6
- title?: string;
7
- errors?: Record<string, string[]>;
8
- }
3
+ /**
4
+ * Messages to show for a status code. On a 4xx they are used when the response carries no specific
5
+ * message of its own. On a 5xx they win over the body. Key 0 covers a request that got no response,
6
+ * because of a dropped connection or a timeout.
7
+ */
8
+ export type StatusMessages = Partial<Record<number, string>>;
9
+
10
+ /**
11
+ * Axios codes for a request that never got a response from the network. A browser reports every
12
+ * network failure as ERR_NETWORK. On the server, Node reports the cause instead.
13
+ */
14
+ const NO_RESPONSE_CODES = new Set([
15
+ "ERR_NETWORK",
16
+ "ECONNABORTED",
17
+ "ETIMEDOUT",
18
+ "ECONNREFUSED",
19
+ "ECONNRESET",
20
+ "ENOTFOUND",
21
+ "EAI_AGAIN",
22
+ "EHOSTUNREACH",
23
+ "ENETUNREACH",
24
+ ]);
9
25
 
10
26
  /**
11
- * Turns a failed request into one line a user can read. Takes the first of `message`, the first
12
- * validation error, `detail` and `title` from the response body, so it reads both plain JSON
13
- * errors and ASP.NET Core problem details. Falls back to the error's own message, then to
14
- * `fallback`.
27
+ * Turns a failed request into one line a user can read.
28
+ *
29
+ * On a 4xx response the body's own message comes first: a text body, then `message`, the first
30
+ * validation error and `detail` from a JSON body. After that come `statusMessages` for the status
31
+ * and `fallback`. On a 5xx the caller's status message comes first, because the body may be a
32
+ * proxy's "Bad Gateway" rather than the API's own words. A problem details `title` is never used:
33
+ * ASP.NET Core fills it with generic text such as "Bad Request", which says less than `fallback`.
34
+ *
35
+ * Only single line text counts as a message. Axios's own message, such as "Request failed with
36
+ * status code 500", is never shown, and a cancelled request gives `fallback`. Any other `Error`
37
+ * gives its message.
15
38
  */
16
- export function formatApiError(error: unknown, fallback: string): string {
39
+ export function formatApiError(
40
+ error: unknown,
41
+ fallback: string,
42
+ statusMessages: StatusMessages = {},
43
+ ): string {
44
+ if (axios.isCancel(error)) return fallback;
17
45
  if (axios.isAxiosError(error)) {
18
- const data = error.response?.data as ApiErrorBody | undefined;
19
- if (data?.message) return data.message;
20
- if (data?.errors) {
21
- const first = Object.values(data.errors)[0];
22
- if (first && first.length) return first[0];
46
+ const response = error.response;
47
+ if (!response) {
48
+ return NO_RESPONSE_CODES.has(error.code ?? "") ? (statusMessages[0] ?? fallback) : fallback;
23
49
  }
24
- if (data?.detail) return data.detail;
25
- if (data?.title) return data.title;
50
+ const message = readBody(response);
51
+ const forStatus = statusMessages[response.status];
52
+ if (response.status >= 500) return forStatus ?? message ?? fallback;
53
+ return message ?? forStatus ?? fallback;
26
54
  }
27
- if (error instanceof Error) return error.message;
55
+ if (error instanceof Error) return singleLine(error.message) ?? fallback;
28
56
  return fallback;
29
57
  }
58
+
59
+ /** The message the body carries for this failure, if it carries one. */
60
+ function readBody(response: AxiosResponse): string | undefined {
61
+ const data = parseIfJson(response.data);
62
+ if (typeof data === "string") return singleLine(data);
63
+ // Only a parsed JSON object has fields to read. A Document, Blob or ArrayBuffer does not.
64
+ if (!isPlainObject(data)) return undefined;
65
+ return firstText(data.message) ?? firstValidationError(data.errors) ?? singleLine(data.detail);
66
+ }
67
+
68
+ /**
69
+ * Axios leaves a JSON body as a string when the request asked for text, so parse it here. Plain text
70
+ * such as "Invalid email or password." is not valid JSON and stays as it is.
71
+ */
72
+ function parseIfJson(data: unknown): unknown {
73
+ if (typeof data !== "string") return data;
74
+ try {
75
+ return JSON.parse(data);
76
+ } catch {
77
+ return data;
78
+ }
79
+ }
80
+
81
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
82
+ if (!value || typeof value !== "object") return false;
83
+ const prototype = Object.getPrototypeOf(value);
84
+ return prototype === Object.prototype || prototype === null;
85
+ }
86
+
87
+ /** Reads the first message from `errors`, whether its values are string arrays or strings. */
88
+ function firstValidationError(errors: unknown): string | undefined {
89
+ if (!errors || typeof errors !== "object") return undefined;
90
+ for (const value of Object.values(errors)) {
91
+ const message = firstText(value);
92
+ if (message) return message;
93
+ }
94
+ return undefined;
95
+ }
96
+
97
+ /** A single line of text, or the first entry when the value is a list of them. */
98
+ function firstText(value: unknown): string | undefined {
99
+ return singleLine(Array.isArray(value) ? value[0] : value);
100
+ }
101
+
102
+ /** The trimmed text, when it is one line of text and not HTML. */
103
+ function singleLine(value: unknown): string | undefined {
104
+ if (typeof value !== "string") return undefined;
105
+ const text = value.trim();
106
+ if (!text || /[\r\n\u2028\u2029]/.test(text) || text.startsWith("<")) return undefined;
107
+ return text;
108
+ }
@@ -0,0 +1,2 @@
1
+ export { useFormDraft } from "./useFormDraft";
2
+ export type { FormDraft, FormDraftOptions } from "./useFormDraft";
@@ -0,0 +1,133 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import { draftPrefix, type SessionConfig } from "../config";
5
+
6
+ const WRITE_DELAY_MS = 500;
7
+
8
+ // How long a draft is offered. Long enough to outlive an expired session or a closed tab, short
9
+ // enough that week-old work is not offered as if it were current. An expired draft is removed when
10
+ // its form next reads it.
11
+ const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
12
+
13
+ interface Envelope<T> {
14
+ savedAt: number;
15
+ value: T;
16
+ }
17
+
18
+ function read<T>(storageKey: string): T | null {
19
+ try {
20
+ const raw = window.localStorage.getItem(storageKey);
21
+ if (raw == null) return null;
22
+
23
+ const envelope = JSON.parse(raw) as Envelope<T> | null;
24
+ const fresh =
25
+ typeof envelope?.savedAt === "number" && Date.now() - envelope.savedAt <= MAX_AGE_MS;
26
+ if (!fresh || envelope?.value === undefined) {
27
+ window.localStorage.removeItem(storageKey);
28
+ return null;
29
+ }
30
+ return envelope.value;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ export interface FormDraftOptions<T> {
37
+ /**
38
+ * The signed-in user's id. A browser profile can be shared, so the draft is stored per user.
39
+ * Without that, the next person to sign in is offered the previous one's work. The last known
40
+ * owner is kept, because a lost cookie reports nobody and that is the moment the draft matters
41
+ * most. Nothing is stored until an owner is known.
42
+ */
43
+ owner: string | undefined;
44
+ /** Identifies the form and the entity it edits, for example `recipe:new` or `recipe:42`. */
45
+ scope: string;
46
+ /** The form's current values. They must survive `JSON.stringify`. */
47
+ value: T;
48
+ }
49
+
50
+ export interface FormDraft<T> {
51
+ /**
52
+ * The stored draft, while it is still waiting to be restored or dismissed. Read once the owner is
53
+ * known and again whenever the owner or scope changes, so it can appear after mount.
54
+ */
55
+ pending: T | null;
56
+ /** Stop offering the draft. The caller applies the values it wants. */
57
+ dismiss: () => void;
58
+ /** Drop the stored draft after a successful save. */
59
+ clear: () => void;
60
+ }
61
+
62
+ /**
63
+ * Keeps a form's values in localStorage so a sign-out, reload or closed tab does not lose them.
64
+ * Values are written half a second after the last change, so only that last half second is at risk.
65
+ *
66
+ * A draft found at mount is offered for restore rather than applied, so an edit form never silently
67
+ * overwrites what the API returned. Saving starts immediately either way. The offer is held in
68
+ * memory, so the newest work is always the thing in storage.
69
+ */
70
+ export function useFormDraft<T>(
71
+ config: Pick<SessionConfig, "draftStoragePrefix">,
72
+ { owner, scope, value }: FormDraftOptions<T>,
73
+ ): FormDraft<T> {
74
+ // Adjusted during render rather than in an effect, which is the supported way to derive state
75
+ // from changing inputs.
76
+ const [lastOwner, setLastOwner] = useState<string | undefined>(undefined);
77
+ if (owner && owner !== lastOwner) setLastOwner(owner);
78
+
79
+ // Checked before the owner is known, so a bad prefix fails on the first render, signed in or not.
80
+ const prefix = draftPrefix(config);
81
+ const key = lastOwner ? `${prefix}${lastOwner}:${scope}` : null;
82
+
83
+ const [pending, setPending] = useState<T | null>(null);
84
+ const json = JSON.stringify(value);
85
+ const untouched = useRef(json);
86
+ const writeTimer = useRef<number | null>(null);
87
+
88
+ useEffect(() => {
89
+ // Re-base on a key change too. The values on screen belong to the old key, and writing them
90
+ // under the new one would put this form's work in another form's draft.
91
+ untouched.current = json;
92
+ // Reading the store is an external-system read, which is what an effect is for.
93
+ // eslint-disable-next-line react-hooks/set-state-in-effect
94
+ setPending(key == null ? null : read<T>(key));
95
+ // Keyed on the draft key alone on purpose. Re-reading whenever the values change would
96
+ // re-offer a draft the user has already answered.
97
+ // eslint-disable-next-line react-hooks/exhaustive-deps
98
+ }, [key]);
99
+
100
+ useEffect(() => {
101
+ if (key == null) return;
102
+ // Write only once the form differs from how it opened. Writing the pristine form would
103
+ // overwrite the draft being offered before the user has answered.
104
+ if (json === untouched.current) return;
105
+ const timer = window.setTimeout(() => {
106
+ try {
107
+ window.localStorage.setItem(key, JSON.stringify({ savedAt: Date.now(), value }));
108
+ } catch {
109
+ // A full or blocked store must not break the form.
110
+ }
111
+ }, WRITE_DELAY_MS);
112
+ writeTimer.current = timer;
113
+ return () => window.clearTimeout(timer);
114
+ // eslint-disable-next-line react-hooks/exhaustive-deps
115
+ }, [key, json]);
116
+
117
+ const clear = useCallback(() => {
118
+ if (key == null) return;
119
+ // Cancel a write that is already scheduled, and treat the current values as the new baseline.
120
+ // Otherwise a save is followed by the draft being written back.
121
+ if (writeTimer.current != null) window.clearTimeout(writeTimer.current);
122
+ untouched.current = json;
123
+ try {
124
+ window.localStorage.removeItem(key);
125
+ } catch {
126
+ // The draft is a convenience, not state anything depends on.
127
+ }
128
+ }, [key, json]);
129
+
130
+ const dismiss = useCallback(() => setPending(null), []);
131
+
132
+ return { pending, dismiss, clear };
133
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export { defineSessionConfig, resolveJwtSecret } from "./config";
2
2
  export type { SessionConfig } from "./config";
3
3
  export { formatApiError } from "./errors";
4
+ export type { StatusMessages } from "./errors";
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Errors the jwt callback sets when the session cannot be recovered without a new sign-in. A
3
+ * transient refresh failure, such as a network error or an API restart, sets no error, so the
4
+ * session keeps working and a later session read retries the refresh.
5
+ */
6
+ export const SESSION_ERRORS = {
7
+ absoluteExpiry: "AbsoluteSessionExpired",
8
+ refreshRejected: "RefreshTokenRejected",
9
+ noRefreshToken: "NoRefreshToken",
10
+ } as const;
11
+
12
+ /** One of the values in `SESSION_ERRORS`. */
13
+ export type SessionError = (typeof SESSION_ERRORS)[keyof typeof SESSION_ERRORS];
14
+
15
+ const TERMINAL: ReadonlySet<string> = new Set(Object.values(SESSION_ERRORS));
16
+
17
+ /** Whether a session error means the user has to sign in again. */
18
+ export function isTerminalSessionError(error: string | undefined): boolean {
19
+ return error != null && TERMINAL.has(error);
20
+ }
21
+
22
+ // Whether the re-sign-in prompt is showing. It lives outside React because the axios interceptor
23
+ // that raises it is a plain module, and because the page gate has to know: it must not redirect to
24
+ // the login page while the prompt is recovering the session in place.
25
+ //
26
+ // Kept on globalThis under a registered symbol, so two copies of this module, such as two installed
27
+ // versions of the package, still share one prompt. Separate state would let a prompt raised through
28
+ // one copy go unseen by a gate reading the other, and the gate would redirect away from the form.
29
+ interface PromptState {
30
+ open: boolean;
31
+ listeners: Set<() => void>;
32
+ }
33
+
34
+ // The shape of PromptState is a contract between releases. A release that changes it must also
35
+ // change this key, so it never reads state in a shape it does not expect.
36
+ const STATE_KEY = Symbol.for("@sjolystinnovation/app-kit/session-prompt/v1");
37
+ const shared = globalThis as typeof globalThis & { [STATE_KEY]?: PromptState };
38
+ const state: PromptState = (shared[STATE_KEY] ??= { open: false, listeners: new Set() });
39
+
40
+ function emit(): void {
41
+ for (const listener of [...state.listeners]) listener();
42
+ }
43
+
44
+ /** Ask the user to sign in again without leaving the page. */
45
+ export function openSessionPrompt(): void {
46
+ if (state.open) return;
47
+ state.open = true;
48
+ emit();
49
+ }
50
+
51
+ export function closeSessionPrompt(): void {
52
+ if (!state.open) return;
53
+ state.open = false;
54
+ emit();
55
+ }
56
+
57
+ export function subscribeSessionPrompt(listener: () => void): () => void {
58
+ state.listeners.add(listener);
59
+ return () => {
60
+ state.listeners.delete(listener);
61
+ };
62
+ }
63
+
64
+ export function getSessionPromptOpen(): boolean {
65
+ return state.open;
66
+ }
@@ -0,0 +1,9 @@
1
+ export {
2
+ SESSION_ERRORS,
3
+ closeSessionPrompt,
4
+ getSessionPromptOpen,
5
+ isTerminalSessionError,
6
+ openSessionPrompt,
7
+ subscribeSessionPrompt,
8
+ } from "./expiry";
9
+ export type { SessionError } from "./expiry";
@@ -0,0 +1,2 @@
1
+ export { useSessionGate } from "./useSessionGate";
2
+ export type { SessionGate } from "./useSessionGate";
@@ -0,0 +1,63 @@
1
+ "use client";
2
+
3
+ import { useSession } from "next-auth/react";
4
+ import { usePathname } from "next/navigation";
5
+ import { useState, useSyncExternalStore } from "react";
6
+ import { getSessionPromptOpen, isTerminalSessionError, subscribeSessionPrompt } from "../expiry";
7
+
8
+ /**
9
+ * Whether a protected page may render, and whether it must stay rendered.
10
+ *
11
+ * Every gate on the way down should use this, so they agree. A nested layout that decides on its
12
+ * own to blank the page undoes the in-place recovery the re-sign-in prompt exists for, and the user
13
+ * loses the form anyway.
14
+ */
15
+ export function useSessionGate() {
16
+ const { data: session, status } = useSession();
17
+ const pathname = usePathname();
18
+ const promptOpen = useSyncExternalStore(
19
+ subscribeSessionPrompt,
20
+ getSessionPromptOpen,
21
+ () => false,
22
+ );
23
+
24
+ // The jwt callback copies its error onto the session. Read it without requiring the app to have
25
+ // added `error` to next-auth's Session type.
26
+ const error = (session as { error?: string } | null)?.error;
27
+ const usable = status === "authenticated" && !isTerminalSessionError(error);
28
+
29
+ // Which page last rendered on a healthy session. Keyed by path, so each page starts the check
30
+ // again and a session that died on the previous page cannot carry a stale pass into a fresh form.
31
+ // Adjusted during render rather than in an effect, which is the supported way to derive state
32
+ // from changing inputs.
33
+ // Undefined until then rather than null, because usePathname can itself return null.
34
+ const [usableAt, setUsableAt] = useState<string | null | undefined>(undefined);
35
+ if (usable && usableAt !== pathname) setUsableAt(pathname);
36
+ const wasUsable = usableAt === pathname;
37
+
38
+ // The page is already open and the prompt is recovering the session in place.
39
+ const recovering = promptOpen && wasUsable;
40
+
41
+ return {
42
+ session,
43
+ status,
44
+ promptOpen,
45
+ usable,
46
+ wasUsable,
47
+ recovering,
48
+ /**
49
+ * The one answer every gate on the way down uses. A layout that decides this again can blank
50
+ * the page that the gate above it chose to keep.
51
+ */
52
+ mayRender:
53
+ recovering ||
54
+ !(
55
+ (status === "loading" && !wasUsable) ||
56
+ status === "unauthenticated" ||
57
+ (!usable && !wasUsable)
58
+ ),
59
+ };
60
+ }
61
+
62
+ /** What `useSessionGate` returns. */
63
+ export type SessionGate = ReturnType<typeof useSessionGate>;
package/src/styles.css CHANGED
@@ -1,2 +1,43 @@
1
1
  /* Adds this package's components to the files Tailwind scans for class names. */
2
2
  @source ".";
3
+
4
+ /*
5
+ * Design tokens for the components. Each default matches a stock Tailwind value, so an app that
6
+ * sets none of them gets the look below. Set any of them in the app's own @theme to restyle, or in
7
+ * a selector such as .dark for a scoped theme.
8
+ */
9
+ @theme default {
10
+ --radius-alert: var(--radius-lg);
11
+ --color-alert-error-bg: var(--color-red-50);
12
+ --color-alert-error-border: var(--color-red-200);
13
+ --color-alert-error-text: var(--color-red-700);
14
+ --color-alert-success-bg: var(--color-green-50);
15
+ --color-alert-success-border: var(--color-green-200);
16
+ --color-alert-success-text: var(--color-green-700);
17
+ --color-alert-info-bg: var(--color-gray-50);
18
+ --color-alert-info-border: var(--color-gray-200);
19
+ --color-alert-info-text: var(--color-gray-700);
20
+ --color-alert-warning-bg: var(--color-amber-50);
21
+ --color-alert-warning-border: var(--color-amber-200);
22
+ --color-alert-warning-text: var(--color-amber-900);
23
+
24
+ --text-field-label: var(--text-sm);
25
+ --text-field-label--line-height: var(--text-sm--line-height);
26
+ --color-field-label-text: var(--color-gray-700);
27
+ --spacing-field-label: calc(var(--spacing) * 1);
28
+ --color-field-required: var(--color-red-600);
29
+ --color-field-error: var(--color-red-600);
30
+
31
+ --radius-input: var(--radius-lg);
32
+ --spacing-input-y: calc(var(--spacing) * 2);
33
+ --color-input-bg: transparent;
34
+ --color-input-border: var(--color-gray-300);
35
+ --color-input-border-error: var(--color-red-400);
36
+ --color-input-focus-border: var(--color-gray-300);
37
+ --color-input-text: var(--color-gray-900);
38
+ --color-input-placeholder: var(--color-gray-400);
39
+ --color-input-focus-ring: var(--color-primary);
40
+ --input-focus-ring-width: 2px;
41
+ --color-input-toggle: var(--color-gray-400);
42
+ --color-input-toggle-hover: var(--color-gray-600);
43
+ }
package/src/ui/Alert.tsx CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
+ import { cx } from "./cx";
2
3
 
3
4
  export interface AlertProps {
4
5
  variant?: "error" | "success" | "info" | "warning";
@@ -7,19 +8,25 @@ export interface AlertProps {
7
8
  * changing, such as a countdown, so it is not announced on every tick.
8
9
  */
9
10
  role?: "alert" | "status";
11
+ /**
12
+ * Classes for placing the alert, such as a margin. Restyle it through the theme variables
13
+ * instead: whether a class that clashes with the alert's own wins depends on the order Tailwind
14
+ * emits them in.
15
+ */
16
+ className?: string;
10
17
  children: ReactNode;
11
18
  }
12
19
 
13
20
  const styles: Record<NonNullable<AlertProps["variant"]>, string> = {
14
- error: "bg-red-50 border-red-200 text-red-700",
15
- success: "bg-green-50 border-green-200 text-green-700",
16
- info: "bg-gray-50 border-gray-200 text-gray-700",
17
- warning: "bg-amber-50 border-amber-200 text-amber-900",
21
+ error: "bg-alert-error-bg border-alert-error-border text-alert-error-text",
22
+ success: "bg-alert-success-bg border-alert-success-border text-alert-success-text",
23
+ info: "bg-alert-info-bg border-alert-info-border text-alert-info-text",
24
+ warning: "bg-alert-warning-bg border-alert-warning-border text-alert-warning-text",
18
25
  };
19
26
 
20
- export function Alert({ variant = "info", role = "alert", children }: AlertProps) {
27
+ export function Alert({ variant = "info", role = "alert", className, children }: AlertProps) {
21
28
  return (
22
- <div className={`rounded-lg border px-3 py-2 text-sm ${styles[variant]}`} role={role}>
29
+ <div className={cx("rounded-alert border px-3 py-2 text-sm", styles[variant], className)} role={role}>
23
30
  {children}
24
31
  </div>
25
32
  );
@@ -2,14 +2,21 @@
2
2
 
3
3
  import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/outline";
4
4
  import { useId, useState, type InputHTMLAttributes } from "react";
5
+ import { cx } from "./cx";
5
6
 
6
- export interface PasswordInputProps extends InputHTMLAttributes<HTMLInputElement> {
7
+ /** The input's type is left out: the component switches it to show and hide the value. */
8
+ export interface PasswordInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
7
9
  label: string;
8
10
  error?: string;
9
11
  /** Accessible name of the toggle while the password is hidden. */
10
12
  showPasswordLabel?: string;
11
13
  /** Accessible name of the toggle while the password is visible. */
12
14
  hidePasswordLabel?: string;
15
+ /**
16
+ * Classes for placing the field, such as a margin. They go on the outer element, which holds the
17
+ * label, the input and the error. Restyle the field through the theme variables instead.
18
+ */
19
+ className?: string;
13
20
  }
14
21
 
15
22
  export function PasswordInput({
@@ -17,44 +24,53 @@ export function PasswordInput({
17
24
  error,
18
25
  id,
19
26
  required,
27
+ className,
20
28
  showPasswordLabel = "Show password",
21
29
  hidePasswordLabel = "Hide password",
22
30
  ...props
23
31
  }: PasswordInputProps) {
24
32
  const [visible, setVisible] = useState(false);
25
- // Fall back to a generated id so the label is always tied to its control. Without it, a call
26
- // site that passes neither id nor name renders a label pointing at nothing, which leaves
27
- // screen reader users with an unidentified field.
33
+ // Fall back to a generated id so the label is always tied to its own control. Not the name: two
34
+ // fields named "password" on one page, such as sign in next to sign up, would share an id.
28
35
  const generated = useId();
29
- const inputId = id ?? props.name ?? generated;
36
+ const inputId = id ?? generated;
37
+ // Built from useId rather than the name, since an id list in aria-describedby splits on spaces.
38
+ const errorId = `${generated}-error`;
30
39
 
31
40
  return (
32
- <div>
33
- <label htmlFor={inputId} className="block text-sm font-medium text-gray-700 mb-1">
41
+ <div className={className}>
42
+ <label htmlFor={inputId} className="block text-field-label font-medium text-field-label-text mb-field-label">
34
43
  {label}
35
- {required && <span className="text-red-600"> *</span>}
44
+ {required && <span className="text-field-required"> *</span>}
36
45
  </label>
37
46
  <div className="relative">
38
47
  <input
48
+ {...props}
39
49
  id={inputId}
40
50
  type={visible ? "text" : "password"}
41
51
  required={required}
42
- aria-required={required || undefined}
43
- className={`w-full rounded-lg border px-3 py-2 pr-10 text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary ${
44
- error ? "border-red-400" : "border-gray-300"
45
- }`}
46
- {...props}
52
+ aria-required={required ? true : props["aria-required"]}
53
+ aria-invalid={error ? true : props["aria-invalid"]}
54
+ aria-describedby={cx(props["aria-describedby"], error && errorId) || undefined}
55
+ className={cx(
56
+ "w-full rounded-input border px-3 py-input-y pr-10 text-sm bg-input-bg text-input-text placeholder-input-placeholder focus:outline-hidden focus:ring-(length:--input-focus-ring-width) focus:ring-input-focus-ring",
57
+ error ? "border-input-border-error" : "border-input-border focus:border-input-focus-border",
58
+ )}
47
59
  />
48
60
  <button
49
61
  type="button"
50
62
  onClick={() => setVisible((v) => !v)}
51
- className="absolute inset-y-0 right-0 px-3 text-gray-400 hover:text-gray-600"
63
+ className="absolute inset-y-0 right-0 px-3 text-input-toggle hover:text-input-toggle-hover"
52
64
  aria-label={visible ? hidePasswordLabel : showPasswordLabel}
53
65
  >
54
66
  {visible ? <EyeSlashIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
55
67
  </button>
56
68
  </div>
57
- {error && <p className="mt-1 text-xs text-red-600">{error}</p>}
69
+ {error && (
70
+ <p id={errorId} className="mt-1 text-xs text-field-error">
71
+ {error}
72
+ </p>
73
+ )}
58
74
  </div>
59
75
  );
60
76
  }
package/src/ui/cx.ts ADDED
@@ -0,0 +1,4 @@
1
+ /** Joins class names, skipping empty ones. */
2
+ export function cx(...classes: Array<string | false | null | undefined>): string {
3
+ return classes.filter(Boolean).join(" ");
4
+ }