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