From 79de6aa2a156568617a373dbbb29d6b3e1f9a068 Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Sat, 8 Aug 2026 15:24:47 +0100 Subject: [PATCH] feat: add settings and access dashboard slice --- .../src/features/settings/access-panel.tsx | 59 ++++ .../src/features/settings/risk-dialog.tsx | 34 ++ .../src/features/settings/settings-api.ts | 58 ++++ .../src/features/settings/settings-editor.tsx | 117 +++++++ .../src/features/settings/settings-model.ts | 142 +++++++++ .../src/features/settings/settings-page.tsx | 44 +++ .../src/features/settings/settings-queries.ts | 45 +++ .../src/features/settings/settings-route.tsx | 19 ++ .../features/settings/settings-sections.tsx | 209 +++++++++++++ .../src/features/settings/settings.css | 48 +++ tests/dashboard/settings.test.tsx | 291 ++++++++++++++++++ 11 files changed, 1066 insertions(+) create mode 100644 apps/dashboard/src/features/settings/access-panel.tsx create mode 100644 apps/dashboard/src/features/settings/risk-dialog.tsx create mode 100644 apps/dashboard/src/features/settings/settings-api.ts create mode 100644 apps/dashboard/src/features/settings/settings-editor.tsx create mode 100644 apps/dashboard/src/features/settings/settings-model.ts create mode 100644 apps/dashboard/src/features/settings/settings-page.tsx create mode 100644 apps/dashboard/src/features/settings/settings-queries.ts create mode 100644 apps/dashboard/src/features/settings/settings-route.tsx create mode 100644 apps/dashboard/src/features/settings/settings-sections.tsx create mode 100644 apps/dashboard/src/features/settings/settings.css create mode 100644 tests/dashboard/settings.test.tsx diff --git a/apps/dashboard/src/features/settings/access-panel.tsx b/apps/dashboard/src/features/settings/access-panel.tsx new file mode 100644 index 0000000..cd5f663 --- /dev/null +++ b/apps/dashboard/src/features/settings/access-panel.tsx @@ -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['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
Loading Reddit access…
; + } + + if (infoQuery.isError || statusQuery.isError) { + const error = infoQuery.error ?? statusQuery.error; + return ( +
+ Reddit access unavailable{errorMessage(error)} + +
+ ); + } + + const info = infoQuery.data; + return ( +
+
Browser session

Access

{sessionLabel(statusQuery.data)}
+

Open Reddit in the managed browser, complete login through noVNC, then recheck the session. Credentials are never entered or displayed here.

+
+ + + {info.vncEnabled ? Open noVNC login window : null} +
+ {!info.vncEnabled ?
noVNC is not available in this deployment.
: null} + {info.passwordRequired ?

A VNC password is required.

: null} + {loginMutation.isSuccess ?
Login page opened.
: null} + {loginMutation.isError ?
{errorMessage(loginMutation.error)}
: null} +
+ ); +} diff --git a/apps/dashboard/src/features/settings/risk-dialog.tsx b/apps/dashboard/src/features/settings/risk-dialog.tsx new file mode 100644 index 0000000..91f5284 --- /dev/null +++ b/apps/dashboard/src/features/settings/risk-dialog.tsx @@ -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(null); + + useEffect(() => { + cancelButtonRef.current?.focus(); + }, []); + + return ( +
+
+ Confirm operational risk +

Confirm risky settings

+
    + {reasons.map((reason) =>
  • {reason}
  • )} +
+
+ + +
+
+
+ ); +} diff --git a/apps/dashboard/src/features/settings/settings-api.ts b/apps/dashboard/src/features/settings/settings-api.ts new file mode 100644 index 0000000..8a44567 --- /dev/null +++ b/apps/dashboard/src/features/settings/settings-api.ts @@ -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(response: Response, schema: ZodType): Promise { + 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 { + return globalThis.fetch(path, { credentials: 'same-origin', ...init }); +} + +export function fetchSettings(): Promise { + 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 { + return request('/api/login/status').then((response) => parseResponse(response, LoginStatusResponseSchema)); +} + +export async function startLogin(): Promise { + const response = await parseResponse( + await request('/api/login/start', { method: 'POST' }), + LoginStartResponseSchema, + ); + if (!response.ok) throw new Error(response.error); +} diff --git a/apps/dashboard/src/features/settings/settings-editor.tsx b/apps/dashboard/src/features/settings/settings-editor.tsx new file mode 100644 index 0000000..2dbd46c --- /dev/null +++ b/apps/dashboard/src/features/settings/settings-editor.tsx @@ -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(() => createSettingsDraft(config)); + const [draft, setDraft] = useState(persistedDraft); + const [errors, setErrors] = useState>({}); + const [pendingRiskySave, setPendingRiskySave] = useState(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>) { + 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' ? : null} + {section === 'rate-limits' ? : null} + {section === 'behaviour' ? : null} + {section === 'access' ? : null} + + {section !== 'access' ? ( +
+
+ {isDirty ? You have unsaved changes. : All changes saved.} + {saveMutation.isSuccess ? Settings saved. : null} + {saveMutation.isError ? {errorMessage(saveMutation.error)} : null} +
+
+ + +
+
+ ) : null} + + {pendingRiskySave ? ( + setPendingRiskySave(null)} + onConfirm={() => submit(pendingRiskySave.payload)} + reasons={pendingRiskySave.reasons} + /> + ) : null} + + ); +} diff --git a/apps/dashboard/src/features/settings/settings-model.ts b/apps/dashboard/src/features/settings/settings-model.ts new file mode 100644 index 0000000..93afbda --- /dev/null +++ b/apps/dashboard/src/features/settings/settings-model.ts @@ -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; + 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; +} + +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 = {}; + 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 = {}; + 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); +} diff --git a/apps/dashboard/src/features/settings/settings-page.tsx b/apps/dashboard/src/features/settings/settings-page.tsx new file mode 100644 index 0000000..cc211b5 --- /dev/null +++ b/apps/dashboard/src/features/settings/settings-page.tsx @@ -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 ( +
+
+
Platform configuration

Settings

Manage supported runtime behaviour and Reddit browser access.

+
+ + + {settingsQuery.isPending ?
Loading settings…
: null} + {settingsQuery.isError ? ( +
Settings unavailable{errorMessage(settingsQuery.error)}
+ ) : null} + {settingsQuery.data ? : null} +
+ ); +} diff --git a/apps/dashboard/src/features/settings/settings-queries.ts b/apps/dashboard/src/features/settings/settings-queries.ts new file mode 100644 index 0000000..cabe0b5 --- /dev/null +++ b/apps/dashboard/src/features/settings/settings-queries.ts @@ -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(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 }); + }, + }); +} diff --git a/apps/dashboard/src/features/settings/settings-route.tsx b/apps/dashboard/src/features/settings/settings-route.tsx new file mode 100644 index 0000000..27fb043 --- /dev/null +++ b/apps/dashboard/src/features/settings/settings-route.tsx @@ -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 ; +} diff --git a/apps/dashboard/src/features/settings/settings-sections.tsx b/apps/dashboard/src/features/settings/settings-sections.tsx new file mode 100644 index 0000000..a8901b1 --- /dev/null +++ b/apps/dashboard/src/features/settings/settings-sections.tsx @@ -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; + 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 ( + + ); +} + +export function MonitoringSection({ draft, errors, onChange }: SectionProps) { + return ( +
+
Discovery

Monitoring

+ + onChange({ ...draft, recentWindowMinutes })} + value={draft.recentWindowMinutes} + /> +
+ ); +} + +export function RateLimitsSection({ draft, errors, onChange }: SectionProps) { + return ( +
+
Sending safeguards

Rate limits

+
+ onChange({ ...draft, minDelayMinutes })} + value={draft.minDelayMinutes} + /> + onChange({ ...draft, maxMessagesPerHour })} + value={draft.maxMessagesPerHour} + /> + onChange({ ...draft, jitterSeconds })} + value={draft.jitterSeconds} + /> +
+
+ ); +} + +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 ( +
+
Runtime behaviour

Behaviour

+ + +
+

Subreddits

Configure monitored communities and their supported message style.

+ +
+ {errors.subreddits ?

{errors.subreddits}

: null} +
+ {draft.subreddits.map((subreddit, index) => ( +
+ Subreddit {index + 1} + + + + +