From 7cdc2ef1ca09ba674d20dd9d3d411dfe830ca0e0 Mon Sep 17 00:00:00 2001 From: J3vb Date: Thu, 2 Apr 2026 16:33:11 +0200 Subject: [PATCH] feat: broadcast username changes to all connected clients via WebSocket Add user_update event so other clients see profile changes in real-time without needing to reconnect. Also updates saved credentials in Windows Credential Manager when the current user changes their username. Fixes: livekit-session test mock missing unpublishTrack property. --- Client/tauri-client/src/lib/dispatcher.ts | 19 +++++++++++++++++++ Client/tauri-client/src/lib/protocolTypes.ts | 1 + Client/tauri-client/src/lib/types.ts | 7 +++++++ Client/tauri-client/src/main.ts | 11 +++++++++++ .../tauri-client/src/stores/members.store.ts | 11 +++++++++++ .../tests/unit/livekit-session.test.ts | 1 + Server/api/coverage_push_test.go | 2 +- Server/api/profile_handler.go | 17 ++++++++++++++--- Server/api/profile_handler_test.go | 2 +- Server/api/router.go | 8 ++++++-- Server/ws/hub.go | 6 ++++++ Server/ws/message_types.go | 1 + Server/ws/messages.go | 14 ++++++++++++++ 13 files changed, 93 insertions(+), 7 deletions(-) diff --git a/Client/tauri-client/src/lib/dispatcher.ts b/Client/tauri-client/src/lib/dispatcher.ts index 1be2e3c7..0db94cff 100644 --- a/Client/tauri-client/src/lib/dispatcher.ts +++ b/Client/tauri-client/src/lib/dispatcher.ts @@ -27,6 +27,7 @@ import { addMember, removeMember, updateMemberRole, + updateMemberProfile, updatePresence, setTyping, } from "@stores/members.store"; @@ -317,6 +318,24 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup { }), ); + unsubs.push( + ws.on(S.USER_UPDATE, (payload) => { + log.info("User profile updated", { userId: payload.user_id, username: payload.username }); + updateMemberProfile(payload.user_id, payload.username, payload.avatar); + + // Update auth store if the current user changed their own profile. + const currentUser = authStore.getState().user; + if (currentUser && payload.user_id === currentUser.id) { + setAuth( + authStore.getState().token ?? "", + { ...currentUser, username: payload.username, avatar: payload.avatar }, + authStore.getState().serverName ?? "", + authStore.getState().motd ?? "", + ); + } + }), + ); + // ── Voice ───────────────────────────────────────────── unsubs.push( diff --git a/Client/tauri-client/src/lib/protocolTypes.ts b/Client/tauri-client/src/lib/protocolTypes.ts index 0493e113..a9572a8a 100644 --- a/Client/tauri-client/src/lib/protocolTypes.ts +++ b/Client/tauri-client/src/lib/protocolTypes.ts @@ -31,6 +31,7 @@ export const ServerMessageType = { MEMBER_JOIN: "member_join", MEMBER_LEAVE: "member_leave", MEMBER_UPDATE: "member_update", + USER_UPDATE: "user_update", MEMBER_BAN: "member_ban", SERVER_RESTART: "server_restart", ERROR: "error", diff --git a/Client/tauri-client/src/lib/types.ts b/Client/tauri-client/src/lib/types.ts index 97c5deb9..ad1790dd 100644 --- a/Client/tauri-client/src/lib/types.ts +++ b/Client/tauri-client/src/lib/types.ts @@ -296,6 +296,12 @@ export interface MemberUpdatePayload { readonly role: string; } +export interface UserUpdatePayload { + readonly user_id: number; + readonly username: string; + readonly avatar: string | null; +} + export interface MemberBanPayload { readonly user_id: number; } @@ -443,6 +449,7 @@ export type ServerMessage = | (WsEnvelope & { readonly type: "member_join" }) | (WsEnvelope & { readonly type: "member_leave" }) | (WsEnvelope & { readonly type: "member_update" }) + | (WsEnvelope & { readonly type: "user_update" }) | (WsEnvelope & { readonly type: "member_ban" }) | (WsEnvelope & { readonly type: "dm_channel_open" }) | (WsEnvelope & { readonly type: "dm_channel_close" }) diff --git a/Client/tauri-client/src/main.ts b/Client/tauri-client/src/main.ts index 6315fc32..f94fb626 100644 --- a/Client/tauri-client/src/main.ts +++ b/Client/tauri-client/src/main.ts @@ -247,6 +247,17 @@ function renderPage(pageId: "connect" | "main"): void { }); } + // Update saved credentials when the current user changes their username. + ws.on("user_update", (payload) => { + const currentUserId = authStore.getState().user?.id ?? 0; + if (payload.user_id === currentUserId) { + const currentToken = authStore.getState().token; + if (currentToken) { + void saveCredential(host, payload.username, currentToken); + } + } + }); + const unsubState = ws.onStateChange((wsState) => { log.debug("WS state change", { state: wsState }); if (wsState === "connected") { diff --git a/Client/tauri-client/src/stores/members.store.ts b/Client/tauri-client/src/stores/members.store.ts index c49d877c..a22f7686 100644 --- a/Client/tauri-client/src/stores/members.store.ts +++ b/Client/tauri-client/src/stores/members.store.ts @@ -93,6 +93,17 @@ export function updateMemberRole(userId: number, role: string): void { }); } +/** Update a member's profile (username, avatar) from a user_update event. */ +export function updateMemberProfile(userId: number, username: string, avatar: string | null): void { + membersStore.setState((prev) => { + const existing = prev.members.get(userId); + if (!existing) return prev; + const next = new Map(prev.members); + next.set(userId, { ...existing, username, avatar }); + return { ...prev, members: next }; + }); +} + /** Update a member's presence status. */ export function updatePresence(userId: number, status: UserStatus): void { membersStore.setState((prev) => { diff --git a/Client/tauri-client/tests/unit/livekit-session.test.ts b/Client/tauri-client/tests/unit/livekit-session.test.ts index 57a0ef81..31078afe 100644 --- a/Client/tauri-client/tests/unit/livekit-session.test.ts +++ b/Client/tauri-client/tests/unit/livekit-session.test.ts @@ -16,6 +16,7 @@ const mockRoom = vi.hoisted(() => ({ setMicrophoneEnabled: vi.fn().mockResolvedValue(undefined), setCameraEnabled: vi.fn().mockResolvedValue(undefined), getTrackPublication: vi.fn().mockReturnValue(undefined), + unpublishTrack: vi.fn().mockResolvedValue(undefined), trackPublications: new Map(), identity: "user-1", }, diff --git a/Server/api/coverage_push_test.go b/Server/api/coverage_push_test.go index b2b03bb5..07c34c7a 100644 --- a/Server/api/coverage_push_test.go +++ b/Server/api/coverage_push_test.go @@ -748,7 +748,7 @@ func buildCombinedRouter(t *testing.T) (http.Handler, *auth.RateLimiter, string) r := chi.NewRouter() api.MountAuthRoutes(r, database, limiter, nil, testTOTPKey) - api.MountProfileRoutes(r, database, limiter, nil) + api.MountProfileRoutes(r, database, limiter, nil, nil) api.MountInviteRoutes(r, database) token := loginAndGetToken(t, r, database, "combined1", 2) diff --git a/Server/api/profile_handler.go b/Server/api/profile_handler.go index 7c9104c3..c810dd6e 100644 --- a/Server/api/profile_handler.go +++ b/Server/api/profile_handler.go @@ -45,13 +45,19 @@ type sessionsListResponse struct { // ─── Route mounting ────────────────────────────────────────────────────────── +// ProfileBroadcaster is the interface the profile handler uses to notify +// connected WebSocket clients about profile changes. +type ProfileBroadcaster interface { + BroadcastUserUpdate(userID int64, username string, avatar *string) +} + // MountProfileRoutes registers user profile management endpoints. // All routes require authentication. trustedProxies is used for rate limiting. -func MountProfileRoutes(r chi.Router, database *db.DB, limiter *auth.RateLimiter, trustedProxies []string) { +func MountProfileRoutes(r chi.Router, database *db.DB, limiter *auth.RateLimiter, trustedProxies []string, broadcaster ProfileBroadcaster) { r.Route("/api/v1/users/me", func(r chi.Router) { r.Use(AuthMiddleware(database)) - r.Patch("/", handleUpdateProfile(database)) + r.Patch("/", handleUpdateProfile(database, broadcaster)) r.With(RateLimitMiddleware(limiter, profilePasswordRateLimitPerMinute, time.Minute, trustedProxies)). Put("/password", handleChangePassword(database, limiter)) @@ -64,7 +70,7 @@ func MountProfileRoutes(r chi.Router, database *db.DB, limiter *auth.RateLimiter // ─── Handlers ──────────────────────────────────────────────────────────────── // handleUpdateProfile processes PATCH /api/v1/users/me. -func handleUpdateProfile(database *db.DB) http.HandlerFunc { +func handleUpdateProfile(database *db.DB, broadcaster ProfileBroadcaster) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(UserKey).(*db.User) if !ok || user == nil { @@ -138,6 +144,11 @@ func handleUpdateProfile(database *db.DB) http.HandlerFunc { slog.Info("profile updated", "user_id", user.ID, "new_username", req.Username) _ = database.LogAudit(user.ID, "profile_update", "user", user.ID, "profile updated") + // Broadcast profile change to all connected WebSocket clients. + if broadcaster != nil { + broadcaster.BroadcastUserUpdate(updated.ID, updated.Username, updated.Avatar) + } + writeJSON(w, http.StatusOK, toUserResponse(updated)) } } diff --git a/Server/api/profile_handler_test.go b/Server/api/profile_handler_test.go index 2a68b4b4..bf81e9fe 100644 --- a/Server/api/profile_handler_test.go +++ b/Server/api/profile_handler_test.go @@ -19,7 +19,7 @@ import ( func buildProfileRouter(database *db.DB) http.Handler { r := chi.NewRouter() limiter := auth.NewRateLimiter() - api.MountProfileRoutes(r, database, limiter, nil) + api.MountProfileRoutes(r, database, limiter, nil, nil) return r } diff --git a/Server/api/router.go b/Server/api/router.go index 170510c0..032675bb 100644 --- a/Server/api/router.go +++ b/Server/api/router.go @@ -83,8 +83,8 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri // Auth routes: register, login, logout, me. MountAuthRoutes(r, database, limiter, cfg.Server.TrustedProxies, totpKey) - // Profile routes: update profile, change password, session management. - MountProfileRoutes(r, database, limiter, cfg.Server.TrustedProxies) + // Profile routes are mounted after hub creation (below) so the hub can + // broadcast user_update events for real-time profile changes. // Invite management routes (require MANAGE_INVITES permission). MountInviteRoutes(r, database) @@ -167,6 +167,10 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri Handle("/livekit/*", http.StripPrefix("/livekit", NewLiveKitProxy(cfg.Voice.LiveKitURL, cfg.Server.AllowedOrigins))) } + // Profile routes: update profile, change password, session management. + // Mounted after hub creation so the hub can broadcast user_update events. + MountProfileRoutes(r, database, limiter, cfg.Server.TrustedProxies, hub) + // DM (direct message) REST routes — mounted after hub creation so the // hub can send real-time dm_channel_close events to WebSocket clients. MountDMRoutes(r, database, hub) diff --git a/Server/ws/hub.go b/Server/ws/hub.go index 7343b0fa..4ba95358 100644 --- a/Server/ws/hub.go +++ b/Server/ws/hub.go @@ -412,6 +412,12 @@ func (h *Hub) DisconnectUser(userID int64) { h.kickClient(c) } +// BroadcastUserUpdate sends a user_update message to all connected clients +// when a user changes their profile (username, avatar). +func (h *Hub) BroadcastUserUpdate(userID int64, username string, avatar *string) { + h.BroadcastToAll(buildUserUpdate(userID, username, avatar)) +} + // BroadcastMemberUpdate sends a member_update message to all connected clients. func (h *Hub) BroadcastMemberUpdate(userID int64, roleName string) { h.BroadcastToAll(buildMemberUpdate(userID, roleName)) diff --git a/Server/ws/message_types.go b/Server/ws/message_types.go index 46f1ac9f..7cc81539 100644 --- a/Server/ws/message_types.go +++ b/Server/ws/message_types.go @@ -48,6 +48,7 @@ const ( MsgTypeMemberJoin = "member_join" MsgTypeMemberLeave = "member_leave" MsgTypeMemberUpdate = "member_update" + MsgTypeUserUpdate = "user_update" MsgTypeMemberBan = "member_ban" MsgTypeServerRestart = "server_restart" MsgTypeError = "error" diff --git a/Server/ws/messages.go b/Server/ws/messages.go index dcd833bd..1a4873f1 100644 --- a/Server/ws/messages.go +++ b/Server/ws/messages.go @@ -59,6 +59,12 @@ type memberUpdatePayload struct { Role string `json:"role"` } +type userUpdatePayload struct { + UserID int64 `json:"user_id"` + Username string `json:"username"` + Avatar *string `json:"avatar"` +} + type memberBanPayload struct { UserID int64 `json:"user_id"` } @@ -270,6 +276,14 @@ func buildMemberUpdate(userID int64, roleName string) []byte { }) } +// buildUserUpdate constructs a user_update broadcast for profile changes. +func buildUserUpdate(userID int64, username string, avatar *string) []byte { + return buildJSON(wsMsg{ + Type: MsgTypeUserUpdate, + Payload: userUpdatePayload{UserID: userID, Username: username, Avatar: avatar}, + }) +} + // buildMemberBan constructs a member_ban broadcast per PROTOCOL.md. func buildMemberBan(userID int64) []byte { return buildJSON(wsMsg{