From 591c0dadc35669f7e79d00d9943e944bc8f82843 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 20 Aug 2026 22:11:34 +0000 Subject: [PATCH] settings: the owner's own account is not "not an Officer account" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pastilhas rendered on the Jellyfin side with nothing opposite it, labelled as belonging to nobody — reported by the owner, about their own name, which is the only line on that screen that could not possibly be true. The cause is a correct exclusion used for the wrong question. `listProvisionableUsers` leaves the owner out on purpose: they hold the admin account this flow authenticates as, and provisioning them would be a second admin nobody asked for. But this view is not the provisioning list, it is "who exists on each side", and an account absent from the input read as an account that does not exist. So the viewer is looked up directly and joined onto the account the connection signs in as — known exactly from `jellyfinUserId` on the server row, never matched by name. It gets its own state rather than being called "linked", because there is no row in jellyfin_accounts and there should not be: it is the credential, not a provisioned account. It is also excluded from the "Jellyfin only" count, which was counting the one account both sides agree on as belonging to neither. Co-Authored-By: Claude Opus 5 --- sidecar/account-view.ts | 52 +++++++++++++++++++++++++++++++++++++---- sidecar/config.ts | 1 + web/AccountsSection.tsx | 14 +++++++---- web/useJellyfinData.ts | 1 + 4 files changed, 60 insertions(+), 8 deletions(-) diff --git a/sidecar/account-view.ts b/sidecar/account-view.ts index 47a311e..f766aa6 100644 --- a/sidecar/account-view.ts +++ b/sidecar/account-view.ts @@ -1,4 +1,4 @@ -import type { ProvisionableUser } from 'officerdb'; +import { getUserById, type ProvisionableUser } from 'officerdb'; import { listJellyfinAccounts } from '../db'; import { listJellyfinUsers } from './provision'; import { normalize, type JellyfinUser } from './reconcile'; @@ -24,6 +24,8 @@ export type AccountPair = { adopted: boolean; /** Names match but nothing is recorded — what connecting would adopt. */ matchable: boolean; + /** The admin account this connection authenticates as. True on exactly one pair, or none. */ + isConnection: boolean; }; export type AccountView = @@ -32,6 +34,17 @@ export type AccountView = const officerOf = (u: ProvisionableUser) => ({ id: u.id, username: u.username, name: u.name, role: u.role }); +// ── The owner is missing from `officer`, and that is correct ── +// +// `listProvisionableUsers` excludes the owner deliberately: they hold the admin account this whole flow +// authenticates as, and provisioning them would be a second admin nobody asked for. But this view is not +// the provisioning list — it is "who exists on each side" — so leaving them out rendered the owner's own +// Jellyfin account as "not an Officer account", which is the one line on the screen that could not +// possibly be true. Reported by the owner, about their own name. +// +// So the viewer is looked up directly and joined onto the account this connection signs in as — known +// exactly from `jellyfinUserId` on the server row, never matched by name. + /** * Build the view for one server. * @@ -39,7 +52,11 @@ const officerOf = (u: ProvisionableUser) => ({ id: u.id, username: u.username, n * 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 { +export async function buildAccountView( + cfg: UpstreamTarget, + serverId: number, + viewerUserId: number, +): Promise { if (!hasProvisionableUsers()) { return { known: false, reason: 'the platform has not sent the account list yet' }; } @@ -47,6 +64,7 @@ export async function buildAccountView(cfg: UpstreamTarget, serverId: number): P const officer = getProvisionableUsers(); const jellyfin: JellyfinUser[] = await listJellyfinUsers(cfg); const links = await listJellyfinAccounts(serverId); + const viewer = await getUserById(viewerUserId).catch(() => undefined); const linkByUserId = new Map(links.map((l) => [l.userId, l])); const jellyfinById = new Map(jellyfin.map((j) => [j.Id, j])); @@ -71,6 +89,7 @@ export async function buildAccountView(cfg: UpstreamTarget, serverId: number): P linked: true, adopted: link.adopted, matchable: false, + isConnection: false, }); continue; } @@ -83,21 +102,45 @@ export async function buildAccountView(cfg: UpstreamTarget, serverId: number): P linked: false, adopted: false, matchable: true, + isConnection: false, }); continue; } - pairs.push({ officer: officerOf(user), jellyfin: null, linked: false, adopted: false, matchable: false }); + pairs.push({ + officer: officerOf(user), + jellyfin: null, + linked: false, + adopted: false, + matchable: false, + isConnection: false, + }); } for (const j of jellyfin) { if (claimed.has(j.Id)) continue; + + // The account this connection signs in as. Its Officer side is the viewer, who is absent from + // `officer` by design — see above. + if (j.Id === cfg.jellyfinUserId) { + pairs.push({ + officer: viewer ? { id: viewer.id, username: viewer.username, name: viewer.name, role: viewer.role } : null, + jellyfin: { id: j.Id, name: j.Name }, + linked: false, + adopted: false, + matchable: false, + isConnection: true, + }); + continue; + } + pairs.push({ officer: null, jellyfin: { id: j.Id, name: j.Name }, linked: false, adopted: false, matchable: false, + isConnection: false, }); } @@ -107,7 +150,8 @@ export async function buildAccountView(cfg: UpstreamTarget, serverId: number): P counts: { linked: pairs.filter((p) => p.linked).length, officerOnly: pairs.filter((p) => p.officer && !p.jellyfin).length, - jellyfinOnly: pairs.filter((p) => !p.officer).length, + // The connection's own account is not "Jellyfin only" — it is the one account both sides agree on. + jellyfinOnly: pairs.filter((p) => !p.officer && !p.isConnection).length, }, }; } diff --git a/sidecar/config.ts b/sidecar/config.ts index 7e10a90..64dce9f 100644 --- a/sidecar/config.ts +++ b/sidecar/config.ts @@ -262,6 +262,7 @@ export async function handleConfigRoute(req: Request, userId: number, subpath: s deviceId: creds.deviceId, }, id, + userId, ); return Response.json(view); } catch (err) { diff --git a/web/AccountsSection.tsx b/web/AccountsSection.tsx index d471b0b..2418446 100644 --- a/web/AccountsSection.tsx +++ b/web/AccountsSection.tsx @@ -1,5 +1,5 @@ import type { AccountPair } from './useJellyfinData'; -import { ArrowLeftRight, CircleSlash, Loader2, UserPlus, Users } from 'lucide-react'; +import { ArrowLeftRight, CircleSlash, KeyRound, Loader2, UserPlus, Users } from 'lucide-react'; import { useJellyfinAccounts } from './useJellyfinData'; // Who exists on each side, shown side by side. @@ -16,8 +16,13 @@ import { useJellyfinAccounts } from './useJellyfinData'; // 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. +// this connection the admin account Officer signs in as. It has to be its own state: the owner is +// excluded from the provisionable list on purpose, so without this their own account +// appears on the Jellyfin side with nothing opposite it and gets labelled as belonging +// to nobody — which is what the owner saw, next to their own name. const TONE = { + connection: 'text-sky-600', linked: 'text-emerald-600', matchable: 'text-amber-600', officer: 'text-muted-foreground', @@ -25,6 +30,7 @@ const TONE = { } as const; const stateOf = (p: AccountPair) => { + if (p.isConnection) return { key: 'connection' as const, label: 'the account Officer connects with' }; 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' }; @@ -96,9 +102,9 @@ export const AccountsSection = ({ serverId }: { serverId: number | null }) => { muted /> - {state.key === 'linked' ? ( - - ) : state.key === 'matchable' ? ( + {state.key === 'connection' ? ( + + ) : state.key === 'linked' || state.key === 'matchable' ? ( ) : ( diff --git a/web/useJellyfinData.ts b/web/useJellyfinData.ts index 60bd4d2..491bec8 100644 --- a/web/useJellyfinData.ts +++ b/web/useJellyfinData.ts @@ -284,6 +284,7 @@ export type AccountPair = { linked: boolean; adopted: boolean; matchable: boolean; + isConnection: boolean; }; export type AccountView =