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