openreader/src/components/admin/AdminFeaturesPanel.tsx
Richard R 83aa1152cb docs(config): update environment variable docs for runtime JSON seed and remove legacy RUNTIME_SEED_* usage
Remove all references to legacy RUNTIME_SEED_* environment variables from documentation and codebase. Update docs and .env.example to document the new RUNTIME_SEED_JSON and RUNTIME_SEED_JSON_PATH variables for first-boot runtime config and provider seeding. Refactor admin seed logic and runtime config schema to eliminate env-var-based seeding in favor of JSON-based initialization. Update admin panel UI and badges to reflect new seed sources. Remove obsolete env parsing logic and tests for RUNTIME_SEED_* flags. Add new tests for JSON seed behavior.

BREAKING CHANGE: RUNTIME_SEED_* environment variables are no longer supported; use RUNTIME_SEED_JSON or RUNTIME_SEED_JSON_PATH for first-boot runtime config and provider seeding.
2026-05-31 00:13:03 -06:00

679 lines
26 KiB
TypeScript

'use client';
import { Fragment, useEffect, useMemo, useState } from 'react';
import { Button, Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from '@headlessui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons';
import {
Badge,
Section,
ToggleRow,
buttonClass,
inputClass,
listboxButtonClass,
listboxOptionClass,
listboxOptionsClass,
} from '@/components/formPrimitives';
import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
import { useSharedProviders, type SharedProviderEntry } from '@/hooks/useSharedProviders';
type RuntimeConfigSource = 'json-seed' | 'env-seed' | 'admin' | 'default';
interface SettingsResponse {
values: Record<string, unknown>;
sources: Record<string, RuntimeConfigSource>;
}
interface ProviderOption {
id: string;
name: string;
providerType: TtsProviderId;
}
const ADMIN_SETTINGS_QUERY_KEY = ['admin-settings'] as const;
async function fetchAdminSettings(): Promise<SettingsResponse> {
const res = await fetch('/api/admin/settings');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as SettingsResponse;
}
async function patchAdminSettings(payload: { updates?: Record<string, unknown>; reset?: string[] }): Promise<void> {
const res = await fetch('/api/admin/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok && res.status !== 207) throw new Error(`HTTP ${res.status}`);
}
export function AdminFeaturesPanel() {
const queryClient = useQueryClient();
const { data, error } = useQuery({
queryKey: ADMIN_SETTINGS_QUERY_KEY,
queryFn: fetchAdminSettings,
});
const [draft, setDraft] = useState<Record<string, unknown>>({});
const [dirty, setDirty] = useState<Set<string>>(new Set());
const { providers: sharedProviders } = useSharedProviders();
useEffect(() => {
if (!data) return;
setDraft({ ...data.values });
setDirty(new Set());
}, [data]);
useEffect(() => {
if (!error) return;
console.error('[AdminFeaturesPanel] load failed:', error);
toast.error('Failed to load site settings');
}, [error]);
const resetMutation = useMutation({
mutationFn: async (key: string) => {
await patchAdminSettings({ reset: [key] });
},
onSuccess: async () => {
toast.success('Reset to env default');
await queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_QUERY_KEY });
},
onError: (mutationError) => {
console.error(mutationError);
toast.error('Reset failed');
},
});
const saveMutation = useMutation({
mutationFn: async (updates: Record<string, unknown>) => {
await patchAdminSettings({ updates });
},
onSuccess: async () => {
toast.success('Settings saved');
await queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_QUERY_KEY });
},
onError: (mutationError) => {
console.error(mutationError);
toast.error('Save failed');
},
});
const saving = resetMutation.isPending || saveMutation.isPending;
const updateDraft = (key: string, value: unknown) => {
setDraft((d) => ({ ...d, [key]: value }));
setDirty((s) => {
const next = new Set(s);
const baselineValue = data?.values?.[key];
if (Object.is(value, baselineValue)) next.delete(key);
else next.add(key);
return next;
});
};
const updatePositiveIntDraft = (key: string, raw: string) => {
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return;
updateDraft(key, Math.max(1, Math.floor(parsed)));
};
const resetField = (key: string) => {
if (saving) return;
resetMutation.mutate(key);
};
const saveAll = () => {
if (saving || dirty.size === 0) return;
const updates: Record<string, unknown> = {};
for (const key of dirty) updates[key] = draft[key];
saveMutation.mutate(updates);
};
const discardAll = () => {
if (!data) return;
setDraft({ ...data.values });
setDirty(new Set());
};
const providerOptions = useMemo<ProviderOption[]>(() => {
return sharedProviders.map((entry) => ({
id: entry.slug,
name: `${entry.displayName} (shared)`,
providerType: entry.providerType,
}));
}, [sharedProviders]);
const currentProviderId =
typeof draft.defaultTtsProvider === 'string'
? draft.defaultTtsProvider
: '';
const currentSharedEntry: SharedProviderEntry | undefined = sharedProviders.find(
(p) => p.slug === currentProviderId,
);
const fallbackShared = providerOptions[0];
const effectiveSelectedProvider = currentSharedEntry
? {
id: currentSharedEntry.slug,
name: `${currentSharedEntry.displayName} (shared)`,
providerType: currentSharedEntry.providerType,
} as ProviderOption
: fallbackShared;
const selectedProviderOption = effectiveSelectedProvider;
const shouldRenderRateLimitInputs = draft.disableTtsRateLimit === false;
const shouldRenderComputeRateLimitInputs = draft.disableComputeRateLimit === false;
const handleProviderChange = (opt: ProviderOption) => {
updateDraft('defaultTtsProvider', opt.id);
};
const renderSource = (key: string) => {
const source = data?.sources?.[key] ?? 'default';
const isDirty = dirty.has(key);
return (
<SourceBadge
source={source}
dirty={isDirty}
canReset={source !== 'default'}
onReset={() => resetField(key)}
saving={saving}
/>
);
};
if (!data) {
return (
<AdminFeaturesSkeleton />
);
}
return (
<div className="space-y-4">
<Section
title="TTS defaults"
subtitle="Defaults for new users."
action={<Badge tone="foreground">Defaults</Badge>}
>
<div className="space-y-1.5 pb-2 border-b border-offbase">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">Default TTS provider</p>
<p className="text-xs text-muted mt-0.5">
Starting provider for new users.
</p>
</div>
<div className="shrink-0">{renderSource('defaultTtsProvider')}</div>
</div>
{providerOptions.length > 0 ? (
<Listbox value={selectedProviderOption} onChange={handleProviderChange}>
<ListboxButton className={listboxButtonClass}>
<span className="block truncate">{selectedProviderOption?.name ?? 'Select provider'}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-4 w-4 text-muted" />
</span>
</ListboxButton>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions anchor="bottom start" className={listboxOptionsClass}>
{providerOptions.map((opt) => (
<ListboxOption
key={opt.id}
value={opt}
className={({ active }) => listboxOptionClass(active)}
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{opt.name}
</span>
{selected && (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
<CheckIcon className="h-5 w-5" />
</span>
)}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</Transition>
</Listbox>
) : (
<div className="px-0.5 py-2 text-sm text-muted">
No shared providers yet. Add one first.
</div>
)}
</div>
<ToggleRow
label="Restrict user API keys (recommended)"
description="Only allow admin shared providers."
checked={Boolean(draft.restrictUserApiKeys)}
onChange={(checked) => {
if (!checked) {
const ok = confirm(
'Turning this off allows user-supplied API keys to flow through this server. Continue?',
);
if (!ok) return;
}
updateDraft('restrictUserApiKeys', checked);
}}
right={renderSource('restrictUserApiKeys')}
variant="flat"
/>
<ToggleRow
label="Show TTS provider settings tab"
description="Allow per-user provider overrides."
checked={Boolean(draft.enableTtsProvidersTab)}
onChange={(checked) => updateDraft('enableTtsProvidersTab', checked)}
right={renderSource('enableTtsProvidersTab')}
variant="flat"
/>
<ToggleRow
label="Show all provider models"
description="Allow model selection beyond defaults."
checked={Boolean(draft.showAllProviderModels)}
onChange={(checked) => updateDraft('showAllProviderModels', checked)}
right={renderSource('showAllProviderModels')}
variant="flat"
/>
</Section>
<Section
title="Rate limiting"
subtitle="Daily TTS quotas, PDF parsing throttle, and upload size."
action={<Badge tone="foreground">Limits</Badge>}
>
<ToggleRow
label="Disable TTS daily rate limiting"
description="When on, per-user/IP daily character quotas are not enforced."
checked={Boolean(draft.disableTtsRateLimit)}
onChange={(checked) => updateDraft('disableTtsRateLimit', checked)}
right={renderSource('disableTtsRateLimit')}
variant="flat"
/>
{shouldRenderRateLimitInputs ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5 px-0.5 py-1.5">
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-foreground">Anonymous per-user daily limit</label>
{renderSource('ttsDailyLimitAnonymous')}
</div>
<input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.ttsDailyLimitAnonymous ?? '')}
onChange={(event) => updatePositiveIntDraft('ttsDailyLimitAnonymous', event.target.value)}
/>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-foreground">Authenticated per-user daily limit</label>
{renderSource('ttsDailyLimitAuthenticated')}
</div>
<input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.ttsDailyLimitAuthenticated ?? '')}
onChange={(event) => updatePositiveIntDraft('ttsDailyLimitAuthenticated', event.target.value)}
/>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-foreground">Anonymous IP daily backstop</label>
{renderSource('ttsIpDailyLimitAnonymous')}
</div>
<input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.ttsIpDailyLimitAnonymous ?? '')}
onChange={(event) => updatePositiveIntDraft('ttsIpDailyLimitAnonymous', event.target.value)}
/>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-foreground">Authenticated IP daily backstop</label>
{renderSource('ttsIpDailyLimitAuthenticated')}
</div>
<input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.ttsIpDailyLimitAuthenticated ?? '')}
onChange={(event) => updatePositiveIntDraft('ttsIpDailyLimitAuthenticated', event.target.value)}
/>
</div>
</div>
) : null}
<ToggleRow
label="Disable PDF parsing rate limiting"
description="When on, per-user limits on starting PDF layout parses are not enforced."
checked={Boolean(draft.disableComputeRateLimit)}
onChange={(checked) => updateDraft('disableComputeRateLimit', checked)}
right={renderSource('disableComputeRateLimit')}
variant="flat"
/>
{shouldRenderComputeRateLimitInputs ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5 px-0.5 py-1.5">
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-foreground">Burst limit (parses)</label>
{renderSource('computeParseBurstMax')}
</div>
<input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.computeParseBurstMax ?? '')}
onChange={(event) => updatePositiveIntDraft('computeParseBurstMax', event.target.value)}
/>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-foreground">Burst window (seconds)</label>
{renderSource('computeParseBurstWindowSec')}
</div>
<input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.computeParseBurstWindowSec ?? '')}
onChange={(event) => updatePositiveIntDraft('computeParseBurstWindowSec', event.target.value)}
/>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-foreground">Sustained limit (parses)</label>
{renderSource('computeParseSustainedMax')}
</div>
<input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.computeParseSustainedMax ?? '')}
onChange={(event) => updatePositiveIntDraft('computeParseSustainedMax', event.target.value)}
/>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-foreground">Sustained window (seconds)</label>
{renderSource('computeParseSustainedWindowSec')}
</div>
<input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.computeParseSustainedWindowSec ?? '')}
onChange={(event) => updatePositiveIntDraft('computeParseSustainedWindowSec', event.target.value)}
/>
</div>
</div>
) : null}
<div className="px-0.5 pt-1 pb-2 border-b border-offbase last:border-b-0">
<div className="flex items-center gap-2.5">
<div className="flex-1 min-w-0 space-y-0.5">
<span className="block text-sm font-medium leading-5 text-foreground">Max upload size</span>
<span className="block text-xs leading-4 text-muted">Largest single document upload accepted.</span>
</div>
<div className="shrink-0 self-start pl-1.5">{renderSource('maxUploadMb')}</div>
<div className="shrink-0 flex items-center gap-1.5">
<input
type="number"
min={1}
step={1}
inputMode="numeric"
aria-label="Max upload size in megabytes"
className="w-20 rounded-md bg-background border border-offbase px-2.5 py-1.5 text-sm text-foreground text-right focus:outline-none focus:ring-2 focus:ring-accent"
value={String(draft.maxUploadMb ?? '')}
onChange={(event) => updatePositiveIntDraft('maxUploadMb', event.target.value)}
/>
<span className="text-xs text-muted">MB</span>
</div>
</div>
</div>
</Section>
<Section
title="Site features"
subtitle="Feature flags for all users."
action={<Badge tone="foreground">Feature Flags</Badge>}
>
<div className="space-y-1.5 pb-2 border-b border-offbase">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">Changelog feed URL</p>
<p className="text-xs text-muted mt-0.5">
Public URL to the changelog manifest JSON used by Settings.
</p>
</div>
<div className="shrink-0">{renderSource('changelogFeedUrl')}</div>
</div>
<input
type="text"
className={inputClass}
value={String(draft.changelogFeedUrl ?? '')}
onChange={(event) => updateDraft('changelogFeedUrl', event.target.value)}
placeholder="https://docs.openreader.richardr.dev/changelog/manifest.json"
/>
</div>
<ToggleRow
label="Allow new account sign-ups"
description="When off, new accounts cannot be created. Existing accounts can still sign in."
checked={Boolean(draft.enableUserSignups)}
onChange={(checked) => updateDraft('enableUserSignups', checked)}
right={renderSource('enableUserSignups')}
variant="flat"
/>
<ToggleRow
label="Audiobook export"
description='Show "Export audiobook" on PDF/EPUB pages.'
checked={Boolean(draft.enableAudiobookExport)}
onChange={(checked) => updateDraft('enableAudiobookExport', checked)}
right={renderSource('enableAudiobookExport')}
variant="flat"
/>
<ToggleRow
label="DOCX upload conversion"
description="Allow DOCX uploads (converted to PDF)."
checked={Boolean(draft.enableDocxConversion)}
onChange={(checked) => updateDraft('enableDocxConversion', checked)}
right={renderSource('enableDocxConversion')}
variant="flat"
/>
<ToggleRow
label="Destructive delete buttons"
description='Show "Delete all data" actions (auth-off mode).'
checked={Boolean(draft.enableDestructiveDeleteActions)}
onChange={(checked) => updateDraft('enableDestructiveDeleteActions', checked)}
right={renderSource('enableDestructiveDeleteActions')}
variant="flat"
/>
</Section>
<Section
title="TTS upstream"
subtitle="Server-side retry, timeout, and cache controls for TTS generation."
action={<Badge tone="foreground">Upstream</Badge>}
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5 px-0.5 py-1.5">
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-foreground">Retry attempts</label>
{renderSource('ttsUpstreamMaxRetries')}
</div>
<input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.ttsUpstreamMaxRetries ?? '')}
onChange={(event) => updatePositiveIntDraft('ttsUpstreamMaxRetries', event.target.value)}
/>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-foreground">Upstream timeout (ms)</label>
{renderSource('ttsUpstreamTimeoutMs')}
</div>
<input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.ttsUpstreamTimeoutMs ?? '')}
onChange={(event) => updatePositiveIntDraft('ttsUpstreamTimeoutMs', event.target.value)}
/>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-foreground">Audio cache size (bytes)</label>
{renderSource('ttsCacheMaxSizeBytes')}
</div>
<input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.ttsCacheMaxSizeBytes ?? '')}
onChange={(event) => updatePositiveIntDraft('ttsCacheMaxSizeBytes', event.target.value)}
/>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-foreground">Audio cache TTL (ms)</label>
{renderSource('ttsCacheTtlMs')}
</div>
<input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.ttsCacheTtlMs ?? '')}
onChange={(event) => updatePositiveIntDraft('ttsCacheTtlMs', event.target.value)}
/>
</div>
</div>
</Section>
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted">
{dirty.size > 0
? `${dirty.size} unsaved change${dirty.size === 1 ? '' : 's'}`
: 'No unsaved changes'}
</p>
<div className="flex gap-2">
<Button
onClick={discardAll}
disabled={dirty.size === 0 || saving}
className={buttonClass({ variant: 'secondary', size: 'sm' })}
>
Discard
</Button>
<Button
onClick={saveAll}
disabled={dirty.size === 0 || saving}
className={buttonClass({ variant: 'primary', size: 'sm' })}
>
{saving ? 'Saving…' : dirty.size > 0 ? `Save (${dirty.size})` : 'Save'}
</Button>
</div>
</div>
</div>
);
}
function AdminFeaturesSkeleton() {
return (
<div className="space-y-4 animate-pulse" aria-label="Loading feature settings" aria-busy="true">
<Section
title="TTS defaults"
subtitle="Defaults for new users."
action={<div className="h-4 w-16 rounded bg-offbase" />}
>
<div className="space-y-1.5 pb-2 border-b border-offbase">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1 min-w-0">
<div className="h-4 w-40 rounded bg-offbase" />
<div className="h-3 w-56 rounded bg-offbase" />
</div>
<div className="h-5 w-20 rounded bg-offbase" />
</div>
<div className="h-9 w-full rounded-md bg-offbase" />
</div>
<div className="space-y-2">
<div className="h-14 w-full rounded-md border border-offbase bg-background" />
<div className="h-14 w-full rounded-md border border-offbase bg-background" />
<div className="h-14 w-full rounded-md border border-offbase bg-background" />
</div>
</Section>
<Section
title="Site features"
subtitle="Feature flags for all users."
action={<div className="h-4 w-24 rounded bg-offbase" />}
>
<div className="space-y-2">
<div className="h-14 w-full rounded-md border border-offbase bg-background" />
<div className="h-14 w-full rounded-md border border-offbase bg-background" />
<div className="h-14 w-full rounded-md border border-offbase bg-background" />
</div>
</Section>
</div>
);
}
function SourceBadge({
source,
dirty,
canReset,
onReset,
saving,
}: {
source: RuntimeConfigSource;
dirty: boolean;
canReset: boolean;
onReset: () => void;
saving: boolean;
}) {
return (
<div className="flex items-center gap-1.5">
{canReset && !dirty && (
<button
type="button"
onClick={onReset}
disabled={saving}
className="text-[11px] font-medium text-muted hover:text-accent transition-colors disabled:opacity-50"
>
Reset
</button>
)}
{dirty ? (
<Badge tone="accent">Modified</Badge>
) : source === 'json-seed' ? (
<Badge tone="muted">from seed</Badge>
) : source === 'env-seed' ? (
<Badge tone="muted">from env</Badge>
) : source === 'admin' ? (
<Badge tone="foreground">admin</Badge>
) : (
<Badge tone="muted">default</Badge>
)}
</div>
);
}