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:
@@ -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')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user