Skip to content

Commit

Permalink
feat: Allow organization managers to resend invitations (#4371)
Browse files Browse the repository at this point in the history
Co-authored-by: Sarah Rudder <[email protected]>
  • Loading branch information
svenaas and sknep committed Mar 27, 2024
1 parent 8ca0fa4 commit db00c54
Show file tree
Hide file tree
Showing 15 changed files with 167 additions and 35 deletions.
11 changes: 6 additions & 5 deletions api/controllers/organization.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ module.exports = wrapHandlers({

async invite(req, res) {
const {
body: { roleId, uaaEmail },
body: { roleId, uaaEmail, isResend },
params: { id },
user,
} = req;
Expand All @@ -48,9 +48,9 @@ module.exports = wrapHandlers({
return res.notFound();
}

const { email, inviteLink: link, origin } = await OrganizationService.inviteUserToOrganization(
user, org.id, toInt(roleId), uaaEmail
);
const { email, inviteLink: link, origin } = isResend
? await OrganizationService.resendInvite(user, uaaEmail)
: await OrganizationService.inviteUserToOrganization(user, org.id, toInt(roleId), uaaEmail);

// TODO - refactor above method to return user so this extra query is not necessary
const newUser = await User.byUAAEmail(email).findOne();
Expand All @@ -66,7 +66,8 @@ module.exports = wrapHandlers({
invite: { email, link },
};

EventCreator.audit(Event.labels.ORG_MANAGER_ACTION, req.user, 'User Invited by Org Manager', {
const auditMessage = isResend ? 'User Invite Resent by Org Manager' : 'User Invited by Org Manager';
EventCreator.audit(Event.labels.ORG_MANAGER_ACTION, req.user, auditMessage, {
organizationId: org.id,
roleId,
email,
Expand Down
1 change: 1 addition & 0 deletions api/serializers/uaa-identity.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const BaseSerializer = require('./base');
const attributes = {
userName: '',
email: '',
origin: '',
};

const adminAttributes = {
Expand Down
1 change: 1 addition & 0 deletions api/serializers/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const attributes = {
Organization: organizationSerializer.serialize(orgRole.Organization, isSystemAdmin),
Role: roleSerializer.serialize(orgRole.Role, isSystemAdmin),
})),
signedInAt: '',
};

const adminAttributes = {
Expand Down
26 changes: 24 additions & 2 deletions frontend/components/organization/Edit.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import federalistApi from '../../util/federalistApi';
import LoadingIndicator from '../LoadingIndicator';
import AddUserForm from './AddUserForm';
import RemoveUserForm from './RemoveUserForm';
import ResendInviteForm from './ResendInviteForm';
import UpdateUserForm from './UpdateUserForm';
import { timeFrom } from '../../util/datetime';
import { sandboxMsg } from '../../util';
Expand Down Expand Up @@ -200,7 +201,7 @@ function Edit({ actions }) {
<tr key={member.User.id}>
<th scope="row" data-title="Email">{member.User.UAAIdentity.email}</th>
<td data-title="Role">
<span className="usa-sr-only">No user to remove</span>
<span className="usa-sr-only">Role</span>
<UpdateUserForm
form={`updateOrganizationUser-${member.User.id}`}
initialValues={{ roleId: member.Role.id }}
Expand All @@ -223,7 +224,7 @@ function Edit({ actions }) {
{timeFrom(member.updatedAt)}
</td>
<td data-title="Actions" className="table-actions">
<span className="usa-sr-only">No user to remove</span>
<span className="usa-sr-only">User actions</span>
<RemoveUserForm
form={`removeOrganizationUser-${member.User.id}`}
onSubmit={() => true}
Expand All @@ -239,6 +240,26 @@ function Edit({ actions }) {
}
}
/>
{ member.User.UAAIdentity.origin === 'uaa' && !member.User.signedInAt && (
<ResendInviteForm
form={`resendInvite-${member.User.id}`}
onSubmit={() => true}
onSubmitSuccess={
async (_, reduxDispatch) => {
await actions.inviteToOrganization(
org.id,
{
uaaEmail: member.User.UAAIdentity.email,
user: member.User.id,
roleID: member.Role.id,
isResend: true,
}
);
reduxDispatch(successNotification('Successfully resent invitation.'));
}
}
/>
)}
</td>
</tr>
))}
Expand All @@ -257,6 +278,7 @@ Edit.propTypes = {
fetchOrganization: PropTypes.func.isRequired,
fetchRoles: PropTypes.func.isRequired,
inviteToOrganization: PropTypes.func.isRequired,
resendInviteToOrganization: PropTypes.func.isRequired,
removeOrganizationRole: PropTypes.func.isRequired,
updateOrganizationRole: PropTypes.func.isRequired,
}).isRequired,
Expand Down
2 changes: 1 addition & 1 deletion frontend/components/organization/RemoveUserForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const RemoveUserForm = ({
<form onSubmit={handleSubmit}>
<button
type="submit"
className="usa-button usa-button-secondary margin-0"
className="usa-button usa-button-red margin-0 small-button"
disabled={submitting}
>
Remove
Expand Down
30 changes: 30 additions & 0 deletions frontend/components/organization/ResendInviteForm.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React from 'react';
import PropTypes from 'prop-types';
import { reduxForm } from 'redux-form';
import { IconEnvelope } from '../icons';

const ResendInviteForm = ({
handleSubmit,
submitting,
}) => (
<form onSubmit={handleSubmit}>
<button
type="submit"
className="usa-button usa-button-secondary margin-0 small-button"
disabled={submitting}
>
<IconEnvelope />
Resend invite
</button>
</form>
);

ResendInviteForm.propTypes = {
// the following props are from reduxForm:
handleSubmit: PropTypes.func.isRequired,
submitting: PropTypes.bool.isRequired,
};

export { ResendInviteForm };

export default reduxForm()(ResendInviteForm);
2 changes: 1 addition & 1 deletion frontend/components/organization/UpdateUserForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const UpdateUserForm = ({
className="update-user-form"
>
<fieldset>
<legend className="usa-sr-only">Update user</legend>
<legend className="usa-sr-only">Update user role</legend>
<Field
id="roleId"
name="roleId"
Expand Down
2 changes: 1 addition & 1 deletion frontend/components/site/siteBuilds.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ function SiteBuilds() {
<CreateBuildLink
handlerParams={{ buildId: build.id, siteId: site.id }}
handleClick={buildActions.restartBuild}
className="usa-button rebuild-button"
className="usa-button small-button rebuild-button"
>
Rebuild
</CreateBuildLink>
Expand Down
5 changes: 3 additions & 2 deletions frontend/sass/_forms.scss
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,14 @@ select {
}

.update-user-form {
display: flex;
display: inline-flex;
justify-content: space-between;
align-items: center;
margin: 0;

fieldset {
margin: 0;
padding-right: 1rem;
display: inline;
}

Expand All @@ -81,4 +82,4 @@ select {

.user-org-select-option {
padding: 1rem;
}
}
45 changes: 24 additions & 21 deletions frontend/sass/_tables.scss
Original file line number Diff line number Diff line change
Expand Up @@ -118,21 +118,33 @@
}
}
.table-actions {

a {
margin: 0;
}
}
.rebuild-button {
margin-bottom: 0;
padding: .4em .6em;
font-size: 1.1em;
border-radius: 4px;
white-space: nowrap;
svg {
margin-right: .4em;
height: 1em;
width: auto;
vertical-align: bottom;
form {
& + form {
margin-top: .5em;
}
}

.small-button {
margin-bottom: 0;
padding: 0.4em 0.6em;
font-size: 1.1em;
border-radius: 4px;
white-space: nowrap;

svg {
margin-right: .4em;
height: 1em;
width: auto;
vertical-align: bottom;
.envelope-icon {
fill: currentColor
}
}

}
}

Expand Down Expand Up @@ -188,15 +200,6 @@
}
}
}
.table-actions > * {
margin-top: auto;
margin-bottom: auto;
.usa-button {
margin-top: 0;
margin-right: 0;
}
}

th, td {
display: flex;
align-items: stretch;
Expand Down
2 changes: 1 addition & 1 deletion public/images/icons/icon-envelope.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions public/swagger/User.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
},
"hasGithubAuth": {
"type": "boolean"
},
"signedInAt": {
"type": "date-time"
}
},
"not": {
Expand Down
5 changes: 5 additions & 0 deletions public/swagger/index.yml
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,11 @@ paths:
type: string
description: The UAA email of the invited user
required: true
- name: isResend
in: body
type: bool
description: Calls for a resent invitation rather than an initial invitation
required: false
- name: githubUsername
in: body
type: string
Expand Down
49 changes: 48 additions & 1 deletion test/api/requests/organization.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ describe('Organization API', () => {
validateAgainstJSONSchema('POST', '/organization/{id}/invite', 400, response.body);
});

it('returns the member and the invitation details for cloud.gov origin', async () => {
it('returns the member and the invitation details for cloud.gov origin upon invitation', async () => {
const uaaEmail = '[email protected]';
const roleId = userRole.id;
const origin = 'cloud.gov'
Expand Down Expand Up @@ -201,6 +201,53 @@ describe('Organization API', () => {
);
});

it('returns the member and the invitation details for cloud.gov origin upon resent invitation', async () => {
const uaaEmail = '[email protected]';
const roleId = userRole.id;
const origin = 'cloud.gov'
const isResend = true;

const [targetUser, org] = await Promise.all([
factory.user(),
factory.organization.create(),
]);

await Promise.all([
currentUser.addOrganization(org, { through: { roleId: managerRole.id } }),
targetUser.addOrganization(org, { through: { roleId } }),
factory.uaaIdentity({ userId: currentUser.id }),
factory.uaaIdentity({ userId: targetUser.id, email: uaaEmail }),
]);

const inviteLink = 'https://example.com';

sinon.stub(OrganizationService, 'resendInvite')
.resolves({
email: uaaEmail,
inviteLink,
origin,
});

sinon.stub(Mailer, 'sendUAAInvite').resolves();

const response = await authenticatedRequest
.post(`/v0/organization/${org.id}/invite`)
.set('x-csrf-token', csrfToken.getToken())
.send({ roleId, uaaEmail, isResend });

validateAgainstJSONSchema('POST', '/organization/{id}/invite', 200, response.body);
const { invite } = response.body;
expect(invite.email).to.eq(uaaEmail);
sinon.assert.calledOnceWithExactly(
Mailer.sendUAAInvite,
uaaEmail,
inviteLink,
origin,
org.name
);
});


it('returns a 400 error if the user is a manager of an inactive organization', async () => {
const uaaEmail = '[email protected]';
const roleId = userRole.id;
Expand Down
18 changes: 18 additions & 0 deletions test/frontend/components/organization/ResendInviteForm.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import React from 'react';
import { shallow } from 'enzyme';
import { expect } from 'chai';

import { ResendInviteForm } from '../../../../frontend/components/organization/ResendInviteForm';

describe('<ResendInviteForm />', () => {
it('renders', () => {
const props = {
handleSubmit: () => Promise.resolve(),
submitting: false,
};

const wrapper = shallow(<ResendInviteForm {...props} />);

expect(wrapper.find('form')).to.have.lengthOf(1);
});
});

0 comments on commit db00c54

Please sign in to comment.