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

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

@ -0,0 +1,53 @@
export const fontFamily = 'HelveticaNeue,Helvetica,Arial,sans-serif';
export const main = {
backgroundColor: '#edf2f7',
fontFamily,
};
export const container = {
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',
};
export const paragraph = {
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
color: '#333',
lineHeight: 1.5,
fontSize: 14,
};
export const h1 = {
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',
};
export const logo = {
display: 'flex',
justifyContent: 'center',
alingItems: 'center',
padding: 4,
};
export const link = {
textDecoration: 'underline',
};
export const footer = {
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

@ -0,0 +1,48 @@
import { container, footer, h1, logo, main } from '../css/styles';
import {
Body,
Container,
Head,
Html,
Row,
Section,
Text,
} 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}>
<MailHeader />
<Container style={container}>{children}</Container>
<MailFooter />
</Body>
</Html>
);
}
export function MailHeader() {
return (
<Section style={logo}>
{/* <Heading style={h1}>docmost</Heading> */}
</Section>
);
}
export function MailFooter() {
return (
<Section style={footer}>
<Row>
<Text style={{ textAlign: 'center', color: '#706a7b' }}>
© {new Date().getFullYear()}, All Rights Reserved <br />
</Text>
</Row>
</Section>
);
}

View File

@ -0,0 +1,6 @@
export const formatDate = (date: Date) => {
new Intl.DateTimeFormat("en", {
dateStyle: "medium",
timeStyle: "medium",
}).format(date);
};

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/*"]
}
}
}