Bluewoo HRMS
Micro-Step Build PlanBuilding Blocks

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.

AttributeValue
Steps09-25
Estimated Time8-12 hours
DependenciesPhase 00 complete (all 3 services running)
Completion GateUser 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:

  1. Validates JWT/session from the request
  2. Populates request.user = { id, tenantId, systemRole }
  3. Guards then read from request.user instead 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:

  1. Platform Admin creates tenant (Phase 10)
  2. Platform Admin configures allowed domains (e.g., @company.com, @company.ch)
  3. User with recognized domain signs in via Google SSO
  4. 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 ps shows 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, TRIAL

NOTE: 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

ErrorCauseFix
Prisma schema validation errorSyntax error in schemaCheck for missing brackets or typos
Unknown type "TenantStatus"Enum defined after modelMove enum before model or use string
Unknown type "User"User model not defined yetExpected - 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 validate will 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, EMPLOYEE

NOTE: 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

ErrorCauseFix
Unknown type "Account"Account model not defined yetExpected - we add it in Step 11
Field "accounts" references missing modelNormal - schema incompleteWill be fixed in Step 11
Prisma validation failedAccount/Session not definedExpected - 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:generate

Expected 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

ErrorCauseFix
P1001: Can't reach databasePostgreSQL not runningdocker compose up -d
Unique constraint failedSchema conflictdocker compose down -v && docker compose up -d
@db.Text not availableWrong Prisma versionCheck 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:push

Lock

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 install

2. 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 } = handlers

Gate

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

ErrorCauseFix
Cannot find module '@hrms/database'Workspace not linkedRun npm install from root
PrismaClient is not definedPrisma not generatedcd packages/database && npm run db:generate
Module not found: next-authWrong package versionCheck 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/web

Lock

After this step, these files are locked:

  • apps/web/lib/prisma.ts
  • apps/web/auth.ts
  • apps/web/app/api/auth/[...nextauth]/route.ts

Checkpoint

Before proceeding to Step 13:

  • Next.js starts without errors
  • /api/auth/providers returns 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:

  1. Go to https://console.cloud.google.com/apis/credentials
  2. Create OAuth 2.0 Client ID
  3. Add http://localhost:3000/api/auth/callback/google as redirect URI
  4. Copy Client ID and Secret to .env.local
  5. 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

ErrorCauseFix
Missing Google credentials.env.local not configuredAdd real GOOGLE_CLIENT_ID/SECRET
redirect_uri_mismatchWrong callback URL in GoogleAdd http://localhost:3000/api/auth/callback/google
AUTH_SECRET missingNo secret configuredRun npx auth secret

Rollback

# Restore auth.ts without Google
# Re-run Step 12 Task
rm apps/web/.env.local

Lock

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/providers returns 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>
  )
}
EOF

Gate

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 OAuth

Common Errors

ErrorCauseFix
signIn is not a functionWrong importImport from @/auth not next-auth/react
Server Actions not allowedMissing "use server"Add "use server" inside async function

Rollback

rm -rf apps/web/app/login

Lock

After this step, these files are locked:

  • apps/web/app/login/page.tsx

Checkpoint

Before proceeding to Step 15:

  • /login page 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>
  )
}
EOF

Gate

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 info

Common Errors

ErrorCauseFix
redirect is not a functionWrong importImport from next/navigation
session is nullNot logged inComplete OAuth flow first
Infinite redirect loopAuth check failingCheck AUTH_SECRET and cookies

Rollback

rm -rf apps/web/app/dashboard

Lock

After this step, these files are locked:

  • apps/web/app/dashboard/layout.tsx
  • apps/web/app/dashboard/page.tsx

Checkpoint

Before proceeding to Step 16:

  • /dashboard redirects to /login when 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>
  )
}
EOF

Gate

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

ErrorCauseFix
signOut is not a functionWrong importImport from @/auth
Session still exists after logoutCookie not clearedCheck signOut with redirectTo

Rollback

# Re-run Step 15 Task to restore layout without logout

Lock

  • 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
    },
  },
})
EOF

Important 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_ADMIN

Common Errors

ErrorCauseFix
tenantId is null after logincreateUser event didn't fireUser already existed - clear all data and re-login
Unique constraint failedUser already existsClear user data using Option A or B above
Error in createUser eventPrisma client issueCheck DATABASE_URL in .env.local matches packages/database/.env

Rollback

# Restore auth.ts from Step 13
# Run Step 13 Task

Lock

  • 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
    },
  },
})
EOF

Gate

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

ErrorCauseFix
tenantId is undefinedUser not linked to tenantRe-run Step 17, clear and re-login
TypeScript errorsMissing type declarationCheck types/next-auth.d.ts exists

Rollback

rm apps/web/types/next-auth.d.ts
# Restore auth.ts from Step 17

Lock

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>
  )
}
EOF

Gate

cd apps/web && npm run dev
# Dashboard should show:
# - Organization Name: "Your Name's Organization"
# - Status: TRIAL
# - Your Role: SYSTEM_ADMIN

Common Errors

ErrorCauseFix
Tenant is nulltenantId not in sessionRe-login after Step 18 changes
PrismaClient errorDatabase connectionCheck DATABASE_URL in .env.local

Rollback

# Restore from Step 15

Lock

  • 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';
EOF

Gate

cd apps/api && npm run build
# Should compile without errors
# Decorator file should exist at src/common/decorators/tenant.decorator.ts

Common Errors

ErrorCauseFix
Cannot find module '@nestjs/common'Dependencies not installednpm install
Import errorsWrong file pathsCheck relative imports

Rollback

rm -rf apps/api/src/common

Lock

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';
EOF

Gate

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 23

Common Errors

ErrorCauseFix
CanActivate not foundWrong importCheck @nestjs/common import

Rollback

rm -rf apps/api/src/common/guards
# Update src/common/index.ts to remove guards export

Lock

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';
EOF

Gate

cd apps/api && npm run build
# Should compile without errors
# Role guard file should exist

Common Errors

ErrorCauseFix
Reflector not foundMissing importImport from @nestjs/core

Rollback

rm apps/api/src/common/guards/role.guard.ts
# Update guards/index.ts to remove role.guard export

Lock

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';
EOF

Note: This decorator extracts user info from the authenticated request. The user object is populated by authentication middleware and should include id, tenantId, systemRole, and employeeId from 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 code

Common Errors

ErrorCauseFix
Cannot find module '@nestjs/common'Dependencies not installednpm install
ExecutionContext not foundWrong importCheck @nestjs/common import

Rollback

rm -rf apps/api/src/auth

Lock

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';
EOF

5. 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

ErrorCauseFix
Cannot find module '../prisma'PrismaModule not importedCheck PrismaModule in imports
TenantService undefinedModule not registeredCheck TenantModule in AppModule
404 Not FoundWrong tenant IDGet correct ID from Prisma Studio

Rollback

rm -rf apps/api/src/tenant
# Restore app.module.ts without TenantModule

Lock

After this step, these files are locked:

  • apps/api/src/tenant/*

Checkpoint

Before proceeding to Step 25:

  • /tenant without header returns 400
  • /tenant with 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>
  )
}
EOF

Gate

# 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 API

Note 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

ErrorCauseFix
API Status: DisconnectedAPI not runningStart API with npm run dev
fetch failedNetwork errorCheck both services running on correct ports
tenant is nulltenantId not in sessionRe-login after Step 18 changes

Rollback

# Restore dashboard from Step 19

Lock

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-github2

2. 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:

  1. Go to https://github.com/settings/developers
  2. Click "New OAuth App"
  3. Set Homepage URL: http://localhost:3000
  4. Set Authorization callback URL: http://localhost:3000/api/auth/callback/github
  5. 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

ErrorCauseFix
Missing GitHub credentials.env.local not configuredAdd GITHUB_CLIENT_ID/SECRET
redirect_uri_mismatchWrong callback URLAdd 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/web

Checkpoint

  • 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/dto

Create 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: SUSPENDED

Common Errors

ErrorCauseFix
Cannot change your own statusAttempting self-updateUse different user ID
ForbiddenMissing role headerAdd X-System-Role: SYSTEM_ADMIN

Rollback

rm -rf apps/api/src/users
# Remove UsersModule from app.module.ts

Checkpoint

  • 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 Forbidden

Common Errors

ErrorCauseFix
Cannot change your own roleAttempting self-updateUse different user ID
ForbiddenNon-admin attemptingOnly 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:generate

3. 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

ErrorCauseFix
UserRole not foundSchema not updatedRun db:push and db:generate
Unique constraint failedRole already assignedRole 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 settings

Common Errors

ErrorCauseFix
ForbiddenNon-admin updatingOnly SYSTEM_ADMIN can update
Settings is nullNew tenantReturns 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 settings

Common Errors

ErrorCauseFix
Access DeniedNot SYSTEM_ADMINLogin as SYSTEM_ADMIN
Settings not loadingAPI not runningStart 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 session

Locked 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:

  1. AuthGuard middleware that validates JWT/session from request
  2. Populate request.user with { id, tenantId, systemRole } from token
  3. Update guards to read from request.user instead of headers
  4. 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 entry

3. 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-auth

5. Verify State Files

cat PROJECT_STATE.md | grep "Phase 01"
# Should show COMPLETED

cat WHAT_EXISTS.md | grep "TenantGuard"
# Should show the guard pattern

Next Phase

After verification, proceed to Phase 02: Employee Entity


Quick Reference

Commands Cheat Sheet

ActionCommand
Reset auth stateClear cookies in browser + delete User/Session/Account in Prisma Studio
Reset database completelycd packages/database && npx prisma db push --force-reset
Regenerate Prisma clientcd packages/database && npm run db:generate
View all tablescd packages/database && npm run db:studio
Test tenant endpointcurl -H "X-Tenant-ID: YOUR_ID" localhost:3001/api/v1/tenant
Generate AUTH_SECRETcd apps/web && npx auth secret

Key URLs

Headers for API Calls

HeaderValueRequired ForNote
X-Tenant-IDUser's tenantId from sessionAll tenant-scoped endpointsTemporary - will be replaced by token validation
X-System-RoleUser's systemRole from sessionRole-protected endpointsTemporary - 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

SymptomLikely CauseSolution
tenantId undefined in sessioncreateUser event didn't fireClear all auth data and re-login
Google OAuth errorWrong callback URLAdd http://localhost:3000/api/auth/callback/google in Google Console
API returns 400Missing X-Tenant-ID headerPass tenantId from session in header
Prisma validation errorSchema models incompleteMake sure to complete all steps 09-11 before running db:push

Last Updated: 2025-11-28

On this page

Phase 01: Multi-Tenant AuthPhase Context (READ FIRST)What This Phase AccomplishesWhat This Phase Does NOT IncludeBluewoo Anti-Pattern ReminderSecurity Note (MVP)Enterprise-Only Registration ModeStep 09: Add Tenant Model to SchemaInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 10: Add User Model with SystemRole EnumInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 11: Add Auth.js Tables (Account, Session, VerificationToken)InputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 12: Install Auth.js in Next.jsInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 13: Configure Auth.js with Google ProviderInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 14: Create Login PageInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 15: Create Protected Dashboard LayoutInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 16: Add Logout FunctionalityInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 17: Create Tenant on First LoginInputConstraintsTaskImportant NoteGateCommon ErrorsRollbackLockCheckpointStep 18: Add Tenant and SystemRole to SessionInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 19: Display Tenant Info on DashboardInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 20: Add TenantId Decorator to APIInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 21: Add TenantGuard to APIInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 22: Add SystemRoleGuard to APIInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 23: Add CurrentUser DecoratorInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 24: Create Tenant EndpointInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 25: Connect Frontend to API with Tenant HeaderInputConstraintsTaskGateNote on CORSCommon ErrorsRollbackLockCheckpointStep 26: Add GitHub OAuth Provider (UO-03)InputConstraintsTaskGateCommon ErrorsRollbackCheckpointStep 27: Add User Status Management Endpoints (UO-05)InputConstraintsTaskGateCommon ErrorsRollbackCheckpointStep 28: Add Role Assignment Endpoint (UO-06)InputConstraintsTaskGateCommon ErrorsCheckpointStep 29: Add Multiple Roles Support (EMP-03)InputConstraintsTaskGateCommon ErrorsCheckpointStep 30: Add Tenant Settings Endpoints (SYS-01)InputConstraintsTaskGateCommon ErrorsCheckpointStep 31: Create Settings UI (SYS-01)InputConstraintsTaskGateCommon ErrorsCheckpointPhase 01 CompleteVerification ChecklistLocked Files After Phase 01Future: Phase 01.1 - Auth HardeningPhase Completion Checklist (MANDATORY)1. Gate Verification2. Update PROJECT_STATE.md3. Update WHAT_EXISTS.md4. Git Tag & Commit5. Verify State FilesNext PhaseQuick ReferenceCommands Cheat SheetKey URLsHeaders for API CallsTroubleshooting