Files
outline/app/scenes/Login/index.tsx

421 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import find from "lodash/find";
import { observer } from "mobx-react";
import { BackIcon, EmailIcon } from "outline-icons";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { useLocation, Link, Redirect } from "react-router-dom";
import styled from "styled-components";
import { getCookie, setCookie } from "tiny-cookie";
import { s } from "@shared/styles";
import { UserPreference } from "@shared/types";
import { parseDomain } from "@shared/utils/domains";
import { Config } from "~/stores/AuthStore";
import ButtonLarge from "~/components/ButtonLarge";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
import OutlineIcon from "~/components/Icons/OutlineIcon";
import Input from "~/components/Input";
import LoadingIndicator from "~/components/LoadingIndicator";
import PageTitle from "~/components/PageTitle";
import TeamLogo from "~/components/TeamLogo";
import Text from "~/components/Text";
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";
import Notices from "./Notices";
function Header({ config }: { config?: Config | undefined }) {
const { t } = useTranslation();
const isSubdomain = !!config?.hostname;
if (!isCloudHosted || parseDomain(window.location.origin).custom) {
return null;
}
if (Desktop.isElectron() && !isSubdomain) {
return null;
}
return (
<Back href={isSubdomain ? env.URL : "https://www.getoutline.com"}>
<BackIcon /> {Desktop.isElectron() ? t("Back") : t("Back to home")}
</Back>
);
}
type Props = {
children?: (config?: Config) => React.ReactNode;
};
function Login({ children }: Props) {
const location = useLocation();
const query = useQuery();
const notice = query.get("notice");
const { t, i18n } = useTranslation();
const { auth } = useStores();
const { config } = auth;
const [error, setError] = React.useState(null);
const [emailLinkSentTo, setEmailLinkSentTo] = React.useState("");
const isCreate = location.pathname === "/create";
const rememberLastPath = !!auth.user?.getPreference(
UserPreference.RememberLastPath
);
const [lastVisitedPath] = useLastVisitedPath();
const handleReset = React.useCallback(() => {
setEmailLinkSentTo("");
}, []);
const handleEmailSuccess = React.useCallback((email) => {
setEmailLinkSentTo(email);
}, []);
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);
}, []);
React.useEffect(() => {
auth.fetchConfig().catch(setError);
}, [auth]);
// TODO: Persist detected language to new user
// Try to detect the user's language and show the login page on its idiom
// if translation is available
React.useEffect(() => {
void changeLanguage(detectLanguage(), i18n);
}, [i18n]);
React.useEffect(() => {
const entries = Object.fromEntries(query.entries());
const existing = getCookie("signupQueryParams");
// We don't want to set this cookie if we're viewing an error notice via
// query string(notice =), if there are no query params, or it's already set
if (Object.keys(entries).length && !query.get("notice") && !existing) {
setCookie("signupQueryParams", JSON.stringify(entries));
}
}, [query]);
if (
auth.authenticated &&
rememberLastPath &&
lastVisitedPath !== location.pathname
) {
return <Redirect to={lastVisitedPath} />;
}
if (auth.authenticated && auth.team?.defaultCollectionId) {
return <Redirect to={`/collection/${auth.team?.defaultCollectionId}`} />;
}
if (auth.authenticated) {
return <Redirect to="/home" />;
}
if (error) {
return (
<Background>
<Header />
<Centered align="center" justify="center" column auto>
<PageTitle title={t("Login")} />
<Heading centered>{t("Error")}</Heading>
<Note>
{t("Failed to load configuration.")}
{!isCloudHosted && (
<p>
{t(
"Check the network requests and server logs for full details of the error."
)}
</p>
)}
</Note>
</Centered>
</Background>
);
}
// we're counting on the config request being fast, so just a simple loading
// indicator here that's delayed by 250ms
if (!config) {
return <LoadingIndicator />;
}
const isCustomDomain = parseDomain(window.location.origin).custom;
// Unmapped custom domain
if (isCloudHosted && isCustomDomain && !config.name) {
return (
<Background>
<Header config={config} />
<Centered align="center" justify="center" column auto>
<PageTitle title={t("Custom domain setup")} />
<Heading centered>{t("Almost there")}</Heading>
<Note>
{t(
"Your custom domain is successfully pointing at Outline. To complete the setup process please contact support."
)}
</Note>
</Centered>
</Background>
);
}
if (Desktop.isElectron() && notice === "domain-required") {
return (
<Background>
<Header config={config} />
<Centered
as="form"
onSubmit={handleGoSubdomain}
align="center"
justify="center"
column
auto
>
<Heading centered>{t("Choose workspace")}</Heading>
<Note>
{t(
"This login method requires choosing your workspace to continue"
)}
</Note>
<Flex>
<Input
name="subdomain"
style={{ textAlign: "right" }}
placeholder={t("subdomain")}
>
<Domain>.getoutline.com</Domain>
</Input>
</Flex>
<ButtonLarge type="submit" fullwidth>
{t("Continue")}
</ButtonLarge>
</Centered>
</Background>
);
}
const hasMultipleProviders = config.providers.length > 1;
const defaultProvider = find(
config.providers,
(provider) => provider.id === auth.lastSignedIn && !isCreate
);
if (emailLinkSentTo) {
return (
<Background>
<Header config={config} />
<Centered align="center" justify="center" column auto>
<PageTitle title={t("Check your email")} />
<CheckEmailIcon size={38} />
<Heading centered>{t("Check your email")}</Heading>
<Note>
<Trans
defaults="A magic sign-in link has been sent to the email <em>{{ emailLinkSentTo }}</em> if an account exists."
values={{ emailLinkSentTo }}
components={{ em: <em /> }}
/>
</Note>
<br />
<ButtonLarge onClick={handleReset} fullwidth neutral>
{t("Back to login")}
</ButtonLarge>
</Centered>
</Background>
);
}
return (
<Background>
<Header config={config} />
<Centered align="center" justify="center" gap={12} column auto>
<PageTitle
title={config.name ? `${config.name} ${t("Login")}` : t("Login")}
/>
<Logo>
{config.logo && !isCreate ? (
<TeamLogo size={48} src={config.logo} />
) : (
<OutlineIcon size={48} />
)}
</Logo>
{isCreate ? (
<>
<StyledHeading as="h2" centered>
{t("Create a workspace")}
</StyledHeading>
<Content>
{t(
"Get started by choosing a sign-in method for your new workspace below…"
)}
</Content>
</>
) : (
<>
<StyledHeading as="h2" centered>
{t("Login to {{ authProviderName }}", {
authProviderName: config.name || env.APP_NAME,
})}
</StyledHeading>
{children?.(config)}
</>
)}
<Notices />
{defaultProvider && (
<React.Fragment key={defaultProvider.id}>
<AuthenticationProvider
isCreate={isCreate}
onEmailSuccess={handleEmailSuccess}
{...defaultProvider}
/>
{hasMultipleProviders && (
<>
<Note>
{t("You signed in with {{ authProviderName }} last time.", {
authProviderName: defaultProvider.name,
})}
</Note>
<Or data-text={t("Or")} />
</>
)}
</React.Fragment>
)}
{config.providers.map((provider) => {
if (defaultProvider && provider.id === defaultProvider.id) {
return null;
}
return (
<AuthenticationProvider
key={provider.id}
isCreate={isCreate}
onEmailSuccess={handleEmailSuccess}
{...provider}
/>
);
})}
{isCreate && (
<Note>
<Trans>
Already have an account? Go to <Link to="/">login</Link>.
</Trans>
</Note>
)}
</Centered>
</Background>
);
}
const StyledHeading = styled(Heading)`
margin: 0;
`;
const Domain = styled.div`
color: ${s("textSecondary")};
padding: 0 8px 0 0;
`;
const CheckEmailIcon = styled(EmailIcon)`
margin-bottom: -1.5em;
`;
const Background = styled(Fade)`
width: 100vw;
height: 100%;
background: ${s("background")};
display: flex;
${draggableOnDesktop()}
`;
const Logo = styled.div`
margin-bottom: -4px;
`;
const Content = styled(Text)`
color: ${s("textSecondary")};
text-align: center;
margin-top: -8px;
`;
const Note = styled(Text)`
color: ${s("textTertiary")};
text-align: center;
font-size: 14px;
margin-top: 8px;
em {
font-style: normal;
font-weight: 500;
}
`;
const Back = styled.a`
display: flex;
align-items: center;
color: inherit;
padding: ${Desktop.isElectron() ? "48px 32px" : "32px"};
font-weight: 500;
position: absolute;
svg {
transition: transform 100ms ease-in-out;
}
&:hover {
svg {
transform: translateX(-4px);
}
}
`;
const Or = styled.hr`
margin: 1em 0;
position: relative;
width: 100%;
&:after {
content: attr(data-text);
display: block;
position: absolute;
left: 50%;
transform: translate3d(-50%, -50%, 0);
text-transform: uppercase;
font-size: 11px;
color: ${s("textSecondary")};
background: ${s("background")};
border-radius: 2px;
padding: 0 4px;
}
`;
const Centered = styled(Flex)`
user-select: none;
width: 90vw;
height: 100%;
max-width: 320px;
margin: 0 auto;
`;
export default observer(Login);