frontend permissions

* rework backend workspace permissions
This commit is contained in:
Philipinho
2024-06-03 02:54:12 +01:00
parent b88e0b605f
commit 886d9591fa
54 changed files with 715 additions and 385 deletions

View File

@ -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",

View File

@ -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"}>

View File

@ -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>
); );

View File

@ -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>

View File

@ -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}
/> />

View File

@ -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>
); );
} }

View File

@ -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);

View File

@ -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,
}); });

View File

@ -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>
)} )}

View File

@ -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>
))} ))}

View File

@ -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>
); );

View File

@ -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>

View File

@ -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 });
}
}, },
}); });
} }

View File

@ -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>
); );

View File

@ -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>
</> </>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>
)} )}
</> </>

View File

@ -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>
))} ))}

View File

@ -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];

View File

@ -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),
};
};

View File

@ -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({

View File

@ -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[];

View File

@ -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</>;
} }

View File

@ -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";

View File

@ -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>
))} ))}

View File

@ -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>

View File

@ -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>
); );
} }

View 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;

View File

@ -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";

View File

@ -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>

View File

@ -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 />

View File

@ -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" />

View File

@ -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}`);

View File

@ -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();
} }

View File

@ -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();
} }
} }

View File

@ -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>,
});
}
}

View File

@ -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);

View File

@ -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();
}

View File

@ -1,7 +0,0 @@
export enum Action {
Manage = 'manage',
Create = 'create',
Read = 'read',
Update = 'update',
Delete = 'delete',
}

View File

@ -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 {}

View File

@ -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);

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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];

View File

@ -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];

View File

@ -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);
} }
} }

View File

@ -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);

View File

@ -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;

View File

@ -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,

View File

@ -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)

View File

@ -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
View File

@ -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