feat(admin): editable member email + Danish slug folding

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) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-06-10 11:44:28 +02:00
parent 59842432bd
commit 4c4df45f0c
2 changed files with 28 additions and 2 deletions

View file

@ -15,6 +15,7 @@ import {
getAllUsersPublic, getAllUsersPublic,
getUserPublicById, getUserPublicById,
updateUserAdminFields, updateUserAdminFields,
updateUserEmail,
updateUserProfile, updateUserProfile,
updateUserRole, updateUserRole,
deactivateUser, deactivateUser,
@ -217,7 +218,15 @@ export const usersResource: Resource<UserPublic> = {
'Changing the role has real access consequences. Setting to Council also allocates a member number.', '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: '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 members login identity. Normalised to lowercase on save; must be unique.',
},
{ key: 'organisation', label: 'Organisation', kind: 'text', readOnly: true, { key: 'organisation', label: 'Organisation', kind: 'text', readOnly: true,
helperText: 'Set at sign-up; editing is not yet supported.' }, helperText: 'Set at sign-up; editing is not yet supported.' },
{ key: 'bio', label: 'Bio', kind: 'textarea', rows: 4 }, { key: 'bio', label: 'Bio', kind: 'textarea', rows: 4 },
@ -282,6 +291,13 @@ export const usersResource: Resource<UserPublic> = {
const current = getUserPublicById(id); const current = getUserPublicById(id);
if (!current) throw new Error(`User ${id} not found`); 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). // Profile fields (name + bio).
const newName = String(data.name ?? current.name); const newName = String(data.name ?? current.name);
const newBio = String(data.bio ?? current.bio ?? ''); const newBio = String(data.bio ?? current.bio ?? '');

View file

@ -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); 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 /** Returns the newly-allocated member_number when the transition lands on
* cab and the user had none; null otherwise. Callers may ignore. */ * cab and the user had none; null otherwise. Callers may ignore. */
export function updateUserRole(id: number, role: Role): { allocated: number | null } { 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 { export function slugifyName(name: string): string {
return name return name
.toLowerCase() .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(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, ''); .replace(/^-+|-+$/g, '');
} }