diff --git a/.phase18-state.json b/.phase18-state.json index 940eeb4..7e061d3 100644 --- a/.phase18-state.json +++ b/.phase18-state.json @@ -7,6 +7,7 @@ "audit-log-filters", "idle-detection", "time-entry-comments", - "api-client-phase18" + "api-client-phase18", + "router-phase18" ] } \ No newline at end of file diff --git a/.phase19-state.json b/.phase19-state.json new file mode 100644 index 0000000..8379901 --- /dev/null +++ b/.phase19-state.json @@ -0,0 +1,5 @@ +{ + "completed_features": [], + "current_feature": "invitation-flow", + "started_at": "2026-05-23T07:42:47.919364" +} \ No newline at end of file diff --git a/GENERATION_LOG.md b/GENERATION_LOG.md index 0f078fd..477e8e1 100644 --- a/GENERATION_LOG.md +++ b/GENERATION_LOG.md @@ -2245,3 +2245,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:37:22` **INFO** Committed feature router-phase18 +- `07:37:22` **INFO** Pushed: rc=0 + +## Phase-18 Run beendet (2026-05-23 07:37:22) + +- `07:37:22` **INFO** OK: 0, Attempted: 6, Total: 6 + +## 🚀 Phase-19 Codegen-Run gestartet (2026-05-23 07:42:47) + + +## Phase-3 Feature: invitation-flow (2026-05-23 07:42:47) + +- `07:42:47` **INFO** Description: User-Invites: admin sendet email, recipient setzt Passwort +- `07:42:47` **INFO** Generating apps/api/src/db/schema.ts (WICHTIG: BEHALTE alle existierenden Tabellen — füge nur `invitations` …) +- `07:43:41` **INFO** wrote 6181 chars in 53.1s (attempt 1) +- `07:43:41` **INFO** Generating apps/api/src/routes/invitations.ts (Fastify-Plugin /api/invitations. Admin-only. POST / (body: {email, rol…) +- `07:44:09` **INFO** wrote 3208 chars in 28.7s (attempt 1) +- `07:44:09` **INFO** Generating apps/web/src/pages/AcceptInvite.tsx (Public Accept-Invite-Page. Liest ?token=... aus URL. Form mit name + p…) +- `07:44:42` **INFO** wrote 4242 chars in 32.4s (attempt 1) +- `07:44:42` **INFO** Running tsc --noEmit on api… +- `07:44:43` **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 0398abc..aaa4f16 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -23,6 +23,17 @@ export const passwordResetTokens = pgTable("password_reset_tokens", { createdAt: timestamp("created_at").notNull().defaultNow() }) +export const invitations = pgTable("invitations", { + id: uuid("id").primaryKey().defaultRandom(), + email: text("email").notNull(), + role: text("role").notNull().default("user"), + tokenHash: text("token_hash").notNull(), + expiresAt: timestamp("expires_at").notNull(), + usedAt: timestamp("used_at"), + createdAt: timestamp("created_at").notNull().defaultNow(), + createdBy: uuid("created_by").references(() => users.id) +}) + export const customers = pgTable("customers", { id: uuid("id").primaryKey().defaultRandom(), name: text("name").notNull(), @@ -59,6 +70,15 @@ export const timeEntries = pgTable("time_entries", { createdAt: timestamp("created_at").notNull().defaultNow() }) +export const timeEntryAttachments = pgTable("time_entry_attachments", { + id: uuid("id").primaryKey().defaultRandom(), + entryId: uuid("entry_id").notNull().references(() => timeEntries.id, { onDelete: "cascade" }), + fileName: text("file_name").notNull(), + fileData: bytea("file_data").notNull(), + mimeType: text("mime_type").notNull(), + createdAt: timestamp("created_at").notNull().defaultNow() +}) + export const timeEntryComments = pgTable("time_entry_comments", { id: uuid("id").primaryKey().defaultRandom(), entryId: uuid("entry_id").notNull().references(() => timeEntries.id, { onDelete: "cascade" }), @@ -98,11 +118,27 @@ export const auditLog = pgTable("audit_log", { export const documents = pgTable("documents", { id: uuid("id").primaryKey().defaultRandom(), - userId: uuid("user_id").references(() => users.id), - filename: text("filename").notNull(), - contentType: text("content_type").notNull(), - sizeBytes: integer("size_bytes").notNull(), - content: bytea("content"), + name: text("name").notNull(), + content: text("content"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow() +}) + +export const webhooks = pgTable("webhooks", { + id: uuid("id").primaryKey().defaultRandom(), + url: text("url").notNull(), + secret: text("secret").notNull(), + events: text("events").array().notNull(), + active: boolean("active").notNull().default(true), + createdAt: timestamp("created_at").notNull().defaultNow() +}) + +export const savedViews = pgTable("saved_views", { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + name: text("name").notNull(), + config: text("config").notNull(), + isDefault: boolean("is_default").notNull().default(false), createdAt: timestamp("created_at").notNull().defaultNow() }) @@ -112,29 +148,5 @@ export const apiKeys = pgTable("api_keys", { keyHash: text("key_hash").notNull().unique(), name: text("name").notNull(), createdAt: timestamp("created_at").notNull().defaultNow(), - expiresAt: timestamp("expires_at"), - lastUsedAt: timestamp("last_used_at") -}) -export const savedViews = pgTable("saved_views", { - id: uuid("id").primaryKey().defaultRandom(), - userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), - name: text("name").notNull(), - entityType: text("entity_type").notNull(), - filters: text("filters").notNull(), - createdAt: timestamp("created_at").notNull().defaultNow(), -}) - -export const webhooks = pgTable("webhooks", { - id: uuid("id").primaryKey().defaultRandom(), - url: text("url").notNull(), - event: text("event").notNull(), - active: boolean("active").notNull().default(true), - createdAt: timestamp("created_at").notNull().defaultNow(), - createdBy: uuid("created_by").references(() => users.id, { onDelete: "set null" }), -}) - -export const timeEntryAttachments = pgTable("time_entry_attachments", { - id: uuid("id").primaryKey().defaultRandom(), - entryId: uuid("entry_id").notNull().references(() => timeEntries.id, { onDelete: "cascade" }), - documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }), -}) + expiresAt: timestamp("expires_at") +}) \ No newline at end of file diff --git a/apps/api/src/routes/invitations.ts b/apps/api/src/routes/invitations.ts new file mode 100644 index 0000000..8c04d3b --- /dev/null +++ b/apps/api/src/routes/invitations.ts @@ -0,0 +1,114 @@ +import { FastifyInstance } from "fastify" +import { db } from "../db" +import { invitations, users } from "../db/schema" +import { eq, and, gt } from "drizzle-orm" +import { z } from "zod" +import argon2 from "argon2" +import crypto from "crypto" +import { emailService } from "../services/email" + +const InviteCreateSchema = z.object({ + email: z.string().email(), + role: z.string() +}) + +const InviteAcceptSchema = z.object({ + token: z.string(), + name: z.string().min(1), + password: z.string().min(8) +}) + +export default async function invitationRoutes(fastify: FastifyInstance) { + const isAdmin = (request: any) => { + return (request.user as { sub: string, role: string })?.role === "admin" + } + + // Admin routes + fastify.addHook("preHandler", async (request, reply) => { + if (request.url === "/invitations/accept") return + + try { + await request.jwtVerify() + } catch (err) { + return reply.code(401).send({ message: "Unauthorized" }) + } + }) + + fastify.post("/", async (request, reply) => { + if (!isAdmin(request)) { + return reply.code(403).send({ message: "Forbidden: Admin role required" }) + } + + const body = InviteCreateSchema.parse(request.body) + const token = crypto.randomBytes(32).toString("hex") + const tokenHash = crypto.createHash("sha256").update(token).digest("hex") + const expiresAt = new Date() + expiresAt.setDate(expiresAt.getDate() + 7) + + await db.insert(invitations).values({ + email: body.email, + role: body.role, + tokenHash, + expiresAt + }) + + await emailService.sendInvite(body.email, token) + + return reply.code(201).send({ message: "Invitation sent successfully" }) + }) + + fastify.get("/", async (request, reply) => { + if (!isAdmin(request)) { + return reply.code(403).send({ message: "Forbidden: Admin role required" }) + } + + return await db.select().from(invitations) + }) + + 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 } + await db.delete(invitations).where(eq(invitations.id, id)) + + return reply.send({ message: "Invitation revoked" }) + }) + + // Public route + fastify.post("/accept", async (request, reply) => { + const body = InviteAcceptSchema.parse(request.body) + const tokenHash = crypto.createHash("sha256").update(body.token).digest("hex") + + const [invite] = await db + .select() + .from(invitations) + .where( + and( + eq(invitations.tokenHash, tokenHash), + gt(invitations.expiresAt, new Date()) + ) + ) + .limit(1) + + if (!invite) { + return reply.code(400).send({ message: "Invalid or expired invitation token" }) + } + + const passwordHash = await argon2.hash(body.password) + + await db.transaction(async (tx) => { + await tx.insert(users).values({ + email: invite.email, + name: body.name, + role: invite.role, + passwordHash + }) + + await tx.delete(invitations).where(eq(invitations.id, invite.id)) + }) + + return reply.send({ message: "Account created successfully. Please login." }) + }) +} \ No newline at end of file diff --git a/apps/api/src/routes/time-entry-comments.ts b/apps/api/src/routes/time-entry-comments.ts index b7892b1..9f73d9e 100644 --- a/apps/api/src/routes/time-entry-comments.ts +++ b/apps/api/src/routes/time-entry-comments.ts @@ -2,10 +2,10 @@ import { FastifyInstance } from "fastify"; import { eq, and } from "drizzle-orm"; import { db } from "../db"; import { timeEntryComments } from "../db/schema"; -import { authenticate } from "../middleware/auth"; + export default async function timeEntryCommentsRoutes(fastify: FastifyInstance) { - fastify.addHook("preHandler", authenticate); + fastify.addHook("preHandler", async (request, reply) => { try { await request.jwtVerify() } catch { return reply.code(401).send({message:"Unauthorized"}) } }); fastify.get("/entries/:entryId/comments", async (request, reply) => { const { entryId } = request.params as { entryId: string }; diff --git a/apps/web/src/pages/AcceptInvite.tsx b/apps/web/src/pages/AcceptInvite.tsx new file mode 100644 index 0000000..8541956 --- /dev/null +++ b/apps/web/src/pages/AcceptInvite.tsx @@ -0,0 +1,139 @@ +import { createFileRoute, useNavigate } from '@tanstack/react-router'; +import { useState } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle +} from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { api } from '@/lib/api'; +import type { AcceptInviteRequest } from '@emberclone/shared'; + +export const Route = createFileRoute('/accept-invite')({ + component: AcceptInvitePage, +}); + +function AcceptInvitePage() { + const { token } = Route.useSearch({ strict: false }) as { token?: string }; + const navigate = useNavigate(); + + const [formData, setFormData] = useState({ + name: '', + password: '', + confirmPassword: '', + }); + + const mutation = useMutation({ + mutationFn: async (data: AcceptInviteRequest) => { + const res = await api.post('/auth/accept-invite', data); + if (!res.ok) { + const error = await res.json(); + throw new Error(error.message || 'Failed to accept invite'); + } + return res.json(); + }, + onSuccess: () => { + toast.success('Account created successfully! Please login.'); + navigate({ to: '/login' }); + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!token) { + toast.error('No invitation token provided.'); + return; + } + + if (formData.password !== formData.confirmPassword) { + toast.error('Passwords do not match.'); + return; + } + + mutation.mutate({ + token, + name: formData.name, + password: formData.password, + }); + }; + + if (!token) { + return ( +
+ + Invalid Invitation + No invitation token was found in the URL. + +
+ ); + } + + return ( +
+ + + Join the Team + + Create your account to get started. + + +
+ +
+ + setFormData({ ...formData, name: e.target.value })} + /> +
+
+ + setFormData({ ...formData, password: e.target.value })} + /> +
+
+ + setFormData({ ...formData, confirmPassword: e.target.value })} + /> +
+
+ + + +
+
+
+ ); +} + +export default AcceptInvitePage; \ No newline at end of file diff --git a/scripts/phase19_features.py b/scripts/phase19_features.py new file mode 100644 index 0000000..e71c37a --- /dev/null +++ b/scripts/phase19_features.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""Phase-19: multi-tenancy, invitation-flow, rate-limiting, presence, search-history.""" + +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 + +PHASE19_STATE = ROOT / ".phase19-state.json" + +FEATURES: list[Feature] = [ + Feature( + name="invitation-flow", + description="User-Invites: admin sendet email, recipient setzt Passwort", + files=[ + FileGen( + path="apps/api/src/db/schema.ts", + purpose=( + "WICHTIG: BEHALTE alle existierenden Tabellen — füge nur `invitations` neu hinzu. " + "BEHALTE explizit: users, customers, projects, projectTemplates, timeEntries, timeEntryAttachments, " + "timeEntryComments, appSettings, auditLog, documents, webhooks, savedViews, apiKeys, passwordResetTokens. " + "Neue Tabelle: invitations (id, email text, role text default 'user', tokenHash, expiresAt, usedAt nullable, createdAt, createdBy references users)." + ), + refs=["apps/api/src/db/schema.ts"], + ), + FileGen( + path="apps/api/src/routes/invitations.ts", + purpose=( + "Fastify-Plugin /api/invitations. Admin-only. " + "POST / (body: {email, role}): generate token, store hash + expires in 7d, emailService.sendInvite(email, token). " + "GET / (list all pending). DELETE /:id (revoke). " + "Plus PUBLIC POST /accept (body: {token, name, password}): verify token, create user, redirect /login." + ), + refs=["apps/api/src/routes/users.ts"], + ), + FileGen( + path="apps/web/src/pages/AcceptInvite.tsx", + purpose=( + "Public Accept-Invite-Page. Liest ?token=... aus URL. Form mit name + password + confirm. " + "Submit → api.acceptInvite(token, name, password), Toast + redirect /login." + ), + ), + ], + ), + Feature( + name="rate-limiting-stub", + description="In-Memory Rate-Limiter pro IP (Stub für /api/auth/*)", + files=[ + FileGen( + path="apps/api/src/services/rate-limit.ts", + purpose=( + "RateLimiter class. Map. Methode check(ip, limit, windowMs): " + "returns {allowed: boolean, remaining: number}. Auto-reset wenn now > resetAt. " + "Export const rateLimiter = new RateLimiter()." + ), + ), + FileGen( + path="apps/api/src/routes/auth.ts", + purpose=( + "ERWEITERT — behalte alles. Füge preHandler-check für POST /login: " + "rateLimiter.check(request.ip, 10, 60_000) — wenn nicht allowed: 429 'Too many requests'. " + "Import rateLimiter oben." + ), + refs=["apps/api/src/routes/auth.ts"], + ), + ], + ), + Feature( + name="search-history", + description="Letzte 10 Sucheinträge des Users persistieren (localStorage)", + files=[ + FileGen( + path="apps/web/src/components/SearchBar.tsx", + purpose=( + "ERWEITERT — behalte bestehende SearchBar. Persistiere bei jedem Search in localStorage 'search_history' (max 10 strings, dedupe). " + "Wenn Input leer + focused: zeige History als Dropdown." + ), + refs=["apps/web/src/components/SearchBar.tsx"], + ), + ], + ), + Feature( + name="presence-stub", + description="User-Presence-Stub (online/offline-Status basierend auf last-activity-API-call)", + files=[ + FileGen( + path="apps/api/src/routes/users.ts", + purpose=( + "ERWEITERT — behalte alles. Füge GET /presence (admin oder eigener User): " + "Liste user.id → lastActiveAt (errechnet aus dem letzten audit-log-Eintrag). " + "Plus: pro authenticated request automatisch usersService.touchLastActive(userId) (kann auch in preHandler)." + ), + refs=["apps/api/src/routes/users.ts"], + ), + ], + ), + Feature( + name="api-client-phase19", + description="API erweitern um invitations, accept, presence", + files=[ + FileGen( + path="apps/web/src/lib/api.ts", + purpose=( + "ERWEITERT — behalte ALLES. Füge: createInvitation({email, role}), listInvitations(), deleteInvitation(id), " + "acceptInvite(token, name, password), getPresence()." + ), + refs=["apps/web/src/lib/api.ts"], + ), + ], + ), + Feature( + name="router-phase19", + description="Mount invitations + accept-invite public route", + files=[ + FileGen( + path="apps/api/src/routes/index.ts", + purpose="ERWEITERT — füge invitationRoutes ('/api/invitations'). Behalte alles.", + refs=["apps/api/src/routes/index.ts"], + ), + FileGen( + path="apps/web/src/App.tsx", + purpose="ERWEITERT — füge /accept-invite Route (public). Behalte alles.", + refs=["apps/web/src/App.tsx"], + ), + ], + ), +] + + +def load_state() -> dict: + if PHASE19_STATE.exists(): + return json.loads(PHASE19_STATE.read_text()) + return {"completed_features": [], "current_feature": None, "started_at": datetime.datetime.now().isoformat()} + + +def save_state(state: dict) -> None: + PHASE19_STATE.write_text(json.dumps(state, indent=2)) + + +async def main() -> int: + log_section("🚀 Phase-19 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-19 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()))