From 09d8948f7159bfd351a7848892b3ad264367e8c5 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Mon, 23 Feb 2026 12:25:13 +0100 Subject: [PATCH 1/2] fix: Consolidate imports from server_settings_service for cleaner code --- bot/src/commands/music_controls.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/src/commands/music_controls.py b/bot/src/commands/music_controls.py index eb9ba37..514155d 100644 --- a/bot/src/commands/music_controls.py +++ b/bot/src/commands/music_controls.py @@ -13,8 +13,7 @@ from discord.ext import commands from src.services.lavalink_service import LavalinkVoiceClient -from src.services.server_settings_service import QueueCapacity -from src.services.server_settings import ServerSettingsService +from src.services.server_settings_service import QueueCapacity, ServerSettingsService from src.utils.embeds import EmbedFactory from src.utils.time import ms_to_clock from lavalink.errors import ClientError From 8379d5eb75f9b6b7ca96cd09e15a9f2260c05733 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Mon, 23 Feb 2026 13:46:27 +0100 Subject: [PATCH 2/2] feat: add admin compliance, enterprise, federation, and metrics management components - Implemented API routes for compliance export requests, enterprise settings, and federation instances. - Created admin components for managing compliance logs, enterprise configurations, and federation instances. - Added blog view tracking functionality to increment view counts on blog posts. - Enhanced error handling and loading states in the UI components. --- bot/src/configs/schema.py | 1 + bot/src/services/status_api_service.py | 2 + frontend/app/account/page.tsx | 8 +- frontend/app/api/account/export/route.ts | 68 +- frontend/app/api/admin/bot-control/route.ts | 1 + .../admin/compliance/export-requests/route.ts | 42 + frontend/app/api/admin/connectivity/route.ts | 42 +- frontend/app/api/admin/enterprise/route.ts | 105 ++ frontend/app/api/admin/federation/route.ts | 64 + .../app/api/admin/metrics/health/route.ts | 42 + frontend/app/api/blog/[id]/view/route.ts | 29 + frontend/app/api/verify-session/route.ts | 18 +- frontend/app/blog/[slug]/page.tsx | 2 + frontend/app/control-panel/admin/page.tsx | 102 +- frontend/app/control-panel/page.tsx | 4 +- .../components/admin-compliance-manager.tsx | 81 ++ .../components/admin-enterprise-manager.tsx | 307 +++++ .../components/admin-federation-manager.tsx | 122 ++ frontend/components/blog-view-tracker.tsx | 23 + frontend/components/discord-widget.tsx | 17 +- frontend/hooks/useAccountApi.ts | 44 +- frontend/lib/api-client.ts | 6 +- frontend/lib/bot-status.ts | 4 +- frontend/lib/db.ts | 134 +- frontend/test-report/report.html | 1080 ----------------- frontend/test-report/results.json | 774 ------------ 26 files changed, 1153 insertions(+), 1969 deletions(-) create mode 100644 frontend/app/api/admin/compliance/export-requests/route.ts create mode 100644 frontend/app/api/admin/enterprise/route.ts create mode 100644 frontend/app/api/admin/federation/route.ts create mode 100644 frontend/app/api/admin/metrics/health/route.ts create mode 100644 frontend/app/api/blog/[id]/view/route.ts create mode 100644 frontend/components/admin-compliance-manager.tsx create mode 100644 frontend/components/admin-enterprise-manager.tsx create mode 100644 frontend/components/admin-federation-manager.tsx create mode 100644 frontend/components/blog-view-tracker.tsx delete mode 100644 frontend/test-report/report.html delete mode 100644 frontend/test-report/results.json diff --git a/bot/src/configs/schema.py b/bot/src/configs/schema.py index d0888cf..eb0f528 100644 --- a/bot/src/configs/schema.py +++ b/bot/src/configs/schema.py @@ -128,6 +128,7 @@ class StatusAPIConfig(BaseModel): usage_token: Optional[str] = None control_start_cmd: Optional[str] = None control_stop_cmd: Optional[str] = None + control_restart_cmd: Optional[str] = None control_reload_cmd: Optional[str] = None control_reload_commands_cmd: Optional[str] = None control_restart_frontend_cmd: Optional[str] = None diff --git a/bot/src/services/status_api_service.py b/bot/src/services/status_api_service.py index fa0b9a1..1df0f6d 100644 --- a/bot/src/services/status_api_service.py +++ b/bot/src/services/status_api_service.py @@ -807,6 +807,8 @@ async def _handle_control_action(self, request: web.Request) -> web.Response: await self._run_or_fallback(self.config.control_restart_frontend_cmd, self._noop) elif action in {"start", "start-bot", "start_bot"}: await self._run_or_fallback(self.config.control_start_cmd, self._noop) + elif action in {"restart", "restart-bot", "restart_bot"}: + await self._run_or_fallback(self.config.control_restart_cmd, self._noop) elif action in {"stop", "stop-bot", "stop_bot"}: await self._run_or_fallback(self.config.control_stop_cmd, self._noop) elif action in {"hot-patch", "hot_patch"}: diff --git a/frontend/app/account/page.tsx b/frontend/app/account/page.tsx index f16ecd3..a1c1f95 100644 --- a/frontend/app/account/page.tsx +++ b/frontend/app/account/page.tsx @@ -159,7 +159,7 @@ export default function AccountPage() { const url = URL.createObjectURL(data) const a = document.createElement('a') a.href = url - a.download = `account-data-${state.discordId}.json` + a.download = `vectobeat-data-${state.discordId}.pdf` a.click() URL.revokeObjectURL(url) setMessageState('dataMessage', 'Data exported successfully') @@ -358,15 +358,15 @@ export default function AccountPage() {
-

Export Data

-

Download a copy of your account data

+

Export Data (Compliance Mode)

+

Download a consolidated PDF report of your account data for GDPR compliance

- Export Data + Request Data Export
diff --git a/frontend/app/api/account/export/route.ts b/frontend/app/api/account/export/route.ts index 4124d4c..06dcf58 100644 --- a/frontend/app/api/account/export/route.ts +++ b/frontend/app/api/account/export/route.ts @@ -1,7 +1,7 @@ import { type NextRequest, NextResponse } from "next/server" import { PDFDocument } from "pdf-lib" import path from "path" -import { getFullUserData, getStoredUserProfile } from "@/lib/db" +import { getFullUserData, getStoredUserProfile, getPool } from "@/lib/db" import { ensureStripeCustomerForUser } from "@/lib/stripe-customers" import { verifyRequestForUser } from "@/lib/auth" import { PdfGenerator } from "@/lib/pdf-generator" @@ -44,10 +44,10 @@ export async function GET(request: NextRequest) { // --- Generate PDF --- const doc = await PDFDocument.create() - + // Attempt to resolve logo path const logoPath = path.join(process.cwd(), "public", "logo.png") - + const gen = new PdfGenerator(doc, logoPath) await gen.init() @@ -77,13 +77,13 @@ export async function GET(request: NextRequest) { // 2. Contact Information gen.drawSectionTitle("Contact Information") - + const email = storedProfile?.email ?? userData.contact?.email ?? "-" const phone = storedProfile?.phone ?? userData.contact?.phone ?? "-" - - let stripeId = userData.contact?.stripeCustomerId ?? - userData.subscriptions?.find((s: any) => s.stripeCustomerId)?.stripeCustomerId ?? - "-" + + let stripeId = userData.contact?.stripeCustomerId ?? + userData.subscriptions?.find((s: any) => s.stripeCustomerId)?.stripeCustomerId ?? + "-" // Attempt to resolve Stripe ID if missing but we have an email if ((stripeId === "-" || !stripeId) && email !== "-") { @@ -110,7 +110,7 @@ export async function GET(request: NextRequest) { gen.drawSectionTitle("Address") const street = [userData.preferences.addressStreet, userData.preferences.addressHouseNumber].filter(Boolean).join(" ") const city = [userData.preferences.addressPostalCode, userData.preferences.addressCity].filter(Boolean).join(" ") - + gen.drawKeyValue("Street", street) gen.drawKeyValue("City", city) gen.drawKeyValue("State", userData.preferences.addressState) @@ -125,10 +125,10 @@ export async function GET(request: NextRequest) { gen.drawKeyValue("Headline", userData.settings.headline) gen.drawKeyValue("Location", userData.settings.location) gen.drawKeyValue("Website", userData.settings.website) - + if (userData.settings.bio) { - gen.checkPageBreak(40) - gen.drawKeyValue("Bio", userData.settings.bio.substring(0, 100) + (userData.settings.bio.length > 100 ? "..." : "")) + gen.checkPageBreak(40) + gen.drawKeyValue("Bio", userData.settings.bio.substring(0, 100) + (userData.settings.bio.length > 100 ? "..." : "")) } } @@ -168,25 +168,25 @@ export async function GET(request: NextRequest) { gen.drawKeyValue("Analytics Opt-In", userData.privacy?.analyticsOptIn ? "Yes" : "No") if (userData.userBackupCodes && userData.userBackupCodes.length > 0) { - gen.checkPageBreak(40) - - const rows = userData.userBackupCodes.map(code => [ - "****" + code.id.slice(-4), // Mask ID - formatDate(code.createdAt), - code.usedAt ? formatDate(code.usedAt) : "Unused" - ]) - gen.drawTable(["Backup Code (Masked)", "Created", "Status"], rows, [150, 150, 150]) + gen.checkPageBreak(40) + + const rows = userData.userBackupCodes.map(code => [ + "****" + code.id.slice(-4), // Mask ID + formatDate(code.createdAt), + code.usedAt ? formatDate(code.usedAt) : "Unused" + ]) + gen.drawTable(["Backup Code (Masked)", "Created", "Status"], rows, [150, 150, 150]) } // 6b. Password History if (userData.passwordHistory && userData.passwordHistory.length > 0) { - gen.checkPageBreak(40) - gen.drawSectionTitle("Password History") - const rows = userData.passwordHistory.map((h: any) => [ - formatDate(h.createdAt), - h.password - ]) - gen.drawTable(["Date", "Password (Hash/Encrypted)"], rows, [150, 300]) + gen.checkPageBreak(40) + gen.drawSectionTitle("Password History") + const rows = userData.passwordHistory.map((h: any) => [ + formatDate(h.createdAt), + h.password + ]) + gen.drawTable(["Date", "Password (Hash/Encrypted)"], rows, [150, 300]) } // 7. Preferences @@ -304,6 +304,20 @@ export async function GET(request: NextRequest) { const pdfBytes = await doc.save() + const db = getPool() + if (db) { + try { + await db.dataExportRequest.create({ + data: { + discordId, + status: "completed", + } + }) + } catch (err) { + console.error("[VectoBeat] Failed to log compliance data export request:", err) + } + } + return new NextResponse(Buffer.from(pdfBytes), { headers: { "Content-Type": "application/pdf", diff --git a/frontend/app/api/admin/bot-control/route.ts b/frontend/app/api/admin/bot-control/route.ts index 9761d8d..1ef5abf 100644 --- a/frontend/app/api/admin/bot-control/route.ts +++ b/frontend/app/api/admin/bot-control/route.ts @@ -11,6 +11,7 @@ const ACTIONS: Record = { reload_commands: "reload-commands", reload_config: "reload-config", restart_frontend: "restart-frontend", + hot_patch: "hot-patch", } export async function POST(request: NextRequest) { diff --git a/frontend/app/api/admin/compliance/export-requests/route.ts b/frontend/app/api/admin/compliance/export-requests/route.ts new file mode 100644 index 0000000..6564c7b --- /dev/null +++ b/frontend/app/api/admin/compliance/export-requests/route.ts @@ -0,0 +1,42 @@ +import { NextResponse, type NextRequest } from "next/server" +import { cookies } from "next/headers" +import { verifyRequestForUser } from "@/lib/auth" +import { getPrismaClient, getUserRole } from "@/lib/db" + +const checkAdmin = async (req: NextRequest) => { + const cookieStore = await cookies() + const discordId = + cookieStore.get("discord_user_id")?.value || + cookieStore.get("discord_id")?.value || + cookieStore.get("discordId")?.value || + req.nextUrl.searchParams.get("discordId") + + if (!discordId) return false + + const verification = await verifyRequestForUser(req, discordId) + if (!verification.valid) return false + + const role = await getUserRole(discordId) + return role === "admin" || role === "operator" +} + +export async function GET(req: NextRequest) { + if (!await checkAdmin(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + try { + const prisma = getPrismaClient() + if (!prisma) return NextResponse.json({ error: "Database unavailable" }, { status: 500 }) + + const requests = await prisma.dataExportRequest.findMany({ + orderBy: { createdAt: "desc" }, + take: 200, + }) + + return NextResponse.json(requests) + } catch (error) { + console.error("Get Data Export Requests Error:", error) + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }) + } +} diff --git a/frontend/app/api/admin/connectivity/route.ts b/frontend/app/api/admin/connectivity/route.ts index a83e931..01741d5 100644 --- a/frontend/app/api/admin/connectivity/route.ts +++ b/frontend/app/api/admin/connectivity/route.ts @@ -33,15 +33,15 @@ const BOT_ENV_KEYS = new Set([ const getEnvCandidates = (target: "frontend" | "bot") => target === "bot" ? [ - path.resolve(process.cwd(), "../bot/.env"), - path.resolve(process.cwd(), "../bot/.env.development"), - path.resolve(process.cwd(), "../bot/.env.local"), - ] + path.resolve(process.cwd(), "../bot/.env"), + path.resolve(process.cwd(), "../bot/.env.development"), + path.resolve(process.cwd(), "../bot/.env.local"), + ] : [ - path.resolve(process.cwd(), ".env"), - path.resolve(process.cwd(), ".env.development"), - path.resolve(process.cwd(), ".env.local"), - ] + path.resolve(process.cwd(), ".env"), + path.resolve(process.cwd(), ".env.development"), + path.resolve(process.cwd(), ".env.local"), + ] const readEnvFile = async (target: "frontend" | "bot") => { const candidates = getEnvCandidates(target) @@ -56,16 +56,24 @@ const readEnvFile = async (target: "frontend" | "bot") => { return {} } -const fetchWithTimeout = async (url: string) => { +const fetchWithTimeout = async (url: string, token: string | null = null) => { if (!/^https?:\/\//i.test(url)) { return true } const controller = new AbortController() const id = setTimeout(() => controller.abort(), 3000) try { - await apiClient(url, { signal: controller.signal, cache: "no-store" }) + const headers: HeadersInit = {} + if (token) { + headers["Authorization"] = `Bearer ${token}` + } + await apiClient(url, { signal: controller.signal, headers, cache: "no-store" }) return true - } catch { + } catch (error: any) { + // If it's an API error returning 401/403/404, the server is online but rejected the ping/auth + if (error?.name === "ApiError" || error?.status === 401 || error?.status === 403 || error?.status === 404) { + return true + } return false } finally { clearTimeout(id) @@ -94,7 +102,17 @@ export async function GET(request: NextRequest) { const fromBot = BOT_ENV_KEYS.has(service.envKey) const envVal = fromBot ? botEnv[service.envKey] || process.env[service.envKey] : frontendEnv[service.envKey] || process.env[service.envKey] if (!envVal) return { label: service.label, key: service.key, url: null, status: "missing" } - const online = await fetchWithTimeout(envVal) + + // Determine authenticaton header key if applicable + let authKey = null + if (service.key === "status_api") authKey = "BOT_STATUS_API_KEY" + else if (service.key === "server_settings") authKey = "SERVER_SETTINGS_API_KEY" + else if (service.key === "queue_sync") authKey = "QUEUE_SYNC_API_KEY" + else if (service.key === "telemetry") authKey = "QUEUE_TELEMETRY_API_KEY" + + const envAuth = authKey ? (fromBot ? botEnv[authKey] || process.env[authKey] : frontendEnv[authKey] || process.env[authKey]) : null + + const online = await fetchWithTimeout(envVal, envAuth || null) return { label: service.label, key: service.key, url: envVal, status: online ? "online" : "offline" } }), ) diff --git a/frontend/app/api/admin/enterprise/route.ts b/frontend/app/api/admin/enterprise/route.ts new file mode 100644 index 0000000..dfc4345 --- /dev/null +++ b/frontend/app/api/admin/enterprise/route.ts @@ -0,0 +1,105 @@ +import { NextResponse, type NextRequest } from "next/server" +import { cookies } from "next/headers" +import { verifyRequestForUser } from "@/lib/auth" +import { getPrismaClient, getUserRole } from "@/lib/db" + +const checkAdmin = async (req: NextRequest) => { + const cookieStore = await cookies() + const discordId = + cookieStore.get("discord_user_id")?.value || + cookieStore.get("discord_id")?.value || + cookieStore.get("discordId")?.value || + req.nextUrl.searchParams.get("discordId") + + if (!discordId) return false + + const verification = await verifyRequestForUser(req, discordId) + if (!verification.valid) return false + + const role = await getUserRole(discordId) + return role === "admin" || role === "operator" +} + +export async function GET(req: NextRequest) { + if (!await checkAdmin(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + try { + const prisma = getPrismaClient() + if (!prisma) return NextResponse.json({ error: "Database unavailable" }, { status: 500 }) + + const settings = await prisma.enterpriseSetting.findMany({ + orderBy: { createdAt: "desc" }, + take: 200, + }) + + return NextResponse.json(settings) + } catch (error) { + console.error("Get Enterprise Settings Error:", error) + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }) + } +} + +export async function POST(req: NextRequest) { + if (!await checkAdmin(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + try { + const body = await req.json() + const { guildId, domain, ssoEnabled, ssoProvider, ssoConfig, branding } = body + + if (!guildId) return NextResponse.json({ error: "guildId is required" }, { status: 400 }) + + const prisma = getPrismaClient() + if (!prisma) return NextResponse.json({ error: "Database unavailable" }, { status: 500 }) + + const setting = await prisma.enterpriseSetting.upsert({ + where: { guildId }, + update: { + domain, + ssoEnabled: ssoEnabled ?? false, + ssoProvider, + ssoConfig: ssoConfig ?? undefined, + branding: branding ?? undefined, + }, + create: { + guildId, + domain, + ssoEnabled: ssoEnabled ?? false, + ssoProvider, + ssoConfig: ssoConfig ?? undefined, + branding: branding ?? undefined, + } + }) + + return NextResponse.json(setting) + } catch (error) { + console.error("Upsert Enterprise Settings Error:", error) + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }) + } +} + +export async function DELETE(req: NextRequest) { + if (!await checkAdmin(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + try { + const id = req.nextUrl.searchParams.get("id") + if (!id) return NextResponse.json({ error: "ID required" }, { status: 400 }) + + const prisma = getPrismaClient() + if (!prisma) return NextResponse.json({ error: "Database unavailable" }, { status: 500 }) + + await prisma.enterpriseSetting.delete({ + where: { id } + }) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Delete Enterprise Settings Error:", error) + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }) + } +} diff --git a/frontend/app/api/admin/federation/route.ts b/frontend/app/api/admin/federation/route.ts new file mode 100644 index 0000000..04a19af --- /dev/null +++ b/frontend/app/api/admin/federation/route.ts @@ -0,0 +1,64 @@ +import { NextResponse, type NextRequest } from "next/server" +import { cookies } from "next/headers" +import { verifyRequestForUser } from "@/lib/auth" +import { getPrismaClient, getUserRole } from "@/lib/db" + +const checkAdmin = async (req: NextRequest) => { + const cookieStore = await cookies() + const discordId = + cookieStore.get("discord_user_id")?.value || + cookieStore.get("discord_id")?.value || + cookieStore.get("discordId")?.value || + req.nextUrl.searchParams.get("discordId") + + if (!discordId) return false + + const verification = await verifyRequestForUser(req, discordId) + if (!verification.valid) return false + + const role = await getUserRole(discordId) + return role === "admin" || role === "operator" +} + +export async function GET(req: NextRequest) { + if (!await checkAdmin(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + try { + const prisma = getPrismaClient() + if (!prisma) return NextResponse.json({ error: "Database unavailable" }, { status: 500 }) + + const instances = await prisma.botInstance.findMany({ + orderBy: { lastHeartbeat: "desc" }, + }) + + return NextResponse.json(instances) + } catch (error) { + console.error("Get Federation Instances Error:", error) + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }) + } +} + +export async function DELETE(req: NextRequest) { + if (!await checkAdmin(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + try { + const id = req.nextUrl.searchParams.get("id") + if (!id) return NextResponse.json({ error: "ID required" }, { status: 400 }) + + const prisma = getPrismaClient() + if (!prisma) return NextResponse.json({ error: "Database unavailable" }, { status: 500 }) + + await prisma.botInstance.delete({ + where: { id } + }) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Delete Federation Instance Error:", error) + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }) + } +} diff --git a/frontend/app/api/admin/metrics/health/route.ts b/frontend/app/api/admin/metrics/health/route.ts new file mode 100644 index 0000000..9de7cf3 --- /dev/null +++ b/frontend/app/api/admin/metrics/health/route.ts @@ -0,0 +1,42 @@ +import { NextResponse, type NextRequest } from "next/server" +import { cookies } from "next/headers" +import { verifyRequestForUser } from "@/lib/auth" +import { getPrismaClient, getUserRole } from "@/lib/db" + +const checkAdmin = async (req: NextRequest) => { + const cookieStore = await cookies() + const discordId = + cookieStore.get("discord_user_id")?.value || + cookieStore.get("discord_id")?.value || + cookieStore.get("discordId")?.value || + req.nextUrl.searchParams.get("discordId") + + if (!discordId) return false + + const verification = await verifyRequestForUser(req, discordId) + if (!verification.valid) return false + + const role = await getUserRole(discordId) + return role === "admin" || role === "operator" +} + +export async function GET(req: NextRequest) { + if (!await checkAdmin(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + try { + const prisma = getPrismaClient() + if (!prisma) return NextResponse.json({ error: "Database unavailable" }, { status: 500 }) + + const snapshots = await prisma.botMetricSnapshot.findMany({ + orderBy: { recordedAt: "asc" }, + take: 100, + }) + + return NextResponse.json(snapshots) + } catch (error) { + console.error("Get Health Metrics Error:", error) + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }) + } +} diff --git a/frontend/app/api/blog/[id]/view/route.ts b/frontend/app/api/blog/[id]/view/route.ts new file mode 100644 index 0000000..5322de6 --- /dev/null +++ b/frontend/app/api/blog/[id]/view/route.ts @@ -0,0 +1,29 @@ +import { type NextRequest, NextResponse } from "next/server" +import { incrementBlogPostView } from "@/lib/db" + +type RouteParams = { params: Promise<{ id: string }> } + +const sanitizeIdentifier = (value: string) => { + try { + return decodeURIComponent(value).trim() + } catch { + return value.trim() + } +} + +export async function POST(_request: NextRequest, { params }: RouteParams) { + const { id } = await params + const identifier = sanitizeIdentifier(id) + + if (!identifier) { + return NextResponse.json({ error: "missing identifier" }, { status: 400 }) + } + + try { + await incrementBlogPostView(identifier) + return NextResponse.json({ success: true }) + } catch (error) { + console.error("[Blog View Tracker] Failed to increment view:", error) + return NextResponse.json({ error: "internal_error" }, { status: 500 }) + } +} diff --git a/frontend/app/api/verify-session/route.ts b/frontend/app/api/verify-session/route.ts index 1911b64..85b9373 100644 --- a/frontend/app/api/verify-session/route.ts +++ b/frontend/app/api/verify-session/route.ts @@ -1,7 +1,9 @@ import { NextResponse, type NextRequest } from "next/server" import { cookies } from "next/headers" +import { extractBearerToken } from "@/lib/auth" import { verifyRequestForUser } from "@/lib/auth" -import { getUserSubscriptions, getUserRole, getUserSecurity, type SubscriptionSummary } from "@/lib/db" +import { hashSessionToken } from "@/lib/session" +import { getUserSubscriptions, getUserRole, getUserSecurity, getSessionByHash, type SubscriptionSummary } from "@/lib/db" import { normalizeTierId } from "@/lib/memberships" import { apiClient } from "@/lib/api-client" @@ -12,7 +14,17 @@ const resolveDiscordId = async (request: NextRequest) => { cookieStore.get("discord_id")?.value || cookieStore.get("discordId")?.value const queryId = request.nextUrl.searchParams.get("discordId") - return cookieId || queryId + if (cookieId || queryId) { + return cookieId || queryId + } + + const token = extractBearerToken(request) + if (token) { + const sessionHash = hashSessionToken(token) + return await getSessionByHash(sessionHash) + } + + return null } export async function GET(request: NextRequest) { @@ -93,7 +105,7 @@ const resolveGuilds = async (verification: any): Promise => { ...guild, // We do not preserve prior.hasBot because it might be stale or incorrect (e.g. defaulted to true). // The frontend will determine actual bot presence via the bot status API and subscription data. - hasBot: false, + hasBot: false, } }) return fresh diff --git a/frontend/app/blog/[slug]/page.tsx b/frontend/app/blog/[slug]/page.tsx index 1772789..2d4aaa9 100644 --- a/frontend/app/blog/[slug]/page.tsx +++ b/frontend/app/blog/[slug]/page.tsx @@ -8,6 +8,7 @@ import { sanitizeSlug, resolveParams } from "@/lib/utils" import Navigation from "@/components/navigation" import Footer from "@/components/footer" import DOMPurify from "isomorphic-dompurify" +import { BlogViewTracker } from "@/components/blog-view-tracker" type BlogPageParams = { params: Promise<{ slug: string }> | { slug: string } } @@ -48,6 +49,7 @@ export default async function BlogPostPage({ params }: BlogPageParams) { return (
+
diff --git a/frontend/app/control-panel/admin/page.tsx b/frontend/app/control-panel/admin/page.tsx index a10f0b6..d7710cc 100644 --- a/frontend/app/control-panel/admin/page.tsx +++ b/frontend/app/control-panel/admin/page.tsx @@ -8,7 +8,11 @@ import Image from "next/image" import type { UserRole } from "@/lib/db" import appPackage from "../../../package.json" import { AdminPluginManager } from "@/components/admin-plugin-manager" +import { AdminComplianceManager } from "@/components/admin-compliance-manager" +import { AdminEnterpriseManager } from "@/components/admin-enterprise-manager" +import { AdminFederationManager } from "@/components/admin-federation-manager" import { apiClient } from "@/lib/api-client" +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts" interface BlogPost { id: string @@ -38,6 +42,9 @@ type AdminTabKey = | "apiKeys" | "plugins" | "forum" + | "compliance" + | "enterprise" + | "federation" type NewsletterSubscriber = { email: string @@ -186,6 +193,7 @@ type AdminEnvEntry = { const BOT_ACTIONS = [ { key: "start", label: "Start bot", description: "Start the bot service if it is stopped." }, { key: "restart", label: "Restart bot", description: "Restart the bot process across all shards." }, + { key: "hot_patch", label: "Hot patch", description: "Zero-downtime patch: reload config + extensions without restart." }, { key: "reload", label: "Reload bot", description: "Reload configuration and caches without a full restart." }, { key: "reload_commands", label: "Reload commands", description: "Refresh slash commands and permissions." }, { key: "stop", label: "Stop bot", description: "Gracefully stop the bot service." }, @@ -197,6 +205,7 @@ const ADMIN_TABS: Array<{ key: AdminTabKey; label: string; description: string } { key: "blog", label: "Blogs", description: "Publish news and manage posts" }, { key: "newsletter", label: "Newsletters", description: "Send announcements to subscribers" }, { key: "ticket", label: "Tickets", description: "Respond to support tickets and attachments" }, + { key: "compliance", label: "Compliance", description: "Trust & Safety export logs, GDPR, and data residency" }, { key: "contacts", label: "Contacts", description: "Inbound contact form submissions and replies" }, { key: "forum", label: "Forum", description: "Manage forum categories, threads, and telemetry" }, { key: "subscriptions", label: "Subscriptions", description: "Oversee guild plans and entitlements" }, @@ -207,6 +216,8 @@ const ADMIN_TABS: Array<{ key: AdminTabKey; label: string; description: string } { key: "botControl", label: "Bot Controls", description: "Manage bot lifecycle actions and deploys" }, { key: "system", label: "System", description: "Runtime health, endpoints, and service versions" }, { key: "logs", label: "Logs", description: "Recent admin and bot activity for audit" }, + { key: "enterprise", label: "Enterprise", description: "Manage SSO, White-label & Custom Domains" }, + { key: "federation", label: "Federation", description: "Monitor Bot instances and shards across regions" }, ] const initialForm = { @@ -217,6 +228,7 @@ const initialForm = { author: "", category: "Announcement", image: "", + publishedAt: "", } function stripMarkdown(content: string): string { @@ -420,6 +432,8 @@ export default function AdminControlPanelPage() { const [confirmModal, setConfirmModal] = useState<{ action: string; label: string } | null>(null) const [systemHealth, setSystemHealth] = useState(null) const [systemHealthError, setSystemHealthError] = useState(null) + const [healthMetrics, setHealthMetrics] = useState([]) + const [healthMetricsLoading, setHealthMetricsLoading] = useState(false) const [systemEndpointSearch, setSystemEndpointSearch] = useState("") const [logEvents, setLogEvents] = useState([]) const [logsLoading, setLogsLoading] = useState(false) @@ -827,6 +841,23 @@ export default function AdminControlPanelPage() { } }, []) + const loadHealthMetrics = useCallback(async () => { + if (!discordId) return + setHealthMetricsLoading(true) + try { + const headers: HeadersInit = authToken ? { Authorization: `Bearer ${authToken}` } : {} + const payload = await apiClient(`/api/admin/metrics/health?discordId=${discordId}`, { + headers, + credentials: "include", + }) + setHealthMetrics(Array.isArray(payload) ? payload : []) + } catch (error) { + console.error("Failed to load health metrics array:", error) + } finally { + setHealthMetricsLoading(false) + } + }, [authToken, discordId]) + const loadLogs = useCallback(async () => { if (!discordId) return setLogsLoading(true) @@ -967,6 +998,7 @@ export default function AdminControlPanelPage() { loadSystemKeys() loadEnvEntries() loadSystemHealth() + loadHealthMetrics() loadLogs() loadRuntimeInfo() loadConnectivity() @@ -984,6 +1016,7 @@ export default function AdminControlPanelPage() { loadSystemKeys, loadEnvEntries, loadSystemHealth, + loadHealthMetrics, loadLogs, loadRuntimeInfo, loadConnectivity, @@ -2419,11 +2452,20 @@ export default function AdminControlPanelPage() { const configChecks = [ { label: "Database URL", key: "DATABASE_URL" }, { label: "Bot status API", key: "BOT_STATUS_API_URL" }, - { label: "Server settings API", key: "SERVER_SETTINGS_API_URL", alt: systemEndpoints.serverSettings }, - { label: "Queue sync secret", key: "QUEUE_SYNC_API_KEY", alt: process.env.QUEUE_SYNC_SECRET }, - { label: "Log ingest token", key: "LOG_INGEST_TOKEN" }, + { label: "Server settings API", key: "SERVER_SETTINGS_API_URL", searchKey: "server_settings" }, + { label: "Queue sync secret", key: "QUEUE_SYNC_API_KEY", searchKey: "queue_sync" }, + { label: "Log ingest token", key: "LOG_INGEST_TOKEN", searchKey: "telemetry" }, ].map((item) => { - const value = envMap[item.key] || item.alt || process.env[item.key] + // Check systemKeys state first if it applies to an API token + const systemKeyRecord = item.searchKey ? systemKeys.find((k) => k.id === item.searchKey) : null + if (systemKeyRecord) { + return { + ...item, + configured: systemKeyRecord.configured, + } + } + // Otherwise fall back to local environment and proxy alt overrides + const value = envMap[item.key] || process.env[item.key] return { ...item, configured: Boolean(value), @@ -3817,6 +3859,15 @@ export default function AdminControlPanelPage() {
) } + case "compliance": { + return + } + case "enterprise": { + return + } + case "federation": { + return + } case "overview": default: { const overviewHighlights: MetricStat[] = [ @@ -3957,6 +4008,38 @@ export default function AdminControlPanelPage() { + {healthMetrics.length > 0 && ( +
+
+

Predictive Health Scoring

+ + Live telemetry across shards + +
+
+ + + + new Date(val).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + stroke="#888" + fontSize={12} + /> + + new Date(val).toLocaleString()} + /> + + + + + +
+
+ )} +

Priority Tickets

@@ -4054,6 +4137,7 @@ export default function AdminControlPanelPage() { body: JSON.stringify({ discordId, ...form, + publishedAt: form.publishedAt || new Date().toISOString(), image: form.image?.trim() ? form.image.trim() : null, author: form.author || "VectoBeat Team", }), @@ -5492,6 +5576,16 @@ export default function AdminControlPanelPage() { />
+
+ + setForm((prev) => ({ ...prev, publishedAt: e.target.value }))} + className="w-full px-4 py-2 rounded-lg bg-background border border-border/50 focus:border-primary/50 outline-none" + /> +

Leave empty to publish immediately. Future dates will schedule the post.

+
diff --git a/frontend/app/control-panel/page.tsx b/frontend/app/control-panel/page.tsx index d0ba3c5..ae1e571 100644 --- a/frontend/app/control-panel/page.tsx +++ b/frontend/app/control-panel/page.tsx @@ -1965,8 +1965,8 @@ export default function ControlPanelPage() { { key: "djMode", title: "DJ Mode", description: "Restrict queue controls to DJ roles only" }, { key: "normalizeVolume", - title: "Normalize Volume", - description: "Smooth out loudness differences between tracks", + title: "Adaptive Mastering Pipeline", + description: "Normalize perceived loudness per track to keep playlists consistent", }, ] as const ).map((setting) => ( diff --git a/frontend/components/admin-compliance-manager.tsx b/frontend/components/admin-compliance-manager.tsx new file mode 100644 index 0000000..1b22677 --- /dev/null +++ b/frontend/components/admin-compliance-manager.tsx @@ -0,0 +1,81 @@ +"use client" + +import { useEffect, useState, useCallback } from "react" +import { apiClient } from "@/lib/api-client" + +type DataExportRequest = { + id: string + discordId: string + status: string + downloadUrl: string | null + expiresAt: string | null + createdAt: string +} + +export function AdminComplianceManager() { + const [requests, setRequests] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const loadRequests = useCallback(async () => { + setLoading(true) + setError(null) + try { + const data = await apiClient('/api/admin/compliance/export-requests') + setRequests(data) + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load requests") + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + loadRequests() + }, [loadRequests]) + + return ( +
+
+
+
+

Data Export Logs

+

+ Review GDPR Trust & Safety Compliance Mode exports requested by users. +

+
+ +
+ {error &&

{error}

} + +
+ {requests.map(req => ( +
+
+

Discord ID: {req.discordId}

+

Requested on: {new Date(req.createdAt).toLocaleString()}

+
+
+ + {req.status} + +
+
+ ))} + {requests.length === 0 && !loading && ( +

No compliance exports found.

+ )} + {loading && ( +

Loading export logs...

+ )} +
+
+
+ ) +} diff --git a/frontend/components/admin-enterprise-manager.tsx b/frontend/components/admin-enterprise-manager.tsx new file mode 100644 index 0000000..7b5beaa --- /dev/null +++ b/frontend/components/admin-enterprise-manager.tsx @@ -0,0 +1,307 @@ +"use client" + +import { useEffect, useState, useCallback } from "react" +import { apiClient } from "@/lib/api-client" + +type EnterpriseSetting = { + id: string + guildId: string + domain: string | null + ssoEnabled: boolean + ssoProvider: string | null + ssoConfig: any | null + branding: any | null + createdAt: string +} + +export function AdminEnterpriseManager() { + const [settings, setSettings] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const [modalOpen, setModalOpen] = useState(false) + const [saving, setSaving] = useState(false) + + const [formData, setFormData] = useState({ + id: "", + guildId: "", + domain: "", + ssoEnabled: false, + ssoProvider: "Okta", + ssoConfig: "{}", + branding: "{}" + }) + + const loadSettings = useCallback(async () => { + setLoading(true) + setError(null) + try { + const data = await apiClient('/api/admin/enterprise') + setSettings(data) + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load enterprise settings") + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + loadSettings() + }, [loadSettings]) + + const handleEdit = (setting: EnterpriseSetting) => { + setFormData({ + id: setting.id, + guildId: setting.guildId, + domain: setting.domain || "", + ssoEnabled: setting.ssoEnabled, + ssoProvider: setting.ssoProvider || "Okta", + ssoConfig: setting.ssoConfig ? JSON.stringify(setting.ssoConfig, null, 2) : "{}", + branding: setting.branding ? JSON.stringify(setting.branding, null, 2) : "{}" + }) + setModalOpen(true) + } + + const handleCreate = () => { + setFormData({ + id: "", + guildId: "", + domain: "", + ssoEnabled: false, + ssoProvider: "Okta", + ssoConfig: "{}", + branding: "{}" + }) + setModalOpen(true) + } + + const handleDelete = async (id: string) => { + if (!window.confirm("Delete this enterprise configuration?")) return + try { + await apiClient(`/api/admin/enterprise?id=${id}`, { method: "DELETE" }) + loadSettings() + } catch (err) { + alert("Failed to delete") + } + } + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault() + setSaving(true) + try { + let parsedConfig = null + let parsedBranding = null + + if (formData.ssoConfig.trim()) parsedConfig = JSON.parse(formData.ssoConfig) + if (formData.branding.trim()) parsedBranding = JSON.parse(formData.branding) + + await apiClient('/api/admin/enterprise', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + guildId: formData.guildId, + domain: formData.domain.trim() || null, + ssoEnabled: formData.ssoEnabled, + ssoProvider: formData.ssoProvider.trim() || null, + ssoConfig: parsedConfig, + branding: parsedBranding + }) + }) + setModalOpen(false) + loadSettings() + } catch (err) { + alert("Validation error. Ensure JSON fields are formatted correctly.") + } finally { + setSaving(false) + } + } + + return ( +
+
+
+
+

Enterprise Configurations

+

+ Manage SSO/SAML integrations and white-label branding for Scale & Enterprise guilds. +

+
+
+ + +
+
+ {error &&

{error}

} + +
+ + + + + + + + + + + + {settings.map(setting => ( + + + + + + + + ))} + {settings.length === 0 && !loading && ( + + + + )} + +
Guild IDDomainSSOBrandingActions
{setting.guildId}{setting.domain || None} + + {setting.ssoEnabled ? setting.ssoProvider || "Enabled" : "Disabled"} + + + {setting.branding ? "Configured" : "Default"} + +
+ + +
+
+ No enterprise configurations found. +
+ {loading && ( +

Loading data...

+ )} +
+
+ + {modalOpen && ( +
+
+
+

{formData.id ? "Edit Enterprise Settings" : "New Enterprise Settings"}

+
+ +
+
+ + setFormData({...formData, guildId: e.target.value})} + className="w-full px-3 py-2 bg-muted/50 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50" + required + /> +
+ +
+ + setFormData({...formData, domain: e.target.value})} + placeholder="e.g. portal.company.com" + className="w-full px-3 py-2 bg-muted/50 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50" + /> +
+ +
+ setFormData({...formData, ssoEnabled: e.target.checked})} + className="rounded border-border text-primary focus:ring-primary/50 scale-110" + /> + +
+ + {formData.ssoEnabled && ( +
+
+ + +
+
+ +