mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
web: request bodies as jsonBody<T> against the generated wire types (D-207)
Every request body in app/, components/, contexts/, hooks/ and lib/ is encoded
as jsonBody<SomeRequest>(…) from lib/apiClient.ts; the apiClient ratchet gains
the `body[:=] JSON.stringify(` pattern (74 → 10 sites) and refuses a bare
jsonBody(. The hand-written RestoreRowRequestBody/RestoreRowMode are the
generated RestoreRowRequest/RestoreRowMode now; scriptDisplayNames posts through
fetchJson; the tenant-group and delegation mutate helpers take a pre-encoded body.
What the typing surfaced and fixes here: canManageBootstrapTokens and `all` are
sent explicitly, AdminConfiguration goes back on the wire through
toWireAdminConfiguration (the outbound twin of fromWire…), TenantManagementSection
uses the wire TenantConfiguration instead of a local subset, nameless CPE entries
are filtered before they are sent, the session action posts
QueueSessionActionRequest, create-from-template sends { variables }.
Remaining baseline (with reason): the three rule/pattern pages, whose local rule
types diverge from the wire — B-cme.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -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<SaveNotificationEmailRequest>({ email }),
|
||||
});
|
||||
|
||||
setEmailStatus("saved");
|
||||
|
||||
@@ -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<UpdateAdminConfigurationResponse>(api.globalConfig.get(), getAccessToken, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(updatedConfig),
|
||||
body: jsonBody<WireAdminConfiguration>(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<UpdateAdminConfigurationResponse>(api.globalConfig.get(), getAccessToken, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(updatedConfig),
|
||||
body: jsonBody<WireAdminConfiguration>(toWireAdminConfiguration(updatedConfig)),
|
||||
});
|
||||
setAdminConfig(fromWireAdminConfiguration(result.config));
|
||||
setGlobalDiagPaths(paths);
|
||||
@@ -484,7 +491,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
|
||||
|
||||
const result = await fetchJson<UpdateAdminConfigurationResponse>(api.globalConfig.get(), getAccessToken, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(updatedConfig),
|
||||
body: jsonBody<WireAdminConfiguration>(toWireAdminConfiguration(updatedConfig)),
|
||||
});
|
||||
setAdminConfig(fromWireAdminConfiguration(result.config));
|
||||
setOpsAlertRules(rules);
|
||||
@@ -515,7 +522,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
|
||||
|
||||
const result = await fetchJson<TestWebhookNotificationResponse>(api.globalConfig.testOpsChannel(), getAccessToken, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ channelId }),
|
||||
body: jsonBody<TestNotificationChannelRequest>({ channelId }),
|
||||
});
|
||||
setTestOpsChannelResult({
|
||||
channelId,
|
||||
|
||||
@@ -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<RestoreRowCommitResponse>(api.backups.restoreRow(backupId), getAccessToken, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
body: jsonBody<RestoreRowRequest>(body),
|
||||
});
|
||||
onCommitted(response);
|
||||
} catch (err) {
|
||||
|
||||
@@ -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<RestoreRowPreviewResponse>(api.backups.restoreRow(backupId), getAccessToken, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
body: jsonBody<RestoreRowRequest>(body),
|
||||
});
|
||||
setActivePreview(preview);
|
||||
} catch (err) {
|
||||
|
||||
@@ -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<BlockDeviceRequest>({
|
||||
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<BlockDeviceRequest>({
|
||||
tenantId: device.tenantId,
|
||||
serialNumber: device.serialNumber,
|
||||
durationHours: remainingHours,
|
||||
|
||||
@@ -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<IdentityBindingRequest>({ homeTenantId: tid, objectId: oid || undefined }),
|
||||
});
|
||||
setSuccessMessage(`Identity binding for ${upn} updated.`);
|
||||
setEditing(null);
|
||||
|
||||
@@ -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<string | null>;
|
||||
@@ -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<IgnoreSoftwareDeleteRequest>({ 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<AutoResolveResult>(api.vulnerability.cpeAutoResolve(), getAccessToken, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
body: jsonBody<AutoResolveCpeMappingRequest>({
|
||||
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<IgnoreSoftwareDeleteRequest>({ softwareName: entry.softwareName, publisher: entry.publisher || "" }),
|
||||
});
|
||||
setIgnoredEntries((prev) => {
|
||||
const next = prev.filter((e) => `${e.softwareName}::${e.publisher}` !== key);
|
||||
|
||||
@@ -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<string | null>;
|
||||
@@ -176,7 +176,7 @@ export function MappedSoftwareTab({
|
||||
getAccessToken,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
body: jsonBody<SaveCustomCpeMappingRequest>({
|
||||
normalizedProduct: entry.normalizedProduct,
|
||||
normalizedVendor: entry.normalizedVendor,
|
||||
cpeUri: cpeUri,
|
||||
@@ -214,7 +214,7 @@ export function MappedSoftwareTab({
|
||||
getAccessToken,
|
||||
{
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({
|
||||
body: jsonBody<DeleteCustomCpeMappingRequest>({
|
||||
normalizedProduct: entry.normalizedProduct,
|
||||
normalizedVendor: entry.normalizedVendor,
|
||||
}),
|
||||
|
||||
@@ -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<UpdateSessionReportNoteRequest>({ adminNote: adminNoteValue }),
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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<AddTenantAdminRequest>(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<UpdateMemberPermissionsRequest>({ role, canManageBootstrapTokens }),
|
||||
});
|
||||
|
||||
setSuccessMessage(`Permissions for ${adminUpn} updated successfully!`);
|
||||
|
||||
@@ -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<ReleaseDelegatedSlotHoldRequest>({ 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<UpdateTenantConfigurationResponse>(api.config.tenant(tenant.tenantId), getAccessToken, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(tenant),
|
||||
body: jsonBody<TenantConfiguration>(tenant),
|
||||
});
|
||||
|
||||
// Update tenant in list
|
||||
@@ -299,7 +264,7 @@ function TenantManagementSectionInner({
|
||||
|
||||
const result = await fetchJson<SetTenantPlanTierResponse>(api.config.plan(tenant.tenantId), getAccessToken, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
body: jsonBody<PatchTenantPlanRequest>({
|
||||
// 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<UpdateTenantAppHomingResponse>(api.config.appHoming(tenant.tenantId), getAccessToken, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ target, force }),
|
||||
body: jsonBody<AppHomingRequest>({ target, force }),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
@@ -435,7 +400,7 @@ function TenantManagementSectionInner({
|
||||
getAccessToken,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: email || "" }),
|
||||
body: jsonBody<SaveNotificationEmailRequest>({ email: email || "" }),
|
||||
}
|
||||
);
|
||||
// The send persisted the address too — keep the search map in step so the tenant is
|
||||
|
||||
@@ -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<string | null>;
|
||||
@@ -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<SaveCustomCpeMappingRequest>({
|
||||
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<AutoResolveCpeMappingRequest>({
|
||||
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<IgnoreSoftwareRequest>({
|
||||
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<IgnoreSoftwareRequest>({
|
||||
items: selectedList.filter(hasSoftwareName).map((e) => ({ softwareName: e.softwareName, publisher: e.publisher || "", reason: "manual" })),
|
||||
}),
|
||||
});
|
||||
setSelectedEntries(new Map());
|
||||
|
||||
@@ -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<BlockVersionRequest>({
|
||||
versionPattern: versionPattern.trim(),
|
||||
action: versionAction,
|
||||
reason: versionReason.trim() || undefined,
|
||||
|
||||
@@ -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<UpdateAdminConfigurationResponse>(api.globalConfig.get(), getAccessToken, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(updatedConfig),
|
||||
body: jsonBody<WireAdminConfiguration>(toWireAdminConfiguration(updatedConfig)),
|
||||
});
|
||||
onAdminConfigUpdated(fromWireAdminConfiguration(result.config));
|
||||
setSuccessMessage("Vulnerability configuration saved successfully!");
|
||||
|
||||
+3
-3
@@ -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<RestoreSessionRequest>({
|
||||
tenantId,
|
||||
manifestId,
|
||||
reason: reason.trim() || null,
|
||||
|
||||
+3
-3
@@ -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<GrantDelegatedAdminRequest>({
|
||||
upn,
|
||||
tenantId: newTenantId,
|
||||
role: newRole,
|
||||
|
||||
@@ -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<EmailTemplateRequest>({ 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<EmailTemplateTestSendResponse>(api.emailTemplates.sendTest(kind), getAccessToken, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(useDraft ? { html: draft } : {}),
|
||||
body: jsonBody<EmailTemplateRequest>(useDraft ? { html: draft } : {}),
|
||||
});
|
||||
flash(`Test ${title.toLowerCase()} sent to ${body?.sentTo ?? "your tenant contact address"}${useDraft ? " (unsaved draft)" : ""}.`);
|
||||
} catch (err) {
|
||||
|
||||
@@ -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<string, string> = {
|
||||
@@ -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<T>() 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<CreateTenantGroupRequest>({ 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<UpdateTenantGroupRequest>({ name }), `Renamed to "${name}".`);
|
||||
},
|
||||
[mutate],
|
||||
);
|
||||
@@ -165,7 +172,7 @@ export function SectionTenantGroups() {
|
||||
`charge:${t.groupId}`,
|
||||
api.tenantGroups.update(t.groupId),
|
||||
"PATCH",
|
||||
{ chargeHomeTenantQuota: next },
|
||||
jsonBody<UpdateTenantGroupRequest>({ 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<AddGroupTenantRequest>({ 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<AssignGroupRequest>({
|
||||
upn,
|
||||
role,
|
||||
homeTenantId: needHomeTenant[t.groupId] ? homeTenantPick[t.groupId] : undefined,
|
||||
},
|
||||
}),
|
||||
`Assigned ${upn} to "${t.name}".`,
|
||||
);
|
||||
if (ok) {
|
||||
|
||||
@@ -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<PlanTierDefinitionsResponse>(api.mcpUsage.planTiers(), getAccessToken, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ tiers }),
|
||||
body: jsonBody<SetPlanTierDefinitionsRequest>({ tiers }),
|
||||
});
|
||||
setTiers(data.tiers || tiers);
|
||||
setHasChanges(false);
|
||||
|
||||
@@ -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<CreateAnalyzeRuleFromTemplateRequest>({ variables }),
|
||||
}
|
||||
);
|
||||
if (result !== null) {
|
||||
|
||||
@@ -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<BlockDeviceRequest>({
|
||||
tenantId: target.tenantId,
|
||||
serialNumber: target.serialNumber,
|
||||
durationHours: 24,
|
||||
|
||||
@@ -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<AcceptDelegationInvitationResponse>(api.delegations.accept(), getAccessToken, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token }),
|
||||
body: jsonBody<AcceptDelegationInvitationRequest>({ token }),
|
||||
}));
|
||||
} catch (err) {
|
||||
setError(explain(err, "Could not accept the invitation."));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<boolean> => {
|
||||
return fetchOk(api.sessions.queueAction(sessionId, effectiveTenantId), getAccessToken, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ type, reason }),
|
||||
body: jsonBody<QueueSessionActionRequest>({ 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<TenantConfiguration>(updated),
|
||||
});
|
||||
|
||||
onDiagnosticsConfigured();
|
||||
|
||||
@@ -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<UpsertSessionAnnotationRequest>(body),
|
||||
}
|
||||
);
|
||||
setAnnotations((prev) => {
|
||||
|
||||
@@ -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<SubmitSessionReportRequest>({
|
||||
tenantId: effectiveTenantId,
|
||||
sessionId,
|
||||
comment,
|
||||
|
||||
@@ -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<PatchTenantConfigurationFieldsRequest>({ 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<AutopilotConsentFailureRequest>({
|
||||
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<AutopilotConsentSuccessRequest>({ trigger }),
|
||||
});
|
||||
} catch {
|
||||
// swallow — observability only
|
||||
@@ -1170,7 +1183,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
try {
|
||||
const data = await fetchJson<TestWebhookNotificationResponse>(api.config.testNotification(tenantId), getAccessToken, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ channelId }),
|
||||
body: jsonBody<TestNotificationChannelRequest>({ 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:<client-id> 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<AddTenantAdminRequest>(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<UpdateMemberPermissionsRequest>({ 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<CreateBootstrapSessionRequest>({ tenantId, validityHours, label }),
|
||||
});
|
||||
trackEvent("bootstrap_session_created", { validityHours });
|
||||
await fetchBootstrapSessions();
|
||||
|
||||
@@ -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<AdminConfiguration>({ ...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<AddMcpUserRequest>(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<SetUsagePlanRequest>({ usagePlan: usagePlan || null }),
|
||||
});
|
||||
|
||||
setSuccessMessage(`Usage plan for ${upn} updated.`);
|
||||
|
||||
@@ -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<SubmitOffboardingFeedbackRequest>({ comment: trimmed.slice(0, FEEDBACK_MAX_CHARS) }),
|
||||
});
|
||||
try {
|
||||
window.localStorage.setItem(localStorageKey, "1");
|
||||
|
||||
+17
-11
@@ -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<T>() 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<boolean | null> => {
|
||||
async (key: string, url: string, method: string, body: string | undefined, ok: string): Promise<boolean | null> => {
|
||||
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() {
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy !== null}
|
||||
onClick={() => mutate("revoke", api.delegations.revokeManager(), "POST", { homeTenantId: m.ownerTenantId }, `Access of ${confirm.label} revoked.`)}
|
||||
onClick={() => mutate("revoke", api.delegations.revokeManager(), "POST", jsonBody<RevokeTenantManagerRequest>({ homeTenantId: m.ownerTenantId ?? "" }), `Access of ${confirm.label} revoked.`)}
|
||||
className="text-xs font-medium text-white bg-red-600 rounded px-2 py-1 hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{busy === "revoke" ? "Revoking…" : "Confirm"}
|
||||
@@ -313,7 +319,7 @@ export function SectionDelegatedAccess() {
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy !== null}
|
||||
onClick={() => mutate("remove", api.delegations.removeManaged(), "POST", { tenantId: t.tenantId }, `${confirm.label} removed.`)}
|
||||
onClick={() => mutate("remove", api.delegations.removeManaged(), "POST", jsonBody<RemoveManagedTenantRequest>({ tenantId: t.tenantId }), `${confirm.label} removed.`)}
|
||||
className="text-xs font-medium text-white bg-red-600 rounded px-2 py-1 hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{busy === "remove" ? "Removing…" : "Confirm"}
|
||||
@@ -452,7 +458,7 @@ export function SectionDelegatedAccess() {
|
||||
type="button"
|
||||
disabled={!assignUpn || busy !== null}
|
||||
onClick={async () => {
|
||||
const ok = await mutate("assign", api.delegations.assignees(), "POST", { upn: assignUpn }, `${principalLabel(assignUpn)} can now read your managed tenants.`);
|
||||
const ok = await mutate("assign", api.delegations.assignees(), "POST", jsonBody<DelegationAssignRequest>({ upn: assignUpn }), `${principalLabel(assignUpn)} can now read your managed tenants.`);
|
||||
if (ok) setAssignUpn("");
|
||||
}}
|
||||
className="shrink-0 px-3 py-1.5 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors"
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useAuth } from "../../../../contexts/AuthContext";
|
||||
import { apiErrorText, fetchOk } from "@/lib/apiClient";
|
||||
import { apiErrorText, fetchOk, jsonBody } from "@/lib/apiClient";
|
||||
import { api } from "@/lib/api";
|
||||
import { trackEvent } from "@/lib/appInsights";
|
||||
import type { SubmitDiagFilesReportRequest } from "@/utils/wire-types.generated";
|
||||
|
||||
const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
const LOG_ACCEPT = ".log,.txt,.zip,.json,.jsonl,.ndjson";
|
||||
@@ -108,7 +109,7 @@ export function SectionSubmitLogs() {
|
||||
getAccessToken,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
body: jsonBody<SubmitDiagFilesReportRequest>({
|
||||
tenantId: user.tenantId,
|
||||
comment,
|
||||
email,
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { nextSlotLimit, slotTenantLabel, type SlotLimitError } from "@/lib/delegatedSlots";
|
||||
import { ApiError, fetchOk } from "@/lib/apiClient";
|
||||
import { ApiError, fetchOk, jsonBody } from "@/lib/apiClient";
|
||||
import type { PatchTenantPlanRequest } from "@/utils/wire-types.generated";
|
||||
|
||||
/**
|
||||
* Raises a managing tenant's delegated slot override via PATCH config/{tenantId}/plan (GlobalAdminOnly).
|
||||
@@ -17,7 +18,7 @@ export async function raiseDelegatedSlotLimit(
|
||||
try {
|
||||
await fetchOk(api.config.plan(homeTenantId), getAccessToken, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ maxDelegatedTenants: newLimit }),
|
||||
body: jsonBody<PatchTenantPlanRequest>({ maxDelegatedTenants: newLimit }),
|
||||
});
|
||||
return null;
|
||||
} catch (err) {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { api } from "@/lib/api";
|
||||
import type { FeedbackEligibilityResponse } from "@/utils/wire-types.generated";
|
||||
import { fetchJson, fetchOk, nullOnApiError } from "@/lib/apiClient";
|
||||
import type { FeedbackEligibilityResponse, FeedbackRequest } from "@/utils/wire-types.generated";
|
||||
import { fetchJson, fetchOk, jsonBody, nullOnApiError } from "@/lib/apiClient";
|
||||
|
||||
type Phase = "loading" | "bubble" | "form" | "thankyou" | "hidden";
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function FeedbackBubble() {
|
||||
try {
|
||||
await fetchOk(api.feedback.submit(), getAccessToken, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ dismissed: true }),
|
||||
body: jsonBody<FeedbackRequest>({ dismissed: true }),
|
||||
});
|
||||
} catch {
|
||||
// Best effort
|
||||
@@ -71,7 +71,7 @@ export default function FeedbackBubble() {
|
||||
try {
|
||||
await fetchOk(api.feedback.submit(), getAccessToken, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ rating, comment: comment.trim() || null, dismissed: false }),
|
||||
body: jsonBody<FeedbackRequest>({ rating, comment: comment.trim() || null, dismissed: false }),
|
||||
});
|
||||
|
||||
setPhase("thankyou");
|
||||
|
||||
@@ -6,7 +6,8 @@ import { api } from '@/lib/api';
|
||||
import { trackEvent } from '@/lib/appInsights';
|
||||
import type { SignalRMessageName } from '@/lib/signalrMessages';
|
||||
import { useAuth } from './AuthContext';
|
||||
import { ApiError, fetchOk } from "@/lib/apiClient";
|
||||
import { ApiError, fetchOk, jsonBody } from "@/lib/apiClient";
|
||||
import type { SignalRJoinGroupRequest, SignalRLeaveGroupRequest } from "@/utils/wire-types.generated";
|
||||
|
||||
// Hub payloads are untyped JSON; mirror @microsoft/signalr's own callback signature so
|
||||
// consumer handlers keep their narrower parameter types without laundering here.
|
||||
@@ -144,7 +145,7 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
|
||||
method: 'POST',
|
||||
// Re-present the stored serial proof for session groups: a roleless
|
||||
// Progress-Portal user's rejoin is refused without it.
|
||||
body: JSON.stringify({
|
||||
body: jsonBody<SignalRJoinGroupRequest>({
|
||||
connectionId,
|
||||
groupName,
|
||||
serialNumber: joinSerialsRef.current.get(groupName),
|
||||
@@ -283,7 +284,7 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
|
||||
try {
|
||||
await fetchOk(api.realtime.joinGroup(), getAccessToken, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
body: jsonBody<SignalRJoinGroupRequest>({
|
||||
connectionId,
|
||||
groupName,
|
||||
serialNumber: options?.serialNumber,
|
||||
@@ -331,7 +332,7 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
await fetchOk(api.realtime.leaveGroup(), getAccessToken, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ connectionId, groupName }),
|
||||
body: jsonBody<SignalRLeaveGroupRequest>({ connectionId, groupName }),
|
||||
});
|
||||
// Confirmed leave: drop the stored serial proof for this group (a refusal lands in the catch, which re-adds the group).
|
||||
joinSerialsRef.current.delete(groupName);
|
||||
|
||||
@@ -16,6 +16,13 @@ export const PERMANENT: Record<string, string> = {
|
||||
"app/dashboard/hooks/useTenantSecurityConfig.ts": "fail-soft banner data; the expiry is the one failure the user must hear about",
|
||||
"app/sessions/hooks/useSessionDetail.ts": "a network failure keeps the retry flag, an expiry must not",
|
||||
"app/settings/components/OffboardingSection.tsx": "expiry message tells the user the offboarding continues in the background",
|
||||
// Rule documents: the pages edit a LOCAL rule shape (app/*/types.ts) whose nested types diverge from the
|
||||
// wire (RuleCondition.itemField optional vs required, suppressByEvent inline vs SuppressByEventConfig,
|
||||
// GatherRule.triggerPhase nullable vs string) and the PUT accepts toggle partials the service merges.
|
||||
// Typing the body against the generated document needs the forms moved onto the wire types first (backlog).
|
||||
"app/analyze-rules/page.tsx": "rule document bodies until the local rule types are the generated ones",
|
||||
"app/gather-rules/page.tsx": "rule document bodies until the local rule types are the generated ones",
|
||||
"app/ime-log-patterns/page.tsx": "pattern document bodies until the local pattern type is the generated one",
|
||||
};
|
||||
|
||||
export const AUTHENTICATEDFETCH_BASELINE: Record<string, number> = {
|
||||
@@ -31,6 +38,17 @@ export const JSONPARSE_BASELINE: Record<string, number> = {
|
||||
"contexts/AuthContext.tsx": 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* `body: JSON.stringify(` sites (D-207): request bodies are `jsonBody<SomeRequest>(…)` so tsc
|
||||
* checks them against the generated request DTO. Scanned over the app dirs AND lib/ (the layer
|
||||
* itself is exempt). Frozen 2026-09-06; only shrinks.
|
||||
*/
|
||||
export const BODY_STRINGIFY_BASELINE: Record<string, number> = {
|
||||
"app/analyze-rules/page.tsx": 5,
|
||||
"app/gather-rules/page.tsx": 3,
|
||||
"app/ime-log-patterns/page.tsx": 2,
|
||||
};
|
||||
|
||||
export const TOKENEXPIRED_BASELINE: Record<string, number> = {
|
||||
"app/admin/ops/session-cleanup/components/MaintenanceStatusBanner.tsx": 1,
|
||||
"app/dashboard/hooks/useBlockDevice.ts": 1,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import {
|
||||
AUTHENTICATEDFETCH_BASELINE,
|
||||
BODY_STRINGIFY_BASELINE,
|
||||
DEDUPEDAUTHFETCH_BASELINE,
|
||||
JSONPARSE_BASELINE,
|
||||
PERMANENT,
|
||||
@@ -23,6 +24,9 @@ import {
|
||||
|
||||
const WEB_ROOT = join(__dirname, "..", "..");
|
||||
const SCAN_DIRS = ["app", "components", "hooks", "contexts"];
|
||||
/** Request bodies are also written from lib/ helpers; the call layer itself is exempt. */
|
||||
const BODY_SCAN_DIRS = [...SCAN_DIRS, "lib"];
|
||||
const BODY_EXEMPT_FILES = new Set(["lib/apiClient.ts"]);
|
||||
/** The lib-level hook is itself a fetchJson consumer and the one place allowed to spell TokenExpiredError. */
|
||||
const EXEMPT_FILES = new Set(["hooks/useAuthenticatedFetch.ts"]);
|
||||
|
||||
@@ -31,6 +35,8 @@ interface Pattern {
|
||||
regex: RegExp;
|
||||
baseline: Record<string, number>;
|
||||
remedy: string;
|
||||
/** Which source map to scan; the default is the app dirs. */
|
||||
scope?: "body";
|
||||
}
|
||||
|
||||
const PATTERNS: Pattern[] = [
|
||||
@@ -52,6 +58,13 @@ const PATTERNS: Pattern[] = [
|
||||
baseline: JSONPARSE_BASELINE,
|
||||
remedy: "fetchJson<T> parses the body; a hand parse hides the error envelope",
|
||||
},
|
||||
{
|
||||
name: "body: JSON.stringify(",
|
||||
regex: /\bbody\s*[:=]\s*JSON\.stringify\(/g,
|
||||
baseline: BODY_STRINGIFY_BASELINE,
|
||||
remedy: "encode request bodies as jsonBody<SomeRequest>(…) from @/lib/apiClient (D-207)",
|
||||
scope: "body",
|
||||
},
|
||||
{
|
||||
name: "instanceof TokenExpiredError",
|
||||
regex: /instanceof TokenExpiredError/g,
|
||||
@@ -78,13 +91,13 @@ function isCommentLine(line: string): boolean {
|
||||
}
|
||||
|
||||
/** file (repo-relative, forward slashes) → code lines. */
|
||||
function scannedSources(): Map<string, string[]> {
|
||||
function scannedSources(dirs: string[] = SCAN_DIRS, exempt: Set<string> = EXEMPT_FILES): Map<string, string[]> {
|
||||
const files: string[] = [];
|
||||
for (const dir of SCAN_DIRS) collectFiles(join(WEB_ROOT, dir), files);
|
||||
for (const dir of dirs) collectFiles(join(WEB_ROOT, dir), files);
|
||||
const out = new Map<string, string[]>();
|
||||
for (const file of files) {
|
||||
const rel = relative(WEB_ROOT, file).replace(/\\/g, "/");
|
||||
if (EXEMPT_FILES.has(rel)) continue;
|
||||
if (exempt.has(rel)) continue;
|
||||
out.set(
|
||||
rel,
|
||||
readFileSync(file, "utf-8")
|
||||
@@ -106,6 +119,7 @@ function countPerFile(sources: Map<string, string[]>, regex: RegExp): Map<string
|
||||
}
|
||||
|
||||
const sources = scannedSources();
|
||||
const bodySources = scannedSources(BODY_SCAN_DIRS, new Set([...EXEMPT_FILES, ...BODY_EXEMPT_FILES]));
|
||||
|
||||
describe("apiClient ratchet", () => {
|
||||
it("scans the app (plausibility floor)", () => {
|
||||
@@ -114,7 +128,7 @@ describe("apiClient ratchet", () => {
|
||||
|
||||
for (const pattern of PATTERNS) {
|
||||
describe(pattern.name, () => {
|
||||
const counts = countPerFile(sources, pattern.regex);
|
||||
const counts = countPerFile(pattern.scope === "body" ? bodySources : sources, pattern.regex);
|
||||
|
||||
it("has no raw site above its baseline", () => {
|
||||
const violations: string[] = [];
|
||||
@@ -142,6 +156,16 @@ describe("apiClient ratchet", () => {
|
||||
});
|
||||
}
|
||||
|
||||
it("never calls jsonBody without an explicit request type argument", () => {
|
||||
const violations: string[] = [];
|
||||
for (const [rel, lines] of bodySources) {
|
||||
lines.forEach((line, i) => {
|
||||
if (/\bjsonBody\(/.test(line)) violations.push(`${rel}:${i + 1}`);
|
||||
});
|
||||
}
|
||||
expect(violations, "jsonBody<SomeRequest>(…) — name the generated request type (utils/wire-types.generated):\n " + violations.join("\n ")).toEqual([]);
|
||||
});
|
||||
|
||||
it("never calls fetchJson without an explicit type argument", () => {
|
||||
const violations: string[] = [];
|
||||
for (const [rel, lines] of sources) {
|
||||
|
||||
@@ -814,15 +814,4 @@ export interface BackupTriggerResponse {
|
||||
statusUrl: string;
|
||||
}
|
||||
|
||||
// ── PR2: single-row restore ───────────────────────────────────────────────
|
||||
|
||||
export type RestoreRowMode = "Preview" | "Commit";
|
||||
|
||||
export interface RestoreRowRequestBody {
|
||||
tableName: string;
|
||||
partitionKey: string;
|
||||
rowKey: string;
|
||||
mode: RestoreRowMode;
|
||||
ifSha256?: string;
|
||||
ifCurrentETag?: string | null;
|
||||
}
|
||||
// ── PR2: single-row restore — request/response types are generated (RestoreRowRequest, RestoreRowMode) ──
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { ApiErrorResponse } from "@/utils/wire-types.generated";
|
||||
* fetchJson<T> — JSON body expected; throws ApiError on non-2xx, empty or malformed body
|
||||
* fetchOk — action calls whose body nobody reads; returns the ok Response (status only)
|
||||
* fetchBlob — downloads
|
||||
* jsonBody<T> — request bodies, typed against the generated request DTO
|
||||
*
|
||||
* Every non-2xx body is the typed envelope `{ error, code, correlationId, hint?,
|
||||
* retryAfterSeconds? }` (D-192); TokenExpiredError from authenticatedFetch passes through
|
||||
@@ -18,6 +19,16 @@ import type { ApiErrorResponse } from "@/utils/wire-types.generated";
|
||||
|
||||
export type GetAccessToken = (forceRefresh?: boolean) => Promise<string | null>;
|
||||
|
||||
/**
|
||||
* The one way a request body is encoded (D-207): the generic names the generated request wire
|
||||
* type (utils/wire-types.generated), so a renamed or retyped backend field fails tsc here instead
|
||||
* of surfacing as a 400 at runtime. The guard refuses `body: JSON.stringify(` and a bare
|
||||
* `jsonBody(` without a type argument.
|
||||
*/
|
||||
export function jsonBody<TReq>(value: TReq): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import { TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import { fetchJson, jsonBody } from "@/lib/apiClient";
|
||||
import type { GetScriptDisplayNamesResponse, ScriptDisplayNamesRequest } from "@/utils/wire-types.generated";
|
||||
|
||||
/** Matches the shape produced by `useAuth().getAccessToken` (may return null when expired). */
|
||||
export type GetAccessToken = (forceRefresh?: boolean) => Promise<string | null>;
|
||||
@@ -80,11 +82,6 @@ function pickString(d: Record<string, unknown>, keys: string[]): string | undefi
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface DisplayNamesResponse {
|
||||
refs: Record<string, string | null>;
|
||||
malformed?: string[];
|
||||
}
|
||||
|
||||
/** Backend's per-request cap on ref count (mirrors `MaxRefsPerRequest` in the C# function). */
|
||||
export const SCRIPT_DISPLAY_NAMES_CHUNK_SIZE = 200;
|
||||
|
||||
@@ -113,16 +110,10 @@ export async function fetchScriptDisplayNames(
|
||||
const chunk = refs.slice(i, i + SCRIPT_DISPLAY_NAMES_CHUNK_SIZE);
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(url, getAccessToken, {
|
||||
const body = await fetchJson<GetScriptDisplayNamesResponse>(url, getAccessToken, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refs: chunk.map(formatRefKey) }),
|
||||
body: jsonBody<ScriptDisplayNamesRequest>({ refs: chunk.map(formatRefKey) }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
// Non-2xx: preserve earlier chunks, stop hammering. UI degrades to "show IDs".
|
||||
break;
|
||||
}
|
||||
const body = (await response.json()) as DisplayNamesResponse;
|
||||
if (body.refs) {
|
||||
Object.assign(merged, body.refs);
|
||||
}
|
||||
@@ -131,9 +122,10 @@ export async function fetchScriptDisplayNames(
|
||||
// can react (token refresh / sign-in redirect). Any partial map collected so far is
|
||||
// sacrificed -- the user is being signed out anyway.
|
||||
if (err instanceof TokenExpiredError) throw err;
|
||||
// Transport-level failure (network timeout, abort, JSON parse, ...): keep whatever
|
||||
// earlier chunks produced and stop. Without this break the exception would bubble to
|
||||
// the hook's outer catch and discard partial results -- defeating the chunked design.
|
||||
// A backend refusal (ApiError) or a transport-level failure (network timeout, abort, ...):
|
||||
// keep whatever earlier chunks produced and stop hammering. UI degrades to "show IDs".
|
||||
// Without this break the exception would bubble to the hook's outer catch and discard
|
||||
// partial results -- defeating the chunked design.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,11 @@ export function fromWireAdminConfiguration(wire: WireAdminConfiguration): AdminC
|
||||
return { ...wire, excessiveEventAutoActionMode: mode };
|
||||
}
|
||||
|
||||
/** The UI configuration back on the wire: an unknown/unset mode is sent as the server default ("Off"). */
|
||||
export function toWireAdminConfiguration(config: AdminConfiguration): WireAdminConfiguration {
|
||||
return { ...config, excessiveEventAutoActionMode: config.excessiveEventAutoActionMode ?? "Off" };
|
||||
}
|
||||
|
||||
/** One parsed entry of AdminConfiguration.opsAlertRulesJson (client-side view). */
|
||||
export interface OpsAlertRule {
|
||||
eventType: string;
|
||||
|
||||
Reference in New Issue
Block a user