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,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')}
/>
);
}