Desktop support (#4484)

* Remove home link on desktop app

* Spellcheck, installation toasts, background styling, …

* Add email,slack, auth support

* More desktop style tweaks

* Move redirect to client

* cleanup

* Record desktop usage

* docs

* fix: Selection state in search input when double clicking header
This commit is contained in:
Tom Moor
2022-11-27 15:07:48 -08:00
committed by GitHub
parent ea9680c3d7
commit cc333637dd
38 changed files with 492 additions and 83 deletions

View File

@@ -1,4 +1,5 @@
import * as React from "react";
import { Client } from "@shared/types";
import env from "@server/env";
import logger from "@server/logging/Logger";
import BaseEmail from "./BaseEmail";
@@ -14,6 +15,7 @@ type Props = {
to: string;
token: string;
teamUrl: string;
client: Client;
};
/**
@@ -28,20 +30,20 @@ export default class SigninEmail extends BaseEmail<Props> {
return "Heres your link to signin to Outline.";
}
protected renderAsText({ token, teamUrl }: Props): string {
protected renderAsText({ token, teamUrl, client }: Props): string {
return `
Use the link below to signin to Outline:
${this.signinLink(token)}
${this.signinLink(token, client)}
If your magic link expired you can request a new one from your teams
signin page at: ${teamUrl}
`;
}
protected render({ token, teamUrl }: Props) {
protected render({ token, client, teamUrl }: Props) {
if (env.ENVIRONMENT === "development") {
logger.debug("email", `Sign-In link: ${this.signinLink(token)}`);
logger.debug("email", `Sign-In link: ${this.signinLink(token, client)}`);
}
return (
@@ -53,7 +55,7 @@ signin page at: ${teamUrl}
<p>Click the button below to sign in to Outline.</p>
<EmptySpace height={10} />
<p>
<Button href={this.signinLink(token)}>Sign In</Button>
<Button href={this.signinLink(token, client)}>Sign In</Button>
</p>
<EmptySpace height={10} />
<p>
@@ -67,7 +69,7 @@ signin page at: ${teamUrl}
);
}
private signinLink(token: string): string {
return `${env.URL}/auth/email.callback?token=${token}`;
private signinLink(token: string, client: Client): string {
return `${env.URL}/auth/email.callback?token=${token}&client=${client}`;
}
}

View File

@@ -2,9 +2,9 @@ import passport from "@outlinewiki/koa-passport";
import { Context } from "koa";
import env from "@server/env";
import Logger from "@server/logging/Logger";
import { AuthenticationResult } from "@server/types";
import { signIn } from "@server/utils/authentication";
import { parseState } from "@server/utils/passport";
import { AccountProvisionerResult } from "../commands/accountProvisioner";
export default function createMiddleware(providerName: string) {
return function passportMiddleware(ctx: Context) {
@@ -13,7 +13,7 @@ export default function createMiddleware(providerName: string) {
{
session: false,
},
async (err, user, result: AccountProvisionerResult) => {
async (err, user, result: AuthenticationResult) => {
if (err) {
Logger.error("Error during authentication", err);
@@ -66,12 +66,10 @@ export default function createMiddleware(providerName: string) {
if (error && error_description) {
Logger.error(
"Error from Azure during authentication",
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string | string[]' is not assign... Remove this comment to see the full error message
new Error(error_description)
new Error(String(error_description))
);
// Display only the descriptive message to the user, log the rest
// @ts-expect-error ts-migrate(2339) FIXME: Property 'split' does not exist on type 'string | ... Remove this comment to see the full error message
const description = error_description.split("Trace ID")[0];
const description = String(error_description).split("Trace ID")[0];
return ctx.redirect(`/?notice=auth-error&description=${description}`);
}
@@ -79,14 +77,7 @@ export default function createMiddleware(providerName: string) {
return ctx.redirect("/?notice=suspended");
}
await signIn(
ctx,
result.user,
result.team,
providerName,
result.isNewUser,
result.isNewTeam
);
await signIn(ctx, providerName, result);
}
)(ctx);
};

View File

@@ -160,16 +160,17 @@ class Team extends ParanoidModel {
}
get url() {
const url = new URL(env.URL);
// custom domain
if (this.domain) {
return `https://${this.domain}`;
return `${url.protocol}//${this.domain}${url.port ? `:${url.port}` : ""}`;
}
if (!this.subdomain || !env.SUBDOMAINS_ENABLED) {
return env.URL;
}
const url = new URL(env.URL);
url.host = `${this.subdomain}.${getBaseDomain()}`;
return url.href.replace(/\/$/, "");
}

View File

@@ -57,6 +57,7 @@ import NotContainsUrl from "./validators/NotContainsUrl";
export enum UserFlag {
InviteSent = "inviteSent",
InviteReminderSent = "inviteReminderSent",
Desktop = "desktop",
DesktopWeb = "desktopWeb",
MobileWeb = "mobileWeb",
}
@@ -366,11 +367,12 @@ class User extends ParanoidModel {
}
// Track the clients each user is using
if (ctx.userAgent?.isMobile) {
this.setFlag(UserFlag.MobileWeb);
}
if (ctx.userAgent?.isDesktop) {
if (ctx.userAgent?.source.includes("Outline/")) {
this.setFlag(UserFlag.Desktop);
} else if (ctx.userAgent?.isDesktop) {
this.setFlag(UserFlag.DesktopWeb);
} else if (ctx.userAgent?.isMobile) {
this.setFlag(UserFlag.MobileWeb);
}
// Save only writes to the database if there are changes

View File

@@ -5,17 +5,17 @@ import type { Context } from "koa";
import Router from "koa-router";
import { Profile } from "passport";
import { slugifyDomain } from "@shared/utils/domains";
import accountProvisioner, {
AccountProvisionerResult,
} from "@server/commands/accountProvisioner";
import accountProvisioner from "@server/commands/accountProvisioner";
import env from "@server/env";
import { MicrosoftGraphError } from "@server/errors";
import passportMiddleware from "@server/middlewares/passport";
import { User } from "@server/models";
import { AuthenticationResult } from "@server/types";
import {
StateStore,
request,
getTeamFromContext,
getClientFromContext,
} from "@server/utils/passport";
const router = new Router();
@@ -49,7 +49,7 @@ if (env.AZURE_CLIENT_ID && env.AZURE_CLIENT_SECRET) {
done: (
err: Error | null,
user: User | null,
result?: AccountProvisionerResult
result?: AuthenticationResult
) => void
) {
try {
@@ -94,6 +94,7 @@ if (env.AZURE_CLIENT_ID && env.AZURE_CLIENT_SECRET) {
}
const team = await getTeamFromContext(ctx);
const client = getClientFromContext(ctx);
const domain = email.split("@")[1];
const subdomain = slugifyDomain(domain);
@@ -124,7 +125,7 @@ if (env.AZURE_CLIENT_ID && env.AZURE_CLIENT_SECRET) {
scopes,
},
});
return done(null, result.user, result);
return done(null, result.user, { ...result, client });
} catch (err) {
return done(err, null);
}

View File

@@ -1,5 +1,6 @@
import Router from "koa-router";
import { find } from "lodash";
import { Client } from "@shared/types";
import { parseDomain } from "@shared/utils/domains";
import { RateLimiterStrategy } from "@server/RateLimiter";
import InviteAcceptedEmail from "@server/emails/templates/InviteAcceptedEmail";
@@ -26,7 +27,7 @@ router.post(
errorHandling(),
rateLimiter(RateLimiterStrategy.TenPerHour),
async (ctx) => {
const { email } = ctx.request.body;
const { email, client } = ctx.request.body;
assertEmail(email, "email is required");
const domain = parseDomain(ctx.request.hostname);
@@ -81,6 +82,7 @@ router.post(
to: user.email,
token: user.getEmailSigninToken(),
teamUrl: team.url,
client: client === Client.Desktop ? Client.Desktop : Client.Web,
});
user.lastSigninEmailSentAt = new Date();
await user.save();
@@ -93,7 +95,7 @@ router.post(
);
router.get("email.callback", async (ctx) => {
const { token } = ctx.request.query;
const { token, client } = ctx.request.query;
assertPresent(token, "token is required");
let user!: User;
@@ -131,7 +133,13 @@ router.get("email.callback", async (ctx) => {
}
// set cookies on response and redirect to team subdomain
await signIn(ctx, user, user.team, "email", false, false);
await signIn(ctx, "email", {
user,
team: user.team,
isNewTeam: false,
isNewUser: false,
client: client === Client.Desktop ? Client.Desktop : Client.Web,
});
});
export default router;

View File

@@ -5,9 +5,7 @@ import { capitalize } from "lodash";
import { Profile } from "passport";
import { Strategy as GoogleStrategy } from "passport-google-oauth2";
import { slugifyDomain } from "@shared/utils/domains";
import accountProvisioner, {
AccountProvisionerResult,
} from "@server/commands/accountProvisioner";
import accountProvisioner from "@server/commands/accountProvisioner";
import env from "@server/env";
import {
GmailAccountCreationError,
@@ -15,7 +13,12 @@ import {
} from "@server/errors";
import passportMiddleware from "@server/middlewares/passport";
import { User } from "@server/models";
import { StateStore, getTeamFromContext } from "@server/utils/passport";
import { AuthenticationResult } from "@server/types";
import {
StateStore,
getTeamFromContext,
getClientFromContext,
} from "@server/utils/passport";
const router = new Router();
const GOOGLE = "google";
@@ -58,13 +61,14 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
done: (
err: Error | null,
user: User | null,
result?: AccountProvisionerResult
result?: AuthenticationResult
) => void
) {
try {
// "domain" is the Google Workspaces domain
const domain = profile._json.hd;
const team = await getTeamFromContext(ctx);
const client = getClientFromContext(ctx);
// No profile domain means personal gmail account
// No team implies the request came from the apex domain
@@ -122,7 +126,7 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
},
});
return done(null, result.user, result);
return done(null, result.user, { ...result, client });
} catch (err) {
return done(err, null);
}

View File

@@ -4,9 +4,7 @@ import Router from "koa-router";
import { get } from "lodash";
import { Strategy } from "passport-oauth2";
import { slugifyDomain } from "@shared/utils/domains";
import accountProvisioner, {
AccountProvisionerResult,
} from "@server/commands/accountProvisioner";
import accountProvisioner from "@server/commands/accountProvisioner";
import env from "@server/env";
import {
OIDCMalformedUserInfoError,
@@ -14,10 +12,12 @@ import {
} from "@server/errors";
import passportMiddleware from "@server/middlewares/passport";
import { User } from "@server/models";
import { AuthenticationResult } from "@server/types";
import {
StateStore,
request,
getTeamFromContext,
getClientFromContext,
} from "@server/utils/passport";
const router = new Router();
@@ -73,7 +73,7 @@ if (env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET) {
done: (
err: Error | null,
user: User | null,
result?: AccountProvisionerResult
result?: AuthenticationResult
) => void
) {
try {
@@ -83,6 +83,7 @@ if (env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET) {
);
}
const team = await getTeamFromContext(ctx);
const client = getClientFromContext(ctx);
const parts = profile.email.toLowerCase().split("@");
const domain = parts.length && parts[1];
@@ -123,7 +124,7 @@ if (env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET) {
scopes,
},
});
return done(null, result.user, result);
return done(null, result.user, { ...result, client });
} catch (err) {
return done(err, null);
}

View File

@@ -3,9 +3,7 @@ import type { Context } from "koa";
import Router from "koa-router";
import { Profile } from "passport";
import { Strategy as SlackStrategy } from "passport-slack-oauth2";
import accountProvisioner, {
AccountProvisionerResult,
} from "@server/commands/accountProvisioner";
import accountProvisioner from "@server/commands/accountProvisioner";
import env from "@server/env";
import auth from "@server/middlewares/authentication";
import passportMiddleware from "@server/middlewares/passport";
@@ -16,7 +14,12 @@ import {
Team,
User,
} from "@server/models";
import { getTeamFromContext, StateStore } from "@server/utils/passport";
import { AuthenticationResult } from "@server/types";
import {
getClientFromContext,
getTeamFromContext,
StateStore,
} from "@server/utils/passport";
import * as Slack from "@server/utils/slack";
import { assertPresent, assertUuid } from "@server/validation";
@@ -80,11 +83,13 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
done: (
err: Error | null,
user: User | null,
result?: AccountProvisionerResult
result?: AuthenticationResult
) => void
) {
try {
const team = await getTeamFromContext(ctx);
const client = getClientFromContext(ctx);
const result = await accountProvisioner({
ip: ctx.ip,
team: {
@@ -110,7 +115,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
scopes,
},
});
return done(null, result.user, result);
return done(null, result.user, { ...result, client });
} catch (err) {
return done(err, null);
}

View File

@@ -1,5 +1,7 @@
import { Context } from "koa";
import { RouterContext } from "koa-router";
import { Client } from "@shared/types";
import { AccountProvisionerResult } from "./commands/accountProvisioner";
import { FileOperation, Team, User } from "./models";
export enum AuthenticationType {
@@ -7,6 +9,10 @@ export enum AuthenticationType {
APP = "app",
}
export type AuthenticationResult = AccountProvisionerResult & {
client: Client;
};
export type AuthenticatedState = {
user: User;
token: string;

View File

@@ -2,10 +2,12 @@ import querystring from "querystring";
import { addMonths } from "date-fns";
import { Context } from "koa";
import { pick } from "lodash";
import { Client } from "@shared/types";
import { getCookieDomain } from "@shared/utils/domains";
import env from "@server/env";
import Logger from "@server/logging/Logger";
import { User, Event, Team, Collection, View } from "@server/models";
import { Event, Collection, View } from "@server/models";
import { AuthenticationResult } from "@server/types";
/**
* Parse and return the details from the "sessions" cookie in the request, if
@@ -27,11 +29,8 @@ export function getSessionsInCookie(ctx: Context) {
export async function signIn(
ctx: Context,
user: User,
team: Team,
service: string,
_isNewUser = false,
isNewTeam = false
{ user, team, client, isNewTeam }: AuthenticationResult
) {
if (user.isSuspended) {
return ctx.redirect("/?notice=suspended");
@@ -74,6 +73,7 @@ export async function signIn(
});
const domain = getCookieDomain(ctx.request.hostname);
const expires = addMonths(new Date(), 3);
// set a cookie for which service we last signed in with. This is
// only used to display a UI hint for the user for next time
ctx.cookies.set("lastSignedIn", service, {
@@ -103,7 +103,20 @@ export async function signIn(
expires,
domain,
});
ctx.redirect(`${team.url}/auth/redirect?token=${user.getTransferToken()}`);
// If the authentication request originally came from the desktop app then we send the user
// back to a screen in the web app that will immediately redirect to the desktop. The reason
// to do this from the client is that if you redirect from the server then the browser ends up
// stuck on the SSO screen.
if (client === Client.Desktop) {
ctx.redirect(
`${team.url}/desktop-redirect?token=${user.getTransferToken()}`
);
} else {
ctx.redirect(
`${team.url}/auth/redirect?token=${user.getTransferToken()}`
);
}
} else {
ctx.cookies.set("accessToken", user.getJwtToken(), {
sameSite: true,
@@ -136,6 +149,7 @@ export async function signIn(
}),
]);
const hasViewedDocuments = !!view;
ctx.redirect(
!hasViewedDocuments && collection
? `${team.url}${collection.url}`

View File

@@ -6,6 +6,7 @@ import {
StateStoreStoreCallback,
StateStoreVerifyCallback,
} from "passport-oauth2";
import { Client } from "@shared/types";
import { getCookieDomain, parseDomain } from "@shared/utils/domains";
import env from "@server/env";
import { Team } from "@server/models";
@@ -20,8 +21,10 @@ export class StateStore {
// We expect host to be a team subdomain, custom domain, or apex domain
// that is passed via query param from the auth provider component.
const clientInput = ctx.query.client?.toString();
const client = clientInput === Client.Desktop ? Client.Desktop : Client.Web;
const host = ctx.query.host?.toString() || parseDomain(ctx.hostname).host;
const state = buildState(host, token);
const state = buildState(host, token, client);
ctx.cookies.set(this.key, state, {
httpOnly: false,
@@ -76,13 +79,19 @@ export async function request(endpoint: string, accessToken: string) {
return response.json();
}
function buildState(host: string, token: string) {
return [host, token].join("|");
function buildState(host: string, token: string, client?: Client) {
return [host, token, client].join("|");
}
export function parseState(state: string) {
const [host, token] = state.split("|");
return { host, token };
const [host, token, client] = state.split("|");
return { host, token, client };
}
export function getClientFromContext(ctx: Context): Client {
const state = ctx.cookies.get("state");
const client = state ? parseState(state).client : undefined;
return client === Client.Desktop ? Client.Desktop : Client.Web;
}
export async function getTeamFromContext(ctx: Context) {
@@ -90,7 +99,6 @@ export async function getTeamFromContext(ctx: Context) {
// we use it to infer the team they intend on signing into
const state = ctx.cookies.get("state");
const host = state ? parseState(state).host : ctx.hostname;
const domain = parseDomain(host);
let team;