From 72674dee4e535a85ee33c9cbe60d2428a417f6c6 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 25 Jul 2026 19:34:51 +0200 Subject: [PATCH] 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) --- src/app/connections/page.tsx | 25 ++ src/components/ConnectionsManager.tsx | 447 ++++++++++++++++++++++++++ 2 files changed, 472 insertions(+) create mode 100644 src/app/connections/page.tsx create mode 100644 src/components/ConnectionsManager.tsx diff --git a/src/app/connections/page.tsx b/src/app/connections/page.tsx new file mode 100644 index 0000000..a9a5428 --- /dev/null +++ b/src/app/connections/page.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/src/components/ConnectionsManager.tsx b/src/components/ConnectionsManager.tsx new file mode 100644 index 0000000..19cb374 --- /dev/null +++ b/src/components/ConnectionsManager.tsx @@ -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(null); + const [form, setForm] = useState(EMPTY); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [busyId, setBusyId] = useState(null); + const [checkResult, setCheckResult] = useState>({}); + + 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(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 = { + 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 ( +
+
+
+

Git-Verbindungen

+

+ 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. +

+
+ +
+ + {connections.length === 0 ? ( +
+ + + +
+

Noch keine Verbindung

+

+ Verbinde GitHub, GitLab oder deine eigene Gitea-Instanz, um Branches, Tags, + Commits und Releases direkt im Dashboard zu sehen. +

+
+ +
+ ) : ( +
+ {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 ( +
+
+
+

+ {connection.name} +

+

{meta.label}

+
+ + {ok === true ? ( + + ) : ok === false ? ( + + ) : ( + + )} + {ok === true ? "verbunden" : ok === false ? "Fehler" : "ungeprueft"} + +
+ +
+
+
Basis-URL
+
+ {connection.baseUrl} +
+
+
+
Token
+
+ {connection.tokenHint ? ( + + + {connection.tokenHint} + + ) : ( + keines + )} +
+
+ {connection.username ? ( +
+
Benutzer
+
{connection.username}
+
+ ) : null} +
+
Projekte
+
+ {connection._count?.projects ?? 0} +
+
+ {connection.lastCheckedAt ? ( +
+
Geprueft
+
+ {formatRelative(connection.lastCheckedAt)} +
+
+ ) : null} +
+ + {message ? ( +

+ {message} +

+ ) : null} + +
+ + + +
+
+ ); + })} +
+ )} + + setDialogOpen(false)} + title={editing ? "Verbindung bearbeiten" : "Neue Git-Verbindung"} + description="Der Token wird verschluesselt gespeichert und nur serverseitig entschluesselt." + > +
+
+ Git-System +
+ {PROVIDERS.map((provider) => ( + + ))} +
+
+ +
+ + update("name", event.target.value)} + placeholder="z. B. Firmen-Gitea" + required + maxLength={80} + /> +
+ +
+ + update("baseUrl", event.target.value)} + placeholder="https://git.example.com" + required + /> +

+ Ohne /api – Nebula haengt den API-Pfad selbst an. +

+
+ +
+ + update("token", event.target.value)} + placeholder={editing ? "••••••••" : "ghp_… / glpat-… / gitea token"} + autoComplete="off" + /> +

+ {PROVIDER_META[form.provider].tokenHelp} + + Doku + + +

+
+ +
+ + update("username", event.target.value)} + placeholder="wird beim Test automatisch gefuellt" + /> +
+ + {error ? ( +

+ {error} +

+ ) : null} + +
+ + +
+
+
+
+ ); +}