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:
@ -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);
|
||||
|
@ -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 => {
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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 team’s 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;
|
@ -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;
|
@ -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;
|
@ -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>
|
||||
);
|
||||
}
|
@ -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;
|
@ -1,9 +0,0 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
|
||||
const Centered = styled.div`
|
||||
margin: 0 auto;
|
||||
max-width: 1000px;
|
||||
`;
|
||||
|
||||
export default Centered;
|
@ -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;
|
||||
}
|
||||
`;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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 team’s 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;
|
@ -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;
|
@ -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;
|
@ -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 can’t 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
|
||||
can’t 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" />,
|
||||
];
|
@ -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, it’s 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>
|
||||
Outline’s API follows simple RPC style conventions where each API
|
||||
endpoint is a method on{" "}
|
||||
<Code>https://www.getoutline.com/api/<METHOD></Code>. Both{" "}
|
||||
<Code>GET</Code> and <Code>POST</Code> methods are supported but
|
||||
it’s 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 you’re
|
||||
making <Code>GET</Code> requests, header based authentication is
|
||||
recommended so that your keys don’t 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
|
||||
there’s 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;
|
||||
`;
|
@ -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
|
||||
|
@ -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 team’s 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);
|
||||
});
|
||||
});
|
@ -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);
|
||||
}
|
Reference in New Issue
Block a user