implement new invitation system

* fix comments on the frontend
* move jwt token service to its own module
* other fixes and updates
This commit is contained in:
Philipinho
2024-05-14 22:55:11 +01:00
parent 525990d6e5
commit eefe63d1cd
75 changed files with 10965 additions and 7846 deletions

View File

@ -26,6 +26,7 @@
"jotai": "^2.7.2",
"jotai-optics": "^0.3.2",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
"react": "^18.2.0",
"react-arborist": "^3.4.0",
"react-dom": "^18.2.0",

View File

@ -22,6 +22,7 @@ import { io } from "socket.io-client";
import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom.ts";
import { SOCKET_URL } from "@/features/websocket/types";
import AccountPreferences from "@/pages/settings/account/account-preferences.tsx";
import { InviteSignUpForm } from "@/features/auth/components/invite-sign-up-form.tsx";
export default function App() {
const [, setSocket] = useAtom(socketAtom);
@ -60,6 +61,7 @@ export default function App() {
<Route index element={<Welcome />} />
<Route path={"/login"} element={<LoginPage />} />
<Route path={"/signup"} element={<SignUpPage />} />
<Route path={"/invites/:invitationId"} element={<InviteSignUpForm />} />
<Route element={<DashboardLayout />}>
<Route path={"/home"} element={<Home />} />

View File

@ -1,6 +1,12 @@
.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);
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);
}
.container {
box-shadow: rgba(0, 0, 0, 0.07) 0px 2px 45px 4px;
border-radius: 4px;
background: light-dark(var(--mantine-color-body), rgba(0, 0, 0, 0.1));
}

View File

@ -0,0 +1,102 @@
import * as React from "react";
import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form";
import {
Container,
Title,
TextInput,
Button,
PasswordInput,
Box,
Stack,
} from "@mantine/core";
import { useParams, useSearchParams } from "react-router-dom";
import { IRegister } from "@/features/auth/types/auth.types";
import useAuth from "@/features/auth/hooks/use-auth";
import classes from "@/features/auth/components/auth.module.css";
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
const formSchema = z.object({
name: z.string().min(2),
password: z.string().min(8),
});
type FormValues = z.infer<typeof formSchema>;
export function InviteSignUpForm() {
const params = useParams();
const [searchParams] = useSearchParams();
const { data: invitation } = useGetInvitationQuery(params?.invitationId);
const { invitationSignup, isLoading } = useAuth();
useRedirectIfAuthenticated();
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
initialValues: {
name: "",
password: "",
},
});
async function onSubmit(data: IRegister) {
const invitationToken = searchParams.get("token");
await invitationSignup({
invitationId: invitation.id,
name: data.name,
password: data.password,
token: invitationToken,
});
}
if (!invitation) {
return <div></div>;
}
return (
<Container size={420} my={40} className={classes.container}>
<Box p="xl" mt={200}>
<Title order={2} ta="center" fw={500} mb="md">
Complete your signup
</Title>
<Stack align="stretch" justify="center" gap="xl">
<form onSubmit={form.onSubmit(onSubmit)}>
<TextInput
id="name"
type="text"
label="Name"
placeholder="enter your full name"
variant="filled"
{...form.getInputProps("name")}
/>
<TextInput
id="email"
type="email"
label="Email"
value={invitation.email}
disabled
variant="filled"
mt="md"
/>
<PasswordInput
label="Password"
placeholder="Your password"
variant="filled"
mt="md"
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
Sign Up
</Button>
</form>
</Stack>
</Box>
</Container>
);
}

View File

@ -1,37 +1,41 @@
import * as React from 'react';
import * as z from 'zod';
import * as React from "react";
import * as z from "zod";
import { useForm, zodResolver } from '@mantine/form';
import useAuth from '@/features/auth/hooks/use-auth';
import { ILogin } from '@/features/auth/types/auth.types';
import { useForm, zodResolver } from "@mantine/form";
import useAuth from "@/features/auth/hooks/use-auth";
import { ILogin } from "@/features/auth/types/auth.types";
import {
Container,
Title,
Anchor,
Paper,
TextInput,
Button,
Text,
PasswordInput,
} from '@mantine/core';
import { Link } from 'react-router-dom';
import classes from './auth.module.css';
Box,
} from "@mantine/core";
import { Link, useNavigate } from "react-router-dom";
import classes from "./auth.module.css";
import { useEffect, useState } from "react";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
const formSchema = z.object({
email: z
.string({ required_error: 'email is required' })
.email({ message: 'Invalid email address' }),
password: z.string({ required_error: 'password is required' }),
.string()
.min(1, { message: "email is required" })
.email({ message: "Invalid email address" }),
password: z.string().min(1, { message: "Password is required" }),
});
export function LoginForm() {
const { signIn, isLoading } = useAuth();
useRedirectIfAuthenticated();
const form = useForm<ILogin>({
validate: zodResolver(formSchema),
initialValues: {
email: '',
password: '',
email: "",
password: "",
},
});
@ -40,9 +44,9 @@ export function LoginForm() {
}
return (
<Container size={420} my={40}>
<Paper shadow="md" p="lg" radius="md" mt={200}>
<Title ta="center" fw={800}>
<Container size={420} my={40} className={classes.container}>
<Box p="xl" mt={200}>
<Title order={2} ta="center" fw={500} mb="md">
Login
</Title>
@ -52,16 +56,16 @@ export function LoginForm() {
type="email"
label="Email"
placeholder="email@example.com"
required
{...form.getInputProps('email')}
variant="filled"
{...form.getInputProps("email")}
/>
<PasswordInput
label="Password"
placeholder="Your password"
required
variant="filled"
mt="md"
{...form.getInputProps('password')}
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
Sign In
@ -69,13 +73,12 @@ export function LoginForm() {
</form>
<Text c="dimmed" size="sm" ta="center" mt="sm">
Don't have an account yet?{' '}
Don't have an account yet?{" "}
<Anchor size="sm" component={Link} to="/signup">
Create account
</Anchor>
</Text>
</Paper>
</Box>
</Container>
);
}

View File

@ -1,36 +1,40 @@
import * as React from 'react';
import * as z from 'zod';
import * as React from "react";
import * as z from "zod";
import { useForm, zodResolver } from '@mantine/form';
import { useForm, zodResolver } from "@mantine/form";
import {
Container,
Title,
Anchor,
Paper,
TextInput,
Button,
Text,
PasswordInput,
} from '@mantine/core';
import { Link } from 'react-router-dom';
import { IRegister } from '@/features/auth/types/auth.types';
import useAuth from '@/features/auth/hooks/use-auth';
Box,
} from "@mantine/core";
import { Link } from "react-router-dom";
import { IRegister } from "@/features/auth/types/auth.types";
import useAuth from "@/features/auth/hooks/use-auth";
import classes from "@/features/auth/components/auth.module.css";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
const formSchema = z.object({
email: z
.string({ required_error: 'email is required' })
.email({ message: 'Invalid email address' }),
password: z.string({ required_error: 'password is required' }),
.string()
.min(1, { message: "email is required" })
.email({ message: "Invalid email address" }),
password: z.string().min(1, { message: "Password is required" }),
});
export function SignUpForm() {
const { signUp, isLoading } = useAuth();
useRedirectIfAuthenticated();
const form = useForm<IRegister>({
validate: zodResolver(formSchema),
initialValues: {
email: '',
password: '',
email: "",
password: "",
},
});
@ -39,40 +43,41 @@ export function SignUpForm() {
}
return (
<Container size={420} my={40}>
<Title ta="center" fw={800}>
Create an account
</Title>
<Text c="dimmed" size="sm" ta="center" mt={5}>
Already have an account?{' '}
<Anchor size="sm" component={Link} to="/login">
Login
</Anchor>
</Text>
<Container size={420} my={40} className={classes.container}>
<Box p="xl" mt={200}>
<Title order={2} ta="center" fw={500} mb="md">
Create an account
</Title>
<Paper shadow="md" p={30} mt={30} radius="md">
<form onSubmit={form.onSubmit(onSubmit)}>
<TextInput
id="email"
type="email"
label="Email"
placeholder="email@example.com"
required
{...form.getInputProps('email')}
variant="filled"
{...form.getInputProps("email")}
/>
<PasswordInput
label="Password"
placeholder="Your password"
required
variant="filled"
mt="md"
{...form.getInputProps('password')}
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
Sign Up
</Button>
</form>
</Paper>
<Text c="dimmed" size="sm" ta="center" mt="sm">
Already have an account?{" "}
<Anchor size="sm" component={Link} to="/login">
Login
</Anchor>
</Text>
</Box>
</Container>
);
}

View File

@ -1,11 +1,15 @@
import { useState } from 'react';
import { login, register } from '@/features/auth/services/auth-service';
import { useNavigate } from 'react-router-dom';
import { useAtom } from 'jotai';
import { authTokensAtom } from '@/features/auth/atoms/auth-tokens-atom';
import { currentUserAtom } from '@/features/user/atoms/current-user-atom';
import { ILogin, IRegister } from '@/features/auth/types/auth.types';
import { notifications } from '@mantine/notifications';
import { useState } from "react";
import { login, register } from "@/features/auth/services/auth-service";
import { useNavigate } from "react-router-dom";
import { useAtom } from "jotai";
import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { ILogin, IRegister } from "@/features/auth/types/auth.types";
import { notifications } from "@mantine/notifications";
import { IAcceptInvite } from "@/features/workspace/types/workspace.types.ts";
import { acceptInvitation } from "@/features/workspace/services/workspace-service.ts";
import Cookies from "js-cookie";
import { jwtDecode } from "jwt-decode";
export default function useAuth() {
const [isLoading, setIsLoading] = useState(false);
@ -22,12 +26,13 @@ export default function useAuth() {
setIsLoading(false);
setAuthToken(res.tokens);
navigate('/home');
navigate("/home");
} catch (err) {
console.log(err);
setIsLoading(false);
notifications.show({
message: err.response?.data.message,
color: 'red',
color: "red",
});
}
};
@ -41,24 +46,72 @@ export default function useAuth() {
setAuthToken(res.tokens);
navigate('/home');
navigate("/home");
} catch (err) {
setIsLoading(false);
notifications.show({
message: err.response?.data.message,
color: 'red',
color: "red",
});
}
};
const hasTokens = () => {
const handleInvitationSignUp = async (data: IAcceptInvite) => {
setIsLoading(true);
try {
const res = await acceptInvitation(data);
setIsLoading(false);
console.log(res);
setAuthToken(res.tokens);
navigate("/home");
} catch (err) {
setIsLoading(false);
notifications.show({
message: err.response?.data.message,
color: "red",
});
}
};
const handleIsAuthenticated = async () => {
if (!authToken) {
return false;
}
try {
const accessToken = authToken.accessToken;
const payload = jwtDecode(accessToken);
// true if jwt is active
const now = Date.now().valueOf() / 1000;
return payload.exp >= now;
} catch (err) {
console.log("invalid jwt token", err);
return false;
}
};
const hasTokens = (): boolean => {
return !!authToken;
};
const handleLogout = async () => {
setAuthToken(null);
setCurrentUser(null);
Cookies.remove("authTokens");
navigate("/login");
};
return { signIn: handleSignIn, signUp: handleSignUp, isLoading, hasTokens };
return {
signIn: handleSignIn,
signUp: handleSignUp,
invitationSignup: handleInvitationSignUp,
isAuthenticated: handleIsAuthenticated,
logout: handleLogout,
hasTokens,
isLoading,
};
}

View File

@ -0,0 +1,19 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import useAuth from "@/features/auth/hooks/use-auth.ts";
export function useRedirectIfAuthenticated() {
const { isAuthenticated } = useAuth();
const navigate = useNavigate();
useEffect(() => {
const checkAuth = async () => {
const validAuth = await isAuthenticated();
if (validAuth) {
navigate("/home");
}
};
checkAuth();
}, [isAuthenticated]);
}

View File

@ -4,6 +4,7 @@ export interface ILogin {
}
export interface IRegister {
name?: string;
email: string;
password: string;
}

View File

@ -1,29 +1,31 @@
import { Group, Text, Box } from '@mantine/core';
import React, { useState } from 'react';
import classes from './comment.module.css';
import { useAtomValue } from 'jotai';
import { timeAgo } from '@/lib/time';
import CommentEditor from '@/features/comment/components/comment-editor';
import { pageEditorAtom } from '@/features/editor/atoms/editor-atoms';
import CommentActions from '@/features/comment/components/comment-actions';
import CommentMenu from '@/features/comment/components/comment-menu';
import { useHover } from '@mantine/hooks';
import { useDeleteCommentMutation, useUpdateCommentMutation } from '@/features/comment/queries/comment-query';
import { IComment } from '@/features/comment/types/comment.types';
import { UserAvatar } from '@/components/ui/user-avatar';
import { Group, Text, Box } from "@mantine/core";
import React, { useState } from "react";
import classes from "./comment.module.css";
import { useAtomValue } from "jotai";
import { timeAgo } from "@/lib/time";
import CommentEditor from "@/features/comment/components/comment-editor";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
import CommentActions from "@/features/comment/components/comment-actions";
import CommentMenu from "@/features/comment/components/comment-menu";
import { useHover } from "@mantine/hooks";
import {
useDeleteCommentMutation,
useUpdateCommentMutation,
} from "@/features/comment/queries/comment-query";
import { IComment } from "@/features/comment/types/comment.types";
import { UserAvatar } from "@/components/ui/user-avatar";
interface CommentListItemProps {
comment: IComment;
}
function CommentListItem({ comment }: CommentListItemProps) {
const { hovered, ref } = useHover();
const [isEditing, setIsEditing] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const editor = useAtomValue(pageEditorAtom);
const [content, setContent] = useState(comment.content);
const [content, setContent] = useState<string>(comment.content);
const updateCommentMutation = useUpdateCommentMutation();
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
@ -31,13 +33,13 @@ function CommentListItem({ comment }: CommentListItemProps) {
try {
setIsLoading(true);
const commentToUpdate = {
id: comment.id,
commentId: comment.id,
content: JSON.stringify(content),
};
await updateCommentMutation.mutateAsync(commentToUpdate);
setIsEditing(false);
} catch (error) {
console.error('Failed to update comment:', error);
console.error("Failed to update comment:", error);
} finally {
setIsLoading(false);
}
@ -48,7 +50,7 @@ function CommentListItem({ comment }: CommentListItemProps) {
await deleteCommentMutation.mutateAsync(comment.id);
editor?.commands.unsetComment(comment.id);
} catch (error) {
console.error('Failed to delete comment:', error);
console.error("Failed to delete comment:", error);
}
}
@ -59,20 +61,28 @@ function CommentListItem({ comment }: CommentListItemProps) {
return (
<Box ref={ref} pb="xs">
<Group>
<UserAvatar color="blue" size="sm" avatarUrl={comment.creator.avatarUrl}
name={comment.creator.name}
<UserAvatar
color="blue"
size="sm"
avatarUrl={comment.creator.avatarUrl}
name={comment.creator.name}
/>
<div style={{ flex: 1 }}>
<Group justify="space-between" wrap="nowrap">
<Text size="sm" fw={500} lineClamp={1}>{comment.creator.name}</Text>
<Text size="sm" fw={500} lineClamp={1}>
{comment.creator.name}
</Text>
<div style={{ visibility: hovered ? 'visible' : 'hidden' }}>
<div style={{ visibility: hovered ? "visible" : "hidden" }}>
{/*!comment.parentCommentId && (
<ResolveComment commentId={comment.id} pageId={comment.pageId} resolvedAt={comment.resolvedAt} />
)*/}
<CommentMenu onEditComment={handleEditToggle} onDeleteComment={handleDeleteComment} />
<CommentMenu
onEditComment={handleEditToggle}
onDeleteComment={handleDeleteComment}
/>
</div>
</Group>
@ -83,26 +93,30 @@ function CommentListItem({ comment }: CommentListItemProps) {
</Group>
<div>
{!comment.parentCommentId && comment?.selection &&
{!comment.parentCommentId && comment?.selection && (
<Box className={classes.textSelection}>
<Text size="sm">{comment?.selection}</Text>
</Box>
}
)}
{
!isEditing ?
(<CommentEditor defaultContent={content} editable={false} />)
:
(<>
<CommentEditor defaultContent={content} editable={true} onUpdate={(newContent) => setContent(newContent)}
autofocus={true} />
<CommentActions onSave={handleUpdateComment} isLoading={isLoading} />
</>)
}
{!isEditing ? (
<CommentEditor defaultContent={content} editable={false} />
) : (
<>
<CommentEditor
defaultContent={content}
editable={true}
onUpdate={(newContent) => setContent(newContent)}
autofocus={true}
/>
<CommentActions
onSave={handleUpdateComment}
isLoading={isLoading}
/>
</>
)}
</div>
</Box>
);
}

View File

@ -18,7 +18,7 @@ function CommentList() {
data: comments,
isLoading: isCommentsLoading,
isError,
} = useCommentsQuery(pageId);
} = useCommentsQuery({ pageId, limit: 100 });
const [isLoading, setIsLoading] = useState(false);
const createCommentMutation = useCreateCommentMutation();

View File

@ -12,6 +12,7 @@ import {
updateComment,
} from "@/features/comment/services/comment-service";
import {
ICommentParams,
IComment,
IResolveComment,
} from "@/features/comment/types/comment.types";
@ -21,12 +22,13 @@ import { IPagination } from "@/lib/types.ts";
export const RQ_KEY = (pageId: string) => ["comments", pageId];
export function useCommentsQuery(
pageId: string,
params: ICommentParams,
): UseQueryResult<IPagination<IComment>, Error> {
return useQuery({
queryKey: RQ_KEY(pageId),
queryFn: () => getPageComments(pageId),
enabled: !!pageId,
// eslint-disable-next-line @tanstack/query/exhaustive-deps
queryKey: RQ_KEY(params.pageId),
queryFn: () => getPageComments(params),
enabled: !!params.pageId,
});
}
@ -36,13 +38,14 @@ export function useCreateCommentMutation() {
return useMutation<IComment, Error, Partial<IComment>>({
mutationFn: (data) => createComment(data),
onSuccess: (data) => {
const newComment = data;
let comments = queryClient.getQueryData(RQ_KEY(data.pageId));
if (comments) {
// comments = prevComments => [...prevComments, newComment];
//queryClient.setQueryData(RQ_KEY(data.pageId), comments);
}
//const newComment = data;
// let comments = queryClient.getQueryData(RQ_KEY(data.pageId));
// if (comments) {
//comments = prevComments => [...prevComments, newComment];
//queryClient.setQueryData(RQ_KEY(data.pageId), comments);
//}
queryClient.invalidateQueries({ queryKey: RQ_KEY(data.pageId) });
notifications.show({ message: "Comment created successfully" });
},
onError: (error) => {
@ -69,11 +72,21 @@ export function useDeleteCommentMutation(pageId?: string) {
return useMutation({
mutationFn: (commentId: string) => deleteComment(commentId),
onSuccess: (data, variables) => {
let comments = queryClient.getQueryData(RQ_KEY(pageId)) as IComment[];
if (comments) {
// comments = comments.filter(comment => comment.id !== variables);
// queryClient.setQueryData(RQ_KEY(pageId), comments);
const comments = queryClient.getQueryData(
RQ_KEY(pageId),
) as IPagination<IComment>;
if (comments && comments.items) {
const commentId = variables;
const newComments = comments.items.filter(
(comment) => comment.id !== commentId,
);
queryClient.setQueryData(RQ_KEY(pageId), {
...comments,
items: newComments,
});
}
notifications.show({ message: "Comment deleted successfully" });
},
onError: (error) => {
@ -92,6 +105,7 @@ export function useResolveCommentMutation() {
RQ_KEY(data.pageId),
) as IComment[];
/*
if (currentComments) {
const updatedComments = currentComments.map((comment) =>
comment.id === variables.commentId
@ -99,7 +113,7 @@ export function useResolveCommentMutation() {
: comment,
);
queryClient.setQueryData(RQ_KEY(data.pageId), updatedComments);
}
}*/
notifications.show({ message: "Comment resolved successfully" });
},

View File

@ -1,5 +1,6 @@
import api from "@/lib/api-client";
import {
ICommentParams,
IComment,
IResolveComment,
} from "@/features/comment/types/comment.types";
@ -9,30 +10,30 @@ export async function createComment(
data: Partial<IComment>,
): Promise<IComment> {
const req = await api.post<IComment>("/comments/create", data);
return req.data as IComment;
return req.data;
}
export async function resolveComment(data: IResolveComment): Promise<IComment> {
const req = await api.post<IComment>(`/comments/resolve`, data);
return req.data as IComment;
return req.data;
}
export async function updateComment(
data: Partial<IComment>,
): Promise<IComment> {
const req = await api.post<IComment>(`/comments/update`, data);
return req.data as IComment;
return req.data;
}
export async function getCommentById(commentId: string): Promise<IComment> {
const req = await api.post<IComment>("/comments/info", { commentId });
return req.data as IComment;
return req.data;
}
export async function getPageComments(
pageId: string,
data: ICommentParams,
): Promise<IPagination<IComment>> {
const req = await api.post("/comments", { pageId });
const req = await api.post("/comments", data);
return req.data;
}

View File

@ -1,4 +1,5 @@
import { IUser } from '@/features/user/types/user.types';
import { IUser } from "@/features/user/types/user.types";
import { QueryParams } from "@/lib/types.ts";
export interface IComment {
id: string;
@ -14,7 +15,7 @@ export interface IComment {
createdAt: Date;
editedAt?: Date;
deletedAt?: Date;
creator: IUser
creator: IUser;
}
export interface ICommentData {
@ -29,3 +30,7 @@ export interface IResolveComment {
commentId: string;
resolved: boolean;
}
export interface ICommentParams extends QueryParams {
pageId: string;
}

View File

@ -1,25 +1,28 @@
.bubbleMenu {
display: flex;
width: fit-content;
border-radius: 2px;
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-8));
display: flex;
width: fit-content;
border-radius: 2px;
border: 1px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-8));
.active {
color: var(--mantine-color-blue-8);
}
.active {
color: light-dark(var(--mantine-color-blue-8), var(--mantine-color-gray-5));
}
.colorButton {
border: none;
}
.colorButton::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 1px;
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-8));
}
.colorButton {
border: none;
}
.colorButton::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 1px;
background-color: light-dark(
var(--mantine-color-gray-3),
var(--mantine-color-gray-8)
);
}
}

View File

@ -4,7 +4,7 @@ import {
isNodeSelection,
useEditor,
} from "@tiptap/react";
import { FC, useState } from "react";
import { FC, useEffect, useRef, useState } from "react";
import {
IconBold,
IconCode,
@ -37,8 +37,13 @@ type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
};
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const [, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
const showCommentPopupRef = useRef(showCommentPopup);
useEffect(() => {
showCommentPopupRef.current = showCommentPopup;
}, [showCommentPopup]);
const items: BubbleMenuItem[] = [
{
@ -94,9 +99,11 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const { empty } = selection;
if (
props.editor.isActive("image") ||
!editor.isEditable ||
editor.isActive("image") ||
empty ||
isNodeSelection(selection)
isNodeSelection(selection) ||
showCommentPopupRef?.current
) {
return false;
}
@ -117,47 +124,43 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
return (
<BubbleMenu {...bubbleMenuProps} className={classes.bubbleMenu}>
<NodeSelector
editor={props.editor}
isOpen={isNodeSelectorOpen}
setIsOpen={() => {
setIsNodeSelectorOpen(!isNodeSelectorOpen);
setIsColorSelectorOpen(false);
setIsLinkSelectorOpen(false);
}}
/>
<BubbleMenu {...bubbleMenuProps}>
<div className={classes.bubbleMenu}>
<NodeSelector
editor={props.editor}
isOpen={isNodeSelectorOpen}
setIsOpen={() => {
setIsNodeSelectorOpen(!isNodeSelectorOpen);
}}
/>
<ActionIcon.Group>
{items.map((item, index) => (
<Tooltip key={index} label={item.name} withArrow>
<ActionIcon
key={index}
variant="default"
size="lg"
radius="0"
aria-label={item.name}
className={clsx({ [classes.active]: item.isActive() })}
style={{ border: "none" }}
onClick={item.command}
>
<item.icon style={{ width: rem(16) }} stroke={2} />
</ActionIcon>
</Tooltip>
))}
</ActionIcon.Group>
<ActionIcon.Group>
{items.map((item, index) => (
<Tooltip key={index} label={item.name} withArrow>
<ActionIcon
key={index}
variant="default"
size="lg"
radius="0"
aria-label={item.name}
className={clsx({ [classes.active]: item.isActive() })}
style={{ border: "none" }}
onClick={item.command}
>
<item.icon style={{ width: rem(16) }} stroke={2} />
</ActionIcon>
</Tooltip>
))}
</ActionIcon.Group>
<ColorSelector
editor={props.editor}
isOpen={isColorSelectorOpen}
setIsOpen={() => {
setIsColorSelectorOpen(!isColorSelectorOpen);
setIsNodeSelectorOpen(false);
setIsLinkSelectorOpen(false);
}}
/>
<ColorSelector
editor={props.editor}
isOpen={isColorSelectorOpen}
setIsOpen={() => {
setIsColorSelectorOpen(!isColorSelectorOpen);
}}
/>
<Tooltip label={commentItem.name} withArrow>
<ActionIcon
variant="default"
size="lg"
@ -168,7 +171,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
>
<IconMessage style={{ width: rem(16) }} stroke={2} />
</ActionIcon>
</Tooltip>
</div>
</BubbleMenu>
);
};

View File

@ -1,6 +1,6 @@
import { Dispatch, FC, SetStateAction } from "react";
import { IconCheck, IconChevronDown } from "@tabler/icons-react";
import { Button, Popover, rem, ScrollArea, Text } from "@mantine/core";
import { Button, Popover, rem, ScrollArea, Text, Tooltip } from "@mantine/core";
import classes from "./bubble-menu.module.css";
import { useEditor } from "@tiptap/react";
@ -110,17 +110,22 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
return (
<Popover width={200} opened={isOpen} withArrow>
<Popover.Target>
<Button
variant="default"
radius="0"
leftSection="A"
rightSection={<IconChevronDown size={16} />}
className={classes.colorButton}
style={{
color: activeColorItem?.color,
}}
onClick={() => setIsOpen(!isOpen)}
/>
<Tooltip label="text color" withArrow>
<Button
variant="default"
radius="0"
rightSection={<IconChevronDown size={16} />}
className={classes.colorButton}
style={{
color: activeColorItem?.color,
paddingLeft: "8px",
paddingRight: "8px",
}}
onClick={() => setIsOpen(!isOpen)}
>
A
</Button>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
@ -159,37 +164,6 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
</Button>
))}
</Button.Group>
<Text span c="dimmed" inherit>
BACKGROUND
</Text>
<Button.Group orientation="vertical">
{HIGHLIGHT_COLORS.map(({ name, color }, index) => (
<Button
key={index}
variant="default"
leftSection={
<span style={{ padding: "4px", background: color }}>A</span>
}
justify="left"
fullWidth
rightSection={
editor.isActive("highlight", { color }) && (
<IconCheck style={{ width: rem(16) }} />
)
}
onClick={() => {
editor.commands.unsetHighlight();
name !== "Default" && editor.commands.setHighlight({ color });
setIsOpen(false);
}}
style={{ border: "none" }}
>
{name}
</Button>
))}
</Button.Group>
</ScrollArea.Autosize>
</Popover.Dropdown>
</Popover>

View File

@ -0,0 +1,2 @@
export * from "./is-custom-node-selected";
export * from "./is-text-selected";

View File

@ -0,0 +1,11 @@
import { Editor } from "@tiptap/react";
import TiptapLink from "@tiptap/extension-link";
import { CodeBlock } from "@tiptap/extension-code-block";
export const isCustomNodeSelected = (editor: Editor, node: HTMLElement) => {
const customNodes = [CodeBlock.name, TiptapLink.name];
return customNodes.some((type) => editor.isActive(type));
};
export default isCustomNodeSelected;

View File

@ -0,0 +1,26 @@
import { isTextSelection } from "@tiptap/core";
import { Editor } from "@tiptap/react";
export const isTextSelected = ({ editor }: { editor: Editor }) => {
const {
state: {
doc,
selection,
selection: { empty, from, to },
},
} = editor;
// Sometime check for `empty` is not enough.
// Doubleclick an empty paragraph returns a node size of 2.
// So we check also for an empty text size.
const isEmptyTextBlock =
!doc.textBetween(from, to).length && isTextSelection(selection);
if (empty || isEmptyTextBlock || !editor.isEditable) {
return false;
}
return true;
};
export default isTextSelected;

View File

@ -8,6 +8,8 @@ import { IconUsersGroup } from "@tabler/icons-react";
interface MultiGroupSelectProps {
onChange: (value: string[]) => void;
label?: string;
description?: string;
mt?: string;
}
const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
@ -21,7 +23,12 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
</Group>
);
export function MultiGroupSelect({ onChange, label }: MultiGroupSelectProps) {
export function MultiGroupSelect({
onChange,
label,
description,
mt,
}: MultiGroupSelectProps) {
const [searchValue, setSearchValue] = useState("");
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
const { data: groups, isLoading } = useGetGroupsQuery({
@ -56,8 +63,10 @@ export function MultiGroupSelect({ onChange, label }: MultiGroupSelectProps) {
renderOption={renderMultiSelectOption}
hidePickedOptions
maxDropdownHeight={300}
description={description}
label={label || "Add groups"}
placeholder="Search for groups"
mt={mt}
searchable
searchValue={searchValue}
onSearchChange={setSearchValue}

View File

@ -1,18 +1,11 @@
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
import { useAtom } from "jotai/index";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import {
Accordion,
AccordionControlProps,
ActionIcon,
Center,
rem,
Tooltip,
} from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { Box } from "@mantine/core";
import { IconNotes } from "@tabler/icons-react";
import React from "react";
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
import SpaceTree from "@/features/page/tree/components/space-tree.tsx";
import { TreeCollapse } from "@/features/page/tree/components/tree-collapse.tsx";
export default function SpaceContent() {
const [currentUser] = useAtom(currentUserAtom);
@ -24,42 +17,15 @@ export default function SpaceContent() {
return (
<>
<Accordion
chevronPosition="left"
maw={400}
mx="auto"
defaultValue={space.id}
>
<Accordion.Item key={space.id} value={space.id}>
<AccordionControl>{space.name}</AccordionControl>
<Accordion.Panel>
<SpaceTree spaceId={space.id} />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<Box p="sm" mx="auto">
<TreeCollapse
initiallyOpened={true}
icon={IconNotes}
label={space.name}
>
<SpaceTree spaceId={space.id} />
</TreeCollapse>
</Box>
</>
);
}
function AccordionControl(props: AccordionControlProps) {
const [tree] = useAtom(treeApiAtom);
function handleCreatePage() {
//todo: create at the bottom
tree?.create({ parentId: null, type: "internal", index: 0 });
}
return (
<Center>
<Accordion.Control {...props} />
{/* <ActionIcon size="lg" variant="subtle" color="gray">
<IconDots size="1rem" />
</ActionIcon> */}
<Tooltip label="Create page" withArrow position="right">
<ActionIcon variant="default" size={18} onClick={handleCreatePage}>
<IconPlus style={{ width: rem(12), height: rem(12) }} stroke={1.5} />
</ActionIcon>
</Tooltip>
</Center>
);
}

View File

@ -0,0 +1,27 @@
.control {
font-weight: 500;
display: block;
width: 100%;
padding: var(--mantine-spacing-xs) var(--mantine-spacing-xs);
color: var(--mantine-color-text);
font-size: var(--mantine-font-size-sm);
@mixin hover {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
}
}
.item {
display: block;
text-decoration: none;
padding: var(--mantine-spacing-xs) var(--mantine-spacing-md);
padding-left: 4px;
margin-left: var(--mantine-spacing-sm);
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
}
.chevron {
transition: transform 200ms ease;
}

View File

@ -0,0 +1,59 @@
import React, { ReactNode, useState } from "react";
import {
Group,
Box,
Collapse,
ThemeIcon,
UnstyledButton,
rem,
} from "@mantine/core";
import { IconChevronRight } from "@tabler/icons-react";
import classes from "./tree-collapse.module.css";
interface LinksGroupProps {
icon?: React.FC<any>;
label: string;
initiallyOpened?: boolean;
children: ReactNode;
}
export function TreeCollapse({
icon: Icon,
label,
initiallyOpened,
children,
}: LinksGroupProps) {
const [opened, setOpened] = useState(initiallyOpened || false);
return (
<>
<UnstyledButton
onClick={() => setOpened((o) => !o)}
className={classes.control}
>
<Group justify="space-between" gap={0}>
<Box style={{ display: "flex", alignItems: "center" }}>
<ThemeIcon variant="light" size={20}>
<Icon style={{ width: rem(18), height: rem(18) }} />
</ThemeIcon>
<Box ml="md">{label}</Box>
</Box>
<IconChevronRight
className={classes.chevron}
stroke={1.5}
style={{
width: rem(16),
height: rem(16),
transform: opened ? "rotate(90deg)" : "none",
}}
/>
</Group>
</UnstyledButton>
<Collapse in={opened}>
<div className={classes.item}>{children}</div>
</Collapse>
</>
);
}

View File

@ -1,17 +1,8 @@
import {
Modal,
Tabs,
rem,
Group,
Divider,
Text,
ScrollArea,
} from "@mantine/core";
import { Modal, Tabs, rem, Group, Divider, ScrollArea } from "@mantine/core";
import SpaceMembersList from "@/features/space/components/space-members.tsx";
import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx";
import React from "react";
import GroupActionMenu from "@/features/group/components/group-action-menu.tsx";
import { 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";
@ -47,7 +38,7 @@ export default function SpaceSettingsModal({
</Modal.Header>
<Modal.Body>
<div style={{ height: rem("600px") }}>
<Tabs color="gray" defaultValue="members">
<Tabs defaultValue="members">
<Tabs.List>
<Tabs.Tab fw={500} value="general">
Settings

View File

@ -1,12 +1,12 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { getUserInfo } from "@/features/user/services/user-service";
import { getMyInfo } from "@/features/user/services/user-service";
import { ICurrentUser } from "@/features/user/types/user.types";
export default function useCurrentUser(): UseQueryResult<ICurrentUser> {
return useQuery({
queryKey: ["currentUser"],
queryFn: async () => {
return await getUserInfo();
return await getMyInfo();
},
});
}

View File

@ -1,29 +1,23 @@
import api from '@/lib/api-client';
import { ICurrentUser, IUser } from '@/features/user/types/user.types';
import api from "@/lib/api-client";
import { ICurrentUser, IUser } from "@/features/user/types/user.types";
export async function getMe(): Promise<IUser> {
const req = await api.post<IUser>('/users/me');
return req.data as IUser;
}
export async function getUserInfo(): Promise<ICurrentUser> {
const req = await api.post<ICurrentUser>('/users/info');
export async function getMyInfo(): Promise<ICurrentUser> {
const req = await api.post<ICurrentUser>("/users/me");
return req.data as ICurrentUser;
}
export async function updateUser(data: Partial<IUser>): Promise<IUser> {
const req = await api.post<IUser>('/users/update', data);
const req = await api.post<IUser>("/users/update", data);
return req.data as IUser;
}
export async function uploadAvatar(file: File) {
const formData = new FormData();
formData.append('avatar', file);
const req = await api.post('/attachments/upload/avatar', formData, {
formData.append("avatar", file);
const req = await api.post("/attachments/upload/avatar", formData, {
headers: {
'Content-Type': 'multipart/form-data',
}
"Content-Type": "multipart/form-data",
},
});
return req.data;
}

View File

@ -1,7 +1,7 @@
import { useAtom } from 'jotai';
import { currentUserAtom } from '@/features/user/atoms/current-user-atom';
import React, { useEffect } from 'react';
import useCurrentUser from '@/features/user/hooks/use-current-user';
import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import React, { useEffect } from "react";
import useCurrentUser from "@/features/user/hooks/use-current-user";
export function UserProvider({ children }: React.PropsWithChildren) {
const [, setCurrentUser] = useAtom(currentUserAtom);
@ -16,6 +16,7 @@ export function UserProvider({ children }: React.PropsWithChildren) {
if (isLoading) return <></>;
if (error) {
console.error(error);
return <>an error occurred</>;
}

View File

@ -0,0 +1,70 @@
import { Menu, ActionIcon, Text } from "@mantine/core";
import React from "react";
import { IconDots, IconTrash } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import {
useResendInvitationMutation,
useRevokeInvitationMutation,
} from "@/features/workspace/queries/workspace-query.ts";
interface Props {
invitationId: string;
}
export default function InviteActionMenu({ invitationId }: Props) {
const resendInvitationMutation = useResendInvitationMutation();
const revokeInvitationMutation = useRevokeInvitationMutation();
const onResend = async () => {
await resendInvitationMutation.mutateAsync({ invitationId });
};
const onRevoke = async () => {
await revokeInvitationMutation.mutateAsync({ invitationId });
};
const openRevokeModal = () =>
modals.openConfirmModal({
title: "Revoke invitation",
children: (
<Text size="sm">
Are you sure you want to revoke this invitation? The user will not be
able to join the workspace.
</Text>
),
centered: true,
labels: { confirm: "Revoke", cancel: "Don't" },
confirmProps: { color: "red" },
onConfirm: onRevoke,
});
return (
<>
<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={onResend}>Resend invitation</Menu.Item>
<Menu.Divider />
<Menu.Item
c="red"
onClick={openRevokeModal}
leftSection={<IconTrash size={16} stroke={2} />}
>
Revoke invitation
</Menu.Item>
</Menu.Dropdown>
</Menu>
</>
);
}

View File

@ -1,50 +1,87 @@
import { Group, Box, Button, TagsInput, Space, Select } from "@mantine/core";
import { Group, Box, Button, TagsInput, Select } from "@mantine/core";
import WorkspaceInviteSection from "@/features/workspace/components/members/components/workspace-invite-section.tsx";
import React from "react";
import React, { useState } from "react";
import { MultiGroupSelect } from "@/features/group/components/multi-group-select.tsx";
import { UserRole } from "@/lib/types.ts";
import { userRoleData } from "@/features/workspace/types/user-role-data.ts";
import { useCreateInvitationMutation } from "@/features/workspace/queries/workspace-query.ts";
import { useNavigate } from "react-router-dom";
enum UserRole {
OWNER = "Owner",
ADMIN = "Admin",
MEMBER = "Member",
interface Props {
onClose: () => void;
}
export function WorkspaceInviteForm({ onClose }: Props) {
const [emails, setEmails] = useState<string[]>([]);
const [role, setRole] = useState<string | null>(UserRole.MEMBER);
const [groupIds, setGroupIds] = useState<string[]>([]);
const createInvitationMutation = useCreateInvitationMutation();
const navigate = useNavigate();
export function WorkspaceInviteForm() {
function handleSubmit(data) {
console.log(data);
async function handleSubmit() {
const validEmails = emails.filter((email) => {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email);
});
await createInvitationMutation.mutateAsync({
role: role.toLowerCase(),
emails: validEmails,
groupIds: groupIds,
});
onClose();
navigate("?tab=invites");
}
const handleGroupSelect = (value: string[]) => {
setGroupIds(value);
};
return (
<>
<Box maw="500" mx="auto">
<WorkspaceInviteSection />
<Space h="md" />
{/*<WorkspaceInviteSection /> */}
<TagsInput
description="Enter valid email addresses separated by comma or space"
label="Invite from email"
mt="sm"
description="Enter valid email addresses separated by comma or space [max: 50]"
label="Invite by email"
placeholder="enter valid emails addresses"
variant="filled"
splitChars={[",", " "]}
maxDropdownHeight={200}
maxTags={50}
onChange={setEmails}
/>
<Space h="md" />
<Select
mt="sm"
description="Select role to assign to all invited members"
label="Select role"
placeholder="Pick a role"
placeholder="Choose a role"
variant="filled"
data={Object.values(UserRole)}
data={userRoleData.filter((role) => role.value !== UserRole.OWNER)}
defaultValue={UserRole.MEMBER}
allowDeselect={false}
checkIconPosition="right"
onChange={setRole}
/>
<Group justify="center" mt="md">
<Button>Send invitation</Button>
<MultiGroupSelect
mt="sm"
description="Invited members will be granted access to spaces the groups can access"
label={"Add to groups"}
onChange={handleGroupSelect}
/>
<Group justify="flex-end" mt="md">
<Button
onClick={handleSubmit}
loading={createInvitationMutation.isPending}
>
Send invitation
</Button>
</Group>
</Box>
</>

View File

@ -10,7 +10,7 @@ export default function WorkspaceInviteModal() {
<Button onClick={open}>Invite members</Button>
<Modal
size="600"
size="550"
opened={opened}
onClose={close}
title="Invite new members"
@ -19,7 +19,7 @@ export default function WorkspaceInviteModal() {
<Divider size="xs" mb="xs" />
<ScrollArea h="80%">
<WorkspaceInviteForm />
<WorkspaceInviteForm onClose={close} />
</ScrollArea>
</Modal>
</>

View File

@ -0,0 +1,62 @@
import { Group, Table, Avatar, Text, Badge, Alert } from "@mantine/core";
import { useWorkspaceInvitationsQuery } from "@/features/workspace/queries/workspace-query.ts";
import React from "react";
import { getUserRoleLabel } from "@/features/workspace/types/user-role-data.ts";
import InviteActionMenu from "@/features/workspace/components/members/components/invite-action-menu.tsx";
import { IconInfoCircle } from "@tabler/icons-react";
import { format } from "date-fns";
export default function WorkspaceInvitesTable() {
const { data, isLoading } = useWorkspaceInvitationsQuery({
limit: 100,
});
return (
<>
<Alert variant="light" color="blue" icon={<IconInfoCircle />}>
Invited members who are yet to accept their invitation will appear here.
</Alert>
{data && (
<>
<Table verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Email</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th>Date</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data?.items.map((invitation, index) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm">
<Avatar src={invitation.email} />
<div>
<Text fz="sm" fw={500}>
{invitation.email}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>{getUserRoleLabel(invitation.role)}</Table.Td>
<Table.Td>
{format(invitation.createdAt, "MM/dd/yyyy")}
</Table.Td>
<Table.Td>
<InviteActionMenu invitationId={invitation.id} />
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</>
)}
</>
);
}

View File

@ -12,7 +12,7 @@ import {
} from "@/features/workspace/types/user-role-data.ts";
export default function WorkspaceMembersTable() {
const { data, isLoading } = useWorkspaceMembersQuery();
const { data, isLoading } = useWorkspaceMembersQuery({ limit: 100 });
const changeMemberRoleMutation = useChangeMemberRoleMutation();
const handleRoleChange = async (

View File

@ -6,12 +6,21 @@ import {
} from "@tanstack/react-query";
import {
changeMemberRole,
getInvitationById,
getPendingInvitations,
getWorkspace,
getWorkspaceMembers,
createInvitation,
resendInvitation,
revokeInvitation,
} from "@/features/workspace/services/workspace-service";
import { QueryParams } from "@/lib/types.ts";
import { IPagination, QueryParams } from "@/lib/types.ts";
import { notifications } from "@mantine/notifications";
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
import {
ICreateInvite,
IInvitation,
IWorkspace,
} from "@/features/workspace/types/workspace.types.ts";
export function useWorkspace(): UseQueryResult<IWorkspace, Error> {
return useQuery({
@ -44,3 +53,85 @@ export function useChangeMemberRoleMutation() {
},
});
}
export function useWorkspaceInvitationsQuery(
params?: QueryParams,
): UseQueryResult<IPagination<IInvitation>, Error> {
return useQuery({
queryKey: ["invitations", params],
queryFn: () => getPendingInvitations(params),
});
}
export function useCreateInvitationMutation() {
const queryClient = useQueryClient();
return useMutation<void, Error, ICreateInvite>({
mutationFn: (data) => createInvitation(data),
onSuccess: (data, variables) => {
notifications.show({ message: "Invitation successfully" });
// TODO: mutate cache
queryClient.invalidateQueries({
queryKey: ["invitations"],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useResendInvitationMutation() {
return useMutation<
void,
Error,
{
invitationId: string;
}
>({
mutationFn: (data) => resendInvitation(data),
onSuccess: (data, variables) => {
notifications.show({ message: "Invitation mail sent" });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useRevokeInvitationMutation() {
const queryClient = useQueryClient();
return useMutation<
void,
Error,
{
invitationId: string;
}
>({
mutationFn: (data) => revokeInvitation(data),
onSuccess: (data, variables) => {
notifications.show({ message: "Invitation revoked" });
queryClient.invalidateQueries({
queryKey: ["invitations"],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useGetInvitationQuery(
invitationId: string,
): UseQueryResult<any, Error> {
return useQuery({
// eslint-disable-next-line @tanstack/query/exhaustive-deps
queryKey: ["invitations", invitationId],
queryFn: () => getInvitationById({ invitationId }),
enabled: !!invitationId,
});
}

View File

@ -1,11 +1,17 @@
import api from "@/lib/api-client";
import { IUser } from "@/features/user/types/user.types";
import { IWorkspace } from "../types/workspace.types";
import {
ICreateInvite,
IInvitation,
IWorkspace,
IAcceptInvite,
} from "../types/workspace.types";
import { IPagination, QueryParams } from "@/lib/types.ts";
import { ITokenResponse } from "@/features/auth/types/auth.types.ts";
export async function getWorkspace(): Promise<IWorkspace> {
const req = await api.post<IWorkspace>("/workspace/info");
return req.data as IWorkspace;
return req.data;
}
// Todo: fix all paginated types
@ -18,8 +24,7 @@ export async function getWorkspaceMembers(
export async function updateWorkspace(data: Partial<IWorkspace>) {
const req = await api.post<IWorkspace>("/workspace/update", data);
return req.data as IWorkspace;
return req.data;
}
export async function changeMemberRole(data: {
@ -28,3 +33,42 @@ export async function changeMemberRole(data: {
}): Promise<void> {
await api.post("/workspace/members/role", data);
}
export async function getPendingInvitations(
params?: QueryParams,
): Promise<IPagination<IInvitation>> {
const req = await api.post("/workspace/invites", params);
return req.data;
}
export async function createInvitation(data: ICreateInvite) {
const req = await api.post("/workspace/invites/create", data);
return req.data;
}
export async function acceptInvitation(
data: IAcceptInvite,
): Promise<ITokenResponse> {
const req = await api.post("/workspace/invites/accept", data);
return req.data;
}
export async function resendInvitation(data: {
invitationId: string;
}): Promise<void> {
console.log(data);
await api.post("/workspace/invites/resend", data);
}
export async function revokeInvitation(data: {
invitationId: string;
}): Promise<void> {
await api.post("/workspace/invites/revoke", data);
}
export async function getInvitationById(data: {
invitationId: string;
}): Promise<IInvitation> {
const req = await api.post("/workspace/invites/info", data);
return req.data;
}

View File

@ -12,3 +12,25 @@ export interface IWorkspace {
createdAt: Date;
updatedAt: Date;
}
export interface ICreateInvite {
role: string;
emails: string[];
groupIds: string[];
}
export interface IInvitation {
id: string;
role: string;
email: string;
workspaceId: string;
invitedById: string;
createdAt: Date;
}
export interface IAcceptInvite {
invitationId: string;
name: string;
password: string;
token: string;
}

View File

@ -10,7 +10,14 @@ const api: AxiosInstance = axios.create({
api.interceptors.request.use(
(config) => {
const tokenData = Cookies.get("authTokens");
const accessToken = tokenData && JSON.parse(tokenData)?.accessToken;
let accessToken: string;
try {
accessToken = tokenData && JSON.parse(tokenData)?.accessToken;
} catch (err) {
console.log("invalid authTokens:", err.message);
Cookies.remove("authTokens");
}
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;

View File

@ -1,26 +1,61 @@
import WorkspaceInviteSection from "@/features/workspace/components/members/components/workspace-invite-section";
import WorkspaceInviteModal from "@/features/workspace/components/members/components/workspace-invite-modal";
import { Divider, Group, Space, Text } from "@mantine/core";
import { Divider, Group, SegmentedControl, Space, Text } from "@mantine/core";
import WorkspaceMembersTable from "@/features/workspace/components/members/components/workspace-members-table";
import SettingsTitle from "@/components/layouts/settings/settings-title.tsx";
import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import WorkspaceInvitesTable from "@/features/workspace/components/members/components/workspace-invites-table.tsx";
export default function WorkspaceMembers() {
const [segmentValue, setSegmentValue] = useState("members");
const [searchParams] = useSearchParams();
const navigate = useNavigate();
useEffect(() => {
const currentTab = searchParams.get("tab");
if (currentTab === "invites") {
setSegmentValue(currentTab);
}
}, [searchParams.get("tab")]);
const handleSegmentChange = (value: string) => {
setSegmentValue(value);
if (value === "invites") {
navigate(`?tab=${value}`);
} else {
navigate("");
}
};
return (
<>
<SettingsTitle title="Members" />
<WorkspaceInviteSection />
<Divider my="lg" />
{/* <WorkspaceInviteSection /> */}
{/* <Divider my="lg" /> */}
<Group justify="space-between">
<Text fw={500}>Members</Text>
<SegmentedControl
value={segmentValue}
onChange={handleSegmentChange}
data={[
{ label: "Members", value: "members" },
{ label: "Pending", value: "invites" },
]}
withItemsBorders={false}
/>
<WorkspaceInviteModal />
</Group>
<Space h="lg" />
<WorkspaceMembersTable />
{segmentValue === "invites" ? (
<WorkspaceInvitesTable />
) : (
<WorkspaceMembersTable />
)}
</>
);
}