taskmanager/src/components/workspace/settings.tsx

769 lines
28 KiB
TypeScript
Raw 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 { 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>
);
}