"use client"; import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from "react"; import { IconHexagon } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { CardContent } from "@kandev/ui/card"; import { Input } from "@kandev/ui/input"; 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 { useLinearEnabled } from "@/hooks/domains/linear/use-linear-enabled"; import { IntegrationAuthStatusBanner, type IntegrationAuthHealth, } from "@/components/integrations/auth-status-banner"; import { WorkspaceScopedSection } from "@/components/integrations/workspace-scoped-section"; import { DraftedIntegrationEnabledControl } from "@/components/integrations/drafted-integration-enabled-control"; import { INTEGRATION_STATUS_REFRESH_MS } from "@/hooks/domains/integrations/use-integration-availability"; import { getLinearConfig, setLinearConfig, deleteLinearConfig, testLinearConnection, listLinearTeams, } from "@/lib/api/domains/linear-api"; import type { LinearConfig, LinearTeam, TestLinearConnectionResult } from "@/lib/types/linear"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { LinearIssueWatchersSection } from "./linear-issue-watchers-section"; import { INTEGRATION_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/integrations"; type FormState = { defaultTeamKey: string; secret: string; }; const emptyForm: FormState = { defaultTeamKey: "", secret: "" }; // Not copy. The mask is a glyph run, `lin_api_...` is the literal shape of a // Linear personal API key, and the link is a Linear-owned URL — its visible // text is the same URL with the scheme trimmed. All four would be corrupted by // the pseudo-locale into something the user cannot match against Linear's own // UI, so they stay out of the catalog. const SECRET_MASK = "••••••••"; const SECRET_EXAMPLE = "lin_api_..."; const API_KEY_SETTINGS_URL = "https://linear.app/settings/account/security"; const API_KEY_SETTINGS_LABEL = "linear.app/settings/account/security"; function configToForm(cfg: LinearConfig | null): FormState { if (!cfg) return emptyForm; return { defaultTeamKey: cfg.defaultTeamKey, secret: "" }; } type FieldsRowProps = { form: FormState; baseline: FormState; loading: boolean; update: (key: K, value: FormState[K]) => void; hasSavedSecret: boolean; teams: LinearTeam[]; loadingTeams: boolean; }; function SecretField({ form, baseline, loading, update, hasSavedSecret, }: Omit) { const { t } = useTranslation(); return (
update("secret", e.target.value)} disabled={loading} />

{t("linear:createPersonalApiKeyAt")}{" "} {API_KEY_SETTINGS_LABEL}

); } function TeamSelector({ form, baseline, loading, update, teams, loadingTeams }: FieldsRowProps) { const { t } = useTranslation(); return (
); } // The identity and the org name are Linear API data, so they are interpolated // rather than written into the catalog — a locale (pseudo included) must not // rewrite the account the user is checking they connected as. // // Every field on the result is optional, so each interpolated value needs a // fallback: i18next leaves an unmatched placeholder in place, so a partial // response would render the literal "{{name}}" to the user rather than the // old template's "undefined". function testResultMessage(t: TFunction, result: TestLinearConnectionResult): string { if (!result.ok) { return t("linear:testFailed", { error: result.error || t("linear:unknownError") }); } const name = result.displayName || result.email || result.userId || t("linear:unknownAccount"); return result.orgName ? t("linear:connectedAsWithOrg", { name, org: result.orgName }) : t("linear:connectedAs", { name }); } function TestResultAlert({ result }: { result: TestLinearConnectionResult | null }) { const { t } = useTranslation(); if (!result) return null; return ( {testResultMessage(t, result)} ); } function configToHealth(config: LinearConfig | 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), }; } type ActionBarProps = { testing: boolean; loading: boolean; hasConfig: boolean; disableTest: boolean; onTest: () => void; onDelete: () => void; }; function ActionBar({ testing, loading, hasConfig, disableTest, onTest, onDelete }: ActionBarProps) { const { t } = useTranslation(); return (
{hasConfig && ( )}
); } type SettingsActionsArgs = { workspaceId: string; form: FormState; setConfig: (cfg: LinearConfig | null) => void; setBaselineConfig: (cfg: LinearConfig | null) => void; setForm: Dispatch>; setTestResult: (r: TestLinearConnectionResult | null) => void; }; function useSettingsActions({ workspaceId, form, setConfig, setBaselineConfig, setForm, setTestResult, }: SettingsActionsArgs) { const { t } = useTranslation(); const { toast } = useToast(); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); const handleTest = useCallback(async () => { setTesting(true); setTestResult(null); try { const res = await testLinearConnection( { authMethod: "api_key", secret: form.secret || undefined, }, { workspaceId }, ); setTestResult(res); } catch (err) { setTestResult({ ok: false, error: String(err) }); } finally { setTesting(false); } }, [workspaceId, form, setTestResult]); const handleSave = useCallback(async () => { const submitted = form; setSaving(true); try { const saved = await setLinearConfig( { authMethod: "api_key", defaultTeamKey: form.defaultTeamKey, secret: form.secret || undefined, }, { workspaceId }, ); setConfig(saved); setBaselineConfig(saved); setForm((current) => JSON.stringify(current) === JSON.stringify(submitted) ? configToForm(saved) : current, ); setTestResult(null); toast({ description: t("linear:configurationSaved"), variant: "success" }); } catch (err) { toast({ description: t("linear:saveFailed", { error: String(err) }), variant: "error" }); throw err; } finally { setSaving(false); } }, [workspaceId, form, t, toast, setConfig, setBaselineConfig, setForm, setTestResult]); const handleDelete = useCallback(async () => { if (!confirm(t("linear:removeLinearConfigurationConfirm"))) return; try { await deleteLinearConfig({ workspaceId }); setConfig(null); setBaselineConfig(null); setForm(emptyForm); setTestResult(null); toast({ description: t("linear:configurationRemoved"), variant: "success" }); } catch (err) { toast({ description: t("linear:deleteFailed", { error: String(err) }), variant: "error" }); } }, [workspaceId, t, toast, setConfig, setBaselineConfig, setForm, setTestResult]); return { saving, testing, handleTest, handleSave, handleDelete }; } function useTeamsLoader( workspaceId: string, hasSecret: boolean | undefined, lastOk: boolean | undefined, ) { // `teams === null` means "no fetch attempt yet", so the dropdown can show a // "Loading…" placeholder without us calling setState synchronously inside // the effect (which the lint rule forbids). Once a fetch settles we always // store an array, even if empty. const [teams, setTeams] = useState(null); // Fetch teams once a working configuration exists. Skips when there's no // saved secret (the API call would 503). Stale teams from a previous save // remain visible after deletion, but the dropdown is gated on hasSecret so // the user never sees them. useEffect(() => { if (!hasSecret) return; let cancelled = false; listLinearTeams({ workspaceId }) .then((res) => { if (!cancelled) setTeams(res.teams ?? []); }) .catch(() => { if (!cancelled) setTeams([]); }); return () => { cancelled = true; }; }, [workspaceId, hasSecret, lastOk]); return { teams: teams ?? [], loadingTeams: teams === null && !!hasSecret }; } function useLinearSettings(workspaceId: string) { const { t } = useTranslation(); const { toast } = useToast(); const [config, setConfig] = useState(null); const [baselineConfig, setBaselineConfig] = useState(null); const [form, setForm] = useState(emptyForm); const [loading, setLoading] = useState(true); const [testResult, setTestResult] = useState(null); const health = configToHealth(config); const { teams, loadingTeams } = useTeamsLoader(workspaceId, config?.hasSecret, config?.lastOk); const load = useCallback(async () => { setLoading(true); try { const cfg = await getLinearConfig({ workspaceId }); setConfig(cfg); setBaselineConfig(cfg); setForm(configToForm(cfg)); } catch (err) { toast({ description: t("linear:failedToLoadConfig", { error: String(err) }), variant: "error", }); } finally { setLoading(false); } }, [workspaceId, t, toast]); useEffect(() => { void load(); }, [load]); // Background refresh so the auth-health banner picks up new probe results. useEffect(() => { const id = setInterval(() => { getLinearConfig({ workspaceId }) .then((cfg) => setConfig(cfg)) .catch(() => { /* transient failures are fine — next tick retries */ }); }, INTEGRATION_STATUS_REFRESH_MS); return () => clearInterval(id); }, [workspaceId]); const update = useCallback( (key: K, value: FormState[K]) => setForm((prev) => ({ ...prev, [key]: value })), [], ); const discard = useCallback(() => setForm(configToForm(baselineConfig)), [baselineConfig]); const { saving, testing, handleTest, handleSave, handleDelete } = useSettingsActions({ workspaceId, form, setConfig, setBaselineConfig, setForm, setTestResult, }); return { config, baselineConfig, form, loading, saving, testing, testResult, health, teams, loadingTeams, update, discard, handleTest, handleSave, handleDelete, }; } function EnabledPill() { const { enabled, setEnabled } = useLinearEnabled(); return ; } export function LinearConnectionSection({ workspaceId }: { workspaceId: string }) { const { t } = useTranslation(); const s = useLinearSettings(workspaceId); const baseline = configToForm(s.baselineConfig); const missingSecret = !s.config?.hasSecret && !s.form.secret; const disableSave = s.saving || missingSecret; const disableTest = missingSecret; const revision = JSON.stringify(s.form); const dirty = !s.loading && revision !== JSON.stringify(configToForm(s.baselineConfig)); useSettingsSaveContributor({ id: `linear-config:${workspaceId}`, revision, isDirty: dirty, canSave: !disableSave, invalidReason: missingSecret ? t("linear:anApiKeyIsRequired") : undefined, save: s.handleSave, discard: s.discard, }); return ( } title={t("linear:linearIntegration")} description={t("linear:linearIntegrationDescription")} action={} > ); } type LinearIntegrationPageProps = { workspaceId?: string; }; export function LinearIntegrationPage({ workspaceId }: LinearIntegrationPageProps = {}) { return (
{(workspaceId) => }
); }