@sjolystinnovation/app-kit 0.3.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
 
@@ -124,6 +124,151 @@ try {
124
124
  }
125
125
  ```
126
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>;
224
+ }
225
+ ```
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
+
127
272
  ### `Alert`
128
273
 
129
274
  ```tsx
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjolystinnovation/app-kit",
3
- "version": "0.3.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.
@@ -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
+ }
@@ -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>;