From 4a23e137310c77e6d2bb9a7308863a71736d0de9 Mon Sep 17 00:00:00 2001 From: Schalli Date: Mon, 7 Sep 2026 10:24:30 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01K5jtbGzC5Sf9npJ3JCjKhq --- .../modules/module-access-denied.tsx | 48 ++++++++ .../modules/module-access-gate.test.tsx | 105 ++++++++++++++++++ .../components/modules/module-access-gate.tsx | 53 +++++++++ 3 files changed, 206 insertions(+) create mode 100644 apps/web/src/components/modules/module-access-denied.tsx create mode 100644 apps/web/src/components/modules/module-access-gate.test.tsx create mode 100644 apps/web/src/components/modules/module-access-gate.tsx diff --git a/apps/web/src/components/modules/module-access-denied.tsx b/apps/web/src/components/modules/module-access-denied.tsx new file mode 100644 index 0000000..3d89a04 --- /dev/null +++ b/apps/web/src/components/modules/module-access-denied.tsx @@ -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 ( +
+
+
+ + + + +
+

{title}

+

{body}

+ + {backToDashboard} + +
+
+ ); +} diff --git a/apps/web/src/components/modules/module-access-gate.test.tsx b/apps/web/src/components/modules/module-access-gate.test.tsx new file mode 100644 index 0000000..cec2a4b --- /dev/null +++ b/apps/web/src/components/modules/module-access-gate.test.tsx @@ -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 = { + '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:
children
, + }); + 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:
children
, + }); + 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:
children
, + }); + 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:
, + }); + + expect(checkModuleAccess).toHaveBeenCalledTimes(1); + expect(checkModuleAccess).toHaveBeenCalledWith('tender-radar'); + }); +}); diff --git a/apps/web/src/components/modules/module-access-gate.tsx b/apps/web/src/components/modules/module-access-gate.tsx new file mode 100644 index 0000000..4615fbb --- /dev/null +++ b/apps/web/src/components/modules/module-access-gate.tsx @@ -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 ( + + ); +}