moex-vibe/docs/superpowers/plans/2026-06-13-auth-system.md
Sergey Krylov c2478277e6
Some checks failed
CI / lint (pull_request) Failing after 1m45s
CI / test (pull_request) Failing after 1m42s
CI / build (pull_request) Failing after 1m31s
CI / lint (push) Failing after 1m46s
CI / test (push) Failing after 1m34s
CI / build (push) Failing after 1m35s
feat: add authentication system with JWT + RBAC
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
2026-06-13 22:24:46 +03:00

1186 lines
35 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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**
```bash
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**
```bash
npx prisma init --datasource-provider sqlite
```
Update the generated `apps/backend/prisma/schema.prisma`:
```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**
```env
DATABASE_URL="file:./dev.db"
```
Add `.env` to backend `.gitignore`:
```gitignore
node_modules/
dist/
.env
*.db
*.db-journal
```
- [ ] **Step 4: PrismaService**
```typescript
// 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**
```typescript
// 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**
```bash
npx prisma migrate dev --name init
```
- [ ] **Step 7: Commit**
```bash
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**
```typescript
// apps/backend/src/modules/auth/interfaces/jwt-payload.interface.ts
export interface JwtPayload {
sub: number;
email: string;
role: string;
}
```
- [ ] **Step 2: Write DTOs**
```typescript
// 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;
}
```
```typescript
// 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;
}
```
```typescript
// 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**
```typescript
// 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**
```bash
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**
```typescript
// 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**
```bash
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**
```typescript
// 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**
```typescript
// 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**
```typescript
// 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**
```typescript
// 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**
```typescript
// 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**
```typescript
// 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**
```typescript
// 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**
```typescript
// 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**
```typescript
// 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**
```typescript
// 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**
```typescript
// 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**
```typescript
// 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**
```typescript
// 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:
```typescript
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:
```typescript
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>
<DSMLtool_calls>
<DSMLinvoke name="todowrite">
<DSMLparameter 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"}]