diff --git a/.phase17-state.json b/.phase17-state.json index e59ca25..e9d0673 100644 --- a/.phase17-state.json +++ b/.phase17-state.json @@ -7,6 +7,7 @@ "batch-rename-projects", "customer-merge", "smart-filter-suggestions", - "time-entry-quick-edit" + "time-entry-quick-edit", + "api-client-phase17" ] } \ No newline at end of file diff --git a/.phase18-state.json b/.phase18-state.json new file mode 100644 index 0000000..e2561a8 --- /dev/null +++ b/.phase18-state.json @@ -0,0 +1,5 @@ +{ + "completed_features": [], + "current_feature": "api-key-management", + "started_at": "2026-05-23T07:29:44.977564" +} \ No newline at end of file diff --git a/GENERATION_LOG.md b/GENERATION_LOG.md index 936b857..007c5a3 100644 --- a/GENERATION_LOG.md +++ b/GENERATION_LOG.md @@ -2124,3 +2124,31 @@ src/index.ts(27,25): error TS2769: No overload matches this call. Overload 2 of 3, '(plugin: FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>, opts?: FastifyRegisterOptions<...> | undefined): FastifyInstance<...> & PromiseLike<...>', gave the following error. Argument of type 'Promise' is not assignable to parameter of type 'FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'. Type 'Promise' provides no match for the signature '(instance: FastifyInstance, FastifyBaseLogger, FastifyTy +- `07:29:02` **INFO** Committed feature api-client-phase17 +- `07:29:03` **INFO** Pushed: rc=0 + +## Phase-17 Run beendet (2026-05-23 07:29:03) + +- `07:29:03` **INFO** OK: 0, Attempted: 6, Total: 6 + +## 🚀 Phase-18 Codegen-Run gestartet (2026-05-23 07:29:44) + + +## Phase-3 Feature: api-key-management (2026-05-23 07:29:44) + +- `07:29:44` **INFO** Description: API-Keys für users (für REST-Programmzugriff) +- `07:29:44` **INFO** Generating apps/api/src/db/schema.ts (WICHTIG: behalte alle bestehenden Tabellen — füge nur `apiKeys` neu hi…) +- `07:30:28` **INFO** wrote 5032 chars in 43.0s (attempt 1) +- `07:30:28` **INFO** Generating apps/api/src/routes/api-keys.ts (Fastify-Plugin /api/api-keys. Auth required. GET / (list user's keys, …) +- `07:30:52` **INFO** wrote 2691 chars in 24.7s (attempt 1) +- `07:30:52` **INFO** Generating apps/web/src/pages/ApiKeys.tsx (ApiKeys-Page. Liste der eigenen Keys (name, lastUsed, status). Create-…) +- `07:31:43` **INFO** wrote 5845 chars in 51.0s (attempt 1) +- `07:31:43` **INFO** Running tsc --noEmit on api… +- `07:31:45` **WARN** tsc errors: +src/index.ts(27,25): error TS2769: No overload matches this call. + Overload 1 of 3, '(plugin: FastifyPluginCallback<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>, opts?: FastifyRegisterOptions<...> | undefined): FastifyInstance<...> & PromiseLike<...>', gave the following error. + Argument of type 'Promise' is not assignable to parameter of type 'FastifyPluginCallback<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'. + Type 'Promise' provides no match for the signature '(instance: FastifyInstance, FastifyBaseLogger, FastifyTypeProvider>, opts: { ...; }, done: (err?: Error | undefined) => void): void'. + Overload 2 of 3, '(plugin: FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>, opts?: FastifyRegisterOptions<...> | undefined): FastifyInstance<...> & PromiseLike<...>', gave the following error. + Argument of type 'Promise' is not assignable to parameter of type 'FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'. + Type 'Promise' provides no match for the signature '(instance: FastifyInstance, FastifyBaseLogger, FastifyTy diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index e720320..842a2f4 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -15,6 +15,14 @@ export const users = pgTable("users", { createdAt: timestamp("created_at").notNull().defaultNow() }) +export const passwordResetTokens = pgTable("password_reset_tokens", { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + tokenHash: text("token_hash").notNull(), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").notNull().defaultNow() +}) + export const customers = pgTable("customers", { id: uuid("id").primaryKey().defaultRandom(), name: text("name").notNull(), @@ -99,25 +107,18 @@ export const timeEntryAttachments = pgTable("time_entry_attachments", { export const webhooks = pgTable("webhooks", { id: uuid("id").primaryKey().defaultRandom(), url: text("url").notNull(), - event: text("event").notNull(), + secret: text("secret").notNull(), + events: text("events").array().notNull(), active: boolean("active").notNull().default(true), - createdAt: timestamp("created_at").notNull().defaultNow(), - createdBy: uuid("created_by").references(() => users.id) -}) - -export const savedViews = pgTable("saved_views", { - id: uuid("id").primaryKey().defaultRandom(), - userId: uuid("user_id").references(() => users.id), - name: text("name").notNull(), - entityType: text("entity_type").notNull(), - config: text("config").notNull(), createdAt: timestamp("created_at").notNull().defaultNow() }) -export const passwordResetTokens = pgTable("password_reset_tokens", { + +export const apiKeys = pgTable("api_keys", { id: uuid("id").primaryKey().defaultRandom(), userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), - tokenHash: text("token_hash").notNull(), - expiresAt: timestamp("expires_at").notNull(), - usedAt: timestamp("used_at"), + name: text("name").notNull(), + keyHash: text("key_hash").notNull().unique(), createdAt: timestamp("created_at").notNull().defaultNow(), -}) + lastUsedAt: timestamp("last_used_at"), + revokedAt: timestamp("revoked_at"), +}) \ No newline at end of file diff --git a/apps/api/src/routes/api-keys.ts b/apps/api/src/routes/api-keys.ts new file mode 100644 index 0000000..32956c0 --- /dev/null +++ b/apps/api/src/routes/api-keys.ts @@ -0,0 +1,103 @@ +import { FastifyInstance } from "fastify" +import { db } from "../db" +import { apiKeys } from "../db/schema" +import { eq, and } from "drizzle-orm" +import { z } from "zod" +import crypto from "crypto" +import argon2 from "argon2" + +const ApiKeyCreateSchema = z.object({ + name: z.string().min(1).max(50) +}) + +export default async function apiKeysRoutes(fastify: FastifyInstance) { + fastify.addHook("preHandler", async (request, reply) => { + try { + await request.jwtVerify() + } catch (err) { + return reply.code(401).send({ message: "Unauthorized" }) + } + }) + + fastify.get("/", 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 keys = await db + .select({ + id: apiKeys.id, + name: apiKeys.name, + createdAt: apiKeys.createdAt, + revokedAt: apiKeys.revokedAt, + lastUsedAt: apiKeys.lastUsedAt + }) + .from(apiKeys) + .where(eq(apiKeys.userId, userId)) + + return keys + }) + + fastify.post("/", 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 = ApiKeyCreateSchema.parse(request.body) + + // Generate a secure random key + const plaintextKey = `ek_${crypto.randomBytes(24).toString('hex')}` + const keyHash = await argon2.hash(plaintextKey) + + const [key] = await db + .insert(apiKeys) + .values({ + userId, + name: body.name, + keyHash: keyHash + }) + .returning({ + id: apiKeys.id, + name: apiKeys.name, + createdAt: apiKeys.createdAt + }) + + if (!key) { + return reply.code(500).send({ message: "Failed to create API key" }) + } + + // Return plaintext key only once + return { + ...key, + key: plaintextKey + } + }) + + fastify.delete("/:id", async (request, reply) => { + const userId = (request.user as { sub: string } | undefined)?.sub + const { id } = request.params as { id: string } + + if (!userId) { + return reply.code(401).send({ message: "User ID not found in token" }) + } + + const result = await db + .update(apiKeys) + .set({ revokedAt: new Date() }) + .where( + and( + eq(apiKeys.id, id), + eq(apiKeys.userId, userId) + ) + ) + .returning() + + if (result.length === 0) { + return reply.code(404).send({ message: "API key not found or unauthorized" }) + } + + return reply.send({ message: "API key revoked successfully" }) + }) +} \ No newline at end of file diff --git a/apps/web/src/pages/ApiKeys.tsx b/apps/web/src/pages/ApiKeys.tsx new file mode 100644 index 0000000..244b77c --- /dev/null +++ b/apps/web/src/pages/ApiKeys.tsx @@ -0,0 +1,149 @@ +import { useState } from "react" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { Key, Copy, Trash2, Plus, Check } from "lucide-react" +import { api } from "../lib/api" + +export default function ApiKeys() { + const queryClient = useQueryClient() + const [keyName, setKeyName] = useState("") + const [newKey, setNewKey] = useState<{ key: string; name: string } | null>(null) + const [copied, setCopied] = useState(false) + + const { data: keys, isLoading, isError } = useQuery({ + queryKey: ["api-keys"], + queryFn: () => api.listApiKeys() + }) + + const createMutation = useMutation({ + mutationFn: (name: string) => api.createApiKey(name), + onSuccess: (data) => { + setNewKey(data) + setKeyName("") + } + }) + + const revokeMutation = useMutation({ + mutationFn: (id: string) => api.revokeApiKey(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["api-keys"] }) + } + }) + + const handleCopy = async () => { + if (!newKey) return + await navigator.clipboard.writeText(newKey.key) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!keyName.trim()) return + createMutation.mutate(keyName) + } + + if (isLoading) return
Loading API keys...
+ if (isError) return
Error loading API keys.
+ + return ( +
+
+
+

API Keys

+

Manage your application access tokens

+
+
+ +
+ setKeyName(e.target.value)} + placeholder="Key name (e.g. Production, Local Dev)" + className="flex-1 px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + +
+ +
+ + + + + + + + + + + {keys?.length === 0 && ( + + + + )} + {keys?.map((k) => ( + + + + + + + ))} + +
NameLast UsedStatusActions
No API keys found.
{k.name} + {k.lastUsed ? new Date(k.lastUsed).toLocaleString() : "Never"} + + + {k.active ? "Active" : "Revoked"} + + + +
+
+ + {newKey && ( +
+
+
+

Your New API Key

+

+ Copy this key now. For security reasons, it will not be shown again. +

+
+ +
+ {newKey.key} + +
+ + +
+
+ )} +
+ ) +} \ No newline at end of file diff --git a/scripts/phase18_features.py b/scripts/phase18_features.py new file mode 100644 index 0000000..449b980 --- /dev/null +++ b/scripts/phase18_features.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Phase-18: invoice-pdf-real, api-key-management, audit-log-filters, idle-detection, time-entry-comments.""" + +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 + +PHASE18_STATE = ROOT / ".phase18-state.json" + +FEATURES: list[Feature] = [ + Feature( + name="api-key-management", + description="API-Keys für users (für REST-Programmzugriff)", + files=[ + FileGen( + path="apps/api/src/db/schema.ts", + purpose=( + "WICHTIG: behalte alle bestehenden Tabellen — füge nur `apiKeys` neu hinzu. " + "Behalte vor allem `passwordResetTokens`. " + "Neue Tabelle: apiKeys (id, userId references users, name, keyHash, createdAt, lastUsedAt nullable, revokedAt nullable)." + ), + refs=["apps/api/src/db/schema.ts"], + ), + FileGen( + path="apps/api/src/routes/api-keys.ts", + purpose=( + "Fastify-Plugin /api/api-keys. Auth required. " + "GET / (list user's keys, ohne keyHash), POST / (generate random key, return plaintext einmalig, store hash), " + "DELETE /:id (revoke = set revokedAt). Use FastifyInstance." + ), + refs=["apps/api/src/routes/users.ts"], + ), + FileGen( + path="apps/web/src/pages/ApiKeys.tsx", + purpose=( + "ApiKeys-Page. Liste der eigenen Keys (name, lastUsed, status). " + "Create-Form mit name. Bei create: Modal zeigt key PLAINTEXT einmal (Copy-Button)." + ), + refs=["apps/web/src/pages/Customers.tsx"], + ), + ], + ), + Feature( + name="audit-log-filters", + description="Audit-Log mit Filter (user, action, date-range)", + files=[ + FileGen( + path="apps/api/src/routes/audit-log.ts", + purpose=( + "ERWEITERT — behalte GET /. Füge Query-Params ?userId=, ?action=, ?from=, ?to=. " + "WHERE clauses mit drizzle and(). Behalte admin-only-Logik." + ), + refs=["apps/api/src/routes/audit-log.ts"], + ), + FileGen( + path="apps/web/src/pages/AuditLog.tsx", + purpose=( + "ERWEITERT — behalte Tabelle. Füge Filter-Bar: User-Select, Action-Search, Date-Range. " + "Refetch bei Filter-Change." + ), + refs=["apps/web/src/pages/AuditLog.tsx"], + ), + ], + ), + Feature( + name="idle-detection", + description="Idle-Detection: nach 5min Inaktivität Active-Timer pausieren-prompt", + files=[ + FileGen( + path="apps/web/src/components/IdleDetector.tsx", + purpose=( + "IdleDetector-Component. Listens auf mousemove, keypress. Resettet Timer. " + "Nach 5min ohne Activity UND wenn ActiveTimer läuft: zeigt Modal 'Du warst 5min inaktiv. " + "Timer pausieren oder weiterlaufen lassen?'. Bei pause: api.stopTimeEntry." + ), + refs=["apps/web/src/components/ActiveTimer.tsx"], + ), + FileGen( + path="apps/web/src/App.tsx", + purpose="ERWEITERT — mount global im Root-Route. Behalte alles.", + refs=["apps/web/src/App.tsx"], + ), + ], + ), + Feature( + name="time-entry-comments", + description="Kommentare/Notes pro TimeEntry als Thread", + files=[ + FileGen( + path="apps/api/src/db/schema.ts", + purpose=( + "WICHTIG: behalte ALLE bestehenden Tabellen (vor allem passwordResetTokens, apiKeys). " + "Füge `timeEntryComments` Tabelle: id, entryId references timeEntries, userId references users, " + "body text, createdAt." + ), + refs=["apps/api/src/db/schema.ts"], + ), + FileGen( + path="apps/api/src/routes/time-entry-comments.ts", + purpose=( + "Fastify-Plugin /api/time-entry-comments. Auth required. " + "GET /entries/:entryId/comments (list), POST /entries/:entryId/comments (body: {body}), DELETE /:id. " + "User kann nur eigene löschen (außer admin)." + ), + ), + ], + ), + Feature( + name="api-client-phase18", + description="API um apikeys + audit-filters + comments erweitern", + files=[ + FileGen( + path="apps/web/src/lib/api.ts", + purpose=( + "ERWEITERT — behalte ALLES. Füge: listApiKeys, createApiKey(name), revokeApiKey(id), " + "listAuditLog(filters), listEntryComments(entryId), createEntryComment(entryId, body), deleteEntryComment(id)." + ), + refs=["apps/web/src/lib/api.ts"], + ), + ], + ), + Feature( + name="router-phase18", + description="Mount + UI-Routen für api-keys, comments", + files=[ + FileGen( + path="apps/api/src/routes/index.ts", + purpose="ERWEITERT — füge apiKeyRoutes ('/api/api-keys') + timeEntryCommentRoutes ('/api/time-entry-comments').", + refs=["apps/api/src/routes/index.ts"], + ), + FileGen( + path="apps/web/src/App.tsx", + purpose="ERWEITERT — füge /api-keys Route. Behalte alles.", + refs=["apps/web/src/App.tsx"], + ), + ], + ), +] + + +def load_state() -> dict: + if PHASE18_STATE.exists(): + return json.loads(PHASE18_STATE.read_text()) + return {"completed_features": [], "current_feature": None, "started_at": datetime.datetime.now().isoformat()} + + +def save_state(state: dict) -> None: + PHASE18_STATE.write_text(json.dumps(state, indent=2)) + + +async def main() -> int: + log_section("🚀 Phase-18 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-18 Run beendet") + log(f"OK: {len(state.get('completed_features', []))}, Attempted: {len(state.get('attempted_features', []))}, Total: {len(FEATURES)}") + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main()))