Files
tessera-ctl/.planning/quick/260630-gbh-user-settings-passwort-ndern-nur-non-lda/260630-gbh-PLAN.md
T

14 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
quick-260630-gbh 01 execute 1
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
true
truths artifacts key_links
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
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
/auth/me isLocalUser flag gates the password form visibility
header <img> 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.

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

@.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 `<img src="/api-proxy/users/me/avatar">` 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 `<AccountSettingsForm />` 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 `<img src="/api-proxy/users/me/avatar" alt="" className="h-8 w-8 rounded-full object-cover">` 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 `<img>` 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.

<threat_model>

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

<success_criteria>

  • 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. </success_criteria>
Create `.planning/quick/260630-gbh-user-settings-passwort-ndern-nur-non-lda/260630-gbh-SUMMARY.md` when done.