@pylonsync/client 0.3.267

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,417 @@
1
+ "use client";
2
+ import React from "react";
3
+
4
+ import {
5
+ type FormEvent,
6
+ type ReactNode,
7
+ useCallback,
8
+ useEffect,
9
+ useRef,
10
+ useState,
11
+ } from "react";
12
+ import { useAuth } from "../hooks/useAuth";
13
+ import {
14
+ ApiError,
15
+ createOrg,
16
+ listOrgs,
17
+ type OrgSummary,
18
+ } from "../lib/api";
19
+ import { cn } from "../lib/cn";
20
+
21
+ export interface OrganizationSwitcherProps {
22
+ /** Hide the "Personal account" entry (no active org). Default: visible. */
23
+ hidePersonal?: boolean;
24
+ /** Disable the "Create organization" menu entry. */
25
+ hideCreate?: boolean;
26
+ /** Called after a switch lands. Useful for analytics / route changes. */
27
+ onSwitched?: (orgId: string | null) => void;
28
+ /** Called after a new org is created (post-creation, pre-switch). */
29
+ onCreated?: (org: OrgSummary) => void;
30
+ className?: string;
31
+ }
32
+
33
+ /**
34
+ * Drop-in org switcher. Lists the signed-in user's orgs from
35
+ * `/api/auth/orgs`, lets them switch via `selectOrg` from useSession,
36
+ * and inline-creates a new org via `/api/auth/orgs` POST.
37
+ *
38
+ * Renders nothing for signed-out users — wrap in <SignedIn> if you
39
+ * need that. Apps that don't use the framework's built-in Org / OrgMember
40
+ * entities will see an empty list (and just the create form, if enabled).
41
+ */
42
+ export function OrganizationSwitcher({
43
+ hidePersonal,
44
+ hideCreate,
45
+ onSwitched,
46
+ onCreated,
47
+ className,
48
+ }: OrganizationSwitcherProps) {
49
+ const { isSignedIn, tenantId, selectOrg, clearOrg } = useAuth();
50
+ const [open, setOpen] = useState(false);
51
+ const [mode, setMode] = useState<"list" | "create">("list");
52
+ const [orgs, setOrgs] = useState<OrgSummary[] | null>(null);
53
+ const [switching, setSwitching] = useState(false);
54
+ const rootRef = useRef<HTMLDivElement | null>(null);
55
+
56
+ const refresh = useCallback(async () => {
57
+ const next = await listOrgs();
58
+ setOrgs(next);
59
+ }, []);
60
+
61
+ useEffect(() => {
62
+ if (!isSignedIn) {
63
+ setOrgs(null);
64
+ return;
65
+ }
66
+ void refresh();
67
+ }, [isSignedIn, refresh]);
68
+
69
+ useEffect(() => {
70
+ if (!open) return;
71
+ function onDocClick(e: MouseEvent) {
72
+ if (!rootRef.current?.contains(e.target as Node)) {
73
+ setOpen(false);
74
+ setMode("list");
75
+ }
76
+ }
77
+ function onKey(e: KeyboardEvent) {
78
+ if (e.key === "Escape") {
79
+ setOpen(false);
80
+ setMode("list");
81
+ }
82
+ }
83
+ document.addEventListener("mousedown", onDocClick);
84
+ document.addEventListener("keydown", onKey);
85
+ return () => {
86
+ document.removeEventListener("mousedown", onDocClick);
87
+ document.removeEventListener("keydown", onKey);
88
+ };
89
+ }, [open]);
90
+
91
+ if (!isSignedIn) return null;
92
+
93
+ const active = orgs?.find((o) => o.id === tenantId) ?? null;
94
+ const label = active?.name ?? (hidePersonal ? "Select org" : "Personal");
95
+
96
+ async function switchTo(orgId: string | null) {
97
+ setSwitching(true);
98
+ try {
99
+ if (orgId === null) {
100
+ await clearOrg();
101
+ } else {
102
+ await selectOrg(orgId);
103
+ }
104
+ onSwitched?.(orgId);
105
+ setOpen(false);
106
+ } catch (err) {
107
+ // Surface "not a member" / other server errors in console for
108
+ // now — full toast UI is the consumer app's call.
109
+ console.error("[pylon] selectOrg failed:", err);
110
+ } finally {
111
+ setSwitching(false);
112
+ }
113
+ }
114
+
115
+ function handleCreated(org: OrgSummary) {
116
+ onCreated?.(org);
117
+ void refresh().then(() => switchTo(org.id));
118
+ setMode("list");
119
+ }
120
+
121
+ return (
122
+ <div ref={rootRef} className={cn("relative inline-flex", className)}>
123
+ <button
124
+ type="button"
125
+ onClick={() => setOpen((o) => !o)}
126
+ aria-haspopup="menu"
127
+ aria-expanded={open}
128
+ className="inline-flex items-center gap-2 rounded-md border border-[var(--pylon-rule,#e5e7eb)] bg-[var(--pylon-paper,#ffffff)] px-3 py-1.5 text-sm font-medium text-[var(--pylon-ink,#0a0a0a)] transition-colors hover:bg-[var(--pylon-paper-2,#f4f4f5)]"
129
+ >
130
+ <span className="flex h-5 w-5 items-center justify-center rounded-sm bg-[var(--pylon-ink,#0a0a0a)] text-[10px] font-semibold text-[var(--pylon-paper,#ffffff)]">
131
+ {glyph(label)}
132
+ </span>
133
+ <span className="max-w-[10rem] truncate">{label}</span>
134
+ <svg
135
+ aria-hidden
136
+ width="10"
137
+ height="10"
138
+ viewBox="0 0 10 10"
139
+ fill="none"
140
+ className="opacity-60"
141
+ >
142
+ <path
143
+ d="M2 4l3 3 3-3"
144
+ stroke="currentColor"
145
+ strokeWidth="1.5"
146
+ strokeLinecap="round"
147
+ strokeLinejoin="round"
148
+ />
149
+ </svg>
150
+ </button>
151
+ {open ? (
152
+ <div
153
+ role="menu"
154
+ className="absolute left-0 top-full z-50 mt-2 w-64 overflow-hidden rounded-lg border border-[var(--pylon-rule,#e5e7eb)] bg-[var(--pylon-paper,#ffffff)] shadow-lg"
155
+ >
156
+ {mode === "list" ? (
157
+ <>
158
+ <div className="max-h-64 overflow-y-auto py-1">
159
+ {!hidePersonal ? (
160
+ <OrgRow
161
+ name="Personal"
162
+ subtitle="No active org"
163
+ active={tenantId === null}
164
+ onClick={() => switchTo(null)}
165
+ disabled={switching}
166
+ />
167
+ ) : null}
168
+ {(orgs ?? []).map((o) => (
169
+ <OrgRow
170
+ key={o.id}
171
+ name={o.name}
172
+ subtitle={roleLabel(o.role)}
173
+ active={o.id === tenantId}
174
+ onClick={() => switchTo(o.id)}
175
+ disabled={switching}
176
+ />
177
+ ))}
178
+ {orgs && orgs.length === 0 && hidePersonal ? (
179
+ <p className="px-3 py-2 text-xs text-[var(--pylon-ink-3,#71717a)]">
180
+ You're not a member of any orgs yet.
181
+ </p>
182
+ ) : null}
183
+ </div>
184
+ {!hideCreate ? (
185
+ <button
186
+ type="button"
187
+ onClick={() => setMode("create")}
188
+ className="flex w-full items-center gap-2 border-t border-[var(--pylon-rule,#e5e7eb)] px-3 py-2.5 text-left text-sm font-medium text-[var(--pylon-ink,#0a0a0a)] transition-colors hover:bg-[var(--pylon-paper-2,#f4f4f5)]"
189
+ >
190
+ <PlusGlyph />
191
+ Create organization
192
+ </button>
193
+ ) : null}
194
+ </>
195
+ ) : (
196
+ <CreateOrgInline
197
+ onCreated={handleCreated}
198
+ onCancel={() => setMode("list")}
199
+ />
200
+ )}
201
+ </div>
202
+ ) : null}
203
+ </div>
204
+ );
205
+ }
206
+
207
+ function OrgRow({
208
+ name,
209
+ subtitle,
210
+ active,
211
+ onClick,
212
+ disabled,
213
+ }: {
214
+ name: string;
215
+ subtitle?: string;
216
+ active: boolean;
217
+ onClick: () => void;
218
+ disabled?: boolean;
219
+ }) {
220
+ return (
221
+ <button
222
+ type="button"
223
+ role="menuitem"
224
+ onClick={onClick}
225
+ disabled={disabled}
226
+ className={cn(
227
+ "flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-[var(--pylon-paper-2,#f4f4f5)] disabled:opacity-60",
228
+ active && "bg-[var(--pylon-paper-2,#f4f4f5)]",
229
+ )}
230
+ >
231
+ <span className="flex min-w-0 items-center gap-2">
232
+ <span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-sm bg-[var(--pylon-ink,#0a0a0a)] text-[10px] font-semibold text-[var(--pylon-paper,#ffffff)]">
233
+ {glyph(name)}
234
+ </span>
235
+ <span className="min-w-0">
236
+ <span className="block truncate text-sm font-medium text-[var(--pylon-ink,#0a0a0a)]">
237
+ {name}
238
+ </span>
239
+ {subtitle ? (
240
+ <span className="block truncate text-[11px] text-[var(--pylon-ink-3,#71717a)]">
241
+ {subtitle}
242
+ </span>
243
+ ) : null}
244
+ </span>
245
+ </span>
246
+ {active ? (
247
+ <svg
248
+ aria-hidden
249
+ width="14"
250
+ height="14"
251
+ viewBox="0 0 14 14"
252
+ fill="none"
253
+ >
254
+ <path
255
+ d="M3 7.5L6 10.5L11 4.5"
256
+ stroke="currentColor"
257
+ strokeWidth="1.6"
258
+ strokeLinecap="round"
259
+ strokeLinejoin="round"
260
+ />
261
+ </svg>
262
+ ) : null}
263
+ </button>
264
+ );
265
+ }
266
+
267
+ function CreateOrgInline({
268
+ onCreated,
269
+ onCancel,
270
+ }: {
271
+ onCreated: (org: OrgSummary) => void;
272
+ onCancel: () => void;
273
+ }) {
274
+ return (
275
+ <div className="p-3">
276
+ <CreateOrganizationForm onCreated={onCreated} />
277
+ <button
278
+ type="button"
279
+ onClick={onCancel}
280
+ className="mt-2 block w-full text-center text-xs text-[var(--pylon-ink-3,#71717a)] hover:underline"
281
+ >
282
+ Back
283
+ </button>
284
+ </div>
285
+ );
286
+ }
287
+
288
+ // ---------------------------------------------------------------------------
289
+ // CreateOrganization — standalone, usable inside a modal or page.
290
+ // ---------------------------------------------------------------------------
291
+
292
+ export interface CreateOrganizationProps {
293
+ onCreated?: (org: OrgSummary) => void;
294
+ /** Title shown above the form. */
295
+ title?: ReactNode;
296
+ className?: string;
297
+ }
298
+
299
+ export function CreateOrganization({
300
+ onCreated,
301
+ title = "Create organization",
302
+ className,
303
+ }: CreateOrganizationProps) {
304
+ return (
305
+ <div
306
+ className={cn(
307
+ "mx-auto w-full max-w-sm space-y-4 rounded-xl border border-[var(--pylon-rule,#e5e7eb)] bg-[var(--pylon-paper,#ffffff)] p-6 shadow-sm",
308
+ className,
309
+ )}
310
+ >
311
+ <h2 className="text-base font-semibold tracking-tight text-[var(--pylon-ink,#0a0a0a)]">
312
+ {title}
313
+ </h2>
314
+ <CreateOrganizationForm onCreated={onCreated} />
315
+ </div>
316
+ );
317
+ }
318
+
319
+ function CreateOrganizationForm({
320
+ onCreated,
321
+ }: {
322
+ onCreated?: (org: OrgSummary) => void;
323
+ }) {
324
+ const [name, setName] = useState("");
325
+ const [error, setError] = useState<string | null>(null);
326
+ const [pending, setPending] = useState(false);
327
+
328
+ async function onSubmit(e: FormEvent) {
329
+ e.preventDefault();
330
+ const trimmed = name.trim();
331
+ if (!trimmed) {
332
+ setError("Pick a name for your org.");
333
+ return;
334
+ }
335
+ setError(null);
336
+ setPending(true);
337
+ try {
338
+ const org = await createOrg(trimmed);
339
+ setName("");
340
+ onCreated?.(org);
341
+ } catch (err) {
342
+ setError(messageFromError(err));
343
+ } finally {
344
+ setPending(false);
345
+ }
346
+ }
347
+
348
+ return (
349
+ <form onSubmit={onSubmit} className="space-y-2.5">
350
+ <label className="block space-y-1.5">
351
+ <span className="text-xs font-medium text-[var(--pylon-ink-2,#52525b)]">
352
+ Organization name
353
+ </span>
354
+ <input
355
+ value={name}
356
+ onChange={(e) => setName(e.target.value)}
357
+ placeholder="Acme Inc."
358
+ autoFocus
359
+ className="w-full rounded-md border border-[var(--pylon-rule,#e5e7eb)] bg-[var(--pylon-paper,#ffffff)] px-3 py-2 text-sm text-[var(--pylon-ink,#0a0a0a)] placeholder:text-[var(--pylon-ink-3,#a1a1aa)] focus:border-[var(--pylon-ink,#0a0a0a)] focus:outline-none"
360
+ />
361
+ </label>
362
+ <button
363
+ type="submit"
364
+ disabled={pending}
365
+ className="w-full rounded-md bg-[var(--pylon-ink,#0a0a0a)] px-3 py-2 text-sm font-medium text-[var(--pylon-paper,#ffffff)] transition-opacity hover:opacity-90 disabled:opacity-60"
366
+ >
367
+ {pending ? "…" : "Create"}
368
+ </button>
369
+ {error ? (
370
+ <p className="rounded-md border border-[var(--pylon-error-rule,#fecaca)] bg-[var(--pylon-error-bg,#fef2f2)] px-3 py-2 text-xs text-[var(--pylon-error-ink,#b91c1c)]">
371
+ {error}
372
+ </p>
373
+ ) : null}
374
+ </form>
375
+ );
376
+ }
377
+
378
+ function PlusGlyph() {
379
+ return (
380
+ <svg aria-hidden width="14" height="14" viewBox="0 0 14 14" fill="none">
381
+ <path
382
+ d="M7 3v8M3 7h8"
383
+ stroke="currentColor"
384
+ strokeWidth="1.6"
385
+ strokeLinecap="round"
386
+ />
387
+ </svg>
388
+ );
389
+ }
390
+
391
+ function glyph(name: string): string {
392
+ const parts = name.split(/\s+/).filter(Boolean);
393
+ if (parts.length >= 2) return (parts[0]![0]! + parts[1]![0]!).toUpperCase();
394
+ return (parts[0] ?? name).slice(0, 1).toUpperCase();
395
+ }
396
+
397
+ function roleLabel(role: string): string {
398
+ if (!role) return "";
399
+ return role.charAt(0).toUpperCase() + role.slice(1);
400
+ }
401
+
402
+ function messageFromError(err: unknown): string {
403
+ if (err instanceof ApiError) {
404
+ switch (err.code) {
405
+ case "MISSING_NAME":
406
+ return "Pick a name for your org.";
407
+ case "ORG_CREATE_FAILED":
408
+ return "Couldn't create org. Make sure Org / OrgMember entities are declared in your manifest.";
409
+ case "API_KEY_AUTH_FORBIDDEN":
410
+ return "Org management requires a real session — not an API key.";
411
+ default:
412
+ return err.message;
413
+ }
414
+ }
415
+ if (err instanceof Error) return err.message;
416
+ return "Something went wrong.";
417
+ }
@@ -0,0 +1,302 @@
1
+ "use client";
2
+ import React from "react";
3
+
4
+ import { type FormEvent, type ReactNode, useEffect, useState } from "react";
5
+ import {
6
+ ApiError,
7
+ completePasswordReset,
8
+ requestPasswordReset,
9
+ } from "../lib/api";
10
+ import { cn } from "../lib/cn";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // <ForgotPassword />
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export interface ForgotPasswordProps {
17
+ /** Return route shown as "back to sign-in" link. */
18
+ signInUrl?: string;
19
+ /** Override headline. */
20
+ title?: ReactNode;
21
+ className?: string;
22
+ }
23
+
24
+ /**
25
+ * "Forgot password" entry point. POSTs `/api/auth/password/reset/request`
26
+ * and always shows a generic success screen — the server returns 200
27
+ * regardless of whether the email is registered (account-enumeration
28
+ * defense), so we mirror that here.
29
+ */
30
+ export function ForgotPassword({
31
+ signInUrl = "/sign-in",
32
+ title = "Reset your password",
33
+ className,
34
+ }: ForgotPasswordProps) {
35
+ const [email, setEmail] = useState("");
36
+ const [sent, setSent] = useState(false);
37
+ const [pending, setPending] = useState(false);
38
+ const [error, setError] = useState<string | null>(null);
39
+
40
+ async function onSubmit(e: FormEvent) {
41
+ e.preventDefault();
42
+ setError(null);
43
+ setPending(true);
44
+ try {
45
+ await requestPasswordReset(email);
46
+ setSent(true);
47
+ } catch (err) {
48
+ setError(messageFromError(err));
49
+ } finally {
50
+ setPending(false);
51
+ }
52
+ }
53
+
54
+ return (
55
+ <Card className={className}>
56
+ <Heading title={title} />
57
+ {sent ? (
58
+ <>
59
+ <p className="text-sm text-[var(--pylon-ink-2,#52525b)]">
60
+ If an account exists for{" "}
61
+ <span className="font-medium text-[var(--pylon-ink,#0a0a0a)]">
62
+ {email}
63
+ </span>
64
+ , we sent a reset link.
65
+ </p>
66
+ <a
67
+ href={signInUrl}
68
+ className="block text-center text-xs text-[var(--pylon-ink-2,#52525b)] hover:underline"
69
+ >
70
+ Back to sign in
71
+ </a>
72
+ </>
73
+ ) : (
74
+ <form onSubmit={onSubmit} className="space-y-3">
75
+ <Field
76
+ label="Email"
77
+ type="email"
78
+ value={email}
79
+ onChange={setEmail}
80
+ required
81
+ autoComplete="email"
82
+ placeholder="you@example.com"
83
+ />
84
+ <button
85
+ type="submit"
86
+ disabled={pending}
87
+ className="w-full rounded-md bg-[var(--pylon-ink,#0a0a0a)] px-3 py-2 text-sm font-medium text-[var(--pylon-paper,#ffffff)] transition-opacity hover:opacity-90 disabled:opacity-60"
88
+ >
89
+ {pending ? "…" : "Send reset link"}
90
+ </button>
91
+ {error ? <ErrorBanner message={error} /> : null}
92
+ <a
93
+ href={signInUrl}
94
+ className="block text-center text-xs text-[var(--pylon-ink-2,#52525b)] hover:underline"
95
+ >
96
+ Back to sign in
97
+ </a>
98
+ </form>
99
+ )}
100
+ </Card>
101
+ );
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // <ResetPassword />
106
+ // ---------------------------------------------------------------------------
107
+
108
+ export interface ResetPasswordProps {
109
+ /** Token from the reset email. Falls back to `?token=` in URL. */
110
+ token?: string;
111
+ /** Where to send the user after a successful reset. Default: `/sign-in`. */
112
+ afterResetUrl?: string;
113
+ title?: ReactNode;
114
+ className?: string;
115
+ }
116
+
117
+ export function ResetPassword({
118
+ token: tokenProp,
119
+ afterResetUrl = "/sign-in",
120
+ title = "Choose a new password",
121
+ className,
122
+ }: ResetPasswordProps) {
123
+ const [token, setToken] = useState<string | null>(null);
124
+ const [next, setNext] = useState("");
125
+ const [confirm, setConfirm] = useState("");
126
+ const [pending, setPending] = useState(false);
127
+ const [error, setError] = useState<string | null>(null);
128
+ const [done, setDone] = useState(false);
129
+
130
+ useEffect(() => {
131
+ if (tokenProp) {
132
+ setToken(tokenProp);
133
+ return;
134
+ }
135
+ if (typeof window !== "undefined") {
136
+ const t = new URLSearchParams(window.location.search).get("token");
137
+ setToken(t);
138
+ }
139
+ }, [tokenProp]);
140
+
141
+ async function onSubmit(e: FormEvent) {
142
+ e.preventDefault();
143
+ setError(null);
144
+ if (!token) {
145
+ setError("Missing reset token. Use the link from your email.");
146
+ return;
147
+ }
148
+ if (next !== confirm) {
149
+ setError("Passwords don't match.");
150
+ return;
151
+ }
152
+ setPending(true);
153
+ try {
154
+ await completePasswordReset({ token, newPassword: next });
155
+ setDone(true);
156
+ if (typeof window !== "undefined") {
157
+ window.location.assign(afterResetUrl);
158
+ }
159
+ } catch (err) {
160
+ setError(messageFromError(err));
161
+ } finally {
162
+ setPending(false);
163
+ }
164
+ }
165
+
166
+ return (
167
+ <Card className={className}>
168
+ <Heading title={title} />
169
+ {done ? (
170
+ <p className="text-sm text-[var(--pylon-ink-2,#52525b)]">
171
+ Password updated. Redirecting…
172
+ </p>
173
+ ) : (
174
+ <form onSubmit={onSubmit} className="space-y-3">
175
+ <Field
176
+ label="New password"
177
+ type="password"
178
+ value={next}
179
+ onChange={setNext}
180
+ required
181
+ autoComplete="new-password"
182
+ />
183
+ <Field
184
+ label="Confirm"
185
+ type="password"
186
+ value={confirm}
187
+ onChange={setConfirm}
188
+ required
189
+ autoComplete="new-password"
190
+ />
191
+ <button
192
+ type="submit"
193
+ disabled={pending || !token}
194
+ className="w-full rounded-md bg-[var(--pylon-ink,#0a0a0a)] px-3 py-2 text-sm font-medium text-[var(--pylon-paper,#ffffff)] transition-opacity hover:opacity-90 disabled:opacity-60"
195
+ >
196
+ {pending ? "…" : "Update password"}
197
+ </button>
198
+ {error ? <ErrorBanner message={error} /> : null}
199
+ </form>
200
+ )}
201
+ </Card>
202
+ );
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Shared chrome (duplicated thinly from SignIn to keep this file self-contained)
207
+ // ---------------------------------------------------------------------------
208
+
209
+ function Card({
210
+ className,
211
+ children,
212
+ }: {
213
+ className?: string;
214
+ children: ReactNode;
215
+ }) {
216
+ return (
217
+ <div
218
+ className={cn(
219
+ "mx-auto w-full max-w-sm space-y-5 rounded-xl border border-[var(--pylon-rule,#e5e7eb)] bg-[var(--pylon-paper,#ffffff)] p-7 shadow-sm",
220
+ className,
221
+ )}
222
+ >
223
+ {children}
224
+ </div>
225
+ );
226
+ }
227
+
228
+ function Heading({ title }: { title: ReactNode }) {
229
+ return (
230
+ <h2 className="text-center text-lg font-semibold tracking-tight text-[var(--pylon-ink,#0a0a0a)]">
231
+ {title}
232
+ </h2>
233
+ );
234
+ }
235
+
236
+ function Field({
237
+ label,
238
+ value,
239
+ onChange,
240
+ type = "text",
241
+ required,
242
+ autoComplete,
243
+ placeholder,
244
+ }: {
245
+ label: string;
246
+ value: string;
247
+ onChange: (v: string) => void;
248
+ type?: string;
249
+ required?: boolean;
250
+ autoComplete?: string;
251
+ placeholder?: string;
252
+ }) {
253
+ return (
254
+ <label className="block space-y-1.5">
255
+ <span className="text-xs font-medium text-[var(--pylon-ink-2,#52525b)]">
256
+ {label}
257
+ </span>
258
+ <input
259
+ type={type}
260
+ value={value}
261
+ onChange={(e) => onChange(e.target.value)}
262
+ required={required}
263
+ autoComplete={autoComplete}
264
+ placeholder={placeholder}
265
+ className="w-full rounded-md border border-[var(--pylon-rule,#e5e7eb)] bg-[var(--pylon-paper,#ffffff)] px-3 py-2 text-sm text-[var(--pylon-ink,#0a0a0a)] placeholder:text-[var(--pylon-ink-3,#a1a1aa)] focus:border-[var(--pylon-ink,#0a0a0a)] focus:outline-none"
266
+ />
267
+ </label>
268
+ );
269
+ }
270
+
271
+ function ErrorBanner({ message }: { message: string }) {
272
+ return (
273
+ <p className="rounded-md border border-[var(--pylon-error-rule,#fecaca)] bg-[var(--pylon-error-bg,#fef2f2)] px-3 py-2 text-xs text-[var(--pylon-error-ink,#b91c1c)]">
274
+ {message}
275
+ </p>
276
+ );
277
+ }
278
+
279
+ function messageFromError(err: unknown): string {
280
+ if (err instanceof ApiError) {
281
+ switch (err.code) {
282
+ case "MISSING_EMAIL":
283
+ return "Enter your email.";
284
+ case "RATE_LIMITED":
285
+ return "Too many reset requests. Try again in a minute.";
286
+ case "INVALID_TOKEN":
287
+ return "This reset link is invalid or expired. Request a new one.";
288
+ case "WEAK_PASSWORD":
289
+ return "Pick a stronger password.";
290
+ case "PWNED_PASSWORD":
291
+ return "This password has been seen in known breaches. Pick another.";
292
+ case "USER_NOT_FOUND":
293
+ return "Account no longer exists.";
294
+ case "MISSING_FIELD":
295
+ return "Token and new password are both required.";
296
+ default:
297
+ return err.message;
298
+ }
299
+ }
300
+ if (err instanceof Error) return err.message;
301
+ return "Something went wrong.";
302
+ }