diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 2d8d6b8..520b4e0 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "5c8258c4cc7635b3e73fb86eccc8b839c78f236753653e43c2ddc0b78e549ad0", + "compatibility_surface_sha256": "b00abcb20bee72d2b127ea0a37a3d2210f8382ab0c589ec2abe5a4d8b595379c", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 3420e28..7ef3081 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -79,7 +79,7 @@ }, { "path": "scripts/tally-company-selection.test.mjs", - "sha256": "06dd510f72ab2365c64a5a14b79b482633abee6b09c1ed0066f2d4ce98012c42" + "sha256": "16bf5a1458afffb614b2e71e5147eb393088ca30f7c4f6eae19f82346ffc8f00" }, { "path": "src-tauri/Cargo.lock", @@ -419,16 +419,16 @@ }, { "path": "src/main.tsx", - "sha256": "93968d5670001e371da85246e1c25ccc7afbac3a75b5118a5e9972f577dbf70d" + "sha256": "919190e826f06940fbe2640c8e453f2e04de1262a6ad3e41f28430b8467491c2" }, { "path": "src/styles.css", - "sha256": "2a1123727cd527e54190236719787dbdc1420c5099712011290eab9e40590ccd" + "sha256": "3d931bf809e7453031e2b5433f2fcee331879353662018876bb89de382a6a6f9" }, { "path": "src/tally-company-selection.ts", - "sha256": "5a5c6eaaba234c3cbda52dfa040ed3314f79e535f744b87bc14d8d76a2299811" + "sha256": "dd033010711ca01a7a395a531285c6ef187a359640a156cbc8da7281e11a39ea" } ], - "manifest_sha256": "5c8258c4cc7635b3e73fb86eccc8b839c78f236753653e43c2ddc0b78e549ad0" + "manifest_sha256": "b00abcb20bee72d2b127ea0a37a3d2210f8382ab0c589ec2abe5a4d8b595379c" } diff --git a/scripts/tally-company-selection.test.mjs b/scripts/tally-company-selection.test.mjs index 2254abe..da526b9 100644 --- a/scripts/tally-company-selection.test.mjs +++ b/scripts/tally-company-selection.test.mjs @@ -5,6 +5,7 @@ import test from "node:test"; import { applyProbeCompanySelectionTransition, + companyDiscoveryPrompt, clearCompanyScopedState, reconcileProbeCompanySelection, } from "../src/tally-company-selection.ts"; @@ -117,6 +118,20 @@ test("an automatic probe drop clears old company state before installing a usabl ); }); +test("discovery requested after a dropped selection remains current after cleanup", () => { + const state = companyScopedState(); + const probeResultsVersion = state.tallyResultsVersion; + + applyProbeCompanySelectionTransition("old-company", [], { + clearDroppedCompanyScope: () => clearCompanyScopedState(cleanupFor(state)), + installProbeState: () => {}, + }); + const discoveryResultsVersion = state.tallyResultsVersion; + + assert.notEqual(probeResultsVersion, state.tallyResultsVersion); + assert.equal(discoveryResultsVersion, state.tallyResultsVersion); +}); + test("a manual company selection clears the existing review and all company-scoped state", () => { const state = companyScopedState(); @@ -163,3 +178,30 @@ test("a selected company retained by the probe is not reported as dropped", () = { selectedCompany: "", dropped: false }, ); }); + +test("a successful probe prompts the operator to explicitly choose a discovered company", () => { + assert.deepEqual(companyDiscoveryPrompt("", ["company-a"]), { + companyCount: 1, + heading: "1 company discovered", + detail: "Bridge identified the current Tally company list. Choose one explicitly before reading or saving any company-scoped data.", + actionLabel: "Choose company", + }); + assert.deepEqual(companyDiscoveryPrompt("", ["company-a", "company-b"]), { + companyCount: 2, + heading: "2 companies discovered", + detail: "Bridge identified the current Tally company list. Choose one explicitly before reading or saving any company-scoped data.", + actionLabel: "Choose company", + }); + assert.equal(companyDiscoveryPrompt("company-a", ["company-a"]), null); + assert.equal(companyDiscoveryPrompt("", []), null); +}); + +test("a direct compatibility listing prompts verification instead of selecting an untrusted identity", () => { + assert.deepEqual(companyDiscoveryPrompt("", [], 1), { + companyCount: 1, + heading: "1 company listed for verification", + detail: "Tally returned a compatibility company listing. Verify the intended company before Bridge treats its identity as evidence or enables company-scoped reads.", + actionLabel: "Verify company", + }); + assert.equal(companyDiscoveryPrompt("selected-company", [], 1), null); +}); diff --git a/src/main.tsx b/src/main.tsx index fa033c2..4938da1 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,7 +2,11 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { Activity, Building2, Cable, CircleHelp, Cloud, Database, FileText, FolderOpen, KeyRound, Play, RefreshCw, ShieldCheck, UploadCloud } from "lucide-react"; import { invoke } from "@tauri-apps/api/core"; -import { applyProbeCompanySelectionTransition, clearCompanyScopedState } from "./tally-company-selection"; +import { + applyProbeCompanySelectionTransition, + clearCompanyScopedState, + companyDiscoveryPrompt, +} from "./tally-company-selection"; import "./styles.css"; type TallyConfig = { @@ -977,6 +981,23 @@ function App() { ); setSelectedCompany(selection.selectedCompany); void refreshPersistedCompanyProfiles(); + setUntrustedDiscoveredCompanies([]); + setUntrustedDiscoveryError(null); + setUntrustedDiscoveryCompleted(false); + if (result.profile.transports.xml_http?.safe_reason_code === "direct_company_report_untrusted") { + const discoveryResultsVersion = tallyResultsVersion.current; + try { + const discovered = await invoke("fetch_tally_companies", { config }); + if (discoveryResultsVersion === tallyResultsVersion.current) { + setUntrustedDiscoveredCompanies(discovered); + setUntrustedDiscoveryCompleted(true); + } + } catch (error) { + if (discoveryResultsVersion === tallyResultsVersion.current) { + setUntrustedDiscoveryError(toOperatorError(error)); + } + } + } } } catch (error) { if (resultsVersion === tallyResultsVersion.current) { @@ -1846,6 +1867,11 @@ function App() { const gstDraftComplete = draft !== null && draft.missing_fields.length === 0; const selectedCompanyRecord = companies.find((company) => tallyCompanyKey(company) === selectedCompany); const selectedCompanyLive = !!selectedCompanyRecord && liveCompanyKeys.includes(tallyCompanyKey(selectedCompanyRecord)); + const discoveredCompanyPrompt = companyDiscoveryPrompt( + selectedCompany, + liveCompanyKeys, + untrustedDiscoveredCompanies.length, + ); React.useEffect(() => { const mirrorCompanyId = selectedCompanyRecord?.mirror_company_id; setFixtureStatus(null); @@ -1995,6 +2021,18 @@ function App() { + {discoveredCompanyPrompt && ( +
+
+ {discoveredCompanyPrompt.heading} + {discoveredCompanyPrompt.detail} +
+ +
+ )} + {["dashboard", "companies", "mirror"].includes(view) && (
Verified baseline{verifiedBaseline}
diff --git a/src/styles.css b/src/styles.css index 7bdad60..6f3fa42 100644 --- a/src/styles.css +++ b/src/styles.css @@ -592,6 +592,34 @@ h2 { overflow-wrap: anywhere; } +.company-discovery-notice { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + border: 1px solid #9bc9b6; + border-left: 4px solid #0f766e; + border-radius: 8px; + background: #f0fdf7; + color: #183b2e; + margin: 0 0 16px; + padding: 14px 16px; +} + +.company-discovery-notice div { + display: grid; + gap: 4px; +} + +.company-discovery-notice span { + color: #416257; + line-height: 1.45; +} + +.company-discovery-notice .primary { + flex: 0 0 auto; +} + .operator-question-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -1079,10 +1107,16 @@ summary:focus-visible, } .company-context-bar, + .company-discovery-notice, .company-profile-grid, .gap-item { grid-template-columns: 1fr; } + + .company-discovery-notice { + align-items: stretch; + flex-direction: column; + } } @media (max-width: 520px) { diff --git a/src/tally-company-selection.ts b/src/tally-company-selection.ts index 709226f..dde02ed 100644 --- a/src/tally-company-selection.ts +++ b/src/tally-company-selection.ts @@ -16,6 +16,13 @@ export type ProbeSelectionTransition = { dropped: boolean; }; +export type CompanyDiscoveryPrompt = { + companyCount: number; + heading: string; + detail: string; + actionLabel: string; +}; + export type ProbeSelectionEffects = { clearDroppedCompanyScope: () => void; installProbeState: () => void; @@ -43,6 +50,36 @@ export function applyProbeCompanySelectionTransition( return transition; } +export function companyDiscoveryPrompt( + selectedCompany: string, + liveCompanyKeys: readonly string[], + untrustedCompanyCount = 0, +): CompanyDiscoveryPrompt | null { + if (selectedCompany !== "") return null; + + if (liveCompanyKeys.length > 0) { + const companyCount = liveCompanyKeys.length; + const companyLabel = companyCount === 1 ? "company" : "companies"; + return { + companyCount, + heading: `${companyCount} ${companyLabel} discovered`, + detail: "Bridge identified the current Tally company list. Choose one explicitly before reading or saving any company-scoped data.", + actionLabel: "Choose company", + }; + } + + if (untrustedCompanyCount === 0) return null; + + const companyCount = untrustedCompanyCount; + const companyLabel = companyCount === 1 ? "company" : "companies"; + return { + companyCount, + heading: `${companyCount} ${companyLabel} listed for verification`, + detail: "Tally returned a compatibility company listing. Verify the intended company before Bridge treats its identity as evidence or enables company-scoped reads.", + actionLabel: "Verify company", + }; +} + export function clearCompanyScopedState(cleanup: CompanyScopeCleanup) { cleanup.clearQualifiedReadReview(); cleanup.clearPassportSnapshot();