diff --git a/.env.example b/.env.example index 3b1f2ce..cd5f577 100644 --- a/.env.example +++ b/.env.example @@ -84,6 +84,10 @@ CMS_PORT=1337 # in production (e.g. https://cms.sharper.ly). CMS_PUBLIC_URL=http://localhost:1337 +# Optional comma-separated recipients for new partner draft notifications. +# When empty, notifications go to all active Strapi Super Admin accounts. +PARTNER_REVIEW_NOTIFICATION_EMAILS= + # --------------------------------------------------------------------------- # WEB APP - Docker mode. In manual mode these live in apps/web/.env. diff --git a/README.md b/README.md index 4f25af6..446aadd 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,35 @@ every boot; it You never have to click through the Users & Permissions screens after adding a content type; restart the CMS and the grant is there. +### Partner content review + +Partner contributors use Strapi's **Author** admin role. To onboard one, create the admin +account, then as Super Admin create a **Partner Access** entry in Content Manager with the +admin user ID (from the user detail URL) and the linked Partner organisation. Each admin +account has one assignment; multiple accounts can be assigned to the same partner. Partner +Access has no public Content API and is reserved for Super Admins. + +Authors can create and edit their own resource, news article, activity, event, or opportunity +drafts. The CMS enforces their partner assignment on every save, regardless of what the +browser submits, and removes the organisation selector from their edit fields. Authors cannot +publish or delete. An **Editor** reviews the draft in the side panel below Preview. Set its +status to **Approved for publishing** before publishing, or choose **Changes requested** or +**Rejected** and add feedback. The Publish action stays disabled until approval is saved, and +the server rejects publish requests for these content types unless their review status is approved. +The partner author can read the status and feedback in the CMS; both fields are excluded from the public API. +Resubmitting resets the status to **Pending** and clears old feedback. + +The CMS also emails reviewers when a partner draft is first submitted or resubmitted, and emails +the submitting admin when the reviewer requests changes, rejects the draft, or publishes it. +Reviewer mail goes to all active Super Admins by default; set the optional comma-separated +`PARTNER_REVIEW_NOTIFICATION_EMAILS` to use a dedicated inbox. These notices use the configured +SMTP provider. Email failures are logged and do not undo a saved draft or review decision. + +Strapi's built-in Author role limits authors to entries they created. Different users assigned +to the same partner therefore share the organisation assignment, but not each other's drafts. +Use Editor for central review; partner-wide collaboration would need a separate custom access +policy. + ### 6. Create the first admin user Strapi has no seeded administrator. On first run, open @@ -901,6 +930,7 @@ Whichever port you pick, `SITE_URL` stays the public origin (`https://sharper.ly | `STRAPI_URL` | web | the CMS origin, e.g. `https://cms.sharper.ly`. It is also prefixed onto media URLs, so it must be resolvable **by the browser**, not only from the server | | `STRAPI_TOKEN` | web | only needed for draft/protected content; published content is public | | `PUBLIC_URL` | cms | `https://cms.sharper.ly` — admin redirects and generated links | +| `PARTNER_REVIEW_NOTIFICATION_EMAILS` | cms | Optional comma-separated addresses for partner draft notices; defaults to active Super Admins | | `IS_PROXIED` | cms | `true` behind nginx / a load balancer, so `X-Forwarded-*` is trusted | | `CORS_ORIGINS` | cms | `https://sharper.ly,https://www.sharper.ly` — an empty list blocks every cross-origin browser call, which is the safe default | | `DATABASE_*` | cms | managed PostgreSQL; set `DATABASE_SSL=true` for a hosted database | diff --git a/apps/cms/.env.example b/apps/cms/.env.example index 4a91378..085a1a7 100644 --- a/apps/cms/.env.example +++ b/apps/cms/.env.example @@ -149,6 +149,9 @@ SMTP_HOST=smtp.example.com SMTP_PORT=587 SMTP_USERNAME=your-username SMTP_PASSWORD=your-password +# Optional comma-separated reviewer recipients. If empty, partner draft +# notifications are sent to all active Strapi Super Admin accounts. +PARTNER_REVIEW_NOTIFICATION_EMAILS= DEFAULT_FROM=no-reply@yourdomain.com DEFAULT_REPLY_TO=support@yourdomain.com # The values above are also the from/reply-to for the registration diff --git a/apps/cms/src/admin/app.tsx b/apps/cms/src/admin/app.tsx new file mode 100644 index 0000000..ff2e225 --- /dev/null +++ b/apps/cms/src/admin/app.tsx @@ -0,0 +1,252 @@ +import * as React from 'react'; +import { Badge, Box, Button, Flex, Textarea, Typography } from '@strapi/design-system'; +import { + unstable_useContentManagerContext as useContentManagerContext, + unstable_useDocumentActions as useDocumentActions, + type StrapiApp, + useAuth, +} from '@strapi/strapi/admin'; +import { useLocation } from 'react-router-dom'; + +const PARTNER_CONTENT_TYPES = new Set([ + 'api::resource.resource', + 'api::news-article.news-article', + 'api::activity.activity', + 'api::event.event', + 'api::opportunity.opportunity', +]); + +type ReviewValues = { + reviewStatus?: string; + reviewFeedback?: string; +}; +type ContentFormValues = ReviewValues & Record; +type ReviewForm = { + values?: ContentFormValues; + initialValues?: ContentFormValues; + modified?: boolean; + isSubmitting?: boolean; + resetForm?: (newInitialValues?: ContentFormValues) => void; + onChange?: (event: { target: { name: string; value: string; type: string } }) => void; +}; + +const STATUS_PRESENTATION: Record = { + pending: { label: 'Pending review', variant: 'warning' }, + approved: { label: 'Approved', variant: 'success' }, + 'changes-requested': { label: 'Changes requested', variant: 'warning' }, + rejected: { label: 'Rejected', variant: 'danger' }, +}; + +const ReviewPanelContent = ({ model }: { model: string }) => { + const { form: rawForm, isCreatingEntry, id, collectionType } = useContentManagerContext(); + const { update, isLoading: isSavingReview } = useDocumentActions(); + const location = useLocation(); + const canReview = useAuth('PartnerReviewPanel', ({ user }) => + user?.roles?.some(({ code }) => code === 'strapi-editor' || code === 'strapi-super-admin') ?? false + ); + const form = rawForm as ReviewForm; + + if (!PARTNER_CONTENT_TYPES.has(model)) return null; + + const values = form.values ?? {}; + const status = STATUS_PRESENTATION[values.reviewStatus ?? ''] ? values.reviewStatus! : 'pending'; + const statusPresentation = STATUS_PRESENTATION[status]; + const feedback = values.reviewFeedback ?? ''; + const initialValues = form.initialValues ?? {}; + const withoutReviewFields = (formValues: ContentFormValues) => + Object.fromEntries( + Object.keys(formValues) + .filter((field) => field !== 'reviewStatus' && field !== 'reviewFeedback') + .sort() + .map((field) => [field, formValues[field]]) + ); + const savedContentValues = withoutReviewFields(initialValues); + const currentContentValues = withoutReviewFields(values); + const hasUnsavedContent = JSON.stringify(savedContentValues) !== JSON.stringify(currentContentValues); + const [isApproving, setIsApproving] = React.useState(false); + const onChange = (name: string, value: string, type: string) => + form.onChange?.({ target: { name, value, type } }); + const approveAndSave = async () => { + if (!id || hasUnsavedContent || isApproving || isSavingReview) return; + + setIsApproving(true); + try { + const locale = new URLSearchParams(location.search).get('plugins[i18n][locale]'); + const result = await update( + { + collectionType, + model, + documentId: id, + ...(locale ? { params: { locale } } : {}), + }, + { reviewStatus: 'approved', reviewFeedback: feedback } + ); + + if (!result || 'error' in result) return; + + form.resetForm?.({ + ...values, + reviewStatus: 'approved', + reviewFeedback: feedback, + }); + } finally { + setIsApproving(false); + } + }; + const reviewChangesPending = + status !== (form.initialValues?.reviewStatus ?? 'pending') || + feedback !== (form.initialValues?.reviewFeedback ?? ''); + + return ( + + + + + Partner submission + + {statusPresentation.label} + + + + Review the partner’s draft, leave a note if needed, then choose an outcome below. + + + + + Reviewer note + +