This commit is contained in:
Philipinho
2026-01-18 22:36:12 +00:00
parent 826bc0114d
commit 1b13f80fb8
13 changed files with 1065 additions and 38 deletions
@@ -0,0 +1,112 @@
import { Group, Menu, Text, UnstyledButton } from "@mantine/core";
import {
IconChevronDown,
IconLock,
IconWorld,
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;
isInherited?: boolean;
};
export function GeneralAccessSelect({
value,
onChange,
disabled,
isInherited,
}: GeneralAccessSelectProps) {
const { t } = useTranslation();
const isRestricted = value === "restricted";
const accessOptions = [
{
value: "open" as const,
label: t("Open"),
description: t("Everyone in this space can access"),
icon: IconWorld,
},
{
value: "restricted" as const,
label: t("Restricted"),
description: t("Only specific people can view or edit"),
icon: IconLock,
},
];
const currentOption = accessOptions.find((opt) => opt.value === value);
const Icon = currentOption?.icon || IconWorld;
if (isInherited) {
return (
<Group className={classes.generalAccessBox}>
<div
className={`${classes.generalAccessIcon} ${isRestricted ? classes.generalAccessIconRestricted : ""}`}
>
<Icon size={18} stroke={1.5} />
</div>
<div>
<Text size="sm" fw={500}>
{currentOption?.label}
</Text>
<Text size="xs" c="dimmed">
{currentOption?.description}
</Text>
</div>
</Group>
);
}
return (
<Menu withArrow disabled={disabled}>
<Menu.Target>
<UnstyledButton className={classes.generalAccessBox}>
<div
className={`${classes.generalAccessIcon} ${isRestricted ? classes.generalAccessIconRestricted : ""}`}
>
<Icon size={18} stroke={1.5} />
</div>
<div style={{ flex: 1 }}>
<Group gap={4}>
<Text size="sm" fw={500}>
{currentOption?.label}
</Text>
{!disabled && <IconChevronDown size={14} />}
</Group>
<Text size="xs" c="dimmed">
{currentOption?.description}
</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 { Group, Menu, Text, UnstyledButton } 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 { 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}>
<Group gap={4}>
<Text fz="sm" fw={500} lineClamp={1}>
{member.name}
</Text>
{isCurrentUser && (
<Text fz="sm" c="dimmed">
({t("You")})
</Text>
)}
</Group>
<Text fz="xs" c="dimmed" lineClamp={1}>
{member.type === "user" && member.email}
{member.type === "group" && formatMemberCount(member.memberCount, t)}
</Text>
</div>
</div>
{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>
);
}
@@ -0,0 +1,179 @@
import { Avatar, Group, ScrollArea, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useAtomValue } from "jotai";
import { modals } from "@mantine/modals";
import { userAtom } from "@/features/user/atoms/current-user-atom";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { IconGroupCircle } from "@/components/icons/icon-people-circle";
import {
IPagePermissionMember,
PagePermissionRole,
} from "@/ee/page-permission/types/page-permission.types";
import {
useRemovePagePermissionMutation,
useUpdatePagePermissionRoleMutation,
} from "@/ee/page-permission/queries/page-permission-query";
import { PagePermissionItem } from "./page-permission-item";
import classes from "./page-permission.module.css";
type PagePermissionListProps = {
pageId: string;
members: IPagePermissionMember[];
canManage: boolean;
onRemoveAll?: () => void;
};
export function PagePermissionList({
pageId,
members,
canManage,
onRemoveAll,
}: PagePermissionListProps) {
const { t } = useTranslation();
const currentUser = useAtomValue(userAtom);
const updateRoleMutation = useUpdatePagePermissionRoleMutation();
const removeMutation = useRemovePagePermissionMutation();
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 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;
});
const getSummaryText = () => {
const names: string[] = [];
let remaining = 0;
for (const member of sortedMembers) {
if (names.length < 2) {
if (member.type === "user" && member.id === currentUser?.id) {
names.push(t("You"));
} else {
names.push(member.name);
}
} else {
remaining++;
}
}
if (remaining > 0) {
return `${names.join(", ")}, ${t("and {{count}} other", { count: remaining })}`;
}
return names.join(", ");
};
if (members.length === 0) {
return null;
}
return (
<>
<div className={classes.specificAccessHeader}>
<Text size="sm" fw={500}>
{t("Specific access")}
</Text>
{canManage && members.length > 0 && (
<>
<Text size="sm" c="dimmed">
</Text>
<Text
className={classes.removeAllLink}
onClick={handleRemoveAll}
>
{t("Remove all")}
</Text>
</>
)}
</div>
<Group gap={0} mb="xs">
<div className={classes.avatarStack}>
{sortedMembers.slice(0, 3).map((member, index) => (
<div
key={member.id}
className={classes.avatarStackItem}
style={{ zIndex: sortedMembers.length - index }}
>
{member.type === "user" ? (
<CustomAvatar
avatarUrl={member.avatarUrl}
name={member.name}
size={28}
/>
) : (
<Avatar size={28} radius="xl">
<IconGroupCircle />
</Avatar>
)}
</div>
))}
</div>
<Text size="sm" ml="xs">
{getSummaryText()}
</Text>
</Group>
<ScrollArea.Autosize mah={250}>
{sortedMembers.map((member) => (
<PagePermissionItem
key={`${member.type}-${member.id}`}
member={member}
onRoleChange={handleRoleChange}
onRemove={handleRemove}
disabled={!canManage}
/>
))}
</ScrollArea.Autosize>
</>
);
}
@@ -0,0 +1,166 @@
import { useState } from "react";
import { Button, Divider, Group, Loader, Select, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Link, useParams } from "react-router-dom";
import { IconArrowRight } 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,
usePagePermissionsQuery,
useRestrictPageMutation,
useUnrestrictPageMutation,
} from "@/ee/page-permission/queries/page-permission-query";
import { pagePermissionRoleData } from "@/ee/page-permission/types/page-permission-role-data";
import { GeneralAccessSelect } from "./general-access-select";
import { PagePermissionList } from "./page-permission-list";
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 { data: permissionsData, isLoading } = usePagePermissionsQuery(pageId);
const restrictMutation = useRestrictPageMutation();
const unrestrictMutation = useUnrestrictPageMutation();
const addPermissionMutation = useAddPagePermissionMutation();
const isRestricted =
restrictionInfo.hasDirectRestriction ||
restrictionInfo.hasInheritedRestriction;
const isInherited =
restrictionInfo.hasInheritedRestriction &&
!restrictionInfo.hasDirectRestriction;
const canManage = restrictionInfo.userAccess.canManage;
const handleAccessChange = async (value: "open" | "restricted") => {
if (value === "restricted" && !isRestricted) {
await restrictMutation.mutateAsync(pageId);
} else if (value === "open" && isRestricted) {
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="sm">
{isRestricted && canManage && !isInherited && (
<>
<Group gap="xs" align="flex-end">
<div style={{ flex: 1 }}>
<MultiMemberSelect onChange={setMemberIds} />
</div>
<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>
<Divider />
</>
)}
<div>
<Text size="sm" fw={500} mb="xs">
{t("General access")}
</Text>
<GeneralAccessSelect
value={isRestricted ? "restricted" : "open"}
onChange={handleAccessChange}
disabled={!canManage || isInherited}
isInherited={isInherited}
/>
{isInherited && (
<div className={classes.inheritedInfo}>
<Text size="xs" c="dimmed">
{t("Inherits restrictions from")}
</Text>
<Link
to={buildPageUrl(
spaceSlug,
restrictionInfo.id,
restrictionInfo.title,
)}
style={{ textDecoration: "none" }}
>
<Group gap={4}>
<Text size="xs" fw={500}>
{restrictionInfo.title || t("Untitled")}
</Text>
<IconArrowRight size={12} />
</Group>
</Link>
</div>
)}
</div>
{isRestricted && (
<>
{isLoading ? (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
) : (
<PagePermissionList
pageId={pageId}
members={permissionsData?.items || []}
canManage={canManage && !isInherited}
onRemoveAll={handleRemoveAll}
/>
)}
</>
)}
</Stack>
);
}
@@ -0,0 +1,108 @@
.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;
}
.permissionItemInfo {
display: flex;
align-items: center;
gap: var(--mantine-spacing-sm);
flex: 1;
min-width: 0;
}
.permissionItemDetails {
min-width: 0;
flex: 1;
}
.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);
}
}
@@ -0,0 +1,99 @@
import { useState } from "react";
import {
Button,
Indicator,
Loader,
Modal,
Tabs,
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 "./page-permission-tab";
import { PublishTab } from "./publish-tab";
type PageShareModalProps = {
readOnly?: boolean;
};
export function PageShareModal({ readOnly }: PageShareModalProps) {
const { t } = useTranslation();
const { pageSlug } = useParams();
const pageSlugId = extractPageSlugId(pageSlug);
const [opened, { open, close }] = useDisclosure(false);
const [activeTab, setActiveTab] = useState<string | null>("share");
const { data: page } = usePageQuery({ pageId: pageSlugId });
const pageId = page?.id;
const { data: restrictionInfo, isLoading: restrictionLoading } =
usePageRestrictionInfoQuery(pageId);
const isRestricted =
restrictionInfo?.hasDirectRestriction ||
restrictionInfo?.hasInheritedRestriction;
return (
<>
<Button
style={{ border: "none" }}
size="compact-sm"
leftSection={
<Indicator
color={isRestricted ? "red" : "green"}
offset={5}
disabled={!restrictionInfo}
withBorder
>
{isRestricted ? (
<IconLock size={20} stroke={1.5} />
) : (
<IconWorld size={20} stroke={1.5} />
)}
</Indicator>
}
variant="default"
onClick={open}
>
{t("Share")}
</Button>
<Modal
opened={opened}
onClose={close}
title={t("Share")}
size="md"
centered
>
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List mb="md">
<Tabs.Tab value="share">{t("Share")}</Tabs.Tab>
<Tabs.Tab value="publish">{t("Publish")}</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="share">
{restrictionLoading || !pageId ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : (
<PagePermissionTab
pageId={pageId}
restrictionInfo={restrictionInfo}
/>
)}
</Tabs.Panel>
<Tabs.Panel value="publish">
<PublishTab pageId={pageId} readOnly={readOnly} />
</Tabs.Panel>
</Tabs>
</Modal>
</>
);
}
@@ -0,0 +1,221 @@
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;
};
export function PublishTab({ pageId, readOnly }: 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 (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,10 @@
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 "./queries/page-permission-query";
export * from "./services/page-permission-service";
export * from "./types/page-permission.types";
export * from "./types/page-permission-role-data";
@@ -7,7 +7,7 @@ import {
} from "@tanstack/react-query";
import {
IAddPagePermission,
IPagePermission,
IPagePermissionMember,
IPageRestrictionInfo,
IRemovePagePermission,
IUpdatePagePermissionRole,
@@ -38,7 +38,7 @@ export function usePageRestrictionInfoQuery(
export function usePagePermissionsQuery(
pageId: string,
params?: QueryParams,
): UseQueryResult<IPagination<IPagePermission>, Error> {
): UseQueryResult<IPagination<IPagePermissionMember>, Error> {
return useQuery({
queryKey: ["page-permissions", pageId, params],
queryFn: () => getPagePermissions(pageId, params),
@@ -88,7 +88,7 @@ export function useUnrestrictPageMutation() {
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to unrestrict page"),
message: errorMessage || t("Failed to remove page restriction"),
color: "red",
});
},
@@ -109,7 +109,7 @@ export function useAddPagePermissionMutation() {
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to add page permission"),
message: errorMessage || t("Failed to add permission"),
color: "red",
});
},
@@ -130,7 +130,7 @@ export function useRemovePagePermissionMutation() {
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to remove page permission"),
message: errorMessage || t("Failed to remove permission"),
color: "red",
});
},
@@ -151,7 +151,7 @@ export function useUpdatePagePermissionRoleMutation() {
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to update page permission role"),
message: errorMessage || t("Failed to update permission"),
color: "red",
});
},
@@ -2,7 +2,7 @@ import api from "@/lib/api-client";
import { IPagination, QueryParams } from "@/lib/types";
import {
IAddPagePermission,
IPagePermission,
IPagePermissionMember,
IPageRestrictionInfo,
IRemovePagePermission,
IUpdatePagePermissionRole,
@@ -37,8 +37,8 @@ export async function unrestrictPage(pageId: string): Promise<void> {
export async function getPagePermissions(
pageId: string,
params?: QueryParams,
): Promise<IPagination<IPagePermission>> {
const req = await api.post<IPagination<IPagePermission>>(
): Promise<IPagination<IPagePermissionMember>> {
const req = await api.post<IPagination<IPagePermissionMember>>(
"/pages/permissions/members",
{ pageId, ...params },
);
@@ -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;
}
@@ -3,10 +3,6 @@ export enum PagePermissionRole {
WRITER = "writer",
}
export type IRestrictPage = {
pageId: string;
};
export type IAddPagePermission = {
pageId: string;
role: PagePermissionRole;
@@ -27,29 +23,35 @@ export type IUpdatePagePermissionRole = {
groupId?: string;
};
export type IRemovePageRestriction = {
pageId: string;
};
export type IPagePermission = {
id: string;
pageId: string;
role: PagePermissionRole;
userId?: string;
groupId?: string;
user?: {
id: string;
name: string;
avatarUrl: string;
};
group?: {
id: string;
name: string;
};
};
export type IPageRestrictionInfo = {
isRestricted: boolean;
hasAccess: boolean;
role?: PagePermissionRole;
id: string;
title: string;
hasDirectRestriction: boolean;
hasInheritedRestriction: boolean;
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;
@@ -44,6 +44,7 @@ import { PageStateSegmentedControl } from "@/features/user/components/page-state
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;
@@ -89,7 +90,9 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
{!readOnly && <PageStateSegmentedControl size="xs" />}
<ShareModal readOnly={readOnly} />
{/*<ShareModal readOnly={readOnly} />*/}
<PageShareModal readOnly={readOnly}/>
<Tooltip label={t("Comments")} openDelay={250} withArrow>
<ActionIcon