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:
Tom Moor
2019-12-15 18:46:08 -08:00
committed by GitHub
parent 5731ff34a4
commit 6d8216c54e
45 changed files with 846 additions and 206 deletions

View File

@ -2,10 +2,12 @@
import * as React from 'react'; import * as React from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import HelpText from 'components/HelpText'; import HelpText from 'components/HelpText';
import VisuallyHidden from 'components/VisuallyHidden';
export type Props = { export type Props = {
checked?: boolean, checked?: boolean,
label?: string, label?: string,
labelHidden?: boolean,
className?: string, className?: string,
note?: string, note?: string,
small?: boolean, small?: boolean,
@ -30,18 +32,26 @@ const Label = styled.label`
export default function Checkbox({ export default function Checkbox({
label, label,
labelHidden,
note, note,
className, className,
small, small,
short, short,
...rest ...rest
}: Props) { }: Props) {
const wrappedLabel = <LabelText small={small}>{label}</LabelText>;
return ( return (
<React.Fragment> <React.Fragment>
<Wrapper small={small}> <Wrapper small={small}>
<Label> <Label>
<input type="checkbox" {...rest} /> <input type="checkbox" {...rest} />
{label && <LabelText small={small}>{label}</LabelText>} {label &&
(labelHidden ? (
<VisuallyHidden>{wrappedLabel}</VisuallyHidden>
) : (
wrappedLabel
))}
</Label> </Label>
{note && <HelpText small>{note}</HelpText>} {note && <HelpText small>{note}</HelpText>}
</Wrapper> </Wrapper>

View File

@ -7,4 +7,12 @@ const Tabs = styled.nav`
margin-bottom: 12px; 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; export default Tabs;

View File

@ -42,7 +42,7 @@ class UserMenu extends React.Component<Props> {
const { user, users } = this.props; const { user, users } = this.props;
if ( if (
!window.confirm( !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; return;
@ -50,6 +50,12 @@ class UserMenu extends React.Component<Props> {
users.suspend(user); users.suspend(user);
}; };
handleRevoke = (ev: SyntheticEvent<>) => {
ev.preventDefault();
const { user, users } = this.props;
users.delete(user, { confirmation: true });
};
handleActivate = (ev: SyntheticEvent<>) => { handleActivate = (ev: SyntheticEvent<>) => {
ev.preventDefault(); ev.preventDefault();
const { user, users } = this.props; const { user, users } = this.props;
@ -71,15 +77,21 @@ class UserMenu extends React.Component<Props> {
Make {user.name} an admin Make {user.name} an admin
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
{user.isSuspended ? ( {!user.lastActiveAt && (
<DropdownMenuItem onClick={this.handleActivate}> <DropdownMenuItem onClick={this.handleRevoke}>
Activate account Revoke invite
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={this.handleSuspend}>
Suspend account
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{user.lastActiveAt &&
(user.isSuspended ? (
<DropdownMenuItem onClick={this.handleActivate}>
Activate account
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={this.handleSuspend}>
Suspend account
</DropdownMenuItem>
))}
</DropdownMenu> </DropdownMenu>
); );
} }

View File

@ -1,4 +1,5 @@
// @flow // @flow
import { computed } from 'mobx';
import BaseModel from './BaseModel'; import BaseModel from './BaseModel';
class Team extends BaseModel { class Team extends BaseModel {
@ -9,8 +10,18 @@ class Team extends BaseModel {
googleConnected: boolean; googleConnected: boolean;
sharing: boolean; sharing: boolean;
documentEmbeds: boolean; documentEmbeds: boolean;
guestSignin: boolean;
subdomain: ?string; subdomain: ?string;
url: 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; export default Team;

View File

@ -1,14 +1,15 @@
// @flow // @flow
import * as React from 'react'; 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 { observable } from 'mobx';
import { inject, observer } from 'mobx-react'; import { inject, observer } from 'mobx-react';
import { CloseIcon } from 'outline-icons'; import { CloseIcon } from 'outline-icons';
import styled from 'styled-components'; import styled from 'styled-components';
import Flex from 'shared/components/Flex'; import Flex from 'shared/components/Flex';
import CopyToClipboard from 'components/CopyToClipboard';
import Button from 'components/Button'; import Button from 'components/Button';
import Input from 'components/Input'; import Input from 'components/Input';
import CopyToClipboard from 'components/CopyToClipboard';
import Checkbox from 'components/Checkbox';
import HelpText from 'components/HelpText'; import HelpText from 'components/HelpText';
import Tooltip from 'components/Tooltip'; import Tooltip from 'components/Tooltip';
import NudeButton from 'components/NudeButton'; import NudeButton from 'components/NudeButton';
@ -16,6 +17,7 @@ import NudeButton from 'components/NudeButton';
import UiStore from 'stores/UiStore'; import UiStore from 'stores/UiStore';
import AuthStore from 'stores/AuthStore'; import AuthStore from 'stores/AuthStore';
import UsersStore from 'stores/UsersStore'; import UsersStore from 'stores/UsersStore';
import PoliciesStore from 'stores/PoliciesStore';
const MAX_INVITES = 20; const MAX_INVITES = 20;
@ -23,6 +25,7 @@ type Props = {
auth: AuthStore, auth: AuthStore,
users: UsersStore, users: UsersStore,
history: RouterHistory, history: RouterHistory,
policies: PoliciesStore,
ui: UiStore, ui: UiStore,
onSubmit: () => void, onSubmit: () => void,
}; };
@ -32,10 +35,10 @@ class Invite extends React.Component<Props> {
@observable isSaving: boolean; @observable isSaving: boolean;
@observable linkCopied: boolean = false; @observable linkCopied: boolean = false;
@observable @observable
invites: { email: string, name: string }[] = [ invites: { email: string, name: string, guest: boolean }[] = [
{ email: '', name: '' }, { email: '', name: '', guest: false },
{ email: '', name: '' }, { email: '', name: '', guest: false },
{ email: '', name: '' }, { email: '', name: '', guest: false },
]; ];
handleSubmit = async (ev: SyntheticEvent<>) => { handleSubmit = async (ev: SyntheticEvent<>) => {
@ -57,6 +60,10 @@ class Invite extends React.Component<Props> {
this.invites[index][ev.target.name] = ev.target.value; this.invites[index][ev.target.name] = ev.target.value;
}; };
handleGuestChange = (ev, index) => {
this.invites[index][ev.target.name] = ev.target.checked;
};
handleAdd = () => { handleAdd = () => {
if (this.invites.length >= MAX_INVITES) { if (this.invites.length >= MAX_INVITES) {
this.props.ui.showToast( 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); this.invites.splice(index, 1);
}; };
@ -81,23 +89,40 @@ class Invite extends React.Component<Props> {
if (!team || !user) return null; if (!team || !user) return null;
const predictedDomain = user.email.split('@')[1]; const predictedDomain = user.email.split('@')[1];
const can = this.props.policies.abilities(team.id);
return ( return (
<form onSubmit={this.handleSubmit}> <form onSubmit={this.handleSubmit}>
<HelpText> {team.guestSignin ? (
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> <HelpText>
You can also{' '} Invite team members or guests to join your knowledge base. Team
<CopyToClipboard text={team.url} onCopy={this.handleCopy}> members can sign in with {team.signinMethods} and guests can use
<a>{this.linkCopied ? 'link copied' : 'copy a link'}</a> their email address.
</CopyToClipboard>{' '}
to your teams signin page.
</HelpText> </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 && (
<CopyBlock>
Want a link to share directly with your team?
<Flex>
<Input type="text" value={team.url} flex />&nbsp;&nbsp;
<CopyToClipboard text={team.url} onCopy={this.handleCopy}>
<Button type="button" neutral>
{this.linkCopied ? 'Link copied' : 'Copy link'}
</Button>
</CopyToClipboard>
</Flex>
</CopyBlock>
)} )}
{this.invites.map((invite, index) => ( {this.invites.map((invite, index) => (
<Flex key={index}> <Flex key={index}>
@ -109,6 +134,7 @@ class Invite extends React.Component<Props> {
onChange={ev => this.handleChange(ev, index)} onChange={ev => this.handleChange(ev, index)}
placeholder={`example@${predictedDomain}`} placeholder={`example@${predictedDomain}`}
value={invite.email} value={invite.email}
required={index === 0}
autoFocus={index === 0} autoFocus={index === 0}
flex flex
/> />
@ -123,10 +149,33 @@ class Invite extends React.Component<Props> {
required={!!invite.email} required={!!invite.email}
flex flex
/> />
{team.guestSignin && (
<React.Fragment>
&nbsp;&nbsp;
<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 && ( {index !== 0 && (
<Remove> <Remove>
<Tooltip tooltip="Remove invite" placement="top"> <Tooltip tooltip="Remove invite" placement="top">
<NudeButton onClick={() => this.handleRemove(index)}> <NudeButton onClick={ev => this.handleRemove(ev, index)}>
<CloseIcon /> <CloseIcon />
</NudeButton> </NudeButton>
</Tooltip> </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')` const Remove = styled('div')`
margin-top: 6px; margin-top: 6px;
position: absolute; position: absolute;
right: -32px; right: -32px;
`; `;
export default inject('auth', 'users', 'ui')(withRouter(Invite)); export default inject('auth', 'users', 'policies', 'ui')(withRouter(Invite));

View File

@ -5,8 +5,6 @@ import { observable } from 'mobx';
import { observer, inject } from 'mobx-react'; import { observer, inject } from 'mobx-react';
import { PlusIcon } from 'outline-icons'; import { PlusIcon } from 'outline-icons';
import AuthStore from 'stores/AuthStore';
import UsersStore from 'stores/UsersStore';
import Empty from 'components/Empty'; import Empty from 'components/Empty';
import { ListPlaceholder } from 'components/LoadingPlaceholder'; import { ListPlaceholder } from 'components/LoadingPlaceholder';
import Modal from 'components/Modal'; import Modal from 'components/Modal';
@ -17,12 +15,17 @@ import PageTitle from 'components/PageTitle';
import HelpText from 'components/HelpText'; import HelpText from 'components/HelpText';
import UserListItem from './components/UserListItem'; import UserListItem from './components/UserListItem';
import List from 'components/List'; import List from 'components/List';
import Tabs from 'components/Tabs'; import Tabs, { Separator } from 'components/Tabs';
import Tab from 'components/Tab'; import Tab from 'components/Tab';
import AuthStore from 'stores/AuthStore';
import UsersStore from 'stores/UsersStore';
import PoliciesStore from 'stores/PoliciesStore';
type Props = { type Props = {
auth: AuthStore, auth: AuthStore,
users: UsersStore, users: UsersStore,
policies: PoliciesStore,
match: Object, match: Object,
}; };
@ -43,22 +46,27 @@ class People extends React.Component<Props> {
}; };
render() { render() {
const { auth, match } = this.props; const { auth, policies, match } = this.props;
const { filter } = match.params; const { filter } = match.params;
const currentUser = auth.user; const currentUser = auth.user;
const team = auth.team;
invariant(currentUser, 'User should exist'); invariant(currentUser, 'User should exist');
invariant(team, 'Team should exist');
let users = this.props.users.active; let users = this.props.users.active;
if (filter === 'all') { if (filter === 'all') {
users = this.props.users.orderedData; users = this.props.users.all;
} else if (filter === 'admins') { } else if (filter === 'admins') {
users = this.props.users.admins; users = this.props.users.admins;
} else if (filter === 'suspended') { } else if (filter === 'suspended') {
users = this.props.users.suspended; users = this.props.users.suspended;
} else if (filter === 'invited') {
users = this.props.users.invited;
} }
const showLoading = this.props.users.isFetching && !users.length; const showLoading = this.props.users.isFetching && !users.length;
const showEmpty = this.props.users.isLoaded && !users.length; const showEmpty = this.props.users.isLoaded && !users.length;
const can = policies.abilities(team.id);
return ( return (
<CenteredContent> <CenteredContent>
@ -66,8 +74,8 @@ class People extends React.Component<Props> {
<h1>People</h1> <h1>People</h1>
<HelpText> <HelpText>
Everyone that has signed into Outline appears here. Its possible that Everyone that has signed into Outline appears here. Its possible that
there are other users who have access through Single Sign-On but there are other users who have access through {team.signinMethods} but
havent signed into Outline yet. havent signed in yet.
</HelpText> </HelpText>
<Button <Button
type="button" type="button"
@ -88,7 +96,7 @@ class People extends React.Component<Props> {
<Tab to="/settings/people/admins" exact> <Tab to="/settings/people/admins" exact>
Admins Admins
</Tab> </Tab>
{currentUser.isAdmin && ( {can.update && (
<Tab to="/settings/people/suspended" exact> <Tab to="/settings/people/suspended" exact>
Suspended Suspended
</Tab> </Tab>
@ -96,13 +104,22 @@ class People extends React.Component<Props> {
<Tab to="/settings/people/all" exact> <Tab to="/settings/people/all" exact>
Everyone Everyone
</Tab> </Tab>
{can.invite && (
<React.Fragment>
<Separator />
<Tab to="/settings/people/invited" exact>
Invited
</Tab>
</React.Fragment>
)}
</Tabs> </Tabs>
<List> <List>
{users.map(user => ( {users.map(user => (
<UserListItem <UserListItem
key={user.id} key={user.id}
user={user} user={user}
showMenu={!!currentUser.isAdmin && currentUser.id !== user.id} showMenu={can.update && currentUser.id !== user.id}
/> />
))} ))}
</List> </List>
@ -121,4 +138,4 @@ class People extends React.Component<Props> {
} }
} }
export default inject('auth', 'users')(People); export default inject('auth', 'users', 'policies')(People);

View File

@ -22,11 +22,13 @@ class Security extends React.Component<Props> {
@observable sharing: boolean; @observable sharing: boolean;
@observable documentEmbeds: boolean; @observable documentEmbeds: boolean;
@observable guestSignin: boolean;
componentDidMount() { componentDidMount() {
const { auth } = this.props; const { auth } = this.props;
if (auth.team) { if (auth.team) {
this.documentEmbeds = auth.team.documentEmbeds; this.documentEmbeds = auth.team.documentEmbeds;
this.guestSignin = auth.team.guestSignin;
this.sharing = auth.team.sharing; this.sharing = auth.team.sharing;
} }
} }
@ -39,12 +41,16 @@ class Security extends React.Component<Props> {
case 'documentEmbeds': case 'documentEmbeds':
this.documentEmbeds = ev.target.checked; this.documentEmbeds = ev.target.checked;
break; break;
case 'guestSignin':
this.guestSignin = ev.target.checked;
break;
default: default:
} }
await this.props.auth.updateTeam({ await this.props.auth.updateTeam({
sharing: this.sharing, sharing: this.sharing,
documentEmbeds: this.documentEmbeds, documentEmbeds: this.documentEmbeds,
guestSignin: this.guestSignin,
}); });
this.showSuccessMessage(); this.showSuccessMessage();
}; };
@ -54,6 +60,8 @@ class Security extends React.Component<Props> {
}, 500); }, 500);
render() { render() {
const { team } = this.props.auth;
return ( return (
<CenteredContent> <CenteredContent>
<PageTitle title="Security" /> <PageTitle title="Security" />
@ -63,6 +71,15 @@ class Security extends React.Component<Props> {
knowledgebase. knowledgebase.
</HelpText> </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 <Checkbox
label="Public document sharing" label="Public document sharing"
name="sharing" name="sharing"

View File

@ -51,7 +51,13 @@ class UserListItem extends React.Component<Props> {
subtitle={ subtitle={
<React.Fragment> <React.Fragment>
{user.email ? `${user.email} · ` : undefined} {user.email ? `${user.email} · ` : undefined}
Active <Time dateTime={user.lastActiveAt} /> ago {user.lastActiveAt ? (
<React.Fragment>
Active <Time dateTime={user.lastActiveAt} /> ago
</React.Fragment>
) : (
'Pending'
)}
{user.isAdmin && <Badge admin={user.isAdmin}>Admin</Badge>} {user.isAdmin && <Badge admin={user.isAdmin}>Admin</Badge>}
{user.isSuspended && <Badge>Suspended</Badge>} {user.isSuspended && <Badge>Suspended</Badge>}
</React.Fragment> </React.Fragment>

View File

@ -29,8 +29,8 @@ export default class AuthStore {
} }
this.rootStore = rootStore; this.rootStore = rootStore;
this.user = data.user; this.user = new User(data.user);
this.team = data.team; this.team = new Team(data.team);
this.token = getCookie('accessToken'); this.token = getCookie('accessToken');
if (this.token) setImmediate(() => this.fetch()); if (this.token) setImmediate(() => this.fetch());
@ -72,8 +72,8 @@ export default class AuthStore {
runInAction('AuthStore#fetch', () => { runInAction('AuthStore#fetch', () => {
this.addPolicies(res.policies); this.addPolicies(res.policies);
const { user, team } = res.data; const { user, team } = res.data;
this.user = user; this.user = new User(user);
this.team = team; this.team = new Team(team);
if (window.Bugsnag) { if (window.Bugsnag) {
Bugsnag.user = { Bugsnag.user = {
@ -141,7 +141,7 @@ export default class AuthStore {
runInAction('AuthStore#updateTeam', () => { runInAction('AuthStore#updateTeam', () => {
this.addPolicies(res.policies); this.addPolicies(res.policies);
this.team = res.data; this.team = new Team(res.data);
}); });
} finally { } finally {
this.isSaving = false; this.isSaving = false;

View File

@ -114,14 +114,17 @@ export default class BaseStore<T: BaseModel> {
} }
@action @action
async delete(item: T) { async delete(item: T, options?: Object = {}) {
if (!this.actions.includes('delete')) { if (!this.actions.includes('delete')) {
throw new Error(`Cannot delete ${this.modelName}`); throw new Error(`Cannot delete ${this.modelName}`);
} }
this.isSaving = true; this.isSaving = true;
try { 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); return this.remove(item.id);
} finally { } finally {
this.isSaving = false; this.isSaving = false;

View File

@ -14,7 +14,10 @@ export default class UsersStore extends BaseStore<User> {
@computed @computed
get active(): User[] { get active(): User[] {
return filter(this.orderedData, user => !user.isSuspended); return filter(
this.orderedData,
user => !user.isSuspended && user.lastActiveAt
);
} }
@computed @computed
@ -22,11 +25,21 @@ export default class UsersStore extends BaseStore<User> {
return filter(this.orderedData, user => user.isSuspended); return filter(this.orderedData, user => user.isSuspended);
} }
@computed
get invited(): User[] {
return filter(this.orderedData, user => !user.lastActiveAt);
}
@computed @computed
get admins(): User[] { get admins(): User[] {
return filter(this.orderedData, user => user.isAdmin); return filter(this.orderedData, user => user.isAdmin);
} }
@computed
get all(): User[] {
return filter(this.orderedData, user => user.lastActiveAt);
}
@computed @computed
get orderedData(): User[] { get orderedData(): User[] {
return orderBy(Array.from(this.data.values()), 'name', 'asc'); return orderBy(Array.from(this.data.values()), 'name', 'asc');

View File

@ -3,7 +3,7 @@
exports[`#users.activate should activate a suspended user 1`] = ` exports[`#users.activate should activate a suspended user 1`] = `
Object { Object {
"data": 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", "createdAt": "2018-01-02T00:00:00.000Z",
"email": "user1@example.com", "email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61", "id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
@ -38,7 +38,7 @@ Object {
exports[`#users.demote should demote an admin 1`] = ` exports[`#users.demote should demote an admin 1`] = `
Object { Object {
"data": 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", "createdAt": "2018-01-02T00:00:00.000Z",
"email": "user1@example.com", "email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61", "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`] = ` exports[`#users.demote should require admin 1`] = `
Object { Object {
"error": "admin_required", "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`] = ` exports[`#users.promote should promote a new admin 1`] = `
Object { Object {
"data": 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", "createdAt": "2018-01-02T00:00:00.000Z",
"email": "user1@example.com", "email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61", "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`] = ` exports[`#users.suspend should require admin 1`] = `
Object { Object {
"error": "admin_required", "error": "admin_required",
@ -108,7 +117,7 @@ Object {
exports[`#users.suspend should suspend an user 1`] = ` exports[`#users.suspend should suspend an user 1`] = `
Object { Object {
"data": 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", "createdAt": "2018-01-02T00:00:00.000Z",
"email": "user1@example.com", "email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61", "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`] = ` exports[`#users.update should require authentication 1`] = `
Object { Object {
"error": "authentication_required", "error": "authentication_required",

View File

@ -56,6 +56,7 @@ describe('#events.list', async () => {
const res = await server.post('/api/events.list', { const res = await server.post('/api/events.list', {
body: { token: admin.getJwtToken() }, body: { token: admin.getJwtToken() },
}); });
const body = await res.json(); const body = await res.json();
expect(res.status).toEqual(200); expect(res.status).toEqual(200);

View File

@ -18,9 +18,9 @@ import notificationSettings from './notificationSettings';
import utils from './utils'; import utils from './utils';
import { NotFoundError } from '../errors'; import { NotFoundError } from '../errors';
import errorHandling from './middlewares/errorHandling'; import errorHandling from '../middlewares/errorHandling';
import validation from '../middlewares/validation'; import validation from '../middlewares/validation';
import methodOverride from './middlewares/methodOverride'; import methodOverride from '../middlewares/methodOverride';
import cache from './middlewares/cache'; import cache from './middlewares/cache';
import apiWrapper from './middlewares/apiWrapper'; import apiWrapper from './middlewares/apiWrapper';

View File

@ -11,20 +11,28 @@ const { authorize } = policy;
const router = new Router(); const router = new Router();
router.post('team.update', auth(), async ctx => { 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 endpoint = publicS3Endpoint();
const user = ctx.state.user; const user = ctx.state.user;
const team = await Team.findByPk(user.teamId); const team = await Team.findByPk(user.teamId);
authorize(user, 'update', team); authorize(user, 'update', team);
if (process.env.SUBDOMAINS_ENABLED === 'true') { if (subdomain !== undefined && process.env.SUBDOMAINS_ENABLED === 'true') {
team.subdomain = subdomain === '' ? null : subdomain; team.subdomain = subdomain === '' ? null : subdomain;
} }
if (name) team.name = name; if (name) team.name = name;
if (sharing !== undefined) team.sharing = sharing; if (sharing !== undefined) team.sharing = sharing;
if (documentEmbeds !== undefined) team.documentEmbeds = documentEmbeds; if (documentEmbeds !== undefined) team.documentEmbeds = documentEmbeds;
if (guestSignin !== undefined) team.guestSignin = guestSignin;
if (avatarUrl && avatarUrl.startsWith(`${endpoint}/uploads/${user.id}`)) { if (avatarUrl && avatarUrl.startsWith(`${endpoint}/uploads/${user.id}`)) {
team.avatarUrl = avatarUrl; team.avatarUrl = avatarUrl;
} }

View File

@ -254,11 +254,12 @@ router.post('users.invite', auth(), async ctx => {
}); });
router.post('users.delete', 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'); ctx.assertPresent(confirmation, 'confirmation is required');
const user = ctx.state.user; let user = ctx.state.user;
authorize(user, 'delete', user); if (id) user = await User.findByPk(id);
authorize(ctx.state.user, 'delete', user);
try { try {
await user.destroy(); await user.destroy();

View File

@ -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 () => { describe('#users.delete', async () => {
it('should not allow deleting without confirmation', async () => { it('should not allow deleting without confirmation', async () => {
const user = await buildUser(); const user = await buildUser();
@ -111,6 +131,27 @@ describe('#users.delete', async () => {
expect(res.status).toEqual(200); 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 () => { it('should require authentication', async () => {
const res = await server.post('/api/users.delete'); const res = await server.post('/api/users.delete');
const body = await res.json(); const body = await res.json();
@ -183,7 +224,7 @@ describe('#users.demote', async () => {
expect(body).toMatchSnapshot(); 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 admin = await buildUser({ isAdmin: true });
const res = await server.post('/api/users.demote', { const res = await server.post('/api/users.demote', {
@ -226,7 +267,7 @@ describe('#users.suspend', async () => {
expect(body).toMatchSnapshot(); 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 admin = await buildUser({ isAdmin: true });
const res = await server.post('/api/users.suspend', { const res = await server.post('/api/users.suspend', {
body: { body: {

91
server/auth/email.js Normal file
View 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;

View File

@ -1,4 +1,5 @@
// @flow // @flow
import Sequelize from 'sequelize';
import crypto from 'crypto'; import crypto from 'crypto';
import Router from 'koa-router'; import Router from 'koa-router';
import { capitalize } from 'lodash'; import { capitalize } from 'lodash';
@ -6,6 +7,8 @@ import { OAuth2Client } from 'google-auth-library';
import { User, Team, Event } from '../models'; import { User, Team, Event } from '../models';
import auth from '../middlewares/authentication'; import auth from '../middlewares/authentication';
const Op = Sequelize.Op;
const router = new Router(); const router = new Router();
const client = new OAuth2Client( const client = new OAuth2Client(
process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_ID,
@ -77,46 +80,75 @@ router.get('google.callback', auth({ required: false }), async ctx => {
}, },
}); });
const [user, isFirstSignin] = await User.findOrCreate({ try {
where: { const [user, isFirstSignin] = await User.findOrCreate({
service: 'google', where: {
serviceId: profile.data.id, [Op.or]: [
teamId: team.id, {
}, service: 'google',
defaults: { serviceId: profile.data.id,
name: profile.data.name, },
email: profile.data.email, {
isAdmin: isFirstUser, service: '',
avatarUrl: profile.data.picture, },
}, ],
}); teamId: team.id,
},
if (isFirstSignin) { defaults: {
await Event.create({ name: profile.data.name,
name: 'users.create', email: profile.data.email,
actorId: user.id, isAdmin: isFirstUser,
userId: user.id, avatarUrl: profile.data.picture,
teamId: team.id,
data: {
name: user.name,
service: 'google',
}, },
ip: ctx.request.ip,
}); });
}
// update email address if it's changed in Google if (isFirstSignin) {
if (!isFirstSignin && profile.data.email !== user.email) { await Event.create({
await user.update({ email: profile.data.email }); name: 'users.create',
} actorId: user.id,
userId: user.id,
teamId: team.id,
data: {
name: user.name,
service: 'google',
},
ip: ctx.request.ip,
});
}
if (isFirstUser) { // update email address if it's changed in Google
await team.provisionFirstCollection(user.id); if (!isFirstSignin && profile.data.email !== user.email) {
await team.provisionSubdomain(hostname); await user.update({ email: profile.data.email });
} }
// set cookies on response and redirect to team subdomain if (isFirstUser) {
ctx.signIn(user, team, 'google', isFirstSignin); await team.provisionFirstCollection(user.id);
await team.provisionSubdomain(hostname);
}
// 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; export default router;

View File

@ -10,12 +10,14 @@ import { stripSubdomain } from '../../shared/utils/domains';
import slack from './slack'; import slack from './slack';
import google from './google'; import google from './google';
import email from './email';
const app = new Koa(); const app = new Koa();
const router = new Router(); const router = new Router();
router.use('/', slack.routes()); router.use('/', slack.routes());
router.use('/', google.routes()); router.use('/', google.routes());
router.use('/', email.routes());
router.get('/redirect', auth(), async ctx => { router.get('/redirect', auth(), async ctx => {
const user = ctx.state.user; const user = ctx.state.user;

View File

@ -1,4 +1,5 @@
// @flow // @flow
import Sequelize from 'sequelize';
import Router from 'koa-router'; import Router from 'koa-router';
import auth from '../middlewares/authentication'; import auth from '../middlewares/authentication';
import addHours from 'date-fns/add_hours'; import addHours from 'date-fns/add_hours';
@ -14,6 +15,7 @@ import {
} from '../models'; } from '../models';
import * as Slack from '../slack'; import * as Slack from '../slack';
const Op = Sequelize.Op;
const router = new Router(); const router = new Router();
// start the oauth process and redirect user to Slack // 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'); ctx.assertPresent(state, 'state is required');
if (state !== ctx.cookies.get('state')) { if (state !== ctx.cookies.get('state')) {
ctx.redirect('/?notice=auth-error'); ctx.redirect('/?notice=auth-error&error=state_mismatch');
return; return;
} }
if (error) { if (error) {
@ -57,46 +59,75 @@ router.get('slack.callback', auth({ required: false }), async ctx => {
}, },
}); });
const [user, isFirstSignin] = await User.findOrCreate({ try {
where: { const [user, isFirstSignin] = await User.findOrCreate({
service: 'slack', where: {
serviceId: data.user.id, [Op.or]: [
teamId: team.id, {
}, service: 'slack',
defaults: { serviceId: data.user.id,
name: data.user.name, },
email: data.user.email, {
isAdmin: isFirstUser, service: '',
avatarUrl: data.user.image_192, },
}, ],
}); teamId: team.id,
},
if (isFirstUser) { defaults: {
await team.provisionFirstCollection(user.id); name: data.user.name,
await team.provisionSubdomain(data.team.domain); email: data.user.email,
} isAdmin: isFirstUser,
avatarUrl: data.user.image_192,
if (isFirstSignin) {
await Event.create({
name: 'users.create',
actorId: user.id,
userId: user.id,
teamId: team.id,
data: {
name: user.name,
service: 'slack',
}, },
ip: ctx.request.ip,
}); });
}
// update email address if it's changed in Slack if (isFirstUser) {
if (!isFirstSignin && data.user.email !== user.email) { await team.provisionFirstCollection(user.id);
await user.update({ email: data.user.email }); await team.provisionSubdomain(data.team.domain);
} }
// set cookies on response and redirect to team subdomain if (isFirstSignin) {
ctx.signIn(user, team, 'slack', isFirstSignin); await Event.create({
name: 'users.create',
actorId: user.id,
userId: user.id,
teamId: team.id,
data: {
name: user.name,
service: 'slack',
},
ip: ctx.request.ip,
});
}
// update email address if it's changed in Slack
if (!isFirstSignin && data.user.email !== user.email) {
await user.update({ email: data.user.email });
}
// 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 => { router.get('slack.commands', auth({ required: false }), async ctx => {

View File

@ -2,8 +2,9 @@
import { uniqBy } from 'lodash'; import { uniqBy } from 'lodash';
import { User, Event, Team } from '../models'; import { User, Event, Team } from '../models';
import mailer from '../mailer'; 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({ export default async function userInviter({
user, user,
@ -16,7 +17,7 @@ export default async function userInviter({
}): Promise<{ sent: Invite[] }> { }): Promise<{ sent: Invite[] }> {
const team = await Team.findByPk(user.teamId); 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( const compactedInvites = uniqBy(
invites.filter(invite => !!invite.email.trim() && invite.email.match('@')), invites.filter(invite => !!invite.email.trim() && invite.email.match('@')),
'email' 'email'
@ -38,24 +39,44 @@ export default async function userInviter({
// send and record invites // send and record invites
await Promise.all( await Promise.all(
filteredInvites.map(async invite => { filteredInvites.map(async invite => {
await Event.create({ const transaction = await sequelize.transaction();
name: 'users.invite', try {
actorId: user.id, await User.create(
teamId: user.teamId, {
data: { teamId: user.teamId,
email: invite.email, name: invite.name,
email: invite.email,
service: null,
},
{ transaction }
);
await Event.create(
{
name: 'users.invite',
actorId: user.id,
teamId: user.teamId,
data: {
email: invite.email,
name: invite.name,
},
ip,
},
{ transaction }
);
await mailer.invite({
to: invite.email,
name: invite.name, name: invite.name,
}, guest: invite.guest,
ip, actorName: user.name,
}); actorEmail: user.email,
await mailer.invite({ teamName: team.name,
to: invite.email, teamUrl: team.url,
name: invite.name, });
actorName: user.name, await transaction.commit();
actorEmail: user.email, } catch (err) {
teamName: team.name, await transaction.rollback();
teamUrl: team.url, throw err;
}); }
}) })
); );

View File

@ -10,6 +10,7 @@ import EmptySpace from './components/EmptySpace';
export type Props = { export type Props = {
name: string, name: string,
guest: boolean,
actorName: string, actorName: string,
actorEmail: string, actorEmail: string,
teamName: string, teamName: string,
@ -21,6 +22,7 @@ export const inviteEmailText = ({
actorName, actorName,
actorEmail, actorEmail,
teamUrl, teamUrl,
guest,
}: Props) => ` }: Props) => `
Join ${teamName} on Outline Join ${teamName} on Outline
@ -28,7 +30,7 @@ ${actorName} (${
actorEmail actorEmail
}) has invited you to join Outline, a place for your team to build and share knowledge. }) 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 = ({ export const InviteEmail = ({
@ -36,6 +38,7 @@ export const InviteEmail = ({
actorName, actorName,
actorEmail, actorEmail,
teamUrl, teamUrl,
guest,
}: Props) => { }: Props) => {
return ( return (
<EmailTemplate> <EmailTemplate>
@ -49,7 +52,9 @@ export const InviteEmail = ({
</p> </p>
<EmptySpace height={10} /> <EmptySpace height={10} />
<p> <p>
<Button href={teamUrl}>Join now</Button> <Button href={`${teamUrl}${guest ? '?guest=true' : ''}`}>
Join now
</Button>
</p> </p>
</Body> </Body>

View 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 teams
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 teams
signin page at: <a href={teamUrl}>{teamUrl}</a>
</p>
</Body>
<Footer />
</EmailTemplate>
);
};

View File

@ -8,6 +8,7 @@ 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 { SigninEmail, signinEmailText } from './emails/SigninEmail';
import { import {
type Props as InviteEmailT, type Props as InviteEmailT,
InviteEmail, 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: 'Heres your link to signin to Outline.',
html: <SigninEmail {...opts} />,
text: signinEmailText(opts),
});
};
documentNotification = async ( documentNotification = async (
opts: { to: string } & DocumentNotificationEmailT opts: { to: string } & DocumentNotificationEmailT
) => { ) => {

View File

@ -24,7 +24,7 @@ export default function validation() {
} }
}; };
ctx.assertEmail = (value, message) => { ctx.assertEmail = (value = '', message) => {
if (!validator.isEmail(value)) { if (!validator.isEmail(value)) {
throw new ValidationError(message); throw new ValidationError(message);
} }

View 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
});
},
};

View File

@ -51,6 +51,11 @@ const Team = sequelize.define(
googleId: { type: DataTypes.STRING, allowNull: true }, googleId: { type: DataTypes.STRING, allowNull: true },
avatarUrl: { type: DataTypes.STRING, allowNull: true }, avatarUrl: { type: DataTypes.STRING, allowNull: true },
sharing: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true }, sharing: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true },
guestSignin: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
documentEmbeds: { documentEmbeds: {
type: DataTypes.BOOLEAN, type: DataTypes.BOOLEAN,
allowNull: false, allowNull: false,

View File

@ -8,6 +8,8 @@ import { publicS3Endpoint, uploadToS3FromUrl } from '../utils/s3';
import { sendEmail } from '../mailer'; import { sendEmail } from '../mailer';
import { Star, Team, Collection, NotificationSetting, ApiKey } from '.'; import { Star, Team, Collection, NotificationSetting, ApiKey } from '.';
const DEFAULT_AVATAR_HOST = 'https://tiley.herokuapp.com';
const User = sequelize.define( const User = sequelize.define(
'user', 'user',
{ {
@ -29,6 +31,7 @@ const User = sequelize.define(
lastActiveIp: { type: DataTypes.STRING, allowNull: true }, lastActiveIp: { type: DataTypes.STRING, allowNull: true },
lastSignedInAt: DataTypes.DATE, lastSignedInAt: DataTypes.DATE,
lastSignedInIp: { type: DataTypes.STRING, allowNull: true }, lastSignedInIp: { type: DataTypes.STRING, allowNull: true },
lastSigninEmailSentAt: DataTypes.DATE,
suspendedAt: DataTypes.DATE, suspendedAt: DataTypes.DATE,
suspendedById: DataTypes.UUID, suspendedById: DataTypes.UUID,
}, },
@ -38,6 +41,18 @@ const User = sequelize.define(
isSuspended() { isSuspended() {
return !!this.suspendedAt; 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); 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 uploadAvatar = async model => {
const endpoint = publicS3Endpoint(); 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( const newUrl = await uploadToS3FromUrl(
model.avatarUrl, avatarUrl,
`avatars/${model.id}/${uuid.v4()}` `avatars/${model.id}/${uuid.v4()}`
); );
if (newUrl) model.avatarUrl = newUrl; if (newUrl) model.avatarUrl = newUrl;
@ -126,7 +157,7 @@ const removeIdentifyingInfo = async (model, options) => {
transaction: options.transaction, transaction: options.transaction,
}); });
model.email = ''; model.email = null;
model.name = 'Unknown'; model.name = 'Unknown';
model.avatarUrl = ''; model.avatarUrl = '';
model.serviceId = null; model.serviceId = null;
@ -165,7 +196,7 @@ User.afterCreate(async user => {
// From Slack support: // From Slack support:
// If you wish to contact users at an email address obtained through Slack, // 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. // 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 }); sendEmail('welcome', user.email, { teamUrl: team.url });
} }
}); });

View File

@ -3,7 +3,7 @@ import * as React from 'react';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import styled from 'styled-components'; import styled from 'styled-components';
import Grid from 'styled-components-grid'; import Grid from 'styled-components-grid';
import AuthErrors from './components/AuthErrors'; import AuthNotices from './components/AuthNotices';
import Hero from './components/Hero'; import Hero from './components/Hero';
import HeroText from './components/HeroText'; import HeroText from './components/HeroText';
import SigninButtons from './components/SigninButtons'; import SigninButtons from './components/SigninButtons';
@ -24,7 +24,7 @@ function Home(props: Props) {
</Helmet> </Helmet>
<Grid> <Grid>
<Hero id="signin"> <Hero id="signin">
<AuthErrors notice={props.notice} /> <AuthNotices notice={props.notice} />
{process.env.TEAM_LOGO && <Logo src={process.env.TEAM_LOGO} />} {process.env.TEAM_LOGO && <Logo src={process.env.TEAM_LOGO} />}
<h1>Our teams knowledge base</h1> <h1>Our teams knowledge base</h1>
<HeroText> <HeroText>

View File

@ -4,15 +4,17 @@ import styled from 'styled-components';
import Grid from 'styled-components-grid'; import Grid from 'styled-components-grid';
import Hero from './components/Hero'; import Hero from './components/Hero';
import HeroText from './components/HeroText'; import HeroText from './components/HeroText';
import Button from './components/Button';
import SigninButtons from './components/SigninButtons'; import SigninButtons from './components/SigninButtons';
import AuthErrors from './components/AuthErrors'; import AuthNotices from './components/AuthNotices';
import Centered from './components/Centered'; import Centered from './components/Centered';
import PageTitle from './components/PageTitle'; import PageTitle from './components/PageTitle';
import { Team } from '../models'; import { Team } from '../models';
type Props = { type Props = {
team: Team, team: Team,
notice?: 'google-hd' | 'auth-error' | 'hd-not-allowed', guest?: boolean,
notice?: 'google-hd' | 'auth-error' | 'hd-not-allowed' | 'guest-success',
lastSignedIn: string, lastSignedIn: string,
googleSigninEnabled: boolean, googleSigninEnabled: boolean,
slackSigninEnabled: boolean, slackSigninEnabled: boolean,
@ -21,6 +23,7 @@ type Props = {
function SubdomainSignin({ function SubdomainSignin({
team, team,
guest,
lastSignedIn, lastSignedIn,
notice, notice,
googleSigninEnabled, googleSigninEnabled,
@ -30,6 +33,18 @@ function SubdomainSignin({
googleSigninEnabled = !!team.googleId && googleSigninEnabled; googleSigninEnabled = !!team.googleId && googleSigninEnabled;
slackSigninEnabled = !!team.slackId && slackSigninEnabled; 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 // only show the "last signed in" hint if there is more than one option available
const signinHint = const signinHint =
googleSigninEnabled && slackSigninEnabled ? lastSignedIn : undefined; googleSigninEnabled && slackSigninEnabled ? lastSignedIn : undefined;
@ -39,19 +54,44 @@ function SubdomainSignin({
<PageTitle title={`Sign in to ${team.name}`} /> <PageTitle title={`Sign in to ${team.name}`} />
<Grid> <Grid>
<Hero> <Hero>
<AuthErrors notice={notice} />
<h1>{lastSignedIn ? 'Welcome back,' : 'Hey there,'}</h1> <h1>{lastSignedIn ? 'Welcome back,' : 'Hey there,'}</h1>
<HeroText> <AuthNotices notice={notice} />
Sign in with your team account to continue to {team.name}. {guest ? (
<Subdomain>{hostname}</Subdomain> <React.Fragment>
</HeroText> <HeroText>
<p> Sign in with your email address to continue to {team.name}.
<SigninButtons <Subdomain>{hostname}</Subdomain>
googleSigninEnabled={googleSigninEnabled} </HeroText>
slackSigninEnabled={slackSigninEnabled} {guestSigninForm}
lastSignedIn={signinHint} <br />
/>
</p> <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>
</HeroText>
<p>
<SigninButtons
googleSigninEnabled={googleSigninEnabled}
slackSigninEnabled={slackSigninEnabled}
lastSignedIn={signinHint}
/>
</p>
<HeroText>Have a guest account? Sign in with email</HeroText>
{guestSigninForm}
</React.Fragment>
)}
</Hero> </Hero>
</Grid> </Grid>
<Alternative> <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` const Subdomain = styled.span`
display: block; display: block;
font-weight: 500; font-weight: 500;

View File

@ -6,9 +6,15 @@ type Props = {
notice?: string, notice?: string,
}; };
export default function AuthErrors({ notice }: Props) { export default function AuthNotices({ notice }: Props) {
return ( return (
<React.Fragment> <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 === 'google-hd' && (
<Notice> <Notice>
Sorry, Google sign in cannot be used with a personal email. Please try 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. an allowed company domain.
</Notice> </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 === 'auth-error' && (
<Notice> <Notice>
Authentication failed - we were unable to sign you in at this time. Authentication failed - we were unable to sign you in at this time.
Please try again. Please try again.
</Notice> </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 === 'suspended' && (
<Notice> <Notice>
Your Outline account has been suspended. To re-activate your account, Your Outline account has been suspended. To re-activate your account,

View File

@ -2,12 +2,13 @@
import styled from 'styled-components'; import styled from 'styled-components';
const Button = styled.a` const Button = styled.a`
border: 0;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
padding: 10px 20px; padding: 10px 20px;
color: ${props => props.theme.white}; color: ${props => props.theme.white};
background: ${props => props.theme.black}; background: ${props => props.theme.black};
border-radius: 4px; border-radius: 6px;
font-weight: 600; font-weight: 600;
height: 56px; height: 56px;
`; `;

View File

@ -7,7 +7,7 @@ const HeroText = styled.p`
font-weight: 500; font-weight: 500;
text-align: left; text-align: left;
max-width: 600px; max-width: 600px;
margin-bottom: 2em; margin-bottom: 1em;
`; `;
export default HeroText; export default HeroText;

View File

@ -13,12 +13,14 @@ type Props = {
lastSignedIn?: string, lastSignedIn?: string,
googleSigninEnabled: boolean, googleSigninEnabled: boolean,
slackSigninEnabled: boolean, slackSigninEnabled: boolean,
guestSigninEnabled?: boolean,
}; };
const SigninButtons = ({ const SigninButtons = ({
lastSignedIn, lastSignedIn,
slackSigninEnabled, slackSigninEnabled,
googleSigninEnabled, googleSigninEnabled,
guestSigninEnabled,
}: Props) => { }: Props) => {
return ( return (
<Wrapper> <Wrapper>

View File

@ -16,10 +16,16 @@ allow(User, 'invite', User, actor => {
return true; 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 || user.teamId !== actor.teamId) return false;
if (user.id === actor.id) return true; 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(); throw new AdminRequiredError();
}); });

View File

@ -2,7 +2,7 @@
exports[`presents a user 1`] = ` exports[`presents a user 1`] = `
Object { Object {
"avatarUrl": "http://example.com/avatar.png", "avatarUrl": undefined,
"createdAt": undefined, "createdAt": undefined,
"id": "123", "id": "123",
"isAdmin": undefined, "isAdmin": undefined,
@ -14,7 +14,7 @@ Object {
exports[`presents a user without slack data 1`] = ` exports[`presents a user without slack data 1`] = `
Object { Object {
"avatarUrl": null, "avatarUrl": undefined,
"createdAt": undefined, "createdAt": undefined,
"id": "123", "id": "123",
"isAdmin": undefined, "isAdmin": undefined,

View File

@ -10,6 +10,7 @@ export default function present(team: Team) {
googleConnected: !!team.googleId, googleConnected: !!team.googleId,
sharing: team.sharing, sharing: team.sharing,
documentEmbeds: team.documentEmbeds, documentEmbeds: team.documentEmbeds,
guestSignin: team.guestSignin,
subdomain: team.subdomain, subdomain: team.subdomain,
url: team.url, url: team.url,
}; };

View File

@ -22,8 +22,7 @@ export default (user: User, options: Options = {}): ?UserPresentation => {
userData.name = user.name; userData.name = user.name;
userData.isAdmin = user.isAdmin; userData.isAdmin = user.isAdmin;
userData.isSuspended = user.isSuspended; userData.isSuspended = user.isSuspended;
userData.avatarUrl = userData.avatarUrl = user.avatarUrl;
user.avatarUrl || (user.slackData ? user.slackData.image_192 : null);
if (options.includeDetails) { if (options.includeDetails) {
userData.email = user.email; userData.email = user.email;

View File

@ -96,6 +96,7 @@ router.get('/', async ctx => {
ctx, ctx,
<SubdomainSignin <SubdomainSignin
team={team} team={team}
guest={ctx.request.query.guest}
notice={ctx.request.query.notice} notice={ctx.request.query.notice}
lastSignedIn={lastSignedIn} lastSignedIn={lastSignedIn}
googleSigninEnabled={!!process.env.GOOGLE_CLIENT_ID} googleSigninEnabled={!!process.env.GOOGLE_CLIENT_ID}

View File

@ -50,6 +50,7 @@ export async function buildUser(overrides: Object = {}) {
service: 'slack', service: 'slack',
serviceId: uuid.v4(), serviceId: uuid.v4(),
createdAt: new Date('2018-01-01T00:00:00.000Z'), createdAt: new Date('2018-01-01T00:00:00.000Z'),
lastActiveAt: new Date('2018-01-01T00:00:00.000Z'),
...overrides, ...overrides,
}); });
} }

View File

@ -1,9 +1,10 @@
// @flow // @flow
import JWT from 'jsonwebtoken'; import JWT from 'jsonwebtoken';
import subMinutes from 'date-fns/sub_minutes';
import { AuthenticationError } from '../errors'; import { AuthenticationError } from '../errors';
import { User } from '../models'; import { User } from '../models';
export async function getUserForJWT(token: string) { function getJWTPayload(token) {
let payload; let payload;
try { try {
payload = JWT.decode(token); payload = JWT.decode(token);
@ -11,8 +12,14 @@ export async function getUserForJWT(token: string) {
throw new AuthenticationError('Unable to decode JWT token'); 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); const user = await User.findByPk(payload.id);
try { try {
@ -23,3 +30,30 @@ export async function getUserForJWT(token: string) {
return user; 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;
}

View File

@ -57,6 +57,7 @@ export const base = {
export const light = { export const light = {
...base, ...base,
background: colors.white, background: colors.white,
secondaryBackground: colors.warmGrey,
link: colors.almostBlack, link: colors.almostBlack,
text: colors.almostBlack, text: colors.almostBlack,
@ -106,6 +107,7 @@ export const light = {
export const dark = { export const dark = {
...base, ...base,
background: colors.almostBlack, background: colors.almostBlack,
secondaryBackground: colors.black50,
link: colors.almostWhite, link: colors.almostWhite,
text: colors.almostWhite, text: colors.almostWhite,