"use client"; import { useCallback, useEffect, useState } from "react"; import { IconAlertTriangle, IconBrandGitlab, IconCheck, IconEye, IconEyeOff, IconKey, IconRefresh, IconTrash, IconWorld, IconX, } from "@tabler/icons-react"; import { Alert, AlertDescription } from "@kandev/ui/alert"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; import { CardContent } from "@kandev/ui/card"; import { Input } from "@kandev/ui/input"; import { Separator } from "@kandev/ui/separator"; import { Spinner } from "@kandev/ui/spinner"; import { WorkspaceScopedSection } from "@/components/integrations/workspace-scoped-section"; import { useToast } from "@/components/toast-provider"; import { SettingsSection } from "@/components/settings/settings-section"; import { SettingsCard } from "@/components/settings/settings-card"; import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider"; import { clearGitLabToken, configureGitLabHost, configureGitLabToken, fetchGitLabStatus, } from "@/lib/api/domains/gitlab-api"; import type { GitLabStatus } from "@/lib/types/gitlab"; const DEFAULT_HOST = "https://gitlab.com"; function StatusBadge({ status }: { status: GitLabStatus | null }) { if (!status) return null; if (status.authenticated) { return ( Connected ); } // A non-empty connection_error means the probe failed for transport reasons // (network / 5xx / parse) — distinct from "no token configured", which has // an empty connection_error and authenticated=false. if (status.connection_error) { return ( Unreachable ); } return ( Not connected ); } // ConnectionErrorAlert renders the per-host transport failure separately from // the "bad token" path so users see "GitLab is currently unreachable" instead // of "your token is broken" during an outage. Hidden when the probe succeeded // or when no token is configured (nothing to probe). function ConnectionErrorAlert({ status }: { status: GitLabStatus | null }) { if (!status?.connection_error) return null; return ( Couldn't reach {status.host}:{" "} {status.connection_error} Your token may still be valid — this looks like a network or upstream issue. ); } function AuthMethodBadge({ method }: { method: GitLabStatus["auth_method"] }) { const labels: Record = { glab_cli: "glab CLI", pat: "Personal access token", none: "Not configured", mock: "Mock (test)", }; return {labels[method] ?? method}; } function HostForm({ initial, onSaved, onDirtyChange, }: { initial: string; onSaved: () => void; onDirtyChange: (isDirty: boolean) => void; }) { const [host, setHost] = useState(initial); const [baseline, setBaseline] = useState(initial); const [syncedInitial, setSyncedInitial] = useState(initial); const { toast } = useToast(); const isDirty = host !== baseline; useEffect(() => onDirtyChange(isDirty), [isDirty, onDirtyChange]); if (initial !== syncedInitial && host === baseline) { setSyncedInitial(initial); setBaseline(initial); setHost(initial); } const save = useCallback(async () => { const submitted = host.trim(); try { await configureGitLabHost(submitted); setBaseline(submitted); setHost((current) => (current.trim() === submitted ? submitted : current)); toast({ description: "GitLab host updated", variant: "success" }); onSaved(); } catch (err) { toast({ description: err instanceof Error ? err.message : "Failed to update host", variant: "error", }); throw err; } }, [host, toast, onSaved]); const discard = useCallback(() => setHost(baseline), [baseline]); const validHost = (() => { try { const url = new URL(host.trim()); return ( (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password ); } catch { return false; } })(); useSettingsSaveContributor({ id: "gitlab-host", revision: host, isDirty, canSave: validHost, invalidReason: validHost ? undefined : "Enter a valid HTTP or HTTPS GitLab host URL.", save, discard, }); return (
setHost(e.target.value)} className="font-mono text-sm" />
); } function TokenForm({ onSuccess, onDirtyChange, }: { onSuccess: () => void; onDirtyChange: (isDirty: boolean) => void; }) { const [token, setToken] = useState(""); const [showToken, setShowToken] = useState(false); const { toast } = useToast(); const isDirty = Boolean(token); useEffect(() => onDirtyChange(isDirty), [isDirty, onDirtyChange]); const save = useCallback(async () => { const submitted = token.trim(); try { await configureGitLabToken(submitted); toast({ description: "GitLab token configured", variant: "success" }); setToken((current) => (current.trim() === submitted ? "" : current)); onSuccess(); } catch (err) { toast({ description: err instanceof Error ? err.message : "Failed to save token", variant: "error", }); throw err; } }, [token, toast, onSuccess]); const discard = useCallback(() => setToken(""), []); useSettingsSaveContributor({ id: "gitlab-token", revision: token, isDirty, canSave: Boolean(token.trim()), invalidReason: token && !token.trim() ? "A GitLab token is required." : undefined, save, discard, }); return (
setToken(e.target.value)} className="font-mono text-sm pr-9" autoComplete="off" />
); } function ClearTokenButton({ onCleared }: { onCleared: () => void }) { const [busy, setBusy] = useState(false); const { toast } = useToast(); return ( ); } type GitLabIntegrationPageProps = { workspaceId?: string; }; export function GitLabIntegrationPage({ workspaceId }: GitLabIntegrationPageProps = {}) { return ( {(ws) => } ); } function GitLabConnectionSection() { const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); const [hostDirty, setHostDirty] = useState(false); const [tokenDirty, setTokenDirty] = useState(false); const reload = useCallback(async () => { setLoading(true); try { const next = await fetchGitLabStatus({ cache: "no-store" }); setStatus(next); } catch { setStatus(null); } finally { setLoading(false); } }, []); useEffect(() => { void reload(); }, [reload]); return ( } action={ } >
{status && } {status?.glab_version && ( glab {status.glab_version} )}
{status?.username && ( Logged in as {status.username} )}

GitLab host URL. Override for self-managed instances; leave at the default for gitlab.com.

void reload()} onDirtyChange={setHostDirty} />

Personal access token. Required scopes: api, read_user. Stored encrypted in the kandev secret store.

{status?.token_configured && void reload()} />}
void reload()} onDirtyChange={setTokenDirty} />
); }