From 2049049bbba588cc21b250f4bb18639ee9bcbc44 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Tue, 23 Jun 2026 14:41:59 -0300 Subject: [PATCH 1/2] feat(supportedVersions): role-targeted expiration messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional `roles` field to supportedVersions `Message`. When set, an expiration message is only shown to users whose roles intersect the list; when omitted (or empty) the message is shown to everyone, so older clients and existing payloads are unaffected. To evaluate targeting, capture the logged-in user's roles from the workspace REST API (/api/v1/me) on login and store them on the server state. Any failure leaves roles unset, which falls back to showing the message to all users — the pre-existing behavior. This lets the signed payload restrict version warnings to admins (the only ones who can act on them) without a desktop release for each targeting change. --- src/servers/common.ts | 1 + src/servers/preload/userLoggedIn.ts | 7 ++ src/servers/preload/userRoles.ts | 59 +++++++++++++++++ src/servers/reducers.ts | 7 ++ .../supportedVersions/main.main.spec.ts | 66 +++++++++++++++++++ src/servers/supportedVersions/main.ts | 26 +++++++- src/servers/supportedVersions/types.ts | 8 +++ src/ui/actions.ts | 5 ++ 8 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 src/servers/preload/userRoles.ts diff --git a/src/servers/common.ts b/src/servers/common.ts index c9ec7a70b8..06f618b153 100644 --- a/src/servers/common.ts +++ b/src/servers/common.ts @@ -17,6 +17,7 @@ export type Server = { failed?: boolean; webContentsId?: number; userLoggedIn?: boolean; + userRoles?: string[]; gitCommitHash?: string; allowedRedirects?: string[]; outlookCredentials?: OutlookCredentials; diff --git a/src/servers/preload/userLoggedIn.ts b/src/servers/preload/userLoggedIn.ts index 9e812fab09..00c94bebce 100644 --- a/src/servers/preload/userLoggedIn.ts +++ b/src/servers/preload/userLoggedIn.ts @@ -2,6 +2,7 @@ import { dispatch } from '../../store'; import { WEBVIEW_USER_LOGGED_IN } from '../../ui/actions'; import type { Server } from '../common'; import { getServerUrl } from './urls'; +import { clearUserRoles, updateUserRoles } from './userRoles'; export const setUserLoggedIn = (userLoggedIn: Server['userLoggedIn']): void => { dispatch({ @@ -11,4 +12,10 @@ export const setUserLoggedIn = (userLoggedIn: Server['userLoggedIn']): void => { userLoggedIn, }, }); + + if (userLoggedIn) { + void updateUserRoles(); + } else { + clearUserRoles(); + } }; diff --git a/src/servers/preload/userRoles.ts b/src/servers/preload/userRoles.ts new file mode 100644 index 0000000000..dbce542d1a --- /dev/null +++ b/src/servers/preload/userRoles.ts @@ -0,0 +1,59 @@ +import { dispatch } from '../../store'; +import { WEBVIEW_USER_ROLES_CHANGED } from '../../ui/actions'; +import type { Server } from '../common'; +import { getServerUrl } from './urls'; + +const dispatchUserRoles = (userRoles: Server['userRoles']): void => { + dispatch({ + type: WEBVIEW_USER_ROLES_CHANGED, + payload: { + url: getServerUrl(), + userRoles, + }, + }); +}; + +const buildMeEndpoint = (serverUrl: string): string => + `${serverUrl.replace(/\/$/, '')}/api/v1/me`; + +/** + * Reads the logged-in user's roles from the workspace REST API and stores them + * on the server state. Roles are used to decide which supportedVersions + * messages a user is allowed to see (see Message.roles). Any failure leaves + * roles unset, which makes role-targeted messages fall back to being shown to + * everyone — the pre-existing behavior. + */ +export const updateUserRoles = async (): Promise => { + try { + const serverUrl = getServerUrl(); + if (!serverUrl) return; + + const authToken = localStorage.getItem('Meteor.loginToken'); + const userId = localStorage.getItem('Meteor.userId'); + if (!authToken || !userId) return; + + const response = await fetch(buildMeEndpoint(serverUrl), { + headers: { + 'X-Auth-Token': authToken, + 'X-User-Id': userId, + }, + }); + if (!response.ok) return; + + const data = await response.json(); + const roles: unknown = data?.roles; + if (!Array.isArray(roles)) return; + + const userRoles = roles.filter( + (role): role is string => typeof role === 'string' + ); + dispatchUserRoles(userRoles); + } catch { + // Network/parse errors are non-fatal: leave roles unset so role-targeted + // messages default to visible. + } +}; + +export const clearUserRoles = (): void => { + dispatchUserRoles(undefined); +}; diff --git a/src/servers/reducers.ts b/src/servers/reducers.ts index 4028a93529..0483e705fc 100644 --- a/src/servers/reducers.ts +++ b/src/servers/reducers.ts @@ -16,6 +16,7 @@ import { WEBVIEW_TITLE_CHANGED, WEBVIEW_UNREAD_CHANGED, WEBVIEW_USER_LOGGED_IN, + WEBVIEW_USER_ROLES_CHANGED, WEBVIEW_FAVICON_CHANGED, WEBVIEW_DID_START_LOADING, WEBVIEW_DID_FAIL_LOAD, @@ -56,6 +57,7 @@ type ServersActionTypes = | ActionOf | ActionOf | ActionOf + | ActionOf | ActionOf | ActionOf | ActionOf @@ -195,6 +197,11 @@ export const servers: Reducer = ( return upsert(state, { url, userLoggedIn }); } + case WEBVIEW_USER_ROLES_CHANGED: { + const { url, userRoles } = action.payload; + return upsert(state, { url, userRoles }); + } + case WEBVIEW_ALLOWED_REDIRECTS_CHANGED: { const { url, allowedRedirects } = action.payload; return upsert(state, { url, allowedRedirects }); diff --git a/src/servers/supportedVersions/main.main.spec.ts b/src/servers/supportedVersions/main.main.spec.ts index 8e02dee740..407e086cc5 100644 --- a/src/servers/supportedVersions/main.main.spec.ts +++ b/src/servers/supportedVersions/main.main.spec.ts @@ -910,6 +910,72 @@ describe('supportedVersions/main.ts', () => { expect(result.supported).toBe(false); }); + + describe('message role targeting', () => { + const futureDate = new Date(Date.now() + 86400000); + const buildSupportedVersions = (roles?: string[]): SupportedVersions => + ({ + enforcementStartDate: new Date(Date.now() + 172800000).toISOString(), + timestamp: new Date().toISOString(), + versions: [ + { + version: '5.4.0', + expiration: futureDate, + }, + ], + messages: [ + { + remainingDays: 30, + title: 'targeted', + subtitle: 'sub', + description: 'desc', + type: 'info', + ...(roles ? { roles } : {}), + params: {}, + link: '', + }, + ], + i18n: { en: {} }, + }) as any; + + it('shows a message with no roles to every user', async () => { + const result = await isServerVersionSupported( + { ...mockServer, userRoles: ['user'] } as any, + buildSupportedVersions() + ); + + expect(result.message?.title).toBe('targeted'); + }); + + it('shows a role-targeted message when the user has the role', async () => { + const result = await isServerVersionSupported( + { ...mockServer, userRoles: ['admin', 'user'] } as any, + buildSupportedVersions(['admin']) + ); + + expect(result.message?.title).toBe('targeted'); + }); + + it('hides a role-targeted message from users without the role', async () => { + const result = await isServerVersionSupported( + { ...mockServer, userRoles: ['user'] } as any, + buildSupportedVersions(['admin']) + ); + + expect(result.supported).toBe(true); + expect(result.message).toBeUndefined(); + }); + + it('hides a role-targeted message when user roles are unknown', async () => { + const result = await isServerVersionSupported( + mockServer as any, + buildSupportedVersions(['admin']) + ); + + expect(result.supported).toBe(true); + expect(result.message).toBeUndefined(); + }); + }); }); describe('Cache and Retry Integration', () => { diff --git a/src/servers/supportedVersions/main.ts b/src/servers/supportedVersions/main.ts index 512ef4a8be..2c0450f393 100644 --- a/src/servers/supportedVersions/main.ts +++ b/src/servers/supportedVersions/main.ts @@ -184,12 +184,30 @@ const getUniqueId = async ( } }; +const messageMatchesUserRoles = ( + message: Message, + userRoles?: string[] +): boolean => { + // No targeting set on the message → show to everyone (default behavior). + if (!message.roles?.length) { + return true; + } + // Targeting set but we don't know the user's roles → don't show, to honor + // the intent of restricting the message. + if (!userRoles?.length) { + return false; + } + return message.roles.some((role) => userRoles.includes(role)); +}; + const getExpirationMessage = ({ messages, expiration, + userRoles, }: { messages?: Message[]; expiration?: Date; + userRoles?: string[]; }): Message | undefined => { if ( !messages?.length || @@ -199,7 +217,10 @@ const getExpirationMessage = ({ ) { return; } - const sortedMessages = messages.sort( + const eligibleMessages = messages.filter((message) => + messageMatchesUserRoles(message, userRoles) + ); + const sortedMessages = eligibleMessages.sort( (a, b) => a.remainingDays - b.remainingDays ); const message = sortedMessages.find( @@ -362,6 +383,7 @@ export const isServerVersionSupported = async ( const selectedExpirationMessage = getExpirationMessage({ messages, expiration: exception.expiration, + userRoles: server.userRoles, }) as Message; return { @@ -387,6 +409,7 @@ export const isServerVersionSupported = async ( const selectedExpirationMessage = getExpirationMessage({ messages, expiration: supportedVersion.expiration, + userRoles: server.userRoles, }) as Message; return { @@ -407,6 +430,7 @@ export const isServerVersionSupported = async ( const selectedExpirationMessage = getExpirationMessage({ messages: supportedVersionsData.messages, expiration: enforcementStartDate, + userRoles: server.userRoles, }) as Message; return { diff --git a/src/servers/supportedVersions/types.ts b/src/servers/supportedVersions/types.ts index 135dedc896..b721f573e7 100644 --- a/src/servers/supportedVersions/types.ts +++ b/src/servers/supportedVersions/types.ts @@ -8,6 +8,14 @@ export type Message = { subtitle: string; description: string; type: 'primary' | 'warning' | 'danger'; + /** + * Roles allowed to see this message. When omitted (or empty), the message is + * shown to every user. When present, the message is only shown to users whose + * roles intersect this list (e.g. `["admin"]` targets workspace admins only). + * Clients that predate this field ignore it and keep showing the message to + * everyone, so the field is backward compatible. + */ + roles?: string[]; params: Record & { instance_version?: string; instance_email?: string; diff --git a/src/ui/actions.ts b/src/ui/actions.ts index 7655816fc1..a5a1b6f244 100644 --- a/src/ui/actions.ts +++ b/src/ui/actions.ts @@ -82,6 +82,7 @@ export const WEBVIEW_TITLE_CHANGED = 'webview/title-changed'; export const WEBVIEW_PAGE_TITLE_CHANGED = 'webview/page-title-changed'; export const WEBVIEW_UNREAD_CHANGED = 'webview/unread-changed'; export const WEBVIEW_USER_LOGGED_IN = 'webview/user-loggedin'; +export const WEBVIEW_USER_ROLES_CHANGED = 'webview/user-roles-changed'; export const WEBVIEW_ALLOWED_REDIRECTS_CHANGED = 'webview/allowed-redirects-changed'; export const SETTINGS_SET_REPORT_OPT_IN_CHANGED = @@ -234,6 +235,10 @@ export type UiActionTypeToPayloadMap = { url: Server['url']; userLoggedIn: Server['userLoggedIn']; }; + [WEBVIEW_USER_ROLES_CHANGED]: { + url: Server['url']; + userRoles: Server['userRoles']; + }; [WEBVIEW_GIT_COMMIT_HASH_CHECK]: { url: Server['url']; gitCommitHash: Server['gitCommitHash']; From 1f97098c363f0970688e608a890988daa8794b63 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Tue, 23 Jun 2026 15:03:26 -0300 Subject: [PATCH 2/2] feat(supportedVersions): push user roles via bridge with REST fallback Make role acquisition hybrid. Expose RocketChatDesktop.setUserRoles so the web client can push the logged-in user's roles reactively (no token handling, propagates on role changes). The /api/v1/me fetch becomes a fallback used only when the bridge hasn't provided roles, covering web clients that predate the bridge call. Bridge-provided roles always take precedence over the fetch. --- src/servers/preload/api.ts | 3 + src/servers/preload/userRoles.spec.ts | 97 +++++++++++++++++++++++++++ src/servers/preload/userRoles.ts | 37 ++++++++-- 3 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 src/servers/preload/userRoles.spec.ts diff --git a/src/servers/preload/api.ts b/src/servers/preload/api.ts index e6aa02a17d..51eef7e5ca 100644 --- a/src/servers/preload/api.ts +++ b/src/servers/preload/api.ts @@ -40,6 +40,7 @@ import { setUserThemeAppearance } from './themeAppearance'; import { setTitle } from './title'; import { setUrlResolver } from './urls'; import { setUserLoggedIn } from './userLoggedIn'; +import { setUserRoles } from './userRoles'; type ServerInfo = { version: string; @@ -59,6 +60,7 @@ type ExtendedIRocketChatDesktop = IRocketChatDesktop & { callback: (payload: { phoneNumber: string; rawUri: string }) => void ) => void; supportedDocumentViewerFormats: () => string[]; + setUserRoles: (roles: string[]) => void; }; declare global { @@ -87,6 +89,7 @@ export const RocketChatDesktop: Window['RocketChatDesktop'] = { setTitle, setUserPresenceDetection, setUserLoggedIn, + setUserRoles, setUserThemeAppearance, createNotification, destroyNotification, diff --git a/src/servers/preload/userRoles.spec.ts b/src/servers/preload/userRoles.spec.ts new file mode 100644 index 0000000000..8f8b426c3f --- /dev/null +++ b/src/servers/preload/userRoles.spec.ts @@ -0,0 +1,97 @@ +import { dispatch } from '../../store'; +import { WEBVIEW_USER_ROLES_CHANGED } from '../../ui/actions'; +import { getServerUrl } from './urls'; +import { clearUserRoles, setUserRoles, updateUserRoles } from './userRoles'; + +jest.mock('../../store', () => ({ + dispatch: jest.fn(), +})); +jest.mock('./urls', () => ({ + getServerUrl: jest.fn(() => 'https://rocket.chat'), +})); + +const dispatchMock = dispatch as jest.MockedFunction; +const getServerUrlMock = getServerUrl as jest.MockedFunction< + typeof getServerUrl +>; + +describe('servers/preload/userRoles', () => { + const originalFetch = global.fetch; + + const mockMeResponse = (roles: unknown): void => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ roles }), + }) as unknown as typeof global.fetch; + }; + + beforeEach(() => { + dispatchMock.mockClear(); + getServerUrlMock.mockReturnValue('https://rocket.chat'); + localStorage.setItem('Meteor.loginToken', 'token'); + localStorage.setItem('Meteor.userId', 'user-id'); + // Reset the module-level bridge flag between tests. + clearUserRoles(); + dispatchMock.mockClear(); + }); + + afterEach(() => { + global.fetch = originalFetch; + localStorage.clear(); + }); + + it('dispatches roles pushed through the bridge', () => { + setUserRoles(['admin', 'user']); + + expect(dispatchMock).toHaveBeenCalledWith({ + type: WEBVIEW_USER_ROLES_CHANGED, + payload: { url: 'https://rocket.chat', userRoles: ['admin', 'user'] }, + }); + }); + + it('ignores non-string entries pushed through the bridge', () => { + setUserRoles(['admin', 42, null]); + + expect(dispatchMock).toHaveBeenCalledWith({ + type: WEBVIEW_USER_ROLES_CHANGED, + payload: { url: 'https://rocket.chat', userRoles: ['admin'] }, + }); + }); + + it('falls back to fetching /api/v1/me when the bridge has not provided roles', async () => { + mockMeResponse(['admin']); + + await updateUserRoles(); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://rocket.chat/api/v1/me', + expect.objectContaining({ + headers: { 'X-Auth-Token': 'token', 'X-User-Id': 'user-id' }, + }) + ); + expect(dispatchMock).toHaveBeenCalledWith({ + type: WEBVIEW_USER_ROLES_CHANGED, + payload: { url: 'https://rocket.chat', userRoles: ['admin'] }, + }); + }); + + it('skips the fetch fallback once the bridge has provided roles', async () => { + setUserRoles(['admin']); + dispatchMock.mockClear(); + global.fetch = jest.fn() as unknown as typeof global.fetch; + + await updateUserRoles(); + + expect(global.fetch).not.toHaveBeenCalled(); + expect(dispatchMock).not.toHaveBeenCalled(); + }); + + it('does not fetch when the session token is missing', async () => { + localStorage.removeItem('Meteor.loginToken'); + global.fetch = jest.fn() as unknown as typeof global.fetch; + + await updateUserRoles(); + + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/servers/preload/userRoles.ts b/src/servers/preload/userRoles.ts index dbce542d1a..99910cf12b 100644 --- a/src/servers/preload/userRoles.ts +++ b/src/servers/preload/userRoles.ts @@ -3,6 +3,12 @@ import { WEBVIEW_USER_ROLES_CHANGED } from '../../ui/actions'; import type { Server } from '../common'; import { getServerUrl } from './urls'; +// When the web client pushes roles through the RocketChatDesktop.setUserRoles +// bridge, that source is authoritative and reactive, so the REST fallback must +// not override it. Older web clients never call the bridge, leaving this false +// and letting the fetch fallback run. +let rolesProvidedByBridge = false; + const dispatchUserRoles = (userRoles: Server['userRoles']): void => { dispatch({ type: WEBVIEW_USER_ROLES_CHANGED, @@ -17,13 +23,29 @@ const buildMeEndpoint = (serverUrl: string): string => `${serverUrl.replace(/\/$/, '')}/api/v1/me`; /** - * Reads the logged-in user's roles from the workspace REST API and stores them - * on the server state. Roles are used to decide which supportedVersions - * messages a user is allowed to see (see Message.roles). Any failure leaves - * roles unset, which makes role-targeted messages fall back to being shown to - * everyone — the pre-existing behavior. + * Authoritative path: the web client observes its own reactive user and pushes + * roles. Reactive and free of token handling/REST. Takes precedence over the + * fetch fallback. + */ +export const setUserRoles = (roles: unknown): void => { + if (!Array.isArray(roles)) return; + const userRoles = roles.filter( + (role): role is string => typeof role === 'string' + ); + rolesProvidedByBridge = true; + dispatchUserRoles(userRoles); +}; + +/** + * Fallback path for web clients that don't push roles through the bridge. + * Reads the logged-in user's roles from the workspace REST API. Skipped once + * the bridge has provided roles. Any failure leaves roles unset, which makes + * role-targeted messages fall back to being shown to everyone — the + * pre-existing behavior. */ export const updateUserRoles = async (): Promise => { + if (rolesProvidedByBridge) return; + try { const serverUrl = getServerUrl(); if (!serverUrl) return; @@ -40,6 +62,10 @@ export const updateUserRoles = async (): Promise => { }); if (!response.ok) return; + // The bridge may have resolved while the request was in flight; if so, keep + // the authoritative value. + if (rolesProvidedByBridge) return; + const data = await response.json(); const roles: unknown = data?.roles; if (!Array.isArray(roles)) return; @@ -55,5 +81,6 @@ export const updateUserRoles = async (): Promise => { }; export const clearUserRoles = (): void => { + rolesProvidedByBridge = false; dispatchUserRoles(undefined); };