Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// src/app.module.ts
import { Module } from '@nestjs/common';
import { CacheModule } from '@nestjs/cache-manager';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ScheduleModule } from '@nestjs/schedule';
Expand All @@ -8,12 +9,41 @@ import { RolesGuard } from './auth/guards/roles.guard';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { LocationsModule } from './locations/locations.module';
import { AuthModule } from './auth/auth.module';
import { AssetsModule } from './assets/assets.module';
import { UsersModule } from './users/users.module';
import { DepartmentsModule } from './departments/departments.module';
import { CategoriesModule } from './categories/categories.module';
import { ReportsModule } from './reports/reports.module';

@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
CacheModule.registerAsync({
imports: [ConfigModule],
isGlobal: true,
inject: [ConfigService],
useFactory: async (configService: ConfigService) => {
const redisHost = configService.get<string>('REDIS_HOST');
const redisPort = Number(configService.get('REDIS_PORT'));
const baseOptions = { ttl: 300 };
if (redisHost && redisPort) {
const redisModule = await import('cache-manager-redis-store');
const redisStore = redisModule.redisStore ?? redisModule.default;
if (redisStore) {
return {
...baseOptions,
store: redisStore,
host: redisHost,
port: redisPort,
};
}
}
return baseOptions;
},
}),
ScheduleModule.forRoot(),
ThrottlerModule.forRoot([
{
Expand All @@ -35,6 +65,12 @@ import { LocationsModule } from './locations/locations.module';
}),
inject: [ConfigService],
}),
AuthModule,
UsersModule,
DepartmentsModule,
CategoriesModule,
AssetsModule,
ReportsModule,
LocationsModule,
],
controllers: [AppController],
Expand Down
41 changes: 41 additions & 0 deletions backend/src/assets/assets.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ import {
Param,
Query,
UseGuards,
UseInterceptors,
UploadedFile,
HttpCode,
HttpStatus,
BadRequestException,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AssetsService } from './assets.service';
Expand All @@ -27,6 +30,9 @@ import { RolesGuard } from '../auth/guards/roles.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
import { User, UserRole } from '../users/user.entity';
import { FileInterceptor } from '@nestjs/platform-express';
import * as multer from 'multer';
import { Express } from 'express';

@ApiTags('Assets')
@ApiBearerAuth('JWT-auth')
Expand Down Expand Up @@ -138,12 +144,47 @@ export class AssetsController {
return this.service.getDocuments(id);
}

const MAX_DOCUMENT_SIZE = 10 * 1024 * 1024;
const ALLOWED_MIME_TYPES = new Set([
'application/pdf',
'image/jpeg',
'image/jpg',
'image/png',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
]);

const documentUploadOptions = {
storage: multer.memoryStorage(),
limits: { fileSize: MAX_DOCUMENT_SIZE },
fileFilter(_: unknown, file: Express.Multer.File, callback: multer.FileFilterCallback) {
if (!ALLOWED_MIME_TYPES.has(file.mimetype)) {
return callback(new BadRequestException('Unsupported file type'), false);
}
callback(null, true);
},
};

@Post(':id/documents')
@ApiOperation({ summary: 'Attach a document (URL) to an asset' })
addDocument(@Param('id') id: string, @Body() dto: CreateDocumentDto, @CurrentUser() user: User) {
return this.service.addDocument(id, dto, user);
}

@Post(':id/documents/upload')
@UseInterceptors(FileInterceptor('file', documentUploadOptions))
@ApiOperation({ summary: 'Upload a document to storage' })
uploadDocument(
@Param('id') id: string,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() user: User,
) {
if (!file) {
throw new BadRequestException('File is required');
}
return this.service.uploadDocument(id, file, user);
}

@Delete(':id/documents/:documentId')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Remove a document from an asset' })
Expand Down
2 changes: 2 additions & 0 deletions backend/src/assets/assets.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { DepartmentsModule } from '../departments/departments.module';
import { CategoriesModule } from '../categories/categories.module';
import { UsersModule } from '../users/users.module';
import { StellarModule } from '../stellar/stellar.module';
import { StorageModule } from '../storage/storage.module';

@Module({
imports: [
Expand All @@ -19,6 +20,7 @@ import { StellarModule } from '../stellar/stellar.module';
CategoriesModule,
UsersModule,
StellarModule,
StorageModule,
],
controllers: [AssetsController],
providers: [AssetsService],
Expand Down
33 changes: 32 additions & 1 deletion backend/src/assets/assets.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ConfigService } from '@nestjs/config';
Expand All @@ -22,6 +22,8 @@ import { CategoriesService } from '../categories/categories.service';
import { UsersService } from '../users/users.service';
import { StellarService } from '../stellar/stellar.service';
import { User } from '../users/user.entity';
import { StorageService } from '../storage/storage.service';
import { Express } from 'express';

@Injectable()
export class AssetsService {
Expand All @@ -43,6 +45,7 @@ export class AssetsService {
private readonly usersService: UsersService,
private readonly configService: ConfigService,
private readonly stellarService: StellarService,
private readonly storageService: StorageService,
) {}

async findAll(filters: AssetFiltersDto): Promise<{ data: Asset[]; total: number; page: number; limit: number }> {
Expand Down Expand Up @@ -350,6 +353,34 @@ export class AssetsService {
return saved;
}

async uploadDocument(assetId: string, file: Express.Multer.File, currentUser: User): Promise<AssetDocument> {
await this.findOne(assetId);

if (!this.storageService.isEnabled) {
throw new BadRequestException('Object storage is not configured');
}

const upload = await this.storageService.uploadFile(file, `assets/${assetId}/documents`);
const doc = this.documentsRepo.create({
assetId,
name: file.originalname,
url: upload.url,
type: file.mimetype ?? 'application/octet-stream',
size: file.size ?? null,
uploadedBy: currentUser,
});
const saved = await this.documentsRepo.save(doc);
await this.logHistory(
{ id: assetId } as Asset,
AssetHistoryAction.DOCUMENT_UPLOADED,
`Document uploaded: ${file.originalname}`,
null,
{ name: file.originalname, url: upload.url },
currentUser,
);
return saved;
}

async deleteDocument(assetId: string, documentId: string): Promise<void> {
const doc = await this.documentsRepo.findOne({ where: { id: documentId, assetId } });
if (!doc) throw new NotFoundException('Document not found');
Expand Down
20 changes: 20 additions & 0 deletions backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { ForgotPasswordDto } from './dto/forgot-password.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { CurrentUser } from './decorators/current-user.decorator';
import { User } from '../users/user.entity';
Expand Down Expand Up @@ -94,6 +96,24 @@ export class AuthController {
return tokens;
}

@Post('forgot-password')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Send a password reset link to an email' })
@ApiResponse({ status: 200, description: 'Password reset email accepted' })
async forgotPassword(@Body() dto: ForgotPasswordDto) {
await this.authService.forgotPassword(dto.email);
return { message: 'If an account exists for that email, a reset link has been sent.' };
}

@Post('reset-password')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Reset password using a token' })
@ApiResponse({ status: 200, description: 'Password updated' })
async resetPassword(@Body() dto: ResetPasswordDto) {
await this.authService.resetPassword(dto.token, dto.newPassword);
return { message: 'Password has been reset successfully.' };
}

@Post('logout')
@HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(JwtAuthGuard)
Expand Down
8 changes: 7 additions & 1 deletion backend/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { UsersModule } from '../users/users.module';
import { MailModule } from '../mail/mail.module';
import { PasswordResetToken } from './entities/password-reset-token.entity';
import { PasswordResetService } from './services/password-reset.service';

@Module({
imports: [
UsersModule,
PassportModule,
JwtModule.register({}),
MailModule,
TypeOrmModule.forFeature([PasswordResetToken]),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
providers: [AuthService, JwtStrategy, PasswordResetService],
exports: [AuthService, JwtModule],
})
export class AuthModule {}
34 changes: 34 additions & 0 deletions backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
Injectable,
ConflictException,
UnauthorizedException,
BadRequestException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
Expand All @@ -10,6 +11,8 @@ import { UsersService } from '../users/users.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { User } from '../users/user.entity';
import { MailService } from '../mail/mail.service';
import { PasswordResetService } from './services/password-reset.service';

export interface AuthTokens {
accessToken: string;
Expand All @@ -22,6 +25,8 @@ export class AuthService {
private readonly usersService: UsersService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly mailService: MailService,
private readonly passwordResetService: PasswordResetService,
) {}

async register(dto: RegisterDto): Promise<{ user: User; tokens: AuthTokens }> {
Expand Down Expand Up @@ -81,6 +86,35 @@ export class AuthService {
await this.usersService.updateRefreshToken(userId, null);
}

async forgotPassword(email: string): Promise<void> {
const user = await this.usersService.findByEmail(email.toLowerCase());
if (!user) {
return;
}

const rawToken = await this.passwordResetService.issueToken(user.id);
const frontendUrl = this.configService.get<string>('FRONTEND_URL', 'http://localhost:3000').replace(/\/$/, '');
const resetUrl = `${frontendUrl}/reset-password?token=${rawToken}`;

await this.mailService.sendMail({
to: user.email,
subject: 'Reset your AssetsUp password',
text: `Use the link below to reset your password:\n${resetUrl}\n\nIf you did not request this, ignore this message.`,
html: `<p>Use the link below to reset your password:</p><p><a href="${resetUrl}">${resetUrl}</a></p>`,
});
}

async resetPassword(token: string, newPassword: string): Promise<void> {
const tokenRecord = await this.passwordResetService.findValidToken(token);
if (!tokenRecord) {
throw new BadRequestException('Invalid or expired password reset token');
}

const hashedPassword = await bcrypt.hash(newPassword, 12);
await this.usersService.updatePassword(tokenRecord.userId, hashedPassword);
await this.passwordResetService.markUsed(tokenRecord.id);
}

private async signTokens(user: User): Promise<AuthTokens> {
const payload = { sub: user.id, email: user.email, role: user.role };

Expand Down
8 changes: 8 additions & 0 deletions backend/src/auth/dto/forgot-password.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail } from 'class-validator';

export class ForgotPasswordDto {
@ApiProperty()
@IsEmail()
email: string;
}
13 changes: 13 additions & 0 deletions backend/src/auth/dto/reset-password.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, MinLength } from 'class-validator';

export class ResetPasswordDto {
@ApiProperty()
@IsString()
token: string;

@ApiProperty()
@IsString()
@MinLength(8)
newPassword: string;
}
27 changes: 27 additions & 0 deletions backend/src/auth/entities/password-reset-token.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
} from 'typeorm';

@Entity('password_reset_tokens')
export class PasswordResetToken {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column()
userId: string;

@Column()
token: string;

@Column({ type: 'timestamptz' })
expiresAt: Date;

@Column({ type: 'timestamptz', nullable: true })
usedAt: Date | null;

@CreateDateColumn()
createdAt: Date;
}
Loading
Loading