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:
commit
328f042307
25
.dockerignore
Normal file
25
.dockerignore
Normal 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
13
.env.example
Normal 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
15
.gitattributes
vendored
Normal 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
|
||||
61
.gitea/workflows/publish-image.yml
Normal file
61
.gitea/workflows/publish-image.yml
Normal 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
49
.gitignore
vendored
Normal 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
80
Dockerfile
Normal 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
116
README.md
Normal 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
14
app/admin/layout.tsx
Normal 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
471
app/admin/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
3
app/api/auth/[...nextauth]/route.ts
Normal file
3
app/api/auth/[...nextauth]/route.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/lib/auth"
|
||||
|
||||
export const { GET, POST } = handlers
|
||||
191
app/api/privacy-policies/[id]/route.ts
Normal file
191
app/api/privacy-policies/[id]/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
102
app/api/privacy-policies/route.ts
Normal file
102
app/api/privacy-policies/route.ts
Normal 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
150
app/api/users/[id]/route.ts
Normal 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
117
app/api/users/route.ts
Normal 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
133
app/auth/signin/page.tsx
Normal 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
220
app/globals.css
Normal 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
54
app/layout.tsx
Normal 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
202
app/page.tsx
Normal 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
21
components.json
Normal 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
29
components/brand.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
88
components/markdown-input.tsx
Normal file
88
components/markdown-input.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
100
components/markdown-renderer.tsx
Normal file
100
components/markdown-renderer.tsx
Normal 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) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
148
components/policy-detail.tsx
Normal file
148
components/policy-detail.tsx
Normal 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
405
components/policy-form.tsx
Normal 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
198
components/print-policy.tsx
Normal 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>
|
||||
`
|
||||
}
|
||||
7
components/providers/session-provider.tsx
Normal file
7
components/providers/session-provider.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { SessionProvider } from "next-auth/react"
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
return <SessionProvider>{children}</SessionProvider>
|
||||
}
|
||||
49
components/theme-toggle.tsx
Normal file
49
components/theme-toggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
66
components/ui/accordion.tsx
Normal file
66
components/ui/accordion.tsx
Normal 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 }
|
||||
143
components/ui/alert-dialog.tsx
Normal file
143
components/ui/alert-dialog.tsx
Normal 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
53
components/ui/avatar.tsx
Normal 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
48
components/ui/badge.tsx
Normal 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
61
components/ui/button.tsx
Normal 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
88
components/ui/card.tsx
Normal 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
154
components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
246
components/ui/dropdown-menu.tsx
Normal file
246
components/ui/dropdown-menu.tsx
Normal 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
24
components/ui/input.tsx
Normal 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
25
components/ui/label.tsx
Normal 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 }
|
||||
56
components/ui/scroll-area.tsx
Normal file
56
components/ui/scroll-area.tsx
Normal 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
179
components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
28
components/ui/separator.tsx
Normal file
28
components/ui/separator.tsx
Normal 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 }
|
||||
13
components/ui/skeleton.tsx
Normal file
13
components/ui/skeleton.tsx
Normal 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
29
components/ui/sonner.tsx
Normal 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
34
components/ui/switch.tsx
Normal 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
107
components/ui/table.tsx
Normal 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
69
components/ui/tabs.tsx
Normal 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 }
|
||||
22
components/ui/textarea.tsx
Normal file
22
components/ui/textarea.tsx
Normal 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
61
components/ui/tooltip.tsx
Normal 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 }
|
||||
466
components/user-management.tsx
Normal file
466
components/user-management.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
BIN
data/import/FBIII_Sonstige_001.pdf
Normal file
BIN
data/import/FBIII_Sonstige_001.pdf
Normal file
Binary file not shown.
BIN
data/import/FBIII_Sonstige_002.pdf
Normal file
BIN
data/import/FBIII_Sonstige_002.pdf
Normal file
Binary file not shown.
BIN
data/import/FBIII_Sonstige_003.pdf
Normal file
BIN
data/import/FBIII_Sonstige_003.pdf
Normal file
Binary file not shown.
BIN
data/import/FBIII_Sonstige_004.pdf
Normal file
BIN
data/import/FBIII_Sonstige_004.pdf
Normal file
Binary file not shown.
BIN
data/import/FBIII_Sonstige_005.pdf
Normal file
BIN
data/import/FBIII_Sonstige_005.pdf
Normal file
Binary file not shown.
BIN
data/import/FBIII_Sonstige_006.pdf
Normal file
BIN
data/import/FBIII_Sonstige_006.pdf
Normal file
Binary file not shown.
BIN
data/import/FBIII_Sonstige_007.pdf
Normal file
BIN
data/import/FBIII_Sonstige_007.pdf
Normal file
Binary file not shown.
BIN
data/import/FBIII_Sonstige_008.pdf
Normal file
BIN
data/import/FBIII_Sonstige_008.pdf
Normal file
Binary file not shown.
BIN
data/import/FBIII_Sonstige_009.pdf
Normal file
BIN
data/import/FBIII_Sonstige_009.pdf
Normal file
Binary file not shown.
BIN
data/import/FBIII_Sonstige_010.pdf
Normal file
BIN
data/import/FBIII_Sonstige_010.pdf
Normal file
Binary file not shown.
BIN
data/import/FBIII_Sonstige_011.pdf
Normal file
BIN
data/import/FBIII_Sonstige_011.pdf
Normal file
Binary file not shown.
BIN
data/import/FBIII_Sonstige_012.pdf
Normal file
BIN
data/import/FBIII_Sonstige_012.pdf
Normal file
Binary file not shown.
BIN
data/import/FBIII_Sonstige_013.pdf
Normal file
BIN
data/import/FBIII_Sonstige_013.pdf
Normal file
Binary file not shown.
BIN
data/import/FBIII_Sonstige_014.pdf
Normal file
BIN
data/import/FBIII_Sonstige_014.pdf
Normal file
Binary file not shown.
BIN
data/import/FBIII_Sonstige_015.pdf
Normal file
BIN
data/import/FBIII_Sonstige_015.pdf
Normal file
Binary file not shown.
BIN
data/import/FBIII_Sonstige_016.pdf
Normal file
BIN
data/import/FBIII_Sonstige_016.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_EMA_001.pdf
Normal file
BIN
data/import/FBII_EMA_001.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_EMA_002.pdf
Normal file
BIN
data/import/FBII_EMA_002.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_EMA_003.pdf
Normal file
BIN
data/import/FBII_EMA_003.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_EMA_004.pdf
Normal file
BIN
data/import/FBII_EMA_004.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_EMA_005.pdf
Normal file
BIN
data/import/FBII_EMA_005.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_EMA_006.pdf
Normal file
BIN
data/import/FBII_EMA_006.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_EMA_007.pdf
Normal file
BIN
data/import/FBII_EMA_007.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_EMA_008.pdf
Normal file
BIN
data/import/FBII_EMA_008.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_Ordnung_001.pdf
Normal file
BIN
data/import/FBII_Ordnung_001.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_Ordnung_002.pdf
Normal file
BIN
data/import/FBII_Ordnung_002.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_Ordnung_003.pdf
Normal file
BIN
data/import/FBII_Ordnung_003.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_Ordnung_004.pdf
Normal file
BIN
data/import/FBII_Ordnung_004.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_Ordnung_005.pdf
Normal file
BIN
data/import/FBII_Ordnung_005.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_Ordnung_006.pdf
Normal file
BIN
data/import/FBII_Ordnung_006.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_Ordnung_007.pdf
Normal file
BIN
data/import/FBII_Ordnung_007.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_Ordnung_008.pdf
Normal file
BIN
data/import/FBII_Ordnung_008.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_Ordnung_009.pdf
Normal file
BIN
data/import/FBII_Ordnung_009.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_Ordnung_010.pdf
Normal file
BIN
data/import/FBII_Ordnung_010.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_Ordnung_011.pdf
Normal file
BIN
data/import/FBII_Ordnung_011.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_Ordnung_012.pdf
Normal file
BIN
data/import/FBII_Ordnung_012.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_Ordnung_013.pdf
Normal file
BIN
data/import/FBII_Ordnung_013.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_Ordnung_014.pdf
Normal file
BIN
data/import/FBII_Ordnung_014.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_Ordnung_015.pdf
Normal file
BIN
data/import/FBII_Ordnung_015.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_Ordnung_016.pdf
Normal file
BIN
data/import/FBII_Ordnung_016.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_Sonstige_001.pdf
Normal file
BIN
data/import/FBII_Sonstige_001.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_Sonstige_002.pdf
Normal file
BIN
data/import/FBII_Sonstige_002.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_Sonstige_003.pdf
Normal file
BIN
data/import/FBII_Sonstige_003.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_Sonstige_004.pdf
Normal file
BIN
data/import/FBII_Sonstige_004.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_Sonstige_005.pdf
Normal file
BIN
data/import/FBII_Sonstige_005.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_Sonstige_006.pdf
Normal file
BIN
data/import/FBII_Sonstige_006.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_Sozial_001.pdf
Normal file
BIN
data/import/FBII_Sozial_001.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_Sozial_002.pdf
Normal file
BIN
data/import/FBII_Sozial_002.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_Sozial_003.pdf
Normal file
BIN
data/import/FBII_Sozial_003.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_Sozial_004.pdf
Normal file
BIN
data/import/FBII_Sozial_004.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_Sozial_005.pdf
Normal file
BIN
data/import/FBII_Sozial_005.pdf
Normal file
Binary file not shown.
BIN
data/import/FBII_Sozial_006.pdf
Normal file
BIN
data/import/FBII_Sozial_006.pdf
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user