diff --git a/db/custom.db b/db/custom.db index 247a9e9..02ff24a 100755 Binary files a/db/custom.db and b/db/custom.db differ diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fc1d9de..6b93769 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -106,6 +106,7 @@ model Company { tickets Ticket[] agencyApplication AgencyApplication? instances Instance[] + repoAssignments RepoAssignment[] } model Person { @@ -953,6 +954,8 @@ model Instance { metadata String? // JSON: { gitRepo, gitBranch, imageTag, resources } lastError String? lastErrorAt DateTime? + allowedRepoId String? + allowedRepo AllowedRepo? @relation(fields: [allowedRepoId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt events InstanceEvent[] @@ -984,3 +987,40 @@ model ProvisioningJob { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } + +// --------------------------------------------------------------------------- +// Allowed Repos (admin controls which Gitea repos clients can deploy) +// --------------------------------------------------------------------------- +// Admin selects which repos are available for instance provisioning. +// Repos can be: +// - public: visible to all clients in the "Neue Instanz" dialog +// - assigned: only visible to specific companies (per-company assignment) +// Unfinished/custom projects are NOT shared unless explicitly assigned. +model AllowedRepo { + id String @id @default(cuid()) + giteaOwner String // e.g. "dfmsolutions" (Gitea org/user) + giteaRepo String // e.g. "restov1" (repo name) + displayName String // human-readable: "Pizzeria RESTO (restov1)" + description String? // what this product does + product String // product slug: "restov1", "aos-cms", etc. + isPublic Boolean @default(false) // true = all clients can see it + isActive Boolean @default(true) // false = hidden from all + envTemplate String? // JSON: default env vars for this repo + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + assignments RepoAssignment[] + instances Instance[] // instances created from this repo + + @@unique([giteaOwner, giteaRepo]) +} + +model RepoAssignment { + id String @id @default(cuid()) + allowedRepoId String + allowedRepo AllowedRepo @relation(fields: [allowedRepoId], references: [id], onDelete: Cascade) + companyId String // which company can use this repo + company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + + @@unique([allowedRepoId, companyId]) +} diff --git a/src/app/api/aos/instances/route.ts b/src/app/api/aos/instances/route.ts index 0698682..846a209 100644 --- a/src/app/api/aos/instances/route.ts +++ b/src/app/api/aos/instances/route.ts @@ -21,10 +21,8 @@ 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'), + allowedRepoId: z.string().min(1), // must reference an AllowedRepo 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). @@ -72,7 +70,7 @@ export async function POST(req: Request) { 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 + const { companyId, licenseId, allowedRepoId, plan, envVars, isDemo } = parsed.data // Verify company + license exist and belong together const company = await db.company.findUnique({ where: { id: companyId } }) @@ -83,9 +81,37 @@ export async function POST(req: Request) { 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 }) + + // --- Repo permission check --- + // Admin must have added this repo to the allowed list AND either: + // a) the repo is public (isPublic=true), OR + // b) the repo is assigned to this company (RepoAssignment exists) + const allowedRepo = await db.allowedRepo.findUnique({ + where: { id: allowedRepoId }, + include: { + assignments: { where: { companyId } }, + }, + }) + if (!allowedRepo) { + return NextResponse.json({ error: 'repo not found in allowed list' }, { status: 404 }) } + if (!allowedRepo.isActive) { + return NextResponse.json({ error: 'repo is disabled' }, { status: 403 }) + } + const hasAccess = allowedRepo.isPublic || allowedRepo.assignments.length > 0 + if (!hasAccess) { + return NextResponse.json({ error: 'repo is not assigned to this company and is not public' }, { status: 403 }) + } + + const product = allowedRepo.product + if (license.product !== product) { + return NextResponse.json({ error: `license product (${license.product}) does not match repo product (${product})` }, { status: 400 }) + } + + // Always use Gitea — git.dfmpanel.eu is our internal repo server + const GITEA_URL = process.env.GITEA_URL ?? 'https://git.dfmpanel.eu' + const gitRepo = `${GITEA_URL}/${allowedRepo.giteaOwner}/${allowedRepo.giteaRepo}.git` + const gitBranch = 'main' // always main // Check that no live instance already exists for this license const existing = await db.instance.findFirst({ @@ -119,6 +145,7 @@ export async function POST(req: Request) { plan, subdomain, demoExpiresAt: isDemo ? new Date(Date.now() + DEMO_DAYS * 86400000) : null, + allowedRepoId, }, }) @@ -128,7 +155,7 @@ export async function POST(req: Request) { type: 'provision.start', step: 'init', result: 'ok', - details: JSON.stringify({ subdomain, product, plan, isDemo, gitRepo }), + details: JSON.stringify({ subdomain, product, plan, isDemo, gitRepo, allowedRepo: allowedRepo.displayName }), }, }) @@ -141,14 +168,24 @@ export async function POST(req: Request) { throw new Error('Dokploy client not configured (DOKPLOY_URL / DOKPLOY_API_KEY missing)') } - // Instance env vars: license key + AOS connection + product-specific + // Parse repo-specific env template (if set by admin) + const repoEnvTemplate: Record = allowedRepo.envTemplate + ? JSON.parse(allowedRepo.envTemplate) + : {} + + // Instance env vars: license + AOS + repo template + overrides const instanceEnv: Record = { + // AOS license server connection 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', + // Standard runtime NODE_ENV: 'production', + // Repo-specific defaults from admin (APP_MODE, AUTH_SECRET, etc.) + ...repoEnvTemplate, + // Caller overrides (highest priority) ...envVars, } @@ -158,7 +195,7 @@ export async function POST(req: Request) { gitRepo, gitBranch, envVars: instanceEnv, - gitProvider: gitRepo.includes('github.com') ? 'github' : 'gitea', + gitProvider: 'gitea', // always Gitea cpuLimit: plan === 'enterprise' ? '2' : plan === 'pro' ? '1' : '0.5', memoryLimit: plan === 'enterprise' ? '2048m' : plan === 'pro' ? '1024m' : '512m', } diff --git a/src/app/api/aos/repos/[id]/route.ts b/src/app/api/aos/repos/[id]/route.ts new file mode 100644 index 0000000..4c57343 --- /dev/null +++ b/src/app/api/aos/repos/[id]/route.ts @@ -0,0 +1,115 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' +import { z } from 'zod' + +export const dynamic = 'force-dynamic' + +const patchSchema = z.object({ + displayName: z.string().optional(), + description: z.string().optional(), + isPublic: z.boolean().optional(), + isActive: z.boolean().optional(), + envTemplate: z.string().optional(), + // Assign to a specific company (or remove assignment) + assignToCompany: z.string().optional(), + unassignFromCompany: z.string().optional(), +}) + +// PATCH /api/aos/repos/[id] — update repo settings + assign/unassign +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 { displayName, description, isPublic, isActive, envTemplate, assignToCompany, unassignFromCompany } = parsed.data + + const repo = await db.allowedRepo.findUnique({ where: { id } }) + if (!repo) { + return NextResponse.json({ error: 'repo not found' }, { status: 404 }) + } + + // Update fields + const updateData: Record = {} + if (displayName !== undefined) updateData.displayName = displayName + if (description !== undefined) updateData.description = description + if (isPublic !== undefined) updateData.isPublic = isPublic + if (isActive !== undefined) updateData.isActive = isActive + if (envTemplate !== undefined) updateData.envTemplate = envTemplate + + if (Object.keys(updateData).length > 0) { + await db.allowedRepo.update({ where: { id }, data: updateData }) + } + + // Assign to company + if (assignToCompany) { + await db.repoAssignment.upsert({ + where: { allowedRepoId_companyId: { allowedRepoId: id, companyId: assignToCompany } }, + create: { allowedRepoId: id, companyId: assignToCompany }, + update: {}, + }) + } + + // Unassign from company + if (unassignFromCompany) { + await db.repoAssignment.deleteMany({ + where: { allowedRepoId: id, companyId: unassignFromCompany }, + }) + } + + await db.auditLog.create({ + data: { + actor: 'admin:ui', + action: 'repo.update', + entity: 'AllowedRepo', + entityId: id, + after: JSON.stringify({ updateData, assignToCompany, unassignFromCompany }), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + + return NextResponse.json({ ok: true }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'internal server error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} + +// DELETE /api/aos/repos/[id] — remove repo from allowed list +export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params + const repo = await db.allowedRepo.findUnique({ + where: { id }, + include: { _count: { select: { instances: true } } }, + }) + if (!repo) { + return NextResponse.json({ error: 'repo not found' }, { status: 404 }) + } + if (repo._count.instances > 0) { + return NextResponse.json({ + error: 'cannot delete repo with active instances', + instances: repo._count.instances, + }, { status: 409 }) + } + + await db.allowedRepo.delete({ where: { id } }) + + await db.auditLog.create({ + data: { + actor: 'admin:ui', + action: 'repo.delete', + entity: 'AllowedRepo', + entityId: id, + after: JSON.stringify({ giteaOwner: repo.giteaOwner, giteaRepo: repo.giteaRepo }), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + + return NextResponse.json({ ok: true }) + } 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/repos/route.ts b/src/app/api/aos/repos/route.ts new file mode 100644 index 0000000..ee0437d --- /dev/null +++ b/src/app/api/aos/repos/route.ts @@ -0,0 +1,110 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' +import { z } from 'zod' + +export const dynamic = 'force-dynamic' + +// GET /api/aos/repos — list all allowed repos (admin) +// GET /api/aos/repos?companyId=X — list repos available to a specific client +export async function GET(req: Request) { + try { + const url = new URL(req.url) + const companyId = url.searchParams.get('companyId') + + if (companyId) { + // Client-scoped: show public repos + repos assigned to this company + const [publicRepos, assignedRepos] = await Promise.all([ + db.allowedRepo.findMany({ + where: { isPublic: true, isActive: true }, + orderBy: { displayName: 'asc' }, + }), + db.allowedRepo.findMany({ + where: { + isActive: true, + assignments: { some: { companyId } }, + }, + include: { assignments: { where: { companyId } } }, + orderBy: { displayName: 'asc' }, + }), + ]) + // Merge + dedupe + const seen = new Set() + const repos = [...publicRepos, ...assignedRepos].filter((r) => { + if (seen.has(r.id)) return false + seen.add(r.id) + return true + }) + return NextResponse.json({ repos }) + } + + // Admin: list all repos + const repos = await db.allowedRepo.findMany({ + orderBy: { createdAt: 'desc' }, + include: { + _count: { select: { assignments: true, instances: true } }, + }, + }) + return NextResponse.json({ repos }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'internal server error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} + +const createSchema = z.object({ + giteaOwner: z.string().min(1), + giteaRepo: z.string().min(1), + displayName: z.string().min(1), + description: z.string().optional(), + product: z.string().min(1), + isPublic: z.boolean().default(false), + envTemplate: z.string().optional(), // JSON string of default env vars +}) + +// POST /api/aos/repos — admin adds a repo to the allowed list +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 { giteaOwner, giteaRepo, displayName, description, product, isPublic, envTemplate } = parsed.data + + const repo = await db.allowedRepo.upsert({ + where: { giteaOwner_giteaRepo: { giteaOwner, giteaRepo } }, + create: { + giteaOwner, + giteaRepo, + displayName, + description, + product, + isPublic, + envTemplate, + }, + update: { + displayName, + description, + product, + isPublic, + envTemplate, + isActive: true, + }, + }) + + await db.auditLog.create({ + data: { + actor: 'admin:ui', + action: 'repo.add', + entity: 'AllowedRepo', + entityId: repo.id, + after: JSON.stringify({ giteaOwner, giteaRepo, product, isPublic }), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + + return NextResponse.json({ repo }, { status: 201 }) + } 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/seed-prod/route.ts b/src/app/api/aos/seed-prod/route.ts index b3eeed2..15230c3 100644 --- a/src/app/api/aos/seed-prod/route.ts +++ b/src/app/api/aos/seed-prod/route.ts @@ -224,7 +224,34 @@ export async function POST(req: Request) { stats.apiTokens = 0 } - // --- 7. Audit log --- + // --- 7. Allowed Repos (tenant instance provisioning) --- + const existingRepos = await db.allowedRepo.count() + if (existingRepos === 0) { + const restov1EnvTemplate = JSON.stringify({ + APP_MODE: 'demo', + RESTO_RESTAURANT_ID: 'resto_demo_01', + RESTO_QR_SECRET: '4a27fcd1564d8557cfc8618c710476fe10f61c30e0896290567ac4656500b6d4', + CUSTOMER_AUTH_SECRET: '6dd5f89e224607ffc41668d1d590176af38dbbdb15d8eddea9ec1011604a260e', + STAFF_AUTH_SECRET: '8b09481986428134937be5685fe4bdc8858861e6a9b22f167506409bcd37ecc9', + }) + await db.allowedRepo.create({ + data: { + giteaOwner: 'dfmsolutions', + giteaRepo: 'restov1', + displayName: 'Pizzeria RESTO (restov1)', + description: 'Online-Bestellsystem für Restaurants — Pizza, Pasta, Lieferung, Reservierung', + product: 'restov1', + isPublic: true, + isActive: true, + envTemplate: restov1EnvTemplate, + }, + }) + stats.allowedRepos = 1 + } else { + stats.allowedRepos = 0 + } + + // --- 8. Audit log --- await db.auditLog.create({ data: { actor: 'system:seed-prod',