feat(260805-d0r): UserAccessModal chips render from data.groups (D-16)
- Chips come from the response's groups (actual GroupMembership rows), not the union of row.viaGroups -- closes the reproduced defect where revoking a group's last grant hid an otherwise-unchanged membership - React key is the group id, not the name - Test file: moved the LDAP/MANUAL badge assertion out of this commit, it belongs to Task 2 which reuses admin.groups.members
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
@@ -17,6 +17,17 @@ interface ModuleAccessRow {
|
|||||||
direct: boolean;
|
direct: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MembershipChip {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
source: 'MANUAL' | 'LDAP';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserAccessResponse {
|
||||||
|
groups: MembershipChip[];
|
||||||
|
modules: ModuleAccessRow[];
|
||||||
|
}
|
||||||
|
|
||||||
interface UserAccessModalProps {
|
interface UserAccessModalProps {
|
||||||
userId: string;
|
userId: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -28,17 +39,18 @@ interface UserAccessModalProps {
|
|||||||
* (read-only -- bearbeitet wird ausschliesslich unter /admin/groups) und
|
* (read-only -- bearbeitet wird ausschliesslich unter /admin/groups) und
|
||||||
* Modul-Zugriff mit geerbten Gruppen plus einem Direkt-Toggle.
|
* Modul-Zugriff mit geerbten Gruppen plus einem Direkt-Toggle.
|
||||||
*
|
*
|
||||||
* Lädt einmal GET /module-grants/users/:userId und rendert beide Abschnitte
|
* Lädt einmal GET /module-grants/users/:userId -- eine Antwort, zwei
|
||||||
* daraus: die Gruppenmitgliedschafts-Chips sind die deduplizierte Menge
|
* Abschnitte: die Gruppenmitgliedschafts-Chips kommen direkt aus den
|
||||||
* aller viaGroups-Namen über alle Module -- ein zweiter Endpoint für die
|
* `groups` der Antwort (den tatsächlichen GroupMembership-Zeilen), `viaGroups`
|
||||||
* reine Mitgliedschaftsliste existiert bewusst nicht (key_link im Plan:
|
* bleibt weiterhin je Modulzeile befüllt und beantwortet die andere Frage
|
||||||
* "geerbte und direkte Rechte kommen aus einer Antwort").
|
* (welche Gruppe gewährt dieses Modul).
|
||||||
*/
|
*/
|
||||||
export function UserAccessModal({ userId, username, onClose }: UserAccessModalProps) {
|
export function UserAccessModal({ userId, username, onClose }: UserAccessModalProps) {
|
||||||
const t = useTranslations('admin.users.grants');
|
const t = useTranslations('admin.users.grants');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
|
|
||||||
const [rows, setRows] = useState<ModuleAccessRow[] | null>(null);
|
const [rows, setRows] = useState<ModuleAccessRow[] | null>(null);
|
||||||
|
const [groups, setGroups] = useState<MembershipChip[] | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
const [togglingModuleId, setTogglingModuleId] = useState<string | null>(null);
|
const [togglingModuleId, setTogglingModuleId] = useState<string | null>(null);
|
||||||
@@ -48,11 +60,17 @@ export function UserAccessModal({ userId, username, onClose }: UserAccessModalPr
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
fetch(`${API_URL}/module-grants/users/${userId}`, { credentials: 'include' })
|
fetch(`${API_URL}/module-grants/users/${userId}`, { credentials: 'include' })
|
||||||
.then((res) => (res.ok ? res.json() : Promise.reject(res)))
|
.then((res) => (res.ok ? res.json() : Promise.reject(res)))
|
||||||
.then((data: ModuleAccessRow[]) => {
|
.then((data: UserAccessResponse) => {
|
||||||
if (!cancelled) setRows(data);
|
if (!cancelled) {
|
||||||
|
setRows(data.modules);
|
||||||
|
setGroups(data.groups);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setRows([]);
|
if (!cancelled) {
|
||||||
|
setRows([]);
|
||||||
|
setGroups([]);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
@@ -62,16 +80,6 @@ export function UserAccessModal({ userId, username, onClose }: UserAccessModalPr
|
|||||||
};
|
};
|
||||||
}, [userId]);
|
}, [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) => {
|
const toggleDirect = async (moduleId: string, currentlyDirect: boolean) => {
|
||||||
setTogglingModuleId(moduleId);
|
setTogglingModuleId(moduleId);
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
@@ -144,16 +152,16 @@ export function UserAccessModal({ userId, username, onClose }: UserAccessModalPr
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-2 text-sm font-medium text-foreground">{t('groupsSection')}</h3>
|
<h3 className="mb-2 text-sm font-medium text-foreground">{t('groupsSection')}</h3>
|
||||||
{groupNames.length === 0 ? (
|
{!groups || groups.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">{t('noGroups')}</p>
|
<p className="text-sm text-muted-foreground">{t('noGroups')}</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="flex flex-wrap gap-2">
|
<ul className="flex flex-wrap gap-2">
|
||||||
{groupNames.map((name) => (
|
{groups.map((group) => (
|
||||||
<li
|
<li
|
||||||
key={name}
|
key={group.id}
|
||||||
className="flex items-center gap-2 rounded-md border border-border px-3 py-1.5 text-sm"
|
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>
|
<span className="font-mono text-xs text-foreground">{group.name}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -23,10 +23,6 @@ const messages: Record<string, Record<string, unknown>> = {
|
|||||||
'{module} direkt für {user} {granted, select, true {freigeben} other {entziehen}}',
|
'{module} direkt für {user} {granted, select, true {freigeben} other {entziehen}}',
|
||||||
saveError: 'Freigabe konnte nicht gespeichert werden. Bitte erneut versuchen.',
|
saveError: 'Freigabe konnte nicht gespeichert werden. Bitte erneut versuchen.',
|
||||||
},
|
},
|
||||||
'admin.groups.members': {
|
|
||||||
sourceManual: 'Manuell',
|
|
||||||
sourceLdap: 'LDAP',
|
|
||||||
},
|
|
||||||
common: {
|
common: {
|
||||||
loading: 'Laden...',
|
loading: 'Laden...',
|
||||||
close: 'Schliessen',
|
close: 'Schliessen',
|
||||||
@@ -256,32 +252,4 @@ describe('UserAccessModal', () => {
|
|||||||
expect(box).toHaveAccessibleName();
|
expect(box).toHaveAccessibleName();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows the LDAP origin badge on a chip with source LDAP and the Manuell badge on source MANUAL', async () => {
|
|
||||||
vi.stubGlobal(
|
|
||||||
'fetch',
|
|
||||||
vi.fn(() =>
|
|
||||||
Promise.resolve({
|
|
||||||
ok: true,
|
|
||||||
json: () =>
|
|
||||||
Promise.resolve({
|
|
||||||
groups: [
|
|
||||||
{ id: 'g1', name: 'Manuelle Gruppe', source: 'MANUAL' as const },
|
|
||||||
{ id: 'g2', name: 'LDAP-Gruppe', source: 'LDAP' as const },
|
|
||||||
],
|
|
||||||
modules: [],
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
render(<UserAccessModal userId="u1" username="Maxi Musterfrau" onClose={vi.fn()} />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Manuelle Gruppe')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
expect(screen.getByText('LDAP-Gruppe')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Manuell')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('LDAP')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user