From 265ca8ac38a02962c7523f64f0b8b230882e1069 Mon Sep 17 00:00:00 2001 From: Weapie Date: Mon, 14 Sep 2026 11:44:41 +0200 Subject: [PATCH] Improve accessibility and access controls; verify production and database recovery --- .dockerignore | 11 + .gitea/workflows/publish-image.yml | 29 + .gitignore | 10 + Dockerfile | 16 +- README.md | 6 + app/admin/layout.tsx | 5 +- app/admin/page.tsx | 10 +- app/api/policy-defaults/route.ts | 3 +- app/api/privacy-policies/[id]/route.ts | 11 +- app/api/privacy-policies/route.ts | 11 +- app/api/site-pages/route.ts | 45 +- app/api/users/[id]/route.ts | 185 +- app/api/users/route.ts | 19 +- app/auth/signin/layout.tsx | 3 + app/auth/signin/page.tsx | 8 +- app/erklaerung/[slug]/page.tsx | 3 +- app/globals.css | 64 +- app/page.tsx | 3 +- components/brand.tsx | 2 +- components/defaultable-field.tsx | 19 +- components/form-errors.tsx | 14 + components/legal-page.tsx | 6 +- components/markdown-input.tsx | 12 +- components/policy-defaults-form.tsx | 18 +- components/policy-detail.tsx | 6 +- components/policy-form.tsx | 23 +- components/policy-list.tsx | 24 +- components/public-header.tsx | 35 +- components/site-pages-form.tsx | 101 +- components/ui/alert-dialog.tsx | 8 +- components/ui/dialog.tsx | 8 +- components/ui/table.tsx | 2 +- components/ui/tabs.tsx | 2 +- components/ui/use-dialog-focus.ts | 21 + components/user-management.tsx | 7 +- docker/prisma-cli/package-lock.json | 457 ++ docker/prisma-cli/package.json | 13 + docs/BETRIEB.md | 61 + docs/UMSETZUNG-2026-09-10.md | 64 + .../01-erklaerung-zur-barrierefreiheit.md | 63 + docs/review-2026-09-09/02-leichte-sprache.md | 137 + docs/review-2026-09-09/03-gebaerdensprache.md | 37 + docs/review-2026-09-09/PRUEFBERICHT.md | 170 + docs/review-2026-09-09/README.md | 46 + eslint.config.mjs | 7 + lib/auth.ts | 5 +- lib/markdown.ts | 35 +- lib/policy-input.ts | 26 + lib/prisma.ts | 4 + lib/require-admin.ts | 34 +- lib/session-access.ts | 8 + lib/site-page-templates.ts | 6 + lib/site-pages.server.ts | 3 +- lib/site-pages.ts | 99 +- lib/user-validation.ts | 16 + middleware.ts | 1 + next.config.js | 1 + package-lock.json | 5928 +++++++++++++++-- package.json | 31 +- playwright.config.ts | 6 + playwright.production.config.ts | 6 + scripts/database-backup.js | 41 + scripts/test-server.mjs | 28 + tests/e2e/portal.spec.ts | 158 + tests/unit/prisma-compat.test.ts | 36 + tests/unit/security.test.ts | 42 + tsconfig.json | 7 +- types/next-auth.d.ts | 2 + 68 files changed, 7438 insertions(+), 890 deletions(-) create mode 100644 app/auth/signin/layout.tsx create mode 100644 components/form-errors.tsx create mode 100644 components/ui/use-dialog-focus.ts create mode 100644 docker/prisma-cli/package-lock.json create mode 100644 docker/prisma-cli/package.json create mode 100644 docs/BETRIEB.md create mode 100644 docs/UMSETZUNG-2026-09-10.md create mode 100644 docs/review-2026-09-09/01-erklaerung-zur-barrierefreiheit.md create mode 100644 docs/review-2026-09-09/02-leichte-sprache.md create mode 100644 docs/review-2026-09-09/03-gebaerdensprache.md create mode 100644 docs/review-2026-09-09/PRUEFBERICHT.md create mode 100644 docs/review-2026-09-09/README.md create mode 100644 eslint.config.mjs create mode 100644 lib/policy-input.ts create mode 100644 lib/prisma.ts create mode 100644 lib/session-access.ts create mode 100644 lib/site-page-templates.ts create mode 100644 lib/user-validation.ts create mode 100644 playwright.config.ts create mode 100644 playwright.production.config.ts create mode 100644 scripts/database-backup.js create mode 100644 scripts/test-server.mjs create mode 100644 tests/e2e/portal.spec.ts create mode 100644 tests/unit/prisma-compat.test.ts create mode 100644 tests/unit/security.test.ts diff --git a/.dockerignore b/.dockerignore index 637e0a4..f3db578 100644 --- a/.dockerignore +++ b/.dockerignore @@ -30,3 +30,14 @@ docker-compose.yml README.md *.md npm-debug.log* + +.next-e2e +.next-check +test-results +playwright-report + +.next-e2e-production + +/backups/ +*.db-wal +*.db-shm diff --git a/.gitea/workflows/publish-image.yml b/.gitea/workflows/publish-image.yml index 1171c63..3b5440a 100644 --- a/.gitea/workflows/publish-image.yml +++ b/.gitea/workflows/publish-image.yml @@ -18,6 +18,35 @@ jobs: - name: Quellcode auschecken uses: actions/checkout@v4 + - name: Node.js bereitstellen + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + - name: Abhängigkeiten installieren + run: npm ci + + - name: Prisma-Client erzeugen + run: npx prisma generate + + - name: Code und Sicherheitsregressionen prüfen + run: | + npm run typecheck + npm run lint + npm test + + - name: Abhängigkeiten auf bekannte Sicherheitslücken prüfen + run: | + npm audit --audit-level=high + npm audit --prefix docker/prisma-cli --audit-level=high + + - name: Testbrowser installieren + run: npx playwright install --with-deps chromium + + - name: Browser- und API-Regressionen prüfen + run: npm run test:e2e:prod + - name: Image-Tags bestimmen id: meta run: | diff --git a/.gitignore b/.gitignore index ae84a8e..eadb907 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,13 @@ Thumbs.db /coverage .vercel *.tsbuildinfo + +/test-results/ +/playwright-report/ +/.next-e2e/ +/.next-check/ +/.next-e2e-production/ + +/backups/ +*.db-wal +*.db-shm diff --git a/Dockerfile b/Dockerfile index eff3de0..68a8460 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,9 +32,8 @@ RUN npm ci --no-audit --no-fund && npm cache clean --force # --------------------------------------------------------------------------- FROM deps AS prisma-cli WORKDIR /prisma-cli -RUN npm init -y > /dev/null \ - && npm install --omit=dev --no-audit --no-fund prisma@6.16.0 \ - && npm cache clean --force +COPY docker/prisma-cli/package.json docker/prisma-cli/package-lock.json ./ +RUN npm ci --omit=dev --no-audit --no-fund && npm cache clean --force # --------------------------------------------------------------------------- # Build @@ -44,14 +43,9 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . -# Platzhalter nur für den Build: Die Auth-Konfiguration wird beim Prerendern -# ausgewertet und würde ohne gesetzte Variable werfen. ARG statt ENV, damit -# nichts davon im fertigen Image landet – der echte Wert kommt zur Laufzeit. -ARG NEXTAUTH_SECRET=build-time-placeholder -ARG DATABASE_URL=file:/tmp/build.db - -RUN npx prisma generate -RUN npm run build +# Ausschließlich öffentliche Platzhalter für den Build; echte Secrets nur zur Laufzeit. +RUN DATABASE_URL=file:/tmp/build.db npx prisma generate +RUN NEXTAUTH_SECRET=build-time-placeholder DATABASE_URL=file:/tmp/build.db npm run build # --------------------------------------------------------------------------- # Laufzeit diff --git a/README.md b/README.md index 128565c..2c80613 100644 --- a/README.md +++ b/README.md @@ -238,3 +238,9 @@ scripts/ Admin- und Importskripte data/import/ Quell-PDFs für den Massenimport data/policies/ Aufbereitete Datensätze als JSON ``` + +## Qualitätssicherung + +Die [Umsetzungsdokumentation](docs/UMSETZUNG-2026-09-10.md) enthält Änderungen, Testergebnisse, verbleibende Prüfpunkte und Hinweise zur Neuanmeldung nach dem Update. + +Betrieb, Sicherung und Wiederherstellung: [Betriebsanleitung](docs/BETRIEB.md). diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index ba60fd9..4b86ae1 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -1,14 +1,17 @@ import type { Metadata } from "next" +import { redirect } from "next/navigation" +import { getAdminSession } from "@/lib/require-admin" export const metadata: Metadata = { title: "Admin", description: "Datenschutzerklärungen verwalten", } -export default function AdminLayout({ +export default async function AdminLayout({ children, }: { children: React.ReactNode }) { + if (!(await getAdminSession())) redirect("/auth/signin") return <>{children} } diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 7c86371..7cb247f 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -217,7 +217,7 @@ export default function AdminDashboard() { return (
-
+
Admin @@ -252,6 +252,7 @@ export default function AdminDashboard() { Standardtexte + {session?.user.role === "SUPER_ADMIN" && (
diff --git a/app/erklaerung/[slug]/page.tsx b/app/erklaerung/[slug]/page.tsx index 5c85276..c1e0490 100644 --- a/app/erklaerung/[slug]/page.tsx +++ b/app/erklaerung/[slug]/page.tsx @@ -1,7 +1,6 @@ import type { Metadata } from "next" import Link from "next/link" import { notFound } from "next/navigation" -import { PrismaClient } from "@prisma/client" import { ArrowLeft, Calendar } from "lucide-react" import { Badge } from "@/components/ui/badge" @@ -16,7 +15,7 @@ import { formatDate } from "@/lib/format" import { PrivacyPolicy } from "@/types/privacy-policy" // Prisma client instance -const prisma = new PrismaClient() +import { prisma } from "@/lib/prisma" // Inhalt kommt aus der Datenbank und ändert sich mit jeder Pflege im Admin. export const dynamic = "force-dynamic" diff --git a/app/globals.css b/app/globals.css index 7fb2c1a..a89c5bc 100644 --- a/app/globals.css +++ b/app/globals.css @@ -163,7 +163,8 @@ Markdown-Inhalte --------------------------------------------------------------- */ .markdown-content { - @apply text-sm leading-relaxed text-foreground; + @apply text-base leading-relaxed text-foreground; + overflow-wrap: anywhere; } .markdown-content > :first-child { @@ -221,3 +222,64 @@ .markdown-content blockquote { @apply my-3 border-l-2 border-border pl-4 text-muted-foreground; } + +.markdown-table { max-width: 100%; overflow-x: auto; } +.markdown-content table { width: 100%; border-collapse: collapse; } +.markdown-content th, .markdown-content td { padding: .5rem; border: 1px solid var(--border); text-align: left; } +.markdown-content img { max-width: 100%; height: auto; } +html { scroll-padding-top: 6rem; } +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } +} + + +/* Seitliche Direktlinks; auf schmalen Ansichten im Dokumentfluss, + damit weder Inhalte noch fokussierte Bedienelemente verdeckt werden. */ +.accessibility-rail { + display: flex; + justify-content: flex-end; + gap: .375rem; + max-width: 64rem; + margin: .75rem auto 0; + padding: 0 1rem; +} +.accessibility-rail-link { + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 3rem; + width: 3rem; + height: 3rem; + border: 2px solid var(--foreground); + border-radius: .375rem; + background: var(--background); + color: var(--foreground); + box-shadow: 0 2px 6px rgb(0 0 0 / .06); +} +.accessibility-rail-link svg { width: 1.75rem; height: 1.75rem; } +.accessibility-rail-link:hover { background: var(--foreground); color: var(--background); } +.accessibility-rail-link:focus-visible { + outline: 3px solid var(--ring); + outline-offset: 3px; +} +@media (min-width: 80rem) { + .accessibility-rail { + position: fixed; + top: 35%; + right: 0; + z-index: 30; + flex-direction: column; + margin: 0; + padding: 0; + } + .accessibility-rail-link { + flex-basis: 3.5rem; + width: 3.5rem; + height: 3.5rem; + border-radius: .375rem 0 0 .375rem; + border-right: 0; + } + .accessibility-rail-link svg { width: 2rem; height: 2rem; } + .accessibility-rail-link:focus-visible { outline-offset: -5px; } +} +@media print { .accessibility-rail { display: none; } } diff --git a/app/page.tsx b/app/page.tsx index 8d5a93d..9791db2 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,4 +1,3 @@ -import { PrismaClient } from "@prisma/client" import { PolicyList } from "@/components/policy-list" import { PublicHeader } from "@/components/public-header" @@ -7,7 +6,7 @@ import { readPolicyDefaults, toApiPolicy } from "@/lib/policy-defaults.server" import { sortByTitle } from "@/lib/policy-sort" // Prisma client instance -const prisma = new PrismaClient() +import { prisma } from "@/lib/prisma" // Liste und Fußzeile kommen aus der Datenbank. export const dynamic = "force-dynamic" diff --git a/components/brand.tsx b/components/brand.tsx index fb96dcc..d11749a 100644 --- a/components/brand.tsx +++ b/components/brand.tsx @@ -19,7 +19,7 @@ export function Brand({ className, subtitle }: BrandProps) {
-
+
{name === DEFAULT_APP_NAME ? ( // Der Vorgabename wird zweifarbig gesetzt; ein konfigurierter diff --git a/components/defaultable-field.tsx b/components/defaultable-field.tsx index bde357d..4dcb6c5 100644 --- a/components/defaultable-field.tsx +++ b/components/defaultable-field.tsx @@ -1,11 +1,10 @@ "use client" -import { useState } from "react" +import { useEffect, useRef, useState } from "react" import { AlertCircle, PencilLine, Undo2 } from "lucide-react" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" -import { Label } from "@/components/ui/label" import { MarkdownInput } from "@/components/markdown-input" import { MarkdownRenderer } from "@/components/markdown-renderer" @@ -45,14 +44,23 @@ export function DefaultableField({ // steuert ihn der Benutzer über die beiden Schaltflächen. const [isOverride, setIsOverride] = useState(() => value.trim().length > 0) + const focusAfterChange = useRef(false) + useEffect(() => { + if (!focusAfterChange.current) return + document.getElementById(isOverride ? id : id + "-override")?.focus() + focusAfterChange.current = false + }, [isOverride, id]) + /** Wechselt in den Sonderfall und übernimmt den Standard als Ausgangstext. */ const startOverride = () => { + focusAfterChange.current = true setIsOverride(true) if (!value.trim()) onChange(defaultValue) } /** Kehrt zum Standard zurück und verwirft den abweichenden Text. */ const resetToDefault = () => { + focusAfterChange.current = true setIsOverride(false) onChange("") } @@ -91,15 +99,17 @@ export function DefaultableField({ return (
- + Standard
@@ -122,6 +132,7 @@ export function DefaultableField({ type="button" variant="ghost" size="sm" + id={id + "-override"} onClick={startOverride} className="text-muted-foreground" > diff --git a/components/form-errors.tsx b/components/form-errors.tsx new file mode 100644 index 0000000..b3e7c81 --- /dev/null +++ b/components/form-errors.tsx @@ -0,0 +1,14 @@ +"use client" +import { useEffect, useRef } from "react" + +export function FormErrors({ errors }: { errors: string[] }) { + const ref = useRef(null) + useEffect(() => { if (errors.length) ref.current?.focus() }, [errors]) + if (!errors.length) return null + return
+

Bitte prüfen Sie Ihre Eingaben.

+ +
    {errors.map((error, index) =>
  • {error}
  • )}
+
+} diff --git a/components/legal-page.tsx b/components/legal-page.tsx index 5762130..3712c5b 100644 --- a/components/legal-page.tsx +++ b/components/legal-page.tsx @@ -62,7 +62,8 @@ export async function LegalPage({ page }: { page: SitePageKey }) {