feat(ee): page-level access/permissions (#1971)

* Add page_hierarchy table

* feat(ee): page-level permissions

* pagination

* rename migration
fixes

* fix

* tabs

* fix theme

* cleanup

* sync

* page permissions notification
* other fixes

* sharing disbled

* fix column nodes

* toggle error handling
This commit is contained in:
Philip Okugbe
2026-02-26 19:49:10 +00:00
committed by GitHub
parent 22f33bab7c
commit 59e945562d
75 changed files with 4235 additions and 363 deletions
@@ -11,7 +11,7 @@ export default function OssDetails() {
withTableBorder
>
<Table.Caption>
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.
</Table.Caption>
<Table.Tbody>
<Table.Tr>
@@ -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 (
<Menu withArrow disabled={disabled}>
<Menu.Target>
<UnstyledButton className={classes.generalAccessBox} disabled={disabled}>
<div
className={`${classes.generalAccessIcon} ${isDirectlyRestricted || showInheritedState ? classes.generalAccessIconRestricted : ""}`}
>
<CurrentIcon size={18} stroke={1.5} />
</div>
<div style={{ flex: 1 }}>
<Group gap={4}>
<Text size="sm" fw={500}>
{currentLabel}
</Text>
{!disabled && <IconChevronDown size={14} />}
</Group>
<Text size="xs" c="dimmed">
{currentDescription}
</Text>
</div>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
{accessOptions.map((option) => (
<Menu.Item
key={option.value}
onClick={() => onChange(option.value)}
leftSection={<option.icon size={16} stroke={1.5} />}
rightSection={
option.value === value ? <IconCheck size={16} /> : null
}
>
<div>
<Text size="sm">{option.label}</Text>
<Text size="xs" c="dimmed">
{option.description}
</Text>
</div>
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
);
}
@@ -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 (
<div className={classes.permissionItem}>
<div className={classes.permissionItemInfo}>
{member.type === "user" && (
<CustomAvatar avatarUrl={member.avatarUrl} name={member.name} />
)}
{member.type === "group" && <IconGroupCircle />}
<div className={classes.permissionItemDetails}>
<AutoTooltipText
fz="sm"
fw={500}
tooltipLabel={isCurrentUser ? `${member.name} (${t("You")})` : member.name}
>
{member.name}
{isCurrentUser && <Text span c="dimmed"> ({t("You")})</Text>}
</AutoTooltipText>
<AutoTooltipText fz="xs" c="dimmed">
{member.type === "user" ? member.email : formatMemberCount(member.memberCount, t)}
</AutoTooltipText>
</div>
</div>
<div className={classes.permissionItemRole}>
{isCurrentUser || disabled ? (
<Text size="sm" c="dimmed">
{t(roleLabel)}
</Text>
) : (
<Menu withArrow position="bottom-end">
<Menu.Target>
<UnstyledButton>
<Group gap={4}>
<Text size="sm">{t(roleLabel)}</Text>
<IconChevronDown size={14} />
</Group>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
{pagePermissionRoleData.map((role) => (
<Menu.Item
key={role.value}
onClick={() => onRoleChange(member.id, member.type, role.value)}
rightSection={
role.value === member.role ? <IconCheck size={16} /> : null
}
>
<div>
<Text size="sm">{t(role.label)}</Text>
<Text size="xs" c="dimmed">
{t(role.description)}
</Text>
</div>
</Menu.Item>
))}
<Menu.Divider />
<Menu.Item
color="red"
onClick={() => onRemove(member.id, member.type)}
>
{t("Remove access")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</div>
</div>
);
}
@@ -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<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(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: (
<Text size="sm">
{t(
"Are you sure you want to remove this member's access to the page?",
)}
</Text>
),
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: (
<Text size="sm">
{t(
"Are you sure you want to remove all specific access? This will make the page open to everyone in the space.",
)}
</Text>
),
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 (
<Center py="md">
<Loader size="sm" />
</Center>
);
}
if (members.length === 0) {
return null;
}
return (
<>
<Group justify="space-between" align="center">
<Text size="sm" fw={500}>
{t("People with access")}
</Text>
{canManage && members.length > 0 && (
<Text className={classes.removeAllLink} onClick={handleRemoveAll}>
{t("Remove all")}
</Text>
)}
</Group>
<ScrollArea mah={250} viewportRef={viewportRef}>
{sortedMembers.map((member) => (
<PagePermissionItem
key={`${member.type}-${member.id}`}
member={member}
onRoleChange={handleRoleChange}
onRemove={handleRemove}
disabled={!canManage}
/>
))}
<div ref={sentinelRef} style={{ height: 1 }} />
{isFetchingNextPage && (
<Center py="xs">
<Loader size="xs" />
</Center>
)}
</ScrollArea>
</>
);
}
@@ -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<string[]>([]);
const [role, setRole] = useState<string>(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 (
<Stack gap="md">
{hasInheritedRestriction && (
<Paper className={classes.inheritedSection} p="sm" radius="sm">
<Group gap="sm" wrap="nowrap">
<ThemeIcon
size="lg"
radius="sm"
variant="light"
color="orange"
>
<IconShieldLock size={18} stroke={1.5} />
</ThemeIcon>
<Box style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{t("Inherited restriction")}
</Text>
<Group gap={4}>
<Text size="xs" c="dimmed">
{t("Access limited by")}
</Text>
{restrictionInfo.inheritedFrom && (
<Link
to={buildPageUrl(
spaceSlug,
restrictionInfo.inheritedFrom.slugId,
restrictionInfo.inheritedFrom.title,
)}
style={{ textDecoration: "none" }}
>
<Group gap={2}>
<Text size="xs" fw={500} c="blue">
{restrictionInfo.inheritedFrom.title || t("Untitled")}
</Text>
<IconArrowRight size={12} color="var(--mantine-color-blue-6)" />
</Group>
</Link>
)}
</Group>
</Box>
</Group>
</Paper>
)}
<Box>
<GeneralAccessSelect
value={hasDirectRestriction ? "restricted" : "open"}
onChange={handleDirectAccessChange}
disabled={!canManage}
hasInheritedRestriction={hasInheritedRestriction}
/>
{!hasDirectRestriction && !hasInheritedRestriction && (
<Text size="xs" c="dimmed" mt={4}>
{t("Restrict access to control who can view and edit this page")}
</Text>
)}
{!hasDirectRestriction && hasInheritedRestriction && (
<Text size="xs" c="dimmed" mt={4}>
{t("Add additional restrictions specific to this page")}
</Text>
)}
</Box>
{hasDirectRestriction && (
<>
<Divider />
{canManage && (
<Group gap="xs" align="flex-end">
<Box style={{ flex: 1 }}>
<MultiMemberSelect value={memberIds} onChange={setMemberIds} />
</Box>
<Select
data={pagePermissionRoleData.map((r) => ({
label: t(r.label),
value: r.value,
}))}
value={role}
onChange={(value) => value && setRole(value)}
allowDeselect={false}
variant="filled"
w={120}
/>
<Button
onClick={handleAddMembers}
disabled={memberIds.length === 0}
loading={addPermissionMutation.isPending}
>
{t("Add")}
</Button>
</Group>
)}
<PagePermissionList
pageId={pageId}
canManage={canManage}
onRemoveAll={handleRemoveAll}
/>
</>
)}
</Stack>
);
}
@@ -0,0 +1,128 @@
.generalAccessBox {
display: flex;
align-items: center;
gap: var(--mantine-spacing-sm);
padding: var(--mantine-spacing-xs) 0;
}
.generalAccessIcon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--mantine-radius-sm);
@mixin light {
background-color: var(--mantine-color-gray-1);
}
@mixin dark {
background-color: var(--mantine-color-dark-5);
}
}
.generalAccessIconRestricted {
@mixin light {
background-color: var(--mantine-color-red-0);
color: var(--mantine-color-red-6);
}
@mixin dark {
background-color: rgba(250, 82, 82, 0.1);
color: var(--mantine-color-red-5);
}
}
.permissionItem {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--mantine-spacing-xs) 0;
gap: var(--mantine-spacing-sm);
}
.permissionItemInfo {
display: flex;
align-items: center;
gap: var(--mantine-spacing-sm);
flex: 1;
min-width: 0;
overflow: hidden;
}
.permissionItemDetails {
min-width: 0;
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.permissionItemRole {
flex-shrink: 0;
}
.avatarStack {
display: flex;
align-items: center;
}
.avatarStackItem {
margin-left: -8px;
border: 2px solid var(--mantine-color-body);
border-radius: 50%;
}
.avatarStackItem:first-child {
margin-left: 0;
}
.specificAccessHeader {
display: flex;
align-items: center;
gap: var(--mantine-spacing-xs);
margin-top: var(--mantine-spacing-md);
margin-bottom: var(--mantine-spacing-xs);
}
.removeAllLink {
cursor: pointer;
font-size: var(--mantine-font-size-sm);
@mixin light {
color: var(--mantine-color-gray-6);
}
@mixin dark {
color: var(--mantine-color-dark-2);
}
&:hover {
text-decoration: underline;
}
}
.inheritedInfo {
display: flex;
align-items: center;
gap: var(--mantine-spacing-xs);
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
border-radius: var(--mantine-radius-sm);
margin-bottom: var(--mantine-spacing-sm);
@mixin light {
background-color: var(--mantine-color-gray-0);
}
@mixin dark {
background-color: var(--mantine-color-dark-6);
}
}
.inheritedSection {
@mixin light {
background-color: var(--mantine-color-orange-0);
border: 1px solid var(--mantine-color-orange-2);
}
@mixin dark {
background-color: rgba(255, 146, 43, 0.08);
border: 1px solid rgba(255, 146, 43, 0.2);
}
}
@@ -0,0 +1,132 @@
import { useState } from "react";
import {
Button,
Indicator,
Loader,
Modal,
Stack,
Tabs,
Text,
Center,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconWorld, IconLock } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { extractPageSlugId } from "@/lib";
import { usePageQuery } from "@/features/page/queries/page-query";
import { usePageRestrictionInfoQuery } from "@/ee/page-permission/queries/page-permission-query";
import { PagePermissionTab } from "@/ee/page-permission";
import { PublishTab } from "./publish-tab";
import { useShareForPageQuery } from "@/features/share/queries/share-query";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
import { useSpaceQuery } from "@/features/space/queries/space-query";
type PageShareModalProps = {
readOnly?: boolean;
};
export function PageShareModal({ readOnly }: PageShareModalProps) {
const { t } = useTranslation();
const { pageSlug, spaceSlug } = useParams();
const pageSlugId = extractPageSlugId(pageSlug);
const [opened, { open, close }] = useDisclosure(false);
const isCloudEE = useIsCloudEE();
const [activeTab, setActiveTab] = useState<string | null>(
isCloudEE ? "access" : "publish",
);
const [workspace] = useAtom(workspaceAtom);
const { data: space } = useSpaceQuery(spaceSlug);
const workspaceSharingDisabled = workspace?.settings?.sharing?.disabled === true;
const spaceSharingDisabled = space?.settings?.sharing?.disabled === true;
const { data: page } = usePageQuery({ pageId: pageSlugId });
const pageId = page?.id;
const isRestricted = page?.permissions?.hasRestriction ?? false;
const { data: share } = useShareForPageQuery(pageId);
const isPubliclyShared = !!share;
const { data: restrictionInfo, isLoading: restrictionLoading } =
usePageRestrictionInfoQuery(opened && isCloudEE ? pageId : undefined);
return (
<>
<Button
style={{ border: "none" }}
size="compact-sm"
leftSection={
isRestricted ? (
<Indicator color="red" offset={5} withBorder>
<IconLock size={20} stroke={1.5} />
</Indicator>
) : isPubliclyShared ? (
<Indicator color="green" offset={5} withBorder>
<IconWorld size={20} stroke={1.5} />
</Indicator>
) : null
}
variant="default"
onClick={open}
>
{t("Share")}
</Button>
<Modal opened={opened} onClose={close} title={t("Share")} size={600}>
<Tabs value={activeTab} color="dark" onChange={setActiveTab}>
<Tabs.List mb="md">
<Tabs.Tab value="access">{t("Access")}</Tabs.Tab>
<Tabs.Tab
value="publish"
rightSection={
isPubliclyShared ? (
<Indicator color="green" size={8} processing />
) : null
}
>
{t("Publish")}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="access">
{!isCloudEE ? (
<Stack align="center" py="md">
<IconLock size={20} stroke={1.5} />
<Text size="sm" ta="center" fw={500}>
{t("Page permissions")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t(
"Control who can view and edit individual pages. Available with an enterprise license.",
)}
</Text>
</Stack>
) : restrictionLoading || !pageId || !restrictionInfo ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : (
<PagePermissionTab
pageId={pageId}
restrictionInfo={restrictionInfo}
/>
)}
</Tabs.Panel>
<Tabs.Panel value="publish">
<PublishTab
pageId={pageId}
readOnly={readOnly}
isRestricted={isRestricted}
workspaceSharingDisabled={workspaceSharingDisabled}
spaceSharingDisabled={spaceSharingDisabled}
/>
</Tabs.Panel>
</Tabs>
</Modal>
</>
);
}
@@ -0,0 +1,254 @@
import { useEffect, useMemo, useState } from "react";
import {
ActionIcon,
Anchor,
Button,
Group,
Stack,
Switch,
Text,
TextInput,
} from "@mantine/core";
import { IconExternalLink, IconLock } from "@tabler/icons-react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { getPageIcon } from "@/lib";
import CopyTextButton from "@/components/common/copy";
import { getAppUrl, isCloud } from "@/lib/config";
import { buildPageUrl } from "@/features/page/page.utils";
import {
useCreateShareMutation,
useDeleteShareMutation,
useShareForPageQuery,
useUpdateShareMutation,
} from "@/features/share/queries/share-query";
import useTrial from "@/ee/hooks/use-trial";
type PublishTabProps = {
pageId: string;
readOnly?: boolean;
isRestricted?: boolean;
workspaceSharingDisabled?: boolean;
spaceSharingDisabled?: boolean;
};
export function PublishTab({ pageId, readOnly, isRestricted, workspaceSharingDisabled, spaceSharingDisabled }: PublishTabProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const { pageSlug, spaceSlug } = useParams();
const { isTrial } = useTrial();
const { data: share } = useShareForPageQuery(pageId);
const createShareMutation = useCreateShareMutation();
const updateShareMutation = useUpdateShareMutation();
const deleteShareMutation = useDeleteShareMutation();
const pageIsShared = share && share.level === 0;
const isDescendantShared = share && share.level > 0;
const publicLink = `${getAppUrl()}/share/${share?.key}/p/${pageSlug}`;
const [isPagePublic, setIsPagePublic] = useState<boolean>(false);
useEffect(() => {
setIsPagePublic(!!share);
}, [share, pageId]);
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
if (value) {
createShareMutation.mutateAsync({
pageId: pageId,
includeSubPages: true,
searchIndexing: false,
});
setIsPagePublic(value);
} else {
if (share && share.id) {
deleteShareMutation.mutateAsync(share.id);
setIsPagePublic(value);
}
}
};
const handleSubPagesChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const value = event.currentTarget.checked;
updateShareMutation.mutateAsync({
shareId: share.id,
includeSubPages: value,
});
};
const handleIndexSearchChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const value = event.currentTarget.checked;
updateShareMutation.mutateAsync({
shareId: share.id,
searchIndexing: value,
});
};
const shareLink = useMemo(
() => (
<Group my="sm" gap={4} wrap="nowrap">
<TextInput
variant="filled"
value={publicLink}
readOnly
rightSection={<CopyTextButton text={publicLink} />}
style={{ width: "100%" }}
/>
<ActionIcon
component="a"
variant="default"
target="_blank"
href={publicLink}
size="sm"
>
<IconExternalLink size={16} />
</ActionIcon>
</Group>
),
[publicLink],
);
if (isCloud() && isTrial) {
return (
<Stack align="center" py="md">
<IconLock size={20} stroke={1.5} />
<Text size="sm" ta="center" fw={500}>
{t("Upgrade to share pages")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t(
"Page sharing is available on paid plans. Upgrade to share your pages publicly.",
)}
</Text>
<Button size="xs" onClick={() => navigate("/settings/billing")}>
{t("Upgrade Plan")}
</Button>
</Stack>
);
}
if (workspaceSharingDisabled || spaceSharingDisabled) {
return (
<Stack align="center" py="md">
<IconLock size={20} stroke={1.5} />
<Text size="sm" ta="center" fw={500}>
{t("Public sharing is disabled")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{workspaceSharingDisabled
? t("Public sharing has been disabled at the workspace level.")
: t("Public sharing has been disabled for this space.")}
</Text>
</Stack>
);
}
if (isRestricted) {
return (
<Stack align="center" py="md">
<IconLock size={20} stroke={1.5} />
<Text size="sm" ta="center" fw={500}>
{t("Restricted page")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t("Restricted pages cannot be shared publicly.")}
</Text>
</Stack>
);
}
if (isDescendantShared) {
return (
<Stack gap="sm">
<Text size="sm">{t("Inherits public sharing from")}</Text>
<Anchor
size="sm"
underline="never"
style={{
cursor: "pointer",
color: "var(--mantine-color-text)",
}}
component={Link}
to={buildPageUrl(
spaceSlug,
share.sharedPage.slugId,
share.sharedPage.title,
)}
>
<Group gap="4" wrap="nowrap">
{getPageIcon(share.sharedPage.icon)}
<Text fz="sm" fw={500} lineClamp={1}>
{share.sharedPage.title || t("untitled")}
</Text>
</Group>
</Anchor>
{shareLink}
</Stack>
);
}
return (
<Stack gap="sm">
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="sm">
{isPagePublic ? t("Shared to web") : t("Share to web")}
</Text>
<Text size="xs" c="dimmed">
{isPagePublic
? t("Anyone with the link can view this page")
: t("Make this page publicly accessible")}
</Text>
</div>
<Switch
onChange={handleChange}
checked={isPagePublic}
disabled={readOnly}
size="xs"
/>
</Group>
{pageIsShared && (
<>
{shareLink}
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="sm">{t("Include sub-pages")}</Text>
<Text size="xs" c="dimmed">
{t("Make sub-pages public too")}
</Text>
</div>
<Switch
onChange={handleSubPagesChange}
checked={share.includeSubPages}
size="xs"
disabled={readOnly}
/>
</Group>
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="sm">{t("Search engine indexing")}</Text>
<Text size="xs" c="dimmed">
{t("Allow search engines to index page")}
</Text>
</div>
<Switch
onChange={handleIndexSearchChange}
checked={share.searchIndexing}
size="xs"
disabled={readOnly}
/>
</Group>
</>
)}
</Stack>
);
}
@@ -0,0 +1,26 @@
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability";
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type";
import { usePageRestrictionInfoQuery } from "@/ee/page-permission/queries/page-permission-query";
export function usePagePermission(pageId: string, spaceRules: any) {
const spaceAbility = useSpaceAbility(spaceRules);
const { data: restrictionInfo, isLoading } =
usePageRestrictionInfoQuery(pageId);
if (isLoading || !restrictionInfo) {
return { canEdit: false, restrictionInfo: undefined };
}
const hasRestriction =
restrictionInfo.hasDirectRestriction ||
restrictionInfo.hasInheritedRestriction;
const canEdit = hasRestriction
? (restrictionInfo.userAccess?.canEdit ?? false)
: spaceAbility.can(SpaceCaslAction.Manage, SpaceCaslSubject.Page);
return { canEdit, restrictionInfo };
}
@@ -0,0 +1,11 @@
export * from "./components/page-share-modal";
export * from "./components/page-permission-tab";
export * from "./components/publish-tab";
export * from "./components/page-permission-list";
export * from "./components/page-permission-item";
export * from "./components/general-access-select";
export * from "./hooks/use-page-permission";
export * from "./queries/page-permission-query";
export * from "./services/page-permission-service";
export * from "./types/page-permission.types";
export * from "./types/page-permission-role-data";
@@ -0,0 +1,175 @@
import {
keepPreviousData,
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
IAddPagePermission,
IPageRestrictionInfo,
IRemovePagePermission,
IUpdatePagePermissionRole,
} from "@/ee/page-permission/types/page-permission.types";
import {
addPagePermission,
getPagePermissions,
getPageRestrictionInfo,
removePagePermission,
restrictPage,
unrestrictPage,
updatePagePermissionRole,
} from "@/ee/page-permission/services/page-permission-service";
import { IPage } from "@/features/page/types/page.types";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
export function usePageRestrictionInfoQuery(
pageId: string | undefined,
): UseQueryResult<IPageRestrictionInfo, Error> {
return useQuery({
queryKey: ["page-restriction-info", pageId],
queryFn: () => getPageRestrictionInfo(pageId),
enabled: !!pageId,
});
}
export function usePagePermissionsQuery(pageId: string) {
return useInfiniteQuery({
queryKey: ["page-permissions", pageId],
queryFn: ({ pageParam }) => getPagePermissions(pageId, pageParam),
enabled: !!pageId,
//gcTime: 5000,
placeholderData: keepPreviousData,
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
});
}
function updatePageRestrictionCache(
queryClient: ReturnType<typeof useQueryClient>,
pageId: string,
hasRestriction: boolean,
) {
queryClient.setQueriesData<IPage>(
{ queryKey: ["pages"] },
(old) => {
if (old?.id === pageId) {
return {
...old,
permissions: { ...old.permissions, hasRestriction },
};
}
return old;
},
);
queryClient.invalidateQueries({
queryKey: ["page-restriction-info", pageId],
});
queryClient.removeQueries({
queryKey: ["page-permissions", pageId],
});
}
export function useRestrictPageMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, string>({
mutationFn: (pageId) => restrictPage(pageId),
onSuccess: (_, pageId) => {
updatePageRestrictionCache(queryClient, pageId, true);
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to restrict page"),
color: "red",
});
},
});
}
export function useUnrestrictPageMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, string>({
mutationFn: (pageId) => unrestrictPage(pageId),
onSuccess: (_, pageId) => {
updatePageRestrictionCache(queryClient, pageId, false);
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to remove page restriction"),
color: "red",
});
},
});
}
export function useAddPagePermissionMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, IAddPagePermission>({
mutationFn: (data) => addPagePermission(data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["page-permissions", variables.pageId],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to add permission"),
color: "red",
});
},
});
}
export function useRemovePagePermissionMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, IRemovePagePermission>({
mutationFn: (data) => removePagePermission(data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["page-permissions", variables.pageId],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to remove permission"),
color: "red",
});
},
});
}
export function useUpdatePagePermissionRoleMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, IUpdatePagePermissionRole>({
mutationFn: (data) => updatePagePermissionRole(data),
onSuccess: (_, variables) => {
queryClient.refetchQueries({
queryKey: ["page-permissions", variables.pageId],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to update permission"),
color: "red",
});
},
});
}
@@ -0,0 +1,55 @@
import api from "@/lib/api-client";
import { IPagination } from "@/lib/types";
import {
IAddPagePermission,
IPagePermissionMember,
IPageRestrictionInfo,
IRemovePagePermission,
IUpdatePagePermissionRole,
} from "@/ee/page-permission/types/page-permission.types";
export async function restrictPage(pageId: string): Promise<void> {
await api.post("/pages/restrict", { pageId });
}
export async function addPagePermission(
data: IAddPagePermission,
): Promise<void> {
await api.post("/pages/add-permission", data);
}
export async function removePagePermission(
data: IRemovePagePermission,
): Promise<void> {
await api.post("/pages/remove-permission", data);
}
export async function updatePagePermissionRole(
data: IUpdatePagePermissionRole,
): Promise<void> {
await api.post("/pages/update-permission", data);
}
export async function unrestrictPage(pageId: string): Promise<void> {
await api.post("/pages/remove-restriction", { pageId });
}
export async function getPagePermissions(
pageId: string,
cursor?: string,
): Promise<IPagination<IPagePermissionMember>> {
const req = await api.post<IPagination<IPagePermissionMember>>(
"/pages/permissions",
{ pageId, ...(cursor && { cursor }) },
);
return req.data;
}
export async function getPageRestrictionInfo(
pageId: string,
): Promise<IPageRestrictionInfo> {
const req = await api.post<IPageRestrictionInfo>("/pages/permission-info", {
pageId,
});
return req.data;
}
@@ -0,0 +1,20 @@
import { IRoleData } from "@/lib/types";
import { PagePermissionRole } from "./page-permission.types";
export const pagePermissionRoleData: IRoleData[] = [
{
label: "Can edit",
value: PagePermissionRole.WRITER,
description: "Can edit page and manage access",
},
{
label: "Can view",
value: PagePermissionRole.READER,
description: "Can only view page",
},
];
export function getPagePermissionRoleLabel(value: string): string | undefined {
const role = pagePermissionRoleData.find((item) => item.value === value);
return role ? role.label : undefined;
}
@@ -0,0 +1,61 @@
export enum PagePermissionRole {
READER = "reader",
WRITER = "writer",
}
export type IAddPagePermission = {
pageId: string;
role: PagePermissionRole;
userIds?: string[];
groupIds?: string[];
};
export type IRemovePagePermission = {
pageId: string;
userIds?: string[];
groupIds?: string[];
};
export type IUpdatePagePermissionRole = {
pageId: string;
role: PagePermissionRole;
userId?: string;
groupId?: string;
};
export type IPageRestrictionInfo = {
restrictionId?: string;
hasDirectRestriction: boolean;
hasInheritedRestriction: boolean;
inheritedFrom?: {
id: string;
slugId: string;
title: string;
};
userAccess: {
canView: boolean;
canEdit: boolean;
canManage: boolean;
};
};
type IPagePermissionBase = {
id: string;
name: string;
role: string;
createdAt: string;
};
export type IPagePermissionUser = IPagePermissionBase & {
type: "user";
email: string;
avatarUrl: string | null;
};
export type IPagePermissionGroup = IPagePermissionBase & {
type: "group";
memberCount: number;
isDefault: boolean;
};
export type IPagePermissionMember = IPagePermissionUser | IPagePermissionGroup;
@@ -17,11 +17,6 @@ import { useTranslation } from "react-i18next";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
function CommentListWithTabs() {
const { t } = useTranslation();
@@ -38,14 +33,7 @@ function CommentListWithTabs() {
const isCloudEE = useIsCloudEE();
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const spaceRules = space?.membership?.permissions;
const spaceAbility = useSpaceAbility(spaceRules);
const canComment: boolean = spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page
);
const canComment = page?.permissions?.canEdit ?? false;
// Separate active and resolved comments
const { activeComments, resolvedComments } = useMemo(() => {
@@ -54,14 +42,14 @@ function CommentListWithTabs() {
}
const parentComments = comments.items.filter(
(comment: IComment) => comment.parentCommentId === null
(comment: IComment) => comment.parentCommentId === null,
);
const active = parentComments.filter(
(comment: IComment) => !comment.resolvedAt
(comment: IComment) => !comment.resolvedAt,
);
const resolved = parentComments.filter(
(comment: IComment) => comment.resolvedAt
(comment: IComment) => comment.resolvedAt,
);
return { activeComments: active, resolvedComments: resolved };
@@ -89,7 +77,7 @@ function CommentListWithTabs() {
setIsLoading(false);
}
},
[createCommentMutation, page?.id]
[createCommentMutation, page?.id],
);
const renderComments = useCallback(
@@ -131,7 +119,7 @@ function CommentListWithTabs() {
)}
</Paper>
),
[comments, handleAddReply, isLoading, space?.membership?.role]
[comments, handleAddReply, isLoading, space?.membership?.role],
);
if (isCommentsLoading) {
@@ -199,7 +187,14 @@ function CommentListWithTabs() {
}
return (
<div style={{ height: "85vh", display: "flex", flexDirection: "column", marginTop: '-15px' }}>
<div
style={{
height: "85vh",
display: "flex",
flexDirection: "column",
marginTop: "-15px",
}}
>
<Tabs defaultValue="open" variant="default" style={{ flex: "0 0 auto" }}>
<Tabs.List justify="center">
<Tabs.Tab
@@ -273,9 +268,9 @@ const ChildComments = ({
const getChildComments = useCallback(
(parentId: string) =>
comments.items.filter(
(comment: IComment) => comment.parentCommentId === parentId
(comment: IComment) => comment.parentCommentId === parentId,
),
[comments.items]
[comments.items],
);
return (
@@ -171,11 +171,14 @@ export function TitleEditor({
}, [pageId]);
useEffect(() => {
// honor user default page edit mode preference
if (userPageEditMode && titleEditor && editable) {
if (userPageEditMode === PageEditMode.Edit) {
titleEditor.setEditable(true);
} else if (userPageEditMode === PageEditMode.Read) {
if (titleEditor) {
if (userPageEditMode && editable) {
if (userPageEditMode === PageEditMode.Edit) {
titleEditor.setEditable(true);
} else if (userPageEditMode === PageEditMode.Read) {
titleEditor.setEditable(false);
}
} else {
titleEditor.setEditable(false);
}
}
@@ -46,6 +46,10 @@ export function NotificationItem({
return t("resolved a comment");
case "page.user_mention":
return t("mentioned you on a page");
case "page.permission_granted":
return notification.data?.role === "writer"
? t("gave you edit access to a page")
: t("gave you view access to a page");
default:
return "";
}
@@ -2,7 +2,8 @@ export type NotificationType =
| "comment.user_mention"
| "comment.created"
| "comment.resolved"
| "page.user_mention";
| "page.user_mention"
| "page.permission_granted";
export type INotification = {
id: string;
@@ -39,7 +39,7 @@ import { formattedDate } from "@/lib/time.ts";
import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import ShareModal from "@/features/share/components/share-modal.tsx";
import { PageShareModal } from "@/ee/page-permission";
interface PageHeaderMenuProps {
readOnly?: boolean;
@@ -75,7 +75,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
{!readOnly && <PageStateSegmentedControl size="xs" />}
<ShareModal readOnly={readOnly} />
<PageShareModal readOnly={readOnly} />
<Tooltip label={t("Comments")} openDelay={250} withArrow>
<ActionIcon
@@ -53,11 +53,7 @@ import {
import { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts";
import { queryClient } from "@/main.tsx";
import { OpenMap } from "react-arborist/dist/main/state/open-slice";
import {
useDisclosure,
useElementSize,
useMergedRef,
} from "@mantine/hooks";
import { useDisclosure, useElementSize, useMergedRef } from "@mantine/hooks";
import { useClipboard } from "@/hooks/use-clipboard";
import { dfs } from "react-arborist/dist/module/utils";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
@@ -244,9 +240,19 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
{isRootReady && rootElement.current && (
<Tree
data={filteredData}
disableDrag={readOnly}
disableDrop={readOnly}
disableEdit={readOnly}
disableDrag={
readOnly
? true
: (data) => {
return data.canEdit === false;
}
}
disableDrop={
readOnly
? true
: ({ parentNode }) => parentNode?.data?.canEdit === false
}
disableEdit={readOnly ? true : (data) => data.canEdit === false}
{...controllers}
width={width}
height={rootElement.current.clientHeight}
@@ -417,7 +423,9 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
<IconFileDescription size="18" />
)
}
readOnly={tree.props.disableEdit as boolean}
readOnly={
tree.props.disableEdit === true || node.data.canEdit === false
}
removeEmojiAction={handleRemoveEmoji}
/>
</div>
@@ -427,7 +435,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
<div className={classes.actions}>
<NodeMenu node={node} treeApi={tree} spaceId={node.data.spaceId} />
{!tree.props.disableEdit && (
{tree.props.disableEdit !== true && node.data.canEdit !== false && (
<CreateNode
node={node}
treeApi={tree}
@@ -532,6 +540,7 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
parentPageId: duplicatedPage.parentPageId,
icon: duplicatedPage.icon,
hasChildren: duplicatedPage.hasChildren,
canEdit: true,
children: [],
};
@@ -610,55 +619,56 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
{t("Export page")}
</Menu.Item>
{!(treeApi.props.disableEdit as boolean) && (
<>
<Menu.Item
leftSection={<IconCopy size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDuplicatePage();
}}
>
{t("Duplicate")}
</Menu.Item>
{treeApi.props.disableEdit !== true &&
node.data.canEdit !== false && (
<>
<Menu.Item
leftSection={<IconCopy size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDuplicatePage();
}}
>
{t("Duplicate")}
</Menu.Item>
<Menu.Item
leftSection={<IconArrowRight size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openMovePageModal();
}}
>
{t("Move")}
</Menu.Item>
<Menu.Item
leftSection={<IconArrowRight size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openMovePageModal();
}}
>
{t("Move")}
</Menu.Item>
<Menu.Item
leftSection={<IconCopy size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openCopyPageModal();
}}
>
{t("Copy to space")}
</Menu.Item>
<Menu.Item
leftSection={<IconCopy size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openCopyPageModal();
}}
>
{t("Copy to space")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
c="red"
leftSection={<IconTrash size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
}}
>
{t("Move to trash")}
</Menu.Item>
</>
)}
<Menu.Divider />
<Menu.Item
c="red"
leftSection={<IconTrash size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
}}
>
{t("Move to trash")}
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
@@ -7,5 +7,6 @@ export type SpaceTreeNode = {
spaceId: string;
parentPageId: string;
hasChildren: boolean;
canEdit?: boolean;
children: SpaceTreeNode[];
};
@@ -24,6 +24,7 @@ export function buildTree(pages: IPage[]): SpaceTreeNode[] {
hasChildren: page.hasChildren,
spaceId: page.spaceId,
parentPageId: page.parentPageId,
canEdit: page.canEdit ?? page.permissions?.canEdit,
children: [],
};
});
@@ -18,10 +18,15 @@ export interface IPage {
deletedAt: Date;
position: string;
hasChildren: boolean;
canEdit?: boolean;
creator: ICreator;
lastUpdatedBy: ILastUpdatedBy;
deletedBy: IDeletedBy;
space: Partial<ISpace>;
permissions?: {
canEdit: boolean;
hasRestriction: boolean;
};
}
interface ICreator {
@@ -69,19 +69,20 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
setIsPagePublic(value);
if (value) {
createShareMutation.mutateAsync({
pageId: pageId,
includeSubPages: true,
searchIndexing: false,
});
setIsPagePublic(value);
} else {
if (share && share.id) {
deleteShareMutation.mutateAsync(share.id);
setIsPagePublic(value);
try {
if (value) {
await createShareMutation.mutateAsync({
pageId: pageId,
includeSubPages: true,
searchIndexing: false,
});
} else if (share && share.id) {
await deleteShareMutation.mutateAsync(share.id);
}
} catch {
setIsPagePublic(!value);
}
};
@@ -89,20 +90,28 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
event: React.ChangeEvent<HTMLInputElement>,
) => {
const value = event.currentTarget.checked;
updateShareMutation.mutateAsync({
shareId: share.id,
includeSubPages: value,
});
try {
await updateShareMutation.mutateAsync({
shareId: share.id,
includeSubPages: value,
});
} catch {
// query invalidation will revert the UI
}
};
const handleIndexSearchChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const value = event.currentTarget.checked;
updateShareMutation.mutateAsync({
shareId: share.id,
searchIndexing: value,
});
try {
await updateShareMutation.mutateAsync({
shareId: share.id,
searchIndexing: value,
});
} catch {
// query invalidation will revert the UI
}
};
const shareLink = useMemo(
@@ -90,7 +90,10 @@ export function useCreateShareMutation() {
});
},
onError: (error) => {
notifications.show({ message: t("Failed to share page"), color: "red" });
notifications.show({
message: error?.["response"]?.data?.message || t("Failed to share page"),
color: "red",
});
},
});
}
@@ -9,6 +9,7 @@ import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
import { useTranslation } from "react-i18next";
interface MultiMemberSelectProps {
value?: string[];
onChange: (value: string[]) => void;
}
@@ -33,7 +34,7 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
</Group>
);
export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
export function MultiMemberSelect({ value, onChange }: MultiMemberSelectProps) {
const { t } = useTranslation();
const [searchValue, setSearchValue] = useState("");
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
@@ -85,6 +86,7 @@ export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
return (
<MultiSelect
data={data}
value={value}
renderOption={renderMultiSelectOption}
hidePickedOptions
maxDropdownHeight={300}
@@ -51,7 +51,7 @@ export default function SpaceSettingsModal({
</Modal.Header>
<Modal.Body>
<div style={{ height: rem(600) }}>
<Tabs defaultValue="members">
<Tabs color="dark" defaultValue="members">
<Tabs.List>
<Tabs.Tab fw={500} value="general">
{t("Settings")}
@@ -63,7 +63,7 @@ export default function SpaceSettingsModal({
<Tabs.Panel value="general">
<ScrollArea h={580} scrollbarSize={5} pr={8}>
<div style={{ paddingBottom: "100px"}}>
<div style={{ paddingBottom: "100px" }}>
<SpaceDetails
spaceId={space?.id}
readOnly={spaceAbility.cannot(
@@ -72,7 +72,6 @@ export default function SpaceSettingsModal({
)}
/>
</div>
</ScrollArea>
</Tabs.Panel>
@@ -40,12 +40,17 @@ export function PageStateSegmentedControl({
const [value, setValue] = useState(pageEditMode);
const handleChange = useCallback(
async (value: string) => {
const updatedUser = await updateUser({ pageEditMode: value });
setValue(value);
setUser(updatedUser);
async (newValue: string) => {
const prevValue = value;
setValue(newValue);
try {
const updatedUser = await updateUser({ pageEditMode: newValue });
setUser(updatedUser);
} catch {
setValue(prevValue);
}
},
[user, setUser],
[value, setUser],
);
useEffect(() => {
@@ -39,9 +39,13 @@ export function PageWidthToggle({ size, label }: PageWidthToggleProps) {
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
const updatedUser = await updateUser({ fullPageWidth: value });
setChecked(value);
setUser(updatedUser);
try {
const updatedUser = await updateUser({ fullPageWidth: value });
setUser(updatedUser);
} catch {
setChecked(!value);
}
};
return (
-1
View File
@@ -42,7 +42,6 @@ if (isCloud() && isPostHogEnabled) {
});
}
const container = document.getElementById("root") as HTMLElement;
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
+3 -18
View File
@@ -6,11 +6,6 @@ import { Helmet } from "react-helmet-async";
import PageHeader from "@/features/page/components/header/page-header.tsx";
import { extractPageSlugId } from "@/lib";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
import { useTranslation } from "react-i18next";
import React from "react";
import { EmptyState } from "@/components/ui/empty-state.tsx";
@@ -18,7 +13,6 @@ import { IconAlertTriangle, IconFileOff } from "@tabler/icons-react";
import { Button } from "@mantine/core";
import { Link } from "react-router-dom";
import { ErrorBoundary } from "react-error-boundary";
const MemoizedFullEditor = React.memo(FullEditor);
const MemoizedPageHeader = React.memo(PageHeader);
const MemoizedHistoryModal = React.memo(HistoryModal);
@@ -58,8 +52,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
} = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const spaceRules = space?.membership?.permissions;
const spaceAbility = useSpaceAbility(spaceRules);
const canEdit = page?.permissions?.canEdit ?? false;
if (isLoading) {
return <></>;
@@ -101,12 +94,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
<title>{`${page?.icon || ""} ${page?.title || t("untitled")}`}</title>
</Helmet>
<MemoizedPageHeader
readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
)}
/>
<MemoizedPageHeader readOnly={!canEdit} />
<MemoizedFullEditor
key={page.id}
@@ -115,10 +103,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
content={page.content}
slugId={page.slugId}
spaceSlug={page?.space?.slug}
editable={spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
)}
editable={canEdit}
/>
<MemoizedHistoryModal pageId={page.id} />
</div>
+12
View File
@@ -2,6 +2,7 @@ import {
createTheme,
CSSVariablesResolver,
MantineColorsTuple,
Tabs,
} from "@mantine/core";
const blue: MantineColorsTuple = [
@@ -35,6 +36,17 @@ export const theme = createTheme({
blue,
red,
},
components: {
Tabs: Tabs.extend({
vars: (theme, props) => ({
root: {
...(props.color === "dark" && {
"--tabs-color": "var(--mantine-color-dark-default)",
}),
},
}),
}),
},
/***
components: {
ActionIcon: ActionIcon.extend({