diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f645d5f9..2222ef4f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,9 +2,9 @@ name: Frontend checks on: pull_request: - branches: [main, dev] + branches: [main, dev, staging] push: - branches: [main, dev] + branches: [main, dev, staging] workflow_dispatch: {} permissions: @@ -14,7 +14,7 @@ jobs: build-and-test: name: Build and test # Org reusable workflow (Node 24): format check, lint, build, unit tests. - uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@main + uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8 with: node-version: "24" @@ -32,26 +32,43 @@ jobs: # push-> the previous commit on the branch (github.event.before) # manual -> dev, for exact-head recovery runs runs-on: ubuntu-latest - env: - GOVERNANCE_BASE: ${{ github.event_name == 'pull_request' && format('origin/{0}', github.base_ref) || github.event_name == 'push' && github.event.before || 'origin/dev' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - - uses: actions/setup-node@v4 + - name: Resolve governance comparison ref + id: governance-ref + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + EVENT_BEFORE: ${{ github.event.before }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + if [[ "${EVENT_NAME}" == "pull_request" ]]; then + base="${PR_BASE_SHA}" + elif [[ "${EVENT_NAME}" == "push" && -n "${EVENT_BEFORE}" && ! "${EVENT_BEFORE}" =~ ^0+$ ]]; then + base="${EVENT_BEFORE}" + else + base="origin/dev" + fi + printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}" + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "24" cache: npm - run: npm ci - run: npm run verify + env: + GOVERNANCE_BASE: ${{ steps.governance-ref.outputs.base }} visual-regression: name: Visual regression runs-on: ubuntu-latest container: mcr.microsoft.com/playwright:v1.61.1-noble steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "24" cache: npm @@ -59,7 +76,7 @@ jobs: - run: npm run test:e2e:visual - name: Upload visual diff artifacts if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: visual-regression-diffs path: | diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml new file mode 100644 index 00000000..eed6460e --- /dev/null +++ b/.github/workflows/deploy-staging.yml @@ -0,0 +1,140 @@ +name: Deploy staging + +# Standalone staging deployment (push to `staging` / manual dispatch), NOT a +# caller of the org reusable `cd-cdk.yaml` (that path is dev-only): staging +# trusts the exact GitHub-environment OIDC subject, which requires the deploy +# job to declare `environment: staging` and run in this repo, with the +# non-secret role ARN pinned below (created by the staging stack itself). +# +# Order is fixed: full `npm run verify` gates run BEFORE any deploy step. +# No secrets are used — OIDC + the static role ARN are the only credentials. + +on: + push: + branches: [staging] + workflow_dispatch: {} + +permissions: + id-token: write + contents: read + +concurrency: + group: deploy-staging + cancel-in-progress: false + +jobs: + deploy: + name: Deploy to staging + # Deploy only the exact staging branch ref, never a tag or other ref + # (workflow_dispatch can be invoked from arbitrary refs). + if: github.ref == 'refs/heads/staging' + runs-on: ubuntu-latest + environment: staging + env: + VITE_API_URL: https://api.staging.seahaven.com/api + AWS_REGION: us-east-1 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - name: Resolve governance comparison ref + id: governance-ref + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + EVENT_BEFORE: ${{ github.event.before }} + run: | + set -euo pipefail + if [[ "${EVENT_NAME}" == "push" && -n "${EVENT_BEFORE}" && ! "${EVENT_BEFORE}" =~ ^0+$ ]]; then + base="${EVENT_BEFORE}" + else + base="origin/dev" + fi + printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}" + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: npm + # Node 24 bundles npm 11 (lockfileVersion 3); the packageManager pin + # (npm@11.16.0) matches this CI environment. + - name: Quality gates (full verify before any deploy) + run: npm ci && npm run verify + env: + GOVERNANCE_BASE: ${{ steps.governance-ref.outputs.base }} + + - name: Assume staging deploy role (OIDC) + uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 + with: + role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging + aws-region: us-east-1 + + # Builds the SPA with the staging VITE_API_URL (process env overrides the + # dev value committed in .env.production), syncs to the staging bucket, + # and invalidates CloudFront. + - name: Build and publish SPA + run: bash scripts/deploy-web.sh + env: + STACK_NAME: shoc-frontend-staging + WAIT_FOR_INVALIDATION: "true" + + - name: Verify deployment + run: | + set -euo pipefail + stack_output() { + aws cloudformation describe-stacks \ + --stack-name shoc-frontend-staging \ + --query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \ + --output text + } + BUCKET="$(stack_output BucketName)" + DIST_ID="$(stack_output DistributionId)" + DIST_DOMAIN="$(stack_output DistributionDomainName)" + SITE_URL="$(stack_output SiteUrl)" + if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" || -z "${DIST_DOMAIN}" || "${DIST_DOMAIN}" == "None" ]]; then + echo "::error::Could not resolve bucket/distribution from stack outputs." >&2 + exit 1 + fi + echo "Bucket=${BUCKET} Distribution=${DIST_ID} (${DIST_DOMAIN}) SiteUrl=${SITE_URL}" + + aws s3api head-bucket --bucket "${BUCKET}" + echo "Bucket exists." + # The distribution is proven to exist and serve by the HTTPS check + # below: the custom domain is an alias to this distribution, and the + # deploy role deliberately carries no cloudfront:GetDistribution + # (least privilege; the dev template is shared and must not drift). + + if grep -Rq "api.dev.seahaven.com" dist/; then + echo "::error::Built assets contain the dev API URL (api.dev.seahaven.com)." >&2 + grep -Rl "api.dev.seahaven.com" dist/ >&2 || true + exit 1 + fi + echo "Built assets carry no dev API URL." + grep -Rq "api.staging.seahaven.com" dist/ + echo "Built assets reference the staging API URL." + + # Verify the actual post-invalidation HTML and its referenced assets, + # not only the local build or a generic endpoint response. + remote_dir="$(mktemp -d)" + trap 'rm -rf "${remote_dir}"' EXIT + for i in 1 2 3 4 5 6; do + if curl -fsS --max-time 30 "${SITE_URL}" -o "${remote_dir}/index.html"; then + break + fi + echo "Endpoint not ready (attempt ${i}); retrying in 20s..." + sleep 20 + done + test -s "${remote_dir}/index.html" + grep -oE '(src|href)="/assets/[^"]+\.(js|css)"' "${remote_dir}/index.html" \ + | sed -E 's/^(src|href)="([^"]+)"$/\2/' \ + | sort -u > "${remote_dir}/asset-paths.txt" + test -s "${remote_dir}/asset-paths.txt" + while IFS= read -r asset_path; do + curl -fsS --max-time 30 "${SITE_URL%/}${asset_path}" \ + >> "${remote_dir}/assets.txt" + done < "${remote_dir}/asset-paths.txt" + if grep -q "api.dev.seahaven.com" "${remote_dir}/assets.txt"; then + echo "::error::Deployed assets contain the dev API URL." >&2 + exit 1 + fi + grep -q "api.staging.seahaven.com" "${remote_dir}/assets.txt" + echo "Deployed staging assets reference only the staging API URL." diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4942324e..aa72c01d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -27,7 +27,7 @@ concurrency: jobs: deploy: - uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@main + uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8 with: node-version: "24" region: us-east-1 diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png index d39596dc..7f701af7 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-add.png differ diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png index 33356fdc..7291783a 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-edit.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png index fb36ec63..52d00495 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png index 21809dbd..f9ab4659 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png index a7109395..bf2ebed4 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png index b4da848d..d812c670 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile.png index 6e98e3fa..5311388e 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png index 9edaccd5..0c225e74 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png differ diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index f242b7cf..e8f54bef 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -61,6 +61,16 @@ const inactiveVendors = [ }, ]; +const LONG_TEXT_VENDOR = { + CompanyName: "Gateway Plumbing and Water Systems of Greater Saint Louis", + ContactName: "Adam Christopher Whyte Senior Field Technician", + Email: "dispatch-and-after-hours-coordination@gatewayplumbing.example", + Phone: "+1 (314) 555-0198 extension 123456", + Address: "New York, NY, Estados Unidos, 123 Long Address Avenue", + City: "", + State: "", +}; + interface MockState { listUrls: string[]; createdBody?: Record; @@ -88,10 +98,12 @@ async function mockVendorApi( deactivationBlocked?: boolean; deleteConflict?: boolean; legacyGateway?: boolean; + longText?: boolean; } = {}, ) { const state: MockState = { listUrls: [] }; const vendorRecords = [...activeVendors, ...inactiveVendors].map((vendor) => ({ ...vendor })); + if (options.longText) Object.assign(vendorRecords[0], LONG_TEXT_VENDOR); if (options.legacyGateway) { vendorRecords[0].Phone = "555-1234 ext 9"; vendorRecords[0].GoogleMapsUrl = "http://maps.example.test/legacy"; @@ -437,6 +449,47 @@ test.describe("Vendor directory prototype parity", () => { await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0); }); + test("truncates every non-abbreviated textual column", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 1000 }); + await mockVendorApi(page, { longText: true }); + await page.goto("/vendors"); + + const row = page.locator("tbody tr").first(); + const fields = [ + { cellIndex: 0, testId: "vendor-text-company", value: LONG_TEXT_VENDOR.CompanyName }, + { cellIndex: 1, testId: "vendor-text-technician", value: LONG_TEXT_VENDOR.ContactName }, + { cellIndex: 2, testId: "vendor-text-location", value: LONG_TEXT_VENDOR.Address }, + { cellIndex: 4, testId: "vendor-text-phone", value: LONG_TEXT_VENDOR.Phone }, + { cellIndex: 5, testId: "vendor-text-email", value: LONG_TEXT_VENDOR.Email }, + ]; + + for (const expected of fields) { + const cell = row.locator("td").nth(expected.cellIndex); + const field = cell.getByTestId(expected.testId); + await expect(field).toBeVisible(); + + const metrics = await field.evaluate((element) => { + const fieldRect = element.getBoundingClientRect(); + const cellRect = element.closest("td")?.getBoundingClientRect(); + return { + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + withinCell: + cellRect !== undefined && + fieldRect.left >= cellRect.left - 1 && + fieldRect.right <= cellRect.right + 1, + }; + }); + expect(metrics.scrollWidth).toBeGreaterThan(metrics.clientWidth); + expect(metrics.withinCell).toBe(true); + + await field.hover(); + await expect(page.getByRole("tooltip", { name: expected.value, exact: true })).toBeVisible(); + await page.mouse.move(0, 0); + await expect(page.getByRole("tooltip", { name: expected.value, exact: true })).toHaveCount(0); + } + }); + test("adds a technician to an existing company, then views and edits the aggregate", async ({ page, }) => { @@ -450,8 +503,86 @@ test.describe("Vendor directory prototype parity", () => { page.getByRole("button", { name: "Add Vendor", exact: true }).last(), ).toBeEnabled(); - await page.getByRole("combobox", { name: "Company" }).click(); + const companyLabel = page + .getByRole("dialog", { name: /Add Vendor/ }) + .locator("label") + .filter({ hasText: "Company (required)" }); + await expect(companyLabel).toBeVisible(); + const companyLabelMetrics = await companyLabel.evaluate((element) => { + const field = element.closest(".MuiFormControl-root"); + const labelRect = element.getBoundingClientRect(); + const fieldRect = field?.getBoundingClientRect(); + return { + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + withinFieldGeometry: fieldRect + ? labelRect.left >= fieldRect.left && + labelRect.right <= fieldRect.right && + labelRect.top >= fieldRect.top && + labelRect.bottom <= fieldRect.bottom + : false, + }; + }); + expect(companyLabelMetrics.clientWidth).toBeGreaterThanOrEqual(companyLabelMetrics.scrollWidth); + expect(companyLabelMetrics.withinFieldGeometry).toBe(true); + + await page.getByRole("combobox", { name: "Company (required)" }).click(); await page.getByRole("option", { name: "Gateway Plumbing" }).click(); + const floatedCompanyLabel = page + .getByRole("dialog", { name: /Add Vendor/ }) + .locator("label") + .filter({ hasText: "Company (required)" }); + const floatedCompanyField = floatedCompanyLabel.locator(".."); + await expect(floatedCompanyLabel).toHaveText("Company (required)"); + await expect(floatedCompanyField.locator("legend")).toHaveText("Company (required)"); + const floatedMetrics = await floatedCompanyLabel.evaluate((element) => { + const labelRect = element.getBoundingClientRect(); + const legendRect = element + .closest(".MuiFormControl-root") + ?.querySelector("legend") + ?.getBoundingClientRect(); + return { + labelClientWidth: element.clientWidth, + labelScrollWidth: element.scrollWidth, + legendWidth: legendRect?.width ?? 0, + labelWidth: labelRect.width, + }; + }); + expect(floatedMetrics.labelClientWidth).toBeGreaterThanOrEqual(floatedMetrics.labelScrollWidth); + expect(floatedMetrics.legendWidth).toBeGreaterThanOrEqual(floatedMetrics.labelWidth); + + // Scrolling focus down the form and back to Company must keep the floated label + // inside the dialog's visible scrollport. + const dialogContent = page + .getByRole("dialog", { name: /Add Vendor/ }) + .locator(".MuiDialogContent-root"); + await page.getByLabel("Technician name (optional)").click(); + await expect + .poll(() => dialogContent.evaluate((element) => element.scrollTop)) + .toBeGreaterThan(0); + await page.getByRole("combobox", { name: "Company (required)" }).click(); + await expect + .poll(() => dialogContent.evaluate((element) => element.scrollTop)) + .toBeLessThanOrEqual(1); + await expect(floatedCompanyLabel).toBeVisible(); + const labelScrollportMetrics = await floatedCompanyLabel.evaluate((element) => { + const content = element.closest(".MuiDialogContent-root"); + return { + labelTop: element.getBoundingClientRect().top, + labelBottom: element.getBoundingClientRect().bottom, + dialogTop: content?.getBoundingClientRect().top ?? Number.NaN, + dialogBottom: content?.getBoundingClientRect().bottom ?? Number.NaN, + overflowY: content ? getComputedStyle(content).overflowY : "", + }; + }); + expect(["auto", "scroll"]).toContain(labelScrollportMetrics.overflowY); + expect(labelScrollportMetrics.labelTop).toBeGreaterThanOrEqual( + labelScrollportMetrics.dialogTop - 1, + ); + expect(labelScrollportMetrics.labelBottom).toBeLessThanOrEqual( + labelScrollportMetrics.dialogBottom + 1, + ); + await expect(page.getByLabel("Company Phone (optional)")).toHaveValue("314-555-0100"); await expect(page.getByRole("textbox", { name: "Email (optional)", exact: true })).toHaveValue( "dispatch@gateway.test", @@ -560,7 +691,7 @@ test.describe("Vendor directory prototype parity", () => { await expect( page.getByRole("button", { name: "Add Vendor", exact: true }).last(), ).toBeEnabled(); - await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC"); + await page.getByRole("combobox", { name: "Company (required)" }).fill("Independent Vendor LLC"); await page.getByLabel("Company Phone (optional)").fill("3145550199"); await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click(); diff --git a/infra/cdk/README.md b/infra/cdk/README.md index e876a0c7..364780af 100644 --- a/infra/cdk/README.md +++ b/infra/cdk/README.md @@ -17,7 +17,8 @@ deployed through the org's **reusable** GitHub Actions workflow. (provisions infra) then `scripts/deploy-web.sh` (builds + uploads the SPA). - **Infra is local to this repo** (CDK in `infra/cdk`); the deploy role is created by this stack, not added to the central `oidc-deploy-roles.yaml`. -- **Environments:** `dev` only today, deployed on push to the `dev` branch. +- **Environments:** `dev` (push to `dev`, via the org reusable workflow) and + `staging` (push to `staging`, via the standalone `deploy-staging.yml`). ``` infra/cdk/ @@ -25,8 +26,9 @@ infra/cdk/ lib/frontend-stack.ts S3 + CloudFront + OAC + OIDC deploy role scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation .github/workflows/ - ci.yml quality gates (lint / build / test / e2e) + ci.yaml quality gates (lint / build / test / e2e) deploy.yml caller of the org reusable cd-cdk.yaml (push to dev) + deploy-staging.yml standalone staging deploy (push to staging) ``` ## What the stack creates @@ -137,25 +139,83 @@ the `SiteUrl` output. --- -## Adding staging / prod later +## Staging environment (same account, exact OIDC subject) -Separate accounts: deploy this stack there with per-env `domainNames`, -`certificateArn`, `hostedZoneId`/`hostedZoneName` context; set that repo's -`AWS_DEPLOY_ROLE_ARN` secret; and add a job to `deploy.yml`. +Staging lives in the same AWS account (396287094661) but deploys through its +own standalone workflow, `.github/workflows/deploy-staging.yml`, not the org +reusable `cd-cdk.yaml`: -Because the SPA calls the API directly at an absolute URL, **`VITE_API_URL` is -baked into `vite build`** — so each environment needs its own build with its own -API host (e.g. `https://api.staging.seahaven.com/api`). Set it per environment -in the deploy job (e.g. export `VITE_API_URL` before the build step) rather than -relying on the committed `.env.production` (which carries the dev value). The -backend must also allow CORS from each frontend origin. +- **Trust:** with `-c githubEnvironment=staging`, the stack's deploy role + (`githubdeploy-shoc-frontend-new-staging`) trusts ONLY the exact GitHub + environment subject + `repo:Sea-Haven-Industries/shoc-frontend-new:environment:staging` + (`StringEquals` on both `aud` and `sub`). The workflow declares + `environment: staging`, so only runs in that environment can assume the role. + Without `githubEnvironment`, the dev stack keeps its branch-ref trust + unchanged. +- **No secret:** the role ARN is static (the role name is deterministic), so + the workflow pins + `arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging` + directly — no `AWS_DEPLOY_ROLE_ARN`-style secret to set. +- **Gates first:** the workflow runs the full `npm run verify` before assuming + the staging role, then runs `scripts/deploy-web.sh` with + `STACK_NAME=shoc-frontend-staging`, + `VITE_API_URL=https://api.staging.seahaven.com/api`, and waits for the + CloudFront invalidation to complete. +- **Application-only role:** the recurring staging workflow can describe only + its exact stack, publish only to its exact bucket, and invalidate only its + exact distribution. It cannot assume the shared CDK bootstrap roles or + modify infrastructure. Staging infrastructure changes use the Administrator + command below. +- **Post-deploy checks:** bucket + distribution existence, HTTPS on + `https://staging.seahaven.com`, and the actual post-invalidation remote assets + contain the staging API URL and no dev API URL. (Not browser QA.) + +### One-time setup (run by a human with admin AWS creds + GitHub Admin) + +1. **GitHub Admin — create the `staging` environment** (Settings → + Environments → New environment → `staging`). Add protection rules as + appropriate (e.g. required reviewers, restrict to the `staging` branch). If + the environment does not exist, GitHub creates it unprotected on first use. +2. **AWS Admin — first deploy with admin creds** (same steps 1–3 as dev; the + OIDC provider and bootstrap already exist in this account): + + ```bash + cd infra/cdk + npx cdk deploy shoc-frontend-staging \ + -c envName=staging \ + -c deployBranch=staging \ + -c githubEnvironment=staging \ + -c domainNames=staging.seahaven.com \ + -c certificateArn=arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00 \ + -c hostedZoneId=Z02602739VQWBWCAGXP4 \ + -c hostedZoneName=staging.seahaven.com + ``` + + The `DeployRoleArn` output must match the ARN pinned in + `deploy-staging.yml` (it will — the role name is deterministic). + +3. **Backend CORS:** the staging API (`https://api.staging.seahaven.com`) must + allow the `https://staging.seahaven.com` origin. +4. Push to `staging` — `ci.yaml` runs the quality gates and + `deploy-staging.yml` deploys. + +### Adding prod later + +Same pattern: a prod account/stack with its own contexts and, ideally, its own +`githubEnvironment=prod` trust + workflow. Keep in mind `VITE_API_URL` is baked +into each environment's build, and the bucket's `RemovalPolicy.DESTROY` + +`autoDeleteObjects` defaults are dev/staging-friendly but should be revisited +for prod. ## Notes - **Teardown:** `npx cdk destroy`. The bucket uses `RemovalPolicy.DESTROY` + `autoDeleteObjects` (dev artifacts are reproducible) — change this for prod. -- **CI and CD both fire on push to `dev`** in parallel; a red-CI commit still - deploys (matches the org's push-time-CD model). Gating deploy on CI is a - follow-up, not part of enabling CICD. +- **CI and CD both fire on push to `dev` and `staging`** in parallel (staging + differs only in that its CD workflow also runs `npm run verify` itself + before deploying); a red-CI commit still deploys on `dev` (matches the + org's push-time-CD model). Gating dev deploy on CI is a follow-up, not part + of enabling CICD. - **npm is pinned to v11.16.0**; the committed `package-lock.json` uses lockfileVersion 3, matching the Node 24 / npm 11 CI environment. diff --git a/infra/cdk/bin/app.ts b/infra/cdk/bin/app.ts index 9ef86c8e..876463fe 100644 --- a/infra/cdk/bin/app.ts +++ b/infra/cdk/bin/app.ts @@ -8,6 +8,10 @@ const app = new App(); const envName = app.node.tryGetContext("envName") ?? "dev"; const githubRepo = app.node.tryGetContext("githubRepo") ?? "Sea-Haven-Industries/shoc-frontend-new"; const deployBranch = app.node.tryGetContext("deployBranch") ?? "dev"; +// When set (e.g. "staging"), the deploy role trusts the exact GitHub +// environment OIDC subject instead of a deploy-branch ref. Empty = dev-style +// branch-ref trust. +const githubEnvironment = app.node.tryGetContext("githubEnvironment") ?? ""; // Custom domain. Comma-separated, e.g. -c domainNames=dev.seahaven.com // The ACM cert MUST be in us-east-1 in the SAME account this stack deploys to. @@ -21,10 +25,17 @@ const certificateArn = app.node.tryGetContext("certificateArn") ?? ""; const hostedZoneId = app.node.tryGetContext("hostedZoneId") ?? ""; const hostedZoneName = app.node.tryGetContext("hostedZoneName") ?? ""; +// Staging and beyond protect their stacks from accidental deletion; dev +// stays teardown-friendly (its artifacts are reproducible). CDK applies this +// at deploy time — it is not part of the synthesized template. +const terminationProtection = envName !== "dev"; + const stack = new FrontendStack(app, `shoc-frontend-${envName}`, { envName, githubRepo, deployBranch, + githubEnvironment, + terminationProtection, domainNames, certificateArn, hostedZoneId, diff --git a/infra/cdk/lib/frontend-stack.ts b/infra/cdk/lib/frontend-stack.ts index be7a4681..dda5f67e 100644 --- a/infra/cdk/lib/frontend-stack.ts +++ b/infra/cdk/lib/frontend-stack.ts @@ -15,6 +15,13 @@ export interface FrontendStackProps extends StackProps { readonly githubRepo: string; /** Git branch whose pushes may deploy (OIDC sub is scoped to this ref). */ readonly deployBranch: string; + /** + * GitHub Actions environment name (e.g. "staging"). When set, the OIDC + * trust uses the EXACT environment subject + * `repo::environment:` (StringEquals) instead of the + * deploy-branch ref match below. Unset = dev-style branch-ref trust. + */ + readonly githubEnvironment?: string; /** * Custom domain(s) for the distribution, e.g. ["dev.seahaven.com"]. * Empty = serve on the default *.cloudfront.net domain. @@ -57,6 +64,7 @@ export class FrontendStack extends Stack { envName, githubRepo, deployBranch, + githubEnvironment = "", domainNames, certificateArn, hostedZoneId, @@ -147,35 +155,52 @@ export class FrontendStack extends Stack { `arn:aws:iam::${this.account}:oidc-provider/token.actions.githubusercontent.com`, ); + // Trust conditions for the OIDC principal. With a GitHub environment + // (staging): exact StringEquals match on both aud and the environment + // subject — the staging workflow declares `environment: staging`, so only + // runs in that environment can assume the role. Without one (dev): keep + // the branch-ref trust, where StringLike scopes `sub` to pushes on the + // deploy branch (reusable-workflow runs still carry the caller-based sub). + const oidcConditions = githubEnvironment + ? { + StringEquals: { + "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", + "token.actions.githubusercontent.com:sub": `repo:${githubRepo}:environment:${githubEnvironment}`, + }, + } + : { + StringEquals: { + "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", + }, + StringLike: { + // Tightly scoped: only pushes to this repo's deploy branch. For a + // reusable-workflow run the OIDC `sub` is still caller-based, so this + // matches even though the deploy job lives in the `.github` repo. + "token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`, + }, + }; + const deployRole = new iam.Role(this, "GithubDeployRole", { roleName: `githubdeploy-shoc-frontend-new-${envName}`, description: `GitHub Actions deploy role for ${githubRepo}@${deployBranch}`, maxSessionDuration: Duration.hours(1), - assumedBy: new iam.OpenIdConnectPrincipal(provider, { - StringEquals: { - "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", - }, - StringLike: { - // Tightly scoped: only pushes to this repo's deploy branch. For a - // reusable-workflow run the OIDC `sub` is still caller-based, so this - // matches even though the deploy job lives in the `.github` repo. - "token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`, - }, - }), + assumedBy: new iam.OpenIdConnectPrincipal(provider, oidcConditions), }); - // The whole `cd-cdk.yaml` job runs as this role. Permissions it needs: - // 1. assume the CDK bootstrap roles -> `cdk deploy` - // 2. describe the stack -> cd-cdk pre-flight / health-check / output reads - // 3. read/write the bucket -> post-deploy `aws s3 sync` - // 4. invalidate the distribution -> post-deploy cache bust - deployRole.addToPolicy( - new iam.PolicyStatement({ - sid: "AssumeCdkBootstrapRoles", - actions: ["sts:AssumeRole"], - resources: [`arn:aws:iam::${this.account}:role/cdk-hnb659fds-*`], - }), - ); + // Dev's reusable CDK workflow needs the shared bootstrap roles. Staging is + // intentionally narrower: its recurring promotion workflow only publishes + // application assets to this stack's bucket/distribution. Infrastructure + // changes remain an administrator-run CDK operation, so the staging OIDC + // role cannot inherit the bootstrap roles' account-wide deployment power. + if (!githubEnvironment) { + deployRole.addToPolicy( + new iam.PolicyStatement({ + sid: "AssumeCdkBootstrapRoles", + actions: ["sts:AssumeRole"], + resources: [`arn:aws:iam::${this.account}:role/cdk-hnb659fds-*`], + }), + ); + } deployRole.addToPolicy( new iam.PolicyStatement({ sid: "DescribeStack", diff --git a/scripts/deploy-web.sh b/scripts/deploy-web.sh index 12c1b021..59b302ef 100755 --- a/scripts/deploy-web.sh +++ b/scripts/deploy-web.sh @@ -13,8 +13,9 @@ set -euo pipefail STACK_NAME="${STACK_NAME:-shoc-frontend-dev}" REGION="${AWS_REGION:-us-east-1}" +WAIT_FOR_INVALIDATION="${WAIT_FOR_INVALIDATION:-false}" -echo "Building SPA (VITE_API_URL comes from .env.production)..." +echo "Building SPA (VITE_API_URL comes from the process environment or .env.production)..." npm ci npm run build @@ -48,8 +49,17 @@ aws s3 cp dist/index.html "s3://${BUCKET}/index.html" \ --content-type "text/html" echo "Invalidating CloudFront ${DIST_ID}..." -aws cloudfront create-invalidation \ +INVALIDATION_ID="$(aws cloudfront create-invalidation \ --distribution-id "${DIST_ID}" \ - --paths "/*" + --paths "/*" \ + --query 'Invalidation.Id' \ + --output text)" + +if [[ "${WAIT_FOR_INVALIDATION}" == "true" ]]; then + echo "Waiting for CloudFront invalidation ${INVALIDATION_ID}..." + aws cloudfront wait invalidation-completed \ + --distribution-id "${DIST_ID}" \ + --id "${INVALIDATION_ID}" +fi echo "Web deploy complete." diff --git a/src/app/(protected)/uplifts/_components/format-uplift-money.ts b/src/app/(protected)/uplifts/_components/format-uplift-money.ts new file mode 100644 index 00000000..01aa64ff --- /dev/null +++ b/src/app/(protected)/uplifts/_components/format-uplift-money.ts @@ -0,0 +1,6 @@ +export function formatUpliftMoney(value: number): string { + return `$${value.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; +} diff --git a/src/app/(protected)/uplifts/_components/reject-dialog.tsx b/src/app/(protected)/uplifts/_components/reject-dialog.tsx new file mode 100644 index 00000000..016da452 --- /dev/null +++ b/src/app/(protected)/uplifts/_components/reject-dialog.tsx @@ -0,0 +1,100 @@ +import { useEffect, useState } from "react"; +import { Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material"; +import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money"; +import { UpliftReasonField } from "@/app/(protected)/uplifts/_components/uplift-reason-field"; +import { Button } from "@/components/ui/button"; +import { DialogCancelButton } from "@/components/ui/dialog-cancel-button"; +import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop"; +import { Text } from "@/components/ui/text"; +import { guardDialogClose } from "@/lib/dialog-guard"; + +export type RejectDialogProps = { + open: boolean; + amount?: number | null; + woNumber?: string | null; + onClose: () => void; + onConfirm: (reason: string) => void; + isPending?: boolean; +}; + +function rejectTitle(amount: number | null | undefined): string { + if (amount != null && Number.isFinite(amount)) { + return `Reject uplift of ${formatUpliftMoney(amount)}?`; + } + return "Reject uplift?"; +} + +export function RejectDialog({ + open, + amount, + woNumber, + onClose, + onConfirm, + isPending = false, +}: RejectDialogProps) { + const [reason, setReason] = useState(""); + const canConfirm = reason.trim().length > 0 && !isPending; + + useEffect(() => { + if (open) { + setReason(""); + } + }, [open]); + + const handleClose = () => { + if (isPending) return; + setReason(""); + onClose(); + }; + + return ( + + + {rejectTitle(amount)} + + + {Boolean(woNumber) && ( + + The dispatcher sees this reason on WO #{woNumber}. + + )} + + + + + + + + ); +} diff --git a/src/app/(protected)/uplifts/_components/revoke-dialog.tsx b/src/app/(protected)/uplifts/_components/revoke-dialog.tsx new file mode 100644 index 00000000..38bf8024 --- /dev/null +++ b/src/app/(protected)/uplifts/_components/revoke-dialog.tsx @@ -0,0 +1,110 @@ +import { useEffect, useState } from "react"; +import { Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material"; +import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money"; +import { UpliftReasonField } from "@/app/(protected)/uplifts/_components/uplift-reason-field"; +import { Button } from "@/components/ui/button"; +import { DialogCancelButton } from "@/components/ui/dialog-cancel-button"; +import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop"; +import { Text } from "@/components/ui/text"; +import { guardDialogClose } from "@/lib/dialog-guard"; + +export type RevokeDialogProps = { + open: boolean; + amount?: number | null; + woNumber?: string | null; + onClose: () => void; + onConfirm: (reason: string) => void; + isPending?: boolean; +}; + +function revokeAmountLabel(amount: number | null | undefined): string { + if (amount != null && Number.isFinite(amount)) { + return formatUpliftMoney(amount); + } + return "this uplift"; +} + +function revokeConfirmLabel(amount: number | null | undefined): string { + if (amount != null && Number.isFinite(amount)) { + return `Revoke ${formatUpliftMoney(amount)}`; + } + return "Revoke"; +} + +export function RevokeDialog({ + open, + amount, + woNumber, + onClose, + onConfirm, + isPending = false, +}: RevokeDialogProps) { + const [reason, setReason] = useState(""); + const canConfirm = reason.trim().length > 0 && !isPending; + + useEffect(() => { + if (open) { + setReason(""); + } + }, [open]); + + const handleClose = () => { + if (isPending) return; + setReason(""); + onClose(); + }; + + const amountLabel = revokeAmountLabel(amount); + const woSuffix = woNumber ? ` on WO ${woNumber}` : ""; + + return ( + + + Revoke this approval? + + + + The approved uplift of {amountLabel} + {woSuffix} will be withdrawn. This does not recover money already spent — it records that + the authorization was a mistake. + + + + + + + + + ); +} diff --git a/src/app/(protected)/uplifts/_components/uplift-reason-field.tsx b/src/app/(protected)/uplifts/_components/uplift-reason-field.tsx new file mode 100644 index 00000000..ee4deddc --- /dev/null +++ b/src/app/(protected)/uplifts/_components/uplift-reason-field.tsx @@ -0,0 +1,46 @@ +import { + DLG_INPUT_CLS, + DLG_INPUT_STYLE, +} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles"; +import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label"; + +type UpliftReasonFieldProps = { + id: string; + label: string; + value: string; + placeholder: string; + required?: boolean; + disabled?: boolean; + onChange: (value: string) => void; +}; + +export function UpliftReasonField({ + id, + label, + value, + placeholder, + required = false, + disabled = false, + onChange, +}: UpliftReasonFieldProps) { + return ( +
+ + {label} + +