diff --git a/apps/client/package.json b/apps/client/package.json index fb9e0058..1c8749c2 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -9,6 +9,8 @@ "preview": "vite preview" }, "dependencies": { + "@casl/ability": "^6.7.1", + "@casl/react": "^3.1.0", "@emoji-mart/data": "^1.1.2", "@emoji-mart/react": "^1.1.1", "@mantine/core": "^7.7.1", diff --git a/apps/client/src/components/layouts/global/top-menu.tsx b/apps/client/src/components/layouts/global/top-menu.tsx index d0edb87c..5d3ea7e3 100644 --- a/apps/client/src/components/layouts/global/top-menu.tsx +++ b/apps/client/src/components/layouts/global/top-menu.tsx @@ -17,8 +17,8 @@ export default function TopMenu() { const [currentUser] = useAtom(currentUserAtom); const { logout } = useAuth(); - const user = currentUser?.user; - const workspace = currentUser?.workspace; + const user = currentUser.user; + const workspace = currentUser.workspace; return ( diff --git a/apps/client/src/components/ui/emoji-picker.tsx b/apps/client/src/components/ui/emoji-picker.tsx index 8c81a873..c0fe3c45 100644 --- a/apps/client/src/components/ui/emoji-picker.tsx +++ b/apps/client/src/components/ui/emoji-picker.tsx @@ -1,16 +1,27 @@ -import React, { ReactNode } from 'react'; -import data from '@emoji-mart/data'; -import Picker from '@emoji-mart/react'; -import { ActionIcon, Popover, Button, useMantineColorScheme } from '@mantine/core'; -import { useDisclosure } from '@mantine/hooks'; +import React, { ReactNode } from "react"; +import data from "@emoji-mart/data"; +import Picker from "@emoji-mart/react"; +import { + ActionIcon, + Popover, + Button, + useMantineColorScheme, +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; export interface EmojiPickerInterface { onEmojiSelect: (emoji: any) => void; icon: ReactNode; removeEmojiAction: () => void; + readOnly: boolean; } -function EmojiPicker({ onEmojiSelect, icon, removeEmojiAction }: EmojiPickerInterface) { +function EmojiPicker({ + onEmojiSelect, + icon, + removeEmojiAction, + readOnly, +}: EmojiPickerInterface) { const [opened, handlers] = useDisclosure(false); const { colorScheme } = useMantineColorScheme(); @@ -30,6 +41,7 @@ function EmojiPicker({ onEmojiSelect, icon, removeEmojiAction }: EmojiPickerInte onClose={handlers.close} width={332} position="bottom" + disabled={readOnly} > @@ -37,18 +49,27 @@ function EmojiPicker({ onEmojiSelect, icon, removeEmojiAction }: EmojiPickerInte - - - ); diff --git a/apps/client/src/components/ui/role-select-menu.tsx b/apps/client/src/components/ui/role-select-menu.tsx index c38700b5..594029e8 100644 --- a/apps/client/src/components/ui/role-select-menu.tsx +++ b/apps/client/src/components/ui/role-select-menu.tsx @@ -27,17 +27,19 @@ interface SpaceRoleMenuProps { roles: IRoleData[]; roleName: string; onChange?: (value: string) => void; + disabled?: boolean; } export default function RoleSelectMenu({ roles, roleName, onChange, + disabled, }: SpaceRoleMenuProps) { return ( - + diff --git a/apps/client/src/features/comment/components/comment-list-item.tsx b/apps/client/src/features/comment/components/comment-list-item.tsx index 71d47200..57dfaf7c 100644 --- a/apps/client/src/features/comment/components/comment-list-item.tsx +++ b/apps/client/src/features/comment/components/comment-list-item.tsx @@ -1,7 +1,7 @@ import { Group, Text, Box } from "@mantine/core"; import React, { useState } from "react"; import classes from "./comment.module.css"; -import { useAtomValue } from "jotai"; +import { useAtom, useAtomValue } from "jotai"; import { timeAgo } from "@/lib/time"; import CommentEditor from "@/features/comment/components/comment-editor"; import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms"; @@ -14,6 +14,7 @@ import { } from "@/features/comment/queries/comment-query"; import { IComment } from "@/features/comment/types/comment.types"; import { UserAvatar } from "@/components/ui/user-avatar"; +import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts"; interface CommentListItemProps { comment: IComment; @@ -28,6 +29,7 @@ function CommentListItem({ comment }: CommentListItemProps) { const [content, setContent] = useState(comment.content); const updateCommentMutation = useUpdateCommentMutation(); const deleteCommentMutation = useDeleteCommentMutation(comment.pageId); + const [currentUser] = useAtom(currentUserAtom); async function handleUpdateComment() { try { @@ -79,10 +81,12 @@ function CommentListItem({ comment }: CommentListItemProps) { )*/} - + {currentUser?.user?.id === comment.creatorId && ( + + )} @@ -106,7 +110,7 @@ function CommentListItem({ comment }: CommentListItemProps) { setContent(newContent)} + onUpdate={(newContent: any) => setContent(newContent)} autofocus={true} /> diff --git a/apps/client/src/features/editor/full-editor.tsx b/apps/client/src/features/editor/full-editor.tsx index bbf174eb..d2d54ebb 100644 --- a/apps/client/src/features/editor/full-editor.tsx +++ b/apps/client/src/features/editor/full-editor.tsx @@ -11,6 +11,7 @@ export interface FullEditorProps { slugId: string; title: string; spaceSlug: string; + editable: boolean; } export function FullEditor({ @@ -18,6 +19,7 @@ export function FullEditor({ title, slugId, spaceSlug, + editable, }: FullEditorProps) { return (
@@ -26,8 +28,9 @@ export function FullEditor({ slugId={slugId} title={title} spaceSlug={spaceSlug} + editable={editable} /> - +
); } diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index 836afe36..684431fd 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -24,13 +24,10 @@ import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubbl interface PageEditorProps { pageId: string; - editable?: boolean; + editable: boolean; } -export default function PageEditor({ - pageId, - editable = true, -}: PageEditorProps) { +export default function PageEditor({ pageId, editable }: PageEditorProps) { const [token] = useAtom(authTokensAtom); const collaborationURL = useCollaborationUrl(); const [currentUser] = useAtom(currentUserAtom); diff --git a/apps/client/src/features/editor/title-editor.tsx b/apps/client/src/features/editor/title-editor.tsx index 253629f9..1bd4eb91 100644 --- a/apps/client/src/features/editor/title-editor.tsx +++ b/apps/client/src/features/editor/title-editor.tsx @@ -28,6 +28,7 @@ export interface TitleEditorProps { slugId: string; title: string; spaceSlug: string; + editable: boolean; } export function TitleEditor({ @@ -35,6 +36,7 @@ export function TitleEditor({ slugId, title, spaceSlug, + editable, }: TitleEditorProps) { const [debouncedTitleState, setDebouncedTitleState] = useState(null); const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 1000); @@ -57,6 +59,7 @@ export function TitleEditor({ Text, Placeholder.configure({ placeholder: "Untitled", + showOnlyWhenEditable: false, }), History.configure({ depth: 20, @@ -72,6 +75,7 @@ export function TitleEditor({ const currentTitle = editor.getText(); setDebouncedTitleState(currentTitle); }, + editable: editable, content: title, }); diff --git a/apps/client/src/features/group/components/group-details.tsx b/apps/client/src/features/group/components/group-details.tsx index bc4fa3d9..c932601f 100644 --- a/apps/client/src/features/group/components/group-details.tsx +++ b/apps/client/src/features/group/components/group-details.tsx @@ -6,11 +6,13 @@ import React from "react"; import { useDisclosure } from "@mantine/hooks"; import EditGroupModal from "@/features/group/components/edit-group-modal.tsx"; import GroupActionMenu from "@/features/group/components/group-action-menu.tsx"; +import useUserRole from "@/hooks/use-user-role.tsx"; export default function GroupDetails() { const { groupId } = useParams(); const { data: group, isLoading } = useGroupQuery(groupId); const [opened, { open, close }] = useDisclosure(false); + const { isAdmin } = useUserRole(); return ( <> @@ -21,8 +23,12 @@ export default function GroupDetails() { {group.description} - - + {isAdmin && ( + <> + + + + )} )} diff --git a/apps/client/src/features/group/components/group-members.tsx b/apps/client/src/features/group/components/group-members.tsx index 443bc752..b51a169d 100644 --- a/apps/client/src/features/group/components/group-members.tsx +++ b/apps/client/src/features/group/components/group-members.tsx @@ -8,11 +8,13 @@ import React from "react"; import { IconDots } from "@tabler/icons-react"; import { modals } from "@mantine/modals"; import { UserAvatar } from "@/components/ui/user-avatar.tsx"; +import useUserRole from "@/hooks/use-user-role.tsx"; export default function GroupMembersList() { const { groupId } = useParams(); const { data, isLoading } = useGroupMembersQuery(groupId); const removeGroupMember = useRemoveGroupMemberMutation(); + const { isAdmin } = useUserRole(); const onRemove = async (userId: string) => { const memberToRemove = { @@ -71,26 +73,28 @@ export default function GroupMembersList() { - - - - - - + {isAdmin && ( + + + + + + - - openRemoveModal(user.id)}> - Remove group member - - - + + openRemoveModal(user.id)}> + Remove group member + + + + )} ))} diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx index 65cacf89..5fdfa078 100644 --- a/apps/client/src/features/page/components/header/page-header-menu.tsx +++ b/apps/client/src/features/page/components/header/page-header-menu.tsx @@ -19,8 +19,12 @@ import { getAppUrl } from "@/lib/config.ts"; import { extractPageSlugId } from "@/lib"; import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts"; import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx"; +import { boolean } from "zod"; -export default function PageHeaderMenu() { +interface PageHeaderMenuProps { + readOnly?: boolean; +} +export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) { const toggleAside = useToggleAside(); return ( @@ -35,12 +39,15 @@ export default function PageHeaderMenu() { - + ); } -function PageActionMenu() { +interface PageActionMenuProps { + readOnly?: boolean; +} +function PageActionMenu({ readOnly }: PageActionMenuProps) { const [, setHistoryModalOpen] = useAtom(historyAtoms); const clipboard = useClipboard({ timeout: 500 }); const { pageSlug, spaceSlug } = useParams(); @@ -96,14 +103,18 @@ function PageActionMenu() { Page history - - } - onClick={handleDeletePage} - > - Delete - + {!readOnly && ( + <> + + } + onClick={handleDeletePage} + > + Delete + + + )}
); diff --git a/apps/client/src/features/page/components/header/page-header.tsx b/apps/client/src/features/page/components/header/page-header.tsx index d802e8e6..b0f380e6 100644 --- a/apps/client/src/features/page/components/header/page-header.tsx +++ b/apps/client/src/features/page/components/header/page-header.tsx @@ -3,14 +3,17 @@ import PageHeaderMenu from "@/features/page/components/header/page-header-menu.t import { Group } from "@mantine/core"; import Breadcrumb from "@/features/page/components/breadcrumbs/breadcrumb.tsx"; -export default function PageHeader() { +interface Props { + readOnly?: boolean; +} +export default function PageHeader({ readOnly }: Props) { return (
- +
diff --git a/apps/client/src/features/page/queries/page-query.ts b/apps/client/src/features/page/queries/page-query.ts index 3d8f36ea..cb6e9113 100644 --- a/apps/client/src/features/page/queries/page-query.ts +++ b/apps/client/src/features/page/queries/page-query.ts @@ -49,11 +49,26 @@ export function useCreatePageMutation() { export function useUpdatePageMutation() { const queryClient = useQueryClient(); + return useMutation>({ mutationFn: (data) => updatePage(data), onSuccess: (data) => { - // update page in cache - queryClient.setQueryData(["pages", data.slugId], data); + const pageBySlug = queryClient.getQueryData([ + "pages", + data.slugId, + ]); + const pageById = queryClient.getQueryData(["pages", data.id]); + + if (pageBySlug) { + queryClient.setQueryData(["pages", data.slugId], { + ...pageBySlug, + ...data, + }); + } + + if (pageById) { + queryClient.setQueryData(["pages", data.id], { ...pageById, ...data }); + } }, }); } diff --git a/apps/client/src/features/page/tree/components/space-tree.tsx b/apps/client/src/features/page/tree/components/space-tree.tsx index de1688af..d5a27351 100644 --- a/apps/client/src/features/page/tree/components/space-tree.tsx +++ b/apps/client/src/features/page/tree/components/space-tree.tsx @@ -50,11 +50,12 @@ import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal. interface SpaceTreeProps { spaceId: string; + readOnly: boolean; } const openTreeNodesAtom = atom({}); -export default function SpaceTree({ spaceId }: SpaceTreeProps) { +export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) { const { pageSlug } = useParams(); const { data, setData, controllers } = useTreeMutation>(spaceId); @@ -190,6 +191,9 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) { {rootElement.current && ( ) { ) } + readOnly={tree.props.disableEdit as boolean} removeEmojiAction={handleRemoveEmoji} /> @@ -336,11 +341,14 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps) {
- handleLoadChildren(node)} - /> + + {!tree.props.disableEdit && ( + handleLoadChildren(node)} + /> + )}
@@ -429,18 +437,23 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) { Copy link - - - } - onClick={() => - openDeleteModal({ onConfirm: () => treeApi?.delete(node) }) - } - > - Delete - + {!(treeApi.props.disableEdit as boolean) && ( + <> + + + + } + onClick={() => + openDeleteModal({ onConfirm: () => treeApi?.delete(node) }) + } + > + Delete + + + )}
); diff --git a/apps/client/src/features/space/components/edit-space-form.tsx b/apps/client/src/features/space/components/edit-space-form.tsx index 6cc684af..c94ee295 100644 --- a/apps/client/src/features/space/components/edit-space-form.tsx +++ b/apps/client/src/features/space/components/edit-space-form.tsx @@ -13,8 +13,9 @@ const formSchema = z.object({ type FormValues = z.infer; interface EditSpaceFormProps { space: ISpace; + readOnly?: boolean; } -export function EditSpaceForm({ space }: EditSpaceFormProps) { +export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) { const updateSpaceMutation = useUpdateSpaceMutation(); const form = useForm({ @@ -51,14 +52,16 @@ export function EditSpaceForm({ space }: EditSpaceFormProps) {