mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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:
@@ -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
|
||||
|
||||
+4
-14
@@ -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);
|
||||
|
||||
+5
-16
@@ -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);
|
||||
}
|
||||
|
||||
+5
-23
@@ -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);
|
||||
}
|
||||
|
||||
+12
-20
@@ -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);
|
||||
}
|
||||
|
||||
+5
-25
@@ -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 {
|
||||
|
||||
+15
-32
@@ -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);
|
||||
|
||||
+26
-48
@@ -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.
|
||||
|
||||
+7
-21
@@ -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);
|
||||
}
|
||||
|
||||
+11
-27
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user