#175: Admin-Personenliste — Aktions-Icons statt Textlinks, Reihenfolge Admin/Deaktivieren/Löschen
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m41s
CI / Build container images (pull_request) Successful in 1m9s
CI / Auth e2e pack (pull_request) Successful in 7m32s
CI / Import/export fidelity gate (pull_request) Successful in 55s
CD / Build and push images (push) Successful in 16s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m41s
CD / Promote to Int (push) Successful in 10s
CI / Lint, typecheck, test (push) Successful in 4m46s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m18s
CI / Import/export fidelity gate (push) Successful in 55s

Die Zeilen-Aktionen der Personenverwaltung sind jetzt IconButtons
(lucide): MailCheck (Bestätigung erneut senden, nur bei Ausstehend),
ShieldPlus/ShieldMinus (Zum Admin machen / Admin entfernen),
UserX/UserCheck (Deaktivieren/Aktivieren), Trash2 (Löschen) — in dieser
Reihenfolge. Das zweistufige Löschen bleibt: die Bestätigung ist
weiterhin ein roter Text-Button und erhält beim Umschalten den Fokus
(kein Fokusverlust, ADR 0017). Lokalisierte Namen kommen unverändert
aus users.json via IconButton (aria-label+title), Icons aria-hidden.
Der Admin-Bereich ist neu im a11y-CI-Pack (axe WCAG A/AA auf /admin).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude Fable 5 2026-07-26 17:41:26 +02:00
parent 858909564e
commit 3283affa67
3 changed files with 48 additions and 28 deletions

View File

@ -60,3 +60,14 @@ test('user settings pass the axe WCAG A/AA scan', async ({ browser }) => {
await expectClean(page, '/settings');
await context.close();
});
test('admin area passes the axe WCAG A/AA scan', async ({ browser }) => {
const context = await contextForUser(browser, BASE, 'fixture-admin');
const page = await context.newPage();
await page.goto('/admin');
await page.waitForLoadState('networkidle');
// Personenliste sichtbar, inkl. der Icon-Aktionen (issue #175).
await page.locator('.user-manager__table .user-row').first().waitFor();
await expectClean(page, '/admin');
await context.close();
});

View File

@ -1,9 +1,11 @@
import type { AdminUserListView, AdminUserView } from '@dorfteich/shared';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useState } from 'react';
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;
@ -101,6 +103,12 @@ function UserRow({
}): 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 (
@ -118,31 +126,20 @@ function UserRow({
<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"
<IconButton
className="user-row__resend"
label={t('actions.resend')}
onClick={() =>
void run(() => apiPost(`/admin/users/${user.id}/resend-verification`))
}
>
{t('actions.resend')}
</button>
<MailCheck aria-hidden />
</IconButton>
)}
<button
type="button"
className="linklike"
<IconButton
className="user-row__admin"
label={user.isSiteAdmin ? t('actions.revokeAdmin') : t('actions.grantAdmin')}
onClick={() =>
void run(() =>
apiPatch(`/admin/users/${user.id}/site-admin`, {
@ -151,11 +148,23 @@ function UserRow({
)
}
>
{user.isSiteAdmin ? t('actions.revokeAdmin') : t('actions.grantAdmin')}
</button>
{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}`))}
@ -163,13 +172,13 @@ function UserRow({
{t('actions.delete')}?
</button>
) : (
<button
type="button"
className="linklike user-row__delete"
<IconButton
className="user-row__delete"
label={t('actions.delete')}
onClick={() => setConfirmingDelete(true)}
>
{t('actions.delete')}
</button>
<Trash2 aria-hidden />
</IconButton>
)}
</>
)}

View File

@ -2985,7 +2985,7 @@ ul[data-type='task_list'] li p:last-of-type {
.user-row__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
}