mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
0139b91b5d
* 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.
239 lines
7.2 KiB
TypeScript
239 lines
7.2 KiB
TypeScript
import { orderBy } from "es-toolkit/compat";
|
|
import { observer } from "mobx-react";
|
|
import * as React from "react";
|
|
import { useTranslation, Trans } from "react-i18next";
|
|
import { Link, useHistory } from "react-router-dom";
|
|
import { toast } from "sonner";
|
|
import styled, { useTheme } from "styled-components";
|
|
import { s } from "@shared/styles";
|
|
import { DocumentPermission } from "@shared/types";
|
|
import type Document from "~/models/Document";
|
|
import type UserMembership from "~/models/UserMembership";
|
|
import { GroupAvatar } from "~/components/Avatar";
|
|
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
|
|
import useCurrentUser from "~/hooks/useCurrentUser";
|
|
import usePolicy from "~/hooks/usePolicy";
|
|
import useStores from "~/hooks/useStores";
|
|
import type { Permission } from "~/types";
|
|
import { EmptySelectValue } from "~/types";
|
|
import { homePath } from "~/utils/routeHelpers";
|
|
import { ListItem } from "../components/ListItem";
|
|
import { GroupMembersPopover } from "../components";
|
|
import DocumentMemberListItem from "./DocumentMemberListItem";
|
|
import ButtonLink from "~/components/ButtonLink";
|
|
|
|
type Props = {
|
|
/** Document to which team members are supposed to be invited */
|
|
document: Document;
|
|
/** Children to be rendered before the list of members */
|
|
children?: React.ReactNode;
|
|
/** List of users that have been invited during the current editing session */
|
|
invitedInSession: string[];
|
|
};
|
|
|
|
function DocumentMemberList({ document, invitedInSession }: Props) {
|
|
const { userMemberships, groupMemberships } = useStores();
|
|
|
|
const user = useCurrentUser();
|
|
const history = useHistory();
|
|
const can = usePolicy(document);
|
|
const { t } = useTranslation();
|
|
const theme = useTheme();
|
|
|
|
const handleRemoveUser = React.useCallback(
|
|
async (item) => {
|
|
try {
|
|
await userMemberships.delete({
|
|
documentId: document.id,
|
|
userId: item.id,
|
|
} as UserMembership);
|
|
|
|
if (item.id === user.id) {
|
|
history.push(homePath());
|
|
} else {
|
|
toast.success(
|
|
t(`{{ userName }} was removed from the document`, {
|
|
userName: item.name,
|
|
})
|
|
);
|
|
}
|
|
} catch (_err) {
|
|
toast.error(t("Could not remove user"));
|
|
}
|
|
},
|
|
[t, history, userMemberships, user, document]
|
|
);
|
|
|
|
const handleUpdateUser = React.useCallback(
|
|
async (userToUpdate, permission) => {
|
|
try {
|
|
await userMemberships.create({
|
|
documentId: document.id,
|
|
userId: userToUpdate.id,
|
|
permission,
|
|
});
|
|
toast.success(
|
|
t(`Permissions for {{ userName }} updated`, {
|
|
userName: userToUpdate.name,
|
|
})
|
|
);
|
|
} catch (_err) {
|
|
toast.error(t("Could not update user"));
|
|
}
|
|
},
|
|
[t, userMemberships, document]
|
|
);
|
|
|
|
// Order newly added users first during the current editing session, on reload members are
|
|
// ordered by name
|
|
const members = React.useMemo(
|
|
() =>
|
|
orderBy(
|
|
Array.from(
|
|
new Map(
|
|
document.members.map((memberUser) => [memberUser.id, memberUser])
|
|
).values()
|
|
),
|
|
(memberUser) =>
|
|
(invitedInSession.includes(memberUser.id) ? "_" : "") +
|
|
memberUser.name.toLocaleLowerCase(),
|
|
"asc"
|
|
),
|
|
[document.members, invitedInSession]
|
|
);
|
|
|
|
const permissions = React.useMemo(
|
|
() =>
|
|
[
|
|
{
|
|
label: t("View only"),
|
|
value: DocumentPermission.Read,
|
|
},
|
|
{
|
|
label: t("Can edit"),
|
|
value: DocumentPermission.ReadWrite,
|
|
},
|
|
{
|
|
label: t("Manage"),
|
|
value: DocumentPermission.Admin,
|
|
},
|
|
{
|
|
divider: true,
|
|
label: t("Remove"),
|
|
value: EmptySelectValue,
|
|
},
|
|
] as Permission[],
|
|
[t]
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{Array.from(
|
|
new Map(
|
|
groupMemberships
|
|
.inDocument(document.id)
|
|
.map((membership) => [membership.group.id, membership])
|
|
).values()
|
|
)
|
|
.sort((a, b) =>
|
|
(
|
|
(invitedInSession.includes(a.group.id) ? "_" : "") + a.group.name
|
|
).localeCompare(
|
|
(invitedInSession.includes(b.group.id) ? "_" : "") + b.group.name
|
|
)
|
|
)
|
|
.map((membership) => {
|
|
const MaybeLink = membership?.source ? StyledLink : React.Fragment;
|
|
return (
|
|
<ListItem
|
|
key={membership.id}
|
|
image={
|
|
<GroupAvatar
|
|
group={membership.group}
|
|
backgroundColor={theme.modalBackground}
|
|
/>
|
|
}
|
|
title={membership.group.name}
|
|
subtitle={
|
|
membership.sourceId ? (
|
|
<Trans>
|
|
Has access through{" "}
|
|
<MaybeLink
|
|
// @ts-expect-error to prop does not exist on React.Fragment
|
|
to={membership.source?.document?.path ?? ""}
|
|
>
|
|
parent
|
|
</MaybeLink>
|
|
</Trans>
|
|
) : (
|
|
<GroupMembersPopover group={membership.group}>
|
|
<StyledButtonLink>
|
|
{t("{{ count }} member", {
|
|
count: membership.group.memberCount,
|
|
})}
|
|
</StyledButtonLink>
|
|
</GroupMembersPopover>
|
|
)
|
|
}
|
|
actions={
|
|
<div style={{ marginRight: -8 }}>
|
|
<InputMemberPermissionSelect
|
|
permissions={permissions}
|
|
onChange={async (
|
|
permission: DocumentPermission | typeof EmptySelectValue
|
|
) => {
|
|
if (permission === EmptySelectValue) {
|
|
await groupMemberships.delete({
|
|
documentId: document.id,
|
|
groupId: membership.groupId,
|
|
});
|
|
} else {
|
|
await groupMemberships.create({
|
|
documentId: document.id,
|
|
groupId: membership.groupId,
|
|
permission,
|
|
});
|
|
}
|
|
}}
|
|
disabled={!can.manageUsers}
|
|
value={membership.permission}
|
|
/>
|
|
</div>
|
|
}
|
|
/>
|
|
);
|
|
})}
|
|
{members.map((item) => (
|
|
<DocumentMemberListItem
|
|
key={item.id}
|
|
user={item}
|
|
membership={item.getMembership(document)}
|
|
onRemove={() => handleRemoveUser(item)}
|
|
onUpdate={
|
|
can.manageUsers
|
|
? (permission) => handleUpdateUser(item, permission)
|
|
: undefined
|
|
}
|
|
onLeave={
|
|
item.id === user.id ? () => handleRemoveUser(item) : undefined
|
|
}
|
|
/>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
const StyledButtonLink = styled(ButtonLink)`
|
|
color: ${s("textTertiary")};
|
|
&:hover {
|
|
text-decoration: underline;
|
|
}
|
|
`;
|
|
|
|
const StyledLink = styled(Link)`
|
|
color: ${s("textTertiary")};
|
|
text-decoration: underline;
|
|
`;
|
|
|
|
export default observer(DocumentMemberList);
|