@dbx-tools/ui-auth 0.6.91
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -0
- package/index.ts +12 -0
- package/package.json +56 -0
- package/src/react/auth-client.ts +55 -0
- package/src/react/auth-gate.tsx +234 -0
- package/src/react/index.ts +2 -0
- package/src/react/passkey-manager.tsx +130 -0
package/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# `@dbx-tools/ui-auth`
|
|
2
|
+
|
|
3
|
+
React passwordless authentication surfaces for `@dbx-tools/auth`.
|
|
4
|
+
|
|
5
|
+
Key features:
|
|
6
|
+
|
|
7
|
+
- email OTP sign-in with passkey actions presented underneath;
|
|
8
|
+
- automatic conditional passkey mediation on supported browsers;
|
|
9
|
+
- first-login passkey enrollment prompt;
|
|
10
|
+
- authenticated passkey list, rename, add, and remove controls;
|
|
11
|
+
- HttpOnly Better Auth sessions with no browser token storage;
|
|
12
|
+
- AppKit and dbx-tools branding primitives.
|
|
13
|
+
|
|
14
|
+
## Relationship To Native AppKit
|
|
15
|
+
|
|
16
|
+
The native AppKit UI is sufficient when the Databricks Apps front door owns
|
|
17
|
+
identity. Use this package with `@dbx-tools/auth` when a public tunnel bypasses
|
|
18
|
+
that front door and needs passkey-first login plus email OTP recovery.
|
|
19
|
+
|
|
20
|
+
```tsx
|
|
21
|
+
import { AuthGate } from "@dbx-tools/ui-auth/react";
|
|
22
|
+
|
|
23
|
+
root.render(
|
|
24
|
+
<AuthGate>
|
|
25
|
+
<App />
|
|
26
|
+
</AuthGate>,
|
|
27
|
+
);
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Public subpaths
|
|
31
|
+
|
|
32
|
+
- `@dbx-tools/ui-auth/react` - `AuthGate` and `PasskeyManager`.
|
package/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// GENERATED by projen watch - DO NOT EDIT.
|
|
2
|
+
// Regenerated from the exporting modules in ./src.
|
|
3
|
+
// Hand edits are overwritten on the next watch; this file is read-only.
|
|
4
|
+
|
|
5
|
+
export const PACKAGE_IDENTIFIER = "@dbx-tools/ui-auth";
|
|
6
|
+
export * as reactAuthClient from "./src/react/auth-client.ts";
|
|
7
|
+
export * as reactAuthGate from "./src/react/auth-gate.tsx";
|
|
8
|
+
export * as reactPasskeyManager from "./src/react/passkey-manager.tsx";
|
|
9
|
+
export { AUTH_BASE } from "./src/react/auth-client.ts";
|
|
10
|
+
export type { PasskeySummary } from "./src/react/auth-client.ts";
|
|
11
|
+
export type { AuthGateProps } from "./src/react/auth-gate.tsx";
|
|
12
|
+
export type { PasskeyManagerProps } from "./src/react/passkey-manager.tsx";
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dbx-tools/ui-auth",
|
|
3
|
+
"repository": {
|
|
4
|
+
"type": "git",
|
|
5
|
+
"url": "git+https://github.com/reggie-db/dbx-tools.git",
|
|
6
|
+
"directory": "packages/js/ui/auth"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "projen build",
|
|
10
|
+
"compile": "projen compile",
|
|
11
|
+
"default": "projen default",
|
|
12
|
+
"package": "projen package",
|
|
13
|
+
"post-compile": "projen post-compile",
|
|
14
|
+
"pre-compile": "projen pre-compile",
|
|
15
|
+
"test": "projen test",
|
|
16
|
+
"watch": "projen watch",
|
|
17
|
+
"projen": "projen"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/bun": "^1.3.14",
|
|
21
|
+
"@types/node": "^24.6.0",
|
|
22
|
+
"@types/react": "^19.2.2",
|
|
23
|
+
"@types/react-dom": "^19.2.2",
|
|
24
|
+
"typescript": "^5.9.3"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@better-auth/passkey": "^1.6.25",
|
|
28
|
+
"@dbx-tools/shared-auth": "0.6.91",
|
|
29
|
+
"@dbx-tools/shared-core": "0.6.91",
|
|
30
|
+
"@dbx-tools/ui-appkit": "0.6.91",
|
|
31
|
+
"@dbx-tools/ui-branding": "0.6.91",
|
|
32
|
+
"better-auth": "^1.6.25",
|
|
33
|
+
"react": "^19.2.4",
|
|
34
|
+
"react-dom": "^19.2.4"
|
|
35
|
+
},
|
|
36
|
+
"license": "UNLICENSED",
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"version": "0.6.91",
|
|
41
|
+
"type": "module",
|
|
42
|
+
"exports": {
|
|
43
|
+
"./react": "./src/react/index.ts",
|
|
44
|
+
"./package.json": "./package.json"
|
|
45
|
+
},
|
|
46
|
+
"files": [
|
|
47
|
+
"index.ts",
|
|
48
|
+
"src"
|
|
49
|
+
],
|
|
50
|
+
"dbxToolsConfig": {
|
|
51
|
+
"tags": [
|
|
52
|
+
"ui"
|
|
53
|
+
]
|
|
54
|
+
},
|
|
55
|
+
"//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"bunx projen\"."
|
|
56
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { passkeyClient } from "@better-auth/passkey/client";
|
|
2
|
+
import { createAuthClient } from "better-auth/client";
|
|
3
|
+
import { emailOTPClient } from "better-auth/client/plugins";
|
|
4
|
+
|
|
5
|
+
export const AUTH_BASE = "/api/email/auth";
|
|
6
|
+
|
|
7
|
+
const client = createAuthClient({
|
|
8
|
+
baseURL:
|
|
9
|
+
typeof window === "undefined"
|
|
10
|
+
? `http://localhost${AUTH_BASE}`
|
|
11
|
+
: `${window.location.origin}${AUTH_BASE}`,
|
|
12
|
+
plugins: [emailOTPClient(), passkeyClient()],
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export interface PasskeySummary {
|
|
16
|
+
id: string;
|
|
17
|
+
name?: string | null;
|
|
18
|
+
createdAt?: Date | string | null;
|
|
19
|
+
deviceType?: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function requestEmailOtp(email: string): Promise<boolean> {
|
|
23
|
+
const result = await client.emailOtp.sendVerificationOtp({ email, type: "sign-in" });
|
|
24
|
+
return result.error === null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function verifyEmailOtp(email: string, otp: string, name: string): Promise<boolean> {
|
|
28
|
+
const result = await client.signIn.emailOtp({ email, otp, name });
|
|
29
|
+
return result.error === null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function signInPasskey(autoFill = false): Promise<boolean> {
|
|
33
|
+
const result = await client.signIn.passkey({ autoFill });
|
|
34
|
+
return result.error === null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function addPasskey(name?: string): Promise<boolean> {
|
|
38
|
+
const result = await client.passkey.addPasskey(name ? { name } : undefined);
|
|
39
|
+
return result.error === null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function listPasskeys(): Promise<PasskeySummary[]> {
|
|
43
|
+
const result = await client.passkey.listUserPasskeys();
|
|
44
|
+
return result.data ?? [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function renamePasskey(id: string, name: string): Promise<boolean> {
|
|
48
|
+
const result = await client.passkey.updatePasskey({ id, name });
|
|
49
|
+
return result.error === null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function removePasskey(id: string): Promise<boolean> {
|
|
53
|
+
const result = await client.passkey.deletePasskey({ id });
|
|
54
|
+
return result.error === null;
|
|
55
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import type { AuthStatus } from "@dbx-tools/shared-auth";
|
|
2
|
+
import { net } from "@dbx-tools/shared-core";
|
|
3
|
+
import { Button, Input } from "@dbx-tools/ui-appkit/react";
|
|
4
|
+
import { BrandIcon, useBrand } from "@dbx-tools/ui-branding/react";
|
|
5
|
+
import { type FormEvent, type ReactNode, useCallback, useEffect, useState } from "react";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
addPasskey,
|
|
9
|
+
AUTH_BASE,
|
|
10
|
+
requestEmailOtp,
|
|
11
|
+
signInPasskey,
|
|
12
|
+
verifyEmailOtp,
|
|
13
|
+
} from "./auth-client.ts";
|
|
14
|
+
|
|
15
|
+
type Phase = "loading" | "email" | "code" | "enroll" | "authed" | "open";
|
|
16
|
+
|
|
17
|
+
export interface AuthGateProps {
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
title?: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Email OTP gate with conditional passkey sign-in and first-login enrollment. */
|
|
24
|
+
export function AuthGate({ children, title, description }: AuthGateProps): ReactNode {
|
|
25
|
+
const { context: brand } = useBrand();
|
|
26
|
+
const [phase, setPhase] = useState<Phase>("loading");
|
|
27
|
+
const [email, setEmail] = useState("");
|
|
28
|
+
const [code, setCode] = useState("");
|
|
29
|
+
const [busy, setBusy] = useState(false);
|
|
30
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
31
|
+
const [passkeysEnabled, setPasskeysEnabled] = useState(false);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
let cancelled = false;
|
|
35
|
+
void fetch(`${AUTH_BASE}/status`, { credentials: "same-origin" })
|
|
36
|
+
.then(async (response) => {
|
|
37
|
+
if (!response.ok) throw new Error(`Authentication status failed (${response.status})`);
|
|
38
|
+
return (await response.json()) as AuthStatus;
|
|
39
|
+
})
|
|
40
|
+
.then((status) => {
|
|
41
|
+
if (cancelled) return;
|
|
42
|
+
setPasskeysEnabled(status.passkeysEnabled === true);
|
|
43
|
+
setPhase(status.enabled ? (status.authenticated ? "authed" : "email") : "open");
|
|
44
|
+
})
|
|
45
|
+
.catch(() => {
|
|
46
|
+
if (!cancelled) setPhase("email");
|
|
47
|
+
});
|
|
48
|
+
return () => {
|
|
49
|
+
cancelled = true;
|
|
50
|
+
};
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (
|
|
55
|
+
!passkeysEnabled ||
|
|
56
|
+
phase !== "email" ||
|
|
57
|
+
typeof PublicKeyCredential === "undefined" ||
|
|
58
|
+
typeof PublicKeyCredential.isConditionalMediationAvailable !== "function"
|
|
59
|
+
) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
let cancelled = false;
|
|
63
|
+
// Conditional mediation cannot reveal whether a credential exists. It lets
|
|
64
|
+
// the browser offer one immediately on the focused `webauthn` input without
|
|
65
|
+
// showing an empty modal to users who have no passkey.
|
|
66
|
+
void PublicKeyCredential.isConditionalMediationAvailable().then(async (available) => {
|
|
67
|
+
if (!available || cancelled) return;
|
|
68
|
+
if ((await signInPasskey(true)) && !cancelled) setPhase("authed");
|
|
69
|
+
});
|
|
70
|
+
return () => {
|
|
71
|
+
cancelled = true;
|
|
72
|
+
};
|
|
73
|
+
}, [passkeysEnabled, phase]);
|
|
74
|
+
|
|
75
|
+
const requestCode = useCallback(
|
|
76
|
+
async (event: FormEvent) => {
|
|
77
|
+
event.preventDefault();
|
|
78
|
+
if (busy) return;
|
|
79
|
+
const addresses = net.parseEmails(email);
|
|
80
|
+
const address = addresses[0];
|
|
81
|
+
if (addresses.length !== 1 || !address || !net.isEmail(address)) {
|
|
82
|
+
setNotice("Enter a valid email address.");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
setBusy(true);
|
|
86
|
+
setNotice(null);
|
|
87
|
+
setEmail(address);
|
|
88
|
+
try {
|
|
89
|
+
if (!(await requestEmailOtp(address))) throw new Error("OTP request failed");
|
|
90
|
+
setNotice("If the address is authorized, a verification code is on its way.");
|
|
91
|
+
setPhase("code");
|
|
92
|
+
} catch {
|
|
93
|
+
setNotice("Unable to request a verification code. Try again.");
|
|
94
|
+
} finally {
|
|
95
|
+
setBusy(false);
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
[busy, email],
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const verifyCode = useCallback(
|
|
102
|
+
async (event: FormEvent) => {
|
|
103
|
+
event.preventDefault();
|
|
104
|
+
if (!code.trim() || busy) return;
|
|
105
|
+
setBusy(true);
|
|
106
|
+
setNotice(null);
|
|
107
|
+
try {
|
|
108
|
+
if (!(await verifyEmailOtp(email, code.trim(), email.split("@")[0] || "User"))) {
|
|
109
|
+
throw new Error("OTP verification failed");
|
|
110
|
+
}
|
|
111
|
+
setPhase(passkeysEnabled ? "enroll" : "authed");
|
|
112
|
+
} catch {
|
|
113
|
+
setNotice("That verification code is incorrect or has expired.");
|
|
114
|
+
} finally {
|
|
115
|
+
setBusy(false);
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
[busy, code, email, passkeysEnabled],
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const usePasskey = useCallback(async () => {
|
|
122
|
+
setBusy(true);
|
|
123
|
+
setNotice(null);
|
|
124
|
+
try {
|
|
125
|
+
if (!(await signInPasskey())) throw new Error("Passkey authentication failed");
|
|
126
|
+
setPhase("authed");
|
|
127
|
+
} catch {
|
|
128
|
+
setNotice("Unable to sign in with a passkey. Use email recovery instead.");
|
|
129
|
+
} finally {
|
|
130
|
+
setBusy(false);
|
|
131
|
+
}
|
|
132
|
+
}, []);
|
|
133
|
+
|
|
134
|
+
const enroll = useCallback(async () => {
|
|
135
|
+
setBusy(true);
|
|
136
|
+
setNotice(null);
|
|
137
|
+
try {
|
|
138
|
+
if (!(await addPasskey("Primary passkey"))) throw new Error("Passkey enrollment failed");
|
|
139
|
+
setPhase("authed");
|
|
140
|
+
} catch {
|
|
141
|
+
setNotice("Unable to create a passkey. You can continue with email recovery.");
|
|
142
|
+
} finally {
|
|
143
|
+
setBusy(false);
|
|
144
|
+
}
|
|
145
|
+
}, []);
|
|
146
|
+
|
|
147
|
+
if (phase === "authed" || phase === "open") return <>{children}</>;
|
|
148
|
+
if (phase === "loading") return null;
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<div className="flex min-h-screen items-center justify-center bg-background p-6">
|
|
152
|
+
<div className="w-full max-w-sm rounded-lg border border-border bg-card p-6 shadow-sm">
|
|
153
|
+
<div className="mb-4 flex items-center gap-2 text-foreground">
|
|
154
|
+
<BrandIcon className="size-5" alt="" aria-hidden />
|
|
155
|
+
<h1 className="text-lg font-semibold">{title ?? `Sign in to ${brand.name}`}</h1>
|
|
156
|
+
</div>
|
|
157
|
+
<p className="mb-4 text-sm text-muted-foreground">
|
|
158
|
+
{description ??
|
|
159
|
+
(phase === "code"
|
|
160
|
+
? "Enter the 6-digit verification code sent to your email address."
|
|
161
|
+
: phase === "enroll"
|
|
162
|
+
? "Create a passkey for faster, phishing-resistant sign-in next time."
|
|
163
|
+
: "Enter your email for a one-time code, or use a passkey.")}
|
|
164
|
+
</p>
|
|
165
|
+
|
|
166
|
+
{phase === "email" ? (
|
|
167
|
+
<form noValidate onSubmit={requestCode} className="space-y-3">
|
|
168
|
+
<Input
|
|
169
|
+
type="email"
|
|
170
|
+
autoComplete="email webauthn"
|
|
171
|
+
autoFocus
|
|
172
|
+
aria-label="Email address"
|
|
173
|
+
placeholder="you@example.com"
|
|
174
|
+
value={email}
|
|
175
|
+
onChange={(event) => setEmail(event.target.value)}
|
|
176
|
+
required
|
|
177
|
+
/>
|
|
178
|
+
<Button type="submit" disabled={busy} className="w-full">
|
|
179
|
+
Sign in with email OTP
|
|
180
|
+
</Button>
|
|
181
|
+
{passkeysEnabled ? (
|
|
182
|
+
<Button
|
|
183
|
+
type="button"
|
|
184
|
+
variant="outline"
|
|
185
|
+
disabled={busy}
|
|
186
|
+
className="w-full"
|
|
187
|
+
onClick={usePasskey}
|
|
188
|
+
>
|
|
189
|
+
Sign in with a passkey
|
|
190
|
+
</Button>
|
|
191
|
+
) : null}
|
|
192
|
+
</form>
|
|
193
|
+
) : phase === "code" ? (
|
|
194
|
+
<form onSubmit={verifyCode} className="space-y-3">
|
|
195
|
+
<Input
|
|
196
|
+
type="text"
|
|
197
|
+
inputMode="numeric"
|
|
198
|
+
autoComplete="one-time-code"
|
|
199
|
+
aria-label="Verification code"
|
|
200
|
+
placeholder="6-digit verification code"
|
|
201
|
+
maxLength={6}
|
|
202
|
+
value={code}
|
|
203
|
+
onChange={(event) => setCode(event.target.value)}
|
|
204
|
+
required
|
|
205
|
+
/>
|
|
206
|
+
<Button type="submit" disabled={busy} className="w-full">
|
|
207
|
+
Continue
|
|
208
|
+
</Button>
|
|
209
|
+
</form>
|
|
210
|
+
) : (
|
|
211
|
+
<div className="space-y-3">
|
|
212
|
+
<Button type="button" disabled={busy} className="w-full" onClick={enroll}>
|
|
213
|
+
Create a passkey
|
|
214
|
+
</Button>
|
|
215
|
+
<Button
|
|
216
|
+
type="button"
|
|
217
|
+
variant="ghost"
|
|
218
|
+
className="w-full"
|
|
219
|
+
onClick={() => setPhase("authed")}
|
|
220
|
+
>
|
|
221
|
+
Not now
|
|
222
|
+
</Button>
|
|
223
|
+
</div>
|
|
224
|
+
)}
|
|
225
|
+
|
|
226
|
+
{notice ? (
|
|
227
|
+
<p role="status" aria-live="polite" className="mt-3 text-xs text-muted-foreground">
|
|
228
|
+
{notice}
|
|
229
|
+
</p>
|
|
230
|
+
) : null}
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { Button, Input } from "@dbx-tools/ui-appkit/react";
|
|
2
|
+
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
addPasskey,
|
|
6
|
+
listPasskeys,
|
|
7
|
+
type PasskeySummary,
|
|
8
|
+
removePasskey,
|
|
9
|
+
renamePasskey,
|
|
10
|
+
} from "./auth-client.ts";
|
|
11
|
+
|
|
12
|
+
export interface PasskeyManagerProps {
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Authenticated management surface for a user's Better Auth passkeys. */
|
|
17
|
+
export function PasskeyManager({ className }: PasskeyManagerProps): ReactNode {
|
|
18
|
+
const [passkeys, setPasskeys] = useState<PasskeySummary[]>([]);
|
|
19
|
+
const [names, setNames] = useState<Record<string, string>>({});
|
|
20
|
+
const [busy, setBusy] = useState(false);
|
|
21
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
22
|
+
|
|
23
|
+
const refresh = useCallback(async () => {
|
|
24
|
+
const values = await listPasskeys();
|
|
25
|
+
setPasskeys(values);
|
|
26
|
+
setNames(Object.fromEntries(values.map((passkey) => [passkey.id, passkey.name ?? "Passkey"])));
|
|
27
|
+
}, []);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
void refresh().catch(() => setNotice("Unable to load passkeys."));
|
|
31
|
+
}, [refresh]);
|
|
32
|
+
|
|
33
|
+
const add = useCallback(async () => {
|
|
34
|
+
setBusy(true);
|
|
35
|
+
setNotice(null);
|
|
36
|
+
try {
|
|
37
|
+
if (!(await addPasskey())) throw new Error("Passkey enrollment failed");
|
|
38
|
+
await refresh();
|
|
39
|
+
} catch {
|
|
40
|
+
setNotice("Unable to add a passkey.");
|
|
41
|
+
} finally {
|
|
42
|
+
setBusy(false);
|
|
43
|
+
}
|
|
44
|
+
}, [refresh]);
|
|
45
|
+
|
|
46
|
+
const rename = useCallback(
|
|
47
|
+
async (id: string) => {
|
|
48
|
+
const name = names[id]?.trim();
|
|
49
|
+
if (!name) return;
|
|
50
|
+
setBusy(true);
|
|
51
|
+
try {
|
|
52
|
+
if (!(await renamePasskey(id, name))) throw new Error("Passkey rename failed");
|
|
53
|
+
await refresh();
|
|
54
|
+
} catch {
|
|
55
|
+
setNotice("Unable to rename the passkey.");
|
|
56
|
+
} finally {
|
|
57
|
+
setBusy(false);
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
[names, refresh],
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const remove = useCallback(
|
|
64
|
+
async (id: string) => {
|
|
65
|
+
setBusy(true);
|
|
66
|
+
try {
|
|
67
|
+
if (!(await removePasskey(id))) throw new Error("Passkey removal failed");
|
|
68
|
+
await refresh();
|
|
69
|
+
} catch {
|
|
70
|
+
setNotice("Unable to remove the passkey.");
|
|
71
|
+
} finally {
|
|
72
|
+
setBusy(false);
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
[refresh],
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<section className={className} aria-label="Passkeys">
|
|
80
|
+
<div className="mb-3 flex items-center justify-between gap-3">
|
|
81
|
+
<div>
|
|
82
|
+
<h2 className="font-medium text-foreground">Passkeys</h2>
|
|
83
|
+
<p className="text-sm text-muted-foreground">
|
|
84
|
+
Add more than one device so email remains recovery, not your daily sign-in.
|
|
85
|
+
</p>
|
|
86
|
+
</div>
|
|
87
|
+
<Button type="button" onClick={add} disabled={busy}>
|
|
88
|
+
Add passkey
|
|
89
|
+
</Button>
|
|
90
|
+
</div>
|
|
91
|
+
<div className="space-y-2">
|
|
92
|
+
{passkeys.map((passkey) => (
|
|
93
|
+
<div key={passkey.id} className="flex items-center gap-2 rounded-md border p-2">
|
|
94
|
+
<Input
|
|
95
|
+
aria-label="Passkey name"
|
|
96
|
+
value={names[passkey.id] ?? ""}
|
|
97
|
+
onChange={(event) =>
|
|
98
|
+
setNames((current) => ({ ...current, [passkey.id]: event.target.value }))
|
|
99
|
+
}
|
|
100
|
+
/>
|
|
101
|
+
<Button
|
|
102
|
+
type="button"
|
|
103
|
+
variant="outline"
|
|
104
|
+
disabled={busy}
|
|
105
|
+
onClick={() => rename(passkey.id)}
|
|
106
|
+
>
|
|
107
|
+
Save
|
|
108
|
+
</Button>
|
|
109
|
+
<Button
|
|
110
|
+
type="button"
|
|
111
|
+
variant="ghost"
|
|
112
|
+
disabled={busy}
|
|
113
|
+
onClick={() => remove(passkey.id)}
|
|
114
|
+
>
|
|
115
|
+
Remove
|
|
116
|
+
</Button>
|
|
117
|
+
</div>
|
|
118
|
+
))}
|
|
119
|
+
{passkeys.length === 0 ? (
|
|
120
|
+
<p className="text-sm text-muted-foreground">No passkeys enrolled.</p>
|
|
121
|
+
) : null}
|
|
122
|
+
</div>
|
|
123
|
+
{notice ? (
|
|
124
|
+
<p role="status" className="mt-2 text-sm text-muted-foreground">
|
|
125
|
+
{notice}
|
|
126
|
+
</p>
|
|
127
|
+
) : null}
|
|
128
|
+
</section>
|
|
129
|
+
);
|
|
130
|
+
}
|