@dbx-tools/ui-email 0.6.90 → 0.6.92

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 CHANGED
@@ -17,8 +17,6 @@ Key features:
17
17
  - dbx-tools branding by default, with consumer brand overrides available.
18
18
  - Recipient parsing, address display, and attachment-label helpers that mirror
19
19
  server expectations.
20
- - `AuthGate` sign-in screen for the email one-time-code auth plugin, branded from
21
- the shared brand context and shaped for platform autofill.
22
20
  - Styles wired to the AppKit UI/Tailwind foundation so host apps do not need a
23
21
  separate email component theme.
24
22
 
@@ -116,49 +114,15 @@ const files = attachmentNames(message.attachments);
116
114
  The helpers keep free-text recipient parsing and attachment labels consistent
117
115
  across approval, compose, and custom UI surfaces.
118
116
 
119
- ## Gate An App Behind An Email Code
120
-
121
- ```tsx
122
- import { AuthGate } from "@dbx-tools/ui-email/react/auth-gate";
123
-
124
- <AuthGate>
125
- <App />
126
- </AuthGate>;
127
- ```
128
-
129
- `AuthGate` is the sign-in screen for an app fronted by the `@dbx-tools/email`
130
- auth plugin - typically one published through
131
- [`@dbx-tools/tunnel`](../../node/tunnel), where the hosting platform's own
132
- identity-aware proxy is not in the request path. It calls the plugin's
133
- `/api/email/auth/*` routes: on mount it checks `status`, renders `children`
134
- straight through when the gate is off or a session already exists, and otherwise
135
- runs the email -> code flow, revealing `children` once a verified code sets the
136
- session cookie.
137
-
138
- It holds no token: the session lives in an HttpOnly cookie the browser sends
139
- automatically. `title` and `description` override the default copy, which
140
- otherwise names the app from the brand context.
141
-
142
- Before requesting a code, the email field requires exactly one address parsed
143
- and validated by `@dbx-tools/shared-core`'s `net.parseEmails` + `net.isEmail`.
144
- Malformed or multi-address input stays in the browser and never reaches the auth
145
- endpoint.
146
-
147
- The code field carries `autocomplete="one-time-code"`, which is what lets iOS,
148
- Android, and Safari offer the code straight from the notification. That only pays
149
- off while the email keeps the conventional `Your verification code is: / <code>`
150
- shape the gate sends, so change one and check the other.
151
-
152
117
  ## Modules
153
118
 
154
119
  - `./react` - `EmailPreview`, `EmailApprovalCard`, `EmailComposeView`,
155
- `EmailBody`, `AuthGate`, address/attachment helpers, shared email message
156
- types, and prop types.
157
- - `./react/auth-gate` - focused `AuthGate` entry that keeps email rendering and
158
- compose dependencies out of a public login bundle.
120
+ `EmailBody`, address/attachment helpers, shared email message types, and prop
121
+ types.
159
122
  - `./styles.css` - Tailwind/AppKit style entrypoint for the email components.
160
123
 
161
124
  Pair this package with [`@dbx-tools/email`](../../node/email) for SMTP or
162
125
  outbox delivery, [`@dbx-tools/shared-email-template`](../../shared/email-template)
163
126
  for the universal presentation, and [`@dbx-tools/shared-email`](../../shared/email)
164
- for schema validation in client/server boundaries.
127
+ for schema validation in client/server boundaries. Passwordless login and
128
+ passkey management live in [`@dbx-tools/ui-auth`](../auth).
package/index.ts CHANGED
@@ -2,12 +2,11 @@
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
+ export const PACKAGE_IDENTIFIER = "@dbx-tools/ui-email";
6
6
  export * as reactEmailApprovalCard from "./src/react/email-approval-card.tsx";
7
7
  export * as reactEmailBody from "./src/react/email-body.tsx";
8
8
  export * as reactEmailCompose from "./src/react/email-compose.tsx";
9
9
  export * as reactFields from "./src/react/fields.ts";
10
- export type { AuthGateProps } from "./src/react/auth-gate.tsx";
11
10
  export { EmailPreview, EmailApprovalCard } from "./src/react/email-approval-card.tsx";
12
11
  export type { EmailPreviewProps, EmailApprovalCardProps } from "./src/react/email-approval-card.tsx";
13
12
  export { EmailBody } from "./src/react/email-body.tsx";
package/package.json CHANGED
@@ -24,11 +24,11 @@
24
24
  "typescript": "^5.9.3"
25
25
  },
26
26
  "dependencies": {
27
- "@dbx-tools/shared-core": "0.6.90",
28
- "@dbx-tools/shared-email": "0.6.90",
29
- "@dbx-tools/shared-email-template": "0.6.90",
30
- "@dbx-tools/ui-appkit": "0.6.90",
31
- "@dbx-tools/ui-branding": "0.6.90",
27
+ "@dbx-tools/shared-core": "0.6.92",
28
+ "@dbx-tools/shared-email": "0.6.92",
29
+ "@dbx-tools/shared-email-template": "0.6.92",
30
+ "@dbx-tools/ui-appkit": "0.6.92",
31
+ "@dbx-tools/ui-branding": "0.6.92",
32
32
  "lucide-react": "^0.554.0",
33
33
  "react": "^19.2.4",
34
34
  "react-dom": "^19.2.4"
@@ -37,11 +37,10 @@
37
37
  "publishConfig": {
38
38
  "access": "public"
39
39
  },
40
- "version": "0.6.90",
40
+ "version": "0.6.92",
41
41
  "type": "module",
42
42
  "exports": {
43
43
  "./react": "./src/react/index.ts",
44
- "./react/auth-gate": "./src/react/auth-gate.tsx",
45
44
  "./styles.css": "./src/styles.css",
46
45
  "./package.json": "./package.json"
47
46
  },
@@ -1,12 +1,10 @@
1
1
  // React surface for `@dbx-tools/ui-email`: a read-only Approve / Deny card for
2
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.
3
+ // editable compose view for use outside a chat bubble. The email components
4
+ // share `./fields` and `./email-body`, so a drafted message renders identically
5
+ // across them. Styled with AppKit tokens.
7
6
 
8
7
  export type { EmailAttachment, EmailMessage } from "@dbx-tools/shared-email";
9
- export { AuthGate, type AuthGateProps } from "./auth-gate.tsx";
10
8
  export {
11
9
  EmailApprovalCard,
12
10
  EmailPreview,
@@ -1,237 +0,0 @@
1
- import { net, string } from "@dbx-tools/shared-core";
2
- import type { AuthStatus } from "@dbx-tools/shared-email";
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
- /**
8
- * Email one-time-code sign-in gate for an app fronted by the
9
- * `@dbx-tools/tunnel` `authGate` plugin - an app reachable on the public internet, where the hosting
10
- * platform's own identity-aware proxy is not in the request path.
11
- *
12
- * Wrap the app in `<AuthGate>...</AuthGate>`. It calls the plugin's
13
- * `/api/email/auth/*` routes: on mount it checks `status`; if the gate is
14
- * disabled or the caller already has a session it renders `children`
15
- * immediately, otherwise it shows the email -> code flow and reveals `children`
16
- * only after a verified code sets the session cookie.
17
- *
18
- * Presentational + fetch only: the session lives in an HttpOnly cookie the
19
- * browser sends automatically, so this component holds no token. Anti-enumeration
20
- * is server-side (every request-code call reports success), so the UI always
21
- * advances to the code step after the code is requested.
22
- *
23
- * Branding comes from the repo-wide `@dbx-tools/ui-branding` context, so the
24
- * sign-in screen carries the host app's mark and name - the same brand the gate's
25
- * code email is themed with - instead of a generic icon and a hardcoded product
26
- * name. With no `BrandProvider` above it, the dbx-tools default context applies.
27
- */
28
-
29
- /** Base path the email auth routes are mounted under. */
30
- const AUTH_BASE = "/api/email/auth";
31
-
32
- type Phase = "loading" | "email" | "code" | "authed" | "open";
33
-
34
- async function postJson<T>(path: string, body: unknown): Promise<T> {
35
- const res = await fetch(path, {
36
- method: "POST",
37
- headers: { "content-type": "application/json" },
38
- body: JSON.stringify(body),
39
- credentials: "same-origin",
40
- });
41
- if (!res.ok) throw new Error(`Authentication request failed (${res.status})`);
42
- return (await res.json()) as T;
43
- }
44
-
45
- /** Props for {@link AuthGate}. */
46
- export interface AuthGateProps {
47
- /** The app to reveal once the caller is authenticated (or the gate is off). */
48
- children: ReactNode;
49
- /** Optional heading shown above the login form. */
50
- title?: string;
51
- /** Optional sub-text shown under the heading. */
52
- description?: string;
53
- }
54
-
55
- /**
56
- * Gate `children` behind the email-OTP login flow. Renders nothing meaningful
57
- * until the initial `status` check resolves; then either the app (authed / gate
58
- * off) or the two-step login.
59
- */
60
- export function AuthGate({ children, title, description }: AuthGateProps): ReactNode {
61
- const { context: brand } = useBrand();
62
- const [phase, setPhase] = useState<Phase>("loading");
63
- const [email, setEmail] = useState("");
64
- const [code, setCode] = useState("");
65
- const [busy, setBusy] = useState(false);
66
- const [notice, setNotice] = useState<string | null>(null);
67
-
68
- // On mount, ask whether the gate is even on and whether we're already in.
69
- useEffect(() => {
70
- let cancelled = false;
71
- void fetch(`${AUTH_BASE}/status`, { credentials: "same-origin" })
72
- .then((res) => {
73
- if (!res.ok) throw new Error(`Authentication status failed (${res.status})`);
74
- return res.json() as Promise<AuthStatus>;
75
- })
76
- .then((status) => {
77
- if (cancelled) return;
78
- if (!status.enabled) setPhase("open");
79
- else setPhase(status.authenticated ? "authed" : "email");
80
- })
81
- .catch(() => {
82
- // A failed status check shouldn't hard-lock the UI; show the login form.
83
- if (!cancelled) setPhase("email");
84
- });
85
- return () => {
86
- cancelled = true;
87
- };
88
- }, []);
89
-
90
- const requestCode = useCallback(
91
- async (e: FormEvent) => {
92
- e.preventDefault();
93
- if (busy) return;
94
- const addresses = net.parseEmails(email);
95
- const normalizedEmail = addresses[0];
96
- if (addresses.length !== 1 || !normalizedEmail || !net.isEmail(normalizedEmail)) {
97
- setNotice("Enter a valid email address.");
98
- return;
99
- }
100
- setBusy(true);
101
- setNotice(null);
102
- setEmail(normalizedEmail);
103
- try {
104
- const result = await postJson<{ ok: true; retryAfter?: number }>(`${AUTH_BASE}/request`, {
105
- email: normalizedEmail,
106
- });
107
- // Anti-enumeration: always advance to the code step. Surface only a
108
- // rate-limit cooldown, which leaks no allow-list state.
109
- setNotice(
110
- result.retryAfter
111
- ? `Too many requests. Try again in ${string.pluralize(result.retryAfter, "second")}.`
112
- : "If an account exists for that email address, a verification code is on its way.",
113
- );
114
- setPhase("code");
115
- } catch {
116
- setNotice("Unable to request a verification code. Try again.");
117
- } finally {
118
- setBusy(false);
119
- }
120
- },
121
- [email, busy],
122
- );
123
-
124
- const verifyCode = useCallback(
125
- async (e: FormEvent) => {
126
- e.preventDefault();
127
- if (!code.trim() || busy) return;
128
- setBusy(true);
129
- setNotice(null);
130
- try {
131
- const result = await postJson<{ ok: boolean; retryAfter?: number }>(`${AUTH_BASE}/verify`, {
132
- email: email.trim(),
133
- code: code.trim(),
134
- });
135
- if (result.ok) {
136
- setPhase("authed");
137
- } else {
138
- setNotice(
139
- result.retryAfter
140
- ? `Too many attempts. Try again in ${string.pluralize(result.retryAfter, "second")}.`
141
- : "That verification code is incorrect or has expired.",
142
- );
143
- }
144
- } catch {
145
- setNotice("Unable to verify the code. Try again.");
146
- } finally {
147
- setBusy(false);
148
- }
149
- },
150
- [code, email, busy],
151
- );
152
-
153
- if (phase === "authed" || phase === "open") return <>{children}</>;
154
- if (phase === "loading") return null;
155
-
156
- return (
157
- <div className="flex min-h-screen items-center justify-center bg-background p-6">
158
- <div className="w-full max-w-sm rounded-lg border border-border bg-card p-6 shadow-sm">
159
- <div className="mb-4 flex items-center gap-2 text-foreground">
160
- <BrandIcon className="size-5" alt="" aria-hidden />
161
- {/*
162
- Names the app, which is the convention for a sign-in screen and the
163
- reassurance a recipient checks the code against. `brand.name` is the
164
- same value that names the app in the code email.
165
- */}
166
- <h1 className="text-lg font-semibold">{title ?? `Sign in to ${brand.name}`}</h1>
167
- </div>
168
- <p className="mb-4 text-sm text-muted-foreground">
169
- {description ??
170
- (phase === "code"
171
- ? "Enter the 6-digit verification code sent to your email address."
172
- : "Enter your email address and we will send you a verification code.")}
173
- </p>
174
-
175
- {phase === "email" ? (
176
- <form key="email" noValidate onSubmit={requestCode} className="space-y-3">
177
- <Input
178
- type="email"
179
- name="email"
180
- autoComplete="email"
181
- aria-label="Email address"
182
- placeholder="you@example.com"
183
- value={email}
184
- onChange={(e) => setEmail(e.target.value)}
185
- required
186
- />
187
- <Button type="submit" disabled={busy} className="w-full">
188
- {busy ? "Sending…" : "Send verification code"}
189
- </Button>
190
- </form>
191
- ) : (
192
- <form key="code" onSubmit={verifyCode} className="space-y-3">
193
- {/*
194
- `autoComplete="one-time-code"` is what lets iOS/Android/Safari offer
195
- the code straight from the notification, and it only pays off when
196
- the email keeps the conventional "Your verification code is: /
197
- <code>" shape the gate sends. `inputMode="numeric"` raises the
198
- number pad without rejecting a paste.
199
- */}
200
- <Input
201
- type="text"
202
- name="code"
203
- inputMode="numeric"
204
- autoComplete="one-time-code"
205
- aria-label="Verification code"
206
- placeholder="6-digit verification code"
207
- maxLength={6}
208
- value={code}
209
- onChange={(e) => setCode(e.target.value)}
210
- required
211
- />
212
- <Button type="submit" disabled={busy} className="w-full">
213
- {busy ? "Verifying…" : "Continue"}
214
- </Button>
215
- <button
216
- type="button"
217
- className="w-full text-center text-xs text-muted-foreground underline"
218
- onClick={() => {
219
- setPhase("email");
220
- setCode("");
221
- setNotice(null);
222
- }}
223
- >
224
- Use a different email address
225
- </button>
226
- </form>
227
- )}
228
-
229
- {notice ? (
230
- <p role="status" aria-live="polite" className="mt-3 text-xs text-muted-foreground">
231
- {notice}
232
- </p>
233
- ) : null}
234
- </div>
235
- </div>
236
- );
237
- }