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
This commit is contained in:
jevb
2026-03-17 20:25:15 +01:00
parent aa2410e9ea
commit 6f35973c1b
31 changed files with 1368 additions and 88 deletions
+1
View File
@@ -40,3 +40,4 @@ Client/publish-release/
# HTML mockups (large design reference files)
Client/login-mockup.html
Client/ui-mockup.html
.gstack/
@@ -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<String>,
}
/// Build the target name used in Windows Credential Manager.
@@ -34,7 +37,7 @@ fn to_wide(s: &str) -> Vec<u16> {
/// 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<String>) -> 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<Option<CredentialData>, 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
@@ -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 = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>';
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;
}
@@ -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 {
@@ -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;
@@ -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",
@@ -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 };
}
@@ -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();
+6 -1
View File
@@ -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);
}
+9 -2
View File
@@ -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<boolean> {
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<string, unknown>;
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;
@@ -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);
}),
);
+3
View File
@@ -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);
+14 -6
View File
@@ -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");
+9
View File
@@ -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<string, never>;
export interface VoiceMutePayload {
readonly muted: boolean;
}
@@ -431,8 +438,10 @@ export type ClientMessage =
| (WsEnvelope<ReactionAddPayload> & { readonly type: "reaction_add" })
| (WsEnvelope<ReactionRemovePayload> & { readonly type: "reaction_remove" })
| (WsEnvelope<TypingStartPayload> & { readonly type: "typing_start" })
| (WsEnvelope<ChannelFocusPayload> & { readonly type: "channel_focus" })
| (WsEnvelope<PresenceUpdatePayload> & { readonly type: "presence_update" })
| (WsEnvelope<VoiceJoinPayload> & { readonly type: "voice_join" })
| (WsEnvelope<VoiceLeaveClientPayload> & { readonly type: "voice_leave" })
| (WsEnvelope<VoiceMutePayload> & { readonly type: "voice_mute" })
| (WsEnvelope<VoiceDeafenPayload> & { readonly type: "voice_deafen" })
| (WsEnvelope<VoiceCameraPayload> & { readonly type: "voice_camera" })
+50 -19
View File
@@ -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<typeof msg.type>)(
msg.payload as Extract<ServerMessage, { type: typeof msg.type }>["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<typeof msg.type>)(
msg.payload as Extract<ServerMessage, { type: typeof msg.type }>["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;
}
+178 -13
View File
@@ -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<void>;
onTotpSubmit(code: string): Promise<void>;
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<string, { dot: HTMLDivElement; latency: HTMLSpanElement }>();
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<ServerProfile>;
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);
},
};
}
@@ -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,
});
+21 -5
View File
@@ -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. */
+14
View File
@@ -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);
@@ -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<Parameters<typeof createCertMismatchModal>[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");
});
});
@@ -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<string>(),
}));
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<typeof createChannelSidebar>;
let onVoiceJoin: ReturnType<typeof vi.fn>;
let onVoiceLeave: ReturnType<typeof vi.fn>;
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();
});
});
@@ -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<ConnectPageCallbacks> = {}): ConnectPa
};
}
const testProfiles: ServerProfile[] = [
const testProfiles: SimpleProfile[] = [
{ name: "Test Server", host: "localhost:8443" },
];
@@ -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();
@@ -49,9 +49,11 @@ function setHasMore(channelId: number, value: boolean): void {
});
}
export type MessageListComponent = ReturnType<typeof createMessageList>;
describe("MessageList", () => {
let container: HTMLDivElement;
let msgList: ReturnType<typeof createMessageList>;
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" }),
@@ -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<string, unknown> = {}) {
return {
id: 1,
code: "abc123xyz",
url: "https://example.com/abc123xyz",
max_uses: 10,
use_count: 3,
expires_at: null,
...overrides,
};
}
function makeMockApi(overrides: Record<string, unknown> = {}) {
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<void>;
};
// 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<void>;
};
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");
});
});
@@ -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",
},
],
@@ -22,7 +22,6 @@ describe("QuickSwitcher", () => {
let container: HTMLDivElement;
let switcher: ReturnType<typeof createQuickSwitcher>;
let onSelectChannel: ReturnType<typeof vi.fn>;
let onSearch: ReturnType<typeof vi.fn>;
let onClose: ReturnType<typeof vi.fn>;
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", () => {
+32
View File
@@ -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")
+1 -1
View File
@@ -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
+7
View File
@@ -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 {
+5 -1
View File
@@ -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"))