space updates

* space UI
* space management
* space permissions
* other fixes
This commit is contained in:
Philipinho
2024-04-12 19:38:58 +01:00
parent b02cfd02f0
commit 90ae750d48
54 changed files with 1966 additions and 365 deletions

View File

@ -11,6 +11,7 @@ import SettingsLayout from "@/components/layouts/settings/settings-layout.tsx";
import WorkspaceSettings from "@/pages/settings/workspace/workspace-settings"; import WorkspaceSettings from "@/pages/settings/workspace/workspace-settings";
import Groups from "@/pages/settings/group/groups"; import Groups from "@/pages/settings/group/groups";
import GroupInfo from "./pages/settings/group/group-info"; import GroupInfo from "./pages/settings/group/group-info";
import Spaces from "@/pages/settings/space/spaces.tsx";
export default function App() { export default function App() {
return ( return (
@ -31,8 +32,7 @@ export default function App() {
<Route path={"members"} element={<WorkspaceMembers />} /> <Route path={"members"} element={<WorkspaceMembers />} />
<Route path={"groups"} element={<Groups />} /> <Route path={"groups"} element={<Groups />} />
<Route path={"groups/:groupId"} element={<GroupInfo />} /> <Route path={"groups/:groupId"} element={<GroupInfo />} />
<Route path={"spaces"} element={<Home />} /> <Route path={"spaces"} element={<Spaces />} />
<Route path={"security"} element={<Home />} />
</Route> </Route>
</Routes> </Routes>
</> </>

View File

@ -0,0 +1,15 @@
import { ActionIcon, rem } from "@mantine/core";
import React from "react";
import { IconUsersGroup } from "@tabler/icons-react";
interface IconPeopleCircleProps extends React.ComponentPropsWithoutRef<"svg"> {
size?: number | string;
}
export function IconGroupCircle() {
return (
<ActionIcon variant="light" size="lg" color="gray" radius="xl">
<IconUsersGroup stroke={1.5} />
</ActionIcon>
);
}

View File

@ -42,11 +42,6 @@ const groupedData: DataGroup[] = [
}, },
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" }, { label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" }, { label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
{
label: "Security",
icon: IconFingerprint,
path: "/settings/security",
},
], ],
}, },
]; ];

View File

@ -31,7 +31,8 @@
text-decoration: none; text-decoration: none;
font-size: var(--mantine-font-size-sm); font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1)); color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm); padding-left: var(--mantine-spacing-xs) ;
min-height: 30px;
border-radius: var(--mantine-radius-sm); border-radius: var(--mantine-radius-sm);
font-weight: 500; font-weight: 500;
user-select: none; user-select: none;

View File

@ -5,24 +5,23 @@ import {
ActionIcon, ActionIcon,
Tooltip, Tooltip,
rem, rem,
} from '@mantine/core'; } from "@mantine/core";
import { spotlight } from '@mantine/spotlight'; import { spotlight } from "@mantine/spotlight";
import { import {
IconSearch, IconSearch,
IconPlus, IconPlus,
IconSettings, IconSettings,
IconFilePlus,
IconHome, IconHome,
} from '@tabler/icons-react'; } from "@tabler/icons-react";
import classes from './navbar.module.css'; import classes from "./navbar.module.css";
import { UserButton } from './user-button'; import { UserButton } from "./user-button";
import React from 'react'; import React from "react";
import { useAtom } from 'jotai'; import { useAtom } from "jotai";
import { SearchSpotlight } from '@/features/search/search-spotlight'; import { SearchSpotlight } from "@/features/search/search-spotlight";
import { treeApiAtom } from '@/features/page/tree/atoms/tree-api-atom'; import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom";
import PageTree from '@/features/page/tree/page-tree'; import PageTree from "@/features/page/tree/page-tree";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
interface PrimaryMenuItem { interface PrimaryMenuItem {
icon: React.ElementType; icon: React.ElementType;
@ -31,9 +30,9 @@ interface PrimaryMenuItem {
} }
const primaryMenu: PrimaryMenuItem[] = [ const primaryMenu: PrimaryMenuItem[] = [
{ icon: IconHome, label: 'Home' }, { icon: IconHome, label: "Home" },
{ icon: IconSearch, label: 'Search' }, { icon: IconSearch, label: "Search" },
{ icon: IconSettings, label: 'Settings' }, { icon: IconSettings, label: "Settings" },
// { icon: IconFilePlus, label: 'New Page' }, // { icon: IconFilePlus, label: 'New Page' },
]; ];
@ -42,21 +41,21 @@ export function Navbar() {
const navigate = useNavigate(); const navigate = useNavigate();
const handleMenuItemClick = (label: string) => { const handleMenuItemClick = (label: string) => {
if (label === 'Home') { if (label === "Home") {
navigate('/home'); navigate("/home");
} }
if (label === 'Search') { if (label === "Search") {
spotlight.open(); spotlight.open();
} }
if (label === 'Settings') { if (label === "Settings") {
navigate('/settings/workspace'); navigate("/settings/workspace");
} }
}; };
function handleCreatePage() { function handleCreatePage() {
tree?.create({ parentId: null, type: 'internal', index: 0 }); tree?.create({ parentId: null, type: "internal", index: 0 });
} }
const primaryMenuItems = primaryMenu.map((menuItem) => ( const primaryMenuItems = primaryMenu.map((menuItem) => (

View File

@ -0,0 +1,63 @@
import React, { forwardRef } from "react";
import { IconCheck, IconChevronDown } from "@tabler/icons-react";
import { Group, Text, Menu, Button } from "@mantine/core";
import { IRoleData } from "@/lib/types.ts";
interface RoleButtonProps extends React.ComponentPropsWithoutRef<"button"> {
name: string;
}
const RoleButton = forwardRef<HTMLButtonElement, RoleButtonProps>(
({ name, ...others }: RoleButtonProps, ref) => (
<Button
variant="default"
ref={ref}
style={{
border: "none",
}}
rightSection={<IconChevronDown size="1rem" />}
{...others}
>
{name}
</Button>
),
);
interface SpaceRoleMenuProps {
roles: IRoleData[];
roleName: string;
onChange?: (value: string) => void;
}
export default function RoleSelectMenu({
roles,
roleName,
onChange,
}: SpaceRoleMenuProps) {
return (
<Menu withArrow>
<Menu.Target>
<RoleButton name={roleName} />
</Menu.Target>
<Menu.Dropdown>
{roles?.map((item) => (
<Menu.Item
onClick={() => onChange && onChange(item.value)}
key={item.value}
>
<Group flex="1" gap="xs">
<div>
<Text size="sm">{item.label}</Text>
<Text size="xs" opacity={0.65}>
{item.description}
</Text>
</div>
{item.label === roleName && <IconCheck size={20} />}
</Group>
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
);
}

View File

@ -1,8 +1,8 @@
import { Table, Group, Text, Anchor } from "@mantine/core"; import { Table, Group, Text, Anchor } from "@mantine/core";
import { useGetGroupsQuery } from "@/features/group/queries/group-query"; import { useGetGroupsQuery } from "@/features/group/queries/group-query";
import { IconUsersGroup } from "@tabler/icons-react";
import React from "react"; import React from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
export default function GroupList() { export default function GroupList() {
const { data, isLoading } = useGetGroupsQuery(); const { data, isLoading } = useGetGroupsQuery();
@ -33,7 +33,7 @@ export default function GroupList() {
to={`/settings/groups/${group.id}`} to={`/settings/groups/${group.id}`}
> >
<Group gap="sm"> <Group gap="sm">
<IconUsersGroup stroke={1.5} /> <IconGroupCircle />
<div> <div>
<Text fz="sm" fw={500}> <Text fz="sm" fw={500}>
{group.name} {group.name}

View File

@ -1,11 +1,30 @@
import { useQuery, UseQueryResult } from '@tanstack/react-query'; import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { searchPage } from '@/features/search/services/search-service'; import {
import { IPageSearch } from '@/features/search/types/search.types'; searchPage,
searchSuggestions,
} from "@/features/search/services/search-service";
import {
IPageSearch,
ISuggestionResult,
SearchSuggestionParams,
} from "@/features/search/types/search.types";
export function usePageSearchQuery(query: string): UseQueryResult<IPageSearch[], Error> { export function usePageSearchQuery(
query: string,
): UseQueryResult<IPageSearch[], Error> {
return useQuery({ return useQuery({
queryKey: ['page-history', query], queryKey: ["page-search", query],
queryFn: () => searchPage(query), queryFn: () => searchPage(query),
enabled: !!query, enabled: !!query,
}); });
} }
export function useSearchSuggestionsQuery(
params: SearchSuggestionParams,
): UseQueryResult<ISuggestionResult, Error> {
return useQuery({
queryKey: ["search-suggestion", params],
queryFn: () => searchSuggestions(params),
enabled: !!params.query,
});
}

View File

@ -1,7 +1,18 @@
import api from '@/lib/api-client'; import api from "@/lib/api-client";
import { IPageSearch } from '@/features/search/types/search.types'; import {
IPageSearch,
ISuggestionResult,
SearchSuggestionParams,
} from "@/features/search/types/search.types";
export async function searchPage(query: string): Promise<IPageSearch[]> { export async function searchPage(query: string): Promise<IPageSearch[]> {
const req = await api.post<IPageSearch[]>('/search', { query }); const req = await api.post<IPageSearch[]>("/search", { query });
return req.data as any; return req.data;
}
export async function searchSuggestions(
params: SearchSuggestionParams,
): Promise<ISuggestionResult> {
const req = await api.post<ISuggestionResult>("/search/suggest", params);
return req.data;
} }

View File

@ -1,3 +1,5 @@
import { IUser } from "@/features/user/types/user.types.ts";
import { IGroup } from "@/features/group/types/group.types.ts";
export interface IPageSearch { export interface IPageSearch {
id: string; id: string;
@ -10,3 +12,14 @@ export interface IPageSearch {
rank: string; rank: string;
highlight: string; highlight: string;
} }
export interface SearchSuggestionParams {
query: string;
includeUsers?: boolean;
includeGroups?: boolean;
}
export interface ISuggestionResult {
users?: Partial<IUser[]>;
groups?: Partial<IGroup[]>;
}

View File

@ -0,0 +1,72 @@
import { Button, Divider, Group, Modal, Stack } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import React, { useState } from "react";
import { useAddSpaceMemberMutation } from "@/features/space/queries/space-query.ts";
import { MultiMemberSelect } from "@/features/space/components/multi-member-select.tsx";
import { SpaceMemberRole } from "@/features/space/components/space-member-role.tsx";
import { SpaceRole } from "@/lib/types.ts";
interface AddSpaceMemberModalProps {
spaceId: string;
}
export default function AddSpaceMembersModal({
spaceId,
}: AddSpaceMemberModalProps) {
const [opened, { open, close }] = useDisclosure(false);
const [memberIds, setMemberIds] = useState<string[]>([]);
const [role, setRole] = useState<string>(SpaceRole.WRITER);
const addSpaceMemberMutation = useAddSpaceMemberMutation();
const handleMultiSelectChange = (value: string[]) => {
setMemberIds(value);
};
const handleRoleSelection = (role: string) => {
setRole(role);
};
const handleSubmit = async () => {
// member can be a users or groups
const userIds = memberIds
.map((id) => (id.startsWith("user-") ? id.split("user-")[1] : null))
.filter((id) => id !== null);
const groupIds = memberIds
.map((id) => (id.startsWith("group-") ? id.split("group-")[1] : null))
.filter((id) => id !== null);
const addSpaceMember = {
spaceId: spaceId,
userIds: userIds,
groupIds: groupIds,
role: role,
};
await addSpaceMemberMutation.mutateAsync(addSpaceMember);
close();
};
return (
<>
<Button onClick={open}>Add space members</Button>
<Modal opened={opened} onClose={close} title="Add space members">
<Divider size="xs" mb="xs" />
<Stack>
<MultiMemberSelect onChange={handleMultiSelectChange} />
<SpaceMemberRole
onSelect={handleRoleSelection}
defaultRole={role}
label="Select role"
/>
</Stack>
<Group justify="flex-end" mt="md">
<Button onClick={handleSubmit} type="submit">
Add
</Button>
</Group>
</Modal>
</>
);
}

View File

@ -0,0 +1,78 @@
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
import React from "react";
import { useForm, zodResolver } from "@mantine/form";
import * as z from "zod";
import { useUpdateSpaceMutation } from "@/features/space/queries/space-query.ts";
import { ISpace } from "@/features/space/types/space.types.ts";
const formSchema = z.object({
name: z.string().min(2).max(50),
description: z.string().max(250),
});
type FormValues = z.infer<typeof formSchema>;
interface EditSpaceFormProps {
space: ISpace;
}
export function EditSpaceForm({ space }: EditSpaceFormProps) {
const updateSpaceMutation = useUpdateSpaceMutation();
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
initialValues: {
name: space?.name,
description: space?.description || "",
},
});
const handleSubmit = async (values: {
name?: string;
description?: string;
}) => {
const spaceData: Partial<ISpace> = {
spaceId: space.id,
};
if (form.isDirty("name")) {
spaceData.name = values.name;
}
if (form.isDirty("description")) {
spaceData.description = values.description;
}
await updateSpaceMutation.mutateAsync(spaceData);
form.resetDirty();
};
return (
<>
<Box>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack>
<TextInput
id="name"
label="Name"
placeholder="e.g Developers"
{...form.getInputProps("name")}
/>
<Textarea
id="description"
label="Description"
placeholder="e.g Space for developers to collaborate"
autosize
minRows={1}
maxRows={3}
{...form.getInputProps("description")}
/>
</Stack>
<Group justify="flex-end" mt="md">
<Button type="submit" disabled={!form.isDirty()}>
Save
</Button>
</Group>
</form>
</Box>
</>
);
}

View File

@ -0,0 +1,117 @@
import React, { useEffect, useState } from "react";
import { useDebouncedValue } from "@mantine/hooks";
import { Group, MultiSelect, MultiSelectProps, Text } from "@mantine/core";
import { IGroup } from "@/features/group/types/group.types.ts";
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query.ts";
import { UserAvatar } from "@/components/ui/user-avatar.tsx";
import { IUser } from "@/features/user/types/user.types.ts";
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
interface MultiMemberSelectProps {
onChange: (value: string[]) => void;
}
const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
option,
}) => (
<Group gap="sm">
{option["type"] === "user" && (
<UserAvatar
avatarUrl={option["avatarUrl"]}
size={20}
name={option.label}
/>
)}
{option["type"] === "group" && <IconGroupCircle />}
<div>
<Text size="sm">{option.label}</Text>
</div>
</Group>
);
export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
const [searchValue, setSearchValue] = useState("");
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
query: debouncedQuery,
includeUsers: true,
includeGroups: true,
});
const [data, setData] = useState([]);
useEffect(() => {
if (suggestion) {
// Extract user and group items
const userItems = suggestion?.users.map((user: IUser) => ({
value: `user-${user.id}`,
label: user.name,
avatarUrl: user.avatarUrl,
type: "user",
}));
const groupItems = suggestion?.groups.map((group: IGroup) => ({
value: `group-${group.id}`,
label: group.name,
type: "group",
}));
// Function to merge items into groups without duplicates
const mergeItemsIntoGroups = (existingGroups, newItems, groupName) => {
const existingValues = new Set(
existingGroups.flatMap((group) =>
group.items.map((item) => item.value),
),
);
const newItemsFiltered = newItems.filter(
(item) => !existingValues.has(item.value),
);
const updatedGroups = existingGroups.map((group) => {
if (group.group === groupName) {
return { ...group, items: [...group.items, ...newItemsFiltered] };
}
return group;
});
// Use spread syntax to avoid mutation
return updatedGroups.some((group) => group.group === groupName)
? updatedGroups
: [...updatedGroups, { group: groupName, items: newItemsFiltered }];
};
// Merge user items into groups
const updatedUserGroups = mergeItemsIntoGroups(
data,
userItems,
"Select a user",
);
// Merge group items into groups
const finalData = mergeItemsIntoGroups(
updatedUserGroups,
groupItems,
"Select a group",
);
setData(finalData);
}
}, [suggestion, data]);
return (
<MultiSelect
data={data}
renderOption={renderMultiSelectOption}
hidePickedOptions
maxDropdownHeight={300}
label="Add members"
placeholder="Search for users and groups"
searchable
searchValue={searchValue}
onSearchChange={setSearchValue}
clearable
variant="filled"
onChange={onChange}
maxValues={50}
/>
);
}

View File

@ -0,0 +1,82 @@
import {
Modal,
Tabs,
rem,
Group,
Divider,
Text,
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 { ISpace } from "@/features/space/types/space.types.ts";
import SpaceDetails from "@/features/space/components/space-details.tsx";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
interface SpaceSettingsModalProps {
spaceId: string;
opened: boolean;
onClose: () => void;
}
export default function SpaceSettingsModal({
spaceId,
opened,
onClose,
}: SpaceSettingsModalProps) {
const { data: space, isLoading } = useSpaceQuery(spaceId);
return (
<>
<Modal.Root
opened={opened}
onClose={onClose}
size={600}
padding="xl"
yOffset="10vh"
xOffset={0}
mah={400}
>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header py={0}>
<Modal.Title fw={500}>{space?.name} space </Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<div style={{ height: rem("600px") }}>
<Tabs color="gray" defaultValue="members">
<Tabs.List>
<Tabs.Tab fw={500} value="general">
Settings
</Tabs.Tab>
<Tabs.Tab fw={500} value="members">
Members
</Tabs.Tab>
</Tabs.List>
<ScrollArea h="600" w="100%" scrollbarSize={5}>
<Tabs.Panel value="general">
<SpaceDetails spaceId={space?.id} />
<Divider my="sm" />
</Tabs.Panel>
<Tabs.Panel value="members">
<Group my="md" justify="flex-end">
<AddSpaceMembersModal spaceId={space?.id} />
<GroupActionMenu />
</Group>
<SpaceMembersList spaceId={space?.id} />
</Tabs.Panel>
</ScrollArea>
</Tabs>
</div>
</Modal.Body>
</Modal.Content>
</Modal.Root>
</>
);
}

View File

@ -0,0 +1,24 @@
import React from "react";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
import { EditSpaceForm } from "@/features/space/components/edit-space-form.tsx";
import { Text } from "@mantine/core";
interface SpaceDetailsProps {
spaceId: string;
}
export default function SpaceDetails({ spaceId }: SpaceDetailsProps) {
const { data: space, isLoading } = useSpaceQuery(spaceId);
return (
<>
{space && (
<div>
<Text my="md" fw={600}>
Details
</Text>
<EditSpaceForm space={space} />
</div>
)}
</>
);
}

View File

@ -0,0 +1,67 @@
import { Table, Group, Text, Avatar } from "@mantine/core";
import React, { useState } from "react";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
import { useDisclosure } from "@mantine/hooks";
export default function SpaceList() {
const { data, isLoading } = useGetSpacesQuery();
const [opened, { open, close }] = useDisclosure(false);
const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null);
const handleClick = (spaceId: string) => {
setSelectedSpaceId(spaceId);
open();
};
return (
<>
{data && (
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Space</Table.Th>
<Table.Th>Members</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data?.items.map((space, index) => (
<Table.Tr
key={index}
style={{ cursor: "pointer" }}
onClick={() => handleClick(space.id)}
>
<Table.Td>
<Group gap="sm">
<Avatar color="gray" radius="xl">
{space.name.charAt(0).toUpperCase()}
</Avatar>
<div>
<Text fz="sm" fw={500}>
{space.name}
</Text>
<Text fz="xs" c="dimmed">
{space.description}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>{space.memberCount} members</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
{selectedSpaceId && (
<SpaceSettingsModal
opened={opened}
onClose={close}
spaceId={selectedSpaceId}
/>
)}
</>
);
}

View File

@ -0,0 +1,52 @@
import { IconCheck } from "@tabler/icons-react";
import { Group, Select, SelectProps, Text } from "@mantine/core";
import React from "react";
import { spaceRoleData } from "@/features/space/types/space-role-data.ts";
const iconProps = {
stroke: 1.5,
color: "currentColor",
opacity: 0.6,
size: 18,
};
const renderSelectOption: SelectProps["renderOption"] = ({
option,
checked,
}) => (
<Group flex="1" gap="xs">
<div>
<Text size="sm">{option.label}</Text>
<Text size="xs" opacity={0.65}>
{option["description"]}
</Text>
</div>{" "}
{checked && (
<IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />
)}
</Group>
);
interface SpaceMemberRoleProps {
onSelect: (value: string) => void;
defaultRole: string;
label?: string;
}
export function SpaceMemberRole({
onSelect,
defaultRole,
label,
}: SpaceMemberRoleProps) {
return (
<Select
data={spaceRoleData}
defaultValue={defaultRole}
label={label}
onChange={onSelect}
renderOption={renderSelectOption}
allowDeselect={false}
variant="filled"
/>
);
}

View File

@ -0,0 +1,176 @@
import { Group, Table, Text, Menu, ActionIcon } from "@mantine/core";
import { useParams } from "react-router-dom";
import React from "react";
import { IconDots } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { UserAvatar } from "@/components/ui/user-avatar.tsx";
import {
useChangeSpaceMemberRoleMutation,
useRemoveSpaceMemberMutation,
useSpaceMembersQuery,
} from "@/features/space/queries/space-query.ts";
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
import { IRemoveSpaceMember } from "@/features/space/types/space.types.ts";
import RoleSelectMenu from "@/components/ui/role-select-menu.tsx";
import {
getSpaceRoleLabel,
spaceRoleData,
} from "@/features/space/types/space-role-data.ts";
type MemberType = "user" | "group";
interface SpaceMembersProps {
spaceId: string;
}
export default function SpaceMembersList({ spaceId }: SpaceMembersProps) {
const { data, isLoading } = useSpaceMembersQuery(spaceId);
const removeSpaceMember = useRemoveSpaceMemberMutation();
const changeSpaceMemberRoleMutation = useChangeSpaceMemberRoleMutation();
const handleRoleChange = async (
memberId: string,
type: MemberType,
newRole: string,
currentRole: string,
) => {
if (newRole === currentRole) {
return;
}
const memberRoleUpdate: {
spaceId: string;
role: string;
userId?: string;
groupId?: string;
} = {
spaceId: spaceId,
role: newRole,
};
if (type === "user") {
memberRoleUpdate.userId = memberId;
}
if (type === "group") {
memberRoleUpdate.groupId = memberId;
}
await changeSpaceMemberRoleMutation.mutateAsync(memberRoleUpdate);
};
const onRemove = async (memberId: string, type: MemberType) => {
console.log("remove", spaceId);
const memberToRemove: IRemoveSpaceMember = {
spaceId: spaceId,
};
if (type === "user") {
memberToRemove.userId = memberId;
}
if (type === "group") {
memberToRemove.groupId = memberId;
}
await removeSpaceMember.mutateAsync(memberToRemove);
};
const openRemoveModal = (memberId: string, type: MemberType) =>
modals.openConfirmModal({
title: "Remove space member",
children: (
<Text size="sm">
Are you sure you want to remove this user from the space? The user
will lose all access to this space.
</Text>
),
centered: true,
labels: { confirm: "Remove", cancel: "Cancel" },
confirmProps: { color: "red" },
onConfirm: () => onRemove(memberId, type),
});
return (
<>
{data && (
<Table verticalSpacing={8}>
<Table.Thead>
<Table.Tr>
<Table.Th>Member</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data?.items.map((member, index) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm">
{member.type === "user" && (
<UserAvatar
avatarUrl={member?.avatarUrl}
name={member.name}
/>
)}
{member.type === "group" && <IconGroupCircle />}
<div>
<Text fz="sm" fw={500}>
{member?.name}
</Text>
<Text fz="xs" c="dimmed">
{member.type == "user" && member?.email}
{member.type == "group" &&
`Group - ${member?.memberCount === 1 ? "1 member" : `${member?.memberCount} members`}`}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<RoleSelectMenu
roles={spaceRoleData}
roleName={getSpaceRoleLabel(member.role)}
onChange={(newRole) =>
handleRoleChange(
member.id,
member.type,
newRole,
member.role,
)
}
/>
</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>
<Menu.Dropdown>
<Menu.Item
onClick={() => openRemoveModal(member.id, member.type)}
>
Remove space member
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</>
);
}

View File

@ -1,10 +1,137 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query"; import {
import { ISpace } from "@/features/space/types/space.types"; useMutation,
import { getUserSpaces } from "@/features/space/services/space-service"; useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
IAddSpaceMember,
IChangeSpaceMemberRole,
IRemoveSpaceMember,
ISpace,
ISpaceMember,
} from "@/features/space/types/space.types";
import {
addSpaceMember,
changeMemberRole,
getSpaceById,
getSpaceMembers,
getSpaces,
removeSpaceMember,
updateSpace,
} from "@/features/space/services/space-service.ts";
import { notifications } from "@mantine/notifications";
import { IPagination } from "@/lib/types.ts";
export function useUserSpacesQuery(): UseQueryResult<ISpace[], Error> { export function useGetSpacesQuery(): UseQueryResult<
IPagination<ISpace>,
Error
> {
return useQuery({ return useQuery({
queryKey: ["user-spaces"], queryKey: ["spaces"],
queryFn: () => getUserSpaces(), queryFn: () => getSpaces(),
});
}
export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
return useQuery({
queryKey: ["space", spaceId],
queryFn: () => getSpaceById(spaceId),
enabled: !!spaceId,
});
}
export function useUpdateSpaceMutation() {
const queryClient = useQueryClient();
return useMutation<ISpace, Error, Partial<ISpace>>({
mutationFn: (data) => updateSpace(data),
onSuccess: (data, variables) => {
notifications.show({ message: "Space updated successfully" });
const space = queryClient.getQueryData([
"space",
variables.spaceId,
]) as ISpace;
if (space) {
const updatedSpace = { ...space, ...data };
queryClient.setQueryData(["space", variables.spaceId], updatedSpace);
}
queryClient.invalidateQueries({
queryKey: ["spaces"],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useSpaceMembersQuery(
spaceId: string,
): UseQueryResult<IPagination<ISpaceMember>, Error> {
return useQuery({
queryKey: ["spaceMembers", spaceId],
queryFn: () => getSpaceMembers(spaceId),
enabled: !!spaceId,
});
}
export function useAddSpaceMemberMutation() {
const queryClient = useQueryClient();
return useMutation<void, Error, IAddSpaceMember>({
mutationFn: (data) => addSpaceMember(data),
onSuccess: (data, variables) => {
notifications.show({ message: "Members added successfully" });
queryClient.invalidateQueries({
queryKey: ["spaceMembers", variables.spaceId],
});
},
onError: () => {
notifications.show({
message: "Failed to add space members",
color: "red",
});
},
});
}
export function useRemoveSpaceMemberMutation() {
const queryClient = useQueryClient();
return useMutation<void, Error, IRemoveSpaceMember>({
mutationFn: (data) => removeSpaceMember(data),
onSuccess: (data, variables) => {
notifications.show({ message: "Removed successfully" });
queryClient.refetchQueries({
queryKey: ["spaceMembers", variables.spaceId],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useChangeSpaceMemberRoleMutation() {
const queryClient = useQueryClient();
return useMutation<void, Error, IChangeSpaceMemberRole>({
mutationFn: (data) => changeMemberRole(data),
onSuccess: (data, variables) => {
notifications.show({ message: "Member role updated successfully" });
// due to pagination levels, change in cache instead
queryClient.refetchQueries({
queryKey: ["spaceMembers", variables.spaceId],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
}); });
} }

View File

@ -1,7 +1,47 @@
import api from '@/lib/api-client'; import api from "@/lib/api-client";
import { ISpace } from '@/features/space/types/space.types'; import {
IAddSpaceMember,
IChangeSpaceMemberRole,
IRemoveSpaceMember,
ISpace,
} from "@/features/space/types/space.types";
import { IPagination } from "@/lib/types.ts";
import { IUser } from "@/features/user/types/user.types.ts";
export async function getUserSpaces(): Promise<ISpace[]> { export async function getSpaces(): Promise<IPagination<ISpace>> {
const req = await api.get<ISpace[]>('/spaces'); const req = await api.post("/spaces");
return req.data as ISpace[]; return req.data;
}
export async function getSpaceById(spaceId: string): Promise<ISpace> {
const req = await api.post<ISpace>("/spaces/info", { spaceId });
return req.data;
}
export async function updateSpace(data: Partial<ISpace>): Promise<ISpace> {
const req = await api.post<ISpace>("/spaces/update", data);
return req.data;
}
export async function getSpaceMembers(
spaceId: string,
): Promise<IPagination<IUser>> {
const req = await api.post<any>("/spaces/members", { spaceId });
return req.data;
}
export async function addSpaceMember(data: IAddSpaceMember): Promise<void> {
await api.post("/spaces/members/add", data);
}
export async function removeSpaceMember(
data: IRemoveSpaceMember,
): Promise<void> {
await api.post("/spaces/members/remove", data);
}
export async function changeMemberRole(
data: IChangeSpaceMemberRole,
): Promise<void> {
await api.post("/spaces/members/role", data);
} }

View File

@ -0,0 +1,24 @@
import { IRoleData, SpaceRole } from "@/lib/types.ts";
export const spaceRoleData: IRoleData[] = [
{
label: "Full access",
value: SpaceRole.ADMIN,
description: "Has full access to space settings and pages",
},
{
label: "Can edit",
value: SpaceRole.WRITER,
description: "Can create and edit pages in space.",
},
{
label: "Can view",
value: SpaceRole.READER,
description: "Can view pages in space but not edit",
},
];
export function getSpaceRoleLabel(value: string) {
const role = spaceRoleData.find((item) => item.value === value);
return role ? role.label : undefined;
}

View File

@ -7,5 +7,43 @@ export interface ISpace {
creatorId: string; creatorId: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
memberCount?: number;
spaceId?: string;
} }
export interface IAddSpaceMember {
spaceId: string;
userIds?: string[];
groupIds?: string[];
}
export interface IRemoveSpaceMember {
spaceId: string;
userId?: string;
groupId?: string;
}
export interface IChangeSpaceMemberRole {
spaceId: string;
userId?: string;
groupId?: string;
}
// space member
export interface SpaceUserInfo {
id: string;
name: string;
email: string;
avatarUrl: string;
type: "user";
}
export interface SpaceGroupInfo {
id: string;
name: string;
isDefault: boolean;
memberCount: number;
type: "group";
}
export type ISpaceMember = { role: string } & (SpaceUserInfo | SpaceGroupInfo);

View File

@ -1,10 +1,36 @@
import { Group, Table, Avatar, Text, Badge } from "@mantine/core"; import { Group, Table, Avatar, Text, Badge } from "@mantine/core";
import { useWorkspaceMembersQuery } from "@/features/workspace/queries/workspace-query.ts"; import {
useChangeMemberRoleMutation,
useWorkspaceMembersQuery,
} from "@/features/workspace/queries/workspace-query.ts";
import { UserAvatar } from "@/components/ui/user-avatar.tsx"; import { UserAvatar } from "@/components/ui/user-avatar.tsx";
import React from "react"; import React from "react";
import RoleSelectMenu from "@/components/ui/role-select-menu.tsx";
import {
getUserRoleLabel,
userRoleData,
} from "@/features/workspace/types/user-role-data.ts";
export default function WorkspaceMembersTable() { export default function WorkspaceMembersTable() {
const { data, isLoading } = useWorkspaceMembersQuery(); const { data, isLoading } = useWorkspaceMembersQuery();
const changeMemberRoleMutation = useChangeMemberRoleMutation();
const handleRoleChange = async (
userId: string,
currentRole: string,
newRole: string,
) => {
if (newRole === currentRole) {
return;
}
const memberRoleUpdate = {
userId: userId,
role: newRole,
};
await changeMemberRoleMutation.mutateAsync(memberRoleUpdate);
};
return ( return (
<> <>
@ -39,7 +65,15 @@ export default function WorkspaceMembersTable() {
<Badge variant="light">Active</Badge> <Badge variant="light">Active</Badge>
</Table.Td> </Table.Td>
<Table.Td>{user.role}</Table.Td> <Table.Td>
<RoleSelectMenu
roles={userRoleData}
roleName={getUserRoleLabel(user.role)}
onChange={(newRole) =>
handleRoleChange(user.id, user.role, newRole)
}
/>
</Table.Td>
</Table.Tr> </Table.Tr>
))} ))}
</Table.Tbody> </Table.Tbody>

View File

@ -1,6 +1,10 @@
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getWorkspaceMembers } from "@/features/workspace/services/workspace-service"; import {
changeMemberRole,
getWorkspaceMembers,
} from "@/features/workspace/services/workspace-service";
import { QueryParams } from "@/lib/types.ts"; import { QueryParams } from "@/lib/types.ts";
import { notifications } from "@mantine/notifications";
export function useWorkspaceMembersQuery(params?: QueryParams) { export function useWorkspaceMembersQuery(params?: QueryParams) {
return useQuery({ return useQuery({
@ -8,3 +12,21 @@ export function useWorkspaceMembersQuery(params?: QueryParams) {
queryFn: () => getWorkspaceMembers(params), queryFn: () => getWorkspaceMembers(params),
}); });
} }
export function useChangeMemberRoleMutation() {
const queryClient = useQueryClient();
return useMutation<any, Error, any>({
mutationFn: (data) => changeMemberRole(data),
onSuccess: (data, variables) => {
notifications.show({ message: "Member role updated successfully" });
queryClient.refetchQueries({
queryKey: ["workspaceMembers", variables.spaceId],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}

View File

@ -1,7 +1,7 @@
import api from "@/lib/api-client"; import api from "@/lib/api-client";
import { IUser } from "@/features/user/types/user.types"; import { IUser } from "@/features/user/types/user.types";
import { IWorkspace } from "../types/workspace.types"; import { IWorkspace } from "../types/workspace.types";
import { QueryParams } from "@/lib/types.ts"; import { IPagination, QueryParams } from "@/lib/types.ts";
export async function getWorkspace(): Promise<IWorkspace> { export async function getWorkspace(): Promise<IWorkspace> {
const req = await api.post<IWorkspace>("/workspace/info"); const req = await api.post<IWorkspace>("/workspace/info");
@ -9,8 +9,10 @@ export async function getWorkspace(): Promise<IWorkspace> {
} }
// Todo: fix all paginated types // Todo: fix all paginated types
export async function getWorkspaceMembers(params?: QueryParams): Promise<any> { export async function getWorkspaceMembers(
const req = await api.post<any>("/workspace/members", params); params?: QueryParams,
): Promise<IPagination<IUser>> {
const req = await api.post("/workspace/members", params);
return req.data; return req.data;
} }
@ -19,3 +21,10 @@ export async function updateWorkspace(data: Partial<IWorkspace>) {
return req.data as IWorkspace; return req.data as IWorkspace;
} }
export async function changeMemberRole(data: {
userId: string;
role: string;
}): Promise<void> {
await api.post("/workspace/members/role", data);
}

View File

@ -0,0 +1,24 @@
import { IRoleData, UserRole } from "@/lib/types.ts";
export const userRoleData: IRoleData[] = [
{
label: "Owner",
value: UserRole.OWNER,
description: "Can manage workspace",
},
{
label: "Admin",
value: UserRole.ADMIN,
description: "Can manage workspace but cannot delete it",
},
{
label: "Member",
value: UserRole.MEMBER,
description: "Can become members of groups and spaces in workspace.",
},
];
export function getUserRoleLabel(value: string) {
const role = userRoleData.find((item) => item.value === value);
return role ? role.label : undefined;
}

View File

@ -3,3 +3,32 @@ export interface QueryParams {
page?: number; page?: number;
limit?: number; limit?: number;
} }
export enum UserRole {
OWNER = "owner",
ADMIN = "admin",
MEMBER = "member",
}
export enum SpaceRole {
ADMIN = "admin",
WRITER = "writer",
READER = "reader",
}
export interface IRoleData {
label: string;
value: string;
description: string;
}
export type IPaginationMeta = {
limit: number;
page: number;
hasNextPage: boolean;
hasPrevPage: boolean;
};
export type IPagination<T> = {
items: T[];
meta: IPaginationMeta;
};

View File

@ -0,0 +1,11 @@
import SettingsTitle from "@/components/layouts/settings/settings-title.tsx";
import SpaceList from "@/features/space/components/space-list.tsx";
export default function Spaces() {
return (
<>
<SettingsTitle title="Spaces" />
<SpaceList />
</>
);
}

View File

@ -22,7 +22,10 @@ export class TokenService {
return this.jwtService.sign(payload); return this.jwtService.sign(payload);
} }
async generateRefreshToken(userId: string, workspaceId): Promise<string> { async generateRefreshToken(
userId: string,
workspaceId: string,
): Promise<string> {
const payload: JwtRefreshPayload = { const payload: JwtRefreshPayload = {
sub: userId, sub: userId,
workspaceId, workspaceId,
@ -32,7 +35,7 @@ export class TokenService {
return this.jwtService.sign(payload, { expiresIn }); return this.jwtService.sign(payload, { expiresIn });
} }
async generateTokens(user): Promise<TokensDto> { async generateTokens(user: User): Promise<TokensDto> {
return { return {
accessToken: await this.generateAccessToken(user), accessToken: await this.generateAccessToken(user),
refreshToken: await this.generateRefreshToken(user.id, user.workspaceId), refreshToken: await this.generateRefreshToken(user.id, user.workspaceId),

View File

@ -47,7 +47,7 @@ export default class CaslAbilityFactory {
} }
if (userRole === UserRole.MEMBER) { if (userRole === UserRole.MEMBER) {
// can<any>([Action.Read], WorkspaceUser); can([Action.Read], 'WorkspaceUser');
// Groups // Groups
can([Action.Read], 'Group'); can([Action.Read], 'Group');

View File

@ -0,0 +1,68 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import {
AbilityBuilder,
createMongoAbility,
MongoAbility,
} from '@casl/ability';
import { SpaceRole } from '../../../helpers/types/permission';
import { User } from '@docmost/db/types/entity.types';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import {
SpaceCaslAction,
SpaceAbility,
SpaceCaslSubject,
} from '../interfaces/space-ability.type';
import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils';
@Injectable()
export default class SpaceAbilityFactory {
constructor(private readonly spaceMemberRepo: SpaceMemberRepo) {}
async createForUser(user: User, spaceId: string) {
const userSpaceRoles = await this.spaceMemberRepo.getUserSpaceRoles(
user.id,
spaceId,
);
const userSpaceRole = findHighestUserSpaceRole(userSpaceRoles);
switch (userSpaceRole) {
case SpaceRole.ADMIN:
return buildSpaceAdminAbility();
case SpaceRole.WRITER:
return buildSpaceWriterAbility();
case SpaceRole.READER:
return buildSpaceReaderAbility();
default:
throw new ForbiddenException(
'You do not have permission to access this space',
);
}
}
}
function buildSpaceAdminAbility() {
const { can, build } = new AbilityBuilder<MongoAbility<SpaceAbility>>(
createMongoAbility,
);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Member);
return build();
}
function buildSpaceWriterAbility() {
const { can, build } = new AbilityBuilder<MongoAbility<SpaceAbility>>(
createMongoAbility,
);
can(SpaceCaslAction.Read, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Read, SpaceCaslSubject.Member);
return build();
}
function buildSpaceReaderAbility() {
const { can, build } = new AbilityBuilder<MongoAbility<SpaceAbility>>(
createMongoAbility,
);
can(SpaceCaslAction.Read, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Read, SpaceCaslSubject.Member);
return build();
}

View File

@ -1,9 +1,10 @@
import { Global, Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import CaslAbilityFactory from './abilities/casl-ability.factory'; import CaslAbilityFactory from './abilities/casl-ability.factory';
import SpaceAbilityFactory from './abilities/space-ability.factory';
@Global() @Global()
@Module({ @Module({
providers: [CaslAbilityFactory], providers: [CaslAbilityFactory, SpaceAbilityFactory],
exports: [CaslAbilityFactory], exports: [CaslAbilityFactory, SpaceAbilityFactory],
}) })
export class CaslModule {} export class CaslModule {}

View File

@ -0,0 +1,15 @@
export enum SpaceCaslAction {
Manage = 'manage',
Create = 'create',
Read = 'read',
Edit = 'edit',
Delete = 'delete',
}
export enum SpaceCaslSubject {
Settings = 'settings',
Member = 'member',
}
export type SpaceAbility =
| [SpaceCaslAction, SpaceCaslSubject.Settings]
| [SpaceCaslAction, SpaceCaslSubject.Member];

View File

@ -31,6 +31,8 @@ export class GroupController {
private readonly groupUserService: GroupUserService, private readonly groupUserService: GroupUserService,
) {} ) {}
@UseGuards(PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can(Action.Read, 'Group'))
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('/') @Post('/')
getWorkspaceGroups( getWorkspaceGroups(
@ -62,7 +64,6 @@ export class GroupController {
@AuthUser() user: User, @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
) { ) {
console.log(createGroupDto);
return this.groupService.createGroup(user, workspace.id, createGroupDto); return this.groupService.createGroup(user, workspace.id, createGroupDto);
} }

View File

@ -1,4 +1,4 @@
import { IsNumber, IsOptional, IsString } from 'class-validator'; import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator';
export class SearchDTO { export class SearchDTO {
@IsString() @IsString()
@ -16,3 +16,16 @@ export class SearchDTO {
@IsNumber() @IsNumber()
offset?: number; offset?: number;
} }
export class SearchSuggestionDTO {
@IsString()
query: string;
@IsOptional()
@IsBoolean()
includeUsers?: string;
@IsOptional()
@IsBoolean()
includeGroups?: number;
}

View File

@ -8,7 +8,7 @@ import {
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { SearchService } from './search.service'; import { SearchService } from './search.service';
import { SearchDTO } from './dto/search.dto'; import { SearchDTO, SearchSuggestionDTO } from './dto/search.dto';
import { AuthWorkspace } from '../../decorators/auth-workspace.decorator'; import { AuthWorkspace } from '../../decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../guards/jwt-auth.guard';
import { Workspace } from '@docmost/db/types/entity.types'; import { Workspace } from '@docmost/db/types/entity.types';
@ -21,17 +21,21 @@ export class SearchController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post() @Post()
async pageSearch( async pageSearch(
@Query('type') type: string,
@Body() searchDto: SearchDTO, @Body() searchDto: SearchDTO,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
) { ) {
if (!type || type === 'page') {
return this.searchService.searchPage( return this.searchService.searchPage(
searchDto.query, searchDto.query,
searchDto, searchDto,
workspace.id, workspace.id,
); );
} }
return;
@Post('suggest')
async searchSuggestions(
@Body() dto: SearchSuggestionDTO,
@AuthWorkspace() workspace: Workspace,
) {
return this.searchService.searchSuggestions(dto, workspace.id);
} }
} }

View File

@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { SearchDTO } from './dto/search.dto'; import { SearchDTO, SearchSuggestionDTO } from './dto/search.dto';
import { SearchResponseDto } from './dto/search-response.dto'; import { SearchResponseDto } from './dto/search-response.dto';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types'; import { KyselyDB } from '@docmost/db/types/kysely.types';
@ -57,4 +57,38 @@ export class SearchService {
return searchResults; return searchResults;
} }
async searchSuggestions(
suggestion: SearchSuggestionDTO,
workspaceId: string,
) {
const limit = 25;
const userSearch = this.db
.selectFrom('users')
.select(['id', 'name', 'avatarUrl'])
.where((eb) => eb('users.name', 'ilike', `%${suggestion.query}%`))
.where('workspaceId', '=', workspaceId)
.limit(limit);
const groupSearch = this.db
.selectFrom('groups')
.select(['id', 'name', 'description'])
.where((eb) => eb('groups.name', 'ilike', `%${suggestion.query}%`))
.where('workspaceId', '=', workspaceId)
.limit(limit);
let users = [];
let groups = [];
if (suggestion.includeUsers) {
users = await userSearch.execute();
}
if (suggestion.includeGroups) {
groups = await groupSearch.execute();
}
return { users, groups };
}
} }

View File

@ -0,0 +1,31 @@
import {
ArrayMaxSize,
IsArray,
IsEnum,
IsUUID,
} from 'class-validator';
import { SpaceIdDto } from './space-id.dto';
import { SpaceRole } from '../../../helpers/types/permission';
export class AddSpaceMembersDto extends SpaceIdDto {
// @IsOptional()
// @IsUUID()
// userId: string;
@IsEnum(SpaceRole)
role: string;
@IsArray()
@ArrayMaxSize(25, {
message: 'userIds must an array with no more than 25 elements',
})
@IsUUID(4, { each: true })
userIds: string[];
@IsArray()
@ArrayMaxSize(25, {
message: 'userIds must an array with no more than 25 elements',
})
@IsUUID(4, { each: true })
groupIds: string[];
}

View File

@ -0,0 +1,14 @@
import { IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
import { SpaceIdDto } from './space-id.dto';
export class RemoveSpaceMemberDto extends SpaceIdDto {
@IsOptional()
@IsNotEmpty()
@IsUUID()
userId: string;
@IsOptional()
@IsNotEmpty()
@IsUUID()
groupId: string;
}

View File

@ -0,0 +1,18 @@
import { IsEnum, IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
import { SpaceIdDto } from './space-id.dto';
import { SpaceRole } from '../../../helpers/types/permission';
export class UpdateSpaceMemberRoleDto extends SpaceIdDto {
@IsOptional()
@IsNotEmpty()
@IsUUID()
userId: string;
@IsOptional()
@IsNotEmpty()
@IsUUID()
groupId: string;
@IsEnum(SpaceRole)
role: string;
}

View File

@ -1,4 +1,10 @@
import { PartialType } from '@nestjs/mapped-types'; import { PartialType } from '@nestjs/mapped-types';
import { CreateSpaceDto } from './create-space.dto'; import { CreateSpaceDto } from './create-space.dto';
import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
export class UpdateSpaceDto extends PartialType(CreateSpaceDto) {} export class UpdateSpaceDto extends PartialType(CreateSpaceDto) {
@IsString()
@IsNotEmpty()
@IsUUID()
spaceId: string;
}

View File

@ -1,12 +1,26 @@
import { Injectable } from '@nestjs/common'; import {
import { PaginationOptions } from '../../../kysely/pagination/pagination-options'; BadRequestException,
import { KyselyTransaction } from '@docmost/db/types/kysely.types'; Injectable,
NotFoundException,
} from '@nestjs/common';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { SpaceMember } from '@docmost/db/types/entity.types'; import { AddSpaceMembersDto } from '../dto/add-space-members.dto';
import { InjectKysely } from 'nestjs-kysely';
import { SpaceMember, User } from '@docmost/db/types/entity.types';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { RemoveSpaceMemberDto } from '../dto/remove-space-member.dto';
import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto';
import { SpaceRole } from '../../../helpers/types/permission';
@Injectable() @Injectable()
export class SpaceMemberService { export class SpaceMemberService {
constructor(private spaceMemberRepo: SpaceMemberRepo) {} constructor(
private spaceMemberRepo: SpaceMemberRepo,
private spaceRepo: SpaceRepo,
@InjectKysely() private readonly db: KyselyDB,
) {}
async addUserToSpace( async addUserToSpace(
userId: string, userId: string,
@ -14,11 +28,11 @@ export class SpaceMemberService {
role: string, role: string,
workspaceId: string, workspaceId: string,
trx?: KyselyTransaction, trx?: KyselyTransaction,
): Promise<SpaceMember> { ): Promise<void> {
//if (existingSpaceUser) { //if (existingSpaceUser) {
// throw new BadRequestException('User already added to this space'); // throw new BadRequestException('User already added to this space');
// } // }
return await this.spaceMemberRepo.insertSpaceMember( await this.spaceMemberRepo.insertSpaceMember(
{ {
userId: userId, userId: userId,
spaceId: spaceId, spaceId: spaceId,
@ -34,13 +48,13 @@ export class SpaceMemberService {
role: string, role: string,
workspaceId: string, workspaceId: string,
trx?: KyselyTransaction, trx?: KyselyTransaction,
): Promise<SpaceMember> { ): Promise<void> {
//const existingSpaceUser = await manager.findOneBy(SpaceMember, { //const existingSpaceUser = await manager.findOneBy(SpaceMember, {
// userId: userId, // userId: userId,
// spaceId: spaceId, // spaceId: spaceId,
// }); // });
// validations? // validations?
return await this.spaceMemberRepo.insertSpaceMember( await this.spaceMemberRepo.insertSpaceMember(
{ {
groupId: groupId, groupId: groupId,
spaceId: spaceId, spaceId: spaceId,
@ -59,7 +73,11 @@ export class SpaceMemberService {
workspaceId: string, workspaceId: string,
pagination: PaginationOptions, pagination: PaginationOptions,
) { ) {
//todo: validate the space is inside the workspace const space = await this.spaceRepo.findById(spaceId, workspaceId);
if (!space) {
throw new NotFoundException('Space not found');
}
const members = await this.spaceMemberRepo.getSpaceMembersPaginated( const members = await this.spaceMemberRepo.getSpaceMembersPaginated(
spaceId, spaceId,
pagination, pagination,
@ -67,35 +85,197 @@ export class SpaceMemberService {
return members; return members;
} }
}
/* async addMembersToSpaceBatch(
* get spaces a user is a member of dto: AddSpaceMembersDto,
* either by direct membership or via groups authUser: User,
*/
/*
async getUserSpaces(
userId: string,
workspaceId: string, workspaceId: string,
paginationOptions: PaginationOptions, ): Promise<void> {
) { // await this.spaceService.findAndValidateSpace(spaceId, workspaceId);
const [userSpaces, count] = await this.spaceMemberRepository
.createQueryBuilder('spaceMember')
.leftJoinAndSelect('spaceMember.space', 'space')
.where('spaceMember.userId = :userId', { userId })
.andWhere('space.workspaceId = :workspaceId', { workspaceId })
.loadRelationCountAndMap(
'space.memberCount',
'space.spaceMembers',
'spaceMembers',
)
.take(paginationOptions.limit)
.skip(paginationOptions.skip)
.getManyAndCount();
const spaces = userSpaces.map((userSpace) => userSpace.space); const space = await this.spaceRepo.findById(dto.spaceId, workspaceId);
if (!space) {
const paginationMeta = new PaginationMetaDto({ count, paginationOptions }); throw new NotFoundException('Space not found');
return new PaginatedResult(spaces, paginationMeta); }
// make sure we have valid workspace users
const validUsersQuery = this.db
.selectFrom('users')
.select(['id', 'name'])
.where('users.id', 'in', dto.userIds)
.where('users.workspaceId', '=', workspaceId)
// using this because we can not use easily use onConflict with two unique indexes.
.where(({ not, exists, selectFrom }) =>
not(
exists(
selectFrom('spaceMembers')
.select('id')
.whereRef('spaceMembers.userId', '=', 'users.id')
.where('spaceMembers.spaceId', '=', dto.spaceId),
),
),
);
const validGroupsQuery = this.db
.selectFrom('groups')
.select(['id', 'name'])
.where('groups.id', 'in', dto.groupIds)
.where('groups.workspaceId', '=', workspaceId)
.where(({ not, exists, selectFrom }) =>
not(
exists(
selectFrom('spaceMembers')
.select('id')
.whereRef('spaceMembers.groupId', '=', 'groups.id')
.where('spaceMembers.spaceId', '=', dto.spaceId),
),
),
);
let validUsers = [],
validGroups = [];
if (dto.userIds && dto.userIds.length > 0) {
validUsers = await validUsersQuery.execute();
}
if (dto.groupIds && dto.groupIds.length > 0) {
validGroups = await validGroupsQuery.execute();
}
const usersToAdd = [];
for (const user of validUsers) {
usersToAdd.push({
spaceId: dto.spaceId,
userId: user.id,
role: dto.role,
creatorId: authUser.id,
});
}
const groupsToAdd = [];
for (const group of validGroups) {
groupsToAdd.push({
spaceId: dto.spaceId,
groupId: group.id,
role: dto.role,
creatorId: authUser.id,
});
}
const membersToAdd = [...usersToAdd, ...groupsToAdd];
if (membersToAdd.length > 0) {
await this.spaceMemberRepo.insertSpaceMember(membersToAdd);
} else {
// either they are already members or do not exist on the workspace
}
}
async removeMemberFromSpace(
dto: RemoveSpaceMemberDto,
authUser: User, // Todo: permissions check
workspaceId: string,
): Promise<void> {
const space = await this.spaceRepo.findById(dto.spaceId, workspaceId);
if (!space) {
throw new NotFoundException('Space not found');
}
let spaceMember: SpaceMember = null;
if (dto.userId) {
spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
dto.spaceId,
{
userId: dto.userId,
},
);
} else if (dto.groupId) {
spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
dto.spaceId,
{
groupId: dto.groupId,
},
);
} else {
throw new BadRequestException(
'Please provide a valid userId or groupId to remove',
);
}
if (!spaceMember) {
throw new NotFoundException('Space membership not found');
}
if (spaceMember.role === SpaceRole.ADMIN) {
await this.validateLastAdmin(dto.spaceId);
}
await this.spaceMemberRepo.removeSpaceMemberById(
spaceMember.id,
dto.spaceId,
);
}
async updateSpaceMemberRole(
dto: UpdateSpaceMemberRoleDto,
authUser: User,
workspaceId: string,
): Promise<void> {
const space = await this.spaceRepo.findById(dto.spaceId, workspaceId);
if (!space) {
throw new NotFoundException('Space not found');
}
let spaceMember: SpaceMember = null;
if (dto.userId) {
spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
dto.spaceId,
{
userId: dto.userId,
},
);
} else if (dto.groupId) {
spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
dto.spaceId,
{
groupId: dto.groupId,
},
);
} else {
throw new BadRequestException(
'Please provide a valid userId or groupId to remove',
);
}
if (!spaceMember) {
throw new NotFoundException('Space membership not found');
}
if (spaceMember.role === dto.role) {
return;
}
if (spaceMember.role === SpaceRole.ADMIN) {
await this.validateLastAdmin(dto.spaceId);
}
await this.spaceMemberRepo.updateSpaceMember(
{ role: dto.role },
spaceMember.id,
dto.spaceId,
);
}
async validateLastAdmin(spaceId: string): Promise<void> {
const spaceOwnerCount = await this.spaceMemberRepo.roleCountBySpaceId(
SpaceRole.ADMIN,
spaceId,
);
if (spaceOwnerCount === 1) {
throw new BadRequestException(
'There must be at least one space admin with full access',
);
}
}
} }
*/

View File

@ -4,12 +4,13 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { CreateSpaceDto } from '../dto/create-space.dto'; import { CreateSpaceDto } from '../dto/create-space.dto';
import { PaginationOptions } from '../../../kysely/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import slugify from 'slugify'; import slugify from 'slugify';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo'; import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { KyselyTransaction } from '@docmost/db/types/kysely.types'; import { KyselyTransaction } from '@docmost/db/types/kysely.types';
import { Space } from '@docmost/db/types/entity.types'; import { Space } from '@docmost/db/types/entity.types';
import { PaginationResult } from '@docmost/db/pagination/pagination'; import { PaginationResult } from '@docmost/db/pagination/pagination';
import { UpdateSpaceDto } from '../dto/update-space.dto';
@Injectable() @Injectable()
export class SpaceService { export class SpaceService {
@ -44,8 +45,28 @@ export class SpaceService {
); );
} }
async updateSpace(
updateSpaceDto: UpdateSpaceDto,
workspaceId: string,
): Promise<Space> {
if (!updateSpaceDto.name && !updateSpaceDto.description) {
throw new BadRequestException('Please provide fields to update');
}
return await this.spaceRepo.updateSpace(
{
name: updateSpaceDto.name,
description: updateSpaceDto.description,
},
updateSpaceDto.spaceId,
workspaceId,
);
}
async getSpaceInfo(spaceId: string, workspaceId: string): Promise<Space> { async getSpaceInfo(spaceId: string, workspaceId: string): Promise<Space> {
const space = await this.spaceRepo.findById(spaceId, workspaceId); const space = await this.spaceRepo.findById(spaceId, workspaceId, {
includeMemberCount: true,
});
if (!space) { if (!space) {
throw new NotFoundException('Space not found'); throw new NotFoundException('Space not found');
} }

View File

@ -1,6 +1,8 @@
import { import {
BadRequestException,
Body, Body,
Controller, Controller,
ForbiddenException,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Post, Post,
@ -11,9 +13,18 @@ import { AuthUser } from '../../decorators/auth-user.decorator';
import { AuthWorkspace } from '../../decorators/auth-workspace.decorator'; import { AuthWorkspace } from '../../decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../guards/jwt-auth.guard';
import { SpaceIdDto } from './dto/space-id.dto'; import { SpaceIdDto } from './dto/space-id.dto';
import { PaginationOptions } from '../../kysely/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { SpaceMemberService } from './services/space-member.service'; import { SpaceMemberService } from './services/space-member.service';
import { User, Workspace } from '@docmost/db/types/entity.types'; import { User, Workspace } from '@docmost/db/types/entity.types';
import { AddSpaceMembersDto } from './dto/add-space-members.dto';
import { RemoveSpaceMemberDto } from './dto/remove-space-member.dto';
import { UpdateSpaceMemberRoleDto } from './dto/update-space-member-role.dto';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type';
import { UpdateSpaceDto } from './dto/update-space.dto';
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('spaces') @Controller('spaces')
@ -21,6 +32,7 @@ export class SpaceController {
constructor( constructor(
private readonly spaceService: SpaceService, private readonly spaceService: SpaceService,
private readonly spaceMemberService: SpaceMemberService, private readonly spaceMemberService: SpaceMemberService,
private readonly spaceAbility: SpaceAbilityFactory,
) {} ) {}
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ -35,23 +47,6 @@ export class SpaceController {
return this.spaceService.getWorkspaceSpaces(workspace.id, pagination); return this.spaceService.getWorkspaceSpaces(workspace.id, pagination);
} }
// get all spaces user is a member of
/*
@HttpCode(HttpStatus.OK)
@Post('user')
async getUserSpaces(
@Body()
pagination: PaginationOptions,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
return this.spaceMemberService.getUserSpaces(
user.id,
workspace.id,
pagination,
);
}*/
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('info') @Post('info')
async getSpaceInfo( async getSpaceInfo(
@ -59,23 +54,135 @@ export class SpaceController {
@AuthUser() user: User, @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
) { ) {
const ability = await this.spaceAbility.createForUser(
user,
spaceIdDto.spaceId,
);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Settings)) {
throw new ForbiddenException();
}
return this.spaceService.getSpaceInfo(spaceIdDto.spaceId, workspace.id); return this.spaceService.getSpaceInfo(spaceIdDto.spaceId, workspace.id);
} }
@HttpCode(HttpStatus.OK)
@Post('update')
async updateGroup(
@Body() updateSpaceDto: UpdateSpaceDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const ability = await this.spaceAbility.createForUser(
user,
updateSpaceDto.spaceId,
);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
throw new ForbiddenException();
}
return this.spaceService.updateSpace(updateSpaceDto, workspace.id);
}
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('members') @Post('members')
async getSpaceMembers( async getSpaceMembers(
// todo: accept type? users | groups
@Body() spaceIdDto: SpaceIdDto, @Body() spaceIdDto: SpaceIdDto,
@Body() @Body()
pagination: PaginationOptions, pagination: PaginationOptions,
@AuthUser() user: User, @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
) { ) {
const ability = await this.spaceAbility.createForUser(
user,
spaceIdDto.spaceId,
);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Member)) {
throw new ForbiddenException();
}
return this.spaceMemberService.getSpaceMembers( return this.spaceMemberService.getSpaceMembers(
spaceIdDto.spaceId, spaceIdDto.spaceId,
workspace.id, workspace.id,
pagination, pagination,
); );
} }
@HttpCode(HttpStatus.OK)
@Post('members/add')
async addSpaceMember(
@Body() dto: AddSpaceMembersDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
if (
(!dto.userIds || dto.userIds.length === 0) &&
(!dto.groupIds || dto.groupIds.length === 0)
) {
throw new BadRequestException('userIds or groupIds is required');
}
const ability = await this.spaceAbility.createForUser(user, dto.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Member)) {
throw new ForbiddenException();
}
return this.spaceMemberService.addMembersToSpaceBatch(
dto,
user,
workspace.id,
);
}
@HttpCode(HttpStatus.OK)
@Post('members/remove')
async removeSpaceMember(
@Body() dto: RemoveSpaceMemberDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
this.validateIds(dto);
const ability = await this.spaceAbility.createForUser(user, dto.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Member)) {
throw new ForbiddenException();
}
return this.spaceMemberService.removeMemberFromSpace(
dto,
user,
workspace.id,
);
}
@HttpCode(HttpStatus.OK)
@Post('members/role')
async updateSpaceMemberRole(
@Body() dto: UpdateSpaceMemberRoleDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
this.validateIds(dto);
const ability = await this.spaceAbility.createForUser(user, dto.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Member)) {
throw new ForbiddenException();
}
return this.spaceMemberService.updateSpaceMemberRole(
dto,
user,
workspace.id,
);
}
validateIds(dto: RemoveSpaceMemberDto | UpdateSpaceMemberRoleDto) {
if (!dto.userId && !dto.groupId) {
throw new BadRequestException('userId or groupId is required');
}
if (dto.userId && dto.groupId) {
throw new BadRequestException(
'please provide either a userId or groupId and both',
);
}
}
} }

View File

@ -1,8 +1,10 @@
import { PartialType } from '@nestjs/mapped-types'; import { OmitType, PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from '../../auth/dto/create-user.dto'; import { CreateUserDto } from '../../auth/dto/create-user.dto';
import { IsOptional, IsString } from 'class-validator'; import { IsOptional, IsString } from 'class-validator';
export class UpdateUserDto extends PartialType(CreateUserDto) { export class UpdateUserDto extends PartialType(
OmitType(CreateUserDto, ['password'] as const),
) {
@IsOptional() @IsOptional()
@IsString() @IsString()
avatarUrl: string; avatarUrl: string;

View File

@ -5,7 +5,6 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { UpdateUserDto } from './dto/update-user.dto'; import { UpdateUserDto } from './dto/update-user.dto';
import { UserRepo } from '@docmost/db/repos/user/user.repo'; import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { hashPassword } from '../../helpers/utils';
@Injectable() @Injectable()
export class UserService { export class UserService {
@ -29,7 +28,6 @@ export class UserService {
user.name = updateUserDto.name; user.name = updateUserDto.name;
} }
// todo need workspace scoping
if (updateUserDto.email && user.email != updateUserDto.email) { if (updateUserDto.email && user.email != updateUserDto.email) {
if (await this.userRepo.findByEmail(updateUserDto.email, workspaceId)) { if (await this.userRepo.findByEmail(updateUserDto.email, workspaceId)) {
throw new BadRequestException('A user with this email already exists'); throw new BadRequestException('A user with this email already exists');
@ -41,10 +39,6 @@ export class UserService {
user.avatarUrl = updateUserDto.avatarUrl; user.avatarUrl = updateUserDto.avatarUrl;
} }
if (updateUserDto.password) {
updateUserDto.password = await hashPassword(updateUserDto.password);
}
await this.userRepo.updateUser(updateUserDto, userId, workspaceId); await this.userRepo.updateUser(updateUserDto, userId, workspaceId);
return user; return user;
} }

View File

@ -100,7 +100,7 @@ export class WorkspaceService {
await this.spaceMemberService.addUserToSpace( await this.spaceMemberService.addUserToSpace(
user.id, user.id,
createdSpace.id, createdSpace.id,
SpaceRole.OWNER, SpaceRole.ADMIN,
workspace.id, workspace.id,
trx, trx,
); );

View File

@ -5,12 +5,12 @@ export enum UserRole {
} }
export enum SpaceRole { export enum SpaceRole {
OWNER = 'owner', // can add members, remove, and delete space ADMIN = 'admin', // can manage space settings, members, and delete space
WRITER = 'writer', // can read and write pages in space WRITER = 'writer', // can read and write pages in space
READER = 'reader', // can only read pages in space READER = 'reader', // can only read pages in space
} }
export enum SpaceVisibility { export enum SpaceVisibility {
OPEN = 'open', // any workspace member can see and join. OPEN = 'open', // any workspace member can see that it exists and join.
PRIVATE = 'private', // only added space users can see PRIVATE = 'private', // only added space users can see
} }

View File

@ -49,7 +49,7 @@ export async function up(db: Kysely<any>): Promise<void> {
col.references('spaces.id').onDelete('cascade').notNull(), col.references('spaces.id').onDelete('cascade').notNull(),
) )
.addColumn('role', 'varchar', (col) => col.notNull()) .addColumn('role', 'varchar', (col) => col.notNull())
.addColumn('creator_id', 'uuid', (col) => col.references('users.id')) .addColumn('addedById', 'uuid', (col) => col.references('users.id'))
.addColumn('created_at', 'timestamptz', (col) => .addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`), col.notNull().defaultTo(sql`now()`),
) )

View File

@ -1,30 +1,94 @@
import { Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx } from '@docmost/db/utils'; import { dbOrTx } from '@docmost/db/utils';
import { import {
InsertableSpaceMember, InsertableSpaceMember,
SpaceMember, SpaceMember,
UpdatableSpaceMember,
} from '@docmost/db/types/entity.types'; } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '../../pagination/pagination-options'; import { PaginationOptions } from '../../pagination/pagination-options';
import { MemberInfo } from './types'; import { MemberInfo, UserSpaceRole } from './types';
import { sql } from 'kysely';
import { executeWithPagination } from '@docmost/db/pagination/pagination'; import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
@Injectable() @Injectable()
export class SpaceMemberRepo { export class SpaceMemberRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {} constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly groupRepo: GroupRepo,
) {}
async insertSpaceMember( async insertSpaceMember(
insertableSpaceMember: InsertableSpaceMember, insertableSpaceMember: InsertableSpaceMember,
trx?: KyselyTransaction, trx?: KyselyTransaction,
): Promise<SpaceMember> { ): Promise<void> {
const db = dbOrTx(this.db, trx); const db = dbOrTx(this.db, trx);
return db await db
.insertInto('spaceMembers') .insertInto('spaceMembers')
.values(insertableSpaceMember) .values(insertableSpaceMember)
.returningAll() .returningAll()
.execute();
}
async updateSpaceMember(
updatableSpaceMember: UpdatableSpaceMember,
spaceMemberId: string,
spaceId: string,
): Promise<void> {
await this.db
.updateTable('spaceMembers')
.set(updatableSpaceMember)
.where('id', '=', spaceMemberId)
.where('spaceId', '=', spaceId)
.execute();
}
async getSpaceMemberByTypeId(
spaceId: string,
opts: {
userId?: string;
groupId?: string;
},
trx?: KyselyTransaction,
): Promise<SpaceMember> {
const db = dbOrTx(this.db, trx);
let query = db
.selectFrom('spaceMembers')
.selectAll()
.where('spaceId', '=', spaceId);
if (opts.userId) {
query = query.where('userId', '=', opts.userId);
} else if (opts.groupId) {
query = query.where('groupId', '=', opts.groupId);
} else {
throw new BadRequestException('Please provider a userId or groupId');
}
return query.executeTakeFirst();
}
async removeSpaceMemberById(
memberId: string,
spaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('spaceMembers')
.where('id', '=', memberId)
.where('spaceId', '=', spaceId)
.execute();
}
async roleCountBySpaceId(role: string, spaceId: string): Promise<number> {
const { count } = await this.db
.selectFrom('spaceMembers')
.select((eb) => eb.fn.count('role').as('count'))
.where('role', '=', role)
.where('spaceId', '=', spaceId)
.executeTakeFirst(); .executeTakeFirst();
return count as number;
} }
async getSpaceMembersPaginated( async getSpaceMembersPaginated(
@ -36,15 +100,17 @@ export class SpaceMemberRepo {
.leftJoin('users', 'users.id', 'spaceMembers.userId') .leftJoin('users', 'users.id', 'spaceMembers.userId')
.leftJoin('groups', 'groups.id', 'spaceMembers.groupId') .leftJoin('groups', 'groups.id', 'spaceMembers.groupId')
.select([ .select([
'groups.id as groupId',
'groups.name as groupName',
'groups.isDefault as groupIsDefault',
'users.id as userId', 'users.id as userId',
'users.name as userName', 'users.name as userName',
'users.avatarUrl as userAvatarUrl', 'users.avatarUrl as userAvatarUrl',
'users.email as userEmail', 'users.email as userEmail',
'groups.id as groupId',
'groups.name as groupName',
'groups.isDefault as groupIsDefault',
'spaceMembers.role', 'spaceMembers.role',
'spaceMembers.createdAt',
]) ])
.select((eb) => this.groupRepo.withMemberCount(eb))
.where('spaceId', '=', spaceId) .where('spaceId', '=', spaceId)
.orderBy('spaceMembers.createdAt', 'asc'); .orderBy('spaceMembers.createdAt', 'asc');
@ -65,10 +131,10 @@ export class SpaceMemberRepo {
type: 'user', type: 'user',
}; };
} else if (member.groupId) { } else if (member.groupId) {
// todo: get group member count
memberInfo = { memberInfo = {
id: member.groupId, id: member.groupId,
name: member.groupName, name: member.groupName,
memberCount: member.memberCount as number,
isDefault: member.groupIsDefault, isDefault: member.groupIsDefault,
type: 'group', type: 'group',
}; };
@ -77,200 +143,45 @@ export class SpaceMemberRepo {
return { return {
...memberInfo, ...memberInfo,
role: member.role, role: member.role,
createdAt: member.createdAt,
}; };
}); });
return members; result.items = members as any;
}
/* return result;
* we want to get all the spaces a user belongs either directly or via a group
* we will pass the user id and workspace id as parameters
* if the user is a member of the space via multiple groups
* we will return the one with the highest role permission
* it should return an array
* Todo: needs more work. this is a draft
*/
async getUserSpaces(userId: string, workspaceId: string) {
const rolePriority = sql`CASE "spaceMembers"."role"
WHEN 'owner' THEN 3
WHEN 'writer' THEN 2
WHEN 'reader' THEN 1
END`.as('role_priority');
const subquery = this.db
.selectFrom('spaces')
.innerJoin('spaceMembers', 'spaces.id', 'spaceMembers.spaceId')
.select([
'spaces.id',
'spaces.name',
'spaces.slug',
'spaces.icon',
'spaceMembers.role',
rolePriority,
])
.where('spaceMembers.userId', '=', userId)
.where('spaces.workspaceId', '=', workspaceId)
.unionAll(
this.db
.selectFrom('spaces')
.innerJoin('spaceMembers', 'spaces.id', 'spaceMembers.spaceId')
.innerJoin('groupUsers', 'spaceMembers.groupId', 'groupUsers.groupId')
.select([
'spaces.id',
'spaces.name',
'spaces.slug',
'spaces.icon',
'spaceMembers.role',
rolePriority,
])
.where('groupUsers.userId', '=', userId),
)
.as('membership');
const results = await this.db
.selectFrom(subquery)
.select([
'membership.id as space_id',
'membership.name as space_name',
'membership.slug as space_slug',
sql`MAX('role_priority')`.as('max_role_priority'),
sql`CASE MAX("role_priority")
WHEN 3 THEN 'owner'
WHEN 2 THEN 'writer'
WHEN 1 THEN 'reader'
END`.as('highest_role'),
])
.groupBy('membership.id')
.groupBy('membership.name')
.groupBy('membership.slug')
.execute();
let membership = {};
const spaces = results.map((result) => {
membership = {
id: result.space_id,
name: result.space_name,
role: result.highest_role,
};
return membership;
});
return spaces;
} }
/* /*
* we want to get a user's role in a space. * we want to get a user's role in a space.
* they user can be a member either directly or via a group * they user can be a member either directly or via a group
* we will pass the user id and space id and workspaceId to return the user's role * we will pass the user id and space id to return the user's roles
* if the user is a member of the space via multiple groups * if the user is a member of the space via multiple groups
* we will return the one with the highest role permission * if the user has no space permission it should return an empty array,
* It returns the space id, space name, user role * maybe we should throw an exception?
* and how the role was derived 'via'
* if the user has no space permission (not a member) it returns undefined
*/ */
async getUserRoleInSpace( async getUserSpaceRoles(
userId: string, userId: string,
spaceId: string, spaceId: string,
workspaceId: string, ): Promise<UserSpaceRole[]> {
) { const roles = await this.db
const rolePriority = sql`CASE "spaceMembers"."role" .selectFrom('spaceMembers')
WHEN 'owner' THEN 3 .select(['userId', 'role'])
WHEN 'writer' THEN 2 .where('userId', '=', userId)
WHEN 'reader' THEN 1 .where('spaceId', '=', spaceId)
END`.as('role_priority');
const subquery = this.db
.selectFrom('spaces')
.innerJoin('spaceMembers', 'spaces.id', 'spaceMembers.spaceId')
.select([
'spaces.id',
'spaces.name',
'spaceMembers.role',
'spaceMembers.userId',
rolePriority,
])
.where('spaceMembers.userId', '=', userId)
.where('spaces.id', '=', spaceId)
.where('spaces.workspaceId', '=', workspaceId)
.unionAll( .unionAll(
this.db this.db
.selectFrom('spaces') .selectFrom('spaceMembers')
.innerJoin('spaceMembers', 'spaces.id', 'spaceMembers.spaceId') .innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
.innerJoin('groupUsers', 'spaceMembers.groupId', 'groupUsers.groupId') .select(['groupUsers.userId', 'spaceMembers.role'])
.select([ .where('groupUsers.userId', '=', userId)
'spaces.id', .where('spaceMembers.spaceId', '=', spaceId),
'spaces.name',
'spaceMembers.role',
'spaceMembers.userId',
rolePriority,
])
.where('spaces.id', '=', spaceId)
.where('spaces.workspaceId', '=', workspaceId)
.where('groupUsers.userId', '=', userId),
) )
.as('membership'); .execute();
const result = await this.db if (roles.length < 1) {
.selectFrom(subquery)
.select([
'membership.id as space_id',
'membership.name as space_name',
'membership.userId as user_id',
sql`MAX('role_priority')`.as('max_role_priority'),
sql`CASE MAX("role_priority")
WHEN 3 THEN 'owner'
WHEN 2 THEN 'writer'
WHEN 1 THEN 'reader'
END`.as('highest_role'),
])
.groupBy('membership.id')
.groupBy('membership.name')
.groupBy('membership.userId')
.executeTakeFirst();
let membership = {};
if (result) {
membership = {
id: result.space_id,
name: result.space_name,
role: result.highest_role,
via: result.user_id ? 'user' : 'group', // user_id is empty then role was derived via a group
};
return membership;
}
return undefined; return undefined;
} }
return roles;
async getSpaceMemberById(
userId: string,
groupId: string,
trx?: KyselyTransaction,
) {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('spaceMembers')
.selectAll()
.where('userId', '=', userId)
.where('groupId', '=', groupId)
.executeTakeFirst();
}
async removeUser(userId: string, spaceId: string): Promise<void> {
await this.db
.deleteFrom('spaceMembers')
.where('userId', '=', userId)
.where('spaceId', '=', spaceId)
.execute();
}
async removeGroup(groupId: string, spaceId: string): Promise<void> {
await this.db
.deleteFrom('spaceMembers')
.where('userId', '=', groupId)
.where('spaceId', '=', spaceId)
.execute();
} }
} }

View File

@ -16,34 +16,29 @@ import { DB } from '@docmost/db/types/db';
export class SpaceRepo { export class SpaceRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {} constructor(@InjectKysely() private readonly db: KyselyDB) {}
private baseFields: Array<keyof Space> = [ async findById(
'id', spaceId: string,
'name', workspaceId: string,
'description', opts?: { includeMemberCount: boolean },
'slug', ): Promise<Space> {
'icon',
'visibility',
'defaultRole',
'workspaceId',
'creatorId',
'createdAt',
'updatedAt',
'deletedAt',
];
async findById(spaceId: string, workspaceId: string): Promise<Space> {
return await this.db return await this.db
.selectFrom('spaces') .selectFrom('spaces')
.select((eb) => [...this.baseFields, this.countSpaceMembers(eb)]) .selectAll('spaces')
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
.where('id', '=', spaceId) .where('id', '=', spaceId)
.where('workspaceId', '=', workspaceId) .where('workspaceId', '=', workspaceId)
.executeTakeFirst(); .executeTakeFirst();
} }
async findBySlug(slug: string, workspaceId: string): Promise<Space> { async findBySlug(
slug: string,
workspaceId: string,
opts?: { includeMemberCount: boolean },
): Promise<Space> {
return await this.db return await this.db
.selectFrom('spaces') .selectFrom('spaces')
.select((eb) => [...this.baseFields, this.countSpaceMembers(eb)]) .selectAll('spaces')
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
.where(sql`LOWER(slug)`, '=', sql`LOWER(${slug})`) .where(sql`LOWER(slug)`, '=', sql`LOWER(${slug})`)
.where('workspaceId', '=', workspaceId) .where('workspaceId', '=', workspaceId)
.executeTakeFirst(); .executeTakeFirst();
@ -62,7 +57,7 @@ export class SpaceRepo {
.where('workspaceId', '=', workspaceId) .where('workspaceId', '=', workspaceId)
.executeTakeFirst(); .executeTakeFirst();
count = count as number; count = count as number;
return count == 0 ? false : true; return count != 0;
} }
async updateSpace( async updateSpace(
@ -77,7 +72,8 @@ export class SpaceRepo {
.set(updatableSpace) .set(updatableSpace)
.where('id', '=', spaceId) .where('id', '=', spaceId)
.where('workspaceId', '=', workspaceId) .where('workspaceId', '=', workspaceId)
.execute(); .returningAll()
.executeTakeFirst();
} }
async insertSpace( async insertSpace(
@ -99,7 +95,8 @@ export class SpaceRepo {
// todo: show spaces user have access based on visibility and memberships // todo: show spaces user have access based on visibility and memberships
let query = this.db let query = this.db
.selectFrom('spaces') .selectFrom('spaces')
.select((eb) => [...this.baseFields, this.countSpaceMembers(eb)]) .selectAll('spaces')
.select((eb) => [this.withMemberCount(eb)])
.where('workspaceId', '=', workspaceId) .where('workspaceId', '=', workspaceId)
.orderBy('createdAt', 'asc'); .orderBy('createdAt', 'asc');
@ -121,11 +118,17 @@ export class SpaceRepo {
return result; return result;
} }
countSpaceMembers(eb: ExpressionBuilder<DB, 'spaces'>) { withMemberCount(eb: ExpressionBuilder<DB, 'spaces'>) {
// should get unique members via groups?
return eb return eb
.selectFrom('spaceMembers') .selectFrom('spaceMembers')
.select((eb) => eb.fn.countAll().as('count')) .innerJoin('groups', 'groups.id', 'spaceMembers.groupId')
.innerJoin('groupUsers', 'groupUsers.groupId', 'groups.id')
.select((eb) =>
eb.fn
.count(sql`concat(space_members.user_id, group_users.user_id)`)
.distinct()
.as('count'),
)
.whereRef('spaceMembers.spaceId', '=', 'spaces.id') .whereRef('spaceMembers.spaceId', '=', 'spaces.id')
.as('memberCount'); .as('memberCount');
} }

View File

@ -1,3 +1,8 @@
export interface UserSpaceRole {
userId: string;
role: string;
}
interface SpaceUserInfo { interface SpaceUserInfo {
id: string; id: string;
name: string; name: string;
@ -10,7 +15,7 @@ interface SpaceGroupInfo {
id: string; id: string;
name: string; name: string;
isDefault: boolean; isDefault: boolean;
memberCount?: number; memberCount: number;
type: 'group'; type: 'group';
} }

View File

@ -0,0 +1,23 @@
import { UserSpaceRole } from '@docmost/db/repos/space/types';
import { SpaceRole } from '../../../helpers/types/permission';
export function findHighestUserSpaceRole(userSpaceRoles: UserSpaceRole[]) {
if (!userSpaceRoles) {
return undefined;
}
const roleOrder: { [key in SpaceRole]: number } = {
[SpaceRole.ADMIN]: 3,
[SpaceRole.WRITER]: 2,
[SpaceRole.READER]: 1,
};
let highestRole: string;
for (const userSpaceRole of userSpaceRoles) {
const currentRole = userSpaceRole.role;
if (!highestRole || roleOrder[currentRole] > roleOrder[highestRole]) {
highestRole = currentRole;
}
}
return highestRole;
}

View File

@ -1,10 +1,15 @@
import type { ColumnType } from "kysely"; import type { ColumnType } from 'kysely';
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U> export type Generated<T> =
T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U> ? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>; : ColumnType<T, T | undefined, T>;
export type Int8 = ColumnType<string, bigint | number | string, bigint | number | string>; export type Int8 = ColumnType<
string,
bigint | number | string,
bigint | number | string
>;
export type Json = JsonValue; export type Json = JsonValue;