Backend: - AuthModule with register, login, logout, refresh, profile endpoints - JwtAuthGuard (global, opt-out via @Public) + RolesGuard (@Roles) - PrismaModule with SQLite via @prisma/adapter-libsql (Prisma 7) - Auth configuration in configuration.ts Frontend: - Auth context with session restoration via refresh cookie - Login, Register, Profile pages with ProtectedRoute - Auto-refresh on 401 with token rotation - API client refactored for auth headers Russian text: auth flows translated to Russian All text translated: auth pages, profile, layout, loading states
35 KiB
Auth System Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add full authentication (register/login/logout/refresh) and role-based authorization (user/admin) to MoexVibe.
Architecture: Backend — NestJS + Prisma (SQLite) for user storage, JWT access tokens (15m) + httpOnly refresh cookies (7d) with rotation. Frontend — React context for auth state, TanStack Query for API, auto-refresh on 401 with retry.
Tech Stack: @prisma/client, @nestjs/jwt, bcrypt, cookie-parser (backend); React Context + fetch (frontend)
Task 1: Backend dependencies + Prisma init
Files:
-
Modify:
apps/backend/package.json -
Create:
apps/backend/prisma/schema.prisma -
Create:
apps/backend/.gitignore(or modify root) -
Create:
apps/backend/src/modules/prisma/prisma.service.ts -
Create:
apps/backend/src/modules/prisma/prisma.module.ts -
Step 1: Install dependencies
npm install @prisma/client @nestjs/jwt bcrypt cookie-parser -w apps/backend
npm install -D prisma @types/bcrypt @types/cookie-parser -w apps/backend
- Step 2: Init Prisma with SQLite
npx prisma init --datasource-provider sqlite
Update the generated apps/backend/prisma/schema.prisma:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
email String @unique
password String
name String?
role String @default("user")
refreshToken String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
- Step 3: Add DATABASE_URL to .env
DATABASE_URL="file:./dev.db"
Add .env to backend .gitignore:
node_modules/
dist/
.env
*.db
*.db-journal
- Step 4: PrismaService
// apps/backend/src/modules/prisma/prisma.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}
- Step 5: PrismaModule
// apps/backend/src/modules/prisma/prisma.module.ts
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
- Step 6: Run migration
npx prisma migrate dev --name init
- Step 7: Commit
git add apps/backend/prisma/ apps/backend/src/modules/prisma/ apps/backend/package.json apps/backend/.env apps/backend/.gitignore
git commit -m "feat: add Prisma ORM with SQLite and User model"
Task 2: AuthService — register, login, refresh, logout (TDD)
Files:
-
Create:
apps/backend/src/modules/auth/interfaces/jwt-payload.interface.ts -
Create:
apps/backend/src/modules/auth/auth.service.spec.ts -
Create:
apps/backend/src/modules/auth/auth.service.ts -
Create:
apps/backend/src/modules/auth/dto/register.dto.ts -
Create:
apps/backend/src/modules/auth/dto/login.dto.ts -
Create:
apps/backend/src/modules/auth/dto/update-profile.dto.ts -
Step 1: Write JwtPayload interface
// apps/backend/src/modules/auth/interfaces/jwt-payload.interface.ts
export interface JwtPayload {
sub: number;
email: string;
role: string;
}
- Step 2: Write DTOs
// apps/backend/src/modules/auth/dto/register.dto.ts
import { IsEmail, IsString, MinLength, MaxLength, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class RegisterDto {
@ApiProperty({ example: 'user@example.com' })
@IsEmail()
email: string;
@ApiProperty({ example: 'securePass123' })
@IsString()
@MinLength(6)
@MaxLength(100)
password: string;
@ApiPropertyOptional({ example: 'John' })
@IsString()
@IsOptional()
@MinLength(1)
@MaxLength(100)
name?: string;
}
// apps/backend/src/modules/auth/dto/login.dto.ts
import { IsEmail, IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class LoginDto {
@ApiProperty({ example: 'user@example.com' })
@IsEmail()
email: string;
@ApiProperty({ example: 'securePass123' })
@IsString()
password: string;
}
// apps/backend/src/modules/auth/dto/update-profile.dto.ts
import { IsString, IsOptional, MinLength, MaxLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateProfileDto {
@ApiPropertyOptional({ example: 'John Doe' })
@IsString()
@IsOptional()
@MinLength(1)
@MaxLength(100)
name?: string;
}
- Step 3: Write failing auth service tests
// apps/backend/src/modules/auth/auth.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule, JwtService } from '@nestjs/jwt';
import { AuthService } from './auth.service';
import { PrismaService } from '../prisma/prisma.service';
import * as bcrypt from 'bcrypt';
import { ConflictException, UnauthorizedException } from '@nestjs/common';
describe('AuthService', () => {
let service: AuthService;
let prisma: PrismaService;
let jwtService: JwtService;
const testUser = {
email: 'test@example.com',
password: 'testPass123',
name: 'Test User',
};
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
JwtModule.register({
secret: 'test-secret',
signOptions: { expiresIn: '15m' },
}),
],
providers: [
AuthService,
{
provide: PrismaService,
useValue: {
user: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
},
},
{
provide: ConfigService,
useValue: {
get: vi.fn((key: string) => {
const config: Record<string, string> = {
'auth.jwtSecret': 'test-secret',
'auth.jwtAccessExpires': '15m',
'auth.jwtRefreshExpires': '7d',
};
return config[key];
}),
},
},
],
}).compile();
service = module.get<AuthService>(AuthService);
prisma = module.get<PrismaService>(PrismaService);
jwtService = module.get<JwtService>(JwtService);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('register', () => {
it('should register a new user and return tokens', async () => {
const hashedPassword = await bcrypt.hash(testUser.password, 12);
const mockUser = {
id: 1,
email: testUser.email,
password: hashedPassword,
name: testUser.name,
role: 'user',
refreshToken: null,
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
vi.mocked(prisma.user.create).mockResolvedValue(mockUser);
const result = await service.register(testUser);
expect(result.user.id).toBe(1);
expect(result.user.email).toBe(testUser.email);
expect(result.user.name).toBe(testUser.name);
expect(result.user.role).toBe('user');
expect(result.accessToken).toBeDefined();
expect(result.refreshToken).toBeDefined();
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { email: testUser.email },
});
expect(prisma.user.create).toHaveBeenCalledWith({
data: {
email: testUser.email,
password: expect.any(String),
name: testUser.name,
},
});
});
it('should throw ConflictException if email already exists', async () => {
const mockUser = { id: 1, email: testUser.email, password: 'hash', name: 'Test', role: 'user', refreshToken: null, createdAt: new Date(), updatedAt: new Date() };
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser);
await expect(service.register(testUser)).rejects.toThrow(ConflictException);
expect(prisma.user.create).not.toHaveBeenCalled();
});
it('should register a user without name', async () => {
const hashedPassword = await bcrypt.hash(testUser.password, 12);
const mockUser = {
id: 2,
email: 'noname@example.com',
password: hashedPassword,
name: null,
role: 'user',
refreshToken: null,
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
vi.mocked(prisma.user.create).mockResolvedValue(mockUser);
const result = await service.register({ email: 'noname@example.com', password: testUser.password });
expect(result.user.name).toBeNull();
expect(result.accessToken).toBeDefined();
});
});
describe('login', () => {
it('should login with valid credentials', async () => {
const passwordHash = await bcrypt.hash(testUser.password, 12);
const mockUser = {
id: 1,
email: testUser.email,
password: passwordHash,
name: testUser.name,
role: 'user',
refreshToken: null,
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser);
vi.mocked(prisma.user.update).mockResolvedValue({ ...mockUser, refreshToken: 'some-hash' });
const result = await service.login({ email: testUser.email, password: testUser.password });
expect(result.user.id).toBe(1);
expect(result.accessToken).toBeDefined();
expect(result.refreshToken).toBeDefined();
});
it('should throw UnauthorizedException for wrong password', async () => {
const mockUser = {
id: 1,
email: testUser.email,
password: await bcrypt.hash(testUser.password, 12),
name: testUser.name,
role: 'user',
refreshToken: null,
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser);
await expect(service.login({ email: testUser.email, password: 'wrongPassword' })).rejects.toThrow(UnauthorizedException);
});
it('should throw UnauthorizedException for non-existent email', async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
await expect(service.login({ email: 'nonexistent@example.com', password: 'pass' })).rejects.toThrow(UnauthorizedException);
});
});
describe('refresh', () => {
it('should rotate tokens on valid refresh', async () => {
const passwordHash = await bcrypt.hash(testUser.password, 12);
const oldRefreshToken = 'valid-refresh-token-jwt';
const oldRefreshHash = await bcrypt.hash(oldRefreshToken, 12);
const mockUser = {
id: 1,
email: testUser.email,
password: passwordHash,
name: testUser.name,
role: 'user',
refreshToken: oldRefreshHash,
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser);
vi.mocked(prisma.user.update).mockResolvedValue(mockUser);
const decoded = { sub: 1, jti: 'some-jti' };
vi.spyOn(jwtService, 'verifyAsync').mockResolvedValue(decoded as any);
const result = await service.refresh(oldRefreshToken);
expect(result.accessToken).toBeDefined();
expect(result.refreshToken).toBeDefined();
expect(result.refreshToken).not.toBe(oldRefreshToken);
expect(prisma.user.update).toHaveBeenCalled();
});
it('should throw on invalid refresh token', async () => {
vi.spyOn(jwtService, 'verifyAsync').mockRejectedValue(new Error('jwt expired'));
await expect(service.refresh('bad-token')).rejects.toThrow(UnauthorizedException);
});
});
describe('logout', () => {
it('should clear refreshToken', async () => {
vi.mocked(prisma.user.update).mockResolvedValue({
id: 1,
email: testUser.email,
password: 'hash',
name: testUser.name,
role: 'user',
refreshToken: null,
createdAt: new Date(),
updatedAt: new Date(),
});
await service.logout(1);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: 1 },
data: { refreshToken: null },
});
});
});
describe('updateProfile', () => {
it('should update user name', async () => {
const mockUser = {
id: 1,
email: testUser.email,
password: 'hash',
name: 'Updated Name',
role: 'user',
refreshToken: null,
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(prisma.user.update).mockResolvedValue(mockUser);
const result = await service.updateProfile(1, { name: 'Updated Name' });
expect(result.name).toBe('Updated Name');
});
it('should return user without changes if no fields provided', async () => {
const mockUser = {
id: 1,
email: testUser.email,
password: 'hash',
name: 'Test User',
role: 'user',
refreshToken: null,
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(prisma.user.update).mockResolvedValue(mockUser);
const result = await service.updateProfile(1, {});
expect(result.name).toBe('Test User');
});
});
});
- Step 4: Run test to verify it fails
npx vitest run apps/backend/src/modules/auth/auth.service.spec.ts -w apps/backend
Expected: FAIL — "AuthService not defined" or similar.
- Step 5: Write minimal AuthService
// apps/backend/src/modules/auth/auth.service.ts
import { Injectable, ConflictException, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '../prisma/prisma.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { JwtPayload } from './interfaces/jwt-payload.interface';
const SALT_ROUNDS = 12;
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
async register(dto: RegisterDto) {
const existing = await this.prisma.user.findUnique({ where: { email: dto.email } });
if (existing) {
throw new ConflictException('Email already registered');
}
const passwordHash = await bcrypt.hash(dto.password, SALT_ROUNDS);
const user = await this.prisma.user.create({
data: {
email: dto.email,
password: passwordHash,
name: dto.name ?? null,
},
});
return this.generateTokens(user);
}
async login(dto: LoginDto) {
const user = await this.prisma.user.findUnique({ where: { email: dto.email } });
if (!user) {
throw new UnauthorizedException('Invalid email or password');
}
const isValid = await bcrypt.compare(dto.password, user.password);
if (!isValid) {
throw new UnauthorizedException('Invalid email or password');
}
return this.generateTokens(user);
}
async refresh(refreshToken: string) {
try {
const payload = await this.jwtService.verifyAsync<{ sub: number; jti: string }>(refreshToken, {
secret: this.configService.get('auth.jwtRefreshSecret'),
});
const user = await this.prisma.user.findUnique({ where: { id: payload.sub } });
if (!user?.refreshToken) {
throw new UnauthorizedException('Invalid refresh token');
}
const isValid = await bcrypt.compare(refreshToken, user.refreshToken);
if (!isValid) {
throw new UnauthorizedException('Invalid refresh token');
}
return this.generateTokens(user);
} catch {
throw new UnauthorizedException('Invalid refresh token');
}
}
async logout(userId: number) {
await this.prisma.user.update({
where: { id: userId },
data: { refreshToken: null },
});
}
async getProfile(userId: number) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new UnauthorizedException('User not found');
}
return this.sanitizeUser(user);
}
async updateProfile(userId: number, dto: UpdateProfileDto) {
const user = await this.prisma.user.update({
where: { id: userId },
data: {
...(dto.name !== undefined && { name: dto.name }),
},
});
return this.sanitizeUser(user);
}
private async generateTokens(user: { id: number; email: string; role: string }) {
const accessPayload: JwtPayload = { sub: user.id, email: user.email, role: user.role };
const accessToken = await this.jwtService.signAsync(accessPayload, {
secret: this.configService.get('auth.jwtSecret'),
expiresIn: this.configService.get('auth.jwtAccessExpires'),
});
const jti = crypto.randomUUID();
const refreshPayload = { sub: user.id, jti };
const refreshToken = await this.jwtService.signAsync(refreshPayload, {
secret: this.configService.get('auth.jwtRefreshSecret'),
expiresIn: this.configService.get('auth.jwtRefreshExpires'),
});
const refreshHash = await bcrypt.hash(refreshToken, SALT_ROUNDS);
await this.prisma.user.update({
where: { id: user.id },
data: { refreshToken: refreshHash },
});
return {
user: this.sanitizeUser(user),
accessToken,
refreshToken,
};
}
private sanitizeUser(user: { id: number; email: string; name: string | null; role: string }) {
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
};
}
}
- Step 6: Run test to verify it passes
npx vitest run apps/backend/src/modules/auth/auth.service.spec.ts -w apps/backend
Expected: PASS
Task 3: Auth guards, decorators, controller, module
Files:
-
Create:
apps/backend/src/modules/auth/decorators/public.decorator.ts -
Create:
apps/backend/src/modules/auth/decorators/current-user.decorator.ts -
Create:
apps/backend/src/modules/auth/decorators/roles.decorator.ts -
Create:
apps/backend/src/modules/auth/guards/jwt-auth.guard.ts -
Create:
apps/backend/src/modules/auth/guards/roles.guard.ts -
Create:
apps/backend/src/modules/auth/auth.controller.ts -
Create:
apps/backend/src/modules/auth/auth.module.ts -
Step 1: Public decorator
// apps/backend/src/modules/auth/decorators/public.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
- Step 2: CurrentUser decorator
// apps/backend/src/modules/auth/decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);
- Step 3: Roles decorator
// apps/backend/src/modules/auth/decorators/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
- Step 4: JwtAuthGuard
// apps/backend/src/modules/auth/guards/jwt-auth.guard.ts
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { JwtPayload } from '../interfaces/jwt-payload.interface';
@Injectable()
export class JwtAuthGuard {
constructor(
private readonly reflector: Reflector,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
const request = context.switchToHttp().getRequest();
const token = this.extractToken(request);
if (!token) {
throw new UnauthorizedException('Authentication required');
}
try {
const payload = await this.jwtService.verifyAsync<JwtPayload>(token, {
secret: this.configService.get('auth.jwtSecret'),
});
request.user = payload;
return true;
} catch {
throw new UnauthorizedException('Invalid or expired token');
}
}
private extractToken(request: any): string | null {
const auth = request.headers?.authorization;
if (!auth) return null;
const [type, token] = auth.split(' ');
return type === 'Bearer' ? token : null;
}
}
- Step 5: RolesGuard
// apps/backend/src/modules/auth/guards/roles.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest();
return requiredRoles.includes(request.user?.role);
}
}
- Step 6: AuthController
// apps/backend/src/modules/auth/auth.controller.ts
import { Controller, Post, Get, Patch, Body, Req, Res, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { CurrentUser } from './decorators/current-user.decorator';
import { Public } from './decorators/public.decorator';
import { JwtPayload } from './interfaces/jwt-payload.interface';
const REFRESH_COOKIE = 'refresh_token';
const COOKIE_OPTIONS = {
httpOnly: true,
sameSite: 'lax' as const,
secure: process.env.NODE_ENV === 'production',
path: '/api/v1/auth',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
};
@ApiTags('Auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Public()
@Post('register')
@ApiOperation({ summary: 'Register new user' })
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
const result = await this.authService.register(dto);
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
return {
data: {
user: result.user,
accessToken: result.accessToken,
},
meta: { fromCache: false, cachedAt: null },
};
}
@Public()
@Post('login')
@ApiOperation({ summary: 'Login with email and password' })
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
const result = await this.authService.login(dto);
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
return {
data: {
user: result.user,
accessToken: result.accessToken,
},
meta: { fromCache: false, cachedAt: null },
};
}
@Public()
@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Refresh access token' })
async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
const token = req.cookies?.[REFRESH_COOKIE];
const result = await this.authService.refresh(token);
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
return {
data: {
user: result.user,
accessToken: result.accessToken,
},
meta: { fromCache: false, cachedAt: null },
};
}
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@ApiOperation({ summary: 'Logout (clear refresh token)' })
async logout(@CurrentUser() user: JwtPayload, @Res({ passthrough: true }) res: Response) {
await this.authService.logout(user.sub);
res.clearCookie(REFRESH_COOKIE, { path: '/api/v1/auth' });
return {
data: { message: 'Logged out successfully' },
meta: { fromCache: false, cachedAt: null },
};
}
@Get('me')
@ApiBearerAuth()
@ApiOperation({ summary: 'Get current user profile' })
async getProfile(@CurrentUser() user: JwtPayload) {
const profile = await this.authService.getProfile(user.sub);
return {
data: profile,
meta: { fromCache: false, cachedAt: null },
};
}
@Patch('me')
@ApiBearerAuth()
@ApiOperation({ summary: 'Update current user profile' })
async updateProfile(@CurrentUser() user: JwtPayload, @Body() dto: UpdateProfileDto) {
const profile = await this.authService.updateProfile(user.sub, dto);
return {
data: profile,
meta: { fromCache: false, cachedAt: null },
};
}
}
- Step 7: AuthModule
// apps/backend/src/modules/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { RolesGuard } from './guards/roles.guard';
@Module({
imports: [
JwtModule.register({}),
],
controllers: [AuthController],
providers: [
AuthService,
JwtAuthGuard,
RolesGuard,
],
exports: [JwtAuthGuard, RolesGuard],
})
export class AuthModule {}
Task 4: Integrate AuthModule into app
Files:
-
Modify:
apps/backend/src/app.module.ts -
Modify:
apps/backend/src/main.ts -
Modify:
apps/backend/src/config/configuration.ts -
Modify:
apps/backend/package.json -
Step 1: Update configuration.ts
// apps/backend/src/config/configuration.ts — add auth section
import { registerAs } from '@nestjs/config';
export default registerAs('app', () => ({
port: parseInt(process.env.PORT || '3000', 10),
// ... existing config ...
auth: {
jwtSecret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production',
jwtRefreshSecret: process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret-change-in-production',
jwtAccessExpires: process.env.JWT_ACCESS_EXPIRES || '15m',
jwtRefreshExpires: process.env.JWT_REFRESH_EXPIRES || '7d',
},
// ... rest of config ...
}));
- Step 2: Update app.module.ts
// apps/backend/src/app.module.ts — add imports
imports: [
ConfigModule.forRoot({ load: [configuration], isGlobal: true }),
PrismaModule,
CacheModule,
MoexClientModule,
HealthModule,
AuthModule,
SecuritiesModule,
SharesModule,
BondsModule,
CandlesModule,
],
- Step 3: Update main.ts — add cookie-parser + Swagger bearer
// apps/backend/src/main.ts
import * as cookieParser from 'cookie-parser';
// After app creation:
app.use(cookieParser());
// Swagger config:
const config = new DocumentBuilder()
.setTitle('MoexVibe API')
.setVersion('1.0.0')
.addBearerAuth()
.build();
- Step 4: Update .env with JWT secrets
JWT_SECRET=dev-jwt-secret-change-in-production
JWT_REFRESH_SECRET=dev-refresh-secret-change-in-production
JWT_ACCESS_EXPIRES=15m
JWT_REFRESH_EXPIRES=7d
Task 5: Frontend — auth types + refactored client
Files:
-
Modify:
apps/frontend/src/api/responses.ts -
Modify:
apps/frontend/src/api/client.ts -
Create:
apps/frontend/src/api/auth.ts -
Step 1: Add auth types to responses.ts
// apps/frontend/src/api/responses.ts — add:
export interface UserResponse {
id: number;
email: string;
name: string | null;
role: string;
}
export interface AuthResponse {
user: UserResponse;
accessToken: string;
}
- Step 2: Refactor client.ts — add auth interceptor pattern
// apps/frontend/src/api/client.ts
import type { ApiEnvelope, ApiResponseMeta } from './responses';
const BASE = '';
let accessToken: string | null = null;
let onUnauthorized: (() => void) | null = null;
export function setAccessToken(token: string | null) {
accessToken = token;
}
export function getAccessToken(): string | null {
return accessToken;
}
export function setOnUnauthorized(cb: () => void) {
onUnauthorized = cb;
}
async function request<T>(
path: string,
params?: Record<string, string>,
options?: { skipAuth?: boolean },
): Promise<{ data: T; meta: ApiResponseMeta }> {
const url = new URL(`${BASE}${path}`, window.location.origin);
if (params) {
for (const [k, v] of Object.entries(params)) {
if (v !== undefined) url.searchParams.set(k, v);
}
}
const headers: Record<string, string> = {};
if (!options?.skipAuth && accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`;
}
const res = await fetch(url.toString(), {
headers,
credentials: 'include',
});
if (res.status === 401 && !options?.skipAuth) {
// Try to refresh
try {
const refreshRes = await fetch(`${BASE}/api/v1/auth/refresh`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
});
if (refreshRes.ok) {
const refreshJson: ApiEnvelope<{ data: AuthResponse; meta: ApiResponseMeta }> = await refreshRes.json();
const authData = refreshJson.data.data;
setAccessToken(authData.accessToken);
// Retry original request with new token
headers['Authorization'] = `Bearer ${authData.accessToken}`;
const retryRes = await fetch(url.toString(), { headers, credentials: 'include' });
if (!retryRes.ok) throw new Error(`API error: ${retryRes.status}`);
const retryJson: ApiEnvelope<{ data: T; meta: ApiResponseMeta }> = await retryRes.json();
return retryJson.data;
} else {
setAccessToken(null);
onUnauthorized?.();
throw new Error('Session expired');
}
} catch {
onUnauthorized?.();
throw new Error('Session expired');
}
}
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
const json: ApiEnvelope<{ data: T; meta: ApiResponseMeta }> = await res.json();
return json.data;
}
// Need to import AuthResponse type
import type { AuthResponse } from './responses';
// ... rest of existing functions stay the same
- Step 3: Create auth.ts
// apps/frontend/src/api/auth.ts
import { request, setAccessToken } from './client';
import type { AuthResponse, UserResponse } from './responses';
export async function login(email: string, password: string) {
const result = await request<AuthResponse>('/api/v1/auth/login', undefined, {
method: 'POST',
body: { email, password },
});
setAccessToken(result.data.accessToken);
return result.data;
}
export async function register(email: string, password: string, name?: string) {
const result = await request<AuthResponse>('/api/v1/auth/register', undefined, {
method: 'POST',
body: { email, password, name },
});
setAccessToken(result.data.accessToken);
return result.data;
}
export async function refreshTokens() {
const result = await request<AuthResponse>('/api/v1/auth/refresh', undefined, {
skipAuth: true,
});
setAccessToken(result.data.accessToken);
return result.data;
}
export async function logout() {
await request<{ message: string }>('/api/v1/auth/logout', undefined, {
method: 'POST',
});
setAccessToken(null);
}
export async function getMe() {
return request<UserResponse>('/api/v1/auth/me');
}
export async function updateProfile(data: { name?: string }) {
return request<UserResponse>('/api/v1/auth/me', undefined, {
method: 'PATCH',
body: data,
});
}
Wait — I need to rethink the client.ts approach. Currently request() uses GET with query params. For POST/PATCH requests I need to handle body. Let me redesign.
Let me restructure: keep existing API functions untouched (they all use GET), and add a new authRequest function or modify request to support other methods.
I'll modify the request function signature slightly:
async function request<T>(
path: string,
params?: Record<string, string> | { method: string; body?: unknown },
options?: { method?: string; body?: unknown; skipAuth?: boolean },
): Promise<{ data: T; meta: ApiResponseMeta }>
This is getting messy. Better approach: create a separate authRequest function or modify the existing request. Let me think...
Actually the cleanest approach: modify the existing request to accept an options bag with method, body, params as separate fields:
interface RequestOptions {
params?: Record<string, string>;
method?: string;
body?: unknown;
skipAuth?: boolean;
}
This way the existing callers still work with the old signature (params as second arg).
Let me plan this properly in the implementation.<|end▁of▁thinking|>
<||DSML||tool_calls> <||DSML||invoke name="todowrite"> <||DSML||parameter name="todos" string="false">[{"priority":"high","content":"Save implementation plan to docs/superpowers/plans/","status":"completed"},{"priority":"high","content":"Install backend dependencies (Prisma, JWT, bcrypt, cookie-parser)","status":"in_progress"},{"priority":"high","content":"Set up Prisma schema + PrismaService + PrismaModule","status":"pending"},{"priority":"high","content":"Build AuthService (register, login, refresh, logout) with TDD","status":"pending"},{"priority":"high","content":"Build Auth guards, decorators, DTOs, controller","status":"pending"},{"priority":"high","content":"Integrate AuthModule into app.module, main.ts, configuration","status":"pending"},{"priority":"high","content":"Frontend: refactor client.ts + add auth types","status":"pending"},{"priority":"high","content":"Frontend: AuthContext + useAuth hook","status":"pending"},{"priority":"high","content":"Frontend: Login, Register, Profile pages + ProtectedRoute","status":"pending"},{"priority":"high","content":"Frontend: Layout + routing changes","status":"pending"},{"priority":"high","content":"Documentation (backend auth.md, database.md, ADR, api.md, architecture.md)","status":"pending"},{"priority":"high","content":"Verification: run tests, lint, build","status":"pending"}]