596 lines
21 KiB
TypeScript
596 lines
21 KiB
TypeScript
"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 { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
|
|
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<AzureDevOpsConfig | null>(null);
|
|
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [testing, setTesting] = useState(false);
|
|
const [testResult, setTestResult] = useState<TestAzureDevOpsConnectionResult | null>(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(
|
|
<K extends keyof FormState>(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, unknown>) => 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 (
|
|
<Alert variant={result.ok ? "default" : "destructive"} data-testid="azure-devops-test-result">
|
|
<AlertDescription>{testResultMessage(t, result)}</AlertDescription>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
type SettingsState = ReturnType<typeof useAzureDevOpsSettings>;
|
|
type ProjectsState = ReturnType<typeof useAzureDevOpsProjects>;
|
|
|
|
// 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 (
|
|
<TooltipProvider disableHoverableContent={false}>
|
|
<Tooltip open={open} onOpenChange={setOpen}>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="size-11 shrink-0 cursor-help text-muted-foreground sm:size-7"
|
|
aria-label={t("azuredevops:howToCreatePat")}
|
|
onClick={() => setOpen((current) => !current)}
|
|
>
|
|
<IconInfoCircle className="size-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent
|
|
id="azure-devops-pat-help"
|
|
side="top"
|
|
align="end"
|
|
className="pointer-events-auto max-w-sm space-y-2 p-3 text-left text-xs leading-relaxed"
|
|
data-testid="azure-devops-pat-help"
|
|
>
|
|
<p className="font-medium text-foreground">{t("azuredevops:patHelpTitle")}</p>
|
|
<ol className="list-decimal space-y-1 pl-4 text-muted-foreground">
|
|
<li>{t("azuredevops:patStepOpenTokenSettings")}</li>
|
|
<li>{t("azuredevops:patStepChooseScopes")}</li>
|
|
<li>
|
|
<Trans
|
|
i18nKey="azuredevops:patStepCheckScopes"
|
|
values={{ workItems: PAT_SCOPE_WORK_ITEMS, code: PAT_SCOPE_CODE }}
|
|
>
|
|
Under <span className="font-medium text-foreground">{PAT_SCOPE_WORK_ITEMS}</span>,
|
|
check Read & write. Under{" "}
|
|
<span className="font-medium text-foreground">{PAT_SCOPE_CODE}</span>, check Read.
|
|
Leave all other scopes unchecked.
|
|
</Trans>
|
|
</li>
|
|
<li>{t("azuredevops:patStepCreateAndPaste")}</li>
|
|
</ol>
|
|
{patURL ? (
|
|
<a
|
|
href={patURL}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="inline-flex cursor-pointer items-center gap-1 font-medium text-foreground underline underline-offset-4"
|
|
>
|
|
<IconExternalLink className="size-3.5" />
|
|
{t("azuredevops:createPersonalAccessToken")}
|
|
</a>
|
|
) : (
|
|
<p className="text-muted-foreground">{t("azuredevops:enterValidOrganizationUrl")}</p>
|
|
)}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<>
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div className="space-y-1.5 sm:col-span-2">
|
|
<Label htmlFor="azure-devops-organization">{t("azuredevops:organizationUrl")}</Label>
|
|
<Input
|
|
id="azure-devops-organization"
|
|
value={state.form.organizationUrl}
|
|
onChange={(event) => state.update("organizationUrl", event.target.value)}
|
|
placeholder="https://dev.azure.com/organization"
|
|
disabled={state.loading}
|
|
autoComplete="url"
|
|
data-testid="azure-devops-organization"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<div className="sm:flex sm:min-h-7 sm:items-center">
|
|
<Label htmlFor="azure-devops-project">{t("azuredevops:defaultProject")}</Label>
|
|
</div>
|
|
<Select
|
|
value={state.form.defaultProjectId || undefined}
|
|
onValueChange={(projectId) => {
|
|
const project = projects.data.find((item) => item.id === projectId);
|
|
state.update("defaultProjectId", projectId);
|
|
state.update("defaultProjectName", project?.name ?? "");
|
|
}}
|
|
disabled={
|
|
state.loading ||
|
|
projects.loading ||
|
|
projects.data.length === 0 ||
|
|
!projectSelectionEnabled
|
|
}
|
|
>
|
|
<SelectTrigger id="azure-devops-project" className="w-full">
|
|
<SelectValue placeholder={projectPlaceholder} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{projects.data.map((project) => (
|
|
<SelectItem key={project.id} value={project.id}>
|
|
{project.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center gap-1 sm:min-h-7">
|
|
<Label htmlFor="azure-devops-pat">{t("azuredevops:personalAccessToken")}</Label>
|
|
<PATSetupHelp organizationUrl={state.form.organizationUrl} />
|
|
</div>
|
|
<Input
|
|
id="azure-devops-pat"
|
|
type="password"
|
|
value={state.form.pat}
|
|
onChange={(event) => 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()}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{projects.error && (
|
|
<p className="text-sm text-destructive" role="alert">
|
|
{projects.error}
|
|
</p>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
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, isMobile } = useResponsiveBreakpoint();
|
|
const [confirmingRemove, setConfirmingRemove] = useState(false);
|
|
const removeAnchorRef = useRef<HTMLButtonElement>(null);
|
|
const removeConfirmation = t("azuredevops:removeConfigurationConfirm");
|
|
const removeLabel = t("azuredevops:remove");
|
|
|
|
useEffect(() => {
|
|
if (!state.config && confirmingRemove) setConfirmingRemove(false);
|
|
}, [confirmingRemove, state.config]);
|
|
|
|
return (
|
|
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => void state.test()}
|
|
disabled={disabled || state.testing}
|
|
className="w-full cursor-pointer sm:w-auto"
|
|
data-testid="azure-devops-test-button"
|
|
>
|
|
<IconPlugConnected className="h-4 w-4" />
|
|
{state.testing ? t("azuredevops:testing") : t("azuredevops:testConnection")}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={() => void state.save()}
|
|
disabled={disabled || state.saving}
|
|
className="w-full cursor-pointer sm:w-auto"
|
|
data-testid="azure-devops-save-button"
|
|
>
|
|
<IconDeviceFloppy className="h-4 w-4" />
|
|
{saveButtonLabel(t, state)}
|
|
</Button>
|
|
{state.config && (isMobile || isFinePointer || !confirmingRemove) && (
|
|
<Button
|
|
ref={removeAnchorRef}
|
|
type="button"
|
|
variant="destructive"
|
|
onClick={() => setConfirmingRemove(true)}
|
|
className="w-full cursor-pointer sm:ml-auto sm:w-auto"
|
|
data-testid="azure-devops-delete-button"
|
|
>
|
|
<IconTrash className="h-4 w-4" />
|
|
{removeLabel}
|
|
</Button>
|
|
)}
|
|
<MobileActionConfirmation
|
|
open={!!state.config && confirmingRemove}
|
|
targetKey={`${state.config?.workspaceId}:${state.config?.organizationUrl}`}
|
|
title={removeConfirmation}
|
|
subject={state.config?.organizationUrl}
|
|
cancelLabel={t("common:cancel")}
|
|
confirmLabel={removeLabel}
|
|
confirmAriaLabel={removeConfirmation}
|
|
confirmTestId="azure-devops-remove-confirm"
|
|
onOpenChange={setConfirmingRemove}
|
|
focusReturnRef={removeAnchorRef}
|
|
onConfirm={() => void state.remove()}
|
|
fallback={
|
|
!isFinePointer ? (
|
|
<InlineConfirmActions
|
|
density="touch"
|
|
testId="azure-devops-remove-inline-confirmation"
|
|
ariaLabel={removeConfirmation}
|
|
description={removeConfirmation}
|
|
cancelLabel={t("common:cancel")}
|
|
confirmLabel={removeLabel}
|
|
confirmAriaLabel={removeConfirmation}
|
|
confirmTestId="azure-devops-remove-confirm"
|
|
onCancel={() => setConfirmingRemove(false)}
|
|
onClose={() => setConfirmingRemove(false)}
|
|
onConfirm={() => void state.remove()}
|
|
/>
|
|
) : (
|
|
<ActionConfirmPopover
|
|
open={confirmingRemove}
|
|
anchorRef={removeAnchorRef}
|
|
title={removeConfirmation}
|
|
cancelLabel={t("common:cancel")}
|
|
confirmLabel={removeLabel}
|
|
confirmAriaLabel={removeConfirmation}
|
|
confirmTestId="azure-devops-remove-confirm"
|
|
testId="azure-devops-remove-confirm-popover"
|
|
onOpenChange={setConfirmingRemove}
|
|
onCancel={() => setConfirmingRemove(false)}
|
|
onConfirm={() => void state.remove()}
|
|
/>
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 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 (
|
|
<SettingsSection
|
|
discoveryTargetId={INTEGRATION_SETTINGS_TARGETS["azure-devops"]}
|
|
icon={<IconBrandAzure className="h-5 w-5" />}
|
|
title={t("azuredevops:integrationTitle")}
|
|
description={t("azuredevops:integrationDescription")}
|
|
action={<AzureDevOpsEnabledControl workspaceId={workspaceId} />}
|
|
>
|
|
<Card>
|
|
<CardContent className="space-y-4 pt-6">
|
|
<IntegrationAuthStatusBanner health={state.health} />
|
|
<ConnectionFields
|
|
state={state}
|
|
projects={projects}
|
|
canReusePAT={canReusePAT}
|
|
projectSelectionEnabled={projectSelectionEnabled}
|
|
/>
|
|
<TestResult result={state.testResult} />
|
|
<Separator />
|
|
<ConnectionActions state={state} disabled={disabled} />
|
|
</CardContent>
|
|
</Card>
|
|
</SettingsSection>
|
|
);
|
|
}
|
|
|
|
/** Azure DevOps's own settings page: connection, watch settings, quick actions, and default queries. */
|
|
export function AzureDevOpsIntegrationPage({ workspaceId }: { workspaceId?: string } = {}) {
|
|
return (
|
|
<WorkspaceScopedSection workspaceId={workspaceId}>
|
|
{(selectedWorkspaceId) => (
|
|
<div className="space-y-6">
|
|
<AzureDevOpsConnectionSection
|
|
key={selectedWorkspaceId}
|
|
workspaceId={selectedWorkspaceId}
|
|
/>
|
|
<AzureDevOpsWatchSettings workspaceId={selectedWorkspaceId} />
|
|
<AzureDevOpsQuickActionsSection workspaceId={selectedWorkspaceId} />
|
|
<AzureDevOpsDefaultQueriesSection workspaceId={selectedWorkspaceId} />
|
|
</div>
|
|
)}
|
|
</WorkspaceScopedSection>
|
|
);
|
|
}
|