commit 328f0423075753b0ea0bb30d42cabb0ff81522f9 Author: Weapie Date: Mon Sep 7 16:23:51 2026 +0200 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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8891d3d --- /dev/null +++ b/.dockerignore @@ -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* diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..495a7f1 --- /dev/null +++ b/.env.example @@ -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="" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5c86c37 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitea/workflows/publish-image.yml b/.gitea/workflows/publish-image.yml new file mode 100644 index 0000000..670a020 --- /dev/null +++ b/.gitea/workflows/publish-image.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f778486 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..518466b --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..78e8920 --- /dev/null +++ b/README.md @@ -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/.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 +``` diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx new file mode 100644 index 0000000..ba60fd9 --- /dev/null +++ b/app/admin/layout.tsx @@ -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} +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..ed61958 --- /dev/null +++ b/app/admin/page.tsx @@ -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("createdAt") + const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc") + const [isFormOpen, setIsFormOpen] = useState(false) + const [editingPolicy, setEditingPolicy] = useState(null) + const [previewPolicy, setPreviewPolicy] = useState(null) + const [isPreviewOpen, setIsPreviewOpen] = useState(false) + const [policyToDelete, setPolicyToDelete] = useState(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 + }) => ( + + + + ) + + if (status === "loading") { + return ( +
+ +
+ ) + } + + if (status === "unauthenticated") { + return ( +
+ Nicht angemeldet – Weiterleitung… +
+ ) + } + + return ( +
+
+
+
+ + Admin +
+ +
+ + + + + Öffentliche Ansicht + + + + + + + Benutzer + + + + + + + + + + + + {session?.user?.name || "Angemeldet"} + + + {session?.user?.email} + + + + setIsUserManagementOpen(true)}> + + Benutzer verwalten + + + signOut({ callbackUrl: "/auth/signin" })} + > + + Abmelden + + + +
+
+
+ +
+
+
+

+ Datenschutzerklärungen +

+

+ {policies.length} gesamt · {publicCount} öffentlich ·{" "} + {policies.length - publicCount} privat +

+
+ +
+
+ + setSearchTerm(e.target.value)} + className="px-9" + /> + {searchTerm && ( + + )} +
+ + +
+
+ + {error ? ( +
+ Fehler beim Laden der Daten: {error.message} +
+ ) : isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, index) => ( + + ))} +
+ ) : visiblePolicies.length === 0 ? ( +
+
+ +
+

+ {searchTerm ? "Keine Treffer" : "Noch keine Datenschutzerklärungen"} +

+

+ {searchTerm + ? "Passen Sie den Suchbegriff an." + : "Legen Sie die erste DSGVO-Erklärung an."} +

+ {!searchTerm && ( + + )} +
+ ) : ( +
+ + + + Titel + + Verantwortlicher + + Status + + Erstellt + + + Geändert + + + + + + {visiblePolicies.map((policy) => ( + + + + + + + {policy.responsible.replace(/[*_#`]/g, "").split("\n")[0]} + + + + {policy.isPublic ? ( + + + Öffentlich + + ) : ( + + + Privat + + )} + + + {formatDateTime(policy.createdAt)} + + + {formatDateTime(policy.updatedAt)} + + + + + + + + handlePreview(policy)}> + + Vorschau + + handleEdit(policy)}> + + Bearbeiten + + + setPolicyToDelete(policy)} + > + + Löschen + + + + + + ))} + +
+
+ )} +
+ + + + + + setIsUserManagementOpen(false)} + /> + + !open && setPolicyToDelete(null)} + > + + + Datenschutzerklärung löschen? + + „{policyToDelete?.title}“ wird unwiderruflich entfernt. Diese Aktion + kann nicht rückgängig gemacht werden. + + + + Abbrechen + { + e.preventDefault() + handleDelete() + }} + disabled={isDeleting} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {isDeleting ? "Löschen…" : "Löschen"} + + + + +
+ ) +} diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..c94bd72 --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from "@/lib/auth" + +export const { GET, POST } = handlers \ No newline at end of file diff --git a/app/api/privacy-policies/[id]/route.ts b/app/api/privacy-policies/[id]/route.ts new file mode 100644 index 0000000..1fabee1 --- /dev/null +++ b/app/api/privacy-policies/[id]/route.ts @@ -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 } + ) + } +} \ No newline at end of file diff --git a/app/api/privacy-policies/route.ts b/app/api/privacy-policies/route.ts new file mode 100644 index 0000000..e9bc7c3 --- /dev/null +++ b/app/api/privacy-policies/route.ts @@ -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 } + ) + } +} \ No newline at end of file diff --git a/app/api/users/[id]/route.ts b/app/api/users/[id]/route.ts new file mode 100644 index 0000000..c7bb5a8 --- /dev/null +++ b/app/api/users/[id]/route.ts @@ -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 } + ) + } +} \ No newline at end of file diff --git a/app/api/users/route.ts b/app/api/users/route.ts new file mode 100644 index 0000000..1e19a2b --- /dev/null +++ b/app/api/users/route.ts @@ -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 } + ) + } +} \ No newline at end of file diff --git a/app/auth/signin/page.tsx b/app/auth/signin/page.tsx new file mode 100644 index 0000000..2dea4b4 --- /dev/null +++ b/app/auth/signin/page.tsx @@ -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 ( +
+
+ +
+ +
+
+
+
+ +
+
+

+ Syncova Policies +

+

+ Anmeldung zum Admin-Portal +

+
+
+ +
+ {error && ( +
+ + {error} +
+ )} + +
+ + setEmail(e.target.value)} + placeholder="name@firma.de" + required + disabled={isLoading} + /> +
+ +
+ +
+ setPassword(e.target.value)} + placeholder="••••••••" + required + disabled={isLoading} + className="pr-10" + /> + +
+
+ + +
+ +

+ Zugang nur für berechtigte Administratoren +

+
+
+
+ ) +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..ed92dea --- /dev/null +++ b/app/globals.css @@ -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; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..31762ee --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + + + + + ` +} diff --git a/components/providers/session-provider.tsx b/components/providers/session-provider.tsx new file mode 100644 index 0000000..c2c5c48 --- /dev/null +++ b/components/providers/session-provider.tsx @@ -0,0 +1,7 @@ +"use client" + +import { SessionProvider } from "next-auth/react" + +export function AuthProvider({ children }: { children: React.ReactNode }) { + return {children} +} \ No newline at end of file diff --git a/components/theme-toggle.tsx b/components/theme-toggle.tsx new file mode 100644 index 0000000..4bbe5d8 --- /dev/null +++ b/components/theme-toggle.tsx @@ -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 ( + + + + + + + + Design-Modus + + + + {options.map(({ value, label, icon: Icon }) => ( + setTheme(value)}> + + {label} + {theme === value && } + + ))} + + + ) +} diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx new file mode 100644 index 0000000..20fed6e --- /dev/null +++ b/components/ui/accordion.tsx @@ -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) { + return +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + + ) +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..e061efe --- /dev/null +++ b/components/ui/alert-dialog.tsx @@ -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) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + AlertDialogTitle, + AlertDialogTrigger, +} diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000..cf3853d --- /dev/null +++ b/components/ui/avatar.tsx @@ -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) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..6caf20a --- /dev/null +++ b/components/ui/badge.tsx @@ -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 & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..00d0d0d --- /dev/null +++ b/components/ui/button.tsx @@ -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 & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/components/ui/card.tsx b/components/ui/card.tsx new file mode 100644 index 0000000..66e65d9 --- /dev/null +++ b/components/ui/card.tsx @@ -0,0 +1,88 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx new file mode 100644 index 0000000..d3dca94 --- /dev/null +++ b/components/ui/dialog.tsx @@ -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) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Schließen + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +/** Scrollbarer Mittelteil für lange Dialoge – hebt das Padding des Contents auf. */ +function DialogBody({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogBody, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..0c466ad --- /dev/null +++ b/components/ui/dropdown-menu.tsx @@ -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) { + return +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} diff --git a/components/ui/input.tsx b/components/ui/input.tsx new file mode 100644 index 0000000..01e5142 --- /dev/null +++ b/components/ui/input.tsx @@ -0,0 +1,24 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/components/ui/label.tsx b/components/ui/label.tsx new file mode 100644 index 0000000..0fbf1d3 --- /dev/null +++ b/components/ui/label.tsx @@ -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) { + return ( + + ) +} + +export { Label } diff --git a/components/ui/scroll-area.tsx b/components/ui/scroll-area.tsx new file mode 100644 index 0000000..9f9b9b4 --- /dev/null +++ b/components/ui/scroll-area.tsx @@ -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) { + return ( + + + {children} + + + + + ) +} + +function ScrollBar({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { ScrollArea, ScrollBar } diff --git a/components/ui/select.tsx b/components/ui/select.tsx new file mode 100644 index 0000000..a9ef691 --- /dev/null +++ b/components/ui/select.tsx @@ -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) { + return +} + +function SelectGroup({ + ...props +}: React.ComponentProps) { + return +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return +} + +function SelectTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + span]:line-clamp-1", + "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + className + )} + {...props} + > + {children} + + + + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectContent({ + className, + children, + position = "popper", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/components/ui/separator.tsx b/components/ui/separator.tsx new file mode 100644 index 0000000..d120c37 --- /dev/null +++ b/components/ui/separator.tsx @@ -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) { + return ( + + ) +} + +export { Separator } diff --git a/components/ui/skeleton.tsx b/components/ui/skeleton.tsx new file mode 100644 index 0000000..0118624 --- /dev/null +++ b/components/ui/skeleton.tsx @@ -0,0 +1,13 @@ +import { cn } from "@/lib/utils" + +function Skeleton({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Skeleton } diff --git a/components/ui/sonner.tsx b/components/ui/sonner.tsx new file mode 100644 index 0000000..7af6acb --- /dev/null +++ b/components/ui/sonner.tsx @@ -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 ( + + ) +} + +export { Toaster } diff --git a/components/ui/switch.tsx b/components/ui/switch.tsx new file mode 100644 index 0000000..f088c4a --- /dev/null +++ b/components/ui/switch.tsx @@ -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) { + return ( + + + + ) +} + +export { Switch } diff --git a/components/ui/table.tsx b/components/ui/table.tsx new file mode 100644 index 0000000..b31c4f0 --- /dev/null +++ b/components/ui/table.tsx @@ -0,0 +1,107 @@ +"use client" + +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Table({ className, ...props }: React.ComponentProps<"table">) { + return ( +
+ + + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ( + + ) +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + + ) +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", + className + )} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( +
+ ) +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + + ) +} + +function TableCaption({ className, ...props }: React.ComponentProps<"caption">) { + return ( +
+ ) +} + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/components/ui/tabs.tsx b/components/ui/tabs.tsx new file mode 100644 index 0000000..a28a347 --- /dev/null +++ b/components/ui/tabs.tsx @@ -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) { + return ( + + ) +} + +function TabsList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/components/ui/textarea.tsx b/components/ui/textarea.tsx new file mode 100644 index 0000000..cfc5d77 --- /dev/null +++ b/components/ui/textarea.tsx @@ -0,0 +1,22 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { + return ( +