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:
@@ -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 "Here’s 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 team’s
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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(/\/$/, "");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user