refactor: split oversized files + add store notification batching

- Split Server/admin/api.go (788→281 lines) into handlers_users.go,
  handlers_channels.go, handlers_settings.go, handlers_backup.go
- Split Client SettingsOverlay.ts (~685→173 lines) into 7 per-tab
  modules under components/settings/
- Add queueMicrotask-based notification batching to createStore with
  flush() for synchronous test assertions
- Update 8 test files with flush() calls for batched store updates

Addresses TODOS.md #9 (split oversized files) for 2 of 3 targets.
This commit is contained in:
jevb
2026-03-17 02:17:26 +01:00
parent 45d1cf9747
commit a196807880
22 changed files with 1271 additions and 955 deletions
@@ -1,15 +1,20 @@
/**
* SettingsOverlay component — full-screen overlay with tabbed settings panels.
* Tabs: Account, Appearance, Notifications, Keybinds.
* Tabs: Account, Appearance, Notifications, Voice & Audio, Keybinds, Logs.
* Subscribes to uiStore for settingsOpen state.
*/
import { createElement, appendChildren, clearChildren, setText } from "@lib/dom";
import { createElement, appendChildren, clearChildren } from "@lib/dom";
import type { MountableComponent } from "@lib/safe-render";
import { uiStore } from "@stores/ui.store";
import { authStore } from "@stores/auth.store";
import { getLogBuffer, clearLogBuffer, addLogListener, setLogLevel } from "@lib/logger";
import type { LogEntry, LogLevel } from "@lib/logger";
import { loadPref, applyTheme } from "./settings/helpers";
import type { ThemeName } from "./settings/helpers";
import { buildAccountTab } from "./settings/AccountTab";
import { buildAppearanceTab } from "./settings/AppearanceTab";
import { buildNotificationsTab } from "./settings/NotificationsTab";
import { buildVoiceAudioTab } from "./settings/VoiceAudioTab";
import { buildKeybindsTab } from "./settings/KeybindsTab";
import { createLogsTab } from "./settings/LogsTab";
// ---------------------------------------------------------------------------
// Types
@@ -22,7 +27,7 @@ export interface SettingsOverlayOptions {
onLogout(): void;
}
type TabName = "Account" | "Appearance" | "Notifications" | "Voice & Audio" | "Keybinds" | "Logs";
export type TabName = "Account" | "Appearance" | "Notifications" | "Voice & Audio" | "Keybinds" | "Logs";
const TAB_NAMES: readonly TabName[] = [
"Account",
@@ -34,44 +39,9 @@ const TAB_NAMES: readonly TabName[] = [
] as const;
// ---------------------------------------------------------------------------
// Theme definitions
// Apply stored appearance (called at app startup)
// ---------------------------------------------------------------------------
const THEMES = {
dark: { "--bg-primary": "#313338", "--bg-secondary": "#2b2d31", "--bg-tertiary": "#1e1f22", "--text-normal": "#dbdee1" },
midnight: { "--bg-primary": "#1a1a2e", "--bg-secondary": "#16213e", "--bg-tertiary": "#0f3460", "--text-normal": "#e0e0e0" },
light: { "--bg-primary": "#ffffff", "--bg-secondary": "#f2f3f5", "--bg-tertiary": "#e3e5e8", "--text-normal": "#313338" },
} as const;
type ThemeName = keyof typeof THEMES;
const STORAGE_PREFIX = "owncord:settings:";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function loadPref<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(STORAGE_PREFIX + key);
return raw !== null ? (JSON.parse(raw) as T) : fallback;
} catch {
return fallback;
}
}
function savePref(key: string, value: unknown): void {
localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(value));
}
function applyTheme(name: ThemeName): void {
const vars = THEMES[name];
const root = document.documentElement;
for (const [prop, val] of Object.entries(vars)) {
root.style.setProperty(prop, val);
}
}
/**
* Apply stored appearance preferences (theme, font size, compact mode).
* Call at app startup so the UI doesn't flash default styles.
@@ -102,479 +72,18 @@ export function createSettingsOverlay(
const tabButtons = new Map<TabName, HTMLButtonElement>();
let unsubUi: (() => void) | null = null;
// Logs tab has stateful cleanup needs — create once via factory
const logsTab = createLogsTab(() => activeTab, ac.signal);
// ---- Tab content builders -------------------------------------------------
function buildAccountTab(): HTMLDivElement {
const section = createElement("div", { class: "settings-pane active" });
const user = authStore.getState().user;
// Account card
const accountCard = createElement("div", { class: "account-card" });
const acAvatar = createElement("div", {
class: "ac-avatar",
style: "background: var(--accent)",
}, (user?.username ?? "U").charAt(0).toUpperCase());
const acInfo = createElement("div", {});
const acName = createElement("div", { class: "ac-name" }, user?.username ?? "Unknown");
const acId = createElement("div", { class: "ac-id" }, `ID: ${user?.id ?? "?"}`);
appendChildren(acInfo, acName, acId);
const editBtn = createElement("button", { class: "ac-btn" }, "Edit Profile");
appendChildren(accountCard, acAvatar, acInfo, editBtn);
section.appendChild(accountCard);
const editForm = createElement("div", { class: "setting-row", style: "display:none" });
const editInput = createElement("input", { class: "form-input", type: "text", placeholder: "New username" });
const saveBtn = createElement("button", { class: "ac-btn" }, "Save");
const cancelBtn = createElement("button", { class: "ac-btn", style: "background:var(--bg-active)" }, "Cancel");
const usernameValue = acName;
appendChildren(editForm, editInput, saveBtn, cancelBtn);
editBtn.addEventListener("click", () => {
editForm.style.display = "flex";
editInput.value = user?.username ?? "";
editInput.focus();
}, { signal: ac.signal });
cancelBtn.addEventListener("click", () => {
editForm.style.display = "none";
}, { signal: ac.signal });
saveBtn.addEventListener("click", () => {
const newName = editInput.value.trim();
if (newName.length > 0) {
void options.onUpdateProfile(newName).then(() => {
setText(usernameValue, newName);
editForm.style.display = "none";
});
}
}, { signal: ac.signal });
section.appendChild(editForm);
// Change password
const pwHeader = createElement("h3", {}, "Change Password");
const oldPw = createElement("input", { class: "form-input", type: "password", placeholder: "Old password", style: "margin-bottom:8px" });
const newPw = createElement("input", { class: "form-input", type: "password", placeholder: "New password", style: "margin-bottom:8px" });
const confirmPw = createElement("input", { class: "form-input", type: "password", placeholder: "Confirm new password", style: "margin-bottom:8px" });
const pwError = createElement("div", { style: "color:var(--red);font-size:13px;margin-bottom:8px" });
const pwBtn = createElement("button", { class: "ac-btn" }, "Change Password");
pwBtn.addEventListener("click", () => {
const oldVal = oldPw.value;
const newVal = newPw.value;
const confirmVal = confirmPw.value;
if (newVal.length < 8) {
setText(pwError, "New password must be at least 8 characters.");
return;
}
if (newVal !== confirmVal) {
setText(pwError, "Passwords do not match.");
return;
}
setText(pwError, "");
void options.onChangePassword(oldVal, newVal).then(() => {
oldPw.value = "";
newPw.value = "";
confirmPw.value = "";
});
}, { signal: ac.signal });
appendChildren(section, pwHeader, oldPw, newPw, confirmPw, pwError, pwBtn);
// Logout
const logoutBtn = createElement("button", {
class: "settings-nav-item danger",
style: "margin-top:16px;width:auto;padding:8px 16px",
}, "Log Out");
logoutBtn.addEventListener("click", () => options.onLogout(), { signal: ac.signal });
section.appendChild(logoutBtn);
return section;
}
function buildAppearanceTab(): HTMLDivElement {
const section = createElement("div", { class: "settings-pane active" });
const currentTheme = loadPref<ThemeName>("theme", "dark");
const currentFontSize = loadPref<number>("fontSize", 16);
const currentCompact = loadPref<boolean>("compactMode", false);
// Theme selector
const themeHeader = createElement("h3", {}, "Theme");
const themeRow = createElement("div", { class: "theme-options" });
for (const name of Object.keys(THEMES) as ThemeName[]) {
const btn = createElement("div", {
class: `theme-opt ${name}${name === currentTheme ? " active" : ""}`,
}, name.charAt(0).toUpperCase() + name.slice(1));
btn.addEventListener("click", () => {
applyTheme(name);
savePref("theme", name);
const prev = themeRow.querySelector(".theme-opt.active");
if (prev) prev.classList.remove("active");
btn.classList.add("active");
}, { signal: ac.signal });
themeRow.appendChild(btn);
}
appendChildren(section, themeHeader, themeRow);
// Font size slider
const fontHeader = createElement("h3", {}, "Font Size");
const fontRow = createElement("div", { class: "slider-row" });
const fontSlider = createElement("input", {
class: "settings-slider",
type: "range",
min: "12",
max: "20",
value: String(currentFontSize),
});
const fontLabel = createElement("span", { class: "slider-val" }, `${currentFontSize}px`);
fontSlider.addEventListener("input", () => {
const size = Number(fontSlider.value);
setText(fontLabel, `${size}px`);
document.documentElement.style.setProperty("--font-size", `${size}px`);
savePref("fontSize", size);
}, { signal: ac.signal });
appendChildren(fontRow, fontSlider, fontLabel);
appendChildren(section, fontHeader, fontRow);
// Compact mode toggle
const compactRow = createElement("div", { class: "setting-row" });
const compactLabel = createElement("span", { class: "setting-label" }, "Compact Mode");
const compactToggle = createElement("div", {
class: currentCompact ? "toggle on" : "toggle",
});
compactToggle.addEventListener("click", () => {
const isNowCompact = !compactToggle.classList.contains("on");
compactToggle.classList.toggle("on", isNowCompact);
savePref("compactMode", isNowCompact);
document.documentElement.classList.toggle("compact-mode", isNowCompact);
}, { signal: ac.signal });
appendChildren(compactRow, compactLabel, compactToggle);
section.appendChild(compactRow);
// Apply stored preferences on render
applyTheme(currentTheme);
document.documentElement.style.setProperty("--font-size", `${currentFontSize}px`);
document.documentElement.classList.toggle("compact-mode", currentCompact);
return section;
}
function buildNotificationsTab(): HTMLDivElement {
const section = createElement("div", { class: "settings-pane active" });
const header = createElement("h1", {}, "Notifications");
section.appendChild(header);
const toggles: ReadonlyArray<{ key: string; label: string; desc: string; fallback: boolean }> = [
{ key: "desktopNotifications", label: "Desktop Notifications", desc: "Show desktop notifications for messages", fallback: true },
{ key: "flashTaskbar", label: "Flash Taskbar", desc: "Flash taskbar on new messages", fallback: true },
{ key: "suppressEveryone", label: "Suppress @everyone", desc: "Mute @everyone and @here mentions", fallback: false },
{ key: "notificationSounds", label: "Notification Sounds", desc: "Play sounds for notifications", fallback: true },
];
for (const item of toggles) {
const row = createElement("div", { class: "setting-row" });
const info = createElement("div", {});
const label = createElement("div", { class: "setting-label" }, item.label);
const desc = createElement("div", { class: "setting-desc" }, item.desc);
appendChildren(info, label, desc);
const isOn = loadPref<boolean>(item.key, item.fallback);
const toggle = createElement("div", { class: isOn ? "toggle on" : "toggle" });
toggle.addEventListener("click", () => {
const nowOn = !toggle.classList.contains("on");
toggle.classList.toggle("on", nowOn);
savePref(item.key, nowOn);
}, { signal: ac.signal });
appendChildren(row, info, toggle);
section.appendChild(row);
}
return section;
}
function buildKeybindsTab(): HTMLDivElement {
const section = createElement("div", { class: "settings-pane active" });
const header = createElement("h1", {}, "Keybinds");
section.appendChild(header);
const pttRow = createElement("div", { class: "keybind-row" });
const pttLabel = createElement("span", { class: "setting-label" }, "Push to Talk");
const pttValue = createElement("span", { class: "kbd" }, loadPref<string>("pttKey", "Not set"));
appendChildren(pttRow, pttLabel, pttValue);
section.appendChild(pttRow);
const searchRow = createElement("div", { class: "keybind-row" });
const searchLabel = createElement("span", { class: "setting-label" }, "Quick Switcher");
const searchValue = createElement("span", { class: "kbd" }, "Ctrl + K");
appendChildren(searchRow, searchLabel, searchValue);
section.appendChild(searchRow);
return section;
}
// ---- Voice & Audio tab ------------------------------------------------------
function buildVoiceAudioTab(): HTMLDivElement {
const section = createElement("div", { class: "settings-pane active" });
const header = createElement("h1", {}, "Voice & Audio");
section.appendChild(header);
// Input device selector
const inputHeader = createElement("h3", {}, "Input Device");
const inputSelect = createElement("select", {
class: "form-input",
style: "width:100%;margin-bottom:12px",
});
const defaultInputOpt = createElement("option", { value: "" }, "Default");
inputSelect.appendChild(defaultInputOpt);
section.appendChild(inputHeader);
section.appendChild(inputSelect);
// Output device selector
const outputHeader = createElement("h3", {}, "Output Device");
const outputSelect = createElement("select", {
class: "form-input",
style: "width:100%;margin-bottom:12px",
});
const defaultOutputOpt = createElement("option", { value: "" }, "Default");
outputSelect.appendChild(defaultOutputOpt);
section.appendChild(outputHeader);
section.appendChild(outputSelect);
// Populate devices asynchronously
void (async () => {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const savedInput = loadPref<string>("audioInputDevice", "");
const savedOutput = loadPref<string>("audioOutputDevice", "");
for (const d of devices) {
if (d.kind === "audioinput") {
const opt = createElement("option", { value: d.deviceId },
d.label || `Microphone (${d.deviceId.slice(0, 8)})`);
if (d.deviceId === savedInput) opt.setAttribute("selected", "");
inputSelect.appendChild(opt);
} else if (d.kind === "audiooutput") {
const opt = createElement("option", { value: d.deviceId },
d.label || `Speaker (${d.deviceId.slice(0, 8)})`);
if (d.deviceId === savedOutput) opt.setAttribute("selected", "");
outputSelect.appendChild(opt);
}
}
// Restore saved selections
if (savedInput) inputSelect.value = savedInput;
if (savedOutput) outputSelect.value = savedOutput;
} catch {
const errOpt = createElement("option", { value: "", disabled: "" },
"Could not enumerate devices");
inputSelect.appendChild(errOpt);
}
})();
inputSelect.addEventListener("change", () => {
savePref("audioInputDevice", inputSelect.value);
}, { signal: ac.signal });
outputSelect.addEventListener("change", () => {
savePref("audioOutputDevice", outputSelect.value);
}, { signal: ac.signal });
// Input sensitivity slider
const sensitivityHeader = createElement("h3", {}, "Input Sensitivity");
const sensitivityRow = createElement("div", { class: "slider-row" });
const savedSensitivity = loadPref<number>("voiceSensitivity", 50);
const sensitivitySlider = createElement("input", {
class: "settings-slider",
type: "range",
min: "0",
max: "100",
value: String(savedSensitivity),
});
const sensitivityLabel = createElement("span", { class: "slider-val" }, `${savedSensitivity}%`);
sensitivitySlider.addEventListener("input", () => {
const val = Number(sensitivitySlider.value);
setText(sensitivityLabel, `${val}%`);
savePref("voiceSensitivity", val);
}, { signal: ac.signal });
appendChildren(sensitivityRow, sensitivitySlider, sensitivityLabel);
appendChildren(section, sensitivityHeader, sensitivityRow);
// Audio processing toggles
const audioToggles: ReadonlyArray<{ key: string; label: string; desc: string; fallback: boolean }> = [
{ key: "echoCancellation", label: "Echo Cancellation", desc: "Reduce echo from speakers feeding back into microphone", fallback: true },
{ key: "noiseSuppression", label: "Noise Suppression", desc: "Filter out background noise from your microphone", fallback: true },
{ key: "autoGainControl", label: "Automatic Gain Control", desc: "Automatically adjust microphone volume", fallback: true },
];
for (const item of audioToggles) {
const row = createElement("div", { class: "setting-row" });
const info = createElement("div", {});
const label = createElement("div", { class: "setting-label" }, item.label);
const desc = createElement("div", { class: "setting-desc" }, item.desc);
appendChildren(info, label, desc);
const isOn = loadPref<boolean>(item.key, item.fallback);
const toggle = createElement("div", { class: isOn ? "toggle on" : "toggle" });
toggle.addEventListener("click", () => {
const nowOn = !toggle.classList.contains("on");
toggle.classList.toggle("on", nowOn);
savePref(item.key, nowOn);
}, { signal: ac.signal });
appendChildren(row, info, toggle);
section.appendChild(row);
}
return section;
}
// ---- Logs tab ---------------------------------------------------------------
let logListEl: HTMLDivElement | null = null;
let logFilterLevel: LogLevel | "all" = "all";
let unsubLogListener: (() => void) | null = null;
const LOG_LEVEL_COLORS: Record<LogLevel, string> = {
debug: "#888",
info: "#3ba55d",
warn: "#faa61a",
error: "#ed4245",
};
function formatLogEntry(entry: LogEntry): HTMLDivElement {
const row = createElement("div", {
class: "log-entry",
style: `border-left: 3px solid ${LOG_LEVEL_COLORS[entry.level]}; padding: 4px 8px; margin: 2px 0; font-family: monospace; font-size: 12px; line-height: 1.4;`,
});
const time = entry.timestamp.slice(11, 23); // HH:MM:SS.mmm
const level = entry.level.toUpperCase().padEnd(5);
const text = `${time} ${level} [${entry.component}] ${entry.message}`;
const textEl = createElement("span", {
style: `color: ${LOG_LEVEL_COLORS[entry.level]}`,
}, text);
row.appendChild(textEl);
if (entry.data !== undefined) {
const dataStr = typeof entry.data === "string" ? entry.data : JSON.stringify(entry.data, null, 2);
const dataEl = createElement("pre", {
style: "margin: 2px 0 0 0; color: #999; font-size: 11px; white-space: pre-wrap; word-break: break-all;",
}, dataStr);
row.appendChild(dataEl);
}
return row;
}
function renderLogEntries(): void {
if (logListEl === null) return;
clearChildren(logListEl);
const entries = getLogBuffer();
for (const entry of entries) {
if (logFilterLevel !== "all" && entry.level !== logFilterLevel) continue;
logListEl.appendChild(formatLogEntry(entry));
}
// Auto-scroll to bottom
logListEl.scrollTop = logListEl.scrollHeight;
}
function buildLogsTab(): HTMLDivElement {
const section = createElement("div", { class: "settings-pane active" });
const header = createElement("h1", {}, "Logs");
section.appendChild(header);
// Controls row
const controls = createElement("div", {
style: "display: flex; gap: 8px; margin-bottom: 8px; align-items: center;",
});
// Filter dropdown
const filterLabel = createElement("span", { class: "setting-label", style: "margin: 0;" }, "Filter:");
const filterSelect = createElement("select", {
style: "background: var(--bg-tertiary); color: var(--text-normal); border: 1px solid var(--bg-active); border-radius: 4px; padding: 4px 8px; font-size: 13px;",
});
const levels: Array<LogLevel | "all"> = ["all", "debug", "info", "warn", "error"];
for (const lvl of levels) {
const opt = createElement("option", { value: lvl }, lvl.toUpperCase());
if (lvl === logFilterLevel) opt.setAttribute("selected", "");
filterSelect.appendChild(opt);
}
filterSelect.addEventListener("change", () => {
logFilterLevel = filterSelect.value as LogLevel | "all";
renderLogEntries();
}, { signal: ac.signal });
// Log level selector
const levelLabel = createElement("span", { class: "setting-label", style: "margin: 0 0 0 16px;" }, "Min Level:");
const levelSelect = createElement("select", {
style: "background: var(--bg-tertiary); color: var(--text-normal); border: 1px solid var(--bg-active); border-radius: 4px; padding: 4px 8px; font-size: 13px;",
});
const minLevels: LogLevel[] = ["debug", "info", "warn", "error"];
for (const lvl of minLevels) {
const opt = createElement("option", { value: lvl }, lvl.toUpperCase());
levelSelect.appendChild(opt);
}
levelSelect.addEventListener("change", () => {
setLogLevel(levelSelect.value as LogLevel);
}, { signal: ac.signal });
// Clear button
const clearBtn = createElement("button", {
class: "ac-btn",
style: "margin-left: auto;",
}, "Clear Logs");
clearBtn.addEventListener("click", () => {
clearLogBuffer();
renderLogEntries();
}, { signal: ac.signal });
// Refresh button
const refreshBtn = createElement("button", { class: "ac-btn" }, "Refresh");
refreshBtn.addEventListener("click", () => renderLogEntries(), { signal: ac.signal });
appendChildren(controls, filterLabel, filterSelect, levelLabel, levelSelect, clearBtn, refreshBtn);
section.appendChild(controls);
// Log count
const countEl = createElement("div", {
style: "font-size: 12px; color: #888; margin-bottom: 4px;",
}, `${getLogBuffer().length} entries`);
section.appendChild(countEl);
// Log list (scrollable)
logListEl = createElement("div", {
class: "log-viewer",
style: "max-height: 60vh; overflow-y: auto; background: var(--bg-tertiary); border-radius: 8px; padding: 8px;",
});
section.appendChild(logListEl);
renderLogEntries();
// Live update: subscribe to new log entries
unsubLogListener?.();
unsubLogListener = addLogListener(() => {
if (activeTab === "Logs") {
renderLogEntries();
countEl.textContent = `${getLogBuffer().length} entries`;
}
});
return section;
}
const TAB_BUILDERS: Readonly<Record<TabName, () => HTMLDivElement>> = {
Account: buildAccountTab,
Appearance: buildAppearanceTab,
Notifications: buildNotificationsTab,
"Voice & Audio": buildVoiceAudioTab,
Keybinds: buildKeybindsTab,
Logs: buildLogsTab,
Account: () => buildAccountTab(options, ac.signal),
Appearance: () => buildAppearanceTab(ac.signal),
Notifications: () => buildNotificationsTab(ac.signal),
"Voice & Audio": () => buildVoiceAudioTab(ac.signal),
Keybinds: () => buildKeybindsTab(),
Logs: () => logsTab.build(),
};
// ---- Core methods ---------------------------------------------------------
@@ -663,9 +172,7 @@ export function createSettingsOverlay(
unsubUi();
unsubUi = null;
}
unsubLogListener?.();
unsubLogListener = null;
logListEl = null;
logsTab.cleanup();
tabButtons.clear();
if (root !== null) {
root.remove();
@@ -0,0 +1,99 @@
/**
* Account settings tab — profile editing, password change, logout.
*/
import { createElement, appendChildren, setText } from "@lib/dom";
import { authStore } from "@stores/auth.store";
import type { SettingsOverlayOptions } from "../SettingsOverlay";
export function buildAccountTab(
options: SettingsOverlayOptions,
signal: AbortSignal,
): HTMLDivElement {
const section = createElement("div", { class: "settings-pane active" });
const user = authStore.getState().user;
// Account card
const accountCard = createElement("div", { class: "account-card" });
const acAvatar = createElement("div", {
class: "ac-avatar",
style: "background: var(--accent)",
}, (user?.username ?? "U").charAt(0).toUpperCase());
const acInfo = createElement("div", {});
const acName = createElement("div", { class: "ac-name" }, user?.username ?? "Unknown");
const acId = createElement("div", { class: "ac-id" }, `ID: ${user?.id ?? "?"}`);
appendChildren(acInfo, acName, acId);
const editBtn = createElement("button", { class: "ac-btn" }, "Edit Profile");
appendChildren(accountCard, acAvatar, acInfo, editBtn);
section.appendChild(accountCard);
const editForm = createElement("div", { class: "setting-row", style: "display:none" });
const editInput = createElement("input", { class: "form-input", type: "text", placeholder: "New username" });
const saveBtn = createElement("button", { class: "ac-btn" }, "Save");
const cancelBtn = createElement("button", { class: "ac-btn", style: "background:var(--bg-active)" }, "Cancel");
const usernameValue = acName;
appendChildren(editForm, editInput, saveBtn, cancelBtn);
editBtn.addEventListener("click", () => {
editForm.style.display = "flex";
editInput.value = user?.username ?? "";
editInput.focus();
}, { signal });
cancelBtn.addEventListener("click", () => {
editForm.style.display = "none";
}, { signal });
saveBtn.addEventListener("click", () => {
const newName = editInput.value.trim();
if (newName.length > 0) {
void options.onUpdateProfile(newName).then(() => {
setText(usernameValue, newName);
editForm.style.display = "none";
});
}
}, { signal });
section.appendChild(editForm);
// Change password
const pwHeader = createElement("h3", {}, "Change Password");
const oldPw = createElement("input", { class: "form-input", type: "password", placeholder: "Old password", style: "margin-bottom:8px" });
const newPw = createElement("input", { class: "form-input", type: "password", placeholder: "New password", style: "margin-bottom:8px" });
const confirmPw = createElement("input", { class: "form-input", type: "password", placeholder: "Confirm new password", style: "margin-bottom:8px" });
const pwError = createElement("div", { style: "color:var(--red);font-size:13px;margin-bottom:8px" });
const pwBtn = createElement("button", { class: "ac-btn" }, "Change Password");
pwBtn.addEventListener("click", () => {
const oldVal = oldPw.value;
const newVal = newPw.value;
const confirmVal = confirmPw.value;
if (newVal.length < 8) {
setText(pwError, "New password must be at least 8 characters.");
return;
}
if (newVal !== confirmVal) {
setText(pwError, "Passwords do not match.");
return;
}
setText(pwError, "");
void options.onChangePassword(oldVal, newVal).then(() => {
oldPw.value = "";
newPw.value = "";
confirmPw.value = "";
});
}, { signal });
appendChildren(section, pwHeader, oldPw, newPw, confirmPw, pwError, pwBtn);
// Logout
const logoutBtn = createElement("button", {
class: "settings-nav-item danger",
style: "margin-top:16px;width:auto;padding:8px 16px",
}, "Log Out");
logoutBtn.addEventListener("click", () => options.onLogout(), { signal });
section.appendChild(logoutBtn);
return section;
}
@@ -0,0 +1,76 @@
/**
* Appearance settings tab — theme, font size, compact mode.
*/
import { createElement, appendChildren, setText } from "@lib/dom";
import { loadPref, savePref, applyTheme, THEMES } from "./helpers";
import type { ThemeName } from "./helpers";
export function buildAppearanceTab(signal: AbortSignal): HTMLDivElement {
const section = createElement("div", { class: "settings-pane active" });
const currentTheme = loadPref<ThemeName>("theme", "dark");
const currentFontSize = loadPref<number>("fontSize", 16);
const currentCompact = loadPref<boolean>("compactMode", false);
// Theme selector
const themeHeader = createElement("h3", {}, "Theme");
const themeRow = createElement("div", { class: "theme-options" });
for (const name of Object.keys(THEMES) as ThemeName[]) {
const btn = createElement("div", {
class: `theme-opt ${name}${name === currentTheme ? " active" : ""}`,
}, name.charAt(0).toUpperCase() + name.slice(1));
btn.addEventListener("click", () => {
applyTheme(name);
savePref("theme", name);
const prev = themeRow.querySelector(".theme-opt.active");
if (prev) prev.classList.remove("active");
btn.classList.add("active");
}, { signal });
themeRow.appendChild(btn);
}
appendChildren(section, themeHeader, themeRow);
// Font size slider
const fontHeader = createElement("h3", {}, "Font Size");
const fontRow = createElement("div", { class: "slider-row" });
const fontSlider = createElement("input", {
class: "settings-slider",
type: "range",
min: "12",
max: "20",
value: String(currentFontSize),
});
const fontLabel = createElement("span", { class: "slider-val" }, `${currentFontSize}px`);
fontSlider.addEventListener("input", () => {
const size = Number(fontSlider.value);
setText(fontLabel, `${size}px`);
document.documentElement.style.setProperty("--font-size", `${size}px`);
savePref("fontSize", size);
}, { signal });
appendChildren(fontRow, fontSlider, fontLabel);
appendChildren(section, fontHeader, fontRow);
// Compact mode toggle
const compactRow = createElement("div", { class: "setting-row" });
const compactLabel = createElement("span", { class: "setting-label" }, "Compact Mode");
const compactToggle = createElement("div", {
class: currentCompact ? "toggle on" : "toggle",
});
compactToggle.addEventListener("click", () => {
const isNowCompact = !compactToggle.classList.contains("on");
compactToggle.classList.toggle("on", isNowCompact);
savePref("compactMode", isNowCompact);
document.documentElement.classList.toggle("compact-mode", isNowCompact);
}, { signal });
appendChildren(compactRow, compactLabel, compactToggle);
section.appendChild(compactRow);
// Apply stored preferences on render
applyTheme(currentTheme);
document.documentElement.style.setProperty("--font-size", `${currentFontSize}px`);
document.documentElement.classList.toggle("compact-mode", currentCompact);
return section;
}
@@ -0,0 +1,26 @@
/**
* Keybinds settings tab — push-to-talk and quick switcher bindings.
*/
import { createElement, appendChildren } from "@lib/dom";
import { loadPref } from "./helpers";
export function buildKeybindsTab(): HTMLDivElement {
const section = createElement("div", { class: "settings-pane active" });
const header = createElement("h1", {}, "Keybinds");
section.appendChild(header);
const pttRow = createElement("div", { class: "keybind-row" });
const pttLabel = createElement("span", { class: "setting-label" }, "Push to Talk");
const pttValue = createElement("span", { class: "kbd" }, loadPref<string>("pttKey", "Not set"));
appendChildren(pttRow, pttLabel, pttValue);
section.appendChild(pttRow);
const searchRow = createElement("div", { class: "keybind-row" });
const searchLabel = createElement("span", { class: "setting-label" }, "Quick Switcher");
const searchValue = createElement("span", { class: "kbd" }, "Ctrl + K");
appendChildren(searchRow, searchLabel, searchValue);
section.appendChild(searchRow);
return section;
}
@@ -0,0 +1,171 @@
/**
* Logs settings tab — log viewer with filtering, level control, live updates.
*/
import { createElement, appendChildren, clearChildren } from "@lib/dom";
import { getLogBuffer, clearLogBuffer, addLogListener, setLogLevel } from "@lib/logger";
import type { LogEntry, LogLevel } from "@lib/logger";
import type { TabName } from "../SettingsOverlay";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const LOG_LEVEL_COLORS: Record<LogLevel, string> = {
debug: "#888",
info: "#3ba55d",
warn: "#faa61a",
error: "#ed4245",
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatLogEntry(entry: LogEntry): HTMLDivElement {
const row = createElement("div", {
class: "log-entry",
style: `border-left: 3px solid ${LOG_LEVEL_COLORS[entry.level]}; padding: 4px 8px; margin: 2px 0; font-family: monospace; font-size: 12px; line-height: 1.4;`,
});
const time = entry.timestamp.slice(11, 23); // HH:MM:SS.mmm
const level = entry.level.toUpperCase().padEnd(5);
const text = `${time} ${level} [${entry.component}] ${entry.message}`;
const textEl = createElement("span", {
style: `color: ${LOG_LEVEL_COLORS[entry.level]}`,
}, text);
row.appendChild(textEl);
if (entry.data !== undefined) {
const dataStr = typeof entry.data === "string" ? entry.data : JSON.stringify(entry.data, null, 2);
const dataEl = createElement("pre", {
style: "margin: 2px 0 0 0; color: #999; font-size: 11px; white-space: pre-wrap; word-break: break-all;",
}, dataStr);
row.appendChild(dataEl);
}
return row;
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export interface LogsTabHandle {
build(): HTMLDivElement;
cleanup(): void;
}
export function createLogsTab(
getActiveTab: () => TabName,
signal: AbortSignal,
): LogsTabHandle {
let logListEl: HTMLDivElement | null = null;
let logFilterLevel: LogLevel | "all" = "all";
let unsubLogListener: (() => void) | null = null;
function renderLogEntries(): void {
if (logListEl === null) return;
clearChildren(logListEl);
const entries = getLogBuffer();
for (const entry of entries) {
if (logFilterLevel !== "all" && entry.level !== logFilterLevel) continue;
logListEl.appendChild(formatLogEntry(entry));
}
// Auto-scroll to bottom
logListEl.scrollTop = logListEl.scrollHeight;
}
function build(): HTMLDivElement {
const section = createElement("div", { class: "settings-pane active" });
const header = createElement("h1", {}, "Logs");
section.appendChild(header);
// Controls row
const controls = createElement("div", {
style: "display: flex; gap: 8px; margin-bottom: 8px; align-items: center;",
});
// Filter dropdown
const filterLabel = createElement("span", { class: "setting-label", style: "margin: 0;" }, "Filter:");
const filterSelect = createElement("select", {
style: "background: var(--bg-tertiary); color: var(--text-normal); border: 1px solid var(--bg-active); border-radius: 4px; padding: 4px 8px; font-size: 13px;",
});
const levels: Array<LogLevel | "all"> = ["all", "debug", "info", "warn", "error"];
for (const lvl of levels) {
const opt = createElement("option", { value: lvl }, lvl.toUpperCase());
if (lvl === logFilterLevel) opt.setAttribute("selected", "");
filterSelect.appendChild(opt);
}
filterSelect.addEventListener("change", () => {
logFilterLevel = filterSelect.value as LogLevel | "all";
renderLogEntries();
}, { signal });
// Log level selector
const levelLabel = createElement("span", { class: "setting-label", style: "margin: 0 0 0 16px;" }, "Min Level:");
const levelSelect = createElement("select", {
style: "background: var(--bg-tertiary); color: var(--text-normal); border: 1px solid var(--bg-active); border-radius: 4px; padding: 4px 8px; font-size: 13px;",
});
const minLevels: LogLevel[] = ["debug", "info", "warn", "error"];
for (const lvl of minLevels) {
const opt = createElement("option", { value: lvl }, lvl.toUpperCase());
levelSelect.appendChild(opt);
}
levelSelect.addEventListener("change", () => {
setLogLevel(levelSelect.value as LogLevel);
}, { signal });
// Clear button
const clearBtn = createElement("button", {
class: "ac-btn",
style: "margin-left: auto;",
}, "Clear Logs");
clearBtn.addEventListener("click", () => {
clearLogBuffer();
renderLogEntries();
}, { signal });
// Refresh button
const refreshBtn = createElement("button", { class: "ac-btn" }, "Refresh");
refreshBtn.addEventListener("click", () => renderLogEntries(), { signal });
appendChildren(controls, filterLabel, filterSelect, levelLabel, levelSelect, clearBtn, refreshBtn);
section.appendChild(controls);
// Log count
const countEl = createElement("div", {
style: "font-size: 12px; color: #888; margin-bottom: 4px;",
}, `${getLogBuffer().length} entries`);
section.appendChild(countEl);
// Log list (scrollable)
logListEl = createElement("div", {
class: "log-viewer",
style: "max-height: 60vh; overflow-y: auto; background: var(--bg-tertiary); border-radius: 8px; padding: 8px;",
});
section.appendChild(logListEl);
renderLogEntries();
// Live update: subscribe to new log entries
unsubLogListener?.();
unsubLogListener = addLogListener(() => {
if (getActiveTab() === "Logs") {
renderLogEntries();
countEl.textContent = `${getLogBuffer().length} entries`;
}
});
return section;
}
function cleanup(): void {
unsubLogListener?.();
unsubLogListener = null;
logListEl = null;
}
return { build, cleanup };
}
@@ -0,0 +1,40 @@
/**
* Notifications settings tab — desktop notifications, taskbar flash, sounds.
*/
import { createElement, appendChildren } from "@lib/dom";
import { loadPref, savePref } from "./helpers";
export function buildNotificationsTab(signal: AbortSignal): HTMLDivElement {
const section = createElement("div", { class: "settings-pane active" });
const header = createElement("h1", {}, "Notifications");
section.appendChild(header);
const toggles: ReadonlyArray<{ key: string; label: string; desc: string; fallback: boolean }> = [
{ key: "desktopNotifications", label: "Desktop Notifications", desc: "Show desktop notifications for messages", fallback: true },
{ key: "flashTaskbar", label: "Flash Taskbar", desc: "Flash taskbar on new messages", fallback: true },
{ key: "suppressEveryone", label: "Suppress @everyone", desc: "Mute @everyone and @here mentions", fallback: false },
{ key: "notificationSounds", label: "Notification Sounds", desc: "Play sounds for notifications", fallback: true },
];
for (const item of toggles) {
const row = createElement("div", { class: "setting-row" });
const info = createElement("div", {});
const label = createElement("div", { class: "setting-label" }, item.label);
const desc = createElement("div", { class: "setting-desc" }, item.desc);
appendChildren(info, label, desc);
const isOn = loadPref<boolean>(item.key, item.fallback);
const toggle = createElement("div", { class: isOn ? "toggle on" : "toggle" });
toggle.addEventListener("click", () => {
const nowOn = !toggle.classList.contains("on");
toggle.classList.toggle("on", nowOn);
savePref(item.key, nowOn);
}, { signal });
appendChildren(row, info, toggle);
section.appendChild(row);
}
return section;
}
@@ -0,0 +1,121 @@
/**
* Voice & Audio settings tab — input/output device, sensitivity, audio processing.
*/
import { createElement, appendChildren, setText } from "@lib/dom";
import { loadPref, savePref } from "./helpers";
export function buildVoiceAudioTab(signal: AbortSignal): HTMLDivElement {
const section = createElement("div", { class: "settings-pane active" });
const header = createElement("h1", {}, "Voice & Audio");
section.appendChild(header);
// Input device selector
const inputHeader = createElement("h3", {}, "Input Device");
const inputSelect = createElement("select", {
class: "form-input",
style: "width:100%;margin-bottom:12px",
});
const defaultInputOpt = createElement("option", { value: "" }, "Default");
inputSelect.appendChild(defaultInputOpt);
section.appendChild(inputHeader);
section.appendChild(inputSelect);
// Output device selector
const outputHeader = createElement("h3", {}, "Output Device");
const outputSelect = createElement("select", {
class: "form-input",
style: "width:100%;margin-bottom:12px",
});
const defaultOutputOpt = createElement("option", { value: "" }, "Default");
outputSelect.appendChild(defaultOutputOpt);
section.appendChild(outputHeader);
section.appendChild(outputSelect);
// Populate devices asynchronously
void (async () => {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const savedInput = loadPref<string>("audioInputDevice", "");
const savedOutput = loadPref<string>("audioOutputDevice", "");
for (const d of devices) {
if (d.kind === "audioinput") {
const opt = createElement("option", { value: d.deviceId },
d.label || `Microphone (${d.deviceId.slice(0, 8)})`);
if (d.deviceId === savedInput) opt.setAttribute("selected", "");
inputSelect.appendChild(opt);
} else if (d.kind === "audiooutput") {
const opt = createElement("option", { value: d.deviceId },
d.label || `Speaker (${d.deviceId.slice(0, 8)})`);
if (d.deviceId === savedOutput) opt.setAttribute("selected", "");
outputSelect.appendChild(opt);
}
}
// Restore saved selections
if (savedInput) inputSelect.value = savedInput;
if (savedOutput) outputSelect.value = savedOutput;
} catch {
const errOpt = createElement("option", { value: "", disabled: "" },
"Could not enumerate devices");
inputSelect.appendChild(errOpt);
}
})();
inputSelect.addEventListener("change", () => {
savePref("audioInputDevice", inputSelect.value);
}, { signal });
outputSelect.addEventListener("change", () => {
savePref("audioOutputDevice", outputSelect.value);
}, { signal });
// Input sensitivity slider
const sensitivityHeader = createElement("h3", {}, "Input Sensitivity");
const sensitivityRow = createElement("div", { class: "slider-row" });
const savedSensitivity = loadPref<number>("voiceSensitivity", 50);
const sensitivitySlider = createElement("input", {
class: "settings-slider",
type: "range",
min: "0",
max: "100",
value: String(savedSensitivity),
});
const sensitivityLabel = createElement("span", { class: "slider-val" }, `${savedSensitivity}%`);
sensitivitySlider.addEventListener("input", () => {
const val = Number(sensitivitySlider.value);
setText(sensitivityLabel, `${val}%`);
savePref("voiceSensitivity", val);
}, { signal });
appendChildren(sensitivityRow, sensitivitySlider, sensitivityLabel);
appendChildren(section, sensitivityHeader, sensitivityRow);
// Audio processing toggles
const audioToggles: ReadonlyArray<{ key: string; label: string; desc: string; fallback: boolean }> = [
{ key: "echoCancellation", label: "Echo Cancellation", desc: "Reduce echo from speakers feeding back into microphone", fallback: true },
{ key: "noiseSuppression", label: "Noise Suppression", desc: "Filter out background noise from your microphone", fallback: true },
{ key: "autoGainControl", label: "Automatic Gain Control", desc: "Automatically adjust microphone volume", fallback: true },
];
for (const item of audioToggles) {
const row = createElement("div", { class: "setting-row" });
const info = createElement("div", {});
const label = createElement("div", { class: "setting-label" }, item.label);
const desc = createElement("div", { class: "setting-desc" }, item.desc);
appendChildren(info, label, desc);
const isOn = loadPref<boolean>(item.key, item.fallback);
const toggle = createElement("div", { class: isOn ? "toggle on" : "toggle" });
toggle.addEventListener("click", () => {
const nowOn = !toggle.classList.contains("on");
toggle.classList.toggle("on", nowOn);
savePref(item.key, nowOn);
}, { signal });
appendChildren(row, info, toggle);
section.appendChild(row);
}
return section;
}
@@ -0,0 +1,46 @@
/**
* Shared helpers and constants for settings tabs.
*/
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
export const STORAGE_PREFIX = "owncord:settings:";
export const THEMES = {
dark: { "--bg-primary": "#313338", "--bg-secondary": "#2b2d31", "--bg-tertiary": "#1e1f22", "--text-normal": "#dbdee1" },
midnight: { "--bg-primary": "#1a1a2e", "--bg-secondary": "#16213e", "--bg-tertiary": "#0f3460", "--text-normal": "#e0e0e0" },
light: { "--bg-primary": "#ffffff", "--bg-secondary": "#f2f3f5", "--bg-tertiary": "#e3e5e8", "--text-normal": "#313338" },
} as const;
export type ThemeName = keyof typeof THEMES;
// ---------------------------------------------------------------------------
// Preference helpers
// ---------------------------------------------------------------------------
export function loadPref<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(STORAGE_PREFIX + key);
return raw !== null ? (JSON.parse(raw) as T) : fallback;
} catch {
return fallback;
}
}
export function savePref(key: string, value: unknown): void {
localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(value));
}
// ---------------------------------------------------------------------------
// Theme application
// ---------------------------------------------------------------------------
export function applyTheme(name: ThemeName): void {
const vars = THEMES[name];
const root = document.documentElement;
for (const [prop, val] of Object.entries(vars)) {
root.style.setProperty(prop, val);
}
}
+28 -5
View File
@@ -8,22 +8,30 @@ export interface Store<T> {
/** Returns the current state (immutable reference). */
getState(): T;
/** Update state via an updater function. Listeners are called synchronously. */
/**
* Update state via an updater function. Subscriber notifications are
* batched via queueMicrotask — multiple rapid setState calls result
* in a single notification with the final state.
*/
setState(updater: (prev: T) => T): void;
/**
* Subscribe to state changes. The listener receives the new state
* after every setState call. Returns an unsubscribe function.
* after every setState batch. Returns an unsubscribe function.
*/
subscribe(listener: (state: T) => void): () => void;
/** Derive a value from the current state using a selector function. */
select<S>(selector: (state: T) => S): S;
/** Flush pending notifications synchronously (useful in tests). */
flush(): void;
}
export function createStore<T>(initialState: T): Store<T> {
let state: T = initialState;
const listeners: Set<(state: T) => void> = new Set();
let notifyScheduled = false;
function getState(): T {
return state;
@@ -31,8 +39,14 @@ export function createStore<T>(initialState: T): Store<T> {
function setState(updater: (prev: T) => T): void {
state = updater(state);
for (const listener of listeners) {
listener(state);
if (!notifyScheduled) {
notifyScheduled = true;
queueMicrotask(() => {
notifyScheduled = false;
for (const listener of listeners) {
listener(state);
}
});
}
}
@@ -47,5 +61,14 @@ export function createStore<T>(initialState: T): Store<T> {
return selector(state);
}
return { getState, setState, subscribe, select };
function flush(): void {
if (notifyScheduled) {
notifyScheduled = false;
for (const listener of listeners) {
listener(state);
}
}
}
return { getState, setState, subscribe, select, flush };
}
@@ -153,6 +153,7 @@ describe("auth store", () => {
const unsub = authStore.subscribe(listener);
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
authStore.flush();
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith(
@@ -175,6 +176,7 @@ describe("auth store", () => {
const unsub = authStore.subscribe(listener);
clearAuth();
authStore.flush();
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith(
@@ -206,6 +208,7 @@ describe("auth store", () => {
const unsubB = authStore.subscribe(listenerB);
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
authStore.flush();
expect(listenerA).toHaveBeenCalledTimes(1);
expect(listenerB).toHaveBeenCalledTimes(1);
@@ -153,6 +153,7 @@ describe("ChannelSidebar", () => {
) as HTMLElement;
expect(textHeader).not.toBeUndefined();
textHeader.click();
uiStore.flush();
// After collapse, "Text Channels" channels should be hidden
// The sidebar re-renders on uiStore change, so channels under
@@ -166,6 +167,7 @@ describe("ChannelSidebar", () => {
(h) => h.querySelector(".category-name")?.textContent === "Text Channels",
) as HTMLElement;
textHeaderAfter.click();
uiStore.flush();
const itemsExpanded = container.querySelectorAll(".channel-item");
expect(itemsExpanded.length).toBe(4);
@@ -353,6 +353,7 @@ describe("MessageList", () => {
attachments: [],
timestamp: "2026-03-15T10:00:00Z",
});
messagesStore.flush();
expect(container.querySelectorAll(".message").length).toBe(1);
list.destroy?.();
@@ -428,6 +429,7 @@ describe("TypingIndicator", () => {
indicator.mount(container);
setTyping(1, 1); // Alice typing in channel 1
membersStore.flush();
const root = container.querySelector(".typing-bar");
expect(root?.textContent).toContain("Alice");
@@ -441,6 +443,7 @@ describe("TypingIndicator", () => {
setTyping(1, 1); // Alice
setTyping(1, 2); // Bob
membersStore.flush();
const root = container.querySelector(".typing-bar");
expect(root?.textContent).toContain("and");
@@ -455,6 +458,7 @@ describe("TypingIndicator", () => {
setTyping(1, 1);
setTyping(1, 2);
setTyping(1, 3);
membersStore.flush();
const root = container.querySelector(".typing-bar");
expect(root?.textContent).toContain("Several people are typing...");
@@ -297,6 +297,7 @@ describe("members store", () => {
const listener = vi.fn();
const unsub = membersStore.subscribe(listener);
setMembers([MEMBER_ALICE]);
membersStore.flush();
expect(listener).toHaveBeenCalledTimes(1);
unsub();
});
@@ -515,6 +515,7 @@ describe("ProfileManager", () => {
m.store.subscribe((s) => states.push(s));
m.addProfile(sampleData);
m.store.flush();
expect(states).toHaveLength(1);
expect(states[0]!.profiles).toHaveLength(1);
@@ -528,6 +529,7 @@ describe("ProfileManager", () => {
m.store.subscribe((s) => states.push(s));
m.removeProfile(profile.id);
m.store.flush();
expect(states).toHaveLength(1);
expect(states[0]!.profiles).toHaveLength(0);
@@ -32,6 +32,7 @@ describe('createStore', () => {
store.subscribe(listener2);
store.setState((prev) => ({ ...prev, count: 5 }));
store.flush();
expect(listener1).toHaveBeenCalledTimes(1);
expect(listener1).toHaveBeenCalledWith({ count: 5, name: 'test' });
@@ -45,11 +46,13 @@ describe('createStore', () => {
const unsubscribe = store.subscribe(listener);
store.setState((prev) => ({ ...prev, count: 1 }));
store.flush();
expect(listener).toHaveBeenCalledTimes(1);
unsubscribe();
store.setState((prev) => ({ ...prev, count: 2 }));
store.flush();
expect(listener).toHaveBeenCalledTimes(1);
});
@@ -61,6 +64,7 @@ describe('createStore', () => {
store.subscribe(() => calls.push(3));
store.setState((prev) => ({ ...prev, count: 10 }));
store.flush();
expect(calls).toEqual([1, 2, 3]);
});
@@ -75,6 +79,7 @@ describe('createStore', () => {
unsub();
store.setState((prev) => ({ ...prev, count: 99 }));
store.flush();
expect(kept).toHaveBeenCalledTimes(1);
expect(removed).not.toHaveBeenCalled();
@@ -109,7 +114,9 @@ describe('createStore', () => {
store.subscribe((s) => received.push(s));
store.setState((prev) => ({ ...prev, count: 7 }));
store.flush();
store.setState((prev) => ({ ...prev, name: 'updated' }));
store.flush();
expect(received).toEqual([
{ count: 7, name: 'test' },
@@ -177,6 +177,7 @@ describe("ui store", () => {
const listener = vi.fn();
const unsub = uiStore.subscribe(listener);
toggleSidebar();
uiStore.flush();
expect(listener).toHaveBeenCalledTimes(1);
unsub();
});
@@ -250,6 +250,7 @@ describe("voice store", () => {
const listener = vi.fn();
const unsub = voiceStore.subscribe(listener);
joinVoiceChannel(42);
voiceStore.flush();
expect(listener).toHaveBeenCalledTimes(1);
unsub();
});
+67 -435
View File
@@ -3,14 +3,8 @@ package admin
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/owncord/server/auth"
@@ -53,6 +47,65 @@ type HubBroadcaster interface {
BroadcastChannelCreate(ch *db.Channel)
BroadcastChannelUpdate(ch *db.Channel)
BroadcastChannelDelete(channelID int64)
BroadcastMemberBan(userID int64)
BroadcastMemberUpdate(userID int64, roleName string)
}
// ─── adminUserResponse ──────────────────────────────────────────────────────
// adminUserResponse is the safe public shape returned by user-listing and
// user-patch endpoints. It deliberately excludes PasswordHash and TOTPSecret.
type adminUserResponse struct {
ID int64 `json:"id"`
Username string `json:"username"`
Avatar *string `json:"avatar,omitempty"`
RoleID int64 `json:"role_id"`
RoleName string `json:"role_name"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
LastSeen *string `json:"last_seen,omitempty"`
Banned bool `json:"banned"`
BanReason *string `json:"ban_reason,omitempty"`
BanExpires *string `json:"ban_expires,omitempty"`
}
// toAdminUserResponse converts a db.UserWithRole to the safe response shape.
func toAdminUserResponse(u db.UserWithRole) adminUserResponse {
return adminUserResponse{
ID: u.ID,
Username: u.Username,
Avatar: u.Avatar,
RoleID: u.RoleID,
RoleName: u.RoleName,
Status: u.Status,
CreatedAt: u.CreatedAt,
LastSeen: u.LastSeen,
Banned: u.Banned,
BanReason: u.BanReason,
BanExpires: u.BanExpires,
}
}
// toAdminUserResponseFromUser converts a plain db.User to the safe response
// shape, resolving the role name via the database.
func toAdminUserResponseFromUser(database *db.DB, u *db.User) adminUserResponse {
roleName := ""
if role, err := database.GetRoleByID(u.RoleID); err == nil && role != nil {
roleName = role.Name
}
return adminUserResponse{
ID: u.ID,
Username: u.Username,
Avatar: u.Avatar,
RoleID: u.RoleID,
RoleName: roleName,
Status: u.Status,
CreatedAt: u.CreatedAt,
LastSeen: u.LastSeen,
Banned: u.Banned,
BanReason: u.BanReason,
BanExpires: u.BanExpires,
}
}
// ─── NewAdminAPI ──────────────────────────────────────────────────────────────
@@ -73,7 +126,7 @@ func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater
r.Get("/stats", handleGetStats(database))
r.Get("/users", handleListUsers(database))
r.Patch("/users/{id}", handlePatchUser(database))
r.Patch("/users/{id}", handlePatchUser(database, hub))
r.Delete("/users/{id}/sessions", handleForceLogout(database))
r.Get("/channels", handleListChannels(database))
r.Post("/channels", handleCreateChannel(database, hub))
@@ -85,6 +138,13 @@ func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater
r.Post("/backup", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ownerOnlyMiddleware(database, handleBackup(database)).ServeHTTP(w, req)
}))
r.Get("/backups", handleListBackups())
r.Delete("/backups/{name}", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ownerOnlyMiddleware(database, handleDeleteBackup(database)).ServeHTTP(w, req)
}))
r.Post("/backups/{name}/restore", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ownerOnlyMiddleware(database, handleRestoreBackup(database)).ServeHTTP(w, req)
}))
r.Get("/updates", handleCheckUpdate(u))
r.Post("/updates/apply", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ownerOnlyMiddleware(database, handleApplyUpdate(u, hub, version)).ServeHTTP(w, req)
@@ -170,434 +230,6 @@ func ownerOnlyMiddleware(database *db.DB, next http.Handler) http.Handler {
})
}
// ─── Handlers ─────────────────────────────────────────────────────────────────
func handleGetStats(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
stats, err := database.GetServerStats()
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to get stats")
return
}
writeJSON(w, http.StatusOK, stats)
}
}
func handleListUsers(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
limit := queryInt(r, "limit", 50)
offset := queryInt(r, "offset", 0)
users, err := database.ListAllUsers(limit, offset)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to list users")
return
}
safe := make([]adminUserResponse, len(users))
for i, u := range users {
safe[i] = toAdminUserResponse(u)
}
writeJSON(w, http.StatusOK, safe)
}
}
// adminUserResponse is the safe public shape returned by user-listing and
// user-patch endpoints. It deliberately excludes PasswordHash and TOTPSecret.
type adminUserResponse struct {
ID int64 `json:"id"`
Username string `json:"username"`
Avatar *string `json:"avatar,omitempty"`
RoleID int64 `json:"role_id"`
RoleName string `json:"role_name"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
LastSeen *string `json:"last_seen,omitempty"`
Banned bool `json:"banned"`
BanReason *string `json:"ban_reason,omitempty"`
BanExpires *string `json:"ban_expires,omitempty"`
}
// toAdminUserResponse converts a db.UserWithRole to the safe response shape.
func toAdminUserResponse(u db.UserWithRole) adminUserResponse {
return adminUserResponse{
ID: u.ID,
Username: u.Username,
Avatar: u.Avatar,
RoleID: u.RoleID,
RoleName: u.RoleName,
Status: u.Status,
CreatedAt: u.CreatedAt,
LastSeen: u.LastSeen,
Banned: u.Banned,
BanReason: u.BanReason,
BanExpires: u.BanExpires,
}
}
// toAdminUserResponseFromUser converts a plain db.User to the safe response
// shape, leaving RoleName empty (it is unknown without a join).
func toAdminUserResponseFromUser(u *db.User) adminUserResponse {
return adminUserResponse{
ID: u.ID,
Username: u.Username,
Avatar: u.Avatar,
RoleID: u.RoleID,
Status: u.Status,
CreatedAt: u.CreatedAt,
LastSeen: u.LastSeen,
Banned: u.Banned,
BanReason: u.BanReason,
BanExpires: u.BanExpires,
}
}
// patchUserRequest is the JSON body for PATCH /admin/api/users/{id}.
type patchUserRequest struct {
RoleID *int64 `json:"role_id"`
Banned *bool `json:"banned"`
BanReason *string `json:"ban_reason"`
}
func handlePatchUser(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := pathInt64(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid user id")
return
}
var req patchUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body")
return
}
user, err := database.GetUserByID(id)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch user")
return
}
if user == nil {
writeErr(w, http.StatusNotFound, "NOT_FOUND", "user not found")
return
}
actor := actorFromContext(r)
// Prevent admins from modifying their own role or ban status, which
// could lock them out of the admin panel with no recovery path.
if id == actor {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "cannot modify your own account via admin panel")
return
}
if req.RoleID != nil {
if err := database.UpdateUserRole(id, *req.RoleID); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update role")
return
}
slog.Info("role changed", "actor_id", actor, "target_user", user.Username, "new_role_id", *req.RoleID)
_ = database.LogAudit(actor, "role_change", "user", id,
fmt.Sprintf("changed %s role to %d", user.Username, *req.RoleID))
}
if req.Banned != nil {
reason := ""
if req.BanReason != nil {
reason = *req.BanReason
}
if *req.Banned {
if err := database.BanUser(id, reason, nil); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to ban user")
return
}
slog.Warn("user banned", "actor_id", actor, "target_user", user.Username, "reason", reason)
_ = database.LogAudit(actor, "user_ban", "user", id,
fmt.Sprintf("banned %s: %s", user.Username, reason))
} else {
if err := database.UnbanUser(id); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to unban user")
return
}
slog.Info("user unbanned", "actor_id", actor, "target_user", user.Username)
_ = database.LogAudit(actor, "user_unban", "user", id,
fmt.Sprintf("unbanned %s", user.Username))
}
}
updated, err := database.GetUserByID(id)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch updated user")
return
}
writeJSON(w, http.StatusOK, toAdminUserResponseFromUser(updated))
}
}
func handleForceLogout(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := pathInt64(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid user id")
return
}
if err := database.ForceLogoutUser(id); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to logout user")
return
}
actor := actorFromContext(r)
slog.Info("force logout", "actor_id", actor, "target_user_id", id)
_ = database.LogAudit(actor, "force_logout", "user", id, "all sessions terminated")
w.WriteHeader(http.StatusNoContent)
}
}
func handleListChannels(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
channels, err := database.ListChannels()
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to list channels")
return
}
writeJSON(w, http.StatusOK, channels)
}
}
// createChannelRequest is the JSON body for POST /admin/api/channels.
type createChannelRequest struct {
Name string `json:"name"`
Type string `json:"type"`
Category string `json:"category"`
Topic string `json:"topic"`
Position int `json:"position"`
}
func handleCreateChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req createChannelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body")
return
}
if strings.TrimSpace(req.Name) == "" {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "name is required")
return
}
if req.Type == "" {
req.Type = "text"
}
id, err := database.AdminCreateChannel(req.Name, req.Type, req.Category, req.Topic, req.Position)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create channel")
return
}
ch, err := database.GetChannel(id)
if err != nil || ch == nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch created channel")
return
}
actor := actorFromContext(r)
slog.Info("channel created", "actor_id", actor, "channel", req.Name, "type", req.Type)
_ = database.LogAudit(actor, "channel_create", "channel", id,
fmt.Sprintf("created #%s (%s)", req.Name, req.Type))
if hub != nil {
hub.BroadcastChannelCreate(ch)
}
writeJSON(w, http.StatusCreated, ch)
}
}
// updateChannelRequest is the JSON body for PATCH /admin/api/channels/{id}.
type updateChannelRequest struct {
Name string `json:"name"`
Topic string `json:"topic"`
SlowMode int `json:"slow_mode"`
Position int `json:"position"`
Archived bool `json:"archived"`
}
func handlePatchChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := pathInt64(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid channel id")
return
}
existing, err := database.GetChannel(id)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch channel")
return
}
if existing == nil {
writeErr(w, http.StatusNotFound, "NOT_FOUND", "channel not found")
return
}
// Start from existing values so a partial body is safe.
req := updateChannelRequest{
Name: existing.Name,
Topic: existing.Topic,
SlowMode: existing.SlowMode,
Position: existing.Position,
Archived: existing.Archived,
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body")
return
}
if err := database.AdminUpdateChannel(id, req.Name, req.Topic, req.SlowMode, req.Position, req.Archived); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update channel")
return
}
actor := actorFromContext(r)
slog.Info("channel updated", "actor_id", actor, "channel_id", id, "name", req.Name)
_ = database.LogAudit(actor, "channel_update", "channel", id,
fmt.Sprintf("updated #%s", req.Name))
updated, err := database.GetChannel(id)
if err != nil || updated == nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch updated channel")
return
}
if hub != nil {
hub.BroadcastChannelUpdate(updated)
}
writeJSON(w, http.StatusOK, updated)
}
}
func handleDeleteChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := pathInt64(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid channel id")
return
}
existing, err := database.GetChannel(id)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch channel")
return
}
if existing == nil {
writeErr(w, http.StatusNotFound, "NOT_FOUND", "channel not found")
return
}
if err := database.AdminDeleteChannel(id); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete channel")
return
}
actor := actorFromContext(r)
slog.Warn("channel deleted", "actor_id", actor, "channel_id", id, "name", existing.Name)
_ = database.LogAudit(actor, "channel_delete", "channel", id,
fmt.Sprintf("deleted #%s", existing.Name))
if hub != nil {
hub.BroadcastChannelDelete(id)
}
w.WriteHeader(http.StatusNoContent)
}
}
func handleGetAuditLog(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
limit := queryInt(r, "limit", 50)
offset := queryInt(r, "offset", 0)
entries, err := database.GetAuditLog(limit, offset)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to get audit log")
return
}
writeJSON(w, http.StatusOK, entries)
}
}
func handleGetSettings(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
settings, err := database.GetAllSettings()
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to get settings")
return
}
writeJSON(w, http.StatusOK, settings)
}
}
func handlePatchSettings(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var updates map[string]string
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body")
return
}
// Validate all keys against the whitelist before writing anything so
// the operation is atomic from the caller's perspective.
for key := range updates {
if _, ok := allowedSettingKeys[key]; !ok {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST",
fmt.Sprintf("unknown setting key: %q", key))
return
}
}
actor := actorFromContext(r)
for key, value := range updates {
if err := database.SetSetting(key, value); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update setting: "+key)
return
}
slog.Info("setting changed", "actor_id", actor, "key", key)
_ = database.LogAudit(actor, "setting_change", "setting", 0,
fmt.Sprintf("%s updated", key))
}
settings, err := database.GetAllSettings()
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch settings")
return
}
writeJSON(w, http.StatusOK, settings)
}
}
func handleBackup(database *db.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
backupDir := filepath.Join("data", "backups")
if err := os.MkdirAll(backupDir, 0o750); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create backup directory")
return
}
timestamp := time.Now().UTC().Format("20060102_150405")
backupPath := filepath.Join(backupDir, "chatserver_"+timestamp+".db")
if err := database.BackupTo(backupPath); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "backup failed")
return
}
actor := actorFromContext(r)
slog.Info("database backup created", "actor_id", actor, "path", backupPath)
_ = database.LogAudit(actor, "backup_create", "server", 0,
fmt.Sprintf("backup saved to %s", backupPath))
writeJSON(w, http.StatusOK, map[string]string{
"path": backupPath,
"created": timestamp,
})
})
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
type errorResponse struct {
+164
View File
@@ -0,0 +1,164 @@
package admin
import (
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/owncord/server/db"
)
// ─── Backup Handlers ─────────────────────────────────────────────────────────
func handleBackup(database *db.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
backupDir := filepath.Join("data", "backups")
if err := os.MkdirAll(backupDir, 0o750); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create backup directory")
return
}
timestamp := time.Now().UTC().Format("20060102_150405")
backupPath := filepath.Join(backupDir, "chatserver_"+timestamp+".db")
if err := database.BackupTo(backupPath); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "backup failed")
return
}
actor := actorFromContext(r)
slog.Info("database backup created", "actor_id", actor, "path", backupPath)
_ = database.LogAudit(actor, "backup_create", "server", 0,
fmt.Sprintf("backup saved to %s", backupPath))
writeJSON(w, http.StatusOK, map[string]string{
"path": backupPath,
"created": timestamp,
})
})
}
// backupEntry is the JSON shape returned by GET /admin/api/backups.
type backupEntry struct {
Name string `json:"name"`
Size int64 `json:"size"`
Date string `json:"date"`
}
func handleListBackups() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
backupDir := filepath.Join("data", "backups")
entries, err := os.ReadDir(backupDir)
if err != nil {
if os.IsNotExist(err) {
writeJSON(w, http.StatusOK, []backupEntry{})
return
}
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to list backups")
return
}
var backups []backupEntry
for _, e := range entries {
if e.IsDir() || filepath.Ext(e.Name()) != ".db" {
continue
}
info, err := e.Info()
if err != nil {
continue
}
backups = append(backups, backupEntry{
Name: e.Name(),
Size: info.Size(),
Date: info.ModTime().UTC().Format(time.RFC3339),
})
}
if backups == nil {
backups = []backupEntry{}
}
// Sort newest first.
sort.Slice(backups, func(i, j int) bool {
return backups[i].Date > backups[j].Date
})
writeJSON(w, http.StatusOK, backups)
}
}
func handleDeleteBackup(database *db.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
if name == "" || strings.Contains(name, "..") || strings.ContainsAny(name, `/\`) {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid backup name")
return
}
backupPath := filepath.Join("data", "backups", name)
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
writeErr(w, http.StatusNotFound, "NOT_FOUND", "backup not found")
return
}
if err := os.Remove(backupPath); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete backup")
return
}
actor := actorFromContext(r)
slog.Info("backup deleted", "actor_id", actor, "name", name)
_ = database.LogAudit(actor, "backup_delete", "server", 0, "deleted backup "+name)
w.WriteHeader(http.StatusNoContent)
})
}
func handleRestoreBackup(database *db.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
if name == "" || strings.Contains(name, "..") || strings.ContainsAny(name, `/\`) {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid backup name")
return
}
backupPath := filepath.Join("data", "backups", name)
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
writeErr(w, http.StatusNotFound, "NOT_FOUND", "backup not found")
return
}
dbPath := filepath.Join("data", "chatserver.db")
// Safety: create a pre-restore backup before overwriting.
preRestore := filepath.Join("data", "backups", "pre_restore_"+time.Now().UTC().Format("20060102_150405")+".db")
if err := database.BackupTo(preRestore); err != nil {
slog.Warn("pre-restore backup failed", "err", err)
}
// Copy the backup file over the live database.
src, err := os.ReadFile(backupPath)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to read backup file")
return
}
if err := os.WriteFile(dbPath, src, 0o644); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to write database file")
return
}
actor := actorFromContext(r)
slog.Warn("database restored from backup", "actor_id", actor, "backup", name)
_ = database.LogAudit(actor, "backup_restore", "server", 0, "restored from "+name)
writeJSON(w, http.StatusOK, map[string]string{
"message": "database restored — server restart recommended",
"backup": name,
})
})
}
+180
View File
@@ -0,0 +1,180 @@
package admin
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"github.com/owncord/server/db"
)
// ─── Channel Handlers ────────────────────────────────────────────────────────
func handleListChannels(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
channels, err := database.ListChannels()
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to list channels")
return
}
writeJSON(w, http.StatusOK, channels)
}
}
// createChannelRequest is the JSON body for POST /admin/api/channels.
type createChannelRequest struct {
Name string `json:"name"`
Type string `json:"type"`
Category string `json:"category"`
Topic string `json:"topic"`
Position int `json:"position"`
}
func handleCreateChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req createChannelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body")
return
}
if strings.TrimSpace(req.Name) == "" {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "name is required")
return
}
if req.Type == "" {
req.Type = "text"
}
id, err := database.AdminCreateChannel(req.Name, req.Type, req.Category, req.Topic, req.Position)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create channel")
return
}
ch, err := database.GetChannel(id)
if err != nil || ch == nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch created channel")
return
}
actor := actorFromContext(r)
slog.Info("channel created", "actor_id", actor, "channel", req.Name, "type", req.Type)
_ = database.LogAudit(actor, "channel_create", "channel", id,
fmt.Sprintf("created #%s (%s)", req.Name, req.Type))
if hub != nil {
hub.BroadcastChannelCreate(ch)
}
writeJSON(w, http.StatusCreated, ch)
}
}
// updateChannelRequest is the JSON body for PATCH /admin/api/channels/{id}.
type updateChannelRequest struct {
Name string `json:"name"`
Topic string `json:"topic"`
SlowMode int `json:"slow_mode"`
Position int `json:"position"`
Archived bool `json:"archived"`
}
func handlePatchChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := pathInt64(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid channel id")
return
}
existing, err := database.GetChannel(id)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch channel")
return
}
if existing == nil {
writeErr(w, http.StatusNotFound, "NOT_FOUND", "channel not found")
return
}
// Start from existing values so a partial body is safe.
req := updateChannelRequest{
Name: existing.Name,
Topic: existing.Topic,
SlowMode: existing.SlowMode,
Position: existing.Position,
Archived: existing.Archived,
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body")
return
}
if err := database.AdminUpdateChannel(id, req.Name, req.Topic, req.SlowMode, req.Position, req.Archived); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update channel")
return
}
actor := actorFromContext(r)
slog.Info("channel updated", "actor_id", actor, "channel_id", id, "name", req.Name)
_ = database.LogAudit(actor, "channel_update", "channel", id,
fmt.Sprintf("updated #%s", req.Name))
updated, err := database.GetChannel(id)
if err != nil || updated == nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch updated channel")
return
}
if hub != nil {
hub.BroadcastChannelUpdate(updated)
}
writeJSON(w, http.StatusOK, updated)
}
}
func handleDeleteChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := pathInt64(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid channel id")
return
}
existing, err := database.GetChannel(id)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch channel")
return
}
if existing == nil {
writeErr(w, http.StatusNotFound, "NOT_FOUND", "channel not found")
return
}
if err := database.AdminDeleteChannel(id); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete channel")
return
}
actor := actorFromContext(r)
slog.Warn("channel deleted", "actor_id", actor, "channel_id", id, "name", existing.Name)
_ = database.LogAudit(actor, "channel_delete", "channel", id,
fmt.Sprintf("deleted #%s", existing.Name))
if hub != nil {
hub.BroadcastChannelDelete(id)
}
w.WriteHeader(http.StatusNoContent)
}
}
func handleGetAuditLog(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
limit := queryInt(r, "limit", 50)
offset := queryInt(r, "offset", 0)
entries, err := database.GetAuditLog(limit, offset)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to get audit log")
return
}
writeJSON(w, http.StatusOK, entries)
}
}
+61
View File
@@ -0,0 +1,61 @@
package admin
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"github.com/owncord/server/db"
)
// ─── Settings Handlers ──────────────────────────────────────────────────────
func handleGetSettings(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
settings, err := database.GetAllSettings()
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to get settings")
return
}
writeJSON(w, http.StatusOK, settings)
}
}
func handlePatchSettings(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var updates map[string]string
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body")
return
}
// Validate all keys against the whitelist before writing anything so
// the operation is atomic from the caller's perspective.
for key := range updates {
if _, ok := allowedSettingKeys[key]; !ok {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST",
fmt.Sprintf("unknown setting key: %q", key))
return
}
}
actor := actorFromContext(r)
for key, value := range updates {
if err := database.SetSetting(key, value); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update setting: "+key)
return
}
slog.Info("setting changed", "actor_id", actor, "key", key)
_ = database.LogAudit(actor, "setting_change", "setting", 0,
fmt.Sprintf("%s updated", key))
}
settings, err := database.GetAllSettings()
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch settings")
return
}
writeJSON(w, http.StatusOK, settings)
}
}
+149
View File
@@ -0,0 +1,149 @@
package admin
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"github.com/owncord/server/db"
)
// ─── User Handlers ───────────────────────────────────────────────────────────
func handleGetStats(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
stats, err := database.GetServerStats()
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to get stats")
return
}
writeJSON(w, http.StatusOK, stats)
}
}
func handleListUsers(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
limit := queryInt(r, "limit", 50)
offset := queryInt(r, "offset", 0)
users, err := database.ListAllUsers(limit, offset)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to list users")
return
}
safe := make([]adminUserResponse, len(users))
for i, u := range users {
safe[i] = toAdminUserResponse(u)
}
writeJSON(w, http.StatusOK, safe)
}
}
// patchUserRequest is the JSON body for PATCH /admin/api/users/{id}.
type patchUserRequest struct {
RoleID *int64 `json:"role_id"`
Banned *bool `json:"banned"`
BanReason *string `json:"ban_reason"`
}
func handlePatchUser(database *db.DB, hub HubBroadcaster) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := pathInt64(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid user id")
return
}
var req patchUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body")
return
}
user, err := database.GetUserByID(id)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch user")
return
}
if user == nil {
writeErr(w, http.StatusNotFound, "NOT_FOUND", "user not found")
return
}
actor := actorFromContext(r)
// Prevent admins from modifying their own role or ban status, which
// could lock them out of the admin panel with no recovery path.
if id == actor {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "cannot modify your own account via admin panel")
return
}
if req.RoleID != nil {
if err := database.UpdateUserRole(id, *req.RoleID); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update role")
return
}
slog.Info("role changed", "actor_id", actor, "target_user", user.Username, "new_role_id", *req.RoleID)
_ = database.LogAudit(actor, "role_change", "user", id,
fmt.Sprintf("changed %s role to %d", user.Username, *req.RoleID))
// Broadcast member_update with the new role name.
if role, err := database.GetRoleByID(*req.RoleID); err == nil && role != nil {
hub.BroadcastMemberUpdate(id, role.Name)
}
}
if req.Banned != nil {
reason := ""
if req.BanReason != nil {
reason = *req.BanReason
}
if *req.Banned {
if err := database.BanUser(id, reason, nil); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to ban user")
return
}
slog.Warn("user banned", "actor_id", actor, "target_user", user.Username, "reason", reason)
_ = database.LogAudit(actor, "user_ban", "user", id,
fmt.Sprintf("banned %s: %s", user.Username, reason))
hub.BroadcastMemberBan(id)
} else {
if err := database.UnbanUser(id); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to unban user")
return
}
slog.Info("user unbanned", "actor_id", actor, "target_user", user.Username)
_ = database.LogAudit(actor, "user_unban", "user", id,
fmt.Sprintf("unbanned %s", user.Username))
}
}
updated, err := database.GetUserByID(id)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch updated user")
return
}
writeJSON(w, http.StatusOK, toAdminUserResponseFromUser(database, updated))
}
}
func handleForceLogout(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := pathInt64(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid user id")
return
}
if err := database.ForceLogoutUser(id); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to logout user")
return
}
actor := actorFromContext(r)
slog.Info("force logout", "actor_id", actor, "target_user_id", id)
_ = database.LogAudit(actor, "force_logout", "user", id, "all sessions terminated")
w.WriteHeader(http.StatusNoContent)
}
}