mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user