diff --git a/.phase4-state.json b/.phase4-state.json new file mode 100644 index 0000000..e27e6fc --- /dev/null +++ b/.phase4-state.json @@ -0,0 +1,5 @@ +{ + "completed_features": [], + "current_feature": "admin-user-management", + "started_at": "2026-05-23T05:10:51.482879" +} \ No newline at end of file diff --git a/GENERATION_LOG.md b/GENERATION_LOG.md index a774827..844b4a5 100644 --- a/GENERATION_LOG.md +++ b/GENERATION_LOG.md @@ -455,3 +455,19 @@ undefined ## Phase-3 Run beendet (2026-05-23 05:01:13) - `05:01:13` **INFO** OK: 4, Attempted: 3, Total: 7 + +## 🚀 Phase-4 Codegen-Run gestartet (2026-05-23 05:10:47) + +- `05:10:47` **INFO** Features: 6 +- `05:10:47` **INFO** Ensuring recharts dep… +- `05:10:51` **INFO** recharts install rc=0 + +## Phase-3 Feature: admin-user-management (2026-05-23 05:10:51) + +- `05:10:51` **INFO** Description: Admin-only CRUD /api/users + Settings-Page für User-Verwaltung +- `05:10:51` **INFO** Generating apps/api/src/routes/users.ts (ERWEITERTE users.ts — behalte GET /me + PATCH /me. Füge hinzu (alle nu…) +- `05:11:24` **INFO** wrote 3491 chars in 33.4s (attempt 1) +- `05:11:24` **INFO** Generating apps/web/src/pages/AdminUsers.tsx (Admin-User-Management Page. Liste aller User (TanStack Query). Inline-…) +- `05:12:47` **INFO** wrote 9929 chars in 82.5s (attempt 1) +- `05:12:47` **INFO** Running tsc --noEmit on api… +- `05:12:48` **INFO** tsc clean ✓ diff --git a/apps/api/src/routes/users.ts b/apps/api/src/routes/users.ts index 7e261e7..ffa46d7 100644 --- a/apps/api/src/routes/users.ts +++ b/apps/api/src/routes/users.ts @@ -3,9 +3,18 @@ import { db } from "../db" import { users } from "../db/schema" import { eq } from "drizzle-orm" import { z } from "zod" +import argon2 from "argon2" const UserUpdateSchema = z.object({ - name: z.string().min(1).optional() + name: z.string().min(1).optional(), + role: z.string().optional() +}) + +const UserCreateSchema = z.object({ + email: z.string().email(), + name: z.string().min(1), + role: z.string(), + password: z.string().min(8) }) export default async function userRoutes(fastify: FastifyInstance) { @@ -17,6 +26,10 @@ export default async function userRoutes(fastify: FastifyInstance) { } }) + const isAdmin = (request: any) => { + return (request.user as { sub: string, role: string })?.role === "admin" + } + fastify.get("/me", async (request, reply) => { const userId = (request.user as { sub: string } | undefined)?.sub @@ -57,4 +70,73 @@ export default async function userRoutes(fastify: FastifyInstance) { return user }) + + fastify.get("/", async (request, reply) => { + if (!isAdmin(request)) { + return reply.code(403).send({ message: "Forbidden: Admin role required" }) + } + + return await db.select().from(users) + }) + + fastify.post("/", async (request, reply) => { + if (!isAdmin(request)) { + return reply.code(403).send({ message: "Forbidden: Admin role required" }) + } + + const body = UserCreateSchema.parse(request.body) + const passwordHash = await argon2.hash(body.password) + + const [user] = await db + .insert(users) + .values({ + email: body.email, + name: body.name, + role: body.role, + passwordHash + }) + .returning() + + return reply.code(201).send(user) + }) + + fastify.patch("/:id", async (request, reply) => { + if (!isAdmin(request)) { + return reply.code(403).send({ message: "Forbidden: Admin role required" }) + } + + const { id } = request.params as { id: string } + const body = UserUpdateSchema.parse(request.body) + + const [user] = await db + .update(users) + .set(body) + .where(eq(users.id, id)) + .returning() + + if (!user) { + return reply.code(404).send({ message: "User not found" }) + } + + return user + }) + + fastify.delete("/:id", async (request, reply) => { + if (!isAdmin(request)) { + return reply.code(403).send({ message: "Forbidden: Admin role required" }) + } + + const { id } = request.params as { id: string } + + const result = await db + .delete(users) + .where(eq(users.id, id)) + .returning() + + if (result.length === 0) { + return reply.code(404).send({ message: "User not found" }) + } + + return reply.code(204).send() + }) } \ No newline at end of file diff --git a/apps/web/package.json b/apps/web/package.json index 64bdde3..df3025e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,7 +14,8 @@ "@tanstack/react-router": "^1.62.7", "lucide-react": "^1.16.0", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "recharts": "^3.8.1" }, "devDependencies": { "@types/react": "^18.3.10", diff --git a/apps/web/src/pages/AdminUsers.tsx b/apps/web/src/pages/AdminUsers.tsx new file mode 100644 index 0000000..354e787 --- /dev/null +++ b/apps/web/src/pages/AdminUsers.tsx @@ -0,0 +1,244 @@ +import { useState } from "react" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { api } from "../lib/api" +import type { User } from "@emberclone/shared" + +export default function AdminUsers() { + const queryClient = useQueryClient() + const [me, setMe] = useState<{ role: string } | null>(null) + const [isMeLoading, setIsMeLoading] = useState(true) + + const [newUser, setNewUser] = useState({ email: "", name: "", role: "user", password: "" }) + const [editingUser, setEditingUser] = useState(null) + + // Check if current user is admin + const { data: currentUser } = useQuery({ + queryKey: ["me"], + queryFn: () => api.getMe(), + onSuccess: (data) => { + setMe(data) + setIsMeLoading(false) + } + }) + + const { data: users, isLoading, isError } = useQuery({ + queryKey: ["users"], + queryFn: () => api.listUsers(), + enabled: !!currentUser && currentUser.role === "admin" + }) + + const createMutation = useMutation({ + mutationFn: (data: any) => api.createUser(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["users"] }) + setNewUser({ email: "", name: "", role: "user", password: "" }) + } + }) + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: string; data: any }) => api.updateUser(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["users"] }) + setEditingUser(null) + } + }) + + const deleteMutation = useMutation({ + mutationFn: (id: string) => api.deleteUser(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["users"] }) + } + }) + + if (isMeLoading || !currentUser) { + return
Loading permissions...
+ } + + if (currentUser.role !== "admin") { + return ( +
+
+

Forbidden

+

You do not have administrative privileges to access this page.

+
+
+ ) + } + + const handleCreateSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!newUser.email || !newUser.password) return + createMutation.mutate(newUser) + } + + const handleUpdateSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!editingUser) return + updateMutation.mutate({ + id: editingUser.id, + data: { name: editingUser.name, role: editingUser.role } + }) + } + + if (isLoading) return
Loading users...
+ if (isError) return
Error loading users.
+ + return ( +
+
+

User Management

+

Manage system users and access levels

+
+ +
+

Create New User

+
+
+ + setNewUser({ ...newUser, email: e.target.value })} + /> +
+
+ + setNewUser({ ...newUser, name: e.target.value })} + /> +
+
+ + +
+
+ +
+ setNewUser({ ...newUser, password: e.target.value })} + /> + +
+
+
+
+ +
+ + + + + + + + + + {users && users.map((user: User) => ( + + + + + + ))} + +
UserRoleActions
+
{user.name}
+
{user.email}
+
+ + {user.role} + + + + +
+
+ + {editingUser && ( +
+
+

Edit User

+
+
+ + setEditingUser({ ...editingUser, name: e.target.value })} + /> +
+
+ + +
+
+ + +
+
+
+
+ )} +
+ ) +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98396dd..5f0f73e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,6 +81,9 @@ importers: react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + recharts: + specifier: ^3.8.1 + version: 3.8.1(@types/react@18.3.29)(react-dom@18.3.1)(react-is@19.2.6)(react@18.3.1)(redux@5.0.1) devDependencies: '@types/react': specifier: ^18.3.10 @@ -1362,6 +1365,27 @@ packages: resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} dev: false + /@reduxjs/toolkit@2.12.0(react-redux@9.3.0)(react@18.3.1): + resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.8 + react: 18.3.1 + react-redux: 9.3.0(@types/react@18.3.29)(react@18.3.1)(redux@5.0.1) + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + dev: false + /@rolldown/pluginutils@1.0.0-beta.27: resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} dev: true @@ -1566,6 +1590,14 @@ packages: dev: true optional: true + /@standard-schema/spec@1.1.0: + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + dev: false + + /@standard-schema/utils@0.3.0: + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + dev: false + /@tanstack/history@1.162.0: resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==} engines: {node: '>=20.19'} @@ -1654,6 +1686,48 @@ packages: '@babel/types': 7.29.0 dev: true + /@types/d3-array@3.2.2: + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + dev: false + + /@types/d3-color@3.1.3: + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + dev: false + + /@types/d3-ease@3.0.2: + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + dev: false + + /@types/d3-interpolate@3.0.4: + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + dependencies: + '@types/d3-color': 3.1.3 + dev: false + + /@types/d3-path@3.1.1: + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + dev: false + + /@types/d3-scale@4.0.9: + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + dependencies: + '@types/d3-time': 3.0.4 + dev: false + + /@types/d3-shape@3.1.8: + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + dependencies: + '@types/d3-path': 3.1.1 + dev: false + + /@types/d3-time@3.0.4: + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + dev: false + + /@types/d3-timer@3.0.2: + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + dev: false + /@types/estree@1.0.8: resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} dev: true @@ -1672,7 +1746,6 @@ packages: /@types/prop-types@15.7.15: resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} - dev: true /@types/react-dom@18.3.7(@types/react@18.3.29): resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} @@ -1687,7 +1760,10 @@ packages: dependencies: '@types/prop-types': 15.7.15 csstype: 3.2.3 - dev: true + + /@types/use-sync-external-store@0.0.6: + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + dev: false /@vitejs/plugin-react@4.7.0(vite@5.4.21): resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} @@ -1894,6 +1970,11 @@ packages: wrap-ansi: 7.0.0 dev: true + /clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + dev: false + /color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1949,7 +2030,77 @@ packages: /csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - dev: true + + /d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + dependencies: + internmap: 2.0.3 + dev: false + + /d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + dev: false + + /d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + dev: false + + /d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + dev: false + + /d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + dependencies: + d3-color: 3.1.0 + dev: false + + /d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + dev: false + + /d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + dev: false + + /d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + dependencies: + d3-path: 3.1.0 + dev: false + + /d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + dependencies: + d3-time: 3.1.0 + dev: false + + /d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + dependencies: + d3-array: 3.2.4 + dev: false + + /d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + dev: false /debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} @@ -1963,6 +2114,10 @@ packages: ms: 2.1.3 dev: true + /decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + dev: false + /didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} dev: true @@ -2098,6 +2253,10 @@ packages: engines: {node: '>= 0.4'} dev: true + /es-toolkit@1.46.1: + resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==} + dev: false + /esbuild-register@3.6.0(esbuild@0.19.12): resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} peerDependencies: @@ -2240,6 +2399,10 @@ packages: engines: {node: '>=6'} dev: true + /eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + dev: false + /fast-content-type-parse@1.1.0: resolution: {integrity: sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==} dev: false @@ -2441,10 +2604,23 @@ packages: function-bind: 1.1.2 dev: true + /immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + dev: false + + /immer@11.1.8: + resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==} + dev: false + /inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} dev: false + /internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + dev: false + /ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -2887,6 +3063,29 @@ packages: scheduler: 0.23.2 dev: false + /react-is@19.2.6: + resolution: {integrity: sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==} + dev: false + + /react-redux@9.3.0(@types/react@18.3.29)(react@18.3.1)(redux@5.0.1): + resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + dependencies: + '@types/react': 18.3.29 + '@types/use-sync-external-store': 0.0.6 + react: 18.3.1 + redux: 5.0.1 + use-sync-external-store: 1.6.0(react@18.3.1) + dev: false + /react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -2917,6 +3116,45 @@ packages: engines: {node: '>= 12.13.0'} dev: false + /recharts@3.8.1(@types/react@18.3.29)(react-dom@18.3.1)(react-is@19.2.6)(react@18.3.1)(redux@5.0.1): + resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + dependencies: + '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0)(react@18.3.1) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.46.1 + eventemitter3: 5.0.4 + immer: 10.2.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-is: 19.2.6 + react-redux: 9.3.0(@types/react@18.3.29)(react@18.3.1)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@18.3.1) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + dev: false + + /redux-thunk@3.1.0(redux@5.0.1): + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + dependencies: + redux: 5.0.1 + dev: false + + /redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + dev: false + /require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -2927,6 +3165,10 @@ packages: engines: {node: '>=0.10.0'} dev: false + /reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + dev: false + /resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} dev: true @@ -3203,6 +3445,10 @@ packages: real-require: 0.2.0 dev: false + /tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + dev: false + /tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} @@ -3278,6 +3524,25 @@ packages: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} dev: true + /victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + dev: false + /vite@5.4.21: resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} engines: {node: ^18.0.0 || >=20.0.0} diff --git a/scripts/__pycache__/phase3_features.cpython-312.pyc b/scripts/__pycache__/phase3_features.cpython-312.pyc new file mode 100644 index 0000000..2e42aa1 Binary files /dev/null and b/scripts/__pycache__/phase3_features.cpython-312.pyc differ diff --git a/scripts/phase4_features.py b/scripts/phase4_features.py new file mode 100644 index 0000000..785ee8e --- /dev/null +++ b/scripts/phase4_features.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Phase-4: admin-page, CSV export, mobile-fix, error-boundary, dashboard-charts.""" + +from __future__ import annotations + +import asyncio +import datetime +import json +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from phase2_features import ( # noqa: E402 + Feature, FileGen, ROOT, log, log_section, git, gemma, + strip_codefence, load_existing, tsc_check, MAX_RETRIES, +) +from phase3_features import HARDENING_HINTS, generate_with_hints, run_feature_v2 # noqa: E402 + + +PHASE4_STATE = ROOT / ".phase4-state.json" + + +FEATURES: list[Feature] = [ + Feature( + name="admin-user-management", + description="Admin-only CRUD /api/users + Settings-Page für User-Verwaltung", + files=[ + FileGen( + path="apps/api/src/routes/users.ts", + purpose=( + "ERWEITERTE users.ts — behalte GET /me + PATCH /me. " + "Füge hinzu (alle nur für role='admin'): " + "GET / (list all users), POST / (create with email+name+role+password — argon2-hash), " + "PATCH /:id (update name, role), DELETE /:id (cascade time-entries). " + "Verwende `request.user as { sub: string, role: string }` für payload-access. " + "403 wenn role !== 'admin'." + ), + refs=["apps/api/src/routes/users.ts", "apps/api/src/db/schema.ts"], + ), + FileGen( + path="apps/web/src/pages/AdminUsers.tsx", + purpose=( + "Admin-User-Management Page. Liste aller User (TanStack Query). " + "Inline-Form zum Anlegen (email, name, role-select, password). " + "Edit-Button pro Row → Modal mit name/role-Update. Delete-Button mit confirm. " + "Verwende api.listUsers(), api.createUser(), api.updateUser(), api.deleteUser(). " + "Wenn current user role !== 'admin': zeige Forbidden-Message statt Inhalt." + ), + refs=["apps/web/src/pages/Customers.tsx", "apps/web/src/lib/api.ts"], + ), + ], + ), + Feature( + name="csv-export-time-entries", + description="CSV-Export-Endpoint + Button in TimeEntries-Page", + files=[ + FileGen( + path="apps/api/src/routes/time-entries.ts", + purpose=( + "ERWEITERTE time-entries.ts — behalte alle bestehenden Routes (CRUD + running/start/stop). " + "Füge hinzu: GET /export.csv?from=...&to=... → returnt text/csv content mit Spalten " + "id,description,projectId,startTime,endTime,durationMinutes. " + "Header content-type: text/csv, content-disposition: attachment; filename=time-entries.csv. " + "Auth erforderlich, user sieht nur eigene (außer admin)." + ), + refs=["apps/api/src/routes/time-entries.ts"], + ), + FileGen( + path="apps/web/src/pages/TimeEntries.tsx", + purpose=( + "ERWEITERT — behalte Form + Filter + Liste. Füge Export-Button im Filter-Bar: " + "ruft `window.location.href = \\'/api/time-entries/export.csv?from=...&to=...\\'` " + "mit aktuellen Filter-Werten. Stil: outline-Button rechts neben To-Date." + ), + refs=["apps/web/src/pages/TimeEntries.tsx"], + ), + ], + ), + Feature( + name="error-boundary", + description="React ErrorBoundary + global wrapping in App.tsx", + files=[ + FileGen( + path="apps/web/src/components/ErrorBoundary.tsx", + purpose=( + "React-ErrorBoundary class-component. Fängt unkaufgefangene Render-Errors. " + "Zeigt schöne Fehlerseite mit message + reload-button. " + "Verwende componentDidCatch + getDerivedStateFromError. " + "Tailwind, centered, max-w-md." + ), + ), + FileGen( + path="apps/web/src/App.tsx", + purpose=( + "ERWEITERT — wrap RouterProvider in . Behalte ToastProvider + alle Routes. " + "Importiere ErrorBoundary von ./components/ErrorBoundary." + ), + refs=["apps/web/src/App.tsx"], + ), + ], + ), + Feature( + name="dashboard-charts", + description="Dashboard mit Stunden-Chart (recharts)", + files=[ + FileGen( + path="apps/web/src/pages/Dashboard.tsx", + purpose=( + "ÜBERARBEITETER Dashboard. Behalte die 3 Karten (Heute/Woche/Aktive Projekte). " + "Füge unten ein BarChart hinzu (recharts BarChart + Bar) mit den letzten 7 Tagen — " + "x-axis: dayName, y-axis: hours. Daten aus listTimeEntries letzte 7 Tage, " + "group-by-day per useMemo. Card-Wrapper mit Tailwind." + ), + refs=["apps/web/src/pages/Dashboard.tsx"], + ), + ], + ), + Feature( + name="api-client-phase4", + description="API-Client um Admin-User + Export-URL ergänzt", + files=[ + FileGen( + path="apps/web/src/lib/api.ts", + purpose=( + "FINAL+ - behalte ALLES aus vorher. Füge hinzu: " + "listUsers(), createUser({email,name,role,password}), updateUser(id, {name,role}), deleteUser(id), " + "Alle Admin-Endpoints → /api/users." + ), + refs=["apps/web/src/lib/api.ts"], + ), + ], + ), + Feature( + name="router-with-admin", + description="App.tsx +/admin route + Nav admin-link bei admin-role", + files=[ + FileGen( + path="apps/web/src/App.tsx", + purpose=( + "ERWEITERT — füge Route /admin (AdminUsers component) hinzu. Auth-Check: zusätzlich role='admin' (sonst redirect /). " + "Behalte ErrorBoundary + ToastProvider + alle Routes." + ), + refs=["apps/web/src/App.tsx", "apps/web/src/pages/AdminUsers.tsx"], + ), + FileGen( + path="apps/web/src/components/Nav.tsx", + purpose=( + "ERWEITERT — Nav zeigt Admin-Link nur wenn current user role='admin'. " + "useQuery(['me'], api.getMe) → wenn data.role==='admin', render Admin. " + "Behalte alle anderen Links + Logout." + ), + refs=["apps/web/src/components/Nav.tsx"], + ), + ], + ), +] + + +def load_state() -> dict: + if PHASE4_STATE.exists(): + return json.loads(PHASE4_STATE.read_text()) + return {"completed_features": [], "current_feature": None, "started_at": datetime.datetime.now().isoformat()} + + +def save_state(state: dict) -> None: + PHASE4_STATE.write_text(json.dumps(state, indent=2)) + + +async def main() -> int: + log_section(f"🚀 Phase-4 Codegen-Run gestartet") + log(f"Features: {len(FEATURES)}") + + # ensure recharts is installed for dashboard-charts + log("Ensuring recharts dep…") + import subprocess + r = subprocess.run(["pnpm", "--filter", "web", "add", "recharts"], + cwd=ROOT, capture_output=True, text=True, timeout=120) + log(f" recharts install rc={r.returncode}") + + state = load_state() + for feature in FEATURES: + if feature.name in state.get("completed_features", []): + log(f"⏭ Skip {feature.name}") + 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 {feature.name} crashed: {e}", level="ERROR") + state.setdefault("attempted_features", []).append(feature.name) + save_state(state) + + log_section("Phase-4 Run beendet") + log(f"OK: {len(state.get('completed_features', []))}, " + f"Attempted: {len(state.get('attempted_features', []))}, " + f"Total: {len(FEATURES)}") + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main()))