mirror of
https://github.com/outline/outline.git
synced 2025-12-21 10:39:41 -06:00
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:
@@ -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 &&
|
||||
|
||||
44
app/components/DesktopEventHandler.tsx
Normal file
44
app/components/DesktopEventHandler.tsx
Normal 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;
|
||||
}
|
||||
@@ -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")`
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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)`
|
||||
|
||||
@@ -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)};
|
||||
|
||||
46
app/hooks/useDesktopTitlebar.ts
Normal file
46
app/hooks/useDesktopTitlebar.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
58
app/scenes/DesktopRedirect.tsx
Normal file
58
app/scenes/DesktopRedirect.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
68
app/typings/window.d.ts
vendored
Normal 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
36
app/utils/Desktop.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user