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