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