Eine Leiste mit allen Repositories des Projekts, sobald es mehr als eines gibt - bei einem einzigen waere eine Leiste mit genau einem Knopf nur Ballast. Jedes hat eigene Kennzahlen, Listen, CI und eigenen Abgleich. Verknuepfen und Loesen laufen ueber einen eigenen Dialog mit derselben Repo-Auswahl wie beim Projekt. Der Projektdialog bearbeitet weiterhin nur das fuehrende: eine Liste darin wuerde den Dialog sprengen, in dem es eigentlich um das Projekt geht. Eine frei waehlbare Bezeichnung tritt an die Stelle von owner/name. Die Karte zeigt "+2 Repos" und im Titel, welche es sind. repoTitle steht in lib/git/meta statt bei den uebrigen Repository-Helfern: lib/repositories zieht Prisma und damit node:-Module nach, die in einer Client-Komponente nicht landen duerfen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
448 lines
16 KiB
TypeScript
448 lines
16 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import {
|
||
CheckCircle2,
|
||
ExternalLink,
|
||
KeyRound,
|
||
Loader2,
|
||
Pencil,
|
||
Plug,
|
||
Plus,
|
||
Trash2,
|
||
XCircle,
|
||
Zap,
|
||
} from "lucide-react";
|
||
import type { GitProvider } from "@prisma/client";
|
||
import { Modal } from "@/components/ui/Modal";
|
||
import { PROVIDER_META } from "@/lib/git/meta";
|
||
import { cn, formatRelative } from "@/lib/utils";
|
||
import type { ConnectionWithCount } from "@/types";
|
||
|
||
const PROVIDERS: GitProvider[] = ["GITHUB", "GITLAB", "GITEA"];
|
||
|
||
interface FormState {
|
||
name: string;
|
||
provider: GitProvider;
|
||
baseUrl: string;
|
||
username: string;
|
||
token: string;
|
||
}
|
||
|
||
const EMPTY: FormState = {
|
||
name: "",
|
||
provider: "GITHUB",
|
||
baseUrl: PROVIDER_META.GITHUB.defaultBaseUrl,
|
||
username: "",
|
||
token: "",
|
||
};
|
||
|
||
export function ConnectionsManager({ connections }: { connections: ConnectionWithCount[] }) {
|
||
const router = useRouter();
|
||
const [dialogOpen, setDialogOpen] = useState(false);
|
||
const [editing, setEditing] = useState<ConnectionWithCount | null>(null);
|
||
const [form, setForm] = useState<FormState>(EMPTY);
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [busyId, setBusyId] = useState<string | null>(null);
|
||
const [checkResult, setCheckResult] = useState<Record<string, { ok: boolean; message: string }>>({});
|
||
|
||
function openCreate() {
|
||
setEditing(null);
|
||
setForm(EMPTY);
|
||
setError(null);
|
||
setDialogOpen(true);
|
||
}
|
||
|
||
function openEdit(connection: ConnectionWithCount) {
|
||
setEditing(connection);
|
||
setForm({
|
||
name: connection.name,
|
||
provider: connection.provider,
|
||
baseUrl: connection.baseUrl,
|
||
username: connection.username ?? "",
|
||
token: "",
|
||
});
|
||
setError(null);
|
||
setDialogOpen(true);
|
||
}
|
||
|
||
function update<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||
setForm((previous) => {
|
||
if (key === "provider") {
|
||
const provider = value as GitProvider;
|
||
const wasDefault = Object.values(PROVIDER_META).some(
|
||
(meta) => meta.defaultBaseUrl === previous.baseUrl,
|
||
);
|
||
return {
|
||
...previous,
|
||
provider,
|
||
baseUrl: wasDefault || !previous.baseUrl
|
||
? PROVIDER_META[provider].defaultBaseUrl
|
||
: previous.baseUrl,
|
||
};
|
||
}
|
||
return { ...previous, [key]: value };
|
||
});
|
||
}
|
||
|
||
async function save(event: React.FormEvent) {
|
||
event.preventDefault();
|
||
setSaving(true);
|
||
setError(null);
|
||
|
||
// Beim Bearbeiten bedeutet ein leeres Token-Feld "unveraendert lassen".
|
||
const body: Record<string, unknown> = {
|
||
name: form.name,
|
||
provider: form.provider,
|
||
baseUrl: form.baseUrl,
|
||
username: form.username,
|
||
};
|
||
if (!editing || form.token.trim()) body.token = form.token;
|
||
|
||
try {
|
||
const response = await fetch(
|
||
editing ? `/api/connections/${editing.id}` : "/api/connections",
|
||
{
|
||
method: editing ? "PATCH" : "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body),
|
||
},
|
||
);
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(payload.error ?? "Speichern fehlgeschlagen.");
|
||
setDialogOpen(false);
|
||
router.refresh();
|
||
} catch (caught) {
|
||
setError(caught instanceof Error ? caught.message : String(caught));
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
async function check(connection: ConnectionWithCount) {
|
||
setBusyId(connection.id);
|
||
try {
|
||
const response = await fetch(`/api/connections/${connection.id}/check`, { method: "POST" });
|
||
const payload = await response.json().catch(() => ({}));
|
||
setCheckResult((previous) => ({
|
||
...previous,
|
||
[connection.id]: {
|
||
ok: Boolean(payload.ok),
|
||
message: payload.message ?? payload.error ?? "Unbekannte Antwort.",
|
||
},
|
||
}));
|
||
router.refresh();
|
||
} finally {
|
||
setBusyId(null);
|
||
}
|
||
}
|
||
|
||
async function remove(connection: ConnectionWithCount) {
|
||
const linked = connection._count?.repositories ?? 0;
|
||
const hint = linked > 0 ? `\n\n${linked} Repository(s) verlieren die Verknuepfung.` : "";
|
||
if (!window.confirm(`Verbindung „${connection.name}“ loeschen?${hint}`)) return;
|
||
setBusyId(connection.id);
|
||
await fetch(`/api/connections/${connection.id}`, { method: "DELETE" }).catch(() => undefined);
|
||
setBusyId(null);
|
||
router.refresh();
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||
<div>
|
||
<h1 className="text-2xl font-semibold tracking-tight text-white">Git-Verbindungen</h1>
|
||
<p className="mt-1 max-w-2xl text-sm text-slate-400">
|
||
Pro Git-System eine Verbindung mit Basis-URL und Access Token. Tokens werden
|
||
AES-256-GCM-verschluesselt in der Datenbank abgelegt und verlassen den Server nie.
|
||
</p>
|
||
</div>
|
||
<button type="button" className="btn btn-primary" onClick={openCreate}>
|
||
<Plus className="h-4 w-4" />
|
||
Verbindung hinzufuegen
|
||
</button>
|
||
</div>
|
||
|
||
{connections.length === 0 ? (
|
||
<div className="glass fade-up flex flex-col items-center gap-4 px-6 py-16 text-center">
|
||
<span className="grid h-14 w-14 place-items-center rounded-2xl bg-gradient-to-br from-cyan-500/25 to-violet-500/20 text-cyan-200">
|
||
<Plug className="h-6 w-6" />
|
||
</span>
|
||
<div>
|
||
<h2 className="text-lg font-semibold text-white">Noch keine Verbindung</h2>
|
||
<p className="mx-auto mt-1 max-w-md text-sm text-slate-400">
|
||
Verbinde GitHub, GitLab oder deine eigene Gitea-Instanz, um Branches, Tags,
|
||
Commits und Releases direkt im Dashboard zu sehen.
|
||
</p>
|
||
</div>
|
||
<button type="button" className="btn btn-primary" onClick={openCreate}>
|
||
<Plus className="h-4 w-4" />
|
||
Erste Verbindung anlegen
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||
{connections.map((connection, index) => {
|
||
const meta = PROVIDER_META[connection.provider];
|
||
const result = checkResult[connection.id];
|
||
const ok = result ? result.ok : connection.lastCheckOk;
|
||
const message = result?.message ?? connection.lastCheckMessage;
|
||
|
||
return (
|
||
<article
|
||
key={connection.id}
|
||
className="glass card-glow fade-up relative p-5"
|
||
style={
|
||
{
|
||
"--accent-from": "#22d3ee",
|
||
"--accent-to": "#8b5cf6",
|
||
animationDelay: `${index * 50}ms`,
|
||
} as React.CSSProperties
|
||
}
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<h2 className="truncate text-base font-semibold text-white">
|
||
{connection.name}
|
||
</h2>
|
||
<p className="mt-0.5 text-xs text-slate-500">{meta.label}</p>
|
||
</div>
|
||
<span
|
||
className={cn(
|
||
"inline-flex items-center gap-1.5 rounded-full px-2 py-1 text-[11px] ring-1 ring-inset",
|
||
ok === true
|
||
? "bg-emerald-500/12 text-emerald-200 ring-emerald-400/30"
|
||
: ok === false
|
||
? "bg-rose-500/12 text-rose-200 ring-rose-400/30"
|
||
: "bg-slate-500/12 text-slate-300 ring-slate-400/25",
|
||
)}
|
||
>
|
||
{ok === true ? (
|
||
<CheckCircle2 className="h-3 w-3" />
|
||
) : ok === false ? (
|
||
<XCircle className="h-3 w-3" />
|
||
) : (
|
||
<Zap className="h-3 w-3" />
|
||
)}
|
||
{ok === true ? "verbunden" : ok === false ? "Fehler" : "ungeprueft"}
|
||
</span>
|
||
</div>
|
||
|
||
<dl className="mt-4 space-y-2 text-xs">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<dt className="text-slate-500">Basis-URL</dt>
|
||
<dd className="min-w-0 truncate text-right font-mono text-slate-300">
|
||
{connection.baseUrl}
|
||
</dd>
|
||
</div>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<dt className="text-slate-500">Token</dt>
|
||
<dd className="font-mono text-slate-300">
|
||
{connection.tokenHint ? (
|
||
<span className="inline-flex items-center gap-1.5">
|
||
<KeyRound className="h-3 w-3 text-amber-300/70" />
|
||
{connection.tokenHint}
|
||
</span>
|
||
) : (
|
||
<span className="text-slate-600">keines</span>
|
||
)}
|
||
</dd>
|
||
</div>
|
||
{connection.username ? (
|
||
<div className="flex items-start justify-between gap-3">
|
||
<dt className="text-slate-500">Benutzer</dt>
|
||
<dd className="truncate text-slate-300">{connection.username}</dd>
|
||
</div>
|
||
) : null}
|
||
<div className="flex items-start justify-between gap-3">
|
||
<dt className="text-slate-500">Repositories</dt>
|
||
<dd className="tabular-nums text-slate-300">
|
||
{connection._count?.repositories ?? 0}
|
||
</dd>
|
||
</div>
|
||
{connection.lastCheckedAt ? (
|
||
<div className="flex items-start justify-between gap-3">
|
||
<dt className="text-slate-500">Geprueft</dt>
|
||
<dd className="text-slate-300" suppressHydrationWarning>
|
||
{formatRelative(connection.lastCheckedAt)}
|
||
</dd>
|
||
</div>
|
||
) : null}
|
||
</dl>
|
||
|
||
{message ? (
|
||
<p
|
||
className={cn(
|
||
"mt-3 rounded-lg px-2.5 py-1.5 text-[11px] leading-snug",
|
||
ok ? "bg-emerald-500/8 text-emerald-200/90" : "bg-rose-500/8 text-rose-200/90",
|
||
)}
|
||
>
|
||
{message}
|
||
</p>
|
||
) : null}
|
||
|
||
<div className="mt-4 flex flex-wrap items-center gap-2 border-t border-white/6 pt-3">
|
||
<button
|
||
type="button"
|
||
className="btn btn-ghost px-2.5 py-1.5 text-xs"
|
||
onClick={() => check(connection)}
|
||
disabled={busyId === connection.id}
|
||
>
|
||
{busyId === connection.id ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
) : (
|
||
<Zap className="h-3.5 w-3.5" />
|
||
)}
|
||
Testen
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-ghost px-2.5 py-1.5 text-xs"
|
||
onClick={() => openEdit(connection)}
|
||
>
|
||
<Pencil className="h-3.5 w-3.5" />
|
||
Bearbeiten
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-danger ml-auto px-2.5 py-1.5 text-xs"
|
||
onClick={() => remove(connection)}
|
||
disabled={busyId === connection.id}
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
</article>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
<Modal
|
||
open={dialogOpen}
|
||
onClose={() => setDialogOpen(false)}
|
||
title={editing ? "Verbindung bearbeiten" : "Neue Git-Verbindung"}
|
||
description="Der Token wird verschluesselt gespeichert und nur serverseitig entschluesselt."
|
||
>
|
||
<form onSubmit={save} className="space-y-4">
|
||
<div>
|
||
<span className="field-label">Git-System</span>
|
||
<div className="grid grid-cols-3 gap-2">
|
||
{PROVIDERS.map((provider) => (
|
||
<button
|
||
key={provider}
|
||
type="button"
|
||
onClick={() => update("provider", provider)}
|
||
className={cn(
|
||
"rounded-xl border px-3 py-2.5 text-sm transition-all",
|
||
form.provider === provider
|
||
? "border-violet-400/60 bg-violet-500/12 text-white shadow-[0_0_24px_-10px_rgba(139,92,246,0.9)]"
|
||
: "border-white/9 text-slate-400 hover:border-white/20 hover:text-slate-200",
|
||
)}
|
||
>
|
||
{PROVIDER_META[provider].label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="field-label" htmlFor="connection-name">
|
||
Anzeigename *
|
||
</label>
|
||
<input
|
||
id="connection-name"
|
||
className="field"
|
||
value={form.name}
|
||
onChange={(event) => update("name", event.target.value)}
|
||
placeholder="z. B. Firmen-Gitea"
|
||
required
|
||
maxLength={80}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="field-label" htmlFor="connection-url">
|
||
Basis-URL *
|
||
</label>
|
||
<input
|
||
id="connection-url"
|
||
className="field font-mono text-xs"
|
||
value={form.baseUrl}
|
||
onChange={(event) => update("baseUrl", event.target.value)}
|
||
placeholder="https://git.example.com"
|
||
required
|
||
/>
|
||
<p className="mt-1 text-[11px] text-slate-500">
|
||
Ohne /api – Nebula haengt den API-Pfad selbst an.
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="field-label" htmlFor="connection-token">
|
||
Access Token {editing ? "(leer lassen = unveraendert)" : ""}
|
||
</label>
|
||
<input
|
||
id="connection-token"
|
||
className="field font-mono text-xs"
|
||
type="password"
|
||
value={form.token}
|
||
onChange={(event) => update("token", event.target.value)}
|
||
placeholder={editing ? "••••••••" : "ghp_… / glpat-… / gitea token"}
|
||
autoComplete="off"
|
||
/>
|
||
<p className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-slate-500">
|
||
{PROVIDER_META[form.provider].tokenHelp}
|
||
<a
|
||
href={PROVIDER_META[form.provider].docsUrl}
|
||
target="_blank"
|
||
rel="noreferrer noopener"
|
||
className="inline-flex items-center gap-1 text-violet-300 hover:text-violet-200"
|
||
>
|
||
Doku
|
||
<ExternalLink className="h-3 w-3" />
|
||
</a>
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="field-label" htmlFor="connection-username">
|
||
Benutzername (optional)
|
||
</label>
|
||
<input
|
||
id="connection-username"
|
||
className="field"
|
||
value={form.username}
|
||
onChange={(event) => update("username", event.target.value)}
|
||
placeholder="wird beim Test automatisch gefuellt"
|
||
/>
|
||
</div>
|
||
|
||
{error ? (
|
||
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
|
||
{error}
|
||
</p>
|
||
) : null}
|
||
|
||
<div className="flex justify-end gap-2 pt-1">
|
||
<button
|
||
type="button"
|
||
className="btn btn-ghost"
|
||
onClick={() => setDialogOpen(false)}
|
||
disabled={saving}
|
||
>
|
||
Abbrechen
|
||
</button>
|
||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||
Speichern
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
}
|