---
phase: 02-authentication-multi-tenancy
plan: 02
type: execute
wave: 2
depends_on:
- 02-01
files_modified:
- apps/web/src/app/(auth)/layout.tsx
- apps/web/src/app/(auth)/login/page.tsx
- apps/web/src/app/(portal)/layout.tsx
- apps/web/src/app/(portal)/page.tsx
- apps/web/src/app/(portal)/admin/users/page.tsx
- apps/web/src/app/(portal)/admin/tenants/page.tsx
- apps/web/src/app/layout.tsx
- apps/web/src/middleware.ts
- apps/web/src/lib/session.ts
- apps/web/src/lib/auth-actions.ts
- apps/web/src/lib/stores/auth-store.ts
- apps/web/src/components/layout/header.tsx
- apps/web/src/components/layout/sidebar-footer.tsx
- apps/web/src/components/layout/sidebar.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/package.json
- apps/api/src/user/user.controller.ts
- apps/api/src/user/user.module.ts
- apps/api/src/user/dto/create-user.dto.ts
- apps/api/src/user/dto/update-user.dto.ts
- apps/api/src/tenant/tenant.controller.ts
- apps/api/src/tenant/tenant.module.ts
- apps/api/src/tenant/dto/create-tenant.dto.ts
autonomous: true
requirements:
- AUTH-02
- AUTH-03
- AUTH-04
- TNNT-02
must_haves:
truths:
- "User sees a split-screen login page with branding left and form right (D-01)"
- "Login page has no sidebar or header -- standalone layout (D-04)"
- "User can log in with valid credentials and is redirected to dashboard"
- "Logged-in user sees their name and role in header and sidebar footer"
- "Unauthenticated browser navigation to any portal page redirects to /login"
- "Admin can create, edit, and delete users with role assignment via admin/users page (D-12)"
- "Super-Admin can create and manage tenants via admin/tenants page (D-10)"
- "Remember-me checkbox on login controls session duration (D-02)"
artifacts:
- path: "apps/web/src/app/(auth)/login/page.tsx"
provides: "Split-screen login page with branding and form"
min_lines: 50
- path: "apps/web/src/app/(auth)/layout.tsx"
provides: "Standalone auth layout without sidebar/header"
min_lines: 10
- path: "apps/web/src/middleware.ts"
provides: "JWT validation for route protection"
min_lines: 20
- path: "apps/web/src/app/(portal)/admin/users/page.tsx"
provides: "User management admin page"
min_lines: 50
- path: "apps/web/src/app/(portal)/admin/tenants/page.tsx"
provides: "Tenant management Super-Admin page"
min_lines: 40
- path: "apps/api/src/user/user.controller.ts"
provides: "User CRUD REST endpoints"
exports: ["UserController"]
- path: "apps/api/src/tenant/tenant.controller.ts"
provides: "Tenant CRUD REST endpoints"
exports: ["TenantController"]
key_links:
- from: "apps/web/src/middleware.ts"
to: "apps/web/src/lib/session.ts"
via: "JWT verification using jose"
pattern: "jwtVerify"
- from: "apps/web/src/app/(auth)/login/page.tsx"
to: "apps/api/src/auth/auth.controller.ts"
via: "POST /auth/login fetch with credentials include"
pattern: "fetch.*auth/login"
- from: "apps/web/src/components/layout/header.tsx"
to: "apps/web/src/lib/stores/auth-store.ts"
via: "Zustand store for client-side user state"
pattern: "useAuthStore"
---
Frontend authentication flow and user/tenant management: Split-screen login page (D-01, D-04), Next.js middleware for route protection, auth-wired header and sidebar, User CRUD API + admin page (AUTH-02), Tenant CRUD API + admin page (TNNT-02), and Zustand auth store for client-side user state.
This plan delivers the vertical slice where a user can log in, see their identity in the portal, and admins can manage users and tenants.
Purpose: Connect the backend auth (Plan 02-01) to the frontend portal shell (Phase 1), making the portal a real authenticated application.
Output: Working login flow, authenticated portal with user info displayed, admin pages for user and tenant management.
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/02-authentication-multi-tenancy/02-CONTEXT.md
@.planning/phases/02-authentication-multi-tenancy/02-RESEARCH.md
@.planning/phases/01-foundation-portal-shell/01-02-SUMMARY.md
@.planning/phases/02-authentication-multi-tenancy/02-01-SUMMARY.md
@apps/web/src/app/layout.tsx
@apps/web/src/components/layout/header.tsx
@apps/web/src/components/layout/sidebar-footer.tsx
@apps/web/src/components/layout/app-shell.tsx
@apps/web/src/messages/de.json
@apps/web/src/messages/en.json
@apps/web/package.json
## Artifacts this phase produces
See Plan 02-01 for the full artifacts table.
Task 1: Login page, auth layout, Next.js middleware, auth store, and auth-wired portal components
- apps/web/src/app/layout.tsx (root layout to understand provider structure)
- apps/web/src/components/layout/header.tsx (user avatar placeholder to wire)
- apps/web/src/components/layout/sidebar-footer.tsx (user info placeholder to wire)
- apps/web/src/components/layout/app-shell.tsx (AppShell wrapper for portal routes)
- apps/web/src/lib/stores/sidebar-store.ts (existing Zustand store pattern to follow)
- apps/web/src/messages/de.json (existing i18n keys to extend)
- apps/web/src/messages/en.json (existing i18n keys to extend)
- apps/web/src/app/globals.css (design tokens for styling login page)
- .planning/phases/02-authentication-multi-tenancy/02-RESEARCH.md (Pattern 4: Next.js middleware, session.ts pattern, DAL pattern)
apps/web/src/app/(auth)/layout.tsx
apps/web/src/app/(auth)/login/page.tsx
apps/web/src/app/(portal)/layout.tsx
apps/web/src/app/(portal)/page.tsx
apps/web/src/middleware.ts
apps/web/src/lib/session.ts
apps/web/src/lib/auth-actions.ts
apps/web/src/lib/stores/auth-store.ts
apps/web/src/components/layout/header.tsx
apps/web/src/components/layout/sidebar-footer.tsx
apps/web/src/messages/de.json
apps/web/src/messages/en.json
apps/web/package.json
apps/web/src/app/layout.tsx
Install frontend auth dependencies: cd apps/web and pnpm add jose zod
Create route group structure for auth separation per D-04:
- Move existing page.tsx content into apps/web/src/app/(portal)/page.tsx (dashboard page)
- Create apps/web/src/app/(portal)/layout.tsx that wraps children with AppShell component (import from @/components/layout/app-shell). This ensures all portal routes get header + sidebar.
- Create apps/web/src/app/(auth)/layout.tsx as a STANDALONE layout: no sidebar, no header. Simple centered layout with min-h-screen bg-background. Per D-04.
- Update apps/web/src/app/layout.tsx to remove AppShell wrapping if it was there (root layout should only have ThemeProvider and NextIntlClientProvider -- the route group layouts handle structural differences).
Create apps/web/src/lib/session.ts:
- Export a function verifySession(token: string) that uses jose.jwtVerify with the JWT_SECRET env var (read from process.env.JWT_SECRET or SESSION_SECRET). Returns the decoded payload or null on failure. Use HS256 algorithm.
- Export a function getSessionFromCookies(cookies) that reads the "session" cookie value.
Create apps/web/src/middleware.ts per RESEARCH.md Pattern 4:
- Define publicRoutes array: ['/login', '/reset-password']
- On every request, check if path starts with a public route prefix -- if yes, NextResponse.next()
- Also skip _next/static, _next/image, favicon.ico, and API routes
- Read "session" cookie from request
- If no cookie, redirect to /login
- Verify JWT with jose.jwtVerify using the secret from process.env.JWT_SECRET
- If verification fails, redirect to /login (clear stale cookie)
- If user payload has mustChangePassword true, redirect to /change-password (except if already on that page)
- Export matcher config per RESEARCH.md: ['/((?!api|_next/static|_next/image|.*\\.png$).*)']
Create apps/web/src/lib/auth-actions.ts with server-side functions:
- login(formData): Extract username and password. POST to API_URL/auth/login with credentials include. On success, the API sets the cookie directly (since both are behind Traefik in production). In development, handle the Set-Cookie from the API response and forward it. Return success/error.
- logout(): POST to API_URL/auth/logout. Clear cookies. Redirect to /login.
- fetchCurrentUser(): GET API_URL/auth/me with credentials include. Return user or null.
NOTE: API_URL should be read from NEXT_PUBLIC_API_URL env var (already set in docker-compose.yml as http://localhost:3001).
Create apps/web/src/lib/stores/auth-store.ts following the existing sidebar-store.ts pattern:
- Zustand store with user state (id, username, displayName, role, tenantId) or null
- Actions: setUser, clearUser
- No persist middleware (user state comes from API, not localStorage)
Create apps/web/src/app/(auth)/login/page.tsx per D-01 split-screen design:
- 'use client' component
- Full-screen split layout: LEFT side (hidden on mobile, flex-1 on md+) shows Tessera branding with primary yellow (#ffed00 / var(--primary)) background, large "Tessera" text, and tagline. RIGHT side (full width mobile, flex-1 desktop) shows login form on white/dark background.
- Form fields: username input, password input, "Angemeldet bleiben" (Remember me) checkbox per D-02
- Submit button with primary color
- Error message display area
- Form submission calls the login action, which POSTs to /auth/login on the API. On success, redirect to '/' (dashboard). On error, show error message.
- All strings through useTranslations('auth') hook per UI-03 pattern
- Include i18n keys for: auth.login, auth.username, auth.password, auth.rememberMe, auth.submit, auth.error.invalidCredentials, auth.branding.tagline
Update apps/web/src/components/layout/header.tsx:
- Replace the user avatar placeholder div with a real user dropdown component
- Import useAuthStore to get current user
- Show user initial (first letter of displayName or username) in the avatar circle
- On click, show a dropdown with: user display name, role badge, "Abmelden" (Logout) button
- Logout button calls the logout action
- Keep the existing hamburger, logo, breadcrumb, and ThemeToggle structure intact
Update apps/web/src/components/layout/sidebar-footer.tsx:
- Replace user info placeholder with real user data from useAuthStore
- Show user display name (or username) and role
- When sidebar is collapsed, show only avatar initial
Add i18n keys to both de.json and en.json for all new strings:
- auth namespace: login, username, password, rememberMe, submit, error.invalidCredentials, branding.tagline
- header namespace: logout, role.SUPER_ADMIN, role.ADMIN, role.USER
- admin namespace: users.title, users.create, users.edit, users.delete, users.name, users.email, users.role, users.status, users.actions, tenants.title, tenants.create, tenants.name, tenants.slug, tenants.status, tenants.actions
cd /home/vicolab/projects/tessera-ctl && pnpm turbo type-check --filter=@tessera/web
- (auth) route group has standalone layout without AppShell per D-04
- (portal) route group wraps children with AppShell (header + sidebar)
- Login page is split-screen with branding left and form right per D-01
- Login form has username, password, and remember-me checkbox per D-02
- Next.js middleware validates JWT cookie and redirects unauthenticated to /login
- middleware.ts allows /login and /reset-password without auth
- Header shows real user initial and dropdown with logout per auth store
- Sidebar footer shows real user name and role per auth store
- All new UI strings use i18n t() function (no hardcoded text)
- Both de.json and en.json have all new auth/admin translation keys
- Type-check passes for @tessera/web
Login page renders split-screen layout; unauthenticated users are redirected to /login; authenticated users see their name in header and sidebar; all strings are internationalized.
Task 2: User CRUD API + admin page and Tenant CRUD API + admin page
- apps/api/src/user/user.service.ts (from Plan 02-01, user operations)
- apps/api/src/user/user.module.ts (from Plan 02-01)
- apps/api/src/tenant/tenant.service.ts (from Plan 02-01, tenant operations)
- apps/api/src/tenant/tenant.module.ts (from Plan 02-01)
- apps/api/src/auth/decorators/roles.decorator.ts (Roles decorator)
- apps/api/src/auth/decorators/current-user.decorator.ts (CurrentUser decorator)
- apps/api/src/auth/guards/roles.guard.ts (RolesGuard)
- .planning/phases/02-authentication-multi-tenancy/02-RESEARCH.md (architecture diagram, project structure)
apps/api/src/user/user.controller.ts
apps/api/src/user/user.module.ts
apps/api/src/user/dto/create-user.dto.ts
apps/api/src/user/dto/update-user.dto.ts
apps/api/src/tenant/tenant.controller.ts
apps/api/src/tenant/tenant.module.ts
apps/api/src/tenant/dto/create-tenant.dto.ts
apps/web/src/app/(portal)/admin/users/page.tsx
apps/web/src/app/(portal)/admin/tenants/page.tsx
apps/web/src/components/layout/sidebar.tsx
Create user DTOs:
- create-user.dto.ts: CreateUserDto with fields: username (@IsString, @IsNotEmpty), email (@IsEmail), password (@IsString, @MinLength(8)), displayName (@IsString, @IsOptional), role (@IsEnum(Role), @IsOptional, default USER). Use class-validator decorators.
- update-user.dto.ts: UpdateUserDto with all fields optional (PartialType of CreateUserDto using @nestjs/mapped-types). Add isActive (@IsBoolean, @IsOptional).
Create apps/api/src/user/user.controller.ts:
- Prefix 'users'
- GET /users: @Roles(Role.ADMIN, Role.SUPER_ADMIN) @UseGuards(RolesGuard). Returns list of users. For ADMIN, filter by tenant (use req.tenantPrisma if available, otherwise filter by user's tenantId). For SUPER_ADMIN, return all users.
- GET /users/:id: @Roles(Role.ADMIN, Role.SUPER_ADMIN). Return single user.
- POST /users: @Roles(Role.ADMIN, Role.SUPER_ADMIN). Body: CreateUserDto. Create user via UserService. ADMIN can only create users in own tenant; SUPER_ADMIN can specify tenantId.
- PATCH /users/:id: @Roles(Role.ADMIN, Role.SUPER_ADMIN). Body: UpdateUserDto. Update user. ADMIN can only update users in own tenant and cannot set role to SUPER_ADMIN.
- DELETE /users/:id: @Roles(Role.ADMIN, Role.SUPER_ADMIN). Delete user. ADMIN can only delete users in own tenant and cannot delete self.
Update user.module.ts to add UserController to controllers array.
Create tenant DTOs:
- create-tenant.dto.ts: CreateTenantDto with fields: name (@IsString, @IsNotEmpty), slug (@IsString, @IsNotEmpty, @Matches /^[a-z0-9-]+$/).
Create apps/api/src/tenant/tenant.controller.ts:
- Prefix 'tenants'
- GET /tenants: @Roles(Role.SUPER_ADMIN) @UseGuards(RolesGuard). Returns all tenants. Per D-10, only Super-Admin manages tenants.
- GET /tenants/:id: @Roles(Role.SUPER_ADMIN). Return single tenant with user count.
- POST /tenants: @Roles(Role.SUPER_ADMIN). Body: CreateTenantDto. Create tenant.
- PATCH /tenants/:id: @Roles(Role.SUPER_ADMIN). Update tenant name, isActive.
- DELETE /tenants/:id: @Roles(Role.SUPER_ADMIN). Delete tenant (only if no active users).
Update tenant.module.ts to add TenantController to controllers array.
Create apps/web/src/app/(portal)/admin/users/page.tsx:
- 'use client' component
- Fetch users from GET /users API endpoint on mount
- Display table with columns: Username, Email, Display Name, Role, Status (Active/Inactive), Actions
- Create user button opens a form dialog/modal with CreateUserDto fields
- Edit button opens pre-filled form dialog
- Delete button with confirmation dialog
- Role displayed as colored badge (SUPER_ADMIN = red, ADMIN = blue, USER = gray)
- All strings through useTranslations('admin') per i18n pattern
- Only visible to ADMIN and SUPER_ADMIN roles (check auth store)
Create apps/web/src/app/(portal)/admin/tenants/page.tsx:
- 'use client' component
- Fetch tenants from GET /tenants API endpoint
- Display table with columns: Name, Slug, Status, User Count, Actions
- Create tenant button opens form dialog
- Edit and deactivate actions
- Only visible to SUPER_ADMIN role (check auth store, show "Access denied" otherwise)
- All strings through useTranslations('admin')
Update apps/web/src/components/layout/sidebar.tsx:
- Add admin section to sidebar navigation: "Verwaltung" (Administration) category
- Under Verwaltung: "Benutzer" (Users) link to /admin/users, "Mandanten" (Tenants) link to /admin/tenants
- Verwaltung section only visible when user role is ADMIN or SUPER_ADMIN
- Tenants link only visible when role is SUPER_ADMIN
- Use auth store to check current user role
- Add i18n keys: sidebar.admin, sidebar.users, sidebar.tenants
cd /home/vicolab/projects/tessera-ctl && pnpm turbo type-check
- User CRUD endpoints exist at /users with proper role guards (ADMIN + SUPER_ADMIN)
- ADMIN can only manage users in own tenant; SUPER_ADMIN can manage all
- Tenant CRUD endpoints exist at /tenants with SUPER_ADMIN-only access per D-10
- Admin users page shows user table with create/edit/delete actions
- Tenants page shows tenant table with create/edit actions (SUPER_ADMIN only)
- Sidebar shows "Verwaltung" section for ADMIN/SUPER_ADMIN roles
- All new UI strings internationalized in de.json and en.json
- Type-check passes for both API and web packages
Admin can create/edit/delete users with role assignment (AUTH-02); Super-Admin can create/manage tenants (TNNT-02); admin navigation visible in sidebar for authorized roles.
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser -> Login form | Untrusted credentials input |
| Next.js middleware -> JWT | Optimistic session check; API validates independently |
| Admin UI -> API | Role-based access must be enforced server-side, not just UI-hidden |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-08 | Elevation of Privilege | User CRUD endpoints | mitigate | RolesGuard enforces ADMIN/SUPER_ADMIN; ADMIN cannot escalate to SUPER_ADMIN or modify other tenants |
| T-02-09 | Tampering | Tenant CRUD | mitigate | SUPER_ADMIN-only via RolesGuard; tenant deletion blocked if active users exist |
| T-02-10 | Information Disclosure | User list | mitigate | ADMIN sees only own-tenant users; SUPER_ADMIN sees all; enforced at controller level |
| T-02-11 | Spoofing | Login form CSRF | mitigate | SameSite=lax cookie prevents cross-origin POST; form submission is same-origin |
| T-02-SC | Tampering | npm installs (jose, zod) | accept | Both packages are VERIFIED in legitimacy audit with 87M+ and 201M+ weekly downloads |
After both tasks complete:
1. Navigate to http://localhost:3000 -- should redirect to /login
2. Login with admin/admin123 -- should redirect to dashboard, header shows "admin" user
3. Navigate to /admin/users -- should show user table with the admin account
4. Create a new user via the admin page -- should appear in the table
5. Navigate to /admin/tenants -- should show "Default" tenant
6. Click logout -- should redirect to /login
7. Try accessing /admin/users directly without login -- should redirect to /login
8. pnpm turbo type-check passes for all packages
- User can log in via split-screen login page and see their identity in portal (AUTH-03, D-01, D-04)
- Session persists across browser refresh via JWT cookie (AUTH-04, D-02)
- Admin can CRUD users with role assignment (AUTH-02, D-12)
- Super-Admin can CRUD tenants (TNNT-02, D-10)
- Unauthenticated access redirects to /login
- All new strings internationalized (DE/EN)