diff --git a/.env.sample b/.env.sample index 50c09999..8e56d94a 100644 --- a/.env.sample +++ b/.env.sample @@ -12,6 +12,7 @@ REDIS_URL=redis://redis:6379 URL=http://localhost:3000 DEPLOYMENT=self ENABLE_UPDATES=true +SUBDOMAINS_ENABLED=false DEBUG=sql,cache,presenters,events # Third party signin credentials (at least one is required) diff --git a/app/menus/RevisionMenu.js b/app/menus/RevisionMenu.js index a1a3a104..5e3cabac 100644 --- a/app/menus/RevisionMenu.js +++ b/app/menus/RevisionMenu.js @@ -36,7 +36,7 @@ class RevisionMenu extends React.Component { render() { const { label, className, onOpen, onClose } = this.props; - const url = `${process.env.URL}${documentHistoryUrl( + const url = `${window.location.origin}${documentHistoryUrl( this.props.document, this.props.revision.id )}`; diff --git a/app/scenes/Settings/Details.js b/app/scenes/Settings/Details.js index 55586a71..c8c6fb85 100644 --- a/app/scenes/Settings/Details.js +++ b/app/scenes/Settings/Details.js @@ -25,12 +25,14 @@ class Details extends React.Component { form: ?HTMLFormElement; @observable name: string; - @observable subdomain: string; + @observable subdomain: ?string; @observable avatarUrl: ?string; componentDidMount() { - if (this.props.auth.team) { - this.name = this.props.auth.team.name; + const { team } = this.props.auth; + if (team) { + this.name = team.name; + this.subdomain = team.subdomain; } } @@ -41,11 +43,16 @@ class Details extends React.Component { handleSubmit = async (ev: SyntheticEvent<*>) => { ev.preventDefault(); - await this.props.auth.updateTeam({ - name: this.name, - avatarUrl: this.avatarUrl, - }); - this.props.ui.showToast('Settings saved', 'success'); + try { + await this.props.auth.updateTeam({ + name: this.name, + avatarUrl: this.avatarUrl, + subdomain: this.subdomain, + }); + this.props.ui.showToast('Settings saved', 'success'); + } catch (err) { + this.props.ui.showToast('Could not save'); + } }; handleNameChange = (ev: SyntheticInputEvent<*>) => { @@ -115,22 +122,24 @@ class Details extends React.Component { required short /> - - - {this.subdomain && ( - - You will be able to access your wiki at{' '} - {this.subdomain}.getoutline.com - + {process.env.SUBDOMAINS_ENABLED && ( + + + {this.subdomain && ( + + You will be able to access your wiki at{' '} + {this.subdomain}.getoutline.com + + )} + )} - diff --git a/app/types/index.js b/app/types/index.js index 165827cd..9daae0b8 100644 --- a/app/types/index.js +++ b/app/types/index.js @@ -46,6 +46,8 @@ export type Team = { slackConnected: boolean, googleConnected: boolean, sharing: boolean, + subdomain?: string, + url: string, }; export type NavigationNode = { diff --git a/docker-compose.yml b/docker-compose.yml index 24259efa..cc46862f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,7 +29,7 @@ services: ports: - "3000:3000" volumes: - - .:/opt/outline + - .:/opt/outline:cached depends_on: - postgres - redis diff --git a/package.json b/package.json index a6d52ddc..13112edb 100644 --- a/package.json +++ b/package.json @@ -139,6 +139,7 @@ "normalizr": "2.0.1", "outline-icons": "^1.3.2", "oy-vey": "^0.10.0", + "parse-domain": "2.1.6", "pg": "^6.1.5", "pg-hstore": "2.3.2", "polished": "1.2.1", diff --git a/server/api/team.js b/server/api/team.js index 3c83ee67..8b10d0ab 100644 --- a/server/api/team.js +++ b/server/api/team.js @@ -20,7 +20,7 @@ router.post('team.update', auth(), async ctx => { authorize(user, 'update', team); if (name) team.name = name; - if (subdomain) team.subdomain = subdomain; + if (subdomain !== undefined) team.subdomain = subdomain; if (sharing !== undefined) team.sharing = sharing; if (avatarUrl && avatarUrl.startsWith(`${endpoint}/uploads/${user.id}`)) { team.avatarUrl = avatarUrl; diff --git a/server/auth/google.js b/server/auth/google.js index c8f28f51..687cbd36 100644 --- a/server/auth/google.js +++ b/server/auth/google.js @@ -2,6 +2,7 @@ import crypto from 'crypto'; import Router from 'koa-router'; import addMonths from 'date-fns/add_months'; +import { stripSubdomain } from '../utils/domains'; import { capitalize } from 'lodash'; import { OAuth2Client } from 'google-auth-library'; import { User, Team } from '../models'; @@ -100,13 +101,15 @@ router.get('google.callback', async ctx => { ctx.cookies.set('lastSignedIn', 'google', { httpOnly: false, expires: new Date('2100'), + domain: stripSubdomain(ctx.request.hostname), }); ctx.cookies.set('accessToken', user.getJwtToken(), { httpOnly: false, expires: addMonths(new Date(), 1), + domain: stripSubdomain(ctx.request.hostname), }); - ctx.redirect('/'); + ctx.redirect(team.url); }); export default router; diff --git a/server/auth/slack.js b/server/auth/slack.js index ecfa562e..93ebdcb8 100644 --- a/server/auth/slack.js +++ b/server/auth/slack.js @@ -5,6 +5,7 @@ import addHours from 'date-fns/add_hours'; import addMonths from 'date-fns/add_months'; import { slackAuth } from '../../shared/utils/routeHelpers'; import { Authentication, Integration, User, Team } from '../models'; +import { stripSubdomain } from '../utils/domains'; import * as Slack from '../slack'; const router = new Router(); @@ -18,6 +19,7 @@ router.get('slack', async ctx => { ctx.cookies.set('state', state, { httpOnly: false, expires: addHours(new Date(), 1), + domain: stripSubdomain(ctx.request.hostname), }); ctx.redirect(slackAuth(state)); }); @@ -29,7 +31,7 @@ router.get('slack.callback', async ctx => { ctx.assertPresent(state, 'state is required'); if (state !== ctx.cookies.get('state') || error) { - ctx.redirect('/?notice=auth-error'); + ctx.redirect(`/?notice=auth-error`); return; } @@ -69,13 +71,15 @@ router.get('slack.callback', async ctx => { ctx.cookies.set('lastSignedIn', 'slack', { httpOnly: false, expires: new Date('2100'), + domain: stripSubdomain(ctx.request.hostname), }); ctx.cookies.set('accessToken', user.getJwtToken(), { httpOnly: false, expires: addMonths(new Date(), 1), + domain: stripSubdomain(ctx.request.hostname), }); - ctx.redirect('/'); + ctx.redirect(team.url); }); router.get('slack.commands', auth(), async ctx => { diff --git a/server/middlewares/subdomainRedirect.js b/server/middlewares/apexRedirect.js similarity index 54% rename from server/middlewares/subdomainRedirect.js rename to server/middlewares/apexRedirect.js index 2ae238ff..61651844 100644 --- a/server/middlewares/subdomainRedirect.js +++ b/server/middlewares/apexRedirect.js @@ -1,10 +1,10 @@ // @flow -import { type Context } from 'koa'; +import type { Context } from 'koa'; -export default function subdomainRedirect() { - return async function subdomainRedirectMiddleware( +export default function apexRedirect() { + return async function apexRedirectMiddleware( ctx: Context, - next: () => Promise + next: () => Promise<*> ) { if (ctx.headers.host === 'getoutline.com') { ctx.redirect(`https://www.${ctx.headers.host}${ctx.path}`); diff --git a/server/models/Team.js b/server/models/Team.js index 79b90d5e..27cc4118 100644 --- a/server/models/Team.js +++ b/server/models/Team.js @@ -1,35 +1,52 @@ // @flow import uuid from 'uuid'; +import url from 'url'; import { DataTypes, sequelize, Op } from '../sequelize'; import { publicS3Endpoint, uploadToS3FromUrl } from '../utils/s3'; -import { RESERVED_SUBDOMAINS } from '../domains'; +import { RESERVED_SUBDOMAINS } from '../utils/domains'; import Collection from './Collection'; import User from './User'; -const Team = sequelize.define('team', { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - name: DataTypes.STRING, - subdomain: { - type: DataTypes.STRING, - allowNull: true, - validate: { - isLowercase: true, - isAlphanumeric: true, - len: [4, 32], - notIn: [RESERVED_SUBDOMAINS], +const Team = sequelize.define( + 'team', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, }, - unique: true, + name: DataTypes.STRING, + subdomain: { + type: DataTypes.STRING, + allowNull: true, + validate: { + isLowercase: true, + is: [/^[a-z\d-]+$/, 'i'], + len: [4, 32], + notIn: [RESERVED_SUBDOMAINS], + }, + unique: true, + }, + slackId: { type: DataTypes.STRING, allowNull: true }, + googleId: { type: DataTypes.STRING, allowNull: true }, + avatarUrl: { type: DataTypes.STRING, allowNull: true }, + sharing: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true }, + slackData: DataTypes.JSONB, }, - slackId: { type: DataTypes.STRING, allowNull: true }, - googleId: { type: DataTypes.STRING, allowNull: true }, - avatarUrl: { type: DataTypes.STRING, allowNull: true }, - sharing: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true }, - slackData: DataTypes.JSONB, -}); + { + getterMethods: { + url() { + if (!this.subdomain) return process.env.URL; + + const u = url.parse(process.env.URL); + if (u.hostname) { + u.hostname = `${this.subdomain}.${u.hostname}`; + return u.href; + } + }, + }, + } +); Team.associate = models => { Team.hasMany(models.Collection, { as: 'collections' }); diff --git a/server/pages/Home.js b/server/pages/Home.js index 685198b9..2a83c765 100644 --- a/server/pages/Home.js +++ b/server/pages/Home.js @@ -4,8 +4,9 @@ import { Helmet } from 'react-helmet'; import styled from 'styled-components'; import Grid from 'styled-components-grid'; import breakpoint from 'styled-components-breakpoint'; -import Notice from '../../shared/components/Notice'; +import AuthErrors from './components/AuthErrors'; import Hero from './components/Hero'; +import HeroText from './components/HeroText'; import Centered from './components/Centered'; import SigninButtons from './components/SigninButtons'; import SlackLogo from '../../shared/components/SlackLogo'; @@ -36,24 +37,7 @@ function Home(props: Props) {

- {props.notice === 'google-hd' && ( - - Sorry, Google sign in cannot be used with a personal email. Please - try signing in with your company Google account. - - )} - {props.notice === 'hd-not-allowed' && ( - - Sorry, your Google apps domain is not allowed. Please try again - with an allowed company domain. - - )} - {props.notice === 'auth-error' && ( - - Authentication failed - we were unable to sign you in at this - time. Please try again. - - )} + @@ -232,13 +216,4 @@ const Footer = styled.div` `}; `; -const HeroText = styled.p` - font-size: 22px; - color: #666; - font-weight: 500; - text-align: left; - max-width: 600px; - margin-bottom: 2em; -`; - export default Home; diff --git a/server/pages/SubdomainSignin.js b/server/pages/SubdomainSignin.js new file mode 100644 index 00000000..a63ecddf --- /dev/null +++ b/server/pages/SubdomainSignin.js @@ -0,0 +1,81 @@ +// @flow +import * as React from 'react'; +import { Helmet } from 'react-helmet'; +import styled from 'styled-components'; +import Grid from 'styled-components-grid'; +import Hero from './components/Hero'; +import HeroText from './components/HeroText'; +import SigninButtons from './components/SigninButtons'; +import AuthErrors from './components/AuthErrors'; +import Centered from './components/Centered'; +import { Team } from '../models'; + +type Props = { + team: Team, + notice?: 'google-hd' | 'auth-error' | 'hd-not-allowed', + lastSignedIn: string, + googleSigninEnabled: boolean, + slackSigninEnabled: boolean, + hostname: string, +}; + +function SubdomainSignin({ + team, + lastSignedIn, + notice, + googleSigninEnabled, + slackSigninEnabled, + hostname, +}: Props) { + googleSigninEnabled = !!team.googleId && googleSigninEnabled; + slackSigninEnabled = !!team.slackId && slackSigninEnabled; + + // only show the "last signed in" hint if there is more than one option available + const signinHint = + googleSigninEnabled && slackSigninEnabled ? lastSignedIn : undefined; + + return ( + + + Outline - Sign in to {team.name} + + + +

{lastSignedIn ? 'Welcome back,' : 'Hey there,'}

+ + Sign in with your team account to continue to {team.name}. + {hostname} + +

+ +

+ +
+
+ +

+ Trying to create or sign in to a different team?{' '} + Head to the homepage. +

+
+
+ ); +} + +const Subdomain = styled.span` + display: block; + font-weight: 500; + font-size: 16px; + margin-top: 0; +`; + +const Alternative = styled(Centered)` + padding: 2em 0; + text-align: center; +`; + +export default SubdomainSignin; diff --git a/server/pages/components/AuthErrors.js b/server/pages/components/AuthErrors.js new file mode 100644 index 00000000..6a6cf80c --- /dev/null +++ b/server/pages/components/AuthErrors.js @@ -0,0 +1,32 @@ +// @flow +import * as React from 'react'; +import Notice from '../../../shared/components/Notice'; + +type Props = { + notice?: string, +}; + +export default function AuthErrors({ notice }: Props) { + return ( + + {notice === 'google-hd' && ( + + Sorry, Google sign in cannot be used with a personal email. Please try + signing in with your company Google account. + + )} + {notice === 'hd-not-allowed' && ( + + Sorry, your Google apps domain is not allowed. Please try again with + an allowed company domain. + + )} + {notice === 'auth-error' && ( + + Authentication failed - we were unable to sign you in at this time. + Please try again. + + )} + + ); +} diff --git a/server/pages/components/Hero.js b/server/pages/components/Hero.js index c1cc4116..afb7e625 100644 --- a/server/pages/components/Hero.js +++ b/server/pages/components/Hero.js @@ -11,6 +11,11 @@ const Hero = styled(Centered)` font-size: 3.5em; line-height: 1em; } + + h2 { + font-size: 2.5em; + line-height: 1em; + } `; export default Hero; diff --git a/server/pages/components/HeroText.js b/server/pages/components/HeroText.js new file mode 100644 index 00000000..9d65e194 --- /dev/null +++ b/server/pages/components/HeroText.js @@ -0,0 +1,13 @@ +// @flow +import styled from 'styled-components'; + +const HeroText = styled.p` + font-size: 22px; + color: #666; + font-weight: 500; + text-align: left; + max-width: 600px; + margin-bottom: 2em; +`; + +export default HeroText; diff --git a/server/pages/components/Navigation.js b/server/pages/components/Navigation.js index 7458563b..e64f3bc4 100644 --- a/server/pages/components/Navigation.js +++ b/server/pages/components/Navigation.js @@ -16,7 +16,7 @@ import { function TopNavigation() { return (