feat: add settings and access dashboard slice
This commit is contained in:
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)); }
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user