From 6f35973c1b5bd8a9a4a3a5fb3ad3c6c4fddbc764 Mon Sep 17 00:00:00 2001 From: jevb Date: Tue, 17 Mar 2026 20:25:15 +0100 Subject: [PATCH] feat: TOFU cert pinning, voice channel sidebar, scroll-to-message, profiles, and review fixes - Add cert mismatch modal for TOFU certificate pinning - Separate voice channels from text in sidebar with user lists - Implement scrollToMessage and jump-to-pinned-message in overlay - Add server profiles with credential auto-fill on connect page - Fix credential auto-fill race condition on rapid profile clicks - Add channel_focus event for channel-scoped message delivery - Fix member list case-insensitive role filtering - Server normalizes role names to lowercase for protocol consistency - Remove redundant permission-denied log in handleChannelFocus - Voice store: bulk set states from ready payload, leave cleanup - WebSocket reconnect and structured logging improvements - Add tests for cert modal, overlay managers, voice sidebar, message list scroll, quick switcher, and profile management --- .gitignore | 1 + .../tauri-client/src-tauri/src/credentials.rs | 16 +- .../src/components/CertMismatchModal.ts | 124 ++++++ .../src/components/ChannelSidebar.ts | 130 ++++++- .../tauri-client/src/components/MemberList.ts | 2 +- .../src/components/MessageInput.ts | 2 +- .../src/components/MessageList.ts | 32 +- .../src/components/QuickSwitcher.ts | 2 - Client/tauri-client/src/lib/api.ts | 7 +- Client/tauri-client/src/lib/credentials.ts | 11 +- Client/tauri-client/src/lib/dispatcher.ts | 9 + Client/tauri-client/src/lib/profiles.ts | 3 + Client/tauri-client/src/lib/safe-render.ts | 20 +- Client/tauri-client/src/lib/types.ts | 9 + Client/tauri-client/src/lib/ws.ts | 69 +++- Client/tauri-client/src/pages/ConnectPage.ts | 191 ++++++++- .../src/pages/main-page/OverlayManagers.ts | 36 +- Client/tauri-client/src/stores/voice.store.ts | 26 +- Client/tauri-client/src/styles/login.css | 14 + .../tests/unit/cert-mismatch-modal.test.ts | 150 ++++++++ .../tests/unit/channel-sidebar.test.ts | 132 ++++++- .../tests/unit/connect-page.test.ts | 4 +- .../tests/unit/message-input.test.ts | 13 + .../tests/unit/message-list.test.ts | 25 +- .../tests/unit/overlay-managers.test.ts | 361 ++++++++++++++++++ .../tauri-client/tests/unit/profiles.test.ts | 9 +- .../tests/unit/quick-switcher.test.ts | 11 +- Server/api/router.go | 32 ++ Server/db/auth_queries.go | 2 +- Server/ws/handlers.go | 7 + Server/ws/serve.go | 6 +- 31 files changed, 1368 insertions(+), 88 deletions(-) create mode 100644 Client/tauri-client/src/components/CertMismatchModal.ts create mode 100644 Client/tauri-client/tests/unit/cert-mismatch-modal.test.ts create mode 100644 Client/tauri-client/tests/unit/overlay-managers.test.ts diff --git a/.gitignore b/.gitignore index 1f45b455..50698765 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ Client/publish-release/ # HTML mockups (large design reference files) Client/login-mockup.html Client/ui-mockup.html +.gstack/ diff --git a/Client/tauri-client/src-tauri/src/credentials.rs b/Client/tauri-client/src-tauri/src/credentials.rs index f7ae63c5..92ea931d 100644 --- a/Client/tauri-client/src-tauri/src/credentials.rs +++ b/Client/tauri-client/src-tauri/src/credentials.rs @@ -12,6 +12,9 @@ use windows::Win32::Security::Credentials::{ pub struct CredentialData { pub username: String, pub token: String, + /// Optional saved password (only present when user opted in). + #[serde(skip_serializing_if = "Option::is_none")] + pub password: Option, } /// Build the target name used in Windows Credential Manager. @@ -34,7 +37,7 @@ fn to_wide(s: &str) -> Vec { /// Target name: `OwnCord/{host}` /// Blob: JSON `{"username":"...","token":"..."}` #[tauri::command] -pub fn save_credential(host: String, username: String, token: String) -> Result<(), String> { +pub fn save_credential(host: String, username: String, token: String, password: Option) -> Result<(), String> { if host.is_empty() { return Err("host must not be empty".into()); } @@ -48,10 +51,13 @@ pub fn save_credential(host: String, username: String, token: String) -> Result< let target = target_name(&host); let wide_user = to_wide(&username); - let payload = serde_json::json!({ + let mut payload = serde_json::json!({ "username": username, "token": token, }); + if let Some(ref pw) = password { + payload["password"] = serde_json::Value::String(pw.clone()); + } let blob = payload.to_string().into_bytes(); let mut cred = CREDENTIALW { @@ -131,11 +137,15 @@ pub fn load_credential(host: String) -> Result, String> { .and_then(|v| v.as_str()) .unwrap_or_default() .to_string(); + let password = parsed + .get("password") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); // Free the credential memory allocated by Windows. CredFree(pcred as *const std::ffi::c_void); - Ok(Some(CredentialData { username, token })) + Ok(Some(CredentialData { username, token, password })) }; result diff --git a/Client/tauri-client/src/components/CertMismatchModal.ts b/Client/tauri-client/src/components/CertMismatchModal.ts new file mode 100644 index 00000000..216bff0d --- /dev/null +++ b/Client/tauri-client/src/components/CertMismatchModal.ts @@ -0,0 +1,124 @@ +/** + * CertMismatchModal — shows a warning when the server TLS certificate + * fingerprint has changed (TOFU mismatch). Gives the user the choice + * to accept the new certificate or disconnect. + * + * Uses the existing .modal-overlay / .cert-* CSS classes from login.css. + */ + +import { createElement, setText, appendChildren } from "@lib/dom"; +import type { MountableComponent } from "@lib/safe-render"; + +export interface CertMismatchModalOptions { + readonly host: string; + readonly storedFingerprint: string; + readonly newFingerprint: string; + readonly onAccept: () => void; + readonly onReject: () => void; +} + +export function createCertMismatchModal( + options: CertMismatchModalOptions, +): MountableComponent { + const { host, storedFingerprint, newFingerprint, onAccept, onReject } = options; + let overlay: HTMLDivElement | null = null; + const ac = new AbortController(); + + function mount(container: Element): void { + overlay = createElement("div", { class: "modal-overlay visible" }); + + const modal = createElement("div", { class: "modal" }); + + // Header + const header = createElement("div", { class: "modal-header" }); + const title = createElement("h3", {}, "Certificate Warning"); + const closeBtn = createElement("button", { class: "modal-close", type: "button" }); + setText(closeBtn, "\u2715"); + closeBtn.addEventListener("click", onReject, { signal: ac.signal }); + appendChildren(header, title, closeBtn); + + // Body + const body = createElement("div", { class: "modal-body" }); + + const warning = createElement("div", { class: "cert-warning" }); + warning.innerHTML = ''; + + const certTitle = createElement("div", { class: "cert-title" }); + setText(certTitle, "Certificate Changed"); + + const desc = createElement("div", { class: "cert-desc" }); + setText( + desc, + "The server's TLS certificate fingerprint has changed. " + + "This could mean the server regenerated its certificate, " + + "or it could indicate a security issue.", + ); + + const details = createElement("div", { class: "cert-details" }); + + const hostRow = buildRow("Host", host, false); + const storedRow = buildRow("Previous", storedFingerprint, true); + const newRow = buildRow("Current", newFingerprint, true); + appendChildren(details, hostRow, storedRow, newRow); + + appendChildren(body, warning, certTitle, desc, details); + + // Footer + const footer = createElement("div", { class: "modal-footer" }); + + const rejectBtn = createElement("button", { + class: "btn-ghost", + type: "button", + }); + setText(rejectBtn, "Disconnect"); + rejectBtn.addEventListener("click", onReject, { signal: ac.signal }); + + const acceptBtn = createElement("button", { + class: "btn-danger", + type: "button", + }); + setText(acceptBtn, "Accept New Certificate"); + acceptBtn.addEventListener("click", onAccept, { signal: ac.signal }); + + appendChildren(footer, rejectBtn, acceptBtn); + + appendChildren(modal, header, body, footer); + overlay.appendChild(modal); + + // Close on backdrop click + overlay.addEventListener( + "click", + (e) => { + if (e.target === overlay) onReject(); + }, + { signal: ac.signal }, + ); + + container.appendChild(overlay); + } + + function destroy(): void { + ac.abort(); + if (overlay !== null) { + overlay.remove(); + overlay = null; + } + } + + return { mount, destroy }; +} + +function buildRow( + label: string, + value: string, + isFingerprint: boolean, +): HTMLDivElement { + const row = createElement("div", { class: "cert-row" }); + const labelEl = createElement("span", { class: "cert-label" }); + setText(labelEl, label); + const valueClass = isFingerprint ? "cert-value cert-fingerprint" : "cert-value"; + const valueEl = createElement("span", { class: valueClass }); + setText(valueEl, value || "Unknown"); + appendChildren(row, labelEl, valueEl); + return row; +} diff --git a/Client/tauri-client/src/components/ChannelSidebar.ts b/Client/tauri-client/src/components/ChannelSidebar.ts index 043942d7..757e6459 100644 --- a/Client/tauri-client/src/components/ChannelSidebar.ts +++ b/Client/tauri-client/src/components/ChannelSidebar.ts @@ -1,6 +1,7 @@ /** * ChannelSidebar component — channel list sidebar with categories, * unread indicators, and collapse/expand behavior. + * Voice channels show connected users and join/leave on click. */ import { @@ -23,8 +24,24 @@ import { toggleCategory, isCategoryCollapsed, } from "@stores/ui.store"; +import { voiceStore, getChannelVoiceUsers } from "@stores/voice.store"; -function renderChannelItem( +export interface ChannelSidebarOptions { + readonly onVoiceJoin: (channelId: number) => void; + readonly onVoiceLeave: () => void; +} + +const AVATAR_COLORS = ["#5865f2", "#57f287", "#fee75c", "#eb459e", "#ed4245"]; + +function pickAvatarColor(username: string): string { + let hash = 0; + for (let i = 0; i < username.length; i++) { + hash = (hash * 31 + username.charCodeAt(i)) | 0; + } + return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length] ?? "#5865f2"; +} + +function renderTextChannelItem( channel: Channel, isActive: boolean, signal: AbortSignal, @@ -40,11 +57,7 @@ function renderChannelItem( const item = createElement("div", { class: classes, "data-testid": `channel-${channel.id}` }); item.dataset.channelId = String(channel.id); - const prefix = - channel.type === "voice" - ? createElement("span", { class: "ch-icon" }, "\uD83D\uDD0A") - : createElement("span", { class: "ch-icon" }, "#"); - + const prefix = createElement("span", { class: "ch-icon" }, "#"); const name = createElement("span", { class: "ch-name" }, channel.name); appendChildren(item, prefix, name); @@ -70,11 +83,101 @@ function renderChannelItem( return item; } +function renderVoiceChannelItem( + channel: Channel, + signal: AbortSignal, + onVoiceJoin: (channelId: number) => void, + onVoiceLeave: () => void, +): HTMLDivElement { + const voiceState = voiceStore.getState(); + const isJoined = voiceState.currentChannelId === channel.id; + + const wrapper = createElement("div", {}); + + const classes = ["channel-item", "voice", isJoined ? "active" : ""] + .filter(Boolean) + .join(" "); + + const item = createElement("div", { class: classes, "data-testid": `channel-${channel.id}` }); + item.dataset.channelId = String(channel.id); + + const prefix = createElement("span", { class: "ch-icon" }, "\uD83D\uDD0A"); + const name = createElement("span", { class: "ch-name" }, channel.name); + + appendChildren(item, prefix, name); + + item.addEventListener( + "click", + () => { + if (isJoined) { + onVoiceLeave(); + } else { + onVoiceJoin(channel.id); + } + }, + { signal }, + ); + + wrapper.appendChild(item); + + // Render connected voice users below the channel + const voiceUsers = getChannelVoiceUsers(channel.id); + if (voiceUsers.length > 0) { + const usersContainer = createElement("div", { class: "voice-users-list" }); + for (const user of voiceUsers) { + const rowClasses = user.speaking + ? "voice-user-item speaking" + : "voice-user-item"; + const row = createElement("div", { class: rowClasses }); + + const initial = user.username.length > 0 + ? user.username.charAt(0).toUpperCase() + : "?"; + const avatar = createElement("div", { class: "vu-avatar" }, initial); + avatar.style.background = pickAvatarColor(user.username); + row.appendChild(avatar); + + const nameEl = createElement( + "span", + { class: "vu-name" }, + user.username || "Unknown", + ); + row.appendChild(nameEl); + + if (user.muted || user.deafened) { + const icon = user.deafened ? "\uD83D\uDD08" : "\uD83D\uDD07"; + const mutedEl = createElement("span", { class: "vu-muted" }, icon); + row.appendChild(mutedEl); + } + + usersContainer.appendChild(row); + } + wrapper.appendChild(usersContainer); + } + + return wrapper; +} + +function renderChannelItem( + channel: Channel, + isActive: boolean, + signal: AbortSignal, + onVoiceJoin: (channelId: number) => void, + onVoiceLeave: () => void, +): HTMLDivElement { + if (channel.type === "voice") { + return renderVoiceChannelItem(channel, signal, onVoiceJoin, onVoiceLeave); + } + return renderTextChannelItem(channel, isActive, signal); +} + function renderCategoryGroup( categoryName: string | null, channels: readonly Channel[], activeChannelId: number | null, signal: AbortSignal, + onVoiceJoin: (channelId: number) => void, + onVoiceLeave: () => void, ): HTMLDivElement { const group = createElement("div", {}); @@ -107,7 +210,7 @@ function renderCategoryGroup( if (!collapsed) { for (const ch of channels) { group.appendChild( - renderChannelItem(ch, ch.id === activeChannelId, signal), + renderChannelItem(ch, ch.id === activeChannelId, signal, onVoiceJoin, onVoiceLeave), ); } } @@ -115,7 +218,7 @@ function renderCategoryGroup( // Uncategorized channels render directly for (const ch of channels) { group.appendChild( - renderChannelItem(ch, ch.id === activeChannelId, signal), + renderChannelItem(ch, ch.id === activeChannelId, signal, onVoiceJoin, onVoiceLeave), ); } } @@ -123,7 +226,8 @@ function renderCategoryGroup( return group; } -export function createChannelSidebar(): MountableComponent { +export function createChannelSidebar(options: ChannelSidebarOptions): MountableComponent { + const { onVoiceJoin, onVoiceLeave } = options; const ac = new AbortController(); let root: HTMLDivElement | null = null; let channelList: HTMLDivElement | null = null; @@ -142,7 +246,7 @@ export function createChannelSidebar(): MountableComponent { for (const [category, channels] of grouped) { channelList.appendChild( - renderCategoryGroup(category, channels, state.activeChannelId, ac.signal), + renderCategoryGroup(category, channels, state.activeChannelId, ac.signal, onVoiceJoin, onVoiceLeave), ); } } @@ -188,6 +292,12 @@ export function createChannelSidebar(): MountableComponent { renderChannels(); }); unsubscribers.push(unsubUi); + + // Subscribe to voice store for connected user updates + const unsubVoice = voiceStore.subscribe(() => { + renderChannels(); + }); + unsubscribers.push(unsubVoice); } function destroy(): void { diff --git a/Client/tauri-client/src/components/MemberList.ts b/Client/tauri-client/src/components/MemberList.ts index 9324feb9..c1e6065a 100644 --- a/Client/tauri-client/src/components/MemberList.ts +++ b/Client/tauri-client/src/components/MemberList.ts @@ -76,7 +76,7 @@ function renderList(root: HTMLDivElement): void { for (const group of ROLE_GROUPS) { const groupMembers = allMembers - .filter((m) => m.role === group.role) + .filter((m) => m.role.toLowerCase() === group.role) .sort((a, b) => statusPriority(a.status) - statusPriority(b.status)); if (groupMembers.length === 0) continue; diff --git a/Client/tauri-client/src/components/MessageInput.ts b/Client/tauri-client/src/components/MessageInput.ts index a0784c48..01632140 100644 --- a/Client/tauri-client/src/components/MessageInput.ts +++ b/Client/tauri-client/src/components/MessageInput.ts @@ -141,7 +141,7 @@ export function createMessageInput( const inputBox = createElement("div", { class: "message-input-box" }); const attachBtn = createElement("button", - { class: "input-btn attach-btn", "aria-label": "Attach file" }, "+"); + { class: "input-btn attach-btn", "aria-label": "Attach file", disabled: "true", title: "File uploads coming soon" }, "+"); textarea = createElement("textarea", { class: "msg-textarea", placeholder: `Message #${options.channelName}`, rows: "1", "data-testid": "msg-textarea", diff --git a/Client/tauri-client/src/components/MessageList.ts b/Client/tauri-client/src/components/MessageList.ts index 88549af5..1c01cfc1 100644 --- a/Client/tauri-client/src/components/MessageList.ts +++ b/Client/tauri-client/src/components/MessageList.ts @@ -74,7 +74,12 @@ function buildVirtualItems(messages: readonly Message[]): readonly VirtualItem[] // -- Factory ------------------------------------------------------------------ -export function createMessageList(options: MessageListOptions): MountableComponent { +export type MessageListComponent = MountableComponent & { + /** Scroll to a message by ID. Returns false if the message is not in the loaded window. */ + scrollToMessage(messageId: number): boolean; +}; + +export function createMessageList(options: MessageListOptions): MessageListComponent { const ac = new AbortController(); const unsubscribers: Array<() => void> = []; let root: HTMLDivElement | null = null; @@ -339,5 +344,28 @@ export function createMessageList(options: MessageListOptions): MountableCompone bottomSpacer = null; } - return { mount, destroy }; + function scrollToMessage(messageId: number): boolean { + if (root === null) return false; + const idx = virtualItems.findIndex( + (item) => item.kind === "message" && item.message.id === messageId, + ); + if (idx === -1) return false; + + root.scrollTop = offsetBefore(idx); + renderWindow(); + + // Briefly highlight the target message element + if (contentContainer !== null) { + const localIdx = idx - renderedStart; + const el = contentContainer.children[localIdx] as HTMLElement | undefined; + if (el !== undefined) { + el.classList.add("highlight-flash"); + setTimeout(() => { el.classList.remove("highlight-flash"); }, 1500); + } + } + + return true; + } + + return { mount, destroy, scrollToMessage }; } diff --git a/Client/tauri-client/src/components/QuickSwitcher.ts b/Client/tauri-client/src/components/QuickSwitcher.ts index 4d2a3646..c8419e7e 100644 --- a/Client/tauri-client/src/components/QuickSwitcher.ts +++ b/Client/tauri-client/src/components/QuickSwitcher.ts @@ -8,7 +8,6 @@ import type { MountableComponent } from "@lib/safe-render"; export interface QuickSwitcherOptions { readonly onSelectChannel: (channelId: number) => void; - readonly onSearch: (query: string) => void; readonly onClose: () => void; } @@ -78,7 +77,6 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom function handleInput(): void { const query = input.value.trim(); - options.onSearch(query); filteredChannels = getFilteredChannels(query); activeIndex = 0; renderResults(); diff --git a/Client/tauri-client/src/lib/api.ts b/Client/tauri-client/src/lib/api.ts index 943ee3ab..ee029d66 100644 --- a/Client/tauri-client/src/lib/api.ts +++ b/Client/tauri-client/src/lib/api.ts @@ -80,18 +80,22 @@ export function createApiClient( init.body = JSON.stringify(body); } + log.debug("API →", { method, path }); + let res: Response; try { res = await fetch(url, init as RequestInit); } catch (fetchErr) { // Tauri plugin errors may not be standard Error instances - log.error("fetch failed", { error: String(fetchErr), type: typeof fetchErr }); + log.error("API fetch failed", { method, path, error: String(fetchErr) }); if (fetchErr instanceof Error) { throw fetchErr; } throw new Error(typeof fetchErr === "string" ? fetchErr : String(fetchErr)); } + log.debug("API ←", { method, path, status: res.status }); + if (res.status === 401) { onUnauthorized?.(); const err = await parseError(res); @@ -100,6 +104,7 @@ export function createApiClient( if (!res.ok) { const err = await parseError(res); + log.warn("API error", { method, path, status: res.status, code: err.error, message: err.message }); throw new ApiClientError(res.status, err.error, err.message); } diff --git a/Client/tauri-client/src/lib/credentials.ts b/Client/tauri-client/src/lib/credentials.ts index 593a7d63..c1646570 100644 --- a/Client/tauri-client/src/lib/credentials.ts +++ b/Client/tauri-client/src/lib/credentials.ts @@ -10,6 +10,7 @@ const log = createLogger("credentials"); export interface SavedCredential { readonly username: string; readonly token: string; + readonly password?: string; } /** Dynamically import Tauri invoke to avoid errors in test/browser. */ @@ -32,6 +33,7 @@ export async function saveCredential( host: string, username: string, token: string, + password?: string, ): Promise { const invoke = await getInvoke(); if (!invoke) { @@ -39,7 +41,7 @@ export async function saveCredential( return false; } try { - await invoke("save_credential", { host, username, token }); + await invoke("save_credential", { host, username, token, password: password ?? null }); return true; } catch (err) { log.error("Failed to save credential", { host, error: String(err) }); @@ -63,7 +65,12 @@ export async function loadCredential( if (result && typeof result === "object") { const cred = result as Record; if (typeof cred.username === "string" && typeof cred.token === "string") { - return { username: cred.username, token: cred.token }; + const saved: SavedCredential = { + username: cred.username, + token: cred.token, + ...(typeof cred.password === "string" ? { password: cred.password } : {}), + }; + return saved; } } return null; diff --git a/Client/tauri-client/src/lib/dispatcher.ts b/Client/tauri-client/src/lib/dispatcher.ts index 42d5cdfe..a8b07d4a 100644 --- a/Client/tauri-client/src/lib/dispatcher.ts +++ b/Client/tauri-client/src/lib/dispatcher.ts @@ -100,6 +100,11 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup { unsubs.push( ws.on("chat_message", (payload) => { + log.debug("chat_message received", { + id: payload.id, + channelId: payload.channel_id, + user: payload.user.username, + }); addMessage(payload); // Increment unread for non-active channels const activeId = channelsStore.select( @@ -191,24 +196,28 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup { unsubs.push( ws.on("member_join", (payload) => { + log.info("Member joined", { userId: payload.user.id, username: payload.user.username }); addMember(payload); }), ); unsubs.push( ws.on("member_leave", (payload) => { + log.info("Member left", { userId: payload.user_id }); removeMember(payload.user_id); }), ); unsubs.push( ws.on("member_ban", (payload) => { + log.info("Member banned", { userId: payload.user_id }); removeMember(payload.user_id); }), ); unsubs.push( ws.on("member_update", (payload) => { + log.info("Member role updated", { userId: payload.user_id, role: payload.role }); updateMemberRole(payload.user_id, payload.role); }), ); diff --git a/Client/tauri-client/src/lib/profiles.ts b/Client/tauri-client/src/lib/profiles.ts index 3ecb65a8..6244093a 100644 --- a/Client/tauri-client/src/lib/profiles.ts +++ b/Client/tauri-client/src/lib/profiles.ts @@ -28,6 +28,7 @@ export interface ServerProfile { readonly host: string; readonly username: string; readonly autoConnect: boolean; + readonly rememberPassword: boolean; readonly color: string; readonly lastConnected: string | null; } @@ -85,6 +86,7 @@ function isValidProfileShape(item: unknown): item is ServerProfile { typeof obj.username === "string" && typeof obj.color === "string" && typeof obj.autoConnect === "boolean" && + (obj.rememberPassword === undefined || typeof obj.rememberPassword === "boolean") && (obj.lastConnected === null || typeof obj.lastConnected === "string") ); } @@ -400,6 +402,7 @@ export function createProfileManager( username: raw.username, color: raw.color, autoConnect: raw.autoConnect, + rememberPassword: raw.rememberPassword ?? false, lastConnected: null, }; newProfiles.push(profile); diff --git a/Client/tauri-client/src/lib/safe-render.ts b/Client/tauri-client/src/lib/safe-render.ts index b2046466..5193edf9 100644 --- a/Client/tauri-client/src/lib/safe-render.ts +++ b/Client/tauri-client/src/lib/safe-render.ts @@ -65,12 +65,20 @@ export function installGlobalErrorHandlers(): void { }); window.addEventListener("unhandledrejection", (event) => { - log.error("Unhandled promise rejection", { - reason: - event.reason instanceof Error - ? event.reason.stack - : String(event.reason), - }); + const reason = + event.reason instanceof Error + ? event.reason.stack ?? event.reason.message + : String(event.reason); + + // Tauri plugin-http GC cleanup: when a consumed Response body is finalized, + // Tauri tries to drop the Rust resource which may already be freed. + // This is cosmetic — downgrade to debug instead of polluting error logs. + if (typeof reason === "string" && /resource id .+ is invalid/.test(reason)) { + log.debug("Tauri resource already freed (benign)", { reason }); + return; + } + + log.error("Unhandled promise rejection", { reason }); }); log.info("Global error handlers installed"); diff --git a/Client/tauri-client/src/lib/types.ts b/Client/tauri-client/src/lib/types.ts index ed708096..ec6ece9f 100644 --- a/Client/tauri-client/src/lib/types.ts +++ b/Client/tauri-client/src/lib/types.ts @@ -355,6 +355,10 @@ export interface TypingStartPayload { readonly channel_id: number; } +export interface ChannelFocusPayload { + readonly channel_id: number; +} + export interface PresenceUpdatePayload { readonly status: UserStatus; } @@ -363,6 +367,9 @@ export interface VoiceJoinPayload { readonly channel_id: number; } +/** Client → Server: leave current voice channel (no payload needed). */ +export type VoiceLeaveClientPayload = Record; + export interface VoiceMutePayload { readonly muted: boolean; } @@ -431,8 +438,10 @@ export type ClientMessage = | (WsEnvelope & { readonly type: "reaction_add" }) | (WsEnvelope & { readonly type: "reaction_remove" }) | (WsEnvelope & { readonly type: "typing_start" }) + | (WsEnvelope & { readonly type: "channel_focus" }) | (WsEnvelope & { readonly type: "presence_update" }) | (WsEnvelope & { readonly type: "voice_join" }) + | (WsEnvelope & { readonly type: "voice_leave" }) | (WsEnvelope & { readonly type: "voice_mute" }) | (WsEnvelope & { readonly type: "voice_deafen" }) | (WsEnvelope & { readonly type: "voice_camera" }) diff --git a/Client/tauri-client/src/lib/ws.ts b/Client/tauri-client/src/lib/ws.ts index a8fc7e2b..687eb6ab 100644 --- a/Client/tauri-client/src/lib/ws.ts +++ b/Client/tauri-client/src/lib/ws.ts @@ -42,6 +42,14 @@ export interface CertTofuEvent { readonly fingerprint: string; readonly status: "trusted_first_use" | "trusted" | "mismatch"; readonly message?: string; + readonly storedFingerprint?: string; +} + +/** Parse the stored fingerprint from the Rust cert-tofu message string. */ +export function parseStoredFingerprint(message?: string): string | undefined { + if (!message) return undefined; + const match = /Stored:\s+(\S+)/.exec(message); + return match?.[1]; } export type CertMismatchListener = (event: CertTofuEvent) => void; @@ -143,19 +151,26 @@ export function createWsClient() { return; } - let msg: ServerMessage; + let parsed: { type?: string; payload?: unknown; id?: string }; try { - msg = JSON.parse(raw) as ServerMessage; + parsed = JSON.parse(raw) as { type?: string; payload?: unknown; id?: string }; } catch { log.warn("Failed to parse WS message", { data: raw }); return; } - if (!msg.type || msg.payload === undefined) { - log.warn("Invalid WS message: missing type or payload", { msg }); + // Server pong messages have no payload — silently ignore. + if (parsed.type === "pong") return; + + if (!parsed.type || parsed.payload === undefined) { + log.warn("Invalid WS message: missing type or payload", { parsed }); return; } + const msg = parsed as unknown as ServerMessage; + + log.debug("WS ←", { type: msg.type, id: msg.id }); + // auth_error — non-recoverable if (msg.type === "auth_error") { log.error("Authentication failed", { message: msg.payload.message }); @@ -178,16 +193,18 @@ export function createWsClient() { function dispatch(msg: ServerMessage): void { const typeListeners = listeners.get(msg.type); - if (typeListeners) { - for (const listener of typeListeners) { - try { - (listener as WsListener)( - msg.payload as Extract["payload"], - msg.id, - ); - } catch (err) { - log.error(`Listener error for ${msg.type}`, err); - } + if (!typeListeners || typeListeners.size === 0) { + log.debug("WS dispatch: no listeners", { type: msg.type }); + return; + } + for (const listener of typeListeners) { + try { + (listener as WsListener)( + msg.payload as Extract["payload"], + msg.id, + ); + } catch (err) { + log.error(`Listener error for ${msg.type}`, err); } } } @@ -232,14 +249,18 @@ export function createWsClient() { // TOFU certificate events const unsubCert = await tauriListen("cert-tofu", (e) => { - const evt = e.payload as CertTofuEvent; - log.info("TOFU cert event", { host: evt.host, status: evt.status }); + const raw = e.payload as CertTofuEvent; + log.info("TOFU cert event", { host: raw.host, status: raw.status }); - if (evt.status === "mismatch") { + if (raw.status === "mismatch") { + const evt: CertTofuEvent = { + ...raw, + storedFingerprint: parseStoredFingerprint(raw.message), + }; log.error("Certificate fingerprint mismatch!", { host: evt.host, fingerprint: evt.fingerprint, - message: evt.message, + storedFingerprint: evt.storedFingerprint, }); certMismatchBlock = true; setState("disconnected"); @@ -253,7 +274,16 @@ export function createWsClient() { function cleanupEventListeners(): void { for (const unsub of eventUnsubs) { - unsub(); + try { + // Unsub may return a rejected promise if the Tauri resource + // was already invalidated after disconnect — safe to ignore. + const result = unsub() as unknown; + if (result instanceof Promise) { + result.catch(() => {}); + } + } catch { + // Sync errors also safe to ignore. + } } eventUnsubs.length = 0; } @@ -305,6 +335,7 @@ export function createWsClient() { function send(msg: ClientMessage | { type: string; payload: unknown }): string { const id = uuid(); const envelope = { ...msg, id }; + log.debug("WS →", { type: msg.type, id }); sendRaw(JSON.stringify(envelope)); return id; } diff --git a/Client/tauri-client/src/pages/ConnectPage.ts b/Client/tauri-client/src/pages/ConnectPage.ts index 7f4a12ba..8ab092da 100644 --- a/Client/tauri-client/src/pages/ConnectPage.ts +++ b/Client/tauri-client/src/pages/ConnectPage.ts @@ -11,18 +11,13 @@ import { import type { MountableComponent } from "@lib/safe-render"; import { openSettings, closeSettings } from "@stores/ui.store"; import { createSettingsOverlay } from "@components/SettingsOverlay"; -import type { HealthStatus } from "@lib/profiles"; +import type { HealthStatus, ServerProfile } from "@lib/profiles"; +import { loadCredential } from "@lib/credentials"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- -/** Saved server profile for quick-connect. */ -export interface ServerProfile { - readonly name: string; - readonly host: string; -} - /** Form state machine states. */ export type FormState = "idle" | "loading" | "totp" | "connecting" | "error"; @@ -39,6 +34,14 @@ export interface ConnectPageCallbacks { inviteCode: string, ): Promise; onTotpSubmit(code: string): Promise; + onAddProfile?(name: string, host: string): void; + onDeleteProfile?(profileId: string): void; +} + +/** Minimal profile shape for the default profile list (backward compat). */ +export interface SimpleProfile { + readonly name: string; + readonly host: string; } // --------------------------------------------------------------------------- @@ -47,7 +50,7 @@ export interface ConnectPageCallbacks { const MIN_PASSWORD_LENGTH = 8; -const DEFAULT_PROFILES: readonly ServerProfile[] = [ +const DEFAULT_PROFILES: readonly SimpleProfile[] = [ { name: "Local Server", host: "localhost:8443" }, ]; @@ -75,13 +78,17 @@ function getIconInitials(name: string): string { export function createConnectPage( callbacks: ConnectPageCallbacks, - initialProfiles: readonly ServerProfile[] = DEFAULT_PROFILES, + initialProfiles: readonly SimpleProfile[] = DEFAULT_PROFILES, ): MountableComponent & { showTotp(): void; showConnecting(): void; showError(message: string): void; resetToIdle(): void; updateHealthStatus(host: string, status: HealthStatus): void; + getRememberPassword(): boolean; + getPassword(): string; + /** Re-render the server profile list with updated data. */ + refreshProfiles(profiles: readonly SimpleProfile[]): void; } { // --- internal state (mutable, local to this instance) --- let formState: FormState = "idle"; @@ -108,6 +115,7 @@ export function createConnectPage( let totpOverlay: HTMLDivElement; let totpInput: HTMLInputElement; let totpSubmitBtn: HTMLButtonElement; + let rememberPasswordCheckbox: HTMLInputElement; let statusBar: HTMLDivElement; let statusBarFill: HTMLDivElement; @@ -147,14 +155,24 @@ export function createConnectPage( renderServerProfiles(initialProfiles); - appendChildren(panel, header, serverListEl); + // Footer with "Add Server" button + const footer = createElement("div", { class: "server-panel-footer" }); + const addBtn = createElement("button", { + class: "btn-add-server", + type: "button", + }); + setText(addBtn, "+ Add Server"); + addBtn.addEventListener("click", handleAddServer, { signal: abortController.signal }); + footer.appendChild(addBtn); + + appendChildren(panel, header, serverListEl, footer); return panel; } // Map of host -> DOM elements for health status updates const healthElements = new Map(); - function renderServerProfiles(profiles: readonly ServerProfile[]): void { + function renderServerProfiles(profiles: readonly SimpleProfile[]): void { clearChildren(serverListEl); healthElements.clear(); for (const profile of profiles) { @@ -179,16 +197,61 @@ export function createConnectPage( const host = createElement("span", { class: "srv-host" }, profile.host); const latency = createElement("span", { class: "srv-latency" }); appendChildren(meta, host, latency); + + // Show username if available (full profile has it) + const fullProfile = profile as Partial; + if (fullProfile.username) { + const usernameEl = createElement("span", { class: "srv-host" }, fullProfile.username); + appendChildren(meta, usernameEl); + } + appendChildren(info, name, meta); healthElements.set(profile.host, { dot: statusDot, latency }); - appendChildren(item, icon, info); + // Delete button (only for full profiles that have an id) + const actions = createElement("div", { class: "srv-actions" }); + if (fullProfile.id && callbacks.onDeleteProfile) { + const deleteBtn = createElement("button", { + class: "srv-btn danger", + type: "button", + "aria-label": "Delete server", + }); + setText(deleteBtn, "\u2715"); + deleteBtn.addEventListener( + "click", + (e) => { + e.stopPropagation(); + callbacks.onDeleteProfile!(fullProfile.id!); + }, + { signal: abortController.signal }, + ); + actions.appendChild(deleteBtn); + } + + appendChildren(item, icon, info, actions); item.addEventListener( "click", () => { hostInput.value = profile.host; + // Auto-fill username from profile + if (fullProfile.username) { + usernameInput.value = fullProfile.username; + } + // Auto-fill credentials from credential store + const requestedHost = profile.host; + void (async () => { + const cred = await loadCredential(requestedHost); + // Guard: user may have clicked a different profile while loading + if (cred && hostInput.value === requestedHost) { + usernameInput.value = cred.username; + if (cred.password) { + passwordInput.value = cred.password; + rememberPasswordCheckbox.checked = true; + } + } + })(); }, { signal: abortController.signal }, ); @@ -262,6 +325,18 @@ export function createConnectPage( const passwordGroup = buildFormGroup("password", "Password", "password", ""); passwordInput = qs("input", passwordGroup) as HTMLInputElement; + // Remember password checkbox + const rememberGroup = createElement("div", { class: "form-group remember-password-group" }); + rememberPasswordCheckbox = createElement("input", { + type: "checkbox", + id: "remember-password", + }); + const rememberLabel = createElement("label", { + for: "remember-password", + class: "remember-password-label", + }, "Remember password"); + appendChildren(rememberGroup, rememberPasswordCheckbox, rememberLabel); + // Invite code (register only, hidden by default) inviteGroup = buildFormGroup("invite", "Invite Code", "text", ""); inviteGroup.classList.add("form-group--hidden"); @@ -283,7 +358,7 @@ export function createConnectPage( toggleModeBtn = createElement("a", {}, "Need an account? Register") as HTMLAnchorElement; formSwitch.appendChild(toggleModeBtn); - appendChildren(form, hostGroup, usernameGroup, passwordGroup, inviteGroup, submitBtn, formSwitch); + appendChildren(form, hostGroup, usernameGroup, passwordGroup, rememberGroup, inviteGroup, submitBtn, formSwitch); // Wire form events form.addEventListener("submit", handleFormSubmit, { signal: abortController.signal }); @@ -389,6 +464,84 @@ export function createConnectPage( return overlay; } + // --------------------------------------------------------------------------- + // Add Server modal + // --------------------------------------------------------------------------- + + function handleAddServer(): void { + if (!callbacks.onAddProfile) return; + + const overlay = createElement("div", { class: "modal-overlay visible" }); + const modal = createElement("div", { class: "modal" }); + + const header = createElement("div", { class: "modal-header" }); + const title = createElement("h3", {}, "Add Server"); + const closeBtn = createElement("button", { class: "modal-close", type: "button" }); + setText(closeBtn, "\u2715"); + appendChildren(header, title, closeBtn); + + const body = createElement("div", { class: "modal-body" }); + const nameGroup = createElement("div", { class: "form-group" }); + const nameLabel = createElement("label", { class: "form-label" }, "Server Name"); + const nameInput = createElement("input", { + class: "form-input", + type: "text", + placeholder: "My Server", + }); + appendChildren(nameGroup, nameLabel, nameInput); + + const hostGroup = createElement("div", { class: "form-group" }); + const hostLabel = createElement("label", { class: "form-label" }, "Host Address"); + const hostAddrInput = createElement("input", { + class: "form-input", + type: "text", + placeholder: "example.com:8443", + }); + appendChildren(hostGroup, hostLabel, hostAddrInput); + + appendChildren(body, nameGroup, hostGroup); + + const footer = createElement("div", { class: "modal-footer" }); + const cancelBtn = createElement("button", { class: "btn-ghost", type: "button" }); + setText(cancelBtn, "Cancel"); + const saveBtn = createElement("button", { class: "btn-primary", type: "button" }); + setText(saveBtn, "Add Server"); + appendChildren(footer, cancelBtn, saveBtn); + + appendChildren(modal, header, body, footer); + overlay.appendChild(modal); + + function closeModal(): void { + overlay.remove(); + } + + function handleSave(): void { + const name = (nameInput as HTMLInputElement).value.trim(); + const addr = (hostAddrInput as HTMLInputElement).value.trim(); + if (!name || !addr) return; + callbacks.onAddProfile!(name, addr); + closeModal(); + } + + closeBtn.addEventListener("click", closeModal, { signal: abortController.signal }); + cancelBtn.addEventListener("click", closeModal, { signal: abortController.signal }); + saveBtn.addEventListener("click", handleSave, { signal: abortController.signal }); + overlay.addEventListener("click", (e) => { + if (e.target === overlay) closeModal(); + }, { signal: abortController.signal }); + + // Allow backdrop stop propagation on modal body + modal.addEventListener("click", (e) => e.stopPropagation(), { signal: abortController.signal }); + + // Enter key submits + hostAddrInput.addEventListener("keydown", (e) => { + if ((e as KeyboardEvent).key === "Enter") handleSave(); + }, { signal: abortController.signal }); + + root.appendChild(overlay); + (nameInput as HTMLInputElement).focus(); + } + // --------------------------------------------------------------------------- // State transitions // --------------------------------------------------------------------------- @@ -660,6 +813,18 @@ export function createConnectPage( showError, resetToIdle, updateHealthStatus, + /** Whether the "Remember Password" checkbox is checked. */ + getRememberPassword(): boolean { + return rememberPasswordCheckbox?.checked ?? false; + }, + /** Get the current password input value (for saving when remember is checked). */ + getPassword(): string { + return passwordInput?.value ?? ""; + }, + /** Re-render the server profile list with updated data. */ + refreshProfiles(profiles: readonly SimpleProfile[]): void { + renderServerProfiles(profiles); + }, }; } diff --git a/Client/tauri-client/src/pages/main-page/OverlayManagers.ts b/Client/tauri-client/src/pages/main-page/OverlayManagers.ts index 1f6bbe2b..6de08065 100644 --- a/Client/tauri-client/src/pages/main-page/OverlayManagers.ts +++ b/Client/tauri-client/src/pages/main-page/OverlayManagers.ts @@ -79,7 +79,6 @@ export function createQuickSwitcherManager( onSelectChannel: (channelId: number) => { setActiveChannel(channelId); }, - onSearch: () => {}, onClose: close, }); instance.mount(root); @@ -149,10 +148,15 @@ export function createInviteManagerController(opts: { return mapInviteResponse(created); }, onRevokeInvite: async (code: string) => { - const raw2 = await opts.api.getInvites(); - const match = raw2.find((i) => i.code === code); - if (match !== undefined) { - await opts.api.revokeInvite(match.id); + try { + const raw2 = await opts.api.getInvites(); + const match = raw2.find((i) => i.code === code); + if (match !== undefined) { + await opts.api.revokeInvite(match.id); + } + } catch (err) { + log.error("Invite revoke failed", { code, error: String(err) }); + throw err; } }, onCopyLink: (code: string) => { @@ -190,6 +194,7 @@ export function createPinnedPanelController(opts: { readonly getRoot: () => HTMLDivElement | null; readonly getToast: () => ToastContainer | null; readonly getCurrentChannelId: () => number | null; + readonly onJumpToMessage?: (messageId: number) => boolean; }): PinnedPanelController { let instance: MountableComponent | null = null; @@ -214,12 +219,25 @@ export function createPinnedPanelController(opts: { instance = createPinnedMessages({ channelId, pinnedMessages: pins, - onJumpToMessage: (_msgId: number) => { - close(); + onJumpToMessage: (msgId: number) => { + if (opts.onJumpToMessage !== undefined) { + const found = opts.onJumpToMessage(msgId); + if (found) { + close(); + } else { + opts.getToast()?.show("Message not in loaded window", "info"); + } + } else { + close(); + } }, onUnpin: (msgId: number) => { - void opts.api.unpinMessage(channelId, msgId); - close(); + void opts.api.unpinMessage(channelId, msgId).then(() => { + close(); + }).catch((err: unknown) => { + log.error("Failed to unpin message", { msgId, error: String(err) }); + opts.getToast()?.show("Failed to unpin message", "error"); + }); }, onClose: close, }); diff --git a/Client/tauri-client/src/stores/voice.store.ts b/Client/tauri-client/src/stores/voice.store.ts index 07f6b560..7009ea25 100644 --- a/Client/tauri-client/src/stores/voice.store.ts +++ b/Client/tauri-client/src/stores/voice.store.ts @@ -142,12 +142,28 @@ export function joinVoiceChannel(channelId: number): void { })); } -/** Clear the current voice channel (local leave). */ +/** Clear the current voice channel and remove current user from voice users. */ export function leaveVoiceChannel(): void { - voiceStore.setState((prev) => ({ - ...prev, - currentChannelId: null, - })); + const currentUserId = authStore.getState().user?.id ?? 0; + voiceStore.setState((prev) => { + const channelId = prev.currentChannelId; + if (channelId === null || currentUserId === 0) { + return { ...prev, currentChannelId: null }; + } + const existingChannel = prev.voiceUsers.get(channelId); + if (!existingChannel || !existingChannel.has(currentUserId)) { + return { ...prev, currentChannelId: null }; + } + const nextChannels = new Map(prev.voiceUsers); + const nextUsers = new Map(existingChannel); + nextUsers.delete(currentUserId); + if (nextUsers.size === 0) { + nextChannels.delete(channelId); + } else { + nextChannels.set(channelId, nextUsers); + } + return { ...prev, currentChannelId: null, voiceUsers: nextChannels }; + }); } /** Toggle local mute state. */ diff --git a/Client/tauri-client/src/styles/login.css b/Client/tauri-client/src/styles/login.css index f381e699..12ab5e81 100644 --- a/Client/tauri-client/src/styles/login.css +++ b/Client/tauri-client/src/styles/login.css @@ -284,6 +284,20 @@ .form-checkbox input:checked + .checkbox-box svg { opacity: 1; } .checkbox-label { font-size: 13px; color: var(--text-muted); } +/* Remember password */ +.remember-password-group { + display: flex; align-items: center; gap: 8px; + margin-bottom: 16px; margin-top: -8px; +} +.remember-password-group input[type="checkbox"] { + width: 16px; height: 16px; accent-color: var(--accent); + cursor: pointer; margin: 0; +} +.remember-password-label { + font-size: 13px; color: var(--text-muted); + cursor: pointer; user-select: none; +} + /* Primary button */ .btn-primary { width: 100%; padding: 12px; border-radius: var(--radius-sm); diff --git a/Client/tauri-client/tests/unit/cert-mismatch-modal.test.ts b/Client/tauri-client/tests/unit/cert-mismatch-modal.test.ts new file mode 100644 index 00000000..341e74d4 --- /dev/null +++ b/Client/tauri-client/tests/unit/cert-mismatch-modal.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createCertMismatchModal } from "../../src/components/CertMismatchModal"; +import { parseStoredFingerprint } from "../../src/lib/ws"; + +// --------------------------------------------------------------------------- +// parseStoredFingerprint +// --------------------------------------------------------------------------- + +describe("parseStoredFingerprint", () => { + it("extracts stored fingerprint from Rust message", () => { + const msg = + "Certificate fingerprint changed for localhost:8443.\n" + + "Stored: 51:32:d1:f9:61:47:e4:cc:26:6f:3a:87\n" + + "Current: 23:e4:00:61:11:f7:e5:12:eb:b9:2d:19\n" + + "This may indicate a man-in-the-middle attack."; + expect(parseStoredFingerprint(msg)).toBe( + "51:32:d1:f9:61:47:e4:cc:26:6f:3a:87", + ); + }); + + it("returns undefined for undefined message", () => { + expect(parseStoredFingerprint(undefined)).toBeUndefined(); + }); + + it("returns undefined when no Stored line present", () => { + expect(parseStoredFingerprint("some other message")).toBeUndefined(); + }); + + it("returns undefined for empty string", () => { + expect(parseStoredFingerprint("")).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// CertMismatchModal +// --------------------------------------------------------------------------- + +describe("CertMismatchModal", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + function mountModal(overrides?: Partial[0]>) { + const onAccept = vi.fn(); + const onReject = vi.fn(); + const modal = createCertMismatchModal({ + host: "localhost:8443", + storedFingerprint: "AA:BB:CC:DD", + newFingerprint: "11:22:33:44", + onAccept, + onReject, + ...overrides, + }); + modal.mount(container); + return { modal, onAccept, onReject }; + } + + it("renders a visible modal overlay", () => { + mountModal(); + const overlay = container.querySelector(".modal-overlay"); + expect(overlay).not.toBeNull(); + expect(overlay!.classList.contains("visible")).toBe(true); + }); + + it("displays the host in the details", () => { + mountModal(); + const values = container.querySelectorAll(".cert-value"); + const texts = Array.from(values).map((el) => el.textContent); + expect(texts).toContain("localhost:8443"); + }); + + it("displays stored and new fingerprints", () => { + mountModal(); + const fps = container.querySelectorAll(".cert-fingerprint"); + const texts = Array.from(fps).map((el) => el.textContent); + expect(texts).toContain("AA:BB:CC:DD"); + expect(texts).toContain("11:22:33:44"); + }); + + it("shows 'Unknown' when storedFingerprint is empty", () => { + mountModal({ storedFingerprint: "" }); + const fps = container.querySelectorAll(".cert-fingerprint"); + const texts = Array.from(fps).map((el) => el.textContent); + expect(texts).toContain("Unknown"); + }); + + it("calls onAccept when accept button is clicked", () => { + const { onAccept } = mountModal(); + const btn = container.querySelector(".btn-danger") as HTMLButtonElement; + expect(btn).not.toBeNull(); + btn.click(); + expect(onAccept).toHaveBeenCalledOnce(); + }); + + it("calls onReject when disconnect button is clicked", () => { + const { onReject } = mountModal(); + const btn = container.querySelector(".btn-ghost") as HTMLButtonElement; + expect(btn).not.toBeNull(); + btn.click(); + expect(onReject).toHaveBeenCalledOnce(); + }); + + it("calls onReject when close X button is clicked", () => { + const { onReject } = mountModal(); + const btn = container.querySelector(".modal-close") as HTMLButtonElement; + expect(btn).not.toBeNull(); + btn.click(); + expect(onReject).toHaveBeenCalledOnce(); + }); + + it("calls onReject when backdrop is clicked", () => { + const { onReject } = mountModal(); + const overlay = container.querySelector(".modal-overlay") as HTMLDivElement; + overlay.click(); + expect(onReject).toHaveBeenCalledOnce(); + }); + + it("does not call onReject when modal body is clicked", () => { + const { onReject } = mountModal(); + const modal = container.querySelector(".modal") as HTMLDivElement; + modal.click(); + expect(onReject).not.toHaveBeenCalled(); + }); + + it("destroy removes the modal from the DOM", () => { + const { modal } = mountModal(); + expect(container.querySelector(".modal-overlay")).not.toBeNull(); + modal.destroy?.(); + expect(container.querySelector(".modal-overlay")).toBeNull(); + }); + + it("displays the title 'Certificate Warning'", () => { + mountModal(); + const title = container.querySelector(".modal-header h3"); + expect(title?.textContent).toBe("Certificate Warning"); + }); + + it("displays the cert title 'Certificate Changed'", () => { + mountModal(); + const title = container.querySelector(".cert-title"); + expect(title?.textContent).toBe("Certificate Changed"); + }); +}); diff --git a/Client/tauri-client/tests/unit/channel-sidebar.test.ts b/Client/tauri-client/tests/unit/channel-sidebar.test.ts index 0537bdc9..f146c314 100644 --- a/Client/tauri-client/tests/unit/channel-sidebar.test.ts +++ b/Client/tauri-client/tests/unit/channel-sidebar.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { createChannelSidebar } from "../../src/components/ChannelSidebar"; import { channelsStore, @@ -7,6 +7,8 @@ import { } from "../../src/stores/channels.store"; import { authStore } from "../../src/stores/auth.store"; import { uiStore, toggleCategory } from "../../src/stores/ui.store"; +import { voiceStore, updateVoiceState } from "../../src/stores/voice.store"; +import { membersStore } from "../../src/stores/members.store"; import type { ReadyChannel } from "../../src/lib/types"; function resetStores(): void { @@ -32,6 +34,17 @@ function resetStores(): void { persistentError: null, collapsedCategories: new Set(), })); + voiceStore.setState(() => ({ + currentChannelId: null, + voiceUsers: new Map(), + voiceConfigs: new Map(), + localMuted: false, + localDeafened: false, + })); + membersStore.setState(() => ({ + members: new Map(), + typingUsers: new Map(), + })); } const testChannels: ReadyChannel[] = [ @@ -74,12 +87,16 @@ const testChannels: ReadyChannel[] = [ describe("ChannelSidebar", () => { let container: HTMLDivElement; let sidebar: ReturnType; + let onVoiceJoin: ReturnType; + let onVoiceLeave: ReturnType; beforeEach(() => { resetStores(); container = document.createElement("div"); document.body.appendChild(container); - sidebar = createChannelSidebar(); + onVoiceJoin = vi.fn(); + onVoiceLeave = vi.fn(); + sidebar = createChannelSidebar({ onVoiceJoin, onVoiceLeave }); }); afterEach(() => { @@ -213,4 +230,115 @@ describe("ChannelSidebar", () => { const icon = voiceItem?.querySelector(".ch-icon"); expect(icon).not.toBeNull(); }); + + it("clicking voice channel calls onVoiceJoin instead of setActiveChannel", () => { + setChannels(testChannels); + sidebar.mount(container); + + const voiceItem = container.querySelector( + '[data-channel-id="3"]', + ) as HTMLElement; + voiceItem.click(); + + // Should NOT set active channel + expect(channelsStore.getState().activeChannelId).toBeNull(); + // Should call onVoiceJoin with channel id + expect(onVoiceJoin).toHaveBeenCalledWith(3); + }); + + it("clicking text channel still sets active channel normally", () => { + setChannels(testChannels); + sidebar.mount(container); + + const textItem = container.querySelector( + '[data-channel-id="1"]', + ) as HTMLElement; + textItem.click(); + + expect(channelsStore.getState().activeChannelId).toBe(1); + expect(onVoiceJoin).not.toHaveBeenCalled(); + }); + + it("clicking joined voice channel calls onVoiceLeave", () => { + setChannels(testChannels); + voiceStore.setState((prev) => ({ ...prev, currentChannelId: 3 })); + sidebar.mount(container); + + const voiceItem = container.querySelector( + '[data-channel-id="3"]', + ) as HTMLElement; + voiceItem.click(); + + expect(onVoiceLeave).toHaveBeenCalled(); + expect(onVoiceJoin).not.toHaveBeenCalled(); + }); + + it("shows connected voice users under voice channel", () => { + setChannels(testChannels); + // Add a member so username resolves + membersStore.setState((prev) => ({ + ...prev, + members: new Map([[10, { id: 10, username: "Alice", avatar: null, role: "member", status: "online" as const }]]), + })); + updateVoiceState({ + channel_id: 3, + user_id: 10, + username: "Alice", + muted: false, + deafened: false, + speaking: false, + camera: false, + screenshare: false, + }); + sidebar.mount(container); + + const voiceUsersList = container.querySelector(".voice-users-list"); + expect(voiceUsersList).not.toBeNull(); + + const userItems = container.querySelectorAll(".voice-user-item"); + expect(userItems.length).toBe(1); + + const userName = userItems[0]?.querySelector(".vu-name"); + expect(userName?.textContent).toBe("Alice"); + }); + + it("highlights voice channel as active when user is joined", () => { + setChannels(testChannels); + voiceStore.setState((prev) => ({ ...prev, currentChannelId: 3 })); + sidebar.mount(container); + + const voiceItem = container.querySelector( + '[data-channel-id="3"]', + ); + expect(voiceItem?.classList.contains("active")).toBe(true); + }); + + it("re-renders when voice store changes", () => { + setChannels(testChannels); + sidebar.mount(container); + + // Initially no voice users + let voiceUsers = container.querySelectorAll(".voice-user-item"); + expect(voiceUsers.length).toBe(0); + + // Add a voice user + updateVoiceState({ + channel_id: 3, + user_id: 20, + username: "Bob", + muted: true, + deafened: false, + speaking: false, + camera: false, + screenshare: false, + }); + voiceStore.flush(); + + voiceUsers = container.querySelectorAll(".voice-user-item"); + expect(voiceUsers.length).toBe(1); + + // Should show muted icon + const mutedIcon = voiceUsers[0]?.querySelector(".vu-muted"); + expect(mutedIcon).not.toBeNull(); + }); }); diff --git a/Client/tauri-client/tests/unit/connect-page.test.ts b/Client/tauri-client/tests/unit/connect-page.test.ts index 3c0503aa..fbc7b546 100644 --- a/Client/tauri-client/tests/unit/connect-page.test.ts +++ b/Client/tauri-client/tests/unit/connect-page.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createConnectPage } from "../../src/pages/ConnectPage"; -import type { ConnectPageCallbacks, ServerProfile } from "../../src/pages/ConnectPage"; +import type { ConnectPageCallbacks, SimpleProfile } from "../../src/pages/ConnectPage"; import { uiStore } from "../../src/stores/ui.store"; // Mock SettingsOverlay so we don't pull in all its dependencies @@ -32,7 +32,7 @@ function makeCallbacks(overrides: Partial = {}): ConnectPa }; } -const testProfiles: ServerProfile[] = [ +const testProfiles: SimpleProfile[] = [ { name: "Test Server", host: "localhost:8443" }, ]; diff --git a/Client/tauri-client/tests/unit/message-input.test.ts b/Client/tauri-client/tests/unit/message-input.test.ts index 11010fe8..42f0ee3f 100644 --- a/Client/tauri-client/tests/unit/message-input.test.ts +++ b/Client/tauri-client/tests/unit/message-input.test.ts @@ -212,6 +212,19 @@ describe("MessageInput", () => { comp.destroy?.(); }); + it("attach button is disabled with tooltip", () => { + const opts = makeOptions(); + const comp = createMessageInput(opts); + comp.mount(container); + + const attachBtn = container.querySelector(".attach-btn") as HTMLButtonElement; + expect(attachBtn).not.toBeNull(); + expect(attachBtn.disabled).toBe(true); + expect(attachBtn.title).toBe("File uploads coming soon"); + + comp.destroy?.(); + }); + it("debounces rapid sends", () => { vi.useFakeTimers(); const opts = makeOptions(); diff --git a/Client/tauri-client/tests/unit/message-list.test.ts b/Client/tauri-client/tests/unit/message-list.test.ts index 6256f809..1205e639 100644 --- a/Client/tauri-client/tests/unit/message-list.test.ts +++ b/Client/tauri-client/tests/unit/message-list.test.ts @@ -49,9 +49,11 @@ function setHasMore(channelId: number, value: boolean): void { }); } +export type MessageListComponent = ReturnType; + describe("MessageList", () => { let container: HTMLDivElement; - let msgList: ReturnType; + let msgList: MessageListComponent; let options: MessageListOptions; beforeEach(() => { @@ -128,6 +130,27 @@ describe("MessageList", () => { expect(content!.children.length).toBeGreaterThan(0); }); + it("scrollToMessage returns true when message exists in virtual items", () => { + const messages = [ + makeMessage({ id: 1, content: "Hello" }), + makeMessage({ id: 2, content: "Target message" }), + makeMessage({ id: 3, content: "World" }), + ]; + setMessages(1, messages); + msgList.mount(container); + + const result = msgList.scrollToMessage(2); + expect(result).toBe(true); + }); + + it("scrollToMessage returns false when message not found", () => { + setMessages(1, [makeMessage({ id: 1 })]); + msgList.mount(container); + + const result = msgList.scrollToMessage(999); + expect(result).toBe(false); + }); + it("renders day dividers between messages on different days", () => { const messages = [ makeMessage({ id: 1, timestamp: "2024-01-15T12:00:00Z" }), diff --git a/Client/tauri-client/tests/unit/overlay-managers.test.ts b/Client/tauri-client/tests/unit/overlay-managers.test.ts new file mode 100644 index 00000000..75c9876e --- /dev/null +++ b/Client/tauri-client/tests/unit/overlay-managers.test.ts @@ -0,0 +1,361 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { Mock } from "vitest"; + +// --------------------------------------------------------------------------- +// Mocks (vi.hoisted so they're available in vi.mock factories) +// --------------------------------------------------------------------------- + +const { + mockLogError, + mockInviteManagerMount, + mockInviteManagerDestroy, + mockPinnedMessagesMount, + mockPinnedMessagesDestroy, +} = vi.hoisted(() => ({ + mockLogError: vi.fn(), + mockInviteManagerMount: vi.fn(), + mockInviteManagerDestroy: vi.fn(), + mockPinnedMessagesMount: vi.fn(), + mockPinnedMessagesDestroy: vi.fn(), +})); + +vi.mock("@lib/logger", () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: mockLogError, + }), +})); + +vi.mock("@components/QuickSwitcher", () => ({ + createQuickSwitcher: vi.fn(() => ({ + mount: vi.fn(), + destroy: vi.fn(), + })), +})); + +vi.mock("@components/InviteManager", () => ({ + createInviteManager: vi.fn(() => ({ + mount: mockInviteManagerMount, + destroy: mockInviteManagerDestroy, + })), +})); + +vi.mock("@components/PinnedMessages", () => ({ + createPinnedMessages: vi.fn(() => ({ + mount: mockPinnedMessagesMount, + destroy: mockPinnedMessagesDestroy, + })), +})); + +vi.mock("@stores/channels.store", () => ({ + setActiveChannel: vi.fn(), +})); + +// --------------------------------------------------------------------------- +// Imports (after mocks) +// --------------------------------------------------------------------------- + +import { createInviteManager } from "@components/InviteManager"; +import { createPinnedMessages } from "@components/PinnedMessages"; +import { + createInviteManagerController, + createPinnedPanelController, +} from "@pages/main-page/OverlayManagers"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeInviteResponse(overrides: Record = {}) { + return { + id: 1, + code: "abc123xyz", + url: "https://example.com/abc123xyz", + max_uses: 10, + use_count: 3, + expires_at: null, + ...overrides, + }; +} + +function makeMockApi(overrides: Record = {}) { + return { + getInvites: vi.fn().mockResolvedValue([makeInviteResponse()]), + createInvite: vi.fn().mockResolvedValue(makeInviteResponse({ code: "new123" })), + revokeInvite: vi.fn().mockResolvedValue(undefined), + getPins: vi.fn().mockResolvedValue({ + messages: [ + { id: 1, user: { username: "Alice" }, content: "Pinned msg", created_at: "2024-01-01" }, + ], + }), + unpinMessage: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +function makeMockToast() { + return { show: vi.fn() }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("createInviteManagerController", () => { + let root: HTMLDivElement; + + beforeEach(() => { + root = document.createElement("div"); + document.body.appendChild(root); + vi.clearAllMocks(); + }); + + afterEach(() => { + root.remove(); + }); + + it("opens invite manager and mounts to root", async () => { + const api = makeMockApi(); + const toast = makeMockToast(); + + const controller = createInviteManagerController({ + api: api as never, + getRoot: () => root, + getToast: () => toast as never, + }); + + await controller.open(); + + expect(createInviteManager).toHaveBeenCalledOnce(); + expect(mockInviteManagerMount).toHaveBeenCalledWith(root); + }); + + it("onRevokeInvite catches API error and re-throws for component handling", async () => { + const api = makeMockApi({ + revokeInvite: vi.fn().mockRejectedValue(new Error("network error")), + }); + const toast = makeMockToast(); + + const controller = createInviteManagerController({ + api: api as never, + getRoot: () => root, + getToast: () => toast as never, + }); + + await controller.open(); + + // Extract the onRevokeInvite callback passed to InviteManager + const opts = (createInviteManager as Mock).mock.calls[0]![0] as { + onRevokeInvite: (code: string) => Promise; + }; + + // The callback should re-throw so InviteManager's catch prevents optimistic removal + await expect(opts.onRevokeInvite("abc123xyz")).rejects.toThrow("network error"); + + // Controller should log the error with context + expect(mockLogError).toHaveBeenCalled(); + }); + + it("onRevokeInvite succeeds normally when API works", async () => { + const api = makeMockApi(); + const toast = makeMockToast(); + + const controller = createInviteManagerController({ + api: api as never, + getRoot: () => root, + getToast: () => toast as never, + }); + + await controller.open(); + + const opts = (createInviteManager as Mock).mock.calls[0]![0] as { + onRevokeInvite: (code: string) => Promise; + }; + + await expect(opts.onRevokeInvite("abc123xyz")).resolves.toBeUndefined(); + expect(mockLogError).not.toHaveBeenCalled(); + }); + + it("shows toast when open fails to load invites", async () => { + const api = makeMockApi({ + getInvites: vi.fn().mockRejectedValue(new Error("load failed")), + }); + const toast = makeMockToast(); + + const controller = createInviteManagerController({ + api: api as never, + getRoot: () => root, + getToast: () => toast as never, + }); + + await controller.open(); + + expect(toast.show).toHaveBeenCalledWith("Failed to load invites", "error"); + }); +}); + +describe("createPinnedPanelController", () => { + let root: HTMLDivElement; + + beforeEach(() => { + root = document.createElement("div"); + document.body.appendChild(root); + vi.clearAllMocks(); + }); + + afterEach(() => { + root.remove(); + }); + + it("toggles pinned panel open and mounts to root", async () => { + const api = makeMockApi(); + const toast = makeMockToast(); + + const controller = createPinnedPanelController({ + api: api as never, + getRoot: () => root, + getToast: () => toast as never, + getCurrentChannelId: () => 42, + }); + + await controller.toggle(); + + expect(createPinnedMessages).toHaveBeenCalledOnce(); + expect(mockPinnedMessagesMount).toHaveBeenCalledWith(root); + }); + + it("onUnpin catches API error, shows toast, and does NOT close the panel", async () => { + const api = makeMockApi({ + unpinMessage: vi.fn().mockRejectedValue(new Error("unpin failed")), + }); + const toast = makeMockToast(); + + const controller = createPinnedPanelController({ + api: api as never, + getRoot: () => root, + getToast: () => toast as never, + getCurrentChannelId: () => 42, + }); + + await controller.toggle(); + + // Extract onUnpin callback passed to PinnedMessages + const opts = (createPinnedMessages as Mock).mock.calls[0]![0] as { + onUnpin: (msgId: number) => void; + }; + + // Call onUnpin — it should handle the error internally + opts.onUnpin(1); + + // Wait for the async error handling to complete + await vi.waitFor(() => { + expect(toast.show).toHaveBeenCalledWith("Failed to unpin message", "error"); + }); + + // Panel should NOT have been destroyed (still open) + expect(mockPinnedMessagesDestroy).not.toHaveBeenCalled(); + }); + + it("onUnpin closes panel on success", async () => { + const api = makeMockApi(); + const toast = makeMockToast(); + + const controller = createPinnedPanelController({ + api: api as never, + getRoot: () => root, + getToast: () => toast as never, + getCurrentChannelId: () => 42, + }); + + await controller.toggle(); + + const opts = (createPinnedMessages as Mock).mock.calls[0]![0] as { + onUnpin: (msgId: number) => void; + }; + + opts.onUnpin(1); + + // Wait for the async success handling to complete + await vi.waitFor(() => { + expect(mockPinnedMessagesDestroy).toHaveBeenCalled(); + }); + + // No error toast should be shown + expect(toast.show).not.toHaveBeenCalled(); + }); + + it("onJumpToMessage calls provided scroll callback and closes panel", async () => { + const api = makeMockApi(); + const toast = makeMockToast(); + const mockScrollToMessage = vi.fn().mockReturnValue(true); + + const controller = createPinnedPanelController({ + api: api as never, + getRoot: () => root, + getToast: () => toast as never, + getCurrentChannelId: () => 42, + onJumpToMessage: mockScrollToMessage, + }); + + await controller.toggle(); + + const opts = (createPinnedMessages as Mock).mock.calls[0]![0] as { + onJumpToMessage: (msgId: number) => void; + }; + + opts.onJumpToMessage(1); + + expect(mockScrollToMessage).toHaveBeenCalledWith(1); + expect(mockPinnedMessagesDestroy).toHaveBeenCalled(); + }); + + it("onJumpToMessage shows toast when message not in loaded window", async () => { + const api = makeMockApi(); + const toast = makeMockToast(); + const mockScrollToMessage = vi.fn().mockReturnValue(false); + + const controller = createPinnedPanelController({ + api: api as never, + getRoot: () => root, + getToast: () => toast as never, + getCurrentChannelId: () => 42, + onJumpToMessage: mockScrollToMessage, + }); + + await controller.toggle(); + + const opts = (createPinnedMessages as Mock).mock.calls[0]![0] as { + onJumpToMessage: (msgId: number) => void; + }; + + opts.onJumpToMessage(999); + + expect(mockScrollToMessage).toHaveBeenCalledWith(999); + expect(toast.show).toHaveBeenCalledWith( + expect.stringContaining("not in"), + "info", + ); + // Panel should NOT close when message not found + expect(mockPinnedMessagesDestroy).not.toHaveBeenCalled(); + }); + + it("shows toast when toggle fails to load pins", async () => { + const api = makeMockApi({ + getPins: vi.fn().mockRejectedValue(new Error("load failed")), + }); + const toast = makeMockToast(); + + const controller = createPinnedPanelController({ + api: api as never, + getRoot: () => root, + getToast: () => toast as never, + getCurrentChannelId: () => 42, + }); + + await controller.toggle(); + + expect(toast.show).toHaveBeenCalledWith("Failed to load pinned messages", "error"); + }); +}); diff --git a/Client/tauri-client/tests/unit/profiles.test.ts b/Client/tauri-client/tests/unit/profiles.test.ts index 918688d4..fa79fb83 100644 --- a/Client/tauri-client/tests/unit/profiles.test.ts +++ b/Client/tauri-client/tests/unit/profiles.test.ts @@ -71,6 +71,7 @@ const sampleData: CreateProfileData = { username: "alice", color: "#ff5500", autoConnect: false, + rememberPassword: false, }; const sampleData2: CreateProfileData = { @@ -79,6 +80,7 @@ const sampleData2: CreateProfileData = { username: "bob", color: "#00aaff", autoConnect: true, + rememberPassword: false, }; // --------------------------------------------------------------------------- @@ -389,6 +391,7 @@ describe("ProfileManager", () => { username: "charlie", color: "#000000", autoConnect: false, + rememberPassword: false, lastConnected: null, }, { @@ -398,6 +401,7 @@ describe("ProfileManager", () => { username: "dave", color: "#ffffff", autoConnect: false, + rememberPassword: false, lastConnected: null, }, ]; @@ -424,8 +428,8 @@ describe("ProfileManager", () => { it("rejects import entries with invalid shape", () => { const m = mgr(); const badEntries = [ - { id: "x", name: "", host: "a", username: "b", color: "#000", autoConnect: false, lastConnected: null }, - { id: "y", name: "Valid", host: "valid.com:443", username: "u", color: "#fff", autoConnect: false, lastConnected: null }, + { id: "x", name: "", host: "a", username: "b", color: "#000", autoConnect: false, rememberPassword: false, lastConnected: null }, + { id: "y", name: "Valid", host: "valid.com:443", username: "u", color: "#fff", autoConnect: false, rememberPassword: false, lastConnected: null }, ]; const result = m.importProfiles(JSON.stringify(badEntries)); expect(result.imported).toBe(1); @@ -475,6 +479,7 @@ describe("ProfileManager", () => { username: "eve", color: "#112233", autoConnect: false, + rememberPassword: false, lastConnected: "2026-01-01T00:00:00.000Z", }, ], diff --git a/Client/tauri-client/tests/unit/quick-switcher.test.ts b/Client/tauri-client/tests/unit/quick-switcher.test.ts index a42db5eb..09c57984 100644 --- a/Client/tauri-client/tests/unit/quick-switcher.test.ts +++ b/Client/tauri-client/tests/unit/quick-switcher.test.ts @@ -22,7 +22,6 @@ describe("QuickSwitcher", () => { let container: HTMLDivElement; let switcher: ReturnType; let onSelectChannel: ReturnType; - let onSearch: ReturnType; let onClose: ReturnType; beforeEach(() => { @@ -31,9 +30,8 @@ describe("QuickSwitcher", () => { container = document.createElement("div"); document.body.appendChild(container); onSelectChannel = vi.fn(); - onSearch = vi.fn(); onClose = vi.fn(); - switcher = createQuickSwitcher({ onSelectChannel, onSearch, onClose }); + switcher = createQuickSwitcher({ onSelectChannel, onClose }); }); afterEach(() => { @@ -79,14 +77,17 @@ describe("QuickSwitcher", () => { expect(name?.textContent).toBe("general"); }); - it("calls onSearch when typing", () => { + it("filters channels without calling external search (client-side only)", () => { switcher.mount(container); const input = container.querySelector(".quick-switcher__input") as HTMLInputElement; input.value = "random"; input.dispatchEvent(new Event("input")); - expect(onSearch).toHaveBeenCalledWith("random"); + // Filtering should work client-side + const items = container.querySelectorAll(".quick-switcher__item"); + expect(items.length).toBe(1); + expect(items[0]!.querySelector(".quick-switcher__name")?.textContent).toBe("random"); }); it("clicking a channel calls onSelectChannel and onClose", () => { diff --git a/Server/api/router.go b/Server/api/router.go index f7103ac3..2e598a5f 100644 --- a/Server/api/router.go +++ b/Server/api/router.go @@ -28,6 +28,7 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string) http.Handler { // any source allows IP spoofing for rate-limit bypass. IP header trust is now // handled explicitly in clientIPWithProxies using the trusted_proxies config. r.Use(middleware.Recoverer) + r.Use(requestLogger) // structured request/response logging r.Use(SecurityHeaders) r.Use(MaxBodySize(1 << 20)) // 1 MiB default; upload routes use their own limit @@ -122,6 +123,37 @@ func setRequestIDHeader(next http.Handler) http.Handler { }) } +// requestLogger logs every HTTP request with method, path, status, and duration. +// Health checks are logged at Debug level to avoid noise. +func requestLogger(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + next.ServeHTTP(ww, r) + elapsed := time.Since(start) + status := ww.Status() + + // Health checks at Debug level; errors at Warn; everything else at Info. + path := r.URL.Path + attrs := []any{ + "method", r.Method, + "path", path, + "status", status, + "duration_ms", elapsed.Milliseconds(), + } + switch { + case path == "/health" || path == "/api/v1/health": + slog.Debug("http request", attrs...) + case status >= 500: + slog.Error("http request", attrs...) + case status >= 400: + slog.Warn("http request", attrs...) + default: + slog.Info("http request", attrs...) + } + }) +} + // writeJSON encodes v as JSON and writes it to w with the given status code. func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") diff --git a/Server/db/auth_queries.go b/Server/db/auth_queries.go index 16655035..e105a88f 100644 --- a/Server/db/auth_queries.go +++ b/Server/db/auth_queries.go @@ -337,7 +337,7 @@ type MemberSummary struct { // ListMembers returns all non-banned users as lightweight summaries. func (d *DB) ListMembers() ([]MemberSummary, error) { rows, err := d.sqlDB.Query( - `SELECT u.id, u.username, u.avatar, u.status, r.name + `SELECT u.id, u.username, u.avatar, u.status, LOWER(r.name) FROM users u JOIN roles r ON u.role_id = r.id WHERE u.banned = 0 diff --git a/Server/ws/handlers.go b/Server/ws/handlers.go index 158c3de9..f5f39fd7 100644 --- a/Server/ws/handlers.go +++ b/Server/ws/handlers.go @@ -76,6 +76,8 @@ func (h *Hub) handleMessage(c *Client, raw []byte) { return } + slog.Debug("ws ← client message", "type", env.Type, "user_id", c.userID, "id", env.ID) + switch env.Type { case "chat_send": h.handleChatSend(c, env.ID, env.Payload) @@ -476,6 +478,7 @@ func (h *Hub) requireChannelPerm(c *Client, channelID int64, perm int64, permLab if h.hasChannelPerm(c, channelID, perm) { return true } + slog.Warn("ws permission denied", "user_id", c.userID, "channel_id", channelID, "perm", permLabel) c.sendMsg(buildErrorMsg("FORBIDDEN", "missing "+permLabel+" permission")) return false } @@ -504,6 +507,7 @@ func (h *Hub) broadcastExclude(channelID, excludeUserID int64, msg []byte) { func (h *Hub) handleChannelFocus(c *Client, payload json.RawMessage) { chID, err := parseChannelID(payload) if err != nil || chID <= 0 { + slog.Debug("handleChannelFocus: invalid channel_id", "user_id", c.userID, "err", err) return } @@ -513,9 +517,12 @@ func (h *Hub) handleChannelFocus(c *Client, payload json.RawMessage) { } c.mu.Lock() + prevCh := c.channelID c.channelID = chID c.mu.Unlock() + slog.Info("channel_focus", "user_id", c.userID, "channel_id", chID, "prev_channel_id", prevCh) + // Mark channel as read by updating read_states to the latest message. latestID, latestErr := h.db.GetLatestMessageID(chID) if latestErr == nil && latestID > 0 { diff --git a/Server/ws/serve.go b/Server/ws/serve.go index 966a86a9..4c93224e 100644 --- a/Server/ws/serve.go +++ b/Server/ws/serve.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "net/http" + "strings" "time" "nhooyr.io/websocket" @@ -48,7 +49,7 @@ func ServeWS(hub *Hub, database *db.DB, allowedOrigins []string) http.HandlerFun // Look up role name for protocol-compliant payloads and cache on client. roleName := "member" if role, roleErr := database.GetRoleByID(user.RoleID); roleErr == nil && role != nil { - roleName = role.Name + roleName = strings.ToLower(role.Name) } c.roleName = roleName @@ -62,8 +63,10 @@ func ServeWS(hub *Hub, database *db.DB, allowedOrigins []string) http.HandlerFun // Send auth_ok followed by the ready payload. ctx := r.Context() + slog.Info("ws sending auth_ok", "user_id", user.ID, "username", user.Username, "role", roleName) _ = conn.Write(ctx, websocket.MessageText, hub.buildAuthOK(user, roleName)) if ready, readyErr := hub.buildReady(database, user.ID); readyErr == nil { + slog.Info("ws sending ready payload", "user_id", user.ID, "payload_bytes", len(ready)) _ = conn.Write(ctx, websocket.MessageText, ready) } else { slog.Error("buildReady failed", "user_id", user.ID, "err", readyErr) @@ -71,6 +74,7 @@ func ServeWS(hub *Hub, database *db.DB, allowedOrigins []string) http.HandlerFun buildErrorMsg("INTERNAL", "failed to build ready payload")) } + slog.Info("ws broadcasting member_join and presence", "user_id", user.ID, "username", user.Username) hub.BroadcastToAll(buildMemberJoin(user, roleName)) hub.BroadcastToAll(buildPresenceMsg(user.ID, "online"))