diff --git a/frontend-modern/browser-verification/42faa8f482b1cf9c.json b/frontend-modern/browser-verification/42faa8f482b1cf9c.json
new file mode 100644
index 0000000000..5715ddb684
--- /dev/null
+++ b/frontend-modern/browser-verification/42faa8f482b1cf9c.json
@@ -0,0 +1,43 @@
+{
+ "version": 1,
+ "result": "passed",
+ "base_sha": "ac45a69738d06d526b08b96d6c47b51431739088",
+ "content_sha256": {
+ "frontend-modern/src/utils/connectionErrorPresentation.ts": "1f6bcf499896a091958c8fd840f26f94f335aa58200631db1b76c1ec86ab8584"
+ },
+ "changed_paths": [
+ "frontend-modern/src/utils/connectionErrorPresentation.ts"
+ ],
+ "routes": [
+ "Existing Settings / Infrastructure connected-systems ledger and API platform probe, plus saved Proxmox connection editor; production consumers in an offline app-runtime fixture"
+ ],
+ "states": [
+ "Untrusted TLS certificate and changed fingerprint with independently checked identity guidance",
+ "403 denial with misleading upstream authentication text preserves intended resource limits",
+ "401 rejected authentication makes replacement conditional on confirmed invalidity",
+ "595 server failure with quoted 401/403 is not presented as invalid credentials",
+ "Ordinary synthetic recovery clears old errors; saved SSL verification, fingerprint and collection opt-ins preserved"
+ ],
+ "interactions": [
+ "Desktop keyboard Tab/Enter and phone touch submit the existing address-only probe, with no credential route selected on failure",
+ "Both production consuming surfaces show the full safe hint, with wrapped text and no horizontal overflow",
+ "Same-address next probe and ledger reload clear stale guidance without any settings write",
+ "Manage opens the saved Proxmox form: Verify SSL remains checked, pin unchanged, VMs on and containers off; dirty name survives ledger reload"
+ ],
+ "viewports": [
+ {
+ "width": 1280,
+ "height": 1000
+ },
+ {
+ "width": 390,
+ "height": 1000
+ }
+ ],
+ "verified_at": "2026-10-14T17:30:38Z",
+ "engines": [
+ "Playwright 1.56.1 / chromium 141.0.7390.37",
+ "Playwright 1.56.1 / webkit 26.0"
+ ],
+ "scope": "Ten HTTP-fixture consumer journeys; all sixteen final captures opened natively. Exact runtime hashes match this source. Not native/installed trust recovery, permissions acceptance, full authentication qualification or release availability."
+}
diff --git a/frontend-modern/src/components/Settings/ConnectionEditor/__tests__/ConnectionEditor.test.tsx b/frontend-modern/src/components/Settings/ConnectionEditor/__tests__/ConnectionEditor.test.tsx
index 358c89d3ed..a31cb040e7 100644
--- a/frontend-modern/src/components/Settings/ConnectionEditor/__tests__/ConnectionEditor.test.tsx
+++ b/frontend-modern/src/components/Settings/ConnectionEditor/__tests__/ConnectionEditor.test.tsx
@@ -22,6 +22,45 @@ describe('ConnectionEditor', () => {
vi.clearAllMocks();
});
+ it.each([
+ [
+ 'certificate fingerprint mismatch: expected fixture-old, got fixture-new',
+ 'TLS fingerprint changed.',
+ 'Keep TLS verification enabled.',
+ ],
+ [
+ 'API error 403: authentication failed',
+ 'Permission denied.',
+ 'Keep intentional access limits',
+ ],
+ ])(
+ 'renders safe probe guidance and clears it on ordinary recovery: %s',
+ async (raw, headline, hint) => {
+ mockedProbe.mockRejectedValueOnce(new Error(raw));
+ mockedProbe.mockResolvedValueOnce({ candidates: [], probedMs: 10 });
+ const renderSlot = vi.fn(() =>
);
+ render(() => {}} />);
+ const input = screen.getByLabelText('API endpoint') as HTMLInputElement;
+ fireEvent.input(input, { target: { value: 'https://pve.lab:8006' } });
+ fireEvent.click(screen.getByRole('button', { name: /probe api endpoint/i }));
+ const alert = await screen.findByRole('alert');
+ expect(alert.textContent).toContain(headline);
+ expect(alert.textContent).toContain(hint);
+ expect(alert.textContent).not.toContain('credentials connected');
+ expect(alert.textContent).not.toContain('disable TLS');
+ expect(input.value).toBe('https://pve.lab:8006');
+ expect(mockedProbe).toHaveBeenCalledTimes(1);
+ expect(renderSlot).not.toHaveBeenCalled();
+
+ fireEvent.click(screen.getByRole('button', { name: /probe api endpoint/i }));
+ await screen.findByText('No supported API-backed platform detected at that address.');
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ expect(mockedProbe).toHaveBeenCalledTimes(2);
+ expect(input.value).toBe('https://pve.lab:8006');
+ expect(renderSlot).not.toHaveBeenCalled();
+ },
+ );
+
it('runs a probe and dispatches the detected type into the credential slot', async () => {
const response: ProbeResponse = {
candidates: [
diff --git a/frontend-modern/src/components/Settings/__tests__/useConnectionsLedger.branchcov0722am.test.ts b/frontend-modern/src/components/Settings/__tests__/useConnectionsLedger.branchcov0722am.test.ts
index c1cef7d215..7f21cc8661 100644
--- a/frontend-modern/src/components/Settings/__tests__/useConnectionsLedger.branchcov0722am.test.ts
+++ b/frontend-modern/src/components/Settings/__tests__/useConnectionsLedger.branchcov0722am.test.ts
@@ -279,6 +279,34 @@ describe('connectionToRow (standalone buildRow arms)', () => {
const row = connectionToRow(connectionFixture({ lastError: null }));
expect(row.lastErrorMessage).toBeUndefined();
});
+
+ it.each([
+ [
+ 'certificate fingerprint mismatch: expected fixture-old, got fixture-new',
+ 'TLS fingerprint changed.',
+ 'Independently verify its identity',
+ ],
+ [
+ 'API error 403: authentication failed',
+ 'Permission denied.',
+ 'Keep intentional access limits',
+ ],
+ ])('presents safe ledger guidance without mutating the connection: %s', (raw, headline, hint) => {
+ const connection = connectionFixture({
+ state: 'unauthorized',
+ scope: { vms: true, containers: false },
+ lastError: { message: raw, at: '2026-07-22T12:00:00Z' },
+ });
+ const original = JSON.stringify(connection);
+ const row = connectionToRow(connection);
+ expect(row.lastErrorMessage).toContain(headline);
+ expect(row.lastErrorMessage).toContain(hint);
+ expect(row.lastErrorMessage).not.toContain('disable TLS');
+ expect(row.lastErrorMessage).not.toContain('credentials connected');
+ expect(row.statusLabel).toBe('Unauthorized');
+ expect(JSON.stringify(connection)).toBe(original);
+ expect(row.connection).toBe(connection);
+ });
});
// ---- useConnectionsLedger (systemToRow & row-derivation arms) ---------------
diff --git a/frontend-modern/src/utils/__tests__/connectionErrorPresentation.test.ts b/frontend-modern/src/utils/__tests__/connectionErrorPresentation.test.ts
index 54dd56ea45..8cf808ac88 100644
--- a/frontend-modern/src/utils/__tests__/connectionErrorPresentation.test.ts
+++ b/frontend-modern/src/utils/__tests__/connectionErrorPresentation.test.ts
@@ -56,6 +56,90 @@ describe('humanizeConnectionError', () => {
expect(result?.headline).toBe('Permission denied');
});
+ describe('trust-preserving recovery guidance', () => {
+ it.each([
+ ['x509: certificate signed by unknown authority', 'TLS certificate not trusted'],
+ ['x509: certificate has expired or is not yet valid', 'TLS certificate expired'],
+ ['remote error: tls: handshake failure', 'TLS handshake failed'],
+ [
+ 'certificate fingerprint mismatch: expected fixture-old, got fixture-new',
+ 'TLS fingerprint changed',
+ ],
+ ['truenas tls pinning failed: fingerprint mismatch', 'TLS fingerprint changed'],
+ [
+ 'TLS fingerprint changed - independently verify the certificate before updating the saved fingerprint',
+ 'TLS fingerprint changed',
+ ],
+ ['x509: certificate is valid for old.lab, not new.lab', 'TLS certificate validation failed'],
+ [
+ 'TLS certificate error - check SSL settings or add fingerprint',
+ 'TLS certificate validation failed',
+ ],
+ ['TLS certificate validation failed', 'TLS certificate validation failed'],
+ ])('keeps verification and independently checked identity for %s', (raw, headline) => {
+ const result = humanizeConnectionError(raw);
+ expect(result?.headline).toBe(headline);
+ expect(result?.hint).toContain('Keep TLS verification enabled.');
+ expect(result?.hint).toContain('hostname, expiry and trusted chain');
+ expect(result?.hint).toContain(
+ 'Independently verify its identity before adding or replacing',
+ );
+ expect(result?.hint).not.toMatch(/disable|temporarily|switch.*HTTP/i);
+ });
+
+ it.each([
+ 'API error 403: Forbidden',
+ 'HTTP status 403: authentication failed; quoted upstream 401',
+ 'authentication error: API error 403: invalid credentials',
+ 'permission denied',
+ ])('does not infer a successful login or prescribe broader access for %s', (raw) => {
+ const result = humanizeConnectionError(raw);
+ expect(result?.headline).toBe('Permission denied');
+ expect(result?.hint).toContain('intended resource');
+ expect(result?.hint).toContain('any proxy policy');
+ expect(result?.hint).toContain('Keep intentional access limits');
+ expect(result?.hint).toContain('do not replace credentials or broaden access just to test');
+ expect(result?.hint).not.toMatch(/credentials connected|logged in|Administrator/);
+ });
+
+ it.each(['401 Unauthorized', 'authentication failed', 'invalid API token'])(
+ 'makes credential replacement conditional for %s',
+ (raw) => {
+ const result = humanizeConnectionError(raw);
+ expect(result?.headline).toBe('Authentication failed');
+ expect(result?.hint).toContain('user, realm and token ID');
+ expect(result?.hint).toContain('Replace it only if invalidity is confirmed');
+ },
+ );
+
+ it.each([500, 502, 595])(
+ 'does not misclassify HTTP %i provider auth text as a 401',
+ (status) => {
+ const result = humanizeConnectionError(
+ `API error ${status}: Authentication failed; provider mentioned 401 and 403`,
+ );
+ expect(result?.headline).toBe('Server error');
+ expect(result?.hint).not.toMatch(/token|credential/);
+ },
+ );
+
+ it('ignores status-shaped numbers in the endpoint and poll identity', () => {
+ expect(
+ humanizeConnectionError(
+ 'poll_nodes failed on 401: Get "https://pve.lab:403/api/500": something novel',
+ ),
+ ).toEqual({ headline: 'something novel', hint: null });
+ });
+
+ it.each([400, 429])(
+ 'keeps unclassified HTTP %i responses instead of inferring auth failure',
+ (status) => {
+ const raw = `API error ${status}: provider mentioned 401 Unauthorized`;
+ expect(humanizeConnectionError(raw)).toEqual({ headline: raw, hint: null });
+ },
+ );
+ });
+
it('classifies access violations as blocked connections', () => {
const result = humanizeConnectionError(
'poll_nodes failed on delly: Get "https://delly:8006/api2/json/nodes": Access violation',
diff --git a/frontend-modern/src/utils/connectionErrorPresentation.ts b/frontend-modern/src/utils/connectionErrorPresentation.ts
index 23d9424e57..60ebd918f1 100644
--- a/frontend-modern/src/utils/connectionErrorPresentation.ts
+++ b/frontend-modern/src/utils/connectionErrorPresentation.ts
@@ -28,8 +28,29 @@ interface HumanizedConnectionError {
const TIMEOUT_HINT =
'Check the host is reachable, the port is correct, and the network path is open.';
const TLS_HINT =
- 'Verify the certificate or pin a fingerprint in TLS settings, or disable TLS verification temporarily for testing.';
-const AUTH_HINT = 'Re-check the API token or username/password.';
+ "Keep TLS verification enabled. Check the certificate's hostname, expiry and trusted chain. Independently verify its identity before adding or replacing a saved fingerprint.";
+const AUTH_HINT =
+ 'Check the configured user, realm and token ID, and whether the credential has expired or been revoked. Replace it only if invalidity is confirmed.';
+const DENIED_HINT =
+ 'Check read permissions for the intended resource and any proxy policy. Keep intentional access limits; do not replace credentials or broaden access just to test.';
+
+const HTTP_STATUS_PRESENTATIONS: Record = {
+ 401: { headline: 'Authentication failed', hint: AUTH_HINT },
+ 403: { headline: 'Permission denied', hint: DENIED_HINT },
+ 404: {
+ headline: 'Endpoint not found',
+ hint: 'Pulse reached the host but the API path is missing. Check the URL path or the platform version.',
+ },
+};
+const SERVER_ERROR: HumanizedConnectionError = {
+ headline: 'Server error',
+ hint: 'The platform returned a server error. Check the platform logs for the underlying cause.',
+};
+
+// Use the first explicit response status, not a number in an endpoint or a
+// quoted provider explanation. A 403/5xx mentioning authentication is not a 401.
+const HTTP_STATUS_PATTERN =
+ /\b(?:API error|HTTP(?: error| status(?: code)?)?|status(?: code)?)[\s:]+([45]\d{2})\b|^([45]\d{2})\b/i;
const HUMANIZED_PATTERNS: { match: RegExp; headline: string; hint: string | null }[] = [
{
@@ -52,6 +73,11 @@ const HUMANIZED_PATTERNS: { match: RegExp; headline: string; hint: string | null
headline: 'No network route to host',
hint: 'The host cannot be reached from this Pulse instance. Check VPN, firewall, or VLAN configuration.',
},
+ {
+ match: /(?:certificate |tls (?:pinning failed: )?)?fingerprint (?:mismatch|changed)/i,
+ headline: 'TLS fingerprint changed',
+ hint: TLS_HINT,
+ },
{
match: /x509:.*signed by unknown authority|certificate signed by unknown/i,
headline: 'TLS certificate not trusted',
@@ -68,9 +94,9 @@ const HUMANIZED_PATTERNS: { match: RegExp; headline: string; hint: string | null
hint: TLS_HINT,
},
{
- match: /\b401\b|unauthorized|authentication failed|invalid (?:credentials|api token)/i,
- headline: 'Authentication failed',
- hint: AUTH_HINT,
+ match: /x509:|TLS certificate (?:error|validation failed)/i,
+ headline: 'TLS certificate validation failed',
+ hint: TLS_HINT,
},
{
match: /access violation/i,
@@ -78,19 +104,22 @@ const HUMANIZED_PATTERNS: { match: RegExp; headline: string; hint: string | null
hint: 'The request was blocked before Pulse could read inventory. Check proxy, firewall, or network policy settings.',
},
{
- match: /\b403\b|forbidden|permission denied/i,
+ match: /forbidden|permission denied/i,
headline: 'Permission denied',
- hint: 'The credentials connected, but the user/token lacks the required role.',
+ hint: DENIED_HINT,
},
{
- match: /\b404\b|not found/i,
- headline: 'Endpoint not found',
- hint: 'Pulse reached the host but the API path is missing. Check the URL path or the platform version.',
+ match: /unauthorized|authentication failed|invalid (?:credentials|api token)/i,
+ headline: 'Authentication failed',
+ hint: AUTH_HINT,
},
{
- match: /\b5\d{2}\b|server error|internal error/i,
- headline: 'Server error',
- hint: 'The platform returned a server error. Check the platform logs for the underlying cause.',
+ match: /not found/i,
+ ...HTTP_STATUS_PRESENTATIONS[404],
+ },
+ {
+ match: /server error|internal error/i,
+ ...SERVER_ERROR,
},
];
@@ -102,15 +131,23 @@ export const humanizeConnectionError = (raw?: string | null): HumanizedConnectio
const trimmed = raw.trim();
if (!trimmed) return null;
+ const cleaned = stripImplementationPrefixes(trimmed);
+ const statusMatch = cleaned.match(HTTP_STATUS_PATTERN);
+ if (statusMatch) {
+ const status = Number(statusMatch[1] ?? statusMatch[2]);
+ const presentation = HTTP_STATUS_PRESENTATIONS[status] ?? (status >= 500 ? SERVER_ERROR : null);
+ if (presentation) return { ...presentation };
+ return { headline: cleaned, hint: null };
+ }
+
for (const pattern of HUMANIZED_PATTERNS) {
- if (pattern.match.test(trimmed)) {
+ if (pattern.match.test(cleaned)) {
return { headline: pattern.headline, hint: pattern.hint };
}
}
// Unknown error: fall back to the raw text with the most common
// implementation-leaking prefixes peeled off so it's at least readable.
- const cleaned = stripImplementationPrefixes(trimmed);
return { headline: cleaned || 'Connection error', hint: null };
};