mirror of
https://github.com/docmost/docmost.git
synced 2026-05-08 23:33:09 +08:00
UI - WIP
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user