"use client"; import { useCallback, useEffect, useState } from "react"; import { IconHexagon } from "@tabler/icons-react"; 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 { Separator } from "@kandev/ui/separator"; import { Alert, AlertDescription } from "@kandev/ui/alert"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; import { Switch } from "@kandev/ui/switch"; import { useToast } from "@/components/toast-provider"; import { SettingsSection } from "@/components/settings/settings-section"; import { useLinearEnabled } from "@/hooks/domains/linear/use-linear-enabled"; import { IntegrationAuthStatusBanner, type IntegrationAuthHealth, } from "@/components/integrations/auth-status-banner"; 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 { LinearIssueWatchersSection } from "./linear-issue-watchers-section"; type FormState = { defaultTeamKey: string; secret: string; }; const emptyForm: FormState = { defaultTeamKey: "", secret: "" }; function configToForm(cfg: LinearConfig | null): FormState { if (!cfg) return emptyForm; return { defaultTeamKey: cfg.defaultTeamKey, secret: "" }; } function saveLabel(saving: boolean, hasConfig: boolean): string { if (saving) return "Saving..."; return hasConfig ? "Update" : "Save"; } type FieldsRowProps = { form: FormState; loading: boolean; update: (key: K, value: FormState[K]) => void; hasSavedSecret: boolean; teams: LinearTeam[]; loadingTeams: boolean; }; function SecretField({ form, loading, update, hasSavedSecret, }: Omit) { return (
update("secret", e.target.value)} disabled={loading} />

Create a personal API key at{" "} linear.app/settings/account/security

); } function TeamSelector({ form, loading, update, teams, loadingTeams }: FieldsRowProps) { return (
); } function TestResultAlert({ result }: { result: TestLinearConnectionResult | null }) { if (!result) return null; return ( {result.ok ? `Connected as ${result.displayName || result.email || result.userId}${result.orgName ? ` (${result.orgName})` : ""}` : `Failed: ${result.error}`} ); } 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 = { saving: boolean; testing: boolean; loading: boolean; hasConfig: boolean; disableSave: boolean; disableTest: boolean; onTest: () => void; onSave: () => void; onDelete: () => void; }; function ActionBar({ saving, testing, loading, hasConfig, disableSave, disableTest, onTest, onSave, onDelete, }: ActionBarProps) { return (
{hasConfig && ( )}
); } type SettingsActionsArgs = { form: FormState; setConfig: (cfg: LinearConfig | null) => void; setForm: (form: FormState) => void; setTestResult: (r: TestLinearConnectionResult | null) => void; }; function useSettingsActions({ form, setConfig, setForm, setTestResult }: SettingsActionsArgs) { 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, }); setTestResult(res); } catch (err) { setTestResult({ ok: false, error: String(err) }); } finally { setTesting(false); } }, [form, setTestResult]); const handleSave = useCallback(async () => { setSaving(true); try { const saved = await setLinearConfig({ authMethod: "api_key", defaultTeamKey: form.defaultTeamKey, secret: form.secret || undefined, }); setConfig(saved); setForm(configToForm(saved)); setTestResult(null); toast({ description: "Linear configuration saved", variant: "success" }); } catch (err) { toast({ description: `Save failed: ${String(err)}`, variant: "error" }); } finally { setSaving(false); } }, [form, toast, setConfig, setForm, setTestResult]); const handleDelete = useCallback(async () => { if (!confirm("Remove Linear configuration?")) return; try { await deleteLinearConfig(); setConfig(null); setForm(emptyForm); setTestResult(null); toast({ description: "Linear configuration removed", variant: "success" }); } catch (err) { toast({ description: `Delete failed: ${String(err)}`, variant: "error" }); } }, [toast, setConfig, setForm, setTestResult]); return { saving, testing, handleTest, handleSave, handleDelete }; } function useTeamsLoader(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() .then((res) => { if (!cancelled) setTeams(res.teams ?? []); }) .catch(() => { if (!cancelled) setTeams([]); }); return () => { cancelled = true; }; }, [hasSecret, lastOk]); return { teams: teams ?? [], loadingTeams: teams === null && !!hasSecret }; } function useLinearSettings() { const { toast } = useToast(); const [config, setConfig] = 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(config?.hasSecret, config?.lastOk); const load = useCallback(async () => { setLoading(true); try { const cfg = await getLinearConfig(); setConfig(cfg); setForm(configToForm(cfg)); } catch (err) { toast({ description: `Failed to load Linear config: ${String(err)}`, variant: "error" }); } finally { setLoading(false); } }, [toast]); useEffect(() => { void load(); }, [load]); // Background refresh so the auth-health banner picks up new probe results. useEffect(() => { const id = setInterval(() => { getLinearConfig() .then((cfg) => setConfig(cfg)) .catch(() => { /* transient failures are fine — next tick retries */ }); }, INTEGRATION_STATUS_REFRESH_MS); return () => clearInterval(id); }, []); const update = useCallback( (key: K, value: FormState[K]) => setForm((prev) => ({ ...prev, [key]: value })), [], ); const { saving, testing, handleTest, handleSave, handleDelete } = useSettingsActions({ form, setConfig, setForm, setTestResult, }); return { config, form, loading, saving, testing, testResult, health, teams, loadingTeams, update, handleTest, handleSave, handleDelete, }; } function EnabledPill() { const { enabled, setEnabled } = useLinearEnabled(); return (
); } // LinearConnectionSection holds the install-wide credentials form. Linear has // no per-workspace state to surface here today, so the page composes only this // section. export function LinearConnectionSection() { const s = useLinearSettings(); const missingSecret = !s.config?.hasSecret && !s.form.secret; const disableSave = s.saving || missingSecret; const disableTest = missingSecret; return ( } title="Linear integration" description="Connect Kandev to Linear with a personal API key. Credentials are stored encrypted server-side and shared across all workspaces." action={} > ); } export function LinearIntegrationPage() { return (
); }