feat: invites (#967)

* stub invite endpoint

* feat: First pass invite UI

* feat: allow removing invite rows

* First pass: sending logic

* fix: label accessibility

* fix: add button submits
incorrect permissions
middleware flow error

* 💚

* Error handling, email filtering, tests

* Flow

* Add Invite to people page
Remove old Tip

* Add copy link to subdomain
This commit is contained in:
Tom Moor
2019-06-24 22:14:59 -07:00
committed by GitHub
parent f406faf08e
commit d5192acabf
21 changed files with 509 additions and 103 deletions

View File

@@ -16,8 +16,12 @@ export default function pagination(options?: Object) {
};
let query = ctx.request.query;
let body: Object = ctx.request.body;
// $FlowFixMe
let body = ctx.request.body;
// $FlowFixMe
let limit = query.limit || body.limit;
// $FlowFixMe
let offset = query.offset || body.offset;
if (limit && isNaN(limit)) {

View File

@@ -12,6 +12,7 @@ import { ValidationError } from '../errors';
import { Event, User, Team } from '../models';
import auth from '../middlewares/authentication';
import pagination from './middlewares/pagination';
import userInviter from '../commands/userInviter';
import { presentUser } from '../presenters';
import policy from '../policies';
@@ -150,11 +151,6 @@ router.post('users.demote', auth(), async ctx => {
};
});
/**
* Suspend user
*
* Admin can suspend users to reduce the number of accounts on their billing plan
*/
router.post('users.suspend', auth(), async ctx => {
const admin = ctx.state.user;
const userId = ctx.body.id;
@@ -176,12 +172,6 @@ router.post('users.suspend', auth(), async ctx => {
};
});
/**
* Activate user
*
* Admin can activate users to let them access resources. These users will also
* account towards the billing plan limits.
*/
router.post('users.activate', auth(), async ctx => {
const admin = ctx.state.user;
const userId = ctx.body.id;
@@ -199,6 +189,20 @@ router.post('users.activate', auth(), async ctx => {
};
});
router.post('users.invite', auth(), async ctx => {
const { invites } = ctx.body;
ctx.assertPresent(invites, 'invites is required');
const user = ctx.state.user;
authorize(user, 'invite', User);
const invitesSent = await userInviter({ user, invites });
ctx.body = {
data: invitesSent,
};
});
router.post('users.delete', auth(), async ctx => {
const { confirmation } = ctx.body;
ctx.assertPresent(confirmation, 'confirmation is required');

View File

@@ -0,0 +1,57 @@
// @flow
import { uniqBy } from 'lodash';
import { User, Team } from '../models';
import events from '../events';
import mailer from '../mailer';
type Invite = { name: string, email: string };
export default async function documentMover({
user,
invites,
}: {
user: User,
invites: Invite[],
}): Promise<{ sent: Invite[] }> {
const team = await Team.findByPk(user.teamId);
// filter out empties, duplicates and non-emails
const compactedInvites = uniqBy(
invites.filter(invite => !!invite.email.trim() && invite.email.match('@')),
'email'
);
const emails = compactedInvites.map(invite => invite.email);
// filter out existing users
const existingUsers = await User.findAll({
where: {
teamId: user.teamId,
email: emails,
},
});
const existingEmails = existingUsers.map(user => user.email);
const filteredInvites = compactedInvites.filter(
invite => !existingEmails.includes(invite.email)
);
// send and record invites
filteredInvites.forEach(async invite => {
await mailer.invite({
to: invite.email,
name: invite.name,
actorName: user.name,
actorEmail: user.email,
teamName: team.name,
teamUrl: team.url,
});
events.add({
name: 'users.invite',
actorId: user.id,
teamId: user.teamId,
email: invite.email,
});
});
return { sent: filteredInvites };
}

View File

@@ -0,0 +1,56 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import userInviter from '../commands/userInviter';
import { flushdb } from '../test/support';
import { buildUser } from '../test/factories';
beforeEach(flushdb);
describe('userInviter', async () => {
it('should return sent invites', async () => {
const user = await buildUser();
const response = await userInviter({
invites: [{ email: 'test@example.com', name: 'Test' }],
user,
});
expect(response.sent.length).toEqual(1);
});
it('should filter empty invites', async () => {
const user = await buildUser();
const response = await userInviter({
invites: [{ email: ' ', name: 'Test' }],
user,
});
expect(response.sent.length).toEqual(0);
});
it('should filter obviously bunk emails', async () => {
const user = await buildUser();
const response = await userInviter({
invites: [{ email: 'notanemail', name: 'Test' }],
user,
});
expect(response.sent.length).toEqual(0);
});
it('should not send duplicates', async () => {
const user = await buildUser();
const response = await userInviter({
invites: [
{ email: 'the@same.com', name: 'Test' },
{ email: 'the@same.com', name: 'Test' },
],
user,
});
expect(response.sent.length).toEqual(1);
});
it('should not send invites to existing team members', async () => {
const user = await buildUser();
const response = await userInviter({
invites: [{ email: user.email, name: user.name }],
user,
});
expect(response.sent.length).toEqual(0);
});
});

View File

@@ -0,0 +1,59 @@
// @flow
import * as React from 'react';
import EmailTemplate from './components/EmailLayout';
import Body from './components/Body';
import Button from './components/Button';
import Heading from './components/Heading';
import Header from './components/Header';
import Footer from './components/Footer';
import EmptySpace from './components/EmptySpace';
export type Props = {
name: string,
actorName: string,
actorEmail: string,
teamName: string,
teamUrl: string,
};
export const inviteEmailText = ({
teamName,
actorName,
actorEmail,
teamUrl,
}: Props) => `
Join ${teamName} on Outline
${actorName} (${
actorEmail
}) has invited you to join Outline, a place for your team to build and share knowledge.
Join now: ${teamUrl}
`;
export const InviteEmail = ({
teamName,
actorName,
actorEmail,
teamUrl,
}: Props) => {
return (
<EmailTemplate>
<Header />
<Body>
<Heading>Join {teamName} on Outline</Heading>
<p>
{actorName} ({actorEmail}) has invited you to join Outline, a place
for your team to build and share knowledge.
</p>
<EmptySpace height={10} />
<p>
<Button href={teamUrl}>Join now</Button>
</p>
</Body>
<Footer />
</EmailTemplate>
);
};

View File

@@ -2,18 +2,25 @@
import Queue from 'bull';
import services from './services';
type UserEvent = {
export type UserEvent =
| {
name: | 'users.create' // eslint-disable-line
| 'users.update'
| 'users.suspend'
| 'users.activate'
| 'users.delete',
modelId: string,
teamId: string,
actorId: string,
};
| 'users.update'
| 'users.suspend'
| 'users.activate'
| 'users.delete',
modelId: string,
teamId: string,
actorId: string,
}
| {
name: 'users.invite',
teamId: string,
actorId: string,
email: string,
};
type DocumentEvent =
export type DocumentEvent =
| {
name: | 'documents.create' // eslint-disable-line
| 'documents.publish'
@@ -48,7 +55,7 @@ type DocumentEvent =
done: boolean,
};
type CollectionEvent =
export type CollectionEvent =
| {
name: | 'collections.create' // eslint-disable-line
| 'collections.update'
@@ -65,7 +72,7 @@ type CollectionEvent =
actorId: string,
};
type IntegrationEvent = {
export type IntegrationEvent = {
name: 'integrations.create' | 'integrations.update' | 'collections.delete',
modelId: string,
teamId: string,

View File

@@ -8,6 +8,11 @@ import Queue from 'bull';
import { baseStyles } from './emails/components/EmailLayout';
import { WelcomeEmail, welcomeEmailText } from './emails/WelcomeEmail';
import { ExportEmail, exportEmailText } from './emails/ExportEmail';
import {
type Props as InviteEmailT,
InviteEmail,
inviteEmailText,
} from './emails/InviteEmail';
import {
type Props as DocumentNotificationEmailT,
DocumentNotificationEmail,
@@ -105,6 +110,19 @@ export class Mailer {
});
};
invite = async (opts: { to: string } & InviteEmailT) => {
this.sendMail({
to: opts.to,
title: `${opts.actorName} invited you to join ${
opts.teamName
}s knowledgebase`,
previewText:
'Outline is a place for your team to build and share knowledge.',
html: <InviteEmail {...opts} />,
text: inviteEmailText(opts),
});
};
documentNotification = async (
opts: { to: string } & DocumentNotificationEmailT
) => {

View File

@@ -239,7 +239,6 @@ Collection.prototype.updateDocument = async function(
this.documentStructure = updateChildren(this.documentStructure);
await this.save({ transaction });
await transaction.commit();
} catch (err) {
if (transaction) {
await transaction.rollback();
@@ -296,7 +295,6 @@ Collection.prototype.removeDocumentInStructure = async function(
transaction,
});
await transaction.commit();
} catch (err) {
if (transaction) {
await transaction.rollback();

View File

@@ -12,6 +12,10 @@ allow(
(actor, user) => user && user.teamId === actor.teamId
);
allow(User, 'invite', User, actor => {
return true;
});
allow(User, ['update', 'delete'], User, (actor, user) => {
if (!user || user.teamId !== actor.teamId) return false;
if (user.id === actor.id) return true;

View File

@@ -1,6 +1,6 @@
// @flow
import { Op } from '../sequelize';
import type { Event } from '../events';
import type { DocumentEvent, CollectionEvent, Event } from '../events';
import { Document, Collection, User, NotificationSetting } from '../models';
import mailer from '../mailer';
@@ -16,7 +16,7 @@ export default class Notifications {
}
}
async documentUpdated(event: Event) {
async documentUpdated(event: DocumentEvent) {
// lets not send a notification on every autosave update
if (event.autosave) return;
@@ -71,7 +71,7 @@ export default class Notifications {
});
}
async collectionCreated(event: Event) {
async collectionCreated(event: CollectionEvent) {
const collection = await Collection.findByPk(event.modelId, {
include: [
{

View File

@@ -1,5 +1,5 @@
// @flow
import type { Event } from '../events';
import type { DocumentEvent, IntegrationEvent, Event } from '../events';
import { Document, Integration, Collection, Team } from '../models';
import { presentSlackAttachment } from '../presenters';
@@ -15,7 +15,7 @@ export default class Slack {
}
}
async integrationCreated(event: Event) {
async integrationCreated(event: IntegrationEvent) {
const integration = await Integration.findOne({
where: {
id: event.modelId,
@@ -56,7 +56,7 @@ export default class Slack {
});
}
async documentUpdated(event: Event) {
async documentUpdated(event: DocumentEvent) {
// lets not send a notification on every autosave update
if (event.autosave) return;