diff --git a/app/actions/definitions/teams.tsx b/app/actions/definitions/teams.tsx index a4662b489..a9847551d 100644 --- a/app/actions/definitions/teams.tsx +++ b/app/actions/definitions/teams.tsx @@ -1,12 +1,14 @@ -import { PlusIcon } from "outline-icons"; +import { ArrowIcon, PlusIcon } from "outline-icons"; import * as React from "react"; import styled from "styled-components"; import { stringToColor } from "@shared/utils/color"; import RootStore from "~/stores/RootStore"; +import { LoginDialog } from "~/scenes/Login/components/LoginDialog"; import TeamNew from "~/scenes/TeamNew"; import TeamLogo from "~/components/TeamLogo"; import { createAction } from "~/actions"; import { ActionContext } from "~/types"; +import Desktop from "~/utils/Desktop"; import { TeamSection } from "../sections"; export const createTeamsList = ({ stores }: { stores: RootStore }) => @@ -66,9 +68,27 @@ export const createTeam = createAction({ }, }); +export const desktopLoginTeam = createAction({ + name: ({ t }) => t("Login to workspace"), + analyticsName: "Login to workspace", + keywords: "change switch workspace organization team", + section: TeamSection, + icon: , + visible: () => Desktop.isElectron(), + perform: ({ t, event, stores }) => { + event?.preventDefault(); + event?.stopPropagation(); + + stores.dialogs.openModal({ + title: t("Login to workspace"), + content: , + }); + }, +}); + const StyledTeamLogo = styled(TeamLogo)` border-radius: 2px; border: 0; `; -export const rootTeamActions = [switchTeam, createTeam]; +export const rootTeamActions = [switchTeam, createTeam, desktopLoginTeam]; diff --git a/app/menus/OrganizationMenu.tsx b/app/menus/OrganizationMenu.tsx index d20e67807..b487003cc 100644 --- a/app/menus/OrganizationMenu.tsx +++ b/app/menus/OrganizationMenu.tsx @@ -5,7 +5,11 @@ import { MenuButton, useMenuState } from "reakit/Menu"; import ContextMenu from "~/components/ContextMenu"; import Template from "~/components/ContextMenu/Template"; import { navigateToSettings, logout } from "~/actions/definitions/navigation"; -import { createTeam, createTeamsList } from "~/actions/definitions/teams"; +import { + createTeam, + createTeamsList, + desktopLoginTeam, +} from "~/actions/definitions/teams"; import useActionContext from "~/hooks/useActionContext"; import usePrevious from "~/hooks/usePrevious"; import useStores from "~/hooks/useStores"; @@ -39,6 +43,7 @@ const OrganizationMenu: React.FC = ({ children }: Props) => { () => [ ...createTeamsList(context), createTeam, + desktopLoginTeam, separator(), navigateToSettings, logout, diff --git a/app/scenes/Login/components/AuthenticationProvider.tsx b/app/scenes/Login/components/AuthenticationProvider.tsx index 5d564453d..0eede84bd 100644 --- a/app/scenes/Login/components/AuthenticationProvider.tsx +++ b/app/scenes/Login/components/AuthenticationProvider.tsx @@ -7,7 +7,7 @@ import InputLarge from "~/components/InputLarge"; import PluginIcon from "~/components/PluginIcon"; import { client } from "~/utils/ApiClient"; import Desktop from "~/utils/Desktop"; -import { getRedirectUrl } from "../getRedirectUrl"; +import { getRedirectUrl } from "../urls"; type Props = { id: string; diff --git a/app/scenes/Login/components/LoginDialog.tsx b/app/scenes/Login/components/LoginDialog.tsx new file mode 100644 index 000000000..c8a5b0a7f --- /dev/null +++ b/app/scenes/Login/components/LoginDialog.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import styled from "styled-components"; +import { s } from "@shared/styles"; +import ButtonLarge from "~/components/ButtonLarge"; +import Input from "~/components/Input"; +import Text from "~/components/Text"; +import { navigateToSubdomain } from "../urls"; + +type FormData = { + subdomain: string; +}; + +export function LoginDialog() { + const { t } = useTranslation(); + + const handleSubmit = async (data: FormData) => { + try { + await navigateToSubdomain(data.subdomain); + } catch { + toast.error(t("The workspace could not be found")); + } + }; + + const { register, handleSubmit: formHandleSubmit } = useForm({ + mode: "all", + defaultValues: { + subdomain: "", + }, + }); + + return ( +
+ {t("To continue, enter your workspace’s subdomain.")} + + .getoutline.com + + + {t("Continue")} + +
+ ); +} + +const Domain = styled.div` + color: ${s("textSecondary")}; + padding: 0 8px 0 0; +`; diff --git a/app/scenes/Login/index.tsx b/app/scenes/Login/index.tsx index c01ad845a..2f486c827 100644 --- a/app/scenes/Login/index.tsx +++ b/app/scenes/Login/index.tsx @@ -33,7 +33,7 @@ import { detectLanguage } from "~/utils/language"; import AuthenticationProvider from "./components/AuthenticationProvider"; import BackButton from "./components/BackButton"; import Notices from "./components/Notices"; -import { getRedirectUrl } from "./getRedirectUrl"; +import { getRedirectUrl, navigateToSubdomain } from "./urls"; type Props = { children?: (config?: Config) => React.ReactNode; @@ -66,17 +66,7 @@ function Login({ children }: Props) { const handleGoSubdomain = React.useCallback(async (event) => { event.preventDefault(); const data = Object.fromEntries(new FormData(event.target)); - const normalizedSubdomain = data.subdomain - .toString() - .toLowerCase() - .trim() - .replace(/^https?:\/\//, ""); - const host = `https://${normalizedSubdomain}.getoutline.com`; - await Desktop.bridge.addCustomHost(host); - - setTimeout(() => { - window.location.href = host; - }, 500); + await navigateToSubdomain(data.subdomain.toString()); }, []); React.useEffect(() => { @@ -186,6 +176,8 @@ function Login({ children }: Props) { name="subdomain" style={{ textAlign: "right" }} placeholder={t("subdomain")} + pattern="^[a-z\d-]+$" + required > .getoutline.com diff --git a/app/scenes/Login/getRedirectUrl.ts b/app/scenes/Login/urls.ts similarity index 61% rename from app/scenes/Login/getRedirectUrl.ts rename to app/scenes/Login/urls.ts index e5039a956..c4d7e33e4 100644 --- a/app/scenes/Login/getRedirectUrl.ts +++ b/app/scenes/Login/urls.ts @@ -8,6 +8,8 @@ import Desktop from "~/utils/Desktop"; * apex (env.URL) for authentication so that the state cookie can be set and read. * We pass the host into the auth URL so that the server can redirect on error * and keep the user on the same page. + * + * @param authUrl The URL to redirect to after authentication */ export function getRedirectUrl(authUrl: string) { const { custom, teamSubdomain, host } = parseDomain(window.location.origin); @@ -23,3 +25,18 @@ export function getRedirectUrl(authUrl: string) { return url.toString(); } + +/** + * Redirect to a subdomain, adding it to the custom hosts list on desktop first. + * + * @param subdomain The subdomain to navigate to + */ +export async function navigateToSubdomain(subdomain: string) { + const normalizedSubdomain = subdomain + .toLowerCase() + .trim() + .replace(/^https?:\/\//, ""); + const host = `https://${normalizedSubdomain}.getoutline.com`; + await Desktop.bridge.addCustomHost(host); + window.location.href = host; +} diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index a5a169ccd..fd7140fd3 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -102,6 +102,7 @@ "Select a workspace": "Select a workspace", "New workspace": "New workspace", "Create a workspace": "Create a workspace", + "Login to workspace": "Login to workspace", "Invite people": "Invite people", "Invite to workspace": "Invite to workspace", "Promote to {{ role }}": "Promote to {{ role }}", @@ -710,6 +711,9 @@ "Continue with Email": "Continue with Email", "Continue with {{ authProviderName }}": "Continue with {{ authProviderName }}", "Back to home": "Back to home", + "The workspace could not be found": "The workspace could not be found", + "To continue, enter your workspace’s subdomain.": "To continue, enter your workspace’s subdomain.", + "subdomain": "subdomain", "The domain associated with your email address has not been allowed for this workspace.": "The domain associated with your email address has not been allowed for this workspace.", "Unable to sign-in. Please navigate to your workspace's custom URL, then try to sign-in again.<1>If you were invited to a workspace, you will find a link to it in the invite email.": "Unable to sign-in. Please navigate to your workspace's custom URL, then try to sign-in again.<1>If you were invited to a workspace, you will find a link to it in the invite email.", "Sorry, a new account cannot be created with a personal Gmail address.<1>Please use a Google Workspaces account instead.": "Sorry, a new account cannot be created with a personal Gmail address.<1>Please use a Google Workspaces account instead.", @@ -735,7 +739,6 @@ "Your custom domain is successfully pointing at Outline. To complete the setup process please contact support.": "Your custom domain is successfully pointing at Outline. To complete the setup process please contact support.", "Choose workspace": "Choose workspace", "This login method requires choosing your workspace to continue": "This login method requires choosing your workspace to continue", - "subdomain": "subdomain", "Check your email": "Check your email", "A magic sign-in link has been sent to the email {{ emailLinkSentTo }} if an account exists.": "A magic sign-in link has been sent to the email {{ emailLinkSentTo }} if an account exists.", "Back to login": "Back to login",