@dbx-tools/ui-email 0.6.41 → 0.6.42

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/index.ts CHANGED
@@ -2,10 +2,12 @@
2
2
  // Regenerated from the exporting modules in ./src.
3
3
  // Hand edits are overwritten on the next watch; this file is read-only.
4
4
 
5
+ export * as reactAuthGate from "./src/react/auth-gate.tsx";
5
6
  export * as reactEmailApprovalCard from "./src/react/email-approval-card.tsx";
6
7
  export * as reactEmailBody from "./src/react/email-body.tsx";
7
8
  export * as reactEmailCompose from "./src/react/email-compose.tsx";
8
9
  export * as reactFields from "./src/react/fields.ts";
10
+ export type { AuthGateProps } from "./src/react/auth-gate.tsx";
9
11
  export { EmailPreview, EmailApprovalCard } from "./src/react/email-approval-card.tsx";
10
12
  export type { EmailPreviewProps, EmailApprovalCardProps } from "./src/react/email-approval-card.tsx";
11
13
  export { EmailBody } from "./src/react/email-body.tsx";
package/package.json CHANGED
@@ -24,9 +24,9 @@
24
24
  "typescript": "^5.9.3"
25
25
  },
26
26
  "dependencies": {
27
- "@dbx-tools/shared-core": "0.6.41",
28
- "@dbx-tools/shared-email": "0.6.41",
29
- "@dbx-tools/ui-appkit": "0.6.41",
27
+ "@dbx-tools/shared-core": "0.6.42",
28
+ "@dbx-tools/shared-email": "0.6.42",
29
+ "@dbx-tools/ui-appkit": "0.6.42",
30
30
  "lucide-react": "^0.554.0",
31
31
  "react": "^19.2.4",
32
32
  "react-dom": "^19.2.4",
@@ -36,7 +36,7 @@
36
36
  "publishConfig": {
37
37
  "access": "public"
38
38
  },
39
- "version": "0.6.41",
39
+ "version": "0.6.42",
40
40
  "type": "module",
41
41
  "exports": {
42
42
  "./react": "./src/react/index.ts",
@@ -0,0 +1,190 @@
1
+ import { Button, Input } from "@dbx-tools/ui-appkit/react";
2
+ import type { AuthStatus } from "@dbx-tools/shared-email";
3
+ import { MailIcon } from "lucide-react";
4
+ import { type FormEvent, type ReactNode, useCallback, useEffect, useState } from "react";
5
+
6
+ /**
7
+ * Email-OTP login gate for an AppKit app fronted by the `@dbx-tools/email` auth
8
+ * plugin (an app exposed publicly, e.g. through a portr tunnel that bypasses the
9
+ * Databricks OAuth proxy).
10
+ *
11
+ * Wrap the app in `<AuthGate>...</AuthGate>`. It calls the plugin's
12
+ * `/api/email/auth/*` routes: on mount it checks `status`; if the gate is
13
+ * disabled or the caller already has a session it renders `children`
14
+ * immediately, otherwise it shows the email -> code flow and reveals `children`
15
+ * only after a verified code sets the session cookie.
16
+ *
17
+ * Presentational + fetch only: the session lives in an HttpOnly cookie the
18
+ * browser sends automatically, so this component holds no token. Anti-enumeration
19
+ * is server-side (every request-code call reports success), so the UI always
20
+ * advances to the code step after "send code".
21
+ */
22
+
23
+ /** Base path the email auth routes are mounted under. */
24
+ const AUTH_BASE = "/api/email/auth";
25
+
26
+ type Phase = "loading" | "email" | "code" | "authed" | "open";
27
+
28
+ async function postJson<T>(path: string, body: unknown): Promise<T> {
29
+ const res = await fetch(path, {
30
+ method: "POST",
31
+ headers: { "content-type": "application/json" },
32
+ body: JSON.stringify(body),
33
+ credentials: "same-origin",
34
+ });
35
+ return (await res.json()) as T;
36
+ }
37
+
38
+ /** Props for {@link AuthGate}. */
39
+ export interface AuthGateProps {
40
+ /** The app to reveal once the caller is authenticated (or the gate is off). */
41
+ children: ReactNode;
42
+ /** Optional heading shown above the login form. */
43
+ title?: string;
44
+ /** Optional sub-text shown under the heading. */
45
+ description?: string;
46
+ }
47
+
48
+ /**
49
+ * Gate `children` behind the email-OTP login flow. Renders nothing meaningful
50
+ * until the initial `status` check resolves; then either the app (authed / gate
51
+ * off) or the two-step login.
52
+ */
53
+ export function AuthGate({ children, title, description }: AuthGateProps): ReactNode {
54
+ const [phase, setPhase] = useState<Phase>("loading");
55
+ const [email, setEmail] = useState("");
56
+ const [code, setCode] = useState("");
57
+ const [busy, setBusy] = useState(false);
58
+ const [notice, setNotice] = useState<string | null>(null);
59
+
60
+ // On mount, ask whether the gate is even on and whether we're already in.
61
+ useEffect(() => {
62
+ let cancelled = false;
63
+ void fetch(`${AUTH_BASE}/status`, { credentials: "same-origin" })
64
+ .then((res) => res.json() as Promise<AuthStatus>)
65
+ .then((status) => {
66
+ if (cancelled) return;
67
+ if (!status.enabled) setPhase("open");
68
+ else setPhase(status.authenticated ? "authed" : "email");
69
+ })
70
+ .catch(() => {
71
+ // A failed status check shouldn't hard-lock the UI; show the login form.
72
+ if (!cancelled) setPhase("email");
73
+ });
74
+ return () => {
75
+ cancelled = true;
76
+ };
77
+ }, []);
78
+
79
+ const requestCode = useCallback(
80
+ async (e: FormEvent) => {
81
+ e.preventDefault();
82
+ if (!email.trim() || busy) return;
83
+ setBusy(true);
84
+ setNotice(null);
85
+ try {
86
+ const result = await postJson<{ ok: true; retryAfter?: number }>(`${AUTH_BASE}/request`, {
87
+ email: email.trim(),
88
+ });
89
+ // Anti-enumeration: always advance to the code step. Surface only a
90
+ // rate-limit cooldown, which leaks no allow-list state.
91
+ setNotice(
92
+ result.retryAfter
93
+ ? `Please wait ${result.retryAfter}s before requesting another code.`
94
+ : "If that address is allowed, a code is on its way.",
95
+ );
96
+ setPhase("code");
97
+ } finally {
98
+ setBusy(false);
99
+ }
100
+ },
101
+ [email, busy],
102
+ );
103
+
104
+ const verifyCode = useCallback(
105
+ async (e: FormEvent) => {
106
+ e.preventDefault();
107
+ if (!code.trim() || busy) return;
108
+ setBusy(true);
109
+ setNotice(null);
110
+ try {
111
+ const result = await postJson<{ ok: boolean; retryAfter?: number }>(`${AUTH_BASE}/verify`, {
112
+ email: email.trim(),
113
+ code: code.trim(),
114
+ });
115
+ if (result.ok) {
116
+ setPhase("authed");
117
+ } else {
118
+ setNotice(
119
+ result.retryAfter
120
+ ? `Too many attempts. Wait ${result.retryAfter}s and request a new code.`
121
+ : "That code didn't match. Check it or request a new one.",
122
+ );
123
+ }
124
+ } finally {
125
+ setBusy(false);
126
+ }
127
+ },
128
+ [code, email, busy],
129
+ );
130
+
131
+ if (phase === "authed" || phase === "open") return <>{children}</>;
132
+ if (phase === "loading") return null;
133
+
134
+ return (
135
+ <div className="flex min-h-screen items-center justify-center bg-background p-6">
136
+ <div className="w-full max-w-sm rounded-lg border border-border bg-card p-6 shadow-sm">
137
+ <div className="mb-4 flex items-center gap-2 text-foreground">
138
+ <MailIcon className="size-5" aria-hidden />
139
+ <h1 className="text-lg font-semibold">{title ?? "Sign in"}</h1>
140
+ </div>
141
+ <p className="mb-4 text-sm text-muted-foreground">
142
+ {description ?? "Enter your email to receive a one-time sign-in code."}
143
+ </p>
144
+
145
+ {phase === "email" ? (
146
+ <form onSubmit={requestCode} className="space-y-3">
147
+ <Input
148
+ type="email"
149
+ autoComplete="email"
150
+ placeholder="you@company.com"
151
+ value={email}
152
+ onChange={(e) => setEmail(e.target.value)}
153
+ required
154
+ />
155
+ <Button type="submit" disabled={busy} className="w-full">
156
+ {busy ? "Sending…" : "Send code"}
157
+ </Button>
158
+ </form>
159
+ ) : (
160
+ <form onSubmit={verifyCode} className="space-y-3">
161
+ <Input
162
+ inputMode="numeric"
163
+ autoComplete="one-time-code"
164
+ placeholder="6-digit code"
165
+ value={code}
166
+ onChange={(e) => setCode(e.target.value)}
167
+ required
168
+ />
169
+ <Button type="submit" disabled={busy} className="w-full">
170
+ {busy ? "Verifying…" : "Verify"}
171
+ </Button>
172
+ <button
173
+ type="button"
174
+ className="w-full text-center text-xs text-muted-foreground underline"
175
+ onClick={() => {
176
+ setPhase("email");
177
+ setCode("");
178
+ setNotice(null);
179
+ }}
180
+ >
181
+ Use a different email
182
+ </button>
183
+ </form>
184
+ )}
185
+
186
+ {notice ? <p className="mt-3 text-xs text-muted-foreground">{notice}</p> : null}
187
+ </div>
188
+ </div>
189
+ );
190
+ }
@@ -1,10 +1,12 @@
1
1
  // React surface for `@dbx-tools/ui-email`: a read-only Approve / Deny card for
2
- // the `send_email` tool's approval flow, the field preview it wraps, and a
3
- // standard editable compose view for use outside a chat bubble. All three share
4
- // `./fields` and `./email-body`, so a drafted message renders identically across
5
- // them. Styled with AppKit tokens.
2
+ // the `send_email` tool's approval flow, the field preview it wraps, a standard
3
+ // editable compose view for use outside a chat bubble, and the `AuthGate`
4
+ // email-OTP login screen for an app fronted by the email auth plugin. The email
5
+ // components share `./fields` and `./email-body`, so a drafted message renders
6
+ // identically across them. Styled with AppKit tokens.
6
7
 
7
8
  export type { EmailAttachment, EmailMessage } from "@dbx-tools/shared-email";
9
+ export { AuthGate, type AuthGateProps } from "./auth-gate.tsx";
8
10
  export {
9
11
  EmailApprovalCard,
10
12
  EmailPreview,