From 04b37f54f6d3135005cc444fa71ac33bb80a7669 Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 30 Jun 2026 11:49:12 +0200 Subject: [PATCH] =?UTF-8?q?docs(260630-gbh):=20pre-dispatch=20plan=20for?= =?UTF-8?q?=20User=20Settings:=20Passwort=20=C3=A4ndern=20(nur=20non-LDAP)?= =?UTF-8?q?=20+=20Profilbild=20setzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../260630-gbh-PLAN.md | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 .planning/quick/260630-gbh-user-settings-passwort-ndern-nur-non-lda/260630-gbh-PLAN.md 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. + + + +Create `.planning/quick/260630-gbh-user-settings-passwort-ndern-nur-non-lda/260630-gbh-SUMMARY.md` when done. +