From 886d9591fa1c3cbd31688e94e8e5556725b92eed Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Mon, 3 Jun 2024 02:54:12 +0100 Subject: [PATCH] frontend permissions * rework backend workspace permissions --- apps/client/package.json | 2 + .../components/layouts/global/top-menu.tsx | 4 +- .../client/src/components/ui/emoji-picker.tsx | 51 +++++--- .../src/components/ui/role-select-menu.tsx | 4 +- .../comment/components/comment-list-item.tsx | 16 ++- .../src/features/editor/full-editor.tsx | 5 +- .../src/features/editor/page-editor.tsx | 7 +- .../src/features/editor/title-editor.tsx | 4 + .../group/components/group-details.tsx | 10 +- .../group/components/group-members.tsx | 42 ++++--- .../components/header/page-header-menu.tsx | 33 +++-- .../page/components/header/page-header.tsx | 7 +- .../src/features/page/queries/page-query.ts | 19 ++- .../page/tree/components/space-tree.tsx | 49 +++++--- .../space/components/edit-space-form.tsx | 21 ++-- .../space/components/settings-modal.tsx | 34 ++++-- .../space/components/sidebar/space-name.tsx | 2 +- .../components/sidebar/space-sidebar.tsx | 67 ++++++---- .../space/components/space-details.tsx | 5 +- .../space/components/space-members.tsx | 56 +++++---- .../space/permissions/permissions.type.ts | 17 +++ .../space/permissions/use-space-ability.ts | 15 +++ .../src/features/space/queries/space-query.ts | 3 + .../src/features/space/types/space.types.ts | 20 +++ .../src/features/user/user-provider.tsx | 7 +- .../components/workspace-invite-form.tsx | 1 - .../components/workspace-invites-table.tsx | 12 +- .../components/workspace-members-table.tsx | 3 + .../components/workspace-name-form.tsx | 17 ++- apps/client/src/hooks/use-user-role.tsx | 19 +++ apps/client/src/lib/utils.ts | 2 + apps/client/src/pages/page/page.tsx | 32 ++++- .../src/pages/settings/group/groups.tsx | 7 +- .../settings/workspace/workspace-members.tsx | 7 +- .../extensions/authentication.extension.ts | 6 +- .../extensions/persistence.extension.ts | 2 +- .../core/attachment/attachment.controller.ts | 18 ++- .../casl/abilities/casl-ability.factory.ts | 61 ---------- .../casl/abilities/space-ability.factory.ts | 8 +- .../abilities/workspace-ability.factory.ts | 73 +++++++++++ apps/server/src/core/casl/ability.action.ts | 7 -- apps/server/src/core/casl/casl.module.ts | 6 +- .../casl/decorators/policies.decorator.ts | 6 - .../src/core/casl/guards/policies.guard.ts | 40 ------ .../interfaces/policy-handler.interface.ts | 9 -- .../casl/interfaces/space-ability.type.ts | 2 +- .../casl/interfaces/workspace-ability.type.ts | 21 ++++ .../server/src/core/group/group.controller.ts | 81 ++++++++---- apps/server/src/core/page/page.controller.ts | 2 +- .../src/core/space/dto/create-space.dto.ts | 4 +- .../src/core/space/services/space.service.ts | 4 - .../server/src/core/space/space.controller.ts | 18 ++- .../controllers/workspace.controller.ts | 115 +++++++++++------- pnpm-lock.yaml | 17 +++ 54 files changed, 715 insertions(+), 385 deletions(-) create mode 100644 apps/client/src/features/space/permissions/permissions.type.ts create mode 100644 apps/client/src/features/space/permissions/use-space-ability.ts create mode 100644 apps/client/src/hooks/use-user-role.tsx delete mode 100644 apps/server/src/core/casl/abilities/casl-ability.factory.ts create mode 100644 apps/server/src/core/casl/abilities/workspace-ability.factory.ts delete mode 100644 apps/server/src/core/casl/ability.action.ts delete mode 100644 apps/server/src/core/casl/decorators/policies.decorator.ts delete mode 100644 apps/server/src/core/casl/guards/policies.guard.ts delete mode 100644 apps/server/src/core/casl/interfaces/policy-handler.interface.ts create mode 100644 apps/server/src/core/casl/interfaces/workspace-ability.type.ts 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) {