Syncova Policies: Portal für DSGVO-Datenschutzerklärungen

Next.js 15 App Router mit öffentlicher Übersicht und geschütztem
Admin-Bereich zur Pflege von Datenschutzerklärungen nach Art. 12ff. DSGVO.

- Oberfläche auf Basis von shadcn/ui, Inter lokal eingebunden
- Prisma/SQLite, Auth.js mit Credentials-Provider und Rollen
- Massenimport der Merkblätter des Amtes Leezen aus PDF
- Docker-Image (standalone) und Gitea-Workflow zur Veröffentlichung

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Weapie 2026-09-07 16:23:51 +02:00
commit 328f042307
367 changed files with 16516 additions and 0 deletions

25
.dockerignore Normal file
View File

@ -0,0 +1,25 @@
node_modules
.next
.git
.gitignore
.github
.gitea
.claude
# Geheimnisse und lokale Daten gehören nicht ins Image
.env
.env.local
*.db
*.db-journal
*.sqlite
# Quelldokumente und Importartefakte werden zur Laufzeit nicht gebraucht
data/import
data/analyse.json
Dockerfile
.dockerignore
docker-compose.yml
README.md
*.md
npm-debug.log*

13
.env.example Normal file
View File

@ -0,0 +1,13 @@
# Datenbank
DATABASE_URL="file:./dev.db"
# NextAuth.js – Secret erzeugen mit:
# node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
NEXTAUTH_SECRET=""
NEXTAUTH_URL="http://localhost:3000"
# Initialer Admin-Zugang für `npm run seed-admin` / `npm run reset-admin`
ADMIN_EMAIL="admin@syncova.de"
ADMIN_NAME="Administrator"
# Leer lassen für ein automatisch generiertes Zufallspasswort
ADMIN_PASSWORD=""

15
.gitattributes vendored Normal file
View File

@ -0,0 +1,15 @@
# Einheitliche Zeilenenden im Repository, unabhängig vom Betriebssystem
* text=auto eol=lf
# Skripte müssen im Container mit LF laufen
*.sh text eol=lf
# Binärdateien nicht anfassen
*.pdf binary
*.ttf binary
*.woff binary
*.woff2 binary
*.png binary
*.jpg binary
*.ico binary
*.db binary

View File

@ -0,0 +1,61 @@
name: Container-Image bauen und veröffentlichen
on:
push:
branches: [main]
tags: ["v*"]
workflow_dispatch:
env:
REGISTRY: git.jfritzsche.de
IMAGE_NAME: jf/syncova-policies
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Quellcode auschecken
uses: actions/checkout@v4
- name: Image-Tags bestimmen
id: meta
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
SHA_TAG="${IMAGE}:$(echo "${GITHUB_SHA}" | cut -c1-7)"
TAGS="-t ${SHA_TAG}"
case "${GITHUB_REF}" in
refs/tags/v*)
VERSION="${GITHUB_REF#refs/tags/}"
TAGS="${TAGS} -t ${IMAGE}:${VERSION} -t ${IMAGE}:latest"
;;
refs/heads/main)
TAGS="${TAGS} -t ${IMAGE}:latest"
;;
esac
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
echo "primary=${SHA_TAG}" >> "$GITHUB_OUTPUT"
echo "Tags: ${TAGS}"
# Der Token liegt als Actions-Secret REGISTRY_TOKEN im Repository,
# nicht im Klartext in dieser Datei.
- name: An der Registry anmelden
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" \
| docker login "${REGISTRY}" -u "${{ gitea.actor }}" --password-stdin
- name: Image bauen
run: docker build ${{ steps.meta.outputs.tags }} .
- name: Image veröffentlichen
run: |
for tag in $(echo "${{ steps.meta.outputs.tags }}" | tr ' ' '\n' | grep -v '^-t$'); do
echo "Push ${tag}"
docker push "${tag}"
done
- name: Abmelden
if: always()
run: docker logout "${REGISTRY}" || true

49
.gitignore vendored Normal file
View File

@ -0,0 +1,49 @@
# Abhängigkeiten
/node_modules
/.pnp
.pnp.*
# Next.js
/.next/
/out/
next-env.d.ts
# Produktion
/build
/dist
# Umgebung – enthält NEXTAUTH_SECRET und Zugangsdaten
.env
.env.local
.env.*.local
!.env.example
# Datenbank – enthält Benutzerkonten und Passwort-Hashes
*.db
*.db-journal
*.sqlite
*.sqlite3
# Import-Zwischenergebnisse
/data/analyse.json
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Betriebssystem / Editor
.DS_Store
Thumbs.db
*.swp
.idea/
.vscode/
# Claude Code
.claude/settings.local.json
# Sonstiges
/coverage
.vercel
*.tsbuildinfo

80
Dockerfile Normal file
View File

@ -0,0 +1,80 @@
# syntax=docker/dockerfile:1
# ---------------------------------------------------------------------------
# Basis – Debian slim statt Alpine, weil Prisma dort ohne musl-Sonderengine
# auskommt. openssl wird von der Prisma Query Engine benötigt.
# ---------------------------------------------------------------------------
FROM node:22-bookworm-slim AS base
ENV NEXT_TELEMETRY_DISABLED=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# ---------------------------------------------------------------------------
# Abhängigkeiten
# ---------------------------------------------------------------------------
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# ---------------------------------------------------------------------------
# Build
# ---------------------------------------------------------------------------
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Der Build liest NEXTAUTH_SECRET nicht aus, braucht die Variable aber, damit
# die Auth-Konfiguration beim Prerendern nicht wirft. Der echte Wert kommt zur
# Laufzeit aus der Umgebung.
ENV NEXTAUTH_SECRET=build-time-placeholder
ENV DATABASE_URL=file:/tmp/build.db
RUN npx prisma generate
RUN npm run build
# ---------------------------------------------------------------------------
# Laufzeit
# ---------------------------------------------------------------------------
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production \
PORT=3000 \
HOSTNAME=0.0.0.0 \
DATABASE_URL=file:/app/data/syncova.db
RUN groupadd --system --gid 1001 nodejs \
&& useradd --system --uid 1001 --gid nodejs nextjs
# Standalone-Server samt der von Next.js ermittelten Abhängigkeiten
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Prisma: Schema, Migrationen, generierter Client und CLI für `migrate deploy`
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/prisma ./node_modules/prisma
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@prisma ./node_modules/@prisma
# Skripte für die Erstanlage des Admin-Zugangs (docker exec)
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/bcryptjs ./node_modules/bcryptjs
COPY --chown=nextjs:nodejs docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Ablage der SQLite-Datenbank – als Volume einhängen, sonst ist sie flüchtig
RUN mkdir -p /app/data && chown -R nextjs:nodejs /app/data
VOLUME ["/app/data"]
USER nextjs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/auth/signin').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "server.js"]

116
README.md Normal file
View File

@ -0,0 +1,116 @@
# Syncova Policies
Verwaltung und Veröffentlichung von Datenschutzerklärungen nach DSGVO (Art. 12ff.).
Öffentliche Übersicht für Betroffene, geschützter Admin-Bereich zur Pflege.
Next.js 15 (App Router) · React 19 · Tailwind CSS 4 · shadcn/ui · Prisma + SQLite · Auth.js
---
## Schnellstart (lokal)
```bash
npm install
cp .env.example .env # NEXTAUTH_SECRET eintragen (siehe unten)
npx prisma migrate deploy
npm run seed-admin # legt den ersten Admin an und zeigt das Passwort einmalig
npm run dev
```
Die Anwendung läuft anschließend auf http://localhost:3000, der Admin-Bereich
unter `/admin`.
### Umgebungsvariablen
| Variable | Pflicht | Bedeutung |
|---|---|---|
| `NEXTAUTH_SECRET` | ja | Signiert die Session-Token. Erzeugen mit `node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"` |
| `NEXTAUTH_URL` | ja | Öffentliche URL der Anwendung, z. B. `https://datenschutz.example.de` |
| `DATABASE_URL` | ja | SQLite-Pfad. Lokal `file:./dev.db`, im Container `file:/app/data/syncova.db` |
| `ADMIN_EMAIL` | nein | Vorgabe für `seed-admin` / `reset-admin` |
| `ADMIN_NAME` | nein | Anzeigename des Admin-Kontos |
| `ADMIN_PASSWORD` | nein | Leer lassen für ein generiertes Zufallspasswort |
> `.env` ist absichtlich nicht versioniert – sie enthält das Session-Secret.
> Ebenso wenig die SQLite-Datei, die Benutzerkonten und Passwort-Hashes enthält.
---
## Betrieb per Docker
```bash
docker run -d --name syncova-policies \
-p 3000:3000 \
-e NEXTAUTH_SECRET="$(node -e "console.log(require('crypto').randomBytes(32).toString('base64'))")" \
-e NEXTAUTH_URL="https://datenschutz.example.de" \
-v syncova-data:/app/data \
git.jfritzsche.de/jf/syncova-policies:latest
```
Oder mit Compose:
```bash
export NEXTAUTH_SECRET="…"
export NEXTAUTH_URL="https://datenschutz.example.de"
docker compose up -d
```
Beim Start wendet der Container ausstehende Migrationen selbst an
(`prisma migrate deploy`). Die Datenbank liegt im Volume unter `/app/data` –
**ohne dieses Volume gehen alle Daten beim Neustart verloren.**
Ersten Admin-Zugang anlegen:
```bash
docker exec -it syncova-policies node scripts/seed-admin.js
```
Passwort zurücksetzen:
```bash
docker exec -it syncova-policies node scripts/reset-admin.js --email admin@example.de
```
---
## Datenpflege
Datenschutzerklärungen werden normalerweise im Admin-Bereich gepflegt. Für
Massenimporte gibt es zwei Wege:
| Befehl | Zweck |
|---|---|
| `npm run import-policy -- data/policies/<datei>.json` | Einzelne Erklärung aus JSON importieren (`--update` überschreibt) |
| `npm run analyze-pdfs` | PDFs in `data/import/` analysieren, ohne zu schreiben |
| `npm run import-pdfs` | Alle PDFs aus `data/import/` importieren (`--dry-run`, `--public`) |
Der PDF-Import ist auf die Merkblatt-Vorlage des Amtes Leezen zugeschnitten
(`scripts/lib/pdf-policy.js`). Er vergleicht den Textbaustein-Teil jedes
Dokuments gegen ein Referenzdokument und meldet jede Abweichung, statt sie
stillschweigend zu übernehmen. Importierte Datensätze werden am Titel erkannt
und aktualisiert, nicht dupliziert.
---
## Veröffentlichung des Images
`.gitea/workflows/publish-image.yml` baut bei jedem Push auf `main` sowie bei
`v*`-Tags ein Image und lädt es in die Gitea-Registry.
Voraussetzung: im Repository unter **Settings → Actions → Secrets** ein Secret
`REGISTRY_TOKEN` mit einem Token hinterlegen, das `write:package` darf.
---
## Projektstruktur
```
app/ Routen (öffentlich, /admin, /auth, /api)
components/ UI-Komponenten; components/ui = shadcn/ui
hooks/ SWR-Datenzugriff, Theme
lib/ Prisma/Auth-Helfer, Markdown, Formatierung
prisma/ Schema und Migrationen
scripts/ Admin- und Importskripte
data/import/ Quell-PDFs für den Massenimport
data/policies/ Aufbereitete Datensätze als JSON
```

14
app/admin/layout.tsx Normal file
View File

@ -0,0 +1,14 @@
import type { Metadata } from "next"
export const metadata: Metadata = {
title: "Admin",
description: "Datenschutzerklärungen verwalten",
}
export default function AdminLayout({
children,
}: {
children: React.ReactNode
}) {
return <>{children}</>
}

471
app/admin/page.tsx Normal file
View File

@ -0,0 +1,471 @@
"use client"
import { useMemo, useState } from "react"
import {
ArrowDown,
ArrowUp,
Eye,
EyeOff,
FileText,
LogOut,
MoreHorizontal,
Pencil,
Plus,
Search,
Trash2,
Users,
X,
} from "lucide-react"
import { signOut, useSession } from "next-auth/react"
import { toast } from "sonner"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { Brand } from "@/components/brand"
import { PolicyDetail } from "@/components/policy-detail"
import { PolicyForm } from "@/components/policy-form"
import { ThemeToggle } from "@/components/theme-toggle"
import { UserManagement } from "@/components/user-management"
import { deletePolicy, usePrivacyPolicies } from "@/hooks/use-privacy-policies"
import { formatDateTime, initials } from "@/lib/format"
import { PrivacyPolicy } from "@/types/privacy-policy"
type SortKey = "title" | "createdAt" | "updatedAt"
export default function AdminDashboard() {
const { data: session, status } = useSession()
const { policies, isLoading, error } = usePrivacyPolicies()
const [searchTerm, setSearchTerm] = useState("")
const [sortBy, setSortBy] = useState<SortKey>("createdAt")
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc")
const [isFormOpen, setIsFormOpen] = useState(false)
const [editingPolicy, setEditingPolicy] = useState<PrivacyPolicy | null>(null)
const [previewPolicy, setPreviewPolicy] = useState<PrivacyPolicy | null>(null)
const [isPreviewOpen, setIsPreviewOpen] = useState(false)
const [policyToDelete, setPolicyToDelete] = useState<PrivacyPolicy | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const [isUserManagementOpen, setIsUserManagementOpen] = useState(false)
const visiblePolicies = useMemo(() => {
const term = searchTerm.trim().toLowerCase()
return policies
.filter(
(policy) =>
!term ||
policy.title.toLowerCase().includes(term) ||
policy.responsible.toLowerCase().includes(term)
)
.sort((a, b) => {
if (sortBy === "title") {
return sortOrder === "asc"
? a.title.localeCompare(b.title, "de")
: b.title.localeCompare(a.title, "de")
}
const aDate = new Date(a[sortBy]).getTime()
const bDate = new Date(b[sortBy]).getTime()
return sortOrder === "asc" ? aDate - bDate : bDate - aDate
})
}, [policies, searchTerm, sortBy, sortOrder])
const toggleSort = (key: SortKey) => {
if (sortBy === key) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc")
} else {
setSortBy(key)
setSortOrder(key === "title" ? "asc" : "desc")
}
}
const handleAddNew = () => {
setEditingPolicy(null)
setIsFormOpen(true)
}
const handlePreview = (policy: PrivacyPolicy) => {
setPreviewPolicy(policy)
setIsPreviewOpen(true)
}
const handleEdit = (policy: PrivacyPolicy) => {
setEditingPolicy(policy)
setIsFormOpen(true)
}
const handleDelete = async () => {
if (!policyToDelete) return
setIsDeleting(true)
try {
await deletePolicy(policyToDelete.id)
toast.success("Datenschutzerklärung gelöscht")
setPolicyToDelete(null)
} catch (err) {
toast.error(err instanceof Error ? err.message : "Fehler beim Löschen")
} finally {
setIsDeleting(false)
}
}
const publicCount = policies.filter((policy) => policy.isPublic).length
const SortableHead = ({
column,
children,
className,
}: {
column: SortKey
children: React.ReactNode
className?: string
}) => (
<TableHead className={className}>
<button
type="button"
onClick={() => toggleSort(column)}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-sm transition-colors hover:text-foreground"
>
{children}
{sortBy === column &&
(sortOrder === "asc" ? (
<ArrowUp className="size-3.5" />
) : (
<ArrowDown className="size-3.5" />
))}
</button>
</TableHead>
)
if (status === "loading") {
return (
<div className="flex min-h-screen items-center justify-center">
<Skeleton className="h-5 w-40" />
</div>
)
}
if (status === "unauthenticated") {
return (
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
Nicht angemeldet – Weiterleitung…
</div>
)
}
return (
<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">
<div className="mx-auto flex h-14 max-w-7xl items-center justify-between gap-4 px-6">
<div className="flex items-center gap-3">
<Brand />
<Badge variant="secondary">Admin</Badge>
</div>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => window.open("/", "_blank")}
>
<Eye />
<span className="sr-only">Öffentliche Ansicht</span>
</Button>
</TooltipTrigger>
<TooltipContent>Öffentliche Ansicht</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setIsUserManagementOpen(true)}
>
<Users />
<span className="sr-only">Benutzerverwaltung</span>
</Button>
</TooltipTrigger>
<TooltipContent>Benutzer</TooltipContent>
</Tooltip>
<ThemeToggle />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="ml-1">
<Avatar>
<AvatarFallback>
{initials(session?.user?.name || session?.user?.email || "?")}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-56">
<DropdownMenuLabel className="font-normal">
<span className="block truncate font-medium">
{session?.user?.name || "Angemeldet"}
</span>
<span className="block truncate text-xs text-muted-foreground">
{session?.user?.email}
</span>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => setIsUserManagementOpen(true)}>
<Users />
Benutzer verwalten
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={() => signOut({ callbackUrl: "/auth/signin" })}
>
<LogOut />
Abmelden
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</header>
<main className="mx-auto max-w-7xl px-6 py-8">
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1.5">
<h1 className="text-2xl font-semibold tracking-tight">
Datenschutzerklärungen
</h1>
<p className="text-sm text-muted-foreground">
{policies.length} gesamt · {publicCount} öffentlich ·{" "}
{policies.length - publicCount} privat
</p>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1 sm:w-64 sm:flex-none">
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Suchen…"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="px-9"
/>
{searchTerm && (
<Button
variant="ghost"
size="icon-sm"
onClick={() => setSearchTerm("")}
className="absolute top-1/2 right-0.5 -translate-y-1/2 text-muted-foreground"
>
<X />
<span className="sr-only">Suche zurücksetzen</span>
</Button>
)}
</div>
<Button onClick={handleAddNew}>
<Plus />
Neu
</Button>
</div>
</div>
{error ? (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
Fehler beim Laden der Daten: {error.message}
</div>
) : isLoading ? (
<div className="space-y-3 rounded-lg border p-4">
{Array.from({ length: 5 }).map((_, index) => (
<Skeleton key={index} className="h-10 w-full" />
))}
</div>
) : visiblePolicies.length === 0 ? (
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed px-6 py-16 text-center">
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
<FileText className="size-6 text-muted-foreground" />
</div>
<p className="font-medium">
{searchTerm ? "Keine Treffer" : "Noch keine Datenschutzerklärungen"}
</p>
<p className="max-w-sm text-sm text-muted-foreground">
{searchTerm
? "Passen Sie den Suchbegriff an."
: "Legen Sie die erste DSGVO-Erklärung an."}
</p>
{!searchTerm && (
<Button onClick={handleAddNew}>
<Plus />
Erste Erklärung anlegen
</Button>
)}
</div>
) : (
<div className="overflow-hidden rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<SortableHead column="title">Titel</SortableHead>
<TableHead className="hidden md:table-cell">
Verantwortlicher
</TableHead>
<TableHead className="w-32">Status</TableHead>
<SortableHead column="createdAt" className="hidden w-40 lg:table-cell">
Erstellt
</SortableHead>
<SortableHead column="updatedAt" className="w-40">
Geändert
</SortableHead>
<TableHead className="w-12" />
</TableRow>
</TableHeader>
<TableBody>
{visiblePolicies.map((policy) => (
<TableRow key={policy.id}>
<TableCell className="max-w-0">
<button
type="button"
onClick={() => handlePreview(policy)}
className="block w-full cursor-pointer truncate text-left font-medium hover:text-brand"
>
{policy.title}
</button>
</TableCell>
<TableCell className="hidden max-w-0 md:table-cell">
<span className="block truncate text-muted-foreground">
{policy.responsible.replace(/[*_#`]/g, "").split("\n")[0]}
</span>
</TableCell>
<TableCell>
{policy.isPublic ? (
<Badge variant="success">
<Eye />
Öffentlich
</Badge>
) : (
<Badge variant="outline">
<EyeOff />
Privat
</Badge>
)}
</TableCell>
<TableCell className="hidden whitespace-nowrap text-muted-foreground lg:table-cell">
{formatDateTime(policy.createdAt)}
</TableCell>
<TableCell className="whitespace-nowrap text-muted-foreground">
{formatDateTime(policy.updatedAt)}
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm">
<MoreHorizontal />
<span className="sr-only">Aktionen</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => handlePreview(policy)}>
<Eye />
Vorschau
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => handleEdit(policy)}>
<Pencil />
Bearbeiten
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={() => setPolicyToDelete(policy)}
>
<Trash2 />
Löschen
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</main>
<PolicyForm
open={isFormOpen}
onOpenChange={setIsFormOpen}
policy={editingPolicy}
/>
<PolicyDetail
policy={previewPolicy}
open={isPreviewOpen}
onOpenChange={setIsPreviewOpen}
/>
<UserManagement
isOpen={isUserManagementOpen}
onClose={() => setIsUserManagementOpen(false)}
/>
<AlertDialog
open={policyToDelete !== null}
onOpenChange={(open) => !open && setPolicyToDelete(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Datenschutzerklärung löschen?</AlertDialogTitle>
<AlertDialogDescription>
„{policyToDelete?.title}“ wird unwiderruflich entfernt. Diese Aktion
kann nicht rückgängig gemacht werden.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Abbrechen</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault()
handleDelete()
}}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? "Löschen…" : "Löschen"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@ -0,0 +1,3 @@
import { handlers } from "@/lib/auth"
export const { GET, POST } = handlers

View File

@ -0,0 +1,191 @@
import { NextRequest, NextResponse } from 'next/server'
import { PrismaClient } from '@prisma/client'
import { requireAdmin } from '@/lib/require-admin'
// Prisma client instance
const prisma = new PrismaClient()
// Validation function for privacy policy data
function validatePrivacyPolicyData(data: any) {
const errors: string[] = []
// Required fields validation
if (!data.title?.trim()) errors.push('Title ist erforderlich')
if (!data.responsible?.trim()) errors.push('Verantwortlicher ist erforderlich')
if (!data.contactDPO?.trim()) errors.push('Kontaktdaten des Datenschutzbeauftragten sind erforderlich')
if (!data.dataSubjectsRights?.trim()) errors.push('Betroffenen-Rechte sind erforderlich')
if (!data.complaintRight?.trim()) errors.push('Beschwerderecht ist erforderlich')
if (!data.withdrawalRight?.trim()) errors.push('Widerrufsrecht ist erforderlich')
if (!data.processingDescription?.trim()) errors.push('Verarbeitungstätigkeit ist erforderlich')
if (!data.recipients?.trim()) errors.push('Empfänger sind erforderlich')
// Optional boolean validation
if (data.isPublic !== undefined && typeof data.isPublic !== 'boolean') {
errors.push('isPublic muss ein Boolean-Wert sein')
}
return errors
}
// GET /api/privacy-policies/[id] - returns a single privacy policy
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id: rawId } = await params
const id = parseInt(rawId)
// Validate ID is a number
if (isNaN(id)) {
return NextResponse.json(
{ error: 'Ungültige ID' },
{ status: 400 }
)
}
// Find privacy policy by ID
const policy = await prisma.privacyPolicy.findUnique({
where: { id }
})
if (!policy) {
return NextResponse.json(
{ error: 'Datenschutzerklärung nicht gefunden' },
{ status: 404 }
)
}
return NextResponse.json(policy)
} catch (error) {
console.error('Error fetching privacy policy:', error)
return NextResponse.json(
{ error: 'Fehler beim Abrufen der Datenschutzerklärung' },
{ status: 500 }
)
}
}
// PUT /api/privacy-policies/[id] - updates a privacy policy (Admin)
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const denied = await requireAdmin()
if (denied) return denied
const { id: rawId } = await params
const id = parseInt(rawId)
const data = await request.json()
// Validate ID is a number
if (isNaN(id)) {
return NextResponse.json(
{ error: 'Ungültige ID' },
{ status: 400 }
)
}
// Validate required fields
const validationErrors = validatePrivacyPolicyData(data)
if (validationErrors.length > 0) {
return NextResponse.json(
{ error: 'Validierungsfehler', details: validationErrors },
{ status: 400 }
)
}
// Check if policy exists
const existingPolicy = await prisma.privacyPolicy.findUnique({
where: { id }
})
if (!existingPolicy) {
return NextResponse.json(
{ error: 'Datenschutzerklärung nicht gefunden' },
{ status: 404 }
)
}
// Update privacy policy
const updatedPolicy = await prisma.privacyPolicy.update({
where: { id },
data: {
title: data.title.trim(),
responsible: data.responsible.trim(),
contactDPO: data.contactDPO.trim(),
dataSubjectsRights: data.dataSubjectsRights.trim(),
complaintRight: data.complaintRight.trim(),
withdrawalRight: data.withdrawalRight.trim(),
processingDescription: data.processingDescription.trim(),
processingPurposes: data.processingPurposes?.trim() || null,
legalBasis: data.legalBasis?.trim() || null,
recipients: data.recipients.trim(),
storageDuration: data.storageDuration?.trim() || null,
dataSource: data.dataSource?.trim() || null,
provisionObligation: data.provisionObligation?.trim() || null,
provisionConsequences: data.provisionConsequences?.trim() || null,
isPublic: data.isPublic !== undefined ? data.isPublic : existingPolicy.isPublic
}
})
return NextResponse.json(updatedPolicy)
} catch (error) {
console.error('Error updating privacy policy:', error)
return NextResponse.json(
{ error: 'Fehler beim Aktualisieren der Datenschutzerklärung' },
{ status: 500 }
)
}
}
// DELETE /api/privacy-policies/[id] - deletes a privacy policy (Admin)
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const denied = await requireAdmin()
if (denied) return denied
const { id: rawId } = await params
const id = parseInt(rawId)
// Validate ID is a number
if (isNaN(id)) {
return NextResponse.json(
{ error: 'Ungültige ID' },
{ status: 400 }
)
}
// Check if policy exists
const existingPolicy = await prisma.privacyPolicy.findUnique({
where: { id }
})
if (!existingPolicy) {
return NextResponse.json(
{ error: 'Datenschutzerklärung nicht gefunden' },
{ status: 404 }
)
}
// Delete privacy policy
await prisma.privacyPolicy.delete({
where: { id }
})
return NextResponse.json(
{ message: 'Datenschutzerklärung erfolgreich gelöscht' },
{ status: 200 }
)
} catch (error) {
console.error('Error deleting privacy policy:', error)
return NextResponse.json(
{ error: 'Fehler beim Löschen der Datenschutzerklärung' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,102 @@
import { NextRequest, NextResponse } from 'next/server'
import { PrismaClient } from '@prisma/client'
import { requireAdmin } from '@/lib/require-admin'
// Prisma client instance
const prisma = new PrismaClient()
// Validation function for privacy policy data
function validatePrivacyPolicyData(data: any) {
const errors: string[] = []
// Required fields validation
if (!data.title?.trim()) errors.push('Title ist erforderlich')
if (!data.responsible?.trim()) errors.push('Verantwortlicher ist erforderlich')
if (!data.contactDPO?.trim()) errors.push('Kontaktdaten des Datenschutzbeauftragten sind erforderlich')
if (!data.dataSubjectsRights?.trim()) errors.push('Betroffenen-Rechte sind erforderlich')
if (!data.complaintRight?.trim()) errors.push('Beschwerderecht ist erforderlich')
if (!data.withdrawalRight?.trim()) errors.push('Widerrufsrecht ist erforderlich')
if (!data.processingDescription?.trim()) errors.push('Verarbeitungstätigkeit ist erforderlich')
if (!data.recipients?.trim()) errors.push('Empfänger sind erforderlich')
// Optional boolean validation
if (data.isPublic !== undefined && typeof data.isPublic !== 'boolean') {
errors.push('isPublic muss ein Boolean-Wert sein')
}
return errors
}
// GET /api/privacy-policies - returns privacy policies based on admin parameter
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const isAdmin = searchParams.get('admin') === 'true'
// Get policies based on admin flag
const policies = await prisma.privacyPolicy.findMany({
where: isAdmin ? {} : {
isPublic: true
},
orderBy: {
createdAt: 'desc'
}
})
return NextResponse.json(policies)
} catch (error) {
console.error('Error fetching privacy policies:', error)
return NextResponse.json(
{ error: 'Fehler beim Abrufen der Datenschutzerklärungen' },
{ status: 500 }
)
}
}
// POST /api/privacy-policies - creates a new privacy policy (Admin)
export async function POST(request: NextRequest) {
try {
const denied = await requireAdmin()
if (denied) return denied
const data = await request.json()
// Validate required fields
const validationErrors = validatePrivacyPolicyData(data)
if (validationErrors.length > 0) {
return NextResponse.json(
{ error: 'Validierungsfehler', details: validationErrors },
{ status: 400 }
)
}
// Create new privacy policy
const newPolicy = await prisma.privacyPolicy.create({
data: {
title: data.title.trim(),
responsible: data.responsible.trim(),
contactDPO: data.contactDPO.trim(),
dataSubjectsRights: data.dataSubjectsRights.trim(),
complaintRight: data.complaintRight.trim(),
withdrawalRight: data.withdrawalRight.trim(),
processingDescription: data.processingDescription.trim(),
processingPurposes: data.processingPurposes?.trim() || null,
legalBasis: data.legalBasis?.trim() || null,
recipients: data.recipients.trim(),
storageDuration: data.storageDuration?.trim() || null,
dataSource: data.dataSource?.trim() || null,
provisionObligation: data.provisionObligation?.trim() || null,
provisionConsequences: data.provisionConsequences?.trim() || null,
isPublic: data.isPublic !== undefined ? data.isPublic : true
}
})
return NextResponse.json(newPolicy, { status: 201 })
} catch (error) {
console.error('Error creating privacy policy:', error)
return NextResponse.json(
{ error: 'Fehler beim Erstellen der Datenschutzerklärung' },
{ status: 500 }
)
}
}

150
app/api/users/[id]/route.ts Normal file
View File

@ -0,0 +1,150 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { PrismaClient } from '@prisma/client'
import bcrypt from 'bcryptjs'
const prisma = new PrismaClient()
// PUT /api/users/[id] - update user
export async function PUT(
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 })
}
const data = await request.json()
const { email, name, password, role, isActive } = data
// Check if user exists
const existingUser = await prisma.user.findUnique({
where: { id }
})
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) {
console.error('Error updating user:', error)
return NextResponse.json(
{ error: 'Fehler beim Aktualisieren des Benutzers' },
{ 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 }
)
}
}

117
app/api/users/route.ts Normal file
View File

@ -0,0 +1,117 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { PrismaClient } from '@prisma/client'
import bcrypt from 'bcryptjs'
const prisma = new PrismaClient()
// GET /api/users - get all users (admin only)
export async function GET() {
try {
const session = await auth()
if (!session || (session.user.role !== 'ADMIN' && session.user.role !== 'SUPER_ADMIN')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const users = await prisma.user.findMany({
select: {
id: true,
email: true,
name: true,
role: true,
isActive: true,
createdAt: true,
updatedAt: true,
createdByUser: {
select: {
email: true,
name: true
}
}
},
orderBy: {
createdAt: 'desc'
}
})
return NextResponse.json(users)
} catch (error) {
console.error('Error fetching users:', error)
return NextResponse.json(
{ error: 'Fehler beim Abrufen der Benutzer' },
{ status: 500 }
)
}
}
// POST /api/users - create new user (admin only)
export async function POST(request: NextRequest) {
try {
const session = await auth()
if (!session || (session.user.role !== 'ADMIN' && session.user.role !== 'SUPER_ADMIN')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const data = await request.json()
const { email, name, password, role } = data
// Validation
if (!email || !password || !role) {
return NextResponse.json(
{ error: 'E-Mail, Passwort und Rolle sind erforderlich' },
{ status: 400 }
)
}
if (password.length < 6) {
return NextResponse.json(
{ error: 'Passwort muss mindestens 6 Zeichen lang sein' },
{ status: 400 }
)
}
// Check if user already exists
const existingUser = await prisma.user.findUnique({
where: { email }
})
if (existingUser) {
return NextResponse.json(
{ error: 'Benutzer mit dieser E-Mail existiert bereits' },
{ status: 400 }
)
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 12)
// Create user
const newUser = await prisma.user.create({
data: {
email,
name: name || null,
password: hashedPassword,
role,
createdBy: parseInt(session.user.id)
},
select: {
id: true,
email: true,
name: true,
role: true,
isActive: true,
createdAt: true
}
})
return NextResponse.json(newUser, { status: 201 })
} catch (error) {
console.error('Error creating user:', error)
return NextResponse.json(
{ error: 'Fehler beim Erstellen des Benutzers' },
{ status: 500 }
)
}
}

133
app/auth/signin/page.tsx Normal file
View File

@ -0,0 +1,133 @@
"use client"
import { useState } from "react"
import { getSession, signIn } from "next-auth/react"
import { useRouter } from "next/navigation"
import { AlertCircle, Eye, EyeOff, ShieldCheck } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { ThemeToggle } from "@/components/theme-toggle"
export default function SignInPage() {
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [showPassword, setShowPassword] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState("")
const router = useRouter()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
setError("")
try {
const result = await signIn("credentials", {
email,
password,
redirect: false,
})
if (result?.error) {
setError("Ungültige Anmeldedaten")
} else {
const session = await getSession()
if (session) {
router.push("/admin")
router.refresh()
}
}
} catch {
setError("Ein Fehler ist aufgetreten")
} finally {
setIsLoading(false)
}
}
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="flex justify-end p-4">
<ThemeToggle />
</div>
<main className="flex flex-1 items-center justify-center px-6 pb-20">
<div className="w-full max-w-sm space-y-6">
<div className="flex flex-col items-center gap-3 text-center">
<div className="flex size-11 items-center justify-center rounded-xl bg-brand text-brand-foreground">
<ShieldCheck className="size-6" />
</div>
<div className="space-y-1.5">
<h1 className="text-xl leading-none font-semibold tracking-tight">
Syncova Policies
</h1>
<p className="text-sm text-muted-foreground">
Anmeldung zum Admin-Portal
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div 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" />
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="email">E-Mail-Adresse</Label>
<Input
id="email"
type="email"
autoComplete="username"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@firma.de"
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Passwort</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
disabled={isLoading}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="absolute top-1/2 right-0.5 -translate-y-1/2 text-muted-foreground"
onClick={() => setShowPassword(!showPassword)}
disabled={isLoading}
>
{showPassword ? <EyeOff /> : <Eye />}
<span className="sr-only">Passwort anzeigen</span>
</Button>
</div>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Anmeldung…" : "Anmelden"}
</Button>
</form>
<p className="text-center text-xs text-muted-foreground">
Zugang nur für berechtigte Administratoren
</p>
</div>
</main>
</div>
)
}

220
app/globals.css Normal file
View File

@ -0,0 +1,220 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(0.99 0.001 286);
--foreground: oklch(0.16 0.004 286);
--card: oklch(1 0 0);
--card-foreground: oklch(0.16 0.004 286);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.16 0.004 286);
--primary: oklch(0.21 0.006 286);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.968 0.002 286);
--secondary-foreground: oklch(0.21 0.006 286);
--muted: oklch(0.968 0.002 286);
--muted-foreground: oklch(0.552 0.014 286);
--accent: oklch(0.968 0.002 286);
--accent-foreground: oklch(0.21 0.006 286);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--success: oklch(0.596 0.145 163);
--border: oklch(0.918 0.004 286);
--input: oklch(0.918 0.004 286);
--ring: oklch(0.6 0.012 286);
--brand: oklch(0.55 0.155 258);
--brand-foreground: oklch(0.985 0 0);
--chart-1: oklch(0.55 0.155 258);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0.001 286);
--sidebar-foreground: oklch(0.16 0.004 286);
--sidebar-primary: oklch(0.21 0.006 286);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.968 0.002 286);
--sidebar-accent-foreground: oklch(0.21 0.006 286);
--sidebar-border: oklch(0.918 0.004 286);
--sidebar-ring: oklch(0.6 0.012 286);
}
.dark {
--background: oklch(0.14 0.004 286);
--foreground: oklch(0.985 0 0);
--card: oklch(0.18 0.005 286);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.19 0.005 286);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.21 0.006 286);
--secondary: oklch(0.256 0.006 286);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.256 0.006 286);
--muted-foreground: oklch(0.712 0.013 286);
--accent: oklch(0.256 0.006 286);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.637 0.237 25.331);
--destructive-foreground: oklch(0.985 0 0);
--success: oklch(0.696 0.17 162.48);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.55 0.012 286);
--brand: oklch(0.68 0.14 258);
--brand-foreground: oklch(0.14 0.004 286);
--chart-1: oklch(0.68 0.14 258);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.18 0.005 286);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.68 0.14 258);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.256 0.006 286);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.55 0.012 286);
}
@theme inline {
--font-sans: var(--font-inter), system-ui, sans-serif;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success);
--color-brand: var(--brand);
--color-brand-foreground: var(--brand-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--animate-accordion-down: accordion-down 0.18s ease-out;
--animate-accordion-up: accordion-up 0.18s ease-out;
}
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground antialiased;
}
}
/* ---------------------------------------------------------------
Markdown-Inhalte
--------------------------------------------------------------- */
.markdown-content {
@apply text-sm leading-relaxed text-foreground;
}
.markdown-content > :first-child {
@apply mt-0;
}
.markdown-content > :last-child {
@apply mb-0;
}
.markdown-content h1 {
@apply mt-5 mb-2 text-lg font-semibold tracking-tight text-foreground;
}
.markdown-content h2 {
@apply mt-5 mb-2 text-base font-semibold tracking-tight text-foreground;
}
.markdown-content h3 {
@apply mt-4 mb-1.5 text-sm font-semibold tracking-tight text-foreground;
}
.markdown-content p {
@apply mb-3;
}
.markdown-content strong {
@apply font-semibold text-foreground;
}
.markdown-content em {
@apply italic;
}
.markdown-content code {
@apply rounded bg-muted px-1.5 py-0.5 font-mono text-xs;
}
.markdown-content ul {
@apply mb-3 list-disc space-y-1 pl-5;
}
.markdown-content ol {
@apply mb-3 list-decimal space-y-1 pl-5;
}
.markdown-content a {
@apply font-medium text-brand underline underline-offset-4 hover:no-underline;
}
.markdown-content hr {
@apply my-5 border-border;
}
.markdown-content blockquote {
@apply my-3 border-l-2 border-border pl-4 text-muted-foreground;
}

54
app/layout.tsx Normal file
View File

@ -0,0 +1,54 @@
import type { Metadata } from "next"
import localFont from "next/font/local"
import { AuthProvider } from "@/components/providers/session-provider"
import { Toaster } from "@/components/ui/sonner"
import "./globals.css"
// Lokal ausgeliefert aus /font/Inter – keine Anfrage an Google Fonts.
const inter = localFont({
src: [
{ path: "../font/Inter/Inter_18pt-Regular.ttf", weight: "400", style: "normal" },
{ path: "../font/Inter/Inter_18pt-Italic.ttf", weight: "400", style: "italic" },
{ path: "../font/Inter/Inter_18pt-Medium.ttf", weight: "500", style: "normal" },
{ path: "../font/Inter/Inter_18pt-MediumItalic.ttf", weight: "500", style: "italic" },
{ path: "../font/Inter/Inter_18pt-SemiBold.ttf", weight: "600", style: "normal" },
{ path: "../font/Inter/Inter_18pt-SemiBoldItalic.ttf", weight: "600", style: "italic" },
{ path: "../font/Inter/Inter_18pt-Bold.ttf", weight: "700", style: "normal" },
{ path: "../font/Inter/Inter_18pt-BoldItalic.ttf", weight: "700", style: "italic" },
],
variable: "--font-inter",
display: "swap",
fallback: ["system-ui", "Segoe UI", "Arial", "sans-serif"],
})
export const metadata: Metadata = {
title: {
default: "Syncova Policies",
template: "%s · Syncova Policies",
},
description: "Datenschutzerklärungen nach DSGVO – transparent und zentral verwaltet",
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="de" className={inter.variable} suppressHydrationWarning>
<head>
<script
// Verhindert ein Aufblitzen des hellen Themes vor der Hydration.
dangerouslySetInnerHTML={{
__html: `(function(){try{var t=localStorage.getItem('theme')||'system';var d=t==='dark'||(t==='system'&&window.matchMedia('(prefers-color-scheme: dark)').matches);document.documentElement.classList.add(d?'dark':'light');document.documentElement.style.colorScheme=d?'dark':'light';}catch(e){}})();`,
}}
/>
</head>
<body className={inter.className}>
<AuthProvider>{children}</AuthProvider>
<Toaster />
</body>
</html>
)
}

202
app/page.tsx Normal file
View File

@ -0,0 +1,202 @@
"use client"
import { useMemo, useState } from "react"
import { ChevronRight, FileText, Search, Settings2, X } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { Skeleton } from "@/components/ui/skeleton"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { Brand } from "@/components/brand"
import { PolicyDetail } from "@/components/policy-detail"
import { PrintPolicy } from "@/components/print-policy"
import { ThemeToggle } from "@/components/theme-toggle"
import { usePublicPrivacyPolicies } from "@/hooks/use-privacy-policies"
import { PrivacyPolicy } from "@/types/privacy-policy"
import { formatDate } from "@/lib/format"
export default function PublicDashboard() {
const { policies, isLoading, error } = usePublicPrivacyPolicies()
const [selectedPolicy, setSelectedPolicy] = useState<PrivacyPolicy | null>(null)
const [isDetailOpen, setIsDetailOpen] = useState(false)
const [searchTerm, setSearchTerm] = useState("")
const filteredPolicies = useMemo(() => {
const term = searchTerm.trim().toLowerCase()
if (!term) return policies
return policies.filter((policy) =>
[policy.title, policy.responsible, policy.processingDescription].some((field) =>
field.toLowerCase().includes(term)
)
)
}, [policies, searchTerm])
const handleViewDetails = (policy: PrivacyPolicy) => {
setSelectedPolicy(policy)
setIsDetailOpen(true)
}
return (
<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">
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between gap-4 px-6">
<Brand subtitle="Datenschutz" />
<div className="flex items-center gap-1">
<ThemeToggle />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => window.open("/admin", "_blank")}
>
<Settings2 />
<span className="sr-only">Admin-Bereich</span>
</Button>
</TooltipTrigger>
<TooltipContent>Admin-Bereich</TooltipContent>
</Tooltip>
</div>
</div>
</header>
<main className="mx-auto max-w-5xl px-6 py-8">
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-1.5">
<h1 className="text-2xl font-semibold tracking-tight">
Datenschutzerklärungen
</h1>
<p className="text-sm text-muted-foreground">
Transparente Informationen zu unserer Datenverarbeitung nach DSGVO
</p>
</div>
<div className="relative w-full sm:w-72">
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Suchen…"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="px-9"
/>
{searchTerm && (
<Button
variant="ghost"
size="icon-sm"
onClick={() => setSearchTerm("")}
className="absolute top-1/2 right-0.5 -translate-y-1/2 text-muted-foreground"
>
<X />
<span className="sr-only">Suche zurücksetzen</span>
</Button>
)}
</div>
</div>
{error ? (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
Fehler beim Laden der Daten: {error.message}
</div>
) : isLoading ? (
<div className="divide-y rounded-lg border">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="flex items-center gap-4 p-4">
<Skeleton className="size-9 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-3.5 w-1/2" />
</div>
<Skeleton className="h-4 w-20" />
</div>
))}
</div>
) : filteredPolicies.length === 0 ? (
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed px-6 py-16 text-center">
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
<FileText className="size-6 text-muted-foreground" />
</div>
<p className="font-medium">
{searchTerm
? "Keine Treffer"
: "Keine Datenschutzerklärungen verfügbar"}
</p>
<p className="max-w-sm text-sm text-muted-foreground">
{searchTerm
? "Versuchen Sie einen anderen Suchbegriff."
: "Derzeit sind keine öffentlichen Datenschutzerklärungen hinterlegt."}
</p>
{searchTerm && (
<Button variant="outline" onClick={() => setSearchTerm("")}>
Suche zurücksetzen
</Button>
)}
</div>
) : (
<div className="divide-y overflow-hidden rounded-lg border bg-card">
{filteredPolicies.map((policy) => (
<div
key={policy.id}
className="group flex items-center gap-3 px-3 transition-colors hover:bg-muted/40"
>
<button
type="button"
onClick={() => handleViewDetails(policy)}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-3 rounded-md py-4 text-left outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-brand/10 text-brand">
<FileText className="size-4.5" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">
{policy.title}
</span>
<span className="mt-0.5 block truncate text-sm text-muted-foreground">
{policy.responsible.replace(/[*_#`]/g, "").split("\n")[0]}
</span>
</span>
</button>
<div className="flex shrink-0 items-center gap-2 pr-1">
<span className="hidden text-sm text-muted-foreground sm:inline">
{formatDate(policy.updatedAt)}
</span>
<Separator orientation="vertical" className="hidden h-5 sm:block" />
<PrintPolicy policy={policy} size="icon-sm" variant="ghost" />
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleViewDetails(policy)}
className="text-muted-foreground group-hover:text-foreground"
>
<ChevronRight />
<span className="sr-only">Details anzeigen</span>
</Button>
</div>
</div>
))}
</div>
)}
{!isLoading && !error && filteredPolicies.length > 0 && (
<div className="mt-4 flex items-center justify-between text-sm text-muted-foreground">
<span>
{filteredPolicies.length}{" "}
{filteredPolicies.length === 1 ? "Erklärung" : "Erklärungen"}
{searchTerm && ` von ${policies.length}`}
</span>
<Badge variant="outline">DSGVO-konform</Badge>
</div>
)}
</main>
<PolicyDetail
policy={selectedPolicy}
open={isDetailOpen}
onOpenChange={setIsDetailOpen}
/>
</div>
)
}

21
components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "./app/globals.css",
"baseColor": "stone",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

29
components/brand.tsx Normal file
View File

@ -0,0 +1,29 @@
import { ShieldCheck } from "lucide-react"
import { cn } from "@/lib/utils"
interface BrandProps {
className?: string
subtitle?: string
}
export function Brand({ className, subtitle }: BrandProps) {
return (
<div className={cn("flex items-center gap-2.5", className)}>
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-brand text-brand-foreground">
<ShieldCheck className="size-4.5" />
</div>
<div className="flex items-baseline gap-2">
<span className="text-base leading-none font-semibold tracking-tight">
Syncova
<span className="font-normal text-muted-foreground"> Policies</span>
</span>
{subtitle && (
<span className="hidden text-sm leading-none text-muted-foreground sm:inline">
· {subtitle}
</span>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,88 @@
"use client"
import { useState } from "react"
import { Eye, Pencil } from "lucide-react"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { MarkdownRenderer } from "@/components/markdown-renderer"
import { cn } from "@/lib/utils"
interface MarkdownInputProps {
id: string
label: string
value: string
onChange: (value: string) => void
placeholder?: string
required?: boolean
rows?: number
className?: string
invalid?: boolean
}
export function MarkdownInput({
id,
label,
value,
onChange,
placeholder,
required = false,
rows = 6,
className,
invalid = false,
}: MarkdownInputProps) {
const [tab, setTab] = useState<"edit" | "preview">("edit")
return (
<Tabs
value={tab}
onValueChange={(next) => setTab(next as "edit" | "preview")}
className={cn("gap-2", className)}
>
<div className="flex items-center justify-between gap-3">
<Label htmlFor={id} className="gap-1">
{label}
{required && <span className="text-destructive">*</span>}
</Label>
<TabsList className="h-8">
<TabsTrigger value="edit" className="px-2.5">
<Pencil />
<span className="sr-only">Bearbeiten</span>
</TabsTrigger>
<TabsTrigger value="preview" className="px-2.5">
<Eye />
<span className="sr-only">Vorschau</span>
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="edit">
<Textarea
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
rows={rows}
aria-invalid={invalid}
className="resize-y font-mono text-sm"
/>
</TabsContent>
<TabsContent value="preview">
<div
className="rounded-md border bg-muted/30 p-4"
style={{ minHeight: `${rows * 1.5 + 1}rem` }}
>
{value ? (
<MarkdownRenderer content={value} />
) : (
<p className="text-sm text-muted-foreground italic">
Noch kein Inhalt – im Bearbeiten-Tab eingeben.
</p>
)}
</div>
</TabsContent>
</Tabs>
)
}

View File

@ -0,0 +1,100 @@
"use client"
import { useEffect, useState } from "react"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"
import { markdownToHtml, markdownToHtmlSync } from "@/lib/markdown"
interface MarkdownRendererProps {
content: string
className?: string
inline?: boolean
}
export function MarkdownRenderer({
content,
className,
inline = false,
}: MarkdownRendererProps) {
const [htmlContent, setHtmlContent] = useState<string>("")
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
let cancelled = false
async function processMarkdown() {
setIsLoading(true)
try {
const html = await markdownToHtml(content)
if (!cancelled) setHtmlContent(html)
} catch (error) {
console.error("Error rendering markdown:", error)
if (!cancelled) setHtmlContent(content)
} finally {
if (!cancelled) setIsLoading(false)
}
}
if (content) {
processMarkdown()
} else {
setHtmlContent("")
setIsLoading(false)
}
return () => {
cancelled = true
}
}, [content])
if (isLoading) {
return (
<div className={cn("space-y-2", className)}>
<Skeleton className="h-4 w-4/5" />
<Skeleton className="h-4 w-3/5" />
</div>
)
}
if (!htmlContent) {
return (
<p className={cn("text-sm text-muted-foreground italic", className)}>
Kein Inhalt verfügbar
</p>
)
}
const Component = inline ? "span" : "div"
return (
<Component
className={cn("markdown-content", className)}
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
)
}
// Synchronous version for cases where we can't use hooks
export function StaticMarkdownRenderer({
content,
className,
inline = false,
}: MarkdownRendererProps) {
if (!content) {
return (
<p className={cn("text-sm text-muted-foreground italic", className)}>
Kein Inhalt verfügbar
</p>
)
}
const Component = inline ? "span" : "div"
return (
<Component
className={cn("markdown-content", className)}
dangerouslySetInnerHTML={{ __html: markdownToHtmlSync(content) }}
/>
)
}

View File

@ -0,0 +1,148 @@
"use client"
import {
AlertTriangle,
Briefcase,
Calendar,
Contact,
Database,
ClipboardCheck,
Gavel,
OctagonAlert,
RotateCcw,
ShieldCheck,
Target,
Timer,
UserCheck,
Users,
type LucideIcon,
} from "lucide-react"
import { Badge } from "@/components/ui/badge"
import {
Dialog,
DialogBody,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { MarkdownRenderer } from "@/components/markdown-renderer"
import { PrintPolicy } from "@/components/print-policy"
import { PrivacyPolicy } from "@/types/privacy-policy"
import { formatDate } from "@/lib/format"
interface PolicyDetailProps {
policy: PrivacyPolicy | null
open: boolean
onOpenChange: (open: boolean) => void
}
interface Section {
icon: LucideIcon
label: string
content?: string | null
}
function SectionBlock({ icon: Icon, label, content }: Section) {
if (!content) return null
return (
<section className="space-y-2">
<h3 className="flex items-center gap-2 text-sm font-semibold">
<Icon className="size-4 text-brand" />
{label}
</h3>
<div className="rounded-lg border bg-muted/30 p-4">
<MarkdownRenderer content={content} />
</div>
</section>
)
}
export function PolicyDetail({ policy, open, onOpenChange }: PolicyDetailProps) {
const responsibleSections: Section[] = policy
? [
{ icon: Contact, label: "Verantwortlicher im Sinne der DSGVO", content: policy.responsible },
{ icon: ShieldCheck, label: "Kontaktdaten der/des Datenschutzbeauftragten", content: policy.contactDPO },
]
: []
const processingSections: Section[] = policy
? [
{ icon: Briefcase, label: "Bezeichnung der Verarbeitungstätigkeit", content: policy.processingDescription },
{ icon: Target, label: "Zweck der Datenerhebung", content: policy.processingPurposes },
{ icon: Gavel, label: "Rechtsgrundlage der Verarbeitung", content: policy.legalBasis },
{ icon: Users, label: "Geplante Empfänger der Daten", content: policy.recipients },
{ icon: Timer, label: "Speicherdauer", content: policy.storageDuration },
{ icon: Database, label: "Herkunft der Daten", content: policy.dataSource },
]
: []
const rightsSections: Section[] = policy
? [
{ icon: UserCheck, label: "Betroffenen-Rechte", content: policy.dataSubjectsRights },
{ icon: AlertTriangle, label: "Beschwerderecht bei der Aufsichtsbehörde", content: policy.complaintRight },
{ icon: RotateCcw, label: "Widerrufsrecht bei Einwilligung", content: policy.withdrawalRight },
{ icon: ClipboardCheck, label: "Ihre Pflicht zur Bereitstellung der Daten", content: policy.provisionObligation },
{ icon: OctagonAlert, label: "Folgen, wenn Sie die Daten nicht angeben", content: policy.provisionConsequences },
]
: []
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh] sm:max-w-3xl">
{policy && (
<>
<DialogHeader>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 space-y-2">
<DialogTitle>{policy.title}</DialogTitle>
<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">
<Calendar className="size-4" />
Erstellt am {formatDate(policy.createdAt)}
</span>
{policy.updatedAt !== policy.createdAt && (
<span>Aktualisiert am {formatDate(policy.updatedAt)}</span>
)}
<Badge variant="brand">DSGVO</Badge>
</div>
</div>
<PrintPolicy policy={policy} size="sm" className="mr-8" />
</div>
</DialogHeader>
<Tabs defaultValue="responsible" className="min-h-0 flex-1">
<TabsList className="w-full">
<TabsTrigger value="responsible">Verantwortlich</TabsTrigger>
<TabsTrigger value="processing">Verarbeitung</TabsTrigger>
<TabsTrigger value="rights">Ihre Rechte</TabsTrigger>
</TabsList>
<DialogBody>
<TabsContent value="responsible" className="space-y-5 py-1">
{responsibleSections.map((section) => (
<SectionBlock key={section.label} {...section} />
))}
</TabsContent>
<TabsContent value="processing" className="space-y-5 py-1">
{processingSections.map((section) => (
<SectionBlock key={section.label} {...section} />
))}
</TabsContent>
<TabsContent value="rights" className="space-y-5 py-1">
{rightsSections.map((section) => (
<SectionBlock key={section.label} {...section} />
))}
</TabsContent>
</DialogBody>
</Tabs>
</>
)}
</DialogContent>
</Dialog>
)
}

405
components/policy-form.tsx Normal file
View File

@ -0,0 +1,405 @@
"use client"
import { useEffect, useState } from "react"
import { AlertCircle } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { MarkdownInput } from "@/components/markdown-input"
import { createPolicy, updatePolicy } from "@/hooks/use-privacy-policies"
import { ApiError } from "@/lib/api"
import { FormData, PrivacyPolicy } from "@/types/privacy-policy"
const initialFormData: FormData = {
title: "",
responsible: "",
contactDPO: "",
dataSubjectsRights: "",
complaintRight: "",
withdrawalRight: "",
processingDescription: "",
processingPurposes: "",
legalBasis: "",
recipients: "",
storageDuration: "",
dataSource: "",
provisionObligation: "",
provisionConsequences: "",
isPublic: true,
}
type TabId = "base" | "processing" | "rights"
const requiredFields: { field: keyof FormData; label: string; tab: TabId }[] = [
{ field: "title", label: "Titel", tab: "base" },
{ field: "responsible", label: "Verantwortlicher", tab: "base" },
{ field: "contactDPO", label: "Datenschutzbeauftragte/r", tab: "base" },
{ field: "processingDescription", label: "Verarbeitungstätigkeit", tab: "processing" },
{ field: "recipients", label: "Empfänger", tab: "processing" },
{ field: "dataSubjectsRights", label: "Betroffenen-Rechte", tab: "rights" },
{ field: "complaintRight", label: "Beschwerderecht", tab: "rights" },
{ field: "withdrawalRight", label: "Widerrufsrecht", tab: "rights" },
]
const placeholders = {
responsible: `**Firmenname**
Musterstraße 123
12345 Musterstadt
*Vertretungsberechtigt:* Max Mustermann`,
contactDPO: `**Datenschutzbeauftragte/r:** Max Mustermann
**E-Mail:** datenschutz@beispiel.de
**Telefon:** +49 123 456789`,
dataSubjectsRights: `- **Auskunftsrecht** (Art. 15 DSGVO)
- **Recht auf Berichtigung** (Art. 16 DSGVO)
- **Recht auf Löschung** (Art. 17 DSGVO)
- **Recht auf Einschränkung** (Art. 18 DSGVO)
- **Recht auf Widerspruch** (Art. 21 DSGVO)`,
complaintRight: `Sie haben das **Recht zur Beschwerde** bei einer Aufsichtsbehörde.
**Zuständige Aufsichtsbehörde:**
[Landesdatenschutzbehörde](https://www.datenschutz.de)`,
withdrawalRight: `Soweit die Verarbeitung auf einer *Einwilligung* beruht, können Sie diese **jederzeit widerrufen** – per E-Mail an datenschutz@beispiel.de.`,
processingDescription: `**Website-Betrieb und Nutzerinteraktion**
Verarbeitung von Nutzerdaten zum Betrieb der Website und zur Bereitstellung unserer Dienstleistungen.`,
processingPurposes: `1. **Website-Betrieb** – Bereitstellung der Funktionalität
2. **Kommunikation** – Beantwortung von Anfragen
3. **Verbesserung** – Analyse der Nutzung`,
legalBasis: `- **Art. 6 Abs. 1 lit. a** – *Einwilligung*
- **Art. 6 Abs. 1 lit. b** – *Vertragserfüllung*
- **Art. 6 Abs. 1 lit. f** – *Berechtigte Interessen*`,
recipients: `### Interne Empfänger
- Kundenservice, IT-Abteilung
### Externe Dienstleister
- **Hosting:** [Anbieter]
- **E-Mail:** [Service]`,
storageDuration: `- **Verträge:** 10 Jahre nach Vertragsende
- **Newsletter:** bis zum Widerruf
- **Website-Logs:** 30 Tage`,
provisionObligation: `Wer ist zur Bereitstellung der Daten verpflichtet, z. B.:
Eigentümer der Immobilie / Verbrauchsstelle`,
provisionConsequences: `Was passiert, wenn die Daten nicht angegeben werden, z. B.:
Verbrauchsschätzung`,
dataSource: `- **Direkte Eingabe:** Kontaktformulare, Registrierung
- **Website-Nutzung:** Cookies, Logs
- **Öffentliche Quellen:** Handelsregister`,
}
interface PolicyFormProps {
open: boolean
onOpenChange: (open: boolean) => void
policy: PrivacyPolicy | null
}
export function PolicyForm({ open, onOpenChange, policy }: PolicyFormProps) {
const [formData, setFormData] = useState<FormData>(initialFormData)
const [errors, setErrors] = useState<string[]>([])
const [invalidFields, setInvalidFields] = useState<Set<string>>(new Set())
const [isSubmitting, setIsSubmitting] = useState(false)
const [tab, setTab] = useState<TabId>("base")
useEffect(() => {
if (!open) return
setErrors([])
setInvalidFields(new Set())
setTab("base")
setFormData(
policy
? {
title: policy.title,
responsible: policy.responsible,
contactDPO: policy.contactDPO,
dataSubjectsRights: policy.dataSubjectsRights,
complaintRight: policy.complaintRight,
withdrawalRight: policy.withdrawalRight,
processingDescription: policy.processingDescription,
processingPurposes: policy.processingPurposes || "",
legalBasis: policy.legalBasis || "",
recipients: policy.recipients,
storageDuration: policy.storageDuration || "",
dataSource: policy.dataSource || "",
provisionObligation: policy.provisionObligation || "",
provisionConsequences: policy.provisionConsequences || "",
isPublic: policy.isPublic,
}
: initialFormData
)
}, [open, policy])
const set = <K extends keyof FormData>(key: K, value: FormData[K]) => {
setFormData((prev) => ({ ...prev, [key]: value }))
setInvalidFields((prev) => {
if (!prev.has(key as string)) return prev
const next = new Set(prev)
next.delete(key as string)
return next
})
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const missing = requiredFields.filter(
({ field }) => !String(formData[field] ?? "").trim()
)
if (missing.length > 0) {
setErrors(missing.map(({ label }) => `${label} ist erforderlich`))
setInvalidFields(new Set(missing.map(({ field }) => field as string)))
setTab(missing[0].tab)
return
}
setErrors([])
setInvalidFields(new Set())
setIsSubmitting(true)
try {
if (policy) {
await updatePolicy(policy.id, formData)
toast.success("Datenschutzerklärung aktualisiert")
} else {
await createPolicy(formData)
toast.success("Datenschutzerklärung erstellt")
}
onOpenChange(false)
} catch (error) {
const details =
error instanceof ApiError && error.details.length > 0
? error.details
: [error instanceof Error ? error.message : "Fehler beim Speichern"]
setErrors(details)
} finally {
setIsSubmitting(false)
}
}
const isInvalid = (field: keyof FormData) => invalidFields.has(field as string)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[88vh] sm:max-w-4xl">
<DialogHeader>
<DialogTitle>
{policy ? "Datenschutzerklärung bearbeiten" : "Neue Datenschutzerklärung"}
</DialogTitle>
<DialogDescription>
Alle Pflichtfelder (*) ausfüllen. Die Felder unterstützen Markdown.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col gap-4">
<Tabs
value={tab}
onValueChange={(value) => setTab(value as TabId)}
className="min-h-0 flex-1 gap-3"
>
<div className="flex items-center justify-between gap-4">
<TabsList>
<TabsTrigger value="base">Stammdaten</TabsTrigger>
<TabsTrigger value="processing">Verarbeitung</TabsTrigger>
<TabsTrigger value="rights">Rechte</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2">
<Label htmlFor="isPublic" className="cursor-pointer text-muted-foreground">
Öffentlich sichtbar
</Label>
<Switch
id="isPublic"
checked={formData.isPublic}
onCheckedChange={(checked) => set("isPublic", checked)}
/>
</div>
</div>
{errors.length > 0 && (
<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">
<TabsContent value="base" className="space-y-5">
<div className="space-y-2">
<Label htmlFor="title" className="gap-1">
Titel der Datenschutzerklärung
<span className="text-destructive">*</span>
</Label>
<Input
id="title"
value={formData.title}
onChange={(e) => set("title", e.target.value)}
placeholder="z. B. Datenschutzerklärung für die Website"
aria-invalid={isInvalid("title")}
/>
</div>
<div className="grid gap-5 md:grid-cols-2">
<MarkdownInput
id="responsible"
label="Verantwortlicher im Sinne der DSGVO"
value={formData.responsible}
onChange={(value) => set("responsible", value)}
placeholder={placeholders.responsible}
invalid={isInvalid("responsible")}
required
/>
<MarkdownInput
id="contactDPO"
label="Kontaktdaten der/des Datenschutzbeauftragten"
value={formData.contactDPO}
onChange={(value) => set("contactDPO", value)}
placeholder={placeholders.contactDPO}
invalid={isInvalid("contactDPO")}
required
/>
</div>
</TabsContent>
<TabsContent value="processing" className="grid gap-5 md:grid-cols-2">
<MarkdownInput
id="processingDescription"
label="Bezeichnung der Verarbeitungstätigkeit"
value={formData.processingDescription}
onChange={(value) => set("processingDescription", value)}
placeholder={placeholders.processingDescription}
invalid={isInvalid("processingDescription")}
required
/>
<MarkdownInput
id="processingPurposes"
label="Zweck der Datenerhebung (optional)"
value={formData.processingPurposes || ""}
onChange={(value) => set("processingPurposes", value)}
placeholder={placeholders.processingPurposes}
/>
<MarkdownInput
id="legalBasis"
label="Rechtsgrundlage der Datenerhebung (optional)"
value={formData.legalBasis || ""}
onChange={(value) => set("legalBasis", value)}
placeholder={placeholders.legalBasis}
/>
<MarkdownInput
id="recipients"
label="Geplante Empfänger der Daten"
value={formData.recipients}
onChange={(value) => set("recipients", value)}
placeholder={placeholders.recipients}
invalid={isInvalid("recipients")}
required
/>
<MarkdownInput
id="storageDuration"
label="Speicherdauer (optional)"
value={formData.storageDuration || ""}
onChange={(value) => set("storageDuration", value)}
placeholder={placeholders.storageDuration}
/>
<MarkdownInput
id="dataSource"
label="Herkunft der Daten (optional)"
value={formData.dataSource || ""}
onChange={(value) => set("dataSource", value)}
placeholder={placeholders.dataSource}
/>
</TabsContent>
<TabsContent value="rights" className="grid gap-5 md:grid-cols-2">
<MarkdownInput
id="dataSubjectsRights"
label="Betroffenen-Rechte"
value={formData.dataSubjectsRights}
onChange={(value) => set("dataSubjectsRights", value)}
placeholder={placeholders.dataSubjectsRights}
invalid={isInvalid("dataSubjectsRights")}
rows={9}
required
/>
<div className="space-y-5">
<MarkdownInput
id="complaintRight"
label="Beschwerderecht bei der Aufsichtsbehörde"
value={formData.complaintRight}
onChange={(value) => set("complaintRight", value)}
placeholder={placeholders.complaintRight}
invalid={isInvalid("complaintRight")}
required
/>
<MarkdownInput
id="withdrawalRight"
label="Widerrufsrecht bei Einwilligung"
value={formData.withdrawalRight}
onChange={(value) => set("withdrawalRight", value)}
placeholder={placeholders.withdrawalRight}
invalid={isInvalid("withdrawalRight")}
required
/>
<MarkdownInput
id="provisionObligation"
label="Ihre Pflicht zur Bereitstellung der Daten (optional)"
value={formData.provisionObligation || ""}
onChange={(value) => set("provisionObligation", value)}
placeholder={placeholders.provisionObligation}
rows={4}
/>
<MarkdownInput
id="provisionConsequences"
label="Folgen, wenn Sie die Daten nicht angeben (optional)"
value={formData.provisionConsequences || ""}
onChange={(value) => set("provisionConsequences", value)}
placeholder={placeholders.provisionConsequences}
rows={4}
/>
</div>
</TabsContent>
</DialogBody>
</Tabs>
<DialogFooter className="border-t pt-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Abbrechen
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting
? "Speichern…"
: policy
? "Änderungen speichern"
: "Erstellen"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

198
components/print-policy.tsx Normal file
View File

@ -0,0 +1,198 @@
"use client"
import { Printer } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { PrivacyPolicy } from "@/types/privacy-policy"
import { markdownToHtmlSync } from "@/lib/markdown"
interface PrintPolicyProps {
policy: PrivacyPolicy
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"
size?: "default" | "sm" | "lg" | "icon" | "icon-sm"
label?: string
className?: string
}
const iconSizes = ["icon", "icon-sm"]
export function PrintPolicy({
policy,
variant = "outline",
size = "sm",
label = "Drucken",
className,
}: PrintPolicyProps) {
const handlePrint = () => {
const printWindow = window.open("", "_blank")
if (!printWindow) return
printWindow.document.write(generatePrintContent(policy))
printWindow.document.close()
}
const isIconOnly = iconSizes.includes(size)
const button = (
<Button variant={variant} size={size} onClick={handlePrint} className={className}>
<Printer />
{!isIconOnly && label}
</Button>
)
if (!isIconOnly) return button
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>Als PDF drucken</TooltipContent>
</Tooltip>
)
}
function generatePrintContent(policy: PrivacyPolicy): string {
const formatDate = (dateString: string) =>
new Date(dateString).toLocaleDateString("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
})
const cleanMarkdown = (content: string) => markdownToHtmlSync(content)
const section = (title: string, content?: string | null) =>
content
? `<section class="section">
<h2 class="section-title">${title}</h2>
<div class="section-content">${cleanMarkdown(content)}</div>
</section>`
: ""
return `
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${policy.title}</title>
<style>
@page { margin: 2cm 1.8cm; size: A4; }
* { box-sizing: border-box; }
body {
font-family: -apple-system, "Segoe UI", Arial, sans-serif;
font-size: 10pt;
line-height: 1.5;
color: #18181b;
margin: 0;
}
.header {
border-bottom: 1px solid #d4d4d8;
padding-bottom: 10px;
margin-bottom: 18px;
page-break-after: avoid;
}
.eyebrow {
font-size: 7.5pt;
letter-spacing: .08em;
text-transform: uppercase;
color: #71717a;
margin: 0 0 4px 0;
}
.header h1 {
margin: 0 0 4px 0;
font-size: 17pt;
font-weight: 600;
letter-spacing: -0.01em;
line-height: 1.2;
}
.meta-info { font-size: 8pt; color: #71717a; margin: 0; }
.section { margin-bottom: 14px; page-break-inside: avoid; orphans: 3; widows: 3; }
.section-title {
font-size: 9pt;
font-weight: 600;
letter-spacing: .04em;
text-transform: uppercase;
color: #52525b;
margin: 0 0 5px 0;
padding-bottom: 3px;
border-bottom: 1px solid #e4e4e7;
page-break-after: avoid;
}
.section-content { font-size: 9.5pt; }
.section-content > :first-child { margin-top: 0; }
.section-content > :last-child { margin-bottom: 0; }
.section-content p { margin: 0 0 5px 0; }
.section-content h1, .section-content h2, .section-content h3 {
font-size: 9.5pt; font-weight: 600; margin: 8px 0 3px 0;
}
.section-content strong { font-weight: 600; }
.section-content code {
background: #f4f4f5; padding: 1px 3px; border-radius: 2px;
font-family: ui-monospace, "Courier New", monospace; font-size: 8.5pt;
}
.section-content ul, .section-content ol { margin: 4px 0; padding-left: 16px; }
.section-content li { margin: 2px 0; }
.section-content a { color: #2563eb; text-decoration: none; }
.footer {
border-top: 1px solid #e4e4e7;
margin-top: 24px;
padding-top: 8px;
font-size: 7.5pt;
color: #a1a1aa;
display: flex;
justify-content: space-between;
page-break-inside: avoid;
}
@media print {
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
}
</style>
</head>
<body>
<header class="header">
<p class="eyebrow">Syncova Policies · Datenschutzerklärung</p>
<h1>${policy.title}</h1>
<p class="meta-info">
Erstellt am ${formatDate(policy.createdAt)}${
policy.updatedAt !== policy.createdAt
? ` · Aktualisiert am ${formatDate(policy.updatedAt)}`
: ""
}
</p>
</header>
${section("Verantwortlicher im Sinne der DSGVO", policy.responsible)}
${section("Kontaktdaten des Datenschutzbeauftragten", policy.contactDPO)}
${section("Bezeichnung der Verarbeitungstätigkeit", policy.processingDescription)}
${section("Zweck der Datenerhebung", policy.processingPurposes)}
${section("Rechtsgrundlage der Datenerhebung", policy.legalBasis)}
${section("Geplante Empfänger der Daten", policy.recipients)}
${section("Speicherdauer der Daten", policy.storageDuration)}
${section("Herkunft der Daten", policy.dataSource)}
${section("Betroffenen-Rechte", policy.dataSubjectsRights)}
${section("Beschwerderecht bei der Aufsichtsbehörde", policy.complaintRight)}
${section("Widerrufsrecht bei Einwilligung", policy.withdrawalRight)}
${section("Ihre Pflicht zur Bereitstellung der Daten", policy.provisionObligation)}
${section("Folgen, wenn Sie die Daten nicht angeben", policy.provisionConsequences)}
<footer class="footer">
<span>${policy.title}</span>
<span>Gedruckt am ${new Date().toLocaleDateString("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}</span>
</footer>
<script>
window.onload = function () {
setTimeout(function () { window.print(); }, 250);
};
</script>
</body>
</html>
`
}

View File

@ -0,0 +1,7 @@
"use client"
import { SessionProvider } from "next-auth/react"
export function AuthProvider({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>
}

View File

@ -0,0 +1,49 @@
"use client"
import { Check, Monitor, Moon, Sun } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { useTheme } from "@/hooks/use-theme"
const options = [
{ value: "light", label: "Hell", icon: Sun },
{ value: "dark", label: "Dunkel", icon: Moon },
{ value: "system", label: "System", icon: Monitor },
] as const
export function ThemeToggle() {
const { theme, setTheme, resolvedTheme } = useTheme()
return (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
{resolvedTheme === "dark" ? <Moon /> : <Sun />}
<span className="sr-only">Design-Modus wechseln</span>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>Design-Modus</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end">
{options.map(({ value, label, icon: Icon }) => (
<DropdownMenuItem key={value} onSelect={() => setTheme(value)}>
<Icon />
{label}
{theme === value && <Check className="ml-auto size-4 text-foreground" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"flex flex-1 cursor-pointer items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPrimitive.Portal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
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",
"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
)}
{...props}
/>
</AlertDialogPrimitive.Portal>
)
}
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
AlertDialogTitle,
AlertDialogTrigger,
}

53
components/ui/avatar.tsx Normal file
View File

@ -0,0 +1,53 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-xs font-medium",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

48
components/ui/badge.tsx Normal file
View File

@ -0,0 +1,48 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-md border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive/10 text-destructive dark:bg-destructive/20",
success:
"border-transparent bg-success/10 text-success dark:bg-success/20",
brand: "border-transparent bg-brand/10 text-brand dark:bg-brand/20",
outline: "text-foreground [a&]:hover:bg-accent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

61
components/ui/button.tsx Normal file
View File

@ -0,0 +1,61 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none disabled:pointer-events-none disabled:opacity-50 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
brand: "bg-brand text-brand-foreground shadow-xs hover:bg-brand/90",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

88
components/ui/card.tsx Normal file
View File

@ -0,0 +1,88 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="card-content" className={cn("px-6", className)} {...props} />
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

154
components/ui/dialog.tsx Normal file
View File

@ -0,0 +1,154 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
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",
"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
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 cursor-pointer rounded-xs opacity-70 ring-offset-background transition-opacity outline-none hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Schließen</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 pr-6 text-center sm:text-left", className)}
{...props}
/>
)
}
/** Scrollbarer Mittelteil für lange Dialoge – hebt das Padding des Contents auf. */
function DialogBody({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-body"
className={cn("-mx-6 min-h-0 flex-1 overflow-y-auto px-6", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogBody,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@ -0,0 +1,246 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"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
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none",
"focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8",
"data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:*:[svg]:!text-destructive",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-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",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
}

24
components/ui/input.tsx Normal file
View File

@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none md:text-sm",
"file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground",
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

25
components/ui/label.tsx Normal file
View File

@ -0,0 +1,25 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none",
"group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@ -0,0 +1,56 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" && "h-full w-1.5 border-l border-l-transparent",
orientation === "horizontal" && "h-1.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

179
components/ui/select.tsx Normal file
View File

@ -0,0 +1,179 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
className={cn(
"flex h-9 w-full cursor-pointer items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none dark:bg-input/30 dark:hover:bg-input/50",
"data-[placeholder]:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md",
"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",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-none select-none",
"focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

29
components/ui/sonner.tsx Normal file
View File

@ -0,0 +1,29 @@
"use client"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { useTheme } from "@/hooks/use-theme"
function Toaster(props: ToasterProps) {
const { resolvedTheme } = useTheme()
return (
<Sonner
theme={resolvedTheme}
className="toaster group"
position="bottom-right"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:!rounded-lg group-[.toaster]:!border group-[.toaster]:!border-border group-[.toaster]:!bg-popover group-[.toaster]:!text-sm group-[.toaster]:!text-popover-foreground group-[.toaster]:!shadow-lg",
description: "group-[.toast]:!text-sm group-[.toast]:!text-muted-foreground",
actionButton: "group-[.toast]:!bg-primary group-[.toast]:!text-primary-foreground",
cancelButton: "group-[.toast]:!bg-muted group-[.toast]:!text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster }

34
components/ui/switch.tsx Normal file
View File

@ -0,0 +1,34 @@
"use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer inline-flex h-[1.15rem] w-8 shrink-0 cursor-pointer items-center rounded-full border border-transparent shadow-xs transition-all outline-none",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50",
"data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"pointer-events-none block size-4 rounded-full bg-background ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0",
"dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

107
components/ui/table.tsx Normal file
View File

@ -0,0 +1,107 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div data-slot="table-container" className="relative w-full overflow-x-auto">
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-3 text-left align-middle font-medium text-muted-foreground whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn("p-3 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
)
}
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

69
components/ui/tabs.tsx Normal file
View File

@ -0,0 +1,69 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"inline-flex h-9 w-fit items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"inline-flex h-[calc(100%-1px)] flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50",
"data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none md:text-sm",
"placeholder:text-muted-foreground dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"disabled:cursor-not-allowed disabled:opacity-50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

61
components/ui/tooltip.tsx Normal file
View File

@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 4,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md bg-primary px-3 py-1.5 text-xs text-balance text-primary-foreground",
"data-[state=delayed-open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=delayed-open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=delayed-open]:zoom-in-95",
className
)}
{...props}
>
{children}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -0,0 +1,466 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { AlertCircle, Eye, EyeOff, MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
import { toast } from "sonner"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { formatDateTime, initials } from "@/lib/format"
interface User {
id: number
email: string
name: string | null
role: string
isActive: boolean
createdAt: string
updatedAt: string
createdByUser?: {
email: string
name: string | null
} | null
}
interface UserFormData {
email: string
name: string
password: string
role: string
}
interface UserManagementProps {
isOpen: boolean
onClose: () => void
}
const emptyForm: UserFormData = {
email: "",
name: "",
password: "",
role: "ADMIN",
}
export function UserManagement({ isOpen, onClose }: UserManagementProps) {
const [users, setUsers] = useState<User[]>([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState("")
const [isFormOpen, setIsFormOpen] = useState(false)
const [editingUser, setEditingUser] = useState<User | null>(null)
const [userToDelete, setUserToDelete] = useState<User | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [formData, setFormData] = useState<UserFormData>(emptyForm)
const loadUsers = useCallback(async () => {
setIsLoading(true)
setError("")
try {
const response = await fetch("/api/users")
if (!response.ok) throw new Error("Fehler beim Laden der Benutzer")
setUsers(await response.json())
} catch (err) {
setError(err instanceof Error ? err.message : "Ein Fehler ist aufgetreten")
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
if (isOpen) loadUsers()
}, [isOpen, loadUsers])
const openCreateForm = () => {
setEditingUser(null)
setFormData(emptyForm)
setShowPassword(false)
setIsFormOpen(true)
}
const openEditForm = (user: User) => {
setEditingUser(user)
setFormData({
email: user.email,
name: user.name || "",
password: "",
role: user.role,
})
setShowPassword(false)
setIsFormOpen(true)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
setError("")
try {
const response = await fetch(
editingUser ? `/api/users/${editingUser.id}` : "/api/users",
{
method: editingUser ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
}
)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || "Fehler beim Speichern")
}
toast.success(editingUser ? "Benutzer aktualisiert" : "Benutzer erstellt")
setIsFormOpen(false)
await loadUsers()
} catch (err) {
const message = err instanceof Error ? err.message : "Ein Fehler ist aufgetreten"
setError(message)
toast.error(message)
} finally {
setIsSubmitting(false)
}
}
const handleDelete = async () => {
if (!userToDelete) return
setIsSubmitting(true)
try {
const response = await fetch(`/api/users/${userToDelete.id}`, {
method: "DELETE",
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || "Fehler beim Löschen")
}
toast.success("Benutzer gelöscht")
setUserToDelete(null)
await loadUsers()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Fehler beim Löschen")
} finally {
setIsSubmitting(false)
}
}
return (
<>
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-h-[85vh] sm:max-w-3xl">
<DialogHeader>
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<DialogTitle>Benutzerverwaltung</DialogTitle>
<DialogDescription>
{users.length} {users.length === 1 ? "Konto" : "Konten"} mit Zugriff
auf das Admin-Portal
</DialogDescription>
</div>
<Button onClick={openCreateForm} className="mr-8">
<Plus />
Neuer Benutzer
</Button>
</div>
</DialogHeader>
{error && (
<div className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
<AlertCircle className="size-4 shrink-0" />
{error}
</div>
)}
<DialogBody>
{isLoading ? (
<div className="space-y-3 py-2">
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={index} className="h-12 w-full" />
))}
</div>
) : (
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Benutzer</TableHead>
<TableHead className="w-32">Rolle</TableHead>
<TableHead className="hidden w-40 sm:table-cell">
Erstellt
</TableHead>
<TableHead className="w-12" />
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="max-w-0">
<div className="flex items-center gap-3">
<Avatar>
<AvatarFallback>
{initials(user.name || user.email)}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="truncate font-medium">
{user.name || user.email}
</span>
{!user.isActive && (
<Badge variant="outline">Inaktiv</Badge>
)}
</div>
{user.name && (
<span className="block truncate text-sm text-muted-foreground">
{user.email}
</span>
)}
</div>
</div>
</TableCell>
<TableCell>
<Badge
variant={
user.role === "SUPER_ADMIN" ? "brand" : "secondary"
}
>
{user.role === "SUPER_ADMIN" ? "Super Admin" : "Admin"}
</Badge>
</TableCell>
<TableCell className="hidden whitespace-nowrap text-muted-foreground sm:table-cell">
{formatDateTime(user.createdAt)}
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm">
<MoreHorizontal />
<span className="sr-only">Aktionen</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => openEditForm(user)}>
<Pencil />
Bearbeiten
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={() => setUserToDelete(user)}
>
<Trash2 />
Löschen
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</DialogBody>
</DialogContent>
</Dialog>
<Dialog open={isFormOpen} onOpenChange={setIsFormOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editingUser ? "Benutzer bearbeiten" : "Neuer Benutzer"}
</DialogTitle>
<DialogDescription>
{editingUser
? "Passwortfeld leer lassen, um das Passwort beizubehalten."
: "Zugang für das Admin-Portal anlegen."}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="user-email" className="gap-1">
E-Mail-Adresse<span className="text-destructive">*</span>
</Label>
<Input
id="user-email"
type="email"
value={formData.email}
onChange={(e) =>
setFormData((prev) => ({ ...prev, email: e.target.value }))
}
placeholder="benutzer@beispiel.de"
required
disabled={isSubmitting}
/>
</div>
<div className="space-y-2">
<Label htmlFor="user-name">Name (optional)</Label>
<Input
id="user-name"
value={formData.name}
onChange={(e) =>
setFormData((prev) => ({ ...prev, name: e.target.value }))
}
placeholder="Max Mustermann"
disabled={isSubmitting}
/>
</div>
<div className="space-y-2">
<Label htmlFor="user-password" className="gap-1">
Passwort
{editingUser ? (
<span className="font-normal text-muted-foreground">
(optional)
</span>
) : (
<span className="text-destructive">*</span>
)}
</Label>
<div className="relative">
<Input
id="user-password"
type={showPassword ? "text" : "password"}
value={formData.password}
onChange={(e) =>
setFormData((prev) => ({ ...prev, password: e.target.value }))
}
placeholder="••••••••"
required={!editingUser}
disabled={isSubmitting}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="absolute top-1/2 right-0.5 -translate-y-1/2 text-muted-foreground"
onClick={() => setShowPassword(!showPassword)}
disabled={isSubmitting}
>
{showPassword ? <EyeOff /> : <Eye />}
<span className="sr-only">Passwort anzeigen</span>
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="user-role" className="gap-1">
Rolle<span className="text-destructive">*</span>
</Label>
<Select
value={formData.role}
onValueChange={(value) =>
setFormData((prev) => ({ ...prev, role: value }))
}
disabled={isSubmitting}
>
<SelectTrigger id="user-role">
<SelectValue placeholder="Rolle auswählen" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ADMIN">Admin</SelectItem>
<SelectItem value="SUPER_ADMIN">Super Admin</SelectItem>
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsFormOpen(false)}
disabled={isSubmitting}
>
Abbrechen
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting
? "Speichern…"
: editingUser
? "Aktualisieren"
: "Erstellen"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<AlertDialog
open={userToDelete !== null}
onOpenChange={(open) => !open && setUserToDelete(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Benutzer löschen?</AlertDialogTitle>
<AlertDialogDescription>
„{userToDelete?.name || userToDelete?.email}“ verliert damit sofort den
Zugang zum Admin-Portal.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isSubmitting}>Abbrechen</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault()
handleDelete()
}}
disabled={isSubmitting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isSubmitting ? "Löschen…" : "Löschen"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More