"use client"; import { useCallback, useEffect, useState } from "react"; 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 { 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 { IntegrationAuthStatusBanner, type IntegrationAuthHealth, } from "@/components/integrations/auth-status-banner"; import { WorkspaceScopedSection } from "@/components/integrations/workspace-scoped-section"; 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 { deleteAzureDevOpsConfig, getAzureDevOpsConfig, setAzureDevOpsConfig, testAzureDevOpsConnection, } from "@/lib/api/domains/azure-devops-api"; import type { AzureDevOpsConfig, SetAzureDevOpsConfigRequest, TestAzureDevOpsConnectionResult, } from "@/lib/types/azure-devops"; 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 { 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: `Failed to load Azure DevOps config: ${String(err)}`, variant: "error", }); } finally { setLoading(false); } }, [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: "Azure DevOps configuration saved", variant: "success" }); } catch (err) { toast({ description: `Save failed: ${String(err)}`, variant: "error" }); } finally { setSaving(false); } }, [config, form, toast, workspaceId]); const remove = useCallback(async () => { if (!confirm("Remove Azure DevOps configuration?")) return; try { await deleteAzureDevOpsConfig(workspaceId); setConfig(null); setForm(EMPTY_FORM); setTestResult(null); toast({ description: "Azure DevOps configuration removed", variant: "success" }); } catch (err) { toast({ description: `Remove failed: ${String(err)}`, variant: "error" }); } }, [toast, workspaceId]); return { config, form, loading, saving, testing, testResult, health: configToHealth(config), update, test, save, remove, }; } function TestResult({ result }: { result: TestAzureDevOpsConnectionResult | null }) { if (!result) return null; return ( {result.ok ? `Connected${result.displayName ? ` as ${result.displayName}` : ""}` : result.error || "Connection failed"} ); } type SettingsState = ReturnType; type ProjectsState = ReturnType; function PATSetupHelp({ organizationUrl }: { organizationUrl: string }) { const patURL = azureDevOpsPATURL(organizationUrl); const [open, setOpen] = useState(false); return (

Create a read-only personal access token

  1. Open token settings and select New Token.
  2. Choose this organization, a short expiration, and Custom defined scopes.
  3. Under Work Items, check Read. Under Code, check Read. Leave all other scopes unchecked.
  4. Create the token, copy it, and paste it into this field.
{patURL ? ( Create personal access token ) : (

Enter a valid organization URL to open its token settings.

)}
); } function ConnectionFields({ state, projects, canReusePAT, projectSelectionEnabled, }: { state: SettingsState; projects: ProjectsState; canReusePAT: boolean; projectSelectionEnabled: boolean; }) { const projectPlaceholder = projects.loading ? "Loading projects..." : "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 ? "Saved credential" : "Paste PAT"} disabled={state.loading} autoComplete="new-password" aria-describedby="azure-devops-pat-help" data-testid="azure-devops-pat" />
{projects.error && (

{projects.error}

)} ); } function saveButtonLabel(state: SettingsState): string { if (state.saving) return "Saving..."; return state.config ? "Update" : "Save"; } function ConnectionActions({ state, disabled }: { state: SettingsState; disabled: boolean }) { return (
{state.config && ( )}
); } export function AzureDevOpsConnectionSection({ workspaceId }: { workspaceId: string }) { 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="Azure DevOps integration" description="Azure DevOps Services organization, project, and read-only PAT for this workspace." > ); } export function AzureDevOpsIntegrationPage({ workspaceId }: { workspaceId?: string } = {}) { return ( {(selectedWorkspaceId) => ( )} ); }