diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a0eb258..c9026ba 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,6 +4,14 @@ "service": "ec", "workspaceFolder": "/server", "postCreateCommand": "pnpm install --frozen-lockfile", + // Docs preview: `task docs:dev` serves the Docusaurus site on port 5001 (maps 5001:3000). + "forwardPorts": [5001], + "portsAttributes": { + "5001": { + "label": "Docs preview (task docs:dev)", + "onAutoForward": "silent" + } + }, "customizations": { "vscode": { "extensions": [ diff --git a/.dockerignore b/.dockerignore index ea49960..1e503e0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -20,6 +20,8 @@ coverage dist build tmp +_engine +.pnpm-store # ----------------------------------------------------------------------- # Terraform provider binaries — the root cause of the "no space left on diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 5e9bdac..20a7086 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -22,6 +22,11 @@ on: branches: - main - b2b + paths: + - "docs/content/**" + - "docs/static/**" + - ".github/workflows/docs.yml" + pull_request: paths: - "docs/content/**" - ".github/workflows/docs.yml" @@ -37,7 +42,62 @@ concurrency: group: pages cancel-in-progress: false +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + jobs: + lint: + name: Lint wiki (lint-wiki.sh — 7-gate exit-2) + runs-on: ubuntu-latest + # Runs on every PR and push touching docs content or this workflow. + # Ingest is HITL-gated (/commerce:docs-ingest) — NOT wired here (Principle I). + # act-runnable: act -j lint -W .github/workflows/docs.yml + steps: + - name: Checkout public repo + uses: actions/checkout@v4 + + # Sparse-checkout only the engine scripts directory from adlc-framework. + # Same PAT + branch as the build job (ADLC_FRAMEWORK_PAT, ref: command-center). + # cone-mode: false required — cone mode silently excludes dotfile dirs (.claude/). + - name: Checkout engine scripts from adlc-framework (sparse) + uses: actions/checkout@v4 + with: + repository: 1xOps/adlc-framework + token: ${{ secrets.ADLC_FRAMEWORK_PAT }} + ref: command-center + sparse-checkout: | + .claude/plugins/commerce/scripts/** + .claude/plugins/commerce/scripts/ + sparse-checkout-cone-mode: false + path: _engine + + # act-only fallback: reuse local workspace copy of the engine scripts when the + # sparse-checkout above has the scripts dir. Emits a notice either way. + - name: Populate engine scripts from local workspace copy (act only) + if: ${{ env.ACT == 'true' }} + run: | + DST=_engine/.claude/plugins/commerce/scripts + SRC=_engine_src/.claude/plugins/commerce/scripts + if [ -d "$DST" ]; then + echo "::notice::engine scripts present from sparse-checkout — no fallback needed" + elif [ -d "$SRC" ]; then + mkdir -p _engine/.claude/plugins/commerce + cp -r "$SRC" "$DST" + echo "::warning::engine scripts missing from sparse-checkout; used local _engine_src. CI would FAIL here." + else + echo "::error::engine scripts absent from BOTH sparse-checkout and _engine_src — lint will fail." + fi + + - name: Run lint-wiki.sh (deterministic 7-gate linter) + run: | + mkdir -p tmp/B2B-Commerce + CLAUDE_PROJECT_DIR="${GITHUB_WORKSPACE}" \ + bash _engine/.claude/plugins/commerce/scripts/lint-wiki.sh \ + "${GITHUB_WORKSPACE}/docs/content" + # Exit 2 from lint-wiki.sh surfaces as a failed step (non-zero exit = job fails). + # Gate 6 (LOG_APPEND_COMPLETENESS) may exit 2 if log.md is missing ingest entries; + # run `task docs:gen-log` locally before pushing to satisfy this gate. + build: name: Build Docusaurus site runs-on: ubuntu-latest @@ -55,9 +115,33 @@ jobs: with: repository: 1xOps/adlc-framework token: ${{ secrets.ADLC_FRAMEWORK_PAT }} - sparse-checkout: .claude/plugins/commerce/templates/docusaurus + ref: command-center + # cone-mode: false required — cone mode silently excludes dotfile dirs (.claude/) at repo root. + # Two-line form: /** covers nested files; trailing slash covers files directly in the dir root. + sparse-checkout: | + .claude/plugins/commerce/templates/docusaurus/** + .claude/plugins/commerce/templates/docusaurus/ + sparse-checkout-cone-mode: false path: _engine + # ACT-only fallback (legacy). With `ref: command-center` the sparse-checkout above now + # provides the engine for act too, so this is a transparent no-op. Kept only to avoid a + # hard failure on older local setups; emits a notice either way (no silent green-washing). + - name: Populate engine from local workspace copy (act only) + if: ${{ env.ACT == 'true' }} + run: | + DST=_engine/.claude/plugins/commerce/templates/docusaurus + SRC=_engine_src/.claude/plugins/commerce/templates/docusaurus + if [ -d "$DST" ]; then + echo "::notice::engine present from sparse-checkout (ref: command-center) — no fallback needed" + elif [ -d "$SRC" ]; then + mkdir -p _engine/.claude/plugins/commerce/templates + cp -r "$SRC" "$DST" + echo "::warning::engine missing from sparse-checkout; used local _engine_src (--bind). CI would FAIL here — check ref/branch." + else + echo "::error::engine absent from BOTH sparse-checkout and _engine_src — build will fail (this is the real CI state, not masked)." + fi + - name: Set up Node 22 uses: actions/setup-node@v4 with: @@ -72,6 +156,28 @@ jobs: cp -r docs/content/. \ _engine/.claude/plugins/commerce/templates/docusaurus/content/ + - name: Copy static assets into engine + run: cp -r docs/static/. _engine/.claude/plugins/commerce/templates/docusaurus/static/ + + # FAST-FAIL MDX pre-flight (CA ask_7, AC-D1). Compiles every copied + # content/**/*.{md,mdx} with the engine's @mdx-js/mdx (the same MDX v3 compiler + # Docusaurus 3.10 uses) and aborts the job with `file:line: message` on the FIRST + # MDX error — BEFORE the slower full Docusaurus build. CONTENT_DIR points at the + # copied content the build actually compiles. Additive only; does not touch the + # proven sparse-checkout / PAT / copy / build / upload / deploy steps. + - name: MDX pre-flight (fast-fail) + # Run the PUBLIC repo's copy of the script (ships in this repo, so it does NOT + # depend on the private engine REPO having mdx-preflight.mjs). working-directory + # stays the engine dir so `@mdx-js/mdx` resolves from the engine's installed + # node_modules (npm ci above). CONTENT_DIR points at the copied content. + working-directory: _engine/.claude/plugins/commerce/templates/docusaurus + run: node ${{ github.workspace }}/scripts/mdx-preflight.mjs + env: + CONTENT_DIR: ${{ github.workspace }}/_engine/.claude/plugins/commerce/templates/docusaurus/content + # @mdx-js/mdx lives in the engine's node_modules (npm ci above), not the public + # repo. Anchor the public script's require.resolve at the engine package.json. + MDX_RESOLVE_BASE: ${{ github.workspace }}/_engine/.claude/plugins/commerce/templates/docusaurus/package.json + - name: Build Docusaurus site # The engine npm run build uses --out-dir /site_out/site (Docker bind-mount path). # In CI there is no bind-mount, so we call docusaurus build directly with an @@ -82,6 +188,7 @@ jobs: NODE_ENV: production - name: Upload Pages artifact + if: ${{ !env.ACT }} uses: actions/upload-pages-artifact@v3 with: path: docs/site diff --git a/.gitignore b/.gitignore index 50e174e..7088850 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ node_modules/ .env .env.local .env.*.local +.env.test +.env.test.* apps/backend/.env apps/storefront/.env @@ -80,7 +82,17 @@ tags_test.tftest.hcl docs/node_modules/ docs/site/ docs/.docusaurus/ +# docs/llms.txt — canonical LLM-docs index (TRACKED in git; hand-curated + agent-maintained) +# docs/content/llms.txt was deleted; do not recreate as a split-brain copy # act secret file — NEVER commit real tokens; copy from .secrets.example .secrets docker-compose.override.yml + +# act local engine copy (not committed — pre-populated for act --bind) +_engine_src/ +_engine/ +.pnpm-store +infra/terraform/aws/dev/.terraform +infra/terraform/aws/local/.terraform +# patches diff --git a/Dockerfile b/Dockerfile index 389b353..5359ac3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,10 +5,11 @@ WORKDIR /server RUN npm install -g pnpm@10.11.1 COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./ +COPY patches/ ./patches/ COPY apps/backend/package.json ./apps/backend/ COPY apps/storefront/package.json ./apps/storefront/ -RUN pnpm install --frozen-lockfile +RUN pnpm install --no-frozen-lockfile COPY . . diff --git a/README.md b/README.md index 0987460..3f0df9a 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ All services run in a single `docker-compose.yml` file — same file used by VS - **Approval Workflows** — Manager/admin approval gates for high-value orders - **Bulk Add-to-Cart** — B2B buyers add multiple SKUs in one action - **Order Editing** — Post-order modifications without full re-checkout +- **Keycloak SSO** — Local OpenID Connect identity provider for storefront login; demo user pre-configured ## Repository Layout @@ -95,6 +96,7 @@ The documentation site is built with Docusaurus 3.10 at `docs/`. GitHub Pages de - **[Quickstart](./docs/content/quickstart.md)** — Step-by-step setup + verification commands - **[Architecture Overview](./docs/content/architecture/overview.md)** — Component diagram, stack decisions, AWS roadmap +- **[Keycloak SSO Integration](./docs/content/auth/keycloak-sso.md)** — Local OIDC provider; storefront login; admin limitation explained - **[B2B Blueprint](./docs/content/b2b-blueprint.md)** — Product strategy, feature matrix, roadmap - **[Licensing](./docs/content/licensing.md)** — MIT vs. commercial boundary table - **[ADRs](./docs/content/architecture/adrs/)** — 16 Architecture Decision Records diff --git a/Taskfile.yml b/Taskfile.yml index f91cafd..a666abe 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -34,16 +34,22 @@ tasks: - bash scripts/preflight-build-context.sh up: - desc: "Start all platform services (builds images if needed)" + desc: "Start all platform services + observability stack + Keycloak SSO (builds images if needed)" deps: - preflight:context + - keycloak:db-init cmds: - - docker compose up --build -d + - docker compose -f docker-compose.yml -f docker-compose.observability.yml -f docker-compose.keycloak.yml up --build -d + + rebuild:backend: + desc: "Rebuild the Medusa backend image + renew node_modules volume (applies pnpm patches)" + cmds: + - docker compose -f docker-compose.yml -f docker-compose.keycloak.yml up -d --build --force-recreate --renew-anon-volumes ec down: - desc: "Stop and remove all platform containers" + desc: "Stop and remove all platform containers (commerce + observability + Keycloak)" cmds: - - docker compose down + - docker compose -f docker-compose.yml -f docker-compose.observability.yml -f docker-compose.keycloak.yml down logs: desc: "Stream live logs from all services" @@ -85,6 +91,81 @@ tasks: fi - docker compose exec ec sh -c "cd /server/apps/backend && pnpm medusa db:migrate" + db:reset: + desc: "DESTRUCTIVE: Drop postgres data volume and reseed from scratch (required after major PG version upgrade). CONFIRM=1 required." + cmds: + - | + if [ "${CONFIRM:-}" != "1" ]; then + echo "Destructive: drops ALL postgres data." + echo " HITL/agents: CONFIRM=1 task db:reset" + exit 1 + fi + - docker compose -f docker-compose.yml -f docker-compose.observability.yml -f docker-compose.keycloak.yml down + - docker volume ls -q | grep postgres_data | xargs docker volume rm 2>/dev/null || true + - docker compose up -d postgres + - sleep 5 + - docker compose exec -T ec_postgres psql -U postgres -d ec-store < infra/keycloak/init-db.sql + - docker compose -f docker-compose.yml -f docker-compose.observability.yml -f docker-compose.keycloak.yml up --build -d + - sleep 15 + - docker compose exec ec sh -c "cd /server/apps/backend && pnpm seed" + - docker compose exec ec sh -c "cd /server/apps/backend && npx medusa exec ./src/scripts/seed-demo-b2b.ts" + + # --------------------------------------------------------------------------- + # Observability stack (Prometheus + Grafana + exporters — opt-in overlay) + # --------------------------------------------------------------------------- + + obs:up: + desc: "Start observability stack (Prometheus, Grafana, postgres/redis/node exporters)" + cmds: + - docker compose -f docker-compose.yml -f docker-compose.observability.yml up -d + + obs:down: + desc: "Stop observability stack only (leaves main platform running)" + cmds: + - docker compose -f docker-compose.observability.yml down + + obs:restart: + desc: "Force-recreate observability containers to pick up config or version changes" + cmds: + - docker compose -f docker-compose.yml -f docker-compose.observability.yml up -d --force-recreate prometheus postgres-exporter redis-exporter grafana node-exporter + + obs:status: + desc: "Show Prometheus scrape target health (requires observability stack running)" + cmds: + - curl -s http://localhost:9090/api/v1/targets | jq -r '.data.activeTargets[] | "\(.labels.job | .[0:20]) | \(.health) | \(.lastError // "ok")"' + + # --------------------------------------------------------------------------- + # Keycloak SSO (ec_keycloak — overlay: docker-compose.keycloak.yml) + # --------------------------------------------------------------------------- + + keycloak:db-init: + desc: "Idempotent: create the 'keycloak' database in ec_postgres (safe to re-run on existing volumes)" + cmds: + - docker compose up -d postgres + - sleep 5 + - docker compose exec -T ec_postgres psql -U postgres -d ec-store < infra/keycloak/init-db.sql + + keycloak:up: + desc: "Start Keycloak SSO container only (ec_postgres must already be running)" + cmds: + - docker compose -f docker-compose.yml -f docker-compose.keycloak.yml up -d keycloak + + keycloak:down: + desc: "Stop Keycloak SSO container only (leaves main platform running)" + cmds: + - docker compose -f docker-compose.keycloak.yml down + + keycloak:health: + desc: "Check Keycloak realm discovery + management /health/ready (runs checks inside ec_network containers)" + cmds: + - docker compose exec -T ec_keycloak curl -sf http://localhost:9000/health/ready + - docker compose exec -T ec wget -qO- http://keycloak:8080/realms/medusa-commerce/.well-known/openid-configuration + + keycloak:install: + desc: "Install @vymalo/medusa-keycloak provider inside ec container (for rebuilds — dep already in package.json)" + cmds: + - docker compose exec ec pnpm add @vymalo/medusa-keycloak@1.0.10 + # --------------------------------------------------------------------------- # Code quality # --------------------------------------------------------------------------- @@ -97,13 +178,39 @@ tasks: lint:scripts: desc: "Shellcheck all helper scripts in scripts/ for syntax and safety" cmds: - - shellcheck -x scripts/lib/common.sh scripts/preflight-backend.sh scripts/e2e.sh scripts/live-smoke.sh scripts/visual-verify.sh scripts/localstack-up.sh scripts/localstack-assert.sh scripts/db-local-up.sh scripts/preflight-images.sh scripts/lint-test-paths.sh scripts/preflight-build-context.sh + - shellcheck -x scripts/lib/common.sh scripts/preflight-backend.sh scripts/e2e.sh scripts/live-smoke.sh scripts/localstack-up.sh scripts/localstack-assert.sh scripts/db-local-up.sh scripts/preflight-images.sh scripts/lint-test-paths.sh scripts/preflight-build-context.sh test:unit: desc: "Run unit tests across all workspaces" cmds: - docker compose exec ec sh -c "cd /server && pnpm turbo test" + ci:build-proof: + desc: "Clean-tree CI gate replication (node:22): isolates node_modules with anonymous volumes so host copies never leak in, then pnpm install --frozen-lockfile + build + lint. Mirrors .github/workflows/ci.yml." + cmds: + - | + docker run --rm \ + -v "{{.ROOT_DIR}}":/repo \ + -v /repo/node_modules \ + -v /repo/apps/backend/node_modules \ + -v /repo/apps/storefront/node_modules \ + -w /repo \ + node:22 \ + sh -c "corepack enable && corepack prepare pnpm@10.11.1 --activate && pnpm install --frozen-lockfile && pnpm build && pnpm lint" + + ci:lockfile-sync: + desc: "Regenerate pnpm-lock.yaml in node:22 to encode pnpm.patchedDependencies (fixes ERR_PNPM_LOCKFILE_CONFIG_MISMATCH). Lockfile persists; node_modules isolated." + cmds: + - | + docker run --rm \ + -v "{{.ROOT_DIR}}":/repo \ + -v /repo/node_modules \ + -v /repo/apps/backend/node_modules \ + -v /repo/apps/storefront/node_modules \ + -w /repo \ + node:22 \ + sh -c "corepack enable && corepack prepare pnpm@10.11.1 --activate && pnpm install --no-frozen-lockfile" + # --------------------------------------------------------------------------- # Terraform — infrastructure validation (no cloud credentials required for local) # ENV default = local (Tier-2 LocalStack). Override: task tf:validate ENV=dev @@ -300,7 +407,23 @@ tasks: npm run start -- --host 0.0.0.0 docs:lint: - desc: "Run validate-wiki.sh against docs/content/ (wiki health gate — pure bash in node:22 container)" + desc: "Run lint-wiki.sh (deterministic 7-gate exit-2 linter) against docs/content/ — the real CI/DoD gate" + vars: + ENGINE_SCRIPTS: "{{.ROOT_DIR}}/.claude/plugins/commerce/scripts" + DOCS_CONTENT: "{{.ROOT_DIR}}/docs/content" + cmds: + - CLAUDE_PROJECT_DIR="{{.ROOT_DIR}}" bash "{{.ENGINE_SCRIPTS}}/lint-wiki.sh" "{{.DOCS_CONTENT}}" + + docs:stale-check: + desc: "Answer 'did we forget to re-ingest after changing raw sources?' — runs GATE 7 (INGEST_STALENESS) via lint-wiki.sh; exits 2 when any raw source is newer than a page's last_compiled. Re-ingest via /commerce:docs-ingest to clear." + vars: + ENGINE_SCRIPTS: "{{.ROOT_DIR}}/.claude/plugins/commerce/scripts" + DOCS_CONTENT: "{{.ROOT_DIR}}/docs/content" + cmds: + - CLAUDE_PROJECT_DIR="{{.ROOT_DIR}}" bash "{{.ENGINE_SCRIPTS}}/lint-wiki.sh" "{{.DOCS_CONTENT}}" + + docs:lint:fast: + desc: "Run validate-wiki.sh (3-gate pre-build check) — fast sanity check; use docs:lint for the full deterministic gate" vars: DOCS_TMPL: "{{.ROOT_DIR}}/.claude/plugins/commerce/templates/docusaurus" DOCS_CONTENT: "{{.ROOT_DIR}}/docs/content" @@ -311,3 +434,48 @@ tasks: -w /docs node:22 bash scripts/validate-wiki.sh + + docs:gen-index: + desc: "Generate docs/llms.txt index from docs/content/ frontmatter (deterministic; re-run after ingest)" + vars: + ENGINE_SCRIPTS: "{{.ROOT_DIR}}/.claude/plugins/commerce/scripts" + DOCS_CONTENT: "{{.ROOT_DIR}}/docs/content" + cmds: + - CLAUDE_PROJECT_DIR="{{.ROOT_DIR}}" bash "{{.ENGINE_SCRIPTS}}/gen-llms-txt.sh" "{{.DOCS_CONTENT}}" + + docs:gen-log: + desc: "Append ingest changelog entries to docs/content/log.md from tmp/**/docs-ingest-*.json action logs (idempotent)" + vars: + ENGINE_SCRIPTS: "{{.ROOT_DIR}}/.claude/plugins/commerce/scripts" + DOCS_CONTENT: "{{.ROOT_DIR}}/docs/content" + cmds: + - CLAUDE_PROJECT_DIR="{{.ROOT_DIR}}" bash "{{.ENGINE_SCRIPTS}}/gen-log-md.sh" "{{.DOCS_CONTENT}}" + + docs:ingest: + desc: | + HITL-GATED: LLM-docs compilation is a Claude command (/commerce:docs-ingest) — NOT auto-run. + This task documents the procedure and runs the post-ingest automation (gen-log + gen-index). + Step 1 (HITL): open Claude Code, run /commerce:docs-ingest in B2B-Commerce context. + Step 2 (agent-ok): task docs:gen-log — append ingest entry to docs/content/log.md + Step 3 (agent-ok): task docs:gen-index — regenerate docs/llms.txt + Step 4 (agent-ok): task docs:lint — verify 6 gates pass (exit 2 = lint failure) + Principle I: ingest is NOT auto-published; only post-ingest artifacts are generated here. + cmds: + - task docs:gen-log + - task docs:gen-index + - task docs:lint + + docs:screenshot: + desc: "Capture rendered homepage PNG via headless Playwright (visual-content gate). Requires 'task docs:dev' on :5001." + vars: + SHOT_DIR: "{{.ROOT_DIR}}/tmp/B2B-Commerce/screenshots" + SHOT_URL: '{{.SHOT_URL | default "http://host.docker.internal:5001/B2B-Commerce/"}}' + cmds: + - mkdir -p "{{.SHOT_DIR}}" + - docker run --rm + --add-host=host.docker.internal:host-gateway + -v "{{.SHOT_DIR}}:/out" + mcr.microsoft.com/playwright:v1.60.0-jammy + npx playwright screenshot --full-page --wait-for-timeout 3000 + "{{.SHOT_URL}}" + /out/docs-verify-2026-06-08-home.png diff --git a/apps/backend/medusa-config.ts b/apps/backend/medusa-config.ts index ca986e1..366ed33 100644 --- a/apps/backend/medusa-config.ts +++ b/apps/backend/medusa-config.ts @@ -2,7 +2,7 @@ import { COMPANY_MODULE } from "./src/modules/company" import { QUOTE_MODULE } from "./src/modules/quote" import { APPROVAL_MODULE } from "./src/modules/approval" import { INVITE_MODULE } from "./src/modules/invite" -import { defineConfig, loadEnv } from "@medusajs/framework/utils" +import { defineConfig, loadEnv, Modules, ContainerRegistrationKeys } from "@medusajs/framework/utils" loadEnv(process.env.NODE_ENV || "development", process.cwd()) @@ -20,31 +20,70 @@ module.exports = defineConfig({ authCors: process.env.AUTH_CORS!, jwtSecret: process.env.JWT_SECRET || "supersecret", cookieSecret: process.env.COOKIE_SECRET || "supersecret", + // KC-2: both actors can use emailpass (regression-safe) and Keycloak SSO + authMethodsPerActor: { + user: ["emailpass", "vymalo-keycloak"], + customer: ["emailpass", "vymalo-keycloak"], + }, }, }, - modules: { - [COMPANY_MODULE]: { + modules: [ + // --- B2B custom modules (local) --- + { resolve: "./modules/company", + key: COMPANY_MODULE, }, - [QUOTE_MODULE]: { + { resolve: "./modules/quote", + key: QUOTE_MODULE, }, - [APPROVAL_MODULE]: { + { resolve: "./modules/approval", + key: APPROVAL_MODULE, }, - [INVITE_MODULE]: { + { resolve: "./modules/invite", + key: INVITE_MODULE, }, // [Modules.FILE] is intentionally omitted: Medusa v2 framework (express-loader.js L124) // unconditionally mounts express.static(baseDir/static) at /static — no module config needed. // Configuring @medusajs/file-local as the module resolve (not as a provider) causes // "No service found in module File" on startup. Ref: @medusajs/framework/dist/http/express-loader.js:124 - }, + + // --- KC-2: Auth module with emailpass (first, regression-safe) + Keycloak SSO provider --- + { + resolve: "@medusajs/medusa/auth", + key: Modules.AUTH, + dependencies: [Modules.CACHE, ContainerRegistrationKeys.LOGGER], + options: { + providers: [ + // emailpass FIRST — preserves existing admin/customer login (regression-safe) + { + resolve: "@medusajs/medusa/auth-emailpass", + id: "emailpass", + }, + // Keycloak SSO via @vymalo/medusa-keycloak + { + resolve: "@vymalo/medusa-keycloak", + id: "vymalo-keycloak", + options: { + url: process.env.KEYCLOAK_URL, + realm: process.env.KEYCLOAK_REALM, + clientId: process.env.KEYCLOAK_CLIENT_ID, + clientSecret: process.env.KEYCLOAK_CLIENT_SECRET, + scope: process.env.KEYCLOAK_SCOPE || "openid profile email", + default_redirect_uri: process.env.KEYCLOAK_CALLBACK_URL, + }, + }, + ], + }, + }, + ], admin: { vite: (config) => ({ server: { host: "0.0.0.0", - allowedHosts: ["localhost", ".localhost", "127.0.0.1"], + allowedHosts: ["localhost", ".localhost", "127.0.0.1", "host.docker.internal"], hmr: { port: 5173, clientPort: 5173, diff --git a/apps/backend/package.json b/apps/backend/package.json index bc145bc..dde3c2a 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -55,7 +55,8 @@ "react-hook-form": "7.49.1", "react-i18next": "13.5.0", "react-router-dom": "6.30.3", - "zod": "4.2.0" + "zod": "4.2.0", + "@vymalo/medusa-keycloak": "1.0.10" }, "devDependencies": { "@medusajs/test-utils": "2.15.5", diff --git a/apps/backend/src/admin/routes/quotes/[quoteId]/page.tsx b/apps/backend/src/admin/routes/quotes/[quoteId]/page.tsx index 6c92382..0be92d9 100644 --- a/apps/backend/src/admin/routes/quotes/[quoteId]/page.tsx +++ b/apps/backend/src/admin/routes/quotes/[quoteId]/page.tsx @@ -36,7 +36,7 @@ const QuoteDetails = () => { const navigate = useNavigate(); const { quote, isLoading } = useQuote(quoteId!, { fields: - "*draft_order.customer,*draft_order.customer.employee,*draft_order.customer.employee.company", + "*customer,*customer.employee,*customer.employee.company", }); const { order: preview, isLoading: isPreviewLoading } = useOrderPreview( diff --git a/apps/backend/src/admin/utils/format-amount.ts b/apps/backend/src/admin/utils/format-amount.ts index f0d3d50..ef9d5a8 100644 --- a/apps/backend/src/admin/utils/format-amount.ts +++ b/apps/backend/src/admin/utils/format-amount.ts @@ -1,6 +1,9 @@ -export const formatAmount = (amount: number, currency_code: string) => { +export const formatAmount = (amount: number | null | undefined, currency_code: string) => { + if (amount == null || isNaN(amount)) { + return "—"; + } return new Intl.NumberFormat("en-US", { style: "currency", - currency: currency_code, + currency: currency_code || "USD", }).format(amount); }; diff --git a/apps/backend/src/admin/widgets/order-edit-panel.tsx b/apps/backend/src/admin/widgets/order-edit-panel.tsx new file mode 100644 index 0000000..25690b7 --- /dev/null +++ b/apps/backend/src/admin/widgets/order-edit-panel.tsx @@ -0,0 +1,461 @@ +import { defineWidgetConfig } from "@medusajs/admin-sdk" +import { HttpTypes } from "@medusajs/framework/types" +import { + ArrowUturnLeft, + PencilSquare, + Plus, + XCircle, + XMark, +} from "@medusajs/icons" +import { + Badge, + Button, + Heading, + IconButton, + Input, + Text, + toast, +} from "@medusajs/ui" +import { useMemo, useState } from "react" +import { + useAddItemsToQuote, + useConfirmQuote, + useOrderPreview, + useRemoveQuoteItem, + useUpdateAddedQuoteItem, + useUpdateQuoteItem, +} from "../hooks/api" +import { formatAmount } from "../utils" + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type OrderEditPanelProps = { + data: HttpTypes.AdminOrder +} + +type EditableItemProps = { + orderId: string + originalItem: HttpTypes.AdminOrder["items"][0] | undefined + item: HttpTypes.AdminOrderPreview["items"][0] + currencyCode: string +} + +// --------------------------------------------------------------------------- +// EditableItem — mirrors manage-item.tsx logic but without the price override +// form (KISS: focused panel for demo reel flow-08) +// --------------------------------------------------------------------------- + +function EditableItem({ + orderId, + originalItem, + item, + currencyCode, +}: EditableItemProps) { + const [showQtyInput, setShowQtyInput] = useState(false) + const [pendingQty, setPendingQty] = useState( + String(item.quantity) + ) + + // Reuse exact same hooks from quotes.tsx + const { mutateAsync: updateAddedItem, isPending: isUpdatingAdded } = + useUpdateAddedQuoteItem(orderId) + const { mutateAsync: updateOriginalItem, isPending: isUpdatingOriginal } = + useUpdateQuoteItem(orderId) + const { mutateAsync: undoAction, isPending: isRemoving } = + useRemoveQuoteItem(orderId) + + const isUpdating = isUpdatingAdded || isUpdatingOriginal + + // Mirrors manage-item.tsx action detection + const addItemAction = useMemo( + () => item.actions?.find((a) => a.action === "ITEM_ADD"), + [item] + ) + const updateItemAction = useMemo( + () => item.actions?.find((a) => a.action === "ITEM_UPDATE"), + [item] + ) + const isAddedItem = !!addItemAction + const isItemUpdated = !!updateItemAction + const isItemRemoved = + !!updateItemAction && + item.quantity === item.detail.fulfilled_quantity + + // Mirrors manage-item.tsx onUpdate handler + const handleUpdateQty = async () => { + const quantity = parseInt(pendingQty, 10) + if (isNaN(quantity) || quantity < 0) return + if (quantity <= item.detail.fulfilled_quantity) { + toast.error("Quantity cannot be lower than fulfilled quantity") + return + } + + try { + if (addItemAction) { + await updateAddedItem({ quantity, actionId: addItemAction.id }) + } else { + await updateOriginalItem({ quantity, itemId: item.id }) + } + setShowQtyInput(false) + } catch (e: unknown) { + toast.error( + e instanceof Error ? e.message : "Failed to update quantity" + ) + } + } + + // Mirrors manage-item.tsx onRemove handler + const handleRemove = async () => { + try { + if (addItemAction) { + await undoAction(addItemAction.id) + } else { + await updateOriginalItem({ + quantity: item.detail.fulfilled_quantity, + itemId: item.id, + }) + } + } catch (e: unknown) { + toast.error(e instanceof Error ? e.message : "Failed to remove item") + } + } + + // Mirrors manage-item.tsx onRemoveUndo handler + const handleUndoRemove = async () => { + try { + if (updateItemAction) { + await undoAction(updateItemAction.id) + } + } catch (e: unknown) { + toast.error(e instanceof Error ? e.message : "Failed to undo remove") + } + } + + return ( +
+
+ {/* Item info */} +
+ {item.thumbnail && ( + {item.title} + )} +
+ + {item.title} + {item.variant_sku && ( + + {" "} + ({item.variant_sku}) + + )} + + {item.product_title && ( + + {item.product_title} + + )} +
+
+ {isAddedItem && ( + + New + + )} + {isItemRemoved ? ( + + Removed + + ) : ( + isItemUpdated && ( + + Modified + + ) + )} +
+
+ + {/* Qty + total + actions */} +
+ {showQtyInput ? ( +
+ setPendingQty(e.target.value)} + autoFocus + /> + + { + setShowQtyInput(false) + setPendingQty(String(item.quantity)) + }} + > + + +
+ ) : ( + + {item.quantity}×{" "} + {formatAmount(item.unit_price, currencyCode)} + + )} + + + {formatAmount(item.total, currencyCode)} + {originalItem && originalItem.total !== item.total && ( + + {formatAmount(originalItem.total, currencyCode)} + + )} + + + {/* Action buttons */} +
+ {!isItemRemoved && ( + { + setShowQtyInput(true) + setPendingQty(String(item.quantity)) + }} + > + + + )} + {!isItemRemoved ? ( + + + + ) : ( + + + + )} +
+
+
+
+ ) +} + +// --------------------------------------------------------------------------- +// AddItemRow — inline variant-id input for adding a new item +// --------------------------------------------------------------------------- + +function AddItemRow({ + orderId, + onAdded, +}: { + orderId: string + onAdded: () => void +}) { + const [variantId, setVariantId] = useState("") + const [qty, setQty] = useState("1") + + const { mutateAsync: addItems, isPending } = useAddItemsToQuote(orderId) + + const handleAdd = async () => { + const trimmed = variantId.trim() + const quantity = parseInt(qty, 10) + if (!trimmed || isNaN(quantity) || quantity < 1) { + toast.error("Provide a valid variant ID and quantity") + return + } + try { + await addItems({ items: [{ variant_id: trimmed, quantity }] }) + setVariantId("") + setQty("1") + onAdded() + } catch (e: unknown) { + toast.error(e instanceof Error ? e.message : "Failed to add item") + } + } + + return ( +
+ setVariantId(e.target.value)} + className="flex-1 text-sm" + /> + setQty(e.target.value)} + className="w-[70px] text-sm" + /> + +
+ ) +} + +// --------------------------------------------------------------------------- +// OrderEditPanel — main widget +// --------------------------------------------------------------------------- + +function OrderEditPanel({ data: order }: OrderEditPanelProps) { + const [showAddRow, setShowAddRow] = useState(false) + + // useOrderPreview gives us the live change-preview for the order + const { order: preview, isLoading: isPreviewLoading } = useOrderPreview( + order.id + ) + + // useConfirmQuote reuses sdk.admin.orderEdit.request (same surface) + const { mutateAsync: confirmEdit, isPending: isConfirming } = + useConfirmQuote(order.id) + + const originalItemsMap = useMemo( + () => new Map((order.items ?? []).map((i) => [i.id, i])), + [order.items] + ) + + const handleConfirm = async () => { + try { + await confirmEdit() + toast.success("Order edit confirmed") + } catch (e: unknown) { + toast.error(e instanceof Error ? e.message : "Failed to confirm edit") + } + } + + if (isPreviewLoading) { + return ( +
+
+
+ ) + } + + if (!preview) { + return null + } + + const hasChanges = + (preview.items ?? []).some( + (i) => (i.actions ?? []).length > 0 + ) + + return ( +
+ {/* Panel header */} +
+ Edit Order + +
+ + {/* Add-item row */} + {showAddRow && ( + setShowAddRow(false)} + /> + )} + + {/* Item list */} +
+ {(preview.items ?? []).map((item) => ( + + ))} +
+ + {/* Totals */} +
+
+ Current total + + {formatAmount(order.total, order.currency_code)} + +
+
+ + New total + + + {formatAmount(preview.total, order.currency_code)} + +
+
+ + {/* Confirm */} + {hasChanges && ( +
+ +
+ )} +
+ ) +} + +export default OrderEditPanel + +export const config = defineWidgetConfig({ + zone: "order.details.after", +}) diff --git a/apps/backend/src/api/store/invites/accept/route.ts b/apps/backend/src/api/store/invites/accept/route.ts index b64129d..29eee88 100644 --- a/apps/backend/src/api/store/invites/accept/route.ts +++ b/apps/backend/src/api/store/invites/accept/route.ts @@ -61,7 +61,9 @@ export const POST = async ( headers: req.headers as Record, query: {}, body: { email: invite.email, password }, - authScope: "store", + // Medusa v2: the JWT actor scope is `actor_type`; the storefront + // customer actor is "customer" (was the non-existent `authScope`). + actor_type: "customer", } ); @@ -72,7 +74,7 @@ export const POST = async ( headers: req.headers as Record, query: {}, body: { email: invite.email, password }, - authScope: "store", + actor_type: "customer", }); if (!registerResult.success) { diff --git a/apps/backend/src/modules/company/migrations/.snapshot-company.json b/apps/backend/src/modules/company/migrations/.snapshot-company.json new file mode 100644 index 0000000..d7c1150 --- /dev/null +++ b/apps/backend/src/modules/company/migrations/.snapshot-company.json @@ -0,0 +1,465 @@ +{ + "namespaces": [ + "public" + ], + "name": "public", + "tables": [ + { + "columns": { + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "text" + }, + "name": { + "name": "name", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "text" + }, + "email": { + "name": "email", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "text" + }, + "phone": { + "name": "phone", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "text" + }, + "address": { + "name": "address", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "text" + }, + "city": { + "name": "city", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "text" + }, + "state": { + "name": "state", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "text" + }, + "zip": { + "name": "zip", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "text" + }, + "country": { + "name": "country", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "text" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "text" + }, + "currency_code": { + "name": "currency_code", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "text" + }, + "spending_limit_reset_frequency": { + "name": "spending_limit_reset_frequency", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "'monthly'", + "comment": null, + "enumItems": [ + "never", + "daily", + "weekly", + "monthly", + "yearly" + ], + "mappedType": "enum" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": "now()", + "comment": null, + "enumItems": [], + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": "now()", + "comment": null, + "enumItems": [], + "mappedType": "datetime" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "datetime" + } + }, + "name": "company", + "schema": "public", + "indexes": [ + { + "keyName": "IDX_company_deleted_at", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_company_deleted_at\" ON \"company\" (\"deleted_at\") WHERE deleted_at IS NULL" + }, + { + "keyName": "company_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": {}, + "nativeEnums": {} + }, + { + "columns": { + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "text" + }, + "spending_limit": { + "name": "spending_limit", + "type": "numeric", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "0", + "comment": null, + "enumItems": [], + "mappedType": "decimal" + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "false", + "comment": null, + "enumItems": [], + "mappedType": "boolean" + }, + "company_id": { + "name": "company_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "text" + }, + "raw_spending_limit": { + "name": "raw_spending_limit", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "'{\"value\":\"0\",\"precision\":20}'", + "comment": null, + "enumItems": [], + "mappedType": "json" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": "now()", + "comment": null, + "enumItems": [], + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": "now()", + "comment": null, + "enumItems": [], + "mappedType": "datetime" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "enumItems": [], + "mappedType": "datetime" + } + }, + "name": "employee", + "schema": "public", + "indexes": [ + { + "keyName": "IDX_employee_company_id", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_employee_company_id\" ON \"employee\" (\"company_id\") WHERE deleted_at IS NULL" + }, + { + "keyName": "IDX_employee_deleted_at", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_employee_deleted_at\" ON \"employee\" (\"deleted_at\") WHERE deleted_at IS NULL" + }, + { + "keyName": "employee_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "employee_company_id_foreign": { + "constraintName": "employee_company_id_foreign", + "columnNames": [ + "company_id" + ], + "localTableName": "public.employee", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.company", + "updateRule": "cascade" + } + }, + "nativeEnums": {} + } + ], + "nativeEnums": {} +} diff --git a/apps/backend/src/modules/company/migrations/Migration20260608232347.ts b/apps/backend/src/modules/company/migrations/Migration20260608232347.ts new file mode 100644 index 0000000..ea29b43 --- /dev/null +++ b/apps/backend/src/modules/company/migrations/Migration20260608232347.ts @@ -0,0 +1,24 @@ +import { Migration } from "@medusajs/framework/mikro-orm/migrations"; + +export class Migration20260608232347 extends Migration { + + override async up(): Promise { + this.addSql(`create table if not exists "company" ("id" text not null, "name" text not null, "email" text not null, "phone" text null, "address" text null, "city" text null, "state" text null, "zip" text null, "country" text null, "logo_url" text null, "currency_code" text null, "spending_limit_reset_frequency" text check ("spending_limit_reset_frequency" in ('never', 'daily', 'weekly', 'monthly', 'yearly')) not null default 'monthly', "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "company_pkey" primary key ("id"));`); + this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_company_deleted_at" ON "company" ("deleted_at") WHERE deleted_at IS NULL;`); + + this.addSql(`create table if not exists "employee" ("id" text not null, "spending_limit" numeric not null default 0, "is_admin" boolean not null default false, "company_id" text not null, "raw_spending_limit" jsonb not null default '{"value":"0","precision":20}', "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "employee_pkey" primary key ("id"));`); + this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_employee_company_id" ON "employee" ("company_id") WHERE deleted_at IS NULL;`); + this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_employee_deleted_at" ON "employee" ("deleted_at") WHERE deleted_at IS NULL;`); + + this.addSql(`alter table if exists "employee" add constraint "employee_company_id_foreign" foreign key ("company_id") references "company" ("id") on update cascade;`); + } + + override async down(): Promise { + this.addSql(`alter table if exists "employee" drop constraint if exists "employee_company_id_foreign";`); + + this.addSql(`drop table if exists "company" cascade;`); + + this.addSql(`drop table if exists "employee" cascade;`); + } + +} diff --git a/apps/backend/src/modules/invite/service.ts b/apps/backend/src/modules/invite/service.ts index 0568b79..fb7d69a 100644 --- a/apps/backend/src/modules/invite/service.ts +++ b/apps/backend/src/modules/invite/service.ts @@ -1,11 +1,16 @@ import crypto from "node:crypto"; import { MedusaService } from "@medusajs/framework/utils"; +import type { InferTypeOf } from "@medusajs/framework/types"; import { Invite } from "./models"; +// `Invite` is a DML model *value* (model.define(...)), not a type. The runtime +// entity shape is inferred via InferTypeOf so methods can be typed correctly. +type InviteDTO = InferTypeOf; + class InviteModuleService extends MedusaService({ Invite, }) { - async accept(token: string): Promise { + async accept(token: string): Promise { const tokenHash = crypto .createHash("sha256") .update(token) @@ -28,11 +33,12 @@ class InviteModuleService extends MedusaService({ return invite; } - async markUsed(inviteId: string): Promise { - return (await this.updateInvites({ + async markUsed(inviteId: string): Promise { + const updated = (await this.updateInvites({ id: inviteId, used_at: new Date(), - }))[0]; + })) as unknown as InviteDTO[]; + return updated[0]; } } diff --git a/apps/backend/src/scripts/reset-approval-pending.ts b/apps/backend/src/scripts/reset-approval-pending.ts new file mode 100644 index 0000000..20baa6d --- /dev/null +++ b/apps/backend/src/scripts/reset-approval-pending.ts @@ -0,0 +1,58 @@ +/** + * Reset the demo approval back to "pending" status for CEO reel re-capture. + * + * Resets BOTH: + * - approval.status = "pending" + * - approval_status.status = "pending" + * + * Required because updateApprovalStatusStep only sets approval_status to + * "approved" on approve — it does NOT revert on reset. This script directly + * resets both records so the admin /app/approvals shows "Pending" on re-capture. + * + * Run: + * npx medusa exec ./src/scripts/reset-approval-pending.ts + */ + +import { MedusaContainer } from "@medusajs/framework"; +import { ContainerRegistrationKeys } from "@medusajs/framework/utils"; +import { APPROVAL_MODULE } from "../modules/approval"; +import { IApprovalModuleService } from "../types"; + +const CART_ID = "cart_01KTJPADGC546FRCA517WJ2469"; +const APPROVAL_ID = "appr_01KTJPADHRQ6457KFCTF1JZ1VX"; +const APPROVAL_STATUS_ID = "apprstat_01KTJPADHZF03FYVDZDTMEY53D"; + +export default async function resetApprovalPending({ + container, +}: { + container: MedusaContainer; +}) { + const logger = container.resolve(ContainerRegistrationKeys.LOGGER); + const approvalModule = + container.resolve(APPROVAL_MODULE); + + logger.info(`=== Reset Approval to Pending ===`); + logger.info(`Approval ID: ${APPROVAL_ID}`); + logger.info(`ApprovalStatus ID: ${APPROVAL_STATUS_ID}`); + + // Reset approval.status to pending + const [updatedApproval] = await approvalModule.updateApprovals([ + { + id: APPROVAL_ID, + status: "pending" as any, + handled_by: null as any, + }, + ]); + logger.info(` approval.status → ${updatedApproval.status}`); + + // Reset approval_status.status to pending + const [updatedStatus] = await approvalModule.updateApprovalStatuses([ + { + id: APPROVAL_STATUS_ID, + status: "pending" as any, + }, + ]); + logger.info(` approval_status.status → ${updatedStatus.status}`); + + logger.info(`Done — approval and approval_status both PENDING.`); +} diff --git a/apps/backend/src/scripts/seed-demo-b2b.ts b/apps/backend/src/scripts/seed-demo-b2b.ts index a21800f..8a02089 100644 --- a/apps/backend/src/scripts/seed-demo-b2b.ts +++ b/apps/backend/src/scripts/seed-demo-b2b.ts @@ -67,6 +67,18 @@ const DEMO_BUYER_EMAIL = "demo-buyer@democorp.local" const DEMO_BUYER_PASSWORD = "Test1234!" const DEMO_COUNTRY = process.env.DEMO_COMPANY_COUNTRY || "nz" const DEMO_CURRENCY = process.env.DEMO_COMPANY_CURRENCY || "nzd" +// DFA-02: Employee spending limit — admin displays value 1:1 (no /100); 200 shows as NZ$200. +// cart.total for 2 items at NZ$130 each = 260 (major units), so 260 > 200 → banner fires. +// *orders expansion does NOT include order.total (undefined), so spent = 0 always. +const DEMO_EMPLOYEE_SPENDING_LIMIT = parseInt( + process.env.DEMO_EMPLOYEE_SPENDING_LIMIT || "200", + 10 +) +// DFA-05: Company spending limit narrated as NZ$2,000 = 200000 minor units. +const DEMO_COMPANY_SPENDING_LIMIT = parseInt( + process.env.DEMO_COMPANY_SPENDING_LIMIT || "200000", + 10 +) // Validate DEMO_COUNTRY is a known market iso2 — fail fast with a clear message. const validIso2s = SUPPORTED_MARKETS.map((m) => m.iso2) as readonly string[] @@ -107,14 +119,31 @@ export default async function seedDemoB2B({ logger.info("Step 1: Company...") + // `name` is a real column on the company model and filters at runtime, but + // ModuleCompanyFilters only declares q/id — narrow cast preserves the exact + // equality filter (using `q` would broaden to a fuzzy multi-field search). const existingCompanies = await companyModule.listCompanies({ name: DEMO_COMPANY_NAME, - }) + } as any) let company: any if (existingCompanies.length > 0) { company = existingCompanies[0] logger.info(` Company already exists (${company.id}) — skipping creation`) + // DFA-05 durability guard: if the existing company has a stale currency_code + // (e.g. EUR from a pre-NZD seed), correct it in-place so future reseeds + // self-heal without requiring a db:reset. + if (company.currency_code !== DEMO_CURRENCY) { + const prevCurrency = company.currency_code + await companyModule.updateCompanies({ + id: company.id, + currency_code: DEMO_CURRENCY, + } as any) + company.currency_code = DEMO_CURRENCY + logger.info( + ` Company currency corrected to ${DEMO_CURRENCY} (was: ${prevCurrency})` + ) + } } else { const { result: companies } = await createCompaniesWorkflow(container).run({ input: [ @@ -320,14 +349,52 @@ export default async function seedDemoB2B({ if (existingEmployees.length > 0) { employee = existingEmployees[0] logger.info(` Employee already exists (${employee.id}) — skipping creation`) + // DFA-02 durability guard: correct spending_limit if stale (e.g. 500000 from pre-DFA seed) + if (employee.spending_limit !== DEMO_EMPLOYEE_SPENDING_LIMIT) { + await (companyModule as any).updateEmployees({ + id: employee.id, + spending_limit: DEMO_EMPLOYEE_SPENDING_LIMIT, + }) + employee.spending_limit = DEMO_EMPLOYEE_SPENDING_LIMIT + logger.info( + ` Employee spending_limit corrected to ${DEMO_EMPLOYEE_SPENDING_LIMIT} (displays as NZ$${DEMO_EMPLOYEE_SPENDING_LIMIT.toLocaleString()})` + ) + } + // DFA-02b durability: ensure employee↔customer remote link exists. + // createEmployeesWorkflow (new-employee path) creates the link automatically, + // but existing employees skip that workflow — the link may be missing or stale. + const { data: empWithCustomer } = await query.graph({ + entity: "employee", + fields: ["id", "customer.*"], + filters: { id: employee.id }, + }) + const linkedCustomerId = (empWithCustomer[0] as any)?.customer?.id + if (linkedCustomerId !== customer.id) { + if (linkedCustomerId) { + await link.dismiss({ + [COMPANY_MODULE]: { employee_id: employee.id }, + [Modules.CUSTOMER]: { customer_id: linkedCustomerId }, + }) + logger.info(` Stale employee↔customer link dismissed (was: ${linkedCustomerId})`) + } + await link.create({ + [COMPANY_MODULE]: { employee_id: employee.id }, + [Modules.CUSTOMER]: { customer_id: customer.id }, + }) + logger.info(` Employee↔customer link created: ${employee.id} → ${customer.id}`) + } else { + logger.info(` Employee↔customer link already correct`) + } } else { const { result: emp } = await createEmployeesWorkflow(container).run({ input: { // customer_id is NOT a column on Employee — it is stored as a remote link. // Pass only the ORM-mapped fields; customerId drives the link step below. + // DFA-02: spending_limit is set below the seeded cart total (2x Mouse NZ$260 = NZ$520) + // so the over-limit banner fires in summary.tsx on first cart view. employeeData: { company_id: company.id, - spending_limit: 500000, + spending_limit: DEMO_EMPLOYEE_SPENDING_LIMIT, is_admin: true, } as any, customerId: customer.id, @@ -425,8 +492,10 @@ export default async function seedDemoB2B({ const { result: adminEmp } = await createEmployeesWorkflow(container).run({ input: { // customer_id is NOT a column on Employee — stored as a remote link only. + // DFA-05: admin employee spending_limit = company governance ceiling (NZ$2,000). employeeData: { company_id: company.id, + spending_limit: DEMO_COMPANY_SPENDING_LIMIT, is_admin: true, } as any, customerId: adminCustomer.id, @@ -513,6 +582,81 @@ export default async function seedDemoB2B({ logger.info(` Cart created and linked: ${cart.id}`) } + // ── Step 5b: Add NZD line items to approval cart (idempotent) ──────────── + // + // DFA-01: The approval in Step 6 attaches to this cart. If the cart has no + // line items, admin /app/approvals shows "0 items". We add 1-2 NZD-priced + // items now so the approval row displays a real item count and NZD subtotal. + // + // Idempotency: only add when cart has zero items. Re-running seed:demo when + // items already exist is a no-op. + + logger.info("Step 5b: Adding NZD line items to approval cart...") + + try { + const productModule = container.resolve(Modules.PRODUCT) + + // Fetch current cart items to check idempotency + const cartWithItems = await cartModule.retrieveCart(cart.id, { + relations: ["items"], + }) + const currentItems: any[] = (cartWithItems as any).items ?? [] + + if (currentItems.length > 0) { + logger.info( + ` Cart already has ${currentItems.length} item(s) — skipping line-item seed` + ) + } else { + // Prefer Wireless Mouse (NZ$260) — 2 units so cart total NZ$520 > employee limit NZ$200 + const [mouseVariant] = await productModule.listProductVariants({ + sku: "MOUSE-WHITE", + }) + const [mouseBlackVariant] = await productModule.listProductVariants({ + sku: "MOUSE-BLACK", + }) + const mouseVar = mouseVariant || mouseBlackVariant + + // Fallback to keyboard if no mouse SKU found + const [keyboardVariant] = await productModule.listProductVariants({ + sku: "KEYBOARD-BLACK", + }) + + const primaryVariant = mouseVar || keyboardVariant + if (!primaryVariant) { + logger.warn( + ` No known SKU found (MOUSE-WHITE, MOUSE-BLACK, KEYBOARD-BLACK) — ` + + `run the base seed.ts first. Approval cart will have 0 items.` + ) + } else { + const isMouseVariant = + primaryVariant.sku === "MOUSE-WHITE" || + primaryVariant.sku === "MOUSE-BLACK" + const unitPrice = isMouseVariant ? 260 : 159 + const itemTitle = isMouseVariant + ? "Wireless Mouse" + : "Wireless Keyboard | Touch ID | Numeric Keypad (Black)" + + await cartModule.addLineItems(cart.id, [ + { + variant_id: primaryVariant.id, + quantity: 2, + unit_price: unitPrice, + title: itemTitle, + }, + ]) + logger.info( + ` Added 2x ${itemTitle} @ NZ$${unitPrice} to approval cart ` + + `(total NZ$${unitPrice * 2} > employee limit NZ$${DEMO_EMPLOYEE_SPENDING_LIMIT / 100})` + ) + } + } + } catch (err: any) { + logger.warn( + ` Step 5b FAILED (line items) — approval cart will have 0 items. ` + + `Error: ${err.message}` + ) + } + // ── Step 6: Approval (idempotent) ──────────────────────────────────────── logger.info("Step 6: Approval...") @@ -533,11 +677,15 @@ export default async function seedDemoB2B({ const { result: approvals } = await createApprovalsWorkflow( container ).run({ + // The create-approval step derives `type` from approval_settings and + // ignores an input `type`; `status` is carried through harmlessly at + // runtime. ModuleCreateApproval declares neither, so narrow-cast the + // payload to keep the exact runtime input unchanged. input: { cart_id: cart.id, created_by: customer.id, status: ApprovalStatusType.PENDING, - }, + } as any, }) approval = Array.isArray(approvals) ? approvals[0] : approvals logger.info( @@ -554,16 +702,22 @@ export default async function seedDemoB2B({ logger.info("Step 7: Quote...") + // `customer_id` is a real column on the quote model and filters at runtime, + // but ModuleQuoteFilters declares only q/id/status — narrow cast preserves + // the exact per-customer idempotency lookup. const existingQuotes = await quoteModule.listQuotes({ customer_id: customer.id, - }) + } as any) if (existingQuotes.length > 0) { logger.info( ` Quote already exists (${existingQuotes[0].id}) — skipping creation` ) } else { - // Create a minimal draft order so the quote has a valid draft_order_id + // Create a minimal draft order so the quote has a valid draft_order_id. + // `is_draft_order` is a real persisted Order column honored by the workflow + // at runtime, but it is absent from CreateOrderDTO (only on UpdateOrderDTO), + // so narrow-cast the input to keep the draft flag set exactly as before. const { result: draftOrder } = await createOrdersWorkflow(container).run({ input: { is_draft_order: true, @@ -592,7 +746,7 @@ export default async function seedDemoB2B({ region_id: region.id, shipping_methods: [], promo_codes: [], - }, + } as any, }) const { result: orderChange } = await beginOrderEditOrderWorkflow( @@ -668,11 +822,13 @@ export default async function seedDemoB2B({ } // Idempotency check — look for an NZD demo-completed order on the resolved customer. + // `is_draft_order` is a real Order column and filters at runtime, but it is + // absent from FilterableOrderProps — narrow cast keeps the exact filter. const existingOrders = await orderModule.listOrders({ customer_id: orderCustomerId, is_draft_order: false, currency_code: region.currency_code, - }) + } as any) const existingDemoOrder = existingOrders.find( (o: any) => o.metadata?.demo_completed_order === true ) @@ -717,7 +873,9 @@ export default async function seedDemoB2B({ ? orderCustomers[0].email : customer.email - // Create the order in "pending" status first (createOrderWorkflow requirement) + // Create the order in "pending" status first (createOrderWorkflow requirement). + // `is_draft_order` is a real persisted Order column honored at runtime but + // absent from CreateOrderDTO — narrow-cast the input to keep the flag. const { result: newOrder } = await createOrderWorkflow(container).run({ input: { is_draft_order: false, @@ -756,7 +914,7 @@ export default async function seedDemoB2B({ demo_completed_order: true, company_id: company.id, }, - }, + } as any, }) // Mark the order as completed diff --git a/apps/backend/src/scripts/seed-demo-negotiated-quote.ts b/apps/backend/src/scripts/seed-demo-negotiated-quote.ts new file mode 100644 index 0000000..feb3930 --- /dev/null +++ b/apps/backend/src/scripts/seed-demo-negotiated-quote.ts @@ -0,0 +1,220 @@ +/** + * Demo Seed — Sales-Manager Quote Negotiation (Priya) — `medusa exec` path + * ──────────────────────────────────────────────────────────────────────── + * Scope: b2b-demo-salesmgr-quote-negotiate + * + * Purpose: + * Advance ONE existing demo-buyer quote from `pending_merchant` (draft_total + * == new_total, 0 messages) to a MERCHANT-COUNTERED state so the buyer-side + * quote detail page (.../account/quotes/details/[id]) renders: + * - Current Total (draft_order.total) != New Total (preview.total) + * - a Messages thread with >= 2 messages (buyer request + Priya counter) + * - status pending_customer -> Accept / Reject affordance is shown + * + * Mechanism (real Medusa v2 — NO invented schema): + * The RFQ already opened an order-edit (order_change, status=pending, 0 actions) + * on the quote's draft order. A merchant counter is a price change staged into + * that open edit via `orderEditUpdateItemQuantityWorkflow` (core-flow), which + * the store /quotes/[id]/preview route surfaces through `previewOrderChange` + * as the "New Total". We then add 2 quote messages via the project's own + * `createQuoteMessageWorkflow`, and flip status to pending_customer. + * + * Counter applied: lower the single line item's unit_price by ~10% (a volume + * discount the sales manager extends to win the deal). The per-line table cell + * then renders the original price struck-through and the counter in blue. + * + * Idempotent: re-running detects the already-countered state (status + * pending_customer AND >=2 messages AND preview.total != order.total) and is a + * no-op. Messages are de-duplicated by exact text. Reversible + local only. + * + * Run (inside container): + * npx medusa exec ./src/scripts/seed-demo-negotiated-quote.ts + * Run (from host): + * docker exec ec_backend npx medusa exec ./src/scripts/seed-demo-negotiated-quote.ts + */ + +import { ExecArgs, IOrderModuleService } from "@medusajs/framework/types"; +import { + ContainerRegistrationKeys, + Modules, +} from "@medusajs/framework/utils"; +import { orderEditUpdateItemQuantityWorkflow } from "@medusajs/core-flows"; +import { QUOTE_MODULE } from "../modules/quote"; +import { createQuoteMessageWorkflow } from "../workflows/quote/workflows"; + +const BUYER_EMAIL = "demo-buyer@democorp.local"; +const COUNTER_FACTOR = 0.9; // sales-manager extends a ~10% volume discount + +// Negotiation thread content (de-duped by exact text on re-run). +const BUYER_MSG = + "Hi — we're standardising on this model fleet-wide and would like to place a " + + "larger recurring order. Can you sharpen the unit price for us?"; +const MERCHANT_MSG = + "Priya here from Sales. Thanks for the volume commitment — I've applied a 10% " + + "volume discount on this line. Revised total is below; happy to finalise once " + + "you approve."; + +export default async function seedNegotiatedQuote({ container }: ExecArgs) { + const logger = container.resolve(ContainerRegistrationKeys.LOGGER); + const query = container.resolve(ContainerRegistrationKeys.QUERY); + const orderModule = container.resolve(Modules.ORDER); + const userModule = container.resolve(Modules.USER); + + // ── 1. Resolve buyer + pick a deterministic target quote ────────────────── + const { data: customers } = await query.graph({ + entity: "customer", + fields: ["id", "email"], + filters: { email: BUYER_EMAIL }, + }); + if (!customers.length) { + logger.error(`[negotiate-seed] customer ${BUYER_EMAIL} not found — run seed-demo-b2b first`); + return; + } + const customerId = customers[0].id; + + const { data: quotes } = await query.graph({ + entity: "quote", + fields: [ + "id", + "status", + "draft_order_id", + "order_change_id", + "created_at", + "messages.id", + "messages.text", + ], + filters: { customer_id: customerId }, + }); + if (!quotes.length) { + logger.error(`[negotiate-seed] no quotes for ${BUYER_EMAIL} — run seed-demo-b2b first`); + return; + } + + // Prefer an already-countered quote (so re-runs target the SAME one); else the + // most recently created pending_merchant quote. + const sorted = [...quotes].sort( + (a: any, b: any) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ); + const alreadyCountered = sorted.find( + (q: any) => q.status === "pending_customer" && (q.messages?.length || 0) >= 2 + ); + const target = alreadyCountered || sorted[0]; + + logger.info( + `[negotiate-seed] target quote ${target.id} (status=${target.status}, ` + + `draft_order=${target.draft_order_id}, messages=${target.messages?.length || 0})` + ); + + // ── 2. Read draft-order line item + current totals ──────────────────────── + const draftOrder = await orderModule.retrieveOrder(target.draft_order_id, { + relations: ["items"], + }); + const items = draftOrder.items || []; + if (!items.length) { + logger.error(`[negotiate-seed] draft order ${target.draft_order_id} has no items`); + return; + } + const line = items[0]; + const currentTotalBefore = Number(draftOrder.total); + const originalUnitPrice = Number(line.unit_price); + const counterUnitPrice = Math.round(originalUnitPrice * COUNTER_FACTOR * 100) / 100; + + logger.info( + `[negotiate-seed] line "${line.title}" qty=${line.quantity} ` + + `unit_price=${originalUnitPrice} -> counter ${counterUnitPrice} ` + + `(current order total=${currentTotalBefore})` + ); + + // ── 3. Stage the merchant counter into the open order-edit (price drop) ──── + // Only stage if the preview total does not already reflect the counter. + let previewBefore = await orderModule.previewOrderChange(target.draft_order_id); + const previewTotalBefore = Number((previewBefore as any).total); + + const counterAlreadyStaged = + Math.abs(previewTotalBefore - currentTotalBefore) > 0.001; + + if (!counterAlreadyStaged) { + await orderEditUpdateItemQuantityWorkflow(container).run({ + input: { + order_id: target.draft_order_id, + items: [ + { + id: line.id, + quantity: Number(line.quantity), + unit_price: counterUnitPrice, + }, + ], + }, + }); + logger.info(`[negotiate-seed] counter staged into order-edit`); + } else { + logger.info( + `[negotiate-seed] counter already staged (preview ${previewTotalBefore} != current ${currentTotalBefore}) — skipping price step` + ); + } + + // ── 4. Give the merchant user a human name so the thread shows "Priya …" ── + const [adminUser] = await userModule.listUsers({}, { take: 1 }); + let adminId: string | undefined = adminUser?.id; + if (adminUser && (!adminUser.first_name || !adminUser.last_name)) { + await userModule.updateUsers({ + id: adminUser.id, + first_name: "Priya", + last_name: "Sharma", + }); + logger.info(`[negotiate-seed] named merchant user ${adminUser.id} -> Priya Sharma`); + } + + // ── 5. Add the 2-message negotiation thread (de-dup by exact text) ──────── + const existingTexts = new Set((target.messages || []).map((m: any) => m.text)); + + if (!existingTexts.has(BUYER_MSG)) { + await createQuoteMessageWorkflow(container).run({ + input: { quote_id: target.id, text: BUYER_MSG, customer_id: customerId }, + }); + logger.info(`[negotiate-seed] added buyer message`); + } + if (!existingTexts.has(MERCHANT_MSG)) { + await createQuoteMessageWorkflow(container).run({ + input: { quote_id: target.id, text: MERCHANT_MSG, admin_id: adminId }, + }); + logger.info(`[negotiate-seed] added merchant (Priya) counter message`); + } + + // ── 6. Flip status to pending_customer (counter sent to buyer for decision) ─ + if (target.status !== "pending_customer") { + const quoteModule: any = container.resolve(QUOTE_MODULE); + await quoteModule.updateQuotes({ id: target.id, status: "pending_customer" }); + logger.info(`[negotiate-seed] status -> pending_customer`); + } + + // ── 7. Verify + report fetched values ───────────────────────────────────── + const verifyOrder = await orderModule.retrieveOrder(target.draft_order_id); + const verifyPreview = await orderModule.previewOrderChange(target.draft_order_id); + const { data: verifyQuotes } = await query.graph({ + entity: "quote", + fields: ["id", "status", "messages.id", "messages.text", "messages.admin_id", "messages.customer_id"], + filters: { id: target.id }, + }); + const vq = verifyQuotes[0]; + + const report = { + quote_id: target.id, + draft_order_id: target.draft_order_id, + status: vq.status, + current_total: Number(verifyOrder.total), + new_total: Number((verifyPreview as any).total), + totals_differ: Math.abs(Number(verifyOrder.total) - Number((verifyPreview as any).total)) > 0.001, + message_count: vq.messages?.length || 0, + messages: (vq.messages || []).map((m: any) => ({ + from: m.admin_id ? "merchant" : "customer", + text: m.text.slice(0, 60) + (m.text.length > 60 ? "…" : ""), + })), + }; + + logger.info(`[negotiate-seed] RESULT ${JSON.stringify(report, null, 2)}`); + logger.info( + `[negotiate-seed] DONE — Current=${report.current_total} New=${report.new_total} ` + + `differ=${report.totals_differ} messages=${report.message_count} status=${report.status}` + ); +} diff --git a/apps/backend/src/scripts/seed.ts b/apps/backend/src/scripts/seed.ts index dd4856c..db8a86d 100644 --- a/apps/backend/src/scripts/seed.ts +++ b/apps/backend/src/scripts/seed.ts @@ -809,7 +809,8 @@ export default async function seed({ headers: {}, query: {}, body: { email: adminEmail, password: adminPassword }, - authScope: "admin", + // Medusa v2: admin actor scope is "user" (was the non-existent `authScope`). + actor_type: "user", } ) diff --git a/apps/storefront/src/app/[countryCode]/(main)/account/@login/auth-callback/page.tsx b/apps/storefront/src/app/[countryCode]/(main)/account/@login/auth-callback/page.tsx new file mode 100644 index 0000000..2b73937 --- /dev/null +++ b/apps/storefront/src/app/[countryCode]/(main)/account/@login/auth-callback/page.tsx @@ -0,0 +1,106 @@ +// Force dynamic rendering so revalidateTag() runs in a live request context, +// not during static-page generation (Next.js 15 restriction). +export const dynamic = "force-dynamic" + +import { handleKeycloakCallback } from "@/lib/data/customer" +import { Text } from "@medusajs/ui" + +type Props = { + params: Promise<{ countryCode: string }> + searchParams: Promise> +} + +/** + * Keycloak SSO callback page (KC-3). + * + * URL: /{countryCode}/account/auth-callback?code=...&state=... + * + * Round-trip: + * 1. Storefront calls sdk.auth.login("customer","vymalo-keycloak",{}) + * → Medusa returns { location: "" } + * 2. Browser is hard-navigated to that URL (window.location.href in login component). + * 3. Keycloak authenticates the user, then redirects to: + * http://localhost:9000/auth/customer/vymalo-keycloak/callback?code=...&state=... + * Medusa backend validates the code with Keycloak and redirects to the + * storefront default_redirect_uri with code+state forwarded, landing here. + * 4. This page calls sdk.auth.callback("customer","vymalo-keycloak",{code,state}) + * → returns a JWT token. + * 5. Token is persisted to _medusa_jwt cookie (same as emailpass login). + * 6. Cache tags are revalidated, cart is transferred, customer is redirected + * to /{countryCode}/account. + * + * HITL-gated prerequisites for the live round-trip: + * - Backend .env: KEYCLOAK_* vars (realm, client_id, client_secret, url) + * - Backend .env: MEDUSA_VYMALO_KEYCLOAK_DEFAULT_REDIRECT_URI pointing to + * http://localhost:8000/{countryCode}/account/auth-callback + * - /etc/hosts: 127.0.0.1 keycloak (Docker service DNS) + * Without these, Medusa returns an error before this page is ever reached. + */ +export default async function AuthCallbackPage({ params, searchParams }: Props) { + const { countryCode } = await params + const query = await searchParams + + const code = Array.isArray(query.code) ? query.code[0] : query.code + const state = Array.isArray(query.state) ? query.state[0] : query.state + // scope is required by @vymalo/medusa-keycloak validateCallback. + // Keycloak does not include scope in the redirect URL, so fall back to the + // configured default scope. If scope is present (e.g. for other OIDC providers), use it. + const scope = Array.isArray(query.scope) ? query.scope[0] : (query.scope ?? "openid profile email") + + if (!code || !state) { + return ( +
+ SSO login failed + + Missing required parameters from the SSO provider. Please try again. + + + Back to sign in + +
+ ) + } + + // handleKeycloakCallback sets the _medusa_jwt cookie, revalidates cache tags, + // transfers the cart, and calls redirect() — so this function never returns + // normally; Next.js throws the NEXT_REDIRECT special error to handle navigation. + try { + await handleKeycloakCallback(code, state, countryCode, scope) + } catch (error: unknown) { + // NEXT_REDIRECT is thrown by redirect() — rethrow it so Next.js handles it. + if ( + error instanceof Error && + (error as NodeJS.ErrnoException).code === "NEXT_REDIRECT" + ) { + throw error + } + + const message = + error instanceof Error ? error.message : "Unknown SSO error" + + return ( +
+ SSO login failed + {message} + + Back to sign in + +
+ ) + } + + // redirect() above always throws NEXT_REDIRECT before we reach this line. + return null +} diff --git a/apps/storefront/src/app/[countryCode]/(main)/categories/[...category]/page.tsx b/apps/storefront/src/app/[countryCode]/(main)/categories/[...category]/page.tsx index bacb2cc..18c6d2c 100644 --- a/apps/storefront/src/app/[countryCode]/(main)/categories/[...category]/page.tsx +++ b/apps/storefront/src/app/[countryCode]/(main)/categories/[...category]/page.tsx @@ -38,28 +38,37 @@ export async function generateMetadata(props: Props): Promise { } export async function generateStaticParams() { - const countryCodes = await listRegions().then( - (regions) => - regions - ?.map((r) => r.countries?.map((c) => c.iso_2)) - .flat() - .filter(Boolean) as string[] - ) + try { + const countryCodes = await listRegions().then( + (regions) => + regions + ?.map((r) => r.countries?.map((c) => c.iso_2)) + .flat() + .filter(Boolean) as string[] + ) - if (!countryCodes) { - return null - } + if (!countryCodes) { + return [] + } - const categories = await listCategories() + const categories = await listCategories() - return countryCodes - .map((countryCode) => - categories.map((category) => ({ - countryCode, - category: category.handle.split("/"), - })) + return countryCodes + .map((countryCode) => + categories.map((category) => ({ + countryCode, + category: category.handle.split("/"), + })) + ) + .flat() + } catch (error) { + console.error( + `Failed to generate static paths for categories pages: ${ + error instanceof Error ? error.message : "Unknown error" + }.` ) - .flat() + return [] + } } export default async function CategoryPage(props: Props) { diff --git a/apps/storefront/src/app/[countryCode]/(main)/collections/[handle]/page.tsx b/apps/storefront/src/app/[countryCode]/(main)/collections/[handle]/page.tsx index bd32984..7203d88 100644 --- a/apps/storefront/src/app/[countryCode]/(main)/collections/[handle]/page.tsx +++ b/apps/storefront/src/app/[countryCode]/(main)/collections/[handle]/page.tsx @@ -19,37 +19,46 @@ type Props = { export const PRODUCT_LIMIT = 12 export async function generateStaticParams() { - const { collections } = await listCollections({ - offset: "0", - limit: "100", - }) - - if (!collections) { - return [] - } - - const countryCodes = await listRegions().then( - (regions: StoreRegion[]) => - regions - ?.map((r) => r.countries?.map((c) => c.iso_2)) - .flat() - .filter(Boolean) as string[] - ) - - const collectionHandles = collections.map( - (collection: StoreCollection) => collection.handle - ) + try { + const { collections } = await listCollections({ + offset: "0", + limit: "100", + }) + + if (!collections) { + return [] + } + + const countryCodes = await listRegions().then( + (regions: StoreRegion[]) => + regions + ?.map((r) => r.countries?.map((c) => c.iso_2)) + .flat() + .filter(Boolean) as string[] + ) - const staticParams = countryCodes - ?.map((countryCode: string) => - collectionHandles.map((handle: string | undefined) => ({ - countryCode, - handle, - })) + const collectionHandles = collections.map( + (collection: StoreCollection) => collection.handle ) - .flat() - return staticParams + const staticParams = countryCodes + ?.map((countryCode: string) => + collectionHandles.map((handle: string | undefined) => ({ + countryCode, + handle, + })) + ) + .flat() + + return staticParams + } catch (error) { + console.error( + `Failed to generate static paths for collections pages: ${ + error instanceof Error ? error.message : "Unknown error" + }.` + ) + return [] + } } export async function generateMetadata(props: Props): Promise { diff --git a/apps/storefront/src/app/[countryCode]/(main)/error.tsx b/apps/storefront/src/app/[countryCode]/(main)/error.tsx index ebeed72..3a50174 100644 --- a/apps/storefront/src/app/[countryCode]/(main)/error.tsx +++ b/apps/storefront/src/app/[countryCode]/(main)/error.tsx @@ -1,6 +1,7 @@ "use client" import { useEffect } from "react" +import Link from "next/link" export default function MainError({ error, @@ -34,12 +35,12 @@ export default function MainError({ > Try again - Go to store - +
diff --git a/apps/storefront/src/app/api/auth/keycloak-callback/route.ts b/apps/storefront/src/app/api/auth/keycloak-callback/route.ts new file mode 100644 index 0000000..073b5d5 --- /dev/null +++ b/apps/storefront/src/app/api/auth/keycloak-callback/route.ts @@ -0,0 +1,164 @@ +/** + * Keycloak SSO OAuth callback — Route Handler (Next.js 15). + * + * Why a Route Handler, not a Page: + * Next.js 15 forbids cookies.set() and revalidateTag() during Server Component + * render. Moving the OAuth exchange to a Route Handler (GET /api/auth/keycloak-callback) + * satisfies the framework constraint while keeping the auth logic server-side. + * + * Flow: + * 1. Keycloak redirects browser to /api/auth/keycloak-callback?code=...&state=... + * 2. This handler calls sdk.auth.callback() → gets JWT token + * 3. Sets _medusa_jwt cookie via NextResponse.cookies + * 4. Schedules cache revalidation via after() + * 5. Redirects to /{countryCode}/account (default: /nz/account) + * + * KEYCLOAK_CALLBACK_URL in docker-compose.yml must match this path: + * http://localhost:8000/api/auth/keycloak-callback + * + * Keycloak client redirect URIs in realm-export.json include http://localhost:8000/* + * which covers this path — no realm config change needed. + */ + +import { sdk } from "@/lib/config" +import { after } from "next/server" +import { revalidateTag } from "next/cache" +import { cookies as nextCookies } from "next/headers" +import { NextRequest, NextResponse } from "next/server" +import { B2BCustomer } from "@/types/global" + +// Country code default — matches KEYCLOAK_CALLBACK_URL base path +const DEFAULT_COUNTRY = process.env.NEXT_PUBLIC_DEFAULT_REGION ?? "nz" + +export async function GET(request: NextRequest): Promise { + const { searchParams } = request.nextUrl + + const code = searchParams.get("code") ?? undefined + const state = searchParams.get("state") ?? undefined + const scope = searchParams.get("scope") ?? "openid profile email" + + // Missing params — redirect back to login page with error + if (!code || !state) { + const errorUrl = new URL(`/${DEFAULT_COUNTRY}/account`, request.url) + errorUrl.searchParams.set("sso_error", "missing_params") + return NextResponse.redirect(errorUrl) + } + + let token: string + + try { + const result = await sdk.auth.callback("customer", "vymalo-keycloak", { + code, + state, + scope, + }) + + if (typeof result !== "string") { + throw new Error("Unexpected MFA response from SSO callback") + } + + token = result + } catch (err: unknown) { + const message = err instanceof Error ? encodeURIComponent(err.message) : "sso_error" + const errorUrl = new URL(`/${DEFAULT_COUNTRY}/account`, request.url) + errorUrl.searchParams.set("sso_error", message) + return NextResponse.redirect(errorUrl) + } + + // Build the success redirect response + const redirectUrl = new URL(`/${DEFAULT_COUNTRY}/account`, request.url) + const response = NextResponse.redirect(redirectUrl) + + // Set the JWT cookie on the response (Route Handler — cookies mutation allowed) + // NOTE: sameSite="lax" (not "strict") is required because this handler is reached + // via a cross-site redirect chain (Keycloak → our domain). SameSite=Strict cookies + // are blocked in this scenario in some browsers. Lax allows same-site navigations + // and cross-site top-level GET navigations (which this redirect is). + response.cookies.set("_medusa_jwt", token, { + maxAge: 60 * 60 * 24 * 7, + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + }) + + console.log(`[keycloak-callback] token=${token.slice(0, 20)}... setting cookie, redirecting to ${redirectUrl.toString()}`) + console.log(`[keycloak-callback] Set-Cookie headers: ${JSON.stringify(response.headers.get("set-cookie"))}`) + + // Fetch customer for company_id (needed for cart metadata) — direct SDK call, no cache + const authHeaders = { authorization: `Bearer ${token}` } + let customer = await sdk.client + .fetch<{ customer: B2BCustomer }>(`/store/customers/me`, { + method: "GET", + query: { fields: "*employee, *orders" }, + headers: authHeaders, + }) + .then(({ customer }) => customer as B2BCustomer) + .catch(() => null) + + // Auto-provision: Medusa v2 auth.callback creates an auth_identity but does NOT + // create the customer record. If the customer doesn't exist yet (401 → null), + // create it now and link it to the auth identity via sdk.store.customer.create(). + if (!customer) { + try { + // Decode the JWT payload to extract user info (no signature verification + // needed — the token was just issued by the backend in this same request). + const payloadB64 = token.split(".")[1] + const payload = payloadB64 + ? JSON.parse(Buffer.from(payloadB64, "base64url").toString("utf-8")) + : {} + + const newCustomer = await sdk.store.customer + .create( + { + email: payload.email ?? "", + first_name: payload.given_name ?? payload.name?.split(" ")[0] ?? "", + last_name: payload.family_name ?? payload.name?.split(" ").slice(1).join(" ") ?? "", + }, + {}, + authHeaders + ) + .then(({ customer }) => customer as B2BCustomer) + .catch(() => null) + + customer = newCustomer + } catch { + // Non-fatal: auto-provision failure redirects to account page where + // Medusa's own "complete registration" flow can run. + } + } + + // Update cart company_id if employee is linked to a company + if (customer?.employee?.company_id) { + try { + const cartIdCookie = request.cookies.get("_medusa_cart_id")?.value + if (cartIdCookie) { + await sdk.store.cart.update( + cartIdCookie, + { + metadata: { + company_id: customer.employee.company_id, + }, + }, + {}, + authHeaders + ) + } + } catch { + // Non-fatal: cart metadata update failure does not block SSO login + } + } + + // Defer cache invalidation to after() — safe in Route Handler context + after(async () => { + const cookies = await nextCookies() + const cacheId = cookies.get("_medusa_cache_id")?.value + if (cacheId) { + revalidateTag(`customers-${cacheId}`) + revalidateTag(`products-${cacheId}`) + revalidateTag(`carts-${cacheId}`) + } + }) + + return response +} diff --git a/apps/storefront/src/lib/data/customer.ts b/apps/storefront/src/lib/data/customer.ts index 15d06e5..3dfeb94 100644 --- a/apps/storefront/src/lib/data/customer.ts +++ b/apps/storefront/src/lib/data/customer.ts @@ -6,6 +6,7 @@ import { B2BCustomer } from "@/types/global" import { HttpTypes } from "@medusajs/types" import { track } from "@vercel/analytics/server" import { revalidateTag } from "next/cache" +import { after } from "next/server" import { redirect } from "next/navigation" import { retrieveCart, updateCart } from "./cart" import { createCompany, createEmployee } from "./companies" @@ -201,6 +202,102 @@ export async function signout(countryCode: string, customerId: string) { redirect(`/${countryCode}/account`) } +/** + * Initiates the Keycloak SSO login flow (third-party OIDC). + * + * Calls sdk.auth.login with actor="customer", method="vymalo-keycloak". + * Returns { location: string } — the caller must redirect the browser to that URL. + * This is a server action so it can only be called from a client component that + * uses router.push / window.location, or from a Server Action that calls redirect(). + */ +export async function loginWithKeycloak(): Promise<{ location: string } | string> { + try { + const result = await sdk.auth.login("customer", "vymalo-keycloak", {}) + + if (typeof result === "object" && "location" in result) { + return { location: result.location } + } + + // Unexpected: provider returned a token directly (should not happen for OIDC) + return result as string + } catch (error: any) { + return error.toString() + } +} + +/** + * Finalizes the Keycloak SSO callback. + * + * Called from the storefront callback page after Keycloak/Medusa redirects back + * with `code` and `state` query params. Mirrors the session-persistence logic of + * login() — same setAuthToken + revalidateTag + transferCart sequence so that + * retrieveCustomer() works immediately after redirect. + */ +export async function handleKeycloakCallback( + code: string, + state: string, + countryCode: string, + // scope is required by @vymalo/medusa-keycloak@1.0.10 validateCallback. + // Keycloak does not echo scope back in the redirect URL, so we default to the + // configured KEYCLOAK_SCOPE value (openid profile email). + scope: string = "openid profile email" +): Promise { + const result = await sdk.auth.callback("customer", "vymalo-keycloak", { + code, + state, + scope, + }) + + if (typeof result !== "string") { + // MFA required — not expected for this B2B SSO flow; surface as error + throw new Error("Unexpected MFA response from SSO callback") + } + + const token = result + + track("customer_logged_in_sso") + await setAuthToken(token) + + // Fetch customer directly (no cache — auth token was just set above) + const authHeaders = { authorization: `Bearer ${token}` } + const customer = await sdk.client + .fetch<{ customer: B2BCustomer }>(`/store/customers/me`, { + method: "GET", + query: { fields: "*employee, *orders" }, + headers: authHeaders, + }) + .then(({ customer }) => customer as B2BCustomer) + .catch(() => null) + + const cart = await retrieveCart() + + if (customer?.employee?.company_id) { + await updateCart({ + metadata: { + ...cart?.metadata, + company_id: customer.employee.company_id, + }, + }) + } + + await transferCart() + + // Defer cache invalidations to after() so revalidateTag does not run during + // the Server Component render phase (Next.js 15 restriction). + after(async () => { + const [customerCacheTag, productsCacheTag, cartsCacheTag] = await Promise.all([ + getCacheTag("customers"), + getCacheTag("products"), + getCacheTag("carts"), + ]) + if (customerCacheTag) revalidateTag(customerCacheTag) + if (productsCacheTag) revalidateTag(productsCacheTag) + if (cartsCacheTag) revalidateTag(cartsCacheTag) + }) + + redirect(`/${countryCode}/account`) +} + export async function transferCart() { const cartId = await getCartId() diff --git a/apps/storefront/src/modules/account/components/login/index.tsx b/apps/storefront/src/modules/account/components/login/index.tsx index 5ffd1ca..ef87231 100644 --- a/apps/storefront/src/modules/account/components/login/index.tsx +++ b/apps/storefront/src/modules/account/components/login/index.tsx @@ -1,11 +1,11 @@ -import { login } from "@/lib/data/customer" +import { login, loginWithKeycloak } from "@/lib/data/customer" import { LOGIN_VIEW } from "@/modules/account/templates/login-template" import ErrorMessage from "@/modules/checkout/components/error-message" import { SubmitButton } from "@/modules/checkout/components/submit-button" import Button from "@/modules/common/components/button" import Input from "@/modules/common/components/input" import { Checkbox, Text } from "@medusajs/ui" -import { useActionState } from "react" +import { useActionState, useState, useTransition } from "react" type Props = { setCurrentView: (view: LOGIN_VIEW) => void @@ -13,6 +13,22 @@ type Props = { const Login = ({ setCurrentView }: Props) => { const [message, formAction] = useActionState(login, null) + const [ssoError, setSsoError] = useState(null) + const [isPending, startTransition] = useTransition() + + const handleSsoLogin = () => { + setSsoError(null) + startTransition(async () => { + const result = await loginWithKeycloak() + if (typeof result === "string") { + setSsoError(result) + return + } + // result.location is the Keycloak authorize URL — hard-navigate so cookies + // and the OIDC state param survive the redirect. + window.location.href = result.location + }) + } return (
{
+ +
+
+ or +
+
+ +
+ + {ssoError && ( + + {ssoError} + + )} +
) } diff --git a/docker-compose.keycloak.yml b/docker-compose.keycloak.yml new file mode 100644 index 0000000..011b9a7 --- /dev/null +++ b/docker-compose.keycloak.yml @@ -0,0 +1,104 @@ +# docker-compose.keycloak.yml — B2B-Commerce Keycloak SSO overlay +# +# KC-1 / KC-2 / KC-5-infra implementation. +# ADLC Architecture Decision (2026-06-09): +# +# Usage: +# task up (includes this file via -f overlay — keycloak cold-boots with commerce) +# task keycloak:up (standalone start — ec_postgres must already be running) +# task keycloak:down (stop keycloak only) +# +# Port Decision (CA-locked): +# - Publish 8080:8080 ONLY. +# - DO NOT publish 9000 — it collides with the Medusa backend host port. +# - Keycloak management/metrics on container port 9000 are reachable via ec_network DNS +# (Prometheus scrapes http://ec_keycloak:9000/metrics — no host exposure needed). +# +# DB Decision: +# - Keycloak uses a dedicated `keycloak` DB inside ec_postgres. +# - `infra/keycloak/init-db.sql` creates the DB (idempotent); run via `task keycloak:db-init`. +# - The init-db.sql is executed as a pre-dep of `task up` so fresh volumes get the DB +# before Keycloak attempts to connect. +# +# Realm: +# - `infra/keycloak/realm-export.json` is imported at startup via --import-realm. +# - Realm: medusa-commerce; client: medusa; demo user: sso.buyer@demo.com / SsoBuyer2026! +# +# Network: joins ec_network (external) — created by docker-compose.yml. +# All service names resolve via Docker DNS on ec_network: +# ec_postgres:5432, ec_backend:9000, ec_keycloak:8080, ec_keycloak:9000 +# +# Secrets hygiene: +# KEYCLOAK_ADMIN_PASSWORD is read from env or .env (never hardcoded here for prod). +# Local dev placeholder is acceptable; rotate for staging/prod. + +services: + + keycloak: + image: nnthanh101/keycloak:26.2-b2b + build: + context: ./infra/keycloak + dockerfile: Dockerfile + container_name: ec_keycloak + restart: unless-stopped + command: + - start-dev + - --import-realm + environment: + # Admin credentials — local dev only; override via .env or shell for staging/prod + - KEYCLOAK_ADMIN=${KEYCLOAK_ADMIN:-admin} + - KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN_PASSWORD:-admin} + + # Hostname / issuer — keycloak resolves via ec_network DNS; browser uses /etc/hosts or localhost + - KC_HOSTNAME=keycloak + - KC_HOSTNAME_STRICT=false + - KC_HTTP_ENABLED=true + + # Management / metrics — served on container port 9000 (NOT published to host) + - KC_HEALTH_ENABLED=true + - KC_METRICS_ENABLED=true + + # Database — connects to the keycloak DB in the shared ec_postgres container + - KC_DB=postgres + - KC_DB_URL=jdbc:postgresql://ec_postgres:5432/keycloak + - KC_DB_USERNAME=postgres + - KC_DB_PASSWORD=${POSTGRES_PASSWORD:-postgres} + + # Logging + - KC_LOG_LEVEL=INFO + ports: + # Only 8080 (HTTP) is published to the host. + # 9000 (management) stays container-internal to avoid collision with Medusa backend :9000 + - "8080:8080" + volumes: + # Realm export imported at start-dev via --import-realm (idempotent — skipped if realm exists) + - ./infra/keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro + networks: + - ec_network + depends_on: + postgres: + condition: service_started + healthcheck: + # Keycloak 26.x (UBI9-micro base) has no curl or wget, and kc.sh has no 'health' + # subcommand (verified: exit=2 "Unknown option: 'health'"). + # bash IS guaranteed present: kc.sh is a bash script (#!/bin/bash shebang), so bash + # must exist for kc.sh itself to run. The /dev/tcp pseudo-device is a bash built-in + # (no external binary required) and probes the management port synchronously. + test: + - "CMD" + - "bash" + - "-c" + - "exec 3<>/dev/tcp/localhost/9000; printf 'GET /health/ready HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' >&3; grep -q '200 OK' <&3" + interval: 30s + timeout: 10s + retries: 10 + start_period: 90s + labels: + application: b2b-commerce + service: keycloak-sso + environment: local + managed-by: adlc + +networks: + ec_network: + external: true diff --git a/docker-compose.observability.yml b/docker-compose.observability.yml index 2b858fa..fff85a8 100644 --- a/docker-compose.observability.yml +++ b/docker-compose.observability.yml @@ -3,16 +3,21 @@ # ADR-007 AMENDMENT 2026-06-05: hybrid-cloud SSOT, execute NOW # # Usage: -# docker compose -f docker-compose.yml -f docker-compose.observability.yml up -d -# OR standalone (observability-only, app containers must already be running): -# docker compose -f docker-compose.observability.yml up -d +# task up (includes this file via -f overlay — observability cold-boots with commerce) +# task obs:up (standalone start — app containers must already be running) +# task obs:down (stop observability only, leaves main platform running) # # Registry exemption (ADR-007 §8): prom/*, grafana/*, prometheuscommunity/*, oliver006/* # are semver-pinned observability images with no nnthanh101/* equivalent. # Pinned tags prevent supply-chain drift. Review at each quarterly version reconciliation. # -# Network: joins ec_network (external) — created by docker-compose.yml. -# All targets use Docker DNS service names (ec, postgres-exporter, redis-exporter, node-exporter). +# Network: joins ec_network (external) — created by docker-compose.yml with `name: ec_network`. +# All targets use Docker container names (ec_backend, ec_postgres, ec_redis) for cross-project DNS. +# The `name: ec_network` in docker-compose.yml ensures both stacks share one named network. +# +# SRE contract: observability is a CRITICAL SRE task but MUST NOT block the commerce stack. +# - ec and storefront have NO depends_on pointing here. +# - prometheus and grafana have healthchecks (self-healing), not dependency gates. # # Secrets hygiene (ADR-007 §9): GF_SECURITY_ADMIN_PASSWORD set via env var only. # Set in shell or .env (gitignored). Default "admin" is blocked here — must be overridden. @@ -22,7 +27,7 @@ services: # ─── Prometheus ──────────────────────────────────────────────────────────────── prometheus: - image: prom/prometheus:v2.55.0 + image: prom/prometheus:v3.4.1 container_name: ec_prometheus restart: unless-stopped command: @@ -39,6 +44,12 @@ services: - prometheus_data:/prometheus networks: - ec_network + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9090/-/healthy"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s labels: application: b2b-commerce service: observability @@ -47,14 +58,14 @@ services: # ─── Grafana ─────────────────────────────────────────────────────────────────── grafana: - image: grafana/grafana:11.3.0 + image: grafana/grafana:12.0.1 container_name: ec_grafana restart: unless-stopped ports: - "3000:3000" environment: - GF_SECURITY_ADMIN_USER=${GF_ADMIN_USER:-admin} - - GF_SECURITY_ADMIN_PASSWORD=${GF_ADMIN_PASSWORD:-Adm1n@Local2026} + - GF_SECURITY_ADMIN_PASSWORD=${GF_ADMIN_PASSWORD:?GF_ADMIN_PASSWORD must be set (add to .env or shell before task up)} - GF_USERS_ALLOW_SIGN_UP=false - GF_SERVER_ROOT_URL=http://localhost:3000 - GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH=/etc/grafana/dashboards/commerce.json @@ -71,6 +82,12 @@ services: - ec_network depends_on: - prometheus + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 20s labels: application: b2b-commerce service: observability @@ -78,14 +95,13 @@ services: managed-by: adlc # ─── Postgres exporter ──────────────────────────────────────────────────────── - # Connects to the ec_postgres container (postgres:5432 inside ec_network) - # DSN uses Docker DNS: postgres resolves to the postgres service in ec_network + # CROSS-PROJECT DNS: uses container name ec_postgres:5432 (resolved via named ec_network) postgres-exporter: - image: prometheuscommunity/postgres-exporter:v0.15.0 + image: prometheuscommunity/postgres-exporter:v0.16.0 container_name: ec_postgres_exporter restart: unless-stopped environment: - - DATA_SOURCE_NAME=postgresql://postgres:postgres@postgres:5432/ec-store?sslmode=disable + - DATA_SOURCE_NAME=postgresql://postgres:${POSTGRES_PASSWORD:-postgres}@ec_postgres:5432/ec-store?sslmode=disable ports: - "9187:9187" networks: @@ -97,13 +113,13 @@ services: managed-by: adlc # ─── Redis exporter ─────────────────────────────────────────────────────────── - # Connects to the ec_redis container (redis:6379 inside ec_network) + # CROSS-PROJECT DNS: uses container name ec_redis:6379 (no scheme — Go net.Dial treats scheme as network type). redis-exporter: - image: oliver006/redis_exporter:v1.62.0 + image: oliver006/redis_exporter:v1.66.0 container_name: ec_redis_exporter restart: unless-stopped environment: - - REDIS_ADDR=redis://redis:6379 + - REDIS_ADDR=ec_redis:6379 ports: - "9121:9121" networks: @@ -118,7 +134,7 @@ services: # Scrapes host metrics (CPU, memory, disk) via /proc and /sys bind mounts # macOS: pid_filesystem not available — node-exporter runs in degraded mode (expected) node-exporter: - image: prom/node-exporter:v1.8.0 + image: prom/node-exporter:v1.9.1 container_name: ec_node_exporter restart: unless-stopped command: diff --git a/docker-compose.yml b/docker-compose.yml index 5d7cfe3..80470ce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,12 +1,13 @@ services: postgres: - image: postgres:15-alpine + # PostgreSQL 17 (LTS, 2026-2030). UPGRADE from PG15: CONFIRM=1 task db:reset (drops volume + reseeds). + image: postgres:17-alpine container_name: ec_postgres restart: unless-stopped environment: POSTGRES_DB: ec-store POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} ports: - "5432:5432" volumes: @@ -15,7 +16,7 @@ services: - ec_network redis: - image: redis:7-alpine + image: redis:8-alpine container_name: ec_redis restart: unless-stopped ports: @@ -35,8 +36,16 @@ services: - "5173:5173" environment: - NODE_ENV=development - - DATABASE_URL=postgres://postgres:postgres@postgres:5432/ec-store + - DATABASE_URL=postgres://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/ec-store - REDIS_URL=redis://redis:6379 + # Keycloak SSO — local-dev config (not secrets; matches realm-export.json client `medusa`) + # These override/augment env_file so no .env edit is required to enable SSO. + - KEYCLOAK_URL=http://keycloak:8080 + - KEYCLOAK_REALM=medusa-commerce + - KEYCLOAK_CLIENT_ID=medusa + - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-medusa-dev-secret-changeme-in-prod} + - KEYCLOAK_SCOPE=openid profile email + - KEYCLOAK_CALLBACK_URL=http://localhost:8000/api/auth/keycloak-callback env_file: - apps/backend/.env volumes: @@ -65,7 +74,7 @@ services: - /server/apps/backend/node_modules - /server/apps/storefront/node_modules - /server/apps/storefront/.next - entrypoint: ["./scripts/start-storefront.sh"] + entrypoint: ["sh", "./scripts/start-storefront.sh"] networks: - ec_network @@ -74,4 +83,5 @@ volumes: networks: ec_network: + name: ec_network driver: bridge diff --git a/docs/content/architecture/adrs/ADR-017-demo-video-pipeline.md b/docs/content/architecture/adrs/ADR-017-demo-video-pipeline.md new file mode 100644 index 0000000..ae418df --- /dev/null +++ b/docs/content/architecture/adrs/ADR-017-demo-video-pipeline.md @@ -0,0 +1,106 @@ +--- +title: "ADR-017: Demo Video Pipeline Hardening" +description: Correct-by-construction demo-video pipeline — assert settled DOM before capture, gate per-flow manifest integrity before assembly. +sidebar_position: 17 +tags: [adr, demo, video, pipeline, capture, ci] +source_refs: + - path: "scripts/capture-flows.mjs" + last_compiled: "2026-06-08" + - path: "scripts/batch-demo-video.sh" + last_compiled: "2026-06-08" +last_compiled: "2026-06-08T00:00:00Z" +--- + +# ADR-017: Demo Video Pipeline Hardening + +**Status**: Accepted +**Date**: 2026-06-08 +**Deciders**: product-owner, cloud-architect, HITL +**Relates to**: [ADR-009](./ADR-009-apps-as-first-party-not-upstream.md), [ADR-010](./ADR-010-medusa-ootb-extended.md) + +## Summary + +The B2B-Commerce demo video pipeline uses screenshot-slideshow composition (Option A) rather than realtime screen recording (Option B). This ADR documents the decision, the hardening mechanisms implemented in Phase 1, and the Phase 2 deferral. + +**Phase 1 Shipped**: +- DOM settlement guard in `capture-flows.mjs` — prevents early captures before UI renders +- Per-flow manifest integrity gate in `batch-demo-video.sh` — validates screenshot count matches narration scenes before video assembly + +**Phase 2 Deferred**: +- Narration text overlay via HTML→PNG headless Playwright (ffmpeg drawtext unavailable in container) + +## Context + +The B2B-Commerce demo pipeline must generate a series of short videos, each 15–90 seconds, showcasing specific business workflows (cart-to-quote, approval, company management, etc.). The pipeline consists of two stages: + +1. **Capture stage** (`capture-flows.mjs`): Playwright drives a user journey through the storefront, capturing PNG screenshots at key interaction points +2. **Assembly stage** (`batch-demo-video.sh`): TTS (text-to-speech) generates audio narration; ffmpeg concatenates PNG images with audio via the concat demuxer + `-shortest` flag, so each screenshot displays for exactly the duration of its corresponding audio segment + +Two technical approaches were evaluated: + +- **Option A (screenshot slideshow)**: Compose PNG images with audio timing driven by TTS duration + ffmpeg concat-filter +- **Option B (realtime capture)**: Use Playwright `recordVideo` or macOS `screencapture`/`ffmpeg -f avfoundation` to capture the live screen recording + +## Decision + +**Option A (screenshot slideshow) ACCEPTED** + +TTS audio becomes the time axis. Each PNG loops for exactly the duration of its corresponding audio segment via ffmpeg `-shortest` flag. The concat-filter resets the timebase after each image→audio pair, preventing cumulative A/V drift over multi-scene videos. This approach is: + +- **Deterministic**: Same input (PNG set, narration text) always produces the same MP4 (reproducible in CI) +- **Slide-lock**: Each screenshot is guaranteed to display for its audio duration; no sync issues +- **Container-safe**: ffmpeg + TTS work in any Alpine container; no GUI/screencapture dependencies + +**Hardening (Phase 1)**: + +1. **DOM Settlement Guard** (`capture-flows.mjs` L45–67): Playwright waits for all network activity to settle and DOM to stabilize before capturing. Pattern: + ```javascript + await page.waitForLoadState('networkidle') + await page.waitForTimeout(500) // additional visual settle time + ``` + Prevents capturing partially-rendered UI, blank prices, or stuck spinners. + +2. **Manifest Integrity Gate** (`batch-demo-video.sh` L82–94): Before assembling video, compare screenshot count in the flow directory to scene count in the narration manifest. Exit 2 if mismatch — blocks assembly of incomplete or extra stills. + +## Option B Rejected + +**Realtime capture approach NOT accepted.** + +- **Playwright `recordVideo`**: Produces silent WebM; audio must still be muxed separately — no simplification of the assembly stage +- **macOS `screencapture`**: Non-deterministic frame timing; subject to OS resource contention; not reproducible in CI +- **ffmpeg `-f avfoundation`**: Requires macOS-specific flags; no Alpine Linux equivalent; blocks CI containerization +- **Net effect**: Complexity increases (capture code + audio-mux code both required), sync guarantees decrease (realtime timing is inherently less predictable than scheduled TTS duration) + +## Consequences + +**Phase 1 Shipped**: +- DOM settlement guard operational; captured stills are correct-state (non-empty prices, no loading spinners) +- Manifest gate operational; mismatch between stills and narration scenes caught before video assembly +- E2E test coverage: `tests/e2e/generated/demo-*.spec.ts` (Playwright test specs for each flow) + +**Phase 2 Deferred**: +- Narration text overlay (e.g., "Step 1: Add product to cart") requires HTML→PNG rendering via headless Chromium +- ffmpeg `drawtext` filter unavailable in Alpine container (freetype library missing) +- Workaround: Render overlay HTML via Playwright in a headless container, convert to PNG per-scene, then composite with concat-filter +- **Timeline**: Deferred beyond Phase 1; low priority (video is self-explanatory via UI screenshots + TTS narration) + +**Path Restructuring Decisions** (both KEEP): + +- **KEEP `/demo/` in static path references**: 17 existing `pathname:///img/demo/` references in markdown + 11 video embeds point to `/demo/` subdirectories. Migration cost (find-replace + test) exceeds value. No architectural reason to move. +- **KEEP separate `docs/static/img/` and `docs/static/video/` dirs**: Screenshots go to `img/demo/`; MP4s go to `video/demo/`. Aligns with Docusaurus static-asset conventions. Combined per-flow dirs would create unconventional structure with no deployment or UX benefit. + +## Trade-offs + +**Accepted**: TTS-driven timing discipline. Every demo uses standardized narrator (macOS `say` voice Daniel, en_GB) and fixed narration-to-screenshot mapping. + +**Rejected**: Custom voice cloning (e.g., AWS Polly, Google Cloud TTS) would allow different narrators per workflow. Deferred — standardized narrator suffices for Phase 1 demo use case. + +**Trade-off**: DOM settlement guard adds ~2–3 seconds per capture (network settle + visual timeout). Acceptable for demo pipeline (not real-time); capture runs autonomously in `task seed:demo` + Playwright batch mode. + +## Cross-References + +- [ADR-009](./ADR-009-apps-as-first-party-not-upstream.md) — Apps as first-party; demo use cases inform UX +- [ADR-010](./ADR-010-medusa-ootb-extended.md) — Medusa storefront extensibility via custom modules; demo flows exercise these extensions +- `scripts/capture-flows.mjs` — Playwright capture driver with DOM settlement guard +- `scripts/batch-demo-video.sh` — ffmpeg assembly with manifest integrity gate +- `tests/e2e/generated/demo-*.spec.ts` — Playwright test suite for demo flows diff --git a/docs/content/architecture/adrs/index.md b/docs/content/architecture/adrs/index.md index 5340c04..9c4e760 100644 --- a/docs/content/architecture/adrs/index.md +++ b/docs/content/architecture/adrs/index.md @@ -33,3 +33,4 @@ This directory contains accepted architectural decisions for the B2B-Commerce pl | [ADR-014](./ADR-014-adlc-subagent-governance.md) | ADLC Subagent Governance | Accepted | | [ADR-015](./ADR-015-local-first-terraform-iac.md) | Local-First Terraform IaC | Accepted | | [ADR-016](./ADR-016-observability-under-infra.md) | Observability Configs Under infra/ | Accepted | +| [ADR-017](./ADR-017-demo-video-pipeline.md) | Demo Video Pipeline Hardening | Accepted | diff --git a/docs/content/auth/index.md b/docs/content/auth/index.md new file mode 100644 index 0000000..e07d137 --- /dev/null +++ b/docs/content/auth/index.md @@ -0,0 +1,36 @@ +--- +title: "Index: Authentication & SSO" +description: Single Sign-On (SSO) and authentication documentation for B2B-Commerce — Keycloak integration, OIDC flows, and token validation. +tags: [auth, sso, keycloak, oidc] +source_refs: + - path: "apps/storefront/src/app/[countryCode]/(main)/account/@login/auth-callback" + last_compiled: "2026-06-09" + - path: "infra/keycloak/" + last_compiled: "2026-06-09" +last_compiled: "2026-06-09T00:00:00Z" +--- + +# Index: Authentication & SSO + +Single Sign-On (SSO) integration for B2B-Commerce users — company employees authenticate via enterprise Keycloak realms, with role-based access control (buyer, sales-manager, admin). + +| Page | Purpose | +|------|---------| +| [Concept: Keycloak SSO](./keycloak-sso.md) | OIDC realm configuration, token validation, employee group mapping to B2B roles | + +## Authentication Flow + +``` +Employee Login + ↓ (OIDC redirect to Keycloak) +Keycloak Realm (company-specific) + ↓ (token + groups claim) +Storefront Auth-Callback + ↓ (sync employee + roles) +B2B Approval Workflow +``` + +## Related Documentation + +- [Entity: Approval Module](../modules/approval-module.md) — role-based approval gates that depend on auth +- [Entity: Company Module](../modules/company-module.md) — employee group membership and role assignment diff --git a/docs/content/auth/keycloak-sso.md b/docs/content/auth/keycloak-sso.md new file mode 100644 index 0000000..da45fb4 --- /dev/null +++ b/docs/content/auth/keycloak-sso.md @@ -0,0 +1,236 @@ +--- +title: Keycloak SSO Integration +description: Local-first Keycloak identity provider for B2B-Commerce storefront and backend authentication. How to run it, configure SSO login, and understand the admin dashboard limitation. +sidebar_position: 1 +tags: [authentication, sso, keycloak, oidc, local-dev, security] +source_refs: + - path: "docker-compose.yml" + last_compiled: "2026-06-08" + - path: "apps/backend/.env.template" + last_compiled: "2026-06-08" +last_compiled: "2026-06-09T00:00:00Z" +--- + +# Keycloak SSO Integration + +B2B-Commerce integrates with Keycloak 26.2 for OpenID Connect (OIDC) authentication. The Medusa backend registers Keycloak as a confidential OIDC provider alongside the built-in email/password auth. The storefront includes a "Sign in with SSO" button; users authenticate via Keycloak and return to a logged-in session. + +## What Ships + +- **Keycloak 26.2** — Local Docker service (`ec_keycloak`, host port 8080) +- **Pre-configured Realm** — `medusa-commerce` realm with demo users and a confidential client (`medusa`) +- **OIDC Provider** — Medusa backend registers `@vymalo/medusa-keycloak@1.0.10` as an auth provider for both **users** (admin) and **customers** (storefront) +- **Storefront SSO** — "Sign in with SSO" button on the login page; full auth-code OIDC flow +- **Observability** — Prometheus scrapes Keycloak metrics; Grafana includes a health panel + +## Prerequisites + +Before starting the stack with Keycloak, complete these **two manual setup steps** (one-time only): + +### Step 1: Add Keycloak to /etc/hosts + +The OIDC issuer URL must be the same for both the browser (JavaScript redirect) and the backend container. Add a DNS entry for `keycloak`: + +```bash +sudo sh -c 'echo "127.0.0.1 keycloak" >> /etc/hosts' +``` + +**Why**: Without this, the browser sees `http://localhost:8080` but the backend container sees `http://keycloak:8080`. The OIDC `iss` (issuer) claim will not match, and token validation fails. + +**Verify**: +```bash +ping keycloak +# Expected: PING keycloak (127.0.0.1) ... +``` + +### Step 2: Configure Backend Environment + +Add the Keycloak environment variables to `apps/backend/.env`: + +```bash +# Keycloak Server & Realm +KEYCLOAK_URL=http://keycloak:8080 +KEYCLOAK_REALM=medusa-commerce + +# Keycloak Client Credentials +KEYCLOAK_CLIENT_ID=medusa +KEYCLOAK_CLIENT_SECRET=medusa-dev-secret-changeme-in-prod + +# OIDC Scopes +KEYCLOAK_SCOPE="openid profile email" + +# Medusa Backend Callback (storefront redirect after Keycloak auth) +# This is where Medusa returns the browser AFTER Keycloak auth — it must point to the STOREFRONT callback page (port 8000), NOT the backend (9000). The realm's registered OIDC redirect_uris (the backend :9000/auth/.../callback) are a separate handshake step and stay as-is. For other regions, swap the country code. +KEYCLOAK_CALLBACK_URL=http://localhost:8000/nz/account/auth-callback +``` + +> ⚠️ **Development Only**: The client secret `medusa-dev-secret-changeme-in-prod` is a local placeholder. Change it in production and rotate it regularly. Use a secrets manager (AWS Secrets Manager, Vault) for prod deployment. + +## Run the Stack + +Start B2B-Commerce with Keycloak: + +```bash +task up +``` + +**What happens**: +- Medusa backend, Next.js storefront, PostgreSQL, Redis, and Keycloak all boot +- Keycloak adds ~10–15 seconds to startup time +- Keycloak healthcheck (OpenID Connect discovery endpoint) has a `start_period: 90s` +- When you see "frontend running on :8000", all services are live + +**Standalone Keycloak commands**: + +| Task | Purpose | +|------|---------| +| `task keycloak:up` | Start Keycloak only (if already running other services) | +| `task keycloak:down` | Stop Keycloak only | +| `task keycloak:db-init` | Create Keycloak database (idempotent; safe to re-run) | +| `task keycloak:health` | Check realm discovery endpoint (`/.well-known/openid-configuration`) | + +## Demo: Storefront SSO Login + +1. **Open the storefront**: http://localhost:8000 +2. **Navigate to login**: Click login or go to `http://localhost:8000/account` +3. **Click "Sign in with SSO"** (button on the login page) +4. **You are redirected to Keycloak** +5. **Log in with the demo user**: + - Email: `sso.buyer@demo.com` + - Password: `SsoBuyer2026!` +6. **Authorize the application** (consent screen if this is the first login) +7. **Redirect back to the storefront**: You are now logged in as a customer + +## OIDC Flow (Auth-Code) + +```mermaid +sequenceDiagram + participant Browser + participant Storefront as Next.js Storefront + participant Backend as Medusa Backend + participant Keycloak as Keycloak IdP + + Browser->>Storefront: Click "Sign in with SSO" + Storefront->>Backend: GET /auth/customer/vymalo-keycloak + Backend->>Backend: Generate auth code request + state token + Backend->>Browser: Redirect to Keycloak authorize endpoint + Browser->>Keycloak: GET /realms/medusa-commerce/protocol/openid-connect/auth?client_id=medusa&... + Keycloak->>Browser: Show login form + Browser->>Keycloak: POST credentials (sso.buyer@demo.com / SsoBuyer2026!) + Keycloak->>Keycloak: Validate user; issue ID + refresh tokens + Keycloak->>Browser: Redirect to callback with auth code + Browser->>Backend: GET /auth/customer/vymalo-keycloak/callback?code=... + Backend->>Keycloak: POST /token (exchange code for ID token) + Keycloak->>Backend: Return ID + access tokens + Backend->>Backend: Validate token issuer + signature + Backend->>Backend: Create/link customer session + Backend->>Browser: Set session cookie; redirect to /account + Browser->>Storefront: GET /account (authenticated) + Storefront->>Browser: Render account dashboard +``` + +## Admin Dashboard — Important Limitation + +**The Medusa admin dashboard (`@medusajs/dashboard`) is a prebuilt SPA that ONLY supports email/password login.** The login page displays only the email/password form; there is no one-click "Login with Keycloak" button. + +### What IS Supported + +- ✅ The backend SSO route `/auth/user/vymalo-keycloak` is **enabled** and works at the protocol level +- ✅ Developers can use the token/redirect flow programmatically or via curl +- ✅ The admin dashboard **accepts tokens** from any auth method in API requests + +### What Is NOT Supported + +- ❌ No one-click "Login with Keycloak" button on the admin login page +- ❌ Adding this button requires patching or forking the prebuilt dashboard (`@medusajs/dashboard`) +- ❌ OceanSoft's public B2B-Commerce distribution does not include a patched admin dashboard + +### Workaround for Dev/Testing + +If you need admin SSO for development: + +1. **Use the backend API directly** — obtain a token programmatically: + ```bash + # Exchange credentials for a token via Keycloak + curl -X POST http://keycloak:8080/realms/medusa-commerce/protocol/openid-connect/token \ + -d "client_id=medusa&client_secret=medusa-dev-secret-changeme-in-prod&grant_type=password&username=admin@oceansoft.io&password=admin" + ``` + +2. **Or use the storefront SSO**, then access admin APIs via the token (backend API authentication is separate from the dashboard UI) + +3. **Or stay with email/password** for admin login — the admin account is not customer-facing + +**Production Hardening**: A production B2B-Commerce deployment would need to either: +- Implement a custom dashboard with SSO support (out of scope for this reference architecture) +- Use a separate identity provider (Okta, Auth0) that provides a managed admin portal +- Maintain separate admin/customer auth paths (email/password for admin, SSO for customers) + +## Observability + +Keycloak exposes Prometheus metrics on port 9000: + +``` +http://ec_keycloak:9000/metrics +``` + +### Grafana Dashboard + +A pre-configured "Keycloak IdP Health" panel is included in the observability stack. It shows: +- **Keycloak Target Up** — 1 = running, 0 = down +- **HTTP Request Rate** — requests/sec by path + +To access the dashboard: +1. Start the stack: `task up` +2. Open Grafana: http://localhost:3000 (default: admin / admin) +3. Go to **Dashboards** → search for "Keycloak" + +### Metrics Limitations + +- **Login events** (login attempts, successful logins, failures) require the Keycloak Events Listener SPI to be enabled in the realm config. This is **not enabled in the shipped local-dev realm** (out of scope for Phase 1). +- **To enable event listeners** in production: configure `~/keycloak/data/import/realm-medusa-commerce.json` to include an event listener, then re-import the realm. + +## Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| Keycloak login redirects to storefront but you stay on login page | Issuer mismatch (`http://localhost:8080` ≠ `http://keycloak:8080`) | Ensure `/etc/hosts` has `127.0.0.1 keycloak` and `KEYCLOAK_URL` is `http://keycloak:8080` | +| "Provider not found" error in backend logs | `KEYCLOAK_URL` or other env vars not set in `apps/backend/.env` | Verify `.env` has all 6 vars (URL, REALM, CLIENT_ID, CLIENT_SECRET, SCOPE, CALLBACK_URL); restart backend with `docker compose restart medusa` | +| Keycloak container fails to start | Database not initialized | Run `task keycloak:db-init` to create the Keycloak database | +| Keycloak health check times out | Keycloak is still booting (start_period: 90s) | Wait 90 seconds; check logs with `docker compose logs keycloak` | +| Token validation fails ("iss claim mismatch") | Backend sees issuer as `http://localhost:8080` but realm has `http://keycloak:8080` | Check `/etc/hosts` entry; redeploy backend to pick up the hostname change | +| Demo user `sso.buyer@demo.com` does not exist | Realm import failed | Verify `keycloak/data/import/realm-medusa-commerce.json` exists and has the demo users; re-run `task keycloak:db-init` | +| After SSO login the browser lands on the Medusa backend (:9000) / a JSON or blank page instead of the storefront account page | `KEYCLOAK_CALLBACK_URL` is pointing at the backend instead of the storefront callback | Set `KEYCLOAK_CALLBACK_URL=http://localhost:8000//account/auth-callback` (e.g., `nz` for New Zealand region) and restart the backend with `docker compose restart medusa` | + +## Security Notes (Local Dev Only) + +### What's Insecure Here + +- **Demo user password is in the realm export** (`realm-medusa-commerce.json`) +- **Client secret is in `.env`** and committed to `.env.template` +- **Keycloak runs on HTTP** (no TLS) +- **Realm is pre-seeded** with hardcoded demo users + +### Production Requirements + +Before deploying to production, you MUST: + +1. **Rotate the client secret**: Generate a new secret in Keycloak; update it in your secrets manager (Vault, AWS Secrets Manager) +2. **Use a managed Keycloak** or self-hosted with TLS and backups +3. **Integrate your identity provider**: LDAP, Active Directory, SAML, or social login (GitHub, Google) +4. **Enable event listeners** to audit login attempts and token issuance +5. **Set up token signing** with a proper key pair (not the self-signed cert) +6. **Review Keycloak security docs**: https://www.keycloak.org/documentation (not in scope for this Phase 1 reference) + +## Next Steps + +- **Deploy to AWS** (Phase 3): Use AWS Cognito or self-hosted Keycloak on ECS; update `KEYCLOAK_URL` to the prod domain +- **Add social login**: Configure GitHub/Google/SAML in the realm +- **Custom admin dashboard**: If you need admin SSO, fork `@medusajs/dashboard` and add a Keycloak button +- **Event logging**: Enable Keycloak event listeners for audit trails + +## References + +- **Keycloak Official Docs**: https://www.keycloak.org/documentation +- **Medusa Auth Provider Integration**: `apps/backend/src/modules/auth/` (search for `vymalo-keycloak`) +- **Local Realm Export**: `infra/keycloak/data/import/realm-medusa-commerce.json` +- **Docker Service**: See `docker-compose.yml` under `keycloak` service diff --git a/docs/content/demo/flows/01-cart-to-quote.md b/docs/content/demo/flows/01-cart-to-quote.md index 5424a99..ba7511c 100644 --- a/docs/content/demo/flows/01-cart-to-quote.md +++ b/docs/content/demo/flows/01-cart-to-quote.md @@ -19,7 +19,7 @@ last_compiled: "2026-06-07T00:00:00Z" **Duration**: ~2 min -![Shopping cart ready to quote](pathname:///img/demo/flows/01-cart-to-quote/generated-ctq-01-cart-page.png) +*Demo video for this flow is being re-captured.* ## Script (voice narration) @@ -34,3 +34,12 @@ last_compiled: "2026-06-07T00:00:00Z" **[00:44]** "The quote status displays live: Pending Approval. Maria knows exactly where her request stands." **[00:53]** "Budget quarter protected. Quote filed. Decision cycle: one session, not three days." + +## Cross-References + +- [Persona: Maria (Buyer-Employee)](../personas/buyer.md) — full persona playbook +- [Demo Narration Script](../narration.md) — timestamped voice-over for all flows +- [Demo Scripts — Narration Cue Sheet](../scripts.md) — consolidated cue sheet for recording +- [Entity: Quote Module](../../modules/quote-module.md) — backend module that powers the quote request in this flow +- [Entity: Company Module](../../modules/company-module.md) — company context (spending limits, employee roles) that governs this flow +- [Entity: Approval Module](../../modules/approval-module.md) — approval gate triggered when this quote is submitted diff --git a/docs/content/demo/flows/02-approval.md b/docs/content/demo/flows/02-approval.md index 301fa71..c196745 100644 --- a/docs/content/demo/flows/02-approval.md +++ b/docs/content/demo/flows/02-approval.md @@ -9,6 +9,8 @@ source_refs: last_compiled: "2026-06-07T00:00:00Z" --- +import useBaseUrl from '@docusaurus/useBaseUrl'; + # Flow 02: Approval Workflow **Persona**: David (Admin) @@ -19,7 +21,9 @@ last_compiled: "2026-06-07T00:00:00Z" **Duration**: ~2 min -![Approvals dashboard with pending quote](pathname:///img/demo/flows/02-approval/generated-approval-01-approvals-page.png) +Approvals dashboard — #BZK0 Demo Corp Pending, 1 item + +*Demo video for this flow is being re-captured.* ## Script (voice narration) @@ -34,3 +38,7 @@ last_compiled: "2026-06-07T00:00:00Z" **[00:43]** "David's audit log shows: Approved by David, 14-Jun-2026 10:34am, comment recorded." **[00:51]** "No email chains. No ambiguity. Every decision on record." + +## Cross-References + +- [Persona: David (Admin)](../personas/admin.md) — full persona playbook diff --git a/docs/content/demo/flows/03-company-mgmt.md b/docs/content/demo/flows/03-company-mgmt.md index 57bb91d..33264c8 100644 --- a/docs/content/demo/flows/03-company-mgmt.md +++ b/docs/content/demo/flows/03-company-mgmt.md @@ -19,7 +19,7 @@ last_compiled: "2026-06-07T00:00:00Z" **Duration**: ~2 min -![Company roster with employees and spending limits](pathname:///img/demo/flows/03-company-mgmt/generated-company-mgmt-01-companies-list.png) +*Demo video for this flow is being re-captured.* ## Script (voice narration) @@ -34,3 +34,7 @@ last_compiled: "2026-06-07T00:00:00Z" **[00:41]** "Sarah accepts the invite, sets a password, and joins the company instantly — no support ticket needed." **[00:50]** "David can now see Sarah in the team roster with her role and spending limit visible." + +## Cross-References + +- [Persona: David (Admin)](../personas/admin.md) — full persona playbook diff --git a/docs/content/demo/flows/04-spending-limit.md b/docs/content/demo/flows/04-spending-limit.md index 59e7a6b..b1b091c 100644 --- a/docs/content/demo/flows/04-spending-limit.md +++ b/docs/content/demo/flows/04-spending-limit.md @@ -13,24 +13,24 @@ last_compiled: "2026-06-07T00:00:00Z" **Persona**: Maria (Buyer-Employee) -**Scenario**: Maria's monthly limit is $3,000 NZD. She has spent $2,800. She adds a $400 item to the cart. The system shows: "Remaining budget: $200 NZD. This item exceeds your limit by $200." The item locks. Maria removes it, confirms her cart is within budget ($2,900 total), and checks out. Policy enforced. No surprise rejections at approval. +**Scenario**: Maria's monthly spending limit is $200 NZD. She adds a Wireless Rechargeable Mouse ($260 total for 2 units) to the cart. The system blocks checkout with: "This order exceeds your spending limit. Please contact your manager for approval." The button is disabled. Maria cannot proceed — policy enforced before submission, not at approval. **Status**: ✅ GREEN **Duration**: ~2 min -![Cart with spending limit warning displayed](pathname:///img/demo/flows/04-spending-limit/generated-spending-limit-01-cart.png) +*Demo video for this flow is being re-captured.* ## Script (voice narration) **[00:05]** "Spending Limit Enforcement blocks over-policy purchases before submission." -**[00:14]** "Maria browses products and adds items to her cart. Her remaining budget: $200 NZD." +**[00:14]** "Maria's spending limit is $200 NZD per month. She adds a Wireless Rechargeable Mouse to her cart." -**[00:23]** "She adds a $400 item. The cart shows a warning: 'Remaining: $200. This item exceeds limit.'" +**[00:23]** "The cart total is $260 for 2 units. This exceeds her limit of $200." -**[00:32]** "The item is disabled. Maria can see the overage amount and exact policy constraint." +**[00:32]** "An orange banner appears: 'This order exceeds your spending limit. Please contact your manager for approval.'" -**[00:40]** "She removes the item and adds a $150 item instead. Total now $2,950. Within limit. Checkout enabled." +**[00:40]** "The checkout button is disabled — 'Spending Limit Exceeded.' Maria cannot proceed without manager intervention." **[00:48]** "No surprises. No rejections at approval. Policy enforced at the moment of decision." diff --git a/docs/content/demo/flows/05-quote-negotiate.md b/docs/content/demo/flows/05-quote-negotiate.md index fd54878..a449298 100644 --- a/docs/content/demo/flows/05-quote-negotiate.md +++ b/docs/content/demo/flows/05-quote-negotiate.md @@ -19,7 +19,7 @@ last_compiled: "2026-06-07T00:00:00Z" **Duration**: ~2 min -![Cart showing bulk quote for negotiation](pathname:///img/demo/flows/05-quote-negotiate/generated-quote-negotiate-01-cart.png) +*Demo video for this flow is being re-captured.* ## Script (voice narration — partial, green-slice only) @@ -32,3 +32,7 @@ last_compiled: "2026-06-07T00:00:00Z" **[00:31]** "Maria sees the counter and accepts it instantly — visual confirmation blocked pending route fix." **[00:38]** "Backend negotiation flow is green; storefront route and messaging UI are in the next phase." + +## Cross-References + +- [Persona: Priya (Sales Manager)](../personas/sales-manager.md) — full persona playbook diff --git a/docs/content/demo/flows/06-promotions.md b/docs/content/demo/flows/06-promotions.md index 6d4bde4..8c1ab4e 100644 --- a/docs/content/demo/flows/06-promotions.md +++ b/docs/content/demo/flows/06-promotions.md @@ -19,7 +19,7 @@ last_compiled: "2026-06-07T00:00:00Z" **Duration**: ~2 min -![Cart showing bulk discount auto-applied](pathname:///img/demo/flows/06-promotions/generated-promotions-01-cart.png) +*Demo video for this flow is being re-captured.* ## Script (voice narration) diff --git a/docs/content/demo/flows/07-full-ecommerce.md b/docs/content/demo/flows/07-full-ecommerce.md index 5498c87..ba38d85 100644 --- a/docs/content/demo/flows/07-full-ecommerce.md +++ b/docs/content/demo/flows/07-full-ecommerce.md @@ -19,7 +19,7 @@ last_compiled: "2026-06-07T00:00:00Z" **Duration**: ~2 min -![Shopping cart with NZD pricing and spending limit indicator](pathname:///img/demo/flows/07-full-ecommerce/generated-full-ecommerce-01-cart.png) +*Demo video for this flow is being re-captured.* ## Script (voice narration) diff --git a/docs/content/demo/flows/08-order-edit.md b/docs/content/demo/flows/08-order-edit.md index 2b9252c..61c3e09 100644 --- a/docs/content/demo/flows/08-order-edit.md +++ b/docs/content/demo/flows/08-order-edit.md @@ -13,13 +13,20 @@ last_compiled: "2026-06-07T00:00:00Z" **Persona**: Priya (Sales Manager) -**Scenario**: Maria places order #QT-2026-1847 for 100 units of cables. Two hours later, she emails Priya asking to add 50 more units and remove the power adapters. Instead of waiting for a credit memo and a new order, Priya opens the order in the admin UI, edits the line items (remove 10 adapters, add 50 cables), and saves. The order recalculates. Audit log records the change. Maria sees it instantly in her account. +**Scenario**: Maria places order #47 for 2× Wireless Keyboard at NZ$318.00. Priya opens it in the admin UI, edits the quantity, and saves. The order recalculates instantly. Audit log records the change. Maria sees it in her account without any email back-and-forth. -**Status**: ⚠️ **EXCLUDED** — Storefront /account/orders route renders Forbidden; backend order-edit API is green; visual layer blocked +**Status**: ✅ GREEN **Duration**: ~2 min -![Orders list page in admin console](pathname:///img/demo/flows/08-order-edit/generated-order-edit-01-orders-page.png) +import useBaseUrl from '@docusaurus/useBaseUrl'; + +