From 1fd5c2e26d0c0c80e3c4e40ade9598a5cfe37c40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Fri, 5 Jun 2026 15:16:41 +0200 Subject: [PATCH 01/14] Minimalistic SECURITY.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Juan José Mata --- SECURITY.md | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 034e84803..390fb7b2e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,20 +2,15 @@ ## Supported Versions -Use this section to tell people about which versions of your project are -currently being supported with security updates. +We maintain `vX.Y.Z-release-branch` [active areas](https://github.com/we-promise/sure/branches/all?query=release-branch&lastTab=overview) for high impact fixes that need to go out as "hotfix" releases ASAP. | Version | Supported | | ------- | ------------------ | -| 5.1.x | :white_check_mark: | -| 5.0.x | :x: | -| 4.0.x | :white_check_mark: | -| < 4.0 | :x: | +| 0.7.1 | :white_check_mark: | +| 0.7.0 | :white_check_mark: | +| 0.6.x | :x: | ## Reporting a Vulnerability -Use this section to tell people how to report a vulnerability. - -Tell them where to go, how often they can expect to get an update on a -reported vulnerability, what to expect if the vulnerability is accepted or -declined, etc. +Join our Discord and DM @Juanjo there so he can address the vulnerability before +disclosing it. From 85d7695d1fa045dc3bcd687a68e8eed829a3bc59 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Fri, 5 Jun 2026 21:54:40 -0600 Subject: [PATCH 02/14] ci(preview): render Cloudflare config from trusted template (#2207) --- .github/workflows/preview-deploy.yml | 116 +++++++++--------- bin/preview_deploy_security_check.rb | 44 +++++-- .../render_preview_config_test.cjs | 100 +++++++++++++++ .../preview/deploy/render_preview_config.cjs | 114 +++++++++++++++++ 4 files changed, 307 insertions(+), 67 deletions(-) create mode 100644 test/javascript/preview_deploy/render_preview_config_test.cjs create mode 100644 workers/preview/deploy/render_preview_config.cjs diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index 62d2266af..d6e356456 100644 --- a/.github/workflows/preview-deploy.yml +++ b/.github/workflows/preview-deploy.yml @@ -206,12 +206,14 @@ jobs: cp -R trusted/workers/preview/src "$preview_dir/src" mkdir -p "$preview_dir/deploy" cp trusted/workers/preview/deploy/redact_preview_log.sh "$preview_dir/deploy/redact_preview_log.sh" + cp trusted/workers/preview/deploy/render_preview_config.cjs "$preview_dir/deploy/render_preview_config.cjs" chmod 0755 "$preview_dir/deploy/redact_preview_log.sh" diagnostics_nonce="$(openssl rand -hex 32)" sed -i "s/\${PR_NUMBER}/${PR_NUMBER}/g" "$preview_dir/wrangler.toml" sed -i "s/\${PR_NUMBER}/${PR_NUMBER}/g" "$preview_dir/src/index.ts" sed -i "s/\${PREVIEW_DIAGNOSTICS_NONCE}/${diagnostics_nonce}/g" "$preview_dir/src/index.ts" + cp "$preview_dir/wrangler.toml" "$preview_dir/wrangler.source.toml" if grep -F "\${PREVIEW_DIAGNOSTICS_NONCE}" "$preview_dir/src/index.ts" >/dev/null; then echo "Preview diagnostics nonce placeholder was not replaced" >&2 @@ -248,6 +250,7 @@ jobs: set -euo pipefail cd "$RUNNER_TEMP/sure-preview-worker" + source_config="$RUNNER_TEMP/sure-preview-worker/wrangler.source.toml" config_path="$RUNNER_TEMP/sure-preview-worker/wrangler.toml" image_tag="sure-preview-pr-${PR_NUMBER}:${HEAD_SHA}" temporary_image_ref="registry.cloudflare.com/${CLOUDFLARE_ACCOUNT_ID}/${image_tag}" @@ -257,23 +260,8 @@ jobs: # wrangler containers push validates wrangler.toml, so point the trusted # config at a registry-shaped ref while it pushes the verified local image. - TEMPORARY_IMAGE_REF="$temporary_image_ref" node - "$config_path" <<'NODE' - const fs = require('node:fs'); - - const configPath = process.argv[2]; - const imageRef = process.env.TEMPORARY_IMAGE_REF; - - if (!/^registry\.cloudflare\.com\/[A-Za-z0-9_-]+\/sure-preview-pr-[1-9][0-9]*:[a-f0-9]{40}$/.test(imageRef || '')) { - throw new Error('Expected registry-shaped preview image ref before wrangler containers push'); - } - - const original = fs.readFileSync(configPath, 'utf8'); - const updated = original.replace(/image = "[^"]+"/, `image = ${JSON.stringify(imageRef)}`); - if (updated === original) { - throw new Error('Expected wrangler.toml to contain an image entry to rewrite before push'); - } - fs.writeFileSync(configPath, updated); - NODE + PREVIEW_IMAGE_REF="$temporary_image_ref" node ./deploy/render_preview_config.cjs render "$source_config" "$config_path" + cp "$config_path" "$RUNNER_TEMP/wrangler-push.toml" set +e ./node_modules/.bin/wrangler containers push "$image_tag" 2>&1 | tee "$push_log" | ./deploy/redact_preview_log.sh @@ -285,19 +273,7 @@ jobs: exit "$push_status" fi - image_ref="$(node - "$clean_log" <<'NODE' - const fs = require('node:fs'); - - const logPath = process.argv[2]; - const log = fs.readFileSync(logPath, 'utf8'); - const expectedSuffix = `sure-preview-pr-${process.env.PR_NUMBER}:${process.env.HEAD_SHA}`; - const pattern = /registry\.cloudflare\.com\/[A-Za-z0-9_-]+\/sure-preview-pr-[1-9][0-9]*:[a-f0-9]{40}/g; - const matches = [...log.matchAll(pattern)].map((match) => match[0]); - const imageRef = matches.findLast((candidate) => candidate.endsWith(`/${expectedSuffix}`)); - - if (imageRef) process.stdout.write(imageRef); - NODE - )" + image_ref="$(node ./deploy/render_preview_config.cjs find "$clean_log")" if [ -z "$image_ref" ]; then echo "Could not find Cloudflare registry image reference in wrangler output" >&2 @@ -312,30 +288,12 @@ jobs: run: | set -euo pipefail + source_config="$RUNNER_TEMP/sure-preview-worker/wrangler.source.toml" config_path="$RUNNER_TEMP/sure-preview-worker/wrangler.toml" - # Use Node instead of sed so the replacement preserves TOML string syntax. - node - "$config_path" <<'NODE' - const fs = require('node:fs'); - - const configPath = process.argv[2]; - const imageRef = process.env.IMAGE_REF; - const expectedSuffix = `sure-preview-pr-${process.env.PR_NUMBER}:${process.env.HEAD_SHA}`; - - if (!/^registry\.cloudflare\.com\/[A-Za-z0-9_-]+\/sure-preview-pr-[1-9][0-9]*:[a-f0-9]{40}$/.test(imageRef || '')) { - throw new Error('Expected a Cloudflare registry image reference'); - } - - if (!imageRef.endsWith(`/${expectedSuffix}`)) { - throw new Error('Cloudflare registry image reference does not match this preview artifact'); - } - - const original = fs.readFileSync(configPath, 'utf8'); - const updated = original.replace(/image = "[^"]+"/, `image = ${JSON.stringify(imageRef)}`); - if (updated === original) { - throw new Error('Expected wrangler.toml to contain an image entry to rewrite'); - } - fs.writeFileSync(configPath, updated); - NODE + # Render from the preserved trusted source template so the push-time + # registry ref cannot make the final deploy rewrite stateful. + PREVIEW_IMAGE_REF="$IMAGE_REF" node "$RUNNER_TEMP/sure-preview-worker/deploy/render_preview_config.cjs" render "$source_config" "$config_path" + cp "$config_path" "$RUNNER_TEMP/wrangler-final.toml" # Print a redacted copy for logs without mutating the config used by deploy. redacted_config="$RUNNER_TEMP/wrangler-redacted.toml" @@ -395,27 +353,66 @@ jobs: run: | set -euo pipefail - diagnostics_file="$RUNNER_TEMP/preview-diagnostics.json" + diagnostics_dir="$RUNNER_TEMP/preview-diagnostics" + diagnostics_file="$diagnostics_dir/preview-diagnostics.json" + latest_metrics_file="$diagnostics_dir/latest-metrics.json" + polls_log="$diagnostics_dir/metrics-polls.log" + summary_file="$diagnostics_dir/summary.md" last_error="" + mkdir -p "$diagnostics_dir" for attempt in $(seq 1 40); do if curl -fsS --connect-timeout 5 --max-time 15 "$PREVIEW_URL/_container_status" -o "$diagnostics_file"; then - if jq -e '.previewReady == true or .previewFailed == true' "$diagnostics_file" >/dev/null; then - break + if jq -e . "$diagnostics_file" >/dev/null 2>&1; then + jq -c --argjson attempt "$attempt" --arg at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '{attempt: $attempt, at: $at, previewReady: (.previewReady // false), previewFailed: (.previewFailed // false), progress: (.progress // {}), timings: (.timings // {})}' \ + "$diagnostics_file" >> "$polls_log" + jq '{previewReady: (.previewReady // false), previewFailed: (.previewFailed // false), progress: (.progress // {}), timings: (.timings // {})}' "$diagnostics_file" > "$latest_metrics_file" + + if jq -e '.previewReady == true or .previewFailed == true' "$diagnostics_file" >/dev/null; then + break + fi + else + last_error="invalid diagnostics JSON on attempt ${attempt}" + raw_snippet="$(head -c 2048 "$diagnostics_file")" + latest_metrics_snapshot="none" + if [ -f "$latest_metrics_file" ]; then + latest_metrics_snapshot="$(head -c 2048 "$latest_metrics_file")" + fi + jq -nc --argjson attempt "$attempt" --arg at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg error "$last_error" --arg latestMetrics "$latest_metrics_snapshot" --arg rawSnippet "$raw_snippet" \ + '{attempt: $attempt, at: $at, error: $error, latestMetrics: $latestMetrics, rawSnippet: $rawSnippet}' >> "$polls_log" fi else last_error="curl failed on attempt ${attempt}" + jq -nc --argjson attempt "$attempt" --arg at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg error "$last_error" \ + '{attempt: $attempt, at: $at, error: $error}' >> "$polls_log" fi sleep 3 done - if [ ! -s "$diagnostics_file" ]; then + if [ ! -s "$diagnostics_file" ] || ! jq -e . "$diagnostics_file" >/dev/null 2>&1; then jq -n --arg error "${last_error:-preview diagnostics unavailable}" \ --arg url "$PREVIEW_URL" \ '{previewReady: false, previewFailed: false, error: $error, previewUrl: $url}' > "$diagnostics_file" fi + jq '{previewReady: (.previewReady // false), previewFailed: (.previewFailed // false), progress: (.progress // {}), timings: (.timings // {}), error: (.error // null)}' "$diagnostics_file" > "$latest_metrics_file" + { + echo "# Preview diagnostics" + echo + echo "- PR: ${PR_NUMBER}" + echo "- Commit: ${HEAD_SHA}" + echo "- Preview URL: ${PREVIEW_URL}" + echo "- Preview ready: $(jq -r '.previewReady // false' "$diagnostics_file")" + echo "- Preview failed: $(jq -r '.previewFailed // false' "$diagnostics_file")" + echo "- Phase: $(jq -r '.progress.phase // "unknown"' "$diagnostics_file")" + echo "- Stage: $(jq -r '.progress.stage // "unknown"' "$diagnostics_file")" + echo "- Seconds to Rails ready: $(jq -r '.timings.secondsToRailsReady // "unknown"' "$diagnostics_file")" + echo "- Seconds to demo data ready: $(jq -r '.timings.secondsToDemoDataReady // "unknown"' "$diagnostics_file")" + echo "- Seconds to preview ready: $(jq -r '.timings.secondsToPreviewReady // "unknown"' "$diagnostics_file")" + } > "$summary_file" + jq -c . "$diagnostics_file" if jq -e '.previewFailed == true' "$diagnostics_file" >/dev/null; then @@ -441,7 +438,7 @@ jobs: uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: preview-diagnostics-pr-${{ env.PR_NUMBER }}-${{ env.HEAD_SHA }} - path: ${{ runner.temp }}/preview-diagnostics.json + path: ${{ runner.temp }}/preview-diagnostics if-no-files-found: error retention-days: 3 @@ -493,6 +490,9 @@ jobs: }' "$manifest_file" > "$diagnostics_dir/preview-image-manifest.json" fi + sanitize_copy "$RUNNER_TEMP/sure-preview-worker/wrangler.source.toml" "$diagnostics_dir/wrangler-source.toml" + sanitize_copy "$RUNNER_TEMP/wrangler-push.toml" "$diagnostics_dir/wrangler-push.toml" + sanitize_copy "$RUNNER_TEMP/wrangler-final.toml" "$diagnostics_dir/wrangler-final.toml" sanitize_copy "$RUNNER_TEMP/sure-preview-worker/wrangler.toml" "$diagnostics_dir/wrangler.toml" sanitize_copy "$RUNNER_TEMP/wrangler-containers-push.clean.log" "$diagnostics_dir/wrangler-containers-push.log" if [ -f "$RUNNER_TEMP/wrangler-deploy.clean.log" ]; then diff --git a/bin/preview_deploy_security_check.rb b/bin/preview_deploy_security_check.rb index 72339cd6e..d5c2b6c1a 100644 --- a/bin/preview_deploy_security_check.rb +++ b/bin/preview_deploy_security_check.rb @@ -8,6 +8,7 @@ PREVIEW_WORKFLOW_PATH = File.join(ROOT, ".github/workflows/preview-deploy.yml") PR_WORKFLOW_PATH = File.join(ROOT, ".github/workflows/pr.yml") LOCKFILE_PATH = File.join(ROOT, "workers/preview/package-lock.json") RESOLVER_PATH = File.join(ROOT, "workers/preview/deploy/resolve_preview_request.cjs") +CONFIG_RENDERER_PATH = File.join(ROOT, "workers/preview/deploy/render_preview_config.cjs") REDACTION_HELPER_PATH = File.join(ROOT, "workers/preview/deploy/redact_preview_log.sh") PREVIEW_WORKER_PATH = File.join(ROOT, "workers/preview/src/index.ts") PREVIEW_DOCKERFILE_PATH = File.join(ROOT, "Dockerfile.preview") @@ -52,6 +53,7 @@ EXPECTED_COMMENT_PERMISSIONS = { }.freeze EXPECTED_DEPLOY_SECRET_ENV = %w[CLOUDFLARE_ACCOUNT_ID CLOUDFLARE_API_TOKEN CLOUDFLARE_WORKERS_SUBDOMAIN].freeze EXPECTED_PUSH_SECRET_ENV = %w[CLOUDFLARE_ACCOUNT_ID CLOUDFLARE_API_TOKEN].freeze +EXPECTED_DIAGNOSTICS_PATH = "${{ runner.temp }}/preview-diagnostics" EXPECTED_FAILURE_DIAGNOSTICS_PATH = "${{ runner.temp }}/preview-failure-diagnostics" EXPECTED_CLEANUP_METADATA_PATH = "${{ runner.temp }}/preview-cleanup-metadata/wrangler.toml" REQUIRED_PREPARE_LINES = [ @@ -62,9 +64,11 @@ REQUIRED_PREPARE_LINES = [ 'cp -R trusted/workers/preview/src "$preview_dir/src"', 'mkdir -p "$preview_dir/deploy"', 'cp trusted/workers/preview/deploy/redact_preview_log.sh "$preview_dir/deploy/redact_preview_log.sh"', + 'cp trusted/workers/preview/deploy/render_preview_config.cjs "$preview_dir/deploy/render_preview_config.cjs"', 'chmod 0755 "$preview_dir/deploy/redact_preview_log.sh"', 'diagnostics_nonce="$(openssl rand -hex 32)"', 'sed -i "s/\${PREVIEW_DIAGNOSTICS_NONCE}/${diagnostics_nonce}/g" "$preview_dir/src/index.ts"', + 'cp "$preview_dir/wrangler.toml" "$preview_dir/wrangler.source.toml"', "Preview diagnostics nonce placeholder was not replaced", "npm ci --ignore-scripts --no-audit --no-fund" ].freeze @@ -160,6 +164,7 @@ preview_workflow = YAML.safe_load_file(PREVIEW_WORKFLOW_PATH, aliases: true) pr_workflow = YAML.safe_load_file(PR_WORKFLOW_PATH, aliases: true) lockfile = JSON.parse(File.read(LOCKFILE_PATH)) resolver_script = File.read(RESOLVER_PATH) +config_renderer_script = File.read(CONFIG_RENDERER_PATH) redaction_helper_script = File.read(REDACTION_HELPER_PATH) preview_worker_script = File.read(PREVIEW_WORKER_PATH) preview_dockerfile = File.read(PREVIEW_DOCKERFILE_PATH) @@ -282,7 +287,7 @@ comment_on_pr = step!(preview_comment_steps, "Comment on PR") [ "fork deployment record guard", create_deployment.fetch("if"), "env.IS_FORK == 'false'" ], [ "diagnostics upload if", upload_diagnostics.fetch("if"), "always() && steps.deploy.outputs.preview_url != ''" ], [ "diagnostics upload name", upload_diagnostics.dig("with", "name"), "preview-diagnostics-pr-${{ env.PR_NUMBER }}-${{ env.HEAD_SHA }}" ], - [ "diagnostics upload path", upload_diagnostics.dig("with", "path"), "${{ runner.temp }}/preview-diagnostics.json" ], + [ "diagnostics upload path", upload_diagnostics.dig("with", "path"), EXPECTED_DIAGNOSTICS_PATH ], [ "diagnostics upload retention", upload_diagnostics.dig("with", "retention-days"), 3 ], [ "failure diagnostics collect if", collect_failure_diagnostics.fetch("if"), "failure()" ], [ "failure diagnostics upload if", upload_failure_diagnostics.fetch("if"), "failure()" ], @@ -423,6 +428,18 @@ assert(File.executable?(REDACTION_HELPER_PATH), "preview log redaction helper mu "" ].each { |needle| assert(redaction_helper_script.include?(needle), "preview log redaction helper must include #{needle.inspect}") } +[ + "REGISTRY_IMAGE_REF_PATTERN", + "REGISTRY_IMAGE_REF_SCAN_PATTERN", + "function validateRegistryImageRef", + "function renderPreviewConfig", + "function findRegistryImageRef", + "Expected wrangler.toml source to contain exactly one image entry", + "Cloudflare registry image reference does not match this preview artifact", + "Cloudflare registry image reference account does not match this workflow", + "module.exports" +].each { |needle| assert(config_renderer_script.include?(needle), "preview config renderer must include #{needle.inspect}") } + prepare_run = assert_run_includes(prepare, *REQUIRED_PREPARE_LINES) assert(!prepare_run.include?("npm install"), "prepare step must not use npm install") assert(!prepare_run.include?("CLOUDFLARE_API_TOKEN"), "prepare step must not receive Cloudflare secrets") @@ -440,29 +457,38 @@ push_image_run = assert_run_includes( push_image, "./node_modules/.bin/wrangler containers push", "registry.cloudflare.com/${CLOUDFLARE_ACCOUNT_ID}/${image_tag}", - "registry\\.cloudflare\\.com\\/", "image_ref=", + 'source_config="$RUNNER_TEMP/sure-preview-worker/wrangler.source.toml"', 'config_path="$RUNNER_TEMP/sure-preview-worker/wrangler.toml"', 'temporary_image_ref="registry.cloudflare.com/${CLOUDFLARE_ACCOUNT_ID}/${image_tag}"', - 'TEMPORARY_IMAGE_REF="$temporary_image_ref" node - "$config_path"', - "Expected registry-shaped preview image ref before wrangler containers push", - "Expected wrangler.toml to contain an image entry to rewrite before push" + 'PREVIEW_IMAGE_REF="$temporary_image_ref" node ./deploy/render_preview_config.cjs render "$source_config" "$config_path"', + 'cp "$config_path" "$RUNNER_TEMP/wrangler-push.toml"', + 'image_ref="$(node ./deploy/render_preview_config.cjs find "$clean_log")"' ) -push_rewrite_index = push_image_run.index('TEMPORARY_IMAGE_REF="$temporary_image_ref" node - "$config_path"') +push_rewrite_index = push_image_run.index('PREVIEW_IMAGE_REF="$temporary_image_ref" node ./deploy/render_preview_config.cjs render "$source_config" "$config_path"') push_command_index = push_image_run.index("./node_modules/.bin/wrangler containers push") assert( push_rewrite_index < push_command_index, "push step must rewrite wrangler.toml to a registry-shaped image ref before wrangler validates it" ) +assert(push_image_run.index('wrangler.source.toml') < push_rewrite_index, "push step must render from the preserved trusted source config") assert(!push_image_run.include?("LOCAL_IMAGE_TAG"), "push step must not write a local Docker tag into wrangler.toml") assert(!push_image_run.include?("Expected local preview image tag"), "push step must not accept local Docker tags as wrangler config image refs") assert_run_includes(push_image, 'tee "$push_log" | ./deploy/redact_preview_log.sh', "push_status=${PIPESTATUS[0]}") -assert_run_includes(configure_image, "registry\\.cloudflare\\.com", "expectedSuffix", "imageRef.endsWith", "Cloudflare registry image reference does not match this preview artifact", 'const original = fs.readFileSync', 'const updated = original.replace(/image = "[^"]+"/', "updated === original", "Expected wrangler.toml to contain an image entry to rewrite", "JSON.stringify(imageRef)", 'redact_preview_log.sh" < "$config_path"') +configure_image_run = assert_run_includes( + configure_image, + 'source_config="$RUNNER_TEMP/sure-preview-worker/wrangler.source.toml"', + 'PREVIEW_IMAGE_REF="$IMAGE_REF" node "$RUNNER_TEMP/sure-preview-worker/deploy/render_preview_config.cjs" render "$source_config" "$config_path"', + 'cp "$config_path" "$RUNNER_TEMP/wrangler-final.toml"', + "preserved trusted source template", + 'redact_preview_log.sh" < "$config_path"' +) +assert(!configure_image_run.include?('const updated = original.replace(/image = "[^"]+"/'), "final image configuration must use the tested renderer instead of inline regex replacement") assert_run_includes(create_deployment, "github.rest.repos.createDeployment", "ref: headSha", "preview-pr-${prNumber}") assert_run_includes(deploy, 'cd "$RUNNER_TEMP/sure-preview-worker"', "deploy_once()", "./node_modules/.bin/wrangler deploy --config wrangler.toml", '--var "PR_NUMBER:${PR_NUMBER}"', 'tee "$deploy_log" | ./deploy/redact_preview_log.sh', "deploy_status=${PIPESTATUS[0]}", "associated with a different durable object namespace", 'if ! ./node_modules/.bin/wrangler delete --name "sure-preview-${PR_NUMBER}" --force', "Preview Worker delete failed", "retrying once") assert_run_includes(warm_preview, "$PREVIEW_URL/_container_status", "--connect-timeout 5", "--max-time 15") -assert_run_includes(collect_diagnostics, "$PREVIEW_URL/_container_status", "--connect-timeout 5", "--max-time 15", "seq 1 40", "preview-diagnostics.json", "jq -e '.previewReady == true or .previewFailed == true'", "jq -e '.previewFailed == true'", "Preview diagnostics from _container_status reported previewFailed=true", "jq -e '.previewReady == true'", "Preview diagnostics from _container_status did not reach previewReady=true", ".timings.previewReadyAt != null and .timings.secondsToPreviewReady != null", "Preview diagnostics are missing readiness timing fields", "exit 1") -assert_run_includes(collect_failure_diagnostics, "preview-failure-diagnostics", "preview-request.json", "preview-image-manifest.json", "wrangler.toml", "wrangler-containers-push.log", "wrangler-deploy.log", "redaction_helper=", 'sanitize_copy "$RUNNER_TEMP/sure-preview-worker/wrangler.toml"', "wrangler-deploy.clean.log", "resolutionSource") +assert_run_includes(collect_diagnostics, "$PREVIEW_URL/_container_status", "--connect-timeout 5", "--max-time 15", "seq 1 40", "preview-diagnostics", "preview-diagnostics.json", "latest-metrics.json", "metrics-polls.log", "summary.md", '! jq -e . "$diagnostics_file"', 'raw_snippet="$(head -c 2048 "$diagnostics_file")"', 'latest_metrics_snapshot="$(head -c 2048 "$latest_metrics_file")"', "rawSnippet", "latestMetrics", "jq -e '.previewReady == true or .previewFailed == true'", "jq -e '.previewFailed == true'", "Preview diagnostics from _container_status reported previewFailed=true", "jq -e '.previewReady == true'", "Preview diagnostics from _container_status did not reach previewReady=true", ".timings.previewReadyAt != null and .timings.secondsToPreviewReady != null", "Preview diagnostics are missing readiness timing fields", "exit 1") +assert_run_includes(collect_failure_diagnostics, "preview-failure-diagnostics", "preview-request.json", "preview-image-manifest.json", "wrangler-source.toml", "wrangler-push.toml", "wrangler-final.toml", "wrangler.toml", "wrangler-containers-push.log", "wrangler-deploy.log", "redaction_helper=", 'sanitize_copy "$RUNNER_TEMP/sure-preview-worker/wrangler.source.toml"', 'sanitize_copy "$RUNNER_TEMP/wrangler-push.toml"', 'sanitize_copy "$RUNNER_TEMP/wrangler-final.toml"', "wrangler-deploy.clean.log", "resolutionSource") assert_run_includes(prepare_cleanup_metadata, "preview-cleanup-metadata", "redact_preview_log.sh", "$RUNNER_TEMP/sure-preview-worker/wrangler.toml", "$metadata_dir/wrangler.toml") assert_run_includes(update_deployment_status, "github.rest.repos.createDeploymentStatus", "process.env.DEPLOY_RESULT === 'success'", "deployment_id: Number(process.env.DEPLOYMENT_ID)") assert_run_includes(comment_on_pr, "github.rest.issues.listComments", "github.rest.issues.updateComment", "github.rest.issues.createComment", "Preview Deployment Ready") diff --git a/test/javascript/preview_deploy/render_preview_config_test.cjs b/test/javascript/preview_deploy/render_preview_config_test.cjs new file mode 100644 index 000000000..70deab73f --- /dev/null +++ b/test/javascript/preview_deploy/render_preview_config_test.cjs @@ -0,0 +1,100 @@ +const assert = require("node:assert/strict"); +const { describe, it } = require("node:test"); + +const { + findRegistryImageRef, + renderPreviewConfig, + validateRegistryImageRef, +} = require("../../../workers/preview/deploy/render_preview_config.cjs"); + +const options = { + accountId: "account_123", + prNumber: "2160", + headSha: "3f013c4d9193ff111295c89a6f833d59bd69d91e", +}; +const imageRef = + "registry.cloudflare.com/account_123/sure-preview-pr-2160:3f013c4d9193ff111295c89a6f833d59bd69d91e"; + +describe("renderPreviewConfig", () => { + it("renders exactly one trusted TOML image entry to a registry reference", () => { + const source = [ + 'name = "sure-preview-2160"', + "", + "[[containers]]", + 'image = "../../Dockerfile.preview"', + 'class_name = "RailsContainer"', + "", + ].join("\n"); + + const rendered = renderPreviewConfig(source, imageRef, options); + + assert.ok(rendered.includes(`image = "${imageRef}"`)); + assert.doesNotMatch(rendered, /Dockerfile\.preview/); + }); + + it("rejects missing image entries", () => { + assert.throws( + () => renderPreviewConfig('name = "sure-preview-2160"\n', imageRef, options), + /contain an image entry/ + ); + }); + + it("rejects duplicate image entries", () => { + const source = [ + "[[containers]]", + 'image = "../../Dockerfile.preview"', + "", + "[[containers]]", + 'image = "../../OtherDockerfile"', + "", + ].join("\n"); + + assert.throws(() => renderPreviewConfig(source, imageRef, options), /exactly one image entry/); + }); + + it("rejects local Docker tags as deploy image refs", () => { + assert.throws( + () => renderPreviewConfig('image = "../../Dockerfile.preview"\n', "my-local-image:latest", options), + /Cloudflare registry image reference/ + ); + }); +}); + +describe("validateRegistryImageRef", () => { + it("accepts the expected registry ref", () => { + assert.equal(validateRegistryImageRef(imageRef, options), imageRef); + }); + + it("rejects registry refs for another PR", () => { + const wrongPr = + "registry.cloudflare.com/account_123/sure-preview-pr-2161:3f013c4d9193ff111295c89a6f833d59bd69d91e"; + + assert.throws(() => validateRegistryImageRef(wrongPr, options), /does not match this preview artifact/); + }); + + it("rejects registry refs for another account", () => { + const wrongAccount = + "registry.cloudflare.com/account_456/sure-preview-pr-2160:3f013c4d9193ff111295c89a6f833d59bd69d91e"; + + assert.throws(() => validateRegistryImageRef(wrongAccount, options), /account does not match/); + }); +}); + +describe("findRegistryImageRef", () => { + it("extracts the expected registry image ref from wrangler output", () => { + const log = [ + "Pushing image layers", + "Published registry.cloudflare.com/account_123/sure-preview-pr-2160:3f013c4d9193ff111295c89a6f833d59bd69d91e", + "Done", + ].join("\n"); + + assert.equal(findRegistryImageRef(log, options), imageRef); + }); + + it("ignores registry refs that do not match this preview artifact", () => { + const log = + "Published registry.cloudflare.com/account_123/sure-preview-pr-2161:3f013c4d9193ff111295c89a6f833d59bd69d91e"; + + assert.equal(findRegistryImageRef(log, options), ""); + }); +}); diff --git a/workers/preview/deploy/render_preview_config.cjs b/workers/preview/deploy/render_preview_config.cjs new file mode 100644 index 000000000..4afeed72e --- /dev/null +++ b/workers/preview/deploy/render_preview_config.cjs @@ -0,0 +1,114 @@ +const fs = require("node:fs"); + +const IMAGE_FIELD_PATTERN = /^(\s*image\s*=\s*)"([^"]*)"(\s*(?:#.*)?)$/gm; +const REGISTRY_IMAGE_REF_PATTERN = + /^registry\.cloudflare\.com\/([A-Za-z0-9_-]+)\/(sure-preview-pr-([1-9][0-9]*):([a-f0-9]{40}))$/; +const REGISTRY_IMAGE_REF_SCAN_PATTERN = + /registry\.cloudflare\.com\/[A-Za-z0-9_-]+\/sure-preview-pr-[1-9][0-9]*:[a-f0-9]{40}/g; + +function expectedImageTag({ prNumber, headSha }) { + if (!/^[1-9][0-9]*$/.test(String(prNumber || ""))) { + throw new Error("Expected a numeric preview PR number"); + } + + if (!/^[a-f0-9]{40}$/.test(String(headSha || ""))) { + throw new Error("Expected a 40-character preview head SHA"); + } + + return `sure-preview-pr-${prNumber}:${headSha}`; +} + +function validateRegistryImageRef(imageRef, { accountId, prNumber, headSha }) { + const match = REGISTRY_IMAGE_REF_PATTERN.exec(imageRef || ""); + if (!match) { + throw new Error("Expected a Cloudflare registry image reference"); + } + + const expectedTag = expectedImageTag({ prNumber, headSha }); + if (match[2] !== expectedTag) { + throw new Error("Cloudflare registry image reference does not match this preview artifact"); + } + + if (accountId && match[1] !== accountId) { + throw new Error("Cloudflare registry image reference account does not match this workflow"); + } + + return imageRef; +} + +function renderPreviewConfig(source, imageRef, options) { + validateRegistryImageRef(imageRef, options); + + const matches = [...source.matchAll(IMAGE_FIELD_PATTERN)]; + if (matches.length === 0) { + throw new Error("Expected wrangler.toml source to contain an image entry"); + } + + if (matches.length > 1) { + throw new Error("Expected wrangler.toml source to contain exactly one image entry"); + } + + return source.replace(IMAGE_FIELD_PATTERN, `$1${JSON.stringify(imageRef)}$3`); +} + +function findRegistryImageRef(log, options) { + const matches = [...new Set(log.match(REGISTRY_IMAGE_REF_SCAN_PATTERN) || [])]; + const matchedRef = matches.find((candidate) => { + try { + validateRegistryImageRef(candidate, options); + return true; + } catch { + return false; + } + }); + + return matchedRef || ""; +} + +function envOptions() { + return { + accountId: process.env.CLOUDFLARE_ACCOUNT_ID, + prNumber: process.env.PR_NUMBER, + headSha: process.env.HEAD_SHA, + }; +} + +function runCli() { + const command = process.argv[2]; + + if (command === "render") { + const sourcePath = process.argv[3]; + const destinationPath = process.argv[4]; + const imageRef = process.env.PREVIEW_IMAGE_REF; + + if (!sourcePath || !destinationPath) { + throw new Error("Usage: render_preview_config.cjs render "); + } + + const rendered = renderPreviewConfig(fs.readFileSync(sourcePath, "utf8"), imageRef, envOptions()); + fs.writeFileSync(destinationPath, rendered); + return; + } + + if (command === "find") { + const logPath = process.argv[3]; + if (!logPath) { + throw new Error("Usage: render_preview_config.cjs find "); + } + + process.stdout.write(findRegistryImageRef(fs.readFileSync(logPath, "utf8"), envOptions())); + return; + } + + throw new Error(`Unknown command ${command || ""}`); +} + +if (require.main === module) { + runCli(); +} + +module.exports = { + findRegistryImageRef, + renderPreviewConfig, + validateRegistryImageRef, +}; From 172301f875a7699967b675f7c69418b2cbd4442a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Sat, 6 Jun 2026 09:14:50 +0200 Subject: [PATCH 03/14] Fix SSO provider settings updates (#2210) --- .../admin/sso_providers_controller.rb | 3 +- app/views/admin/sso_providers/_form.html.erb | 57 ++++++++++------- .../admin/sso_providers_controller_test.rb | 62 +++++++++++++++++++ 3 files changed, 99 insertions(+), 23 deletions(-) create mode 100644 test/controllers/admin/sso_providers_controller_test.rb diff --git a/app/controllers/admin/sso_providers_controller.rb b/app/controllers/admin/sso_providers_controller.rb index 97bc86fa3..ca493380b 100644 --- a/app/controllers/admin/sso_providers_controller.rb +++ b/app/controllers/admin/sso_providers_controller.rb @@ -50,7 +50,8 @@ module Admin authorize @sso_provider # Auto-update redirect_uri if name changed - params_hash = processed_params.to_h + params_hash = processed_params.to_h.with_indifferent_access + params_hash.delete(:client_secret) if params_hash[:client_secret].blank? if params_hash[:name].present? && params_hash[:name] != @sso_provider.name params_hash[:redirect_uri] = "#{request.base_url}/auth/#{params_hash[:name]}/callback" end diff --git a/app/views/admin/sso_providers/_form.html.erb b/app/views/admin/sso_providers/_form.html.erb index 9845bab1a..a49adb22d 100644 --- a/app/views/admin/sso_providers/_form.html.erb +++ b/app/views/admin/sso_providers/_form.html.erb @@ -83,7 +83,9 @@ <%= form.password_field :client_secret, label: t("admin.sso_providers.form.client_secret_label"), placeholder: sso_provider.persisted? ? t("admin.sso_providers.form.client_secret_placeholder_existing") : t("admin.sso_providers.form.client_secret_placeholder_new"), - required: !sso_provider.persisted? %> + required: !sso_provider.persisted?, + autocomplete: "new-password", + value: nil %> <% if sso_provider.persisted? %>

<%= t("admin.sso_providers.form.client_secret_help_existing") %>

<% end %> @@ -190,14 +192,18 @@

<%= t("admin.sso_providers.form.provisioning_title") %>

- <%= form.select "settings[default_role]", - options_for_select([ - [t("admin.sso_providers.form.role_guest", default: "Guest"), "guest"], - [t("admin.sso_providers.form.role_member"), "member"], - [t("admin.sso_providers.form.role_admin"), "admin"], - [t("admin.sso_providers.form.role_super_admin"), "super_admin"] - ], sso_provider.settings&.dig("default_role").to_s.presence || "member"), - { label: t("admin.sso_providers.form.default_role_label"), include_blank: false } %> +
+ <%= label_tag "sso_provider_settings_default_role", t("admin.sso_providers.form.default_role_label"), class: "form-field__label" %> + <%= select_tag "sso_provider[settings][default_role]", + options_for_select([ + [t("admin.sso_providers.form.role_guest", default: "Guest"), "guest"], + [t("admin.sso_providers.form.role_member"), "member"], + [t("admin.sso_providers.form.role_admin"), "admin"], + [t("admin.sso_providers.form.role_super_admin"), "super_admin"] + ], sso_provider.settings&.dig("default_role").to_s.presence || "member"), + id: "sso_provider_settings_default_role", + class: "form-field__input" %> +

<%= t("admin.sso_providers.form.default_role_help") %>

@@ -249,22 +255,29 @@

<%= t("admin.sso_providers.form.advanced_title") %>

- <%= form.text_field "settings[scopes]", - label: t("admin.sso_providers.form.scopes_label"), - value: sso_provider.settings&.dig("scopes"), - placeholder: "openid email profile groups" %> + <%= label_tag "sso_provider_settings_scopes", t("admin.sso_providers.form.scopes_label"), class: "form-field__label" %> + <%= text_field_tag "sso_provider[settings][scopes]", + sso_provider.settings&.dig("scopes"), + id: "sso_provider_settings_scopes", + class: "form-field__input", + placeholder: "openid email profile groups", + autocomplete: "off" %>

<%= t("admin.sso_providers.form.scopes_help") %>

- <%= form.select "settings[prompt]", - options_for_select([ - [t("admin.sso_providers.form.prompt_default"), ""], - [t("admin.sso_providers.form.prompt_login"), "login"], - [t("admin.sso_providers.form.prompt_consent"), "consent"], - [t("admin.sso_providers.form.prompt_select_account"), "select_account"], - [t("admin.sso_providers.form.prompt_none"), "none"] - ], sso_provider.settings&.dig("prompt")), - { label: t("admin.sso_providers.form.prompt_label"), include_blank: false } %> +
+ <%= label_tag "sso_provider_settings_prompt", t("admin.sso_providers.form.prompt_label"), class: "form-field__label" %> + <%= select_tag "sso_provider[settings][prompt]", + options_for_select([ + [t("admin.sso_providers.form.prompt_default"), ""], + [t("admin.sso_providers.form.prompt_login"), "login"], + [t("admin.sso_providers.form.prompt_consent"), "consent"], + [t("admin.sso_providers.form.prompt_select_account"), "select_account"], + [t("admin.sso_providers.form.prompt_none"), "none"] + ], sso_provider.settings&.dig("prompt")), + id: "sso_provider_settings_prompt", + class: "form-field__input" %> +

<%= t("admin.sso_providers.form.prompt_help") %>

diff --git a/test/controllers/admin/sso_providers_controller_test.rb b/test/controllers/admin/sso_providers_controller_test.rb new file mode 100644 index 000000000..c9085055c --- /dev/null +++ b/test/controllers/admin/sso_providers_controller_test.rb @@ -0,0 +1,62 @@ +require "test_helper" + +class Admin::SsoProvidersControllerTest < ActionDispatch::IntegrationTest + setup do + ensure_tailwind_build + sign_in users(:sure_support_staff) + + @provider = SsoProvider.create!( + strategy: "google_oauth2", + name: "google_oauth2_test", + label: "Sign in with Google", + enabled: true, + client_id: "client-id", + client_secret: "existing-secret", + settings: { "default_role" => "member" } + ) + end + + test "edit form posts nested settings and optional client secret" do + get edit_admin_sso_provider_path(@provider) + + assert_response :success + assert_includes response.body, 'name="sso_provider[settings][default_role]"' + assert_includes response.body, 'name="sso_provider[settings][scopes]"' + assert_includes response.body, 'name="sso_provider[settings][prompt]"' + assert_includes response.body, 'name="sso_provider[client_secret]"' + assert_no_match(/name="sso_provider\[client_secret\]"[^>]*required/, response.body) + end + + test "update persists nested default role setting" do + patch admin_sso_provider_path(@provider), params: { + sso_provider: valid_update_params(settings: { default_role: "guest" }) + } + + assert_redirected_to admin_sso_providers_path + assert_equal "guest", @provider.reload.settings["default_role"] + end + + test "update preserves existing client secret when blank" do + patch admin_sso_provider_path(@provider), params: { + sso_provider: valid_update_params(client_secret: "", label: "Updated Google") + } + + assert_redirected_to admin_sso_providers_path + @provider.reload + assert_equal "Updated Google", @provider.label + assert_equal "existing-secret", @provider.client_secret + end + + private + def valid_update_params(overrides = {}) + { + strategy: @provider.strategy, + name: @provider.name, + label: @provider.label, + enabled: "1", + client_id: @provider.client_id, + client_secret: @provider.client_secret, + settings: @provider.settings + }.deep_merge(overrides) + end +end From 5d0eb7f4451b2818e648f260edcb1c85d428cce6 Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Sat, 6 Jun 2026 16:24:17 +0200 Subject: [PATCH 04/14] =?UTF-8?q?fix(goals):=20UI=20polish=20=E2=80=94=20s?= =?UTF-8?q?ubmit=20validation,=20container-responsive=20cards,=20picker=20?= =?UTF-8?q?&=20filter=20fixes=20(#2160)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ds): disabled buttons use not-allowed cursor Tailwind v4 preflight sets cursor:pointer on every <% end %> @@ -139,7 +147,7 @@ · <%= @grid_goals.size %> -
+
<% @grid_goals.each do |goal| %> <%= render Goals::CardComponent.new(goal: goal) %> <% end %> @@ -179,7 +187,7 @@ <%= @archived_goals.size %> <% end %> -
+
<% @archived_goals.each do |goal| %> <%= render Goals::CardComponent.new(goal: goal, filterable: false) %> <% end %> diff --git a/app/views/goals/show.html.erb b/app/views/goals/show.html.erb index 21bc8ec22..6409f4d87 100644 --- a/app/views/goals/show.html.erb +++ b/app/views/goals/show.html.erb @@ -224,7 +224,7 @@ <% end %>
-
" diff --git a/config/locales/views/goals/en.yml b/config/locales/views/goals/en.yml index 031633272..cf2a5a57a 100644 --- a/config/locales/views/goals/en.yml +++ b/config/locales/views/goals/en.yml @@ -2,6 +2,7 @@ en: goals: color_picker: + trigger_label: Choose color and icon color_heading: Color icon_heading: Icon poor_contrast: Poor contrast, choose darker color or @@ -222,9 +223,6 @@ en: days_left: one: 1 day left other: "%{count} days left" - days_left_by: - one: 1 day left · by %{date} - other: "%{count} days left · by %{date}" pace_with_target: "%{avg}/mo · target %{target}/mo" pace_no_target: "%{avg}/mo avg" footer_paused: Paused From d88d6e9e58715c243616cfc4d585fe906e289b77 Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Sat, 6 Jun 2026 16:28:22 +0200 Subject: [PATCH 05/14] =?UTF-8?q?fix(ds):=20canonical=20transaction-row=20?= =?UTF-8?q?=E2=80=94=20stop=20category=20pills=20truncating=20(#2147)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ds): canonical transaction-row — stop category pills truncating (#2137) The desktop transaction grid gave the name column col-span-8 (67%, usually half-empty) and the category column only col-span-2 (17%), so the category pill's name area was clamped to ~44px and ellipsized nearly every value ("Misc...", "Shop...", "Rest...") despite the empty space the audit flagged. - Rebalance the lg:grid-cols-12 row: name col-span-8 -> 7, category 2 -> 3 (amount unchanged; still 12). Category gains 50% width from the over-wide name column. - categories/_badge: the pill was `flex w-full` (stretched to fill the column); make it `inline-flex max-w-full` so it hugs its content and short names render fully, capping + truncating only when genuinely too long. Verified on /transactions: visible-row category truncation dropped 7/7 -> 2/7 even in the compressed (AI-panel-open) view; Payment / Shopping / Restaurants now render in full. Fixes both the interactive categories/menu and the static transfer categories/badge (menu reuses the badge partial). * feat(ds): lift categories/_badge onto DS::Pill Addresses the Drift Patrol finding (and jjmata's request) properly instead of patching the bespoke span: the badge becomes a DS::Pill in badge mode. The pill primitive grows the three capabilities the badge needed and the bot's one-liner glossed over: - truncate: pills that may shrink inside min-w-0 columns drop their shrink-0/whitespace-nowrap and ellipsize the label instead of overflowing (the transaction-row category cell this PR fixes). - label_testid: stamps data-testid on the label span; five test files target [data-testid='category-name']. - icon_size: passthrough to the icon helper (badge keeps its established sm glyph; default stays xs). custom_color was already the sanctioned escape hatch for user-chosen hues. Owned visual deltas from pill standardization: px-1.5/py-1 -> px-2/py-0.5, gap-1 -> gap-1.5, border mix 10% -> 20%; bg mix, hex text, radius, text scale, icon size, truncation and testid are parity. Label wrapper only renders when truncate/label_testid ask for it, so existing pill DOM (and the find("span", text:) assertions on it) is unchanged. Four new pill tests + a Lookbook case cover the recipe. --- app/components/DS/pill.html.erb | 8 +++-- app/components/DS/pill.rb | 24 +++++++++++--- app/views/categories/_badge.html.erb | 27 ++++++++------- app/views/transactions/_transaction.html.erb | 4 +-- test/components/DS/pill_test.rb | 33 +++++++++++++++++++ .../previews/pill_component_preview.rb | 15 +++++++++ 6 files changed, 91 insertions(+), 20 deletions(-) diff --git a/app/components/DS/pill.html.erb b/app/components/DS/pill.html.erb index 2dc15e0ec..67e051905 100644 --- a/app/components/DS/pill.html.erb +++ b/app/components/DS/pill.html.erb @@ -10,11 +10,15 @@ <% else %> <% if icon %> - <%= helpers.icon(icon, size: "xs", color: "current") %> + <%= helpers.icon(icon, size: icon_size, color: "current") %> <% elsif show_dot %> <% end %> - <%= label %> + <% if truncate || label_testid %> + <%= tag.span label, class: ("min-w-0 truncate" if truncate), data: (label_testid ? { testid: label_testid } : nil) %> + <% else %> + <%= label %> + <% end %> <% end %> diff --git a/app/components/DS/pill.rb b/app/components/DS/pill.rb index c45bf056c..b9ef102db 100644 --- a/app/components/DS/pill.rb +++ b/app/components/DS/pill.rb @@ -16,7 +16,8 @@ class DS::Pill < DesignSystemComponent neutral: :gray }.freeze - attr_reader :label, :tone, :style, :size, :show_dot, :dot_only, :title, :icon, :marker, :custom_color + attr_reader :label, :tone, :style, :size, :show_dot, :dot_only, :title, :icon, :marker, :custom_color, + :truncate, :label_testid, :icon_size # Generic inline pill primitive. Two modes: # @@ -50,8 +51,17 @@ class DS::Pill < DesignSystemComponent # `SEMANTIC_TONE_ALIASES`. # - Sure has full violet / indigo / fuchsia / amber / green / gray / # red ramps in the design system; this component picks named tokens - # at render time. No raw hex. - def initialize(label: nil, tone: :violet, style: :soft, size: :sm, show_dot: nil, dot_only: false, title: nil, icon: nil, marker: true, custom_color: nil) + # at render time. No raw hex — except `custom_color:`, which exists for + # user-defined entities (categories, tags) whose hue is data, not design. + # - `truncate: true` lets the pill shrink inside a `min-w-0` parent and + # ellipsize its label instead of overflowing (dense table cells like the + # transaction row's category column). Default pills stay `shrink-0`. + # - `label_testid:` stamps `data-testid` on the label span for system / + # controller tests that need to target the text node. + # - `icon_size:` passes through to the icon helper (default "xs"; the + # category badge uses "sm" to keep its established glyph size). + def initialize(label: nil, tone: :violet, style: :soft, size: :sm, show_dot: nil, dot_only: false, title: nil, icon: nil, marker: true, custom_color: nil, + truncate: false, label_testid: nil, icon_size: "xs") resolved_tone = SEMANTIC_TONE_ALIASES.fetch(tone.to_sym, tone.to_sym) @label = label || I18n.t("ds.pill.default_label", default: "Beta") @tone = TONES.include?(resolved_tone) ? resolved_tone : :violet @@ -65,6 +75,9 @@ class DS::Pill < DesignSystemComponent @icon = icon @marker = marker @custom_color = custom_color + @truncate = truncate + @label_testid = label_testid + @icon_size = icon_size end def palette @@ -149,7 +162,10 @@ class DS::Pill < DesignSystemComponent def container_classes base = [ - "inline-flex items-center align-middle font-medium whitespace-nowrap shrink-0", + "inline-flex items-center align-middle font-medium", + # Truncating pills must be allowed to shrink (and let the label span + # ellipsize); everything else keeps its intrinsic width. + truncate ? "max-w-full min-w-0" : "whitespace-nowrap shrink-0", "border leading-none" ] diff --git a/app/views/categories/_badge.html.erb b/app/views/categories/_badge.html.erb index 5ab8d6c1d..283c05fc2 100644 --- a/app/views/categories/_badge.html.erb +++ b/app/views/categories/_badge.html.erb @@ -1,17 +1,20 @@ <%# locals: (category:) %> <% category ||= Category.uncategorized %> +<%# Canonical category badge: DS::Pill in badge mode carrying the category's + user-chosen hex (custom_color is the sanctioned escape hatch for hues that + are data, not design). truncate + this min-w-0 wrapper keep long names + ellipsizing inside tight columns (e.g. the transaction row's category + cell) instead of overflowing them. %>
- - <% if category.lucide_icon.present? %> - - <%= icon category.lucide_icon, size: "sm", color: "current" %> - - <% end %> - <%= category.name %> - + <%= render DS::Pill.new( + label: category.name, + custom_color: category.color, + icon: category.lucide_icon.presence, + icon_size: "sm", + marker: false, + size: :md, + truncate: true, + label_testid: "category-name", + title: category.name) %>
diff --git a/app/views/transactions/_transaction.html.erb b/app/views/transactions/_transaction.html.erb index 9aff39d03..9752d6117 100644 --- a/app/views/transactions/_transaction.html.erb +++ b/app/views/transactions/_transaction.html.erb @@ -7,7 +7,7 @@ <%= turbo_frame_tag dom_id(transaction) do %>
"> -
+
<%= check_box_tag dom_id(entry, "selection"), disabled: transaction.transfer.present?, class: "checkbox checkbox--light hidden lg:block", @@ -179,7 +179,7 @@
-