diff --git a/src/Web/autopilot-monitor-web/app/activation/page.tsx b/src/Web/autopilot-monitor-web/app/activation/page.tsx index e7a8fb1e..832a63ec 100644 --- a/src/Web/autopilot-monitor-web/app/activation/page.tsx +++ b/src/Web/autopilot-monitor-web/app/activation/page.tsx @@ -4,9 +4,10 @@ import { useAuth } from "../../contexts/AuthContext"; import { useRouter } from "next/navigation"; import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { api } from "@/lib/api"; -import { apiErrorText, fetchOk } from "@/lib/apiClient"; +import { apiErrorText, fetchOk, jsonBody } from "@/lib/apiClient"; import { DOCS_URL } from "@/utils/config"; import { BrandMark } from "../../components/BrandMark"; +import type { SaveNotificationEmailRequest } from "@/utils/wire-types.generated"; // The URL query string is fixed for the lifetime of a page load — nothing to subscribe to. const subscribeNever = () => () => {}; @@ -120,7 +121,7 @@ export default function ActivationPage() { await fetchOk(api.preview.notificationEmail(), getAccessToken, { method: "PUT", - body: JSON.stringify({ email }), + body: jsonBody({ email }), }); setEmailStatus("saved"); diff --git a/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx b/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx index 0a63b3c3..a272fa9b 100644 --- a/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx @@ -3,9 +3,16 @@ import { createContext, useCallback, useContext, useRef, useState } from "react"; import { useAuth } from "../../contexts/AuthContext"; import { api } from "@/lib/api"; -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 { apiErrorText, fetchJson, jsonBody } from "@/lib/apiClient"; +import { fromWireAdminConfiguration, toWireAdminConfiguration, type AdminConfiguration, type OpsAlertRule } from "@/types/adminConfig"; +import type { + AdminConfiguration as WireAdminConfiguration, + GetAllPreviewNotificationEmailsResponse, + GetPreviewWhitelistResponse, + TestNotificationChannelRequest, + 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 @@ -360,7 +367,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) const result = await fetchJson(api.globalConfig.get(), getAccessToken, { method: "PUT", - body: JSON.stringify(updatedConfig), + body: jsonBody(toWireAdminConfiguration(updatedConfig)), }); setAdminConfig(fromWireAdminConfiguration(result.config)); setSuccessMessage("Admin configuration saved successfully!"); @@ -440,7 +447,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) const result = await fetchJson(api.globalConfig.get(), getAccessToken, { method: "PUT", - body: JSON.stringify(updatedConfig), + body: jsonBody(toWireAdminConfiguration(updatedConfig)), }); setAdminConfig(fromWireAdminConfiguration(result.config)); setGlobalDiagPaths(paths); @@ -484,7 +491,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) const result = await fetchJson(api.globalConfig.get(), getAccessToken, { method: "PUT", - body: JSON.stringify(updatedConfig), + body: jsonBody(toWireAdminConfiguration(updatedConfig)), }); setAdminConfig(fromWireAdminConfiguration(result.config)); setOpsAlertRules(rules); @@ -515,7 +522,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) const result = await fetchJson(api.globalConfig.testOpsChannel(), getAccessToken, { method: "POST", - body: JSON.stringify({ channelId }), + body: jsonBody({ channelId }), }); setTestOpsChannelResult({ channelId, 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 cfbbfe05..5612940b 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 @@ -1,14 +1,15 @@ "use client"; import { useState } from "react"; -import { api, type RestoreRowRequestBody } from "@/lib/api"; +import { api } from "@/lib/api"; import type { RestoreRowCommitResponse, + RestoreRowRequest, RestoreRowPreviewResponse, RestoreRowPropertyDiff, RestoreRowPropertySnapshot, } from "@/utils/wire-types.generated"; -import { apiErrorText, fetchJson } from "@/lib/apiClient"; +import { apiErrorText, fetchJson, jsonBody } from "@/lib/apiClient"; interface RestoreRowDiffModalProps { backupId: string; @@ -39,17 +40,17 @@ export function RestoreRowDiffModal({ setError(null); setCommitting(true); try { - const body: RestoreRowRequestBody = { + const body: RestoreRowRequest = { tableName: preview.tableName, partitionKey: preview.partitionKey, rowKey: preview.rowKey, mode: "Commit", ifSha256: preview.rowSha256, - ifCurrentETag: preview.currentETag ?? null, + ifCurrentETag: preview.currentETag ?? undefined, }; const response = await fetchJson(api.backups.restoreRow(backupId), getAccessToken, { method: "POST", - body: JSON.stringify(body), + body: jsonBody(body), }); onCommitted(response); } catch (err) { 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 872668f8..86de5f08 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 @@ -7,10 +7,9 @@ import { api, type BackupManifest, type BackupTableEntry, - type RestoreRowRequestBody, } from "@/lib/api"; -import type { RestoreRowCommitResponse, RestoreRowPreviewResponse } from "@/utils/wire-types.generated"; -import { apiErrorText, fetchJson } from "@/lib/apiClient"; +import type { RestoreRowCommitResponse, RestoreRowPreviewResponse, RestoreRowRequest } from "@/utils/wire-types.generated"; +import { apiErrorText, fetchJson, jsonBody } from "@/lib/apiClient"; import { useAdminConfig } from "../../AdminConfigContext"; import { AdminNotifications } from "../../AdminNotifications"; import { RestoreRowDiffModal } from "../components/RestoreRowDiffModal"; @@ -66,7 +65,7 @@ function BackupDetailContent() { setError(null); setPreviewLoading(true); try { - const body: RestoreRowRequestBody = { + const body: RestoreRowRequest = { tableName, partitionKey: pk, rowKey: rk, @@ -74,7 +73,7 @@ function BackupDetailContent() { }; const preview = await fetchJson(api.backups.restoreRow(backupId), getAccessToken, { method: "POST", - body: JSON.stringify(body), + body: jsonBody(body), }); setActivePreview(preview); } catch (err) { 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 300a1a98..13756e9b 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/DeviceBlockSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/DeviceBlockSection.tsx @@ -5,14 +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 { apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient"; +import { apiErrorText, fetchJson, fetchOk, jsonBody } 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"; +import type { BlockDeviceRequest, BlockedDeviceEntry, BlockedDeviceListResponse, GetSessionResponse } from "@/utils/wire-types.generated"; interface ResolvedDevice { sessionId: string; @@ -176,7 +176,7 @@ function DeviceBlockSectionInner({ setError(null); await fetchOk(api.devices.block(), getAccessToken, { method: "POST", - body: JSON.stringify({ + body: jsonBody({ tenantId: blockTenantId, serialNumber: blockSerialNumber.trim(), durationHours: blockDurationHours, @@ -239,7 +239,7 @@ function DeviceBlockSectionInner({ const remainingHours = Math.max(1, Math.ceil((unblockAtMs - Date.now()) / 3600000)); await fetchOk(api.devices.block(), getAccessToken, { method: "POST", - body: JSON.stringify({ + body: jsonBody({ tenantId: device.tenantId, serialNumber: device.serialNumber, durationHours: remainingHours, 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 3493de40..200677ae 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/IdentityBindingsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/IdentityBindingsSection.tsx @@ -2,10 +2,10 @@ import { useCallback, useState } from "react"; import { api } from "@/lib/api"; -import { apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient"; +import { apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient"; import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform"; import { isGuid, type IdentityBinding } from "@/lib/identityBinding"; -import type { IdentityBindingListResponse } from "@/utils/wire-types.generated"; +import type { IdentityBindingListResponse, IdentityBindingRequest } from "@/utils/wire-types.generated"; interface IdentityBindingsSectionProps { tenantId: string; @@ -80,7 +80,7 @@ export function IdentityBindingsSection({ setError(null); await fetchOk(api.identityBindings.put(upn), getAccessToken, { method: "PUT", - body: JSON.stringify({ homeTenantId: tid, objectId: oid || undefined }), + body: jsonBody({ homeTenantId: tid, objectId: oid || undefined }), }); setSuccessMessage(`Identity binding for ${upn} updated.`); setEditing(null); 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 bd841856..e2d64c24 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/IgnoredSoftwareTab.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/IgnoredSoftwareTab.tsx @@ -2,12 +2,12 @@ import { useCallback, useEffect, useState } from "react"; import { api } from "@/lib/api"; -import { apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient"; +import { apiErrorText, fetchJson, fetchOk, jsonBody } 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"; +import type { AutoResolveCpeMappingRequest, GetIgnoredSoftwareResponse, IgnoreSoftwareDeleteRequest } from "@/utils/wire-types.generated"; interface IgnoredSoftwareTabProps { getAccessToken: () => Promise; @@ -73,7 +73,7 @@ export function IgnoredSoftwareTab({ setError(null); await fetchOk(api.vulnerability.ignoredSoftware(), getAccessToken, { method: "DELETE", - body: JSON.stringify({ softwareName: entry.softwareName, publisher: entry.publisher || "" }), + body: jsonBody({ softwareName: entry.softwareName, publisher: entry.publisher || "" }), }); setIgnoredEntries((prev) => { const next = prev.filter((e) => `${e.softwareName}::${e.publisher}` !== key); @@ -97,7 +97,7 @@ export function IgnoredSoftwareTab({ // First, call auto-resolve for this single item const data = await fetchJson(api.vulnerability.cpeAutoResolve(), getAccessToken, { method: "POST", - body: JSON.stringify({ + body: jsonBody({ items: [{ softwareName: entry.softwareName, publisher: entry.publisher || "", normalizedVendor: entry.publisher || "" }], }), }); @@ -106,7 +106,7 @@ export function IgnoredSoftwareTab({ // Resolved! Remove from ignore list and refresh await fetchOk(api.vulnerability.ignoredSoftware(), getAccessToken, { method: "DELETE", - body: JSON.stringify({ softwareName: entry.softwareName, publisher: entry.publisher || "" }), + body: jsonBody({ softwareName: entry.softwareName, publisher: entry.publisher || "" }), }); setIgnoredEntries((prev) => { const next = prev.filter((e) => `${e.softwareName}::${e.publisher}` !== key); 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 743496e9..9cb400b1 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/MappedSoftwareTab.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/MappedSoftwareTab.tsx @@ -2,11 +2,11 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "@/lib/api"; -import { apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient"; +import { apiErrorText, fetchJson, fetchOk, jsonBody } 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"; +import type { DeleteCustomCpeMappingRequest, GetCpeMappingsResponse, SaveCustomCpeMappingRequest } from "@/utils/wire-types.generated"; interface MappedSoftwareTabProps { getAccessToken: () => Promise; @@ -176,7 +176,7 @@ export function MappedSoftwareTab({ getAccessToken, { method: "POST", - body: JSON.stringify({ + body: jsonBody({ normalizedProduct: entry.normalizedProduct, normalizedVendor: entry.normalizedVendor, cpeUri: cpeUri, @@ -214,7 +214,7 @@ export function MappedSoftwareTab({ getAccessToken, { method: "DELETE", - body: JSON.stringify({ + body: jsonBody({ normalizedProduct: entry.normalizedProduct, normalizedVendor: entry.normalizedVendor, }), 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 1cd36b68..5f0e8806 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 { apiErrorText, fetchJson, fetchOk, nullOn404 } from "@/lib/apiClient"; +import { apiErrorText, fetchJson, fetchOk, jsonBody, nullOn404 } from "@/lib/apiClient"; import TruncatedLabel from "@/components/TruncatedLabel"; import { extractContinuation } from "@/lib/paginationLink"; import { isGuid } from "@/utils/inputValidation"; @@ -11,7 +11,7 @@ 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"; +import type { SessionReportDownloadUrlResponse, SessionReportListResponse, SessionReportMetadata, UpdateSessionReportNoteRequest } from "@/utils/wire-types.generated"; const PAGE_SIZE = 20; @@ -100,7 +100,7 @@ function AdminNoteEditor({ getAccessToken, { method: "PATCH", - body: JSON.stringify({ adminNote: adminNoteValue }), + body: jsonBody({ adminNote: adminNoteValue }), } ); 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 7ca79203..088b8b34 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/TenantAdminSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/TenantAdminSection.tsx @@ -2,9 +2,9 @@ import { useEffect, useState } from "react"; import { api } from "@/lib/api"; -import { apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient"; +import { apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient"; import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform"; -import type { TenantAdminRow } from "@/utils/wire-types.generated"; +import type { AddTenantAdminRequest, TenantAdminRow, UpdateMemberPermissionsRequest } from "@/utils/wire-types.generated"; import { isApplicationKey, looksLikeGuid, principalLabel, type MemberKind } from "@/utils/principalKeys"; // Wire type is generated from the backend DTO ("role" is absent for legacy pre-role rows). @@ -72,11 +72,11 @@ export function TenantAdminSection({ setError(null); const body = addingApplication - ? { applicationId: newAdminEmail.trim(), role: "Viewer" } - : { upn: newAdminEmail.trim(), role: newMemberRole }; + ? { applicationId: newAdminEmail.trim(), role: "Viewer", canManageBootstrapTokens: false } + : { upn: newAdminEmail.trim(), role: newMemberRole, canManageBootstrapTokens: false }; await fetchOk(api.tenants.admins(tenantId), getAccessToken, { method: "POST", - body: JSON.stringify(body), + body: jsonBody(body), }); setSuccessMessage(addingApplication @@ -158,7 +158,7 @@ export function TenantAdminSection({ await fetchOk(api.tenants.adminPermissions(tenantId, adminUpn), getAccessToken, { method: "PATCH", - body: JSON.stringify({ role, canManageBootstrapTokens }), + body: jsonBody({ role, canManageBootstrapTokens }), }); setSuccessMessage(`Permissions for ${adminUpn} updated successfully!`); 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 06ab30ea..e10829bb 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,25 @@ "use client"; import { Suspense, useState, useEffect } from "react"; -import type { AppHomingDeniedResponse, DelegatedSlotUsageResponse, GetPreviewNotificationEmailResponse, OffboardResponse, PlanTierDefinitionsResponse, PreviewWhitelistActionResponse, SetTenantPlanTierResponse, UpdateTenantAppHomingResponse, UpdateTenantConfigurationResponse } from "@/utils/wire-types.generated"; +import type { + AppHomingDeniedResponse, + AppHomingRequest, + DelegatedSlotUsageResponse, + GetPreviewNotificationEmailResponse, + OffboardResponse, + PatchTenantPlanRequest, + PlanTierDefinitionsResponse, + PreviewWhitelistActionResponse, + ReleaseDelegatedSlotHoldRequest, + SaveNotificationEmailRequest, + SetTenantPlanTierResponse, + TenantConfiguration as WireTenantConfiguration, + UpdateTenantAppHomingResponse, + UpdateTenantConfigurationResponse, +} from "@/utils/wire-types.generated"; import { useSearchParams } from "next/navigation"; import { api } from "@/lib/api"; -import { ApiError, apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient"; +import { ApiError, apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient"; import { classifyClientId, legacyConfigured } from "@/lib/authApp"; import { appHomingErrorMessage } from "@/lib/appHoming"; import { trackEvent } from "@/lib/appInsights"; @@ -23,62 +38,12 @@ function effectiveEditionLabel(edition: unknown, source: unknown): string { return "Pro"; } -export interface TenantConfiguration { - tenantId: string; - domainName: string; - lastUpdated: string; - updatedBy: string; - /** Tenant-maintained contact address for service matters. Read-only here. */ - contactEmail?: string | null; - /** Tenant-maintained company name (contact profile). Read-only here. */ - companyName?: string | null; - disabled: boolean; - disabledReason?: string; - disabledUntil?: string; - /** Per-tenant device API rate-limit override (null/undefined = inherit global). GA-only. */ - customRateLimitRequestsPerMinute?: number | null; - /** Per-tenant user API rate-limit override for standard users (null/undefined = inherit global). GA-only. */ - customUserRateLimitRequestsPerMinute?: number | null; - manufacturerWhitelist: string; - modelWhitelist: string; - validateAutopilotDevice: boolean; - allowInsecureAgentRequests?: boolean; - bootstrapTokenEnabled?: boolean; - unrestrictedModeEnabled?: boolean; - entraAppRolesEnabled?: boolean; - /** Operator-set (GA-only): Device-phase ESP failures on Continue-Anyway profiles get a 60-min observation instead of an immediate hard fail. */ - enableEspContinueAnywayObservation?: boolean; - dataRetentionDays: number; - sessionTimeoutHours: number; - planTier?: string; - /** Pro-trial end (ISO, UTC). Null/undefined = no trial. Managed via PATCH plan. */ - trialExpiresUtc?: string | null; - /** Whether the tenant has used its one self-service trial. */ - trialConsumed?: boolean; - /** Delegated (MSP) tenant slot override; null/undefined = plan entitlement (Community 0, Pro 2). Managed via PATCH plan. */ - maxDelegatedTenantsOverride?: number | null; - /** - * MCP usage-plan override — a SectionUsagePlans plan name applied to the WHOLE tenant (every member's - * default plan + the organization windows); null/undefined = edition default. Managed via PATCH plan. - */ - mcpUsagePlanOverride?: string | null; - /** Sales bookkeeping: the tenant pays for its plan. No entitlement effect. Managed via PATCH plan. */ - payingCustomer?: boolean; - /** - * Read-time projection (never stored): the permanent-Pro tenant whose delegation confers Pro on this - * one, or null. While set the effective edition is "Pro (MSP)". - */ - managedByProTenantId?: string | null; - /** - * Dual app-reg homing: null/undefined = legacy app. Typed explicitly so a payload refactor - * cannot silently drop the field on the generic PUT round-trip (absent ⇒ backend resets to - * legacy). Mutated ONLY via POST app-homing — the PUT preserves it server-side. - */ - homedAppClientId?: string | null; - /** System-written login provenance (AuthFunction) — read-only observability. */ - lastAuthClientId?: string | null; - lastAuthClientIdSince?: string | null; -} +/** + * The tenant row as the backend serves and accepts it (PUT config/{tenantId} sends the whole + * object back, PATCH plan the presence-sensitive subset). The former local subset hid fields + * the PUT carries anyway; D-207 types the body against the wire. + */ +export type TenantConfiguration = WireTenantConfiguration; /** Small "New app" / "Legacy app" indicator for the dual app-reg parallel window. */ function HomingBadge({ clientId }: { clientId?: string | null }) { @@ -170,7 +135,7 @@ function TenantManagementSectionInner({ setError(null); await fetchOk(api.delegatedSlots.releaseHold(tenantId), getAccessToken, { method: "POST", - body: JSON.stringify({ invitationId }), + body: jsonBody({ invitationId, all: false }), }); setSlotUsage((prev) => prev ? { ...prev, holds: prev.holds.filter((h) => h.invitationId !== invitationId), used: Math.max(0, prev.used - 1) } @@ -270,7 +235,7 @@ function TenantManagementSectionInner({ const result = await fetchJson(api.config.tenant(tenant.tenantId), getAccessToken, { method: "PUT", - body: JSON.stringify(tenant), + body: jsonBody(tenant), }); // Update tenant in list @@ -299,7 +264,7 @@ function TenantManagementSectionInner({ const result = await fetchJson(api.config.plan(tenant.tenantId), getAccessToken, { method: "PATCH", - body: JSON.stringify({ + body: jsonBody({ // The select only offers the two canonical write values (community/pro); legacy stored // "enterprise" normalizes to "pro" on save, legacy "free" to "community". planTier: tenant.planTier === "pro" || tenant.planTier === "enterprise" ? "pro" : "community", @@ -343,7 +308,7 @@ function TenantManagementSectionInner({ try { data = await fetchJson(api.config.appHoming(tenant.tenantId), getAccessToken, { method: "POST", - body: JSON.stringify({ target, force }), + body: jsonBody({ target, force }), }); } catch (err) { if (err instanceof ApiError) { @@ -435,7 +400,7 @@ function TenantManagementSectionInner({ getAccessToken, { method: "POST", - body: JSON.stringify({ email: email || "" }), + body: jsonBody({ email: email || "" }), } ); // The send persisted the address too — keep the search map in step so the tenant is 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 4266b386..597a527f 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/UnmappedSoftwareTab.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/UnmappedSoftwareTab.tsx @@ -3,12 +3,12 @@ import { sessionUrl } from "@/lib/routes"; import { useCallback, useEffect, useState } from "react"; import { api } from "@/lib/api"; -import { apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient"; +import { apiErrorText, fetchJson, fetchOk, jsonBody } 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"; +import type { AutoResolveCpeMappingRequest, GetUnmatchedSoftwareResponse, IgnoreSoftwareRequest, SaveCustomCpeMappingRequest } from "@/utils/wire-types.generated"; interface UnmappedSoftwareTabProps { getAccessToken: () => Promise; @@ -166,7 +166,12 @@ export function UnmappedSoftwareTab({ } }; + /** The backend keys mappings and ignores by software name; a nameless entry has nothing to send. */ + const hasSoftwareName = (e: UnmatchedSoftwareEntry): e is UnmatchedSoftwareEntry & { softwareName: string } => + typeof e.softwareName === "string" && e.softwareName.length > 0; + const handleSaveMapping = async (entry: UnmatchedSoftwareEntry) => { + if (!hasSoftwareName(entry)) return; const key = getRowKey(entry); const cpeUri = (cpeInputs[key] || "").trim(); if (!cpeUri) return; @@ -180,7 +185,7 @@ export function UnmappedSoftwareTab({ getAccessToken, { method: "POST", - body: JSON.stringify({ + body: jsonBody({ normalizedProduct: entry.softwareName, normalizedVendor: entry.publisher || "", cpeUri: cpeUri, @@ -265,8 +270,8 @@ export function UnmappedSoftwareTab({ getAccessToken, { method: "POST", - body: JSON.stringify({ - items: batches[i].map((e) => ({ + body: jsonBody({ + items: batches[i].filter(hasSoftwareName).map((e) => ({ softwareName: e.softwareName, publisher: e.publisher, normalizedVendor: e.normalizedVendor || e.publisher || "", @@ -310,13 +315,14 @@ export function UnmappedSoftwareTab({ // --- Ignore --- const handleIgnoreSoftware = async (entry: UnmatchedSoftwareEntry) => { + if (!hasSoftwareName(entry)) return; const key = getRowKey(entry); try { setIgnoringRow(key); setError(null); await fetchOk(api.vulnerability.ignoredSoftware(), getAccessToken, { method: "POST", - body: JSON.stringify({ + body: jsonBody({ items: [{ softwareName: entry.softwareName, publisher: entry.publisher || "", reason: "manual" }], }), }); @@ -340,8 +346,8 @@ export function UnmappedSoftwareTab({ setError(null); await fetchOk(api.vulnerability.ignoredSoftware(), getAccessToken, { method: "POST", - body: JSON.stringify({ - items: selectedList.map((e) => ({ softwareName: e.softwareName, publisher: e.publisher || "", reason: "manual" })), + body: jsonBody({ + items: selectedList.filter(hasSoftwareName).map((e) => ({ softwareName: e.softwareName, publisher: e.publisher || "", reason: "manual" })), }), }); setSelectedEntries(new Map()); diff --git a/src/Web/autopilot-monitor-web/app/admin/components/VersionBlockSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/VersionBlockSection.tsx index 0e78ba4b..9f98c532 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/VersionBlockSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/VersionBlockSection.tsx @@ -2,10 +2,10 @@ import { useState } from "react"; import { api } from "@/lib/api"; -import { apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient"; +import { apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient"; import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform"; import { SectionCardHeader } from "@/components/SectionCardHeader"; -import type { BlockedVersionListResponse } from "@/utils/wire-types.generated"; +import type { BlockedVersionListResponse, BlockVersionRequest } from "@/utils/wire-types.generated"; interface BlockedVersion { versionPattern: string; @@ -61,7 +61,7 @@ export function VersionBlockSection({ setError(null); await fetchOk(api.versions.block(), getAccessToken, { method: "POST", - body: JSON.stringify({ + body: jsonBody({ versionPattern: versionPattern.trim(), action: versionAction, reason: versionReason.trim() || undefined, 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 f8e579ca..1918fd73 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/VulnerabilityDataSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/VulnerabilityDataSection.tsx @@ -2,13 +2,21 @@ import { useCallback, useEffect, useState } from "react"; import { api } from "@/lib/api"; -import { apiErrorText, fetchJson } from "@/lib/apiClient"; +import { apiErrorText, fetchJson, jsonBody } from "@/lib/apiClient"; import SaveResetBar from "../../settings/components/SaveResetBar"; import VulnerabilityExposurePanel from "@/components/VulnerabilityExposurePanel"; -import { fromWireAdminConfiguration, type AdminConfiguration } from "@/types/adminConfig"; +import { fromWireAdminConfiguration, toWireAdminConfiguration, 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"; +import type { + AdminConfiguration as WireAdminConfiguration, + GetVulnerabilitySyncStatusResponse, + TriggerEpssSyncResponse, + TriggerMsrcSyncResponse, + TriggerNvdCacheRefreshResponse, + TriggerVulnerabilityDataSyncResponse, + UpdateAdminConfigurationResponse, +} from "@/utils/wire-types.generated"; interface VulnerabilityTopCve { cveId: string; @@ -181,7 +189,7 @@ export function VulnerabilityDataSection({ const result = await fetchJson(api.globalConfig.get(), getAccessToken, { method: "PUT", - body: JSON.stringify(updatedConfig), + body: jsonBody(toWireAdminConfiguration(updatedConfig)), }); onAdminConfigUpdated(fromWireAdminConfiguration(result.config)); setSuccessMessage("Vulnerability configuration saved successfully!"); 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 eda66125..116d71c9 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,8 +2,8 @@ import { useState } from "react"; import { api } from "@/lib/api"; -import { apiErrorText, fetchJson } from "@/lib/apiClient"; -import type { SessionRestoreResponse } from "@/utils/wire-types.generated"; +import { apiErrorText, fetchJson, jsonBody } from "@/lib/apiClient"; +import type { RestoreSessionRequest, SessionRestoreResponse } from "@/utils/wire-types.generated"; interface RestoreConfirmDialogProps { tenantId: string; @@ -52,7 +52,7 @@ export function RestoreConfirmDialog({ getAccessToken, { method: "POST", - body: JSON.stringify({ + body: jsonBody({ tenantId, manifestId, reason: reason.trim() || null, 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 71fe67da..2487ebdd 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 @@ -3,13 +3,13 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useAuth } from "@/contexts/AuthContext"; import { useTenantList } from "@/hooks/useTenantList"; -import { ApiError, apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient"; +import { ApiError, apiErrorText, fetchJson, fetchOk, jsonBody } 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"; +import type { DelegatedAdminListResponse, GrantDelegatedAdminRequest } from "@/utils/wire-types.generated"; /** One delegated-admin assignment as returned by /api/global/delegated-admins (camelCase JSON). */ interface DelegatedAssignment { @@ -97,7 +97,7 @@ export function SectionDelegatedAdmins() { try { await fetchOk(api.delegatedAdmins.grant(), getAccessToken, { method: "POST", - body: JSON.stringify({ + body: jsonBody({ upn, tenantId: newTenantId, role: newRole, 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 19948840..fe096d2b 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,10 +2,10 @@ import { useCallback, useEffect, useState } from "react"; import { api } from "@/lib/api"; -import { fetchJson, fetchOk } from "@/lib/apiClient"; +import { fetchJson, fetchOk, jsonBody } from "@/lib/apiClient"; import { useAdminConfig } from "../../AdminConfigContext"; import { AdminNotifications } from "../../AdminNotifications"; -import type { EmailTemplateTestSendResponse } from "@/utils/wire-types.generated"; +import type { EmailTemplateRequest, EmailTemplateTestSendResponse } from "@/utils/wire-types.generated"; type TemplateKind = "welcome" | "farewell"; @@ -122,7 +122,7 @@ function TemplateCard({ kind, title, description, getAccessToken, setError, setS setError(null); await fetchOk(api.emailTemplates.save(kind), getAccessToken, { method: "PUT", - body: JSON.stringify({ html: draft }), + body: jsonBody({ html: draft }), }); flash(`${title} saved — real sends use the customized HTML from now on.`); setEditing(false); @@ -156,7 +156,7 @@ function TemplateCard({ kind, title, description, getAccessToken, setError, setS setError(null); const body = await fetchJson(api.emailTemplates.sendTest(kind), getAccessToken, { method: "POST", - body: JSON.stringify(useDraft ? { html: draft } : {}), + body: jsonBody(useDraft ? { html: draft } : {}), }); flash(`Test ${title.toLowerCase()} sent to ${body?.sentTo ?? "your tenant contact address"}${useDraft ? " (unsaved draft)" : ""}.`); } catch (err) { 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 4652913d..972694bc 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 @@ -3,17 +3,24 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useAuth } from "@/contexts/AuthContext"; import { useTenantList } from "@/hooks/useTenantList"; -import { ApiError, apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient"; +import { ApiError, apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient"; import { api } from "@/lib/api"; import { HOME_TENANT_UNRESOLVED } from "@/lib/identityBinding"; import { SectionCardHeader } from "@/components/SectionCardHeader"; -import type { TenantGroup, TenantGroupListResponse } from "@/utils/wire-types.generated"; +import type { + AddGroupTenantRequest, + AssignGroupRequest, + CreateTenantGroupRequest, + TenantGroup, + TenantGroupListResponse, + UpdateTenantGroupRequest, +} from "@/utils/wire-types.generated"; import { parseSlotLimitError, type SlotLimitError } from "@/lib/delegatedSlots"; import { DelegatedSlotPrompt, raiseDelegatedSlotLimit } from "@/components/DelegatedSlotPrompt"; /** A mutation held back by the slot limit, with everything needed to replay it after the limit is raised. */ interface SlotPromptState extends SlotLimitError { - retry: { key: string; url: string; method: string; body: unknown; ok: string }; + retry: { key: string; url: string; method: string; body: string | undefined; ok: string }; } const ROLE_LABELS: Record = { @@ -83,14 +90,14 @@ export function SectionTenantGroups() { void run(); }, [fetchGroups]); - /** Runs a mutation with shared busy/error/refetch handling. `body` undefined ⇒ no JSON body. */ + /** Runs a mutation with shared busy/error/refetch handling. `body` is a jsonBody() string; undefined ⇒ no JSON body. */ const mutate = useCallback( - async (key: string, url: string, method: string, body: unknown, ok: string) => { + async (key: string, url: string, method: string, body: string | undefined, ok: string) => { try { setBusyKey(key); setError(null); const init: RequestInit = { method }; - if (body !== undefined) init.body = JSON.stringify(body); + if (body !== undefined) init.body = body; try { await fetchOk(url, getAccessToken, init); } catch (err) { @@ -139,7 +146,7 @@ export function SectionTenantGroups() { const name = newName.trim(); if (!name) return; setCreating(true); - const ok = await mutate("create", api.tenantGroups.create(), "POST", { name }, `Created group "${name}".`); + const ok = await mutate("create", api.tenantGroups.create(), "POST", jsonBody({ name }), `Created group "${name}".`); setCreating(false); if (ok) setNewName(""); }, [newName, mutate]); @@ -148,7 +155,7 @@ export function SectionTenantGroups() { async (t: TenantGroup) => { const name = prompt("Rename group:", t.name)?.trim(); if (!name || name === t.name) return; - await mutate(`rename:${t.groupId}`, api.tenantGroups.update(t.groupId), "PATCH", { name }, `Renamed to "${name}".`); + await mutate(`rename:${t.groupId}`, api.tenantGroups.update(t.groupId), "PATCH", jsonBody({ name }), `Renamed to "${name}".`); }, [mutate], ); @@ -165,7 +172,7 @@ export function SectionTenantGroups() { `charge:${t.groupId}`, api.tenantGroups.update(t.groupId), "PATCH", - { chargeHomeTenantQuota: next }, + jsonBody({ chargeHomeTenantQuota: next }), next ? `"${t.name}": MCP reads are now charged to the assignee's home tenant.` : `"${t.name}": MCP reads are now charged to the managed tenant.`, @@ -191,7 +198,7 @@ export function SectionTenantGroups() { `addtenant:${t.groupId}`, api.tenantGroups.addTenant(t.groupId), "POST", - { tenantId }, + jsonBody({ tenantId }), `Added ${domainOf(tenantId) || tenantId} to "${t.name}".`, ); if (ok) setTenantToAdd((prev) => ({ ...prev, [t.groupId]: "" })); @@ -230,11 +237,11 @@ export function SectionTenantGroups() { `assign:${t.groupId}`, api.tenantGroups.assign(t.groupId), "POST", - { + jsonBody({ upn, role, homeTenantId: needHomeTenant[t.groupId] ? homeTenantPick[t.groupId] : undefined, - }, + }), `Assigned ${upn} to "${t.name}".`, ); if (ok) { 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 3c30f03e..95b637c5 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,10 +3,10 @@ import { useCallback, useEffect, useState } from "react"; import { useAuth } from "../../../../contexts/AuthContext"; import { useAdminConfig } from "../../AdminConfigContext"; -import { apiErrorText, fetchJson } from "@/lib/apiClient"; +import { apiErrorText, fetchJson, jsonBody } from "@/lib/apiClient"; import { api } from "@/lib/api"; import { TableSkeleton } from "@/components/skeletons/TableSkeleton"; -import type { PlanTierDefinitionsResponse } from "@/utils/wire-types.generated"; +import type { PlanTierDefinitionsResponse, SetPlanTierDefinitionsRequest } from "@/utils/wire-types.generated"; interface PlanTierDefinition { name: string; @@ -88,7 +88,7 @@ export function SectionUsagePlans() { const data = await fetchJson(api.mcpUsage.planTiers(), getAccessToken, { method: "PUT", - body: JSON.stringify({ tiers }), + body: jsonBody({ tiers }), }); setTiers(data.tiers || tiers); setHasChanges(false); diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx b/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx index 84a82ba5..88ff83ae 100644 --- a/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx +++ b/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx @@ -33,8 +33,8 @@ import TemplateConfigModal from "./components/TemplateConfigModal"; import { SectionCardHeader } from "@/components/SectionCardHeader"; import { DOCS_PATHS } from "@/lib/docsPaths"; import { DocsLink } from "@/components/DocsLink"; -import type { RuleStatsResponse, TenantConfiguration } from "@/utils/wire-types.generated"; -import { fetchJson } from "@/lib/apiClient"; +import type { CreateAnalyzeRuleFromTemplateRequest, RuleStatsResponse, TenantConfiguration } from "@/utils/wire-types.generated"; +import { fetchJson, jsonBody } from "@/lib/apiClient"; export default function AnalyzeRulesPage() { const { user, getAccessToken } = useAuth(); @@ -437,7 +437,7 @@ export default function AnalyzeRulesPage() { { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(variables), + body: jsonBody({ variables }), } ); if (result !== null) { diff --git a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useBlockDevice.ts b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useBlockDevice.ts index b5ec9e1a..7fee46bc 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useBlockDevice.ts +++ b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useBlockDevice.ts @@ -4,7 +4,8 @@ import { api } from "@/lib/api"; import { TokenExpiredError } from "@/lib/authenticatedFetch"; import { NotificationType } from "@/contexts/NotificationContext"; import { BULK_CONCURRENCY, runWithConcurrency, summarizeBlockOutcomes, type BlockOutcome } from "./bulkActions"; -import { ApiError, fetchOk } from "@/lib/apiClient"; +import { ApiError, fetchOk, jsonBody } from "@/lib/apiClient"; +import type { BlockDeviceRequest } from "@/utils/wire-types.generated"; export interface BlockTarget { serialNumber: string; @@ -50,7 +51,7 @@ export function useBlockDevice( try { await fetchOk(api.devices.block(), getAccessToken, { method: 'POST', - body: JSON.stringify({ + body: jsonBody({ tenantId: target.tenantId, serialNumber: target.serialNumber, durationHours: 24, diff --git a/src/Web/autopilot-monitor-web/app/delegations/accept/page.tsx b/src/Web/autopilot-monitor-web/app/delegations/accept/page.tsx index 53220b24..5d23bbae 100644 --- a/src/Web/autopilot-monitor-web/app/delegations/accept/page.tsx +++ b/src/Web/autopilot-monitor-web/app/delegations/accept/page.tsx @@ -6,8 +6,8 @@ import { useSearchParams } from "next/navigation"; import { useAuth } from "@/contexts/AuthContext"; import { api } from "@/lib/api"; import { describeDelegationError, invitationStatusLabel } from "@/lib/delegations"; -import type { AcceptDelegationInvitationResponse, DelegationAcceptPreviewResponse } from "@/utils/wire-types.generated"; -import { ApiError, apiErrorText, fetchJson } from "@/lib/apiClient"; +import type { AcceptDelegationInvitationRequest, AcceptDelegationInvitationResponse, DelegationAcceptPreviewResponse } from "@/utils/wire-types.generated"; +import { ApiError, apiErrorText, fetchJson, jsonBody } from "@/lib/apiClient"; export default function AcceptDelegationPage() { // useSearchParams needs a Suspense boundary for the static prerender (query-string route). @@ -70,7 +70,7 @@ function AcceptDelegationInner() { try { setDone(await fetchJson(api.delegations.accept(), getAccessToken, { method: "POST", - body: JSON.stringify({ token }), + body: jsonBody({ token }), })); } catch (err) { setError(explain(err, "Could not accept the invitation.")); diff --git a/src/Web/autopilot-monitor-web/app/ime-log-patterns/page.tsx b/src/Web/autopilot-monitor-web/app/ime-log-patterns/page.tsx index e5fa410f..df67cf07 100644 --- a/src/Web/autopilot-monitor-web/app/ime-log-patterns/page.tsx +++ b/src/Web/autopilot-monitor-web/app/ime-log-patterns/page.tsx @@ -17,6 +17,7 @@ import ImePatternCard from "./components/ImePatternCard"; import { trackEvent } from "@/lib/appInsights"; import { DocsLink } from "@/components/DocsLink"; import { DOCS_PATHS } from "@/lib/docsPaths"; +import { } from "@/lib/apiClient"; export default function ImeLogPatternsPage() { const { tenantId } = useTenant(); diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/CollectLogsButton.tsx b/src/Web/autopilot-monitor-web/app/sessions/components/CollectLogsButton.tsx index 33da5f43..00ceb05e 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/components/CollectLogsButton.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/components/CollectLogsButton.tsx @@ -3,9 +3,9 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { EnrollmentEvent } from "@/types"; import { api } from "@/lib/api"; -import { ApiError, apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient"; +import { ApiError, apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient"; import { NotificationType } from "@/contexts/NotificationContext"; -import type { TenantConfiguration } from "@/utils/wire-types.generated"; +import type { QueueSessionActionRequest, TenantConfiguration } from "@/utils/wire-types.generated"; import { CollectPhase, COLLECT_TIMEOUT_MS, @@ -78,7 +78,7 @@ export default function CollectLogsButton({ const queueAction = useCallback(async (type: string, reason: string): Promise => { return fetchOk(api.sessions.queueAction(sessionId, effectiveTenantId), getAccessToken, { method: "POST", - body: JSON.stringify({ type, reason }), + body: jsonBody({ type, reason }), }).then( () => true, (err: unknown) => { @@ -142,7 +142,7 @@ export default function CollectLogsButton({ await fetchOk(api.config.tenantCollectLogsQuickConfig(effectiveTenantId), getAccessToken, { method: "PUT", - body: JSON.stringify(updated), + body: jsonBody(updated), }); onDiagnosticsConfigured(); diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/SessionAnnotationsCard.tsx b/src/Web/autopilot-monitor-web/app/sessions/components/SessionAnnotationsCard.tsx index 0fd180be..21e9606a 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/components/SessionAnnotationsCard.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/components/SessionAnnotationsCard.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { api } from "@/lib/api"; -import { apiErrorText, fetchJson } from "@/lib/apiClient"; +import { apiErrorText, fetchJson, jsonBody } from "@/lib/apiClient"; import { useAdminMode } from "@/hooks/useAdminMode"; import { ANNOTATION_MAX_NOTE_LENGTH, @@ -21,6 +21,7 @@ import { type AnnotationVerdict, type SessionAnnotationDto, } from "./sessionAnnotationLogic"; +import type { UpsertSessionAnnotationRequest } from "@/utils/wire-types.generated"; /** * Session annotations: per-lane human verdict + note about this enrollment's analysis @@ -131,7 +132,7 @@ export default function SessionAnnotationsCard({ getAccessToken, { method: "PUT", - body: JSON.stringify(body), + body: jsonBody(body), } ); setAnnotations((prev) => { diff --git a/src/Web/autopilot-monitor-web/app/sessions/page.tsx b/src/Web/autopilot-monitor-web/app/sessions/page.tsx index eedfdef8..fff2ea65 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/page.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/page.tsx @@ -17,7 +17,7 @@ import { useLatestVersions } from '@/lib/useLatestVersions'; import { useScriptDisplayNames } from '@/lib/scriptDisplayNames'; import { api } from "@/lib/api"; import { isGuid } from "@/utils/inputValidation"; -import { ApiError, fetchBlob, fetchJson, fetchOk } from "@/lib/apiClient"; +import { ApiError, fetchBlob, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient"; import { useSessionAnalysis } from "./hooks/useSessionAnalysis"; import { useAutoScroll } from "./hooks/useAutoScroll"; @@ -48,7 +48,7 @@ import { trackEvent } from "@/lib/appInsights"; import { useAdminMode } from "@/hooks/useAdminMode"; import { DocsLink } from "@/components/DocsLink"; import { DOCS_PATHS } from "@/lib/docsPaths"; -import type { GetSessionTimeAttributionResponse } from "@/utils/wire-types.generated"; +import type { GetSessionTimeAttributionResponse, SubmitSessionReportRequest } from "@/utils/wire-types.generated"; export default function SessionDetailPage() { // useSearchParams() in SessionDetailContent requires a Suspense boundary for static prerender. @@ -279,7 +279,7 @@ function SessionDetailContent() { getAccessToken, { method: 'POST', - body: JSON.stringify({ + body: jsonBody({ tenantId: effectiveTenantId, sessionId, comment, diff --git a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx index fd5937cc..83d1d7a6 100644 --- a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx @@ -7,7 +7,7 @@ import { useAuth } from "../../contexts/AuthContext"; import { useNotifications } from "../../contexts/NotificationContext"; import { api } from "@/lib/api"; import { TokenExpiredError } from "@/lib/authenticatedFetch"; -import { ApiError, apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient"; +import { ApiError, apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient"; import { trackEvent } from "@/lib/appInsights"; import { classifyAccessCheck, type AccessCheckOutcome, type AccessCheckPayload } from "@/lib/accessCheck"; import { primaryClientId } from "@/lib/authApp"; @@ -32,7 +32,20 @@ import { TenantConfiguration, TenantAdmin, DiagnosticsLogPath, NotificationChann import { SECTION_FIELD_MAP, type SectionFieldSpec, type SettingsSectionName } from "./sectionFieldMap"; import { looksLikeGuid, type MemberKind } from "@/utils/principalKeys"; import { type BootstrapSessionItem } from "./components/BootstrapSessionsSection"; -import type { AutopilotConsentStatusResponse, AutopilotConsentUrlResponse, OffboardResponse, TenantFeatureFlagsResponse, TestWebhookNotificationResponse } from "@/utils/wire-types.generated"; +import type { + AddTenantAdminRequest, + AutopilotConsentFailureRequest, + AutopilotConsentStatusResponse, + AutopilotConsentSuccessRequest, + AutopilotConsentUrlResponse, + CreateBootstrapSessionRequest, + OffboardResponse, + PatchTenantConfigurationFieldsRequest, + TenantFeatureFlagsResponse, + TestNotificationChannelRequest, + TestWebhookNotificationResponse, + UpdateMemberPermissionsRequest, +} from "@/utils/wire-types.generated"; /** * Channels for display/editing from a loaded config: prefers notificationChannelsJson; while @@ -828,7 +841,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } // backend verified exactly these fields changed, so merge them locally. await fetchOk(api.config.fields(tenantId), getAccessToken, { method: "PATCH", - body: JSON.stringify({ fields: patchFields, reason: `settings:${sectionName}` }), + body: jsonBody({ fields: patchFields, reason: `settings:${sectionName}` }), }); setConfig({ ...config, ...patchFields } as TenantConfiguration); } @@ -1005,7 +1018,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } try { await fetchOk(api.config.autopilotConsentFailure(tenantId), getAccessToken, { method: "POST", - body: JSON.stringify({ + body: jsonBody({ error: consentError, errorDescription: consentErrorDescription ? decodeURIComponent(consentErrorDescription) : undefined, }), @@ -1065,7 +1078,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } try { await fetchOk(api.config.autopilotConsentSuccess(tenantId), getAccessToken, { method: "POST", - body: JSON.stringify({ trigger }), + body: jsonBody({ trigger }), }); } catch { // swallow — observability only @@ -1170,7 +1183,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } try { const data = await fetchJson(api.config.testNotification(tenantId), getAccessToken, { method: "POST", - body: JSON.stringify({ channelId }), + body: jsonBody({ channelId }), }); setTestChannelResult({ channelId, success: data.success, message: data.message }); } catch (err) { @@ -1340,11 +1353,11 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } // A service principal is stored under app: and is always read-only (Viewer). const body = addingApplication - ? { applicationId: newAdminEmail.trim(), role: "Viewer" } - : { upn: newAdminEmail.trim(), role: newMemberRole }; + ? { applicationId: newAdminEmail.trim(), role: "Viewer", canManageBootstrapTokens: false } + : { upn: newAdminEmail.trim(), role: newMemberRole, canManageBootstrapTokens: false }; await fetchOk(api.tenants.admins(tenantId), getAccessToken, { method: "POST", - body: JSON.stringify(body), + body: jsonBody(body), }); trackEvent("admin_member_added", { role: body.role, kind: newMemberKind }); @@ -1429,7 +1442,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } getAccessToken, { method: "PATCH", - body: JSON.stringify({ role, canManageBootstrapTokens }), + body: jsonBody({ role, canManageBootstrapTokens }), }, ); @@ -1455,7 +1468,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } try { const data = await fetchJson<{ bootstrapUrl?: string }>(api.bootstrap.sessions(), getAccessToken, { method: "POST", - body: JSON.stringify({ tenantId, validityHours, label }), + body: jsonBody({ tenantId, validityHours, label }), }); trackEvent("bootstrap_session_created", { validityHours }); await fetchBootstrapSessions(); diff --git a/src/Web/autopilot-monitor-web/app/settings/components/McpUsersSection.tsx b/src/Web/autopilot-monitor-web/app/settings/components/McpUsersSection.tsx index 872472f0..1fdde4d0 100644 --- a/src/Web/autopilot-monitor-web/app/settings/components/McpUsersSection.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/components/McpUsersSection.tsx @@ -2,14 +2,22 @@ import { useCallback, useEffect, useState } from "react"; import { useAuth } from "../../../contexts/AuthContext"; -import { ApiError, apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient"; +import { ApiError, apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient"; import { api } from "@/lib/api"; import { useTenantList } from "@/hooks/useTenantList"; import { HOME_TENANT_UNRESOLVED } from "@/lib/identityBinding"; import { isApplicationKey, looksLikeGuid, principalLabel } from "@/utils/principalKeys"; import { SectionCardHeader } from "@/components/SectionCardHeader"; import { DOCS_PATHS } from "@/lib/docsPaths"; -import type { AdminConfiguration, GetMcpUsersResponse, McpUserEntry, PlanTierDefinition as PlanTierDefinitionWire, PlanTierDefinitionsResponse } from "@/utils/wire-types.generated"; +import type { + AddMcpUserRequest, + AdminConfiguration, + GetMcpUsersResponse, + McpUserEntry, + PlanTierDefinition as PlanTierDefinitionWire, + PlanTierDefinitionsResponse, + SetUsagePlanRequest, +} from "@/utils/wire-types.generated"; /** One MCP user row and one plan tier — the wire shapes. */ type McpUser = McpUserEntry; @@ -108,7 +116,7 @@ export default function McpUsersSection() { await fetchOk(api.globalConfig.get(), getAccessToken, { method: "PUT", - body: JSON.stringify({ ...config, mcpAccessPolicy: newPolicy }), + body: jsonBody({ ...config, mcpAccessPolicy: newPolicy }), }); setPolicy(newPolicy); @@ -132,7 +140,7 @@ export default function McpUsersSection() { ? { applicationId: newEmail.trim(), homeTenantId: homeTenantPick } : { upn: newEmail.trim(), homeTenantId: needHomeTenant ? homeTenantPick : undefined }; try { - await fetchOk(api.mcpUsers.add(), getAccessToken, { method: "POST", body: JSON.stringify(body) }); + await fetchOk(api.mcpUsers.add(), getAccessToken, { method: "POST", body: jsonBody(body) }); } catch (err) { if (err instanceof ApiError && err.status === 422 && err.code === HOME_TENANT_UNRESOLVED) setNeedHomeTenant(true); throw err; @@ -197,7 +205,7 @@ export default function McpUsersSection() { await fetchOk(api.mcpUsers.setUsagePlan(upn), getAccessToken, { method: "PATCH", - body: JSON.stringify({ usagePlan: usagePlan || null }), + body: jsonBody({ usagePlan: usagePlan || null }), }); setSuccessMessage(`Usage plan for ${upn} updated.`); diff --git a/src/Web/autopilot-monitor-web/app/settings/components/OffboardingSection.tsx b/src/Web/autopilot-monitor-web/app/settings/components/OffboardingSection.tsx index 03eb82c4..be97b715 100644 --- a/src/Web/autopilot-monitor-web/app/settings/components/OffboardingSection.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/components/OffboardingSection.tsx @@ -3,9 +3,10 @@ import { useEffect, useRef, useState } from "react"; import { api } from "@/lib/api"; import { TokenExpiredError } from "@/lib/authenticatedFetch"; -import { apiErrorText, fetchOk } from "@/lib/apiClient"; +import { apiErrorText, fetchOk, jsonBody } from "@/lib/apiClient"; import { SectionCardHeader } from "@/components/SectionCardHeader"; import { DOCS_PATHS } from "@/lib/docsPaths"; +import type { SubmitOffboardingFeedbackRequest } from "@/utils/wire-types.generated"; interface OffboardingInProgressInfo { status: string; // "Queued" | "Initiated" | "InProgress" | "Completed" | "Failed" @@ -272,7 +273,7 @@ function OffboardingInProgressBanner({ try { await fetchOk(api.tenants.offboardFeedback(tenantId), getAccessToken, { method: "POST", - body: JSON.stringify({ comment: trimmed.slice(0, FEEDBACK_MAX_CHARS) }), + body: jsonBody({ comment: trimmed.slice(0, FEEDBACK_MAX_CHARS) }), }); try { window.localStorage.setItem(localStorageKey, "1"); diff --git a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionDelegatedAccess.tsx b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionDelegatedAccess.tsx index 49bf4ab0..94e6e5ec 100644 --- a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionDelegatedAccess.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionDelegatedAccess.tsx @@ -4,14 +4,23 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useAuth } from "@/contexts/AuthContext"; import { useTenantConfig } from "../../TenantConfigContext"; -import { ApiError, apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient"; +import { ApiError, apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient"; import { api } from "@/lib/api"; import { SectionCardHeader } from "@/components/SectionCardHeader"; import { DOCS_PATHS } from "@/lib/docsPaths"; import { isProViaMsp } from "@/lib/edition"; import { buildInviteLink, describeDelegationError, holdRemainingLabel, invitationStatusLabel } from "@/lib/delegations"; import { principalLabel } from "@/utils/principalKeys"; -import type { CreateDelegationInvitationResponse, DelegationAssigneeListResponse, DelegationInvitationListResponse, ManagedTenantListResponse, TenantManagerListResponse } from "@/utils/wire-types.generated"; +import type { + CreateDelegationInvitationResponse, + DelegationAssigneeListResponse, + DelegationAssignRequest, + DelegationInvitationListResponse, + ManagedTenantListResponse, + RemoveManagedTenantRequest, + RevokeTenantManagerRequest, + TenantManagerListResponse, +} from "@/utils/wire-types.generated"; type Confirm = | { kind: "revoke"; homeTenantId: string; label: string } @@ -91,17 +100,14 @@ export function SectionDelegatedAccess() { void run(); }, [load, canEditConfig]); - /** Runs a mutation; a structured error body ({ error, code }) is explained via describeDelegationError. */ + /** Runs a mutation (`body` is a jsonBody() string; undefined ⇒ none); a structured error body ({ error, code }) is explained via describeDelegationError. */ const mutate = useCallback( - async (key: string, url: string, method: string, body: unknown, ok: string): Promise => { + async (key: string, url: string, method: string, body: string | undefined, ok: string): Promise => { setBusy(key); setError(null); try { const init: RequestInit = { method }; - if (body !== undefined) { - init.headers = { "Content-Type": "application/json" }; - init.body = JSON.stringify(body); - } + if (body !== undefined) init.body = body; try { await fetchOk(url, getAccessToken, init); } catch (err) { @@ -225,7 +231,7 @@ export function SectionDelegatedAccess() {