mirror of
https://github.com/docmost/docmost.git
synced 2025-11-12 16:42:37 +10:00
frontend permissions
* rework backend workspace permissions
This commit is contained in:
@ -9,6 +9,8 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@casl/ability": "^6.7.1",
|
||||||
|
"@casl/react": "^3.1.0",
|
||||||
"@emoji-mart/data": "^1.1.2",
|
"@emoji-mart/data": "^1.1.2",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@mantine/core": "^7.7.1",
|
"@mantine/core": "^7.7.1",
|
||||||
|
|||||||
@ -17,8 +17,8 @@ export default function TopMenu() {
|
|||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const { logout } = useAuth();
|
const { logout } = useAuth();
|
||||||
|
|
||||||
const user = currentUser?.user;
|
const user = currentUser.user;
|
||||||
const workspace = currentUser?.workspace;
|
const workspace = currentUser.workspace;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu width={250} position="bottom-end" withArrow shadow={"lg"}>
|
<Menu width={250} position="bottom-end" withArrow shadow={"lg"}>
|
||||||
|
|||||||
@ -1,16 +1,27 @@
|
|||||||
import React, { ReactNode } from 'react';
|
import React, { ReactNode } from "react";
|
||||||
import data from '@emoji-mart/data';
|
import data from "@emoji-mart/data";
|
||||||
import Picker from '@emoji-mart/react';
|
import Picker from "@emoji-mart/react";
|
||||||
import { ActionIcon, Popover, Button, useMantineColorScheme } from '@mantine/core';
|
import {
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
ActionIcon,
|
||||||
|
Popover,
|
||||||
|
Button,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
|
||||||
export interface EmojiPickerInterface {
|
export interface EmojiPickerInterface {
|
||||||
onEmojiSelect: (emoji: any) => void;
|
onEmojiSelect: (emoji: any) => void;
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
removeEmojiAction: () => void;
|
removeEmojiAction: () => void;
|
||||||
|
readOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmojiPicker({ onEmojiSelect, icon, removeEmojiAction }: EmojiPickerInterface) {
|
function EmojiPicker({
|
||||||
|
onEmojiSelect,
|
||||||
|
icon,
|
||||||
|
removeEmojiAction,
|
||||||
|
readOnly,
|
||||||
|
}: EmojiPickerInterface) {
|
||||||
const [opened, handlers] = useDisclosure(false);
|
const [opened, handlers] = useDisclosure(false);
|
||||||
const { colorScheme } = useMantineColorScheme();
|
const { colorScheme } = useMantineColorScheme();
|
||||||
|
|
||||||
@ -30,6 +41,7 @@ function EmojiPicker({ onEmojiSelect, icon, removeEmojiAction }: EmojiPickerInte
|
|||||||
onClose={handlers.close}
|
onClose={handlers.close}
|
||||||
width={332}
|
width={332}
|
||||||
position="bottom"
|
position="bottom"
|
||||||
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<ActionIcon c="gray" variant="transparent" onClick={handlers.toggle}>
|
<ActionIcon c="gray" variant="transparent" onClick={handlers.toggle}>
|
||||||
@ -37,18 +49,27 @@ function EmojiPicker({ onEmojiSelect, icon, removeEmojiAction }: EmojiPickerInte
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
<Popover.Dropdown bg="000" style={{ border: "none" }}>
|
<Popover.Dropdown bg="000" style={{ border: "none" }}>
|
||||||
<Picker data={data} onEmojiSelect={handleEmojiSelect}
|
<Picker
|
||||||
perLine={8}
|
data={data}
|
||||||
skinTonePosition='search'
|
onEmojiSelect={handleEmojiSelect}
|
||||||
theme={colorScheme}
|
perLine={8}
|
||||||
|
skinTonePosition="search"
|
||||||
|
theme={colorScheme}
|
||||||
/>
|
/>
|
||||||
<Button variant="default" c="gray"
|
<Button
|
||||||
size="xs"
|
variant="default"
|
||||||
style={{ position: 'absolute', zIndex: 2, bottom: '1rem', right: '1rem'}}
|
c="gray"
|
||||||
onClick={handleRemoveEmoji}>
|
size="xs"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
zIndex: 2,
|
||||||
|
bottom: "1rem",
|
||||||
|
right: "1rem",
|
||||||
|
}}
|
||||||
|
onClick={handleRemoveEmoji}
|
||||||
|
>
|
||||||
Remove
|
Remove
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
</Popover.Dropdown>
|
</Popover.Dropdown>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -27,17 +27,19 @@ interface SpaceRoleMenuProps {
|
|||||||
roles: IRoleData[];
|
roles: IRoleData[];
|
||||||
roleName: string;
|
roleName: string;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RoleSelectMenu({
|
export default function RoleSelectMenu({
|
||||||
roles,
|
roles,
|
||||||
roleName,
|
roleName,
|
||||||
onChange,
|
onChange,
|
||||||
|
disabled,
|
||||||
}: SpaceRoleMenuProps) {
|
}: SpaceRoleMenuProps) {
|
||||||
return (
|
return (
|
||||||
<Menu withArrow>
|
<Menu withArrow>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<RoleButton name={roleName} />
|
<RoleButton name={roleName} disabled={disabled} />
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Group, Text, Box } from "@mantine/core";
|
import { Group, Text, Box } from "@mantine/core";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import classes from "./comment.module.css";
|
import classes from "./comment.module.css";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { timeAgo } from "@/lib/time";
|
import { timeAgo } from "@/lib/time";
|
||||||
import CommentEditor from "@/features/comment/components/comment-editor";
|
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
||||||
@ -14,6 +14,7 @@ import {
|
|||||||
} from "@/features/comment/queries/comment-query";
|
} from "@/features/comment/queries/comment-query";
|
||||||
import { IComment } from "@/features/comment/types/comment.types";
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
import { UserAvatar } from "@/components/ui/user-avatar";
|
import { UserAvatar } from "@/components/ui/user-avatar";
|
||||||
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
|
|
||||||
interface CommentListItemProps {
|
interface CommentListItemProps {
|
||||||
comment: IComment;
|
comment: IComment;
|
||||||
@ -28,6 +29,7 @@ function CommentListItem({ comment }: CommentListItemProps) {
|
|||||||
const [content, setContent] = useState<string>(comment.content);
|
const [content, setContent] = useState<string>(comment.content);
|
||||||
const updateCommentMutation = useUpdateCommentMutation();
|
const updateCommentMutation = useUpdateCommentMutation();
|
||||||
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
||||||
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
|
|
||||||
async function handleUpdateComment() {
|
async function handleUpdateComment() {
|
||||||
try {
|
try {
|
||||||
@ -79,10 +81,12 @@ function CommentListItem({ comment }: CommentListItemProps) {
|
|||||||
<ResolveComment commentId={comment.id} pageId={comment.pageId} resolvedAt={comment.resolvedAt} />
|
<ResolveComment commentId={comment.id} pageId={comment.pageId} resolvedAt={comment.resolvedAt} />
|
||||||
)*/}
|
)*/}
|
||||||
|
|
||||||
<CommentMenu
|
{currentUser?.user?.id === comment.creatorId && (
|
||||||
onEditComment={handleEditToggle}
|
<CommentMenu
|
||||||
onDeleteComment={handleDeleteComment}
|
onEditComment={handleEditToggle}
|
||||||
/>
|
onDeleteComment={handleDeleteComment}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
@ -106,7 +110,7 @@ function CommentListItem({ comment }: CommentListItemProps) {
|
|||||||
<CommentEditor
|
<CommentEditor
|
||||||
defaultContent={content}
|
defaultContent={content}
|
||||||
editable={true}
|
editable={true}
|
||||||
onUpdate={(newContent) => setContent(newContent)}
|
onUpdate={(newContent: any) => setContent(newContent)}
|
||||||
autofocus={true}
|
autofocus={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@ export interface FullEditorProps {
|
|||||||
slugId: string;
|
slugId: string;
|
||||||
title: string;
|
title: string;
|
||||||
spaceSlug: string;
|
spaceSlug: string;
|
||||||
|
editable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FullEditor({
|
export function FullEditor({
|
||||||
@ -18,6 +19,7 @@ export function FullEditor({
|
|||||||
title,
|
title,
|
||||||
slugId,
|
slugId,
|
||||||
spaceSlug,
|
spaceSlug,
|
||||||
|
editable,
|
||||||
}: FullEditorProps) {
|
}: FullEditorProps) {
|
||||||
return (
|
return (
|
||||||
<div className={classes.editor}>
|
<div className={classes.editor}>
|
||||||
@ -26,8 +28,9 @@ export function FullEditor({
|
|||||||
slugId={slugId}
|
slugId={slugId}
|
||||||
title={title}
|
title={title}
|
||||||
spaceSlug={spaceSlug}
|
spaceSlug={spaceSlug}
|
||||||
|
editable={editable}
|
||||||
/>
|
/>
|
||||||
<MemoizedPageEditor pageId={pageId} />
|
<MemoizedPageEditor pageId={pageId} editable={editable} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,13 +24,10 @@ import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubbl
|
|||||||
|
|
||||||
interface PageEditorProps {
|
interface PageEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
editable?: boolean;
|
editable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PageEditor({
|
export default function PageEditor({ pageId, editable }: PageEditorProps) {
|
||||||
pageId,
|
|
||||||
editable = true,
|
|
||||||
}: PageEditorProps) {
|
|
||||||
const [token] = useAtom(authTokensAtom);
|
const [token] = useAtom(authTokensAtom);
|
||||||
const collaborationURL = useCollaborationUrl();
|
const collaborationURL = useCollaborationUrl();
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
|
|||||||
@ -28,6 +28,7 @@ export interface TitleEditorProps {
|
|||||||
slugId: string;
|
slugId: string;
|
||||||
title: string;
|
title: string;
|
||||||
spaceSlug: string;
|
spaceSlug: string;
|
||||||
|
editable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TitleEditor({
|
export function TitleEditor({
|
||||||
@ -35,6 +36,7 @@ export function TitleEditor({
|
|||||||
slugId,
|
slugId,
|
||||||
title,
|
title,
|
||||||
spaceSlug,
|
spaceSlug,
|
||||||
|
editable,
|
||||||
}: TitleEditorProps) {
|
}: TitleEditorProps) {
|
||||||
const [debouncedTitleState, setDebouncedTitleState] = useState(null);
|
const [debouncedTitleState, setDebouncedTitleState] = useState(null);
|
||||||
const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 1000);
|
const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 1000);
|
||||||
@ -57,6 +59,7 @@ export function TitleEditor({
|
|||||||
Text,
|
Text,
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: "Untitled",
|
placeholder: "Untitled",
|
||||||
|
showOnlyWhenEditable: false,
|
||||||
}),
|
}),
|
||||||
History.configure({
|
History.configure({
|
||||||
depth: 20,
|
depth: 20,
|
||||||
@ -72,6 +75,7 @@ export function TitleEditor({
|
|||||||
const currentTitle = editor.getText();
|
const currentTitle = editor.getText();
|
||||||
setDebouncedTitleState(currentTitle);
|
setDebouncedTitleState(currentTitle);
|
||||||
},
|
},
|
||||||
|
editable: editable,
|
||||||
content: title,
|
content: title,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -6,11 +6,13 @@ import React from "react";
|
|||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import EditGroupModal from "@/features/group/components/edit-group-modal.tsx";
|
import EditGroupModal from "@/features/group/components/edit-group-modal.tsx";
|
||||||
import GroupActionMenu from "@/features/group/components/group-action-menu.tsx";
|
import GroupActionMenu from "@/features/group/components/group-action-menu.tsx";
|
||||||
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
|
|
||||||
export default function GroupDetails() {
|
export default function GroupDetails() {
|
||||||
const { groupId } = useParams();
|
const { groupId } = useParams();
|
||||||
const { data: group, isLoading } = useGroupQuery(groupId);
|
const { data: group, isLoading } = useGroupQuery(groupId);
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
const { isAdmin } = useUserRole();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -21,8 +23,12 @@ export default function GroupDetails() {
|
|||||||
<Text c="dimmed">{group.description}</Text>
|
<Text c="dimmed">{group.description}</Text>
|
||||||
|
|
||||||
<Group my="md" justify="flex-end">
|
<Group my="md" justify="flex-end">
|
||||||
<AddGroupMemberModal />
|
{isAdmin && (
|
||||||
<GroupActionMenu />
|
<>
|
||||||
|
<AddGroupMemberModal />
|
||||||
|
<GroupActionMenu />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -8,11 +8,13 @@ import React from "react";
|
|||||||
import { IconDots } from "@tabler/icons-react";
|
import { IconDots } from "@tabler/icons-react";
|
||||||
import { modals } from "@mantine/modals";
|
import { modals } from "@mantine/modals";
|
||||||
import { UserAvatar } from "@/components/ui/user-avatar.tsx";
|
import { UserAvatar } from "@/components/ui/user-avatar.tsx";
|
||||||
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
|
|
||||||
export default function GroupMembersList() {
|
export default function GroupMembersList() {
|
||||||
const { groupId } = useParams();
|
const { groupId } = useParams();
|
||||||
const { data, isLoading } = useGroupMembersQuery(groupId);
|
const { data, isLoading } = useGroupMembersQuery(groupId);
|
||||||
const removeGroupMember = useRemoveGroupMemberMutation();
|
const removeGroupMember = useRemoveGroupMemberMutation();
|
||||||
|
const { isAdmin } = useUserRole();
|
||||||
|
|
||||||
const onRemove = async (userId: string) => {
|
const onRemove = async (userId: string) => {
|
||||||
const memberToRemove = {
|
const memberToRemove = {
|
||||||
@ -71,26 +73,28 @@ export default function GroupMembersList() {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Menu
|
{isAdmin && (
|
||||||
shadow="xl"
|
<Menu
|
||||||
position="bottom-end"
|
shadow="xl"
|
||||||
offset={20}
|
position="bottom-end"
|
||||||
width={200}
|
offset={20}
|
||||||
withArrow
|
width={200}
|
||||||
arrowPosition="center"
|
withArrow
|
||||||
>
|
arrowPosition="center"
|
||||||
<Menu.Target>
|
>
|
||||||
<ActionIcon variant="subtle" c="gray">
|
<Menu.Target>
|
||||||
<IconDots size={20} stroke={2} />
|
<ActionIcon variant="subtle" c="gray">
|
||||||
</ActionIcon>
|
<IconDots size={20} stroke={2} />
|
||||||
</Menu.Target>
|
</ActionIcon>
|
||||||
|
</Menu.Target>
|
||||||
|
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item onClick={() => openRemoveModal(user.id)}>
|
<Menu.Item onClick={() => openRemoveModal(user.id)}>
|
||||||
Remove group member
|
Remove group member
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -19,8 +19,12 @@ import { getAppUrl } from "@/lib/config.ts";
|
|||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
||||||
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
|
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();
|
const toggleAside = useToggleAside();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -35,12 +39,15 @@ export default function PageHeaderMenu() {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<PageActionMenu />
|
<PageActionMenu readOnly={readOnly} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PageActionMenu() {
|
interface PageActionMenuProps {
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||||
const [, setHistoryModalOpen] = useAtom(historyAtoms);
|
const [, setHistoryModalOpen] = useAtom(historyAtoms);
|
||||||
const clipboard = useClipboard({ timeout: 500 });
|
const clipboard = useClipboard({ timeout: 500 });
|
||||||
const { pageSlug, spaceSlug } = useParams();
|
const { pageSlug, spaceSlug } = useParams();
|
||||||
@ -96,14 +103,18 @@ function PageActionMenu() {
|
|||||||
Page history
|
Page history
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Divider />
|
{!readOnly && (
|
||||||
<Menu.Item
|
<>
|
||||||
color={"red"}
|
<Menu.Divider />
|
||||||
leftSection={<IconTrash size={16} stroke={2} />}
|
<Menu.Item
|
||||||
onClick={handleDeletePage}
|
color={"red"}
|
||||||
>
|
leftSection={<IconTrash size={16} stroke={2} />}
|
||||||
Delete
|
onClick={handleDeletePage}
|
||||||
</Menu.Item>
|
>
|
||||||
|
Delete
|
||||||
|
</Menu.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -3,14 +3,17 @@ import PageHeaderMenu from "@/features/page/components/header/page-header-menu.t
|
|||||||
import { Group } from "@mantine/core";
|
import { Group } from "@mantine/core";
|
||||||
import Breadcrumb from "@/features/page/components/breadcrumbs/breadcrumb.tsx";
|
import Breadcrumb from "@/features/page/components/breadcrumbs/breadcrumb.tsx";
|
||||||
|
|
||||||
export default function PageHeader() {
|
interface Props {
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
export default function PageHeader({ readOnly }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className={classes.header}>
|
<div className={classes.header}>
|
||||||
<Group justify="space-between" h="100%" px="md" wrap="nowrap">
|
<Group justify="space-between" h="100%" px="md" wrap="nowrap">
|
||||||
<Breadcrumb />
|
<Breadcrumb />
|
||||||
|
|
||||||
<Group justify="flex-end" h="100%" px="md" wrap="nowrap">
|
<Group justify="flex-end" h="100%" px="md" wrap="nowrap">
|
||||||
<PageHeaderMenu />
|
<PageHeaderMenu readOnly={readOnly} />
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -49,11 +49,26 @@ export function useCreatePageMutation() {
|
|||||||
|
|
||||||
export function useUpdatePageMutation() {
|
export function useUpdatePageMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation<IPage, Error, Partial<IPageInput>>({
|
return useMutation<IPage, Error, Partial<IPageInput>>({
|
||||||
mutationFn: (data) => updatePage(data),
|
mutationFn: (data) => updatePage(data),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
// update page in cache
|
const pageBySlug = queryClient.getQueryData<IPage>([
|
||||||
queryClient.setQueryData(["pages", data.slugId], data);
|
"pages",
|
||||||
|
data.slugId,
|
||||||
|
]);
|
||||||
|
const pageById = queryClient.getQueryData<IPage>(["pages", data.id]);
|
||||||
|
|
||||||
|
if (pageBySlug) {
|
||||||
|
queryClient.setQueryData(["pages", data.slugId], {
|
||||||
|
...pageBySlug,
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageById) {
|
||||||
|
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,11 +50,12 @@ import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.
|
|||||||
|
|
||||||
interface SpaceTreeProps {
|
interface SpaceTreeProps {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
|
readOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const openTreeNodesAtom = atom<OpenMap>({});
|
const openTreeNodesAtom = atom<OpenMap>({});
|
||||||
|
|
||||||
export default function SpaceTree({ spaceId }: SpaceTreeProps) {
|
export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
const { data, setData, controllers } =
|
const { data, setData, controllers } =
|
||||||
useTreeMutation<TreeApi<SpaceTreeNode>>(spaceId);
|
useTreeMutation<TreeApi<SpaceTreeNode>>(spaceId);
|
||||||
@ -190,6 +191,9 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) {
|
|||||||
{rootElement.current && (
|
{rootElement.current && (
|
||||||
<Tree
|
<Tree
|
||||||
data={data}
|
data={data}
|
||||||
|
disableDrag={readOnly}
|
||||||
|
disableDrop={readOnly}
|
||||||
|
disableEdit={readOnly}
|
||||||
{...controllers}
|
{...controllers}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
@ -328,6 +332,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
|||||||
<IconFileDescription size="18" />
|
<IconFileDescription size="18" />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
readOnly={tree.props.disableEdit as boolean}
|
||||||
removeEmojiAction={handleRemoveEmoji}
|
removeEmojiAction={handleRemoveEmoji}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -336,11 +341,14 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
|||||||
|
|
||||||
<div className={classes.actions}>
|
<div className={classes.actions}>
|
||||||
<NodeMenu node={node} treeApi={tree} />
|
<NodeMenu node={node} treeApi={tree} />
|
||||||
<CreateNode
|
|
||||||
node={node}
|
{!tree.props.disableEdit && (
|
||||||
treeApi={tree}
|
<CreateNode
|
||||||
onExpandTree={() => handleLoadChildren(node)}
|
node={node}
|
||||||
/>
|
treeApi={tree}
|
||||||
|
onExpandTree={() => handleLoadChildren(node)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@ -429,18 +437,23 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
|||||||
Copy link
|
Copy link
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Divider />
|
{!(treeApi.props.disableEdit as boolean) && (
|
||||||
<Menu.Item
|
<>
|
||||||
c="red"
|
<Menu.Divider />
|
||||||
leftSection={
|
|
||||||
<IconTrash style={{ width: rem(14), height: rem(14) }} />
|
<Menu.Item
|
||||||
}
|
c="red"
|
||||||
onClick={() =>
|
leftSection={
|
||||||
openDeleteModal({ onConfirm: () => treeApi?.delete(node) })
|
<IconTrash style={{ width: rem(14), height: rem(14) }} />
|
||||||
}
|
}
|
||||||
>
|
onClick={() =>
|
||||||
Delete
|
openDeleteModal({ onConfirm: () => treeApi?.delete(node) })
|
||||||
</Menu.Item>
|
}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Menu.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -13,8 +13,9 @@ const formSchema = z.object({
|
|||||||
type FormValues = z.infer<typeof formSchema>;
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
interface EditSpaceFormProps {
|
interface EditSpaceFormProps {
|
||||||
space: ISpace;
|
space: ISpace;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
export function EditSpaceForm({ space }: EditSpaceFormProps) {
|
export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
|
||||||
const updateSpaceMutation = useUpdateSpaceMutation();
|
const updateSpaceMutation = useUpdateSpaceMutation();
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
@ -51,14 +52,16 @@ export function EditSpaceForm({ space }: EditSpaceFormProps) {
|
|||||||
<TextInput
|
<TextInput
|
||||||
id="name"
|
id="name"
|
||||||
label="Name"
|
label="Name"
|
||||||
placeholder="e.g Developers"
|
placeholder="e.g Sales"
|
||||||
|
variant="filled"
|
||||||
{...form.getInputProps("name")}
|
{...form.getInputProps("name")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
id="description"
|
id="description"
|
||||||
label="Description"
|
label="Description"
|
||||||
placeholder="e.g Space for developers to collaborate"
|
placeholder="e.g Space for sales team to collaborate"
|
||||||
|
variant="filled"
|
||||||
autosize
|
autosize
|
||||||
minRows={1}
|
minRows={1}
|
||||||
maxRows={3}
|
maxRows={3}
|
||||||
@ -66,11 +69,13 @@ export function EditSpaceForm({ space }: EditSpaceFormProps) {
|
|||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Group justify="flex-end" mt="md">
|
{!readOnly && (
|
||||||
<Button type="submit" disabled={!form.isDirty()}>
|
<Group justify="flex-end" mt="md">
|
||||||
Save
|
<Button type="submit" disabled={!form.isDirty()}>
|
||||||
</Button>
|
Save
|
||||||
</Group>
|
</Button>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
import { Modal, Tabs, rem, Group, Divider, ScrollArea } from "@mantine/core";
|
import { Modal, Tabs, rem, Group, Divider, ScrollArea } from "@mantine/core";
|
||||||
import SpaceMembersList from "@/features/space/components/space-members.tsx";
|
import SpaceMembersList from "@/features/space/components/space-members.tsx";
|
||||||
import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx";
|
import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx";
|
||||||
import React from "react";
|
import React, { useMemo } from "react";
|
||||||
import GroupActionMenu from "@/features/group/components/group-action-menu.tsx";
|
|
||||||
import SpaceDetails from "@/features/space/components/space-details.tsx";
|
import SpaceDetails from "@/features/space/components/space-details.tsx";
|
||||||
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||||
|
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||||
|
import {
|
||||||
|
SpaceCaslAction,
|
||||||
|
SpaceCaslSubject,
|
||||||
|
} from "@/features/space/permissions/permissions.type.ts";
|
||||||
|
|
||||||
interface SpaceSettingsModalProps {
|
interface SpaceSettingsModalProps {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@ -19,6 +23,9 @@ export default function SpaceSettingsModal({
|
|||||||
}: SpaceSettingsModalProps) {
|
}: SpaceSettingsModalProps) {
|
||||||
const { data: space, isLoading } = useSpaceQuery(spaceId);
|
const { data: space, isLoading } = useSpaceQuery(spaceId);
|
||||||
|
|
||||||
|
const spaceRules = space?.membership?.permissions;
|
||||||
|
const spaceAbility = useMemo(() => useSpaceAbility(spaceRules), [spaceRules]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal.Root
|
<Modal.Root
|
||||||
@ -50,17 +57,30 @@ export default function SpaceSettingsModal({
|
|||||||
|
|
||||||
<ScrollArea h="600" w="100%" scrollbarSize={5}>
|
<ScrollArea h="600" w="100%" scrollbarSize={5}>
|
||||||
<Tabs.Panel value="general">
|
<Tabs.Panel value="general">
|
||||||
<SpaceDetails spaceId={space?.id} />
|
<SpaceDetails
|
||||||
<Divider my="sm" />
|
spaceId={space?.id}
|
||||||
|
readOnly={spaceAbility.cannot(
|
||||||
|
SpaceCaslAction.Manage,
|
||||||
|
SpaceCaslSubject.Settings,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Tabs.Panel value="members">
|
<Tabs.Panel value="members">
|
||||||
<Group my="md" justify="flex-end">
|
<Group my="md" justify="flex-end">
|
||||||
<AddSpaceMembersModal spaceId={space?.id} />
|
{spaceAbility.can(
|
||||||
<GroupActionMenu />
|
SpaceCaslAction.Manage,
|
||||||
|
SpaceCaslSubject.Member,
|
||||||
|
) && <AddSpaceMembersModal spaceId={space?.id} />}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<SpaceMembersList spaceId={space?.id} />
|
<SpaceMembersList
|
||||||
|
spaceId={space?.id}
|
||||||
|
readOnly={spaceAbility.cannot(
|
||||||
|
SpaceCaslAction.Manage,
|
||||||
|
SpaceCaslSubject.Member,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@ -9,7 +9,7 @@ export function SpaceName({ spaceName }: SpaceNameProps) {
|
|||||||
<UnstyledButton className={classes.spaceName}>
|
<UnstyledButton className={classes.spaceName}>
|
||||||
<Group>
|
<Group>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<Text size="md" fw={500}>
|
<Text size="md" fw={500} lineClamp={1}>
|
||||||
{spaceName}
|
{spaceName}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,21 +1,21 @@
|
|||||||
import {
|
import {
|
||||||
UnstyledButton,
|
|
||||||
Text,
|
|
||||||
Group,
|
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Tooltip,
|
Group,
|
||||||
rem,
|
rem,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
UnstyledButton,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { spotlight } from "@mantine/spotlight";
|
import { spotlight } from "@mantine/spotlight";
|
||||||
import {
|
import {
|
||||||
IconSearch,
|
|
||||||
IconPlus,
|
|
||||||
IconSettings,
|
|
||||||
IconHome,
|
IconHome,
|
||||||
|
IconPlus,
|
||||||
|
IconSearch,
|
||||||
|
IconSettings,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
import classes from "./space-sidebar.module.css";
|
import classes from "./space-sidebar.module.css";
|
||||||
import React from "react";
|
import React, { useMemo } from "react";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { SearchSpotlight } from "@/features/search/search-spotlight.tsx";
|
import { SearchSpotlight } from "@/features/search/search-spotlight.tsx";
|
||||||
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
||||||
@ -27,6 +27,11 @@ import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"
|
|||||||
import { SpaceName } from "@/features/space/components/sidebar/space-name.tsx";
|
import { SpaceName } from "@/features/space/components/sidebar/space-name.tsx";
|
||||||
import { getSpaceUrl } from "@/lib/config.ts";
|
import { getSpaceUrl } from "@/lib/config.ts";
|
||||||
import SpaceTree from "@/features/page/tree/components/space-tree.tsx";
|
import SpaceTree from "@/features/page/tree/components/space-tree.tsx";
|
||||||
|
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||||
|
import {
|
||||||
|
SpaceCaslAction,
|
||||||
|
SpaceCaslSubject,
|
||||||
|
} from "@/features/space/permissions/permissions.type.ts";
|
||||||
|
|
||||||
export function SpaceSidebar() {
|
export function SpaceSidebar() {
|
||||||
const [tree] = useAtom(treeApiAtom);
|
const [tree] = useAtom(treeApiAtom);
|
||||||
@ -36,14 +41,17 @@ export function SpaceSidebar() {
|
|||||||
const { spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
const { data: space, isLoading, isError } = useGetSpaceBySlugQuery(spaceSlug);
|
const { data: space, isLoading, isError } = useGetSpaceBySlugQuery(spaceSlug);
|
||||||
|
|
||||||
function handleCreatePage() {
|
const spaceRules = space?.membership?.permissions;
|
||||||
tree?.create({ parentId: null, type: "internal", index: 0 });
|
const spaceAbility = useMemo(() => useSpaceAbility(spaceRules), [spaceRules]);
|
||||||
}
|
|
||||||
|
|
||||||
if (!space) {
|
if (!space) {
|
||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleCreatePage() {
|
||||||
|
tree?.create({ parentId: null, type: "internal", index: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={classes.navbar}>
|
<div className={classes.navbar}>
|
||||||
@ -110,22 +118,33 @@ export function SpaceSidebar() {
|
|||||||
Pages
|
Pages
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Tooltip label="Create page" withArrow position="right">
|
{spaceAbility.can(
|
||||||
<ActionIcon
|
SpaceCaslAction.Manage,
|
||||||
variant="default"
|
SpaceCaslSubject.Page,
|
||||||
size={18}
|
) && (
|
||||||
onClick={handleCreatePage}
|
<Tooltip label="Create page" withArrow position="right">
|
||||||
>
|
<ActionIcon
|
||||||
<IconPlus
|
variant="default"
|
||||||
style={{ width: rem(12), height: rem(12) }}
|
size={18}
|
||||||
stroke={1.5}
|
onClick={handleCreatePage}
|
||||||
/>
|
>
|
||||||
</ActionIcon>
|
<IconPlus
|
||||||
</Tooltip>
|
style={{ width: rem(12), height: rem(12) }}
|
||||||
|
stroke={1.5}
|
||||||
|
/>
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<div className={classes.pages}>
|
<div className={classes.pages}>
|
||||||
<SpaceTree spaceId={space.id} />
|
<SpaceTree
|
||||||
|
spaceId={space.id}
|
||||||
|
readOnly={spaceAbility.cannot(
|
||||||
|
SpaceCaslAction.Manage,
|
||||||
|
SpaceCaslSubject.Page,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,8 +5,9 @@ import { Text } from "@mantine/core";
|
|||||||
|
|
||||||
interface SpaceDetailsProps {
|
interface SpaceDetailsProps {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
export default function SpaceDetails({ spaceId }: SpaceDetailsProps) {
|
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
||||||
const { data: space, isLoading } = useSpaceQuery(spaceId);
|
const { data: space, isLoading } = useSpaceQuery(spaceId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -16,7 +17,7 @@ export default function SpaceDetails({ spaceId }: SpaceDetailsProps) {
|
|||||||
<Text my="md" fw={600}>
|
<Text my="md" fw={600}>
|
||||||
Details
|
Details
|
||||||
</Text>
|
</Text>
|
||||||
<EditSpaceForm space={space} />
|
<EditSpaceForm space={space} readOnly={readOnly} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -16,12 +16,17 @@ import {
|
|||||||
getSpaceRoleLabel,
|
getSpaceRoleLabel,
|
||||||
spaceRoleData,
|
spaceRoleData,
|
||||||
} from "@/features/space/types/space-role-data.ts";
|
} from "@/features/space/types/space-role-data.ts";
|
||||||
|
import { formatMemberCount } from "@/lib";
|
||||||
|
|
||||||
type MemberType = "user" | "group";
|
type MemberType = "user" | "group";
|
||||||
interface SpaceMembersProps {
|
interface SpaceMembersProps {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
export default function SpaceMembersList({ spaceId }: SpaceMembersProps) {
|
export default function SpaceMembersList({
|
||||||
|
spaceId,
|
||||||
|
readOnly,
|
||||||
|
}: SpaceMembersProps) {
|
||||||
const { data, isLoading } = useSpaceMembersQuery(spaceId);
|
const { data, isLoading } = useSpaceMembersQuery(spaceId);
|
||||||
const removeSpaceMember = useRemoveSpaceMemberMutation();
|
const removeSpaceMember = useRemoveSpaceMemberMutation();
|
||||||
const changeSpaceMemberRoleMutation = useChangeSpaceMemberRoleMutation();
|
const changeSpaceMemberRoleMutation = useChangeSpaceMemberRoleMutation();
|
||||||
@ -120,7 +125,7 @@ export default function SpaceMembersList({ spaceId }: SpaceMembersProps) {
|
|||||||
{member.type == "user" && member?.email}
|
{member.type == "user" && member?.email}
|
||||||
|
|
||||||
{member.type == "group" &&
|
{member.type == "group" &&
|
||||||
`Group - ${member?.memberCount === 1 ? "1 member" : `${member?.memberCount} members`}`}
|
`Group - ${formatMemberCount(member?.memberCount)}`}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
@ -138,32 +143,37 @@ export default function SpaceMembersList({ spaceId }: SpaceMembersProps) {
|
|||||||
member.role,
|
member.role,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
disabled={readOnly}
|
||||||
/>
|
/>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Menu
|
{!readOnly && (
|
||||||
shadow="xl"
|
<Menu
|
||||||
position="bottom-end"
|
shadow="xl"
|
||||||
offset={20}
|
position="bottom-end"
|
||||||
width={200}
|
offset={20}
|
||||||
withArrow
|
width={200}
|
||||||
arrowPosition="center"
|
withArrow
|
||||||
>
|
arrowPosition="center"
|
||||||
<Menu.Target>
|
>
|
||||||
<ActionIcon variant="subtle" c="gray">
|
<Menu.Target>
|
||||||
<IconDots size={20} stroke={2} />
|
<ActionIcon variant="subtle" c="gray">
|
||||||
</ActionIcon>
|
<IconDots size={20} stroke={2} />
|
||||||
</Menu.Target>
|
</ActionIcon>
|
||||||
|
</Menu.Target>
|
||||||
|
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
onClick={() => openRemoveModal(member.id, member.type)}
|
onClick={() =>
|
||||||
>
|
openRemoveModal(member.id, member.type)
|
||||||
Remove space member
|
}
|
||||||
</Menu.Item>
|
>
|
||||||
</Menu.Dropdown>
|
Remove space member
|
||||||
</Menu>
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -0,0 +1,17 @@
|
|||||||
|
export enum SpaceCaslAction {
|
||||||
|
Manage = "manage",
|
||||||
|
Create = "create",
|
||||||
|
Read = "read",
|
||||||
|
Edit = "edit",
|
||||||
|
Delete = "delete",
|
||||||
|
}
|
||||||
|
export enum SpaceCaslSubject {
|
||||||
|
Settings = "settings",
|
||||||
|
Member = "member",
|
||||||
|
Page = "page",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SpaceAbility =
|
||||||
|
| [SpaceCaslAction, SpaceCaslSubject.Settings]
|
||||||
|
| [SpaceCaslAction, SpaceCaslSubject.Member]
|
||||||
|
| [SpaceCaslAction, SpaceCaslSubject.Page];
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
import { createMongoAbility } from "@casl/ability";
|
||||||
|
import { SpaceAbility } from "@/features/space/permissions/permissions.type.ts";
|
||||||
|
|
||||||
|
export const useSpaceAbility = (rules: any) => {
|
||||||
|
if (!rules) {
|
||||||
|
rules = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ability = createMongoAbility<SpaceAbility>(rules);
|
||||||
|
|
||||||
|
return {
|
||||||
|
can: ability.can.bind(ability),
|
||||||
|
cannot: ability.cannot.bind(ability),
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -38,6 +38,7 @@ export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
|
|||||||
queryKey: ["space", spaceId],
|
queryKey: ["space", spaceId],
|
||||||
queryFn: () => getSpaceById(spaceId),
|
queryFn: () => getSpaceById(spaceId),
|
||||||
enabled: !!spaceId,
|
enabled: !!spaceId,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,6 +49,7 @@ export function useGetSpaceBySlugQuery(
|
|||||||
queryKey: ["space", spaceId],
|
queryKey: ["space", spaceId],
|
||||||
queryFn: () => getSpaceById(spaceId),
|
queryFn: () => getSpaceById(spaceId),
|
||||||
enabled: !!spaceId,
|
enabled: !!spaceId,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,6 +68,7 @@ export function useUpdateSpaceMutation() {
|
|||||||
if (space) {
|
if (space) {
|
||||||
const updatedSpace = { ...space, ...data };
|
const updatedSpace = { ...space, ...data };
|
||||||
queryClient.setQueryData(["space", variables.spaceId], updatedSpace);
|
queryClient.setQueryData(["space", variables.spaceId], updatedSpace);
|
||||||
|
queryClient.setQueryData(["space", data.slug], updatedSpace);
|
||||||
}
|
}
|
||||||
|
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
|
|||||||
@ -1,3 +1,9 @@
|
|||||||
|
import { SpaceRole } from "@/lib/types.ts";
|
||||||
|
import {
|
||||||
|
SpaceCaslAction,
|
||||||
|
SpaceCaslSubject,
|
||||||
|
} from "@/features/space/permissions/permissions.type.ts";
|
||||||
|
|
||||||
export interface ISpace {
|
export interface ISpace {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@ -10,8 +16,22 @@ export interface ISpace {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
memberCount?: number;
|
memberCount?: number;
|
||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
|
membership?: IMembership;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface IMembership {
|
||||||
|
userId: string;
|
||||||
|
role: SpaceRole;
|
||||||
|
permissions?: Permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Permission {
|
||||||
|
action: SpaceCaslAction;
|
||||||
|
subject: SpaceCaslSubject;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Permissions = Permission[];
|
||||||
|
|
||||||
export interface IAddSpaceMember {
|
export interface IAddSpaceMember {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
userIds?: string[];
|
userIds?: string[];
|
||||||
|
|||||||
@ -8,15 +8,16 @@ export function UserProvider({ children }: React.PropsWithChildren) {
|
|||||||
const { data, isLoading, error } = useCurrentUser();
|
const { data, isLoading, error } = useCurrentUser();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data && data.user) {
|
if (data && data.user && data.workspace) {
|
||||||
setCurrentUser(data);
|
setCurrentUser(data);
|
||||||
}
|
}
|
||||||
}, [data, isLoading, setCurrentUser]);
|
}, [data, isLoading]);
|
||||||
|
|
||||||
if (isLoading) return <></>;
|
if (isLoading) return <></>;
|
||||||
|
|
||||||
|
if (!data.user && !data.workspace) return <></>;
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error(error);
|
|
||||||
return <>an error occurred</>;
|
return <>an error occurred</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { Group, Box, Button, TagsInput, Select } from "@mantine/core";
|
import { Group, Box, Button, TagsInput, Select } from "@mantine/core";
|
||||||
import WorkspaceInviteSection from "@/features/workspace/components/members/components/workspace-invite-section.tsx";
|
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { MultiGroupSelect } from "@/features/group/components/multi-group-select.tsx";
|
import { MultiGroupSelect } from "@/features/group/components/multi-group-select.tsx";
|
||||||
import { UserRole } from "@/lib/types.ts";
|
import { UserRole } from "@/lib/types.ts";
|
||||||
|
|||||||
@ -4,12 +4,14 @@ import React from "react";
|
|||||||
import { getUserRoleLabel } from "@/features/workspace/types/user-role-data.ts";
|
import { getUserRoleLabel } from "@/features/workspace/types/user-role-data.ts";
|
||||||
import InviteActionMenu from "@/features/workspace/components/members/components/invite-action-menu.tsx";
|
import InviteActionMenu from "@/features/workspace/components/members/components/invite-action-menu.tsx";
|
||||||
import { IconInfoCircle } from "@tabler/icons-react";
|
import { IconInfoCircle } from "@tabler/icons-react";
|
||||||
import { format } from "date-fns";
|
import { formattedDate } from "@/lib/time.ts";
|
||||||
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
|
|
||||||
export default function WorkspaceInvitesTable() {
|
export default function WorkspaceInvitesTable() {
|
||||||
const { data, isLoading } = useWorkspaceInvitationsQuery({
|
const { data, isLoading } = useWorkspaceInvitationsQuery({
|
||||||
limit: 100,
|
limit: 100,
|
||||||
});
|
});
|
||||||
|
const { isAdmin } = useUserRole();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -44,12 +46,12 @@ export default function WorkspaceInvitesTable() {
|
|||||||
|
|
||||||
<Table.Td>{getUserRoleLabel(invitation.role)}</Table.Td>
|
<Table.Td>{getUserRoleLabel(invitation.role)}</Table.Td>
|
||||||
|
|
||||||
<Table.Td>
|
<Table.Td>{formattedDate(invitation.createdAt)}</Table.Td>
|
||||||
{format(invitation.createdAt, "MM/dd/yyyy")}
|
|
||||||
</Table.Td>
|
|
||||||
|
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<InviteActionMenu invitationId={invitation.id} />
|
{isAdmin && (
|
||||||
|
<InviteActionMenu invitationId={invitation.id} />
|
||||||
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -10,10 +10,12 @@ import {
|
|||||||
getUserRoleLabel,
|
getUserRoleLabel,
|
||||||
userRoleData,
|
userRoleData,
|
||||||
} from "@/features/workspace/types/user-role-data.ts";
|
} from "@/features/workspace/types/user-role-data.ts";
|
||||||
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
|
|
||||||
export default function WorkspaceMembersTable() {
|
export default function WorkspaceMembersTable() {
|
||||||
const { data, isLoading } = useWorkspaceMembersQuery({ limit: 100 });
|
const { data, isLoading } = useWorkspaceMembersQuery({ limit: 100 });
|
||||||
const changeMemberRoleMutation = useChangeMemberRoleMutation();
|
const changeMemberRoleMutation = useChangeMemberRoleMutation();
|
||||||
|
const { isAdmin } = useUserRole();
|
||||||
|
|
||||||
const handleRoleChange = async (
|
const handleRoleChange = async (
|
||||||
userId: string,
|
userId: string,
|
||||||
@ -72,6 +74,7 @@ export default function WorkspaceMembersTable() {
|
|||||||
onChange={(newRole) =>
|
onChange={(newRole) =>
|
||||||
handleRoleChange(user.id, user.role, newRole)
|
handleRoleChange(user.id, user.role, newRole)
|
||||||
}
|
}
|
||||||
|
disabled={!isAdmin}
|
||||||
/>
|
/>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
|
|||||||
import { TextInput, Button } from "@mantine/core";
|
import { TextInput, Button } from "@mantine/core";
|
||||||
import { useForm, zodResolver } from "@mantine/form";
|
import { useForm, zodResolver } from "@mantine/form";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string().nonempty("Workspace name cannot be blank"),
|
name: z.string().nonempty("Workspace name cannot be blank"),
|
||||||
@ -23,6 +24,7 @@ export default function WorkspaceNameForm() {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const [, setWorkspace] = useAtom(workspaceAtom);
|
const [, setWorkspace] = useAtom(workspaceAtom);
|
||||||
|
const { isAdmin } = useUserRole();
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
validate: zodResolver(formSchema),
|
validate: zodResolver(formSchema),
|
||||||
@ -46,6 +48,7 @@ export default function WorkspaceNameForm() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
form.resetDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -57,9 +60,17 @@ export default function WorkspaceNameForm() {
|
|||||||
variant="filled"
|
variant="filled"
|
||||||
{...form.getInputProps("name")}
|
{...form.getInputProps("name")}
|
||||||
/>
|
/>
|
||||||
<Button mt="sm" type="submit" disabled={isLoading} loading={isLoading}>
|
|
||||||
Save
|
{isAdmin && (
|
||||||
</Button>
|
<Button
|
||||||
|
mt="sm"
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || !form.isDirty()}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
19
apps/client/src/hooks/use-user-role.tsx
Normal file
19
apps/client/src/hooks/use-user-role.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { UserRole } from "@/lib/types.ts";
|
||||||
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
|
|
||||||
|
export const useUserRole = () => {
|
||||||
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
|
|
||||||
|
const isAdmin =
|
||||||
|
currentUser?.user?.role === UserRole.ADMIN ||
|
||||||
|
currentUser?.user?.role === UserRole.OWNER;
|
||||||
|
|
||||||
|
const isOwner = currentUser?.user?.role === UserRole.OWNER;
|
||||||
|
|
||||||
|
const isMember = currentUser?.user?.role === UserRole.MEMBER;
|
||||||
|
|
||||||
|
return { isAdmin, isOwner, isMember };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useUserRole;
|
||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { UserRole } from "@/lib/types.ts";
|
||||||
|
|
||||||
export function formatMemberCount(memberCount: number): string {
|
export function formatMemberCount(memberCount: number): string {
|
||||||
if (memberCount === 1) {
|
if (memberCount === 1) {
|
||||||
return "1 member";
|
return "1 member";
|
||||||
|
|||||||
@ -5,14 +5,25 @@ import HistoryModal from "@/features/page-history/components/history-modal";
|
|||||||
import { Helmet } from "react-helmet-async";
|
import { Helmet } from "react-helmet-async";
|
||||||
import PageHeader from "@/features/page/components/header/page-header.tsx";
|
import PageHeader from "@/features/page/components/header/page-header.tsx";
|
||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
|
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||||
|
import {
|
||||||
|
SpaceCaslAction,
|
||||||
|
SpaceCaslSubject,
|
||||||
|
} from "@/features/space/permissions/permissions.type.ts";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const { pageSlug, spaceSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
const {
|
const {
|
||||||
data: page,
|
data: page,
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
} = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
} = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||||
|
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||||
|
|
||||||
|
const spaceRules = space?.membership?.permissions;
|
||||||
|
const spaceAbility = useMemo(() => useSpaceAbility(spaceRules), [spaceRules]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <></>;
|
return <></>;
|
||||||
@ -23,20 +34,33 @@ export default function Page() {
|
|||||||
return <div>Error fetching page data.</div>;
|
return <div>Error fetching page data.</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!space) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
page && (
|
page && (
|
||||||
<div>
|
<div>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{page.title}</title>
|
<title>{`${page?.icon || ""} ${page.title || "untitled"}`}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
<PageHeader />
|
<PageHeader
|
||||||
|
readOnly={spaceAbility.cannot(
|
||||||
|
SpaceCaslAction.Manage,
|
||||||
|
SpaceCaslSubject.Page,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FullEditor
|
<FullEditor
|
||||||
pageId={page.id}
|
pageId={page.id}
|
||||||
title={page.title}
|
title={page.title}
|
||||||
slugId={page.slugId}
|
slugId={page.slugId}
|
||||||
spaceSlug={page?.space?.slug || spaceSlug}
|
spaceSlug={page?.space?.slug}
|
||||||
|
editable={spaceAbility.can(
|
||||||
|
SpaceCaslAction.Manage,
|
||||||
|
SpaceCaslSubject.Page,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<HistoryModal pageId={page.id} />
|
<HistoryModal pageId={page.id} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,15 +1,18 @@
|
|||||||
import GroupList from "@/features/group/components/group-list";
|
import GroupList from "@/features/group/components/group-list";
|
||||||
import SettingsTitle from "@/components/settings/settings-title.tsx";
|
import SettingsTitle from "@/components/settings/settings-title.tsx";
|
||||||
import { Group, Text } from "@mantine/core";
|
import { Group } from "@mantine/core";
|
||||||
import CreateGroupModal from "@/features/group/components/create-group-modal";
|
import CreateGroupModal from "@/features/group/components/create-group-modal";
|
||||||
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
|
|
||||||
export default function Groups() {
|
export default function Groups() {
|
||||||
|
const { isAdmin } = useUserRole();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingsTitle title="Groups" />
|
<SettingsTitle title="Groups" />
|
||||||
|
|
||||||
<Group my="md" justify="flex-end">
|
<Group my="md" justify="flex-end">
|
||||||
<CreateGroupModal />
|
{isAdmin && <CreateGroupModal />}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<GroupList />
|
<GroupList />
|
||||||
|
|||||||
@ -1,15 +1,16 @@
|
|||||||
import WorkspaceInviteSection from "@/features/workspace/components/members/components/workspace-invite-section";
|
|
||||||
import WorkspaceInviteModal from "@/features/workspace/components/members/components/workspace-invite-modal";
|
import WorkspaceInviteModal from "@/features/workspace/components/members/components/workspace-invite-modal";
|
||||||
import { Divider, Group, SegmentedControl, Space, Text } from "@mantine/core";
|
import { Group, SegmentedControl, Space, Text } from "@mantine/core";
|
||||||
import WorkspaceMembersTable from "@/features/workspace/components/members/components/workspace-members-table";
|
import WorkspaceMembersTable from "@/features/workspace/components/members/components/workspace-members-table";
|
||||||
import SettingsTitle from "@/components/settings/settings-title.tsx";
|
import SettingsTitle from "@/components/settings/settings-title.tsx";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import WorkspaceInvitesTable from "@/features/workspace/components/members/components/workspace-invites-table.tsx";
|
import WorkspaceInvitesTable from "@/features/workspace/components/members/components/workspace-invites-table.tsx";
|
||||||
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
|
|
||||||
export default function WorkspaceMembers() {
|
export default function WorkspaceMembers() {
|
||||||
const [segmentValue, setSegmentValue] = useState("members");
|
const [segmentValue, setSegmentValue] = useState("members");
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
const { isAdmin } = useUserRole();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -46,7 +47,7 @@ export default function WorkspaceMembers() {
|
|||||||
withItemsBorders={false}
|
withItemsBorders={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WorkspaceInviteModal />
|
{isAdmin && <WorkspaceInviteModal />}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Space h="lg" />
|
<Space h="lg" />
|
||||||
|
|||||||
@ -47,7 +47,7 @@ export class AuthenticationExtension implements Extension {
|
|||||||
|
|
||||||
const page = await this.pageRepo.findById(pageId);
|
const page = await this.pageRepo.findById(pageId);
|
||||||
if (!page) {
|
if (!page) {
|
||||||
this.logger.warn(`Page not found: ${pageId}}`);
|
this.logger.warn(`Page not found: ${pageId}`);
|
||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,13 +59,13 @@ export class AuthenticationExtension implements Extension {
|
|||||||
const userSpaceRole = findHighestUserSpaceRole(userSpaceRoles);
|
const userSpaceRole = findHighestUserSpaceRole(userSpaceRoles);
|
||||||
|
|
||||||
if (!userSpaceRole) {
|
if (!userSpaceRole) {
|
||||||
this.logger.warn(`User authorized to access page: ${pageId}}`);
|
this.logger.warn(`User not authorized to access page: ${pageId}`);
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userSpaceRole === SpaceRole.READER) {
|
if (userSpaceRole === SpaceRole.READER) {
|
||||||
data.connection.readOnly = true;
|
data.connection.readOnly = true;
|
||||||
this.logger.warn(`User granted readonly access to page: ${pageId}}`);
|
this.logger.debug(`User granted readonly access to page: ${pageId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug(`Authenticated user ${user.id} on page ${pageId}`);
|
this.logger.debug(`Authenticated user ${user.id} on page ${pageId}`);
|
||||||
|
|||||||
@ -57,7 +57,7 @@ export class PersistenceExtension implements Extension {
|
|||||||
return ydoc;
|
return ydoc;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug(`creating fresh ydoc': ${pageId}`);
|
this.logger.debug(`creating fresh ydoc: ${pageId}`);
|
||||||
return new Y.Doc();
|
return new Y.Doc();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -33,13 +33,16 @@ import {
|
|||||||
MAX_AVATAR_SIZE,
|
MAX_AVATAR_SIZE,
|
||||||
MAX_FILE_SIZE,
|
MAX_FILE_SIZE,
|
||||||
} from './attachment.constants';
|
} from './attachment.constants';
|
||||||
import CaslAbilityFactory from '../casl/abilities/casl-ability.factory';
|
|
||||||
import {
|
import {
|
||||||
SpaceCaslAction,
|
SpaceCaslAction,
|
||||||
SpaceCaslSubject,
|
SpaceCaslSubject,
|
||||||
} from '../casl/interfaces/space-ability.type';
|
} from '../casl/interfaces/space-ability.type';
|
||||||
import { Action } from '../casl/ability.action';
|
|
||||||
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
||||||
|
import {
|
||||||
|
WorkspaceCaslAction,
|
||||||
|
WorkspaceCaslSubject,
|
||||||
|
} from '../casl/interfaces/workspace-ability.type';
|
||||||
|
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
|
||||||
|
|
||||||
@Controller('attachments')
|
@Controller('attachments')
|
||||||
export class AttachmentController {
|
export class AttachmentController {
|
||||||
@ -48,7 +51,7 @@ export class AttachmentController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly attachmentService: AttachmentService,
|
private readonly attachmentService: AttachmentService,
|
||||||
private readonly storageService: StorageService,
|
private readonly storageService: StorageService,
|
||||||
private readonly caslAbility: CaslAbilityFactory,
|
private readonly workspaceAbility: WorkspaceAbilityFactory,
|
||||||
private readonly spaceAbility: SpaceAbilityFactory,
|
private readonly spaceAbility: SpaceAbilityFactory,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -155,8 +158,13 @@ export class AttachmentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (attachmentType === AttachmentType.WorkspaceLogo) {
|
if (attachmentType === AttachmentType.WorkspaceLogo) {
|
||||||
const ability = this.caslAbility.createForUser(user, workspace);
|
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||||
if (ability.cannot(Action.Manage, 'Workspace')) {
|
if (
|
||||||
|
ability.cannot(
|
||||||
|
WorkspaceCaslAction.Manage,
|
||||||
|
WorkspaceCaslSubject.Settings,
|
||||||
|
)
|
||||||
|
) {
|
||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,61 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
AbilityBuilder,
|
|
||||||
createMongoAbility,
|
|
||||||
ExtractSubjectType,
|
|
||||||
MongoAbility,
|
|
||||||
} from '@casl/ability';
|
|
||||||
import { Action } from '../ability.action';
|
|
||||||
import { UserRole } from '../../../helpers/types/permission';
|
|
||||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
|
||||||
|
|
||||||
export type Subjects =
|
|
||||||
| 'Workspace'
|
|
||||||
| 'Space'
|
|
||||||
| 'SpaceMember'
|
|
||||||
| 'Group'
|
|
||||||
| 'GroupUser'
|
|
||||||
| 'Attachment'
|
|
||||||
| 'Comment'
|
|
||||||
| 'Page'
|
|
||||||
| 'User'
|
|
||||||
| 'WorkspaceUser'
|
|
||||||
| 'all';
|
|
||||||
export type AppAbility = MongoAbility<[Action, Subjects]>;
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export default class CaslAbilityFactory {
|
|
||||||
createForUser(user: User, workspace: Workspace) {
|
|
||||||
const { can, build } = new AbilityBuilder<AppAbility>(createMongoAbility);
|
|
||||||
|
|
||||||
const userRole = user.role;
|
|
||||||
|
|
||||||
if (userRole === UserRole.OWNER || userRole === UserRole.ADMIN) {
|
|
||||||
// Workspace Users
|
|
||||||
can([Action.Manage], 'Workspace');
|
|
||||||
can([Action.Manage], 'WorkspaceUser');
|
|
||||||
|
|
||||||
// Groups
|
|
||||||
can([Action.Manage], 'Group');
|
|
||||||
can([Action.Manage], 'GroupUser');
|
|
||||||
|
|
||||||
// Attachments
|
|
||||||
can([Action.Manage], 'Attachment');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userRole === UserRole.MEMBER) {
|
|
||||||
can([Action.Read], 'WorkspaceUser');
|
|
||||||
|
|
||||||
// Groups
|
|
||||||
can([Action.Read], 'Group');
|
|
||||||
can([Action.Read], 'GroupUser');
|
|
||||||
|
|
||||||
// Attachments
|
|
||||||
can([Action.Read, Action.Create], 'Attachment');
|
|
||||||
}
|
|
||||||
|
|
||||||
return build({
|
|
||||||
detectSubjectType: (item) => item as ExtractSubjectType<Subjects>,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -9,7 +9,7 @@ import { User } from '@docmost/db/types/entity.types';
|
|||||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
import {
|
import {
|
||||||
SpaceCaslAction,
|
SpaceCaslAction,
|
||||||
SpaceAbility,
|
ISpaceAbility,
|
||||||
SpaceCaslSubject,
|
SpaceCaslSubject,
|
||||||
} from '../interfaces/space-ability.type';
|
} from '../interfaces/space-ability.type';
|
||||||
import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils';
|
import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils';
|
||||||
@ -39,7 +39,7 @@ export default class SpaceAbilityFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildSpaceAdminAbility() {
|
function buildSpaceAdminAbility() {
|
||||||
const { can, build } = new AbilityBuilder<MongoAbility<SpaceAbility>>(
|
const { can, build } = new AbilityBuilder<MongoAbility<ISpaceAbility>>(
|
||||||
createMongoAbility,
|
createMongoAbility,
|
||||||
);
|
);
|
||||||
can(SpaceCaslAction.Manage, SpaceCaslSubject.Settings);
|
can(SpaceCaslAction.Manage, SpaceCaslSubject.Settings);
|
||||||
@ -49,7 +49,7 @@ function buildSpaceAdminAbility() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildSpaceWriterAbility() {
|
function buildSpaceWriterAbility() {
|
||||||
const { can, build } = new AbilityBuilder<MongoAbility<SpaceAbility>>(
|
const { can, build } = new AbilityBuilder<MongoAbility<ISpaceAbility>>(
|
||||||
createMongoAbility,
|
createMongoAbility,
|
||||||
);
|
);
|
||||||
can(SpaceCaslAction.Read, SpaceCaslSubject.Settings);
|
can(SpaceCaslAction.Read, SpaceCaslSubject.Settings);
|
||||||
@ -59,7 +59,7 @@ function buildSpaceWriterAbility() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildSpaceReaderAbility() {
|
function buildSpaceReaderAbility() {
|
||||||
const { can, build } = new AbilityBuilder<MongoAbility<SpaceAbility>>(
|
const { can, build } = new AbilityBuilder<MongoAbility<ISpaceAbility>>(
|
||||||
createMongoAbility,
|
createMongoAbility,
|
||||||
);
|
);
|
||||||
can(SpaceCaslAction.Read, SpaceCaslSubject.Settings);
|
can(SpaceCaslAction.Read, SpaceCaslSubject.Settings);
|
||||||
|
|||||||
@ -0,0 +1,73 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
AbilityBuilder,
|
||||||
|
createMongoAbility,
|
||||||
|
MongoAbility,
|
||||||
|
} from '@casl/ability';
|
||||||
|
import { UserRole } from '../../../helpers/types/permission';
|
||||||
|
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||||
|
import {
|
||||||
|
IWorkspaceAbility,
|
||||||
|
WorkspaceCaslAction,
|
||||||
|
WorkspaceCaslSubject,
|
||||||
|
} from '../interfaces/workspace-ability.type';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export default class WorkspaceAbilityFactory {
|
||||||
|
createForUser(user: User, workspace: Workspace) {
|
||||||
|
const userRole = user.role;
|
||||||
|
|
||||||
|
switch (userRole) {
|
||||||
|
case UserRole.OWNER:
|
||||||
|
return buildWorkspaceOwnerAbility();
|
||||||
|
case UserRole.ADMIN:
|
||||||
|
return buildWorkspaceAdminAbility();
|
||||||
|
case UserRole.MEMBER:
|
||||||
|
return buildWorkspaceMemberAbility();
|
||||||
|
default:
|
||||||
|
throw new NotFoundException('Workspace permissions not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWorkspaceOwnerAbility() {
|
||||||
|
const { can, build } = new AbilityBuilder<MongoAbility<IWorkspaceAbility>>(
|
||||||
|
createMongoAbility,
|
||||||
|
);
|
||||||
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Settings);
|
||||||
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member);
|
||||||
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Space);
|
||||||
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group);
|
||||||
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member);
|
||||||
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
|
||||||
|
|
||||||
|
return build();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWorkspaceAdminAbility() {
|
||||||
|
const { can, build } = new AbilityBuilder<MongoAbility<IWorkspaceAbility>>(
|
||||||
|
createMongoAbility,
|
||||||
|
);
|
||||||
|
|
||||||
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Settings);
|
||||||
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member);
|
||||||
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Space);
|
||||||
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group);
|
||||||
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member);
|
||||||
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
|
||||||
|
|
||||||
|
return build();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWorkspaceMemberAbility() {
|
||||||
|
const { can, build } = new AbilityBuilder<MongoAbility<IWorkspaceAbility>>(
|
||||||
|
createMongoAbility,
|
||||||
|
);
|
||||||
|
can(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Settings);
|
||||||
|
can(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Member);
|
||||||
|
can(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Space);
|
||||||
|
can(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Group);
|
||||||
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
|
||||||
|
|
||||||
|
return build();
|
||||||
|
}
|
||||||
@ -1,7 +0,0 @@
|
|||||||
export enum Action {
|
|
||||||
Manage = 'manage',
|
|
||||||
Create = 'create',
|
|
||||||
Read = 'read',
|
|
||||||
Update = 'update',
|
|
||||||
Delete = 'delete',
|
|
||||||
}
|
|
||||||
@ -1,10 +1,10 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import CaslAbilityFactory from './abilities/casl-ability.factory';
|
|
||||||
import SpaceAbilityFactory from './abilities/space-ability.factory';
|
import SpaceAbilityFactory from './abilities/space-ability.factory';
|
||||||
|
import WorkspaceAbilityFactory from './abilities/workspace-ability.factory';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
providers: [CaslAbilityFactory, SpaceAbilityFactory],
|
providers: [WorkspaceAbilityFactory, SpaceAbilityFactory],
|
||||||
exports: [CaslAbilityFactory, SpaceAbilityFactory],
|
exports: [WorkspaceAbilityFactory, SpaceAbilityFactory],
|
||||||
})
|
})
|
||||||
export class CaslModule {}
|
export class CaslModule {}
|
||||||
|
|||||||
@ -1,6 +0,0 @@
|
|||||||
import { PolicyHandler } from '../interfaces/policy-handler.interface';
|
|
||||||
import { SetMetadata } from '@nestjs/common';
|
|
||||||
|
|
||||||
export const CHECK_POLICIES_KEY = 'check_policy';
|
|
||||||
export const CheckPolicies = (...handlers: PolicyHandler[]) =>
|
|
||||||
SetMetadata(CHECK_POLICIES_KEY, handlers);
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
|
||||||
import { Reflector } from '@nestjs/core';
|
|
||||||
import CaslAbilityFactory, {
|
|
||||||
AppAbility,
|
|
||||||
} from '../abilities/casl-ability.factory';
|
|
||||||
import { PolicyHandler } from '../interfaces/policy-handler.interface';
|
|
||||||
import { CHECK_POLICIES_KEY } from '../decorators/policies.decorator';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class PoliciesGuard implements CanActivate {
|
|
||||||
constructor(
|
|
||||||
private reflector: Reflector,
|
|
||||||
private caslAbilityFactory: CaslAbilityFactory,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
||||||
const policyHandlers =
|
|
||||||
this.reflector.get<PolicyHandler[]>(
|
|
||||||
CHECK_POLICIES_KEY,
|
|
||||||
context.getHandler(),
|
|
||||||
) || [];
|
|
||||||
|
|
||||||
const request = context.switchToHttp().getRequest();
|
|
||||||
const user = request.user.user;
|
|
||||||
const workspace = request.user.workspace;
|
|
||||||
|
|
||||||
const ability = this.caslAbilityFactory.createForUser(user, workspace);
|
|
||||||
|
|
||||||
return policyHandlers.every((handler) =>
|
|
||||||
this.execPolicyHandler(handler, ability),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private execPolicyHandler(handler: PolicyHandler, ability: AppAbility) {
|
|
||||||
if (typeof handler === 'function') {
|
|
||||||
return handler(ability);
|
|
||||||
}
|
|
||||||
return handler.handle(ability);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
import { AppAbility } from '../abilities/casl-ability.factory';
|
|
||||||
|
|
||||||
interface IPolicyHandler {
|
|
||||||
handle(ability: AppAbility): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
type PolicyHandlerCallback = (ability: AppAbility) => boolean;
|
|
||||||
|
|
||||||
export type PolicyHandler = IPolicyHandler | PolicyHandlerCallback;
|
|
||||||
@ -11,7 +11,7 @@ export enum SpaceCaslSubject {
|
|||||||
Page = 'page',
|
Page = 'page',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SpaceAbility =
|
export type ISpaceAbility =
|
||||||
| [SpaceCaslAction, SpaceCaslSubject.Settings]
|
| [SpaceCaslAction, SpaceCaslSubject.Settings]
|
||||||
| [SpaceCaslAction, SpaceCaslSubject.Member]
|
| [SpaceCaslAction, SpaceCaslSubject.Member]
|
||||||
| [SpaceCaslAction, SpaceCaslSubject.Page];
|
| [SpaceCaslAction, SpaceCaslSubject.Page];
|
||||||
|
|||||||
@ -0,0 +1,21 @@
|
|||||||
|
export enum WorkspaceCaslAction {
|
||||||
|
Manage = 'manage',
|
||||||
|
Create = 'create',
|
||||||
|
Read = 'read',
|
||||||
|
Edit = 'edit',
|
||||||
|
Delete = 'delete',
|
||||||
|
}
|
||||||
|
export enum WorkspaceCaslSubject {
|
||||||
|
Settings = 'settings',
|
||||||
|
Member = 'member',
|
||||||
|
Space = 'space',
|
||||||
|
Group = 'group',
|
||||||
|
Attachment = 'attachment',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IWorkspaceAbility =
|
||||||
|
| [WorkspaceCaslAction, WorkspaceCaslSubject.Settings]
|
||||||
|
| [WorkspaceCaslAction, WorkspaceCaslSubject.Member]
|
||||||
|
| [WorkspaceCaslAction, WorkspaceCaslSubject.Space]
|
||||||
|
| [WorkspaceCaslAction, WorkspaceCaslSubject.Group]
|
||||||
|
| [WorkspaceCaslAction, WorkspaceCaslSubject.Attachment];
|
||||||
@ -5,6 +5,7 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
|
ForbiddenException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { GroupService } from './services/group.service';
|
import { GroupService } from './services/group.service';
|
||||||
import { CreateGroupDto } from './dto/create-group.dto';
|
import { CreateGroupDto } from './dto/create-group.dto';
|
||||||
@ -16,12 +17,13 @@ import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
|||||||
import { AddGroupUserDto } from './dto/add-group-user.dto';
|
import { AddGroupUserDto } from './dto/add-group-user.dto';
|
||||||
import { RemoveGroupUserDto } from './dto/remove-group-user.dto';
|
import { RemoveGroupUserDto } from './dto/remove-group-user.dto';
|
||||||
import { UpdateGroupDto } from './dto/update-group.dto';
|
import { UpdateGroupDto } from './dto/update-group.dto';
|
||||||
import { Action } from '../casl/ability.action';
|
|
||||||
import { PoliciesGuard } from '../casl/guards/policies.guard';
|
|
||||||
import { CheckPolicies } from '../casl/decorators/policies.decorator';
|
|
||||||
import { AppAbility } from '../casl/abilities/casl-ability.factory';
|
|
||||||
import { JwtAuthGuard } from '../../guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../guards/jwt-auth.guard';
|
||||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||||
|
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
|
||||||
|
import {
|
||||||
|
WorkspaceCaslAction,
|
||||||
|
WorkspaceCaslSubject,
|
||||||
|
} from '../casl/interfaces/workspace-ability.type';
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('groups')
|
@Controller('groups')
|
||||||
@ -29,10 +31,9 @@ export class GroupController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly groupService: GroupService,
|
private readonly groupService: GroupService,
|
||||||
private readonly groupUserService: GroupUserService,
|
private readonly groupUserService: GroupUserService,
|
||||||
|
private readonly workspaceAbility: WorkspaceAbilityFactory,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@UseGuards(PoliciesGuard)
|
|
||||||
@CheckPolicies((ability: AppAbility) => ability.can(Action.Read, 'Group'))
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('/')
|
@Post('/')
|
||||||
getWorkspaceGroups(
|
getWorkspaceGroups(
|
||||||
@ -40,11 +41,14 @@ export class GroupController {
|
|||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
|
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||||
|
if (ability.cannot(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Group)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
return this.groupService.getWorkspaceGroups(workspace.id, pagination);
|
return this.groupService.getWorkspaceGroups(workspace.id, pagination);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(PoliciesGuard)
|
|
||||||
@CheckPolicies((ability: AppAbility) => ability.can(Action.Read, 'Group'))
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('/info')
|
@Post('/info')
|
||||||
getGroup(
|
getGroup(
|
||||||
@ -52,11 +56,13 @@ export class GroupController {
|
|||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
|
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||||
|
if (ability.cannot(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Group)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
return this.groupService.getGroupInfo(groupIdDto.groupId, workspace.id);
|
return this.groupService.getGroupInfo(groupIdDto.groupId, workspace.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(PoliciesGuard)
|
|
||||||
@CheckPolicies((ability: AppAbility) => ability.can(Action.Manage, 'Group'))
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('create')
|
@Post('create')
|
||||||
createGroup(
|
createGroup(
|
||||||
@ -64,11 +70,15 @@ export class GroupController {
|
|||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
|
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||||
|
if (
|
||||||
|
ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
return this.groupService.createGroup(user, workspace.id, createGroupDto);
|
return this.groupService.createGroup(user, workspace.id, createGroupDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(PoliciesGuard)
|
|
||||||
@CheckPolicies((ability: AppAbility) => ability.can(Action.Manage, 'Group'))
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('update')
|
@Post('update')
|
||||||
updateGroup(
|
updateGroup(
|
||||||
@ -76,18 +86,29 @@ export class GroupController {
|
|||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
|
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||||
|
if (
|
||||||
|
ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
return this.groupService.updateGroup(workspace.id, updateGroupDto);
|
return this.groupService.updateGroup(workspace.id, updateGroupDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(PoliciesGuard)
|
|
||||||
@CheckPolicies((ability: AppAbility) => ability.can(Action.Read, 'GroupUser'))
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('members')
|
@Post('members')
|
||||||
getGroupMembers(
|
getGroupMembers(
|
||||||
@Body() groupIdDto: GroupIdDto,
|
@Body() groupIdDto: GroupIdDto,
|
||||||
@Body() pagination: PaginationOptions,
|
@Body() pagination: PaginationOptions,
|
||||||
|
@AuthUser() user: User,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
|
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||||
|
if (ability.cannot(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Group)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
return this.groupUserService.getGroupUsers(
|
return this.groupUserService.getGroupUsers(
|
||||||
groupIdDto.groupId,
|
groupIdDto.groupId,
|
||||||
workspace.id,
|
workspace.id,
|
||||||
@ -95,10 +116,6 @@ export class GroupController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(PoliciesGuard)
|
|
||||||
@CheckPolicies((ability: AppAbility) =>
|
|
||||||
ability.can(Action.Manage, 'GroupUser'),
|
|
||||||
)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('members/add')
|
@Post('members/add')
|
||||||
addGroupMember(
|
addGroupMember(
|
||||||
@ -106,6 +123,13 @@ export class GroupController {
|
|||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
|
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||||
|
if (
|
||||||
|
ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
return this.groupUserService.addUsersToGroupBatch(
|
return this.groupUserService.addUsersToGroupBatch(
|
||||||
addGroupUserDto.userIds,
|
addGroupUserDto.userIds,
|
||||||
addGroupUserDto.groupId,
|
addGroupUserDto.groupId,
|
||||||
@ -113,17 +137,20 @@ export class GroupController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(PoliciesGuard)
|
|
||||||
@CheckPolicies((ability: AppAbility) =>
|
|
||||||
ability.can(Action.Manage, 'GroupUser'),
|
|
||||||
)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('members/remove')
|
@Post('members/remove')
|
||||||
removeGroupMember(
|
removeGroupMember(
|
||||||
@Body() removeGroupUserDto: RemoveGroupUserDto,
|
@Body() removeGroupUserDto: RemoveGroupUserDto,
|
||||||
//@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
|
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||||
|
if (
|
||||||
|
ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
return this.groupUserService.removeUserFromGroup(
|
return this.groupUserService.removeUserFromGroup(
|
||||||
removeGroupUserDto.userId,
|
removeGroupUserDto.userId,
|
||||||
removeGroupUserDto.groupId,
|
removeGroupUserDto.groupId,
|
||||||
@ -131,8 +158,6 @@ export class GroupController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(PoliciesGuard)
|
|
||||||
@CheckPolicies((ability: AppAbility) => ability.can(Action.Manage, 'Group'))
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('delete')
|
@Post('delete')
|
||||||
deleteGroup(
|
deleteGroup(
|
||||||
@ -140,6 +165,12 @@ export class GroupController {
|
|||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
|
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||||
|
if (
|
||||||
|
ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
return this.groupService.deleteGroup(groupIdDto.groupId, workspace.id);
|
return this.groupService.deleteGroup(groupIdDto.groupId, workspace.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -106,7 +106,7 @@ export class PageController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
await this.pageService.forceDelete(pageIdDto.pageId);
|
await this.pageService.forceDelete(pageIdDto.pageId);
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
export class CreateSpaceDto {
|
export class CreateSpaceDto {
|
||||||
@MinLength(4)
|
@MinLength(2)
|
||||||
@MaxLength(64)
|
@MaxLength(64)
|
||||||
@IsString()
|
@IsString()
|
||||||
name: string;
|
name: string;
|
||||||
@ -10,7 +10,7 @@ export class CreateSpaceDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
@MinLength(4)
|
@MinLength(2)
|
||||||
@MaxLength(64)
|
@MaxLength(64)
|
||||||
@IsString()
|
@IsString()
|
||||||
slug: string;
|
slug: string;
|
||||||
|
|||||||
@ -48,10 +48,6 @@ export class SpaceService {
|
|||||||
updateSpaceDto: UpdateSpaceDto,
|
updateSpaceDto: UpdateSpaceDto,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
): Promise<Space> {
|
): Promise<Space> {
|
||||||
if (!updateSpaceDto.name && !updateSpaceDto.description) {
|
|
||||||
throw new BadRequestException('Please provide fields to update');
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.spaceRepo.updateSpace(
|
return await this.spaceRepo.updateSpace(
|
||||||
{
|
{
|
||||||
name: updateSpaceDto.name,
|
name: updateSpaceDto.name,
|
||||||
|
|||||||
@ -26,6 +26,8 @@ import {
|
|||||||
SpaceCaslSubject,
|
SpaceCaslSubject,
|
||||||
} from '../casl/interfaces/space-ability.type';
|
} from '../casl/interfaces/space-ability.type';
|
||||||
import { UpdateSpaceDto } from './dto/update-space.dto';
|
import { UpdateSpaceDto } from './dto/update-space.dto';
|
||||||
|
import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils';
|
||||||
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('spaces')
|
@Controller('spaces')
|
||||||
@ -33,6 +35,7 @@ export class SpaceController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly spaceService: SpaceService,
|
private readonly spaceService: SpaceService,
|
||||||
private readonly spaceMemberService: SpaceMemberService,
|
private readonly spaceMemberService: SpaceMemberService,
|
||||||
|
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||||
private readonly spaceAbility: SpaceAbilityFactory,
|
private readonly spaceAbility: SpaceAbilityFactory,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -67,7 +70,20 @@ export class SpaceController {
|
|||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
|
|
||||||
return space;
|
const userSpaceRoles = await this.spaceMemberRepo.getUserSpaceRoles(
|
||||||
|
user.id,
|
||||||
|
space.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
const userSpaceRole = findHighestUserSpaceRole(userSpaceRoles);
|
||||||
|
|
||||||
|
const membership = {
|
||||||
|
userId: user.id,
|
||||||
|
role: userSpaceRole,
|
||||||
|
permissions: ability.rules,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ...space, membership };
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Post,
|
Post,
|
||||||
@ -21,12 +22,13 @@ import {
|
|||||||
InviteUserDto,
|
InviteUserDto,
|
||||||
RevokeInviteDto,
|
RevokeInviteDto,
|
||||||
} from '../dto/invitation.dto';
|
} from '../dto/invitation.dto';
|
||||||
import { Action } from '../../casl/ability.action';
|
|
||||||
import { CheckPolicies } from '../../casl/decorators/policies.decorator';
|
|
||||||
import { AppAbility } from '../../casl/abilities/casl-ability.factory';
|
|
||||||
import { PoliciesGuard } from '../../casl/guards/policies.guard';
|
|
||||||
import { JwtAuthGuard } from '../../../guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../../guards/jwt-auth.guard';
|
||||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||||
|
import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.factory';
|
||||||
|
import {
|
||||||
|
WorkspaceCaslAction,
|
||||||
|
WorkspaceCaslSubject,
|
||||||
|
} from '../../casl/interfaces/workspace-ability.type';
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('workspace')
|
@Controller('workspace')
|
||||||
@ -34,12 +36,13 @@ export class WorkspaceController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly workspaceService: WorkspaceService,
|
private readonly workspaceService: WorkspaceService,
|
||||||
private readonly workspaceInvitationService: WorkspaceInvitationService,
|
private readonly workspaceInvitationService: WorkspaceInvitationService,
|
||||||
|
private readonly workspaceAbility: WorkspaceAbilityFactory,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('/public')
|
@Post('/public')
|
||||||
async getWorkspacePublicInfo(@Req() req) {
|
async getWorkspacePublicInfo(@Req() req: any) {
|
||||||
return this.workspaceService.getWorkspacePublicData(req.raw.workspaceId);
|
return this.workspaceService.getWorkspacePublicData(req.raw.workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,72 +52,89 @@ export class WorkspaceController {
|
|||||||
return this.workspaceService.getWorkspaceInfo(workspace.id);
|
return this.workspaceService.getWorkspaceInfo(workspace.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(PoliciesGuard)
|
|
||||||
@CheckPolicies((ability: AppAbility) =>
|
|
||||||
ability.can(Action.Manage, 'Workspace'),
|
|
||||||
)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('update')
|
@Post('update')
|
||||||
async updateWorkspace(
|
async updateWorkspace(
|
||||||
@Body() updateWorkspaceDto: UpdateWorkspaceDto,
|
@Body() updateWorkspaceDto: UpdateWorkspaceDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
|
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||||
|
if (
|
||||||
|
ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Settings)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
return this.workspaceService.update(workspace.id, updateWorkspaceDto);
|
return this.workspaceService.update(workspace.id, updateWorkspaceDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(PoliciesGuard)
|
|
||||||
@CheckPolicies((ability: AppAbility) =>
|
|
||||||
ability.can(Action.Read, 'WorkspaceUser'),
|
|
||||||
)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('members')
|
@Post('members')
|
||||||
async getWorkspaceMembers(
|
async getWorkspaceMembers(
|
||||||
@Body()
|
@Body()
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
|
@AuthUser() user: User,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
|
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||||
|
if (ability.cannot(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Member)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
return this.workspaceService.getWorkspaceUsers(workspace.id, pagination);
|
return this.workspaceService.getWorkspaceUsers(workspace.id, pagination);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(PoliciesGuard)
|
|
||||||
// @CheckPolicies((ability: AppAbility) =>
|
|
||||||
// ability.can(Action.Manage, 'WorkspaceUser'),
|
|
||||||
// )
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('members/deactivate')
|
@Post('members/deactivate')
|
||||||
async deactivateWorkspaceMember() {
|
async deactivateWorkspaceMember(
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||||
|
if (
|
||||||
|
ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
return this.workspaceService.deactivateUser();
|
return this.workspaceService.deactivateUser();
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(PoliciesGuard)
|
|
||||||
@CheckPolicies((ability: AppAbility) =>
|
|
||||||
ability.can(Action.Manage, 'WorkspaceUser'),
|
|
||||||
)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('members/change-role')
|
@Post('members/change-role')
|
||||||
async updateWorkspaceMemberRole(
|
async updateWorkspaceMemberRole(
|
||||||
@Body() workspaceUserRoleDto: UpdateWorkspaceUserRoleDto,
|
@Body() workspaceUserRoleDto: UpdateWorkspaceUserRoleDto,
|
||||||
@AuthUser() authUser: User,
|
@AuthUser() user: User,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
|
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||||
|
if (
|
||||||
|
ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
return this.workspaceService.updateWorkspaceUserRole(
|
return this.workspaceService.updateWorkspaceUserRole(
|
||||||
authUser,
|
user,
|
||||||
workspaceUserRoleDto,
|
workspaceUserRoleDto,
|
||||||
workspace.id,
|
workspace.id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(PoliciesGuard)
|
|
||||||
@CheckPolicies((ability: AppAbility) =>
|
|
||||||
ability.can(Action.Read, 'WorkspaceUser'),
|
|
||||||
)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('invites')
|
@Post('invites')
|
||||||
async getInvitations(
|
async getInvitations(
|
||||||
|
@AuthUser() user: User,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
@Body()
|
@Body()
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
) {
|
) {
|
||||||
|
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||||
|
if (ability.cannot(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Member)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
return this.workspaceInvitationService.getInvitations(
|
return this.workspaceInvitationService.getInvitations(
|
||||||
workspace.id,
|
workspace.id,
|
||||||
pagination,
|
pagination,
|
||||||
@ -131,50 +151,61 @@ export class WorkspaceController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(PoliciesGuard)
|
|
||||||
@CheckPolicies((ability: AppAbility) =>
|
|
||||||
ability.can(Action.Manage, 'WorkspaceUser'),
|
|
||||||
)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('invites/create')
|
@Post('invites/create')
|
||||||
async inviteUser(
|
async inviteUser(
|
||||||
@Body() inviteUserDto: InviteUserDto,
|
@Body() inviteUserDto: InviteUserDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
@AuthUser() authUser: User,
|
|
||||||
) {
|
) {
|
||||||
|
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||||
|
if (
|
||||||
|
ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
return this.workspaceInvitationService.createInvitation(
|
return this.workspaceInvitationService.createInvitation(
|
||||||
inviteUserDto,
|
inviteUserDto,
|
||||||
workspace.id,
|
workspace.id,
|
||||||
authUser,
|
user,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(PoliciesGuard)
|
|
||||||
@CheckPolicies((ability: AppAbility) =>
|
|
||||||
ability.can(Action.Manage, 'WorkspaceUser'),
|
|
||||||
)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('invites/resend')
|
@Post('invites/resend')
|
||||||
async resendInvite(
|
async resendInvite(
|
||||||
@Body() revokeInviteDto: RevokeInviteDto,
|
@Body() revokeInviteDto: RevokeInviteDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
|
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||||
|
if (
|
||||||
|
ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
return this.workspaceInvitationService.resendInvitation(
|
return this.workspaceInvitationService.resendInvitation(
|
||||||
revokeInviteDto.invitationId,
|
revokeInviteDto.invitationId,
|
||||||
workspace.id,
|
workspace.id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(PoliciesGuard)
|
|
||||||
@CheckPolicies((ability: AppAbility) =>
|
|
||||||
ability.can(Action.Manage, 'WorkspaceUser'),
|
|
||||||
)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('invites/revoke')
|
@Post('invites/revoke')
|
||||||
async revokeInvite(
|
async revokeInvite(
|
||||||
@Body() revokeInviteDto: RevokeInviteDto,
|
@Body() revokeInviteDto: RevokeInviteDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
|
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||||
|
if (
|
||||||
|
ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
return this.workspaceInvitationService.revokeInvitation(
|
return this.workspaceInvitationService.revokeInvitation(
|
||||||
revokeInviteDto.invitationId,
|
revokeInviteDto.invitationId,
|
||||||
workspace.id,
|
workspace.id,
|
||||||
|
|||||||
17
pnpm-lock.yaml
generated
17
pnpm-lock.yaml
generated
@ -126,6 +126,12 @@ importers:
|
|||||||
|
|
||||||
apps/client:
|
apps/client:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@casl/ability':
|
||||||
|
specifier: ^6.7.1
|
||||||
|
version: 6.7.1
|
||||||
|
'@casl/react':
|
||||||
|
specifier: ^3.1.0
|
||||||
|
version: 3.1.0(@casl/ability@6.7.1)(react@18.2.0)
|
||||||
'@emoji-mart/data':
|
'@emoji-mart/data':
|
||||||
specifier: ^1.1.2
|
specifier: ^1.1.2
|
||||||
version: 1.1.2
|
version: 1.1.2
|
||||||
@ -1397,6 +1403,12 @@ packages:
|
|||||||
'@casl/ability@6.7.1':
|
'@casl/ability@6.7.1':
|
||||||
resolution: {integrity: sha512-e+Vgrehd1/lzOSwSqKHtmJ6kmIuZbGBlM2LBS5IuYGGKmVHuhUuyh3XgTn1VIw9+TO4gqU+uptvxfIRBUEdJuw==}
|
resolution: {integrity: sha512-e+Vgrehd1/lzOSwSqKHtmJ6kmIuZbGBlM2LBS5IuYGGKmVHuhUuyh3XgTn1VIw9+TO4gqU+uptvxfIRBUEdJuw==}
|
||||||
|
|
||||||
|
'@casl/react@3.1.0':
|
||||||
|
resolution: {integrity: sha512-p4Xmex1Slxz/G0cBtZik+xyOkeOynBUe0UrMFTai6aYkYOb4NyUy3w+9rtnedjcuKijiow2HKJQjnSurLxdc/g==}
|
||||||
|
peerDependencies:
|
||||||
|
'@casl/ability': ^3.0.0 || ^4.0.0 || ^5.1.0 || ^6.0.0
|
||||||
|
react: ^16.0.0 || ^17.0.0 || ^18.0.0
|
||||||
|
|
||||||
'@colors/colors@1.5.0':
|
'@colors/colors@1.5.0':
|
||||||
resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
|
resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
|
||||||
engines: {node: '>=0.1.90'}
|
engines: {node: '>=0.1.90'}
|
||||||
@ -9035,6 +9047,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@ucast/mongo2js': 1.3.4
|
'@ucast/mongo2js': 1.3.4
|
||||||
|
|
||||||
|
'@casl/react@3.1.0(@casl/ability@6.7.1)(react@18.2.0)':
|
||||||
|
dependencies:
|
||||||
|
'@casl/ability': 6.7.1
|
||||||
|
react: 18.2.0
|
||||||
|
|
||||||
'@colors/colors@1.5.0':
|
'@colors/colors@1.5.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user