Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Notify exsting users with invite #218

Merged
merged 1 commit into from
Jun 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions app/src/controllers/email.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

import config from 'config';
import emailService from '../services/email';

Expand All @@ -11,11 +10,7 @@ const controller = {
* Send email using CHES API
* https://ches.api.gov.bc.ca/api/v1/docs#tag/EmailMerge/operation/postMerge
*/
send: async (
req: Request<never, never, Email>,
res: Response,
next: NextFunction
) => {
send: async (req: Request<never, never, Email>, res: Response, next: NextFunction) => {
try {
req.body.from = config.get('server.ches.from');
req.body.bodyType = 'html';
Expand Down
7 changes: 3 additions & 4 deletions app/src/validators/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { emailJoi } from './common';
import { validate } from '../middleware/validation';

const schema = {

mergeSchema: {
body: Joi.object().keys({
body: Joi.string().required(),
Expand All @@ -13,13 +12,13 @@ const schema = {
Joi.object().keys({
to: Joi.array().items(emailJoi).required(),
context: Joi.object().keys({
token: Joi.string().required(),
token: Joi.string(),
fullName: Joi.string()
})
})
)
})
},

}
};

export default {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/common/BulkPermission.vue
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ const onSubmit = handleSubmit(async (values: any, { resetForm }) => {
}

// format results into human-readable descriptions
results.value = toBulkResult(values.notFound, values.action, resultData);
results.value = toBulkResult(values.notFound, values.action, false, resultData);

// refresh store
if (props.resourceType === 'object') {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/common/BulkPermissionResults.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Column, DataTable, Dialog } from '@/lib/primevue';
import { Button, Column, DataTable, Dialog } from '@/lib/primevue';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';

// Props
Expand Down Expand Up @@ -31,7 +31,7 @@
>
<DataTable
ref="batchResults"
:export-filename="`${resourceType === 'object' ? resource.name.replace(/\.[^/.]+$/, '') : resource.bucketName}_bulk_results`"

Check warning on line 34 in frontend/src/components/common/BulkPermissionResults.vue

View workflow job for this annotation

GitHub Actions / Unit Tests (Frontend) (16.x)

This line has a length of 131. Maximum allowed is 120

Check warning on line 34 in frontend/src/components/common/BulkPermissionResults.vue

View workflow job for this annotation

GitHub Actions / Unit Tests (Frontend) (18.x)

This line has a length of 131. Maximum allowed is 120

Check warning on line 34 in frontend/src/components/common/BulkPermissionResults.vue

View workflow job for this annotation

GitHub Actions / Unit Tests (Frontend) (20.x)

This line has a length of 131. Maximum allowed is 120

Check warning on line 34 in frontend/src/components/common/BulkPermissionResults.vue

View workflow job for this annotation

GitHub Actions / Unit Tests (Frontend) (16.x)

This line has a length of 131. Maximum allowed is 120

Check warning on line 34 in frontend/src/components/common/BulkPermissionResults.vue

View workflow job for this annotation

GitHub Actions / Unit Tests (Frontend) (18.x)

This line has a length of 131. Maximum allowed is 120

Check warning on line 34 in frontend/src/components/common/BulkPermissionResults.vue

View workflow job for this annotation

GitHub Actions / Unit Tests (Frontend) (20.x)

This line has a length of 131. Maximum allowed is 120
:value="props.results"
class="p-datatable-striped"
>
Expand Down
42 changes: 33 additions & 9 deletions frontend/src/components/common/Invite.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { storeToRefs } from 'pinia';
import { useForm, ErrorMessage } from 'vee-validate';
import * as yup from 'yup';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { Button, RadioButton, Checkbox, useToast, TextArea } from '@/lib/primevue';
import { Button, RadioButton, Checkbox, InputSwitch, useToast, TextArea } from '@/lib/primevue';
import TextInput from '@/components/form/TextInput.vue';
import { Spinner } from '@/components/layout';
import { BulkPermissionResults } from '@/components/common';
Expand Down Expand Up @@ -101,7 +101,8 @@ const { values, defineField, handleSubmit } = useForm({
permCodes: props.resourceType === 'object' ? ['READ'] : [],
email: '',
emailType: 'single',
multiEmail: ''
multiEmail: '',
notify: true
}
});
// maps the input models for vee-validate
Expand All @@ -110,6 +111,7 @@ const [permCodes] = defineField('permCodes', {});
const [emailType] = defineField('emailType', {});
const [email] = defineField('email', {});
const [multiEmail] = defineField('multiEmail', {});
const [notify] = defineField('notify', {});

// require READ perm for file invites
const isDisabled = (optionValue: string) => {
Expand All @@ -136,7 +138,7 @@ const onSubmit = handleSubmit(async (values: any, { resetForm }) => {
values.permCodes.forEach((pc: string) => {
permData.push({ userId: users[0].userId, permCode: pc });
});
return { email: email, userId: users[0].userId, permissions: [] };
return { email: email, user: users[0], permissions: [] };
} else {
newUsers.push(email);
return { email: email };
Expand All @@ -150,13 +152,25 @@ const onSubmit = handleSubmit(async (values: any, { resetForm }) => {
props.resourceType === 'object'
? await permissionService.objectAddPermissions(resourceId.value, permData)
: await permissionService.bucketAddPermissions(resourceId.value, permData);
// add permissions data to result
permResponse.data.forEach((p: any) => {
const el = resultData.find((r: any) => r.userId === p.userId);
el.permissions.push({
createdAt: p.createdAt,
permCode: p.permCode
});
const el = resultData.find((r: any) => r.user.userId === p.userId);
el.permissions.push({ createdAt: p.createdAt, permCode: p.permCode });
});
// if notifying existing users about this file/folder
if (values.notify) {
const users = resultData.filter((r: any) => r.user).map((r: any) => r.user);
const emailResponse = await inviteService.notifyUsers(
props.resourceType,
props.resource,
getUser.value?.profile,
users
);
// add to results
emailResponse.data.messages.forEach((msg: { msgId: string; to: Array<string> }) => {
resultData.find((r: any) => r.email === msg.to[0]).chesMsgId = msg.msgId;
});
}
}

// generate invites (for emails not already in the system)
Expand All @@ -176,7 +190,7 @@ const onSubmit = handleSubmit(async (values: any, { resetForm }) => {
});
}
// format results into human-readable descriptions
results.value = toBulkResult('invite', 'add', resultData);
results.value = toBulkResult('invite', 'add', values.notify, resultData);
complete.value = true;
resetForm();
} catch (error: any) {
Expand Down Expand Up @@ -311,6 +325,16 @@ const onSubmit = handleSubmit(async (values: any, { resetForm }) => {
</div>
</div>

<p class="mb-2">If a person you are inviting is already using BCBox</p>
<div class="flex flex-wrap gap-3 mb-3">
<InputSwitch
v-model="notify"
aria-label="Notify existing BCBox users"
/>
<span v-if="notify">email them a link to the {{ props.resourceType === 'bucket' ? 'folder' : 'file' }}</span>
<span v-else>don't send them a notification</span>
</div>

<div class="my-4 inline-flex">
<Button
class="p-button p-button-primary mr-3"
Expand Down
48 changes: 46 additions & 2 deletions frontend/src/services/inviteService.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { appAxios, comsAxios } from './interceptors';
import { invite as inviteEmailTemplate } from '@/utils/emailTemplates';
import { invite as inviteEmailTemplate, notify as notifyEmailTemplate } from '@/utils/emailTemplates';

const PATH = 'permission/invite';

Expand Down Expand Up @@ -60,7 +60,7 @@ export default {
* @param {string} resourceType eg bucket or object
* @param {COMSObject | Bucket } resource COMS object or bucket
* @param {User | null} currentUser current user creating the invite
* @param {Array<string>} invites array of email adddresses
* @param {Array<{object}>} invites array of email and token pairs
* @returns {Promise<string>} CHES TransactionId
*/
emailInvites(resourceType: string, resource: any, currentUser: any, invites: any) {
Expand Down Expand Up @@ -97,6 +97,50 @@ export default {
}
},

/**
* @function notifyUsers
* Semd email to each invitee containing a link to the resource
* ref: https://ches.api.gov.bc.ca/api/v1/docs#tag/EmailMerge/operation/postMerge *
* @param {string} resourceType eg bucket or object
* @param {COMSObject | Bucket } resource COMS object or bucket
* @param {User | null} currentUser current user creating the invite
* @param {Array<User>} users array of BCBox users
* @returns {Promise<string>} CHES TransactionId
*/
notifyUsers(resourceType: string, resource: any, currentUser: any, users: Array<any>) {
TimCsaky marked this conversation as resolved.
Show resolved Hide resolved
try {
let resourceName: string, subject: string, resourceUrl: string;
// alternate templates depending if resource is a file or a folder
if (resourceType === 'object') {
TimCsaky marked this conversation as resolved.
Show resolved Hide resolved
resourceName = resource.name;
subject = `You have been invited to access ${resourceName} on BCBox`;
resourceUrl = `${window.location.origin}/detail/objects?objectId=${resource.id}`;
} else {
TimCsaky marked this conversation as resolved.
Show resolved Hide resolved
resourceName = resource.bucketName;
subject = `You have been invited to access ${resourceName} on BCBox`;
resourceUrl = `${window.location.origin}/list/objects?bucketId=${resource.bucketId}`;
}
// build html template for email body
const body = notifyEmailTemplate(resourceType, resourceName, resourceUrl, currentUser);
// define email data matching the structure required by CHES api
const emailData: any = {
contexts: users.map((user: any) => {
return {
to: [user.email],
context: {
fullName: user.fullName ? user.fullName : 'BCBox user'
}
};
}),
subject: subject,
body: body
};
return appAxios().post('email', emailData);
} catch (err) {
return Promise.reject(err);
}
},

/**
* @function getInvite
* Use an invite token
Expand Down
38 changes: 38 additions & 0 deletions frontend/src/utils/emailTemplates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,41 @@ export function invite(resourceType: string, resourceName: string, currentUser:

return html;
}

/**
* creates html email body for share notification
* @param {string} resourceType either 'object' or 'bucket'
* @param {string} resourceName the object name or bucket name
* @param {string} resourceUrl the URL to the resource
* @param {User | null} currentUser current user sending the invite
* @returns {string} the template html
*/
export function notify(resourceType: string, resourceName: string, resourceUrl: string, currentUser: any): string {
let html = '';
// eslint-disable-next-line max-len
const currentUserEmail = `<a href="mailto:${currentUser.email}" style="color: #1a5a96 !important">${currentUser.email}</a>`;
// alternate templates depending if resource is a file or a folder
if (resourceType === 'object') {
TimCsaky marked this conversation as resolved.
Show resolved Hide resolved
html += '<html style="color: #495057 !important; max-width: 500px !important;"><br>';
html += '<p style="color: #495057 !important;">{{fullName}},</p>';
html += `<h2 style="color: #495057 !important;">${currentUserEmail} invited you to access a file on BCBox</h2>`;
html += '<p style="color: #495057 !important;">';
html += `Here's a link to access the file that ${currentUserEmail} shared with you:</p>`;
} else if (resourceType === 'bucket') {
html += '<html"><br>';
html += `<h2 style="color: #495057 !important;">${currentUserEmail} invited you to access a folder on BCBox</h2>\n`;
html += '<p style="color: #495057 !important;">';
html += `Here's a link to access the folder that ${currentUserEmail} shared with you:</p>`;
}
html += '<p>';
html += `<strong><a style="font-size: large; color: #1a5a96" href="${resourceUrl}">`;
html += `${resourceName}</a></strong></p><br>`;
html += `<small style="color: #495057 !important;">
If you do not recognize the sender, do not click on the link above.<br>
Only open links that you are expecting from a known sender.
</small><br><br>
<a style="color: #1a5a96" href="${window.location.origin}">Learn more about BCBox</a>
</html>`;

return html;
}
5 changes: 4 additions & 1 deletion frontend/src/utils/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,19 +44,21 @@ export function toKebabCase(str: string | null) {
* transforms an array of invite/add/remove data into an array of human-readable descriptions
* @param {string} notFound if user not found (eg: 'invite' or 'ignore')
* @param {string} action permission action (eg: 'add' or 'remove')
* @param {boolean} notify whether existing users were notified
* @param {object[]} data results invite/add/remove
* @returns {object[]} an array of human-readable descriptions
*/
export function toBulkResult(
notFound: string,
action: string,
notify: boolean = false,
data: Array<{ email: string; chesMsgId: string; permissions: Array<{ permCode: string }> | undefined }>
) {
const result = data.map((r) => {
let description: string = 'No action taken';
let status: number = 1;
// invites
if (r.chesMsgId && notFound === 'invite') description = 'Invite emailed';
if (r.chesMsgId && notFound === 'invite' && !r.permissions) description = 'Invite emailed';
else if (notFound === 'ignore' && !r.permissions) {
description = 'No invite was emailed';
status = 0;
Expand All @@ -72,6 +74,7 @@ export function toBulkResult(
description = 'Permissions already existed';
status = 0;
}
if (notify) description += '; notification emailed';
}
// removing permissions
else if (action === 'remove' && r.permissions) {
Expand Down
Loading