feat: add settings and access dashboard slice

This commit is contained in:
jjbrunton committed 2026-08-08 15:24:47 +01:00
1 parent e003b0d8b2
commit e4dd4ad4da
11 files changed
+1066

No files matched your search

@@ -0,0 +1,59 @@
import { useLoginInfoQuery, useLoginStatusQuery, useStartLoginMutation } from './settings-queries.js';
function errorMessage(error: Error | null): string {
return error?.message ?? 'Something went wrong.';
}
function sessionLabel(status: ReturnType<typeof useLoginStatusQuery>['data']): string {
if (!status) return 'Unknown';
if (!status.ready) return 'Browser unavailable';
if ('error' in status) return 'Check failed';
return {
unknown: 'Unknown',
authenticated: 'Authenticated',
'login-required': 'Login required',
unreachable: 'Reddit unreachable',
}[status.status];
}
function noVncUrl(publicUrl: string | null, port: number): string {
if (publicUrl) return publicUrl;
return `${window.location.protocol}//${window.location.hostname}:${port}/vnc.html?autoconnect=true&resize=scale`;
}
export function AccessPanel() {
const infoQuery = useLoginInfoQuery();
const statusQuery = useLoginStatusQuery();
const loginMutation = useStartLoginMutation();
if (infoQuery.isPending || statusQuery.isPending) {
return <section aria-label="Loading access" className="panel panel-loading" role="status"><span className="spinner" />Loading Reddit access…</section>;
}
if (infoQuery.isError || statusQuery.isError) {
const error = infoQuery.error ?? statusQuery.error;
return (
<section className="panel error-panel" role="alert">
<strong>Reddit access unavailable</strong><span>{errorMessage(error)}</span>
<button className="button secondary" onClick={() => void Promise.all([infoQuery.refetch(), statusQuery.refetch()])} type="button">Retry access</button>
</section>
);
}
const info = infoQuery.data;
return (
<section aria-labelledby="access-heading" className="panel settings-section">
<div className="panel-heading"><div><span className="eyebrow">Browser session</span><h2 id="access-heading">Access</h2></div><span className={statusQuery.data.ready && 'status' in statusQuery.data && statusQuery.data.loggedIn ? 'pill success' : 'pill warning'}>{sessionLabel(statusQuery.data)}</span></div>
<p className="settings-copy">Open Reddit in the managed browser, complete login through noVNC, then recheck the session. Credentials are never entered or displayed here.</p>
<div className="access-actions">
<button className="button primary" disabled={loginMutation.isPending} onClick={() => loginMutation.mutate()} type="button">{loginMutation.isPending ? 'Opening login…' : 'Open Reddit login'}</button>
<button className="button secondary" disabled={statusQuery.isFetching} onClick={() => void statusQuery.refetch()} type="button">{statusQuery.isFetching ? 'Checking session…' : 'Recheck session'}</button>
{info.vncEnabled ? <a className="button secondary" href={noVncUrl(info.publicUrl, info.novncPort)} rel="noreferrer" target="_blank">Open noVNC login window</a> : null}
</div>
{!info.vncEnabled ? <div className="settings-notice warning" role="status">noVNC is not available in this deployment.</div> : null}
{info.passwordRequired ? <p className="settings-copy">A VNC password is required.</p> : null}
{loginMutation.isSuccess ? <div className="settings-notice success" role="status">Login page opened.</div> : null}
{loginMutation.isError ? <div className="settings-notice error" role="alert">{errorMessage(loginMutation.error)}</div> : null}
</section>
);
}
@@ -0,0 +1,34 @@
import { useEffect, useRef } from 'react';
interface RiskDialogProps {
reasons: string[];
isPending: boolean;
onCancel(): void;
onConfirm(): void;
}
export function RiskDialog({ reasons, isPending, onCancel, onConfirm }: RiskDialogProps) {
const cancelButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
cancelButtonRef.current?.focus();
}, []);
return (
<div aria-labelledby="risk-settings-title" aria-modal="true" className="dialog-backdrop" role="dialog">
<div className="dialog-card">
<span className="eyebrow">Confirm operational risk</span>
<h2 id="risk-settings-title">Confirm risky settings</h2>
<ul className="risk-list">
{reasons.map((reason) => <li key={reason}>{reason}</li>)}
</ul>
<div className="dialog-actions">
<button className="button secondary" disabled={isPending} onClick={onCancel} ref={cancelButtonRef} type="button">Cancel</button>
<button className="button danger" disabled={isPending} onClick={onConfirm} type="button">
{isPending ? 'Applying…' : 'Apply risky settings'}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,58 @@
import type { ZodType } from 'zod';
import {
ConfigResponseSchema,
ConfigUpdateRequestSchema,
ConfigUpdateResponseSchema,
ErrorResponseSchema,
LoginInfoResponseSchema,
LoginStartResponseSchema,
LoginStatusResponseSchema,
type ConfigResponse,
type ConfigUpdateRequest,
type LoginStatusResponse,
} from '../../../../../packages/api-contracts/src/index.js';
async function parseResponse<T>(response: Response, schema: ZodType<T>): Promise<T> {
const payload: unknown = await response.json();
if (!response.ok) {
const parsedError = ErrorResponseSchema.safeParse(payload);
throw new Error(parsedError.success ? parsedError.data.error : `Request failed (${response.status})`);
}
return schema.parse(payload);
}
function request(path: string, init?: RequestInit): Promise<Response> {
return globalThis.fetch(path, { credentials: 'same-origin', ...init });
}
export function fetchSettings(): Promise<ConfigResponse> {
return request('/api/config').then((response) => parseResponse(response, ConfigResponseSchema));
}
export async function saveSettings(updates: ConfigUpdateRequest) {
const body = ConfigUpdateRequestSchema.parse(updates);
return parseResponse(
await request('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
ConfigUpdateResponseSchema,
);
}
export function fetchLoginInfo() {
return request('/api/login/info').then((response) => parseResponse(response, LoginInfoResponseSchema));
}
export function fetchLoginStatus(): Promise<LoginStatusResponse> {
return request('/api/login/status').then((response) => parseResponse(response, LoginStatusResponseSchema));
}
export async function startLogin(): Promise<void> {
const response = await parseResponse(
await request('/api/login/start', { method: 'POST' }),
LoginStartResponseSchema,
);
if (!response.ok) throw new Error(response.error);
}
@@ -0,0 +1,117 @@
import { useEffect, useState } from 'react';
import type { ConfigResponse, ConfigUpdateRequest } from '../../../../../packages/api-contracts/src/index.js';
import { AccessPanel } from './access-panel.js';
import { RiskDialog } from './risk-dialog.js';
import { useSaveSettingsMutation } from './settings-queries.js';
import { createSettingsDraft, isDraftDirty, validateSettingsDraft, type SettingsDraft } from './settings-model.js';
import { BehaviourSection, MonitoringSection, RateLimitsSection } from './settings-sections.js';
import type { SettingsSection } from './settings-page.js';
interface SettingsEditorProps {
config: ConfigResponse;
section: SettingsSection;
}
interface PendingRiskySave {
payload: ConfigUpdateRequest;
reasons: string[];
}
function errorMessage(error: Error | null): string {
return error?.message ?? 'Something went wrong.';
}
export function SettingsEditor({ config, section }: SettingsEditorProps) {
const [persistedDraft, setPersistedDraft] = useState<SettingsDraft>(() => createSettingsDraft(config));
const [draft, setDraft] = useState<SettingsDraft>(persistedDraft);
const [errors, setErrors] = useState<Record<string, string>>({});
const [pendingRiskySave, setPendingRiskySave] = useState<PendingRiskySave | null>(null);
const saveMutation = useSaveSettingsMutation();
const isDirty = isDraftDirty(draft, persistedDraft);
useEffect(() => {
function warnBeforeUnload(event: BeforeUnloadEvent) {
if (!isDirty) return;
event.preventDefault();
event.returnValue = '';
}
window.addEventListener('beforeunload', warnBeforeUnload);
return () => window.removeEventListener('beforeunload', warnBeforeUnload);
}, [isDirty]);
function applySavedResponse(response: Awaited<ReturnType<typeof saveMutation.mutateAsync>>) {
const nextConfig: ConfigResponse = {
...response.config,
subreddits: response.subreddits,
tones: config.tones,
};
const nextDraft = createSettingsDraft(nextConfig);
setPersistedDraft(nextDraft);
setDraft(nextDraft);
setPendingRiskySave(null);
}
function submit(payload: ConfigUpdateRequest) {
saveMutation.mutate(payload, { onSuccess: applySavedResponse });
}
function updateDraft(nextDraft: SettingsDraft) {
saveMutation.reset();
setErrors({});
setDraft(nextDraft);
}
function prepareSave() {
saveMutation.reset();
const validation = validateSettingsDraft(draft);
setErrors(validation.errors);
if (!validation.data) return;
const reasons: string[] = [];
if (persistedDraft.dryRun && !validation.data.dryRun) {
reasons.push('Automated messages will be sent to Reddit because dry run mode is being disabled.');
}
const persistedLimit = Number(persistedDraft.maxMessagesPerHour);
if (validation.data.maxMessagesPerHour > 20 && validation.data.maxMessagesPerHour > persistedLimit) {
reasons.push(`The limit will allow ${validation.data.maxMessagesPerHour} messages per hour.`);
}
if (reasons.length > 0) {
setPendingRiskySave({ payload: validation.data.payload, reasons });
return;
}
submit(validation.data.payload);
}
return (
<>
{section === 'monitoring' ? <MonitoringSection draft={draft} errors={errors} onChange={updateDraft} /> : null}
{section === 'rate-limits' ? <RateLimitsSection draft={draft} errors={errors} onChange={updateDraft} /> : null}
{section === 'behaviour' ? <BehaviourSection config={config} draft={draft} errors={errors} onChange={updateDraft} /> : null}
{section === 'access' ? <AccessPanel /> : null}
{section !== 'access' ? (
<div className="settings-save-bar">
<div aria-live="polite">
{isDirty ? <span role="status">You have unsaved changes.</span> : <span>All changes saved.</span>}
{saveMutation.isSuccess ? <strong className="save-success">Settings saved.</strong> : null}
{saveMutation.isError ? <strong className="save-error" role="alert">{errorMessage(saveMutation.error)}</strong> : null}
</div>
<div className="header-actions">
<button className="button secondary" disabled={!isDirty || saveMutation.isPending} onClick={() => { setDraft(persistedDraft); setErrors({}); saveMutation.reset(); }} type="button">Reset changes</button>
<button aria-label={saveMutation.isPending ? 'Saving settings' : 'Save settings'} className="button primary" disabled={!isDirty || saveMutation.isPending} onClick={prepareSave} type="button">{saveMutation.isPending ? 'Saving…' : 'Save settings'}</button>
</div>
</div>
) : null}
{pendingRiskySave ? (
<RiskDialog
isPending={saveMutation.isPending}
onCancel={() => setPendingRiskySave(null)}
onConfirm={() => submit(pendingRiskySave.payload)}
reasons={pendingRiskySave.reasons}
/>
) : null}
</>
);
}
@@ -0,0 +1,142 @@
import { z } from 'zod';
import {
ConfigUpdateRequestSchema,
SubredditConfigSchema,
ToneKeySchema,
type ConfigResponse,
type ConfigUpdateRequest,
} from '../../../../../packages/api-contracts/src/index.js';
export interface SubredditDraft {
name: string;
type: 'image' | 'chat';
weight: string;
tone: z.infer<typeof ToneKeySchema>;
scripted: string;
}
export interface SettingsDraft {
minDelayMinutes: string;
maxMessagesPerHour: string;
jitterSeconds: string;
dryRun: boolean;
prioritizeRecentPosts: boolean;
recentWindowMinutes: string;
subreddits: SubredditDraft[];
}
export interface ValidatedSettings {
payload: ConfigUpdateRequest;
dryRun: boolean;
maxMessagesPerHour: number;
}
export interface SettingsValidationResult {
data?: ValidatedSettings;
errors: Record<string, string>;
}
const EditableSubredditSchema = SubredditConfigSchema.extend({
name: z.string().trim().min(1, 'Subreddit name is required.'),
weight: z.number().positive('Subreddit weight must be greater than 0.').optional(),
});
const ValidatedSettingsSchema = z.strictObject({
minDelayMinutes: z.number().finite().min(0, 'Minimum delay cannot be negative.'),
maxMessagesPerHour: z.number().int().min(1, 'Maximum messages per hour must be at least 1.'),
jitterSeconds: z.number().finite().min(0, 'Jitter cannot be negative.'),
dryRun: z.boolean(),
prioritizeRecentPosts: z.boolean(),
recentWindowMinutes: z.number().finite().positive('Recent window must be greater than 0 minutes.'),
subreddits: z.array(EditableSubredditSchema).min(1, 'At least one subreddit is required.'),
}).superRefine((value, context) => {
const names = value.subreddits.map((subreddit) => subreddit.name.trim().toLowerCase());
if (new Set(names).size !== names.length) {
context.addIssue({ code: 'custom', path: ['subreddits'], message: 'Subreddit names must be unique.' });
}
});
export function createSettingsDraft(config: ConfigResponse): SettingsDraft {
return {
minDelayMinutes: String(config.rateLimit.minDelayBetweenMessagesMs / 60_000),
maxMessagesPerHour: String(config.rateLimit.maxMessagesPerHour),
jitterSeconds: String(config.rateLimit.jitterMs / 1_000),
dryRun: config.browser.dryRun,
prioritizeRecentPosts: config.reddit.prioritizeRecentPosts,
recentWindowMinutes: String(config.reddit.recentWindowMinutes),
subreddits: config.subreddits.map((subreddit) => ({
name: subreddit.name,
type: subreddit.type,
weight: subreddit.weight === undefined ? '' : String(subreddit.weight),
tone: subreddit.tone,
scripted: subreddit.scripted ?? '',
})),
};
}
export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationResult {
const requiredErrors: Record<string, string> = {};
if (draft.minDelayMinutes.trim() === '') requiredErrors.minDelayMinutes = 'Minimum delay is required.';
if (draft.maxMessagesPerHour.trim() === '') requiredErrors.maxMessagesPerHour = 'Maximum messages per hour is required.';
if (draft.jitterSeconds.trim() === '') requiredErrors.jitterSeconds = 'Jitter is required.';
if (draft.recentWindowMinutes.trim() === '') requiredErrors.recentWindowMinutes = 'Recent window is required.';
draft.subreddits.forEach((subreddit, index) => {
if (subreddit.name.trim() === '') requiredErrors[`subreddits.${index}.name`] = 'Subreddit name is required.';
if (subreddit.weight.trim() !== '' && Number(subreddit.weight) <= 0) {
requiredErrors[`subreddits.${index}.weight`] = 'Subreddit weight must be greater than 0.';
}
});
if (Object.keys(requiredErrors).length > 0) return { errors: requiredErrors };
const parsed = ValidatedSettingsSchema.safeParse({
minDelayMinutes: Number(draft.minDelayMinutes),
maxMessagesPerHour: Number(draft.maxMessagesPerHour),
jitterSeconds: Number(draft.jitterSeconds),
dryRun: draft.dryRun,
prioritizeRecentPosts: draft.prioritizeRecentPosts,
recentWindowMinutes: Number(draft.recentWindowMinutes),
subreddits: draft.subreddits.map((subreddit) => ({
name: subreddit.name.trim(),
type: subreddit.type,
weight: subreddit.weight.trim() === '' ? undefined : Number(subreddit.weight),
tone: subreddit.tone,
scripted: subreddit.scripted.trim() === '' ? undefined : subreddit.scripted,
})),
});
if (!parsed.success) {
const errors: Record<string, string> = {};
for (const issue of parsed.error.issues) {
const path = issue.path.join('.');
if (!(path in errors)) errors[path] = issue.message;
}
return { errors };
}
const payload = ConfigUpdateRequestSchema.parse({
rateLimit: {
minDelayBetweenMessagesMs: parsed.data.minDelayMinutes * 60_000,
maxMessagesPerHour: parsed.data.maxMessagesPerHour,
jitterMs: parsed.data.jitterSeconds * 1_000,
},
browser: { dryRun: parsed.data.dryRun },
reddit: {
prioritizeRecentPosts: parsed.data.prioritizeRecentPosts,
recentWindowMinutes: parsed.data.recentWindowMinutes,
},
subreddits: parsed.data.subreddits,
});
return {
data: {
payload,
dryRun: parsed.data.dryRun,
maxMessagesPerHour: parsed.data.maxMessagesPerHour,
},
errors: {},
};
}
export function isDraftDirty(draft: SettingsDraft, persisted: SettingsDraft): boolean {
return JSON.stringify(draft) !== JSON.stringify(persisted);
}
@@ -0,0 +1,44 @@
import { SettingsEditor } from './settings-editor.js';
import { useSettingsQuery } from './settings-queries.js';
import './settings.css';
export type SettingsSection = 'monitoring' | 'rate-limits' | 'behaviour' | 'access';
interface SettingsPageProps {
section: SettingsSection;
onSectionChange(section: SettingsSection): void;
}
const sections: ReadonlyArray<{ id: SettingsSection; label: string; description: string }> = [
{ id: 'monitoring', label: 'Monitoring', description: 'Post discovery and recency' },
{ id: 'rate-limits', label: 'Rate limits', description: 'Sending pace and safeguards' },
{ id: 'behaviour', label: 'Behaviour', description: 'Dry run and subreddits' },
{ id: 'access', label: 'Access', description: 'Reddit browser session' },
];
function errorMessage(error: Error | null): string {
return error?.message ?? 'Something went wrong.';
}
export function SettingsPage({ section, onSectionChange }: SettingsPageProps) {
const settingsQuery = useSettingsQuery();
return (
<div className="page settings-page">
<header className="page-header">
<div><span className="eyebrow">Platform configuration</span><h1>Settings</h1><p>Manage supported runtime behaviour and Reddit browser access.</p></div>
</header>
<nav aria-label="Settings sections" className="settings-navigation">
{sections.map((item) => (
<button aria-current={section === item.id ? 'page' : undefined} aria-label={item.label} className={section === item.id ? 'is-active' : ''} key={item.id} onClick={() => onSectionChange(item.id)} type="button"><strong>{item.label}</strong><small>{item.description}</small></button>
))}
</nav>
{settingsQuery.isPending ? <div aria-label="Loading settings" className="page-state settings-loading" role="status"><span className="spinner" />Loading settings…</div> : null}
{settingsQuery.isError ? (
<div className="page-state error-state settings-loading" role="alert"><strong>Settings unavailable</strong><span>{errorMessage(settingsQuery.error)}</span><button className="button secondary" onClick={() => void settingsQuery.refetch()} type="button">Retry settings</button></div>
) : null}
{settingsQuery.data ? <SettingsEditor config={settingsQuery.data} section={section} /> : null}
</div>
);
}
@@ -0,0 +1,45 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
import { fetchLoginInfo, fetchLoginStatus, fetchSettings, saveSettings, startLogin } from './settings-api.js';
export const settingsQueryKeys = {
config: ['dashboard', 'config'] as const,
loginInfo: ['dashboard', 'login', 'info'] as const,
loginStatus: ['dashboard', 'login', 'status'] as const,
};
export function useSettingsQuery() {
return useQuery({ queryKey: settingsQueryKeys.config, queryFn: fetchSettings });
}
export function useSaveSettingsMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: saveSettings,
onSuccess(response) {
queryClient.setQueryData<ConfigResponse>(settingsQueryKeys.config, (current) => ({
...response.config,
subreddits: response.subreddits,
tones: current?.tones ?? [],
}));
},
});
}
export function useLoginInfoQuery() {
return useQuery({ queryKey: settingsQueryKeys.loginInfo, queryFn: fetchLoginInfo });
}
export function useLoginStatusQuery() {
return useQuery({ queryKey: settingsQueryKeys.loginStatus, queryFn: fetchLoginStatus });
}
export function useStartLoginMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: startLogin,
onSuccess() {
void queryClient.invalidateQueries({ queryKey: settingsQueryKeys.loginStatus });
},
});
}
@@ -0,0 +1,19 @@
import { useSearchParams } from 'react-router-dom';
import { SettingsPage, type SettingsSection } from './settings-page.js';
const validSections: readonly SettingsSection[] = ['monitoring', 'rate-limits', 'behaviour', 'access'];
function parseSection(value: string | null): SettingsSection {
return validSections.find((section) => section === value) ?? 'monitoring';
}
export function SettingsRoute() {
const [searchParams, setSearchParams] = useSearchParams();
const section = parseSection(searchParams.get('section'));
function selectSection(nextSection: SettingsSection) {
setSearchParams({ section: nextSection }, { replace: true });
}
return <SettingsPage onSectionChange={selectSection} section={section} />;
}
@@ -0,0 +1,209 @@
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
import type { SettingsDraft, SubredditDraft } from './settings-model.js';
interface SectionProps {
draft: SettingsDraft;
errors: Record<string, string>;
onChange(next: SettingsDraft): void;
}
interface NumberFieldProps {
error?: string;
label: string;
name: string;
min?: number;
value: string;
onChange(value: string): void;
}
function NumberField({ error, label, name, min, value, onChange }: NumberFieldProps) {
const errorId = `${name}-error`;
return (
<label className="settings-field">
<span>{label}</span>
<input
aria-describedby={error ? errorId : undefined}
aria-invalid={error ? 'true' : undefined}
aria-label={label}
min={min}
name={name}
onChange={(event) => onChange(event.target.value)}
step="1"
type="number"
value={value}
/>
{error ? <small className="field-error" id={errorId}>{error}</small> : null}
</label>
);
}
export function MonitoringSection({ draft, errors, onChange }: SectionProps) {
return (
<section aria-labelledby="monitoring-heading" className="panel settings-section">
<div className="panel-heading"><div><span className="eyebrow">Discovery</span><h2 id="monitoring-heading">Monitoring</h2></div></div>
<label className="toggle-field">
<input
aria-label="Prioritise recent posts"
checked={draft.prioritizeRecentPosts}
onChange={(event) => onChange({ ...draft, prioritizeRecentPosts: event.target.checked })}
type="checkbox"
/>
<span><strong>Prioritise recent posts</strong><small>Prefer posts inside the configured recent window.</small></span>
</label>
<NumberField
error={errors.recentWindowMinutes}
label="Recent window in minutes"
min={1}
name="recent-window"
onChange={(recentWindowMinutes) => onChange({ ...draft, recentWindowMinutes })}
value={draft.recentWindowMinutes}
/>
</section>
);
}
export function RateLimitsSection({ draft, errors, onChange }: SectionProps) {
return (
<section aria-labelledby="rate-limits-heading" className="panel settings-section">
<div className="panel-heading"><div><span className="eyebrow">Sending safeguards</span><h2 id="rate-limits-heading">Rate limits</h2></div></div>
<div className="settings-grid">
<NumberField
error={errors.minDelayMinutes}
label="Minimum delay in minutes"
min={0}
name="minimum-delay"
onChange={(minDelayMinutes) => onChange({ ...draft, minDelayMinutes })}
value={draft.minDelayMinutes}
/>
<NumberField
error={errors.maxMessagesPerHour}
label="Maximum messages per hour"
min={1}
name="hourly-limit"
onChange={(maxMessagesPerHour) => onChange({ ...draft, maxMessagesPerHour })}
value={draft.maxMessagesPerHour}
/>
<NumberField
error={errors.jitterSeconds}
label="Random jitter in seconds"
min={0}
name="jitter"
onChange={(jitterSeconds) => onChange({ ...draft, jitterSeconds })}
value={draft.jitterSeconds}
/>
</div>
</section>
);
}
function updateSubreddit(draft: SettingsDraft, index: number, next: SubredditDraft): SettingsDraft {
return { ...draft, subreddits: draft.subreddits.map((item, itemIndex) => itemIndex === index ? next : item) };
}
export function BehaviourSection({ config, draft, errors, onChange }: SectionProps & { config: ConfigResponse }) {
return (
<section aria-labelledby="behaviour-heading" className="panel settings-section">
<div className="panel-heading"><div><span className="eyebrow">Runtime behaviour</span><h2 id="behaviour-heading">Behaviour</h2></div></div>
<label className="toggle-field">
<input
aria-label="Dry run mode"
checked={draft.dryRun}
onChange={(event) => onChange({ ...draft, dryRun: event.target.checked })}
type="checkbox"
/>
<span><strong>Dry run mode</strong><small>Evaluate posts without sending messages.</small></span>
</label>
<div className="subreddit-heading">
<div><h3>Subreddits</h3><p>Configure monitored communities and their supported message style.</p></div>
<button
className="button secondary"
onClick={() => onChange({
...draft,
subreddits: [
...draft.subreddits,
{ name: '', type: 'image', weight: '1', tone: 'default', scripted: '' },
],
})}
type="button"
>
Add subreddit
</button>
</div>
{errors.subreddits ? <p className="field-error" role="alert">{errors.subreddits}</p> : null}
<div className="subreddit-list">
{draft.subreddits.map((subreddit, index) => (
<fieldset className="subreddit-row" key={index}>
<legend>Subreddit {index + 1}</legend>
<label className="settings-field">
<span>Name</span>
<input
aria-describedby={errors[`subreddits.${index}.name`] ? `subreddit-${index}-name-error` : undefined}
aria-invalid={errors[`subreddits.${index}.name`] ? 'true' : undefined}
aria-label={`Subreddit ${index + 1} name`}
onChange={(event) => onChange(updateSubreddit(draft, index, { ...subreddit, name: event.target.value }))}
type="text"
value={subreddit.name}
/>
{errors[`subreddits.${index}.name`] ? <small className="field-error" id={`subreddit-${index}-name-error`}>{errors[`subreddits.${index}.name`]}</small> : null}
</label>
<label className="settings-field">
<span>Type</span>
<select
aria-label={`Subreddit ${index + 1} type`}
onChange={(event) => onChange(updateSubreddit(draft, index, { ...subreddit, type: event.target.value as SubredditDraft['type'] }))}
value={subreddit.type}
>
<option value="image">Image</option><option value="chat">Chat</option>
</select>
</label>
<label className="settings-field">
<span>Weight</span>
<input
aria-describedby={errors[`subreddits.${index}.weight`] ? `subreddit-${index}-weight-error` : undefined}
aria-invalid={errors[`subreddits.${index}.weight`] ? 'true' : undefined}
aria-label={`Subreddit ${index + 1} weight`}
min="0.01"
onChange={(event) => onChange(updateSubreddit(draft, index, { ...subreddit, weight: event.target.value }))}
step="0.01"
type="number"
value={subreddit.weight}
/>
{errors[`subreddits.${index}.weight`] ? <small className="field-error" id={`subreddit-${index}-weight-error`}>{errors[`subreddits.${index}.weight`]}</small> : null}
</label>
<label className="settings-field">
<span>Tone</span>
<select
aria-label={`Subreddit ${index + 1} tone`}
onChange={(event) => onChange(updateSubreddit(draft, index, { ...subreddit, tone: event.target.value as SubredditDraft['tone'] }))}
value={subreddit.tone}
>
{config.tones.map((tone) => <option key={tone} value={tone}>{tone}</option>)}
</select>
</label>
<label className="settings-field scripted-field">
<span>Scripted message (optional)</span>
<textarea
aria-label={`Subreddit ${index + 1} scripted message`}
onChange={(event) => onChange(updateSubreddit(draft, index, { ...subreddit, scripted: event.target.value }))}
rows={2}
value={subreddit.scripted}
/>
</label>
<button
className="button secondary remove-subreddit"
disabled={draft.subreddits.length === 1}
onClick={() => onChange({
...draft,
subreddits: draft.subreddits.filter((_, itemIndex) => itemIndex !== index),
})}
type="button"
>
Remove subreddit {index + 1}
</button>
</fieldset>
))}
</div>
</section>
);
}
@@ -0,0 +1,48 @@
.settings-page { max-width: 72rem; }
.settings-navigation { display: grid; grid-template-columns: 1fr; gap: .5rem; margin-bottom: 1rem; }
.settings-navigation button { display: grid; gap: .15rem; padding: .8rem; color: var(--muted); text-align: left; background: var(--surface); border: 1px solid var(--border); border-radius: .75rem; }
.settings-navigation button strong { color: #e9f1ec; }
.settings-navigation button small { font-size: .72rem; }
.settings-navigation button.is-active { background: var(--accent-dark); border-color: #28593d; }
.settings-navigation button:focus-visible, .settings-field input:focus-visible, .settings-field select:focus-visible, .settings-field textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.settings-loading { min-height: 24rem; }
.settings-section { display: grid; gap: 1rem; }
.settings-copy { color: var(--muted); }
.settings-grid { display: grid; gap: 1rem; }
.settings-field { display: grid; gap: .4rem; color: var(--muted); font-size: .8rem; font-weight: 750; }
.settings-field input, .settings-field select, .settings-field textarea { width: 100%; min-height: 2.7rem; padding: .55rem .7rem; color: #e9f1ec; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .6rem; }
.settings-field textarea { resize: vertical; }
.settings-field [aria-invalid="true"] { border-color: var(--danger); }
.field-error, .save-error { color: var(--danger); }
.toggle-field { display: flex; align-items: flex-start; gap: .8rem; padding: .9rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .75rem; }
.toggle-field input { width: 1.15rem; height: 1.15rem; margin-top: .15rem; accent-color: var(--accent); }
.toggle-field span { display: grid; gap: .2rem; }
.toggle-field small { color: var(--muted); }
.subreddit-heading { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 1rem; padding-top: .5rem; }
.subreddit-heading h3 { margin: 0 0 .25rem; }
.subreddit-heading p { margin: 0; color: var(--muted); }
.subreddit-list { display: grid; gap: .75rem; }
.subreddit-row { display: grid; gap: .75rem; min-width: 0; padding: 1rem; border: 1px solid var(--border); border-radius: .75rem; }
.subreddit-row legend { padding: 0 .4rem; color: var(--accent); font-weight: 800; }
.remove-subreddit { align-self: end; }
.settings-save-bar { position: sticky; bottom: 1rem; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; padding: .9rem 1rem; margin-top: 1rem; background: rgba(23, 35, 30, .97); border: 1px solid var(--border); border-radius: .8rem; box-shadow: 0 1rem 3rem rgba(0, 0, 0, .3); }
.settings-save-bar > div:first-child { display: grid; gap: .15rem; color: var(--muted); }
.save-success { color: var(--accent); }
.risk-list { display: grid; gap: .5rem; padding-left: 1.2rem; color: var(--warning); }
.access-actions { display: flex; flex-wrap: wrap; gap: .75rem; }
.access-actions a { display: inline-flex; align-items: center; text-decoration: none; }
.settings-notice { padding: .75rem; border-radius: .65rem; }
.settings-notice.success { color: #aaf0c4; background: #163a26; }
.settings-notice.warning { color: #ffe1a0; background: #42351b; }
.settings-notice.error { color: #ffd1ce; background: #461b18; }
@media (min-width: 42rem) {
.settings-navigation { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.settings-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.subreddit-row { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.scripted-field { grid-column: 1 / -1; }
}
@media (min-width: 70rem) {
.settings-navigation { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
+291
View File
@@ -0,0 +1,291 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import { SettingsRoute } from '../../apps/dashboard/src/features/settings/settings-route.js';
const config = {
rateLimit: { minDelayBetweenMessagesMs: 300_000, maxMessagesPerHour: 8, jitterMs: 60_000 },
browser: { dryRun: true },
reddit: { prioritizeRecentPosts: false, recentWindowMinutes: 60 },
subreddits: [{ name: 'casualconversation', type: 'chat', weight: 2, tone: 'default' }],
tones: ['default', 'dominant', 'submissive', 'playful', 'direct', 'gentle', 'kink-friendly', 'teasing'],
};
const server = setupServer();
function installConfigApi() {
server.use(http.get('/api/config', () => HttpResponse.json(config)));
}
function renderSettings(path = '/settings') {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const router = createMemoryRouter(
[{ path: '/settings', element: <SettingsRoute /> }],
{ initialEntries: [path] },
);
const view = render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>,
);
return { ...view, queryClient, router };
}
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
cleanup();
server.resetHandlers();
vi.restoreAllMocks();
});
afterAll(() => server.close());
describe('settings and access', () => {
it('loads the requested route-friendly section and keeps section selection in the URL', async () => {
let resolveConfig: ((response: Response) => void) | undefined;
server.use(
http.get('/api/config', async ({ request }) => {
expect(request.credentials).toBe('same-origin');
return new Promise<Response>((resolve) => { resolveConfig = resolve; });
}),
);
const user = userEvent.setup();
const { router } = renderSettings('/settings?section=rate-limits');
expect(screen.getByRole('status', { name: 'Loading settings' })).toBeInTheDocument();
await waitFor(() => expect(resolveConfig).toBeTypeOf('function'));
resolveConfig?.(HttpResponse.json(config));
expect(await screen.findByRole('heading', { name: 'Rate limits' })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Monitoring' }));
expect(screen.getByRole('heading', { name: 'Monitoring' })).toBeInTheDocument();
expect(router.state.location.search).toBe('?section=monitoring');
});
it('shows a recoverable initial configuration error', async () => {
let requests = 0;
server.use(
http.get('/api/config', () => {
requests += 1;
return requests === 1
? HttpResponse.json({ error: 'Configuration unavailable' }, { status: 503 })
: HttpResponse.json(config);
}),
);
const user = userEvent.setup();
renderSettings();
const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent('Configuration unavailable');
await user.click(within(alert).getByRole('button', { name: 'Retry settings' }));
expect(await screen.findByRole('heading', { name: 'Monitoring' })).toBeInTheDocument();
});
it('validates fields, warns about unsaved changes, and resets to persisted values', async () => {
installConfigApi();
let updateRequests = 0;
server.use(http.put('/api/config', () => {
updateRequests += 1;
return HttpResponse.json({ success: true, config, subreddits: config.subreddits });
}));
const user = userEvent.setup();
renderSettings('/settings?section=monitoring');
const recentWindow = await screen.findByRole('spinbutton', { name: 'Recent window in minutes' });
await user.clear(recentWindow);
await user.type(recentWindow, '0');
expect(screen.getByRole('status')).toHaveTextContent('You have unsaved changes.');
const beforeUnload = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(beforeUnload);
expect(beforeUnload.defaultPrevented).toBe(true);
await user.click(screen.getByRole('button', { name: 'Save settings' }));
expect(screen.getByText('Recent window must be greater than 0 minutes.')).toBeInTheDocument();
expect(recentWindow).toHaveAttribute('aria-invalid', 'true');
expect(updateRequests).toBe(0);
await user.click(screen.getByRole('button', { name: 'Reset changes' }));
expect(recentWindow).toHaveValue(60);
expect(screen.queryByText('You have unsaved changes.')).not.toBeInTheDocument();
});
it('rejects blank numeric controls and invalid subreddit fields before transport', async () => {
installConfigApi();
let updateRequests = 0;
server.use(http.put('/api/config', () => {
updateRequests += 1;
return HttpResponse.json({ success: true });
}));
const user = userEvent.setup();
const { router } = renderSettings('/settings?section=rate-limits');
const minimumDelay = await screen.findByRole('spinbutton', { name: 'Minimum delay in minutes' });
await user.clear(minimumDelay);
await user.click(screen.getByRole('button', { name: 'Save settings' }));
expect(screen.getByText('Minimum delay is required.')).toBeInTheDocument();
expect(minimumDelay).toHaveAttribute('aria-invalid', 'true');
await user.click(screen.getByRole('button', { name: 'Behaviour' }));
expect(router.state.location.search).toBe('?section=behaviour');
const subredditName = screen.getByRole('textbox', { name: 'Subreddit 1 name' });
await user.clear(subredditName);
await user.click(screen.getByRole('button', { name: 'Save settings' }));
expect(screen.getByText('Subreddit name is required.')).toBeInTheDocument();
expect(subredditName).toHaveAttribute('aria-invalid', 'true');
await user.type(subredditName, 'example');
const weight = screen.getByRole('spinbutton', { name: 'Subreddit 1 weight' });
await user.clear(weight);
await user.type(weight, '-1');
await user.click(screen.getByRole('button', { name: 'Save settings' }));
expect(screen.getByText('Subreddit weight must be greater than 0.')).toBeInTheDocument();
expect(weight).toHaveAttribute('aria-invalid', 'true');
expect(updateRequests).toBe(0);
});
it('saves validated settings with pending and authoritative success states', async () => {
installConfigApi();
let requestBody: unknown;
let resolveUpdate: ((response: Response) => void) | undefined;
server.use(
http.put('/api/config', async ({ request }) => {
expect(request.credentials).toBe('same-origin');
requestBody = await request.json();
return new Promise<Response>((resolve) => { resolveUpdate = resolve; });
}),
);
const user = userEvent.setup();
renderSettings('/settings?section=monitoring');
const prioritize = await screen.findByRole('checkbox', { name: 'Prioritise recent posts' });
await user.click(prioritize);
await user.click(screen.getByRole('button', { name: 'Save settings' }));
expect(screen.getByRole('button', { name: 'Saving settings' })).toBeDisabled();
await waitFor(() => expect(resolveUpdate).toBeTypeOf('function'));
expect(requestBody).toMatchObject({ reddit: { prioritizeRecentPosts: true, recentWindowMinutes: 60 } });
resolveUpdate?.(HttpResponse.json({
success: true,
config: {
rateLimit: config.rateLimit,
browser: config.browser,
reddit: { ...config.reddit, prioritizeRecentPosts: true },
},
subreddits: config.subreddits,
}));
expect(await screen.findByText('Settings saved.')).toBeInTheDocument();
expect(screen.queryByText('You have unsaved changes.')).not.toBeInTheDocument();
await user.click(prioritize);
expect(screen.getByText('You have unsaved changes.')).toBeInTheDocument();
expect(screen.queryByText('Settings saved.')).not.toBeInTheDocument();
});
it('validates subreddit rows and confirms disabling dry run before saving behaviour', async () => {
installConfigApi();
let updateRequests = 0;
server.use(
http.put('/api/config', async ({ request }) => {
updateRequests += 1;
const body = await request.json() as { browser: { dryRun: boolean }; subreddits: typeof config.subreddits };
return HttpResponse.json({
success: true,
config: { rateLimit: config.rateLimit, browser: body.browser, reddit: config.reddit },
subreddits: body.subreddits,
});
}),
);
const user = userEvent.setup();
renderSettings('/settings?section=behaviour');
await user.click(await screen.findByRole('button', { name: 'Add subreddit' }));
await user.type(screen.getByRole('textbox', { name: 'Subreddit 2 name' }), 'CasualConversation');
await user.click(screen.getByRole('button', { name: 'Save settings' }));
expect(screen.getByText('Subreddit names must be unique.')).toBeInTheDocument();
expect(updateRequests).toBe(0);
const secondName = screen.getByRole('textbox', { name: 'Subreddit 2 name' });
await user.clear(secondName);
await user.type(secondName, 'askreddit');
await user.click(screen.getByRole('checkbox', { name: 'Dry run mode' }));
await user.click(screen.getByRole('button', { name: 'Save settings' }));
const dialog = screen.getByRole('dialog', { name: 'Confirm risky settings' });
expect(dialog).toHaveTextContent('Automated messages will be sent to Reddit');
expect(updateRequests).toBe(0);
await user.click(within(dialog).getByRole('button', { name: 'Apply risky settings' }));
expect(await screen.findByText('Settings saved.')).toBeInTheDocument();
expect(updateRequests).toBe(1);
});
it('confirms unusually large hourly limits', async () => {
installConfigApi();
let updateRequests = 0;
server.use(http.put('/api/config', () => {
updateRequests += 1;
return HttpResponse.json({ success: true, config, subreddits: config.subreddits });
}));
const user = userEvent.setup();
renderSettings('/settings?section=rate-limits');
const hourlyLimit = await screen.findByRole('spinbutton', { name: 'Maximum messages per hour' });
await user.clear(hourlyLimit);
await user.type(hourlyLimit, '50');
await user.click(screen.getByRole('button', { name: 'Save settings' }));
expect(screen.getByRole('dialog', { name: 'Confirm risky settings' })).toHaveTextContent('50 messages per hour');
expect(updateRequests).toBe(0);
});
it('shows Reddit session and noVNC state without exposing credentials, then starts and rechecks login', async () => {
let statusRequests = 0;
let loginRequests = 0;
server.use(
http.get('/api/config', () => HttpResponse.json(config)),
http.get('/api/login/info', ({ request }) => {
expect(request.credentials).toBe('same-origin');
return HttpResponse.json({
vncEnabled: true,
novncPort: 6080,
publicUrl: 'https://login.example/vnc.html',
passwordRequired: true,
});
}),
http.get('/api/login/status', () => {
statusRequests += 1;
return HttpResponse.json(statusRequests === 1
? { ready: true, loggedIn: false, status: 'login-required' }
: { ready: true, loggedIn: true, status: 'authenticated' });
}),
http.post('/api/login/start', ({ request }) => {
expect(request.credentials).toBe('same-origin');
loginRequests += 1;
return HttpResponse.json({ ok: true });
}),
);
const user = userEvent.setup();
renderSettings('/settings?section=access');
expect(await screen.findByText('Login required')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Open noVNC login window' })).toHaveAttribute('href', 'https://login.example/vnc.html');
expect(screen.getByText('A VNC password is required.')).toBeInTheDocument();
expect(screen.queryByRole('textbox', { name: /password/i })).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Open Reddit login' }));
expect(await screen.findByText('Login page opened.')).toBeInTheDocument();
expect(loginRequests).toBe(1);
expect(await screen.findByText('Authenticated')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Recheck session' }));
expect(statusRequests).toBeGreaterThanOrEqual(3);
});
});