#175: Admin-Personenliste — Aktions-Icons statt Textlinks #176

Merged
stwaidele merged 1 commits from admin-user-action-icons into main 2026-07-27 13:21:26 +02:00
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 expectClean(page, '/settings');
await context.close(); 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 type { AdminUserListView, AdminUserView } from '@dorfteich/shared';
import { keepPreviousData, useQuery } from '@tanstack/react-query'; 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 { useTranslation } from 'react-i18next';
import { useAuth } from '../auth/auth-context'; import { useAuth } from '../auth/auth-context';
import { IconButton } from '../components/IconButton';
import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api'; import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api';
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
@ -101,6 +103,12 @@ function UserRow({
}): React.JSX.Element { }): React.JSX.Element {
const { t } = useTranslation('users'); const { t } = useTranslation('users');
const [confirmingDelete, setConfirmingDelete] = useState(false); 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'; const disabled = user.status === 'DISABLED';
return ( return (
@ -118,31 +126,20 @@ function UserRow({
<td className="user-row__actions"> <td className="user-row__actions">
{!isSelf && ( {!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' && ( {user.status === 'PENDING_VERIFICATION' && (
<button <IconButton
type="button" className="user-row__resend"
className="linklike" label={t('actions.resend')}
onClick={() => onClick={() =>
void run(() => apiPost(`/admin/users/${user.id}/resend-verification`)) void run(() => apiPost(`/admin/users/${user.id}/resend-verification`))
} }
> >
{t('actions.resend')} <MailCheck aria-hidden />
</button> </IconButton>
)} )}
<button <IconButton
type="button" className="user-row__admin"
className="linklike" label={user.isSiteAdmin ? t('actions.revokeAdmin') : t('actions.grantAdmin')}
onClick={() => onClick={() =>
void run(() => void run(() =>
apiPatch(`/admin/users/${user.id}/site-admin`, { apiPatch(`/admin/users/${user.id}/site-admin`, {
@ -151,11 +148,23 @@ function UserRow({
) )
} }
> >
{user.isSiteAdmin ? t('actions.revokeAdmin') : t('actions.grantAdmin')} {user.isSiteAdmin ? <ShieldMinus aria-hidden /> : <ShieldPlus aria-hidden />}
</button> </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 ? ( {confirmingDelete ? (
<button <button
type="button" type="button"
ref={confirmRef}
className="linklike user-row__delete-confirm" className="linklike user-row__delete-confirm"
title={t('actions.deleteConfirm', { name: user.displayName })} title={t('actions.deleteConfirm', { name: user.displayName })}
onClick={() => void run(() => apiDelete(`/admin/users/${user.id}`))} onClick={() => void run(() => apiDelete(`/admin/users/${user.id}`))}
@ -163,13 +172,13 @@ function UserRow({
{t('actions.delete')}? {t('actions.delete')}?
</button> </button>
) : ( ) : (
<button <IconButton
type="button" className="user-row__delete"
className="linklike user-row__delete" label={t('actions.delete')}
onClick={() => setConfirmingDelete(true)} onClick={() => setConfirmingDelete(true)}
> >
{t('actions.delete')} <Trash2 aria-hidden />
</button> </IconButton>
)} )}
</> </>
)} )}

View File

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