mirror of
https://github.com/docmost/docmost.git
synced 2025-11-10 07:02:06 +10:00
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:
@ -1,3 +1,6 @@
|
||||
APP_URL=http://localhost
|
||||
APP_SECRET=
|
||||
|
||||
PORT=3000
|
||||
DEBUG_MODE=true
|
||||
NODE_ENV=production
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 />} />
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
102
apps/client/src/features/auth/components/invite-sign-up-form.tsx
Normal file
102
apps/client/src/features/auth/components/invite-sign-up-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import useAuth from "@/features/auth/hooks/use-auth.ts";
|
||||
|
||||
export function useRedirectIfAuthenticated() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
const validAuth = await isAuthenticated();
|
||||
if (validAuth) {
|
||||
navigate("/home");
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, [isAuthenticated]);
|
||||
}
|
||||
@ -4,6 +4,7 @@ export interface ILogin {
|
||||
}
|
||||
|
||||
export interface IRegister {
|
||||
name?: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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" });
|
||||
},
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
2
apps/client/src/features/editor/utils/index.ts
Normal file
2
apps/client/src/features/editor/utils/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./is-custom-node-selected";
|
||||
export * from "./is-text-selected";
|
||||
@ -0,0 +1,11 @@
|
||||
import { Editor } from "@tiptap/react";
|
||||
import TiptapLink from "@tiptap/extension-link";
|
||||
import { CodeBlock } from "@tiptap/extension-code-block";
|
||||
|
||||
export const isCustomNodeSelected = (editor: Editor, node: HTMLElement) => {
|
||||
const customNodes = [CodeBlock.name, TiptapLink.name];
|
||||
|
||||
return customNodes.some((type) => editor.isActive(type));
|
||||
};
|
||||
|
||||
export default isCustomNodeSelected;
|
||||
26
apps/client/src/features/editor/utils/is-text-selected.ts
Normal file
26
apps/client/src/features/editor/utils/is-text-selected.ts
Normal 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;
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,27 @@
|
||||
.control {
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-xs);
|
||||
color: var(--mantine-color-text);
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
|
||||
}
|
||||
}
|
||||
|
||||
.item {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-md);
|
||||
padding-left: 4px;
|
||||
margin-left: var(--mantine-spacing-sm);
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||
}
|
||||
|
||||
.chevron {
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
import React, { ReactNode, useState } from "react";
|
||||
import {
|
||||
Group,
|
||||
Box,
|
||||
Collapse,
|
||||
ThemeIcon,
|
||||
UnstyledButton,
|
||||
rem,
|
||||
} from "@mantine/core";
|
||||
import { IconChevronRight } from "@tabler/icons-react";
|
||||
import classes from "./tree-collapse.module.css";
|
||||
|
||||
interface LinksGroupProps {
|
||||
icon?: React.FC<any>;
|
||||
label: string;
|
||||
initiallyOpened?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function TreeCollapse({
|
||||
icon: Icon,
|
||||
label,
|
||||
initiallyOpened,
|
||||
children,
|
||||
}: LinksGroupProps) {
|
||||
const [opened, setOpened] = useState(initiallyOpened || false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UnstyledButton
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
className={classes.control}
|
||||
>
|
||||
<Group justify="space-between" gap={0}>
|
||||
<Box style={{ display: "flex", alignItems: "center" }}>
|
||||
<ThemeIcon variant="light" size={20}>
|
||||
<Icon style={{ width: rem(18), height: rem(18) }} />
|
||||
</ThemeIcon>
|
||||
<Box ml="md">{label}</Box>
|
||||
</Box>
|
||||
|
||||
<IconChevronRight
|
||||
className={classes.chevron}
|
||||
stroke={1.5}
|
||||
style={{
|
||||
width: rem(16),
|
||||
height: rem(16),
|
||||
transform: opened ? "rotate(90deg)" : "none",
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
<Collapse in={opened}>
|
||||
<div className={classes.item}>{children}</div>
|
||||
</Collapse>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,17 +1,8 @@
|
||||
import {
|
||||
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
|
||||
|
||||
@ -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();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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</>;
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,70 @@
|
||||
import { Menu, ActionIcon, Text } from "@mantine/core";
|
||||
import React from "react";
|
||||
import { IconDots, IconTrash } from "@tabler/icons-react";
|
||||
import { modals } from "@mantine/modals";
|
||||
import {
|
||||
useResendInvitationMutation,
|
||||
useRevokeInvitationMutation,
|
||||
} from "@/features/workspace/queries/workspace-query.ts";
|
||||
|
||||
interface Props {
|
||||
invitationId: string;
|
||||
}
|
||||
export default function InviteActionMenu({ invitationId }: Props) {
|
||||
const resendInvitationMutation = useResendInvitationMutation();
|
||||
const revokeInvitationMutation = useRevokeInvitationMutation();
|
||||
|
||||
const onResend = async () => {
|
||||
await resendInvitationMutation.mutateAsync({ invitationId });
|
||||
};
|
||||
|
||||
const onRevoke = async () => {
|
||||
await revokeInvitationMutation.mutateAsync({ invitationId });
|
||||
};
|
||||
|
||||
const openRevokeModal = () =>
|
||||
modals.openConfirmModal({
|
||||
title: "Revoke invitation",
|
||||
children: (
|
||||
<Text size="sm">
|
||||
Are you sure you want to revoke this invitation? The user will not be
|
||||
able to join the workspace.
|
||||
</Text>
|
||||
),
|
||||
centered: true,
|
||||
labels: { confirm: "Revoke", cancel: "Don't" },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: onRevoke,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu
|
||||
shadow="xl"
|
||||
position="bottom-end"
|
||||
offset={20}
|
||||
width={200}
|
||||
withArrow
|
||||
arrowPosition="center"
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" c="gray">
|
||||
<IconDots size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={onResend}>Resend invitation</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
c="red"
|
||||
onClick={openRevokeModal}
|
||||
leftSection={<IconTrash size={16} stroke={2} />}
|
||||
>
|
||||
Revoke invitation
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,50 +1,87 @@
|
||||
import { Group, Box, Button, TagsInput, Space, Select } from "@mantine/core";
|
||||
import { Group, Box, Button, TagsInput, Select } from "@mantine/core";
|
||||
import WorkspaceInviteSection from "@/features/workspace/components/members/components/workspace-invite-section.tsx";
|
||||
import 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>
|
||||
</>
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -0,0 +1,62 @@
|
||||
import { Group, Table, Avatar, Text, Badge, Alert } from "@mantine/core";
|
||||
import { useWorkspaceInvitationsQuery } from "@/features/workspace/queries/workspace-query.ts";
|
||||
import React from "react";
|
||||
import { getUserRoleLabel } from "@/features/workspace/types/user-role-data.ts";
|
||||
import InviteActionMenu from "@/features/workspace/components/members/components/invite-action-menu.tsx";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { format } from "date-fns";
|
||||
|
||||
export default function WorkspaceInvitesTable() {
|
||||
const { data, isLoading } = useWorkspaceInvitationsQuery({
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle />}>
|
||||
Invited members who are yet to accept their invitation will appear here.
|
||||
</Alert>
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<Table verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Email</Table.Th>
|
||||
<Table.Th>Role</Table.Th>
|
||||
<Table.Th>Date</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{data?.items.map((invitation, index) => (
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>
|
||||
<Group gap="sm">
|
||||
<Avatar src={invitation.email} />
|
||||
<div>
|
||||
<Text fz="sm" fw={500}>
|
||||
{invitation.email}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>{getUserRoleLabel(invitation.role)}</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
{format(invitation.createdAt, "MM/dd/yyyy")}
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
<InviteActionMenu invitationId={invitation.id} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -12,7 +12,7 @@ import {
|
||||
} from "@/features/workspace/types/user-role-data.ts";
|
||||
|
||||
export default function WorkspaceMembersTable() {
|
||||
const { data, isLoading } = useWorkspaceMembersQuery();
|
||||
const { data, isLoading } = useWorkspaceMembersQuery({ limit: 100 });
|
||||
const changeMemberRoleMutation = useChangeMemberRoleMutation();
|
||||
|
||||
const handleRoleChange = async (
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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}`;
|
||||
|
||||
@ -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 />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -9,8 +9,8 @@ import {
|
||||
|
||||
export class CreateUserDto {
|
||||
@IsOptional()
|
||||
@MinLength(3)
|
||||
@MaxLength(35)
|
||||
@MinLength(2)
|
||||
@MaxLength(60)
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
24
apps/server/src/core/auth/token.module.ts
Normal file
24
apps/server/src/core/auth/token.module.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
import { TokenService } from './services/token.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule.registerAsync({
|
||||
useFactory: async (environmentService: EnvironmentService) => {
|
||||
return {
|
||||
secret: environmentService.getAppSecret(),
|
||||
signOptions: {
|
||||
expiresIn: environmentService.getJwtTokenExpiresIn(),
|
||||
issuer: 'Docmost',
|
||||
},
|
||||
};
|
||||
},
|
||||
inject: [EnvironmentService],
|
||||
}),
|
||||
],
|
||||
providers: [TokenService],
|
||||
exports: [TokenService],
|
||||
})
|
||||
export class TokenModule {}
|
||||
@ -11,7 +11,6 @@ import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
|
||||
export type Subjects =
|
||||
| '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');
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class AddWorkspaceUserDto {
|
||||
@IsNotEmpty()
|
||||
@IsUUID()
|
||||
userId: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
role: string;
|
||||
}
|
||||
@ -1,13 +1,35 @@
|
||||
import { IsEmail, IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsEmail,
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { UserRole } from '../../../helpers/types/permission';
|
||||
|
||||
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 {}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,63 +0,0 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { UpdateWorkspaceUserRoleDto } from '../dto/update-workspace-user-role.dto';
|
||||
import { PaginationOptions } from '../../../kysely/pagination/pagination-options';
|
||||
import { UserRole } from '../../../helpers/types/permission';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
import { PaginationResult } from '@docmost/db/pagination/pagination';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceUserService {
|
||||
constructor(private userRepo: UserRepo) {}
|
||||
|
||||
async getWorkspaceUsers(
|
||||
workspaceId: string,
|
||||
pagination: PaginationOptions,
|
||||
): Promise<PaginationResult<User>> {
|
||||
const users = await this.userRepo.getUsersPaginated(
|
||||
workspaceId,
|
||||
pagination,
|
||||
);
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
async updateWorkspaceUserRole(
|
||||
authUser: User,
|
||||
userRoleDto: UpdateWorkspaceUserRoleDto,
|
||||
workspaceId: string,
|
||||
) {
|
||||
const user = await this.userRepo.findById(userRoleDto.userId, workspaceId);
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('Workspace member not found');
|
||||
}
|
||||
|
||||
if (user.role === userRoleDto.role) {
|
||||
return user;
|
||||
}
|
||||
|
||||
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId(
|
||||
UserRole.OWNER,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (user.role === UserRole.OWNER && workspaceOwnerCount === 1) {
|
||||
throw new BadRequestException(
|
||||
'There must be at least one workspace owner',
|
||||
);
|
||||
}
|
||||
|
||||
await this.userRepo.updateUser(
|
||||
{
|
||||
role: userRoleDto.role,
|
||||
},
|
||||
user.id,
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
async deactivateUser(): Promise<any> {
|
||||
return 'todo';
|
||||
}
|
||||
}
|
||||
@ -8,7 +8,6 @@ import { UpdateWorkspaceDto } from '../dto/update-workspace.dto';
|
||||
import { SpaceService } from '../../space/services/space.service';
|
||||
import { 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';
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
|
||||
5
apps/server/src/helpers/nanoid.utils.ts
Normal file
5
apps/server/src/helpers/nanoid.utils.ts
Normal file
@ -0,0 +1,5 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { customAlphabet } = require('fix-esm').require('nanoid');
|
||||
|
||||
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
export const nanoIdGen = customAlphabet(alphabet, 10);
|
||||
@ -9,9 +9,21 @@ export class EnvironmentService {
|
||||
return this.configService.get<string>('NODE_ENV');
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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',
|
||||
};
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
import { Section, Text } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
invitedUserName: string;
|
||||
invitedUserEmail: string;
|
||||
}
|
||||
|
||||
export const InvitationAcceptedEmail = ({
|
||||
invitedUserName,
|
||||
invitedUserEmail,
|
||||
}: Props) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi there,</Text>
|
||||
<Text style={paragraph}>
|
||||
{invitedUserName} ({invitedUserEmail}) has accepted your invitation,
|
||||
and is now a member of the workspace.
|
||||
</Text>
|
||||
</Section>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
export default InvitationAcceptedEmail;
|
||||
@ -0,0 +1,37 @@
|
||||
import { Section, Text, Button } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import { button, content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
inviteLink: string;
|
||||
}
|
||||
|
||||
export const InvitationEmail = ({ inviteLink }: Props) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi there,</Text>
|
||||
<Text style={paragraph}>You have been invited to Docmost.</Text>
|
||||
<Text style={paragraph}>
|
||||
Please click the button below to accept this invitation.
|
||||
</Text>
|
||||
</Section>
|
||||
<Section
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '15px',
|
||||
paddingBottom: '15px',
|
||||
}}
|
||||
>
|
||||
<Button href={inviteLink} style={button}>
|
||||
Accept Invite
|
||||
</Button>
|
||||
</Section>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
export default InvitationEmail;
|
||||
@ -40,7 +40,7 @@ export function MailFooter() {
|
||||
<Section style={footer}>
|
||||
<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>
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('page_ordering')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
|
||||
)
|
||||
.addColumn('entity_id', 'uuid', (col) => col.notNull())
|
||||
.addColumn('entity_type', 'varchar', (col) => col.notNull()) // can be page or space
|
||||
.addColumn('children_ids', sql`uuid[]`, (col) => col.notNull())
|
||||
.addColumn('space_id', 'uuid', (col) =>
|
||||
col.references('spaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('deleted_at', 'timestamptz', (col) => col)
|
||||
.addUniqueConstraint('page_ordering_entity_id_entity_type_unique', [
|
||||
'entity_id',
|
||||
'entity_type',
|
||||
])
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('page_ordering').execute();
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.alterTable('workspace_invitations')
|
||||
.addColumn('token', 'varchar', (col) => col)
|
||||
.addColumn('group_ids', sql`uuid[]`, (col) => col)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.alterTable('workspace_invitations')
|
||||
.dropColumn('status')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.alterTable('workspace_invitations')
|
||||
.addUniqueConstraint('invitation_email_workspace_id_unique', [
|
||||
'email',
|
||||
'workspace_id',
|
||||
])
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.alterTable('workspace_invitations')
|
||||
.dropColumn('token')
|
||||
.execute();
|
||||
await db.schema
|
||||
.alterTable('workspace_invitations')
|
||||
.dropColumn('group_ids')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.alterTable('workspace_invitations')
|
||||
.addColumn('status', 'varchar', (col) => col)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.alterTable('workspace_invitations')
|
||||
.dropConstraint('invitation_email_workspace_id_unique')
|
||||
.execute();
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
import { type Kysely } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.alterTable('users')
|
||||
.addColumn('invited_by_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.alterTable('users').dropColumn('invited_by_id').execute();
|
||||
}
|
||||
@ -1,14 +1,24 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { 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')
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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',
|
||||
|
||||
19
apps/server/src/kysely/types/db.d.ts
vendored
19
apps/server/src/kysely/types/db.d.ts
vendored
@ -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;
|
||||
}
|
||||
|
||||
@ -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
16215
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user