syncova-policies/app/admin/page.tsx
Weapie 265ca8ac38
All checks were successful
Container-Image bauen und veröffentlichen / build-and-push (push) Successful in 5m15s
Improve accessibility and access controls; verify production and database recovery
2026-09-14 11:44:41 +02:00

576 lines
21 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 { compareByTitle } from "@/lib/policy-sort"
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("")
// Vorgabe: alphabetisch nach Titel. Die Spaltenköpfe bleiben umschaltbar.
const [sortBy, setSortBy] = useState<SortKey>("title")
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc")
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" ? compareByTitle(a, b) : compareByTitle(b, a)
}
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 min-h-14 flex-wrap py-2 max-w-7xl items-center justify-between gap-2 px-4 sm:gap-4 sm: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>
{session?.user.role === "SUPER_ADMIN" && (
<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" aria-label="Benutzermenü">
<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>
{session?.user.role === "SUPER_ADMIN" && <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 id="inhalt" tabIndex={-1} className="mx-auto max-w-7xl px-4 py-6 sm:px-6 sm: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-700 dark:text-amber-400">
{incompleteCount} unvollständig
</span>
</>
)}
</p>
</div>
<div className="flex w-full items-center gap-2 sm:w-auto">
<div className="min-w-0 flex-1 sm:w-64 sm:flex-none">
<label htmlFor="admin-suche" className="sr-only">
Erklärungen nach Titel oder Verantwortlichem durchsuchen
</label>
<div className="relative">
<Search
aria-hidden="true"
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
id="admin-suche"
type="search"
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 aria-hidden="true" />
<span className="sr-only">Suche zurücksetzen</span>
</Button>
)}
</div>
</div>
<Button onClick={handleAddNew} className="shrink-0">
<Plus aria-hidden="true" />
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-700 dark:text-amber-400"
>
<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>
)
}