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
+3
View File
@@ -1,3 +1,6 @@
APP_URL=http://localhost
APP_SECRET=
PORT=3000 PORT=3000
DEBUG_MODE=true DEBUG_MODE=true
NODE_ENV=production NODE_ENV=production
+1
View File
@@ -26,6 +26,7 @@
"jotai": "^2.7.2", "jotai": "^2.7.2",
"jotai-optics": "^0.3.2", "jotai-optics": "^0.3.2",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-arborist": "^3.4.0", "react-arborist": "^3.4.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
+2
View File
@@ -22,6 +22,7 @@ import { io } from "socket.io-client";
import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom.ts"; import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom.ts";
import { SOCKET_URL } from "@/features/websocket/types"; import { SOCKET_URL } from "@/features/websocket/types";
import AccountPreferences from "@/pages/settings/account/account-preferences.tsx"; import AccountPreferences from "@/pages/settings/account/account-preferences.tsx";
import { InviteSignUpForm } from "@/features/auth/components/invite-sign-up-form.tsx";
export default function App() { export default function App() {
const [, setSocket] = useAtom(socketAtom); const [, setSocket] = useAtom(socketAtom);
@@ -60,6 +61,7 @@ export default function App() {
<Route index element={<Welcome />} /> <Route index element={<Welcome />} />
<Route path={"/login"} element={<LoginPage />} /> <Route path={"/login"} element={<LoginPage />} />
<Route path={"/signup"} element={<SignUpPage />} /> <Route path={"/signup"} element={<SignUpPage />} />
<Route path={"/invites/:invitationId"} element={<InviteSignUpForm />} />
<Route element={<DashboardLayout />}> <Route element={<DashboardLayout />}>
<Route path={"/home"} element={<Home />} /> <Route path={"/home"} element={<Home />} />
@@ -4,3 +4,9 @@
background-size: cover; 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); 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));
}
@@ -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>
);
}
@@ -1,37 +1,41 @@
import * as React from 'react'; import * as React from "react";
import * as z from 'zod'; import * as z from "zod";
import { useForm, zodResolver } from '@mantine/form'; import { useForm, zodResolver } from "@mantine/form";
import useAuth from '@/features/auth/hooks/use-auth'; import useAuth from "@/features/auth/hooks/use-auth";
import { ILogin } from '@/features/auth/types/auth.types'; import { ILogin } from "@/features/auth/types/auth.types";
import { import {
Container, Container,
Title, Title,
Anchor, Anchor,
Paper,
TextInput, TextInput,
Button, Button,
Text, Text,
PasswordInput, PasswordInput,
} from '@mantine/core'; Box,
import { Link } from 'react-router-dom'; } from "@mantine/core";
import classes from './auth.module.css'; 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({ const formSchema = z.object({
email: z email: z
.string({ required_error: 'email is required' }) .string()
.email({ message: 'Invalid email address' }), .min(1, { message: "email is required" })
password: z.string({ required_error: 'password is required' }), .email({ message: "Invalid email address" }),
password: z.string().min(1, { message: "Password is required" }),
}); });
export function LoginForm() { export function LoginForm() {
const { signIn, isLoading } = useAuth(); const { signIn, isLoading } = useAuth();
useRedirectIfAuthenticated();
const form = useForm<ILogin>({ const form = useForm<ILogin>({
validate: zodResolver(formSchema), validate: zodResolver(formSchema),
initialValues: { initialValues: {
email: '', email: "",
password: '', password: "",
}, },
}); });
@@ -40,9 +44,9 @@ export function LoginForm() {
} }
return ( return (
<Container size={420} my={40}> <Container size={420} my={40} className={classes.container}>
<Paper shadow="md" p="lg" radius="md" mt={200}> <Box p="xl" mt={200}>
<Title ta="center" fw={800}> <Title order={2} ta="center" fw={500} mb="md">
Login Login
</Title> </Title>
@@ -52,16 +56,16 @@ export function LoginForm() {
type="email" type="email"
label="Email" label="Email"
placeholder="email@example.com" placeholder="email@example.com"
required variant="filled"
{...form.getInputProps('email')} {...form.getInputProps("email")}
/> />
<PasswordInput <PasswordInput
label="Password" label="Password"
placeholder="Your password" placeholder="Your password"
required variant="filled"
mt="md" mt="md"
{...form.getInputProps('password')} {...form.getInputProps("password")}
/> />
<Button type="submit" fullWidth mt="xl" loading={isLoading}> <Button type="submit" fullWidth mt="xl" loading={isLoading}>
Sign In Sign In
@@ -69,13 +73,12 @@ export function LoginForm() {
</form> </form>
<Text c="dimmed" size="sm" ta="center" mt="sm"> <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"> <Anchor size="sm" component={Link} to="/signup">
Create account Create account
</Anchor> </Anchor>
</Text> </Text>
</Box>
</Paper>
</Container> </Container>
); );
} }
@@ -1,36 +1,40 @@
import * as React from 'react'; import * as React from "react";
import * as z from 'zod'; import * as z from "zod";
import { useForm, zodResolver } from '@mantine/form'; import { useForm, zodResolver } from "@mantine/form";
import { import {
Container, Container,
Title, Title,
Anchor, Anchor,
Paper,
TextInput, TextInput,
Button, Button,
Text, Text,
PasswordInput, PasswordInput,
} from '@mantine/core'; Box,
import { Link } from 'react-router-dom'; } from "@mantine/core";
import { IRegister } from '@/features/auth/types/auth.types'; import { Link } from "react-router-dom";
import useAuth from '@/features/auth/hooks/use-auth'; 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({ const formSchema = z.object({
email: z email: z
.string({ required_error: 'email is required' }) .string()
.email({ message: 'Invalid email address' }), .min(1, { message: "email is required" })
password: z.string({ required_error: 'password is required' }), .email({ message: "Invalid email address" }),
password: z.string().min(1, { message: "Password is required" }),
}); });
export function SignUpForm() { export function SignUpForm() {
const { signUp, isLoading } = useAuth(); const { signUp, isLoading } = useAuth();
useRedirectIfAuthenticated();
const form = useForm<IRegister>({ const form = useForm<IRegister>({
validate: zodResolver(formSchema), validate: zodResolver(formSchema),
initialValues: { initialValues: {
email: '', email: "",
password: '', password: "",
}, },
}); });
@@ -39,40 +43,41 @@ export function SignUpForm() {
} }
return ( return (
<Container size={420} my={40}> <Container size={420} my={40} className={classes.container}>
<Title ta="center" fw={800}> <Box p="xl" mt={200}>
<Title order={2} ta="center" fw={500} mb="md">
Create an account Create an account
</Title> </Title>
<Text c="dimmed" size="sm" ta="center" mt={5}>
Already have an account?{' '}
<Anchor size="sm" component={Link} to="/login">
Login
</Anchor>
</Text>
<Paper shadow="md" p={30} mt={30} radius="md">
<form onSubmit={form.onSubmit(onSubmit)}> <form onSubmit={form.onSubmit(onSubmit)}>
<TextInput <TextInput
id="email" id="email"
type="email" type="email"
label="Email" label="Email"
placeholder="email@example.com" placeholder="email@example.com"
required variant="filled"
{...form.getInputProps('email')} {...form.getInputProps("email")}
/> />
<PasswordInput <PasswordInput
label="Password" label="Password"
placeholder="Your password" placeholder="Your password"
required variant="filled"
mt="md" mt="md"
{...form.getInputProps('password')} {...form.getInputProps("password")}
/> />
<Button type="submit" fullWidth mt="xl" loading={isLoading}> <Button type="submit" fullWidth mt="xl" loading={isLoading}>
Sign Up Sign Up
</Button> </Button>
</form> </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> </Container>
); );
} }
+67 -14
View File
@@ -1,11 +1,15 @@
import { useState } from 'react'; import { useState } from "react";
import { login, register } from '@/features/auth/services/auth-service'; import { login, register } from "@/features/auth/services/auth-service";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
import { useAtom } from 'jotai'; import { useAtom } from "jotai";
import { authTokensAtom } from '@/features/auth/atoms/auth-tokens-atom'; import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom";
import { currentUserAtom } from '@/features/user/atoms/current-user-atom'; import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { ILogin, IRegister } from '@/features/auth/types/auth.types'; import { ILogin, IRegister } from "@/features/auth/types/auth.types";
import { notifications } from '@mantine/notifications'; 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() { export default function useAuth() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -22,12 +26,13 @@ export default function useAuth() {
setIsLoading(false); setIsLoading(false);
setAuthToken(res.tokens); setAuthToken(res.tokens);
navigate('/home'); navigate("/home");
} catch (err) { } catch (err) {
console.log(err);
setIsLoading(false); setIsLoading(false);
notifications.show({ notifications.show({
message: err.response?.data.message, message: err.response?.data.message,
color: 'red', color: "red",
}); });
} }
}; };
@@ -41,24 +46,72 @@ export default function useAuth() {
setAuthToken(res.tokens); setAuthToken(res.tokens);
navigate('/home'); navigate("/home");
} catch (err) { } catch (err) {
setIsLoading(false); setIsLoading(false);
notifications.show({ notifications.show({
message: err.response?.data.message, 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; return !!authToken;
}; };
const handleLogout = async () => { const handleLogout = async () => {
setAuthToken(null); setAuthToken(null);
setCurrentUser(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,
};
} }
@@ -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]);
}
@@ -4,6 +4,7 @@ export interface ILogin {
} }
export interface IRegister { export interface IRegister {
name?: string;
email: string; email: string;
password: string; password: string;
} }
@@ -1,29 +1,31 @@
import { Group, Text, Box } from '@mantine/core'; import { Group, Text, Box } from "@mantine/core";
import React, { useState } from 'react'; import React, { useState } from "react";
import classes from './comment.module.css'; import classes from "./comment.module.css";
import { useAtomValue } from 'jotai'; import { useAtomValue } from "jotai";
import { timeAgo } from '@/lib/time'; import { timeAgo } from "@/lib/time";
import CommentEditor from '@/features/comment/components/comment-editor'; import CommentEditor from "@/features/comment/components/comment-editor";
import { pageEditorAtom } from '@/features/editor/atoms/editor-atoms'; import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
import CommentActions from '@/features/comment/components/comment-actions'; import CommentActions from "@/features/comment/components/comment-actions";
import CommentMenu from '@/features/comment/components/comment-menu'; import CommentMenu from "@/features/comment/components/comment-menu";
import { useHover } from '@mantine/hooks'; import { useHover } from "@mantine/hooks";
import { useDeleteCommentMutation, useUpdateCommentMutation } from '@/features/comment/queries/comment-query'; import {
import { IComment } from '@/features/comment/types/comment.types'; useDeleteCommentMutation,
import { UserAvatar } from '@/components/ui/user-avatar'; useUpdateCommentMutation,
} from "@/features/comment/queries/comment-query";
import { IComment } from "@/features/comment/types/comment.types";
import { UserAvatar } from "@/components/ui/user-avatar";
interface CommentListItemProps { interface CommentListItemProps {
comment: IComment; comment: IComment;
} }
function CommentListItem({ comment }: CommentListItemProps) { function CommentListItem({ comment }: CommentListItemProps) {
const { hovered, ref } = useHover(); const { hovered, ref } = useHover();
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const editor = useAtomValue(pageEditorAtom); const editor = useAtomValue(pageEditorAtom);
const [content, setContent] = useState(comment.content); const [content, setContent] = useState<string>(comment.content);
const updateCommentMutation = useUpdateCommentMutation(); const updateCommentMutation = useUpdateCommentMutation();
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId); const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
@@ -31,13 +33,13 @@ function CommentListItem({ comment }: CommentListItemProps) {
try { try {
setIsLoading(true); setIsLoading(true);
const commentToUpdate = { const commentToUpdate = {
id: comment.id, commentId: comment.id,
content: JSON.stringify(content), content: JSON.stringify(content),
}; };
await updateCommentMutation.mutateAsync(commentToUpdate); await updateCommentMutation.mutateAsync(commentToUpdate);
setIsEditing(false); setIsEditing(false);
} catch (error) { } catch (error) {
console.error('Failed to update comment:', error); console.error("Failed to update comment:", error);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -48,7 +50,7 @@ function CommentListItem({ comment }: CommentListItemProps) {
await deleteCommentMutation.mutateAsync(comment.id); await deleteCommentMutation.mutateAsync(comment.id);
editor?.commands.unsetComment(comment.id); editor?.commands.unsetComment(comment.id);
} catch (error) { } 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 ( return (
<Box ref={ref} pb="xs"> <Box ref={ref} pb="xs">
<Group> <Group>
<UserAvatar color="blue" size="sm" avatarUrl={comment.creator.avatarUrl} <UserAvatar
color="blue"
size="sm"
avatarUrl={comment.creator.avatarUrl}
name={comment.creator.name} name={comment.creator.name}
/> />
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<Group justify="space-between" wrap="nowrap"> <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 && ( {/*!comment.parentCommentId && (
<ResolveComment commentId={comment.id} pageId={comment.pageId} resolvedAt={comment.resolvedAt} /> <ResolveComment commentId={comment.id} pageId={comment.pageId} resolvedAt={comment.resolvedAt} />
)*/} )*/}
<CommentMenu onEditComment={handleEditToggle} onDeleteComment={handleDeleteComment} /> <CommentMenu
onEditComment={handleEditToggle}
onDeleteComment={handleDeleteComment}
/>
</div> </div>
</Group> </Group>
@@ -83,26 +93,30 @@ function CommentListItem({ comment }: CommentListItemProps) {
</Group> </Group>
<div> <div>
{!comment.parentCommentId && comment?.selection && {!comment.parentCommentId && comment?.selection && (
<Box className={classes.textSelection}> <Box className={classes.textSelection}>
<Text size="sm">{comment?.selection}</Text> <Text size="sm">{comment?.selection}</Text>
</Box> </Box>
} )}
{ {!isEditing ? (
!isEditing ? <CommentEditor defaultContent={content} editable={false} />
(<CommentEditor defaultContent={content} editable={false} />) ) : (
: <>
(<> <CommentEditor
<CommentEditor defaultContent={content} editable={true} onUpdate={(newContent) => setContent(newContent)} defaultContent={content}
autofocus={true} /> editable={true}
onUpdate={(newContent) => setContent(newContent)}
<CommentActions onSave={handleUpdateComment} isLoading={isLoading} /> autofocus={true}
</>) />
}
<CommentActions
onSave={handleUpdateComment}
isLoading={isLoading}
/>
</>
)}
</div> </div>
</Box> </Box>
); );
} }
@@ -18,7 +18,7 @@ function CommentList() {
data: comments, data: comments,
isLoading: isCommentsLoading, isLoading: isCommentsLoading,
isError, isError,
} = useCommentsQuery(pageId); } = useCommentsQuery({ pageId, limit: 100 });
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const createCommentMutation = useCreateCommentMutation(); const createCommentMutation = useCreateCommentMutation();
@@ -12,6 +12,7 @@ import {
updateComment, updateComment,
} from "@/features/comment/services/comment-service"; } from "@/features/comment/services/comment-service";
import { import {
ICommentParams,
IComment, IComment,
IResolveComment, IResolveComment,
} from "@/features/comment/types/comment.types"; } 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 const RQ_KEY = (pageId: string) => ["comments", pageId];
export function useCommentsQuery( export function useCommentsQuery(
pageId: string, params: ICommentParams,
): UseQueryResult<IPagination<IComment>, Error> { ): UseQueryResult<IPagination<IComment>, Error> {
return useQuery({ return useQuery({
queryKey: RQ_KEY(pageId), // eslint-disable-next-line @tanstack/query/exhaustive-deps
queryFn: () => getPageComments(pageId), queryKey: RQ_KEY(params.pageId),
enabled: !!pageId, queryFn: () => getPageComments(params),
enabled: !!params.pageId,
}); });
} }
@@ -36,13 +38,14 @@ export function useCreateCommentMutation() {
return useMutation<IComment, Error, Partial<IComment>>({ return useMutation<IComment, Error, Partial<IComment>>({
mutationFn: (data) => createComment(data), mutationFn: (data) => createComment(data),
onSuccess: (data) => { onSuccess: (data) => {
const newComment = data; //const newComment = data;
let comments = queryClient.getQueryData(RQ_KEY(data.pageId)); // let comments = queryClient.getQueryData(RQ_KEY(data.pageId));
if (comments) { // if (comments) {
//comments = prevComments => [...prevComments, newComment]; //comments = prevComments => [...prevComments, newComment];
//queryClient.setQueryData(RQ_KEY(data.pageId), comments); //queryClient.setQueryData(RQ_KEY(data.pageId), comments);
} //}
queryClient.invalidateQueries({ queryKey: RQ_KEY(data.pageId) });
notifications.show({ message: "Comment created successfully" }); notifications.show({ message: "Comment created successfully" });
}, },
onError: (error) => { onError: (error) => {
@@ -69,11 +72,21 @@ export function useDeleteCommentMutation(pageId?: string) {
return useMutation({ return useMutation({
mutationFn: (commentId: string) => deleteComment(commentId), mutationFn: (commentId: string) => deleteComment(commentId),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
let comments = queryClient.getQueryData(RQ_KEY(pageId)) as IComment[]; const comments = queryClient.getQueryData(
if (comments) { RQ_KEY(pageId),
// comments = comments.filter(comment => comment.id !== variables); ) as IPagination<IComment>;
// queryClient.setQueryData(RQ_KEY(pageId), comments);
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" }); notifications.show({ message: "Comment deleted successfully" });
}, },
onError: (error) => { onError: (error) => {
@@ -92,6 +105,7 @@ export function useResolveCommentMutation() {
RQ_KEY(data.pageId), RQ_KEY(data.pageId),
) as IComment[]; ) as IComment[];
/*
if (currentComments) { if (currentComments) {
const updatedComments = currentComments.map((comment) => const updatedComments = currentComments.map((comment) =>
comment.id === variables.commentId comment.id === variables.commentId
@@ -99,7 +113,7 @@ export function useResolveCommentMutation() {
: comment, : comment,
); );
queryClient.setQueryData(RQ_KEY(data.pageId), updatedComments); queryClient.setQueryData(RQ_KEY(data.pageId), updatedComments);
} }*/
notifications.show({ message: "Comment resolved successfully" }); notifications.show({ message: "Comment resolved successfully" });
}, },
@@ -1,5 +1,6 @@
import api from "@/lib/api-client"; import api from "@/lib/api-client";
import { import {
ICommentParams,
IComment, IComment,
IResolveComment, IResolveComment,
} from "@/features/comment/types/comment.types"; } from "@/features/comment/types/comment.types";
@@ -9,30 +10,30 @@ export async function createComment(
data: Partial<IComment>, data: Partial<IComment>,
): Promise<IComment> { ): Promise<IComment> {
const req = await api.post<IComment>("/comments/create", data); 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> { export async function resolveComment(data: IResolveComment): Promise<IComment> {
const req = await api.post<IComment>(`/comments/resolve`, data); const req = await api.post<IComment>(`/comments/resolve`, data);
return req.data as IComment; return req.data;
} }
export async function updateComment( export async function updateComment(
data: Partial<IComment>, data: Partial<IComment>,
): Promise<IComment> { ): Promise<IComment> {
const req = await api.post<IComment>(`/comments/update`, data); 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> { export async function getCommentById(commentId: string): Promise<IComment> {
const req = await api.post<IComment>("/comments/info", { commentId }); const req = await api.post<IComment>("/comments/info", { commentId });
return req.data as IComment; return req.data;
} }
export async function getPageComments( export async function getPageComments(
pageId: string, data: ICommentParams,
): Promise<IPagination<IComment>> { ): Promise<IPagination<IComment>> {
const req = await api.post("/comments", { pageId }); const req = await api.post("/comments", data);
return req.data; return req.data;
} }
@@ -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 { export interface IComment {
id: string; id: string;
@@ -14,7 +15,7 @@ export interface IComment {
createdAt: Date; createdAt: Date;
editedAt?: Date; editedAt?: Date;
deletedAt?: Date; deletedAt?: Date;
creator: IUser creator: IUser;
} }
export interface ICommentData { export interface ICommentData {
@@ -29,3 +30,7 @@ export interface IResolveComment {
commentId: string; commentId: string;
resolved: boolean; resolved: boolean;
} }
export interface ICommentParams extends QueryParams {
pageId: string;
}
@@ -2,10 +2,11 @@
display: flex; display: flex;
width: fit-content; width: fit-content;
border-radius: 2px; border-radius: 2px;
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-8)); border: 1px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-8));
.active { .active {
color: var(--mantine-color-blue-8); color: light-dark(var(--mantine-color-blue-8), var(--mantine-color-gray-5));
} }
.colorButton { .colorButton {
@@ -19,7 +20,9 @@
bottom: 0; bottom: 0;
left: 0; left: 0;
width: 1px; width: 1px;
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-8)); background-color: light-dark(
var(--mantine-color-gray-3),
var(--mantine-color-gray-8)
);
} }
} }
@@ -4,7 +4,7 @@ import {
isNodeSelection, isNodeSelection,
useEditor, useEditor,
} from "@tiptap/react"; } from "@tiptap/react";
import { FC, useState } from "react"; import { FC, useEffect, useRef, useState } from "react";
import { import {
IconBold, IconBold,
IconCode, IconCode,
@@ -37,8 +37,13 @@ type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
}; };
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => { export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const [, setShowCommentPopup] = useAtom(showCommentPopupAtom); const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [, setDraftCommentId] = useAtom(draftCommentIdAtom); const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
const showCommentPopupRef = useRef(showCommentPopup);
useEffect(() => {
showCommentPopupRef.current = showCommentPopup;
}, [showCommentPopup]);
const items: BubbleMenuItem[] = [ const items: BubbleMenuItem[] = [
{ {
@@ -94,9 +99,11 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const { empty } = selection; const { empty } = selection;
if ( if (
props.editor.isActive("image") || !editor.isEditable ||
editor.isActive("image") ||
empty || empty ||
isNodeSelection(selection) isNodeSelection(selection) ||
showCommentPopupRef?.current
) { ) {
return false; return false;
} }
@@ -117,14 +124,13 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false); const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
return ( return (
<BubbleMenu {...bubbleMenuProps} className={classes.bubbleMenu}> <BubbleMenu {...bubbleMenuProps}>
<div className={classes.bubbleMenu}>
<NodeSelector <NodeSelector
editor={props.editor} editor={props.editor}
isOpen={isNodeSelectorOpen} isOpen={isNodeSelectorOpen}
setIsOpen={() => { setIsOpen={() => {
setIsNodeSelectorOpen(!isNodeSelectorOpen); setIsNodeSelectorOpen(!isNodeSelectorOpen);
setIsColorSelectorOpen(false);
setIsLinkSelectorOpen(false);
}} }}
/> />
@@ -152,12 +158,9 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
isOpen={isColorSelectorOpen} isOpen={isColorSelectorOpen}
setIsOpen={() => { setIsOpen={() => {
setIsColorSelectorOpen(!isColorSelectorOpen); setIsColorSelectorOpen(!isColorSelectorOpen);
setIsNodeSelectorOpen(false);
setIsLinkSelectorOpen(false);
}} }}
/> />
<Tooltip label={commentItem.name} withArrow>
<ActionIcon <ActionIcon
variant="default" variant="default"
size="lg" size="lg"
@@ -168,7 +171,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
> >
<IconMessage style={{ width: rem(16) }} stroke={2} /> <IconMessage style={{ width: rem(16) }} stroke={2} />
</ActionIcon> </ActionIcon>
</Tooltip> </div>
</BubbleMenu> </BubbleMenu>
); );
}; };
@@ -1,6 +1,6 @@
import { Dispatch, FC, SetStateAction } from "react"; import { Dispatch, FC, SetStateAction } from "react";
import { IconCheck, IconChevronDown } from "@tabler/icons-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 classes from "./bubble-menu.module.css";
import { useEditor } from "@tiptap/react"; import { useEditor } from "@tiptap/react";
@@ -110,17 +110,22 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
return ( return (
<Popover width={200} opened={isOpen} withArrow> <Popover width={200} opened={isOpen} withArrow>
<Popover.Target> <Popover.Target>
<Tooltip label="text color" withArrow>
<Button <Button
variant="default" variant="default"
radius="0" radius="0"
leftSection="A"
rightSection={<IconChevronDown size={16} />} rightSection={<IconChevronDown size={16} />}
className={classes.colorButton} className={classes.colorButton}
style={{ style={{
color: activeColorItem?.color, color: activeColorItem?.color,
paddingLeft: "8px",
paddingRight: "8px",
}} }}
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
/> >
A
</Button>
</Tooltip>
</Popover.Target> </Popover.Target>
<Popover.Dropdown> <Popover.Dropdown>
@@ -159,37 +164,6 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
</Button> </Button>
))} ))}
</Button.Group> </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> </ScrollArea.Autosize>
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
@@ -0,0 +1,2 @@
export * from "./is-custom-node-selected";
export * from "./is-text-selected";
@@ -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;
@@ -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;
@@ -8,6 +8,8 @@ import { IconUsersGroup } from "@tabler/icons-react";
interface MultiGroupSelectProps { interface MultiGroupSelectProps {
onChange: (value: string[]) => void; onChange: (value: string[]) => void;
label?: string; label?: string;
description?: string;
mt?: string;
} }
const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
@@ -21,7 +23,12 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
</Group> </Group>
); );
export function MultiGroupSelect({ onChange, label }: MultiGroupSelectProps) { export function MultiGroupSelect({
onChange,
label,
description,
mt,
}: MultiGroupSelectProps) {
const [searchValue, setSearchValue] = useState(""); const [searchValue, setSearchValue] = useState("");
const [debouncedQuery] = useDebouncedValue(searchValue, 500); const [debouncedQuery] = useDebouncedValue(searchValue, 500);
const { data: groups, isLoading } = useGetGroupsQuery({ const { data: groups, isLoading } = useGetGroupsQuery({
@@ -56,8 +63,10 @@ export function MultiGroupSelect({ onChange, label }: MultiGroupSelectProps) {
renderOption={renderMultiSelectOption} renderOption={renderMultiSelectOption}
hidePickedOptions hidePickedOptions
maxDropdownHeight={300} maxDropdownHeight={300}
description={description}
label={label || "Add groups"} label={label || "Add groups"}
placeholder="Search for groups" placeholder="Search for groups"
mt={mt}
searchable searchable
searchValue={searchValue} searchValue={searchValue}
onSearchChange={setSearchValue} onSearchChange={setSearchValue}
@@ -1,18 +1,11 @@
import { useSpaceQuery } from "@/features/space/queries/space-query.ts"; import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
import { useAtom } from "jotai/index"; import { useAtom } from "jotai/index";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { import { Box } from "@mantine/core";
Accordion, import { IconNotes } from "@tabler/icons-react";
AccordionControlProps,
ActionIcon,
Center,
rem,
Tooltip,
} from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import React from "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 SpaceTree from "@/features/page/tree/components/space-tree.tsx";
import { TreeCollapse } from "@/features/page/tree/components/tree-collapse.tsx";
export default function SpaceContent() { export default function SpaceContent() {
const [currentUser] = useAtom(currentUserAtom); const [currentUser] = useAtom(currentUserAtom);
@@ -24,42 +17,15 @@ export default function SpaceContent() {
return ( return (
<> <>
<Accordion <Box p="sm" mx="auto">
chevronPosition="left" <TreeCollapse
maw={400} initiallyOpened={true}
mx="auto" icon={IconNotes}
defaultValue={space.id} label={space.name}
> >
<Accordion.Item key={space.id} value={space.id}>
<AccordionControl>{space.name}</AccordionControl>
<Accordion.Panel>
<SpaceTree spaceId={space.id} /> <SpaceTree spaceId={space.id} />
</Accordion.Panel> </TreeCollapse>
</Accordion.Item> </Box>
</Accordion>
</> </>
); );
} }
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>
);
}
@@ -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;
}
@@ -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>
</>
);
}
@@ -1,17 +1,8 @@
import { import { Modal, Tabs, rem, Group, Divider, ScrollArea } from "@mantine/core";
Modal,
Tabs,
rem,
Group,
Divider,
Text,
ScrollArea,
} from "@mantine/core";
import SpaceMembersList from "@/features/space/components/space-members.tsx"; import SpaceMembersList from "@/features/space/components/space-members.tsx";
import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx"; import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx";
import React from "react"; import React from "react";
import GroupActionMenu from "@/features/group/components/group-action-menu.tsx"; 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 SpaceDetails from "@/features/space/components/space-details.tsx";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts"; import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
@@ -47,7 +38,7 @@ export default function SpaceSettingsModal({
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
<div style={{ height: rem("600px") }}> <div style={{ height: rem("600px") }}>
<Tabs color="gray" defaultValue="members"> <Tabs defaultValue="members">
<Tabs.List> <Tabs.List>
<Tabs.Tab fw={500} value="general"> <Tabs.Tab fw={500} value="general">
Settings Settings
@@ -1,12 +1,12 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query"; 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"; import { ICurrentUser } from "@/features/user/types/user.types";
export default function useCurrentUser(): UseQueryResult<ICurrentUser> { export default function useCurrentUser(): UseQueryResult<ICurrentUser> {
return useQuery({ return useQuery({
queryKey: ["currentUser"], queryKey: ["currentUser"],
queryFn: async () => { queryFn: async () => {
return await getUserInfo(); return await getMyInfo();
}, },
}); });
} }
@@ -1,29 +1,23 @@
import api from '@/lib/api-client'; import api from "@/lib/api-client";
import { ICurrentUser, IUser } from '@/features/user/types/user.types'; import { ICurrentUser, IUser } from "@/features/user/types/user.types";
export async function getMe(): Promise<IUser> { export async function getMyInfo(): Promise<ICurrentUser> {
const req = await api.post<IUser>('/users/me'); const req = await api.post<ICurrentUser>("/users/me");
return req.data as IUser;
}
export async function getUserInfo(): Promise<ICurrentUser> {
const req = await api.post<ICurrentUser>('/users/info');
return req.data as ICurrentUser; return req.data as ICurrentUser;
} }
export async function updateUser(data: Partial<IUser>): Promise<IUser> { 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; return req.data as IUser;
} }
export async function uploadAvatar(file: File) { export async function uploadAvatar(file: File) {
const formData = new FormData(); const formData = new FormData();
formData.append('avatar', file); formData.append("avatar", file);
const req = await api.post('/attachments/upload/avatar', formData, { const req = await api.post("/attachments/upload/avatar", formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data', "Content-Type": "multipart/form-data",
} },
}); });
return req.data; return req.data;
} }
@@ -1,7 +1,7 @@
import { useAtom } from 'jotai'; import { useAtom } from "jotai";
import { currentUserAtom } from '@/features/user/atoms/current-user-atom'; import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import React, { useEffect } from 'react'; import React, { useEffect } from "react";
import useCurrentUser from '@/features/user/hooks/use-current-user'; import useCurrentUser from "@/features/user/hooks/use-current-user";
export function UserProvider({ children }: React.PropsWithChildren) { export function UserProvider({ children }: React.PropsWithChildren) {
const [, setCurrentUser] = useAtom(currentUserAtom); const [, setCurrentUser] = useAtom(currentUserAtom);
@@ -16,6 +16,7 @@ export function UserProvider({ children }: React.PropsWithChildren) {
if (isLoading) return <></>; if (isLoading) return <></>;
if (error) { if (error) {
console.error(error);
return <>an error occurred</>; return <>an error occurred</>;
} }
@@ -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>
</>
);
}
@@ -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 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 { interface Props {
OWNER = "Owner", onClose: () => void;
ADMIN = "Admin", }
MEMBER = "Member", 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();
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");
} }
export function WorkspaceInviteForm() { const handleGroupSelect = (value: string[]) => {
function handleSubmit(data) { setGroupIds(value);
console.log(data); };
}
return ( return (
<> <>
<Box maw="500" mx="auto"> <Box maw="500" mx="auto">
<WorkspaceInviteSection /> {/*<WorkspaceInviteSection /> */}
<Space h="md" />
<TagsInput <TagsInput
description="Enter valid email addresses separated by comma or space" mt="sm"
label="Invite from email" description="Enter valid email addresses separated by comma or space [max: 50]"
label="Invite by email"
placeholder="enter valid emails addresses" placeholder="enter valid emails addresses"
variant="filled" variant="filled"
splitChars={[",", " "]} splitChars={[",", " "]}
maxDropdownHeight={200} maxDropdownHeight={200}
maxTags={50} maxTags={50}
onChange={setEmails}
/> />
<Space h="md" />
<Select <Select
mt="sm"
description="Select role to assign to all invited members" description="Select role to assign to all invited members"
label="Select role" label="Select role"
placeholder="Pick a role" placeholder="Choose a role"
variant="filled" variant="filled"
data={Object.values(UserRole)} data={userRoleData.filter((role) => role.value !== UserRole.OWNER)}
defaultValue={UserRole.MEMBER} defaultValue={UserRole.MEMBER}
allowDeselect={false} allowDeselect={false}
checkIconPosition="right" checkIconPosition="right"
onChange={setRole}
/> />
<Group justify="center" mt="md"> <MultiGroupSelect
<Button>Send invitation</Button> 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> </Group>
</Box> </Box>
</> </>
@@ -10,7 +10,7 @@ export default function WorkspaceInviteModal() {
<Button onClick={open}>Invite members</Button> <Button onClick={open}>Invite members</Button>
<Modal <Modal
size="600" size="550"
opened={opened} opened={opened}
onClose={close} onClose={close}
title="Invite new members" title="Invite new members"
@@ -19,7 +19,7 @@ export default function WorkspaceInviteModal() {
<Divider size="xs" mb="xs" /> <Divider size="xs" mb="xs" />
<ScrollArea h="80%"> <ScrollArea h="80%">
<WorkspaceInviteForm /> <WorkspaceInviteForm onClose={close} />
</ScrollArea> </ScrollArea>
</Modal> </Modal>
</> </>
@@ -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>
</>
)}
</>
);
}
@@ -12,7 +12,7 @@ import {
} from "@/features/workspace/types/user-role-data.ts"; } from "@/features/workspace/types/user-role-data.ts";
export default function WorkspaceMembersTable() { export default function WorkspaceMembersTable() {
const { data, isLoading } = useWorkspaceMembersQuery(); const { data, isLoading } = useWorkspaceMembersQuery({ limit: 100 });
const changeMemberRoleMutation = useChangeMemberRoleMutation(); const changeMemberRoleMutation = useChangeMemberRoleMutation();
const handleRoleChange = async ( const handleRoleChange = async (
@@ -6,12 +6,21 @@ import {
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { import {
changeMemberRole, changeMemberRole,
getInvitationById,
getPendingInvitations,
getWorkspace, getWorkspace,
getWorkspaceMembers, getWorkspaceMembers,
createInvitation,
resendInvitation,
revokeInvitation,
} from "@/features/workspace/services/workspace-service"; } 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 { 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> { export function useWorkspace(): UseQueryResult<IWorkspace, Error> {
return useQuery({ 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,
});
}
@@ -1,11 +1,17 @@
import api from "@/lib/api-client"; import api from "@/lib/api-client";
import { IUser } from "@/features/user/types/user.types"; import { IUser } from "@/features/user/types/user.types";
import { IWorkspace } from "../types/workspace.types"; import {
ICreateInvite,
IInvitation,
IWorkspace,
IAcceptInvite,
} from "../types/workspace.types";
import { IPagination, QueryParams } from "@/lib/types.ts"; import { IPagination, QueryParams } from "@/lib/types.ts";
import { ITokenResponse } from "@/features/auth/types/auth.types.ts";
export async function getWorkspace(): Promise<IWorkspace> { export async function getWorkspace(): Promise<IWorkspace> {
const req = await api.post<IWorkspace>("/workspace/info"); const req = await api.post<IWorkspace>("/workspace/info");
return req.data as IWorkspace; return req.data;
} }
// Todo: fix all paginated types // Todo: fix all paginated types
@@ -18,8 +24,7 @@ export async function getWorkspaceMembers(
export async function updateWorkspace(data: Partial<IWorkspace>) { 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;
return req.data as IWorkspace;
} }
export async function changeMemberRole(data: { export async function changeMemberRole(data: {
@@ -28,3 +33,42 @@ export async function changeMemberRole(data: {
}): Promise<void> { }): Promise<void> {
await api.post("/workspace/members/role", data); 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;
}
@@ -12,3 +12,25 @@ export interface IWorkspace {
createdAt: Date; createdAt: Date;
updatedAt: 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;
}
+8 -1
View File
@@ -10,7 +10,14 @@ const api: AxiosInstance = axios.create({
api.interceptors.request.use( api.interceptors.request.use(
(config) => { (config) => {
const tokenData = Cookies.get("authTokens"); 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) { if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`; config.headers.Authorization = `Bearer ${accessToken}`;
@@ -1,26 +1,61 @@
import WorkspaceInviteSection from "@/features/workspace/components/members/components/workspace-invite-section"; import WorkspaceInviteSection from "@/features/workspace/components/members/components/workspace-invite-section";
import WorkspaceInviteModal from "@/features/workspace/components/members/components/workspace-invite-modal"; 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 WorkspaceMembersTable from "@/features/workspace/components/members/components/workspace-members-table";
import SettingsTitle from "@/components/layouts/settings/settings-title.tsx"; 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() { 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 ( return (
<> <>
<SettingsTitle title="Members" /> <SettingsTitle title="Members" />
<WorkspaceInviteSection /> {/* <WorkspaceInviteSection /> */}
{/* <Divider my="lg" /> */}
<Divider my="lg" />
<Group justify="space-between"> <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 /> <WorkspaceInviteModal />
</Group> </Group>
<Space h="lg" /> <Space h="lg" />
{segmentValue === "invites" ? (
<WorkspaceInvitesTable />
) : (
<WorkspaceMembersTable /> <WorkspaceMembersTable />
)}
</> </>
); );
} }
+3 -1
View File
@@ -18,6 +18,7 @@
"migration:down": "tsx ./src/kysely/migrate.ts down", "migration:down": "tsx ./src/kysely/migrate.ts down",
"migration:latest": "tsx ./src/kysely/migrate.ts latest", "migration:latest": "tsx ./src/kysely/migrate.ts latest",
"migration:redo": "tsx ./src/kysely/migrate.ts redo", "migration:redo": "tsx ./src/kysely/migrate.ts redo",
"migration:reset": "tsx ./src/kysely/migrate.ts down-to NO_MIGRATIONS",
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/kysely/types/db.d.ts", "migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/kysely/types/db.d.ts",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest", "test": "jest",
@@ -30,7 +31,6 @@
"@aws-sdk/client-s3": "^3.565.0", "@aws-sdk/client-s3": "^3.565.0",
"@aws-sdk/s3-request-presigner": "^3.565.0", "@aws-sdk/s3-request-presigner": "^3.565.0",
"@casl/ability": "^6.7.1", "@casl/ability": "^6.7.1",
"@docmost/transactional": "workspace:^",
"@fastify/multipart": "^8.2.0", "@fastify/multipart": "^8.2.0",
"@fastify/static": "^7.0.3", "@fastify/static": "^7.0.3",
"@nestjs/bullmq": "^10.1.1", "@nestjs/bullmq": "^10.1.1",
@@ -53,10 +53,12 @@
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"fastify": "^4.26.2", "fastify": "^4.26.2",
"fix-esm": "^1.0.1",
"fs-extra": "^11.2.0", "fs-extra": "^11.2.0",
"kysely": "^0.27.3", "kysely": "^0.27.3",
"kysely-migration-cli": "^0.4.0", "kysely-migration-cli": "^0.4.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"nanoid": "^5.0.7",
"nestjs-kysely": "^0.1.7", "nestjs-kysely": "^0.1.7",
"nodemailer": "^6.9.13", "nodemailer": "^6.9.13",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
@@ -1,5 +1,4 @@
import { Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { AuthModule } from '../core/auth/auth.module';
import { AuthenticationExtension } from './extensions/authentication.extension'; import { AuthenticationExtension } from './extensions/authentication.extension';
import { PersistenceExtension } from './extensions/persistence.extension'; import { PersistenceExtension } from './extensions/persistence.extension';
import { CollaborationGateway } from './collaboration.gateway'; import { CollaborationGateway } from './collaboration.gateway';
@@ -8,6 +7,7 @@ import { CollabWsAdapter } from './adapter/collab-ws.adapter';
import { IncomingMessage } from 'http'; import { IncomingMessage } from 'http';
import { WebSocket } from 'ws'; import { WebSocket } from 'ws';
import { HistoryExtension } from './extensions/history.extension'; import { HistoryExtension } from './extensions/history.extension';
import { TokenModule } from '../core/auth/token.module';
@Module({ @Module({
providers: [ providers: [
@@ -16,7 +16,7 @@ import { HistoryExtension } from './extensions/history.extension';
PersistenceExtension, PersistenceExtension,
HistoryExtension, HistoryExtension,
], ],
imports: [AuthModule], imports: [TokenModule],
}) })
export class CollaborationModule implements OnModuleInit, OnModuleDestroy { export class CollaborationModule implements OnModuleInit, OnModuleDestroy {
private collabWsAdapter: CollabWsAdapter; private collabWsAdapter: CollabWsAdapter;
+3 -21
View File
@@ -1,32 +1,14 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { AuthService } from './services/auth.service'; import { AuthService } from './services/auth.service';
import { JwtModule } from '@nestjs/jwt';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { TokenService } from './services/token.service';
import { JwtStrategy } from './strategies/jwt.strategy'; import { JwtStrategy } from './strategies/jwt.strategy';
import { WorkspaceModule } from '../workspace/workspace.module'; import { WorkspaceModule } from '../workspace/workspace.module';
import { SignupService } from './services/signup.service'; import { SignupService } from './services/signup.service';
import { GroupModule } from '../group/group.module'; import { TokenModule } from './token.module';
@Module({ @Module({
imports: [ imports: [TokenModule, WorkspaceModule],
JwtModule.registerAsync({
useFactory: async (environmentService: EnvironmentService) => {
return {
secret: environmentService.getJwtSecret(),
signOptions: {
expiresIn: environmentService.getJwtTokenExpiresIn(),
},
};
},
inject: [EnvironmentService],
}),
WorkspaceModule,
GroupModule,
],
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService, SignupService, TokenService, JwtStrategy], providers: [AuthService, SignupService, JwtStrategy],
exports: [TokenService],
}) })
export class AuthModule {} export class AuthModule {}
@@ -9,8 +9,8 @@ import {
export class CreateUserDto { export class CreateUserDto {
@IsOptional() @IsOptional()
@MinLength(3) @MinLength(2)
@MaxLength(35) @MaxLength(60)
@IsString() @IsString()
name: string; name: string;
@@ -3,19 +3,19 @@ import { CreateUserDto } from '../dto/create-user.dto';
import { WorkspaceService } from '../../workspace/services/workspace.service'; import { WorkspaceService } from '../../workspace/services/workspace.service';
import { CreateWorkspaceDto } from '../../workspace/dto/create-workspace.dto'; import { CreateWorkspaceDto } from '../../workspace/dto/create-workspace.dto';
import { CreateAdminUserDto } from '../dto/create-admin-user.dto'; import { CreateAdminUserDto } from '../dto/create-admin-user.dto';
import { GroupUserService } from '../../group/services/group-user.service';
import { UserRepo } from '@docmost/db/repos/user/user.repo'; import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { executeTx } from '@docmost/db/utils'; import { executeTx } from '@docmost/db/utils';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { User } from '@docmost/db/types/entity.types'; import { User } from '@docmost/db/types/entity.types';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
@Injectable() @Injectable()
export class SignupService { export class SignupService {
constructor( constructor(
private userRepo: UserRepo, private userRepo: UserRepo,
private workspaceService: WorkspaceService, private workspaceService: WorkspaceService,
private groupUserService: GroupUserService, private groupUserRepo: GroupUserRepo,
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
) {} ) {}
@@ -56,7 +56,7 @@ export class SignupService {
); );
// add user to default group // add user to default group
await this.groupUserService.addUserToDefaultGroup( await this.groupUserRepo.addUserToDefaultGroup(
user.id, user.id,
workspaceId, workspaceId,
trx, trx,
+24
View File
@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { TokenService } from './services/token.service';
@Module({
imports: [
JwtModule.registerAsync({
useFactory: async (environmentService: EnvironmentService) => {
return {
secret: environmentService.getAppSecret(),
signOptions: {
expiresIn: environmentService.getJwtTokenExpiresIn(),
issuer: 'Docmost',
},
};
},
inject: [EnvironmentService],
}),
],
providers: [TokenService],
exports: [TokenService],
})
export class TokenModule {}
@@ -11,7 +11,6 @@ import { User, Workspace } from '@docmost/db/types/entity.types';
export type Subjects = export type Subjects =
| 'Workspace' | 'Workspace'
| 'WorkspaceInvitation'
| 'Space' | 'Space'
| 'SpaceMember' | 'SpaceMember'
| 'Group' | 'Group'
@@ -36,8 +35,6 @@ export default class CaslAbilityFactory {
can([Action.Manage], 'Workspace'); can([Action.Manage], 'Workspace');
can([Action.Manage], 'WorkspaceUser'); can([Action.Manage], 'WorkspaceUser');
can([Action.Manage], 'WorkspaceInvitation');
// Groups // Groups
can([Action.Manage], 'Group'); can([Action.Manage], 'Group');
can([Action.Manage], 'GroupUser'); can([Action.Manage], 'GroupUser');
@@ -66,8 +66,7 @@ export class CommentService {
workspaceId: workspaceId, workspaceId: workspaceId,
}); });
// return created comment and creator relation return createdComment;
return this.findById(createdComment.id);
} }
async findByPageId( async findByPageId(
@@ -114,7 +113,12 @@ export class CommentService {
return comment; return comment;
} }
async remove(id: string): Promise<void> { async remove(commentId: string): Promise<void> {
await this.commentRepo.deleteComment(id); const comment = await this.commentRepo.findById(commentId);
if (!comment) {
throw new NotFoundException('Comment not found');
}
await this.commentRepo.deleteComment(commentId);
} }
} }
@@ -2,13 +2,9 @@ import { ArrayMaxSize, ArrayMinSize, IsArray, IsUUID } from 'class-validator';
import { GroupIdDto } from './group-id.dto'; import { GroupIdDto } from './group-id.dto';
export class AddGroupUserDto extends GroupIdDto { export class AddGroupUserDto extends GroupIdDto {
// @IsOptional()
// @IsUUID()
// userId: string;
@IsArray() @IsArray()
@ArrayMaxSize(50, { @ArrayMaxSize(50, {
message: 'userIds must an array with no more than 50 elements', message: 'you cannot add more than 50 users at a time',
}) })
@ArrayMinSize(1) @ArrayMinSize(1)
@IsUUID(4, { each: true }) @IsUUID(4, { each: true })
@@ -7,17 +7,14 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { GroupService } from './group.service'; import { GroupService } from './group.service';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; import { KyselyDB } from '@docmost/db/types/kysely.types';
import { executeTx } from '@docmost/db/utils';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo'; import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo'; import { UserRepo } from '@docmost/db/repos/user/user.repo';
@Injectable() @Injectable()
export class GroupUserService { export class GroupUserService {
constructor( constructor(
private groupRepo: GroupRepo,
private groupUserRepo: GroupUserRepo, private groupUserRepo: GroupUserRepo,
private userRepo: UserRepo, private userRepo: UserRepo,
@Inject(forwardRef(() => GroupService)) @Inject(forwardRef(() => GroupService))
@@ -40,24 +37,6 @@ export class GroupUserService {
return groupUsers; return groupUsers;
} }
async addUserToDefaultGroup(
userId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
await executeTx(
this.db,
async (trx) => {
const defaultGroup = await this.groupRepo.getDefaultGroup(
workspaceId,
trx,
);
await this.addUserToGroup(userId, defaultGroup.id, workspaceId, trx);
},
trx,
);
}
async addUsersToGroupBatch( async addUsersToGroupBatch(
userIds: string[], userIds: string[],
groupId: string, groupId: string,
@@ -90,48 +69,6 @@ export class GroupUserService {
.execute(); .execute();
} }
async addUserToGroup(
userId: string,
groupId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
await executeTx(
this.db,
async (trx) => {
await this.groupService.findAndValidateGroup(groupId, workspaceId);
const user = await this.userRepo.findById(userId, workspaceId, {
trx: trx,
});
if (!user) {
throw new NotFoundException('User not found');
}
const groupUserExists = await this.groupUserRepo.getGroupUserById(
userId,
groupId,
trx,
);
if (groupUserExists) {
throw new BadRequestException(
'User is already a member of this group',
);
}
await this.groupUserRepo.insertGroupUser(
{
userId,
groupId,
},
trx,
);
},
trx,
);
}
async removeUserFromGroup( async removeUserFromGroup(
userId: string, userId: string,
groupId: string, groupId: string,
+1 -21
View File
@@ -4,42 +4,22 @@ import {
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Post, Post,
UnauthorizedException,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { UserService } from './user.service'; import { UserService } from './user.service';
import { UpdateUserDto } from './dto/update-user.dto'; import { UpdateUserDto } from './dto/update-user.dto';
import { AuthUser } from '../../decorators/auth-user.decorator'; import { AuthUser } from '../../decorators/auth-user.decorator';
import { JwtAuthGuard } from '../../guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../guards/jwt-auth.guard';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { AuthWorkspace } from '../../decorators/auth-workspace.decorator'; import { AuthWorkspace } from '../../decorators/auth-workspace.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types'; import { User, Workspace } from '@docmost/db/types/entity.types';
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('users') @Controller('users')
export class UserController { export class UserController {
constructor( constructor(private readonly userService: UserService) {}
private readonly userService: UserService,
private userRepo: UserRepo,
) {}
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('me') @Post('me')
async getUser(
@AuthUser() authUser: User,
@AuthWorkspace() workspace: Workspace,
) {
const user = await this.userRepo.findById(authUser.id, workspace.id);
if (!user) {
throw new UnauthorizedException('Invalid user');
}
return user;
}
@HttpCode(HttpStatus.OK)
@Post('info')
async getUserIno( async getUserIno(
@AuthUser() authUser: User, @AuthUser() authUser: User,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
@@ -4,19 +4,20 @@ import {
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Post, Post,
Req,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { WorkspaceService } from '../services/workspace.service'; import { WorkspaceService } from '../services/workspace.service';
import { UpdateWorkspaceDto } from '../dto/update-workspace.dto'; import { UpdateWorkspaceDto } from '../dto/update-workspace.dto';
import { DeleteWorkspaceDto } from '../dto/delete-workspace.dto';
import { UpdateWorkspaceUserRoleDto } from '../dto/update-workspace-user-role.dto'; import { UpdateWorkspaceUserRoleDto } from '../dto/update-workspace-user-role.dto';
import { AuthUser } from '../../../decorators/auth-user.decorator'; import { AuthUser } from '../../../decorators/auth-user.decorator';
import { AuthWorkspace } from '../../../decorators/auth-workspace.decorator'; import { AuthWorkspace } from '../../../decorators/auth-workspace.decorator';
import { PaginationOptions } from '../../../kysely/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { WorkspaceInvitationService } from '../services/workspace-invitation.service'; import { WorkspaceInvitationService } from '../services/workspace-invitation.service';
import { Public } from '../../../decorators/public.decorator'; import { Public } from '../../../decorators/public.decorator';
import { import {
AcceptInviteDto, AcceptInviteDto,
InvitationIdDto,
InviteUserDto, InviteUserDto,
RevokeInviteDto, RevokeInviteDto,
} from '../dto/invitation.dto'; } from '../dto/invitation.dto';
@@ -24,7 +25,6 @@ import { Action } from '../../casl/ability.action';
import { CheckPolicies } from '../../casl/decorators/policies.decorator'; import { CheckPolicies } from '../../casl/decorators/policies.decorator';
import { AppAbility } from '../../casl/abilities/casl-ability.factory'; import { AppAbility } from '../../casl/abilities/casl-ability.factory';
import { PoliciesGuard } from '../../casl/guards/policies.guard'; import { PoliciesGuard } from '../../casl/guards/policies.guard';
import { WorkspaceUserService } from '../services/workspace-user.service';
import { JwtAuthGuard } from '../../../guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../../guards/jwt-auth.guard';
import { User, Workspace } from '@docmost/db/types/entity.types'; import { User, Workspace } from '@docmost/db/types/entity.types';
@@ -33,7 +33,6 @@ import { User, Workspace } from '@docmost/db/types/entity.types';
export class WorkspaceController { export class WorkspaceController {
constructor( constructor(
private readonly workspaceService: WorkspaceService, private readonly workspaceService: WorkspaceService,
private readonly workspaceUserService: WorkspaceUserService,
private readonly workspaceInvitationService: WorkspaceInvitationService, private readonly workspaceInvitationService: WorkspaceInvitationService,
) {} ) {}
@@ -59,16 +58,6 @@ export class WorkspaceController {
return this.workspaceService.update(workspace.id, updateWorkspaceDto); return this.workspaceService.update(workspace.id, updateWorkspaceDto);
} }
@UseGuards(PoliciesGuard)
@CheckPolicies((ability: AppAbility) =>
ability.can(Action.Manage, 'Workspace'),
)
@HttpCode(HttpStatus.OK)
@Post('delete')
async deleteWorkspace(@Body() deleteWorkspaceDto: DeleteWorkspaceDto) {
// return this.workspaceService.delete(deleteWorkspaceDto);
}
@UseGuards(PoliciesGuard) @UseGuards(PoliciesGuard)
@CheckPolicies((ability: AppAbility) => @CheckPolicies((ability: AppAbility) =>
ability.can(Action.Read, 'WorkspaceUser'), ability.can(Action.Read, 'WorkspaceUser'),
@@ -80,10 +69,7 @@ export class WorkspaceController {
pagination: PaginationOptions, pagination: PaginationOptions,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
) { ) {
return this.workspaceUserService.getWorkspaceUsers( return this.workspaceService.getWorkspaceUsers(workspace.id, pagination);
workspace.id,
pagination,
);
} }
@UseGuards(PoliciesGuard) @UseGuards(PoliciesGuard)
@@ -93,7 +79,7 @@ export class WorkspaceController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('members/deactivate') @Post('members/deactivate')
async deactivateWorkspaceMember() { async deactivateWorkspaceMember() {
return this.workspaceUserService.deactivateUser(); return this.workspaceService.deactivateUser();
} }
@UseGuards(PoliciesGuard) @UseGuards(PoliciesGuard)
@@ -107,7 +93,7 @@ export class WorkspaceController {
@AuthUser() authUser: User, @AuthUser() authUser: User,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
) { ) {
return this.workspaceUserService.updateWorkspaceUserRole( return this.workspaceService.updateWorkspaceUserRole(
authUser, authUser,
workspaceUserRoleDto, workspaceUserRoleDto,
workspace.id, workspace.id,
@@ -116,37 +102,91 @@ export class WorkspaceController {
@UseGuards(PoliciesGuard) @UseGuards(PoliciesGuard)
@CheckPolicies((ability: AppAbility) => @CheckPolicies((ability: AppAbility) =>
ability.can(Action.Manage, 'WorkspaceInvitation'), ability.can(Action.Read, 'WorkspaceUser'),
) )
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('invite') @Post('invites')
async inviteUser( async getInvitations(
@Body() inviteUserDto: InviteUserDto,
@AuthUser() authUser: User,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
@Body()
pagination: PaginationOptions,
) { ) {
/* return this.workspaceInvitationService.createInvitation( return this.workspaceInvitationService.getInvitations(
authUser,
workspace.id, workspace.id,
inviteUserDto, pagination,
);*/ );
} }
@Public() @Public()
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('invite/accept') @Post('invites/info')
async acceptInvite(@Body() acceptInviteDto: AcceptInviteDto) { async getInvitationById(@Body() dto: InvitationIdDto, @Req() req: any) {
// return this.workspaceInvitationService.acceptInvitation( return this.workspaceInvitationService.getInvitationById(
// acceptInviteDto.invitationId, dto.invitationId,
//); req.raw.workspaceId,
);
} }
// TODO: authorize permission with guards @UseGuards(PoliciesGuard)
@CheckPolicies((ability: AppAbility) =>
ability.can(Action.Manage, 'WorkspaceUser'),
)
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('invite/revoke') @Post('invites/create')
async revokeInvite(@Body() revokeInviteDto: RevokeInviteDto) { async inviteUser(
// return this.workspaceInvitationService.revokeInvitation( @Body() inviteUserDto: InviteUserDto,
// revokeInviteDto.invitationId, @AuthWorkspace() workspace: Workspace,
// ); @AuthUser() authUser: User,
) {
return this.workspaceInvitationService.createInvitation(
inviteUserDto,
workspace.id,
authUser,
);
}
@UseGuards(PoliciesGuard)
@CheckPolicies((ability: AppAbility) =>
ability.can(Action.Manage, 'WorkspaceUser'),
)
@HttpCode(HttpStatus.OK)
@Post('invites/resend')
async resendInvite(
@Body() revokeInviteDto: RevokeInviteDto,
@AuthWorkspace() workspace: Workspace,
) {
return this.workspaceInvitationService.resendInvitation(
revokeInviteDto.invitationId,
workspace.id,
);
}
@UseGuards(PoliciesGuard)
@CheckPolicies((ability: AppAbility) =>
ability.can(Action.Manage, 'WorkspaceUser'),
)
@HttpCode(HttpStatus.OK)
@Post('invites/revoke')
async revokeInvite(
@Body() revokeInviteDto: RevokeInviteDto,
@AuthWorkspace() workspace: Workspace,
) {
return this.workspaceInvitationService.revokeInvitation(
revokeInviteDto.invitationId,
workspace.id,
);
}
@Public()
@HttpCode(HttpStatus.OK)
@Post('invites/accept')
async acceptInvite(
@Body() acceptInviteDto: AcceptInviteDto,
@Req() req: any,
) {
return this.workspaceInvitationService.acceptInvitation(
acceptInviteDto,
req.raw.workspaceId,
);
} }
} }
@@ -1,11 +0,0 @@
import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
export class AddWorkspaceUserDto {
@IsNotEmpty()
@IsUUID()
userId: string;
@IsNotEmpty()
@IsString()
role: string;
}
@@ -1,13 +1,35 @@
import { IsEmail, IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsEmail,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
MaxLength,
MinLength,
} from 'class-validator';
import { UserRole } from '../../../helpers/types/permission'; import { UserRole } from '../../../helpers/types/permission';
export class InviteUserDto { export class InviteUserDto {
@IsString() @IsArray()
@IsOptional() @ArrayMaxSize(50, {
name: string; message: 'you cannot invite more than 50 users at a time',
})
@ArrayMinSize(1)
@IsEmail({}, { each: true })
emails: string[];
@IsEmail() @IsOptional()
email: string; @IsArray()
@ArrayMaxSize(25, {
message: 'you cannot add invited users to more than 25 groups at a time',
})
@ArrayMinSize(0)
@IsUUID(4, { each: true })
groupIds: string[];
@IsEnum(UserRole) @IsEnum(UserRole)
role: string; role: string;
@@ -18,6 +40,19 @@ export class InvitationIdDto {
invitationId: string; invitationId: string;
} }
export class AcceptInviteDto extends InvitationIdDto {} export class AcceptInviteDto extends InvitationIdDto {
@MinLength(2)
@MaxLength(60)
@IsString()
name: string;
@MinLength(8)
@IsString()
password: string;
@IsNotEmpty()
@IsString()
token: string;
}
export class RevokeInviteDto extends InvitationIdDto {} export class RevokeInviteDto extends InvitationIdDto {}
@@ -1,106 +1,318 @@
import { Injectable } from '@nestjs/common'; import {
import { WorkspaceService } from './workspace.service'; BadRequestException,
import { UserService } from '../../user/user.service'; Injectable,
import { WorkspaceUserService } from './workspace-user.service'; Logger,
NotFoundException,
} from '@nestjs/common';
import { AcceptInviteDto, InviteUserDto } from '../dto/invitation.dto';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { executeTx } from '@docmost/db/utils';
import {
Group,
User,
WorkspaceInvitation,
} from '@docmost/db/types/entity.types';
import { MailService } from '../../../integrations/mail/mail.service';
import InvitationEmail from '@docmost/transactional/emails/invitation-email';
import { hashPassword } from '../../../helpers';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import InvitationAcceptedEmail from '@docmost/transactional/emails/invitation-accepted-email';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import { TokenService } from '../../auth/services/token.service';
import { nanoIdGen } from '../../../helpers/nanoid.utils';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { TokensDto } from '../../auth/dto/tokens.dto';
// need reworking
@Injectable() @Injectable()
export class WorkspaceInvitationService { export class WorkspaceInvitationService {
private readonly logger = new Logger(WorkspaceInvitationService.name);
constructor( constructor(
private workspaceService: WorkspaceService, private userRepo: UserRepo,
private workspaceUserService: WorkspaceUserService, private groupUserRepo: GroupUserRepo,
private userService: UserService, private mailService: MailService,
private environmentService: EnvironmentService,
private tokenService: TokenService,
@InjectKysely() private readonly db: KyselyDB,
) {} ) {}
/*
async findInvitedUserByEmail( async getInvitations(workspaceId: string, pagination: PaginationOptions) {
email, let query = this.db
workspaceId, .selectFrom('workspaceInvitations')
): Promise<WorkspaceInvitation> { .select(['id', 'email', 'role', 'workspaceId', 'createdAt'])
return this.workspaceInvitationRepository.findOneBy({ .where('workspaceId', '=', workspaceId);
email: email,
workspaceId: workspaceId, if (pagination.query) {
query = query.where((eb) =>
eb('email', 'ilike', `%${pagination.query}%`),
);
}
const result = executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
}); });
return result;
}
async getInvitationById(invitationId: string, workspaceId: string) {
const invitation = await this.db
.selectFrom('workspaceInvitations')
.select(['id', 'email', 'createdAt'])
.where('id', '=', invitationId)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
if (!invitation) {
throw new NotFoundException('Invitation not found');
}
return invitation;
} }
async createInvitation( async createInvitation(
authUser: User,
workspaceId: string,
inviteUserDto: InviteUserDto, inviteUserDto: InviteUserDto,
): Promise<WorkspaceInvitation> { workspaceId: string,
// check if invited user is already a workspace member authUser: User,
const invitedUser = ): Promise<void> {
await this.workspaceUserService.findWorkspaceUserByEmail( const { emails, role, groupIds } = inviteUserDto;
inviteUserDto.email,
workspaceId,
);
if (invitedUser) { let invites: WorkspaceInvitation[] = [];
throw new BadRequestException(
'User is already a member of this workspace', try {
); await executeTx(this.db, async (trx) => {
// we do not want to invite existing members
const findExistingUsers = await this.db
.selectFrom('users')
.select(['email'])
.where('users.email', 'in', emails)
.where('users.workspaceId', '=', workspaceId)
.execute();
let existingUserEmails = [];
if (findExistingUsers) {
existingUserEmails = findExistingUsers.map((user) => user.email);
} }
// check if user was already invited // filter out existing users
const existingInvitation = await this.findInvitedUserByEmail( const inviteEmails = emails.filter(
inviteUserDto.email, (email) => !existingUserEmails.includes(email),
workspaceId,
); );
if (existingInvitation) { let validGroups = [];
throw new BadRequestException('User has already been invited'); if (groupIds && groupIds.length > 0) {
validGroups = await trx
.selectFrom('groups')
.select(['id', 'name'])
.where('groups.id', 'in', groupIds)
.where('groups.workspaceId', '=', workspaceId)
.execute();
} }
const invitation = new WorkspaceInvitation(); const invitesToInsert = inviteEmails.map((email) => ({
invitation.workspaceId = workspaceId; email: email,
invitation.email = inviteUserDto.email; role: role,
invitation.role = inviteUserDto.role; token: nanoIdGen(16),
invitation.invitedById = authUser.id; workspaceId: workspaceId,
invitedById: authUser.id,
groupIds: validGroups?.map((group: Partial<Group>) => group.id),
}));
// TODO: send invitation email invites = await trx
.insertInto('workspaceInvitations')
return await this.workspaceInvitationRepository.save(invitation); .values(invitesToInsert)
} .onConflict((oc) => oc.columns(['email', 'workspaceId']).doNothing())
.returningAll()
async acceptInvitation(invitationId: string) { .execute();
const invitation = await this.workspaceInvitationRepository.findOneBy({
id: invitationId,
}); });
} catch (err) {
if (!invitation) { this.logger.error(`createInvitation - ${err}`);
throw new BadRequestException('Invalid or expired invitation code'); throw new BadRequestException(
'An error occurred while processing the invitations.',
);
} }
// TODO: to be completed // do not send code to do nothing users
if (invites) {
// check if user is already a member invites.forEach((invitation: WorkspaceInvitation) => {
const invitedUser = this.sendInvitationMail(
await this.workspaceUserService.findWorkspaceUserByEmail( invitation.id,
invitation.email, invitation.email,
invitation.workspaceId, invitation.token,
authUser.name,
); );
if (invitedUser) {
throw new BadRequestException(
'User is already a member of this workspace',
);
}
// add create account for user
// add the user to the workspace
return null;
}
async revokeInvitation(invitationId: string): Promise<void> {
const invitation = await this.workspaceInvitationRepository.findOneBy({
id: invitationId,
}); });
}
}
async acceptInvitation(dto: AcceptInviteDto, workspaceId: string) {
const invitation = await this.db
.selectFrom('workspaceInvitations')
.selectAll()
.where('id', '=', dto.invitationId)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
if (!invitation) { if (!invitation) {
throw new BadRequestException('Invitation not found'); throw new BadRequestException('Invitation not found');
} }
await this.workspaceInvitationRepository.delete(invitationId); if (dto.token !== invitation.token) {
throw new BadRequestException('Invalid invitation token');
} }
*/ const password = await hashPassword(dto.password);
let newUser: User;
try {
await executeTx(this.db, async (trx) => {
newUser = await trx
.insertInto('users')
.values({
name: dto.name,
email: invitation.email,
password: password,
workspaceId: workspaceId,
role: invitation.role,
lastLoginAt: new Date(),
invitedById: invitation.invitedById,
})
.returningAll()
.executeTakeFirst();
// add user to default group
await this.groupUserRepo.addUserToDefaultGroup(
newUser.id,
workspaceId,
trx,
);
if (invitation.groupIds && invitation.groupIds.length > 0) {
// Ensure the groups are valid
const validGroups = await trx
.selectFrom('groups')
.select(['id', 'name'])
.where('groups.id', 'in', invitation.groupIds)
.where('groups.workspaceId', '=', workspaceId)
.execute();
if (validGroups && validGroups.length > 0) {
const groupUsersToInsert = validGroups.map((group) => ({
userId: newUser.id,
groupId: group.id,
}));
// add user to groups specified during invite
await trx
.insertInto('groupUsers')
.values(groupUsersToInsert)
.onConflict((oc) => oc.columns(['userId', 'groupId']).doNothing())
.execute();
}
}
// delete invitation record
await trx
.deleteFrom('workspaceInvitations')
.where('id', '=', invitation.id)
.execute();
});
} catch (err: any) {
this.logger.error(`acceptInvitation - ${err}`);
if (err.message.includes('unique constraint')) {
throw new BadRequestException('Invitation already accepted');
}
throw new BadRequestException(
'Failed to accept invitation. An error occurred.',
);
}
if (!newUser) {
return;
}
// notify the inviter
const invitedByUser = await this.userRepo.findById(
invitation.invitedById,
workspaceId,
);
if (invitedByUser) {
const emailTemplate = InvitationAcceptedEmail({
invitedUserName: newUser.name,
invitedUserEmail: newUser.email,
});
await this.mailService.sendToQueue({
to: invitation.email,
subject: `${newUser.name} has accepted your Docmost invite`,
template: emailTemplate,
});
}
const tokens: TokensDto = await this.tokenService.generateTokens(newUser);
return { tokens };
}
async resendInvitation(
invitationId: string,
workspaceId: string,
): Promise<void> {
//
const invitation = await this.db
.selectFrom('workspaceInvitations')
.selectAll()
.where('id', '=', invitationId)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
if (!invitation) {
throw new BadRequestException('Invitation not found');
}
const invitedByUser = await this.userRepo.findById(
invitation.invitedById,
workspaceId,
);
await this.sendInvitationMail(
invitation.id,
invitation.email,
invitation.token,
invitedByUser.name,
);
}
async revokeInvitation(
invitationId: string,
workspaceId: string,
): Promise<void> {
await this.db
.deleteFrom('workspaceInvitations')
.where('id', '=', invitationId)
.where('workspaceId', '=', workspaceId)
.execute();
}
async sendInvitationMail(
invitationId: string,
inviteeEmail: string,
inviteToken: string,
invitedByName: string,
): Promise<void> {
const inviteLink = `${this.environmentService.getAppUrl()}/invites/${invitationId}?token=${inviteToken}`;
const emailTemplate = InvitationEmail({
inviteLink,
});
await this.mailService.sendToQueue({
to: inviteeEmail,
subject: `${invitedByName} invited you to Docmost`,
template: emailTemplate,
});
}
} }
@@ -1,63 +0,0 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { UpdateWorkspaceUserRoleDto } from '../dto/update-workspace-user-role.dto';
import { PaginationOptions } from '../../../kysely/pagination/pagination-options';
import { UserRole } from '../../../helpers/types/permission';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { User } from '@docmost/db/types/entity.types';
import { PaginationResult } from '@docmost/db/pagination/pagination';
@Injectable()
export class WorkspaceUserService {
constructor(private userRepo: UserRepo) {}
async getWorkspaceUsers(
workspaceId: string,
pagination: PaginationOptions,
): Promise<PaginationResult<User>> {
const users = await this.userRepo.getUsersPaginated(
workspaceId,
pagination,
);
return users;
}
async updateWorkspaceUserRole(
authUser: User,
userRoleDto: UpdateWorkspaceUserRoleDto,
workspaceId: string,
) {
const user = await this.userRepo.findById(userRoleDto.userId, workspaceId);
if (!user) {
throw new BadRequestException('Workspace member not found');
}
if (user.role === userRoleDto.role) {
return user;
}
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId(
UserRole.OWNER,
workspaceId,
);
if (user.role === UserRole.OWNER && workspaceOwnerCount === 1) {
throw new BadRequestException(
'There must be at least one workspace owner',
);
}
await this.userRepo.updateUser(
{
role: userRoleDto.role,
},
user.id,
workspaceId,
);
}
async deactivateUser(): Promise<any> {
return 'todo';
}
}
@@ -8,7 +8,6 @@ import { UpdateWorkspaceDto } from '../dto/update-workspace.dto';
import { SpaceService } from '../../space/services/space.service'; import { SpaceService } from '../../space/services/space.service';
import { CreateSpaceDto } from '../../space/dto/create-space.dto'; import { CreateSpaceDto } from '../../space/dto/create-space.dto';
import { SpaceRole, UserRole } from '../../../helpers/types/permission'; import { SpaceRole, UserRole } from '../../../helpers/types/permission';
import { GroupService } from '../../group/services/group.service';
import { SpaceMemberService } from '../../space/services/space-member.service'; import { SpaceMemberService } from '../../space/services/space-member.service';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
@@ -16,6 +15,11 @@ import { executeTx } from '@docmost/db/utils';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { User } from '@docmost/db/types/entity.types'; import { User } from '@docmost/db/types/entity.types';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo'; import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { PaginationResult } from '@docmost/db/pagination/pagination';
import { UpdateWorkspaceUserRoleDto } from '../dto/update-workspace-user-role.dto';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
@Injectable() @Injectable()
export class WorkspaceService { export class WorkspaceService {
@@ -23,8 +27,9 @@ export class WorkspaceService {
private workspaceRepo: WorkspaceRepo, private workspaceRepo: WorkspaceRepo,
private spaceService: SpaceService, private spaceService: SpaceService,
private spaceMemberService: SpaceMemberService, private spaceMemberService: SpaceMemberService,
private groupService: GroupService, private groupRepo: GroupRepo,
private groupUserRepo: GroupUserRepo, private groupUserRepo: GroupUserRepo,
private userRepo: UserRepo,
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
) {} ) {}
@@ -33,7 +38,6 @@ export class WorkspaceService {
} }
async getWorkspaceInfo(workspaceId: string) { async getWorkspaceInfo(workspaceId: string) {
// todo: add member count
const workspace = this.workspaceRepo.findById(workspaceId); const workspace = this.workspaceRepo.findById(workspaceId);
if (!workspace) { if (!workspace) {
throw new NotFoundException('Workspace not found'); throw new NotFoundException('Workspace not found');
@@ -61,11 +65,10 @@ export class WorkspaceService {
); );
// create default group // create default group
const group = await this.groupService.createDefaultGroup( const group = await this.groupRepo.createDefaultGroup(workspace.id, {
workspace.id, userId: user.id,
user.id, trx: trx,
trx, });
);
// add user to workspace // add user to workspace
await trx await trx
@@ -181,11 +184,54 @@ export class WorkspaceService {
return workspace; return workspace;
} }
async delete(workspaceId: string): Promise<void> { async getWorkspaceUsers(
const workspace = await this.workspaceRepo.findById(workspaceId); workspaceId: string,
if (!workspace) { pagination: PaginationOptions,
throw new NotFoundException('Workspace not found'); ): Promise<PaginationResult<User>> {
const users = await this.userRepo.getUsersPaginated(
workspaceId,
pagination,
);
return users;
} }
//delete
async updateWorkspaceUserRole(
authUser: User,
userRoleDto: UpdateWorkspaceUserRoleDto,
workspaceId: string,
) {
const user = await this.userRepo.findById(userRoleDto.userId, workspaceId);
if (!user) {
throw new BadRequestException('Workspace member not found');
}
if (user.role === userRoleDto.role) {
return user;
}
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId(
UserRole.OWNER,
workspaceId,
);
if (user.role === UserRole.OWNER && workspaceOwnerCount === 1) {
throw new BadRequestException(
'There must be at least one workspace owner',
);
}
await this.userRepo.updateUser(
{
role: userRoleDto.role,
},
user.id,
workspaceId,
);
}
async deactivateUser(): Promise<any> {
return 'todo';
} }
} }
@@ -3,18 +3,12 @@ import { WorkspaceService } from './services/workspace.service';
import { WorkspaceController } from './controllers/workspace.controller'; import { WorkspaceController } from './controllers/workspace.controller';
import { SpaceModule } from '../space/space.module'; import { SpaceModule } from '../space/space.module';
import { WorkspaceInvitationService } from './services/workspace-invitation.service'; import { WorkspaceInvitationService } from './services/workspace-invitation.service';
import { WorkspaceUserService } from './services/workspace-user.service'; import { TokenModule } from '../auth/token.module';
import { UserModule } from '../user/user.module';
import { GroupModule } from '../group/group.module';
@Module({ @Module({
imports: [SpaceModule, UserModule, GroupModule], imports: [SpaceModule, TokenModule],
controllers: [WorkspaceController], controllers: [WorkspaceController],
providers: [ providers: [WorkspaceService, WorkspaceInvitationService],
WorkspaceService,
WorkspaceUserService,
WorkspaceInvitationService,
],
exports: [WorkspaceService], exports: [WorkspaceService],
}) })
export class WorkspaceModule {} export class WorkspaceModule {}
+5
View File
@@ -0,0 +1,5 @@
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { customAlphabet } = require('fix-esm').require('nanoid');
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
export const nanoIdGen = customAlphabet(alphabet, 10);
@@ -9,9 +9,21 @@ export class EnvironmentService {
return this.configService.get<string>('NODE_ENV'); return this.configService.get<string>('NODE_ENV');
} }
getAppUrl(): string {
return (
this.configService.get<string>('APP_URL') ||
'http://localhost:' + this.getPort()
);
}
getPort(): number { getPort(): number {
return parseInt(this.configService.get<string>('PORT')); return parseInt(this.configService.get<string>('PORT'));
} }
getAppSecret(): string {
return this.configService.get<string>('APP_SECRET');
}
getDatabaseURL(): string { getDatabaseURL(): string {
return this.configService.get<string>('DATABASE_URL'); return this.configService.get<string>('DATABASE_URL');
} }
@@ -7,6 +7,9 @@ export class EnvironmentVariables {
@IsUrl({ protocols: ['postgres', 'postgresql'], require_tld: false }) @IsUrl({ protocols: ['postgres', 'postgresql'], require_tld: false })
DATABASE_URL: string; DATABASE_URL: string;
@IsString()
APP_SECRET: string;
} }
export function validate(config: Record<string, any>) { export function validate(config: Record<string, any>) {
@@ -14,7 +17,13 @@ export function validate(config: Record<string, any>) {
const errors = validateSync(validatedConfig); const errors = validateSync(validatedConfig);
if (errors.length > 0) { if (errors.length > 0) {
throw new Error(errors.toString()); errors.map((error) => {
console.error(error.toString());
});
console.log(
'Please fix the environment variables and try again. Shutting down...',
);
process.exit(1);
} }
return validatedConfig; return validatedConfig;
} }
@@ -18,8 +18,9 @@ export class MailService {
async sendEmail(message: MailMessage): Promise<void> { async sendEmail(message: MailMessage): Promise<void> {
if (message.template) { if (message.template) {
// in case this method is used directly // in case this method is used directly. we do not send the tsx template from queue
message.html = render(message.template); message.html = render(message.template, { pretty: true });
message.text = render(message.template, { plainText: true });
} }
const sender = `${this.environmentService.getMailFromName()} <${this.environmentService.getMailFromAddress()}> `; const sender = `${this.environmentService.getMailFromName()} <${this.environmentService.getMailFromAddress()}> `;
@@ -29,7 +30,10 @@ export class MailService {
async sendToQueue(message: MailMessage): Promise<void> { async sendToQueue(message: MailMessage): Promise<void> {
if (message.template) { if (message.template) {
// transform the React object because it gets lost when sent via the queue // transform the React object because it gets lost when sent via the queue
message.html = render(message.template); message.html = render(message.template, { pretty: true });
message.text = render(message.template, {
plainText: true,
});
delete message.template; delete message.template;
} }
await this.emailQueue.add(QueueJob.SEND_EMAIL, message); await this.emailQueue.add(QueueJob.SEND_EMAIL, message);
@@ -23,7 +23,7 @@ export const paragraph = {
fontFamily: fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
color: '#333', color: '#333',
lineHeight: 1.5, lineHeight: 1,
fontSize: 14, fontSize: 14,
}; };
@@ -51,3 +51,16 @@ export const footer = {
maxWidth: '580px', maxWidth: '580px',
margin: '0 auto', margin: '0 auto',
}; };
export const button = {
backgroundColor: '#176ae5',
borderRadius: '3px',
color: '#fff',
fontFamily: "'Open Sans', 'Helvetica Neue', Arial",
fontSize: '16px',
textDecoration: 'none',
textAlign: 'center' as const,
display: 'block',
width: '100px',
padding: '8px',
};
@@ -3,11 +3,11 @@ import * as React from 'react';
import { content, paragraph } from '../css/styles'; import { content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials'; import { MailBody } from '../partials/partials';
interface ChangePasswordEmailProps { interface Props {
username?: string; username?: string;
} }
export const ChangePasswordEmail = ({ username }: ChangePasswordEmailProps) => { export const ChangePasswordEmail = ({ username }: Props) => {
return ( return (
<MailBody> <MailBody>
<Section style={content}> <Section style={content}>
@@ -0,0 +1,28 @@
import { Section, Text } from '@react-email/components';
import * as React from 'react';
import { content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials';
interface Props {
invitedUserName: string;
invitedUserEmail: string;
}
export const InvitationAcceptedEmail = ({
invitedUserName,
invitedUserEmail,
}: Props) => {
return (
<MailBody>
<Section style={content}>
<Text style={paragraph}>Hi there,</Text>
<Text style={paragraph}>
{invitedUserName} ({invitedUserEmail}) has accepted your invitation,
and is now a member of the workspace.
</Text>
</Section>
</MailBody>
);
};
export default InvitationAcceptedEmail;
@@ -0,0 +1,37 @@
import { Section, Text, Button } from '@react-email/components';
import * as React from 'react';
import { button, content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials';
interface Props {
inviteLink: string;
}
export const InvitationEmail = ({ inviteLink }: Props) => {
return (
<MailBody>
<Section style={content}>
<Text style={paragraph}>Hi there,</Text>
<Text style={paragraph}>You have been invited to Docmost.</Text>
<Text style={paragraph}>
Please click the button below to accept this invitation.
</Text>
</Section>
<Section
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingLeft: '15px',
paddingBottom: '15px',
}}
>
<Button href={inviteLink} style={button}>
Accept Invite
</Button>
</Section>
</MailBody>
);
};
export default InvitationEmail;
@@ -40,7 +40,7 @@ export function MailFooter() {
<Section style={footer}> <Section style={footer}>
<Row> <Row>
<Text style={{ textAlign: 'center', color: '#706a7b' }}> <Text style={{ textAlign: 'center', color: '#706a7b' }}>
© {new Date().getFullYear()}, All Rights Reserved <br /> © {new Date().getFullYear()} Docmost, All Rights Reserved <br />
</Text> </Text>
</Row> </Row>
</Section> </Section>
@@ -1,34 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('page_ordering')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
)
.addColumn('entity_id', 'uuid', (col) => col.notNull())
.addColumn('entity_type', 'varchar', (col) => col.notNull()) // can be page or space
.addColumn('children_ids', sql`uuid[]`, (col) => col.notNull())
.addColumn('space_id', 'uuid', (col) =>
col.references('spaces.id').onDelete('cascade').notNull(),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('deleted_at', 'timestamptz', (col) => col)
.addUniqueConstraint('page_ordering_entity_id_entity_type_unique', [
'entity_id',
'entity_type',
])
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('page_ordering').execute();
}
@@ -0,0 +1,43 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('workspace_invitations')
.addColumn('token', 'varchar', (col) => col)
.addColumn('group_ids', sql`uuid[]`, (col) => col)
.execute();
await db.schema
.alterTable('workspace_invitations')
.dropColumn('status')
.execute();
await db.schema
.alterTable('workspace_invitations')
.addUniqueConstraint('invitation_email_workspace_id_unique', [
'email',
'workspace_id',
])
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('workspace_invitations')
.dropColumn('token')
.execute();
await db.schema
.alterTable('workspace_invitations')
.dropColumn('group_ids')
.execute();
await db.schema
.alterTable('workspace_invitations')
.addColumn('status', 'varchar', (col) => col)
.execute();
await db.schema
.alterTable('workspace_invitations')
.dropConstraint('invitation_email_workspace_id_unique')
.execute();
}
@@ -0,0 +1,14 @@
import { type Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('users')
.addColumn('invited_by_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('users').dropColumn('invited_by_id').execute();
}
@@ -1,14 +1,24 @@
import { Injectable } from '@nestjs/common'; import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx } from '@docmost/db/utils'; import { dbOrTx, executeTx } from '@docmost/db/utils';
import { GroupUser, InsertableGroupUser } from '@docmost/db/types/entity.types'; import { GroupUser, InsertableGroupUser } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '../../pagination/pagination-options'; import { PaginationOptions } from '../../pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination'; import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
@Injectable() @Injectable()
export class GroupUserRepo { export class GroupUserRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {} constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly groupRepo: GroupRepo,
private readonly userRepo: UserRepo,
) {}
async getGroupUserById( async getGroupUserById(
userId: string, userId: string,
@@ -62,6 +72,78 @@ export class GroupUserRepo {
return result; return result;
} }
async addUserToGroup(
userId: string,
groupId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
await executeTx(
this.db,
async (trx) => {
const group = await this.groupRepo.findById(groupId, workspaceId, {
trx,
});
if (!group) {
throw new NotFoundException('Group not found');
}
const user = await this.userRepo.findById(userId, workspaceId, {
trx: trx,
});
if (!user) {
throw new NotFoundException('User not found');
}
const groupUserExists = await this.getGroupUserById(
userId,
groupId,
trx,
);
if (groupUserExists) {
throw new BadRequestException(
'User is already a member of this group',
);
}
await this.insertGroupUser(
{
userId,
groupId,
},
trx,
);
},
trx,
);
}
async addUserToDefaultGroup(
userId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
await executeTx(
this.db,
async (trx) => {
const defaultGroup = await this.groupRepo.getDefaultGroup(
workspaceId,
trx,
);
await this.insertGroupUser(
{
userId,
groupId: defaultGroup.id,
},
trx,
);
},
trx,
);
}
async delete(userId: string, groupId: string): Promise<void> { async delete(userId: string, groupId: string): Promise<void> {
await this.db await this.db
.deleteFrom('groupUsers') .deleteFrom('groupUsers')
@@ -11,6 +11,7 @@ import { ExpressionBuilder, sql } from 'kysely';
import { PaginationOptions } from '../../pagination/pagination-options'; import { PaginationOptions } from '../../pagination/pagination-options';
import { DB } from '@docmost/db/types/db'; import { DB } from '@docmost/db/types/db';
import { executeWithPagination } from '@docmost/db/pagination/pagination'; import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { DefaultGroup } from '../../../core/group/dto/create-group.dto';
@Injectable() @Injectable()
export class GroupRepo { export class GroupRepo {
@@ -19,9 +20,10 @@ export class GroupRepo {
async findById( async findById(
groupId: string, groupId: string,
workspaceId: string, workspaceId: string,
opts?: { includeMemberCount: boolean }, opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction },
): Promise<Group> { ): Promise<Group> {
return await this.db const db = dbOrTx(this.db, opts?.trx);
return db
.selectFrom('groups') .selectFrom('groups')
.selectAll('groups') .selectAll('groups')
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount)) .$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
@@ -33,9 +35,10 @@ export class GroupRepo {
async findByName( async findByName(
groupName: string, groupName: string,
workspaceId: string, workspaceId: string,
opts?: { includeMemberCount: boolean }, opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction },
): Promise<Group> { ): Promise<Group> {
return await this.db const db = dbOrTx(this.db, opts?.trx);
return db
.selectFrom('groups') .selectFrom('groups')
.selectAll('groups') .selectAll('groups')
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount)) .$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
@@ -85,6 +88,21 @@ export class GroupRepo {
); );
} }
async createDefaultGroup(
workspaceId: string,
opts?: { userId?: string; trx?: KyselyTransaction },
): Promise<Group> {
const { userId, trx } = opts;
const insertableGroup: InsertableGroup = {
name: DefaultGroup.EVERYONE,
isDefault: true,
creatorId: userId,
workspaceId: workspaceId,
};
return this.insertGroup(insertableGroup, trx);
}
async getGroupsPaginated(workspaceId: string, pagination: PaginationOptions) { async getGroupsPaginated(workspaceId: string, pagination: PaginationOptions) {
let query = this.db let query = this.db
.selectFrom('groups') .selectFrom('groups')
@@ -93,7 +93,7 @@ export class UserRepo {
trx?: KyselyTransaction, trx?: KyselyTransaction,
): Promise<User> { ): Promise<User> {
const user: InsertableUser = { const user: InsertableUser = {
name: insertableUser.name || insertableUser.email.split('@')[0], name: insertableUser.name || insertableUser.email.toLowerCase(),
email: insertableUser.email.toLowerCase(), email: insertableUser.email.toLowerCase(),
password: await hashPassword(insertableUser.password), password: await hashPassword(insertableUser.password),
locale: 'en', locale: 'en',
+6 -9
View File
@@ -1,15 +1,10 @@
import type { ColumnType } from 'kysely'; import type { ColumnType } from "kysely";
export type Generated<T> = export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U> ? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>; : ColumnType<T, T | undefined, T>;
export type Int8 = ColumnType< export type Int8 = ColumnType<string, bigint | number | string, bigint | number | string>;
string,
bigint | number | string,
bigint | number | string
>;
export type Json = JsonValue; export type Json = JsonValue;
@@ -151,6 +146,7 @@ export interface Users {
email: string; email: string;
emailVerifiedAt: Timestamp | null; emailVerifiedAt: Timestamp | null;
id: Generated<string>; id: Generated<string>;
invitedById: string | null;
lastActiveAt: Timestamp | null; lastActiveAt: Timestamp | null;
lastLoginAt: Timestamp | null; lastLoginAt: Timestamp | null;
locale: string | null; locale: string | null;
@@ -167,10 +163,11 @@ export interface Users {
export interface WorkspaceInvitations { export interface WorkspaceInvitations {
createdAt: Generated<Timestamp>; createdAt: Generated<Timestamp>;
email: string; email: string;
groupIds: string[] | null;
id: Generated<string>; id: Generated<string>;
invitedById: string | null; invitedById: string | null;
role: string; role: string;
status: string | null; token: string | null;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
workspaceId: string; workspaceId: string;
} }
+2 -2
View File
@@ -1,9 +1,9 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { WsGateway } from './ws.gateway'; import { WsGateway } from './ws.gateway';
import { AuthModule } from '../core/auth/auth.module'; import { TokenModule } from '../core/auth/token.module';
@Module({ @Module({
imports: [AuthModule], imports: [TokenModule],
providers: [WsGateway], providers: [WsGateway],
}) })
export class WsModule {} export class WsModule {}
+9134 -7081
View File
File diff suppressed because it is too large Load Diff