diff --git a/app/containers/markdown/__snapshots__/Markdown.test.tsx.snap b/app/containers/markdown/__snapshots__/Markdown.test.tsx.snap index 009e5615a36..2517ef4eb26 100644 --- a/app/containers/markdown/__snapshots__/Markdown.test.tsx.snap +++ b/app/containers/markdown/__snapshots__/Markdown.test.tsx.snap @@ -5882,7 +5882,7 @@ exports[`Story Snapshots: Timestamp should match snapshot 1`] = ` ] } > - a year ago + 2 years ago diff --git a/app/definitions/IServer.ts b/app/definitions/IServer.ts index 418b11f1de3..826ac45540a 100644 --- a/app/definitions/IServer.ts +++ b/app/definitions/IServer.ts @@ -14,6 +14,14 @@ export type TSVMessage = { subtitle?: string; description?: string; type: 'info' | 'alert' | 'error'; + /** + * 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; link: string; }; diff --git a/app/lib/methods/checkSupportedVersions.test.ts b/app/lib/methods/checkSupportedVersions.test.ts index e3bd0debfab..d6d561ad89c 100644 --- a/app/lib/methods/checkSupportedVersions.test.ts +++ b/app/lib/methods/checkSupportedVersions.test.ts @@ -1,4 +1,4 @@ -import { type ISupportedVersionsData } from '../../definitions'; +import { type ISupportedVersionsData, type TSVMessage } from '../../definitions'; import { checkSupportedVersions, getMessage } from './checkSupportedVersions'; const MOCK_I18N = { @@ -518,4 +518,139 @@ describe('getMessage', () => { link: 'Docs page' }); }); + + describe('role targeting', () => { + const buildMessages = (roles?: string[]): TSVMessage[] => [ + { + remainingDays: 15, + title: 'targeted', + subtitle: 'subtitle_token', + description: 'description_token', + type: 'info', + ...(roles ? { roles } : {}), + link: 'Docs page' + } + ]; + + test('shows a message with no roles to every user', () => { + expect( + getMessage({ messages: buildMessages(), expiration: '2023-04-10T00:00:00.000Z', userRoles: ['user'] }) + ).toMatchObject({ title: 'targeted' }); + }); + + test('shows a role-targeted message when the user has the role', () => { + expect( + getMessage({ messages: buildMessages(['admin']), expiration: '2023-04-10T00:00:00.000Z', userRoles: ['admin', 'user'] }) + ).toMatchObject({ title: 'targeted' }); + }); + + test('hides a role-targeted message from users without the role', () => { + expect( + getMessage({ messages: buildMessages(['admin']), expiration: '2023-04-10T00:00:00.000Z', userRoles: ['user'] }) + ).toBeUndefined(); + }); + + test('hides a role-targeted message when user roles are unknown', () => { + expect(getMessage({ messages: buildMessages(['admin']), expiration: '2023-04-10T00:00:00.000Z' })).toBeUndefined(); + }); + }); +}); + +describe('checkSupportedVersions role targeting', () => { + const buildSupportedVersions = (roles?: string[]): ISupportedVersionsData => ({ + timestamp: TODAY, + enforcementStartDate: TODAY, + messages: [ + { + remainingDays: 15, + title: 'targeted', + subtitle: 'subtitle_token', + description: 'description_token', + type: 'info', + ...(roles ? { roles } : {}), + link: 'Docs page' + } + ], + i18n: MOCK_I18N, + versions: [ + { + version: '1.4.0', + expiration: '2023-04-10T00:00:00.000Z' + } + ] + }); + + test('shows a role-targeted message when the user has the role', () => { + expect( + checkSupportedVersions({ + supportedVersions: buildSupportedVersions(['admin']), + serverVersion: '1.4.0', + userRoles: ['admin', 'user'] + }) + ).toMatchObject({ status: 'warn', message: { title: 'targeted' } }); + }); + + test('hides a role-targeted message from users without the role', () => { + const result = checkSupportedVersions({ + supportedVersions: buildSupportedVersions(['admin']), + serverVersion: '1.4.0', + userRoles: ['user'] + }); + expect(result.status).toBe('supported'); + expect(result.message).toBeUndefined(); + }); + + test('hides a role-targeted message when user roles are unknown', () => { + const result = checkSupportedVersions({ + supportedVersions: buildSupportedVersions(['admin']), + serverVersion: '1.4.0' + }); + expect(result.status).toBe('supported'); + expect(result.message).toBeUndefined(); + }); + + describe('enforcement window', () => { + const buildEnforcementSupportedVersions = (roles?: string[]): ISupportedVersionsData => ({ + timestamp: TODAY, + enforcementStartDate: '2023-04-15T00:00:00.000Z', + messages: [ + { + remainingDays: 15, + title: 'targeted', + subtitle: 'subtitle_token', + description: 'description_token', + type: 'info', + ...(roles ? { roles } : {}), + link: 'Docs page' + } + ], + i18n: MOCK_I18N, + versions: [ + { + version: '1.4.0', + expiration: '2023-03-10T00:00:00.000Z' + } + ] + }); + + test('does not warn a non-targeted user during the enforcement grace window', () => { + const result = checkSupportedVersions({ + supportedVersions: buildEnforcementSupportedVersions(['admin']), + serverVersion: '1.4.0', + userRoles: ['user'] + }); + expect(result.status).toBe('supported'); + expect(result.message).toBeUndefined(); + }); + + test('warns a targeted user during the enforcement grace window', () => { + expect( + checkSupportedVersions({ + supportedVersions: buildEnforcementSupportedVersions(['admin']), + serverVersion: '1.4.0', + userRoles: ['admin'] + }) + ).toMatchObject({ status: 'warn', message: { title: 'targeted' } }); + }); + }); }); diff --git a/app/lib/methods/checkSupportedVersions.ts b/app/lib/methods/checkSupportedVersions.ts index 8f98b1dc950..ea442170ac2 100644 --- a/app/lib/methods/checkSupportedVersions.ts +++ b/app/lib/methods/checkSupportedVersions.ts @@ -5,17 +5,32 @@ import dayjs from '../dayjs'; import { type ISupportedVersionsData, type TSVDictionary, type TSVMessage, type TSVStatus } from '../../definitions'; import builtInSupportedVersions from '../../../app-supportedversions.json'; +const messageMatchesUserRoles = (message: TSVMessage, userRoles?: string[]): boolean => { + // No targeting on the message → show to everyone (default behavior). + if (!message.roles?.length) { + return true; + } + // Targeting set but user roles unknown → don't show, honoring the restriction. + if (!userRoles?.length) { + return false; + } + return message.roles.some(role => userRoles.includes(role)); +}; + export const getMessage = ({ messages, - expiration + expiration, + userRoles }: { messages?: TSVMessage[]; expiration?: string; + userRoles?: string[]; }): TSVMessage | undefined => { if (!messages?.length || !expiration || dayjs(expiration).diff(new Date(), 'days') < 0) { return; } - const sortedMessages = messages.sort((a, b) => a.remainingDays - b.remainingDays); + const eligibleMessages = messages.filter(message => messageMatchesUserRoles(message, userRoles)); + const sortedMessages = eligibleMessages.sort((a, b) => a.remainingDays - b.remainingDays); return sortedMessages.find(({ remainingDays }) => dayjs(expiration).diff(new Date(), 'hours') <= remainingDays * 24); }; @@ -31,10 +46,12 @@ const getStatus = ({ expiration, message }: { expiration?: string; message?: TSV export const checkSupportedVersions = function ({ supportedVersions, - serverVersion + serverVersion, + userRoles }: { supportedVersions?: ISupportedVersionsData; serverVersion: string; + userRoles?: string[]; }): { status: TSVStatus; message?: TSVMessage; @@ -56,21 +73,25 @@ export const checkSupportedVersions = function ({ const messages = exception?.messages || (exception ? sv.exceptions?.messages : undefined) || versionInfo?.messages || sv.messages; const expiration = exception?.expiration || versionInfo?.expiration; - const message = getMessage({ messages, expiration }); + const message = getMessage({ messages, expiration, userRoles }); const status = getStatus({ message, expiration }); // TODO: enforcement start date is temp only. Remove after a few releases. if (status === 'expired' && sv?.enforcementStartDate && new Date(sv.enforcementStartDate) > new Date()) { const enforcementMessage = getMessage({ messages, - expiration: sv.enforcementStartDate + expiration: sv.enforcementStartDate, + userRoles }); - return { - status: 'warn', - message: enforcementMessage, - i18n: enforcementMessage ? sv?.i18n : undefined, - expiration: sv.enforcementStartDate - }; + if (enforcementMessage) { + return { + status: 'warn', + message: enforcementMessage, + i18n: sv?.i18n, + expiration: sv.enforcementStartDate + }; + } + return { status: 'supported', expiration: sv.enforcementStartDate }; } return { diff --git a/app/sagas/selectServer.ts b/app/sagas/selectServer.ts index 5373f6b0fcf..d1a9f497d7f 100644 --- a/app/sagas/selectServer.ts +++ b/app/sagas/selectServer.ts @@ -118,9 +118,11 @@ const getServerInfoSaga = function* getServerInfoSaga({ server, raiseError = tru if (!serverRecord) { throw new Error('Server not found'); } + const userRoles = (yield* appSelector(state => state.login?.user?.roles)) ?? []; const supportedVersionsResult = yield* call(checkSupportedVersions, { supportedVersions: serverRecord.supportedVersions, - serverVersion: serverRecord.version + serverVersion: serverRecord.version, + userRoles }); yield put(setSupportedVersions(supportedVersionsResult));