A modern full-stack monorepo demonstrating best practices for building web applications with React and NestJS.
| Layer | Technology | Version |
|---|---|---|
| Frontend | React | 19 |
| TypeScript | 6 | |
| Vite | 8 | |
| React Router | 7 | |
| Tailwind CSS | 4.2 | |
| shadcn/ui | latest | |
| Zustand | 5 | |
| Vitest | 4 | |
| Backend | NestJS | 11 |
| TypeScript | 6 | |
| Jest | 30 | |
| Database | PostgreSQL | 18 |
| MongoDB | 8.2 | |
| Storage | SeaweedFS | 3.80 |
| Runtime | Node.js | 24 LTS |
- Docker Desktop (for devcontainer)
- VS Code with Dev Containers extension
Or for local development without Docker:
- Node.js 24 LTS
- npm 10+
- PostgreSQL 18
- MongoDB 8.2
-
Clone the repository
git clone <repository-url> cd Demo
-
Set up environment variables
cp .env.example .env
Edit
.envif you need to change default values. -
Copy .env for devcontainer
cp .env .devcontainer/.env
This copy is required for Docker Compose to read environment variables during container build.
Note: If you update the root
.envfile, remember to copy it to.devcontainer/.envagain. -
Open in VS Code
code . -
Start Dev Container
- Press
F1and selectDev Containers: Reopen in Container - Wait for the container to build (first time takes a few minutes)
- Press
-
Install dependencies
npm install
-
Start development servers
npm run dev
-
Access the application
- Frontend: http://localhost:5173
- Backend API: http://localhost:3000
-
Clone and set up environment
git clone <repository-url> cd Demo cp .env.example .env
-
Install dependencies
npm install
-
Start infrastructure services
docker compose -f infrastructure/docker-compose.infra.yml up -d
-
Start development servers
npm run dev
Demo/
├── frontend/ # React application
│ ├── src/
│ │ ├── main.tsx # Application entry point
│ │ ├── index.css # Tailwind CSS & theme variables
│ │ ├── routes/ # React Router configuration
│ │ ├── pages/ # Page components
│ │ ├── layouts/ # Layout components
│ │ ├── components/ # Shared components
│ │ │ └── ui/ # shadcn/ui components
│ │ ├── lib/utils.ts # Utility functions (cn helper)
│ │ ├── hooks/ # Custom React hooks
│ │ └── assets/ # Static assets
│ ├── public/ # Public static files
│ ├── components.json # shadcn/ui configuration
│ ├── package.json
│ ├── vite.config.ts # Vite configuration
│ └── tsconfig.json # TypeScript configuration
│
├── backend/ # NestJS application
│ ├── src/
│ │ ├── main.ts # Application bootstrap
│ │ ├── app.module.ts # Root module
│ │ ├── app.controller.ts
│ │ └── app.service.ts
│ ├── test/ # E2E tests
│ ├── package.json
│ └── tsconfig.json
│
├── infrastructure/ # Docker services
│ └── docker-compose.infra.yml
│
├── .devcontainer/ # VS Code dev container
│ ├── devcontainer.json
│ ├── docker-compose.yml
│ └── Dockerfile
│
├── .husky/ # Git hooks
│ └── pre-commit
│
├── .env.example # Environment variables template
├── .gitignore # Git ignore rules
├── eslint.config.js # ESLint configuration (monorepo)
├── .prettierrc.json # Prettier configuration
├── .lintstagedrc.json # Lint-staged configuration
└── package.json # Root package with workspaces
All commands are run from the root directory.
# Start both frontend and backend in development mode
npm run dev
# Start only frontend (Vite dev server)
npm run dev:frontend
# Start only backend (NestJS watch mode)
npm run dev:backend# Run all tests
npm run test
# Run frontend tests only
npm run test:frontend
# Run backend tests only
npm run test:backend
# Run backend E2E tests
npm run test:e2e
# Run tests with coverage
npm run test:coverage# Type check entire project
npm run typecheck
# Type check frontend only
npm run typecheck:frontend
# Type check backend only
npm run typecheck:backend# Build both frontend and backend
npm run build
# Build frontend only (outputs to frontend/dist)
npm run build:frontend
# Build backend only (outputs to backend/dist)
npm run build:backend# Run ESLint
npm run lint
# Run ESLint with auto-fix
npm run lint:fix
# Format code with Prettier
npm run format
# Check formatting
npm run format:check# Install all dependencies (root + frontend + backend)
npm run install:all
# Clean everything (node_modules + dist + coverage)
npm run clean
# Clean only build outputs
npm run clean:dist
# Clean and reinstall all dependencies
npm run reinstall
# Full verification (typecheck + lint + test + build)
npm run verify
# Start backend in production mode
npm run start:prod-
Create a new branch for your feature
git checkout -b feature/my-feature
-
Start the development servers
npm run dev
-
Make your changes - the servers will hot-reload automatically
The pre-commit hook automatically runs ESLint and Prettier on staged files.
For a full verification before pushing:
npm run verifyThis project enforces consistent code style:
- ESLint - Catches errors and enforces best practices
- Prettier - Formats code automatically
- TypeScript - Strict type checking enabled
Configuration files:
eslint.config.js- ESLint rules for the entire monorepo.prettierrc.json- Prettier formatting options
This project uses Tailwind CSS v4 with shadcn/ui components.
Tailwind v4 uses a CSS-first configuration. All theme customization is done in frontend/src/index.css.
Colors use the OKLCH color space for better perceptual uniformity:
/* frontend/src/index.css */
:root {
/* Primary brand color */
--primary: oklch(20.47% 0.006 285.88);
--primary-foreground: oklch(98.51% 0.001 285.94);
/* Accent color */
--accent: oklch(96.76% 0.001 285.94);
--accent-foreground: oklch(20.47% 0.006 285.88);
/* Add more custom colors... */
}
.dark {
/* Dark mode overrides */
--primary: oklch(98.51% 0.001 285.94);
--primary-foreground: oklch(20.47% 0.006 285.88);
}Extend the theme using @theme inline:
@theme inline {
/* Map CSS variables to Tailwind utilities */
--color-brand: var(--brand);
--color-brand-foreground: var(--brand-foreground);
/* Custom spacing */
--spacing-18: 4.5rem;
/* Custom fonts */
--font-heading: 'Inter', sans-serif;
}Then use in your components: bg-brand, text-brand-foreground, p-18, font-heading.
OKLCH format: oklch(lightness% chroma hue)
- Lightness: 0% (black) to 100% (white)
- Chroma: 0 (gray) to ~0.4 (vivid)
- Hue: 0-360 (red=30, orange=70, yellow=110, green=150, cyan=190, blue=260, purple=300, pink=350)
Tools: OKLCH Color Picker
shadcn/ui provides beautifully designed, accessible components that you copy into your project.
# Add individual components
npx shadcn@latest add button
npx shadcn@latest add card
npx shadcn@latest add input
npx shadcn@latest add dialog
# Add multiple components at once
npx shadcn@latest add button card input label
# View all available components
npx shadcn@latest addComponents are installed to frontend/src/components/ui/.
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
export function MyComponent() {
return (
<Card>
<CardHeader>
<CardTitle>Welcome</CardTitle>
</CardHeader>
<CardContent>
<Button variant="default">Click me</Button>
<Button variant="outline">Secondary</Button>
<Button variant="destructive">Delete</Button>
</CardContent>
</Card>
);
}Components are copied to your project, so you can modify them directly:
// frontend/src/components/ui/button.tsx
// Edit variants, sizes, styles as neededUse the cn() utility for conditional classes:
import { cn } from '@/lib/utils';
<div className={cn(
'base-styles',
isActive && 'active-styles',
className
)} />frontend/src/
├── components/
│ └── ui/ # shadcn/ui components (auto-generated)
│ ├── button.tsx
│ ├── card.tsx
│ └── ...
├── lib/
│ └── utils.ts # cn() helper function
└── hooks/ # Custom React hooks
shadcn/ui settings are in frontend/components.json:
- style:
new-york(modern design) - baseColor:
neutral(gray scale) - cssVariables:
true(uses CSS custom properties) - iconLibrary:
lucide(Lucide React icons)
For more components and examples, visit ui.shadcn.com.
This project uses React Router v7 for client-side navigation.
frontend/src/
├── routes/
│ └── index.tsx # Router configuration
├── pages/
│ ├── HomePage.tsx # Home page (/)
│ ├── AboutPage.tsx # About page (/about)
│ └── NotFoundPage.tsx # 404 page
├── layouts/
│ └── RootLayout.tsx # Main layout with navbar
└── components/
└── Navbar.tsx # Navigation component
- Create a new page component in
frontend/src/pages/:
// frontend/src/pages/ContactPage.tsx
export default function ContactPage() {
return (
<div className="mx-auto max-w-3xl px-4 py-16">
<h1 className="text-3xl font-bold">Contact Us</h1>
{/* Page content */}
</div>
);
}- Add the route in
frontend/src/routes/index.tsx:
import ContactPage from '@/pages/ContactPage';
export const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />,
children: [
// ... existing routes
{
path: 'contact',
element: <ContactPage />,
},
],
},
]);- Add navigation link in
frontend/src/components/Navbar.tsx:
<NavLink to="/contact" className={({ isActive }) => cn(...)}>
Contact
</NavLink>Use React Router's components for navigation:
import { Link, NavLink, useNavigate } from 'react-router-dom';
// Basic link
<Link to="/about">About</Link>
// NavLink with active state styling
<NavLink
to="/about"
className={({ isActive }) =>
cn('text-sm', isActive ? 'text-primary' : 'text-muted-foreground')
}
>
About
</NavLink>
// Programmatic navigation
const navigate = useNavigate();
navigate('/about');
navigate(-1); // Go backTo create dropdown menus with navigation:
# Install dropdown menu component
npx shadcn@latest add dropdown-menu buttonimport { Link } from 'react-router-dom';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { Menu } from 'lucide-react';
export function NavMenu() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Menu className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link to="/">Home</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/about">About</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/contact">Contact</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}For complex navigation menus:
# Install navigation menu component
npx shadcn@latest add navigation-menuimport { Link } from 'react-router-dom';
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
} from '@/components/ui/navigation-menu';
export function MainNav() {
return (
<NavigationMenu>
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuLink asChild>
<Link to="/">Home</Link>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger>Products</NavigationMenuTrigger>
<NavigationMenuContent>
<NavigationMenuLink asChild>
<Link to="/products/electronics">Electronics</Link>
</NavigationMenuLink>
<NavigationMenuLink asChild>
<Link to="/products/clothing">Clothing</Link>
</NavigationMenuLink>
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
);
}// Dynamic route with parameters
{
path: 'users/:userId',
element: <UserPage />,
}
// Access params in component
import { useParams } from 'react-router-dom';
function UserPage() {
const { userId } = useParams();
return <div>User ID: {userId}</div>;
}For more details, see React Router Documentation.
| Service | Port | Description |
|---|---|---|
| Frontend (Vite) | 5173 | React development server |
| Backend (NestJS) | 3000 | REST API server |
| PostgreSQL | 5432 | Relational database |
| MongoDB | 27017 | Document database |
| SeaweedFS Master | 9333 | Distributed storage master |
| SeaweedFS S3 | 8333 | S3-compatible API |
All environment variables are defined in a single .env file at the project root.
-
Copy the example file:
cp .env.example .env
-
Available variables (see
.env.examplefor full list):Variable Default Description NODE_ENVdevelopment Environment mode PORT3000 Backend server port VITE_API_URLhttp://localhost:3000 API URL for frontend POSTGRES_*- PostgreSQL connection settings MONGO_*- MongoDB connection settings SEAWEEDFS_*- SeaweedFS connection settings
Note: Never commit
.envto version control. It's already in.gitignore.
Connection details are configured via environment variables in .env.
Host: localhost (or $POSTGRES_HOST in devcontainer)
Port: $POSTGRES_PORT (default: 5432)
Database: $POSTGRES_DB (default: app_db)
Username: $POSTGRES_USER (default: user)
Password: $POSTGRES_PASSWORD
Host: localhost (or $MONGO_HOST in devcontainer)
Port: $MONGO_PORT (default: 27017)
Username: $MONGO_USER (default: admin)
Password: $MONGO_PASSWORD
This project implements JWT-based authentication with a manual credential validation approach rather than using passport-local. This section explains the architecture and the reasoning behind this design decision.
You might notice there's no passport-local strategy in this codebase. This is an intentional design choice, not an oversight.
┌─────────────┐ ┌──────────────────┐ ┌─────────────┐ ┌─────────────┐
│ Request │───▶│ passport-local │───▶│ Validate │───▶│ Issue JWT │
│ (email/pw) │ │ Strategy │ │ Credentials │ │ Tokens │
└─────────────┘ └──────────────────┘ └─────────────┘ └─────────────┘
┌─────────────┐ ┌──────────────────┐ ┌─────────────┐
│ Request │───▶│ AuthService │───▶│ Issue JWT │
│ (email/pw) │ │ .login() │ │ Tokens │
└─────────────┘ │ (validates here) │ └─────────────┘
└──────────────────┘
| Approach | Best For |
|---|---|
| Manual validation (this project) | Simple email/password, JWT-only APIs, no sessions |
| passport-local | Multiple auth methods (OAuth, SAML), session-based auth, complex auth flows |
- Simpler code - No Passport boilerplate for a single auth method
- Fewer dependencies - No need for
passport-localpackage - Full control - Custom validation logic, better error messages
- Easier audit logging - Log at each step of validation
- Clearer flow - Students can trace the entire auth flow in one file
The login endpoint (POST /api/auth/login) is not protected by any guard - it's a public endpoint that validates credentials manually.
POST /api/auth/login
│
▼
┌───────────────────────────────────────────────────────────────┐
│ AuthService.login() │
├───────────────────────────────────────────────────────────────┤
│ │
│ 1. Find user by email │
│ └─▶ usersService.findByEmail(email) │
│ └─▶ Returns User entity or null │
│ │
│ 2. Check user exists │
│ └─▶ If null → log failure → throw 401 "Invalid credentials"│
│ │
│ 3. Check user is active │
│ └─▶ If !isActive → log failure → throw 401 "Account deactivated"│
│ │
│ 4. Validate password │
│ └─▶ usersService.validatePassword(user, password) │
│ └─▶ bcrypt.compare(password, user.password) │
│ └─▶ If false → log failure → throw 401 "Invalid credentials"│
│ │
│ 5. Generate tokens │
│ └─▶ generateTokens(userId, email, roles) │
│ └─▶ jwtService.signAsync(accessPayload, { expiresIn: '15m' })│
│ └─▶ jwtService.signAsync(refreshPayload, { secret, expiresIn: '7d' })│
│ │
│ 6. Log successful login │
│ └─▶ auditLogService.log(userId, email, LOGIN, 'auth') │
│ │
│ 7. Return response │
│ └─▶ { accessToken, refreshToken, user: { id, email, name, roles } }│
│ │
└───────────────────────────────────────────────────────────────┘
// auth.service.ts - login() method
async login(loginDto: LoginDto, context?: RequestContext): Promise<AuthResponse> {
// Step 1: Find user
const user = await this.usersService.findByEmail(loginDto.email);
// Step 2: User exists?
if (!user) {
await this.auditLogService.log('unknown', loginDto.email, AuditAction.LOGIN_FAILED, ...);
throw new UnauthorizedException('Invalid credentials');
}
// Step 3: User active?
if (!user.isActive) {
await this.auditLogService.log(user.id, user.email, AuditAction.LOGIN_FAILED, ...);
throw new UnauthorizedException('Account is deactivated');
}
// Step 4: Password valid?
const isPasswordValid = await this.usersService.validatePassword(user, loginDto.password);
if (!isPasswordValid) {
await this.auditLogService.log(user.id, user.email, AuditAction.LOGIN_FAILED, ...);
throw new UnauthorizedException('Invalid credentials');
}
// Step 5: Generate tokens
const tokens = await this.generateTokens(user.id, user.email, user.roles);
// Step 6: Log success
await this.auditLogService.log(user.id, user.email, AuditAction.LOGIN, 'auth', ...);
// Step 7: Return
return { ...tokens, user: { id, email, name, roles } };
}Tokens are created using @nestjs/jwt's JwtService.signAsync():
// auth.service.ts - generateTokens() method
private async generateTokens(userId: string, email: string, roles: string[]): Promise<TokenPair> {
// Access token payload
const accessPayload: JwtPayload = {
sub: userId, // Subject (user ID)
email,
roles,
type: 'access', // Token type identifier
};
// Refresh token payload (same structure)
const refreshPayload: JwtPayload = {
sub: userId,
email,
roles,
type: 'refresh',
};
// Sign both tokens in parallel
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(accessPayload, {
expiresIn: '15m', // Short-lived access token
}),
this.jwtService.signAsync(refreshPayload, {
secret: this.refreshSecret, // Different secret for refresh tokens!
expiresIn: '7d', // Long-lived refresh token
}),
]);
return { accessToken, refreshToken };
}| Field | Access Token | Refresh Token |
|---|---|---|
sub |
User ID | User ID |
email |
User email | User email |
roles |
User roles | User roles |
type |
"access" |
"refresh" |
exp |
15 minutes | 7 days |
secret |
JWT_SECRET | JWT_REFRESH_SECRET |
Passport strategies are used only for validating existing tokens, not for initial authentication:
| Strategy | File | Purpose |
|---|---|---|
jwt |
jwt.strategy.ts |
Validates access tokens on protected routes |
jwt-refresh |
jwt-refresh.strategy.ts |
Validates refresh tokens for token renewal |
// jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(...) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), // Bearer token from header
ignoreExpiration: false,
secretOrKey: configService.get('JWT_SECRET'),
passReqToCallback: true,
});
}
async validate(req: Request, payload: JwtPayload) {
// Check token isn't blacklisted
if (token && this.tokenBlacklistService.isBlacklisted(token)) {
throw new UnauthorizedException('Token has been revoked');
}
// Only accept access tokens (not refresh tokens)
if (payload.type !== 'access') {
throw new UnauthorizedException('Invalid token type');
}
// Verify user still exists and is active
const user = await this.usersService.findById(payload.sub);
if (!user || !user.isActive) {
throw new UnauthorizedException('User not found or inactive');
}
// Return user info for @CurrentUser() decorator
return { userId: payload.sub, email: payload.email, roles: payload.roles };
}
}┌─────────────────────────────────────────────────────────────────────────────┐
│ AUTHENTICATION FLOW │
└─────────────────────────────────────────────────────────────────────────────┘
1. LOGIN (No Passport)
════════════════════
Client Server
│ │
│ POST /api/auth/login │
│ { email, password } │
│ ─────────────────────────────▶ │
│ │ AuthController.login()
│ │ │
│ │ ▼
│ │ AuthService.login()
│ │ │
│ │ ├─▶ Find user by email
│ │ ├─▶ Check user active
│ │ ├─▶ bcrypt.compare(password)
│ │ ├─▶ Generate JWT tokens
│ │ └─▶ Log to audit
│ │
│ { accessToken, refreshToken, │
│ user: {...} } │
│ ◀───────────────────────────── │
2. PROTECTED REQUEST (Uses Passport JWT)
══════════════════════════════════════
Client Server
│ │
│ GET /api/admin/users │
│ Authorization: Bearer <token> │
│ ─────────────────────────────▶ │
│ │ JwtAuthGuard
│ │ │
│ │ ▼
│ │ JwtStrategy.validate()
│ │ │
│ │ ├─▶ Verify signature
│ │ ├─▶ Check not expired
│ │ ├─▶ Check not blacklisted
│ │ ├─▶ Check type === 'access'
│ │ └─▶ Verify user active
│ │
│ │ RolesGuard
│ │ │
│ │ └─▶ Check user.roles includes 'admin'
│ │
│ │ AdminController.findAll()
│ { users: [...] } │
│ ◀───────────────────────────── │
3. TOKEN REFRESH (Uses Passport JWT-Refresh)
══════════════════════════════════════════
Client Server
│ │
│ POST /api/auth/refresh │
│ { refreshToken } │
│ ─────────────────────────────▶ │
│ │ JwtRefreshGuard
│ │ │
│ │ ▼
│ │ JwtRefreshStrategy.validate()
│ │ │
│ │ ├─▶ Extract from body
│ │ ├─▶ Verify with REFRESH secret
│ │ ├─▶ Check not blacklisted
│ │ └─▶ Check type === 'refresh'
│ │
│ │ AuthService.refreshTokens()
│ │ │
│ │ ├─▶ Blacklist old refresh token
│ │ └─▶ Generate new token pair
│ │
│ { accessToken, refreshToken } │
│ ◀───────────────────────────── │
4. LOGOUT (Blacklists Tokens)
═══════════════════════════
Client Server
│ │
│ POST /api/auth/logout │
│ Authorization: Bearer <token> │
│ { refreshToken? } │
│ ─────────────────────────────▶ │
│ │ JwtAuthGuard (validates access token)
│ │ │
│ │ ▼
│ │ AuthService.logout()
│ │ │
│ │ ├─▶ Add access token to blacklist
│ │ ├─▶ Add refresh token to blacklist
│ │ └─▶ Log to audit
│ │
│ { message: "Logged out" } │
│ ◀───────────────────────────── │
| Method | Endpoint | Auth Required | Description |
|---|---|---|---|
| POST | /api/auth/login |
No | Login with email/password |
| POST | /api/auth/refresh |
Refresh Token | Get new token pair |
| POST | /api/auth/logout |
Access Token | Invalidate tokens |
| GET | /api/auth/me |
Access Token | Get current user profile |
The system automatically creates a default admin user on startup:
| Field | Value |
|---|---|
admin@example.com |
|
| Password | admin123 |
| Roles | admin, user |
Warning: Change these credentials in production!
- Separate secrets - Access and refresh tokens use different secrets
- Token blacklisting - Revoked tokens are blacklisted until expiry
- Token type validation - Prevents using refresh tokens as access tokens
- User status check - Deactivated users can't authenticate
- Audit logging - All auth events logged to MongoDB
- Password hashing - bcrypt with cost factor 10
| File | Purpose |
|---|---|
backend/src/auth/auth.service.ts |
Login, logout, token generation |
backend/src/auth/auth.controller.ts |
Auth endpoints |
backend/src/auth/strategies/jwt.strategy.ts |
Access token validation |
backend/src/auth/strategies/jwt-refresh.strategy.ts |
Refresh token validation |
backend/src/auth/guards/jwt-auth.guard.ts |
Protects routes requiring auth |
backend/src/auth/guards/roles.guard.ts |
Enforces role requirements |
backend/src/auth/token-blacklist.service.ts |
In-memory token blacklist |
This section explains how role-based access control (RBAC) is implemented using decorators, guards, and enums.
Roles are defined as a TypeScript enum in backend/src/common/enums/role.enum.ts:
export enum Role {
USER = 'user',
ADMIN = 'admin',
}| Role | Description | Typical Access |
|---|---|---|
USER |
Standard user | Own profile, own files |
ADMIN |
Administrator | All users, all files, audit logs |
Users can have multiple roles. The default admin has both ['admin', 'user'].
Three custom decorators control access to routes:
File: backend/src/auth/decorators/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
import { Role } from '@/common/enums/role.enum';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);Usage:
@Roles(Role.ADMIN) // Only admins
@Roles(Role.USER) // Only users
@Roles(Role.ADMIN, Role.USER) // Either admin OR user (not both required)How it works:
SetMetadata()attaches role requirements to the route handlerRolesGuardreads this metadata usingReflector- Guard checks if user has ANY of the required roles
File: backend/src/auth/decorators/public.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);Usage:
@Public() // No authentication required
@Get('public-data')
async getPublicData() { ... }How it works:
- Sets
isPublic: truemetadata on the route JwtAuthGuardchecks this metadata BEFORE validating token- If
isPublicis true, guard allows request without authentication
File: backend/src/auth/decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: string | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest<{ user?: Record<string, unknown> }>();
const user = request.user;
if (!user) {
return null;
}
return data ? user[data] : user; // Can extract specific field
},
);Usage:
// Get entire user object
@Get('profile')
async getProfile(@CurrentUser() user: AuthenticatedUser) {
console.log(user); // { userId, email, roles }
}
// Get specific field
@Get('my-id')
async getMyId(@CurrentUser('userId') userId: string) {
console.log(userId); // "uuid-string"
}How it works:
JwtStrategy.validate()returns user data and attaches it torequest.user@CurrentUser()decorator extracts this from the request- Optional
dataparameter allows extracting a specific field
Guards run BEFORE the route handler and determine if the request should proceed.
File: backend/src/auth/guards/jwt-auth.guard.ts
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
// Check if route is marked as @Public()
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(), // Check method decorator
context.getClass(), // Check class decorator
]);
if (isPublic) {
return true; // Skip authentication for public routes
}
return super.canActivate(context); // Run Passport JWT validation
}
handleRequest<TUser>(err: Error | null, user: TUser | false, info: Error | undefined): TUser {
if (err || !user) {
throw err || new UnauthorizedException(info?.message ?? 'Unauthorized');
}
return user;
}
}Execution Flow:
Request arrives
│
▼
┌─────────────────────────┐
│ JwtAuthGuard │
│ canActivate() │
├─────────────────────────┤
│ 1. Check @Public() │──▶ If public, return true (skip auth)
│ 2. Call Passport JWT │
│ └─▶ JwtStrategy │
│ .validate() │
│ 3. Attach user to req │
└─────────────────────────┘
│
▼
Request proceeds (or 401)
File: backend/src/auth/guards/roles.guard.ts
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
// Get required roles from @Roles() decorator
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
// No @Roles() decorator = no role restriction
if (!requiredRoles) {
return true;
}
// Get user from request (set by JwtAuthGuard)
const request = context.switchToHttp().getRequest<{ user?: RequestUser }>();
const user = request.user;
if (!user) {
return false; // No user = deny access
}
// Check if user has ANY of the required roles
return requiredRoles.some((role) => user.roles.includes(role));
}
}Execution Flow:
After JwtAuthGuard passes
│
▼
┌─────────────────────────────┐
│ RolesGuard │
│ canActivate() │
├─────────────────────────────┤
│ 1. Read @Roles() metadata │
│ 2. If no roles required │──▶ return true
│ 3. Get user from request │
│ 4. Check user.roles │
│ includes any required │
└─────────────────────────────┘
│
▼
true = proceed
false = 403 Forbidden
Important: Guards execute in the order they are listed in @UseGuards():
@UseGuards(JwtAuthGuard, RolesGuard) // Auth first, then roles┌────────────────────────────────────────────────────────────────┐
│ REQUEST PIPELINE │
├────────────────────────────────────────────────────────────────┤
│ │
│ Request ──▶ JwtAuthGuard ──▶ RolesGuard ──▶ Route Handler │
│ │ │ │
│ │ └─▶ Checks roles │
│ │ (user already on request) │
│ │ │
│ └─▶ Validates JWT token │
│ Attaches user to request │
│ │
└────────────────────────────────────────────────────────────────┘
File: backend/src/admin/admin.controller.ts
This controller demonstrates class-level guards and decorators:
@Controller('admin/users')
@UseGuards(JwtAuthGuard, RolesGuard) // Applied to ALL routes in this controller
@Roles(Role.ADMIN) // ALL routes require ADMIN role
export class AdminController {
constructor(private readonly usersService: UsersService) {}
@Get()
async findAll() {
const users = await this.usersService.findAll();
return users.map((user) => user.toSafeObject());
}
@Get(':id')
async findOne(@Param('id') id: string) {
const user = await this.usersService.findById(id);
if (!user) {
throw new NotFoundException('User not found');
}
return user.toSafeObject();
}
@Post()
async create(@Body() createUserDto: CreateUserDto) {
const user = await this.usersService.create(createUserDto);
return user.toSafeObject();
}
@Put(':id')
async update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
const user = await this.usersService.update(id, updateUserDto);
return user.toSafeObject();
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id') id: string) {
await this.usersService.remove(id);
}
}Request Flow for GET /api/admin/users:
┌─────────────────────────────────────────────────────────────────────────────┐
│ GET /api/admin/users │
│ Authorization: Bearer <token> │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ STEP 1: JwtAuthGuard │
├─────────────────────────────────────────────────────────────────────────────┤
│ • Check @Public() on findAll() method → Not found │
│ • Check @Public() on AdminController class → Not found │
│ • Call Passport JWT Strategy │
│ └─▶ Extract token from Authorization header │
│ └─▶ Verify signature with JWT_SECRET │
│ └─▶ Check token not expired │
│ └─▶ Check token not blacklisted │
│ └─▶ Check token type === 'access' │
│ └─▶ Load user, verify active │
│ └─▶ Return { userId, email, roles } │
│ • Attach user to request.user │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ STEP 2: RolesGuard │
├─────────────────────────────────────────────────────────────────────────────┤
│ • Read @Roles() from findAll() method → Not found │
│ • Read @Roles() from AdminController class → [Role.ADMIN] │
│ • Get user from request.user → { userId, email, roles: ['admin', 'user'] } │
│ • Check: ['admin', 'user'].includes('admin') → true │
│ • Return true (allow access) │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ STEP 3: Route Handler │
├─────────────────────────────────────────────────────────────────────────────┤
│ • AdminController.findAll() executes │
│ • Returns list of users │
└─────────────────────────────────────────────────────────────────────────────┘
File: backend/src/storage/storage.controller.ts
This controller shows how to mix protected and public routes:
@Controller('files')
@UseGuards(JwtAuthGuard) // Default: all routes require authentication
export class StorageController {
constructor(private readonly storageService: StorageService) {}
// Protected: requires valid JWT
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
async upload(
@UploadedFile() file: Express.Multer.File,
@Body() uploadDto: UploadFileDto,
@CurrentUser() user: JwtUser, // User is guaranteed to exist
) {
return this.storageService.upload(file, uploadDto, user.id, user.email);
}
// Protected: requires valid JWT
@Get('user/my-files')
async getMyFiles(@CurrentUser() user: JwtUser) {
return this.storageService.findByUser(user.id);
}
// PUBLIC: anyone can access (overrides class-level guard)
@Public()
@Get('public')
async findPublic(@Query() query: FileQueryDto) {
query.visibility = FileVisibility.PUBLIC;
return this.storageService.findAll(query);
}
// PUBLIC: anyone can download public files
@Public()
@Get(':id/download/public')
async downloadPublic(@Param('id') id: string, @Res() res: Response) {
// Only works for files with visibility: PUBLIC
return this.storageService.download(id, undefined);
}
// Protected: user can access their own files or public files
@Get(':id')
async findOne(
@Param('id') id: string,
@CurrentUser() user: JwtUser | null, // May be null if somehow bypassed
) {
return this.storageService.findById(id, user?.id);
}
}Access Summary:
| Endpoint | Decorator | Auth Required | Who Can Access |
|---|---|---|---|
POST /files/upload |
(none) | Yes | Any authenticated user |
GET /files/user/my-files |
(none) | Yes | Any authenticated user |
GET /files/public |
@Public() |
No | Anyone |
GET /files/:id/download/public |
@Public() |
No | Anyone (public files only) |
GET /files/:id |
(none) | Yes | Owner or public files |
Here's a template for creating a new controller with authentication:
import { Controller, Get, Post, Body, Param, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard';
import { RolesGuard } from '@/auth/guards/roles.guard';
import { Roles } from '@/auth/decorators/roles.decorator';
import { Public } from '@/auth/decorators/public.decorator';
import { CurrentUser } from '@/auth/decorators/current-user.decorator';
import { Role } from '@/common/enums/role.enum';
interface AuthenticatedUser {
userId: string;
email: string;
roles: string[];
}
@Controller('products')
@UseGuards(JwtAuthGuard, RolesGuard) // Protect all routes by default
export class ProductsController {
// Public endpoint - no auth required
@Public()
@Get()
async findAll() {
return []; // Anyone can list products
}
// Any authenticated user
@Get(':id')
async findOne(@Param('id') id: string) {
return {}; // Any logged-in user can view
}
// Only admins can create
@Post()
@Roles(Role.ADMIN)
async create(
@Body() createDto: CreateProductDto,
@CurrentUser() user: AuthenticatedUser,
) {
console.log(`Created by: ${user.email}`);
return {};
}
// User-specific data
@Get('user/favorites')
async getMyFavorites(@CurrentUser() user: AuthenticatedUser) {
return []; // Get favorites for user.userId
}
}| Decorator/Guard | Location | Purpose |
|---|---|---|
@UseGuards(JwtAuthGuard) |
Class or Method | Enable JWT authentication |
@UseGuards(RolesGuard) |
Class or Method | Enable role checking |
@Roles(Role.ADMIN) |
Class or Method | Require specific role(s) |
@Public() |
Method | Skip authentication |
@CurrentUser() |
Parameter | Get authenticated user |
@CurrentUser('userId') |
Parameter | Get specific user field |
// 1. All routes protected, all require ADMIN
@Controller('admin/settings')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN)
export class AdminSettingsController { }
// 2. All routes protected, different roles per method
@Controller('orders')
@UseGuards(JwtAuthGuard, RolesGuard)
export class OrdersController {
@Get()
@Roles(Role.ADMIN) // Only admins see all orders
findAll() { }
@Get('my-orders') // No @Roles = any authenticated user
findMyOrders(@CurrentUser() user) { }
}
// 3. Mix of public and protected
@Controller('articles')
@UseGuards(JwtAuthGuard) // No RolesGuard needed
export class ArticlesController {
@Public()
@Get()
findPublished() { } // Anyone
@Post()
create(@CurrentUser() user) { } // Authenticated only
}
// 4. Completely public controller
@Controller('health')
export class HealthController { // No guards at all
@Get()
check() { return { status: 'ok' }; }
}Guards and decorators handle route-level authorization ("Can this user access this endpoint?"). But many applications need resource-level authorization ("Can this user access THIS specific resource?").
┌─────────────────────────────────────────────────────────────────────────────┐
│ AUTHORIZATION LEVELS │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ LEVEL 1: Route-Level (Guards & Decorators) │
│ ────────────────────────────────────────── │
│ Question: "Can this user access this ENDPOINT?" │
│ Handled by: @UseGuards(), @Roles(), @Public() │
│ Examples: │
│ • Only admins can access /admin/* │
│ • Only authenticated users can POST /files │
│ • Anyone can GET /public/* │
│ │
│ LEVEL 2: Resource-Level (Service Logic) │
│ ─────────────────────────────────────── │
│ Question: "Can this user perform this action on THIS RESOURCE?" │
│ Handled by: Ownership checks in service methods │
│ Examples: │
│ • Users can only delete their OWN files │
│ • Users can only update their OWN profile │
│ • Admins can modify ANY user │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Consider this scenario:
// This guard setup is NOT sufficient!
@Controller('files')
@UseGuards(JwtAuthGuard)
export class FilesController {
@Delete(':id')
async deleteFile(@Param('id') id: string) {
// PROBLEM: Any authenticated user can delete ANY file!
return this.filesService.delete(id);
}
}The guard only checks "is the user logged in?" but not "does this user own this file?"
Resource-level authorization belongs in service methods, not controllers. Here's the pattern from StorageService:
// storage.service.ts
async delete(id: string, userId: string): Promise<void> {
const metadata = await this.fileMetadataModel.findById(id).exec();
// 1. Check resource exists
if (!metadata) {
throw new NotFoundException('File not found');
}
// 2. Check ownership (RESOURCE-LEVEL AUTHORIZATION)
if (metadata.uploadedBy !== userId) {
throw new ForbiddenException('Only the file owner can delete this file');
}
// 3. Perform action
await this.s3Client.send(new DeleteObjectCommand({ ... }));
await this.fileMetadataModel.findByIdAndDelete(id).exec();
}The controller passes the user ID, and the service decides if the action is allowed:
// storage.controller.ts
@Delete(':id')
async delete(
@Param('id') id: string,
@CurrentUser() user: JwtUser, // Get authenticated user
) {
// Pass user ID to service for ownership check
await this.storageService.delete(id, user.id);
return { message: 'File deleted successfully' };
}// Only the resource owner can access
async update(id: string, updateDto: UpdateDto, userId: string): Promise<Resource> {
const resource = await this.repository.findById(id);
if (!resource) {
throw new NotFoundException('Resource not found');
}
if (resource.ownerId !== userId) {
throw new ForbiddenException('Access denied');
}
return this.repository.update(id, updateDto);
}// Owner can access their own, admin can access any
async findOne(
id: string,
userId: string,
userRoles: string[]
): Promise<Resource> {
const resource = await this.repository.findById(id);
if (!resource) {
throw new NotFoundException('Resource not found');
}
const isOwner = resource.ownerId === userId;
const isAdmin = userRoles.includes(Role.ADMIN);
if (!isOwner && !isAdmin) {
throw new ForbiddenException('Access denied');
}
return resource;
}// Public resources accessible to all, private only to owner
async findById(
id: string,
userId?: string, // Optional - may not be authenticated
): Promise<FileMetadata> {
const metadata = await this.fileMetadataModel.findById(id).exec();
if (!metadata) {
throw new NotFoundException('File not found');
}
// Public files: anyone can access
if (metadata.visibility === FileVisibility.PUBLIC) {
return metadata;
}
// Private files: only owner can access
if (metadata.uploadedBy !== userId) {
throw new ForbiddenException('Access denied');
}
return metadata;
}This is a common pattern that's NOT yet implemented in this codebase. Here's how to add it:
// users.service.ts - Enhanced with resource-level auth
async updateProfile(
targetUserId: string,
updateDto: UpdateProfileDto,
requestingUserId: string,
requestingUserRoles: string[],
): Promise<User> {
const user = await this.userRepository.findOne({ where: { id: targetUserId } });
if (!user) {
throw new NotFoundException('User not found');
}
const isSelf = targetUserId === requestingUserId;
const isAdmin = requestingUserRoles.includes(Role.ADMIN);
// Authorization check
if (!isSelf && !isAdmin) {
throw new ForbiddenException('You can only update your own profile');
}
// Restrict what non-admins can update
if (!isAdmin) {
// Regular users can't change their own roles or active status
if (updateDto.roles !== undefined || updateDto.isActive !== undefined) {
throw new ForbiddenException('You cannot modify roles or account status');
}
}
// Proceed with update...
if (updateDto.email) user.email = updateDto.email;
if (updateDto.name) user.name = updateDto.name;
if (updateDto.password) user.setPassword(updateDto.password);
// Only admins can change these
if (isAdmin) {
if (updateDto.roles) user.roles = updateDto.roles;
if (updateDto.isActive !== undefined) user.isActive = updateDto.isActive;
}
return this.userRepository.save(user);
}And the controller:
// users.controller.ts - User-facing profile endpoint
@Controller('users')
@UseGuards(JwtAuthGuard)
export class UsersController {
constructor(private readonly usersService: UsersService) {}
// Any user can update their own profile
@Put('me')
async updateMyProfile(
@Body() updateDto: UpdateProfileDto,
@CurrentUser() user: AuthenticatedUser,
) {
return this.usersService.updateProfile(
user.userId, // Target: themselves
updateDto,
user.userId, // Requester: themselves
user.roles,
);
}
// Admins can update any user via admin routes
// (Already exists in AdminController)
}Sometimes different users can update different fields:
// Define what each role can update
const UPDATABLE_FIELDS = {
[Role.USER]: ['name', 'email', 'password', 'avatar'], // Own profile fields
[Role.ADMIN]: ['name', 'email', 'password', 'avatar', 'roles', 'isActive'], // All fields
};
async updateUser(
targetId: string,
updateDto: Record<string, unknown>,
requesterId: string,
requesterRoles: string[],
): Promise<User> {
const user = await this.findById(targetId);
const isSelf = targetId === requesterId;
const isAdmin = requesterRoles.includes(Role.ADMIN);
if (!isSelf && !isAdmin) {
throw new ForbiddenException('Access denied');
}
// Determine allowed fields based on role
const allowedFields = isAdmin
? UPDATABLE_FIELDS[Role.ADMIN]
: UPDATABLE_FIELDS[Role.USER];
// Filter out unauthorized field updates
const sanitizedDto: Record<string, unknown> = {};
for (const [key, value] of Object.entries(updateDto)) {
if (allowedFields.includes(key) && value !== undefined) {
sanitizedDto[key] = value;
}
}
// Check if trying to update unauthorized fields
const attemptedFields = Object.keys(updateDto).filter(k => updateDto[k] !== undefined);
const unauthorizedFields = attemptedFields.filter(f => !allowedFields.includes(f));
if (unauthorizedFields.length > 0) {
throw new ForbiddenException(
`You cannot update these fields: ${unauthorizedFields.join(', ')}`
);
}
return this.update(targetId, sanitizedDto);
}Use a matrix to plan authorization logic:
| Action | Owner | Admin | Other Users | Public |
|---|---|---|---|---|
| View own profile | ✅ | ✅ | ❌ | ❌ |
| Update own profile | ✅ | ✅ | ❌ | ❌ |
| Change own password | ✅ | ✅ | ❌ | ❌ |
| Change own roles | ❌ | ✅ | ❌ | ❌ |
| Deactivate own account | ❌ | ✅ | ❌ | ❌ |
| View other's profile | ❌ | ✅ | ❌ | ❌ |
| View public file | ✅ | ✅ | ✅ | ✅ |
| View private file | ✅ (owner) | ✅ | ❌ | ❌ |
| Delete own file | ✅ | ✅ | ❌ | ❌ |
| Delete other's file | ❌ | ✅ | ❌ | ❌ |
-
Always pass userId to service methods - Don't rely on "the controller checked it"
// Good async delete(id: string, userId: string): Promise<void> // Bad - no way to verify ownership async delete(id: string): Promise<void>
-
Check ownership BEFORE performing actions - Fail fast
// Good - check first if (resource.ownerId !== userId) { throw new ForbiddenException(); } await this.expensiveOperation(); // Bad - wasted work if unauthorized await this.expensiveOperation(); if (resource.ownerId !== userId) { ... }
-
Use ForbiddenException (403), not UnauthorizedException (401)
- 401 = "Who are you?" (authentication)
- 403 = "You can't do that" (authorization)
-
Don't leak information in error messages
// Good - generic message throw new ForbiddenException('Access denied'); // Bad - reveals resource exists throw new ForbiddenException('You do not own file xyz-123');
-
Consider using a dedicated authorization service for complex rules
@Injectable() export class AuthorizationService { canUserModifyResource(user: User, resource: Resource): boolean { if (user.roles.includes(Role.ADMIN)) return true; if (resource.ownerId === user.id) return true; if (resource.visibility === 'public' && action === 'read') return true; return false; } }
| Resource | Owner-Only | Admin Override | Notes |
|---|---|---|---|
| Files (StorageService) | ✅ | ❌ | Owner can CRUD, no admin override |
| Users (UsersService) | ❌ | N/A | Admin-only via AdminController |
| Audit Logs | N/A | ✅ | Read-only for admins |
Gap: Users cannot currently update their own profile (name, password). This would require:
- A new
UsersControllerwith/users/meendpoints - Resource-level checks in
UsersService.updateProfile()
- Ensure Docker Desktop is running
- Try rebuilding:
F1→Dev Containers: Rebuild Container
If you see warnings like The "POSTGRES_USER" variable is not set:
# Copy .env to devcontainer folder
cp .env .devcontainer/.envThen rebuild the container.
# Find process using port (e.g., 3000)
lsof -i :3000
# Kill the process
kill -9 <PID>npm run reinstallYou may see warnings like:
npm warn deprecated glob@10.5.0: Old versions of glob are not supported...
This is an upstream issue that cannot be resolved through overrides. Jest@30 and TypeORM explicitly require glob@^10.x, and version 10.5.0 is the latest in the 10.x series. The glob maintainer has deprecated all 10.x versions in favor of 11+, but Jest and TypeORM have not yet updated their dependencies.
This warning is safe to ignore. It will be resolved automatically when Jest and TypeORM release updates that use glob 11+.
# Restart TypeScript server in VS Code
F1 → "TypeScript: Restart TS Server"- React Documentation
- Vite Guide
- Tailwind CSS v4
- shadcn/ui Components
- Zustand Documentation
- Vitest Documentation
- NestJS Documentation
- TypeORM Documentation (for PostgreSQL)
- Mongoose Documentation (for MongoDB)
Copyright (c) 2026 Ceralumelabs India (OPC) Private Limited. All rights reserved.
This is proprietary software provided for educational purposes as part of the PCFS course. See LICENSE.txt for details.