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

@ -114,7 +114,7 @@ export default function Button({
const hasIcon = icon !== undefined; const hasIcon = icon !== undefined;
return ( return (
<RealButton small={small} {...rest}> <RealButton small={small} type={type} {...rest}>
<Inner hasIcon={hasIcon} small={small} disclosure={disclosure}> <Inner hasIcon={hasIcon} small={small} disclosure={disclosure}>
{hasIcon && icon} {hasIcon && icon}
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>} {hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}

View File

@ -3,6 +3,7 @@ import * as React from 'react';
import { observer } from 'mobx-react'; import { observer } from 'mobx-react';
import { observable } from 'mobx'; import { observable } from 'mobx';
import styled from 'styled-components'; import styled from 'styled-components';
import VisuallyHidden from 'components/VisuallyHidden';
import Flex from 'shared/components/Flex'; import Flex from 'shared/components/Flex';
const RealTextarea = styled.textarea` const RealTextarea = styled.textarea`
@ -38,9 +39,10 @@ const RealInput = styled.input`
`; `;
const Wrapper = styled.div` const Wrapper = styled.div`
flex: ${props => (props.flex ? '1' : '0')};
max-width: ${props => (props.short ? '350px' : '100%')}; max-width: ${props => (props.short ? '350px' : '100%')};
min-height: ${({ minHeight }) => (minHeight ? `${minHeight}px` : '0')}; 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)` export const Outline = styled(Flex)`
@ -70,6 +72,8 @@ export type Props = {
value?: string, value?: string,
label?: string, label?: string,
className?: string, className?: string,
labelHidden?: boolean,
flex?: boolean,
short?: boolean, short?: boolean,
}; };
@ -86,14 +90,28 @@ class Input extends React.Component<Props> {
}; };
render() { 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 InputComponent = type === 'textarea' ? RealTextarea : RealInput;
const wrappedLabel = <LabelText>{label}</LabelText>;
return ( return (
<Wrapper className={className} short={short}> <Wrapper className={className} short={short} flex={flex}>
<label> <label>
{label && <LabelText>{label}</LabelText>} {label &&
(labelHidden ? (
<VisuallyHidden>{wrappedLabel}</VisuallyHidden>
) : (
wrappedLabel
))}
<Outline focused={this.focused}> <Outline focused={this.focused}>
<InputComponent <InputComponent
onBlur={this.handleBlur} onBlur={this.handleBlur}

View File

@ -7,9 +7,12 @@ import {
EditIcon, EditIcon,
SearchIcon, SearchIcon,
StarredIcon, StarredIcon,
PlusIcon,
} from 'outline-icons'; } from 'outline-icons';
import Flex from 'shared/components/Flex'; import Flex from 'shared/components/Flex';
import Modal from 'components/Modal';
import Invite from 'scenes/Invite';
import AccountMenu from 'menus/AccountMenu'; import AccountMenu from 'menus/AccountMenu';
import Sidebar from './Sidebar'; import Sidebar from './Sidebar';
import Scrollable from 'components/Scrollable'; import Scrollable from 'components/Scrollable';
@ -22,6 +25,7 @@ import Bubble from './components/Bubble';
import AuthStore from 'stores/AuthStore'; import AuthStore from 'stores/AuthStore';
import DocumentsStore from 'stores/DocumentsStore'; import DocumentsStore from 'stores/DocumentsStore';
import UiStore from 'stores/UiStore'; import UiStore from 'stores/UiStore';
import { observable } from 'mobx';
type Props = { type Props = {
auth: AuthStore, auth: AuthStore,
@ -31,6 +35,8 @@ type Props = {
@observer @observer
class MainSidebar extends React.Component<Props> { class MainSidebar extends React.Component<Props> {
@observable inviteModalOpen: boolean = false;
componentDidMount() { componentDidMount() {
this.props.documents.fetchDrafts(); this.props.documents.fetchDrafts();
} }
@ -39,6 +45,14 @@ class MainSidebar extends React.Component<Props> {
this.props.ui.setActiveModal('collection-new'); this.props.ui.setActiveModal('collection-new');
}; };
handleInviteModalOpen = () => {
this.inviteModalOpen = true;
};
handleInviteModalClose = () => {
this.inviteModalOpen = false;
};
render() { render() {
const { auth, documents } = this.props; const { auth, documents } = this.props;
const { user, team } = auth; const { user, team } = auth;
@ -110,9 +124,23 @@ class MainSidebar extends React.Component<Props> {
documents.active ? documents.active.isArchived : undefined documents.active ? documents.active.isArchived : undefined
} }
/> />
{user.isAdmin && (
<SidebarLink
onClick={this.handleInviteModalOpen}
icon={<PlusIcon />}
label="Invite people…"
/>
)}
</Section> </Section>
</Scrollable> </Scrollable>
</Flex> </Flex>
<Modal
title="Invite people"
onRequestClose={this.handleInviteModalClose}
isOpen={this.inviteModalOpen}
>
<Invite onSubmit={this.handleInviteModalClose} />
</Modal>
</Sidebar> </Sidebar>
); );
} }

View File

@ -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 teams 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;

View 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;

View File

@ -52,4 +52,7 @@ window.addEventListener('load', async () => {
window.ga('require', 'outboundLinkTracker'); window.ga('require', 'outboundLinkTracker');
window.ga('require', 'urlChangeTracker'); window.ga('require', 'urlChangeTracker');
window.ga('require', 'eventTracker', {
attributePrefix: 'data-',
});
}); });

View File

@ -13,7 +13,6 @@ import PageTitle from 'components/PageTitle';
import Tabs from 'components/Tabs'; import Tabs from 'components/Tabs';
import Tab from 'components/Tab'; import Tab from 'components/Tab';
import PaginatedDocumentList from '../components/PaginatedDocumentList'; import PaginatedDocumentList from '../components/PaginatedDocumentList';
import TipInvite from 'components/TipInvite';
type Props = { type Props = {
documents: DocumentsStore, documents: DocumentsStore,
@ -30,10 +29,6 @@ class Dashboard extends React.Component<Props> {
return ( return (
<CenteredContent> <CenteredContent>
<PageTitle title="Home" /> <PageTitle title="Home" />
<TipInvite
team={auth.team}
disabled={!auth.team.subdomain || !auth.user.isAdmin}
/>
<h1>Home</h1> <h1>Home</h1>
<Tabs> <Tabs>
<Tab to="/dashboard" exact> <Tab to="/dashboard" exact>

165
app/scenes/Invite.js Normal file
View 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
/>
&nbsp;&nbsp;
<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));

View File

@ -1,10 +1,14 @@
// @flow // @flow
import * as React from 'react'; import * as React from 'react';
import invariant from 'invariant'; import invariant from 'invariant';
import { observable } from 'mobx';
import { observer, inject } from 'mobx-react'; import { observer, inject } from 'mobx-react';
import AuthStore from 'stores/AuthStore'; import AuthStore from 'stores/AuthStore';
import UsersStore from 'stores/UsersStore'; 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 CenteredContent from 'components/CenteredContent';
import PageTitle from 'components/PageTitle'; import PageTitle from 'components/PageTitle';
import HelpText from 'components/HelpText'; import HelpText from 'components/HelpText';
@ -21,10 +25,20 @@ type Props = {
@observer @observer
class People extends React.Component<Props> { class People extends React.Component<Props> {
@observable inviteModalOpen: boolean = false;
componentDidMount() { componentDidMount() {
this.props.users.fetchPage({ limit: 100 }); this.props.users.fetchPage({ limit: 100 });
} }
handleInviteModalOpen = () => {
this.inviteModalOpen = true;
};
handleInviteModalClose = () => {
this.inviteModalOpen = false;
};
render() { render() {
const { auth, match } = this.props; const { auth, match } = this.props;
const { filter } = match.params; 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 there are other users who have access through Single Sign-On but
havent signed into Outline yet. havent signed into Outline yet.
</HelpText> </HelpText>
<Button
type="button"
data-on="click"
data-event-category="invite"
data-event-action="peoplePage"
onClick={this.handleInviteModalOpen}
neutral
>
Invite people
</Button>
<Tabs> <Tabs>
<Tab to="/settings/people" exact> <Tab to="/settings/people" exact>
@ -68,6 +92,13 @@ class People extends React.Component<Props> {
/> />
))} ))}
</List> </List>
<Modal
title="Invite people"
onRequestClose={this.handleInviteModalClose}
isOpen={this.inviteModalOpen}
>
<Invite onSubmit={this.handleInviteModalClose} />
</Modal>
</CenteredContent> </CenteredContent>
); );
} }

View File

@ -47,6 +47,13 @@ export default class UsersStore extends BaseStore<User> {
return this.actionOnUser('activate', 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) => { actionOnUser = async (action: string, user: User) => {
const res = await client.post(`/users.${action}`, { const res = await client.post(`/users.${action}`, {
id: user.id, id: user.id,

View File

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

View File

@ -12,6 +12,7 @@ import { ValidationError } from '../errors';
import { Event, User, Team } from '../models'; import { Event, User, Team } from '../models';
import auth from '../middlewares/authentication'; import auth from '../middlewares/authentication';
import pagination from './middlewares/pagination'; import pagination from './middlewares/pagination';
import userInviter from '../commands/userInviter';
import { presentUser } from '../presenters'; import { presentUser } from '../presenters';
import policy from '../policies'; 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 => { router.post('users.suspend', auth(), async ctx => {
const admin = ctx.state.user; const admin = ctx.state.user;
const userId = ctx.body.id; 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 => { router.post('users.activate', auth(), async ctx => {
const admin = ctx.state.user; const admin = ctx.state.user;
const userId = ctx.body.id; 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 => { router.post('users.delete', auth(), async ctx => {
const { confirmation } = ctx.body; const { confirmation } = ctx.body;
ctx.assertPresent(confirmation, 'confirmation is required'); 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 Queue from 'bull';
import services from './services'; import services from './services';
type UserEvent = { export type UserEvent =
| {
name: | 'users.create' // eslint-disable-line name: | 'users.create' // eslint-disable-line
| 'users.update' | 'users.update'
| 'users.suspend' | 'users.suspend'
| 'users.activate' | 'users.activate'
| 'users.delete', | 'users.delete',
modelId: string, modelId: string,
teamId: string, teamId: string,
actorId: string, actorId: string,
}; }
| {
name: 'users.invite',
teamId: string,
actorId: string,
email: string,
};
type DocumentEvent = export type DocumentEvent =
| { | {
name: | 'documents.create' // eslint-disable-line name: | 'documents.create' // eslint-disable-line
| 'documents.publish' | 'documents.publish'
@ -48,7 +55,7 @@ type DocumentEvent =
done: boolean, done: boolean,
}; };
type CollectionEvent = export type CollectionEvent =
| { | {
name: | 'collections.create' // eslint-disable-line name: | 'collections.create' // eslint-disable-line
| 'collections.update' | 'collections.update'
@ -65,7 +72,7 @@ type CollectionEvent =
actorId: string, actorId: string,
}; };
type IntegrationEvent = { export type IntegrationEvent = {
name: 'integrations.create' | 'integrations.update' | 'collections.delete', name: 'integrations.create' | 'integrations.update' | 'collections.delete',
modelId: string, modelId: string,
teamId: string, teamId: string,

View File

@ -8,6 +8,11 @@ import Queue from 'bull';
import { baseStyles } from './emails/components/EmailLayout'; import { baseStyles } from './emails/components/EmailLayout';
import { WelcomeEmail, welcomeEmailText } from './emails/WelcomeEmail'; import { WelcomeEmail, welcomeEmailText } from './emails/WelcomeEmail';
import { ExportEmail, exportEmailText } from './emails/ExportEmail'; import { ExportEmail, exportEmailText } from './emails/ExportEmail';
import {
type Props as InviteEmailT,
InviteEmail,
inviteEmailText,
} from './emails/InviteEmail';
import { import {
type Props as DocumentNotificationEmailT, type Props as DocumentNotificationEmailT,
DocumentNotificationEmail, 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 ( documentNotification = async (
opts: { to: string } & DocumentNotificationEmailT opts: { to: string } & DocumentNotificationEmailT
) => { ) => {

View File

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

View File

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

View File

@ -1,6 +1,6 @@
// @flow // @flow
import { Op } from '../sequelize'; import { Op } from '../sequelize';
import type { Event } from '../events'; import type { DocumentEvent, CollectionEvent, Event } from '../events';
import { Document, Collection, User, NotificationSetting } from '../models'; import { Document, Collection, User, NotificationSetting } from '../models';
import mailer from '../mailer'; 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 // lets not send a notification on every autosave update
if (event.autosave) return; 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, { const collection = await Collection.findByPk(event.modelId, {
include: [ include: [
{ {

View File

@ -1,5 +1,5 @@
// @flow // @flow
import type { Event } from '../events'; import type { DocumentEvent, IntegrationEvent, Event } from '../events';
import { Document, Integration, Collection, Team } from '../models'; import { Document, Integration, Collection, Team } from '../models';
import { presentSlackAttachment } from '../presenters'; 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({ const integration = await Integration.findOne({
where: { where: {
id: event.modelId, 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 // lets not send a notification on every autosave update
if (event.autosave) return; if (event.autosave) return;