@exegia/corpora-ui 0.6.0 → 0.8.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.
Files changed (33) hide show
  1. package/dist-lib/components/blocks/auth/__tests__/linked-accounts-block.test.d.ts +1 -0
  2. package/dist-lib/components/blocks/auth/__tests__/onboarding-block.test.d.ts +1 -0
  3. package/dist-lib/components/blocks/auth/__tests__/passkey-manager-block.test.d.ts +1 -0
  4. package/dist-lib/components/blocks/auth/__tests__/passkey-sign-in-block.test.d.ts +1 -0
  5. package/dist-lib/components/blocks/auth/__tests__/signup-block.test.d.ts +1 -0
  6. package/dist-lib/components/blocks/auth/__tests__/update-password-block.test.d.ts +1 -0
  7. package/dist-lib/components/blocks/auth/linked-accounts-block.d.ts +30 -0
  8. package/dist-lib/components/blocks/auth/onboarding/profile-step.d.ts +19 -0
  9. package/dist-lib/components/blocks/auth/onboarding-block.d.ts +69 -0
  10. package/dist-lib/components/blocks/auth/passkey-manager-block.d.ts +38 -0
  11. package/dist-lib/components/blocks/auth/passkey-sign-in-block.d.ts +27 -0
  12. package/dist-lib/components/blocks/auth/signup-block.d.ts +16 -1
  13. package/dist-lib/components/blocks/auth/update-password-block.d.ts +28 -0
  14. package/dist-lib/components/composed/social-providers.d.ts +7 -0
  15. package/dist-lib/index.d.ts +5 -0
  16. package/dist-lib/index.js +1122 -392
  17. package/dist-lib/index.js.map +1 -1
  18. package/package.json +5 -1
  19. package/src/components/blocks/auth/__tests__/linked-accounts-block.test.tsx +110 -0
  20. package/src/components/blocks/auth/__tests__/onboarding-block.test.tsx +156 -0
  21. package/src/components/blocks/auth/__tests__/passkey-manager-block.test.tsx +131 -0
  22. package/src/components/blocks/auth/__tests__/passkey-sign-in-block.test.tsx +59 -0
  23. package/src/components/blocks/auth/__tests__/signup-block.test.tsx +176 -0
  24. package/src/components/blocks/auth/__tests__/update-password-block.test.tsx +95 -0
  25. package/src/components/blocks/auth/linked-accounts-block.tsx +223 -0
  26. package/src/components/blocks/auth/onboarding/profile-step.tsx +227 -0
  27. package/src/components/blocks/auth/onboarding-block.tsx +240 -0
  28. package/src/components/blocks/auth/passkey-manager-block.tsx +334 -0
  29. package/src/components/blocks/auth/passkey-sign-in-block.tsx +81 -0
  30. package/src/components/blocks/auth/signup-block.tsx +43 -10
  31. package/src/components/blocks/auth/update-password-block.tsx +157 -0
  32. package/src/components/composed/social-providers.tsx +3 -2
  33. package/src/index.ts +5 -0
@@ -0,0 +1,240 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ import { cn } from "@/lib/utils";
6
+ import type { AuthAccent } from "@/lib/auth-accent";
7
+ import {
8
+ AuthCard,
9
+ AuthError,
10
+ AuthSuccess,
11
+ MorphStep,
12
+ type AuthStatus,
13
+ } from "./auth-shell";
14
+ import { ProfileStep } from "./onboarding/profile-step";
15
+
16
+ /** Value a single onboarding field can hold. */
17
+ export type OnboardingValue = string | boolean;
18
+
19
+ export interface OnboardingSelectOption {
20
+ value: string;
21
+ label: string;
22
+ }
23
+
24
+ interface OnboardingFieldBase {
25
+ /** Key the value is collected under; unique within the flow. */
26
+ name: string;
27
+ label: string;
28
+ /** Required fields gate the step's advance. */
29
+ required?: boolean;
30
+ placeholder?: string;
31
+ /** Extra validation; return a message to reject, `null` to accept. */
32
+ validate?: (value: string) => string | null;
33
+ }
34
+
35
+ export interface OnboardingTextField extends OnboardingFieldBase {
36
+ kind: "text" | "textarea" | "url";
37
+ }
38
+
39
+ export interface OnboardingCheckboxField extends OnboardingFieldBase {
40
+ kind: "checkbox";
41
+ }
42
+
43
+ export interface OnboardingSelectField extends OnboardingFieldBase {
44
+ kind: "select";
45
+ options: OnboardingSelectOption[];
46
+ }
47
+
48
+ /** Discriminated on `kind`. */
49
+ export type OnboardingFieldConfig =
50
+ | OnboardingTextField
51
+ | OnboardingCheckboxField
52
+ | OnboardingSelectField;
53
+
54
+ export interface OnboardingStepConfig {
55
+ /** Unique within the flow and stable across releases. */
56
+ id: string;
57
+ title: string;
58
+ description?: string;
59
+ fields: OnboardingFieldConfig[];
60
+ }
61
+
62
+ /** Default configuration: a single required display-name step. */
63
+ export const DEFAULT_ONBOARDING_STEPS: OnboardingStepConfig[] = [
64
+ {
65
+ id: "profile",
66
+ title: "Your profile",
67
+ fields: [
68
+ { kind: "text", name: "display_name", label: "Display name", required: true },
69
+ ],
70
+ },
71
+ ];
72
+
73
+ export interface OnboardingBlockProps {
74
+ /** Declared profile steps. */
75
+ steps?: OnboardingStepConfig[];
76
+ /** Brand mark rendered above the title. Omit for no logo row at all. */
77
+ logo?: React.ReactNode;
78
+ /** Brand accent for the primary action. Omit to keep the default primary. */
79
+ accent?: AuthAccent;
80
+ /**
81
+ * Fires per step as it is submitted. Reject (or throw) to keep the user on
82
+ * the step and show the error.
83
+ */
84
+ onStepSubmit?: (
85
+ stepId: string,
86
+ values: Record<string, OnboardingValue>,
87
+ ) => Promise<void> | void;
88
+ /** Fires once, after the final step is accepted, with the merged profile. */
89
+ onComplete?: (profile: Record<string, OnboardingValue>) => Promise<void> | void;
90
+ /** Shows a brief success screen once onboarding completes. */
91
+ showCompleteScreen?: boolean;
92
+ className?: string;
93
+ }
94
+
95
+ /**
96
+ * Multi-step profile onboarding: a declared `steps` config renders as
97
+ * progress-tracked forms with back/forward navigation, per-step submission and
98
+ * a single completion signal. Drafts survive back-navigation, and the step
99
+ * heading takes focus on advance so a screen reader announces where the flow
100
+ * moved.
101
+ *
102
+ * Account creation and email confirmation are separate blocks (`SignupBlock`,
103
+ * `CodeAuthBlock`) — this one picks up once the account exists.
104
+ */
105
+ export function OnboardingBlock({
106
+ steps = DEFAULT_ONBOARDING_STEPS,
107
+ logo,
108
+ accent,
109
+ onStepSubmit,
110
+ onComplete,
111
+ showCompleteScreen = true,
112
+ className,
113
+ }: OnboardingBlockProps) {
114
+ const headingRef = React.useRef<HTMLHeadingElement>(null);
115
+ // Unsubmitted per-step drafts. Accumulated in a ref so a keystroke does not
116
+ // re-render the whole flow, then published to state at each navigation —
117
+ // the only moment the seed values are actually read.
118
+ const draftsRef = React.useRef<
119
+ Record<string, Record<string, OnboardingValue>>
120
+ >({});
121
+ const [drafts, setDrafts] = React.useState<
122
+ Record<string, Record<string, OnboardingValue>>
123
+ >({});
124
+
125
+ const [index, setIndex] = React.useState(0);
126
+ const [values, setValues] = React.useState<Record<string, OnboardingValue>>(
127
+ {},
128
+ );
129
+ const [status, setStatus] = React.useState<AuthStatus>("idle");
130
+ const [error, setError] = React.useState<string | null>(null);
131
+
132
+ const done = status === "success";
133
+ const step = steps[index];
134
+ const last = index >= steps.length - 1;
135
+
136
+ // Focus the step heading on advance; on error the alert carries the message
137
+ // and stealing focus would talk over it.
138
+ React.useEffect(() => {
139
+ if (error) return;
140
+ headingRef.current?.focus();
141
+ }, [index, done, error]);
142
+
143
+ async function handleStepSubmit(stepValues: Record<string, OnboardingValue>) {
144
+ if (!step) return;
145
+ const merged = { ...values, ...stepValues };
146
+ setError(null);
147
+ setStatus("loading");
148
+ try {
149
+ await onStepSubmit?.(step.id, stepValues);
150
+ if (last) await onComplete?.(merged);
151
+ setValues(merged);
152
+ delete draftsRef.current[step.id];
153
+ setDrafts({ ...draftsRef.current });
154
+ setStatus(last ? "success" : "idle");
155
+ if (!last) setIndex((current) => current + 1);
156
+ } catch (cause) {
157
+ setStatus("idle");
158
+ setError(
159
+ cause instanceof Error ? cause.message : "Unable to save your profile.",
160
+ );
161
+ }
162
+ }
163
+
164
+ if (done && !showCompleteScreen) return null;
165
+
166
+ const heading = done ? "You're all set" : (step?.title ?? "Your profile");
167
+
168
+ return (
169
+ <AuthCard
170
+ title={heading}
171
+ description={done ? undefined : step?.description}
172
+ logo={logo}
173
+ accent={accent}
174
+ className={className}
175
+ >
176
+ <MorphStep step={done ? "done" : `step-${index}`}>
177
+ {done ? (
178
+ <AuthSuccess
179
+ title="Profile saved"
180
+ description="Your account is ready and your profile has been saved."
181
+ />
182
+ ) : step ? (
183
+ <div className="flex flex-col gap-4">
184
+ <nav aria-label="Onboarding progress">
185
+ <ol className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
186
+ {steps.map((item, itemIndex) => (
187
+ <li
188
+ key={item.id}
189
+ aria-current={itemIndex === index ? "step" : undefined}
190
+ className={cn(
191
+ "text-muted-foreground",
192
+ itemIndex === index && "font-medium text-foreground",
193
+ )}
194
+ >
195
+ {item.title}
196
+ {itemIndex < index && (
197
+ <span className="sr-only"> (completed)</span>
198
+ )}
199
+ </li>
200
+ ))}
201
+ </ol>
202
+ </nav>
203
+
204
+ {/* The AuthCard title is the visible heading; this one exists to
205
+ take focus on advance without duplicating it on screen. */}
206
+ <h3 ref={headingRef} tabIndex={-1} className="sr-only">
207
+ {step.title}
208
+ </h3>
209
+
210
+ <AuthError message={error} />
211
+
212
+ <ProfileStep
213
+ key={step.id}
214
+ step={step}
215
+ values={{ ...values, ...drafts[step.id] }}
216
+ submitting={status === "loading"}
217
+ submitLabel={last ? "Finish" : "Continue"}
218
+ onBack={
219
+ index > 0
220
+ ? () => {
221
+ setError(null);
222
+ setDrafts({ ...draftsRef.current });
223
+ setIndex((current) => current - 1);
224
+ }
225
+ : undefined
226
+ }
227
+ onDraftChange={(name, value) => {
228
+ draftsRef.current[step.id] = {
229
+ ...draftsRef.current[step.id],
230
+ [name]: value,
231
+ };
232
+ }}
233
+ onSubmit={handleStepSubmit}
234
+ />
235
+ </div>
236
+ ) : null}
237
+ </MorphStep>
238
+ </AuthCard>
239
+ );
240
+ }
@@ -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&apos;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
+ }