f325299d-c1a0-4376-98ae-ddb747d23da5
This commit is contained in:
1 parent
9c58e29472
commit
be34f88822
22 files changed
+1361
-1
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 157 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 117 KiB |
+125
@@ -0,0 +1,125 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Verify Public API + Webhooks with real (non-mock) behavior
|
||||||
|
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. OpenAPI spec (public, no token) ==="
|
||||||
|
curl -s -o /dev/null -w " /api/v1/openapi.json -> HTTP %{http_code}\n" "$BASE/api/v1/openapi.json"
|
||||||
|
curl -s "$BASE/api/v1/openapi.json" | python3 -c "
|
||||||
|
import sys,json
|
||||||
|
d=json.load(sys.stdin)
|
||||||
|
print(f' openapi version: {d[\"openapi\"]}')
|
||||||
|
print(f' title: {d[\"info\"][\"title\"]}')
|
||||||
|
print(f' paths: {list(d[\"paths\"].keys())}')
|
||||||
|
print(f' scopes: {list(d[\"x-aos-scopes\"].keys())}')
|
||||||
|
print(f' webhook signature: {d[\"x-aos-webhook-signature\"][\"algorithm\"]}')
|
||||||
|
" 2>/dev/null
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== 2. Public API without token (should 401) ==="
|
||||||
|
echo -n " /api/v1/companies: "; curl -s -o /dev/null -w "HTTP %{http_code}\n" "$BASE/api/v1/companies"
|
||||||
|
echo -n " /api/v1/invoices: "; curl -s -o /dev/null -w "HTTP %{http_code}\n" "$BASE/api/v1/invoices"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== 3. Create a real API token ==="
|
||||||
|
TOKEN_RES=$(curl -s -X POST "$BASE/api/aos/api-tokens" -H 'content-type: application/json' -d '{"name":"E2E Test Token","scopes":["crm.company:read","finance.invoice:read","webhooks.test"]}')
|
||||||
|
TOKEN=$(echo "$TOKEN_RES" | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null)
|
||||||
|
echo " token: ${TOKEN:0:30}... (length=${#TOKEN})"
|
||||||
|
echo " is aos_ format: $(echo "$TOKEN" | grep -c '^aos_[0-9a-f]\{64\}$')"
|
||||||
|
echo " hash check: stored as SHA-256 (not plaintext)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== 4. Use real token to call public API ==="
|
||||||
|
echo -n " /api/v1/companies: "; curl -s -o /dev/null -w "HTTP %{http_code}\n" -H "Authorization: Bearer $TOKEN" "$BASE/api/v1/companies"
|
||||||
|
echo " response:"
|
||||||
|
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/api/v1/companies" | python3 -c "
|
||||||
|
import sys,json
|
||||||
|
d=json.load(sys.stdin)
|
||||||
|
print(f' data count: {len(d[\"data\"])}')
|
||||||
|
print(f' pagination: {d[\"pagination\"]}')
|
||||||
|
print(f' first company: {d[\"data\"][0][\"name\"] if d[\"data\"] else \"none\"}')
|
||||||
|
print(f' token used: {d[\"token\"]}')
|
||||||
|
" 2>/dev/null
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== 5. Scope enforcement (token without licenses scope → 403) ==="
|
||||||
|
echo -n " /api/v1/licenses: "; curl -s -H "Authorization: Bearer $TOKEN" "$BASE/api/v1/licenses" | python3 -c "
|
||||||
|
import sys,json
|
||||||
|
d=json.load(sys.stdin)
|
||||||
|
print(f' error: {d.get(\"error\")}')
|
||||||
|
print(f' scopes: {d.get(\"scopes\")}')
|
||||||
|
" 2>/dev/null
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== 6. Invalid token (should 401) ==="
|
||||||
|
echo -n " /api/v1/companies: "; curl -s -H "Authorization: Bearer aos_invalidtoken123" "$BASE/api/v1/companies" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f' error: {d.get(\"error\")}')" 2>/dev/null
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== 7. Register a real webhook (generates HMAC secret) ==="
|
||||||
|
WH_RES=$(curl -s -X POST "$BASE/api/aos/webhooks" -H 'content-type: application/json' -d '{"url":"https://httpbin.org/post","events":["test.event","invoice.paid"]}')
|
||||||
|
WH_SECRET=$(echo "$WH_RES" | python3 -c "import sys,json; print(json.load(sys.stdin).get('secret',''))" 2>/dev/null)
|
||||||
|
echo " webhook secret: ${WH_SECRET:0:25}... (length=${#WH_SECRET})"
|
||||||
|
echo " secret format: $(echo "$WH_SECRET" | grep -c '^whsec_[0-9a-f]\{48\}$')"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== 8. Trigger real webhook delivery (POST + HMAC) ==="
|
||||||
|
echo -n " test webhook delivery: "
|
||||||
|
curl -s -X POST "$BASE/api/v1/webhooks/test" -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{"event":"test.event","payload":{"source":"E2E test"}}' | python3 -c "
|
||||||
|
import sys,json
|
||||||
|
d=json.load(sys.stdin)
|
||||||
|
print(f'delivered={d.get(\"delivered\")}')
|
||||||
|
for r in d.get('results',[]):
|
||||||
|
print(f' url={r[\"url\"][:40]} subscribed={r[\"subscribed\"]} lastStatus={r[\"lastStatus\"]} sent={r[\"sentCount\"]}')
|
||||||
|
" 2>/dev/null
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== 9. Revoke a token (real soft-delete) ==="
|
||||||
|
TOKEN_ID=$(echo "$TOKEN_RES" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
|
||||||
|
curl -s -X PATCH "$BASE/api/aos/api-tokens/$TOKEN_ID/revoke" | python3 -c "
|
||||||
|
import sys,json
|
||||||
|
d=json.load(sys.stdin)
|
||||||
|
print(f' revoked: {d.get(\"revoked\")}')
|
||||||
|
print(f' revokedAt: {d.get(\"token\",{}).get(\"revokedAt\",\"?\")}')
|
||||||
|
" 2>/dev/null
|
||||||
|
echo " === Using revoked token (should 401) ==="
|
||||||
|
echo -n " /api/v1/companies: "; curl -s -o /dev/null -w "HTTP %{http_code}\n" -H "Authorization: Bearer $TOKEN" "$BASE/api/v1/companies"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== 10. Browser: API & Webhooks view ==="
|
||||||
|
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
|
||||||
|
agent-browser snapshot -i >/tmp/s.txt 2>&1
|
||||||
|
API=$(grep -i 'button "API & Webhooks"' /tmp/s.txt | grep -oE 'ref=e[0-9]+' | head -1)
|
||||||
|
agent-browser click "$API" >/dev/null 2>&1
|
||||||
|
agent-browser wait 3000 >/dev/null 2>&1
|
||||||
|
H1=$(agent-browser get text h1 2>/dev/null | head -1)
|
||||||
|
echo " API view H1: $H1"
|
||||||
|
agent-browser screenshot --full "$SHOTS/v15-api-tokens.png" >/dev/null 2>&1 && echo " tokens shot OK"
|
||||||
|
|
||||||
|
echo " === Switch to Docs tab ==="
|
||||||
|
agent-browser find role tab click --name "Dokumentation" >/dev/null 2>&1 || agent-browser find text "Dokumentation" click >/dev/null 2>&1
|
||||||
|
agent-browser wait 2000 >/dev/null 2>&1
|
||||||
|
agent-browser screenshot --full "$SHOTS/v15-api-docs.png" >/dev/null 2>&1 && echo " docs shot OK"
|
||||||
|
|
||||||
|
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 ==="
|
||||||
Binary file not shown.
@@ -474,4 +474,38 @@ model DunningRun {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API (PRD §5.5) — API tokens with scopes, webhooks with HMAC signing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
model ApiToken {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
// The token shown to the user ONCE. We store only its SHA-256 hash so a
|
||||||
|
// compromised DB does not leak usable tokens (like GitHub PATs).
|
||||||
|
tokenHash String @unique
|
||||||
|
tokenPrefix String // first 8 chars for identification (e.g. "aos_abc1…")
|
||||||
|
scopes String // JSON array of scope strings, e.g. ["crm.company:read","finance.invoice:read"]
|
||||||
|
lastUsedAt DateTime?
|
||||||
|
expiresAt DateTime?
|
||||||
|
revokedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model Webhook {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
url String // https endpoint to deliver to
|
||||||
|
events String // JSON array, e.g. ["invoice.paid","contract.signed"]
|
||||||
|
secret String // HMAC signing secret (stored, needed to verify signatures)
|
||||||
|
active Boolean @default(true)
|
||||||
|
// delivery stats
|
||||||
|
sentCount Int @default(0)
|
||||||
|
failCount Int @default(0)
|
||||||
|
lastSentAt DateTime?
|
||||||
|
lastStatus Int? // last HTTP status code received
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
// PATCH /api/aos/api-tokens/[id]/revoke — revoke a token (soft delete)
|
||||||
|
export async function PATCH(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const { id } = await params
|
||||||
|
const token = await db.apiToken.findUnique({ where: { id } })
|
||||||
|
if (!token) return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||||
|
if (token.revokedAt) return NextResponse.json({ error: 'already revoked' }, { status: 409 })
|
||||||
|
|
||||||
|
const updated = await db.apiToken.update({
|
||||||
|
where: { id },
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.auditLog.create({
|
||||||
|
data: {
|
||||||
|
actor: 'max@nordlicht.digital',
|
||||||
|
action: 'api.token:revoke',
|
||||||
|
entity: 'ApiToken',
|
||||||
|
entityId: id,
|
||||||
|
after: JSON.stringify({ revokedAt: updated.revokedAt }),
|
||||||
|
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ token: updated, revoked: true })
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { createHash, randomBytes } from 'crypto'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
const ALL_SCOPES = [
|
||||||
|
'crm.company:read',
|
||||||
|
'finance.invoice:read',
|
||||||
|
'licenses.license:read',
|
||||||
|
'webhooks.test',
|
||||||
|
]
|
||||||
|
|
||||||
|
// GET /api/aos/api-tokens — list tokens (without revealing the secret)
|
||||||
|
export async function GET() {
|
||||||
|
const tokens = await db.apiToken.findMany({ orderBy: { createdAt: 'desc' } })
|
||||||
|
return NextResponse.json({
|
||||||
|
tokens: tokens.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
tokenPrefix: t.tokenPrefix,
|
||||||
|
scopes: JSON.parse(t.scopes),
|
||||||
|
lastUsedAt: t.lastUsedAt,
|
||||||
|
expiresAt: t.expiresAt,
|
||||||
|
revokedAt: t.revokedAt,
|
||||||
|
createdAt: t.createdAt,
|
||||||
|
active: !t.revokedAt && (!t.expiresAt || t.expiresAt > new Date()),
|
||||||
|
})),
|
||||||
|
availableScopes: ALL_SCOPES,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/aos/api-tokens — create a new token. Returns the plaintext token ONCE.
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { name, scopes, expiresAt } = (await req.json()) as {
|
||||||
|
name: string
|
||||||
|
scopes: string[]
|
||||||
|
expiresAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name?.trim()) return NextResponse.json({ error: 'name required' }, { status: 400 })
|
||||||
|
if (!scopes?.length) return NextResponse.json({ error: 'at least one scope required' }, { status: 400 })
|
||||||
|
|
||||||
|
// validate scopes
|
||||||
|
const invalid = scopes.filter((s) => !ALL_SCOPES.includes(s))
|
||||||
|
if (invalid.length) return NextResponse.json({ error: `invalid scopes: ${invalid.join(', ')}`, valid: ALL_SCOPES }, { status: 400 })
|
||||||
|
|
||||||
|
// generate a real cryptographic token (256-bit random)
|
||||||
|
const tokenBytes = randomBytes(32)
|
||||||
|
const plaintext = 'aos_' + tokenBytes.toString('hex')
|
||||||
|
const tokenHash = createHash('sha256').update(plaintext).digest('hex')
|
||||||
|
const tokenPrefix = 'aos_' + plaintext.slice(4, 11) // "aos_abc1234"
|
||||||
|
|
||||||
|
const record = await db.apiToken.create({
|
||||||
|
data: {
|
||||||
|
name: name.trim(),
|
||||||
|
tokenHash,
|
||||||
|
tokenPrefix,
|
||||||
|
scopes: JSON.stringify(scopes),
|
||||||
|
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.auditLog.create({
|
||||||
|
data: {
|
||||||
|
actor: 'max@nordlicht.digital',
|
||||||
|
action: 'api.token:create',
|
||||||
|
entity: 'ApiToken',
|
||||||
|
entityId: record.id,
|
||||||
|
after: JSON.stringify({ name, scopes, tokenPrefix }),
|
||||||
|
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// return the plaintext token ONCE — it is never retrievable again
|
||||||
|
return NextResponse.json({
|
||||||
|
id: record.id,
|
||||||
|
name: record.name,
|
||||||
|
token: plaintext,
|
||||||
|
tokenPrefix,
|
||||||
|
scopes,
|
||||||
|
warning: 'This token is shown only once. Store it securely. The stored hash cannot be reversed.',
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ export async function POST() {
|
|||||||
await db.dunningRun.deleteMany()
|
await db.dunningRun.deleteMany()
|
||||||
await db.vatReturn.deleteMany()
|
await db.vatReturn.deleteMany()
|
||||||
await db.bankTransaction.deleteMany()
|
await db.bankTransaction.deleteMany()
|
||||||
|
await db.webhook.deleteMany()
|
||||||
|
await db.apiToken.deleteMany()
|
||||||
await db.estimateLine.deleteMany()
|
await db.estimateLine.deleteMany()
|
||||||
await db.estimate.deleteMany()
|
await db.estimate.deleteMany()
|
||||||
await db.ticketMessage.deleteMany()
|
await db.ticketMessage.deleteMany()
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { randomBytes } from 'crypto'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
const KNOWN_EVENTS = [
|
||||||
|
'invoice.paid',
|
||||||
|
'invoice.overdue',
|
||||||
|
'contract.signed',
|
||||||
|
'lead.approved',
|
||||||
|
'license.activated',
|
||||||
|
'ticket.created',
|
||||||
|
'test.event',
|
||||||
|
]
|
||||||
|
|
||||||
|
// GET /api/aos/webhooks — list registered webhooks
|
||||||
|
export async function GET() {
|
||||||
|
const webhooks = await db.webhook.findMany({ orderBy: { createdAt: 'desc' } })
|
||||||
|
return NextResponse.json({
|
||||||
|
webhooks: webhooks.map((w) => ({
|
||||||
|
id: w.id,
|
||||||
|
url: w.url,
|
||||||
|
events: JSON.parse(w.events),
|
||||||
|
active: w.active,
|
||||||
|
sentCount: w.sentCount,
|
||||||
|
failCount: w.failCount,
|
||||||
|
lastSentAt: w.lastSentAt,
|
||||||
|
lastStatus: w.lastStatus,
|
||||||
|
createdAt: w.createdAt,
|
||||||
|
hasSecret: !!w.secret,
|
||||||
|
})),
|
||||||
|
knownEvents: KNOWN_EVENTS,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/aos/webhooks — register a webhook (generates a real HMAC secret)
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { url, events } = (await req.json()) as { url: string; events: string[] }
|
||||||
|
|
||||||
|
if (!url?.trim()) return NextResponse.json({ error: 'url required' }, { status: 400 })
|
||||||
|
if (!url.startsWith('https://')) return NextResponse.json({ error: 'url must be https' }, { status: 400 })
|
||||||
|
if (!events?.length) return NextResponse.json({ error: 'at least one event required' }, { status: 400 })
|
||||||
|
|
||||||
|
const invalid = events.filter((e) => !KNOWN_EVENTS.includes(e) && e !== '*')
|
||||||
|
if (invalid.length) return NextResponse.json({ error: `invalid events: ${invalid.join(', ')}`, valid: KNOWN_EVENTS }, { status: 400 })
|
||||||
|
|
||||||
|
const secret = 'whsec_' + randomBytes(24).toString('hex')
|
||||||
|
|
||||||
|
const record = await db.webhook.create({
|
||||||
|
data: {
|
||||||
|
url: url.trim(),
|
||||||
|
events: JSON.stringify(events),
|
||||||
|
secret,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.auditLog.create({
|
||||||
|
data: {
|
||||||
|
actor: 'max@nordlicht.digital',
|
||||||
|
action: 'webhook.create',
|
||||||
|
entity: 'Webhook',
|
||||||
|
entityId: record.id,
|
||||||
|
after: JSON.stringify({ url, events }),
|
||||||
|
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// return the secret ONCE so the user can configure verification on their end
|
||||||
|
return NextResponse.json({
|
||||||
|
id: record.id,
|
||||||
|
url: record.url,
|
||||||
|
events,
|
||||||
|
secret,
|
||||||
|
warning: 'Store the secret securely. It is needed to verify HMAC-SHA256 signatures on incoming deliveries.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /api/aos/webhooks/[id] — toggle active
|
||||||
|
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params
|
||||||
|
const { active } = (await req.json()) as { active: boolean }
|
||||||
|
const updated = await db.webhook.update({ where: { id }, data: { active } })
|
||||||
|
return NextResponse.json({ webhook: updated })
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { verifyToken, requireScope } from '@/lib/api-auth'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
// GET /api/v1/companies
|
||||||
|
// Public API: list companies. Requires scope "crm.company:read".
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const auth = await verifyToken(req.headers.get('authorization'))
|
||||||
|
const check = requireScope(auth, 'crm.company:read')
|
||||||
|
if (!check.allowed) return check.error!
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const limit = Math.min(Number(searchParams.get('limit') ?? 50), 200)
|
||||||
|
const offset = Number(searchParams.get('offset') ?? 0)
|
||||||
|
const q = searchParams.get('q')
|
||||||
|
|
||||||
|
const where = q ? { name: { contains: q } } : {}
|
||||||
|
const [companies, total] = await Promise.all([
|
||||||
|
db.company.findMany({ where, take: limit, skip: offset, orderBy: { createdAt: 'desc' }, include: { _count: { select: { deals: true, projects: true } } } }),
|
||||||
|
db.company.count({ where }),
|
||||||
|
])
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
data: companies.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
legalForm: c.legalForm,
|
||||||
|
vatId: c.vatId,
|
||||||
|
website: c.website,
|
||||||
|
city: c.city,
|
||||||
|
email: c.email,
|
||||||
|
dealCount: c._count.deals,
|
||||||
|
projectCount: c._count.projects,
|
||||||
|
})),
|
||||||
|
pagination: { total, limit, offset, hasMore: offset + companies.length < total },
|
||||||
|
token: auth.token!.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { verifyToken, requireScope } from '@/lib/api-auth'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
// GET /api/v1/invoices
|
||||||
|
// Public API: list invoices. Requires scope "finance.invoice:read".
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const auth = await verifyToken(req.headers.get('authorization'))
|
||||||
|
const check = requireScope(auth, 'finance.invoice:read')
|
||||||
|
if (!check.allowed) return check.error!
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const limit = Math.min(Number(searchParams.get('limit') ?? 50), 200)
|
||||||
|
const offset = Number(searchParams.get('offset') ?? 0)
|
||||||
|
const status = searchParams.get('status')
|
||||||
|
|
||||||
|
const where = status ? { status } : {}
|
||||||
|
const [invoices, total] = await Promise.all([
|
||||||
|
db.invoice.findMany({ where, take: limit, skip: offset, orderBy: { issueDate: 'desc' }, include: { company: true, lines: true } }),
|
||||||
|
db.invoice.count({ where }),
|
||||||
|
])
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
data: invoices.map((i) => ({
|
||||||
|
id: i.id,
|
||||||
|
number: i.number,
|
||||||
|
status: i.status,
|
||||||
|
type: i.type,
|
||||||
|
issueDate: i.issueDate,
|
||||||
|
dueDate: i.dueDate,
|
||||||
|
paidDate: i.paidDate,
|
||||||
|
netCents: i.netCents,
|
||||||
|
grossCents: i.grossCents,
|
||||||
|
currency: 'EUR',
|
||||||
|
eRechnung: { zugferd: i.zugferd, xrechnung: i.xrechnung, datevExported: i.datevExported },
|
||||||
|
company: { id: i.company.id, name: i.company.name, vatId: i.company.vatId },
|
||||||
|
lines: i.lines.map((l) => ({ description: l.description, quantity: l.quantity, unitCents: l.unitCents, totalCents: l.totalCents })),
|
||||||
|
})),
|
||||||
|
pagination: { total, limit, offset, hasMore: offset + invoices.length < total },
|
||||||
|
token: auth.token!.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { verifyToken, requireScope } from '@/lib/api-auth'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
// GET /api/v1/licenses
|
||||||
|
// Public API: list issued licenses. Requires scope "licenses.license:read".
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const auth = await verifyToken(req.headers.get('authorization'))
|
||||||
|
const check = requireScope(auth, 'licenses.license:read')
|
||||||
|
if (!check.allowed) return check.error!
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const limit = Math.min(Number(searchParams.get('limit') ?? 50), 200)
|
||||||
|
const status = searchParams.get('status')
|
||||||
|
|
||||||
|
const where = status ? { status } : {}
|
||||||
|
const licenses = await db.license.findMany({
|
||||||
|
where,
|
||||||
|
take: limit,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: { company: true, activations: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
data: licenses.map((l) => ({
|
||||||
|
id: l.id,
|
||||||
|
key: l.key,
|
||||||
|
product: l.product,
|
||||||
|
plan: l.plan,
|
||||||
|
status: l.status,
|
||||||
|
maxActivations: l.maxActivations,
|
||||||
|
validUntil: l.validUntil,
|
||||||
|
createdAt: l.createdAt,
|
||||||
|
company: { id: l.company.id, name: l.company.name },
|
||||||
|
activations: l.activations.map((a) => ({ domain: a.domain, environment: a.environment, version: a.version, lastCheck: a.lastCheck })),
|
||||||
|
})),
|
||||||
|
pagination: { total: licenses.length, limit },
|
||||||
|
token: auth.token!.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// GET /api/v1/openapi.json
|
||||||
|
// Real OpenAPI 3.0.3 specification for the AOS Public API.
|
||||||
|
// Generated from the actual endpoints + scopes defined in the codebase.
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
const SCOPES = {
|
||||||
|
'crm.company:read': 'Read companies and contacts',
|
||||||
|
'finance.invoice:read': 'Read invoices and E-Rechnung metadata',
|
||||||
|
'licenses.license:read': 'Read issued licenses and activations',
|
||||||
|
'webhooks.test': 'Trigger test webhook deliveries',
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const spec = {
|
||||||
|
openapi: '3.0.3',
|
||||||
|
info: {
|
||||||
|
title: 'AOS Public API',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'Agency Operating System — public REST API for external integrations and agents (ZeroClaw, MCP). All endpoints require a Bearer token with the appropriate scope. Webhook payloads are HMAC-SHA256 signed.',
|
||||||
|
contact: { name: 'Nordlicht Digital GmbH', email: 'api@nordlicht.digital' },
|
||||||
|
license: { name: 'Proprietary' },
|
||||||
|
},
|
||||||
|
servers: [{ url: '/api/v1', description: 'AOS instance' }],
|
||||||
|
components: {
|
||||||
|
securitySchemes: {
|
||||||
|
bearerAuth: {
|
||||||
|
type: 'http',
|
||||||
|
scheme: 'bearer',
|
||||||
|
bearerFormat: 'aos_<64 hex chars>',
|
||||||
|
description: 'API token in format `aos_<random>`. Create tokens in the API Tokens view. Tokens are SHA-256 hashed at rest.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
schemas: {
|
||||||
|
Company: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
name: { type: 'string' },
|
||||||
|
legalForm: { type: 'string', nullable: true, example: 'GmbH' },
|
||||||
|
vatId: { type: 'string', nullable: true, example: 'DE284617302' },
|
||||||
|
website: { type: 'string', nullable: true },
|
||||||
|
city: { type: 'string', nullable: true },
|
||||||
|
email: { type: 'string', nullable: true },
|
||||||
|
dealCount: { type: 'integer' },
|
||||||
|
projectCount: { type: 'integer' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Invoice: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
number: { type: 'string', example: 'RE-2026-0042' },
|
||||||
|
status: { type: 'string', enum: ['draft', 'sent', 'paid', 'overdue', 'cancelled', 'storno'] },
|
||||||
|
type: { type: 'string', enum: ['final', 'partial', 'recurring', 'credit'] },
|
||||||
|
netCents: { type: 'integer', description: 'Net amount in euro cents' },
|
||||||
|
grossCents: { type: 'integer', description: 'Gross amount in euro cents (includes tax)' },
|
||||||
|
currency: { type: 'string', example: 'EUR' },
|
||||||
|
eRechnung: { type: 'object', properties: { zugferd: { type: 'boolean' }, xrechnung: { type: 'boolean' }, datevExported: { type: 'boolean' } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
License: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
key: { type: 'string', example: 'AOS-PRO-7F3K-9Q2L-NWX4' },
|
||||||
|
product: { type: 'string' },
|
||||||
|
plan: { type: 'string', enum: ['starter', 'pro', 'enterprise'] },
|
||||||
|
status: { type: 'string', enum: ['active', 'suspended', 'expired', 'revoked'] },
|
||||||
|
maxActivations: { type: 'integer' },
|
||||||
|
activations: { type: 'array', items: { type: 'object', properties: { domain: { type: 'string' }, environment: { type: 'string' } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
WebhookDelivery: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
event: { type: 'string' },
|
||||||
|
deliveredAt: { type: 'string', format: 'date-time' },
|
||||||
|
data: { type: 'object' },
|
||||||
|
},
|
||||||
|
description: 'Signed with x-aos-signature header (HMAC-SHA256 of timestamp.body)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
security: [{ bearerAuth: [] }],
|
||||||
|
paths: {
|
||||||
|
'/companies': {
|
||||||
|
get: {
|
||||||
|
summary: 'List companies',
|
||||||
|
description: 'Returns companies with deal/project counts. Supports pagination + full-text search.',
|
||||||
|
tags: ['CRM'],
|
||||||
|
parameters: [
|
||||||
|
{ name: 'limit', in: 'query', schema: { type: 'integer', default: 50, maximum: 200 } },
|
||||||
|
{ name: 'offset', in: 'query', schema: { type: 'integer', default: 0 } },
|
||||||
|
{ name: 'q', in: 'query', schema: { type: 'string' }, description: 'Full-text search on company name' },
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
'200': {
|
||||||
|
description: 'List of companies',
|
||||||
|
content: { 'application/json': { schema: { type: 'object', properties: { data: { type: 'array', items: { $ref: '#/components/schemas/Company' } }, pagination: { type: 'object' } } } } },
|
||||||
|
},
|
||||||
|
'401': { description: 'Missing or invalid token' },
|
||||||
|
'403': { description: 'Token lacks crm.company:read scope' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/invoices': {
|
||||||
|
get: {
|
||||||
|
summary: 'List invoices',
|
||||||
|
description: 'Returns invoices with line items and E-Rechnung metadata (ZUGFeRD/XRechnung/DATEV).',
|
||||||
|
tags: ['Finance'],
|
||||||
|
parameters: [
|
||||||
|
{ name: 'limit', in: 'query', schema: { type: 'integer', default: 50, maximum: 200 } },
|
||||||
|
{ name: 'offset', in: 'query', schema: { type: 'integer', default: 0 } },
|
||||||
|
{ name: 'status', in: 'query', schema: { type: 'string', enum: ['draft', 'sent', 'paid', 'overdue', 'cancelled'] } },
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
'200': { description: 'List of invoices', content: { 'application/json': { schema: { type: 'object', properties: { data: { type: 'array', items: { $ref: '#/components/schemas/Invoice' } } } } } } },
|
||||||
|
'401': { description: 'Missing or invalid token' },
|
||||||
|
'403': { description: 'Token lacks finance.invoice:read scope' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/licenses': {
|
||||||
|
get: {
|
||||||
|
summary: 'List issued licenses',
|
||||||
|
description: 'Returns product licenses with activations (domain/environment). For plugin update + activation workflows.',
|
||||||
|
tags: ['Licenses'],
|
||||||
|
parameters: [
|
||||||
|
{ name: 'limit', in: 'query', schema: { type: 'integer', default: 50, maximum: 200 } },
|
||||||
|
{ name: 'status', in: 'query', schema: { type: 'string', enum: ['active', 'suspended', 'expired', 'revoked'] } },
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
'200': { description: 'List of licenses', content: { 'application/json': { schema: { type: 'object', properties: { data: { type: 'array', items: { $ref: '#/components/schemas/License' } } } } } } },
|
||||||
|
'403': { description: 'Token lacks licenses.license:read scope' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/webhooks/test': {
|
||||||
|
post: {
|
||||||
|
summary: 'Trigger test webhook delivery',
|
||||||
|
description: 'Sends a real HTTP POST with HMAC-SHA256 signature to all active webhooks subscribing to the event.',
|
||||||
|
tags: ['Webhooks'],
|
||||||
|
requestBody: { content: { 'application/json': { schema: { type: 'object', properties: { event: { type: 'string' }, payload: { type: 'object' } } } } } },
|
||||||
|
responses: {
|
||||||
|
'200': { description: 'Delivery results per webhook' },
|
||||||
|
'403': { description: 'Token lacks webhooks.test scope' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'x-aos-scopes': SCOPES,
|
||||||
|
'x-aos-webhook-signature': {
|
||||||
|
algorithm: 'HMAC-SHA256',
|
||||||
|
header: 'x-aos-signature',
|
||||||
|
format: 'sha256=<hex>',
|
||||||
|
signatureBase: '<timestamp>.<body>',
|
||||||
|
timestampHeader: 'x-aos-timestamp',
|
||||||
|
note: 'Verify by computing HMAC-SHA256(secret, timestamp.body) and comparing with the x-aos-signature header value.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(spec, {
|
||||||
|
headers: { 'content-type': 'application/json; charset=utf-8' },
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import { ExpensesView } from './views/expenses'
|
|||||||
import { FinanceView } from './views/finance'
|
import { FinanceView } from './views/finance'
|
||||||
import { AccountingView } from './views/accounting'
|
import { AccountingView } from './views/accounting'
|
||||||
import { AuditView } from './views/audit'
|
import { AuditView } from './views/audit'
|
||||||
|
import { ApiView } from './views/api'
|
||||||
import { SettingsView } from './views/settings'
|
import { SettingsView } from './views/settings'
|
||||||
import { SetupWizardView } from './views/setup-wizard'
|
import { SetupWizardView } from './views/setup-wizard'
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ export function AppShell() {
|
|||||||
{view === 'finance' && <FinanceView />}
|
{view === 'finance' && <FinanceView />}
|
||||||
{view === 'accounting' && <AccountingView />}
|
{view === 'accounting' && <AccountingView />}
|
||||||
{view === 'audit' && <AuditView />}
|
{view === 'audit' && <AuditView />}
|
||||||
|
{view === 'api' && <ApiView />}
|
||||||
{view === 'settings' && <SettingsView />}
|
{view === 'settings' && <SettingsView />}
|
||||||
{view === 'setup' && <SetupWizardView />}
|
{view === 'setup' && <SetupWizardView />}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
BarChart3,
|
BarChart3,
|
||||||
Rocket,
|
Rocket,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
Webhook,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAos, type ViewId } from '@/store/aos'
|
import { useAos, type ViewId } from '@/store/aos'
|
||||||
import {
|
import {
|
||||||
@@ -48,6 +49,7 @@ const VIEWS: { id: ViewId; label: string; icon: typeof LayoutDashboard; hint: st
|
|||||||
{ id: 'finance', label: 'Finanzen', icon: Receipt, hint: 'Rechnungen & E-Rechnung' },
|
{ id: 'finance', label: 'Finanzen', icon: Receipt, hint: 'Rechnungen & E-Rechnung' },
|
||||||
{ id: 'accounting', label: 'Buchhaltung', icon: BookOpen, hint: 'USt-Voranmeldung, EÜR, Bank' },
|
{ id: 'accounting', label: 'Buchhaltung', icon: BookOpen, hint: 'USt-Voranmeldung, EÜR, Bank' },
|
||||||
{ id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, hint: 'Hash-Kette & GoBD' },
|
{ id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, hint: 'Hash-Kette & GoBD' },
|
||||||
|
{ id: 'api', label: 'API & Webhooks', icon: Webhook, hint: 'Tokens, Scopes, HMAC' },
|
||||||
{ id: 'settings', label: 'Erweiterungen', icon: Settings2, hint: 'Module & Compliance' },
|
{ id: 'settings', label: 'Erweiterungen', icon: Settings2, hint: 'Module & Compliance' },
|
||||||
{ id: 'setup', label: 'Setup-Wizard', icon: Rocket, hint: '≤ 15 Min Installation' },
|
{ id: 'setup', label: 'Setup-Wizard', icon: Rocket, hint: '≤ 15 Min Installation' },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
BarChart3,
|
BarChart3,
|
||||||
Rocket,
|
Rocket,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
Webhook,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAos, type ViewId } from '@/store/aos'
|
import { useAos, type ViewId } from '@/store/aos'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -66,6 +67,7 @@ const NAV: {
|
|||||||
group: 'System',
|
group: 'System',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'settings', label: 'Erweiterungen', icon: Settings2, permission: 'admin' },
|
{ id: 'settings', label: 'Erweiterungen', icon: Settings2, permission: 'admin' },
|
||||||
|
{ id: 'api', label: 'API & Webhooks', icon: Webhook, permission: 'admin' },
|
||||||
{ id: 'setup', label: 'Setup-Wizard', icon: Rocket, permission: 'admin' },
|
{ id: 'setup', label: 'Setup-Wizard', icon: Rocket, permission: 'admin' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const TITLES: Record<ViewId, { title: string; crumb: string }> = {
|
|||||||
finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' },
|
finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' },
|
||||||
accounting: { title: 'Buchhaltung', crumb: 'Betrieb / Buchhaltung' },
|
accounting: { title: 'Buchhaltung', crumb: 'Betrieb / Buchhaltung' },
|
||||||
audit: { title: 'Audit-Protokoll', crumb: 'Betrieb / Audit' },
|
audit: { title: 'Audit-Protokoll', crumb: 'Betrieb / Audit' },
|
||||||
|
api: { title: 'API & Webhooks', crumb: 'System / API' },
|
||||||
settings: { title: 'Erweiterungen', crumb: 'System / Module' },
|
settings: { title: 'Erweiterungen', crumb: 'System / Module' },
|
||||||
setup: { title: 'Setup-Wizard', crumb: 'System / Setup' },
|
setup: { title: 'Setup-Wizard', crumb: 'System / Setup' },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,524 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Webhook,
|
||||||
|
KeyRound,
|
||||||
|
Plus,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Loader2,
|
||||||
|
Send,
|
||||||
|
ShieldCheck,
|
||||||
|
ExternalLink,
|
||||||
|
Ban,
|
||||||
|
Activity,
|
||||||
|
Code,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useFetch } from '@/hooks/use-fetch'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { PageHeader } from '../primitives'
|
||||||
|
import { formatRelative } from '@/lib/format'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
interface ApiToken {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
tokenPrefix: string
|
||||||
|
scopes: string[]
|
||||||
|
lastUsedAt: string | null
|
||||||
|
expiresAt: string | null
|
||||||
|
revokedAt: string | null
|
||||||
|
createdAt: string
|
||||||
|
active: boolean
|
||||||
|
}
|
||||||
|
interface Webhook {
|
||||||
|
id: string
|
||||||
|
url: string
|
||||||
|
events: string[]
|
||||||
|
active: boolean
|
||||||
|
sentCount: number
|
||||||
|
failCount: number
|
||||||
|
lastSentAt: string | null
|
||||||
|
lastStatus: number | null
|
||||||
|
createdAt: string
|
||||||
|
hasSecret: boolean
|
||||||
|
}
|
||||||
|
interface ApiData {
|
||||||
|
tokens: ApiToken[]
|
||||||
|
webhooks: Webhook[]
|
||||||
|
availableScopes: string[]
|
||||||
|
knownEvents: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApiView() {
|
||||||
|
const { data, loading, refetch } = useFetch<ApiData>('/api/aos/api-tokens')
|
||||||
|
const { data: webhookData, refetch: refetchWebhooks } = useFetch<ApiData>('/api/aos/webhooks')
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [newToken, setNewToken] = useState<string | null>(null)
|
||||||
|
const [tokenName, setTokenName] = useState('')
|
||||||
|
const [tokenScopes, setTokenScopes] = useState<string[]>([])
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const [whCreateOpen, setWhCreateOpen] = useState(false)
|
||||||
|
const [whUrl, setWhUrl] = useState('')
|
||||||
|
const [whEvents, setWhEvents] = useState<string[]>([])
|
||||||
|
const [whCreating, setWhCreating] = useState(false)
|
||||||
|
const [newWhSecret, setNewWhSecret] = useState<string | null>(null)
|
||||||
|
const [testing, setTesting] = useState(false)
|
||||||
|
|
||||||
|
const allScopes = data?.availableScopes ?? []
|
||||||
|
const knownEvents = webhookData?.knownEvents ?? []
|
||||||
|
const tokens = data?.tokens ?? []
|
||||||
|
const webhooks = webhookData?.webhooks ?? []
|
||||||
|
|
||||||
|
const createToken = async () => {
|
||||||
|
if (!tokenName.trim() || tokenScopes.length === 0) return
|
||||||
|
setCreating(true)
|
||||||
|
const t = toast.loading('Token wird generiert (256-bit, SHA-256 gehasht)…')
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/aos/api-tokens', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: tokenName, scopes: tokenScopes }),
|
||||||
|
})
|
||||||
|
const res = await r.json()
|
||||||
|
if (!r.ok) throw new Error(res.error || 'create failed')
|
||||||
|
setNewToken(res.token)
|
||||||
|
toast.success('Token erstellt — nur einmal sichtbar!', { id: t })
|
||||||
|
refetch()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Erstellung fehlgeschlagen', { id: t })
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const revokeToken = async (id: string) => {
|
||||||
|
const t = toast.loading('Token wird widerrufen…')
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/aos/api-tokens/${id}/revoke`, { method: 'PATCH' })
|
||||||
|
if (!r.ok) throw new Error('revoke failed')
|
||||||
|
toast.success('Token widerrufen', { id: t })
|
||||||
|
refetch()
|
||||||
|
} catch {
|
||||||
|
toast.error('Widerruf fehlgeschlagen', { id: t })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createWebhook = async () => {
|
||||||
|
if (!whUrl.trim() || whEvents.length === 0) return
|
||||||
|
setWhCreating(true)
|
||||||
|
const t = toast.loading('Webhook wird registriert (HMAC-Secret generiert)…')
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/aos/webhooks', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: whUrl, events: whEvents }),
|
||||||
|
})
|
||||||
|
const res = await r.json()
|
||||||
|
if (!r.ok) throw new Error(res.error || 'create failed')
|
||||||
|
setNewWhSecret(res.secret)
|
||||||
|
toast.success('Webhook registriert — Secret einmalig sichtbar', { id: t })
|
||||||
|
refetchWebhooks()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Registrierung fehlgeschlagen', { id: t })
|
||||||
|
} finally {
|
||||||
|
setWhCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const testWebhooks = async () => {
|
||||||
|
setTesting(true)
|
||||||
|
const t = toast.loading('Test-Webhook wird versendet (realer HTTP POST + HMAC)…')
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/v1/webhooks/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ event: 'test.event', payload: { source: 'AOS UI', timestamp: new Date().toISOString() } }),
|
||||||
|
})
|
||||||
|
const res = await r.json()
|
||||||
|
if (!r.ok) throw new Error(res.error || 'test failed')
|
||||||
|
toast.success(`${res.delivered} Webhook(s) ausgeliefert`, { id: t })
|
||||||
|
refetchWebhooks()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Test fehlgeschlagen', { id: t })
|
||||||
|
} finally {
|
||||||
|
setTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyToken = () => {
|
||||||
|
if (newToken) {
|
||||||
|
navigator.clipboard.writeText(newToken)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading || !data) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-4 md:p-6">
|
||||||
|
<Skeleton className="h-9 w-48" />
|
||||||
|
<Skeleton className="h-40" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-4 md:p-6">
|
||||||
|
<PageHeader
|
||||||
|
title="API & Webhooks"
|
||||||
|
description="Public REST API mit Scoped API-Tokens (SHA-256 gehasht). Webhooks mit HMAC-SHA256-Signatur. OpenAPI-Spec auto-generiert. Für externe Agenten (ZeroClaw) und Integrationen."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-muted-foreground">Aktive Tokens</p>
|
||||||
|
<KeyRound className="size-3.5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{tokens.filter((t) => t.active).length}</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-muted-foreground">Webhooks</p>
|
||||||
|
<Webhook className="size-3.5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{webhooks.filter((w) => w.active).length}</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-muted-foreground">Ausgeliefert</p>
|
||||||
|
<Send className="size-3.5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{webhooks.reduce((s, w) => s + w.sentCount, 0)}</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-muted-foreground">Fehlgeschlagen</p>
|
||||||
|
<Activity className="size-3.5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{webhooks.reduce((s, w) => s + w.failCount, 0)}</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="tokens">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="tokens">API-Tokens</TabsTrigger>
|
||||||
|
<TabsTrigger value="webhooks">Webhooks</TabsTrigger>
|
||||||
|
<TabsTrigger value="docs">Dokumentation</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Tokens tab */}
|
||||||
|
<TabsContent value="tokens" className="mt-4 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Tokens werden SHA-256 gehasht gespeichert. Das Plaintext-Token ist nur einmal bei Erstellung sichtbar.
|
||||||
|
</p>
|
||||||
|
<Button size="sm" onClick={() => { setCreateOpen(true); setNewToken(null); setTokenName(''); setTokenScopes([]) }}>
|
||||||
|
<Plus className="size-4" />Token erstellen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 font-medium">Name</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Präfix</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Scopes</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Zuletzt verwendet</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Status</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Aktion</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{tokens.map((t) => (
|
||||||
|
<tr key={t.id} className="transition hover:bg-muted/40">
|
||||||
|
<td className="px-4 py-2.5 text-xs font-medium">{t.name}</td>
|
||||||
|
<td className="px-4 py-2.5"><code className="font-mono text-[11px] text-muted-foreground">{t.tokenPrefix}…</code></td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{t.scopes.map((s) => <Badge key={s} variant="outline" className="text-[9px] font-mono">{s}</Badge>)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-[11px] text-muted-foreground">{t.lastUsedAt ? formatRelative(t.lastUsedAt) : 'nie'}</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
{t.revokedAt ? (
|
||||||
|
<Badge variant="outline" className="text-[10px] text-muted-foreground">Widerrufen</Badge>
|
||||||
|
) : t.active ? (
|
||||||
|
<Badge variant="outline" className="text-[10px] border-foreground/40">Aktiv</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-[10px]">Abgelaufen</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
{t.active && (
|
||||||
|
<Button size="sm" variant="ghost" className="h-7 gap-1 text-[11px] text-muted-foreground hover:text-foreground" onClick={() => revokeToken(t.id)}>
|
||||||
|
<Ban className="size-3" />Widerrufen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Webhooks tab */}
|
||||||
|
<TabsContent value="webhooks" className="mt-4 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
POST mit HMAC-SHA256-Signatur (<code className="font-mono">x-aos-signature</code> Header).
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={testWebhooks} disabled={testing}>
|
||||||
|
{testing ? <Loader2 className="size-4 animate-spin" /> : <Send className="size-4" />}
|
||||||
|
Test senden
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => { setWhCreateOpen(true); setNewWhSecret(null); setWhUrl(''); setWhEvents([]) }}>
|
||||||
|
<Plus className="size-4" />Webhook
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{webhooks.map((w) => (
|
||||||
|
<Card key={w.id} className="p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="truncate text-xs font-mono">{w.url}</code>
|
||||||
|
{w.lastStatus && (
|
||||||
|
<Badge variant="outline" className={cn('text-[10px]', w.lastStatus >= 200 && w.lastStatus < 300 ? 'border-foreground/40' : 'border-foreground/60')}>
|
||||||
|
{w.lastStatus}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{!w.active && <Badge variant="outline" className="text-[10px] text-muted-foreground">Inaktiv</Badge>}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{w.events.map((e) => <Badge key={e} variant="secondary" className="text-[9px] font-mono">{e}</Badge>)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center gap-3 text-[10px] text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1"><Send className="size-2.5" />{w.sentCount} gesendet</span>
|
||||||
|
{w.failCount > 0 && <span className="flex items-center gap-1"><Activity className="size-2.5" />{w.failCount} fehl</span>}
|
||||||
|
{w.lastSentAt && <span>· {formatRelative(w.lastSentAt)}</span>}
|
||||||
|
{w.hasSecret && <span className="flex items-center gap-1"><ShieldCheck className="size-2.5" />Secret gesetzt</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
{webhooks.length === 0 && <p className="py-6 text-center text-xs text-muted-foreground">Keine Webhooks registriert.</p>}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Docs tab */}
|
||||||
|
<TabsContent value="docs" className="mt-4 space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm">
|
||||||
|
<Code className="size-3.5 text-muted-foreground" />
|
||||||
|
OpenAPI-Spezifikation
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-xs">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Die API-Spezifikation wird automatisch generiert und unter <code className="rounded bg-muted px-1 font-mono">/api/v1/openapi.json</code> bereitgestellt.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-semibold">Verfügbare Endpoints:</p>
|
||||||
|
<pre className="overflow-x-auto rounded-md border border-border bg-muted/30 p-3 text-[11px] leading-relaxed">{`GET /api/v1/companies # crm.company:read
|
||||||
|
GET /api/v1/invoices # finance.invoice:read
|
||||||
|
GET /api/v1/licenses # licenses.license:read
|
||||||
|
POST /api/v1/webhooks/test # webhooks.test
|
||||||
|
GET /api/v1/openapi.json # (öffentlich, kein Token)`}</pre>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-semibold">Authentifizierung:</p>
|
||||||
|
<pre className="overflow-x-auto rounded-md border border-border bg-muted/30 p-3 text-[11px] leading-relaxed">{`curl -H "Authorization: Bearer aos_<64hex>" \\
|
||||||
|
https://aos.nordlicht.digital/api/v1/companies`}</pre>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-semibold">Webhook-Signatur verifizieren (Node.js):</p>
|
||||||
|
<pre className="overflow-x-auto rounded-md border border-border bg-muted/30 p-3 text-[11px] leading-relaxed">{`import { createHmac } from 'crypto'
|
||||||
|
const sig = createHmac('sha256', WEBHOOK_SECRET)
|
||||||
|
.update(\`\${timestamp}.\${body}\`)
|
||||||
|
.digest('hex')
|
||||||
|
// vergleiche mit x-aos-signature Header: "sha256=<sig>"`}</pre>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-dashed border-border p-3 text-[11px] text-muted-foreground">
|
||||||
|
<p className="font-medium text-foreground">Sicherheit</p>
|
||||||
|
<ul className="mt-1 space-y-0.5">
|
||||||
|
<li>• Tokens werden SHA-256 gehasht gespeichert (compromised DB → keine nutzbaren Tokens)</li>
|
||||||
|
<li>• Scopes sind <code className="font-mono">resource:action</code> (kein Wildcard-Schreiben)</li>
|
||||||
|
<li>• Webhook-Secrets werden einmalig generiert und zur Verifizierung benötigt</li>
|
||||||
|
<li>• Alle API-Zugriffe werden im Audit-Log protokolliert</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">Verfügbare Scopes</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-1.5">
|
||||||
|
{allScopes.map((s) => (
|
||||||
|
<div key={s} className="flex items-center justify-between rounded-md border border-border px-3 py-1.5">
|
||||||
|
<code className="font-mono text-[11px]">{s}</code>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{s.startsWith('crm.') && 'Lese-Zugriff auf Firmen/Kontakte'}
|
||||||
|
{s.startsWith('finance.') && 'Lese-Zugriff auf Rechnungen'}
|
||||||
|
{s.startsWith('licenses.') && 'Lese-Zugriff auf Lizenzen'}
|
||||||
|
{s.startsWith('webhooks.') && 'Webhook-Tests auslösen'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Create Token Dialog */}
|
||||||
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Neues API-Token</DialogTitle>
|
||||||
|
<DialogDescription>Token wird kryptografisch generiert und SHA-256 gehasht gespeichert.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{newToken ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="rounded-md border border-foreground/30 bg-foreground/5 p-3">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Token (nur einmal sichtbar)</p>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<code className="flex-1 truncate font-mono text-[11px]">{newToken}</code>
|
||||||
|
<Button size="sm" variant="outline" className="h-7 shrink-0" onClick={copyToken}>
|
||||||
|
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-[10px] text-muted-foreground">⚠ Speichere das Token jetzt sicher. Es kann später nicht wieder angezeigt werden.</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => setCreateOpen(false)}>Fertig</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Name</Label>
|
||||||
|
<Input value={tokenName} onChange={(e) => setTokenName(e.target.value)} placeholder="z.B. ZeroClaw Agent" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">Scopes</Label>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{allScopes.map((s) => (
|
||||||
|
<label key={s} className="flex items-center gap-2 rounded-md border border-border p-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={tokenScopes.includes(s)}
|
||||||
|
onCheckedChange={(c) => {
|
||||||
|
if (c) setTokenScopes((sc) => [...sc, s])
|
||||||
|
else setTokenScopes((sc) => sc.filter((x) => x !== s))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<code className="font-mono text-[11px]">{s}</code>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>Abbrechen</Button>
|
||||||
|
<Button onClick={createToken} disabled={creating || !tokenName.trim() || tokenScopes.length === 0} className="gap-1.5">
|
||||||
|
{creating && <Loader2 className="size-3.5 animate-spin" />}
|
||||||
|
Generieren
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Create Webhook Dialog */}
|
||||||
|
<Dialog open={whCreateOpen} onOpenChange={setWhCreateOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Webhook registrieren</DialogTitle>
|
||||||
|
<DialogDescription>HMAC-SHA256-Signatur wird für jede Auslieferung berechnet.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{newWhSecret ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="rounded-md border border-foreground/30 bg-foreground/5 p-3">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Signing-Secret (nur einmal sichtbar)</p>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<code className="flex-1 truncate font-mono text-[11px]">{newWhSecret}</code>
|
||||||
|
<Button size="sm" variant="outline" className="h-7" onClick={() => { navigator.clipboard.writeText(newWhSecret); toast.success('Secret kopiert') }}>
|
||||||
|
<Copy className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-[10px] text-muted-foreground">Benötigt zur Verifizierung der <code className="font-mono">x-aos-signature</code> Header auf deinem Server.</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter><Button onClick={() => setWhCreateOpen(false)}>Fertig</Button></DialogFooter>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Ziel-URL (HTTPS)</Label>
|
||||||
|
<Input value={whUrl} onChange={(e) => setWhUrl(e.target.value)} placeholder="https://your-server.com/webhooks/aos" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">Events (abonnieren)</Label>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{knownEvents.map((e) => (
|
||||||
|
<label key={e} className="flex items-center gap-2 rounded-md border border-border p-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={whEvents.includes(e)}
|
||||||
|
onCheckedChange={(c) => {
|
||||||
|
if (c) setWhEvents((ev) => [...ev, e])
|
||||||
|
else setWhEvents((ev) => ev.filter((x) => x !== e))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<code className="font-mono text-[11px]">{e}</code>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setWhCreateOpen(false)}>Abbrechen</Button>
|
||||||
|
<Button onClick={createWebhook} disabled={whCreating || !whUrl.trim() || whEvents.length === 0} className="gap-1.5">
|
||||||
|
{whCreating && <Loader2 className="size-3.5 animate-spin" />}
|
||||||
|
Registrieren
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
|
||||||
|
// AOS Public API authentication + authorization.
|
||||||
|
// Tokens are SHA-256 hashed at rest (never stored plaintext).
|
||||||
|
|
||||||
|
export interface AuthResult {
|
||||||
|
ok: boolean
|
||||||
|
token?: { id: string; name: string; scopes: string[] }
|
||||||
|
error?: string
|
||||||
|
status?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify a Bearer token from the Authorization header.
|
||||||
|
// Returns the token record if valid + not revoked + not expired.
|
||||||
|
export async function verifyToken(authHeader: string | null): Promise<AuthResult> {
|
||||||
|
if (!authHeader) {
|
||||||
|
return { ok: false, error: 'Authorization header required', status: 401 }
|
||||||
|
}
|
||||||
|
const match = authHeader.match(/^Bearer\s+(aos_[a-z0-9]+)$/i)
|
||||||
|
if (!match) {
|
||||||
|
return { ok: false, error: 'Invalid token format (expected: Bearer aos_…)', status: 401 }
|
||||||
|
}
|
||||||
|
const token = match[1]!
|
||||||
|
// Hash the token to look it up (constant-time comparison via DB unique index)
|
||||||
|
const tokenHash = createHash('sha256').update(token).digest('hex')
|
||||||
|
const record = await db.apiToken.findUnique({ where: { tokenHash } })
|
||||||
|
if (!record) {
|
||||||
|
return { ok: false, error: 'Invalid or unknown token', status: 401 }
|
||||||
|
}
|
||||||
|
if (record.revokedAt) {
|
||||||
|
return { ok: false, error: 'Token revoked', status: 401 }
|
||||||
|
}
|
||||||
|
if (record.expiresAt && record.expiresAt < new Date()) {
|
||||||
|
return { ok: false, error: 'Token expired', status: 401 }
|
||||||
|
}
|
||||||
|
// update lastUsedAt (fire-and-forget)
|
||||||
|
db.apiToken.update({ where: { id: record.id }, data: { lastUsedAt: new Date() } }).catch(() => {})
|
||||||
|
|
||||||
|
const scopes: string[] = JSON.parse(record.scopes)
|
||||||
|
return { ok: true, token: { id: record.id, name: record.name, scopes } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a token has a specific scope (resource:action).
|
||||||
|
// Supports wildcard: "crm.*:read" matches "crm.company:read".
|
||||||
|
export function hasScope(token: AuthResult['token'], required: string): boolean {
|
||||||
|
if (!token) return false
|
||||||
|
return token.scopes.some((s) => {
|
||||||
|
if (s === required) return true
|
||||||
|
// wildcard: "crm.*:read" matches any crm resource read
|
||||||
|
const [res, action] = s.split(':')
|
||||||
|
if (res && res.endsWith('.*')) {
|
||||||
|
const prefix = res.slice(0, -2)
|
||||||
|
return required.startsWith(prefix + '.') && required.endsWith(':' + action)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require a scope or return a 403 response.
|
||||||
|
export function requireScope(auth: AuthResult, scope: string): { allowed: boolean; error?: Response } {
|
||||||
|
if (!auth.ok || !auth.token) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
error: Response.json({ error: auth.error ?? 'unauthorized' }, { status: auth.status ?? 401 }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hasScope(auth.token, scope)) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
error: Response.json({ error: `insufficient scope — requires "${scope}"`, token: auth.token.name, scopes: auth.token.scopes }, { status: 403 }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { allowed: true }
|
||||||
|
}
|
||||||
+44
-1
@@ -669,10 +669,53 @@ async function main() {
|
|||||||
|
|
||||||
// audit entries for accounting
|
// audit entries for accounting
|
||||||
await db.auditLog.create({ data: { actor: 'nora@nordlicht.digital', action: 'vat.compute', entity: 'VatReturn', entityId: 'seed', after: '{"period":"2026-10","payable":94800}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
|
await db.auditLog.create({ data: { actor: 'nora@nordlicht.digital', action: 'vat.compute', entity: 'VatReturn', entityId: 'seed', after: '{"period":"2026-10","payable":94800}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
|
||||||
await db.auditLog.create({ data: { actor: 'nora@nordlicht.digital', action: 'vat.submit:elster', entity: 'VatReturn', entityId: 'seed', after: '{"elsterRef":"ERiC-2026-09","status":"accepted"}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
|
await db.auditLog.create({ data: { actor: 'nora@nordlicht.digital', action: 'vat.prepare:xml', entity: 'VatReturn', entityId: 'seed', after: '{"status":"prepared","xmlBytes":993}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
|
||||||
await db.auditLog.create({ data: { actor: 'nora@nordlicht.digital', action: 'bank.import:camt053', entity: 'BankTransaction', entityId: 'seed', after: '{"count":10,"batch":"camt-2026-10"}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
|
await db.auditLog.create({ data: { actor: 'nora@nordlicht.digital', action: 'bank.import:camt053', entity: 'BankTransaction', entityId: 'seed', after: '{"count":10,"batch":"camt-2026-10"}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
|
||||||
await db.auditLog.create({ data: { actor: 'nora@nordlicht.digital', action: 'bank.match:invoice', entity: 'BankTransaction', entityId: 'seed', after: '{"invoiceId":"matched","status":"matched"}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
|
await db.auditLog.create({ data: { actor: 'nora@nordlicht.digital', action: 'bank.match:invoice', entity: 'BankTransaction', entityId: 'seed', after: '{"invoiceId":"matched","status":"matched"}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
|
||||||
|
|
||||||
|
// --- API Tokens + Webhooks (real crypto, hashed at rest) -----------------
|
||||||
|
const crypto = await import('crypto')
|
||||||
|
const apiTokens = [
|
||||||
|
{ name: 'ZeroClaw Agent (read-only)', scopes: ['crm.company:read', 'finance.invoice:read', 'licenses.license:read'] },
|
||||||
|
{ name: 'DATEV Export-Skript', scopes: ['finance.invoice:read'] },
|
||||||
|
{ name: 'WordPress Update-Server', scopes: ['licenses.license:read'] },
|
||||||
|
]
|
||||||
|
for (const t of apiTokens) {
|
||||||
|
const plaintext = 'aos_' + crypto.randomBytes(32).toString('hex')
|
||||||
|
const tokenHash = crypto.createHash('sha256').update(plaintext).digest('hex')
|
||||||
|
const tokenPrefix = 'aos_' + plaintext.slice(4, 11)
|
||||||
|
await db.apiToken.create({
|
||||||
|
data: { name: t.name, tokenHash, tokenPrefix, scopes: JSON.stringify(t.scopes), lastUsedAt: daysFromNow(-2) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// one revoked token
|
||||||
|
const revokedPlain = 'aos_' + crypto.randomBytes(32).toString('hex')
|
||||||
|
await db.apiToken.create({
|
||||||
|
data: {
|
||||||
|
name: 'Altes Backup-Skript (widerrufen)',
|
||||||
|
tokenHash: crypto.createHash('sha256').update(revokedPlain).digest('hex'),
|
||||||
|
tokenPrefix: 'aos_' + revokedPlain.slice(4, 11),
|
||||||
|
scopes: JSON.stringify(['finance.invoice:read']),
|
||||||
|
revokedAt: daysFromNow(-30),
|
||||||
|
lastUsedAt: daysFromNow(-32),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Webhooks with real HMAC secrets
|
||||||
|
const webhooks = [
|
||||||
|
{ url: 'https://make.nordlicht.digital/webhooks/aos', events: ['invoice.paid', 'contract.signed', 'lead.approved'], sentCount: 47, failCount: 2, lastStatus: 200, lastSentAt: daysFromNow(-0.2) },
|
||||||
|
{ url: 'https://hooks.slack.com/services/T0/B0/xxxx', events: ['ticket.created', 'invoice.overdue'], sentCount: 12, failCount: 0, lastStatus: 200, lastSentAt: daysFromNow(-1) },
|
||||||
|
{ url: 'https://erp.hansemoebel.de/api/aos-events', events: ['license.activated'], sentCount: 5, failCount: 1, lastStatus: 500, lastSentAt: daysFromNow(-3) },
|
||||||
|
]
|
||||||
|
for (const w of webhooks) {
|
||||||
|
await db.webhook.create({
|
||||||
|
data: { ...w, events: JSON.stringify(w.events), secret: 'whsec_' + crypto.randomBytes(24).toString('hex') },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.auditLog.create({ data: { actor: 'max@nordlicht.digital', action: 'api.token:create', entity: 'ApiToken', entityId: 'seed', after: '{"name":"ZeroClaw Agent","scopes":["crm.company:read"]}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
|
||||||
|
await db.auditLog.create({ data: { actor: 'max@nordlicht.digital', action: 'webhook.create', entity: 'Webhook', entityId: 'seed', after: '{"url":"make.nordlicht.digital","events":["invoice.paid"]}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
|
||||||
|
|
||||||
console.log('Seed complete.')
|
console.log('Seed complete.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { createHmac } from 'crypto'
|
||||||
|
|
||||||
|
// Deliver a webhook event to all registered endpoints that subscribe to it.
|
||||||
|
// Each delivery is signed with HMAC-SHA256 using the webhook's secret so the
|
||||||
|
// receiver can verify authenticity.
|
||||||
|
|
||||||
|
export async function dispatchWebhook(event: string, payload: unknown): Promise<void> {
|
||||||
|
const webhooks = await db.webhook.findMany({ where: { active: true } })
|
||||||
|
const matching = webhooks.filter((w) => {
|
||||||
|
const events: string[] = JSON.parse(w.events)
|
||||||
|
return events.includes(event) || events.includes('*')
|
||||||
|
})
|
||||||
|
// Fire-and-forget: each delivery is independent
|
||||||
|
for (const hook of matching) {
|
||||||
|
void deliver(hook, event, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deliver(
|
||||||
|
hook: { id: string; url: string; secret: string },
|
||||||
|
event: string,
|
||||||
|
payload: unknown
|
||||||
|
): Promise<void> {
|
||||||
|
const body = JSON.stringify({ event, deliveredAt: new Date().toISOString(), data: payload })
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000)
|
||||||
|
const signatureBase = `${timestamp}.${body}`
|
||||||
|
const signature = createHmac('sha256', hook.secret).update(signatureBase).digest('hex')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(hook.url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-aos-event': event,
|
||||||
|
'x-aos-timestamp': String(timestamp),
|
||||||
|
'x-aos-signature': `sha256=${signature}`,
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
})
|
||||||
|
await db.webhook.update({
|
||||||
|
where: { id: hook.id },
|
||||||
|
data: {
|
||||||
|
sentCount: { increment: 1 },
|
||||||
|
lastSentAt: new Date(),
|
||||||
|
lastStatus: res.status,
|
||||||
|
failCount: res.ok ? undefined : { increment: 1 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
await db.webhook.update({
|
||||||
|
where: { id: hook.id },
|
||||||
|
data: { sentCount: { increment: 1 }, failCount: { increment: 1 }, lastSentAt: new Date() },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export type ViewId =
|
|||||||
| 'finance'
|
| 'finance'
|
||||||
| 'accounting'
|
| 'accounting'
|
||||||
| 'audit'
|
| 'audit'
|
||||||
|
| 'api'
|
||||||
| 'reporting'
|
| 'reporting'
|
||||||
| 'settings'
|
| 'settings'
|
||||||
| 'setup'
|
| 'setup'
|
||||||
|
|||||||
Reference in new issue
Block a user