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

+ + +