mirror of
https://github.com/Shadowfita/docmost.git
synced 2025-11-13 00:02:30 +10:00
Implement password change endpoint
* move email templates to server
This commit is contained in:
@ -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",
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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],
|
||||
|
||||
13
apps/server/src/core/auth/dto/change-password.dto.ts
Normal file
13
apps/server/src/core/auth/dto/change-password.dto.ts
Normal 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;
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -4,4 +4,5 @@ export interface MailMessage {
|
||||
subject: string;
|
||||
text?: string;
|
||||
html?: string;
|
||||
template?: any;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
53
apps/server/src/integrations/transactional/css/styles.ts
Normal file
53
apps/server/src/integrations/transactional/css/styles.ts
Normal 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',
|
||||
};
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
export const formatDate = (date: Date) => {
|
||||
new Intl.DateTimeFormat("en", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "medium",
|
||||
}).format(date);
|
||||
};
|
||||
@ -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();
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
"paths": {
|
||||
"@docmost/db": ["./src/kysely"],
|
||||
"@docmost/db/*": ["./src/kysely/*"],
|
||||
"@docmost/transactional/*": ["./src/integrations/transactional/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user