"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { IconBrandAzure, IconDeviceFloppy, IconExternalLink, IconInfoCircle, IconPlugConnected, IconTrash, } from "@tabler/icons-react"; import { Alert, AlertDescription } from "@kandev/ui/alert"; import { Button } from "@kandev/ui/button"; import { Card, CardContent } from "@kandev/ui/card"; import { Input } from "@kandev/ui/input"; import { settingsCredentialClassName } from "@/components/settings/settings-control"; import { Label } from "@kandev/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; import { Separator } from "@kandev/ui/separator"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover"; import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions"; import { IntegrationAuthStatusBanner, type IntegrationAuthHealth, } from "@/components/integrations/auth-status-banner"; import { WorkspaceScopedSection } from "@/components/integrations/workspace-scoped-section"; import { AzureDevOpsDefaultQueriesSection } from "@/components/azure-devops/azure-devops-default-queries"; import { AzureDevOpsEnabledControl } from "@/components/azure-devops/azure-devops-enabled-control"; import { AzureDevOpsQuickActionsSection } from "@/components/azure-devops/azure-devops-quick-actions"; import { AzureDevOpsWatchSettings } from "@/components/azure-devops/azure-devops-watch-settings"; import { SettingsSection } from "@/components/settings/settings-section"; import { useToast } from "@/components/toast-provider"; import { INTEGRATION_STATUS_REFRESH_MS } from "@/hooks/domains/integrations/use-integration-availability"; import { useAzureDevOpsProjects } from "@/hooks/domains/azure-devops/use-azure-devops-projects"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { deleteAzureDevOpsConfig, getAzureDevOpsConfig, setAzureDevOpsConfig, testAzureDevOpsConnection, } from "@/lib/api/domains/azure-devops-api"; import type { AzureDevOpsConfig, SetAzureDevOpsConfigRequest, TestAzureDevOpsConnectionResult, } from "@/lib/types/azure-devops"; import { INTEGRATION_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/integrations"; type FormState = { organizationUrl: string; defaultProjectId: string; defaultProjectName: string; pat: string; }; const EMPTY_FORM: FormState = { organizationUrl: "", defaultProjectId: "", defaultProjectName: "", pat: "", }; function configToForm(config: AzureDevOpsConfig | null): FormState { if (!config) return EMPTY_FORM; return { organizationUrl: config.organizationUrl, defaultProjectId: config.defaultProjectId ?? "", defaultProjectName: config.defaultProjectName ?? "", pat: "", }; } function configToHealth(config: AzureDevOpsConfig | null): IntegrationAuthHealth | null { if (!config?.hasSecret) return null; return { ok: config.lastOk, error: config.lastError ?? "", checkedAt: config.lastCheckedAt ? new Date(config.lastCheckedAt) : null, }; } function normalizedOrganization(value: string): string { return value.trim().replace(/\/+$/, ""); } function azureDevOpsPATURL(value: string): string | null { let organizationURL: URL; try { organizationURL = new URL(value.trim()); } catch { return null; } const organization = organizationURL.pathname.split("/").filter(Boolean); if ( organizationURL.protocol !== "https:" || organizationURL.hostname.toLowerCase() !== "dev.azure.com" || organizationURL.port || organizationURL.username || organizationURL.password || organizationURL.search || organizationURL.hash || organization.length !== 1 || !/^[a-z0-9-]+$/i.test(organization[0]) ) { return null; } return `https://dev.azure.com/${organization[0]}/_usersSettings/tokens`; } function savedPATMatches(config: AzureDevOpsConfig | null, form: FormState): boolean { return ( !!config?.hasSecret && normalizedOrganization(config.organizationUrl) === normalizedOrganization(form.organizationUrl) ); } function requestFromForm( form: FormState, savedConfig: AzureDevOpsConfig | null, ): SetAzureDevOpsConfigRequest { const organizationMatches = normalizedOrganization(form.organizationUrl) === normalizedOrganization(savedConfig?.organizationUrl ?? ""); return { organizationUrl: normalizedOrganization(form.organizationUrl), defaultProjectId: organizationMatches ? form.defaultProjectId : undefined, defaultProjectName: organizationMatches ? form.defaultProjectName : undefined, authMethod: "pat", pat: form.pat || undefined, }; } function useConfigRefresh( workspaceId: string, setConfig: (config: AzureDevOpsConfig | null) => void, ) { useEffect(() => { const interval = setInterval(() => { getAzureDevOpsConfig(workspaceId) .then(setConfig) .catch(() => undefined); }, INTEGRATION_STATUS_REFRESH_MS); return () => clearInterval(interval); }, [setConfig, workspaceId]); } function useAzureDevOpsSettings(workspaceId: string) { const { t } = useTranslation(); const { toast } = useToast(); const [config, setConfig] = useState(null); const [form, setForm] = useState(EMPTY_FORM); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState(null); const load = useCallback(async () => { setLoading(true); try { const next = await getAzureDevOpsConfig(workspaceId, { cache: "no-store" }); setConfig(next); setForm(configToForm(next)); } catch (err) { toast({ description: t("azuredevops:failedToLoadConfig", { error: String(err) }), variant: "error", }); } finally { setLoading(false); } }, [t, toast, workspaceId]); useEffect(() => void load(), [load]); useConfigRefresh(workspaceId, setConfig); const update = useCallback( (key: K, value: FormState[K]) => setForm((current) => ({ ...current, [key]: value, ...(key === "organizationUrl" && normalizedOrganization(String(value)) !== normalizedOrganization(current.organizationUrl) ? { defaultProjectId: "", defaultProjectName: "" } : {}), })), [], ); const test = useCallback(async () => { setTesting(true); setTestResult(null); try { setTestResult(await testAzureDevOpsConnection(workspaceId, requestFromForm(form, config))); } catch (err) { setTestResult({ ok: false, error: String(err) }); } finally { setTesting(false); } }, [config, form, workspaceId]); const save = useCallback(async () => { setSaving(true); try { const next = await setAzureDevOpsConfig(workspaceId, requestFromForm(form, config)); setConfig(next); setForm(configToForm(next)); setTestResult(null); toast({ description: t("azuredevops:configurationSaved"), variant: "success" }); } catch (err) { toast({ description: t("azuredevops:saveFailed", { error: String(err) }), variant: "error", }); } finally { setSaving(false); } }, [config, form, t, toast, workspaceId]); const remove = useCallback(async () => { try { await deleteAzureDevOpsConfig(workspaceId); setConfig(null); setForm(EMPTY_FORM); setTestResult(null); toast({ description: t("azuredevops:configurationRemoved"), variant: "success" }); } catch (err) { toast({ description: t("azuredevops:removeFailed", { error: String(err) }), variant: "error", }); } }, [t, toast, workspaceId]); return { config, form, loading, saving, testing, testResult, health: configToHealth(config), update, test, save, remove, }; } function testResultMessage( t: (key: string, values?: Record) => string, result: TestAzureDevOpsConnectionResult, ): string { if (!result.ok) return result.error || t("azuredevops:connectionFailed"); // `displayName` is the Azure DevOps identity — data, so it is interpolated // rather than concatenated into the message. return result.displayName ? t("azuredevops:connectedAs", { name: result.displayName }) : t("azuredevops:connected"); } function TestResult({ result }: { result: TestAzureDevOpsConnectionResult | null }) { const { t } = useTranslation(); if (!result) return null; return ( {testResultMessage(t, result)} ); } type SettingsState = ReturnType; type ProjectsState = ReturnType; // Azure DevOps names these two PAT scope groups in its own token UI. They are // pointers into that screen, not copy, so they are interpolated as values — the // pseudo-locale must not transliterate a label the user has to find verbatim. // i18n-exempt: Azure DevOps' own PAT scope names; the user picks these exact entries in Azure's UI. const PAT_SCOPE_WORK_ITEMS = "Work Items"; // i18n-exempt: Azure DevOps' own PAT scope names; the user picks these exact entries in Azure's UI. const PAT_SCOPE_CODE = "Code"; function PATSetupHelp({ organizationUrl }: { organizationUrl: string }) { const { t } = useTranslation(); const patURL = azureDevOpsPATURL(organizationUrl); const [open, setOpen] = useState(false); return (

{t("azuredevops:patHelpTitle")}

  1. {t("azuredevops:patStepOpenTokenSettings")}
  2. {t("azuredevops:patStepChooseScopes")}
  3. Under {PAT_SCOPE_WORK_ITEMS}, check Read & write. Under{" "} {PAT_SCOPE_CODE}, check Read. Leave all other scopes unchecked.
  4. {t("azuredevops:patStepCreateAndPaste")}
{patURL ? ( {t("azuredevops:createPersonalAccessToken")} ) : (

{t("azuredevops:enterValidOrganizationUrl")}

)}
); } function ConnectionFields({ state, projects, canReusePAT, projectSelectionEnabled, }: { state: SettingsState; projects: ProjectsState; canReusePAT: boolean; projectSelectionEnabled: boolean; }) { const { t } = useTranslation(); const projectPlaceholder = projects.loading ? t("azuredevops:loadingProjects") : t("azuredevops:optional"); return ( <>
state.update("organizationUrl", event.target.value)} placeholder="https://dev.azure.com/organization" disabled={state.loading} autoComplete="url" data-testid="azure-devops-organization" />
state.update("pat", event.target.value)} placeholder={canReusePAT ? t("azuredevops:savedCredential") : t("azuredevops:pastePat")} disabled={state.loading} autoComplete="new-password" aria-describedby="azure-devops-pat-help" data-testid="azure-devops-pat" className={settingsCredentialClassName()} />
{projects.error && (

{projects.error}

)} ); } function saveButtonLabel(t: (key: string) => string, state: SettingsState): string { if (state.saving) return t("azuredevops:saving"); return state.config ? t("azuredevops:update") : t("azuredevops:save"); } function ConnectionActions({ state, disabled }: { state: SettingsState; disabled: boolean }) { const { t } = useTranslation(); const { isFinePointer } = useResponsiveBreakpoint(); const [confirmingRemove, setConfirmingRemove] = useState(false); const removeAnchorRef = useRef(null); const removeConfirmation = t("azuredevops:removeConfigurationConfirm"); useEffect(() => { if (!state.config && confirmingRemove) setConfirmingRemove(false); }, [confirmingRemove, state.config]); return (
{state.config && (isFinePointer || !confirmingRemove) && ( )} {state.config && !isFinePointer && confirmingRemove ? ( setConfirmingRemove(false)} onClose={() => setConfirmingRemove(false)} onConfirm={() => void state.remove()} /> ) : null} {state.config && isFinePointer ? ( setConfirmingRemove(false)} onConfirm={() => void state.remove()} /> ) : null}
); } /** Azure DevOps connection card: title/enable toggle, credential form, and test-connection actions. */ export function AzureDevOpsConnectionSection({ workspaceId }: { workspaceId: string }) { const { t } = useTranslation(); const state = useAzureDevOpsSettings(workspaceId); const projects = useAzureDevOpsProjects(workspaceId, !!state.config?.hasSecret); const canReusePAT = savedPATMatches(state.config, state.form); const projectSelectionEnabled = !!state.config?.hasSecret && normalizedOrganization(state.config.organizationUrl) === normalizedOrganization(state.form.organizationUrl); const missingPAT = !canReusePAT && !state.form.pat; const disabled = state.loading || !state.form.organizationUrl || missingPAT; return ( } title={t("azuredevops:integrationTitle")} description={t("azuredevops:integrationDescription")} action={} > ); } /** Azure DevOps's own settings page: connection, watch settings, quick actions, and default queries. */ export function AzureDevOpsIntegrationPage({ workspaceId }: { workspaceId?: string } = {}) { return ( {(selectedWorkspaceId) => (
)}
); }