dorfteich/apps/web/src/pages/UserManager.tsx
Claude Opus 4.8 42e97b9df2
Some checks failed
CD / Build and push images (push) Successful in 3m12s
CI / Lint, typecheck, test (push) Failing after 2m29s
CI / Auth e2e pack (push) Successful in 3m32s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 11s
Add Site-Admin user management (#59)
Instance operators get basic user administration for support, abuse handling,
and GDPR groundwork (security.md §Privacy).

- api `admin/`: Site-Admin-gated `/admin/users` — a searchable, paginated list
  (username, e-mail, status, role, pond count, last login) plus lifecycle
  actions: disable/enable (a disabled user is logged out everywhere and login
  is refused with the distinct `account_disabled`), resend verification, delete,
  and grant/revoke Site Admin. Guards: you cannot act on your own account
  (`cannot_modify_self`) and the last Site Admin cannot be dropped
  (`last_site_admin`). Every action is audit-logged with the actor.
- `PseudonymizationService`: account deletion scrubs the PII, removes all login
  identities + sessions, and trashes the personal pond — the kept row is what
  authorship references, so shared content the user authored shows as "Deleted
  user" (no orphaned/cascaded content).
- web: the Admin area gains a 'Users' surface — search, pagination, and the
  actions (destructive ones behind an inline two-step confirm; self-actions
  hidden). New `users` i18n namespace (de+en).
- tests: `user-admin.e2e.db.test.ts` (disable → logout + login blocked; delete
  → pseudonymized authorship + personal pond trashed + credentials gone; last
  Site Admin and self protected; Site-Admin gating); a non-destructive browser
  `admin-users` pack proving disable-in-UI blocks login and enable restores it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 00:31:41 +02:00

180 lines
5.5 KiB
TypeScript

import type { AdminUserListView, AdminUserView } from '@dorfteich/shared';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../auth/auth-context';
import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api';
const PAGE_SIZE = 20;
/**
* Site-Admin user management (issue #59): a searchable, paginated list with the
* lifecycle actions. Destructive ones use an inline two-step confirm; the api
* enforces the "not on self / not the last Site Admin" rules — this only hides
* the buttons on your own row.
*/
export function UserManager(): React.JSX.Element {
const { t } = useTranslation('users');
const { user: me } = useAuth();
const [q, setQ] = useState('');
const [page, setPage] = useState(1);
const query = useQuery({
queryKey: ['admin', 'users', q, page],
queryFn: () =>
apiGet<AdminUserListView>(
`/admin/users?q=${encodeURIComponent(q)}&page=${page}&pageSize=${PAGE_SIZE}`,
),
placeholderData: keepPreviousData,
});
const run = async (fn: () => Promise<unknown>): Promise<void> => {
await fn();
await query.refetch();
};
const data = query.data;
const totalPages = data ? Math.max(1, Math.ceil(data.total / PAGE_SIZE)) : 1;
return (
<section className="settings-section user-manager">
<h2>{t('title')}</h2>
<input
className="user-manager__search"
type="search"
value={q}
placeholder={t('search')}
onChange={(e) => {
setQ(e.target.value);
setPage(1);
}}
/>
<table className="table user-manager__table">
<thead>
<tr>
<th>{t('columns.user')}</th>
<th>{t('columns.status')}</th>
<th>{t('columns.role')}</th>
<th>{t('columns.ponds')}</th>
<th>{t('columns.lastLogin')}</th>
<th>{t('columns.actions')}</th>
</tr>
</thead>
<tbody>
{(data?.users ?? []).map((u) => (
<UserRow key={u.id} user={u} isSelf={u.id === me?.id} run={run} />
))}
</tbody>
</table>
<div className="user-manager__pager">
<button
type="button"
className="button"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
{t('prev')}
</button>
<span>{t('page', { page })}</span>
<button
type="button"
className="button"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
{t('next')}
</button>
</div>
</section>
);
}
function UserRow({
user,
isSelf,
run,
}: {
user: AdminUserView;
isSelf: boolean;
run: (fn: () => Promise<unknown>) => Promise<void>;
}): React.JSX.Element {
const { t } = useTranslation('users');
const [confirmingDelete, setConfirmingDelete] = useState(false);
const disabled = user.status === 'DISABLED';
return (
<tr className="user-row" data-username={user.username}>
<td>
{user.displayName}
<span className="user-row__username"> @{user.username}</span>
{isSelf && <span className="badge">{t('actions.you')}</span>}
<div className="user-row__email">{user.email}</div>
</td>
<td className="user-row__status">{t(`status.${user.status}`)}</td>
<td>{user.isSiteAdmin ? t('role.admin') : t('role.user')}</td>
<td>{user.pondCount}</td>
<td>{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleDateString() : t('never')}</td>
<td className="user-row__actions">
{!isSelf && (
<>
<button
type="button"
className="linklike user-row__disable"
onClick={() =>
void run(() =>
apiPatch(`/admin/users/${user.id}/disabled`, { disabled: !disabled }),
)
}
>
{disabled ? t('actions.enable') : t('actions.disable')}
</button>
{user.status === 'PENDING_VERIFICATION' && (
<button
type="button"
className="linklike"
onClick={() =>
void run(() => apiPost(`/admin/users/${user.id}/resend-verification`))
}
>
{t('actions.resend')}
</button>
)}
<button
type="button"
className="linklike"
onClick={() =>
void run(() =>
apiPatch(`/admin/users/${user.id}/site-admin`, {
isSiteAdmin: !user.isSiteAdmin,
}),
)
}
>
{user.isSiteAdmin ? t('actions.revokeAdmin') : t('actions.grantAdmin')}
</button>
{confirmingDelete ? (
<button
type="button"
className="linklike user-row__delete-confirm"
title={t('actions.deleteConfirm', { name: user.displayName })}
onClick={() => void run(() => apiDelete(`/admin/users/${user.id}`))}
>
{t('actions.delete')}?
</button>
) : (
<button
type="button"
className="linklike user-row__delete"
onClick={() => setConfirmingDelete(true)}
>
{t('actions.delete')}
</button>
)}
</>
)}
</td>
</tr>
);
}