New login screen (#1331)

* wip

* feat: first draft of auth.config

* chore: auth methodS

* chore: styling

* styling, styling, styling

* feat: Auth notices

* chore: Remove server-rendered pages, move shared/components -> components

* lint

* cleanup

* cleanup

* fix: Remove unused component

* fix: Ensure env variables in prod too

* style tweaks

* fix: Entering SSO email into login form fails
fix: Tweak language around guest signin
This commit is contained in:
Tom Moor
2020-07-09 22:33:07 -07:00
committed by GitHub
parent 75561079eb
commit 5cb04d7ac1
128 changed files with 769 additions and 2264 deletions

View File

@ -1,11 +1,105 @@
// @flow
import Router from "koa-router";
import { reject } from "lodash";
import auth from "../middlewares/authentication";
import { presentUser, presentTeam, presentPolicies } from "../presenters";
import { Team } from "../models";
import { signin } from "../../shared/utils/routeHelpers";
import { parseDomain, isCustomSubdomain } from "../../shared/utils/domains";
const router = new Router();
let services = [];
if (process.env.GOOGLE_CLIENT_ID) {
services.push({
id: "google",
name: "Google",
authUrl: signin("google"),
});
}
if (process.env.SLACK_KEY) {
services.push({
id: "slack",
name: "Slack",
authUrl: signin("slack"),
});
}
services.push({
id: "email",
name: "Email",
authUrl: "",
});
function filterServices(team) {
let output = services;
if (team && !team.googleId) {
output = reject(output, service => service.id === "google");
}
if (team && !team.slackId) {
output = reject(output, service => service.id === "slack");
}
if (!team || !team.guestSignin) {
output = reject(output, service => service.id === "email");
}
return output;
}
router.post("auth.config", async ctx => {
// If self hosted AND there is only one team then that team becomes the
// brand for the knowledgebase and it's guest signin option is used for the
// root login page.
if (process.env.DEPLOYMENT !== "hosted") {
const teams = await Team.findAll();
if (teams.length === 1) {
const team = teams[0];
ctx.body = {
data: {
name: team.name,
services: filterServices(team),
},
};
return;
}
}
// If subdomain signin page then we return minimal team details to allow
// for a custom screen showing only relevant signin options for that team.
if (
process.env.SUBDOMAINS_ENABLED === "true" &&
isCustomSubdomain(ctx.request.hostname)
) {
const domain = parseDomain(ctx.request.hostname);
const subdomain = domain ? domain.subdomain : undefined;
const team = await Team.findOne({
where: { subdomain },
});
if (team) {
ctx.body = {
data: {
name: team.name,
hostname: ctx.request.hostname,
services: filterServices(team),
},
};
return;
}
}
// Otherwise, we're requesting from the standard root signin page
ctx.body = {
data: {
services: filterServices(),
},
};
});
router.post("auth.info", auth(), async ctx => {
const user = ctx.state.user;
const team = await Team.findByPk(user.teamId);

View File

@ -30,7 +30,10 @@ router.post("email", async ctx => {
// 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}`);
ctx.body = {
redirect: `${team.url}/auth/${user.service}`,
};
return;
}
if (!team.guestSignin) {
@ -55,12 +58,12 @@ router.post("email", async ctx => {
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`);
}
// respond with success regardless of whether an email was sent
ctx.body = {
success: true,
};
});
router.get("email.callback", auth({ required: false }), async ctx => {

View File

@ -4,7 +4,7 @@ import { User, Event, Team } from "../models";
import mailer from "../mailer";
import { sequelize } from "../sequelize";
type Invite = { name: string, email: string, guest: boolean };
type Invite = { name: string, email: string };
export default async function userInviter({
user,
@ -77,7 +77,6 @@ export default async function userInviter({
await mailer.invite({
to: invite.email,
name: invite.name,
guest: invite.guest,
actorName: user.name,
actorEmail: user.email,
teamName: team.name,

View File

@ -10,7 +10,6 @@ import EmptySpace from "./components/EmptySpace";
export type Props = {
name: string,
guest: boolean,
actorName: string,
actorEmail: string,
teamName: string,
@ -22,7 +21,6 @@ export const inviteEmailText = ({
actorName,
actorEmail,
teamUrl,
guest,
}: Props) => `
Join ${teamName} on Outline
@ -30,7 +28,7 @@ ${actorName} (${
actorEmail
}) has invited you to join Outline, a place for your team to build and share knowledge.
Join now: ${teamUrl}${guest ? "?guest=true" : ""}
Join now: ${teamUrl}
`;
export const InviteEmail = ({
@ -38,7 +36,6 @@ export const InviteEmail = ({
actorName,
actorEmail,
teamUrl,
guest,
}: Props) => {
return (
<EmailTemplate>
@ -52,9 +49,7 @@ export const InviteEmail = ({
</p>
<EmptySpace height={10} />
<p>
<Button href={`${teamUrl}${guest ? "?guest=true" : ""}`}>
Join now
</Button>
<Button href={teamUrl}>Join now</Button>
</p>
</Body>

View File

@ -1,50 +0,0 @@
// @flow
import * as React from "react";
import { Helmet } from "react-helmet";
import styled from "styled-components";
import Grid from "styled-components-grid";
import AuthNotices from "./components/AuthNotices";
import Hero from "./components/Hero";
import HeroText from "./components/HeroText";
import SigninButtons from "./components/SigninButtons";
import Branding from "../../shared/components/Branding";
import { githubUrl } from "../../shared/utils/routeHelpers";
type Props = {
notice?: "google-hd" | "auth-error" | "hd-not-allowed",
lastSignedIn: string,
googleSigninEnabled: boolean,
slackSigninEnabled: boolean,
};
function Home(props: Props) {
return (
<span>
<Helmet>
<title>Outline - Team wiki & knowledge base</title>
</Helmet>
<Grid>
<Hero id="signin">
<AuthNotices notice={props.notice} />
{process.env.TEAM_LOGO && <Logo src={process.env.TEAM_LOGO} />}
<h1>Our teams knowledge base</h1>
<HeroText>
Team wiki, documentation, meeting notes, playbooks, onboarding, work
logs, brainstorming, & more
</HeroText>
<p>
<SigninButtons {...props} />
</p>
</Hero>
</Grid>
<Branding href={githubUrl()} />
</span>
);
}
const Logo = styled.img`
height: 60px;
border-radius: 4px;
`;
export default Home;

View File

@ -1,131 +0,0 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import Grid from "styled-components-grid";
import Hero from "./components/Hero";
import HeroText from "./components/HeroText";
import Button from "./components/Button";
import SigninButtons from "./components/SigninButtons";
import AuthNotices from "./components/AuthNotices";
import Centered from "./components/Centered";
import PageTitle from "./components/PageTitle";
import { Team } from "../models";
type Props = {
team: Team,
guest?: boolean,
notice?: "google-hd" | "auth-error" | "hd-not-allowed" | "guest-success",
lastSignedIn: string,
googleSigninEnabled: boolean,
slackSigninEnabled: boolean,
hostname: string,
};
function SubdomainSignin({
team,
guest,
lastSignedIn,
notice,
googleSigninEnabled,
slackSigninEnabled,
hostname,
}: Props) {
googleSigninEnabled = !!team.googleId && googleSigninEnabled;
slackSigninEnabled = !!team.slackId && slackSigninEnabled;
const guestSigninEnabled = team.guestSignin;
const guestSigninForm = (
<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
const signinHint =
googleSigninEnabled && slackSigninEnabled ? lastSignedIn : undefined;
return (
<React.Fragment>
<PageTitle title={`Sign in to ${team.name}`} />
<Grid>
<Hero>
<h1>{lastSignedIn ? "Welcome back," : "Hey there,"}</h1>
<AuthNotices notice={notice} />
{guest && guestSigninEnabled ? (
<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>
Sign in with your team account to continue to {team.name}.
<Subdomain>{hostname}</Subdomain>
</HeroText>
<p>
<SigninButtons
googleSigninEnabled={googleSigninEnabled}
slackSigninEnabled={slackSigninEnabled}
lastSignedIn={signinHint}
/>
</p>
{guestSigninEnabled && (
<React.Fragment>
<HeroText>Have a guest account? Sign in with email</HeroText>
{guestSigninForm}
</React.Fragment>
)}
</React.Fragment>
)}
</Hero>
</Grid>
<Alternative>
<p>
Trying to create or sign in to a different team?{" "}
<a href={process.env.URL}>Head to the homepage</a>.
</p>
</Alternative>
</React.Fragment>
);
}
const EmailInput = styled.input`
padding: 12px;
border-radius: 4px;
border: 1px solid #999;
min-width: 217px;
height: 56px;
`;
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;

View File

@ -1,23 +0,0 @@
// @flow
import * as React from "react";
function Analytics() {
if (!process.env.GOOGLE_ANALYTICS_ID) return null;
return (
<React.Fragment>
<script
dangerouslySetInnerHTML={{
__html: `
window.ga=window.ga||function(){(ga.q=ga.q||[]).push(arguments)};ga.l=+new Date;
ga('create', '${process.env.GOOGLE_ANALYTICS_ID}', 'auto');
ga('send', 'pageview');
`,
}}
/>
<script async src="https://www.google-analytics.com/analytics.js" />
</React.Fragment>
);
}
export default Analytics;

View File

@ -1,62 +0,0 @@
// @flow
import * as React from "react";
import Notice from "../../../shared/components/Notice";
type Props = {
notice?: string,
};
export default function AuthNotices({ notice }: Props) {
return (
<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>
Sorry, Google sign in cannot be used with a personal email. Please try
signing in with your company Google account.
</Notice>
)}
{notice === "hd-not-allowed" && (
<Notice>
Sorry, your Google apps domain is not allowed. Please try again with
an allowed company domain.
</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>
Authentication failed - we were unable to sign you in at this time.
Please try again.
</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>
Your Outline account has been suspended. To re-activate your account,
please contact a team admin.
</Notice>
)}
</React.Fragment>
);
}

View File

@ -1,16 +0,0 @@
// @flow
import styled from "styled-components";
const Button = styled.a`
border: 0;
display: inline-flex;
align-items: center;
padding: 10px 20px;
color: ${props => props.theme.white};
background: ${props => props.theme.black};
border-radius: 6px;
font-weight: 600;
height: 56px;
`;
export default Button;

View File

@ -1,9 +0,0 @@
// @flow
import styled from "styled-components";
const Centered = styled.div`
margin: 0 auto;
max-width: 1000px;
`;
export default Centered;

View File

@ -1,13 +0,0 @@
// @flow
import styled from "styled-components";
export default styled.div`
width: 100%;
max-width: 1000px;
margin: 0 auto 2em;
font-size: 1.1em;
li {
padding: 0.2em 0;
}
`;

View File

@ -1,63 +0,0 @@
// @flow
import * as React from "react";
import breakpoint from "styled-components-breakpoint";
import styled from "styled-components";
import Centered from "./Centered";
type Props = {
children: React.Node,
background?: string,
};
const Header = ({ children, background }: Props) => {
return (
<Wrapper background={background}>
<Centered>{children}</Centered>
</Wrapper>
);
};
const Wrapper = styled.div`
width: 100%;
padding: 8em 0 3em;
position: relative;
margin-top: -70px;
margin-bottom: 2em;
text-align: center;
background: ${props => props.background || "transparent"};
z-index: -1;
&:before {
content: "";
position: absolute;
top: 0;
left: -30px;
width: 100vw;
height: 100%;
background: ${props => props.background || "transparent"};
z-index: -10;
}
p {
font-size: 22px;
font-weight: 500;
color: rgba(0, 0, 0, 0.6);
margin: 0;
}
h1 {
font-size: 2em;
margin: 0 0 0.1em;
}
${breakpoint("tablet")`
padding: 8em 3em 3em 3em;
h1 {
font-size: 4em;
}
`};
`;
export default Header;

View File

@ -1,22 +0,0 @@
// @flow
import styled from "styled-components";
import Centered from "./Centered";
const Hero = styled(Centered)`
width: 100%;
margin-top: 50vh;
transform: translateY(-50%);
h1 {
font-size: 3.5em;
line-height: 1em;
margin-top: 0;
}
h2 {
font-size: 2.5em;
line-height: 1em;
}
`;
export default Hero;

View File

@ -1,13 +0,0 @@
// @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: 1em;
`;
export default HeroText;

View File

@ -1,81 +0,0 @@
// @flow
import * as React from "react";
import { Helmet } from "react-helmet";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Analytics from "./Analytics";
import GlobalStyles from "../../../shared/styles/globals";
import prefetchTags from "../../utils/prefetchTags";
export const title = "Outline";
export const description =
"Your teams knowledge base - Team wiki, documentation, playbooks, onboarding & more…";
export const screenshotUrl = `${process.env.URL}/screenshot.png`;
type Props = {
children?: React.Node,
sessions: Object,
loggedIn: boolean,
};
function Layout({ children, loggedIn, sessions }: Props) {
return (
<html lang="en">
<head>
<GlobalStyles />
<Helmet>
<title>{title}</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="origin" />
<meta name="slack-app-id" content="A0W3UMKBQ" />
<meta name="description" content={description} />
<meta name="theme-color" content="#FFFFFF" />
<meta name="og:site_name" content={title} />
<meta name="og:type" content="website" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={screenshotUrl} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:domain" value="getoutline.com" />
<meta name="twitter:title" value={title} />
<meta name="twitter:description" value={description} />
<meta name="twitter:image" content={screenshotUrl} />
<meta name="twitter:url" value={process.env.URL} />
<link rel="manifest" href="/manifest.json" />
<link
rel="shortcut icon"
type="image/png"
href="/favicon-16.png"
sizes="16x16"
/>
<link
rel="shortcut icon"
type="image/png"
href="/favicon-32.png"
sizes="32x32"
/>
{prefetchTags}
</Helmet>
<Analytics />
{"{{HEAD}}"}
{"{{CSS}}"}
</head>
<Body>{children}</Body>
</html>
);
}
const Body = styled.body`
padding: 0 30px;
${breakpoint("desktop")`
padding: 0;
`};
`;
export default Layout;

View File

@ -1,13 +0,0 @@
// @flow
import * as React from "react";
import { Helmet } from "react-helmet";
function PageTitle({ title }: { title: string }) {
return (
<Helmet>
<title>{title} Outline</title>
</Helmet>
);
}
export default PageTitle;

View File

@ -1,94 +0,0 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import Button from "./Button";
import { signin } from "../../../shared/utils/routeHelpers";
import Flex from "../../../shared/components/Flex";
import Notice from "../../../shared/components/Notice";
import GoogleLogo from "../../../shared/components/GoogleLogo";
import SlackLogo from "../../../shared/components/SlackLogo";
import breakpoint from "styled-components-breakpoint";
type Props = {
lastSignedIn?: string,
googleSigninEnabled: boolean,
slackSigninEnabled: boolean,
guestSigninEnabled?: boolean,
};
const SigninButtons = ({
lastSignedIn,
slackSigninEnabled,
googleSigninEnabled,
guestSigninEnabled,
}: Props) => {
return (
<Wrapper>
{!slackSigninEnabled &&
!googleSigninEnabled && (
<Notice>
Neither Slack or Google sign in is enabled. You must configure at
least one authentication method to sign in to Outline.
</Notice>
)}
{slackSigninEnabled && (
<Column column>
<Button href={signin("slack")}>
<SlackLogo />
<Spacer>Sign In with Slack</Spacer>
</Button>
<LastLogin>
{lastSignedIn === "slack" && "You signed in with Slack previously"}
</LastLogin>
</Column>
)}
{googleSigninEnabled && (
<Column column>
<Button href={signin("google")}>
<GoogleLogo />
<Spacer>Sign In with Google</Spacer>
</Button>
<LastLogin>
{lastSignedIn === "google" &&
"You signed in with Google previously"}
</LastLogin>
</Column>
)}
</Wrapper>
);
};
const Column = styled(Flex)`
text-align: center;
${breakpoint("tablet")`
&:first-child {
margin-right: 8px;
}
`};
`;
const Wrapper = styled(Flex)`
display: block;
justify-content: center;
margin-top: 16px;
${breakpoint("tablet")`
display: flex;
justify-content: flex-start;
margin-top: 0;
`};
`;
const Spacer = styled.span`
padding-left: 10px;
`;
const LastLogin = styled.p`
font-size: 13px;
font-weight: 500;
color: rgba(20, 23, 26, 0.5);
padding-top: 4px;
`;
export default SigninButtons;

View File

@ -1,970 +0,0 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import Grid from "styled-components-grid";
import PageTitle from "../components/PageTitle";
import Header from "../components/Header";
import Content from "../components/Content";
export default function Api() {
return (
<Grid>
<PageTitle title="API Reference" />
<Header background="#AA34F0">
<h1>API Reference</h1>
<p>Outline is built on an open, best-in-class, API</p>
</Header>
<Content>
<Methods>
<Method method="auth.info" label="Get current auth">
<Description>
This method returns the user and team info for the user identified
by the token.
</Description>
<Arguments />
</Method>
<Method method="events.list" label="List team's events">
<Description>List all of the events in the team.</Description>
<Arguments pagination>
<Argument
id="auditLog"
description="Boolean. If user token has access, return auditing events"
/>
</Arguments>
</Method>
<Method method="users.list" label="List team's users">
<Description>List all of the users in the team.</Description>
<Arguments pagination />
</Method>
<Method method="users.info" label="Get current user">
<Description>
This method returns the profile info for the user identified by
the token.
</Description>
<Arguments>
<Argument id="id" description="Collection ID" required />
</Arguments>
</Method>
<Method method="attachments.create" label="Get S3 upload credentials">
<Description>
You can upload small files and images as part of your documents.
All files are stored using Amazon S3. Instead of uploading files
to Outline, you need to upload them directly to S3 with
credentials which can be obtained through this endpoint.
</Description>
<Arguments>
<Argument
id="name"
description="Name of the uploaded file"
required
/>
<Argument
id="contentType"
description="Mimetype of the file"
required
/>
<Argument
id="size"
description="Size in bytes of the file"
required
/>
<Argument
id="documentId"
description="ID of the associated document"
/>
</Arguments>
</Method>
<Method method="users.promote" label="Promote a new admin user">
<Description>
Promote a user to be a team admin. This endpoint is only available
for admin users.
</Description>
<Arguments>
<Argument id="id" description="User ID to be promoted" required />
</Arguments>
</Method>
<Method method="users.demote" label="Demote existing admin user">
<Description>
Demote existing team admin if there are more than one as one admin
is always required. This endpoint is only available for admin
users.
</Description>
<Arguments>
<Argument id="id" description="User ID to be demoted" required />
</Arguments>
</Method>
<Method method="users.suspend" label="Suspend user account">
<Description>
Admin can suspend users to reduce the number of accounts on their
billing plan or prevent them from accessing documention.
</Description>
<Arguments>
<Argument
id="id"
description="User ID to be suspended"
required
/>
</Arguments>
</Method>
<Method
method="users.activate"
label="Activate a suspended user account"
>
<Description>
Admin can re-active a suspended user. This will update the billing
plan and re-enable their access to the documention.
</Description>
<Arguments>
<Argument
id="id"
description="User ID to be activated"
required
/>
</Arguments>
</Method>
<Method
method="collections.list"
label="List your document collections"
>
<Description>List all your document collections.</Description>
<Arguments pagination />
</Method>
<Method method="collections.info" label="Get a collection">
<Description>
Returns detailed information on a document collection.
</Description>
<Arguments>
<Argument id="id" description="Collection ID" required />
</Arguments>
</Method>
<Method
method="collections.create"
label="Create a document collection"
>
<Description>Creates a new document collection.</Description>
<Arguments>
<Argument id="name" description="Collection name" required />
<Argument
id="description"
description="Short description for the collection"
/>
</Arguments>
</Method>
<Method method="collections.export" label="Export a collection">
<Description>
Returns a zip file of all the collections documents in markdown
format. If documents are nested then they will be nested in
folders inside the zip file.
</Description>
<Arguments>
<Argument id="id" description="Collection ID" required />
</Arguments>
</Method>
<Method
method="collections.export_all"
label="Export all collections"
>
<Description>
Returns a zip file of all the collections or creates an async job
to send a zip file via email to the authenticated user. If
documents are nested then they will be nested in folders inside
the zip file.
</Description>
<Arguments>
<Argument
id="download"
description="Download as zip (default is email)"
/>
</Arguments>
</Method>
<Method method="collections.update" label="Update a collection">
<Description>
This method allows you to modify an already created collection.
</Description>
<Arguments>
<Argument id="id" description="Collection ID" required />
<Argument id="name" description="Name for the collection" />
<Argument id="private" description="Boolean" />
<Argument
id="color"
description="Collection color in hex form (e.g. #E1E1E1)"
/>
</Arguments>
</Method>
<Method method="collections.add_user" label="Add a collection member">
<Description>
This method allows you to add a user to a private collection.
</Description>
<Arguments>
<Argument id="id" description="Collection ID" required />
<Argument
id="userId"
description="User ID to add to the collection"
/>
</Arguments>
</Method>
<Method
method="collections.remove_user"
label="Remove a collection member"
>
<Description>
This method allows you to remove a user from a private collection.
</Description>
<Arguments>
<Argument id="id" description="Collection ID" required />
<Argument
id="userId"
description="User ID to remove from the collection"
/>
</Arguments>
</Method>
<Method
method="collections.add_group"
label="Add a group to a collection"
>
<Description>
This method allows you to give all members in a group access to a
collection.
</Description>
<Arguments>
<Argument id="id" description="Collection ID" required />
<Argument
id="groupId"
description="Group ID to add to the collection"
/>
</Arguments>
</Method>
<Method
method="collections.remove_group"
label="Remove a group from a collection"
>
<Description>
This method allows you to revoke all members in a group access to
a collection. Note that members of the group may still retain
access through other groups or individual memberships.
</Description>
<Arguments>
<Argument id="id" description="Collection ID" required />
<Argument
id="groupId"
description="Group ID to remove from the collection"
/>
</Arguments>
</Method>
<Method
method="collections.memberships"
label="List collection members"
>
<Description>
This method allows you to list a collections individual
memberships. This is both a collections maintainers, and user
permissions for read and write if the collection is private
</Description>
<Arguments pagination>
<Argument id="id" description="Collection ID" required />
<Argument id="query" description="Filter results by user name" />
<Argument
id="permission"
description="Filter results by permission"
/>
</Arguments>
</Method>
<Method
method="collections.group_memberships"
label="List collection group members"
>
<Description>
This method allows you to list a collections group memberships.
This is the list of groups that have been given access to the
collection.
</Description>
<Arguments pagination>
<Argument id="id" description="Collection ID" required />
<Argument id="query" description="Filter results by group name" />
<Argument
id="permission"
description="Filter results by permission"
/>
</Arguments>
</Method>
<Method method="collections.delete" label="Delete a collection">
<Description>
Delete a collection and all of its documents. This action cant be
undone so please be careful.
</Description>
<Arguments>
<Argument id="id" description="Collection ID" required />
</Arguments>
</Method>
<Method method="documents.list" label="List your documents">
<Description>List all published documents.</Description>
<Arguments pagination>
<Argument
id="collection"
description="Collection ID to filter by"
/>
<Argument id="user" description="User ID to filter by" />
<Argument
id="backlinkDocumentId"
description="Backlinked document ID to filter by"
/>
<Argument
id="parentDocumentId"
description="Parent document ID to filter by"
/>
</Arguments>
</Method>
<Method method="documents.drafts" label="List your draft documents">
<Description>List all your draft documents.</Description>
</Method>
<Method method="documents.info" label="Get a document">
<Description>
<p>
This method returns information for a document with a specific
ID. The following identifiers are allowed:
</p>
<ul>
<li>
UUID - <Code>id</Code> field of the document
</li>
<li>
URI identifier - Human readable identifier used in Outline
URLs (e.g. <Code>outline-api-i48ZEZc5zjXndcP</Code>)
</li>
</ul>
</Description>
<Arguments>
<Argument id="id" description="Document ID or URI identifier" />
<Argument id="shareId" description="An active shareId" />
</Arguments>
</Method>
<Method method="documents.search" label="Search documents">
<Description>
This methods allows you to search your teams documents with
keywords. Search results will be restricted to those accessible by
the current access token.
</Description>
<Arguments>
<Argument id="query" description="Search query" required />
<Argument id="userId" description="User ID" />
<Argument id="collectionId" description="Collection ID" />
<Argument id="includeArchived" description="Boolean" />
<Argument id="includeDrafts" description="Boolean" />
<Argument
id="dateFilter"
description="Date range to consider (day, week, month or year)"
/>
</Arguments>
</Method>
<Method method="documents.create" label="Create a new document">
<Description>
This method allows you to publish a new document under an existing
collection. By default a document is set to the parent collection
root. If you want to create a subdocument, you can pass{" "}
<Code>parentDocumentId</Code> to set parent document.
</Description>
<Arguments>
<Argument
id="collectionId"
description={
<span>
<Code>ID</Code> of the collection to which the document is
created
</span>
}
required
/>
<Argument id="title" description="Title for the document" />
<Argument
id="text"
description="Content of the document in Markdow"
required
/>
<Argument
id="parentDocumentId"
description={
<span>
<Code>ID</Code> of the parent document within the collection
</span>
}
/>
<Argument
id="publish"
description={
<span>
<Code>true</Code> by default. Pass <Code>false</Code> to
create a draft.
</span>
}
/>
</Arguments>
</Method>
<Method method="documents.update" label="Update a document">
<Description>
This method allows you to modify already created document.
</Description>
<Arguments>
<Argument
id="id"
description="Document ID or URI identifier"
required
/>
<Argument id="title" description="Title for the document" />
<Argument
id="text"
description="Content of the document in Markdown"
/>
<Argument
id="publish"
description={
<span>
Pass <Code>true</Code> to publish a draft.
</span>
}
/>
<Argument
id="append"
description={
<span>
Pass <Code>true</Code> to append the text parameter to the
end of the document rather than replace.
</span>
}
/>
<Argument
id="autosave"
description={
<span>
Pass <Code>true</Code> to signify an autosave. This skips
creating a revision.
</span>
}
/>
<Argument
id="done"
description={
<span>
Pass <Code>true</Code> to signify the end of an editing
session. This will trigger update notifications.
</span>
}
/>
</Arguments>
</Method>
<Method method="documents.move" label="Move document in a collection">
<Description>
Move a document to a new location or collection. If no parent
document is provided, the document will be moved to the collection
root.
</Description>
<Arguments>
<Argument
id="id"
description="Document ID or URI identifier"
required
/>
<Argument
id="collectionId"
description="ID of the collection"
required
/>
<Argument
id="parentDocumentId"
description="ID of the new parent document"
/>
</Arguments>
</Method>
<Method method="documents.archive" label="Archive a document">
<Description>
Archive a document and all of its nested documents, if any.
</Description>
<Arguments>
<Argument
id="id"
description="Document ID or URI identifier"
required
/>
</Arguments>
</Method>
<Method method="documents.delete" label="Delete a document">
<Description>
Permanently delete a document and all of its nested documents, if
any.
</Description>
<Arguments>
<Argument
id="id"
description="Document ID or URI identifier"
required
/>
</Arguments>
</Method>
<Method
method="documents.restore"
label="Restore a previous revision"
>
<Description>
Restores a document to a previous revision by creating a new
revision with the contents of the given revisionId or restores an
archived document if no revisionId is passed.
</Description>
<Arguments>
<Argument
id="id"
description="Document ID or URI identifier"
required
/>
<Argument
id="revisionId"
description="Revision ID to restore to"
/>
</Arguments>
</Method>
<Method method="documents.pin" label="Pin a document">
<Description>
Pins a document to the collection home. The pinned document is
visible to all members of the team.
</Description>
<Arguments>
<Argument
id="id"
description="Document ID or URI identifier"
required
/>
</Arguments>
</Method>
<Method method="documents.unpin" label="Unpin a document">
<Description>
Unpins a document from the collection home. It will still remain
in the collection itself.
</Description>
<Arguments>
<Argument
id="id"
description="Document ID or URI identifier"
required
/>
</Arguments>
</Method>
<Method method="documents.star" label="Star a document">
<Description>
Star (favorite) a document for authenticated user.
</Description>
<Arguments>
<Argument
id="id"
description="Document ID or URI identifier"
required
/>
</Arguments>
</Method>
<Method method="documents.unstar" label="Unstar a document">
<Description>
Unstar a starred (favorited) document for authenticated user.
</Description>
<Arguments>
<Argument
id="id"
description="Document ID or URI identifier"
required
/>
</Arguments>
</Method>
<Method
method="documents.viewed"
label="Get recently viewed document for user"
>
<Description>
Return recently viewed documents for the authenticated user
</Description>
<Arguments pagination />
</Method>
<Method
method="documents.starred"
label="Get recently starred document for user"
>
<Description>
Return recently starred documents for the authenticated user
</Description>
<Arguments pagination />
</Method>
<Method
method="documents.pinned"
label="Get pinned documents for a collection"
>
<Description>Return pinned documents for a collection</Description>
<Arguments pagination>
<Argument
id="collectionId"
description="Collection ID"
required
/>
</Arguments>
</Method>
<Method method="revisions.info" label="Get revision for a document">
<Description>Return a specific revision of a document.</Description>
<Arguments>
<Argument id="id" description="Revision ID" required />
</Arguments>
</Method>
<Method method="revisions.list" label="Get revisions for a document">
<Description>
Return revisions for a document. Upon each edit, a new revision is
stored.
</Description>
<Arguments pagination>
<Argument
id="documentId"
description="Document ID or URI identifier"
required
/>
</Arguments>
</Method>
<Method method="groups.create" label="Create a group">
<Description>
This method allows you to create a new group to organize people in
the team.
</Description>
<Arguments>
<Argument
id="name"
description="The name of the group"
required
/>
</Arguments>
</Method>
<Method method="groups.update" label="Update a group">
<Description>
This method allows you to update an existing group. At this time
the only field that can be edited is the name.
</Description>
<Arguments>
<Argument id="id" description="Group ID" required />
<Argument
id="name"
description="The name of the group"
required
/>
</Arguments>
</Method>
<Method method="groups.delete" label="Delete a group">
<Description>
Deleting a group will cause all of its members to lose access to
any collections the group has been given access to. This action
cant be undone so please be careful.
</Description>
<Arguments>
<Argument id="id" description="Group ID" required />
</Arguments>
</Method>
<Method method="groups.info" label="Get a group">
<Description>Returns detailed information on a group.</Description>
<Arguments>
<Argument id="id" description="Group ID" required />
</Arguments>
</Method>
<Method method="groups.list" label="List groups">
<Description>
List all groups the current user has access to.
</Description>
<Arguments pagination />
</Method>
<Method
method="groups.memberships"
label="List the group memberships"
>
<Description>
List members in a group, the query parameter allows filtering by
user name.
</Description>
<Arguments pagination>
<Argument id="id" description="Group ID" />
<Argument id="query" description="Search query" />
</Arguments>
</Method>
<Method method="groups.add_user" label="Add a group member">
<Description>
This method allows you to add a user to a group.
</Description>
<Arguments>
<Argument id="id" description="Group ID" required />
<Argument id="userId" description="User ID to add to the group" />
</Arguments>
</Method>
<Method method="groups.remove_user" label="Remove a group member">
<Description>
This method allows you to remove a user from a group.
</Description>
<Arguments>
<Argument id="id" description="Group ID" required />
<Argument
id="userId"
description="User ID to remove from the group"
/>
</Arguments>
</Method>
<Method method="shares.list" label="List shared document links">
<Description>
List all your currently shared document links.
</Description>
<Arguments pagination />
</Method>
<Method method="shares.create" label="Create a share link">
<Description>
Creates a new share link that can be used by anyone to access a
document. If you request multiple shares for the same document
with the same user the same share will be returned.
</Description>
<Arguments>
<Argument id="documentId" description="Document ID" required />
</Arguments>
</Method>
<Method method="shares.revoke" label="Revoke a share link">
<Description>
Makes the share link inactive so that it can no longer be used to
access the document.
</Description>
<Arguments>
<Argument id="id" description="Share ID" required />
</Arguments>
</Method>
<Method method="views.list" label="List document views">
<Description>
List all users that have viewed a document and the overall view
count.
</Description>
<Arguments>
<Argument id="documentId" description="Document ID" required />
</Arguments>
</Method>
<Method method="views.create" label="Create a document view">
<Description>
Creates a new view for a document. This is documented in the
interests of thoroughness however it is recommended that views are
not created from outside of the Outline UI.
</Description>
<Arguments>
<Argument id="documentId" description="Document ID" required />
</Arguments>
</Method>
</Methods>
</Content>
</Grid>
);
}
const MenuItem = styled.a`
display: flex;
align-items: center;
font-size: 16px;
color: ${props => props.theme.text};
`;
const List = styled.ul`
list-style: none;
margin: 0;
padding: 0;
`;
const Methods = (props: { children: React.Node }) => {
const children = React.Children.toArray(props.children);
const methods = children.map(child => child.props.method);
return (
<React.Fragment>
<Grid>
<Grid.Unit
size={{ tablet: 1 / 4 }}
visible={{ mobile: false, tablet: true }}
>
<nav>
<h2>Reference</h2>
<List>
{methods.map(method => (
<li key={method}>
<MenuItem href={`#${method}`}>{method}</MenuItem>
</li>
))}
</List>
</nav>
</Grid.Unit>
<Grid.Unit size={{ tablet: 3 / 4 }}>{children}</Grid.Unit>
</Grid>
</React.Fragment>
);
};
const MethodContainer = styled.div`
margin-bottom: 80px;
`;
const Request = styled.h4`
text-transform: capitalize;
`;
type MethodProps = {
method: string,
label: string,
children: React.Node,
};
const Description = (props: { children: React.Node }) => (
<p>{props.children}</p>
);
type ArgumentsProps = {
pagination?: boolean,
children?: React.Node | string,
};
const Table = styled.table`
border-collapse: collapse;
thead {
td {
padding: 5px 12px 5px 0;
border-bottom: 1px solid #ddd;
vertical-align: bottom;
font-weight: 500;
}
}
tbody,
thead {
td {
padding: 5px 12px 5px 0;
}
td:last-child {
width: 100%;
padding-right: 0;
}
}
`;
const Arguments = (props: ArgumentsProps) => (
<Table>
<thead>
<tr>
<td>Argument</td>
<td>Required</td>
<td>Description</td>
</tr>
</thead>
<tbody>
<Argument id="token" description="Authentication token" required />
{props.pagination && <PaginationArguments />}
{props.children}
</tbody>
</Table>
);
const Heading = styled.h3`
code {
font-size: 1em;
padding: 2px 4px;
background: #333;
border-radius: 4px;
color: #fff;
}
`;
const Code = styled.code`
font-size: 15px;
`;
const Method = (props: MethodProps) => {
const children = React.Children.toArray(props.children);
const description = children.find(child => child.type === Description);
const apiArgs = children.find(child => child.type === Arguments);
return (
<MethodContainer>
<Heading id={props.method}>
<code>{props.method}</code> {props.label}
</Heading>
<div>{description}</div>
<Request>HTTP request & arguments</Request>
<p>
<Code>{`${process.env.URL}/api/${props.method}`}</Code>
</p>
{apiArgs}
</MethodContainer>
);
};
type ArgumentProps = {
id: string,
required?: boolean,
description: React.Node | string,
};
const Argument = (props: ArgumentProps) => (
<tr>
<td>
<Code>{props.id}</Code>
</td>
<td>
<i>{props.required ? "required" : "optional"}</i>
</td>
<td>{props.description}</td>
</tr>
);
const PaginationArguments = () => [
<Argument id="offset" description="Pagination offset" />,
<Argument id="limit" description="Pagination limit" />,
];

View File

@ -1,167 +0,0 @@
// @flow
import * as React from "react";
import Grid from "styled-components-grid";
import styled from "styled-components";
import PageTitle from "../components/PageTitle";
import Header from "../components/Header";
import Content from "../components/Content";
export default function Developers() {
return (
<Grid>
<PageTitle title="Developers" />
<Header background="#AA34F0">
<h1>Developers</h1>
<p>Outline is built on an open, best-in-class, API</p>
</Header>
<Content>
<Grid>
<Grid.Unit
size={{ tablet: 1 / 4 }}
visible={{ mobile: false, tablet: true }}
>
<nav>
<h2>Introduction</h2>
<List>
<li>
<MenuItem href="#requests">Making requests</MenuItem>
</li>
<li>
<MenuItem href="#authentication">Authentication</MenuItem>
</li>
<li>
<MenuItem href="#errors">Errors</MenuItem>
</li>
</List>
<h2>API</h2>
<List>
<li>
<MenuItem href="/developers/api">Reference</MenuItem>
</li>
</List>
</nav>
</Grid.Unit>
<Grid.Unit size={{ tablet: 3 / 4 }}>
<p>
As developers, its our mission to make the API as great as
possible. While Outline is still in public beta, we might make
small adjustments, including breaking changes to the API.
</p>
<h2 id="requests">Making requests</h2>
<p>
Outlines API follows simple RPC style conventions where each API
endpoint is a method on{" "}
<Code>https://www.getoutline.com/api/&lt;METHOD&gt;</Code>. Both{" "}
<Code>GET</Code> and <Code>POST</Code> methods are supported but
its recommended that you make all calls using <Code>POST</Code>.
Only HTTPS is supported in production.
</p>
<p>
For <Code>GET</Code> requests query string parameters are expected
(e.g.
<Code>/api/document.info?id=...&token=...</Code>). When making{" "}
<Code>POST</Code> requests, request parameters are parsed
depending on <Code>Content-Type</Code> header. To make a call
using JSON payload, one must pass{" "}
<Code>Content-Type: application/json</Code> header:
</p>
<p>
<strong>Example POST request:</strong>
</p>
<Pre>
<Code>
{`curl https://www.getoutline.com/api/documents.info
-X POST
-H 'authorization: Bearer API_KEY'
-H 'content-type: application/json'
-H 'accept: application/json'
-d '{"id": "outline-api-NTpezNwhUP"}'
`}
</Code>
</Pre>
<p>
<strong>Example GET request:</strong>
</p>
<Pre>
<Code>
{`curl https://www.getoutline.com/api/documents.info?id=outline-api-NTpezNwhUP&token=API_KEY
`}
</Code>
</Pre>
<h2 id="authentication">Authentication</h2>
<p>
To access private API endpoints, you must provide a valid API key.
You can create new API keys in your{" "}
<a href={`${process.env.URL}/settings`}>account settings</a>. Be
careful when handling your keys as they give access to all of your
documents.
</p>
<p>
To authenticate with Outline API, you can supply the API key as a
header (<Code>Authorization: Bearer YOUR_API_KEY</Code>) or as
part of the payload using <Code>token</Code> parameter. If youre
making <Code>GET</Code> requests, header based authentication is
recommended so that your keys dont leak into logs.
</p>
<p>
Some API endpoints allow unauhenticated requests for public
resources and they can be called without an API key.
</p>
<h2 id="errors">Errors</h2>
<p>
All successful API requests will be returned with <Code>200</Code>{" "}
status code and <Code>ok: true</Code> in the response payload. If
theres an error while making the request, appropriate status code
is returned with the <Code>error</Code> message:
</p>
<Pre>
<Code>
{`{
"ok": false,
"error: "Not Found"
}
`}
</Code>
</Pre>
</Grid.Unit>
</Grid>
</Content>
</Grid>
);
}
const Pre = styled.pre`
padding: 0.5em 1em;
background: #f9fbfc;
border-radius: 4px;
border: 1px solid #e8ebed;
overflow: auto;
`;
const Code = styled.code`
font-size: 15px;
`;
const MenuItem = styled.a`
display: flex;
align-items: center;
font-size: 16px;
color: ${props => props.theme.text};
`;
const List = styled.ul`
list-style: none;
margin: 0;
padding: 0;
`;

View File

@ -1,35 +1,17 @@
// @flow
import * as React from "react";
import path from "path";
import Koa from "koa";
import Router from "koa-router";
import sendfile from "koa-sendfile";
import serve from "koa-static";
import apexRedirect from "./middlewares/apexRedirect";
import renderpage from "./utils/renderpage";
import { isCustomSubdomain, parseDomain } from "../shared/utils/domains";
import { robotsResponse } from "./utils/robots";
import { opensearchResponse } from "./utils/opensearch";
import { NotFoundError } from "./errors";
import { Team } from "./models";
import Home from "./pages/Home";
import Developers from "./pages/developers";
import Api from "./pages/developers/Api";
import SubdomainSignin from "./pages/SubdomainSignin";
const isProduction = process.env.NODE_ENV === "production";
const koa = new Koa();
const router = new Router();
const renderapp = async ctx => {
if (isProduction) {
await sendfile(ctx, path.join(__dirname, "../dist/index.html"));
} else {
await sendfile(ctx, path.join(__dirname, "./static/dev.html"));
}
};
// serve static assets
koa.use(
serve(path.resolve(__dirname, "../public"), {
@ -52,67 +34,6 @@ if (process.env.NODE_ENV === "production") {
});
}
// static pages
router.get("/developers", ctx => renderpage(ctx, <Developers />));
router.get("/developers/api", ctx => renderpage(ctx, <Api />));
// home page
router.get("/", async ctx => {
const lastSignedIn = ctx.cookies.get("lastSignedIn");
const accessToken = ctx.cookies.get("accessToken");
// Because we render both the signed in and signed out views depending
// on a cookie it's important that the browser does not render from cache.
ctx.set("Cache-Control", "no-cache");
// If we have an accessToken we can just go ahead and render the app if
// the accessToken turns out to be invalid the user will be redirected.
if (accessToken) {
return renderapp(ctx);
}
// If we're on a custom subdomain then we display a slightly different signed
// out view that includes the teams basic information.
if (
process.env.SUBDOMAINS_ENABLED === "true" &&
isCustomSubdomain(ctx.request.hostname)
) {
const domain = parseDomain(ctx.request.hostname);
const subdomain = domain ? domain.subdomain : undefined;
const team = await Team.findOne({
where: { subdomain },
});
if (team) {
return renderpage(
ctx,
<SubdomainSignin
team={team}
guest={ctx.request.query.guest}
notice={ctx.request.query.notice}
lastSignedIn={lastSignedIn}
googleSigninEnabled={!!process.env.GOOGLE_CLIENT_ID}
slackSigninEnabled={!!process.env.SLACK_KEY}
hostname={ctx.request.hostname}
/>
);
}
ctx.redirect(`${process.env.URL}?notice=invalid-auth`);
return;
}
// Otherwise, go ahead and render the homepage
return renderpage(
ctx,
<Home
notice={ctx.request.query.notice}
lastSignedIn={lastSignedIn}
googleSigninEnabled={!!process.env.GOOGLE_CLIENT_ID}
slackSigninEnabled={!!process.env.SLACK_KEY}
/>
);
});
router.get("/robots.txt", ctx => {
ctx.body = robotsResponse(ctx);
});
@ -122,12 +43,17 @@ router.get("/opensearch.xml", ctx => {
ctx.body = opensearchResponse();
});
// catch all for react app
// catch all for application
router.get("*", async (ctx, next) => {
if (ctx.request.path === "/realtime/") return next();
if (ctx.request.path === "/realtime/") {
return next();
}
await renderapp(ctx);
if (!ctx.status) ctx.throw(new NotFoundError());
if (isProduction) {
await sendfile(ctx, path.join(__dirname, "../dist/index.html"));
} else {
await sendfile(ctx, path.join(__dirname, "./static/dev.html"));
}
});
// middleware

View File

@ -1,27 +0,0 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from "fetch-test-server";
import app from "./app";
import { flushdb } from "./test/support";
const server = new TestServer(app.callback());
beforeEach(flushdb);
afterAll(server.close);
describe("#index", async () => {
it("should render homepage", async () => {
const res = await server.get("/");
const html = await res.text();
expect(res.status).toEqual(200);
expect(html.includes("Our teams knowledge base")).toEqual(true);
});
it("should render app if there is an accessToken", async () => {
const res = await server.get("/", {
headers: { Cookie: ["accessToken=12345667"] },
});
const html = await res.text();
expect(res.status).toEqual(200);
expect(html.includes('id="root"')).toEqual(true);
});
});

View File

@ -1,47 +0,0 @@
// @flow
import * as React from "react";
import ReactDOMServer from "react-dom/server";
import { Helmet } from "react-helmet";
import {
ServerStyleSheet,
StyleSheetManager,
ThemeProvider,
} from "styled-components";
import Layout from "../pages/components/Layout";
import { light } from "../../shared/styles/theme";
export default function renderpage(ctx: Object, children: React.Node) {
let sessions = {};
try {
sessions = JSON.parse(
decodeURIComponent(ctx.cookies.get("sessions") || "") || "{}"
);
} catch (err) {
console.error(`Sessions cookie could not be parsed: ${err}`);
}
const sheet = new ServerStyleSheet();
const loggedIn = !!(
ctx.cookies.get("accessToken") || Object.keys(sessions).length
);
const html = ReactDOMServer.renderToString(
<StyleSheetManager sheet={sheet.instance}>
<ThemeProvider theme={light}>
<Layout sessions={sessions} loggedIn={loggedIn}>
{children}
</Layout>
</ThemeProvider>
</StyleSheetManager>
);
// helmet returns an object of meta tags with toString methods, urgh.
const helmet = Helmet.renderStatic();
let head = "";
// $FlowFixMe
Object.keys(helmet).forEach(key => (head += helmet[key].toString()));
ctx.body = `<!DOCTYPE html>\n${html}`
.replace("{{CSS}}", sheet.getStyleTags())
.replace("{{HEAD}}", head);
}