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 (
+
+ );
+}