@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 +4 -40
- package/index.ts +1 -2
- package/package.json +6 -7
- package/src/react/index.ts +3 -5
- package/src/react/auth-gate.tsx +0 -237
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`,
|
|
156
|
-
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
|
|
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.
|
|
28
|
-
"@dbx-tools/shared-email": "0.6.
|
|
29
|
-
"@dbx-tools/shared-email-template": "0.6.
|
|
30
|
-
"@dbx-tools/ui-appkit": "0.6.
|
|
31
|
-
"@dbx-tools/ui-branding": "0.6.
|
|
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.
|
|
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
|
},
|
package/src/react/index.ts
CHANGED
|
@@ -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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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,
|
package/src/react/auth-gate.tsx
DELETED
|
@@ -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
|
-
}
|