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>
This commit is contained in:
@@ -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<AccountView> {
|
||||
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<string>();
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ? (
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm text-foreground">{name}</div>
|
||||
{sub ? <div className="truncate text-[11px] text-muted-foreground">{sub}</div> : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className={`text-sm ${muted ? 'text-muted-foreground/40' : 'text-muted-foreground'}`}>—</div>
|
||||
);
|
||||
|
||||
export const AccountsSection = ({ serverId }: { serverId: number | null }) => {
|
||||
const { data, isLoading, error } = useJellyfinAccounts(serverId);
|
||||
|
||||
if (serverId === null) return null;
|
||||
|
||||
return (
|
||||
<section className="mt-8">
|
||||
<header className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium text-foreground">Accounts</h3>
|
||||
{data?.known ? (
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{data.counts.linked} linked · {data.counts.officerOnly} Officer only · {data.counts.jellyfinOnly} Jellyfin
|
||||
only
|
||||
</span>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="mt-3 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> reading both sides…
|
||||
</p>
|
||||
) : 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) ? (
|
||||
<p className="mt-3 flex items-start gap-2 text-[11px] text-muted-foreground">
|
||||
<CircleSlash className="mt-0.5 h-3 w-3 shrink-0" />
|
||||
<span>{data && !data.known ? data.reason : 'could not read the accounts on this server'}</span>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{data?.known ? (
|
||||
<div className="mt-3 overflow-hidden rounded-lg border border-border">
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-3 border-b border-border bg-muted/40 px-3 py-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<span>Officer</span>
|
||||
<span className="w-4" />
|
||||
<span>Jellyfin</span>
|
||||
</div>
|
||||
{data.pairs.map((pair) => {
|
||||
const state = stateOf(pair);
|
||||
return (
|
||||
<div
|
||||
key={`${pair.officer?.id ?? 'x'}-${pair.jellyfin?.id ?? 'x'}`}
|
||||
className="grid grid-cols-[1fr_auto_1fr] items-center gap-3 border-b border-border/60 px-3 py-2 last:border-b-0"
|
||||
>
|
||||
<Cell
|
||||
name={pair.officer?.username ?? null}
|
||||
sub={pair.officer ? (pair.officer.name ?? pair.officer.role) : null}
|
||||
muted
|
||||
/>
|
||||
<span className={`flex w-4 justify-center ${TONE[state.key]}`} title={state.label}>
|
||||
{state.key === 'linked' ? (
|
||||
<ArrowLeftRight className="h-3.5 w-3.5" />
|
||||
) : state.key === 'matchable' ? (
|
||||
<ArrowLeftRight className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<UserPlus className="h-3.5 w-3.5 opacity-30" />
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<Cell name={pair.jellyfin?.name ?? null} muted />
|
||||
<div className={`text-[11px] ${TONE[state.key]}`}>{state.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="mt-2 text-[11px] leading-relaxed text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -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 && <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>
|
||||
);
|
||||
|
||||
+2
-2
@@ -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=<id>. That keeps the section list closed (four entries, checked by a type guard)
|
||||
@@ -97,7 +97,7 @@ export const JellyfinNav = () => {
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="mx-4 my-2 border-t" />
|
||||
<nav className="flex flex-col gap-0.5 px-2 pb-3">{sectionLink('settings', 'Connection', Plug)}</nav>
|
||||
<nav className="flex flex-col gap-0.5 px-2 pb-3">{sectionLink('settings', 'Settings', Plug)}</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<AccountView>(`/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 };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user