feat(password-change): Change-Password Endpoint + Form in Profile [tsc:ok]
This commit is contained in:
parent
573e2c9680
commit
1e1f47023c
5
.phase6-state.json
Normal file
5
.phase6-state.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"completed_features": [],
|
||||
"current_feature": "password-change",
|
||||
"started_at": "2026-05-23T05:30:16.203066"
|
||||
}
|
||||
@ -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 ✓
|
||||
|
||||
@ -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" })
|
||||
|
||||
@ -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 (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@ -53,15 +87,15 @@ export default function Profile() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto py-10 px-4">
|
||||
<div className="max-w-2xl mx-auto py-10 px-4 space-y-8">
|
||||
{/* Profile Card */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-200 bg-slate-50/50">
|
||||
<h1 className="text-xl font-semibold text-slate-800">Mein Profil</h1>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
<form onSubmit={handleProfileSubmit} className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
{/* Name Field */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-700">Vollständiger Name</label>
|
||||
<input
|
||||
@ -73,7 +107,6 @@ export default function Profile() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email Field (Readonly) */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-700">E-Mail Adresse</label>
|
||||
<input
|
||||
@ -84,7 +117,6 @@ export default function Profile() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Role Badge */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-700">Benutzerrolle</label>
|
||||
<div className="flex items-center">
|
||||
@ -109,6 +141,64 @@ export default function Profile() {
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Password Card */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-200 bg-slate-50/50">
|
||||
<h2 className="text-xl font-semibold text-slate-800">Passwort ändern</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handlePasswordSubmit} className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-700">Aktuelles Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
value={passwords.oldPassword}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-700">Neues Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
value={passwords.newPassword}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-700">Passwort bestätigen</label>
|
||||
<input
|
||||
type="password"
|
||||
value={passwords.confirmPassword}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={passwordMutation.isPending}
|
||||
className="px-4 py-2 bg-slate-900 text-white rounded-md hover:bg-slate-800 disabled:opacity-50 transition-colors font-medium"
|
||||
>
|
||||
{passwordMutation.isPending ? 'Ändert...' : 'Passwort aktualisieren'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
191
scripts/phase6_features.py
Normal file
191
scripts/phase6_features.py
Normal file
@ -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()))
|
||||
Loading…
Reference in New Issue
Block a user