diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 155df5d..f01d449 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -25,6 +25,7 @@ import Layout from "@/components/layouts/global/layout.tsx"; import { ErrorBoundary } from "react-error-boundary"; import InviteSignup from "@/pages/auth/invite-signup.tsx"; import ForgotPassword from "@/pages/auth/forgot-password.tsx"; +import PasswordReset from "./pages/auth/password-reset"; export default function App() { const [, setSocket] = useAtom(socketAtom); @@ -62,9 +63,10 @@ export default function App() { } /> } /> - } /> } /> } /> + } /> + } /> } /> diff --git a/apps/client/src/components/ui/error-404.tsx b/apps/client/src/components/ui/error-404.tsx index 8657329..52d4b83 100644 --- a/apps/client/src/components/ui/error-404.tsx +++ b/apps/client/src/components/ui/error-404.tsx @@ -1,19 +1,25 @@ import { Title, Text, Button, Container, Group } from "@mantine/core"; import classes from "./error-404.module.css"; import { Link } from "react-router-dom"; +import { Helmet } from "react-helmet-async"; export function Error404() { return ( - - 404 Page Not Found - - Sorry, we can't find the page you are looking for. - - - - - + <> + + 404 page not found - Docmost + + + 404 Page Not Found + + Sorry, we can't find the page you are looking for. + + + + + + ); } diff --git a/apps/client/src/features/auth/components/auth.module.css b/apps/client/src/features/auth/components/auth.module.css index a7f6962..958dccf 100644 --- a/apps/client/src/features/auth/components/auth.module.css +++ b/apps/client/src/features/auth/components/auth.module.css @@ -3,12 +3,3 @@ border-radius: 4px; background: light-dark(var(--mantine-color-body), rgba(0, 0, 0, 0.1)); } - -.forgotPasswordBtn { - font-size: var(--input-label-size, var(--mantine-font-size-sm)); - text-decoration: underline; -} - -.formElemWithTopMargin { - margin-top: var(--mantine-spacing-md); -} diff --git a/apps/client/src/features/auth/components/forgot-password-form.tsx b/apps/client/src/features/auth/components/forgot-password-form.tsx index 1f1f572..74714e5 100644 --- a/apps/client/src/features/auth/components/forgot-password-form.tsx +++ b/apps/client/src/features/auth/components/forgot-password-form.tsx @@ -1,109 +1,70 @@ -import * as React from "react"; -import {useState} from "react"; +import { useState } from "react"; 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 {IForgotPassword} from "@/features/auth/types/auth.types"; -import {Box, Button, Container, PasswordInput, TextInput, Title,} from "@mantine/core"; +import { IForgotPassword } from "@/features/auth/types/auth.types"; +import { Box, Button, Container, Text, TextInput, Title } from "@mantine/core"; import classes from "./auth.module.css"; -import {useRedirectIfAuthenticated} from "@/features/auth/hooks/use-redirect-if-authenticated.ts"; -import {notifications} from "@mantine/notifications"; +import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts"; -const stepOneSchema = z.object({ - email: z - .string() - .min(1, {message: "Email is required"}) - .email({message: "Invalid email address"}), -}); - -const stepTwoSchema = z.object({ - email: z - .string() - .min(1, {message: "Email is required"}) - .email({message: "Invalid email address"}), - token: z.string().min(1, {message: 'Token is required'}), - newPassword: z.string().min(8, {message: 'Password must contain at least 8 character(s)'}), +const formSchema = z.object({ + email: z + .string() + .min(1, { message: "Email is required" }) + .email({ message: "Invalid email address" }), }); export function ForgotPasswordForm() { - const {forgotPassword, isLoading} = useAuth(); - const [isTokenSend, setIsTokenSend] = useState(false) - useRedirectIfAuthenticated(); + const { forgotPassword, isLoading } = useAuth(); + const [isTokenSent, setIsTokenSent] = useState(false); + useRedirectIfAuthenticated(); - const form = useForm({ - validate: isTokenSend ? zodResolver(stepTwoSchema) : zodResolver(stepOneSchema), - initialValues: { - email: "", - token: null, - newPassword: null, - }, - }); + const form = useForm({ + validate: zodResolver(formSchema), + initialValues: { + email: "", + }, + }); - async function onSubmit(data: IForgotPassword) { - const success = await forgotPassword(data); - - if (success) { - if (isTokenSend) { - notifications.show({ - message: 'Password updated', - color: "green", - }); - } - - if (!isTokenSend) { - setIsTokenSend(true); - } - } + async function onSubmit(data: IForgotPassword) { + if (await forgotPassword(data)) { + setIsTokenSent(true); } + } - return ( - - - - Forgot password - + return ( + + + + Forgot password + -
- + + {!isTokenSent && ( + + )} - { - isTokenSend - && ( - <> - + {isTokenSent && ( + + A password reset link has been sent to your email. Please check + your inbox. + + )} - - - ) - } - - - -
-
- ); + {!isTokenSent && ( + + )} + +
+
+ ); } diff --git a/apps/client/src/features/auth/components/login-form.tsx b/apps/client/src/features/auth/components/login-form.tsx index d7bb6b9..8d55206 100644 --- a/apps/client/src/features/auth/components/login-form.tsx +++ b/apps/client/src/features/auth/components/login-form.tsx @@ -1,4 +1,3 @@ -import * as React from "react"; import * as z from "zod"; import { useForm, zodResolver } from "@mantine/form"; import useAuth from "@/features/auth/hooks/use-auth"; @@ -10,12 +9,12 @@ import { Button, PasswordInput, Box, - UnstyledButton, + + Anchor, } from "@mantine/core"; import classes from "./auth.module.css"; import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts"; -import clsx from "clsx"; -import {useNavigate} from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import APP_ROUTE from "@/lib/app-route.ts"; const formSchema = z.object({ @@ -28,7 +27,6 @@ const formSchema = z.object({ export function LoginForm() { const { signIn, isLoading } = useAuth(); - const navigate = useNavigate(); useRedirectIfAuthenticated(); const form = useForm({ @@ -68,17 +66,19 @@ export function LoginForm() { {...form.getInputProps("password")} /> - navigate(APP_ROUTE.AUTH.FORGOT_PASSWORD)} - className = {clsx(classes.forgotPasswordBtn, classes.formElemWithTopMargin)}> -
- Forgot Password -
-
+ + + Forgot your password? + ); diff --git a/apps/client/src/features/auth/components/password-reset-form.tsx b/apps/client/src/features/auth/components/password-reset-form.tsx new file mode 100644 index 0000000..373bf71 --- /dev/null +++ b/apps/client/src/features/auth/components/password-reset-form.tsx @@ -0,0 +1,67 @@ +import * as z from "zod"; +import { useForm, zodResolver } from "@mantine/form"; +import useAuth from "@/features/auth/hooks/use-auth"; +import { IPasswordReset } from "@/features/auth/types/auth.types"; +import { + Box, + Button, + Container, + PasswordInput, + Text, + Title, +} from "@mantine/core"; +import classes from "./auth.module.css"; +import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts"; + +const formSchema = z.object({ + newPassword: z + .string() + .min(8, { message: "Password must contain at least 8 characters" }), +}); + +interface PasswordResetFormProps { + resetToken?: string; +} + +export function PasswordResetForm({ resetToken }: PasswordResetFormProps) { + const { passwordReset, isLoading } = useAuth(); + useRedirectIfAuthenticated(); + + const form = useForm({ + validate: zodResolver(formSchema), + initialValues: { + newPassword: "", + }, + }); + + async function onSubmit(data: IPasswordReset) { + await passwordReset({ + token: resetToken, + newPassword: data.newPassword + }) + } + + return ( + + + + Password Reset + + +
+ + + + +
+
+ ); +} diff --git a/apps/client/src/features/auth/hooks/use-auth.ts b/apps/client/src/features/auth/hooks/use-auth.ts index 0210db8..47ba52f 100644 --- a/apps/client/src/features/auth/hooks/use-auth.ts +++ b/apps/client/src/features/auth/hooks/use-auth.ts @@ -1,10 +1,22 @@ import { useState } from "react"; -import {forgotPassword, login, setupWorkspace} from "@/features/auth/services/auth-service"; +import { + forgotPassword, + login, + passwordReset, + setupWorkspace, + verifyUserToken, +} from "@/features/auth/services/auth-service"; import { useNavigate } from "react-router-dom"; import { useAtom } from "jotai"; import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom"; -import {IForgotPassword, ILogin, ISetupWorkspace} from "@/features/auth/types/auth.types"; +import { + IForgotPassword, + ILogin, + IPasswordReset, + ISetupWorkspace, + IVerifyUserToken, +} from "@/features/auth/types/auth.types"; import { notifications } from "@mantine/notifications"; import { IAcceptInvite } from "@/features/workspace/types/workspace.types.ts"; import { acceptInvitation } from "@/features/workspace/services/workspace-service.ts"; @@ -76,6 +88,28 @@ export default function useAuth() { } }; + const handlePasswordReset = async (data: IPasswordReset) => { + setIsLoading(true); + + try { + const res = await passwordReset(data); + setIsLoading(false); + + setAuthToken(res.tokens); + + navigate(APP_ROUTE.HOME); + notifications.show({ + message: "Password reset was successful", + }); + } catch (err) { + setIsLoading(false); + notifications.show({ + message: err.response?.data.message, + color: "red", + }); + } + }; + const handleIsAuthenticated = async () => { if (!authToken) { return false; @@ -109,7 +143,7 @@ export default function useAuth() { setIsLoading(true); try { - const res = await forgotPassword(data); + await forgotPassword(data); setIsLoading(false); return true; @@ -125,13 +159,31 @@ export default function useAuth() { } }; + const handleVerifyUserToken = async (data: IVerifyUserToken) => { + setIsLoading(true); + + try { + await verifyUserToken(data); + setIsLoading(false); + } catch (err) { + console.log(err); + setIsLoading(false); + notifications.show({ + message: err.response?.data.message, + color: "red", + }); + } + }; + return { signIn: handleSignIn, invitationSignup: handleInvitationSignUp, setupWorkspace: handleSetupWorkspace, isAuthenticated: handleIsAuthenticated, - logout: handleLogout, forgotPassword: handleForgotPassword, + passwordReset: handlePasswordReset, + verifyUserToken: handleVerifyUserToken, + logout: handleLogout, hasTokens, isLoading, }; diff --git a/apps/client/src/features/auth/queries/auth-query.tsx b/apps/client/src/features/auth/queries/auth-query.tsx new file mode 100644 index 0000000..b2c52bb --- /dev/null +++ b/apps/client/src/features/auth/queries/auth-query.tsx @@ -0,0 +1,14 @@ +import { useQuery, UseQueryResult } from "@tanstack/react-query"; +import { verifyUserToken } from "../services/auth-service"; +import { IVerifyUserToken } from "../types/auth.types"; + +export function useVerifyUserTokenQuery( + verify: IVerifyUserToken, + ): UseQueryResult { + return useQuery({ + queryKey: ["verify-token", verify], + queryFn: () => verifyUserToken(verify), + enabled: !!verify.token, + staleTime: 0, + }); + } \ No newline at end of file diff --git a/apps/client/src/features/auth/services/auth-service.ts b/apps/client/src/features/auth/services/auth-service.ts index 8374ba8..5c43972 100644 --- a/apps/client/src/features/auth/services/auth-service.ts +++ b/apps/client/src/features/auth/services/auth-service.ts @@ -3,9 +3,11 @@ import { IChangePassword, IForgotPassword, ILogin, + IPasswordReset, IRegister, ISetupWorkspace, ITokenResponse, + IVerifyUserToken, } from "@/features/auth/types/auth.types"; export async function login(data: ILogin): Promise { @@ -20,21 +22,30 @@ export async function register(data: IRegister): Promise { }*/ export async function changePassword( - data: IChangePassword, + data: IChangePassword ): Promise { const req = await api.post("/auth/change-password", data); return req.data; } export async function setupWorkspace( - data: ISetupWorkspace, + data: ISetupWorkspace ): Promise { const req = await api.post("/auth/setup", data); return req.data; } -export async function forgotPassword(data: IForgotPassword): Promise { - const req = await api.post("/auth/forgot-password", data); +export async function forgotPassword(data: IForgotPassword): Promise { + await api.post("/auth/forgot-password", data); +} + +export async function passwordReset( + data: IPasswordReset +): Promise { + const req = await api.post("/auth/password-reset", data); return req.data; } +export async function verifyUserToken(data: IVerifyUserToken): Promise { + return api.post("/auth/verify-token", data); +} diff --git a/apps/client/src/features/auth/types/auth.types.ts b/apps/client/src/features/auth/types/auth.types.ts index eec4de7..9ad8b2e 100644 --- a/apps/client/src/features/auth/types/auth.types.ts +++ b/apps/client/src/features/auth/types/auth.types.ts @@ -3,12 +3,6 @@ export interface ILogin { password: string; } -export interface IForgotPassword { - email: string; - token: string; - newPassword: string; -} - export interface IRegister { name?: string; email: string; @@ -35,3 +29,17 @@ export interface IChangePassword { oldPassword: string; newPassword: string; } + +export interface IForgotPassword { + email: string; +} + +export interface IPasswordReset { + token?: string; + newPassword: string; +} + +export interface IVerifyUserToken { + token: string; + type: string; +} diff --git a/apps/client/src/features/space/queries/space-query.ts b/apps/client/src/features/space/queries/space-query.ts index 306ca30..b7c6945 100644 --- a/apps/client/src/features/space/queries/space-query.ts +++ b/apps/client/src/features/space/queries/space-query.ts @@ -20,6 +20,7 @@ import { removeSpaceMember, createSpace, updateSpace, + deleteSpace, } from '@/features/space/services/space-service.ts'; import { notifications } from '@mantine/notifications'; import { IPagination, QueryParams } from '@/lib/types.ts'; diff --git a/apps/client/src/lib/app-route.ts b/apps/client/src/lib/app-route.ts index c955462..c2bc5ec 100644 --- a/apps/client/src/lib/app-route.ts +++ b/apps/client/src/lib/app-route.ts @@ -4,7 +4,8 @@ const APP_ROUTE = { LOGIN: "/login", SIGNUP: "/signup", SETUP: "/setup/register", - FORGOT_PASSWORD: "/forgotPassword", + FORGOT_PASSWORD: "/forgot-password", + PASSWORD_RESET: "/password-reset", }, SETTINGS: { ACCOUNT: { diff --git a/apps/client/src/pages/auth/forgot-password.tsx b/apps/client/src/pages/auth/forgot-password.tsx index a7df477..0872fe3 100644 --- a/apps/client/src/pages/auth/forgot-password.tsx +++ b/apps/client/src/pages/auth/forgot-password.tsx @@ -1,11 +1,11 @@ +import { ForgotPasswordForm } from "@/features/auth/components/forgot-password-form"; import { Helmet } from "react-helmet-async"; -import {ForgotPasswordForm} from "@/features/auth/components/forgot-password-form.tsx"; export default function ForgotPassword() { return ( <> - Forgot Password + Forgot Password - Docmost diff --git a/apps/client/src/pages/auth/invite-signup.tsx b/apps/client/src/pages/auth/invite-signup.tsx index a7c4cc9..95c35c2 100644 --- a/apps/client/src/pages/auth/invite-signup.tsx +++ b/apps/client/src/pages/auth/invite-signup.tsx @@ -5,7 +5,7 @@ export default function InviteSignup() { return ( <> - Invitation signup + Invitation signup - Docmost diff --git a/apps/client/src/pages/auth/login.tsx b/apps/client/src/pages/auth/login.tsx index c3f47be..68ba2c7 100644 --- a/apps/client/src/pages/auth/login.tsx +++ b/apps/client/src/pages/auth/login.tsx @@ -5,7 +5,7 @@ export default function LoginPage() { return ( <> - Login + Login - Docmost diff --git a/apps/client/src/pages/auth/password-reset.tsx b/apps/client/src/pages/auth/password-reset.tsx new file mode 100644 index 0000000..0f37086 --- /dev/null +++ b/apps/client/src/pages/auth/password-reset.tsx @@ -0,0 +1,53 @@ +import { Helmet } from "react-helmet-async"; +import { PasswordResetForm } from "@/features/auth/components/password-reset-form"; +import { Link, useSearchParams } from "react-router-dom"; +import { useVerifyUserTokenQuery } from "@/features/auth/queries/auth-query"; +import { Button, Container, Group, Text } from "@mantine/core"; +import APP_ROUTE from "@/lib/app-route"; + +export default function PasswordReset() { + const [searchParams] = useSearchParams(); + const { data, isLoading, isError } = useVerifyUserTokenQuery({ + token: searchParams.get("token"), + type: "forgot-password", + }); + const resetToken = searchParams.get("token"); + + if (isLoading) { + return
; + } + + if (isError || !resetToken) { + return ( + <> + + Password Reset - Docmost + + + + Invalid or expired password reset link + + + + + + + ); + } + + return ( + <> + + Password Reset - Docmost + + + + ); +} diff --git a/apps/client/src/pages/auth/setup-workspace.tsx b/apps/client/src/pages/auth/setup-workspace.tsx index f0203e0..e24a592 100644 --- a/apps/client/src/pages/auth/setup-workspace.tsx +++ b/apps/client/src/pages/auth/setup-workspace.tsx @@ -32,7 +32,7 @@ export default function SetupWorkspace() { return ( <> - Setup workspace + Setup workspace - Docmost diff --git a/apps/server/src/core/auth/auth.constants.ts b/apps/server/src/core/auth/auth.constants.ts new file mode 100644 index 0000000..555149c --- /dev/null +++ b/apps/server/src/core/auth/auth.constants.ts @@ -0,0 +1,3 @@ +export enum UserTokenType { + FORGOT_PASSWORD = 'forgot-password', +} diff --git a/apps/server/src/core/auth/auth.controller.ts b/apps/server/src/core/auth/auth.controller.ts index 6bf2ace..0554062 100644 --- a/apps/server/src/core/auth/auth.controller.ts +++ b/apps/server/src/core/auth/auth.controller.ts @@ -10,7 +10,6 @@ import { } from '@nestjs/common'; import { LoginDto } from './dto/login.dto'; import { AuthService } from './services/auth.service'; -import { CreateUserDto } from './dto/create-user.dto'; import { SetupGuard } from './guards/setup.guard'; import { EnvironmentService } from '../../integrations/environment/environment.service'; import { CreateAdminUserDto } from './dto/create-admin-user.dto'; @@ -20,6 +19,8 @@ import { User, Workspace } from '@docmost/db/types/entity.types'; import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { ForgotPasswordDto } from './dto/forgot-password.dto'; +import { PasswordResetDto } from './dto/password-reset.dto'; +import { VerifyUserTokenDto } from './dto/verify-user-token.dto'; @Controller('auth') export class AuthController { @@ -34,18 +35,6 @@ export class AuthController { return this.authService.login(loginInput, req.raw.workspaceId); } - @HttpCode(HttpStatus.OK) - @Post('forgot-password') - async forgotPassword( - @Req() req, - @Body() forgotPasswordDto: ForgotPasswordDto, - ) { - return this.authService.forgotPassword( - forgotPasswordDto, - req.raw.workspaceId, - ); - } - /* @HttpCode(HttpStatus.OK) @Post('register') async register(@Req() req, @Body() createUserDto: CreateUserDto) { @@ -74,4 +63,31 @@ export class AuthController { ) { return this.authService.changePassword(dto, user.id, workspace.id); } + + @HttpCode(HttpStatus.OK) + @Post('forgot-password') + async forgotPassword( + @Body() forgotPasswordDto: ForgotPasswordDto, + @AuthWorkspace() workspace: Workspace, + ) { + return this.authService.forgotPassword(forgotPasswordDto, workspace.id); + } + + @HttpCode(HttpStatus.OK) + @Post('password-reset') + async passwordReset( + @Body() passwordResetDto: PasswordResetDto, + @AuthWorkspace() workspace: Workspace, + ) { + return this.authService.passwordReset(passwordResetDto, workspace.id); + } + + @HttpCode(HttpStatus.OK) + @Post('verify-token') + async verifyResetToken( + @Body() verifyUserTokenDto: VerifyUserTokenDto, + @AuthWorkspace() workspace: Workspace, + ) { + return this.authService.verifyUserToken(verifyUserTokenDto, workspace.id); + } } diff --git a/apps/server/src/core/auth/dto/forgot-password.dto.ts b/apps/server/src/core/auth/dto/forgot-password.dto.ts index 92aa2ed..7526bc3 100644 --- a/apps/server/src/core/auth/dto/forgot-password.dto.ts +++ b/apps/server/src/core/auth/dto/forgot-password.dto.ts @@ -1,16 +1,7 @@ -import { IsEmail, IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator'; +import { IsEmail, IsNotEmpty } from 'class-validator'; export class ForgotPasswordDto { @IsNotEmpty() @IsEmail() email: string; - - @IsOptional() - @IsString() - token: string; - - @IsOptional() - @IsString() - @MinLength(8) - newPassword: string; } diff --git a/apps/server/src/core/auth/dto/password-reset.dto.ts b/apps/server/src/core/auth/dto/password-reset.dto.ts new file mode 100644 index 0000000..36938ba --- /dev/null +++ b/apps/server/src/core/auth/dto/password-reset.dto.ts @@ -0,0 +1,10 @@ +import { IsString, MinLength } from 'class-validator'; + +export class PasswordResetDto { + @IsString() + token: string; + + @IsString() + @MinLength(8) + newPassword: string; +} diff --git a/apps/server/src/core/auth/dto/verify-user-token.dto.ts b/apps/server/src/core/auth/dto/verify-user-token.dto.ts new file mode 100644 index 0000000..f789a3d --- /dev/null +++ b/apps/server/src/core/auth/dto/verify-user-token.dto.ts @@ -0,0 +1,9 @@ +import { IsString, MinLength } from 'class-validator'; + +export class VerifyUserTokenDto { + @IsString() + token: string; + + @IsString() + type: string; +} diff --git a/apps/server/src/core/auth/services/auth.service.ts b/apps/server/src/core/auth/services/auth.service.ts index 36355ec..6f0ace7 100644 --- a/apps/server/src/core/auth/services/auth.service.ts +++ b/apps/server/src/core/auth/services/auth.service.ts @@ -11,13 +11,25 @@ import { TokensDto } from '../dto/tokens.dto'; import { SignupService } from './signup.service'; import { CreateAdminUserDto } from '../dto/create-admin-user.dto'; import { UserRepo } from '@docmost/db/repos/user/user.repo'; -import { comparePasswordHash, hashPassword, nanoIdGen } from '../../../common/helpers'; +import { + comparePasswordHash, + hashPassword, + nanoIdGen, +} from '../../../common/helpers'; import { ChangePasswordDto } from '../dto/change-password.dto'; import { MailService } from '../../../integrations/mail/mail.service'; import ChangePasswordEmail from '@docmost/transactional/emails/change-password-email'; import { ForgotPasswordDto } from '../dto/forgot-password.dto'; import ForgotPasswordEmail from '@docmost/transactional/emails/forgot-password-email'; -import { UserTokensRepo } from '@docmost/db/repos/user-tokens/user-tokens.repo'; +import { UserTokenRepo } from '@docmost/db/repos/user-token/user-token.repo'; +import { PasswordResetDto } from '../dto/password-reset.dto'; +import { UserToken } from '@docmost/db/types/entity.types'; +import { UserTokenType } from '../auth.constants'; +import { KyselyDB } from '@docmost/db/types/kysely.types'; +import { InjectKysely } from 'nestjs-kysely'; +import { executeTx } from '@docmost/db/utils'; +import { VerifyUserTokenDto } from '../dto/verify-user-token.dto'; +import { EnvironmentService } from 'src/integrations/environment/environment.service'; @Injectable() export class AuthService { @@ -25,8 +37,10 @@ export class AuthService { private signupService: SignupService, private tokenService: TokenService, private userRepo: UserRepo, - private userTokensRepo: UserTokensRepo, + private userTokenRepo: UserTokenRepo, private mailService: MailService, + private environmentService: EnvironmentService, + @InjectKysely() private readonly db: KyselyDB, ) {} async login(loginDto: LoginDto, workspaceId: string) { @@ -50,94 +64,6 @@ export class AuthService { return { tokens }; } - async forgotPassword( - forgotPasswordDto: ForgotPasswordDto, - workspaceId: string, - ) { - const user = await this.userRepo.findByEmail( - forgotPasswordDto.email, - workspaceId, - true, - ); - if (!user) { - return; - } - - if ( - forgotPasswordDto.token == null || - forgotPasswordDto.newPassword == null - ) { - // Generate 5-character user token - const code = nanoIdGen(5).toUpperCase(); - const hashedToken = await hashPassword(code); - await this.userTokensRepo.insertUserToken({ - token: hashedToken, - user_id: user.id, - workspace_id: user.workspaceId, - expires_at: new Date(new Date().getTime() + 3_600_000), // should expires in 1 hour - type: "forgot-password", - }); - - const emailTemplate = ForgotPasswordEmail({ - username: user.name, - code: code, - }); - await this.mailService.sendToQueue({ - to: user.email, - subject: 'Reset your password', - template: emailTemplate, - }); - - return; - } - - // Get all user tokens that are not expired - const userTokens = await this.userTokensRepo.findByUserId( - user.id, - user.workspaceId, - "forgot-password" - ); - // Limit to the last 3 token, so we have a total time window of 15 minutes - const validUserTokens = userTokens - .filter((token) => token.expires_at > new Date() && token.used_at == null) - .slice(0, 3); - - for (const token of validUserTokens) { - const validated = await comparePasswordHash( - forgotPasswordDto.token, - token.token, - ); - if (validated) { - await Promise.all([ - this.userTokensRepo.deleteUserToken(user.id, user.workspaceId, "forgot-password"), - this.userTokensRepo.deleteExpiredUserTokens(), - ]); - - const newPasswordHash = await hashPassword( - forgotPasswordDto.newPassword, - ); - await this.userRepo.updateUser( - { - password: newPasswordHash, - }, - user.id, - workspaceId, - ); - - const emailTemplate = ChangePasswordEmail({ username: user.name }); - await this.mailService.sendToQueue({ - to: user.email, - subject: 'Your password has been changed', - template: emailTemplate, - }); - - return; - } - } - - throw new BadRequestException('Incorrect code'); - } - async register(createUserDto: CreateUserDto, workspaceId: string) { const user = await this.signupService.signup(createUserDto, workspaceId); @@ -192,4 +118,108 @@ export class AuthService { template: emailTemplate, }); } + + async forgotPassword( + forgotPasswordDto: ForgotPasswordDto, + workspaceId: string, + ): Promise { + const user = await this.userRepo.findByEmail( + forgotPasswordDto.email, + workspaceId, + ); + + if (!user) { + return; + } + + const token = nanoIdGen(16); + const resetLink = `${this.environmentService.getAppUrl()}/password-reset?token=${token}`; + + await this.userTokenRepo.insertUserToken({ + token: token, + userId: user.id, + workspaceId: user.workspaceId, + expiresAt: new Date(new Date().getTime() + 60 * 60 * 1000), // 1 hour + type: UserTokenType.FORGOT_PASSWORD, + }); + + const emailTemplate = ForgotPasswordEmail({ + username: user.name, + resetLink: resetLink, + }); + + await this.mailService.sendToQueue({ + to: user.email, + subject: 'Reset your password', + template: emailTemplate, + }); + } + + async passwordReset(passwordResetDto: PasswordResetDto, workspaceId: string) { + const userToken = await this.userTokenRepo.findById( + passwordResetDto.token, + workspaceId, + ); + + if ( + !userToken || + userToken.type !== UserTokenType.FORGOT_PASSWORD || + userToken.expiresAt < new Date() + ) { + throw new BadRequestException('Invalid or expired token'); + } + + const user = await this.userRepo.findById(userToken.userId, workspaceId); + if (!user) { + throw new NotFoundException('User not found'); + } + + const newPasswordHash = await hashPassword(passwordResetDto.newPassword); + + await executeTx(this.db, async (trx) => { + await this.userRepo.updateUser( + { + password: newPasswordHash, + }, + user.id, + workspaceId, + trx, + ); + + trx + .deleteFrom('userTokens') + .where('userId', '=', user.id) + .where('type', '=', UserTokenType.FORGOT_PASSWORD) + .execute(); + }); + + const emailTemplate = ChangePasswordEmail({ username: user.name }); + await this.mailService.sendToQueue({ + to: user.email, + subject: 'Your password has been changed', + template: emailTemplate, + }); + + const tokens: TokensDto = await this.tokenService.generateTokens(user); + + return { tokens }; + } + + async verifyUserToken( + userTokenDto: VerifyUserTokenDto, + workspaceId: string, + ): Promise { + const userToken = await this.userTokenRepo.findById( + userTokenDto.token, + workspaceId, + ); + + if ( + !userToken || + userToken.type !== userTokenDto.type || + userToken.expiresAt < new Date() + ) { + throw new BadRequestException('Invalid or expired token'); + } + } } diff --git a/apps/server/src/database/database.module.ts b/apps/server/src/database/database.module.ts index c4b6bc0..4799ae8 100644 --- a/apps/server/src/database/database.module.ts +++ b/apps/server/src/database/database.module.ts @@ -22,7 +22,7 @@ import { AttachmentRepo } from './repos/attachment/attachment.repo'; import { KyselyDB } from '@docmost/db/types/kysely.types'; import * as process from 'node:process'; import { MigrationService } from '@docmost/db/services/migration.service'; -import { UserTokensRepo } from './repos/user-tokens/user-tokens.repo'; +import { UserTokenRepo } from './repos/user-token/user-token.repo'; // https://github.com/brianc/node-postgres/issues/811 types.setTypeParser(types.builtins.INT8, (val) => Number(val)); @@ -67,7 +67,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val)); PageHistoryRepo, CommentRepo, AttachmentRepo, - UserTokensRepo, + UserTokenRepo, ], exports: [ WorkspaceRepo, @@ -80,7 +80,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val)); PageHistoryRepo, CommentRepo, AttachmentRepo, - UserTokensRepo, + UserTokenRepo, ], }) export class DatabaseModule implements OnModuleDestroy, OnApplicationBootstrap { diff --git a/apps/server/src/database/migrations/20240903T124647-user-tokens.ts b/apps/server/src/database/migrations/20240903T124647-user-tokens.ts index 373160b..25ee2d0 100644 --- a/apps/server/src/database/migrations/20240903T124647-user-tokens.ts +++ b/apps/server/src/database/migrations/20240903T124647-user-tokens.ts @@ -7,13 +7,13 @@ export async function up(db: Kysely): Promise { col.primaryKey().defaultTo(sql`gen_uuid_v7()`), ) .addColumn('token', 'varchar', (col) => col.notNull()) + .addColumn('type', 'varchar', (col) => col.notNull()) .addColumn('user_id', 'uuid', (col) => col.notNull().references('users.id').onDelete('cascade'), ) .addColumn('workspace_id', 'uuid', (col) => col.references('workspaces.id').onDelete('cascade'), ) - .addColumn('type', 'varchar', (col) => col.notNull()) .addColumn('expires_at', 'timestamptz') .addColumn('used_at', 'timestamptz', (col) => col) .addColumn('created_at', 'timestamptz', (col) => diff --git a/apps/server/src/database/repos/user-tokens/user-tokens.repo.ts b/apps/server/src/database/repos/user-token/user-token.repo.ts similarity index 59% rename from apps/server/src/database/repos/user-tokens/user-tokens.repo.ts rename to apps/server/src/database/repos/user-token/user-token.repo.ts index 6e3ef8f..58971dc 100644 --- a/apps/server/src/database/repos/user-tokens/user-tokens.repo.ts +++ b/apps/server/src/database/repos/user-token/user-token.repo.ts @@ -1,6 +1,7 @@ import { InsertableUserToken, UpdatableUserToken, + UserToken, } from '@docmost/db/types/entity.types'; import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; import { dbOrTx } from '@docmost/db/utils'; @@ -8,9 +9,33 @@ import { Injectable } from '@nestjs/common'; import { InjectKysely } from 'nestjs-kysely'; @Injectable() -export class UserTokensRepo { +export class UserTokenRepo { constructor(@InjectKysely() private readonly db: KyselyDB) {} + async findById( + token: string, + workspaceId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + + return db + .selectFrom('userTokens') + .select([ + 'id', + 'token', + 'userId', + 'workspaceId', + 'type', + 'expiresAt', + 'usedAt', + 'createdAt', + ]) + .where('token', '=', token) + .where('workspaceId', '=', workspaceId) + .executeTakeFirst(); + } + async insertUserToken( insertableUserToken: InsertableUserToken, trx?: KyselyTransaction, @@ -28,24 +53,24 @@ export class UserTokensRepo { workspaceId: string, tokenType: string, trx?: KyselyTransaction, - ) { + ): Promise { const db = dbOrTx(this.db, trx); return db .selectFrom('userTokens') .select([ 'id', 'token', - 'user_id', - 'workspace_id', + 'userId', + 'workspaceId', 'type', - 'expires_at', - 'used_at', - 'created_at', + 'expiresAt', + 'usedAt', + 'createdAt', ]) - .where('user_id', '=', userId) - .where('workspace_id', '=', workspaceId) + .where('userId', '=', userId) + .where('workspaceId', '=', workspaceId) .where('type', '=', tokenType) - .orderBy('expires_at desc') + .orderBy('expiresAt desc') .execute(); } @@ -57,33 +82,21 @@ export class UserTokensRepo { const db = dbOrTx(this.db, trx); return db .updateTable('userTokens') - .set({ ...updatableUserToken }) + .set(updatableUserToken) .where('id', '=', userTokenId) .execute(); } - async deleteUserToken( - userId: string, - workspaceId: string, - tokenType: string, - trx?: KyselyTransaction, - ) { + async deleteToken(token: string, trx?: KyselyTransaction): Promise { const db = dbOrTx(this.db, trx); - return db - .deleteFrom('userTokens') - .where('user_id', '=', userId) - .where('workspace_id', '=', workspaceId) - .where('type', '=', tokenType) - .execute(); + await db.deleteFrom('userTokens').where('token', '=', token).execute(); } - async deleteExpiredUserTokens( - trx?: KyselyTransaction, - ) { + async deleteExpiredUserTokens(trx?: KyselyTransaction): Promise { const db = dbOrTx(this.db, trx); - return db + await db .deleteFrom('userTokens') - .where('expires_at', '<', new Date()) - .execute(); + .where('expiresAt', '<', new Date()) + .execute(); } } diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index f6be1a7..792dae9 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -1,22 +1,22 @@ -import type { ColumnType } from 'kysely'; +/** + * This file was generated by kysely-codegen. + * Please do not edit it manually. + */ -export type Generated = - T extends ColumnType - ? ColumnType - : ColumnType; +import type { ColumnType } from "kysely"; -export type Int8 = ColumnType< - string, - bigint | number | string, - bigint | number | string ->; +export type Generated = T extends ColumnType + ? ColumnType + : ColumnType; + +export type Int8 = ColumnType; export type Json = JsonValue; export type JsonArray = JsonValue[]; export type JsonObject = { - [K in string]?: JsonValue; + [x: string]: JsonValue | undefined; }; export type JsonPrimitive = boolean | number | string | null; @@ -162,6 +162,17 @@ export interface Users { workspaceId: string | null; } +export interface UserTokens { + createdAt: Generated; + expiresAt: Timestamp | null; + id: Generated; + token: string; + type: string; + usedAt: Timestamp | null; + userId: string; + workspaceId: string | null; +} + export interface WorkspaceInvitations { createdAt: Generated; email: string | null; @@ -190,17 +201,6 @@ export interface Workspaces { updatedAt: Generated; } -export interface UserTokens { - id: Generated; - token: string; - user_id: string; - workspace_id: string; - type: string; - expires_at: Timestamp | null; - used_at: Timestamp | null; - created_at: Generated; -} - export interface DB { attachments: Attachments; comments: Comments; @@ -211,7 +211,7 @@ export interface DB { spaceMembers: SpaceMembers; spaces: Spaces; users: Users; + userTokens: UserTokens; workspaceInvitations: WorkspaceInvitations; workspaces: Workspaces; - userTokens: UserTokens; } diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts index 722cb7e..8145bc9 100644 --- a/apps/server/src/database/types/entity.types.ts +++ b/apps/server/src/database/types/entity.types.ts @@ -73,7 +73,7 @@ export type Attachment = Selectable; export type InsertableAttachment = Insertable; export type UpdatableAttachment = Updateable>; -// User Tokens +// User Token export type UserToken = Selectable; export type InsertableUserToken = Insertable; export type UpdatableUserToken = Updateable>; \ No newline at end of file diff --git a/apps/server/src/integrations/transactional/emails/forgot-password-email.tsx b/apps/server/src/integrations/transactional/emails/forgot-password-email.tsx index 820c5f5..7fb6cb8 100644 --- a/apps/server/src/integrations/transactional/emails/forgot-password-email.tsx +++ b/apps/server/src/integrations/transactional/emails/forgot-password-email.tsx @@ -1,27 +1,28 @@ -import { Section, Text } from '@react-email/components'; +import { Button, Link, Section, Text } from '@react-email/components'; import * as React from 'react'; -import { content, paragraph } from '../css/styles'; +import { button, content, paragraph } from '../css/styles'; import { MailBody } from '../partials/partials'; interface Props { - username: string; - code: string; + username: string; + resetLink: string; } -export const ForgotPasswordEmail = ({ username, code }: Props) => { - return ( - -
- Hi {username}, - - The code for resetting your password is: {code}. - - - If you did not request a password reset, please ignore this email. - -
-
- ); -} +export const ForgotPasswordEmail = ({ username, resetLink }: Props) => { + return ( + +
+ Hi {username}, + + We received a request from you to reset your password. + + Click here to set a new password + + If you did not request a password reset, please ignore this email. + +
+
+ ); +}; -export default ForgotPasswordEmail; \ No newline at end of file +export default ForgotPasswordEmail;