feat(15-07): Freigabe-Matrix Module x Gruppen unter /admin/modules/grants
- Neue Client-Komponente admin/modules/grants/page.tsx: laedt GET /module-grants/matrix einmal, rendert Module x Gruppen mit sticky erster Spalte/Kopfzeile, Kategorie- Gruppierung, Suchfeld und Admin-Bypass-Fussnote (D-03/D-15, PERM-03) - Jede Zelle togglet sofort optimistisch (POST/DELETE /module-grants); Fehlschlag springt die Checkbox zurueck und zeigt die Fehlermeldung im bestehenden error-Div (T-15-25) -- identisches Muster zu AdminModulesPage.toggleModule - aria-label pro Checkbox aus admin.groups.grants.matrixCheckboxLabel beschreibt die bevorstehende Aktion (freigeben/entziehen), nicht den aktuellen Zustand - admin/modules/page.tsx: neuer Header-Button "Freigaben-Matrix" verlinkt auf die Unterseite, kein siebter Sidebar-Eintrag - grants-matrix.test.tsx: 5 Tests (befuellte Matrix, leerer Zustand, Rollback bei Fehler, Suchfilter, aria-label je Checkbox)
This commit is contained in:
@@ -0,0 +1,214 @@
|
|||||||
|
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Namespace-aware next-intl mock: dot-path keys resolve against the
|
||||||
|
// namespace passed to useTranslations(ns); {param} placeholders are
|
||||||
|
// substituted plainly, and the one ICU `select` template used by this
|
||||||
|
// phase (`{granted, select, true {...} other {...}}`) is resolved against
|
||||||
|
// the boolean `granted` param, mirroring next-intl's own behaviour closely
|
||||||
|
// enough for aria-label assertions.
|
||||||
|
const messages: Record<string, Record<string, unknown>> = {
|
||||||
|
'adminModules.grants': {
|
||||||
|
title: 'Freigaben-Matrix',
|
||||||
|
searchPlaceholder: 'Module oder Gruppen durchsuchen...',
|
||||||
|
emptyModules:
|
||||||
|
'Es sind noch keine Module für diesen Mandanten aktiviert. Aktiviere zuerst ein Modul unter Module.',
|
||||||
|
emptyModulesLink: 'Zu Module',
|
||||||
|
adminNote:
|
||||||
|
'ADMIN und SUPER_ADMIN haben immer Zugriff auf alle aktiven Module — diese Matrix betrifft nur die Rolle USER.',
|
||||||
|
saveError: 'Freigabe konnte nicht gespeichert werden. Bitte erneut versuchen.',
|
||||||
|
},
|
||||||
|
'admin.groups.grants': {
|
||||||
|
matrixCheckboxLabel: '{module} für Gruppe {group} {granted, select, true {freigeben} other {entziehen}}',
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
loading: 'Laden...',
|
||||||
|
accessDenied: 'Zugriff verweigert',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/link', () => ({
|
||||||
|
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
|
||||||
|
<a href={href}>{children}</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockAuthStore = vi.fn();
|
||||||
|
vi.mock('@/lib/stores/auth-store', () => ({
|
||||||
|
useAuthStore: (selector: (state: unknown) => unknown) => mockAuthStore(selector),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import AdminModuleGrantsPage from './page';
|
||||||
|
|
||||||
|
function stubAdmin() {
|
||||||
|
mockAuthStore.mockImplementation(
|
||||||
|
(selector: (state: { user: { id: string; role: string; tenantId: string } }) => unknown) =>
|
||||||
|
selector({ user: { id: 'u1', role: 'ADMIN', tenantId: 't1' } }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockMatrix = {
|
||||||
|
modules: [
|
||||||
|
{ id: 'm1', name: 'Ausschreibungs-Radar', category: 'procurement' },
|
||||||
|
{ id: 'm2', name: 'DKV Flotte', category: 'fleet' },
|
||||||
|
],
|
||||||
|
groups: [
|
||||||
|
{ id: 'g1', name: 'Alle Benutzer' },
|
||||||
|
{ id: 'g2', name: 'Buchhaltung' },
|
||||||
|
],
|
||||||
|
grants: [{ moduleId: 'm1', groupId: 'g1' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AdminModuleGrantsPage (Permission-Matrix)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
stubAdmin();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a populated matrix with correctly pre-checked checkboxes', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockMatrix) })),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<AdminModuleGrantsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Ausschreibungs-Radar')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText('DKV Flotte')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Alle Benutzer')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Buchhaltung')).toBeInTheDocument();
|
||||||
|
|
||||||
|
const grantedCheckbox = screen.getByLabelText(
|
||||||
|
'Ausschreibungs-Radar für Gruppe Alle Benutzer entziehen',
|
||||||
|
);
|
||||||
|
expect(grantedCheckbox).toBeChecked();
|
||||||
|
|
||||||
|
const ungrantedCheckbox = screen.getByLabelText(
|
||||||
|
'Ausschreibungs-Radar für Gruppe Buchhaltung freigeben',
|
||||||
|
);
|
||||||
|
expect(ungrantedCheckbox).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the empty state with a link back to /admin/modules when no active modules exist', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ modules: [], groups: [], grants: [] }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<AdminModuleGrantsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/keine Module für diesen Mandanten aktiviert/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
const link = screen.getByText('Zu Module');
|
||||||
|
expect(link.closest('a')).toHaveAttribute('href', '/admin/modules');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls back a checkbox and shows a visible error when the grant request fails', async () => {
|
||||||
|
const fetchMock = vi.fn((url: string, init?: RequestInit) => {
|
||||||
|
if (typeof url === 'string' && url.endsWith('/module-grants/matrix')) {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockMatrix) });
|
||||||
|
}
|
||||||
|
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(mockMatrix) });
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
render(<AdminModuleGrantsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Buchhaltung')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkbox = screen.getByLabelText(
|
||||||
|
'Ausschreibungs-Radar für Gruppe Buchhaltung freigeben',
|
||||||
|
);
|
||||||
|
await userEvent.click(checkbox);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/500: boom/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(checkbox).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters the matrix rows via the search field', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockMatrix) })),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<AdminModuleGrantsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('DKV Flotte')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchInput = screen.getByPlaceholderText('Module oder Gruppen durchsuchen...');
|
||||||
|
await userEvent.type(searchInput, 'Flotte');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Ausschreibungs-Radar')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText('DKV Flotte')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives every matrix checkbox an aria-label', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockMatrix) })),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<AdminModuleGrantsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Buchhaltung')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkboxes = screen.getAllByRole('checkbox');
|
||||||
|
expect(checkboxes.length).toBeGreaterThan(0);
|
||||||
|
for (const box of checkboxes) {
|
||||||
|
expect(box).toHaveAccessibleName();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||||
|
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
interface Module {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Group {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GrantPair {
|
||||||
|
moduleId: string;
|
||||||
|
groupId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellKey(moduleId: string, groupId: string): string {
|
||||||
|
return `${moduleId}:${groupId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Freigabe-Matrix Module x Gruppen (PERM-03, D-15).
|
||||||
|
*
|
||||||
|
* Unterseite von /admin/modules, kein eigener Navigationseintrag. Lädt
|
||||||
|
* einmal GET /module-grants/matrix (Module, Gruppen, bestehende
|
||||||
|
* Gruppen-Grants in einer Antwort) und schaltet jede Zelle sofort um
|
||||||
|
* (optimistisches UI, Rücksprung bei Fehler -- identisches Verhalten zu
|
||||||
|
* AdminModulesPage.toggleModule).
|
||||||
|
*/
|
||||||
|
export default function AdminModuleGrantsPage() {
|
||||||
|
const t = useTranslations('adminModules.grants');
|
||||||
|
const tGrantLabel = useTranslations('admin.groups.grants');
|
||||||
|
const tCommon = useTranslations('common');
|
||||||
|
const currentUser = useAuthStore((s) => s.user);
|
||||||
|
|
||||||
|
const hasAccess = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
|
||||||
|
|
||||||
|
const [modules, setModules] = useState<Module[]>([]);
|
||||||
|
const [groups, setGroups] = useState<Group[]>([]);
|
||||||
|
const [grants, setGrants] = useState<Set<string>>(new Set());
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [togglingKey, setTogglingKey] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchMatrix = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/module-grants/matrix`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data: { modules: Module[]; groups: Group[]; grants: GrantPair[] } =
|
||||||
|
await res.json();
|
||||||
|
setModules(data.modules);
|
||||||
|
setGroups(data.groups);
|
||||||
|
setGrants(new Set(data.grants.map((g) => cellKey(g.moduleId, g.groupId))));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silently fail -- same precedent as the other admin list pages
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasAccess) {
|
||||||
|
fetchMatrix();
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [hasAccess, fetchMatrix]);
|
||||||
|
|
||||||
|
const toggleGrant = async (moduleId: string, groupId: string, currentlyGranted: boolean) => {
|
||||||
|
const key = cellKey(moduleId, groupId);
|
||||||
|
setTogglingKey(key);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Optimistic toggle -- rolled back below on any failure so the UI never
|
||||||
|
// shows a grant the database does not have (T-15-25).
|
||||||
|
setGrants((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (currentlyGranted) {
|
||||||
|
next.delete(key);
|
||||||
|
} else {
|
||||||
|
next.add(key);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const rollback = () => {
|
||||||
|
setGrants((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (currentlyGranted) {
|
||||||
|
next.add(key);
|
||||||
|
} else {
|
||||||
|
next.delete(key);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/module-grants`, {
|
||||||
|
method: currentlyGranted ? 'DELETE' : 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ moduleId, groupId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
rollback();
|
||||||
|
const body = await res.text().catch(() => '');
|
||||||
|
setError(`${res.status}: ${body || t('saveError')}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
rollback();
|
||||||
|
setError(String(err));
|
||||||
|
} finally {
|
||||||
|
setTogglingKey(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchLower = search.trim().toLowerCase();
|
||||||
|
const matches = (text: string) => searchLower === '' || text.toLowerCase().includes(searchLower);
|
||||||
|
|
||||||
|
const filteredModules = useMemo(
|
||||||
|
() => modules.filter((m) => matches(m.name)),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[modules, searchLower],
|
||||||
|
);
|
||||||
|
const filteredGroups = useMemo(
|
||||||
|
() => groups.filter((g) => matches(g.name)),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[groups, searchLower],
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupedModules = useMemo(() => {
|
||||||
|
const result: { category: string; mods: Module[] }[] = [];
|
||||||
|
for (const mod of filteredModules) {
|
||||||
|
const last = result[result.length - 1];
|
||||||
|
if (last && last.category === mod.category) {
|
||||||
|
last.mods.push(mod);
|
||||||
|
} else {
|
||||||
|
result.push({ category: mod.category, mods: [mod] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [filteredModules]);
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<p className="text-lg text-muted-foreground">{tCommon('accessDenied')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-muted-foreground">{tCommon('loading')}</p>
|
||||||
|
) : modules.length === 0 ? (
|
||||||
|
<div className="rounded-md border border-border bg-card p-6 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">{t('emptyModules')}</p>
|
||||||
|
<Link
|
||||||
|
href="/admin/modules"
|
||||||
|
className="mt-2 inline-block text-sm font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{t('emptyModulesLink')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder={t('searchPlaceholder')}
|
||||||
|
className="flex h-9 w-full max-w-sm rounded-md border border-input bg-background px-3 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto overflow-y-auto max-h-[70vh] rounded-md border border-border">
|
||||||
|
<table className="w-full text-sm border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="sticky left-0 top-0 z-20 min-w-[180px] max-w-[260px] border-b border-r border-border bg-card px-4 py-3 text-left" />
|
||||||
|
{filteredGroups.map((g) => (
|
||||||
|
<th
|
||||||
|
key={g.id}
|
||||||
|
title={g.name}
|
||||||
|
className="sticky top-0 z-10 min-w-[120px] max-w-[160px] truncate border-b border-border bg-card px-4 py-3 text-left text-xs font-medium text-muted-foreground"
|
||||||
|
>
|
||||||
|
{g.name}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{groupedModules.map(({ category, mods }, groupIndex) => (
|
||||||
|
<Fragment key={`cat-${category}-${groupIndex}`}>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={filteredGroups.length + 1}
|
||||||
|
className="bg-muted/50 px-4 py-2 text-xs font-medium text-muted-foreground"
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{mods.map((mod) => (
|
||||||
|
<tr key={mod.id} className="hover:bg-muted/30 transition-colors">
|
||||||
|
<td
|
||||||
|
title={mod.name}
|
||||||
|
className="sticky left-0 z-10 min-w-[180px] max-w-[260px] truncate border-r border-border bg-card px-4 py-3 font-medium text-foreground"
|
||||||
|
>
|
||||||
|
{mod.name}
|
||||||
|
</td>
|
||||||
|
{filteredGroups.map((g) => {
|
||||||
|
const key = cellKey(mod.id, g.id);
|
||||||
|
const isGranted = grants.has(key);
|
||||||
|
const isToggling = togglingKey === key;
|
||||||
|
return (
|
||||||
|
<td key={g.id} className="px-4 py-3 text-center">
|
||||||
|
<span className="relative inline-flex items-center justify-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isGranted}
|
||||||
|
disabled={isToggling}
|
||||||
|
onChange={() => toggleGrant(mod.id, g.id, isGranted)}
|
||||||
|
aria-label={tGrantLabel('matrixCheckboxLabel', {
|
||||||
|
module: mod.name,
|
||||||
|
group: g.name,
|
||||||
|
// The label describes the action the click performs, not
|
||||||
|
// the current state (which the native checked/aria-checked
|
||||||
|
// attribute already conveys) -- so an already-granted cell
|
||||||
|
// (click revokes) reads "...entziehen", and vice versa.
|
||||||
|
granted: String(!isGranted),
|
||||||
|
})}
|
||||||
|
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>
|
||||||
|
))}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">{t('adminNote')}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||||
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
||||||
@@ -126,10 +127,18 @@ export default function AdminModulesPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
|
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
|
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/admin/modules/grants"
|
||||||
|
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
{t('grantsLink')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
|||||||
Reference in New Issue
Block a user