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:
@ -114,7 +114,7 @@ export default function Button({
|
||||
const hasIcon = icon !== undefined;
|
||||
|
||||
return (
|
||||
<RealButton small={small} {...rest}>
|
||||
<RealButton small={small} type={type} {...rest}>
|
||||
<Inner hasIcon={hasIcon} small={small} disclosure={disclosure}>
|
||||
{hasIcon && icon}
|
||||
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
|
||||
|
@ -3,6 +3,7 @@ import * as React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import { observable } from 'mobx';
|
||||
import styled from 'styled-components';
|
||||
import VisuallyHidden from 'components/VisuallyHidden';
|
||||
import Flex from 'shared/components/Flex';
|
||||
|
||||
const RealTextarea = styled.textarea`
|
||||
@ -38,9 +39,10 @@ const RealInput = styled.input`
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
flex: ${props => (props.flex ? '1' : '0')};
|
||||
max-width: ${props => (props.short ? '350px' : '100%')};
|
||||
min-height: ${({ minHeight }) => (minHeight ? `${minHeight}px` : '0')};
|
||||
max-height: ${({ maxHeight }) => (maxHeight ? `${maxHeight}px` : 'auto')};
|
||||
max-height: ${({ maxHeight }) => (maxHeight ? `${maxHeight}px` : 'initial')};
|
||||
`;
|
||||
|
||||
export const Outline = styled(Flex)`
|
||||
@ -70,6 +72,8 @@ export type Props = {
|
||||
value?: string,
|
||||
label?: string,
|
||||
className?: string,
|
||||
labelHidden?: boolean,
|
||||
flex?: boolean,
|
||||
short?: boolean,
|
||||
};
|
||||
|
||||
@ -86,14 +90,28 @@ class Input extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { type = 'text', label, className, short, ...rest } = this.props;
|
||||
const {
|
||||
type = 'text',
|
||||
label,
|
||||
className,
|
||||
short,
|
||||
flex,
|
||||
labelHidden,
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
const InputComponent = type === 'textarea' ? RealTextarea : RealInput;
|
||||
const wrappedLabel = <LabelText>{label}</LabelText>;
|
||||
|
||||
return (
|
||||
<Wrapper className={className} short={short}>
|
||||
<Wrapper className={className} short={short} flex={flex}>
|
||||
<label>
|
||||
{label && <LabelText>{label}</LabelText>}
|
||||
{label &&
|
||||
(labelHidden ? (
|
||||
<VisuallyHidden>{wrappedLabel}</VisuallyHidden>
|
||||
) : (
|
||||
wrappedLabel
|
||||
))}
|
||||
<Outline focused={this.focused}>
|
||||
<InputComponent
|
||||
onBlur={this.handleBlur}
|
||||
|
@ -7,9 +7,12 @@ import {
|
||||
EditIcon,
|
||||
SearchIcon,
|
||||
StarredIcon,
|
||||
PlusIcon,
|
||||
} from 'outline-icons';
|
||||
|
||||
import Flex from 'shared/components/Flex';
|
||||
import Modal from 'components/Modal';
|
||||
import Invite from 'scenes/Invite';
|
||||
import AccountMenu from 'menus/AccountMenu';
|
||||
import Sidebar from './Sidebar';
|
||||
import Scrollable from 'components/Scrollable';
|
||||
@ -22,6 +25,7 @@ import Bubble from './components/Bubble';
|
||||
import AuthStore from 'stores/AuthStore';
|
||||
import DocumentsStore from 'stores/DocumentsStore';
|
||||
import UiStore from 'stores/UiStore';
|
||||
import { observable } from 'mobx';
|
||||
|
||||
type Props = {
|
||||
auth: AuthStore,
|
||||
@ -31,6 +35,8 @@ type Props = {
|
||||
|
||||
@observer
|
||||
class MainSidebar extends React.Component<Props> {
|
||||
@observable inviteModalOpen: boolean = false;
|
||||
|
||||
componentDidMount() {
|
||||
this.props.documents.fetchDrafts();
|
||||
}
|
||||
@ -39,6 +45,14 @@ class MainSidebar extends React.Component<Props> {
|
||||
this.props.ui.setActiveModal('collection-new');
|
||||
};
|
||||
|
||||
handleInviteModalOpen = () => {
|
||||
this.inviteModalOpen = true;
|
||||
};
|
||||
|
||||
handleInviteModalClose = () => {
|
||||
this.inviteModalOpen = false;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { auth, documents } = this.props;
|
||||
const { user, team } = auth;
|
||||
@ -110,9 +124,23 @@ class MainSidebar extends React.Component<Props> {
|
||||
documents.active ? documents.active.isArchived : undefined
|
||||
}
|
||||
/>
|
||||
{user.isAdmin && (
|
||||
<SidebarLink
|
||||
onClick={this.handleInviteModalOpen}
|
||||
icon={<PlusIcon />}
|
||||
label="Invite people…"
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
</Scrollable>
|
||||
</Flex>
|
||||
<Modal
|
||||
title="Invite people"
|
||||
onRequestClose={this.handleInviteModalClose}
|
||||
isOpen={this.inviteModalOpen}
|
||||
>
|
||||
<Invite onSubmit={this.handleInviteModalClose} />
|
||||
</Modal>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
@ -1,61 +0,0 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { observable } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import styled from 'styled-components';
|
||||
import Tip from './Tip';
|
||||
import CopyToClipboard from './CopyToClipboard';
|
||||
import Team from '../models/Team';
|
||||
|
||||
type Props = {
|
||||
team: Team,
|
||||
disabled: boolean,
|
||||
};
|
||||
|
||||
@observer
|
||||
class TipInvite extends React.Component<Props> {
|
||||
@observable linkCopied: boolean = false;
|
||||
|
||||
handleCopy = () => {
|
||||
this.linkCopied = true;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { team, disabled } = this.props;
|
||||
if (disabled) return null;
|
||||
|
||||
return (
|
||||
<Tip id="subdomain-invite">
|
||||
<Heading>Looking to invite your team?</Heading>
|
||||
<Paragraph>
|
||||
Your teammates can sign in with{' '}
|
||||
{team.slackConnected ? 'Slack' : 'Google'} to join this knowledgebase
|
||||
at your team’s own subdomain ({team.url.replace(/^https?:\/\//, '')})
|
||||
–{' '}
|
||||
<CopyToClipboard text={team.url} onCopy={this.handleCopy}>
|
||||
<a>
|
||||
{this.linkCopied
|
||||
? 'link copied to clipboard!'
|
||||
: 'copy a link to share.'}
|
||||
</a>
|
||||
</CopyToClipboard>
|
||||
</Paragraph>
|
||||
</Tip>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Heading = styled.h3`
|
||||
margin: 0.25em 0 0.5em 0;
|
||||
`;
|
||||
|
||||
const Paragraph = styled.p`
|
||||
margin: 0.25em 0;
|
||||
|
||||
a {
|
||||
color: ${props => props.theme.text};
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
export default TipInvite;
|
13
app/components/VisuallyHidden.js
Normal file
13
app/components/VisuallyHidden.js
Normal file
@ -0,0 +1,13 @@
|
||||
// @flow
|
||||
import styled from 'styled-components';
|
||||
|
||||
const VisuallyHidden = styled('span')`
|
||||
position: absolute !important;
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(1px 1px 1px 1px); /* IE6, IE7 */
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
`;
|
||||
|
||||
export default VisuallyHidden;
|
@ -52,4 +52,7 @@ window.addEventListener('load', async () => {
|
||||
|
||||
window.ga('require', 'outboundLinkTracker');
|
||||
window.ga('require', 'urlChangeTracker');
|
||||
window.ga('require', 'eventTracker', {
|
||||
attributePrefix: 'data-',
|
||||
});
|
||||
});
|
||||
|
@ -13,7 +13,6 @@ import PageTitle from 'components/PageTitle';
|
||||
import Tabs from 'components/Tabs';
|
||||
import Tab from 'components/Tab';
|
||||
import PaginatedDocumentList from '../components/PaginatedDocumentList';
|
||||
import TipInvite from 'components/TipInvite';
|
||||
|
||||
type Props = {
|
||||
documents: DocumentsStore,
|
||||
@ -30,10 +29,6 @@ class Dashboard extends React.Component<Props> {
|
||||
return (
|
||||
<CenteredContent>
|
||||
<PageTitle title="Home" />
|
||||
<TipInvite
|
||||
team={auth.team}
|
||||
disabled={!auth.team.subdomain || !auth.user.isAdmin}
|
||||
/>
|
||||
<h1>Home</h1>
|
||||
<Tabs>
|
||||
<Tab to="/dashboard" exact>
|
||||
|
165
app/scenes/Invite.js
Normal file
165
app/scenes/Invite.js
Normal file
@ -0,0 +1,165 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { observable } from 'mobx';
|
||||
import { inject, observer } from 'mobx-react';
|
||||
import { CloseIcon } from 'outline-icons';
|
||||
import styled from 'styled-components';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import CopyToClipboard from 'components/CopyToClipboard';
|
||||
import Button from 'components/Button';
|
||||
import Input from 'components/Input';
|
||||
import HelpText from 'components/HelpText';
|
||||
import Tooltip from 'components/Tooltip';
|
||||
|
||||
import UiStore from 'stores/UiStore';
|
||||
import AuthStore from 'stores/AuthStore';
|
||||
import UsersStore from 'stores/UsersStore';
|
||||
|
||||
const MAX_INVITES = 20;
|
||||
|
||||
type Props = {
|
||||
auth: AuthStore,
|
||||
users: UsersStore,
|
||||
history: Object,
|
||||
ui: UiStore,
|
||||
onSubmit: () => void,
|
||||
};
|
||||
|
||||
@observer
|
||||
class Invite extends React.Component<Props> {
|
||||
@observable isSaving: boolean;
|
||||
@observable linkCopied: boolean = false;
|
||||
@observable
|
||||
invites: { email: string, name: string }[] = [
|
||||
{ email: '', name: '' },
|
||||
{ email: '', name: '' },
|
||||
{ email: '', name: '' },
|
||||
];
|
||||
|
||||
handleSubmit = async (ev: SyntheticEvent<*>) => {
|
||||
ev.preventDefault();
|
||||
this.isSaving = true;
|
||||
|
||||
try {
|
||||
await this.props.users.invite(this.invites);
|
||||
this.props.onSubmit();
|
||||
this.props.ui.showToast('We sent out your invites!');
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(err.message);
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
};
|
||||
|
||||
handleChange = (ev, index) => {
|
||||
this.invites[index][ev.target.name] = ev.target.value;
|
||||
};
|
||||
|
||||
handleAdd = () => {
|
||||
if (this.invites.length >= MAX_INVITES) {
|
||||
this.props.ui.showToast(
|
||||
`Sorry, you can only send ${MAX_INVITES} invites at a time`
|
||||
);
|
||||
}
|
||||
|
||||
this.invites.push({ email: '', name: '' });
|
||||
};
|
||||
|
||||
handleRemove = (index: number) => {
|
||||
this.invites.splice(index, 1);
|
||||
};
|
||||
|
||||
handleCopy = () => {
|
||||
this.linkCopied = true;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { team, user } = this.props.auth;
|
||||
if (!team || !user) return null;
|
||||
|
||||
const predictedDomain = user.email.split('@')[1];
|
||||
|
||||
return (
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
<HelpText>
|
||||
Send invites to your team members to get them kick started. Currently,
|
||||
they must be able to sign in with your team{' '}
|
||||
{team.slackConnected ? 'Slack' : 'Google'} account to be able to join
|
||||
Outline.
|
||||
</HelpText>
|
||||
{team.subdomain && (
|
||||
<HelpText>
|
||||
You can also{' '}
|
||||
<CopyToClipboard text={team.url} onCopy={this.handleCopy}>
|
||||
<a>{this.linkCopied ? 'link copied' : 'copy a link'}</a>
|
||||
</CopyToClipboard>{' '}
|
||||
to your teams signin page.
|
||||
</HelpText>
|
||||
)}
|
||||
{this.invites.map((invite, index) => (
|
||||
<Flex key={index}>
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
label="Email"
|
||||
labelHidden={index !== 0}
|
||||
onChange={ev => this.handleChange(ev, index)}
|
||||
placeholder={`example@${predictedDomain}`}
|
||||
value={invite.email}
|
||||
autoFocus={index === 0}
|
||||
flex
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
name="name"
|
||||
label="Full name"
|
||||
labelHidden={index !== 0}
|
||||
onChange={ev => this.handleChange(ev, index)}
|
||||
value={invite.name}
|
||||
required={!!invite.email}
|
||||
flex
|
||||
/>
|
||||
{index !== 0 && (
|
||||
<Remove>
|
||||
<Tooltip tooltip="Remove invite" placement="top">
|
||||
<CloseIcon onClick={() => this.handleRemove(index)} />
|
||||
</Tooltip>
|
||||
</Remove>
|
||||
)}
|
||||
</Flex>
|
||||
))}
|
||||
|
||||
<Flex justify="space-between">
|
||||
{this.invites.length <= MAX_INVITES ? (
|
||||
<Button type="button" onClick={this.handleAdd} neutral>
|
||||
Add another…
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={this.isSaving}
|
||||
data-on="click"
|
||||
data-event-category="invite"
|
||||
data-event-action="sendInvites"
|
||||
>
|
||||
{this.isSaving ? 'Inviting…' : 'Send Invites'}
|
||||
</Button>
|
||||
</Flex>
|
||||
<br />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Remove = styled('div')`
|
||||
margin-top: 6px;
|
||||
position: absolute;
|
||||
right: -32px;
|
||||
`;
|
||||
|
||||
export default inject('auth', 'users', 'ui')(withRouter(Invite));
|
@ -1,10 +1,14 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import invariant from 'invariant';
|
||||
import { observable } from 'mobx';
|
||||
import { observer, inject } from 'mobx-react';
|
||||
|
||||
import AuthStore from 'stores/AuthStore';
|
||||
import UsersStore from 'stores/UsersStore';
|
||||
import Modal from 'components/Modal';
|
||||
import Button from 'components/Button';
|
||||
import Invite from 'scenes/Invite';
|
||||
import CenteredContent from 'components/CenteredContent';
|
||||
import PageTitle from 'components/PageTitle';
|
||||
import HelpText from 'components/HelpText';
|
||||
@ -21,10 +25,20 @@ type Props = {
|
||||
|
||||
@observer
|
||||
class People extends React.Component<Props> {
|
||||
@observable inviteModalOpen: boolean = false;
|
||||
|
||||
componentDidMount() {
|
||||
this.props.users.fetchPage({ limit: 100 });
|
||||
}
|
||||
|
||||
handleInviteModalOpen = () => {
|
||||
this.inviteModalOpen = true;
|
||||
};
|
||||
|
||||
handleInviteModalClose = () => {
|
||||
this.inviteModalOpen = false;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { auth, match } = this.props;
|
||||
const { filter } = match.params;
|
||||
@ -47,6 +61,16 @@ class People extends React.Component<Props> {
|
||||
there are other users who have access through Single Sign-On but
|
||||
haven’t signed into Outline yet.
|
||||
</HelpText>
|
||||
<Button
|
||||
type="button"
|
||||
data-on="click"
|
||||
data-event-category="invite"
|
||||
data-event-action="peoplePage"
|
||||
onClick={this.handleInviteModalOpen}
|
||||
neutral
|
||||
>
|
||||
Invite people…
|
||||
</Button>
|
||||
|
||||
<Tabs>
|
||||
<Tab to="/settings/people" exact>
|
||||
@ -68,6 +92,13 @@ class People extends React.Component<Props> {
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
<Modal
|
||||
title="Invite people"
|
||||
onRequestClose={this.handleInviteModalClose}
|
||||
isOpen={this.inviteModalOpen}
|
||||
>
|
||||
<Invite onSubmit={this.handleInviteModalClose} />
|
||||
</Modal>
|
||||
</CenteredContent>
|
||||
);
|
||||
}
|
||||
|
@ -47,6 +47,13 @@ export default class UsersStore extends BaseStore<User> {
|
||||
return this.actionOnUser('activate', user);
|
||||
};
|
||||
|
||||
@action
|
||||
invite = async (invites: { email: string, name: string }[]) => {
|
||||
const res = await client.post(`/users.invite`, { invites });
|
||||
invariant(res && res.data, 'Data should be available');
|
||||
return res.data;
|
||||
};
|
||||
|
||||
actionOnUser = async (action: string, user: User) => {
|
||||
const res = await client.post(`/users.${action}`, {
|
||||
id: user.id,
|
||||
|
@ -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,7 +2,8 @@
|
||||
import Queue from 'bull';
|
||||
import services from './services';
|
||||
|
||||
type UserEvent = {
|
||||
export type UserEvent =
|
||||
| {
|
||||
name: | 'users.create' // eslint-disable-line
|
||||
| 'users.update'
|
||||
| 'users.suspend'
|
||||
@ -11,9 +12,15 @@ type UserEvent = {
|
||||
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