Files
outline/app/scenes/Settings/Security.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

369 lines
10 KiB
TypeScript

import { debounce } from "es-toolkit/compat";
import { observer } from "mobx-react";
import { ShieldIcon } from "outline-icons";
import { useState } from "react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import { TeamPreference, EmailDisplay } from "@shared/types";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Heading from "~/components/Heading";
import type { Option } from "~/components/InputSelect";
import { InputSelect } from "~/components/InputSelect";
import Scene from "~/components/Scene";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
import isCloudHosted from "~/utils/isCloudHosted";
import SettingRow from "./components/SettingRow";
function Security() {
const { dialogs } = useStores();
const team = useCurrentTeam();
const { t } = useTranslation();
const [data, setData] = useState({
sharing: team.sharing,
defaultUserRole: team.defaultUserRole,
memberCollectionCreate: team.memberCollectionCreate,
memberTeamCreate: team.memberTeamCreate,
inviteRequired: team.inviteRequired,
passkeysEnabled: team.passkeysEnabled,
});
const userRoleOptions: Option[] = React.useMemo(
() =>
[
{
type: "item",
label: t("Editor"),
value: "member",
},
{
type: "item",
label: t("Viewer"),
value: "viewer",
},
] satisfies Option[],
[t]
);
const emailDisplayOptions: Option[] = React.useMemo(
() =>
[
{
type: "item",
label: t("Members"),
value: EmailDisplay.Members,
},
{
type: "item",
label: t("Members and guests"),
value: EmailDisplay.Everyone,
},
{
type: "item",
label: t("No one"),
value: EmailDisplay.None,
},
] satisfies Option[],
[t]
);
const showSuccessMessage = React.useMemo(
() =>
debounce(() => {
toast.success(t("Settings saved"));
}, 250),
[t]
);
const saveData = React.useCallback(
async (newData) => {
try {
setData((prev) => ({ ...prev, ...newData }));
await team.save(newData);
showSuccessMessage();
} catch (err) {
toast.error(err.message);
}
},
[team, showSuccessMessage]
);
const handleDefaultRoleChange = React.useCallback(
async (newDefaultRole: string) => {
await saveData({ defaultUserRole: newDefaultRole });
},
[saveData]
);
const handleSharingChange = React.useCallback(
async (checked: boolean) => {
await saveData({ sharing: checked });
},
[saveData]
);
const handlePasskeysEnabledChange = React.useCallback(
async (checked: boolean) => {
await saveData({ passkeysEnabled: checked });
},
[saveData]
);
const handleMemberCollectionCreateChange = React.useCallback(
async (checked: boolean) => {
await saveData({ memberCollectionCreate: checked });
},
[saveData]
);
const handleMemberTeamCreateChange = React.useCallback(
async (checked: boolean) => {
await saveData({ memberTeamCreate: checked });
},
[saveData]
);
const handleMembersCanInviteChange = React.useCallback(
async (checked: boolean) => {
const preferences = {
...team.preferences,
[TeamPreference.MembersCanInvite]: checked,
};
await saveData({ preferences });
},
[saveData, team.preferences]
);
const handleViewersCanExportChange = React.useCallback(
async (checked: boolean) => {
const preferences = {
...team.preferences,
[TeamPreference.ViewersCanExport]: checked,
};
await saveData({ preferences });
},
[saveData, team.preferences]
);
const handleMembersCanDeleteAccountChange = React.useCallback(
async (checked: boolean) => {
const preferences = {
...team.preferences,
[TeamPreference.MembersCanDeleteAccount]: checked,
};
await saveData({ preferences });
},
[saveData, team.preferences]
);
const handleEmailDisplayChange = React.useCallback(
async (emailDisplay: string) => {
const preferences = {
...team.preferences,
[TeamPreference.EmailDisplay]: emailDisplay,
};
await saveData({ preferences });
},
[saveData, team.preferences]
);
const handleInviteRequiredChange = React.useCallback(
async (checked: boolean) => {
const inviteRequired = checked;
const newData = { ...data, inviteRequired };
if (inviteRequired) {
dialogs.openModal({
title: t("Are you sure you want to require invites?"),
content: (
<ConfirmationDialog
onSubmit={async () => {
await saveData(newData);
}}
savingText={`${t("Saving")}`}
danger
>
<Trans
defaults="New users will first need to be invited to create an account. <em>Default role</em> and <em>Allowed domains</em> will no longer apply."
values={{
authenticationMethods: team.signinMethods,
}}
components={{
em: <strong />,
}}
/>
</ConfirmationDialog>
),
});
} else {
await saveData(newData);
}
},
[data, saveData, t, dialogs, team.signinMethods]
);
return (
<Scene title={t("Security")} icon={<ShieldIcon />}>
<Heading>{t("Security")}</Heading>
<Text as="p" type="secondary">
<Trans>
Settings that impact the access, security, and content of your
workspace.
</Trans>
</Text>
<Heading as="h2">{t("Invites")}</Heading>
<SettingRow
label={t("Allow users to send invites")}
name={TeamPreference.MembersCanInvite}
description={t("Allow editors to invite other people to the workspace")}
>
<Switch
id={TeamPreference.MembersCanInvite}
checked={team.getPreference(TeamPreference.MembersCanInvite)}
onChange={handleMembersCanInviteChange}
/>
</SettingRow>
{isCloudHosted && (
<SettingRow
label={t("Require invites")}
name="inviteRequired"
description={t(
"Require members to be invited to the workspace before they can create an account using SSO."
)}
>
<Switch
id="inviteRequired"
checked={data.inviteRequired}
onChange={handleInviteRequiredChange}
/>
</SettingRow>
)}
{!data.inviteRequired && (
<SettingRow
label={t("Default role")}
name="defaultUserRole"
description={t(
"The default user role for new accounts. Changing this setting does not affect existing user accounts."
)}
border={false}
>
<InputSelect
value={data.defaultUserRole}
options={userRoleOptions}
onChange={handleDefaultRoleChange}
label={t("Default role")}
labelHidden
short
/>
</SettingRow>
)}
<Heading as="h2">{t("Authentication")}</Heading>
<SettingRow
label={t("Passkeys")}
name="passkeysEnabled"
description={t(
"Allow users to sign in with passkeys for passwordless authentication"
)}
>
<Switch
id="passkeysEnabled"
checked={data.passkeysEnabled}
onChange={handlePasskeysEnabledChange}
/>
</SettingRow>
<Heading as="h2">{t("Behavior")}</Heading>
<SettingRow
label={t("Public document sharing")}
name="sharing"
description={t(
"When enabled, documents can be shared publicly on the internet by any member of the workspace"
)}
>
<Switch
id="sharing"
checked={data.sharing}
onChange={handleSharingChange}
/>
</SettingRow>
<SettingRow
label={t("Viewer document exports")}
name={TeamPreference.ViewersCanExport}
description={t(
"When enabled, viewers can see download options for documents"
)}
>
<Switch
id={TeamPreference.ViewersCanExport}
checked={team.getPreference(TeamPreference.ViewersCanExport)}
onChange={handleViewersCanExportChange}
/>
</SettingRow>
<SettingRow
label={t("Users can delete account")}
name={TeamPreference.MembersCanDeleteAccount}
description={t(
"When enabled, users can delete their own account from the workspace"
)}
>
<Switch
id={TeamPreference.MembersCanDeleteAccount}
checked={team.getPreference(TeamPreference.MembersCanDeleteAccount)}
onChange={handleMembersCanDeleteAccountChange}
/>
</SettingRow>
<SettingRow
label={t("Email address visibility")}
name={TeamPreference.EmailDisplay}
description={t(
"Controls who can see user email addresses in the workspace"
)}
>
<InputSelect
value={team.getPreference(TeamPreference.EmailDisplay) as string}
options={emailDisplayOptions}
onChange={handleEmailDisplayChange}
label={t("Email address visibility")}
labelHidden
short
/>
</SettingRow>
<SettingRow
label={t("Collection creation")}
name="memberCollectionCreate"
description={t(
"Allow editors to create new collections within the workspace"
)}
>
<Switch
id="memberCollectionCreate"
checked={data.memberCollectionCreate}
onChange={handleMemberCollectionCreateChange}
/>
</SettingRow>
{isCloudHosted && (
<SettingRow
label={t("Workspace creation")}
name="memberTeamCreate"
description={t("Allow editors to create new workspaces")}
>
<Switch
id="memberTeamCreate"
checked={data.memberTeamCreate}
onChange={handleMemberTeamCreateChange}
/>
</SettingRow>
)}
</Scene>
);
}
export default observer(Security);