Files
jellyfin/web/ConnectionSection.tsx
pastilhasandClaude Opus 5 e8aa85a35b settings: show who exists on each side, and which pairs are the same person
Provisioning was invisible. Connecting a server can create accounts, adopt accounts and leave
accounts alone, and the only record of which happened was a JSON field in a response nobody reads and
rows in a table nobody can see. "Did it work" could only be answered by opening Jellyfin and counting.

The nav entry is Settings rather than Connection, since it is now more than the connection.

The join is computed in the sidecar and shares `normalize` with the reconcile planner, so the screen
cannot disagree with the matching rule about whether Diogo and diogo are one person — a settings page
saying two would be lying about what the next connection will do. It joins by the link ROW first and
by folded name only where none exists: a recorded pairing is a fact, a matching name is a candidate.

Four states, because they mean different things — linked (adopted or created), names match but not
recorded yet, Officer account with no Jellyfin one, and a Jellyfin account with no Officer user. The
last is never touched, and a linked pairing whose Jellyfin account has vanished is shown as such
rather than silently re-matched by name.

"Not known" is kept distinct from "none": if the platform has not sent the account list, or the
server cannot be reached, it says so instead of rendering an empty table that would read as a fact
about the server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:01:43 +00:00

340 lines
12 KiB
TypeScript

import type { JellyfinServerRow } from './useJellyfinData';
import { useState } from 'react';
import { Check, CheckCircle2, Loader2, Plug, Trash2, TriangleAlert } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { formatShortDate } from './shared';
import { AccountsSection } from './AccountsSection';
import {
jellyfinErrorMessage,
useJellyfinHealth,
useJellyfinServerActions,
useJellyfinServers,
} from './useJellyfinData';
// Connecting Jellyfin servers to Officer, from the app.
//
// This screen is BOTH the setup wizard and the permanent settings page: JellyfinView renders it in place of
// whatever section the URL asks for while nothing is connected, and /jellyfin/settings renders it for good.
// One component, so a second server added later goes through exactly the code path that stored the first.
//
// There is no "paste an API key" alternative here, unlike the Immich screen. Jellyfin's API keys are
// SERVER-wide and identity-less — they authenticate as nobody, which breaks every per-user route this app
// depends on (resume points, next up, watched marks). Signing in is the only credential that has a user
// attached, so it is the only one offered. The password is used once and never stored.
const HINT = 'text-[11px] leading-relaxed text-muted-foreground';
/** Jellyfin's own default port. The sidecar dials from this machine, so localhost is the right default. */
const DEFAULT_URL = 'http://localhost:8096';
const URL_HINT =
"The server's base URL, without /web. Officer reaches it from the server, not from this browser — so " +
'localhost here means the machine Officer runs on, and a local instance needs no TLS.';
const SIGN_IN_HINT =
'Your Jellyfin login. It is used once, by the sidecar, to exchange for an access token — the password is ' +
'never stored and never reaches Postgres. The session shows up in Jellyfin under Devices as "Officer"; ' +
'signing it out there is what revokes this connection.';
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>
);
function useCredential() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const filled = !!username.trim() && !!password;
const reset = () => {
setUsername('');
setPassword('');
};
return { username, setUsername, password, setPassword, filled, reset };
}
type CredentialState = ReturnType<typeof useCredential>;
const CredentialFields = ({ cred, autoFocus }: { cred: CredentialState; autoFocus?: boolean }) => (
<>
<Field
label="Username"
value={cred.username}
onChange={cred.setUsername}
placeholder="your Jellyfin user"
autoFocus={autoFocus}
/>
<Field
label="Password"
value={cred.password}
onChange={cred.setPassword}
placeholder="••••••••"
type="password"
hint={SIGN_IN_HINT}
/>
</>
);
/**
* One stored server. The active one carries the live health line, because health only ever describes the
* server actually in use — showing a status next to the others would be inventing one. "Test" on an inactive
* row probes it directly instead, which is the only way to check a server without switching to it first.
*/
const ServerRow = ({ server }: { server: JellyfinServerRow }) => {
const { data: health, refetch: recheck, isFetching: checking } = useJellyfinHealth();
const { edit, activate, remove, test } = useJellyfinServerActions();
const cred = useCredential();
const [error, setError] = useState<string | null>(null);
const [replacing, setReplacing] = useState(false);
// Removing a server throws away a token Officer can never show again, so the bin asks once.
const [confirming, setConfirming] = useState(false);
const run = async (action: Promise<unknown>) => {
setError(null);
try {
await action;
cred.reset();
setReplacing(false);
} catch (err) {
setError(jellyfinErrorMessage(err));
}
};
const busy = checking || test.isPending;
return (
<div className={`flex flex-col gap-2 rounded-lg border p-4 text-xs ${server.isActive ? 'border-primary/40' : ''}`}>
<div className="flex items-center gap-2">
{server.isActive ? <Check className="h-4 w-4 shrink-0 text-primary" /> : <span className="h-4 w-4 shrink-0" />}
<span className="truncate font-medium">{server.label}</span>
{server.isActive && (
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary">in use</span>
)}
<div className="ml-auto flex items-center gap-1">
{!server.isActive && (
<Button
variant="ghost"
size="sm"
className="h-7"
disabled={activate.isPending}
onClick={() => void run(activate.mutateAsync(server.id))}
>
Use
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-7"
disabled={busy}
onClick={() => void (server.isActive ? recheck() : run(test.mutateAsync(server.id)))}
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Test'}
</Button>
<Button
variant="ghost"
size="sm"
className={`h-7 ${confirming ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}`}
disabled={remove.isPending}
onClick={() => {
if (!confirming) return setConfirming(true);
setConfirming(false);
void run(remove.mutateAsync(server.id));
}}
>
{confirming ? 'Remove?' : <Trash2 className="h-3.5 w-3.5" />}
</Button>
</div>
</div>
<p className="truncate text-muted-foreground">
{server.url}
{server.jellyfinUsername ? ` · ${server.jellyfinUsername}` : ''}
</p>
{server.isActive ? (
<div className="flex items-center gap-1.5 text-muted-foreground">
{health?.ok ? (
<CheckCircle2 className="h-3.5 w-3.5 text-success" />
) : (
<TriangleAlert className="h-3.5 w-3.5 text-warning" />
)}
<span>
{health?.ok
? `Jellyfin ${health.version ?? '?'}${health.serverName ? ` · ${health.serverName}` : ''}`
: (health?.error ?? 'not checked yet')}
</span>
</div>
) : (
<p className="text-muted-foreground">
{server.version ? `Jellyfin ${server.version}` : 'version unknown'}
{server.lastSeenAt ? ` · last answered ${formatShortDate(server.lastSeenAt)}` : ''}
</p>
)}
{replacing ? (
<div className="flex flex-col gap-3 rounded-lg border p-3">
<CredentialFields cred={cred} autoFocus />
<div className="flex items-center gap-2">
<Button
size="sm"
className="h-8"
disabled={!cred.filled || edit.isPending}
onClick={() =>
void run(edit.mutateAsync({ id: server.id, username: cred.username.trim(), password: cred.password }))
}
>
{edit.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
Save
</Button>
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={() => {
cred.reset();
setReplacing(false);
}}
>
Cancel
</Button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setReplacing(true)}
className="self-start text-[11px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
Sign in again
</button>
)}
{error && <p className="text-destructive">{error}</p>}
</div>
);
};
export const ConnectionSection = () => {
const { data, isLoading } = useJellyfinServers();
const { add } = useJellyfinServerActions();
const cred = useCredential();
const [label, setLabel] = useState('');
const [url, setUrl] = useState(DEFAULT_URL);
const [error, setError] = useState<string | null>(null);
const servers = data?.servers ?? [];
const hasServers = servers.length > 0;
// The active one, because the accounts view is per server and "which server" is already a decided
// question here — the switcher owns it, and asking again on this screen would be a second answer.
const activeServer = servers.find((s) => s.isActive) ?? servers[0] ?? null;
const submit = async () => {
setError(null);
if (!url.trim()) return setError('The Jellyfin URL is required');
if (!cred.filled) return setError('A username and password are required');
try {
await add.mutateAsync({
label: label.trim(),
url: url.trim(),
username: cred.username.trim(),
password: cred.password,
});
setLabel('');
setUrl(DEFAULT_URL);
cred.reset();
} catch (err) {
setError(jellyfinErrorMessage(err));
}
};
return (
<div className="h-full overflow-y-auto">
<div className="mx-auto flex max-w-xl flex-col gap-4 p-6">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary/15 text-primary">
<Plug className="h-5 w-5" />
</div>
<div>
<h2 className="text-sm font-semibold">{hasServers ? 'Jellyfin servers' : 'Connect Jellyfin'}</h2>
<p className="text-xs text-muted-foreground">
{hasServers
? 'Officer stores each server and its access token encrypted, for this account only. One is in use at a time.'
: 'Video needs a Jellyfin server and a login before it can show anything.'}
</p>
</div>
</div>
{!isLoading && servers.map((server) => <ServerRow key={server.id} server={server} />)}
<form
onSubmit={(ev) => {
ev.preventDefault();
void submit();
}}
className="flex flex-col gap-3 rounded-lg border p-4"
>
<p className="text-xs font-medium">{hasServers ? 'Add another server' : 'Add a server'}</p>
<Field
label="Label"
value={label}
onChange={setLabel}
placeholder="Optional — defaults to the server's own name"
hint="What the switcher calls this server. Two servers cannot share a label; two can share a URL."
autoFocus={hasServers}
/>
<Field
label="Jellyfin URL"
value={url}
onChange={setUrl}
placeholder={DEFAULT_URL}
hint={URL_HINT}
autoFocus={!hasServers}
/>
<CredentialFields cred={cred} />
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex items-center gap-2">
<Button type="submit" size="sm" disabled={add.isPending}>
{add.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
{add.isPending ? 'Verifying…' : hasServers ? 'Add server' : 'Connect'}
</Button>
{add.isPending && <span className={HINT}>Checking the server and signing in…</span>}
</div>
</form>
{/* Below the form on purpose: connecting is what CHANGES this, so the thing you act on comes
first and the consequence is read underneath it. */}
<AccountsSection serverId={activeServer?.id ?? null} />
</div>
</div>
);
};