diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 25848e5d..c8f979b7 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -429,6 +429,8 @@ "Public sharing is disabled at the workspace level": "Public sharing is disabled at the workspace level", "Prevent pages in this space from being shared publicly.": "Prevent pages in this space from being shared publicly.", "Requires an enterprise license": "Requires an enterprise license", + "Page permissions": "Page permissions", + "Control who can view and edit individual pages. Available with an enterprise license.": "Control who can view and edit individual pages. Available with an enterprise license.", "Enable public sharing": "Enable public sharing", "Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Are you sure you want to enable public sharing? Members will be able to share pages publicly.", "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.", @@ -622,8 +624,33 @@ "commented on a page": "commented on a page", "resolved a comment": "resolved a comment", "mentioned you on a page": "mentioned you on a page", + "gave you edit access to a page": "gave you edit access to a page", + "gave you view access to a page": "gave you view access to a page", "Today": "Today", "Yesterday": "Yesterday", "This week": "This week", - "Older": "Older" + "Older": "Older", + "Restricted page": "Restricted page", + "Restricted pages cannot be shared publicly.": "Restricted pages cannot be shared publicly.", + "Restricted by parent": "Restricted by parent", + "Restricted": "Restricted", + "Open": "Open", + "Inherits restrictions from ancestor page": "Inherits restrictions from ancestor page", + "Only people listed below can access this page": "Only people listed below can access this page", + "Everyone in this space can access": "Everyone in this space can access", + "No additional restrictions on this page": "No additional restrictions on this page", + "Only specific people can access": "Only specific people can access", + "Use only inherited restrictions": "Use only inherited restrictions", + "Add restrictions on top of inherited": "Add restrictions on top of inherited", + "Inherited restriction": "Inherited restriction", + "Access limited by": "Access limited by", + "Restrict access to control who can view and edit this page": "Restrict access to control who can view and edit this page", + "Add additional restrictions specific to this page": "Add additional restrictions specific to this page", + "Access": "Access", + "People with access": "People with access", + "Remove all": "Remove all", + "Remove access": "Remove access", + "Remove all access": "Remove all access", + "Are you sure you want to remove this member's access to the page?": "Are you sure you want to remove this member's access to the page?", + "Are you sure you want to remove all specific access? This will make the page open to everyone in the space.": "Are you sure you want to remove all specific access? This will make the page open to everyone in the space." } diff --git a/apps/client/src/ee/licence/components/oss-details.tsx b/apps/client/src/ee/licence/components/oss-details.tsx index 5a3fd6c4..3f31e1a4 100644 --- a/apps/client/src/ee/licence/components/oss-details.tsx +++ b/apps/client/src/ee/licence/components/oss-details.tsx @@ -11,7 +11,7 @@ export default function OssDetails() { withTableBorder > - To unlock enterprise features like AI, SSO, MFA, Resolve comments, contact sales@docmost.com. + To unlock enterprise features like SSO, AI, Page-level permissions, SSO, MFA, Resolve comments, contact sales@docmost.com. diff --git a/apps/client/src/ee/page-permission/components/general-access-select.tsx b/apps/client/src/ee/page-permission/components/general-access-select.tsx new file mode 100644 index 00000000..8bee6e4b --- /dev/null +++ b/apps/client/src/ee/page-permission/components/general-access-select.tsx @@ -0,0 +1,112 @@ +import { Group, Menu, Text, UnstyledButton } from "@mantine/core"; +import { + IconChevronDown, + IconLock, + IconShieldLock, + IconCheck, +} from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import classes from "./page-permission.module.css"; + +type AccessLevel = "open" | "restricted"; + +type GeneralAccessSelectProps = { + value: AccessLevel; + onChange: (value: AccessLevel) => void; + disabled?: boolean; + hasInheritedRestriction?: boolean; +}; + +export function GeneralAccessSelect({ + value, + onChange, + disabled, + hasInheritedRestriction, +}: GeneralAccessSelectProps) { + const { t } = useTranslation(); + + const isDirectlyRestricted = value === "restricted"; + const showInheritedState = hasInheritedRestriction && !isDirectlyRestricted; + + const currentLabel = showInheritedState + ? t("Restricted by parent") + : isDirectlyRestricted + ? t("Restricted") + : t("Open"); + + const currentDescription = showInheritedState + ? t("Inherits restrictions from ancestor page") + : isDirectlyRestricted + ? t("Only people listed below can access this page") + : t("Everyone in this space can access"); + + const CurrentIcon = showInheritedState + ? IconShieldLock + : isDirectlyRestricted + ? IconLock + : IconShieldLock; + + const accessOptions = [ + { + value: "open" as const, + label: hasInheritedRestriction ? t("Restricted by parent") : t("Open"), + description: hasInheritedRestriction + ? t("Use only inherited restrictions") + : t("No additional restrictions on this page"), + icon: IconShieldLock, + }, + { + value: "restricted" as const, + label: t("Restricted"), + description: hasInheritedRestriction + ? t("Add restrictions on top of inherited") + : t("Only specific people can access"), + icon: IconLock, + }, + ]; + + return ( + + + +
+ +
+
+ + + {currentLabel} + + {!disabled && } + + + {currentDescription} + +
+
+
+ + + {accessOptions.map((option) => ( + onChange(option.value)} + leftSection={} + rightSection={ + option.value === value ? : null + } + > +
+ {option.label} + + {option.description} + +
+
+ ))} +
+
+ ); +} diff --git a/apps/client/src/ee/page-permission/components/page-permission-item.tsx b/apps/client/src/ee/page-permission/components/page-permission-item.tsx new file mode 100644 index 00000000..b0a5c5f4 --- /dev/null +++ b/apps/client/src/ee/page-permission/components/page-permission-item.tsx @@ -0,0 +1,107 @@ +import { Menu, Text, UnstyledButton, Group } from "@mantine/core"; +import { IconChevronDown, IconCheck } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { useAtomValue } from "jotai"; +import { CustomAvatar } from "@/components/ui/custom-avatar"; +import { AutoTooltipText } from "@/components/ui/auto-tooltip-text"; +import { IconGroupCircle } from "@/components/icons/icon-people-circle"; +import { userAtom } from "@/features/user/atoms/current-user-atom"; +import { formatMemberCount } from "@/lib"; +import { + IPagePermissionMember, + PagePermissionRole, +} from "@/ee/page-permission/types/page-permission.types"; +import { + pagePermissionRoleData, + getPagePermissionRoleLabel, +} from "@/ee/page-permission/types/page-permission-role-data"; +import classes from "./page-permission.module.css"; + +type PagePermissionItemProps = { + member: IPagePermissionMember; + onRoleChange: (memberId: string, type: "user" | "group", role: string) => void; + onRemove: (memberId: string, type: "user" | "group") => void; + disabled?: boolean; +}; + +export function PagePermissionItem({ + member, + onRoleChange, + onRemove, + disabled, +}: PagePermissionItemProps) { + const { t } = useTranslation(); + const currentUser = useAtomValue(userAtom); + const isCurrentUser = member.type === "user" && member.id === currentUser?.id; + const roleLabel = getPagePermissionRoleLabel(member.role); + + return ( +
+
+ {member.type === "user" && ( + + )} + {member.type === "group" && } + +
+ + {member.name} + {isCurrentUser && ({t("You")})} + + + {member.type === "user" ? member.email : formatMemberCount(member.memberCount, t)} + +
+
+ +
+ {isCurrentUser || disabled ? ( + + {t(roleLabel)} + + ) : ( + + + + + {t(roleLabel)} + + + + + + + {pagePermissionRoleData.map((role) => ( + onRoleChange(member.id, member.type, role.value)} + rightSection={ + role.value === member.role ? : null + } + > +
+ {t(role.label)} + + {t(role.description)} + +
+
+ ))} + + onRemove(member.id, member.type)} + > + {t("Remove access")} + +
+
+ )} +
+
+ ); +} diff --git a/apps/client/src/ee/page-permission/components/page-permission-list.tsx b/apps/client/src/ee/page-permission/components/page-permission-list.tsx new file mode 100644 index 00000000..e586b968 --- /dev/null +++ b/apps/client/src/ee/page-permission/components/page-permission-list.tsx @@ -0,0 +1,164 @@ +import { Center, Group, Loader, ScrollArea, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { useAtomValue } from "jotai"; +import { useEffect, useRef } from "react"; +import { modals } from "@mantine/modals"; +import { userAtom } from "@/features/user/atoms/current-user-atom"; +import { PagePermissionRole } from "@/ee/page-permission/types/page-permission.types"; +import { + usePagePermissionsQuery, + useRemovePagePermissionMutation, + useUpdatePagePermissionRoleMutation, +} from "@/ee/page-permission/queries/page-permission-query"; +import { PagePermissionItem } from "@/ee/page-permission"; +import classes from "./page-permission.module.css"; + +type PagePermissionListProps = { + pageId: string; + canManage: boolean; + onRemoveAll?: () => void; +}; + +export function PagePermissionList({ + pageId, + canManage, + onRemoveAll, +}: PagePermissionListProps) { + const { t } = useTranslation(); + const currentUser = useAtomValue(userAtom); + const updateRoleMutation = useUpdatePagePermissionRoleMutation(); + const removeMutation = useRemovePagePermissionMutation(); + + const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } = + usePagePermissionsQuery(pageId); + + const sentinelRef = useRef(null); + const viewportRef = useRef(null); + + useEffect(() => { + const sentinel = sentinelRef.current; + if (!sentinel) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + { root: viewportRef.current, threshold: 0.1 }, + ); + + observer.observe(sentinel); + return () => observer.disconnect(); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + const handleRoleChange = async ( + memberId: string, + type: "user" | "group", + newRole: string, + ) => { + await updateRoleMutation.mutateAsync({ + pageId, + role: newRole as PagePermissionRole, + ...(type === "user" ? { userId: memberId } : { groupId: memberId }), + }); + }; + + const handleRemove = (memberId: string, type: "user" | "group") => { + modals.openConfirmModal({ + title: t("Remove access"), + children: ( + + {t( + "Are you sure you want to remove this member's access to the page?", + )} + + ), + centered: true, + labels: { confirm: t("Remove"), cancel: t("Cancel") }, + confirmProps: { color: "red" }, + onConfirm: async () => { + await removeMutation.mutateAsync({ + pageId, + ...(type === "user" + ? { userIds: [memberId] } + : { groupIds: [memberId] }), + }); + }, + }); + }; + + const handleRemoveAll = () => { + modals.openConfirmModal({ + title: t("Remove all access"), + children: ( + + {t( + "Are you sure you want to remove all specific access? This will make the page open to everyone in the space.", + )} + + ), + centered: true, + labels: { confirm: t("Remove all"), cancel: t("Cancel") }, + confirmProps: { color: "red" }, + onConfirm: () => onRemoveAll?.(), + }); + }; + + const members = data?.pages.flatMap((page) => page.items) ?? []; + + const sortedMembers = [...members].sort((a, b) => { + if (a.type === "user" && a.id === currentUser?.id) return -1; + if (b.type === "user" && b.id === currentUser?.id) return 1; + if (a.type === "group" && b.type === "user") return -1; + if (a.type === "user" && b.type === "group") return 1; + return 0; + }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (members.length === 0) { + return null; + } + + return ( + <> + + + {t("People with access")} + + {canManage && members.length > 0 && ( + + {t("Remove all")} + + )} + + + + {sortedMembers.map((member) => ( + + ))} + +
+ + {isFetchingNextPage && ( +
+ +
+ )} + + + ); +} diff --git a/apps/client/src/ee/page-permission/components/page-permission-tab.tsx b/apps/client/src/ee/page-permission/components/page-permission-tab.tsx new file mode 100644 index 00000000..93f9277c --- /dev/null +++ b/apps/client/src/ee/page-permission/components/page-permission-tab.tsx @@ -0,0 +1,189 @@ +import { useState } from "react"; +import { + Box, + Button, + Divider, + Group, + Paper, + Select, + Stack, + Text, + ThemeIcon, +} from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { Link, useParams } from "react-router-dom"; +import { IconArrowRight, IconLock, IconShieldLock } from "@tabler/icons-react"; +import { MultiMemberSelect } from "@/features/space/components/multi-member-select"; +import { + IPageRestrictionInfo, + PagePermissionRole, +} from "@/ee/page-permission/types/page-permission.types"; +import { + useAddPagePermissionMutation, + useRestrictPageMutation, + useUnrestrictPageMutation, +} from "@/ee/page-permission/queries/page-permission-query"; +import { pagePermissionRoleData } from "@/ee/page-permission/types/page-permission-role-data"; +import { GeneralAccessSelect } from "@/ee/page-permission"; +import { PagePermissionList } from "@/ee/page-permission"; +import classes from "./page-permission.module.css"; +import { buildPageUrl } from "@/features/page/page.utils"; + +type PagePermissionTabProps = { + pageId: string; + restrictionInfo: IPageRestrictionInfo; +}; + +export function PagePermissionTab({ + pageId, + restrictionInfo, +}: PagePermissionTabProps) { + const { t } = useTranslation(); + const { spaceSlug } = useParams(); + const [memberIds, setMemberIds] = useState([]); + const [role, setRole] = useState(PagePermissionRole.WRITER); + + const restrictMutation = useRestrictPageMutation(); + const unrestrictMutation = useUnrestrictPageMutation(); + const addPermissionMutation = useAddPagePermissionMutation(); + + const hasInheritedRestriction = restrictionInfo.hasInheritedRestriction; + const hasDirectRestriction = restrictionInfo.hasDirectRestriction; + const canManage = restrictionInfo.userAccess.canManage; + + const handleDirectAccessChange = async (value: "open" | "restricted") => { + if (value === "restricted" && !hasDirectRestriction) { + await restrictMutation.mutateAsync(pageId); + } else if (value === "open" && hasDirectRestriction) { + await unrestrictMutation.mutateAsync(pageId); + } + }; + + const handleAddMembers = async () => { + if (memberIds.length === 0) return; + + const userIds = memberIds + .filter((id) => id.startsWith("user-")) + .map((id) => id.replace("user-", "")); + + const groupIds = memberIds + .filter((id) => id.startsWith("group-")) + .map((id) => id.replace("group-", "")); + + await addPermissionMutation.mutateAsync({ + pageId, + role: role as PagePermissionRole, + ...(userIds.length > 0 && { userIds }), + ...(groupIds.length > 0 && { groupIds }), + }); + + setMemberIds([]); + }; + + const handleRemoveAll = async () => { + await unrestrictMutation.mutateAsync(pageId); + }; + + return ( + + {hasInheritedRestriction && ( + + + + + + + + {t("Inherited restriction")} + + + + {t("Access limited by")} + + {restrictionInfo.inheritedFrom && ( + + + + {restrictionInfo.inheritedFrom.title || t("Untitled")} + + + + + )} + + + + + )} + + + + {!hasDirectRestriction && !hasInheritedRestriction && ( + + {t("Restrict access to control who can view and edit this page")} + + )} + {!hasDirectRestriction && hasInheritedRestriction && ( + + {t("Add additional restrictions specific to this page")} + + )} + + + {hasDirectRestriction && ( + <> + + + {canManage && ( + + + + +