Files
memby/admin-ui/src/pages/Account.tsx
T
2026-08-14 09:40:03 +12:00

775 lines
26 KiB
TypeScript

import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { api } from '../api/client';
import { useAction, useQuery } from '../lib/hooks';
import { useToast } from '../lib/toast';
import { initials, num, presence, recent, when } from '../lib/format';
import {
Banner,
Button,
Card,
Chip,
Confirm,
Empty,
Field,
Grid,
Loading,
PageHead,
Tag,
Toggle,
} from '../components/ui';
import type { DeviceVersion } from '../api/types';
/* One person: their televisions, their recommendation setup and the settings that follow
them to every set. The identity is in the URL rather than in a query string so the page
can be linked, bookmarked and returned to after a sign-in. */
interface PreferenceOption {
value: string;
label: string;
}
interface PreferenceDefinition {
key: string;
name: string;
description: string;
area: string;
kind: 'toggle' | 'choice' | 'number' | 'multi' | 'list';
options?: PreferenceOption[];
numbers?: number[];
unit?: string;
}
interface ThemeDefinition {
id: string;
name: string;
description: string;
palette?: Record<string, string>;
}
interface AccountDevice {
id: string;
name: string;
version: string;
signedInAt: string;
lastSeen: string;
versions: DeviceVersion[] | null;
}
interface RecommendationState {
prompted?: boolean;
completed?: boolean;
ratings?: { title: string; rating: number }[];
genres?: string[];
studios?: string[];
actors?: string[];
actresses?: string[];
directors?: string[];
contentTypes?: string[];
}
interface AccountDetail {
id: string;
username: string;
lastSeen: string;
devices: AccountDevice[] | null;
themes: string[] | null;
recommendations?: RecommendationState;
settings?: {
saved?: boolean;
revision?: number;
source?: string;
updatedAt?: string;
preferences?: Record<string, unknown>;
};
}
interface AccountsPayload {
accounts: AccountDetail[] | null;
catalogue: PreferenceDefinition[] | null;
themes: ThemeDefinition[] | null;
}
/* The palette is written the way Android reads it, #AARRGGBB, and CSS reads #RRGGBBAA. The
conversion lives here rather than on the wire because the television is the end that has
to parse thousands of these and the console is the end that parses eight. */
function cssColour(value: string | undefined): string {
const hex = String(value ?? '').replace('#', '');
if (hex.length !== 8) return `#${hex}`;
return `#${hex.slice(2)}${hex.slice(0, 2)}`;
}
type Pending =
| { kind: 'remove-device'; deviceId: string; name: string }
| { kind: 'remove-account' }
| { kind: 'reset-recommendations' }
| { kind: 'cancel-prompt' }
| { kind: 'reset-preferences' }
| { kind: 'no-themes' };
export function AccountPage() {
const { userId = '' } = useParams();
const navigate = useNavigate();
const { wrap } = useToast();
const { busy, run } = useAction();
const base = `/admin/api/accounts/${encodeURIComponent(userId)}`;
const { data, error, loading, reload } = useQuery<AccountsPayload>('/admin/api/accounts', {
pollMs: 30_000,
});
/* True while the operator has edited a form without saving. The page polls, and a redraw
would take a half-finished change away mid-sentence — so a dirty form keeps what it has
until it is saved, discarded or reloaded. The two forms track this separately, so
saving one does not discard an unsaved edit to the other. */
const [prefs, setPrefs] = useState<Record<string, unknown> | null>(null);
const [themes, setThemes] = useState<string[] | null>(null);
const [pending, setPending] = useState<Pending | null>(null);
const [renaming, setRenaming] = useState<{ id: string; name: string } | null>(null);
const account = (data?.accounts ?? []).find((entry) => entry.id === userId);
const catalogue = data?.catalogue ?? [];
const themeCatalogue = data?.themes ?? [];
useEffect(() => {
if (prefs === null && account) setPrefs({ ...(account.settings?.preferences ?? {}) });
}, [account, prefs]);
useEffect(() => {
if (themes !== null || !account) return;
// An empty list from the server means unrestricted, so it draws as every box ticked.
// Storing "all" and "never configured" identically is deliberate — they are the same
// decision — and this is the one place an operator would notice if it were not.
const allowed = account.themes ?? [];
setThemes(allowed.length === 0 ? themeCatalogue.map((theme) => theme.id) : allowed);
}, [account, themes, themeCatalogue]);
const areas = useMemo(() => {
const grouped: { name: string; definitions: PreferenceDefinition[] }[] = [];
for (const definition of catalogue) {
let area = grouped.find((entry) => entry.name === definition.area);
if (!area) grouped.push((area = { name: definition.area, definitions: [] }));
area.definitions.push(definition);
}
return grouped;
}, [catalogue]);
const act = (key: string, fn: () => Promise<unknown>, message: string, after?: () => void) =>
run(key, async () => {
const ok = await wrap(fn, message);
setPending(null);
if (ok !== undefined) after?.();
await reload();
});
if (loading) {
return (
<>
<PageHead title="User" crumbs={<Link to="/admin/accounts"> All users</Link>} />
<Loading />
</>
);
}
if (!account) {
return (
<>
<PageHead title="User" crumbs={<Link to="/admin/accounts"> All users</Link>} />
<Banner message={error} />
<Card>
<Empty>This user is no longer signed in to Memby.</Empty>
</Card>
</>
);
}
const devices = account.devices ?? [];
const active = devices.filter((device) => recent(device.lastSeen)).length;
const settings = account.settings ?? {};
const prompt = account.recommendations ?? {};
return (
<>
<PageHead
title={account.username || 'Unnamed user'}
intro={`Memby user · ${num(devices.length)} device${devices.length === 1 ? '' : 's'} · last seen ${when(account.lastSeen)}`}
crumbs={<Link to="/admin/accounts"> All users</Link>}
actions={
<>
<span className="avatar">{initials(account.username)}</span>
{active ? <Tag tone="ok">{active} active now</Tag> : <Tag>idle</Tag>}
<Chip>{account.id}</Chip>
</>
}
/>
<Banner message={error} />
<Grid cols="wide">
<Card
title="Devices"
intro="Every build a set has been seen running is listed under it. Signing one out revokes its Memby session, drops that history and removes it from Emby's own device list. Its Emby account is not changed."
icon="tv"
tone="info"
>
{devices.length === 0 ? (
<Empty>No devices are signed in to this user.</Empty>
) : (
<div className="list">
{devices.map((device) => {
const seen = presence(device.lastSeen);
return (
<div className="list-item" key={device.id || device.name}>
<div className="list-body">
<b>
<span className="dot-state" data-tone={seen.tone} title={seen.label} />{' '}
<Link
className="table-row-link"
to={`/admin/devices/${encodeURIComponent(device.id)}`}
>
{device.name || 'Memby TV'}
</Link>
</b>
<p>
{device.version ? `Memby ${device.version}` : 'Legacy Memby client'} · {seen.label} ·
last seen {when(device.lastSeen)} · signed in {when(device.signedInAt)}
</p>
{/* Every build this set has been seen running. One television that has
been through four releases is a different thing from four
televisions, and the history is what says which of those this is. */}
{(device.versions ?? []).length > 0 ? (
<div className="chips">
{(device.versions ?? []).map((entry) => (
<Chip key={entry.version} tone={entry.version === device.version ? 'ok' : undefined}>
{entry.version}
{entry.version === device.version ? ' · now' : ''}
</Chip>
))}
</div>
) : null}
</div>
<div className="list-actions">
<Button
size="sm"
disabled={!device.id}
onClick={() => setRenaming({ id: device.id, name: device.name })}
>
Rename
</Button>
<Button
size="sm"
variant="danger"
disabled={!device.id}
onClick={() =>
setPending({ kind: 'remove-device', deviceId: device.id, name: device.name })
}
>
Sign out
</Button>
</div>
</div>
);
})}
</div>
)}
</Card>
<Card
title="Recommendation setup"
intro="The prompt appears the next time this person opens Memby on any of their televisions."
icon="sparkle"
tone="note"
footer={
prompt.completed ? (
<Button
busy={busy === 'reset-rec'}
onClick={() => setPending({ kind: 'reset-recommendations' })}
>
Clear stored choices
</Button>
) : prompt.prompted ? (
<Button onClick={() => setPending({ kind: 'cancel-prompt' })}>Cancel prompt</Button>
) : (
<Button
variant="primary"
busy={busy === 'prompt'}
onClick={() =>
void act(
'prompt',
() => api.put(`${base}/recommendations/prompt`),
'Setup prompt queued.',
)
}
>
Send setup prompt
</Button>
)
}
>
<div className="row tight">
{prompt.completed ? (
<Tag tone="ok">completed</Tag>
) : prompt.prompted ? (
<Tag tone="warn">prompt queued</Tag>
) : (
<Tag>not invited</Tag>
)}
</div>
<RecommendationChips prompt={prompt} />
</Card>
</Grid>
<Card
title="Settings"
intro="These live on the server and follow the person, so a change here reaches every television they use — usually within a few seconds, and on the next launch for a set that is switched off."
icon="sliders"
tone="ok"
actions={
settings.saved ? (
<Tag tone={settings.source === 'admin' ? 'warn' : 'ok'}>
r{num(settings.revision)} · {settings.source || 'device'} · {when(settings.updatedAt)}
</Tag>
) : (
<Tag>defaults · never synced</Tag>
)
}
footer={
<>
<Button
variant="primary"
busy={busy === 'push'}
onClick={() =>
void act(
'push',
() => api.put(`${base}/preferences`, { preferences: prefs ?? {} }),
'Pushed to their televisions.',
// Cleared so the form is redrawn from what the server actually stored
// rather than from what was submitted.
() => setPrefs(null),
)
}
>
Push to their televisions
</Button>
<Button
onClick={() => {
setPrefs(null);
void reload();
}}
>
Discard changes
</Button>
<Button onClick={() => setPending({ kind: 'reset-preferences' })}>Restore defaults</Button>
<Link className="crumb" to={`/admin/accounts/${encodeURIComponent(userId)}/settings`}>
History and rollback
</Link>
</>
}
>
{areas.map((area) => (
<div className="group" key={area.name}>
<p className="group-label">{area.name}</p>
{area.definitions.map((definition) => (
<SettingControl
key={definition.key}
definition={definition}
value={prefs?.[definition.key]}
onChange={(next) => setPrefs((current) => ({ ...(current ?? {}), [definition.key]: next }))}
/>
))}
</div>
))}
</Card>
<Card
title="Colour schemes"
intro="Which palettes this person may choose between in Settings → Appearance. Tick everything to leave them unrestricted. Their current choice is an ordinary setting above; withdrawing it here puts them back on Midnight."
icon="sparkle"
tone="note"
actions={
(account.themes ?? []).length === 0 ? (
<Tag>all schemes</Tag>
) : (
<Tag tone="note">
{num((account.themes ?? []).length)} of {num(themeCatalogue.length)}
</Tag>
)
}
footer={
<>
<Button
variant="primary"
busy={busy === 'themes'}
onClick={() =>
(themes ?? []).length === 0
? setPending({ kind: 'no-themes' })
: void act(
'themes',
() => api.put(`${base}/themes`, { themes: themes ?? [] }),
'Colour schemes saved.',
// Cleared so the boxes are redrawn from what was stored, which is how
// "every box ticked" comes back as unrestricted rather than as a list.
() => setThemes(null),
)
}
>
Save colour schemes
</Button>
<Button onClick={() => setThemes(themeCatalogue.map((theme) => theme.id))}>Allow all</Button>
<span className="hint">
Seasonal themes are not listed. They apply to every television in the house for their
dates and nobody can decline one the only switch is <em>Seasonal themes</em> on the
features page.
</span>
</>
}
>
<div className="checks columns">
{themeCatalogue.map((theme) => (
<label className="check" key={theme.id}>
<input
type="checkbox"
checked={(themes ?? []).includes(theme.id)}
onChange={(event) =>
setThemes((current) =>
event.target.checked
? [...(current ?? []), theme.id]
: (current ?? []).filter((entry) => entry !== theme.id),
)
}
/>
<span className="switch" />
<span
className="swatch"
style={
{
'--swatch-surface': cssColour(theme.palette?.surface),
'--swatch-accent': cssColour(theme.palette?.accent),
'--swatch-hairline': cssColour(theme.palette?.hairline),
} as React.CSSProperties
}
>
<i />
</span>
<span className="check-body">
<b>{theme.name}</b>
<p>{theme.description}</p>
</span>
</label>
))}
</div>
</Card>
<Card
title="Remove Memby access"
intro="Signs every one of this person's Memby devices out. Their Emby account, viewing history and library permissions are untouched."
icon="alert"
tone="bad"
>
<Button variant="danger" onClick={() => setPending({ kind: 'remove-account' })}>
Remove Memby access
</Button>
</Card>
{renaming ? (
<RenameDialog
initial={renaming.name}
busy={busy === 'rename'}
onCancel={() => setRenaming(null)}
onConfirm={(name) =>
void act(
'rename',
() => api.put(`${base}/devices/${encodeURIComponent(renaming.id)}`, { deviceName: name }),
'Device renamed.',
() => setRenaming(null),
)
}
/>
) : null}
{pending ? (
<PendingDialog
pending={pending}
busy={busy}
username={account.username}
onCancel={() => setPending(null)}
onConfirm={() => {
switch (pending.kind) {
case 'remove-device':
return void act(
'remove-device',
() => api.del(`${base}/devices/${encodeURIComponent(pending.deviceId)}`),
'Device signed out.',
);
case 'remove-account':
return void act(
'remove-account',
() => api.del(`${base}/sessions`),
'Memby access removed.',
() => navigate('/admin/accounts'),
);
case 'reset-recommendations':
case 'cancel-prompt':
return void act(
'reset-rec',
() => api.del(`${base}/recommendations`),
'Recommendation choices cleared.',
);
case 'reset-preferences':
return void act(
'reset-prefs',
() => api.del(`${base}/preferences`),
'Defaults restored.',
() => setPrefs(null),
);
case 'no-themes':
return void act(
'themes',
() => api.put(`${base}/themes`, { themes: [] }),
'Colour schemes saved.',
() => setThemes(null),
);
}
}}
/>
) : null}
</>
);
}
function RecommendationChips({ prompt }: { prompt: RecommendationState }) {
const ratings = prompt.ratings ?? [];
const dimensions: [string, string[] | undefined][] = [
['Genres', prompt.genres],
['Studios', prompt.studios],
['Actors', prompt.actors],
['Actresses', prompt.actresses],
['Directors', prompt.directors],
['Types', prompt.contentTypes],
];
const chips: ReactNode[] = [
...ratings.map((rating) => (
<Chip key={`r:${rating.title}`} tone="warn">
{rating.title} · {num(rating.rating)}
</Chip>
)),
...dimensions.flatMap(([label, values]) =>
(values ?? []).map((value) => (
<Chip key={`${label}:${value}`}>
{label}: {value}
</Chip>
)),
),
];
if (chips.length === 0) return <Empty>No recommendation selections have been saved.</Empty>;
return <div className="chips">{chips}</div>;
}
function SettingControl({
definition,
value,
onChange,
}: {
definition: PreferenceDefinition;
value: unknown;
onChange: (next: unknown) => void;
}) {
if (definition.kind === 'toggle') {
return (
<Toggle
label={definition.name}
hint={definition.description}
checked={Boolean(value)}
onChange={onChange}
/>
);
}
if (definition.kind === 'choice' || definition.kind === 'number') {
// A number's unit comes from the catalogue. Assuming minutes was safe while the only
// number was a time budget, and wrong the moment a second one counted anything else.
const unit = definition.unit ?? '';
const options =
definition.kind === 'number'
? (definition.numbers ?? []).map((amount) => ({
value: String(amount),
label: amount === 0 ? 'No limit' : unit ? `${amount} ${unit}` : String(amount),
}))
: (definition.options ?? []);
return (
<Field label={definition.name} hint={definition.description}>
<select
value={String(value ?? '')}
onChange={(event) =>
onChange(definition.kind === 'number' ? Number(event.target.value) : event.target.value)
}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</Field>
);
}
if (definition.kind === 'multi') {
// Rendered in the viewer's own order, then whatever they have not selected. The order
// of these rows is the order the launcher draws them in, so preserving it matters as
// much as which are ticked.
const selected = Array.isArray(value) ? (value as string[]) : [];
const ordered = [
...selected,
...(definition.options ?? []).map((option) => option.value).filter((option) => !selected.includes(option)),
];
return (
<div className="field">
<span>{definition.name}</span>
<small>{definition.description}</small>
<div className="checks">
{ordered.map((option) => {
const match = (definition.options ?? []).find((entry) => entry.value === option);
if (!match) return null;
return (
<Toggle
key={option}
label={match.label}
checked={selected.includes(option)}
onChange={(on) =>
onChange(on ? [...selected, option] : selected.filter((entry) => entry !== option))
}
/>
);
})}
</div>
</div>
);
}
// A free-form list of server row ids: one per line, which is also how the television
// stores them. There is no vocabulary to offer, because these ids ship from the gateway
// without an app release.
const entries = Array.isArray(value) ? (value as string[]) : [];
return (
<Field label={definition.name} hint={definition.description}>
<textarea
spellCheck={false}
placeholder="One row id per line"
value={entries.join('\n')}
onChange={(event) =>
onChange(
event.target.value
.split('\n')
.map((entry) => entry.trim())
.filter(Boolean),
)
}
/>
</Field>
);
}
function RenameDialog({
initial,
busy,
onConfirm,
onCancel,
}: {
initial: string;
busy: boolean;
onConfirm: (name: string) => void;
onCancel: () => void;
}) {
const [name, setName] = useState(initial || 'Memby TV');
return (
<div className="scrim" onPointerDown={(event) => event.target === event.currentTarget && onCancel()}>
<div className="dialog" role="dialog" aria-modal="true">
<h2>Name this device</h2>
<p>The name a viewer sees in Settings Devices, and what the console calls it.</p>
<Field label="Device name">
<input
type="text"
value={name}
autoFocus
maxLength={80}
onChange={(event) => setName(event.target.value)}
/>
</Field>
<div className="dialog-actions">
<Button variant="quiet" onClick={onCancel}>
Cancel
</Button>
<Button variant="primary" busy={busy} disabled={!name.trim()} onClick={() => onConfirm(name.trim())}>
Rename
</Button>
</div>
</div>
</div>
);
}
function PendingDialog({
pending,
busy,
username,
onConfirm,
onCancel,
}: {
pending: Pending;
busy: string | null;
username: string;
onConfirm: () => void;
onCancel: () => void;
}) {
const copy: Record<Pending['kind'], { title: string; body: string; label: string; destructive: boolean }> = {
'remove-device': {
title: 'Sign this device out of Memby?',
body: "Its Emby account will not be changed. The set can sign in again at any time.",
label: 'Sign out',
destructive: true,
},
'remove-account': {
title: `Remove Memby access for ${username || 'this user'}?`,
body: 'Every Memby device will be signed out. Their Emby account, viewing history and library permissions are untouched.',
label: 'Remove access',
destructive: true,
},
'reset-recommendations': {
title: "Clear this person's stored recommendation choices?",
body: 'Viewing history remains intact; only the explicit setup answers are removed.',
label: 'Clear',
destructive: true,
},
'cancel-prompt': {
title: "Cancel this person's queued recommendation prompt?",
body: 'They will not be invited to set up recommendations on their next launch.',
label: 'Cancel prompt',
destructive: false,
},
'reset-preferences': {
title: 'Restore the Memby defaults for this person?',
body: 'Their televisions will pick the change up the next time they check in.',
label: 'Restore defaults',
destructive: true,
},
'no-themes': {
title: 'Allow this person no colour schemes?',
body: 'They will be left on Midnight with nothing to choose between.',
label: 'Save anyway',
destructive: true,
},
};
const chosen = copy[pending.kind];
return (
<Confirm
title={chosen.title}
body={chosen.body}
confirmLabel={chosen.label}
destructive={chosen.destructive}
busy={Boolean(busy)}
onConfirm={onConfirm}
onCancel={onCancel}
/>
);
}