@sjolystinnovation/app-kit 0.5.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
@@ -28,8 +28,8 @@ const nextConfig: NextConfig = {
28
28
  };
29
29
  ```
30
30
 
31
- The components in `@sjolystinnovation/app-kit/ui` are styled with Tailwind CSS. Import the
32
- 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:
33
33
 
34
34
  ```css
35
35
  /* src/app/globals.css */
@@ -265,35 +265,54 @@ export const getRecipe = (id: number) =>
265
265
  Decides when a user has to sign in again. Safe to import from server code, such as the next-auth
266
266
  callbacks.
267
267
 
268
- The gate reads `session.error`. `createAuthOptions` sets it already. An app with its own next-auth
269
- options sets `token.error` in its `jwt` callback and copies it across in its `session` callback:
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:
270
272
 
271
273
  ```ts
274
+ const SESSION_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000;
275
+
272
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
+ },
273
286
  session({ session, token }) {
287
+ session.user.id = token.sub ?? "";
274
288
  session.error = token.error;
289
+ session.absoluteExpiresAt = token.loginAt ? token.loginAt + SESSION_LIFETIME_MS : undefined;
275
290
  return session;
276
291
  },
277
292
  },
278
293
  ```
279
294
 
280
- 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
281
296
  the app's existing next-auth type declarations, or create them:
282
297
 
283
298
  ```ts
284
299
  // src/types/next-auth.d.ts
285
300
  import "next-auth";
286
301
  import "next-auth/jwt";
302
+ import type { DefaultSession } from "next-auth";
287
303
 
288
304
  declare module "next-auth" {
289
305
  interface Session {
306
+ user: { id: string } & DefaultSession["user"];
290
307
  error?: string | undefined;
308
+ absoluteExpiresAt?: number | undefined;
291
309
  }
292
310
  }
293
311
 
294
312
  declare module "next-auth/jwt" {
295
313
  interface JWT {
296
314
  error?: string | undefined;
315
+ loginAt?: number | undefined;
297
316
  }
298
317
  }
299
318
  ```
@@ -335,37 +354,8 @@ A session that turns signed out with no prompt open blanks the page. A page the
335
354
  dead session does not render. Use `mayRender` in every gate and layout on the way down, so none of
336
355
  them blanks a page that another one kept.
337
356
 
338
- The hook does not navigate. The gate sends the user to the login page itself, and never while the
339
- prompt is open. The app passes the login URL in, so an app with a locale in the path can build it
340
- for the current locale:
341
-
342
- ```tsx
343
- "use client";
344
-
345
- import { useSessionGate } from "@sjolystinnovation/app-kit/session/react";
346
- import { useRouter } from "next/navigation";
347
- import { useEffect } from "react";
348
-
349
- export function ProtectedGate({
350
- loginHref,
351
- children,
352
- }: {
353
- loginHref: string;
354
- children: React.ReactNode;
355
- }) {
356
- const router = useRouter();
357
- const { status, promptOpen, usable, wasUsable, mayRender } = useSessionGate();
358
-
359
- useEffect(() => {
360
- if (promptOpen) return;
361
- if (status === "unauthenticated" || (status === "authenticated" && !usable && !wasUsable)) {
362
- router.push(loginHref);
363
- }
364
- }, [status, usable, wasUsable, promptOpen, router, loginHref]);
365
-
366
- return mayRender ? children : <p>Loading…</p>;
367
- }
368
- ```
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.
369
359
 
370
360
  | Field | Description |
371
361
  | --- | --- |
@@ -378,6 +368,50 @@ export function ProtectedGate({
378
368
 
379
369
  `SessionGate` is the type of the result.
380
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
+
381
415
  ### `useFormDraft(config, options)`
382
416
 
383
417
  From `@sjolystinnovation/app-kit/forms`. Keeps a form's values in `localStorage`, so a sign-out,
@@ -415,6 +449,40 @@ Drafts are stored under `<draftStoragePrefix>:draft:<owner>:<scope>`. The hook t
415
449
  `draftStoragePrefix` is empty or contains a colon. A draft older than 7 days is not offered, and is
416
450
  removed when its form next reads it.
417
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
+
418
486
  ### `Alert`
419
487
 
420
488
  ```tsx
@@ -426,7 +494,7 @@ import { Alert } from "@sjolystinnovation/app-kit/ui";
426
494
  | Prop | Type | Default | Description |
427
495
  | --- | --- | --- | --- |
428
496
  | `variant` | `"error" \| "success" \| "info" \| "warning"` | `"info"` | Colour of the box. |
429
- | `role` | `"alert" \| "status"` | `"alert"` | Use `status` for text that keeps changing, such as a countdown, so a screen reader does not announce every change. |
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. |
430
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. |
431
499
  | `children` | `ReactNode` | | Content. |
432
500
 
@@ -495,10 +563,22 @@ selector such as `.dark` for a scoped theme:
495
563
  | `--input-focus-ring-width` | `2px` |
496
564
  | `--color-input-toggle` | gray 400, the show password icon |
497
565
  | `--color-input-toggle-hover` | gray 600 |
498
-
499
- A default that points at another variable, such as `--color-input-focus-ring` at `--color-primary`,
500
- is read once for the whole page. A scoped theme that changes `--color-primary` should set
501
- `--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.
502
582
 
503
583
  An app that clears a whole Tailwind namespace, such as `--color-*: initial`, clears these variables
504
584
  with it and has to set the ones it uses itself.
@@ -508,8 +588,9 @@ with it and has to set the ones it uses itself.
508
588
  - Next.js 16, React 19, next-auth 4.24 or later and axios 1.20 or later, as peer dependencies the
509
589
  app installs itself.
510
590
  - `jsonwebtoken` comes with the package. `@sjolystinnovation/app-kit/auth` uses it on the server.
511
- - For `@sjolystinnovation/app-kit/ui`: Tailwind CSS 4 and `@heroicons/react` 2. The focus ring uses
512
- the theme's `primary` colour unless `--color-input-focus-ring` is set.
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.
513
594
  - TypeScript with `"moduleResolution": "bundler"`. Relative imports here are extensionless, which
514
595
  `node16` and `nodenext` reject.
515
596
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjolystinnovation/app-kit",
3
- "version": "0.5.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",
@@ -0,0 +1,34 @@
1
+ "use client";
2
+
3
+ import { useRouter } from "next/navigation";
4
+ import { useEffect, type ReactNode } from "react";
5
+ import { useSessionGate } from "./useSessionGate";
6
+
7
+ export interface ProtectedGateProps {
8
+ /** Where a signed-out visitor, or a session dead on arrival, is sent. */
9
+ loginHref: string;
10
+ /** What shows while the page may not render yet. */
11
+ fallback?: ReactNode | undefined;
12
+ children: ReactNode;
13
+ }
14
+
15
+ /**
16
+ * Renders a protected page only on a usable session, and keeps it rendered while the re-sign-in
17
+ * prompt recovers the session. Never redirects while the prompt is open.
18
+ */
19
+ export function ProtectedGate({ loginHref, fallback = null, children }: ProtectedGateProps) {
20
+ const router = useRouter();
21
+ const { status, promptOpen, usable, wasUsable, mayRender } = useSessionGate();
22
+
23
+ useEffect(() => {
24
+ // A page opened on a dead session must not render, or the user starts work they cannot save.
25
+ // A session that dies later keeps the page, and the prompt recovers it in place.
26
+ if (promptOpen) return;
27
+ if (status === "unauthenticated" || (status === "authenticated" && !usable && !wasUsable)) {
28
+ // replace, so Back does not return to a page that only redirects again.
29
+ router.replace(loginHref);
30
+ }
31
+ }, [status, usable, wasUsable, promptOpen, router, loginHref]);
32
+
33
+ return mayRender ? children : fallback;
34
+ }
@@ -0,0 +1,294 @@
1
+ "use client";
2
+
3
+ import { getSession, signOut, useSession } from "next-auth/react";
4
+ import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
5
+ import { Alert } from "../../ui/Alert";
6
+ import { CredentialsForm } from "../../ui/CredentialsForm";
7
+ import { SignInRejected, type CredentialsFormStrings } from "../../ui/credentialsFormStrings";
8
+ import { withDefaults, type TextOverrides } from "../../ui/withDefaults";
9
+ import {
10
+ closeSessionPrompt,
11
+ getSessionPromptOpen,
12
+ isTerminalSessionError,
13
+ openSessionPrompt,
14
+ subscribeSessionPrompt,
15
+ } from "../expiry";
16
+ import { defaultSessionExpiryGuardStrings, type SessionExpiryGuardStrings } from "./sessionExpiryGuardStrings";
17
+
18
+ // The absolute expiry cannot be refreshed away, so the banner warns while there is still time.
19
+ const WARN_BEFORE_MS = 30 * 60 * 1000;
20
+ const MINUTE_MS = 60 * 1000;
21
+ // The minutes left at which a screen reader hears the countdown again, after it first appears.
22
+ const ANNOUNCE_AT_MINUTES = [10, 5, 1];
23
+
24
+ // Timers pause while a computer sleeps, so no wait runs longer than this. The banner is then at most
25
+ // this late after a wake. It also keeps every wait far below the browser's 32-bit timer limit.
26
+ const MAX_WAIT_MS = 5 * MINUTE_MS;
27
+
28
+ // How long until the banner text can next change: the warning starts, the minute count drops, or
29
+ // the session expires. Null when nothing more can change.
30
+ function msUntilNextChange(remaining: number): number | null {
31
+ if (remaining <= 0) return null;
32
+ const wait = remaining > WARN_BEFORE_MS ? remaining - WARN_BEFORE_MS : remaining % MINUTE_MS || MINUTE_MS;
33
+ return Math.min(wait, MAX_WAIT_MS);
34
+ }
35
+
36
+
37
+ export interface SessionExpiryGuardProps {
38
+ /** Where a sign-in as a different user is sent after it is signed out. */
39
+ loginHref: string;
40
+ /** Runs after the prompt signs the user back in, such as to show a toast. */
41
+ onRestored?: (() => void) | undefined;
42
+ strings?: TextOverrides<SessionExpiryGuardStrings> | undefined;
43
+ formStrings?: TextOverrides<CredentialsFormStrings> | undefined;
44
+ }
45
+
46
+ type User = { id?: string; email?: string | null };
47
+ type Owner = { id: string; email: string };
48
+
49
+ /**
50
+ * Warns before the session's absolute expiry, and signs the user back in without leaving the page
51
+ * when a request finds a dead session. Render it once, inside the SessionProvider.
52
+ */
53
+ export function SessionExpiryGuard({ loginHref, onRestored, strings, formStrings }: SessionExpiryGuardProps) {
54
+ const text = withDefaults(defaultSessionExpiryGuardStrings, strings);
55
+ const { data: session, status } = useSession();
56
+ const sessionInfo = session as { user?: User; error?: string; absoluteExpiresAt?: number } | null;
57
+
58
+ const open = useSyncExternalStore(subscribeSessionPrompt, getSessionPromptOpen, () => false);
59
+
60
+ // The signed-in user. Latched, because a re-read after a lost cookie reports nobody.
61
+ const [owner, setOwner] = useState<Owner | null>(null);
62
+ const current = sessionInfo?.user;
63
+ // Not while the prompt is open, unless no owner is known yet. A sign-in in the prompt must be
64
+ // checked against the owner, not become it.
65
+ if (current?.id && current.id !== owner?.id && (!open || !owner)) {
66
+ setOwner({ id: current.id, email: current.email ?? "" });
67
+ }
68
+ // The user the session reports now, for the sign-in check when the read-back finds nobody.
69
+ const liveUser = useRef<User | undefined>(undefined);
70
+ useEffect(() => {
71
+ liveUser.current = current;
72
+ });
73
+
74
+ // The minutes left when the countdown banner opened the prompt. Null when a failed request opened
75
+ // it. Kept while the prompt is open, so the wording does not change under the user mid sign-in.
76
+ const [bannerMinutes, setBannerMinutes] = useState<number | null>(null);
77
+ // Cleared when the prompt closes, so a later prompt from a failed request does not reuse it.
78
+ const [wasOpen, setWasOpen] = useState(open);
79
+ if (open !== wasOpen) {
80
+ setWasOpen(open);
81
+ if (!open) setBannerMinutes(null);
82
+ }
83
+ // A sign-in is in flight. Closing then would hide its outcome.
84
+ const [pending, setPending] = useState(false);
85
+
86
+ // The clock lives in state, so rendering stays pure. A timer reads it again when the banner can
87
+ // change, and at least every few minutes.
88
+ const [now, setNow] = useState(() => Date.now());
89
+ const remaining =
90
+ status === "authenticated" && sessionInfo?.absoluteExpiresAt != null
91
+ ? sessionInfo.absoluteExpiresAt - now
92
+ : null;
93
+ useEffect(() => {
94
+ const wait = remaining == null ? null : msUntilNextChange(remaining);
95
+ if (wait == null) return;
96
+ const timer = window.setTimeout(() => setNow(Date.now()), wait);
97
+ return () => window.clearTimeout(timer);
98
+ }, [remaining]);
99
+ // A tab coming back, often after the computer slept, reads the clock again at once.
100
+ useEffect(() => {
101
+ const onVisible = () => {
102
+ if (document.visibilityState === "visible") setNow(Date.now());
103
+ };
104
+ document.addEventListener("visibilitychange", onVisible);
105
+ return () => document.removeEventListener("visibilitychange", onVisible);
106
+ }, []);
107
+
108
+ // Rounded up, so the last half minute reads 1 min, not 0.
109
+ const minutesLeft =
110
+ remaining != null && remaining > 0 && remaining <= WARN_BEFORE_MS ? Math.ceil(remaining / MINUTE_MS) : null;
111
+ const expired =
112
+ status === "authenticated" &&
113
+ (isTerminalSessionError(sessionInfo?.error) || (remaining != null && remaining <= 0));
114
+
115
+ const countdown = (minutes: number | null) => text.expiringSoon.replaceAll("{minutes}", String(minutes));
116
+
117
+ // The minutes a screen reader last heard. Every change of a status region is announced, so it
118
+ // changes only when the countdown starts and when it passes one of ANNOUNCE_AT_MINUTES.
119
+ const [announcedMinutes, setAnnouncedMinutes] = useState<number | null>(null);
120
+ const announceNow =
121
+ minutesLeft == null
122
+ ? null
123
+ : announcedMinutes == null || ANNOUNCE_AT_MINUTES.some((at) => minutesLeft <= at && announcedMinutes > at)
124
+ ? minutesLeft
125
+ : announcedMinutes;
126
+ if (announceNow !== announcedMinutes) setAnnouncedMinutes(announceNow);
127
+
128
+ // An open prompt stops the gate from redirecting, so the prompt closes when the guard unmounts.
129
+ useEffect(() => closeSessionPrompt, []);
130
+
131
+ // Who the prompt opened for. signIn replaces the session before it resolves, so the live owner
132
+ // would already be the account that just signed in.
133
+ const promptOwner = useRef<Owner | null>(null);
134
+ useEffect(() => {
135
+ if (!open) {
136
+ promptOwner.current = null;
137
+ return;
138
+ }
139
+ // Only on the way open. Reassigning while open would adopt the new account.
140
+ promptOwner.current ??= owner;
141
+ }, [open, owner]);
142
+
143
+ const dialog = useRef<HTMLDivElement | null>(null);
144
+ // The keydown handler is bound once per opening, so it reads pending through a ref.
145
+ const pendingRef = useRef(false);
146
+ const onPendingChange = (next: boolean) => {
147
+ pendingRef.current = next;
148
+ setPending(next);
149
+ };
150
+ const titleId = useId();
151
+
152
+ // Escape closes the dialog, except while a sign-in is pending. Tab cycles inside the dialog, even
153
+ // when focus has left it. Focus starts on the password field and returns where it was afterwards.
154
+ useEffect(() => {
155
+ if (!open) return;
156
+ const previous = document.activeElement as HTMLElement | null;
157
+ const focusable = () =>
158
+ Array.from(dialog.current?.querySelectorAll<HTMLElement>("input, button:not([disabled])") ?? []);
159
+
160
+ focusable()
161
+ .find((element) => element instanceof HTMLInputElement && element.type === "password")
162
+ ?.focus();
163
+
164
+ const onKeyDown = (event: KeyboardEvent) => {
165
+ if (event.key === "Escape") {
166
+ if (!pendingRef.current) closeSessionPrompt();
167
+ return;
168
+ }
169
+ if (event.key !== "Tab") return;
170
+ const elements = focusable();
171
+ const first = elements[0];
172
+ const last = elements[elements.length - 1];
173
+ if (!first || !last) return;
174
+ const edge = event.shiftKey ? first : last;
175
+ const outside = !dialog.current?.contains(document.activeElement);
176
+ if (outside || document.activeElement === edge) {
177
+ event.preventDefault();
178
+ (event.shiftKey ? last : first).focus();
179
+ }
180
+ };
181
+
182
+ document.addEventListener("keydown", onKeyDown);
183
+ return () => {
184
+ document.removeEventListener("keydown", onKeyDown);
185
+ // The control that opened the prompt may be gone by now, such as the banner's button.
186
+ if (previous?.isConnected) previous.focus();
187
+ };
188
+ }, [open]);
189
+
190
+ // A session read already in flight can answer after the sign-out and set the cookie again. So
191
+ // the session is checked and the sign-out repeated, and the page is left whatever happens.
192
+ const signOutFully = async () => {
193
+ for (let attempt = 0; attempt < 3; attempt++) {
194
+ try {
195
+ await signOut({ redirect: false });
196
+ if (!(await getSession({ broadcast: false }))) break;
197
+ } catch {
198
+ break;
199
+ }
200
+ }
201
+ window.location.assign(loginHref);
202
+ };
203
+
204
+ const signedIn = async () => {
205
+ // Read the session back, and only report success when it can save.
206
+ let next = (await getSession()) as { user?: User; error?: string } | null;
207
+ // A failed read-back with nobody known yet says nothing about who signed in, so read again.
208
+ for (let attempt = 0; attempt < 2 && !next && !liveUser.current?.id; attempt++) {
209
+ next = (await getSession({ broadcast: false })) as { user?: User; error?: string } | null;
210
+ }
211
+ // The form on the page belongs to whoever opened it. signIn has already replaced the session,
212
+ // so a different account is signed out, even when the read-back failed. The opener's draft
213
+ // stays stored under the opener's id, and comes back when they sign in.
214
+ const opener = promptOwner.current ?? owner;
215
+ // A session without an id counts as another account. When the read-back failed, only a known
216
+ // id counts.
217
+ const signedInId = next ? next.user?.id : liveUser.current?.id;
218
+ if (opener && (next || signedInId) && signedInId !== opener.id) {
219
+ await signOutFully();
220
+ throw new SignInRejected(text.wrongUser);
221
+ }
222
+ if (!next || isTerminalSessionError(next.error)) {
223
+ throw new SignInRejected(text.notRestored);
224
+ }
225
+ closeSessionPrompt();
226
+ onRestored?.();
227
+ };
228
+
229
+ return (
230
+ <>
231
+ {/* Mounted from the start, because a screen reader skips text that arrives with its region. */}
232
+ <div role="status" className="sr-only">
233
+ {!expired && announcedMinutes != null ? countdown(announcedMinutes) : ""}
234
+ </div>
235
+ <div role="alert" className="sr-only">
236
+ {expired ? text.expiredBody : ""}
237
+ </div>
238
+ {(expired || minutesLeft != null) && (
239
+ // At the bottom, because a sticky navbar usually holds the top at the same stacking level.
240
+ <div className="fixed inset-x-0 bottom-0 z-40">
241
+ <Alert variant="warning" role="none">
242
+ <div className="max-w-5xl mx-auto flex flex-wrap items-center justify-between gap-2">
243
+ <span>{expired ? text.expiredBody : countdown(minutesLeft)}</span>
244
+ <button
245
+ type="button"
246
+ onClick={() => {
247
+ if (!expired) setBannerMinutes(minutesLeft);
248
+ openSessionPrompt();
249
+ }}
250
+ className="font-semibold underline underline-offset-2"
251
+ >
252
+ {text.reSignIn}
253
+ </button>
254
+ </div>
255
+ </Alert>
256
+ </div>
257
+ )}
258
+
259
+ {open && (
260
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-dialog-backdrop px-4">
261
+ <div
262
+ ref={dialog}
263
+ role="dialog"
264
+ aria-modal="true"
265
+ aria-labelledby={titleId}
266
+ className="w-full max-w-sm rounded-dialog bg-dialog-bg p-6 shadow-xl"
267
+ >
268
+ <h2 id={titleId} className="text-lg font-bold text-dialog-title">
269
+ {bannerMinutes != null ? text.reSignIn : text.expiredTitle}
270
+ </h2>
271
+ <p className="mt-1 mb-4 text-sm text-dialog-text">
272
+ {bannerMinutes != null ? countdown(bannerMinutes) : text.expiredBody}
273
+ </p>
274
+ <CredentialsForm
275
+ initialEmail={owner?.email ?? ""}
276
+ onSignedIn={signedIn}
277
+ strings={formStrings}
278
+ onPendingChange={onPendingChange}
279
+ >
280
+ <button
281
+ type="button"
282
+ onClick={closeSessionPrompt}
283
+ disabled={pending}
284
+ className="rounded-button border border-button-secondary-border px-4 text-sm font-medium text-button-secondary-text hover:bg-button-secondary-hover-bg"
285
+ >
286
+ {text.close}
287
+ </button>
288
+ </CredentialsForm>
289
+ </div>
290
+ </div>
291
+ )}
292
+ </>
293
+ );
294
+ }
@@ -1,2 +1,8 @@
1
+ export { ProtectedGate } from "./ProtectedGate";
2
+ export type { ProtectedGateProps } from "./ProtectedGate";
3
+ export { SessionExpiryGuard } from "./SessionExpiryGuard";
4
+ export type { SessionExpiryGuardProps } from "./SessionExpiryGuard";
5
+ export { defaultSessionExpiryGuardStrings } from "./sessionExpiryGuardStrings";
6
+ export type { SessionExpiryGuardStrings } from "./sessionExpiryGuardStrings";
1
7
  export { useSessionGate } from "./useSessionGate";
2
8
  export type { SessionGate } from "./useSessionGate";
@@ -0,0 +1,26 @@
1
+ // Plain values, with no "use client", so a server component can import them.
2
+
3
+ /**
4
+ * The guard's text. Pass the app's own language. The defaults are English. Plain strings, so a
5
+ * server component can pass them.
6
+ */
7
+ export interface SessionExpiryGuardStrings {
8
+ expiredTitle: string;
9
+ expiredBody: string;
10
+ /** Every `{minutes}` is replaced with the minutes left. */
11
+ expiringSoon: string;
12
+ reSignIn: string;
13
+ notRestored: string;
14
+ wrongUser: string;
15
+ close: string;
16
+ }
17
+
18
+ export const defaultSessionExpiryGuardStrings: SessionExpiryGuardStrings = {
19
+ expiredTitle: "Session expired",
20
+ expiredBody: "Your session has expired. Sign in again to save.",
21
+ expiringSoon: "Your session expires in {minutes} min. Sign in again to continue without interruption.",
22
+ reSignIn: "Sign in again",
23
+ notRestored: "We could not renew the session. Try again.",
24
+ wrongUser: "This page belongs to a different user. Sign in with the same account.",
25
+ close: "Close",
26
+ };
package/src/styles.css CHANGED
@@ -2,9 +2,9 @@
2
2
  @source ".";
3
3
 
4
4
  /*
5
- * Design tokens for the components. The defaults are stock Tailwind values, except the focus ring,
6
- * which uses the theme's --color-primary. Set a token in the app's own @theme to restyle, or in a
7
- * selector such as .dark for a scoped theme.
5
+ * Design tokens for the components. The defaults are stock Tailwind values, except the focus ring
6
+ * and the primary button, which use the theme's --color-primary and --color-primary-foreground. Set
7
+ * a token in the app's own @theme to restyle, or in a selector such as .dark for a scoped theme.
8
8
  */
9
9
  @theme default {
10
10
  --radius-alert: var(--radius-lg);
@@ -40,4 +40,17 @@
40
40
  --input-focus-ring-width: 2px;
41
41
  --color-input-toggle: var(--color-gray-400);
42
42
  --color-input-toggle-hover: var(--color-gray-600);
43
+
44
+ --radius-button: var(--radius-lg);
45
+ --color-button-primary-bg: var(--color-primary);
46
+ --color-button-primary-text: var(--color-primary-foreground);
47
+ --color-button-secondary-border: var(--color-gray-300);
48
+ --color-button-secondary-text: var(--color-gray-700);
49
+ --color-button-secondary-hover-bg: var(--color-gray-50);
50
+
51
+ --radius-dialog: var(--radius-xl);
52
+ --color-dialog-backdrop: color-mix(in oklab, var(--color-black) 40%, transparent);
53
+ --color-dialog-bg: var(--color-white);
54
+ --color-dialog-title: var(--color-gray-900);
55
+ --color-dialog-text: var(--color-gray-600);
43
56
  }
package/src/ui/Alert.tsx CHANGED
@@ -4,10 +4,11 @@ import { cx } from "./cx";
4
4
  export interface AlertProps {
5
5
  variant?: "error" | "success" | "info" | "warning";
6
6
  /**
7
- * Defaults to alert, which a screen reader interrupts for. Use status for text that keeps
8
- * changing, such as a countdown, so it is not announced on every tick.
7
+ * Defaults to alert, which a screen reader interrupts for. A screen reader announces status
8
+ * without interrupting, on every change of its text. Use none when a separate live region
9
+ * announces the text.
9
10
  */
10
- role?: "alert" | "status";
11
+ role?: "alert" | "status" | "none";
11
12
  /**
12
13
  * Classes for placing the alert, such as a margin. Restyle it through the theme variables. A
13
14
  * class that clashes with the alert's own wins or loses by the order Tailwind emits them in.
@@ -0,0 +1,113 @@
1
+ "use client";
2
+
3
+ import { signIn } from "next-auth/react";
4
+ import { useState, type FormEvent, type ReactNode } from "react";
5
+ import { SIGN_IN_ERRORS } from "../session/expiry";
6
+ import { Alert } from "./Alert";
7
+ import { PasswordInput } from "./PasswordInput";
8
+ import {
9
+ SignInRejected,
10
+ defaultCredentialsFormStrings,
11
+ type CredentialsFormStrings,
12
+ } from "./credentialsFormStrings";
13
+ import { TextInput } from "./TextInput";
14
+ import { withDefaults, type TextOverrides } from "./withDefaults";
15
+
16
+
17
+ export interface CredentialsFormProps {
18
+ initialEmail?: string | undefined;
19
+ /** Runs after next-auth accepts the credentials. Throw `SignInRejected` to refuse with a reason. */
20
+ onSignedIn: () => void | Promise<void>;
21
+ /** Extra buttons next to the submit button, such as a close button. */
22
+ children?: ReactNode;
23
+ strings?: TextOverrides<CredentialsFormStrings> | undefined;
24
+ /** Runs when a sign-in starts and when it ends, such as to stop a dialog closing mid-request. */
25
+ onPendingChange?: ((pending: boolean) => void) | undefined;
26
+ }
27
+
28
+ // next-auth answers a stale CSRF token with its own sign-in page and ?csrf=true.
29
+ function refusedForCsrf(url: string | null | undefined): boolean {
30
+ if (!url) return false;
31
+ const parsed = new URL(url, window.location.origin);
32
+ return parsed.pathname.endsWith("/signin") && parsed.searchParams.get("csrf") === "true";
33
+ }
34
+
35
+ /** Email and password sign-in through next-auth's credentials provider. */
36
+ export function CredentialsForm({
37
+ initialEmail = "",
38
+ onSignedIn,
39
+ children,
40
+ strings,
41
+ onPendingChange,
42
+ }: CredentialsFormProps) {
43
+ const text = withDefaults(defaultCredentialsFormStrings, strings);
44
+ const [email, setEmail] = useState(initialEmail);
45
+ // Follows initialEmail until the user types, so an email learned after mount still fills in.
46
+ const [emailTyped, setEmailTyped] = useState(false);
47
+ if (!emailTyped && email !== initialEmail) setEmail(initialEmail);
48
+ const [password, setPassword] = useState("");
49
+ const [error, setError] = useState<string | null>(null);
50
+ const [submitting, setSubmitting] = useState(false);
51
+
52
+ const handleSubmit = async (event: FormEvent) => {
53
+ event.preventDefault();
54
+ setError(null);
55
+ setSubmitting(true);
56
+ onPendingChange?.(true);
57
+ try {
58
+ const result = await signIn("credentials", { email, password, redirect: false });
59
+ if (result?.error) {
60
+ setError(result.error === SIGN_IN_ERRORS.unavailable ? text.signInUnavailable : text.invalidCredentials);
61
+ return;
62
+ }
63
+ // next-auth reports a stale CSRF token as a success, see refusedForCsrf.
64
+ if (!result?.ok || refusedForCsrf(result.url)) {
65
+ setError(text.somethingWentWrong);
66
+ return;
67
+ }
68
+ await onSignedIn();
69
+ setPassword("");
70
+ } catch (caught) {
71
+ setError(caught instanceof SignInRejected ? caught.message : text.somethingWentWrong);
72
+ } finally {
73
+ setSubmitting(false);
74
+ onPendingChange?.(false);
75
+ }
76
+ };
77
+
78
+ return (
79
+ <form onSubmit={handleSubmit} className="space-y-4">
80
+ {error && <Alert variant="error">{error}</Alert>}
81
+ <TextInput
82
+ label={text.email}
83
+ name="email"
84
+ type="email"
85
+ value={email}
86
+ onChange={(e) => {
87
+ setEmailTyped(true);
88
+ setEmail(e.target.value);
89
+ }}
90
+ required
91
+ />
92
+ <PasswordInput
93
+ label={text.password}
94
+ name="password"
95
+ value={password}
96
+ onChange={(e) => setPassword(e.target.value)}
97
+ required
98
+ showPasswordLabel={text.showPassword}
99
+ hidePasswordLabel={text.hidePassword}
100
+ />
101
+ <div className="flex gap-2">
102
+ <button
103
+ type="submit"
104
+ disabled={submitting}
105
+ className="flex-1 rounded-button bg-button-primary-bg text-button-primary-text font-semibold py-2.5 hover:brightness-95 disabled:opacity-60 transition"
106
+ >
107
+ {submitting ? text.signingIn : text.signIn}
108
+ </button>
109
+ {children}
110
+ </div>
111
+ </form>
112
+ );
113
+ }
@@ -1,17 +1,17 @@
1
1
  "use client";
2
2
 
3
3
  import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/outline";
4
- import { useId, useState, type InputHTMLAttributes } from "react";
5
- import { cx } from "./cx";
4
+ import { useState, type InputHTMLAttributes } from "react";
5
+ import { FieldError, FieldLabel, inputClass, useField } from "./field";
6
6
 
7
7
  /** The input's type is left out: the component switches it to show and hide the value. */
8
8
  export interface PasswordInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
9
9
  label: string;
10
- error?: string;
10
+ error?: string | undefined;
11
11
  /** Accessible name of the toggle while the password is hidden. */
12
- showPasswordLabel?: string;
12
+ showPasswordLabel?: string | undefined;
13
13
  /** Accessible name of the toggle while the password is visible. */
14
- hidePasswordLabel?: string;
14
+ hidePasswordLabel?: string | undefined;
15
15
  /**
16
16
  * Classes for placing the field, such as a margin. They go on the outer element, which holds the
17
17
  * label, the input and the error. Restyle the field through the theme variables.
@@ -22,39 +22,25 @@ export interface PasswordInputProps extends Omit<InputHTMLAttributes<HTMLInputEl
22
22
  export function PasswordInput({
23
23
  label,
24
24
  error,
25
- id,
26
- required,
27
25
  className,
28
26
  showPasswordLabel = "Show password",
29
27
  hidePasswordLabel = "Hide password",
30
28
  ...props
31
29
  }: PasswordInputProps) {
32
30
  const [visible, setVisible] = useState(false);
33
- // A generated id ties the label to its own control, even when two fields on a page share a name.
34
- const generated = useId();
35
- const inputId = id ?? generated;
36
- // From useId, which has no spaces. aria-describedby splits its ids on spaces.
37
- const errorId = `${generated}-error`;
31
+ const field = useField(props, error);
38
32
 
39
33
  return (
40
34
  <div className={className}>
41
- <label htmlFor={inputId} className="block text-field-label font-medium text-field-label-text mb-field-label">
35
+ <FieldLabel htmlFor={field.inputId} required={props.required}>
42
36
  {label}
43
- {required && <span className="text-field-required"> *</span>}
44
- </label>
37
+ </FieldLabel>
45
38
  <div className="relative">
46
39
  <input
47
40
  {...props}
48
- id={inputId}
41
+ {...field.inputProps}
49
42
  type={visible ? "text" : "password"}
50
- required={required}
51
- aria-required={required ? true : props["aria-required"]}
52
- aria-invalid={error ? true : props["aria-invalid"]}
53
- aria-describedby={cx(props["aria-describedby"], error && errorId) || undefined}
54
- className={cx(
55
- "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",
56
- error ? "border-input-border-error" : "border-input-border focus:border-input-focus-border",
57
- )}
43
+ className={inputClass(error, "pr-10")}
58
44
  />
59
45
  <button
60
46
  type="button"
@@ -65,11 +51,7 @@ export function PasswordInput({
65
51
  {visible ? <EyeSlashIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
66
52
  </button>
67
53
  </div>
68
- {error && (
69
- <p id={errorId} className="mt-1 text-xs text-field-error">
70
- {error}
71
- </p>
72
- )}
54
+ <FieldError id={field.errorId} error={error} />
73
55
  </div>
74
56
  );
75
57
  }
@@ -0,0 +1,28 @@
1
+ "use client";
2
+
3
+ import type { InputHTMLAttributes } from "react";
4
+ import { FieldError, FieldLabel, inputClass, useField } from "./field";
5
+
6
+ export interface TextInputProps extends InputHTMLAttributes<HTMLInputElement> {
7
+ label: string;
8
+ error?: string | undefined;
9
+ /**
10
+ * Classes for placing the field, such as a margin. They go on the outer element, which holds the
11
+ * label, the input and the error. Restyle the field through the theme variables.
12
+ */
13
+ className?: string | undefined;
14
+ }
15
+
16
+ export function TextInput({ label, error, className, ...props }: TextInputProps) {
17
+ const field = useField(props, error);
18
+
19
+ return (
20
+ <div className={className}>
21
+ <FieldLabel htmlFor={field.inputId} required={props.required}>
22
+ {label}
23
+ </FieldLabel>
24
+ <input {...props} {...field.inputProps} className={inputClass(error)} />
25
+ <FieldError id={field.errorId} error={error} />
26
+ </div>
27
+ );
28
+ }
@@ -0,0 +1,29 @@
1
+ // Plain values, with no "use client", so a server component can import them.
2
+
3
+ /** Thrown from `onSignedIn` to refuse a sign-in. Its message is shown in the form. */
4
+ export class SignInRejected extends Error {}
5
+
6
+ /** The form's text. Pass the app's own language. The defaults are English. */
7
+ export interface CredentialsFormStrings {
8
+ email: string;
9
+ password: string;
10
+ signIn: string;
11
+ signingIn: string;
12
+ invalidCredentials: string;
13
+ signInUnavailable: string;
14
+ somethingWentWrong: string;
15
+ showPassword: string;
16
+ hidePassword: string;
17
+ }
18
+
19
+ export const defaultCredentialsFormStrings: CredentialsFormStrings = {
20
+ email: "Email",
21
+ password: "Password",
22
+ signIn: "Sign in",
23
+ signingIn: "Signing in…",
24
+ invalidCredentials: "Invalid email or password.",
25
+ signInUnavailable: "Sign-in is unavailable right now. Try again shortly.",
26
+ somethingWentWrong: "Something went wrong",
27
+ showPassword: "Show password",
28
+ hidePassword: "Hide password",
29
+ };
@@ -0,0 +1,51 @@
1
+ "use client";
2
+
3
+ import { useId, type InputHTMLAttributes, type ReactNode } from "react";
4
+ import { cx } from "./cx";
5
+
6
+ /** The ids and aria attributes a labelled input needs, from the caller's own props. */
7
+ export function useField(props: Pick<InputHTMLAttributes<HTMLInputElement>, "id" | "required" | "aria-required" | "aria-invalid" | "aria-describedby">, error: string | undefined) {
8
+ // A generated id ties the label to its own control, even when two fields on a page share a name.
9
+ const generated = useId();
10
+ const inputId = props.id ?? generated;
11
+ // From useId, which has no spaces. aria-describedby splits its ids on spaces.
12
+ const errorId = `${generated}-error`;
13
+ return {
14
+ inputId,
15
+ errorId,
16
+ inputProps: {
17
+ id: inputId,
18
+ required: props.required,
19
+ "aria-required": props.required ? true : props["aria-required"],
20
+ "aria-invalid": error ? true : props["aria-invalid"],
21
+ "aria-describedby": cx(props["aria-describedby"], error && errorId) || undefined,
22
+ },
23
+ };
24
+ }
25
+
26
+ /** The input's look, with the error border when there is an error. */
27
+ export function inputClass(error: string | undefined, extra?: string): string {
28
+ return cx(
29
+ "w-full rounded-input border px-3 py-input-y 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",
30
+ extra,
31
+ error ? "border-input-border-error" : "border-input-border focus:border-input-focus-border",
32
+ );
33
+ }
34
+
35
+ export function FieldLabel({ htmlFor, required, children }: { htmlFor: string; required: boolean | undefined; children: ReactNode }) {
36
+ return (
37
+ <label htmlFor={htmlFor} className="block text-field-label font-medium text-field-label-text mb-field-label">
38
+ {children}
39
+ {required && <span className="text-field-required" aria-hidden="true"> *</span>}
40
+ </label>
41
+ );
42
+ }
43
+
44
+ export function FieldError({ id, error }: { id: string; error: string | undefined }) {
45
+ if (!error) return null;
46
+ return (
47
+ <p id={id} className="mt-1 text-xs text-field-error">
48
+ {error}
49
+ </p>
50
+ );
51
+ }
package/src/ui/index.ts CHANGED
@@ -1,4 +1,11 @@
1
1
  export { Alert } from "./Alert";
2
2
  export type { AlertProps } from "./Alert";
3
+ export { CredentialsForm } from "./CredentialsForm";
4
+ export type { CredentialsFormProps } from "./CredentialsForm";
5
+ export { SignInRejected, defaultCredentialsFormStrings } from "./credentialsFormStrings";
6
+ export type { CredentialsFormStrings } from "./credentialsFormStrings";
3
7
  export { PasswordInput } from "./PasswordInput";
4
8
  export type { PasswordInputProps } from "./PasswordInput";
9
+ export { TextInput } from "./TextInput";
10
+ export type { TextInputProps } from "./TextInput";
11
+ export type { TextOverrides } from "./withDefaults";
@@ -0,0 +1,14 @@
1
+ /** Text overrides where any key may be missing or undefined. */
2
+ export type TextOverrides<T> = { [K in keyof T]?: T[K] | undefined };
3
+
4
+ /**
5
+ * Fills in defaults for any string that is missing or undefined, so a key missing from an app's
6
+ * dictionary still shows the default text.
7
+ */
8
+ export function withDefaults<T extends object>(defaults: T, overrides: TextOverrides<T> | undefined): T {
9
+ const result = { ...defaults };
10
+ for (const [key, value] of Object.entries(overrides ?? {})) {
11
+ if (value !== undefined) (result as Record<string, unknown>)[key] = value;
12
+ }
13
+ return result;
14
+ }