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

View File

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

View File

@ -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>
);
}

View File

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

View File

@ -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 />&nbsp;&nbsp;
<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>
&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 && (
<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));

View File

@ -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. Its possible that
there are other users who have access through Single Sign-On but
havent signed into Outline yet.
there are other users who have access through {team.signinMethods} but
havent 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);

View File

@ -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"

View File

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

View File

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

View File

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

View File

@ -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');

View File

@ -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",

View File

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

View File

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

View File

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

View File

@ -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();

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 () => {
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
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
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;

View File

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

View File

@ -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 => {

View File

@ -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;
}
})
);

View File

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

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 { 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: 'Heres your link to signin to Outline.',
html: <SigninEmail {...opts} />,
text: signinEmailText(opts),
});
};
documentNotification = async (
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)) {
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 },
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,

View File

@ -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 });
}
});

View File

@ -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 teams knowledge base</h1>
<HeroText>

View File

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

View File

@ -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,

View File

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

View File

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

View File

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

View File

@ -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();
});

View File

@ -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,

View File

@ -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,
};

View File

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

View File

@ -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}

View File

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

View File

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

View File

@ -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,