Files
minstrel/web/src/routes/admin/integrations/+page.svelte
T
bvandeusen 9ed576a029 fix(ci): pgconn import path + reset-password token type + SMTP label
Three CI fixes from the U1+U2+U3 push:

- internal/api/admin_users.go imported the standalone
  github.com/jackc/pgconn — that module isn't in go.sum and
  go vet caught it. Switch to github.com/jackc/pgx/v5/pgconn
  (the path used elsewhere in the package, e.g. auth_register.go
  and me_profile.go).

- /reset-password/[token] dereferenced page.params.token without
  the undefined narrowing svelte-kit's typegen requires. Coalesce
  to '' on read and reject empty token at submit time with a
  clear error.

- /admin/integrations SMTP card had a label without an associated
  control as the TLS row's leading column header. Switched to a
  span — the actual checkbox lives in the wrapping label below it.
2026-05-07 17:48:44 -04:00

674 lines
27 KiB
Svelte

<script lang="ts">
import { pageTitle } from '$lib/branding';
import { Save, RefreshCw, Trash2 } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import {
createLidarrConfigQuery,
createQualityProfilesQuery,
createMetadataProfilesQuery,
createRootFoldersQuery,
putLidarrConfig,
testLidarrConnection,
createCoverProvidersQuery,
updateCoverProvider,
testCoverProvider,
updateSMTPConfig,
testSMTPConfig,
createSMTPConfigQuery,
type CoverProvider,
type SMTPConfig
} from '$lib/api/admin';
import { qk } from '$lib/api/queries';
import type { LidarrConfig, LidarrTestResult } from '$lib/api/types';
// Lidarr connection panel. The "saved api key" is masked as "***" on GET —
// never displayed in the input. The input always starts empty; sending an
// empty string on PUT tells the backend "preserve the saved key". This is
// the one field with that semantics; everything else is sent as-typed.
const client = useQueryClient();
const configStore = createLidarrConfigQuery();
const config = $derived($configStore);
// Local form state — initialized once when config first loads. Subsequent
// refetches don't clobber whatever the operator is in the middle of editing.
let baseUrl = $state('');
let apiKeyInput = $state(''); // intentionally never seeded with '***'
let qualityId = $state<number>(0);
let metadataId = $state<number>(0);
let rootPath = $state<string>('');
let initialized = false;
$effect(() => {
const c = config.data;
if (c && !initialized) {
baseUrl = c.base_url;
qualityId = c.default_quality_profile_id;
metadataId = c.default_metadata_profile_id;
rootPath = c.default_root_folder_path;
initialized = true;
}
});
// Auto-default to the first option Lidarr returns when the operator
// hasn't picked one yet. Saves a click for the common case (one
// profile + one root folder, which is what most home setups have).
// The operator can still change any of them before saving.
$effect(() => {
if (qualityId === 0 && profiles.data && profiles.data.length > 0) {
qualityId = profiles.data[0].id;
}
});
$effect(() => {
if (metadataId === 0 && metadataProfiles.data && metadataProfiles.data.length > 0) {
metadataId = metadataProfiles.data[0].id;
}
});
$effect(() => {
if (rootPath === '' && folders.data && folders.data.length > 0) {
rootPath = folders.data[0].path;
}
});
// The dropdown queries only fire once Lidarr is configured; otherwise the
// backend has no client to call and would 4xx.
const profilesEnabled = $derived(!!config.data?.enabled);
const profilesStore = $derived(createQualityProfilesQuery(profilesEnabled));
const profiles = $derived($profilesStore);
const metadataProfilesStore = $derived(createMetadataProfilesQuery(profilesEnabled));
const metadataProfiles = $derived($metadataProfilesStore);
const foldersStore = $derived(createRootFoldersQuery(profilesEnabled));
const folders = $derived($foldersStore);
let testResult: LidarrTestResult | null = $state(null);
let saveError: string | null = $state(null);
let isSaving = $state(false);
let isTesting = $state(false);
async function onSave() {
isSaving = true;
saveError = null;
try {
const cfg: LidarrConfig = {
enabled: true,
base_url: baseUrl,
api_key: apiKeyInput, // empty string tells backend "preserve saved key"
default_quality_profile_id: qualityId,
default_metadata_profile_id: metadataId,
default_root_folder_path: rootPath
};
await putLidarrConfig(cfg);
// Invalidate config + profile/folder lists. A new base_url means a
// different server, so the cached lists must refetch.
await Promise.all([
client.invalidateQueries({ queryKey: qk.lidarrConfig() }),
client.invalidateQueries({ queryKey: qk.lidarrQualityProfiles() }),
client.invalidateQueries({ queryKey: qk.lidarrMetadataProfiles() }),
client.invalidateQueries({ queryKey: qk.lidarrRootFolders() })
]);
apiKeyInput = '';
} catch (e) {
saveError = (e as { code?: string }).code ?? 'save_failed';
} finally {
isSaving = false;
}
}
async function onTest() {
isTesting = true;
testResult = null;
try {
testResult = await testLidarrConnection({
base_url: baseUrl,
api_key: apiKeyInput
});
} finally {
isTesting = false;
}
}
// Disconnect typed-confirm modal. Requires the literal "DISCONNECT" string
// (whitespace-trimmed) to avoid muscle-memory clearing of an integration the
// operator depends on.
let modalOpen = $state(false);
let disconnectInput = $state('');
let disconnectError = $state<string | null>(null);
const canDisconnect = $derived(disconnectInput.trim() === 'DISCONNECT');
async function onConfirmDisconnect() {
if (!canDisconnect) return;
disconnectError = null;
try {
await putLidarrConfig({
enabled: false,
base_url: '',
api_key: '',
default_quality_profile_id: 0,
default_metadata_profile_id: 0,
default_root_folder_path: ''
});
await Promise.all([
client.invalidateQueries({ queryKey: qk.lidarrConfig() }),
client.invalidateQueries({ queryKey: qk.lidarrQualityProfiles() }),
client.invalidateQueries({ queryKey: qk.lidarrMetadataProfiles() }),
client.invalidateQueries({ queryKey: qk.lidarrRootFolders() })
]);
modalOpen = false;
disconnectInput = '';
} catch (e) {
disconnectError = (e as { code?: string }).code ?? 'disconnect_failed';
}
}
function cancelDisconnect() {
modalOpen = false;
disconnectInput = '';
disconnectError = null;
}
// ---- Cover art providers ----
const coverProvidersStore = $derived(createCoverProvidersQuery());
const coverProvidersQ = $derived($coverProvidersStore);
// Local edit state — keyed by provider_id. Initialised lazily when
// the query data lands, then preserved across refetches so the
// operator's in-flight edits don't get clobbered.
let coverLocalState = $state<Record<string, { enabled: boolean; apiKey: string }>>({});
let coverTestResults = $state<Record<string, { ok: boolean; duration_ms?: number; error?: string }>>({});
let coverSaving = $state<Record<string, boolean>>({});
let coverTesting = $state<Record<string, boolean>>({});
$effect(() => {
if (coverProvidersQ.data) {
for (const p of coverProvidersQ.data.providers) {
if (!coverLocalState[p.id]) {
coverLocalState[p.id] = { enabled: p.enabled, apiKey: '' };
}
}
}
});
function coverHasChanges(provider: CoverProvider): boolean {
const local = coverLocalState[provider.id];
if (!local) return false;
return provider.enabled !== local.enabled || local.apiKey !== '';
}
async function coverSave(provider: CoverProvider) {
const local = coverLocalState[provider.id];
if (!local || !coverHasChanges(provider)) return;
const patch: { enabled?: boolean; api_key?: string } = {};
if (provider.enabled !== local.enabled) patch.enabled = local.enabled;
if (local.apiKey !== '') patch.api_key = local.apiKey;
coverSaving[provider.id] = true;
try {
const result = await updateCoverProvider(provider.id, patch);
coverLocalState[provider.id] = { enabled: result.enabled, apiKey: '' };
await client.invalidateQueries({ queryKey: qk.coverProviders() });
if (result.version_bumped) {
await client.invalidateQueries({ queryKey: qk.coverage() });
}
} catch (_e) {
// Error display is handled inline via saveError pattern; swallow here.
} finally {
coverSaving[provider.id] = false;
}
}
async function coverTest(provider: CoverProvider) {
coverTesting[provider.id] = true;
try {
coverTestResults[provider.id] = await testCoverProvider(provider.id);
} catch (e) {
coverTestResults[provider.id] = { ok: false, error: (e as Error).message };
} finally {
coverTesting[provider.id] = false;
}
}
// Toast -------------------------------------------------------------------
let toast = $state<string | null>(null);
let toastTimer: ReturnType<typeof setTimeout> | undefined;
function showToast(msg: string) {
toast = msg;
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => { toast = null; }, 4000);
}
// SMTP config -------------------------------------------------------------
const smtpStore = $derived(createSMTPConfigQuery());
const smtpQ = $derived($smtpStore);
let smtpForm = $state<SMTPConfig>({
enabled: false, host: '', port: 587, username: '',
password: '', from_address: '', from_name: 'Minstrel', use_tls: true,
});
let smtpPasswordInput = $state('');
let smtpSaving = $state(false);
let smtpTesting = $state(false);
$effect(() => {
if (smtpQ.data) {
smtpForm = smtpQ.data;
smtpPasswordInput = '';
}
});
async function onSaveSMTP() {
smtpSaving = true;
try {
await updateSMTPConfig({ ...smtpForm, password: smtpPasswordInput });
await client.invalidateQueries({ queryKey: qk.smtpConfig() });
smtpPasswordInput = '';
showToast('SMTP config saved.');
} catch (e: unknown) {
const code = (e as { code?: string })?.code;
if (code === 'missing_fields') showToast('Host and from address are required when enabled.');
else showToast(`Save failed: ${code ?? 'unknown'}`);
} finally {
smtpSaving = false;
}
}
async function onTestSMTP() {
smtpTesting = true;
try {
await testSMTPConfig();
showToast('Test email sent. Check your inbox.');
} catch (e: unknown) {
const code = (e as { code?: string })?.code;
const message = (e as { message?: string })?.message;
if (code === 'no_email_on_file') showToast('Set your email in /settings before testing.');
else if (code === 'not_configured') showToast('Save the SMTP config first.');
else if (code === 'send_failed') showToast(`Send failed: ${message || 'see server logs'}`);
else showToast(`Test failed: ${code ?? 'unknown'}`);
} finally {
smtpTesting = false;
}
}
</script>
<svelte:head><title>{pageTitle('Admin · Integrations')}</title></svelte:head>
{#if toast}
<div data-testid="toast" role="status"
class="fixed bottom-4 right-4 z-50 rounded-md border border-border bg-surface px-4 py-2 text-sm text-text-primary shadow">
{toast}
</div>
{/if}
<div class="space-y-6">
<header class="flex items-center justify-between">
<h2 class="font-display text-2xl font-medium text-text-primary">Integrations</h2>
{#if config.data?.enabled}
<span
class="inline-flex items-center gap-2 rounded-md bg-accent-tint px-3 py-1 text-xs font-medium text-action-primary"
>
<span class="h-1.5 w-1.5 rounded-full bg-action-primary"></span>
Lidarr · connected
</span>
{:else}
<span
class="inline-flex items-center gap-2 rounded-md border border-border px-3 py-1 text-xs text-text-muted"
>
<span class="h-1.5 w-1.5 rounded-full bg-text-muted"></span>
Lidarr · unset
</span>
{/if}
</header>
<!-- Lidarr panel -->
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
<div>
<h3 class="font-display text-lg font-medium text-text-primary">Lidarr</h3>
<p class="mt-1 text-sm text-text-secondary">
Search Lidarr from <span class="font-mono text-accent">/discover</span> and
route approved requests to it.
</p>
</div>
<label class="block">
<span class="block text-sm text-text-secondary">Base URL</span>
<input
type="text"
bind:value={baseUrl}
placeholder="http://lidarr.lan:8686"
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent"
/>
</label>
<label class="block">
<span class="block text-sm text-text-secondary">API key</span>
<input
type="password"
bind:value={apiKeyInput}
placeholder={config.data?.api_key === '***'
? '••• (saved — leave empty to keep)'
: 'Paste API key'}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent"
/>
</label>
<label class="block">
<span class="block text-sm text-text-secondary">Default quality profile</span>
<select
bind:value={qualityId}
disabled={!profilesEnabled || profiles.isPending}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent disabled:opacity-50"
>
{#each profiles.data ?? [] as p (p.id)}
<option value={p.id}>{p.name}</option>
{/each}
</select>
</label>
<label class="block">
<span class="block text-sm text-text-secondary">Default metadata profile</span>
<select
bind:value={metadataId}
disabled={!profilesEnabled || metadataProfiles.isPending}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent disabled:opacity-50"
>
{#each metadataProfiles.data ?? [] as p (p.id)}
<option value={p.id}>{p.name}</option>
{/each}
</select>
</label>
<label class="block">
<span class="block text-sm text-text-secondary">Default root folder</span>
<select
bind:value={rootPath}
disabled={!profilesEnabled || folders.isPending}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent disabled:opacity-50"
>
{#each folders.data ?? [] as f (f.path)}
<option value={f.path}
>{f.path}{f.accessible ? '' : ' (not accessible)'}</option
>
{/each}
</select>
</label>
{#if testResult}
{#if testResult.ok}
<p class="text-sm text-action-primary">
Connected — Lidarr {testResult.version}
</p>
{:else}
<p class="text-sm text-error">Connection failed — {testResult.error}</p>
{/if}
{/if}
{#if saveError}
<p class="text-sm text-error">Save failed — {saveError}</p>
{/if}
<div class="flex items-center gap-2 pt-2">
<button
type="button"
onclick={onSave}
disabled={isSaving}
class="inline-flex items-center gap-2 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:opacity-50"
>
<Save size={14} strokeWidth={2} />
Save changes
</button>
<button
type="button"
onclick={onTest}
disabled={isTesting}
class="inline-flex items-center gap-2 rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50"
>
<RefreshCw size={14} strokeWidth={2} />
Test connection
</button>
<button
type="button"
onclick={() => (modalOpen = true)}
class="ml-auto inline-flex items-center gap-2 rounded-md bg-action-destructive px-3 py-1.5 text-sm text-action-fg"
>
<Trash2 size={14} strokeWidth={2} />
Disconnect
</button>
</div>
</section>
<!-- MusicBrainz overrides — visually present, not implemented. Foreshadows
future integrations so the page doesn't read like Lidarr-only. -->
<section class="space-y-2 rounded-xl border border-border bg-surface p-5 opacity-60">
<div class="flex items-center justify-between">
<h3 class="font-display text-lg font-medium text-text-primary">
MusicBrainz overrides
</h3>
<span
class="inline-flex items-center gap-2 rounded-md border border-border px-3 py-1 text-xs text-text-muted"
>
<span class="h-1.5 w-1.5 rounded-full bg-text-muted"></span>
unset
</span>
</div>
<p class="text-sm text-text-secondary">Not yet configured.</p>
</section>
<!-- Cover art providers -->
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
<div>
<h3 class="font-display text-lg font-medium text-text-primary">Cover art providers</h3>
<p class="mt-1 text-sm text-text-secondary">
External sources for album covers and artist images. Providers
are tried in registration order; first hit wins.
</p>
</div>
{#if coverProvidersQ.data}
{#each coverProvidersQ.data.providers as provider (provider.id)}
<article class="rounded-lg border border-border bg-background p-4 space-y-3">
<header class="flex items-baseline justify-between gap-3">
<div class="flex items-center gap-2">
<h4 class="font-display text-base font-medium text-text-primary">{provider.display_name}</h4>
<span
class="h-1.5 w-1.5 rounded-full {provider.enabled ? 'bg-action-primary' : 'bg-text-muted'}"
title={provider.enabled ? 'Active' : 'Disabled'}
></span>
</div>
<div class="flex flex-wrap gap-1">
{#each provider.supports as cap}
<span class="rounded bg-surface px-1.5 py-0.5 text-xs text-text-secondary">{cap.replace('_', ' ')}</span>
{/each}
</div>
</header>
{#if coverLocalState[provider.id]}
<div class="grid gap-2 sm:grid-cols-[auto_1fr] items-center">
<label class="text-sm text-text-secondary" for="enabled-{provider.id}">Enabled</label>
<div>
<input id="enabled-{provider.id}" type="checkbox"
bind:checked={coverLocalState[provider.id].enabled} />
</div>
{#if provider.requires_api_key}
<label class="text-sm text-text-secondary" for="key-{provider.id}">API key</label>
<div class="flex items-center gap-2">
<input id="key-{provider.id}" type="password"
placeholder={provider.api_key_set ? '••• (saved — leave empty to keep)' : 'Leave blank to use the public test key'}
bind:value={coverLocalState[provider.id].apiKey}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent flex-1" />
{#if provider.api_key_set}
<span class="text-xs text-text-secondary whitespace-nowrap">✓ Set</span>
{/if}
</div>
{/if}
</div>
<div class="flex items-center gap-2 pt-1">
<button type="button"
disabled={!coverHasChanges(provider) || coverSaving[provider.id]}
onclick={() => coverSave(provider)}
class="inline-flex items-center gap-2 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:opacity-50">
{coverSaving[provider.id] ? 'Saving…' : 'Save changes'}
</button>
{#if provider.testable}
<button type="button"
disabled={coverTesting[provider.id]}
onclick={() => coverTest(provider)}
class="inline-flex items-center gap-2 rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50">
{coverTesting[provider.id] ? 'Testing…' : 'Test connection'}
</button>
{/if}
{#if coverTestResults[provider.id]}
{#if coverTestResults[provider.id].ok}
<p class="text-sm text-action-primary">
OK{coverTestResults[provider.id].duration_ms ? ` (${coverTestResults[provider.id].duration_ms}ms)` : ''}
</p>
{:else}
<p class="text-sm text-error">
Failed — {coverTestResults[provider.id].error}
</p>
{/if}
{/if}
</div>
{/if}
{#if provider.id === 'theaudiodb'}
<p class="text-xs text-text-muted">
Public test key works out of the box. A free personal key gives separate quota
accounting — apply at <a class="underline" href="https://www.theaudiodb.com/api_apply.php" target="_blank" rel="noopener noreferrer">theaudiodb.com/api_apply.php</a>.
</p>
{/if}
</article>
{/each}
{:else if coverProvidersQ.isPending}
<p class="text-sm text-text-muted">Loading…</p>
{:else}
<p class="text-sm text-text-muted">No providers registered.</p>
{/if}
</section>
<!-- SMTP / email config -->
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
<div>
<h3 class="font-display text-lg font-medium text-text-primary">Email (SMTP)</h3>
<p class="mt-1 text-sm text-text-secondary">
Configure outgoing email for password resets. The "Send test email"
button verifies your config end-to-end.
</p>
</div>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" bind:checked={smtpForm.enabled} />
Enabled
</label>
<div class="grid gap-3 sm:grid-cols-[auto_1fr] items-center">
<label for="smtp-host" class="text-sm text-text-secondary">Host</label>
<input id="smtp-host" type="text" bind:value={smtpForm.host}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent" />
<label for="smtp-port" class="text-sm text-text-secondary">Port</label>
<input id="smtp-port" type="number" min="1" max="65535"
bind:value={smtpForm.port}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent" />
<label for="smtp-username" class="text-sm text-text-secondary">Username</label>
<input id="smtp-username" type="text" bind:value={smtpForm.username}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent" />
<label for="smtp-password" class="text-sm text-text-secondary">Password</label>
<input id="smtp-password" type="password"
placeholder={smtpForm.password === '***' ? 'Leave blank to keep current' : ''}
bind:value={smtpPasswordInput}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent" />
<label for="smtp-from" class="text-sm text-text-secondary">From address</label>
<input id="smtp-from" type="email" bind:value={smtpForm.from_address}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent" />
<label for="smtp-from-name" class="text-sm text-text-secondary">From name</label>
<input id="smtp-from-name" type="text" bind:value={smtpForm.from_name}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent" />
<span class="text-sm text-text-secondary">TLS</span>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" bind:checked={smtpForm.use_tls} />
Use STARTTLS
</label>
</div>
<div class="flex items-center gap-2 pt-2">
<button type="button" disabled={smtpSaving} onclick={onSaveSMTP}
class="inline-flex items-center gap-2 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:opacity-50">
{smtpSaving ? 'Saving…' : 'Save'}
</button>
<button type="button" disabled={smtpTesting} onclick={onTestSMTP}
class="inline-flex items-center gap-2 rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50">
{smtpTesting ? 'Sending…' : 'Send test email'}
</button>
</div>
</section>
</div>
{#if modalOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center"
style="background: rgba(0,0,0,0.5);"
onclick={cancelDisconnect}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="disconnect-title"
class="w-full max-w-md rounded-xl border border-border bg-surface p-5 shadow-lg"
onclick={(e) => e.stopPropagation()}
tabindex="-1"
>
<h3
id="disconnect-title"
class="font-display text-lg font-medium text-text-primary"
>
Disconnect Lidarr?
</h3>
<p class="mt-2 text-text-secondary">
This clears the saved configuration. Type
<span class="font-mono text-text-primary">DISCONNECT</span> to remove the
Lidarr connection.
</p>
<input
type="text"
bind:value={disconnectInput}
placeholder="DISCONNECT"
aria-label="Type DISCONNECT to confirm"
class="mt-3 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent"
/>
{#if disconnectError}
<p class="mt-2 text-sm text-error">Disconnect failed — {disconnectError}</p>
{/if}
<div class="mt-5 flex justify-end gap-2">
<button
type="button"
onclick={cancelDisconnect}
class="rounded-md bg-action-secondary px-3 py-1.5 text-sm text-action-fg"
>
Cancel
</button>
<button
type="button"
onclick={onConfirmDisconnect}
disabled={!canDisconnect}
class="rounded-md bg-action-destructive px-3 py-1.5 text-sm text-action-fg disabled:opacity-50"
>
Disconnect
</button>
</div>
</div>
</div>
{/if}