"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 { GitLabEnabledControl } from "@/components/gitlab/gitlab-enabled-control"; 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"; import { Trans, useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { INTEGRATION_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/integrations"; const DEFAULT_HOST = "https://gitlab.com"; // The bare hostname as it reads mid-sentence. Interpolated rather than written // into the catalog so no locale — including pseudo — can transliterate it into a // hostname that does not resolve. const DEFAULT_HOST_NAME = "gitlab.com"; function StatusBadge({ status }: { status: GitLabStatus | null }) { const { t } = useTranslation(); if (!status) return null; if (status.authenticated) { return ( {t("gitlab: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 ( {t("gitlab:unreachable")} ); } if (status.token_configured || status.auth_method !== "none") { return ( {t("gitlab:reconnectRequired")} ); } return ( {t("gitlab:notConnected")} ); } // 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 }) { const { t } = useTranslation(); if (!status?.connection_error) return null; return ( {/* One message rather than a stem plus the host: where the host and the upstream error sit in the sentence is the translator's call. Both are server data, so they travel through `values`. */} Couldn't reach {status.host}:{" "} {status.connection_error} {t("gitlab:yourTokenMayStillBeValid")} ); } // The record keys are the wire `auth_method` values the backend sends and must // never be translated; only the values are copy. Built inside the component so // `t()` runs at render — a module-scope table would freeze at the boot locale. // The `?? method` fallback deliberately echoes the raw wire value for a method // this build does not know about. function AuthMethodBadge({ method }: { method: GitLabStatus["auth_method"] }) { const { t } = useTranslation(); const labels: Record = { glab_cli: t("gitlab:glabCli"), pat: t("gitlab:personalAccessToken"), environment: t("gitlab:environmentToken"), none: t("gitlab:notConfigured"), mock: t("gitlab:mockTest"), }; 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; } } // `t` is threaded in: this is a plain function, and the guard only inspects JSX, // so a literal returned from here would never be reported. function credentialInvalidReason(t: TFunction, validHost: boolean, patNeedsToken: boolean) { if (!validHost) return t("gitlab:enterAValidHttpOrHttpsGitlabHost"); if (patNeedsToken) return t("gitlab:enterAPersonalAccessTokenToSwitch"); return undefined; } function useGitLabCredentialDraft({ initial, initialHost, host, workspaceId, hasToken, onChange, onSaved, onDirtyChange, onHostChange, }: GitLabCredentialsFormProps) { const { t } = useTranslation(); 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: t("gitlab:gitlabAuthenticationMethodUpdated"), variant: "success" }); onSaved(); } catch (error) { toast({ description: error instanceof Error ? error.message : t("gitlab:failedToUpdateAuthenticationMethod"), variant: "error", }); throw error; } }, [host, method, onSaved, t, 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(t, 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 }; } /** Credential form (PAT or OAuth) for connecting a workspace's GitLab account. */ export function GitLabCredentialsForm(props: GitLabCredentialsFormProps) { const { t } = useTranslation(); const draft = useGitLabCredentialDraft(props); const [showToken, setShowToken] = useState(false); return (

{t("gitlab:chooseAWorkspacePatTheLocal")}

{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 { t } = useTranslation(); const [busy, setBusy] = useState(false); const { toast } = useToast(); return ( ); } type GitLabIntegrationPageProps = { workspaceId?: string; }; /** GitLab's own settings page: connection, action presets, and watch settings. */ 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 { t } = useTranslation(); const { workspaceId, status, loading, authMethodDirty, hostDraft, authMethodDraft, setHostDraft, setAuthMethodDraft, setAuthMethodDirty, reload, } = props; return ( } action={
} >

{t("gitlab:gitlabHostUrlOverrideForSelf", { host: DEFAULT_HOST_NAME })}

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 ( ); }