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

View File

@ -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 (
<Menu width={250} position="bottom-end" withArrow shadow={"lg"}>

View File

@ -1,16 +1,27 @@
import React, { ReactNode } from 'react';
import data from '@emoji-mart/data';
import Picker from '@emoji-mart/react';
import { ActionIcon, Popover, Button, useMantineColorScheme } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import React, { ReactNode } from "react";
import data from "@emoji-mart/data";
import Picker from "@emoji-mart/react";
import {
ActionIcon,
Popover,
Button,
useMantineColorScheme,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
export interface EmojiPickerInterface {
onEmojiSelect: (emoji: any) => void;
icon: ReactNode;
removeEmojiAction: () => void;
readOnly: boolean;
}
function EmojiPicker({ onEmojiSelect, icon, removeEmojiAction }: EmojiPickerInterface) {
function EmojiPicker({
onEmojiSelect,
icon,
removeEmojiAction,
readOnly,
}: EmojiPickerInterface) {
const [opened, handlers] = useDisclosure(false);
const { colorScheme } = useMantineColorScheme();
@ -30,6 +41,7 @@ function EmojiPicker({ onEmojiSelect, icon, removeEmojiAction }: EmojiPickerInte
onClose={handlers.close}
width={332}
position="bottom"
disabled={readOnly}
>
<Popover.Target>
<ActionIcon c="gray" variant="transparent" onClick={handlers.toggle}>
@ -37,18 +49,27 @@ function EmojiPicker({ onEmojiSelect, icon, removeEmojiAction }: EmojiPickerInte
</ActionIcon>
</Popover.Target>
<Popover.Dropdown bg="000" style={{ border: "none" }}>
<Picker data={data} onEmojiSelect={handleEmojiSelect}
perLine={8}
skinTonePosition='search'
theme={colorScheme}
<Picker
data={data}
onEmojiSelect={handleEmojiSelect}
perLine={8}
skinTonePosition="search"
theme={colorScheme}
/>
<Button variant="default" c="gray"
size="xs"
style={{ position: 'absolute', zIndex: 2, bottom: '1rem', right: '1rem'}}
onClick={handleRemoveEmoji}>
<Button
variant="default"
c="gray"
size="xs"
style={{
position: "absolute",
zIndex: 2,
bottom: "1rem",
right: "1rem",
}}
onClick={handleRemoveEmoji}
>
Remove
</Button>
</Popover.Dropdown>
</Popover>
);

View File

@ -27,17 +27,19 @@ interface SpaceRoleMenuProps {
roles: IRoleData[];
roleName: string;
onChange?: (value: string) => void;
disabled?: boolean;
}
export default function RoleSelectMenu({
roles,
roleName,
onChange,
disabled,
}: SpaceRoleMenuProps) {
return (
<Menu withArrow>
<Menu.Target>
<RoleButton name={roleName} />
<RoleButton name={roleName} disabled={disabled} />
</Menu.Target>
<Menu.Dropdown>

View File

@ -1,7 +1,7 @@
import { Group, Text, Box } from "@mantine/core";
import React, { useState } from "react";
import classes from "./comment.module.css";
import { useAtomValue } from "jotai";
import { useAtom, useAtomValue } from "jotai";
import { timeAgo } from "@/lib/time";
import CommentEditor from "@/features/comment/components/comment-editor";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
@ -14,6 +14,7 @@ import {
} from "@/features/comment/queries/comment-query";
import { IComment } from "@/features/comment/types/comment.types";
import { UserAvatar } from "@/components/ui/user-avatar";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
interface CommentListItemProps {
comment: IComment;
@ -28,6 +29,7 @@ function CommentListItem({ comment }: CommentListItemProps) {
const [content, setContent] = useState<string>(comment.content);
const updateCommentMutation = useUpdateCommentMutation();
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
const [currentUser] = useAtom(currentUserAtom);
async function handleUpdateComment() {
try {
@ -79,10 +81,12 @@ function CommentListItem({ comment }: CommentListItemProps) {
<ResolveComment commentId={comment.id} pageId={comment.pageId} resolvedAt={comment.resolvedAt} />
)*/}
<CommentMenu
onEditComment={handleEditToggle}
onDeleteComment={handleDeleteComment}
/>
{currentUser?.user?.id === comment.creatorId && (
<CommentMenu
onEditComment={handleEditToggle}
onDeleteComment={handleDeleteComment}
/>
)}
</div>
</Group>
@ -106,7 +110,7 @@ function CommentListItem({ comment }: CommentListItemProps) {
<CommentEditor
defaultContent={content}
editable={true}
onUpdate={(newContent) => setContent(newContent)}
onUpdate={(newContent: any) => setContent(newContent)}
autofocus={true}
/>

View File

@ -11,6 +11,7 @@ export interface FullEditorProps {
slugId: string;
title: string;
spaceSlug: string;
editable: boolean;
}
export function FullEditor({
@ -18,6 +19,7 @@ export function FullEditor({
title,
slugId,
spaceSlug,
editable,
}: FullEditorProps) {
return (
<div className={classes.editor}>
@ -26,8 +28,9 @@ export function FullEditor({
slugId={slugId}
title={title}
spaceSlug={spaceSlug}
editable={editable}
/>
<MemoizedPageEditor pageId={pageId} />
<MemoizedPageEditor pageId={pageId} editable={editable} />
</div>
);
}

View File

@ -24,13 +24,10 @@ import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubbl
interface PageEditorProps {
pageId: string;
editable?: boolean;
editable: boolean;
}
export default function PageEditor({
pageId,
editable = true,
}: PageEditorProps) {
export default function PageEditor({ pageId, editable }: PageEditorProps) {
const [token] = useAtom(authTokensAtom);
const collaborationURL = useCollaborationUrl();
const [currentUser] = useAtom(currentUserAtom);

View File

@ -28,6 +28,7 @@ export interface TitleEditorProps {
slugId: string;
title: string;
spaceSlug: string;
editable: boolean;
}
export function TitleEditor({
@ -35,6 +36,7 @@ export function TitleEditor({
slugId,
title,
spaceSlug,
editable,
}: TitleEditorProps) {
const [debouncedTitleState, setDebouncedTitleState] = useState(null);
const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 1000);
@ -57,6 +59,7 @@ export function TitleEditor({
Text,
Placeholder.configure({
placeholder: "Untitled",
showOnlyWhenEditable: false,
}),
History.configure({
depth: 20,
@ -72,6 +75,7 @@ export function TitleEditor({
const currentTitle = editor.getText();
setDebouncedTitleState(currentTitle);
},
editable: editable,
content: title,
});

View File

@ -6,11 +6,13 @@ import React from "react";
import { useDisclosure } from "@mantine/hooks";
import EditGroupModal from "@/features/group/components/edit-group-modal.tsx";
import GroupActionMenu from "@/features/group/components/group-action-menu.tsx";
import useUserRole from "@/hooks/use-user-role.tsx";
export default function GroupDetails() {
const { groupId } = useParams();
const { data: group, isLoading } = useGroupQuery(groupId);
const [opened, { open, close }] = useDisclosure(false);
const { isAdmin } = useUserRole();
return (
<>
@ -21,8 +23,12 @@ export default function GroupDetails() {
<Text c="dimmed">{group.description}</Text>
<Group my="md" justify="flex-end">
<AddGroupMemberModal />
<GroupActionMenu />
{isAdmin && (
<>
<AddGroupMemberModal />
<GroupActionMenu />
</>
)}
</Group>
</div>
)}

View File

@ -8,11 +8,13 @@ import React from "react";
import { IconDots } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { UserAvatar } from "@/components/ui/user-avatar.tsx";
import useUserRole from "@/hooks/use-user-role.tsx";
export default function GroupMembersList() {
const { groupId } = useParams();
const { data, isLoading } = useGroupMembersQuery(groupId);
const removeGroupMember = useRemoveGroupMemberMutation();
const { isAdmin } = useUserRole();
const onRemove = async (userId: string) => {
const memberToRemove = {
@ -71,26 +73,28 @@ export default function GroupMembersList() {
</Table.Td>
<Table.Td>
<Menu
shadow="xl"
position="bottom-end"
offset={20}
width={200}
withArrow
arrowPosition="center"
>
<Menu.Target>
<ActionIcon variant="subtle" c="gray">
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
{isAdmin && (
<Menu
shadow="xl"
position="bottom-end"
offset={20}
width={200}
withArrow
arrowPosition="center"
>
<Menu.Target>
<ActionIcon variant="subtle" c="gray">
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={() => openRemoveModal(user.id)}>
Remove group member
</Menu.Item>
</Menu.Dropdown>
</Menu>
<Menu.Dropdown>
<Menu.Item onClick={() => openRemoveModal(user.id)}>
Remove group member
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Table.Td>
</Table.Tr>
))}

View File

@ -19,8 +19,12 @@ import { getAppUrl } from "@/lib/config.ts";
import { extractPageSlugId } from "@/lib";
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
import { boolean } from "zod";
export default function PageHeaderMenu() {
interface PageHeaderMenuProps {
readOnly?: boolean;
}
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
const toggleAside = useToggleAside();
return (
@ -35,12 +39,15 @@ export default function PageHeaderMenu() {
</ActionIcon>
</Tooltip>
<PageActionMenu />
<PageActionMenu readOnly={readOnly} />
</>
);
}
function PageActionMenu() {
interface PageActionMenuProps {
readOnly?: boolean;
}
function PageActionMenu({ readOnly }: PageActionMenuProps) {
const [, setHistoryModalOpen] = useAtom(historyAtoms);
const clipboard = useClipboard({ timeout: 500 });
const { pageSlug, spaceSlug } = useParams();
@ -96,14 +103,18 @@ function PageActionMenu() {
Page history
</Menu.Item>
<Menu.Divider />
<Menu.Item
color={"red"}
leftSection={<IconTrash size={16} stroke={2} />}
onClick={handleDeletePage}
>
Delete
</Menu.Item>
{!readOnly && (
<>
<Menu.Divider />
<Menu.Item
color={"red"}
leftSection={<IconTrash size={16} stroke={2} />}
onClick={handleDeletePage}
>
Delete
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
);

View File

@ -3,14 +3,17 @@ import PageHeaderMenu from "@/features/page/components/header/page-header-menu.t
import { Group } from "@mantine/core";
import Breadcrumb from "@/features/page/components/breadcrumbs/breadcrumb.tsx";
export default function PageHeader() {
interface Props {
readOnly?: boolean;
}
export default function PageHeader({ readOnly }: Props) {
return (
<div className={classes.header}>
<Group justify="space-between" h="100%" px="md" wrap="nowrap">
<Breadcrumb />
<Group justify="flex-end" h="100%" px="md" wrap="nowrap">
<PageHeaderMenu />
<PageHeaderMenu readOnly={readOnly} />
</Group>
</Group>
</div>

View File

@ -49,11 +49,26 @@ export function useCreatePageMutation() {
export function useUpdatePageMutation() {
const queryClient = useQueryClient();
return useMutation<IPage, Error, Partial<IPageInput>>({
mutationFn: (data) => updatePage(data),
onSuccess: (data) => {
// update page in cache
queryClient.setQueryData(["pages", data.slugId], data);
const pageBySlug = queryClient.getQueryData<IPage>([
"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 {
spaceId: string;
readOnly: boolean;
}
const openTreeNodesAtom = atom<OpenMap>({});
export default function SpaceTree({ spaceId }: SpaceTreeProps) {
export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
const { pageSlug } = useParams();
const { data, setData, controllers } =
useTreeMutation<TreeApi<SpaceTreeNode>>(spaceId);
@ -190,6 +191,9 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) {
{rootElement.current && (
<Tree
data={data}
disableDrag={readOnly}
disableDrop={readOnly}
disableEdit={readOnly}
{...controllers}
width={width}
height={height}
@ -328,6 +332,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
<IconFileDescription size="18" />
)
}
readOnly={tree.props.disableEdit as boolean}
removeEmojiAction={handleRemoveEmoji}
/>
</div>
@ -336,11 +341,14 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
<div className={classes.actions}>
<NodeMenu node={node} treeApi={tree} />
<CreateNode
node={node}
treeApi={tree}
onExpandTree={() => handleLoadChildren(node)}
/>
{!tree.props.disableEdit && (
<CreateNode
node={node}
treeApi={tree}
onExpandTree={() => handleLoadChildren(node)}
/>
)}
</div>
</div>
</>
@ -429,18 +437,23 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
Copy link
</Menu.Item>
<Menu.Divider />
<Menu.Item
c="red"
leftSection={
<IconTrash style={{ width: rem(14), height: rem(14) }} />
}
onClick={() =>
openDeleteModal({ onConfirm: () => treeApi?.delete(node) })
}
>
Delete
</Menu.Item>
{!(treeApi.props.disableEdit as boolean) && (
<>
<Menu.Divider />
<Menu.Item
c="red"
leftSection={
<IconTrash style={{ width: rem(14), height: rem(14) }} />
}
onClick={() =>
openDeleteModal({ onConfirm: () => treeApi?.delete(node) })
}
>
Delete
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
);

View File

@ -13,8 +13,9 @@ const formSchema = z.object({
type FormValues = z.infer<typeof formSchema>;
interface EditSpaceFormProps {
space: ISpace;
readOnly?: boolean;
}
export function EditSpaceForm({ space }: EditSpaceFormProps) {
export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
const updateSpaceMutation = useUpdateSpaceMutation();
const form = useForm<FormValues>({
@ -51,14 +52,16 @@ export function EditSpaceForm({ space }: EditSpaceFormProps) {
<TextInput
id="name"
label="Name"
placeholder="e.g Developers"
placeholder="e.g Sales"
variant="filled"
{...form.getInputProps("name")}
/>
<Textarea
id="description"
label="Description"
placeholder="e.g Space for developers to collaborate"
placeholder="e.g Space for sales team to collaborate"
variant="filled"
autosize
minRows={1}
maxRows={3}
@ -66,11 +69,13 @@ export function EditSpaceForm({ space }: EditSpaceFormProps) {
/>
</Stack>
<Group justify="flex-end" mt="md">
<Button type="submit" disabled={!form.isDirty()}>
Save
</Button>
</Group>
{!readOnly && (
<Group justify="flex-end" mt="md">
<Button type="submit" disabled={!form.isDirty()}>
Save
</Button>
</Group>
)}
</form>
</Box>
</>

View File

@ -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 (
<>
<Modal.Root
@ -50,17 +57,30 @@ export default function SpaceSettingsModal({
<ScrollArea h="600" w="100%" scrollbarSize={5}>
<Tabs.Panel value="general">
<SpaceDetails spaceId={space?.id} />
<Divider my="sm" />
<SpaceDetails
spaceId={space?.id}
readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage,
SpaceCaslSubject.Settings,
)}
/>
</Tabs.Panel>
<Tabs.Panel value="members">
<Group my="md" justify="flex-end">
<AddSpaceMembersModal spaceId={space?.id} />
<GroupActionMenu />
{spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Member,
) && <AddSpaceMembersModal spaceId={space?.id} />}
</Group>
<SpaceMembersList spaceId={space?.id} />
<SpaceMembersList
spaceId={space?.id}
readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage,
SpaceCaslSubject.Member,
)}
/>
</Tabs.Panel>
</ScrollArea>
</Tabs>

View File

@ -9,7 +9,7 @@ export function SpaceName({ spaceName }: SpaceNameProps) {
<UnstyledButton className={classes.spaceName}>
<Group>
<div style={{ flex: 1 }}>
<Text size="md" fw={500}>
<Text size="md" fw={500} lineClamp={1}>
{spaceName}
</Text>
</div>

View File

@ -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 (
<>
<div className={classes.navbar}>
@ -110,22 +118,33 @@ export function SpaceSidebar() {
Pages
</Text>
<Tooltip label="Create page" withArrow position="right">
<ActionIcon
variant="default"
size={18}
onClick={handleCreatePage}
>
<IconPlus
style={{ width: rem(12), height: rem(12) }}
stroke={1.5}
/>
</ActionIcon>
</Tooltip>
{spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
) && (
<Tooltip label="Create page" withArrow position="right">
<ActionIcon
variant="default"
size={18}
onClick={handleCreatePage}
>
<IconPlus
style={{ width: rem(12), height: rem(12) }}
stroke={1.5}
/>
</ActionIcon>
</Tooltip>
)}
</Group>
<div className={classes.pages}>
<SpaceTree spaceId={space.id} />
<SpaceTree
spaceId={space.id}
readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
)}
/>
</div>
</div>
</div>

View File

@ -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) {
<Text my="md" fw={600}>
Details
</Text>
<EditSpaceForm space={space} />
<EditSpaceForm space={space} readOnly={readOnly} />
</div>
)}
</>

View File

@ -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)}`}
</Text>
</div>
</Group>
@ -138,32 +143,37 @@ export default function SpaceMembersList({ spaceId }: SpaceMembersProps) {
member.role,
)
}
disabled={readOnly}
/>
</Table.Td>
<Table.Td>
<Menu
shadow="xl"
position="bottom-end"
offset={20}
width={200}
withArrow
arrowPosition="center"
>
<Menu.Target>
<ActionIcon variant="subtle" c="gray">
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
{!readOnly && (
<Menu
shadow="xl"
position="bottom-end"
offset={20}
width={200}
withArrow
arrowPosition="center"
>
<Menu.Target>
<ActionIcon variant="subtle" c="gray">
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={() => openRemoveModal(member.id, member.type)}
>
Remove space member
</Menu.Item>
</Menu.Dropdown>
</Menu>
<Menu.Dropdown>
<Menu.Item
onClick={() =>
openRemoveModal(member.id, member.type)
}
>
Remove space member
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Table.Td>
</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],
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({

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

View File

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

View File

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

View File

@ -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() {
<Table.Td>{getUserRoleLabel(invitation.role)}</Table.Td>
<Table.Td>
{format(invitation.createdAt, "MM/dd/yyyy")}
</Table.Td>
<Table.Td>{formattedDate(invitation.createdAt)}</Table.Td>
<Table.Td>
<InviteActionMenu invitationId={invitation.id} />
{isAdmin && (
<InviteActionMenu invitationId={invitation.id} />
)}
</Table.Td>
</Table.Tr>
))}

View File

@ -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}
/>
</Table.Td>
</Table.Tr>

View File

@ -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<FormValues>({
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")}
/>
<Button mt="sm" type="submit" disabled={isLoading} loading={isLoading}>
Save
</Button>
{isAdmin && (
<Button
mt="sm"
type="submit"
disabled={isLoading || !form.isDirty()}
loading={isLoading}
>
Save
</Button>
)}
</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 {
if (memberCount === 1) {
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 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 <div>Error fetching page data.</div>;
}
if (!space) {
return <></>;
}
return (
page && (
<div>
<Helmet>
<title>{page.title}</title>
<title>{`${page?.icon || ""} ${page.title || "untitled"}`}</title>
</Helmet>
<PageHeader />
<PageHeader
readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
)}
/>
<FullEditor
pageId={page.id}
title={page.title}
slugId={page.slugId}
spaceSlug={page?.space?.slug || spaceSlug}
spaceSlug={page?.space?.slug}
editable={spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
)}
/>
<HistoryModal pageId={page.id} />
</div>

View File

@ -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 (
<>
<SettingsTitle title="Groups" />
<Group my="md" justify="flex-end">
<CreateGroupModal />
{isAdmin && <CreateGroupModal />}
</Group>
<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 { 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}
/>
<WorkspaceInviteModal />
{isAdmin && <WorkspaceInviteModal />}
</Group>
<Space h="lg" />