mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
fix: voice session safety, delete confirm UX, image URL validation, test coverage (BUG-039 through BUG-045)
- BUG-039: switchOutputDevice continues loop on partial failure instead of early return - BUG-040: clearOnError() prevents stale callback after MainPage destroy - BUG-041: voice store tests cover localCamera, localScreenshare, setLocalSpeaking - BUG-042: auth store updateUser tests and UserBar mute/deafen callback tests - BUG-043: switchInputDevice guards against no active WebRTC session - BUG-044: replace synchronous confirm() with double-click-to-delete via toast - BUG-045: isSafeUrl() blocks javascript: URLs in image attachment src
This commit is contained in:
@@ -151,8 +151,17 @@ function isImageMime(mime: string): boolean {
|
||||
return mime.startsWith("image/");
|
||||
}
|
||||
|
||||
function isSafeUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin);
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderAttachment(att: Attachment): HTMLDivElement {
|
||||
if (isImageMime(att.mime)) {
|
||||
if (isImageMime(att.mime) && isSafeUrl(att.url)) {
|
||||
const wrap = createElement("div", { class: "msg-image" });
|
||||
const img = createElement("img", {
|
||||
src: att.url,
|
||||
|
||||
@@ -164,6 +164,11 @@ export function setOnError(cb: (message: string) => void): void {
|
||||
onErrorCallback = cb;
|
||||
}
|
||||
|
||||
/** Clear the error callback (call on component destroy to avoid stale refs). */
|
||||
export function clearOnError(): void {
|
||||
onErrorCallback = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch ICE servers (TURN/STUN credentials) for WebRTC.
|
||||
* Falls back to empty array on failure so voice still works on LAN.
|
||||
@@ -321,6 +326,11 @@ export function setDeafened(deafened: boolean): void {
|
||||
|
||||
/** Switch the input (microphone) device on an active session. */
|
||||
export async function switchInputDevice(deviceId: string): Promise<void> {
|
||||
// Don't acquire microphone if there's no active voice session
|
||||
if (webrtcService === null) {
|
||||
log.debug("Skipping input device switch — no active voice session");
|
||||
return;
|
||||
}
|
||||
if (audioManager === null) {
|
||||
audioManager = createAudioManager();
|
||||
}
|
||||
@@ -363,17 +373,20 @@ export async function switchInputDevice(deviceId: string): Promise<void> {
|
||||
|
||||
/** Switch the output (speaker) device on an active session. */
|
||||
export async function switchOutputDevice(deviceId: string): Promise<void> {
|
||||
let hadError = false;
|
||||
for (const el of audioElements.values()) {
|
||||
if (typeof el.setSinkId === "function") {
|
||||
try {
|
||||
await el.setSinkId(deviceId);
|
||||
} catch (err) {
|
||||
log.error("Failed to set output device on audio element", err);
|
||||
onErrorCallback?.("Failed to switch speaker");
|
||||
return;
|
||||
hadError = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hadError) {
|
||||
onErrorCallback?.("Failed to switch some audio to new speaker");
|
||||
}
|
||||
log.info("Switched output device", { deviceId });
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
setDeafened as voiceSessionSetDeafened,
|
||||
setWsClient,
|
||||
setOnError as setVoiceOnError,
|
||||
clearOnError as clearVoiceOnError,
|
||||
} from "@lib/voiceSession";
|
||||
import {
|
||||
setMessages,
|
||||
@@ -98,6 +99,9 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
// Track currently mounted channel to avoid redundant rebuilds
|
||||
let currentChannelId: number | null = null;
|
||||
|
||||
// Pending delete confirmations (double-click to delete pattern)
|
||||
const pendingDeletes = new Map<number, number>();
|
||||
|
||||
// Abort controller for channel-scoped async operations (e.g. message fetch)
|
||||
let channelAbort: AbortController | null = null;
|
||||
|
||||
@@ -208,12 +212,19 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
}
|
||||
},
|
||||
onDeleteClick: (msgId: number) => {
|
||||
if (!confirm("Delete this message?")) return;
|
||||
ws.send({
|
||||
type: "chat_delete",
|
||||
payload: { message_id: msgId },
|
||||
});
|
||||
toast?.show("Message deleted", "success");
|
||||
if (pendingDeletes.has(msgId)) {
|
||||
window.clearTimeout(pendingDeletes.get(msgId));
|
||||
pendingDeletes.delete(msgId);
|
||||
ws.send({
|
||||
type: "chat_delete",
|
||||
payload: { message_id: msgId },
|
||||
});
|
||||
toast?.show("Message deleted", "success");
|
||||
} else {
|
||||
toast?.show("Click delete again to confirm", "info");
|
||||
const tid = window.setTimeout(() => pendingDeletes.delete(msgId), 5000);
|
||||
pendingDeletes.set(msgId, tid);
|
||||
}
|
||||
},
|
||||
onReactionClick: (msgId: number, emoji: string) => {
|
||||
if (emoji === "") return;
|
||||
@@ -301,6 +312,11 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
}
|
||||
|
||||
function destroyChannelComponents(): void {
|
||||
for (const tid of pendingDeletes.values()) {
|
||||
window.clearTimeout(tid);
|
||||
}
|
||||
pendingDeletes.clear();
|
||||
|
||||
if (channelAbort !== null) {
|
||||
channelAbort.abort();
|
||||
channelAbort = null;
|
||||
@@ -596,6 +612,7 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
|
||||
function destroy(): void {
|
||||
log.info("MainPage destroying");
|
||||
clearVoiceOnError();
|
||||
destroyChannelComponents();
|
||||
|
||||
for (const child of children) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
clearAuth,
|
||||
getToken,
|
||||
getCurrentUser,
|
||||
updateUser,
|
||||
} from "../../src/stores/auth.store";
|
||||
import type { UserWithRole } from "../../src/lib/types";
|
||||
|
||||
@@ -128,7 +129,46 @@ describe("auth store", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// 5. getCurrentUser returns current user
|
||||
// 5. updateUser patches user fields
|
||||
describe("updateUser", () => {
|
||||
it("updates username on authenticated user", () => {
|
||||
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
|
||||
updateUser({ username: "newname" });
|
||||
expect(authStore.getState().user?.username).toBe("newname");
|
||||
});
|
||||
|
||||
it("preserves other user fields when patching", () => {
|
||||
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
|
||||
updateUser({ username: "newname" });
|
||||
const user = authStore.getState().user;
|
||||
expect(user?.id).toBe(42);
|
||||
expect(user?.avatar).toBe("avatar.png");
|
||||
expect(user?.role).toBe("member");
|
||||
});
|
||||
|
||||
it("is a no-op when user is null", () => {
|
||||
updateUser({ username: "newname" });
|
||||
expect(authStore.getState().user).toBeNull();
|
||||
});
|
||||
|
||||
it("produces a new state object", () => {
|
||||
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
|
||||
const before = authStore.getState();
|
||||
updateUser({ username: "changed" });
|
||||
expect(authStore.getState()).not.toBe(before);
|
||||
});
|
||||
|
||||
it("produces a new user object (immutable)", () => {
|
||||
setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
|
||||
const userBefore = authStore.getState().user;
|
||||
updateUser({ avatar: "new-avatar.png" });
|
||||
const userAfter = authStore.getState().user;
|
||||
expect(userBefore).not.toBe(userAfter);
|
||||
expect(userAfter?.avatar).toBe("new-avatar.png");
|
||||
});
|
||||
});
|
||||
|
||||
// 6. getCurrentUser returns current user
|
||||
describe("getCurrentUser", () => {
|
||||
it("returns null when unauthenticated", () => {
|
||||
expect(getCurrentUser()).toBeNull();
|
||||
|
||||
@@ -101,6 +101,51 @@ describe("UserBar", () => {
|
||||
expect(openSettings).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("calls onMuteToggle when mute button clicked", () => {
|
||||
setAuthState({ username: "alice" }, true);
|
||||
const onMuteToggle = vi.fn();
|
||||
comp = createUserBar({ onMuteToggle });
|
||||
comp.mount(container);
|
||||
|
||||
const muteBtn = container.querySelector('[title="Mute"]') as HTMLButtonElement;
|
||||
muteBtn.click();
|
||||
expect(onMuteToggle).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("calls onDeafenToggle when deafen button clicked", () => {
|
||||
setAuthState({ username: "alice" }, true);
|
||||
const onDeafenToggle = vi.fn();
|
||||
comp = createUserBar({ onDeafenToggle });
|
||||
comp.mount(container);
|
||||
|
||||
const deafenBtn = container.querySelector('[title="Deafen"]') as HTMLButtonElement;
|
||||
deafenBtn.click();
|
||||
expect(onDeafenToggle).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not throw when mute/deafen clicked without callbacks", () => {
|
||||
setAuthState({ username: "alice" }, true);
|
||||
comp = createUserBar();
|
||||
comp.mount(container);
|
||||
|
||||
const muteBtn = container.querySelector('[title="Mute"]') as HTMLButtonElement;
|
||||
const deafenBtn = container.querySelector('[title="Deafen"]') as HTMLButtonElement;
|
||||
expect(() => muteBtn.click()).not.toThrow();
|
||||
expect(() => deafenBtn.click()).not.toThrow();
|
||||
});
|
||||
|
||||
it("stops responding to clicks after destroy", () => {
|
||||
setAuthState({ username: "alice" }, true);
|
||||
const onMuteToggle = vi.fn();
|
||||
comp = createUserBar({ onMuteToggle });
|
||||
comp.mount(container);
|
||||
|
||||
const muteBtn = container.querySelector('[title="Mute"]') as HTMLButtonElement;
|
||||
comp.destroy?.();
|
||||
muteBtn.click();
|
||||
expect(onMuteToggle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("destroy removes DOM and unsubscribes", () => {
|
||||
setAuthState({ username: "alice" }, true);
|
||||
comp = createUserBar();
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
leaveVoiceChannel,
|
||||
setLocalMuted,
|
||||
setLocalDeafened,
|
||||
setLocalCamera,
|
||||
setLocalScreenshare,
|
||||
setLocalSpeaking,
|
||||
getChannelVoiceUsers,
|
||||
} from "../../src/stores/voice.store";
|
||||
import type {
|
||||
@@ -15,6 +18,7 @@ import type {
|
||||
VoiceStatePayload,
|
||||
VoiceLeavePayload,
|
||||
} from "../../src/lib/types";
|
||||
import { authStore } from "../../src/stores/auth.store";
|
||||
|
||||
function resetStore(): void {
|
||||
voiceStore.setState(() => ({
|
||||
@@ -23,6 +27,8 @@ function resetStore(): void {
|
||||
voiceConfigs: new Map(),
|
||||
localMuted: false,
|
||||
localDeafened: false,
|
||||
localCamera: false,
|
||||
localScreenshare: false,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -79,6 +85,14 @@ describe("voice store", () => {
|
||||
it("has localDeafened false", () => {
|
||||
expect(voiceStore.getState().localDeafened).toBe(false);
|
||||
});
|
||||
|
||||
it("has localCamera false", () => {
|
||||
expect(voiceStore.getState().localCamera).toBe(false);
|
||||
});
|
||||
|
||||
it("has localScreenshare false", () => {
|
||||
expect(voiceStore.getState().localScreenshare).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setVoiceStates", () => {
|
||||
@@ -228,6 +242,68 @@ describe("voice store", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("setLocalCamera / setLocalScreenshare", () => {
|
||||
it("setLocalCamera sets camera to true", () => {
|
||||
setLocalCamera(true);
|
||||
expect(voiceStore.getState().localCamera).toBe(true);
|
||||
});
|
||||
|
||||
it("setLocalCamera sets camera to false", () => {
|
||||
setLocalCamera(true);
|
||||
setLocalCamera(false);
|
||||
expect(voiceStore.getState().localCamera).toBe(false);
|
||||
});
|
||||
|
||||
it("setLocalScreenshare sets screenshare to true", () => {
|
||||
setLocalScreenshare(true);
|
||||
expect(voiceStore.getState().localScreenshare).toBe(true);
|
||||
});
|
||||
|
||||
it("setLocalScreenshare sets screenshare to false", () => {
|
||||
setLocalScreenshare(true);
|
||||
setLocalScreenshare(false);
|
||||
expect(voiceStore.getState().localScreenshare).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setLocalSpeaking", () => {
|
||||
it("updates speaking state for current user in active channel", () => {
|
||||
// Set up: current user (id=1) in channel 10
|
||||
authStore.setState(() => ({
|
||||
token: "t",
|
||||
user: { id: 1, username: "me", avatar: "", role: "member" },
|
||||
serverName: "s",
|
||||
motd: "",
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
setVoiceStates([VOICE_STATE_1]);
|
||||
joinVoiceChannel(10);
|
||||
|
||||
setLocalSpeaking(true);
|
||||
const user = voiceStore.getState().voiceUsers.get(10)?.get(1);
|
||||
expect(user?.speaking).toBe(true);
|
||||
|
||||
setLocalSpeaking(false);
|
||||
const userAfter = voiceStore.getState().voiceUsers.get(10)?.get(1);
|
||||
expect(userAfter?.speaking).toBe(false);
|
||||
|
||||
// Cleanup
|
||||
authStore.setState(() => ({
|
||||
token: null,
|
||||
user: null,
|
||||
serverName: null,
|
||||
motd: null,
|
||||
isAuthenticated: false,
|
||||
}));
|
||||
});
|
||||
|
||||
it("is a no-op when not in a voice channel", () => {
|
||||
const before = voiceStore.getState();
|
||||
setLocalSpeaking(true);
|
||||
expect(voiceStore.getState()).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getChannelVoiceUsers", () => {
|
||||
it("returns all voice users for a channel", () => {
|
||||
setVoiceStates([VOICE_STATE_1, VOICE_STATE_2]);
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# Open Bugs
|
||||
|
||||
Bug tracker for the OwnCord project.
|
||||
|
||||
## Active
|
||||
|
||||
### Critical
|
||||
|
||||
(none)
|
||||
|
||||
### High
|
||||
|
||||
(none)
|
||||
|
||||
### Medium
|
||||
|
||||
(none)
|
||||
|
||||
## Resolved
|
||||
|
||||
- **BUG-039**: `switchOutputDevice` early return on partial failure — fixed 2026-03-18
|
||||
- Replaced `return` with error tracking; all elements attempted before reporting
|
||||
- **BUG-040**: Stale `onErrorCallback` after MainPage destroy — fixed 2026-03-18
|
||||
- Added `clearOnError()` export; MainPage calls it on destroy to prevent stale refs
|
||||
- **BUG-041**: Voice store `resetStore` missing new fields in tests — fixed 2026-03-18
|
||||
- Added `localCamera`/`localScreenshare` to resetStore; tests for setLocalCamera,
|
||||
setLocalScreenshare, setLocalSpeaking
|
||||
- **BUG-042**: `updateUser` and UserBar option callbacks untested — fixed 2026-03-18
|
||||
- Added updateUser tests to auth.store.test.ts; added mute/deafen callback tests
|
||||
to user-bar.test.ts
|
||||
- **BUG-043**: `switchInputDevice` triggers `getUserMedia` with no session — fixed 2026-03-18
|
||||
- Added `webrtcService === null` guard; skips mic acquisition when not in voice
|
||||
- **BUG-044**: `confirm()` blocks Tauri WebView renderer — fixed 2026-03-18
|
||||
- Replaced synchronous `confirm()` with double-click-to-delete pattern using toast
|
||||
- **BUG-045**: Image `att.url` not scheme-validated — fixed 2026-03-18
|
||||
- Added `isSafeUrl()` check; only http/https URLs render as images
|
||||
|
||||
- **BUG-031**: VoiceAudioTab device selection not applied to WebRTC — fixed 2026-03-18
|
||||
- Added `switchInputDevice`/`switchOutputDevice` to voiceSession; VoiceAudioTab
|
||||
calls on change
|
||||
- **BUG-032**: No WS handlers for channel_create/update/delete — closed 2026-03-18
|
||||
- Handlers wired in dispatcher.ts:173-200; `wireDispatcher` called in main.ts:141
|
||||
- **BUG-033**: No WS handlers for member_update/member_ban — closed 2026-03-18
|
||||
- Handlers wired in dispatcher.ts:219-229; `wireDispatcher` called in main.ts:141
|
||||
- **BUG-034**: InviteManager mutates state before API resolves — closed 2026-03-18
|
||||
- Filter is inside `.then()` — only runs after promise resolves
|
||||
- **BUG-035**: DmSidebar active highlight never updates — fixed 2026-03-18
|
||||
- Click handler now removes `.active` from siblings and adds to clicked item
|
||||
- **BUG-036**: WebRTC failure silently disconnects user — fixed 2026-03-18
|
||||
- Added `setOnError` callback pattern; MainPage wires it to toast
|
||||
|
||||
- **BUG-026**: Image attachments render placeholder, not actual images — fixed 2026-03-18
|
||||
- Replaced placeholder `<div>` with `<img src=att.url>` + error fallback
|
||||
- **BUG-030**: Orphaned MessageActionsBar + ReactionBar components — fixed 2026-03-18
|
||||
- Deleted dead code: both components and their tests (never imported anywhere)
|
||||
|
||||
- **BUG-024**: Reactions cannot be removed — fixed 2026-03-18
|
||||
- Toggles `reaction_add`/`reaction_remove` based on `me` field per PROTOCOL.md
|
||||
- **BUG-028**: Message delete fires with no confirmation — fixed 2026-03-18
|
||||
- Added `confirm()` guard before sending `chat_delete`; success toast added
|
||||
- **BUG-029**: Message edit sends without validation — fixed 2026-03-18
|
||||
- Added empty-check, no-op detection, and toast feedback
|
||||
- **BUG-037**: Reaction rate limit silently swallows clicks — fixed 2026-03-18
|
||||
- Shows error toast when rate limited
|
||||
- **BUG-038**: No toasts for chat edit/delete/reaction operations — fixed 2026-03-18
|
||||
- Added success toasts for delete and edit; error toast for rate-limited reactions
|
||||
|
||||
- **BUG-021**: Camera toggle hardcoded to `enabled: false` — fixed 2026-03-18
|
||||
- Added `localCamera` state to voice store; toggle reads actual state
|
||||
- **BUG-022**: Screenshare handler completely empty — fixed 2026-03-18
|
||||
- Added `localScreenshare` state; sends `voice_screenshare` WS message
|
||||
- **BUG-023**: UserBar mute/deafen buttons have no event listeners — fixed 2026-03-18
|
||||
- Added `UserBarOptions` interface; MainPage passes mute/deafen handlers
|
||||
- **BUG-027**: VAD speaking state never sent to server — fixed 2026-03-18
|
||||
- Wired `vadDetector.onSpeakingChange` → `setLocalSpeaking` in voice store
|
||||
|
||||
- **BUG-020**: Account settings do nothing — fixed 2026-03-18
|
||||
- Wired `api.changePassword()` and `api.updateProfile()` into MainPage callbacks
|
||||
- Added `updateUser()` to auth store for username sync after profile edit
|
||||
- Added toast feedback for success/error on both operations
|
||||
- **BUG-025**: Theme changes don't sync to uiStore — fixed 2026-03-18
|
||||
- Added `setTheme(name)` call in AppearanceTab click handler
|
||||
- Store now stays in sync with localStorage and applied CSS
|
||||
|
||||
- **BUG-001**: NilHub tests pass mockHub not nil — fixed 2026-03-18 (#12)
|
||||
- Added nil hub tests for PatchUser ban and role change paths
|
||||
- **BUG-002**: window-state.ts untyped `any` — fixed (already resolved) (#10)
|
||||
- Code already uses proper types (`Record<string, unknown>`, `typeof import(...)`)
|
||||
- No `any` or `getInvoke()` pattern found — was fixed in a prior refactor
|
||||
|
||||
- **BUG-003**: Hub double-close panic — fixed 2026-03-17 (issue #3)
|
||||
- Added `sync.Once` guard on quit channel close
|
||||
- **BUG-004**: golangci-lint version incompatibility — fixed 2026-03-17 (issue #4)
|
||||
- Pinned compatible linter version in CI
|
||||
- **BUG-005**: SearchMessages missing validation — fixed 2026-03-17 (issue #5)
|
||||
- Added input length and channel access checks
|
||||
- **BUG-006**: InviteManager unhandled rejections — fixed 2026-03-17 (issue #6)
|
||||
- Wrapped async calls with proper error handling
|
||||
- **BUG-007**: Test schema missing columns — fixed 2026-03-17 (issue #7)
|
||||
- Synced test fixtures with production schema
|
||||
- **BUG-008**: Capacity over-allocation in
|
||||
getReactionsBatch — fixed 2026-03-17 (#9)
|
||||
- Corrected slice capacity to match actual batch size
|
||||
- **BUG-009**: golangci-lint violations blocking CI — fixed 2026-03-17 (issue #13)
|
||||
- Resolved all outstanding lint errors
|
||||
- **BUG-010**: buildReady() silent hang — fixed 2026-03-17 (T-038)
|
||||
- Server now sends INTERNAL error to client on buildReady failure
|
||||
- **BUG-011**: Banned user keeps chatting — fixed 2026-03-17 (T-044)
|
||||
- Added ban check to periodic session validation in WS handler
|
||||
- **BUG-012**: Reaction error DB leak — fixed 2026-03-17 (T-039)
|
||||
- Sanitized error messages, raw DB errors logged server-side only
|
||||
- **BUG-013**: WS proxy no connect timeout — fixed 2026-03-17 (T-046)
|
||||
- Added 10s connect timeout to Rust WS proxy
|
||||
- **BUG-014**: Channel delete stale view — fixed 2026-03-17 (T-045)
|
||||
- Client auto-redirects to first text channel on active channel deletion
|
||||
- **BUG-015**: Missing rate limits on chat_edit/chat_delete — fixed 2026-03-17 (#18)
|
||||
- Added rate limiting to edit and delete message endpoints
|
||||
- **BUG-016**: Cert mismatch event not handled — fixed 2026-03-17 (#19)
|
||||
- TOFU flow now properly handles certificate mismatch events
|
||||
- **BUG-017**: SHA-256 fingerprint validation incorrect — fixed 2026-03-17 (#20)
|
||||
- Fixed fingerprint comparison logic in cert pinning
|
||||
- **BUG-018**: Session+ban query N+1 — fixed 2026-03-17 (#21)
|
||||
- Optimized with JOIN query instead of separate lookups
|
||||
- **BUG-019**: Channel position sorting broken — fixed 2026-03-17 (#22)
|
||||
- Channels now sort correctly by position field
|
||||
Reference in New Issue
Block a user