Files
outline/app/scenes/Login/Login.tsx
T
Tom Moor 0139b91b5d chore: Replace lodash with es-toolkit (#12281)
* chore: Replace lodash with es-toolkit

Migrate all direct lodash imports to es-toolkit/compat for a smaller,
faster, lodash-compatible utility library. Transitive lodash usage from
other packages remains unchanged.

* fix: Restore isPlainObject semantics in CanCan policy

The lodash migration aliased `isObject` to `lodash/isPlainObject` and
the codemod incorrectly mapped the local name to es-toolkit's `isObject`,
which also returns true for arrays and functions. This caused condition
objects in policy definitions to be skipped, breaking authorization
checks across the codebase.

* fix: Restore unicode-aware length counting in validators

es-toolkit/compat's size() returns string.length, while lodash's _.size()
counts unicode code points. Switch to [...value].length to preserve the
previous behavior so multi-byte characters like emoji count as one.
2026-05-06 21:03:47 -04:00

434 lines
13 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 "es-toolkit/compat";
import { observer } from "mobx-react";
import { 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 { Client, UserPreference } from "@shared/types";
import { isPWA } from "@shared/utils/browser";
import { parseDomain } from "@shared/utils/domains";
import type { Config } from "~/stores/AuthStore";
import { AvatarSize } from "~/components/Avatar";
import ButtonLarge from "~/components/ButtonLarge";
import ChangeLanguage from "~/components/ChangeLanguage";
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 { OneTimePasswordInput } from "~/components/OneTimePasswordInput";
import PageTitle from "~/components/PageTitle";
import TeamLogo from "~/components/TeamLogo";
import Text from "~/components/Text";
import env from "~/env";
import useCurrentUser from "~/hooks/useCurrentUser";
import {
useLastVisitedPath,
usePostLoginPath,
} from "~/hooks/useLastVisitedPath";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import Desktop from "~/utils/Desktop";
import isCloudHosted from "~/utils/isCloudHosted";
import { detectLanguage } from "~/utils/language";
import { homePath } from "~/utils/routeHelpers";
import AuthenticationProvider from "./components/AuthenticationProvider";
import { BackButton } from "./components/BackButton";
import { Background } from "./components/Background";
import { Centered } from "./components/Centered";
import { Notices } from "./components/Notices";
import { getRedirectUrl, navigateToSubdomain } from "./urls";
import lazyWithRetry from "~/utils/lazyWithRetry";
const WorkspaceSetup = lazyWithRetry(
() => import("./components/WorkspaceSetup")
);
type Props = {
children?: (config?: Config) => React.ReactNode;
onBack?: () => void;
};
function Login({ children, onBack }: Props) {
const location = useLocation();
const query = useQuery();
const notice = query.get("notice");
const forceOTP = query.get("forceOTP");
const { t } = useTranslation();
const user = useCurrentUser({ rejectOnEmpty: false });
const { auth } = useStores();
const { config } = auth;
const [error, setError] = React.useState(null);
const [emailLinkSentTo, setEmailLinkSentTo] = React.useState("");
const isCreate = location.pathname === "/create";
const rememberLastPath = !!user?.getPreference(
UserPreference.RememberLastPath
);
const [lastVisitedPath] = useLastVisitedPath();
const [spendPostLoginPath] = usePostLoginPath();
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));
await navigateToSubdomain(data.subdomain as string);
}, []);
React.useEffect(() => {
auth.fetchConfig().catch(setError);
}, [auth]);
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) {
const postLoginPath = spendPostLoginPath();
if (postLoginPath) {
return <Redirect to={postLoginPath} />;
}
if (rememberLastPath && lastVisitedPath !== location.pathname) {
return <Redirect to={lastVisitedPath} />;
}
if (auth.team?.defaultCollectionId) {
return <Redirect to={`/collection/${auth.team?.defaultCollectionId}`} />;
}
return <Redirect to={homePath()} />;
}
if (error) {
return (
<Background>
<BackButton onBack={onBack} />
<ChangeLanguage locale={detectLanguage()} />
<Centered>
<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>
<BackButton onBack={onBack} config={config} />
<ChangeLanguage locale={detectLanguage()} />
<Centered>
<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>
<BackButton onBack={onBack} config={config} />
<ChangeLanguage locale={detectLanguage()} />
<Centered as="form" onSubmit={handleGoSubdomain}>
<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")}
pattern="^[a-z\d-]+$"
required
>
<Domain>.getoutline.com</Domain>
</Input>
</Flex>
<ButtonLarge type="submit" fullwidth>
{t("Continue")}
</ButtonLarge>
</Centered>
</Background>
);
}
const firstRun =
config.providers.length === 0 && !isCloudHosted && !config.name;
const hasMultipleProviders = config.providers.length > 1;
const defaultProvider = find(
config.providers,
(provider) => provider.id === auth.lastSignedIn && !isCreate
);
const clientType = Desktop.isElectron() ? Client.Desktop : Client.Web;
const preferOTP = isPWA || !!forceOTP;
if (firstRun) {
return (
<React.Suspense fallback={null}>
<WorkspaceSetup onBack={onBack} />
</React.Suspense>
);
}
if (emailLinkSentTo) {
return (
<Background>
<BackButton onBack={onBack} config={config} />
<Centered>
<PageTitle title={t("Check your email")} />
<CheckEmailIcon size={38} />
<Heading centered>{t("Check your email")}</Heading>
{preferOTP ? (
<>
<Note>
<Trans
defaults="Enter the sign-in code sent to the email <em>{{ emailLinkSentTo }}</em>"
values={{ emailLinkSentTo }}
components={{ em: <em /> }}
/>
.
</Note>
<Form
method="POST"
action="/auth/email.callback"
style={{ width: "100%" }}
>
<input type="hidden" name="email" value={emailLinkSentTo} />
<input type="hidden" name="client" value={clientType} />
<input type="hidden" name="follow" value="true" />
<OneTimePasswordInput name="code" />
<br />
<ButtonLarge type="submit" fullwidth>
{t("Continue")}
</ButtonLarge>
</Form>
</>
) : (
<>
<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>
);
}
// If there is only one provider and it's OIDC, redirect immediately.
if (
config.providers.length === 1 &&
config.providers[0].id === "oidc" &&
!env.OIDC_DISABLE_REDIRECT &&
!query.get("notice") &&
!query.get("logout")
) {
window.location.href = getRedirectUrl(config.providers[0].authUrl);
return null;
}
return (
<Background>
<BackButton onBack={onBack} config={config} />
<ChangeLanguage locale={detectLanguage()} />
<Centered gap={12}>
<PageTitle
title={config.name ? `${config.name} ${t("Login")}` : t("Login")}
/>
<Logo>
{config.logo && !isCreate ? (
<TeamLogo size={AvatarSize.XXLarge} src={config.logo} />
) : (
<OutlineIcon size={AvatarSize.XXLarge} />
)}
</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}
preferOTP={preferOTP}
{...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}
preferOTP={preferOTP}
neutral={defaultProvider && hasMultipleProviders}
{...provider}
/>
);
})}
{isCreate && (
<Note>
<Trans>
Already have an account? Go to <Link to="/">login</Link>.
</Trans>
</Note>
)}
</Centered>
</Background>
);
}
const Form = styled.form`
margin: 1em 0;
`;
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 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 Or = styled.hr`
margin: 1em 0;
position: relative;
width: 100%;
border: 0;
border-top: 1px solid ${s("divider")};
&:after {
content: attr(data-text);
display: block;
position: absolute;
left: 50%;
transform: translate3d(-50%, -50%, 0);
text-transform: uppercase;
font-size: 11px;
font-weight: 500;
color: ${s("textTertiary")};
background: ${s("background")};
border-radius: 2px;
padding: 0 4px;
}
`;
export default observer(Login);