0.3.22 - PINs
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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.');
|
||||
|
||||
Reference in New Issue
Block a user