From 4c4df45f0cce8c84d044dc66a5660c5d8ea2eede Mon Sep 17 00:00:00 2001 From: Jonathan Hvid Date: Wed, 10 Jun 2026 11:44:28 +0200 Subject: [PATCH] feat(admin): editable member email + Danish slug folding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets a fenja admin edit a member's email in the People resource (the field was read-only). Email is required, format- and uniqueness-checked, and normalised to lowercase on save; collisions surface as a form error via the new updateUserEmail() helper. Also folds ø/æ/å in slugifyName so Danish names produce clean member slugs (soren-friis, not s-ren-friis) — NFKD leaves those letters intact. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/admin/resources/users.ts | 18 +++++++++++++++++- src/lib/db.ts | 12 +++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/admin/resources/users.ts b/src/admin/resources/users.ts index 6079ba0..f89a399 100644 --- a/src/admin/resources/users.ts +++ b/src/admin/resources/users.ts @@ -15,6 +15,7 @@ import { getAllUsersPublic, getUserPublicById, updateUserAdminFields, + updateUserEmail, updateUserProfile, updateUserRole, deactivateUser, @@ -217,7 +218,15 @@ export const usersResource: Resource = { 'Changing the role has real access consequences. Setting to Council also allocates a member number.', }, { key: 'name', label: 'Name', kind: 'text', required: true, maxLength: 120 }, - { key: 'email', label: 'Email', kind: 'text', readOnly: true }, + { + key: 'email', + label: 'Email', + kind: 'text', + required: true, + maxLength: 200, + pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, + helperText: 'The member’s login identity. Normalised to lowercase on save; must be unique.', + }, { key: 'organisation', label: 'Organisation', kind: 'text', readOnly: true, helperText: 'Set at sign-up; editing is not yet supported.' }, { key: 'bio', label: 'Bio', kind: 'textarea', rows: 4 }, @@ -282,6 +291,13 @@ export const usersResource: Resource = { const current = getUserPublicById(id); if (!current) throw new Error(`User ${id} not found`); + // Email (login identity) — only written when changed. Throws on a + // collision, which the save handler surfaces as a form error. + const newEmail = String(data.email ?? '').trim().toLowerCase(); + if (newEmail && newEmail !== current.email) { + updateUserEmail(id, newEmail); + } + // Profile fields (name + bio). const newName = String(data.name ?? current.name); const newBio = String(data.bio ?? current.bio ?? ''); diff --git a/src/lib/db.ts b/src/lib/db.ts index 30f3321..816f65f 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -152,6 +152,15 @@ export function updateUserProfile(id: number, name: string, bio: string): void { db.prepare('UPDATE users SET name = ?, bio = ? WHERE id = ?').run(name, bio, id); } +/** Update a user's email — their login identity. Throws if the address is + * already used by another account (the column is UNIQUE). */ +export function updateUserEmail(id: number, email: string): void { + const clash = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?') + .get(email, id) as { id: number } | undefined; + if (clash) throw new Error('That email is already in use by another account.'); + db.prepare('UPDATE users SET email = ? WHERE id = ?').run(email, id); +} + /** Returns the newly-allocated member_number when the transition lands on * cab and the user had none; null otherwise. Callers may ignore. */ export function updateUserRole(id: number, role: Role): { allocated: number | null } { @@ -213,7 +222,8 @@ export function updateUserAdminFields(id: number, data: { export function slugifyName(name: string): string { return name .toLowerCase() - .normalize('NFKD').replace(/[̀-ͯ]/g, '') // strip diacritics + .replace(/ø/g, 'o').replace(/æ/g, 'ae').replace(/å/g, 'a') // Danish letters NFKD leaves intact + .normalize('NFKD').replace(/[̀-ͯ]/g, '') // strip remaining diacritics .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); }