- /account gains a Change password form (verify current, 8+ char new, confirm match) backed by updateUserPassword + verifyPassword/hashPassword. - Admin users resource gains a "Reset password" action that generates a fresh temp password, sets it immediately, and reveals it once in the panel (new temp-password action-result, reusing the copy-box UI) for the admin to send to the user. - Backstage top-left logo now links to the portal (main menu). Temp passwords are generated + hashed at request time; never stored in git or logged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
356 lines
11 KiB
TypeScript
356 lines
11 KiB
TypeScript
/* ---------------------------------------------------------------------------
|
||
* People (users) resource — replaces the old Participants tab.
|
||
*
|
||
* Single resource for every user, regardless of role. The filter chips swap
|
||
* the visible columns (council shows member_number + focus_tags; pilots/team
|
||
* show role + last_seen_at). The edit panel's CAB-specific fields render
|
||
* only when the user is in role=cab.
|
||
*
|
||
* Creation is intentionally absent — users come in through invites, not
|
||
* direct admin creation. The "+ New" button is suppressed automatically
|
||
* because ops.create is undefined.
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
import {
|
||
getAllUsersPublic,
|
||
getUserPublicById,
|
||
updateUserAdminFields,
|
||
updateUserEmail,
|
||
updateUserPassword,
|
||
updateUserProfile,
|
||
updateUserRole,
|
||
deactivateUser,
|
||
type Role,
|
||
type UserPublic,
|
||
} from '../../lib/db';
|
||
import { generateTempPassword, hashPassword } from '../../lib/auth';
|
||
import { parseFocusTags, readFocusTags } from '../../lib/format';
|
||
import type { Resource } from '../resource-types';
|
||
|
||
const ROLE_LABEL: Record<Role, string> = {
|
||
pilot: 'Pilot',
|
||
cab: 'Council',
|
||
fenja: 'Fenja team',
|
||
};
|
||
|
||
const ROLE_PILL_CLASS: Record<Role, string> = {
|
||
pilot: 'pill-pilot',
|
||
cab: 'pill-cab',
|
||
fenja: 'pill-fenja',
|
||
};
|
||
|
||
export const usersResource: Resource<UserPublic> = {
|
||
key: 'users',
|
||
label: 'People',
|
||
pluralLabel: 'People',
|
||
singularLabel: 'Person',
|
||
groupKey: 'council',
|
||
description: 'Everyone with an account on the portal — pilots, council, and team.',
|
||
publicRoutePattern: (item) => (item.slug ? `/members/${item.slug}` : null),
|
||
|
||
list: {
|
||
queryFn: () => getAllUsersPublic(),
|
||
|
||
// Default columns shown under "Council" (the default filter): the CAB-
|
||
// specific identity columns.
|
||
columns: [
|
||
{
|
||
key: 'name',
|
||
label: 'Name',
|
||
primary: true,
|
||
width: '2fr',
|
||
render: (item) => ({
|
||
title: item.name,
|
||
subtitle: item.email,
|
||
}),
|
||
},
|
||
{
|
||
key: 'member_number',
|
||
label: 'Member #',
|
||
kind: 'number',
|
||
width: '100px',
|
||
value: (item) => item.member_number,
|
||
},
|
||
{
|
||
key: 'organisation',
|
||
label: 'Organisation',
|
||
width: '1.5fr',
|
||
render: (item) => ({ title: item.organisation || '—' }),
|
||
},
|
||
{
|
||
key: 'focus_tags',
|
||
label: 'Focus',
|
||
kind: 'tag-list',
|
||
width: '1.5fr',
|
||
value: (item) => readFocusTags(item.focus_tags),
|
||
},
|
||
],
|
||
|
||
// Pilots / Team / All show role + organisation + last seen instead.
|
||
columnsByFilter: {
|
||
pilots: [
|
||
{
|
||
key: 'name',
|
||
label: 'Name',
|
||
primary: true,
|
||
width: '2fr',
|
||
render: (item) => ({ title: item.name, subtitle: item.email }),
|
||
},
|
||
{
|
||
key: 'role',
|
||
label: 'Role',
|
||
kind: 'pill',
|
||
width: '120px',
|
||
pillVariants: {
|
||
pilot: { label: 'Pilot', class: 'pill-pilot' },
|
||
cab: { label: 'Council', class: 'pill-cab' },
|
||
fenja: { label: 'Fenja team', class: 'pill-fenja' },
|
||
},
|
||
},
|
||
{
|
||
key: 'organisation',
|
||
label: 'Organisation',
|
||
width: '1.5fr',
|
||
render: (item) => ({ title: item.organisation || '—' }),
|
||
},
|
||
{
|
||
key: 'last_seen_at',
|
||
label: 'Last seen',
|
||
kind: 'relative-date',
|
||
width: '120px',
|
||
emptyFallback: 'never',
|
||
},
|
||
],
|
||
team: [
|
||
{
|
||
key: 'name',
|
||
label: 'Name',
|
||
primary: true,
|
||
width: '2fr',
|
||
render: (item) => ({ title: item.name, subtitle: item.email }),
|
||
},
|
||
{
|
||
key: 'role',
|
||
label: 'Role',
|
||
kind: 'pill',
|
||
width: '120px',
|
||
pillVariants: {
|
||
pilot: { label: 'Pilot', class: 'pill-pilot' },
|
||
cab: { label: 'Council', class: 'pill-cab' },
|
||
fenja: { label: 'Fenja team', class: 'pill-fenja' },
|
||
},
|
||
},
|
||
{
|
||
key: 'organisation',
|
||
label: 'Organisation',
|
||
width: '1.5fr',
|
||
render: (item) => ({ title: item.organisation || '—' }),
|
||
},
|
||
{
|
||
key: 'last_seen_at',
|
||
label: 'Last seen',
|
||
kind: 'relative-date',
|
||
width: '120px',
|
||
emptyFallback: 'never',
|
||
},
|
||
],
|
||
all: [
|
||
{
|
||
key: 'name',
|
||
label: 'Name',
|
||
primary: true,
|
||
width: '2fr',
|
||
render: (item) => ({ title: item.name, subtitle: item.email }),
|
||
},
|
||
{
|
||
key: 'role',
|
||
label: 'Role',
|
||
kind: 'pill',
|
||
width: '120px',
|
||
pillVariants: {
|
||
pilot: { label: 'Pilot', class: 'pill-pilot' },
|
||
cab: { label: 'Council', class: 'pill-cab' },
|
||
fenja: { label: 'Fenja team', class: 'pill-fenja' },
|
||
},
|
||
},
|
||
{
|
||
key: 'organisation',
|
||
label: 'Organisation',
|
||
width: '1.5fr',
|
||
render: (item) => ({ title: item.organisation || '—' }),
|
||
},
|
||
{
|
||
key: 'last_seen_at',
|
||
label: 'Last seen',
|
||
kind: 'relative-date',
|
||
width: '120px',
|
||
emptyFallback: 'never',
|
||
},
|
||
],
|
||
},
|
||
|
||
filters: [
|
||
{ key: 'council', label: 'Council', predicate: (i) => i.role === 'cab', isDefault: true },
|
||
{ key: 'pilots', label: 'Pilots', predicate: (i) => i.role === 'pilot' },
|
||
{ key: 'team', label: 'Team', predicate: (i) => i.role === 'fenja' },
|
||
{ key: 'all', label: 'All', predicate: () => true },
|
||
],
|
||
search: {
|
||
placeholder: 'Search by name, email, organisation…',
|
||
fields: ['name', 'email', 'organisation'],
|
||
},
|
||
defaultSort: { key: 'name', direction: 'asc' },
|
||
pageSize: 50,
|
||
},
|
||
|
||
form: {
|
||
fields: [
|
||
// ── Always visible ───────────────────────────────────────────────
|
||
{
|
||
key: 'role',
|
||
label: 'Role',
|
||
kind: 'select',
|
||
required: true,
|
||
options: [
|
||
{ value: 'pilot', label: 'Pilot' },
|
||
{ value: 'cab', label: 'Council' },
|
||
{ value: 'fenja', label: 'Fenja team' },
|
||
],
|
||
helperText:
|
||
'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',
|
||
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 },
|
||
|
||
// ── CAB-only ────────────────────────────────────────────────────
|
||
{
|
||
key: 'title',
|
||
label: 'Title',
|
||
kind: 'text',
|
||
maxLength: 120,
|
||
visibleWhen: (ctx) => ctx.formValues.role === 'cab',
|
||
},
|
||
{
|
||
key: 'pull_quote',
|
||
label: 'Pull quote',
|
||
kind: 'textarea',
|
||
rows: 3,
|
||
maxLength: 240,
|
||
helperText: 'Shown on the member profile page. Two sentences max.',
|
||
visibleWhen: (ctx) => ctx.formValues.role === 'cab',
|
||
},
|
||
{
|
||
key: 'focus_tags_text',
|
||
label: 'Focus tags',
|
||
kind: 'text',
|
||
maxLength: 80,
|
||
helperText:
|
||
'Comma-separated. Up to 3 tags, 24 chars each. Normalised on save.',
|
||
visibleWhen: (ctx) => ctx.formValues.role === 'cab',
|
||
},
|
||
{
|
||
key: 'cab_joined_date',
|
||
label: 'Council joined',
|
||
kind: 'readonly',
|
||
visibleWhen: (ctx) => ctx.formValues.role === 'cab',
|
||
render: (value) => (value ? String(value) : '—'),
|
||
},
|
||
{
|
||
key: 'member_number',
|
||
label: 'Member number',
|
||
kind: 'readonly',
|
||
visibleWhen: (ctx) => ctx.formValues.role === 'cab',
|
||
render: (value) => (value ? `#${value}` : 'pending'),
|
||
},
|
||
],
|
||
},
|
||
|
||
ops: {
|
||
getById: (id) => {
|
||
const u = getUserPublicById(id);
|
||
if (!u) return null;
|
||
// Surface focus_tags as plaintext for the editor.
|
||
return {
|
||
...u,
|
||
focus_tags_text: readFocusTags(u.focus_tags).join(', '),
|
||
} as unknown as UserPublic;
|
||
},
|
||
|
||
// No ops.create — users come in via invites.
|
||
|
||
update: (id, data) => {
|
||
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 ?? '');
|
||
updateUserProfile(id, newName, newBio);
|
||
|
||
// Role transition — runs after profile update so member_number can be
|
||
// allocated against an up-to-date user row.
|
||
const newRole = data.role as Role;
|
||
if (newRole && newRole !== current.role) {
|
||
updateUserRole(id, newRole);
|
||
}
|
||
|
||
// CAB-specific admin fields. Only applied when the user is CAB after
|
||
// the role update; otherwise the form fields aren't visible.
|
||
const isCab = (newRole ?? current.role) === 'cab';
|
||
if (isCab) {
|
||
const tagsRaw = String(data.focus_tags_text ?? '');
|
||
updateUserAdminFields(id, {
|
||
title: ((data.title as string) ?? '').trim() || null,
|
||
pull_quote: ((data.pull_quote as string) ?? '').trim() || null,
|
||
focus_tags: parseFocusTags(tagsRaw),
|
||
});
|
||
}
|
||
},
|
||
|
||
delete: (id) => deactivateUser(id),
|
||
},
|
||
|
||
actions: [
|
||
{
|
||
key: 'reset-password',
|
||
label: 'Reset password',
|
||
confirmText:
|
||
'Generate a new temporary password for this user? Their current password stops working immediately. You will get a password to send them.',
|
||
handler: (id) => {
|
||
const temp = generateTempPassword();
|
||
updateUserPassword(id, hashPassword(temp));
|
||
return { kind: 'temp-password', password: temp };
|
||
},
|
||
},
|
||
],
|
||
|
||
notifyCount: {
|
||
// CAB members without focus tags read as half-finished profiles —
|
||
// surface them as something to attend to.
|
||
count: (items) =>
|
||
items.filter(
|
||
(u) => u.role === 'cab' && readFocusTags(u.focus_tags).length === 0,
|
||
).length,
|
||
},
|
||
};
|
||
|
||
// Keep the role label map exported for any callers that want display copy.
|
||
export { ROLE_LABEL, ROLE_PILL_CLASS };
|