@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 +124 -43
- package/package.json +1 -1
- package/src/session/react/ProtectedGate.tsx +34 -0
- package/src/session/react/SessionExpiryGuard.tsx +294 -0
- package/src/session/react/index.ts +6 -0
- package/src/session/react/sessionExpiryGuardStrings.ts +26 -0
- package/src/styles.css +16 -3
- package/src/ui/Alert.tsx +4 -3
- package/src/ui/CredentialsForm.tsx +113 -0
- package/src/ui/PasswordInput.tsx +11 -29
- package/src/ui/TextInput.tsx +28 -0
- package/src/ui/credentialsFormStrings.ts +29 -0
- package/src/ui/field.tsx +51 -0
- package/src/ui/index.ts +7 -0
- package/src/ui/withDefaults.ts +14 -0
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.
|
|
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`. `
|
|
269
|
-
|
|
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,
|
|
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.
|
|
339
|
-
|
|
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"` |
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
`--color-
|
|
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
|
|
512
|
-
the theme's `primary`
|
|
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
|
@@ -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
|
|
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.
|
|
8
|
-
*
|
|
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
|
+
}
|
package/src/ui/PasswordInput.tsx
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/outline";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
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
|
-
|
|
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
|
-
<
|
|
35
|
+
<FieldLabel htmlFor={field.inputId} required={props.required}>
|
|
42
36
|
{label}
|
|
43
|
-
|
|
44
|
-
</label>
|
|
37
|
+
</FieldLabel>
|
|
45
38
|
<div className="relative">
|
|
46
39
|
<input
|
|
47
40
|
{...props}
|
|
48
|
-
|
|
41
|
+
{...field.inputProps}
|
|
49
42
|
type={visible ? "text" : "password"}
|
|
50
|
-
|
|
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
|
+
};
|
package/src/ui/field.tsx
ADDED
|
@@ -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
|
+
}
|