@sjolystinnovation/app-kit 0.4.0 → 0.6.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
@@ -5,9 +5,13 @@
5
5
 
6
6
  > Building blocks for Next.js apps that sign in against a JWT API through next-auth
7
7
 
8
- Several apps sharing one API also share the code around it: the session plumbing, how a failed
9
- request becomes a message a user can read, and the form controls on the sign in page. This package
10
- holds that code once, so the apps install it instead of each keeping a copy.
8
+ Several apps sharing one API also share the code around it:
9
+
10
+ - the session plumbing
11
+ - how a failed request becomes a message a user can read
12
+ - the form controls on the sign in page
13
+
14
+ This package holds that code once, and each app installs it.
11
15
 
12
16
  ## Install
13
17
 
@@ -24,8 +28,8 @@ const nextConfig: NextConfig = {
24
28
  };
25
29
  ```
26
30
 
27
- The components in `@sjolystinnovation/app-kit/ui` are styled with Tailwind CSS. Import the
28
- package's stylesheet after Tailwind:
31
+ The components in `@sjolystinnovation/app-kit/ui` and `session/react` are styled with Tailwind CSS.
32
+ Import the package's stylesheet after Tailwind:
29
33
 
30
34
  ```css
31
35
  /* src/app/globals.css */
@@ -70,15 +74,23 @@ const secret = resolveJwtSecret(sessionConfig, process.env);
70
74
 
71
75
  `(config: SessionConfig) => SessionConfig`
72
76
 
73
- Returns the config unchanged, so TypeScript checks its shape where it is declared rather than at
74
- every call site. Throws when `draftStoragePrefix` is empty or contains a colon.
77
+ Returns the config unchanged, so TypeScript checks its shape once, where it is declared. Throws
78
+ when `draftStoragePrefix` is empty or contains a colon.
79
+
80
+ ### `requireEnv(name, value)`
81
+
82
+ `(name: string, value: string | undefined) => string`
83
+
84
+ Returns `value`, or throws `Missing <name>.` when it is unset or empty. Pass the value read in
85
+ place, such as `process.env.NEXT_PUBLIC_API_URL`, so Next.js can inline a public variable at build
86
+ time.
75
87
 
76
88
  ### `resolveJwtSecret(config, env)`
77
89
 
78
90
  `(config: SessionConfig, env: Record<string, string | undefined>) => string`
79
91
 
80
- Reads the secret named by `config.jwtSecretEnvVar`. Throws when that variable is unset or empty, so a
81
- missing secret fails at startup instead of at the first token verification.
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.
82
94
 
83
95
  ```ts
84
96
  resolveJwtSecret(sessionConfig, { API_JWT_SECRET: "s3cret" });
@@ -88,8 +100,8 @@ resolveJwtSecret(sessionConfig, {});
88
100
  // Error: Missing API_JWT_SECRET. The app verifies API session tokens with it.
89
101
  ```
90
102
 
91
- The environment is a parameter rather than a read of `process.env`, which keeps Node types out of a
92
- package that also runs in the browser.
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.
93
105
 
94
106
  ### `formatApiError(error, fallback, statusMessages?)`
95
107
 
@@ -110,11 +122,16 @@ body can come from a proxy, such as "Bad Gateway", so the caller's own words win
110
122
  A problem details `title` is never shown. ASP.NET Core fills it with generic text, such as "Bad
111
123
  Request" or "An error occurred while processing your request.", which says less than `fallback`.
112
124
 
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`.
125
+ A few more rules:
126
+
127
+ - Key `0` in `statusMessages` covers a request that got no response, because of a dropped
128
+ connection or a timeout.
129
+ - Only text on a single line counts as a message.
130
+ - Axios's own message, such as "Request failed with status code 500", is never shown.
131
+ - A cancelled request gives `fallback`.
132
+ - Any other `Error` gives its own message.
133
+ - A body requested as an `arraybuffer` or a `blob`, as for a file download, is not read. A failed
134
+ download gets `statusMessages` or `fallback`.
118
135
 
119
136
  ```ts
120
137
  try {
@@ -124,44 +141,185 @@ try {
124
141
  }
125
142
  ```
126
143
 
144
+ ### `createAuthOptions(config, settings)`
145
+
146
+ From `@sjolystinnovation/app-kit/auth`. Server only. Builds the next-auth options for an app that
147
+ signs in against the API with email and password:
148
+
149
+ ```ts
150
+ // src/lib/authOptions.ts
151
+ import { requireEnv } from "@sjolystinnovation/app-kit";
152
+ import { createAuthOptions } from "@sjolystinnovation/app-kit/auth";
153
+ import { sessionConfig } from "@/lib/session";
154
+
155
+ export const authOptions = createAuthOptions(sessionConfig, {
156
+ apiUrl: requireEnv("NEXT_PUBLIC_API_URL", process.env.NEXT_PUBLIC_API_URL),
157
+ env: process.env,
158
+ });
159
+ ```
160
+
161
+ ```ts
162
+ // src/app/api/auth/[...nextauth]/route.ts
163
+ import NextAuth from "next-auth/next";
164
+ import { authOptions } from "@/lib/authOptions";
165
+
166
+ const handler = NextAuth(authOptions);
167
+
168
+ export { handler as GET, handler as POST };
169
+ ```
170
+
171
+ | Setting | Type | Description |
172
+ | --- | --- | --- |
173
+ | `apiUrl` | `string` | Base URL of the API. Sign-in posts to `/auth/login` and refresh to `/auth/refresh-token` under it. An empty value throws. |
174
+ | `env` | `Record<string, string \| undefined>` | The server environment, usually `process.env`. It supplies the secret named by `jwtSecretEnvVar`. |
175
+ | `http` | `AxiosInstance` | In place of `apiUrl`, for tests. The client for sign-in and refresh, with its own base URL. |
176
+
177
+ next-auth reads `NEXTAUTH_SECRET` itself and refuses to run in production without it.
178
+
179
+ What the options do:
180
+
181
+ - **Sign-in** posts `{ email, password }` and expects `{ token, refreshToken }`. The access token is
182
+ verified with the API JWT secret, allowing 60 seconds of clock difference between the server and
183
+ the API. Its `sub`, `unique_name` and `role` claims become the user's `id`, `name` and `roles`.
184
+ The log gets the status, error code and message, never the request. A failed sign-in reports one
185
+ of these in next-auth's `error`:
186
+ - `SIGN_IN_ERRORS.unavailable` when the API does not answer, or a gateway returns 502, 503 or 504.
187
+ - `SIGN_IN_ERRORS.invalidCredentials` for every other failure, including an unreadable token and
188
+ an answer without both tokens.
189
+ - **Refresh** runs ahead of expiry: five minutes before it, or a quarter of the token's lifetime
190
+ before it when that is shorter. It is never scheduled sooner than 30 seconds out, so a token that
191
+ lives under 30 seconds is refreshed after it expires. Parallel reads of one session in one server
192
+ process share a single refresh. Its answer is kept for a minute, because the API rotates the
193
+ refresh token on every call.
194
+ - **Failures:** a 400 or 401 from the refresh ends the session with `RefreshTokenRejected`. Any
195
+ other failure keeps it and tries again a minute later. A session without a refresh token ends with
196
+ `NoRefreshToken` when its refresh falls due.
197
+ - **Cap:** the session ends seven days after sign-in with `AbsoluteSessionExpired`, however recently
198
+ it was refreshed.
199
+ - **Session:** it carries `user`, `accessToken`, `error`, and `absoluteExpiresAt`, the end of the
200
+ seven days in milliseconds.
201
+ - **Sign-in page:** `pages.signIn` is `config.loginRoute`.
202
+
203
+ Two rules keep a refresh from being lost:
204
+
205
+ - Read the session on the client, through `useSession` or `getSession`. In the App Router,
206
+ `getServerSession(authOptions)` cannot store a cookie, in server components, route handlers and
207
+ server actions alike. A refresh there replaces the tokens at the API and loses the new ones. The
208
+ next refresh then reuses the old token, and the API revokes every session the user has. On the
209
+ server, read the token with `getToken` from `next-auth/jwt`, which never refreshes.
210
+ - Run the app as one server process. Parallel session reads share one refresh only within a process.
211
+ Two replicas can refresh the same token at once, with the same result.
212
+
213
+ The package does not declare these fields on next-auth's types. The app declares them, so its own
214
+ code can read them:
215
+
216
+ ```ts
217
+ // src/types/next-auth.d.ts
218
+ import "next-auth";
219
+ import "next-auth/jwt";
220
+
221
+ declare module "next-auth" {
222
+ interface Session {
223
+ user: { id: string; name: string; email: string; roles: string[] };
224
+ accessToken: string;
225
+ error?: string | undefined;
226
+ absoluteExpiresAt?: number;
227
+ }
228
+ }
229
+
230
+ declare module "next-auth/jwt" {
231
+ interface JWT {
232
+ accessToken?: string;
233
+ refreshToken?: string;
234
+ refreshAt?: number;
235
+ loginAt?: number;
236
+ user?: { id: string; name: string; email: string; roles: string[] };
237
+ error?: string | undefined;
238
+ }
239
+ }
240
+ ```
241
+
242
+ ### `@sjolystinnovation/app-kit/api`
243
+
244
+ `createApiClient(baseURL)` returns an axios instance for the signed-in API, for code that runs in
245
+ the browser. It reads the session through next-auth's `getSession`, which has no cookies to read on
246
+ the server. An empty `baseURL` throws. Each request carries the session's access token. A 401
247
+ opens the re-sign-in prompt when the session is gone or has a terminal error. It does not sign
248
+ the user out, so the page keeps its state. The error still rejects as usual.
249
+
250
+ `request(call, fallback, statusMessages?)` runs a call and returns its `data`. A failure becomes an
251
+ `Error` with the message `formatApiError` gives it. A cancelled call rethrows the cancel unchanged:
252
+
253
+ ```ts
254
+ import { requireEnv } from "@sjolystinnovation/app-kit";
255
+ import { createApiClient, request } from "@sjolystinnovation/app-kit/api";
256
+
257
+ const api = createApiClient(requireEnv("NEXT_PUBLIC_API_URL", process.env.NEXT_PUBLIC_API_URL));
258
+
259
+ export const getRecipe = (id: number) =>
260
+ request(() => api.get<Recipe>(`/recipes/${id}`), "Could not load the recipe.");
261
+ ```
262
+
127
263
  ### `@sjolystinnovation/app-kit/session`
128
264
 
129
265
  Decides when a user has to sign in again. Safe to import from server code, such as the next-auth
130
266
  callbacks.
131
267
 
132
- The gate reads `session.error`. The app's `jwt` callback sets `token.error`, and its `session`
133
- callback copies it across:
268
+ The gate reads `session.error`. `SessionExpiryGuard` also reads `session.absoluteExpiresAt` for its
269
+ countdown, `session.user.id` to keep the page with the user who opened it, and `session.user.email`
270
+ to fill in the prompt. `createAuthOptions` sets all of these. An app with its own next-auth options sets them in its
271
+ callbacks:
134
272
 
135
273
  ```ts
274
+ const SESSION_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000;
275
+
136
276
  callbacks: {
277
+ jwt({ token, user }) {
278
+ if (user) token.loginAt = Date.now();
279
+ // A token without a sign-in time counts as expired.
280
+ if (!token.loginAt || Date.now() - token.loginAt > SESSION_LIFETIME_MS) {
281
+ token.error = SESSION_ERRORS.absoluteExpiry;
282
+ }
283
+ // Also set token.error when a refresh is refused.
284
+ return token;
285
+ },
137
286
  session({ session, token }) {
287
+ session.user.id = token.sub ?? "";
138
288
  session.error = token.error;
289
+ session.absoluteExpiresAt = token.loginAt ? token.loginAt + SESSION_LIFETIME_MS : undefined;
139
290
  return session;
140
291
  },
141
292
  },
142
293
  ```
143
294
 
144
- For that to compile, `error` has to be on next-auth's `Session` and `JWT` types. Add it to
295
+ For that to compile, these fields have to be on next-auth's `Session` and `JWT` types. Add them to
145
296
  the app's existing next-auth type declarations, or create them:
146
297
 
147
298
  ```ts
148
299
  // src/types/next-auth.d.ts
149
300
  import "next-auth";
150
301
  import "next-auth/jwt";
302
+ import type { DefaultSession } from "next-auth";
151
303
 
152
304
  declare module "next-auth" {
153
305
  interface Session {
306
+ user: { id: string } & DefaultSession["user"];
154
307
  error?: string | undefined;
308
+ absoluteExpiresAt?: number | undefined;
155
309
  }
156
310
  }
157
311
 
158
312
  declare module "next-auth/jwt" {
159
313
  interface JWT {
160
314
  error?: string | undefined;
315
+ loginAt?: number | undefined;
161
316
  }
162
317
  }
163
318
  ```
164
319
 
320
+ `SIGN_IN_ERRORS` holds the two codes a failed sign-in reports: `InvalidCredentials` and
321
+ `SignInUnavailable`. Map them to the app's own messages.
322
+
165
323
  `SESSION_ERRORS` holds the values the jwt callback puts in `token.error` when the session cannot be
166
324
  recovered: `AbsoluteSessionExpired`, `RefreshTokenRejected` and `NoRefreshToken`. `SessionError`
167
325
  is their union type. `isTerminalSessionError(error)` is true for those three only, so a transient
@@ -178,51 +336,26 @@ The re-sign-in prompt is a flag outside React, so an axios interceptor can raise
178
336
 
179
337
  Copies of the package installed side by side share the one flag, as long as their releases use the
180
338
  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.
339
+ copy of these functions does not share the flag. So the interceptor, the prompt and every gate all
340
+ import them from this package.
183
341
 
184
342
  ### `useSessionGate()`
185
343
 
186
344
  From `@sjolystinnovation/app-kit/session/react`. Tells a protected page whether it may render. It
187
345
  reads `useSession`, so it needs a `SessionProvider` above it.
188
346
 
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.
347
+ A page that rendered on a healthy session stays rendered, so an open form keeps its values:
194
348
 
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:
349
+ - when the session gets a terminal error
350
+ - while the session is re-read
351
+ - while the prompt recovers it
198
352
 
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]);
353
+ A session that turns signed out with no prompt open blanks the page. A page the user opens on a
354
+ dead session does not render. Use `mayRender` in every gate and layout on the way down, so none of
355
+ them blanks a page that another one kept.
222
356
 
223
- return mayRender ? children : <p>Loading…</p>;
224
- }
225
- ```
357
+ The hook does not navigate. `ProtectedGate` below adds the redirect. Use the hook directly in a
358
+ layout that needs its own decision, such as a role check, so it agrees with the gate.
226
359
 
227
360
  | Field | Description |
228
361
  | --- | --- |
@@ -235,6 +368,50 @@ export function ProtectedGate({
235
368
 
236
369
  `SessionGate` is the type of the result.
237
370
 
371
+ ### `ProtectedGate`
372
+
373
+ From `@sjolystinnovation/app-kit/session/react`. Wraps the protected part of the app. It renders the
374
+ page only when `useSessionGate` allows it, and sends a signed-out visitor or a session dead on
375
+ arrival to `loginHref`. It never redirects while the re-sign-in prompt is open.
376
+
377
+ ```tsx
378
+ <ProtectedGate loginHref="/login" fallback={<p>Loading…</p>}>
379
+ {children}
380
+ </ProtectedGate>
381
+ ```
382
+
383
+ | Prop | Type | Description |
384
+ | --- | --- | --- |
385
+ | `loginHref` | `string` | Where the redirect goes. An app with a locale in the path builds it for the current locale. |
386
+ | `fallback` | `ReactNode` | What shows while the page may not render yet. Defaults to nothing. |
387
+
388
+ ### `SessionExpiryGuard`
389
+
390
+ From `@sjolystinnovation/app-kit/session/react`. Render it once, inside the `SessionProvider`. It
391
+ shows:
392
+
393
+ - a banner in the last 30 minutes before `session.absoluteExpiresAt`, with the minutes left. That is
394
+ seven days after sign-in with `createAuthOptions`.
395
+ - a banner when the session is dead
396
+ - the re-sign-in prompt, a dialog with `CredentialsForm`, when a request or the banner opens it
397
+
398
+ A sign-in in the prompt keeps the page and its form. A sign-in in the prompt as a different user is
399
+ signed out and sent to `loginHref`, so the prompt never hands the page to another user.
400
+
401
+ ```tsx
402
+ <SessionExpiryGuard
403
+ loginHref="/login"
404
+ onRestored={() => toast.success("You are signed in again.")}
405
+ />
406
+ ```
407
+
408
+ | Prop | Type | Description |
409
+ | --- | --- | --- |
410
+ | `loginHref` | `string` | Where a sign-in as a different user is sent after it is signed out. |
411
+ | `onRestored` | `() => void` | Runs after the prompt signs the user back in, such as to show a toast. |
412
+ | `strings` | `TextOverrides<SessionExpiryGuardStrings>` | The guard's text. `{minutes}` in `expiringSoon` becomes the minutes left. The defaults are English. |
413
+ | `formStrings` | `TextOverrides<CredentialsFormStrings>` | The text of the form in the prompt. |
414
+
238
415
  ### `useFormDraft(config, options)`
239
416
 
240
417
  From `@sjolystinnovation/app-kit/forms`. Keeps a form's values in `localStorage`, so a sign-out,
@@ -260,15 +437,52 @@ Mount an edit form once the entity has loaded, with its values already in `value
260
437
  the values it first sees under a key as the untouched form, so values that arrive later count as an
261
438
  edit and are written over the draft being offered.
262
439
 
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.
440
+ It returns:
441
+
442
+ - `pending`, the stored draft. The page offers it to the user and does not apply it. It is read
443
+ when the owner is first known and again whenever the owner or `scope` changes, so it can appear
444
+ after the form mounts.
445
+ - `dismiss()`, to stop offering it.
446
+ - `clear()`, to drop the stored draft after a successful save.
267
447
 
268
448
  Drafts are stored under `<draftStoragePrefix>:draft:<owner>:<scope>`. The hook throws when
269
449
  `draftStoragePrefix` is empty or contains a colon. A draft older than 7 days is not offered, and is
270
450
  removed when its form next reads it.
271
451
 
452
+ ### `CredentialsForm`
453
+
454
+ From `@sjolystinnovation/app-kit/ui`. Email and password sign-in through next-auth's credentials
455
+ provider, with `redirect: false`, so the page stays where it is. Use it on the login page and in
456
+ the prompt.
457
+
458
+ ```tsx
459
+ <CredentialsForm onSignedIn={() => router.push("/")} strings={{ signIn: "Logg inn" }} />
460
+ ```
461
+
462
+ | Prop | Type | Description |
463
+ | --- | --- | --- |
464
+ | `onSignedIn` | `() => void \| Promise<void>` | Runs after next-auth accepts the credentials. Throw `SignInRejected` with a message to refuse the sign-in. |
465
+ | `initialEmail` | `string` | The email the field starts with. |
466
+ | `children` | `ReactNode` | Extra buttons next to the submit button. |
467
+ | `strings` | `TextOverrides<CredentialsFormStrings>` | The form's text. The defaults are English. |
468
+ | `onPendingChange` | `(pending: boolean) => void` | Runs when a sign-in starts and ends, such as to keep a dialog open until it finishes. |
469
+
470
+ A failed sign-in shows `signInUnavailable` for `SIGN_IN_ERRORS.unavailable` and
471
+ `invalidCredentials` for any other error code. A request next-auth refuses without an error code,
472
+ or one that throws, shows `somethingWentWrong`.
473
+
474
+ `TextOverrides<T>`, from `@sjolystinnovation/app-kit/ui`, is the type of every `strings` prop. Each
475
+ key is optional and may be `undefined`, and a missing or undefined key shows the English default.
476
+
477
+ ### `TextInput`
478
+
479
+ From `@sjolystinnovation/app-kit/ui`. A labelled text field with the same look and props as
480
+ `PasswordInput`, apart from the show and hide toggle. It takes the other `<input>` attributes too.
481
+
482
+ ```tsx
483
+ <TextInput label="Email" name="email" type="email" required error={errors.email} />
484
+ ```
485
+
272
486
  ### `Alert`
273
487
 
274
488
  ```tsx
@@ -280,8 +494,8 @@ import { Alert } from "@sjolystinnovation/app-kit/ui";
280
494
  | Prop | Type | Default | Description |
281
495
  | --- | --- | --- | --- |
282
496
  | `variant` | `"error" \| "success" \| "info" \| "warning"` | `"info"` | Colour of the box. |
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. |
497
+ | `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. |
498
+ | `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. |
285
499
  | `children` | `ReactNode` | | Content. |
286
500
 
287
501
  ### `PasswordInput`
@@ -302,7 +516,7 @@ import { PasswordInput } from "@sjolystinnovation/app-kit/ui";
302
516
  | `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. |
303
517
  | `showPasswordLabel` | `string` | `"Show password"` | Accessible name of the toggle while the value is hidden. |
304
518
  | `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. |
519
+ | `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. |
306
520
 
307
521
  Pass the two toggle labels in the app's language. The defaults are English.
308
522
 
@@ -349,25 +563,40 @@ selector such as `.dark` for a scoped theme:
349
563
  | `--input-focus-ring-width` | `2px` |
350
564
  | `--color-input-toggle` | gray 400, the show password icon |
351
565
  | `--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.
566
+ | `--radius-button` | `var(--radius-lg)` |
567
+ | `--color-button-primary-bg` | `var(--color-primary)` |
568
+ | `--color-button-primary-text` | `var(--color-primary-foreground)` |
569
+ | `--color-button-secondary-border` | gray 300 |
570
+ | `--color-button-secondary-text` | gray 700 |
571
+ | `--color-button-secondary-hover-bg` | gray 50 |
572
+ | `--radius-dialog` | `var(--radius-xl)` |
573
+ | `--color-dialog-backdrop` | black at 40% |
574
+ | `--color-dialog-bg` | white |
575
+ | `--color-dialog-title` | gray 900 |
576
+ | `--color-dialog-text` | gray 600 |
577
+
578
+ A default that points at another variable is read once for the whole page. The focus ring and the
579
+ primary button point at `--color-primary` and `--color-primary-foreground`. A scoped theme that
580
+ changes those also sets `--color-input-focus-ring`, `--color-button-primary-bg` and
581
+ `--color-button-primary-text` in the same selector.
356
582
 
357
583
  An app that clears a whole Tailwind namespace, such as `--color-*: initial`, clears these variables
358
584
  with it and has to set the ones it uses itself.
359
585
 
360
586
  ## Requirements
361
587
 
362
- - Next.js 16, React 19, next-auth 4 and axios 1, as peer dependencies the app installs itself.
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.
588
+ - Next.js 16, React 19, next-auth 4.24 or later and axios 1.20 or later, as peer dependencies the
589
+ app installs itself.
590
+ - `jsonwebtoken` comes with the package. `@sjolystinnovation/app-kit/auth` uses it on the server.
591
+ - For `@sjolystinnovation/app-kit/ui` and the components in `session/react`: Tailwind CSS 4 and
592
+ `@heroicons/react` 2. The focus ring and the primary button use the theme's `primary` and
593
+ `primary-foreground` colours, unless their own variables are set.
365
594
  - TypeScript with `"moduleResolution": "bundler"`. Relative imports here are extensionless, which
366
595
  `node16` and `nodenext` reject.
367
596
 
368
- There is no build output. Shipping source is what keeps the `"use client"` directives intact, since
369
- bundlers strip or relocate them. The consumer therefore compiles this source with its own
370
- `compilerOptions`, and `skipLibCheck` does not exempt it.
597
+ There is no build output. Bundlers strip or relocate `"use client"` directives, and shipping source
598
+ keeps them intact. The consumer compiles this source with its own `compilerOptions`, and
599
+ `skipLibCheck` does not exempt it.
371
600
 
372
601
  ## License
373
602
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjolystinnovation/app-kit",
3
- "version": "0.4.0",
3
+ "version": "0.6.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",
@@ -19,6 +19,8 @@
19
19
  "./session": "./src/session/index.ts",
20
20
  "./session/react": "./src/session/react/index.ts",
21
21
  "./forms": "./src/forms/index.ts",
22
+ "./auth": "./src/auth/index.ts",
23
+ "./api": "./src/api/index.ts",
22
24
  "./ui": "./src/ui/index.ts",
23
25
  "./styles.css": "./src/styles.css"
24
26
  },
@@ -35,7 +37,7 @@
35
37
  },
36
38
  "peerDependencies": {
37
39
  "@heroicons/react": "^2",
38
- "axios": "^1",
40
+ "axios": "^1.20.0",
39
41
  "next": "^16",
40
42
  "next-auth": "^4.24",
41
43
  "react": "^19",
@@ -49,7 +51,7 @@
49
51
  "@types/react": "^19",
50
52
  "@types/react-dom": "^19",
51
53
  "@vitejs/plugin-react": "^6.1.2",
52
- "axios": "^1.19.0",
54
+ "axios": "^1.20.0",
53
55
  "jsdom": "^30.1.1",
54
56
  "next": "^16.3.6",
55
57
  "next-auth": "^4.24.15",
@@ -65,5 +67,9 @@
65
67
  "jwt",
66
68
  "react",
67
69
  "tailwindcss"
68
- ]
70
+ ],
71
+ "dependencies": {
72
+ "@types/jsonwebtoken": "^9.0.8",
73
+ "jsonwebtoken": "^9.0.2"
74
+ }
69
75
  }
@@ -0,0 +1,43 @@
1
+ import axios, { type AxiosInstance } from "axios";
2
+ import { getSession } from "next-auth/react";
3
+ import { isTerminalSessionError, openSessionPrompt } from "../session/expiry";
4
+
5
+ /**
6
+ * An axios instance for the signed-in API, for browser code. Each request carries the session's
7
+ * access token. A 401 on a dead session opens the re-sign-in prompt, so the page keeps its state.
8
+ */
9
+ export function createApiClient(baseURL: string): AxiosInstance {
10
+ if (!baseURL) {
11
+ throw new Error("createApiClient needs the API base URL, and baseURL is empty.");
12
+ }
13
+ // allowAbsoluteUrls: false joins every URL onto baseURL, so the token only goes to the API.
14
+ const client = axios.create({ baseURL, allowAbsoluteUrls: false });
15
+
16
+ // broadcast: false, so a request does not make every other open tab refetch its session.
17
+ client.interceptors.request.use(async (request) => {
18
+ const session = await getSession({ broadcast: false });
19
+ const accessToken = (session as { accessToken?: string } | null)?.accessToken;
20
+ if (accessToken) {
21
+ request.headers.Authorization = `Bearer ${accessToken}`;
22
+ }
23
+ return request;
24
+ });
25
+
26
+ client.interceptors.response.use(
27
+ (response) => response,
28
+ async (error) => {
29
+ if (axios.isAxiosError(error) && error.response?.status === 401) {
30
+ const session = await getSession({ broadcast: false });
31
+ // No session also opens the prompt. The cookie is gone, or the session fetch failed. Either
32
+ // way there is no error field to read, and signing in again recovers both.
33
+ const sessionError = (session as { error?: string } | null)?.error;
34
+ if (!session || isTerminalSessionError(sessionError)) {
35
+ openSessionPrompt();
36
+ }
37
+ }
38
+ return Promise.reject(error);
39
+ },
40
+ );
41
+
42
+ return client;
43
+ }
@@ -0,0 +1,2 @@
1
+ export { createApiClient } from "./createApiClient";
2
+ export { request } from "./request";
@@ -0,0 +1,22 @@
1
+ import axios from "axios";
2
+ import { formatApiError, type StatusMessages } from "../errors";
3
+
4
+ /**
5
+ * Runs an API call and returns its data. A failure throws an `Error` with a message for the user,
6
+ * from `formatApiError`. A cancelled call rethrows the cancel as it is, so the caller can ignore it.
7
+ */
8
+ export async function request<T>(
9
+ call: () => Promise<{ data: T }>,
10
+ fallback: string,
11
+ statusMessages?: StatusMessages,
12
+ ): Promise<T> {
13
+ try {
14
+ const response = await call();
15
+ return response.data;
16
+ } catch (error: unknown) {
17
+ if (axios.isCancel(error)) throw error;
18
+ // No cause. Errors get logged, and the axios error carries the access token and request body.
19
+ // eslint-disable-next-line preserve-caught-error
20
+ throw new Error(formatApiError(error, fallback, statusMessages));
21
+ }
22
+ }