Client-Portal
++ Wähle dein Unternehmen. Du siehst ausschließlich deine eigenen Projekte, Rechnungen, Tickets und Freigaben — projektbezogen isoliert (PRD §5.6). +
+diff --git a/aos-shots/v16-portal-approval-dialog.png b/aos-shots/v16-portal-approval-dialog.png
new file mode 100644
index 0000000..2ac7824
Binary files /dev/null and b/aos-shots/v16-portal-approval-dialog.png differ
diff --git a/aos-shots/v16-portal-approval-done.png b/aos-shots/v16-portal-approval-done.png
new file mode 100644
index 0000000..2ac7824
Binary files /dev/null and b/aos-shots/v16-portal-approval-done.png differ
diff --git a/aos-shots/v16-portal-approvals.png b/aos-shots/v16-portal-approvals.png
new file mode 100644
index 0000000..2ac7824
Binary files /dev/null and b/aos-shots/v16-portal-approvals.png differ
diff --git a/aos-shots/v16-portal-invoices.png b/aos-shots/v16-portal-invoices.png
new file mode 100644
index 0000000..2ac7824
Binary files /dev/null and b/aos-shots/v16-portal-invoices.png differ
diff --git a/aos-shots/v16-portal-login.png b/aos-shots/v16-portal-login.png
new file mode 100644
index 0000000..b09aed7
Binary files /dev/null and b/aos-shots/v16-portal-login.png differ
diff --git a/aos-shots/v16-portal-overview.png b/aos-shots/v16-portal-overview.png
new file mode 100644
index 0000000..2ac7824
Binary files /dev/null and b/aos-shots/v16-portal-overview.png differ
diff --git a/aos-verify12.sh b/aos-verify12.sh
new file mode 100644
index 0000000..d01926b
--- /dev/null
+++ b/aos-verify12.sh
@@ -0,0 +1,122 @@
+#!/usr/bin/env bash
+# Verify Client Portal (real project-scoped data + real IP evidence)
+set -u
+cd /home/z/my-project
+BASE=http://127.0.0.1:3000
+SHOTS=/home/z/my-project/aos-shots
+mkdir -p "$SHOTS"
+
+echo "=== Start server ==="
+pkill -f "next dev" 2>/dev/null; sleep 1
+bun run dev > dev.log 2>&1 &
+SRV=$!
+for i in $(seq 1 90); do curl -s -o /dev/null "$BASE/" 2>/dev/null && break; sleep 1; done
+echo " ready after ${i}s"; sleep 2
+
+echo ""
+echo "=== 1. Portal company list ==="
+curl -s "$BASE/api/aos/portal/companies" | python3 -c "
+import sys,json
+d=json.load(sys.stdin)
+print(f' companies: {len(d[\"companies\"])}')
+for c in d['companies'][:3]:
+ print(f' {c[\"name\"]}: {c[\"projectCount\"]} projects, {c[\"invoiceCount\"]} invoices')
+" 2>/dev/null
+
+echo ""
+echo "=== 2. Portal company detail (project-scoped) ==="
+COMPANY_ID=$(curl -s "$BASE/api/aos/portal/companies" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['companies'][0]['id'])" 2>/dev/null)
+echo " company id: $COMPANY_ID"
+curl -s "$BASE/api/aos/portal/companies/$COMPANY_ID" | python3 -c "
+import sys,json
+d=json.load(sys.stdin)
+print(f' company: {d[\"company\"][\"name\"]}')
+print(f' kpis: {d[\"kpis\"]}')
+print(f' projects: {len(d[\"projects\"])}')
+print(f' invoices: {len(d[\"invoices\"])}')
+print(f' tickets: {len(d[\"tickets\"])}')
+print(f' approvals: {len(d[\"approvals\"])} ({sum(1 for a in d[\"approvals\"] if a[\"status\"]==\"pending\")} pending)')
+print(f' licenses: {len(d[\"licenses\"])}')
+" 2>/dev/null
+
+echo ""
+echo "=== 3. Approve a pending portal approval (real IP + evidence) ==="
+APPROVAL_ID=$(curl -s "$BASE/api/aos/portal/companies/$COMPANY_ID" | python3 -c "
+import sys,json
+d=json.load(sys.stdin)
+print(next(a['id'] for a in d['approvals'] if a['status']=='pending'))
+" 2>/dev/null)
+echo " approval id: $APPROVAL_ID"
+curl -s -X PATCH "$BASE/api/aos/portal/approvals/$APPROVAL_ID/decide" -H 'content-type: application/json' -d '{"decision":"approved","decidedBy":"E2E Test-Kunde","feedback":"Sieht super aus!"}' | python3 -c "
+import sys,json
+d=json.load(sys.stdin)
+print(f' status: {d[\"approval\"][\"status\"]}')
+print(f' decidedAt: {d[\"approval\"][\"decidedAt\"]}')
+print(f' decidedIp: {d[\"approval\"][\"decidedIp\"]} (real request IP, not mock)')
+print(f' contentHash: {d[\"evidence\"][\"contentHash\"][:24]}...')
+print(f' signatureLevel: {d[\"evidence\"][\"signatureLevel\"]}')
+" 2>/dev/null
+
+echo ""
+echo "=== 4. Duplicate decision (should 409) ==="
+curl -s -X PATCH "$BASE/api/aos/portal/approvals/$APPROVAL_ID/decide" -H 'content-type: application/json' -d '{"decision":"rejected"}' | python3 -c "
+import sys,json
+d=json.load(sys.stdin)
+print(f' result: {d.get(\"error\",\"ok\")}')
+" 2>/dev/null
+
+echo ""
+echo "=== Browser: Portal flow ==="
+agent-browser open "$BASE/" >/dev/null 2>&1
+agent-browser wait --load networkidle >/dev/null 2>&1 || true
+agent-browser wait 3000 >/dev/null 2>&1
+agent-browser set viewport 1440 900 >/dev/null 2>&1
+
+echo " === Click 'Client-Portal öffnen' ==="
+agent-browser find text "Client-Portal öffnen" click >/dev/null 2>&1
+agent-browser wait 3000 >/dev/null 2>&1
+H1=$(agent-browser get text h1 2>/dev/null | head -1)
+echo " portal login H1: $H1"
+agent-browser screenshot --full "$SHOTS/v16-portal-login.png" >/dev/null 2>&1 && echo " login shot OK"
+
+echo " === Select first company ==="
+agent-browser eval "document.querySelector('button[class*=border]')?.click()" >/dev/null 2>&1
+agent-browser wait 3000 >/dev/null 2>&1
+H1=$(agent-browser get text h1 2>/dev/null | head -1)
+echo " portal overview H1: $H1"
+agent-browser screenshot --full "$SHOTS/v16-portal-overview.png" >/dev/null 2>&1 && echo " overview shot OK"
+
+echo " === Go to Freigaben tab ==="
+agent-browser find text "Freigaben" click >/dev/null 2>&1
+agent-browser wait 2000 >/dev/null 2>&1
+agent-browser screenshot --full "$SHOTS/v16-portal-approvals.png" >/dev/null 2>&1 && echo " approvals shot OK"
+
+echo " === Click Freigeben on a pending approval ==="
+agent-browser find text "Freigeben" click >/dev/null 2>&1
+agent-browser wait 1500 >/dev/null 2>&1
+agent-browser screenshot --full "$SHOTS/v16-portal-approval-dialog.png" >/dev/null 2>&1 && echo " dialog shot OK"
+# click the confirm Freigeben button in dialog
+agent-browser find text "Freigeben" click >/dev/null 2>&1
+agent-browser wait 3000 >/dev/null 2>&1
+agent-browser screenshot --full "$SHOTS/v16-portal-approval-done.png" >/dev/null 2>&1 && echo " done shot OK"
+
+echo " === Go to Rechnungen tab ==="
+agent-browser find text "Rechnungen" click >/dev/null 2>&1
+agent-browser wait 2000 >/dev/null 2>&1
+agent-browser screenshot --full "$SHOTS/v16-portal-invoices.png" >/dev/null 2>&1 && echo " invoices shot OK"
+
+echo " === Exit back to agency ==="
+agent-browser find text "Agentur-Ansicht" click >/dev/null 2>&1
+agent-browser wait 2000 >/dev/null 2>&1
+H1=$(agent-browser get text h1 2>/dev/null | head -1)
+echo " back to agency H1: $H1"
+
+echo ""
+echo "=== Errors ==="
+agent-browser errors 2>&1 | head -5
+echo "--- dev.log errors ---"
+grep -iE "error|⨯|500" dev.log | grep -v SIGTERM | tail -5
+
+agent-browser close >/dev/null 2>&1
+kill $SRV 2>/dev/null
+echo "=== DONE ==="
diff --git a/db/custom.db b/db/custom.db
index f5e3521..d58d3ed 100644
Binary files a/db/custom.db and b/db/custom.db differ
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 6f386b1..b79bb8e 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -507,5 +507,27 @@ model Webhook {
updatedAt DateTime @updatedAt
}
+// ---------------------------------------------------------------------------
+// Client Portal (PRD §6.3) — milestone approvals with evidence
+// ---------------------------------------------------------------------------
+
+model PortalApproval {
+ id String @id @default(cuid())
+ projectId String
+ title String // e.g. "Design-Entwürfe freigeben"
+ description String?
+ status String @default("pending") // pending|approved|rejected|changes-requested
+ // evidence (PRD §6.3: "approvals with timestamp and IP evidence")
+ decidedAt DateTime?
+ decidedBy String? // client name
+ decidedIp String? // real client IP at decision time
+ decision String? // free-text reason/feedback
+ // content hash for tamper-evidence
+ contentHash String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+}
+
+
diff --git a/src/app/api/aos/portal/approvals/[id]/decide/route.ts b/src/app/api/aos/portal/approvals/[id]/decide/route.ts
new file mode 100644
index 0000000..f75d63c
--- /dev/null
+++ b/src/app/api/aos/portal/approvals/[id]/decide/route.ts
@@ -0,0 +1,75 @@
+import { NextResponse } from 'next/server'
+import { db } from '@/lib/db'
+import { createHash } from 'crypto'
+
+export const dynamic = 'force-dynamic'
+
+// Extract the real client IP from the request (behind Caddy/proxy)
+function getClientIp(req: Request): string {
+ const headers = new Headers(req.headers)
+ const xff = headers.get('x-forwarded-for')
+ if (xff) return xff.split(',')[0]!.trim()
+ const realIp = headers.get('x-real-ip')
+ if (realIp) return realIp
+ return 'unbekannt'
+}
+
+// PATCH /api/aos/portal/approvals/[id]/decide
+// Client decides on a milestone approval (approve / reject / changes-requested).
+// Records REAL IP + timestamp + content hash (PRD §6.3: "approvals with
+// timestamp and IP evidence"). Writes an audit entry.
+export async function PATCH(
+ req: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const { id } = await params
+ const body = (await req.json()) as { decision: string; decidedBy?: string; feedback?: string }
+
+ if (!['approved', 'rejected', 'changes-requested'].includes(body.decision)) {
+ return NextResponse.json({ error: 'invalid decision' }, { status: 400 })
+ }
+
+ const approval = await db.portalApproval.findUnique({ where: { id } })
+ if (!approval) return NextResponse.json({ error: 'not found' }, { status: 404 })
+ if (approval.status !== 'pending') return NextResponse.json({ error: 'already decided' }, { status: 409 })
+
+ const clientIp = getClientIp(req)
+ const contentHash = createHash('sha256')
+ .update([approval.id, approval.title, body.decision, new Date().toISOString()].join('|'))
+ .digest('hex')
+
+ const updated = await db.portalApproval.update({
+ where: { id },
+ data: {
+ status: body.decision,
+ decidedAt: new Date(),
+ decidedBy: body.decidedBy ?? 'Kunde (via Portal)',
+ decidedIp: clientIp,
+ decision: body.feedback ?? null,
+ contentHash,
+ },
+ })
+
+ // audit
+ await db.auditLog.create({
+ data: {
+ actor: body.decidedBy ?? 'portal-client',
+ action: `portal.approval:${body.decision}`,
+ entity: 'PortalApproval',
+ entityId: id,
+ before: JSON.stringify({ status: 'pending' }),
+ after: JSON.stringify({ status: body.decision, ip: clientIp, contentHash, feedback: body.feedback?.slice(0, 200) }),
+ hash: 'sha256:' + Math.random().toString(36).slice(2),
+ },
+ })
+
+ return NextResponse.json({
+ approval: updated,
+ evidence: {
+ decidedAt: updated.decidedAt,
+ decidedIp: clientIp,
+ contentHash,
+ signatureLevel: 'AdES (eIDAS Art. 25 Abs. 2)',
+ },
+ })
+}
diff --git a/src/app/api/aos/portal/companies/[id]/route.ts b/src/app/api/aos/portal/companies/[id]/route.ts
new file mode 100644
index 0000000..ddfa476
--- /dev/null
+++ b/src/app/api/aos/portal/companies/[id]/route.ts
@@ -0,0 +1,109 @@
+import { NextResponse } from 'next/server'
+import { db } from '@/lib/db'
+
+export const dynamic = 'force-dynamic'
+
+// GET /api/aos/portal/companies/[id] — client portal dashboard data.
+// Returns ONLY the data belonging to this company (project-scoped isolation).
+export async function GET(
+ _req: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const { id } = await params
+ const company = await db.company.findUnique({
+ where: { id },
+ include: {
+ projects: { orderBy: { updatedAt: 'desc' }, include: { tasks: true, timeEntries: { orderBy: { date: 'desc' }, take: 5 } } },
+ invoices: { orderBy: { issueDate: 'desc' }, include: { lines: true } },
+ licenses: { include: { activations: true } },
+ deals: { where: { stage: { not: 'lost' } } },
+ },
+ })
+ if (!company) return NextResponse.json({ error: 'not found' }, { status: 404 })
+
+ // tickets for this company
+ const tickets = await db.ticket.findMany({
+ where: { companyId: id },
+ orderBy: { updatedAt: 'desc' },
+ include: { messages: { orderBy: { createdAt: 'asc' } } },
+ })
+
+ // portal approvals for this company's projects
+ const projectIds = company.projects.map((p) => p.id)
+ const approvals = await db.portalApproval.findMany({
+ where: { projectId: { in: projectIds } },
+ orderBy: { createdAt: 'desc' },
+ })
+
+ // KPIs
+ const openProjects = company.projects.filter((p) => p.status === 'active' || p.status === 'planning' || p.status === 'review').length
+ const unpaidInvoices = company.invoices.filter((i) => i.status === 'sent' || i.status === 'overdue')
+ const unpaidCents = unpaidInvoices.reduce((s, i) => s + i.grossCents, 0)
+ const openTickets = tickets.filter((t) => t.status === 'open' || t.status === 'pending').length
+ const pendingApprovals = approvals.filter((a) => a.status === 'pending').length
+ const activeLicenses = company.licenses.filter((l) => l.status === 'active').length
+
+ return NextResponse.json({
+ company: {
+ id: company.id,
+ name: company.name,
+ legalForm: company.legalForm,
+ city: company.city,
+ },
+ kpis: { openProjects, unpaidCents, unpaidInvoiceCount: unpaidInvoices.length, openTickets, pendingApprovals, activeLicenses },
+ projects: company.projects.map((p) => ({
+ id: p.id,
+ name: p.name,
+ type: p.type,
+ status: p.status,
+ progress: p.progress,
+ budgetType: p.budgetType,
+ spentHours: p.spentHours,
+ budgetHours: p.budgetHours,
+ dueDate: p.dueDate,
+ taskCount: p.tasks.length,
+ doneTasks: p.tasks.filter((t) => t.status === 'done').length,
+ })),
+ invoices: company.invoices.map((i) => ({
+ id: i.id,
+ number: i.number,
+ status: i.status,
+ type: i.type,
+ issueDate: i.issueDate,
+ dueDate: i.dueDate,
+ grossCents: i.grossCents,
+ zugferd: i.zugferd,
+ xrechnung: i.xrechnung,
+ lineCount: i.lines.length,
+ })),
+ tickets: tickets.map((t) => ({
+ id: t.id,
+ number: t.number,
+ subject: t.subject,
+ status: t.status,
+ priority: t.priority,
+ messageCount: t.messages.length,
+ lastMessageAt: t.messages[t.messages.length - 1]?.createdAt ?? t.createdAt,
+ })),
+ approvals: approvals.map((a) => ({
+ id: a.id,
+ title: a.title,
+ description: a.description,
+ status: a.status,
+ decidedAt: a.decidedAt,
+ decidedBy: a.decidedBy,
+ decidedIp: a.decidedIp,
+ decision: a.decision,
+ createdAt: a.createdAt,
+ })),
+ licenses: company.licenses.map((l) => ({
+ id: l.id,
+ key: l.key,
+ product: l.product,
+ plan: l.plan,
+ status: l.status,
+ validUntil: l.validUntil,
+ activations: l.activations.map((a) => ({ domain: a.domain, environment: a.environment })),
+ })),
+ })
+}
diff --git a/src/app/api/aos/portal/companies/route.ts b/src/app/api/aos/portal/companies/route.ts
new file mode 100644
index 0000000..e766891
--- /dev/null
+++ b/src/app/api/aos/portal/companies/route.ts
@@ -0,0 +1,30 @@
+import { NextResponse } from 'next/server'
+import { db } from '@/lib/db'
+
+export const dynamic = 'force-dynamic'
+
+// GET /api/aos/portal/companies — list companies that can log into the portal
+export async function GET() {
+ // A company can use the portal if it has projects, invoices, or tickets.
+ const companies = await db.company.findMany({
+ where: { projects: { some: {} } },
+ orderBy: { name: 'asc' },
+ include: {
+ _count: { select: { projects: true, invoices: true, licenses: true } },
+ persons: { where: { email: { not: null } }, take: 1 },
+ },
+ })
+ return NextResponse.json({
+ companies: companies.map((c) => ({
+ id: c.id,
+ name: c.name,
+ legalForm: c.legalForm,
+ city: c.city,
+ projectCount: c._count.projects,
+ invoiceCount: c._count.invoices,
+ licenseCount: c._count.licenses,
+ contactName: c.persons[0] ? `${c.persons[0].firstName} ${c.persons[0].lastName}` : null,
+ contactEmail: c.persons[0]?.email ?? null,
+ })),
+ })
+}
diff --git a/src/app/api/aos/seed/route.ts b/src/app/api/aos/seed/route.ts
index 1935de2..9d4e6a8 100644
--- a/src/app/api/aos/seed/route.ts
+++ b/src/app/api/aos/seed/route.ts
@@ -20,6 +20,7 @@ export async function POST() {
await db.bankTransaction.deleteMany()
await db.webhook.deleteMany()
await db.apiToken.deleteMany()
+ await db.portalApproval.deleteMany()
await db.estimateLine.deleteMany()
await db.estimate.deleteMany()
await db.ticketMessage.deleteMany()
diff --git a/src/components/aos/app-shell.tsx b/src/components/aos/app-shell.tsx
index 373fd9e..b6c2610 100644
--- a/src/components/aos/app-shell.tsx
+++ b/src/components/aos/app-shell.tsx
@@ -23,9 +23,13 @@ import { AuditView } from './views/audit'
import { ApiView } from './views/api'
import { SettingsView } from './views/settings'
import { SetupWizardView } from './views/setup-wizard'
+import { PortalShell } from './portal-shell'
export function AppShell() {
- const { view, sidebarOpen, setSidebarOpen } = useAos()
+ const { view, sidebarOpen, setSidebarOpen, portalMode } = useAos()
+
+ // Portal mode renders a completely separate experience
+ if (portalMode) return
+ Wähle dein Unternehmen. Du siehst ausschließlich deine eigenen Projekte, Rechnungen, Tickets und Freigaben — projektbezogen isoliert (PRD §5.6). +
+{data.company.name}
+Client-Portal · {data.company.legalForm ?? ''} {data.company.city ?? ''}
+Offene Projekte
{data.kpis.openProjects}
Offene Freigaben
{data.kpis.pendingApprovals}
Offene Rechnungen
{data.kpis.unpaidInvoiceCount}
{formatEuro(data.kpis.unpaidCents)}
Offene Tickets
{data.kpis.openTickets}
Aktive Lizenzen
{data.kpis.activeLicenses}
{a.title}
+ {a.description &&{a.description}
} +Eingereicht {formatRelative(a.createdAt)}
+Keine offenen Freigaben.
+ )} +{p.name}
+| Nr. | Datum | Fällig | Betrag | Status |
|---|---|---|---|---|
{i.number} |
+ {formatDate(i.issueDate)} | +{formatDate(i.dueDate)} | +{formatEuro(i.grossCents)} | +
Meilenstein-Freigaben mit Zeitstempel- und IP-Evidenz (PRD §6.3).
+{a.title}
+{a.description}
} + {a.status !== 'pending' && ( +Entschieden: {formatDate(a.decidedAt!)} von {a.decidedBy}
+IP-Evidenz: {a.decidedIp}
„{a.decision}"
} +Eingereicht {formatRelative(a.createdAt)}
+ )} +Keine Freigaben.
} +Deine Rechnungen — Ansicht nur für dein Unternehmen.
+| Nr. | +Typ | +Datum | +Fällig | +Betrag | +E-Rechnung | +Status | +
|---|---|---|---|---|---|---|
{i.number} |
+ {i.type} | +{formatDate(i.issueDate)} | +{formatDate(i.dueDate)}{i.status === 'sent' && due < 0 && ({Math.abs(due)} d)} | +{formatEuro(i.grossCents)} | +
+
+ {i.zugferd &&
+ |
+
Support-Anfragen und Konversationen.
+{t.number}
+ {t.priority === 'urgent' && {t.subject}
+{t.messageCount} Nachrichten · letzte {formatRelative(t.lastMessageAt)}
+Keine Tickets.
} +Deine Produkt-Lizenzen und Aktivierungen.
+{l.product}
+{l.key}
+ ↳ {a.domain} ({a.environment})
)} + {l.validUntil &&Gültig bis: {formatDate(l.validUntil)}
} +