421 lines
11 KiB
TypeScript
421 lines
11 KiB
TypeScript
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);
|