feat(15-07): Benutzer-Detail mit geerbten Rechten und Direkt-Freigaben

- Neue UserAccessModal.tsx: laedt einmal GET /module-grants/users/:userId
  und rendert daraus zwei Abschnitte -- Gruppenmitgliedschaften (read-only
  Chip-Liste, dedupliziert aus allen viaGroups-Namen; Bearbeitung bleibt
  ausschliesslich unter /admin/groups, D-16) und Modul-Zugriff (Modul |
  erbende Gruppen als Chips oder "–" | Direkt-Checkbox)
- Direkt-Checkbox verhaelt sich identisch zur Matrix-Zelle: optimistisches
  Toggle via POST/DELETE /module-grants mit moduleId+userId, Rollback samt
  sichtbarer Fehlermeldung bei Fehlschlag (T-15-25), aria-label pro Zeile
  aus admin.users.grants.directCheckboxLabel
- admin/users/page.tsx: vierter Aktionsbutton "Details" je Zeile oeffnet
  das Modal
- user-access-modal.test.tsx: 5 Tests (Chip-Liste + Leerzustand, Modultabelle
  mit geerbtem/nicht-geerbtem Modul, Rollback bei Fehler, Hinweistext ohne
  aktive Module, aria-label je Checkbox)
This commit is contained in:
2026-08-04 19:25:15 +02:00
parent 3cd6d997cf
commit 050d070340
3 changed files with 456 additions and 0 deletions
@@ -0,0 +1,242 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
interface ModuleSummary {
id: string;
name: string;
category: string;
}
interface ModuleAccessRow {
module: ModuleSummary;
viaGroups: string[];
direct: boolean;
}
interface UserAccessModalProps {
userId: string;
username: string;
onClose: () => void;
}
/**
* Benutzer-Detail-Zugriff (D-16, Surface Contract 3): Gruppenmitgliedschaften
* (read-only -- bearbeitet wird ausschliesslich unter /admin/groups) und
* Modul-Zugriff mit geerbten Gruppen plus einem Direkt-Toggle.
*
* Lädt einmal GET /module-grants/users/:userId und rendert beide Abschnitte
* daraus: die Gruppenmitgliedschafts-Chips sind die deduplizierte Menge
* aller viaGroups-Namen über alle Module -- ein zweiter Endpoint für die
* reine Mitgliedschaftsliste existiert bewusst nicht (key_link im Plan:
* "geerbte und direkte Rechte kommen aus einer Antwort").
*/
export function UserAccessModal({ userId, username, onClose }: UserAccessModalProps) {
const t = useTranslations('admin.users.grants');
const tCommon = useTranslations('common');
const [rows, setRows] = useState<ModuleAccessRow[] | null>(null);
const [loading, setLoading] = useState(true);
const [saveError, setSaveError] = useState<string | null>(null);
const [togglingModuleId, setTogglingModuleId] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(`${API_URL}/module-grants/users/${userId}`, { credentials: 'include' })
.then((res) => (res.ok ? res.json() : Promise.reject(res)))
.then((data: ModuleAccessRow[]) => {
if (!cancelled) setRows(data);
})
.catch(() => {
if (!cancelled) setRows([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [userId]);
const groupNames = useMemo(() => {
const names = new Set<string>();
for (const row of rows ?? []) {
for (const name of row.viaGroups) {
names.add(name);
}
}
return Array.from(names).sort();
}, [rows]);
const toggleDirect = async (moduleId: string, currentlyDirect: boolean) => {
setTogglingModuleId(moduleId);
setSaveError(null);
// Optimistic toggle -- rolled back below on failure (T-15-25): the UI
// must never show a grant the database does not have.
setRows((prev) =>
prev
? prev.map((r) => (r.module.id === moduleId ? { ...r, direct: !currentlyDirect } : r))
: prev,
);
const rollback = () => {
setRows((prev) =>
prev
? prev.map((r) => (r.module.id === moduleId ? { ...r, direct: currentlyDirect } : r))
: prev,
);
};
try {
const res = await fetch(`${API_URL}/module-grants`, {
method: currentlyDirect ? 'DELETE' : 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ moduleId, userId }),
});
if (!res.ok) {
rollback();
const body = await res.text().catch(() => '');
setSaveError(`${res.status}: ${body || t('saveError')}`);
}
} catch (err) {
rollback();
setSaveError(String(err));
} finally {
setTogglingModuleId(null);
}
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
role="dialog"
aria-modal="true"
>
<div className="w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-lg border border-border bg-card p-6 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-bold text-foreground">{t('modalTitle', { username })}</h2>
<button
type="button"
onClick={onClose}
aria-label={tCommon('close')}
className="rounded px-2 py-1 text-sm text-muted-foreground hover:bg-muted transition-colors"
>
{tCommon('close')}
</button>
</div>
{saveError && (
<div className="mb-4 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{saveError}
</div>
)}
{loading ? (
<p className="text-sm text-muted-foreground">{tCommon('loading')}</p>
) : (
<div className="space-y-6">
<div>
<h3 className="mb-2 text-sm font-medium text-foreground">{t('groupsSection')}</h3>
{groupNames.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('noGroups')}</p>
) : (
<ul className="flex flex-wrap gap-2">
{groupNames.map((name) => (
<li
key={name}
className="flex items-center gap-2 rounded-md border border-border px-3 py-1.5 text-sm"
>
<span className="font-mono text-xs text-foreground">{name}</span>
</li>
))}
</ul>
)}
</div>
<div>
<h3 className="mb-2 text-sm font-medium text-foreground">{t('accessSection')}</h3>
{!rows || rows.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('noActiveModules')}</p>
) : (
<div className="overflow-x-auto rounded-md border border-border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
{t('moduleColumn')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
{t('viaGroupsColumn')}
</th>
<th className="px-4 py-3 text-center font-medium text-muted-foreground">
{t('directColumn')}
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{rows.map((row) => {
const isToggling = togglingModuleId === row.module.id;
return (
<tr key={row.module.id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 font-medium text-foreground">
{row.module.name}
</td>
<td className="px-4 py-3 text-muted-foreground">
{row.viaGroups.length === 0 ? (
t('noInheritance')
) : (
<span className="flex flex-wrap gap-1">
{row.viaGroups.map((name) => (
<span
key={name}
className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground"
>
{name}
</span>
))}
</span>
)}
</td>
<td className="px-4 py-3 text-center">
<span className="relative inline-flex items-center justify-center">
<input
type="checkbox"
checked={row.direct}
disabled={isToggling}
onChange={() => toggleDirect(row.module.id, row.direct)}
aria-label={t('directCheckboxLabel', {
module: row.module.name,
user: username,
// Describes the action the click performs (grant vs.
// revoke), not the current state -- identical
// reasoning to the matrix checkbox.
granted: String(!row.direct),
})}
className="h-4 w-4 rounded border-input"
/>
{isToggling && (
<span className="absolute -right-4 inline-block h-3 w-3 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
)}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
</div>
</div>
);
}