@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 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,2 @@
1
+ export { AuthGate, type AuthGateProps } from "./auth-gate.tsx";
2
+ export { PasskeyManager, type PasskeyManagerProps } from "./passkey-manager.tsx";
@@ -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
+ }