Implement password change endpoint

* move email templates to server
This commit is contained in:
Philipinho
2024-05-04 15:46:11 +01:00
parent c1cd090252
commit fece460051
22 changed files with 323 additions and 425 deletions

View File

@ -1,12 +1,24 @@
import api from "@/lib/api-client";
import { ILogin, IRegister, ITokenResponse } from "@/features/auth/types/auth.types";
import {
IChangePassword,
ILogin,
IRegister,
ITokenResponse,
} from "@/features/auth/types/auth.types";
export async function login(data: ILogin): Promise<ITokenResponse>{
export async function login(data: ILogin): Promise<ITokenResponse> {
const req = await api.post<ITokenResponse>("/auth/login", data);
return req.data as ITokenResponse;
return req.data;
}
export async function register(data: IRegister): Promise<ITokenResponse>{
export async function register(data: IRegister): Promise<ITokenResponse> {
const req = await api.post<ITokenResponse>("/auth/register", data);
return req.data as ITokenResponse;
return req.data;
}
export async function changePassword(
data: IChangePassword,
): Promise<IChangePassword> {
const req = await api.post<IChangePassword>("/auth/change-password", data);
return req.data;
}

View File

@ -1,18 +1,23 @@
export interface ILogin {
email: string,
password: string
email: string;
password: string;
}
export interface IRegister {
email: string,
password: string
email: string;
password: string;
}
export interface ITokens {
accessToken: string,
refreshToken: string
accessToken: string;
refreshToken: string;
}
export interface ITokenResponse {
tokens: ITokens
tokens: ITokens;
}
export interface IChangePassword {
oldPassword: string;
newPassword: string;
}

View File

@ -27,15 +27,17 @@ export default function ChangeEmail() {
</Text>
</div>
{/*
<Button onClick={open} variant="default">
Change email
</Button>
*/}
<Modal opened={opened} onClose={close} title="Change email" centered>
<Text mb="md">
To change your email, you have to enter your password and new email.
</Text>
<ChangePasswordForm />
<ChangeEmailForm />
</Modal>
</Group>
);
@ -50,7 +52,7 @@ const formSchema = z.object({
type FormValues = z.infer<typeof formSchema>;
function ChangePasswordForm() {
function ChangeEmailForm() {
const [isLoading, setIsLoading] = useState(false);
const form = useForm<FormValues>({

View File

@ -1,9 +1,11 @@
import { Button, Group, Text, Modal, PasswordInput } from '@mantine/core';
import * as z from 'zod';
import { useState } from 'react';
import { useDisclosure } from '@mantine/hooks';
import * as React from 'react';
import { useForm, zodResolver } from '@mantine/form';
import { Button, Group, Text, Modal, PasswordInput } from "@mantine/core";
import * as z from "zod";
import { useState } from "react";
import { useDisclosure } from "@mantine/hooks";
import * as React from "react";
import { useForm, zodResolver } from "@mantine/form";
import { changePassword } from "@/features/auth/services/auth-service.ts";
import { notifications } from "@mantine/notifications";
export default function ChangePassword() {
const [opened, { open, close }] = useDisclosure(false);
@ -17,55 +19,72 @@ export default function ChangePassword() {
</Text>
</div>
<Button onClick={open} variant="default">Change password</Button>
<Button onClick={open} variant="default">
Change password
</Button>
<Modal opened={opened} onClose={close} title="Change password" centered>
<Text mb="md">Your password must be a minimum of 8 characters.</Text>
<ChangePasswordForm />
<ChangePasswordForm onClose={close} />
</Modal>
</Group>
);
}
const formSchema = z.object({
current: z.string({ required_error: 'your current password is required' }).min(1),
password: z.string({ required_error: 'New password is required' }).min(8),
confirm_password: z.string({ required_error: 'Password confirmation is required' }).min(8),
}).refine(data => data.password === data.confirm_password, {
message: 'Your new password and confirmation does not match.',
path: ['confirm_password'],
oldPassword: z
.string({ required_error: "your current password is required" })
.min(8),
newPassword: z.string({ required_error: "New password is required" }).min(8),
});
type FormValues = z.infer<typeof formSchema>
type FormValues = z.infer<typeof formSchema>;
function ChangePasswordForm() {
interface ChangePasswordFormProps {
onClose?: () => void;
}
function ChangePasswordForm({ onClose }: ChangePasswordFormProps) {
const [isLoading, setIsLoading] = useState(false);
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
initialValues: {
current: '',
password: '',
confirm_password: '',
oldPassword: "",
newPassword: "",
},
});
function handleSubmit(data: FormValues) {
async function handleSubmit(data: FormValues) {
setIsLoading(true);
console.log(data);
try {
await changePassword({
oldPassword: data.oldPassword,
newPassword: data.newPassword,
});
notifications.show({
message: "Password changed successfully",
});
onClose();
} catch (err) {
notifications.show({
message: `Error: ${err.response.data.message}`,
color: "red",
});
}
setIsLoading(false);
}
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<PasswordInput
label="Current password"
name="current"
name="oldPassword"
placeholder="Enter your current password"
variant="filled"
mb="md"
{...form.getInputProps('current')}
data-autofocus
{...form.getInputProps("oldPassword")}
/>
<PasswordInput
@ -73,7 +92,7 @@ function ChangePasswordForm() {
placeholder="Enter your new password"
variant="filled"
mb="md"
{...form.getInputProps('password')}
{...form.getInputProps("newPassword")}
/>
<Button type="submit" disabled={isLoading} loading={isLoading}>

View File

@ -12,23 +12,25 @@
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config test/jest-e2e.json",
"email:dev": "email dev -p 5019 -d ./src/integrations/transactional/emails",
"migration:create": "tsx ./src/kysely/migrate.ts create",
"migration:up": "tsx ./src/kysely/migrate.ts up",
"migration:down": "tsx ./src/kysely/migrate.ts down",
"migration:latest": "tsx ./src/kysely/migrate.ts latest",
"migration:redo": "tsx ./src/kysely/migrate.ts redo",
"migration: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",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config test/jest-e2e.json"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.565.0",
"@aws-sdk/s3-request-presigner": "^3.565.0",
"@casl/ability": "^6.7.1",
"@docmost/transactional": "workspace:^",
"@fastify/multipart": "^8.2.0",
"@fastify/static": "^7.0.3",
"@nestjs/bullmq": "^10.1.1",
@ -42,6 +44,7 @@
"@nestjs/platform-socket.io": "^10.3.8",
"@nestjs/serve-static": "^4.0.2",
"@nestjs/websockets": "^10.3.8",
"@react-email/components": "0.0.17",
"@react-email/render": "^0.0.13",
"@types/pg": "^8.11.5",
"bcrypt": "^5.1.1",
@ -93,6 +96,7 @@
"jest": "^29.7.0",
"kysely-codegen": "^0.15.0",
"prettier": "^3.2.5",
"react-email": "^2.1.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.1.2",

View File

@ -14,6 +14,11 @@ 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';
import { ChangePasswordDto } from './dto/change-password.dto';
import { AuthUser } from '../../decorators/auth-user.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { AuthWorkspace } from '../../decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../guards/jwt-auth.guard';
@Controller('auth')
export class AuthController {
@ -44,4 +49,15 @@ export class AuthController {
if (this.environmentService.isCloud()) throw new NotFoundException();
return this.authService.setup(createAdminUserDto);
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('change-password')
async changePassword(
@Body() dto: ChangePasswordDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
return this.authService.changePassword(dto, user.id, workspace.id);
}
}

View File

@ -7,8 +7,6 @@ import { TokenService } from './services/token.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { WorkspaceModule } from '../workspace/workspace.module';
import { SignupService } from './services/signup.service';
import { UserModule } from '../user/user.module';
import { SpaceModule } from '../space/space.module';
import { GroupModule } from '../group/group.module';
@Module({
@ -23,10 +21,8 @@ import { GroupModule } from '../group/group.module';
};
},
inject: [EnvironmentService],
} as any),
UserModule,
}),
WorkspaceModule,
SpaceModule,
GroupModule,
],
controllers: [AuthController],

View File

@ -0,0 +1,13 @@
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
export class ChangePasswordDto {
@IsNotEmpty()
@MinLength(8)
@IsString()
oldPassword: string;
@IsNotEmpty()
@MinLength(8)
@IsString()
newPassword: string;
}

View File

@ -1,4 +1,9 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import {
BadRequestException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { LoginDto } from '../dto/login.dto';
import { CreateUserDto } from '../dto/create-user.dto';
import { TokenService } from './token.service';
@ -6,7 +11,10 @@ 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 } from '../../../helpers/utils';
import { comparePasswordHash, hashPassword } from '../../../helpers';
import { ChangePasswordDto } from '../dto/change-password.dto';
import { MailService } from '../../../integrations/mail/mail.service';
import ChangePasswordEmail from '@docmost/transactional/emails/change-password-email';
@Injectable()
export class AuthService {
@ -14,6 +22,7 @@ export class AuthService {
private signupService: SignupService,
private tokenService: TokenService,
private userRepo: UserRepo,
private mailService: MailService,
) {}
async login(loginDto: LoginDto, workspaceId: string) {
@ -52,4 +61,43 @@ export class AuthService {
return { tokens };
}
async changePassword(
dto: ChangePasswordDto,
userId: string,
workspaceId: string,
): Promise<void> {
const user = await this.userRepo.findById(userId, workspaceId, {
includePassword: true,
});
if (!user) {
throw new NotFoundException('User not found');
}
const comparePasswords = await comparePasswordHash(
dto.oldPassword,
user.password,
);
if (!comparePasswords) {
throw new BadRequestException('Current password is incorrect');
}
const newPasswordHash = await hashPassword(dto.newPassword);
await this.userRepo.updateUser(
{
password: newPasswordHash,
},
userId,
workspaceId,
);
const emailTemplate = ChangePasswordEmail({ username: user.name });
await this.mailService.sendToQueue({
to: user.email,
subject: 'Your password has been changed',
template: emailTemplate,
});
}
}

View File

@ -100,12 +100,9 @@ export class GroupUserService {
this.db,
async (trx) => {
await this.groupService.findAndValidateGroup(groupId, workspaceId);
const user = await this.userRepo.findById(
userId,
workspaceId,
false,
trx,
);
const user = await this.userRepo.findById(userId, workspaceId, {
trx: trx,
});
if (!user) {
throw new NotFoundException('User not found');

View File

@ -4,4 +4,5 @@ export interface MailMessage {
subject: string;
text?: string;
html?: string;
template?: any;
}

View File

@ -6,6 +6,7 @@ import { EnvironmentService } from '../environment/environment.service';
import { InjectQueue } from '@nestjs/bullmq';
import { QueueName, QueueJob } from '../queue/constants';
import { Queue } from 'bullmq';
import { render } from '@react-email/render';
@Injectable()
export class MailService {
@ -16,11 +17,21 @@ export class MailService {
) {}
async sendEmail(message: MailMessage): Promise<void> {
if (message.template) {
// in case this method is used directly
message.html = render(message.template);
}
const sender = `${this.environmentService.getMailFromName()} <${this.environmentService.getMailFromAddress()}> `;
await this.mailDriver.sendMail({ from: sender, ...message });
}
async sendToQueue(message: MailMessage): Promise<void> {
if (message.template) {
// transform the React object because it gets lost when sent via the queue
message.html = render(message.template);
delete message.template;
}
await this.emailQueue.add(QueueJob.SEND_EMAIL, message);
}
}

View File

@ -1,53 +1,53 @@
export const fontFamily = "HelveticaNeue,Helvetica,Arial,sans-serif";
export const fontFamily = 'HelveticaNeue,Helvetica,Arial,sans-serif';
export const main = {
backgroundColor: "#edf2f7",
backgroundColor: '#edf2f7',
fontFamily,
};
export const container = {
maxWidth: "580px",
margin: "30px auto",
backgroundColor: "#ffffff",
borderColor: "#e8e5ef",
borderRadius: "2px",
borderWidth: "1px",
boxShadow: "0 2px 0 rgba(0, 0, 150, 0.025), 2px 4px 0 rgba(0, 0, 150, 0.015)",
maxWidth: '580px',
margin: '10px auto',
backgroundColor: '#ffffff',
borderColor: '#e8e5ef',
borderRadius: '2px',
borderWidth: '1px',
boxShadow: '0 2px 0 rgba(0, 0, 150, 0.025), 2px 4px 0 rgba(0, 0, 150, 0.015)',
};
export const content = {
padding: "5px 20px 10px 20px",
padding: '5px 20px 10px 20px',
};
export const paragraph = {
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
color: "#333",
color: '#333',
lineHeight: 1.5,
fontSize: 14,
};
export const h1 = {
color: "#333",
color: '#333',
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
fontSize: "24px",
fontWeight: "bold",
padding: "0",
fontSize: '24px',
fontWeight: 'bold',
padding: '0',
};
export const logo = {
display: "flex",
justifyContent: "center",
alingItems: "center",
display: 'flex',
justifyContent: 'center',
alingItems: 'center',
padding: 4,
};
export const link = {
textDecoration: "underline",
textDecoration: 'underline',
};
export const footer = {
maxWidth: "580px",
margin: "0 auto",
maxWidth: '580px',
margin: '0 auto',
};

View File

@ -0,0 +1,23 @@
import { Section, Text } from '@react-email/components';
import * as React from 'react';
import { content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials';
interface ChangePasswordEmailProps {
username?: string;
}
export const ChangePasswordEmail = ({ username }: ChangePasswordEmailProps) => {
return (
<MailBody>
<Section style={content}>
<Text style={paragraph}>Hi {username},</Text>
<Text style={paragraph}>
This is a confirmation that your password has been changed.
</Text>
</Section>
</MailBody>
);
};
export default ChangePasswordEmail;

View File

@ -1,23 +1,28 @@
import { footer, h1, logo, main } from "../css/styles";
import { container, footer, h1, logo, main } from '../css/styles';
import {
Body,
Container,
Head,
Heading,
Html,
Row,
Section,
Text,
} from "@react-email/components";
import * as React from "react";
} from '@react-email/components';
import * as React from 'react';
interface MailBodyProps {
children: React.ReactNode;
}
export function MailBody({ children }: MailBodyProps) {
return (
<Html>
<Head />
<Body style={main}>{children}</Body>
<Body style={main}>
<MailHeader />
<Container style={container}>{children}</Container>
<MailFooter />
</Body>
</Html>
);
}
@ -25,7 +30,7 @@ export function MailBody({ children }: MailBodyProps) {
export function MailHeader() {
return (
<Section style={logo}>
<Heading style={h1}>logo/text</Heading>
{/* <Heading style={h1}>docmost</Heading> */}
</Section>
);
}
@ -34,7 +39,7 @@ export function MailFooter() {
return (
<Section style={footer}>
<Row>
<Text style={{ textAlign: "center", color: "#706a7b" }}>
<Text style={{ textAlign: 'center', color: '#706a7b' }}>
© {new Date().getFullYear()}, All Rights Reserved <br />
</Text>
</Row>

View File

@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { Users } from '@docmost/db/types/db';
import { hashPassword } from '../../../helpers/utils';
import { hashPassword } from '../../../helpers';
import { dbOrTx } from '@docmost/db/utils';
import {
InsertableUser,
@ -35,14 +35,16 @@ export class UserRepo {
async findById(
userId: string,
workspaceId: string,
includePassword?: boolean,
trx?: KyselyTransaction,
opts?: {
includePassword?: boolean;
trx?: KyselyTransaction;
},
): Promise<User> {
const db = dbOrTx(this.db, trx);
const db = dbOrTx(this.db, opts?.trx);
return db
.selectFrom('users')
.select(this.baseFields)
.$if(includePassword, (qb) => qb.select('password'))
.$if(opts?.includePassword, (qb) => qb.select('password'))
.where('id', '=', userId)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();

View File

@ -22,6 +22,7 @@
"paths": {
"@docmost/db": ["./src/kysely"],
"@docmost/db/*": ["./src/kysely/*"],
"@docmost/transactional/*": ["./src/integrations/transactional/*"]
}
}
}

View File

@ -1,16 +0,0 @@
.env
# compiled output
/dist
/node_modules
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store

View File

@ -1,26 +0,0 @@
import { Container, Section, Text } from "@react-email/components";
import * as React from "react";
import { container, content, paragraph } from "../css/styles";
import { MailBody, MailFooter } from "../partials/partials";
interface WelcomeEmailProps {
username?: string;
}
export const TestEmail = ({ username }: WelcomeEmailProps) => {
return (
<MailBody>
<Container style={container}>
<Section style={content}>
<Text style={paragraph}>Hi {username},</Text>
<Text style={paragraph}>
This is a test email. Make sure to read it.
</Text>
</Section>
</Container>
<MailFooter />
</MailBody>
);
};
export default TestEmail;

View File

@ -1,10 +0,0 @@
{
"name": "@docmost/transactional",
"scripts": {
"dev": "email dev -p 5019"
},
"dependencies": {
"@react-email/components": "0.0.17",
"react-email": "^2.1.2"
}
}

355
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff