From 886d9591fa1c3cbd31688e94e8e5556725b92eed Mon Sep 17 00:00:00 2001
From: Philipinho <16838612+Philipinho@users.noreply.github.com>
Date: Mon, 3 Jun 2024 02:54:12 +0100
Subject: [PATCH] frontend permissions * rework backend workspace permissions
---
apps/client/package.json | 2 +
.../components/layouts/global/top-menu.tsx | 4 +-
.../client/src/components/ui/emoji-picker.tsx | 51 +++++---
.../src/components/ui/role-select-menu.tsx | 4 +-
.../comment/components/comment-list-item.tsx | 16 ++-
.../src/features/editor/full-editor.tsx | 5 +-
.../src/features/editor/page-editor.tsx | 7 +-
.../src/features/editor/title-editor.tsx | 4 +
.../group/components/group-details.tsx | 10 +-
.../group/components/group-members.tsx | 42 ++++---
.../components/header/page-header-menu.tsx | 33 +++--
.../page/components/header/page-header.tsx | 7 +-
.../src/features/page/queries/page-query.ts | 19 ++-
.../page/tree/components/space-tree.tsx | 49 +++++---
.../space/components/edit-space-form.tsx | 21 ++--
.../space/components/settings-modal.tsx | 34 ++++--
.../space/components/sidebar/space-name.tsx | 2 +-
.../components/sidebar/space-sidebar.tsx | 67 ++++++----
.../space/components/space-details.tsx | 5 +-
.../space/components/space-members.tsx | 56 +++++----
.../space/permissions/permissions.type.ts | 17 +++
.../space/permissions/use-space-ability.ts | 15 +++
.../src/features/space/queries/space-query.ts | 3 +
.../src/features/space/types/space.types.ts | 20 +++
.../src/features/user/user-provider.tsx | 7 +-
.../components/workspace-invite-form.tsx | 1 -
.../components/workspace-invites-table.tsx | 12 +-
.../components/workspace-members-table.tsx | 3 +
.../components/workspace-name-form.tsx | 17 ++-
apps/client/src/hooks/use-user-role.tsx | 19 +++
apps/client/src/lib/utils.ts | 2 +
apps/client/src/pages/page/page.tsx | 32 ++++-
.../src/pages/settings/group/groups.tsx | 7 +-
.../settings/workspace/workspace-members.tsx | 7 +-
.../extensions/authentication.extension.ts | 6 +-
.../extensions/persistence.extension.ts | 2 +-
.../core/attachment/attachment.controller.ts | 18 ++-
.../casl/abilities/casl-ability.factory.ts | 61 ----------
.../casl/abilities/space-ability.factory.ts | 8 +-
.../abilities/workspace-ability.factory.ts | 73 +++++++++++
apps/server/src/core/casl/ability.action.ts | 7 --
apps/server/src/core/casl/casl.module.ts | 6 +-
.../casl/decorators/policies.decorator.ts | 6 -
.../src/core/casl/guards/policies.guard.ts | 40 ------
.../interfaces/policy-handler.interface.ts | 9 --
.../casl/interfaces/space-ability.type.ts | 2 +-
.../casl/interfaces/workspace-ability.type.ts | 21 ++++
.../server/src/core/group/group.controller.ts | 81 ++++++++----
apps/server/src/core/page/page.controller.ts | 2 +-
.../src/core/space/dto/create-space.dto.ts | 4 +-
.../src/core/space/services/space.service.ts | 4 -
.../server/src/core/space/space.controller.ts | 18 ++-
.../controllers/workspace.controller.ts | 115 +++++++++++-------
pnpm-lock.yaml | 17 +++
54 files changed, 715 insertions(+), 385 deletions(-)
create mode 100644 apps/client/src/features/space/permissions/permissions.type.ts
create mode 100644 apps/client/src/features/space/permissions/use-space-ability.ts
create mode 100644 apps/client/src/hooks/use-user-role.tsx
delete mode 100644 apps/server/src/core/casl/abilities/casl-ability.factory.ts
create mode 100644 apps/server/src/core/casl/abilities/workspace-ability.factory.ts
delete mode 100644 apps/server/src/core/casl/ability.action.ts
delete mode 100644 apps/server/src/core/casl/decorators/policies.decorator.ts
delete mode 100644 apps/server/src/core/casl/guards/policies.guard.ts
delete mode 100644 apps/server/src/core/casl/interfaces/policy-handler.interface.ts
create mode 100644 apps/server/src/core/casl/interfaces/workspace-ability.type.ts
diff --git a/apps/client/package.json b/apps/client/package.json
index fb9e0058..1c8749c2 100644
--- a/apps/client/package.json
+++ b/apps/client/package.json
@@ -9,6 +9,8 @@
"preview": "vite preview"
},
"dependencies": {
+ "@casl/ability": "^6.7.1",
+ "@casl/react": "^3.1.0",
"@emoji-mart/data": "^1.1.2",
"@emoji-mart/react": "^1.1.1",
"@mantine/core": "^7.7.1",
diff --git a/apps/client/src/components/layouts/global/top-menu.tsx b/apps/client/src/components/layouts/global/top-menu.tsx
index d0edb87c..5d3ea7e3 100644
--- a/apps/client/src/components/layouts/global/top-menu.tsx
+++ b/apps/client/src/components/layouts/global/top-menu.tsx
@@ -17,8 +17,8 @@ export default function TopMenu() {
const [currentUser] = useAtom(currentUserAtom);
const { logout } = useAuth();
- const user = currentUser?.user;
- const workspace = currentUser?.workspace;
+ const user = currentUser.user;
+ const workspace = currentUser.workspace;
return (
);
diff --git a/apps/client/src/features/space/components/edit-space-form.tsx b/apps/client/src/features/space/components/edit-space-form.tsx
index 6cc684af..c94ee295 100644
--- a/apps/client/src/features/space/components/edit-space-form.tsx
+++ b/apps/client/src/features/space/components/edit-space-form.tsx
@@ -13,8 +13,9 @@ const formSchema = z.object({
type FormValues = z.infer;
interface EditSpaceFormProps {
space: ISpace;
+ readOnly?: boolean;
}
-export function EditSpaceForm({ space }: EditSpaceFormProps) {
+export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
const updateSpaceMutation = useUpdateSpaceMutation();
const form = useForm({
@@ -51,14 +52,16 @@ export function EditSpaceForm({ space }: EditSpaceFormProps) {
-
-
-
+ {!readOnly && (
+
+
+
+ )}
>
diff --git a/apps/client/src/features/space/components/settings-modal.tsx b/apps/client/src/features/space/components/settings-modal.tsx
index ae62d6aa..90cc5196 100644
--- a/apps/client/src/features/space/components/settings-modal.tsx
+++ b/apps/client/src/features/space/components/settings-modal.tsx
@@ -1,10 +1,14 @@
import { Modal, Tabs, rem, Group, Divider, ScrollArea } from "@mantine/core";
import SpaceMembersList from "@/features/space/components/space-members.tsx";
import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx";
-import React from "react";
-import GroupActionMenu from "@/features/group/components/group-action-menu.tsx";
+import React, { useMemo } from "react";
import SpaceDetails from "@/features/space/components/space-details.tsx";
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 {
spaceId: string;
@@ -19,6 +23,9 @@ export default function SpaceSettingsModal({
}: SpaceSettingsModalProps) {
const { data: space, isLoading } = useSpaceQuery(spaceId);
+ const spaceRules = space?.membership?.permissions;
+ const spaceAbility = useMemo(() => useSpaceAbility(spaceRules), [spaceRules]);
+
return (
<>
-
-
+
-
-
+ {spaceAbility.can(
+ SpaceCaslAction.Manage,
+ SpaceCaslSubject.Member,
+ ) && }
-
+
diff --git a/apps/client/src/features/space/components/sidebar/space-name.tsx b/apps/client/src/features/space/components/sidebar/space-name.tsx
index 0532b420..121bedaf 100644
--- a/apps/client/src/features/space/components/sidebar/space-name.tsx
+++ b/apps/client/src/features/space/components/sidebar/space-name.tsx
@@ -9,7 +9,7 @@ export function SpaceName({ spaceName }: SpaceNameProps) {
-
+
{spaceName}
diff --git a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx
index 83a1ee6e..d75e65f5 100644
--- a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx
+++ b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx
@@ -1,21 +1,21 @@
import {
- UnstyledButton,
- Text,
- Group,
ActionIcon,
- Tooltip,
+ Group,
rem,
+ Text,
+ Tooltip,
+ UnstyledButton,
} from "@mantine/core";
import { spotlight } from "@mantine/spotlight";
import {
- IconSearch,
- IconPlus,
- IconSettings,
IconHome,
+ IconPlus,
+ IconSearch,
+ IconSettings,
} from "@tabler/icons-react";
import classes from "./space-sidebar.module.css";
-import React from "react";
+import React, { useMemo } from "react";
import { useAtom } from "jotai";
import { SearchSpotlight } from "@/features/search/search-spotlight.tsx";
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 { getSpaceUrl } from "@/lib/config.ts";
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() {
const [tree] = useAtom(treeApiAtom);
@@ -36,14 +41,17 @@ export function SpaceSidebar() {
const { spaceSlug } = useParams();
const { data: space, isLoading, isError } = useGetSpaceBySlugQuery(spaceSlug);
- function handleCreatePage() {
- tree?.create({ parentId: null, type: "internal", index: 0 });
- }
+ const spaceRules = space?.membership?.permissions;
+ const spaceAbility = useMemo(() => useSpaceAbility(spaceRules), [spaceRules]);
if (!space) {
return <>>;
}
+ function handleCreatePage() {
+ tree?.create({ parentId: null, type: "internal", index: 0 });
+ }
+
return (
<>
@@ -110,22 +118,33 @@ export function SpaceSidebar() {
Pages
-
-
-
-
-
+ {spaceAbility.can(
+ SpaceCaslAction.Manage,
+ SpaceCaslSubject.Page,
+ ) && (
+
+
+
+
+
+ )}
-
+
diff --git a/apps/client/src/features/space/components/space-details.tsx b/apps/client/src/features/space/components/space-details.tsx
index f2ab6869..2161eb60 100644
--- a/apps/client/src/features/space/components/space-details.tsx
+++ b/apps/client/src/features/space/components/space-details.tsx
@@ -5,8 +5,9 @@ import { Text } from "@mantine/core";
interface SpaceDetailsProps {
spaceId: string;
+ readOnly?: boolean;
}
-export default function SpaceDetails({ spaceId }: SpaceDetailsProps) {
+export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
const { data: space, isLoading } = useSpaceQuery(spaceId);
return (
@@ -16,7 +17,7 @@ export default function SpaceDetails({ spaceId }: SpaceDetailsProps) {
Details
-
+
)}
>
diff --git a/apps/client/src/features/space/components/space-members.tsx b/apps/client/src/features/space/components/space-members.tsx
index 7884b9f4..042c0838 100644
--- a/apps/client/src/features/space/components/space-members.tsx
+++ b/apps/client/src/features/space/components/space-members.tsx
@@ -16,12 +16,17 @@ import {
getSpaceRoleLabel,
spaceRoleData,
} from "@/features/space/types/space-role-data.ts";
+import { formatMemberCount } from "@/lib";
type MemberType = "user" | "group";
interface SpaceMembersProps {
spaceId: string;
+ readOnly?: boolean;
}
-export default function SpaceMembersList({ spaceId }: SpaceMembersProps) {
+export default function SpaceMembersList({
+ spaceId,
+ readOnly,
+}: SpaceMembersProps) {
const { data, isLoading } = useSpaceMembersQuery(spaceId);
const removeSpaceMember = useRemoveSpaceMemberMutation();
const changeSpaceMemberRoleMutation = useChangeSpaceMemberRoleMutation();
@@ -120,7 +125,7 @@ export default function SpaceMembersList({ spaceId }: SpaceMembersProps) {
{member.type == "user" && member?.email}
{member.type == "group" &&
- `Group - ${member?.memberCount === 1 ? "1 member" : `${member?.memberCount} members`}`}
+ `Group - ${formatMemberCount(member?.memberCount)}`}
@@ -138,32 +143,37 @@ export default function SpaceMembersList({ spaceId }: SpaceMembersProps) {
member.role,
)
}
+ disabled={readOnly}
/>
-
-
-
-
-
-
+ {!readOnly && (
+
+
+
+
+
+
-
- openRemoveModal(member.id, member.type)}
- >
- Remove space member
-
-
-
+
+
+ openRemoveModal(member.id, member.type)
+ }
+ >
+ Remove space member
+
+
+
+ )}
))}
diff --git a/apps/client/src/features/space/permissions/permissions.type.ts b/apps/client/src/features/space/permissions/permissions.type.ts
new file mode 100644
index 00000000..8e4b4de9
--- /dev/null
+++ b/apps/client/src/features/space/permissions/permissions.type.ts
@@ -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];
diff --git a/apps/client/src/features/space/permissions/use-space-ability.ts b/apps/client/src/features/space/permissions/use-space-ability.ts
new file mode 100644
index 00000000..126fc47e
--- /dev/null
+++ b/apps/client/src/features/space/permissions/use-space-ability.ts
@@ -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(rules);
+
+ return {
+ can: ability.can.bind(ability),
+ cannot: ability.cannot.bind(ability),
+ };
+};
diff --git a/apps/client/src/features/space/queries/space-query.ts b/apps/client/src/features/space/queries/space-query.ts
index 7a577b3f..ad8c3ac9 100644
--- a/apps/client/src/features/space/queries/space-query.ts
+++ b/apps/client/src/features/space/queries/space-query.ts
@@ -38,6 +38,7 @@ export function useSpaceQuery(spaceId: string): UseQueryResult {
queryKey: ["space", spaceId],
queryFn: () => getSpaceById(spaceId),
enabled: !!spaceId,
+ staleTime: 5 * 60 * 1000,
});
}
@@ -48,6 +49,7 @@ export function useGetSpaceBySlugQuery(
queryKey: ["space", spaceId],
queryFn: () => getSpaceById(spaceId),
enabled: !!spaceId,
+ staleTime: 5 * 60 * 1000,
});
}
@@ -66,6 +68,7 @@ export function useUpdateSpaceMutation() {
if (space) {
const updatedSpace = { ...space, ...data };
queryClient.setQueryData(["space", variables.spaceId], updatedSpace);
+ queryClient.setQueryData(["space", data.slug], updatedSpace);
}
queryClient.invalidateQueries({
diff --git a/apps/client/src/features/space/types/space.types.ts b/apps/client/src/features/space/types/space.types.ts
index 4366cb7b..c4164f90 100644
--- a/apps/client/src/features/space/types/space.types.ts
+++ b/apps/client/src/features/space/types/space.types.ts
@@ -1,3 +1,9 @@
+import { SpaceRole } from "@/lib/types.ts";
+import {
+ SpaceCaslAction,
+ SpaceCaslSubject,
+} from "@/features/space/permissions/permissions.type.ts";
+
export interface ISpace {
id: string;
name: string;
@@ -10,8 +16,22 @@ export interface ISpace {
updatedAt: Date;
memberCount?: number;
spaceId?: string;
+ membership?: IMembership;
}
+interface IMembership {
+ userId: string;
+ role: SpaceRole;
+ permissions?: Permissions;
+}
+
+interface Permission {
+ action: SpaceCaslAction;
+ subject: SpaceCaslSubject;
+}
+
+type Permissions = Permission[];
+
export interface IAddSpaceMember {
spaceId: string;
userIds?: string[];
diff --git a/apps/client/src/features/user/user-provider.tsx b/apps/client/src/features/user/user-provider.tsx
index 93e9ac6f..5e77162e 100644
--- a/apps/client/src/features/user/user-provider.tsx
+++ b/apps/client/src/features/user/user-provider.tsx
@@ -8,15 +8,16 @@ export function UserProvider({ children }: React.PropsWithChildren) {
const { data, isLoading, error } = useCurrentUser();
useEffect(() => {
- if (data && data.user) {
+ if (data && data.user && data.workspace) {
setCurrentUser(data);
}
- }, [data, isLoading, setCurrentUser]);
+ }, [data, isLoading]);
if (isLoading) return <>>;
+ if (!data.user && !data.workspace) return <>>;
+
if (error) {
- console.error(error);
return <>an error occurred>;
}
diff --git a/apps/client/src/features/workspace/components/members/components/workspace-invite-form.tsx b/apps/client/src/features/workspace/components/members/components/workspace-invite-form.tsx
index 29de4698..7bbbbc63 100644
--- a/apps/client/src/features/workspace/components/members/components/workspace-invite-form.tsx
+++ b/apps/client/src/features/workspace/components/members/components/workspace-invite-form.tsx
@@ -1,5 +1,4 @@
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 { MultiGroupSelect } from "@/features/group/components/multi-group-select.tsx";
import { UserRole } from "@/lib/types.ts";
diff --git a/apps/client/src/features/workspace/components/members/components/workspace-invites-table.tsx b/apps/client/src/features/workspace/components/members/components/workspace-invites-table.tsx
index 46f9e128..d5b8c467 100644
--- a/apps/client/src/features/workspace/components/members/components/workspace-invites-table.tsx
+++ b/apps/client/src/features/workspace/components/members/components/workspace-invites-table.tsx
@@ -4,12 +4,14 @@ import React from "react";
import { getUserRoleLabel } from "@/features/workspace/types/user-role-data.ts";
import InviteActionMenu from "@/features/workspace/components/members/components/invite-action-menu.tsx";
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() {
const { data, isLoading } = useWorkspaceInvitationsQuery({
limit: 100,
});
+ const { isAdmin } = useUserRole();
return (
<>
@@ -44,12 +46,12 @@ export default function WorkspaceInvitesTable() {
{getUserRoleLabel(invitation.role)}
-
- {format(invitation.createdAt, "MM/dd/yyyy")}
-
+ {formattedDate(invitation.createdAt)}
-
+ {isAdmin && (
+
+ )}
))}
diff --git a/apps/client/src/features/workspace/components/members/components/workspace-members-table.tsx b/apps/client/src/features/workspace/components/members/components/workspace-members-table.tsx
index 7080b4d3..841f5a13 100644
--- a/apps/client/src/features/workspace/components/members/components/workspace-members-table.tsx
+++ b/apps/client/src/features/workspace/components/members/components/workspace-members-table.tsx
@@ -10,10 +10,12 @@ import {
getUserRoleLabel,
userRoleData,
} from "@/features/workspace/types/user-role-data.ts";
+import useUserRole from "@/hooks/use-user-role.tsx";
export default function WorkspaceMembersTable() {
const { data, isLoading } = useWorkspaceMembersQuery({ limit: 100 });
const changeMemberRoleMutation = useChangeMemberRoleMutation();
+ const { isAdmin } = useUserRole();
const handleRoleChange = async (
userId: string,
@@ -72,6 +74,7 @@ export default function WorkspaceMembersTable() {
onChange={(newRole) =>
handleRoleChange(user.id, user.role, newRole)
}
+ disabled={!isAdmin}
/>
diff --git a/apps/client/src/features/workspace/components/settings/components/workspace-name-form.tsx b/apps/client/src/features/workspace/components/settings/components/workspace-name-form.tsx
index ca1764a6..a4f77fdb 100644
--- a/apps/client/src/features/workspace/components/settings/components/workspace-name-form.tsx
+++ b/apps/client/src/features/workspace/components/settings/components/workspace-name-form.tsx
@@ -8,6 +8,7 @@ import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
import { TextInput, Button } from "@mantine/core";
import { useForm, zodResolver } from "@mantine/form";
import { notifications } from "@mantine/notifications";
+import useUserRole from "@/hooks/use-user-role.tsx";
const formSchema = z.object({
name: z.string().nonempty("Workspace name cannot be blank"),
@@ -23,6 +24,7 @@ export default function WorkspaceNameForm() {
const [isLoading, setIsLoading] = useState(false);
const [currentUser] = useAtom(currentUserAtom);
const [, setWorkspace] = useAtom(workspaceAtom);
+ const { isAdmin } = useUserRole();
const form = useForm({
validate: zodResolver(formSchema),
@@ -46,6 +48,7 @@ export default function WorkspaceNameForm() {
});
}
setIsLoading(false);
+ form.resetDirty();
}
return (
@@ -57,9 +60,17 @@ export default function WorkspaceNameForm() {
variant="filled"
{...form.getInputProps("name")}
/>
-
+
+ {isAdmin && (
+
+ )}
);
}
diff --git a/apps/client/src/hooks/use-user-role.tsx b/apps/client/src/hooks/use-user-role.tsx
new file mode 100644
index 00000000..643e9b7c
--- /dev/null
+++ b/apps/client/src/hooks/use-user-role.tsx
@@ -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;
diff --git a/apps/client/src/lib/utils.ts b/apps/client/src/lib/utils.ts
index 1c88a05d..fdb01ab5 100644
--- a/apps/client/src/lib/utils.ts
+++ b/apps/client/src/lib/utils.ts
@@ -1,3 +1,5 @@
+import { UserRole } from "@/lib/types.ts";
+
export function formatMemberCount(memberCount: number): string {
if (memberCount === 1) {
return "1 member";
diff --git a/apps/client/src/pages/page/page.tsx b/apps/client/src/pages/page/page.tsx
index 7dd2d69f..6276173b 100644
--- a/apps/client/src/pages/page/page.tsx
+++ b/apps/client/src/pages/page/page.tsx
@@ -5,14 +5,25 @@ import HistoryModal from "@/features/page-history/components/history-modal";
import { Helmet } from "react-helmet-async";
import PageHeader from "@/features/page/components/header/page-header.tsx";
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() {
- const { pageSlug, spaceSlug } = useParams();
+ const { pageSlug } = useParams();
const {
data: page,
isLoading,
isError,
} = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
+ const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
+
+ const spaceRules = space?.membership?.permissions;
+ const spaceAbility = useMemo(() => useSpaceAbility(spaceRules), [spaceRules]);
if (isLoading) {
return <>>;
@@ -23,20 +34,33 @@ export default function Page() {
return Error fetching page data.
;
}
+ if (!space) {
+ return <>>;
+ }
+
return (
page && (
- {page.title}
+ {`${page?.icon || ""} ${page.title || "untitled"}`}
-
+
diff --git a/apps/client/src/pages/settings/group/groups.tsx b/apps/client/src/pages/settings/group/groups.tsx
index 8adafa7f..719233f6 100644
--- a/apps/client/src/pages/settings/group/groups.tsx
+++ b/apps/client/src/pages/settings/group/groups.tsx
@@ -1,15 +1,18 @@
import GroupList from "@/features/group/components/group-list";
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 useUserRole from "@/hooks/use-user-role.tsx";
export default function Groups() {
+ const { isAdmin } = useUserRole();
+
return (
<>
-
+ {isAdmin && }
diff --git a/apps/client/src/pages/settings/workspace/workspace-members.tsx b/apps/client/src/pages/settings/workspace/workspace-members.tsx
index ffbce23a..6ae01aa8 100644
--- a/apps/client/src/pages/settings/workspace/workspace-members.tsx
+++ b/apps/client/src/pages/settings/workspace/workspace-members.tsx
@@ -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 { 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 SettingsTitle from "@/components/settings/settings-title.tsx";
import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import WorkspaceInvitesTable from "@/features/workspace/components/members/components/workspace-invites-table.tsx";
+import useUserRole from "@/hooks/use-user-role.tsx";
export default function WorkspaceMembers() {
const [segmentValue, setSegmentValue] = useState("members");
const [searchParams] = useSearchParams();
+ const { isAdmin } = useUserRole();
const navigate = useNavigate();
useEffect(() => {
@@ -46,7 +47,7 @@ export default function WorkspaceMembers() {
withItemsBorders={false}
/>
-
+ {isAdmin && }
diff --git a/apps/server/src/collaboration/extensions/authentication.extension.ts b/apps/server/src/collaboration/extensions/authentication.extension.ts
index e515280d..835a08cd 100644
--- a/apps/server/src/collaboration/extensions/authentication.extension.ts
+++ b/apps/server/src/collaboration/extensions/authentication.extension.ts
@@ -47,7 +47,7 @@ export class AuthenticationExtension implements Extension {
const page = await this.pageRepo.findById(pageId);
if (!page) {
- this.logger.warn(`Page not found: ${pageId}}`);
+ this.logger.warn(`Page not found: ${pageId}`);
throw new NotFoundException('Page not found');
}
@@ -59,13 +59,13 @@ export class AuthenticationExtension implements Extension {
const userSpaceRole = findHighestUserSpaceRole(userSpaceRoles);
if (!userSpaceRole) {
- this.logger.warn(`User authorized to access page: ${pageId}}`);
+ this.logger.warn(`User not authorized to access page: ${pageId}`);
throw new UnauthorizedException();
}
if (userSpaceRole === SpaceRole.READER) {
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}`);
diff --git a/apps/server/src/collaboration/extensions/persistence.extension.ts b/apps/server/src/collaboration/extensions/persistence.extension.ts
index 01b30ab1..25b46be4 100644
--- a/apps/server/src/collaboration/extensions/persistence.extension.ts
+++ b/apps/server/src/collaboration/extensions/persistence.extension.ts
@@ -57,7 +57,7 @@ export class PersistenceExtension implements Extension {
return ydoc;
}
- this.logger.debug(`creating fresh ydoc': ${pageId}`);
+ this.logger.debug(`creating fresh ydoc: ${pageId}`);
return new Y.Doc();
}
diff --git a/apps/server/src/core/attachment/attachment.controller.ts b/apps/server/src/core/attachment/attachment.controller.ts
index cf12dcef..897af58a 100644
--- a/apps/server/src/core/attachment/attachment.controller.ts
+++ b/apps/server/src/core/attachment/attachment.controller.ts
@@ -33,13 +33,16 @@ import {
MAX_AVATAR_SIZE,
MAX_FILE_SIZE,
} from './attachment.constants';
-import CaslAbilityFactory from '../casl/abilities/casl-ability.factory';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type';
-import { Action } from '../casl/ability.action';
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')
export class AttachmentController {
@@ -48,7 +51,7 @@ export class AttachmentController {
constructor(
private readonly attachmentService: AttachmentService,
private readonly storageService: StorageService,
- private readonly caslAbility: CaslAbilityFactory,
+ private readonly workspaceAbility: WorkspaceAbilityFactory,
private readonly spaceAbility: SpaceAbilityFactory,
) {}
@@ -155,8 +158,13 @@ export class AttachmentController {
}
if (attachmentType === AttachmentType.WorkspaceLogo) {
- const ability = this.caslAbility.createForUser(user, workspace);
- if (ability.cannot(Action.Manage, 'Workspace')) {
+ const ability = this.workspaceAbility.createForUser(user, workspace);
+ if (
+ ability.cannot(
+ WorkspaceCaslAction.Manage,
+ WorkspaceCaslSubject.Settings,
+ )
+ ) {
throw new ForbiddenException();
}
}
diff --git a/apps/server/src/core/casl/abilities/casl-ability.factory.ts b/apps/server/src/core/casl/abilities/casl-ability.factory.ts
deleted file mode 100644
index 54585f38..00000000
--- a/apps/server/src/core/casl/abilities/casl-ability.factory.ts
+++ /dev/null
@@ -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(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,
- });
- }
-}
diff --git a/apps/server/src/core/casl/abilities/space-ability.factory.ts b/apps/server/src/core/casl/abilities/space-ability.factory.ts
index a14b5877..3dbbe32c 100644
--- a/apps/server/src/core/casl/abilities/space-ability.factory.ts
+++ b/apps/server/src/core/casl/abilities/space-ability.factory.ts
@@ -9,7 +9,7 @@ import { User } from '@docmost/db/types/entity.types';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import {
SpaceCaslAction,
- SpaceAbility,
+ ISpaceAbility,
SpaceCaslSubject,
} from '../interfaces/space-ability.type';
import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils';
@@ -39,7 +39,7 @@ export default class SpaceAbilityFactory {
}
function buildSpaceAdminAbility() {
- const { can, build } = new AbilityBuilder>(
+ const { can, build } = new AbilityBuilder>(
createMongoAbility,
);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Settings);
@@ -49,7 +49,7 @@ function buildSpaceAdminAbility() {
}
function buildSpaceWriterAbility() {
- const { can, build } = new AbilityBuilder>(
+ const { can, build } = new AbilityBuilder>(
createMongoAbility,
);
can(SpaceCaslAction.Read, SpaceCaslSubject.Settings);
@@ -59,7 +59,7 @@ function buildSpaceWriterAbility() {
}
function buildSpaceReaderAbility() {
- const { can, build } = new AbilityBuilder>(
+ const { can, build } = new AbilityBuilder>(
createMongoAbility,
);
can(SpaceCaslAction.Read, SpaceCaslSubject.Settings);
diff --git a/apps/server/src/core/casl/abilities/workspace-ability.factory.ts b/apps/server/src/core/casl/abilities/workspace-ability.factory.ts
new file mode 100644
index 00000000..7c3785f6
--- /dev/null
+++ b/apps/server/src/core/casl/abilities/workspace-ability.factory.ts
@@ -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>(
+ 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>(
+ 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>(
+ 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();
+}
diff --git a/apps/server/src/core/casl/ability.action.ts b/apps/server/src/core/casl/ability.action.ts
deleted file mode 100644
index b398b218..00000000
--- a/apps/server/src/core/casl/ability.action.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-export enum Action {
- Manage = 'manage',
- Create = 'create',
- Read = 'read',
- Update = 'update',
- Delete = 'delete',
-}
diff --git a/apps/server/src/core/casl/casl.module.ts b/apps/server/src/core/casl/casl.module.ts
index f8498413..4dc08af2 100644
--- a/apps/server/src/core/casl/casl.module.ts
+++ b/apps/server/src/core/casl/casl.module.ts
@@ -1,10 +1,10 @@
import { Global, Module } from '@nestjs/common';
-import CaslAbilityFactory from './abilities/casl-ability.factory';
import SpaceAbilityFactory from './abilities/space-ability.factory';
+import WorkspaceAbilityFactory from './abilities/workspace-ability.factory';
@Global()
@Module({
- providers: [CaslAbilityFactory, SpaceAbilityFactory],
- exports: [CaslAbilityFactory, SpaceAbilityFactory],
+ providers: [WorkspaceAbilityFactory, SpaceAbilityFactory],
+ exports: [WorkspaceAbilityFactory, SpaceAbilityFactory],
})
export class CaslModule {}
diff --git a/apps/server/src/core/casl/decorators/policies.decorator.ts b/apps/server/src/core/casl/decorators/policies.decorator.ts
deleted file mode 100644
index 73286636..00000000
--- a/apps/server/src/core/casl/decorators/policies.decorator.ts
+++ /dev/null
@@ -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);
diff --git a/apps/server/src/core/casl/guards/policies.guard.ts b/apps/server/src/core/casl/guards/policies.guard.ts
deleted file mode 100644
index 6a27e3c0..00000000
--- a/apps/server/src/core/casl/guards/policies.guard.ts
+++ /dev/null
@@ -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 {
- const policyHandlers =
- this.reflector.get(
- 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);
- }
-}
diff --git a/apps/server/src/core/casl/interfaces/policy-handler.interface.ts b/apps/server/src/core/casl/interfaces/policy-handler.interface.ts
deleted file mode 100644
index 4b33d571..00000000
--- a/apps/server/src/core/casl/interfaces/policy-handler.interface.ts
+++ /dev/null
@@ -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;
diff --git a/apps/server/src/core/casl/interfaces/space-ability.type.ts b/apps/server/src/core/casl/interfaces/space-ability.type.ts
index 407500b8..c927229b 100644
--- a/apps/server/src/core/casl/interfaces/space-ability.type.ts
+++ b/apps/server/src/core/casl/interfaces/space-ability.type.ts
@@ -11,7 +11,7 @@ export enum SpaceCaslSubject {
Page = 'page',
}
-export type SpaceAbility =
+export type ISpaceAbility =
| [SpaceCaslAction, SpaceCaslSubject.Settings]
| [SpaceCaslAction, SpaceCaslSubject.Member]
| [SpaceCaslAction, SpaceCaslSubject.Page];
diff --git a/apps/server/src/core/casl/interfaces/workspace-ability.type.ts b/apps/server/src/core/casl/interfaces/workspace-ability.type.ts
new file mode 100644
index 00000000..99d56ffe
--- /dev/null
+++ b/apps/server/src/core/casl/interfaces/workspace-ability.type.ts
@@ -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];
diff --git a/apps/server/src/core/group/group.controller.ts b/apps/server/src/core/group/group.controller.ts
index a328cda1..b19dd011 100644
--- a/apps/server/src/core/group/group.controller.ts
+++ b/apps/server/src/core/group/group.controller.ts
@@ -5,6 +5,7 @@ import {
UseGuards,
HttpCode,
HttpStatus,
+ ForbiddenException,
} from '@nestjs/common';
import { GroupService } from './services/group.service';
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 { RemoveGroupUserDto } from './dto/remove-group-user.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 { 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)
@Controller('groups')
@@ -29,10 +31,9 @@ export class GroupController {
constructor(
private readonly groupService: GroupService,
private readonly groupUserService: GroupUserService,
+ private readonly workspaceAbility: WorkspaceAbilityFactory,
) {}
- @UseGuards(PoliciesGuard)
- @CheckPolicies((ability: AppAbility) => ability.can(Action.Read, 'Group'))
@HttpCode(HttpStatus.OK)
@Post('/')
getWorkspaceGroups(
@@ -40,11 +41,14 @@ export class GroupController {
@AuthUser() user: User,
@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);
}
- @UseGuards(PoliciesGuard)
- @CheckPolicies((ability: AppAbility) => ability.can(Action.Read, 'Group'))
@HttpCode(HttpStatus.OK)
@Post('/info')
getGroup(
@@ -52,11 +56,13 @@ export class GroupController {
@AuthUser() user: User,
@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);
}
- @UseGuards(PoliciesGuard)
- @CheckPolicies((ability: AppAbility) => ability.can(Action.Manage, 'Group'))
@HttpCode(HttpStatus.OK)
@Post('create')
createGroup(
@@ -64,11 +70,15 @@ export class GroupController {
@AuthUser() user: User,
@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);
}
- @UseGuards(PoliciesGuard)
- @CheckPolicies((ability: AppAbility) => ability.can(Action.Manage, 'Group'))
@HttpCode(HttpStatus.OK)
@Post('update')
updateGroup(
@@ -76,18 +86,29 @@ export class GroupController {
@AuthUser() user: User,
@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);
}
- @UseGuards(PoliciesGuard)
- @CheckPolicies((ability: AppAbility) => ability.can(Action.Read, 'GroupUser'))
@HttpCode(HttpStatus.OK)
@Post('members')
getGroupMembers(
@Body() groupIdDto: GroupIdDto,
@Body() pagination: PaginationOptions,
+ @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
+ const ability = this.workspaceAbility.createForUser(user, workspace);
+ if (ability.cannot(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Group)) {
+ throw new ForbiddenException();
+ }
+
return this.groupUserService.getGroupUsers(
groupIdDto.groupId,
workspace.id,
@@ -95,10 +116,6 @@ export class GroupController {
);
}
- @UseGuards(PoliciesGuard)
- @CheckPolicies((ability: AppAbility) =>
- ability.can(Action.Manage, 'GroupUser'),
- )
@HttpCode(HttpStatus.OK)
@Post('members/add')
addGroupMember(
@@ -106,6 +123,13 @@ export class GroupController {
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
+ const ability = this.workspaceAbility.createForUser(user, workspace);
+ if (
+ ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group)
+ ) {
+ throw new ForbiddenException();
+ }
+
return this.groupUserService.addUsersToGroupBatch(
addGroupUserDto.userIds,
addGroupUserDto.groupId,
@@ -113,17 +137,20 @@ export class GroupController {
);
}
- @UseGuards(PoliciesGuard)
- @CheckPolicies((ability: AppAbility) =>
- ability.can(Action.Manage, 'GroupUser'),
- )
@HttpCode(HttpStatus.OK)
@Post('members/remove')
removeGroupMember(
@Body() removeGroupUserDto: RemoveGroupUserDto,
- //@AuthUser() user: User,
+ @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
+ const ability = this.workspaceAbility.createForUser(user, workspace);
+ if (
+ ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group)
+ ) {
+ throw new ForbiddenException();
+ }
+
return this.groupUserService.removeUserFromGroup(
removeGroupUserDto.userId,
removeGroupUserDto.groupId,
@@ -131,8 +158,6 @@ export class GroupController {
);
}
- @UseGuards(PoliciesGuard)
- @CheckPolicies((ability: AppAbility) => ability.can(Action.Manage, 'Group'))
@HttpCode(HttpStatus.OK)
@Post('delete')
deleteGroup(
@@ -140,6 +165,12 @@ export class GroupController {
@AuthUser() user: User,
@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);
}
}
diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts
index f47304ee..d8dccced 100644
--- a/apps/server/src/core/page/page.controller.ts
+++ b/apps/server/src/core/page/page.controller.ts
@@ -106,7 +106,7 @@ export class PageController {
}
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();
}
await this.pageService.forceDelete(pageIdDto.pageId);
diff --git a/apps/server/src/core/space/dto/create-space.dto.ts b/apps/server/src/core/space/dto/create-space.dto.ts
index 5f022873..252498ec 100644
--- a/apps/server/src/core/space/dto/create-space.dto.ts
+++ b/apps/server/src/core/space/dto/create-space.dto.ts
@@ -1,7 +1,7 @@
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class CreateSpaceDto {
- @MinLength(4)
+ @MinLength(2)
@MaxLength(64)
@IsString()
name: string;
@@ -10,7 +10,7 @@ export class CreateSpaceDto {
@IsString()
description?: string;
- @MinLength(4)
+ @MinLength(2)
@MaxLength(64)
@IsString()
slug: string;
diff --git a/apps/server/src/core/space/services/space.service.ts b/apps/server/src/core/space/services/space.service.ts
index bc275161..d9da2ab9 100644
--- a/apps/server/src/core/space/services/space.service.ts
+++ b/apps/server/src/core/space/services/space.service.ts
@@ -48,10 +48,6 @@ export class SpaceService {
updateSpaceDto: UpdateSpaceDto,
workspaceId: string,
): Promise {
- if (!updateSpaceDto.name && !updateSpaceDto.description) {
- throw new BadRequestException('Please provide fields to update');
- }
-
return await this.spaceRepo.updateSpace(
{
name: updateSpaceDto.name,
diff --git a/apps/server/src/core/space/space.controller.ts b/apps/server/src/core/space/space.controller.ts
index d9f5dcc2..bd21fffd 100644
--- a/apps/server/src/core/space/space.controller.ts
+++ b/apps/server/src/core/space/space.controller.ts
@@ -26,6 +26,8 @@ import {
SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type';
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)
@Controller('spaces')
@@ -33,6 +35,7 @@ export class SpaceController {
constructor(
private readonly spaceService: SpaceService,
private readonly spaceMemberService: SpaceMemberService,
+ private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly spaceAbility: SpaceAbilityFactory,
) {}
@@ -67,7 +70,20 @@ export class SpaceController {
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)
diff --git a/apps/server/src/core/workspace/controllers/workspace.controller.ts b/apps/server/src/core/workspace/controllers/workspace.controller.ts
index b5d55c68..8bf8307a 100644
--- a/apps/server/src/core/workspace/controllers/workspace.controller.ts
+++ b/apps/server/src/core/workspace/controllers/workspace.controller.ts
@@ -1,6 +1,7 @@
import {
Body,
Controller,
+ ForbiddenException,
HttpCode,
HttpStatus,
Post,
@@ -21,12 +22,13 @@ import {
InviteUserDto,
RevokeInviteDto,
} 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 { 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)
@Controller('workspace')
@@ -34,12 +36,13 @@ export class WorkspaceController {
constructor(
private readonly workspaceService: WorkspaceService,
private readonly workspaceInvitationService: WorkspaceInvitationService,
+ private readonly workspaceAbility: WorkspaceAbilityFactory,
) {}
@Public()
@HttpCode(HttpStatus.OK)
@Post('/public')
- async getWorkspacePublicInfo(@Req() req) {
+ async getWorkspacePublicInfo(@Req() req: any) {
return this.workspaceService.getWorkspacePublicData(req.raw.workspaceId);
}
@@ -49,72 +52,89 @@ export class WorkspaceController {
return this.workspaceService.getWorkspaceInfo(workspace.id);
}
- @UseGuards(PoliciesGuard)
- @CheckPolicies((ability: AppAbility) =>
- ability.can(Action.Manage, 'Workspace'),
- )
@HttpCode(HttpStatus.OK)
@Post('update')
async updateWorkspace(
@Body() updateWorkspaceDto: UpdateWorkspaceDto,
+ @AuthUser() user: User,
@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);
}
- @UseGuards(PoliciesGuard)
- @CheckPolicies((ability: AppAbility) =>
- ability.can(Action.Read, 'WorkspaceUser'),
- )
@HttpCode(HttpStatus.OK)
@Post('members')
async getWorkspaceMembers(
@Body()
pagination: PaginationOptions,
+ @AuthUser() user: User,
@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);
}
- @UseGuards(PoliciesGuard)
- // @CheckPolicies((ability: AppAbility) =>
- // ability.can(Action.Manage, 'WorkspaceUser'),
- // )
@HttpCode(HttpStatus.OK)
@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();
}
- @UseGuards(PoliciesGuard)
- @CheckPolicies((ability: AppAbility) =>
- ability.can(Action.Manage, 'WorkspaceUser'),
- )
@HttpCode(HttpStatus.OK)
@Post('members/change-role')
async updateWorkspaceMemberRole(
@Body() workspaceUserRoleDto: UpdateWorkspaceUserRoleDto,
- @AuthUser() authUser: User,
+ @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.updateWorkspaceUserRole(
- authUser,
+ user,
workspaceUserRoleDto,
workspace.id,
);
}
- @UseGuards(PoliciesGuard)
- @CheckPolicies((ability: AppAbility) =>
- ability.can(Action.Read, 'WorkspaceUser'),
- )
@HttpCode(HttpStatus.OK)
@Post('invites')
async getInvitations(
+ @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@Body()
pagination: PaginationOptions,
) {
+ const ability = this.workspaceAbility.createForUser(user, workspace);
+ if (ability.cannot(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Member)) {
+ throw new ForbiddenException();
+ }
+
return this.workspaceInvitationService.getInvitations(
workspace.id,
pagination,
@@ -131,50 +151,61 @@ export class WorkspaceController {
);
}
- @UseGuards(PoliciesGuard)
- @CheckPolicies((ability: AppAbility) =>
- ability.can(Action.Manage, 'WorkspaceUser'),
- )
@HttpCode(HttpStatus.OK)
@Post('invites/create')
async inviteUser(
@Body() inviteUserDto: InviteUserDto,
+ @AuthUser() user: User,
@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(
inviteUserDto,
workspace.id,
- authUser,
+ user,
);
}
- @UseGuards(PoliciesGuard)
- @CheckPolicies((ability: AppAbility) =>
- ability.can(Action.Manage, 'WorkspaceUser'),
- )
@HttpCode(HttpStatus.OK)
@Post('invites/resend')
async resendInvite(
@Body() revokeInviteDto: RevokeInviteDto,
+ @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.workspaceInvitationService.resendInvitation(
revokeInviteDto.invitationId,
workspace.id,
);
}
- @UseGuards(PoliciesGuard)
- @CheckPolicies((ability: AppAbility) =>
- ability.can(Action.Manage, 'WorkspaceUser'),
- )
@HttpCode(HttpStatus.OK)
@Post('invites/revoke')
async revokeInvite(
@Body() revokeInviteDto: RevokeInviteDto,
+ @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.workspaceInvitationService.revokeInvitation(
revokeInviteDto.invitationId,
workspace.id,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b087ebe5..0a6ac3d5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -126,6 +126,12 @@ importers:
apps/client:
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':
specifier: ^1.1.2
version: 1.1.2
@@ -1397,6 +1403,12 @@ packages:
'@casl/ability@6.7.1':
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':
resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
engines: {node: '>=0.1.90'}
@@ -9035,6 +9047,11 @@ snapshots:
dependencies:
'@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':
optional: true