Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion server/blocklist-permissions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,23 @@ test('staff can read and add blocklist entries, recorded under their own email',
assert.equal(f.created[0].value, '1.2.3.4');
});

test('staff cannot edit or remove blocklist entries', async t => {
test('staff can edit and remove any blocklist entry', async t => {
const f = await fixture(t, 'staff');
const patched = await f.send('PATCH', '/api/entities/BlocklistEntry/b1', { value: 'x.example', reason: 'Spam' });
assert.equal(patched.statusCode, 200, patched.body);
assert.equal(patched.json().data.value, 'x.example');
assert.equal((await f.send('DELETE', '/api/entities/BlocklistEntry/b1')).statusCode, 200);
});

test('editing a blocklist entry cannot rewrite who added it', async t => {
const f = await fixture(t, 'staff');
const patched = await f.send('PATCH', '/api/entities/BlocklistEntry/b1', { value: 'x.example', added_by: 'forged@example.test' });
assert.equal(patched.statusCode, 200, patched.body);
assert.equal(patched.json().data.added_by, undefined);
});

test('regular users cannot edit or remove blocklist entries', async t => {
const f = await fixture(t, 'user');
assert.equal((await f.send('PATCH', '/api/entities/BlocklistEntry/b1', { value: 'x' })).statusCode, 403);
assert.equal((await f.send('DELETE', '/api/entities/BlocklistEntry/b1')).statusCode, 403);
});
Expand Down
10 changes: 6 additions & 4 deletions server/entity-routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@ const PUBLIC_SETTINGS = new Set([
'requests_locked', 'requests_locked_message',
]);

// Staff can create, edit and delete these, just like admins.
const STAFF_WRITABLE = new Set([
'AbuseReport',
]);

// Staff can add blocklist entries but only admins can edit or remove them.
const STAFF_CREATABLE = new Set([
'BlocklistEntry',
]);

// Entities staff may create but not edit or delete. Currently none.
const STAFF_CREATABLE = new Set([]);

function isElevated(user) {
return user?.role === 'admin' || user?.role === 'staff';
}
Expand Down Expand Up @@ -158,6 +158,8 @@ export async function registerEntityRoutes(app) {
if (user.role === 'user' && request.params.entity === 'ApiToken') {
for (const key of Object.keys(body)) if (!['revoked', 'revoked_by'].includes(key)) delete body[key];
}
// Editing an entry doesn't change who originally added it.
if (request.params.entity === 'BlocklistEntry') delete body.added_by;
const updated = await store.update(request.params.entity, request.params.id, body);
return { data: redact(user, request.params.entity, updated) };
});
Expand Down
109 changes: 104 additions & 5 deletions src/components/BlocklistManager.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { toast } from 'sonner';
import { Loader2, Plus, Trash2, ShieldOff } from 'lucide-react';
import { Loader2, Plus, Trash2, ShieldOff, Pencil, Check, X } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';

Expand All @@ -22,13 +22,16 @@ const TYPE_COLORS = {
};

export default function BlocklistManager({ currentUser }) {const { t } = useTranslation();
// Staff can add entries; removing them stays admin-only (enforced server-side too).
const canRemove = currentUser?.role === 'admin';
// Staff and admins can add, edit and remove entries (enforced server-side too).
const canManage = ['admin', 'staff'].includes(currentUser?.role);
const [entries, setEntries] = useState([]);
const [loading, setLoading] = useState(true);
const [adding, setAdding] = useState(false);
const [deletingId, setDeletingId] = useState(null);
const [form, setForm] = useState({ value: '', is_regex: false, record_type: 'ANY', reason: '', notes: '' });
const [editingId, setEditingId] = useState(null);
const [editForm, setEditForm] = useState(null);
const [saving, setSaving] = useState(false);

const load = async () => {
setLoading(true);
Expand Down Expand Up @@ -80,6 +83,45 @@ export default function BlocklistManager({ currentUser }) {const { t } = useTran
}
};

const startEdit = (entry) => {
setEditingId(entry.id);
setEditForm({
value: entry.value || '',
is_regex: !!entry.is_regex,
record_type: entry.record_type || 'ANY',
reason: entry.reason || '',
notes: entry.notes || ''
});
};

const cancelEdit = () => {
setEditingId(null);
setEditForm(null);
};

const saveEdit = async (e) => {
e.preventDefault();
if (!editForm?.value.trim()) return;
setSaving(true);
try {
const changes = {
value: editForm.is_regex ? editForm.value.trim() : editForm.value.trim().toLowerCase(),
is_regex: editForm.is_regex,
record_type: editForm.record_type || 'ANY',
reason: editForm.reason.trim(),
notes: editForm.notes.trim()
};
const updated = await rootminster.entities.BlocklistEntry.update(editingId, changes);
setEntries((prev) => prev.map((row) => row.id === editingId ? { ...row, ...changes, ...(updated || {}) } : row));
toast.success(t("operational.blocklist_manager.entry_updated"));
cancelEdit();
} catch {
toast.error(t("operational.blocklist_manager.failed_to_update_entry"));
} finally {
setSaving(false);
}
};

return (
<div className="space-y-6">
{/* Add form */}
Expand Down Expand Up @@ -173,7 +215,54 @@ export default function BlocklistManager({ currentUser }) {const { t } = useTran
<p className="text-muted-foreground text-sm text-center py-8">{t("operational.blocklist_manager.no_blocklist_entries_yet_d136ea")}</p> :

<div className="space-y-2">
{entries.map((entry) =>
{entries.map((entry) => editingId === entry.id && editForm ?
<form key={entry.id} onSubmit={saveEdit}
className="space-y-3 p-3 bg-muted/40 border border-primary/40 rounded-lg">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="sm:col-span-2 space-y-1.5">
<Label className="text-xs">{t("operational.blocklist_manager.value_to_block_600ed9")}</Label>
<Input value={editForm.value} required autoFocus
onChange={(e) => setEditForm((f) => ({ ...f, value: e.target.value }))} />
</div>
<div className="space-y-1.5">
<Label className="text-xs">{t("operational.blocklist_manager.record_type_bdd640")}</Label>
<Select value={editForm.record_type} onValueChange={(v) => setEditForm((f) => ({ ...f, record_type: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{RECORD_TYPES.map((type) =>
<SelectItem key={type} value={type}>{type === 'ANY' ? 'ANY (all types)' : type}</SelectItem>
)}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">{t("operational.blocklist_manager.reason_shown_to_user_1b7ed3")}</Label>
<Input value={editForm.reason}
onChange={(e) => setEditForm((f) => ({ ...f, reason: e.target.value }))} />
</div>
<div className="space-y-1.5">
<Label className="text-xs">{t("operational.blocklist_manager.internal_notes_85ab57")}</Label>
<Input value={editForm.notes}
onChange={(e) => setEditForm((f) => ({ ...f, notes: e.target.value }))} />
</div>
</div>
<div className="flex items-center justify-between gap-3">
<label className="flex items-center gap-2 text-sm text-foreground">
<Switch checked={editForm.is_regex} onCheckedChange={(v) => setEditForm((f) => ({ ...f, is_regex: v }))} />
{t("operational.blocklist_manager.regex_pattern_6c75e5")}
</label>
<div className="flex items-center gap-2">
<Button type="button" variant="ghost" size="sm" onClick={cancelEdit} disabled={saving} className="gap-1">
<X size={13} /> {t("operational.blocklist_manager.cancel")}
</Button>
<Button type="submit" size="sm" disabled={saving || !editForm.value.trim()} className="gap-1">
{saving ? <Loader2 size={13} className="animate-spin" /> : <Check size={13} />} {t("operational.blocklist_manager.save")}
</Button>
</div>
</div>
</form> :
<div key={entry.id}
className="flex items-center justify-between gap-3 p-3 bg-muted/40 border border-border rounded-lg">
<div className="flex items-center gap-3 min-w-0">
Expand All @@ -192,7 +281,17 @@ export default function BlocklistManager({ currentUser }) {const { t } = useTran
{entry.added_by &&
<span className="text-muted-foreground/80 text-xs hidden md:block">{entry.added_by}</span>
}
{canRemove &&
{canManage &&
<Button
variant="ghost" size="icon"
onClick={() => startEdit(entry)}
disabled={deletingId === entry.id || saving}
aria-label={`Edit ${entry.value}`}
className="text-muted-foreground hover:text-foreground h-7 w-7 cursor-pointer">
<Pencil size={13} />
</Button>
}
{canManage &&
<Button
variant="ghost" size="icon"
onClick={() => remove(entry.id)}
Expand Down
6 changes: 5 additions & 1 deletion src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -806,7 +806,11 @@
"add_to_blocklist_4557b2": "Add to Blocklist",
"current_blocklist_312b27": "Current Blocklist",
"no_blocklist_entries_yet_d136ea": "No blocklist entries yet.",
"regex_d6a8e6": "regex"
"regex_d6a8e6": "regex",
"entry_updated": "Entry updated",
"failed_to_update_entry": "Failed to update entry",
"cancel": "Cancel",
"save": "Save"
},
"bulk_import_modal": {
"bulk_import_dns_records_f5e5a0": "Bulk Import DNS Records",
Expand Down
2 changes: 1 addition & 1 deletion src/pages/StaffBlocklist.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export default function StaffBlocklist() {
<AdminHeader
eyebrow={currentUser?.role === 'admin' ? 'Administration' : 'Staff'}
title="Blocklist"
description="Stop users registering names that imitate protected brands, and block values from being used in DNS records. Staff can add blocklist entries; only admins can remove them."
description="Stop users registering names that imitate protected brands, and block values from being used in DNS records. Staff and admins can add, edit and remove blocklist entries."
/>
{currentUser
? <div className="max-w-2xl space-y-6"><ProtectedBrandsManager /><BlocklistManager currentUser={currentUser} /></div>
Expand Down
Loading