@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.
- package/README.md +125 -0
- package/package.json +32 -0
- package/src/components/AcceptInvite.tsx +160 -0
- package/src/components/ChatBot.tsx +228 -0
- package/src/components/ConnectAccount.tsx +119 -0
- package/src/components/EnsureGuest.tsx +49 -0
- package/src/components/EntityForm.tsx +308 -0
- package/src/components/EntityList.tsx +203 -0
- package/src/components/FileUpload.tsx +213 -0
- package/src/components/Gates.tsx +139 -0
- package/src/components/InviteMembers.tsx +562 -0
- package/src/components/OrganizationSwitcher.tsx +417 -0
- package/src/components/PasswordReset.tsx +302 -0
- package/src/components/SignIn.tsx +515 -0
- package/src/components/SignOutButton.tsx +42 -0
- package/src/components/UserButton.tsx +163 -0
- package/src/components/UserProfile.tsx +485 -0
- package/src/hooks/useAuth.ts +27 -0
- package/src/index.ts +130 -0
- package/src/lib/api.ts +368 -0
- package/src/lib/cn.ts +7 -0
- package/src/router/Router.tsx +282 -0
- package/src/router/context.ts +25 -0
- package/src/router/match.ts +106 -0
- package/src/router/useRouter.ts +40 -0
- package/src/theme.css +30 -0
- package/tsconfig.json +6 -0
|
@@ -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
|
+
}
|