769 lines
28 KiB
TypeScript
769 lines
28 KiB
TypeScript
"use client";
|
||
import { useState } from "react";
|
||
import { useQueryClient } from "@tanstack/react-query";
|
||
import {
|
||
Upload,
|
||
FileSpreadsheet,
|
||
CalendarDays,
|
||
KeyRound,
|
||
Webhook,
|
||
History,
|
||
Plus,
|
||
Copy,
|
||
CheckCircle2,
|
||
AlertCircle,
|
||
Trash2,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
Loader2,
|
||
} from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import { allowed, capable } from "@/lib/rbac";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardHeader,
|
||
CardTitle,
|
||
CardDescription,
|
||
} from "@/components/ui/card";
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
} from "@/components/ui/dialog";
|
||
import { request, useData, Notice, Field, type Me } from "./common";
|
||
import {
|
||
PageHeader,
|
||
EmptyState,
|
||
LoadingRows,
|
||
dateLabel,
|
||
useConfirm,
|
||
} from "./ui";
|
||
type Preview = {
|
||
results: { line: number; ok: boolean; error?: string; preview?: string }[];
|
||
committed: boolean;
|
||
};
|
||
type Integration = {
|
||
hooks: { id: string; name: string; url: string; active: boolean }[];
|
||
tokens: { name: string; expiresAt: string }[];
|
||
deliveries: { id: string; attempts: number; deliveredAt: string | null }[];
|
||
};
|
||
export function Settings() {
|
||
const me = useData<Me>("/api/me");
|
||
const qc = useQueryClient();
|
||
const [csv, setCsv] = useState("");
|
||
const [xlsx, setXlsx] = useState("");
|
||
const [filename, setFilename] = useState("");
|
||
const [preview, setPreview] = useState<Preview | null>(null);
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState<unknown>();
|
||
const [auditPage, setAuditPage] = useState(1);
|
||
const [modal, setModal] = useState<"token" | "webhook" | null>(null);
|
||
const [secret, setSecret] = useState<{ label: string; value: string } | null>(
|
||
null,
|
||
);
|
||
const { confirm, confirmation } = useConfirm();
|
||
const audit = useData<
|
||
{
|
||
id: string;
|
||
action: string;
|
||
actorId: string;
|
||
targetId: string;
|
||
createdAt: string;
|
||
}[]
|
||
>(
|
||
allowed(me.data, "audit.read")
|
||
? `/api/workspace?mode=audit&page=${auditPage}`
|
||
: "/api/me",
|
||
);
|
||
const integrations = useData<Integration>(
|
||
allowed(me.data, "integrations.manage")
|
||
? "/api/workspace?mode=integrations"
|
||
: "/api/me",
|
||
);
|
||
async function run(
|
||
fn: () => Promise<unknown>,
|
||
message = "Änderungen gespeichert",
|
||
) {
|
||
setBusy(true);
|
||
setError(null);
|
||
try {
|
||
const r = (await fn()) as
|
||
| { token?: string; secret?: string; url?: string }
|
||
| undefined;
|
||
if (r?.token || r?.secret || r?.url)
|
||
setSecret({
|
||
label: r.token
|
||
? "API-Token"
|
||
: r.secret
|
||
? "Webhook-Signaturgeheimnis"
|
||
: "Kalenderabonnement",
|
||
value: r.token || r.secret || r.url || "",
|
||
});
|
||
await qc.invalidateQueries();
|
||
toast.success(message);
|
||
setModal(null);
|
||
} catch (e) {
|
||
setError(e);
|
||
toast.error(e instanceof Error ? e.message : "Aktion fehlgeschlagen");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
return (
|
||
<div className="space-y-6">
|
||
{confirmation}
|
||
<PageHeader
|
||
title="Import & Integrationen"
|
||
description="Daten übernehmen, Kalender verbinden und Abläufe nachvollziehen."
|
||
/>
|
||
<Notice error={error} />
|
||
<Tabs defaultValue="import">
|
||
<div className="overflow-x-auto pb-1">
|
||
<TabsList>
|
||
<TabsTrigger value="import">
|
||
<FileSpreadsheet className="mr-2 size-4" />
|
||
Import
|
||
</TabsTrigger>
|
||
<TabsTrigger value="calendar">
|
||
<CalendarDays className="mr-2 size-4" />
|
||
Kalender
|
||
</TabsTrigger>
|
||
{allowed(me.data, "integrations.manage") && (
|
||
<>
|
||
<TabsTrigger value="tokens">
|
||
<KeyRound className="mr-2 size-4" />
|
||
API-Tokens
|
||
</TabsTrigger>
|
||
<TabsTrigger value="hooks">
|
||
<Webhook className="mr-2 size-4" />
|
||
Webhooks
|
||
</TabsTrigger>
|
||
</>
|
||
)}
|
||
{allowed(me.data, "audit.read") && (
|
||
<TabsTrigger value="audit">
|
||
<History className="mr-2 size-4" />
|
||
Protokoll
|
||
</TabsTrigger>
|
||
)}
|
||
</TabsList>
|
||
</div>
|
||
<TabsContent value="import" className="space-y-5 pt-4">
|
||
<Card className="max-w-4xl shadow-none">
|
||
<CardHeader>
|
||
<CardTitle className="text-lg">Aufgaben importieren</CardTitle>
|
||
<CardDescription>
|
||
CSV- oder Excel-Datei auswählen, Vorschau prüfen und
|
||
anschließend übernehmen.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-5">
|
||
<label className="flex cursor-pointer flex-col items-center rounded-xl border border-dashed bg-muted/20 p-10 text-center hover:bg-muted/40">
|
||
<Upload className="mb-3 size-7 text-primary" />
|
||
<span className="text-sm font-medium">
|
||
{filename || "CSV- oder Excel-Datei auswählen"}
|
||
</span>
|
||
<span className="mt-1 text-xs text-muted-foreground">
|
||
Bis 100 Aufgaben · maximal 100 KB
|
||
</span>
|
||
<input
|
||
aria-label="Importdatei"
|
||
className="sr-only"
|
||
type="file"
|
||
accept=".csv,.xlsx"
|
||
onChange={async (e) => {
|
||
const f = e.target.files?.[0];
|
||
if (!f) return;
|
||
if (f.size > 100000) {
|
||
toast.error("Die Datei darf höchstens 100 KB groß sein.");
|
||
return;
|
||
}
|
||
setFilename(f.name);
|
||
setPreview(null);
|
||
if (f.name.toLowerCase().endsWith(".xlsx")) {
|
||
const bytes = new Uint8Array(await f.arrayBuffer());
|
||
setXlsx(
|
||
btoa(
|
||
Array.from(bytes, (b) => String.fromCharCode(b)).join(
|
||
"",
|
||
),
|
||
),
|
||
);
|
||
setCsv("");
|
||
} else {
|
||
setCsv(await f.text());
|
||
setXlsx("");
|
||
}
|
||
}}
|
||
/>
|
||
</label>
|
||
<details className="rounded-lg border p-4">
|
||
<summary className="cursor-pointer text-sm font-medium">
|
||
CSV direkt einfügen & Dateiformat
|
||
</summary>
|
||
<p className="my-3 text-xs leading-relaxed text-muted-foreground">
|
||
UTF-8 mit Semikolon. Verwenden Sie einen Aufgabenexport als
|
||
Vorlage. Spalten: wo, was, bisWann, owningGroupId, assigneeId,
|
||
priority, project, tags. Excel: erste Tabelle, keine Formeln.
|
||
</p>
|
||
<Textarea
|
||
aria-label="CSV-Inhalt"
|
||
rows={6}
|
||
value={csv}
|
||
placeholder="CSV-Inhalt hier einfügen…"
|
||
onChange={(e) => {
|
||
setCsv(e.target.value);
|
||
setXlsx("");
|
||
setFilename("");
|
||
setPreview(null);
|
||
}}
|
||
/>
|
||
</details>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button
|
||
disabled={
|
||
busy || (!csv && !xlsx) || !capable(me.data, "tasks.create")
|
||
}
|
||
variant={preview ? "outline" : "default"}
|
||
onClick={() =>
|
||
run(async () => {
|
||
setPreview(
|
||
await request("/api/transfer", {
|
||
...(xlsx ? { xlsx } : { csv }),
|
||
commit: false,
|
||
}),
|
||
);
|
||
}, "Vorschau erstellt")
|
||
}
|
||
>
|
||
{busy ? (
|
||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||
) : (
|
||
<FileSpreadsheet className="mr-2 size-4" />
|
||
)}
|
||
Vorschau prüfen
|
||
</Button>
|
||
<Button
|
||
disabled={
|
||
busy ||
|
||
!preview ||
|
||
preview.committed ||
|
||
preview.results.some((r) => !r.ok)
|
||
}
|
||
onClick={() =>
|
||
run(async () => {
|
||
setPreview(
|
||
await request("/api/transfer", {
|
||
...(xlsx ? { xlsx } : { csv }),
|
||
commit: true,
|
||
}),
|
||
);
|
||
}, "Import abgeschlossen")
|
||
}
|
||
>
|
||
<Upload className="mr-2 size-4" />
|
||
Import ausführen
|
||
</Button>
|
||
</div>
|
||
{preview && (
|
||
<div className="overflow-hidden rounded-lg border">
|
||
<p className="border-b bg-muted/40 px-4 py-3 text-sm font-medium">
|
||
{preview.committed
|
||
? "Importergebnis"
|
||
: "Vorschau – noch nichts angelegt"}
|
||
</p>
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>Zeile</TableHead>
|
||
<TableHead>Prüfung</TableHead>
|
||
<TableHead>Aufgabe / Hinweis</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{preview.results.map((r) => (
|
||
<TableRow key={r.line}>
|
||
<TableCell>{r.line}</TableCell>
|
||
<TableCell>
|
||
{r.ok ? (
|
||
<CheckCircle2
|
||
className="size-4 text-emerald-600"
|
||
aria-label="Gültig"
|
||
/>
|
||
) : (
|
||
<AlertCircle
|
||
className="size-4 text-destructive"
|
||
aria-label="Fehler"
|
||
/>
|
||
)}
|
||
</TableCell>
|
||
<TableCell className="text-sm">
|
||
{r.ok ? r.preview || "Erfolgreich" : r.error}
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
<TabsContent value="calendar" className="pt-4">
|
||
<Card className="max-w-2xl shadow-none">
|
||
<CardHeader>
|
||
<div className="mb-3 flex size-11 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||
<CalendarDays className="size-5" />
|
||
</div>
|
||
<CardTitle className="text-lg">
|
||
Aufgaben im eigenen Kalender
|
||
</CardTitle>
|
||
<CardDescription>
|
||
Abonnieren Sie Ihre freigegebenen Aufgaben in Outlook, Apple
|
||
Kalender oder einem anderen Kalenderprogramm.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-5">
|
||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||
Der private Link gilt 90 Tage und berücksichtigt Ihre aktuellen
|
||
Aufgabenrechte. Behandeln Sie ihn wie ein Passwort.
|
||
</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button
|
||
disabled={busy || !capable(me.data, "tasks.export")}
|
||
onClick={() =>
|
||
run(
|
||
() =>
|
||
request("/api/workspace", { action: "calendarToken" }),
|
||
"Kalenderlink erstellt",
|
||
)
|
||
}
|
||
>
|
||
<Plus className="mr-2 size-4" />
|
||
Abonnement-Link erstellen
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
disabled={busy}
|
||
onClick={async () => {
|
||
if (
|
||
await confirm(
|
||
"Kalenderlinks widerrufen?",
|
||
"Bestehende Kalenderabonnements können danach keine Aufgaben mehr abrufen.",
|
||
"Ja, widerrufen",
|
||
)
|
||
)
|
||
run(
|
||
() =>
|
||
request("/api/workspace", {
|
||
action: "revokeCalendar",
|
||
}),
|
||
"Kalenderlinks widerrufen",
|
||
);
|
||
}}
|
||
>
|
||
Alle Links widerrufen
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
{allowed(me.data, "integrations.manage") && (
|
||
<>
|
||
<TabsContent value="tokens" className="space-y-5 pt-4">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div>
|
||
<h2 className="font-semibold">API-Tokens</h2>
|
||
<p className="mt-1 text-sm text-muted-foreground">
|
||
Lesender Zugriff innerhalb Ihrer aktuellen Berechtigungen.
|
||
</p>
|
||
</div>
|
||
<Button onClick={() => setModal("token")}>
|
||
<Plus className="mr-2 size-4" />
|
||
Token erstellen
|
||
</Button>
|
||
</div>
|
||
<Notice error={integrations.error} />
|
||
<div className="rounded-xl border">
|
||
{integrations.data?.tokens?.length ? (
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow className="bg-muted/40">
|
||
<TableHead>Name</TableHead>
|
||
<TableHead>Gültig bis</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{integrations.data.tokens.map((t, i) => (
|
||
<TableRow key={i}>
|
||
<TableCell className="py-4 font-medium">
|
||
{t.name.startsWith("calendar:")
|
||
? "Kalenderabonnement"
|
||
: t.name}
|
||
</TableCell>
|
||
<TableCell>{dateLabel(t.expiresAt)}</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
) : (
|
||
<EmptyState
|
||
title="Noch keine Tokens"
|
||
description="Erstellen Sie einen Token für eine externe Anwendung."
|
||
/>
|
||
)}
|
||
</div>
|
||
<Button
|
||
variant="outline"
|
||
className="text-destructive"
|
||
disabled={busy || !integrations.data?.tokens?.length}
|
||
onClick={async () => {
|
||
if (
|
||
await confirm(
|
||
"Alle API-Tokens widerrufen?",
|
||
"Verbundene Anwendungen und Kalenderabonnements verlieren ihren Zugriff.",
|
||
"Ja, widerrufen",
|
||
)
|
||
)
|
||
run(
|
||
() =>
|
||
request("/api/workspace", { action: "revokeTokens" }),
|
||
"Tokens widerrufen",
|
||
);
|
||
}}
|
||
>
|
||
<Trash2 className="mr-2 size-4" />
|
||
Alle eigenen Tokens widerrufen
|
||
</Button>
|
||
</TabsContent>
|
||
<TabsContent value="hooks" className="space-y-5 pt-4">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div>
|
||
<h2 className="font-semibold">Webhooks</h2>
|
||
<p className="mt-1 text-sm text-muted-foreground">
|
||
Signierte Ereignisse an freigegebene HTTPS-Adressen senden.
|
||
</p>
|
||
</div>
|
||
<Button onClick={() => setModal("webhook")}>
|
||
<Plus className="mr-2 size-4" />
|
||
Webhook anlegen
|
||
</Button>
|
||
</div>
|
||
<div className="rounded-xl border">
|
||
{integrations.data?.hooks?.length ? (
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow className="bg-muted/40">
|
||
<TableHead>Verbindung</TableHead>
|
||
<TableHead>Status</TableHead>
|
||
<TableHead />
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{integrations.data.hooks.map((h) => (
|
||
<TableRow key={h.id}>
|
||
<TableCell className="py-4">
|
||
<p className="font-medium">{h.name}</p>
|
||
<p className="mt-1 max-w-sm truncate text-xs text-muted-foreground">
|
||
{h.url}
|
||
</p>
|
||
</TableCell>
|
||
<TableCell>
|
||
<Badge variant={h.active ? "secondary" : "outline"}>
|
||
{h.active ? "Aktiv" : "Deaktiviert"}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell>
|
||
{h.active && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
disabled={busy}
|
||
onClick={async () => {
|
||
if (
|
||
await confirm(
|
||
"Webhook deaktivieren?",
|
||
`„${h.name}“ erhält keine weiteren Ereignisse.`,
|
||
"Ja, deaktivieren",
|
||
)
|
||
)
|
||
run(() =>
|
||
request("/api/workspace", {
|
||
action: "disableWebhook",
|
||
id: h.id,
|
||
}),
|
||
);
|
||
}}
|
||
>
|
||
Deaktivieren
|
||
</Button>
|
||
)}
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
) : (
|
||
<EmptyState
|
||
title="Keine Webhooks eingerichtet"
|
||
description="Lassen Sie den Zielhost vor dem Anlegen vom Betreiber freigeben."
|
||
/>
|
||
)}
|
||
</div>
|
||
<div className="flex gap-4 text-sm text-muted-foreground">
|
||
<span className="flex items-center gap-1.5">
|
||
<CheckCircle2 className="size-4 text-emerald-600" />
|
||
{integrations.data?.deliveries?.filter((d) => d.deliveredAt)
|
||
.length || 0}{" "}
|
||
zugestellt
|
||
</span>
|
||
<span className="flex items-center gap-1.5">
|
||
<AlertCircle className="size-4" />
|
||
{integrations.data?.deliveries?.filter(
|
||
(d) => !d.deliveredAt && d.attempts >= 5,
|
||
).length || 0}{" "}
|
||
fehlgeschlagen
|
||
</span>
|
||
<span>
|
||
{integrations.data?.deliveries?.filter(
|
||
(d) => !d.deliveredAt && d.attempts < 5,
|
||
).length || 0}{" "}
|
||
ausstehend
|
||
</span>
|
||
</div>
|
||
<p className="text-xs text-muted-foreground">
|
||
Letzte Zustellungen. Fehlgeschlagene Versuche werden bis zu
|
||
fünfmal wiederholt.
|
||
</p>
|
||
</TabsContent>
|
||
</>
|
||
)}
|
||
{allowed(me.data, "audit.read") && (
|
||
<TabsContent value="audit" className="space-y-4 pt-4">
|
||
<h2 className="font-semibold">Aktivitätsprotokoll</h2>
|
||
<p className="text-sm text-muted-foreground">
|
||
Änderungen an Aufgaben, Benutzern und Berechtigungen.
|
||
</p>
|
||
<Notice error={audit.error} />
|
||
<div className="overflow-hidden rounded-xl border">
|
||
{audit.isLoading ? (
|
||
<LoadingRows />
|
||
) : (
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow className="bg-muted/40">
|
||
<TableHead>Zeitpunkt</TableHead>
|
||
<TableHead>Aktion</TableHead>
|
||
<TableHead>Benutzer</TableHead>
|
||
<TableHead>Objekt</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{Array.isArray(audit.data) &&
|
||
audit.data.map((a) => (
|
||
<TableRow key={a.id}>
|
||
<TableCell className="whitespace-nowrap text-xs">
|
||
{new Date(a.createdAt).toLocaleString("de-DE")}
|
||
</TableCell>
|
||
<TableCell className="text-sm">{a.action}</TableCell>
|
||
<TableCell
|
||
className="max-w-40 truncate text-xs text-muted-foreground"
|
||
title={a.actorId}
|
||
>
|
||
{a.actorId}
|
||
</TableCell>
|
||
<TableCell
|
||
className="max-w-40 truncate text-xs text-muted-foreground"
|
||
title={a.targetId}
|
||
>
|
||
{a.targetId}
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs text-muted-foreground">
|
||
Seite {auditPage}
|
||
</span>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={auditPage === 1}
|
||
onClick={() => setAuditPage(auditPage - 1)}
|
||
>
|
||
<ChevronLeft className="mr-1 size-4" />
|
||
Zurück
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={
|
||
!Array.isArray(audit.data) || audit.data.length < 100
|
||
}
|
||
onClick={() => setAuditPage(auditPage + 1)}
|
||
>
|
||
Weiter
|
||
<ChevronRight className="ml-1 size-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</TabsContent>
|
||
)}
|
||
</Tabs>
|
||
<Dialog
|
||
open={!!modal}
|
||
onOpenChange={(v) => {
|
||
if (!v && !busy) setModal(null);
|
||
}}
|
||
>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>
|
||
{modal === "token" ? "API-Token erstellen" : "Webhook anlegen"}
|
||
</DialogTitle>
|
||
<DialogDescription>
|
||
{modal === "token"
|
||
? "Der Token wird nur einmal angezeigt. Bewahren Sie ihn sicher auf."
|
||
: "Nur durch den Betreiber freigegebene HTTPS-Hosts sind erlaubt."}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<form
|
||
className="space-y-4"
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
const f = new FormData(e.currentTarget);
|
||
run(
|
||
() =>
|
||
request(
|
||
"/api/workspace",
|
||
modal === "token"
|
||
? {
|
||
action: "token",
|
||
name: f.get("name"),
|
||
days: Number(f.get("days")),
|
||
permissions: [
|
||
"tasks.read",
|
||
"tasks.export",
|
||
"comments.read",
|
||
"files.read",
|
||
].filter((p) => capable(me.data, p)),
|
||
}
|
||
: {
|
||
action: "webhook",
|
||
name: f.get("name"),
|
||
url: f.get("url"),
|
||
},
|
||
),
|
||
modal === "token" ? "Token erstellt" : "Webhook angelegt",
|
||
);
|
||
}}
|
||
>
|
||
<Field label="Name">
|
||
<Input
|
||
required
|
||
name="name"
|
||
maxLength={100}
|
||
placeholder={
|
||
modal === "token"
|
||
? "Zum Beispiel Berichtssystem"
|
||
: "Zum Beispiel Team-Benachrichtigungen"
|
||
}
|
||
/>
|
||
</Field>
|
||
{modal === "token" ? (
|
||
<Field label="Gültigkeit in Tagen">
|
||
<Input
|
||
type="number"
|
||
name="days"
|
||
min={1}
|
||
max={90}
|
||
defaultValue={30}
|
||
/>
|
||
</Field>
|
||
) : (
|
||
<Field label="HTTPS-Adresse">
|
||
<Input required type="url" name="url" placeholder="https://…" />
|
||
</Field>
|
||
)}
|
||
<Notice error={error} />
|
||
<DialogFooter>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
disabled={busy}
|
||
onClick={() => setModal(null)}
|
||
>
|
||
Abbrechen
|
||
</Button>
|
||
<Button disabled={busy}>
|
||
{busy && <Loader2 className="mr-2 size-4 animate-spin" />}
|
||
Erstellen
|
||
</Button>
|
||
</DialogFooter>
|
||
</form>
|
||
</DialogContent>
|
||
</Dialog>
|
||
<Dialog
|
||
open={!!secret}
|
||
onOpenChange={(v) => {
|
||
if (!v) setSecret(null);
|
||
}}
|
||
>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>{secret?.label}</DialogTitle>
|
||
<DialogDescription>
|
||
Kopieren Sie diesen Wert jetzt. Er wird nicht erneut angezeigt.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<Textarea
|
||
aria-label="Geheimnis"
|
||
readOnly
|
||
value={secret?.value || ""}
|
||
className="break-all font-mono text-xs"
|
||
/>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setSecret(null)}>
|
||
Schließen
|
||
</Button>
|
||
<Button
|
||
onClick={async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(secret?.value || "");
|
||
toast.success("In die Zwischenablage kopiert");
|
||
} catch {
|
||
toast.error("Bitte den Wert markieren und manuell kopieren.");
|
||
}
|
||
}}
|
||
>
|
||
<Copy className="mr-2 size-4" />
|
||
Kopieren
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|