@exegia/corpora-ui 0.6.0 → 0.7.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/dist-lib/components/blocks/auth/__tests__/linked-accounts-block.test.d.ts +1 -0
- package/dist-lib/components/blocks/auth/__tests__/onboarding-block.test.d.ts +1 -0
- package/dist-lib/components/blocks/auth/__tests__/passkey-manager-block.test.d.ts +1 -0
- package/dist-lib/components/blocks/auth/__tests__/passkey-sign-in-block.test.d.ts +1 -0
- package/dist-lib/components/blocks/auth/__tests__/update-password-block.test.d.ts +1 -0
- package/dist-lib/components/blocks/auth/linked-accounts-block.d.ts +30 -0
- package/dist-lib/components/blocks/auth/onboarding/profile-step.d.ts +19 -0
- package/dist-lib/components/blocks/auth/onboarding-block.d.ts +69 -0
- package/dist-lib/components/blocks/auth/passkey-manager-block.d.ts +38 -0
- package/dist-lib/components/blocks/auth/passkey-sign-in-block.d.ts +27 -0
- package/dist-lib/components/blocks/auth/update-password-block.d.ts +28 -0
- package/dist-lib/components/composed/social-providers.d.ts +7 -0
- package/dist-lib/index.d.ts +5 -0
- package/dist-lib/index.js +1032 -312
- package/dist-lib/index.js.map +1 -1
- package/package.json +5 -1
- package/src/components/blocks/auth/__tests__/linked-accounts-block.test.tsx +110 -0
- package/src/components/blocks/auth/__tests__/onboarding-block.test.tsx +156 -0
- package/src/components/blocks/auth/__tests__/passkey-manager-block.test.tsx +131 -0
- package/src/components/blocks/auth/__tests__/passkey-sign-in-block.test.tsx +59 -0
- package/src/components/blocks/auth/__tests__/update-password-block.test.tsx +95 -0
- package/src/components/blocks/auth/linked-accounts-block.tsx +223 -0
- package/src/components/blocks/auth/onboarding/profile-step.tsx +227 -0
- package/src/components/blocks/auth/onboarding-block.tsx +240 -0
- package/src/components/blocks/auth/passkey-manager-block.tsx +334 -0
- package/src/components/blocks/auth/passkey-sign-in-block.tsx +81 -0
- package/src/components/blocks/auth/update-password-block.tsx +157 -0
- package/src/components/composed/social-providers.tsx +3 -2
- package/src/index.ts +5 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { AnimatePresence, MotionConfig, motion } from "motion/react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import {
|
|
8
|
+
Card,
|
|
9
|
+
CardDescription,
|
|
10
|
+
CardHeader,
|
|
11
|
+
CardPanel,
|
|
12
|
+
CardTitle,
|
|
13
|
+
} from "@/components/ui/card";
|
|
14
|
+
import { Field, FieldError, FieldLabel } from "@/components/ui/field";
|
|
15
|
+
import { Input } from "@/components/ui/input";
|
|
16
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
17
|
+
import { cn } from "@/lib/utils";
|
|
18
|
+
import { AuthError, EASE, Reveal } from "./auth-shell";
|
|
19
|
+
|
|
20
|
+
/** One registered passkey, as rendered by {@link PasskeyManagerBlock}. */
|
|
21
|
+
export interface PasskeyRecord {
|
|
22
|
+
id: string;
|
|
23
|
+
/** Server-derived name; falls back to "Passkey" when absent. */
|
|
24
|
+
name?: string | null;
|
|
25
|
+
/** ISO timestamps. Unparseable values are simply not shown. */
|
|
26
|
+
createdAt?: string | null;
|
|
27
|
+
lastUsedAt?: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PasskeyManagerBlockProps {
|
|
31
|
+
title?: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
/** The account's passkeys, newest first. */
|
|
34
|
+
passkeys?: PasskeyRecord[];
|
|
35
|
+
/** Whether this device can register passkeys at all. */
|
|
36
|
+
available?: boolean;
|
|
37
|
+
/** Shows the loading row instead of the list. */
|
|
38
|
+
loading?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Reject (or throw) to show the error. Resolving with `{ cancelled: true }`
|
|
41
|
+
* returns silently to idle — a dismissed OS prompt is not a failure.
|
|
42
|
+
*/
|
|
43
|
+
onRegister?: () => Promise<{ cancelled?: boolean } | void> | void;
|
|
44
|
+
onRename?: (id: string, name: string) => Promise<void> | void;
|
|
45
|
+
onDelete?: (id: string) => Promise<void> | void;
|
|
46
|
+
className?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const NAME_MAX = 120;
|
|
50
|
+
|
|
51
|
+
const LAST_PASSKEY_WARNING =
|
|
52
|
+
"This is your last passkey. After deleting it you won't be able to sign in with a passkey until you register a new one.";
|
|
53
|
+
|
|
54
|
+
function formatDate(iso: string | null | undefined): string | null {
|
|
55
|
+
if (!iso) return null;
|
|
56
|
+
const date = new Date(iso);
|
|
57
|
+
return Number.isNaN(date.getTime()) ? null : date.toLocaleDateString();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Settings panel for the passkeys on an account: register, inline rename with
|
|
62
|
+
* field-level validation, and delete behind an explicit confirmation. Deleting
|
|
63
|
+
* the last passkey is allowed — it is the account's passkey method that goes
|
|
64
|
+
* away, not its only way in — so the confirmation carries a warning instead of
|
|
65
|
+
* a block.
|
|
66
|
+
*/
|
|
67
|
+
export function PasskeyManagerBlock({
|
|
68
|
+
title = "Passkeys",
|
|
69
|
+
description = "Sign in without a password using your device's biometrics or security key.",
|
|
70
|
+
passkeys = [],
|
|
71
|
+
available = true,
|
|
72
|
+
loading = false,
|
|
73
|
+
onRegister,
|
|
74
|
+
onRename,
|
|
75
|
+
onDelete,
|
|
76
|
+
className,
|
|
77
|
+
}: PasskeyManagerBlockProps) {
|
|
78
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
79
|
+
const [busy, setBusy] = React.useState(false);
|
|
80
|
+
const [registering, setRegistering] = React.useState(false);
|
|
81
|
+
const [renamingId, setRenamingId] = React.useState<string | null>(null);
|
|
82
|
+
const [renameValue, setRenameValue] = React.useState("");
|
|
83
|
+
const [renameError, setRenameError] = React.useState<string | null>(null);
|
|
84
|
+
const [confirmingId, setConfirmingId] = React.useState<string | null>(null);
|
|
85
|
+
|
|
86
|
+
const lastPasskey = passkeys.length === 1;
|
|
87
|
+
|
|
88
|
+
async function run(action: () => Promise<unknown> | unknown) {
|
|
89
|
+
if (busy) return { ok: false } as const;
|
|
90
|
+
setError(null);
|
|
91
|
+
setBusy(true);
|
|
92
|
+
try {
|
|
93
|
+
const result = await action();
|
|
94
|
+
return { ok: true, result } as const;
|
|
95
|
+
} catch (cause) {
|
|
96
|
+
setError(
|
|
97
|
+
cause instanceof Error ? cause.message : "Something went wrong.",
|
|
98
|
+
);
|
|
99
|
+
return { ok: false } as const;
|
|
100
|
+
} finally {
|
|
101
|
+
setBusy(false);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function handleRegister() {
|
|
106
|
+
setRegistering(true);
|
|
107
|
+
await run(() => onRegister?.());
|
|
108
|
+
setRegistering(false);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function startRename(passkey: PasskeyRecord) {
|
|
112
|
+
setConfirmingId(null);
|
|
113
|
+
setRenamingId(passkey.id);
|
|
114
|
+
setRenameValue(passkey.name ?? "");
|
|
115
|
+
setRenameError(null);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function submitRename(id: string) {
|
|
119
|
+
const name = renameValue.trim();
|
|
120
|
+
if (name.length < 1 || name.length > NAME_MAX) {
|
|
121
|
+
setRenameError(`Name must be between 1 and ${NAME_MAX} characters.`);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const outcome = await run(() => onRename?.(id, name));
|
|
125
|
+
if (!outcome.ok) return;
|
|
126
|
+
setRenamingId(null);
|
|
127
|
+
setRenameError(null);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function confirmDelete(id: string) {
|
|
131
|
+
const outcome = await run(() => onDelete?.(id));
|
|
132
|
+
setConfirmingId(null);
|
|
133
|
+
if (!outcome.ok) return;
|
|
134
|
+
if (renamingId === id) setRenamingId(null);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<MotionConfig reducedMotion="user">
|
|
139
|
+
<Card className={cn("w-full", className)} data-slot="auth-block">
|
|
140
|
+
<CardHeader>
|
|
141
|
+
<CardTitle>{title}</CardTitle>
|
|
142
|
+
<CardDescription>{description}</CardDescription>
|
|
143
|
+
</CardHeader>
|
|
144
|
+
<CardPanel>
|
|
145
|
+
{!available ? (
|
|
146
|
+
<p className="text-sm text-muted-foreground">
|
|
147
|
+
Passkeys aren't available on this device. You can manage them
|
|
148
|
+
from a device that supports passkeys.
|
|
149
|
+
</p>
|
|
150
|
+
) : loading ? (
|
|
151
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
152
|
+
<Spinner className="size-4" />
|
|
153
|
+
Loading passkeys…
|
|
154
|
+
</div>
|
|
155
|
+
) : (
|
|
156
|
+
<motion.div
|
|
157
|
+
layout
|
|
158
|
+
transition={{ duration: 0.3, ease: EASE }}
|
|
159
|
+
className="flex flex-col gap-3"
|
|
160
|
+
>
|
|
161
|
+
<AuthError message={error} />
|
|
162
|
+
|
|
163
|
+
{passkeys.length > 0 ? (
|
|
164
|
+
<ul aria-label="Passkeys" className="flex flex-col gap-2">
|
|
165
|
+
<AnimatePresence initial={false}>
|
|
166
|
+
{passkeys.map((passkey, index) => {
|
|
167
|
+
const name = passkey.name ?? "Passkey";
|
|
168
|
+
const created = formatDate(passkey.createdAt);
|
|
169
|
+
const lastUsed = formatDate(passkey.lastUsedAt);
|
|
170
|
+
return (
|
|
171
|
+
<motion.li
|
|
172
|
+
key={passkey.id}
|
|
173
|
+
layout
|
|
174
|
+
className="flex flex-col gap-2 rounded-lg border px-3 py-2"
|
|
175
|
+
initial={{ opacity: 0, y: -4 }}
|
|
176
|
+
animate={{ opacity: 1, y: 0 }}
|
|
177
|
+
exit={{ opacity: 0, y: -4 }}
|
|
178
|
+
// 40ms per row, capped so a long list never leaves
|
|
179
|
+
// the last row arriving noticeably late.
|
|
180
|
+
transition={{
|
|
181
|
+
duration: 0.25,
|
|
182
|
+
ease: EASE,
|
|
183
|
+
delay: Math.min(index, 5) * 0.04,
|
|
184
|
+
}}
|
|
185
|
+
>
|
|
186
|
+
<div className="flex items-center justify-between gap-3">
|
|
187
|
+
<div className="flex min-w-0 flex-col">
|
|
188
|
+
<span className="truncate font-medium text-sm">
|
|
189
|
+
{name}
|
|
190
|
+
</span>
|
|
191
|
+
<span className="text-xs text-muted-foreground">
|
|
192
|
+
{created ? `Added ${created}` : null}
|
|
193
|
+
{created && lastUsed ? " · " : null}
|
|
194
|
+
{lastUsed ? `Last used ${lastUsed}` : null}
|
|
195
|
+
</span>
|
|
196
|
+
</div>
|
|
197
|
+
<div className="flex shrink-0 gap-2">
|
|
198
|
+
<Button
|
|
199
|
+
aria-label={`Rename ${name}`}
|
|
200
|
+
size="sm"
|
|
201
|
+
type="button"
|
|
202
|
+
variant="outline"
|
|
203
|
+
disabled={busy}
|
|
204
|
+
onClick={() => startRename(passkey)}
|
|
205
|
+
>
|
|
206
|
+
Rename
|
|
207
|
+
</Button>
|
|
208
|
+
<Button
|
|
209
|
+
aria-label={`Delete ${name}`}
|
|
210
|
+
size="sm"
|
|
211
|
+
type="button"
|
|
212
|
+
variant="destructive-outline"
|
|
213
|
+
disabled={busy}
|
|
214
|
+
onClick={() => {
|
|
215
|
+
setRenamingId(null);
|
|
216
|
+
setConfirmingId(passkey.id);
|
|
217
|
+
}}
|
|
218
|
+
>
|
|
219
|
+
Delete
|
|
220
|
+
</Button>
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<Reveal show={renamingId === passkey.id}>
|
|
225
|
+
<form
|
|
226
|
+
className="flex items-start gap-2 pt-1"
|
|
227
|
+
onSubmit={(event) => {
|
|
228
|
+
event.preventDefault();
|
|
229
|
+
void submitRename(passkey.id);
|
|
230
|
+
}}
|
|
231
|
+
>
|
|
232
|
+
<Field
|
|
233
|
+
className="min-w-0 grow"
|
|
234
|
+
invalid={Boolean(renameError)}
|
|
235
|
+
>
|
|
236
|
+
<FieldLabel className="sr-only">
|
|
237
|
+
Passkey name
|
|
238
|
+
</FieldLabel>
|
|
239
|
+
<Input
|
|
240
|
+
aria-label="Passkey name"
|
|
241
|
+
aria-invalid={renameError ? true : undefined}
|
|
242
|
+
autoFocus
|
|
243
|
+
disabled={busy}
|
|
244
|
+
maxLength={NAME_MAX + 1}
|
|
245
|
+
value={renameValue}
|
|
246
|
+
onChange={(event) =>
|
|
247
|
+
setRenameValue(event.target.value)
|
|
248
|
+
}
|
|
249
|
+
/>
|
|
250
|
+
{renameError && (
|
|
251
|
+
<FieldError match>{renameError}</FieldError>
|
|
252
|
+
)}
|
|
253
|
+
</Field>
|
|
254
|
+
<Button size="sm" type="submit" loading={busy}>
|
|
255
|
+
Save
|
|
256
|
+
</Button>
|
|
257
|
+
<Button
|
|
258
|
+
size="sm"
|
|
259
|
+
type="button"
|
|
260
|
+
variant="ghost"
|
|
261
|
+
disabled={busy}
|
|
262
|
+
onClick={() => setRenamingId(null)}
|
|
263
|
+
>
|
|
264
|
+
Cancel
|
|
265
|
+
</Button>
|
|
266
|
+
</form>
|
|
267
|
+
</Reveal>
|
|
268
|
+
|
|
269
|
+
<Reveal show={confirmingId === passkey.id}>
|
|
270
|
+
<div
|
|
271
|
+
className="flex flex-col gap-2 rounded-lg bg-muted/50 p-2"
|
|
272
|
+
role="alertdialog"
|
|
273
|
+
aria-label={`Confirm deleting ${name}`}
|
|
274
|
+
>
|
|
275
|
+
<p className="text-sm">
|
|
276
|
+
Delete this passkey? It can no longer be used to
|
|
277
|
+
sign in.
|
|
278
|
+
</p>
|
|
279
|
+
{lastPasskey && (
|
|
280
|
+
<p className="text-xs text-destructive-foreground">
|
|
281
|
+
{LAST_PASSKEY_WARNING}
|
|
282
|
+
</p>
|
|
283
|
+
)}
|
|
284
|
+
<div className="flex gap-2">
|
|
285
|
+
<Button
|
|
286
|
+
size="sm"
|
|
287
|
+
type="button"
|
|
288
|
+
variant="destructive"
|
|
289
|
+
loading={busy}
|
|
290
|
+
onClick={() => void confirmDelete(passkey.id)}
|
|
291
|
+
>
|
|
292
|
+
Delete passkey
|
|
293
|
+
</Button>
|
|
294
|
+
<Button
|
|
295
|
+
size="sm"
|
|
296
|
+
type="button"
|
|
297
|
+
variant="ghost"
|
|
298
|
+
disabled={busy}
|
|
299
|
+
onClick={() => setConfirmingId(null)}
|
|
300
|
+
>
|
|
301
|
+
Keep it
|
|
302
|
+
</Button>
|
|
303
|
+
</div>
|
|
304
|
+
</div>
|
|
305
|
+
</Reveal>
|
|
306
|
+
</motion.li>
|
|
307
|
+
);
|
|
308
|
+
})}
|
|
309
|
+
</AnimatePresence>
|
|
310
|
+
</ul>
|
|
311
|
+
) : (
|
|
312
|
+
<p className="text-sm text-muted-foreground">
|
|
313
|
+
No passkeys registered yet. Add one to sign in without a
|
|
314
|
+
password.
|
|
315
|
+
</p>
|
|
316
|
+
)}
|
|
317
|
+
|
|
318
|
+
<Button
|
|
319
|
+
className="w-full"
|
|
320
|
+
type="button"
|
|
321
|
+
variant="outline"
|
|
322
|
+
disabled={busy}
|
|
323
|
+
loading={registering}
|
|
324
|
+
onClick={() => void handleRegister()}
|
|
325
|
+
>
|
|
326
|
+
Add a passkey
|
|
327
|
+
</Button>
|
|
328
|
+
</motion.div>
|
|
329
|
+
)}
|
|
330
|
+
</CardPanel>
|
|
331
|
+
</Card>
|
|
332
|
+
</MotionConfig>
|
|
333
|
+
);
|
|
334
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { FingerprintIcon } from "lucide-react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { cn } from "@/lib/utils";
|
|
8
|
+
import { AuthError, Reveal } from "./auth-shell";
|
|
9
|
+
|
|
10
|
+
export interface PasskeySignInBlockProps {
|
|
11
|
+
/**
|
|
12
|
+
* Whether this device can use passkeys. `false` renders nothing at all —
|
|
13
|
+
* a passkey button that is guaranteed to fail is worse than no button.
|
|
14
|
+
*/
|
|
15
|
+
available?: boolean;
|
|
16
|
+
/** Button label. */
|
|
17
|
+
label?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Reject (or throw) to show the inline error. Resolving with
|
|
20
|
+
* `{ cancelled: true }` returns silently to idle — a dismissed OS prompt is
|
|
21
|
+
* not a failure and must not surface an error.
|
|
22
|
+
*/
|
|
23
|
+
onSignIn?: () => Promise<{ cancelled?: boolean } | void> | void;
|
|
24
|
+
/** Hint shown under an error, pointing at the remaining sign-in methods. */
|
|
25
|
+
fallbackHint?: React.ReactNode;
|
|
26
|
+
className?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Passkey sign-in entry point — a single button plus its error state, sized
|
|
31
|
+
* to drop into a login card next to the password form rather than to stand on
|
|
32
|
+
* its own. Renders `null` while passkeys are unavailable.
|
|
33
|
+
*/
|
|
34
|
+
export function PasskeySignInBlock({
|
|
35
|
+
available = true,
|
|
36
|
+
label = "Sign in with a passkey",
|
|
37
|
+
onSignIn,
|
|
38
|
+
fallbackHint = "You can still sign in with your other methods below.",
|
|
39
|
+
className,
|
|
40
|
+
}: PasskeySignInBlockProps) {
|
|
41
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
42
|
+
const [loading, setLoading] = React.useState(false);
|
|
43
|
+
|
|
44
|
+
if (!available) return null;
|
|
45
|
+
|
|
46
|
+
async function handleClick() {
|
|
47
|
+
if (loading) return;
|
|
48
|
+
setError(null);
|
|
49
|
+
setLoading(true);
|
|
50
|
+
try {
|
|
51
|
+
await onSignIn?.();
|
|
52
|
+
} catch (cause) {
|
|
53
|
+
setError(
|
|
54
|
+
cause instanceof Error
|
|
55
|
+
? cause.message
|
|
56
|
+
: "Unable to sign in with a passkey.",
|
|
57
|
+
);
|
|
58
|
+
} finally {
|
|
59
|
+
setLoading(false);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div className={cn("flex w-full flex-col gap-2", className)}>
|
|
65
|
+
<Button
|
|
66
|
+
variant="outline"
|
|
67
|
+
className="w-full"
|
|
68
|
+
type="button"
|
|
69
|
+
loading={loading}
|
|
70
|
+
onClick={() => void handleClick()}
|
|
71
|
+
>
|
|
72
|
+
<FingerprintIcon aria-hidden="true" />
|
|
73
|
+
<span className="flex-1">{label}</span>
|
|
74
|
+
</Button>
|
|
75
|
+
<AuthError message={error} />
|
|
76
|
+
<Reveal show={Boolean(error) && Boolean(fallbackHint)}>
|
|
77
|
+
<p className="text-xs text-muted-foreground">{fallbackHint}</p>
|
|
78
|
+
</Reveal>
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
getPasswordStrength,
|
|
7
|
+
PasswordInput,
|
|
8
|
+
} from "@/components/composed/password-input";
|
|
9
|
+
import { Button } from "@/components/ui/button";
|
|
10
|
+
import { Field, FieldLabel } from "@/components/ui/field";
|
|
11
|
+
import { type AuthAccent, authAccentActionStyles } from "@/lib/auth-accent";
|
|
12
|
+
import { cn } from "@/lib/utils";
|
|
13
|
+
import {
|
|
14
|
+
AuthCard,
|
|
15
|
+
AuthError,
|
|
16
|
+
AuthSuccess,
|
|
17
|
+
MorphStep,
|
|
18
|
+
Reveal,
|
|
19
|
+
type AuthStatus,
|
|
20
|
+
} from "./auth-shell";
|
|
21
|
+
|
|
22
|
+
export interface UpdatePasswordBlockProps {
|
|
23
|
+
title?: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
/** Brand mark rendered above the title. Omit for no logo row at all. */
|
|
26
|
+
logo?: React.ReactNode;
|
|
27
|
+
/** Brand accent for the primary action. Omit to keep the default primary. */
|
|
28
|
+
accent?: AuthAccent;
|
|
29
|
+
/**
|
|
30
|
+
* Minimum strength (0-4, as scored by `getPasswordStrength`) the new
|
|
31
|
+
* password must reach before the confirm field is revealed. 0 disables the
|
|
32
|
+
* gate.
|
|
33
|
+
*/
|
|
34
|
+
minStrength?: number;
|
|
35
|
+
/** Reject (or throw) to show the error state with the error's message. */
|
|
36
|
+
onSubmit?: (data: { password: string }) => Promise<void> | void;
|
|
37
|
+
onDone?: () => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Password change form for an already-authenticated user: a strength-metered
|
|
42
|
+
* new password gates the confirm field, which in turn gates the submit
|
|
43
|
+
* button. Mismatch is caught client-side; everything else comes back as a
|
|
44
|
+
* rejection from `onSubmit`.
|
|
45
|
+
*/
|
|
46
|
+
export function UpdatePasswordBlock({
|
|
47
|
+
title = "Update your password",
|
|
48
|
+
description = "Choose a new password for your account",
|
|
49
|
+
logo,
|
|
50
|
+
accent,
|
|
51
|
+
minStrength = 4,
|
|
52
|
+
onSubmit,
|
|
53
|
+
onDone,
|
|
54
|
+
}: UpdatePasswordBlockProps) {
|
|
55
|
+
const passwordId = React.useId();
|
|
56
|
+
const confirmId = React.useId();
|
|
57
|
+
const [status, setStatus] = React.useState<AuthStatus>("idle");
|
|
58
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
59
|
+
const [password, setPassword] = React.useState("");
|
|
60
|
+
const [confirmPassword, setConfirmPassword] = React.useState("");
|
|
61
|
+
|
|
62
|
+
// Progressive disclosure, matching the signup block: the confirm field
|
|
63
|
+
// appears once the new password clears the policy, the submit button once
|
|
64
|
+
// the two match.
|
|
65
|
+
const strongEnough = getPasswordStrength(password) >= minStrength;
|
|
66
|
+
const matches = confirmPassword.length > 0 && confirmPassword === password;
|
|
67
|
+
const canSubmit = strongEnough && matches;
|
|
68
|
+
|
|
69
|
+
// Only flag a mismatch once the confirmation has diverged — while it is
|
|
70
|
+
// still a prefix of the new password the user is simply mid-keystroke, and
|
|
71
|
+
// nagging on every character would shake the field the whole way through.
|
|
72
|
+
const mismatch =
|
|
73
|
+
confirmPassword.length > 0 && !password.startsWith(confirmPassword);
|
|
74
|
+
|
|
75
|
+
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
|
76
|
+
event.preventDefault();
|
|
77
|
+
// Enter in any field submits the form even while the button is hidden.
|
|
78
|
+
if (!canSubmit) return;
|
|
79
|
+
setError(null);
|
|
80
|
+
setStatus("loading");
|
|
81
|
+
try {
|
|
82
|
+
await onSubmit?.({ password });
|
|
83
|
+
setStatus("success");
|
|
84
|
+
} catch (cause) {
|
|
85
|
+
setStatus("idle");
|
|
86
|
+
setError(
|
|
87
|
+
cause instanceof Error ? cause.message : "Unable to update password.",
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<AuthCard
|
|
94
|
+
title={title}
|
|
95
|
+
description={description}
|
|
96
|
+
logo={logo}
|
|
97
|
+
accent={accent}
|
|
98
|
+
>
|
|
99
|
+
<MorphStep step={status === "success" ? "success" : "form"}>
|
|
100
|
+
{status === "success" ? (
|
|
101
|
+
<AuthSuccess
|
|
102
|
+
title="Password updated"
|
|
103
|
+
description="Your password has been changed successfully."
|
|
104
|
+
>
|
|
105
|
+
{onDone && (
|
|
106
|
+
<Button variant="link" onClick={onDone}>
|
|
107
|
+
Continue
|
|
108
|
+
</Button>
|
|
109
|
+
)}
|
|
110
|
+
</AuthSuccess>
|
|
111
|
+
) : (
|
|
112
|
+
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
|
113
|
+
<Field name="password">
|
|
114
|
+
<FieldLabel htmlFor={passwordId}>New password</FieldLabel>
|
|
115
|
+
<PasswordInput
|
|
116
|
+
id={passwordId}
|
|
117
|
+
name="password"
|
|
118
|
+
autoComplete="new-password"
|
|
119
|
+
required
|
|
120
|
+
showStrength
|
|
121
|
+
disabled={status === "loading"}
|
|
122
|
+
value={password}
|
|
123
|
+
onChange={(event) => setPassword(event.target.value)}
|
|
124
|
+
/>
|
|
125
|
+
</Field>
|
|
126
|
+
<Reveal show={strongEnough}>
|
|
127
|
+
<Field name="confirmPassword">
|
|
128
|
+
<FieldLabel htmlFor={confirmId}>Confirm new password</FieldLabel>
|
|
129
|
+
<PasswordInput
|
|
130
|
+
id={confirmId}
|
|
131
|
+
name="confirmPassword"
|
|
132
|
+
autoComplete="new-password"
|
|
133
|
+
required
|
|
134
|
+
disabled={status === "loading"}
|
|
135
|
+
value={confirmPassword}
|
|
136
|
+
onChange={(event) => setConfirmPassword(event.target.value)}
|
|
137
|
+
/>
|
|
138
|
+
</Field>
|
|
139
|
+
</Reveal>
|
|
140
|
+
<AuthError
|
|
141
|
+
message={error ?? (mismatch ? "Passwords do not match." : null)}
|
|
142
|
+
/>
|
|
143
|
+
<Reveal show={canSubmit}>
|
|
144
|
+
<Button
|
|
145
|
+
className={cn("w-full", accent && authAccentActionStyles)}
|
|
146
|
+
type="submit"
|
|
147
|
+
loading={status === "loading"}
|
|
148
|
+
>
|
|
149
|
+
Update password
|
|
150
|
+
</Button>
|
|
151
|
+
</Reveal>
|
|
152
|
+
</form>
|
|
153
|
+
)}
|
|
154
|
+
</MorphStep>
|
|
155
|
+
</AuthCard>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
@@ -13,7 +13,8 @@ import { cn } from "@/lib/utils";
|
|
|
13
13
|
|
|
14
14
|
export type SocialProvider = "google" | "apple" | "github" | "x";
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
/** Label + brand mark per provider, shared with the auth blocks. */
|
|
17
|
+
export const SOCIAL_PROVIDERS: Record<
|
|
17
18
|
SocialProvider,
|
|
18
19
|
{ label: string; Icon: React.ComponentType<{ className?: string }> }
|
|
19
20
|
> = {
|
|
@@ -63,7 +64,7 @@ export function SocialProviders({
|
|
|
63
64
|
)}
|
|
64
65
|
>
|
|
65
66
|
{providers.map((provider) => {
|
|
66
|
-
const { label, Icon } =
|
|
67
|
+
const { label, Icon } = SOCIAL_PROVIDERS[provider];
|
|
67
68
|
return (
|
|
68
69
|
<Button
|
|
69
70
|
key={provider}
|
package/src/index.ts
CHANGED
|
@@ -28,8 +28,13 @@ export * from "./components/composed/social-providers"
|
|
|
28
28
|
export * from "./components/blocks/auth/auth-shell"
|
|
29
29
|
export * from "./components/blocks/auth/code-auth-block"
|
|
30
30
|
export * from "./components/blocks/auth/forgot-password-block"
|
|
31
|
+
export * from "./components/blocks/auth/linked-accounts-block"
|
|
31
32
|
export * from "./components/blocks/auth/login-block"
|
|
33
|
+
export * from "./components/blocks/auth/onboarding-block"
|
|
34
|
+
export * from "./components/blocks/auth/passkey-manager-block"
|
|
35
|
+
export * from "./components/blocks/auth/passkey-sign-in-block"
|
|
32
36
|
export * from "./components/blocks/auth/signup-block"
|
|
37
|
+
export * from "./components/blocks/auth/update-password-block"
|
|
33
38
|
|
|
34
39
|
// lib
|
|
35
40
|
export * from "./lib/auth-accent"
|