diff --git a/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx b/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx index e2edbbb3..85b97c59 100644 --- a/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx @@ -3,9 +3,9 @@ import { createContext, useCallback, useContext, useRef, useState } from "react"; import { useAuth } from "../../contexts/AuthContext"; import { api } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; -import type { AdminConfiguration, OpsAlertRule } from "@/types/adminConfig"; -import type { GetPreviewWhitelistResponse } from "@/utils/wire-types.generated"; +import { apiErrorText, fetchJson } from "@/lib/apiClient"; +import { fromWireAdminConfiguration, type AdminConfiguration, type OpsAlertRule } from "@/types/adminConfig"; +import type { AdminConfiguration as WireAdminConfiguration, GetAllPreviewNotificationEmailsResponse, GetPreviewWhitelistResponse, TestWebhookNotificationResponse, UpdateAdminConfigurationResponse } from "@/utils/wire-types.generated"; import type { NotificationChannel } from "@/app/settings/types"; // Re-export so existing `import { AdminConfiguration } from "../AdminConfigContext"` consumers keep working @@ -217,13 +217,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) setLoadingConfig(true); setError(null); - const response = await authenticatedFetch(api.globalConfig.get(), getAccessToken); - - if (!response.ok) { - throw new Error(`Failed to load admin configuration: ${response.statusText}`); - } - - const data: AdminConfiguration = await response.json(); + const data = fromWireAdminConfiguration(await fetchJson(api.globalConfig.get(), getAccessToken)); setAdminConfig(data); setGlobalRateLimit(data.globalRateLimitRequestsPerMinute); setUserRateLimit(data.userRateLimitRequestsPerMinute ?? 120); @@ -268,16 +262,12 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) setOpsAlertSlackEnabled(data.opsAlertSlackEnabled ?? false); setOpsAlertSlackWebhookUrl(data.opsAlertSlackWebhookUrl ?? ""); setExcessiveEventCountThreshold(data.excessiveEventCountThreshold ?? 2000); - setExcessiveEventAutoActionMode((data.excessiveEventAutoActionMode ?? "Off") as "Off" | "Block" | "Kill"); + setExcessiveEventAutoActionMode(data.excessiveEventAutoActionMode ?? "Off"); setExcessiveEventAutoActionThreshold(data.excessiveEventAutoActionThreshold ?? 2500); setExcessiveEventAutoActionDurationHours(data.excessiveEventAutoActionDurationHours ?? 24); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while fetching admin configuration"); - } else { - console.error("Error fetching admin configuration:", err); - } - setError(err instanceof Error ? err.message : "Failed to load admin configuration"); + console.error("Error fetching admin configuration:", err); + setError(apiErrorText(err, "Failed to load admin configuration")); } finally { setLoadingConfig(false); } @@ -303,38 +293,23 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) // The address map is best-effort: it only powers the search-by-address, so a failure // there must degrade that one capability, never take the tenant list down with it. - const [tenantsRes, previewRes, emailsRes] = await Promise.all([ - authenticatedFetch(api.config.all(), getAccessToken), - authenticatedFetch(api.preview.whitelist(), getAccessToken), - authenticatedFetch(api.preview.notificationEmails(), getAccessToken).catch(() => null) + // config/all is a bare array of tenant configurations (deliberately untyped, D-043). + const [data, previewData, emailData] = await Promise.all([ + fetchJson(api.config.all(), getAccessToken), + fetchJson(api.preview.whitelist(), getAccessToken).catch(() => null), + fetchJson(api.preview.notificationEmails(), getAccessToken).catch(() => null), ]); - - if (!tenantsRes.ok) { - throw new Error(`Failed to load tenants: ${tenantsRes.statusText}`); - } - - const data: TenantConfiguration[] = await tenantsRes.json(); setTenants(data); - if (previewRes.ok) { - const previewData = (await previewRes.json()) as GetPreviewWhitelistResponse; - const approvedIds = new Set( - (previewData.tenants || []).map((t) => t.tenantId) - ); - setPreviewApproved(approvedIds); + if (previewData) { + setPreviewApproved(new Set((previewData.tenants || []).map((t) => t.tenantId))); } - - if (emailsRes?.ok) { - const emailData = await emailsRes.json().catch(() => ({})); + if (emailData) { setNotificationEmails(emailData.emails ?? {}); } } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while fetching tenants"); - } else { - console.error("Error fetching tenants:", err); - } - setError(err instanceof Error ? err.message : "Failed to load tenants"); + console.error("Error fetching tenants:", err); + setError(apiErrorText(err, "Failed to load tenants")); } finally { setLoadingTenants(false); } @@ -378,27 +353,16 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) maxImeMsiDownloadSizeMB, }; - const response = await authenticatedFetch(api.globalConfig.get(), getAccessToken, { + const result = await fetchJson(api.globalConfig.get(), getAccessToken, { method: "PUT", - headers: { "Content-Type": "application/json" }, body: JSON.stringify(updatedConfig), }); - - if (!response.ok) { - throw new Error(`Failed to save admin configuration: ${response.statusText}`); - } - - const result = await response.json(); - setAdminConfig(result.config); + setAdminConfig(fromWireAdminConfiguration(result.config)); setSuccessMessage("Admin configuration saved successfully!"); setTimeout(() => setSuccessMessage(null), 3000); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while saving admin configuration"); - } else { - console.error("Error saving admin configuration:", err); - } - setError(err instanceof Error ? err.message : "Failed to save admin configuration"); + console.error("Error saving admin configuration:", err); + setError(apiErrorText(err, "Failed to save admin configuration")); } finally { setSavingConfig(false); } @@ -447,7 +411,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) setOpsAlertSlackEnabled(adminConfig.opsAlertSlackEnabled ?? false); setOpsAlertSlackWebhookUrl(adminConfig.opsAlertSlackWebhookUrl ?? ""); setExcessiveEventCountThreshold(adminConfig.excessiveEventCountThreshold ?? 2000); - setExcessiveEventAutoActionMode((adminConfig.excessiveEventAutoActionMode ?? "Off") as "Off" | "Block" | "Kill"); + setExcessiveEventAutoActionMode(adminConfig.excessiveEventAutoActionMode ?? "Off"); setExcessiveEventAutoActionThreshold(adminConfig.excessiveEventAutoActionThreshold ?? 2500); setExcessiveEventAutoActionDurationHours(adminConfig.excessiveEventAutoActionDurationHours ?? 24); setSuccessMessage(null); @@ -468,24 +432,16 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) diagnosticsGlobalLogPathsJson: JSON.stringify(paths), }; - const response = await authenticatedFetch(api.globalConfig.get(), getAccessToken, { + const result = await fetchJson(api.globalConfig.get(), getAccessToken, { method: "PUT", - headers: { "Content-Type": "application/json" }, body: JSON.stringify(updatedConfig), }); - - if (!response.ok) throw new Error(`Failed to save diagnostics paths: ${response.statusText}`); - - const result = await response.json(); - setAdminConfig(result.config); + setAdminConfig(fromWireAdminConfiguration(result.config)); setGlobalDiagPaths(paths); setSuccessMessage("Global diagnostics log paths saved successfully!"); setTimeout(() => setSuccessMessage(null), 4000); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while saving diagnostics paths"); - } - setError(err instanceof Error ? err.message : "Failed to save diagnostics paths"); + setError(apiErrorText(err, "Failed to save diagnostics paths")); } finally { setSavingDiagPaths(false); } @@ -520,16 +476,11 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) excessiveEventAutoActionDurationHours: newAutoActionDurationHours, }; - const response = await authenticatedFetch(api.globalConfig.get(), getAccessToken, { + const result = await fetchJson(api.globalConfig.get(), getAccessToken, { method: "PUT", - headers: { "Content-Type": "application/json" }, body: JSON.stringify(updatedConfig), }); - - if (!response.ok) throw new Error(`Failed to save alert configuration: ${response.statusText}`); - - const result = await response.json(); - setAdminConfig(result.config); + setAdminConfig(fromWireAdminConfiguration(result.config)); setOpsAlertRules(rules); setOpsNotificationChannels(channels); setExcessiveEventCountThreshold(newExcessiveThreshold); @@ -539,10 +490,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) setSuccessMessage("Alert configuration saved successfully!"); setTimeout(() => setSuccessMessage(null), 4000); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while saving alert configuration"); - } - setError(err instanceof Error ? err.message : "Failed to save alert configuration"); + setError(apiErrorText(err, "Failed to save alert configuration")); } finally { setSavingOpsAlerts(false); } @@ -559,28 +507,20 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) setTestingOpsChannelId(channelId); setTestOpsChannelResult(null); - const response = await authenticatedFetch(api.globalConfig.testOpsChannel(), getAccessToken, { + const result = await fetchJson(api.globalConfig.testOpsChannel(), getAccessToken, { method: "POST", - headers: { "Content-Type": "application/json" }, body: JSON.stringify({ channelId }), }); - - if (!response.ok) throw new Error(`Test failed: ${response.statusText}`); - - const result = await response.json(); setTestOpsChannelResult({ channelId, success: Boolean(result.success), message: result.message ?? (result.success ? "Sent." : "Failed."), }); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while testing an ops channel"); - } setTestOpsChannelResult({ channelId, success: false, - message: err instanceof Error ? err.message : "Test failed", + message: apiErrorText(err, "Test failed"), }); } finally { setTestingOpsChannelId(null); diff --git a/src/Web/autopilot-monitor-web/app/admin/backups/components/RestoreRowDiffModal.tsx b/src/Web/autopilot-monitor-web/app/admin/backups/components/RestoreRowDiffModal.tsx index a7bcd734..38a8911b 100644 --- a/src/Web/autopilot-monitor-web/app/admin/backups/components/RestoreRowDiffModal.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/backups/components/RestoreRowDiffModal.tsx @@ -9,7 +9,7 @@ import { type RestoreRowPropertySnapshot, type RestoreRowRequestBody, } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { apiErrorText, fetchJson } from "@/lib/apiClient"; interface RestoreRowDiffModalProps { backupId: string; @@ -48,20 +48,13 @@ export function RestoreRowDiffModal({ ifSha256: preview.rowSha256, ifCurrentETag: preview.currentETag ?? null, }; - const res = await authenticatedFetch(api.backups.restoreRow(backupId), getAccessToken, { + const response = await fetchJson(api.backups.restoreRow(backupId), getAccessToken, { method: "POST", body: JSON.stringify(body), - headers: { "Content-Type": "application/json" }, }); - if (!res.ok) { - const text = await res.text(); - throw new Error(`Restore failed: ${res.status} ${text}`); - } - const response = (await res.json()) as RestoreRowCommitResponse; onCommitted(response); } catch (err) { - if (err instanceof TokenExpiredError) setError(err.message); - else setError(err instanceof Error ? err.message : String(err)); + setError(apiErrorText(err)); } finally { setCommitting(false); } diff --git a/src/Web/autopilot-monitor-web/app/admin/backups/detail/page.tsx b/src/Web/autopilot-monitor-web/app/admin/backups/detail/page.tsx index 2386bdea..c85ba415 100644 --- a/src/Web/autopilot-monitor-web/app/admin/backups/detail/page.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/backups/detail/page.tsx @@ -11,7 +11,7 @@ import { type RestoreRowPreviewResponse, type RestoreRowRequestBody, } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { apiErrorText, fetchJson } from "@/lib/apiClient"; import { useAdminConfig } from "../../AdminConfigContext"; import { AdminNotifications } from "../../AdminNotifications"; import { RestoreRowDiffModal } from "../components/RestoreRowDiffModal"; @@ -47,13 +47,10 @@ function BackupDetailContent() { if (!backupId) return; try { setLoading(true); - const res = await authenticatedFetch(api.backups.manifest(backupId), getAccessToken); - if (!res.ok) throw new Error(`Manifest failed: ${res.status} ${res.statusText}`); - const body = (await res.json()) as BackupManifest; + const body = await fetchJson(api.backups.manifest(backupId), getAccessToken); setManifest(body); } catch (err) { - if (err instanceof TokenExpiredError) setError(err.message); - else setError(err instanceof Error ? err.message : String(err)); + setError(apiErrorText(err)); } finally { setLoading(false); } @@ -76,20 +73,13 @@ function BackupDetailContent() { rowKey: rk, mode: "Preview", }; - const res = await authenticatedFetch(api.backups.restoreRow(backupId), getAccessToken, { + const preview = await fetchJson(api.backups.restoreRow(backupId), getAccessToken, { method: "POST", body: JSON.stringify(body), - headers: { "Content-Type": "application/json" }, }); - if (!res.ok) { - const text = await res.text(); - throw new Error(`Preview failed: ${res.status} ${text}`); - } - const preview = (await res.json()) as RestoreRowPreviewResponse; setActivePreview(preview); } catch (err) { - if (err instanceof TokenExpiredError) setError(err.message); - else setError(err instanceof Error ? err.message : String(err)); + setError(apiErrorText(err)); } finally { setPreviewLoading(false); } diff --git a/src/Web/autopilot-monitor-web/app/admin/backups/page.tsx b/src/Web/autopilot-monitor-web/app/admin/backups/page.tsx index b14d48f3..34eaeaf6 100644 --- a/src/Web/autopilot-monitor-web/app/admin/backups/page.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/backups/page.tsx @@ -9,7 +9,7 @@ import { type BackupListResponse, type BackupTriggerResponse, } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { apiErrorText, fetchJson } from "@/lib/apiClient"; import { useAdminConfig } from "../AdminConfigContext"; import { AdminNotifications } from "../AdminNotifications"; @@ -31,13 +31,10 @@ export default function BackupsListPage() { const load = useCallback(async () => { try { setLoading(true); - const res = await authenticatedFetch(api.backups.list(), getAccessToken); - if (!res.ok) throw new Error(`List backups failed: ${res.status} ${res.statusText}`); - const body = (await res.json()) as BackupListResponse; + const body = await fetchJson(api.backups.list(), getAccessToken); setBackupIds(body.backupIds ?? []); } catch (err) { - if (err instanceof TokenExpiredError) setError(err.message); - else setError(err instanceof Error ? err.message : String(err)); + setError(apiErrorText(err)); } finally { setLoading(false); } @@ -71,13 +68,10 @@ export default function BackupsListPage() { const timer = window.setTimeout(async () => { if (cancelled) return; try { - const res = await authenticatedFetch(api.backups.jobStatus(activeJob.jobId), getAccessToken); - if (!res.ok) throw new Error(`Job status failed: ${res.status} ${res.statusText}`); - const body = (await res.json()) as BackupJobStatus; + const body = await fetchJson(api.backups.jobStatus(activeJob.jobId), getAccessToken); setActiveJob(body); } catch (err) { - if (err instanceof TokenExpiredError) setError(err.message); - else setError(err instanceof Error ? err.message : String(err)); + setError(apiErrorText(err)); setActiveJob(null); } }, POLL_INTERVAL_MS); @@ -91,14 +85,7 @@ export default function BackupsListPage() { const triggerBackup = useCallback(async () => { try { setTriggering(true); - const res = await authenticatedFetch(api.backups.trigger(), getAccessToken, { - method: "POST", - }); - if (res.status !== 202 && !res.ok) { - const text = await res.text(); - throw new Error(`Trigger backup failed: ${res.status} ${text}`); - } - const body = (await res.json()) as BackupTriggerResponse; + const body = await fetchJson(api.backups.trigger(), getAccessToken, { method: "POST" }); // Seed the poller with the freshly-created job. setActiveJob({ jobId: body.jobId, @@ -110,8 +97,7 @@ export default function BackupsListPage() { }); setSuccessMessage(`Backup queued — jobId ${body.jobId}.`); } catch (err) { - if (err instanceof TokenExpiredError) setError(err.message); - else setError(err instanceof Error ? err.message : String(err)); + setError(apiErrorText(err)); } finally { setTriggering(false); } diff --git a/src/Web/autopilot-monitor-web/app/admin/components/ConfigReseedSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/ConfigReseedSection.tsx index 72af0c03..9502b9f0 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/ConfigReseedSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/ConfigReseedSection.tsx @@ -2,8 +2,9 @@ import { useState } from "react"; import { api } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { apiErrorText, fetchJson } from "@/lib/apiClient"; import { SectionCardHeader } from "@/components/SectionCardHeader"; +import type { ReseedFromGitHubResponse } from "@/utils/wire-types.generated"; interface ConfigReseedSectionProps { getAccessToken: () => Promise; @@ -27,25 +28,14 @@ export function ConfigReseedSection({ setError(null); setSuccessMessage(null); - const response = await authenticatedFetch(api.rules.reseedFromGitHub("analyze"), getAccessToken, { + const result = await fetchJson(api.rules.reseedFromGitHub("analyze"), getAccessToken, { method: "POST", }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || `Failed to reseed analyze rules: ${response.statusText}`); - } - - const result = await response.json(); setSuccessMessage(`Analyze rules reseeded from GitHub: ${result.analyze?.deleted ?? 0} deleted, ${result.analyze?.written ?? 0} written`); setTimeout(() => setSuccessMessage(null), 5000); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while reseeding analyze rules"); - } else { - console.error("Error reseeding analyze rules:", err); - } - setError(err instanceof Error ? err.message : "Failed to reseed analyze rules"); + console.error("Error reseeding analyze rules:", err); + setError(apiErrorText(err, "Failed to reseed analyze rules")); } finally { setReseedingRules(false); } @@ -57,25 +47,14 @@ export function ConfigReseedSection({ setError(null); setSuccessMessage(null); - const response = await authenticatedFetch(api.rules.reseedFromGitHub("gather"), getAccessToken, { + const result = await fetchJson(api.rules.reseedFromGitHub("gather"), getAccessToken, { method: "POST", }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || `Failed to reseed gather rules: ${response.statusText}`); - } - - const result = await response.json(); setSuccessMessage(`Gather rules reseeded from GitHub: ${result.gather?.deleted ?? 0} deleted, ${result.gather?.written ?? 0} written`); setTimeout(() => setSuccessMessage(null), 5000); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while reseeding gather rules"); - } else { - console.error("Error reseeding gather rules:", err); - } - setError(err instanceof Error ? err.message : "Failed to reseed gather rules"); + console.error("Error reseeding gather rules:", err); + setError(apiErrorText(err, "Failed to reseed gather rules")); } finally { setReseedingGatherRules(false); } @@ -87,25 +66,14 @@ export function ConfigReseedSection({ setError(null); setSuccessMessage(null); - const response = await authenticatedFetch(api.rules.reseedFromGitHub("ime"), getAccessToken, { + const result = await fetchJson(api.rules.reseedFromGitHub("ime"), getAccessToken, { method: "POST", }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || `Failed to reseed IME log patterns: ${response.statusText}`); - } - - const result = await response.json(); setSuccessMessage(`IME log patterns reseeded from GitHub: ${result.ime?.deleted ?? 0} deleted, ${result.ime?.written ?? 0} written`); setTimeout(() => setSuccessMessage(null), 5000); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while reseeding IME log patterns"); - } else { - console.error("Error reseeding IME log patterns:", err); - } - setError(err instanceof Error ? err.message : "Failed to reseed IME log patterns"); + console.error("Error reseeding IME log patterns:", err); + setError(apiErrorText(err, "Failed to reseed IME log patterns")); } finally { setReseedingImePatterns(false); } @@ -117,16 +85,9 @@ export function ConfigReseedSection({ setError(null); setSuccessMessage(null); - const response = await authenticatedFetch(api.rules.reseedFromGitHub("all"), getAccessToken, { + const result = await fetchJson(api.rules.reseedFromGitHub("all"), getAccessToken, { method: "POST", }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || `Failed to reseed from GitHub: ${response.statusText}`); - } - - const result = await response.json(); setSuccessMessage( `GitHub Reseed complete: Gather (${result.gather?.deleted ?? 0} deleted, ${result.gather?.written ?? 0} written), ` + `Analyze (${result.analyze?.deleted ?? 0} deleted, ${result.analyze?.written ?? 0} written), ` + @@ -134,12 +95,8 @@ export function ConfigReseedSection({ ); setTimeout(() => setSuccessMessage(null), 8000); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while reseeding from GitHub"); - } else { - console.error("Error reseeding from GitHub:", err); - } - setError(err instanceof Error ? err.message : "Failed to reseed from GitHub"); + console.error("Error reseeding from GitHub:", err); + setError(apiErrorText(err, "Failed to reseed from GitHub")); } finally { setFetchingFromGitHub(false); } diff --git a/src/Web/autopilot-monitor-web/app/admin/components/DeviceBlockSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/DeviceBlockSection.tsx index 2359b33f..300a1a98 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/DeviceBlockSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/DeviceBlockSection.tsx @@ -5,13 +5,14 @@ import { Suspense, useCallback, useEffect, useRef, useState } from "react"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; import { api } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient"; import { trackEvent } from "@/lib/appInsights"; import { TenantConfiguration } from "./TenantManagementSection"; import { TenantSearchSelect } from "./TenantSearchSelect"; import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform"; import { firstBlockedSessionId, blockedSessionCount } from "./blockedDeviceHelpers"; import { SectionCardHeader } from "@/components/SectionCardHeader"; +import type { BlockedDeviceEntry, BlockedDeviceListResponse, GetSessionResponse } from "@/utils/wire-types.generated"; interface ResolvedDevice { sessionId: string; @@ -22,22 +23,8 @@ interface ResolvedDevice { model: string; } -interface BlockedDevice { - tenantId: string; - serialNumber: string; - blockedAt: string; - unblockAt: string; - blockedByEmail: string; - durationHours: number; - reason?: string; - action?: string; - /** - * Comma-separated sessions this block is scoped to — set by the maintenance auto-block - * paths, absent on a manual whole-device block. Drives the serial's deep-link: without a - * session there is nothing honest to link to. - */ - blockedSessionIds?: string | null; -} +/** One row of GET devices/blocked — the wire shape, so a backend rename fails here instead of rendering blanks. */ +type BlockedDevice = BlockedDeviceEntry; interface DeviceBlockSectionProps { tenants: TenantConfiguration[]; @@ -90,11 +77,7 @@ function DeviceBlockSectionInner({ try { setResolvingSession(true); setError(null); - const response = await authenticatedFetch(api.sessions.get(id), getAccessToken); - const data = await response.json(); - if (!response.ok || !data?.success || !data.session) { - throw new Error(data?.message || `Session ${id} not found`); - } + const data = await fetchJson(api.sessions.get(id), getAccessToken); const session = data.session as { sessionId: string; tenantId: string; @@ -114,11 +97,8 @@ function DeviceBlockSectionInner({ model: session.model ?? "", }); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while resolving session ID"); - } setResolvedDevice(null); - setError(err instanceof Error ? err.message : "Failed to resolve session"); + setError(apiErrorText(err, "Failed to resolve session")); } finally { setResolvingSession(false); } @@ -151,15 +131,10 @@ function DeviceBlockSectionInner({ if (!tenantId) return; try { setLoadingBlockedDevices(true); - const response = await authenticatedFetch(api.devices.blocked(tenantId), getAccessToken); - if (!response.ok) throw new Error(`Failed to load blocked devices: ${response.statusText}`); - const data = await response.json(); + const data = await fetchJson(api.devices.blocked(tenantId), getAccessToken); setBlockedDevices(data.blocked ?? []); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while loading blocked devices"); - } - setError(err instanceof Error ? err.message : "Failed to load blocked devices"); + setError(apiErrorText(err, "Failed to load blocked devices")); } finally { setLoadingBlockedDevices(false); } @@ -168,16 +143,11 @@ function DeviceBlockSectionInner({ const fetchAllBlockedDevices = async () => { try { setLoadingAllBlocked(true); - const response = await authenticatedFetch(api.devices.allBlocked(), getAccessToken); - if (!response.ok) throw new Error(`Failed to load blocked devices: ${response.statusText}`); - const data = await response.json(); + const data = await fetchJson(api.devices.allBlocked(), getAccessToken); setBlockedDevices(data.blocked ?? []); setBlockListTenantId(""); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while loading all blocked devices"); - } - setError(err instanceof Error ? err.message : "Failed to load all blocked devices"); + setError(apiErrorText(err, "Failed to load all blocked devices")); } finally { setLoadingAllBlocked(false); } @@ -204,9 +174,8 @@ function DeviceBlockSectionInner({ try { setBlockingDevice(true); setError(null); - const response = await authenticatedFetch(api.devices.block(), getAccessToken, { + await fetchOk(api.devices.block(), getAccessToken, { method: "POST", - headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tenantId: blockTenantId, serialNumber: blockSerialNumber.trim(), @@ -216,8 +185,6 @@ function DeviceBlockSectionInner({ blockedSessionId: sessionIdToForward, }), }); - const result = await response.json(); - if (!response.ok) throw new Error(result.message || response.statusText); trackEvent("device_blocked", { action: blockAction, durationHours: blockDurationHours, @@ -236,10 +203,7 @@ function DeviceBlockSectionInner({ setResolvedDevice(null); if (blockListTenantId === blockTenantId) await fetchBlockedDevices(blockTenantId); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while blocking device"); - } - setError(err instanceof Error ? err.message : "Failed to block device"); + setError(apiErrorText(err, "Failed to block device")); } finally { setBlockingDevice(false); } @@ -250,22 +214,13 @@ function DeviceBlockSectionInner({ try { setUnblockingDevice(serialNumber); setError(null); - const response = await authenticatedFetch( - api.devices.unblock(serialNumber, tenantId), - getAccessToken, - { method: "DELETE" } - ); - const result = await response.json(); - if (!response.ok) throw new Error(result.message || response.statusText); + await fetchOk(api.devices.unblock(serialNumber, tenantId), getAccessToken, { method: "DELETE" }); trackEvent("device_unblocked"); setSuccessMessage(`Device ${serialNumber} unblocked.`); setTimeout(() => setSuccessMessage(null), 3000); setBlockedDevices((prev) => prev.filter((d) => d.serialNumber !== serialNumber || d.tenantId !== tenantId)); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while unblocking device"); - } - setError(err instanceof Error ? err.message : "Failed to unblock device"); + setError(apiErrorText(err, "Failed to unblock device")); } finally { setUnblockingDevice(null); } @@ -280,10 +235,10 @@ function DeviceBlockSectionInner({ try { setKillingDevice(device.serialNumber); setError(null); - const remainingHours = Math.max(1, Math.ceil((new Date(device.unblockAt).getTime() - Date.now()) / 3600000)); - const response = await authenticatedFetch(api.devices.block(), getAccessToken, { + const unblockAtMs = device.unblockAt ? new Date(device.unblockAt).getTime() : Date.now(); + const remainingHours = Math.max(1, Math.ceil((unblockAtMs - Date.now()) / 3600000)); + await fetchOk(api.devices.block(), getAccessToken, { method: "POST", - headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tenantId: device.tenantId, serialNumber: device.serialNumber, @@ -292,17 +247,12 @@ function DeviceBlockSectionInner({ action: "Kill", }), }); - const result = await response.json(); - if (!response.ok) throw new Error(result.message || response.statusText); trackEvent("device_killed"); setSuccessMessage(`Device ${device.serialNumber} upgraded to kill signal.`); setTimeout(() => setSuccessMessage(null), 4000); await fetchBlockedDevices(device.tenantId); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while sending kill signal"); - } - setError(err instanceof Error ? err.message : "Failed to send kill signal"); + setError(apiErrorText(err, "Failed to send kill signal")); } finally { setKillingDevice(null); } @@ -556,7 +506,7 @@ function DeviceBlockSectionInner({ )} {new Date(d.blockedAt).toLocaleString()} - {new Date(d.unblockAt).toLocaleString()} + {d.unblockAt ? new Date(d.unblockAt).toLocaleString() : "\u2014"} {d.blockedByEmail} {d.reason || "\u2014"} diff --git a/src/Web/autopilot-monitor-web/app/admin/components/DistressReportsSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/DistressReportsSection.tsx index 9daa09e6..ce5b3b19 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/DistressReportsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/DistressReportsSection.tsx @@ -2,22 +2,12 @@ import { useState, useEffect } from "react"; import { api } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { apiErrorText, fetchJson, nullOn404 } from "@/lib/apiClient"; import { TableSkeleton } from "@/components/skeletons/TableSkeleton"; +import type { DistressReportEntry, DistressReportListResponse } from "@/utils/wire-types.generated"; -interface DistressReport { - tenantId: string; - errorType: string; - manufacturer: string | null; - model: string | null; - serialNumber: string | null; - agentVersion: string | null; - httpStatusCode: number | null; - message: string | null; - agentTimestamp: string; - ingestedAt: string; - sourceIp: string | null; -} +/** One stored distress report — the wire shape (optional where the agent sent nothing). */ +type DistressReport = DistressReportEntry; type SortKey = "ingestedAt" | "errorType" | "tenantId" | "manufacturer" | "model" | "serialNumber" | "httpStatusCode"; type SortDir = "asc" | "desc"; @@ -149,19 +139,11 @@ export function DistressReportsSection({ const fetchReports = async () => { try { setLoading(true); - const res = await authenticatedFetch(api.distressReports.list(), getAccessToken); - if (res.status === 404) { - setReports([]); - return; - } - if (!res.ok) throw new Error(`Failed to load distress reports: ${res.statusText}`); - const data = await res.json(); - setReports(data.reports ?? []); + // 404 = the table does not exist yet: no reports, not an error. + const data = await fetchJson(api.distressReports.list(), getAccessToken).catch(nullOn404); + setReports(data?.reports ?? []); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while loading distress reports"); - } - setError(err instanceof Error ? err.message : "Failed to load distress reports"); + setError(apiErrorText(err, "Failed to load distress reports")); } finally { setLoading(false); } diff --git a/src/Web/autopilot-monitor-web/app/admin/components/FeedbackSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/FeedbackSection.tsx index e3356c21..bef9b315 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/FeedbackSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/FeedbackSection.tsx @@ -2,22 +2,11 @@ import { useCallback, useEffect, useState } from "react"; import { api } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { apiErrorText, fetchJson } from "@/lib/apiClient"; +import type { FeedbackEntryWire, FeedbackListResponse } from "@/utils/wire-types.generated"; -interface FeedbackEntry { - type: "InApp" | "Offboarding" | string; // server returns these two; future-proof for additional kinds - upn: string; - tenantId: string; - displayName: string; - rating: number | null; - comment: string | null; - dismissed: boolean; - submitted: boolean; - interactedAt: string | null; - // Offboarding-only — null for InApp entries. - historyRowKey: string | null; - domainName: string | null; -} +/** One stored feedback entry — the wire shape (fields absent on dismissals). */ +type FeedbackEntry = FeedbackEntryWire; type FeedbackTab = "InApp" | "Offboarding"; type InAppFilter = "all" | "submitted" | "dismissed"; @@ -46,13 +35,7 @@ export function FeedbackSection({ getAccessToken, setError }: FeedbackSectionPro setLoading(true); setError(null); - const response = await authenticatedFetch(api.feedback.all(), getAccessToken); - - if (!response.ok) { - throw new Error(`Failed to load feedback: ${response.statusText}`); - } - - const data = await response.json(); + const data = await fetchJson(api.feedback.all(), getAccessToken); // Sort by interactedAt descending (newest first) const sorted = (data.feedback || []).sort((a: FeedbackEntry, b: FeedbackEntry) => { const dateA = a.interactedAt ? new Date(a.interactedAt).getTime() : 0; @@ -62,12 +45,8 @@ export function FeedbackSection({ getAccessToken, setError }: FeedbackSectionPro setEntries(sorted); setNowMs(Date.now()); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while fetching feedback"); - } else { - console.error("Error fetching feedback:", err); - } - setError(err instanceof Error ? err.message : "Failed to load feedback"); + console.error("Error fetching feedback:", err); + setError(apiErrorText(err, "Failed to load feedback")); } finally { setLoading(false); } @@ -280,15 +259,15 @@ export function FeedbackSection({ getAccessToken, setError }: FeedbackSectionPro > {entry.upn} - {entry.submitted ? renderStars(entry.rating) : ( + {entry.submitted ? renderStars(entry.rating ?? null) : ( dismissed )}
- {formatTimeAgo(entry.interactedAt)} + {formatTimeAgo(entry.interactedAt ?? null)} · - {entry.tenantId.substring(0, 8)}... + {entry.tenantId?.substring(0, 8)}...
@@ -313,11 +292,11 @@ export function FeedbackSection({ getAccessToken, setError }: FeedbackSectionPro Offboarded - {entry.domainName || entry.tenantId.substring(0, 8) + "…"} + {entry.domainName || entry.tenantId?.substring(0, 8) + "…"}
- {formatTimeAgo(entry.interactedAt)} + {formatTimeAgo(entry.interactedAt ?? null)} · {entry.upn} diff --git a/src/Web/autopilot-monitor-web/app/admin/components/IdentityBindingsSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/IdentityBindingsSection.tsx index 01be53c7..3493de40 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/IdentityBindingsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/IdentityBindingsSection.tsx @@ -2,9 +2,10 @@ import { useCallback, useState } from "react"; import { api } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient"; import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform"; import { isGuid, type IdentityBinding } from "@/lib/identityBinding"; +import type { IdentityBindingListResponse } from "@/utils/wire-types.generated"; interface IdentityBindingsSectionProps { tenantId: string; @@ -37,17 +38,14 @@ export function IdentityBindingsSection({ const fail = useCallback( (err: unknown, fallback: string) => { - if (err instanceof TokenExpiredError) setError(err.message); - else setError(err instanceof Error ? err.message : fallback); + setError(apiErrorText(err, fallback)); }, [setError], ); const load = useCallback(async () => { try { - const response = await authenticatedFetch(api.identityBindings.list(), getAccessToken); - if (!response.ok) throw new Error(`Failed to load identity bindings: ${response.statusText}`); - const data = await response.json(); + const data = await fetchJson(api.identityBindings.list(), getAccessToken); const all: IdentityBinding[] = data.bindings ?? []; setBindings( all @@ -80,15 +78,10 @@ export function IdentityBindingsSection({ try { setBusy(upn); setError(null); - const response = await authenticatedFetch(api.identityBindings.put(upn), getAccessToken, { + await fetchOk(api.identityBindings.put(upn), getAccessToken, { method: "PUT", - headers: { "Content-Type": "application/json" }, body: JSON.stringify({ homeTenantId: tid, objectId: oid || undefined }), }); - if (!response.ok) { - const data = await response.json().catch(() => ({})); - throw new Error(data.error || `Failed to update binding: ${response.statusText}`); - } setSuccessMessage(`Identity binding for ${upn} updated.`); setEditing(null); await load(); @@ -104,11 +97,7 @@ export function IdentityBindingsSection({ try { setBusy(upn); setError(null); - const response = await authenticatedFetch(api.identityBindings.remove(upn), getAccessToken, { method: "DELETE" }); - if (!response.ok) { - const data = await response.json().catch(() => ({})); - throw new Error(data.error || `Failed to remove binding: ${response.statusText}`); - } + await fetchOk(api.identityBindings.remove(upn), getAccessToken, { method: "DELETE" }); setSuccessMessage(`Identity binding for ${upn} removed.`); await load(); } catch (err) { diff --git a/src/Web/autopilot-monitor-web/app/admin/components/IgnoredSoftwareTab.tsx b/src/Web/autopilot-monitor-web/app/admin/components/IgnoredSoftwareTab.tsx index a30d589b..bd841856 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/IgnoredSoftwareTab.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/IgnoredSoftwareTab.tsx @@ -2,11 +2,12 @@ import { useCallback, useEffect, useState } from "react"; import { api } from "@/lib/api"; -import { authenticatedFetch } from "@/lib/authenticatedFetch"; +import { apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient"; import TruncatedLabel from "@/components/TruncatedLabel"; import { trackEvent } from "@/lib/appInsights"; import { TableSkeleton } from "@/components/skeletons/TableSkeleton"; import type { AutoResolveResult, IgnoredSoftwareEntry } from "./SoftwareMappingTypes"; +import type { GetIgnoredSoftwareResponse } from "@/utils/wire-types.generated"; interface IgnoredSoftwareTabProps { getAccessToken: () => Promise; @@ -40,9 +41,7 @@ export function IgnoredSoftwareTab({ try { setIgnoredLoading(true); setError(null); - const response = await authenticatedFetch(api.vulnerability.ignoredSoftware(), getAccessToken); - if (!response.ok) throw new Error(`Failed to load ignored software: ${response.statusText}`); - const data = await response.json(); + const data = await fetchJson(api.vulnerability.ignoredSoftware(), getAccessToken); const items: IgnoredSoftwareEntry[] = data.items || []; setIgnoredEntries(items); setIgnoredLoaded(true); @@ -50,7 +49,7 @@ export function IgnoredSoftwareTab({ onCountChanged(items.length); } catch (err) { console.error("Error fetching ignored software:", err); - setError(err instanceof Error ? err.message : "Failed to load ignored software"); + setError(apiErrorText(err, "Failed to load ignored software")); } finally { setIgnoredLoading(false); } @@ -72,12 +71,10 @@ export function IgnoredSoftwareTab({ try { setRestoringRow(key); setError(null); - const response = await authenticatedFetch(api.vulnerability.ignoredSoftware(), getAccessToken, { + await fetchOk(api.vulnerability.ignoredSoftware(), getAccessToken, { method: "DELETE", - headers: { "Content-Type": "application/json" }, body: JSON.stringify({ softwareName: entry.softwareName, publisher: entry.publisher || "" }), }); - if (!response.ok) throw new Error(`Failed to restore software: ${response.statusText}`); setIgnoredEntries((prev) => { const next = prev.filter((e) => `${e.softwareName}::${e.publisher}` !== key); onCountChanged(next.length); @@ -86,7 +83,7 @@ export function IgnoredSoftwareTab({ onRestored(); } catch (err) { console.error("Error restoring software:", err); - setError(err instanceof Error ? err.message : "Failed to restore software"); + setError(apiErrorText(err, "Failed to restore software")); } finally { setRestoringRow(null); } @@ -98,21 +95,17 @@ export function IgnoredSoftwareTab({ setRestoringRow(key); setError(null); // First, call auto-resolve for this single item - const resolveResponse = await authenticatedFetch(api.vulnerability.cpeAutoResolve(), getAccessToken, { + const data = await fetchJson(api.vulnerability.cpeAutoResolve(), getAccessToken, { method: "POST", - headers: { "Content-Type": "application/json" }, body: JSON.stringify({ items: [{ softwareName: entry.softwareName, publisher: entry.publisher || "", normalizedVendor: entry.publisher || "" }], }), }); - if (!resolveResponse.ok) throw new Error(`NVD check failed: ${resolveResponse.statusText}`); - const data: AutoResolveResult = await resolveResponse.json(); if (data.totalResolved > 0) { // Resolved! Remove from ignore list and refresh - await authenticatedFetch(api.vulnerability.ignoredSoftware(), getAccessToken, { + await fetchOk(api.vulnerability.ignoredSoftware(), getAccessToken, { method: "DELETE", - headers: { "Content-Type": "application/json" }, body: JSON.stringify({ softwareName: entry.softwareName, publisher: entry.publisher || "" }), }); setIgnoredEntries((prev) => { @@ -128,7 +121,7 @@ export function IgnoredSoftwareTab({ } } catch (err) { console.error("Error checking NVD:", err); - setError(err instanceof Error ? err.message : "NVD check failed"); + setError(apiErrorText(err, "NVD check failed")); } finally { setRestoringRow(null); } diff --git a/src/Web/autopilot-monitor-web/app/admin/components/MaintenanceTriggerSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/MaintenanceTriggerSection.tsx index 18dd4f9e..2f35c875 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/MaintenanceTriggerSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/MaintenanceTriggerSection.tsx @@ -2,8 +2,9 @@ import { useState } from "react"; import { api } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient"; import { SectionCardHeader } from "@/components/SectionCardHeader"; +import type { GetLatestVersionsResponse } from "@/utils/wire-types.generated"; interface MaintenanceTriggerSectionProps { getAccessToken: () => Promise; @@ -29,15 +30,9 @@ export function MaintenanceTriggerSection({ setError(null); setSuccessMessage(null); - const response = await authenticatedFetch(api.config.latestVersions({ refresh: true }), getAccessToken, { + const data = await fetchJson(api.config.latestVersions({ refresh: true }), getAccessToken, { method: "GET", }); - - if (!response.ok) { - throw new Error(`Failed to refresh latest versions: ${response.statusText}`); - } - - const data = await response.json(); const agentVer = data.latestAgentVersion ?? "unknown"; const bootstrapVer = data.latestBootstrapScriptVersion ?? "unknown"; const fetchedAt = data.fetchedAtUtc ? new Date(data.fetchedAtUtc).toISOString().replace("T", " ").substring(0, 19) + " UTC" : "now"; @@ -45,12 +40,8 @@ export function MaintenanceTriggerSection({ setTimeout(() => setSuccessMessage(null), 8000); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while refreshing versions"); - } else { - console.error("Error refreshing latest versions:", err); - } - setError(err instanceof Error ? err.message : "Failed to refresh latest versions"); + console.error("Error refreshing latest versions:", err); + setError(apiErrorText(err, "Failed to refresh latest versions")); } finally { setRefreshingVersions(false); } @@ -62,25 +53,17 @@ export function MaintenanceTriggerSection({ setError(null); setSuccessMessage(null); - const response = await authenticatedFetch(api.maintenance.trigger(maintenanceDate || undefined), getAccessToken, { + await fetchOk(api.maintenance.trigger(maintenanceDate || undefined), getAccessToken, { method: "POST", }); - if (!response.ok) { - throw new Error(`Failed to trigger maintenance: ${response.statusText}`); - } - const dateInfo = maintenanceDate ? ` for ${maintenanceDate}` : ''; setSuccessMessage(`Maintenance job completed successfully${dateInfo}!`); setTimeout(() => setSuccessMessage(null), 5000); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while triggering maintenance"); - } else { - console.error("Error triggering maintenance:", err); - } - setError(err instanceof Error ? err.message : "Failed to trigger maintenance job"); + console.error("Error triggering maintenance:", err); + setError(apiErrorText(err, "Failed to trigger maintenance job")); } finally { setTriggeringMaintenance(false); } diff --git a/src/Web/autopilot-monitor-web/app/admin/components/MappedSoftwareTab.tsx b/src/Web/autopilot-monitor-web/app/admin/components/MappedSoftwareTab.tsx index 394ac4ef..743496e9 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/MappedSoftwareTab.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/MappedSoftwareTab.tsx @@ -2,10 +2,11 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient"; import { trackEvent } from "@/lib/appInsights"; import { CpeMappingEntry } from "./SoftwareMappingTypes"; import { TableSkeleton } from "@/components/skeletons/TableSkeleton"; +import type { GetCpeMappingsResponse } from "@/utils/wire-types.generated"; interface MappedSoftwareTabProps { getAccessToken: () => Promise; @@ -53,28 +54,18 @@ export function MappedSoftwareTab({ setMappedLoading(true); setError(null); - const response = await authenticatedFetch( + const data = await fetchJson( api.vulnerability.cpeMappings(), getAccessToken ); - - if (!response.ok) { - throw new Error(`Failed to load CPE mappings: ${response.statusText}`); - } - - const data = await response.json(); const mappings = data.mappings || []; setMappedEntries(mappings); setMappedLoaded(true); setMappedPage(0); onCountChanged(mappings.length); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while fetching CPE mappings"); - } else { - console.error("Error fetching CPE mappings:", err); - } - setError(err instanceof Error ? err.message : "Failed to load CPE mappings"); + console.error("Error fetching CPE mappings:", err); + setError(apiErrorText(err, "Failed to load CPE mappings")); } finally { setMappedLoading(false); } @@ -180,12 +171,11 @@ export function MappedSoftwareTab({ .map((s) => s.trim()) .filter(Boolean); - const response = await authenticatedFetch( + await fetchOk( api.vulnerability.cpeMapping(), getAccessToken, { method: "POST", - headers: { "Content-Type": "application/json" }, body: JSON.stringify({ normalizedProduct: entry.normalizedProduct, normalizedVendor: entry.normalizedVendor, @@ -200,21 +190,12 @@ export function MappedSoftwareTab({ } ); - if (!response.ok) { - const data = await response.json().catch(() => null); - throw new Error(data?.message || `Failed to save mapping: ${response.statusText}`); - } - setEditingRow(null); // Reload to reflect changes await fetchCpeMappings(); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while saving CPE mapping edit"); - } else { - console.error("Error saving CPE mapping edit:", err); - } - setError(err instanceof Error ? err.message : "Failed to save CPE mapping"); + console.error("Error saving CPE mapping edit:", err); + setError(apiErrorText(err, "Failed to save CPE mapping")); } finally { setSavingEdit(false); } @@ -228,12 +209,11 @@ export function MappedSoftwareTab({ setDeletingRow(key); setError(null); - const response = await authenticatedFetch( + await fetchOk( api.vulnerability.cpeMapping(), getAccessToken, { method: "DELETE", - headers: { "Content-Type": "application/json" }, body: JSON.stringify({ normalizedProduct: entry.normalizedProduct, normalizedVendor: entry.normalizedVendor, @@ -241,23 +221,14 @@ export function MappedSoftwareTab({ } ); - if (!response.ok) { - const data = await response.json().catch(() => null); - throw new Error(data?.message || `Failed to delete mapping: ${response.statusText}`); - } - setMappedEntries((prev) => { const updated = prev.filter((m) => getMappedRowKey(m) !== key); onCountChanged(updated.length); return updated; }); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while deleting CPE mapping"); - } else { - console.error("Error deleting CPE mapping:", err); - } - setError(err instanceof Error ? err.message : "Failed to delete CPE mapping"); + console.error("Error deleting CPE mapping:", err); + setError(apiErrorText(err, "Failed to delete CPE mapping")); } finally { setDeletingRow(null); } diff --git a/src/Web/autopilot-monitor-web/app/admin/components/OpsEventsSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/OpsEventsSection.tsx index a14d70b9..e6b389bd 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/OpsEventsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/OpsEventsSection.tsx @@ -5,22 +5,14 @@ import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; import { api } from "@/lib/api"; import TruncatedLabel from "@/components/TruncatedLabel"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { apiErrorText, fetchJson, nullOn404 } from "@/lib/apiClient"; import { extractContinuation } from "@/lib/paginationLink"; import { extractSessionId, buildAutoReason } from "./opsEventSessionHelpers"; import { SectionCardHeader } from "@/components/SectionCardHeader"; +import type { OpsEventEntry, OpsEventListResponse } from "@/utils/wire-types.generated"; -interface OpsEvent { - id: string; - category: string; - eventType: string; - severity: string; - tenantId: string | null; - userId: string | null; - message: string; - details: string | null; - timestamp: string; -} +/** One ops event row (wire shape). */ +type OpsEvent = OpsEventEntry; interface OpsEventsSectionProps { getAccessToken: () => Promise; @@ -145,7 +137,8 @@ export function OpsEventsSection({ const fetchEvents = useCallback(async (cursor: string | null) => { try { setLoading(true); - const res = await authenticatedFetch( + // 404 = the table does not exist yet: no events, not an error. + const data = await fetchJson( api.opsEvents.list(categoryFilter || undefined, { dateFrom: dateFromIso, dateTo: dateToIso, @@ -153,21 +146,11 @@ export function OpsEventsSection({ continuation: cursor ?? undefined, }), getAccessToken - ); - if (res.status === 404) { - setEvents([]); - setNextLink(null); - return; - } - if (!res.ok) throw new Error(`Failed to load ops events: ${res.statusText}`); - const data = await res.json(); - setEvents(data.events ?? []); - setNextLink(data.nextLink ?? null); + ).catch(nullOn404); + setEvents(data?.events ?? []); + setNextLink(data?.nextLink ?? null); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while loading ops events"); - } - setError(err instanceof Error ? err.message : "Failed to load ops events"); + setError(apiErrorText(err, "Failed to load ops events")); } finally { setLoading(false); } @@ -552,7 +535,7 @@ export function OpsEventsSection({ {(() => { - const sessionId = extractSessionId(selectedEvent.details); + const sessionId = extractSessionId(selectedEvent.details ?? null); if (!sessionId) return null; const reason = buildAutoReason(selectedEvent.eventType, sessionId); return ( diff --git a/src/Web/autopilot-monitor-web/app/admin/components/SessionExportSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/SessionExportSection.tsx index 03cb5fa4..fbc55890 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/SessionExportSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/SessionExportSection.tsx @@ -2,12 +2,13 @@ import { useState } from "react"; import { api } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { apiErrorText, fetchJson } from "@/lib/apiClient"; import { TenantConfiguration } from "./TenantManagementSection"; import { TenantSearchSelect } from "./TenantSearchSelect"; import { SessionExportEvent, generateCsvExport, generateUiExport } from "@/utils/sessionExportUtils"; import { trackEvent } from "@/lib/appInsights"; import { SectionCardHeader } from "@/components/SectionCardHeader"; +import type { GetSessionEventsResponse } from "@/utils/wire-types.generated"; function downloadFile(content: string, filename: string, mimeType: string) { const blob = new Blob([content], { type: mimeType }); @@ -47,19 +48,14 @@ export function SessionExportSection({ setExportLoading(true); setExportError(null); setExportedEvents(null); - const res = await authenticatedFetch( + const data = await fetchJson( api.sessions.events(sid, tid), getAccessToken ); - if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); - const data = await res.json(); - if (!data.success) throw new Error(data.message || "Backend returned error"); - setExportedEvents(data.events ?? []); + // No `fields` projection on this call, so every event is complete. + setExportedEvents((data.events ?? []) as SessionExportEvent[]); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while fetching export events"); - } - setExportError(err instanceof Error ? err.message : "Failed to fetch events"); + setExportError(apiErrorText(err, "Failed to fetch events")); } finally { setExportLoading(false); } diff --git a/src/Web/autopilot-monitor-web/app/admin/components/SessionReportsSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/SessionReportsSection.tsx index 77d5cd5b..1cd36b68 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/SessionReportsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/SessionReportsSection.tsx @@ -3,7 +3,7 @@ import { Suspense, useCallback, useEffect, useState } from "react"; import { useSearchParams } from "next/navigation"; import { api } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { apiErrorText, fetchJson, fetchOk, nullOn404 } from "@/lib/apiClient"; import TruncatedLabel from "@/components/TruncatedLabel"; import { extractContinuation } from "@/lib/paginationLink"; import { isGuid } from "@/utils/inputValidation"; @@ -11,26 +11,12 @@ import { trackEvent } from "@/lib/appInsights"; import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform"; import { TableSkeleton } from "@/components/skeletons/TableSkeleton"; import { SectionCardHeader } from "@/components/SectionCardHeader"; +import type { SessionReportDownloadUrlResponse, SessionReportListResponse, SessionReportMetadata } from "@/utils/wire-types.generated"; const PAGE_SIZE = 20; -interface SessionReport { - reportId: string; - tenantId: string; - sessionId: string; - comment: string; - email: string; - blobName: string; - submittedBy: string; - submittedAt: string; - adminNote?: string; - /** "session" (legacy default) or "diagFiles" (no session context) */ - reportType?: "session" | "diagFiles"; - /** Flat name of the preserved diagnostics archive copy — present only when the copy succeeded */ - diagnosticsBlobName?: string; - /** "Copied" or a "Failed:*" reason — absent when the submitter did not request the copy */ - diagnosticsCopyStatus?: string; -} +/** One session report row (wire shape). */ +type SessionReport = SessionReportMetadata; function ReportTypeBadge({ type }: { type?: string }) { const isDiagFiles = type === "diagFiles"; @@ -109,25 +95,20 @@ function AdminNoteEditor({ setSavingNote(true); setNoteSaveResult(null); - const res = await authenticatedFetch( + await fetchOk( api.reports.note(report.reportId), getAccessToken, { method: "PATCH", - headers: { "Content-Type": "application/json" }, body: JSON.stringify({ adminNote: adminNoteValue }), } ); - if (!res.ok) throw new Error(`Failed to save note: ${res.statusText}`); // Update parent state onSaved({ ...report, adminNote: adminNoteValue }); setNoteSaveResult("saved"); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while saving admin note"); - } - setNoteSaveResult(err instanceof Error ? err.message : "Failed to save note"); + setNoteSaveResult(apiErrorText(err, "Failed to save note")); } finally { setSavingNote(false); } @@ -252,12 +233,10 @@ function SessionReportsSectionInner({ try { setDownloadingBlob(blobName); - const res = await authenticatedFetch( + const data = await fetchJson( api.reports.downloadUrl(blobName), getAccessToken ); - if (!res.ok) throw new Error(`Failed to get download URL: ${res.statusText}`); - const data = await res.json(); if (!data.downloadUrl) throw new Error("No download URL returned"); trackEvent("session_report_downloaded"); @@ -266,10 +245,7 @@ function SessionReportsSectionInner({ a.download = blobName; a.click(); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while downloading report"); - } - setError(err instanceof Error ? err.message : "Failed to download report"); + setError(apiErrorText(err, "Failed to download report")); } finally { setDownloadingBlob(null); } @@ -284,30 +260,19 @@ function SessionReportsSectionInner({ try { setLoading(true); - const res = await authenticatedFetch( + // 404 = table/container does not exist yet: no reports submitted so far, not an error. + const data = await fetchJson( api.reports.list({ tenantId: filterTenantId, pageSize: PAGE_SIZE, continuation: cursor ?? undefined, }), getAccessToken, - ); - - if (res.status === 404) { - // Table/container doesn't exist yet — no reports submitted so far - setReports([]); - setNextLink(null); - return; - } - if (!res.ok) throw new Error(`Failed to load reports: ${res.statusText}`); - const data = await res.json(); - setReports(data.reports ?? []); - setNextLink(data.nextLink ?? null); + ).catch(nullOn404); + setReports(data?.reports ?? []); + setNextLink(data?.nextLink ?? null); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while loading reports"); - } - setError(err instanceof Error ? err.message : "Failed to load reports"); + setError(apiErrorText(err, "Failed to load reports")); } finally { setLoading(false); } diff --git a/src/Web/autopilot-monitor-web/app/admin/components/SoftwareMappingTypes.ts b/src/Web/autopilot-monitor-web/app/admin/components/SoftwareMappingTypes.ts index ee8f684f..84e45811 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/SoftwareMappingTypes.ts +++ b/src/Web/autopilot-monitor-web/app/admin/components/SoftwareMappingTypes.ts @@ -1,12 +1,7 @@ -export interface UnmatchedSoftwareEntry { - softwareName: string; - publisher: string; - frequency: number; - lastSeenAt: string; - exampleSessionId: string; - normalizedVendor?: string; - normalizedVersion?: string; -} +import type { UnmatchedSoftwareItem } from "@/utils/wire-types.generated"; + +/** One row of GET vulnerability/unmatched-software — the wire shape. */ +export type UnmatchedSoftwareEntry = UnmatchedSoftwareItem; export interface AutoResolveResult { resolved: Array<{ softwareName: string; cpeUri: string; confidence: number }>; diff --git a/src/Web/autopilot-monitor-web/app/admin/components/TenantAdminSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/TenantAdminSection.tsx index b5bce1b8..7ca79203 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/TenantAdminSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/TenantAdminSection.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { api } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient"; import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform"; import type { TenantAdminRow } from "@/utils/wire-types.generated"; import { isApplicationKey, looksLikeGuid, principalLabel, type MemberKind } from "@/utils/principalKeys"; @@ -45,21 +45,11 @@ export function TenantAdminSection({ setLoadingAdmins(true); setCurrentAdminPage(0); // Reset to first page when loading new tenant - const response = await authenticatedFetch(api.tenants.admins(tid), getAccessToken); - - if (!response.ok) { - throw new Error(`Failed to load admins: ${response.statusText}`); - } - - const data: TenantAdmin[] = await response.json(); + const data = await fetchJson(api.tenants.admins(tid), getAccessToken); setTenantAdmins(data); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while fetching tenant admins"); - } else { - console.error("Error fetching tenant admins:", err); - } - setError(err instanceof Error ? err.message : "Failed to load tenant admins"); + console.error("Error fetching tenant admins:", err); + setError(apiErrorText(err, "Failed to load tenant admins")); } finally { setLoadingAdmins(false); } @@ -84,17 +74,11 @@ export function TenantAdminSection({ const body = addingApplication ? { applicationId: newAdminEmail.trim(), role: "Viewer" } : { upn: newAdminEmail.trim(), role: newMemberRole }; - const response = await authenticatedFetch(api.tenants.admins(tenantId), getAccessToken, { + await fetchOk(api.tenants.admins(tenantId), getAccessToken, { method: "POST", - headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || `Failed to add admin: ${response.statusText}`); - } - setSuccessMessage(addingApplication ? `Service principal ${newAdminEmail.trim()} added as Viewer.` : `${newMemberRole} ${newAdminEmail} added successfully!`); @@ -106,12 +90,8 @@ export function TenantAdminSection({ // Auto-hide success message after 3 seconds setTimeout(() => setSuccessMessage(null), 3000); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while adding tenant admin"); - } else { - console.error("Error adding tenant admin:", err); - } - setError(err instanceof Error ? err.message : "Failed to add admin"); + console.error("Error adding tenant admin:", err); + setError(apiErrorText(err, "Failed to add admin")); } finally { setAddingAdmin(false); } @@ -127,15 +107,10 @@ export function TenantAdminSection({ setRemovingAdmin(adminUpn); setError(null); - const response = await authenticatedFetch(api.tenants.admin(tenantId, adminUpn), getAccessToken, { + await fetchOk(api.tenants.admin(tenantId, adminUpn), getAccessToken, { method: "DELETE", }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || `Failed to remove admin: ${response.statusText}`); - } - setSuccessMessage(`Admin ${adminUpn} removed successfully!`); // Refresh admin list @@ -144,12 +119,8 @@ export function TenantAdminSection({ // Auto-hide success message after 3 seconds setTimeout(() => setSuccessMessage(null), 3000); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while removing tenant admin"); - } else { - console.error("Error removing tenant admin:", err); - } - setError(err instanceof Error ? err.message : "Failed to remove admin"); + console.error("Error removing tenant admin:", err); + setError(apiErrorText(err, "Failed to remove admin")); } finally { setRemovingAdmin(null); } @@ -162,19 +133,7 @@ export function TenantAdminSection({ setError(null); const action = isEnabled ? 'disable' : 'enable'; - const response = await authenticatedFetch(api.tenants.adminAction(tenantId, adminUpn, action), getAccessToken, { - method: "PATCH", - }); - - if (!response.ok) { - let errorData; - try { - errorData = await response.json(); - } catch { - errorData = { error: `Failed to ${action} admin: ${response.statusText}` }; - } - throw new Error(errorData.error || `Failed to ${action} admin: ${response.statusText}`); - } + await fetchOk(api.tenants.adminAction(tenantId, adminUpn, action), getAccessToken, { method: "PATCH" }); setSuccessMessage(`Admin ${adminUpn} ${isEnabled ? 'disabled' : 'enabled'} successfully!`); @@ -184,12 +143,8 @@ export function TenantAdminSection({ // Auto-hide success message after 3 seconds setTimeout(() => setSuccessMessage(null), 3000); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while toggling tenant admin"); - } else { - console.error("Error toggling tenant admin:", err); - } - setError(err instanceof Error ? err.message : "Failed to toggle admin"); + console.error("Error toggling tenant admin:", err); + setError(apiErrorText(err, "Failed to toggle admin")); } finally { setTogglingAdmin(null); } @@ -201,22 +156,11 @@ export function TenantAdminSection({ setTogglingAdmin(adminUpn); setError(null); - const response = await authenticatedFetch(api.tenants.adminPermissions(tenantId, adminUpn), getAccessToken, { + await fetchOk(api.tenants.adminPermissions(tenantId, adminUpn), getAccessToken, { method: "PATCH", - headers: { "Content-Type": "application/json" }, body: JSON.stringify({ role, canManageBootstrapTokens }), }); - if (!response.ok) { - let errorData; - try { - errorData = await response.json(); - } catch { - errorData = { error: `Failed to update permissions: ${response.statusText}` }; - } - throw new Error(errorData.error || `Failed to update permissions: ${response.statusText}`); - } - setSuccessMessage(`Permissions for ${adminUpn} updated successfully!`); // Refresh admin list @@ -225,12 +169,8 @@ export function TenantAdminSection({ // Auto-hide success message after 3 seconds setTimeout(() => setSuccessMessage(null), 3000); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while updating member permissions"); - } else { - console.error("Error updating member permissions:", err); - } - setError(err instanceof Error ? err.message : "Failed to update permissions"); + console.error("Error updating member permissions:", err); + setError(apiErrorText(err, "Failed to update permissions")); } finally { setTogglingAdmin(null); } diff --git a/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx index e33d850c..8b547a5a 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx @@ -1,10 +1,10 @@ "use client"; import { Suspense, useState, useEffect } from "react"; -import type { DelegatedSlotUsageResponse, PlanTierDefinitionsResponse } from "@/utils/wire-types.generated"; +import type { AppHomingDeniedResponse, DelegatedSlotUsageResponse, GetPreviewNotificationEmailResponse, OffboardResponse, PlanTierDefinitionsResponse, PreviewWhitelistActionResponse, SetTenantPlanTierResponse, UpdateTenantAppHomingResponse, UpdateTenantConfigurationResponse } from "@/utils/wire-types.generated"; import { useSearchParams } from "next/navigation"; import { api } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { ApiError, apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient"; import { classifyClientId, legacyConfigured } from "@/lib/authApp"; import { appHomingErrorMessage } from "@/lib/appHoming"; import { trackEvent } from "@/lib/appInsights"; @@ -168,22 +168,17 @@ function TenantManagementSectionInner({ try { setReleasingHold(invitationId); setError(null); - const response = await authenticatedFetch(api.delegatedSlots.releaseHold(tenantId), getAccessToken, { + await fetchOk(api.delegatedSlots.releaseHold(tenantId), getAccessToken, { method: "POST", - headers: { "Content-Type": "application/json" }, body: JSON.stringify({ invitationId }), }); - if (!response.ok) { - const data = await response.json().catch(() => ({})); - throw new Error(data.error || `Failed to release the hold: ${response.statusText}`); - } setSlotUsage((prev) => prev ? { ...prev, holds: prev.holds.filter((h) => h.invitationId !== invitationId), used: Math.max(0, prev.used - 1) } : prev); setSuccessMessage("Slot hold released — the managing tenant can invite again now."); setTimeout(() => setSuccessMessage(null), 4000); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to release the hold"); + setError(apiErrorText(err, "Failed to release the hold")); } finally { setReleasingHold(null); } @@ -192,10 +187,9 @@ function TenantManagementSectionInner({ useEffect(() => { if (!editingTenantId) return; let cancelled = false; - authenticatedFetch(api.delegatedSlots.get(editingTenantId), getAccessToken) - .then(async (res) => { - if (cancelled) return; - setSlotUsage(res.ok ? ((await res.json()) as DelegatedSlotUsageResponse) : null); + fetchJson(api.delegatedSlots.get(editingTenantId), getAccessToken) + .then((usage) => { + if (!cancelled) setSlotUsage(usage); }) .catch(() => { if (!cancelled) setSlotUsage(null); @@ -209,11 +203,9 @@ function TenantManagementSectionInner({ // with just the stored value + "edition default", so the editor stays usable. if (!editingTenantId) return; let cancelled = false; - authenticatedFetch(api.mcpUsage.planTiers(), getAccessToken) - .then(async (res) => { - if (cancelled) return; - const data = res.ok ? ((await res.json()) as PlanTierDefinitionsResponse) : null; - setUsagePlanNames(data ? data.tiers.map((t) => t.name.trim().toLowerCase()).filter(Boolean) : null); + fetchJson(api.mcpUsage.planTiers(), getAccessToken) + .then((data) => { + if (!cancelled) setUsagePlanNames(data.tiers.map((t) => t.name.trim().toLowerCase()).filter(Boolean)); }) .catch(() => { if (!cancelled) setUsagePlanNames(null); @@ -276,18 +268,11 @@ function TenantManagementSectionInner({ setError(null); setSuccessMessage(null); - const response = await authenticatedFetch(api.config.tenant(tenant.tenantId), getAccessToken, { + const result = await fetchJson(api.config.tenant(tenant.tenantId), getAccessToken, { method: "PUT", - headers: { "Content-Type": "application/json" }, body: JSON.stringify(tenant), }); - if (!response.ok) { - throw new Error(`Failed to save tenant configuration: ${response.statusText}`); - } - - const result = await response.json(); - // Update tenant in list setTenants(prev => prev.map(t => t.tenantId === tenant.tenantId ? result.config : t)); setEditingTenant(null); @@ -296,12 +281,8 @@ function TenantManagementSectionInner({ // Auto-hide success message after 3 seconds setTimeout(() => setSuccessMessage(null), 3000); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while saving tenant configuration"); - } else { - console.error("Error saving tenant configuration:", err); - } - setError(err instanceof Error ? err.message : "Failed to save tenant configuration"); + console.error("Error saving tenant configuration:", err); + setError(apiErrorText(err, "Failed to save tenant configuration")); } finally { setSavingTenant(false); } @@ -316,9 +297,8 @@ function TenantManagementSectionInner({ setError(null); setSuccessMessage(null); - const response = await authenticatedFetch(api.config.plan(tenant.tenantId), getAccessToken, { + const result = await fetchJson(api.config.plan(tenant.tenantId), getAccessToken, { method: "PATCH", - headers: { "Content-Type": "application/json" }, body: JSON.stringify({ // The select only offers the two canonical write values (community/pro); legacy stored // "enterprise" normalizes to "pro" on save, legacy "free" to "community". @@ -329,13 +309,6 @@ function TenantManagementSectionInner({ payingCustomer: tenant.payingCustomer === true, }), }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.error || `Failed to save plan: ${response.statusText}`); - } - - const result = await response.json(); const apply = (t: TenantConfiguration): TenantConfiguration => ({ ...t, planTier: result.planTier, @@ -351,10 +324,7 @@ function TenantManagementSectionInner({ setSuccessMessage(`Plan saved — effective edition: ${effectiveEditionLabel(result.effectiveEdition, result.editionSource)}`); setTimeout(() => setSuccessMessage(null), 4000); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while saving plan"); - } - setError(err instanceof Error ? err.message : "Failed to save plan"); + setError(apiErrorText(err, "Failed to save plan")); } finally { setSavingPlan(false); } @@ -369,22 +339,20 @@ function TenantManagementSectionInner({ setHomingError(null); setSuccessMessage(null); - const response = await authenticatedFetch(api.config.appHoming(tenant.tenantId), getAccessToken, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ target, force }), - }); - const data = await response.json().catch(() => ({})); - if (!response.ok) { - trackEvent("app_homing_manual_flip_failed", { - tenantId: tenant.tenantId, - target, - force, - reason: data.code ?? data.reason ?? `http-${response.status}`, + let data: UpdateTenantAppHomingResponse; + try { + data = await fetchJson(api.config.appHoming(tenant.tenantId), getAccessToken, { + method: "POST", + body: JSON.stringify({ target, force }), }); - // `code` is the error-envelope field (AppHomingDeniedResponse); `reason` is the pre-envelope - // name, read until the backend deploy that precedes this web build is everywhere. - throw new Error(appHomingErrorMessage(data.code ?? data.reason, response.statusText, data.probe?.missingRoles)); + } catch (err) { + if (err instanceof ApiError) { + trackEvent("app_homing_manual_flip_failed", { tenantId: tenant.tenantId, target, force, reason: err.code || `http-${err.status}` }); + // AppHomingDeniedResponse: the envelope prefix plus the probe that names the missing roles. + const denied = err.body as Partial | null; + throw new Error(appHomingErrorMessage(err.code, err.message, denied?.probe?.missingRoles)); + } + throw err; } trackEvent("app_homing_manual_flip", { tenantId: tenant.tenantId, @@ -409,10 +377,7 @@ function TenantManagementSectionInner({ ); setTimeout(() => setSuccessMessage(null), 4000); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while switching app homing"); - } - setHomingError(err instanceof Error ? err.message : "Failed to switch app registration"); + setHomingError(apiErrorText(err, "Failed to switch app registration")); } finally { setSavingHoming(false); } @@ -428,16 +393,15 @@ function TenantManagementSectionInner({ setOffboardError(null); setSuccessMessage(null); - const response = await authenticatedFetch(api.tenants.offboard(tenant.tenantId), getAccessToken, { - method: "DELETE", - }); - const data = await response.json().catch(() => ({})); - if (!response.ok) { + let data: OffboardResponse; + try { + data = await fetchJson(api.tenants.offboard(tenant.tenantId), getAccessToken, { method: "DELETE" }); + } catch (err) { trackEvent("admin_tenant_offboard_failed", { tenantId: tenant.tenantId, - reason: data.error ?? `http-${response.status}`, + reason: err instanceof ApiError ? err.code || `http-${err.status}` : "network", }); - throw new Error(data.error || `Failed to offboard tenant: ${response.statusText}`); + throw err; } trackEvent("admin_tenant_offboarded", { tenantId: tenant.tenantId, @@ -452,12 +416,8 @@ function TenantManagementSectionInner({ setSuccessMessage(data.message || `Offboarding queued for tenant ${tenant.tenantId}`); setTimeout(() => setSuccessMessage(null), 8000); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while offboarding tenant"); - } else { - console.error("Error offboarding tenant:", err); - } - setOffboardError(err instanceof Error ? err.message : "Failed to offboard tenant"); + console.error("Error offboarding tenant:", err); + setOffboardError(apiErrorText(err, "Failed to offboard tenant")); } finally { setOffboarding(false); } @@ -470,34 +430,24 @@ function TenantManagementSectionInner({ setError(null); setSuccessMessage(null); - const response = await authenticatedFetch( + const result = await fetchJson( api.preview.sendWelcomeEmail(tenantId), getAccessToken, { method: "POST", - headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: email || "" }), } ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.error || `Failed to send welcome email: ${response.statusText}`); - } - - const result = await response.json(); // The send persisted the address too — keep the search map in step so the tenant is // findable by it right away instead of only after the next list refresh. - if (result.email) { - setNotificationEmails(prev => ({ ...prev, [tenantId.toLowerCase()]: result.email })); + const sentTo = result.email; + if (sentTo) { + setNotificationEmails(prev => ({ ...prev, [tenantId.toLowerCase()]: sentTo })); } setSuccessMessage(`Welcome email sent to ${result.email}`); setTimeout(() => setSuccessMessage(null), 4000); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while sending welcome email"); - } - setError(err instanceof Error ? err.message : "Failed to send welcome email"); + setError(apiErrorText(err, "Failed to send welcome email")); } finally { setSendingWelcomeEmail(false); } @@ -512,14 +462,7 @@ function TenantManagementSectionInner({ const isCurrentlyApproved = previewApproved.has(tenantId); - const response = await authenticatedFetch(api.preview.whitelistTenant(tenantId), getAccessToken, { - method: isCurrentlyApproved ? "DELETE" : "POST", - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.error || `Failed to update preview access: ${response.statusText}`); - } + await fetchOk(api.preview.whitelistTenant(tenantId), getAccessToken, { method: isCurrentlyApproved ? "DELETE" : "POST" }); setPreviewApproved(prev => { const next = new Set(prev); @@ -538,10 +481,7 @@ function TenantManagementSectionInner({ ); setTimeout(() => setSuccessMessage(null), 4000); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while updating preview access"); - } - setError(err instanceof Error ? err.message : "Failed to update preview access"); + setError(apiErrorText(err, "Failed to update preview access")); } finally { setTogglingPreviewTenant(null); } @@ -739,14 +679,8 @@ function TenantManagementSectionInner({ setNotificationEmail( notificationEmailFor(notificationEmails, tenant.tenantId) ?? ""); try { - const resp = await authenticatedFetch( - api.preview.notificationEmailTenant(tenant.tenantId), - getAccessToken - ); - if (resp.ok) { - const data = await resp.json(); - setNotificationEmail(data.email || ""); - } + const data = await fetchJson(api.preview.notificationEmailTenant(tenant.tenantId), getAccessToken); + setNotificationEmail(data.email || ""); } catch { /* best-effort */ } }} className="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors" diff --git a/src/Web/autopilot-monitor-web/app/admin/components/UnmappedSoftwareTab.tsx b/src/Web/autopilot-monitor-web/app/admin/components/UnmappedSoftwareTab.tsx index 7f76fde5..4266b386 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/UnmappedSoftwareTab.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/UnmappedSoftwareTab.tsx @@ -3,11 +3,12 @@ import { sessionUrl } from "@/lib/routes"; import { useCallback, useEffect, useState } from "react"; import { api } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient"; import TruncatedLabel from "@/components/TruncatedLabel"; import { TableSkeleton } from "@/components/skeletons/TableSkeleton"; import { trackEvent } from "@/lib/appInsights"; import type { UnmatchedSoftwareEntry, AutoResolveResult } from "./SoftwareMappingTypes"; +import type { GetUnmatchedSoftwareResponse } from "@/utils/wire-types.generated"; interface UnmappedSoftwareTabProps { getAccessToken: () => Promise; @@ -69,16 +70,10 @@ export function UnmappedSoftwareTab({ setLoading(true); setError(null); - const response = await authenticatedFetch( + const data = await fetchJson( api.vulnerability.unmatchedSoftware(page * pageSize, pageSize), getAccessToken ); - - if (!response.ok) { - throw new Error(`Failed to load unmatched software: ${response.statusText}`); - } - - const data = await response.json(); const items: UnmatchedSoftwareEntry[] = data.software || []; const serverTotal: number = data.total ?? items.length; setEntries(items); @@ -86,12 +81,8 @@ export function UnmappedSoftwareTab({ setInitialLoaded(true); return serverTotal; } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while fetching unmatched software"); - } else { - console.error("Error fetching unmatched software:", err); - } - setError(err instanceof Error ? err.message : "Failed to load unmatched software"); + console.error("Error fetching unmatched software:", err); + setError(apiErrorText(err, "Failed to load unmatched software")); return null; } finally { setLoading(false); @@ -157,6 +148,7 @@ export function UnmappedSoftwareTab({ return "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300"; }; + const shortSessionId = (id?: string) => (id && id.length > 8 ? `${id.slice(0, 8)}...` : id ?? ""); const getRowKey = (entry: UnmatchedSoftwareEntry): string => `${entry.softwareName}::${entry.publisher}`; @@ -183,12 +175,11 @@ export function UnmappedSoftwareTab({ setSavingMapping(key); setError(null); - const response = await authenticatedFetch( + await fetchOk( api.vulnerability.cpeMapping(), getAccessToken, { method: "POST", - headers: { "Content-Type": "application/json" }, body: JSON.stringify({ normalizedProduct: entry.softwareName, normalizedVendor: entry.publisher || "", @@ -199,21 +190,12 @@ export function UnmappedSoftwareTab({ } ); - if (!response.ok) { - const data = await response.json().catch(() => null); - throw new Error(data?.message || `Failed to save mapping: ${response.statusText}`); - } - setSavedMappings((prev) => new Set(prev).add(key)); setExpandedMappingRow(null); onMappingChanged(); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while saving CPE mapping"); - } else { - console.error("Error saving CPE mapping:", err); - } - setError(err instanceof Error ? err.message : "Failed to save CPE mapping"); + console.error("Error saving CPE mapping:", err); + setError(apiErrorText(err, "Failed to save CPE mapping")); } finally { setSavingMapping(null); } @@ -278,12 +260,11 @@ export function UnmappedSoftwareTab({ failedSoFar: cumulativeFailed.length, }); - const response = await authenticatedFetch( + const data = await fetchJson( api.vulnerability.cpeAutoResolve(), getAccessToken, { method: "POST", - headers: { "Content-Type": "application/json" }, body: JSON.stringify({ items: batches[i].map((e) => ({ softwareName: e.softwareName, @@ -293,12 +274,6 @@ export function UnmappedSoftwareTab({ }), } ); - - if (!response.ok) { - throw new Error(`Batch ${i + 1} failed: ${response.statusText}`); - } - - const data: AutoResolveResult = await response.json(); cumulativeResolved.push(...data.resolved); cumulativeFailed.push(...data.failed); @@ -325,7 +300,7 @@ export function UnmappedSoftwareTab({ totalFailed: cumulativeFailed.length.toString(), }); } catch (err) { - setError(err instanceof Error ? err.message : "Auto-resolve failed"); + setError(apiErrorText(err, "Auto-resolve failed")); } finally { setAutoResolving(false); setAutoResolveProgress(null); @@ -339,20 +314,18 @@ export function UnmappedSoftwareTab({ try { setIgnoringRow(key); setError(null); - const response = await authenticatedFetch(api.vulnerability.ignoredSoftware(), getAccessToken, { + await fetchOk(api.vulnerability.ignoredSoftware(), getAccessToken, { method: "POST", - headers: { "Content-Type": "application/json" }, body: JSON.stringify({ items: [{ softwareName: entry.softwareName, publisher: entry.publisher || "", reason: "manual" }], }), }); - if (!response.ok) throw new Error(`Failed to ignore software: ${response.statusText}`); setSelectedEntries((prev) => { const n = new Map(prev); n.delete(key); return n; }); onIgnored(); await refreshCurrentPage(); } catch (err) { console.error("Error ignoring software:", err); - setError(err instanceof Error ? err.message : "Failed to ignore software"); + setError(apiErrorText(err, "Failed to ignore software")); } finally { setIgnoringRow(null); } @@ -365,20 +338,18 @@ export function UnmappedSoftwareTab({ try { setIgnoringRow("bulk"); setError(null); - const response = await authenticatedFetch(api.vulnerability.ignoredSoftware(), getAccessToken, { + await fetchOk(api.vulnerability.ignoredSoftware(), getAccessToken, { method: "POST", - headers: { "Content-Type": "application/json" }, body: JSON.stringify({ items: selectedList.map((e) => ({ softwareName: e.softwareName, publisher: e.publisher || "", reason: "manual" })), }), }); - if (!response.ok) throw new Error(`Failed to ignore software: ${response.statusText}`); setSelectedEntries(new Map()); onIgnored(); await refreshCurrentPage(); } catch (err) { console.error("Error bulk ignoring software:", err); - setError(err instanceof Error ? err.message : "Failed to ignore software"); + setError(apiErrorText(err, "Failed to ignore software")); } finally { setIgnoringRow(null); } @@ -571,7 +542,7 @@ export function UnmappedSoftwareTab({ /> )}
- +
{entry.publisher || unknown}
@@ -581,17 +552,15 @@ export function UnmappedSoftwareTab({
- {formatDate(entry.lastSeenAt)} + {entry.lastSeenAt ? formatDate(entry.lastSeenAt) : "—"}
@@ -615,7 +584,7 @@ export function UnmappedSoftwareTab({ Map { try { setLoadingVersions(true); - const response = await authenticatedFetch(api.versions.blocked(), getAccessToken); - if (!response.ok) throw new Error(`Failed to load version blocks: ${response.statusText}`); - const data = await response.json(); + const data = await fetchJson(api.versions.blocked(), getAccessToken); setBlockedVersions(data.rules ?? []); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while loading version blocks"); - } - setError(err instanceof Error ? err.message : "Failed to load version blocks"); + setError(apiErrorText(err, "Failed to load version blocks")); } finally { setLoadingVersions(false); } @@ -63,17 +59,14 @@ export function VersionBlockSection({ try { setAddingRule(true); setError(null); - const response = await authenticatedFetch(api.versions.block(), getAccessToken, { + await fetchOk(api.versions.block(), getAccessToken, { method: "POST", - headers: { "Content-Type": "application/json" }, body: JSON.stringify({ versionPattern: versionPattern.trim(), action: versionAction, reason: versionReason.trim() || undefined, }), }); - const result = await response.json(); - if (!response.ok) throw new Error(result.message || response.statusText); setSuccessMessage( versionAction === "Kill" ? `Version pattern "${versionPattern.trim()}" set to kill.` @@ -85,10 +78,7 @@ export function VersionBlockSection({ setVersionAction("Block"); if (blockedVersions.length > 0) await fetchBlockedVersions(); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while adding version block"); - } - setError(err instanceof Error ? err.message : "Failed to add version block rule"); + setError(apiErrorText(err, "Failed to add version block rule")); } finally { setAddingRule(false); } @@ -99,21 +89,12 @@ export function VersionBlockSection({ try { setRemovingPattern(pattern); setError(null); - const response = await authenticatedFetch( - api.versions.unblock(pattern), - getAccessToken, - { method: "DELETE" } - ); - const result = await response.json(); - if (!response.ok) throw new Error(result.message || response.statusText); + await fetchOk(api.versions.unblock(pattern), getAccessToken, { method: "DELETE" }); setSuccessMessage(`Version pattern "${pattern}" removed.`); setTimeout(() => setSuccessMessage(null), 3000); setBlockedVersions((prev) => prev.filter((v) => v.versionPattern !== pattern)); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while removing version block"); - } - setError(err instanceof Error ? err.message : "Failed to remove version block rule"); + setError(apiErrorText(err, "Failed to remove version block rule")); } finally { setRemovingPattern(null); } diff --git a/src/Web/autopilot-monitor-web/app/admin/components/VulnerabilityDataSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/VulnerabilityDataSection.tsx index b5e54376..f8e579ca 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/VulnerabilityDataSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/VulnerabilityDataSection.tsx @@ -2,12 +2,13 @@ import { useCallback, useEffect, useState } from "react"; import { api } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { apiErrorText, fetchJson } from "@/lib/apiClient"; import SaveResetBar from "../../settings/components/SaveResetBar"; import VulnerabilityExposurePanel from "@/components/VulnerabilityExposurePanel"; -import type { AdminConfiguration } from "@/types/adminConfig"; +import { fromWireAdminConfiguration, type AdminConfiguration } from "@/types/adminConfig"; import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform"; import { SectionCardHeader } from "@/components/SectionCardHeader"; +import type { AdminConfiguration as WireAdminConfiguration, GetVulnerabilitySyncStatusResponse, TriggerEpssSyncResponse, TriggerMsrcSyncResponse, TriggerNvdCacheRefreshResponse, TriggerVulnerabilityDataSyncResponse, UpdateAdminConfigurationResponse } from "@/utils/wire-types.generated"; interface VulnerabilityTopCve { cveId: string; @@ -91,9 +92,7 @@ export function VulnerabilityDataSection({ const refreshSyncStatus = useCallback(async () => { try { - const response = await authenticatedFetch(api.vulnerability.syncStatus(), getAccessToken); - if (!response.ok) return; - const data = await response.json(); + const data = await fetchJson(api.vulnerability.syncStatus(), getAccessToken); setSyncStatus({ kevCatalogEntries: data.kevCatalogEntries, msrcIndexedCves: data.msrcIndexedCves, @@ -125,18 +124,19 @@ export function VulnerabilityDataSection({ return () => window.clearInterval(id); }, [nvdRunning, refreshSyncStatus]); + // Non-critical: after a sync, pull the admin config again for the new timestamps. + const refreshAdminConfig = useCallback(async () => { + try { + onAdminConfigUpdated(fromWireAdminConfiguration(await fetchJson(api.globalConfig.get(), getAccessToken))); + } catch { + // Non-critical: config refresh after sync + } + }, [getAccessToken, onAdminConfigUpdated]); + const refreshSummary = useCallback(async () => { setLoadingSummary(true); try { - const response = await authenticatedFetch( - api.metrics.globalVulnerability(summaryDays, 10), - getAccessToken, - ); - if (!response.ok) { - setSummary(null); - return; - } - setSummary((await response.json()) as VulnerabilitySummary); + setSummary(await fetchJson(api.metrics.globalVulnerability(summaryDays, 10), getAccessToken)); } catch { // Non-critical: the overview is auxiliary; config + sync still render. setSummary(null); @@ -179,25 +179,15 @@ export function VulnerabilityDataSection({ vulnerabilityCorrelationEnabled, }; - const response = await authenticatedFetch(api.globalConfig.get(), getAccessToken, { + const result = await fetchJson(api.globalConfig.get(), getAccessToken, { method: "PUT", - headers: { "Content-Type": "application/json" }, body: JSON.stringify(updatedConfig), }); - - if (!response.ok) { - throw new Error(`Failed to save vulnerability configuration: ${response.statusText}`); - } - - const result = await response.json(); - onAdminConfigUpdated(result.config); + onAdminConfigUpdated(fromWireAdminConfiguration(result.config)); setSuccessMessage("Vulnerability configuration saved successfully!"); setTimeout(() => setSuccessMessage(null), 4000); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired while saving vulnerability configuration"); - } - const message = err instanceof Error ? err.message : "Failed to save vulnerability configuration"; + const message = apiErrorText(err, "Failed to save vulnerability configuration"); setError(message); throw err; // Let SaveResetBar show "Save failed" } finally { @@ -217,47 +207,29 @@ export function VulnerabilityDataSection({ setError(null); try { - const response = await authenticatedFetch( + const data = await fetchJson( api.vulnerability.sync(), getAccessToken, { method: "POST" } ); - - if (!response.ok) { - throw new Error(`Sync failed: ${response.statusText}`); - } - - const data = await response.json(); setSyncResult({ success: true, message: data.message || "Sync completed", syncedAt: data.syncedAt, }); - // Refresh admin config to get updated timestamp - try { - const configResponse = await authenticatedFetch(api.globalConfig.get(), getAccessToken); - if (configResponse.ok) { - const updatedConfig = await configResponse.json(); - onAdminConfigUpdated(updatedConfig); - } - } catch { - // Non-critical: config refresh after sync - } + await refreshAdminConfig(); void refreshSyncStatus(); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired during vulnerability sync"); - } setSyncResult({ success: false, - message: err instanceof Error ? err.message : "Sync failed", + message: apiErrorText(err, "Sync failed"), }); - setError(err instanceof Error ? err.message : "Vulnerability data sync failed"); + setError(apiErrorText(err, "Vulnerability data sync failed")); } finally { setSyncing(false); } - }, [getAccessToken, setError, onAdminConfigUpdated, refreshSyncStatus]); + }, [getAccessToken, setError, refreshAdminConfig, refreshSyncStatus]); const handleSyncMsrc = useCallback(async () => { setSyncingMsrc(true); @@ -265,46 +237,29 @@ export function VulnerabilityDataSection({ setError(null); try { - const response = await authenticatedFetch( + const data = await fetchJson( api.vulnerability.syncMsrc(), getAccessToken, { method: "POST" } ); - - if (!response.ok) { - throw new Error(`MSRC sync failed: ${response.statusText}`); - } - - const data = await response.json(); setSyncResult({ success: !!data.success, message: data.message || (data.success ? "MSRC index refreshed" : "MSRC sync failed"), syncedAt: data.syncedAt, }); - try { - const configResponse = await authenticatedFetch(api.globalConfig.get(), getAccessToken); - if (configResponse.ok) { - const updatedConfig = await configResponse.json(); - onAdminConfigUpdated(updatedConfig); - } - } catch { - // Non-critical: config refresh after sync - } + await refreshAdminConfig(); void refreshSyncStatus(); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired during MSRC sync"); - } setSyncResult({ success: false, - message: err instanceof Error ? err.message : "MSRC sync failed", + message: apiErrorText(err, "MSRC sync failed"), }); - setError(err instanceof Error ? err.message : "MSRC sync failed"); + setError(apiErrorText(err, "MSRC sync failed")); } finally { setSyncingMsrc(false); } - }, [getAccessToken, setError, onAdminConfigUpdated, refreshSyncStatus]); + }, [getAccessToken, setError, refreshAdminConfig, refreshSyncStatus]); // NVD cache refresh + EPSS re-score share one shape: POST, show the backend's message, // pull the admin config (timestamps) and the sync status (counts) afterwards. @@ -317,42 +272,27 @@ export function VulnerabilityDataSection({ setSyncResult(null); setError(null); try { - const response = await authenticatedFetch( + const data = await fetchJson( kind === "nvd" ? api.vulnerability.syncNvd() : api.vulnerability.syncEpss(), getAccessToken, { method: "POST" } ); - const data = await response.json().catch(() => ({})); - if (!response.ok) { - // 409 = already running: informational, not an error banner. - throw new Error(data.message || `${label} failed: ${response.statusText}`); - } setSyncResult({ success: !!data.success, message: data.message || `${label} completed`, - syncedAt: data.syncedAt ?? data.startedAt, + syncedAt: "syncedAt" in data ? data.syncedAt : data.startedAt, }); - try { - const configResponse = await authenticatedFetch(api.globalConfig.get(), getAccessToken); - if (configResponse.ok) { - onAdminConfigUpdated(await configResponse.json()); - } - } catch { - // Non-critical: config refresh after sync - } + await refreshAdminConfig(); void refreshSyncStatus(); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error(`Session expired during ${label}`); - } setSyncResult({ success: false, - message: err instanceof Error ? err.message : `${label} failed`, + message: apiErrorText(err, `${label} failed`), }); } finally { setBusy(false); } - }, [getAccessToken, setError, onAdminConfigUpdated, refreshSyncStatus]); + }, [getAccessToken, setError, refreshAdminConfig, refreshSyncStatus]); const handleSyncNvd = useCallback(() => runCacheSync("nvd", setSyncingNvd), [runCacheSync]); const handleSyncEpss = useCallback(() => runCacheSync("epss", setSyncingEpss), [runCacheSync]); @@ -364,17 +304,11 @@ export function VulnerabilityDataSection({ setError(null); try { - const response = await authenticatedFetch( + const data = await fetchJson( api.vulnerability.syncReseed(type), getAccessToken, { method: "POST" } ); - - if (!response.ok) { - throw new Error(`Re-seed failed: ${response.statusText}`); - } - - const data = await response.json(); const count = isSeed ? (data.cpeSeedEntries ?? 0) : (data.cpeCommunityEntries ?? 0); const errorMsg = isSeed ? data.cpeSeedError : data.cpeCommunityError; @@ -386,30 +320,18 @@ export function VulnerabilityDataSection({ syncedAt: data.syncedAt, }); - // Refresh admin config to get updated timestamp - try { - const configResponse = await authenticatedFetch(api.globalConfig.get(), getAccessToken); - if (configResponse.ok) { - const updatedConfig = await configResponse.json(); - onAdminConfigUpdated(updatedConfig); - } - } catch { - // Non-critical - } + await refreshAdminConfig(); void refreshSyncStatus(); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired during vulnerability reseed"); - } setSyncResult({ success: false, - message: err instanceof Error ? err.message : "Re-seed failed", + message: apiErrorText(err, "Re-seed failed"), }); - setError(err instanceof Error ? err.message : "Vulnerability data re-seed failed"); + setError(apiErrorText(err, "Vulnerability data re-seed failed")); } finally { if (isSeed) setReseedingSeed(false); else setReseedingCommunity(false); } - }, [getAccessToken, setError, onAdminConfigUpdated, refreshSyncStatus]); + }, [getAccessToken, setError, refreshAdminConfig, refreshSyncStatus]); const formatTimestamp = (ts: string | undefined): string => { if (!ts) return "Never"; diff --git a/src/Web/autopilot-monitor-web/app/admin/customs-archive/detail/page.tsx b/src/Web/autopilot-monitor-web/app/admin/customs-archive/detail/page.tsx index 70d73a5b..4b282810 100644 --- a/src/Web/autopilot-monitor-web/app/admin/customs-archive/detail/page.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/customs-archive/detail/page.tsx @@ -9,10 +9,11 @@ import { type CustomsArchiveFullEntry, type CustomsArchiveListEntriesResponse, } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient"; import { useAdminConfig } from "../../AdminConfigContext"; import { AdminNotifications } from "../../AdminNotifications"; import { DeleteConfirmModal } from "../components/DeleteConfirmModal"; +import type { CustomsArchiveEntryResponse } from "@/utils/wire-types.generated"; export default function CustomsArchiveDetailPage() { // useSearchParams() in CustomsArchiveDetailContent requires a Suspense boundary @@ -40,18 +41,10 @@ function CustomsArchiveDetailContent() { try { setLoading(true); const url = api.customsArchive.listEntries(tenantId, historyRowKey); - const res = await authenticatedFetch(url, getAccessToken); - if (!res.ok) { - throw new Error(`List entries failed: ${res.status} ${res.statusText}`); - } - const body = (await res.json()) as CustomsArchiveListEntriesResponse; + const body = await fetchJson(url, getAccessToken); setEntries(body.entries ?? []); } catch (err) { - if (err instanceof TokenExpiredError) { - setError(err.message); - } else { - setError(err instanceof Error ? err.message : String(err)); - } + setError(apiErrorText(err)); } finally { setLoading(false); } @@ -78,12 +71,10 @@ function CustomsArchiveDetailContent() { setExpanded((prev) => ({ ...prev, [key]: "loading" })); try { - const res = await authenticatedFetch( + const body = await fetchJson( api.customsArchive.getEntry(tenantId, historyRowKey, entry.rowKey), getAccessToken, ); - if (!res.ok) throw new Error(`Get entry failed: ${res.status}`); - const body = await res.json(); setExpanded((prev) => ({ ...prev, [key]: body.entry as CustomsArchiveFullEntry })); } catch (err) { setExpanded((prev) => { @@ -91,7 +82,7 @@ function CustomsArchiveDetailContent() { delete next[key]; return next; }); - setError(err instanceof Error ? err.message : String(err)); + setError(apiErrorText(err)); } }, [expanded, tenantId, historyRowKey, getAccessToken, setError]); @@ -100,17 +91,16 @@ function CustomsArchiveDetailContent() { if (!entry) return; try { setDeletingRk(entry.rowKey); - const res = await authenticatedFetch( + await fetchOk( api.customsArchive.deleteEntry(tenantId, historyRowKey, entry.rowKey), getAccessToken, { method: "DELETE" }, ); - if (!res.ok) throw new Error(`Delete failed: ${res.status}`); setSuccessMessage(`Deleted ${entry.originalTable} / ${entry.originalRowKey}`); setPendingDelete(null); await load(); } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + setError(apiErrorText(err)); } finally { setDeletingRk(null); } diff --git a/src/Web/autopilot-monitor-web/app/admin/customs-archive/page.tsx b/src/Web/autopilot-monitor-web/app/admin/customs-archive/page.tsx index cdf8fb3d..5503759d 100644 --- a/src/Web/autopilot-monitor-web/app/admin/customs-archive/page.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/customs-archive/page.tsx @@ -8,10 +8,11 @@ import { type CustomsArchiveListRunsResponse, type CustomsArchiveRunSummary, } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { apiErrorText, fetchJson } from "@/lib/apiClient"; import { useAdminConfig } from "../AdminConfigContext"; import { AdminNotifications } from "../AdminNotifications"; import { DeleteConfirmModal } from "./components/DeleteConfirmModal"; +import type { CustomsArchiveDeleteRunResponse } from "@/utils/wire-types.generated"; export default function CustomsArchivePage() { const { getAccessToken, setError, setSuccessMessage } = useAdminConfig(); @@ -25,18 +26,10 @@ export default function CustomsArchivePage() { try { setLoading(true); const url = api.customsArchive.listRuns({ tenantId: filter.trim() || undefined }); - const res = await authenticatedFetch(url, getAccessToken); - if (!res.ok) { - throw new Error(`List runs failed: ${res.status} ${res.statusText}`); - } - const body = (await res.json()) as CustomsArchiveListRunsResponse; + const body = await fetchJson(url, getAccessToken); setRuns(body.runs ?? []); } catch (err) { - if (err instanceof TokenExpiredError) { - setError(err.message); - } else { - setError(err instanceof Error ? err.message : String(err)); - } + setError(apiErrorText(err)); } finally { setLoading(false); } @@ -54,24 +47,16 @@ export default function CustomsArchivePage() { if (!run) return; try { setDeletingPk(run.partitionKey); - const res = await authenticatedFetch( + const body = await fetchJson( api.customsArchive.deleteRun(run.tenantId, run.historyRowKey), getAccessToken, { method: "DELETE" } ); - if (!res.ok) { - throw new Error(`Delete run failed: ${res.status} ${res.statusText}`); - } - const body = await res.json(); setSuccessMessage(`Deleted ${body.deleted ?? 0} archived rules.`); setPendingDelete(null); await load(); } catch (err) { - if (err instanceof TokenExpiredError) { - setError(err.message); - } else { - setError(err instanceof Error ? err.message : String(err)); - } + setError(apiErrorText(err)); } finally { setDeletingPk(null); } diff --git a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionAgentMetrics.tsx b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionAgentMetrics.tsx index 38871634..26ffaa5a 100644 --- a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionAgentMetrics.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionAgentMetrics.tsx @@ -4,8 +4,7 @@ import { useEffect, useState, useMemo, useCallback } from 'react'; import { api } from '@/lib/api'; import TruncatedLabel from '@/components/TruncatedLabel'; import { useAuth } from '../../../../contexts/AuthContext'; -import { useNotifications } from '../../../../contexts/NotificationContext'; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { ApiError, apiErrorText, fetchJson } from "@/lib/apiClient"; // ── Types ────────────────────────────────────────────────────────────────────── @@ -138,7 +137,6 @@ function pN(values: number[], percentile: number): number { export function SectionAgentMetrics() { const { getAccessToken } = useAuth(); - const { addNotification } = useNotifications(); const [loading, setLoading] = useState(true); const [sessionMetrics, setSessionMetrics] = useState([]); @@ -161,18 +159,7 @@ export function SectionAgentMetrics() { setError(null); try { - const res = await authenticatedFetch(api.metrics.platform({ limit, days }), getAccessToken); - - if (!res.ok) { - if (res.status === 403) { - setError('Access denied. Global Admin privileges required.'); - } else { - setError(`Failed to fetch platform metrics: ${res.status}`); - } - return; - } - - const data: PlatformMetricsResponse = await res.json(); + const data = await fetchJson(api.metrics.platform({ limit, days }), getAccessToken); setCacheInfo({ fromCache: data.fromCache, computeDurationMs: data.computeDurationMs, computedAt: data.computedAt }); setDeliveryLatency(data.deliveryLatency || null); setCrashRate(data.crashRate || null); @@ -208,16 +195,15 @@ export function SectionAgentMetrics() { setSessionMetrics(mapped); } catch (err) { - if (err instanceof TokenExpiredError) { - addNotification('error', 'Session Expired', err.message, 'session-expired-error'); + if (err instanceof ApiError && err.status === 403) { + setError('Access denied. Global Admin privileges required.'); } else { - console.error('Platform metrics fetch error:', err); - setError(err instanceof Error ? err.message : 'Unknown error'); + setError(apiErrorText(err, 'Unknown error')); } } finally { setLoading(false); } - }, [getAccessToken, addNotification]); + }, [getAccessToken]); // Refetch whenever the user picks a new sample size or window (both drive // backend params, not a client-side slice). fetchMetrics is intentionally diff --git a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionImePatternHealth.tsx b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionImePatternHealth.tsx index 8246fad8..06615d54 100644 --- a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionImePatternHealth.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionImePatternHealth.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; import { useAuth } from "../../../../contexts/AuthContext"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { apiErrorText, fetchJson, nullOn404 } from "@/lib/apiClient"; import { api } from "@/lib/api"; import { buildMatrix, @@ -41,20 +41,10 @@ export function SectionImePatternHealth() { setLoading(true); setError(null); try { - const res = await authenticatedFetch(api.metrics.globalImePatternHealth(), getAccessToken); - if (res.status === 404) { - // Deploy skew: backend without the route yet — reads as "no rows", never as an error. - setData(null); - return; - } - if (!res.ok) throw new Error(`IME pattern health: ${res.status}`); - setData((await res.json()) as ImePatternHealthResponse); + // Deploy skew: a backend without the route yet (404) reads as "no rows", never as an error. + setData(await fetchJson(api.metrics.globalImePatternHealth(), getAccessToken).catch(nullOn404)); } catch (err) { - if (err instanceof TokenExpiredError) { - setError("Session expired. Please refresh the page."); - } else { - setError(err instanceof Error ? err.message : "Failed to load IME pattern health"); - } + setError(apiErrorText(err, "Failed to load IME pattern health")); } finally { setLoading(false); } diff --git a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionMcpUsage.tsx b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionMcpUsage.tsx index d932445e..b064cff2 100644 --- a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionMcpUsage.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionMcpUsage.tsx @@ -3,9 +3,10 @@ import { SegmentedControl, TIME_RANGE_OPTIONS } from "@/components/SegmentedControl"; import { useCallback, useEffect, useState } from "react"; import { useAuth } from "../../../../contexts/AuthContext"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { apiErrorText, fetchJson } from "@/lib/apiClient"; import { api } from "@/lib/api"; import { isApplicationKey, principalLabel } from "@/utils/principalKeys"; +import type { GetGlobalMcpUsageDailyResponse, GetGlobalMcpUsageResponse, UserUsageDailySummary } from "@/utils/wire-types.generated"; interface UsageRecord { userId: string; @@ -17,13 +18,8 @@ interface UsageRecord { lastRequestAt: string; } -interface DailySummary { - date: string; - tenantId: string | null; - totalRequests: number; - uniqueUsers: number; - uniqueEndpoints: number; -} +/** One per-day usage row (wire shape). */ +type DailySummary = UserUsageDailySummary; type DateRange = "7d" | "30d" | "90d"; @@ -66,25 +62,15 @@ export function SectionMcpUsage() { const dateFrom = getDateFrom(range); const dateTo = getDateTo(); - const [globalRes, dailyRes] = await Promise.all([ - authenticatedFetch(api.mcpUsage.global(undefined, dateFrom, dateTo), getAccessToken), - authenticatedFetch(api.mcpUsage.daily(undefined, dateFrom, dateTo), getAccessToken), + const [globalData, dailyData] = await Promise.all([ + fetchJson(api.mcpUsage.global(undefined, dateFrom, dateTo), getAccessToken), + fetchJson(api.mcpUsage.daily(undefined, dateFrom, dateTo), getAccessToken), ]); - if (!globalRes.ok) throw new Error(`Global usage: ${globalRes.status}`); - if (!dailyRes.ok) throw new Error(`Daily usage: ${dailyRes.status}`); - - const globalData = await globalRes.json(); - const dailyData = await dailyRes.json(); - setRecords(globalData.records || []); setDailySummaries(dailyData.summaries || []); } catch (err) { - if (err instanceof TokenExpiredError) { - setError("Session expired. Please refresh the page."); - } else { - setError(err instanceof Error ? err.message : "Failed to fetch usage data"); - } + setError(apiErrorText(err, "Failed to fetch usage data")); } finally { setLoading(false); } diff --git a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionPlatformUsage.tsx b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionPlatformUsage.tsx index f089765e..8901daab 100644 --- a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionPlatformUsage.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionPlatformUsage.tsx @@ -4,100 +4,16 @@ import { useCallback, useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { api } from '@/lib/api'; import { useAuth } from '../../../../contexts/AuthContext'; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { apiErrorText, fetchJson } from "@/lib/apiClient"; import { useFetchProgress } from "@/hooks/useFetchProgress"; import { CalculatingCard } from "@/components/CalculatingCard"; +import type { PlatformUsageMetrics } from "@/utils/wire-types.generated"; // A fresh cross-tenant compute takes tens of seconds server-side; the default 30s fetch timeout // aborted it client-side (HTTP 499 on the backend) while the server kept computing. Give this one // call generous headroom — the backend caches the result for 15 minutes afterwards. const FETCH_TIMEOUT_MS = 180_000; -interface SessionMetrics { - total: number; - today: number; - last7Days: number; - last30Days: number; - succeeded: number; - failed: number; - inProgress: number; - incomplete: number; - successRate: number; -} - -interface TenantMetrics { - total: number; - active7Days: number; - active30Days: number; -} - -interface UserMetrics { - total: number; - dailyLogins: number; - active7Days: number; - active30Days: number; - note: string; -} - -interface PerformanceMetrics { - avgDurationMinutes: number; - medianDurationMinutes: number; - p95DurationMinutes: number; - p99DurationMinutes: number; -} - -interface HardwareCount { - name: string; - count: number; - percentage: number; -} - -interface HardwareMetrics { - topManufacturers: HardwareCount[]; - topModels: HardwareCount[]; -} - -interface DeploymentTypeMetrics { - userDriven: number; - whiteGlove: number; - userDrivenPercentage: number; - whiteGlovePercentage: number; -} - -interface PlatformStats { - totalEnrollments: number; - totalUsers: number; - totalTenants: number; - totalSignedUpTenants: number; - uniqueDeviceModels: number; - totalEventsProcessed: number; - successfulEnrollments: number; - issuesDetected: number; -} - -interface AppScriptMetrics { - avgAppsPerSession: number; - totalUniqueApps: number; - avgPlatformScriptsPerSession: number; - avgRemediationScriptsPerSession: number; - totalPlatformScripts: number; - totalRemediationScripts: number; -} - -interface PlatformUsageMetrics { - sessions: SessionMetrics; - tenants: TenantMetrics; - users: UserMetrics; - performance: PerformanceMetrics; - hardware: HardwareMetrics; - deploymentTypes: DeploymentTypeMetrics; - appScripts?: AppScriptMetrics; - platformStats?: PlatformStats; - windowDays: number; - computedAt: string; - computeDurationMs: number; - fromCache: boolean; -} export function SectionPlatformUsage() { const router = useRouter(); @@ -124,27 +40,17 @@ export function SectionPlatformUsage() { progressBegin(); // Platform-wide metrics - cross-tenant (Global Admin only) - const response = await authenticatedFetch(api.metrics.globalUsage(), getAccessToken, { + const data = await fetchJson(api.metrics.globalUsage(), getAccessToken, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); - - if (!response.ok) { - throw new Error(`Failed to fetch platform usage metrics: ${response.statusText}`); - } - - const data: PlatformUsageMetrics = await response.json(); setMetrics(data); recordEstimate = !data.fromCache; } catch (err) { - if (err instanceof TokenExpiredError) { - console.error('Session expired:', err.message); - } else { - console.error('Error fetching platform usage metrics:', err); - } + console.error('Error fetching platform usage metrics:', err); if (err instanceof DOMException && (err.name === 'TimeoutError' || err.name === 'AbortError')) { setError('The calculation is taking unusually long. It continues on the server and the result is cached once finished — please retry in a moment.'); } else { - setError(err instanceof Error ? err.message : 'Failed to fetch platform usage metrics'); + setError(apiErrorText(err, 'Failed to fetch platform usage metrics')); } } finally { progressFinish(recordEstimate); diff --git a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionVerdictCalibration.tsx b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionVerdictCalibration.tsx index ec494bc2..2748d377 100644 --- a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionVerdictCalibration.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionVerdictCalibration.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { useAuth } from "../../../../contexts/AuthContext"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { apiErrorText, fetchJson, nullOn404 } from "@/lib/apiClient"; import { api } from "@/lib/api"; import { useAggregatedAdminScope } from "@/hooks"; import { TenantScopeSelector } from "@/components/TenantScopeSelector"; @@ -38,23 +38,12 @@ export function SectionVerdictCalibration() { setLoading(true); setError(null); try { - const res = await authenticatedFetch( - api.metrics.globalVerdictCalibration(RANGE_DAYS[range], tenantId || undefined), - getAccessToken + // Deploy skew: a backend without the route yet (404) reads as "no rows", never as an error. + setData( + await fetchJson(api.metrics.globalVerdictCalibration(RANGE_DAYS[range], tenantId || undefined), getAccessToken).catch(nullOn404), ); - if (res.status === 404) { - // Deploy skew: backend without the route yet — reads as "no rows", never as an error. - setData(null); - return; - } - if (!res.ok) throw new Error(`Verdict calibration: ${res.status}`); - setData((await res.json()) as VerdictCalibrationResponse); } catch (err) { - if (err instanceof TokenExpiredError) { - setError("Session expired. Please refresh the page."); - } else { - setError(err instanceof Error ? err.message : "Failed to load verdict calibration"); - } + setError(apiErrorText(err, "Failed to load verdict calibration")); } finally { setLoading(false); } diff --git a/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/DeletionManifestSummaryView.tsx b/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/DeletionManifestSummaryView.tsx index 1c665f3b..088e2c53 100644 --- a/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/DeletionManifestSummaryView.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/DeletionManifestSummaryView.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { api } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { apiErrorText, fetchBlob, fetchJson } from "@/lib/apiClient"; /** * Reusable stored-manifest summary block. Used both inside the modal (DeletionPreviewModal, @@ -67,23 +67,14 @@ export function DeletionManifestSummaryView({ setError(null); setSummary(null); try { - const resp = await authenticatedFetch( + const json = await fetchJson( api.sessionDeletions.storedManifest(sessionId, tenantId, manifestId, "summary"), getAccessToken, ); - if (!resp.ok) { - const detail = await resp.text().catch(() => ""); - throw new Error(`Stored-manifest request failed: HTTP ${resp.status}${detail ? ` — ${detail.slice(0, 200)}` : ""}`); - } - const json = (await resp.json()) as StoredManifestSummary; if (!cancelled) setSummary(json); } catch (err) { if (cancelled) return; - if (err instanceof TokenExpiredError) { - setError("Session expired; reload the page and try again."); - } else { - setError(err instanceof Error ? err.message : String(err)); - } + setError(apiErrorText(err)); } finally { if (!cancelled) setLoading(false); } @@ -97,12 +88,7 @@ export function DeletionManifestSummaryView({ setDownloading(true); setError(null); try { - const resp = await authenticatedFetch( - api.sessionDeletions.storedManifest(sessionId, tenantId, manifestId, "download"), - getAccessToken, - ); - if (!resp.ok) throw new Error(`Download failed: HTTP ${resp.status}`); - const blob = await resp.blob(); + const blob = await fetchBlob(api.sessionDeletions.storedManifest(sessionId, tenantId, manifestId, "download"), getAccessToken); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; @@ -112,11 +98,7 @@ export function DeletionManifestSummaryView({ a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); } catch (err) { - if (err instanceof TokenExpiredError) { - setError("Session expired; reload the page and try again."); - } else { - setError(err instanceof Error ? err.message : String(err)); - } + setError(apiErrorText(err)); } finally { setDownloading(false); } diff --git a/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/MaintenanceStatusBanner.tsx b/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/MaintenanceStatusBanner.tsx index 61835d3b..087271c1 100644 --- a/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/MaintenanceStatusBanner.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/MaintenanceStatusBanner.tsx @@ -2,7 +2,8 @@ import { useCallback, useEffect, useState } from "react"; import { api } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { TokenExpiredError } from "@/lib/authenticatedFetch"; +import { ApiError, apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient"; interface OpsEvent { id: string; @@ -64,12 +65,10 @@ export function MaintenanceStatusBanner({ const fetchStatus = useCallback(async () => { try { - const r = await authenticatedFetch( + const data = await fetchJson( api.opsEvents.list("Maintenance", { pageSize: 100 }), getAccessToken, ); - if (!r.ok) throw new Error(`HTTP ${r.status} loading maintenance ops events`); - const data = (await r.json()) as OpsEventsResponse; const lifecycle = (data.events ?? []).filter((e) => e.eventType.startsWith("SessionDeletionMaintenance"), @@ -129,24 +128,17 @@ export function MaintenanceStatusBanner({ const triggerRun = useCallback(async () => { setTriggering(true); try { - const r = await authenticatedFetch(api.sessionDeletions.triggerMaintenance(), getAccessToken, { - method: "POST", - }); - if (r.status === 202) { - setSuccessMessage("Maintenance run queued — it will appear here as active shortly."); - } else if (r.status === 409) { - setSuccessMessage("A maintenance run is already active."); - } else { - const body = await r.text(); - throw new Error(`HTTP ${r.status}: ${body}`); - } + // 202 = queued; 409 = a run is already active (not an error for the operator). + const queued = await fetchOk(api.sessionDeletions.triggerMaintenance(), getAccessToken, { method: "POST" }) + .then(() => true) + .catch((err: unknown) => { + if (err instanceof ApiError && err.status === 409) return false; + throw err; + }); + setSuccessMessage(queued ? "Maintenance run queued — it will appear here as active shortly." : "A maintenance run is already active."); setRefreshKey((k) => k + 1); } catch (err) { - if (err instanceof TokenExpiredError) { - setError("Session expired; reload the page and try again."); - } else { - setError(err instanceof Error ? err.message : String(err)); - } + setError(apiErrorText(err)); } finally { setTriggering(false); } diff --git a/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/RestoreBrowserTab.tsx b/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/RestoreBrowserTab.tsx index 64e80e72..2f6f46b6 100644 --- a/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/RestoreBrowserTab.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/RestoreBrowserTab.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { api } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { apiErrorText, fetchJson } from "@/lib/apiClient"; import { useAdminConfig } from "../../../AdminConfigContext"; import { DeletionManifestSummaryView } from "./DeletionManifestSummaryView"; import { RestoreConfirmDialog } from "./RestoreConfirmDialog"; @@ -88,27 +88,17 @@ export function RestoreBrowserTab({ getAccessToken, setError, setSuccessMessage (async () => { setLoadingRestoreFilter(true); try { - const resp = await authenticatedFetch( + const json = await fetchJson( api.sessionDeletions.tenantsWithManifests(), getAccessToken, { signal: controller.signal }, ); if (cancelled) return; - if (!resp.ok) { - const detail = await resp.text().catch(() => ""); - throw new Error(`HTTP ${resp.status}${detail ? ` — ${detail.slice(0, 200)}` : ""}`); - } - const json = (await resp.json()) as TenantsWithManifestsResponse; - if (cancelled) return; setTenantsWithRestore(new Set(json.tenantIds ?? [])); } catch (err) { if (cancelled) return; if (err instanceof DOMException && err.name === "AbortError") return; - if (err instanceof TokenExpiredError) { - setError("Session expired; reload the page and try again."); - } else { - setError(err instanceof Error ? err.message : String(err)); - } + setError(apiErrorText(err)); // On fetch failure leave the filter unchecked so the operator isn't stranded with an // empty dropdown — the toggle won't visually persist as "on" without data backing it. setOnlyWithRestore(false); @@ -161,18 +151,12 @@ export function RestoreBrowserTab({ getAccessToken, setError, setSuccessMessage setLoadingSessions(true); try { - const resp = await authenticatedFetch( + const json = await fetchJson( api.sessionDeletions.tenantManifests(selectedTenantId), getAccessToken, { signal: controller.signal }, ); if (cancelled) return; - if (!resp.ok) { - const detail = await resp.text().catch(() => ""); - throw new Error(`HTTP ${resp.status}${detail ? ` — ${detail.slice(0, 200)}` : ""}`); - } - const json = (await resp.json()) as TenantManifestsResponse; - if (cancelled) return; // Pin the response to the tenantId it was fetched for. The render path keys off // loadedTenantId === selectedTenantId, so a late response that no longer matches the // current dropdown is invisible to the operator even if it sneaks through. @@ -183,11 +167,7 @@ export function RestoreBrowserTab({ getAccessToken, setError, setSuccessMessage // AbortError is the expected outcome when the effect re-runs before the previous fetch // resolved (tenant switched, refresh hit, component unmounted) — swallow silently. if (err instanceof DOMException && err.name === "AbortError") return; - if (err instanceof TokenExpiredError) { - setError("Session expired; reload the page and try again."); - } else { - setError(err instanceof Error ? err.message : String(err)); - } + setError(apiErrorText(err)); setSessions([]); setLoadedTenantId(selectedTenantId); } finally { diff --git a/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/RestoreConfirmDialog.tsx b/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/RestoreConfirmDialog.tsx index 2edd58ba..eda66125 100644 --- a/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/RestoreConfirmDialog.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/RestoreConfirmDialog.tsx @@ -2,7 +2,8 @@ import { useState } from "react"; import { api } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { apiErrorText, fetchJson } from "@/lib/apiClient"; +import type { SessionRestoreResponse } from "@/utils/wire-types.generated"; interface RestoreConfirmDialogProps { tenantId: string; @@ -46,12 +47,11 @@ export function RestoreConfirmDialog({ setSubmitting(true); setError(null); try { - const resp = await authenticatedFetch( + const body = await fetchJson( api.sessionDeletions.restore(sessionId), getAccessToken, { method: "POST", - headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tenantId, manifestId, @@ -60,10 +60,6 @@ export function RestoreConfirmDialog({ }), }, ); - const body = await resp.json().catch(() => ({})); - if (!resp.ok) { - throw new Error(body?.message ?? `Restore failed: HTTP ${resp.status}`); - } const view = parseRestoreResult(body); if (dryRun) { setDryRunResult(view); @@ -72,11 +68,7 @@ export function RestoreConfirmDialog({ onRestored(); } } catch (err) { - if (err instanceof TokenExpiredError) { - setError("Session expired; reload the page and try again."); - } else { - setError(err instanceof Error ? err.message : String(err)); - } + setError(apiErrorText(err)); } finally { setSubmitting(false); } @@ -275,26 +267,17 @@ function ResultSection({ ); } -function parseRestoreResult(body: Record): RestoreResultView { +function parseRestoreResult(body: SessionRestoreResponse): RestoreResultView { return { - outcome: typeof body.outcome === "string" ? body.outcome : "Unknown", - mode: typeof body.mode === "string" ? body.mode : null, - message: typeof body.message === "string" ? body.message : null, - currentState: typeof body.currentState === "string" ? body.currentState : null, - pendingManifestId: typeof body.pendingManifestId === "string" ? body.pendingManifestId : null, - rowsRestoredByTable: isStringNumberMap(body.rowsRestoredByTable) ? body.rowsRestoredByTable : {}, - rowsSkippedByTable: isStringNumberMap(body.rowsSkippedByTable) ? body.rowsSkippedByTable : {}, - wouldRestoreByTable: isStringNumberMap(body.wouldRestoreByTable) ? body.wouldRestoreByTable : {}, - inventoryReIncrements: typeof body.inventoryReIncrements === "number" ? body.inventoryReIncrements : 0, - durationMs: typeof body.durationMs === "number" ? body.durationMs : 0, + outcome: body.outcome || "Unknown", + mode: body.mode ?? null, + message: body.message ?? null, + currentState: body.currentState ?? null, + pendingManifestId: body.pendingManifestId ?? null, + rowsRestoredByTable: body.rowsRestoredByTable ?? {}, + rowsSkippedByTable: body.rowsSkippedByTable ?? {}, + wouldRestoreByTable: body.wouldRestoreByTable ?? {}, + inventoryReIncrements: body.inventoryReIncrements ?? 0, + durationMs: body.durationMs ?? 0, }; } - -function isStringNumberMap(value: unknown): value is Record { - return ( - typeof value === "object" - && value !== null - && !Array.isArray(value) - && Object.values(value).every((v) => typeof v === "number") - ); -} diff --git a/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/page.tsx b/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/page.tsx index a9dc3f41..4051b4dd 100644 --- a/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/page.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/page.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; import { api } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { apiErrorText, fetchJson } from "@/lib/apiClient"; import { useAdminConfig } from "../../AdminConfigContext"; import { AdminNotifications } from "../../AdminNotifications"; import { DeletionPreviewModal } from "./components/DeletionPreviewModal"; @@ -154,10 +154,7 @@ function InFlightTab({ try { const results = await Promise.all( IN_FLIGHT_STATES.map((state) => - authenticatedFetch(api.sessionDeletions.list(state), getAccessToken).then(async (r) => { - if (!r.ok) throw new Error(`HTTP ${r.status} for state=${state}`); - return (await r.json()) as SessionDeletionListResponse; - }), + fetchJson(api.sessionDeletions.list(state), getAccessToken), ), ); const all = results.flatMap((r) => r.sessions); @@ -165,11 +162,7 @@ function InFlightTab({ all.sort((a, b) => b.ageMinutes - a.ageMinutes); setRows(all); } catch (err) { - if (err instanceof TokenExpiredError) { - setError("Session expired; reload the page and try again."); - } else { - setError(err instanceof Error ? err.message : String(err)); - } + setError(apiErrorText(err)); } finally { setLoading(false); } @@ -239,17 +232,11 @@ function PoisonedTab({ const fetchPoisoned = useCallback(async () => { setLoading(true); try { - const resp = await authenticatedFetch(api.sessionDeletions.list("Poisoned"), getAccessToken); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const json = (await resp.json()) as SessionDeletionListResponse; + const json = await fetchJson(api.sessionDeletions.list("Poisoned"), getAccessToken); const sorted = [...json.sessions].sort((a, b) => b.ageMinutes - a.ageMinutes); setRows(sorted); } catch (err) { - if (err instanceof TokenExpiredError) { - setError("Session expired; reload the page and try again."); - } else { - setError(err instanceof Error ? err.message : String(err)); - } + setError(apiErrorText(err)); } finally { setLoading(false); } @@ -339,20 +326,14 @@ function StrandedTab({ const fetchStranded = useCallback(async () => { setLoading(true); try { - const resp = await authenticatedFetch( + const json = await fetchJson( api.sessionDeletions.list("Queued", STRANDED_THRESHOLD_MINUTES), getAccessToken, ); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const json = (await resp.json()) as SessionDeletionListResponse; const sorted = [...json.sessions].sort((a, b) => b.ageMinutes - a.ageMinutes); setRows(sorted); } catch (err) { - if (err instanceof TokenExpiredError) { - setError("Session expired; reload the page and try again."); - } else { - setError(err instanceof Error ? err.message : String(err)); - } + setError(apiErrorText(err)); } finally { setLoading(false); } diff --git a/src/Web/autopilot-monitor-web/app/admin/presence/page.tsx b/src/Web/autopilot-monitor-web/app/admin/presence/page.tsx index 8856c34f..ece0ad2b 100644 --- a/src/Web/autopilot-monitor-web/app/admin/presence/page.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/presence/page.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from "react"; import { api } from "@/lib/api"; import { useLatest } from "@/hooks/useLatest"; import { useAuth } from "../../../contexts/AuthContext"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { apiErrorText, fetchJson } from "@/lib/apiClient"; const REFRESH_INTERVAL_MS = 30_000; const WINDOW_OPTIONS = [5, 15, 30, 60]; @@ -68,20 +68,12 @@ export default function PresencePage() { else setLoading(true); setError(null); - const response = await authenticatedFetch(api.metrics.activeUsers(windowRef.current), getAccessToken); - if (!response.ok) { - throw new Error(`Failed to fetch active users: ${response.statusText}`); - } - const json: PresenceResponse = await response.json(); + const json = await fetchJson(api.metrics.activeUsers(windowRef.current), getAccessToken); setData(json); setLastUpdated(new Date()); } catch (err) { - if (err instanceof TokenExpiredError) { - console.error("Session expired:", err.message); - } else { - console.error("Error fetching active users:", err); - } - setError(err instanceof Error ? err.message : "Failed to fetch active users"); + console.error("Error fetching active users:", err); + setError(apiErrorText(err, "Failed to fetch active users")); } finally { setLoading(false); setRefreshing(false); diff --git a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionDelegatedAdmins.tsx b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionDelegatedAdmins.tsx index 8d5143e2..71fe67da 100644 --- a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionDelegatedAdmins.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionDelegatedAdmins.tsx @@ -2,14 +2,14 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useAuth } from "@/contexts/AuthContext"; -import { useNotifications } from "@/contexts/NotificationContext"; import { useTenantList } from "@/hooks/useTenantList"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { ApiError, apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient"; import { api } from "@/lib/api"; import { HOME_TENANT_UNRESOLVED } from "@/lib/identityBinding"; import { parseSlotLimitError, type SlotLimitError } from "@/lib/delegatedSlots"; import { DelegatedSlotPrompt, raiseDelegatedSlotLimit } from "@/components/DelegatedSlotPrompt"; import { SectionCardHeader } from "@/components/SectionCardHeader"; +import type { DelegatedAdminListResponse } from "@/utils/wire-types.generated"; /** One delegated-admin assignment as returned by /api/global/delegated-admins (camelCase JSON). */ interface DelegatedAssignment { @@ -36,7 +36,6 @@ const ROLE_LABELS: Record = { */ export function SectionDelegatedAdmins() { const { getAccessToken } = useAuth(); - const { addNotification } = useNotifications(); const tenants = useTenantList(true); const [assignments, setAssignments] = useState([]); @@ -68,27 +67,18 @@ export function SectionDelegatedAdmins() { setTimeout(() => setSuccessMessage(null), 3000); }; - const handleError = (err: unknown, fallback: string) => { - if (err instanceof TokenExpiredError) { - addNotification("error", "Session Expired", err.message, "session-expired-error"); - } else { - setError(err instanceof Error ? err.message : fallback); - } - }; + const handleError = (err: unknown, fallback: string) => setError(apiErrorText(err, fallback)); const fetchAssignments = useCallback(async () => { try { setLoading(true); - const response = await authenticatedFetch(api.delegatedAdmins.list(), getAccessToken); - if (!response.ok) throw new Error(`Failed to load delegated admins: ${response.statusText}`); - const data = await response.json(); + const data = await fetchJson(api.delegatedAdmins.list(), getAccessToken); setAssignments(data.assignments ?? []); } catch (err) { handleError(err, "Failed to load delegated admins"); } finally { setLoading(false); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [getAccessToken]); useEffect(() => { @@ -104,28 +94,27 @@ export function SectionDelegatedAdmins() { try { setGranting(true); setError(null); - const response = await authenticatedFetch(api.delegatedAdmins.grant(), getAccessToken, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - upn, - tenantId: newTenantId, - role: newRole, - homeTenantId: needHomeTenant ? homeTenantPick : undefined, - }), - }); - if (!response.ok) { - const data = await response.json().catch(() => ({})); - if (response.status === 422 && data.code === HOME_TENANT_UNRESOLVED) { - setNeedHomeTenant(true); + try { + await fetchOk(api.delegatedAdmins.grant(), getAccessToken, { + method: "POST", + body: JSON.stringify({ + upn, + tenantId: newTenantId, + role: newRole, + homeTenantId: needHomeTenant ? homeTenantPick : undefined, + }), + }); + } catch (err) { + if (err instanceof ApiError) { + if (err.status === 422 && err.code === HOME_TENANT_UNRESOLVED) setNeedHomeTenant(true); + const slot = parseSlotLimitError(err.status, err.body); + if (slot) { + // Keep the form as-is; the prompt's "raise & retry" re-runs this grant. + setSlotPrompt(slot); + return; + } } - const slot = parseSlotLimitError(response.status, data); - if (slot) { - // Keep the form as-is; the prompt's "raise & retry" re-runs this grant. - setSlotPrompt(slot); - return; - } - throw new Error(data.error || `Failed to grant: ${response.statusText}`); + throw err; } setSlotPrompt(null); const dom = domainOf(newTenantId); @@ -141,7 +130,6 @@ export function SectionDelegatedAdmins() { } finally { setGranting(false); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [newUpn, newTenantId, newRole, needHomeTenant, homeTenantPick, getAccessToken, fetchAssignments, domainOf]); const handleRaiseSlots = useCallback(async (newLimit: number) => { @@ -166,11 +154,7 @@ export function SectionDelegatedAdmins() { const url = a.isEnabled ? api.delegatedAdmins.disable(a.upn, a.tenantId) : api.delegatedAdmins.enable(a.upn, a.tenantId); - const response = await authenticatedFetch(url, getAccessToken, { method: "PATCH" }); - if (!response.ok) { - const data = await response.json().catch(() => ({})); - throw new Error(data.error || `Failed to update: ${response.statusText}`); - } + await fetchOk(url, getAccessToken, { method: "PATCH" }); flash(`${a.upn} ${a.isEnabled ? "disabled" : "enabled"} on ${domainOf(a.tenantId) || a.tenantId}.`); await fetchAssignments(); } catch (err) { @@ -178,7 +162,6 @@ export function SectionDelegatedAdmins() { } finally { setBusyKey(null); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [getAccessToken, fetchAssignments, domainOf]); const handleRevoke = useCallback(async (a: DelegatedAssignment) => { @@ -188,13 +171,9 @@ export function SectionDelegatedAdmins() { try { setBusyKey(key); setError(null); - const response = await authenticatedFetch(api.delegatedAdmins.revoke(a.upn, a.tenantId), getAccessToken, { + await fetchOk(api.delegatedAdmins.revoke(a.upn, a.tenantId), getAccessToken, { method: "DELETE", }); - if (!response.ok) { - const data = await response.json().catch(() => ({})); - throw new Error(data.error || `Failed to revoke: ${response.statusText}`); - } flash(`Revoked ${a.upn}'s access to ${dom}.`); await fetchAssignments(); } catch (err) { @@ -202,7 +181,6 @@ export function SectionDelegatedAdmins() { } finally { setBusyKey(null); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [getAccessToken, fetchAssignments, domainOf]); // Group rows by delegated admin (UPN) so a multi-tenant MSP reads as one block. diff --git a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionEmailTemplates.tsx b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionEmailTemplates.tsx index 4c99b79d..19948840 100644 --- a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionEmailTemplates.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionEmailTemplates.tsx @@ -2,9 +2,10 @@ import { useCallback, useEffect, useState } from "react"; import { api } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { fetchJson, fetchOk } from "@/lib/apiClient"; import { useAdminConfig } from "../../AdminConfigContext"; import { AdminNotifications } from "../../AdminNotifications"; +import type { EmailTemplateTestSendResponse } from "@/utils/wire-types.generated"; type TemplateKind = "welcome" | "farewell"; @@ -87,20 +88,14 @@ function TemplateCard({ kind, title, description, getAccessToken, setError, setS const [previewHtml, setPreviewHtml] = useState(null); const fail = useCallback((err: unknown, fallback: string) => { - if (err instanceof TokenExpiredError) { - console.error("Session expired", err); - } else { - console.error(fallback, err); - } + console.error(fallback, err); setError(err instanceof Error ? err.message : fallback); }, [setError]); const load = useCallback(async () => { try { setLoading(true); - const response = await authenticatedFetch(api.emailTemplates.get(kind), getAccessToken); - if (!response.ok) throw new Error(`Failed to load ${kind} template: ${response.statusText}`); - const data = (await response.json()) as TemplateState; + const data = await fetchJson(api.emailTemplates.get(kind), getAccessToken); setState(data); setDraft(data.html); } catch (err) { @@ -125,15 +120,10 @@ function TemplateCard({ kind, title, description, getAccessToken, setError, setS try { setBusy("save"); setError(null); - const response = await authenticatedFetch(api.emailTemplates.save(kind), getAccessToken, { + await fetchOk(api.emailTemplates.save(kind), getAccessToken, { method: "PUT", - headers: { "Content-Type": "application/json" }, body: JSON.stringify({ html: draft }), }); - if (!response.ok) { - const body = await response.json().catch(() => null); - throw new Error(body?.error ?? `Failed to save ${kind} template: ${response.statusText}`); - } flash(`${title} saved — real sends use the customized HTML from now on.`); setEditing(false); await load(); @@ -149,8 +139,7 @@ function TemplateCard({ kind, title, description, getAccessToken, setError, setS try { setBusy("reset"); setError(null); - const response = await authenticatedFetch(api.emailTemplates.reset(kind), getAccessToken, { method: "DELETE" }); - if (!response.ok) throw new Error(`Failed to reset ${kind} template: ${response.statusText}`); + await fetchOk(api.emailTemplates.reset(kind), getAccessToken, { method: "DELETE" }); flash(`${title} reset to the built-in template.`); setEditing(false); await load(); @@ -165,13 +154,10 @@ function TemplateCard({ kind, title, description, getAccessToken, setError, setS try { setBusy("test"); setError(null); - const response = await authenticatedFetch(api.emailTemplates.sendTest(kind), getAccessToken, { + const body = await fetchJson(api.emailTemplates.sendTest(kind), getAccessToken, { method: "POST", - headers: { "Content-Type": "application/json" }, body: JSON.stringify(useDraft ? { html: draft } : {}), }); - const body = await response.json().catch(() => null); - if (!response.ok) throw new Error(body?.error ?? `Failed to send ${kind} test email: ${response.statusText}`); flash(`Test ${title.toLowerCase()} sent to ${body?.sentTo ?? "your tenant contact address"}${useDraft ? " (unsaved draft)" : ""}.`); } catch (err) { fail(err, `Failed to send ${kind} test email`); diff --git a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionTenantGroups.tsx b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionTenantGroups.tsx index 8fa605bd..4652913d 100644 --- a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionTenantGroups.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionTenantGroups.tsx @@ -2,13 +2,12 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useAuth } from "@/contexts/AuthContext"; -import { useNotifications } from "@/contexts/NotificationContext"; import { useTenantList } from "@/hooks/useTenantList"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { ApiError, apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient"; import { api } from "@/lib/api"; import { HOME_TENANT_UNRESOLVED } from "@/lib/identityBinding"; import { SectionCardHeader } from "@/components/SectionCardHeader"; -import type { TenantGroup } from "@/utils/wire-types.generated"; +import type { TenantGroup, TenantGroupListResponse } from "@/utils/wire-types.generated"; import { parseSlotLimitError, type SlotLimitError } from "@/lib/delegatedSlots"; import { DelegatedSlotPrompt, raiseDelegatedSlotLimit } from "@/components/DelegatedSlotPrompt"; @@ -30,7 +29,6 @@ const ROLE_LABELS: Record = { */ export function SectionTenantGroups() { const { getAccessToken } = useAuth(); - const { addNotification } = useNotifications(); const tenants = useTenantList(true); const [groups, setGroups] = useState([]); @@ -64,27 +62,18 @@ export function SectionTenantGroups() { setTimeout(() => setSuccessMessage(null), 3000); }; - const handleError = (err: unknown, fallback: string) => { - if (err instanceof TokenExpiredError) { - addNotification("error", "Session Expired", err.message, "session-expired-error"); - } else { - setError(err instanceof Error ? err.message : fallback); - } - }; + const handleError = (err: unknown, fallback: string) => setError(apiErrorText(err, fallback)); const fetchGroups = useCallback(async () => { try { setLoading(true); - const response = await authenticatedFetch(api.tenantGroups.list(), getAccessToken); - if (!response.ok) throw new Error(`Failed to load groups: ${response.statusText}`); - const data = await response.json(); + const data = await fetchJson(api.tenantGroups.list(), getAccessToken); setGroups(data.groups ?? []); } catch (err) { handleError(err, "Failed to load groups"); } finally { setLoading(false); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [getAccessToken]); useEffect(() => { @@ -101,22 +90,21 @@ export function SectionTenantGroups() { setBusyKey(key); setError(null); const init: RequestInit = { method }; - if (body !== undefined) { - init.headers = { "Content-Type": "application/json" }; - init.body = JSON.stringify(body); - } - const response = await authenticatedFetch(url, getAccessToken, init); - if (!response.ok) { - const data = await response.json().catch(() => ({})); - if (response.status === 422 && data.code === HOME_TENANT_UNRESOLVED && key.startsWith("assign:")) { - setNeedHomeTenant((p) => ({ ...p, [key.slice("assign:".length)]: true })); + if (body !== undefined) init.body = JSON.stringify(body); + try { + await fetchOk(url, getAccessToken, init); + } catch (err) { + if (err instanceof ApiError) { + if (err.status === 422 && err.code === HOME_TENANT_UNRESOLVED && key.startsWith("assign:")) { + setNeedHomeTenant((p) => ({ ...p, [key.slice("assign:".length)]: true })); + } + const slot = parseSlotLimitError(err.status, err.body); + if (slot) { + setSlotPrompt({ ...slot, retry: { key, url, method, body, ok } }); + return false; + } } - const slot = parseSlotLimitError(response.status, data); - if (slot) { - setSlotPrompt({ ...slot, retry: { key, url, method, body, ok } }); - return false; - } - throw new Error(data.error || `Request failed: ${response.statusText}`); + throw err; } setSlotPrompt(null); flash(ok); @@ -129,7 +117,6 @@ export function SectionTenantGroups() { setBusyKey(null); } }, - // eslint-disable-next-line react-hooks/exhaustive-deps [getAccessToken, fetchGroups], ); diff --git a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionUsagePlans.tsx b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionUsagePlans.tsx index 1fe02444..4a6c6785 100644 --- a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionUsagePlans.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionUsagePlans.tsx @@ -3,9 +3,10 @@ import { useCallback, useEffect, useState } from "react"; import { useAuth } from "../../../../contexts/AuthContext"; import { useAdminConfig } from "../../AdminConfigContext"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { apiErrorText, fetchJson } from "@/lib/apiClient"; import { api } from "@/lib/api"; import { TableSkeleton } from "@/components/skeletons/TableSkeleton"; +import type { PlanTierDefinitionsResponse } from "@/utils/wire-types.generated"; interface PlanTierDefinition { name: string; @@ -51,17 +52,11 @@ export function SectionUsagePlans() { try { setLoading(true); setError(null); - const res = await authenticatedFetch(api.mcpUsage.planTiers(), getAccessToken); - if (!res.ok) throw new Error(`Failed to load plan tiers: ${res.status}`); - const data = await res.json(); + const data = await fetchJson(api.mcpUsage.planTiers(), getAccessToken); setTiers(data.tiers || []); setHasChanges(false); } catch (err) { - if (err instanceof TokenExpiredError) { - setError("Session expired. Please refresh the page."); - } else { - setError(err instanceof Error ? err.message : "Failed to load plan tiers"); - } + setError(apiErrorText(err, "Failed to load plan tiers")); } finally { setLoading(false); } @@ -91,28 +86,16 @@ export function SectionUsagePlans() { setError(null); setSuccessMessage(null); - const res = await authenticatedFetch(api.mcpUsage.planTiers(), getAccessToken, { + const data = await fetchJson(api.mcpUsage.planTiers(), getAccessToken, { method: "PUT", - headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tiers }), }); - - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error || `Failed to save: ${res.status}`); - } - - const data = await res.json(); setTiers(data.tiers || tiers); setHasChanges(false); setSuccessMessage("Plan tier definitions saved successfully."); setTimeout(() => setSuccessMessage(null), 3000); } catch (err) { - if (err instanceof TokenExpiredError) { - setError("Session expired. Please refresh the page."); - } else { - setError(err instanceof Error ? err.message : "Failed to save plan tiers"); - } + setError(apiErrorText(err, "Failed to save plan tiers")); } finally { setSaving(false); } diff --git a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx index 3251e3a4..e09a79a5 100644 --- a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx @@ -3,7 +3,7 @@ import { useEffect, useState, useCallback } from 'react'; import { useAuth } from '../../../../contexts/AuthContext'; import { api } from '@/lib/api'; -import { authenticatedFetch, TokenExpiredError } from '@/lib/authenticatedFetch'; +import { apiErrorText, fetchJson } from "@/lib/apiClient"; // ── Types ──────────────────────────────────────────────────────────────────── @@ -360,23 +360,15 @@ export function SectionTenantConfigReport() { const fetchTenants = async () => { try { setLoadingTenants(true); - const response = await authenticatedFetch(api.config.all(), getAccessToken); - if (response.ok) { - const data = await response.json(); - const mapped: TenantInfo[] = data.map((t: { tenantId: string; domainName: string }) => ({ - tenantId: t.tenantId, - domainName: t.domainName || '', - })); - mapped.sort((a, b) => (a.domainName || a.tenantId).localeCompare(b.domainName || b.tenantId)); - setTenants(mapped); - // Don't auto-select — user must pick a tenant from the dropdown - } + // config/all is a bare array of tenant configurations (deliberately untyped, D-043). + const data = await fetchJson>(api.config.all(), getAccessToken); + const mapped: TenantInfo[] = data.map((t) => ({ tenantId: t.tenantId, domainName: t.domainName || '' })); + mapped.sort((a, b) => (a.domainName || a.tenantId).localeCompare(b.domainName || b.tenantId)); + setTenants(mapped); + // Don't auto-select: the user must pick a tenant from the dropdown. } catch (err) { - if (err instanceof TokenExpiredError) { - setError('Session expired. Please refresh.'); - } else { - console.error('Error fetching tenant list:', err); - } + console.error('Error fetching tenant list:', err); + setError(apiErrorText(err, 'Failed to load tenants')); } finally { setLoadingTenants(false); } @@ -390,21 +382,13 @@ export function SectionTenantConfigReport() { try { setLoading(true); setError(null); - const response = await authenticatedFetch( + const data = await fetchJson( api.config.tenant(selectedTenantId), getAccessToken, ); - if (!response.ok) { - throw new Error(`Failed to load config: ${response.status} ${response.statusText}`); - } - const data: TenantConfig = await response.json(); setConfig(data); } catch (err) { - if (err instanceof TokenExpiredError) { - setError('Session expired. Please refresh.'); - } else { - setError((err as Error).message); - } + setError(apiErrorText(err)); setConfig(null); } finally { setLoading(false); diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/apiClient.guard.baseline.ts b/src/Web/autopilot-monitor-web/lib/__tests__/apiClient.guard.baseline.ts index 2296c381..2788db7f 100644 --- a/src/Web/autopilot-monitor-web/lib/__tests__/apiClient.guard.baseline.ts +++ b/src/Web/autopilot-monitor-web/lib/__tests__/apiClient.guard.baseline.ts @@ -12,44 +12,6 @@ export const PERMANENT: Record = { }; export const AUTHENTICATEDFETCH_BASELINE: Record = { - "app/admin/AdminConfigContext.tsx": 8, - "app/admin/backups/components/RestoreRowDiffModal.tsx": 1, - "app/admin/backups/detail/page.tsx": 2, - "app/admin/backups/page.tsx": 3, - "app/admin/components/ConfigReseedSection.tsx": 4, - "app/admin/components/DeviceBlockSection.tsx": 6, - "app/admin/components/DistressReportsSection.tsx": 1, - "app/admin/components/FeedbackSection.tsx": 1, - "app/admin/components/IdentityBindingsSection.tsx": 3, - "app/admin/components/IgnoredSoftwareTab.tsx": 4, - "app/admin/components/MaintenanceTriggerSection.tsx": 2, - "app/admin/components/MappedSoftwareTab.tsx": 3, - "app/admin/components/OpsEventsSection.tsx": 1, - "app/admin/components/SessionExportSection.tsx": 1, - "app/admin/components/SessionReportsSection.tsx": 3, - "app/admin/components/TenantAdminSection.tsx": 5, - "app/admin/components/TenantManagementSection.tsx": 10, - "app/admin/components/UnmappedSoftwareTab.tsx": 5, - "app/admin/components/VersionBlockSection.tsx": 3, - "app/admin/components/VulnerabilityDataSection.tsx": 11, - "app/admin/customs-archive/detail/page.tsx": 3, - "app/admin/customs-archive/page.tsx": 2, - "app/admin/metrics/sections/SectionAgentMetrics.tsx": 1, - "app/admin/metrics/sections/SectionImePatternHealth.tsx": 1, - "app/admin/metrics/sections/SectionMcpUsage.tsx": 2, - "app/admin/metrics/sections/SectionPlatformUsage.tsx": 1, - "app/admin/metrics/sections/SectionVerdictCalibration.tsx": 1, - "app/admin/ops/session-cleanup/components/DeletionManifestSummaryView.tsx": 2, - "app/admin/ops/session-cleanup/components/MaintenanceStatusBanner.tsx": 2, - "app/admin/ops/session-cleanup/components/RestoreBrowserTab.tsx": 2, - "app/admin/ops/session-cleanup/components/RestoreConfirmDialog.tsx": 1, - "app/admin/ops/session-cleanup/page.tsx": 3, - "app/admin/presence/page.tsx": 1, - "app/admin/settings/sections/SectionDelegatedAdmins.tsx": 4, - "app/admin/settings/sections/SectionEmailTemplates.tsx": 4, - "app/admin/settings/sections/SectionTenantGroups.tsx": 2, - "app/admin/settings/sections/SectionUsagePlans.tsx": 2, - "app/admin/tenants/sections/SectionTenantConfigReport.tsx": 2, "app/analyze-rules/page.tsx": 2, "app/annotations/page.tsx": 1, "app/apps/components/InventoryTab.tsx": 1, @@ -109,44 +71,6 @@ export const DEDUPEDAUTHFETCH_BASELINE: Record = { export const JSONPARSE_BASELINE: Record = { "app/activation/page.tsx": 1, - "app/admin/AdminConfigContext.tsx": 8, - "app/admin/backups/components/RestoreRowDiffModal.tsx": 1, - "app/admin/backups/detail/page.tsx": 2, - "app/admin/backups/page.tsx": 3, - "app/admin/components/ConfigReseedSection.tsx": 8, - "app/admin/components/DeviceBlockSection.tsx": 6, - "app/admin/components/DistressReportsSection.tsx": 1, - "app/admin/components/FeedbackSection.tsx": 1, - "app/admin/components/IdentityBindingsSection.tsx": 3, - "app/admin/components/IgnoredSoftwareTab.tsx": 2, - "app/admin/components/MaintenanceTriggerSection.tsx": 1, - "app/admin/components/MappedSoftwareTab.tsx": 3, - "app/admin/components/OpsEventsSection.tsx": 1, - "app/admin/components/SessionExportSection.tsx": 1, - "app/admin/components/SessionReportsSection.tsx": 2, - "app/admin/components/TenantAdminSection.tsx": 5, - "app/admin/components/TenantManagementSection.tsx": 12, - "app/admin/components/UnmappedSoftwareTab.tsx": 3, - "app/admin/components/VersionBlockSection.tsx": 3, - "app/admin/components/VulnerabilityDataSection.tsx": 11, - "app/admin/customs-archive/detail/page.tsx": 2, - "app/admin/customs-archive/page.tsx": 2, - "app/admin/metrics/sections/SectionAgentMetrics.tsx": 1, - "app/admin/metrics/sections/SectionImePatternHealth.tsx": 1, - "app/admin/metrics/sections/SectionMcpUsage.tsx": 2, - "app/admin/metrics/sections/SectionPlatformUsage.tsx": 1, - "app/admin/metrics/sections/SectionVerdictCalibration.tsx": 1, - "app/admin/ops/session-cleanup/components/DeletionManifestSummaryView.tsx": 1, - "app/admin/ops/session-cleanup/components/MaintenanceStatusBanner.tsx": 1, - "app/admin/ops/session-cleanup/components/RestoreBrowserTab.tsx": 2, - "app/admin/ops/session-cleanup/components/RestoreConfirmDialog.tsx": 1, - "app/admin/ops/session-cleanup/page.tsx": 3, - "app/admin/presence/page.tsx": 1, - "app/admin/settings/sections/SectionDelegatedAdmins.tsx": 4, - "app/admin/settings/sections/SectionEmailTemplates.tsx": 3, - "app/admin/settings/sections/SectionTenantGroups.tsx": 2, - "app/admin/settings/sections/SectionUsagePlans.tsx": 3, - "app/admin/tenants/sections/SectionTenantConfigReport.tsx": 2, "app/analyze-rules/page.tsx": 2, "app/annotations/page.tsx": 1, "app/apps/components/InventoryTab.tsx": 1, @@ -202,43 +126,7 @@ export const JSONPARSE_BASELINE: Record = { }; export const TOKENEXPIRED_BASELINE: Record = { - "app/admin/AdminConfigContext.tsx": 6, - "app/admin/backups/components/RestoreRowDiffModal.tsx": 1, - "app/admin/backups/detail/page.tsx": 2, - "app/admin/backups/page.tsx": 3, - "app/admin/components/ConfigReseedSection.tsx": 4, - "app/admin/components/DeviceBlockSection.tsx": 6, - "app/admin/components/DistressReportsSection.tsx": 1, - "app/admin/components/FeedbackSection.tsx": 1, - "app/admin/components/IdentityBindingsSection.tsx": 1, - "app/admin/components/MaintenanceTriggerSection.tsx": 2, - "app/admin/components/MappedSoftwareTab.tsx": 3, - "app/admin/components/OpsEventsSection.tsx": 1, - "app/admin/components/SessionExportSection.tsx": 1, - "app/admin/components/SessionReportsSection.tsx": 3, - "app/admin/components/TenantAdminSection.tsx": 5, - "app/admin/components/TenantManagementSection.tsx": 6, - "app/admin/components/UnmappedSoftwareTab.tsx": 2, - "app/admin/components/VersionBlockSection.tsx": 3, - "app/admin/components/VulnerabilityDataSection.tsx": 5, - "app/admin/customs-archive/detail/page.tsx": 1, - "app/admin/customs-archive/page.tsx": 2, - "app/admin/metrics/sections/SectionAgentMetrics.tsx": 1, - "app/admin/metrics/sections/SectionImePatternHealth.tsx": 1, - "app/admin/metrics/sections/SectionMcpUsage.tsx": 1, - "app/admin/metrics/sections/SectionPlatformUsage.tsx": 1, - "app/admin/metrics/sections/SectionVerdictCalibration.tsx": 1, - "app/admin/ops/session-cleanup/components/DeletionManifestSummaryView.tsx": 2, - "app/admin/ops/session-cleanup/components/MaintenanceStatusBanner.tsx": 2, - "app/admin/ops/session-cleanup/components/RestoreBrowserTab.tsx": 2, - "app/admin/ops/session-cleanup/components/RestoreConfirmDialog.tsx": 1, - "app/admin/ops/session-cleanup/page.tsx": 3, - "app/admin/presence/page.tsx": 1, - "app/admin/settings/sections/SectionDelegatedAdmins.tsx": 1, - "app/admin/settings/sections/SectionEmailTemplates.tsx": 1, - "app/admin/settings/sections/SectionTenantGroups.tsx": 1, - "app/admin/settings/sections/SectionUsagePlans.tsx": 2, - "app/admin/tenants/sections/SectionTenantConfigReport.tsx": 2, + "app/admin/ops/session-cleanup/components/MaintenanceStatusBanner.tsx": 1, "app/apps/components/InventoryTab.tsx": 1, "app/apps/components/VulnerabilitiesTab.tsx": 1, "app/apps/detail/page.tsx": 2, diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/apiClient.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/apiClient.test.ts index 8510f87d..d295c4bc 100644 --- a/src/Web/autopilot-monitor-web/lib/__tests__/apiClient.test.ts +++ b/src/Web/autopilot-monitor-web/lib/__tests__/apiClient.test.ts @@ -8,6 +8,7 @@ import { fetchBlob, fetchJson, fetchOk, + nullOn404, } from "../apiClient"; import { TokenExpiredError } from "../authenticatedFetch"; @@ -46,6 +47,13 @@ describe("apiErrorFromResponse", () => { expect(none.retryAfterSeconds).toBeNull(); }); + it("keeps the parsed body so specialised envelopes stay readable", async () => { + const err = await apiErrorFromResponse(jsonResponse(409, { error: "Slot limit.", code: "DelegatedSlotLimitReached", correlationId: "c", homeTenantId: "t1", limit: 3 })); + expect(err.body?.homeTenantId).toBe("t1"); + expect(err.body?.limit).toBe(3); + expect((await apiErrorFromResponse(new Response("nope", { status: 502 }))).body).toBeNull(); + }); + it("falls back to the pre-envelope message, then to statusText; an empty body is not an exception", async () => { const legacy = await apiErrorFromResponse(jsonResponse(400, { success: false, message: "tenantId is required" }, "Bad Request")); expect(legacy.message).toBe("tenantId is required"); @@ -126,6 +134,14 @@ describe("fetchJson / fetchOk / fetchBlob", () => { }); }); +describe("nullOn404", () => { + it("turns a 404 into null and rethrows everything else", async () => { + await expect(Promise.reject(new ApiError(404, "Not found.", "NotFound", "c")).catch(nullOn404)).resolves.toBeNull(); + await expect(Promise.reject(new ApiError(403, "No.", "Forbidden", "c")).catch(nullOn404)).rejects.toBeInstanceOf(ApiError); + await expect(Promise.reject(new Error("boom")).catch(nullOn404)).rejects.toThrow("boom"); + }); +}); + describe("describeApiError / apiErrorText / apiErrorNotification", () => { it("renders an ApiError with the short correlation id as reference", () => { const err = new ApiError(500, "GetSessions failed.", "InternalError", "3f2a9c1e-7b4d-4e0a-9d2c-1a2b3c4d5e6f"); diff --git a/src/Web/autopilot-monitor-web/lib/apiClient.ts b/src/Web/autopilot-monitor-web/lib/apiClient.ts index c9ba8e36..a20ca3bb 100644 --- a/src/Web/autopilot-monitor-web/lib/apiClient.ts +++ b/src/Web/autopilot-monitor-web/lib/apiClient.ts @@ -29,6 +29,8 @@ export class ApiError extends Error { public readonly hint: string | null = null, /** Seconds the server asked us to wait (429/503); null when it sent none. */ public readonly retryAfterSeconds: number | null = null, + /** The parsed envelope — specialised bodies carry domain fields after the prefix (slot limit, app-homing probe, delete lock). */ + public readonly body: ErrorBody | null = null, ) { super(message); this.name = "ApiError"; @@ -42,6 +44,9 @@ export class ApiError extends Error { */ type ErrorEnvelope = Partial & { message?: unknown }; +/** What ApiError.body exposes: the envelope prefix plus whatever the specialised body adds. */ +export type ErrorBody = Partial & Record; + const str = (v: unknown): string | null => (typeof v === "string" && v.length > 0 ? v : null); const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null); @@ -71,9 +76,16 @@ export async function apiErrorFromResponse(response: Response): Promise typeof raw === "string" && m.toLowerCase() === raw.toLowerCase()); + return { ...wire, excessiveEventAutoActionMode: mode }; +} + /** One parsed entry of AdminConfiguration.opsAlertRulesJson (client-side view). */ export interface OpsAlertRule { eventType: string;