feat(quick-260907-e8k-01): gemeinsame 403-Komponente und ModuleAccessGate

Zieht das bisher inline in [category]/[moduleSlug]/page.tsx stehende
403-Markup in ModuleAccessDenied (uebersetzungsfrei, nimmt fertige Texte
als Props) und legt mit ModuleAccessGate eine wiederverwendbare
Server-Component-Pruefung an, die checkModuleAccess aufruft und bei
jeder Ausnahme ebenfalls als "kein Zugriff" wertet (zweite
Verteidigungslinie ueber das bereits geschlossen ausfallende
checkModuleAccess, T-15-29). Vier Testfaelle decken Durchlassen,
Verweigern, Ausnahme und Slug-Weitergabe ab.

Bereitet Task 2 vor: die vier Modul-Layouts und die generische Route
werden auf dieses Gate umgestellt (WINDOWS #10, PERM-04).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5jtbGzC5Sf9npJ3JCjKhq
This commit is contained in:
2026-09-07 10:24:30 +02:00
parent 74a30fb8ef
commit 4a23e13731
3 changed files with 206 additions and 0 deletions
@@ -0,0 +1,48 @@
import Link from 'next/link';
interface ModuleAccessDeniedProps {
title: string;
body: string;
backToDashboard: string;
}
/**
* Shared 403 markup for module routes (D-07, PERM-04).
*
* Deliberately synchronous and translation-free: it receives the three
* finished strings as props and renders the same outer shell, lock icon,
* and dashboard link the module page route used to render inline. Any
* caller (the ModuleAccessGate used by both the generic route and the
* four module-owned layouts) resolves translations itself and passes them
* in — this component owns markup only, not i18n lookup.
*/
export function ModuleAccessDenied({ title, body, backToDashboard }: ModuleAccessDeniedProps) {
return (
<div className="mx-auto max-w-2xl space-y-6 p-6">
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="rounded-lg bg-muted p-4 mb-4">
<svg
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-muted-foreground"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<h2 className="text-xl font-semibold mb-2">{title}</h2>
<p className="text-sm text-muted-foreground mb-6">{body}</p>
<Link href="/" className="text-sm font-medium text-primary hover:underline">
{backToDashboard}
</Link>
</div>
</div>
);
}
@@ -0,0 +1,105 @@
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
/**
* Covers ModuleAccessGate (D-07, PERM-04) — the reusable gate shared by
* the generic module route and the four module-owned layouts:
*
* - Granted access: renders children, the 403 heading never appears.
* - Denied access: renders the 403 heading, body, and dashboard link;
* children never appear.
* - Thrown exception from the access check: renders the 403 markup, not
* the children (fails closed, second line of defense over
* checkModuleAccess's own fail-closed behavior).
* - Slug pass-through: checkModuleAccess is called exactly once, with
* the exact slug the gate received.
*
* The expected German strings are hand-written here (not produced via
* the same translation function the production code uses) — next-intl/server
* is mocked with a hand-maintained key table, same pattern as
* module-access.test.tsx.
*/
vi.mock('next-intl/server', () => ({
getTranslations: async () => (key: string) => {
const translations: Record<string, string> = {
'accessDenied.title': 'Kein Zugriff auf dieses Modul',
'accessDenied.body': 'Du hast für dieses Modul keine Freigabe. Wende dich an deinen Administrator.',
'accessDenied.backToDashboard': 'Zur Startseite',
};
return translations[key] ?? key;
},
}));
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.resetModules();
vi.doUnmock('@/lib/module-access-actions');
});
describe('ModuleAccessGate — server access gate (D-07, PERM-04)', () => {
it('renders the children and not the 403 heading when access is granted', async () => {
vi.doMock('@/lib/module-access-actions', () => ({
checkModuleAccess: vi.fn().mockResolvedValue(true),
}));
const { ModuleAccessGate } = await import('./module-access-gate');
const element = await ModuleAccessGate({
moduleSlug: 'tender-radar',
children: <div data-testid="module-children">children</div>,
});
render(element);
expect(screen.getByTestId('module-children')).toBeInTheDocument();
expect(screen.queryByText('Kein Zugriff auf dieses Modul')).not.toBeInTheDocument();
});
it('renders the 403 heading, body, and dashboard link and not the children when access is denied', async () => {
vi.doMock('@/lib/module-access-actions', () => ({
checkModuleAccess: vi.fn().mockResolvedValue(false),
}));
const { ModuleAccessGate } = await import('./module-access-gate');
const element = await ModuleAccessGate({
moduleSlug: 'tender-radar',
children: <div data-testid="module-children">children</div>,
});
render(element);
expect(screen.getByText('Kein Zugriff auf dieses Modul')).toBeInTheDocument();
expect(screen.getByText(/keine Freigabe/)).toBeInTheDocument();
expect(screen.getByText('Zur Startseite')).toBeInTheDocument();
expect(screen.queryByTestId('module-children')).not.toBeInTheDocument();
});
it('renders the 403 markup and not the children when the access check throws', async () => {
vi.doMock('@/lib/module-access-actions', () => ({
checkModuleAccess: vi.fn().mockRejectedValue(new Error('network error')),
}));
const { ModuleAccessGate } = await import('./module-access-gate');
const element = await ModuleAccessGate({
moduleSlug: 'tender-radar',
children: <div data-testid="module-children">children</div>,
});
render(element);
expect(screen.getByText('Kein Zugriff auf dieses Modul')).toBeInTheDocument();
expect(screen.queryByTestId('module-children')).not.toBeInTheDocument();
});
it('passes the slug through to checkModuleAccess unchanged, exactly once', async () => {
const checkModuleAccess = vi.fn().mockResolvedValue(true);
vi.doMock('@/lib/module-access-actions', () => ({ checkModuleAccess }));
const { ModuleAccessGate } = await import('./module-access-gate');
await ModuleAccessGate({
moduleSlug: 'tender-radar',
children: <div />,
});
expect(checkModuleAccess).toHaveBeenCalledTimes(1);
expect(checkModuleAccess).toHaveBeenCalledWith('tender-radar');
});
});
@@ -0,0 +1,53 @@
import { checkModuleAccess } from '@/lib/module-access-actions';
import { getTranslations } from 'next-intl/server';
import type { ReactNode } from 'react';
import { ModuleAccessDenied } from './module-access-denied';
interface ModuleAccessGateProps {
moduleSlug: string;
children: ReactNode;
}
/**
* Reusable server-side access gate for module routes (D-07, PERM-04).
*
* Calls checkModuleAccess(moduleSlug) — the same ModuleAccessService
* resolution the sidebar and ModuleGuard use (D-01), no separate role
* logic in the frontend. Renders the children only when access resolves
* to explicitly `true`; every other outcome (denied, or the access check
* throwing) renders the shared 403 markup instead.
*
* checkModuleAccess already fails closed itself and does not throw
* (T-15-29) — the try/catch here is a second line of defense so a future
* change to that function cannot silently flip this gate open. The
* condition is deliberately "only pass through on explicit grant", never
* "only block on explicit denial".
*
* No redirect and no not-found: the 403 rendering here is the server
* response itself (D-07) — the user learns the module exists and they
* lack a grant, they aren't bounced elsewhere or left thinking it's
* missing.
*/
export async function ModuleAccessGate({ moduleSlug, children }: ModuleAccessGateProps) {
let hasAccess = false;
try {
hasAccess = await checkModuleAccess(moduleSlug);
} catch {
hasAccess = false;
}
if (hasAccess === true) {
return children;
}
const t = await getTranslations('modules');
return (
<ModuleAccessDenied
title={t('accessDenied.title')}
body={t('accessDenied.body')}
backToDashboard={t('accessDenied.backToDashboard')}
/>
);
}