feat: Guest email authentication (#1088)
* feat: API endpoints for email signin * fix: After testing * Initial signin flow working * move shared middleware * feat: Add guest signin toggle, obey on endpoints * feat: Basic email signin when enabled * Improve guest signin email Disable double signin with JWT * fix: Simple rate limiting * create placeholder users in db * fix: Give invited users default avatar add invited users to people settings * test * add transaction * tmp: test CI * derp * md5 * urgh * again * test: pass * test * fix: Remove usage of data values * guest signin page * Visually separator 'Invited' from other people tabs * fix: Edge case attempting SSO signin for guest email account * fix: Correctly set email auth method to cookie * Improve rate limit error display * lint: cleanup / comments * Improve invalid token error display * style tweaks * pass guest value to subdomain * Restore copy link option * feat: Allow invite revoke from people management * fix: Incorrect users email schema does not allow for user deletion * lint * fix: avatarUrl for deleted user failure * change default to off for guest invites * fix: Changing security settings wipes subdomain * fix: user delete permissioning * test: Add user.invite specs
This commit is contained in:
@ -2,10 +2,12 @@
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import HelpText from 'components/HelpText';
|
||||
import VisuallyHidden from 'components/VisuallyHidden';
|
||||
|
||||
export type Props = {
|
||||
checked?: boolean,
|
||||
label?: string,
|
||||
labelHidden?: boolean,
|
||||
className?: string,
|
||||
note?: string,
|
||||
small?: boolean,
|
||||
@ -30,18 +32,26 @@ const Label = styled.label`
|
||||
|
||||
export default function Checkbox({
|
||||
label,
|
||||
labelHidden,
|
||||
note,
|
||||
className,
|
||||
small,
|
||||
short,
|
||||
...rest
|
||||
}: Props) {
|
||||
const wrappedLabel = <LabelText small={small}>{label}</LabelText>;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Wrapper small={small}>
|
||||
<Label>
|
||||
<input type="checkbox" {...rest} />
|
||||
{label && <LabelText small={small}>{label}</LabelText>}
|
||||
{label &&
|
||||
(labelHidden ? (
|
||||
<VisuallyHidden>{wrappedLabel}</VisuallyHidden>
|
||||
) : (
|
||||
wrappedLabel
|
||||
))}
|
||||
</Label>
|
||||
{note && <HelpText small>{note}</HelpText>}
|
||||
</Wrapper>
|
||||
|
@ -7,4 +7,12 @@ const Tabs = styled.nav`
|
||||
margin-bottom: 12px;
|
||||
`;
|
||||
|
||||
export const Separator = styled.span`
|
||||
border-left: 1px solid ${props => props.theme.divider};
|
||||
position: relative;
|
||||
top: 2px;
|
||||
margin-right: 24px;
|
||||
margin-top: 6px;
|
||||
`;
|
||||
|
||||
export default Tabs;
|
||||
|
@ -42,7 +42,7 @@ class UserMenu extends React.Component<Props> {
|
||||
const { user, users } = this.props;
|
||||
if (
|
||||
!window.confirm(
|
||||
"Are you want to suspend this account? Suspended users won't be able to access Outline."
|
||||
'Are you want to suspend this account? Suspended users will be prevented from logging in.'
|
||||
)
|
||||
) {
|
||||
return;
|
||||
@ -50,6 +50,12 @@ class UserMenu extends React.Component<Props> {
|
||||
users.suspend(user);
|
||||
};
|
||||
|
||||
handleRevoke = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
const { user, users } = this.props;
|
||||
users.delete(user, { confirmation: true });
|
||||
};
|
||||
|
||||
handleActivate = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
const { user, users } = this.props;
|
||||
@ -71,7 +77,13 @@ class UserMenu extends React.Component<Props> {
|
||||
Make {user.name} an admin…
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{user.isSuspended ? (
|
||||
{!user.lastActiveAt && (
|
||||
<DropdownMenuItem onClick={this.handleRevoke}>
|
||||
Revoke invite…
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{user.lastActiveAt &&
|
||||
(user.isSuspended ? (
|
||||
<DropdownMenuItem onClick={this.handleActivate}>
|
||||
Activate account
|
||||
</DropdownMenuItem>
|
||||
@ -79,7 +91,7 @@ class UserMenu extends React.Component<Props> {
|
||||
<DropdownMenuItem onClick={this.handleSuspend}>
|
||||
Suspend account…
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
))}
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
// @flow
|
||||
import { computed } from 'mobx';
|
||||
import BaseModel from './BaseModel';
|
||||
|
||||
class Team extends BaseModel {
|
||||
@ -9,8 +10,18 @@ class Team extends BaseModel {
|
||||
googleConnected: boolean;
|
||||
sharing: boolean;
|
||||
documentEmbeds: boolean;
|
||||
guestSignin: boolean;
|
||||
subdomain: ?string;
|
||||
url: string;
|
||||
|
||||
@computed
|
||||
get signinMethods(): string {
|
||||
if (this.slackConnected && this.googleConnected) {
|
||||
return 'Slack or Google';
|
||||
}
|
||||
if (this.slackConnected) return 'Slack';
|
||||
return 'Google';
|
||||
}
|
||||
}
|
||||
|
||||
export default Team;
|
||||
|
@ -1,14 +1,15 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { withRouter, type RouterHistory } from 'react-router-dom';
|
||||
import { Link, withRouter, type RouterHistory } 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 CopyToClipboard from 'components/CopyToClipboard';
|
||||
import Checkbox from 'components/Checkbox';
|
||||
import HelpText from 'components/HelpText';
|
||||
import Tooltip from 'components/Tooltip';
|
||||
import NudeButton from 'components/NudeButton';
|
||||
@ -16,6 +17,7 @@ import NudeButton from 'components/NudeButton';
|
||||
import UiStore from 'stores/UiStore';
|
||||
import AuthStore from 'stores/AuthStore';
|
||||
import UsersStore from 'stores/UsersStore';
|
||||
import PoliciesStore from 'stores/PoliciesStore';
|
||||
|
||||
const MAX_INVITES = 20;
|
||||
|
||||
@ -23,6 +25,7 @@ type Props = {
|
||||
auth: AuthStore,
|
||||
users: UsersStore,
|
||||
history: RouterHistory,
|
||||
policies: PoliciesStore,
|
||||
ui: UiStore,
|
||||
onSubmit: () => void,
|
||||
};
|
||||
@ -32,10 +35,10 @@ 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: '' },
|
||||
invites: { email: string, name: string, guest: boolean }[] = [
|
||||
{ email: '', name: '', guest: false },
|
||||
{ email: '', name: '', guest: false },
|
||||
{ email: '', name: '', guest: false },
|
||||
];
|
||||
|
||||
handleSubmit = async (ev: SyntheticEvent<>) => {
|
||||
@ -57,6 +60,10 @@ class Invite extends React.Component<Props> {
|
||||
this.invites[index][ev.target.name] = ev.target.value;
|
||||
};
|
||||
|
||||
handleGuestChange = (ev, index) => {
|
||||
this.invites[index][ev.target.name] = ev.target.checked;
|
||||
};
|
||||
|
||||
handleAdd = () => {
|
||||
if (this.invites.length >= MAX_INVITES) {
|
||||
this.props.ui.showToast(
|
||||
@ -64,10 +71,11 @@ class Invite extends React.Component<Props> {
|
||||
);
|
||||
}
|
||||
|
||||
this.invites.push({ email: '', name: '' });
|
||||
this.invites.push({ email: '', name: '', guest: false });
|
||||
};
|
||||
|
||||
handleRemove = (index: number) => {
|
||||
handleRemove = (ev: SyntheticEvent<>, index: number) => {
|
||||
ev.preventDefault();
|
||||
this.invites.splice(index, 1);
|
||||
};
|
||||
|
||||
@ -81,23 +89,40 @@ class Invite extends React.Component<Props> {
|
||||
if (!team || !user) return null;
|
||||
|
||||
const predictedDomain = user.email.split('@')[1];
|
||||
const can = this.props.policies.abilities(team.id);
|
||||
|
||||
return (
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
{team.guestSignin ? (
|
||||
<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.
|
||||
Invite team members or guests to join your knowledge base. Team
|
||||
members can sign in with {team.signinMethods} and guests can use
|
||||
their email address.
|
||||
</HelpText>
|
||||
) : (
|
||||
<HelpText>
|
||||
Invite team members to join your knowledge base. They will need to
|
||||
sign in with {team.signinMethods}.{' '}
|
||||
{can.update && (
|
||||
<React.Fragment>
|
||||
As an admin you can also{' '}
|
||||
<Link to="/settings/security">enable guest invites</Link>.
|
||||
</React.Fragment>
|
||||
)}
|
||||
</HelpText>
|
||||
)}
|
||||
{team.subdomain && (
|
||||
<HelpText>
|
||||
You can also{' '}
|
||||
<CopyBlock>
|
||||
Want a link to share directly with your team?
|
||||
<Flex>
|
||||
<Input type="text" value={team.url} flex />
|
||||
<CopyToClipboard text={team.url} onCopy={this.handleCopy}>
|
||||
<a>{this.linkCopied ? 'link copied' : 'copy a link'}</a>
|
||||
</CopyToClipboard>{' '}
|
||||
to your teams signin page.
|
||||
</HelpText>
|
||||
<Button type="button" neutral>
|
||||
{this.linkCopied ? 'Link copied' : 'Copy link'}
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
</Flex>
|
||||
</CopyBlock>
|
||||
)}
|
||||
{this.invites.map((invite, index) => (
|
||||
<Flex key={index}>
|
||||
@ -109,6 +134,7 @@ class Invite extends React.Component<Props> {
|
||||
onChange={ev => this.handleChange(ev, index)}
|
||||
placeholder={`example@${predictedDomain}`}
|
||||
value={invite.email}
|
||||
required={index === 0}
|
||||
autoFocus={index === 0}
|
||||
flex
|
||||
/>
|
||||
@ -123,10 +149,33 @@ class Invite extends React.Component<Props> {
|
||||
required={!!invite.email}
|
||||
flex
|
||||
/>
|
||||
{team.guestSignin && (
|
||||
<React.Fragment>
|
||||
|
||||
<Tooltip
|
||||
tooltip={
|
||||
<span>
|
||||
Guests can sign in with email and <br />do not require{' '}
|
||||
{team.signinMethods} accounts
|
||||
</span>
|
||||
}
|
||||
placement="top"
|
||||
>
|
||||
<Guest>
|
||||
<Checkbox
|
||||
name="guest"
|
||||
label="Guest"
|
||||
onChange={ev => this.handleGuestChange(ev, index)}
|
||||
checked={invite.guest}
|
||||
/>
|
||||
</Guest>
|
||||
</Tooltip>
|
||||
</React.Fragment>
|
||||
)}
|
||||
{index !== 0 && (
|
||||
<Remove>
|
||||
<Tooltip tooltip="Remove invite" placement="top">
|
||||
<NudeButton onClick={() => this.handleRemove(index)}>
|
||||
<NudeButton onClick={ev => this.handleRemove(ev, index)}>
|
||||
<CloseIcon />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
@ -160,10 +209,29 @@ class Invite extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
const CopyBlock = styled('div')`
|
||||
font-size: 14px;
|
||||
background: ${props => props.theme.secondaryBackground};
|
||||
padding: 8px 16px 4px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
input {
|
||||
background: ${props => props.theme.background};
|
||||
border-radius: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Guest = styled('div')`
|
||||
padding-top: 4px;
|
||||
margin: 0 4px 16px;
|
||||
align-self: flex-end;
|
||||
`;
|
||||
|
||||
const Remove = styled('div')`
|
||||
margin-top: 6px;
|
||||
position: absolute;
|
||||
right: -32px;
|
||||
`;
|
||||
|
||||
export default inject('auth', 'users', 'ui')(withRouter(Invite));
|
||||
export default inject('auth', 'users', 'policies', 'ui')(withRouter(Invite));
|
||||
|
@ -5,8 +5,6 @@ import { observable } from 'mobx';
|
||||
import { observer, inject } from 'mobx-react';
|
||||
import { PlusIcon } from 'outline-icons';
|
||||
|
||||
import AuthStore from 'stores/AuthStore';
|
||||
import UsersStore from 'stores/UsersStore';
|
||||
import Empty from 'components/Empty';
|
||||
import { ListPlaceholder } from 'components/LoadingPlaceholder';
|
||||
import Modal from 'components/Modal';
|
||||
@ -17,12 +15,17 @@ import PageTitle from 'components/PageTitle';
|
||||
import HelpText from 'components/HelpText';
|
||||
import UserListItem from './components/UserListItem';
|
||||
import List from 'components/List';
|
||||
import Tabs from 'components/Tabs';
|
||||
import Tabs, { Separator } from 'components/Tabs';
|
||||
import Tab from 'components/Tab';
|
||||
|
||||
import AuthStore from 'stores/AuthStore';
|
||||
import UsersStore from 'stores/UsersStore';
|
||||
import PoliciesStore from 'stores/PoliciesStore';
|
||||
|
||||
type Props = {
|
||||
auth: AuthStore,
|
||||
users: UsersStore,
|
||||
policies: PoliciesStore,
|
||||
match: Object,
|
||||
};
|
||||
|
||||
@ -43,22 +46,27 @@ class People extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { auth, match } = this.props;
|
||||
const { auth, policies, match } = this.props;
|
||||
const { filter } = match.params;
|
||||
const currentUser = auth.user;
|
||||
const team = auth.team;
|
||||
invariant(currentUser, 'User should exist');
|
||||
invariant(team, 'Team should exist');
|
||||
|
||||
let users = this.props.users.active;
|
||||
if (filter === 'all') {
|
||||
users = this.props.users.orderedData;
|
||||
users = this.props.users.all;
|
||||
} else if (filter === 'admins') {
|
||||
users = this.props.users.admins;
|
||||
} else if (filter === 'suspended') {
|
||||
users = this.props.users.suspended;
|
||||
} else if (filter === 'invited') {
|
||||
users = this.props.users.invited;
|
||||
}
|
||||
|
||||
const showLoading = this.props.users.isFetching && !users.length;
|
||||
const showEmpty = this.props.users.isLoaded && !users.length;
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
return (
|
||||
<CenteredContent>
|
||||
@ -66,8 +74,8 @@ class People extends React.Component<Props> {
|
||||
<h1>People</h1>
|
||||
<HelpText>
|
||||
Everyone that has signed into Outline appears here. It’s possible that
|
||||
there are other users who have access through Single Sign-On but
|
||||
haven’t signed into Outline yet.
|
||||
there are other users who have access through {team.signinMethods} but
|
||||
haven’t signed in yet.
|
||||
</HelpText>
|
||||
<Button
|
||||
type="button"
|
||||
@ -88,7 +96,7 @@ class People extends React.Component<Props> {
|
||||
<Tab to="/settings/people/admins" exact>
|
||||
Admins
|
||||
</Tab>
|
||||
{currentUser.isAdmin && (
|
||||
{can.update && (
|
||||
<Tab to="/settings/people/suspended" exact>
|
||||
Suspended
|
||||
</Tab>
|
||||
@ -96,13 +104,22 @@ class People extends React.Component<Props> {
|
||||
<Tab to="/settings/people/all" exact>
|
||||
Everyone
|
||||
</Tab>
|
||||
|
||||
{can.invite && (
|
||||
<React.Fragment>
|
||||
<Separator />
|
||||
<Tab to="/settings/people/invited" exact>
|
||||
Invited
|
||||
</Tab>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</Tabs>
|
||||
<List>
|
||||
{users.map(user => (
|
||||
<UserListItem
|
||||
key={user.id}
|
||||
user={user}
|
||||
showMenu={!!currentUser.isAdmin && currentUser.id !== user.id}
|
||||
showMenu={can.update && currentUser.id !== user.id}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
@ -121,4 +138,4 @@ class People extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
export default inject('auth', 'users')(People);
|
||||
export default inject('auth', 'users', 'policies')(People);
|
||||
|
@ -22,11 +22,13 @@ class Security extends React.Component<Props> {
|
||||
|
||||
@observable sharing: boolean;
|
||||
@observable documentEmbeds: boolean;
|
||||
@observable guestSignin: boolean;
|
||||
|
||||
componentDidMount() {
|
||||
const { auth } = this.props;
|
||||
if (auth.team) {
|
||||
this.documentEmbeds = auth.team.documentEmbeds;
|
||||
this.guestSignin = auth.team.guestSignin;
|
||||
this.sharing = auth.team.sharing;
|
||||
}
|
||||
}
|
||||
@ -39,12 +41,16 @@ class Security extends React.Component<Props> {
|
||||
case 'documentEmbeds':
|
||||
this.documentEmbeds = ev.target.checked;
|
||||
break;
|
||||
case 'guestSignin':
|
||||
this.guestSignin = ev.target.checked;
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
await this.props.auth.updateTeam({
|
||||
sharing: this.sharing,
|
||||
documentEmbeds: this.documentEmbeds,
|
||||
guestSignin: this.guestSignin,
|
||||
});
|
||||
this.showSuccessMessage();
|
||||
};
|
||||
@ -54,6 +60,8 @@ class Security extends React.Component<Props> {
|
||||
}, 500);
|
||||
|
||||
render() {
|
||||
const { team } = this.props.auth;
|
||||
|
||||
return (
|
||||
<CenteredContent>
|
||||
<PageTitle title="Security" />
|
||||
@ -63,6 +71,15 @@ class Security extends React.Component<Props> {
|
||||
knowledgebase.
|
||||
</HelpText>
|
||||
|
||||
<Checkbox
|
||||
label="Allow guest invites"
|
||||
name="guestSignin"
|
||||
checked={this.guestSignin}
|
||||
onChange={this.handleChange}
|
||||
note={`When enabled guests can be invited by email address and are able to signin without ${
|
||||
team ? team.signinMethods : 'SSO'
|
||||
}`}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Public document sharing"
|
||||
name="sharing"
|
||||
|
@ -51,7 +51,13 @@ class UserListItem extends React.Component<Props> {
|
||||
subtitle={
|
||||
<React.Fragment>
|
||||
{user.email ? `${user.email} · ` : undefined}
|
||||
{user.lastActiveAt ? (
|
||||
<React.Fragment>
|
||||
Active <Time dateTime={user.lastActiveAt} /> ago
|
||||
</React.Fragment>
|
||||
) : (
|
||||
'Pending'
|
||||
)}
|
||||
{user.isAdmin && <Badge admin={user.isAdmin}>Admin</Badge>}
|
||||
{user.isSuspended && <Badge>Suspended</Badge>}
|
||||
</React.Fragment>
|
||||
|
@ -29,8 +29,8 @@ export default class AuthStore {
|
||||
}
|
||||
|
||||
this.rootStore = rootStore;
|
||||
this.user = data.user;
|
||||
this.team = data.team;
|
||||
this.user = new User(data.user);
|
||||
this.team = new Team(data.team);
|
||||
this.token = getCookie('accessToken');
|
||||
|
||||
if (this.token) setImmediate(() => this.fetch());
|
||||
@ -72,8 +72,8 @@ export default class AuthStore {
|
||||
runInAction('AuthStore#fetch', () => {
|
||||
this.addPolicies(res.policies);
|
||||
const { user, team } = res.data;
|
||||
this.user = user;
|
||||
this.team = team;
|
||||
this.user = new User(user);
|
||||
this.team = new Team(team);
|
||||
|
||||
if (window.Bugsnag) {
|
||||
Bugsnag.user = {
|
||||
@ -141,7 +141,7 @@ export default class AuthStore {
|
||||
|
||||
runInAction('AuthStore#updateTeam', () => {
|
||||
this.addPolicies(res.policies);
|
||||
this.team = res.data;
|
||||
this.team = new Team(res.data);
|
||||
});
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
|
@ -114,14 +114,17 @@ export default class BaseStore<T: BaseModel> {
|
||||
}
|
||||
|
||||
@action
|
||||
async delete(item: T) {
|
||||
async delete(item: T, options?: Object = {}) {
|
||||
if (!this.actions.includes('delete')) {
|
||||
throw new Error(`Cannot delete ${this.modelName}`);
|
||||
}
|
||||
this.isSaving = true;
|
||||
|
||||
try {
|
||||
await client.post(`/${this.modelName}s.delete`, { id: item.id });
|
||||
await client.post(`/${this.modelName}s.delete`, {
|
||||
id: item.id,
|
||||
...options,
|
||||
});
|
||||
return this.remove(item.id);
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
|
@ -14,7 +14,10 @@ export default class UsersStore extends BaseStore<User> {
|
||||
|
||||
@computed
|
||||
get active(): User[] {
|
||||
return filter(this.orderedData, user => !user.isSuspended);
|
||||
return filter(
|
||||
this.orderedData,
|
||||
user => !user.isSuspended && user.lastActiveAt
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
@ -22,11 +25,21 @@ export default class UsersStore extends BaseStore<User> {
|
||||
return filter(this.orderedData, user => user.isSuspended);
|
||||
}
|
||||
|
||||
@computed
|
||||
get invited(): User[] {
|
||||
return filter(this.orderedData, user => !user.lastActiveAt);
|
||||
}
|
||||
|
||||
@computed
|
||||
get admins(): User[] {
|
||||
return filter(this.orderedData, user => user.isAdmin);
|
||||
}
|
||||
|
||||
@computed
|
||||
get all(): User[] {
|
||||
return filter(this.orderedData, user => user.lastActiveAt);
|
||||
}
|
||||
|
||||
@computed
|
||||
get orderedData(): User[] {
|
||||
return orderBy(Array.from(this.data.values()), 'name', 'asc');
|
||||
|
@ -3,7 +3,7 @@
|
||||
exports[`#users.activate should activate a suspended user 1`] = `
|
||||
Object {
|
||||
"data": Object {
|
||||
"avatarUrl": "http://example.com/avatar.png",
|
||||
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
|
||||
"createdAt": "2018-01-02T00:00:00.000Z",
|
||||
"email": "user1@example.com",
|
||||
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||
@ -38,7 +38,7 @@ Object {
|
||||
exports[`#users.demote should demote an admin 1`] = `
|
||||
Object {
|
||||
"data": Object {
|
||||
"avatarUrl": "http://example.com/avatar.png",
|
||||
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
|
||||
"createdAt": "2018-01-02T00:00:00.000Z",
|
||||
"email": "user1@example.com",
|
||||
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||
@ -52,6 +52,15 @@ Object {
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#users.demote should not demote admins if only one available 1`] = `
|
||||
Object {
|
||||
"error": "validation_error",
|
||||
"message": "At least one admin is required",
|
||||
"ok": false,
|
||||
"status": 400,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#users.demote should require admin 1`] = `
|
||||
Object {
|
||||
"error": "admin_required",
|
||||
@ -61,19 +70,10 @@ Object {
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#users.demote shouldn't demote admins if only one available 1`] = `
|
||||
Object {
|
||||
"error": "validation_error",
|
||||
"message": "At least one admin is required",
|
||||
"ok": false,
|
||||
"status": 400,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#users.promote should promote a new admin 1`] = `
|
||||
Object {
|
||||
"data": Object {
|
||||
"avatarUrl": "http://example.com/avatar.png",
|
||||
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
|
||||
"createdAt": "2018-01-02T00:00:00.000Z",
|
||||
"email": "user1@example.com",
|
||||
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||
@ -96,6 +96,15 @@ Object {
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#users.suspend should not allow suspending the user themselves 1`] = `
|
||||
Object {
|
||||
"error": "validation_error",
|
||||
"message": "Unable to suspend the current user",
|
||||
"ok": false,
|
||||
"status": 400,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#users.suspend should require admin 1`] = `
|
||||
Object {
|
||||
"error": "admin_required",
|
||||
@ -108,7 +117,7 @@ Object {
|
||||
exports[`#users.suspend should suspend an user 1`] = `
|
||||
Object {
|
||||
"data": Object {
|
||||
"avatarUrl": "http://example.com/avatar.png",
|
||||
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
|
||||
"createdAt": "2018-01-02T00:00:00.000Z",
|
||||
"email": "user1@example.com",
|
||||
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||
@ -122,15 +131,6 @@ Object {
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#users.suspend shouldn't allow suspending the user themselves 1`] = `
|
||||
Object {
|
||||
"error": "validation_error",
|
||||
"message": "Unable to suspend the current user",
|
||||
"ok": false,
|
||||
"status": 400,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#users.update should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
|
@ -56,6 +56,7 @@ describe('#events.list', async () => {
|
||||
const res = await server.post('/api/events.list', {
|
||||
body: { token: admin.getJwtToken() },
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
|
@ -18,9 +18,9 @@ import notificationSettings from './notificationSettings';
|
||||
import utils from './utils';
|
||||
|
||||
import { NotFoundError } from '../errors';
|
||||
import errorHandling from './middlewares/errorHandling';
|
||||
import errorHandling from '../middlewares/errorHandling';
|
||||
import validation from '../middlewares/validation';
|
||||
import methodOverride from './middlewares/methodOverride';
|
||||
import methodOverride from '../middlewares/methodOverride';
|
||||
import cache from './middlewares/cache';
|
||||
import apiWrapper from './middlewares/apiWrapper';
|
||||
|
||||
|
@ -11,20 +11,28 @@ const { authorize } = policy;
|
||||
const router = new Router();
|
||||
|
||||
router.post('team.update', auth(), async ctx => {
|
||||
const { name, avatarUrl, subdomain, sharing, documentEmbeds } = ctx.body;
|
||||
const {
|
||||
name,
|
||||
avatarUrl,
|
||||
subdomain,
|
||||
sharing,
|
||||
guestSignin,
|
||||
documentEmbeds,
|
||||
} = ctx.body;
|
||||
const endpoint = publicS3Endpoint();
|
||||
|
||||
const user = ctx.state.user;
|
||||
const team = await Team.findByPk(user.teamId);
|
||||
authorize(user, 'update', team);
|
||||
|
||||
if (process.env.SUBDOMAINS_ENABLED === 'true') {
|
||||
if (subdomain !== undefined && process.env.SUBDOMAINS_ENABLED === 'true') {
|
||||
team.subdomain = subdomain === '' ? null : subdomain;
|
||||
}
|
||||
|
||||
if (name) team.name = name;
|
||||
if (sharing !== undefined) team.sharing = sharing;
|
||||
if (documentEmbeds !== undefined) team.documentEmbeds = documentEmbeds;
|
||||
if (guestSignin !== undefined) team.guestSignin = guestSignin;
|
||||
if (avatarUrl && avatarUrl.startsWith(`${endpoint}/uploads/${user.id}`)) {
|
||||
team.avatarUrl = avatarUrl;
|
||||
}
|
||||
|
@ -254,11 +254,12 @@ router.post('users.invite', auth(), async ctx => {
|
||||
});
|
||||
|
||||
router.post('users.delete', auth(), async ctx => {
|
||||
const { confirmation } = ctx.body;
|
||||
const { confirmation, id } = ctx.body;
|
||||
ctx.assertPresent(confirmation, 'confirmation is required');
|
||||
|
||||
const user = ctx.state.user;
|
||||
authorize(user, 'delete', user);
|
||||
let user = ctx.state.user;
|
||||
if (id) user = await User.findByPk(id);
|
||||
authorize(ctx.state.user, 'delete', user);
|
||||
|
||||
try {
|
||||
await user.destroy();
|
||||
|
@ -76,6 +76,26 @@ describe('#users.info', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#users.invite', async () => {
|
||||
it('should return sent invites', async () => {
|
||||
const user = await buildUser();
|
||||
const res = await server.post('/api/users.invite', {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
invites: [{ email: 'test@example.com', name: 'Test', guest: false }],
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.sent.length).toEqual(1);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const res = await server.post('/api/users.invite');
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#users.delete', async () => {
|
||||
it('should not allow deleting without confirmation', async () => {
|
||||
const user = await buildUser();
|
||||
@ -111,6 +131,27 @@ describe('#users.delete', async () => {
|
||||
expect(res.status).toEqual(200);
|
||||
});
|
||||
|
||||
it('should allow deleting pending user account with admin', async () => {
|
||||
const user = await buildUser({ isAdmin: true });
|
||||
const pending = await buildUser({
|
||||
teamId: user.teamId,
|
||||
lastActiveAt: null,
|
||||
});
|
||||
const res = await server.post('/api/users.delete', {
|
||||
body: { token: user.getJwtToken(), id: pending.id, confirmation: true },
|
||||
});
|
||||
expect(res.status).toEqual(200);
|
||||
});
|
||||
|
||||
it('should not allow deleting another user account', async () => {
|
||||
const user = await buildUser({ isAdmin: true });
|
||||
const user2 = await buildUser({ teamId: user.teamId });
|
||||
const res = await server.post('/api/users.delete', {
|
||||
body: { token: user.getJwtToken(), id: user2.id, confirmation: true },
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const res = await server.post('/api/users.delete');
|
||||
const body = await res.json();
|
||||
@ -183,7 +224,7 @@ describe('#users.demote', async () => {
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("shouldn't demote admins if only one available ", async () => {
|
||||
it('should not demote admins if only one available', async () => {
|
||||
const admin = await buildUser({ isAdmin: true });
|
||||
|
||||
const res = await server.post('/api/users.demote', {
|
||||
@ -226,7 +267,7 @@ describe('#users.suspend', async () => {
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("shouldn't allow suspending the user themselves", async () => {
|
||||
it('should not allow suspending the user themselves', async () => {
|
||||
const admin = await buildUser({ isAdmin: true });
|
||||
const res = await server.post('/api/users.suspend', {
|
||||
body: {
|
||||
|
91
server/auth/email.js
Normal file
91
server/auth/email.js
Normal file
@ -0,0 +1,91 @@
|
||||
// @flow
|
||||
import Router from 'koa-router';
|
||||
import mailer from '../mailer';
|
||||
import subMinutes from 'date-fns/sub_minutes';
|
||||
import { getUserForEmailSigninToken } from '../utils/jwt';
|
||||
import { User, Team } from '../models';
|
||||
import methodOverride from '../middlewares/methodOverride';
|
||||
import validation from '../middlewares/validation';
|
||||
import auth from '../middlewares/authentication';
|
||||
import { AuthorizationError } from '../errors';
|
||||
|
||||
const router = new Router();
|
||||
|
||||
router.use(methodOverride());
|
||||
router.use(validation());
|
||||
|
||||
router.post('email', async ctx => {
|
||||
const { email } = ctx.body;
|
||||
|
||||
ctx.assertEmail(email, 'email is required');
|
||||
|
||||
const user = await User.findOne({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
if (user) {
|
||||
const team = await Team.findByPk(user.teamId);
|
||||
|
||||
// If the user matches an email address associated with an SSO
|
||||
// signin then just forward them directly to that service's
|
||||
// login page
|
||||
if (user.service && user.service !== 'email') {
|
||||
return ctx.redirect(`${team.url}/auth/${user.service}`);
|
||||
}
|
||||
|
||||
if (!team.guestSignin) {
|
||||
throw new AuthorizationError();
|
||||
}
|
||||
|
||||
// basic rate limit of endpoint to prevent send email abuse
|
||||
if (
|
||||
user.lastSigninEmailSentAt &&
|
||||
user.lastSigninEmailSentAt > subMinutes(new Date(), 2)
|
||||
) {
|
||||
ctx.redirect(`${team.url}?notice=email-auth-ratelimit`);
|
||||
return;
|
||||
}
|
||||
|
||||
// send email to users registered address with a short-lived token
|
||||
mailer.signin({
|
||||
to: user.email,
|
||||
token: user.getEmailSigninToken(),
|
||||
teamUrl: team.url,
|
||||
});
|
||||
|
||||
user.lastSigninEmailSentAt = new Date();
|
||||
await user.save();
|
||||
|
||||
// respond with success regardless of whether an email was sent
|
||||
ctx.redirect(`${team.url}?notice=guest-success`);
|
||||
} else {
|
||||
ctx.redirect(`${process.env.URL}?notice=guest-success`);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('email.callback', auth({ required: false }), async ctx => {
|
||||
const { token } = ctx.request.query;
|
||||
|
||||
ctx.assertPresent(token, 'token is required');
|
||||
|
||||
try {
|
||||
const user = await getUserForEmailSigninToken(token);
|
||||
|
||||
const team = await Team.findByPk(user.teamId);
|
||||
if (!team.guestSignin) {
|
||||
throw new AuthorizationError();
|
||||
}
|
||||
|
||||
if (!user.service) {
|
||||
user.service = 'email';
|
||||
await user.save();
|
||||
}
|
||||
|
||||
// set cookies on response and redirect to team subdomain
|
||||
ctx.signIn(user, team, 'email', false);
|
||||
} catch (err) {
|
||||
ctx.redirect(`${process.env.URL}?notice=expired-token`);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
@ -1,4 +1,5 @@
|
||||
// @flow
|
||||
import Sequelize from 'sequelize';
|
||||
import crypto from 'crypto';
|
||||
import Router from 'koa-router';
|
||||
import { capitalize } from 'lodash';
|
||||
@ -6,6 +7,8 @@ import { OAuth2Client } from 'google-auth-library';
|
||||
import { User, Team, Event } from '../models';
|
||||
import auth from '../middlewares/authentication';
|
||||
|
||||
const Op = Sequelize.Op;
|
||||
|
||||
const router = new Router();
|
||||
const client = new OAuth2Client(
|
||||
process.env.GOOGLE_CLIENT_ID,
|
||||
@ -77,10 +80,18 @@ router.get('google.callback', auth({ required: false }), async ctx => {
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const [user, isFirstSignin] = await User.findOrCreate({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{
|
||||
service: 'google',
|
||||
serviceId: profile.data.id,
|
||||
},
|
||||
{
|
||||
service: '',
|
||||
},
|
||||
],
|
||||
teamId: team.id,
|
||||
},
|
||||
defaults: {
|
||||
@ -117,6 +128,27 @@ router.get('google.callback', auth({ required: false }), async ctx => {
|
||||
|
||||
// set cookies on response and redirect to team subdomain
|
||||
ctx.signIn(user, team, 'google', isFirstSignin);
|
||||
} catch (err) {
|
||||
if (err instanceof Sequelize.UniqueConstraintError) {
|
||||
const exists = await User.findOne({
|
||||
where: {
|
||||
service: 'email',
|
||||
email: profile.data.email,
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (exists) {
|
||||
ctx.redirect(`${team.url}?notice=email-auth-required`);
|
||||
} else {
|
||||
ctx.redirect(`${team.url}?notice=auth-error`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
@ -10,12 +10,14 @@ import { stripSubdomain } from '../../shared/utils/domains';
|
||||
|
||||
import slack from './slack';
|
||||
import google from './google';
|
||||
import email from './email';
|
||||
|
||||
const app = new Koa();
|
||||
const router = new Router();
|
||||
|
||||
router.use('/', slack.routes());
|
||||
router.use('/', google.routes());
|
||||
router.use('/', email.routes());
|
||||
|
||||
router.get('/redirect', auth(), async ctx => {
|
||||
const user = ctx.state.user;
|
||||
|
@ -1,4 +1,5 @@
|
||||
// @flow
|
||||
import Sequelize from 'sequelize';
|
||||
import Router from 'koa-router';
|
||||
import auth from '../middlewares/authentication';
|
||||
import addHours from 'date-fns/add_hours';
|
||||
@ -14,6 +15,7 @@ import {
|
||||
} from '../models';
|
||||
import * as Slack from '../slack';
|
||||
|
||||
const Op = Sequelize.Op;
|
||||
const router = new Router();
|
||||
|
||||
// start the oauth process and redirect user to Slack
|
||||
@ -37,7 +39,7 @@ router.get('slack.callback', auth({ required: false }), async ctx => {
|
||||
ctx.assertPresent(state, 'state is required');
|
||||
|
||||
if (state !== ctx.cookies.get('state')) {
|
||||
ctx.redirect('/?notice=auth-error');
|
||||
ctx.redirect('/?notice=auth-error&error=state_mismatch');
|
||||
return;
|
||||
}
|
||||
if (error) {
|
||||
@ -57,10 +59,18 @@ router.get('slack.callback', auth({ required: false }), async ctx => {
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const [user, isFirstSignin] = await User.findOrCreate({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{
|
||||
service: 'slack',
|
||||
serviceId: data.user.id,
|
||||
},
|
||||
{
|
||||
service: '',
|
||||
},
|
||||
],
|
||||
teamId: team.id,
|
||||
},
|
||||
defaults: {
|
||||
@ -97,6 +107,27 @@ router.get('slack.callback', auth({ required: false }), async ctx => {
|
||||
|
||||
// set cookies on response and redirect to team subdomain
|
||||
ctx.signIn(user, team, 'slack', isFirstSignin);
|
||||
} catch (err) {
|
||||
if (err instanceof Sequelize.UniqueConstraintError) {
|
||||
const exists = await User.findOne({
|
||||
where: {
|
||||
service: 'email',
|
||||
email: data.user.email,
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (exists) {
|
||||
ctx.redirect(`${team.url}?notice=email-auth-required`);
|
||||
} else {
|
||||
ctx.redirect(`${team.url}?notice=auth-error`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
router.get('slack.commands', auth({ required: false }), async ctx => {
|
||||
|
@ -2,8 +2,9 @@
|
||||
import { uniqBy } from 'lodash';
|
||||
import { User, Event, Team } from '../models';
|
||||
import mailer from '../mailer';
|
||||
import { sequelize } from '../sequelize';
|
||||
|
||||
type Invite = { name: string, email: string };
|
||||
type Invite = { name: string, email: string, guest: boolean };
|
||||
|
||||
export default async function userInviter({
|
||||
user,
|
||||
@ -16,7 +17,7 @@ export default async function userInviter({
|
||||
}): Promise<{ sent: Invite[] }> {
|
||||
const team = await Team.findByPk(user.teamId);
|
||||
|
||||
// filter out empties, duplicates and non-emails
|
||||
// filter out empties, duplicates and obvious non-emails
|
||||
const compactedInvites = uniqBy(
|
||||
invites.filter(invite => !!invite.email.trim() && invite.email.match('@')),
|
||||
'email'
|
||||
@ -38,7 +39,19 @@ export default async function userInviter({
|
||||
// send and record invites
|
||||
await Promise.all(
|
||||
filteredInvites.map(async invite => {
|
||||
await Event.create({
|
||||
const transaction = await sequelize.transaction();
|
||||
try {
|
||||
await User.create(
|
||||
{
|
||||
teamId: user.teamId,
|
||||
name: invite.name,
|
||||
email: invite.email,
|
||||
service: null,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await Event.create(
|
||||
{
|
||||
name: 'users.invite',
|
||||
actorId: user.id,
|
||||
teamId: user.teamId,
|
||||
@ -47,15 +60,23 @@ export default async function userInviter({
|
||||
name: invite.name,
|
||||
},
|
||||
ip,
|
||||
});
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await mailer.invite({
|
||||
to: invite.email,
|
||||
name: invite.name,
|
||||
guest: invite.guest,
|
||||
actorName: user.name,
|
||||
actorEmail: user.email,
|
||||
teamName: team.name,
|
||||
teamUrl: team.url,
|
||||
});
|
||||
await transaction.commit();
|
||||
} catch (err) {
|
||||
await transaction.rollback();
|
||||
throw err;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
|
@ -10,6 +10,7 @@ import EmptySpace from './components/EmptySpace';
|
||||
|
||||
export type Props = {
|
||||
name: string,
|
||||
guest: boolean,
|
||||
actorName: string,
|
||||
actorEmail: string,
|
||||
teamName: string,
|
||||
@ -21,6 +22,7 @@ export const inviteEmailText = ({
|
||||
actorName,
|
||||
actorEmail,
|
||||
teamUrl,
|
||||
guest,
|
||||
}: Props) => `
|
||||
Join ${teamName} on Outline
|
||||
|
||||
@ -28,7 +30,7 @@ ${actorName} (${
|
||||
actorEmail
|
||||
}) has invited you to join Outline, a place for your team to build and share knowledge.
|
||||
|
||||
Join now: ${teamUrl}
|
||||
Join now: ${teamUrl}${guest ? '?guest=true' : ''}
|
||||
`;
|
||||
|
||||
export const InviteEmail = ({
|
||||
@ -36,6 +38,7 @@ export const InviteEmail = ({
|
||||
actorName,
|
||||
actorEmail,
|
||||
teamUrl,
|
||||
guest,
|
||||
}: Props) => {
|
||||
return (
|
||||
<EmailTemplate>
|
||||
@ -49,7 +52,9 @@ export const InviteEmail = ({
|
||||
</p>
|
||||
<EmptySpace height={10} />
|
||||
<p>
|
||||
<Button href={teamUrl}>Join now</Button>
|
||||
<Button href={`${teamUrl}${guest ? '?guest=true' : ''}`}>
|
||||
Join now
|
||||
</Button>
|
||||
</p>
|
||||
</Body>
|
||||
|
||||
|
51
server/emails/SigninEmail.js
Normal file
51
server/emails/SigninEmail.js
Normal file
@ -0,0 +1,51 @@
|
||||
// @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 = {
|
||||
token: string,
|
||||
teamUrl: string,
|
||||
};
|
||||
|
||||
export const signinEmailText = ({ token, teamUrl }: Props) => `
|
||||
Use the link below to signin to Outline:
|
||||
|
||||
${process.env.URL}/auth/email.callback?token=${token}
|
||||
|
||||
If your magic link expired you can request a new one from your team’s
|
||||
signin page at: ${teamUrl}
|
||||
`;
|
||||
|
||||
export const SigninEmail = ({ token, teamUrl }: Props) => {
|
||||
return (
|
||||
<EmailTemplate>
|
||||
<Header />
|
||||
|
||||
<Body>
|
||||
<Heading>Magic signin link</Heading>
|
||||
<p>Click the button below to signin to Outline.</p>
|
||||
<EmptySpace height={10} />
|
||||
<p>
|
||||
<Button
|
||||
href={`${process.env.URL}/auth/email.callback?token=${token}`}
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
</p>
|
||||
<EmptySpace height={10} />
|
||||
<p>
|
||||
If your magic link expired you can request a new one from your team’s
|
||||
signin page at: <a href={teamUrl}>{teamUrl}</a>
|
||||
</p>
|
||||
</Body>
|
||||
|
||||
<Footer />
|
||||
</EmailTemplate>
|
||||
);
|
||||
};
|
@ -8,6 +8,7 @@ import Queue from 'bull';
|
||||
import { baseStyles } from './emails/components/EmailLayout';
|
||||
import { WelcomeEmail, welcomeEmailText } from './emails/WelcomeEmail';
|
||||
import { ExportEmail, exportEmailText } from './emails/ExportEmail';
|
||||
import { SigninEmail, signinEmailText } from './emails/SigninEmail';
|
||||
import {
|
||||
type Props as InviteEmailT,
|
||||
InviteEmail,
|
||||
@ -123,6 +124,16 @@ export class Mailer {
|
||||
});
|
||||
};
|
||||
|
||||
signin = async (opts: { to: string, token: string, teamUrl: string }) => {
|
||||
this.sendMail({
|
||||
to: opts.to,
|
||||
title: 'Magic signin link',
|
||||
previewText: 'Here’s your link to signin to Outline.',
|
||||
html: <SigninEmail {...opts} />,
|
||||
text: signinEmailText(opts),
|
||||
});
|
||||
};
|
||||
|
||||
documentNotification = async (
|
||||
opts: { to: string } & DocumentNotificationEmailT
|
||||
) => {
|
||||
|
@ -24,7 +24,7 @@ export default function validation() {
|
||||
}
|
||||
};
|
||||
|
||||
ctx.assertEmail = (value, message) => {
|
||||
ctx.assertEmail = (value = '', message) => {
|
||||
if (!validator.isEmail(value)) {
|
||||
throw new ValidationError(message);
|
||||
}
|
||||
|
25
server/migrations/20191121035144-guest-invite.js
Normal file
25
server/migrations/20191121035144-guest-invite.js
Normal file
@ -0,0 +1,25 @@
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.addColumn('teams', 'guestSignin', {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
});
|
||||
await queryInterface.addColumn('users', 'lastSigninEmailSentAt', {
|
||||
type: Sequelize.DATE
|
||||
});
|
||||
await queryInterface.changeColumn('users', 'email', {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
});
|
||||
},
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.removeColumn('teams', 'guestSignin');
|
||||
await queryInterface.removeColumn('users', 'lastSigninEmailSentAt');
|
||||
await queryInterface.changeColumn('users', 'email', {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false
|
||||
});
|
||||
},
|
||||
};
|
@ -51,6 +51,11 @@ const Team = sequelize.define(
|
||||
googleId: { type: DataTypes.STRING, allowNull: true },
|
||||
avatarUrl: { type: DataTypes.STRING, allowNull: true },
|
||||
sharing: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true },
|
||||
guestSignin: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
documentEmbeds: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
|
@ -8,6 +8,8 @@ import { publicS3Endpoint, uploadToS3FromUrl } from '../utils/s3';
|
||||
import { sendEmail } from '../mailer';
|
||||
import { Star, Team, Collection, NotificationSetting, ApiKey } from '.';
|
||||
|
||||
const DEFAULT_AVATAR_HOST = 'https://tiley.herokuapp.com';
|
||||
|
||||
const User = sequelize.define(
|
||||
'user',
|
||||
{
|
||||
@ -29,6 +31,7 @@ const User = sequelize.define(
|
||||
lastActiveIp: { type: DataTypes.STRING, allowNull: true },
|
||||
lastSignedInAt: DataTypes.DATE,
|
||||
lastSignedInIp: { type: DataTypes.STRING, allowNull: true },
|
||||
lastSigninEmailSentAt: DataTypes.DATE,
|
||||
suspendedAt: DataTypes.DATE,
|
||||
suspendedById: DataTypes.UUID,
|
||||
},
|
||||
@ -38,6 +41,18 @@ const User = sequelize.define(
|
||||
isSuspended() {
|
||||
return !!this.suspendedAt;
|
||||
},
|
||||
avatarUrl() {
|
||||
const original = this.getDataValue('avatarUrl');
|
||||
if (original) {
|
||||
return original;
|
||||
}
|
||||
|
||||
const hash = crypto
|
||||
.createHash('md5')
|
||||
.update(this.email || '')
|
||||
.digest('hex');
|
||||
return `${DEFAULT_AVATAR_HOST}/avatar/${hash}/${this.name[0]}.png`;
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
@ -96,12 +111,28 @@ User.prototype.getJwtToken = function() {
|
||||
return JWT.sign({ id: this.id }, this.jwtSecret);
|
||||
};
|
||||
|
||||
User.prototype.getEmailSigninToken = function() {
|
||||
if (this.service && this.service !== 'email') {
|
||||
throw new Error('Cannot generate email signin token for OAuth user');
|
||||
}
|
||||
|
||||
return JWT.sign(
|
||||
{ id: this.id, createdAt: new Date().toISOString() },
|
||||
this.jwtSecret
|
||||
);
|
||||
};
|
||||
|
||||
const uploadAvatar = async model => {
|
||||
const endpoint = publicS3Endpoint();
|
||||
const { avatarUrl } = model;
|
||||
|
||||
if (model.avatarUrl && !model.avatarUrl.startsWith(endpoint)) {
|
||||
if (
|
||||
avatarUrl &&
|
||||
!avatarUrl.startsWith(endpoint) &&
|
||||
!avatarUrl.startsWith(DEFAULT_AVATAR_HOST)
|
||||
) {
|
||||
const newUrl = await uploadToS3FromUrl(
|
||||
model.avatarUrl,
|
||||
avatarUrl,
|
||||
`avatars/${model.id}/${uuid.v4()}`
|
||||
);
|
||||
if (newUrl) model.avatarUrl = newUrl;
|
||||
@ -126,7 +157,7 @@ const removeIdentifyingInfo = async (model, options) => {
|
||||
transaction: options.transaction,
|
||||
});
|
||||
|
||||
model.email = '';
|
||||
model.email = null;
|
||||
model.name = 'Unknown';
|
||||
model.avatarUrl = '';
|
||||
model.serviceId = null;
|
||||
@ -165,7 +196,7 @@ User.afterCreate(async user => {
|
||||
// From Slack support:
|
||||
// If you wish to contact users at an email address obtained through Slack,
|
||||
// you need them to opt-in through a clear and separate process.
|
||||
if (!team.slackId) {
|
||||
if (user.service && user.service !== 'slack') {
|
||||
sendEmail('welcome', user.email, { teamUrl: team.url });
|
||||
}
|
||||
});
|
||||
|
@ -3,7 +3,7 @@ import * as React from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import styled from 'styled-components';
|
||||
import Grid from 'styled-components-grid';
|
||||
import AuthErrors from './components/AuthErrors';
|
||||
import AuthNotices from './components/AuthNotices';
|
||||
import Hero from './components/Hero';
|
||||
import HeroText from './components/HeroText';
|
||||
import SigninButtons from './components/SigninButtons';
|
||||
@ -24,7 +24,7 @@ function Home(props: Props) {
|
||||
</Helmet>
|
||||
<Grid>
|
||||
<Hero id="signin">
|
||||
<AuthErrors notice={props.notice} />
|
||||
<AuthNotices notice={props.notice} />
|
||||
{process.env.TEAM_LOGO && <Logo src={process.env.TEAM_LOGO} />}
|
||||
<h1>Our team’s knowledge base</h1>
|
||||
<HeroText>
|
||||
|
@ -4,15 +4,17 @@ import styled from 'styled-components';
|
||||
import Grid from 'styled-components-grid';
|
||||
import Hero from './components/Hero';
|
||||
import HeroText from './components/HeroText';
|
||||
import Button from './components/Button';
|
||||
import SigninButtons from './components/SigninButtons';
|
||||
import AuthErrors from './components/AuthErrors';
|
||||
import AuthNotices from './components/AuthNotices';
|
||||
import Centered from './components/Centered';
|
||||
import PageTitle from './components/PageTitle';
|
||||
import { Team } from '../models';
|
||||
|
||||
type Props = {
|
||||
team: Team,
|
||||
notice?: 'google-hd' | 'auth-error' | 'hd-not-allowed',
|
||||
guest?: boolean,
|
||||
notice?: 'google-hd' | 'auth-error' | 'hd-not-allowed' | 'guest-success',
|
||||
lastSignedIn: string,
|
||||
googleSigninEnabled: boolean,
|
||||
slackSigninEnabled: boolean,
|
||||
@ -21,6 +23,7 @@ type Props = {
|
||||
|
||||
function SubdomainSignin({
|
||||
team,
|
||||
guest,
|
||||
lastSignedIn,
|
||||
notice,
|
||||
googleSigninEnabled,
|
||||
@ -30,6 +33,18 @@ function SubdomainSignin({
|
||||
googleSigninEnabled = !!team.googleId && googleSigninEnabled;
|
||||
slackSigninEnabled = !!team.slackId && slackSigninEnabled;
|
||||
|
||||
const guestSigninEnabled = team.guestSignin;
|
||||
const guestSigninForm = guestSigninEnabled && (
|
||||
<div>
|
||||
<form method="POST" action="/auth/email">
|
||||
<EmailInput type="email" name="email" placeholder="jane@domain.com" />{' '}
|
||||
<Button type="submit" as="button">
|
||||
Sign In
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
// only show the "last signed in" hint if there is more than one option available
|
||||
const signinHint =
|
||||
googleSigninEnabled && slackSigninEnabled ? lastSignedIn : undefined;
|
||||
@ -39,8 +54,28 @@ function SubdomainSignin({
|
||||
<PageTitle title={`Sign in to ${team.name}`} />
|
||||
<Grid>
|
||||
<Hero>
|
||||
<AuthErrors notice={notice} />
|
||||
<h1>{lastSignedIn ? 'Welcome back,' : 'Hey there,'}</h1>
|
||||
<AuthNotices notice={notice} />
|
||||
{guest ? (
|
||||
<React.Fragment>
|
||||
<HeroText>
|
||||
Sign in with your email address to continue to {team.name}.
|
||||
<Subdomain>{hostname}</Subdomain>
|
||||
</HeroText>
|
||||
{guestSigninForm}
|
||||
<br />
|
||||
|
||||
<HeroText>Have a team account? Sign in with SSO…</HeroText>
|
||||
<p>
|
||||
<SigninButtons
|
||||
googleSigninEnabled={googleSigninEnabled}
|
||||
slackSigninEnabled={slackSigninEnabled}
|
||||
lastSignedIn={signinHint}
|
||||
/>
|
||||
</p>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<HeroText>
|
||||
Sign in with your team account to continue to {team.name}.
|
||||
<Subdomain>{hostname}</Subdomain>
|
||||
@ -52,6 +87,11 @@ function SubdomainSignin({
|
||||
lastSignedIn={signinHint}
|
||||
/>
|
||||
</p>
|
||||
|
||||
<HeroText>Have a guest account? Sign in with email…</HeroText>
|
||||
{guestSigninForm}
|
||||
</React.Fragment>
|
||||
)}
|
||||
</Hero>
|
||||
</Grid>
|
||||
<Alternative>
|
||||
@ -64,6 +104,14 @@ function SubdomainSignin({
|
||||
);
|
||||
}
|
||||
|
||||
const EmailInput = styled.input`
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #999;
|
||||
min-width: 217px;
|
||||
height: 56px;
|
||||
`;
|
||||
|
||||
const Subdomain = styled.span`
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
|
@ -6,9 +6,15 @@ type Props = {
|
||||
notice?: string,
|
||||
};
|
||||
|
||||
export default function AuthErrors({ notice }: Props) {
|
||||
export default function AuthNotices({ notice }: Props) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
{notice === 'guest-success' && (
|
||||
<Notice>
|
||||
A magic sign-in link has been sent to your email address, no password
|
||||
needed.
|
||||
</Notice>
|
||||
)}
|
||||
{notice === 'google-hd' && (
|
||||
<Notice>
|
||||
Sorry, Google sign in cannot be used with a personal email. Please try
|
||||
@ -21,12 +27,30 @@ export default function AuthErrors({ notice }: Props) {
|
||||
an allowed company domain.
|
||||
</Notice>
|
||||
)}
|
||||
{notice === 'email-auth-required' && (
|
||||
<Notice>
|
||||
Your account uses email sign-in, please sign-in with email to
|
||||
continue.
|
||||
</Notice>
|
||||
)}
|
||||
{notice === 'email-auth-ratelimit' && (
|
||||
<Notice>
|
||||
An email sign-in link was recently sent, please check your inbox and
|
||||
try again in a few minutes.
|
||||
</Notice>
|
||||
)}
|
||||
{notice === 'auth-error' && (
|
||||
<Notice>
|
||||
Authentication failed - we were unable to sign you in at this time.
|
||||
Please try again.
|
||||
</Notice>
|
||||
)}
|
||||
{notice === 'expired-token' && (
|
||||
<Notice>
|
||||
Sorry, it looks like that sign-in link is no longer valid, please try
|
||||
requesting another.
|
||||
</Notice>
|
||||
)}
|
||||
{notice === 'suspended' && (
|
||||
<Notice>
|
||||
Your Outline account has been suspended. To re-activate your account,
|
@ -2,12 +2,13 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Button = styled.a`
|
||||
border: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 10px 20px;
|
||||
color: ${props => props.theme.white};
|
||||
background: ${props => props.theme.black};
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
height: 56px;
|
||||
`;
|
||||
|
@ -7,7 +7,7 @@ const HeroText = styled.p`
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
max-width: 600px;
|
||||
margin-bottom: 2em;
|
||||
margin-bottom: 1em;
|
||||
`;
|
||||
|
||||
export default HeroText;
|
||||
|
@ -13,12 +13,14 @@ type Props = {
|
||||
lastSignedIn?: string,
|
||||
googleSigninEnabled: boolean,
|
||||
slackSigninEnabled: boolean,
|
||||
guestSigninEnabled?: boolean,
|
||||
};
|
||||
|
||||
const SigninButtons = ({
|
||||
lastSignedIn,
|
||||
slackSigninEnabled,
|
||||
googleSigninEnabled,
|
||||
guestSigninEnabled,
|
||||
}: Props) => {
|
||||
return (
|
||||
<Wrapper>
|
||||
|
@ -16,10 +16,16 @@ allow(User, 'invite', User, actor => {
|
||||
return true;
|
||||
});
|
||||
|
||||
allow(User, ['update', 'delete'], User, (actor, user) => {
|
||||
allow(User, 'update', User, (actor, user) => {
|
||||
if (!user || user.teamId !== actor.teamId) return false;
|
||||
if (user.id === actor.id) return true;
|
||||
if (actor.isAdmin) return true;
|
||||
throw new AdminRequiredError();
|
||||
});
|
||||
|
||||
allow(User, 'delete', User, (actor, user) => {
|
||||
if (!user || user.teamId !== actor.teamId) return false;
|
||||
if (user.id === actor.id) return true;
|
||||
if (actor.isAdmin && !user.lastActiveAt) return true;
|
||||
throw new AdminRequiredError();
|
||||
});
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
exports[`presents a user 1`] = `
|
||||
Object {
|
||||
"avatarUrl": "http://example.com/avatar.png",
|
||||
"avatarUrl": undefined,
|
||||
"createdAt": undefined,
|
||||
"id": "123",
|
||||
"isAdmin": undefined,
|
||||
@ -14,7 +14,7 @@ Object {
|
||||
|
||||
exports[`presents a user without slack data 1`] = `
|
||||
Object {
|
||||
"avatarUrl": null,
|
||||
"avatarUrl": undefined,
|
||||
"createdAt": undefined,
|
||||
"id": "123",
|
||||
"isAdmin": undefined,
|
||||
|
@ -10,6 +10,7 @@ export default function present(team: Team) {
|
||||
googleConnected: !!team.googleId,
|
||||
sharing: team.sharing,
|
||||
documentEmbeds: team.documentEmbeds,
|
||||
guestSignin: team.guestSignin,
|
||||
subdomain: team.subdomain,
|
||||
url: team.url,
|
||||
};
|
||||
|
@ -22,8 +22,7 @@ export default (user: User, options: Options = {}): ?UserPresentation => {
|
||||
userData.name = user.name;
|
||||
userData.isAdmin = user.isAdmin;
|
||||
userData.isSuspended = user.isSuspended;
|
||||
userData.avatarUrl =
|
||||
user.avatarUrl || (user.slackData ? user.slackData.image_192 : null);
|
||||
userData.avatarUrl = user.avatarUrl;
|
||||
|
||||
if (options.includeDetails) {
|
||||
userData.email = user.email;
|
||||
|
@ -96,6 +96,7 @@ router.get('/', async ctx => {
|
||||
ctx,
|
||||
<SubdomainSignin
|
||||
team={team}
|
||||
guest={ctx.request.query.guest}
|
||||
notice={ctx.request.query.notice}
|
||||
lastSignedIn={lastSignedIn}
|
||||
googleSigninEnabled={!!process.env.GOOGLE_CLIENT_ID}
|
||||
|
@ -50,6 +50,7 @@ export async function buildUser(overrides: Object = {}) {
|
||||
service: 'slack',
|
||||
serviceId: uuid.v4(),
|
||||
createdAt: new Date('2018-01-01T00:00:00.000Z'),
|
||||
lastActiveAt: new Date('2018-01-01T00:00:00.000Z'),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
@ -1,9 +1,10 @@
|
||||
// @flow
|
||||
import JWT from 'jsonwebtoken';
|
||||
import subMinutes from 'date-fns/sub_minutes';
|
||||
import { AuthenticationError } from '../errors';
|
||||
import { User } from '../models';
|
||||
|
||||
export async function getUserForJWT(token: string) {
|
||||
function getJWTPayload(token) {
|
||||
let payload;
|
||||
try {
|
||||
payload = JWT.decode(token);
|
||||
@ -11,8 +12,14 @@ export async function getUserForJWT(token: string) {
|
||||
throw new AuthenticationError('Unable to decode JWT token');
|
||||
}
|
||||
|
||||
if (!payload) throw new AuthenticationError('Invalid token');
|
||||
if (!payload) {
|
||||
throw new AuthenticationError('Invalid token');
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function getUserForJWT(token: string) {
|
||||
const payload = getJWTPayload(token);
|
||||
const user = await User.findByPk(payload.id);
|
||||
|
||||
try {
|
||||
@ -23,3 +30,30 @@ export async function getUserForJWT(token: string) {
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function getUserForEmailSigninToken(token: string) {
|
||||
const payload = getJWTPayload(token);
|
||||
|
||||
// check the token is within it's expiration time
|
||||
if (payload.createdAt) {
|
||||
if (new Date(payload.createdAt) < subMinutes(new Date(), 10)) {
|
||||
throw new AuthenticationError('Expired token');
|
||||
}
|
||||
}
|
||||
|
||||
const user = await User.findByPk(payload.id);
|
||||
|
||||
// if user has signed in at all since the token was created then
|
||||
// it's no longer valid, they'll need a new one.
|
||||
if (user.lastSignedInAt > payload.createdAt) {
|
||||
throw new AuthenticationError('Token has already been used');
|
||||
}
|
||||
|
||||
try {
|
||||
JWT.verify(token, user.jwtSecret);
|
||||
} catch (err) {
|
||||
throw new AuthenticationError('Invalid token');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
@ -57,6 +57,7 @@ export const base = {
|
||||
export const light = {
|
||||
...base,
|
||||
background: colors.white,
|
||||
secondaryBackground: colors.warmGrey,
|
||||
|
||||
link: colors.almostBlack,
|
||||
text: colors.almostBlack,
|
||||
@ -106,6 +107,7 @@ export const light = {
|
||||
export const dark = {
|
||||
...base,
|
||||
background: colors.almostBlack,
|
||||
secondaryBackground: colors.black50,
|
||||
|
||||
link: colors.almostWhite,
|
||||
text: colors.almostWhite,
|
||||
|
Reference in New Issue
Block a user