Skip to content

Commit 017d0c8

Browse files
committed
feat(admin): add email regex blocklist page
1 parent 5e35f3b commit 017d0c8

16 files changed

Lines changed: 559 additions & 3 deletions

File tree

libs/shared/guards/src/lib/admin-panel-guard.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export enum AdminPanelFeature {
4949
UnsubscribeFromMailingLists = 'UnsubscribeFromMailingLists',
5050
RateLimiting = 'RateLimiting',
5151
DeleteRecoveryPhone = 'DeleteRecoveryPhone',
52+
EmailBlocklist = 'EmailBlocklist',
5253
}
5354

5455
/** Enum of known user groups */
@@ -209,6 +210,10 @@ const defaultAdminPanelPermissions: Permissions = {
209210
name: 'Delete Recovery Phone',
210211
level: PermissionLevel.Admin,
211212
},
213+
[AdminPanelFeature.EmailBlocklist]: {
214+
name: 'Manage Email Blocklist',
215+
level: PermissionLevel.Admin,
216+
},
212217
};
213218

214219
/**
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
SET NAMES utf8mb4 COLLATE utf8mb4_bin;
2+
3+
CALL assertPatchLevel('189');
4+
5+
CREATE TABLE IF NOT EXISTS emailBlocklist (
6+
regex VARCHAR(768) NOT NULL,
7+
createdAt BIGINT UNSIGNED NOT NULL,
8+
UNIQUE KEY uq_emailBlocklist_regex (regex)
9+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
10+
11+
UPDATE dbMetadata SET value = '190' WHERE name = 'schema-patch-level';
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
SET NAMES utf8mb4 COLLATE utf8mb4_bin;
2+
3+
CALL assertPatchLevel('190');
4+
5+
DROP TABLE IF EXISTS emailBlocklist;
6+
7+
UPDATE dbMetadata SET value = '189' WHERE name = 'schema-patch-level';
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
"level": 189
2+
"level": 190
33
}

packages/fxa-admin-panel/src/App.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import PageRelyingParties from './components/PageRelyingParties';
1515
import PageAccountDelete from './components/PageAccountDelete';
1616
import PageRateLimiting from './components/PageRateLimiting';
1717
import PageAccountReset from './components/PageAccountReset';
18+
import PageEmailBlocklist from './components/PageEmailBlocklist';
1819

1920
const App = ({ config }: { config: IClientConfig }) => {
2021
const [guard, setGuard] = useState<AdminPanelGuard>(config.guard);
@@ -46,6 +47,12 @@ const App = ({ config }: { config: IClientConfig }) => {
4647
{guard.allow(AdminPanelFeature.AccountReset, user.group) && (
4748
<Route path="/account-reset" element={<PageAccountReset />} />
4849
)}
50+
{guard.allow(AdminPanelFeature.EmailBlocklist, user.group) && (
51+
<Route
52+
path="/email-blocklist"
53+
element={<PageEmailBlocklist />}
54+
/>
55+
)}
4956
<Route path="/permissions" element={<PagePermissions />} />
5057
</Routes>
5158
</AppLayout>

packages/fxa-admin-panel/src/components/Nav/index.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { NavLink } from 'react-router-dom';
77
import accountIcon from '../../images/icon-account.svg';
88
import keyIcon from '../../images/icon-key.svg';
99
import logsIcon from '../../images/icon-logs.svg';
10+
import emailBlocklistIcon from '../../images/icon-email-blocklist.svg';
1011
import { AdminPanelFeature } from '@fxa/shared/guards';
1112
import Guard from '../Guard';
1213

@@ -67,6 +68,21 @@ export const Nav = () => (
6768
</NavLink>
6869
</li>
6970
</Guard>
71+
<Guard features={[AdminPanelFeature.EmailBlocklist]}>
72+
<li>
73+
<NavLink
74+
to="/email-blocklist"
75+
className={({ isActive }) => getNavLinkClassName(isActive)}
76+
>
77+
<img
78+
className="inline-flex mr-2 w-4"
79+
src={emailBlocklistIcon}
80+
alt="blocklist icon"
81+
/>
82+
Email Blocklist
83+
</NavLink>
84+
</li>
85+
</Guard>
7086
<Guard features={[AdminPanelFeature.RelyingParties]}>
7187
<li>
7288
<NavLink
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import React, { useCallback, useEffect, useRef, useState } from 'react';
6+
import { adminApi } from '../../lib/api';
7+
import type { EmailBlocklistEntry } from 'fxa-admin-server/src/types';
8+
9+
const btnClass =
10+
'bg-grey-10 border-2 p-1 border-grey-100 font-small leading-6 rounded';
11+
12+
const PageEmailBlocklist = () => {
13+
const [entries, setEntries] = useState<EmailBlocklistEntry[]>([]);
14+
const [loading, setLoading] = useState(true);
15+
const [error, setError] = useState<string | null>(null);
16+
const [submitting, setSubmitting] = useState(false);
17+
const textareaRef = useRef<HTMLTextAreaElement>(null);
18+
const fileInputRef = useRef<HTMLInputElement>(null);
19+
20+
const loadEntries = useCallback(async () => {
21+
setError(null);
22+
try {
23+
const data = await adminApi.getEmailBlocklist();
24+
setEntries(
25+
[...data].sort(
26+
(a, b) => b.createdAt - a.createdAt || a.regex.localeCompare(b.regex)
27+
)
28+
);
29+
} catch (e) {
30+
setError('Failed to load blocklist.');
31+
}
32+
}, []);
33+
34+
useEffect(() => {
35+
setLoading(true);
36+
loadEntries().finally(() => setLoading(false));
37+
}, [loadEntries]);
38+
39+
const handleSubmit = async (e: React.FormEvent) => {
40+
e.preventDefault();
41+
const raw = textareaRef.current?.value ?? '';
42+
const regexes = raw
43+
.split('\n')
44+
.map((x) => x.trim())
45+
.filter((x) => x.length > 0);
46+
47+
if (regexes.length === 0) return;
48+
49+
setSubmitting(true);
50+
try {
51+
await adminApi.addEmailBlocklistEntries(regexes);
52+
if (textareaRef.current) textareaRef.current.value = '';
53+
await loadEntries();
54+
} catch (e) {
55+
window.alert(
56+
`Error: ${e instanceof Error ? e.message : 'Unknown error'}`
57+
);
58+
} finally {
59+
setSubmitting(false);
60+
}
61+
};
62+
63+
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
64+
const file = e.target.files?.[0];
65+
if (!file) return;
66+
const reader = new FileReader();
67+
reader.onload = (ev) => {
68+
const lines = (ev.target?.result as string)
69+
.split(/\r?\n/)
70+
.map((l) => l.trim().replace(/^"|"$/g, ''))
71+
.filter((l) => l.length > 0);
72+
if (textareaRef.current) textareaRef.current.value = lines.join('\n');
73+
};
74+
reader.readAsText(file);
75+
e.target.value = '';
76+
};
77+
78+
const handleDelete = async (regex: string) => {
79+
try {
80+
await adminApi.removeEmailBlocklistEntry(regex);
81+
setEntries((prev) => prev.filter((e) => e.regex !== regex));
82+
} catch {
83+
window.alert('Failed to remove entry.');
84+
}
85+
};
86+
87+
const handleDeleteAll = async () => {
88+
if (
89+
!window.confirm(
90+
`Delete all ${entries.length} blocklist entries? This cannot be undone.`
91+
)
92+
)
93+
return;
94+
try {
95+
await adminApi.deleteAllEmailBlocklistEntries();
96+
await loadEntries();
97+
} catch {
98+
window.alert('Failed to delete all entries.');
99+
}
100+
};
101+
102+
return (
103+
<>
104+
<h2 className="header-page">Email Blocklist</h2>
105+
<ul className="list-disc list-inside mb-4">
106+
<li>
107+
Blocks registration for emails matching any pattern. Does not affect
108+
existing accounts.
109+
</li>
110+
<li>
111+
Patterns are regexes matched against the full address. Mostly used for
112+
domains (e.g. <code>@evildoge\.example\.com$</code>).
113+
</li>
114+
<li>
115+
Use <code>$</code> to anchor to the end of the address — without it,{' '}
116+
<code>@mozilla\.com</code> would also match{' '}
117+
<code>@mozilla\.com.haxor.net</code>.
118+
</li>
119+
<li>
120+
Enter one pattern per line, or upload a CSV/TXT file (one entry per
121+
line). Duplicates are silently ignored.
122+
</li>
123+
<li>Blocked attempts are logged and counted in statsd.</li>
124+
<li>
125+
Changes propagate to auth-server within 5 minutes. Keep the list small
126+
(hundreds of entries, not thousands) to avoid slowing registration.
127+
</li>
128+
</ul>
129+
<p className="mb-4">
130+
<strong>
131+
⚠️ Avoid complex patterns with nested quantifiers (e.g.{' '}
132+
<code>{'(a+)+'}</code>).
133+
</strong>{' '}
134+
These can cause slow matching on every registration attempt. Stick to
135+
simple domain patterns like <code>@domain\.com$</code>.
136+
</p>
137+
138+
<form method="post" onSubmit={handleSubmit}>
139+
<textarea
140+
ref={textareaRef}
141+
data-testid="blocklist-input"
142+
name="regexList"
143+
rows={8}
144+
cols={60}
145+
className="border-2 block"
146+
placeholder={'@evildoge\\.example\\.com$\n@haxor\\.net$'}
147+
/>
148+
<br />
149+
<input
150+
ref={fileInputRef}
151+
type="file"
152+
accept=".csv,.txt"
153+
className="hidden"
154+
onChange={handleFileChange}
155+
/>
156+
<button
157+
type="button"
158+
className={btnClass}
159+
onClick={() => fileInputRef.current?.click()}
160+
>
161+
📂 Load from file…
162+
</button>
163+
&nbsp;
164+
<button
165+
type="submit"
166+
data-testid="blocklist-add-btn"
167+
className="bg-green-50 border-2 p-1 border-green-300 font-small leading-6 rounded"
168+
disabled={submitting}
169+
>
170+
➕ Add Entries
171+
</button>
172+
</form>
173+
174+
<hr className="my-4" />
175+
176+
<div className="flex items-center justify-between mb-2">
177+
<h2 className="header-page">Current Blocklist</h2>
178+
{entries.length > 0 && (
179+
<button
180+
className="bg-red-50 border-2 p-1 border-red-300 font-small leading-6 rounded"
181+
onClick={handleDeleteAll}
182+
>
183+
🗑️ Delete All
184+
</button>
185+
)}
186+
</div>
187+
188+
{loading && <p>Loading…</p>}
189+
{error && <p className="text-red-600">{error}</p>}
190+
{!loading && !error && entries.length === 0 && (
191+
<p className="result-grey">No entries yet.</p>
192+
)}
193+
{entries.length > 0 && (
194+
<table className="w-full border-collapse text-sm">
195+
<thead>
196+
<tr className="bg-grey-50">
197+
<th className="text-left p-2 border border-grey-100">Pattern</th>
198+
<th className="text-left p-2 border border-grey-100">Added</th>
199+
<th className="p-2 border border-grey-100"></th>
200+
</tr>
201+
</thead>
202+
<tbody>
203+
{entries.map((entry) => (
204+
<tr key={entry.regex} className="hover:bg-grey-10">
205+
<td className="p-2 border border-grey-100 font-mono">
206+
{entry.regex}
207+
</td>
208+
<td className="p-2 border border-grey-100 whitespace-nowrap">
209+
{new Date(entry.createdAt).toISOString()}
210+
</td>
211+
<td className="p-2 border border-grey-100 text-center">
212+
<button
213+
data-testid={`delete-${entry.regex}`}
214+
className={btnClass}
215+
onClick={() => handleDelete(entry.regex)}
216+
>
217+
🗑️ Remove
218+
</button>
219+
</td>
220+
</tr>
221+
))}
222+
</tbody>
223+
</table>
224+
)}
225+
</>
226+
);
227+
};
228+
229+
export default PageEmailBlocklist;
Lines changed: 10 additions & 0 deletions
Loading

packages/fxa-admin-panel/src/lib/api.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
AccountDeleteResponse,
1515
AccountDeleteTaskStatus,
1616
AccountResetResponse,
17+
EmailBlocklistEntry,
1718
} from 'fxa-admin-server/src/types';
1819

1920
function baseUrl() {
@@ -249,4 +250,28 @@ export const adminApi = {
249250
{ method: 'DELETE' }
250251
);
251252
},
253+
254+
// ---- Email blocklist ----
255+
256+
getEmailBlocklist(): Promise<EmailBlocklistEntry[]> {
257+
return apiFetch('/api/email-blocklist');
258+
},
259+
260+
addEmailBlocklistEntries(regexes: string[]): Promise<{ ok: boolean }> {
261+
return apiFetch('/api/email-blocklist/add', {
262+
method: 'POST',
263+
body: JSON.stringify({ regexes }),
264+
});
265+
},
266+
267+
removeEmailBlocklistEntry(regex: string): Promise<{ removed: boolean }> {
268+
return apiFetch('/api/email-blocklist', {
269+
method: 'DELETE',
270+
body: JSON.stringify({ regex }),
271+
});
272+
},
273+
274+
deleteAllEmailBlocklistEntries(): Promise<{ ok: boolean }> {
275+
return apiFetch('/api/email-blocklist/all', { method: 'DELETE' });
276+
},
252277
};

0 commit comments

Comments
 (0)