@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.
- 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__/signup-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/signup-block.d.ts +16 -1
- 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 +1122 -392
- 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__/signup-block.test.tsx +176 -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/signup-block.tsx +43 -10
- 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,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'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
|
+
}
|