mirror of
https://github.com/Shadowfita/docmost.git
synced 2025-11-19 11:11:05 +10:00
client: updates
* work on groups ui * move settings to its own page * other fixes and refactoring
This commit is contained in:
6
apps/client/src/features/auth/components/auth.module.css
Normal file
6
apps/client/src/features/auth/components/auth.module.css
Normal file
@ -0,0 +1,6 @@
|
||||
.authBackground {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
background-size: cover;
|
||||
background-image: url(https://images.unsplash.com/photo-1701010063921-5f3255259e6d?q=80&w=3024&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D);
|
||||
}
|
||||
@ -15,6 +15,7 @@ import {
|
||||
PasswordInput,
|
||||
} from '@mantine/core';
|
||||
import { Link } from 'react-router-dom';
|
||||
import classes from './auth.module.css';
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z
|
||||
@ -40,17 +41,11 @@ export function LoginForm() {
|
||||
|
||||
return (
|
||||
<Container size={420} my={40}>
|
||||
<Title ta="center" fw={800}>
|
||||
Login
|
||||
</Title>
|
||||
<Text c="dimmed" size="sm" ta="center" mt={5}>
|
||||
Don't have an account yet?{' '}
|
||||
<Anchor size="sm" component={Link} to="/signup">
|
||||
Create account
|
||||
</Anchor>
|
||||
</Text>
|
||||
<Paper shadow="md" p="lg" radius="md" mt={200}>
|
||||
<Title ta="center" fw={800}>
|
||||
Login
|
||||
</Title>
|
||||
|
||||
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<TextInput
|
||||
id="email"
|
||||
@ -72,6 +67,14 @@ export function LoginForm() {
|
||||
Sign In
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<Text c="dimmed" size="sm" ta="center" mt="sm">
|
||||
Don't have an account yet?{' '}
|
||||
<Anchor size="sm" component={Link} to="/signup">
|
||||
Create account
|
||||
</Anchor>
|
||||
</Text>
|
||||
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@ -50,7 +50,7 @@ export function SignUpForm() {
|
||||
</Anchor>
|
||||
</Text>
|
||||
|
||||
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
||||
<Paper shadow="md" p={30} mt={30} radius="md">
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<TextInput
|
||||
id="email"
|
||||
|
||||
@ -78,7 +78,7 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
||||
|
||||
<Stack gap={2}>
|
||||
<Group>
|
||||
<Avatar size="sm" color="blue">{currentUser.user.name.charAt(0)}</Avatar>
|
||||
<Avatar size="sm" c="blue">{currentUser.user.name.charAt(0)}</Avatar>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" fw={500} lineClamp={1}>{currentUser.user.name}</Text>
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
import { Button, Divider, Group, Modal, ScrollArea } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import React, { useState } from "react";
|
||||
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useAddGroupMemberMutation } from "@/features/group/queries/group-query.ts";
|
||||
|
||||
export default function AddGroupMemberModal() {
|
||||
const { groupId } = useParams();
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [userIds, setUserIds] = useState<string[]>([]);
|
||||
const addGroupMemberMutation = useAddGroupMemberMutation();
|
||||
|
||||
const handleMultiSelectChange = (value: string[]) => {
|
||||
setUserIds(value);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const addGroupMember = {
|
||||
groupId: groupId,
|
||||
userIds: userIds,
|
||||
};
|
||||
|
||||
await addGroupMemberMutation.mutateAsync(addGroupMember);
|
||||
close();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={open}>Add group members</Button>
|
||||
|
||||
<Modal opened={opened} onClose={close} title="Add group members">
|
||||
<Divider size="xs" mb="xs" />
|
||||
|
||||
<MultiUserSelect onChange={handleMultiSelectChange} />
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button onClick={handleSubmit} type="submit">
|
||||
Add
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,82 @@
|
||||
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
|
||||
import React, { useState } from "react";
|
||||
import { useCreateGroupMutation } from "@/features/group/queries/group-query.ts";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import * as z from "zod";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(2).max(50),
|
||||
description: z.string().max(500),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function CreateGroupForm() {
|
||||
const createGroupMutation = useCreateGroupMutation();
|
||||
const [userIds, setUserIds] = useState<string[]>([]);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zodResolver(formSchema),
|
||||
initialValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleMultiSelectChange = (value: string[]) => {
|
||||
setUserIds(value);
|
||||
};
|
||||
|
||||
const handleSubmit = async (data: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}) => {
|
||||
const groupData = {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
userIds: userIds,
|
||||
};
|
||||
|
||||
const createdGroup = await createGroupMutation.mutateAsync(groupData);
|
||||
navigate(`/settings/groups/${createdGroup.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box maw="500" mx="auto">
|
||||
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
withAsterisk
|
||||
id="name"
|
||||
label="Group name"
|
||||
placeholder="e.g Developers"
|
||||
variant="filled"
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
id="description"
|
||||
label="Group description"
|
||||
placeholder="e.g Group for developers"
|
||||
variant="filled"
|
||||
autosize
|
||||
minRows={2}
|
||||
maxRows={8}
|
||||
{...form.getInputProps("description")}
|
||||
/>
|
||||
|
||||
<MultiUserSelect onChange={handleMultiSelectChange} />
|
||||
</Stack>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button type="submit">Create</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
import { Button, Divider, Modal } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { CreateGroupForm } from "@/features/group/components/create-group-form.tsx";
|
||||
|
||||
export default function CreateGroupModal() {
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={open}>Create group</Button>
|
||||
|
||||
<Modal opened={opened} onClose={close} title="Create group">
|
||||
<Divider size="xs" mb="xs" />
|
||||
<CreateGroupForm />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,88 @@
|
||||
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
|
||||
import React, { useEffect } from "react";
|
||||
import {
|
||||
useGroupQuery,
|
||||
useUpdateGroupMutation,
|
||||
} from "@/features/group/queries/group-query.ts";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import * as z from "zod";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(2).max(50),
|
||||
description: z.string().max(500),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
interface EditGroupFormProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
export function EditGroupForm({ onClose }: EditGroupFormProps) {
|
||||
const updateGroupMutation = useUpdateGroupMutation();
|
||||
const { isSuccess } = updateGroupMutation;
|
||||
const { groupId } = useParams();
|
||||
const { data: group } = useGroupQuery(groupId);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
}, [isSuccess]);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zodResolver(formSchema),
|
||||
initialValues: {
|
||||
name: group?.name,
|
||||
description: group?.description,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (data: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}) => {
|
||||
const groupData = {
|
||||
groupId: groupId,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
};
|
||||
|
||||
await updateGroupMutation.mutateAsync(groupData);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box maw="500" mx="auto">
|
||||
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
withAsterisk
|
||||
id="name"
|
||||
label="Group name"
|
||||
placeholder="e.g Developers"
|
||||
variant="filled"
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
id="description"
|
||||
label="Group description"
|
||||
placeholder="e.g Group for developers"
|
||||
variant="filled"
|
||||
autosize
|
||||
minRows={2}
|
||||
maxRows={8}
|
||||
{...form.getInputProps("description")}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button type="submit">Edit</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
import { Divider, Modal } from "@mantine/core";
|
||||
import { EditGroupForm } from "@/features/group/components/edit-group-form.tsx";
|
||||
|
||||
interface EditGroupModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function EditGroupModal({
|
||||
opened,
|
||||
onClose,
|
||||
}: EditGroupModalProps) {
|
||||
return (
|
||||
<>
|
||||
<Modal opened={opened} onClose={onClose} title="Edit group">
|
||||
<Divider size="xs" mb="xs" />
|
||||
<EditGroupForm onClose={onClose} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
import {
|
||||
useDeleteGroupMutation,
|
||||
useGroupQuery,
|
||||
} from "@/features/group/queries/group-query";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Menu, ActionIcon, Text } from "@mantine/core";
|
||||
import React from "react";
|
||||
import { IconDots, IconTrash } from "@tabler/icons-react";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import EditGroupModal from "@/features/group/components/edit-group-modal.tsx";
|
||||
import { modals } from "@mantine/modals";
|
||||
|
||||
export default function GroupActionMenu() {
|
||||
const { groupId } = useParams();
|
||||
const { data: group, isLoading } = useGroupQuery(groupId);
|
||||
const deleteGroupMutation = useDeleteGroupMutation();
|
||||
const navigate = useNavigate();
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
|
||||
const onDelete = async () => {
|
||||
await deleteGroupMutation.mutateAsync(groupId);
|
||||
navigate("/settings/groups");
|
||||
};
|
||||
|
||||
const openDeleteModal = () =>
|
||||
modals.openConfirmModal({
|
||||
title: "Delete group",
|
||||
children: (
|
||||
<Text size="sm">
|
||||
Are you sure you want to delete this group? Members will lose access
|
||||
to resources this group has access to.
|
||||
</Text>
|
||||
),
|
||||
centered: true,
|
||||
labels: { confirm: "Delete", cancel: "Cancel" },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: onDelete,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{group && (
|
||||
<>
|
||||
<Menu
|
||||
shadow="xl"
|
||||
position="bottom-end"
|
||||
offset={20}
|
||||
width={200}
|
||||
withArrow
|
||||
arrowPosition="center"
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="light">
|
||||
<IconDots size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={open} disabled={group.isDefault}>
|
||||
Edit group
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
c="red"
|
||||
onClick={openDeleteModal}
|
||||
disabled={group.isDefault}
|
||||
leftSection={<IconTrash size={16} stroke={2} />}
|
||||
>
|
||||
Delete group
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
|
||||
<EditGroupModal opened={opened} onClose={close} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
33
apps/client/src/features/group/components/group-details.tsx
Normal file
33
apps/client/src/features/group/components/group-details.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { useGroupQuery } from "@/features/group/queries/group-query";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Group, Title, Text } from "@mantine/core";
|
||||
import AddGroupMemberModal from "@/features/group/components/add-group-member-modal";
|
||||
import React from "react";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import EditGroupModal from "@/features/group/components/edit-group-modal.tsx";
|
||||
import GroupActionMenu from "@/features/group/components/group-action-menu.tsx";
|
||||
|
||||
export default function GroupDetails() {
|
||||
const { groupId } = useParams();
|
||||
const { data: group, isLoading } = useGroupQuery(groupId);
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{group && (
|
||||
<div>
|
||||
{/* Todo: back navigation */}
|
||||
<Title order={3}>{group.name}</Title>
|
||||
<Text c="dimmed">{group.description}</Text>
|
||||
|
||||
<Group my="md" justify="flex-end">
|
||||
<AddGroupMemberModal />
|
||||
<GroupActionMenu />
|
||||
</Group>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EditGroupModal opened={opened} onClose={close} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
70
apps/client/src/features/group/components/group-list.tsx
Normal file
70
apps/client/src/features/group/components/group-list.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import { Table, Group, Text, Anchor } from "@mantine/core";
|
||||
import { useGetGroupsQuery } from "@/features/group/queries/group-query";
|
||||
import { IconUsersGroup } from "@tabler/icons-react";
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export default function GroupList() {
|
||||
const { data, isLoading } = useGetGroupsQuery();
|
||||
|
||||
return (
|
||||
<>
|
||||
{data && (
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Group</Table.Th>
|
||||
<Table.Th>Members</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{data?.items.map((group, index) => (
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>
|
||||
<Anchor
|
||||
size="sm"
|
||||
underline="never"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: "var(--mantine-color-text)",
|
||||
}}
|
||||
component={Link}
|
||||
to={`/settings/groups/${group.id}`}
|
||||
>
|
||||
<Group gap="sm">
|
||||
<IconUsersGroup stroke={1.5} />
|
||||
<div>
|
||||
<Text fz="sm" fw={500}>
|
||||
{group.name}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{group.description}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Anchor>
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
<Anchor
|
||||
size="sm"
|
||||
underline="never"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: "var(--mantine-color-text)",
|
||||
}}
|
||||
component={Link}
|
||||
to={`/settings/groups/${group.id}`}
|
||||
>
|
||||
{group.memberCount} members
|
||||
</Anchor>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
102
apps/client/src/features/group/components/group-members.tsx
Normal file
102
apps/client/src/features/group/components/group-members.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import { Group, Table, Text, Badge, Menu, ActionIcon } from "@mantine/core";
|
||||
import {
|
||||
useGroupMembersQuery,
|
||||
useRemoveGroupMemberMutation,
|
||||
} from "@/features/group/queries/group-query";
|
||||
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";
|
||||
|
||||
export default function GroupMembersList() {
|
||||
const { groupId } = useParams();
|
||||
const { data, isLoading } = useGroupMembersQuery(groupId);
|
||||
const removeGroupMember = useRemoveGroupMemberMutation();
|
||||
|
||||
const onRemove = async (userId: string) => {
|
||||
const memberToRemove = {
|
||||
groupId: groupId,
|
||||
userId: userId,
|
||||
};
|
||||
await removeGroupMember.mutateAsync(memberToRemove);
|
||||
};
|
||||
|
||||
const openRemoveModal = (userId: string) =>
|
||||
modals.openConfirmModal({
|
||||
title: "Remove group member",
|
||||
children: (
|
||||
<Text size="sm">
|
||||
Are you sure you want to remove this user from the group? The user
|
||||
will lose access to resources this group has access to.
|
||||
</Text>
|
||||
),
|
||||
centered: true,
|
||||
labels: { confirm: "Delete", cancel: "Cancel" },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: () => onRemove(userId),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{data && (
|
||||
<Table verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>User</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{data?.items.map((user, index) => (
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>
|
||||
<Group gap="sm">
|
||||
<UserAvatar avatarUrl={user.avatarUrl} name={user.name} />
|
||||
<div>
|
||||
<Text fz="sm" fw={500}>
|
||||
{user.name}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{user.email}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
<Badge variant="light">Active</Badge>
|
||||
</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(user.id)}>
|
||||
Remove group member
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useWorkspaceMembersQuery } from "@/features/workspace/queries/workspace-query.ts";
|
||||
import { IUser } from "@/features/user/types/user.types.ts";
|
||||
import {
|
||||
Avatar,
|
||||
Group,
|
||||
MultiSelect,
|
||||
MultiSelectProps,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
|
||||
interface MultiUserSelectProps {
|
||||
onChange: (value: string[]) => void;
|
||||
}
|
||||
|
||||
const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
|
||||
option,
|
||||
}) => (
|
||||
<Group gap="sm">
|
||||
<Avatar src={option?.["avatarUrl"]} size={36} radius="xl" />
|
||||
<div>
|
||||
<Text size="sm">{option.label}</Text>
|
||||
<Text size="xs" opacity={0.5}>
|
||||
{option?.["email"]}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
|
||||
export function MultiUserSelect({ onChange }: MultiUserSelectProps) {
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
|
||||
const { data: users, isLoading } = useWorkspaceMembersQuery({
|
||||
query: debouncedQuery,
|
||||
limit: 50,
|
||||
});
|
||||
const [data, setData] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (users) {
|
||||
const usersData = users?.items.map((user: IUser) => {
|
||||
return {
|
||||
value: user.id,
|
||||
label: user.name,
|
||||
avatarUrl: user.avatarUrl,
|
||||
email: user.email,
|
||||
};
|
||||
});
|
||||
if (usersData.length > 0) {
|
||||
setData(usersData);
|
||||
}
|
||||
}
|
||||
}, [users]);
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
data={data}
|
||||
renderOption={renderMultiSelectOption}
|
||||
hidePickedOptions
|
||||
maxDropdownHeight={300}
|
||||
label="Add group members"
|
||||
placeholder="Search for users"
|
||||
searchable
|
||||
searchValue={searchValue}
|
||||
onSearchChange={setSearchValue}
|
||||
variant="filled"
|
||||
onChange={onChange}
|
||||
nothingFoundMessage="Nothing found..."
|
||||
maxValues={50}
|
||||
/>
|
||||
);
|
||||
}
|
||||
134
apps/client/src/features/group/queries/group-query.ts
Normal file
134
apps/client/src/features/group/queries/group-query.ts
Normal file
@ -0,0 +1,134 @@
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import { IGroup } from "@/features/group/types/group.types";
|
||||
import {
|
||||
addGroupMember,
|
||||
createGroup,
|
||||
deleteGroup,
|
||||
getGroupById,
|
||||
getGroupMembers,
|
||||
getGroups,
|
||||
removeGroupMember,
|
||||
updateGroup,
|
||||
} from "@/features/group/services/group-service";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
|
||||
export function useGetGroupsQuery(): UseQueryResult<any, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["groups"],
|
||||
queryFn: () => getGroups(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useGroupQuery(groupId: string): UseQueryResult<IGroup, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["group", groupId],
|
||||
queryFn: () => getGroupById(groupId),
|
||||
enabled: !!groupId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGroupMembersQuery(groupId: string) {
|
||||
return useQuery({
|
||||
queryKey: ["groupMembers", groupId],
|
||||
queryFn: () => getGroupMembers(groupId),
|
||||
enabled: !!groupId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateGroupMutation() {
|
||||
return useMutation<IGroup, Error, Partial<IGroup>>({
|
||||
mutationFn: (data) => createGroup(data),
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: "Group created successfully" });
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({ message: "Failed to create group", color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateGroupMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<IGroup, Error, Partial<IGroup>>({
|
||||
mutationFn: (data) => updateGroup(data),
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: "Group updated successfully" });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["group", variables.groupId],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteGroupMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (groupId: string) => deleteGroup({ groupId }),
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: "Group deleted successfully" });
|
||||
queryClient.refetchQueries({
|
||||
queryKey: ["groups"],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddGroupMemberMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<void, Error, { groupId: string; userIds: string[] }>({
|
||||
mutationFn: (data) => addGroupMember(data),
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: "Added successfully" });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["groupMembers", variables.groupId],
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: "Failed to add group members",
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveGroupMemberMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
void,
|
||||
Error,
|
||||
{
|
||||
groupId: string;
|
||||
userId: string;
|
||||
}
|
||||
>({
|
||||
mutationFn: (data) => removeGroupMember(data),
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: "Removed successfully" });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["groupMembers", variables.groupId],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
46
apps/client/src/features/group/services/group-service.ts
Normal file
46
apps/client/src/features/group/services/group-service.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import api from "@/lib/api-client";
|
||||
import { IGroup } from "@/features/group/types/group.types";
|
||||
|
||||
export async function getGroups(): Promise<any> {
|
||||
// TODO: returns paginated. Fix type
|
||||
const req = await api.post<any>("/groups");
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getGroupById(groupId: string): Promise<IGroup> {
|
||||
const req = await api.post<IGroup>("/groups/info", { groupId });
|
||||
return req.data as IGroup;
|
||||
}
|
||||
|
||||
export async function getGroupMembers(groupId: string) {
|
||||
const req = await api.post("/groups/members", { groupId });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function createGroup(data: Partial<IGroup>): Promise<IGroup> {
|
||||
const req = await api.post<IGroup>("/groups/create", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function updateGroup(data: Partial<IGroup>): Promise<IGroup> {
|
||||
const req = await api.post<IGroup>("/groups/update", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function deleteGroup(data: { groupId: string }): Promise<void> {
|
||||
await api.post("/groups/delete", data);
|
||||
}
|
||||
|
||||
export async function addGroupMember(data: {
|
||||
groupId: string;
|
||||
userIds: string[];
|
||||
}): Promise<void> {
|
||||
await api.post<IGroup>("/groups/members/add", data);
|
||||
}
|
||||
|
||||
export async function removeGroupMember(data: {
|
||||
groupId: string;
|
||||
userId: string;
|
||||
}): Promise<void> {
|
||||
await api.post<IGroup>("/groups/members/remove", data);
|
||||
}
|
||||
12
apps/client/src/features/group/types/group.types.ts
Normal file
12
apps/client/src/features/group/types/group.types.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export interface IGroup {
|
||||
groupId: string;
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
isDefault: boolean;
|
||||
creatorId: string | null;
|
||||
workspaceId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
memberCount: number;
|
||||
}
|
||||
@ -22,7 +22,7 @@ export default function HomeTabs() {
|
||||
|
||||
<Tabs.Panel value="recent">
|
||||
|
||||
<RecentChanges />
|
||||
{/* <RecentChanges /> */}
|
||||
|
||||
</Tabs.Panel>
|
||||
|
||||
|
||||
@ -24,7 +24,7 @@ function HistoryItem({ historyItem, onSelect, isActive }: HistoryItemProps) {
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<UserAvatar color="blue" size="sm" avatarUrl={historyItem.lastUpdatedBy.avatarUrl}
|
||||
<UserAvatar c="blue" size="sm" avatarUrl={historyItem.lastUpdatedBy.avatarUrl}
|
||||
name={historyItem.lastUpdatedBy.name} />
|
||||
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||
{historyItem.lastUpdatedBy.name}
|
||||
|
||||
@ -7,7 +7,7 @@ export async function createPage(data: Partial<IPage>): Promise<IPage> {
|
||||
}
|
||||
|
||||
export async function getPageById(id: string): Promise<IPage> {
|
||||
const req = await api.post<IPage>('/pages/details', { id });
|
||||
const req = await api.post<IPage>('/pages/info', { id });
|
||||
return req.data as IPage;
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { NodeApi, NodeRendererProps, Tree, TreeApi } from 'react-arborist';
|
||||
import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist";
|
||||
import {
|
||||
IconArrowsLeftRight,
|
||||
IconChevronDown,
|
||||
@ -11,24 +11,27 @@ import {
|
||||
IconPlus,
|
||||
IconStar,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react';
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import classes from './styles/tree.module.css';
|
||||
import { ActionIcon, Menu, rem } from '@mantine/core';
|
||||
import { useAtom } from 'jotai';
|
||||
import { FillFlexParent } from './components/fill-flex-parent';
|
||||
import { TreeNode } from './types';
|
||||
import { treeApiAtom } from './atoms/tree-api-atom';
|
||||
import { usePersistence } from '@/features/page/tree/hooks/use-persistence';
|
||||
import useWorkspacePageOrder from '@/features/page/tree/hooks/use-workspace-page-order';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { convertToTree, updateTreeNodeIcon } from '@/features/page/tree/utils';
|
||||
import { useGetPagesQuery, useUpdatePageMutation } from '@/features/page/queries/page-query';
|
||||
import EmojiPicker from '@/components/emoji-picker';
|
||||
import { treeDataAtom } from '@/features/page/tree/atoms/tree-data-atom';
|
||||
import classes from "./styles/tree.module.css";
|
||||
import { ActionIcon, Menu, rem } from "@mantine/core";
|
||||
import { useAtom } from "jotai";
|
||||
import { FillFlexParent } from "./components/fill-flex-parent";
|
||||
import { TreeNode } from "./types";
|
||||
import { treeApiAtom } from "./atoms/tree-api-atom";
|
||||
import { usePersistence } from "@/features/page/tree/hooks/use-persistence";
|
||||
import useWorkspacePageOrder from "@/features/page/tree/hooks/use-workspace-page-order";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { convertToTree, updateTreeNodeIcon } from "@/features/page/tree/utils";
|
||||
import {
|
||||
useGetPagesQuery,
|
||||
useUpdatePageMutation,
|
||||
} from "@/features/page/queries/page-query";
|
||||
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
|
||||
|
||||
export default function PageTree() {
|
||||
const { data, setData, controllers } = usePersistence<TreeApi<TreeNode>>();
|
||||
@ -46,7 +49,7 @@ export default function PageTree() {
|
||||
setData(treeData);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching tree data: ', err);
|
||||
console.error("Error fetching tree data: ", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -58,7 +61,7 @@ export default function PageTree() {
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
tree?.select(pageId);
|
||||
tree?.scrollTo(pageId, 'center');
|
||||
tree?.scrollTo(pageId, "center");
|
||||
}, 200);
|
||||
}, [tree, pageId]);
|
||||
|
||||
@ -106,7 +109,7 @@ function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
|
||||
const handleEmojiIconClick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmojiSelect = (emoji) => {
|
||||
handleUpdateNodeIcon(node.id, emoji.native);
|
||||
@ -134,19 +137,25 @@ function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
|
||||
>
|
||||
<PageArrow node={node} />
|
||||
|
||||
<div onClick={handleEmojiIconClick} style={{ marginRight: '4px' }}>
|
||||
<EmojiPicker onEmojiSelect={handleEmojiSelect} icon={
|
||||
node.data.icon ? node.data.icon :
|
||||
<IconFileDescription size="18px" />
|
||||
|
||||
} removeEmojiAction={handleRemoveEmoji}/>
|
||||
<div onClick={handleEmojiIconClick} style={{ marginRight: "4px" }}>
|
||||
<EmojiPicker
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
icon={
|
||||
node.data.icon ? (
|
||||
node.data.icon
|
||||
) : (
|
||||
<IconFileDescription size="18px" />
|
||||
)
|
||||
}
|
||||
removeEmojiAction={handleRemoveEmoji}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className={classes.text}>
|
||||
{node.isEditing ? (
|
||||
<Input node={node} />
|
||||
) : (
|
||||
node.data.name || 'untitled'
|
||||
node.data.name || "untitled"
|
||||
)}
|
||||
</span>
|
||||
|
||||
@ -163,15 +172,19 @@ function CreateNode({ node }: { node: NodeApi<TreeNode> }) {
|
||||
const [tree] = useAtom(treeApiAtom);
|
||||
|
||||
function handleCreate() {
|
||||
tree?.create({ type: 'internal', parentId: node.id, index: 0 });
|
||||
tree?.create({ type: "internal", parentId: node.id, index: 0 });
|
||||
}
|
||||
|
||||
return (
|
||||
<ActionIcon variant="transparent" color="gray" onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCreate();
|
||||
}}>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
c="gray"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCreate();
|
||||
}}
|
||||
>
|
||||
<IconPlus style={{ width: rem(20), height: rem(20) }} stroke={2} />
|
||||
</ActionIcon>
|
||||
);
|
||||
@ -187,10 +200,14 @@ function NodeMenu({ node }: { node: NodeApi<TreeNode> }) {
|
||||
return (
|
||||
<Menu shadow="md" width={200}>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="transparent" color="gray" onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
c="gray"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<IconDotsVertical
|
||||
style={{ width: rem(20), height: rem(20) }}
|
||||
stroke={2}
|
||||
@ -240,7 +257,7 @@ function NodeMenu({ node }: { node: NodeApi<TreeNode> }) {
|
||||
Archive
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
c="red"
|
||||
leftSection={
|
||||
<IconTrash style={{ width: rem(14), height: rem(14) }} />
|
||||
}
|
||||
@ -255,13 +272,16 @@ function NodeMenu({ node }: { node: NodeApi<TreeNode> }) {
|
||||
|
||||
function PageArrow({ node }: { node: NodeApi<TreeNode> }) {
|
||||
return (
|
||||
<ActionIcon size={20} variant="subtle" color="gray"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
node.toggle();
|
||||
}}>
|
||||
|
||||
<ActionIcon
|
||||
size={20}
|
||||
variant="subtle"
|
||||
c="gray"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
node.toggle();
|
||||
}}
|
||||
>
|
||||
{node.isInternal ? (
|
||||
node.children && node.children.length > 0 ? (
|
||||
node.isOpen ? (
|
||||
@ -270,7 +290,7 @@ function PageArrow({ node }: { node: NodeApi<TreeNode> }) {
|
||||
<IconChevronRight stroke={2} size={18} />
|
||||
)
|
||||
) : (
|
||||
<IconChevronRight size={18} style={{ visibility: 'hidden' }} />
|
||||
<IconChevronRight size={18} style={{ visibility: "hidden" }} />
|
||||
)
|
||||
) : null}
|
||||
</ActionIcon>
|
||||
@ -278,7 +298,6 @@ function PageArrow({ node }: { node: NodeApi<TreeNode> }) {
|
||||
}
|
||||
|
||||
function Input({ node }: { node: NodeApi<TreeNode> }) {
|
||||
|
||||
return (
|
||||
<input
|
||||
autoFocus
|
||||
@ -289,10 +308,9 @@ function Input({ node }: { node: NodeApi<TreeNode> }) {
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
onBlur={() => node.reset()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') node.reset();
|
||||
if (e.key === 'Enter') node.submit(e.currentTarget.value);
|
||||
if (e.key === "Escape") node.reset();
|
||||
if (e.key === "Enter") node.submit(e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
import { atom } from "jotai";
|
||||
|
||||
export const settingsModalAtom = atom<boolean>(false);
|
||||
@ -1,76 +0,0 @@
|
||||
.sidebar {
|
||||
max-height: rem(700px);
|
||||
width: rem(180px);
|
||||
padding: var(--mantine-spacing-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: rem(1px) solid
|
||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
}
|
||||
|
||||
.sidebarFlex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sidebarMain {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebarRightSection {
|
||||
flex: 1;
|
||||
padding: rem(16px) rem(40px);
|
||||
}
|
||||
|
||||
.sidebarItemHeader {
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sidebarItem {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||
|
||||
.sidebarItemIcon {
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||
}
|
||||
}
|
||||
|
||||
&[data-active] {
|
||||
&,
|
||||
& :hover {
|
||||
background-color: var(--mantine-color-blue-light);
|
||||
color: var(--mantine-color-blue-light-color);
|
||||
|
||||
.sidebarItemIcon {
|
||||
color: var(--mantine-color-blue-light-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebarItemIcon {
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
margin-right: var(--mantine-spacing-sm);
|
||||
width: rem(20px);
|
||||
height: rem(20px);
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
import { Modal, Text } from '@mantine/core';
|
||||
import React from 'react';
|
||||
import SettingsSidebar from '@/features/settings/modal/settings-sidebar';
|
||||
import { useAtom } from 'jotai';
|
||||
import { settingsModalAtom } from '@/features/settings/modal/atoms/settings-modal-atom';
|
||||
|
||||
export default function SettingsModal() {
|
||||
const [isModalOpen, setModalOpen] = useAtom(settingsModalAtom);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal.Root size={1000} opened={isModalOpen} onClose={() => setModalOpen(false)}>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content style={{ overflow: 'hidden' }}>
|
||||
<Modal.Header>
|
||||
<Modal.Title>
|
||||
<Text size="md" fw={500}>Settings</Text>
|
||||
</Modal.Title>
|
||||
<Modal.CloseButton />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
|
||||
<SettingsSidebar />
|
||||
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,101 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import classes from '@/features/settings/modal/modal.module.css';
|
||||
import { IconBell, IconFingerprint, IconReceipt, IconSettingsCog, IconUser, IconUsers } from '@tabler/icons-react';
|
||||
import { Loader, ScrollArea, Text } from '@mantine/core';
|
||||
|
||||
const AccountSettings = React.lazy(() => import('@/features/settings/account/settings/account-settings'));
|
||||
const WorkspaceSettings = React.lazy(() => import('@/features/settings/workspace/settings/workspace-settings'));
|
||||
const WorkspaceMembers = React.lazy(() => import('@/features/settings/workspace/members/workspace-members'));
|
||||
|
||||
interface DataItem {
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
}
|
||||
|
||||
interface DataGroup {
|
||||
heading: string;
|
||||
items: DataItem[];
|
||||
}
|
||||
|
||||
const groupedData: DataGroup[] = [
|
||||
{
|
||||
heading: 'Account',
|
||||
items: [
|
||||
{ label: 'Account', icon: IconUser },
|
||||
{ label: 'Notifications', icon: IconBell },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Workspace',
|
||||
items: [
|
||||
{ label: 'General', icon: IconSettingsCog },
|
||||
{ label: 'Members', icon: IconUsers },
|
||||
{ label: 'Security', icon: IconFingerprint },
|
||||
{ label: 'Billing', icon: IconReceipt },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function SettingsSidebar() {
|
||||
const [active, setActive] = useState('Account');
|
||||
|
||||
const menu = groupedData.map((group) => (
|
||||
<div key={group.heading}>
|
||||
<Text c="dimmed" className={classes.sidebarItemHeader}>{group.heading}</Text>
|
||||
{group.items.map((item) => (
|
||||
<div
|
||||
className={classes.sidebarItem}
|
||||
data-active={item.label === active || undefined}
|
||||
key={item.label}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
setActive(item.label);
|
||||
}}
|
||||
>
|
||||
<item.icon className={classes.sidebarItemIcon} stroke={1.5} />
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
));
|
||||
|
||||
let ActiveComponent;
|
||||
|
||||
switch (active) {
|
||||
case 'Account':
|
||||
ActiveComponent = AccountSettings;
|
||||
break;
|
||||
case 'General':
|
||||
ActiveComponent = WorkspaceSettings;
|
||||
break;
|
||||
case 'Members':
|
||||
ActiveComponent = WorkspaceMembers;
|
||||
break;
|
||||
default:
|
||||
ActiveComponent = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.sidebarFlex}>
|
||||
<nav className={classes.sidebar}>
|
||||
<div className={classes.sidebarMain}>
|
||||
{menu}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
<ScrollArea h="650" w="100%" scrollbarSize={4}>
|
||||
|
||||
<div className={classes.sidebarRightSection}>
|
||||
|
||||
<React.Suspense fallback={<Loader size="sm" color="gray" />}>
|
||||
{ActiveComponent && <ActiveComponent />}
|
||||
</React.Suspense>
|
||||
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { atomWithStorage } from "jotai/utils";
|
||||
|
||||
import { ICurrentUserResponse } from "@/features/user/types/user.types";
|
||||
import { ICurrentUser } from "@/features/user/types/user.types";
|
||||
|
||||
export const currentUserAtom = atomWithStorage<ICurrentUserResponse | null>("currentUser", null);
|
||||
export const currentUserAtom = atomWithStorage<ICurrentUser | null>("currentUser", null);
|
||||
|
||||
60
apps/client/src/features/user/components/account-avatar.tsx
Normal file
60
apps/client/src/features/user/components/account-avatar.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { focusAtom } from "jotai-optics";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useState } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { UserAvatar } from "@/components/ui/user-avatar.tsx";
|
||||
import { FileButton, Button, Text, Popover, Tooltip } from "@mantine/core";
|
||||
import { uploadAvatar } from "@/features/user/services/user-service.ts";
|
||||
|
||||
const userAtom = focusAtom(currentUserAtom, (optic) => optic.prop("user"));
|
||||
|
||||
export default function AccountAvatar() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const [, setUser] = useAtom(userAtom);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
|
||||
const handleFileChange = async (selectedFile: File) => {
|
||||
if (!selectedFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
|
||||
setFile(selectedFile);
|
||||
setPreviewUrl(URL.createObjectURL(selectedFile));
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const upload = await uploadAvatar(selectedFile);
|
||||
console.log(upload);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FileButton onChange={handleFileChange} accept="image/png,image/jpeg">
|
||||
{(props) => (
|
||||
<Tooltip label="Change photo" position="bottom">
|
||||
<UserAvatar
|
||||
{...props}
|
||||
component="button"
|
||||
radius="xl"
|
||||
size="60px"
|
||||
avatarUrl={previewUrl || currentUser.user.avatarUrl}
|
||||
name={currentUser.user.name}
|
||||
style={{ cursor: "pointer" }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</FileButton>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,76 @@
|
||||
import { useAtom } from "jotai";
|
||||
import { focusAtom } from "jotai-optics";
|
||||
import * as z from "zod";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { updateUser } from "@/features/user/services/user-service.ts";
|
||||
import { IUser } from "@/features/user/types/user.types.ts";
|
||||
import { useState } from "react";
|
||||
import { TextInput, Button } from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(2).max(40).nonempty("Your name cannot be blank"),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const userAtom = focusAtom(currentUserAtom, (optic) => optic.prop("user"));
|
||||
|
||||
export default function AccountNameForm() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const [, setUser] = useAtom(userAtom);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zodResolver(formSchema),
|
||||
initialValues: {
|
||||
name: currentUser?.user.name,
|
||||
},
|
||||
});
|
||||
|
||||
async function handleSubmit(data: Partial<IUser>) {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const updatedUser = await updateUser(data);
|
||||
setUser(updatedUser);
|
||||
notifications.show({
|
||||
message: "Updated successfully",
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
notifications.show({
|
||||
message: "Failed to update data",
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<TextInput
|
||||
id="name"
|
||||
label="Name"
|
||||
placeholder="Your name"
|
||||
variant="filled"
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<Button type="submit" mt="sm" disabled={isLoading} loading={isLoading}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
<div className={classes.controls}>
|
||||
<TextInput
|
||||
placeholder="Your email"
|
||||
classNames={{ input: classes.input, root: classes.inputWrapper }}
|
||||
/>
|
||||
<Button className={classes.control}>Subscribe</Button>
|
||||
</div>
|
||||
*/
|
||||
94
apps/client/src/features/user/components/change-email.tsx
Normal file
94
apps/client/src/features/user/components/change-email.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
import {
|
||||
Modal,
|
||||
TextInput,
|
||||
Button,
|
||||
Text,
|
||||
Group,
|
||||
PasswordInput,
|
||||
} from "@mantine/core";
|
||||
import * as z from "zod";
|
||||
import { useState } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import * as React from "react";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
|
||||
export default function ChangeEmail() {
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<Text size="md">Email</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{currentUser?.user.email}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button onClick={open} variant="default">
|
||||
Change email
|
||||
</Button>
|
||||
|
||||
<Modal opened={opened} onClose={close} title="Change email" centered>
|
||||
<Text mb="md">
|
||||
To change your email, you have to enter your password and new email.
|
||||
</Text>
|
||||
<ChangePasswordForm />
|
||||
</Modal>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string({ required_error: "New email is required" }).email(),
|
||||
password: z
|
||||
.string({ required_error: "your current password is required" })
|
||||
.min(8),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
function ChangePasswordForm() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zodResolver(formSchema),
|
||||
initialValues: {
|
||||
password: "",
|
||||
email: "",
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(data: FormValues) {
|
||||
setIsLoading(true);
|
||||
console.log(data);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Enter your password"
|
||||
variant="filled"
|
||||
mb="md"
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
id="email"
|
||||
label="Email"
|
||||
description="Enter your new preferred email"
|
||||
placeholder="New email"
|
||||
variant="filled"
|
||||
mb="md"
|
||||
{...form.getInputProps("email")}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={isLoading} loading={isLoading}>
|
||||
Change email
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
84
apps/client/src/features/user/components/change-password.tsx
Normal file
84
apps/client/src/features/user/components/change-password.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { Button, Group, Text, Modal, PasswordInput } from '@mantine/core';
|
||||
import * as z from 'zod';
|
||||
import { useState } from 'react';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import * as React from 'react';
|
||||
import { useForm, zodResolver } from '@mantine/form';
|
||||
|
||||
export default function ChangePassword() {
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<Text size="md">Password</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
You can change your password here.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button onClick={open} variant="default">Change password</Button>
|
||||
|
||||
<Modal opened={opened} onClose={close} title="Change password" centered>
|
||||
<Text mb="md">Your password must be a minimum of 8 characters.</Text>
|
||||
<ChangePasswordForm />
|
||||
|
||||
</Modal>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
current: z.string({ required_error: 'your current password is required' }).min(1),
|
||||
password: z.string({ required_error: 'New password is required' }).min(8),
|
||||
confirm_password: z.string({ required_error: 'Password confirmation is required' }).min(8),
|
||||
}).refine(data => data.password === data.confirm_password, {
|
||||
message: 'Your new password and confirmation does not match.',
|
||||
path: ['confirm_password'],
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>
|
||||
|
||||
function ChangePasswordForm() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zodResolver(formSchema),
|
||||
initialValues: {
|
||||
current: '',
|
||||
password: '',
|
||||
confirm_password: '',
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(data: FormValues) {
|
||||
setIsLoading(true);
|
||||
console.log(data);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
|
||||
<PasswordInput
|
||||
label="Current password"
|
||||
name="current"
|
||||
placeholder="Enter your current password"
|
||||
variant="filled"
|
||||
mb="md"
|
||||
{...form.getInputProps('current')}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label="New password"
|
||||
placeholder="Enter your new password"
|
||||
variant="filled"
|
||||
mb="md"
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={isLoading} loading={isLoading}>
|
||||
Change password
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||
import { getUserInfo } from "@/features/user/services/user-service";
|
||||
import { ICurrentUserResponse } from "@/features/user/types/user.types";
|
||||
import { ICurrentUser } from "@/features/user/types/user.types";
|
||||
|
||||
export default function useCurrentUser(): UseQueryResult<ICurrentUserResponse> {
|
||||
export default function useCurrentUser(): UseQueryResult<ICurrentUser> {
|
||||
return useQuery({
|
||||
queryKey: ["currentUser"],
|
||||
queryFn: async () => {
|
||||
|
||||
@ -1,19 +1,18 @@
|
||||
import api from '@/lib/api-client';
|
||||
import { ICurrentUserResponse, IUser } from '@/features/user/types/user.types';
|
||||
import { ICurrentUser, IUser } from '@/features/user/types/user.types';
|
||||
|
||||
export async function getMe(): Promise<IUser> {
|
||||
const req = await api.get<IUser>('/user/me');
|
||||
const req = await api.post<IUser>('/users/me');
|
||||
return req.data as IUser;
|
||||
}
|
||||
|
||||
export async function getUserInfo(): Promise<ICurrentUserResponse> {
|
||||
const req = await api.get<ICurrentUserResponse>('/user/info');
|
||||
return req.data as ICurrentUserResponse;
|
||||
export async function getUserInfo(): Promise<ICurrentUser> {
|
||||
const req = await api.post<ICurrentUser>('/users/info');
|
||||
return req.data as ICurrentUser;
|
||||
}
|
||||
|
||||
export async function updateUser(data: Partial<IUser>): Promise<IUser> {
|
||||
const req = await api.post<IUser>('/user/update', data);
|
||||
|
||||
const req = await api.post<IUser>('/users/update', data);
|
||||
return req.data as IUser;
|
||||
}
|
||||
|
||||
|
||||
@ -9,13 +9,13 @@ export interface IUser {
|
||||
timezone: string;
|
||||
settings: any;
|
||||
lastLoginAt: string;
|
||||
lastLoginIp: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
workspaceRole?: string;
|
||||
role: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface ICurrentUserResponse {
|
||||
export interface ICurrentUser {
|
||||
user: IUser,
|
||||
workspace: IWorkspace
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Group, Box, Button, TagsInput, Space, Select } from "@mantine/core";
|
||||
import WorkspaceInviteSection from "@/features/settings/workspace/members/components/workspace-invite-section";
|
||||
import WorkspaceInviteSection from "@/features/workspace/components/members/components/workspace-invite-section.tsx";
|
||||
import React from "react";
|
||||
|
||||
enum UserRole {
|
||||
|
||||
@ -1,27 +1,27 @@
|
||||
import { WorkspaceInviteForm } from '@/features/settings/workspace/members/components/workspace-invite-form';
|
||||
import { Button, Divider, Modal, ScrollArea } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { WorkspaceInviteForm } from "@/features/workspace/components/members/components/workspace-invite-form.tsx";
|
||||
import { Button, Divider, Modal, ScrollArea } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
|
||||
export default function WorkspaceInviteModal() {
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={open}>
|
||||
Invite members
|
||||
</Button>
|
||||
<Button onClick={open}>Invite members</Button>
|
||||
|
||||
<Modal size="600" opened={opened} onClose={close} title="Invite new members" centered>
|
||||
|
||||
<Divider size="xs" mb="xs"/>
|
||||
<Modal
|
||||
size="600"
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
title="Invite new members"
|
||||
centered
|
||||
>
|
||||
<Divider size="xs" mb="xs" />
|
||||
|
||||
<ScrollArea h="80%">
|
||||
|
||||
<WorkspaceInviteForm />
|
||||
|
||||
</ScrollArea>
|
||||
</Modal>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useAtom } from "jotai";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, CopyButton, Group, Text, TextInput } from "@mantine/core";
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Group, Table, Avatar, Text, Badge } from "@mantine/core";
|
||||
import { useWorkspaceMembersQuery } from "@/features/workspace/queries/workspace-query";
|
||||
import { useWorkspaceMembersQuery } from "@/features/workspace/queries/workspace-query.ts";
|
||||
import { UserAvatar } from "@/components/ui/user-avatar.tsx";
|
||||
import React from "react";
|
||||
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useAtom } from "jotai";
|
||||
import * as z from "zod";
|
||||
import { useState } from "react";
|
||||
import { focusAtom } from "jotai-optics";
|
||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service";
|
||||
import { IWorkspace } from "@/features/workspace/types/workspace.types";
|
||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
|
||||
import { TextInput, Button } from "@mantine/core";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getWorkspaceMembers } from "@/features/workspace/services/workspace-service";
|
||||
import { QueryParams } from "@/lib/types.ts";
|
||||
|
||||
export function useWorkspaceMembersQuery(params?: QueryParams) {
|
||||
return useQuery({
|
||||
queryKey: ["workspaceMembers", params],
|
||||
queryFn: () => getWorkspaceMembers(params),
|
||||
});
|
||||
}
|
||||
@ -1,19 +1,21 @@
|
||||
import api from '@/lib/api-client';
|
||||
import { ICurrentUserResponse, IUser } from '@/features/user/types/user.types';
|
||||
import { IWorkspace } from '../types/workspace.types';
|
||||
import api from "@/lib/api-client";
|
||||
import { IUser } from "@/features/user/types/user.types";
|
||||
import { IWorkspace } from "../types/workspace.types";
|
||||
import { QueryParams } from "@/lib/types.ts";
|
||||
|
||||
export async function getWorkspace(): Promise<IWorkspace> {
|
||||
const req = await api.get<IWorkspace>('/workspace');
|
||||
const req = await api.post<IWorkspace>("/workspace/info");
|
||||
return req.data as IWorkspace;
|
||||
}
|
||||
|
||||
export async function getWorkspaceUsers(): Promise<IUser[]> {
|
||||
const req = await api.get<IUser[]>('/workspace/members');
|
||||
return req.data as IUser[];
|
||||
// Todo: fix all paginated types
|
||||
export async function getWorkspaceMembers(params?: QueryParams): Promise<any> {
|
||||
const req = await api.post<any>("/workspace/members", params);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function updateWorkspace(data: Partial<IWorkspace>) {
|
||||
const req = await api.post<IWorkspace>('/workspace/update', data);
|
||||
const req = await api.post<IWorkspace>("/workspace/update", data);
|
||||
|
||||
return req.data as IWorkspace;
|
||||
}
|
||||
|
||||
@ -8,8 +8,6 @@ export interface IWorkspace {
|
||||
enableInvite: boolean;
|
||||
inviteCode: string;
|
||||
settings: any;
|
||||
creatorId: string;
|
||||
pageOrder?:[]
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user