Files
kandev/apps/web/components/jira/jira-settings.tsx
T
2026-09-12 14:58:49 +02:00

549 lines
18 KiB
TypeScript

"use client";
import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from "react";
import { IconTicket } from "@tabler/icons-react";
import { 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 { Separator } from "@kandev/ui/separator";
import { Alert, AlertDescription } from "@kandev/ui/alert";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { useToast } from "@/components/toast-provider";
import { SettingsSection } from "@/components/settings/settings-section";
import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider";
import { SettingsCard } from "@/components/settings/settings-card";
import { TaskPresetsSection } from "@/components/jira/task-presets-section";
import { JiraIssueWatchersSection } from "@/components/jira/jira-issue-watchers-section";
import { JiraEnabledControl } from "@/components/jira/jira-enabled-control";
import { JiraActionBar } from "@/components/jira/jira-action-bar";
import {
IntegrationAuthStatusBanner,
type IntegrationAuthHealth,
} from "@/components/integrations/auth-status-banner";
import { WorkspaceScopedSection } from "@/components/integrations/workspace-scoped-section";
import { INTEGRATION_STATUS_REFRESH_MS } from "@/hooks/domains/integrations/use-integration-availability";
import {
getJiraConfig,
setJiraConfig,
deleteJiraConfig,
testJiraConnection,
} from "@/lib/api/domains/jira-api";
import type {
JiraAuthMethod,
JiraConfig,
JiraInstanceType,
TestJiraConnectionResult,
} from "@/lib/types/jira";
import { useTranslation } from "react-i18next";
import {
configToForm,
deriveFormState,
emptyForm,
type FormState,
} from "@/components/jira/jira-form-state";
import {
CookieExpiry,
SecretHelp,
secretCopy,
secretPlaceholder,
} from "@/components/jira/jira-secret-help";
import { OAuthFields } from "@/components/jira/jira-oauth-fields";
import { INTEGRATION_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/integrations";
// Cloud sites live under this domain; shown as an example, not as prose, so it
// is interpolated rather than written into the catalog.
const ATLASSIAN_CLOUD_DOMAIN = "*.atlassian.net";
// defaultAuthForInstance returns the canonical auth method for an instance
// type. Used when the user switches Instance type and the current auth method
// is no longer valid for the new type (e.g. PAT picked for Cloud).
function defaultAuthForInstance(instance: JiraInstanceType): JiraAuthMethod {
return instance === "server" ? "pat" : "api_token";
}
// authAllowedForInstance reports whether an auth method is allowed for a given
// instance type. Mirrors the backend validation so the user can't submit an
// invalid combination. session_cookie is Cloud-only today because the backend
// wraps the secret under cloud.session.token / tenant.session.token cookie
// names — Server/DC uses JSESSIONID, so the wrapping is a no-op there until we
// add a Server-aware path.
function authAllowedForInstance(auth: JiraAuthMethod, instance: JiraInstanceType): boolean {
if (auth === "api_token") return instance === "cloud";
if (auth === "pat") return instance === "server";
if (auth === "session_cookie") return instance === "cloud";
if (auth === "oauth") return instance === "cloud";
return false;
}
type FieldsRowProps = {
form: FormState;
baseline: FormState;
loading: boolean;
update: <K extends keyof FormState>(key: K, value: FormState[K]) => void;
};
type InstanceFieldsProps = FieldsRowProps & {
setForm: Dispatch<SetStateAction<FormState>>;
};
function InstanceFields({ form, baseline, loading, setForm }: InstanceFieldsProps) {
const { t } = useTranslation();
return (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="jira-instance">{t("jira:instanceType")}</Label>
<Select
value={form.instanceType}
onValueChange={(v) => {
const next = v as JiraInstanceType;
setForm((prev) => {
// Switching instance type changes which auth methods are valid;
// if the current one would become invalid, swap it for the
// canonical default for the new instance.
const auth = authAllowedForInstance(prev.authMethod, next)
? prev.authMethod
: defaultAuthForInstance(next);
return { ...prev, instanceType: next, authMethod: auth };
});
}}
disabled={loading}
>
<SelectTrigger
id="jira-instance"
className="w-full cursor-pointer"
data-settings-dirty={form.instanceType !== baseline.instanceType}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="cloud">{t("jira:atlassianCloud")}</SelectItem>
<SelectItem value="server">{t("jira:serverDataCenter")}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{form.instanceType === "cloud"
? t("jira:sitesHostedAt", { domain: ATLASSIAN_CLOUD_DOMAIN })
: t("jira:selfHostedJiraServerOrDataCenter")}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="jira-project">{t("jira:defaultProjectKeyOptional")}</Label>
<Input
id="jira-project"
data-testid="jira-project-input"
placeholder="PROJ"
value={form.defaultProjectKey}
data-settings-dirty={form.defaultProjectKey !== baseline.defaultProjectKey}
onChange={(e) =>
setForm((prev) => ({ ...prev, defaultProjectKey: e.target.value.toUpperCase() }))
}
disabled={loading}
/>
</div>
</div>
);
}
function SiteFields({ form, baseline, loading, update }: FieldsRowProps) {
const { t } = useTranslation();
const placeholder =
form.instanceType === "server" ? "https://jira.your-company.com" : "https://acme.atlassian.net";
return (
<div className="space-y-1.5">
<Label htmlFor="jira-site">{t("jira:siteUrl")}</Label>
<Input
id="jira-site"
data-testid="jira-site-input"
placeholder={placeholder}
value={form.siteUrl}
data-settings-dirty={form.siteUrl !== baseline.siteUrl}
onChange={(e) => update("siteUrl", e.target.value)}
disabled={loading}
/>
</div>
);
}
function AuthFields({ form, baseline, loading, update }: FieldsRowProps) {
const { t } = useTranslation();
const showEmail = form.instanceType === "cloud" && form.authMethod === "api_token";
return (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="jira-auth">{t("jira:authenticationMethod")}</Label>
<Select
value={form.authMethod}
onValueChange={(v) => update("authMethod", v as JiraAuthMethod)}
disabled={loading}
>
<SelectTrigger
id="jira-auth"
className="w-full cursor-pointer"
data-settings-dirty={form.authMethod !== baseline.authMethod}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{form.instanceType === "cloud" ? (
<>
<SelectItem value="api_token">{t("jira:apiTokenRecommended")}</SelectItem>
<SelectItem value="oauth">{t("jira:oauth2")}</SelectItem>
<SelectItem value="session_cookie">{t("jira:browserSessionCookie")}</SelectItem>
</>
) : (
<SelectItem value="pat">{t("jira:personalAccessToken")}</SelectItem>
)}
</SelectContent>
</Select>
</div>
{showEmail ? (
<div className="space-y-1.5">
<Label htmlFor="jira-email">{t("jira:email")}</Label>
<Input
id="jira-email"
data-testid="jira-email-input"
type="email"
placeholder="you@example.com"
value={form.email}
data-settings-dirty={form.email !== baseline.email}
onChange={(e) => update("email", e.target.value)}
disabled={loading}
/>
</div>
) : (
// Keep the grid balanced so the auth select doesn't span both columns.
<div aria-hidden className="hidden sm:block" />
)}
</div>
);
}
type SecretFieldProps = FieldsRowProps & { hasSavedSecret: boolean };
type SecretFieldPropsWithExpiry = SecretFieldProps & { secretExpiresAt?: string | null };
function SecretField({
form,
baseline,
loading,
update,
hasSavedSecret,
secretExpiresAt,
}: SecretFieldPropsWithExpiry) {
const { t } = useTranslation();
const method = form.authMethod;
const siteUrl = form.siteUrl.replace(/\/+$/, "");
const patHref = siteUrl ? `${siteUrl}/secure/ViewProfile.jspa` : undefined;
return (
<div className="space-y-1.5">
<Label htmlFor="jira-secret">
{secretCopy(t)[method].label}
{hasSavedSecret && (
<span className="text-xs text-muted-foreground ml-2">{t("jira:savedLeaveBlank")}</span>
)}
</Label>
<Input
id="jira-secret"
data-testid="jira-secret-input"
type="password"
placeholder={secretPlaceholder(t, method, hasSavedSecret)}
value={form.secret}
data-settings-dirty={form.secret !== baseline.secret}
onChange={(e) => update("secret", e.target.value)}
disabled={loading}
className={settingsCredentialClassName()}
/>
{method === "session_cookie" && hasSavedSecret && secretExpiresAt && (
<CookieExpiry expiresAt={secretExpiresAt} />
)}
<SecretHelp method={method} patHref={patHref} />
</div>
);
}
function TestResultAlert({ result }: { result: TestJiraConnectionResult | null }) {
const { t } = useTranslation();
if (!result) return null;
return (
<Alert variant={result.ok ? "default" : "destructive"}>
<AlertDescription>
{/* Both values are Jira's own reply — an account name and an upstream
error message — so they travel through `values`, never the catalog. */}
{result.ok
? t("jira:connectedAs", {
name: result.displayName || result.email || result.accountId,
})
: t("jira:testFailed", { error: result.error })}
</AlertDescription>
</Alert>
);
}
function configToHealth(config: JiraConfig | null): IntegrationAuthHealth | null {
if (!config?.hasSecret) return null;
if (!config.lastCheckedAt) return { ok: false, error: "", checkedAt: null };
return {
ok: !!config.lastOk,
error: config.lastError ?? "",
checkedAt: new Date(config.lastCheckedAt),
};
}
function useJiraConfigRefresh(workspaceId: string, setConfig: (cfg: JiraConfig | null) => void) {
// Background refresh so the auth-health banner picks up new probe results
// from the backend poller without requiring a page reload. We re-fetch the
// config rather than the loud full `load()` to avoid flashing the form.
useEffect(() => {
const id = setInterval(() => {
getJiraConfig({ workspaceId })
.then((cfg) => setConfig(cfg))
.catch(() => {
/* transient failures are fine — next tick retries */
});
}, INTEGRATION_STATUS_REFRESH_MS);
return () => clearInterval(id);
}, [workspaceId, setConfig]);
}
type JiraConfigMutationDeps = {
workspaceId: string;
form: FormState;
setConfig: Dispatch<SetStateAction<JiraConfig | null>>;
setForm: Dispatch<SetStateAction<FormState>>;
setTestResult: (result: TestJiraConnectionResult | null) => void;
setSaving: (saving: boolean) => void;
};
// The two callbacks that write the config and then re-seed the form, split out of
// useJiraSettings to keep each hook inside the function-length limit.
function useJiraConfigMutations({
workspaceId,
form,
setConfig,
setForm,
setTestResult,
setSaving,
}: JiraConfigMutationDeps) {
const { t } = useTranslation();
const { toast } = useToast();
const handleSave = useCallback(async () => {
const submitted = form;
setSaving(true);
try {
const saved = await setJiraConfig(
{
siteUrl: form.siteUrl,
email: form.email,
authMethod: form.authMethod,
instanceType: form.instanceType,
defaultProjectKey: form.defaultProjectKey,
secret: form.secret || undefined,
clientId: form.clientId || undefined,
},
{ workspaceId },
);
setConfig(saved);
setForm((current) =>
JSON.stringify(current) === JSON.stringify(submitted) ? configToForm(saved) : current,
);
// Clear any inline test result from the previous credentials so the
// alert reflects only the currently-saved state.
setTestResult(null);
toast({ description: t("jira:jiraConfigurationSaved"), variant: "success" });
} catch (err) {
toast({ description: t("jira:saveFailed", { error: String(err) }), variant: "error" });
throw err;
} finally {
setSaving(false);
}
}, [workspaceId, form, setConfig, setForm, setTestResult, setSaving, t, toast]);
const handleDelete = useCallback(async () => {
try {
await deleteJiraConfig({ workspaceId });
setConfig(null);
setForm(emptyForm);
setTestResult(null);
toast({ description: t("jira:jiraConfigurationRemoved"), variant: "success" });
} catch (err) {
toast({ description: t("jira:deleteFailed", { error: String(err) }), variant: "error" });
}
}, [workspaceId, setConfig, setForm, setTestResult, t, toast]);
return { handleSave, handleDelete };
}
function useJiraSettings(workspaceId: string) {
const { t } = useTranslation();
const { toast } = useToast();
const [config, setConfig] = useState<JiraConfig | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<TestJiraConnectionResult | null>(null);
const health = configToHealth(config);
const load = useCallback(async () => {
setLoading(true);
try {
const cfg = await getJiraConfig({ workspaceId });
setConfig(cfg);
setForm(configToForm(cfg));
} catch (err) {
toast({
description: t("jira:failedToLoadJiraConfig", { error: String(err) }),
variant: "error",
});
} finally {
setLoading(false);
}
}, [workspaceId, t, toast]);
useEffect(() => {
void load();
}, [load]);
useJiraConfigRefresh(workspaceId, setConfig);
const update = useCallback(
<K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((prev) => ({ ...prev, [key]: value })),
[],
);
const handleTest = useCallback(async () => {
setTesting(true);
setTestResult(null);
try {
const res = await testJiraConnection({ ...form }, { workspaceId });
setTestResult(res);
} catch (err) {
setTestResult({ ok: false, error: String(err) });
} finally {
setTesting(false);
}
}, [workspaceId, form]);
const { handleSave, handleDelete } = useJiraConfigMutations({
workspaceId,
form,
setConfig,
setForm,
setTestResult,
setSaving,
});
const discard = useCallback(() => setForm(configToForm(config)), [config]);
return {
config,
form,
setForm,
loading,
saving,
testing,
testResult,
health,
update,
handleTest,
handleSave,
handleDelete,
discard,
load,
};
}
export function JiraConnectionSection({ workspaceId }: { workspaceId: string }) {
const { t } = useTranslation();
const s = useJiraSettings(workspaceId);
const baseline = configToForm(s.config);
const {
isOAuth,
savedSecretMatchesMode,
disableSave,
disableTest,
revision,
dirty,
invalidReason,
} = deriveFormState(s, t);
useSettingsSaveContributor({
id: `jira-config:${workspaceId}`,
revision,
isDirty: dirty,
canSave: !disableSave,
invalidReason,
save: s.handleSave,
discard: s.discard,
});
return (
<SettingsSection
discoveryTargetId={INTEGRATION_SETTINGS_TARGETS.jira}
icon={<IconTicket className="h-5 w-5" />}
title={t("jira:jiraIntegration")}
description={t("jira:connectThisWorkspaceToAtlassianCloud")}
action={<JiraEnabledControl workspaceId={workspaceId} />}
>
<SettingsCard isDirty={dirty}>
<CardContent className="space-y-4 pt-6">
<IntegrationAuthStatusBanner health={s.health} />
<InstanceFields
form={s.form}
baseline={baseline}
loading={s.loading}
update={s.update}
setForm={s.setForm}
/>
<SiteFields form={s.form} baseline={baseline} loading={s.loading} update={s.update} />
<AuthFields form={s.form} baseline={baseline} loading={s.loading} update={s.update} />
{isOAuth ? (
<OAuthFields
form={s.form}
loading={s.loading}
workspaceId={workspaceId}
connected={savedSecretMatchesMode}
tokenExpiresAt={s.config?.tokenExpiresAt ?? null}
onConnected={() => void s.load()}
/>
) : (
<SecretField
form={s.form}
baseline={baseline}
loading={s.loading}
update={s.update}
hasSavedSecret={savedSecretMatchesMode}
secretExpiresAt={s.config?.secretExpiresAt ?? null}
/>
)}
<TestResultAlert result={s.testResult} />
<Separator />
<JiraActionBar
workspaceId={workspaceId}
testing={s.testing}
loading={s.loading}
hasConfig={!!s.config}
disableTest={disableTest}
onTest={s.handleTest}
onDelete={s.handleDelete}
/>
</CardContent>
</SettingsCard>
</SettingsSection>
);
}
export function JiraIntegrationPage({ workspaceId }: { workspaceId?: string } = {}) {
return (
<div className="space-y-8">
<WorkspaceScopedSection workspaceId={workspaceId}>
{(workspaceId) => <JiraConnectionSection key={workspaceId} workspaceId={workspaceId} />}
</WorkspaceScopedSection>
<JiraIssueWatchersSection />
<TaskPresetsSection />
</div>
);
}