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 =