implement new invitation system

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

View File

@ -1,3 +1,6 @@
APP_URL=http://localhost
APP_SECRET=
PORT=3000
DEBUG_MODE=true
NODE_ENV=production

View File

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

View File

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

View File

@ -1,6 +1,12 @@
.authBackground {
position: relative;
min-height: 100vh;
background-size: cover;
background-image: url(https://images.unsplash.com/photo-1701010063921-5f3255259e6d?q=80&w=3024&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D);
position: relative;
min-height: 100vh;
background-size: cover;
background-image: url(https://images.unsplash.com/photo-1701010063921-5f3255259e6d?q=80&w=3024&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D);
}
.container {
box-shadow: rgba(0, 0, 0, 0.07) 0px 2px 45px 4px;
border-radius: 4px;
background: light-dark(var(--mantine-color-body), rgba(0, 0, 0, 0.1));
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -18,6 +18,7 @@
"migration:down": "tsx ./src/kysely/migrate.ts down",
"migration:latest": "tsx ./src/kysely/migrate.ts latest",
"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",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
@ -30,7 +31,6 @@
"@aws-sdk/client-s3": "^3.565.0",
"@aws-sdk/s3-request-presigner": "^3.565.0",
"@casl/ability": "^6.7.1",
"@docmost/transactional": "workspace:^",
"@fastify/multipart": "^8.2.0",
"@fastify/static": "^7.0.3",
"@nestjs/bullmq": "^10.1.1",
@ -53,10 +53,12 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"fastify": "^4.26.2",
"fix-esm": "^1.0.1",
"fs-extra": "^11.2.0",
"kysely": "^0.27.3",
"kysely-migration-cli": "^0.4.0",
"mime-types": "^2.1.35",
"nanoid": "^5.0.7",
"nestjs-kysely": "^0.1.7",
"nodemailer": "^6.9.13",
"passport-jwt": "^4.0.1",

View File

@ -1,5 +1,4 @@
import { Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { AuthModule } from '../core/auth/auth.module';
import { AuthenticationExtension } from './extensions/authentication.extension';
import { PersistenceExtension } from './extensions/persistence.extension';
import { CollaborationGateway } from './collaboration.gateway';
@ -8,6 +7,7 @@ import { CollabWsAdapter } from './adapter/collab-ws.adapter';
import { IncomingMessage } from 'http';
import { WebSocket } from 'ws';
import { HistoryExtension } from './extensions/history.extension';
import { TokenModule } from '../core/auth/token.module';
@Module({
providers: [
@ -16,7 +16,7 @@ import { HistoryExtension } from './extensions/history.extension';
PersistenceExtension,
HistoryExtension,
],
imports: [AuthModule],
imports: [TokenModule],
})
export class CollaborationModule implements OnModuleInit, OnModuleDestroy {
private collabWsAdapter: CollabWsAdapter;

View File

@ -1,32 +1,14 @@
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
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 { WorkspaceModule } from '../workspace/workspace.module';
import { SignupService } from './services/signup.service';
import { GroupModule } from '../group/group.module';
import { TokenModule } from './token.module';
@Module({
imports: [
JwtModule.registerAsync({
useFactory: async (environmentService: EnvironmentService) => {
return {
secret: environmentService.getJwtSecret(),
signOptions: {
expiresIn: environmentService.getJwtTokenExpiresIn(),
},
};
},
inject: [EnvironmentService],
}),
WorkspaceModule,
GroupModule,
],
imports: [TokenModule, WorkspaceModule],
controllers: [AuthController],
providers: [AuthService, SignupService, TokenService, JwtStrategy],
exports: [TokenService],
providers: [AuthService, SignupService, JwtStrategy],
})
export class AuthModule {}

View File

@ -9,8 +9,8 @@ import {
export class CreateUserDto {
@IsOptional()
@MinLength(3)
@MaxLength(35)
@MinLength(2)
@MaxLength(60)
@IsString()
name: string;

View File

@ -3,19 +3,19 @@ import { CreateUserDto } from '../dto/create-user.dto';
import { WorkspaceService } from '../../workspace/services/workspace.service';
import { CreateWorkspaceDto } from '../../workspace/dto/create-workspace.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 { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { executeTx } from '@docmost/db/utils';
import { InjectKysely } from 'nestjs-kysely';
import { User } from '@docmost/db/types/entity.types';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
@Injectable()
export class SignupService {
constructor(
private userRepo: UserRepo,
private workspaceService: WorkspaceService,
private groupUserService: GroupUserService,
private groupUserRepo: GroupUserRepo,
@InjectKysely() private readonly db: KyselyDB,
) {}
@ -56,7 +56,7 @@ export class SignupService {
);
// add user to default group
await this.groupUserService.addUserToDefaultGroup(
await this.groupUserRepo.addUserToDefaultGroup(
user.id,
workspaceId,
trx,

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 {}

View File

@ -11,7 +11,6 @@ import { User, Workspace } from '@docmost/db/types/entity.types';
export type Subjects =
| 'Workspace'
| 'WorkspaceInvitation'
| 'Space'
| 'SpaceMember'
| 'Group'
@ -36,8 +35,6 @@ export default class CaslAbilityFactory {
can([Action.Manage], 'Workspace');
can([Action.Manage], 'WorkspaceUser');
can([Action.Manage], 'WorkspaceInvitation');
// Groups
can([Action.Manage], 'Group');
can([Action.Manage], 'GroupUser');

View File

@ -66,8 +66,7 @@ export class CommentService {
workspaceId: workspaceId,
});
// return created comment and creator relation
return this.findById(createdComment.id);
return createdComment;
}
async findByPageId(
@ -114,7 +113,12 @@ export class CommentService {
return comment;
}
async remove(id: string): Promise<void> {
await this.commentRepo.deleteComment(id);
async remove(commentId: string): Promise<void> {
const comment = await this.commentRepo.findById(commentId);
if (!comment) {
throw new NotFoundException('Comment not found');
}
await this.commentRepo.deleteComment(commentId);
}
}

View File

@ -2,13 +2,9 @@ import { ArrayMaxSize, ArrayMinSize, IsArray, IsUUID } from 'class-validator';
import { GroupIdDto } from './group-id.dto';
export class AddGroupUserDto extends GroupIdDto {
// @IsOptional()
// @IsUUID()
// userId: string;
@IsArray()
@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)
@IsUUID(4, { each: true })

View File

@ -7,17 +7,14 @@ import {
} from '@nestjs/common';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { GroupService } from './group.service';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { executeTx } from '@docmost/db/utils';
import { KyselyDB } from '@docmost/db/types/kysely.types';
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 { UserRepo } from '@docmost/db/repos/user/user.repo';
@Injectable()
export class GroupUserService {
constructor(
private groupRepo: GroupRepo,
private groupUserRepo: GroupUserRepo,
private userRepo: UserRepo,
@Inject(forwardRef(() => GroupService))
@ -40,24 +37,6 @@ export class GroupUserService {
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(
userIds: string[],
groupId: string,
@ -90,48 +69,6 @@ export class GroupUserService {
.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(
userId: string,
groupId: string,

View File

@ -4,42 +4,22 @@ import {
HttpCode,
HttpStatus,
Post,
UnauthorizedException,
UseGuards,
} from '@nestjs/common';
import { UserService } from './user.service';
import { UpdateUserDto } from './dto/update-user.dto';
import { AuthUser } from '../../decorators/auth-user.decorator';
import { JwtAuthGuard } from '../../guards/jwt-auth.guard';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { AuthWorkspace } from '../../decorators/auth-workspace.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
@UseGuards(JwtAuthGuard)
@Controller('users')
export class UserController {
constructor(
private readonly userService: UserService,
private userRepo: UserRepo,
) {}
constructor(private readonly userService: UserService) {}
@HttpCode(HttpStatus.OK)
@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(
@AuthUser() authUser: User,
@AuthWorkspace() workspace: Workspace,

View File

@ -4,19 +4,20 @@ import {
HttpCode,
HttpStatus,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { WorkspaceService } from '../services/workspace.service';
import { UpdateWorkspaceDto } from '../dto/update-workspace.dto';
import { DeleteWorkspaceDto } from '../dto/delete-workspace.dto';
import { UpdateWorkspaceUserRoleDto } from '../dto/update-workspace-user-role.dto';
import { AuthUser } from '../../../decorators/auth-user.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 { Public } from '../../../decorators/public.decorator';
import {
AcceptInviteDto,
InvitationIdDto,
InviteUserDto,
RevokeInviteDto,
} from '../dto/invitation.dto';
@ -24,7 +25,6 @@ import { Action } from '../../casl/ability.action';
import { CheckPolicies } from '../../casl/decorators/policies.decorator';
import { AppAbility } from '../../casl/abilities/casl-ability.factory';
import { PoliciesGuard } from '../../casl/guards/policies.guard';
import { WorkspaceUserService } from '../services/workspace-user.service';
import { JwtAuthGuard } from '../../../guards/jwt-auth.guard';
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 {
constructor(
private readonly workspaceService: WorkspaceService,
private readonly workspaceUserService: WorkspaceUserService,
private readonly workspaceInvitationService: WorkspaceInvitationService,
) {}
@ -59,16 +58,6 @@ export class WorkspaceController {
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)
@CheckPolicies((ability: AppAbility) =>
ability.can(Action.Read, 'WorkspaceUser'),
@ -80,10 +69,7 @@ export class WorkspaceController {
pagination: PaginationOptions,
@AuthWorkspace() workspace: Workspace,
) {
return this.workspaceUserService.getWorkspaceUsers(
workspace.id,
pagination,
);
return this.workspaceService.getWorkspaceUsers(workspace.id, pagination);
}
@UseGuards(PoliciesGuard)
@ -93,7 +79,7 @@ export class WorkspaceController {
@HttpCode(HttpStatus.OK)
@Post('members/deactivate')
async deactivateWorkspaceMember() {
return this.workspaceUserService.deactivateUser();
return this.workspaceService.deactivateUser();
}
@UseGuards(PoliciesGuard)
@ -107,7 +93,7 @@ export class WorkspaceController {
@AuthUser() authUser: User,
@AuthWorkspace() workspace: Workspace,
) {
return this.workspaceUserService.updateWorkspaceUserRole(
return this.workspaceService.updateWorkspaceUserRole(
authUser,
workspaceUserRoleDto,
workspace.id,
@ -116,37 +102,91 @@ export class WorkspaceController {
@UseGuards(PoliciesGuard)
@CheckPolicies((ability: AppAbility) =>
ability.can(Action.Manage, 'WorkspaceInvitation'),
ability.can(Action.Read, 'WorkspaceUser'),
)
@HttpCode(HttpStatus.OK)
@Post('invite')
async inviteUser(
@Body() inviteUserDto: InviteUserDto,
@AuthUser() authUser: User,
@Post('invites')
async getInvitations(
@AuthWorkspace() workspace: Workspace,
@Body()
pagination: PaginationOptions,
) {
/* return this.workspaceInvitationService.createInvitation(
authUser,
return this.workspaceInvitationService.getInvitations(
workspace.id,
inviteUserDto,
);*/
pagination,
);
}
@Public()
@HttpCode(HttpStatus.OK)
@Post('invite/accept')
async acceptInvite(@Body() acceptInviteDto: AcceptInviteDto) {
// return this.workspaceInvitationService.acceptInvitation(
// acceptInviteDto.invitationId,
//);
@Post('invites/info')
async getInvitationById(@Body() dto: InvitationIdDto, @Req() req: any) {
return this.workspaceInvitationService.getInvitationById(
dto.invitationId,
req.raw.workspaceId,
);
}
// TODO: authorize permission with guards
@UseGuards(PoliciesGuard)
@CheckPolicies((ability: AppAbility) =>
ability.can(Action.Manage, 'WorkspaceUser'),
)
@HttpCode(HttpStatus.OK)
@Post('invite/revoke')
async revokeInvite(@Body() revokeInviteDto: RevokeInviteDto) {
// return this.workspaceInvitationService.revokeInvitation(
// revokeInviteDto.invitationId,
// );
@Post('invites/create')
async inviteUser(
@Body() inviteUserDto: InviteUserDto,
@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,
);
}
}

View File

@ -1,11 +0,0 @@
import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
export class AddWorkspaceUserDto {
@IsNotEmpty()
@IsUUID()
userId: string;
@IsNotEmpty()
@IsString()
role: string;
}

View File

@ -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';
export class InviteUserDto {
@IsString()
@IsOptional()
name: string;
@IsArray()
@ArrayMaxSize(50, {
message: 'you cannot invite more than 50 users at a time',
})
@ArrayMinSize(1)
@IsEmail({}, { each: true })
emails: string[];
@IsEmail()
email: string;
@IsOptional()
@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)
role: string;
@ -18,6 +40,19 @@ export class InvitationIdDto {
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 {}

View File

@ -1,106 +1,318 @@
import { Injectable } from '@nestjs/common';
import { WorkspaceService } from './workspace.service';
import { UserService } from '../../user/user.service';
import { WorkspaceUserService } from './workspace-user.service';
import {
BadRequestException,
Injectable,
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()
export class WorkspaceInvitationService {
private readonly logger = new Logger(WorkspaceInvitationService.name);
constructor(
private workspaceService: WorkspaceService,
private workspaceUserService: WorkspaceUserService,
private userService: UserService,
private userRepo: UserRepo,
private groupUserRepo: GroupUserRepo,
private mailService: MailService,
private environmentService: EnvironmentService,
private tokenService: TokenService,
@InjectKysely() private readonly db: KyselyDB,
) {}
/*
async findInvitedUserByEmail(
email,
workspaceId,
): Promise<WorkspaceInvitation> {
return this.workspaceInvitationRepository.findOneBy({
email: email,
workspaceId: workspaceId,
async getInvitations(workspaceId: string, pagination: PaginationOptions) {
let query = this.db
.selectFrom('workspaceInvitations')
.select(['id', 'email', 'role', 'workspaceId', 'createdAt'])
.where('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(
authUser: User,
workspaceId: string,
inviteUserDto: InviteUserDto,
): Promise<WorkspaceInvitation> {
// check if invited user is already a workspace member
const invitedUser =
await this.workspaceUserService.findWorkspaceUserByEmail(
inviteUserDto.email,
workspaceId,
);
workspaceId: string,
authUser: User,
): Promise<void> {
const { emails, role, groupIds } = inviteUserDto;
if (invitedUser) {
let invites: WorkspaceInvitation[] = [];
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);
}
// filter out existing users
const inviteEmails = emails.filter(
(email) => !existingUserEmails.includes(email),
);
let validGroups = [];
if (groupIds && groupIds.length > 0) {
validGroups = await trx
.selectFrom('groups')
.select(['id', 'name'])
.where('groups.id', 'in', groupIds)
.where('groups.workspaceId', '=', workspaceId)
.execute();
}
const invitesToInsert = inviteEmails.map((email) => ({
email: email,
role: role,
token: nanoIdGen(16),
workspaceId: workspaceId,
invitedById: authUser.id,
groupIds: validGroups?.map((group: Partial<Group>) => group.id),
}));
invites = await trx
.insertInto('workspaceInvitations')
.values(invitesToInsert)
.onConflict((oc) => oc.columns(['email', 'workspaceId']).doNothing())
.returningAll()
.execute();
});
} catch (err) {
this.logger.error(`createInvitation - ${err}`);
throw new BadRequestException(
'User is already a member of this workspace',
'An error occurred while processing the invitations.',
);
}
// check if user was already invited
const existingInvitation = await this.findInvitedUserByEmail(
inviteUserDto.email,
workspaceId,
);
if (existingInvitation) {
throw new BadRequestException('User has already been invited');
// do not send code to do nothing users
if (invites) {
invites.forEach((invitation: WorkspaceInvitation) => {
this.sendInvitationMail(
invitation.id,
invitation.email,
invitation.token,
authUser.name,
);
});
}
const invitation = new WorkspaceInvitation();
invitation.workspaceId = workspaceId;
invitation.email = inviteUserDto.email;
invitation.role = inviteUserDto.role;
invitation.invitedById = authUser.id;
// TODO: send invitation email
return await this.workspaceInvitationRepository.save(invitation);
}
async acceptInvitation(invitationId: string) {
const invitation = await this.workspaceInvitationRepository.findOneBy({
id: invitationId,
});
if (!invitation) {
throw new BadRequestException('Invalid or expired invitation code');
}
// TODO: to be completed
// check if user is already a member
const invitedUser =
await this.workspaceUserService.findWorkspaceUserByEmail(
invitation.email,
invitation.workspaceId,
);
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) {
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,
});
}
}

View File

@ -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';
}
}

View File

@ -8,7 +8,6 @@ import { UpdateWorkspaceDto } from '../dto/update-workspace.dto';
import { SpaceService } from '../../space/services/space.service';
import { CreateSpaceDto } from '../../space/dto/create-space.dto';
import { SpaceRole, UserRole } from '../../../helpers/types/permission';
import { GroupService } from '../../group/services/group.service';
import { SpaceMemberService } from '../../space/services/space-member.service';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
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 { User } from '@docmost/db/types/entity.types';
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()
export class WorkspaceService {
@ -23,8 +27,9 @@ export class WorkspaceService {
private workspaceRepo: WorkspaceRepo,
private spaceService: SpaceService,
private spaceMemberService: SpaceMemberService,
private groupService: GroupService,
private groupRepo: GroupRepo,
private groupUserRepo: GroupUserRepo,
private userRepo: UserRepo,
@InjectKysely() private readonly db: KyselyDB,
) {}
@ -33,7 +38,6 @@ export class WorkspaceService {
}
async getWorkspaceInfo(workspaceId: string) {
// todo: add member count
const workspace = this.workspaceRepo.findById(workspaceId);
if (!workspace) {
throw new NotFoundException('Workspace not found');
@ -61,11 +65,10 @@ export class WorkspaceService {
);
// create default group
const group = await this.groupService.createDefaultGroup(
workspace.id,
user.id,
trx,
);
const group = await this.groupRepo.createDefaultGroup(workspace.id, {
userId: user.id,
trx: trx,
});
// add user to workspace
await trx
@ -181,11 +184,54 @@ export class WorkspaceService {
return workspace;
}
async delete(workspaceId: string): Promise<void> {
const workspace = await this.workspaceRepo.findById(workspaceId);
if (!workspace) {
throw new NotFoundException('Workspace not found');
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');
}
//delete
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';
}
}

View File

@ -3,18 +3,12 @@ import { WorkspaceService } from './services/workspace.service';
import { WorkspaceController } from './controllers/workspace.controller';
import { SpaceModule } from '../space/space.module';
import { WorkspaceInvitationService } from './services/workspace-invitation.service';
import { WorkspaceUserService } from './services/workspace-user.service';
import { UserModule } from '../user/user.module';
import { GroupModule } from '../group/group.module';
import { TokenModule } from '../auth/token.module';
@Module({
imports: [SpaceModule, UserModule, GroupModule],
imports: [SpaceModule, TokenModule],
controllers: [WorkspaceController],
providers: [
WorkspaceService,
WorkspaceUserService,
WorkspaceInvitationService,
],
providers: [WorkspaceService, WorkspaceInvitationService],
exports: [WorkspaceService],
})
export class WorkspaceModule {}

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);

View File

@ -9,9 +9,21 @@ export class EnvironmentService {
return this.configService.get<string>('NODE_ENV');
}
getAppUrl(): string {
return (
this.configService.get<string>('APP_URL') ||
'http://localhost:' + this.getPort()
);
}
getPort(): number {
return parseInt(this.configService.get<string>('PORT'));
}
getAppSecret(): string {
return this.configService.get<string>('APP_SECRET');
}
getDatabaseURL(): string {
return this.configService.get<string>('DATABASE_URL');
}

View File

@ -7,6 +7,9 @@ export class EnvironmentVariables {
@IsUrl({ protocols: ['postgres', 'postgresql'], require_tld: false })
DATABASE_URL: string;
@IsString()
APP_SECRET: string;
}
export function validate(config: Record<string, any>) {
@ -14,7 +17,13 @@ export function validate(config: Record<string, any>) {
const errors = validateSync(validatedConfig);
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;
}

View File

@ -18,8 +18,9 @@ export class MailService {
async sendEmail(message: MailMessage): Promise<void> {
if (message.template) {
// in case this method is used directly
message.html = render(message.template);
// in case this method is used directly. we do not send the tsx template from queue
message.html = render(message.template, { pretty: true });
message.text = render(message.template, { plainText: true });
}
const sender = `${this.environmentService.getMailFromName()} <${this.environmentService.getMailFromAddress()}> `;
@ -29,7 +30,10 @@ export class MailService {
async sendToQueue(message: MailMessage): Promise<void> {
if (message.template) {
// 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;
}
await this.emailQueue.add(QueueJob.SEND_EMAIL, message);

View File

@ -23,7 +23,7 @@ export const paragraph = {
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
color: '#333',
lineHeight: 1.5,
lineHeight: 1,
fontSize: 14,
};
@ -51,3 +51,16 @@ export const footer = {
maxWidth: '580px',
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',
};

View File

@ -3,11 +3,11 @@ import * as React from 'react';
import { content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials';
interface ChangePasswordEmailProps {
interface Props {
username?: string;
}
export const ChangePasswordEmail = ({ username }: ChangePasswordEmailProps) => {
export const ChangePasswordEmail = ({ username }: Props) => {
return (
<MailBody>
<Section style={content}>

View File

@ -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;

View File

@ -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;

View File

@ -40,7 +40,7 @@ export function MailFooter() {
<Section style={footer}>
<Row>
<Text style={{ textAlign: 'center', color: '#706a7b' }}>
© {new Date().getFullYear()}, All Rights Reserved <br />
© {new Date().getFullYear()} Docmost, All Rights Reserved <br />
</Text>
</Row>
</Section>

View File

@ -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();
}

View File

@ -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();
}

View File

@ -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();
}

View File

@ -1,14 +1,24 @@
import { Injectable } from '@nestjs/common';
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
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 { PaginationOptions } from '../../pagination/pagination-options';
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()
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(
userId: string,
@ -62,6 +72,78 @@ export class GroupUserRepo {
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> {
await this.db
.deleteFrom('groupUsers')

View File

@ -11,6 +11,7 @@ import { ExpressionBuilder, sql } from 'kysely';
import { PaginationOptions } from '../../pagination/pagination-options';
import { DB } from '@docmost/db/types/db';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { DefaultGroup } from '../../../core/group/dto/create-group.dto';
@Injectable()
export class GroupRepo {
@ -19,9 +20,10 @@ export class GroupRepo {
async findById(
groupId: string,
workspaceId: string,
opts?: { includeMemberCount: boolean },
opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction },
): Promise<Group> {
return await this.db
const db = dbOrTx(this.db, opts?.trx);
return db
.selectFrom('groups')
.selectAll('groups')
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
@ -33,9 +35,10 @@ export class GroupRepo {
async findByName(
groupName: string,
workspaceId: string,
opts?: { includeMemberCount: boolean },
opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction },
): Promise<Group> {
return await this.db
const db = dbOrTx(this.db, opts?.trx);
return db
.selectFrom('groups')
.selectAll('groups')
.$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) {
let query = this.db
.selectFrom('groups')

View File

@ -93,7 +93,7 @@ export class UserRepo {
trx?: KyselyTransaction,
): Promise<User> {
const user: InsertableUser = {
name: insertableUser.name || insertableUser.email.split('@')[0],
name: insertableUser.name || insertableUser.email.toLowerCase(),
email: insertableUser.email.toLowerCase(),
password: await hashPassword(insertableUser.password),
locale: 'en',

View File

@ -1,15 +1,10 @@
import type { ColumnType } from 'kysely';
import type { ColumnType } from "kysely";
export type Generated<T> =
T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>;
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>;
export type Int8 = ColumnType<
string,
bigint | number | string,
bigint | number | string
>;
export type Int8 = ColumnType<string, bigint | number | string, bigint | number | string>;
export type Json = JsonValue;
@ -151,6 +146,7 @@ export interface Users {
email: string;
emailVerifiedAt: Timestamp | null;
id: Generated<string>;
invitedById: string | null;
lastActiveAt: Timestamp | null;
lastLoginAt: Timestamp | null;
locale: string | null;
@ -167,10 +163,11 @@ export interface Users {
export interface WorkspaceInvitations {
createdAt: Generated<Timestamp>;
email: string;
groupIds: string[] | null;
id: Generated<string>;
invitedById: string | null;
role: string;
status: string | null;
token: string | null;
updatedAt: Generated<Timestamp>;
workspaceId: string;
}

View File

@ -1,9 +1,9 @@
import { Module } from '@nestjs/common';
import { WsGateway } from './ws.gateway';
import { AuthModule } from '../core/auth/auth.module';
import { TokenModule } from '../core/auth/token.module';
@Module({
imports: [AuthModule],
imports: [TokenModule],
providers: [WsGateway],
})
export class WsModule {}

16215
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff