diff --git a/apps/automation/docker-compose.yml b/apps/automation/docker-compose.yml index 99b1aff..08b4b56 100644 --- a/apps/automation/docker-compose.yml +++ b/apps/automation/docker-compose.yml @@ -2,7 +2,7 @@ name: strapi-automation services: n8n: - image: n8nio/n8n:latest + image: n8nio/n8n:2.27.4 restart: unless-stopped ports: - "5678:5678" @@ -11,14 +11,6 @@ services: TZ: ${GENERIC_TIMEZONE:-UTC} NODE_ENV: production N8N_SECURE_COOKIE: "false" - # Allow workflow expressions to read process env ($env.*). n8n blocks this by - # default; our workflows resolve the Strapi base URL + token from env. - N8N_BLOCK_ENV_ACCESS_IN_NODE: "false" - # Strapi connection used by workflows ($env.STRAPI_BASE_URL / $env.STRAPI_API_TOKEN). - # Default targets the host Strapi via the podman host-gateway alias. - STRAPI_BASE_URL: ${STRAPI_BASE_URL:-http://host.containers.internal:1337} - STRAPI_API_URL: ${STRAPI_API_URL:-http://host.containers.internal:1337} - STRAPI_API_TOKEN: ${STRAPI_API_TOKEN:-} volumes: - n8n_data:/home/node/.n8n healthcheck: diff --git a/apps/automation/package.json b/apps/automation/package.json index 5e207c1..de822ef 100644 --- a/apps/automation/package.json +++ b/apps/automation/package.json @@ -14,6 +14,7 @@ "n8n:ps": "./scripts/compose.sh ps", "n8n:logs": "./scripts/compose.sh logs -f", "workflows:export": "node ./scripts/export-workflows.mjs", - "workflows:import": "node ./scripts/import-workflows.mjs" + "workflows:import": "node ./scripts/import-workflows.mjs", + "workflows:deploy": "node ./scripts/deploy-workflows.mjs" } } diff --git a/apps/automation/scripts/deploy-workflows.mjs b/apps/automation/scripts/deploy-workflows.mjs new file mode 100644 index 0000000..a87ee1e --- /dev/null +++ b/apps/automation/scripts/deploy-workflows.mjs @@ -0,0 +1,222 @@ +#!/usr/bin/env node +// Deploy the workflows to a target n8n instance for ONE environment. +// +// Does three things the plain import does not: +// 1. Rewrites the Strapi base URL in every HTTP node from the committed +// placeholder (http://localhost:1337) to this environment's Strapi. +// 2. Rewrites the webhook node paths to this environment's namespace +// (strapi/ -> /) so duplicate sets on one n8n +// instance never collide. Must match Strapi's N8N_WEBHOOK_NAMESPACE. +// 3. Re-links the by-id references (executeWorkflow -> render-email and +// settings.errorWorkflow -> error-handler) to the target instance's ids. +// +// Workflows are imported INACTIVE. Credentials are bound once in the n8n UI +// (see README); on re-deploy this script re-attaches existing credential bindings +// by node name, so re-running does not wipe them. It never reads secret values. +// +// Required env: +// N8N_URL target n8n base URL (e.g. https://n8n.tools.strapi.team) +// N8N_API_KEY target n8n API key +// STRAPI_BASE_URL Strapi base URL for this environment (e.g. https://cms.example) +// Optional env: +// N8N_WEBHOOK_NAMESPACE webhook path prefix (default "strapi"; e.g. "staging") +// +// Example: +// N8N_URL=https://n8n.tools.strapi.team N8N_API_KEY=*** \ +// STRAPI_BASE_URL=https://staging-cms.example N8N_WEBHOOK_NAMESPACE=staging \ +// pnpm --filter automation run workflows:deploy + +import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const AUTOMATION_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const WORKFLOWS_DIR = join(AUTOMATION_ROOT, 'workflows'); +const PLACEHOLDER_BASE = 'http://localhost:1337'; +const RENDER_EMAIL_NAME = 'render-email (shared sub-workflow)'; +const ERROR_HANDLER_NAME = 'error-handler'; +// settings keys the n8n public API accepts on write (others -> 400 on 2.27+) +const ALLOWED_SETTINGS = [ + 'executionOrder', 'saveDataErrorExecution', 'saveDataSuccessExecution', + 'saveManualExecutions', 'saveExecutionProgress', 'errorWorkflow', + 'executionTimeout', 'timezone', 'callerPolicy', 'callerIds', +]; + +loadDotEnv(join(AUTOMATION_ROOT, '.env')); + +const N8N_URL = (process.env.N8N_URL || 'http://localhost:5678').replace(/\/$/, ''); +const API_KEY = process.env.N8N_API_KEY; +const STRAPI_BASE_URL = (process.env.STRAPI_BASE_URL || '').replace(/\/$/, ''); +const NAMESPACE = process.env.N8N_WEBHOOK_NAMESPACE || 'strapi'; + +if (!API_KEY) { + console.error('Error: N8N_API_KEY is not set (target instance key).'); + process.exit(1); +} +if (!STRAPI_BASE_URL) { + console.error('Error: STRAPI_BASE_URL is not set (the Strapi URL for this environment).'); + process.exit(1); +} + +const headers = { + 'X-N8N-API-KEY': API_KEY, + Accept: 'application/json', + 'Content-Type': 'application/json', +}; + +function sanitize(wf) { + const out = {}; + for (const f of ['name', 'nodes', 'connections', 'staticData']) { + if (wf[f] !== undefined) out[f] = wf[f]; + } + const settings = {}; + for (const k of ALLOWED_SETTINGS) { + if (wf.settings && wf.settings[k] !== undefined) settings[k] = wf.settings[k]; + } + out.settings = settings; + return out; +} + +// Rewrite base URL + webhook namespace for this environment (mutates wf). +function applyEnvConfig(wf) { + for (const node of wf.nodes ?? []) { + if (node.type === 'n8n-nodes-base.webhook') { + const p = node.parameters?.path; + if (typeof p === 'string' && p.startsWith('strapi/')) { + node.parameters.path = `${NAMESPACE}/${p.slice('strapi/'.length)}`; + } + } + if (node.type === 'n8n-nodes-base.httpRequest') { + const u = node.parameters?.url; + if (typeof u === 'string' && u.includes(PLACEHOLDER_BASE)) { + node.parameters.url = u.replaceAll(PLACEHOLDER_BASE, STRAPI_BASE_URL); + } + } + } +} + +// Re-point intra-set references to the target instance's ids (mutates wf). +function relink(wf, renderId, errorId) { + let changed = false; + for (const node of wf.nodes ?? []) { + if (node.type === 'n8n-nodes-base.executeWorkflow' && renderId) { + const ref = node.parameters?.workflowId; + if (ref && typeof ref === 'object' && ref.value !== renderId) { + ref.value = renderId; + ref.cachedResultName = RENDER_EMAIL_NAME; + changed = true; + } + } + } + if (errorId && wf.settings?.errorWorkflow && wf.settings.errorWorkflow !== errorId) { + wf.settings.errorWorkflow = errorId; + changed = true; + } + return changed; +} + +async function api(method, path, body) { + const r = await fetch(`${N8N_URL}${path}`, { + method, headers, body: body ? JSON.stringify(body) : undefined, + }); + if (!r.ok) throw new Error(`${method} ${path} -> ${r.status} ${await r.text()}`); + return r.json(); +} + +async function listWorkflows() { + const all = []; + let cursor; + do { + const url = new URL(`${N8N_URL}/api/v1/workflows`); + url.searchParams.set('limit', '100'); + if (cursor) url.searchParams.set('cursor', cursor); + const r = await fetch(url, { headers }); + if (!r.ok) throw new Error(`List failed: ${r.status} ${await r.text()}`); + const body = await r.json(); + all.push(...(body.data ?? [])); + cursor = body.nextCursor; + } while (cursor); + return all; +} + +function loadLocal() { + const out = []; + for (const entry of readdirSync(WORKFLOWS_DIR, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const p = join(WORKFLOWS_DIR, entry.name, 'workflow.json'); + if (existsSync(p)) out.push(JSON.parse(readFileSync(p, 'utf8'))); + } + return out; +} + +function loadDotEnv(path) { + if (!existsSync(path)) return; + for (const line of readFileSync(path, 'utf8').split('\n')) { + const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/); + if (!m) continue; + const val = m[2].replace(/^["']|["']$/g, ''); + if (!(m[1] in process.env)) process.env[m[1]] = val; + } +} + +async function main() { + console.log(`Deploying to ${N8N_URL}`); + console.log(` Strapi base URL : ${STRAPI_BASE_URL}`); + console.log(` webhook namespace: ${NAMESPACE} (paths -> /webhook/${NAMESPACE}/)\n`); + + const local = loadLocal().filter((wf) => wf.name); + local.forEach(applyEnvConfig); + + // Re-attach UI-bound credentials from an existing workflow onto the repo nodes + // (matched by node name), so a re-deploy never wipes credential bindings. + function preserveCreds(wf, existing) { + const byName = new Map(); + for (const n of existing.nodes ?? []) if (n.credentials) byName.set(n.name, n.credentials); + for (const n of wf.nodes ?? []) if (byName.has(n.name)) n.credentials = byName.get(n.name); + } + + // Pass 1: create/update by name (preserving existing credential bindings). + const existing = new Map((await listWorkflows()).map((w) => [w.name, w.id])); + const ids = new Map(); + let created = 0, updated = 0, failed = 0; + for (const wf of local) { + try { + if (existing.has(wf.name)) { + const id = existing.get(wf.name); + const current = await api('GET', `/api/v1/workflows/${id}`); + preserveCreds(wf, current); + await api('PUT', `/api/v1/workflows/${id}`, sanitize(wf)); + ids.set(wf.name, id); + updated++; console.log(` updated ${wf.name}`); + } else { + const res = await api('POST', '/api/v1/workflows', sanitize(wf)); + ids.set(wf.name, res.id); + created++; console.log(` created ${wf.name}`); + } + } catch (e) { + failed++; console.error(` failed ${wf.name}: ${e.message}`); + } + } + + // Pass 2: re-link by-id references to the target instance's ids. + const renderId = ids.get(RENDER_EMAIL_NAME); + const errorId = ids.get(ERROR_HANDLER_NAME); + let relinked = 0; + for (const wf of local) { + if (!ids.has(wf.name)) continue; + if (relink(wf, renderId, errorId)) { + try { + await api('PUT', `/api/v1/workflows/${ids.get(wf.name)}`, sanitize(wf)); + relinked++; + } catch (e) { + console.error(` relink failed ${wf.name}: ${e.message}`); + } + } + } + + console.log(`\nSummary: ${created} created, ${updated} updated, ${relinked} re-linked, ${failed} failed.`); + console.log('Imported INACTIVE. Next: bind credentials in the n8n UI (see README), then activate.'); + if (failed > 0) process.exit(1); +} + +main().catch((e) => { console.error(e.stack ?? e.message); process.exit(1); }); diff --git a/apps/automation/workflows/README.md b/apps/automation/workflows/README.md new file mode 100644 index 0000000..9929946 --- /dev/null +++ b/apps/automation/workflows/README.md @@ -0,0 +1,154 @@ +# Community Hub automation — n8n workflows + +These workflows power moderation automation. Strapi emits lifecycle webhooks → n8n +orchestrates developer emails (SendGrid), Slack notifications, and the security scan, +and calls back into Strapi for templates and scan results. + +> **Multi-environment on a shared n8n instance.** Staging and production run +> **duplicate workflow sets in the same n8n instance**, separated by a webhook +> **namespace** (`strapi` for production, `staging` for staging). Each set has its own +> credentials, its own Strapi base URL, and its own webhook paths +> (`/webhook//`) so the two sets never collide. The n8n-internal +> workflow IDs also differ per set, so by-id references are re-linked on deploy. + +## Architecture + +```mermaid +flowchart LR + subgraph strapi["Strapi (one per environment)"] + direction TB + trig["Submit form + admin review
(publish · decide · run-security-scan)"] + ep_tpl["GET /api/email-templates"] + ep_scan["POST /api/moderation/:plural/:id/security-scan-result"] + ep_stale["GET /api/moderation/:plural/stale-scans"] + ep_pkg["GET + PUT /api/packages"] + end + + subgraph n8n["n8n — one workflow set per env (namespace = strapi or staging)"] + direction TB + wf_life["lifecycle workflows
submission-received · approved
declined · changes-requested
(plugin + template)"] + wf_render["render-email
(shared sub-workflow)"] + wf_scan["security-scan"] + wf_sweep["scan-timeout-sweeper
(schedule)"] + wf_clean["cleanup-new-label-after-60d
(schedule)"] + wf_err["error-handler
(error trigger)"] + end + + subgraph ext["External services"] + sg["SendGrid"] + sl["Slack #integration-marketplace"] + ai["Anthropic (Claude)"] + reg["GitHub / npm / OSV.dev"] + end + + %% Strapi -> n8n (Strapi is the caller; namespaced webhook paths) + trig -->|"lifecycle webhooks"| wf_life + trig -->|"run-security-scan webhook"| wf_scan + + %% lifecycle workflows -> render-email + Slack + wf_life -->|"render and send mail"| wf_render + wf_life -.->|"notify (received + approved)"| sl + + %% render-email -> Strapi + SendGrid + wf_render -->|"fetch template"| ep_tpl + wf_render -->|"send email"| sg + + %% security-scan -> external + Strapi callback + wf_scan -->|"package.json + dep advisories"| reg + wf_scan -->|"AI analysis"| ai + wf_scan -->|"PATCH stage results"| ep_scan + + %% schedules -> Strapi + wf_sweep -->|"find stale running scans"| ep_stale + wf_sweep -->|"mark timed-out as failed"| ep_scan + wf_sweep -.->|"notify"| sl + wf_clean -->|"find stale 'new' + strip label"| ep_pkg + + %% errors + wf_err -.->|"on any workflow failure"| sl +``` + +The 7 lifecycle workflows have **no** Strapi URL of their own — they send mail through +the shared `render-email` sub-workflow, which is the only one that fetches templates. +Only **4 workflows** call Strapi directly (render-email, security-scan, +scan-timeout-sweeper, cleanup). + +## Deploy to an environment (recommended) + +`workflows:deploy` imports every workflow into a target instance with the +environment's Strapi base URL and webhook namespace applied, and re-links the by-id +references automatically. Imported **inactive**. Safe to re-run: it re-attaches existing +credential bindings by node name, so a re-deploy won't wipe the credentials you bound. + +```bash +N8N_URL=https://n8n.tools.strapi.team \ +N8N_API_KEY= \ +STRAPI_BASE_URL=https:// \ +N8N_WEBHOOK_NAMESPACE=staging \ # omit/`strapi` for production +pnpm --filter automation run workflows:deploy +``` + +It (1) rewrites every HTTP node's base URL from the committed `http://localhost:1337` +placeholder to `STRAPI_BASE_URL`, (2) rewrites webhook paths to +`/`, and (3) re-points `executeWorkflow → render-email` and +`Settings → Error Workflow → error-handler` to the target instance's ids. + +> **Plain `workflows:import`** is the no-substitution variant (local dev): matches by +> name, updates in place, but leaves the `localhost` base URL and `strapi/` paths and +> does **not** re-link — fine for `localhost:5678` against a local Strapi. + +### After deploy (manual, once per set) +1. **Bind the 5 credentials** in the n8n UI (next section). +2. **Activate** the 8 webhook workflows + `render-email` (+ the two schedule workflows when wanted). + +## n8n credentials (bound in the UI, per set) + +There are **no n8n environment variables** — the Strapi token lives in a credential. + +| Credential | Type | Value | Bound to (nodes) | +|---|---|---|---| +| **Strapi API** | HTTP Header Auth | `Authorization` = `Bearer ` | the **9 Strapi HTTP nodes**: render-email *Fetch Template*; security-scan *PATCH Scan/AI/Summary*; scan-timeout-sweeper *Find Stale ×2 + Mark Failed*; cleanup *Fetch Stale + Strip Label* | +| **n8n Webhook Auth** | HTTP Header Auth | `X-N8N-Auth` = `` | the **8 webhook trigger nodes**. Must equal Strapi `N8N_WEBHOOK_AUTH_VALUE`. | +| **SendGrid** | SendGrid API | n8n SendGrid key (community@strapi.io account) | render-email *Send via SendGrid* | +| **Slack** | Slack API (bot token) | bot token for `#integration-marketplace` | the **6 Slack notify nodes** | +| **Anthropic** | Anthropic API (predefined) | Anthropic API key | security-scan *Claude Haiku Security Analysis* (node uses *Predefined Credential Type → Anthropic*) | + +> The two Header Auth credentials use different header names (`Authorization` vs +> `X-N8N-Auth`) — create them separately. + +## Base URL (handled by the deploy script) + +The Strapi base URL appears in **9 HTTP nodes across 4 workflows** +(security-scan ×3, scan-timeout-sweeper ×3, cleanup ×2, render-email ×1); the other 8 +workflows have none. `workflows:deploy` sets all 9 from `STRAPI_BASE_URL`, so you never +hand-edit them. It can't live in a credential (HTTP Request credentials are auth-only) +or an n8n Variable (instance-wide — can't differ between the two sets on one instance), +so it's a per-set node value that the deploy script fills. + +## Strapi environment variables (per instance) + +| Variable | Value | +|---|---| +| `N8N_WEBHOOK_BASE_URL` | this environment's n8n base URL | +| `N8N_WEBHOOK_MODE` | `production` (always-on `/webhook/...` paths; workflows must be **active**) | +| `N8N_WEBHOOK_NAMESPACE` | `strapi` (prod, default) or `staging` — must match the deployed set's webhook paths | +| `N8N_WEBHOOK_AUTH_HEADER` | `X-N8N-Auth` | +| `N8N_WEBHOOK_AUTH_VALUE` | the shared secret — must equal the **n8n Webhook Auth** credential value | +| `CLOUD_APP_URL` | Strapi admin URL (builds `dashboard_link` in Slack; auto-set on Strapi Cloud) | +| `SENDGRID_API_KEY` | Strapi-native email only (password resets / admin invites) — **separate** from n8n's SendGrid key | + +Also generate a **Strapi API token** (admin → Settings → API Tokens; full-access for +first bring-up) → paste into the **Strapi API** n8n credential. It needs read on +`email-template` and access to the `moderation` content-API routes +(`/:plural/:documentId/security-scan-result`, `/:plural/stale-scans`). + +## Notes / gotchas + +- **Versions are pinned for portability:** HTTP Request nodes at `typeVersion 4.4`, n8n + image at `n8nio/n8n:2.27.4`. If a node shows *"install this node / from a newer + version of n8n,"* the target instance is older — bump it, or lower the `typeVersion`. +- **`approved` requires a publishable record:** publishing a package/template needs + `slug` (and `description` for packages) set first, or publish 400s and the `approved` + webhook never fires. Reviewers set these before approving. +- **Webhook namespace** comes from the CMS plugin config (`N8N_WEBHOOK_NAMESPACE`, + default `strapi`) in `config/plugins.ts` + `config/env/production/plugins.ts`. diff --git a/apps/automation/workflows/cleanup-new-label-after-60d/workflow.json b/apps/automation/workflows/cleanup-new-label-after-60d/workflow.json index dba400a..6c31df9 100644 --- a/apps/automation/workflows/cleanup-new-label-after-60d/workflow.json +++ b/apps/automation/workflows/cleanup-new-label-after-60d/workflow.json @@ -61,7 +61,7 @@ ], "parameters": { "method": "GET", - "url": "={{ $env.STRAPI_BASE_URL }}/api/packages", + "url": "=http://localhost:1337/api/packages", "sendQuery": true, "queryParameters": { "parameters": [ @@ -95,16 +95,9 @@ } ] }, - "sendHeaders": true, - "headerParameters": { - "parameters": [ - { - "name": "Authorization", - "value": "=Bearer {{ $env.STRAPI_API_TOKEN }}" - } - ] - }, - "options": {} + "options": {}, + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth" }, "retryOnFail": true, "notes": "TODO: add pagination loop once we expect >100 stale packages. Depends on #21's 'is_new' field being added to the package 'labels' component (not yet present in the schema)." @@ -134,14 +127,10 @@ ], "parameters": { "method": "PUT", - "url": "={{ $env.STRAPI_BASE_URL }}/api/packages/{{ $json.id }}", + "url": "=http://localhost:1337/api/packages/{{ $json.id }}", "sendHeaders": true, "headerParameters": { "parameters": [ - { - "name": "Authorization", - "value": "=Bearer {{ $env.STRAPI_API_TOKEN }}" - }, { "name": "Content-Type", "value": "application/json" @@ -151,7 +140,9 @@ "sendBody": true, "contentType": "json", "jsonBody": "={\n \"data\": {\n \"labels\": {\n \"is_new\": false\n }\n }\n}", - "options": {} + "options": {}, + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth" }, "retryOnFail": true, "onError": "continueRegularOutput", diff --git a/apps/automation/workflows/render-email-shared-sub-workflow/workflow.json b/apps/automation/workflows/render-email-shared-sub-workflow/workflow.json index 98ebe58..6fc40d6 100644 --- a/apps/automation/workflows/render-email-shared-sub-workflow/workflow.json +++ b/apps/automation/workflows/render-email-shared-sub-workflow/workflow.json @@ -46,7 +46,7 @@ ], "parameters": { "method": "GET", - "url": "={{ $env.STRAPI_BASE_URL }}/api/email-templates", + "url": "=http://localhost:1337/api/email-templates", "sendQuery": true, "queryParameters": { "parameters": [ @@ -60,16 +60,9 @@ } ] }, - "sendHeaders": true, - "headerParameters": { - "parameters": [ - { - "name": "Authorization", - "value": "=Bearer {{ $env.STRAPI_API_TOKEN }}" - } - ] - }, - "options": {} + "options": {}, + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth" }, "retryOnFail": true, "notes": "TODO: swap env-var auth for an n8n 'Strapi' credential once the CMS token is issued" diff --git a/apps/automation/workflows/scan-timeout-sweeper/workflow.json b/apps/automation/workflows/scan-timeout-sweeper/workflow.json index da1024b..822622c 100644 --- a/apps/automation/workflows/scan-timeout-sweeper/workflow.json +++ b/apps/automation/workflows/scan-timeout-sweeper/workflow.json @@ -57,15 +57,15 @@ "id": "fetch-plugins-1", "name": "Find Stale Plugin Scans", "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, + "typeVersion": 4.4, "position": [ 600, 360 ], "parameters": { "method": "GET", - "url": "={{ $env.STRAPI_BASE_URL }}/api/moderation/packages/stale-scans", - "authentication": "none", + "url": "=http://localhost:1337/api/moderation/packages/stale-scans", + "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "sendQuery": true, "queryParameters": { @@ -84,15 +84,6 @@ "responseFormat": "json" } } - }, - "sendHeaders": true, - "headerParameters": { - "parameters": [ - { - "name": "Authorization", - "value": "=Bearer {{ $env.STRAPI_API_TOKEN }}" - } - ] } }, "onError": "continueRegularOutput", @@ -105,15 +96,15 @@ "id": "fetch-templates-1", "name": "Find Stale Template Scans", "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, + "typeVersion": 4.4, "position": [ 600, 560 ], "parameters": { "method": "GET", - "url": "={{ $env.STRAPI_BASE_URL }}/api/moderation/templates/stale-scans", - "authentication": "none", + "url": "=http://localhost:1337/api/moderation/templates/stale-scans", + "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "sendQuery": true, "queryParameters": { @@ -132,15 +123,6 @@ "responseFormat": "json" } } - }, - "sendHeaders": true, - "headerParameters": { - "parameters": [ - { - "name": "Authorization", - "value": "=Bearer {{ $env.STRAPI_API_TOKEN }}" - } - ] } }, "onError": "continueRegularOutput", @@ -168,15 +150,15 @@ "id": "mark-failed-1", "name": "Mark Failed in Strapi", "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, + "typeVersion": 4.4, "position": [ 1040, 460 ], "parameters": { "method": "POST", - "url": "={{ $env.STRAPI_BASE_URL }}/api/moderation/{{ $json.kind === 'template' ? 'templates' : 'packages' }}/{{ $json.documentId }}/security-scan-result", - "authentication": "none", + "url": "=http://localhost:1337/api/moderation/{{ $json.kind === 'template' ? 'templates' : 'packages' }}/{{ $json.documentId }}/security-scan-result", + "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "sendBody": true, "contentType": "json", @@ -184,15 +166,6 @@ "jsonBody": "={{ JSON.stringify({ stage: 'summary', status: 'failed', result: { runAt: new Date().toISOString(), error: 'Scan exceeded the 30-minute timeout and was reaped by scan-timeout-sweeper.', passed: false, reaped_at: new Date().toISOString(), started_at: $json.started_at } }) }}", "options": { "timeout": 15000 - }, - "sendHeaders": true, - "headerParameters": { - "parameters": [ - { - "name": "Authorization", - "value": "=Bearer {{ $env.STRAPI_API_TOKEN }}" - } - ] } }, "onError": "continueRegularOutput", diff --git a/apps/automation/workflows/security-scan/workflow.json b/apps/automation/workflows/security-scan/workflow.json index baccab7..2530ac1 100644 --- a/apps/automation/workflows/security-scan/workflow.json +++ b/apps/automation/workflows/security-scan/workflow.json @@ -122,7 +122,7 @@ "id": "deps-fetch-1", "name": "Fetch Repo package.json", "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, + "typeVersion": 4.4, "position": [ 820, 480 @@ -166,7 +166,7 @@ "id": "osv-http-1", "name": "OSV.dev Batch Query", "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, + "typeVersion": 4.4, "position": [ 1260, 480 @@ -207,15 +207,15 @@ "id": "deps-patch-1", "name": "PATCH Scan Result", "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, + "typeVersion": 4.4, "position": [ 1700, 480 ], "parameters": { "method": "POST", - "url": "={{ $env.STRAPI_BASE_URL }}/api/moderation/packages/{{ $('Extract Payload').item.json.packageId }}/security-scan-result", - "authentication": "none", + "url": "=http://localhost:1337/api/moderation/packages/{{ $('Extract Payload').item.json.packageId }}/security-scan-result", + "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "sendBody": true, "contentType": "json", @@ -223,15 +223,6 @@ "jsonBody": "={{ JSON.stringify({ stage: $json.stage, result: $json.result }) }}", "options": { "timeout": 15000 - }, - "sendHeaders": true, - "headerParameters": { - "parameters": [ - { - "name": "Authorization", - "value": "=Bearer {{ $env.STRAPI_API_TOKEN }}" - } - ] } }, "onError": "continueRegularOutput", @@ -260,7 +251,7 @@ "id": "ai-http-1", "name": "Claude Haiku Security Analysis", "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, + "typeVersion": 4.4, "position": [ 1040, 800 @@ -268,8 +259,7 @@ "parameters": { "method": "POST", "url": "https://api.anthropic.com/v1/messages", - "authentication": "genericCredentialType", - "genericAuthType": "httpHeaderAuth", + "authentication": "predefinedCredentialType", "sendHeaders": true, "headerParameters": { "parameters": [ @@ -285,7 +275,8 @@ "jsonBody": "={{ JSON.stringify({ model: 'claude-haiku-4-5', max_tokens: 1024, system: 'You are a security auditor reviewing a Strapi community submission. You are given package metadata, install-time scripts, the README, and ACTUAL SOURCE FILES from the published package (or source repo for templates). Read the source files carefully \u2014 runtime exfiltration, credential theft, obfuscation, and suspicious network calls often hide in main entry files or bin scripts, not the README. Prioritise supply-chain signals: install-time scripts, typosquatting, mismatches between the published artifact and its source repo, obfuscated or minified code, dynamic eval / Function constructor use, network calls to unknown hosts, filesystem writes outside package dir. Respond ONLY with valid JSON matching this schema: {\"risk_level\":\"low|medium|high\",\"summary\":\"\",\"concerns\":[\"\",...],\"red_flags\":[\"\",...],\"recommendation\":\"approve|request_changes|reject\"}. Do not include explanation text outside the JSON.', messages: [{ role: 'user', content: `Submission kind: ${$json.submission_kind}\\nName: ${$json.plugin_name}\\npackage_location: ${$json.package_location || '(none \u2014 template or no registry URL)'}\\nregistry: ${$json.package_info?.registry || 'n/a'}\\nregistry_available: ${$json.package_info?.available ? 'yes' : 'no'}${$json.package_info?.notImplemented ? ' (deep scan not yet implemented for this registry)' : ''}\\nVersion: ${$json.package_info?.version || 'n/a'}\\nSubmitted repo: ${$json.repository_url}\\nDeclared repo on published artifact: ${$json.package_info?.declaredRepository || '(none declared)'}\\n\\nInstall-time scripts on the published artifact (immediate red flag if any are non-trivial):\\n${JSON.stringify($json.package_info?.installScripts || {}, null, 2)}\\n\\nSource files scanned: ${$json.files_scanned} of ${$json.files_available} (${$json.bytes_scanned} bytes, source: ${$json.scan_source || 'none'}).\\n\\nREADME:\\n---\\n${(($json.package_info?.readme || $json.readme) || '(no README available)').toString().slice(0, 8000)}\\n---\\n\\nSOURCE FILES (truncated per-file + overall):\\n---\\n${$json.package_code || '(no source files could be fetched)'}\\n---\\n\\nReview the code above against the schema. Output JSON only.` }] }) }}", "options": { "timeout": 45000 - } + }, + "nodeCredentialType": "anthropicApi" }, "onError": "continueRegularOutput", "retryOnFail": true, @@ -312,15 +303,15 @@ "id": "ai-patch-1", "name": "PATCH AI Result", "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, + "typeVersion": 4.4, "position": [ 1480, 800 ], "parameters": { "method": "POST", - "url": "={{ $env.STRAPI_BASE_URL }}/api/moderation/packages/{{ $('Extract Payload').item.json.packageId }}/security-scan-result", - "authentication": "none", + "url": "=http://localhost:1337/api/moderation/packages/{{ $('Extract Payload').item.json.packageId }}/security-scan-result", + "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "sendBody": true, "contentType": "json", @@ -328,15 +319,6 @@ "jsonBody": "={{ JSON.stringify({ stage: $json.stage, result: $json.result }) }}", "options": { "timeout": 15000 - }, - "sendHeaders": true, - "headerParameters": { - "parameters": [ - { - "name": "Authorization", - "value": "=Bearer {{ $env.STRAPI_API_TOKEN }}" - } - ] } }, "onError": "continueRegularOutput", @@ -382,15 +364,15 @@ "id": "summary-patch-1", "name": "PATCH Summary + Complete", "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, + "typeVersion": 4.4, "position": [ 2360, 640 ], "parameters": { "method": "POST", - "url": "={{ $env.STRAPI_BASE_URL }}/api/moderation/packages/{{ $('Extract Payload').item.json.packageId }}/security-scan-result", - "authentication": "none", + "url": "=http://localhost:1337/api/moderation/packages/{{ $('Extract Payload').item.json.packageId }}/security-scan-result", + "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "sendBody": true, "contentType": "json", @@ -398,15 +380,6 @@ "jsonBody": "={{ JSON.stringify({ stage: $json.stage, result: $json.result, status: $json.status }) }}", "options": { "timeout": 15000 - }, - "sendHeaders": true, - "headerParameters": { - "parameters": [ - { - "name": "Authorization", - "value": "=Bearer {{ $env.STRAPI_API_TOKEN }}" - } - ] } }, "onError": "continueRegularOutput", diff --git a/apps/cms/.env.example b/apps/cms/.env.example index 43acf50..ea12fc3 100644 --- a/apps/cms/.env.example +++ b/apps/cms/.env.example @@ -43,8 +43,6 @@ ENABLE_MIGRATION=false GITHUB_ACCESS_TOKEN=tobemodified -SCREENSHOTONE_KEY=tobemodified - # SendGrid — used for Strapi-native emails (password resets, admin invites). # Automation emails (submission received, approved, etc.) run through n8n with # a separate SendGrid key. See github.com/strapi/community/issues/54. diff --git a/apps/cms/config/admin.ts b/apps/cms/config/admin.ts index b7fdd1b..b746092 100644 --- a/apps/cms/config/admin.ts +++ b/apps/cms/config/admin.ts @@ -1,5 +1,17 @@ import { Strategy as GoogleStrategy } from "passport-google-oauth2"; +const verify = (request, accessToken, refreshToken, profile, done) => { + if (profile && profile.email && profile.email.endsWith("@strapi.io")) { + done(null, { + email: profile.email, + firstname: profile.given_name, + lastname: profile.family_name, + }); + } else { + done(new Error("Forbidden email address")); + } +}; + export default ({ env }) => ({ auth: { secret: env("ADMIN_JWT_SECRET"), @@ -18,15 +30,11 @@ export default ({ env }) => ({ "https://www.googleapis.com/auth/userinfo.profile", ], callbackURL: + env("CLOUD_APP_URL") + strapi.admin.services.passport.getStrategyCallbackURL("google"), + passReqToCallback: true, }, - (request, accessToken, refreshToken, profile, done) => { - done(null, { - email: profile.email, - firstname: profile.given_name, - lastname: profile.family_name, - }); - }, + verify, ), }, ], diff --git a/apps/cms/config/env/production/admin.ts b/apps/cms/config/env/production/admin.ts new file mode 100644 index 0000000..a0e1541 --- /dev/null +++ b/apps/cms/config/env/production/admin.ts @@ -0,0 +1,45 @@ +import { Strategy as GoogleStrategy } from "passport-google-oauth2"; + +const verify = (request, accessToken, refreshToken, profile, done) => { + if (profile && profile.email && profile.email.endsWith("@strapi.io")) { + done(null, { + email: profile.email, + firstname: profile.given_name, + lastname: profile.family_name, + }); + } else { + done(new Error("Forbidden email address")); + } +}; + +export default ({ env }) => ({ + auth: { + providers: [ + { + uid: "google", + displayName: "Google", + icon: "https://cdn2.iconfinder.com/data/icons/social-icons-33/128/Google-512.png", + createStrategy: (strapi) => + new GoogleStrategy( + { + clientID: env("GOOGLE_CLIENT_ID"), + clientSecret: env("GOOGLE_CLIENT_SECRET"), + scope: [ + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + ], + callbackURL: + env("CLOUD_APP_URL") + + strapi.admin.services.passport.getStrategyCallbackURL("google"), + passReqToCallback: true, + }, + verify, + ), + }, + ], + }, + flags: { + nps: env.bool("FLAG_NPS", false), + promoteEE: env.bool("FLAG_PROMOTE_EE", false), + }, +}); diff --git a/apps/cms/config/env/production/plugins.ts b/apps/cms/config/env/production/plugins.ts index 6df5117..414133d 100644 --- a/apps/cms/config/env/production/plugins.ts +++ b/apps/cms/config/env/production/plugins.ts @@ -1,3 +1,8 @@ +// n8n webhook path namespace — mirrors config/plugins.ts so the moderation webhook +// paths resolve correctly on Strapi Cloud (which uses the production config). +// Defaults to "strapi"; set N8N_WEBHOOK_NAMESPACE per environment (e.g. "staging"). +const N8N_WEBHOOK_NS = process.env.N8N_WEBHOOK_NAMESPACE || "strapi"; + export default ({ env }) => ({ email: { config: { @@ -18,4 +23,51 @@ export default ({ env }) => ({ }, }, }, + moderation: { + enabled: true, + resolve: "./src/plugins/moderation", + config: { + contentTypes: [ + { + uid: "api::package.package", + singularName: "package", + pluralName: "packages", + label: "Plugins", + categoryUid: "api::package-category.package-category", + defaultFieldValues: { + labels: { official: false, featured: false, paid: false }, + }, + checks: [ + "repo_public", + "readme_exists", + "mit_license", + "strapi_peer_dep", + "enterprise_competition", + ], + webhooks: { + submissionReceived: `${N8N_WEBHOOK_NS}/plugin-submission-received`, + approved: `${N8N_WEBHOOK_NS}/plugin-approved`, + declined: `${N8N_WEBHOOK_NS}/plugin-declined`, + changesRequested: `${N8N_WEBHOOK_NS}/plugin-changes-requested`, + }, + }, + { + uid: "api::template.template", + singularName: "template", + pluralName: "templates", + label: "Templates", + categoryUid: "api::template-category.template-category", + defaultFieldValues: { + labels: { official: false, featured: false, paid: false }, + }, + checks: ["repo_public", "readme_exists", "mit_license"], + webhooks: { + submissionReceived: `${N8N_WEBHOOK_NS}/template-submission-received`, + approved: `${N8N_WEBHOOK_NS}/template-approved`, + declined: `${N8N_WEBHOOK_NS}/template-declined`, + }, + }, + ], + }, + }, }); diff --git a/apps/cms/config/plugins.ts b/apps/cms/config/plugins.ts index 5dd829b..f06db6a 100644 --- a/apps/cms/config/plugins.ts +++ b/apps/cms/config/plugins.ts @@ -1,3 +1,8 @@ +// n8n webhook path namespace. Each Strapi instance posts to "/", +// so duplicate workflow sets sharing one n8n instance (e.g. staging vs production) +// listen on distinct paths and never collide. Defaults to "strapi" (production). +const N8N_WEBHOOK_NS = process.env.N8N_WEBHOOK_NAMESPACE || "strapi"; + export default ({ env }) => ({ email: { config: { @@ -37,10 +42,10 @@ export default ({ env }) => ({ "enterprise_competition", ], webhooks: { - submissionReceived: "strapi/plugin-submission-received", - approved: "strapi/plugin-approved", - declined: "strapi/plugin-declined", - changesRequested: "strapi/plugin-changes-requested", + submissionReceived: `${N8N_WEBHOOK_NS}/plugin-submission-received`, + approved: `${N8N_WEBHOOK_NS}/plugin-approved`, + declined: `${N8N_WEBHOOK_NS}/plugin-declined`, + changesRequested: `${N8N_WEBHOOK_NS}/plugin-changes-requested`, }, }, { @@ -54,9 +59,9 @@ export default ({ env }) => ({ }, checks: ["repo_public", "readme_exists", "mit_license"], webhooks: { - submissionReceived: "strapi/template-submission-received", - approved: "strapi/template-approved", - declined: "strapi/template-declined", + submissionReceived: `${N8N_WEBHOOK_NS}/template-submission-received`, + approved: `${N8N_WEBHOOK_NS}/template-approved`, + declined: `${N8N_WEBHOOK_NS}/template-declined`, }, }, ], diff --git a/apps/cms/src/api/cta/content-types/cta/schema.json b/apps/cms/src/api/cta/content-types/cta/schema.json index ee48b12..2a2472b 100644 --- a/apps/cms/src/api/cta/content-types/cta/schema.json +++ b/apps/cms/src/api/cta/content-types/cta/schema.json @@ -36,12 +36,6 @@ "component": "shared.button", "repeatable": false, "required": true - }, - "illustration": { - "type": "enumeration", - "required": true, - "default": "image", - "enum": ["image", "community_members"] } } } diff --git a/apps/cms/src/index.ts b/apps/cms/src/index.ts index 730e201..3dd0190 100644 --- a/apps/cms/src/index.ts +++ b/apps/cms/src/index.ts @@ -3,7 +3,6 @@ import { migrateIntegrations } from "./migration/integrations"; import { migratePartners } from "./migration/partners"; import { migratePlugins } from "./migration/plugins"; import { migrateProviders } from "./migration/providers"; -import { migrateShowcases } from "./migration/showcases"; import { seedEmailTemplates } from "./seed/email-templates"; export default { @@ -20,6 +19,5 @@ export default { await migrateIntegrations(); await migratePlugins(); await migrateProviders(); - await migrateShowcases(); }, }; diff --git a/apps/cms/src/migration/showcases.ts b/apps/cms/src/migration/showcases.ts deleted file mode 100644 index b4b096f..0000000 --- a/apps/cms/src/migration/showcases.ts +++ /dev/null @@ -1,132 +0,0 @@ -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import { parse } from "yaml"; -import { createCategories, uploadFile } from "./utils"; - -const GITHUB_API_URL = - "https://api.github.com/repos/strapi/community-content/contents/showcase/sites.yml?ref=master"; - -type ShowcaseSite = { - title: string; - url: string; - description: string; - categories?: string[]; - frontend?: string[]; - made_by?: string; - made_by_url?: string; -}; - -const captureScreenshot = async (url: string, title: string) => { - const params = new URLSearchParams({ - access_key: process.env.SCREENSHOTONE_KEY, - url, - viewport_width: "1024", - viewport_height: "768", - format: "jpg", - block_ads: "true", - block_cookie_banners: "true", - block_banners_by_heuristics: "false", - block_trackers: "true", - delay: "3", - timeout: "20", - response_type: "by_format", - image_quality: "80", - }); - - const response = await fetch(`https://api.screenshotone.com/take?${params}`, { - signal: AbortSignal.timeout(90_000), - }); - - if (!response.ok) { - throw new Error( - `Screenshot API returned ${response.status} for ${url}. ${await response.text()}`, - ); - } - - const buffer = Buffer.from(await response.arrayBuffer()); - const tmpPath = path.join( - os.tmpdir(), - `screenshot-${Date.now()}-${Math.random().toString(36).slice(2)}.jpg`, - ); - - await fs.promises.writeFile(tmpPath, buffer); - - try { - return await uploadFile(tmpPath, `${title}.jpg`, "image/jpeg"); - } finally { - await fs.promises.unlink(tmpPath).catch(() => {}); - } -}; - -export const migrateShowcases = async () => { - strapi.log.info("Starting showcases migration..."); - let migrated = 0; - let skipped = 0; - let failed = 0; - - try { - const response = await fetch(GITHUB_API_URL, { - headers: { Accept: "application/vnd.github.v3+json" }, - }); - - if (!response.ok) { - throw new Error(`GitHub API request failed: ${response.statusText}`); - } - - const { content } = (await response.json()) as { content: string }; - const yamlContent = Buffer.from(content, "base64").toString("utf-8"); - const sites = parse(yamlContent) as ShowcaseSite[]; - - for (const site of sites) { - const existing = await strapi - .documents("api::showcase.showcase") - .findFirst({ filters: { url: site.url } }); - - if (existing) { - skipped++; - continue; - } - - const categoryIds = await createCategories( - "api::showcase-category.showcase-category", - site.categories ?? [], - ); - - const techStackIds = await createCategories( - "api::tech-stack.tech-stack", - site.frontend ?? [], - ); - - let image = null; - try { - image = await captureScreenshot(site.url, site.title); - } catch (err) { - strapi.log.warn( - `Failed to capture screenshot for ${site.url}, skipping.`, - err, - ); - failed++; - continue; - } - - await strapi.documents("api::showcase.showcase").create({ - data: { - title: site.title, - url: site.url, - description: site.description, - categories: categoryIds, - tech_stacks: techStackIds, - ...(image ? { image: image.id } : {}), - }, - }); - migrated++; - } - } catch (error) { - strapi.log.error("Error migrating showcases:", error); - } finally { - strapi.log.info( - `Showcases migration finished. Migrated: ${migrated}, Skipped: ${skipped}, Failed: ${failed}`, - ); - } -}; diff --git a/apps/cms/src/plugins/moderation/server/src/services/n8n-webhook.ts b/apps/cms/src/plugins/moderation/server/src/services/n8n-webhook.ts index dcc4015..4a8007e 100644 --- a/apps/cms/src/plugins/moderation/server/src/services/n8n-webhook.ts +++ b/apps/cms/src/plugins/moderation/server/src/services/n8n-webhook.ts @@ -3,15 +3,19 @@ * * Accepts webhook paths directly (e.g. "strapi/plugin-submission-received"). * Paths come from the plugin config's `webhooks` object per content type. - * The SECURITY_SCAN_PATH constant is hardcoded because security scanning is - * not configurable — it always applies to api::package.package. + * The security-scan path is fixed per namespace (security scanning is not a + * configurable content-type webhook — it always applies to api::package.package), + * but shares the N8N_WEBHOOK_NAMESPACE prefix so duplicate workflow sets on a + * shared n8n instance (e.g. staging vs production) don't collide. * * Flipping N8N_WEBHOOK_MODE between 'production' and 'test' toggles all * webhooks between n8n's always-on prod URL (/webhook/) and the * developer-only test URL (/webhook-test/). */ -export const SECURITY_SCAN_PATH = "strapi/security-scan"; +const N8N_WEBHOOK_NS = process.env.N8N_WEBHOOK_NAMESPACE || "strapi"; + +export const SECURITY_SCAN_PATH = `${N8N_WEBHOOK_NS}/security-scan`; function getWebhookUrl(path: string) { const base = process.env.N8N_WEBHOOK_BASE_URL; diff --git a/apps/cms/types/generated/contentTypes.d.ts b/apps/cms/types/generated/contentTypes.d.ts index 6f20242..b8e88e1 100644 --- a/apps/cms/types/generated/contentTypes.d.ts +++ b/apps/cms/types/generated/contentTypes.d.ts @@ -513,9 +513,6 @@ export interface ApiCtaCta extends Struct.CollectionTypeSchema { createdAt: Schema.Attribute.DateTime; createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & Schema.Attribute.Private; - illustration: Schema.Attribute.Enumeration<['image', 'community_members']> & - Schema.Attribute.Required & - Schema.Attribute.DefaultTo<'image'>; image: Schema.Attribute.Media<'images'> & Schema.Attribute.Required; locale: Schema.Attribute.String & Schema.Attribute.Private; localizations: Schema.Attribute.Relation<'oneToMany', 'api::cta.cta'> & @@ -1522,10 +1519,10 @@ export interface PluginBetterAuthJwks extends Struct.CollectionTypeSchema { }; pluginOptions: { 'content-manager': { - visible: false; + visible: true; }; 'content-type-builder': { - visible: false; + visible: true; }; }; attributes: { diff --git a/apps/web/public/template-fallback-preview.png b/apps/web/public/template-fallback-preview.png index e78fbea..d37723a 100644 Binary files a/apps/web/public/template-fallback-preview.png and b/apps/web/public/template-fallback-preview.png differ diff --git a/apps/web/src/components/content/avatar-pile/avatar-pile.tsx b/apps/web/src/components/content/avatar-pile/avatar-pile.tsx index 9b3eca8..8a18526 100644 --- a/apps/web/src/components/content/avatar-pile/avatar-pile.tsx +++ b/apps/web/src/components/content/avatar-pile/avatar-pile.tsx @@ -7,6 +7,7 @@ type Props = { items: Data.ContentType<"plugin::better-auth.user">[]; size?: "S" | "L"; clickable?: boolean; + white?: boolean; }; // Percentage widths per slot (outer → center → outer), relative to the container. @@ -60,7 +61,7 @@ const AvatarLarge = ({ ); }; -const AvatarPile = ({ items, clickable, size = "S" }: Props) => { +const AvatarPile = ({ items, clickable, size = "S", white = false }: Props) => { if (!items?.length) return null; if (size === "L") { @@ -91,9 +92,7 @@ const AvatarPile = ({ items, clickable, size = "S" }: Props) => { style={{ marginLeft: i === 0 ? 0 : -8 }} /> {items.filter(Boolean).length === 1 && ( - - {items.find(Boolean)?.name} - + {items.find(Boolean)?.name} )} ) : ( @@ -109,7 +108,7 @@ const AvatarPile = ({ items, clickable, size = "S" }: Props) => { {m.name?.[0]} {items.filter(Boolean).length === 1 && ( - + {items.find(Boolean)?.name} )} diff --git a/apps/web/src/components/layout/breadcrumbs/breadcrumbs.tsx b/apps/web/src/components/layout/breadcrumbs/breadcrumbs.tsx index e0d8f5c..6e1aece 100644 --- a/apps/web/src/components/layout/breadcrumbs/breadcrumbs.tsx +++ b/apps/web/src/components/layout/breadcrumbs/breadcrumbs.tsx @@ -1,6 +1,6 @@ "use client"; -import { ChevronRight } from "lucide-react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; @@ -29,7 +29,6 @@ const Breadcrumbs = ({ variant = "full" }: BreadcrumbsProps) => { })), ]; - // "short" variant: home + direct parent + current (skip intermediate segments) const crumbs = variant === "short" && allCrumbs.length > 3 ? [ @@ -39,35 +38,51 @@ const Breadcrumbs = ({ variant = "full" }: BreadcrumbsProps) => { ] : allCrumbs; + const parent = crumbs.length > 1 ? crumbs[crumbs.length - 2] : null; + return ( ); }; diff --git a/apps/web/src/components/layout/hero/home-hero/home-hero.tsx b/apps/web/src/components/layout/hero/home-hero/home-hero.tsx index 345a32f..960dc89 100644 --- a/apps/web/src/components/layout/hero/home-hero/home-hero.tsx +++ b/apps/web/src/components/layout/hero/home-hero/home-hero.tsx @@ -5,7 +5,8 @@ import type { Data } from "@strapi/types"; import { Code2, LayoutTemplate, Search } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; -import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; import { AvatarPile } from "@/components/content/avatar-pile"; import { Breadcrumbs } from "@/components/layout/breadcrumbs"; import { Hero, HeroSection } from "@/components/layout/hero/hero"; @@ -50,23 +51,55 @@ type Props = { const HomeHero = (props: Props) => { const { title, ctaText, ctaTitle, ctaButtons, packages, templates } = props; + const router = useRouter(); const [activeTab, setActiveTab] = useState("packages"); + const [searchQuery, setSearchQuery] = useState(""); + + const tabScrollRef = useRef(null); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + + useEffect(() => { + const el = tabScrollRef.current; + if (!el) return; + const update = () => { + setCanScrollLeft(el.scrollLeft > 4); + setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4); + }; + update(); + el.addEventListener("scroll", update, { passive: true }); + const ro = new ResizeObserver(update); + ro.observe(el); + return () => { + el.removeEventListener("scroll", update); + ro.disconnect(); + }; + }, []); return ( -
- {/* Left: Main heading & search */} -
+
+ {/* Main: heading & search */} +
-

+

{title}

-
+
setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && searchQuery.trim()) { + router.push( + `/marketplace?query=${encodeURIComponent(searchQuery.trim())}`, + ); + } + }} placeholder="Search Community" className="w-full rounded-lg border border-(--color-grey700) px-4 py-3.5 pr-12 text-sm text-white placeholder:text-(--color-hero-nav-muted) focus:outline-none focus:ring-1 focus:ring-(--color-primary500)" style={{ @@ -78,8 +111,8 @@ const HomeHero = (props: Props) => {
- {/* Right: Join Community CTA */} -
+ {/* CTA: Join Community */} +

{ctaTitle}

@@ -104,35 +137,51 @@ const HomeHero = (props: Props) => { {/* Bottom section: Tabs + Cards */} {/* Tab bar */} -
- {tabs.map((tab) => ( - - ))} +
+
+
+ {tabs.map((tab) => ( + + ))} +
+
{/* Cards grid */} -
+
{activeTab === "packages" && packages.map((pkg) => ( -
+
- {/* Preview placeholder */} -
+
{

{pkg.description}

- {/* Avatar pile */}
- +
))} {activeTab === "templates" && templates.map((template) => ( -
+
- {/* Preview placeholder */} -
+
{ template.name ?? "" } - className="rounded-lg border border-(--color-grey700)" + className="rounded-lg border border-(--color-grey700) object-cover" />

@@ -185,9 +232,8 @@ const HomeHero = (props: Props) => {

{template.description}

- {/* Avatar pile */}
- +

@@ -197,7 +243,7 @@ const HomeHero = (props: Props) => { {/* View More button */} {activeTab === "templates" && ( -
+
)} {activeTab === "packages" && ( -
+
+ + {isOpen && ( + <> + + )) + )} +
+
+ +
+ + )} + + ); +}; + +export { MobileNav }; diff --git a/apps/web/src/components/layout/navigation/navigation.tsx b/apps/web/src/components/layout/navigation/navigation.tsx index 5af4cf1..a863431 100644 --- a/apps/web/src/components/layout/navigation/navigation.tsx +++ b/apps/web/src/components/layout/navigation/navigation.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { AuthNavigation } from "@/features/auth/components/auth-navigation"; import { isAuthEnabled } from "@/features/auth/lib/is-enabled"; import { cmsClient } from "@/features/cms/lib/strapi"; +import { MobileNav } from "./mobile-nav"; type Props = { theme: "light" | "dark"; @@ -49,7 +50,7 @@ const Navigation = async ({ theme }: Props) => {
  • {link.label} @@ -60,15 +61,23 @@ const Navigation = async ({ theme }: Props) => {
  • - {isAuthEnabled ? ( - - ) : ( - data.data.cta_links?.map((link) => ( - - )) - )} +
    + {isAuthEnabled ? ( + + ) : ( + data.data.cta_links?.map((link) => ( + + )) + )} +
    +
    diff --git a/apps/web/src/components/ui/tabs/tabs.tsx b/apps/web/src/components/ui/tabs/tabs.tsx index ca3b374..edbf231 100644 --- a/apps/web/src/components/ui/tabs/tabs.tsx +++ b/apps/web/src/components/ui/tabs/tabs.tsx @@ -87,9 +87,53 @@ type TabsListProps = { className?: string; }; -const TabsList = ({ children, className }: TabsListProps) => ( -
    {children}
    -); +const TabsList = ({ children, className }: TabsListProps) => { + const scrollRef = useRef(null); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const update = () => { + setCanScrollLeft(el.scrollLeft > 4); + setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4); + }; + update(); + el.addEventListener("scroll", update, { passive: true }); + const ro = new ResizeObserver(update); + ro.observe(el); + return () => { + el.removeEventListener("scroll", update); + ro.disconnect(); + }; + }, []); + + return ( +
    +
    +
    + {children} +
    +
    +
    + ); +}; // ─── TabsTrigger ──────────────────────────────────────────────────────────── @@ -116,7 +160,7 @@ const TabsTrigger = ({ aria-selected={isActive} onClick={() => setActiveTab(value)} className={cn( - "cursor-pointer font-semibold inline-flex items-center gap-2 rounded-full px-4 py-3 text-[15px] transition-colors", + "shrink-0 cursor-pointer font-semibold inline-flex items-center gap-2 rounded-full px-4 py-3 text-[15px] transition-colors", isActive ? "bg-(--color-primary600) text-white" : "bg-(--color-neutral100) text-(--color-neutral700) hover:bg-(--color-neutral150)", diff --git a/apps/web/src/features/cms/pages/organization/template.tsx b/apps/web/src/features/cms/pages/organization/template.tsx index b685994..8f0aa97 100644 --- a/apps/web/src/features/cms/pages/organization/template.tsx +++ b/apps/web/src/features/cms/pages/organization/template.tsx @@ -37,7 +37,7 @@ const OrganizationTemplate = ({ document, members, relatedContent }: Props) => { -
    +
    @@ -132,7 +132,7 @@ const OrganizationTemplate = ({ document, members, relatedContent }: Props) => { defaultValue={noRelatedContent ? "about" : "content"} className="w-full" > - + {!noRelatedContent && ( { {templates.length > 0 && ( -
    -

    +
    +

    Templates

    @@ -181,8 +181,8 @@ const OrganizationTemplate = ({ document, members, relatedContent }: Props) => { )} {packages.length > 0 && ( -
    -

    +
    +

    Packages

    @@ -211,9 +211,9 @@ const OrganizationTemplate = ({ document, members, relatedContent }: Props) => { -

    +

    About {document.name}

    {document.profile?.readme ? ( @@ -227,9 +227,9 @@ const OrganizationTemplate = ({ document, members, relatedContent }: Props) => { -

    +

    People

    {members.length > 0 ? ( diff --git a/apps/web/src/features/cms/pages/overview-page/template.tsx b/apps/web/src/features/cms/pages/overview-page/template.tsx index 97de371..27b5fcd 100644 --- a/apps/web/src/features/cms/pages/overview-page/template.tsx +++ b/apps/web/src/features/cms/pages/overview-page/template.tsx @@ -17,12 +17,12 @@ const OverviewPageTemplate = ({ document }: Props) => { -
    -
    +
    +
    -

    +

    {document.title}

    @@ -35,7 +35,7 @@ const OverviewPageTemplate = ({ document }: Props) => { alt={document.title!} width={480} height={480} - className="mt-10 rounded-lg object-cover ml-auto" + className="rounded-lg object-cover ml-auto md:ml-auto w-full max-w-120 md:max-w-120 md:min-w-0 md:shrink max-h-130" /> )}

    diff --git a/apps/web/src/features/cms/pages/package/template.tsx b/apps/web/src/features/cms/pages/package/template.tsx index 1cb27c3..b20bfb7 100644 --- a/apps/web/src/features/cms/pages/package/template.tsx +++ b/apps/web/src/features/cms/pages/package/template.tsx @@ -32,9 +32,9 @@ const PackageTemplate = ({ document }: Props) => { <> -
    +
    {/* ── Left column ── */} -
    +
    {/* Header */}
    diff --git a/apps/web/src/features/cms/pages/template/template.tsx b/apps/web/src/features/cms/pages/template/template.tsx index 92e8ac3..cc32afa 100644 --- a/apps/web/src/features/cms/pages/template/template.tsx +++ b/apps/web/src/features/cms/pages/template/template.tsx @@ -29,9 +29,9 @@ const TemplateTemplate = ({ document }: Props) => { <> -
    +
    {/* ── Left column ── */} -
    +
    {/* Header */}
    diff --git a/apps/web/src/features/cms/pages/user/template.tsx b/apps/web/src/features/cms/pages/user/template.tsx index d4178a2..e983107 100644 --- a/apps/web/src/features/cms/pages/user/template.tsx +++ b/apps/web/src/features/cms/pages/user/template.tsx @@ -33,7 +33,7 @@ const UserTemplate = ({ document, relatedContent }: Props) => { -
    +
    @@ -138,7 +138,7 @@ const UserTemplate = ({ document, relatedContent }: Props) => { defaultValue={noRelatedContent ? "about" : "content"} className="w-full" > - + {!noRelatedContent && ( { {templates.length > 0 && ( -
    -

    +
    +

    Templates

    @@ -182,8 +182,8 @@ const UserTemplate = ({ document, relatedContent }: Props) => { )} {packages.length > 0 && ( -
    -

    +
    +

    Packages

    @@ -212,9 +212,9 @@ const UserTemplate = ({ document, relatedContent }: Props) => { -

    +

    About {document.name}

    {document.profile?.readme ? ( diff --git a/apps/web/src/features/cms/sections/card-grid/card-grid.tsx b/apps/web/src/features/cms/sections/card-grid/card-grid.tsx index 08dd449..262ad73 100644 --- a/apps/web/src/features/cms/sections/card-grid/card-grid.tsx +++ b/apps/web/src/features/cms/sections/card-grid/card-grid.tsx @@ -10,28 +10,28 @@ type Props = { const CardGridSection = ({ section }: Props) => { return ( -
    +
    {section.items?.map((item, index) => (
    -
    -
    - {item.icon && ( - - {renderIcon(item.icon, { - size: 24, - className: "text-(--color-primary700)", - })} - - )} -

    - {item.title} -

    -
    -

    +

    + {item.icon && ( + + {renderIcon(item.icon, { + size: 24, + className: "text-(--color-primary700)", + })} + + )} +

    + {item.title} +

    +
    +
    +

    {item.content}

    diff --git a/apps/web/src/features/cms/sections/cta/cta.tsx b/apps/web/src/features/cms/sections/cta/cta.tsx index 0442ad6..20a18f1 100644 --- a/apps/web/src/features/cms/sections/cta/cta.tsx +++ b/apps/web/src/features/cms/sections/cta/cta.tsx @@ -60,32 +60,23 @@ const sharedWrapperStyle: React.CSSProperties = { }; const CTASection = async ({ section }: Props) => { - const { cta } = section; - - const isCommunity = cta?.illustration === "community_members"; - - const users = isCommunity - ? await cmsClient.collection("plugin::better-auth.user").find({ - limit: 5, - populate: ["profile"], - }) - : null; + const cta = section.cta!; if (cta?.size === "S") { return ( -
    +
    -
    -
    -

    +
    +
    +

    {cta.title}

    -

    +

    {cta.content}

    {cta.button?.link && ( @@ -99,22 +90,24 @@ const CTASection = async ({ section }: Props) => { )}
    -
    - {isCommunity && users?.data && users.data.length > 0 ? ( - - ) : ( - cta.image?.url && ( - { - ) - )} +
    + {cta.image.alternativeText +
    + { +
    @@ -125,14 +118,14 @@ const CTASection = async ({ section }: Props) => { } return ( -
    +
    -
    +

    {cta?.title}

    @@ -149,22 +142,23 @@ const CTASection = async ({ section }: Props) => { )}
    - - {isCommunity && users?.data && users.data.length > 0 ? ( -
    - -
    - ) : ( - cta?.image?.url && ( +
    + {cta.image.alternativeText +
    {cta.image.alternativeText - ) - )} +
    +
    diff --git a/apps/web/src/features/search/components/search-index/search-index.tsx b/apps/web/src/features/search/components/search-index/search-index.tsx index 1c9f6e6..430e92c 100644 --- a/apps/web/src/features/search/components/search-index/search-index.tsx +++ b/apps/web/src/features/search/components/search-index/search-index.tsx @@ -1,13 +1,17 @@ "use client"; import { instantMeiliSearch } from "@meilisearch/instant-meilisearch"; +import { SlidersHorizontal, X } from "lucide-react"; +import { useSearchParams } from "next/navigation"; import type { ComponentPropsWithoutRef, ReactNode } from "react"; +import { createContext, useContext, useEffect, useState } from "react"; import { Configure, InstantSearch } from "react-instantsearch"; import { InstantSearchNext } from "react-instantsearch-nextjs"; import { GridHits } from "@/features/search/components/grid-hits"; import { SearchBox } from "@/features/search/components/search-box"; import { SortBy } from "@/features/search/components/sort-by"; import { Stats } from "@/features/search/components/stats"; +import { cn } from "@/lib/utils"; const { searchClient } = instantMeiliSearch( process.env.NEXT_PUBLIC_SEARCH_URL as string, @@ -15,6 +19,22 @@ const { searchClient } = instantMeiliSearch( { keepZeroFacets: true }, ); +// Context + +interface SearchIndexContextValue { + isFilterModalOpen: boolean; + setIsFilterModalOpen: (open: boolean) => void; + hasFilters: boolean; + setHasFilters: (has: boolean) => void; +} + +const SearchIndexContext = createContext({ + isFilterModalOpen: false, + setIsFilterModalOpen: () => {}, + hasFilters: false, + setHasFilters: () => {}, +}); + // Root interface SearchIndexRootProps { @@ -28,12 +48,19 @@ const SearchIndexRoot = ({ useNextSearch = true, children, }: SearchIndexRootProps) => { + const searchParams = useSearchParams(); + const initialQuery = searchParams.get("query") ?? undefined; + const initialUiState = initialQuery + ? { [indexName]: { query: initialQuery } } + : undefined; + if (useNextSearch) { return ( {children} @@ -41,7 +68,12 @@ const SearchIndexRoot = ({ } return ( - + {children} ); @@ -49,19 +81,91 @@ const SearchIndexRoot = ({ // Layout -const SearchIndexLayout = ({ children }: { children: ReactNode }) => ( -
    {children}
    -); +const SearchIndexLayout = ({ children }: { children: ReactNode }) => { + const [isFilterModalOpen, setIsFilterModalOpen] = useState(false); + const [hasFilters, setHasFilters] = useState(false); + + useEffect(() => { + if (isFilterModalOpen) { + document.body.style.overflow = "hidden"; + } + return () => { + document.body.style.overflow = ""; + }; + }, [isFilterModalOpen]); + + return ( + +
    {children}
    +
    + ); +}; // Sidebar -const SearchIndexSidebar = ({ children }: { children?: ReactNode }) => ( - -); +const SearchIndexSidebar = ({ children }: { children?: ReactNode }) => { + const { isFilterModalOpen, setIsFilterModalOpen, setHasFilters } = + useContext(SearchIndexContext); + + useEffect(() => { + setHasFilters(true); + return () => setHasFilters(false); + }, [setHasFilters]); + + if (!children) return null; + + return ( + + ); +}; // FilterGroup @@ -106,9 +210,26 @@ const SearchIndexSearchBox = () => ( // Toolbar -const SearchIndexToolbar = ({ children }: { children: ReactNode }) => ( -
    {children}
    -); +const SearchIndexToolbar = ({ children }: { children: ReactNode }) => { + const { hasFilters, setIsFilterModalOpen } = useContext(SearchIndexContext); + + return ( +
    + {hasFilters && ( + + )} + {children} +
    + ); +}; // Stats @@ -122,7 +243,7 @@ interface SortItem { } const SearchIndexSortBy = ({ items }: { items: SortItem[] }) => ( -
    +
    ); diff --git a/apps/web/src/features/search/components/sort-by/sort-by.tsx b/apps/web/src/features/search/components/sort-by/sort-by.tsx index 83458bc..aa1d4fb 100644 --- a/apps/web/src/features/search/components/sort-by/sort-by.tsx +++ b/apps/web/src/features/search/components/sort-by/sort-by.tsx @@ -14,7 +14,7 @@ const SortBy = (props: SortByProps) => { return (