Improve accessibility and access controls; verify production and database recovery
All checks were successful
Container-Image bauen und veröffentlichen / build-and-push (push) Successful in 5m15s
All checks were successful
Container-Image bauen und veröffentlichen / build-and-push (push) Successful in 5m15s
This commit is contained in:
parent
0134dd6db4
commit
265ca8ac38
@ -30,3 +30,14 @@ docker-compose.yml
|
|||||||
README.md
|
README.md
|
||||||
*.md
|
*.md
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|
||||||
|
.next-e2e
|
||||||
|
.next-check
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
|
||||||
|
.next-e2e-production
|
||||||
|
|
||||||
|
/backups/
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
|||||||
@ -18,6 +18,35 @@ jobs:
|
|||||||
- name: Quellcode auschecken
|
- name: Quellcode auschecken
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Image-Tags bestimmen
|
||||||
id: meta
|
id: meta
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
10
.gitignore
vendored
10
.gitignore
vendored
@ -52,3 +52,13 @@ Thumbs.db
|
|||||||
/coverage
|
/coverage
|
||||||
.vercel
|
.vercel
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
/.next-e2e/
|
||||||
|
/.next-check/
|
||||||
|
/.next-e2e-production/
|
||||||
|
|
||||||
|
/backups/
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
|||||||
16
Dockerfile
16
Dockerfile
@ -32,9 +32,8 @@ RUN npm ci --no-audit --no-fund && npm cache clean --force
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
FROM deps AS prisma-cli
|
FROM deps AS prisma-cli
|
||||||
WORKDIR /prisma-cli
|
WORKDIR /prisma-cli
|
||||||
RUN npm init -y > /dev/null \
|
COPY docker/prisma-cli/package.json docker/prisma-cli/package-lock.json ./
|
||||||
&& npm install --omit=dev --no-audit --no-fund prisma@6.16.0 \
|
RUN npm ci --omit=dev --no-audit --no-fund && npm cache clean --force
|
||||||
&& npm cache clean --force
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Build
|
# Build
|
||||||
@ -44,14 +43,9 @@ WORKDIR /app
|
|||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Platzhalter nur für den Build: Die Auth-Konfiguration wird beim Prerendern
|
# Ausschließlich öffentliche Platzhalter für den Build; echte Secrets nur zur Laufzeit.
|
||||||
# ausgewertet und würde ohne gesetzte Variable werfen. ARG statt ENV, damit
|
RUN DATABASE_URL=file:/tmp/build.db npx prisma generate
|
||||||
# nichts davon im fertigen Image landet – der echte Wert kommt zur Laufzeit.
|
RUN NEXTAUTH_SECRET=build-time-placeholder DATABASE_URL=file:/tmp/build.db npm run build
|
||||||
ARG NEXTAUTH_SECRET=build-time-placeholder
|
|
||||||
ARG DATABASE_URL=file:/tmp/build.db
|
|
||||||
|
|
||||||
RUN npx prisma generate
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Laufzeit
|
# Laufzeit
|
||||||
|
|||||||
@ -238,3 +238,9 @@ scripts/ Admin- und Importskripte
|
|||||||
data/import/ Quell-PDFs für den Massenimport
|
data/import/ Quell-PDFs für den Massenimport
|
||||||
data/policies/ Aufbereitete Datensätze als JSON
|
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).
|
||||||
|
|||||||
@ -1,14 +1,17 @@
|
|||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { getAdminSession } from "@/lib/require-admin"
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Admin",
|
title: "Admin",
|
||||||
description: "Datenschutzerklärungen verwalten",
|
description: "Datenschutzerklärungen verwalten",
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminLayout({
|
export default async function AdminLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
|
if (!(await getAdminSession())) redirect("/auth/signin")
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -217,7 +217,7 @@ export default function AdminDashboard() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<div className="mx-auto flex h-14 max-w-7xl items-center justify-between gap-2 px-4 sm:gap-4 sm:px-6">
|
<div className="mx-auto flex min-h-14 flex-wrap py-2 max-w-7xl items-center justify-between gap-2 px-4 sm:gap-4 sm:px-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Brand />
|
<Brand />
|
||||||
<Badge variant="secondary">Admin</Badge>
|
<Badge variant="secondary">Admin</Badge>
|
||||||
@ -252,6 +252,7 @@ export default function AdminDashboard() {
|
|||||||
<TooltipContent>Standardtexte</TooltipContent>
|
<TooltipContent>Standardtexte</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
{session?.user.role === "SUPER_ADMIN" && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@ -265,12 +266,13 @@ export default function AdminDashboard() {
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Benutzer</TooltipContent>
|
<TooltipContent>Benutzer</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="ml-1">
|
<Button variant="ghost" size="icon" className="ml-1" aria-label="Benutzermenü">
|
||||||
<Avatar>
|
<Avatar>
|
||||||
<AvatarFallback>
|
<AvatarFallback>
|
||||||
{initials(session?.user?.name || session?.user?.email || "?")}
|
{initials(session?.user?.name || session?.user?.email || "?")}
|
||||||
@ -296,10 +298,10 @@ export default function AdminDashboard() {
|
|||||||
<Scale />
|
<Scale />
|
||||||
Rechtliche Seiten
|
Rechtliche Seiten
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => setIsUserManagementOpen(true)}>
|
{session?.user.role === "SUPER_ADMIN" && <DropdownMenuItem onSelect={() => setIsUserManagementOpen(true)}>
|
||||||
<Users />
|
<Users />
|
||||||
Benutzer verwalten
|
Benutzer verwalten
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { PrismaClient } from '@prisma/client'
|
|
||||||
import { requireAdmin } from '@/lib/require-admin'
|
import { requireAdmin } from '@/lib/require-admin'
|
||||||
import { DEFAULTED_FIELDS, DEFAULTED_FIELD_LABELS, REQUIRED_DEFAULTED_FIELDS } from '@/lib/policy-defaults'
|
import { DEFAULTED_FIELDS, DEFAULTED_FIELD_LABELS, REQUIRED_DEFAULTED_FIELDS } from '@/lib/policy-defaults'
|
||||||
import { readPolicyDefaults } from '@/lib/policy-defaults.server'
|
import { readPolicyDefaults } from '@/lib/policy-defaults.server'
|
||||||
|
|
||||||
// Prisma client instance
|
// Prisma client instance
|
||||||
const prisma = new PrismaClient()
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
// GET /api/policy-defaults - liefert die zentralen Standardtexte
|
// GET /api/policy-defaults - liefert die zentralen Standardtexte
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { PrismaClient } from '@prisma/client'
|
import { parsePolicyInput, type PolicyInput } from '@/lib/policy-input'
|
||||||
import { isAdminSession, requireAdmin } from '@/lib/require-admin'
|
import { isAdminSession, requireAdmin } from '@/lib/require-admin'
|
||||||
import {
|
import {
|
||||||
DEFAULTED_FIELDS,
|
DEFAULTED_FIELDS,
|
||||||
@ -10,7 +10,7 @@ import { resolveSlug } from '@/lib/policy-slug.server'
|
|||||||
import { PolicyDefaults } from '@/types/privacy-policy'
|
import { PolicyDefaults } from '@/types/privacy-policy'
|
||||||
|
|
||||||
// Prisma client instance
|
// Prisma client instance
|
||||||
const prisma = new PrismaClient()
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prüft die Angaben einer Erklärung.
|
* Prüft die Angaben einer Erklärung.
|
||||||
@ -20,7 +20,7 @@ const prisma = new PrismaClient()
|
|||||||
* erst beim Veröffentlichen verlangt – ein Entwurf darf unvollständig sein,
|
* erst beim Veröffentlichen verlangt – ein Entwurf darf unvollständig sein,
|
||||||
* eine öffentlich sichtbare Erklärung nicht.
|
* eine öffentlich sichtbare Erklärung nicht.
|
||||||
*/
|
*/
|
||||||
function validatePrivacyPolicyData(data: any, defaults: PolicyDefaults) {
|
function validatePrivacyPolicyData(data: PolicyInput, defaults: PolicyDefaults) {
|
||||||
const errors: string[] = []
|
const errors: string[] = []
|
||||||
|
|
||||||
// Ohne diese Angaben ist der Datensatz nicht einmal als Entwurf brauchbar
|
// Ohne diese Angaben ist der Datensatz nicht einmal als Entwurf brauchbar
|
||||||
@ -47,7 +47,7 @@ function validatePrivacyPolicyData(data: any, defaults: PolicyDefaults) {
|
|||||||
* Übernimmt die Baustein-Felder aus der Anfrage: leer wird zu `null` und
|
* Übernimmt die Baustein-Felder aus der Anfrage: leer wird zu `null` und
|
||||||
* bedeutet damit „Standardtext verwenden“.
|
* bedeutet damit „Standardtext verwenden“.
|
||||||
*/
|
*/
|
||||||
function defaultedFieldsFrom(data: any) {
|
function defaultedFieldsFrom(data: PolicyInput) {
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
DEFAULTED_FIELDS.map((field) => [field, data[field]?.trim() || null])
|
DEFAULTED_FIELDS.map((field) => [field, data[field]?.trim() || null])
|
||||||
) as Record<(typeof DEFAULTED_FIELDS)[number], string | null>
|
) as Record<(typeof DEFAULTED_FIELDS)[number], string | null>
|
||||||
@ -108,7 +108,8 @@ export async function PUT(
|
|||||||
|
|
||||||
const { id: rawId } = await params
|
const { id: rawId } = await params
|
||||||
const id = parseInt(rawId)
|
const id = parseInt(rawId)
|
||||||
const data = await request.json()
|
const data = parsePolicyInput(await request.json().catch(() => null))
|
||||||
|
if (!data) return NextResponse.json({ error: 'Ungültige Feldtypen oder zu lange Texte.' }, { status: 400 })
|
||||||
|
|
||||||
// Validate ID is a number
|
// Validate ID is a number
|
||||||
if (isNaN(id)) {
|
if (isNaN(id)) {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { PrismaClient } from '@prisma/client'
|
import { parsePolicyInput, type PolicyInput } from '@/lib/policy-input'
|
||||||
import { isAdminSession, requireAdmin } from '@/lib/require-admin'
|
import { isAdminSession, requireAdmin } from '@/lib/require-admin'
|
||||||
import {
|
import {
|
||||||
DEFAULTED_FIELDS,
|
DEFAULTED_FIELDS,
|
||||||
@ -11,7 +11,7 @@ import { sortByTitle } from '@/lib/policy-sort'
|
|||||||
import { PolicyDefaults } from '@/types/privacy-policy'
|
import { PolicyDefaults } from '@/types/privacy-policy'
|
||||||
|
|
||||||
// Prisma client instance
|
// Prisma client instance
|
||||||
const prisma = new PrismaClient()
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prüft die Angaben einer Erklärung.
|
* Prüft die Angaben einer Erklärung.
|
||||||
@ -21,7 +21,7 @@ const prisma = new PrismaClient()
|
|||||||
* erst beim Veröffentlichen verlangt – ein Entwurf darf unvollständig sein,
|
* erst beim Veröffentlichen verlangt – ein Entwurf darf unvollständig sein,
|
||||||
* eine öffentlich sichtbare Erklärung nicht.
|
* eine öffentlich sichtbare Erklärung nicht.
|
||||||
*/
|
*/
|
||||||
function validatePrivacyPolicyData(data: any, defaults: PolicyDefaults) {
|
function validatePrivacyPolicyData(data: PolicyInput, defaults: PolicyDefaults) {
|
||||||
const errors: string[] = []
|
const errors: string[] = []
|
||||||
|
|
||||||
// Ohne diese Angaben ist der Datensatz nicht einmal als Entwurf brauchbar
|
// Ohne diese Angaben ist der Datensatz nicht einmal als Entwurf brauchbar
|
||||||
@ -48,7 +48,7 @@ function validatePrivacyPolicyData(data: any, defaults: PolicyDefaults) {
|
|||||||
* Übernimmt die Baustein-Felder aus der Anfrage: leer wird zu `null` und
|
* Übernimmt die Baustein-Felder aus der Anfrage: leer wird zu `null` und
|
||||||
* bedeutet damit „Standardtext verwenden“.
|
* bedeutet damit „Standardtext verwenden“.
|
||||||
*/
|
*/
|
||||||
function defaultedFieldsFrom(data: any) {
|
function defaultedFieldsFrom(data: PolicyInput) {
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
DEFAULTED_FIELDS.map((field) => [field, data[field]?.trim() || null])
|
DEFAULTED_FIELDS.map((field) => [field, data[field]?.trim() || null])
|
||||||
) as Record<(typeof DEFAULTED_FIELDS)[number], string | null>
|
) as Record<(typeof DEFAULTED_FIELDS)[number], string | null>
|
||||||
@ -95,7 +95,8 @@ export async function POST(request: NextRequest) {
|
|||||||
const denied = await requireAdmin()
|
const denied = await requireAdmin()
|
||||||
if (denied) return denied
|
if (denied) return denied
|
||||||
|
|
||||||
const data = await request.json()
|
const data = parsePolicyInput(await request.json().catch(() => null))
|
||||||
|
if (!data) return NextResponse.json({ error: 'Ungültige Feldtypen oder zu lange Texte.' }, { status: 400 })
|
||||||
const defaults = await readPolicyDefaults(prisma)
|
const defaults = await readPolicyDefaults(prisma)
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { PrismaClient } from '@prisma/client'
|
|
||||||
import { requireAdmin } from '@/lib/require-admin'
|
import { requireAdmin } from '@/lib/require-admin'
|
||||||
import { SITE_MEDIA, SITE_PAGES, isSafeMediaUrl } from '@/lib/site-pages'
|
import { EMPTY_SITE_PAGES, SITE_MEDIA, SITE_PAGES, validateSitePages } from '@/lib/site-pages'
|
||||||
import { readSitePages } from '@/lib/site-pages.server'
|
import { readSitePages } from '@/lib/site-pages.server'
|
||||||
|
|
||||||
// Prisma client instance
|
// Prisma client instance
|
||||||
const prisma = new PrismaClient()
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
// GET /api/site-pages - liefert die Inhalte der rechtlichen Seiten
|
// GET /api/site-pages - liefert die Inhalte der rechtlichen Seiten
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
@ -28,37 +27,21 @@ export async function PUT(request: NextRequest) {
|
|||||||
|
|
||||||
const data = await request.json()
|
const data = await request.json()
|
||||||
|
|
||||||
// Nur die bekannten Seiten übernehmen, jeweils getrimmt. Ein leerer Text
|
|
||||||
// ist zulässig und bedeutet: Seite nicht veröffentlichen.
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
const previous = await tx.sitePages.findUnique({ where: { id: 1 } })
|
||||||
|
const errors = validateSitePages(data, previous ?? EMPTY_SITE_PAGES)
|
||||||
|
if (errors.length) return { errors }
|
||||||
const values = Object.fromEntries(
|
const values = Object.fromEntries(
|
||||||
[...SITE_PAGES, ...SITE_MEDIA].map((key) => [
|
[...SITE_PAGES, ...SITE_MEDIA].map(key => [key, data[key].trim()])
|
||||||
key,
|
|
||||||
String(data[key] ?? '').trim(),
|
|
||||||
])
|
|
||||||
)
|
)
|
||||||
|
const saved = await tx.sitePages.upsert({
|
||||||
// Medienadressen landen in einem src-Attribut. Nur eigener Server oder
|
where: { id: 1 }, update: values, create: { id: 1, ...values },
|
||||||
// https – alles andere wird abgewiesen, statt still gespeichert zu werden.
|
|
||||||
const ungueltig = SITE_MEDIA.filter((key) => !isSafeMediaUrl(values[key]))
|
|
||||||
if (ungueltig.length > 0) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: 'Ungültige Adresse',
|
|
||||||
details: ungueltig.map(
|
|
||||||
(key) =>
|
|
||||||
`${key}: nur eine Adresse auf diesem Server (beginnt mit /) oder https ist zulässig`
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const saved = await prisma.sitePages.upsert({
|
|
||||||
where: { id: 1 },
|
|
||||||
update: values,
|
|
||||||
create: { id: 1, ...values },
|
|
||||||
})
|
})
|
||||||
|
return { saved }
|
||||||
|
})
|
||||||
|
if (result.errors) return NextResponse.json({ error: "Bitte prüfen Sie die Seiteninhalte.", details: result.errors }, { status: 400 })
|
||||||
|
const saved = result.saved
|
||||||
return NextResponse.json(saved)
|
return NextResponse.json(saved)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating site pages:', error)
|
console.error('Error updating site pages:', error)
|
||||||
|
|||||||
@ -1,150 +1,51 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { auth } from '@/lib/auth'
|
import { getAdminSession } from '@/lib/require-admin'
|
||||||
import { PrismaClient } from '@prisma/client'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { validateUserInput } from '@/lib/user-validation'
|
||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
|
import type { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
const prisma = new PrismaClient()
|
async function mutate(request: NextRequest, params: Promise<{ id: string }>, remove: boolean) {
|
||||||
|
const session = await getAdminSession()
|
||||||
// PUT /api/users/[id] - update user
|
if (!session) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||||
export async function PUT(
|
if (session.user.role !== 'SUPER_ADMIN') return NextResponse.json({ error: 'Nur Super-Admins dürfen Benutzer verwalten.' }, { status: 403 })
|
||||||
request: NextRequest,
|
const raw = (await params).id
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
if (!/^[1-9]\d*$/.test(raw) || !Number.isSafeInteger(Number(raw))) return NextResponse.json({ error: 'Ungültige ID' }, { status: 400 })
|
||||||
) {
|
const id = Number(raw)
|
||||||
try {
|
try {
|
||||||
const session = await auth()
|
const data = remove ? {} : await request.json()
|
||||||
|
const error = remove ? null : validateUserInput(data, false)
|
||||||
if (!session || (session.user.role !== 'ADMIN' && session.user.role !== 'SUPER_ADMIN')) {
|
if (error) return NextResponse.json({ error }, { status: 400 })
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
if (remove && id === Number(session.user.id)) return NextResponse.json({ error: 'Sie können sich nicht selbst löschen.' }, { status: 400 })
|
||||||
|
const update: Prisma.UserUpdateInput = {}
|
||||||
|
if (data.email !== undefined) update.email = data.email.trim().toLowerCase()
|
||||||
|
if (data.name !== undefined) update.name = data.name?.trim() || null
|
||||||
|
if (data.role !== undefined) update.role = data.role
|
||||||
|
if (data.isActive !== undefined) update.isActive = data.isActive
|
||||||
|
if (data.password) update.password = await bcrypt.hash(data.password, 12)
|
||||||
|
const result = await prisma.$transaction(async tx => {
|
||||||
|
const user = await tx.user.findUnique({ where: { id } })
|
||||||
|
if (!user) return { status: 404, body: { error: 'Benutzer nicht gefunden.' } }
|
||||||
|
const demote = remove || (data.role !== undefined && data.role !== 'SUPER_ADMIN') || data.isActive === false
|
||||||
|
if (user.isActive && user.role === 'SUPER_ADMIN' && demote &&
|
||||||
|
await tx.user.count({ where: { role: 'SUPER_ADMIN', isActive: true } }) <= 1) {
|
||||||
|
return { status: 400, body: { error: 'Mindestens ein aktiver Super-Admin muss erhalten bleiben.' } }
|
||||||
}
|
}
|
||||||
|
if (remove) {
|
||||||
const { id: rawId } = await params
|
await tx.user.delete({ where: { id } })
|
||||||
const id = parseInt(rawId)
|
return { status: 200, body: { message: 'Benutzer gelöscht.' } }
|
||||||
if (isNaN(id)) {
|
|
||||||
return NextResponse.json({ error: 'Ungültige ID' }, { status: 400 })
|
|
||||||
}
|
}
|
||||||
|
const saved = await tx.user.update({ where: { id }, data: update,
|
||||||
const data = await request.json()
|
select: { id: true, email: true, name: true, role: true, isActive: true, updatedAt: true } })
|
||||||
const { email, name, password, role, isActive } = data
|
return { status: 200, body: saved }
|
||||||
|
|
||||||
// Check if user exists
|
|
||||||
const existingUser = await prisma.user.findUnique({
|
|
||||||
where: { id }
|
|
||||||
})
|
})
|
||||||
|
return NextResponse.json(result.body, { status: result.status })
|
||||||
if (!existingUser) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Benutzer nicht gefunden' },
|
|
||||||
{ status: 404 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if email is already taken by another user
|
|
||||||
if (email && email !== existingUser.email) {
|
|
||||||
const emailExists = await prisma.user.findUnique({
|
|
||||||
where: { email }
|
|
||||||
})
|
|
||||||
|
|
||||||
if (emailExists) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'E-Mail wird bereits verwendet' },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare update data
|
|
||||||
const updateData: any = {}
|
|
||||||
|
|
||||||
if (email) updateData.email = email
|
|
||||||
if (name !== undefined) updateData.name = name || null
|
|
||||||
if (role) updateData.role = role
|
|
||||||
if (isActive !== undefined) updateData.isActive = isActive
|
|
||||||
|
|
||||||
// Hash new password if provided
|
|
||||||
if (password) {
|
|
||||||
if (password.length < 6) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Passwort muss mindestens 6 Zeichen lang sein' },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
updateData.password = await bcrypt.hash(password, 12)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update user
|
|
||||||
const updatedUser = await prisma.user.update({
|
|
||||||
where: { id },
|
|
||||||
data: updateData,
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
name: true,
|
|
||||||
role: true,
|
|
||||||
isActive: true,
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json(updatedUser)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating user:', error)
|
if (error && typeof error === 'object' && 'code' in error && error.code === 'P2002')
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: 'Diese E-Mail-Adresse wird bereits verwendet.' }, { status: 409 })
|
||||||
{ error: 'Fehler beim Aktualisieren des Benutzers' },
|
console.error('Benutzerverwaltung fehlgeschlagen', error)
|
||||||
{ status: 500 }
|
return NextResponse.json({ error: 'Benutzer konnte nicht geändert werden.' }, { status: 500 })
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DELETE /api/users/[id] - delete user
|
|
||||||
export async function DELETE(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const session = await auth()
|
|
||||||
|
|
||||||
if (!session || (session.user.role !== 'ADMIN' && session.user.role !== 'SUPER_ADMIN')) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id: rawId } = await params
|
|
||||||
const id = parseInt(rawId)
|
|
||||||
if (isNaN(id)) {
|
|
||||||
return NextResponse.json({ error: 'Ungültige ID' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent self-deletion
|
|
||||||
if (id === parseInt(session.user.id)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Sie können sich nicht selbst löschen' },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user exists
|
|
||||||
const existingUser = await prisma.user.findUnique({
|
|
||||||
where: { id }
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!existingUser) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Benutzer nicht gefunden' },
|
|
||||||
{ status: 404 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete user
|
|
||||||
await prisma.user.delete({
|
|
||||||
where: { id }
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({ message: 'Benutzer erfolgreich gelöscht' })
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting user:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Fehler beim Löschen des Benutzers' },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { return mutate(request, params, false) }
|
||||||
|
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { return mutate(request, params, true) }
|
||||||
|
|||||||
@ -1,16 +1,16 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { auth } from '@/lib/auth'
|
import { getAdminSession } from '@/lib/require-admin'
|
||||||
import { PrismaClient } from '@prisma/client'
|
|
||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
|
import { validateUserInput } from '@/lib/user-validation'
|
||||||
|
|
||||||
const prisma = new PrismaClient()
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
// GET /api/users - get all users (admin only)
|
// GET /api/users - get all users (admin only)
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const session = await auth()
|
const session = await getAdminSession()
|
||||||
|
|
||||||
if (!session || (session.user.role !== 'ADMIN' && session.user.role !== 'SUPER_ADMIN')) {
|
if (!session || session.user.role !== 'SUPER_ADMIN') {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,14 +48,17 @@ export async function GET() {
|
|||||||
// POST /api/users - create new user (admin only)
|
// POST /api/users - create new user (admin only)
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await auth()
|
const session = await getAdminSession()
|
||||||
|
|
||||||
if (!session || (session.user.role !== 'ADMIN' && session.user.role !== 'SUPER_ADMIN')) {
|
if (!session || session.user.role !== 'SUPER_ADMIN') {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await request.json()
|
const data = await request.json()
|
||||||
const { email, name, password, role } = data
|
const error = validateUserInput(data, true)
|
||||||
|
if (error) return NextResponse.json({ error }, { status: 400 })
|
||||||
|
const { name, password, role } = data
|
||||||
|
const email = data.email.trim().toLowerCase()
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!email || !password || !role) {
|
if (!email || !password || !role) {
|
||||||
|
|||||||
3
app/auth/signin/layout.tsx
Normal file
3
app/auth/signin/layout.tsx
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import type { Metadata } from "next"
|
||||||
|
export const metadata: Metadata = { title: "Anmeldung" }
|
||||||
|
export default function SignInLayout({children}: {children: React.ReactNode}) { return children }
|
||||||
@ -73,7 +73,7 @@ export default function SignInPage() {
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-center gap-2.5 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
|
<div id="signin-error" role="alert" className="flex items-center gap-2.5 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
|
||||||
<AlertCircle className="size-4 shrink-0" />
|
<AlertCircle className="size-4 shrink-0" />
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
@ -84,6 +84,8 @@ export default function SignInPage() {
|
|||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
|
aria-invalid={!!error}
|
||||||
|
aria-describedby={error ? "signin-error" : undefined}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
@ -99,6 +101,8 @@ export default function SignInPage() {
|
|||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
aria-describedby={error ? "signin-error" : undefined}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
@ -116,7 +120,7 @@ export default function SignInPage() {
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{showPassword ? <EyeOff /> : <Eye />}
|
{showPassword ? <EyeOff /> : <Eye />}
|
||||||
<span className="sr-only">Passwort anzeigen</span>
|
<span className="sr-only">{showPassword ? "Passwort verbergen" : "Passwort anzeigen"}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
import { PrismaClient } from "@prisma/client"
|
|
||||||
import { ArrowLeft, Calendar } from "lucide-react"
|
import { ArrowLeft, Calendar } from "lucide-react"
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
@ -16,7 +15,7 @@ import { formatDate } from "@/lib/format"
|
|||||||
import { PrivacyPolicy } from "@/types/privacy-policy"
|
import { PrivacyPolicy } from "@/types/privacy-policy"
|
||||||
|
|
||||||
// Prisma client instance
|
// 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.
|
// Inhalt kommt aus der Datenbank und ändert sich mit jeder Pflege im Admin.
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|||||||
@ -163,7 +163,8 @@
|
|||||||
Markdown-Inhalte
|
Markdown-Inhalte
|
||||||
--------------------------------------------------------------- */
|
--------------------------------------------------------------- */
|
||||||
.markdown-content {
|
.markdown-content {
|
||||||
@apply text-sm leading-relaxed text-foreground;
|
@apply text-base leading-relaxed text-foreground;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-content > :first-child {
|
.markdown-content > :first-child {
|
||||||
@ -221,3 +222,64 @@
|
|||||||
.markdown-content blockquote {
|
.markdown-content blockquote {
|
||||||
@apply my-3 border-l-2 border-border pl-4 text-muted-foreground;
|
@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; } }
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import { PrismaClient } from "@prisma/client"
|
|
||||||
|
|
||||||
import { PolicyList } from "@/components/policy-list"
|
import { PolicyList } from "@/components/policy-list"
|
||||||
import { PublicHeader } from "@/components/public-header"
|
import { PublicHeader } from "@/components/public-header"
|
||||||
@ -7,7 +6,7 @@ import { readPolicyDefaults, toApiPolicy } from "@/lib/policy-defaults.server"
|
|||||||
import { sortByTitle } from "@/lib/policy-sort"
|
import { sortByTitle } from "@/lib/policy-sort"
|
||||||
|
|
||||||
// Prisma client instance
|
// Prisma client instance
|
||||||
const prisma = new PrismaClient()
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
// Liste und Fußzeile kommen aus der Datenbank.
|
// Liste und Fußzeile kommen aus der Datenbank.
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|||||||
@ -19,7 +19,7 @@ export function Brand({ className, subtitle }: BrandProps) {
|
|||||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-brand text-brand-foreground">
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-brand text-brand-foreground">
|
||||||
<ShieldCheck className="size-4.5" />
|
<ShieldCheck className="size-4.5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex min-w-0 flex-wrap items-baseline gap-2">
|
||||||
<span className="text-base leading-none font-semibold tracking-tight">
|
<span className="text-base leading-none font-semibold tracking-tight">
|
||||||
{name === DEFAULT_APP_NAME ? (
|
{name === DEFAULT_APP_NAME ? (
|
||||||
// Der Vorgabename wird zweifarbig gesetzt; ein konfigurierter
|
// Der Vorgabename wird zweifarbig gesetzt; ein konfigurierter
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { AlertCircle, PencilLine, Undo2 } from "lucide-react"
|
import { AlertCircle, PencilLine, Undo2 } from "lucide-react"
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { MarkdownInput } from "@/components/markdown-input"
|
import { MarkdownInput } from "@/components/markdown-input"
|
||||||
import { MarkdownRenderer } from "@/components/markdown-renderer"
|
import { MarkdownRenderer } from "@/components/markdown-renderer"
|
||||||
|
|
||||||
@ -45,14 +44,23 @@ export function DefaultableField({
|
|||||||
// steuert ihn der Benutzer über die beiden Schaltflächen.
|
// steuert ihn der Benutzer über die beiden Schaltflächen.
|
||||||
const [isOverride, setIsOverride] = useState(() => value.trim().length > 0)
|
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. */
|
/** Wechselt in den Sonderfall und übernimmt den Standard als Ausgangstext. */
|
||||||
const startOverride = () => {
|
const startOverride = () => {
|
||||||
|
focusAfterChange.current = true
|
||||||
setIsOverride(true)
|
setIsOverride(true)
|
||||||
if (!value.trim()) onChange(defaultValue)
|
if (!value.trim()) onChange(defaultValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Kehrt zum Standard zurück und verwirft den abweichenden Text. */
|
/** Kehrt zum Standard zurück und verwirft den abweichenden Text. */
|
||||||
const resetToDefault = () => {
|
const resetToDefault = () => {
|
||||||
|
focusAfterChange.current = true
|
||||||
setIsOverride(false)
|
setIsOverride(false)
|
||||||
onChange("")
|
onChange("")
|
||||||
}
|
}
|
||||||
@ -91,15 +99,17 @@ export function DefaultableField({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<Label htmlFor={id} className="gap-1">
|
<span id={id + "-label"} className="text-sm font-medium">
|
||||||
{label}
|
{label}
|
||||||
{required && <span className="text-destructive">*</span>}
|
{required && <span className="text-destructive">*</span>}
|
||||||
</Label>
|
</span>
|
||||||
<Badge variant="secondary">Standard</Badge>
|
<Badge variant="secondary">Standard</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
id={id}
|
id={id}
|
||||||
|
role="group"
|
||||||
|
aria-labelledby={id + "-label"}
|
||||||
className="rounded-md border border-dashed bg-muted/30 p-4"
|
className="rounded-md border border-dashed bg-muted/30 p-4"
|
||||||
style={{ minHeight: `${rows * 1.5 + 1}rem` }}
|
style={{ minHeight: `${rows * 1.5 + 1}rem` }}
|
||||||
>
|
>
|
||||||
@ -122,6 +132,7 @@ export function DefaultableField({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
id={id + "-override"}
|
||||||
onClick={startOverride}
|
onClick={startOverride}
|
||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
>
|
>
|
||||||
|
|||||||
14
components/form-errors.tsx
Normal file
14
components/form-errors.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
"use client"
|
||||||
|
import { useEffect, useRef } from "react"
|
||||||
|
|
||||||
|
export function FormErrors({ errors }: { errors: string[] }) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
useEffect(() => { if (errors.length) ref.current?.focus() }, [errors])
|
||||||
|
if (!errors.length) return null
|
||||||
|
return <div ref={ref} tabIndex={-1} role="region" aria-label="Fehler beim Speichern"
|
||||||
|
className="max-h-40 shrink-0 overflow-auto rounded-lg border border-destructive p-3 text-sm text-destructive outline-none focus:ring-2 focus:ring-ring">
|
||||||
|
<p className="font-semibold">Bitte prüfen Sie Ihre Eingaben.</p>
|
||||||
|
<button type="button" className="my-2 underline" onClick={() => ref.current?.closest("form")?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus()}>Zum ersten fehlerhaften Feld</button>
|
||||||
|
<ul className="list-disc pl-5">{errors.map((error, index) => <li key={index}>{error}</li>)}</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@ -62,7 +62,8 @@ export async function LegalPage({ page }: { page: SitePageKey }) {
|
|||||||
<div className="aspect-video w-full overflow-hidden rounded-lg border bg-muted">
|
<div className="aspect-video w-full overflow-hidden rounded-lg border bg-muted">
|
||||||
<iframe
|
<iframe
|
||||||
src={video}
|
src={video}
|
||||||
title="Video in Deutscher Gebärdensprache mit Erläuterungen zu diesem Portal"
|
title="Gebärdensprach-Video des Amts Leezen"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
|
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
|
||||||
allowFullScreen
|
allowFullScreen
|
||||||
className="size-full border-0"
|
className="size-full border-0"
|
||||||
@ -89,7 +90,8 @@ export async function LegalPage({ page }: { page: SitePageKey }) {
|
|||||||
</video>
|
</video>
|
||||||
)}
|
)}
|
||||||
<figcaption className="mt-2 text-sm text-muted-foreground">
|
<figcaption className="mt-2 text-sm text-muted-foreground">
|
||||||
Erläuterungen zu diesem Portal in Deutscher Gebärdensprache
|
Video in Deutscher Gebärdensprache.{" "}
|
||||||
|
<a className="underline" href={video}>Video direkt öffnen</a>
|
||||||
</figcaption>
|
</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Eye, Pencil } from "lucide-react"
|
import { Eye, Pencil } from "lucide-react"
|
||||||
|
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
@ -33,6 +33,7 @@ export function MarkdownInput({
|
|||||||
invalid = false,
|
invalid = false,
|
||||||
}: MarkdownInputProps) {
|
}: MarkdownInputProps) {
|
||||||
const [tab, setTab] = useState<"edit" | "preview">("edit")
|
const [tab, setTab] = useState<"edit" | "preview">("edit")
|
||||||
|
useEffect(() => { if (invalid) setTab("edit") }, [invalid])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
@ -40,12 +41,12 @@ export function MarkdownInput({
|
|||||||
onValueChange={(next) => setTab(next as "edit" | "preview")}
|
onValueChange={(next) => setTab(next as "edit" | "preview")}
|
||||||
className={cn("gap-2", className)}
|
className={cn("gap-2", className)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<Label htmlFor={id} className="gap-1">
|
<Label htmlFor={id} className="gap-1">
|
||||||
{label}
|
{label}
|
||||||
{required && <span className="text-destructive">*</span>}
|
{required && <span className="text-destructive"> (Pflichtfeld)</span>}
|
||||||
</Label>
|
</Label>
|
||||||
<TabsList className="h-8">
|
<TabsList aria-label={label + ": Ansicht"} className="h-8">
|
||||||
<TabsTrigger value="edit" className="px-2.5">
|
<TabsTrigger value="edit" className="px-2.5">
|
||||||
<Pencil />
|
<Pencil />
|
||||||
<span className="sr-only">Bearbeiten</span>
|
<span className="sr-only">Bearbeiten</span>
|
||||||
@ -65,8 +66,11 @@ export function MarkdownInput({
|
|||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
aria-invalid={invalid}
|
aria-invalid={invalid}
|
||||||
|
aria-required={required}
|
||||||
|
aria-describedby={invalid ? id + "-error" : undefined}
|
||||||
className="resize-y font-mono text-sm"
|
className="resize-y font-mono text-sm"
|
||||||
/>
|
/>
|
||||||
|
{invalid && <p id={id + "-error"} className="text-sm text-destructive">Bitte ergänzen oder korrigieren Sie dieses Feld.</p>}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="preview">
|
<TabsContent value="preview">
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { FormErrors } from "@/components/form-errors"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { AlertCircle } from "lucide-react"
|
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@ -171,7 +172,7 @@ export function PolicyDefaultsForm({ open, onOpenChange }: PolicyDefaultsFormPro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-h-[88vh] sm:max-w-4xl">
|
<DialogContent className="max-h-[calc(100dvh-2rem)] sm:max-w-4xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Standardtexte</DialogTitle>
|
<DialogTitle>Standardtexte</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
@ -181,7 +182,7 @@ export function PolicyDefaultsForm({ open, onOpenChange }: PolicyDefaultsFormPro
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col gap-4">
|
<form aria-label="Bearbeitungsformular" onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col gap-4">
|
||||||
<Tabs
|
<Tabs
|
||||||
value={tab}
|
value={tab}
|
||||||
onValueChange={(value) => setTab(value as TabId)}
|
onValueChange={(value) => setTab(value as TabId)}
|
||||||
@ -193,16 +194,7 @@ export function PolicyDefaultsForm({ open, onOpenChange }: PolicyDefaultsFormPro
|
|||||||
<TabsTrigger value="rights">Ihre Rechte</TabsTrigger>
|
<TabsTrigger value="rights">Ihre Rechte</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{errors.length > 0 && (
|
<FormErrors errors={errors} />
|
||||||
<div className="flex gap-2.5 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
|
|
||||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{errors.map((error, index) => (
|
|
||||||
<li key={index}>{error}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogBody className="pt-1">
|
<DialogBody className="pt-1">
|
||||||
<TabsContent value="base" className="grid gap-5 md:grid-cols-2">
|
<TabsContent value="base" className="grid gap-5 md:grid-cols-2">
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import {
|
|||||||
DialogBody,
|
DialogBody,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
|
DialogDescription,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
@ -100,13 +101,14 @@ export function PolicyDetail({ policy, open, onOpenChange }: PolicyDetailProps)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-h-[85vh] sm:max-w-3xl">
|
<DialogContent className="max-h-[calc(100dvh-2rem)] sm:max-w-3xl">
|
||||||
{policy && (
|
{policy && (
|
||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div className="min-w-0 space-y-2">
|
<div className="min-w-0 space-y-2">
|
||||||
<DialogTitle>{policy.title}</DialogTitle>
|
<DialogTitle>{policy.title}</DialogTitle>
|
||||||
|
<DialogDescription>Vorschau der Datenschutzerklärung</DialogDescription>
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<Calendar className="size-4" />
|
<Calendar className="size-4" />
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { FormErrors } from "@/components/form-errors"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { AlertCircle } from "lucide-react"
|
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@ -276,7 +277,7 @@ export function PolicyForm({ open, onOpenChange, policy }: PolicyFormProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-h-[88vh] sm:max-w-4xl">
|
<DialogContent className="max-h-[calc(100dvh-2rem)] sm:max-w-4xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{policy ? "Datenschutzerklärung bearbeiten" : "Neue Datenschutzerklärung"}
|
{policy ? "Datenschutzerklärung bearbeiten" : "Neue Datenschutzerklärung"}
|
||||||
@ -289,13 +290,13 @@ export function PolicyForm({ open, onOpenChange, policy }: PolicyFormProps) {
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col gap-4">
|
<form aria-label="Bearbeitungsformular" onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col gap-4">
|
||||||
<Tabs
|
<Tabs
|
||||||
value={tab}
|
value={tab}
|
||||||
onValueChange={(value) => setTab(value as TabId)}
|
onValueChange={(value) => setTab(value as TabId)}
|
||||||
className="min-h-0 flex-1 gap-3"
|
className="min-h-0 flex-1 gap-3"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="base">Stammdaten</TabsTrigger>
|
<TabsTrigger value="base">Stammdaten</TabsTrigger>
|
||||||
<TabsTrigger value="processing">Verarbeitung</TabsTrigger>
|
<TabsTrigger value="processing">Verarbeitung</TabsTrigger>
|
||||||
@ -314,16 +315,7 @@ export function PolicyForm({ open, onOpenChange, policy }: PolicyFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{errors.length > 0 && (
|
<FormErrors errors={errors} />
|
||||||
<div className="flex gap-2.5 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
|
|
||||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{errors.map((error, index) => (
|
|
||||||
<li key={index}>{error}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogBody className="pt-1">
|
<DialogBody className="pt-1">
|
||||||
<TabsContent value="base" className="space-y-5">
|
<TabsContent value="base" className="space-y-5">
|
||||||
@ -338,7 +330,10 @@ export function PolicyForm({ open, onOpenChange, policy }: PolicyFormProps) {
|
|||||||
onChange={(e) => set("title", e.target.value)}
|
onChange={(e) => set("title", e.target.value)}
|
||||||
placeholder="z. B. Datenschutzerklärung für die Website"
|
placeholder="z. B. Datenschutzerklärung für die Website"
|
||||||
aria-invalid={isInvalid("title")}
|
aria-invalid={isInvalid("title")}
|
||||||
|
aria-required="true"
|
||||||
|
aria-describedby={isInvalid("title") ? "title-error" : undefined}
|
||||||
/>
|
/>
|
||||||
|
{isInvalid("title") && <p id="title-error" className="text-sm text-destructive">Bitte geben Sie einen Titel ein.</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-5 md:grid-cols-2">
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useRef, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ChevronRight, FileText, Search, X } from "lucide-react"
|
import { ChevronRight, FileText, Search, X } from "lucide-react"
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
@ -17,6 +16,8 @@ import { PrivacyPolicy } from "@/types/privacy-policy"
|
|||||||
export function PolicyList({ initial }: { initial: PrivacyPolicy[] }) {
|
export function PolicyList({ initial }: { initial: PrivacyPolicy[] }) {
|
||||||
const { policies, isLoading, error } = usePublicPrivacyPolicies(initial)
|
const { policies, isLoading, error } = usePublicPrivacyPolicies(initial)
|
||||||
const [searchTerm, setSearchTerm] = useState("")
|
const [searchTerm, setSearchTerm] = useState("")
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null)
|
||||||
|
const resetSearch = () => { setSearchTerm(""); searchRef.current?.focus() }
|
||||||
|
|
||||||
const filteredPolicies = useMemo(() => {
|
const filteredPolicies = useMemo(() => {
|
||||||
const term = searchTerm.trim().toLowerCase()
|
const term = searchTerm.trim().toLowerCase()
|
||||||
@ -52,6 +53,7 @@ export function PolicyList({ initial }: { initial: PrivacyPolicy[] }) {
|
|||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
id="suche"
|
id="suche"
|
||||||
|
ref={searchRef}
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Suchen…"
|
placeholder="Suchen…"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
@ -62,7 +64,7 @@ export function PolicyList({ initial }: { initial: PrivacyPolicy[] }) {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
onClick={() => setSearchTerm("")}
|
onClick={resetSearch}
|
||||||
className="absolute top-1/2 right-0.5 -translate-y-1/2 text-muted-foreground"
|
className="absolute top-1/2 right-0.5 -translate-y-1/2 text-muted-foreground"
|
||||||
>
|
>
|
||||||
<X aria-hidden="true" />
|
<X aria-hidden="true" />
|
||||||
@ -106,7 +108,7 @@ export function PolicyList({ initial }: { initial: PrivacyPolicy[] }) {
|
|||||||
: "Derzeit sind keine öffentlichen Datenschutzerklärungen hinterlegt."}
|
: "Derzeit sind keine öffentlichen Datenschutzerklärungen hinterlegt."}
|
||||||
</p>
|
</p>
|
||||||
{searchTerm && (
|
{searchTerm && (
|
||||||
<Button variant="outline" onClick={() => setSearchTerm("")}>
|
<Button variant="outline" onClick={resetSearch}>
|
||||||
Suche zurücksetzen
|
Suche zurücksetzen
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@ -156,17 +158,9 @@ export function PolicyList({ initial }: { initial: PrivacyPolicy[] }) {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && !error && filteredPolicies.length > 0 && (
|
<p role="status" aria-live="polite" aria-atomic="true" className="mt-4 text-sm text-muted-foreground">
|
||||||
<div className="mt-4 flex items-center justify-between gap-3 text-sm text-muted-foreground">
|
{error ? "Fehler beim Laden der Datenschutzerklärungen." : isLoading ? "Datenschutzerklärungen werden geladen…" : filteredPolicies.length + (filteredPolicies.length === 1 ? " Erklärung" : " Erklärungen") + (searchTerm ? " von " + policies.length : "")}
|
||||||
{/* Beim Filtern hört eine Vorlesesoftware sonst nicht, was passiert */}
|
|
||||||
<p role="status" aria-live="polite">
|
|
||||||
{filteredPolicies.length}{" "}
|
|
||||||
{filteredPolicies.length === 1 ? "Erklärung" : "Erklärungen"}
|
|
||||||
{searchTerm && ` von ${policies.length}`}
|
|
||||||
</p>
|
</p>
|
||||||
<Badge variant="outline">DSGVO-konform</Badge>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,18 +1,22 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Settings2 } from "lucide-react"
|
import { BookOpenText, Hand, Info, Settings2 } from "lucide-react"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
import { Brand } from "@/components/brand"
|
import { Brand } from "@/components/brand"
|
||||||
import { ThemeToggle } from "@/components/theme-toggle"
|
import { ThemeToggle } from "@/components/theme-toggle"
|
||||||
|
import { readSitePages } from "@/lib/site-pages.server"
|
||||||
|
import { hasSitePageContent, SITE_PAGE_META } from "@/lib/site-pages"
|
||||||
|
|
||||||
/** Kopfzeile der öffentlichen Seiten. */
|
/** Kopfzeile der öffentlichen Seiten. */
|
||||||
export function PublicHeader({ subtitle }: { subtitle?: string }) {
|
export async function PublicHeader({ subtitle }: { subtitle?: string }) {
|
||||||
|
const pages = await readSitePages()
|
||||||
|
const offers = (["easyLanguage", "signLanguage", "accessibility"] as const).filter(key => hasSitePageContent(pages, key))
|
||||||
|
const icons = { easyLanguage: BookOpenText, signLanguage: Hand, accessibility: Info }
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between gap-2 px-4 sm:gap-4 sm:px-6">
|
<div className="mx-auto flex min-h-14 flex-wrap py-2 max-w-5xl items-center justify-between gap-2 px-4 sm:gap-4 sm:px-6">
|
||||||
<Link href="/" className="rounded-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring">
|
<Link href="/" className="rounded-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring">
|
||||||
<Brand subtitle={subtitle} />
|
<Brand subtitle={subtitle} />
|
||||||
<span className="sr-only">Zur Übersicht</span>
|
<span className="sr-only">Zur Übersicht</span>
|
||||||
@ -34,5 +38,26 @@ export function PublicHeader({ subtitle }: { subtitle?: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
{offers.length > 0 && (
|
||||||
|
<nav aria-label="Barrierefreie Angebote" className="accessibility-rail">
|
||||||
|
{offers.map(key => {
|
||||||
|
const Icon = icons[key]
|
||||||
|
return (
|
||||||
|
<Tooltip key={key}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Link href={SITE_PAGE_META[key].path} className="accessibility-rail-link">
|
||||||
|
<Icon aria-hidden="true" strokeWidth={2} />
|
||||||
|
<span className="sr-only">{SITE_PAGE_META[key].title}</span>
|
||||||
|
</Link>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="left" sideOffset={10} className="max-w-60 text-sm">
|
||||||
|
{SITE_PAGE_META[key].title}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { FormErrors } from "@/components/form-errors"
|
||||||
|
import { sitePageTemplates } from "@/lib/site-page-templates"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { AlertCircle, ExternalLink } from "lucide-react"
|
import { ExternalLink } from "lucide-react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
@ -79,78 +82,7 @@ Auskunft (Art. 15), Berichtigung (Art. 16), Löschung (Art. 17), Einschränkung
|
|||||||
(Art. 18), Widerspruch (Art. 21) sowie Beschwerde bei der Aufsichtsbehörde
|
(Art. 18), Widerspruch (Art. 21) sowie Beschwerde bei der Aufsichtsbehörde
|
||||||
(Art. 77 DSGVO).`,
|
(Art. 77 DSGVO).`,
|
||||||
|
|
||||||
accessibility: `## Erklärung zur Barrierefreiheit
|
...sitePageTemplates,
|
||||||
|
|
||||||
Diese Erklärung gilt für die Website **datenschutz.beispiel.de**.
|
|
||||||
|
|
||||||
### Stand der Vereinbarkeit mit den Anforderungen
|
|
||||||
Diese Website ist mit der BITV 2.0 *[vollständig / teilweise / nicht]*
|
|
||||||
vereinbar.
|
|
||||||
|
|
||||||
### Nicht barrierefreie Inhalte
|
|
||||||
Hier die bekannten Einschränkungen und deren Begründung auflisten.
|
|
||||||
|
|
||||||
### Erstellung dieser Erklärung
|
|
||||||
Diese Erklärung wurde am TT.MM.JJJJ erstellt.
|
|
||||||
Grundlage war eine *[Selbstbewertung / Prüfung durch Dritte]*.
|
|
||||||
Letzte Überprüfung: TT.MM.JJJJ
|
|
||||||
|
|
||||||
### Barrieren melden – Kontakt
|
|
||||||
Sie können uns Mängel bei der Barrierefreiheit mitteilen und Informationen in
|
|
||||||
barrierefreier Form anfordern:
|
|
||||||
Name, E-Mail, Telefon
|
|
||||||
|
|
||||||
### Schlichtungsverfahren
|
|
||||||
Wenn wir auf Ihre Meldung nicht zufriedenstellend reagieren, können Sie die
|
|
||||||
Schlichtungsstelle nach § 16 BGG anrufen:
|
|
||||||
Anschrift und Kontakt der zuständigen Schlichtungsstelle`,
|
|
||||||
|
|
||||||
easyLanguage: `## Willkommen
|
|
||||||
|
|
||||||
Das hier ist die Internet-Seite vom Amt Musterstadt.
|
|
||||||
Hier steht: Was macht das Amt mit Ihren Daten?
|
|
||||||
|
|
||||||
## Was finden Sie auf dieser Seite?
|
|
||||||
|
|
||||||
Sie finden hier eine Liste.
|
|
||||||
In der Liste stehen viele Themen.
|
|
||||||
Zum Beispiel: Bau-Antrag. Oder: Wohn-Geld.
|
|
||||||
Zu jedem Thema gibt es einen Text.
|
|
||||||
In dem Text steht: Welche Daten braucht das Amt?
|
|
||||||
Und: Wie lange speichert das Amt die Daten?
|
|
||||||
|
|
||||||
## Wie finden Sie ein Thema?
|
|
||||||
|
|
||||||
Oben auf der Seite ist ein Such-Feld.
|
|
||||||
Dort können Sie ein Wort eintippen.
|
|
||||||
Dann zeigt die Seite nur noch passende Themen.
|
|
||||||
Sie können auch auf ein Thema in der Liste klicken.
|
|
||||||
|
|
||||||
## Barriere-Freiheit
|
|
||||||
|
|
||||||
Barriere-frei heißt: Alle Menschen können die Seite benutzen.
|
|
||||||
Wir arbeiten daran, dass die Seite barriere-frei wird.
|
|
||||||
Manche Sachen sind noch nicht barriere-frei.
|
|
||||||
Sie können uns das melden.
|
|
||||||
Schreiben Sie an: *[E-Mail-Adresse eintragen]*
|
|
||||||
|
|
||||||
*Dieser Text muss vor der Veröffentlichung von einer Prüf-Gruppe
|
|
||||||
gegengelesen werden. Das ist bei Leichter Sprache üblich.*`,
|
|
||||||
|
|
||||||
signLanguage: `## Informationen in Deutscher Gebärdensprache
|
|
||||||
|
|
||||||
Das Video oben erklärt:
|
|
||||||
- die wesentlichen Inhalte dieses Portals
|
|
||||||
- wie die Navigation funktioniert
|
|
||||||
- die wesentlichen Inhalte der Erklärung zur Barrierefreiheit
|
|
||||||
|
|
||||||
Die Adresse des Videos tragen Sie oben in das Feld ein; es erscheint dann
|
|
||||||
über diesem Text.
|
|
||||||
|
|
||||||
### Kontakt
|
|
||||||
|
|
||||||
Fragen zur Barrierefreiheit richten Sie bitte an:
|
|
||||||
*[Kontaktdaten eintragen]*`,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SitePagesFormProps {
|
interface SitePagesFormProps {
|
||||||
@ -160,7 +92,7 @@ interface SitePagesFormProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Pflegt Impressum, Datenschutzerklärung und Erklärung zur Barrierefreiheit.
|
* Pflegt Impressum, Datenschutzerklärung und Erklärung zur Barrierefreiheit.
|
||||||
* Ein leer gelassener Text nimmt die betreffende Seite vom Netz.
|
* Veröffentlichte Seiten dürfen nicht versehentlich geleert werden.
|
||||||
*/
|
*/
|
||||||
export function SitePagesForm({ open, onOpenChange }: SitePagesFormProps) {
|
export function SitePagesForm({ open, onOpenChange }: SitePagesFormProps) {
|
||||||
const { pages, isLoading } = useSitePages()
|
const { pages, isLoading } = useSitePages()
|
||||||
@ -205,7 +137,7 @@ export function SitePagesForm({ open, onOpenChange }: SitePagesFormProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-h-[88vh] sm:max-w-4xl">
|
<DialogContent className="max-h-[calc(100dvh-2rem)] sm:max-w-4xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Rechtliche Seiten</DialogTitle>
|
<DialogTitle>Rechtliche Seiten</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
@ -216,7 +148,7 @@ export function SitePagesForm({ open, onOpenChange }: SitePagesFormProps) {
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col gap-4">
|
<form aria-label="Bearbeitungsformular" onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col gap-4">
|
||||||
<Tabs
|
<Tabs
|
||||||
value={tab}
|
value={tab}
|
||||||
onValueChange={(value) => setTab(value as SitePageKey)}
|
onValueChange={(value) => setTab(value as SitePageKey)}
|
||||||
@ -230,16 +162,7 @@ export function SitePagesForm({ open, onOpenChange }: SitePagesFormProps) {
|
|||||||
))}
|
))}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{errors.length > 0 && (
|
<FormErrors errors={errors} />
|
||||||
<div className="flex gap-2.5 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
|
|
||||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{errors.map((error, index) => (
|
|
||||||
<li key={index}>{error}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogBody className="pt-1">
|
<DialogBody className="pt-1">
|
||||||
{SITE_PAGES.map((key) => (
|
{SITE_PAGES.map((key) => (
|
||||||
@ -300,14 +223,14 @@ export function SitePagesForm({ open, onOpenChange }: SitePagesFormProps) {
|
|||||||
placeholder="/videos/gebaerdensprache.vtt"
|
placeholder="/videos/gebaerdensprache.vtt"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
WebVTT-Datei, nur bei einer eigenen Videodatei nötig
|
WebVTT-Datei, für eigene Videodateien
|
||||||
(WCAG 1.2.2). Ein Player-Anbieter bringt seine
|
(WCAG 1.2.2). Bei eingebetteten Playern müssen die Untertitel dort eingerichtet und geprüft werden.
|
||||||
Untertitel selbst mit.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{key in sitePageTemplates && !formData[key].trim() && <Button type="button" variant="outline" onClick={() => setFormData(prev => ({ ...prev, [key]: sitePageTemplates[key as keyof typeof sitePageTemplates] }))}>Vorlage einfügen</Button>}
|
||||||
<MarkdownInput
|
<MarkdownInput
|
||||||
id={`site-page-${key}`}
|
id={`site-page-${key}`}
|
||||||
label={SITE_PAGE_META[key].title}
|
label={SITE_PAGE_META[key].title}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import * as React from "react"
|
|||||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useDialogFocus } from "@/components/ui/use-dialog-focus"
|
||||||
import { buttonVariants } from "@/components/ui/button"
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
function AlertDialog({
|
function AlertDialog({
|
||||||
@ -38,15 +39,20 @@ function AlertDialogOverlay({
|
|||||||
|
|
||||||
function AlertDialogContent({
|
function AlertDialogContent({
|
||||||
className,
|
className,
|
||||||
|
onOpenAutoFocus,
|
||||||
|
onCloseAutoFocus,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||||
|
const focus = useDialogFocus()
|
||||||
return (
|
return (
|
||||||
<AlertDialogPrimitive.Portal>
|
<AlertDialogPrimitive.Portal>
|
||||||
<AlertDialogOverlay />
|
<AlertDialogOverlay />
|
||||||
<AlertDialogPrimitive.Content
|
<AlertDialogPrimitive.Content
|
||||||
data-slot="alert-dialog-content"
|
data-slot="alert-dialog-content"
|
||||||
|
onOpenAutoFocus={event => { focus.onOpenAutoFocus(); onOpenAutoFocus?.(event) }}
|
||||||
|
onCloseAutoFocus={event => { onCloseAutoFocus?.(event); if (!event.defaultPrevented) focus.onCloseAutoFocus(event) }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 sm:max-w-lg",
|
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] max-h-[calc(100dvh-2rem)] overflow-y-auto -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog"
|
|||||||
import { XIcon } from "lucide-react"
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useDialogFocus } from "@/components/ui/use-dialog-focus"
|
||||||
|
|
||||||
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
@ -48,17 +49,22 @@ function DialogContent({
|
|||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
showCloseButton = true,
|
showCloseButton = true,
|
||||||
|
onOpenAutoFocus,
|
||||||
|
onCloseAutoFocus,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
showCloseButton?: boolean
|
showCloseButton?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const focus = useDialogFocus()
|
||||||
return (
|
return (
|
||||||
<DialogPortal data-slot="dialog-portal">
|
<DialogPortal data-slot="dialog-portal">
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
|
onOpenAutoFocus={event => { focus.onOpenAutoFocus(); onOpenAutoFocus?.(event) }}
|
||||||
|
onCloseAutoFocus={event => { onCloseAutoFocus?.(event); if (!event.defaultPrevented) focus.onCloseAutoFocus(event) }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed top-1/2 left-1/2 z-50 flex w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 sm:max-w-lg",
|
"fixed top-1/2 left-1/2 z-50 block w-full max-w-[calc(100%-2rem)] max-h-[calc(100dvh-2rem)] overflow-y-auto -translate-x-1/2 -translate-y-1/2 space-y-4 rounded-lg border bg-background p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { cn } from "@/lib/utils"
|
|||||||
|
|
||||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
return (
|
return (
|
||||||
<div data-slot="table-container" className="relative w-full overflow-x-auto">
|
<div tabIndex={0} role="region" aria-label="Tabelle" data-slot="table-container" className="relative w-full overflow-x-auto">
|
||||||
<table
|
<table
|
||||||
data-slot="table"
|
data-slot="table"
|
||||||
className={cn("w-full caption-bottom text-sm", className)}
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
|||||||
@ -26,7 +26,7 @@ function TabsList({
|
|||||||
<TabsPrimitive.List
|
<TabsPrimitive.List
|
||||||
data-slot="tabs-list"
|
data-slot="tabs-list"
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-9 w-fit items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground",
|
"inline-flex min-h-9 h-auto flex-wrap w-fit max-w-full items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
21
components/ui/use-dialog-focus.ts
Normal file
21
components/ui/use-dialog-focus.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
"use client"
|
||||||
|
import { useRef } from "react"
|
||||||
|
|
||||||
|
export function useDialogFocus() {
|
||||||
|
const target = useRef<HTMLElement | null>(null)
|
||||||
|
return {
|
||||||
|
onOpenAutoFocus() {
|
||||||
|
const active = document.activeElement
|
||||||
|
const menuTrigger = document.querySelector<HTMLElement>('[data-slot="dropdown-menu-trigger"][data-state="open"]')
|
||||||
|
target.current = menuTrigger ?? (active instanceof HTMLElement ? active : null)
|
||||||
|
},
|
||||||
|
onCloseAutoFocus(event: Event) {
|
||||||
|
event.preventDefault()
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const fallback = document.querySelector<HTMLElement>('[role="dialog"] button, main')
|
||||||
|
const next = target.current?.isConnected && target.current !== document.body ? target.current : fallback
|
||||||
|
next?.focus()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -193,9 +193,9 @@ export function UserManagement({ isOpen, onClose }: UserManagementProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||||
<DialogContent className="max-h-[85vh] sm:max-w-3xl">
|
<DialogContent className="max-h-[calc(100dvh-2rem)] sm:max-w-3xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<DialogTitle>Benutzerverwaltung</DialogTitle>
|
<DialogTitle>Benutzerverwaltung</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
@ -324,6 +324,7 @@ export function UserManagement({ isOpen, onClose }: UserManagementProps) {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{error && <p role="alert" className="text-destructive">{error}</p>}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="user-email" className="gap-1">
|
<Label htmlFor="user-email" className="gap-1">
|
||||||
E-Mail-Adresse<span className="text-destructive">*</span>
|
E-Mail-Adresse<span className="text-destructive">*</span>
|
||||||
@ -387,7 +388,7 @@ export function UserManagement({ isOpen, onClose }: UserManagementProps) {
|
|||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
{showPassword ? <EyeOff /> : <Eye />}
|
{showPassword ? <EyeOff /> : <Eye />}
|
||||||
<span className="sr-only">Passwort anzeigen</span>
|
<span className="sr-only">{showPassword ? "Passwort verbergen" : "Passwort anzeigen"}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
457
docker/prisma-cli/package-lock.json
generated
Normal file
457
docker/prisma-cli/package-lock.json
generated
Normal file
@ -0,0 +1,457 @@
|
|||||||
|
{
|
||||||
|
"name": "syncova-prisma-runtime",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "syncova-prisma-runtime",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"prisma": "6.19.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"../..": {
|
||||||
|
"name": "syncova-policies",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"extraneous": true,
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"dependencies": {
|
||||||
|
"@auth/prisma-adapter": "^2.10.0",
|
||||||
|
"@prisma/client": "^6.19.3",
|
||||||
|
"@radix-ui/react-accordion": "^1.2.20",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.23",
|
||||||
|
"@radix-ui/react-avatar": "^1.2.6",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.11",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
||||||
|
"@radix-ui/react-label": "^2.1.15",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.18",
|
||||||
|
"@radix-ui/react-select": "^2.3.7",
|
||||||
|
"@radix-ui/react-separator": "^1.1.15",
|
||||||
|
"@radix-ui/react-slot": "^1.3.3",
|
||||||
|
"@radix-ui/react-switch": "^1.3.7",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.21",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.16",
|
||||||
|
"@tailwindcss/postcss": "^4.1.13",
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.544.0",
|
||||||
|
"next": "^15.5.25",
|
||||||
|
"next-auth": "^5.0.0-beta.29",
|
||||||
|
"postcss": "^8.5.23",
|
||||||
|
"react": "^19.1.9",
|
||||||
|
"react-dom": "^19.1.9",
|
||||||
|
"remark": "^15.0.1",
|
||||||
|
"remark-breaks": "^4.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"remark-html": "^16.0.1",
|
||||||
|
"sonner": "^2.0.8",
|
||||||
|
"swr": "^2.3.6",
|
||||||
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"tailwindcss": "^4.1.13",
|
||||||
|
"tw-animate-css": "^1.3.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@axe-core/playwright": "^4.13.0",
|
||||||
|
"@playwright/test": "^1.63.0",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/node": "^24.5.0",
|
||||||
|
"@types/react": "^19.1.13",
|
||||||
|
"@types/react-dom": "^19.1.9",
|
||||||
|
"eslint": "^9.39.5",
|
||||||
|
"eslint-config-next": "^15.5.25",
|
||||||
|
"pdf-parse": "^2.4.5",
|
||||||
|
"prisma": "^6.19.3",
|
||||||
|
"tsx": "^4.23.13",
|
||||||
|
"typescript": "^5.9.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@prisma/config": {
|
||||||
|
"version": "6.19.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz",
|
||||||
|
"integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"c12": "3.1.0",
|
||||||
|
"deepmerge-ts": "7.1.5",
|
||||||
|
"effect": "3.21.0",
|
||||||
|
"empathic": "2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@prisma/debug": {
|
||||||
|
"version": "6.19.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz",
|
||||||
|
"integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/@prisma/engines": {
|
||||||
|
"version": "6.19.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz",
|
||||||
|
"integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/debug": "6.19.3",
|
||||||
|
"@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
|
||||||
|
"@prisma/fetch-engine": "6.19.3",
|
||||||
|
"@prisma/get-platform": "6.19.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@prisma/engines-version": {
|
||||||
|
"version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
|
||||||
|
"integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/@prisma/fetch-engine": {
|
||||||
|
"version": "6.19.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz",
|
||||||
|
"integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/debug": "6.19.3",
|
||||||
|
"@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
|
||||||
|
"@prisma/get-platform": "6.19.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@prisma/get-platform": {
|
||||||
|
"version": "6.19.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz",
|
||||||
|
"integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/debug": "6.19.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/c12": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"chokidar": "^4.0.3",
|
||||||
|
"confbox": "^0.2.2",
|
||||||
|
"defu": "^6.1.4",
|
||||||
|
"dotenv": "^16.6.1",
|
||||||
|
"exsolve": "^1.0.7",
|
||||||
|
"giget": "^2.0.0",
|
||||||
|
"jiti": "^2.4.2",
|
||||||
|
"ohash": "^2.0.11",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"perfect-debounce": "^1.0.0",
|
||||||
|
"pkg-types": "^2.2.0",
|
||||||
|
"rc9": "^2.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"magicast": "^0.3.5"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"magicast": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chokidar": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"readdirp": "^4.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.16.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/citty": {
|
||||||
|
"version": "0.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
||||||
|
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"consola": "^3.2.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/confbox": {
|
||||||
|
"version": "0.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
||||||
|
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/consola": {
|
||||||
|
"version": "3.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
|
||||||
|
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.18.0 || >=16.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/deepmerge-ts": {
|
||||||
|
"version": "8.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.2.tgz",
|
||||||
|
"integrity": "sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "ko-fi",
|
||||||
|
"url": "https://ko-fi.com/rebeccastevens"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/deepmerge-ts"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/defu": {
|
||||||
|
"version": "6.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
|
||||||
|
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/destr": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/dotenv": {
|
||||||
|
"version": "16.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
|
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/effect": {
|
||||||
|
"version": "3.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz",
|
||||||
|
"integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"fast-check": "^3.23.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/empathic": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/exsolve": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/fast-check": {
|
||||||
|
"version": "3.23.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
|
||||||
|
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/dubzzz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fast-check"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pure-rand": "^6.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/giget": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"citty": "^0.1.6",
|
||||||
|
"consola": "^3.4.0",
|
||||||
|
"defu": "^6.1.4",
|
||||||
|
"node-fetch-native": "^1.6.6",
|
||||||
|
"nypm": "^0.6.0",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"giget": "dist/cli.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jiti": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/node-fetch-native": {
|
||||||
|
"version": "1.6.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
||||||
|
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/nypm": {
|
||||||
|
"version": "0.6.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.10.tgz",
|
||||||
|
"integrity": "sha512-W72Hrj1petq+b3Hk2aAC+9zswetlIFTnDW4s5djseZh2nYqBbyQLOtj472HwcbcWykPBUW1WpWpjOd4nK6gMRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"citty": "^0.2.2",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"tinyexec": "^1.3.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"nypm": "dist/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nypm/node_modules/citty": {
|
||||||
|
"version": "0.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz",
|
||||||
|
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/ohash": {
|
||||||
|
"version": "2.0.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz",
|
||||||
|
"integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pathe": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/perfect-debounce": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pkg-types": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"confbox": "^0.3.1",
|
||||||
|
"exsolve": "^1.1.1",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pkg-types/node_modules/confbox": {
|
||||||
|
"version": "0.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz",
|
||||||
|
"integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/prisma": {
|
||||||
|
"version": "6.19.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz",
|
||||||
|
"integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/config": "6.19.3",
|
||||||
|
"@prisma/engines": "6.19.3"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"prisma": "build/index.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pure-rand": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/dubzzz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fast-check"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/rc9": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"defu": "^6.1.4",
|
||||||
|
"destr": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/readdirp": {
|
||||||
|
"version": "4.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||||
|
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.18.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tinyexec": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
docker/prisma-cli/package.json
Normal file
13
docker/prisma-cli/package.json
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "syncova-prisma-runtime",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"prisma": "6.19.3"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"@prisma/config": {
|
||||||
|
"deepmerge-ts": "8.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
docs/BETRIEB.md
Normal file
61
docs/BETRIEB.md
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
# Betrieb und technische Abnahme
|
||||||
|
|
||||||
|
Stand: 14.09.2026. Die fachliche Freigabe durch den DSB und die vollständige manuelle Barrierefreiheitsprüfung sind getrennte Abnahmen.
|
||||||
|
|
||||||
|
## Geprüfter Stand
|
||||||
|
|
||||||
|
- Sechs Unit-/Integrationstests einschließlich Backup, Wiederherstellung und Überschreibschutz erfolgreich.
|
||||||
|
- Acht Browser-/API-Tests gegen den gebauten Standalone-Server erfolgreich.
|
||||||
|
- TypeScript, ESLint und beide npm-Audits ohne Fehler bzw. bekannte Schwachstellen.
|
||||||
|
- Aktueller Docker-Datenbestand gesichert und getrennt wiederhergestellt: 120 Erklärungen, ein Konto, ein Standardtext-Datensatz und ein Seitentext-Datensatz.
|
||||||
|
- Die Sicherung liegt lokal unter backups/abschluss-20260914.db und zusätzlich im Docker-Datenvolume unter /app/data/backups/abschluss-20260914.db. backups/ ist von Git und Docker-Builds ausgeschlossen.
|
||||||
|
|
||||||
|
## Backup
|
||||||
|
|
||||||
|
Das Werkzeug erstellt mit SQLite VACUUM INTO einen konsistenten Snapshot, auch während die Anwendung läuft. Es prüft die SQLite-Integrität und die vier Anwendungstabellen. Bestehende Zieldateien werden abgewiesen. Ein Backup enthält personenbezogene Inhalte und Passwort-Hashes; Zugriff auf berechtigte Administratoren begrenzen und für eine externe Ablage verschlüsseln.
|
||||||
|
|
||||||
|
Beispiel (Dateiname für jede Sicherung ändern):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker exec syncova-policies node scripts/database-backup.js backup /app/data/backups/sicherung-20260914.db
|
||||||
|
docker cp syncova-policies:/app/data/backups/sicherung-20260914.db ./backups/sicherung-20260914.db
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Hostordner backups muss vorher existieren. Eine Kopie auf demselben Rechner schützt nicht vor Verlust des Rechners. Ein externes Sicherungsziel und eine tägliche Ausführung müssen auf dem tatsächlichen Produktionshost eingerichtet werden; sie sind lokal noch nicht automatisiert. Auch vor jedem Update sichern. NEXTAUTH_SECRET und die Betriebs-Konfiguration separat im vorgesehenen Secret-/Passwortspeicher sichern; sie sind nicht Bestandteil des Datenbankbackups.
|
||||||
|
|
||||||
|
## Wiederherstellung prüfen
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker exec syncova-policies node scripts/database-backup.js verify /app/data/backups/sicherung-20260914.db
|
||||||
|
docker exec syncova-policies node scripts/database-backup.js restore /app/data/backups/sicherung-20260914.db /tmp/wiederherstellung.db
|
||||||
|
```
|
||||||
|
|
||||||
|
Der zweite Befehl erzeugt ausschließlich eine neue Datei und schaltet den laufenden Dienst nicht um. Für eine echte Wiederherstellung:
|
||||||
|
|
||||||
|
1. Dienst stoppen und aktuellen Zustand zusätzlich sichern.
|
||||||
|
2. Backup in ein neues Datenvolume übernehmen; Integrität mit dem Werkzeug prüfen.
|
||||||
|
3. Eigentümer UID/GID 1001:1001 für Datenbank und Datenverzeichnis sicherstellen.
|
||||||
|
4. Compose auf das neue Volume und das zum Backup passende Image umstellen; NEXTAUTH_SECRET beibehalten.
|
||||||
|
5. Dienst starten, Migrationen und Healthcheck kontrollieren; Anmeldung, Erklärungen und Seitentexte prüfen.
|
||||||
|
6. Altes Volume bis zur abgeschlossenen Abnahme behalten. Keine laufende SQLite-Datei überschreiben; WAL-/SHM-Dateien aus dem alten Zustand nicht mit dem Backup mischen.
|
||||||
|
|
||||||
|
## Veröffentlichung und Updates
|
||||||
|
|
||||||
|
Der Workflow .gitea/workflows/publish-image.yml prüft Code, Abhängigkeiten und Produktions-Browsertests, bevor er das Image baut. Pushes auf main veröffentlichen latest und einen kurzen Commit-Hash unter git.jfritzsche.de/jf/syncova-policies. REGISTRY_TOKEN muss als Gitea-Actions-Secret mit Schreibrecht auf die Registry vorhanden sein.
|
||||||
|
|
||||||
|
Produktiv möglichst den geprüften Commit-Tag oder Digest festlegen. Ein vorheriges Image allein ist kein sicherer Rollback nach einer nicht rückwärtskompatiblen Datenbankmigration; dafür auch die passende Sicherung verwenden.
|
||||||
|
|
||||||
|
Die lokale Testinstanz läuft auf http://localhost:3001. Der Entwicklungsserver nutzt Port 3000. Die lokale Portumstellung erfolgte als Compose-Override, die Standarddatei verwendet weiterhin Port 3000. Ein unverändertes docker compose up kann deshalb lokal einen Portkonflikt verursachen.
|
||||||
|
|
||||||
|
## Abnahmeliste für den Produktionshost
|
||||||
|
|
||||||
|
- HTTPS, Domain und Reverse-Proxy-Header prüfen.
|
||||||
|
- Dauerhaftes Datenvolume und stabiles NEXTAUTH_SECRET sicherstellen.
|
||||||
|
- Anmeldung mit berechtigtem Konto, Sperrung und Abmeldung prüfen.
|
||||||
|
- Erklärungen zählen, Stichproben öffnen und Seitentexte/Video kontrollieren.
|
||||||
|
- Externe Sicherung und tägliche Ausführung einrichten; Wiederherstellung regelmäßig proben.
|
||||||
|
- Healthcheck und Fehlerlogs überwachen.
|
||||||
|
- DSB-Freigabe sowie manuelle Screenreader-, Leichte-Sprache- und Videoprüfung dokumentieren.
|
||||||
|
- Barrierefreiheitserklärung um bestätigten Status und konkrete Restbarrieren ergänzen.
|
||||||
|
|
||||||
|
Automatisierte Tests bestätigen keine vollständige rechtliche oder technische Barrierefreiheit. Die lokale Sicherung und die lokale Docker-Instanz sind keine Prüfung des entfernten Produktionsservers.
|
||||||
64
docs/UMSETZUNG-2026-09-10.md
Normal file
64
docs/UMSETZUNG-2026-09-10.md
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
# Umsetzung vom 10.09.2026 – Abschlussprüfung am 11.09.2026
|
||||||
|
|
||||||
|
Ausgangspunkt: Commit 0134dd6 und Prüfbericht vom 09.09.2026.
|
||||||
|
|
||||||
|
## Änderungen
|
||||||
|
|
||||||
|
- Next.js 15.5.25, React/React DOM 19.1.9, NextAuth beta.32, @auth/core 0.41.3 und aktualisierter Prisma-Adapter.
|
||||||
|
- Prisma-Client und CLI einheitlich 6.19.3; gemeinsame Clientinstanz. Die Middleware verwendet die unterstützte Node.js-Laufzeit. PostCSS wird einheitlich auf eine gepatchte 8.5-Version aufgelöst.
|
||||||
|
- Geschützte Zugriffe überprüfen das aktuelle Konto. Sperren und Kontoänderungen entziehen alten Sitzungen den Zugriff.
|
||||||
|
- Benutzerverwaltung ausschließlich für Super-Admins; Eingabevalidierung und transaktionaler Schutz des letzten aktiven Super-Admins.
|
||||||
|
- Neue Passwörter: mindestens 12 Zeichen und höchstens 72 UTF-8-Bytes wegen bcrypt.
|
||||||
|
- Dauerhafte Suchstatusmeldung einschließlich null Treffern, Fokus ins Suchfeld beim Zurücksetzen.
|
||||||
|
- Programmatisch gekennzeichnete Pflichtfelder, verknüpfte Fehler und fokussierte Fehlerzusammenfassung. Loginfehler als Alert; korrekte Anzeigen-/Verbergen-Beschriftung.
|
||||||
|
- Dialoge mit Fokusrückgabe und durchgehend scrollbarem Inhalt. Der Formularbereich bleibt auch bei langen Fehlermeldungen erreichbar.
|
||||||
|
- Responsive Admin-Kopfzeile, Tabs und Dialogköpfe; öffentliche Fließtexte mit Standardschriftgröße; lokale Inter-Schrift bleibt erhalten.
|
||||||
|
- Tastaturzugängliche Tabellenbereiche, Umbruch langer Inhalte und Rücksicht auf reduzierte Bewegung.
|
||||||
|
- Serverseitige Links zu gepflegten barrierefreien Angeboten in der öffentlichen Kopfzeile.
|
||||||
|
- Vollständigkeitsprüfung der Seiten-Nutzdaten und Schutz vor dem Leeren veröffentlichter Seiten. Vorlagen mit unausgefüllten Platzhaltern werden nicht veröffentlicht.
|
||||||
|
- Medienvalidierung für lokale Pfade und signlanguage.alangu.de; sichtbarer Video-Direktlink.
|
||||||
|
- Drei neue Vorlagen im Admin bei leeren Textfeldern über „Vorlage einfügen“. Bestehende Inhalte bleiben erhalten.
|
||||||
|
- Gitea prüft vor dem Image-Build TypeScript, Linting, Unit-Tests, beide npm-Abhängigkeitsbäume und Browser-/API-Regressionen am gebauten Standalone-Server.
|
||||||
|
- Policy-APIs validieren Datentypen, Textlängen und fehlerhaftes JSON und antworten bei ungültigen Eingaben mit HTTP 400.
|
||||||
|
- Die Prisma-CLI im Docker-Image erhält eine eigene gesperrte Abhängigkeitsliste. Der auf @prisma/config begrenzte Override auf deepmerge-ts 8.0.2 schließt die zuletzt gemeldete Abhängigkeitslücke.
|
||||||
|
|
||||||
|
## Nachweise
|
||||||
|
|
||||||
|
- TypeScript und ESLint: keine Fehler. Die bisherigen any-Typwarnungen in den Policy-APIs wurden durch typisierte Eingabevalidierung beseitigt.
|
||||||
|
- Sechs Unit-/Integrationstests erfolgreich: Sitzungswiderruf, Medienvalidierung, Seitenschutz, Benutzervalidierung, Policy-Eingaben und Prisma-Konfiguration einschließlich wiederholter Migrationen.
|
||||||
|
- Acht Browser-/API-Tests im Gesamtlauf gegen den gebauten Produktions-Standalone-Server erfolgreich. Enthalten sind mobile Ansichten, Dunkelmodus, wiederholtes Öffnen/Schließen von Dialogen, Fokusrückgabe, Tastaturnavigation, vergrößerter Text und Textabstände sowie Berechtigungs- und API-Prüfungen.
|
||||||
|
- Keine axe-Verstöße in den geprüften Beispielen für WCAG 2 A/AA und WCAG 2.1 AA. Geprüft: öffentliche Übersicht, Login, einzelne Erklärung, Informationsseiten und Adminformulare, unter anderem bei 320 Pixeln; ausgewählte Seiten und Benutzerdialog zusätzlich im Dunkelmodus.
|
||||||
|
- Die Screenshotkontrolle fand trotz zunächst erfolgreichem axe-Test einen kollabierten mobilen Formularbereich. Dieser wurde korrigiert und mit einem tatsächlichen Fokus-/Eingabetest abgesichert.
|
||||||
|
- Produktionsbuild und Linux-Docker-Build erfolgreich, einschließlich Node.js-Middleware. Lokales Image: syncova-policies:review-20260911.
|
||||||
|
- Docker-Laufzeitprüfung mit eigener temporärer Datenbank: alle zehn Migrationen angewendet, Admin angelegt, /auth/signin liefert HTTP 200. Wiederholtes migrate deploy meldet keine ausstehenden Migrationen. Testcontainer und anonymes Testvolume anschließend entfernt.
|
||||||
|
- npm audit: null bekannte Schwachstellen im Hauptprojekt und im gesonderten Prisma-CLI-Lockfile (Stand 11.09.2026). Das ist keine Prüfung der Betriebssystempakete im Container.
|
||||||
|
- git diff --check ohne Fehler.
|
||||||
|
|
||||||
|
Die Browser-Tests verwenden eine neue temporäre SQLite-Datenbank und eigene Ausgabeordner .next-e2e beziehungsweise .next-e2e-production auf Port 3219. Bestehende Daten und Zugangsdaten wurden nicht verändert. Kein Git-Push oder Deployment durchgeführt.
|
||||||
|
|
||||||
|
## Verbleibende Punkte
|
||||||
|
|
||||||
|
Die ursprünglich drei hohen npm-Meldungen derselben Prisma-Abhängigkeitskette sind behoben. Der begrenzte Override auf deepmerge-ts 8.0.2 ist mit dem tatsächlichen Prisma-Konfigurationsloader und wiederholten Migrationen unter Windows sowie mit Migrationen im Linux-Container geprüft. Version 8 verändert unter anderem das Zusammenführen von Maps; die hier verwendete Konfiguration nutzt normale Objekte. Bei einem späteren Prisma-Update ist der Override erneut zu prüfen und möglichst zu entfernen. Siehe [offizielle Versionshinweise zu deepmerge-ts 8](https://github.com/RebeccaStevens/deepmerge-ts/releases/tag/v8.0.0).
|
||||||
|
|
||||||
|
Docker meldet einen Hinweis zum bestehenden Build-Argument NEXTAUTH_SECRET. Sein Standardwert ist ausschließlich ein öffentlicher Build-Platzhalter; echte Secrets müssen weiterhin erst zur Laufzeit gesetzt und dürfen nicht als Build-Argument übergeben werden.
|
||||||
|
|
||||||
|
Die Tests sind keine vollständige EN-301-549-/Screenreaderprüfung. Produktive Texte, Videoinhalt/-player, Leichte Sprache und gegebenenfalls PDFs benötigen weiterhin die fachliche und manuelle Abnahme. Kein Konformitätsstatus wurde automatisch bestätigt oder veröffentlicht.
|
||||||
|
|
||||||
|
Ein eigenständiger redaktioneller Entwurfs-/Freigabeworkflow mit Versionierung ist nicht implementiert. Der neue Schutz verhindert unvollständige Aktualisierungen und das Leeren bereits veröffentlichter Seiten.
|
||||||
|
|
||||||
|
Alte Sitzungen ohne den neuen Ausgabezeitpunkt werden nach dem Deployment abgewiesen: einmal neu anmelden. Bestehende Passwörter bleiben gültig. Auch Kontoänderungen erfordern eine neue Anmeldung.
|
||||||
|
|
||||||
|
## Befehle
|
||||||
|
|
||||||
|
1. npm ci
|
||||||
|
2. npx prisma generate
|
||||||
|
3. npm run typecheck
|
||||||
|
4. npm run lint
|
||||||
|
5. npm test
|
||||||
|
6. npx playwright install chromium
|
||||||
|
7. npm run test:e2e:prod
|
||||||
|
8. npm audit --audit-level=high
|
||||||
|
9. npm audit --prefix docker/prisma-cli --audit-level=high
|
||||||
|
10. docker build -t syncova-policies:review-20260911 .
|
||||||
|
|
||||||
|
Die Markdown-Vorlagen und redaktionellen Hinweise liegen in docs/review-2026-09-09.
|
||||||
63
docs/review-2026-09-09/01-erklaerung-zur-barrierefreiheit.md
Normal file
63
docs/review-2026-09-09/01-erklaerung-zur-barrierefreiheit.md
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
Das Amt Leezen möchte dieses Portal für alle Menschen zugänglich machen. Maßgeblich sind das Landesbehindertengleichstellungsgesetz Schleswig-Holstein (LBGG), insbesondere § 13 Absatz 3 und § 14, sowie die darüber einbezogenen Anforderungen der BITV 2.0.
|
||||||
|
|
||||||
|
Diese Erklärung gilt für das Datenschutzportal des Amts Leezen unter **{{PORTAL_URL}}** einschließlich **{{GELTUNGSBEREICH_KONKRET_BENENNEN}}**.
|
||||||
|
|
||||||
|
## Stand der Vereinbarkeit mit den Anforderungen
|
||||||
|
|
||||||
|
Diese Website ist mit den geltenden Anforderungen **{{BEWERTETEN_STATUS_EINTRAGEN: vollständig vereinbar / teilweise vereinbar / nicht vereinbar}}**.
|
||||||
|
|
||||||
|
Die Bewertung bezieht sich auf die anwendbaren Anforderungen der EN 301 549 V3.2.1, einschließlich der WCAG 2.1 auf den Stufen A und AA.
|
||||||
|
|
||||||
|
## Nicht barrierefreie Inhalte
|
||||||
|
|
||||||
|
### Abweichungen von den Anforderungen
|
||||||
|
|
||||||
|
{{BESTÄTIGTE_BARRIEREN_AUFLISTEN. Je Barriere: betroffene Seite oder Funktion, Einschränkung, nicht erfüllte Anforderung, Ursache, verfügbare Alternative und gegebenenfalls geplanter Behebungstermin.}}
|
||||||
|
|
||||||
|
### Unverhältnismäßige Belastung
|
||||||
|
|
||||||
|
{{NUR_TATSÄCHLICH_GEPRÜFTE_UND_BEGRÜNDETE_AUSNAHMEN_EINTRAGEN. Falls keine beansprucht werden, ausdrücklich so angeben.}}
|
||||||
|
|
||||||
|
### Inhalte außerhalb des Anwendungsbereichs
|
||||||
|
|
||||||
|
{{TATSÄCHLICH_AUSGENOMMENE_INHALTE_MIT_GRUND_BENENNEN. Falls keine bekannt sind, entsprechend angeben.}}
|
||||||
|
|
||||||
|
## Erstellung und Überprüfung dieser Erklärung
|
||||||
|
|
||||||
|
Erstellt am: **{{ERSTELLUNGSDATUM}}**
|
||||||
|
Zuletzt überprüft am: **{{PRÜFDATUM}}**
|
||||||
|
|
||||||
|
Die Bewertung erfolgte durch **{{VERANTWORTLICHE_STELLE_ODER_PRÜFDIENST}}** mittels **{{TATSÄCHLICHE_PRÜFMETHODE}}**.
|
||||||
|
|
||||||
|
Geprüft wurden **{{SEITEN_UND_PROZESSE}}**. Dabei kamen **{{TATSÄCHLICH_VERWENDETE_BROWSER_HILFSMITTEL_UND_TESTVERFAHREN}}** zum Einsatz.
|
||||||
|
|
||||||
|
## Barrieren melden und Informationen anfordern
|
||||||
|
|
||||||
|
Sie können uns Probleme bei der Nutzung melden. Sie können auch Informationen in einer für Sie zugänglichen Form anfordern.
|
||||||
|
|
||||||
|
**Amt Leezen – {{ZUSTÄNDIGE_STELLE}}**
|
||||||
|
|
||||||
|
- E-Mail: [{{KONTAKT_EMAIL}}](mailto:{{KONTAKT_EMAIL}})
|
||||||
|
- Telefon: {{KONTAKT_TELEFON}}
|
||||||
|
- Postanschrift: {{KONTAKT_POSTANSCHRIFT}}
|
||||||
|
|
||||||
|
Bitte nennen Sie möglichst die betroffene Seite und beschreiben Sie das Problem. Teilen Sie uns mit, auf welchem Weg wir Sie erreichen können.
|
||||||
|
|
||||||
|
## Beschwerdeverfahren
|
||||||
|
|
||||||
|
Wenn Sie mit unserer Antwort nicht zufrieden sind oder Ihre Meldung unbeantwortet bleibt, können Sie sich an die Beschwerdestelle für barrierefreie Informationstechnik in Schleswig-Holstein wenden. Sie unterstützt die Durchsetzung der digitalen Barrierefreiheit nach § 16 LBGG.
|
||||||
|
|
||||||
|
**Beschwerdestelle für barrierefreie Informationstechnik**
|
||||||
|
bei der Landesbeauftragten für Menschen mit Behinderungen
|
||||||
|
Postfach 7121
|
||||||
|
24171 Kiel
|
||||||
|
|
||||||
|
- Ansprechpartner: Jens Handler
|
||||||
|
- Telefon: 0431 988-1612
|
||||||
|
- E-Mail: [jens.handler@landtag.ltsh.de](mailto:jens.handler@landtag.ltsh.de)
|
||||||
|
- [Informationen, Kontakt und Zugang zum Beschwerdeformular](https://www.inklusion.sh/unsere-aufgaben/beschwerdestelle/)
|
||||||
|
|
||||||
|
## Weitere Angebote
|
||||||
|
|
||||||
|
- [Informationen in Leichter Sprache](/leichte-sprache)
|
||||||
|
- [Informationen in Deutscher Gebärdensprache](/gebaerdensprache)
|
||||||
137
docs/review-2026-09-09/02-leichte-sprache.md
Normal file
137
docs/review-2026-09-09/02-leichte-sprache.md
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
## Willkommen
|
||||||
|
|
||||||
|
Sie sind auf einer Internet-Seite vom Amt Leezen.
|
||||||
|
|
||||||
|
Hier finden Sie Informationen zum Daten-Schutz.
|
||||||
|
|
||||||
|
## Was sind persönliche Daten?
|
||||||
|
|
||||||
|
Persönliche Daten sind Informationen über einen Menschen.
|
||||||
|
|
||||||
|
Zum Beispiel:
|
||||||
|
|
||||||
|
- Ihr Name.
|
||||||
|
- Ihre Adresse.
|
||||||
|
- Ihr Geburts-Datum.
|
||||||
|
|
||||||
|
Das Amt braucht für viele Aufgaben persönliche Daten.
|
||||||
|
|
||||||
|
Zum Beispiel:
|
||||||
|
Sie stellen einen Antrag.
|
||||||
|
Das Amt muss wissen:
|
||||||
|
Wer stellt den Antrag?
|
||||||
|
|
||||||
|
## Was steht auf dieser Internet-Seite?
|
||||||
|
|
||||||
|
Hier gibt es Texte zu verschiedenen Aufgaben vom Amt.
|
||||||
|
|
||||||
|
Diese Texte heißen:
|
||||||
|
Daten-Schutz-Erklärungen.
|
||||||
|
|
||||||
|
In den Texten steht zum Beispiel:
|
||||||
|
|
||||||
|
- Welche Daten braucht das Amt?
|
||||||
|
- Wofür braucht das Amt die Daten?
|
||||||
|
- Wer bekommt die Daten?
|
||||||
|
- Wie lange speichert das Amt die Daten?
|
||||||
|
- Welche Rechte haben Sie?
|
||||||
|
- Wen können Sie bei Fragen ansprechen?
|
||||||
|
|
||||||
|
## So finden Sie einen Text
|
||||||
|
|
||||||
|
Gehen Sie zur [Übersicht der Daten-Schutz-Erklärungen](/).
|
||||||
|
|
||||||
|
Dort sehen Sie eine Liste mit Themen.
|
||||||
|
|
||||||
|
Die Themen sind nach dem Alphabet geordnet.
|
||||||
|
|
||||||
|
Wählen Sie ein Thema aus.
|
||||||
|
Dann öffnet sich der Text zu diesem Thema.
|
||||||
|
|
||||||
|
Sie können auch die Suche benutzen.
|
||||||
|
|
||||||
|
Schreiben Sie ein Wort in das Such-Feld.
|
||||||
|
Zum Beispiel: Bau.
|
||||||
|
|
||||||
|
Dann sehen Sie die passenden Themen.
|
||||||
|
|
||||||
|
Sie finden nichts?
|
||||||
|
|
||||||
|
Versuchen Sie ein anderes Wort.
|
||||||
|
|
||||||
|
Oder wählen Sie:
|
||||||
|
**Suche zurücksetzen**.
|
||||||
|
|
||||||
|
Dann sehen Sie wieder alle Themen.
|
||||||
|
|
||||||
|
## So kommen Sie zurück zur Übersicht
|
||||||
|
|
||||||
|
Wählen Sie den Namen des Portals in der Kopf-Zeile.
|
||||||
|
|
||||||
|
Oder nutzen Sie diesen Link:
|
||||||
|
|
||||||
|
[Zur Übersicht](/)
|
||||||
|
|
||||||
|
## Was bedeutet Barriere-Freiheit?
|
||||||
|
|
||||||
|
Eine Barriere ist ein Hindernis.
|
||||||
|
|
||||||
|
Auf einer Internet-Seite kann das zum Beispiel sein:
|
||||||
|
|
||||||
|
Ein Text ist schwer zu lesen.
|
||||||
|
|
||||||
|
Oder:
|
||||||
|
Eine Schalt-Fläche lässt sich nicht mit der Tastatur bedienen.
|
||||||
|
|
||||||
|
Wir möchten:
|
||||||
|
Alle Menschen können unsere Internet-Seite benutzen.
|
||||||
|
|
||||||
|
## Wie barriere-frei ist diese Seite?
|
||||||
|
|
||||||
|
{{HIER_DEN_GEPRÜFTEN_STAND_IN_KURZEN_SÄTZEN_BESCHREIBEN. BESTÄTIGTE_HINDERNISSE_UND_HILFEN_BENENNEN.}}
|
||||||
|
|
||||||
|
Mehr dazu steht in unserer
|
||||||
|
[Erklärung zur Barriere-Freiheit](/barrierefreiheit).
|
||||||
|
|
||||||
|
## Sie brauchen Hilfe?
|
||||||
|
|
||||||
|
Sie können uns ein Problem melden.
|
||||||
|
|
||||||
|
Sie können auch fragen:
|
||||||
|
Können Sie mir die Information anders geben?
|
||||||
|
|
||||||
|
Zum Beispiel:
|
||||||
|
Als gut lesbaren Text.
|
||||||
|
|
||||||
|
Schreiben Sie uns:
|
||||||
|
|
||||||
|
[{{KONTAKT_EMAIL}}](mailto:{{KONTAKT_EMAIL}})
|
||||||
|
|
||||||
|
Oder rufen Sie uns an:
|
||||||
|
|
||||||
|
**{{KONTAKT_TELEFON}}**
|
||||||
|
|
||||||
|
Sagen Sie uns:
|
||||||
|
|
||||||
|
- Welche Seite möchten Sie benutzen?
|
||||||
|
- Was funktioniert nicht?
|
||||||
|
- Wie können wir Ihnen antworten?
|
||||||
|
|
||||||
|
## Ihre Meldung hilft nicht weiter?
|
||||||
|
|
||||||
|
Es gibt eine Beschwerde-Stelle in Schleswig-Holstein.
|
||||||
|
|
||||||
|
Sie hilft bei Hindernissen auf Internet-Seiten von öffentlichen Stellen.
|
||||||
|
|
||||||
|
Zum Beispiel:
|
||||||
|
Auf einer Internet-Seite von einem Amt.
|
||||||
|
|
||||||
|
[Hier finden Sie die Beschwerde-Stelle](https://www.inklusion.sh/unsere-aufgaben/beschwerdestelle/).
|
||||||
|
|
||||||
|
## Weitere Informationen
|
||||||
|
|
||||||
|
Es gibt auch eine Seite mit einem Video in Deutscher Gebärden-Sprache.
|
||||||
|
|
||||||
|
[Zur Seite mit Gebärden-Sprache](/gebaerdensprache)
|
||||||
|
|
||||||
|
{{WEITERE_TATSÄCHLICH_VORHANDENE_ANGEBOTE_IN_LEICHTER_SPRACHE_VERLINKEN_ODER_DIESEN_PLATZHALTER_ENTFERNEN.}}
|
||||||
37
docs/review-2026-09-09/03-gebaerdensprache.md
Normal file
37
docs/review-2026-09-09/03-gebaerdensprache.md
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
## Informationen in Deutscher Gebärdensprache
|
||||||
|
|
||||||
|
Hier finden Sie das Gebärdensprach-Video des Amts Leezen zur Erklärung zur Barrierefreiheit des Datenschutzportals.
|
||||||
|
|
||||||
|
Das Video wird über den Player von alangu bereitgestellt.
|
||||||
|
|
||||||
|
[Video direkt bei alangu öffnen](https://signlanguage.alangu.de/player/?key=v2DM_moWiFpmKRjIa18Pt00kKH_g&autostart=false&subtitle=true)
|
||||||
|
|
||||||
|
## Ergänzende Informationen zum Portal
|
||||||
|
|
||||||
|
In diesem Portal finden Sie Datenschutzerklärungen zu Verwaltungsaufgaben des Amts Leezen. Sie erläutern, welche personenbezogenen Daten verarbeitet werden, zu welchem Zweck dies geschieht und welche Rechte Sie haben.
|
||||||
|
|
||||||
|
In der [Übersicht der Datenschutzerklärungen](/) können Sie einen Titel auswählen oder nach einem Thema suchen. Über den Namen des Portals in der Kopfzeile gelangen Sie zur Übersicht zurück.
|
||||||
|
|
||||||
|
## Erklärung zur Barrierefreiheit
|
||||||
|
|
||||||
|
Die [Erklärung zur Barrierefreiheit](/barrierefreiheit) informiert über den geprüften Stand, bestehende Barrieren und Kontaktmöglichkeiten.
|
||||||
|
|
||||||
|
## Barrieren melden
|
||||||
|
|
||||||
|
Wenn Sie das Video oder eine andere Funktion nicht nutzen können, wenden Sie sich bitte an:
|
||||||
|
|
||||||
|
**Amt Leezen – {{ZUSTÄNDIGE_STELLE}}**
|
||||||
|
|
||||||
|
- E-Mail: [{{KONTAKT_EMAIL}}](mailto:{{KONTAKT_EMAIL}})
|
||||||
|
- Telefon: {{KONTAKT_TELEFON}}
|
||||||
|
- Postanschrift: {{KONTAKT_POSTANSCHRIFT}}
|
||||||
|
|
||||||
|
Sie können uns auch mitteilen, in welcher Form Sie eine Information benötigen.
|
||||||
|
|
||||||
|
Falls Ihre Meldung unbeantwortet bleibt oder Sie mit der Antwort nicht zufrieden sind, können Sie sich an die [Beschwerdestelle für barrierefreie Informationstechnik in Schleswig-Holstein](https://www.inklusion.sh/unsere-aufgaben/beschwerdestelle/) wenden.
|
||||||
|
|
||||||
|
## Weitere Angebote
|
||||||
|
|
||||||
|
[Informationen in Leichter Sprache](/leichte-sprache)
|
||||||
|
|
||||||
|
{{WEITERE_TATSÄCHLICH_VORHANDENE_DGS_ANGEBOTE_VERLINKEN_ODER_DIESEN_PLATZHALTER_ENTFERNEN.}}
|
||||||
170
docs/review-2026-09-09/PRUEFBERICHT.md
Normal file
170
docs/review-2026-09-09/PRUEFBERICHT.md
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
# Code- und Barrierefreiheitsprüfung
|
||||||
|
|
||||||
|
Stand: 09.09.2026. Repository: syncova-policies. Geprüfter Commit: 0134dd6.
|
||||||
|
|
||||||
|
## Ergebnis und Prüfgrenzen
|
||||||
|
|
||||||
|
Die Anwendung enthält sinnvolle Grundlagen: lokal eingebundene Inter-Schrift, lang="de", einen Sprunglink zum Hauptinhalt, beschriftete Suchfelder, echte Links zu Datenschutzerklärungen, Radix-Dialoge mit Titeln und bereinigte Markdown-Ausgabe. Vollständige technische oder rechtliche Barrierefreiheit ist damit noch nicht belegt. Es bestehen konkrete Fehler in Statusmeldungen und Formularsemantik sowie Risiken bei kleinen Ansichten und der Veröffentlichung von Pflichtseiten.
|
||||||
|
|
||||||
|
Durchgeführt: statische Prüfung zentraler öffentlicher Komponenten, Admin-Formulare, Dialog-/Tab-Bausteine, Authentifizierung, Benutzer- und Seiten-APIs, CSS, Lockdatei und Workflow; Sichtung der fünf letzten Commits; Abgleich mit aktuellen offiziellen Quellen. TypeScript: node node_modules/typescript/bin/tsc --noEmit --incremental false, Exit-Code 0.
|
||||||
|
|
||||||
|
Nicht durchgeführt: produktiver Browser-/Screenreadertest, vollständiger EN-301-549-Test, Kontrastmessung gerenderter Zustände, Prüfung der aktuell in der Datenbank veröffentlichten Texte, Sichtung/Übersetzungsprüfung des alangu-Videos, Prüfung exportierter PDF-Dateien oder Penetrationstest. Aus CSS abgeleitete Layout-/Fokusrisiken sind deshalb ausdrücklich noch zu reproduzieren. Keine Änderungen an Anwendung, Datenbank oder Deployment.
|
||||||
|
|
||||||
|
## Priorität 1: Sicherheit
|
||||||
|
|
||||||
|
### S1 – Veraltete, von kritischen Sicherheitslücken betroffene Next.js-Version
|
||||||
|
|
||||||
|
Fundstelle: package-lock.json:4233, package.json (Next-Abhängigkeit).
|
||||||
|
|
||||||
|
Die Lockdatei fixiert Next.js 15.5.3. Die App verwendet den App Router mit Server Components. Diese Kombination liegt im betroffenen Bereich der RSC-Sicherheitslücke CVE-2025-66478. Ein Versionsbereich mit ^ in package.json behebt dies bei npm ci nicht.
|
||||||
|
|
||||||
|
Vorschlag: Next.js und die zugehörigen React-Abhängigkeiten gemeinsam auf einen kompatiblen, aktuell gepatchten Stand aktualisieren und die Lockdatei neu erzeugen. Das Sicherheitsrelease vom 25.08.2026 nennt 15.5.24 für die 15.5-Reihe. Anschließend Authentifizierung, Middleware, Build und Docker-Image prüfen. Die zusätzlichen August-Lücken haben eigene Einsatzbedingungen; ihre konkrete Ausnutzbarkeit wurde hier nicht behauptet oder getestet.
|
||||||
|
|
||||||
|
Quellen: [RSC-Sicherheitsmeldung](https://nextjs.org/blog/CVE-2025-66478), [Sicherheitsrelease August 2026](https://nextjs.org/blog/august-2026-security-release).
|
||||||
|
|
||||||
|
### S2 – Sperren und Rollenänderungen widerrufen vorhandene Berechtigungen nicht
|
||||||
|
|
||||||
|
Fundstellen: lib/auth.ts:29 und :55, lib/require-admin.ts:10, app/api/users/[id]/route.ts:16.
|
||||||
|
|
||||||
|
isActive wird bei der Anmeldung geprüft. Danach kommt die Rolle aus dem JWT; geschützte APIs vertrauen der Sitzung. Wird ein Konto gesperrt, gelöscht oder herabgestuft, kann dessen bereits ausgestellte Sitzung weiterhin autorisiert werden.
|
||||||
|
|
||||||
|
Vorschlag: Vor privilegierten Datenzugriffen den aktuellen Benutzerzustand aus der Datenbank prüfen. Für Passwortwechsel bzw. „alle Sitzungen abmelden“ eine Sitzungsversionsnummer oder eine widerrufbare Sitzung verwenden. Die Middleware allein ersetzt diese Prüfung nicht.
|
||||||
|
|
||||||
|
Abnahme: Konto anmelden, anschließend sperren/herabstufen/löschen, alte Sitzung gegen Lese- und Schreib-APIs verwenden. Die jeweils entzogenen Rechte müssen sofort abgewiesen werden.
|
||||||
|
|
||||||
|
### S3 – ADMIN und SUPER_ADMIN haben faktisch dieselben Benutzerverwaltungsrechte
|
||||||
|
|
||||||
|
Fundstellen: app/api/users/route.ts:53 und :96; app/api/users/[id]/route.ts:16, :60 und :107.
|
||||||
|
|
||||||
|
Ein ADMIN darf SUPER_ADMIN-Konten anlegen, Rollen ändern und andere Konten löschen. Wenn SUPER_ADMIN eine höhere Vertrauensstufe darstellen soll, fehlt die serverseitige Abgrenzung.
|
||||||
|
|
||||||
|
Vorschlag: Berechtigungsmatrix festlegen und zentral durchsetzen; Rollenwerte und Eingabetypen serverseitig validieren. Den letzten aktiven Super-Admin gegen versehentliche Sperrung/Herabstufung/Löschung schützen. Falls beide Rollen absichtlich gleichberechtigt sind, das Rollenmodell vereinfachen.
|
||||||
|
|
||||||
|
## Priorität 2: Barrierefreiheit und Bedienbarkeit
|
||||||
|
|
||||||
|
### A1 – Dynamische Fehler werden nicht zuverlässig angekündigt
|
||||||
|
|
||||||
|
Fundstellen: app/auth/signin/page.tsx:75; components/policy-form.tsx:317; components/policy-defaults-form.tsx:196; components/site-pages-form.tsx:232; components/policy-list.tsx:76.
|
||||||
|
|
||||||
|
Die Fehlermeldungen sind normale div-Elemente ohne Live-Region oder gezielte Fokusführung. Im Policy-Formular werden Fehler gesetzt und Tabs gewechselt, der Fokus wird aber nicht zur Zusammenfassung bzw. zum betroffenen Feld geführt.
|
||||||
|
|
||||||
|
Vorschlag: Fehlermeldungen programmatisch ankündigen, beispielsweise über eine geeignete Live-Region. Eine fokussierbare Fehlerzusammenfassung mit Links zu den Feldern vorsehen und beim Navigieren zum Fehler zuerst den passenden Tab öffnen. Feldfehler über aria-describedby verknüpfen. Keine unnötigen Doppelansagen durch gleichzeitigen Fokus und mehrere Live-Regionen.
|
||||||
|
|
||||||
|
Bezug: WCAG 3.3.1, 3.3.3 und 4.1.3. Die genaue Ansage mit NVDA prüfen.
|
||||||
|
|
||||||
|
### A2 – Die Suche meldet „keine Treffer“ nicht über ihre Statusregion
|
||||||
|
|
||||||
|
Fundstelle: components/policy-list.tsx:159.
|
||||||
|
|
||||||
|
Die Statusregion existiert nur bei mehr als null Treffern. Gerade beim Wechsel auf null Treffer wird sie entfernt. Auch das erneute Einfügen einer bereits gefüllten Live-Region ist nicht zuverlässig.
|
||||||
|
|
||||||
|
Vorschlag: Die Statusregion dauerhaft rendern; darin Laden, Trefferzahl, null Treffer und Ladefehler sinnvoll abbilden. Beim Zurücksetzen den Fokus ins Suchfeld zurückführen, weil die Zurücksetzen-Schaltfläche verschwindet.
|
||||||
|
|
||||||
|
Bezug: WCAG 4.1.3; Fokusführung ergänzend prüfen.
|
||||||
|
|
||||||
|
### A3 – Pflichtfelder werden nur teilweise programmatisch ausgezeichnet
|
||||||
|
|
||||||
|
Fundstellen: components/markdown-input.tsx:44 und :61; components/policy-form.tsx:331.
|
||||||
|
|
||||||
|
required steuert im MarkdownInput nur ein Sternchen, nicht required oder aria-required an der Textarea. Die Bedeutung des Sternchens wird nicht am Formular erläutert. aria-invalid allein erklärt nicht den Fehler.
|
||||||
|
|
||||||
|
Vorschlag: Eine sichtbare Pflichtfelderläuterung, aria-required und verknüpfte Hinweise/Fehler ergänzen. Native required-Validierung nur einsetzen, wenn das Verhalten in ausgeblendeten Tabs und bei geerbten Standardtexten berücksichtigt ist. Tablisten im Markdown-Editor mit dem jeweiligen Feldnamen beschriften.
|
||||||
|
|
||||||
|
Bezug: WCAG 1.3.1, 3.3.2, 4.1.2.
|
||||||
|
|
||||||
|
### A4 – Dialoge sind nicht durchgängig für schmale bzw. niedrige Ansichten ausgelegt
|
||||||
|
|
||||||
|
Fundstellen: components/policy-form.tsx:298; components/user-management.tsx:314; components/ui/dialog.tsx:61.
|
||||||
|
|
||||||
|
Tabs und Veröffentlichungsschalter stehen ohne responsiven Umbruch nebeneinander. Das Formular „Neuer Benutzer“ verwendet den Dialog ohne maximale Höhe und ohne scrollbaren Bereich. Der gemeinsame Dialog-Baustein begrenzt nur die Breite. Viele Fehlermeldungen stehen außerdem außerhalb des scrollbaren Formularbereichs.
|
||||||
|
|
||||||
|
Vorschlag: Die Kopfzeile unterhalb des sm-Breakpoints untereinander setzen. Alle Dialoge auf die verfügbare dynamische Viewporthöhe begrenzen, etwa max-height: calc(100dvh - 2rem), und Inhalt samt Fehlern erreichbar scrollen lassen. Speichern/Abbrechen müssen erreichbar bleiben. Nicht einfach Überlauf verstecken.
|
||||||
|
|
||||||
|
Bezug: WCAG 1.4.4, 1.4.10, 1.4.12 und 2.1.1. Risiko aus dem Code; Reproduktion bei 320 CSS-Pixeln, 200 % Textvergrößerung, 400 % Zoom und geöffneter Bildschirmtastatur steht aus.
|
||||||
|
|
||||||
|
### A5 – Fokus nach programmgesteuert geöffneten Dialogen ist nicht ausdrücklich abgesichert
|
||||||
|
|
||||||
|
Fundstellen: app/admin/page.tsx:291 und :496; components/ui/dialog.tsx:58; components/defaultable-field.tsx:49 und :55.
|
||||||
|
|
||||||
|
Die Dialoge werden über State und Menüaktionen geöffnet, nicht über einen zugehörigen DialogTrigger. Eine explizite Rückgabe des Fokus an einen dauerhaft vorhandenen Auslöser ist nicht implementiert. Auch „Abweichen“ und „Standard übernehmen“ ersetzen das fokussierte Element.
|
||||||
|
|
||||||
|
Vorschlag: Auslöserreferenz bzw. ein sinnvolles Ersatzziel speichern und über onCloseAutoFocus wieder fokussieren. Beim Moduswechsel das neue Eingabefeld bzw. dessen Auslöser fokussieren. Verschachtelte Benutzer-/Löschdialoge gesondert testen. Keinen globalen pointer-events-Reset als Ersatz für korrektes Dialogverhalten verwenden.
|
||||||
|
|
||||||
|
Bezug: WCAG 2.4.3 und 2.1.2. Radix bringt Grundlagen mit; das konkrete Zusammenspiel ist noch im Browser zu prüfen.
|
||||||
|
|
||||||
|
### A6 – Pflichtseiten lassen sich versehentlich vollständig entfernen
|
||||||
|
|
||||||
|
Fundstellen: app/api/site-pages/route.ts:31; components/site-footer.tsx:17; components/legal-page.tsx:36.
|
||||||
|
|
||||||
|
Ein leer gespeicherter Text ist erlaubt, der Footer entfernt den Link und die Seite antwortet mit 404. Fehlende Felder einer PUT-Anfrage werden ebenfalls zu leeren Strings. So kann eine unvollständige Aktualisierung mehrere Seiten entfernen.
|
||||||
|
|
||||||
|
Vorschlag: Entwurf und veröffentlichte Fassung trennen. Vor Veröffentlichung Pflichtinhalte prüfen; unvollständige PUT-Nutzdaten ablehnen oder ausdrücklich PATCH-Semantik implementieren. Eine vorhandene Erklärung nicht wegen eines leeren Entwurfs vom Netz nehmen. Links zu Leichter Sprache und Gebärdensprache zusätzlich gut sichtbar im Kopfbereich platzieren; der Footer allein ist nicht pauschal ein Gesetzesverstoß.
|
||||||
|
|
||||||
|
Rechtsbezug: Erklärung nach § 14 LBGG; ergänzende Angebote nach § 13 Absatz 3 LBGG in Verbindung mit § 4 BITV 2.0.
|
||||||
|
|
||||||
|
### A7 – Die vorhandene Erklärungsvorlage verweist auf die falsche Stelle
|
||||||
|
|
||||||
|
Fundstelle: components/site-pages-form.tsx:103.
|
||||||
|
|
||||||
|
Die Vorlage nennt die Schlichtungsstelle nach § 16 BGG. Für dieses kommunale Portal in Schleswig-Holstein ist das Landesverfahren über die Beschwerdestelle für barrierefreie Informationstechnik maßgeblich. Auch lib/site-pages.ts:40 sollte die landesrechtliche Grundlage nennen.
|
||||||
|
|
||||||
|
Vorschlag: Neue Vorlage dieses Pakets verwenden, aktuelle Kontaktdaten und überprüften Status eintragen. Keine vollständige Vereinbarkeit allein aus einem erfolgreichen automatisierten Test ableiten.
|
||||||
|
|
||||||
|
Quelle: [Beschwerdestelle Schleswig-Holstein](https://www.inklusion.sh/unsere-aufgaben/beschwerdestelle/).
|
||||||
|
|
||||||
|
### A8 – Videoverfügbarkeit, Videoinhalt und Barrierefreiheit werden zu pauschal beschrieben
|
||||||
|
|
||||||
|
Fundstellen: components/legal-page.tsx:65 und :92; components/site-pages-form.tsx:140 und :302.
|
||||||
|
|
||||||
|
Die Beschreibung behauptet Erläuterungen zum gesamten Portal. Die bisher mitgeteilte Videobezeichnung bezieht sich dagegen auf die Barrierefreiheitserklärung; der tatsächliche Inhalt wurde nicht gesichtet. Der Admin-Hinweis behauptet pauschal, ein Player-Anbieter bringe Untertitel mit. Das ist nicht aus dem Code ableitbar. Eine externe Untertitel-URL wird beim iframe nicht verwendet.
|
||||||
|
|
||||||
|
Vorschlag: Videotitel und Inhaltsbeschreibung pflegbar machen. Abdeckung von Portalinhalt, Navigation, Erklärung und weiteren Angeboten prüfen und gegebenenfalls das Video ergänzen. Sichtbaren Direktlink und entsprechende Textalternative anbieten. Tastaturbedienung, Fokus, Untertitel und gegebenenfalls Audiodeskription am konkreten Medium prüfen; Anforderungen hängen von dessen Ton-/Bildinhalt ab.
|
||||||
|
|
||||||
|
Quelle: [§ 4 BITV 2.0](https://www.gesetze-im-internet.de/bitv_2_0/__4.html). Ein ergänzender deutscher Text ersetzt fehlende DGS-Inhalte nicht.
|
||||||
|
|
||||||
|
### A9 – Markdown kann die Seitenstruktur und den Umbruch verschlechtern
|
||||||
|
|
||||||
|
Fundstellen: lib/markdown.ts:6 und :73; components/legal-page.tsx:49; app/globals.css:165.
|
||||||
|
|
||||||
|
Die Seite erzeugt bereits eine h1. Beliebige Markdown-h1, Überschriftensprünge, Bilder ohne sinnvollen Alternativtext und breite Tabellen werden redaktionell nicht geprüft. Für Tabellen und lange Links fehlen gezielte Umbruchregeln. Im Fehler-Fallback werden Listen zu visuellen Aufzählungspunkten statt semantischen Listen.
|
||||||
|
|
||||||
|
Vorschlag: Hinweise/Validierung im Editor, kontextgerechte Überschriftenebenen, Alt-Text-Prüfung und Tabellenüberschriften. Lange Links umbrechen; tatsächlich zweidimensionale Tabellen in einem beschrifteten, tastaturbedienbaren Scrollbereich darstellen. Einen semantikerhaltenden Fallback einsetzen. Die Vorlagen beginnen mit h2, weil die Seite den Haupttitel bereits liefert.
|
||||||
|
|
||||||
|
Bezug: WCAG 1.1.1, 1.3.1 und 1.4.10. Konkrete veröffentlichte Inhalte sind zusätzlich zu prüfen.
|
||||||
|
|
||||||
|
### A10 – Kleine Beschriftungs- und Lesbarkeitsdetails
|
||||||
|
|
||||||
|
Fundstellen: app/auth/signin/page.tsx:119; components/user-management.tsx:390; app/globals.css:165.
|
||||||
|
|
||||||
|
„Passwort anzeigen“ bleibt auch bei sichtbarem Passwort unverändert. Beschriftung zwischen Anzeigen/Verbergen wechseln oder einen korrekt benannten Toggle mit aria-pressed verwenden.
|
||||||
|
|
||||||
|
Die öffentlichen Markdown-Texte sind weiterhin text-sm, also normalerweise 14 px. Das ist kein automatischer WCAG-Verstoß. Für längere Texte würde ich entsprechend dem Wunsch nach Standardschrift text-base mit etwa 1,6 Zeilenhöhe verwenden; kompakte Abstände können erhalten bleiben. Leichte Sprache zusätzlich hinsichtlich Darstellung und Verständlichkeit prüfen lassen.
|
||||||
|
|
||||||
|
## Weitere Verbesserungen
|
||||||
|
|
||||||
|
- components/policy-list.tsx:167: Das bedingungslos angezeigte Badge „DSGVO-konform“ entfernen oder durch eine sachliche Bezeichnung ersetzen. Ein nichtleerer Datensatz ist keine nachgewiesene Rechtsprüfung.
|
||||||
|
- lib/site-pages.ts:133: Medien-URLs mit URL-Normalisierung validieren. Eine mit / und anschließendem Backslash beginnende Adresse kann vom Browser als fremder Host interpretiert werden. Explizite Regeln für lokale Dateien und erlaubte externe Hosts verwenden; Nutzername/Passwort in URLs ablehnen.
|
||||||
|
- components/site-pages-form.tsx:278: Der Hinweis „Andere Videoportale sind gesperrt“ ist zu weitgehend. Beliebige HTTPS-Adressen sind als native Medienquelle erlaubt; nur die iframe-Auswahl ist auf alangu beschränkt.
|
||||||
|
- lib/site-pages.ts:84: Ein Auftragsverarbeitungsvertrag allein beweist nicht die Zulässigkeit einer Einbindung. Den tatsächlichen Datenfluss des Players mit dem DSB bewerten; keine pauschale Anbieterbewertung in Codekommentaren.
|
||||||
|
- lib/auth.ts:7 und mehrere API-Routen: Gemeinsamen Prisma-Client statt separater Instanzen in vielen Modulen nutzen.
|
||||||
|
- Benutzer-/Seiten-APIs: Typen, Längen, gültige Rollen und E-Mail-Normalisierung serverseitig einheitlich validieren.
|
||||||
|
- .gitea/workflows/publish-image.yml: Vor dem Image-Publish TypeScript, Linting, gezielte Funktionstests und automatisierte Accessibility-Tests einbauen. Im geprüften Repository fehlen eine entsprechende Test-Suite und Playwright/axe-Abhängigkeiten.
|
||||||
|
|
||||||
|
## Rechtlicher Maßstab
|
||||||
|
|
||||||
|
Für das Portal des Amts Leezen sind die landesrechtlichen Vorschriften Schleswig-Holsteins maßgeblich: insbesondere LBGG §§ 11–16, BfWebV SH und die über § 13 Absatz 3 LBGG herangezogene BITV 2.0. Die aktuelle Landeshandreichung nennt EN 301 549 V3.2.1 mit WCAG 2.1 A/AA und weiteren anwendbaren Anforderungen. WCAG 2.2 ist ein sinnvolles zusätzliches Entwicklungsziel, hier aber nicht pauschal als bereits verbindlicher Ersatz angesetzt.
|
||||||
|
|
||||||
|
Eine Erklärung, dass Barrieren bestehen, ersetzt deren Beseitigung nicht. Status, konkrete Barrieren, Alternativen, Bewertungsmethode, Datum, Feedback und Beschwerdeweg müssen sachlich zutreffen. Nicht geprüft bedeutet nicht automatisch nicht vereinbar, und kein automatisierter Befund bedeutet nicht vollständig vereinbar.
|
||||||
|
|
||||||
|
Quelle: [Aktuelle Landeshandreichung](https://www.schleswig-holstein.de/DE/landesregierung/ministerien-behoerden/PBISH/Service/checklisten/downloads/mindestanforderungen_websites.pdf?__blob=publicationFile&v=4).
|
||||||
|
|
||||||
|
## Empfohlene Abnahme
|
||||||
|
|
||||||
|
1. Sicherheitspatches und wirksamen Sitzungswiderruf umsetzen.
|
||||||
|
2. Formulare, Suche, Dialoge und Veröffentlichungsschutz korrigieren.
|
||||||
|
3. Öffentlich und administrativ mit Tastatur, NVDA/Firefox oder NVDA/Chrome sowie VoiceOver/Safari prüfen: Öffnen, Escape, Fokus zurück, verschachtelte Dialoge, Dropdowns, Tabs, Fehler und Speichern.
|
||||||
|
4. Reflow, Textvergrößerung, Textabstände und Hoch-/Querformat prüfen; Hell/Dunkel, Hover, Fokus und Fehlermeldungen messen. Für normalen Text mindestens 4,5:1, großen Text 3:1, relevante UI-Grenzen und Zustände 3:1.
|
||||||
|
5. Tatsächlich veröffentlichte Inhalte, alangu-Player und gegebenenfalls bereitgestellte PDFs gesondert prüfen.
|
||||||
|
6. Konformitätsstatus anhand aller anwendbaren Anforderungen festlegen, Vorlagen finalisieren und Erklärung mindestens jährlich sowie nach relevanten Änderungen überprüfen.
|
||||||
|
|
||||||
|
Referenz für technische Kriterien: [WCAG 2.1](https://www.w3.org/TR/WCAG21/). Die Prüfliste ist ein Arbeitsplan und kein Prüfprotokoll mit bestandenen Ergebnissen.
|
||||||
46
docs/review-2026-09-09/README.md
Normal file
46
docs/review-2026-09-09/README.md
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
# Neue Vorlagen und Prüfbericht
|
||||||
|
|
||||||
|
Erstellt am 09.09.2026 für das Datenschutzportal des Amts Leezen.
|
||||||
|
|
||||||
|
## Dateien
|
||||||
|
|
||||||
|
- PRUEFBERICHT.md: konkrete Befunde, Prioritäten, Fundstellen, Quellen und noch erforderliche Tests.
|
||||||
|
- 01-erklaerung-zur-barrierefreiheit.md: Vorlage für das Admin-Feld „Erklärung zur Barrierefreiheit“.
|
||||||
|
- 02-leichte-sprache.md: redaktioneller Entwurf für „Leichte Sprache“.
|
||||||
|
- 03-gebaerdensprache.md: Begleittext für „Gebärdensprache“.
|
||||||
|
|
||||||
|
Die Vorlagen beginnen ohne Hauptüberschrift, weil LegalPage bereits eine h1 ausgibt.
|
||||||
|
|
||||||
|
## Vor Veröffentlichung
|
||||||
|
|
||||||
|
1. Alle Platzhalter in doppelten geschweiften Klammern ersetzen oder entsprechend ihrer Anweisung entfernen. Keine Platzhalter veröffentlichen.
|
||||||
|
2. Tatsächliche Portaladresse, verantwortliche Kontaktstelle, Geltungsbereich und erreichbare Kontaktwege eintragen.
|
||||||
|
3. Einen nachvollziehbaren Konformitätsstatus anhand einer ausreichenden Prüfung festlegen. Dieser Code-Review ist keine vollständige EN-301-549-Prüfung. Ein noch ungeprüfter Bereich darf nicht einfach als nachgewiesener Verstoß eingetragen werden.
|
||||||
|
4. Nur wirklich durchgeführte Tests, reale Prüf- und Veröffentlichungsdaten und tatsächlich zugesagte Behebungstermine nennen. Bestehende Einschränkungen aus dem Bericht vor Übernahme im Browser bestätigen.
|
||||||
|
5. Leichte Sprache fachlich und möglichst mit Menschen aus der Zielgruppe prüfen; Verständlichkeit und passende Gestaltung sind mit der Texterstellung allein nicht bestätigt. Keine Zertifizierung oder Prüfung durch eine Prüfgruppe behaupten.
|
||||||
|
6. Beschreibung und Umfang des Gebärdensprach-Videos mit dem tatsächlichen Video abgleichen. Der Begleittext orientiert sich am vom Nutzer mitgeteilten Titel. Das Video wurde nicht gesichtet.
|
||||||
|
7. Die DGS-Angebote müssen die wesentlichen Inhalte, Navigation, wesentlichen Inhalte der Barrierefreiheitserklärung und Hinweise auf weitere entsprechende Angebote abdecken. Falls das bestehende Video nur die Erklärung behandelt, braucht es eine Ergänzung oder ein neues Gesamtvideo. Die ergänzenden deutschen Absätze sind keine DGS-Übersetzung.
|
||||||
|
8. Veröffentlichten Stand und Links anschließend ohne Anmeldung prüfen.
|
||||||
|
|
||||||
|
## Video einbinden
|
||||||
|
|
||||||
|
Im Admin unter „Rechtliche Seiten“ → „Gebärdensprache“ die folgende Adresse in das separate Feld „Adresse des Videos“ eintragen:
|
||||||
|
|
||||||
|
https://signlanguage.alangu.de/player/?key=v2DM_moWiFpmKRjIa18Pt00kKH_g&autostart=false&subtitle=true
|
||||||
|
|
||||||
|
Den Inhalt von 03-gebaerdensprache.md in das Markdown-Feld kopieren. Kein iframe-HTML in das Markdown einfügen: Der Sanitizer entfernt es. Das separate Untertitelfeld wirkt nur für native Videodateien, nicht für den alangu-iframe. subtitle=true ist eine Player-Einstellung, kein Nachweis tatsächlich vorhandener oder korrekter Untertitel.
|
||||||
|
|
||||||
|
## Inhaltliche Abweichungen von den bisherigen Vorlagen
|
||||||
|
|
||||||
|
- Schleswig-Holstein und dessen Beschwerdeverfahren statt der Bundes-Schlichtungsstelle.
|
||||||
|
- Konkreter Geltungsbereich, überprüfter Status und tatsächliche Prüfmethode bleiben bewusst auszufüllen.
|
||||||
|
- Nicht barrierefreie Inhalte, unverhältnismäßige Belastung und ausgenommene Inhalte werden getrennt erfasst.
|
||||||
|
- Leichte Sprache erklärt Daten, Portalnutzung, Hilfen und Beschwerden.
|
||||||
|
- Der Gebärdensprachtext behauptet nicht, dass das vorhandene Video bereits alle Portalthemen abdeckt.
|
||||||
|
- Redaktionshinweise stehen in dieser Anleitung, nicht als fertige Behauptungen in den veröffentlichten Texten.
|
||||||
|
|
||||||
|
## Quellen zur redaktionellen Finalisierung
|
||||||
|
|
||||||
|
- [Prüfstelle Schleswig-Holstein: Checklisten und aktuelle Vorlagen](https://www.schleswig-holstein.de/DE/landesregierung/ministerien-behoerden/PBISH/Service/checklisten/checklisten)
|
||||||
|
- [§ 4 BITV 2.0: Leichte Sprache und Deutsche Gebärdensprache](https://www.gesetze-im-internet.de/bitv_2_0/__4.html)
|
||||||
|
- [Beschwerdestelle: Verfahren und aktuelle Kontaktdaten](https://www.inklusion.sh/unsere-aufgaben/beschwerdestelle/)
|
||||||
7
eslint.config.mjs
Normal file
7
eslint.config.mjs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { FlatCompat } from "@eslint/eslintrc"
|
||||||
|
const compat = new FlatCompat({ baseDirectory: import.meta.dirname })
|
||||||
|
export default [
|
||||||
|
{ ignores: [".next/**", "node_modules/**", "test-results/**", "playwright-report/**"] },
|
||||||
|
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||||
|
{ rules: { "@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-unused-vars": "warn", "react-hooks/exhaustive-deps": "warn" } },
|
||||||
|
]
|
||||||
@ -1,10 +1,9 @@
|
|||||||
import NextAuth, { NextAuthConfig } from "next-auth"
|
import NextAuth, { NextAuthConfig } from "next-auth"
|
||||||
import CredentialsProvider from "next-auth/providers/credentials"
|
import CredentialsProvider from "next-auth/providers/credentials"
|
||||||
import { PrismaAdapter } from "@auth/prisma-adapter"
|
import { PrismaAdapter } from "@auth/prisma-adapter"
|
||||||
import { PrismaClient } from "@prisma/client"
|
|
||||||
import bcrypt from "bcryptjs"
|
import bcrypt from "bcryptjs"
|
||||||
|
|
||||||
const prisma = new PrismaClient()
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
export const authConfig: NextAuthConfig = {
|
export const authConfig: NextAuthConfig = {
|
||||||
adapter: PrismaAdapter(prisma),
|
adapter: PrismaAdapter(prisma),
|
||||||
@ -55,11 +54,13 @@ export const authConfig: NextAuthConfig = {
|
|||||||
async jwt({ token, user }) {
|
async jwt({ token, user }) {
|
||||||
if (user) {
|
if (user) {
|
||||||
token.role = user.role
|
token.role = user.role
|
||||||
|
token.issuedAt = Date.now()
|
||||||
}
|
}
|
||||||
return token
|
return token
|
||||||
},
|
},
|
||||||
async session({ session, token }) {
|
async session({ session, token }) {
|
||||||
if (token) {
|
if (token) {
|
||||||
|
session.issuedAt = typeof token.issuedAt === "number" ? token.issuedAt : undefined
|
||||||
session.user.id = token.sub ?? ""
|
session.user.id = token.sub ?? ""
|
||||||
session.user.role = token.role as string
|
session.user.role = token.role as string
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,7 +33,7 @@ export async function markdownToHtml(markdown: string): Promise<string> {
|
|||||||
// Simple preprocessing for line breaks
|
// Simple preprocessing for line breaks
|
||||||
const preprocessed = simplePreprocessLineBreaks(markdown)
|
const preprocessed = simplePreprocessLineBreaks(markdown)
|
||||||
const result = await processor.process(preprocessed)
|
const result = await processor.process(preprocessed)
|
||||||
return result.toString()
|
return wrapTables(result.toString())
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error processing markdown:', error)
|
console.error('Error processing markdown:', error)
|
||||||
// Fallback: return sanitized markdown with basic formatting
|
// Fallback: return sanitized markdown with basic formatting
|
||||||
@ -51,7 +51,7 @@ export function markdownToHtmlSync(markdown: string): string {
|
|||||||
// Simple preprocessing for line breaks
|
// Simple preprocessing for line breaks
|
||||||
const preprocessed = simplePreprocessLineBreaks(markdown)
|
const preprocessed = simplePreprocessLineBreaks(markdown)
|
||||||
const result = processor.processSync(preprocessed)
|
const result = processor.processSync(preprocessed)
|
||||||
return result.toString()
|
return wrapTables(result.toString())
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error processing markdown synchronously:', error)
|
console.error('Error processing markdown synchronously:', error)
|
||||||
// Fallback: return sanitized markdown with basic formatting
|
// Fallback: return sanitized markdown with basic formatting
|
||||||
@ -71,28 +71,9 @@ function simplePreprocessLineBreaks(markdown: string): string {
|
|||||||
|
|
||||||
// Fallback function for basic markdown formatting
|
// Fallback function for basic markdown formatting
|
||||||
function sanitizeAndFormatMarkdown(text: string): string {
|
function sanitizeAndFormatMarkdown(text: string): string {
|
||||||
// Simple, clean markdown processing
|
return "<p>" + escapeHtml(text).replace(/\n/g, "<br>") + "</p>"
|
||||||
return text
|
}
|
||||||
// Escape HTML
|
|
||||||
.replace(/&/g, '&')
|
function wrapTables(html: string) {
|
||||||
.replace(/</g, '<')
|
return html.replace(/<table>/g, '<div class="markdown-table" role="region" aria-label="Tabelle" tabindex="0"><table>').replace(/<\/table>/g, "</table></div>")
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''')
|
|
||||||
// Basic markdown formatting
|
|
||||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
|
||||||
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
|
||||||
.replace(/`(.*?)`/g, '<code>$1</code>')
|
|
||||||
// Headers
|
|
||||||
.replace(/^### (.*$)/gm, '<h3>$1</h3>')
|
|
||||||
.replace(/^## (.*$)/gm, '<h2>$1</h2>')
|
|
||||||
.replace(/^# (.*$)/gm, '<h1>$1</h1>')
|
|
||||||
// Lists
|
|
||||||
.replace(/^\* (.*$)/gm, '• $1')
|
|
||||||
.replace(/^- (.*$)/gm, '• $1')
|
|
||||||
.replace(/^(\d+)\. (.*$)/gm, '$1. $2')
|
|
||||||
// Line breaks
|
|
||||||
.replace(/\n/g, '<br>')
|
|
||||||
// Clean up multiple <br> tags
|
|
||||||
.replace(/<br><br>/g, '<br>')
|
|
||||||
}
|
}
|
||||||
26
lib/policy-input.ts
Normal file
26
lib/policy-input.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { DEFAULTED_FIELDS } from './policy-defaults'
|
||||||
|
import type { DefaultedField } from './policy-defaults'
|
||||||
|
|
||||||
|
const OPTIONAL_FIELDS = ['processingPurposes', 'legalBasis', 'legitimateInterests',
|
||||||
|
'storageDuration', 'dataSource', 'dataCategories'] as const
|
||||||
|
const REQUIRED_FIELDS = ['title', 'processingDescription', 'recipients'] as const
|
||||||
|
export type PolicyInput = Record<typeof REQUIRED_FIELDS[number], string> &
|
||||||
|
Partial<Record<DefaultedField | typeof OPTIONAL_FIELDS[number], string | null>> & { isPublic?: boolean }
|
||||||
|
|
||||||
|
/** Validate untrusted request values before callers use string operations. */
|
||||||
|
export function parsePolicyInput(value: unknown): PolicyInput | null {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
||||||
|
const input = value as Record<string, unknown>
|
||||||
|
if (input.isPublic !== undefined && typeof input.isPublic !== 'boolean') return null
|
||||||
|
const fields = [...REQUIRED_FIELDS, ...OPTIONAL_FIELDS, ...DEFAULTED_FIELDS]
|
||||||
|
for (const field of fields) {
|
||||||
|
const text = input[field]
|
||||||
|
if (text !== undefined && text !== null && (typeof text !== 'string' || text.length > 100000)) return null
|
||||||
|
}
|
||||||
|
const text = (key: string) => typeof input[key] === 'string' ? input[key].trim() : ''
|
||||||
|
return {
|
||||||
|
...Object.fromEntries([...OPTIONAL_FIELDS, ...DEFAULTED_FIELDS].map(key => [key, text(key) || null])),
|
||||||
|
title: text('title'), processingDescription: text('processingDescription'), recipients: text('recipients'),
|
||||||
|
...(input.isPublic === undefined ? {} : { isPublic: input.isPublic as boolean }),
|
||||||
|
}
|
||||||
|
}
|
||||||
4
lib/prisma.ts
Normal file
4
lib/prisma.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }
|
||||||
|
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
|
||||||
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||||
@ -1,32 +1,22 @@
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
|
|
||||||
import { auth } from '@/lib/auth'
|
import { auth } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { canUseSession } from '@/lib/session-access'
|
||||||
|
|
||||||
/**
|
export async function getAdminSession() {
|
||||||
* Sagt, ob die aktuelle Anfrage von einer angemeldeten Admin-Sitzung kommt.
|
|
||||||
* Für Routen, die je nach Anmeldung unterschiedlich viel ausliefern, statt
|
|
||||||
* die Anfrage abzuweisen.
|
|
||||||
*/
|
|
||||||
export async function isAdminSession() {
|
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
|
const id = Number(session?.user.id)
|
||||||
return (
|
if (!session || !Number.isSafeInteger(id) || id <= 0) return null
|
||||||
!!session &&
|
const user = await prisma.user.findUnique({ where: { id } })
|
||||||
(session.user.role === 'ADMIN' || session.user.role === 'SUPER_ADMIN')
|
if (!canUseSession(user, session.issuedAt)) return null
|
||||||
)
|
return { ...session, user: { ...session.user, role: user!.role } }
|
||||||
|
}
|
||||||
|
export async function isAdminSession() {
|
||||||
|
return !!(await getAdminSession())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Prüft, ob eine angemeldete Sitzung mit Admin-Rolle vorliegt.
|
|
||||||
* Gibt im Fehlerfall die fertige 401-Antwort zurück, sonst `null`.
|
|
||||||
*/
|
|
||||||
export async function requireAdmin() {
|
export async function requireAdmin() {
|
||||||
if (!(await isAdminSession())) {
|
if (!(await isAdminSession())) {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||||
{ error: 'Nicht autorisiert' },
|
|
||||||
{ status: 401 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
8
lib/session-access.ts
Normal file
8
lib/session-access.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export function canUseSession(
|
||||||
|
user: { isActive: boolean; updatedAt: Date; role: string } | null,
|
||||||
|
issuedAt: number | undefined,
|
||||||
|
) {
|
||||||
|
return !!user && user.isActive &&
|
||||||
|
(user.role === 'ADMIN' || user.role === 'SUPER_ADMIN') &&
|
||||||
|
typeof issuedAt === 'number' && issuedAt >= user.updatedAt.getTime()
|
||||||
|
}
|
||||||
6
lib/site-page-templates.ts
Normal file
6
lib/site-page-templates.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
// Entwürfe: Platzhalter vor der Veröffentlichung ausfüllen.
|
||||||
|
export const sitePageTemplates = {
|
||||||
|
"accessibility": "Das Amt Leezen möchte dieses Portal für alle Menschen zugänglich machen. Maßgeblich sind das Landesbehindertengleichstellungsgesetz Schleswig-Holstein (LBGG), insbesondere § 13 Absatz 3 und § 14, sowie die darüber einbezogenen Anforderungen der BITV 2.0.\n\nDiese Erklärung gilt für das Datenschutzportal des Amts Leezen unter **{{PORTAL_URL}}** einschließlich **{{GELTUNGSBEREICH_KONKRET_BENENNEN}}**.\n\n## Stand der Vereinbarkeit mit den Anforderungen\n\nDiese Website ist mit den geltenden Anforderungen **{{BEWERTETEN_STATUS_EINTRAGEN: vollständig vereinbar / teilweise vereinbar / nicht vereinbar}}**.\n\nDie Bewertung bezieht sich auf die anwendbaren Anforderungen der EN 301 549 V3.2.1, einschließlich der WCAG 2.1 auf den Stufen A und AA.\n\n## Nicht barrierefreie Inhalte\n\n### Abweichungen von den Anforderungen\n\n{{BESTÄTIGTE_BARRIEREN_AUFLISTEN. Je Barriere: betroffene Seite oder Funktion, Einschränkung, nicht erfüllte Anforderung, Ursache, verfügbare Alternative und gegebenenfalls geplanter Behebungstermin.}}\n\n### Unverhältnismäßige Belastung\n\n{{NUR_TATSÄCHLICH_GEPRÜFTE_UND_BEGRÜNDETE_AUSNAHMEN_EINTRAGEN. Falls keine beansprucht werden, ausdrücklich so angeben.}}\n\n### Inhalte außerhalb des Anwendungsbereichs\n\n{{TATSÄCHLICH_AUSGENOMMENE_INHALTE_MIT_GRUND_BENENNEN. Falls keine bekannt sind, entsprechend angeben.}}\n\n## Erstellung und Überprüfung dieser Erklärung\n\nErstellt am: **{{ERSTELLUNGSDATUM}}** \nZuletzt überprüft am: **{{PRÜFDATUM}}**\n\nDie Bewertung erfolgte durch **{{VERANTWORTLICHE_STELLE_ODER_PRÜFDIENST}}** mittels **{{TATSÄCHLICHE_PRÜFMETHODE}}**.\n\nGeprüft wurden **{{SEITEN_UND_PROZESSE}}**. Dabei kamen **{{TATSÄCHLICH_VERWENDETE_BROWSER_HILFSMITTEL_UND_TESTVERFAHREN}}** zum Einsatz.\n\n## Barrieren melden und Informationen anfordern\n\nSie können uns Probleme bei der Nutzung melden. Sie können auch Informationen in einer für Sie zugänglichen Form anfordern.\n\n**Amt Leezen – {{ZUSTÄNDIGE_STELLE}}**\n\n- E-Mail: [{{KONTAKT_EMAIL}}](mailto:{{KONTAKT_EMAIL}})\n- Telefon: {{KONTAKT_TELEFON}}\n- Postanschrift: {{KONTAKT_POSTANSCHRIFT}}\n\nBitte nennen Sie möglichst die betroffene Seite und beschreiben Sie das Problem. Teilen Sie uns mit, auf welchem Weg wir Sie erreichen können.\n\n## Beschwerdeverfahren\n\nWenn Sie mit unserer Antwort nicht zufrieden sind oder Ihre Meldung unbeantwortet bleibt, können Sie sich an die Beschwerdestelle für barrierefreie Informationstechnik in Schleswig-Holstein wenden. Sie unterstützt die Durchsetzung der digitalen Barrierefreiheit nach § 16 LBGG.\n\n**Beschwerdestelle für barrierefreie Informationstechnik** \nbei der Landesbeauftragten für Menschen mit Behinderungen \nPostfach 7121 \n24171 Kiel\n\n- Ansprechpartner: Jens Handler\n- Telefon: 0431 988-1612\n- E-Mail: [jens.handler@landtag.ltsh.de](mailto:jens.handler@landtag.ltsh.de)\n- [Informationen, Kontakt und Zugang zum Beschwerdeformular](https://www.inklusion.sh/unsere-aufgaben/beschwerdestelle/)\n\n## Weitere Angebote\n\n- [Informationen in Leichter Sprache](/leichte-sprache)\n- [Informationen in Deutscher Gebärdensprache](/gebaerdensprache)\n",
|
||||||
|
"easyLanguage": "## Willkommen\n\nSie sind auf einer Internet-Seite vom Amt Leezen.\n\nHier finden Sie Informationen zum Daten-Schutz.\n\n## Was sind persönliche Daten?\n\nPersönliche Daten sind Informationen über einen Menschen.\n\nZum Beispiel:\n\n- Ihr Name.\n- Ihre Adresse.\n- Ihr Geburts-Datum.\n\nDas Amt braucht für viele Aufgaben persönliche Daten.\n\nZum Beispiel:\nSie stellen einen Antrag.\nDas Amt muss wissen:\nWer stellt den Antrag?\n\n## Was steht auf dieser Internet-Seite?\n\nHier gibt es Texte zu verschiedenen Aufgaben vom Amt.\n\nDiese Texte heißen:\nDaten-Schutz-Erklärungen.\n\nIn den Texten steht zum Beispiel:\n\n- Welche Daten braucht das Amt?\n- Wofür braucht das Amt die Daten?\n- Wer bekommt die Daten?\n- Wie lange speichert das Amt die Daten?\n- Welche Rechte haben Sie?\n- Wen können Sie bei Fragen ansprechen?\n\n## So finden Sie einen Text\n\nGehen Sie zur [Übersicht der Daten-Schutz-Erklärungen](/).\n\nDort sehen Sie eine Liste mit Themen.\n\nDie Themen sind nach dem Alphabet geordnet.\n\nWählen Sie ein Thema aus.\nDann öffnet sich der Text zu diesem Thema.\n\nSie können auch die Suche benutzen.\n\nSchreiben Sie ein Wort in das Such-Feld.\nZum Beispiel: Bau.\n\nDann sehen Sie die passenden Themen.\n\nSie finden nichts?\n\nVersuchen Sie ein anderes Wort.\n\nOder wählen Sie:\n**Suche zurücksetzen**.\n\nDann sehen Sie wieder alle Themen.\n\n## So kommen Sie zurück zur Übersicht\n\nWählen Sie den Namen des Portals in der Kopf-Zeile.\n\nOder nutzen Sie diesen Link:\n\n[Zur Übersicht](/)\n\n## Was bedeutet Barriere-Freiheit?\n\nEine Barriere ist ein Hindernis.\n\nAuf einer Internet-Seite kann das zum Beispiel sein:\n\nEin Text ist schwer zu lesen.\n\nOder:\nEine Schalt-Fläche lässt sich nicht mit der Tastatur bedienen.\n\nWir möchten:\nAlle Menschen können unsere Internet-Seite benutzen.\n\n## Wie barriere-frei ist diese Seite?\n\n{{HIER_DEN_GEPRÜFTEN_STAND_IN_KURZEN_SÄTZEN_BESCHREIBEN. BESTÄTIGTE_HINDERNISSE_UND_HILFEN_BENENNEN.}}\n\nMehr dazu steht in unserer\n[Erklärung zur Barriere-Freiheit](/barrierefreiheit).\n\n## Sie brauchen Hilfe?\n\nSie können uns ein Problem melden.\n\nSie können auch fragen:\nKönnen Sie mir die Information anders geben?\n\nZum Beispiel:\nAls gut lesbaren Text.\n\nSchreiben Sie uns:\n\n[{{KONTAKT_EMAIL}}](mailto:{{KONTAKT_EMAIL}})\n\nOder rufen Sie uns an:\n\n**{{KONTAKT_TELEFON}}**\n\nSagen Sie uns:\n\n- Welche Seite möchten Sie benutzen?\n- Was funktioniert nicht?\n- Wie können wir Ihnen antworten?\n\n## Ihre Meldung hilft nicht weiter?\n\nEs gibt eine Beschwerde-Stelle in Schleswig-Holstein.\n\nSie hilft bei Hindernissen auf Internet-Seiten von öffentlichen Stellen.\n\nZum Beispiel:\nAuf einer Internet-Seite von einem Amt.\n\n[Hier finden Sie die Beschwerde-Stelle](https://www.inklusion.sh/unsere-aufgaben/beschwerdestelle/).\n\n## Weitere Informationen\n\nEs gibt auch eine Seite mit einem Video in Deutscher Gebärden-Sprache.\n\n[Zur Seite mit Gebärden-Sprache](/gebaerdensprache)\n\n{{WEITERE_TATSÄCHLICH_VORHANDENE_ANGEBOTE_IN_LEICHTER_SPRACHE_VERLINKEN_ODER_DIESEN_PLATZHALTER_ENTFERNEN.}}\n",
|
||||||
|
"signLanguage": "## Informationen in Deutscher Gebärdensprache\n\nHier finden Sie das Gebärdensprach-Video des Amts Leezen zur Erklärung zur Barrierefreiheit des Datenschutzportals.\n\nDas Video wird über den Player von alangu bereitgestellt.\n\n[Video direkt bei alangu öffnen](https://signlanguage.alangu.de/player/?key=v2DM_moWiFpmKRjIa18Pt00kKH_g&autostart=false&subtitle=true)\n\n## Ergänzende Informationen zum Portal\n\nIn diesem Portal finden Sie Datenschutzerklärungen zu Verwaltungsaufgaben des Amts Leezen. Sie erläutern, welche personenbezogenen Daten verarbeitet werden, zu welchem Zweck dies geschieht und welche Rechte Sie haben.\n\nIn der [Übersicht der Datenschutzerklärungen](/) können Sie einen Titel auswählen oder nach einem Thema suchen. Über den Namen des Portals in der Kopfzeile gelangen Sie zur Übersicht zurück.\n\n## Erklärung zur Barrierefreiheit\n\nDie [Erklärung zur Barrierefreiheit](/barrierefreiheit) informiert über den geprüften Stand, bestehende Barrieren und Kontaktmöglichkeiten.\n\n## Barrieren melden\n\nWenn Sie das Video oder eine andere Funktion nicht nutzen können, wenden Sie sich bitte an:\n\n**Amt Leezen – {{ZUSTÄNDIGE_STELLE}}**\n\n- E-Mail: [{{KONTAKT_EMAIL}}](mailto:{{KONTAKT_EMAIL}})\n- Telefon: {{KONTAKT_TELEFON}}\n- Postanschrift: {{KONTAKT_POSTANSCHRIFT}}\n\nSie können uns auch mitteilen, in welcher Form Sie eine Information benötigen.\n\nFalls Ihre Meldung unbeantwortet bleibt oder Sie mit der Antwort nicht zufrieden sind, können Sie sich an die [Beschwerdestelle für barrierefreie Informationstechnik in Schleswig-Holstein](https://www.inklusion.sh/unsere-aufgaben/beschwerdestelle/) wenden.\n\n## Weitere Angebote\n\n[Informationen in Leichter Sprache](/leichte-sprache)\n\n{{WEITERE_TATSÄCHLICH_VORHANDENE_DGS_ANGEBOTE_VERLINKEN_ODER_DIESEN_PLATZHALTER_ENTFERNEN.}}\n"
|
||||||
|
} as const
|
||||||
@ -1,4 +1,3 @@
|
|||||||
import { PrismaClient } from '@prisma/client'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EMPTY_SITE_PAGES,
|
EMPTY_SITE_PAGES,
|
||||||
@ -8,7 +7,7 @@ import {
|
|||||||
} from '@/lib/site-pages'
|
} from '@/lib/site-pages'
|
||||||
|
|
||||||
// Prisma client instance
|
// Prisma client instance
|
||||||
const prisma = new PrismaClient()
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liest die Inhalte der rechtlichen Seiten (id = 1). Fehlt der Datensatz,
|
* Liest die Inhalte der rechtlichen Seiten (id = 1). Fehlt der Datensatz,
|
||||||
|
|||||||
@ -37,7 +37,7 @@ export const SITE_PAGE_META: Record<
|
|||||||
title: "Erklärung zur Barrierefreiheit",
|
title: "Erklärung zur Barrierefreiheit",
|
||||||
short: "Barrierefreiheit",
|
short: "Barrierefreiheit",
|
||||||
path: "/barrierefreiheit",
|
path: "/barrierefreiheit",
|
||||||
hint: "Pflichtangabe öffentlicher Stellen nach § 7 BITV 2.0, jährlich zu überprüfen",
|
hint: "Pflichtangabe öffentlicher Stellen nach § 14 LBGG Schleswig-Holstein, jährlich zu überprüfen",
|
||||||
},
|
},
|
||||||
easyLanguage: {
|
easyLanguage: {
|
||||||
title: "Leichte Sprache",
|
title: "Leichte Sprache",
|
||||||
@ -78,66 +78,55 @@ export const EMPTY_SITE_PAGES: SitePagesContent = {
|
|||||||
signLanguageCaptions: "",
|
signLanguageCaptions: "",
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Anbieter, deren Player als eingebetteter Rahmen zugelassen ist.
|
|
||||||
*
|
|
||||||
* Bewusst eine kurze Liste statt einer allgemeinen Freigabe für iframes: Ein
|
|
||||||
* eingebetteter Rahmen lädt beim Seitenaufruf von einem fremden Server und
|
|
||||||
* überträgt dabei IP-Adresse und Browserkennung der Besucherinnen und
|
|
||||||
* Besucher. Das ist bei einem Gebärdensprach-Dienst vertretbar, für den ein
|
|
||||||
* Auftragsverarbeitungsvertrag besteht – bei einem Videoportal wie YouTube
|
|
||||||
* wäre es das nicht.
|
|
||||||
*
|
|
||||||
* Eingetragen wird die Domain; Unterdomains sind eingeschlossen.
|
|
||||||
*/
|
|
||||||
export const EMBED_HOSTS = ["alangu.de"] as const
|
|
||||||
|
|
||||||
/**
|
/** Only the explicitly configured player host may be embedded. */
|
||||||
* Hat eine Seite Inhalt und wird deshalb ausgeliefert und verlinkt?
|
export const EMBED_HOSTS = ["signlanguage.alangu.de"] as const
|
||||||
*
|
|
||||||
* Auf der Gebärdensprach-Seite ist das Video der eigentliche Inhalt; sie zählt
|
|
||||||
* daher auch ohne Text als gepflegt. Fußzeile und Seite selbst müssen dieselbe
|
|
||||||
* Regel verwenden, sonst gibt es eine erreichbare Seite ohne Link oder einen
|
|
||||||
* Link ins Leere.
|
|
||||||
*/
|
|
||||||
export function hasSitePageContent(
|
|
||||||
pages: SitePagesContent,
|
|
||||||
key: SitePageKey
|
|
||||||
): boolean {
|
|
||||||
if (pages[key].trim()) return true
|
|
||||||
return key === "signLanguage" && pages.signLanguageVideo.trim().length > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Gehört die Adresse zu einem zugelassenen Einbettungs-Anbieter? */
|
|
||||||
export function isAllowedEmbedUrl(value: string) {
|
export function isAllowedEmbedUrl(value: string) {
|
||||||
const wert = value.trim()
|
|
||||||
if (!wert) return false
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(wert)
|
const url = new URL(value.trim())
|
||||||
if (url.protocol !== "https:") return false
|
return url.protocol === "https:" && !url.username && !url.password &&
|
||||||
|
!url.port && EMBED_HOSTS.some(host => url.hostname === host)
|
||||||
return EMBED_HOSTS.some(
|
} catch { return false }
|
||||||
(host) => url.hostname === host || url.hostname.endsWith(`.${host}`)
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Prüft eine Medienadresse. Zugelassen sind eine Adresse auf dem eigenen
|
|
||||||
* Server (mit / beginnend) und https. Damit sind `javascript:` und `data:`
|
|
||||||
* ausgeschlossen, bevor der Wert in ein src-Attribut gelangt.
|
|
||||||
*/
|
|
||||||
export function isSafeMediaUrl(value: string) {
|
export function isSafeMediaUrl(value: string) {
|
||||||
const wert = value.trim()
|
const input = value.trim()
|
||||||
if (!wert) return true
|
if (!input) return true
|
||||||
if (wert.startsWith("/") && !wert.startsWith("//")) return true
|
if (/[\\\u0000-\u0020]/.test(input)) return false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return new URL(wert).protocol === "https:"
|
const base = "https://local.invalid"
|
||||||
} catch {
|
const url = new URL(input, base)
|
||||||
return false
|
if (url.username || url.password || url.port) return false
|
||||||
|
if (input.startsWith("/") && !input.startsWith("//")) return url.origin === base
|
||||||
|
return isAllowedEmbedUrl(input)
|
||||||
|
} catch { return false }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasSitePageContent(pages: SitePagesContent, key: SitePageKey): boolean {
|
||||||
|
if (pages[key].trim()) return true
|
||||||
|
return key === "signLanguage" && !!pages.signLanguageVideo.trim() && isSafeMediaUrl(pages.signLanguageVideo)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateSitePages(data: unknown, previous: SitePagesContent) {
|
||||||
|
if (!data || typeof data !== "object" || Array.isArray(data)) return ["Ungültige Seiteninhalte."]
|
||||||
|
const input = data as Record<string, unknown>
|
||||||
|
const errors: string[] = []
|
||||||
|
for (const key of [...SITE_PAGES, ...SITE_MEDIA]) {
|
||||||
|
if (typeof input[key] !== "string" || (input[key] as string).length > 100000) {
|
||||||
|
errors.push(key + ": ein Text mit maximal 100.000 Zeichen ist erforderlich.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (errors.length) return errors
|
||||||
|
const next = input as SitePagesContent
|
||||||
|
for (const key of SITE_MEDIA) {
|
||||||
|
if (!isSafeMediaUrl(next[key])) errors.push(key + ": nur lokale Pfade oder der freigegebene alangu-Host sind zulässig.")
|
||||||
|
}
|
||||||
|
for (const key of SITE_PAGES) {
|
||||||
|
if (hasSitePageContent(previous, key) && !hasSitePageContent(next, key))
|
||||||
|
errors.push(SITE_PAGE_META[key].title + ": eine veröffentlichte Seite darf nicht geleert werden.")
|
||||||
|
if (/\{\{[\s\S]*?\}\}/.test(next[key]))
|
||||||
|
errors.push(SITE_PAGE_META[key].title + ": bitte zuerst die Platzhalter der Vorlage ausfüllen.")
|
||||||
|
}
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|||||||
16
lib/user-validation.ts
Normal file
16
lib/user-validation.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
export function validateUserInput(input: unknown, create: boolean): string | null {
|
||||||
|
if (!input || typeof input !== "object" || Array.isArray(input)) return "Ungültige Benutzerdaten."
|
||||||
|
const data = input as Record<string, unknown>
|
||||||
|
if (create || data.email !== undefined) {
|
||||||
|
if (typeof data.email !== "string" || data.email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email.trim())) return "Bitte geben Sie eine gültige E-Mail-Adresse ein."
|
||||||
|
}
|
||||||
|
if (data.name !== undefined && data.name !== null && (typeof data.name !== "string" || data.name.length > 200)) return "Der Name darf maximal 200 Zeichen enthalten."
|
||||||
|
if (create || data.role !== undefined) {
|
||||||
|
if (data.role !== "ADMIN" && data.role !== "SUPER_ADMIN") return "Ungültige Rolle."
|
||||||
|
}
|
||||||
|
if (data.isActive !== undefined && typeof data.isActive !== "boolean") return "Ungültiger Aktivierungsstatus."
|
||||||
|
if (create || (data.password !== undefined && data.password !== "")) {
|
||||||
|
if (typeof data.password !== "string" || data.password.length < 12 || new TextEncoder().encode(data.password).length > 72) return "Das Passwort muss mindestens 12 Zeichen und höchstens 72 UTF-8-Bytes enthalten."
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
@ -80,6 +80,7 @@ export default auth((req) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
|
runtime: "nodejs",
|
||||||
matcher: [
|
matcher: [
|
||||||
/*
|
/*
|
||||||
* Alle Pfade außer statischen Next.js-Dateien und dem Favicon.
|
* Alle Pfade außer statischen Next.js-Dateien und dem Favicon.
|
||||||
|
|||||||
@ -3,6 +3,7 @@ const path = require('path')
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
distDir: process.env.TEST_DIST_DIR || '.next',
|
||||||
// Blendet das Next.js-Overlay unten links im Entwicklungsmodus aus.
|
// Blendet das Next.js-Overlay unten links im Entwicklungsmodus aus.
|
||||||
devIndicators: false,
|
devIndicators: false,
|
||||||
// Erzeugt .next/standalone mit minimalem Server für das Docker-Image.
|
// Erzeugt .next/standalone mit minimalem Server für das Docker-Image.
|
||||||
|
|||||||
5928
package-lock.json
generated
5928
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
31
package.json
31
package.json
@ -8,7 +8,7 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "eslint app components hooks lib --quiet",
|
||||||
"seed-admin": "node scripts/seed-admin.js",
|
"seed-admin": "node scripts/seed-admin.js",
|
||||||
"reset-admin": "node scripts/reset-admin.js",
|
"reset-admin": "node scripts/reset-admin.js",
|
||||||
"import-policy": "node scripts/import-policy.js",
|
"import-policy": "node scripts/import-policy.js",
|
||||||
@ -18,11 +18,15 @@
|
|||||||
"compare-csv-pdf": "node scripts/compare-csv-pdf.js",
|
"compare-csv-pdf": "node scripts/compare-csv-pdf.js",
|
||||||
"check-completeness": "node scripts/check-completeness.js",
|
"check-completeness": "node scripts/check-completeness.js",
|
||||||
"enrich-legal-basis": "node scripts/enrich-legal-basis.js",
|
"enrich-legal-basis": "node scripts/enrich-legal-basis.js",
|
||||||
"fix-legal-citations": "node scripts/fix-legal-citations.js"
|
"fix-legal-citations": "node scripts/fix-legal-citations.js",
|
||||||
|
"typecheck": "tsc --noEmit --incremental false",
|
||||||
|
"test": "tsx --test tests/unit/security.test.ts tests/unit/prisma-compat.test.ts",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:prod": "playwright test --config playwright.production.config.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^2.10.0",
|
"@auth/prisma-adapter": "^2.10.0",
|
||||||
"@prisma/client": "^6.16.0",
|
"@prisma/client": "^6.19.3",
|
||||||
"@radix-ui/react-accordion": "^1.2.20",
|
"@radix-ui/react-accordion": "^1.2.20",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.23",
|
"@radix-ui/react-alert-dialog": "^1.1.23",
|
||||||
"@radix-ui/react-avatar": "^1.2.6",
|
"@radix-ui/react-avatar": "^1.2.6",
|
||||||
@ -44,11 +48,11 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"next": "^15.5.3",
|
"next": "^15.5.25",
|
||||||
"next-auth": "^5.0.0-beta.29",
|
"next-auth": "^5.0.0-beta.29",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.23",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.9",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.9",
|
||||||
"remark": "^15.0.1",
|
"remark": "^15.0.1",
|
||||||
"remark-breaks": "^4.0.0",
|
"remark-breaks": "^4.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
@ -60,12 +64,23 @@
|
|||||||
"tw-animate-css": "^1.3.8"
|
"tw-animate-css": "^1.3.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@axe-core/playwright": "^4.13.0",
|
||||||
|
"@playwright/test": "^1.63.0",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^24.5.0",
|
"@types/node": "^24.5.0",
|
||||||
"@types/react": "^19.1.13",
|
"@types/react": "^19.1.13",
|
||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.1.9",
|
||||||
|
"eslint": "^9.39.5",
|
||||||
|
"eslint-config-next": "^15.5.25",
|
||||||
"pdf-parse": "^2.4.5",
|
"pdf-parse": "^2.4.5",
|
||||||
"prisma": "^6.16.0",
|
"prisma": "^6.19.3",
|
||||||
|
"tsx": "^4.23.13",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^5.9.2"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"postcss": "$postcss",
|
||||||
|
"@prisma/config": {
|
||||||
|
"deepmerge-ts": "8.0.2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
playwright.config.ts
Normal file
6
playwright.config.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from '@playwright/test'
|
||||||
|
export default defineConfig({
|
||||||
|
testDir:'./tests/e2e', workers:1, timeout:60000,
|
||||||
|
use:{baseURL:'http://127.0.0.1:3219',headless:true,trace:'retain-on-failure'},
|
||||||
|
webServer:{command:'node scripts/test-server.mjs',port:3219,reuseExistingServer:false,timeout:180000},
|
||||||
|
})
|
||||||
6
playwright.production.config.ts
Normal file
6
playwright.production.config.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from '@playwright/test'
|
||||||
|
import config from './playwright.config'
|
||||||
|
export default defineConfig({
|
||||||
|
...config,
|
||||||
|
webServer: { command:'node scripts/test-server.mjs --production',port:3219,reuseExistingServer:false,timeout:240000 },
|
||||||
|
})
|
||||||
41
scripts/database-backup.js
Normal file
41
scripts/database-backup.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const { PrismaClient } = require('@prisma/client')
|
||||||
|
const { loadEnv } = require('./lib/admin-account')
|
||||||
|
|
||||||
|
async function verify(file) {
|
||||||
|
const db = new PrismaClient({ datasources: { db: { url: 'file:' + file.split(path.sep).join('/') } } })
|
||||||
|
try {
|
||||||
|
const result = await db.$queryRawUnsafe('PRAGMA integrity_check')
|
||||||
|
if (result.length !== 1 || Object.values(result[0])[0] !== 'ok') throw new Error('SQLite integrity check failed')
|
||||||
|
const counts = {}
|
||||||
|
for (const model of ['user', 'privacyPolicy', 'policyDefaults', 'sitePages']) counts[model] = await db[model].count()
|
||||||
|
return counts
|
||||||
|
} finally { await db.$disconnect() }
|
||||||
|
}
|
||||||
|
async function main() {
|
||||||
|
loadEnv()
|
||||||
|
const [action, input, output] = process.argv.slice(2)
|
||||||
|
if (!input || !['backup', 'verify', 'restore'].includes(action)) throw new Error('Usage: node scripts/database-backup.js backup FILE | verify FILE | restore BACKUP NEW_FILE')
|
||||||
|
const file = path.resolve(input)
|
||||||
|
if (action === 'verify') {
|
||||||
|
if (!fs.existsSync(file)) throw new Error('Backup does not exist')
|
||||||
|
console.log(JSON.stringify(await verify(file)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const target = action === 'restore' ? (output ? path.resolve(output) : null) : file
|
||||||
|
if (!target) throw new Error('Restore requires a new destination file; existing files are never overwritten')
|
||||||
|
if (fs.existsSync(target)) throw new Error('Destination already exists; refusing to overwrite')
|
||||||
|
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 })
|
||||||
|
if (action === 'backup') {
|
||||||
|
const db = new PrismaClient()
|
||||||
|
try { await db.$executeRawUnsafe('VACUUM INTO ?', target) } finally { await db.$disconnect() }
|
||||||
|
} else {
|
||||||
|
if (!fs.existsSync(file)) throw new Error('Backup does not exist')
|
||||||
|
await verify(file)
|
||||||
|
fs.copyFileSync(file, target, fs.constants.COPYFILE_EXCL)
|
||||||
|
}
|
||||||
|
fs.chmodSync(target, 0o600)
|
||||||
|
console.log(JSON.stringify({ action, file: target, counts: await verify(target) }))
|
||||||
|
}
|
||||||
|
main().catch(error => { console.error(error.message); process.exitCode = 1 })
|
||||||
28
scripts/test-server.mjs
Normal file
28
scripts/test-server.mjs
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { mkdtempSync, cpSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { spawnSync, spawn } from 'node:child_process'
|
||||||
|
import { randomBytes } from 'node:crypto'
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
const production=process.argv.includes('--production')
|
||||||
|
const dir=mkdtempSync(join(tmpdir(),'syncova-test-'))
|
||||||
|
const database='file:'+join(dir,'test.db').replaceAll('\\','/')
|
||||||
|
const env={...process.env,DATABASE_URL:database,NEXTAUTH_SECRET:randomBytes(32).toString('hex'),AUTH_TRUST_HOST:'true',NEXTAUTH_URL:'http://127.0.0.1:3219',PORT:'3219',HOSTNAME:'127.0.0.1',TEST_DIST_DIR:production?'.next-e2e-production':'.next-e2e'}
|
||||||
|
const setup=spawnSync(process.execPath,['node_modules/prisma/build/index.js','db','push','--skip-generate'],{env,stdio:'inherit'})
|
||||||
|
if(setup.status!==0) process.exit(1)
|
||||||
|
const prisma=new PrismaClient({datasources:{db:{url:database}}})
|
||||||
|
await prisma.user.create({data:{email:'super@example.org',password:await bcrypt.hash('Test-password-only-2026',12),role:'SUPER_ADMIN'}})
|
||||||
|
await prisma.user.create({data:{email:'editor@example.org',password:await bcrypt.hash('Test-password-only-2026',12),role:'ADMIN'}})
|
||||||
|
await prisma.sitePages.create({data:{id:1,imprint:'## Kontakt\nTestverwaltung',privacy:'## Datenschutz\nTestinhalt',accessibility:'## Stand\nTestinhalt',easyLanguage:'## Willkommen\nTestinhalt',signLanguage:'## Video\nTestinhalt'}})
|
||||||
|
await prisma.privacyPolicy.create({data:{slug:'test-bauantrag',title:'Bauantrag',processingDescription:'Bauantrag bearbeiten',recipients:'Bauamt',responsible:'Testverwaltung',isPublic:true}})
|
||||||
|
await prisma.$disconnect()
|
||||||
|
if(production){
|
||||||
|
const build=spawnSync(process.execPath,['node_modules/next/dist/bin/next','build'],{env,stdio:'inherit'})
|
||||||
|
if(build.status!==0)process.exit(build.status??1)
|
||||||
|
cpSync(join(env.TEST_DIST_DIR,'static'),join(env.TEST_DIST_DIR,'standalone',env.TEST_DIST_DIR,'static'),{recursive:true})
|
||||||
|
}
|
||||||
|
const args=production?[join(env.TEST_DIST_DIR,'standalone','server.js')]:['node_modules/next/dist/bin/next','dev','--port','3219','--hostname','127.0.0.1']
|
||||||
|
const child=spawn(process.execPath,args,{env,stdio:'inherit'})
|
||||||
|
for(const signal of ['SIGINT','SIGTERM']) process.on(signal,()=>child.kill(signal))
|
||||||
|
child.on('exit',code=>process.exit(code??1))
|
||||||
158
tests/e2e/portal.spec.ts
Normal file
158
tests/e2e/portal.spec.ts
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import { test, expect, type Page } from '@playwright/test'
|
||||||
|
import AxeBuilder from '@axe-core/playwright'
|
||||||
|
|
||||||
|
async function login(page: Page, email='super@example.org') {
|
||||||
|
await page.goto('http://127.0.0.1:3219/auth/signin')
|
||||||
|
await page.getByLabel('E-Mail-Adresse',{exact:true}).fill(email)
|
||||||
|
await page.getByLabel('Passwort',{exact:true}).fill('Test-password-only-2026')
|
||||||
|
await page.getByRole('button',{name:'Anmelden',exact:true}).click()
|
||||||
|
await expect(page).toHaveURL(/\/admin$/)
|
||||||
|
}
|
||||||
|
test('public search announces zero results and reset restores focus',async({page})=>{
|
||||||
|
await page.goto('/')
|
||||||
|
const search=page.getByRole('searchbox')
|
||||||
|
await search.fill('zzzznonexistent')
|
||||||
|
await expect(page.getByRole('status')).toContainText('0 Erklärungen')
|
||||||
|
await page.getByRole('button',{name:'Suche zurücksetzen'}).first().click()
|
||||||
|
await expect(search).toBeFocused()
|
||||||
|
await expect(page.getByRole('link',{name:/Bauantrag/})).toBeVisible()
|
||||||
|
for(const width of [320,1280]){
|
||||||
|
await page.setViewportSize({width,height:800})
|
||||||
|
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true)
|
||||||
|
const results=await new AxeBuilder({page}).withTags(['wcag2a','wcag2aa','wcag21aa']).analyze()
|
||||||
|
expect(results.violations).toEqual([])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
test('login errors are announced and password button changes its name',async({page})=>{
|
||||||
|
await page.goto('/auth/signin')
|
||||||
|
await page.getByRole('button',{name:'Passwort anzeigen'}).click()
|
||||||
|
await expect(page.getByRole('button',{name:'Passwort verbergen'})).toBeVisible()
|
||||||
|
await page.getByLabel('E-Mail-Adresse',{exact:true}).fill('missing@example.org')
|
||||||
|
await page.getByLabel('Passwort',{exact:true}).fill('wrong-password')
|
||||||
|
await page.getByRole('button',{name:'Anmelden',exact:true}).click()
|
||||||
|
await expect(page.getByRole('alert').filter({hasText:'Ungültige Anmeldedaten'})).toBeVisible()
|
||||||
|
})
|
||||||
|
test('dialogs close without blocking interaction; required pages cannot be cleared',async({page})=>{
|
||||||
|
await login(page)
|
||||||
|
for(const name of ['Rechtliche Seiten','Standardtexte pflegen','Benutzer verwalten']){
|
||||||
|
await page.getByRole('button',{name:'Benutzermenü'}).click()
|
||||||
|
await page.getByRole('menuitem',{name}).click()
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible()
|
||||||
|
await page.keyboard.press('Escape')
|
||||||
|
await expect(page.getByRole('dialog')).toHaveCount(0)
|
||||||
|
await expect(page.getByRole('button',{name:'Benutzermenü'})).toBeFocused()
|
||||||
|
}
|
||||||
|
const previous=await (await page.request.get('/api/site-pages')).json()
|
||||||
|
expect((await page.request.put('/api/site-pages',{data:{...previous,accessibility:''}})).status()).toBe(400)
|
||||||
|
expect((await page.request.get('/api/site-pages')).ok()).toBe(true)
|
||||||
|
const users=await (await page.request.get('/api/users')).json()
|
||||||
|
const self=users.find((user: {id:number;email:string})=>user.email==='super@example.org')
|
||||||
|
expect((await page.request.put('/api/users/'+self.id,{data:{isActive:false}})).status()).toBe(400)
|
||||||
|
})
|
||||||
|
test('editor cannot promote itself and an existing session is revoked after suspension',async({browser})=>{
|
||||||
|
const adminContext=await browser.newContext()
|
||||||
|
const editorContext=await browser.newContext()
|
||||||
|
const admin=await adminContext.newPage(), editor=await editorContext.newPage()
|
||||||
|
await login(admin)
|
||||||
|
await login(editor,'editor@example.org')
|
||||||
|
expect((await editor.request.post('/api/users',{data:{email:'intruder@example.org',password:'Test-password-only-2026',role:'SUPER_ADMIN'}})).ok()).toBe(false)
|
||||||
|
const users=await (await admin.request.get('/api/users')).json()
|
||||||
|
const user=users.find((user: {id:number;email:string})=>user.email==='editor@example.org')
|
||||||
|
expect((await admin.request.put('/api/users/'+user.id,{data:{isActive:false}})).ok()).toBe(true)
|
||||||
|
expect((await editor.request.put('/api/site-pages',{data:{}})).status()).toBe(401)
|
||||||
|
await editor.goto('/admin')
|
||||||
|
await expect(editor).toHaveURL(/\/auth\/signin/)
|
||||||
|
await adminContext.close(); await editorContext.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('public information and admin forms remain accessible at narrow widths',async({page})=>{
|
||||||
|
await page.setViewportSize({width:320,height:640})
|
||||||
|
for(const route of ['/auth/signin','/erklaerung/test-bauantrag','/barrierefreiheit','/leichte-sprache','/gebaerdensprache']){
|
||||||
|
await page.goto(route)
|
||||||
|
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true)
|
||||||
|
await page.waitForFunction(() => document.getAnimations().every(a => a.playState !== 'running' || a.effect?.getTiming().iterations === Infinity))
|
||||||
|
expect((await new AxeBuilder({page}).withTags(['wcag2a','wcag2aa','wcag21aa']).analyze()).violations).toEqual([])
|
||||||
|
}
|
||||||
|
await login(page)
|
||||||
|
for(const name of ['Rechtliche Seiten','Standardtexte pflegen','Benutzer verwalten']){
|
||||||
|
await page.getByRole('button',{name:'Benutzermenü'}).click()
|
||||||
|
await page.getByRole('menuitem',{name}).click()
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible()
|
||||||
|
await page.waitForFunction(() => document.getAnimations().every(a => a.playState !== 'running' || a.effect?.getTiming().iterations === Infinity))
|
||||||
|
expect((await new AxeBuilder({page}).withTags(['wcag2a','wcag2aa','wcag21aa']).analyze()).violations).toEqual([])
|
||||||
|
await page.keyboard.press('Escape')
|
||||||
|
}
|
||||||
|
await page.getByRole('button',{name:'Neu',exact:true}).click()
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible()
|
||||||
|
await page.getByRole('button',{name:/Erklärung erstellen|Speichern|Erstellen/}).last().click()
|
||||||
|
await expect(page.getByRole('region',{name:'Fehler beim Speichern'})).toBeFocused()
|
||||||
|
await page.waitForFunction(() => document.getAnimations().every(a => a.playState !== 'running' || a.effect?.getTiming().iterations === Infinity))
|
||||||
|
expect((await new AxeBuilder({page}).withTags(['wcag2a','wcag2aa','wcag21aa']).analyze()).violations).toEqual([])
|
||||||
|
await page.getByRole('button',{name:'Zum ersten fehlerhaften Feld'}).click()
|
||||||
|
const title=page.getByRole('textbox',{name:/Titel der Datenschutzerklärung/})
|
||||||
|
await expect(title).toBeFocused()
|
||||||
|
await title.fill('Test der erreichbaren Eingabe')
|
||||||
|
expect((await title.boundingBox())!.height).toBeGreaterThan(20)
|
||||||
|
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true)
|
||||||
|
await page.screenshot({path:'test-results/admin-mobile.png',fullPage:true})
|
||||||
|
await page.keyboard.press('Escape')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('dark mode and nested user dialogs remain accessible',async({page})=>{
|
||||||
|
await page.addInitScript(()=>localStorage.setItem('theme','dark'))
|
||||||
|
await page.setViewportSize({width:320,height:640})
|
||||||
|
for(const route of ['/','/barrierefreiheit','/auth/signin']){
|
||||||
|
await page.goto(route)
|
||||||
|
await expect(page.locator('html')).toHaveClass(/dark/)
|
||||||
|
await page.waitForFunction(()=>document.getAnimations().every(a=>a.playState!=='running'||a.effect?.getTiming().iterations===Infinity))
|
||||||
|
expect((await new AxeBuilder({page}).withTags(['wcag2a','wcag2aa','wcag21aa']).analyze()).violations).toEqual([])
|
||||||
|
}
|
||||||
|
await login(page)
|
||||||
|
await page.getByRole('button',{name:'Benutzermenü'}).click()
|
||||||
|
await page.getByRole('menuitem',{name:'Benutzer verwalten'}).click()
|
||||||
|
await page.getByRole('button',{name:'Neuer Benutzer',exact:true}).click()
|
||||||
|
const dialog=page.getByRole('dialog',{name:'Neuer Benutzer',exact:true})
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
await page.waitForFunction(()=>document.getAnimations().every(a=>a.playState!=='running'||a.effect?.getTiming().iterations===Infinity))
|
||||||
|
expect(await dialog.evaluate(el=>el.scrollWidth<=el.clientWidth)).toBe(true)
|
||||||
|
expect((await new AxeBuilder({page}).withTags(['wcag2a','wcag2aa','wcag21aa']).analyze()).violations).toEqual([])
|
||||||
|
await page.keyboard.press('Escape')
|
||||||
|
await expect(page.getByRole('button',{name:'Neuer Benutzer',exact:true})).toBeFocused()
|
||||||
|
await page.keyboard.press('Escape')
|
||||||
|
await expect(page.getByRole('button',{name:'Benutzermenü'})).toBeFocused()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keyboard navigation, text resizing and spacing preserve access', async ({page}) => {
|
||||||
|
await page.goto('/')
|
||||||
|
await page.keyboard.press('Tab')
|
||||||
|
await expect(page.getByRole('link',{name:'Zum Inhalt springen'})).toBeFocused()
|
||||||
|
await page.keyboard.press('Enter')
|
||||||
|
await expect(page.locator('main')).toBeFocused()
|
||||||
|
await page.setViewportSize({width:1280,height:800})
|
||||||
|
await page.addStyleTag({content:'html { font-size:200% !important; } * { line-height:1.5 !important; letter-spacing:.12em !important; word-spacing:.16em !important; } p { margin-bottom:2em !important; }'})
|
||||||
|
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true)
|
||||||
|
const search=page.getByRole('searchbox')
|
||||||
|
await search.fill('Bauantrag')
|
||||||
|
await expect(page.getByRole('link',{name:/Bauantrag/})).toBeVisible()
|
||||||
|
await page.getByRole('link',{name:/Bauantrag/}).click()
|
||||||
|
await expect(page.getByRole('heading',{name:'Bauantrag',exact:true})).toBeVisible()
|
||||||
|
await login(page)
|
||||||
|
await page.getByRole('button',{name:'Neu',exact:true}).focus()
|
||||||
|
await page.keyboard.press('Enter')
|
||||||
|
const dialog=page.getByRole('dialog',{name:'Neue Datenschutzerklärung',exact:true})
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
for(let step=0;step<16;step++) {
|
||||||
|
await page.keyboard.press(step%3===0?'Shift+Tab':'Tab')
|
||||||
|
expect(await dialog.evaluate(el=>el.contains(document.activeElement))).toBe(true)
|
||||||
|
}
|
||||||
|
await page.keyboard.press('Escape')
|
||||||
|
await expect(page.getByRole('button',{name:'Neu',exact:true})).toBeFocused()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('policy APIs reject malformed data without server errors',async({page})=>{
|
||||||
|
await login(page)
|
||||||
|
for(const data of [null,[],{title:123},{recipients:{}},{isPublic:'false'}]){
|
||||||
|
expect((await page.request.post('/api/privacy-policies',{data})).status()).toBe(400)
|
||||||
|
}
|
||||||
|
expect((await page.request.post('/api/privacy-policies',{data:'{invalid',headers:{'Content-Type':'application/json'}})).status()).toBe(400)
|
||||||
|
})
|
||||||
36
tests/unit/prisma-compat.test.ts
Normal file
36
tests/unit/prisma-compat.test.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { mkdtempSync, writeFileSync, readFileSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join, resolve } from 'node:path'
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import { loadConfigFromFile } from '@prisma/config'
|
||||||
|
|
||||||
|
test('patched merger loads Prisma configuration and applies migrations idempotently', async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'syncova-prisma-compat-'))
|
||||||
|
const schema = resolve('prisma/schema.prisma')
|
||||||
|
const configFile = join(dir, 'prisma.config.cjs')
|
||||||
|
writeFileSync(configFile, 'module.exports = ' + JSON.stringify({
|
||||||
|
schema, migrations: { path: resolve('prisma/migrations') },
|
||||||
|
}))
|
||||||
|
const loaded = await loadConfigFromFile({ configRoot: dir, configFile })
|
||||||
|
assert.equal(loaded.error, undefined)
|
||||||
|
assert.equal(loaded.config?.schema, schema)
|
||||||
|
const env = { ...process.env, DATABASE_URL: 'file:' + join(dir, 'migration.db').split('\\').join('/') }
|
||||||
|
for (let run = 0; run < 2; run++) {
|
||||||
|
const result = spawnSync(process.execPath, [
|
||||||
|
'node_modules/prisma/build/index.js', 'migrate', 'deploy', '--schema', schema,
|
||||||
|
], { env, encoding: 'utf8', timeout: 60000 })
|
||||||
|
assert.equal(result.status, 0, result.stderr + result.stdout)
|
||||||
|
}
|
||||||
|
const backup = join(dir, 'backup.db')
|
||||||
|
const restored = join(dir, 'restored.db')
|
||||||
|
for (const args of [['backup', backup], ['restore', backup, restored], ['verify', restored]]) {
|
||||||
|
const result = spawnSync(process.execPath, ['scripts/database-backup.js', ...args], { env, encoding: 'utf8', timeout: 60000 })
|
||||||
|
assert.equal(result.status, 0, result.stderr + result.stdout)
|
||||||
|
}
|
||||||
|
assert.deepEqual(readFileSync(backup), readFileSync(restored))
|
||||||
|
const overwrite = spawnSync(process.execPath, ['scripts/database-backup.js', 'restore', backup, restored], { env, encoding: 'utf8' })
|
||||||
|
assert.equal(overwrite.status, 1)
|
||||||
|
assert.match(overwrite.stderr, /refusing to overwrite/)
|
||||||
|
})
|
||||||
42
tests/unit/security.test.ts
Normal file
42
tests/unit/security.test.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { canUseSession } from '../../lib/session-access'
|
||||||
|
import { EMPTY_SITE_PAGES, isSafeMediaUrl, isAllowedEmbedUrl, validateSitePages } from '../../lib/site-pages'
|
||||||
|
import { validateUserInput } from '../../lib/user-validation'
|
||||||
|
|
||||||
|
test('sessions fail closed after account changes, deletion or suspension', () => {
|
||||||
|
const user = { role: 'ADMIN', isActive: true, updatedAt: new Date(1000) }
|
||||||
|
assert.equal(canUseSession(user, 1001), true)
|
||||||
|
for (const candidate of [null, {...user,isActive:false}, {...user,role:'USER'}, {...user,updatedAt:new Date(2000)}])
|
||||||
|
assert.equal(canUseSession(candidate, 1001), false)
|
||||||
|
assert.equal(canUseSession(user, undefined), false)
|
||||||
|
})
|
||||||
|
test('media allowlist rejects normalized foreign origins and credentials', () => {
|
||||||
|
for (const url of ['//evil.example/a', '/'+String.fromCharCode(92)+'evil.example/a','javascript:alert(1)','https://evil.example/a','https://signlanguage.alangu.de.evil.example/a','https://user:pass@signlanguage.alangu.de/player/'])
|
||||||
|
assert.equal(isSafeMediaUrl(url), false, url)
|
||||||
|
assert.equal(isSafeMediaUrl('/videos/dgs.mp4'), true)
|
||||||
|
assert.equal(isAllowedEmbedUrl('https://signlanguage.alangu.de/player/?subtitle=true'), true)
|
||||||
|
})
|
||||||
|
test('partial updates, placeholder publication and unpublishing are rejected', () => {
|
||||||
|
const previous = {...EMPTY_SITE_PAGES, accessibility:'Veröffentlichte Erklärung'}
|
||||||
|
assert.ok(validateSitePages({}, previous).length)
|
||||||
|
assert.ok(validateSitePages({...EMPTY_SITE_PAGES}, previous).length)
|
||||||
|
assert.ok(validateSitePages({...previous,accessibility:'{{STATUS}}'},previous).length)
|
||||||
|
assert.deepEqual(validateSitePages({...previous,accessibility:'Neue Erklärung'},previous),[])
|
||||||
|
})
|
||||||
|
test('user input rejects unexpected roles, invalid types and bcrypt truncation', () => {
|
||||||
|
const valid={email:'admin@example.org',role:'ADMIN',password:'A-safe-test-password'}
|
||||||
|
assert.equal(validateUserInput(valid,true),null)
|
||||||
|
for (const data of [{...valid,role:'ROOT'},{...valid,password:123},{...valid,password:'ü'.repeat(40)},{...valid,isActive:'false'}])
|
||||||
|
assert.ok(validateUserInput(data,true))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('policy inputs reject objects and booleans in text fields without throwing', async () => {
|
||||||
|
const {parsePolicyInput}=await import('../../lib/policy-input')
|
||||||
|
for(const value of [null,[],{title:123},{recipients:{}},{isPublic:'false'},{legalBasis:['text']}])
|
||||||
|
assert.equal(parsePolicyInput(value),null)
|
||||||
|
const data=parsePolicyInput({title:' Beispiel ',processingDescription:'Verarbeitung',recipients:'Amt',isPublic:false})
|
||||||
|
assert.equal(data?.title,'Beispiel')
|
||||||
|
assert.equal(data?.isPublic,false)
|
||||||
|
assert.equal(data?.responsible,null)
|
||||||
|
})
|
||||||
@ -30,10 +30,13 @@
|
|||||||
"target": "ES2017"
|
"target": "ES2017"
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"next-env.d.ts",
|
|
||||||
"**/*.ts",
|
"**/*.ts",
|
||||||
"**/*.tsx",
|
"**/*.tsx",
|
||||||
".next/types/**/*.ts"
|
".next-check/types/**/*.ts",
|
||||||
|
".next-e2e/types/**/*.ts",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
"next-env.d.ts",
|
||||||
|
".next-e2e-production/types/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"node_modules"
|
"node_modules"
|
||||||
|
|||||||
2
types/next-auth.d.ts
vendored
2
types/next-auth.d.ts
vendored
@ -2,6 +2,7 @@ import { DefaultSession } from "next-auth"
|
|||||||
|
|
||||||
declare module "next-auth" {
|
declare module "next-auth" {
|
||||||
interface Session {
|
interface Session {
|
||||||
|
issuedAt?: number
|
||||||
user: {
|
user: {
|
||||||
id: string
|
id: string
|
||||||
role: string
|
role: string
|
||||||
@ -15,6 +16,7 @@ declare module "next-auth" {
|
|||||||
|
|
||||||
declare module "next-auth/jwt" {
|
declare module "next-auth/jwt" {
|
||||||
interface JWT {
|
interface JWT {
|
||||||
|
issuedAt?: number
|
||||||
role: string
|
role: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user