@exegia/corpora-ui 0.5.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.
Files changed (29) 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__/update-password-block.test.d.ts +1 -0
  6. package/dist-lib/components/blocks/auth/linked-accounts-block.d.ts +30 -0
  7. package/dist-lib/components/blocks/auth/onboarding/profile-step.d.ts +19 -0
  8. package/dist-lib/components/blocks/auth/onboarding-block.d.ts +69 -0
  9. package/dist-lib/components/blocks/auth/passkey-manager-block.d.ts +38 -0
  10. package/dist-lib/components/blocks/auth/passkey-sign-in-block.d.ts +27 -0
  11. package/dist-lib/components/blocks/auth/update-password-block.d.ts +28 -0
  12. package/dist-lib/components/composed/social-providers.d.ts +7 -0
  13. package/dist-lib/index.d.ts +5 -0
  14. package/dist-lib/index.js +1032 -312
  15. package/dist-lib/index.js.map +1 -1
  16. package/package.json +5 -1
  17. package/src/components/blocks/auth/__tests__/linked-accounts-block.test.tsx +110 -0
  18. package/src/components/blocks/auth/__tests__/onboarding-block.test.tsx +156 -0
  19. package/src/components/blocks/auth/__tests__/passkey-manager-block.test.tsx +131 -0
  20. package/src/components/blocks/auth/__tests__/passkey-sign-in-block.test.tsx +59 -0
  21. package/src/components/blocks/auth/__tests__/update-password-block.test.tsx +95 -0
  22. package/src/components/blocks/auth/linked-accounts-block.tsx +223 -0
  23. package/src/components/blocks/auth/onboarding/profile-step.tsx +227 -0
  24. package/src/components/blocks/auth/onboarding-block.tsx +240 -0
  25. package/src/components/blocks/auth/passkey-manager-block.tsx +334 -0
  26. package/src/components/blocks/auth/passkey-sign-in-block.tsx +81 -0
  27. package/src/components/blocks/auth/update-password-block.tsx +157 -0
  28. package/src/components/composed/social-providers.tsx +3 -2
  29. 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&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
+ }
@@ -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
- const PROVIDERS: Record<
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 } = PROVIDERS[provider];
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"