From fed40a5167f13a7e749a5dbc5301fe1a05b44f8c Mon Sep 17 00:00:00 2001 From: alex-strapi Date: Fri, 17 Apr 2026 17:50:13 +0100 Subject: [PATCH 01/26] feat: plugin submission workflow --- apps/cms/config/plugins.ts | 4 + .../src/plugins/moderation/admin/custom.d.ts | 26 + .../src/components/ReviewPanel/index.tsx | 590 ++++++++++++ .../plugins/moderation/admin/src/index.tsx | 45 + .../src/pages/SubmissionDetail/index.tsx | 756 ++++++++++++++++ .../admin/src/pages/SubmissionList/index.tsx | 226 +++++ apps/cms/src/plugins/moderation/package.json | 19 + .../moderation/server/content-types/index.js | 3 + .../content-types/plugin-submission/index.js | 3 + .../plugin-submission/schema.json | 117 +++ .../moderation/server/controllers/index.js | 3 + .../server/controllers/plugin-submission.js | 130 +++ .../src/plugins/moderation/server/index.js | 6 + .../plugins/moderation/server/routes/index.js | 1 + .../server/routes/plugin-submission.js | 53 ++ .../server/services/automated-checks.js | 384 ++++++++ .../moderation/server/services/index.js | 3 + .../server/services/plugin-submission.js | 271 ++++++ .../server/services/security-checks.js | 109 +++ .../src/plugins/moderation/strapi-server.js | 24 + apps/cms/types/generated/contentTypes.d.ts | 82 ++ apps/web/.env.example | 10 +- apps/web/src/app/api/categories/route.ts | 34 + apps/web/src/app/api/submit-plugin/route.ts | 287 ++++++ apps/web/src/app/submit/plugin/page.tsx | 843 ++++++++++++++++++ 25 files changed, 4028 insertions(+), 1 deletion(-) create mode 100644 apps/cms/src/plugins/moderation/admin/custom.d.ts create mode 100644 apps/cms/src/plugins/moderation/admin/src/components/ReviewPanel/index.tsx create mode 100644 apps/cms/src/plugins/moderation/admin/src/index.tsx create mode 100644 apps/cms/src/plugins/moderation/admin/src/pages/SubmissionDetail/index.tsx create mode 100644 apps/cms/src/plugins/moderation/admin/src/pages/SubmissionList/index.tsx create mode 100644 apps/cms/src/plugins/moderation/package.json create mode 100644 apps/cms/src/plugins/moderation/server/content-types/index.js create mode 100644 apps/cms/src/plugins/moderation/server/content-types/plugin-submission/index.js create mode 100644 apps/cms/src/plugins/moderation/server/content-types/plugin-submission/schema.json create mode 100644 apps/cms/src/plugins/moderation/server/controllers/index.js create mode 100644 apps/cms/src/plugins/moderation/server/controllers/plugin-submission.js create mode 100644 apps/cms/src/plugins/moderation/server/index.js create mode 100644 apps/cms/src/plugins/moderation/server/routes/index.js create mode 100644 apps/cms/src/plugins/moderation/server/routes/plugin-submission.js create mode 100644 apps/cms/src/plugins/moderation/server/services/automated-checks.js create mode 100644 apps/cms/src/plugins/moderation/server/services/index.js create mode 100644 apps/cms/src/plugins/moderation/server/services/plugin-submission.js create mode 100644 apps/cms/src/plugins/moderation/server/services/security-checks.js create mode 100644 apps/cms/src/plugins/moderation/strapi-server.js create mode 100644 apps/web/src/app/api/categories/route.ts create mode 100644 apps/web/src/app/api/submit-plugin/route.ts create mode 100644 apps/web/src/app/submit/plugin/page.tsx diff --git a/apps/cms/config/plugins.ts b/apps/cms/config/plugins.ts index c41de57..197adcf 100644 --- a/apps/cms/config/plugins.ts +++ b/apps/cms/config/plugins.ts @@ -23,6 +23,10 @@ export default ({ env }) => ({ enabled: true, resolve: "./src/plugins/owner-selector", }, + moderation: { + enabled: true, + resolve: "./src/plugins/moderation", + }, "package-info": { enabled: true, resolve: "./src/plugins/package-info", diff --git a/apps/cms/src/plugins/moderation/admin/custom.d.ts b/apps/cms/src/plugins/moderation/admin/custom.d.ts new file mode 100644 index 0000000..5b0b4a0 --- /dev/null +++ b/apps/cms/src/plugins/moderation/admin/custom.d.ts @@ -0,0 +1,26 @@ +import type { StrapiTheme } from "@strapi/design-system"; + +declare module "styled-components" { + export interface DefaultTheme extends StrapiTheme {} +} + +export interface BrowserStrapi { + backendURL: string; + isEE: boolean; + features: { + SSO: "sso"; + AUDIT_LOGS: "audit-logs"; + REVIEW_WORKFLOWS: "review-workflows"; + isEnabled: (featureName?: string) => boolean; + }; + isTrialLicense: boolean; + flags: { promoteEE?: boolean; nps?: boolean }; + projectType: "Community" | "Enterprise"; + telemetryDisabled: boolean; +} + +declare global { + interface Window { + strapi: BrowserStrapi; + } +} diff --git a/apps/cms/src/plugins/moderation/admin/src/components/ReviewPanel/index.tsx b/apps/cms/src/plugins/moderation/admin/src/components/ReviewPanel/index.tsx new file mode 100644 index 0000000..eb2e771 --- /dev/null +++ b/apps/cms/src/plugins/moderation/admin/src/components/ReviewPanel/index.tsx @@ -0,0 +1,590 @@ +import { + Box, + Button, + Field, + Flex, + SingleSelect, + SingleSelectOption, + Textarea, + Typography, +} from "@strapi/design-system"; +import { ChevronDown } from "@strapi/icons"; +import { useFetchClient, useNotification } from "@strapi/strapi/admin"; +import { useState } from "react"; +import { useMutation } from "react-query"; + +interface Props { + documentId: string; + initialBusinessStatus: "pending" | "approved" | "rejected"; + initialSecurityStatus: "pending" | "approved" | "rejected"; + initialOverallStatus: string; + initialFeedback?: string; + initialRejectionReason?: string; + initialBusinessNotes?: string; + initialSecurityNotes?: string; + onSaved: () => void; +} + +type ReviewStatus = "pending" | "approved" | "rejected"; + +const REVIEW_STYLES: Record< + ReviewStatus, + { bg: string; text: string; dot: string } +> = { + pending: { bg: "neutral150", text: "neutral600", dot: "#c0c0c0" }, + approved: { bg: "success100", text: "success600", dot: "#27ae60" }, + rejected: { bg: "danger100", text: "danger600", dot: "#e74c3c" }, +}; + +const StatusPill = ({ status }: { status: ReviewStatus }) => { + const s = REVIEW_STYLES[status]; + return ( + + + + + {status} + + + + ); +}; + +export const ReviewPanel = ({ + documentId, + initialBusinessStatus, + initialSecurityStatus, + initialOverallStatus, + initialFeedback, + initialRejectionReason, + initialBusinessNotes, + initialSecurityNotes, + onSaved, +}: Props) => { + const { put, post } = useFetchClient(); + const { toggleNotification } = useNotification(); + + const [businessStatus, setBusinessStatus] = useState( + initialBusinessStatus, + ); + const [securityStatus, setSecurityStatus] = useState( + initialSecurityStatus, + ); + const [businessNotes, setBusinessNotes] = useState( + initialBusinessNotes || "", + ); + const [securityNotes, setSecurityNotes] = useState( + initialSecurityNotes || "", + ); + const [feedback, setFeedback] = useState(initialFeedback || ""); + const [rejectionReason, setRejectionReason] = useState( + initialRejectionReason || "", + ); + const [openSection, setOpenSection] = useState< + "business" | "security" | null + >(null); + + const toggleSection = (section: "business" | "security") => + setOpenSection((prev) => (prev === section ? null : section)); + + const bothApproved = + businessStatus === "approved" && securityStatus === "approved"; + const isAlreadyApproved = initialOverallStatus === "approved"; + const isAlreadyRejected = initialOverallStatus === "rejected"; + const isLocked = isAlreadyApproved || isAlreadyRejected; + + const { mutate: saveReview, isLoading: saving } = useMutation( + async () => { + await put(`/moderation/submissions/${documentId}/review`, { + data: { + business_review_status: businessStatus, + security_review_status: securityStatus, + business_review_notes: businessNotes, + security_review_notes: securityNotes, + reviewer_feedback: feedback, + rejection_reason: rejectionReason, + }, + }); + }, + { + onSuccess() { + toggleNotification({ type: "success", message: "Review saved." }); + onSaved(); + }, + onError(err: unknown) { + toggleNotification({ + type: "danger", + message: + err instanceof Error ? err.message : "Failed to save review.", + }); + }, + }, + ); + + const { mutate: approve, isLoading: approving } = useMutation( + async () => { + await put(`/moderation/submissions/${documentId}/review`, { + data: { + business_review_status: businessStatus, + security_review_status: securityStatus, + business_review_notes: businessNotes, + security_review_notes: securityNotes, + overall_status: "approved", + }, + }); + }, + { + onSuccess() { + toggleNotification({ + type: "success", + message: "Submission approved.", + }); + onSaved(); + }, + onError(err: unknown) { + toggleNotification({ + type: "danger", + message: err instanceof Error ? err.message : "Failed to approve.", + }); + }, + }, + ); + + const { mutate: promote, isLoading: promoting } = useMutation( + async () => { + await post(`/moderation/submissions/${documentId}/promote`, {}); + }, + { + onSuccess() { + toggleNotification({ + type: "success", + message: "Package entry created. Open Content Manager to publish.", + }); + onSaved(); + }, + onError(err: unknown) { + toggleNotification({ + type: "danger", + message: err instanceof Error ? err.message : "Promotion failed.", + }); + }, + }, + ); + + const { mutate: reject, isLoading: rejecting } = useMutation( + async () => { + await post(`/moderation/submissions/${documentId}/decide`, { + data: { status: "rejected", reason: rejectionReason, feedback }, + }); + }, + { + onSuccess() { + toggleNotification({ + type: "success", + message: "Submission rejected.", + }); + onSaved(); + }, + onError(err: unknown) { + toggleNotification({ + type: "danger", + message: err instanceof Error ? err.message : "Failed to reject.", + }); + }, + }, + ); + + const { mutate: requestChanges, isLoading: requesting } = useMutation( + async () => { + await post(`/moderation/submissions/${documentId}/decide`, { + data: { status: "changes_requested", feedback }, + }); + }, + { + onSuccess() { + toggleNotification({ type: "success", message: "Changes requested." }); + onSaved(); + }, + onError(err: unknown) { + toggleNotification({ + type: "danger", + message: + err instanceof Error ? err.message : "Failed to request changes.", + }); + }, + }, + ); + + return ( + + {/* Panel header */} + + + Review Decision + {!isLocked && ( + + )} + + + + {/* Business Review — accordion */} + + + + {openSection === "business" && ( + + + + + Status + setBusinessStatus(val as ReviewStatus)} + disabled={isLocked} + > + + Pending + + + Approved + + + Rejected + + + + + + Internal Notes +