Files
outline/app/scenes/Login/index.tsx
2023-04-07 19:43:34 -07:00

357 lines
9.7 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";
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 { 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 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 ||
Desktop.isElectron()
) {
return null;
}
return (
<Back href={isSubdomain ? env.URL : "https://www.getoutline.com"}>
<BackIcon color="currentColor" /> {t("Back to home")}
</Back>
);
}
type Props = {
children?: (config?: Config) => React.ReactNode;
};
function Login({ children }: Props) {
const location = useLocation();
const query = useQuery();
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?.preferences?.rememberLastPath;
const [lastVisitedPath] = useLastVisitedPath();
const handleReset = React.useCallback(() => {
setEmailLinkSentTo("");
}, []);
const handleEmailSuccess = React.useCallback((email) => {
setEmailLinkSentTo(email);
}, []);
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(() => {
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>
);
}
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} color="currentColor" />
<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 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: 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);