feat: Guest email authentication (#1088)
* feat: API endpoints for email signin * fix: After testing * Initial signin flow working * move shared middleware * feat: Add guest signin toggle, obey on endpoints * feat: Basic email signin when enabled * Improve guest signin email Disable double signin with JWT * fix: Simple rate limiting * create placeholder users in db * fix: Give invited users default avatar add invited users to people settings * test * add transaction * tmp: test CI * derp * md5 * urgh * again * test: pass * test * fix: Remove usage of data values * guest signin page * Visually separator 'Invited' from other people tabs * fix: Edge case attempting SSO signin for guest email account * fix: Correctly set email auth method to cookie * Improve rate limit error display * lint: cleanup / comments * Improve invalid token error display * style tweaks * pass guest value to subdomain * Restore copy link option * feat: Allow invite revoke from people management * fix: Incorrect users email schema does not allow for user deletion * lint * fix: avatarUrl for deleted user failure * change default to off for guest invites * fix: Changing security settings wipes subdomain * fix: user delete permissioning * test: Add user.invite specs
This commit is contained in:
@ -2,10 +2,12 @@
|
|||||||
import * as React from 'react';
|
import * 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>
|
||||||
|
@ -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;
|
||||||
|
@ -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,7 +77,13 @@ 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.handleRevoke}>
|
||||||
|
Revoke invite…
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{user.lastActiveAt &&
|
||||||
|
(user.isSuspended ? (
|
||||||
<DropdownMenuItem onClick={this.handleActivate}>
|
<DropdownMenuItem onClick={this.handleActivate}>
|
||||||
Activate account
|
Activate account
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@ -79,7 +91,7 @@ class UserMenu extends React.Component<Props> {
|
|||||||
<DropdownMenuItem onClick={this.handleSuspend}>
|
<DropdownMenuItem onClick={this.handleSuspend}>
|
||||||
Suspend account…
|
Suspend account…
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
))}
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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}>
|
||||||
|
{team.guestSignin ? (
|
||||||
<HelpText>
|
<HelpText>
|
||||||
Send invites to your team members to get them kick started. Currently,
|
Invite team members or guests to join your knowledge base. Team
|
||||||
they must be able to sign in with your team{' '}
|
members can sign in with {team.signinMethods} and guests can use
|
||||||
{team.slackConnected ? 'Slack' : 'Google'} account to be able to join
|
their email address.
|
||||||
Outline.
|
|
||||||
</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 && (
|
{team.subdomain && (
|
||||||
<HelpText>
|
<CopyBlock>
|
||||||
You can also{' '}
|
Want a link to share directly with your team?
|
||||||
|
<Flex>
|
||||||
|
<Input type="text" value={team.url} flex />
|
||||||
<CopyToClipboard text={team.url} onCopy={this.handleCopy}>
|
<CopyToClipboard text={team.url} onCopy={this.handleCopy}>
|
||||||
<a>{this.linkCopied ? 'link copied' : 'copy a link'}</a>
|
<Button type="button" neutral>
|
||||||
</CopyToClipboard>{' '}
|
{this.linkCopied ? 'Link copied' : 'Copy link'}
|
||||||
to your teams signin page.
|
</Button>
|
||||||
</HelpText>
|
</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>
|
||||||
|
|
||||||
|
<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));
|
||||||
|
@ -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. It’s possible that
|
Everyone that has signed into Outline appears here. It’s 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
|
||||||
haven’t signed into Outline yet.
|
haven’t 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);
|
||||||
|
@ -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"
|
||||||
|
@ -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}
|
||||||
|
{user.lastActiveAt ? (
|
||||||
|
<React.Fragment>
|
||||||
Active <Time dateTime={user.lastActiveAt} /> ago
|
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>
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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');
|
||||||
|
@ -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",
|
||||||
|
@ -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);
|
||||||
|
@ -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';
|
||||||
|
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
|
@ -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
91
server/auth/email.js
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
// @flow
|
||||||
|
import Router from 'koa-router';
|
||||||
|
import mailer from '../mailer';
|
||||||
|
import subMinutes from 'date-fns/sub_minutes';
|
||||||
|
import { getUserForEmailSigninToken } from '../utils/jwt';
|
||||||
|
import { User, Team } from '../models';
|
||||||
|
import methodOverride from '../middlewares/methodOverride';
|
||||||
|
import validation from '../middlewares/validation';
|
||||||
|
import auth from '../middlewares/authentication';
|
||||||
|
import { AuthorizationError } from '../errors';
|
||||||
|
|
||||||
|
const router = new Router();
|
||||||
|
|
||||||
|
router.use(methodOverride());
|
||||||
|
router.use(validation());
|
||||||
|
|
||||||
|
router.post('email', async ctx => {
|
||||||
|
const { email } = ctx.body;
|
||||||
|
|
||||||
|
ctx.assertEmail(email, 'email is required');
|
||||||
|
|
||||||
|
const user = await User.findOne({
|
||||||
|
where: { email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
const team = await Team.findByPk(user.teamId);
|
||||||
|
|
||||||
|
// If the user matches an email address associated with an SSO
|
||||||
|
// signin then just forward them directly to that service's
|
||||||
|
// login page
|
||||||
|
if (user.service && user.service !== 'email') {
|
||||||
|
return ctx.redirect(`${team.url}/auth/${user.service}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!team.guestSignin) {
|
||||||
|
throw new AuthorizationError();
|
||||||
|
}
|
||||||
|
|
||||||
|
// basic rate limit of endpoint to prevent send email abuse
|
||||||
|
if (
|
||||||
|
user.lastSigninEmailSentAt &&
|
||||||
|
user.lastSigninEmailSentAt > subMinutes(new Date(), 2)
|
||||||
|
) {
|
||||||
|
ctx.redirect(`${team.url}?notice=email-auth-ratelimit`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// send email to users registered address with a short-lived token
|
||||||
|
mailer.signin({
|
||||||
|
to: user.email,
|
||||||
|
token: user.getEmailSigninToken(),
|
||||||
|
teamUrl: team.url,
|
||||||
|
});
|
||||||
|
|
||||||
|
user.lastSigninEmailSentAt = new Date();
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
// respond with success regardless of whether an email was sent
|
||||||
|
ctx.redirect(`${team.url}?notice=guest-success`);
|
||||||
|
} else {
|
||||||
|
ctx.redirect(`${process.env.URL}?notice=guest-success`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('email.callback', auth({ required: false }), async ctx => {
|
||||||
|
const { token } = ctx.request.query;
|
||||||
|
|
||||||
|
ctx.assertPresent(token, 'token is required');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await getUserForEmailSigninToken(token);
|
||||||
|
|
||||||
|
const team = await Team.findByPk(user.teamId);
|
||||||
|
if (!team.guestSignin) {
|
||||||
|
throw new AuthorizationError();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.service) {
|
||||||
|
user.service = 'email';
|
||||||
|
await user.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// set cookies on response and redirect to team subdomain
|
||||||
|
ctx.signIn(user, team, 'email', false);
|
||||||
|
} catch (err) {
|
||||||
|
ctx.redirect(`${process.env.URL}?notice=expired-token`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
@ -1,4 +1,5 @@
|
|||||||
// @flow
|
// @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,10 +80,18 @@ router.get('google.callback', auth({ required: false }), async ctx => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
const [user, isFirstSignin] = await User.findOrCreate({
|
const [user, isFirstSignin] = await User.findOrCreate({
|
||||||
where: {
|
where: {
|
||||||
|
[Op.or]: [
|
||||||
|
{
|
||||||
service: 'google',
|
service: 'google',
|
||||||
serviceId: profile.data.id,
|
serviceId: profile.data.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
service: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
},
|
},
|
||||||
defaults: {
|
defaults: {
|
||||||
@ -117,6 +128,27 @@ router.get('google.callback', auth({ required: false }), async ctx => {
|
|||||||
|
|
||||||
// set cookies on response and redirect to team subdomain
|
// set cookies on response and redirect to team subdomain
|
||||||
ctx.signIn(user, team, 'google', isFirstSignin);
|
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;
|
||||||
|
@ -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;
|
||||||
|
@ -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,10 +59,18 @@ router.get('slack.callback', auth({ required: false }), async ctx => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
const [user, isFirstSignin] = await User.findOrCreate({
|
const [user, isFirstSignin] = await User.findOrCreate({
|
||||||
where: {
|
where: {
|
||||||
|
[Op.or]: [
|
||||||
|
{
|
||||||
service: 'slack',
|
service: 'slack',
|
||||||
serviceId: data.user.id,
|
serviceId: data.user.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
service: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
},
|
},
|
||||||
defaults: {
|
defaults: {
|
||||||
@ -97,6 +107,27 @@ router.get('slack.callback', auth({ required: false }), async ctx => {
|
|||||||
|
|
||||||
// set cookies on response and redirect to team subdomain
|
// set cookies on response and redirect to team subdomain
|
||||||
ctx.signIn(user, team, 'slack', isFirstSignin);
|
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 => {
|
||||||
|
@ -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,7 +39,19 @@ 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();
|
||||||
|
try {
|
||||||
|
await User.create(
|
||||||
|
{
|
||||||
|
teamId: user.teamId,
|
||||||
|
name: invite.name,
|
||||||
|
email: invite.email,
|
||||||
|
service: null,
|
||||||
|
},
|
||||||
|
{ transaction }
|
||||||
|
);
|
||||||
|
await Event.create(
|
||||||
|
{
|
||||||
name: 'users.invite',
|
name: 'users.invite',
|
||||||
actorId: user.id,
|
actorId: user.id,
|
||||||
teamId: user.teamId,
|
teamId: user.teamId,
|
||||||
@ -47,15 +60,23 @@ export default async function userInviter({
|
|||||||
name: invite.name,
|
name: invite.name,
|
||||||
},
|
},
|
||||||
ip,
|
ip,
|
||||||
});
|
},
|
||||||
|
{ transaction }
|
||||||
|
);
|
||||||
await mailer.invite({
|
await mailer.invite({
|
||||||
to: invite.email,
|
to: invite.email,
|
||||||
name: invite.name,
|
name: invite.name,
|
||||||
|
guest: invite.guest,
|
||||||
actorName: user.name,
|
actorName: user.name,
|
||||||
actorEmail: user.email,
|
actorEmail: user.email,
|
||||||
teamName: team.name,
|
teamName: team.name,
|
||||||
teamUrl: team.url,
|
teamUrl: team.url,
|
||||||
});
|
});
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (err) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -10,6 +10,7 @@ import EmptySpace from './components/EmptySpace';
|
|||||||
|
|
||||||
export type Props = {
|
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>
|
||||||
|
|
||||||
|
51
server/emails/SigninEmail.js
Normal file
51
server/emails/SigninEmail.js
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
// @flow
|
||||||
|
import * as React from 'react';
|
||||||
|
import EmailTemplate from './components/EmailLayout';
|
||||||
|
import Body from './components/Body';
|
||||||
|
import Button from './components/Button';
|
||||||
|
import Heading from './components/Heading';
|
||||||
|
import Header from './components/Header';
|
||||||
|
import Footer from './components/Footer';
|
||||||
|
import EmptySpace from './components/EmptySpace';
|
||||||
|
|
||||||
|
export type Props = {
|
||||||
|
token: string,
|
||||||
|
teamUrl: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const signinEmailText = ({ token, teamUrl }: Props) => `
|
||||||
|
Use the link below to signin to Outline:
|
||||||
|
|
||||||
|
${process.env.URL}/auth/email.callback?token=${token}
|
||||||
|
|
||||||
|
If your magic link expired you can request a new one from your team’s
|
||||||
|
signin page at: ${teamUrl}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SigninEmail = ({ token, teamUrl }: Props) => {
|
||||||
|
return (
|
||||||
|
<EmailTemplate>
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<Body>
|
||||||
|
<Heading>Magic signin link</Heading>
|
||||||
|
<p>Click the button below to signin to Outline.</p>
|
||||||
|
<EmptySpace height={10} />
|
||||||
|
<p>
|
||||||
|
<Button
|
||||||
|
href={`${process.env.URL}/auth/email.callback?token=${token}`}
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</Button>
|
||||||
|
</p>
|
||||||
|
<EmptySpace height={10} />
|
||||||
|
<p>
|
||||||
|
If your magic link expired you can request a new one from your team’s
|
||||||
|
signin page at: <a href={teamUrl}>{teamUrl}</a>
|
||||||
|
</p>
|
||||||
|
</Body>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</EmailTemplate>
|
||||||
|
);
|
||||||
|
};
|
@ -8,6 +8,7 @@ import Queue from 'bull';
|
|||||||
import { baseStyles } from './emails/components/EmailLayout';
|
import { 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: 'Here’s your link to signin to Outline.',
|
||||||
|
html: <SigninEmail {...opts} />,
|
||||||
|
text: signinEmailText(opts),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
documentNotification = async (
|
documentNotification = async (
|
||||||
opts: { to: string } & DocumentNotificationEmailT
|
opts: { to: string } & DocumentNotificationEmailT
|
||||||
) => {
|
) => {
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
25
server/migrations/20191121035144-guest-invite.js
Normal file
25
server/migrations/20191121035144-guest-invite.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.addColumn('teams', 'guestSignin', {
|
||||||
|
type: Sequelize.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false,
|
||||||
|
});
|
||||||
|
await queryInterface.addColumn('users', 'lastSigninEmailSentAt', {
|
||||||
|
type: Sequelize.DATE
|
||||||
|
});
|
||||||
|
await queryInterface.changeColumn('users', 'email', {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
down: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.removeColumn('teams', 'guestSignin');
|
||||||
|
await queryInterface.removeColumn('users', 'lastSigninEmailSentAt');
|
||||||
|
await queryInterface.changeColumn('users', 'email', {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
@ -51,6 +51,11 @@ const Team = sequelize.define(
|
|||||||
googleId: { type: DataTypes.STRING, allowNull: true },
|
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,
|
||||||
|
@ -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 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -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 team’s knowledge base</h1>
|
<h1>Our team’s knowledge base</h1>
|
||||||
<HeroText>
|
<HeroText>
|
||||||
|
@ -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,8 +54,28 @@ 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>
|
||||||
|
<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>
|
<HeroText>
|
||||||
Sign in with your team account to continue to {team.name}.
|
Sign in with your team account to continue to {team.name}.
|
||||||
<Subdomain>{hostname}</Subdomain>
|
<Subdomain>{hostname}</Subdomain>
|
||||||
@ -52,6 +87,11 @@ function SubdomainSignin({
|
|||||||
lastSignedIn={signinHint}
|
lastSignedIn={signinHint}
|
||||||
/>
|
/>
|
||||||
</p>
|
</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;
|
||||||
|
@ -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,
|
@ -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;
|
||||||
`;
|
`;
|
||||||
|
@ -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;
|
||||||
|
@ -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>
|
||||||
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
|
@ -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;
|
||||||
|
@ -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}
|
||||||
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
@ -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,
|
||||||
|
Reference in New Issue
Block a user