From c6adc67479d11f9125942aeeee50b0226a45d905 Mon Sep 17 00:00:00 2001 From: Z User Date: Wed, 28 Oct 2026 16:37:26 +0000 Subject: [PATCH] 4413045f-d05e-4d7a-a70d-81d6be253c85 --- .env | 41 + db/custom.db | Bin 491520 -> 520192 bytes prisma/schema.prisma | 92 ++ src/app/api/aos/instances/[id]/route.ts | 257 ++++ src/app/api/aos/instances/cleanup/route.ts | 165 +++ src/app/api/aos/instances/route.ts | 314 ++++ src/app/api/aos/licenses/[id]/cancel/route.ts | 37 +- src/components/aos/app-shell.tsx | 2 + src/components/aos/command-menu.tsx | 2 + src/components/aos/sidebar-nav.tsx | 2 + src/components/aos/top-bar.tsx | 1 + src/components/aos/views/instances.tsx | 1280 +++++++++++++++++ src/lib/dokploy.ts | 392 +++++ src/lib/hetzner-dns.ts | 271 ++++ src/lib/i18n.ts | 3 + src/store/aos.ts | 1 + .../read_1793204612141_0f15d70a7836.txt | 1202 ++++++++++++++++ .../read_1793204614406_d730a1dabdf0.txt | 1202 ++++++++++++++++ 18 files changed, 5263 insertions(+), 1 deletion(-) create mode 100644 src/app/api/aos/instances/[id]/route.ts create mode 100644 src/app/api/aos/instances/cleanup/route.ts create mode 100644 src/app/api/aos/instances/route.ts create mode 100644 src/components/aos/views/instances.tsx create mode 100644 src/lib/dokploy.ts create mode 100644 src/lib/hetzner-dns.ts create mode 100644 tool-results/read_1793204612141_0f15d70a7836.txt create mode 100644 tool-results/read_1793204614406_d730a1dabdf0.txt diff --git a/.env b/.env index 7f9ca6d..a111aa5 100755 --- a/.env +++ b/.env @@ -37,3 +37,44 @@ LICENSE_OFFLINE_GRACE_HOURS=72 AGENCY_DISCOUNT_MIN_PCT=0 AGENCY_DISCOUNT_MAX_PCT=20 AGENCY_DISCOUNT_DEFAULT_PCT=10 + +# ── Platform Domain ── +# The domain AOS runs on. Tenant instances get subdomains of this domain. +PLATFORM_DOMAIN=dfmpanel.eu +# Public URL of the AOS instance (used by license-server SDK + email links) +AOS_PUBLIC_URL=https://dfmpanel.eu + +# ── Dokploy (tenant instance provisioning) ── +# Dokploy server URL + API key for managing tenant instances. +DOKPLOY_URL=https://cp.project-url.eu +DOKPLOY_API_KEY=CEIqJlWVtmSNgffNWywTJmEeuvUJBndinBzudretwiuzVWPxuvUNmoQaHUwRwUBy +# The Dokploy server ID to deploy to (default "default" if not set) +# DOKPLOY_SERVER_ID=default +# The Dokploy project to group tenant applications under +# DOKPLOY_PROJECT_ID=default +# The IP of the Dokploy server (used for Hetzner DNS A records) +# DOKPLOY_SERVER_IP=123.456.789.012 + +# ── Hetzner Cloud DNS ── +# API token scoped to the platform zone (dfmpanel.eu) +# HETZNER_DNS_TOKEN=your-hetzner-dns-token +# Zone ID of the platform domain (find via: GET https://dns.hetzner.com/api/v1/zones) +# HETZNER_ZONE_ID=your-zone-id +# If true, a single *.dfmpanel.eu wildcard A record handles all subdomains +# (recommended — avoids per-instance DNS records + Let's Encrypt rate limits) +USE_WILDCARD_DNS=true + +# ── Cron Job ── +# Shared secret for the cleanup cron job (POST /api/aos/instances/cleanup) +# The cron job (Dokploy cron or GitHub Action) must send this as: +# Authorization: Bearer +# CRON_SECRET=your-cron-secret + +# ── Instance Defaults ── +# Default Git repo + branch for new tenant instances +INSTANCE_GIT_REPO=https://github.com/cetinmustafa83/restov1 +INSTANCE_GIT_BRANCH=main +# Demo instance duration in days +INSTANCE_DEMO_DAYS=14 +# Grace period after demo expiry or subscription cancellation (days) +INSTANCE_GRACE_DAYS=7 diff --git a/db/custom.db b/db/custom.db index 1bf26831ff12e2311c54a325f865a0730d67b937..247a9e904f51666a081262887f5db73a8c33a0bc 100755 GIT binary patch delta 1527 zcmbtU&ubHD7|#5zX=__eleS6BbYQ8auC;7kD6+6I88u5xYZ_M%Lh?29wG5riOlKzj zp}U)uHHtS&&^L&O76cC-c6(TRbzwn75cMxuFCILsdRD}->BLTqr~P1H-uHRteR!XF z->+OgQ2sY^J=k;40fX$pUdrNwAQB>Jg&S%tAU5ui;A>(#Y(KZ`627j$^xC%@TKK+Dla816ZG z$jjSDP>^YfhM^gNs?;$~#miQ@s6tPL9)V{GH+g5hqGW!Z4o} zPl#zT6%(f&uVM2R)yu`A)3In=gpF~+NB&TRAO9gkv(0tKni9YMe%RmwLqo)s;I?Is z=i(w(Emv^SZI=rJ6e7QMSp|_R^nm}9O#+`0&u4bLFtAxvVDmB^;3RAzSuv|`xdfCa zi4MQ3;{P^a!i0s#WGL;fI_3&5UE8N~fpC~uTXHq;ka8_shOvj_VF)IPE_nKcs4XTed7O|0#s%xG7 zJ_*mFqGCBj2^TaGf;FnYZO%Y>EUFUlHOj|GSiu$$<3w*FUonAyd_70COfV!Uy1sry^F={LJRok|8`ywYX_y1(+ U?k&2^ypP;`z|wc^H@+Tz1Sz2FhX4Qo delta 98 zcmZp8Am7j+H$hr(83O}D1rR3yF(VLrOw=)ET-KP-n!wnaz|@+++?v3$HGy@(dp4jX v5VW&>VBOC4f$ev_Fp$T_wSa;DB7X_@N$xq^mRt)qD+=^*ZC_Bup1=wK_HP>7 diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4cf788f..fc1d9de 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -105,6 +105,7 @@ model Company { expenses Expense[] tickets Ticket[] agencyApplication AgencyApplication? + instances Instance[] } model Person { @@ -291,6 +292,10 @@ model License { stripeSubscriptionId String? stripePriceId String? + // Link to the tenant instance (1:1). Set when this license has a deployed + // instance on the Dokploy server. + instance Instance? + createdAt DateTime @default(now()) activations LicenseActivation[] } @@ -892,3 +897,90 @@ model CustomFieldValue { @@unique([entityType, entityId, fieldDefId]) } + +// --------------------------------------------------------------------------- +// Tenant Instance Provisioning (PRD §6: SaaS demo + paid instances via Dokploy) +// --------------------------------------------------------------------------- +// AOS runs at dfmpanel.eu. Each license may have a tenant instance: an isolated +// deployment of restov1 (or another product) running on the Dokploy server, +// reachable under .dfmpanel.eu (demo) or the customer's own domain. +// +// Lifecycle: +// requested -> provisioning -> demo_active --(subscribe)--> active +// | | +// (expired/cancelled) (sub cancelled) +// v v +// grace <-------------------+ +// | +// (grace elapsed) +// v +// deleting -> deleted +// failed (retried, then alerts operator) + +model Instance { + id String @id @default(cuid()) + licenseId String? @unique // link to License (1:1, nullable for orphan cleanup) + license License? @relation(fields: [licenseId], references: [id]) + companyId String // owning customer + company Company @relation(fields: [companyId], references: [id]) + product String // 'restov1', 'aos-cms', ... (which template to deploy) + status String @default("requested") // requested|provisioning|demo_active|active|grace|deleting|deleted|failed + plan String @default("pro") // starter|pro|enterprise (resource limits) + // Subdomain on the platform domain (e.g. "k7f3a9" -> k7f3a9.dfmpanel.eu) + subdomain String @unique + // Dokploy server id (for future multi-server; v1 single server) + serverId String @default("default") + // Lifecycle timestamps + demoExpiresAt DateTime? + graceEndsAt DateTime? + deletedAt DateTime? + // Custom domain (customer's own domain, verified via TXT) + customDomain String? + domainVerificationToken String? + domainVerifiedAt DateTime? + // Dokploy resource IDs (returned by Dokploy API on creation) + dokployAppId String? + dokployDbId String? + dokployDbName String? + dokployDbUser String? + dokployDbPassword String? // stored encrypted in production; demo ok plaintext dev + // Hetzner DNS record ID (null if using wildcard DNS) + dnsRecordId String? + // Admin login link (one-time, demo only) + adminLoginToken String? + adminLoginExpiresAt DateTime? + // Provisioning log + metadata + metadata String? // JSON: { gitRepo, gitBranch, imageTag, resources } + lastError String? + lastErrorAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + events InstanceEvent[] + jobs ProvisioningJob[] +} + +model InstanceEvent { + id String @id @default(cuid()) + instanceId String + instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade) + type String // provision.start, provision.dokploy.created, provision.dns.created, provision.ok, delete.start, delete.dokploy.removed, delete.ok, suspend, reactivate, domain.verify, domain.attached, etc. + step String? // sub-step identifier + result String // ok|error|pending + details String? // JSON + createdAt DateTime @default(now()) +} + +model ProvisioningJob { + id String @id @default(cuid()) + instanceId String + instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade) + kind String // provision|delete|domain|suspend|reactivate + step String // current step in the sequence + attempts Int @default(0) + maxAttempts Int @default(5) + lastError String? + status String @default("pending") // pending|running|done|failed + nextRunAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/src/app/api/aos/instances/[id]/route.ts b/src/app/api/aos/instances/[id]/route.ts new file mode 100644 index 0000000..96e8a08 --- /dev/null +++ b/src/app/api/aos/instances/[id]/route.ts @@ -0,0 +1,257 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' +import { z } from 'zod' +import { createDokployClientFromEnv } from '@/lib/dokploy' +import { createHetznerDnsClientFromEnv } from '@/lib/hetzner-dns' + +export const dynamic = 'force-dynamic' +export const maxDuration = 300 + +const patchSchema = z.object({ + action: z.enum(['suspend', 'reactivate', 'extend', 'retry']), + extendDays: z.number().int().min(1).max(90).optional(), +}) + +// GET /api/aos/instances/[id] — instance detail with events + provisioning log +export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params + const instance = await db.instance.findUnique({ + where: { id }, + include: { + company: { select: { id: true, name: true, email: true } }, + license: { select: { id: true, key: true, product: true, plan: true, billingCycle: true, netPriceCents: true, status: true, cancelledAt: true } }, + events: { orderBy: { createdAt: 'desc' }, take: 50 }, + jobs: { orderBy: { createdAt: 'desc' }, take: 10 }, + }, + }) + if (!instance) { + return NextResponse.json({ error: 'instance not found' }, { status: 404 }) + } + return NextResponse.json({ instance }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'internal server error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} + +// PATCH /api/aos/instances/[id] — suspend / reactivate / extend demo / retry provisioning +export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params + const parsed = patchSchema.safeParse(await req.json()) + if (!parsed.success) { + return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 }) + } + const { action, extendDays } = parsed.data + + const instance = await db.instance.findUnique({ where: { id } }) + if (!instance) { + return NextResponse.json({ error: 'instance not found' }, { status: 404 }) + } + + const dokploy = createDokployClientFromEnv() + + if (action === 'suspend') { + if (!['demo_active', 'active'].includes(instance.status)) { + return NextResponse.json({ error: `cannot suspend instance in status ${instance.status}` }, { status: 400 }) + } + const prevStatus = instance.status + // Stop the Dokploy application + if (dokploy && instance.dokployAppId) { + try { + await dokploy.stopApplication(instance.dokployAppId) + } catch (e) { + // Log but don't fail — the instance record is the source of truth + await db.instanceEvent.create({ + data: { + instanceId: id, + type: 'suspend.dokploy.error', + step: 'dokploy', + result: 'error', + details: JSON.stringify({ error: e instanceof Error ? e.message : 'unknown' }), + }, + }) + } + } + await db.instance.update({ + where: { id }, + data: { + status: 'grace', + graceEndsAt: new Date(Date.now() + 7 * 86400000), // 7-day grace + }, + }) + await db.instanceEvent.create({ + data: { instanceId: id, type: 'suspend', step: 'done', result: 'ok', details: JSON.stringify({ prevStatus }) }, + }) + return NextResponse.json({ ok: true, status: 'grace' }) + } + + if (action === 'reactivate') { + if (instance.status !== 'grace') { + return NextResponse.json({ error: 'can only reactivate from grace' }, { status: 400 }) + } + if (dokploy && instance.dokployAppId) { + try { + await dokploy.startApplication(instance.dokployAppId) + } catch (e) { + await db.instanceEvent.create({ + data: { instanceId: id, type: 'reactivate.dokploy.error', step: 'dokploy', result: 'error', details: JSON.stringify({ error: e instanceof Error ? e.message : 'unknown' }) }, + }) + } + } + // If the license is still in trial, keep demo_active; otherwise active + const newStatus = instance.demoExpiresAt && new Date(instance.demoExpiresAt) > new Date() ? 'demo_active' : 'active' + await db.instance.update({ + where: { id }, + data: { status: newStatus, graceEndsAt: null }, + }) + await db.instanceEvent.create({ + data: { instanceId: id, type: 'reactivate', step: 'done', result: 'ok', details: JSON.stringify({ status: newStatus }) }, + }) + return NextResponse.json({ ok: true, status: newStatus }) + } + + if (action === 'extend') { + if (instance.status !== 'demo_active') { + return NextResponse.json({ error: 'can only extend demo_active instances' }, { status: 400 }) + } + const days = extendDays ?? 7 + const newExpiry = new Date(Date.now() + days * 86400000) + await db.instance.update({ + where: { id }, + data: { demoExpiresAt: newExpiry }, + }) + await db.instanceEvent.create({ + data: { instanceId: id, type: 'demo.extend', step: 'done', result: 'ok', details: JSON.stringify({ extendedBy: days, newExpiry }) }, + }) + return NextResponse.json({ ok: true, demoExpiresAt: newExpiry }) + } + + if (action === 'retry') { + // Reset to provisioning — the reconciler or manual call will re-provision + if (instance.status !== 'failed') { + return NextResponse.json({ error: 'can only retry failed instances' }, { status: 400 }) + } + await db.instance.update({ + where: { id }, + data: { status: 'requested', lastError: null, lastErrorAt: null }, + }) + await db.instanceEvent.create({ + data: { instanceId: id, type: 'provision.retry', step: 'init', result: 'pending' }, + }) + return NextResponse.json({ ok: true, status: 'requested', message: 'Instance queued for re-provisioning' }) + } + + return NextResponse.json({ error: 'unknown action' }, { status: 400 }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'internal server error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} + +// DELETE /api/aos/instances/[id] — tear down instance (idempotent, resumable) +// Removes: Dokploy domain → database → application + volumes → DNS record. +// Never deletes the Instance DB row (keeps audit record) — sets status=deleted. +export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params + const instance = await db.instance.findUnique({ where: { id } }) + if (!instance) { + return NextResponse.json({ error: 'instance not found' }, { status: 404 }) + } + if (instance.status === 'deleted') { + return NextResponse.json({ ok: true, message: 'already deleted', steps: [] }) + } + + await db.instance.update({ where: { id }, data: { status: 'deleting' } }) + await db.instanceEvent.create({ + data: { instanceId: id, type: 'delete.start', step: 'init', result: 'pending' }, + }) + + const steps: string[] = [] + const dokploy = createDokployClientFromEnv() + const dns = createHetznerDnsClientFromEnv() + + // Parse metadata for Dokploy domain ID + const meta = instance.metadata ? JSON.parse(instance.metadata) as { dokployDomainId?: string; url?: string } : {} + + // 1. Remove Dokploy domain + database + application (idempotent) + if (dokploy) { + try { + const result = await dokploy.teardownInstance({ + dokployAppId: instance.dokployAppId ?? undefined, + dokployDbId: instance.dokployDbId ?? undefined, + dnsDomainId: meta.dokployDomainId, + }) + steps.push(...result.steps) + await db.instanceEvent.create({ + data: { instanceId: id, type: 'delete.dokploy.done', step: 'dokploy', result: 'ok', details: JSON.stringify({ steps: result.steps }) }, + }) + } catch (e) { + steps.push(`dokploy.error: ${e instanceof Error ? e.message : 'unknown'}`) + await db.instanceEvent.create({ + data: { instanceId: id, type: 'delete.dokploy.error', step: 'dokploy', result: 'error', details: JSON.stringify({ error: e instanceof Error ? e.message : 'unknown' }) }, + }) + } + } else { + steps.push('dokploy.skipped (not configured)') + } + + // 2. Remove DNS A record (if per-instance, not wildcard) + if (dns && instance.dnsRecordId && instance.dnsRecordId !== 'wildcard') { + try { + await dns.deleteRecord(instance.dnsRecordId) + steps.push('dns.removed') + await db.instanceEvent.create({ + data: { instanceId: id, type: 'delete.dns.removed', step: 'dns', result: 'ok', details: JSON.stringify({ recordId: instance.dnsRecordId }) }, + }) + } catch (e) { + steps.push(`dns.error: ${e instanceof Error ? e.message : 'unknown'}`) + } + } + + // 3. Mark deleted (keep the row for audit — GoBD) + const now = new Date() + await db.instance.update({ + where: { id }, + data: { + status: 'deleted', + deletedAt: now, + dokployAppId: null, + dokployDbId: null, + dnsRecordId: null, + dokployDbPassword: null, // clear secrets + adminLoginToken: null, + }, + }) + + // 4. Unlink from license (keep license, just remove instance ref) + if (instance.licenseId) { + await db.license.update({ + where: { id: instance.licenseId }, + data: { instance: { disconnect: true } }, + }) + } + + await db.instanceEvent.create({ + data: { instanceId: id, type: 'delete.ok', step: 'done', result: 'ok', details: JSON.stringify({ steps, deletedAt: now }) }, + }) + + await db.auditLog.create({ + data: { + actor: 'admin:ui', + action: 'instance.delete', + entity: 'Instance', + entityId: id, + after: JSON.stringify({ subdomain: instance.subdomain, steps }), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + + return NextResponse.json({ ok: true, status: 'deleted', steps, deletedAt: now }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'internal server error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/api/aos/instances/cleanup/route.ts b/src/app/api/aos/instances/cleanup/route.ts new file mode 100644 index 0000000..fd80b20 --- /dev/null +++ b/src/app/api/aos/instances/cleanup/route.ts @@ -0,0 +1,165 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' +import { createDokployClientFromEnv } from '@/lib/dokploy' +import { createHetznerDnsClientFromEnv } from '@/lib/hetzner-dns' + +export const dynamic = 'force-dynamic' +export const maxDuration = 300 + +// POST /api/aos/instances/cleanup +// Cron job: run daily. Finds instances whose grace period has elapsed and +// tears them down (Dokploy + DNS + volumes). Also finds expired demos and +// moves them to grace. Idempotent — safe to call multiple times. +// +// In production, this is triggered by a cron job (e.g. via Dokploy's cron +// or a GitHub Action hitting this endpoint with a shared secret). +export async function POST(req: Request) { + try { + // Simple auth: shared secret in header (cron job includes it) + const authHeader = req.headers.get('authorization') + const expectedSecret = process.env.CRON_SECRET + if (expectedSecret && authHeader !== `Bearer ${expectedSecret}`) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }) + } + + const now = new Date() + const results = { + expiredDemos: 0, + graceExpired: 0, + deleted: 0, + failed: 0, + details: [] as string[], + } + + // 1. Move expired demos → grace (demoExpiresAt < now) + const expiredDemos = await db.instance.findMany({ + where: { + status: 'demo_active', + demoExpiresAt: { lt: now }, + }, + }) + for (const inst of expiredDemos) { + await db.instance.update({ + where: { id: inst.id }, + data: { + status: 'grace', + graceEndsAt: new Date(now.getTime() + 7 * 86400000), // 7-day grace + }, + }) + await db.instanceEvent.create({ + data: { + instanceId: inst.id, + type: 'demo.expired', + step: 'lifecycle', + result: 'ok', + details: JSON.stringify({ demoExpiresAt: inst.demoExpiresAt, graceEndsAt: now.getTime() + 7 * 86400000 }), + }, + }) + results.expiredDemos++ + results.details.push(`${inst.subdomain}: demo → grace`) + } + + // 2. Delete instances whose grace has elapsed (graceEndsAt < now) + const graceExpired = await db.instance.findMany({ + where: { + status: 'grace', + graceEndsAt: { lt: now }, + }, + }) + results.graceExpired = graceExpired.length + + const dokploy = createDokployClientFromEnv() + const dns = createHetznerDnsClientFromEnv() + + for (const inst of graceExpired) { + try { + await db.instance.update({ where: { id: inst.id }, data: { status: 'deleting' } }) + await db.instanceEvent.create({ + data: { instanceId: inst.id, type: 'cleanup.delete.start', step: 'reconciler', result: 'pending' }, + }) + + const meta = inst.metadata ? JSON.parse(inst.metadata) as { dokployDomainId?: string } : {} + const steps: string[] = [] + + // Teardown Dokploy resources + if (dokploy) { + try { + const r = await dokploy.teardownInstance({ + dokployAppId: inst.dokployAppId ?? undefined, + dokployDbId: inst.dokployDbId ?? undefined, + dnsDomainId: meta.dokployDomainId, + }) + steps.push(...r.steps) + } catch (e) { + steps.push(`dokploy.error: ${e instanceof Error ? e.message : 'unknown'}`) + } + } + + // Remove DNS record + if (dns && inst.dnsRecordId && inst.dnsRecordId !== 'wildcard') { + try { + await dns.deleteRecord(inst.dnsRecordId) + steps.push('dns.removed') + } catch (e) { + steps.push(`dns.error: ${e instanceof Error ? e.message : 'unknown'}`) + } + } + + // Mark deleted + await db.instance.update({ + where: { id: inst.id }, + data: { + status: 'deleted', + deletedAt: now, + dokployAppId: null, + dokployDbId: null, + dokployDbPassword: null, + dnsRecordId: null, + adminLoginToken: null, + }, + }) + + // Unlink license + if (inst.licenseId) { + await db.license.update({ + where: { id: inst.licenseId }, + data: { instance: { disconnect: true } }, + }) + } + + await db.instanceEvent.create({ + data: { instanceId: inst.id, type: 'cleanup.delete.ok', step: 'reconciler', result: 'ok', details: JSON.stringify({ steps }) }, + }) + results.deleted++ + results.details.push(`${inst.subdomain}: deleted (${steps.join(', ')})`) + } catch (e) { + results.failed++ + results.details.push(`${inst.subdomain}: FAILED (${e instanceof Error ? e.message : 'unknown'})`) + await db.instance.update({ + where: { id: inst.id }, + data: { + status: 'failed', + lastError: e instanceof Error ? e.message : 'cleanup failed', + lastErrorAt: now, + }, + }) + await db.instanceEvent.create({ + data: { instanceId: inst.id, type: 'cleanup.delete.error', step: 'reconciler', result: 'error', details: JSON.stringify({ error: e instanceof Error ? e.message : 'unknown' }) }, + }) + } + } + + // 3. Alert on failed instances + const failedInstances = await db.instance.count({ where: { status: 'failed' } }) + + return NextResponse.json({ + ok: true, + runAt: now, + ...results, + failedInstances, + }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'internal server error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/api/aos/instances/route.ts b/src/app/api/aos/instances/route.ts new file mode 100644 index 0000000..be05d15 --- /dev/null +++ b/src/app/api/aos/instances/route.ts @@ -0,0 +1,314 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' +import { z } from 'zod' +import { + createDokployClientFromEnv, + type ProvisionInstanceInput, +} from '@/lib/dokploy' +import { + createHetznerDnsClientFromEnv, + generateSubdomain, + isReservedSubdomain, +} from '@/lib/hetzner-dns' + +export const dynamic = 'force-dynamic' +export const maxDuration = 300 // 5 min — provisioning can take a while + +const PLATFORM_DOMAIN = process.env.PLATFORM_DOMAIN ?? 'dfmpanel.eu' +const DEMO_DAYS = 14 +const GRACE_DAYS = 7 + +const createSchema = z.object({ + companyId: z.string().min(1), + licenseId: z.string().min(1), + product: z.string().min(1).default('restov1'), + plan: z.enum(['starter', 'pro', 'enterprise']).default('pro'), + gitRepo: z.string().url().default('https://github.com/cetinmustafa83/restov1'), + gitBranch: z.string().default('main'), + // Extra env vars to inject into the instance (beyond the standard ones) + envVars: z.record(z.string(), z.string()).optional(), + // If true, start as a demo (14-day expiry). If false, start as active (paid). + isDemo: z.boolean().default(true), +}) + +// GET /api/aos/instances — list all instances (operator console) +export async function GET(_req: Request) { + try { + const instances = await db.instance.findMany({ + orderBy: { createdAt: 'desc' }, + include: { + company: { select: { id: true, name: true, email: true } }, + license: { select: { id: true, key: true, product: true, plan: true } }, + _count: { select: { events: true, jobs: true } }, + }, + }) + + const stats = { + total: instances.length, + provisioning: instances.filter((i) => i.status === 'provisioning').length, + demoActive: instances.filter((i) => i.status === 'demo_active').length, + active: instances.filter((i) => i.status === 'active').length, + grace: instances.filter((i) => i.status === 'grace').length, + deleting: instances.filter((i) => i.status === 'deleting').length, + deleted: instances.filter((i) => i.status === 'deleted').length, + failed: instances.filter((i) => i.status === 'failed').length, + // Demos expiring in next 48h + expiringSoon: instances.filter( + (i) => i.demoExpiresAt && new Date(i.demoExpiresAt) < new Date(Date.now() + 48 * 3600000) && i.status === 'demo_active' + ).length, + } + + return NextResponse.json({ instances, stats }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'internal server error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} + +// POST /api/aos/instances — provision a new tenant instance +export async function POST(req: Request) { + try { + const parsed = createSchema.safeParse(await req.json()) + if (!parsed.success) { + return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 }) + } + const { companyId, licenseId, product, plan, gitRepo, gitBranch, envVars, isDemo } = parsed.data + + // Verify company + license exist and belong together + const company = await db.company.findUnique({ where: { id: companyId } }) + if (!company) return NextResponse.json({ error: 'company not found' }, { status: 404 }) + + const license = await db.license.findUnique({ where: { id: licenseId } }) + if (!license) return NextResponse.json({ error: 'license not found' }, { status: 404 }) + if (license.companyId !== companyId) { + return NextResponse.json({ error: 'license does not belong to this company' }, { status: 400 }) + } + if (license.product !== product) { + return NextResponse.json({ error: 'license product does not match instance product' }, { status: 400 }) + } + + // Check that no live instance already exists for this license + const existing = await db.instance.findFirst({ + where: { licenseId, status: { notIn: ['deleted'] } }, + }) + if (existing) { + return NextResponse.json({ error: 'license already has an active instance', instanceId: existing.id }, { status: 409 }) + } + + // Generate unique subdomain (collision-checked + reserved list) + let subdomain = generateSubdomain() + for (let i = 0; i < 10; i++) { + if (isReservedSubdomain(subdomain)) { + subdomain = generateSubdomain() + continue + } + const collision = await db.instance.findUnique({ where: { subdomain } }) + if (!collision) break + subdomain = generateSubdomain() + } + + const fullDomain = `${subdomain}.${PLATFORM_DOMAIN}` + + // Create instance record (status = requested) + const instance = await db.instance.create({ + data: { + licenseId, + companyId, + product, + status: 'requested', + plan, + subdomain, + demoExpiresAt: isDemo ? new Date(Date.now() + DEMO_DAYS * 86400000) : null, + }, + }) + + await db.instanceEvent.create({ + data: { + instanceId: instance.id, + type: 'provision.start', + step: 'init', + result: 'ok', + details: JSON.stringify({ subdomain, product, plan, isDemo, gitRepo }), + }, + }) + + // --- Provision via Dokploy --- + try { + await db.instance.update({ where: { id: instance.id }, data: { status: 'provisioning' } }) + + const dokploy = createDokployClientFromEnv() + if (!dokploy) { + throw new Error('Dokploy client not configured (DOKPLOY_URL / DOKPLOY_API_KEY missing)') + } + + // Instance env vars: license key + AOS connection + product-specific + const instanceEnv: Record = { + AOS_LICENSE_SERVER_URL: process.env.AOS_PUBLIC_URL ?? `https://${PLATFORM_DOMAIN}`, + AOS_PRODUCT: product, + AOS_LICENSE_KEY: license.key, + AOS_LICENSE_DOMAIN: fullDomain, + AOS_OFFLINE_GRACE_HOURS: '72', + NODE_ENV: 'production', + ...envVars, + } + + const provisionInput: ProvisionInstanceInput = { + name: `${product}-${subdomain}`, + subdomain: fullDomain, + gitRepo, + gitBranch, + envVars: instanceEnv, + databaseName: `${product}_${subdomain}`.replace(/-/g, '_').slice(0, 40), + cpuLimit: plan === 'enterprise' ? '2' : plan === 'pro' ? '1' : '0.5', + memoryLimit: plan === 'enterprise' ? '2048m' : plan === 'pro' ? '1024m' : '512m', + } + + await db.instanceEvent.create({ + data: { + instanceId: instance.id, + type: 'provision.dokploy.start', + step: 'dokploy', + result: 'pending', + details: JSON.stringify({ gitRepo, gitBranch }), + }, + }) + + const result = await dokploy.provisionInstance(provisionInput) + + await db.instanceEvent.create({ + data: { + instanceId: instance.id, + type: 'provision.dokploy.created', + step: 'dokploy', + result: 'ok', + details: JSON.stringify({ + appId: result.app.applicationId, + dbId: result.database?.databaseId, + domainId: result.domain.domainId, + url: result.url, + }), + }, + }) + + // --- DNS A record --- + let dnsRecordId: string | null = null + const dns = createHetznerDnsClientFromEnv() + if (dns) { + try { + const record = await dns.createARecord(subdomain) + dnsRecordId = record.recordId + await db.instanceEvent.create({ + data: { + instanceId: instance.id, + type: 'provision.dns.created', + step: 'dns', + result: 'ok', + details: JSON.stringify({ recordId: record.recordId, subdomain }), + }, + }) + } catch (dnsErr) { + // DNS is non-fatal if using wildcard; log but continue + await db.instanceEvent.create({ + data: { + instanceId: instance.id, + type: 'provision.dns.skipped', + step: 'dns', + result: 'error', + details: JSON.stringify({ error: dnsErr instanceof Error ? dnsErr.message : 'unknown' }), + }, + }) + } + } + + // --- Update instance with resource IDs + activate --- + const now = new Date() + await db.instance.update({ + where: { id: instance.id }, + data: { + status: isDemo ? 'demo_active' : 'active', + dokployAppId: result.app.applicationId, + dokployDbId: result.database?.databaseId ?? null, + dokployDbName: result.database?.database ?? null, + dokployDbUser: result.database?.username ?? null, + dokployDbPassword: result.database?.password ?? null, + dnsRecordId, + metadata: JSON.stringify({ + gitRepo, + gitBranch, + dokployDomainId: result.domain.domainId, + url: result.url, + provisionedAt: now.toISOString(), + }), + }, + }) + + // --- Link instance to license --- + await db.license.update({ + where: { id: licenseId }, + data: { instance: { connect: { id: instance.id } } }, + }) + + await db.instanceEvent.create({ + data: { + instanceId: instance.id, + type: 'provision.ok', + step: 'done', + result: 'ok', + details: JSON.stringify({ url: result.url, status: isDemo ? 'demo_active' : 'active' }), + }, + }) + + await db.auditLog.create({ + data: { + actor: 'admin:ui', + action: 'instance.provision', + entity: 'Instance', + entityId: instance.id, + after: JSON.stringify({ + subdomain, + url: result.url, + product, + company: company.name, + isDemo, + }), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + + return NextResponse.json({ + instanceId: instance.id, + subdomain, + url: result.url, + status: isDemo ? 'demo_active' : 'active', + demoExpiresAt: instance.demoExpiresAt, + }, { status: 201 }) + } catch (provErr) { + // Provisioning failed — mark as failed, keep the record for retry + await db.instance.update({ + where: { id: instance.id }, + data: { + status: 'failed', + lastError: provErr instanceof Error ? provErr.message : 'provisioning failed', + lastErrorAt: new Date(), + }, + }) + await db.instanceEvent.create({ + data: { + instanceId: instance.id, + type: 'provision.error', + step: 'dokploy', + result: 'error', + details: JSON.stringify({ error: provErr instanceof Error ? provErr.message : 'unknown' }), + }, + }) + return NextResponse.json({ + error: 'provisioning failed', + details: provErr instanceof Error ? provErr.message : 'unknown', + instanceId: instance.id, + }, { status: 500 }) + } + } catch (err) { + const msg = err instanceof Error ? err.message : 'internal server error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/api/aos/licenses/[id]/cancel/route.ts b/src/app/api/aos/licenses/[id]/cancel/route.ts index 34f5652..eb9ea2f 100644 --- a/src/app/api/aos/licenses/[id]/cancel/route.ts +++ b/src/app/api/aos/licenses/[id]/cancel/route.ts @@ -102,6 +102,40 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st }, }) + // --- Instance lifecycle hook (PRD §5: trial cancel → instance deletion) --- + // If cancelling during the 14-day trial (immediate), schedule the tenant + // instance for deletion. The actual Dokploy + DNS teardown happens via + // the instance DELETE endpoint (idempotent job), not here directly — + // we fire-and-forget so the cancel response is fast. + let instanceDeleted = false + if ((immediate || inTrial) && license.id) { + const inst = await db.instance.findFirst({ + where: { licenseId: license.id, status: { notIn: ['deleted', 'deleting'] } }, + }) + if (inst) { + // Mark instance as grace → will be picked up by the deletion job. + // For immediate trial cancel, set a short grace (1 hour) so the client + // can still access data briefly if they change their mind, then it's gone. + await db.instance.update({ + where: { id: inst.id }, + data: { + status: 'grace', + graceEndsAt: new Date(Date.now() + 3600000), // 1 hour + }, + }) + await db.instanceEvent.create({ + data: { + instanceId: inst.id, + type: 'license.cancel.trial', + step: 'hook', + result: 'ok', + details: JSON.stringify({ licenseId: license.id, graceEndsIn: '1h' }), + }, + }) + instanceDeleted = true + } + } + return NextResponse.json({ ok: true, cancelledAt: now, @@ -109,8 +143,9 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st inTrial: !!inTrial, immediate: immediate || inTrial, cancelledModules, + instanceScheduled: instanceDeleted, message: inTrial - ? 'Probezeit-Kündigung — sofort wirksam, keine Abbuchung.' + ? 'Probezeit-Kündigung — sofort wirksam, keine Abbuchung. Instanz wird gelöscht.' : `Kündigung eingegangen. Wirksam zum ${effectiveAt.toLocaleDateString('de-DE')} (3-Monate-Frist).`, }) } catch (err) { diff --git a/src/components/aos/app-shell.tsx b/src/components/aos/app-shell.tsx index fdc9edf..36226fa 100755 --- a/src/components/aos/app-shell.tsx +++ b/src/components/aos/app-shell.tsx @@ -18,6 +18,7 @@ import { TimesheetView } from './views/timesheet' import { SupportView } from './views/support' import { LicensesView } from './views/licenses' import { AgencyView } from './views/agency' +import { InstancesView } from './views/instances' import { ContractsView } from './views/contracts' import { ExpensesView } from './views/expenses' import { FinanceView } from './views/finance' @@ -73,6 +74,7 @@ export function AppShell() { {view === 'support' && } {view === 'licenses' && } {view === 'agency' && } + {view === 'instances' && } {view === 'contracts' && } {view === 'expenses' && } {view === 'finance' && } diff --git a/src/components/aos/command-menu.tsx b/src/components/aos/command-menu.tsx index 22869d9..e89e2fc 100755 --- a/src/components/aos/command-menu.tsx +++ b/src/components/aos/command-menu.tsx @@ -26,6 +26,7 @@ import { SlidersHorizontal, CalendarDays, Database, + Server, } from 'lucide-react' import { useAos, type ViewId } from '@/store/aos' import { @@ -50,6 +51,7 @@ const VIEWS: { id: ViewId; label: string; icon: typeof LayoutDashboard; hint: st { id: 'support', label: 'Support', icon: LifeBuoy, hint: 'Tickets & SLA' }, { id: 'licenses', label: 'Lizenzen', icon: KeyRound, hint: 'Ausgestellt & Erneuerungen' }, { id: 'agency', label: 'Bayis', icon: Users, hint: 'Reseller-Bewerbungen & Rabatte' }, + { id: 'instances', label: 'Instanzen', icon: Server, hint: 'Tenant-Instanzen & Provisioning' }, { id: 'contracts', label: 'Verträge', icon: FileSignature, hint: 'Werkvertrag, AVV, NDA' }, { id: 'expenses', label: 'Ausgaben', icon: Wallet, hint: 'Belastbar & DATEV' }, { id: 'finance', label: 'Finanzen', icon: Receipt, hint: 'Rechnungen & E-Rechnung' }, diff --git a/src/components/aos/sidebar-nav.tsx b/src/components/aos/sidebar-nav.tsx index 8eca56b..26bdf24 100755 --- a/src/components/aos/sidebar-nav.tsx +++ b/src/components/aos/sidebar-nav.tsx @@ -28,6 +28,7 @@ import { Database, RefreshCw, Landmark, + Server, } from 'lucide-react' import { useAos, type ViewId } from '@/store/aos' import { translate } from '@/lib/i18n' @@ -68,6 +69,7 @@ const NAV: { items: [ { id: 'licenses', label: 'Lizenzen', icon: KeyRound, permission: 'licenses:read' }, { id: 'agency', label: 'Bayis', icon: Users, permission: 'admin' }, + { id: 'instances', label: 'Instanzen', icon: Server, permission: 'admin' }, { id: 'contracts', label: 'Verträge', icon: FileSignature, permission: 'contracts:read' }, { id: 'expenses', label: 'Ausgaben', icon: Wallet, permission: 'expenses:read' }, { id: 'finance', label: 'Finanzen', icon: Receipt, permission: 'finance:read' }, diff --git a/src/components/aos/top-bar.tsx b/src/components/aos/top-bar.tsx index 310cefe..a9af470 100755 --- a/src/components/aos/top-bar.tsx +++ b/src/components/aos/top-bar.tsx @@ -29,6 +29,7 @@ const TITLES: Record = { support: { title: 'Support', crumb: 'Lieferung / Tickets' }, licenses: { title: 'Lizenzen', crumb: 'Betrieb / Lizenzen' }, agency: { title: 'Bayis', crumb: 'Betrieb / Bayis' }, + instances: { title: 'Instanzen', crumb: 'Betrieb / Instanzen' }, contracts: { title: 'Verträge', crumb: 'Betrieb / Verträge' }, expenses: { title: 'Ausgaben', crumb: 'Betrieb / Ausgaben' }, finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' }, diff --git a/src/components/aos/views/instances.tsx b/src/components/aos/views/instances.tsx new file mode 100644 index 0000000..3b40cb8 --- /dev/null +++ b/src/components/aos/views/instances.tsx @@ -0,0 +1,1280 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import { + Plus, + Server, + FlaskConical, + Clock, + AlertTriangle, + Timer, + Loader2, + ExternalLink, + Ban, + Play, + RotateCcw, + CalendarPlus, + Trash2, + ChevronRight, + CheckCircle2, + XCircle, + CircleDashed, +} from 'lucide-react' +import { useFetch } from '@/hooks/use-fetch' +import { Card } 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 { Switch } from '@/components/ui/switch' +import { Skeleton } from '@/components/ui/skeleton' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible' +import SlideCommit from '@/components/SlideCommit' +import { PageHeader } from '../primitives' +import { formatDate, formatRelative, daysUntil } from '@/lib/format' +import { cn } from '@/lib/utils' +import { toast } from 'sonner' + +// --------------------------------------------------------------------------- +// Types — mirror the Prisma Instance shape returned by the API +// --------------------------------------------------------------------------- + +type InstanceStatus = + | 'requested' + | 'provisioning' + | 'demo_active' + | 'active' + | 'grace' + | 'deleting' + | 'deleted' + | 'failed' + +interface Instance { + id: string + licenseId: string | null + companyId: string + product: string + status: InstanceStatus | string + plan: string + subdomain: string + serverId: string + demoExpiresAt: string | null + graceEndsAt: string | null + deletedAt: string | null + customDomain: string | null + domainVerificationToken: string | null + domainVerifiedAt: string | null + dokployAppId: string | null + dokployDbId: string | null + dokployDbName: string | null + dokployDbUser: string | null + dokployDbPassword: string | null + dnsRecordId: string | null + adminLoginToken: string | null + adminLoginExpiresAt: string | null + metadata: string | null + lastError: string | null + lastErrorAt: string | null + createdAt: string + updatedAt: string + company: { id: string; name: string; email: string | null } + license: { + id: string + key: string + product: string + plan: string + billingCycle: string + netPriceCents: number + status: string + cancelledAt: string | null + } | null + _count: { events: number; jobs: number } +} + +interface InstanceEvent { + id: string + instanceId: string + type: string + step: string | null + result: 'ok' | 'error' | 'pending' | string + details: string | null + createdAt: string +} + +interface ProvisioningJob { + id: string + instanceId: string + kind: string + step: string + attempts: number + maxAttempts: number + lastError: string | null + status: string + nextRunAt: string + createdAt: string + updatedAt: string +} + +interface InstanceDetail extends Instance { + events: InstanceEvent[] + jobs: ProvisioningJob[] +} + +interface InstanceStats { + total: number + provisioning: number + demoActive: number + active: number + grace: number + deleting: number + deleted: number + failed: number + expiringSoon: number +} + +interface InstancesData { + instances: Instance[] + stats: InstanceStats +} + +// Minimal company + license shapes needed for the "Neue Instanz" dialog. +interface CompanyLite { + id: string + name: string + email?: string | null +} + +interface LicenseLite { + id: string + key: string + product: string + plan: string + status: string + company: { id: string; name: string } +} + +interface LicensesData { + licenses: LicenseLite[] +} + +interface CrmData { + companies: CompanyLite[] +} + +// --------------------------------------------------------------------------- +// Status badge config — border-foreground/40 for active/demo, /30 for warnings +// --------------------------------------------------------------------------- + +interface StatusMeta { + label: string + spinner?: boolean + borderClass: string +} + +const STATUS_META: Record = { + requested: { label: 'Angefragt', borderClass: 'border-foreground/30' }, + provisioning: { label: 'Provisioning…', spinner: true, borderClass: 'border-foreground/30' }, + demo_active: { label: 'Demo', borderClass: 'border-foreground/40' }, + active: { label: 'Aktiv', borderClass: 'border-foreground/40' }, + grace: { label: 'Grace', borderClass: 'border-foreground/30' }, + deleting: { label: 'Wird gelöscht…', spinner: true, borderClass: 'border-foreground/30' }, + deleted: { label: 'Gelöscht', borderClass: 'border-foreground/20' }, + failed: { label: 'Fehlgeschlagen', borderClass: 'border-foreground/30' }, +} + +function StatusBadge({ status, lastError }: { status: string; lastError?: string | null }) { + const meta = STATUS_META[status] ?? { label: status, borderClass: 'border-foreground/30' } + const badge = ( + + {meta.spinner && } + {meta.label} + + ) + if (status === 'failed' && lastError) { + return ( + + {badge} + + {lastError} + + + ) + } + return badge +} + +// --------------------------------------------------------------------------- +// Plan label +// --------------------------------------------------------------------------- + +const PLAN_LABELS: Record = { + starter: 'Starter', + pro: 'Pro', + enterprise: 'Enterprise', +} + +// --------------------------------------------------------------------------- +// Main view +// --------------------------------------------------------------------------- + +export function InstancesView() { + const { data, loading, refetch } = useFetch('/api/aos/instances') + const [createOpen, setCreateOpen] = useState(false) + const [detailId, setDetailId] = useState(null) + + if (loading || !data) { + return ( +
+ +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ +
+ ) + } + + const { instances, stats } = data + + return ( +
+ setCreateOpen(true)}> + + Neue Instanz + + } + /> + + {/* Stats row — 5 cards */} +
+ + + + 0} + /> + +
+ + {/* Table */} + {instances.length === 0 ? ( +
+
+ +
+
+

Keine Instanzen

+

+ Erstelle die erste mit „Neue Instanz“. +

+
+ +
+ ) : ( +
+ + + + Subdomain + Produkt / Plan + Kunde + Status + Demo bis + Aktionen + + + + {instances.map((inst) => ( + setDetailId(inst.id)} + /> + ))} + +
+
+ )} + + {/* Detail Sheet */} + !o && setDetailId(null)} + onMutated={refetch} + /> + + {/* Create dialog */} + i.status !== 'deleted' && i.licenseId) + .map((i) => i.licenseId as string) + ) + } + onCreated={refetch} + /> +
+ ) +} + +// --------------------------------------------------------------------------- +// Stat card +// --------------------------------------------------------------------------- + +function StatCard({ + icon: Icon, + label, + value, + sub, + emphasis, +}: { + icon: typeof Server + label: string + value: number + sub?: string + emphasis?: boolean +}) { + return ( + +
+
+

{label}

+

{value}

+ {sub &&

{sub}

} +
+
+ +
+
+
+ ) +} + +// --------------------------------------------------------------------------- +// Instance row +// --------------------------------------------------------------------------- + +function InstanceRow({ + instance, + onOpen, +}: { + instance: Instance + onOpen: () => void +}) { + const fullDomain = `${instance.subdomain}.dfmpanel.eu` + const demoActive = instance.status === 'demo_active' && instance.demoExpiresAt + const demoExpired = instance.demoExpiresAt && new Date(instance.demoExpiresAt) < new Date() + + return ( + + + + + +
+ {instance.product} + + {PLAN_LABELS[instance.plan] ?? instance.plan} + +
+
+ +
+ {instance.company?.name ?? '—'} + {instance.company?.email && ( + {instance.company.email} + )} +
+
+ + + + + {instance.demoExpiresAt ? ( +
+ {formatDate(instance.demoExpiresAt)} + + {demoActive ? formatRelative(instance.demoExpiresAt) : 'beendet'} + +
+ ) : ( + — + )} +
+ + + +
+ ) +} + +// --------------------------------------------------------------------------- +// Instance detail sheet (right side) +// --------------------------------------------------------------------------- + +function InstanceDetailSheet({ + instanceId, + onOpenChange, + onMutated, +}: { + instanceId: string | null + onOpenChange: (o: boolean) => void + onMutated: () => void +}) { + const open = !!instanceId + const { data, loading, refetch } = useFetch( + instanceId ? `/api/aos/instances/${instanceId}` : null + ) + const [busyAction, setBusyAction] = useState(null) + const [confirmingDelete, setConfirmingDelete] = useState(false) + + // Reset transient sheet state when closing + useEffect(() => { + if (!open) { + setBusyAction(null) + setConfirmingDelete(false) + } + }, [open]) + + const handleAction = async ( + action: 'suspend' | 'reactivate' | 'extend' | 'retry', + label: string, + extendDays?: number + ) => { + if (!instanceId) return + setBusyAction(action) + const t = toast.loading(`${label}…`) + try { + const body: Record = { action } + if (extendDays !== undefined) body.extendDays = extendDays + const r = await fetch(`/api/aos/instances/${instanceId}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + const res = await r.json() + if (!r.ok) throw new Error(res.error || 'action failed') + toast.success(`${label} erfolgreich`, { id: t }) + refetch() + onMutated() + } catch (err) { + toast.error(err instanceof Error ? err.message : `${label} fehlgeschlagen`, { id: t }) + } finally { + setBusyAction(null) + } + } + + const handleDelete = async () => { + if (!instanceId) return + setBusyAction('delete') + const t = toast.loading('Instanz wird gelöscht…') + try { + const r = await fetch(`/api/aos/instances/${instanceId}`, { method: 'DELETE' }) + const res = await r.json() + if (!r.ok) throw new Error(res.error || 'delete failed') + const steps = Array.isArray(res.steps) ? res.steps : [] + toast.success(`Instanz gelöscht — ${steps.length} Schritte ausgeführt`, { + id: t, + duration: 6000, + }) + onOpenChange(false) + onMutated() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Löschen fehlgeschlagen', { id: t }) + } finally { + setBusyAction(null) + } + } + + // Parse metadata for Dokploy resource IDs + const meta = useMemo(() => { + if (!data?.metadata) return {} + try { + return JSON.parse(data.metadata) as Record + } catch { + return {} + } + }, [data?.metadata]) + + const instance = data + const status = instance?.status ?? '' + const fullDomain = instance ? `${instance.subdomain}.dfmpanel.eu` : '' + + return ( + + + + + + {instance ? instance.subdomain : 'Instanz'} + + + {fullDomain} + + + +
+ {loading || !instance ? ( +
+ + + +
+ ) : ( +
+ {/* Top: status + IDs */} +
+
+ + {instance.plan && ( + + {PLAN_LABELS[instance.plan] ?? instance.plan} + + )} + + {instance.product} + +
+ {instance.lastError && ( +

+ Fehler: {instance.lastError} +

+ )} +
+ + {/* Instance facts */} +
+

+ Instanz +

+
+ {instance.id}} /> + + {fullDomain} + + + } + /> + + + + + + + {instance.customDomain && ( + + {instance.customDomain} + {instance.domainVerifiedAt ? ( + + ) : ( + + )} + + } + /> + )} +
+
+ + {/* License info */} + {instance.license && ( +
+

+ Lizenz +

+
+ + {maskLicenseKey(instance.license.key)} + + } + /> + + + + + {instance.license.status} + + } + /> + +
+
+ )} + + {/* Dokploy resource IDs */} +
+

+ Dokploy-Resourcen +

+
+ {instance.dokployAppId ?? '—'}} + /> + {instance.dokployDbId ?? '—'}} + /> + + {(meta.dokployDomainId as string) ?? '—'} + + } + /> + {instance.dnsRecordId ?? '—'}} + /> +
+
+ + {/* Status timeline (events) */} +
+

+ Ereignisverlauf +

+ {instance.events.length === 0 ? ( +

Keine Ereignisse protokolliert.

+ ) : ( +
    + {instance.events.map((ev) => ( + + ))} +
+ )} +
+ + {/* Actions */} +
+

+ Aktionen +

+ {confirmingDelete ? ( +
+

+ Die Instanz wird unwiderruflich gelöscht. Dokploy-Ressourcen, DNS-Einträge + und Volumes werden entfernt (die Datenbank-Zeile bleibt für das Audit-Log + erhalten). +

+ + +
+ ) : ( + handleAction('suspend', 'Suspendieren')} + onReactivate={() => handleAction('reactivate', 'Reaktivieren')} + onExtend={() => handleAction('extend', 'Demo verlängern', 7)} + onRetry={() => handleAction('retry', 'Provisioning wiederholen')} + onDelete={() => setConfirmingDelete(true)} + /> + )} +
+
+ )} +
+
+
+ ) +} + +function DetailRow({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+
{label}
+
{value}
+
+ ) +} + +function maskLicenseKey(key: string): string { + if (!key) return '—' + if (key.length <= 12) return key + return `${key.slice(0, 8)}…${key.slice(-4)}` +} + +// --------------------------------------------------------------------------- +// Event row — collapsible JSON details +// --------------------------------------------------------------------------- + +function EventRow({ event }: { event: InstanceEvent }) { + const [open, setOpen] = useState(false) + const result = event.result + const tone = + result === 'ok' + ? 'border-foreground/40 text-foreground' + : result === 'error' + ? 'border-foreground/30 bg-muted text-foreground' + : 'border-foreground/30 text-muted-foreground' + const Icon = + result === 'ok' ? CheckCircle2 : result === 'error' ? XCircle : CircleDashed + + let pretty = event.details ?? '' + try { + if (pretty) pretty = JSON.stringify(JSON.parse(pretty), null, 2) + } catch { + // not JSON, keep raw + } + + return ( +
  • +
    + + + {event.result} + + {event.type} + {event.step && ( + · {event.step} + )} + + {formatRelative(event.createdAt)} + +
    + {pretty && ( + + + + + +
    +              {pretty}
    +            
    +
    +
    + )} +
  • + ) +} + +// --------------------------------------------------------------------------- +// Actions row — depends on status +// --------------------------------------------------------------------------- + +function ActionsRow({ + status, + busyAction, + onSuspend, + onReactivate, + onExtend, + onRetry, + onDelete, +}: { + status: string + busyAction: string | null + onSuspend: () => void + onReactivate: () => void + onExtend: () => void + onRetry: () => void + onDelete: () => void +}) { + const busy = busyAction !== null + const btn = (props: { + onClick: () => void + label: string + icon: typeof Ban + variant?: 'default' | 'outline' | 'destructive' | 'ghost' + loading?: boolean + }) => { + const Icon = props.icon + return ( + + ) + } + + if (status === 'demo_active') { + return ( +
    + {btn({ + onClick: onExtend, + label: 'Demo +7 d', + icon: CalendarPlus, + loading: busyAction === 'extend', + })} + {btn({ + onClick: onSuspend, + label: 'Suspendieren', + icon: Ban, + loading: busyAction === 'suspend', + })} + {btn({ + onClick: onDelete, + label: 'Löschen', + icon: Trash2, + variant: 'destructive', + })} +
    + ) + } + + if (status === 'active') { + return ( +
    + {btn({ + onClick: onSuspend, + label: 'Suspendieren', + icon: Ban, + loading: busyAction === 'suspend', + })} + {btn({ + onClick: onDelete, + label: 'Löschen', + icon: Trash2, + variant: 'destructive', + })} +
    + ) + } + + if (status === 'grace') { + return ( +
    + {btn({ + onClick: onReactivate, + label: 'Reaktivieren', + icon: Play, + loading: busyAction === 'reactivate', + })} + {btn({ + onClick: onDelete, + label: 'Sofort löschen', + icon: Trash2, + variant: 'destructive', + })} +
    + ) + } + + if (status === 'failed') { + return ( +
    + {btn({ + onClick: onRetry, + label: 'Provisioning wiederholen', + icon: RotateCcw, + loading: busyAction === 'retry', + })} +
    + ) + } + + // provisioning / deleting / deleted / requested → no actions + return ( +

    + Für diesen Status sind keine Aktionen verfügbar. +

    + ) +} + +// --------------------------------------------------------------------------- +// Create Instance Dialog +// --------------------------------------------------------------------------- + +function CreateInstanceDialog({ + open, + onOpenChange, + existingLicenseIds, + onCreated, +}: { + open: boolean + onOpenChange: (o: boolean) => void + existingLicenseIds: Set + onCreated: () => void +}) { + const { data: crmData } = useFetch('/api/aos/crm') + const { data: licensesData } = useFetch('/api/aos/licenses') + + const [companyId, setCompanyId] = useState('') + const [licenseId, setLicenseId] = useState('') + const [product, setProduct] = useState('restov1') + const [plan, setPlan] = useState<'starter' | 'pro' | 'enterprise'>('pro') + const [gitRepo, setGitRepo] = useState('https://github.com/cetinmustafa83/restov1') + const [gitBranch, setGitBranch] = useState('main') + const [isDemo, setIsDemo] = useState(true) + const [saving, setSaving] = useState(false) + + const companies = useMemo( + () => + (crmData?.companies ?? []) + .slice() + .sort((a, b) => a.name.localeCompare(b.name)), + [crmData?.companies] + ) + + // Filter licenses: same company, no existing live instance. + const availableLicenses = useMemo(() => { + const all = licensesData?.licenses ?? [] + return all + .filter((l) => l.company?.id === companyId) + .filter((l) => !existingLicenseIds.has(l.id)) + .filter((l) => l.status !== 'cancelled') + }, [licensesData?.licenses, companyId, existingLicenseIds]) + + // Pick first company when opening + useEffect(() => { + if (open && companies[0] && !companyId) setCompanyId(companies[0].id) + }, [open, companies, companyId]) + + // Auto-select the first available license when company changes + useEffect(() => { + if (availableLicenses[0] && !availableLicenses.find((l) => l.id === licenseId)) { + setLicenseId(availableLicenses[0].id) + } + if (availableLicenses.length === 0) setLicenseId('') + }, [availableLicenses, licenseId]) + + // Reset form when closing + useEffect(() => { + if (open) return + setCompanyId('') + setLicenseId('') + setProduct('restov1') + setPlan('pro') + setGitRepo('https://github.com/cetinmustafa83/restov1') + setGitBranch('main') + setIsDemo(true) + }, [open]) + + const submit = async () => { + if (!companyId) { toast.error('Bitte Firma auswählen'); return } + if (!licenseId) { toast.error('Bitte Lizenz auswählen'); return } + if (!product.trim()) { toast.error('Produkt eingeben'); return } + setSaving(true) + const t = toast.loading('Instanz wird provisioniert (1–3 Min)…') + try { + const r = await fetch('/api/aos/instances', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + companyId, + licenseId, + product: product.trim(), + plan, + gitRepo: gitRepo.trim(), + gitBranch: gitBranch.trim(), + isDemo, + }), + }) + const res = await r.json() + if (!r.ok) throw new Error(res.error || 'create failed') + toast.success(`Instanz wird provisioniert — ${res.url ?? res.subdomain}`, { + id: t, + duration: 8000, + }) + onOpenChange(false) + onCreated() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Provisioning fehlgeschlagen', { id: t }) + } finally { + setSaving(false) + } + } + + return ( + + + + Neue Instanz + + Stellt eine neue Tenant-Instanz bereit. Provisioning via Dokploy + Hetzner DNS + (~1–3 Min). Die Instanz läuft unter <subdomain>.dfmpanel.eu. + + + +
    + {/* Kunde */} +
    + + +
    + + {/* Lizenz */} +
    + + +

    + Nur Lizenzen ohne bestehende Instanz. Gekündigte Lizenzen sind nicht verfügbar. +

    +
    + + {/* Produkt + Plan */} +
    +
    + + setProduct(e.target.value)} /> +
    +
    + + +
    +
    + + {/* Git */} +
    +
    + + setGitRepo(e.target.value)} /> +
    +
    + + setGitBranch(e.target.value)} /> +
    +
    + + {/* Demo Switch */} +
    +
    + +

    + 14 Tage kostenlos. Bei Abbruch wird die Instanz automatisch gelöscht. +

    +
    + +
    +
    + + + + + +
    +
    + ) +} diff --git a/src/lib/dokploy.ts b/src/lib/dokploy.ts new file mode 100644 index 0000000..047af45 --- /dev/null +++ b/src/lib/dokploy.ts @@ -0,0 +1,392 @@ +/** + * Dokploy API Client + * ------------------- + * Manages tenant instances on the Dokploy server: create applications from + * a Git repo, deploy, attach domains, create databases, suspend/delete. + * + * Config (env): + * DOKPLOY_URL - e.g. https://cp.project-url.eu + * DOKPLOY_API_KEY - bearer token + * DOKPLOY_SERVER_ID - the Dokploy server ID to deploy to (default "default") + * DOKPLOY_PROJECT_ID - the Dokploy project to put tenant apps under + * + * NOTE: Dokploy API endpoints should be verified against the current API docs + * at implementation time. The endpoints below are based on the Dokploy REST API. + * See: https://docs.dokploy.com/docs/api + */ + +export interface DokployConfig { + url: string + apiKey: string + serverId?: string + projectId?: string +} + +export interface DokployApp { + applicationId: string + name: string + status?: string + url?: string + createdAt?: string +} + +export interface DokployDatabase { + databaseId: string + name: string + type: string // postgres|mysql|redis|... + host: string + port: number + database: string + username: string + password: string + externalUrl?: string +} + +export interface DokployDomain { + domainId: string + hostName: string + applicationId: string + https: boolean + certificateStatus?: string +} + +export interface ProvisionInstanceInput { + name: string + subdomain: string + gitRepo: string // e.g. https://github.com/cetinmustafa83/restov1 + gitBranch: string // e.g. main + buildPath?: string // e.g. / (repo root, default) + envVars: Record + // Resource limits (Dokploy applies these as container constraints) + cpuLimit?: string // e.g. "0.5" = 50% of one core + memoryLimit?: string // e.g. "512m" + // Database + databaseName?: string // if set, a Postgres DB is created for this instance +} + +export interface ProvisionInstanceResult { + app: DokployApp + database?: DokployDatabase + domain: DokployDomain + url: string +} + +class DokployError extends Error { + constructor( + message: string, + public status: number, + public endpoint: string, + public body?: unknown + ) { + super(message) + this.name = 'DokployError' + } +} + +export function createDokployClient(config: DokployConfig) { + const baseUrl = config.url.replace(/\/$/, '') + const serverId = config.serverId ?? 'default' + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${config.apiKey}`, + } + + async function call(endpoint: string, body?: Record, method = 'POST'): Promise { + const url = `${baseUrl}${endpoint}` + const res = await fetch(url, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }) + const text = await res.text() + let json: unknown + try { + json = text ? JSON.parse(text) : null + } catch { + json = text + } + if (!res.ok) { + throw new DokployError( + `Dokploy API ${endpoint} failed: HTTP ${res.status}`, + res.status, + endpoint, + json + ) + } + return json as T + } + + // --- Applications --- + + async function createApplication(input: { + name: string + description?: string + gitRepo: string + gitBranch: string + buildPath?: string + envVars?: Record + cpuLimit?: string + memoryLimit?: string + }): Promise { + const body: Record = { + name: input.name, + description: input.description ?? `Tenant instance: ${input.name}`, + serverId, + projectName: config.projectId ?? 'default', + sourceType: 'git', + gitRepository: input.gitRepo, + gitBranch: input.gitBranch, + buildPath: input.buildPath ?? '/', + // Dokploy expects env vars as a JSON string + env: JSON.stringify(input.envVars ?? {}), + cpuLimit: input.cpuLimit ?? '0.5', + memoryLimit: input.memoryLimit ?? '512m', + } + const res = await call<{ applicationId?: string; app?: { applicationId: string }; [k: string]: unknown }>('/api/application.create', body) + const applicationId = res.applicationId ?? (res.app as { applicationId: string } | undefined)?.applicationId + if (!applicationId) { + throw new DokployError('Dokploy createApplication returned no applicationId', 200, '/api/application.create', res) + } + return { applicationId, name: input.name, status: 'created' } + } + + async function deployApplication(applicationId: string): Promise<{ deployId: string; status: string }> { + const res = await call<{ deployId?: string; jobId?: string; status?: string }>('/api/application.deploy', { applicationId }) + return { deployId: res.deployId ?? res.jobId ?? '', status: res.status ?? 'deploying' } + } + + async function getApplication(applicationId: string): Promise { + const res = await call( + `/api/application.one`, + { applicationId }, + ) + return { + applicationId: res.applicationId, + name: res.name, + status: res.applicationStatus ?? res.status, + url: res.previewDomain ?? res.url, + createdAt: res.createdAt, + } + } + + async function deleteApplication(applicationId: string): Promise { + await call('/api/application.delete', { applicationId, deleteVolumes: true }) + } + + async function stopApplication(applicationId: string): Promise { + await call('/api/application.stop', { applicationId }) + } + + async function startApplication(applicationId: string): Promise { + await call('/api/application.start', { applicationId }) + } + + async function updateEnv(applicationId: string, envVars: Record): Promise { + await call('/api/application.update', { + applicationId, + env: JSON.stringify(envVars), + }) + } + + // --- Databases (Postgres) --- + + async function createPostgresDatabase(input: { + name: string + appName: string // the application this DB belongs to + description?: string + externalUrl?: boolean + }): Promise { + const body: Record = { + name: input.name, + appName: input.appName, + description: input.description ?? `DB for ${input.appName}`, + serverId, + projectName: config.projectId ?? 'default', + externalUrl: input.externalUrl ?? false, + } + const res = await call>('/api/database.postgres.create', body) + // The response shape varies between Dokploy versions; handle both. + const db = (res.database as Record | undefined) ?? res + const databaseId = (db.databaseId as string | undefined) ?? (res.databaseId as string | undefined) + if (!databaseId) { + throw new DokployError('Dokploy createDatabase returned no databaseId', 200, '/api/database.postgres.create', res) + } + return { + databaseId, + name: (db.name as string | undefined) ?? input.name, + type: 'postgres', + host: (db.host as string | undefined) ?? 'postgres', + port: (db.port as number | undefined) ?? 5432, + database: (db.database as string | undefined) ?? input.name, + username: (db.username as string | undefined) ?? 'postgres', + password: (db.password as string | undefined) ?? '', + } + } + + async function deleteDatabase(databaseId: string): Promise { + await call('/api/database.postgres.delete', { databaseId }) + } + + // --- Domains --- + + async function createDomain(input: { + applicationId: string + hostName: string + path?: string + port?: number + https?: boolean + certificateType?: 'letsencrypt' | 'none' + }): Promise { + const body: Record = { + applicationId: input.applicationId, + hostName: input.hostName, + path: input.path ?? '/', + port: input.port ?? 3000, + https: input.https ?? true, + certificateType: input.certificateType ?? 'letsencrypt', + } + const res = await call<{ domainId?: string; id?: string; hostName?: string; https?: boolean }>('/api/domain.create', body) + const domainId = res.domainId ?? res.id + if (!domainId) { + throw new DokployError('Dokploy createDomain returned no domainId', 200, '/api/domain.create', res) + } + return { + domainId, + hostName: input.hostName, + applicationId: input.applicationId, + https: input.https ?? true, + } + } + + async function deleteDomain(domainId: string): Promise { + await call('/api/domain.delete', { domainId }) + } + + // --- High-level: provision a full instance --- + + async function provisionInstance(input: ProvisionInstanceInput): Promise { + // 1. Create application from Git repo + const app = await createApplication({ + name: input.name, + gitRepo: input.gitRepo, + gitBranch: input.gitBranch, + buildPath: '/', + envVars: input.envVars, + cpuLimit: input.cpuLimit, + memoryLimit: input.memoryLimit, + }) + + // 2. (Optional) Create Postgres database + let database: DokployDatabase | undefined + if (input.databaseName) { + database = await createPostgresDatabase({ + name: input.databaseName, + appName: input.name, + }) + // Inject DATABASE_URL into the app's env + const dbUrl = `postgresql://${database.username}:${database.password}@${database.host}:${database.port}/${database.database}` + await updateEnv(app.applicationId, { + ...input.envVars, + DATABASE_URL: dbUrl, + }) + } + + // 3. Attach domain (subdomain.platform-domain) + const hostName = input.subdomain + const domain = await createDomain({ + applicationId: app.applicationId, + hostName, + port: 3000, + https: true, + }) + + // 4. Trigger deploy + await deployApplication(app.applicationId) + + return { + app, + database, + domain, + url: `https://${hostName}`, + } + } + + // --- High-level: teardown an instance (idempotent) --- + + async function teardownInstance(input: { + dokployAppId?: string + dokployDbId?: string + dnsDomainId?: string // Dokploy domain ID + }): Promise<{ steps: string[] }> { + const steps: string[] = [] + // Remove domain first (stop traffic) + if (input.dnsDomainId) { + try { + await deleteDomain(input.dnsDomainId) + steps.push('domain.removed') + } catch (e) { + if (e instanceof DokployError && e.status !== 404) { + steps.push(`domain.error: ${e.message}`) + } + } + } + // Delete database + if (input.dokployDbId) { + try { + await deleteDatabase(input.dokployDbId) + steps.push('database.removed') + } catch (e) { + if (e instanceof DokployError && e.status !== 404) { + steps.push(`database.error: ${e.message}`) + } + } + } + // Delete application (+ volumes) + if (input.dokployAppId) { + try { + await deleteApplication(input.dokployAppId) + steps.push('application.removed') + } catch (e) { + if (e instanceof DokployError && e.status !== 404) { + steps.push(`application.error: ${e.message}`) + } + } + } + return { steps } + } + + return { + // Low-level + createApplication, + deployApplication, + getApplication, + deleteApplication, + stopApplication, + startApplication, + updateEnv, + createPostgresDatabase, + deleteDatabase, + createDomain, + deleteDomain, + call, + // High-level + provisionInstance, + teardownInstance, + } +} + +export type DokployClient = ReturnType + +// --- Factory: create from env --- + +export function createDokployClientFromEnv(): DokployClient | null { + const url = process.env.DOKPLOY_URL + const apiKey = process.env.DOKPLOY_API_KEY + if (!url || !apiKey) return null + return createDokployClient({ + url, + apiKey, + serverId: process.env.DOKPLOY_SERVER_ID, + projectId: process.env.DOKPLOY_PROJECT_ID, + }) +} diff --git a/src/lib/hetzner-dns.ts b/src/lib/hetzner-dns.ts new file mode 100644 index 0000000..4112e32 --- /dev/null +++ b/src/lib/hetzner-dns.ts @@ -0,0 +1,271 @@ +/** + * Hetzner Cloud DNS API Client + * ------------------------------ + * Manages DNS records for the platform domain (e.g. dfmpanel.eu). + * Used to create A records for tenant instance subdomains. + * + * Config (env): + * HETZNER_DNS_TOKEN - API token (scoped to the platform zone) + * HETZNER_ZONE_ID - the zone ID of the platform domain (dfmpanel.eu) + * DOKPLOY_SERVER_IP - the IP of the Dokploy server (A record target) + * PLATFORM_DOMAIN - e.g. dfmpanel.eu + * USE_WILDCARD_DNS - if "true", a single *.dfmpanel.eu A record is used + * and no per-instance records are needed. + * + * API docs: https://dns.hetzner.com/api-docs + */ + +export interface HetznerDnsConfig { + token: string + zoneId: string + platformDomain: string // e.g. dfmpanel.eu + dokployServerIp: string + useWildcard?: boolean // if true, don't create per-instance records +} + +export interface DnsRecord { + recordId: string + type: string // A|AAAA|CNAME|TXT + name: string // e.g. k7f3a9 (relative to zone) or @ for apex + value: string + ttl: number + zoneId: string +} + +class HetznerDnsError extends Error { + constructor(message: string, public status: number, public body?: unknown) { + super(message) + this.name = 'HetznerDnsError' + } +} + +export function createHetznerDnsClient(config: HetznerDnsConfig) { + const baseUrl = 'https://dns.hetzner.com/api/v1' + const headers: Record = { + 'Content-Type': 'application/json', + 'Auth-API-Token': config.token, + } + + async function call(path: string, options: { method?: string; body?: unknown } = {}): Promise { + const method = options.method ?? 'GET' + const res = await fetch(`${baseUrl}${path}`, { + method, + headers, + body: options.body ? JSON.stringify(options.body) : undefined, + }) + const text = await res.text() + let json: unknown + try { + json = text ? JSON.parse(text) : null + } catch { + json = text + } + if (!res.ok) { + throw new HetznerDnsError( + `Hetzner DNS ${method} ${path} failed: HTTP ${res.status}`, + res.status, + json + ) + } + return json as T + } + + // --- Records --- + + async function createARecord(subdomain: string): Promise { + if (config.useWildcard) { + // Wildcard record handles all subdomains; no per-instance record needed. + return { + recordId: 'wildcard', + type: 'A', + name: subdomain, + value: config.dokployServerIp, + ttl: 300, + zoneId: config.zoneId, + } + } + const res = await call<{ record: { id: string; type: string; name: string; value: string; ttl: number; zone_id: string } }>( + '/records', + { + method: 'POST', + body: { + zone_id: config.zoneId, + type: 'A', + name: subdomain, // relative to zone: "k7f3a9" -> k7f3a9.dfmpanel.eu + value: config.dokployServerIp, + ttl: 300, + }, + } + ) + return { + recordId: res.record.id, + type: res.record.type, + name: res.record.name, + value: res.record.value, + ttl: res.record.ttl, + zoneId: res.record.zone_id, + } + } + + async function deleteRecord(recordId: string): Promise { + if (recordId === 'wildcard' || !recordId) return // wildcard is shared, never delete + try { + await call(`/records/${recordId}`, { method: 'DELETE' }) + } catch (e) { + if (e instanceof HetznerDnsError && e.status === 404) return + throw e + } + } + + // --- Custom domain verification (TXT records) --- + + async function createTxtRecord(domain: string, value: string): Promise { + // For custom domain verification: _dfm-verify. = token + // The domain may be in a different zone (customer's own), so this is only + // useful if the customer's zone is also managed in this Hetzner account. + // In most cases, the customer manages their own DNS and we only poll. + const res = await call<{ record: { id: string; type: string; name: string; value: string; ttl: number; zone_id: string } }>( + '/records', + { + method: 'POST', + body: { + zone_id: config.zoneId, + type: 'TXT', + name: domain, + value, + ttl: 300, + }, + } + ) + return { + recordId: res.record.id, + type: res.record.type, + name: res.record.name, + value: res.record.value, + ttl: res.record.ttl, + zoneId: res.record.zone_id, + } + } + + // --- Verify a custom domain's CNAME points to our platform --- + + async function verifyCustomDomainCname(customDomain: string, expectedTarget: string): Promise<{ + verified: boolean + found?: string + error?: string + }> { + try { + // Use DNS-over-HTTPS (Google) to resolve the CNAME — we don't manage the + // customer's zone, so we can't query Hetzner API for it. + const res = await fetch( + `https://dns.google/resolve?name=${encodeURIComponent(customDomain)}&type=CNAME`, + { headers: { Accept: 'application/dns-json' } } + ) + const data = await res.json() as { Answer?: { name: string; type: number; data: string }[] } + const cnameRecord = data.Answer?.find((a) => a.type === 5) // type 5 = CNAME + if (!cnameRecord) { + return { verified: false, error: 'No CNAME record found' } + } + const cnameTarget = cnameRecord.data.replace(/\.$/, '') // strip trailing dot + if (cnameTarget === expectedTarget || cnameTarget.endsWith(`.${expectedTarget}`)) { + return { verified: true, found: cnameTarget } + } + return { verified: false, found: cnameTarget, error: `CNAME points to ${cnameTarget}, expected ${expectedTarget}` } + } catch (e) { + return { verified: false, error: e instanceof Error ? e.message : 'DNS lookup failed' } + } + } + + async function verifyTxtRecord(domain: string, expectedToken: string): Promise<{ + verified: boolean + found?: string + error?: string + }> { + try { + const res = await fetch( + `https://dns.google/resolve?name=${encodeURIComponent(domain)}&type=TXT`, + { headers: { Accept: 'application/dns-json' } } + ) + const data = await res.json() as { Answer?: { name: string; type: number; data: string }[] } + const txtRecords = data.Answer?.filter((a) => a.type === 16) ?? [] // type 16 = TXT + for (const r of txtRecords) { + const value = r.data.replace(/^"|"$/g, '') // strip quotes + if (value === expectedToken) { + return { verified: true, found: value } + } + } + return { verified: false, error: 'TXT token not found' } + } catch (e) { + return { verified: false, error: e instanceof Error ? e.message : 'DNS lookup failed' } + } + } + + // --- List records (for reconciler) --- + + async function listRecords(): Promise { + const res = await call<{ records: { id: string; type: string; name: string; value: string; ttl: number; zone_id: string }[] }>( + `/records?zone_id=${config.zoneId}` + ) + return (res.records ?? []).map((r) => ({ + recordId: r.id, + type: r.type, + name: r.name, + value: r.value, + ttl: r.ttl, + zoneId: r.zone_id, + })) + } + + return { + createARecord, + deleteRecord, + createTxtRecord, + verifyCustomDomainCname, + verifyTxtRecord, + listRecords, + } +} + +export type HetznerDnsClient = ReturnType + +export function createHetznerDnsClientFromEnv(): HetznerDnsClient | null { + const token = process.env.HETZNER_DNS_TOKEN + const zoneId = process.env.HETZNER_ZONE_ID + const platformDomain = process.env.PLATFORM_DOMAIN ?? 'dfmpanel.eu' + const dokployServerIp = process.env.DOKPLOY_SERVER_IP + if (!token || !zoneId || !dokployServerIp) return null + return createHetznerDnsClient({ + token, + zoneId, + platformDomain, + dokployServerIp, + useWildcard: process.env.USE_WILDCARD_DNS === 'true', + }) +} + +// --- Subdomain generation (random, collision-checked, base32) --- + +const BASE32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567' // no confusable 0/O, 1/I + +export function generateSubdomain(length = 7): string { + const chars: string[] = [] + for (let i = 0; i < length; i++) { + chars.push(BASE32_ALPHABET[Math.floor(Math.random() * BASE32_ALPHABET.length)]) + } + return chars.join('') +} + +// Reserved subdomains that can't be used for instances +const RESERVED_SUBDOMAINS = new Set([ + 'www', 'mail', 'smtp', 'imap', 'api', 'admin', 'app', 'dashboard', + 'docs', 'blog', 'shop', 'portal', 'cdn', 'static', 'assets', + 'auth', 'login', 'register', 'signup', 'status', 'help', 'support', + 'demo', 'test', 'staging', 'dev', 'prod', 'production', + 'ns1', 'ns2', 'dns', 'mx', 'txt', 'cname', 'a', 'aaaa', + 'ftp', 'sftp', 'ssh', 'git', 'svn', 'hg', + 'autodiscover', 'autoconfig', 'well-known', +]) + +export function isReservedSubdomain(subdomain: string): boolean { + return RESERVED_SUBDOMAINS.has(subdomain.toLowerCase()) +} diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 0ceb9c5..b7edc61 100755 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -29,6 +29,7 @@ const de: Dict = { 'nav.support': 'Support', 'nav.licenses': 'Lizenzen', 'nav.agency': 'Bayis', + 'nav.instances': 'Instanzen', 'nav.contracts': 'Verträge', 'nav.expenses': 'Ausgaben', 'nav.finance': 'Finanzen', @@ -86,6 +87,7 @@ const en: Dict = { 'nav.support': 'Support', 'nav.licenses': 'Licenses', 'nav.agency': 'Resellers (Bayis)', + 'nav.instances': 'Instances', 'nav.contracts': 'Contracts', 'nav.expenses': 'Expenses', 'nav.finance': 'Finance', @@ -139,6 +141,7 @@ const tr: Dict = { 'nav.support': 'Destek', 'nav.licenses': 'Lisanslar', 'nav.agency': 'Bayiler', + 'nav.instances': 'Sunucular', 'nav.contracts': 'Sözleşmeler', 'nav.expenses': 'Giderler', 'nav.finance': 'Finans', diff --git a/src/store/aos.ts b/src/store/aos.ts index 5a3f5e0..871d48e 100755 --- a/src/store/aos.ts +++ b/src/store/aos.ts @@ -14,6 +14,7 @@ export type ViewId = | 'support' | 'licenses' | 'agency' + | 'instances' | 'contracts' | 'expenses' | 'finance' diff --git a/tool-results/read_1793204612141_0f15d70a7836.txt b/tool-results/read_1793204612141_0f15d70a7836.txt new file mode 100644 index 0000000..ca8760b --- /dev/null +++ b/tool-results/read_1793204612141_0f15d70a7836.txt @@ -0,0 +1,1202 @@ + 1→'use client' + 2→ + 3→import { useEffect, useMemo, useState } from 'react' + 4→import { + 5→ Plus, + 6→ KeyRound, + 7→ ShieldCheck, + 8→ AlertTriangle, + 9→ CalendarClock, + 10→ RefreshCw, + 11→ CheckCircle2, + 12→ XCircle, + 13→ PauseCircle, + 14→ ChevronRight, + 15→ Ban, + 16→ Play, + 17→ RotateCcw, + 18→ TrendingUp, + 19→ FlaskConical, + 20→ Loader2, + 21→ CircleDollarSign, + 22→} from 'lucide-react' + 23→import { useFetch } from '@/hooks/use-fetch' + 24→import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' + 25→import { Badge } from '@/components/ui/badge' + 26→import { Button } from '@/components/ui/button' + 27→import { Input } from '@/components/ui/input' + 28→import { Label } from '@/components/ui/label' + 29→import { Switch } from '@/components/ui/switch' + 30→import { Textarea } from '@/components/ui/textarea' + 31→import { Skeleton } from '@/components/ui/skeleton' + 32→import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' + 33→import { + 34→ Dialog, + 35→ DialogContent, + 36→ DialogDescription, + 37→ DialogFooter, + 38→ DialogHeader, + 39→ DialogTitle, + 40→} from '@/components/ui/dialog' + 41→import { + 42→ Select, + 43→ SelectContent, + 44→ SelectItem, + 45→ SelectTrigger, + 46→ SelectValue, + 47→} from '@/components/ui/select' + 48→import SlideCommit from '@/components/SlideCommit' + 49→import { PageHeader } from '../primitives' + 50→import { formatEuro, formatEuroCompact, formatDate, formatRelative, daysUntil } from '@/lib/format' + 51→import { cn } from '@/lib/utils' + 52→import { toast } from 'sonner' + 53→ + 54→// --------------------------------------------------------------------------- + 55→// Types + 56→// --------------------------------------------------------------------------- + 57→ + 58→interface LicenseModule { + 59→ id: string + 60→ key: string + 61→ module: string | null + 62→ status: string + 63→ environment: 'demo' | 'production' + 64→ maxActivations: number + 65→ validUntil: string | null + 66→ createdAt: string + 67→ // B2B billing fields (mirror the parent License shape so module rows can + 68→ // show their own billing state — most modules inherit "via Core" and have + 69→ // priceCents = 0, but modules with their own pricing show their netPriceCents). + 70→ billingCycle: 'monthly' | 'yearly' + 71→ priceCents: number + 72→ discountPct: number + 73→ netPriceCents: number + 74→ trialEndsAt: string | null + 75→ subscriptionStartsAt: string | null + 76→ cancelledAt: string | null + 77→ cancellationEffectiveAt: string | null + 78→} + 79→ + 80→interface License { + 81→ id: string + 82→ key: string + 83→ product: string + 84→ plan: string + 85→ status: string + 86→ environment: 'demo' | 'production' + 87→ parentId: string | null + 88→ module: string | null + 89→ maxActivations: number + 90→ validUntil: string | null + 91→ createdAt: string + 92→ suspendReason: string | null + 93→ suspendedAt: string | null + 94→ // --- B2B Subscription & Billing (German law) --- + 95→ noticePeriodDays: number + 96→ trialEndsAt: string | null + 97→ subscriptionStartsAt: string | null + 98→ billingCycle: 'monthly' | 'yearly' + 99→ priceCents: number + 100→ discountPct: number + 101→ netPriceCents: number + 102→ cancelledAt: string | null + 103→ cancellationEffectiveAt: string | null + 104→ cancellationReason: string | null + 105→ company: { id: string; name: string } + 106→ activations: { id: string; domain: string; environment: string; version: string | null; lastCheck: string }[] + 107→ modules: LicenseModule[] + 108→} + 109→ + 110→interface Asset { + 111→ id: string + 112→ name: string + 113→ type: string + 114→ provider: string | null + 115→ costCentsPerMonth: number | null + 116→ renewalDate: string + 117→ rebill: boolean + 118→ markupPct: number + 119→ status: string + 120→} + 121→ + 122→interface LicensesData { + 123→ licenses: License[] + 124→ assets: Asset[] + 125→ stats: { + 126→ activeLicenses: number + 127→ suspended: number + 128→ expired: number + 129→ cancelled: number + 130→ inTrial: number + 131→ totalActivations: number + 132→ expiringSoon: number + 133→ expiredAssets: number + 134→ monthlyRebill: number + 135→ demoLicenses: number + 136→ mrrCents: number + 137→ } + 138→} + 139→ + 140→// Minimal company shape for the create-license dialog. The CRM route returns + 141→// the full Prisma Company (including the agency fields needed to preview the + 142→// agency discount in the pricing breakdown). + 143→interface CompanyLite { + 144→ id: string + 145→ name: string + 146→ isAgency: boolean + 147→ agencyStatus: string | null + 148→ agencyDiscountPct: number + 149→ parentAgencyId: string | null + 150→} + 151→ + 152→// --------------------------------------------------------------------------- + 153→// Constants + 154→// --------------------------------------------------------------------------- + 155→ + 156→const LIC_STATUS: Record = { + 157→ active: { label: 'Aktiv', icon: CheckCircle2 }, + 158→ suspended: { label: 'Suspendiert', icon: PauseCircle }, + 159→ expired: { label: 'Abgelaufen', icon: XCircle }, + 160→ revoked: { label: 'Widerrufen', icon: XCircle }, + 161→ cancelled: { label: 'Gekündigt', icon: Ban }, + 162→} + 163→ + 164→const ASSET_TYPE_LABELS: Record = { + 165→ domain: 'Domain', + 166→ hosting: 'Hosting', + 167→ ssl: 'SSL', + 168→ 'plugin-seat': 'Plugin-Seat', + 169→ saas: 'SaaS', + 170→} + 171→ + 172→// Default monthly prices per plan (mirror of the backend PLAN_PRICES table). + 173→const PLAN_PRICES: Record = { + 174→ starter: 2900, + 175→ pro: 5000, + 176→ enterprise: 15000, + 177→} + 178→const YEARLY_DISCOUNT_PCT = 10 + 179→ + 180→type FilterKey = 'all' | 'active' | 'trial' | 'cancelled' | 'demo' + 181→const FILTERS: { key: FilterKey; label: string }[] = [ + 182→ { key: 'all', label: 'Alle' }, + 183→ { key: 'active', label: 'Aktiv' }, + 184→ { key: 'trial', label: 'In Trial' }, + 185→ { key: 'cancelled', label: 'Gekündigt' }, + 186→ { key: 'demo', label: 'Demo' }, + 187→] + 188→ + 189→// --------------------------------------------------------------------------- + 190→// Main view + 191→// --------------------------------------------------------------------------- + 192→ + 193→export function LicensesView() { + 194→ const { data, loading, refetch } = useFetch('/api/aos/licenses') + 195→ // Companies are needed for the create-license dialog (parent agency lookup). + 196→ const { data: crmData } = useFetch<{ companies: CompanyLite[] } | null>('/api/aos/crm') + 197→ const [createOpen, setCreateOpen] = useState(false) + 198→ const [suspendTarget, setSuspendTarget] = useState(null) + 199→ const [cancelTarget, setCancelTarget] = useState(null) + 200→ const [filter, setFilter] = useState('all') + 201→ + 202→ if (loading || !data) { + 203→ return ( + 204→
    + 205→ + 206→ + 207→
    + 208→ ) + 209→ } + 210→ + 211→ const { licenses, assets, stats } = data + 212→ const companies = crmData?.companies ?? [] + 213→ + 214→ const now = new Date() + 215→ const filtered = licenses.filter((l) => { + 216→ if (filter === 'all') return true + 217→ if (filter === 'demo') return l.environment === 'demo' + 218→ if (filter === 'cancelled') return !!l.cancelledAt || l.status === 'cancelled' + 219→ if (filter === 'trial') return !!l.trialEndsAt && new Date(l.trialEndsAt) > now + 220→ if (filter === 'active') { + 221→ return ( + 222→ l.status === 'active' && + 223→ !l.cancelledAt && + 224→ !(l.trialEndsAt && new Date(l.trialEndsAt) > now) + 225→ ) + 226→ } + 227→ return true + 228→ }) + 229→ + 230→ const handlePromote = (license: License) => { + 231→ void promoteLicense(license, refetch) + 232→ } + 233→ + 234→ return ( + 235→
    + 236→ setCreateOpen(true)}> + 241→ + 242→ Lizenz ausstellen + 243→ + 244→ } + 245→ /> + 246→ + 247→ {/* KPI row — 4 existing + MRR + In Trial = 6 cards */} + 248→
    + 249→ + 250→
    + 251→

    Aktive Lizenzen

    + 252→ + 253→
    + 254→

    {stats.activeLicenses}

    + 255→
    + 256→ + 257→
    + 258→

    Aktivierungen

    + 259→ + 260→
    + 261→

    {stats.totalActivations}

    + 262→
    + 263→ + 264→
    + 265→

    Erneuerungen fällig

    + 266→ + 267→
    + 268→

    {stats.expiringSoon + stats.expiredAssets}

    + 269→

    {stats.expiredAssets} abgelaufen

    + 270→
    + 271→ + 272→
    + 273→

    Monatl. Weiterbelastung

    + 274→ + 275→
    + 276→

    {formatEuro(stats.monthlyRebill)}

    + 277→
    + 278→ + 279→
    + 280→

    MRR

    + 281→ + 282→
    + 283→

    {formatEuroCompact(stats.mrrCents)}

    + 284→

    Monatlich wiederkehrend

    + 285→
    + 286→ + 287→
    + 288→

    In Trial

    + 289→ + 290→
    + 291→

    {stats.inTrial}

    + 292→

    14-Tage-Testphase

    + 293→
    + 294→
    + 295→ + 296→ + 297→ + 298→ Ausgestellte Lizenzen + 299→ Erneuerungsregister + 300→ + 301→ + 302→ + 303→ {/* Filter tabs */} + 304→
    + 305→ {FILTERS.map((f) => ( + 306→ + 316→ ))} + 317→
    + 318→ + 319→ + 320→ + 321→ Produktlizenzen ({filtered.length}) + 322→ + 323→ + 324→
    + 325→ + 326→ + 327→ + 328→ + 330→ + 331→ + 332→ + 333→ + 334→ + 335→ + 336→ + 337→ + 338→ + 339→ {filtered.length === 0 ? ( + 340→ + 341→ + 344→ + 345→ ) : ( + 346→ filtered.map((l) => ( + 347→ setSuspendTarget(lic)} + 351→ onCancel={(lic) => setCancelTarget(lic)} + 352→ onPromote={handlePromote} + 353→ /> + 354→ )) + 355→ )} + 356→ + 357→
    + 329→ LizenzschlüsselProdukt / PlanKundeAktivierungenStatus & BillingGültig bisAktionen
    + 342→ Keine Lizenzen in diesem Filter. + 343→
    + 358→
    + 359→
    + 360→
    + 361→ + 362→ {/* Signing info card */} + 363→ + 364→ + 365→
    + 366→
    + 367→ + 368→
    + 369→
    + 370→

    Ed25519-signierte Lizenz-Tokens

    + 371→

    + 372→ Jede Lizenz hat einen menschenlesbaren Schlüssel plus einen kryptografisch signierten Token. Kunden können offline verifizieren. + 373→ Der private Schlüssel ist verschlüsselt (AES-256-GCM); Rotation wird unterstützt. + 374→

    + 375→
    + 376→ Public API: /api/v1/validate + 377→ WordPress-Updater kompatibel + 378→ Offline-Grace: 14 d + 379→
    + 380→
    + 381→
    + 382→
    + 383→
    + 384→
    + 385→ + 386→ + 387→ + 388→ + 389→ Asset- & Erneuerungsregister ({assets.length}) + 390→ + 391→ + 392→
    + 393→ + 394→ + 395→ + 396→ + 397→ + 398→ + 399→ + 400→ + 401→ + 402→ + 403→ + 404→ + 405→ + 406→ {assets.map((a) => { + 407→ const days = daysUntil(a.renewalDate) + 408→ return ( + 409→ + 410→ + 411→ + 414→ + 415→ + 418→ + 426→ + 435→ + 447→ + 448→ ) + 449→ })} + 450→ + 451→
    AssetTypAnbieterKosten/MonatErneuerungWeiterbelastenStatus
    {a.name} + 412→ {ASSET_TYPE_LABELS[a.type] ?? a.type} + 413→ {a.provider ?? '—'} + 416→ {a.costCentsPerMonth ? formatEuro(a.costCentsPerMonth) : '—'} + 417→ + 419→ + 420→ {formatDate(a.renewalDate)} + 421→ + 422→ + 423→ {days < 0 ? `(${Math.abs(days)} d vorbei)` : `(${days} d)`} + 424→ + 425→ + 427→ {a.rebill ? ( + 428→ + 429→ +{a.markupPct}% + 430→ + 431→ ) : ( + 432→ — + 433→ )} + 434→ + 436→ + 444→ {a.status === 'expired' ? 'Abgelaufen' : a.status === 'expiring' ? 'Läuft ab' : 'Aktiv'} + 445→ + 446→
    + 452→
    + 453→
    + 454→
    + 455→
    + 456→
    + 457→ + 458→ + 464→ !o && setSuspendTarget(null)} + 467→ onDone={refetch} + 468→ /> + 469→ !o && setCancelTarget(null)} + 472→ onDone={refetch} + 473→ /> + 474→
    + 475→ ) + 476→} + 477→ + 478→// --------------------------------------------------------------------------- + 479→// LicenseRow — expandable row with billing badges + Cancel/Suspend/Promote + 480→// --------------------------------------------------------------------------- + 481→ + 482→function LicenseRow({ + 483→ license, + 484→ onSuspend, + 485→ onCancel, + 486→ onPromote, + 487→}: { + 488→ license: License + 489→ onSuspend: (l: License) => void + 490→ onCancel: (l: License) => void + 491→ onPromote: (l: License) => void + 492→}) { + 493→ const [open, setOpen] = useState(false) + 494→ const st = LIC_STATUS[license.status] ?? LIC_STATUS.active + 495→ const Icon = st.icon + 496→ const valid = license.validUntil ? daysUntil(license.validUntil) : null + 497→ const isModule = !!license.parentId + 498→ const inTrial = !!license.trialEndsAt && new Date(license.trialEndsAt) > new Date() + 499→ const trialDaysLeft = inTrial ? Math.max(0, daysUntil(license.trialEndsAt!)) : 0 + 500→ const cancelled = !!license.cancelledAt || license.status === 'cancelled' + 501→ const canCancel = !isModule && !cancelled && license.status === 'active' + 502→ const canReactivate = cancelled + 503→ const hasModules = !isModule && license.modules.length > 0 + 504→ const showPrice = !isModule && license.netPriceCents > 0 + 505→ + 506→ return ( + 507→ <> + 508→ + 509→ + 510→ {hasModules ? ( + 511→ + 518→ ) : ( + 519→ + 520→ )} + 521→ + 522→ + 523→ {license.key} + 524→ {isModule && ( + 525→ Modul + 526→ )} + 527→ + 528→ + 529→

    {license.product}

    + 530→ {license.plan} + 531→ + 532→ {license.company.name} + 533→ + 534→ + 535→ {license.activations.length} / {license.maxActivations} + 536→ + 537→ {license.activations[0] && ( + 538→

    {license.activations[0].domain}

    + 539→ )} + 540→ + 541→ + 542→
    + 543→ + 552→ {st.label} + 553→ + 554→ {showPrice && ( + 555→ <> + 556→ + 561→ {license.billingCycle === 'yearly' ? 'Y' : 'M'} + 562→ + 563→ + 564→ {formatEuroCompact(license.netPriceCents)} + 565→ /{license.billingCycle === 'yearly' ? 'Jahr' : 'Mon.'} + 566→ + 567→ + 568→ )} + 569→ {inTrial && ( + 570→ + 571→ Trial ({trialDaysLeft} d) + 572→ + 573→ )} + 574→ {cancelled && license.cancellationEffectiveAt && ( + 575→ + 580→ Gekündigt → {formatDate(license.cancellationEffectiveAt)} + 581→ + 582→ )} + 583→ {license.environment === 'demo' && ( + 584→ Demo + 585→ )} + 586→
    + 587→ + 588→ + 589→ {license.validUntil ? ( + 590→ + 591→ {formatDate(license.validUntil)} + 592→ {valid !== null && ( + 593→ + 594→ {valid < 0 ? `(${Math.abs(valid)} d vorbei)` : `(${valid} d)`} + 595→ + 596→ )} + 597→ + 598→ ) : ( + 599→ — + 600→ )} + 601→ + 602→ + 603→
    + 604→ {canCancel && ( + 605→ + 608→ )} + 609→ {license.status === 'active' && !cancelled && ( + 610→ + 613→ )} + 614→ {canReactivate && ( + 615→ + 618→ )} + 619→ {license.environment === 'demo' && !cancelled && ( + 620→ + 623→ )} + 624→
    + 625→ + 626→ + 627→ + 628→ {/* Expanded module rows */} + 629→ {open && hasModules && ( + 630→ + 631→ + 632→
    + 633→ + 634→ + 635→ + 636→ + 637→ + 638→ + 639→ + 640→ + 641→ + 642→ + 643→ + 644→ {license.modules.map((m) => { + 645→ const mst = LIC_STATUS[m.status] ?? LIC_STATUS.active + 646→ const MIcon = mst.icon + 647→ const mInTrial = !!m.trialEndsAt && new Date(m.trialEndsAt) > new Date() + 648→ const mCancelled = !!m.cancelledAt || m.status === 'cancelled' + 649→ return ( + 650→ + 651→ + 652→ + 655→ + 665→ + 681→ + 684→ + 685→ ) + 686→ })} + 687→ + 688→
    ModulSchlüsselBillingStatusGültig bis
    {m.module ?? '—'} + 653→ {m.key} + 654→ + 656→ {m.priceCents > 0 ? ( + 657→ + 658→ {formatEuroCompact(m.netPriceCents)} + 659→ /{m.billingCycle === 'yearly' ? 'Jahr' : 'Mon.'} + 660→ + 661→ ) : ( + 662→ via Core + 663→ )} + 664→ + 666→ + 673→ {mst.label} + 674→ + 675→ {mInTrial && ( + 676→ + 677→ Trial + 678→ + 679→ )} + 680→ + 682→ {m.validUntil ? formatDate(m.validUntil) : '—'} + 683→
    + 689→
    + 690→ + 691→ + 692→ )} + 693→ + 694→ ) + 695→} + 696→ + 697→// --------------------------------------------------------------------------- + 698→// Promote / Reactivate helper + 699→// --------------------------------------------------------------------------- + 700→ + 701→async function promoteLicense(license: License, refetch: () => void) { + 702→ const t = toast.loading('Lizenz wird reaktiviert…') + 703→ try { + 704→ const r = await fetch(`/api/aos/licenses/${license.id}/promote`, { + 705→ method: 'PATCH', + 706→ headers: { 'content-type': 'application/json' }, + 707→ body: JSON.stringify({ environment: 'production' }), + 708→ }) + 709→ if (!r.ok) { + 710→ const e = await r.json().catch(() => ({})) + 711→ throw new Error(e.error || 'promote failed') + 712→ } + 713→ toast.success('Lizenz reaktiviert', { id: t }) + 714→ refetch() + 715→ } catch (err) { + 716→ toast.error(err instanceof Error ? err.message : 'Reaktivierung fehlgeschlagen', { id: t }) + 717→ } + 718→} + 719→ + 720→// --------------------------------------------------------------------------- + 721→// Create License Dialog — with Billing section + live pricing preview + 722→// --------------------------------------------------------------------------- + 723→ + 724→function CreateLicenseDialog({ + 725→ open, + 726→ onOpenChange, + 727→ companies, + 728→ onCreated, + 729→}: { + 730→ open: boolean + 731→ onOpenChange: (o: boolean) => void + 732→ companies: CompanyLite[] + 733→ onCreated: () => void + 734→}) { + 735→ const [companyId, setCompanyId] = useState('') + 736→ const [product, setProduct] = useState('aos-cms') + 737→ const [plan, setPlan] = useState<'starter' | 'pro' | 'enterprise'>('pro') + 738→ const [environment, setEnvironment] = useState<'demo' | 'production'>('production') + 739→ const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly') + 740→ const [monthlyPriceCents, setMonthlyPriceCents] = useState('') + 741→ const [startTrial, setStartTrial] = useState(true) + 742→ const [maxActivations, setMaxActivations] = useState(1) + 743→ const [saving, setSaving] = useState(false) + 744→ + 745→ // Default trial ON for production, OFF for demo. + 746→ useEffect(() => { + 747→ if (environment === 'demo') setStartTrial(false) + 748→ else setStartTrial(true) + 749→ }, [environment]) + 750→ + 751→ // Pick first company when the dialog opens. + 752→ useEffect(() => { + 753→ if (open && companies[0] && !companyId) setCompanyId(companies[0].id) + 754→ }, [open, companies, companyId]) + 755→ + 756→ // Reset everything when the dialog closes. + 757→ useEffect(() => { + 758→ if (open) return + 759→ setCompanyId('') + 760→ setProduct('aos-cms') + 761→ setPlan('pro') + 762→ setEnvironment('production') + 763→ setBillingCycle('monthly') + 764→ setMonthlyPriceCents('') + 765→ setStartTrial(true) + 766→ setMaxActivations(1) + 767→ }, [open]) + 768→ + 769→ const selectedCompany = companies.find((c) => c.id === companyId) ?? null + 770→ + 771→ // Live pricing preview — mirrors the backend computePricing() formula. + 772→ const pricing = useMemo(() => { + 773→ const monthlyCents = monthlyPriceCents + 774→ ? Math.max(0, parseInt(monthlyPriceCents, 10) || 0) + 775→ : PLAN_PRICES[plan] ?? 0 + 776→ const baseCents = billingCycle === 'yearly' ? monthlyCents * 12 : monthlyCents + 777→ const yearlyPct = billingCycle === 'yearly' ? YEARLY_DISCOUNT_PCT : 0 + 778→ let agencyPct = 0 + 779→ if (selectedCompany?.parentAgencyId) { + 780→ const agency = companies.find((c) => c.id === selectedCompany.parentAgencyId) + 781→ if (agency?.isAgency && agency.agencyStatus === 'approved') { + 782→ agencyPct = agency.agencyDiscountPct ?? 0 + 783→ } + 784→ } + 785→ const totalPct = agencyPct + yearlyPct + 786→ const discountAmount = Math.round((baseCents * totalPct) / 100) + 787→ const netPriceCents = baseCents - discountAmount + 788→ return { monthlyCents, baseCents, agencyPct, yearlyPct, totalPct, discountAmount, netPriceCents } + 789→ }, [monthlyPriceCents, billingCycle, plan, selectedCompany, companies]) + 790→ + 791→ const submit = async () => { + 792→ if (!companyId) { toast.error('Firma auswählen'); return } + 793→ if (!product.trim()) { toast.error('Produkt eingeben'); return } + 794→ setSaving(true) + 795→ const t = toast.loading('Lizenz wird ausgestellt…') + 796→ try { + 797→ const body: Record = { + 798→ companyId, + 799→ product: product.trim(), + 800→ plan, + 801→ environment, + 802→ billingCycle, + 803→ maxActivations, + 804→ startTrial, + 805→ } + 806→ const parsed = monthlyPriceCents ? parseInt(monthlyPriceCents, 10) : NaN + 807→ if (!Number.isNaN(parsed) && parsed > 0) body.monthlyPriceCents = parsed + 808→ const r = await fetch('/api/aos/licenses', { + 809→ method: 'POST', + 810→ headers: { 'content-type': 'application/json' }, + 811→ body: JSON.stringify(body), + 812→ }) + 813→ const res = await r.json() + 814→ if (!r.ok) throw new Error(res.error || 'create failed') + 815→ const pricingEcho = res.pricing + 816→ const cycleLabel = billingCycle === 'yearly' ? 'Jahr' : 'Monat' + 817→ const netCents = typeof pricingEcho?.netPriceCents === 'number' + 818→ ? pricingEcho.netPriceCents + 819→ : pricing.netPriceCents + 820→ const discPct = typeof pricingEcho?.totalDiscountPct === 'number' + 821→ ? pricingEcho.totalDiscountPct + 822→ : pricing.totalPct + 823→ const netEuro = (netCents / 100).toLocaleString('de-DE', { + 824→ minimumFractionDigits: 2, + 825→ maximumFractionDigits: 2, + 826→ }) + 827→ toast.success( + 828→ `Lizenz ausgestellt — ${netEuro} €/${cycleLabel} (inkl. ${discPct}% Rabatt)`, + 829→ { id: t, duration: 6000 } + 830→ ) + 831→ onOpenChange(false) + 832→ onCreated() + 833→ } catch (err) { + 834→ toast.error(err instanceof Error ? err.message : 'Ausstellung fehlgeschlagen', { id: t }) + 835→ } finally { + 836→ setSaving(false) + 837→ } + 838→ } + 839→ + 840→ return ( + 841→ + 842→ + 843→ + 844→ Lizenz ausstellen + 845→ + 846→ Stellt eine neue Lizenz aus. Billing, Probezeit und Kündigungsfrist (90 Tage) werden automatisch konfiguriert. + 847→ + 848→ + 849→
    + 850→ {/* Customer */} + 851→
    + 852→ + 853→ + 864→
    + 865→ {/* Product + Plan */} + 866→
    + 867→
    + 868→ + 869→ setProduct(e.target.value)} placeholder="z.B. aos-cms" /> + 870→
    + 871→
    + 872→ + 873→ + 881→
    + 882→
    + 883→ {/* Environment + Activations */} + 884→
    + 885→
    + 886→ + 887→ + 894→
    + 895→
    + 896→ + 897→ setMaxActivations(Math.max(1, parseInt(e.target.value, 10) || 1))} + 903→ /> + 904→
    + 905→
    + 906→ + 907→ {/* Billing section */} + 908→
    + 909→
    + 910→ + 911→ Billing + 912→
    + 913→
    + 914→
    + 915→ + 916→ + 923→ {billingCycle === 'yearly' && ( + 924→

    10% Jahresrabatt wird auf den Jahresbetrag angewendet.

    + 925→ )} + 926→
    + 927→
    + 928→ + 929→ setMonthlyPriceCents(e.target.value)} + 934→ placeholder="z.B. 5000 (= 50€)" + 935→ /> + 936→

    + 937→ Standard: 29€ (Starter), 50€ (Pro), 150€ (Enterprise). Überschreiben für Custom-Preis. + 938→

    + 939→
    + 940→
    + 941→
    + 942→
    + 943→ + 944→

    Tag 1–14 kostenlos kündbar. Tag 15 beginnt das kostenpflichtige Abo.

    + 945→
    + 946→ + 951→
    + 952→ + 953→ {/* Live pricing preview */} + 954→
    + 955→
    + 956→ Basispreis ({billingCycle === 'yearly' ? 'Jahr' : 'Monat'}) + 957→ {formatEuro(pricing.baseCents)} + 958→
    + 959→ {pricing.agencyPct > 0 && ( + 960→
    + 961→ Agentur-Rabatt ({pricing.agencyPct}%) + 962→ −{formatEuro(Math.round((pricing.baseCents * pricing.agencyPct) / 100))} + 963→
    + 964→ )} + 965→ {pricing.yearlyPct > 0 && ( + 966→
    + 967→ Jahresrabatt ({pricing.yearlyPct}%) + 968→ −{formatEuro(Math.round((pricing.baseCents * pricing.yearlyPct) / 100))} + 969→
    + 970→ )} + 971→ {pricing.totalPct > 0 && ( + 972→
    + 973→ Gesamtrabatt + 974→ {pricing.totalPct}% + 975→
    + 976→ )} + 977→
    + 978→ Netto / {billingCycle === 'yearly' ? 'Jahr' : 'Monat'} + 979→ {formatEuro(pricing.netPriceCents)} + 980→
    + 981→
    + 982→
    + 983→
    + 984→ + 985→ + 986→ + 990→ + 991→
    + 992→
    + 993→ ) + 994→} + 995→ + 996→// --------------------------------------------------------------------------- + 997→// Suspend Dialog + 998→// --------------------------------------------------------------------------- + 999→ + 1000→function SuspendDialog({ + 1001→ license, + 1002→ onOpenChange, + 1003→ onDone, + 1004→}: { + 1005→ license: License | null + 1006→ onOpenChange: (o: boolean) => void + 1007→ onDone: () => void + 1008→}) { + 1009→ const [reason, setReason] = useState('') + 1010→ const [saving, setSaving] = useState(false) + 1011→ + 1012→ useEffect(() => { + 1013→ if (!license) setReason('') + 1014→ }, [license]) + 1015→ + 1016→ const submit = async () => { + 1017→ if (!license) return + 1018→ if (!reason.trim()) { toast.error('Grund erforderlich'); return } + 1019→ setSaving(true) + 1020→ const t = toast.loading('Lizenz wird suspendiert…') + 1021→ try { + 1022→ const r = await fetch(`/api/aos/licenses/${license.id}/suspend`, { + 1023→ method: 'PATCH', + 1024→ headers: { 'content-type': 'application/json' }, + 1025→ body: JSON.stringify({ reason: reason.trim() }), + 1026→ }) + 1027→ const res = await r.json() + 1028→ if (!r.ok) throw new Error(res.error || 'suspend failed') + 1029→ toast.success( + 1030→ `Lizenz suspendiert${res.suspendedModules ? ` (+${res.suspendedModules} Module)` : ''}`, + 1031→ { id: t } + 1032→ ) + 1033→ onOpenChange(false) + 1034→ onDone() + 1035→ } catch (err) { + 1036→ toast.error(err instanceof Error ? err.message : 'Suspension fehlgeschlagen', { id: t }) + 1037→ } finally { + 1038→ setSaving(false) + 1039→ } + 1040→ } + 1041→ + 1042→ return ( + 1043→ + 1044→ + 1045→ + 1046→ Lizenz suspendieren + 1047→ + 1048→ Suspendiert die Lizenz (und alle Module). Aktivierte Installationen verweigern den Dienst bis zur Reaktivierung. + 1049→ + 1050→ + 1051→ {license && ( + 1052→
    + 1053→
    + 1054→

    {license.product} — {license.plan}

    + 1055→

    {license.company.name}

    + 1056→ {license.key} + 1057→
    + 1058→
    + 1059→ + 1060→