import { useState, type FormEvent } from "react"; import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import * as stylex from "@stylexjs/stylex"; import InlineError from "@/lib/InlineError"; import { settingsPutMutation, settingsQuery } from "@/lib/queries"; import { buildSettingsPatch } from "@/lib/settingsDiff"; import type { Settings, SettingsPatch } from "@/lib/types"; import { raiseRestartBanner } from "./restartBanner"; import { READ_ONLY_HINT, useReadOnlyConfig } from "./authority"; import Select from "@/ui/Select"; import { styles as shared } from "@/ui/styles"; import { colors } from "@/ui/tokens.stylex"; /** True when the patch touches anything besides the write-only `web.password` (ruling 11). */ export function patchRequiresRestart(patch: SettingsPatch): boolean { return Object.entries(patch).some(([section, fields]) => Object.keys(fields ?? {}).some((key) => !(section === "web" && key === "password")), ); } interface FieldDef { key: keyof Settings[S] & string; kind: "number" | "text" | "boolean" | readonly string[]; } interface SectionDef { section: S; title: string; fields: readonly FieldDef[]; } /** Binds each section's field keys to that section's Settings type at definition. */ function defineSection(def: SectionDef): SectionDef { return def; } /** The registry read back as a heterogeneous list, once the per-section binding has been proven. */ type AnyFieldDef = { [S in keyof Settings]: FieldDef }[keyof Settings]; type AnySectionDef = { [S in keyof Settings]: SectionDef }[keyof Settings]; /** * A section's values as a string-keyed view. The keys are proven against * `Settings[S]` where each section is defined; iterating the heterogeneous * registry loses that correlation, so consumption widens here in one place. */ function sectionValues(settings: Settings, section: keyof Settings): Record { return settings[section] as Record; } const TLS_FIELDS: readonly FieldDef<"doh_server" | "dot_server">[] = [ { key: "enabled", kind: "boolean" }, { key: "bind", kind: "text" }, { key: "port", kind: "number" }, { key: "cert_path", kind: "text" }, { key: "key_path", kind: "text" }, ]; const SECTIONS: readonly AnySectionDef[] = [ defineSection({ section: "upstream", title: "Upstream", fields: [ { key: "attempt_timeout_ms", kind: "number" }, { key: "read_timeout_ms", kind: "number" }, { key: "total_timeout_ms", kind: "number" }, ], }), defineSection({ section: "dns", title: "DNS", fields: [ { key: "bind_ipv4", kind: "text" }, { key: "bind_ipv6", kind: "text" }, { key: "port", kind: "number" }, { key: "rate_limit", kind: "number" }, { key: "rate_window_seconds", kind: "number" }, ], }), defineSection({ section: "blocking", title: "Blocking", fields: [ { key: "response", kind: ["zero", "nxdomain"] }, { key: "ttl", kind: "number" }, ], }), defineSection({ section: "cache", title: "Cache", fields: [ { key: "size", kind: "number" }, { key: "negative_ttl_max", kind: "number" }, ], }), defineSection({ section: "web", title: "Web", fields: [ { key: "enabled", kind: "boolean" }, { key: "bind", kind: "text" }, { key: "port", kind: "number" }, { key: "session_ttl_hours", kind: "number" }, { key: "api_rate_limit_per_min", kind: "number" }, { key: "api_localhost_exempt", kind: "boolean" }, { key: "sse_max_connections_per_ip", kind: "number" }, { key: "trusted_proxies", kind: "text" }, ], }), defineSection({ section: "doh_server", title: "DoH Server", fields: TLS_FIELDS }), defineSection({ section: "dot_server", title: "DoT Server", fields: TLS_FIELDS }), defineSection({ section: "edns", title: "EDNS", fields: [{ key: "ecs_mode", kind: ["strip", "forward"] }] }), defineSection({ section: "logging", title: "Logging", fields: [ { key: "level", kind: ["error", "warn", "info", "debug"] }, { key: "retention_days", kind: "number" }, { key: "query_log_buffer_max", kind: "number" }, { key: "hide_domains", kind: "boolean" }, { key: "hide_client_ips", kind: "boolean" }, { key: "output", kind: ["stderr", "syslog", "file"] }, { key: "file_path", kind: "text" }, { key: "max_size_mb", kind: "number" }, { key: "max_files", kind: "number" }, ], }), defineSection({ section: "disk", title: "Disk", fields: [ { key: "min_free_mb", kind: "number" }, { key: "warn_free_mb", kind: "number" }, ], }), defineSection({ section: "blocklist_update", title: "Blocklist Update", fields: [ { key: "enabled", kind: "boolean" }, { key: "interval_hours", kind: "number" }, ], }), ]; const DARK = "@media (prefers-color-scheme: dark)"; const styles = stylex.create({ heading: { fontSize: "1.5rem", lineHeight: "2rem", fontWeight: 600, }, intro: { marginTop: "0.25rem", fontSize: "0.875rem", lineHeight: "1.25rem", color: colors.textMuted, }, form: { marginTop: "1rem", maxWidth: "48rem", }, /** A `fieldset` has a browser default border and padding; the layout wants neither. */ sections: { display: "flex", flexDirection: "column", gap: "1.5rem", borderStyle: "none", margin: 0, padding: 0, }, section: { borderRadius: "0.25rem", borderWidth: 1, borderStyle: "solid", borderColor: colors.border, padding: "1rem", }, legend: { paddingInline: "0.25rem", fontSize: "0.875rem", lineHeight: "1.25rem", fontWeight: 600, }, /** One column on a phone, two from `sm`, as before. */ fieldGrid: { display: "grid", gap: "0.75rem", gridTemplateColumns: { default: "repeat(1, minmax(0, 1fr))", "@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))", }, }, label: { fontSize: "0.875rem", lineHeight: "1.25rem", color: { default: "oklch(37% 0.013 285.805)", [DARK]: "oklch(87.1% 0.006 286.286)", }, }, checkboxRow: { display: "flex", alignItems: "center", gap: "0.5rem", }, field: { display: "flex", flexDirection: "column", gap: "0.25rem", }, fieldInput: { borderRadius: "0.25rem", borderWidth: 1, borderStyle: "solid", borderColor: colors.borderStrong, backgroundColor: colors.surfaceRaised, color: colors.text, paddingInline: "0.5rem", paddingBlock: "0.25rem", fontSize: "0.875rem", lineHeight: "1.25rem", }, derived: { color: colors.textMuted, }, /** Both notices span the whole grid so the wrapped sentence stays readable. */ spanRow: { gridColumn: { default: null, "@media (min-width: 640px)": "span 2 / span 2" }, fontSize: "0.875rem", lineHeight: "1.25rem", }, passwordNotice: { color: { default: "oklch(55.5% 0.163 48.998)", [DARK]: "oklch(82.8% 0.189 84.429)" }, }, mismatchNotice: { color: colors.danger, }, submitRow: { display: "flex", alignItems: "center", gap: "0.75rem", }, save: { borderStyle: "none", borderRadius: "0.25rem", paddingInline: "1rem", paddingBlock: "0.375rem", fontSize: "0.875rem", lineHeight: "1.25rem", fontWeight: 500, backgroundColor: { default: colors.primary, ":disabled": "oklch(87.1% 0.006 286.286)", [DARK]: { default: colors.primary, ":disabled": "oklch(27.4% 0.006 286.033)" }, }, color: { default: colors.primaryText, ":disabled": "oklch(55.2% 0.016 285.938)" }, }, }); function FieldRow({ section, def, value, onChange, }: { section: string; def: AnyFieldDef; value: unknown; onChange: (value: unknown) => void; }) { const id = `${section}.${def.key}`; if (def.kind === "boolean") { return (
onChange(e.target.checked)} {...stylex.props(shared.focusRing)} />
); } if (Array.isArray(def.kind)) { return ( onChange(e.target.valueAsNumber)} {...stylex.props(styles.fieldInput, shared.focusRing)} /> ); } return (
onChange(e.target.value)} {...stylex.props(styles.fieldInput, shared.focusRing)} />
); } export default function SettingsPage() { const { data } = useSuspenseQuery(settingsQuery()); const queryClient = useQueryClient(); const mutation = useMutation(settingsPutMutation(queryClient)); // Frozen at mount and re-frozen on save: diffing against live query data would // turn a background refetch's out-of-band changes into phantom user edits. const [baseline, setBaseline] = useState(() => structuredClone(data.settings)); const [edited, setEdited] = useState(() => structuredClone(data.settings)); const [password, setPassword] = useState(""); const [confirm, setConfirm] = useState(""); const passwordsMismatch = (password !== "" || confirm !== "") && password !== confirm; const hasInvalidNumber = SECTIONS.some(({ section, fields }) => { const values = sectionValues(edited, section); return (fields as readonly AnyFieldDef[]).some( (field) => field.kind === "number" && Number.isNaN(values[field.key]), ); }); const patch = buildSettingsPatch(baseline, edited, password === "" ? undefined : password); const readOnly = useReadOnlyConfig(); const saveDisabled = patch === null || passwordsMismatch || hasInvalidNumber || mutation.isPending || readOnly; function setField(section: keyof Settings, key: string, value: unknown): void { setEdited((prev) => ({ ...prev, [section]: { ...sectionValues(prev, section), [key]: value }, })); } function handleSubmit(event: FormEvent): void { event.preventDefault(); if (patch === null || passwordsMismatch || hasInvalidNumber) return; const restartNeeded = patchRequiresRestart(patch); mutation.mutate(patch, { onSuccess: (envelope) => { setBaseline(structuredClone(envelope.settings)); setEdited(structuredClone(envelope.settings)); setPassword(""); setConfirm(""); if (restartNeeded) raiseRestartBanner(); }, }); } return (

Settings

Changes are validated as a whole; every setting requires a restart to take effect.

{SECTIONS.map(({ section, title, fields }) => (
{title}
{(fields as readonly AnyFieldDef[]).map((def) => ( setField(section, def.key, value)} /> ))} {section === "web" && ( <>

auth_enabled: {data.settings.web.auth_enabled ? "true" : "false"}{" "} (derived, read-only)

setPassword(e.target.value)} {...stylex.props(styles.fieldInput, shared.focusRing)} />
setConfirm(e.target.value)} {...stylex.props(styles.fieldInput, shared.focusRing)} />
{password !== "" && (

Changing the password signs out every session; you will be asked to log in again.

)} {passwordsMismatch && (

Passwords do not match.

)} )}
))}
{mutation.isError && }
); }