All checks were successful
Container-Image bauen und veröffentlichen / build-and-push (push) Successful in 1m23s
Die öffentliche API gab bei ?admin=true alle Datensätze heraus, auch die nicht veröffentlichten: von 117 Erklärungen waren 112 Entwürfe, die jeder Besucher abrufen konnte. Der Parameter setzt jetzt eine Admin-Sitzung voraus, sonst kommt die öffentliche Liste. Der Einzelabruf behandelt Entwürfe wie nicht vorhanden, damit sich über die fortlaufende ID nicht abklopfen lässt, welche Datensätze existieren. Markdown wurde mit sanitize: false gerendert und per dangerouslySetInnerHTML eingesetzt. Das Standard-Schema lässt jetzt Überschriften, Listen, Tabellen und http(s)/mailto-Links durch und entfernt Skripte, Event-Attribute, eingebettete Rahmen und javascript:-Ziele. Der Titel in der Druckfassung war an drei Stellen unmaskiert. Standardtexte - Zehn Bausteine, die für alle Erklärungen gelten, werden einmal zentral gepflegt. In der Erklärung steht nur eine Abweichung; ein leeres Feld bedeutet "folgt dem Standard". Ein Wechsel in der Amtsleitung wirkt damit sofort auf alle Erklärungen, die nicht bewusst abweichen. - Die Migration übernimmt den häufigsten Bestandswert als Standard und setzt übereinstimmende Felder auf NULL. Von 117 Erklärungen folgen danach 116 dem Standard, eine weicht ab (Online-Verfahren mit ZIT-SH als zusätzlichem Verantwortlichen). Pflichtangaben nach Art. 13/14 DSGVO - Neue Felder für Drittlandübermittlung (Art. 13 Abs. 1 lit. f), automatisierte Entscheidungsfindung (Art. 13 Abs. 2 lit. f), Datenkategorien (Art. 14 Abs. 1 lit. d) und berechtigte Interessen (Art. 13 Abs. 1 lit. d). Auf die ersten beiden muss auch dann hingewiesen werden, wenn es sie nicht gibt, deshalb sind sie Standardtexte mit ausdrücklichem "findet nicht statt". - Der Hinweis auf das Widerspruchsrecht steht nach Art. 21 Abs. 4 in einem eigenen, abgesetzten Kasten vor allen Abschnitten, nicht als Zeile im Fließtext der Betroffenenrechte. - Zweck, Rechtsgrundlage und Speicherdauer sind Pflicht. Geprüft wird beim Veröffentlichen, nicht beim Speichern: Ein Entwurf darf unvollständig sein, eine öffentlich sichtbare Erklärung nicht. So bleiben Bestandsdaten bearbeitbar, ohne dass die Anwendung Angaben erfindet, die nur die Fachabteilung kennt. Die Admin-Übersicht markiert unvollständige Erklärungen und nennt die fehlenden Angaben. Öffentliche Seiten - Jede Erklärung hat eine dauerhafte Adresse /erklaerung/<bezeichner>, serverseitig gerendert, zum Verlinken aus Antragsformularen. Der Bezeichner wird aus dem Titel abgeleitet und bleibt beim Bearbeiten erhalten, solange der Titel gleich bleibt. Entwürfe liefern 404. - Impressum, Datenschutzerklärung des Portals und Erklärung zur Barrierefreiheit werden im Admin als Markdown gepflegt und in der Fußzeile verlinkt. Die Fußzeile rendert serverseitig, weil das Impressum ohne JavaScript erreichbar sein muss. Ein leerer Text nimmt die Seite vom Netz, statt eine leere Seite auszuliefern. - Die Übersicht nutzt echte Links statt Klick-Handler und wird serverseitig vorbefüllt. APP_COMPANY_NAME ersetzt den Namen der Anwendung in Kopf- und Fußzeile, Seitentiteln, Anmeldeseite, Druckfassung und Startmeldung des Containers. Der Wert wird zur Laufzeit gelesen, ein Neubau des Images ist zum Umbenennen nicht nötig. Getestet gegen die 117 Bestandsdatensätze: Migrationen angewendet, der angezeigte Text blieb bei allen 117 x 7 Baustein-Feldern identisch. Alle 117 Bezeichner sind eindeutig und stimmen mit lib/slug.ts überein (SQLites LOWER() kennt nur ASCII, Großumlaute werden deshalb vorher ersetzt). ?admin=true liefert ohne Anmeldung 5 statt 117 Datensätze, Entwürfe 404. Über die API mit Anmeldung geprüft: unvollständig als Entwurf wird angelegt, dieselben Angaben mit isPublic abgelehnt, ebenso fehlende Datenkategorien bei angegebener Herkunft. Ohne APP_COMPANY_NAME erscheint "Syncova Policies", mit gesetztem Wert kein einziges Vorkommen mehr. Die Änderungen liegen in einem Commit, weil Schema, API-Routen und Formulare von allen Teilen berührt werden und eine thematische Aufteilung nicht lauffähige Zwischenstände ergäbe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
564 lines
20 KiB
TypeScript
564 lines
20 KiB
TypeScript
"use client"
|
||
|
||
import { useMemo, useState } from "react"
|
||
import {
|
||
ArrowDown,
|
||
ArrowUp,
|
||
Eye,
|
||
EyeOff,
|
||
FileText,
|
||
FileType2,
|
||
Scale,
|
||
LogOut,
|
||
MoreHorizontal,
|
||
Pencil,
|
||
Plus,
|
||
Search,
|
||
TriangleAlert,
|
||
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 { PolicyDefaultsForm } from "@/components/policy-defaults-form"
|
||
import { PolicyForm } from "@/components/policy-form"
|
||
import { SitePagesForm } from "@/components/site-pages-form"
|
||
import { ThemeToggle } from "@/components/theme-toggle"
|
||
import { UserManagement } from "@/components/user-management"
|
||
import { deletePolicy, usePrivacyPolicies } from "@/hooks/use-privacy-policies"
|
||
import { usePolicyDefaults } from "@/hooks/use-policy-defaults"
|
||
import { describeMissing, missingRequiredFields } from "@/lib/policy-completeness"
|
||
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 { defaults } = usePolicyDefaults()
|
||
|
||
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 [isDefaultsOpen, setIsDefaultsOpen] = useState(false)
|
||
const [isSitePagesOpen, setIsSitePagesOpen] = 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
|
||
|
||
/**
|
||
* Fehlende Pflichtangaben nach Art. 13/14 DSGVO je Erklärung.
|
||
*
|
||
* Die API liefert die Bausteine bereits mit eingesetztem Standardtext – was
|
||
* hier als fehlend gilt, fehlt also tatsächlich und nicht nur im Datensatz.
|
||
*/
|
||
const missingByPolicy = useMemo(() => {
|
||
const map = new Map<number, string[]>()
|
||
|
||
for (const policy of policies) {
|
||
const missing = missingRequiredFields(
|
||
policy as unknown as Record<string, unknown>,
|
||
defaults
|
||
)
|
||
if (missing.length > 0) map.set(policy.id, missing.map(describeMissing))
|
||
}
|
||
|
||
return map
|
||
}, [policies, defaults])
|
||
|
||
const incompleteCount = missingByPolicy.size
|
||
|
||
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={() => setIsDefaultsOpen(true)}
|
||
>
|
||
<FileType2 />
|
||
<span className="sr-only">Standardtexte</span>
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>Standardtexte</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={() => setIsDefaultsOpen(true)}>
|
||
<FileType2 />
|
||
Standardtexte pflegen
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem onSelect={() => setIsSitePagesOpen(true)}>
|
||
<Scale />
|
||
Rechtliche Seiten
|
||
</DropdownMenuItem>
|
||
<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
|
||
{incompleteCount > 0 && (
|
||
<>
|
||
{" · "}
|
||
<span className="text-amber-600 dark:text-amber-500">
|
||
{incompleteCount} unvollständig
|
||
</span>
|
||
</>
|
||
)}
|
||
</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>
|
||
<div className="flex flex-wrap items-center gap-1.5">
|
||
{policy.isPublic ? (
|
||
<Badge variant="success">
|
||
<Eye />
|
||
Öffentlich
|
||
</Badge>
|
||
) : (
|
||
<Badge variant="outline">
|
||
<EyeOff />
|
||
Privat
|
||
</Badge>
|
||
)}
|
||
|
||
{missingByPolicy.has(policy.id) && (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Badge
|
||
variant="outline"
|
||
className="cursor-help border-amber-500/40 text-amber-600 dark:text-amber-500"
|
||
>
|
||
<TriangleAlert />
|
||
{missingByPolicy.get(policy.id)!.length}
|
||
</Badge>
|
||
</TooltipTrigger>
|
||
<TooltipContent className="max-w-xs">
|
||
<p className="mb-1 font-medium">
|
||
Fehlende Pflichtangaben
|
||
</p>
|
||
<ul className="space-y-0.5">
|
||
{missingByPolicy.get(policy.id)!.map((text) => (
|
||
<li key={text}>· {text}</li>
|
||
))}
|
||
</ul>
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
)}
|
||
</div>
|
||
</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}
|
||
/>
|
||
|
||
<PolicyDefaultsForm open={isDefaultsOpen} onOpenChange={setIsDefaultsOpen} />
|
||
|
||
<SitePagesForm open={isSitePagesOpen} onOpenChange={setIsSitePagesOpen} />
|
||
|
||
<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>
|
||
)
|
||
}
|