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:
parent
59842432bd
commit
4c4df45f0c
2 changed files with 28 additions and 2 deletions
|
|
@ -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 member’s 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 ?? '');
|
||||||
|
|
|
||||||
|
|
@ -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, '');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue