From 6d8216c54ede7a56f39c15d919533409703ab5d8 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 15 Dec 2019 18:46:08 -0800 Subject: [PATCH] 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 --- app/components/Checkbox.js | 12 +- app/components/Tabs.js | 8 ++ app/menus/UserMenu.js | 28 +++-- app/models/Team.js | 11 ++ app/scenes/Invite.js | 112 ++++++++++++++---- app/scenes/Settings/People.js | 37 ++++-- app/scenes/Settings/Security.js | 17 +++ .../Settings/components/UserListItem.js | 8 +- app/stores/AuthStore.js | 10 +- app/stores/BaseStore.js | 7 +- app/stores/UsersStore.js | 15 ++- server/api/__snapshots__/users.test.js.snap | 44 +++---- server/api/events.test.js | 1 + server/api/index.js | 4 +- server/api/team.js | 12 +- server/api/users.js | 7 +- server/api/users.test.js | 45 ++++++- server/auth/email.js | 91 ++++++++++++++ server/auth/google.js | 102 ++++++++++------ server/auth/index.js | 2 + server/auth/slack.js | 105 ++++++++++------ server/commands/userInviter.js | 59 ++++++--- server/emails/InviteEmail.js | 9 +- server/emails/SigninEmail.js | 51 ++++++++ server/mailer.js | 11 ++ server/{api => }/middlewares/errorHandling.js | 0 .../{api => }/middlewares/methodOverride.js | 0 server/middlewares/validation.js | 2 +- .../migrations/20191121035144-guest-invite.js | 25 ++++ server/models/Team.js | 5 + server/models/User.js | 39 +++++- server/pages/Home.js | 4 +- server/pages/SubdomainSignin.js | 76 +++++++++--- .../{AuthErrors.js => AuthNotices.js} | 26 +++- server/pages/components/Button.js | 3 +- server/pages/components/HeroText.js | 2 +- server/pages/components/SigninButtons.js | 2 + server/policies/user.js | 10 +- .../__snapshots__/user.test.js.snap | 4 +- server/presenters/team.js | 1 + server/presenters/user.js | 3 +- server/routes.js | 1 + server/test/factories.js | 1 + server/utils/jwt.js | 38 +++++- shared/styles/theme.js | 2 + 45 files changed, 846 insertions(+), 206 deletions(-) create mode 100644 server/auth/email.js create mode 100644 server/emails/SigninEmail.js rename server/{api => }/middlewares/errorHandling.js (100%) rename server/{api => }/middlewares/methodOverride.js (100%) create mode 100644 server/migrations/20191121035144-guest-invite.js rename server/pages/components/{AuthErrors.js => AuthNotices.js} (54%) diff --git a/app/components/Checkbox.js b/app/components/Checkbox.js index 8f7243f9a..f6420cff2 100644 --- a/app/components/Checkbox.js +++ b/app/components/Checkbox.js @@ -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 = {label}; + return ( {note && {note}} diff --git a/app/components/Tabs.js b/app/components/Tabs.js index 578f6615f..a92da1dd4 100644 --- a/app/components/Tabs.js +++ b/app/components/Tabs.js @@ -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; diff --git a/app/menus/UserMenu.js b/app/menus/UserMenu.js index 0336cd8ab..a6a70d1cd 100644 --- a/app/menus/UserMenu.js +++ b/app/menus/UserMenu.js @@ -42,7 +42,7 @@ class UserMenu extends React.Component { 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 { 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,15 +77,21 @@ class UserMenu extends React.Component { Make {user.name} an admin… ))} - {user.isSuspended ? ( - - Activate account - - ) : ( - - Suspend account… + {!user.lastActiveAt && ( + + Revoke invite… )} + {user.lastActiveAt && + (user.isSuspended ? ( + + Activate account + + ) : ( + + Suspend account… + + ))} ); } diff --git a/app/models/Team.js b/app/models/Team.js index 5706396ba..d149ab4b8 100644 --- a/app/models/Team.js +++ b/app/models/Team.js @@ -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; diff --git a/app/scenes/Invite.js b/app/scenes/Invite.js index 68b048f6a..6854fef33 100644 --- a/app/scenes/Invite.js +++ b/app/scenes/Invite.js @@ -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 { @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 { 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 { ); } - 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 { if (!team || !user) return null; const predictedDomain = user.email.split('@')[1]; + const can = this.props.policies.abilities(team.id); return (
- - 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. - - {team.subdomain && ( + {team.guestSignin ? ( - You can also{' '} - - {this.linkCopied ? 'link copied' : 'copy a link'} - {' '} - to your teams signin page. + 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. + ) : ( + + Invite team members to join your knowledge base. They will need to + sign in with {team.signinMethods}.{' '} + {can.update && ( + + As an admin you can also{' '} + enable guest invites. + + )} + + )} + {team.subdomain && ( + + Want a link to share directly with your team? + +    + + + + + )} {this.invites.map((invite, index) => ( @@ -109,6 +134,7 @@ class Invite extends React.Component { 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 { required={!!invite.email} flex /> + {team.guestSignin && ( + +    + + Guests can sign in with email and
do not require{' '} + {team.signinMethods} accounts + + } + placement="top" + > + + this.handleGuestChange(ev, index)} + checked={invite.guest} + /> + +
+
+ )} {index !== 0 && ( - this.handleRemove(index)}> + this.handleRemove(ev, index)}> @@ -160,10 +209,29 @@ class Invite extends React.Component { } } +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)); diff --git a/app/scenes/Settings/People.js b/app/scenes/Settings/People.js index 0e95fc9f1..0137730db 100644 --- a/app/scenes/Settings/People.js +++ b/app/scenes/Settings/People.js @@ -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 { }; 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 ( @@ -66,8 +74,8 @@ class People extends React.Component {

People

Everyone that has signed into Outline appears here. It’s possible that - there are other users who have access through Single Sign-On but - haven’t signed into Outline yet. + there are other users who have access through {team.signinMethods} but + haven’t signed in yet. +

diff --git a/server/emails/SigninEmail.js b/server/emails/SigninEmail.js new file mode 100644 index 000000000..2bf6e480f --- /dev/null +++ b/server/emails/SigninEmail.js @@ -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 ( + +
+ + + Magic signin link +

Click the button below to signin to Outline.

+ +

+ +

+ +

+ If your magic link expired you can request a new one from your team’s + signin page at: {teamUrl} +

+ + +