move transmission and slskd credentials into the database

both sidecars read their upstream from a new service_connections table instead of
process.env: one row per (user, service), the secret encrypted at rest, upserted
through a /_config route the app drives. transmission gains a Connection section,
soulseek gains one too, and both take over the whole app while nothing is stored.

TRANSMISSION_URL/USER/PASS/RPC_PATH and SLSKD_URL/API_KEY can come out of .env.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 18:04:22 +00:00
co-authored by Claude Opus 5
parent f20d4a300e
commit d7b775113b
26 changed files with 1235 additions and 175 deletions
@@ -0,0 +1,175 @@
import type { ServiceConnection } from '../../hooks/useServiceConnection';
import { useEffect, useState } from 'react';
import { CheckCircle2, Loader2, Plug, Trash2, TriangleAlert } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
serviceErrorMessage,
useServiceConnection,
useServiceConnectionActions,
useServiceHealth,
} from '../../hooks/useServiceConnection';
// Connecting a slskd daemon to Officer, from the app.
//
// Both the setup wizard and the permanent settings page: SoulseekView renders it in place of whatever
// section the nav asked for while nothing is connected, and the Connection section renders it for good.
// One component, so re-pointing at a different daemon later goes through exactly the code path that
// stored the first one.
//
// The API key is write-only — the GET it reads has no field that could carry one back, so the input is
// always blank and an empty input means "keep the stored key".
const HINT = 'text-[11px] leading-relaxed text-muted-foreground';
/** slskd's own default HTTP port. Officer dials it from the server, not from this browser. */
const DEFAULT_URL = 'http://localhost:5030';
const URL_HINT =
"The daemon's base URL. Officer reaches it from the server, not from this browser — so localhost here " +
'means the machine Officer runs on.';
const KEY_HINT =
'An API key from slskd.yml (web.authentication.api_keys). Officer sends it as X-API-Key on every call.';
type SaveInput = Record<string, unknown> & { url: string };
export const SoulseekConnection = () => {
const { data, isLoading } = useServiceConnection('slskd');
const { data: health } = useServiceHealth('slskd');
const { save, forget } = useServiceConnectionActions<SaveInput>('slskd');
const connection = data?.connection ?? null;
const [url, setUrl] = useState('');
const [apiKey, setApiKey] = useState('');
const [error, setError] = useState('');
// Seed from the stored row once it arrives. Keyed on its id so a save (same id) doesn't stomp what the
// owner is still typing, while forgetting and re-adding does reset the form.
useEffect(() => {
setUrl(connection?.url ?? '');
setApiKey('');
}, [connection?.id]);
const submit = async () => {
setError('');
try {
await save.mutateAsync({ url: url.trim(), apiKey: apiKey.trim() });
setApiKey('');
} catch (err) {
setError(serviceErrorMessage(err));
}
};
const remove = async () => {
setError('');
try {
await forget.mutateAsync();
} catch (err) {
setError(serviceErrorMessage(err));
}
};
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading
</div>
);
}
return (
<div className="h-full overflow-y-auto">
<div className="mx-auto flex max-w-xl flex-col gap-6 p-6">
<header className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-sky-500/15 text-sky-500">
<Plug className="h-5 w-5" />
</div>
<div>
<h2 className="text-sm font-semibold">slskd daemon</h2>
<p className={HINT}>
{connection
? 'Where Officer talks to slskd. Saving re-checks the daemon before storing anything.'
: 'Point Officer at your slskd daemon. It needs the URL and one API key.'}
</p>
</div>
</header>
{connection && <StatusRow connection={connection} health={health} />}
<div className="flex flex-col gap-4 rounded-xl border p-4">
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium">Server URL</span>
<Input
value={url}
onChange={(ev) => setUrl(ev.target.value)}
placeholder={DEFAULT_URL}
autoFocus={!connection}
autoComplete="off"
spellCheck={false}
/>
<span className={HINT}>{URL_HINT}</span>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium">API key</span>
<Input
value={apiKey}
onChange={(ev) => setApiKey(ev.target.value)}
placeholder={connection?.hasSecret ? '•••••••• (unchanged)' : 'slskd API key'}
type="password"
autoComplete="off"
spellCheck={false}
/>
<span className={HINT}>{KEY_HINT}</span>
</label>
{error && (
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 p-2.5 text-xs text-destructive">
<TriangleAlert className="mt-px h-3.5 w-3.5 shrink-0" />
<span>{error}</span>
</div>
)}
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={submit}
disabled={!url.trim() || (!apiKey.trim() && !connection?.hasSecret) || save.isPending}
>
{save.isPending && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
{connection ? 'Save' : 'Connect'}
</Button>
{connection && (
<Button size="sm" variant="ghost" onClick={remove} disabled={forget.isPending}>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Disconnect
</Button>
)}
</div>
</div>
</div>
</div>
);
};
type StatusRowProps = { connection: ServiceConnection; health: { ok: boolean; error?: string } | undefined };
const StatusRow = ({ connection, health }: StatusRowProps) => (
<div className="flex items-start gap-2 rounded-xl border p-3 text-xs">
{health?.ok ? (
<CheckCircle2 className="mt-px h-4 w-4 shrink-0 text-emerald-500" />
) : (
<TriangleAlert className="mt-px h-4 w-4 shrink-0 text-amber-500" />
)}
<div className="min-w-0">
<div className="font-medium">{health?.ok ? 'Connected' : 'Not responding'}</div>
<div className="truncate text-muted-foreground">
{connection.url}
{connection.version ? ` · slskd ${connection.version}` : ''}
</div>
{!health?.ok && health?.error && <div className="text-destructive">{health.error}</div>}
</div>
</div>
);
@@ -1,6 +1,16 @@
import type { LucideIcon } from 'lucide-react';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { LayoutGrid, Search, ArrowDownToLine, ArrowUpFromLine, Hash, MessageCircle, Users, Server } from 'lucide-react';
import {
LayoutGrid,
Search,
ArrowDownToLine,
ArrowUpFromLine,
Hash,
MessageCircle,
Users,
Server,
Plug,
} from 'lucide-react';
import { SOULSEEK_SECTION_CHANNEL, SOULSEEK_SECTIONS, type SoulseekSectionId } from './shared';
// Left panel of the /soulseek workspace — a vertical section menu mirroring slskd's top nav. Publishes
@@ -15,6 +25,7 @@ const ICONS: Record<SoulseekSectionId, LucideIcon> = {
chat: MessageCircle,
users: Users,
system: Server,
connection: Plug,
};
export const SoulseekNav = () => {
@@ -50,7 +61,9 @@ export const SoulseekNav = () => {
{active && (
<span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />
)}
<Icon className={`h-4 w-4 shrink-0 ${active ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`} />
<Icon
className={`h-4 w-4 shrink-0 ${active ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
/>
{label}
</button>
);
@@ -10,6 +10,8 @@ import { SoulseekDashboard } from './SoulseekDashboard';
import { SoulseekChat } from './SoulseekChat';
import { SoulseekUsers } from './SoulseekUsers';
import { SoulseekSystem } from './SoulseekSystem';
import { SoulseekConnection } from './SoulseekConnection';
import { useServiceConnection } from '../../hooks/useServiceConnection';
// Right panel of the /soulseek workspace — renders the UI for the section the nav selected. The panel
// header's +/- controls set a per-panel zoom factor, persisted via useDashboardState (same store as the
@@ -49,6 +51,8 @@ const sectionView = (section: SoulseekSectionId) => {
return <SoulseekUsers />;
case 'system':
return <SoulseekSystem />;
case 'connection':
return <SoulseekConnection />;
default:
return <Placeholder id={section} />;
}
@@ -59,8 +63,13 @@ type SoulseekViewProps = { panelId: string };
export const SoulseekView = ({ panelId }: SoulseekViewProps) => {
const [section] = usePanelChannel<SoulseekSectionId>(SOULSEEK_SECTION_CHANNEL, 'dashboard');
const { value: zoom } = useDashboardState<number>(soulseekZoomKey(panelId), 1);
const { data: connection, isLoading } = useServiceConnection('slskd');
const z = zoom ?? 1;
// Nothing connected yet: the setup form takes over every section, because none of them can do anything
// without a daemon. Zoom is skipped for it too — it is a form, not a dense slskd panel.
if (!isLoading && !connection?.configured) return <SoulseekConnection />;
if (z === 1) return <div className="h-full w-full">{sectionView(section)}</div>;
// transform: scale doesn't reflow, so size the box to 1/z and let the scale bring it back to 100%.
@@ -255,7 +255,8 @@ export type SoulseekSectionId =
| 'rooms'
| 'chat'
| 'users'
| 'system';
| 'system'
| 'connection';
export const SOULSEEK_SECTIONS: { id: SoulseekSectionId; label: string }[] = [
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'search', label: 'Search' },
@@ -265,6 +266,7 @@ export const SOULSEEK_SECTIONS: { id: SoulseekSectionId; label: string }[] = [
{ id: 'chat', label: 'Chat' },
{ id: 'users', label: 'Users' },
{ id: 'system', label: 'System' },
{ id: 'connection', label: 'Connection' },
];
// Published by the username dropdown (search results / downloads) to jump straight to a peer in the Users
@@ -0,0 +1,219 @@
import type { ServiceConnection } from '../../hooks/useServiceConnection';
import { useEffect, useState } from 'react';
import { CheckCircle2, Loader2, Plug, Trash2, TriangleAlert } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
serviceErrorMessage,
useServiceConnection,
useServiceConnectionActions,
useServiceHealth,
} from '../../hooks/useServiceConnection';
// Connecting a Transmission daemon to Officer, from the app.
//
// This screen is BOTH the setup wizard and the permanent settings page: TransmissionView renders it in place
// of whatever section the URL asks for while nothing is connected, and /transmission/connection renders it
// for good. One component, so re-pointing at a different daemon later goes through exactly the code path
// that stored the first one.
//
// ONE connection, not a registry — nobody runs two Transmission daemons. And usually one FIELD: most
// daemons run with no RPC auth at all, which is why username/password sit behind a disclosure rather than
// in the owner's way. An empty username means "no auth", not "empty credentials"; the sidecar is careful
// about that distinction because Transmission rejects a request carrying an empty Basic header.
const HINT = 'text-[11px] leading-relaxed text-muted-foreground';
/** Transmission's own default RPC port. Officer dials it from the server, not from this browser. */
const DEFAULT_URL = 'http://localhost:9091';
const URL_HINT =
"The daemon's base URL, without the RPC path. Officer reaches it from the server, not from this browser " +
'— so localhost here means the machine Officer runs on.';
const AUTH_HINT =
'Only if the daemon has rpc-authentication-required set. Leave the username empty for the usual case: an ' +
'empty username means no authentication, and sending a blank one anyway makes Transmission refuse the call.';
const PATH_HINT = 'Only differs behind a reverse proxy that mounts the RPC endpoint somewhere else.';
type FieldProps = {
label: string;
hint?: string;
value: string;
onChange: (value: string) => void;
placeholder: string;
type?: string;
autoFocus?: boolean;
};
const Field = ({ label, hint, value, onChange, placeholder, type, autoFocus }: FieldProps) => (
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium">{label}</span>
<Input
value={value}
onChange={(ev) => onChange(ev.target.value)}
placeholder={placeholder}
type={type}
autoFocus={autoFocus}
autoComplete="off"
spellCheck={false}
/>
{hint && <span className={HINT}>{hint}</span>}
</label>
);
type SaveInput = Record<string, unknown> & { url: string };
export const ConnectionView = () => {
const { data, isLoading } = useServiceConnection('transmission');
const { data: health } = useServiceHealth('transmission');
const { save, forget } = useServiceConnectionActions<SaveInput>('transmission');
const connection = data?.connection ?? null;
const [url, setUrl] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [rpcPath, setRpcPath] = useState('');
const [showAdvanced, setShowAdvanced] = useState(false);
const [error, setError] = useState('');
// Seed the form from the stored row once it arrives. Keyed on the row's id so a save (which returns the
// same id) does not stomp what the owner is still typing, but forgetting and re-adding does reset it.
useEffect(() => {
setUrl(connection?.url ?? '');
setUsername(connection?.username ?? '');
setRpcPath(connection?.path ?? '');
setPassword('');
setShowAdvanced(!!connection?.username);
}, [connection?.id]);
const submit = async () => {
setError('');
try {
await save.mutateAsync({ url: url.trim(), username: username.trim(), password, rpcPath: rpcPath.trim() });
setPassword('');
} catch (err) {
setError(serviceErrorMessage(err));
}
};
const remove = async () => {
setError('');
try {
await forget.mutateAsync();
} catch (err) {
setError(serviceErrorMessage(err));
}
};
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading
</div>
);
}
return (
<div className="h-full overflow-y-auto">
<div className="mx-auto flex max-w-xl flex-col gap-6 p-6">
<header className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-amber-500/15 text-amber-500">
<Plug className="h-5 w-5" />
</div>
<div>
<h2 className="text-sm font-semibold">Transmission daemon</h2>
<p className={HINT}>
{connection
? 'Where Officer talks to Transmission. Saving re-checks the daemon before storing anything.'
: 'Point Officer at your Transmission daemon. Usually the URL is all it needs.'}
</p>
</div>
</header>
{connection && <StatusRow connection={connection} health={health} />}
<div className="flex flex-col gap-4 rounded-xl border p-4">
<Field
label="Server URL"
hint={URL_HINT}
value={url}
onChange={setUrl}
placeholder={DEFAULT_URL}
autoFocus={!connection}
/>
<button
type="button"
onClick={() => setShowAdvanced((v) => !v)}
className="self-start text-[11px] font-medium text-primary hover:underline"
>
{showAdvanced ? 'Hide' : 'Show'} authentication and RPC path
</button>
{showAdvanced && (
<>
<Field label="Username" hint={AUTH_HINT} value={username} onChange={setUsername} placeholder="(none)" />
<Field
label="Password"
value={password}
onChange={setPassword}
placeholder={connection?.hasSecret ? '•••••••• (unchanged)' : '(none)'}
type="password"
/>
<Field
label="RPC path"
hint={PATH_HINT}
value={rpcPath}
onChange={setRpcPath}
placeholder="/transmission/rpc"
/>
</>
)}
{error && (
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 p-2.5 text-xs text-destructive">
<TriangleAlert className="mt-px h-3.5 w-3.5 shrink-0" />
<span>{error}</span>
</div>
)}
<div className="flex items-center gap-2">
<Button size="sm" onClick={submit} disabled={!url.trim() || save.isPending}>
{save.isPending && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
{connection ? 'Save' : 'Connect'}
</Button>
{connection && (
<Button size="sm" variant="ghost" onClick={remove} disabled={forget.isPending}>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Disconnect
</Button>
)}
</div>
</div>
</div>
</div>
);
};
type StatusRowProps = { connection: ServiceConnection; health: { ok: boolean; error?: string } | undefined };
const StatusRow = ({ connection, health }: StatusRowProps) => (
<div className="flex items-start gap-2 rounded-xl border p-3 text-xs">
{health?.ok ? (
<CheckCircle2 className="mt-px h-4 w-4 shrink-0 text-emerald-500" />
) : (
<TriangleAlert className="mt-px h-4 w-4 shrink-0 text-amber-500" />
)}
<div className="min-w-0">
<div className="font-medium">{health?.ok ? 'Connected' : 'Not responding'}</div>
<div className="truncate text-muted-foreground">
{connection.url}
{connection.version ? ` · Transmission ${connection.version}` : ''}
</div>
{!health?.ok && health?.error && <div className="text-destructive">{health.error}</div>}
</div>
</div>
);
@@ -133,8 +133,8 @@ export const TorrentsView = () => {
<div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center">
<div className="text-sm font-medium">Cannot reach Transmission</div>
<p className="max-w-md text-xs text-muted-foreground">
The officer-transmission sidecar answered with an error. Check that the daemon is running and that
TRANSMISSION_URL points at it.
The officer-transmission sidecar answered with an error. Check that the daemon is running, and that the URL
under Connection still points at it.
</p>
</div>
);
@@ -10,6 +10,7 @@ import {
Folder,
Gauge,
ListChecks,
Plug,
Radio,
Settings,
Tag,
@@ -33,6 +34,7 @@ const ICONS: Record<TransmissionSectionId, LucideIcon> = {
torrents: ListChecks,
stats: Gauge,
settings: Settings,
connection: Plug,
};
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
@@ -1,14 +1,24 @@
import { useServiceConnection } from '../../hooks/useServiceConnection';
import { useTransmissionSection } from './useTransmissionSection';
import { TorrentsView } from './TorrentsView';
import { StatsView } from './StatsView';
import { SettingsView } from './SettingsView';
import { ConnectionView } from './ConnectionView';
// Right panel of the /transmission workspace — renders the section named by the URL.
//
// With no daemon configured every other section can only render an error, so the connection form takes over
// until there is one. The URL is left alone: once connected, the section already in it is what appears.
export const TransmissionView = () => {
const section = useTransmissionSection();
const { data, isLoading } = useServiceConnection('transmission');
if (!isLoading && !data?.configured) return <ConnectionView />;
switch (section) {
case 'connection':
return <ConnectionView />;
case 'stats':
return <StatsView />;
case 'settings':
@@ -10,6 +10,7 @@ export const TRANSMISSION_SECTIONS = [
{ id: 'torrents', label: 'Torrents' },
{ id: 'stats', label: 'Statistics' },
{ id: 'settings', label: 'Settings' },
{ id: 'connection', label: 'Connection' },
] as const;
export type TransmissionSectionId = (typeof TRANSMISSION_SECTIONS)[number]['id'];
@@ -2,3 +2,4 @@ export * from './useFilesAPI';
export * from './useFileViewerPanels';
export * from './useChat';
export * from './useDock';
export * from './useServiceConnection';
@@ -0,0 +1,114 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
// The data layer for a SINGLE-connection sidecar — one where the owner has exactly one of the thing, so the
// whole configuration is one row and one form. Transmission and slskd both work this way; the registries
// (photos, invoiceshelf) do not, and deliberately have their own hooks.
//
// Every such sidecar serves the same three-verb contract at `/<service>/_config`, so the hook is written
// once here rather than copied per app:
//
// GET → { configured, connection } never any secret, only whether one is stored
// PUT → { connection } upsert, validated against the live service first
// DELETE → { configured: false } forget it
//
// plus `/<service>/_health`, whose failure BODIES are the useful part — see useServiceHealth.
export type ServiceConnection = {
id: number;
service: string;
url: string;
username: string | null;
path: string | null;
hasSecret: boolean;
version: string | null;
lastSeenAt: string | null;
createdAt: string;
};
export type ServiceConnectionState = { configured: boolean; connection: ServiceConnection | null };
export type ServiceHealth = { ok: boolean; configured: boolean; version?: string | null; error?: string; ms?: number };
/** Unwrap the `{ status, message }` useClient throws, where `message` is the sidecar's JSON body. */
export function serviceErrorMessage(err: unknown): string {
const raw = (err as { message?: unknown } | null)?.message;
if (typeof raw !== 'string' || !raw) return 'Something went wrong';
try {
const parsed = JSON.parse(raw) as { error?: unknown };
if (typeof parsed.error === 'string' && parsed.error) return parsed.error;
} catch {
/* plain text */
}
return raw.slice(0, 300);
}
const configKey = (service: string) => [service, 'connection'] as const;
const healthKey = (service: string) => [service, 'health'] as const;
export function useServiceConnection(service: string) {
const { get } = useClient();
return useQuery({
queryKey: configKey(service),
queryFn: () => get<ServiceConnectionState>(`/${service}/_config`),
staleTime: 60_000,
retry: false,
});
}
/**
* Health, including its failure bodies.
*
* `get` throws on any status >= 400, so a plain query would leave `data` undefined for exactly the two cases
* the UI most needs to tell apart — 503 not connected and 502 connected-but-broken. Both carry a JSON body,
* so the throw is turned back into the answer rather than an error state.
*/
export function useServiceHealth(service: string) {
const { get } = useClient();
return useQuery({
queryKey: healthKey(service),
queryFn: async (): Promise<ServiceHealth> => {
try {
return await get<ServiceHealth>(`/${service}/_health`);
} catch (err) {
const raw = (err as { message?: unknown } | null)?.message;
if (typeof raw === 'string') {
try {
const body = JSON.parse(raw) as ServiceHealth;
if (body && body.ok === false) return body;
} catch {
/* not the sidecar's body */
}
}
// Anything else — the platform proxy, auth, the sidecar being down — is a configured service that is
// failing, not an unconfigured one. Never offer the setup form on a guess.
return { ok: false, configured: true, error: serviceErrorMessage(err) };
}
},
staleTime: 60_000,
retry: false,
});
}
/**
* Save and forget. Both invalidate the WHOLE service prefix, not just the connection: re-pointing at a
* different daemon invalidates every list, stat and setting already in the cache.
*/
export function useServiceConnectionActions<TInput extends Record<string, unknown>>(service: string) {
const { put, delete: del } = useClient();
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: [service] });
const save = useMutation({
mutationFn: (input: TInput) => put<{ connection: ServiceConnection }>(`/${service}/_config`, input),
onSuccess: invalidate,
});
const forget = useMutation({
mutationFn: () => del<ServiceConnectionState>(`/${service}/_config`),
onSuccess: invalidate,
});
return { save, forget };
}