Unverified Commit 191640c3 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

feat(panel): implement admin submission approval and reject queue

parent c7315e44
Loading
Loading
Loading
Loading
+136 −0
Changes for panel/dev/mockApi.ts: 136 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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<AccountID, { id: string; name: string; created_at: string }[]>;
  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: "[email protected]",
        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: "[email protected]",
        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: "[email protected]",
        created_at: new Date(Date.now() - 7200000).toISOString(),
        reviewed_at: new Date(Date.now() - 7100000).toISOString(),
      },
      {
        id: "sub-3",
        submitted_by: "[email protected]",
        display_name: "Dangerous Modpack (Exploitative)",
        context_ref: "minio/contexts/sub-3/context.tar.gz",
        status: "rejected",
        reviewed_by: "[email protected]",
        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<boolean> {
        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<boolean> {
  return false;
}

async function handleSubmissionRoute(ctx: SessionContext): Promise<boolean> {
  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,
+2 −0
Changes for panel/src/App.tsx: 2 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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() {
                <Route index element={<Navigate to="/admin/images" replace />} />
                <Route path="images" element={<ImageAdmin />} />
                <Route path="builds" element={<ImageBuildPage />} />
                <Route path="submissions" element={<SubmissionsPage />} />
              </Route>

              <Route path="*" element={<Navigate to="/" replace />} />
+1 −0
Changes for panel/src/components/ui/button.tsx: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
@@ -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",
+20 −1
Changes for panel/src/i18n/resources/en-US/admin.json: 20 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -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
+2 −1
Changes for panel/src/i18n/resources/en-US/navigation.json: 2 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -4,5 +4,6 @@
  "account": "Account",
  "admin_section": "Admin",
  "admin_images": "Images",
  "admin_builds": "Build Pipeline"
  "admin_builds": "Build Pipeline",
  "admin_submissions": "Submissions"
}
Loading