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 <noreply@anthropic.com>
131 lines
6.3 KiB
TypeScript
131 lines
6.3 KiB
TypeScript
import type { AccountPair } from './useJellyfinData';
|
|
import { ArrowLeftRight, CircleSlash, KeyRound, 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.
|
|
// 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',
|
|
jellyfin: 'text-muted-foreground',
|
|
} 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' };
|
|
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 === 'connection' ? (
|
|
<KeyRound className="h-3.5 w-3.5" />
|
|
) : state.key === 'linked' || 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>
|
|
);
|
|
};
|