Phase 01: Multi-Tenant Auth
Full authentication with SystemRole-based access control, tenant isolation, and protected endpoints
Phase 01: Multi-Tenant Auth
Goal: Full authentication with SystemRole-based access control per our documented schema.
| Attribute | Value |
|---|---|
| Steps | 09-25 |
| Estimated Time | 8-12 hours |
| Dependencies | Phase 00 complete (all 3 services running) |
| Completion Gate | User can login via Google, session contains tenantId and systemRole, API endpoints are protected by tenant and role guards |
Phase Context (READ FIRST)
What This Phase Accomplishes
- Tenant and User models in database
- Auth.js with Google SSO in Next.js
- JWT-based session with tenantId and systemRole
- TenantGuard and SystemRoleGuard in NestJS
- Protected dashboard that requires login
- API endpoints protected by tenant context
What This Phase Does NOT Include
- Email/password authentication (Google SSO only for MVP)
- Password reset flow
- Multi-factor authentication
- User management UI (handled via Platform Admin - Phase 10)
- Employee model (Phase 02)
- User invitation flow (users join via email domain registration - Phase 10)
Bluewoo Anti-Pattern Reminder
This phase intentionally has NO:
- Custom session storage (use Auth.js defaults)
- Complex RBAC with permissions (just SystemRole enum)
- Separate auth microservice (auth in Next.js)
- Redis for sessions (PostgreSQL via Prisma adapter)
If the AI suggests adding any of these, REJECT and continue with the spec.
Security Note (MVP)
Phase 01 uses X-Tenant-ID and X-System-Role headers as temporary transport only.
They are NOT validated against the logged-in user's session.
In a production system, you would add an AuthGuard that:
- Validates JWT/session from the request
- Populates
request.user = { id, tenantId, systemRole } - Guards then read from
request.userinstead of headers
This is intentional MVP plumbing. Auth hardening is covered in Phase 01.1 (future).
Security Note - MVP Only
Header-based auth (X-Tenant-ID, X-System-Role) is NOT production-ready. Headers can be spoofed by malicious clients. Before production:
- Implement JWT validation with signed tokens
- Move tenant/role claims inside the JWT payload
- Add AuthGuard that validates signatures, not headers
Enterprise-Only Registration Mode
Domain-Based Registration
Users can only register if their email domain is pre-configured by a Platform Admin.
Registration Flow:
- Platform Admin creates tenant (Phase 10)
- Platform Admin configures allowed domains (e.g., @company.com, @company.ch)
- User with recognized domain signs in via Google SSO
- User automatically joins the correct tenant with EMPLOYEE role
Unrecognized domains are rejected - users see an error message and cannot register.
This is intentional for enterprise deployments where only employees with company emails should access the system.
Step 09: Add Tenant Model to Schema
Input
- Phase 00 complete
- PostgreSQL running (
docker compose psshows healthy) - packages/database exists with Prisma
Constraints
- DO NOT add any models beyond Tenant
- DO NOT add User model yet (that's Step 10)
- DO NOT run migrations yet
- ONLY modify:
packages/database/prisma/schema.prisma
Task
Open packages/database/prisma/schema.prisma and add the following after the HealthCheck model:
// Phase 01: Tenant
model Tenant {
id String @id @default(cuid())
name String
domain String? @unique
status TenantStatus @default(ACTIVE)
settings Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
@@map("tenants")
}
enum TenantStatus {
ACTIVE
SUSPENDED
TRIAL
}Expected result: Your schema should now have HealthCheck model followed by Tenant model and TenantStatus enum.
Gate
cat packages/database/prisma/schema.prisma | grep -A 15 "model Tenant"
# Should show Tenant model with id, name, domain, status, settings, timestamps
# Should show TenantStatus enum with ACTIVE, SUSPENDED, TRIALNOTE: Schema validation (npx prisma validate) will FAIL at this step because Tenant references users User[] but User model doesn't exist yet. This is expected - User is added in Step 10.
Common Errors
| Error | Cause | Fix |
|---|---|---|
Prisma schema validation error | Syntax error in schema | Check for missing brackets or typos |
Unknown type "TenantStatus" | Enum defined after model | Move enum before model or use string |
Unknown type "User" | User model not defined yet | Expected - added in Step 10 |
Rollback
Remove the Tenant model and TenantStatus enum from schema.prisma, keeping only the HealthCheck model from Phase 00.
Lock
- No files locked yet (schema not pushed)
Checkpoint
Before proceeding to Step 10:
- Schema file contains Tenant model
- TenantStatus enum defined
-
npx prisma validatewill fail (expected - User model added in Step 10) - Type "GATE 09 PASSED" to continue
Step 10: Add User Model with SystemRole Enum
Input
- Step 09 complete
- Tenant model exists in schema
Constraints
- DO NOT add Auth.js tables yet (that's Step 11)
- DO NOT add Employee relation yet (Phase 02)
- DO NOT run migrations yet
- ONLY modify:
packages/database/prisma/schema.prisma
Task
Open packages/database/prisma/schema.prisma and add the following after the TenantStatus enum:
// Phase 01: User
// NOTE: tenantId is optional because Auth.js PrismaAdapter creates user BEFORE
// our createUser event fires. The event then updates the user with tenant info.
model User {
id String @id @default(cuid())
tenantId String? // Optional - set by createUser event after Auth.js creates user
email String
emailVerified DateTime? // Required by Auth.js adapter
name String?
image String?
systemRole SystemRole @default(EMPLOYEE)
status UserStatus @default(ACTIVE)
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
accounts Account[]
sessions Session[]
@@unique([email]) // Email unique across system (simpler for MVP)
@@index([tenantId]) // Index for tenant queries (not unique since nullable)
@@map("users")
}
enum SystemRole {
SYSTEM_ADMIN
HR_ADMIN
MANAGER
EMPLOYEE
}
enum UserStatus {
ACTIVE
INACTIVE
PENDING
}Expected result: Your schema should now have HealthCheck → Tenant → TenantStatus → User → SystemRole → UserStatus.
Gate
cat packages/database/prisma/schema.prisma | grep -A 25 "model User"
# Should show User model with:
# - tenantId String? (OPTIONAL - this is intentional for Auth.js)
# - emailVerified DateTime? (required by Auth.js)
# - email String with @@unique([email])
# - @@index([tenantId])
# Should show SystemRole enum with SYSTEM_ADMIN, HR_ADMIN, MANAGER, EMPLOYEENOTE: Schema validation (npx prisma validate) will FAIL at this step because Account and Session models don't exist yet. This is expected - they're added in Step 11.
Common Errors
| Error | Cause | Fix |
|---|---|---|
Unknown type "Account" | Account model not defined yet | Expected - we add it in Step 11 |
Field "accounts" references missing model | Normal - schema incomplete | Will be fixed in Step 11 |
Prisma validation failed | Account/Session not defined | Expected - continue to Step 11 |
Rollback
Remove the User model, SystemRole enum, and UserStatus enum from schema.prisma, keeping only models from Step 09.
Lock
- No files locked yet (schema not pushed)
Checkpoint
Before proceeding to Step 11:
- User model has tenantId as OPTIONAL (String?)
- User model has emailVerified field
- SystemRole enum has all 4 values
- UserStatus enum defined
- Type "GATE 10 PASSED" to continue
Step 11: Add Auth.js Tables (Account, Session, VerificationToken)
Input
- Step 10 complete
- User model exists in schema
Constraints
- DO NOT add custom fields to Auth.js models
- DO NOT modify User model structure
- ONLY modify:
packages/database/prisma/schema.prisma
Task
Open packages/database/prisma/schema.prisma and add the following after the UserStatus enum:
// Auth.js models
// NOTE: This schema aligns with Auth.js v5 PrismaAdapter.
// If Auth.js is upgraded, verify adapter's expected model fields still match.
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@map("accounts")
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("sessions")
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
@@map("verification_tokens")
}Then push and generate:
cd packages/database
npm run db:push
npm run db:generateExpected result: Schema now has all 6 models: HealthCheck, Tenant, User, Account, Session, VerificationToken.
Gate
cd packages/database && npm run db:studio
# Should open Prisma Studio
# Should show tables: HealthCheck, Tenant, User, Account, Session, VerificationToken
# All tables should exist (empty)Common Errors
| Error | Cause | Fix |
|---|---|---|
P1001: Can't reach database | PostgreSQL not running | docker compose up -d |
Unique constraint failed | Schema conflict | docker compose down -v && docker compose up -d |
@db.Text not available | Wrong Prisma version | Check Prisma 5.x installed |
Rollback
# Reset database completely
docker compose down -v
docker compose up -d
cd packages/database
# Restore original schema (Phase 00)
git checkout -- prisma/schema.prisma
npm run db:pushLock
After this step, these files are locked:
packages/database/prisma/schema.prisma(Tenant, User, Auth.js tables)
Checkpoint
Before proceeding to Step 12:
- Prisma Studio shows all 6 tables
- No migration errors
- Schema validates successfully
- Type "GATE 11 PASSED" to continue
Step 12: Install Auth.js in Next.js
Input
- Step 11 complete
- All auth tables exist in database
Constraints
- DO NOT configure providers yet (that's Step 13)
- DO NOT create login page yet
- DO NOT modify any API files
- ONLY modify files in
apps/web/
Task
1. Install Auth.js dependencies:
cd apps/web
npm install next-auth@5.0.0-beta.25 @auth/prisma-adapter@2.8.0 @hrms/database@workspace:*
cd ../..
npm install2. Create Prisma singleton at apps/web/lib/prisma.ts:
import { PrismaClient } from '@hrms/database';
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;Why a singleton? In Next.js dev mode, hot reload creates multiple PrismaClient instances causing "too many connections" warnings. This pattern reuses the same client.
3. Create auth.ts config file at apps/web/auth.ts:
import NextAuth from "next-auth"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "@/lib/prisma"
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
// Will add Google in Step 13
],
session: {
strategy: "database",
},
callbacks: {
async session({ session, user }) {
// Will add tenantId and systemRole in Step 18
return session
},
},
})4. Create API route at apps/web/app/api/auth/[...nextauth]/route.ts:
import { handlers } from "@/auth"
export const { GET, POST } = handlersGate
cd apps/web && npm run dev
# Should start without errors
# Check http://localhost:3000/api/auth/providers
# Should return {} (empty, no providers yet)Common Errors
| Error | Cause | Fix |
|---|---|---|
Cannot find module '@hrms/database' | Workspace not linked | Run npm install from root |
PrismaClient is not defined | Prisma not generated | cd packages/database && npm run db:generate |
Module not found: next-auth | Wrong package version | Check next-auth@5.0.0-beta.25 installed |
Rollback
rm apps/web/auth.ts
rm apps/web/lib/prisma.ts
rm -rf apps/web/app/api/auth
npm uninstall next-auth @auth/prisma-adapter --filter @hrms/webLock
After this step, these files are locked:
apps/web/lib/prisma.tsapps/web/auth.tsapps/web/app/api/auth/[...nextauth]/route.ts
Checkpoint
Before proceeding to Step 13:
- Next.js starts without errors
-
/api/auth/providersreturns JSON (empty object) - No TypeScript errors
- Type "GATE 12 PASSED" to continue
Step 13: Configure Auth.js with Google Provider
Input
- Step 12 complete
- Auth.js installed and route exists
Constraints
- DO NOT add other OAuth providers
- DO NOT add email/password auth
- DO NOT modify database schema
- ONLY modify:
apps/web/auth.ts,apps/web/.env.local
Task
1. Update auth.ts - Add Google provider to the providers array in apps/web/auth.ts:
import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "@/lib/prisma"
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
],
session: {
strategy: "database",
},
pages: {
signIn: "/login",
},
callbacks: {
async session({ session, user }) {
return session
},
},
})2. Create .env.local at apps/web/.env.local:
# Auth.js
AUTH_SECRET="generate-a-random-secret-here"
# Google OAuth (get from Google Cloud Console)
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
# Database (same as packages/database/.env)
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/hrms?schema=public"Note: These values are for local development only. In production, use environment variables via Secret Manager or your deployment platform's environment configuration.
3. Setup steps:
- Go to https://console.cloud.google.com/apis/credentials
- Create OAuth 2.0 Client ID
- Add
http://localhost:3000/api/auth/callback/googleas redirect URI - Copy Client ID and Secret to
.env.local - Generate AUTH_SECRET with:
npx auth secret
Gate
cd apps/web && npm run dev
# After updating .env.local with real credentials:
curl http://localhost:3000/api/auth/providers
# Should return: {"google":{"id":"google","name":"Google",...}}Common Errors
| Error | Cause | Fix |
|---|---|---|
Missing Google credentials | .env.local not configured | Add real GOOGLE_CLIENT_ID/SECRET |
redirect_uri_mismatch | Wrong callback URL in Google | Add http://localhost:3000/api/auth/callback/google |
AUTH_SECRET missing | No secret configured | Run npx auth secret |
Rollback
# Restore auth.ts without Google
# Re-run Step 12 Task
rm apps/web/.env.localLock
After this step, these files are locked:
apps/web/auth.ts(with Google provider)
Checkpoint
Before proceeding to Step 14:
- Google credentials in .env.local
-
/api/auth/providersreturns google provider - AUTH_SECRET generated
- Type "GATE 13 PASSED" to continue
Step 14: Create Login Page
Input
- Step 13 complete
- Google provider configured
Constraints
- DO NOT add styling beyond basic layout
- DO NOT add form validation
- DO NOT add password fields
- ONLY create:
apps/web/app/login/page.tsx
Task
cd apps/web
# Create login page
mkdir -p app/login
cat > app/login/page.tsx << 'EOF'
import { signIn } from "@/auth"
export default function LoginPage() {
return (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
gap: '1rem'
}}>
<h1>HRMS Login</h1>
<p>Sign in to access your dashboard</p>
<form
action={async () => {
"use server"
await signIn("google", { redirectTo: "/dashboard" })
}}
>
<button
type="submit"
style={{
padding: '0.75rem 1.5rem',
fontSize: '1rem',
backgroundColor: '#4285f4',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Sign in with Google
</button>
</form>
</div>
)
}
EOFGate
cd apps/web && npm run dev
# Open http://localhost:3000/login
# Should show "HRMS Login" heading
# Should show "Sign in with Google" button
# Clicking button should redirect to Google OAuthCommon Errors
| Error | Cause | Fix |
|---|---|---|
signIn is not a function | Wrong import | Import from @/auth not next-auth/react |
Server Actions not allowed | Missing "use server" | Add "use server" inside async function |
Rollback
rm -rf apps/web/app/loginLock
After this step, these files are locked:
apps/web/app/login/page.tsx
Checkpoint
Before proceeding to Step 15:
-
/loginpage renders - Google sign-in button visible
- Button redirects to Google OAuth
- Type "GATE 14 PASSED" to continue
Step 15: Create Protected Dashboard Layout
Input
- Step 14 complete
- Login page works
Constraints
- DO NOT add navigation components yet
- DO NOT add sidebar
- DO NOT fetch additional data
- ONLY create:
apps/web/app/dashboard/layout.tsx,apps/web/app/dashboard/page.tsx
Task
cd apps/web
# Create dashboard layout with auth check
mkdir -p app/dashboard
cat > app/dashboard/layout.tsx << 'EOF'
import { auth } from "@/auth"
import { redirect } from "next/navigation"
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await auth()
if (!session?.user) {
redirect("/login")
}
return (
<div style={{ padding: '2rem' }}>
<header style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '2rem',
paddingBottom: '1rem',
borderBottom: '1px solid #eee'
}}>
<h1>HRMS Dashboard</h1>
<div>
<span>Logged in as: {session.user.email}</span>
</div>
</header>
<main>{children}</main>
</div>
)
}
EOF
# Create dashboard page
cat > app/dashboard/page.tsx << 'EOF'
import { auth } from "@/auth"
export default async function DashboardPage() {
const session = await auth()
return (
<div>
<h2>Welcome, {session?.user?.name || session?.user?.email}</h2>
<p>Your dashboard is ready.</p>
<div style={{ marginTop: '2rem' }}>
<h3>Session Info</h3>
<pre style={{
background: '#f5f5f5',
padding: '1rem',
borderRadius: '4px',
overflow: 'auto'
}}>
{JSON.stringify(session, null, 2)}
</pre>
</div>
</div>
)
}
EOFGate
cd apps/web && npm run dev
# 1. Go to http://localhost:3000/dashboard (not logged in)
# Should redirect to /login
# 2. Sign in with Google
# Should redirect to /dashboard
# 3. Dashboard should show:
# - "HRMS Dashboard" header
# - "Logged in as: your@email.com"
# - Session JSON with user infoCommon Errors
| Error | Cause | Fix |
|---|---|---|
redirect is not a function | Wrong import | Import from next/navigation |
session is null | Not logged in | Complete OAuth flow first |
Infinite redirect loop | Auth check failing | Check AUTH_SECRET and cookies |
Rollback
rm -rf apps/web/app/dashboardLock
After this step, these files are locked:
apps/web/app/dashboard/layout.tsxapps/web/app/dashboard/page.tsx
Checkpoint
Before proceeding to Step 16:
-
/dashboardredirects to/loginwhen not authenticated - After login, dashboard shows user email
- Session JSON displays in dashboard
- Type "GATE 15 PASSED" to continue
Step 16: Add Logout Functionality
Input
- Step 15 complete
- Dashboard with session display works
Constraints
- DO NOT add confirmation modal
- DO NOT preserve any session data
- ONLY modify:
apps/web/app/dashboard/layout.tsx
Task
cd apps/web
# Update dashboard layout with logout button
cat > app/dashboard/layout.tsx << 'EOF'
import { auth, signOut } from "@/auth"
import { redirect } from "next/navigation"
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await auth()
if (!session?.user) {
redirect("/login")
}
return (
<div style={{ padding: '2rem' }}>
<header style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '2rem',
paddingBottom: '1rem',
borderBottom: '1px solid #eee'
}}>
<h1>HRMS Dashboard</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<span>Logged in as: {session.user.email}</span>
<form
action={async () => {
"use server"
await signOut({ redirectTo: "/login" })
}}
>
<button
type="submit"
style={{
padding: '0.5rem 1rem',
backgroundColor: '#dc3545',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Logout
</button>
</form>
</div>
</header>
<main>{children}</main>
</div>
)
}
EOFGate
cd apps/web && npm run dev
# 1. Login to dashboard
# 2. Click "Logout" button
# Should redirect to /login
# Going to /dashboard should redirect to /login (session cleared)Common Errors
| Error | Cause | Fix |
|---|---|---|
signOut is not a function | Wrong import | Import from @/auth |
Session still exists after logout | Cookie not cleared | Check signOut with redirectTo |
Rollback
# Re-run Step 15 Task to restore layout without logoutLock
- File already locked from Step 15
Checkpoint
Before proceeding to Step 17:
- Logout button visible in header
- Clicking logout clears session
- Redirects to /login after logout
- Type "GATE 16 PASSED" to continue
Step 17: Create Tenant on First Login
Input
- Step 16 complete
- Full login/logout flow works
Constraints
- DO NOT allow user to choose tenant name (auto-generate)
- DO NOT create separate tenant registration flow
- ONLY modify:
apps/web/auth.ts
Task
cd apps/web
# Update auth.ts to create tenant on first login
cat > auth.ts << 'EOF'
import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { PrismaClient } from "@hrms/database"
const prisma = new PrismaClient()
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
],
session: {
strategy: "database",
},
pages: {
signIn: "/login",
},
events: {
async createUser({ user }) {
// Create tenant for new user
const tenant = await prisma.tenant.create({
data: {
name: `${user.name || user.email?.split('@')[0]}'s Organization`,
status: "TRIAL",
},
})
// Link user to tenant with SYSTEM_ADMIN role
await prisma.user.update({
where: { id: user.id },
data: {
tenantId: tenant.id,
systemRole: "SYSTEM_ADMIN",
},
})
console.log(`Created tenant ${tenant.id} for user ${user.id}`)
},
},
callbacks: {
async session({ session, user }) {
return session
},
},
})
EOFImportant Note
The Auth.js PrismaAdapter creates the User record BEFORE the createUser event fires.
This is why tenantId must be optional in the schema (Step 10).
The event then updates the user with tenant info via prisma.user.update().
Gate
cd apps/web && npm run dev
# 1. Clear existing user data COMPLETELY (choose one method):
# Option A: Using Prisma Studio (manual)
cd packages/database && npm run db:studio
# In Prisma Studio, delete records in this order:
# - Session table (all records)
# - Account table (all records)
# - User table (all records)
# - Tenant table (all records)
# Option B: Reset database completely (WARNING: clears ALL data)
cd packages/database
npx prisma db push --force-reset
npm run db:generate
# 2. Sign in with Google (fresh login)
# 3. Check Prisma Studio:
# - Tenant table should have new record
# - User table should have tenantId populated and systemRole=SYSTEM_ADMINCommon Errors
| Error | Cause | Fix |
|---|---|---|
tenantId is null after login | createUser event didn't fire | User already existed - clear all data and re-login |
Unique constraint failed | User already exists | Clear user data using Option A or B above |
Error in createUser event | Prisma client issue | Check DATABASE_URL in .env.local matches packages/database/.env |
Rollback
# Restore auth.ts from Step 13
# Run Step 13 TaskLock
- File already locked from Step 12
Checkpoint
Before proceeding to Step 18:
- New user gets Tenant created automatically
- User is linked to Tenant with SYSTEM_ADMIN role
- Tenant name is auto-generated
- Type "GATE 17 PASSED" to continue
Step 18: Add Tenant and SystemRole to Session
Input
- Step 17 complete
- User has tenantId and systemRole in database
Constraints
- DO NOT add custom session fields beyond tenantId, systemRole
- DO NOT modify database schema
- ONLY modify:
apps/web/auth.ts,apps/web/types/next-auth.d.ts
Task
cd apps/web
# Create TypeScript declarations for extended session
mkdir -p types
cat > types/next-auth.d.ts << 'EOF'
import { DefaultSession } from "next-auth"
declare module "next-auth" {
interface Session {
user: {
id: string
tenantId: string | null // Matches database String? - null until createUser event assigns tenant
systemRole: "SYSTEM_ADMIN" | "HR_ADMIN" | "MANAGER" | "EMPLOYEE" | null
} & DefaultSession["user"]
}
}
EOF
# Update auth.ts to include tenant info in session
cat > auth.ts << 'EOF'
import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { PrismaClient } from "@hrms/database"
const prisma = new PrismaClient()
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
],
session: {
strategy: "database",
},
pages: {
signIn: "/login",
},
events: {
async createUser({ user }) {
const tenant = await prisma.tenant.create({
data: {
name: `${user.name || user.email?.split('@')[0]}'s Organization`,
status: "TRIAL",
},
})
await prisma.user.update({
where: { id: user.id },
data: {
tenantId: tenant.id,
systemRole: "SYSTEM_ADMIN",
},
})
console.log(`Created tenant ${tenant.id} for user ${user.id}`)
},
},
callbacks: {
async session({ session, user }) {
// Fetch full user with tenant info
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: {
id: true,
tenantId: true,
systemRole: true,
},
})
if (dbUser) {
session.user.id = dbUser.id
session.user.tenantId = dbUser.tenantId
session.user.systemRole = dbUser.systemRole
}
return session
},
},
})
EOFGate
cd apps/web && npm run dev
# 1. Login to dashboard
# 2. Check session JSON display:
# - Should include "tenantId": "clx..."
# - Should include "systemRole": "SYSTEM_ADMIN"Common Errors
| Error | Cause | Fix |
|---|---|---|
tenantId is undefined | User not linked to tenant | Re-run Step 17, clear and re-login |
| TypeScript errors | Missing type declaration | Check types/next-auth.d.ts exists |
Rollback
rm apps/web/types/next-auth.d.ts
# Restore auth.ts from Step 17Lock
After this step, these files are locked:
apps/web/types/next-auth.d.ts
Checkpoint
Before proceeding to Step 19:
- Session includes tenantId
- Session includes systemRole
- TypeScript recognizes extended session types
- Type "GATE 18 PASSED" to continue
Step 19: Display Tenant Info on Dashboard
Input
- Step 18 complete
- Session has tenantId and systemRole
Constraints
- DO NOT add tenant switching
- DO NOT add tenant settings page
- ONLY modify:
apps/web/app/dashboard/page.tsx
Task
cd apps/web
# Update dashboard to show tenant info
cat > app/dashboard/page.tsx << 'EOF'
import { auth } from "@/auth"
import { PrismaClient } from "@hrms/database"
const prisma = new PrismaClient()
export default async function DashboardPage() {
const session = await auth()
// Fetch tenant info
const tenant = session?.user?.tenantId
? await prisma.tenant.findUnique({
where: { id: session.user.tenantId },
})
: null
return (
<div>
<h2>Welcome, {session?.user?.name || session?.user?.email}</h2>
<div style={{
marginTop: '1rem',
padding: '1rem',
background: '#f0f8ff',
borderRadius: '8px'
}}>
<h3>Organization</h3>
<p><strong>Name:</strong> {tenant?.name || 'Unknown'}</p>
<p><strong>Status:</strong> {tenant?.status || 'Unknown'}</p>
<p><strong>Your Role:</strong> {session?.user?.systemRole || 'Unknown'}</p>
</div>
<div style={{ marginTop: '2rem' }}>
<h3>Session Debug</h3>
<pre style={{
background: '#f5f5f5',
padding: '1rem',
borderRadius: '4px',
overflow: 'auto'
}}>
{JSON.stringify(session, null, 2)}
</pre>
</div>
</div>
)
}
EOFGate
cd apps/web && npm run dev
# Dashboard should show:
# - Organization Name: "Your Name's Organization"
# - Status: TRIAL
# - Your Role: SYSTEM_ADMINCommon Errors
| Error | Cause | Fix |
|---|---|---|
Tenant is null | tenantId not in session | Re-login after Step 18 changes |
PrismaClient error | Database connection | Check DATABASE_URL in .env.local |
Rollback
# Restore from Step 15Lock
- File already updated, no new locks
Checkpoint
Before proceeding to Step 20:
- Dashboard shows organization name
- Dashboard shows tenant status (TRIAL)
- Dashboard shows user role (SYSTEM_ADMIN)
- Type "GATE 19 PASSED" to continue
Step 20: Add TenantId Decorator to API
Input
- Step 19 complete
- Frontend shows tenant info
Constraints
- DO NOT add authentication to API yet
- DO NOT add role guards yet
- ONLY create files in
apps/api/src/common/
Task
cd apps/api
# Create common decorators directory
mkdir -p src/common/decorators
# Create TenantId decorator
cat > src/common/decorators/tenant.decorator.ts << 'EOF'
import { createParamDecorator, ExecutionContext, BadRequestException } from '@nestjs/common';
export const TenantId = createParamDecorator(
(data: unknown, ctx: ExecutionContext): string => {
const request = ctx.switchToHttp().getRequest();
const tenantId = request.headers['x-tenant-id'];
if (!tenantId) {
throw new BadRequestException('X-Tenant-ID header is required');
}
return tenantId as string;
},
);
EOF
# Create index barrel file
cat > src/common/decorators/index.ts << 'EOF'
export * from './tenant.decorator';
EOF
# Create common module index
cat > src/common/index.ts << 'EOF'
export * from './decorators';
EOFGate
cd apps/api && npm run build
# Should compile without errors
# Decorator file should exist at src/common/decorators/tenant.decorator.tsCommon Errors
| Error | Cause | Fix |
|---|---|---|
Cannot find module '@nestjs/common' | Dependencies not installed | npm install |
| Import errors | Wrong file paths | Check relative imports |
Rollback
rm -rf apps/api/src/commonLock
After this step, these files are locked:
apps/api/src/common/decorators/tenant.decorator.ts
Checkpoint
Before proceeding to Step 21:
- TenantId decorator file exists
- API builds successfully
- Type "GATE 20 PASSED" to continue
Step 21: Add TenantGuard to API
Input
- Step 20 complete
- TenantId decorator exists
Constraints
- DO NOT validate tenant exists in database (just check header)
- DO NOT add authentication validation
- ONLY create files in
apps/api/src/common/guards/
Task
cd apps/api
# Create guards directory
mkdir -p src/common/guards
# Create TenantGuard
cat > src/common/guards/tenant.guard.ts << 'EOF'
import {
Injectable,
CanActivate,
ExecutionContext,
BadRequestException,
} from '@nestjs/common';
@Injectable()
export class TenantGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const tenantId = request.headers['x-tenant-id'];
if (!tenantId) {
throw new BadRequestException('X-Tenant-ID header is required');
}
// Attach tenantId to request for later use
request.tenantId = tenantId;
return true;
}
}
EOF
# Create index barrel file
cat > src/common/guards/index.ts << 'EOF'
export * from './tenant.guard';
EOF
# Update common index
cat > src/common/index.ts << 'EOF'
export * from './decorators';
export * from './guards';
EOFGate
cd apps/api && npm run build
# Should compile without errors
# Test the guard behavior
cd apps/api && npm run dev &
sleep 3
# Without X-Tenant-ID header (after we apply guard in Step 23)
# Will test in Step 23Common Errors
| Error | Cause | Fix |
|---|---|---|
CanActivate not found | Wrong import | Check @nestjs/common import |
Rollback
rm -rf apps/api/src/common/guards
# Update src/common/index.ts to remove guards exportLock
After this step, these files are locked:
apps/api/src/common/guards/tenant.guard.ts
Checkpoint
Before proceeding to Step 22:
- TenantGuard file exists
- API builds successfully
- Type "GATE 21 PASSED" to continue
Step 22: Add SystemRoleGuard to API
Input
- Step 21 complete
- TenantGuard exists
Constraints
- DO NOT validate against database (just check header)
- DO NOT add complex permission logic
- ONLY modify files in
apps/api/src/common/
Task
cd apps/api
# Create SystemRoleGuard
cat > src/common/guards/role.guard.ts << 'EOF'
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
SetMetadata,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
export const ROLES_KEY = 'roles';
export const RequireRoles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
@Injectable()
export class SystemRoleGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
// No roles required, allow access
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest();
const userRole = request.headers['x-system-role'];
if (!userRole) {
throw new ForbiddenException('X-System-Role header is required');
}
const hasRole = requiredRoles.includes(userRole);
if (!hasRole) {
throw new ForbiddenException(
`Role ${userRole} is not authorized. Required: ${requiredRoles.join(', ')}`,
);
}
return true;
}
}
EOF
# Update guards index
cat > src/common/guards/index.ts << 'EOF'
export * from './tenant.guard';
export * from './role.guard';
EOFGate
cd apps/api && npm run build
# Should compile without errors
# Role guard file should existCommon Errors
| Error | Cause | Fix |
|---|---|---|
Reflector not found | Missing import | Import from @nestjs/core |
Rollback
rm apps/api/src/common/guards/role.guard.ts
# Update guards/index.ts to remove role.guard exportLock
After this step, these files are locked:
apps/api/src/common/guards/role.guard.ts
Checkpoint
Before proceeding to Step 23:
- SystemRoleGuard file exists
- RequireRoles decorator exported
- API builds successfully
- Type "GATE 22 PASSED" to continue
Step 23: Add CurrentUser Decorator
Input
- Step 22 complete
- SystemRoleGuard exists
Constraints
- DO NOT add authentication middleware yet
- DO NOT modify guards
- ONLY create:
apps/api/src/auth/current-user.decorator.ts
Task
cd apps/api
# Create auth decorators directory
mkdir -p src/auth
# Create CurrentUser decorator
cat > src/auth/current-user.decorator.ts << 'EOF'
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: string, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
return data ? user?.[data] : user;
},
);
EOF
# Create index barrel file
cat > src/auth/index.ts << 'EOF'
export * from './current-user.decorator';
EOFNote: This decorator extracts user info from the authenticated request. The
userobject is populated by authentication middleware and should includeid,tenantId,systemRole, andemployeeIdfrom the session/token.
Gate
cd apps/api && npm run build
# Should compile without errors
# Verify decorator file exists
cat src/auth/current-user.decorator.ts
# Should show the decorator codeCommon Errors
| Error | Cause | Fix |
|---|---|---|
Cannot find module '@nestjs/common' | Dependencies not installed | npm install |
ExecutionContext not found | Wrong import | Check @nestjs/common import |
Rollback
rm -rf apps/api/src/authLock
After this step, these files are locked:
apps/api/src/auth/current-user.decorator.ts
Checkpoint
Before proceeding to Step 24:
- CurrentUser decorator file exists
- API builds successfully
- Type "GATE 23 PASSED" to continue
Step 24: Create Tenant Endpoint
Input
- Step 23 complete
- All guards and decorators exist
Constraints
- DO NOT add full CRUD for tenants
- DO NOT allow tenant creation via API
- ONLY create:
apps/api/src/tenant/module
Task
cd apps/api
# Create tenant module directory
mkdir -p src/tenant
# Create tenant controller
cat > src/tenant/tenant.controller.ts << 'EOF'
import { Controller, Get, UseGuards } from '@nestjs/common';
import { TenantGuard } from '../common/guards';
import { TenantId } from '../common/decorators';
import { TenantService } from './tenant.service';
@Controller('api/v1/tenant')
@UseGuards(TenantGuard)
export class TenantController {
constructor(private readonly tenantService: TenantService) {}
@Get()
async getCurrentTenant(@TenantId() tenantId: string) {
return this.tenantService.findById(tenantId);
}
}
EOF
# Create tenant service
cat > src/tenant/tenant.service.ts << 'EOF'
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class TenantService {
constructor(private readonly prisma: PrismaService) {}
async findById(id: string) {
const tenant = await this.prisma.tenant.findUnique({
where: { id },
});
if (!tenant) {
throw new NotFoundException(`Tenant with ID ${id} not found`);
}
return {
id: tenant.id,
name: tenant.name,
status: tenant.status,
createdAt: tenant.createdAt,
};
}
}
EOF
# Create tenant module
cat > src/tenant/tenant.module.ts << 'EOF'
import { Module } from '@nestjs/common';
import { TenantController } from './tenant.controller';
import { TenantService } from './tenant.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [TenantController],
providers: [TenantService],
exports: [TenantService],
})
export class TenantModule {}
EOF
# Create index barrel
cat > src/tenant/index.ts << 'EOF'
export * from './tenant.module';
export * from './tenant.service';
export * from './tenant.controller';
EOF5. Update app.module.ts - Add TenantModule to imports in apps/api/src/app.module.ts:
import { TenantModule } from './tenant';
@Module({
imports: [PrismaModule, HealthModule, TenantModule], // Add TenantModule
controllers: [AppController],
providers: [],
})
export class AppModule {}Gate
cd apps/api && npm run dev &
sleep 3
# Without X-Tenant-ID header - should fail
curl http://localhost:3001/api/v1/tenant
# Expected: {"statusCode":400,"message":"X-Tenant-ID header is required"}
# Get tenant ID from Prisma Studio, then:
curl -H "X-Tenant-ID: YOUR_TENANT_ID" http://localhost:3001/api/v1/tenant
# Expected: {"id":"...","name":"...","status":"TRIAL","createdAt":"..."}Common Errors
| Error | Cause | Fix |
|---|---|---|
Cannot find module '../prisma' | PrismaModule not imported | Check PrismaModule in imports |
TenantService undefined | Module not registered | Check TenantModule in AppModule |
404 Not Found | Wrong tenant ID | Get correct ID from Prisma Studio |
Rollback
rm -rf apps/api/src/tenant
# Restore app.module.ts without TenantModuleLock
After this step, these files are locked:
apps/api/src/tenant/*
Checkpoint
Before proceeding to Step 25:
-
/tenantwithout header returns 400 -
/tenantwith valid header returns tenant info - TenantGuard is working
- Type "GATE 24 PASSED" to continue
Step 25: Connect Frontend to API with Tenant Header
Input
- Step 24 complete
- Tenant endpoint works with header
Constraints
- DO NOT add complex state management
- DO NOT add caching
- ONLY modify:
apps/web/app/dashboard/page.tsx
Task
cd apps/web
# Update dashboard to fetch from API
cat > app/dashboard/page.tsx << 'EOF'
import { auth } from "@/auth"
async function getTenantFromAPI(tenantId: string) {
// NOTE: In production, use process.env.API_URL instead of hardcoded localhost
const apiUrl = process.env.API_URL || 'http://localhost:3001';
try {
const response = await fetch(`${apiUrl}/api/v1/tenant`, {
headers: {
'X-Tenant-ID': tenantId,
},
cache: 'no-store',
});
if (!response.ok) {
throw new Error('Failed to fetch tenant');
}
return response.json();
} catch (error) {
console.error('API Error:', error);
return null;
}
}
export default async function DashboardPage() {
const session = await auth()
// Fetch tenant from API
const tenant = session?.user?.tenantId
? await getTenantFromAPI(session.user.tenantId)
: null
const apiStatus = tenant ? 'Connected' : 'Disconnected'
return (
<div>
<h2>Welcome, {session?.user?.name || session?.user?.email}</h2>
<div style={{
marginTop: '1rem',
padding: '1rem',
background: tenant ? '#d4edda' : '#f8d7da',
borderRadius: '8px'
}}>
<h3>API Status: {apiStatus}</h3>
</div>
{tenant && (
<div style={{
marginTop: '1rem',
padding: '1rem',
background: '#f0f8ff',
borderRadius: '8px'
}}>
<h3>Organization (from API)</h3>
<p><strong>Name:</strong> {tenant.name}</p>
<p><strong>Status:</strong> {tenant.status}</p>
<p><strong>Your Role:</strong> {session?.user?.systemRole}</p>
</div>
)}
<div style={{ marginTop: '2rem' }}>
<h3>Session</h3>
<pre style={{
background: '#f5f5f5',
padding: '1rem',
borderRadius: '4px',
overflow: 'auto'
}}>
{JSON.stringify(session, null, 2)}
</pre>
</div>
</div>
)
}
EOFGate
# Start both services
cd apps/api && npm run dev &
cd apps/web && npm run dev &
# Go to http://localhost:3000/dashboard
# Should show:
# - "API Status: Connected" (green background)
# - Organization info fetched from APINote on CORS
This fetch happens server-side (in a Next.js Server Component), so CORS is NOT required. Server-to-server communication bypasses browser CORS restrictions.
If you later add client-side fetches (e.g., from React hooks or client components),
you'll need to enable CORS in the API. Add this to apps/api/src/main.ts:
app.enableCors({
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
credentials: true,
});Common Errors
| Error | Cause | Fix |
|---|---|---|
API Status: Disconnected | API not running | Start API with npm run dev |
fetch failed | Network error | Check both services running on correct ports |
tenant is null | tenantId not in session | Re-login after Step 18 changes |
Rollback
# Restore dashboard from Step 19Lock
After this step, these files are locked:
apps/web/app/dashboard/page.tsx
Checkpoint
PHASE 01 COMPLETE when:
- Dashboard shows "API Status: Connected"
- Organization info comes from API (not database)
- All guards working (TenantGuard tested)
- Type "PHASE 01 COMPLETE" to finish
Step 26: Add GitHub OAuth Provider (UO-03)
Input
- Step 25 complete
- Google OAuth working
- Frontend connected to API
Constraints
- DO NOT remove Google OAuth
- DO NOT modify existing session handling
- ONLY add GitHub as additional provider
Task
1. Install passport-github2:
cd apps/web
npm install passport-github22. Update auth.ts - Add GitHub provider alongside Google in apps/web/auth.ts:
import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
import GitHub from "next-auth/providers/github"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { PrismaClient } from "@hrms/database"
const prisma = new PrismaClient()
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
GitHub({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
],
session: {
strategy: "database",
},
pages: {
signIn: "/login",
},
events: {
async createUser({ user }) {
const tenant = await prisma.tenant.create({
data: {
name: `${user.name || user.email?.split('@')[0]}'s Organization`,
status: "TRIAL",
},
})
await prisma.user.update({
where: { id: user.id },
data: {
tenantId: tenant.id,
systemRole: "SYSTEM_ADMIN",
},
})
console.log(`Created tenant ${tenant.id} for user ${user.id}`)
},
},
callbacks: {
async session({ session, user }) {
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: {
id: true,
tenantId: true,
systemRole: true,
},
})
if (dbUser) {
session.user.id = dbUser.id
session.user.tenantId = dbUser.tenantId
session.user.systemRole = dbUser.systemRole
}
return session
},
},
})3. Update .env.local - Add GitHub credentials:
# GitHub OAuth (get from GitHub Developer Settings)
GITHUB_CLIENT_ID="your-github-client-id"
GITHUB_CLIENT_SECRET="your-github-client-secret"4. Update Login Page - Add GitHub button in apps/web/app/login/page.tsx:
import { signIn } from "@/auth"
export default function LoginPage() {
return (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
gap: '1rem'
}}>
<h1>HRMS Login</h1>
<p>Sign in to access your dashboard</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<form
action={async () => {
"use server"
await signIn("google", { redirectTo: "/dashboard" })
}}
>
<button
type="submit"
style={{
padding: '0.75rem 1.5rem',
fontSize: '1rem',
backgroundColor: '#4285f4',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
width: '200px'
}}
>
Sign in with Google
</button>
</form>
<form
action={async () => {
"use server"
await signIn("github", { redirectTo: "/dashboard" })
}}
>
<button
type="submit"
style={{
padding: '0.75rem 1.5rem',
fontSize: '1rem',
backgroundColor: '#24292e',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
width: '200px'
}}
>
Sign in with GitHub
</button>
</form>
</div>
</div>
)
}5. Setup GitHub OAuth App:
- Go to https://github.com/settings/developers
- Click "New OAuth App"
- Set Homepage URL:
http://localhost:3000 - Set Authorization callback URL:
http://localhost:3000/api/auth/callback/github - Copy Client ID and Secret to
.env.local
Gate
cd apps/web && npm run dev
# Open http://localhost:3000/login
# Should show both Google and GitHub sign-in buttons
# Clicking GitHub should redirect to GitHub OAuth
curl http://localhost:3000/api/auth/providers
# Should return: {"google":{...},"github":{...}}Common Errors
| Error | Cause | Fix |
|---|---|---|
Missing GitHub credentials | .env.local not configured | Add GITHUB_CLIENT_ID/SECRET |
redirect_uri_mismatch | Wrong callback URL | Add http://localhost:3000/api/auth/callback/github |
Rollback
# Restore auth.ts from Step 18
# Remove GitHub variables from .env.local
npm uninstall passport-github2 --filter @hrms/webCheckpoint
- GitHub provider in /api/auth/providers
- GitHub sign-in button on login page
- GitHub OAuth flow works
- Type "GATE 26 PASSED" to continue
Step 27: Add User Status Management Endpoints (UO-05)
Input
- Step 26 complete
- User model has status field
Constraints
- Only SYSTEM_ADMIN and HR_ADMIN can change user status
- DO NOT allow self-status change
- ONLY create endpoints for status management
Task
1. Add UserStatus enum to schema (if not exists) in packages/database/prisma/schema.prisma:
enum UserStatus {
ACTIVE
INACTIVE
SUSPENDED
}2. Create User Status DTO at apps/api/src/users/dto/update-user-status.dto.ts:
import { IsEnum, IsNotEmpty } from 'class-validator';
export enum UserStatusEnum {
ACTIVE = 'ACTIVE',
INACTIVE = 'INACTIVE',
SUSPENDED = 'SUSPENDED',
}
export class UpdateUserStatusDto {
@IsNotEmpty()
@IsEnum(UserStatusEnum)
status: UserStatusEnum;
}3. Create Users Module at apps/api/src/users/:
mkdir -p apps/api/src/users/dtoCreate apps/api/src/users/users.service.ts:
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { UpdateUserStatusDto } from './dto/update-user-status.dto';
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
async updateStatus(
userId: string,
dto: UpdateUserStatusDto,
tenantId: string,
currentUserId: string,
) {
// Prevent self-status change
if (userId === currentUserId) {
throw new ForbiddenException('Cannot change your own status');
}
const user = await this.prisma.user.findFirst({
where: { id: userId, tenantId },
});
if (!user) {
throw new NotFoundException(`User with ID ${userId} not found`);
}
return this.prisma.user.update({
where: { id: userId },
data: { status: dto.status },
select: {
id: true,
email: true,
name: true,
status: true,
systemRole: true,
},
});
}
async findAll(tenantId: string) {
return this.prisma.user.findMany({
where: { tenantId },
select: {
id: true,
email: true,
name: true,
status: true,
systemRole: true,
lastLoginAt: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
});
}
async findById(userId: string, tenantId: string) {
const user = await this.prisma.user.findFirst({
where: { id: userId, tenantId },
select: {
id: true,
email: true,
name: true,
status: true,
systemRole: true,
lastLoginAt: true,
createdAt: true,
},
});
if (!user) {
throw new NotFoundException(`User with ID ${userId} not found`);
}
return user;
}
}Create apps/api/src/users/users.controller.ts:
import { Controller, Get, Patch, Param, Body, UseGuards } from '@nestjs/common';
import { TenantGuard, SystemRoleGuard, RequireRoles } from '../common/guards';
import { TenantId } from '../common/decorators';
import { CurrentUser } from '../auth/current-user.decorator';
import { UsersService } from './users.service';
import { UpdateUserStatusDto } from './dto/update-user-status.dto';
@Controller('api/v1/users')
@UseGuards(TenantGuard, SystemRoleGuard)
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get()
@RequireRoles('SYSTEM_ADMIN', 'HR_ADMIN')
async findAll(@TenantId() tenantId: string) {
const data = await this.usersService.findAll(tenantId);
return { data, error: null };
}
@Get(':id')
@RequireRoles('SYSTEM_ADMIN', 'HR_ADMIN')
async findById(
@Param('id') id: string,
@TenantId() tenantId: string,
) {
const data = await this.usersService.findById(id, tenantId);
return { data, error: null };
}
@Patch(':id/status')
@RequireRoles('SYSTEM_ADMIN', 'HR_ADMIN')
async updateStatus(
@Param('id') id: string,
@Body() dto: UpdateUserStatusDto,
@TenantId() tenantId: string,
@CurrentUser('id') currentUserId: string,
) {
const data = await this.usersService.updateStatus(id, dto, tenantId, currentUserId);
return { data, error: null };
}
}Create apps/api/src/users/users.module.ts:
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}Create apps/api/src/users/index.ts:
export * from './users.module';
export * from './users.service';
export * from './users.controller';
export * from './dto/update-user-status.dto';4. Register UsersModule in apps/api/src/app.module.ts:
import { UsersModule } from './users/users.module';
@Module({
imports: [PrismaModule, HealthModule, TenantModule, UsersModule],
// ...
})
export class AppModule {}Gate
cd apps/api && npm run dev &
sleep 3
# List users
curl -H "X-Tenant-ID: YOUR_TENANT_ID" \
-H "X-System-Role: SYSTEM_ADMIN" \
http://localhost:3001/api/v1/users
# Should return { data: [...users], error: null }
# Update user status
curl -X PATCH \
-H "X-Tenant-ID: YOUR_TENANT_ID" \
-H "X-System-Role: SYSTEM_ADMIN" \
-H "Content-Type: application/json" \
-d '{"status":"SUSPENDED"}' \
http://localhost:3001/api/v1/users/USER_ID/status
# Should return updated user with status: SUSPENDEDCommon Errors
| Error | Cause | Fix |
|---|---|---|
Cannot change your own status | Attempting self-update | Use different user ID |
Forbidden | Missing role header | Add X-System-Role: SYSTEM_ADMIN |
Rollback
rm -rf apps/api/src/users
# Remove UsersModule from app.module.tsCheckpoint
- GET /users returns user list
- PATCH /users/:id/status updates status
- Role guard prevents unauthorized access
- Type "GATE 27 PASSED" to continue
Step 28: Add Role Assignment Endpoint (UO-06)
Input
- Step 27 complete
- UsersModule exists
Constraints
- Only SYSTEM_ADMIN can assign roles
- DO NOT allow self-role change
- ONLY modify UsersService and UsersController
Task
1. Create Assign Role DTO at apps/api/src/users/dto/assign-role.dto.ts:
import { IsEnum, IsNotEmpty } from 'class-validator';
export enum SystemRoleEnum {
SYSTEM_ADMIN = 'SYSTEM_ADMIN',
HR_ADMIN = 'HR_ADMIN',
MANAGER = 'MANAGER',
EMPLOYEE = 'EMPLOYEE',
}
export class AssignRoleDto {
@IsNotEmpty()
@IsEnum(SystemRoleEnum)
role: SystemRoleEnum;
}2. Update UsersService - Add assignRole method:
// Add to apps/api/src/users/users.service.ts
async assignRole(
userId: string,
dto: AssignRoleDto,
tenantId: string,
currentUserId: string,
) {
// Prevent self-role change
if (userId === currentUserId) {
throw new ForbiddenException('Cannot change your own role');
}
const user = await this.prisma.user.findFirst({
where: { id: userId, tenantId },
});
if (!user) {
throw new NotFoundException(`User with ID ${userId} not found`);
}
return this.prisma.user.update({
where: { id: userId },
data: { systemRole: dto.role },
select: {
id: true,
email: true,
name: true,
status: true,
systemRole: true,
},
});
}3. Update UsersController - Add role endpoint:
// Add to apps/api/src/users/users.controller.ts
import { AssignRoleDto } from './dto/assign-role.dto';
@Patch(':id/role')
@RequireRoles('SYSTEM_ADMIN') // Only SYSTEM_ADMIN can assign roles
async assignRole(
@Param('id') id: string,
@Body() dto: AssignRoleDto,
@TenantId() tenantId: string,
@CurrentUser('id') currentUserId: string,
) {
const data = await this.usersService.assignRole(id, dto, tenantId, currentUserId);
return { data, error: null };
}4. Update users/index.ts:
export * from './dto/assign-role.dto';Gate
# Assign role to user
curl -X PATCH \
-H "X-Tenant-ID: YOUR_TENANT_ID" \
-H "X-System-Role: SYSTEM_ADMIN" \
-H "Content-Type: application/json" \
-d '{"role":"HR_ADMIN"}' \
http://localhost:3001/api/v1/users/USER_ID/role
# Should return updated user with systemRole: HR_ADMIN
# Non-admin should be blocked
curl -X PATCH \
-H "X-Tenant-ID: YOUR_TENANT_ID" \
-H "X-System-Role: HR_ADMIN" \
-H "Content-Type: application/json" \
-d '{"role":"MANAGER"}' \
http://localhost:3001/api/v1/users/USER_ID/role
# Should return 403 ForbiddenCommon Errors
| Error | Cause | Fix |
|---|---|---|
Cannot change your own role | Attempting self-update | Use different user ID |
Forbidden | Non-admin attempting | Only SYSTEM_ADMIN can assign roles |
Checkpoint
- PATCH /users/:id/role works
- Only SYSTEM_ADMIN can use endpoint
- Self-role change prevented
- Type "GATE 28 PASSED" to continue
Step 29: Add Multiple Roles Support (EMP-03)
Input
- Step 28 complete
- Single role assignment works
Constraints
- Keep single systemRole for backward compatibility
- Add UserRole junction table for multiple roles
- ONLY modify schema and add new endpoints
Task
1. Update Schema - Add UserRole model in packages/database/prisma/schema.prisma:
// Add after User model
model UserRole {
id String @id @default(cuid())
userId String
role SystemRole
isPrimary Boolean @default(false)
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, role])
@@index([userId])
@@map("user_roles")
}
// Update User model to add relation
model User {
// ... existing fields ...
roles UserRole[]
}2. Run migration:
cd packages/database
npm run db:push
npm run db:generate3. Add Multiple Roles Methods to UsersService:
// Add to apps/api/src/users/users.service.ts
async addRole(userId: string, role: string, isPrimary: boolean = false, tenantId: string) {
const user = await this.prisma.user.findFirst({
where: { id: userId, tenantId },
});
if (!user) {
throw new NotFoundException(`User with ID ${userId} not found`);
}
if (isPrimary) {
// Unset other primary roles
await this.prisma.userRole.updateMany({
where: { userId, isPrimary: true },
data: { isPrimary: false },
});
}
return this.prisma.userRole.upsert({
where: { userId_role: { userId, role: role as any } },
create: { userId, role: role as any, isPrimary },
update: { isPrimary },
});
}
async removeRole(userId: string, role: string, tenantId: string) {
const user = await this.prisma.user.findFirst({
where: { id: userId, tenantId },
});
if (!user) {
throw new NotFoundException(`User with ID ${userId} not found`);
}
return this.prisma.userRole.delete({
where: { userId_role: { userId, role: role as any } },
});
}
async getUserRoles(userId: string, tenantId: string) {
const user = await this.prisma.user.findFirst({
where: { id: userId, tenantId },
});
if (!user) {
throw new NotFoundException(`User with ID ${userId} not found`);
}
return this.prisma.userRole.findMany({
where: { userId },
orderBy: { isPrimary: 'desc' },
});
}4. Add Role Endpoints to UsersController:
// Add to apps/api/src/users/users.controller.ts
@Get(':id/roles')
@RequireRoles('SYSTEM_ADMIN', 'HR_ADMIN')
async getRoles(
@Param('id') id: string,
@TenantId() tenantId: string,
) {
const data = await this.usersService.getUserRoles(id, tenantId);
return { data, error: null };
}
@Post(':id/roles')
@RequireRoles('SYSTEM_ADMIN')
async addRole(
@Param('id') id: string,
@Body() dto: { role: string; isPrimary?: boolean },
@TenantId() tenantId: string,
) {
const data = await this.usersService.addRole(id, dto.role, dto.isPrimary || false, tenantId);
return { data, error: null };
}
@Delete(':id/roles/:role')
@RequireRoles('SYSTEM_ADMIN')
async removeRole(
@Param('id') id: string,
@Param('role') role: string,
@TenantId() tenantId: string,
) {
await this.usersService.removeRole(id, role, tenantId);
return { success: true, error: null };
}Gate
# Add role to user
curl -X POST \
-H "X-Tenant-ID: YOUR_TENANT_ID" \
-H "X-System-Role: SYSTEM_ADMIN" \
-H "Content-Type: application/json" \
-d '{"role":"MANAGER","isPrimary":false}' \
http://localhost:3001/api/v1/users/USER_ID/roles
# Should return created UserRole
# Get user roles
curl -H "X-Tenant-ID: YOUR_TENANT_ID" \
-H "X-System-Role: SYSTEM_ADMIN" \
http://localhost:3001/api/v1/users/USER_ID/roles
# Should return array of roles
# Remove role
curl -X DELETE \
-H "X-Tenant-ID: YOUR_TENANT_ID" \
-H "X-System-Role: SYSTEM_ADMIN" \
http://localhost:3001/api/v1/users/USER_ID/roles/MANAGER
# Should return { success: true }Common Errors
| Error | Cause | Fix |
|---|---|---|
UserRole not found | Schema not updated | Run db:push and db:generate |
Unique constraint failed | Role already assigned | Role already exists for user |
Checkpoint
- UserRole table created
- POST /users/:id/roles adds role
- GET /users/:id/roles returns roles
- DELETE /users/:id/roles/:role removes role
- Type "GATE 29 PASSED" to continue
Step 30: Add Tenant Settings Endpoints (SYS-01)
Input
- Step 29 complete
- Tenant model has settings JSON field
Constraints
- Only SYSTEM_ADMIN can modify settings
- Settings stored as JSON in Tenant.settings
- ONLY modify TenantService and add settings endpoints
Task
1. Create Settings DTO at apps/api/src/tenant/dto/update-settings.dto.ts:
import { IsOptional, IsString, IsUrl, IsIn } from 'class-validator';
export class UpdateSettingsDto {
@IsOptional()
@IsString()
organizationName?: string;
@IsOptional()
@IsUrl()
logoUrl?: string;
@IsOptional()
@IsIn(['light', 'dark', 'system'])
theme?: string;
@IsOptional()
@IsString()
primaryColor?: string;
@IsOptional()
@IsString()
timezone?: string;
@IsOptional()
@IsString()
dateFormat?: string;
}2. Update TenantService - Add settings methods:
// Add to apps/api/src/tenant/tenant.service.ts
async getSettings(tenantId: string) {
const tenant = await this.prisma.tenant.findUnique({
where: { id: tenantId },
select: { id: true, name: true, settings: true },
});
if (!tenant) {
throw new NotFoundException(`Tenant with ID ${tenantId} not found`);
}
// Merge with defaults
const defaults = {
theme: 'system',
primaryColor: '#3b82f6',
timezone: 'UTC',
dateFormat: 'YYYY-MM-DD',
};
return {
...defaults,
...(tenant.settings as object),
organizationName: tenant.name,
};
}
async updateSettings(tenantId: string, dto: UpdateSettingsDto) {
const tenant = await this.prisma.tenant.findUnique({
where: { id: tenantId },
});
if (!tenant) {
throw new NotFoundException(`Tenant with ID ${tenantId} not found`);
}
const currentSettings = (tenant.settings as object) || {};
const newSettings = { ...currentSettings };
// Update settings fields
if (dto.logoUrl !== undefined) newSettings['logoUrl'] = dto.logoUrl;
if (dto.theme !== undefined) newSettings['theme'] = dto.theme;
if (dto.primaryColor !== undefined) newSettings['primaryColor'] = dto.primaryColor;
if (dto.timezone !== undefined) newSettings['timezone'] = dto.timezone;
if (dto.dateFormat !== undefined) newSettings['dateFormat'] = dto.dateFormat;
// Update tenant name and settings
return this.prisma.tenant.update({
where: { id: tenantId },
data: {
name: dto.organizationName || tenant.name,
settings: newSettings,
},
select: {
id: true,
name: true,
settings: true,
},
});
}3. Update TenantController - Add settings endpoints:
// Add to apps/api/src/tenant/tenant.controller.ts
import { UpdateSettingsDto } from './dto/update-settings.dto';
import { SystemRoleGuard, RequireRoles } from '../common/guards';
@Controller('api/v1/tenant')
@UseGuards(TenantGuard, SystemRoleGuard)
export class TenantController {
constructor(private readonly tenantService: TenantService) {}
@Get()
async getCurrentTenant(@TenantId() tenantId: string) {
return this.tenantService.findById(tenantId);
}
@Get('settings')
async getSettings(@TenantId() tenantId: string) {
const data = await this.tenantService.getSettings(tenantId);
return { data, error: null };
}
@Patch('settings')
@RequireRoles('SYSTEM_ADMIN')
async updateSettings(
@TenantId() tenantId: string,
@Body() dto: UpdateSettingsDto,
) {
const data = await this.tenantService.updateSettings(tenantId, dto);
return { data, error: null };
}
}4. Create index for dto at apps/api/src/tenant/dto/index.ts:
export * from './update-settings.dto';Gate
# Get settings
curl -H "X-Tenant-ID: YOUR_TENANT_ID" \
-H "X-System-Role: SYSTEM_ADMIN" \
http://localhost:3001/api/v1/tenant/settings
# Should return { data: { organizationName, theme, primaryColor, ... }, error: null }
# Update settings
curl -X PATCH \
-H "X-Tenant-ID: YOUR_TENANT_ID" \
-H "X-System-Role: SYSTEM_ADMIN" \
-H "Content-Type: application/json" \
-d '{"theme":"dark","primaryColor":"#10b981"}' \
http://localhost:3001/api/v1/tenant/settings
# Should return updated settingsCommon Errors
| Error | Cause | Fix |
|---|---|---|
Forbidden | Non-admin updating | Only SYSTEM_ADMIN can update |
Settings is null | New tenant | Returns defaults |
Checkpoint
- GET /tenant/settings returns settings with defaults
- PATCH /tenant/settings updates settings
- Only SYSTEM_ADMIN can update
- Type "GATE 30 PASSED" to continue
Step 31: Create Settings UI (SYS-01)
Input
- Step 30 complete
- Settings API endpoints work
Constraints
- Only SYSTEM_ADMIN can access settings page
- Use form to update settings
- ONLY create frontend settings page
Task
1. Create Settings Hooks at apps/web/hooks/use-settings.ts:
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useSession } from 'next-auth/react';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
export function useSettings() {
const { data: session } = useSession();
return useQuery({
queryKey: ['settings'],
queryFn: async () => {
const response = await fetch(`${API_URL}/api/v1/tenant/settings`, {
headers: {
'X-Tenant-ID': session?.user?.tenantId || '',
'X-System-Role': session?.user?.systemRole || '',
},
});
const result = await response.json();
return result.data;
},
enabled: !!session?.user?.tenantId,
});
}
export function useUpdateSettings() {
const { data: session } = useSession();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (settings: Record<string, unknown>) => {
const response = await fetch(`${API_URL}/api/v1/tenant/settings`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': session?.user?.tenantId || '',
'X-System-Role': session?.user?.systemRole || '',
},
body: JSON.stringify(settings),
});
const result = await response.json();
return result.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings'] });
},
});
}2. Create Settings Page at apps/web/app/dashboard/settings/page.tsx:
'use client';
import { useState, useEffect } from 'react';
import { useSession } from 'next-auth/react';
import { useSettings, useUpdateSettings } from '@/hooks/use-settings';
export default function SettingsPage() {
const { data: session } = useSession();
const { data: settings, isLoading } = useSettings();
const updateSettings = useUpdateSettings();
const [formData, setFormData] = useState({
organizationName: '',
logoUrl: '',
theme: 'system',
primaryColor: '#3b82f6',
timezone: 'UTC',
dateFormat: 'YYYY-MM-DD',
});
useEffect(() => {
if (settings) {
setFormData({
organizationName: settings.organizationName || '',
logoUrl: settings.logoUrl || '',
theme: settings.theme || 'system',
primaryColor: settings.primaryColor || '#3b82f6',
timezone: settings.timezone || 'UTC',
dateFormat: settings.dateFormat || 'YYYY-MM-DD',
});
}
}, [settings]);
// Only SYSTEM_ADMIN can access
if (session?.user?.systemRole !== 'SYSTEM_ADMIN') {
return (
<div style={{ padding: '2rem' }}>
<h1>Access Denied</h1>
<p>Only System Administrators can access this page.</p>
</div>
);
}
if (isLoading) {
return <div>Loading settings...</div>;
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await updateSettings.mutateAsync(formData);
alert('Settings saved successfully!');
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
setFormData(prev => ({
...prev,
[e.target.name]: e.target.value,
}));
};
return (
<div style={{ maxWidth: '800px' }}>
<h1 style={{ marginBottom: '2rem' }}>Organization Settings</h1>
<form onSubmit={handleSubmit}>
{/* Branding Section */}
<div style={{
background: 'white',
padding: '1.5rem',
borderRadius: '8px',
marginBottom: '1.5rem',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)'
}}>
<h2 style={{ marginBottom: '1rem' }}>Branding</h2>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>
Organization Name
</label>
<input
type="text"
name="organizationName"
value={formData.organizationName}
onChange={handleChange}
style={{
width: '100%',
padding: '0.5rem',
border: '1px solid #ddd',
borderRadius: '4px',
}}
/>
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>
Logo URL
</label>
<input
type="url"
name="logoUrl"
value={formData.logoUrl}
onChange={handleChange}
placeholder="https://example.com/logo.png"
style={{
width: '100%',
padding: '0.5rem',
border: '1px solid #ddd',
borderRadius: '4px',
}}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<div>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>
Theme
</label>
<select
name="theme"
value={formData.theme}
onChange={handleChange}
style={{
width: '100%',
padding: '0.5rem',
border: '1px solid #ddd',
borderRadius: '4px',
}}
>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">System</option>
</select>
</div>
<div>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>
Primary Color
</label>
<input
type="color"
name="primaryColor"
value={formData.primaryColor}
onChange={handleChange}
style={{
width: '100%',
height: '38px',
padding: '0.25rem',
border: '1px solid #ddd',
borderRadius: '4px',
}}
/>
</div>
</div>
</div>
{/* Regional Section */}
<div style={{
background: 'white',
padding: '1.5rem',
borderRadius: '8px',
marginBottom: '1.5rem',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)'
}}>
<h2 style={{ marginBottom: '1rem' }}>Regional Settings</h2>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<div>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>
Timezone
</label>
<select
name="timezone"
value={formData.timezone}
onChange={handleChange}
style={{
width: '100%',
padding: '0.5rem',
border: '1px solid #ddd',
borderRadius: '4px',
}}
>
<option value="UTC">UTC</option>
<option value="America/New_York">Eastern Time</option>
<option value="America/Chicago">Central Time</option>
<option value="America/Denver">Mountain Time</option>
<option value="America/Los_Angeles">Pacific Time</option>
<option value="Europe/London">London</option>
<option value="Europe/Paris">Paris</option>
<option value="Asia/Tokyo">Tokyo</option>
</select>
</div>
<div>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>
Date Format
</label>
<select
name="dateFormat"
value={formData.dateFormat}
onChange={handleChange}
style={{
width: '100%',
padding: '0.5rem',
border: '1px solid #ddd',
borderRadius: '4px',
}}
>
<option value="YYYY-MM-DD">2024-01-15</option>
<option value="MM/DD/YYYY">01/15/2024</option>
<option value="DD/MM/YYYY">15/01/2024</option>
<option value="DD.MM.YYYY">15.01.2024</option>
</select>
</div>
</div>
</div>
{/* Actions */}
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button
type="submit"
disabled={updateSettings.isPending}
style={{
padding: '0.75rem 1.5rem',
backgroundColor: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
opacity: updateSettings.isPending ? 0.5 : 1,
}}
>
{updateSettings.isPending ? 'Saving...' : 'Save Settings'}
</button>
</div>
</form>
</div>
);
}Gate
cd apps/web && npm run dev
# Navigate to http://localhost:3000/dashboard/settings
# Should show settings form with:
# - Organization Name field
# - Logo URL field
# - Theme dropdown
# - Primary Color picker
# - Timezone dropdown
# - Date Format dropdown
# Submit should save settingsCommon Errors
| Error | Cause | Fix |
|---|---|---|
Access Denied | Not SYSTEM_ADMIN | Login as SYSTEM_ADMIN |
Settings not loading | API not running | Start API server |
Checkpoint
- Settings page accessible to SYSTEM_ADMIN
- Form shows current settings
- Submit saves settings
- Non-admin sees access denied
- Type "GATE 31 PASSED" to continue
Phase 01 Complete
Verification Checklist
Run these commands to verify Phase 01 is complete:
# 1. Start all services
docker compose up -d # PostgreSQL
cd apps/api && npm run dev &
cd apps/web && npm run dev &
# 2. Test authentication flow
# - Go to http://localhost:3000/dashboard (redirects to /login)
# - Sign in with Google
# - Should redirect to /dashboard
# 3. Verify session content
# Dashboard should show:
# - tenantId in session
# - systemRole: SYSTEM_ADMIN in session
# - API Status: Connected
# 4. Test API protection
curl http://localhost:3001/api/v1/tenant
# Should return 400 (missing header)
curl -H "X-Tenant-ID: invalid" http://localhost:3001/api/v1/tenant
# Should return 404 (tenant not found)
# 5. Test logout
# Click logout button, should clear sessionLocked Files After Phase 01
# Phase 00 locks, plus:
packages/database/prisma/schema.prisma
apps/web/lib/prisma.ts
apps/web/auth.ts
apps/web/app/api/auth/[...nextauth]/route.ts
apps/web/types/next-auth.d.ts
apps/web/app/login/page.tsx
apps/web/app/dashboard/layout.tsx
apps/web/app/dashboard/page.tsx
apps/api/src/common/decorators/tenant.decorator.ts
apps/api/src/common/guards/tenant.guard.ts
apps/api/src/common/guards/role.guard.ts
apps/api/src/auth/current-user.decorator.ts
apps/api/src/tenant/*Future: Phase 01.1 - Auth Hardening
Phase 01 uses header-based guards as temporary MVP plumbing. For production, a future Phase 01.1 would add:
- AuthGuard middleware that validates JWT/session from request
- Populate
request.userwith{ id, tenantId, systemRole }from token - Update guards to read from
request.userinstead of headers - Remove header trust - guards no longer read
X-Tenant-ID/X-System-Role
This would make the API properly secured instead of trusting client-supplied headers.
Phase Completion Checklist (MANDATORY)
BEFORE MOVING TO NEXT PHASE
Complete ALL items before proceeding. Do NOT skip any step.
1. Gate Verification
- All step gates passed (Steps 09-25)
- Login via Google works
- Session contains tenantId and systemRole
- API protected by tenant and role guards
2. Update PROJECT_STATE.md
# Update these sections:
- Mark Phase 01 as COMPLETED with timestamp
- Add all locked files to "Locked Files" section
- Update "Current Phase" to Phase 02
- Add session log entry3. Update WHAT_EXISTS.md
# Add these items:
## Database Models
- Tenant, User, Account, Session, TenantDomain
## API Endpoints
- GET /api/v1/tenant - Get current tenant
- GET /api/v1/users - List users
- PATCH /api/v1/users/:id/status - Update status
## Frontend Routes
- /login, /dashboard
## Established Patterns
- TenantGuard: apps/api/src/common/guards/tenant.guard.ts
- RoleGuard: apps/api/src/common/guards/role.guard.ts
- @TenantId decorator: apps/api/src/common/decorators/4. Git Tag & Commit
git add PROJECT_STATE.md WHAT_EXISTS.md
git commit -m "chore: complete Phase 01 - Multi-Tenant Auth"
git tag phase-01-multi-tenant-auth5. Verify State Files
cat PROJECT_STATE.md | grep "Phase 01"
# Should show COMPLETED
cat WHAT_EXISTS.md | grep "TenantGuard"
# Should show the guard patternNext Phase
After verification, proceed to Phase 02: Employee Entity
Quick Reference
Commands Cheat Sheet
| Action | Command |
|---|---|
| Reset auth state | Clear cookies in browser + delete User/Session/Account in Prisma Studio |
| Reset database completely | cd packages/database && npx prisma db push --force-reset |
| Regenerate Prisma client | cd packages/database && npm run db:generate |
| View all tables | cd packages/database && npm run db:studio |
| Test tenant endpoint | curl -H "X-Tenant-ID: YOUR_ID" localhost:3001/api/v1/tenant |
| Generate AUTH_SECRET | cd apps/web && npx auth secret |
Key URLs
| Page | URL | Auth Required |
|---|---|---|
| Login | http://localhost:3000/login | No |
| Dashboard | http://localhost:3000/dashboard | Yes |
| Auth Providers | http://localhost:3000/api/auth/providers | No |
| API Health | http://localhost:3001/health | No |
| API Tenant | http://localhost:3001/api/v1/tenant | Yes (X-Tenant-ID header) |
Headers for API Calls
| Header | Value | Required For | Note |
|---|---|---|---|
| X-Tenant-ID | User's tenantId from session | All tenant-scoped endpoints | Temporary - will be replaced by token validation |
| X-System-Role | User's systemRole from session | Role-protected endpoints | Temporary - not secure, for dev only |
Security Warning: These headers are MVP plumbing only. They trust client-supplied values without validation. See "Security Note (MVP)" in Phase Context and Phase 01.1 for hardening plan.
Troubleshooting
| Symptom | Likely Cause | Solution |
|---|---|---|
tenantId undefined in session | createUser event didn't fire | Clear all auth data and re-login |
| Google OAuth error | Wrong callback URL | Add http://localhost:3000/api/auth/callback/google in Google Console |
| API returns 400 | Missing X-Tenant-ID header | Pass tenantId from session in header |
| Prisma validation error | Schema models incomplete | Make sure to complete all steps 09-11 before running db:push |
Last Updated: 2025-11-28