All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m39s
CI / Build container images (pull_request) Successful in 1m10s
CI / Auth e2e pack (pull_request) Successful in 7m43s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Build and push images (push) Successful in 18s
CD / Deploy to Test (push) Successful in 13s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 4m50s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m24s
CI / Import/export fidelity gate (push) Successful in 55s
Release / Build release images and notes (push) Successful in 1m11s
Release / Release-candidate operations QA (push) Successful in 45s
Prod deploy / Deploy the released images to Prod (push) Successful in 16s
Die Trennlinie unter der Aktionen-Spalte endete auf Höhe der Icon-Reihe statt am Zeilenende: display:flex direkt auf dem td nahm der Zelle ihr table-cell-Verhalten, sie wuchs nicht mehr auf Zeilenhöhe. Das Flex- Layout liegt jetzt auf einem Innen-Wrapper (.user-row__actions-inner); gemessen: 0 px Bottom-Delta über alle Zellen jeder Zeile. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
191 lines
6.4 KiB
TypeScript
191 lines
6.4 KiB
TypeScript
import type { AdminUserListView, AdminUserView } from '@dorfteich/shared';
|
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
|
import { MailCheck, ShieldMinus, ShieldPlus, Trash2, UserCheck, UserX } from 'lucide-react';
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { useAuth } from '../auth/auth-context';
|
|
import { IconButton } from '../components/IconButton';
|
|
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 confirmRef = useRef<HTMLButtonElement>(null);
|
|
// The icon button unmounts when the confirm step appears — hand focus over
|
|
// so keyboard users land on the confirmation instead of losing focus.
|
|
useEffect(() => {
|
|
if (confirmingDelete) confirmRef.current?.focus();
|
|
}, [confirmingDelete]);
|
|
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">
|
|
{/* Flex lives on the inner div: a td with display:flex stops behaving
|
|
like a table cell and its bottom border no longer meets the row's. */}
|
|
{!isSelf && (
|
|
<div className="user-row__actions-inner">
|
|
{user.status === 'PENDING_VERIFICATION' && (
|
|
<IconButton
|
|
className="user-row__resend"
|
|
label={t('actions.resend')}
|
|
onClick={() =>
|
|
void run(() => apiPost(`/admin/users/${user.id}/resend-verification`))
|
|
}
|
|
>
|
|
<MailCheck aria-hidden />
|
|
</IconButton>
|
|
)}
|
|
<IconButton
|
|
className="user-row__admin"
|
|
label={user.isSiteAdmin ? t('actions.revokeAdmin') : t('actions.grantAdmin')}
|
|
onClick={() =>
|
|
void run(() =>
|
|
apiPatch(`/admin/users/${user.id}/site-admin`, {
|
|
isSiteAdmin: !user.isSiteAdmin,
|
|
}),
|
|
)
|
|
}
|
|
>
|
|
{user.isSiteAdmin ? <ShieldMinus aria-hidden /> : <ShieldPlus aria-hidden />}
|
|
</IconButton>
|
|
<IconButton
|
|
className="user-row__disable"
|
|
label={disabled ? t('actions.enable') : t('actions.disable')}
|
|
onClick={() =>
|
|
void run(() =>
|
|
apiPatch(`/admin/users/${user.id}/disabled`, { disabled: !disabled }),
|
|
)
|
|
}
|
|
>
|
|
{disabled ? <UserCheck aria-hidden /> : <UserX aria-hidden />}
|
|
</IconButton>
|
|
{confirmingDelete ? (
|
|
<button
|
|
type="button"
|
|
ref={confirmRef}
|
|
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>
|
|
) : (
|
|
<IconButton
|
|
className="user-row__delete"
|
|
label={t('actions.delete')}
|
|
onClick={() => setConfirmingDelete(true)}
|
|
>
|
|
<Trash2 aria-hidden />
|
|
</IconButton>
|
|
)}
|
|
</div>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|