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, {
|
import ActionButton, {
|
||||||
Props as ActionButtonProps,
|
Props as ActionButtonProps,
|
||||||
} from "~/components/ActionButton";
|
} from "~/components/ActionButton";
|
||||||
|
import { undraggableOnDesktop } from "~/styles";
|
||||||
|
|
||||||
type RealProps = {
|
type RealProps = {
|
||||||
$fullwidth?: boolean;
|
$fullwidth?: boolean;
|
||||||
@@ -33,6 +34,7 @@ const RealButton = styled(ActionButton)<RealProps>`
|
|||||||
cursor: var(--pointer);
|
cursor: var(--pointer);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
appearance: none !important;
|
appearance: none !important;
|
||||||
|
${undraggableOnDesktop()}
|
||||||
|
|
||||||
${(props) =>
|
${(props) =>
|
||||||
!props.$borderOnHover &&
|
!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 useEventListener from "~/hooks/useEventListener";
|
||||||
import useMobile from "~/hooks/useMobile";
|
import useMobile from "~/hooks/useMobile";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
|
import { draggableOnDesktop, fadeOnDesktopBackgrounded } from "~/styles";
|
||||||
|
import Desktop from "~/utils/Desktop";
|
||||||
import { supportsPassiveListener } from "~/utils/browser";
|
import { supportsPassiveListener } from "~/utils/browser";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -26,6 +28,7 @@ function Header({ left, title, actions, hasSidebar }: Props) {
|
|||||||
const isMobile = useMobile();
|
const isMobile = useMobile();
|
||||||
|
|
||||||
const hasMobileSidebar = hasSidebar && isMobile;
|
const hasMobileSidebar = hasSidebar && isMobile;
|
||||||
|
const sidebarCollapsed = ui.isEditing || ui.sidebarCollapsed;
|
||||||
|
|
||||||
const passThrough = !actions && !left && !title;
|
const passThrough = !actions && !left && !title;
|
||||||
|
|
||||||
@@ -50,7 +53,12 @@ function Header({ left, title, actions, hasSidebar }: Props) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Wrapper align="center" shrink={false} $passThrough={passThrough}>
|
<Wrapper
|
||||||
|
align="center"
|
||||||
|
shrink={false}
|
||||||
|
$passThrough={passThrough}
|
||||||
|
$insetTitleAdjust={sidebarCollapsed && Desktop.hasInsetTitlebar()}
|
||||||
|
>
|
||||||
{left || hasMobileSidebar ? (
|
{left || hasMobileSidebar ? (
|
||||||
<Breadcrumbs>
|
<Breadcrumbs>
|
||||||
{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)<WrapperProps>`
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: ${depths.header};
|
z-index: ${depths.header};
|
||||||
position: sticky;
|
position: sticky;
|
||||||
@@ -120,6 +133,8 @@ const Wrapper = styled(Flex)<{ $passThrough?: boolean }>`
|
|||||||
transform: translate3d(0, 0, 0);
|
transform: translate3d(0, 0, 0);
|
||||||
min-height: 64px;
|
min-height: 64px;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
|
${draggableOnDesktop()}
|
||||||
|
${fadeOnDesktopBackgrounded()}
|
||||||
|
|
||||||
@supports (backdrop-filter: blur(20px)) {
|
@supports (backdrop-filter: blur(20px)) {
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
@@ -133,7 +148,8 @@ const Wrapper = styled(Flex)<{ $passThrough?: boolean }>`
|
|||||||
${breakpoint("tablet")`
|
${breakpoint("tablet")`
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
`};
|
${(props: WrapperProps) => props.$insetTitleAdjust && `padding-left: 64px;`}
|
||||||
|
`};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Title = styled("div")`
|
const Title = styled("div")`
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { VisuallyHidden } from "reakit/VisuallyHidden";
|
|||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import breakpoint from "styled-components-breakpoint";
|
import breakpoint from "styled-components-breakpoint";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
|
import { undraggableOnDesktop } from "~/styles";
|
||||||
|
|
||||||
const RealTextarea = styled.textarea<{ hasIcon?: boolean }>`
|
const RealTextarea = styled.textarea<{ hasIcon?: boolean }>`
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -32,6 +33,7 @@ const RealInput = styled.input<{ hasIcon?: boolean }>`
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
${undraggableOnDesktop()}
|
||||||
|
|
||||||
&:disabled,
|
&:disabled,
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
@@ -98,6 +100,9 @@ export const Outline = styled(Flex)<{
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: ${(props) => props.theme.background};
|
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`
|
export const LabelText = styled.div`
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import useMobile from "~/hooks/useMobile";
|
|||||||
import usePrevious from "~/hooks/usePrevious";
|
import usePrevious from "~/hooks/usePrevious";
|
||||||
import useUnmount from "~/hooks/useUnmount";
|
import useUnmount from "~/hooks/useUnmount";
|
||||||
import { fadeAndScaleIn } from "~/styles/animations";
|
import { fadeAndScaleIn } from "~/styles/animations";
|
||||||
|
import Desktop from "~/utils/Desktop";
|
||||||
|
|
||||||
let openModals = 0;
|
let openModals = 0;
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -222,7 +223,7 @@ const Back = styled(NudeButton)`
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
display: none;
|
display: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
top: 2rem;
|
top: ${Desktop.hasInsetTitlebar() ? "3rem" : "2rem"};
|
||||||
left: 2rem;
|
left: 2rem;
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
color: ${(props) => props.theme.text};
|
color: ${(props) => props.theme.text};
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import useCurrentUser from "~/hooks/useCurrentUser";
|
|||||||
import usePolicy from "~/hooks/usePolicy";
|
import usePolicy from "~/hooks/usePolicy";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import OrganizationMenu from "~/menus/OrganizationMenu";
|
import OrganizationMenu from "~/menus/OrganizationMenu";
|
||||||
|
import Desktop from "~/utils/Desktop";
|
||||||
import {
|
import {
|
||||||
homePath,
|
homePath,
|
||||||
draftsPath,
|
draftsPath,
|
||||||
@@ -63,7 +64,16 @@ function AppSidebar() {
|
|||||||
<HeaderButton
|
<HeaderButton
|
||||||
{...props}
|
{...props}
|
||||||
title={team.name}
|
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
|
showDisclosure
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import styled from "styled-components";
|
|||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
import Scrollable from "~/components/Scrollable";
|
import Scrollable from "~/components/Scrollable";
|
||||||
import useAuthorizedSettingsConfig from "~/hooks/useAuthorizedSettingsConfig";
|
import useAuthorizedSettingsConfig from "~/hooks/useAuthorizedSettingsConfig";
|
||||||
|
import Desktop from "~/utils/Desktop";
|
||||||
import isCloudHosted from "~/utils/isCloudHosted";
|
import isCloudHosted from "~/utils/isCloudHosted";
|
||||||
import Sidebar from "./Sidebar";
|
import Sidebar from "./Sidebar";
|
||||||
import Header from "./components/Header";
|
import Header from "./components/Header";
|
||||||
@@ -32,7 +33,7 @@ function SettingsSidebar() {
|
|||||||
title={t("Return to App")}
|
title={t("Return to App")}
|
||||||
image={<StyledBackIcon color="currentColor" />}
|
image={<StyledBackIcon color="currentColor" />}
|
||||||
onClick={returnToApp}
|
onClick={returnToApp}
|
||||||
minHeight={48}
|
minHeight={Desktop.hasInsetTitlebar() ? undefined : 48}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Flex auto column>
|
<Flex auto column>
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import useMenuContext from "~/hooks/useMenuContext";
|
|||||||
import usePrevious from "~/hooks/usePrevious";
|
import usePrevious from "~/hooks/usePrevious";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import AccountMenu from "~/menus/AccountMenu";
|
import AccountMenu from "~/menus/AccountMenu";
|
||||||
|
import { draggableOnDesktop, fadeOnDesktopBackgrounded } from "~/styles";
|
||||||
import { fadeIn } from "~/styles/animations";
|
import { fadeIn } from "~/styles/animations";
|
||||||
|
import Desktop from "~/utils/Desktop";
|
||||||
import Avatar from "../Avatar";
|
import Avatar from "../Avatar";
|
||||||
import HeaderButton, { HeaderButtonProps } from "./components/HeaderButton";
|
import HeaderButton, { HeaderButtonProps } from "./components/HeaderButton";
|
||||||
import ResizeBorder from "./components/ResizeBorder";
|
import ResizeBorder from "./components/ResizeBorder";
|
||||||
@@ -251,6 +253,9 @@ const Container = styled(Flex)<ContainerProps>`
|
|||||||
z-index: ${depths.sidebar};
|
z-index: ${depths.sidebar};
|
||||||
max-width: 70%;
|
max-width: 70%;
|
||||||
min-width: 280px;
|
min-width: 280px;
|
||||||
|
padding-top: ${Desktop.hasInsetTitlebar() ? 24 : 0}px;
|
||||||
|
${draggableOnDesktop()}
|
||||||
|
${fadeOnDesktopBackgrounded()}
|
||||||
|
|
||||||
${Positioner} {
|
${Positioner} {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -265,7 +270,9 @@ const Container = styled(Flex)<ContainerProps>`
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
transform: translateX(${(props: ContainerProps) =>
|
transform: translateX(${(props: ContainerProps) =>
|
||||||
props.$collapsed ? "calc(-100% + 16px)" : 0});
|
props.$collapsed
|
||||||
|
? `calc(-100% + ${Desktop.hasInsetTitlebar() ? 8 : 16}px)`
|
||||||
|
: 0});
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus-within {
|
&:focus-within {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import * as React from "react";
|
|||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
|
|
||||||
export type HeaderButtonProps = {
|
export type HeaderButtonProps = React.ComponentProps<typeof Wrapper> & {
|
||||||
title: React.ReactNode;
|
title: React.ReactNode;
|
||||||
image: React.ReactNode;
|
image: React.ReactNode;
|
||||||
minHeight?: number;
|
minHeight?: number;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
import { undraggableOnDesktop } from "~/styles";
|
||||||
|
|
||||||
const ResizeBorder = styled.div<{ dir?: "left" | "right" }>`
|
const ResizeBorder = styled.div<{ dir?: "left" | "right" }>`
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -8,6 +9,7 @@ const ResizeBorder = styled.div<{ dir?: "left" | "right" }>`
|
|||||||
left: ${(props) => (props.dir === "right" ? "-1px" : "auto")};
|
left: ${(props) => (props.dir === "right" ? "-1px" : "auto")};
|
||||||
width: 2px;
|
width: 2px;
|
||||||
cursor: col-resize;
|
cursor: col-resize;
|
||||||
|
${undraggableOnDesktop()}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transition-delay: 500ms;
|
transition-delay: 500ms;
|
||||||
@@ -22,6 +24,7 @@ const ResizeBorder = styled.div<{ dir?: "left" | "right" }>`
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
right: -4px;
|
right: -4px;
|
||||||
width: 10px;
|
width: 10px;
|
||||||
|
${undraggableOnDesktop()}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import styled, { useTheme, css } from "styled-components";
|
|||||||
import breakpoint from "styled-components-breakpoint";
|
import breakpoint from "styled-components-breakpoint";
|
||||||
import EventBoundary from "~/components/EventBoundary";
|
import EventBoundary from "~/components/EventBoundary";
|
||||||
import NudeButton from "~/components/NudeButton";
|
import NudeButton from "~/components/NudeButton";
|
||||||
|
import { undraggableOnDesktop } from "~/styles";
|
||||||
import { NavigationNode } from "~/types";
|
import { NavigationNode } from "~/types";
|
||||||
import Disclosure from "./Disclosure";
|
import Disclosure from "./Disclosure";
|
||||||
import NavLink, { Props as NavLinkProps } from "./NavLink";
|
import NavLink, { Props as NavLinkProps } from "./NavLink";
|
||||||
@@ -181,6 +182,7 @@ const Link = styled(NavLink)<{
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
cursor: var(--pointer);
|
cursor: var(--pointer);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
${undraggableOnDesktop()}
|
||||||
|
|
||||||
${(props) =>
|
${(props) =>
|
||||||
props.$disabled &&
|
props.$disabled &&
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import * as React from "react";
|
|||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { LabelText } from "~/components/Input";
|
import { LabelText } from "~/components/Input";
|
||||||
import Text from "~/components/Text";
|
import Text from "~/components/Text";
|
||||||
|
import { undraggableOnDesktop } from "~/styles";
|
||||||
|
|
||||||
type Props = React.HTMLAttributes<HTMLInputElement> & {
|
type Props = React.HTMLAttributes<HTMLInputElement> & {
|
||||||
width?: number;
|
width?: number;
|
||||||
@@ -62,6 +63,7 @@ function Switch({
|
|||||||
|
|
||||||
const Wrapper = styled.div`
|
const Wrapper = styled.div`
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
|
${undraggableOnDesktop()}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const InlineLabelText = styled(LabelText)`
|
const InlineLabelText = styled(LabelText)`
|
||||||
|
|||||||
@@ -69,14 +69,14 @@ function Toast({ closeAfterMs = 3000, onRequestClose, toast }: Props) {
|
|||||||
|
|
||||||
const Action = styled.span`
|
const Action = styled.span`
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 10px 12px;
|
padding: 4px 8px;
|
||||||
height: 100%;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-size: 12px;
|
|
||||||
color: ${(props) => props.theme.toastText};
|
color: ${(props) => props.theme.toastText};
|
||||||
background: ${(props) => darken(0.05, props.theme.toastBackground)};
|
background: ${(props) => darken(0.05, props.theme.toastBackground)};
|
||||||
border-top-right-radius: 5px;
|
border-radius: 4px;
|
||||||
border-bottom-right-radius: 5px;
|
margin-left: 8px;
|
||||||
|
margin-right: -4px;
|
||||||
|
font-weight: 500;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: ${(props) => darken(0.1, props.theme.toastBackground)};
|
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 Theme from "~/components/Theme";
|
||||||
import Toasts from "~/components/Toasts";
|
import Toasts from "~/components/Toasts";
|
||||||
import env from "~/env";
|
import env from "~/env";
|
||||||
|
import Desktop from "./components/DesktopEventHandler";
|
||||||
import LazyPolyfill from "./components/LazyPolyfills";
|
import LazyPolyfill from "./components/LazyPolyfills";
|
||||||
import Routes from "./routes";
|
import Routes from "./routes";
|
||||||
import Logger from "./utils/Logger";
|
import Logger from "./utils/Logger";
|
||||||
@@ -92,6 +93,7 @@ if (element) {
|
|||||||
</ScrollToTop>
|
</ScrollToTop>
|
||||||
<Toasts />
|
<Toasts />
|
||||||
<Dialogs />
|
<Dialogs />
|
||||||
|
<Desktop />
|
||||||
</>
|
</>
|
||||||
</Router>
|
</Router>
|
||||||
</LazyMotion>
|
</LazyMotion>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Switch, Redirect } from "react-router-dom";
|
import { Switch, Redirect } from "react-router-dom";
|
||||||
|
import DesktopRedirect from "~/scenes/DesktopRedirect";
|
||||||
import DelayedMount from "~/components/DelayedMount";
|
import DelayedMount from "~/components/DelayedMount";
|
||||||
import FullscreenLoading from "~/components/FullscreenLoading";
|
import FullscreenLoading from "~/components/FullscreenLoading";
|
||||||
import Route from "~/components/ProfiledRoute";
|
import Route from "~/components/ProfiledRoute";
|
||||||
@@ -54,6 +55,7 @@ export default function Routes() {
|
|||||||
<Route exact path="/" component={Login} />
|
<Route exact path="/" component={Login} />
|
||||||
<Route exact path="/create" component={Login} />
|
<Route exact path="/create" component={Login} />
|
||||||
<Route exact path="/logout" component={Logout} />
|
<Route exact path="/logout" component={Logout} />
|
||||||
|
<Route exact path="/desktop-redirect" component={DesktopRedirect} />
|
||||||
|
|
||||||
<Redirect exact from="/share/:shareId" to="/s/:shareId" />
|
<Redirect exact from="/share/:shareId" to="/s/:shareId" />
|
||||||
<Route exact path="/s/:shareId" component={SharedDocument} />
|
<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 * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
import { Client } from "@shared/types";
|
||||||
import { parseDomain } from "@shared/utils/domains";
|
import { parseDomain } from "@shared/utils/domains";
|
||||||
import AuthLogo from "~/components/AuthLogo";
|
import AuthLogo from "~/components/AuthLogo";
|
||||||
import ButtonLarge from "~/components/ButtonLarge";
|
import ButtonLarge from "~/components/ButtonLarge";
|
||||||
import InputLarge from "~/components/InputLarge";
|
import InputLarge from "~/components/InputLarge";
|
||||||
import env from "~/env";
|
import env from "~/env";
|
||||||
import { client } from "~/utils/ApiClient";
|
import { client } from "~/utils/ApiClient";
|
||||||
|
import Desktop from "~/utils/Desktop";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -39,6 +41,7 @@ function AuthenticationProvider(props: Props) {
|
|||||||
try {
|
try {
|
||||||
const response = await client.post(event.currentTarget.action, {
|
const response = await client.post(event.currentTarget.action, {
|
||||||
email,
|
email,
|
||||||
|
client: Desktop.isElectron() ? "desktop" : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.redirect) {
|
if (response.redirect) {
|
||||||
@@ -95,7 +98,9 @@ function AuthenticationProvider(props: Props) {
|
|||||||
// and keep the user on the same page.
|
// and keep the user on the same page.
|
||||||
const { custom, teamSubdomain, host } = parseDomain(window.location.origin);
|
const { custom, teamSubdomain, host } = parseDomain(window.location.origin);
|
||||||
const needsRedirect = custom || teamSubdomain;
|
const needsRedirect = custom || teamSubdomain;
|
||||||
const href = needsRedirect
|
const href = Desktop.isElectron()
|
||||||
|
? `${env.URL}${authUrl}?client=${Client.Desktop}`
|
||||||
|
: needsRedirect
|
||||||
? `${env.URL}${authUrl}?host=${encodeURI(host)}`
|
? `${env.URL}${authUrl}?host=${encodeURI(host)}`
|
||||||
: authUrl;
|
: authUrl;
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import env from "~/env";
|
|||||||
import useLastVisitedPath from "~/hooks/useLastVisitedPath";
|
import useLastVisitedPath from "~/hooks/useLastVisitedPath";
|
||||||
import useQuery from "~/hooks/useQuery";
|
import useQuery from "~/hooks/useQuery";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
|
import { draggableOnDesktop } from "~/styles";
|
||||||
|
import Desktop from "~/utils/Desktop";
|
||||||
import isCloudHosted from "~/utils/isCloudHosted";
|
import isCloudHosted from "~/utils/isCloudHosted";
|
||||||
import { changeLanguage, detectLanguage } from "~/utils/language";
|
import { changeLanguage, detectLanguage } from "~/utils/language";
|
||||||
import AuthenticationProvider from "./AuthenticationProvider";
|
import AuthenticationProvider from "./AuthenticationProvider";
|
||||||
@@ -30,7 +32,11 @@ function Header({ config }: { config?: Config | undefined }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isSubdomain = !!config?.hostname;
|
const isSubdomain = !!config?.hostname;
|
||||||
|
|
||||||
if (!isCloudHosted || parseDomain(window.location.origin).custom) {
|
if (
|
||||||
|
!isCloudHosted ||
|
||||||
|
parseDomain(window.location.origin).custom ||
|
||||||
|
Desktop.isElectron()
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,6 +280,7 @@ const Background = styled(Fade)`
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
background: ${(props) => props.theme.background};
|
background: ${(props) => props.theme.background};
|
||||||
display: flex;
|
display: flex;
|
||||||
|
${draggableOnDesktop()}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Logo = styled.div`
|
const Logo = styled.div`
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Desktop from "~/utils/Desktop";
|
||||||
import { isTouchDevice } from "~/utils/browser";
|
import { isTouchDevice } from "~/utils/browser";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -6,3 +7,34 @@ import { isTouchDevice } from "~/utils/browser";
|
|||||||
* using `&:hover {...}`.
|
* using `&:hover {...}`.
|
||||||
*/
|
*/
|
||||||
export const hover = isTouchDevice() ? "active" : "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.
|
* Returns true if the client is running on a Mac.
|
||||||
*/
|
*/
|
||||||
export function isMac(): boolean {
|
export function isMac(): boolean {
|
||||||
const SSR = typeof window === "undefined";
|
return window.navigator.platform === "MacIntel";
|
||||||
return !SSR && 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;
|
let supportsPassive = false;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { i18n } from "i18next";
|
import { i18n } from "i18next";
|
||||||
|
import Desktop from "./Desktop";
|
||||||
|
|
||||||
export function detectLanguage() {
|
export function detectLanguage() {
|
||||||
const [ln, r] = navigator.language.split("-");
|
const [ln, r] = navigator.language.split("-");
|
||||||
@@ -13,6 +14,9 @@ export function changeLanguage(
|
|||||||
if (toLanguageString && i18n.language !== toLanguageString) {
|
if (toLanguageString && i18n.language !== toLanguageString) {
|
||||||
// Languages are stored in en_US format in the database, however the
|
// Languages are stored in en_US format in the database, however the
|
||||||
// frontend translation framework (i18next) expects en-US
|
// 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 * as React from "react";
|
||||||
|
import { Client } from "@shared/types";
|
||||||
import env from "@server/env";
|
import env from "@server/env";
|
||||||
import logger from "@server/logging/Logger";
|
import logger from "@server/logging/Logger";
|
||||||
import BaseEmail from "./BaseEmail";
|
import BaseEmail from "./BaseEmail";
|
||||||
@@ -14,6 +15,7 @@ type Props = {
|
|||||||
to: string;
|
to: string;
|
||||||
token: string;
|
token: string;
|
||||||
teamUrl: 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.";
|
return "Here’s your link to signin to Outline.";
|
||||||
}
|
}
|
||||||
|
|
||||||
protected renderAsText({ token, teamUrl }: Props): string {
|
protected renderAsText({ token, teamUrl, client }: Props): string {
|
||||||
return `
|
return `
|
||||||
Use the link below to signin to Outline:
|
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
|
If your magic link expired you can request a new one from your team’s
|
||||||
signin page at: ${teamUrl}
|
signin page at: ${teamUrl}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected render({ token, teamUrl }: Props) {
|
protected render({ token, client, teamUrl }: Props) {
|
||||||
if (env.ENVIRONMENT === "development") {
|
if (env.ENVIRONMENT === "development") {
|
||||||
logger.debug("email", `Sign-In link: ${this.signinLink(token)}`);
|
logger.debug("email", `Sign-In link: ${this.signinLink(token, client)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -53,7 +55,7 @@ signin page at: ${teamUrl}
|
|||||||
<p>Click the button below to sign in to Outline.</p>
|
<p>Click the button below to sign in to Outline.</p>
|
||||||
<EmptySpace height={10} />
|
<EmptySpace height={10} />
|
||||||
<p>
|
<p>
|
||||||
<Button href={this.signinLink(token)}>Sign In</Button>
|
<Button href={this.signinLink(token, client)}>Sign In</Button>
|
||||||
</p>
|
</p>
|
||||||
<EmptySpace height={10} />
|
<EmptySpace height={10} />
|
||||||
<p>
|
<p>
|
||||||
@@ -67,7 +69,7 @@ signin page at: ${teamUrl}
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private signinLink(token: string): string {
|
private signinLink(token: string, client: Client): string {
|
||||||
return `${env.URL}/auth/email.callback?token=${token}`;
|
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 { Context } from "koa";
|
||||||
import env from "@server/env";
|
import env from "@server/env";
|
||||||
import Logger from "@server/logging/Logger";
|
import Logger from "@server/logging/Logger";
|
||||||
|
import { AuthenticationResult } from "@server/types";
|
||||||
import { signIn } from "@server/utils/authentication";
|
import { signIn } from "@server/utils/authentication";
|
||||||
import { parseState } from "@server/utils/passport";
|
import { parseState } from "@server/utils/passport";
|
||||||
import { AccountProvisionerResult } from "../commands/accountProvisioner";
|
|
||||||
|
|
||||||
export default function createMiddleware(providerName: string) {
|
export default function createMiddleware(providerName: string) {
|
||||||
return function passportMiddleware(ctx: Context) {
|
return function passportMiddleware(ctx: Context) {
|
||||||
@@ -13,7 +13,7 @@ export default function createMiddleware(providerName: string) {
|
|||||||
{
|
{
|
||||||
session: false,
|
session: false,
|
||||||
},
|
},
|
||||||
async (err, user, result: AccountProvisionerResult) => {
|
async (err, user, result: AuthenticationResult) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
Logger.error("Error during authentication", err);
|
Logger.error("Error during authentication", err);
|
||||||
|
|
||||||
@@ -66,12 +66,10 @@ export default function createMiddleware(providerName: string) {
|
|||||||
if (error && error_description) {
|
if (error && error_description) {
|
||||||
Logger.error(
|
Logger.error(
|
||||||
"Error from Azure during authentication",
|
"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(String(error_description))
|
||||||
new Error(error_description)
|
|
||||||
);
|
);
|
||||||
// Display only the descriptive message to the user, log the rest
|
// 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 = String(error_description).split("Trace ID")[0];
|
||||||
const description = error_description.split("Trace ID")[0];
|
|
||||||
return ctx.redirect(`/?notice=auth-error&description=${description}`);
|
return ctx.redirect(`/?notice=auth-error&description=${description}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,14 +77,7 @@ export default function createMiddleware(providerName: string) {
|
|||||||
return ctx.redirect("/?notice=suspended");
|
return ctx.redirect("/?notice=suspended");
|
||||||
}
|
}
|
||||||
|
|
||||||
await signIn(
|
await signIn(ctx, providerName, result);
|
||||||
ctx,
|
|
||||||
result.user,
|
|
||||||
result.team,
|
|
||||||
providerName,
|
|
||||||
result.isNewUser,
|
|
||||||
result.isNewTeam
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
)(ctx);
|
)(ctx);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -160,16 +160,17 @@ class Team extends ParanoidModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get url() {
|
get url() {
|
||||||
|
const url = new URL(env.URL);
|
||||||
|
|
||||||
// custom domain
|
// custom domain
|
||||||
if (this.domain) {
|
if (this.domain) {
|
||||||
return `https://${this.domain}`;
|
return `${url.protocol}//${this.domain}${url.port ? `:${url.port}` : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.subdomain || !env.SUBDOMAINS_ENABLED) {
|
if (!this.subdomain || !env.SUBDOMAINS_ENABLED) {
|
||||||
return env.URL;
|
return env.URL;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(env.URL);
|
|
||||||
url.host = `${this.subdomain}.${getBaseDomain()}`;
|
url.host = `${this.subdomain}.${getBaseDomain()}`;
|
||||||
return url.href.replace(/\/$/, "");
|
return url.href.replace(/\/$/, "");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import NotContainsUrl from "./validators/NotContainsUrl";
|
|||||||
export enum UserFlag {
|
export enum UserFlag {
|
||||||
InviteSent = "inviteSent",
|
InviteSent = "inviteSent",
|
||||||
InviteReminderSent = "inviteReminderSent",
|
InviteReminderSent = "inviteReminderSent",
|
||||||
|
Desktop = "desktop",
|
||||||
DesktopWeb = "desktopWeb",
|
DesktopWeb = "desktopWeb",
|
||||||
MobileWeb = "mobileWeb",
|
MobileWeb = "mobileWeb",
|
||||||
}
|
}
|
||||||
@@ -366,11 +367,12 @@ class User extends ParanoidModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Track the clients each user is using
|
// Track the clients each user is using
|
||||||
if (ctx.userAgent?.isMobile) {
|
if (ctx.userAgent?.source.includes("Outline/")) {
|
||||||
this.setFlag(UserFlag.MobileWeb);
|
this.setFlag(UserFlag.Desktop);
|
||||||
}
|
} else if (ctx.userAgent?.isDesktop) {
|
||||||
if (ctx.userAgent?.isDesktop) {
|
|
||||||
this.setFlag(UserFlag.DesktopWeb);
|
this.setFlag(UserFlag.DesktopWeb);
|
||||||
|
} else if (ctx.userAgent?.isMobile) {
|
||||||
|
this.setFlag(UserFlag.MobileWeb);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save only writes to the database if there are changes
|
// 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 Router from "koa-router";
|
||||||
import { Profile } from "passport";
|
import { Profile } from "passport";
|
||||||
import { slugifyDomain } from "@shared/utils/domains";
|
import { slugifyDomain } from "@shared/utils/domains";
|
||||||
import accountProvisioner, {
|
import accountProvisioner from "@server/commands/accountProvisioner";
|
||||||
AccountProvisionerResult,
|
|
||||||
} from "@server/commands/accountProvisioner";
|
|
||||||
import env from "@server/env";
|
import env from "@server/env";
|
||||||
import { MicrosoftGraphError } from "@server/errors";
|
import { MicrosoftGraphError } from "@server/errors";
|
||||||
import passportMiddleware from "@server/middlewares/passport";
|
import passportMiddleware from "@server/middlewares/passport";
|
||||||
import { User } from "@server/models";
|
import { User } from "@server/models";
|
||||||
|
import { AuthenticationResult } from "@server/types";
|
||||||
import {
|
import {
|
||||||
StateStore,
|
StateStore,
|
||||||
request,
|
request,
|
||||||
getTeamFromContext,
|
getTeamFromContext,
|
||||||
|
getClientFromContext,
|
||||||
} from "@server/utils/passport";
|
} from "@server/utils/passport";
|
||||||
|
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
@@ -49,7 +49,7 @@ if (env.AZURE_CLIENT_ID && env.AZURE_CLIENT_SECRET) {
|
|||||||
done: (
|
done: (
|
||||||
err: Error | null,
|
err: Error | null,
|
||||||
user: User | null,
|
user: User | null,
|
||||||
result?: AccountProvisionerResult
|
result?: AuthenticationResult
|
||||||
) => void
|
) => void
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
@@ -94,6 +94,7 @@ if (env.AZURE_CLIENT_ID && env.AZURE_CLIENT_SECRET) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const team = await getTeamFromContext(ctx);
|
const team = await getTeamFromContext(ctx);
|
||||||
|
const client = getClientFromContext(ctx);
|
||||||
|
|
||||||
const domain = email.split("@")[1];
|
const domain = email.split("@")[1];
|
||||||
const subdomain = slugifyDomain(domain);
|
const subdomain = slugifyDomain(domain);
|
||||||
@@ -124,7 +125,7 @@ if (env.AZURE_CLIENT_ID && env.AZURE_CLIENT_SECRET) {
|
|||||||
scopes,
|
scopes,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return done(null, result.user, result);
|
return done(null, result.user, { ...result, client });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return done(err, null);
|
return done(err, null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Router from "koa-router";
|
import Router from "koa-router";
|
||||||
import { find } from "lodash";
|
import { find } from "lodash";
|
||||||
|
import { Client } from "@shared/types";
|
||||||
import { parseDomain } from "@shared/utils/domains";
|
import { parseDomain } from "@shared/utils/domains";
|
||||||
import { RateLimiterStrategy } from "@server/RateLimiter";
|
import { RateLimiterStrategy } from "@server/RateLimiter";
|
||||||
import InviteAcceptedEmail from "@server/emails/templates/InviteAcceptedEmail";
|
import InviteAcceptedEmail from "@server/emails/templates/InviteAcceptedEmail";
|
||||||
@@ -26,7 +27,7 @@ router.post(
|
|||||||
errorHandling(),
|
errorHandling(),
|
||||||
rateLimiter(RateLimiterStrategy.TenPerHour),
|
rateLimiter(RateLimiterStrategy.TenPerHour),
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const { email } = ctx.request.body;
|
const { email, client } = ctx.request.body;
|
||||||
assertEmail(email, "email is required");
|
assertEmail(email, "email is required");
|
||||||
|
|
||||||
const domain = parseDomain(ctx.request.hostname);
|
const domain = parseDomain(ctx.request.hostname);
|
||||||
@@ -81,6 +82,7 @@ router.post(
|
|||||||
to: user.email,
|
to: user.email,
|
||||||
token: user.getEmailSigninToken(),
|
token: user.getEmailSigninToken(),
|
||||||
teamUrl: team.url,
|
teamUrl: team.url,
|
||||||
|
client: client === Client.Desktop ? Client.Desktop : Client.Web,
|
||||||
});
|
});
|
||||||
user.lastSigninEmailSentAt = new Date();
|
user.lastSigninEmailSentAt = new Date();
|
||||||
await user.save();
|
await user.save();
|
||||||
@@ -93,7 +95,7 @@ router.post(
|
|||||||
);
|
);
|
||||||
|
|
||||||
router.get("email.callback", async (ctx) => {
|
router.get("email.callback", async (ctx) => {
|
||||||
const { token } = ctx.request.query;
|
const { token, client } = ctx.request.query;
|
||||||
assertPresent(token, "token is required");
|
assertPresent(token, "token is required");
|
||||||
|
|
||||||
let user!: User;
|
let user!: User;
|
||||||
@@ -131,7 +133,13 @@ router.get("email.callback", async (ctx) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// set cookies on response and redirect to team subdomain
|
// 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;
|
export default router;
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ import { capitalize } from "lodash";
|
|||||||
import { Profile } from "passport";
|
import { Profile } from "passport";
|
||||||
import { Strategy as GoogleStrategy } from "passport-google-oauth2";
|
import { Strategy as GoogleStrategy } from "passport-google-oauth2";
|
||||||
import { slugifyDomain } from "@shared/utils/domains";
|
import { slugifyDomain } from "@shared/utils/domains";
|
||||||
import accountProvisioner, {
|
import accountProvisioner from "@server/commands/accountProvisioner";
|
||||||
AccountProvisionerResult,
|
|
||||||
} from "@server/commands/accountProvisioner";
|
|
||||||
import env from "@server/env";
|
import env from "@server/env";
|
||||||
import {
|
import {
|
||||||
GmailAccountCreationError,
|
GmailAccountCreationError,
|
||||||
@@ -15,7 +13,12 @@ import {
|
|||||||
} from "@server/errors";
|
} from "@server/errors";
|
||||||
import passportMiddleware from "@server/middlewares/passport";
|
import passportMiddleware from "@server/middlewares/passport";
|
||||||
import { User } from "@server/models";
|
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 router = new Router();
|
||||||
const GOOGLE = "google";
|
const GOOGLE = "google";
|
||||||
@@ -58,13 +61,14 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
|
|||||||
done: (
|
done: (
|
||||||
err: Error | null,
|
err: Error | null,
|
||||||
user: User | null,
|
user: User | null,
|
||||||
result?: AccountProvisionerResult
|
result?: AuthenticationResult
|
||||||
) => void
|
) => void
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
// "domain" is the Google Workspaces domain
|
// "domain" is the Google Workspaces domain
|
||||||
const domain = profile._json.hd;
|
const domain = profile._json.hd;
|
||||||
const team = await getTeamFromContext(ctx);
|
const team = await getTeamFromContext(ctx);
|
||||||
|
const client = getClientFromContext(ctx);
|
||||||
|
|
||||||
// No profile domain means personal gmail account
|
// No profile domain means personal gmail account
|
||||||
// No team implies the request came from the apex domain
|
// 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) {
|
} catch (err) {
|
||||||
return done(err, null);
|
return done(err, null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import Router from "koa-router";
|
|||||||
import { get } from "lodash";
|
import { get } from "lodash";
|
||||||
import { Strategy } from "passport-oauth2";
|
import { Strategy } from "passport-oauth2";
|
||||||
import { slugifyDomain } from "@shared/utils/domains";
|
import { slugifyDomain } from "@shared/utils/domains";
|
||||||
import accountProvisioner, {
|
import accountProvisioner from "@server/commands/accountProvisioner";
|
||||||
AccountProvisionerResult,
|
|
||||||
} from "@server/commands/accountProvisioner";
|
|
||||||
import env from "@server/env";
|
import env from "@server/env";
|
||||||
import {
|
import {
|
||||||
OIDCMalformedUserInfoError,
|
OIDCMalformedUserInfoError,
|
||||||
@@ -14,10 +12,12 @@ import {
|
|||||||
} from "@server/errors";
|
} from "@server/errors";
|
||||||
import passportMiddleware from "@server/middlewares/passport";
|
import passportMiddleware from "@server/middlewares/passport";
|
||||||
import { User } from "@server/models";
|
import { User } from "@server/models";
|
||||||
|
import { AuthenticationResult } from "@server/types";
|
||||||
import {
|
import {
|
||||||
StateStore,
|
StateStore,
|
||||||
request,
|
request,
|
||||||
getTeamFromContext,
|
getTeamFromContext,
|
||||||
|
getClientFromContext,
|
||||||
} from "@server/utils/passport";
|
} from "@server/utils/passport";
|
||||||
|
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
@@ -73,7 +73,7 @@ if (env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET) {
|
|||||||
done: (
|
done: (
|
||||||
err: Error | null,
|
err: Error | null,
|
||||||
user: User | null,
|
user: User | null,
|
||||||
result?: AccountProvisionerResult
|
result?: AuthenticationResult
|
||||||
) => void
|
) => void
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
@@ -83,6 +83,7 @@ if (env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const team = await getTeamFromContext(ctx);
|
const team = await getTeamFromContext(ctx);
|
||||||
|
const client = getClientFromContext(ctx);
|
||||||
|
|
||||||
const parts = profile.email.toLowerCase().split("@");
|
const parts = profile.email.toLowerCase().split("@");
|
||||||
const domain = parts.length && parts[1];
|
const domain = parts.length && parts[1];
|
||||||
@@ -123,7 +124,7 @@ if (env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET) {
|
|||||||
scopes,
|
scopes,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return done(null, result.user, result);
|
return done(null, result.user, { ...result, client });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return done(err, null);
|
return done(err, null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ import type { Context } from "koa";
|
|||||||
import Router from "koa-router";
|
import Router from "koa-router";
|
||||||
import { Profile } from "passport";
|
import { Profile } from "passport";
|
||||||
import { Strategy as SlackStrategy } from "passport-slack-oauth2";
|
import { Strategy as SlackStrategy } from "passport-slack-oauth2";
|
||||||
import accountProvisioner, {
|
import accountProvisioner from "@server/commands/accountProvisioner";
|
||||||
AccountProvisionerResult,
|
|
||||||
} from "@server/commands/accountProvisioner";
|
|
||||||
import env from "@server/env";
|
import env from "@server/env";
|
||||||
import auth from "@server/middlewares/authentication";
|
import auth from "@server/middlewares/authentication";
|
||||||
import passportMiddleware from "@server/middlewares/passport";
|
import passportMiddleware from "@server/middlewares/passport";
|
||||||
@@ -16,7 +14,12 @@ import {
|
|||||||
Team,
|
Team,
|
||||||
User,
|
User,
|
||||||
} from "@server/models";
|
} 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 * as Slack from "@server/utils/slack";
|
||||||
import { assertPresent, assertUuid } from "@server/validation";
|
import { assertPresent, assertUuid } from "@server/validation";
|
||||||
|
|
||||||
@@ -80,11 +83,13 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
|
|||||||
done: (
|
done: (
|
||||||
err: Error | null,
|
err: Error | null,
|
||||||
user: User | null,
|
user: User | null,
|
||||||
result?: AccountProvisionerResult
|
result?: AuthenticationResult
|
||||||
) => void
|
) => void
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const team = await getTeamFromContext(ctx);
|
const team = await getTeamFromContext(ctx);
|
||||||
|
const client = getClientFromContext(ctx);
|
||||||
|
|
||||||
const result = await accountProvisioner({
|
const result = await accountProvisioner({
|
||||||
ip: ctx.ip,
|
ip: ctx.ip,
|
||||||
team: {
|
team: {
|
||||||
@@ -110,7 +115,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
|
|||||||
scopes,
|
scopes,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return done(null, result.user, result);
|
return done(null, result.user, { ...result, client });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return done(err, null);
|
return done(err, null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Context } from "koa";
|
import { Context } from "koa";
|
||||||
import { RouterContext } from "koa-router";
|
import { RouterContext } from "koa-router";
|
||||||
|
import { Client } from "@shared/types";
|
||||||
|
import { AccountProvisionerResult } from "./commands/accountProvisioner";
|
||||||
import { FileOperation, Team, User } from "./models";
|
import { FileOperation, Team, User } from "./models";
|
||||||
|
|
||||||
export enum AuthenticationType {
|
export enum AuthenticationType {
|
||||||
@@ -7,6 +9,10 @@ export enum AuthenticationType {
|
|||||||
APP = "app",
|
APP = "app",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AuthenticationResult = AccountProvisionerResult & {
|
||||||
|
client: Client;
|
||||||
|
};
|
||||||
|
|
||||||
export type AuthenticatedState = {
|
export type AuthenticatedState = {
|
||||||
user: User;
|
user: User;
|
||||||
token: string;
|
token: string;
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import querystring from "querystring";
|
|||||||
import { addMonths } from "date-fns";
|
import { addMonths } from "date-fns";
|
||||||
import { Context } from "koa";
|
import { Context } from "koa";
|
||||||
import { pick } from "lodash";
|
import { pick } from "lodash";
|
||||||
|
import { Client } from "@shared/types";
|
||||||
import { getCookieDomain } from "@shared/utils/domains";
|
import { getCookieDomain } from "@shared/utils/domains";
|
||||||
import env from "@server/env";
|
import env from "@server/env";
|
||||||
import Logger from "@server/logging/Logger";
|
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
|
* 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(
|
export async function signIn(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
user: User,
|
|
||||||
team: Team,
|
|
||||||
service: string,
|
service: string,
|
||||||
_isNewUser = false,
|
{ user, team, client, isNewTeam }: AuthenticationResult
|
||||||
isNewTeam = false
|
|
||||||
) {
|
) {
|
||||||
if (user.isSuspended) {
|
if (user.isSuspended) {
|
||||||
return ctx.redirect("/?notice=suspended");
|
return ctx.redirect("/?notice=suspended");
|
||||||
@@ -74,6 +73,7 @@ export async function signIn(
|
|||||||
});
|
});
|
||||||
const domain = getCookieDomain(ctx.request.hostname);
|
const domain = getCookieDomain(ctx.request.hostname);
|
||||||
const expires = addMonths(new Date(), 3);
|
const expires = addMonths(new Date(), 3);
|
||||||
|
|
||||||
// set a cookie for which service we last signed in with. This is
|
// 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
|
// only used to display a UI hint for the user for next time
|
||||||
ctx.cookies.set("lastSignedIn", service, {
|
ctx.cookies.set("lastSignedIn", service, {
|
||||||
@@ -103,7 +103,20 @@ export async function signIn(
|
|||||||
expires,
|
expires,
|
||||||
domain,
|
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 {
|
} else {
|
||||||
ctx.cookies.set("accessToken", user.getJwtToken(), {
|
ctx.cookies.set("accessToken", user.getJwtToken(), {
|
||||||
sameSite: true,
|
sameSite: true,
|
||||||
@@ -136,6 +149,7 @@ export async function signIn(
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
const hasViewedDocuments = !!view;
|
const hasViewedDocuments = !!view;
|
||||||
|
|
||||||
ctx.redirect(
|
ctx.redirect(
|
||||||
!hasViewedDocuments && collection
|
!hasViewedDocuments && collection
|
||||||
? `${team.url}${collection.url}`
|
? `${team.url}${collection.url}`
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
StateStoreStoreCallback,
|
StateStoreStoreCallback,
|
||||||
StateStoreVerifyCallback,
|
StateStoreVerifyCallback,
|
||||||
} from "passport-oauth2";
|
} from "passport-oauth2";
|
||||||
|
import { Client } from "@shared/types";
|
||||||
import { getCookieDomain, parseDomain } from "@shared/utils/domains";
|
import { getCookieDomain, parseDomain } from "@shared/utils/domains";
|
||||||
import env from "@server/env";
|
import env from "@server/env";
|
||||||
import { Team } from "@server/models";
|
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
|
// We expect host to be a team subdomain, custom domain, or apex domain
|
||||||
// that is passed via query param from the auth provider component.
|
// 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 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, {
|
ctx.cookies.set(this.key, state, {
|
||||||
httpOnly: false,
|
httpOnly: false,
|
||||||
@@ -76,13 +79,19 @@ export async function request(endpoint: string, accessToken: string) {
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildState(host: string, token: string) {
|
function buildState(host: string, token: string, client?: Client) {
|
||||||
return [host, token].join("|");
|
return [host, token, client].join("|");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseState(state: string) {
|
export function parseState(state: string) {
|
||||||
const [host, token] = state.split("|");
|
const [host, token, client] = state.split("|");
|
||||||
return { host, token };
|
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) {
|
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
|
// we use it to infer the team they intend on signing into
|
||||||
const state = ctx.cookies.get("state");
|
const state = ctx.cookies.get("state");
|
||||||
const host = state ? parseState(state).host : ctx.hostname;
|
const host = state ? parseState(state).host : ctx.hostname;
|
||||||
|
|
||||||
const domain = parseDomain(host);
|
const domain = parseDomain(host);
|
||||||
|
|
||||||
let team;
|
let team;
|
||||||
|
|||||||
@@ -417,6 +417,8 @@
|
|||||||
"Add additional access for individual members and groups": "Add additional access for individual members and groups",
|
"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 groups to {{ collectionName }}": "Add groups to {{ collectionName }}",
|
||||||
"Add people to {{ collectionName }}": "Add people 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}}",
|
"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?",
|
"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?",
|
"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 type DateFilter = "day" | "week" | "month" | "year";
|
||||||
|
|
||||||
|
export enum Client {
|
||||||
|
Web = "web",
|
||||||
|
Desktop = "desktop",
|
||||||
|
}
|
||||||
|
|
||||||
export type PublicEnv = {
|
export type PublicEnv = {
|
||||||
URL: string;
|
URL: string;
|
||||||
CDN_URL: string;
|
CDN_URL: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user