From bc6afefeaef41ede22353257e741549dc0be45de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Mon, 23 Mar 2026 09:32:38 +0100 Subject: [PATCH] feat: admin set password for users + fix dashboard cache error Admin Set Password: - New setPassword adminProcedure in user router (Argon2 hashing) - Audit log: "Password reset by admin" (no password value logged) - UI: per-user "Password" button with key icon in User Management - Modal: new password + confirm, min 8 chars, mismatch validation - Success toast + auto-close on completion Dashboard fix: - Corrupted .next cache causing "Cannot find module worker.js" - Fixed by clearing .next cache and restarting dev server Co-Authored-By: claude-flow --- apps/web/src/components/admin/UsersClient.tsx | 159 ++++++++++++++- packages/api/src/router/user.ts | 35 ++++ plan.md | 182 ++++-------------- 3 files changed, 226 insertions(+), 150 deletions(-) diff --git a/apps/web/src/components/admin/UsersClient.tsx b/apps/web/src/components/admin/UsersClient.tsx index e936a4d..fc21dba 100644 --- a/apps/web/src/components/admin/UsersClient.tsx +++ b/apps/web/src/components/admin/UsersClient.tsx @@ -3,6 +3,8 @@ import { useState, useMemo } from "react"; import { SystemRole, PermissionKey, ROLE_DEFAULT_PERMISSIONS, type PermissionOverrides } from "@planarchy/shared"; import { trpc } from "~/lib/trpc/client.js"; +import { AnimatedModal } from "~/components/ui/AnimatedModal.js"; +import { SuccessToast } from "~/components/ui/SuccessToast.js"; import { FilterChips } from "~/components/ui/FilterChips.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js"; @@ -90,6 +92,11 @@ export function UsersClient() { const [actionError, setActionError] = useState(null); const [search, setSearch] = useState(""); const [roleFilter, setRoleFilter] = useState(""); + const [passwordTarget, setPasswordTarget] = useState<{ userId: string; userName: string } | null>(null); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [passwordError, setPasswordError] = useState(null); + const [passwordSuccess, setPasswordSuccess] = useState(false); const utils = trpc.useUtils(); @@ -166,6 +173,53 @@ export function UsersClient() { onError: (err) => setActionError(err.message), }); + const setPasswordMutation = trpc.user.setPassword.useMutation({ + onSuccess: () => { + setPasswordSuccess(true); + setNewPassword(""); + setConfirmPassword(""); + setPasswordError(null); + setTimeout(() => { + setPasswordTarget(null); + setPasswordSuccess(false); + }, 1500); + }, + onError: (err) => setPasswordError(err.message), + }); + + function openSetPassword(user: UserRow) { + setPasswordTarget({ userId: user.id, userName: user.name ?? user.email }); + setNewPassword(""); + setConfirmPassword(""); + setPasswordError(null); + setPasswordSuccess(false); + } + + function closeSetPassword() { + setPasswordTarget(null); + setNewPassword(""); + setConfirmPassword(""); + setPasswordError(null); + setPasswordSuccess(false); + } + + async function handleSetPassword() { + if (!passwordTarget) return; + if (newPassword.length < 8) { + setPasswordError("Password must be at least 8 characters"); + return; + } + if (newPassword !== confirmPassword) { + setPasswordError("Passwords do not match"); + return; + } + setPasswordError(null); + await setPasswordMutation.mutateAsync({ + userId: passwordTarget.userId, + password: newPassword, + }); + } + function openEdit(user: UserRow) { const role = (user.systemRole as SystemRole) ?? SystemRole.USER; const overrides = user.permissionOverrides as PermissionOverrides | null; @@ -291,7 +345,8 @@ export function UsersClient() { updateRoleMutation.isPending || setPermissionsMutation.isPending || resetPermissionsMutation.isPending || - createUserMutation.isPending; + createUserMutation.isPending || + setPasswordMutation.isPending; function clearAll() { setSearch(""); @@ -474,13 +529,26 @@ export function UsersClient() { {new Date(user.createdAt).toLocaleDateString("en-GB")} - +
+ + +
))} @@ -488,6 +556,81 @@ export function UsersClient() { + {/* Set Password Modal */} + +
+

+ Set Password for {passwordTarget?.userName} +

+
+ +
+ {passwordError && ( +
+ {passwordError} +
+ )} + +
+ + setNewPassword(e.target.value)} + placeholder="Min. 8 characters" + className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" + autoComplete="new-password" + /> + {newPassword.length > 0 && newPassword.length < 8 && ( +

+ {8 - newPassword.length} more character{8 - newPassword.length !== 1 ? "s" : ""} needed +

+ )} +
+ +
+ + setConfirmPassword(e.target.value)} + placeholder="Repeat password" + className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" + autoComplete="new-password" + /> + {confirmPassword.length > 0 && newPassword !== confirmPassword && ( +

+ Passwords do not match +

+ )} +
+
+ +
+ + +
+
+ + + {/* Create User Modal */} {createState && (
diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index 01b2330..57e5091 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -126,6 +126,41 @@ export const userRouter = createTRPCRouter({ return user; }), + setPassword: adminProcedure + .input( + z.object({ + userId: z.string(), + password: z.string().min(8, "Password must be at least 8 characters"), + }), + ) + .mutation(async ({ ctx, input }) => { + const user = await ctx.db.user.findUniqueOrThrow({ + where: { id: input.userId }, + select: { id: true, name: true, email: true }, + }); + + const { hash } = await import("@node-rs/argon2"); + const passwordHash = await hash(input.password); + + await ctx.db.user.update({ + where: { id: input.userId }, + data: { passwordHash }, + }); + + void createAuditEntry({ + db: ctx.db, + entityType: "User", + entityId: user.id, + entityName: `${user.name} (${user.email})`, + action: "UPDATE", + ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), + source: "ui", + summary: "Password reset by admin", + }); + + return { success: true }; + }), + updateRole: adminProcedure .input( z.object({ diff --git a/plan.md b/plan.md index 566f7c8..0074778 100644 --- a/plan.md +++ b/plan.md @@ -1,61 +1,19 @@ -# Dashboard Widget Filter System — Plan +# Admin Set Password — Plan ## Anforderungsanalyse -**Was:** Einheitliches Filter-System fuer alle Dashboard-Widgets. Filter-Logik soll geteilt werden statt pro Widget dupliziert. +**Was:** Admins sollen im User-Management das Passwort fuer beliebige User setzen/zuruecksetzen koennen. -**Anforderungen pro Widget:** -| Widget | Filter benoetigt | -|--------|-----------------| -| **Project Health** | Projektname (Suche), Client | -| **Budget Forecast** | Projektname (Suche), Client | -| **Chargeability Overview** | Country, Role/Chapter | -| **Skill Gap** | (optional: Skill-Suche) | -| **Resource Table** | Hat bereits: Chapter-Filter | -| **Project Table** | Hat bereits: Suche + Status-Filter | -| **Peak Times** | Hat bereits: Granularity + GroupBy | +**Ist-Zustand:** +- `user.create` Mutation hat Passwort-Support (Argon2 Hashing via `@node-rs/argon2`) +- Kein `setPassword` oder `resetPassword` Mutation fuer bestehende User +- UsersClient hat Passwort nur im Create-Formular, nicht im Edit-Bereich -**Design-Prinzip:** Ein shared `` Komponente die verschiedene Filter-Typen als deklarative Config akzeptiert. Filter-State wird via `onConfigChange` im Widget-Config persistiert (bereits vorhanden). - ---- - -## Architektur - -### Shared Filter Component - -```tsx -// Deklarative Filter-Konfiguration pro Widget -const filters: WidgetFilter[] = [ - { type: "search", key: "search", placeholder: "Filter projects..." }, - { type: "select", key: "clientId", label: "Client", options: clients }, - { type: "select", key: "countryId", label: "Country", options: countries }, - { type: "select", key: "roleId", label: "Role", options: roles }, -]; - - -``` - -### Filter-Typen - -| Typ | UI-Element | Use Cases | -|-----|-----------|-----------| -| `search` | Text-Input mit Lupe | Projektname, Ressourcenname | -| `select` | Dropdown | Client, Country, Role, Status | -| `toggle` | Checkbox | Include Proposed, Show Inactive | - -### Daten-Flow - -``` -Widget Config (localStorage) - ↓ values -WidgetFilterBar → onChange → onConfigChange → persistiert - ↓ values -Widget Query (tRPC) → gefilterte Daten -``` +**Soll-Zustand:** +- Neuer `setPassword` adminProcedure im user Router +- "Set Password" Button pro User im Admin-UI +- Passwort-Modal mit Eingabe + Bestaetigung +- Audit-Log Eintrag bei Passwort-Aenderung (ohne Passwort-Wert!) --- @@ -63,114 +21,54 @@ Widget Query (tRPC) → gefilterte Daten | Paket | Dateien | Art der Aenderung | |-------|---------|------------------| -| `apps/web` | `src/components/dashboard/WidgetFilterBar.tsx` | **create** — Shared Filter-Komponente | -| `apps/web` | `src/hooks/useWidgetFilterOptions.ts` | **create** — Hook fuer gemeinsame Filter-Optionen (clients, countries, roles) | -| `apps/web` | `src/components/dashboard/widgets/ProjectHealthWidget.tsx` | **edit** — Filter integrieren | -| `apps/web` | `src/components/dashboard/widgets/BudgetForecastWidget.tsx` | **edit** — Filter integrieren | -| `apps/web` | `src/components/dashboard/widgets/ChargeabilityWidget.tsx` | **edit** — Filter integrieren | -| `apps/web` | `src/components/dashboard/widgets/SkillGapWidget.tsx` | **edit** — Optional: Skill-Suche | -| `apps/web` | `src/components/dashboard/widgets/DemandWidget.tsx` | **edit** — Optional: Client/Chapter Filter | -| `apps/web` | `src/components/dashboard/widgets/TopValueWidget.tsx` | **edit** — Optional: Chapter Filter | +| `packages/api` | `src/router/user.ts` | **edit** — `setPassword` Mutation hinzufuegen | +| `apps/web` | `src/components/admin/UsersClient.tsx` | **edit** — "Set Password" Button + Modal | --- ## Task-Liste -### Phase 1: Shared Infrastructure +- [ ] **Task 1:** `setPassword` Mutation → `packages/api/src/router/user.ts` + - Input: `{ userId: string, password: string }` (min 8 Zeichen) + - adminProcedure (nur Admins duerfen Passwoerter setzen) + - Hash mit `@node-rs/argon2` (gleiches Pattern wie `create`) + - `db.user.update({ where: { id }, data: { passwordHash } })` + - Audit-Log: `createAuditEntry({ entityType: "User", action: "UPDATE", summary: "Password reset by admin" })` + - KEIN Passwort-Wert im Audit-Log (Sicherheit!) -- [ ] **Task 1:** `useWidgetFilterOptions` Hook erstellen → `src/hooks/useWidgetFilterOptions.ts` - - Cached Queries fuer Clients, Countries, Roles (alle mit `staleTime: 300_000`) - - Gibt `{ clients, countries, roles, chapters }` als `{ value: string, label: string }[]` zurueck - - Chapters extrahiert aus Resource-Daten oder als dedizierte Query - - Nur einmal pro Dashboard geladen, von allen Widgets geteilt - -- [ ] **Task 2:** `WidgetFilterBar` Komponente erstellen → `src/components/dashboard/WidgetFilterBar.tsx` - ```typescript - interface WidgetFilter { - type: "search" | "select" | "toggle"; - key: string; // Config-Schluessel (z.B. "clientId") - label?: string; // Display-Label - placeholder?: string; // Fuer search/select - options?: { value: string; label: string }[]; // Fuer select - } - - interface WidgetFilterBarProps { - filters: WidgetFilter[]; - values: Record; - onChange: (update: Record) => void; - } - ``` - - Kompaktes Layout: horizontal, passt in Widget-Header-Bereich - - Kleine Inputs (text-xs, py-1) damit sie nicht zu viel Platz nehmen - - "Reset" Button wenn Filter aktiv - - Dark-Theme Support - -### Phase 2: Widget Integration (parallel) - -- [ ] **Task 3:** ProjectHealthWidget + Filter → `ProjectHealthWidget.tsx` - - Filter: `search` (Projektname), `select` (Client) - - Client-Daten laden via useWidgetFilterOptions - - Filtern: `rows.filter(r => matchesSearch && matchesClient)` - - Filter-State via `config.search`, `config.clientId` - -- [ ] **Task 4:** BudgetForecastWidget + Filter → `BudgetForecastWidget.tsx` - - Gleiche Filter wie ProjectHealth: search + clientId - - Gleiche Logik, gleiche WidgetFilterBar Config - -- [ ] **Task 5:** ChargeabilityWidget + Filter → `ChargeabilityWidget.tsx` - - Filter: `select` (Country), `select` (Role/Chapter) - - Country/Role-Daten via useWidgetFilterOptions - - Filtern ueber die Resource-Daten in der Chargeability-Antwort - - `toggle` (Include Proposed) — bereits vorhanden, in WidgetFilterBar integrieren - -- [ ] **Task 6:** SkillGapWidget + Filter → `SkillGapWidget.tsx` - - Filter: `search` (Skill-Name) - - Einfache client-seitige Filterung der Skill-Liste - -- [ ] **Task 7:** TopValueWidget + Filter → `TopValueWidget.tsx` - - Filter: `select` (Chapter) — bereits sortierbar, Chapter-Filter hinzufuegen +- [ ] **Task 2:** UI — "Set Password" Button + Modal → `UsersClient.tsx` + - Pro User-Zeile: "Set Password" Button (Schloss-Icon) + - Klick oeffnet AnimatedModal mit: + - User-Name als Titel + - Neues Passwort Input (min 8 Zeichen) + - Passwort bestaetigen Input + - Validierung: Passwoerter muessen uebereinstimmen + - Submit-Button (disabled wenn <8 Zeichen oder nicht matching) + - Success: Toast "Password updated", Modal schliessen + - Error: Fehlermeldung anzeigen --- ## Abhaengigkeiten -``` -Task 1 (Hook) + Task 2 (WidgetFilterBar) → koennen parallel -Task 1+2 → Tasks 3-7 (alle parallel, verschiedene Dateien) -``` - -- Tasks 3-7 sind **vollstaendig parallel** (verschiedene Widget-Dateien) -- Tasks 1+2 muessen zuerst (shared Infrastructure) +- Task 1 muss vor Task 2 (API benoetigt fuer UI) +- Beide Tasks koennen in einer Sequenz implementiert werden (gleicher Agent) --- ## Akzeptanzkriterien - [ ] `pnpm --filter @planarchy/web exec tsc --noEmit` — keine neuen Errors -- [ ] **ProjectHealth** filterbar nach Projektname + Client -- [ ] **BudgetForecast** filterbar nach Projektname + Client -- [ ] **Chargeability** filterbar nach Country + Role -- [ ] **SkillGap** filterbar nach Skill-Name -- [ ] **TopValue** filterbar nach Chapter -- [ ] Filter-State wird in Widget-Config persistiert (bleibt nach Reload) -- [ ] Reset-Button setzt alle Filter zurueck -- [ ] Dark-Theme funktioniert fuer alle Filter -- [ ] Filter-Optionen werden gecacht (nicht pro Widget neu geladen) +- [ ] Admin kann Passwort fuer beliebigen User setzen +- [ ] Passwort wird mit Argon2 gehasht (nicht plaintext gespeichert) +- [ ] Audit-Log Eintrag wird erstellt (ohne Passwort-Wert) +- [ ] Min 8 Zeichen Validierung im UI und API +- [ ] Passwort-Bestaetigung muss uebereinstimmen --- ## Risiken & offene Fragen -### Risiken -- **Widget-Groesse:** Filter-Bar braucht Platz — bei kleinen Widgets koennte es eng werden - → Mitigation: Kompakte Inputs (xs), collapsible Filter-Bar mit Funnel-Icon -- **Performance:** Zusaetzliche tRPC-Queries fuer Client/Country/Role Listen - → Mitigation: Ein shared Hook mit 5-Minuten staleTime, von allen Widgets geteilt - -### Offene Fragen -1. **Server-side vs Client-side Filtering?** - → Empfehlung: Client-seitig, da Widget-Daten bereits komplett geladen sind (max 30-50 Rows) -2. **Soll der Filter-Bar im Widget-Header oder darunter angezeigt werden?** - → Empfehlung: Direkt unter dem Titel, ueber der Tabelle — kompakt mit kleinen Inputs -3. **Sollen Filter-Optionen leer sein wenn keine Daten vorhanden?** - → Ja, leere Dropdowns zeigen "(All)" als Default +- **Sicherheit:** Nur ADMIN-Rolle darf Passwoerter setzen (adminProcedure) +- **Audit:** Passwort-Wert DARF NICHT im Audit-Log erscheinen +- **UX:** Soll der User benachrichtigt werden? → Vorschlag: Nein, Admin setzt manuell und teilt dem User das Passwort separat mit