diff --git a/app/components/Button.tsx b/app/components/Button.tsx index 6c23ceaa03..8d26da8ef0 100644 --- a/app/components/Button.tsx +++ b/app/components/Button.tsx @@ -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)` cursor: var(--pointer); user-select: none; appearance: none !important; + ${undraggableOnDesktop()} ${(props) => !props.$borderOnHover && diff --git a/app/components/DesktopEventHandler.tsx b/app/components/DesktopEventHandler.tsx new file mode 100644 index 0000000000..453a546b67 --- /dev/null +++ b/app/components/DesktopEventHandler.tsx @@ -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; +} diff --git a/app/components/Header.tsx b/app/components/Header.tsx index 3822c7739e..522ed15a3b 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -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 ( - + {left || hasMobileSidebar ? ( {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)` 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")` diff --git a/app/components/Input.tsx b/app/components/Input.tsx index f8f34139c6..44004c1f2d 100644 --- a/app/components/Input.tsx +++ b/app/components/Input.tsx @@ -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` diff --git a/app/components/Modal.tsx b/app/components/Modal.tsx index 59875d1993..1767905b9c 100644 --- a/app/components/Modal.tsx +++ b/app/components/Modal.tsx @@ -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}; diff --git a/app/components/Sidebar/App.tsx b/app/components/Sidebar/App.tsx index 6965ab0aee..2c38d6d8d1 100644 --- a/app/components/Sidebar/App.tsx +++ b/app/components/Sidebar/App.tsx @@ -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() { } + image={ + + } + style={ + Desktop.hasInsetTitlebar() ? { paddingLeft: 8 } : undefined + } showDisclosure /> )} diff --git a/app/components/Sidebar/Settings.tsx b/app/components/Sidebar/Settings.tsx index 5ac80f451c..1350e0dc40 100644 --- a/app/components/Sidebar/Settings.tsx +++ b/app/components/Sidebar/Settings.tsx @@ -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={} onClick={returnToApp} - minHeight={48} + minHeight={Desktop.hasInsetTitlebar() ? undefined : 48} /> diff --git a/app/components/Sidebar/Sidebar.tsx b/app/components/Sidebar/Sidebar.tsx index 9c2b67511a..eb55f4f061 100644 --- a/app/components/Sidebar/Sidebar.tsx +++ b/app/components/Sidebar/Sidebar.tsx @@ -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)` 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)` 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 { diff --git a/app/components/Sidebar/components/HeaderButton.tsx b/app/components/Sidebar/components/HeaderButton.tsx index 88379ff332..464a8623b1 100644 --- a/app/components/Sidebar/components/HeaderButton.tsx +++ b/app/components/Sidebar/components/HeaderButton.tsx @@ -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 & { title: React.ReactNode; image: React.ReactNode; minHeight?: number; diff --git a/app/components/Sidebar/components/ResizeBorder.ts b/app/components/Sidebar/components/ResizeBorder.ts index ec3bec89fc..050ed2ce39 100644 --- a/app/components/Sidebar/components/ResizeBorder.ts +++ b/app/components/Sidebar/components/ResizeBorder.ts @@ -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()} } `; diff --git a/app/components/Sidebar/components/SidebarLink.tsx b/app/components/Sidebar/components/SidebarLink.tsx index 01fea9e02d..5137c86b66 100644 --- a/app/components/Sidebar/components/SidebarLink.tsx +++ b/app/components/Sidebar/components/SidebarLink.tsx @@ -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 && diff --git a/app/components/Switch.tsx b/app/components/Switch.tsx index 1b34bcd36b..fbe9257f92 100644 --- a/app/components/Switch.tsx +++ b/app/components/Switch.tsx @@ -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 & { width?: number; @@ -62,6 +63,7 @@ function Switch({ const Wrapper = styled.div` padding-bottom: 8px; + ${undraggableOnDesktop()} `; const InlineLabelText = styled(LabelText)` diff --git a/app/components/Toast.tsx b/app/components/Toast.tsx index ba440f9c5a..1ad4410a65 100644 --- a/app/components/Toast.tsx +++ b/app/components/Toast.tsx @@ -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)}; diff --git a/app/hooks/useDesktopTitlebar.ts b/app/hooks/useDesktopTitlebar.ts new file mode 100644 index 0000000000..b327f9c8eb --- /dev/null +++ b/app/hooks/useDesktopTitlebar.ts @@ -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") + ); +} diff --git a/app/index.tsx b/app/index.tsx index 172338e395..a9ac001288 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -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) { + diff --git a/app/routes/index.tsx b/app/routes/index.tsx index f6069d93d4..ca18945e3a 100644 --- a/app/routes/index.tsx +++ b/app/routes/index.tsx @@ -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() { + diff --git a/app/scenes/DesktopRedirect.tsx b/app/scenes/DesktopRedirect.tsx new file mode 100644 index 0000000000..25a82aaadf --- /dev/null +++ b/app/scenes/DesktopRedirect.tsx @@ -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 ( + + + {t("Signing in")}… + + {t( + "You can safely close this window once the Outline desktop app has opened" + )} + . + + + ); +}; + +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; diff --git a/app/scenes/Login/AuthenticationProvider.tsx b/app/scenes/Login/AuthenticationProvider.tsx index b5bb854ad0..9456011ff9 100644 --- a/app/scenes/Login/AuthenticationProvider.tsx +++ b/app/scenes/Login/AuthenticationProvider.tsx @@ -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; diff --git a/app/scenes/Login/index.tsx b/app/scenes/Login/index.tsx index c4b408425a..9cfb299a5e 100644 --- a/app/scenes/Login/index.tsx +++ b/app/scenes/Login/index.tsx @@ -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` diff --git a/app/styles/index.ts b/app/styles/index.ts index d872d52444..5a8245cb88 100644 --- a/app/styles/index.ts +++ b/app/styles/index.ts @@ -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; } + `; +}; diff --git a/app/typings/window.d.ts b/app/typings/window.d.ts new file mode 100644 index 0000000000..3c31e42e68 --- /dev/null +++ b/app/typings/window.d.ts @@ -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; + + /** + * Restarts the application. + */ + restart: () => Promise; + + /** + * Restarts the application and installs the update. + */ + restartAndInstall: () => Promise; + + /** + * Tells the updater to check for updates now. + */ + checkForUpdates: () => Promise; + + /** + * Passes double click events from titlebar area + */ + onTitlebarDoubleClick: () => Promise; + + /** + * Adds a custom host to config + */ + addCustomHost: (host: string) => Promise; + + /** + * Set the language used by the spellchecker on Windows/Linux. + */ + setSpellCheckerLanguages: (languages: string[]) => Promise; + + /** + * 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 {}; diff --git a/app/utils/Desktop.ts b/app/utils/Desktop.ts new file mode 100644 index 0000000000..e0dd608164 --- /dev/null +++ b/app/utils/Desktop.ts @@ -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; +} diff --git a/app/utils/browser.ts b/app/utils/browser.ts index fe60c4a51a..ac642149ac 100644 --- a/app/utils/browser.ts +++ b/app/utils/browser.ts @@ -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; diff --git a/app/utils/language.ts b/app/utils/language.ts index de9bcc942f..c7078542aa 100644 --- a/app/utils/language.ts +++ b/app/utils/language.ts @@ -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]); } } diff --git a/server/emails/templates/SigninEmail.tsx b/server/emails/templates/SigninEmail.tsx index e49a2857ff..4cfb921788 100644 --- a/server/emails/templates/SigninEmail.tsx +++ b/server/emails/templates/SigninEmail.tsx @@ -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 { 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}

Click the button below to sign in to Outline.

- +

@@ -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}`; } } diff --git a/server/middlewares/passport.ts b/server/middlewares/passport.ts index 6b17e2a667..6e94e255ec 100644 --- a/server/middlewares/passport.ts +++ b/server/middlewares/passport.ts @@ -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); }; diff --git a/server/models/Team.ts b/server/models/Team.ts index 066e08ceef..3b1f3af387 100644 --- a/server/models/Team.ts +++ b/server/models/Team.ts @@ -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(/\/$/, ""); } diff --git a/server/models/User.ts b/server/models/User.ts index 7c6b79ce49..0754d904e9 100644 --- a/server/models/User.ts +++ b/server/models/User.ts @@ -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 diff --git a/server/routes/auth/providers/azure.ts b/server/routes/auth/providers/azure.ts index 18649906ce..1a940ce564 100644 --- a/server/routes/auth/providers/azure.ts +++ b/server/routes/auth/providers/azure.ts @@ -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); } diff --git a/server/routes/auth/providers/email.ts b/server/routes/auth/providers/email.ts index 53e8a44219..a2a96fe59c 100644 --- a/server/routes/auth/providers/email.ts +++ b/server/routes/auth/providers/email.ts @@ -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; diff --git a/server/routes/auth/providers/google.ts b/server/routes/auth/providers/google.ts index 4f9cd23ded..7cd63b1414 100644 --- a/server/routes/auth/providers/google.ts +++ b/server/routes/auth/providers/google.ts @@ -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); } diff --git a/server/routes/auth/providers/oidc.ts b/server/routes/auth/providers/oidc.ts index 8b8e159246..dabcaebf30 100644 --- a/server/routes/auth/providers/oidc.ts +++ b/server/routes/auth/providers/oidc.ts @@ -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); } diff --git a/server/routes/auth/providers/slack.ts b/server/routes/auth/providers/slack.ts index 7f4976aa3d..b28c1227d1 100644 --- a/server/routes/auth/providers/slack.ts +++ b/server/routes/auth/providers/slack.ts @@ -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); } diff --git a/server/types.ts b/server/types.ts index 1c8be0b242..bb5c7d937d 100644 --- a/server/types.ts +++ b/server/types.ts @@ -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; diff --git a/server/utils/authentication.ts b/server/utils/authentication.ts index 80bde37c7a..9a01cb26e9 100644 --- a/server/utils/authentication.ts +++ b/server/utils/authentication.ts @@ -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}` diff --git a/server/utils/passport.ts b/server/utils/passport.ts index 897e320811..7d9e46c847 100644 --- a/server/utils/passport.ts +++ b/server/utils/passport.ts @@ -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; diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 217144dc73..1df51dad61 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -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?", diff --git a/shared/types.ts b/shared/types.ts index 7954137a6e..c9ee1bf1fb 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -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;