Compare commits

...

1 Commits

Author SHA1 Message Date
core-fe 1ba5b8e029 fix(canvas): honest secrets UI — write-only indicator + honest Test errors
CI / Detect changes (pull_request) Successful in 48s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 50s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 57s
E2E API Smoke Test / detect-changes (pull_request) Successful in 24s
E2E Chat / detect-changes (pull_request) Successful in 44s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 45s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 45s
Harness Replays / detect-changes (pull_request) Successful in 30s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 27s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 33s
qa-review / approved (pull_request) Failing after 27s
gate-check-v3 / gate-check (pull_request) Successful in 31s
sop-tier-check / tier-check (pull_request) Successful in 23s
security-review / approved (pull_request) Failing after 33s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m45s
CI / Python Lint & Test (pull_request) Successful in 8m26s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 7s
CI / Platform (Go) (pull_request) Successful in 12m25s
CI / Canvas (Next.js) (pull_request) Successful in 13m37s
CI / all-required (pull_request) Successful in 13m21s
E2E Chat / E2E Chat (pull_request) Failing after 6m44s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2s
Harness Replays / Harness Replays (pull_request) Successful in 2s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 7m57s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
sop-checklist / review-refire (pull_request_target) Has been skipped
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, l
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request_target) Successful in 5s
sop-tier-check / tier-check (pull_request_target) Successful in 8s
Two staging-port fixes addressing internal#490 and internal#491:

- SecretRow: remove dead RevealToggle (value is write-only, never
  readable). Replace with honest 🔒 indicator + aria-label explaining
  rotation is via Edit. Removes revealed-state hooks and AUTO_HIDE_MS.
  Adds .secret-row__write-only CSS class.

- TestConnectionButton: catch block now distinguishes network/abort
  errors (no server answer → "Could not reach…") from server-answered
  ApiErrors (404/501 → "not available yet", other → status code).
  Uses duck-typed isApiError() guard for test mock compatibility.

Test updates: SecretRow test checks write-only indicator; both
TestConnectionButton test suites updated for new honest error copy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 22:06:29 +00:00
6 changed files with 60 additions and 32 deletions
@@ -102,7 +102,7 @@ describe("TestConnectionButton — state machine", () => {
expect(screen.getByText("Permission denied")).toBeTruthy();
});
it("shows generic error message on unexpected exception", async () => {
it("shows honest network error on unexpected exception", async () => {
vi.mocked(validateSecret).mockRejectedValue(new Error("timeout"));
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
@@ -110,8 +110,9 @@ describe("TestConnectionButton — state machine", () => {
await act(async () => { /* flush */ });
expect(screen.getByRole("alert")).toBeTruthy();
// The error detail is hardcoded to "Connection timed out. Service may be down."
expect(document.body.querySelector('[role="alert"]')?.textContent).toMatch(/timed out/i);
// The new error detail distinguishes network/abort errors (no server answer)
// from server-answered ApiErrors (404 = not available, other = status code).
expect(document.body.querySelector('[role="alert"]')?.textContent).toMatch(/Could not reach/i);
});
});
+19 -23
View File
@@ -3,16 +3,24 @@ import { useState, useCallback, useRef, useEffect } from 'react';
import type { Secret, SecretGroup } from '@/types/secrets';
import { useSecretsStore } from '@/stores/secrets-store';
import { StatusBadge } from '@/components/ui/StatusBadge';
import { RevealToggle } from '@/components/ui/RevealToggle';
import { KeyValueField } from '@/components/ui/KeyValueField';
import { ValidationHint } from '@/components/ui/ValidationHint';
import { TestConnectionButton } from '@/components/ui/TestConnectionButton';
import { validateSecretValue } from '@/lib/validation/secret-formats';
import { SERVICES } from '@/lib/services';
const AUTO_HIDE_MS = 30_000;
const VALIDATION_DEBOUNCE_MS = 400;
// Secret values are write-only from the browser: the server List endpoint
// "Never exposes values", there is no per-secret decrypt route, and the
// only decrypted path (GET /secrets/values) is bulk + token-gated for
// remote agents. The old eye/RevealToggle was a dead affordance — it
// flipped its own icon but could never reveal anything, which read as
// "this doesn't work" (esp. once clicked → eye-with-slash). We show an
// honest static indicator instead; rotation is via Edit.
const WRITE_ONLY_TITLE =
'Value is write-only and cannot be revealed — use Edit to replace/rotate it';
interface SecretRowProps {
secret: Secret;
workspaceId: string;
@@ -31,28 +39,12 @@ export function SecretRow({ secret, workspaceId }: SecretRowProps) {
const setSecretStatus = useSecretsStore((s) => s.setSecretStatus);
const isEditing = editingKey === secret.name;
const [revealed, setRevealed] = useState(false);
const [editValue, setEditValue] = useState('');
const [validationError, setValidationError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const editBtnRef = useRef<HTMLButtonElement>(null);
const revealTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
// Auto-hide revealed value after 30s
useEffect(() => {
if (revealed) {
clearTimeout(revealTimerRef.current);
revealTimerRef.current = setTimeout(() => setRevealed(false), AUTO_HIDE_MS);
return () => clearTimeout(revealTimerRef.current);
}
}, [revealed]);
// Reset revealed state when panel closes (session-only)
useEffect(() => {
return () => setRevealed(false);
}, []);
// Debounced validation
useEffect(() => {
@@ -133,11 +125,15 @@ export function SecretRow({ secret, workspaceId }: SecretRowProps) {
{secret.masked_value}
</span>
<div className="secret-row__actions">
<RevealToggle
revealed={revealed}
onToggle={() => setRevealed((r) => !r)}
label={`Toggle reveal ${secret.name}`}
/>
<span
data-testid="write-only-indicator"
className="secret-row__write-only"
role="img"
aria-label={`${secret.name} value is write-only and cannot be revealed; use Edit to replace it`}
title={WRITE_ONLY_TITLE}
>
🔒
</span>
<StatusBadge status={secret.status} />
<button
type="button"
@@ -138,9 +138,10 @@ describe("SecretRow — display mode", () => {
expect(document.querySelector('[role="row"]')).toBeTruthy();
});
it("has Reveal, Copy, Edit, Delete buttons", () => {
it("has write-only indicator, Copy, Edit, Delete buttons", () => {
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
expect(screen.getByTestId("reveal-toggle")).toBeTruthy();
// Secret values are write-only — no RevealToggle; honest 🔒 indicator instead
expect(screen.getByTestId("write-only-indicator")).toBeTruthy();
expect(screen.getByRole("button", { name: /copy/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /edit/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /delete/i })).toBeTruthy();
@@ -55,9 +55,28 @@ export function TestConnectionButton({
}
onResult?.(result.valid);
resetTimerRef.current = setTimeout(() => setState('idle'), RESET_DELAYS[nextState]!);
} catch {
} catch (err) {
// Distinguish a real failure shape rather than always claiming a
// timeout. A reachable server that answered with an HTTP status
// (ApiError) did NOT time out — most commonly the validation route
// is not available (404/501), which must not masquerade as
// "service down". Only an actual thrown network/abort error is a
// connectivity failure.
setState('failure');
setErrorDetail('Connection timed out. Service may be down.');
// Duck-type ApiError: { status: number } — avoids class-identity issues
// when the module is mocked in tests (instanceof may not cross mock boundary).
const isApiError = (e: unknown): e is { status: number; message: string } =>
typeof e === 'object' && e !== null && 'status' in e;
if (isApiError(err)) {
setErrorDetail(
err.status === 404 || err.status === 501
? 'Key validation is not available for this service yet. The key was not tested.'
: `Could not verify key (server returned ${err.status}). Saving is unaffected.`,
);
} else {
setErrorDetail('Could not reach the validation service. Check your connection and try again.');
}
onResult?.(false);
resetTimerRef.current = setTimeout(() => setState('idle'), RESET_DELAYS.failure);
}
@@ -201,7 +201,7 @@ describe("TestConnectionButton — failure path", () => {
});
describe("TestConnectionButton — catch path", () => {
it("shows 'Connection timed out' on network error", async () => {
it("shows honest network error on network error", async () => {
mockValidateSecret.mockRejectedValue(new Error("timeout"));
render(
<TestConnectionButton provider="github" secretValue="ghp_xxx" />,
@@ -210,7 +210,9 @@ describe("TestConnectionButton — catch path", () => {
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(document.body.textContent).toContain("Connection timed out");
// The new error detail distinguishes network/abort errors (no server answer)
// from server-answered ApiErrors (404 = not available, other = status code).
expect(document.body.textContent).toContain("Could not reach");
});
it("calls onResult(false) on network error", async () => {
+9
View File
@@ -239,6 +239,15 @@
.secret-row__action-btn:focus-visible { outline: var(--focus-ring); outline-offset: var(--focus-ring-offset); }
.secret-row__action-btn--delete:hover { color: var(--status-invalid); }
.secret-row__write-only {
display: inline-flex;
align-items: center;
padding: 4px;
font-size: 14px;
color: #a1a1aa; /* zinc-400 */
user-select: none;
}
/* Secret row — edit form */
.secret-row__edit-form {