0.3.22 - PINs

This commit is contained in:
ponzischeme89
2026-08-25 11:39:55 +12:00
parent 396d35e2f5
commit 0fcc02f57e
2697 changed files with 5360 additions and 50 deletions
+78 -1
View File
@@ -74,6 +74,7 @@ interface Viewer {
colour?: string;
kind: string;
hasPin?: boolean;
pin?: string;
createdAt?: string;
}
@@ -176,6 +177,7 @@ export function AccountPage() {
/* Renaming or adding a viewer. One piece of state for both, because the dialog is the
same question either way and a null id is what says there is nobody to rename yet. */
const [namingViewer, setNamingViewer] = useState<{ id: string | null; name: string } | null>(null);
const [pinningViewer, setPinningViewer] = useState<Viewer | null>(null);
/* Viewers are their own request rather than a field on the accounts payload: that
response is the whole household and this is a list per person, so folding it in would
@@ -400,7 +402,9 @@ export function AccountPage() {
{viewer.createdAt && viewer.kind !== 'main' ? ` · added ${when(viewer.createdAt)}` : ''}
</p>
</div>
<div className="list-actions">
<div className="list-actions">
<Tag tone={viewer.hasPin ? 'ok' : 'warn'}>{viewer.hasPin ? `PIN ${viewer.pin ?? 'set'}` : 'No PIN'}</Tag>
<Button size="sm" onClick={() => setPinningViewer(viewer)}>{viewer.hasPin ? 'Change PIN' : 'Set PIN'}</Button>
{viewer.kind === 'main' ? (
<Tag tone="info">synced with Emby</Tag>
) : (
@@ -818,6 +822,39 @@ export function AccountPage() {
/>
) : null}
{pinningViewer ? (
<PinDialog
viewer={pinningViewer}
busy={busy === 'viewer-pin'}
onCancel={() => setPinningViewer(null)}
onConfirm={(pin) =>
void act(
'viewer-pin',
() => api.put(`${base}/viewers/${encodeURIComponent(pinningViewer.id)}`, { pin }),
'PIN saved.',
() => {
setPinningViewer(null);
void viewersQuery.reload();
},
)
}
onClear={
pinningViewer.hasPin
? () =>
void act(
'viewer-pin',
() => api.put(`${base}/viewers/${encodeURIComponent(pinningViewer.id)}`, { clearPin: true }),
'PIN cleared.',
() => {
setPinningViewer(null);
void viewersQuery.reload();
},
)
: undefined
}
/>
) : null}
{pending ? (
<PendingDialog
pending={pending}
@@ -1114,6 +1151,46 @@ function ViewerNameDialog({
);
}
function PinDialog({
viewer,
busy,
onConfirm,
onClear,
onCancel,
}: {
viewer: Viewer;
busy: boolean;
onConfirm: (pin: string) => void;
onClear?: () => void;
onCancel: () => void;
}) {
const [pin, setPin] = useState('');
return (
<div className="scrim" onPointerDown={(event) => event.target === event.currentTarget && onCancel()}>
<div className="dialog" role="dialog" aria-modal="true">
<h2>{viewer.hasPin ? 'Change PIN' : 'Set PIN'} for {viewer.name}</h2>
<p>The PIN is checked by the gateway and is never included in ordinary user responses or logs.</p>
<Field label="PIN">
<input
type="password"
inputMode="numeric"
autoFocus
minLength={4}
maxLength={12}
value={pin}
onChange={(event) => setPin(event.target.value.replace(/\D/g, '').slice(0, 12))}
/>
</Field>
<div className="dialog-actions">
<Button variant="quiet" onClick={onCancel}>Cancel</Button>
{onClear ? <Button variant="danger" onClick={onClear}>Reset PIN</Button> : null}
<Button variant="primary" busy={busy} disabled={pin.length < 4} onClick={() => onConfirm(pin)}>Save PIN</Button>
</div>
</div>
</div>
);
}
function PendingDialog({
pending,
busy,
+64 -2
View File
@@ -18,6 +18,18 @@ import {
Tag,
Toggle,
} from '../components/ui';
import { RowsEditor } from '../components/RowsEditor';
import type { RemoteSectionDefinition } from '../api/types';
/* The three configuration keys that hold a page's row composition. They are edited
* visually through RowsEditor rather than through the generic configuration list below,
* which is why they are carved out of `configuration` before that list renders — a
* technical JSON textarea for these three is exactly what the visual editor replaces. */
const ROW_CONFIGURATION_KEYS: Record<'home' | 'movies' | 'tv', string> = {
home: 'home.sectionDefinitions',
movies: 'movies.sectionDefinitions',
tv: 'tv.sectionDefinitions',
};
interface Pending {
action: 'safe-mode' | 'rollback' | 'reset' | 'feature';
@@ -36,7 +48,13 @@ export function FeaturesPage() {
const policy = status?.features;
const features = policy?.features ?? [];
const configuration = policy?.configuration ?? [];
const rowTypes = policy?.rowTypes ?? [];
const rowConfiguration = policy?.configuration ?? [];
// Everything but the three row-composition values, which the visual editors below
// render instead of the generic list.
const rowKeys = new Set(Object.values(ROW_CONFIGURATION_KEYS));
const configuration = rowConfiguration.filter((item) => !rowKeys.has(item.key));
const rowsFor = (key: string) => (rowConfiguration.find((item) => item.key === key)?.value as RemoteSectionDefinition[] | undefined) ?? [];
const clients = status?.clients ?? [];
const revision = policy?.revision ?? 0;
@@ -66,10 +84,26 @@ export function FeaturesPage() {
[key]: enabled,
});
const valuesFor = (key: string, value: unknown) => ({
...Object.fromEntries(configuration.map((item) => [item.key, item.value])),
...Object.fromEntries(rowConfiguration.map((item) => [item.key, item.value])),
[key]: value,
});
const saveRows = (key: string) => async (next: RemoteSectionDefinition[]) => {
await run(key, async () => {
await wrap(
() =>
api.post('/admin/api/features', {
action: 'save',
expectedRevision: revision,
values: valuesFor(key, next),
overrides: Object.fromEntries(features.filter((f) => f.source === 'override').map((f) => [f.key, f.enabled])),
}),
'Rows saved.',
);
await reload();
});
};
return (
<>
<PageHead
@@ -94,6 +128,29 @@ export function FeaturesPage() {
<a className="chip" href="/admin/clients">Client versions and capabilities</a>
</div>
</Card>
<RowsEditor
page="home"
rows={rowsFor(ROW_CONFIGURATION_KEYS.home)}
rowTypes={rowTypes}
busy={busy === ROW_CONFIGURATION_KEYS.home}
onSave={saveRows(ROW_CONFIGURATION_KEYS.home)}
/>
<RowsEditor
page="movies"
rows={rowsFor(ROW_CONFIGURATION_KEYS.movies)}
rowTypes={rowTypes}
busy={busy === ROW_CONFIGURATION_KEYS.movies}
onSave={saveRows(ROW_CONFIGURATION_KEYS.movies)}
/>
<RowsEditor
page="tv"
rows={rowsFor(ROW_CONFIGURATION_KEYS.tv)}
rowTypes={rowTypes}
busy={busy === ROW_CONFIGURATION_KEYS.tv}
onSave={saveRows(ROW_CONFIGURATION_KEYS.tv)}
/>
<Card
title="Control plane"
intro="Every optional feature has a safe default, an explicit override and a remote recovery path. Safe mode turns all of them off at once; sign-in, browsing and playback are never optional."
@@ -186,6 +243,11 @@ export function FeaturesPage() {
} catch { /* wait for valid JSON before publishing */ }
}}
/>
) : item.type === 'string' ? (
<input type="text" value={String(value ?? '')} disabled={busy === item.key} onChange={(event) => void run(item.key, async () => {
await wrap(() => api.post('/admin/api/features', { action: 'save', expectedRevision: revision, values: valuesFor(item.key, event.target.value), overrides: Object.fromEntries(features.filter((f) => f.source === 'override').map((f) => [f.key, f.enabled])) }), 'Configuration saved.');
await reload();
})} />
) : (
<input type="number" value={Number(value)} min={item.min} max={item.max} disabled={busy === item.key} onChange={(event) => void run(item.key, async () => {
await wrap(() => api.post('/admin/api/features', { action: 'save', expectedRevision: revision, values: valuesFor(item.key, Number(event.target.value)), overrides: Object.fromEntries(features.filter((f) => f.source === 'override').map((f) => [f.key, f.enabled])) }), 'Configuration saved.');