---
phase: quick-260630-gbh
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- apps/api/prisma/schema.prisma
- apps/api/prisma/migrations
- apps/api/src/user/user.controller.ts
- apps/api/src/auth/auth.controller.ts
- apps/api/src/auth/auth.service.ts
- apps/web/src/app/(portal)/settings/general/account/page.tsx
- apps/web/src/components/settings/account-settings-form.tsx
- apps/web/src/components/settings/settings-sidebar.tsx
- 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/messages/de.json
- apps/web/src/messages/en.json
autonomous: true
requirements: []
must_haves:
truths:
- "A local (non-LDAP) user can change their own password from the Settings > Konto page; LDAP users do not see the password form"
- "Any user can upload a profile picture from the Settings > Konto page and see it reflected in the header avatar without a full reload"
- "The header avatar shows the uploaded image when present, otherwise falls back to the username initial"
artifacts:
- "apps/api/prisma migration adding User.avatarPath"
- "POST/GET /users/me/avatar endpoints on the API"
- "Enriched GET /auth/me returning isLocalUser + hasAvatar"
- "apps/web settings account page with conditional password form + avatar upload"
key_links:
- "/auth/me isLocalUser flag gates the password form visibility"
- "header
reads /api-proxy/users/me/avatar (same-origin rewrite) so no Next Image remote config is needed"
- "avatar files persist under user-files/avatars/ (gitignored, same dir DKV already uses)"
---
Surface two self-service capabilities on the Tessera User Settings page:
1. Password change — reuse the already-complete `POST /auth/change-password` flow, but expose it in Settings and show it ONLY for local (non-LDAP) users.
2. Profile picture — add upload + storage + serving on the API, an upload control in Settings, and avatar display in the header.
Purpose: Give end users (role USER included) control over their own credentials and identity without admin involvement.
Output: One new settings page, avatar backend endpoints, a Prisma field, an enriched `/auth/me`, and header avatar rendering.
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
@.planning/STATE.md
# Backend — password change is ALREADY implemented; do not rebuild it
@apps/api/src/auth/auth.service.ts
@apps/api/src/auth/auth.controller.ts
@apps/api/src/user/user.controller.ts
@apps/api/src/user/user.service.ts
@apps/api/src/auth/decorators/current-user.decorator.ts
# File upload pattern to mirror (FileInterceptor, memory storage, size limit)
@apps/api/src/dkv/dkv.controller.ts
# Frontend — existing password form + actions to reuse
@apps/web/src/lib/auth-actions.ts
@apps/web/src/app/(portal)/change-password/page.tsx
@apps/web/src/components/settings/settings-sidebar.tsx
@apps/web/src/components/layout/header.tsx
@apps/web/src/lib/stores/auth-store.ts
Task 1: Avatar persistence + API endpoints + enrich /auth/me
apps/api/prisma/schema.prisma, apps/api/prisma/migrations, apps/api/src/user/user.controller.ts, apps/api/src/auth/auth.controller.ts, apps/api/src/auth/auth.service.ts
Add an optional `avatarPath String?` column to the `User` model in schema.prisma. Generate a migration named `add_user_avatar` via `pnpm --filter @tessera/api exec prisma migrate dev --name add_user_avatar` (run against the dev DB) so the SQL migration file is created under apps/api/prisma/migrations.
In UserController add two authenticated, self-scoped routes that all roles (including USER) may call. The class is decorated with `@UseGuards(RolesGuard)` and every existing method carries `@Roles(...)`; the new routes must NOT carry `@Roles`, because RolesGuard returns true when no role metadata is present — confirm this by reading the RolesGuard implementation before relying on it. If RolesGuard instead blocks when no `@Roles` is set, place these two routes on AuthController (which has no class-level RolesGuard) instead of UserController, keeping the same logic.
- `POST /users/me/avatar`: wrap with `@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 2 * 1024 * 1024 } }))` mirroring the DKV import handler. Reject when no `file.buffer`, and reject any mimetype other than image/png, image/jpeg, or image/webp with a BadRequestException. Resolve a storage dir `user-files/avatars/` relative to the monorepo root using the same `path.resolve(__dirname, ...)` upward-walk approach DkvService uses for `user-files/`; create the dir if missing (`fs.mkdirSync(dir, { recursive: true })`). Write the buffer to `{userId}.{ext}` where ext is derived from the mimetype (png/jpg/webp), overwriting any prior file, then update the user row `avatarPath` to the relative path. Use the authenticated user id from `@CurrentUser()`. Return `{ success: true }`.
- `GET /users/me/avatar`: look up the current user's `avatarPath`; if absent or the file is missing, return 404 (NotFoundException). Otherwise stream the file with the correct `Content-Type` header (infer from extension) using `res.send(buffer)` like the DKV export download handler. Set `Cache-Control: no-store` so a freshly uploaded image is not served stale.
Enrich `GET /auth/me`: the route currently returns the raw JWT payload (`request.user` = id/username/role/tenantId) which carries no auth-source or avatar info. Add a method to AuthService (PrismaService is already injected there) — e.g. `getMe(userId)` — that loads the user and returns the existing public fields PLUS `displayName`, `isLocalUser` (true when `passwordHash != null && ldapDn == null`), and `hasAvatar` (true when `avatarPath != null`). Update the `me()` handler in AuthController to await and return that enriched object. Do not leak `passwordHash` or `ldapDn` in the response.
pnpm --filter @tessera/api exec tsc --noEmit
schema has avatarPath, migration file exists, `tsc --noEmit` passes for @tessera/api, and the three endpoints (POST/GET avatar, enriched /auth/me) compile with self-scoped (non-admin) access.
Task 2: Settings "Konto" page — conditional password form + avatar upload
apps/web/src/app/(portal)/settings/general/account/page.tsx, apps/web/src/components/settings/account-settings-form.tsx, apps/web/src/components/settings/settings-sidebar.tsx, apps/web/src/lib/auth-actions.ts, apps/web/src/messages/de.json, apps/web/src/messages/en.json
Update the `AuthUser` interface in apps/web/src/lib/auth-actions.ts to include the optional `isLocalUser?: boolean` and `hasAvatar?: boolean` fields now returned by `/auth/me` (these come back from `fetchCurrentUser`). Add a server action `uploadAvatarAction(formData: FormData)` that forwards the multipart `file` to `POST /users/me/avatar` using the session cookie, following the cookie-forwarding pattern already used by `changePasswordAction` (read session from `cookies()`, send `Cookie: session=...` header, POST to `${API_URL}/users/me/avatar`). Return a discriminated `{ success: boolean; error?: string }`. Do NOT call `redirect()` inside it — the caller updates UI in place.
Create `account-settings-form.tsx` (client component) that:
- Calls `fetchCurrentUser()` on mount and reads `isLocalUser`.
- Renders the password-change section ONLY when `isLocalUser === true`. Reuse the exact field layout (currentPassword / newPassword / confirmPassword, minLength 8, client-side mismatch check) and submit flow from the existing `/change-password` page, calling `changePasswordAction`. Show an inline success message instead of redirecting away (the existing action redirects to '/'; for the settings context, prefer surfacing success inline — if reusing `changePasswordAction` as-is keeps the redirect, that is acceptable for v-parity, but do not introduce a "simplified" password flow). When `isLocalUser` is false, render a short note that password is managed via the directory (LDAP) and no form.
- Renders an avatar section: shows the current avatar via `
` with an `onError` fallback to the username initial (same visual as the header), plus a file input (accept="image/png,image/jpeg,image/webp") and an upload button that builds a FormData with field name `file` and calls `uploadAvatarAction`. On success, force the preview to refresh by appending a cache-busting query (`?t=${Date.now()}`) to the img src.
Create the route page `settings/general/account/page.tsx` that renders `` inside the existing settings layout (mirror how `settings/general/smtp/page.tsx` is structured).
Add a nav link to `settings-sidebar.tsx` under the "Allgemein" (categoryGeneral) group, above or beside the SMTP link, pointing to `/settings/general/account` with the same active-state styling and `aria-current` handling used by the SMTP link.
Add the new i18n keys to BOTH de.json and en.json under the `settings` namespace (e.g. `categoryAccount`, an `account` sub-object with avatar labels: title, avatarLabel, avatarHelp, uploadCta, uploadSuccess, uploadError, ldapManagedNotice, passwordSectionTitle). Reuse the existing `auth.changePassword.*` keys for the password field labels/messages to avoid duplication.
pnpm --filter @tessera/web exec tsc --noEmit
`/settings/general/account` renders; password form appears only for local users; LDAP users see the managed-notice; avatar upload control posts to the API; sidebar shows the Konto link; both locale files have the new keys; web typecheck passes.
Task 3: Header avatar display with initial fallback
apps/web/src/components/layout/header.tsx, apps/web/src/lib/stores/auth-store.ts
Extend the `AuthUser` interface in apps/web/src/lib/stores/auth-store.ts with an optional `hasAvatar?: boolean` so the store can carry it (populate it in the existing `setUser({...})` call in header.tsx from the `fetchCurrentUser()` result `u.hasAvatar`).
In header.tsx, replace the avatar button's text-only content: when the user has an avatar, render `
` with an `onError` handler that hides the img and reveals the existing `userInitial` span (keep the initial as the fallback so a missing/404 image still shows a letter). When `hasAvatar` is false/unknown, keep the current initial-only rendering. Do not change the dropdown markup. The `/api-proxy` path is same-origin (next.config rewrite), so no `next/image` remote config is required — a plain `
` is correct here.
pnpm --filter @tessera/web exec tsc --noEmit
Header shows the uploaded avatar image when present and the username initial otherwise; web typecheck passes.
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser → API (avatar upload) | untrusted multipart file crosses here |
| browser → API (change-password) | already mitigated by existing flow (current-password verification) |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-gbh-01 | Tampering | POST /users/me/avatar upload | medium | mitigate | Enforce 2MB size limit (FileInterceptor) and an image-only mimetype allowlist (png/jpeg/webp); reject all else with 400 |
| T-gbh-02 | Elevation of Privilege | /users/me/avatar self-scoping | high | mitigate | Derive target user id from `@CurrentUser()` only; never accept a userId from the request body/params for the "me" routes |
| T-gbh-03 | Information Disclosure | enriched /auth/me | medium | mitigate | Return only public fields + isLocalUser/hasAvatar; never serialize passwordHash or ldapDn |
| T-gbh-04 | Tampering | avatar file path | medium | mitigate | Write to `user-files/avatars/{userId}.{ext}` with ext from a fixed mimetype map (no user-controlled filename), preventing path traversal |
| T-gbh-05 | Information Disclosure | GET /users/me/avatar | low | accept | Avatar served only for the authenticated user's own file; no cross-user enumeration route is added in this plan |
- Backend typecheck: `pnpm --filter @tessera/api exec tsc --noEmit` passes.
- Frontend typecheck: `pnpm --filter @tessera/web exec tsc --noEmit` passes.
- Migration present under apps/api/prisma/migrations with the avatarPath column.
- Manual (human-check): As a local user, open Settings > Konto, change the password (current-password required, mismatch rejected), then upload a PNG and confirm it appears in the header avatar without a full reload. As an LDAP user (ldapDn set), confirm the password form is hidden and the managed-notice shows.
- Local users can change their password from Settings; LDAP users cannot see the form.
- Users can upload a profile picture that persists and displays in the header avatar with initial fallback.
- No passwordHash/ldapDn leakage in `/auth/me`.
- Avatar upload enforces size + image-mimetype limits and is self-scoped to the authenticated user.
- Both type checks pass.