mirror of
https://github.com/docmost/docmost.git
synced 2025-11-13 04:22:37 +10:00
space updates
* space UI * space management * space permissions * other fixes
This commit is contained in:
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
15
apps/client/src/components/icons/icon-people-circle.tsx
Normal file
15
apps/client/src/components/icons/icon-people-circle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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) => (
|
||||||
|
|||||||
63
apps/client/src/components/ui/role-select-menu.tsx
Normal file
63
apps/client/src/components/ui/role-select-menu.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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}
|
||||||
|
|||||||
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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[]>;
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
apps/client/src/features/space/components/settings-modal.tsx
Normal file
82
apps/client/src/features/space/components/settings-modal.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
apps/client/src/features/space/components/space-details.tsx
Normal file
24
apps/client/src/features/space/components/space-details.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
apps/client/src/features/space/components/space-list.tsx
Normal file
67
apps/client/src/features/space/components/space-list.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
176
apps/client/src/features/space/components/space-members.tsx
Normal file
176
apps/client/src/features/space/components/space-members.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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" });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
24
apps/client/src/features/space/types/space-role-data.ts
Normal file
24
apps/client/src/features/space/types/space-role-data.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
24
apps/client/src/features/workspace/types/user-role-data.ts
Normal file
24
apps/client/src/features/workspace/types/user-role-data.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
};
|
||||||
|
|||||||
11
apps/client/src/pages/settings/space/spaces.tsx
Normal file
11
apps/client/src/pages/settings/space/spaces.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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),
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
68
apps/server/src/core/casl/abilities/space-ability.factory.ts
Normal file
68
apps/server/src/core/casl/abilities/space-ability.factory.ts
Normal 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();
|
||||||
|
}
|
||||||
@ -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 {}
|
||||||
|
|||||||
15
apps/server/src/core/casl/interfaces/space-ability.type.ts
Normal file
15
apps/server/src/core/casl/interfaces/space-ability.type.ts
Normal 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];
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
apps/server/src/core/space/dto/add-space-members.dto.ts
Normal file
31
apps/server/src/core/space/dto/add-space-members.dto.ts
Normal 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[];
|
||||||
|
}
|
||||||
14
apps/server/src/core/space/dto/remove-space-member.dto.ts
Normal file
14
apps/server/src/core/space/dto/remove-space-member.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()`),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
23
apps/server/src/kysely/repos/space/utils.ts
Normal file
23
apps/server/src/kysely/repos/space/utils.ts
Normal 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;
|
||||||
|
}
|
||||||
11
apps/server/src/kysely/types/db.d.ts
vendored
11
apps/server/src/kysely/types/db.d.ts
vendored
@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user