web(admin): every API call through fetchJson/fetchOk/fetchBlob, errors through apiErrorText (audit package 5, stage 3a)

The 113 raw authenticatedFetch sites under app/admin are gone: 73 by the transformer (fetch + ok-check
+ json parse become one typed call), the rest by hand — 404-as-empty lists read through
.catch(nullOn404), the app-homing, offboard, delegated-admin and tenant-group flows read the
specialised envelope through the new ApiError.body, downloads use fetchBlob, action calls fetchOk.
Session-expired branches that only logged or set the same message are gone; apiErrorText renders
every failure with the reference.

Typing the responses surfaced eight local mirrors that had drifted from the wire types (required
fields the backend leaves optional): BlockedDevice, FeedbackEntry, DistressReport, OpsEvent,
SessionReport, IdentityBinding, UnmatchedSoftwareEntry, DailySummary and the whole
SectionPlatformUsage metric set are now aliases of the generated types, with the optional fields
handled where they render. AdminConfiguration keeps its literal-union narrowing through
fromWireAdminConfiguration instead of an unchecked parse.

Ratchet baseline: 228 → 115 raw fetches, 221 → 111 parses, 140 → 59 token checks.

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