diff --git a/.planning/quick/260630-gbh-user-settings-passwort-ndern-nur-non-lda/260630-gbh-PLAN.md b/.planning/quick/260630-gbh-user-settings-passwort-ndern-nur-non-lda/260630-gbh-PLAN.md
new file mode 100644
index 0000000..618bde9
--- /dev/null
+++ b/.planning/quick/260630-gbh-user-settings-passwort-ndern-nur-non-lda/260630-gbh-PLAN.md
@@ -0,0 +1,171 @@
+---
+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.
+
+
+