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