mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 19:51:17 +00:00
297 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|