docs(260630-gbh): pre-dispatch plan for User Settings: Passwort ändern (nur non-LDAP) + Profilbild setzen
This commit is contained in:
+171
@@ -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 <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)"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<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
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Avatar persistence + API endpoints + enrich /auth/me</name>
|
||||
<files>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</files>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/api exec tsc --noEmit</automated>
|
||||
</verify>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Settings "Konto" page — conditional password form + avatar upload</name>
|
||||
<files>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</files>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/web exec tsc --noEmit</automated>
|
||||
</verify>
|
||||
<done>`/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.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Header avatar display with initial fallback</name>
|
||||
<files>apps/web/src/components/layout/header.tsx, apps/web/src/lib/stores/auth-store.ts</files>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/web exec tsc --noEmit</automated>
|
||||
</verify>
|
||||
<done>Header shows the uploaded avatar image when present and the username initial otherwise; web typecheck passes.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<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>
|
||||
|
||||
<verification>
|
||||
- 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.
|
||||
</verification>
|
||||
|
||||
<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>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/260630-gbh-user-settings-passwort-ndern-nur-non-lda/260630-gbh-SUMMARY.md` when done.
|
||||
</output>
|
||||
Reference in New Issue
Block a user