From ed29312fd6920e371bace269159f89002ff50ab5 Mon Sep 17 00:00:00 2001 From: Niko Lindroos Date: Wed, 16 Oct 2024 16:58:19 +0300 Subject: [PATCH] feat: messages can be sent to all only with special permission KK-1039. A list of recipients in Message form will include the choice "all" only when the current user has 'canSendToAllInProject' permission (in my admin profile). The editing of the message record is prevented by hiding the edit action buttons, when the message is not for the currently active project or the message has recipients set to "all" and the current user does not have permissions to send to "all". Fixed the recipient selection in messages form, which should be validated as required. Updated the related browser tests. --- browser-tests/messageFeature.ts | 7 ++- browser-tests/pages/messages.ts | 1 + browser-tests/utils/jwt/clientUtils/login.ts | 2 +- browser-tests/utils/jwt/services.ts | 2 +- browser-tests/utils/jwt/types.ts | 5 +- src/common/translation/en.json | 8 ++- src/common/translation/fi.json | 8 ++- src/common/translation/sv.json | 8 ++- src/domain/api/generatedTypes/graphql.tsx | 32 +++++++---- .../__tests__/authProvider.test.js | 1 + .../__tests__/authService.test.js | 1 + src/domain/authentication/authProvider.ts | 5 ++ .../authentication/authorizationService.ts | 14 +++-- src/domain/messages/choices.ts | 24 ++++++++ .../messages/detail/MessageDetailsToolbar.tsx | 56 ++++++++++++++++--- src/domain/messages/detail/MessagesDetail.tsx | 14 ++++- src/domain/messages/form/MessageForm.tsx | 26 +++++++-- src/domain/messages/queries/MessageQueries.ts | 4 ++ src/domain/messages/validations.ts | 16 +++++- src/domain/profile/queries.ts | 1 + 20 files changed, 195 insertions(+), 40 deletions(-) diff --git a/browser-tests/messageFeature.ts b/browser-tests/messageFeature.ts index dd3ea4c3..167a1de4 100644 --- a/browser-tests/messageFeature.ts +++ b/browser-tests/messageFeature.ts @@ -1,4 +1,4 @@ -import { within } from '@testing-library/testcafe'; +import { within, screen } from '@testing-library/testcafe'; import { RequestMock } from 'testcafe'; import { routes } from './pages/routes'; @@ -25,6 +25,7 @@ function buildTestMessage(protocol = '') { const protocolLabel = protocol ? ` (${protocol})` : ''; return { + recipientSelection: 'Kaikki', subject: `Browser test message ${new Date().toJSON()}${protocolLabel}`, bodyText: `Test body text ${new Date().toJSON()}${protocolLabel}`, }; @@ -36,6 +37,8 @@ async function addMessage(t: TestController) { // Fill in subject and body fields, then submit the form await t + .click(messagesCreatePage.recipientSelectionInput) + .click(screen.findByText(t.ctx.message.recipientSelection)) .typeText(messagesCreatePage.subjectInput, t.ctx.message.subject) .typeText(messagesCreatePage.bodyTextInput, t.ctx.message.bodyText) .click(messagesCreatePage.submitCreateMessageForm); @@ -285,6 +288,8 @@ test('As an admin I should be able to send SMS messages', async (t) => { // eslint-disable-next-line no-console console.debug('Submitting form and sending the message.'); await t + .click(messagesCreatePage.recipientSelectionInput) + .click(screen.findByText(t.ctx.message.recipientSelection)) .typeText(messagesCreatePage.bodyTextInput, t.ctx.sms.bodyText) .click(messagesCreatePage.submitAndSendMessage); diff --git a/browser-tests/pages/messages.ts b/browser-tests/pages/messages.ts index b57b360e..9bfb8151 100644 --- a/browser-tests/pages/messages.ts +++ b/browser-tests/pages/messages.ts @@ -10,6 +10,7 @@ export const messagesListPage = { }; export const messagesCreatePage = { + recipientSelectionInput: screen.getByLabelText('Vastaanottajat *'), title: screen.getByText('Uusi sähköpostiviesti'), subjectInput: screen.getByLabelText('Viestin otsikko *'), bodyTextInput: screen.getByLabelText('Viestin teksti *'), diff --git a/browser-tests/utils/jwt/clientUtils/login.ts b/browser-tests/utils/jwt/clientUtils/login.ts index 27c8a2b8..a66263c0 100644 --- a/browser-tests/utils/jwt/clientUtils/login.ts +++ b/browser-tests/utils/jwt/clientUtils/login.ts @@ -132,7 +132,7 @@ const _handleReactAdminPermissions = async (oidcUserData: OIDCUserDataType) => { role: 'admin', // 'admin' as in authorizationService.getRole projects: { // All project permissions, see authorizationService.fetchRole - [projectId]: ['publish', 'manageEventGroups'], + [projectId]: ['publish', 'manageEventGroups', 'canSendToAllInProject'], }, }; // eslint-disable-next-line no-console diff --git a/browser-tests/utils/jwt/services.ts b/browser-tests/utils/jwt/services.ts index 69f34fc5..8fba2b6a 100644 --- a/browser-tests/utils/jwt/services.ts +++ b/browser-tests/utils/jwt/services.ts @@ -15,7 +15,7 @@ async function fetchMyAdminProfile(apiToken: string) { 'content-type': 'application/json', }), // eslint-disable-next-line max-len - body: '{"operationName":"MyAdminProfile","variables":{},"query":"query MyAdminProfile {\\n myAdminProfile {\\n id\\n projects {\\n edges {\\n node {\\n id\\n year\\n name\\n myPermissions {\\n publish\\n manageEventGroups\\n __typename\\n }\\n __typename\\n }\\n __typename\\n }\\n __typename\\n }\\n __typename\\n }\\n}"}', + body: '{"operationName":"MyAdminProfile","variables":{},"query":"query MyAdminProfile {\\n myAdminProfile {\\n id\\n projects {\\n edges {\\n node {\\n id\\n year\\n name\\n myPermissions {\\n publish\\n manageEventGroups\\n canSendToAllInProject\\n __typename\\n }\\n __typename\\n }\\n __typename\\n }\\n __typename\\n }\\n __typename\\n }\\n}"}', method: 'POST', } ); diff --git a/browser-tests/utils/jwt/types.ts b/browser-tests/utils/jwt/types.ts index f02d9b96..163f493d 100644 --- a/browser-tests/utils/jwt/types.ts +++ b/browser-tests/utils/jwt/types.ts @@ -81,7 +81,10 @@ export type OIDCTokenEndpointRefreshResponseType = { 'not-before-policy': 0; }; -type ProjectPermission = 'manageEventGroups' | 'publish'; +type ProjectPermission = + | 'manageEventGroups' + | 'publish' + | 'canSendToAllInProject'; export type PermissionsStoragePermission = { role: null | 'admin' | 'none'; diff --git a/src/common/translation/en.json b/src/common/translation/en.json index 5a7625cd..37640c34 100644 --- a/src/common/translation/en.json +++ b/src/common/translation/en.json @@ -289,6 +289,11 @@ } }, "fields": { + "project": { + "year": { + "label": "Year group" + } + }, "recipientSelection": { "choices": { "ALL": { @@ -307,7 +312,8 @@ "label": "Subscribed to free spot message" } }, - "label": "Recipients" + "label": "Recipients", + "required": "The recipient selection is required" }, "bodyText": { "label": "Message text", diff --git a/src/common/translation/fi.json b/src/common/translation/fi.json index 00a7d093..04760525 100644 --- a/src/common/translation/fi.json +++ b/src/common/translation/fi.json @@ -289,6 +289,11 @@ } }, "fields": { + "project": { + "year": { + "label": "Vuosiryhmä" + } + }, "recipientSelection": { "choices": { "ALL": { @@ -307,7 +312,8 @@ "label": "Ilmoituksen tilanneet" } }, - "label": "Vastaanottajat" + "label": "Vastaanottajat", + "required": "Vastaanottajan valitseminen on pakollista." }, "bodyText": { "label": "Viestin teksti", diff --git a/src/common/translation/sv.json b/src/common/translation/sv.json index 5a7625cd..37640c34 100644 --- a/src/common/translation/sv.json +++ b/src/common/translation/sv.json @@ -289,6 +289,11 @@ } }, "fields": { + "project": { + "year": { + "label": "Year group" + } + }, "recipientSelection": { "choices": { "ALL": { @@ -307,7 +312,8 @@ "label": "Subscribed to free spot message" } }, - "label": "Recipients" + "label": "Recipients", + "required": "The recipient selection is required" }, "bodyText": { "label": "Message text", diff --git a/src/domain/api/generatedTypes/graphql.tsx b/src/domain/api/generatedTypes/graphql.tsx index 723e0b92..cf95a548 100644 --- a/src/domain/api/generatedTypes/graphql.tsx +++ b/src/domain/api/generatedTypes/graphql.tsx @@ -80,6 +80,7 @@ export type AddMessageMutationInput = { occurrenceIds?: InputMaybe>; projectId: Scalars['ID']['input']; protocol: ProtocolType; + /** Set the scope for message recipients. The 'ALL' is valid only when a user has a specific permission. */ recipientSelection: RecipientSelectionEnum; /** Sends the message directly after the save */ sendDirectly?: InputMaybe; @@ -126,7 +127,7 @@ export type AdminNode = Node & { /** The ID of the object */ id: Scalars['ID']['output']; projects: Maybe; - /** Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only. */ + /** Vaaditaan. Enintään 150 merkkiä. Vain kirjaimet, numerot ja @/./+/-/_ ovat sallittuja. */ username: Scalars['String']['output']; }; @@ -842,11 +843,11 @@ export type LanguageTranslationType = { /** An enumeration. */ export enum LanguagesLanguageTranslationLanguageCodeChoices { - /** English */ + /** englanti */ En = 'EN', - /** Finnish */ + /** suomi */ Fi = 'FI', - /** Swedish */ + /** ruotsi */ Sv = 'SV' } @@ -948,7 +949,7 @@ export type MessageTranslationsInput = { /** An enumeration. */ export enum MessagingMessageProtocolChoices { - /** Email */ + /** Sähköposti */ Email = 'EMAIL', /** SMS */ Sms = 'SMS' @@ -956,11 +957,11 @@ export enum MessagingMessageProtocolChoices { /** An enumeration. */ export enum MessagingMessageTranslationLanguageCodeChoices { - /** English */ + /** englanti */ En = 'EN', - /** Finnish */ + /** suomi */ Fi = 'FI', - /** Swedish */ + /** ruotsi */ Sv = 'SV' } @@ -1347,6 +1348,7 @@ export type ProjectNodeEdge = { export type ProjectPermissionsType = { __typename?: 'ProjectPermissionsType'; + canSendToAllInProject: Maybe; manageEventGroups: Maybe; publish: Maybe; }; @@ -1853,6 +1855,7 @@ export type UpdateMessageMutationInput = { occurrenceIds?: InputMaybe>; projectId?: InputMaybe; protocol?: InputMaybe; + /** Set the scope for message recipients. The 'ALL' is valid only when a user has a specific permission. */ recipientSelection?: InputMaybe; translations?: InputMaybe>>; }; @@ -2168,7 +2171,7 @@ export type SendMessageMutationVariables = Exact<{ export type SendMessageMutation = { __typename?: 'Mutation', sendMessage: { __typename?: 'SendMessageMutationPayload', message: { __typename?: 'MessageNode', id: string } | null } | null }; -export type MessageFragment = { __typename?: 'MessageNode', id: string, subject: string | null, bodyText: string | null, recipientSelection: RecipientSelectionEnum | null, recipientCount: number | null, sentAt: any | null, protocol: MessagingMessageProtocolChoices, event: { __typename?: 'EventNode', id: string, name: string | null } | null, translations: Array<{ __typename?: 'MessageTranslationType', languageCode: MessagingMessageTranslationLanguageCodeChoices, subject: string, bodyText: string }>, occurrences: { __typename?: 'OccurrenceNodeConnection', edges: Array<{ __typename?: 'OccurrenceNodeEdge', node: { __typename?: 'OccurrenceNode', id: string, time: any } | null } | null> } }; +export type MessageFragment = { __typename?: 'MessageNode', id: string, subject: string | null, bodyText: string | null, recipientSelection: RecipientSelectionEnum | null, recipientCount: number | null, sentAt: any | null, protocol: MessagingMessageProtocolChoices, project: { __typename?: 'ProjectNode', id: string, year: number }, event: { __typename?: 'EventNode', id: string, name: string | null } | null, translations: Array<{ __typename?: 'MessageTranslationType', languageCode: MessagingMessageTranslationLanguageCodeChoices, subject: string, bodyText: string }>, occurrences: { __typename?: 'OccurrenceNodeConnection', edges: Array<{ __typename?: 'OccurrenceNodeEdge', node: { __typename?: 'OccurrenceNode', id: string, time: any } | null } | null> } }; export type MessagesQueryVariables = Exact<{ projectId: InputMaybe; @@ -2178,14 +2181,14 @@ export type MessagesQueryVariables = Exact<{ }>; -export type MessagesQuery = { __typename?: 'Query', messages: { __typename?: 'MessageNodeConnection', count: number, edges: Array<{ __typename?: 'MessageNodeEdge', node: { __typename?: 'MessageNode', id: string, subject: string | null, bodyText: string | null, recipientSelection: RecipientSelectionEnum | null, recipientCount: number | null, sentAt: any | null, protocol: MessagingMessageProtocolChoices, event: { __typename?: 'EventNode', id: string, name: string | null } | null, translations: Array<{ __typename?: 'MessageTranslationType', languageCode: MessagingMessageTranslationLanguageCodeChoices, subject: string, bodyText: string }>, occurrences: { __typename?: 'OccurrenceNodeConnection', edges: Array<{ __typename?: 'OccurrenceNodeEdge', node: { __typename?: 'OccurrenceNode', id: string, time: any } | null } | null> } } | null } | null> } | null }; +export type MessagesQuery = { __typename?: 'Query', messages: { __typename?: 'MessageNodeConnection', count: number, edges: Array<{ __typename?: 'MessageNodeEdge', node: { __typename?: 'MessageNode', id: string, subject: string | null, bodyText: string | null, recipientSelection: RecipientSelectionEnum | null, recipientCount: number | null, sentAt: any | null, protocol: MessagingMessageProtocolChoices, project: { __typename?: 'ProjectNode', id: string, year: number }, event: { __typename?: 'EventNode', id: string, name: string | null } | null, translations: Array<{ __typename?: 'MessageTranslationType', languageCode: MessagingMessageTranslationLanguageCodeChoices, subject: string, bodyText: string }>, occurrences: { __typename?: 'OccurrenceNodeConnection', edges: Array<{ __typename?: 'OccurrenceNodeEdge', node: { __typename?: 'OccurrenceNode', id: string, time: any } | null } | null> } } | null } | null> } | null }; export type MessageQueryVariables = Exact<{ id: Scalars['ID']['input']; }>; -export type MessageQuery = { __typename?: 'Query', message: { __typename?: 'MessageNode', id: string, subject: string | null, bodyText: string | null, recipientSelection: RecipientSelectionEnum | null, recipientCount: number | null, sentAt: any | null, protocol: MessagingMessageProtocolChoices, event: { __typename?: 'EventNode', id: string, name: string | null } | null, translations: Array<{ __typename?: 'MessageTranslationType', languageCode: MessagingMessageTranslationLanguageCodeChoices, subject: string, bodyText: string }>, occurrences: { __typename?: 'OccurrenceNodeConnection', edges: Array<{ __typename?: 'OccurrenceNodeEdge', node: { __typename?: 'OccurrenceNode', id: string, time: any } | null } | null> } } | null }; +export type MessageQuery = { __typename?: 'Query', message: { __typename?: 'MessageNode', id: string, subject: string | null, bodyText: string | null, recipientSelection: RecipientSelectionEnum | null, recipientCount: number | null, sentAt: any | null, protocol: MessagingMessageProtocolChoices, project: { __typename?: 'ProjectNode', id: string, year: number }, event: { __typename?: 'EventNode', id: string, name: string | null } | null, translations: Array<{ __typename?: 'MessageTranslationType', languageCode: MessagingMessageTranslationLanguageCodeChoices, subject: string, bodyText: string }>, occurrences: { __typename?: 'OccurrenceNodeConnection', edges: Array<{ __typename?: 'OccurrenceNodeEdge', node: { __typename?: 'OccurrenceNode', id: string, time: any } | null } | null> } } | null }; export type AddOccurrenceMutationVariables = Exact<{ input: AddOccurrenceMutationInput; @@ -2233,7 +2236,7 @@ export type OccurrenceQuery = { __typename?: 'Query', occurrence: { __typename?: export type MyAdminProfileQueryVariables = Exact<{ [key: string]: never; }>; -export type MyAdminProfileQuery = { __typename?: 'Query', myAdminProfile: { __typename?: 'AdminNode', id: string, projects: { __typename?: 'ProjectNodeConnection', edges: Array<{ __typename?: 'ProjectNodeEdge', node: { __typename?: 'ProjectNode', id: string, year: number, name: string | null, myPermissions: { __typename?: 'ProjectPermissionsType', publish: boolean | null, manageEventGroups: boolean | null } | null } | null } | null> } | null } | null }; +export type MyAdminProfileQuery = { __typename?: 'Query', myAdminProfile: { __typename?: 'AdminNode', id: string, projects: { __typename?: 'ProjectNodeConnection', edges: Array<{ __typename?: 'ProjectNodeEdge', node: { __typename?: 'ProjectNode', id: string, year: number, name: string | null, myPermissions: { __typename?: 'ProjectPermissionsType', publish: boolean | null, manageEventGroups: boolean | null, canSendToAllInProject: boolean | null } | null } | null } | null> } | null } | null }; export type ImportTicketSystemPasswordsMutationVariables = Exact<{ input: ImportTicketSystemPasswordsMutationInput; @@ -2365,6 +2368,10 @@ export const MessageFragmentDoc = gql` recipientCount sentAt protocol + project { + id + year + } event { id name @@ -3047,6 +3054,7 @@ export const MyAdminProfileDocument = gql` myPermissions { publish manageEventGroups + canSendToAllInProject } } } diff --git a/src/domain/authentication/__tests__/authProvider.test.js b/src/domain/authentication/__tests__/authProvider.test.js index be8f3993..d5441ce7 100644 --- a/src/domain/authentication/__tests__/authProvider.test.js +++ b/src/domain/authentication/__tests__/authProvider.test.js @@ -124,6 +124,7 @@ describe('authProvider', () => { Object { "canManageEventGroupsWithinProject": [Function], "canPublishWithinProject": [Function], + "canSendMessagesToAllRecipientsWithinProject": [Function], "role": "admin", } `); diff --git a/src/domain/authentication/__tests__/authService.test.js b/src/domain/authentication/__tests__/authService.test.js index 2bf0359b..79de6388 100644 --- a/src/domain/authentication/__tests__/authService.test.js +++ b/src/domain/authentication/__tests__/authService.test.js @@ -29,6 +29,7 @@ describe('authService', () => { myPermissions: { publish: true, manageEventGroups: true, + canSendToAllInProject: true, }, }, }, diff --git a/src/domain/authentication/authProvider.ts b/src/domain/authentication/authProvider.ts index fb55f526..8f7a5384 100644 --- a/src/domain/authentication/authProvider.ts +++ b/src/domain/authentication/authProvider.ts @@ -7,6 +7,9 @@ export type Permissions = { role: null | 'admin' | 'none'; canPublishWithinProject: (projectId?: string) => boolean | null; canManageEventGroupsWithinProject: (projectId?: string) => boolean | null; + canSendMessagesToAllRecipientsWithinProject: ( + projectId?: string + ) => boolean | null; }; const authProvider: AuthProvider = { @@ -62,6 +65,8 @@ const authProvider: AuthProvider = { canPublishWithinProject: authorizationService.canPublishWithinProject, canManageEventGroupsWithinProject: authorizationService.canManageEventGroupsWithinProject, + canSendMessagesToAllRecipientsWithinProject: + authorizationService.canSendMessagesToAllRecipientsWithinProject, }); }, }; diff --git a/src/domain/authentication/authorizationService.ts b/src/domain/authentication/authorizationService.ts index 08394c9c..0325e55d 100644 --- a/src/domain/authentication/authorizationService.ts +++ b/src/domain/authentication/authorizationService.ts @@ -54,6 +54,8 @@ export class AuthorizationService { this.canPublishWithinProject = this.canPublishWithinProject.bind(this); this.canManageEventGroupsWithinProject = this.canManageEventGroupsWithinProject.bind(this); + this.canSendMessagesToAllRecipientsWithinProject = + this.canSendMessagesToAllRecipientsWithinProject.bind(this); } private get permissionStorage(): null | PermissionStorage { @@ -99,9 +101,7 @@ export class AuthorizationService { if (!projectId) { return null; } - const projectPermissions = this.getProjectPermissions(projectId); - return projectPermissions.includes('publish'); } @@ -109,12 +109,18 @@ export class AuthorizationService { if (!projectId) { return null; } - const projectPermissions = this.getProjectPermissions(projectId); - return projectPermissions.includes('manageEventGroups'); } + canSendMessagesToAllRecipientsWithinProject(projectId?: string) { + if (!projectId) { + return null; + } + const projectPermissions = this.getProjectPermissions(projectId); + return projectPermissions.includes('canSendToAllInProject'); + } + private getProjectPermissions(projectId: string) { return this.permissionStorage?.projects[projectId] || []; } diff --git a/src/domain/messages/choices.ts b/src/domain/messages/choices.ts index dd779998..d3e4b670 100644 --- a/src/domain/messages/choices.ts +++ b/src/domain/messages/choices.ts @@ -28,6 +28,30 @@ export const recipientSelectionChoices: { id: RecipientId; name: string }[] = [ }, ]; +export const getFilteredRecipientSelectionChoicesByPermissions = ({ + hasPermissionToSendToAll = false, +}: { + hasPermissionToSendToAll: boolean; +}): typeof recipientSelectionChoices => { + if (!hasPermissionToSendToAll) { + return recipientSelectionChoices.filter((choice) => choice.id !== 'ALL'); + } + return recipientSelectionChoices; +}; + +export const getRecipientSelectionChoicesByPermissions = ({ + hasPermissionToSendToAll = false, +}: { + hasPermissionToSendToAll: boolean; +}): typeof recipientSelectionChoices => { + if (!hasPermissionToSendToAll) { + return recipientSelectionChoices.map((choice) => + choice.id !== 'ALL' ? choice : { ...choice, disabled: true } + ); + } + return recipientSelectionChoices; +}; + export const recipientsWithEventSelection: RecipientId[] = [ 'ENROLLED', 'ATTENDED', diff --git a/src/domain/messages/detail/MessageDetailsToolbar.tsx b/src/domain/messages/detail/MessageDetailsToolbar.tsx index d7d82135..b62563f6 100644 --- a/src/domain/messages/detail/MessageDetailsToolbar.tsx +++ b/src/domain/messages/detail/MessageDetailsToolbar.tsx @@ -6,13 +6,19 @@ import { TopToolbar, useResourceContext, useRecordContext, + usePermissions, } from 'react-admin'; import { makeStyles } from '@mui/styles'; import Typography from '@mui/material/Typography'; import { toDateTimeString } from '../../../common/utils'; import MessageSendButton from './MessageSendButton'; -import type { MessageNode } from '../../api/generatedTypes/graphql'; +import { + RecipientSelectionEnum, + type MessageNode, +} from '../../api/generatedTypes/graphql'; +import type { Permissions } from '../../authentication/authProvider'; +import projectService from '../../projects/projectService'; const useMessageDetailsToolbarStyles = makeStyles((theme) => ({ wrapper: { @@ -30,13 +36,44 @@ const useMessageDetailsToolbarStyles = makeStyles((theme) => ({ }, })); +/** + * If the message is set to be sent to "all" recipients and user doesn't + * have permissions to send to "all", the Message editing should be prevented. + * Also, the message must be for the current project that is active in + * the React-Admin UI. + */ +const useResolveEditPermission = () => { + const record = useRecordContext(); + const projectId = projectService.projectId ?? ''; + const isMessageOfCurrentProject = Boolean(projectId === record?.project?.id); + const { permissions, isLoading } = usePermissions(); + const canSendMessagesToAllRecipientsWithinProject = Boolean( + permissions?.canSendMessagesToAllRecipientsWithinProject(projectId) + ); + const rejectIfForAll = Boolean( + record?.recipientSelection?.toUpperCase() === RecipientSelectionEnum.All && + !canSendMessagesToAllRecipientsWithinProject + ); + // Record (message) available, + // the message is for current active project and + // if it is for "all" recipients, user must have permissions to send for "all". + const hasEditPermission = + Boolean(record) && isMessageOfCurrentProject && !rejectIfForAll; + + return { + hasEditPermission, + isLoading, + }; +}; + const MessageDetailToolbar = () => { const record = useRecordContext(); const resource = useResourceContext(); const basePath = `/${resource}`; const classes = useMessageDetailsToolbarStyles(); const t = useTranslate(); - + const { hasEditPermission, isLoading: isLoadingPermissions } = + useResolveEditPermission(); const isSent = Boolean(record?.sentAt); if (isSent) { @@ -58,13 +95,18 @@ const MessageDetailToolbar = () => { ); } + // Don't render a toolbar with edit actions, + // if permissions loading is still ongoing or user does not have + // edit permissions for current record. + if (!record || isLoadingPermissions || !hasEditPermission) { + return null; + } + return ( - {record && } - {record && } - {record && basePath && ( - - )} + + + {basePath && } ); }; diff --git a/src/domain/messages/detail/MessagesDetail.tsx b/src/domain/messages/detail/MessagesDetail.tsx index eb1feaf4..62cd0053 100644 --- a/src/domain/messages/detail/MessagesDetail.tsx +++ b/src/domain/messages/detail/MessagesDetail.tsx @@ -18,6 +18,7 @@ import { recipientSelectionChoices } from '../choices'; import TranslatableProvider from '../../../common/providers/TranslatableProvider'; import MessageDetailToolbar from './MessageDetailsToolbar'; import useTranslatableContext from '../../../common/hooks/useTranslatableContext'; +import type { MessageNode } from '../../api/generatedTypes/graphql'; import { ProtocolType } from '../../api/generatedTypes/graphql'; const useStyles = makeStyles((theme) => ({ @@ -87,6 +88,11 @@ function MessageDetails() { {record.protocol !== ProtocolType.Sms && languageTabsComponent} + record?.project?.year} + /> { + render={(record?: MessageNode) => { const stringifiedRecords = - record?.occurrences?.edges.map((connection: any) => - toShortDateTimeString(new Date(connection.node.time)) + record?.occurrences?.edges.map((connection) => + connection?.node + ? toShortDateTimeString(new Date(connection.node.time)) + : '' ) ?? []; if (stringifiedRecords.length === 0) { diff --git a/src/domain/messages/form/MessageForm.tsx b/src/domain/messages/form/MessageForm.tsx index 938bc8e1..fb2d87ee 100644 --- a/src/domain/messages/form/MessageForm.tsx +++ b/src/domain/messages/form/MessageForm.tsx @@ -8,6 +8,7 @@ import { FormDataConsumer, useTranslate, useRecordContext, + usePermissions, } from 'react-admin'; import { makeStyles } from '@mui/styles'; import Typography from '@mui/material/Typography'; @@ -24,16 +25,17 @@ import { emailMessageSchema, } from '../validations'; import { - recipientSelectionChoices, + getRecipientSelectionChoicesByPermissions, recipientsWithEventSelection, } from '../choices'; import TranslatableProvider from '../../../common/providers/TranslatableProvider'; import TranslatableContext from '../../../common/contexts/TranslatableContext'; import { ProtocolType } from '../../api/generatedTypes/graphql'; +import type { Permissions } from '../../authentication/authProvider'; +import projectService from '../../projects/projectService'; const CustomOnChange = ({ children, onChange, ...rest }: any) => { const form = useFormContext(); - return React.cloneElement(children, { ...rest, onChange: (e: ChangeEvent) => onChange(e, form), @@ -85,7 +87,12 @@ type Props = Omit & { const MessageForm = ({ protocol, ...delegatedProps }: Props) => { const record = useRecordContext(); - + const { permissions, isLoading: isLoadingPermissions } = + usePermissions(); + const projectId = projectService.projectId ?? ''; + const canSendMessagesToAllRecipientsWithinProject = Boolean( + permissions?.canSendMessagesToAllRecipientsWithinProject(projectId) + ); // When editing, the record exists and the protocol is already set. if (record?.protocol) { protocol = record.protocol; @@ -94,6 +101,13 @@ const MessageForm = ({ protocol, ...delegatedProps }: Props) => { const t = useTranslate(); const classes = useStyles(); + // Filter the list of recipient choices by permissions + // NOTE: use `getFilteredRecipientSelectionChoicesByPermissions` if + // the values are wanted to be hidden instead of disabled. + const recipientSelectionChoices = getRecipientSelectionChoicesByPermissions({ + hasPermissionToSendToAll: canSendMessagesToAllRecipientsWithinProject, + }); + const handleRecipientSelectionChange = ( event: ChangeEvent, form: ReturnType @@ -122,6 +136,10 @@ const MessageForm = ({ protocol, ...delegatedProps }: Props) => { form.setValue(name, value); }; + if (isLoadingPermissions) { + return null; + } + return ( { label="messages.fields.recipientSelection.label" choices={recipientSelectionChoices} validate={validateRecipientSelection} - defaultValue="ALL" + defaultValue="" fullWidth formClassName={classes.recipientSelection} /> diff --git a/src/domain/messages/queries/MessageQueries.ts b/src/domain/messages/queries/MessageQueries.ts index 50d76c8b..e3cd1df8 100644 --- a/src/domain/messages/queries/MessageQueries.ts +++ b/src/domain/messages/queries/MessageQueries.ts @@ -9,6 +9,10 @@ const MessageFragment = gql` recipientCount sentAt protocol + project { + id + year + } event { id name diff --git a/src/domain/messages/validations.ts b/src/domain/messages/validations.ts index fcaf0750..d127c2a9 100644 --- a/src/domain/messages/validations.ts +++ b/src/domain/messages/validations.ts @@ -11,18 +11,22 @@ export const validateBodyText = required(); export const validateCapacityPerOccurrence = [minValue(0), required()]; export const validateRecipientSelection = [ - choices(recipientSelectionChoices.map((choice) => choice.id)), + required('messages.fields.recipientSelection.required'), + choices( + recipientSelectionChoices.map((choice) => choice.id), + 'messages.fields.recipientSelection.required' + ), ]; export const getEmailMessagesTranslatedFieldsSchema = (lang: Language) => object({ bodyText: lang === Language.Fi - ? string().required('message.translations.FI.subject.required') + ? string().required('message.translations.FI.bodyText.required') : string(), subject: lang === Language.Fi - ? string().required('message.translations.FI.bodyText.required') + ? string().required('message.translations.FI.subject.required') : string(), }); @@ -35,6 +39,9 @@ export const getSmsMessagesTranslatedFieldsSchema = (lang: Language) => }); export const emailMessageSchema = object({ + recipientSelection: string().required( + 'messages.fields.recipientSelection.required' + ), translations: object({ [Language.Fi]: getEmailMessagesTranslatedFieldsSchema(Language.Fi), [Language.Sv]: getEmailMessagesTranslatedFieldsSchema(Language.Sv), @@ -43,6 +50,9 @@ export const emailMessageSchema = object({ }); export const smsMessageSchema = object({ + recipientSelection: string().required( + 'messages.fields.recipientSelection.required' + ), translations: object({ [Language.Fi]: getSmsMessagesTranslatedFieldsSchema(Language.Fi), [Language.Sv]: getSmsMessagesTranslatedFieldsSchema(Language.Sv), diff --git a/src/domain/profile/queries.ts b/src/domain/profile/queries.ts index 3ac6fc9d..482d3dc0 100644 --- a/src/domain/profile/queries.ts +++ b/src/domain/profile/queries.ts @@ -13,6 +13,7 @@ export const myAdminProfileQuery = gql` myPermissions { publish manageEventGroups + canSendToAllInProject } } }