/* --------------------------------------------------------------------------- * 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 = { pilot: 'Pilot', cab: 'Council', fenja: 'Fenja team', }; const ROLE_PILL_CLASS: Record = { pilot: 'pill-pilot', cab: 'pill-cab', fenja: 'pill-fenja', }; export const usersResource: Resource = { 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 };