Files
tessera-ctl/.planning/phases/02-authentication-multi-tenancy/02-02-PLAN.md
T
schalli e8e89686f5 docs(02): create phase 2 authentication & multi-tenancy plans
5 plans across 4 waves covering all 9 requirements (AUTH-01..06, TNNT-01..03)
and 18 locked decisions (D-01..D-18):
- Plan 01 (W1): Backend auth foundation with Prisma schema, RLS, JWT, admin seed
- Plan 02 (W2): Frontend auth flow, login page, user/tenant CRUD admin pages
- Plan 03 (W2): SUS package verification, password reset, force-change interceptor
- Plan 04 (W3): LDAP sync service, per-tenant config, field mapping, admin UI
- Plan 05 (W4): Visual verification of complete auth and multi-tenancy system

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 13:10:13 +02:00

20 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
02-authentication-multi-tenancy 02 execute 2
02-01
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
true
AUTH-02
AUTH-03
AUTH-04
TNNT-02
truths artifacts key_links
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)
path provides min_lines
apps/web/src/app/(auth)/login/page.tsx Split-screen login page with branding and form 50
path provides min_lines
apps/web/src/app/(auth)/layout.tsx Standalone auth layout without sidebar/header 10
path provides min_lines
apps/web/src/middleware.ts JWT validation for route protection 20
path provides min_lines
apps/web/src/app/(portal)/admin/users/page.tsx User management admin page 50
path provides min_lines
apps/web/src/app/(portal)/admin/tenants/page.tsx Tenant management Super-Admin page 40
path provides exports
apps/api/src/user/user.controller.ts User CRUD REST endpoints
UserController
path provides exports
apps/api/src/tenant/tenant.controller.ts Tenant CRUD REST endpoints
TenantController
from to via pattern
apps/web/src/middleware.ts apps/web/src/lib/session.ts JWT verification using jose jwtVerify
from to via pattern
apps/web/src/app/(auth)/login/page.tsx apps/api/src/auth/auth.controller.ts POST /auth/login fetch with credentials include fetch.*auth/login
from to via pattern
apps/web/src/components/layout/header.tsx apps/web/src/lib/stores/auth-store.ts Zustand store for client-side user state 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.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

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

<threat_model>

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
</threat_model>
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

<success_criteria>

  • 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) </success_criteria>
Create `.planning/phases/02-authentication-multi-tenancy/02-02-SUMMARY.md` when done