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;
|
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>}
|
||||||
|
@ -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}
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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', 'outboundLinkTracker');
|
||||||
window.ga('require', 'urlChangeTracker');
|
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 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
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
|
// @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
|
||||||
haven’t signed into Outline yet.
|
haven’t 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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)) {
|
||||||
|
@ -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');
|
||||||
|
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 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,
|
||||||
|
@ -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
|
||||||
) => {
|
) => {
|
||||||
|
@ -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();
|
||||||
|
@ -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;
|
||||||
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user