since3-portal/modules/admin/ui/components/internal-credentials-table.tsx
2026-10-11 18:22:32 +02:00

297 lines
14 KiB
TypeScript

"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { CheckIcon, CopyIcon, ExternalLinkIcon, EyeIcon, EyeOffIcon, PencilIcon, SearchIcon, Trash2Icon } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils"
import { deleteInternalCredential, revealInternalCredential } from "@/modules/admin/actions/internal-credentials"
import { formatCost, type Plan } from "@/modules/admin/lib/internal-costs"
import type { InternalCredential } from "@/modules/admin/queries/internal-credentials"
import { getServiceById } from "@/modules/shared/data/services-data"
import { ServiceLogo } from "@/modules/shared/ui/components/service-logo"
import { InternalCredentialDialog } from "./internal-credential-dialog"
const HIDE_AFTER_MS = 30_000
const FILTERS: { value: Plan | "all"; label: string }[] = [
{ value: "all", label: "Alle" },
{ value: "paid", label: "Bezahlt" },
{ value: "free", label: "Kostenlos" },
]
async function fetchPassword(id: string) {
const result = await revealInternalCredential(id)
if ("error" in result) throw new Error(result.error)
return result.password
}
// Kopiert Text; ist er noch nicht geladen, wartet ClipboardItem auf ihn (sonst blockt Safari nach dem await)
function CopyButton({ label, getText }: { label: string; getText: () => string | Promise<string> }) {
const [copied, setCopied] = useState(false)
async function copy() {
try {
const text = getText()
if (typeof text === "string") {
await navigator.clipboard.writeText(text)
} else {
const blob = text.then((value) => new Blob([value], { type: "text/plain" }))
await navigator.clipboard.write([new ClipboardItem({ "text/plain": blob })])
}
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
// Abgebrochen oder vom Browser nicht erlaubt
}
}
return (
<Button type="button" variant="ghost" size="icon-sm" onClick={copy} aria-label={`${label} kopieren`} title={`${label} kopieren`}>
{copied ? <CheckIcon className="size-4 text-signal-success" /> : <CopyIcon className="size-4" />}
</Button>
)
}
function SecretCell({ id }: { id: string }) {
const [password, setPassword] = useState<string | null>(null)
const [pending, setPending] = useState(false)
// Angezeigtes Passwort nach 30 Sekunden wieder verbergen
useEffect(() => {
if (password === null) return
const timer = setTimeout(() => setPassword(null), HIDE_AFTER_MS)
return () => clearTimeout(timer)
}, [password])
async function toggle() {
if (password !== null) {
setPassword(null)
return
}
setPending(true)
try {
setPassword(await fetchPassword(id))
} catch {
// Fehler: Passwort bleibt verborgen
} finally {
setPending(false)
}
}
return (
<div className="flex items-center gap-1">
<span className="font-mono text-small break-all sm:min-w-32">{password ?? "••••••••"}</span>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={toggle}
disabled={pending}
aria-label={password !== null ? "Passwort verbergen" : "Passwort anzeigen"}
>
{password !== null ? <EyeOffIcon className="size-4" /> : <EyeIcon className="size-4" />}
</Button>
<CopyButton label="Passwort" getText={() => password ?? fetchPassword(id)} />
</div>
)
}
function CostLabel({ credential }: { credential: InternalCredential }) {
return credential.plan === "paid" ? (
<span className="tabular-nums whitespace-nowrap">{formatCost(credential)}</span>
) : (
<Badge variant="neutral">Kostenlos</Badge>
)
}
function DeleteDialog({ id, name }: { id: string; name: string }) {
const [open, setOpen] = useState(false)
const [pending, setPending] = useState(false)
const [error, setError] = useState<string | null>(null)
const router = useRouter()
async function handleDelete() {
setPending(true)
const result = await deleteInternalCredential(id)
setPending(false)
if (result && "error" in result) {
setError(result.error)
return
}
setOpen(false)
router.refresh()
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button variant="ghost" size="icon-sm" aria-label="Löschen" />}>
<Trash2Icon className="size-4" />
</DialogTrigger>
<DialogContent>
<DialogHeader className="mb-4">
<DialogTitle>Zugang löschen</DialogTitle>
<DialogDescription>
Möchtest du den Zugang <span className="font-medium text-foreground">{name}</span> wirklich löschen?
Das Passwort wird dabei auch aus dem Vault entfernt.
</DialogDescription>
</DialogHeader>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={pending}>
Abbrechen
</Button>
<Button type="button" variant="destructive" onClick={handleDelete} disabled={pending}>
{pending ? "Wird gelöscht..." : "Löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export function InternalCredentialsTable({ credentials }: { credentials: InternalCredential[] }) {
const [query, setQuery] = useState("")
const [filter, setFilter] = useState<Plan | "all">("all")
const term = query.trim().toLowerCase()
const visible = credentials.filter(
(credential) =>
(filter === "all" || credential.plan === filter) &&
(!term ||
[credential.title, getServiceById(credential.service).label, credential.username, credential.url]
.join(" ")
.toLowerCase()
.includes(term))
)
if (credentials.length === 0) {
return (
<p className="rounded-lg border border-border bg-surface/50 p-8 text-center text-muted-foreground">
Noch keine internen Zugänge hinterlegt.
</p>
)
}
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex gap-1 rounded-lg border p-1">
{FILTERS.map((option) => (
<button
key={option.value}
type="button"
aria-pressed={filter === option.value}
onClick={() => setFilter(option.value)}
className={cn(
"cursor-pointer rounded-md px-3 py-1.5 text-sm transition-colors",
filter === option.value ? "bg-surface font-medium" : "text-muted-foreground hover:text-foreground"
)}
>
{option.label}
</button>
))}
</div>
<label className="relative sm:w-80">
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Suchen, z. B. Vercel"
aria-label="Zugänge durchsuchen"
className="pl-9"
/>
</label>
</div>
<div className="overflow-hidden rounded-lg border border-border">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Zugang</TableHead>
<TableHead className="hidden md:table-cell">Benutzername</TableHead>
<TableHead>Passwort</TableHead>
<TableHead className="hidden lg:table-cell">Kosten</TableHead>
<TableHead className="w-20 sm:w-24" />
</TableRow>
</TableHeader>
<TableBody>
{visible.map((credential) => {
const service = getServiceById(credential.service)
const name = credential.title.trim() || service.label
return (
<TableRow key={credential.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-background">
<ServiceLogo serviceId={credential.service} size={18} className="size-4.5 object-contain" />
</div>
<div className="flex min-w-0 flex-col">
<span className="flex items-center gap-1.5 font-medium">
{name}
{credential.url && (
<a
href={credential.url}
target="_blank"
rel="noreferrer"
aria-label={`${name} öffnen`}
className="text-muted-foreground transition-colors hover:text-foreground"
>
<ExternalLinkIcon className="size-3.5" />
</a>
)}
</span>
{credential.title.trim() && <span className="text-small text-muted-foreground">{service.label}</span>}
<span className="text-small text-muted-foreground break-all md:hidden">{credential.username}</span>
<span className="mt-1 text-small lg:hidden">
<CostLabel credential={credential} />
</span>
</div>
</div>
</TableCell>
<TableCell className="hidden md:table-cell">
<div className="flex items-center gap-1">
<span className="text-muted-foreground break-all">{credential.username}</span>
<CopyButton label="Benutzername" getText={() => credential.username} />
</div>
</TableCell>
<TableCell>
<SecretCell id={credential.id} />
</TableCell>
<TableCell className="hidden lg:table-cell">
<CostLabel credential={credential} />
</TableCell>
<TableCell>
<div className="flex justify-end gap-1">
<InternalCredentialDialog
credential={credential}
trigger={
<Button variant="ghost" size="icon-sm" aria-label="Bearbeiten">
<PencilIcon className="size-4" />
</Button>
}
/>
<DeleteDialog id={credential.id} name={name} />
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
{visible.length === 0 && <p className="text-center text-sm text-muted-foreground">Kein Zugang gefunden.</p>}
</div>
)
}