Skip to content

Commit

Permalink
feat: messages can be sent to all only with special permission
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
nikomakela committed Oct 18, 2024
1 parent 1ab6439 commit ed29312
Show file tree
Hide file tree
Showing 20 changed files with 195 additions and 40 deletions.
7 changes: 6 additions & 1 deletion browser-tests/messageFeature.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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}`,
};
Expand All @@ -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);
Expand Down Expand Up @@ -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);

Expand Down
1 change: 1 addition & 0 deletions browser-tests/pages/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 *'),
Expand Down
2 changes: 1 addition & 1 deletion browser-tests/utils/jwt/clientUtils/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion browser-tests/utils/jwt/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}
);
Expand Down
5 changes: 4 additions & 1 deletion browser-tests/utils/jwt/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,10 @@ export type OIDCTokenEndpointRefreshResponseType = {
'not-before-policy': 0;
};

type ProjectPermission = 'manageEventGroups' | 'publish';
type ProjectPermission =
| 'manageEventGroups'
| 'publish'
| 'canSendToAllInProject';

export type PermissionsStoragePermission<T extends string = string> = {
role: null | 'admin' | 'none';
Expand Down
8 changes: 7 additions & 1 deletion src/common/translation/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,11 @@
}
},
"fields": {
"project": {
"year": {
"label": "Year group"
}
},
"recipientSelection": {
"choices": {
"ALL": {
Expand 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",
Expand Down
8 changes: 7 additions & 1 deletion src/common/translation/fi.json
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,11 @@
}
},
"fields": {
"project": {
"year": {
"label": "Vuosiryhmä"
}
},
"recipientSelection": {
"choices": {
"ALL": {
Expand All @@ -307,7 +312,8 @@
"label": "Ilmoituksen tilanneet"
}
},
"label": "Vastaanottajat"
"label": "Vastaanottajat",
"required": "Vastaanottajan valitseminen on pakollista."
},
"bodyText": {
"label": "Viestin teksti",
Expand Down
8 changes: 7 additions & 1 deletion src/common/translation/sv.json
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,11 @@
}
},
"fields": {
"project": {
"year": {
"label": "Year group"
}
},
"recipientSelection": {
"choices": {
"ALL": {
Expand 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",
Expand Down
32 changes: 20 additions & 12 deletions src/domain/api/generatedTypes/graphql.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export type AddMessageMutationInput = {
occurrenceIds?: InputMaybe<Array<Scalars['ID']['input']>>;
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<Scalars['Boolean']['input']>;
Expand Down Expand Up @@ -126,7 +127,7 @@ export type AdminNode = Node & {
/** The ID of the object */
id: Scalars['ID']['output'];
projects: Maybe<ProjectNodeConnection>;
/** Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only. */
/** Vaaditaan. Enintään 150 merkkiä. Vain kirjaimet, numerot ja @/./+/-/_ ovat sallittuja. */
username: Scalars['String']['output'];
};

Expand Down Expand Up @@ -842,11 +843,11 @@ export type LanguageTranslationType = {

/** An enumeration. */
export enum LanguagesLanguageTranslationLanguageCodeChoices {
/** English */
/** englanti */
En = 'EN',
/** Finnish */
/** suomi */
Fi = 'FI',
/** Swedish */
/** ruotsi */
Sv = 'SV'
}

Expand Down Expand Up @@ -948,19 +949,19 @@ export type MessageTranslationsInput = {

/** An enumeration. */
export enum MessagingMessageProtocolChoices {
/** Email */
/** Sähköposti */
Email = 'EMAIL',
/** SMS */
Sms = 'SMS'
}

/** An enumeration. */
export enum MessagingMessageTranslationLanguageCodeChoices {
/** English */
/** englanti */
En = 'EN',
/** Finnish */
/** suomi */
Fi = 'FI',
/** Swedish */
/** ruotsi */
Sv = 'SV'
}

Expand Down Expand Up @@ -1347,6 +1348,7 @@ export type ProjectNodeEdge = {

export type ProjectPermissionsType = {
__typename?: 'ProjectPermissionsType';
canSendToAllInProject: Maybe<Scalars['Boolean']['output']>;
manageEventGroups: Maybe<Scalars['Boolean']['output']>;
publish: Maybe<Scalars['Boolean']['output']>;
};
Expand Down Expand Up @@ -1853,6 +1855,7 @@ export type UpdateMessageMutationInput = {
occurrenceIds?: InputMaybe<Array<Scalars['ID']['input']>>;
projectId?: InputMaybe<Scalars['ID']['input']>;
protocol?: InputMaybe<ProtocolType>;
/** Set the scope for message recipients. The 'ALL' is valid only when a user has a specific permission. */
recipientSelection?: InputMaybe<RecipientSelectionEnum>;
translations?: InputMaybe<Array<InputMaybe<MessageTranslationsInput>>>;
};
Expand Down Expand Up @@ -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<Scalars['ID']['input']>;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -2365,6 +2368,10 @@ export const MessageFragmentDoc = gql`
recipientCount
sentAt
protocol
project {
id
year
}
event {
id
name
Expand Down Expand Up @@ -3047,6 +3054,7 @@ export const MyAdminProfileDocument = gql`
myPermissions {
publish
manageEventGroups
canSendToAllInProject
}
}
}
Expand Down
1 change: 1 addition & 0 deletions src/domain/authentication/__tests__/authProvider.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ describe('authProvider', () => {
Object {
"canManageEventGroupsWithinProject": [Function],
"canPublishWithinProject": [Function],
"canSendMessagesToAllRecipientsWithinProject": [Function],
"role": "admin",
}
`);
Expand Down
1 change: 1 addition & 0 deletions src/domain/authentication/__tests__/authService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ describe('authService', () => {
myPermissions: {
publish: true,
manageEventGroups: true,
canSendToAllInProject: true,
},
},
},
Expand Down
5 changes: 5 additions & 0 deletions src/domain/authentication/authProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -62,6 +65,8 @@ const authProvider: AuthProvider = {
canPublishWithinProject: authorizationService.canPublishWithinProject,
canManageEventGroupsWithinProject:
authorizationService.canManageEventGroupsWithinProject,
canSendMessagesToAllRecipientsWithinProject:
authorizationService.canSendMessagesToAllRecipientsWithinProject,
});
},
};
Expand Down
14 changes: 10 additions & 4 deletions src/domain/authentication/authorizationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -99,22 +101,26 @@ export class AuthorizationService {
if (!projectId) {
return null;
}

const projectPermissions = this.getProjectPermissions(projectId);

return projectPermissions.includes('publish');
}

canManageEventGroupsWithinProject(projectId?: string) {
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] || [];
}
Expand Down
24 changes: 24 additions & 0 deletions src/domain/messages/choices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading

0 comments on commit ed29312

Please sign in to comment.