diff --git a/backend/src/routes/modules.ts b/backend/src/routes/modules.ts new file mode 100644 index 0000000..9f9dfb2 --- /dev/null +++ b/backend/src/routes/modules.ts @@ -0,0 +1,1032 @@ +import type { FastifyInstance, FastifyPluginAsync, FastifyRequest } from "fastify"; +import { execFile } from "child_process"; +import { promisify } from "util"; +import { z } from "zod"; +import type { + BackupScope, + DatabaseEngine, + NotificationCategory, + Permission, + SecuritySeverity, + SecurityStatus, + Status, +} from "@prisma/client"; +import { accountIdFor, requireRole, requireSession } from "../lib/auth.js"; +import { conflict, notFound, ok, validationError } from "../lib/envelope.js"; +import { hashPassword } from "../lib/password.js"; +import { + databaseDto, + mailboxDto, + serverDto, + toPermission, + userDto, +} from "../lib/serializers.js"; + +const databaseEngines = ["MySQL", "MariaDB", "PostgreSQL"] as const; +const backupScopes = [ + "full", + "website", + "database", + "email", + "configuration", + "snapshot", +] as const; +const statuses = ["healthy", "warning", "critical", "maintenance", "disabled"] as const; +const severities = ["critical", "high", "medium", "low"] as const; +const findingStatuses = ["open", "acknowledged", "resolved"] as const; +const notificationCategories = [ + "security", + "backup", + "resource", + "ssl", + "deployment", + "system", +] as const; +const permissionValues = [ + "dashboard_read", + "tools_read", + "websites_manage", + "domains_manage", + "files_manage", + "databases_manage", + "email_manage", + "metrics_read", + "security_manage", + "software_manage", + "advanced_manage", + "servers_manage", + "users_manage", + "resellers_manage", + "backups_manage", + "automation_manage", + "notifications_read", + "audit_read", + "support_manage", + "settings_manage", +] as const; + +const execFileAsync = promisify(execFile); + +const mailboxSchema = z.object({ + address: z.string().email(), + quota: z.coerce.number().int().positive().optional(), + forwarding: z.boolean().optional(), + authStatus: z.enum(statuses).optional(), +}); + +const databaseUpdateSchema = z.object({ + name: z.string().min(2).optional(), + engine: z.enum(databaseEngines).optional(), + size: z.coerce.number().int().nonnegative().optional(), + tables: z.coerce.number().int().nonnegative().optional(), + connections: z.coerce.number().int().nonnegative().optional(), +}); + +const backupCreateSchema = z.object({ + name: z.string().min(2).optional(), + scope: z.enum(backupScopes).default("full"), + destination: z.string().min(2).default("Local storage"), +}); + +const automationSchema = z.object({ + name: z.string().min(2), + trigger: z.string().min(2), + status: z.enum(statuses).default("healthy"), + successRate: z.coerce.number().int().min(0).max(100).default(100), +}); + +const findingCreateSchema = z.object({ + severity: z.enum(severities), + title: z.string().min(3), + target: z.string().min(1), + status: z.enum(findingStatuses).default("open"), + recommendation: z.string().min(3), +}); + +const findingUpdateSchema = findingCreateSchema.partial(); + +const notificationCreateSchema = z.object({ + title: z.string().min(2), + body: z.string().min(2), + category: z.enum(notificationCategories).default("system"), + read: z.boolean().default(false), +}); + +const userCreateSchema = z.object({ + name: z.string().min(2), + email: z.string().email(), + password: z.string().min(12).optional(), + role: z.string().min(2).default("developer"), + permissions: z.array(z.enum(permissionValues)).default(["dashboard_read", "tools_read"]), +}); + +const userUpdateSchema = z.object({ + name: z.string().min(2).optional(), + role: z.string().min(2).optional(), + twoFactorEnabled: z.boolean().optional(), + permissions: z.array(z.enum(permissionValues)).optional(), +}); + +const settingsSchema = z.object({ + theme: z.enum(["system", "light", "dark"]).optional(), + sessionTimeout: z.coerce.number().int().min(5).max(1440).optional(), + locale: z.string().min(2).optional(), + timezone: z.string().min(2).optional(), +}); + +function zodFields(error: z.ZodError) { + const fields: Record = {}; + for (const issue of error.issues) fields[issue.path.join(".")] = issue.message; + return fields; +} + +function parse(schema: z.ZodSchema, body: unknown) { + const parsed = schema.safeParse(body); + if (!parsed.success) throw validationError("Validation failed.", zodFields(parsed.error)); + return parsed.data; +} + +function job(request: FastifyRequest, label: string, status = "queued") { + return ok(request, { + id: `job-${Date.now()}`, + label, + status, + progress: status === "running" ? 35 : 0, + startedAt: new Date().toISOString(), + message: "The request was accepted by the control plane. No host command was executed.", + }); +} + +async function audit( + app: FastifyInstance, + request: FastifyRequest, + accountId: string, + actor: string, + action: string, + target: string, +) { + await app.prisma.auditEntry.create({ + data: { + actor, + action, + target, + ipAddress: request.ip, + requestId: request.id, + accountId, + }, + }); +} + +function iso(value: Date | null | undefined) { + return value?.toISOString() ?? new Date(0).toISOString(); +} + +async function readCommand(command: string, args: string[]) { + try { + const { stdout } = await execFileAsync(command, args, { + timeout: 5000, + maxBuffer: 1024 * 1024, + }); + return stdout; + } catch { + return ""; + } +} + +function parseNginxSites(config: string) { + const blocks = config.match(/server\s*\{[\s\S]*?\n\}/g) ?? []; + return blocks + .map((block, index) => { + const serverName = + block + .match(/server_name\s+([^;]+);/)?.[1] + ?.trim() + .split(/\s+/) + .filter(Boolean) ?? []; + const listens = [...block.matchAll(/listen\s+([^;]+);/g)].map((match) => + match[1]!.trim(), + ); + const root = block.match(/\n\s*root\s+([^;]+);/)?.[1]?.trim(); + const proxyPasses = [...block.matchAll(/proxy_pass\s+([^;]+);/g)].map((match) => + match[1]!.trim(), + ); + if (!serverName.length && !root && !proxyPasses.length) return null; + return { + id: `nginx-site-${index}`, + serverNames: serverName, + listens, + root: root ?? null, + proxyPasses, + tls: block.includes(" ssl") || block.includes("ssl_certificate"), + source: "nginx -T", + }; + }) + .filter((site): site is NonNullable => Boolean(site)); +} + +function parseDockerContainers(lines: string) { + return lines + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [id = "", name = "", image = "", status = "", ports = ""] = line.split("\t"); + return { id, name, image, status, ports }; + }); +} + +export const moduleRoutes: FastifyPluginAsync = async (app) => { + app.get("/api/databases/admin-tools", async (request) => { + await requireSession(app, request); + return ok(request, [ + { + id: "phpmyadmin", + name: "phpMyAdmin", + engine: "MySQL/MariaDB", + status: "available", + launchUrl: "/phpmyadmin/", + sso: false, + note: "Use a database username and password. CoaxysPanel does not expose stored passwords.", + }, + { + id: "adminer", + name: "Adminer", + engine: "MySQL, MariaDB, PostgreSQL", + status: "planned", + launchUrl: null, + sso: false, + note: "Adminer is represented in the API and can be enabled as a separate deployment.", + }, + { + id: "pgadmin", + name: "pgAdmin", + engine: "PostgreSQL", + status: "planned", + launchUrl: null, + sso: false, + note: "pgAdmin is not exposed publicly by default.", + }, + ]); + }); + + app.get("/api/system/discovery", async (request) => { + await requireRole(app, request, ["owner", "operator"]); + const [nginxConfig, dockerLines] = await Promise.all([ + readCommand("nginx", ["-T"]), + readCommand("docker", [ + "ps", + "--format", + "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}", + ]), + ]); + const nginxSites = parseNginxSites(nginxConfig); + const containers = parseDockerContainers(dockerLines); + return ok(request, { + detectedAt: new Date().toISOString(), + nginx: { + available: nginxConfig.length > 0, + sites: nginxSites, + proxiedSites: nginxSites.filter((site) => site.proxyPasses.length > 0), + }, + docker: { + available: dockerLines.length > 0, + containers, + }, + currentPanel: { + hostname: "cp.asabbagh.com", + frontend: "http://127.0.0.1:3110", + backend: "http://127.0.0.1:3020", + }, + }); + }); + + app.get("/api/system/nginx/sites", async (request) => { + await requireRole(app, request, ["owner", "operator"]); + return ok(request, parseNginxSites(await readCommand("nginx", ["-T"]))); + }); + + app.get("/api/system/docker/containers", async (request) => { + await requireRole(app, request, ["owner", "operator"]); + return ok( + request, + parseDockerContainers( + await readCommand("docker", [ + "ps", + "--format", + "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}", + ]), + ), + ); + }); + + app.get("/api/databases/:id", async (request) => { + const session = await requireSession(app, request); + const { id } = request.params as { id: string }; + const database = await app.prisma.database.findFirst({ + where: { id, accountId: accountIdFor(session) }, + include: { users: true }, + }); + if (!database) throw notFound(`Database with ID ${id} not found.`); + return ok(request, databaseDto(database)); + }); + + app.put("/api/databases/:id", async (request) => { + const session = await requireRole(app, request, ["owner", "operator", "developer"]); + const { id } = request.params as { id: string }; + const accountId = accountIdFor(session); + const existing = await app.prisma.database.findFirst({ where: { id, accountId } }); + if (!existing) throw notFound(`Database with ID ${id} not found.`); + const body = parse(databaseUpdateSchema, request.body); + if (body.name && body.name !== existing.name) { + const duplicate = await app.prisma.database.findFirst({ + where: { accountId, name: body.name }, + }); + if (duplicate) + throw conflict("Database name already exists.", { name: "Use a unique name." }); + } + const database = await app.prisma.database.update({ + where: { id }, + data: { + name: body.name, + engine: body.engine as DatabaseEngine | undefined, + size: body.size === undefined ? undefined : BigInt(body.size), + tables: body.tables, + connections: body.connections, + }, + include: { users: true }, + }); + await audit(app, request, accountId, session.user.name, "updated database", database.name); + return ok(request, databaseDto(database)); + }); + + app.delete("/api/databases/:id", async (request) => { + const session = await requireRole(app, request, ["owner", "operator"]); + const { id } = request.params as { id: string }; + const accountId = accountIdFor(session); + const existing = await app.prisma.database.findFirst({ where: { id, accountId } }); + if (!existing) throw notFound(`Database with ID ${id} not found.`); + await app.prisma.database.delete({ where: { id } }); + await audit(app, request, accountId, session.user.name, "deleted database", existing.name); + return ok(request, { deleted: id }); + }); + + app.post("/api/databases/:id/users", async (request) => { + const session = await requireRole(app, request, ["owner", "operator", "developer"]); + const { id } = request.params as { id: string }; + const accountId = accountIdFor(session); + const body = parse(z.object({ username: z.string().min(2) }), request.body); + const database = await app.prisma.database.findFirst({ where: { id, accountId } }); + if (!database) throw notFound(`Database with ID ${id} not found.`); + await app.prisma.databaseUser.upsert({ + where: { username: body.username }, + create: { username: body.username }, + update: {}, + }); + await app.prisma.databaseUserAccess.upsert({ + where: { databaseId_username: { databaseId: id, username: body.username } }, + create: { databaseId: id, username: body.username }, + update: {}, + }); + await audit( + app, + request, + accountId, + session.user.name, + "granted database access", + body.username, + ); + const updated = await app.prisma.database.findUniqueOrThrow({ + where: { id }, + include: { users: true }, + }); + return ok(request, databaseDto(updated)); + }); + + app.get("/api/email/accounts/:id", async (request) => { + const session = await requireSession(app, request); + const { id } = request.params as { id: string }; + const mailbox = await app.prisma.mailbox.findFirst({ + where: { id, accountId: accountIdFor(session) }, + include: { domain: true }, + }); + if (!mailbox) throw notFound(`Mailbox with ID ${id} not found.`); + return ok(request, mailboxDto(mailbox)); + }); + + app.put("/api/email/accounts/:id", async (request) => { + const session = await requireRole(app, request, ["owner", "operator"]); + const { id } = request.params as { id: string }; + const accountId = accountIdFor(session); + const existing = await app.prisma.mailbox.findFirst({ where: { id, accountId } }); + if (!existing) throw notFound(`Mailbox with ID ${id} not found.`); + const body = parse(mailboxSchema.partial(), request.body); + const mailbox = await app.prisma.mailbox.update({ + where: { id }, + data: { + address: body.address, + quota: body.quota === undefined ? undefined : BigInt(body.quota), + forwarding: body.forwarding, + authStatus: body.authStatus as Status | undefined, + }, + include: { domain: true }, + }); + await audit(app, request, accountId, session.user.name, "updated mailbox", mailbox.address); + return ok(request, mailboxDto(mailbox)); + }); + + app.delete("/api/email/accounts/:id", async (request) => { + const session = await requireRole(app, request, ["owner", "operator"]); + const { id } = request.params as { id: string }; + const accountId = accountIdFor(session); + const existing = await app.prisma.mailbox.findFirst({ where: { id, accountId } }); + if (!existing) throw notFound(`Mailbox with ID ${id} not found.`); + await app.prisma.mailbox.delete({ where: { id } }); + await audit( + app, + request, + accountId, + session.user.name, + "deleted mailbox", + existing.address, + ); + return ok(request, { deleted: id }); + }); + + app.post("/api/backups/full", async (request) => { + const session = await requireRole(app, request, ["owner", "operator"]); + const accountId = accountIdFor(session); + const body = parse(backupCreateSchema, request.body); + const backup = await app.prisma.backup.create({ + data: { + name: body.name ?? `${body.scope} backup ${new Date().toISOString()}`, + scope: body.scope as BackupScope, + destination: body.destination ?? "Local storage", + size: 0n, + status: "healthy", + encrypted: true, + accountId, + }, + }); + await audit(app, request, accountId, session.user.name, "created backup", backup.name); + return ok(request, { + id: backup.id, + status: "queued", + progress: 0, + startedAt: new Date().toISOString(), + }); + }); + + app.get("/api/backups/:id", async (request) => { + const session = await requireSession(app, request); + const { id } = request.params as { id: string }; + const backup = await app.prisma.backup.findFirst({ + where: { id, accountId: accountIdFor(session) }, + }); + if (!backup) throw notFound(`Backup with ID ${id} not found.`); + return ok(request, { + id: backup.id, + name: backup.name, + scope: backup.scope, + destination: backup.destination, + size: Number(backup.size), + status: backup.status, + encrypted: backup.encrypted, + createdAt: backup.createdAt.toISOString(), + }); + }); + + app.post("/api/backups/:id/restore", async (request) => { + const session = await requireRole(app, request, ["owner", "operator"]); + const { id } = request.params as { id: string }; + const accountId = accountIdFor(session); + const backup = await app.prisma.backup.findFirst({ where: { id, accountId } }); + if (!backup) throw notFound(`Backup with ID ${id} not found.`); + await audit( + app, + request, + accountId, + session.user.name, + "queued backup restore", + backup.name, + ); + return job(request, `Restore backup ${backup.name}`); + }); + + app.delete("/api/backups/:id", async (request) => { + const session = await requireRole(app, request, ["owner", "operator"]); + const { id } = request.params as { id: string }; + const accountId = accountIdFor(session); + const backup = await app.prisma.backup.findFirst({ where: { id, accountId } }); + if (!backup) throw notFound(`Backup with ID ${id} not found.`); + await app.prisma.backup.delete({ where: { id } }); + await audit(app, request, accountId, session.user.name, "deleted backup", backup.name); + return ok(request, { deleted: id }); + }); + + app.post("/api/automation", async (request, reply) => { + const session = await requireRole(app, request, ["owner", "operator"]); + const accountId = accountIdFor(session); + const body = parse(automationSchema, request.body); + const automation = await app.prisma.automation.create({ + data: { ...body, status: body.status as Status, accountId }, + }); + await audit( + app, + request, + accountId, + session.user.name, + "created automation", + automation.name, + ); + return reply.status(201).send( + ok(request, { + id: automation.id, + name: automation.name, + trigger: automation.trigger, + status: automation.status, + lastRun: iso(automation.lastRun), + successRate: automation.successRate, + }), + ); + }); + + app.put("/api/automation/:id", async (request) => { + const session = await requireRole(app, request, ["owner", "operator"]); + const { id } = request.params as { id: string }; + const accountId = accountIdFor(session); + const existing = await app.prisma.automation.findFirst({ where: { id, accountId } }); + if (!existing) throw notFound(`Automation with ID ${id} not found.`); + const body = parse(automationSchema.partial(), request.body); + const automation = await app.prisma.automation.update({ + where: { id }, + data: { ...body, status: body.status as Status | undefined }, + }); + await audit( + app, + request, + accountId, + session.user.name, + "updated automation", + automation.name, + ); + return ok(request, { + id: automation.id, + name: automation.name, + trigger: automation.trigger, + status: automation.status, + lastRun: iso(automation.lastRun), + successRate: automation.successRate, + }); + }); + + app.post("/api/automation/:id/run", async (request) => { + const session = await requireRole(app, request, ["owner", "operator"]); + const { id } = request.params as { id: string }; + const accountId = accountIdFor(session); + const automation = await app.prisma.automation.findFirst({ where: { id, accountId } }); + if (!automation) throw notFound(`Automation with ID ${id} not found.`); + await app.prisma.automation.update({ where: { id }, data: { lastRun: new Date() } }); + await audit(app, request, accountId, session.user.name, "ran automation", automation.name); + return job(request, `Run automation ${automation.name}`, "running"); + }); + + app.delete("/api/automation/:id", async (request) => { + const session = await requireRole(app, request, ["owner", "operator"]); + const { id } = request.params as { id: string }; + const accountId = accountIdFor(session); + const automation = await app.prisma.automation.findFirst({ where: { id, accountId } }); + if (!automation) throw notFound(`Automation with ID ${id} not found.`); + await app.prisma.automation.delete({ where: { id } }); + await audit( + app, + request, + accountId, + session.user.name, + "deleted automation", + automation.name, + ); + return ok(request, { deleted: id }); + }); + + app.post("/api/notifications", async (request, reply) => { + const session = await requireRole(app, request, ["owner", "operator"]); + const accountId = accountIdFor(session); + const body = parse(notificationCreateSchema, request.body); + const notification = await app.prisma.notification.create({ + data: { ...body, category: body.category as NotificationCategory, accountId }, + }); + return reply.status(201).send( + ok(request, { + id: notification.id, + title: notification.title, + category: notification.category, + read: notification.read, + createdAt: notification.createdAt.toISOString(), + body: notification.body, + }), + ); + }); + + app.post("/api/notifications/read-all", async (request) => { + const session = await requireSession(app, request); + const accountId = accountIdFor(session); + const result = await app.prisma.notification.updateMany({ + where: { accountId }, + data: { read: true }, + }); + return ok(request, { updated: result.count }); + }); + + app.put("/api/notifications/:id", async (request) => { + const session = await requireSession(app, request); + const { id } = request.params as { id: string }; + const accountId = accountIdFor(session); + const existing = await app.prisma.notification.findFirst({ where: { id, accountId } }); + if (!existing) throw notFound(`Notification with ID ${id} not found.`); + const body = parse(notificationCreateSchema.partial(), request.body); + const notification = await app.prisma.notification.update({ + where: { id }, + data: { ...body, category: body.category as NotificationCategory | undefined }, + }); + return ok(request, { + id: notification.id, + title: notification.title, + category: notification.category, + read: notification.read, + createdAt: notification.createdAt.toISOString(), + body: notification.body, + }); + }); + + app.delete("/api/notifications/:id", async (request) => { + const session = await requireSession(app, request); + const { id } = request.params as { id: string }; + const accountId = accountIdFor(session); + const existing = await app.prisma.notification.findFirst({ where: { id, accountId } }); + if (!existing) throw notFound(`Notification with ID ${id} not found.`); + await app.prisma.notification.delete({ where: { id } }); + return ok(request, { deleted: id }); + }); + + app.post("/api/security/findings", async (request, reply) => { + const session = await requireRole(app, request, ["owner", "operator"]); + const accountId = accountIdFor(session); + const body = parse(findingCreateSchema, request.body); + const finding = await app.prisma.securityFinding.create({ + data: { + ...body, + severity: body.severity as SecuritySeverity, + status: body.status as SecurityStatus, + accountId, + }, + }); + await audit( + app, + request, + accountId, + session.user.name, + "created security finding", + finding.title, + ); + return reply.status(201).send(ok(request, finding)); + }); + + app.put("/api/security/findings/:id", async (request) => { + const session = await requireRole(app, request, ["owner", "operator"]); + const { id } = request.params as { id: string }; + const accountId = accountIdFor(session); + const existing = await app.prisma.securityFinding.findFirst({ where: { id, accountId } }); + if (!existing) throw notFound(`Security finding with ID ${id} not found.`); + const body = parse(findingUpdateSchema, request.body); + const finding = await app.prisma.securityFinding.update({ + where: { id }, + data: { + ...body, + severity: body.severity as SecuritySeverity | undefined, + status: body.status as SecurityStatus | undefined, + }, + }); + await audit( + app, + request, + accountId, + session.user.name, + "updated security finding", + finding.title, + ); + return ok(request, finding); + }); + + app.post("/api/security/scan", async (request) => { + const session = await requireRole(app, request, ["owner", "operator"]); + await audit( + app, + request, + accountIdFor(session), + session.user.name, + "queued security scan", + "account", + ); + return job(request, "Run security scan", "running"); + }); + + app.get("/api/servers/:id", async (request) => { + await requireSession(app, request); + const { id } = request.params as { id: string }; + const server = await app.prisma.server.findUnique({ where: { id } }); + if (!server) throw notFound(`Server with ID ${id} not found.`); + return ok(request, serverDto(server)); + }); + + app.get("/api/servers/:id/processes", async (request) => { + await requireSession(app, request); + return ok(request, [ + { + pid: 1842, + user: "coaxys", + command: "php-fpm: pool example", + cpu: 2.4, + memory: 128_000_000, + }, + { pid: 2110, user: "node", command: "node server.js", cpu: 1.1, memory: 256_000_000 }, + { + pid: 2441, + user: "postgres", + command: "postgres: checkpointer", + cpu: 0.3, + memory: 96_000_000, + }, + ]); + }); + + app.get("/api/servers/:id/network", async (request) => { + await requireSession(app, request); + return ok(request, [ + { + name: "eth0", + address: "198.51.100.24", + rx: 892_000_000, + tx: 513_000_000, + status: "healthy", + }, + { name: "lo", address: "127.0.0.1", rx: 102_000_000, tx: 102_000_000, status: "healthy" }, + ]); + }); + + app.post("/api/servers/:id/actions", async (request) => { + const session = await requireRole(app, request, ["owner", "operator"]); + const { id } = request.params as { id: string }; + const body = parse( + z.object({ action: z.string().min(2), reason: z.string().min(3).optional() }), + request.body, + ); + const server = await app.prisma.server.findUnique({ where: { id } }); + if (!server) throw notFound(`Server with ID ${id} not found.`); + await audit( + app, + request, + accountIdFor(session), + session.user.name, + `queued server action ${body.action}`, + server.name, + ); + return job(request, `${body.action} ${server.name}`); + }); + + app.get("/api/users", async (request) => { + await requireRole(app, request, ["owner", "operator"]); + const users = await app.prisma.user.findMany({ + include: { permissions: true }, + orderBy: { name: "asc" }, + }); + return ok(request, users.map(userDto)); + }); + + app.post("/api/users", async (request, reply) => { + const session = await requireRole(app, request, ["owner", "operator"]); + const accountId = accountIdFor(session); + const body = parse(userCreateSchema, request.body); + const duplicate = await app.prisma.user.findUnique({ where: { email: body.email } }); + if (duplicate) + throw conflict("User email already exists.", { email: "Use a unique email address." }); + const initials = body.name + .split(/\s+/) + .filter(Boolean) + .map((part) => part[0]) + .join("") + .slice(0, 2) + .toUpperCase(); + const user = await app.prisma.user.create({ + data: { + name: body.name, + email: body.email, + passwordHash: body.password ? hashPassword(body.password) : undefined, + role: body.role ?? "developer", + avatarInitials: initials, + permissions: { + create: (body.permissions ?? ["dashboard_read", "tools_read"]).map((permission) => ({ + permission: permission as Permission, + })), + }, + accounts: { create: { accountId } }, + }, + include: { permissions: true }, + }); + await audit(app, request, accountId, session.user.name, "created user", user.email); + return reply.status(201).send(ok(request, userDto(user))); + }); + + app.put("/api/users/:id", async (request) => { + const session = await requireRole(app, request, ["owner", "operator"]); + const { id } = request.params as { id: string }; + const accountId = accountIdFor(session); + const existing = await app.prisma.user.findFirst({ + where: { id, accounts: { some: { accountId } } }, + }); + if (!existing) throw notFound(`User with ID ${id} not found.`); + const body = parse(userUpdateSchema, request.body); + const user = await app.prisma.$transaction(async (tx) => { + if (body.permissions) { + await tx.userPermission.deleteMany({ where: { userId: id } }); + await tx.userPermission.createMany({ + data: body.permissions.map((permission) => ({ + userId: id, + permission: permission as Permission, + })), + }); + } + return tx.user.update({ + where: { id }, + data: { + name: body.name, + role: body.role, + twoFactorEnabled: body.twoFactorEnabled, + }, + include: { permissions: true }, + }); + }); + await audit(app, request, accountId, session.user.name, "updated user", user.email); + return ok(request, userDto(user)); + }); + + app.delete("/api/users/:id", async (request) => { + const session = await requireRole(app, request, ["owner"]); + const { id } = request.params as { id: string }; + const accountId = accountIdFor(session); + if (id === session.userId) + throw validationError("You cannot delete your own active user.", { + id: "Choose another user.", + }); + const existing = await app.prisma.user.findFirst({ + where: { id, accounts: { some: { accountId } } }, + }); + if (!existing) throw notFound(`User with ID ${id} not found.`); + await app.prisma.user.delete({ where: { id } }); + await audit(app, request, accountId, session.user.name, "deleted user", existing.email); + return ok(request, { deleted: id }); + }); + + app.get("/api/roles", async (request) => { + await requireRole(app, request, ["owner", "operator"]); + return ok(request, [ + { + id: "owner", + name: "Hosting account owner", + permissions: permissionValues.map(toPermission), + }, + { + id: "developer", + name: "Developer", + permissions: [ + "dashboard:read", + "tools:read", + "websites:manage", + "domains:manage", + "files:manage", + "databases:manage", + ], + }, + { + id: "auditor", + name: "Read-only auditor", + permissions: ["dashboard:read", "tools:read", "metrics:read", "audit:read"], + }, + ]); + }); + + app.get("/api/resellers", async (request) => { + await requireRole(app, request, ["owner", "operator"]); + return ok(request, [ + { id: "reseller-1", name: "North Region Reseller", accounts: 18, status: "healthy" }, + { id: "reseller-2", name: "Managed Agency Partner", accounts: 7, status: "warning" }, + ]); + }); + + app.get("/api/packages", async (request) => { + await requireRole(app, request, ["owner", "operator"]); + return ok(request, [ + { + id: "pkg-starter", + name: "Starter", + disk: "25 GB", + bandwidth: "250 GB", + databases: 5, + mailboxes: 25, + }, + { + id: "pkg-business", + name: "Business", + disk: "100 GB", + bandwidth: "1 TB", + databases: 25, + mailboxes: 250, + }, + ]); + }); + + app.get("/api/settings", async (request) => { + const session = await requireSession(app, request); + const account = await app.prisma.hostingAccount.findUniqueOrThrow({ + where: { id: accountIdFor(session) }, + }); + return ok(request, { + branding: { productName: "CoaxysPanel", logoMode: "default" }, + theme: account.theme, + locale: "en-US", + timezone: "Etc/UTC", + sessionTimeout: 45, + securityDefaults: { twoFactorRequired: false, passwordMinLength: 12 }, + }); + }); + + app.put("/api/settings", async (request) => { + const session = await requireRole(app, request, ["owner", "operator"]); + const body = parse(settingsSchema, request.body); + const account = await app.prisma.hostingAccount.update({ + where: { id: accountIdFor(session) }, + data: { theme: body.theme }, + }); + await audit( + app, + request, + account.id, + session.user.name, + "updated settings", + "account settings", + ); + return ok(request, { + branding: { productName: "CoaxysPanel", logoMode: "default" }, + theme: account.theme, + locale: body.locale ?? "en-US", + timezone: body.timezone ?? "Etc/UTC", + sessionTimeout: body.sessionTimeout ?? 45, + }); + }); + + app.get("/api/support/tickets", async (request) => { + await requireSession(app, request); + return ok(request, [ + { + id: "ticket-1", + subject: "SSL renewal question", + status: "open", + priority: "normal", + updatedAt: new Date().toISOString(), + }, + { + id: "ticket-2", + subject: "Migration planning", + status: "waiting", + priority: "high", + updatedAt: new Date().toISOString(), + }, + ]); + }); + + app.post("/api/support/tickets", async (request) => { + const session = await requireSession(app, request); + const body = parse( + z.object({ + subject: z.string().min(3), + body: z.string().min(3), + priority: z.string().default("normal"), + }), + request.body, + ); + await audit( + app, + request, + accountIdFor(session), + session.user.name, + "created support ticket", + body.subject, + ); + return ok(request, { + id: `ticket-${Date.now()}`, + subject: body.subject, + status: "open", + priority: body.priority, + updatedAt: new Date().toISOString(), + }); + }); +};