@sjolystinnovation/app-kit 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ };
@@ -8,9 +8,8 @@ import { getSessionPromptOpen, isTerminalSessionError, subscribeSessionPrompt }
8
8
  /**
9
9
  * Whether a protected page may render, and whether it must stay rendered.
10
10
  *
11
- * Every gate on the way down should use this, so they agree. A nested layout that decides on its
12
- * own to blank the page undoes the in-place recovery the re-sign-in prompt exists for, and the user
13
- * loses the form anyway.
11
+ * Every gate on the way down should use this, so they all agree. A nested layout that decides on
12
+ * its own can blank the page. That undoes the in-place recovery the re-sign-in prompt exists for.
14
13
  */
15
14
  export function useSessionGate() {
16
15
  const { data: session, status } = useSession();
@@ -26,11 +25,10 @@ export function useSessionGate() {
26
25
  const error = (session as { error?: string } | null)?.error;
27
26
  const usable = status === "authenticated" && !isTerminalSessionError(error);
28
27
 
29
- // Which page last rendered on a healthy session. Keyed by path, so each page starts the check
30
- // again and a session that died on the previous page cannot carry a stale pass into a fresh form.
31
- // Adjusted during render rather than in an effect, which is the supported way to derive state
32
- // from changing inputs.
33
- // Undefined until then rather than null, because usePathname can itself return null.
28
+ // The path that last rendered on a healthy session. Keyed by path, so each page checks again. A
29
+ // session that died on the previous page cannot pass a fresh form.
30
+ // Set during render, which is React's supported way to derive state from changing inputs.
31
+ // It starts as undefined, not null, because usePathname can return null.
34
32
  const [usableAt, setUsableAt] = useState<string | null | undefined>(undefined);
35
33
  if (usable && usableAt !== pathname) setUsableAt(pathname);
36
34
  const wasUsable = usableAt === pathname;
package/src/styles.css CHANGED
@@ -2,9 +2,9 @@
2
2
  @source ".";
3
3
 
4
4
  /*
5
- * Design tokens for the components. Each default matches a stock Tailwind value, so an app that
6
- * sets none of them gets the look below. Set any of them in the app's own @theme to restyle, or in
7
- * a selector such as .dark for a scoped theme.
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,14 +4,14 @@ 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
- * Classes for placing the alert, such as a margin. Restyle it through the theme variables
13
- * instead: whether a class that clashes with the alert's own wins depends on the order Tailwind
14
- * emits them in.
13
+ * Classes for placing the alert, such as a margin. Restyle it through the theme variables. A
14
+ * class that clashes with the alert's own wins or loses by the order Tailwind emits them in.
15
15
  */
16
16
  className?: string;
17
17
  children: ReactNode;
@@ -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,20 +1,20 @@
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
- * label, the input and the error. Restyle the field through the theme variables instead.
17
+ * label, the input and the error. Restyle the field through the theme variables.
18
18
  */
19
19
  className?: string;
20
20
  }
@@ -22,40 +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
- // Fall back to a generated id so the label is always tied to its own control. Not the name: two
34
- // fields named "password" on one page, such as sign in next to sign up, would share an id.
35
- const generated = useId();
36
- const inputId = id ?? generated;
37
- // Built from useId rather than the name, since an id list in aria-describedby splits on spaces.
38
- const errorId = `${generated}-error`;
31
+ const field = useField(props, error);
39
32
 
40
33
  return (
41
34
  <div className={className}>
42
- <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}>
43
36
  {label}
44
- {required && <span className="text-field-required"> *</span>}
45
- </label>
37
+ </FieldLabel>
46
38
  <div className="relative">
47
39
  <input
48
40
  {...props}
49
- id={inputId}
41
+ {...field.inputProps}
50
42
  type={visible ? "text" : "password"}
51
- required={required}
52
- aria-required={required ? true : props["aria-required"]}
53
- aria-invalid={error ? true : props["aria-invalid"]}
54
- aria-describedby={cx(props["aria-describedby"], error && errorId) || undefined}
55
- className={cx(
56
- "w-full rounded-input border px-3 py-input-y pr-10 text-sm bg-input-bg text-input-text placeholder-input-placeholder focus:outline-hidden focus:ring-(length:--input-focus-ring-width) focus:ring-input-focus-ring",
57
- error ? "border-input-border-error" : "border-input-border focus:border-input-focus-border",
58
- )}
43
+ className={inputClass(error, "pr-10")}
59
44
  />
60
45
  <button
61
46
  type="button"
@@ -66,11 +51,7 @@ export function PasswordInput({
66
51
  {visible ? <EyeSlashIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
67
52
  </button>
68
53
  </div>
69
- {error && (
70
- <p id={errorId} className="mt-1 text-xs text-field-error">
71
- {error}
72
- </p>
73
- )}
54
+ <FieldError id={field.errorId} error={error} />
74
55
  </div>
75
56
  );
76
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
+ };