Files
outline/app/components/Sharing/Document/AccessControlList.tsx
T
Tom MoorandGitHub 08a3b64295 chore: Exhaustive deps warning -> error (#13124)
* chore: Exhaustive deps turned to error

* Refactor useDragResize

* Refactor Lightbox to explicit state machine

* feedback

* ci
2026-07-25 10:43:49 -04:00

283 lines
8.9 KiB
TypeScript

import { observer } from "mobx-react";
import { MoreIcon, QuestionMarkIcon, UserIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import { s } from "@shared/styles";
import { CollectionPermission, IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import type Collection from "~/models/Collection";
import type Document from "~/models/Document";
import type Share from "~/models/Share";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import Scrollable from "~/components/Scrollable";
import Text from "~/components/Text";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import useMaxHeight from "~/hooks/useMaxHeight";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { Avatar, AvatarSize } from "../../Avatar";
import CollectionIcon from "../../Icons/CollectionIcon";
import Tooltip from "../../Tooltip";
import { Separator } from "../components";
import { ListItem } from "../components/ListItem";
import { Placeholder } from "../components/Placeholder";
import DocumentMemberList from "./DocumentMemberList";
import PublicAccess from "./PublicAccess";
type Props = {
/** The document being shared. */
document: Document;
/** List of users that have been invited during the current editing session */
invitedInSession: string[];
/** The existing share model, if any. */
share: Share | null | undefined;
/** The existing share parent model, if any. */
sharedParent: Share | null | undefined;
/** Callback fired when the popover requests to be closed. */
onRequestClose: () => void;
/** Whether the popover is visible. */
visible: boolean;
/** Whether the share data is currently loading. */
loading: boolean;
};
export const AccessControlList = observer(
({
document,
invitedInSession,
share,
sharedParent,
onRequestClose,
visible,
loading,
}: Props) => {
const { t } = useTranslation();
const theme = useTheme();
const collection = document.collection;
const usersInCollection = useUsersInCollection(collection);
const user = useCurrentUser();
const { groupMemberships } = useStores();
const collectionSharingDisabled = document.collection?.sharing === false;
const team = useCurrentTeam();
const can = usePolicy(document);
const canCollection = usePolicy(collection);
const documentId = document.id;
const containerRef = React.useRef<HTMLDivElement | null>(null);
const publicAccessRef = React.useRef<HTMLDivElement | null>(null);
const publicAccessHeight = publicAccessRef.current?.clientHeight || 0;
const { maxHeight, calcMaxHeight } = useMaxHeight({
elementRef: containerRef,
maxViewportPercentage: 45,
margin: 24,
});
const hasMemberships =
groupMemberships.inDocument(documentId)?.length > 0 ||
document.members.length > 0;
const showLoading = !hasMemberships && loading;
React.useEffect(() => {
calcMaxHeight();
});
return (
<Wrapper>
<ScrollableContainer
ref={containerRef}
hiddenScrollbars
style={{
maxHeight: maxHeight ? maxHeight - publicAccessHeight : undefined,
}}
>
{document.isDraft ? (
<>
<ListItem
image={<Avatar model={document.createdBy} />}
title={document.createdBy?.name}
actions={
<AccessTooltip content={t("Created the document")}>
{t("Can edit")}
</AccessTooltip>
}
/>
{showLoading ? (
<Placeholder />
) : (
<DocumentMemberList
document={document}
invitedInSession={invitedInSession}
/>
)}
</>
) : collection && canCollection.readDocument ? (
<>
{collection.permission ? (
<ListItem
image={
<Squircle color={theme.accent} size={AvatarSize.Medium}>
<UserIcon color={theme.accentText} size={16} />
</Squircle>
}
title={t("All members")}
subtitle={t("Everyone in the workspace")}
actions={
<AccessTooltip>
{collection?.permission === CollectionPermission.ReadWrite
? t("Can edit")
: t("Can view")}
</AccessTooltip>
}
/>
) : usersInCollection ? (
<ListItem
image={<CollectionSquircle collection={collection} />}
title={collection.name}
subtitle={t("Everyone in the collection")}
actions={<AccessTooltip>{t("Can view")}</AccessTooltip>}
/>
) : (
<ListItem
image={<Avatar model={user} />}
title={user.name}
subtitle={t("You have full access")}
actions={<AccessTooltip>{t("Can edit")}</AccessTooltip>}
/>
)}
{showLoading ? (
<Placeholder />
) : (
<DocumentMemberList
document={document}
invitedInSession={invitedInSession}
/>
)}
</>
) : (
<>
{showLoading ? (
<Placeholder />
) : (
<DocumentMemberList
document={document}
invitedInSession={invitedInSession}
/>
)}
<ListItem
image={
<Squircle color={theme.accent} size={AvatarSize.Medium}>
<MoreIcon color={theme.accentText} size={16} />
</Squircle>
}
title={t("Other people")}
subtitle={t("Other workspace members may have access")}
actions={
<AccessTooltip
content={t(
"This document may be shared with more workspace members through a parent document or collection you do not have access to"
)}
/>
}
/>
</>
)}
</ScrollableContainer>
{team.sharing && can.share && !collectionSharingDisabled && visible && (
<Sticky>
{document.members.length ? <Separator /> : null}
<PublicAccess
ref={publicAccessRef}
document={document}
share={share}
sharedParent={sharedParent}
onRequestClose={onRequestClose}
/>
</Sticky>
)}
</Wrapper>
);
}
);
const AccessTooltip = ({
children,
content,
}: {
children?: React.ReactNode;
content?: string;
}) => {
const { t } = useTranslation();
return (
<Flex align="center" gap={2}>
<Text type="secondary" size="small">
{children}
</Text>
<Tooltip content={content ?? t("Access inherited from collection")}>
<NudeButton size={18}>
<QuestionMarkIcon size={18} />
</NudeButton>
</Tooltip>
</Flex>
);
};
const CollectionSquircle = ({ collection }: { collection: Collection }) => {
const theme = useTheme();
const iconType = determineIconType(collection.icon)!;
const squircleColor =
iconType === IconType.SVG ? collection.color! : theme.slateLight;
const iconSize = iconType === IconType.SVG ? 16 : 22;
return (
<Squircle color={squircleColor} size={AvatarSize.Medium}>
<CollectionIcon
collection={collection}
color={theme.white}
size={iconSize}
/>
</Squircle>
);
};
function useUsersInCollection(collection?: Collection) {
const { users, memberships } = useStores();
const fetchMemberships = React.useCallback(
() => memberships.fetchPage({ limit: 1, id: collection!.id }),
[memberships, collection]
);
const { request } = useRequest(fetchMemberships);
React.useEffect(() => {
if (collection && !collection.permission) {
void request();
}
}, [collection, request]);
return collection
? collection.permission
? true
: users.inCollection(collection.id).length > 1
: false;
}
const Wrapper = styled(Flex)`
flex-direction: column;
`;
const Sticky = styled.div`
background: ${s("menuBackground")};
position: sticky;
bottom: 0;
`;
const ScrollableContainer = styled(Scrollable)`
padding: 12px 24px;
margin: -12px -24px;
`;