diff --git a/sidecar/account-view.ts b/sidecar/account-view.ts new file mode 100644 index 0000000..47a311e --- /dev/null +++ b/sidecar/account-view.ts @@ -0,0 +1,113 @@ +import type { ProvisionableUser } from 'officerdb'; +import { listJellyfinAccounts } from '../db'; +import { listJellyfinUsers } from './provision'; +import { normalize, type JellyfinUser } from './reconcile'; +import { getProvisionableUsers, hasProvisionableUsers } from './user-list'; +import type { UpstreamTarget } from './upstream'; + +// Who exists on each side, and which pairs are the same person. +// +// Read-only, and deliberately no part of provisioning: this answers "what is true now", where +// `planReconcile` answers "what would happen if we ran". They share `normalize` so the screen cannot +// disagree with the matching rule about whether `Diogo` and `diogo` are one person — a settings page that +// showed them as two would be lying about what the next connection will do. +// +// The join is by the LINK ROW where one exists, and by the folded name where one does not. That ordering +// matters: a recorded pairing is a fact, and a name that happens to match is only a candidate. + +export type AccountPair = { + officer: { id: number; username: string | null; name: string | null; role: string } | null; + jellyfin: { id: string; name: string } | null; + /** A row in `jellyfin_accounts` says these two are the same person. */ + linked: boolean; + /** The Jellyfin account existed before Officer knew about it. Never deleted on deprovision. */ + adopted: boolean; + /** Names match but nothing is recorded — what connecting would adopt. */ + matchable: boolean; +}; + +export type AccountView = + | { known: false; reason: string } + | { known: true; pairs: AccountPair[]; counts: { linked: number; officerOnly: number; jellyfinOnly: number } }; + +const officerOf = (u: ProvisionableUser) => ({ id: u.id, username: u.username, name: u.name, role: u.role }); + +/** + * Build the view for one server. + * + * `known: false` when the platform has not sent the account list. That is not the same as "no users" and + * the screen must not render it as an empty table — it means this plugin is not entitled to the list or + * the push has not arrived, and showing "0 Officer accounts" would read as a fact about the server. + */ +export async function buildAccountView(cfg: UpstreamTarget, serverId: number): Promise { + if (!hasProvisionableUsers()) { + return { known: false, reason: 'the platform has not sent the account list yet' }; + } + + const officer = getProvisionableUsers(); + const jellyfin: JellyfinUser[] = await listJellyfinUsers(cfg); + const links = await listJellyfinAccounts(serverId); + + const linkByUserId = new Map(links.map((l) => [l.userId, l])); + const jellyfinById = new Map(jellyfin.map((j) => [j.Id, j])); + const jellyfinByName = new Map(jellyfin.map((j) => [normalize(j.Name), j])); + const claimed = new Set(); + + const pairs: AccountPair[] = []; + + for (const user of officer) { + const link = linkByUserId.get(user.id); + const byName = user.username ? jellyfinByName.get(normalize(user.username)) : undefined; + + if (link) { + // The linked account can be GONE — deleted in Jellyfin's own UI. Reported as an Officer account with + // no Jellyfin side rather than quietly re-matching it by name, because those are different events + // and only one of them is somebody's account disappearing. + const live = jellyfinById.get(link.jellyfinUserId); + if (live) claimed.add(live.Id); + pairs.push({ + officer: officerOf(user), + jellyfin: live ? { id: live.Id, name: live.Name } : null, + linked: true, + adopted: link.adopted, + matchable: false, + }); + continue; + } + + if (byName && !claimed.has(byName.Id)) { + claimed.add(byName.Id); + pairs.push({ + officer: officerOf(user), + jellyfin: { id: byName.Id, name: byName.Name }, + linked: false, + adopted: false, + matchable: true, + }); + continue; + } + + pairs.push({ officer: officerOf(user), jellyfin: null, linked: false, adopted: false, matchable: false }); + } + + for (const j of jellyfin) { + if (claimed.has(j.Id)) continue; + pairs.push({ + officer: null, + jellyfin: { id: j.Id, name: j.Name }, + linked: false, + adopted: false, + matchable: false, + }); + } + + return { + known: true, + pairs, + counts: { + linked: pairs.filter((p) => p.linked).length, + officerOnly: pairs.filter((p) => p.officer && !p.jellyfin).length, + jellyfinOnly: pairs.filter((p) => !p.officer).length, + }, + }; +} diff --git a/sidecar/config.ts b/sidecar/config.ts index c239257..7e10a90 100644 --- a/sidecar/config.ts +++ b/sidecar/config.ts @@ -10,6 +10,7 @@ import { } from '../db'; import { UpstreamError, authenticate, invalidateConfig, normalizeBase, probe, publicInfo } from './upstream'; import { runProvisioning } from './provision-run'; +import { buildAccountView } from './account-view'; // `/_config` — the Jellyfin server registry, driven from the app. // @@ -245,6 +246,31 @@ export async function handleConfigRoute(req: Request, userId: number, subpath: s return serverList(userId); } + // Who exists on each side, and which pairs are the same person. Read-only and computed on demand — + // nothing is cached, because the interesting case is somebody adding or removing an account in + // Jellyfin's own UI and wondering why Officer disagrees. + if (action === 'users') { + if (req.method !== 'GET') return bad('method not allowed', 405); + const creds = await getJellyfinCredentials(userId, id); + if (!creds) return bad('no such server', 404); + try { + const view = await buildAccountView( + { + base: normalizeBase(creds.url), + token: creds.accessToken, + jellyfinUserId: creds.jellyfinUserId, + deviceId: creds.deviceId, + }, + id, + ); + return Response.json(view); + } catch (err) { + // The server being unreachable is the common one, and it is not this route's fault. Reported as a + // reason rather than a 500 so the screen can say so beside the connection details. + return Response.json({ known: false, reason: err instanceof Error ? err.message : String(err) }); + } + } + // GET only. It probes one server and reports whether it answered — nothing is written, and nothing is // switched (that is `activate`, below). It accepted POST as well until 2026-08-06, which made the // method meaningless for deciding whether this is a read; the permission model needs that answer. diff --git a/web/AccountsSection.tsx b/web/AccountsSection.tsx new file mode 100644 index 0000000..d471b0b --- /dev/null +++ b/web/AccountsSection.tsx @@ -0,0 +1,124 @@ +import type { AccountPair } from './useJellyfinData'; +import { ArrowLeftRight, CircleSlash, Loader2, UserPlus, Users } from 'lucide-react'; +import { useJellyfinAccounts } from './useJellyfinData'; + +// Who exists on each side, shown side by side. +// +// The reason this is worth a screen: provisioning is invisible. Connecting a server can create accounts, +// adopt accounts, and leave accounts alone, and until now the only record of which happened was a JSON +// field in a response nobody reads and rows in a table nobody can see. An owner asking "did it work" had +// no way to find out except to open Jellyfin and count. +// +// Four states, and each one means something different: +// +// linked a row in `jellyfin_accounts` says these are the same person +// matchable the names fold to the same thing but nothing is recorded — what connecting WOULD adopt +// Officer only no Jellyfin account, and no name that matches one — what connecting WOULD create +// Jellyfin only somebody with no Officer account. Never touched, and that is deliberate: an absence +// in Officer is indistinguishable from a list that has not been told about them yet. + +const TONE = { + linked: 'text-emerald-600', + matchable: 'text-amber-600', + officer: 'text-muted-foreground', + jellyfin: 'text-muted-foreground', +} as const; + +const stateOf = (p: AccountPair) => { + if (p.linked && p.jellyfin) return { key: 'linked' as const, label: p.adopted ? 'linked (adopted)' : 'linked' }; + if (p.linked) return { key: 'matchable' as const, label: 'linked, but the Jellyfin account is gone' }; + if (p.matchable) return { key: 'matchable' as const, label: 'names match — not linked yet' }; + if (p.officer) return { key: 'officer' as const, label: 'no Jellyfin account' }; + return { key: 'jellyfin' as const, label: 'not an Officer account' }; +}; + +const Cell = ({ name, sub, muted }: { name: string | null; sub?: string | null; muted?: boolean }) => + name ? ( +
+
{name}
+ {sub ?
{sub}
: null} +
+ ) : ( +
—
+ ); + +export const AccountsSection = ({ serverId }: { serverId: number | null }) => { + const { data, isLoading, error } = useJellyfinAccounts(serverId); + + if (serverId === null) return null; + + return ( +
+
+ +

Accounts

+ {data?.known ? ( + + {data.counts.linked} linked · {data.counts.officerOnly} Officer only · {data.counts.jellyfinOnly} Jellyfin + only + + ) : null} +
+ + {isLoading ? ( +

+ reading both sides… +

+ ) : null} + + {/* Distinct from "there are none". Not knowing means the platform has not sent the account list, or + this server could not be reached — rendering an empty table for either would state something + false about the server. */} + {error || (data && !data.known) ? ( +

+ + {data && !data.known ? data.reason : 'could not read the accounts on this server'} +

+ ) : null} + + {data?.known ? ( +
+
+ Officer + + Jellyfin +
+ {data.pairs.map((pair) => { + const state = stateOf(pair); + return ( +
+ + + {state.key === 'linked' ? ( + + ) : state.key === 'matchable' ? ( + + ) : ( + + )} + +
+ +
{state.label}
+
+
+ ); + })} +
+ ) : null} + +

+ Connecting a server links accounts whose usernames match, creates the ones Officer has and Jellyfin does not, + and never touches a Jellyfin account with no Officer user — an absence here is indistinguishable from someone + the list has not been told about yet. +

+
+ ); +}; diff --git a/web/ConnectionSection.tsx b/web/ConnectionSection.tsx index 76b6808..51d61d8 100644 --- a/web/ConnectionSection.tsx +++ b/web/ConnectionSection.tsx @@ -4,6 +4,7 @@ import { Check, CheckCircle2, Loader2, Plug, Trash2, TriangleAlert } from 'lucid import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { formatShortDate } from './shared'; +import { AccountsSection } from './AccountsSection'; import { jellyfinErrorMessage, useJellyfinHealth, @@ -249,6 +250,9 @@ export const ConnectionSection = () => { 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); @@ -325,6 +329,10 @@ export const ConnectionSection = () => { {add.isPending && Checking the server and signing in…} + + {/* 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. */} + ); diff --git a/web/JellyfinNav.tsx b/web/JellyfinNav.tsx index 20e684a..72de332 100644 --- a/web/JellyfinNav.tsx +++ b/web/JellyfinNav.tsx @@ -6,7 +6,7 @@ import { ServerSwitcher } from './ServerSwitcher'; import { useJellyfinViews } from './useJellyfinData'; // Left panel of the /jellyfin workspace: Home and Search, the libraries the server actually has, then the -// connection screen. +// settings screen. // // The libraries are not a fixed vocabulary, so they cannot be sections in the path — they are // /jellyfin/library?view=. That keeps the section list closed (four entries, checked by a type guard) @@ -97,7 +97,7 @@ export const JellyfinNav = () => {
- +
); }; diff --git a/web/useJellyfinData.ts b/web/useJellyfinData.ts index 1211cbe..60bd4d2 100644 --- a/web/useJellyfinData.ts +++ b/web/useJellyfinData.ts @@ -278,6 +278,35 @@ export function useJellyfinServers() { }); } +export type AccountPair = { + officer: { id: number; username: string | null; name: string | null; role: string } | null; + jellyfin: { id: string; name: string } | null; + linked: boolean; + adopted: boolean; + matchable: boolean; +}; + +export type AccountView = + | { known: false; reason: string } + | { known: true; pairs: AccountPair[]; counts: { linked: number; officerOnly: number; jellyfinOnly: number } }; + +/** + * Who exists on each side of one server, and which pairs are the same person. + * + * Not cached beyond a few seconds: the reason to look at this is usually that somebody changed something + * in Jellyfin's own UI, and a stale answer is the one failure this screen exists to prevent. + */ +export function useJellyfinAccounts(serverId: number | null) { + const { get } = useClient(); + return useQuery({ + queryKey: [KEY, 'accounts', serverId], + queryFn: () => get(`/jellyfin/_config/${serverId}/users`), + enabled: serverId !== null, + staleTime: 5_000, + retry: false, + }); +} + export type AddServerInput = { label: string; url: string; username: string; password: string }; export type EditServerInput = { id: number; label?: string; url?: string; username?: string; password?: string };