"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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; 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, fetchGitLabStatus, setGitLabConfig } from "@/lib/api/domains/gitlab-api"; import type { GitLabConfig, GitLabStatus } from "@/lib/types/gitlab"; import { GitLabWatchSettings } from "./watch-settings"; import { GitLabActionPresetsSection } from "./action-presets-section"; 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 ); } if (status.token_configured || status.auth_method !== "none") { return ( Reconnect required ); } 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", environment: "Environment token", none: "Not configured", mock: "Mock (test)", }; return {labels[method] ?? method}; } function HostForm({ host, baseline, onHostChange, }: { host: string; baseline: string; onHostChange: (host: string) => void; }) { const isDirty = host !== baseline; return (
onHostChange(event.target.value)} className="font-mono text-sm" />
); } type GitLabCredentialsFormProps = { initial: GitLabConfig["auth_method"]; initialHost: string; host: string; workspaceId: string; hasToken: boolean; onChange: (method: GitLabConfig["auth_method"]) => void; onSaved: () => void; onDirtyChange: (isDirty: boolean) => void; onHostChange: (host: string) => void; }; function isValidGitLabHost(host: string): boolean { try { const url = new URL(host.trim()); return ( (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password ); } catch { return false; } } function credentialInvalidReason(validHost: boolean, patNeedsToken: boolean) { if (!validHost) return "Enter a valid HTTP or HTTPS GitLab host URL."; if (patNeedsToken) return "Enter a personal access token to switch to PAT."; return undefined; } function useGitLabCredentialDraft({ initial, initialHost, host, workspaceId, hasToken, onChange, onSaved, onDirtyChange, onHostChange, }: GitLabCredentialsFormProps) { const [method, setMethod] = useState(initial); const [baseline, setBaseline] = useState(initial); const [syncedInitial, setSyncedInitial] = useState(initial); const [hostBaseline, setHostBaseline] = useState(initialHost); const [token, setToken] = useState(""); const { toast } = useToast(); const isDirty = method !== baseline || Boolean(token) || host.trim() !== hostBaseline; useEffect(() => onDirtyChange(isDirty), [isDirty, onDirtyChange]); if (initial !== syncedInitial && method === baseline) { setSyncedInitial(initial); setBaseline(initial); setMethod(initial); } if (initialHost !== hostBaseline && !token && method === baseline) { setHostBaseline(initialHost); } const save = useCallback(async () => { try { const submittedToken = token.trim(); await setGitLabConfig( { host: host.trim(), auth_method: method, ...(method === "pat" && submittedToken ? { token: submittedToken } : {}), }, { workspaceId }, ); setBaseline(method); setHostBaseline(host.trim()); setToken((current) => (current.trim() === submittedToken ? "" : current)); toast({ description: "GitLab authentication method updated", variant: "success" }); onSaved(); } catch (error) { toast({ description: error instanceof Error ? error.message : "Failed to update authentication method", variant: "error", }); throw error; } }, [host, method, onSaved, toast, token, workspaceId]); const patNeedsToken = method === "pat" && !hasToken && !token.trim(); const validHost = isValidGitLabHost(host); useSettingsSaveContributor({ id: "gitlab-credentials", revision: JSON.stringify({ host: host.trim(), method, token }), isDirty, canSave: validHost && !patNeedsToken, invalidReason: credentialInvalidReason(validHost, patNeedsToken), save, discard: () => { setMethod(baseline); setToken(""); onHostChange(hostBaseline); onChange(baseline); }, }); const selectMethod = (value: string) => { const next = value as GitLabConfig["auth_method"]; setMethod(next); onChange(next); }; return { method, token, setToken, selectMethod, isDirty }; } export function GitLabCredentialsForm(props: GitLabCredentialsFormProps) { const draft = useGitLabCredentialDraft(props); const [showToken, setShowToken] = useState(false); return (

Choose a workspace PAT, the local glab CLI login, or an environment-provided token. CLI and environment credentials must already be available to the kandev backend.

{draft.method === "pat" ? (
draft.setToken(event.target.value)} className="font-mono text-sm pr-9" autoComplete="off" />
) : null}
); } function ClearTokenButton({ workspaceId, onCleared, }: { workspaceId: string; onCleared: () => void; }) { const [busy, setBusy] = useState(false); const { toast } = useToast(); return ( ); } type GitLabIntegrationPageProps = { workspaceId?: string; }; export function GitLabIntegrationPage({ workspaceId }: GitLabIntegrationPageProps = {}) { return ( {(ws) => (
)}
); } function editableAuthMethod(status: GitLabStatus | null): GitLabConfig["auth_method"] { return status?.auth_method === "glab_cli" || status?.auth_method === "environment" ? status.auth_method : "pat"; } type ConnectionCardProps = { workspaceId: string; status: GitLabStatus | null; loading: boolean; authMethodDirty: boolean; hostDraft: string; authMethodDraft: GitLabConfig["auth_method"]; setHostDraft: (host: string) => void; setAuthMethodDraft: (method: GitLabConfig["auth_method"]) => void; setAuthMethodDirty: (dirty: boolean) => void; reload: () => Promise; }; function ConnectionStatusRow({ status }: { status: GitLabStatus | null }) { return (
{status && } {status?.glab_version ? ( glab {status.glab_version} ) : null}
{status?.username ? ( Logged in as {status.username} ) : null}
); } function GitLabConnectionCard(props: ConnectionCardProps) { const { workspaceId, status, loading, authMethodDirty, hostDraft, authMethodDraft, setHostDraft, setAuthMethodDraft, setAuthMethodDirty, reload, } = props; return ( } action={ } >

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

void reload()} onDirtyChange={setAuthMethodDirty} onHostChange={setHostDraft} /> {authMethodDraft === "pat" && status?.token_configured ? (
void reload()} />
) : null}
); } function GitLabConnectionSection({ workspaceId }: { workspaceId: string }) { const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); const [authMethodDirty, setAuthMethodDirty] = useState(false); const [hostDraft, setHostDraft] = useState(DEFAULT_HOST); const [authMethodDraft, setAuthMethodDraft] = useState("pat"); const reload = useCallback(async () => { setLoading(true); try { const next = await fetchGitLabStatus({ cache: "no-store", workspaceId }); setStatus(next); setHostDraft(next?.host ?? DEFAULT_HOST); setAuthMethodDraft(editableAuthMethod(next)); } catch { setStatus(null); } finally { setLoading(false); } }, [workspaceId]); useEffect(() => { void reload(); }, [reload]); return ( ); }