Skip to content
Open
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
30 changes: 29 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ pnpm --filter api test:e2e # Run e2e tests
- Entry: `src/main.tsx` → `src/App.tsx`
- Vite + React 19, strict TypeScript (`tsconfig.app.json`)
- ESLint with React hooks and refresh plugins
- Styling with **Tailwind CSS**
- Styling with **Tailwind CSS** (v4, config via `@theme` em `src/index.css`)

#### Component Structure — Atomic Design

Expand All @@ -66,6 +66,34 @@ src/components/
pages/ # Full pages wired to real data/routes
```

#### Design Tokens — Cores

A paleta do projeto é definida via `@theme` em `src/index.css` e deve ser usada em todos os componentes. **Nunca use hexadecimais diretamente em classes Tailwind.**

| Token | Uso |
|---|---|
| `grafite` | Fundo da página |
| `cinza-escuro` | Fundo de cards/modais |
| `cinza-medio` | Fundo de inputs, checkboxes |
| `offwhite` | Texto principal |
| `verde-destaque` | Acento primário (botões, links, focus rings) |
| `verde-petroleo` | Texto sobre fundo verde (ex: label do botão primário) |

Exemplos: `bg-grafite`, `text-offwhite`, `bg-verde-destaque`, `text-verde-petroleo`, `focus:ring-verde-destaque`.

#### Design Tokens — Tamanhos de Fonte

Use sempre os tokens padrão do Tailwind mais próximos ao valor do design. **Nunca use tamanhos arbitrários como `text-[31px]`.**

| Token Tailwind | Tamanho | Uso (Figma) |
|---|---|---|
| `text-3xl` | 30px | Títulos de página (Subtitle Large) |
| `text-2xl` | 24px | Subtítulos (Paragraph Large ~22px) |
| `text-lg` | 18px | Texto de corpo / labels (Paragraph) |
| `text-base` | 16px | Texto secundário (Paragraph Small ~15px) |
| `text-sm` | 14px | Texto auxiliar pequeno |
| `text-xs` | 12px | Labels menores (Label ~12.5px) |

Rules:
- A component **must not** import from a higher-level category (atoms cannot import molecules).
- Every component **must have a co-located test** file (`ComponentName.test.tsx`) covering its essential usage (render, key interactions, accessibility where applicable).
Expand Down
19 changes: 17 additions & 2 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,39 @@
"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"
"test:e2e": "jest --config ./test/jest-e2e.json",
"seed": "ts-node -r tsconfig-paths/register src/seed.ts"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.2.6",
"@nestjs/typeorm": "^11.0.0",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.20.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
"rxjs": "^7.8.1",
"typeorm": "^0.3.28"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
Expand Down
30 changes: 29 additions & 1 deletion apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,37 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AuthModule } from './auth/auth.module';
import { Comment } from './posts/entities/comment.entity';
import { Like } from './posts/entities/like.entity';
import { Post } from './posts/entities/post.entity';
import { PostsModule } from './posts/posts.module';
import { User } from './users/entities/user.entity';
import { UsersModule } from './users/users.module';

@Module({
imports: [],
imports: [
ConfigModule.forRoot({ isGlobal: true }),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'postgres',
host: config.get<string>('DB_HOST', 'localhost'),
port: config.get<number>('DB_PORT', 5432),
username: config.get<string>('DB_USERNAME', 'codeconnect'),
password: config.get<string>('DB_PASSWORD', 'codeconnect'),
database: config.get<string>('DB_NAME', 'codeconnect'),
entities: [User, Post, Comment, Like],
synchronize: true,
}),
}),
UsersModule,
AuthModule,
PostsModule,
],
controllers: [AppController],
providers: [AppService],
})
Expand Down
54 changes: 54 additions & 0 deletions apps/api/src/auth/auth.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';

describe('AuthController', () => {
let controller: AuthController;
let authService: jest.Mocked<AuthService>;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
providers: [
{
provide: AuthService,
useValue: {
login: jest.fn(),
},
},
],
}).compile();

controller = module.get<AuthController>(AuthController);
authService = module.get(AuthService);
});

describe('login', () => {
it('should call authService.login and return the token', async () => {
const dto = { email: 'john@example.com', password: 'secret123' };
const expected = { access_token: 'signed-token' };
authService.login.mockResolvedValue(expected);

const result = await controller.login(dto);

// eslint-disable-next-line @typescript-eslint/unbound-method
expect(authService.login).toHaveBeenCalledWith(dto);
expect(result).toEqual(expected);
});
});

describe('getMe', () => {
it('should return req.user', () => {
const mockUser = {
id: 'uuid-1',
name: 'John',
email: 'john@example.com',
};
const req = { user: mockUser };

const result = controller.getMe(req);

expect(result).toEqual(mockUser);
});
});
});
44 changes: 44 additions & 0 deletions apps/api/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Post,
Request,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';

@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}

@Post('login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Login and receive JWT' })
@ApiResponse({ status: 200, description: 'JWT token returned' })
@ApiResponse({ status: 401, description: 'Invalid credentials' })
async login(@Body() loginDto: LoginDto) {
return this.authService.login(loginDto);
}

@Get('me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get current user profile' })
@ApiResponse({ status: 200, description: 'Current user data' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
getMe(@Request() req: { user: unknown }) {
return req.user;
}
}
25 changes: 25 additions & 0 deletions apps/api/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { UsersModule } from '../users/users.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';

@Module({
imports: [
UsersModule,
PassportModule,
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('JWT_SECRET', 'dev-secret-change-me'),
signOptions: { expiresIn: '1h' },
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
})
export class AuthModule {}
83 changes: 83 additions & 0 deletions apps/api/src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing';
import * as bcrypt from 'bcrypt';
import { UsersService } from '../users/users.service';
import { AuthService } from './auth.service';

describe('AuthService', () => {
let service: AuthService;
let usersService: jest.Mocked<UsersService>;
let jwtService: jest.Mocked<JwtService>;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
{
provide: UsersService,
useValue: {
findByEmail: jest.fn(),
},
},
{
provide: JwtService,
useValue: {
sign: jest.fn(),
},
},
],
}).compile();

service = module.get<AuthService>(AuthService);
usersService = module.get(UsersService);
jwtService = module.get(JwtService);
});

describe('login', () => {
it('should return access_token on valid credentials', async () => {
const hashedPassword = await bcrypt.hash('secret123', 10);
usersService.findByEmail.mockResolvedValue({
id: 'uuid-1',
name: 'John',
email: 'john@example.com',
password: hashedPassword,
});
jwtService.sign.mockReturnValue('signed-token');

const result = await service.login({
email: 'john@example.com',
password: 'secret123',
});

expect(result).toEqual({ access_token: 'signed-token' });
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(jwtService.sign).toHaveBeenCalledWith({
sub: 'uuid-1',
email: 'john@example.com',
});
});

it('should throw UnauthorizedException when email not found', async () => {
usersService.findByEmail.mockResolvedValue(null);

await expect(
service.login({ email: 'nobody@example.com', password: 'pass' }),
).rejects.toThrow(UnauthorizedException);
});

it('should throw UnauthorizedException when password is wrong', async () => {
const hashedPassword = await bcrypt.hash('correct-pass', 10);
usersService.findByEmail.mockResolvedValue({
id: 'uuid-1',
name: 'John',
email: 'john@example.com',
password: hashedPassword,
});

await expect(
service.login({ email: 'john@example.com', password: 'wrong-pass' }),
).rejects.toThrow(UnauthorizedException);
});
});
});
28 changes: 28 additions & 0 deletions apps/api/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { UsersService } from '../users/users.service';
import { LoginDto } from './dto/login.dto';

@Injectable()
export class AuthService {
constructor(
private readonly usersService: UsersService,
private readonly jwtService: JwtService,
) {}

async login(dto: LoginDto): Promise<{ access_token: string }> {
const user = await this.usersService.findByEmail(dto.email);
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}

const passwordMatch = await bcrypt.compare(dto.password, user.password);
if (!passwordMatch) {
throw new UnauthorizedException('Invalid credentials');
}

const payload = { sub: user.id, email: user.email };
return { access_token: this.jwtService.sign(payload) };
}
}
2 changes: 2 additions & 0 deletions apps/api/src/auth/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const JWT_SECRET = process.env.JWT_SECRET ?? 'dev-secret-change-me';
export const JWT_EXPIRES_IN = '1h';
12 changes: 12 additions & 0 deletions apps/api/src/auth/dto/login.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsString } from 'class-validator';

export class LoginDto {
@ApiProperty({ example: 'john@example.com' })
@IsEmail()
email: string;

@ApiProperty({ example: 'strongP@ss1' })
@IsString()
password: string;
}
5 changes: 5 additions & 0 deletions apps/api/src/auth/guards/jwt-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
10 changes: 10 additions & 0 deletions apps/api/src/auth/guards/optional-jwt-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class OptionalJwtAuthGuard extends AuthGuard('jwt') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
handleRequest(_err: any, user: any) {
return user || null;
}
}
Loading
Loading