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:
@@ -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)) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
57
server/commands/userInviter.js
Normal file
57
server/commands/userInviter.js
Normal 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 };
|
||||
}
|
||||
56
server/commands/userInviter.test.js
Normal file
56
server/commands/userInviter.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
59
server/emails/InviteEmail.js
Normal file
59
server/emails/InviteEmail.js
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user