From 1e1f47023c0c216018cad6e8f4cbfe21fb4f77ad Mon Sep 17 00:00:00 2001 From: "Dennis (via Claude+Gemma)" Date: Sat, 23 May 2026 05:32:02 +0200 Subject: [PATCH] feat(password-change): Change-Password Endpoint + Form in Profile [tsc:ok] --- .phase6-state.json | 5 + GENERATION_LOG.md | 13 +++ apps/api/src/routes/users.ts | 38 +++++++ apps/web/src/pages/Profile.tsx | 102 ++++++++++++++++-- scripts/phase6_features.py | 191 +++++++++++++++++++++++++++++++++ 5 files changed, 343 insertions(+), 6 deletions(-) create mode 100644 .phase6-state.json create mode 100644 scripts/phase6_features.py diff --git a/.phase6-state.json b/.phase6-state.json new file mode 100644 index 0000000..08e5d00 --- /dev/null +++ b/.phase6-state.json @@ -0,0 +1,5 @@ +{ + "completed_features": [], + "current_feature": "password-change", + "started_at": "2026-05-23T05:30:16.203066" +} \ No newline at end of file diff --git a/GENERATION_LOG.md b/GENERATION_LOG.md index 630d5ec..8aa29e5 100644 --- a/GENERATION_LOG.md +++ b/GENERATION_LOG.md @@ -647,3 +647,16 @@ Migrations completed successfully Checking for admin user... Admin user already exists + +## 🚀 Phase-6 Codegen-Run gestartet (2026-05-23 05:30:16) + + +## Phase-3 Feature: password-change (2026-05-23 05:30:16) + +- `05:30:16` **INFO** Description: Change-Password Endpoint + Form in Profile +- `05:30:16` **INFO** Generating apps/api/src/routes/users.ts (ERWEITERT — behalte alles. Füge POST /me/password (body: {oldPassword,…) +- `05:30:58` **INFO** wrote 4542 chars in 42.7s (attempt 1) +- `05:30:58` **INFO** Generating apps/web/src/pages/Profile.tsx (ERWEITERT — behalte bestehendes Form (Name update). Füge zweite Card '…) +- `05:32:00` **INFO** wrote 7584 chars in 61.9s (attempt 1) +- `05:32:00` **INFO** Running tsc --noEmit on api… +- `05:32:02` **INFO** tsc clean ✓ diff --git a/apps/api/src/routes/users.ts b/apps/api/src/routes/users.ts index ffa46d7..05c569d 100644 --- a/apps/api/src/routes/users.ts +++ b/apps/api/src/routes/users.ts @@ -17,6 +17,11 @@ const UserCreateSchema = z.object({ password: z.string().min(8) }) +const PasswordChangeSchema = z.object({ + oldPassword: z.string(), + newPassword: z.string().min(8) +}) + export default async function userRoutes(fastify: FastifyInstance) { fastify.addHook("preHandler", async (request, reply) => { try { @@ -71,6 +76,39 @@ export default async function userRoutes(fastify: FastifyInstance) { return user }) + fastify.post("/me/password", async (request, reply) => { + const userId = (request.user as { sub: string } | undefined)?.sub + if (!userId) { + return reply.code(401).send({ message: "User ID not found in token" }) + } + + const body = PasswordChangeSchema.parse(request.body) + + const [user] = await db + .select() + .from(users) + .where(eq(users.id, userId)) + .limit(1) + + if (!user) { + return reply.code(404).send({ message: "User not found" }) + } + + const isPasswordCorrect = await argon2.verify(user.passwordHash, body.oldPassword) + if (!isPasswordCorrect) { + return reply.code(401).send({ message: "Incorrect old password" }) + } + + const newPasswordHash = await argon2.hash(body.newPassword) + + await db + .update(users) + .set({ passwordHash: newPasswordHash }) + .where(eq(users.id, userId)) + + return reply.send({ message: "Password updated successfully" }) + }) + fastify.get("/", async (request, reply) => { if (!isAdmin(request)) { return reply.code(403).send({ message: "Forbidden: Admin role required" }) diff --git a/apps/web/src/pages/Profile.tsx b/apps/web/src/pages/Profile.tsx index 7239cb5..be94727 100644 --- a/apps/web/src/pages/Profile.tsx +++ b/apps/web/src/pages/Profile.tsx @@ -6,6 +6,11 @@ import { useToast } from '../components/Toast'; export default function Profile() { const toast = useToast(); const [name, setName] = useState(''); + const [passwords, setPasswords] = useState({ + oldPassword: '', + newPassword: '', + confirmPassword: '', + }); const { data: user, isLoading, refetch } = useQuery({ queryKey: ['me'], @@ -25,17 +30,46 @@ export default function Profile() { }, }); + const passwordMutation = useMutation({ + mutationFn: async (data: typeof passwords) => { + return api.changePassword({ + oldPassword: data.oldPassword, + newPassword: data.newPassword, + }); + }, + onSuccess: () => { + toast.success('Passwort erfolgreich geändert'); + setPasswords({ oldPassword: '', newPassword: '', confirmPassword: '' }); + }, + onError: () => { + toast.error('Fehler beim Ändern des Passworts. Bitte Daten prüfen.'); + }, + }); + useEffect(() => { if (user?.name) { setName(user.name); } }, [user]); - const handleSubmit = async (e: React.FormEvent) => { + const handleProfileSubmit = async (e: React.FormEvent) => { e.preventDefault(); updateMutation.mutate(name); }; + const handlePasswordSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (passwords.newPassword !== passwords.confirmPassword) { + toast.error('Die neuen Passwörter stimmen nicht überein'); + return; + } + if (passwords.newPassword.length < 6) { + toast.error('Das neue Passwort muss mindestens 6 Zeichen lang sein'); + return; + } + passwordMutation.mutate(passwords); + }; + if (isLoading) { return (
@@ -53,15 +87,15 @@ export default function Profile() { } return ( -
+
+ {/* Profile Card */}

Mein Profil

-
+
- {/* Name Field */}
- {/* Email Field (Readonly) */}
- {/* Role Badge */}
@@ -109,6 +141,64 @@ export default function Profile() {
+ + {/* Password Card */} +
+
+

Passwort ändern

+
+ +
+
+
+ + setPasswords({ ...passwords, oldPassword: e.target.value })} + className="w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all" + placeholder="••••••••" + required + /> +
+ +
+
+ + setPasswords({ ...passwords, newPassword: e.target.value })} + className="w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all" + placeholder="••••••••" + required + /> +
+
+ + setPasswords({ ...passwords, confirmPassword: e.target.value })} + className="w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all" + placeholder="••••••••" + required + /> +
+
+
+ +
+ +
+
+
); } \ No newline at end of file diff --git a/scripts/phase6_features.py b/scripts/phase6_features.py new file mode 100644 index 0000000..97f913c --- /dev/null +++ b/scripts/phase6_features.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""Phase-6: file-upload, password-change, audit-log, keyboard-shortcuts, calendar-week.""" + +from __future__ import annotations + +import asyncio +import datetime +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from phase2_features import Feature, FileGen, ROOT, log, log_section # noqa: E402 +from phase3_features import run_feature_v2 # noqa: E402 + +PHASE6_STATE = ROOT / ".phase6-state.json" + +FEATURES: list[Feature] = [ + Feature( + name="password-change", + description="Change-Password Endpoint + Form in Profile", + files=[ + FileGen( + path="apps/api/src/routes/users.ts", + purpose=( + "ERWEITERT — behalte alles. Füge POST /me/password (body: {oldPassword, newPassword}). " + "Argon2.verify alten, dann argon2.hash neuen + db.update. 401 wenn alt nicht stimmt." + ), + refs=["apps/api/src/routes/users.ts"], + ), + FileGen( + path="apps/web/src/pages/Profile.tsx", + purpose=( + "ERWEITERT — behalte bestehendes Form (Name update). Füge zweite Card 'Passwort ändern': " + "Inputs altes Passwort + neues Passwort + Bestätigung. Submit → api.changePassword(). Toast feedback." + ), + refs=["apps/web/src/pages/Profile.tsx"], + ), + ], + ), + Feature( + name="audit-log", + description="Audit-Log Tabelle + Page (admin-only)", + files=[ + FileGen( + path="apps/api/src/db/schema.ts", + purpose=( + "ERWEITERT — behalte alle Tabellen. Füge `auditLog` (pgTable 'audit_log'): " + "id (uuid pk default random), userId (uuid references users id), " + "action (text notnull, e.g. 'create:customer'), resourceType (text), resourceId (text nullable), " + "metadata (text nullable JSON), createdAt (timestamp default now)." + ), + refs=["apps/api/src/db/schema.ts"], + ), + FileGen( + path="apps/api/src/routes/audit-log.ts", + purpose=( + "Fastify-Plugin /api/audit-log. GET / (admin only, returns last 100 entries desc by createdAt). " + "Auth via fastify.addHook preHandler." + ), + refs=["apps/api/src/routes/users.ts"], + ), + FileGen( + path="apps/web/src/pages/AuditLog.tsx", + purpose=( + "AuditLog-Page (admin-only). Tabelle: When / User / Action / Resource. " + "useQuery api.listAuditLog(). Empty/Loading states." + ), + refs=["apps/web/src/pages/AdminUsers.tsx"], + ), + ], + ), + Feature( + name="calendar-week-view", + description="Wochen-Kalender für Time-Entries", + files=[ + FileGen( + path="apps/web/src/pages/Calendar.tsx", + purpose=( + "Calendar-Page mit Week-View. 7-Spalten-Grid (Mon-Sun mit aktueller Woche). " + "Vor/zurück-Buttons für Wochen-Navigation. " + "useQuery api.listTimeEntries({from: weekStart, to: weekEnd}). " + "Pro Tag: Liste der Einträge mit Zeit + Description + Gesamt-Stunden des Tages oben. " + "Tailwind grid-cols-7." + ), + refs=["apps/web/src/lib/api.ts"], + ), + ], + ), + Feature( + name="keyboard-shortcuts", + description="Cmd/Ctrl-K Command-Palette für Navigation", + files=[ + FileGen( + path="apps/web/src/components/CommandPalette.tsx", + purpose=( + "Command-Palette Modal. Trigger: Cmd/Ctrl+K via window-keydown. " + "Zeigt Liste navigierbarer Items (Dashboard, TimeEntries, Customers, Projects, Calendar, Settings, Profile). " + "Fuzzy-Filter per Search-Input. Enter navigiert. Escape schließt. " + "Tailwind: fixed inset-0 bg-black/50 + centered card." + ), + ), + ], + ), + Feature( + name="api-client-phase6", + description="API um password + audit-log erweitert", + files=[ + FileGen( + path="apps/web/src/lib/api.ts", + purpose=( + "ERWEITERT — behalte ALLES. Füge: changePassword({oldPassword, newPassword}), listAuditLog()." + ), + refs=["apps/web/src/lib/api.ts"], + ), + ], + ), + Feature( + name="router-phase6", + description="App.tsx + Nav + routes/index für phase6", + files=[ + FileGen( + path="apps/api/src/routes/index.ts", + purpose=( + "ERWEITERT — behalte alle registrations. Füge auditLogRoutes mit prefix '/api/audit-log'." + ), + refs=["apps/api/src/routes/index.ts"], + ), + FileGen( + path="apps/web/src/App.tsx", + purpose=( + "ERWEITERT — füge /calendar (Calendar), /audit-log (AuditLog admin-only) hinzu. " + "Wrap CommandPalette global (mount once at root). Behalte alles bestehende." + ), + refs=["apps/web/src/App.tsx"], + ), + FileGen( + path="apps/web/src/components/Nav.tsx", + purpose=( + "ERWEITERT — Calendar-Link, Audit-Log-Link bei admin, kleines '⌘K' Hint rechts vom Logo." + ), + refs=["apps/web/src/components/Nav.tsx"], + ), + ], + ), +] + + +def load_state() -> dict: + if PHASE6_STATE.exists(): + return json.loads(PHASE6_STATE.read_text()) + return {"completed_features": [], "current_feature": None, "started_at": datetime.datetime.now().isoformat()} + + +def save_state(state: dict) -> None: + PHASE6_STATE.write_text(json.dumps(state, indent=2)) + + +async def main() -> int: + log_section("🚀 Phase-6 Codegen-Run gestartet") + state = load_state() + for feature in FEATURES: + if feature.name in state.get("completed_features", []): + continue + state["current_feature"] = feature.name; save_state(state) + try: + success = await run_feature_v2(feature) + if success: + state.setdefault("completed_features", []).append(feature.name) + else: + state.setdefault("attempted_features", []).append(feature.name) + save_state(state) + except Exception as e: + log(f"❌ {feature.name} crashed: {e}", level="ERROR") + state.setdefault("attempted_features", []).append(feature.name); save_state(state) + + log_section("Phase-6 Run beendet") + log(f"OK: {len(state.get('completed_features', []))}, Attempted: {len(state.get('attempted_features', []))}, Total: {len(FEATURES)}") + + # auto-migrate for audit_log table + import subprocess + log("Running db:generate + db:migrate…") + r = subprocess.run(["pnpm", "--filter", "api", "db:generate"], cwd=ROOT, capture_output=True, text=True, timeout=60) + log(f" db:generate rc={r.returncode}: {r.stdout[-200:]}") + r = subprocess.run(["pnpm", "--filter", "api", "db:migrate"], cwd=ROOT, capture_output=True, text=True, timeout=60) + log(f" db:migrate rc={r.returncode}: {r.stdout[-200:]}") + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main()))