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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
import { UserAccessModal } from './components/UserAccessModal';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
@@ -40,6 +41,7 @@ export default function AdminUsersPage() {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
const [detailsUser, setDetailsUser] = useState<User | null>(null);
|
||||
const [formData, setFormData] = useState<UserFormData>({
|
||||
username: '',
|
||||
email: '',
|
||||
@@ -245,6 +247,12 @@ export default function AdminUsersPage() {
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setDetailsUser(user)}
|
||||
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
{t('grants.detailsButton')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEdit(user)}
|
||||
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
|
||||
@@ -403,6 +411,15 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User-Detail-Zugriff (D-16): geerbte + direkte Modulfreigaben */}
|
||||
{detailsUser && (
|
||||
<UserAccessModal
|
||||
userId={detailsUser.id}
|
||||
username={detailsUser.displayName ?? detailsUser.username}
|
||||
onClose={() => setDetailsUser(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Namespace-aware next-intl mock -- same convention as
|
||||
// admin/groups/groups-page.test.tsx and admin/modules/grants/grants-matrix.test.tsx.
|
||||
// {param} placeholders are substituted plainly; the one ICU `select` template
|
||||
// used here (`directCheckboxLabel`) is resolved against the string-coerced
|
||||
// `granted` param.
|
||||
const messages: Record<string, Record<string, unknown>> = {
|
||||
'admin.users.grants': {
|
||||
detailsButton: 'Details',
|
||||
modalTitle: '{username} — Zugriff',
|
||||
groupsSection: 'Gruppenmitgliedschaften',
|
||||
noGroups: 'Dieser Benutzer ist keiner Gruppe zugeordnet.',
|
||||
accessSection: 'Modul-Zugriff',
|
||||
moduleColumn: 'Modul',
|
||||
viaGroupsColumn: 'Über Gruppe(n)',
|
||||
directColumn: 'Direkt',
|
||||
noInheritance: '–',
|
||||
noActiveModules: 'Für diesen Mandanten sind keine Module aktiviert.',
|
||||
directCheckboxLabel:
|
||||
'{module} direkt für {user} {granted, select, true {freigeben} other {entziehen}}',
|
||||
saveError: 'Freigabe konnte nicht gespeichert werden. Bitte erneut versuchen.',
|
||||
},
|
||||
common: {
|
||||
loading: 'Laden...',
|
||||
close: 'Schliessen',
|
||||
},
|
||||
};
|
||||
|
||||
function resolve(ns: string, key: string, params?: Record<string, unknown>): string {
|
||||
const parts = key.split('.');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let val: any = messages[ns] ?? {};
|
||||
for (const part of parts) {
|
||||
val = val?.[part];
|
||||
}
|
||||
if (typeof val !== 'string') return key;
|
||||
if (params) {
|
||||
val = val.replace(
|
||||
/\{granted,\s*select,\s*true\s*\{([^}]*)\}\s*other\s*\{([^}]*)\}\}/,
|
||||
(_match: string, whenTrue: string, whenOther: string) =>
|
||||
String(params.granted) === 'true' ? whenTrue : whenOther,
|
||||
);
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (k === 'granted') continue;
|
||||
val = val.replace(`{${k}}`, String(v));
|
||||
}
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: (ns: string) => (key: string, params?: Record<string, unknown>) =>
|
||||
resolve(ns, key, params),
|
||||
}));
|
||||
|
||||
import { UserAccessModal } from './components/UserAccessModal';
|
||||
|
||||
const mockAccessRows = [
|
||||
{
|
||||
module: { id: 'm1', name: 'Ausschreibungs-Radar', category: 'procurement' },
|
||||
viaGroups: ['Alle Benutzer'],
|
||||
direct: false,
|
||||
},
|
||||
{
|
||||
module: { id: 'm2', name: 'DKV Flotte', category: 'fleet' },
|
||||
viaGroups: [],
|
||||
direct: true,
|
||||
},
|
||||
];
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('UserAccessModal', () => {
|
||||
it('renders the group-membership chip list, and the empty state when the user has none', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockAccessRows) })),
|
||||
);
|
||||
|
||||
render(<UserAccessModal userId="u1" username="Maxi Musterfrau" onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Alle Benutzer').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
cleanup();
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve(
|
||||
mockAccessRows.map((row) => ({ ...row, viaGroups: [] as string[] })),
|
||||
),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
render(<UserAccessModal userId="u1" username="Maxi Musterfrau" onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Dieser Benutzer ist keiner Gruppe zugeordnet.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the module access table with one inherited and one non-inherited module', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockAccessRows) })),
|
||||
);
|
||||
|
||||
render(<UserAccessModal userId="u1" username="Maxi Musterfrau" onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Ausschreibungs-Radar')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('DKV Flotte')).toBeInTheDocument();
|
||||
// m1 is inherited via 'Alle Benutzer' -- shown as a chip in the via-groups column.
|
||||
expect(screen.getAllByText('Alle Benutzer').length).toBeGreaterThan(0);
|
||||
// m2 has no inheriting groups -- renders the em-dash placeholder.
|
||||
expect(screen.getByText('–')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('rolls back the direct checkbox and shows a visible error when the request fails', async () => {
|
||||
const fetchMock = vi.fn((url: string, init?: RequestInit) => {
|
||||
if (typeof url === 'string' && url.endsWith('/module-grants/users/u1')) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockAccessRows) });
|
||||
}
|
||||
if (typeof url === 'string' && url.endsWith('/module-grants') && init?.method === 'POST') {
|
||||
return Promise.resolve({ ok: false, status: 500, text: () => Promise.resolve('boom') });
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockAccessRows) });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
render(<UserAccessModal userId="u1" username="Maxi Musterfrau" onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Ausschreibungs-Radar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const checkbox = screen.getByLabelText(
|
||||
'Ausschreibungs-Radar direkt für Maxi Musterfrau freigeben',
|
||||
);
|
||||
expect(checkbox).not.toBeChecked();
|
||||
await userEvent.click(checkbox);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/500: boom/)).toBeInTheDocument();
|
||||
});
|
||||
expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('shows the hint text when the tenant has no active modules', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve([]) })),
|
||||
);
|
||||
|
||||
render(<UserAccessModal userId="u1" username="Maxi Musterfrau" onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Für diesen Mandanten sind keine Module aktiviert.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('gives every direct checkbox an aria-label', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockAccessRows) })),
|
||||
);
|
||||
|
||||
render(<UserAccessModal userId="u1" username="Maxi Musterfrau" onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('DKV Flotte')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
expect(checkboxes.length).toBe(2);
|
||||
for (const box of checkboxes) {
|
||||
expect(box).toHaveAccessibleName();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user