project-bifrost-platform/src/admin/resources/users.ts
Jonathan Hvid 096c9bc297 feat(auth): self-service password change + admin password reset
- /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>
2026-06-17 15:42:45 +02:00

356 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ---------------------------------------------------------------------------
* 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 members 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 };