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

@@ -6,6 +6,7 @@ import styled from "styled-components";
import ActionButton, {
Props as ActionButtonProps,
} from "~/components/ActionButton";
import { undraggableOnDesktop } from "~/styles";
type RealProps = {
$fullwidth?: boolean;
@@ -33,6 +34,7 @@ const RealButton = styled(ActionButton)<RealProps>`
cursor: var(--pointer);
user-select: none;
appearance: none !important;
${undraggableOnDesktop()}
${(props) =>
!props.$borderOnHover &&

View File

@@ -0,0 +1,44 @@
import * as React from "react";
import { useHistory } from "react-router-dom";
import { useDesktopTitlebar } from "~/hooks/useDesktopTitlebar";
import useToasts from "~/hooks/useToasts";
import Desktop from "~/utils/Desktop";
export default function DesktopEventHandler() {
useDesktopTitlebar();
const history = useHistory();
const { showToast } = useToasts();
React.useEffect(() => {
Desktop.bridge?.redirect((path: string, replace = false) => {
if (replace) {
history.replace(path);
} else {
history.push(path);
}
});
Desktop.bridge?.updateDownloaded(() => {
showToast("An update is ready to install.", {
type: "info",
timeout: Infinity,
action: {
text: "Install now",
onClick: () => {
Desktop.bridge?.restartAndInstall();
},
},
});
});
Desktop.bridge?.focus(() => {
window.document.body.classList.remove("backgrounded");
});
Desktop.bridge?.blur(() => {
window.document.body.classList.add("backgrounded");
});
}, [history, showToast]);
return null;
}

View File

@@ -12,6 +12,8 @@ import Flex from "~/components/Flex";
import useEventListener from "~/hooks/useEventListener";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { draggableOnDesktop, fadeOnDesktopBackgrounded } from "~/styles";
import Desktop from "~/utils/Desktop";
import { supportsPassiveListener } from "~/utils/browser";
type Props = {
@@ -26,6 +28,7 @@ function Header({ left, title, actions, hasSidebar }: Props) {
const isMobile = useMobile();
const hasMobileSidebar = hasSidebar && isMobile;
const sidebarCollapsed = ui.isEditing || ui.sidebarCollapsed;
const passThrough = !actions && !left && !title;
@@ -50,7 +53,12 @@ function Header({ left, title, actions, hasSidebar }: Props) {
}, []);
return (
<Wrapper align="center" shrink={false} $passThrough={passThrough}>
<Wrapper
align="center"
shrink={false}
$passThrough={passThrough}
$insetTitleAdjust={sidebarCollapsed && Desktop.hasInsetTitlebar()}
>
{left || hasMobileSidebar ? (
<Breadcrumbs>
{hasMobileSidebar && (
@@ -98,7 +106,12 @@ const Actions = styled(Flex)`
`};
`;
const Wrapper = styled(Flex)<{ $passThrough?: boolean }>`
type WrapperProps = {
$passThrough?: boolean;
$insetTitleAdjust?: boolean;
};
const Wrapper = styled(Flex)<WrapperProps>`
top: 0;
z-index: ${depths.header};
position: sticky;
@@ -120,6 +133,8 @@ const Wrapper = styled(Flex)<{ $passThrough?: boolean }>`
transform: translate3d(0, 0, 0);
min-height: 64px;
justify-content: flex-start;
${draggableOnDesktop()}
${fadeOnDesktopBackgrounded()}
@supports (backdrop-filter: blur(20px)) {
backdrop-filter: blur(20px);
@@ -133,7 +148,8 @@ const Wrapper = styled(Flex)<{ $passThrough?: boolean }>`
${breakpoint("tablet")`
padding: 16px;
justify-content: center;
`};
${(props: WrapperProps) => props.$insetTitleAdjust && `padding-left: 64px;`}
`};
`;
const Title = styled("div")`

View File

@@ -5,6 +5,7 @@ import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Flex from "~/components/Flex";
import { undraggableOnDesktop } from "~/styles";
const RealTextarea = styled.textarea<{ hasIcon?: boolean }>`
border: 0;
@@ -32,6 +33,7 @@ const RealInput = styled.input<{ hasIcon?: boolean }>`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
${undraggableOnDesktop()}
&:disabled,
&::placeholder {
@@ -98,6 +100,9 @@ export const Outline = styled(Flex)<{
align-items: center;
overflow: hidden;
background: ${(props) => props.theme.background};
/* Prevents an issue where input placeholder appears in a selected style when double clicking title bar */
user-select: none;
`;
export const LabelText = styled.div`

View File

@@ -15,6 +15,7 @@ import useMobile from "~/hooks/useMobile";
import usePrevious from "~/hooks/usePrevious";
import useUnmount from "~/hooks/useUnmount";
import { fadeAndScaleIn } from "~/styles/animations";
import Desktop from "~/utils/Desktop";
let openModals = 0;
type Props = {
@@ -222,7 +223,7 @@ const Back = styled(NudeButton)`
position: absolute;
display: none;
align-items: center;
top: 2rem;
top: ${Desktop.hasInsetTitlebar() ? "3rem" : "2rem"};
left: 2rem;
opacity: 0.75;
color: ${(props) => props.theme.text};

View File

@@ -14,6 +14,7 @@ import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import OrganizationMenu from "~/menus/OrganizationMenu";
import Desktop from "~/utils/Desktop";
import {
homePath,
draftsPath,
@@ -63,7 +64,16 @@ function AppSidebar() {
<HeaderButton
{...props}
title={team.name}
image={<TeamLogo model={team} size={32} alt={t("Logo")} />}
image={
<TeamLogo
model={team}
size={Desktop.hasInsetTitlebar() ? 24 : 32}
alt={t("Logo")}
/>
}
style={
Desktop.hasInsetTitlebar() ? { paddingLeft: 8 } : undefined
}
showDisclosure
/>
)}

View File

@@ -8,6 +8,7 @@ import styled from "styled-components";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import useAuthorizedSettingsConfig from "~/hooks/useAuthorizedSettingsConfig";
import Desktop from "~/utils/Desktop";
import isCloudHosted from "~/utils/isCloudHosted";
import Sidebar from "./Sidebar";
import Header from "./components/Header";
@@ -32,7 +33,7 @@ function SettingsSidebar() {
title={t("Return to App")}
image={<StyledBackIcon color="currentColor" />}
onClick={returnToApp}
minHeight={48}
minHeight={Desktop.hasInsetTitlebar() ? undefined : 48}
/>
<Flex auto column>

View File

@@ -11,7 +11,9 @@ import useMenuContext from "~/hooks/useMenuContext";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
import AccountMenu from "~/menus/AccountMenu";
import { draggableOnDesktop, fadeOnDesktopBackgrounded } from "~/styles";
import { fadeIn } from "~/styles/animations";
import Desktop from "~/utils/Desktop";
import Avatar from "../Avatar";
import HeaderButton, { HeaderButtonProps } from "./components/HeaderButton";
import ResizeBorder from "./components/ResizeBorder";
@@ -251,6 +253,9 @@ const Container = styled(Flex)<ContainerProps>`
z-index: ${depths.sidebar};
max-width: 70%;
min-width: 280px;
padding-top: ${Desktop.hasInsetTitlebar() ? 24 : 0}px;
${draggableOnDesktop()}
${fadeOnDesktopBackgrounded()}
${Positioner} {
display: none;
@@ -265,7 +270,9 @@ const Container = styled(Flex)<ContainerProps>`
margin: 0;
min-width: 0;
transform: translateX(${(props: ContainerProps) =>
props.$collapsed ? "calc(-100% + 16px)" : 0});
props.$collapsed
? `calc(-100% + ${Desktop.hasInsetTitlebar() ? 8 : 16}px)`
: 0});
&:hover,
&:focus-within {

View File

@@ -3,7 +3,7 @@ import * as React from "react";
import styled from "styled-components";
import Flex from "~/components/Flex";
export type HeaderButtonProps = {
export type HeaderButtonProps = React.ComponentProps<typeof Wrapper> & {
title: React.ReactNode;
image: React.ReactNode;
minHeight?: number;

View File

@@ -1,4 +1,5 @@
import styled from "styled-components";
import { undraggableOnDesktop } from "~/styles";
const ResizeBorder = styled.div<{ dir?: "left" | "right" }>`
position: absolute;
@@ -8,6 +9,7 @@ const ResizeBorder = styled.div<{ dir?: "left" | "right" }>`
left: ${(props) => (props.dir === "right" ? "-1px" : "auto")};
width: 2px;
cursor: col-resize;
${undraggableOnDesktop()}
&:hover {
transition-delay: 500ms;
@@ -22,6 +24,7 @@ const ResizeBorder = styled.div<{ dir?: "left" | "right" }>`
bottom: 0;
right: -4px;
width: 10px;
${undraggableOnDesktop()}
}
`;

View File

@@ -4,6 +4,7 @@ import styled, { useTheme, css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import EventBoundary from "~/components/EventBoundary";
import NudeButton from "~/components/NudeButton";
import { undraggableOnDesktop } from "~/styles";
import { NavigationNode } from "~/types";
import Disclosure from "./Disclosure";
import NavLink, { Props as NavLinkProps } from "./NavLink";
@@ -181,6 +182,7 @@ const Link = styled(NavLink)<{
font-size: 16px;
cursor: var(--pointer);
overflow: hidden;
${undraggableOnDesktop()}
${(props) =>
props.$disabled &&

View File

@@ -2,6 +2,7 @@ import * as React from "react";
import styled from "styled-components";
import { LabelText } from "~/components/Input";
import Text from "~/components/Text";
import { undraggableOnDesktop } from "~/styles";
type Props = React.HTMLAttributes<HTMLInputElement> & {
width?: number;
@@ -62,6 +63,7 @@ function Switch({
const Wrapper = styled.div`
padding-bottom: 8px;
${undraggableOnDesktop()}
`;
const InlineLabelText = styled(LabelText)`

View File

@@ -69,14 +69,14 @@ function Toast({ closeAfterMs = 3000, onRequestClose, toast }: Props) {
const Action = styled.span`
display: inline-block;
padding: 10px 12px;
height: 100%;
text-transform: uppercase;
font-size: 12px;
padding: 4px 8px;
color: ${(props) => props.theme.toastText};
background: ${(props) => darken(0.05, props.theme.toastBackground)};
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
border-radius: 4px;
margin-left: 8px;
margin-right: -4px;
font-weight: 500;
user-select: none;
&:hover {
background: ${(props) => darken(0.1, props.theme.toastBackground)};

View File

@@ -0,0 +1,46 @@
import * as React from "react";
import Desktop from "~/utils/Desktop";
export const useDesktopTitlebar = () => {
React.useEffect(() => {
if (!Desktop.isElectron()) {
return;
}
const handleDoubleClick = (event: MouseEvent) => {
// Ignore double clicks on interactive elements such as inputs and buttons
if (event.composedPath().some(elementIsInteractive)) {
return;
}
// Ignore if the mouse position is further down than the header height
if (event.clientY > 64) {
return;
}
event.preventDefault();
Desktop.bridge.onTitlebarDoubleClick();
};
window.addEventListener("dblclick", handleDoubleClick);
return () => window.removeEventListener("dblclick", handleDoubleClick);
}, []);
};
/**
* Check if an element is user interactive.
*
* @param target HTML element
* @returns boolean
*/
function elementIsInteractive(target: EventTarget) {
return (
target &&
target instanceof HTMLElement &&
(target instanceof HTMLSelectElement ||
target instanceof HTMLInputElement ||
target instanceof HTMLButtonElement ||
target.getAttribute("role") === "button" ||
target.getAttribute("role") === "textarea")
);
}

View File

@@ -15,6 +15,7 @@ import ScrollToTop from "~/components/ScrollToTop";
import Theme from "~/components/Theme";
import Toasts from "~/components/Toasts";
import env from "~/env";
import Desktop from "./components/DesktopEventHandler";
import LazyPolyfill from "./components/LazyPolyfills";
import Routes from "./routes";
import Logger from "./utils/Logger";
@@ -92,6 +93,7 @@ if (element) {
</ScrollToTop>
<Toasts />
<Dialogs />
<Desktop />
</>
</Router>
</LazyMotion>

View File

@@ -1,5 +1,6 @@
import * as React from "react";
import { Switch, Redirect } from "react-router-dom";
import DesktopRedirect from "~/scenes/DesktopRedirect";
import DelayedMount from "~/components/DelayedMount";
import FullscreenLoading from "~/components/FullscreenLoading";
import Route from "~/components/ProfiledRoute";
@@ -54,6 +55,7 @@ export default function Routes() {
<Route exact path="/" component={Login} />
<Route exact path="/create" component={Login} />
<Route exact path="/logout" component={Logout} />
<Route exact path="/desktop-redirect" component={DesktopRedirect} />
<Redirect exact from="/share/:shareId" to="/s/:shareId" />
<Route exact path="/s/:shareId" component={SharedDocument} />

View File

@@ -0,0 +1,58 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
import PageTitle from "~/components/PageTitle";
import Text from "~/components/Text";
import useQuery from "~/hooks/useQuery";
const DesktopRedirect = () => {
const params = useQuery();
const token = params.get("token");
const { t } = useTranslation();
React.useEffect(() => {
if (token) {
window.location.href = `outline://${window.location.host}/auth/redirect?token=${token}`;
// Clean the url so it's not possible to hit reload, re-using the transfer token will not work.
window.location.search = "";
}
}, [token]);
return (
<Centered align="center" justify="center" column auto>
<PageTitle title={`${t("Signing in")}`} />
<Heading centered>{t("Signing in")}</Heading>
<Note>
{t(
"You can safely close this window once the Outline desktop app has opened"
)}
.
</Note>
</Centered>
);
};
const Note = styled(Text)`
color: ${(props) => props.theme.textTertiary};
text-align: center;
font-size: 14px;
margin-top: 8px;
em {
font-style: normal;
font-weight: 500;
}
`;
const Centered = styled(Flex)`
user-select: none;
width: 90vw;
height: 100%;
max-width: 320px;
margin: 0 auto;
`;
export default DesktopRedirect;

View File

@@ -2,12 +2,14 @@ import { EmailIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { Client } from "@shared/types";
import { parseDomain } from "@shared/utils/domains";
import AuthLogo from "~/components/AuthLogo";
import ButtonLarge from "~/components/ButtonLarge";
import InputLarge from "~/components/InputLarge";
import env from "~/env";
import { client } from "~/utils/ApiClient";
import Desktop from "~/utils/Desktop";
type Props = {
id: string;
@@ -39,6 +41,7 @@ function AuthenticationProvider(props: Props) {
try {
const response = await client.post(event.currentTarget.action, {
email,
client: Desktop.isElectron() ? "desktop" : undefined,
});
if (response.redirect) {
@@ -95,7 +98,9 @@ function AuthenticationProvider(props: Props) {
// and keep the user on the same page.
const { custom, teamSubdomain, host } = parseDomain(window.location.origin);
const needsRedirect = custom || teamSubdomain;
const href = needsRedirect
const href = Desktop.isElectron()
? `${env.URL}${authUrl}?client=${Client.Desktop}`
: needsRedirect
? `${env.URL}${authUrl}?host=${encodeURI(host)}`
: authUrl;

View File

@@ -21,6 +21,8 @@ import env from "~/env";
import useLastVisitedPath from "~/hooks/useLastVisitedPath";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import { draggableOnDesktop } from "~/styles";
import Desktop from "~/utils/Desktop";
import isCloudHosted from "~/utils/isCloudHosted";
import { changeLanguage, detectLanguage } from "~/utils/language";
import AuthenticationProvider from "./AuthenticationProvider";
@@ -30,7 +32,11 @@ function Header({ config }: { config?: Config | undefined }) {
const { t } = useTranslation();
const isSubdomain = !!config?.hostname;
if (!isCloudHosted || parseDomain(window.location.origin).custom) {
if (
!isCloudHosted ||
parseDomain(window.location.origin).custom ||
Desktop.isElectron()
) {
return null;
}
@@ -274,6 +280,7 @@ const Background = styled(Fade)`
height: 100%;
background: ${(props) => props.theme.background};
display: flex;
${draggableOnDesktop()}
`;
const Logo = styled.div`

View File

@@ -1,3 +1,4 @@
import Desktop from "~/utils/Desktop";
import { isTouchDevice } from "~/utils/browser";
/**
@@ -6,3 +7,34 @@ import { isTouchDevice } from "~/utils/browser";
* using `&:hover {...}`.
*/
export const hover = isTouchDevice() ? "active" : "hover";
/**
* Mixin to make an element drag the window when rendered in the desktop app.
*
* @returns string of CSS
*/
export const draggableOnDesktop = () =>
Desktop.isElectron() ? "-webkit-app-region: drag;" : "";
/**
* Mixin to make an element not drag the window when rendered in the desktop app.
*
* @returns string of CSS
*/
export const undraggableOnDesktop = () =>
Desktop.isElectron() ? "-webkit-app-region: no-drag;" : "";
/**
* Mixin to make an element fade when the desktop app is backgrounded.
*
* @returns string of CSS
*/
export const fadeOnDesktopBackgrounded = () => {
if (!Desktop.isElectron()) {
return "";
}
return `
body.backgrounded & { opacity: 0.75; }
`;
};

68
app/typings/window.d.ts vendored Normal file
View File

@@ -0,0 +1,68 @@
declare global {
interface Window {
DesktopBridge: {
/**
* The name of the platform running on.
*/
platform: string;
/**
* The version of the loaded application.
*/
version: () => Promise<string>;
/**
* Restarts the application.
*/
restart: () => Promise<void>;
/**
* Restarts the application and installs the update.
*/
restartAndInstall: () => Promise<void>;
/**
* Tells the updater to check for updates now.
*/
checkForUpdates: () => Promise<void>;
/**
* Passes double click events from titlebar area
*/
onTitlebarDoubleClick: () => Promise<void>;
/**
* Adds a custom host to config
*/
addCustomHost: (host: string) => Promise<void>;
/**
* Set the language used by the spellchecker on Windows/Linux.
*/
setSpellCheckerLanguages: (languages: string[]) => Promise<void>;
/**
* Registers a callback to be called when the window is focused.
*/
focus: (callback: () => void) => void;
/**
* Registers a callback to be called when the window loses focus.
*/
blur: (callback: () => void) => void;
/**
* Registers a callback to be called when a route change is requested from the main process.
* This would usually be when it is responding to a deeplink.
*/
redirect: (callback: (path: string, replace: boolean) => void) => void;
/**
* Registers a callback to be called when the application is ready to update.
*/
updateDownloaded: (callback: () => void) => void;
};
}
}
export {};

36
app/utils/Desktop.ts Normal file
View File

@@ -0,0 +1,36 @@
import { isMac, isWindows } from "./browser";
export default class Desktop {
/**
* Returns true if the client has inset/floating window controls.
*/
static hasInsetTitlebar() {
return this.isMacApp();
}
/**
* Returns true if the client is running in the macOS app.
*/
static isMacApp() {
return this.isElectron() && isMac();
}
/**
* Returns true if the client is running in the Windows app.
*/
static isWindowsApp() {
return this.isElectron() && isWindows();
}
/**
* Returns true if the client is running in a desktop app.
*/
static isElectron() {
return navigator?.userAgent?.includes("Electron");
}
/**
* The bridge provides secure access to API's in desktop wrapper.
*/
static bridge = window.DesktopBridge;
}

View File

@@ -12,8 +12,14 @@ export function isTouchDevice(): boolean {
* Returns true if the client is running on a Mac.
*/
export function isMac(): boolean {
const SSR = typeof window === "undefined";
return !SSR && window.navigator.platform === "MacIntel";
return window.navigator.platform === "MacIntel";
}
/**
* Returns true if the client is running on Windows.
*/
export function isWindows(): boolean {
return window.navigator.platform === "Win32";
}
let supportsPassive = false;

View File

@@ -1,4 +1,5 @@
import { i18n } from "i18next";
import Desktop from "./Desktop";
export function detectLanguage() {
const [ln, r] = navigator.language.split("-");
@@ -13,6 +14,9 @@ export function changeLanguage(
if (toLanguageString && i18n.language !== toLanguageString) {
// Languages are stored in en_US format in the database, however the
// frontend translation framework (i18next) expects en-US
i18n.changeLanguage(toLanguageString.replace("_", "-"));
const locale = toLanguageString.replace("_", "-");
i18n.changeLanguage(locale);
Desktop.bridge?.setSpellCheckerLanguages(["en-US", locale]);
}
}

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;

View File

@@ -417,6 +417,8 @@
"Add additional access for individual members and groups": "Add additional access for individual members and groups",
"Add groups to {{ collectionName }}": "Add groups to {{ collectionName }}",
"Add people to {{ collectionName }}": "Add people to {{ collectionName }}",
"Signing in": "Signing in",
"You can safely close this window once the Outline desktop app has opened": "You can safely close this window once the Outline desktop app has opened",
"Document updated by {{userName}}": "Document updated by {{userName}}",
"You have unsaved changes.\nAre you sure you want to discard them?": "You have unsaved changes.\nAre you sure you want to discard them?",
"Images are still uploading.\nAre you sure you want to discard them?": "Images are still uploading.\nAre you sure you want to discard them?",

View File

@@ -2,6 +2,11 @@ export type Role = "admin" | "viewer" | "member";
export type DateFilter = "day" | "week" | "month" | "year";
export enum Client {
Web = "web",
Desktop = "desktop",
}
export type PublicEnv = {
URL: string;
CDN_URL: string;