diff --git a/panel/dev/mockApi.ts b/panel/dev/mockApi.ts index 0035d81..0dc8af1 100644 --- a/panel/dev/mockApi.ts +++ b/panel/dev/mockApi.ts @@ -11,6 +11,7 @@ import type { Phase, ServerInfo, WhitelistImage, + Submission, } from "../src/lib/types"; const ACCOUNT_IDS = ["owner", "user", "linked", "setup"] as const; @@ -53,6 +54,7 @@ interface MockState { backups: BackupView[]; builds: Build[]; passkeys: Record; + submissions: Submission[]; } // PLAYER_NAME mirrors the backend's mcNameRe (handlers_access.go) so the mock @@ -267,6 +269,39 @@ function initialState(): MockState { user: [], setup: [], }, + submissions: [ + { + id: "sub-1", + submitted_by: "user@mock.felis.local", + display_name: "Pixelmon Modpack V2", + context_ref: "minio/contexts/sub-1/context.tar.gz", + status: "pending_review", + created_at: new Date(Date.now() - 3600000).toISOString(), + }, + { + id: "sub-2", + submitted_by: "linked@mock.felis.local", + display_name: "Create: Astral pack", + context_ref: "minio/contexts/sub-2/context.tar.gz", + status: "approved", + image_ref: "registry.felis.svc:5000/user-uploads/sub-2:latest", + build_id: "bld-1", + reviewed_by: "owner@mock.felis.local", + created_at: new Date(Date.now() - 7200000).toISOString(), + reviewed_at: new Date(Date.now() - 7100000).toISOString(), + }, + { + id: "sub-3", + submitted_by: "user@mock.felis.local", + display_name: "Dangerous Modpack (Exploitative)", + context_ref: "minio/contexts/sub-3/context.tar.gz", + status: "rejected", + reviewed_by: "owner@mock.felis.local", + reject_reason: "Contains malicious code in scripts/run.sh that tries to download remote malware.", + created_at: new Date(Date.now() - 10800000).toISOString(), + reviewed_at: new Date(Date.now() - 10700000).toISOString(), + }, + ], }; } @@ -650,6 +685,7 @@ async function handleSession(ctx: SessionContext): Promise { return true; } if (await handleImageRoute(ctx)) return true; + if (await handleSubmissionRoute(ctx)) return true; return await handleServerRoute(ctx); } } @@ -816,6 +852,106 @@ async function handleImageRoute(ctx: SessionContext): Promise { return false; } +async function handleSubmissionRoute(ctx: SessionContext): Promise { + if (ctx.parts[2] !== "submissions") return false; + + // GET /api/v1/submissions + if (is("GET", ctx) && ctx.parts.length === 3) { + if (ctx.account.role !== "admin") { + sendError(ctx.res, 403, "forbidden", "admin account required"); + return true; + } + sendJSON(ctx.res, 200, { submissions: ctx.state.submissions }); + return true; + } + + // POST /api/v1/submissions/{id}/approve + if (is("POST", ctx) && ctx.parts[4] === "approve" && ctx.parts.length === 5) { + if (ctx.account.role !== "admin") { + sendError(ctx.res, 403, "forbidden", "admin account required"); + return true; + } + const id = ctx.parts[3]; + const sub = ctx.state.submissions.find((s) => s.id === id); + if (!sub) { + sendError(ctx.res, 404, "not_found", "submission not found"); + return true; + } + if (sub.status !== "pending_review") { + sendError(ctx.res, 409, "already_reviewed", "submission already reviewed"); + return true; + } + + sub.status = "approved"; + sub.reviewed_by = ctx.account.email; + sub.reviewed_at = new Date().toISOString(); + sub.image_ref = `registry.felis.svc:5000/user-uploads/${id}:latest`; + + // Trigger mock build + const newBuild: Build = { + id: `bld-${Date.now()}`, + image_ref: sub.image_ref, + status: "building", + dockerfile: `# felis user-modpack submission ${id}\n# The executed Dockerfile is provided by the uploaded build context:\n# ${sub.context_ref}\n`, + context_ref: sub.context_ref, + requested_by: ctx.account.email, + created_at: new Date().toISOString(), + }; + ctx.state.builds.unshift(newBuild); + sub.build_id = newBuild.id; + + // Succeeded after 15 seconds + setTimeout(() => { + const b = ctx.state.builds.find((x) => x.id === newBuild.id); + if (b && b.status === "building") { + b.status = "succeeded"; + b.finished_at = new Date().toISOString(); + if (!ctx.state.images.some((i) => i.image_ref === b.image_ref)) { + ctx.state.images.unshift({ image_ref: b.image_ref, enabled: true, source: "built" }); + } + } + }, 15000); + + sendJSON(ctx.res, 200, sub); + return true; + } + + // POST /api/v1/submissions/{id}/reject + if (is("POST", ctx) && ctx.parts[4] === "reject" && ctx.parts.length === 5) { + if (ctx.account.role !== "admin") { + sendError(ctx.res, 403, "forbidden", "admin account required"); + return true; + } + const id = ctx.parts[3]; + const sub = ctx.state.submissions.find((s) => s.id === id); + if (!sub) { + sendError(ctx.res, 404, "not_found", "submission not found"); + return true; + } + if (sub.status !== "pending_review") { + sendError(ctx.res, 409, "already_reviewed", "submission already reviewed"); + return true; + } + + const body = await readJSON<{ reason?: string }>(ctx.req); + const reason = body.reason?.trim(); + if (!reason) { + sendError(ctx.res, 400, "bad_request", "reason is required"); + return true; + } + + sub.status = "rejected"; + sub.reviewed_by = ctx.account.email; + sub.reviewed_at = new Date().toISOString(); + sub.reject_reason = reason; + + sendJSON(ctx.res, 200, sub); + return true; + } + + return false; +} + function streamBuildLogs( req: IncomingMessage, res: ServerResponse, diff --git a/panel/src/App.tsx b/panel/src/App.tsx index 3e6d63e..9810853 100644 --- a/panel/src/App.tsx +++ b/panel/src/App.tsx @@ -14,6 +14,7 @@ import { ServerBackups } from "@/pages/ServerBackups"; import { Account } from "@/pages/Account"; import { ImageAdmin } from "@/pages/admin/ImageAdmin"; import { ImageBuildPage } from "@/pages/admin/ImageBuildPage"; +import { SubmissionsPage } from "@/pages/admin/SubmissionsPage"; // Three UX surfaces over two Zero-Trust tiers (DESIGN-WEB-3SIDES): // / User-Side — app-tier, every authenticated principal @@ -53,6 +54,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/panel/src/components/ui/button.tsx b/panel/src/components/ui/button.tsx index 62ae5f4..4feec6c 100644 --- a/panel/src/components/ui/button.tsx +++ b/panel/src/components/ui/button.tsx @@ -15,6 +15,7 @@ const buttonVariants = cva( outline: "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground", ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline bg-transparent", }, size: { default: "h-9 px-4 py-2", diff --git a/panel/src/i18n/resources/en-US/admin.json b/panel/src/i18n/resources/en-US/admin.json index 5e97921..05404c2 100644 --- a/panel/src/i18n/resources/en-US/admin.json +++ b/panel/src/i18n/resources/en-US/admin.json @@ -52,5 +52,24 @@ "filter_all": "All", "filter_enabled": "Enabled", "filter_disabled": "Disabled", - "image_ref_label": "Image Reference" + "image_ref_label": "Image Reference", + "submissions_title": "Submissions", + "submissions_subtitle": "Review queue for user-uploaded modpacks. Approved submissions will automatically trigger safe Kaniko builds with Trivy scans.", + "table_submitted_by": "Submitted By", + "table_display_name": "Name", + "approve_btn": "Approve", + "reject_btn": "Reject", + "reject_reason_label": "Rejection Reason", + "reject_reason_placeholder": "Please provide a reason (max 1000 characters)", + "reject_dialog_title": "Reject Submission", + "reject_dialog_desc": "Please provide a detailed reason for rejecting this modpack submission. This reason will be shown to the user.", + "reject_dialog_submit": "Confirm Reject", + "status_pending_review": "Pending Review", + "status_approved": "Approved", + "status_rejected": "Rejected", + "no_submissions_title": "No Submissions Found", + "no_submissions_hint": "There are currently no pending or reviewed modpack submissions.", + "reviewer": "Reviewer", + "reviewed_at": "Reviewed At", + "reject_reason": "Rejection Reason" } \ No newline at end of file diff --git a/panel/src/i18n/resources/en-US/navigation.json b/panel/src/i18n/resources/en-US/navigation.json index 0c9a6b3..c365e76 100644 --- a/panel/src/i18n/resources/en-US/navigation.json +++ b/panel/src/i18n/resources/en-US/navigation.json @@ -4,5 +4,6 @@ "account": "Account", "admin_section": "Admin", "admin_images": "Images", - "admin_builds": "Build Pipeline" + "admin_builds": "Build Pipeline", + "admin_submissions": "Submissions" } diff --git a/panel/src/i18n/resources/zh-CN/admin.json b/panel/src/i18n/resources/zh-CN/admin.json index 4512667..4563071 100644 --- a/panel/src/i18n/resources/zh-CN/admin.json +++ b/panel/src/i18n/resources/zh-CN/admin.json @@ -52,5 +52,24 @@ "filter_all": "全部", "filter_enabled": "已启用", "filter_disabled": "已禁用", - "image_ref_label": "镜像引用" + "image_ref_label": "镜像引用", + "submissions_title": "审核提交", + "submissions_subtitle": "用户模组包上传的审核队列。通过审核的任务将自动启动 Kaniko 构建并运行 Trivy 安全扫描。", + "table_submitted_by": "提交人", + "table_display_name": "名称", + "approve_btn": "通过", + "reject_btn": "驳回", + "reject_reason_label": "驳回原因", + "reject_reason_placeholder": "请填写驳回原因(限 1000 字符)", + "reject_dialog_title": "驳回申请", + "reject_dialog_desc": "请提供驳回该模组包提交的详细原因,提交后该原因将展示给用户。", + "reject_dialog_submit": "确定驳回", + "status_pending_review": "待审核", + "status_approved": "已通过", + "status_rejected": "已驳回", + "no_submissions_title": "暂无审核任务", + "no_submissions_hint": "当前没有待审核或已审核的模组包提交。", + "reviewer": "审核人", + "reviewed_at": "审核时间", + "reject_reason": "驳回理由" } \ No newline at end of file diff --git a/panel/src/i18n/resources/zh-CN/navigation.json b/panel/src/i18n/resources/zh-CN/navigation.json index 1ba03f4..52dd370 100644 --- a/panel/src/i18n/resources/zh-CN/navigation.json +++ b/panel/src/i18n/resources/zh-CN/navigation.json @@ -4,5 +4,6 @@ "account": "账户", "admin_section": "管理", "admin_images": "镜像", - "admin_builds": "构建流水线" + "admin_builds": "构建流水线", + "admin_submissions": "审核提交" } diff --git a/panel/src/lib/api.test.ts b/panel/src/lib/api.test.ts index b13ec0e..68b1738 100644 --- a/panel/src/lib/api.test.ts +++ b/panel/src/lib/api.test.ts @@ -383,4 +383,40 @@ describe("image whitelist and builds wire shapes", () => { expect(String(url)).toBe("/images/build/bld-1/cancel"); expect((opts as RequestInit).method).toBe("POST"); }); + + describe("submissions", () => { + it("listSubmissions GETs from /submissions", async () => { + const submissions = [{ id: "sub-1", display_name: "test", status: "pending_review" }]; + const fetchSpy = fakeFetch({ submissions }); + vi.stubGlobal("fetch", fetchSpy); + const res = await api.listSubmissions(); + expect(res).toEqual(submissions); + const [url, opts] = (fetchSpy as unknown as ReturnType).mock.calls[0]; + expect(String(url)).toBe("/submissions"); + expect((opts as RequestInit).method).toBe("GET"); + }); + + it("approveSubmission POSTs to /submissions/{id}/approve", async () => { + const sub = { id: "sub-1", status: "approved" }; + const fetchSpy = fakeFetch(sub); + vi.stubGlobal("fetch", fetchSpy); + const res = await api.approveSubmission("sub-1"); + expect(res).toEqual(sub); + const [url, opts] = (fetchSpy as unknown as ReturnType).mock.calls[0]; + expect(String(url)).toBe("/submissions/sub-1/approve"); + expect((opts as RequestInit).method).toBe("POST"); + }); + + it("rejectSubmission POSTs {reason} to /submissions/{id}/reject", async () => { + const sub = { id: "sub-1", status: "rejected", reject_reason: "bad" }; + const fetchSpy = fakeFetch(sub); + vi.stubGlobal("fetch", fetchSpy); + const res = await api.rejectSubmission("sub-1", "bad"); + expect(res).toEqual(sub); + const [url, opts] = (fetchSpy as unknown as ReturnType).mock.calls[0]; + expect(String(url)).toBe("/submissions/sub-1/reject"); + expect((opts as RequestInit).method).toBe("POST"); + expect(JSON.parse((opts as RequestInit).body as string)).toEqual({ reason: "bad" }); + }); + }); }); diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index fce6d77..940d604 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -16,6 +16,7 @@ import type { ServerInfo, WhitelistImage, WhitelistResult, + Submission, } from "./types"; import { loadConfig } from "./config"; import i18next from "i18next"; @@ -248,6 +249,15 @@ export const api = { passkeyDelete: (id: string) => request("DELETE", `/account/passkey/credentials/${id}`), + + listSubmissions: () => + request<{ submissions: Submission[] }>("GET", "/submissions").then((r) => r.submissions ?? []), + + approveSubmission: (id: string) => + request("POST", `/submissions/${id}/approve`), + + rejectSubmission: (id: string, reason: string) => + request("POST", `/submissions/${id}/reject`, { reason }), }; /** diff --git a/panel/src/lib/nav.ts b/panel/src/lib/nav.ts index eba1b02..9be9f57 100644 --- a/panel/src/lib/nav.ts +++ b/panel/src/lib/nav.ts @@ -4,6 +4,7 @@ import { UserRound, Boxes, Cpu, + ClipboardCheck, type LucideIcon, } from "lucide-react"; @@ -53,6 +54,7 @@ export const NAV_SECTIONS: NavSection[] = [ items: [ { to: "/admin/images", key: "admin_images", icon: Boxes }, { to: "/admin/builds", key: "admin_builds", icon: Cpu }, + { to: "/admin/submissions", key: "admin_submissions", icon: ClipboardCheck }, ], }, ]; diff --git a/panel/src/lib/types.ts b/panel/src/lib/types.ts index 39e174a..64306be 100644 --- a/panel/src/lib/types.ts +++ b/panel/src/lib/types.ts @@ -245,3 +245,20 @@ export interface Build { created_at: string; finished_at?: string; } + +export type SubmissionStatus = "pending_review" | "approved" | "rejected"; + +/** Submission mirrors an image_submissions row (spec §6, migration 0002). */ +export interface Submission { + id: string; + submitted_by: string; + display_name: string; + context_ref: string; + status: SubmissionStatus; + image_ref?: string; + build_id?: string; + reviewed_by?: string; + reject_reason?: string; + created_at: string; + reviewed_at?: string; +} diff --git a/panel/src/pages/admin/SubmissionsPage.tsx b/panel/src/pages/admin/SubmissionsPage.tsx new file mode 100644 index 0000000..ad50719 --- /dev/null +++ b/panel/src/pages/admin/SubmissionsPage.tsx @@ -0,0 +1,521 @@ +import { useState, useMemo } from "react"; +import { ClipboardCheck, CheckCircle2, CircleSlash, Search, ChevronDown, ChevronUp, Check, X, Loader2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Card, CardContent } 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 { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import { Loading, ErrorState, EmptyState } from "@/components/States"; +import { Pagination } from "@/components/Pagination"; +import { api, humanizeError } from "@/lib/api"; +import { useAsync } from "@/lib/hooks"; +import { formatRelative, formatAbsolute } from "@/lib/format"; +import type { Submission, SubmissionStatus } from "@/lib/types"; + +const PAGE_SIZE = 10; + +function StatCard({ + icon: Icon, + label, + value, + accentClass, +}: { + icon: any; + label: string; + value: number; + accentClass?: string; +}) { + return ( + + +
+ +
+
+
{value}
+
{label}
+
+
+
+ ); +} + +export function SubmissionsPage() { + const { t, i18n } = useTranslation("admin"); + const locale = i18n.language; + const now = Date.now(); + + const { data, error, loading, reload } = useAsync(() => api.listSubmissions(), []); + const submissions: Submission[] = data ?? []; + + // Dialog State + const [rejectDialogOpen, setRejectDialogOpen] = useState(false); + const [rejectingId, setRejectingId] = useState(null); + const [rejectReason, setRejectReason] = useState(""); + const [actionError, setActionError] = useState(null); + + // Pending actions (for button spinners) + const [busyId, setBusyId] = useState(null); + const [busyType, setBusyType] = useState<"approve" | "reject" | null>(null); + + // Search & Filtering State + const [search, setSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState<"all" | SubmissionStatus>("all"); + const [page, setPage] = useState(1); + const [expandedId, setExpandedId] = useState(null); + + // Stats + const stats = useMemo(() => { + const total = submissions.length; + const pending = submissions.filter((s) => s.status === "pending_review").length; + const approved = submissions.filter((s) => s.status === "approved").length; + const rejected = submissions.filter((s) => s.status === "rejected").length; + return { total, pending, approved, rejected }; + }, [submissions]); + + // Filtered & Paginated Submissions + const filteredSubmissions = useMemo(() => { + let list = [...submissions]; + + // 1. Search Filter + if (search.trim()) { + const q = search.toLowerCase(); + list = list.filter( + (s) => + s.display_name.toLowerCase().includes(q) || + s.submitted_by.toLowerCase().includes(q) || + s.id.toLowerCase().includes(q), + ); + } + + // 2. Status Filter + if (statusFilter !== "all") { + list = list.filter((s) => s.status === statusFilter); + } + + return list; + }, [submissions, search, statusFilter]); + + // Reset page when filter changes + const lastFilterKey = `${search}-${statusFilter}`; + const [prevFilterKey, setPrevFilterKey] = useState(lastFilterKey); + if (prevFilterKey !== lastFilterKey) { + setPage(1); + setPrevFilterKey(lastFilterKey); + } + + const paginatedSubmissions = useMemo(() => { + const start = (page - 1) * PAGE_SIZE; + return filteredSubmissions.slice(start, start + PAGE_SIZE); + }, [filteredSubmissions, page]); + + async function handleApprove(id: string) { + if (busyId) return; + setBusyId(id); + setBusyType("approve"); + setActionError(null); + try { + await api.approveSubmission(id); + reload(); + } catch (err) { + setActionError(humanizeError(err)); + } finally { + setBusyId(null); + setBusyType(null); + } + } + + function openRejectDialog(id: string) { + setRejectingId(id); + setRejectReason(""); + setActionError(null); + setRejectDialogOpen(true); + } + + async function handleRejectSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!rejectingId || !rejectReason.trim()) return; + setBusyId(rejectingId); + setBusyType("reject"); + setActionError(null); + try { + await api.rejectSubmission(rejectingId, rejectReason.trim()); + setRejectDialogOpen(false); + reload(); + } catch (err) { + setActionError(humanizeError(err)); + } finally { + setBusyId(null); + setBusyType(null); + } + } + + const STATUS_BADGE_STYLE: Record = { + pending_review: "bg-amber-500/10 text-amber-500 border-amber-500/20", + approved: "bg-emerald-500/10 text-emerald-500 border-emerald-500/20", + rejected: "bg-rose-500/10 text-rose-500 border-rose-500/20", + }; + + return ( +
+ {/* Header */} +
+
+ +
+

{t("submissions_title")}

+

+ {t("submissions_subtitle")} +

+
+
+
+ + {/* Action Error Alert */} + {actionError && ( +

+ {actionError} +

+ )} + + {/* Stats Cards Row */} +
+ + 0 && "text-amber-500 bg-amber-500/10")} /> + + +
+ + {/* Main Table Card */} + + + {/* Filters Bar */} +
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+
+ + + + +
+
+ + {/* List Content */} + {loading && !data ? ( +
+ ) : error ? ( +
+ ) : filteredSubmissions.length === 0 ? ( +
+ +
+ ) : ( +
+ {/* Table Header */} +
+
{t("table_display_name")}
+
{t("table_submitted_by")}
+
{t("table_status")}
+
{t("table_created_at")}
+
{t("table_action")}
+
+ + {/* Table Body */} +
+ {paginatedSubmissions.map((sub) => { + const isExpanded = expandedId === sub.id; + const isBusyApprove = busyId === sub.id && busyType === "approve"; + const isBusyReject = busyId === sub.id && busyType === "reject"; + return ( +
+
setExpandedId(isExpanded ? null : sub.id)} + > + {/* Name */} +
+ {isExpanded ? ( + + ) : ( + + )} + {sub.display_name} + + {sub.id} + +
+ + {/* Submitter */} +
+ {sub.submitted_by} +
+ + {/* Status */} +
+ + {sub.status === "pending_review" && t("status_pending_review")} + {sub.status === "approved" && t("status_approved")} + {sub.status === "rejected" && t("status_rejected")} + +
+ + {/* Created At */} +
+ {formatRelative(sub.created_at, now, locale)} +
+ + {/* Action buttons (only in table row if NOT pending, else show triggers) */} +
e.stopPropagation()}> + {sub.status === "pending_review" ? ( +
+ + +
+ ) : ( + + — + + )} +
+
+ + {/* Expandable details block */} + {isExpanded && ( +
+
+
+

构建上下文引用 (Context Ref)

+
{sub.context_ref}
+
+ {sub.image_ref && ( +
+

目标镜像引用 (Image Ref)

+
{sub.image_ref}
+
+ )} +
+ + {sub.build_id && ( +
+

关联构建任务 (Build ID)

+ {sub.build_id} +
+ )} + + {sub.status !== "pending_review" && ( +
+
+ {t("reviewer")}:{" "} + {sub.reviewed_by} +
+ {sub.reviewed_at && ( +
+ {t("reviewed_at")}:{" "} + {formatAbsolute(sub.reviewed_at, locale)} ({formatRelative(sub.reviewed_at, now, locale)}) +
+ )} + {sub.status === "rejected" && sub.reject_reason && ( +
+ {t("reject_reason")}: +

{sub.reject_reason}

+
+ )} +
+ )} +
+ )} +
+ ); + })} +
+ + {/* Pagination */} + {filteredSubmissions.length > PAGE_SIZE && ( +
+ +
+ )} +
+ )} +
+
+ + {/* Reject Modal Dialog */} + { + setRejectDialogOpen(o); + if (!o) { + setRejectingId(null); + setRejectReason(""); + setActionError(null); + } + }}> + + + {t("reject_dialog_title")} + + {t("reject_dialog_desc")} + + +
+
+ +