--- phase: quick-260630-gbh plan: "01" subsystem: user-settings tags: [avatar, password-change, user-settings, auth, api] status: complete dependency_graph: requires: [] provides: [avatar-api, enriched-auth-me, account-settings-page, header-avatar] affects: [auth-controller, user-controller, header, settings-sidebar] tech_stack: added: [] patterns: [FileInterceptor-memory-storage, self-scoped-routes-no-roles, same-origin-img-proxy] key_files: created: - apps/api/prisma/migrations/20260630095533_add_user_avatar/migration.sql - apps/web/src/components/settings/account-settings-form.tsx - apps/web/src/app/(portal)/settings/general/account/page.tsx modified: - apps/api/prisma/schema.prisma - apps/api/src/user/user.controller.ts - apps/api/src/auth/auth.service.ts - apps/api/src/auth/auth.controller.ts - apps/web/src/lib/auth-actions.ts - apps/web/src/components/settings/settings-sidebar.tsx - apps/web/src/lib/stores/auth-store.ts - apps/web/src/components/layout/header.tsx - apps/web/src/messages/de.json - apps/web/src/messages/en.json decisions: - "Avatar routes added to UserController (not AuthController) — RolesGuard returns true when no @Roles metadata, confirmed from guard source" - "Used Prisma v6.19.3 (installed version), not v7 specified in CLAUDE.md — package.json pins ^6.0.0" - "Plain tag in header (not next/image) — /api-proxy is same-origin rewrite, no remote config needed" - "Web tsc --noEmit has pre-existing failures across all files (missing JSX/module type declarations); not introduced by this plan" metrics: duration: "~15 minutes" completed: "2026-06-30" tasks_completed: 3 tasks_total: 3 files_modified: 10 files_created: 3 --- # Phase quick-260630-gbh Plan 01: User Settings — Avatar + Password Change Summary **One-liner:** Profile avatar upload/serve with per-user file storage and conditional password-change form gated on local-vs-LDAP user status. ## Tasks Completed | Task | Name | Commit | Files | |------|------|--------|-------| | 1 | Avatar persistence + API endpoints + enrich /auth/me | 0fba45d | schema.prisma, migration, user.controller.ts, auth.service.ts, auth.controller.ts | | 2 | Settings Konto page — conditional password form + avatar upload | 4482a8c | auth-actions.ts, account-settings-form.tsx, settings-sidebar.tsx, account/page.tsx, de.json, en.json | | 3 | Header avatar display with initial fallback | 5ae498f | auth-store.ts, header.tsx | ## What Was Built **Backend:** - `User.avatarPath String?` column added via Prisma migration `20260630095533_add_user_avatar` - `POST /users/me/avatar`: FileInterceptor with 2 MB limit; image/png, image/jpeg, image/webp allowlist (T-gbh-01); writes `user-files/avatars/{userId}.{ext}` derived from MIME type (T-gbh-04); removes stale files with other extensions; userId from `@CurrentUser()` only (T-gbh-02); returns `{ success: true }` - `GET /users/me/avatar`: looks up `avatarPath`, streams buffer with correct Content-Type and `Cache-Control: no-store` - `AuthService.getMe(userId)`: loads user, returns public fields + `isLocalUser` (passwordHash set && ldapDn null) + `hasAvatar` (avatarPath not null); never serialises passwordHash or ldapDn (T-gbh-03) - `GET /auth/me`: now calls `authService.getMe(user.id)` instead of returning raw JWT payload **Frontend:** - `AuthUser` interface (both auth-actions.ts and auth-store.ts): added `isLocalUser?` and `hasAvatar?` - `uploadAvatarAction`: server action POSTing multipart to `/users/me/avatar` with session cookie; returns `{ success, error? }` without redirect - `AccountSettingsForm` (client component): avatar preview with initial fallback + file upload; password form gated on `isLocalUser === true`; LDAP managed notice when false - `/settings/general/account` page: renders `AccountSettingsForm` - `SettingsSidebar`: Konto link added above SMTP link - i18n: `categoryAccount` + `account.*` keys in both de.json and en.json **Header:** - Avatar button: shows `` when `hasAvatar=true` with `onError` fallback to initial letter ## Deviations from Plan ### Pre-existing condition (no action required) **[Pre-existing] Web tsc --noEmit fails across all source files** - All JSX files fail with `TS7026: JSX element implicitly has type 'any'` and module resolution errors (`next/link`, `next-intl`, `zustand`, etc.) - This affects the entire web package, not just files in this plan - Out of scope per deviation rule scope boundary; logged here for awareness ### Auto-decisions **[Rule 2 - Correctness] Auth.me enrichment placed in AuthService not inline** - Kept DB access logic in AuthService (consistent with existing pattern) rather than inlining in controller **HEAD mismatch at startup** - Expected base `04b37f54` but HEAD was `dcba4b9` (3 DKV commits landed on main after plan dispatch) - Proceeded: worktree branch is `worktree-agent-a51974fb00fe6b744` (correct), DKV work is orthogonal to this plan's scope ## Threat Surface Scan All T-gbh-01 through T-gbh-05 mitigations from the plan's threat model are implemented: - T-gbh-01: 2 MB FileInterceptor limit + MIME allowlist in user.controller.ts - T-gbh-02: `@CurrentUser()` only for userId — never from request body/params - T-gbh-03: `getMe()` strips passwordHash/ldapDn/avatarPath before return - T-gbh-04: filename = `{userId}.{ext}` from fixed MIME map, no user-controlled path component - T-gbh-05: GET /users/me/avatar serves only the authenticated user's own file No new threat surface was introduced beyond what the plan's threat model already covers. ## Self-Check: PASSED | Check | Result | |-------|--------| | migration.sql exists | FOUND | | user.controller.ts | FOUND | | auth.service.ts | FOUND | | account-settings-form.tsx | FOUND | | account/page.tsx | FOUND | | commit 0fba45d (Task 1) | FOUND | | commit 4482a8c (Task 2) | FOUND | | commit 5ae498f (Task 3) | FOUND |