feat(ui): Verwaltung der Git-Verbindungen

Eine Seite fuer alle angebundenen Git-Systeme. Mehrere Verbindungen desselben
Typs sind moeglich, etwa privates GitHub und Firmen-Gitea nebeneinander.

- Anlegen mit Systemauswahl, Basis-URL und Access Token; die Basis-URL wird
  beim Wechsel des Systems auf den passenden Standard gesetzt, sofern sie
  nicht von Hand geaendert wurde
- Verbindungstest auf Knopfdruck: prueft Erreichbarkeit und Token, meldet den
  erkannten Benutzer und bei Gitea die Serverversion. Ergebnis und Zeitpunkt
  werden gespeichert und in der Karte angezeigt
- Token erscheint nur als Hinweis; ein leeres Feld beim Bearbeiten laesst ihn
  unveraendert
- Loeschen trennt nur die Verknuepfung, die Projekte bleiben erhalten
- Hinweis auf die noetigen Scopes samt Link zur Doku des jeweiligen Systems

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-25 19:34:51 +02:00
co-authored by Claude Opus 5
parent 826706aeca
commit 72674dee4e
2 changed files with 472 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import { ConnectionsManager } from "@/components/ConnectionsManager";
import { prisma } from "@/lib/db";
import { connectionSafeSelect } from "@/lib/projects";
import type { ConnectionWithCount } from "@/types";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Git-Verbindungen",
description: "GitHub, GitLab und Gitea mit Nebula verbinden.",
};
export default async function ConnectionsPage() {
const connections = await prisma.gitConnection.findMany({
select: { ...connectionSafeSelect, _count: { select: { projects: true } } },
orderBy: { name: "asc" },
});
return (
<div className="pt-4">
<ConnectionsManager connections={connections as unknown as ConnectionWithCount[]} />
</div>
);
}
+447
View File
@@ -0,0 +1,447 @@
"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?.projects ?? 0;
const hint = linked > 0 ? `\n\n${linked} Projekt(e) 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">Projekte</dt>
<dd className="tabular-nums text-slate-300">
{connection._count?.projects ?? 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>
);
}