From ee5d0c636447c56acaaf67b8dad62bafd1b5a1ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 17:59:02 +0000 Subject: [PATCH 1/2] fix: address bulk block review comments - share MIN/MAX bulk block constants via @llmgateway/shared so the admin UI gate cannot drift from what the API enforces - keep the preview dialog out of a stuck loading state if the preview server action rejects - keep the confirmation form visible after a failed bulk block and re-resolve the preview, so a retry confirms against current numbers instead of resubmitting a stale count Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SVus1fjS6z6xppaEai34Uj --- apps/api/src/routes/admin-bulk-block.spec.ts | 4 +- apps/api/src/routes/admin.ts | 10 +---- ee/admin/src/app/organizations/page.tsx | 11 ++--- .../src/components/bulk-block-orgs-button.tsx | 40 ++++++++++++++----- packages/shared/src/bulk-block.ts | 13 ++++++ packages/shared/src/index.ts | 5 +++ 6 files changed, 54 insertions(+), 29 deletions(-) create mode 100644 packages/shared/src/bulk-block.ts diff --git a/apps/api/src/routes/admin-bulk-block.spec.ts b/apps/api/src/routes/admin-bulk-block.spec.ts index b125f6af6a..40415a7dcd 100644 --- a/apps/api/src/routes/admin-bulk-block.spec.ts +++ b/apps/api/src/routes/admin-bulk-block.spec.ts @@ -4,12 +4,10 @@ import { app } from "@/index.js"; import { createTestUser, deleteAll } from "@/testing.js"; import { db, tables } from "@llmgateway/db"; +import { MAX_BULK_BLOCK_ORGANIZATIONS } from "@llmgateway/shared"; const originalAdminEmails = process.env.ADMIN_EMAILS; -// Mirrors MAX_BULK_BLOCK_ORGANIZATIONS in apps/api/src/routes/admin.ts. -const MAX_BULK_BLOCK_ORGANIZATIONS = 500; - interface PreviewResponse { search: string; matched: number; diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index c622d215bb..7aeb4451bf 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -66,6 +66,8 @@ import { type DevPlanTier, getDevPlanPremiumWeeklyLimit, getIncludedResetPassesRemaining, + MAX_BULK_BLOCK_ORGANIZATIONS, + MIN_BULK_BLOCK_SEARCH_LENGTH, } from "@llmgateway/shared"; import { getResendClient, @@ -6248,14 +6250,6 @@ admin.openapi(blockOrganizationRoute, async (c) => { // --- Bulk block organizations (filtered list) --- -// Hard ceiling on a single bulk block. A filter matching more than this is -// treated as too broad to be an intentional selection and is rejected outright -// rather than partially applied. -const MAX_BULK_BLOCK_ORGANIZATIONS = 500; -// A one or two character filter matches far too much to be a deliberate -// selection, so require something specific enough to identify a set of orgs. -const MIN_BULK_BLOCK_SEARCH_LENGTH = 3; - const bulkBlockOrganizationSchema = z.object({ id: z.string(), name: z.string(), diff --git a/ee/admin/src/app/organizations/page.tsx b/ee/admin/src/app/organizations/page.tsx index 68ce49b8c9..5d75afd1e3 100644 --- a/ee/admin/src/app/organizations/page.tsx +++ b/ee/admin/src/app/organizations/page.tsx @@ -32,6 +32,8 @@ import { requireSession } from "@/lib/require-session"; import { createServerApiClient } from "@/lib/server-api"; import { cn } from "@/lib/utils"; +import { MIN_BULK_BLOCK_SEARCH_LENGTH } from "@llmgateway/shared"; + type SortBy = | "name" | "billingEmail" @@ -44,11 +46,6 @@ type SortBy = | "totalSpent"; type SortOrder = "asc" | "desc"; -// Mirrors MIN_BULK_BLOCK_SEARCH_LENGTH in apps/api/src/routes/admin.ts. The -// server rejects anything shorter; this only hides the entry point so the -// action never looks available for an empty or near-empty filter. -const BULK_BLOCK_MIN_SEARCH_LENGTH = 3; - function SortableHeader({ label, sortKey, @@ -252,7 +249,7 @@ export default async function OrganizationsPage({ - {search.trim().length >= BULK_BLOCK_MIN_SEARCH_LENGTH && ( + {search.trim().length >= MIN_BULK_BLOCK_SEARCH_LENGTH && (

Bulk actions apply to every organization matching the current @@ -260,7 +257,7 @@ export default async function OrganizationsPage({

diff --git a/ee/admin/src/components/bulk-block-orgs-button.tsx b/ee/admin/src/components/bulk-block-orgs-button.tsx index 94c8a2754a..ba444fbcf5 100644 --- a/ee/admin/src/components/bulk-block-orgs-button.tsx +++ b/ee/admin/src/components/bulk-block-orgs-button.tsx @@ -67,17 +67,30 @@ export function BulkBlockOrgsButton({ setPreviewLoading(false); }; + const loadPreview = async () => { + setPreview(null); + setConfirmation(""); + setPreviewLoading(true); + try { + const response = await onPreview(trimmedSearch); + if (response.success && response.preview) { + setPreview(response.preview); + } else { + setError(response.error ?? "Failed to preview bulk block"); + } + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to preview bulk block", + ); + } finally { + setPreviewLoading(false); + } + }; + const handleOpen = async () => { resetState(); setOpen(true); - setPreviewLoading(true); - const response = await onPreview(trimmedSearch); - setPreviewLoading(false); - if (response.success && response.preview) { - setPreview(response.preview); - } else { - setError(response.error ?? "Failed to preview bulk block"); - } + await loadPreview(); }; const handleConfirm = async () => { @@ -88,12 +101,17 @@ export function BulkBlockOrgsButton({ setError(null); try { const response = await onBulkBlock(preview.search, preview.blockable); - setResult(response); if (response.success) { + setResult(response); router.refresh(); - } else { - setError(response.error ?? "Failed to bulk block organizations"); + return; } + // A failure keeps the dialog on the confirmation step instead of showing + // a summary. The most likely cause is the server rejecting a count that + // no longer matches, so re-resolve the set: the admin then confirms + // against current numbers rather than resubmitting the stale one. + setError(response.error ?? "Failed to bulk block organizations"); + await loadPreview(); } catch (err) { setError( err instanceof Error diff --git a/packages/shared/src/bulk-block.ts b/packages/shared/src/bulk-block.ts new file mode 100644 index 0000000000..5ebf9caecb --- /dev/null +++ b/packages/shared/src/bulk-block.ts @@ -0,0 +1,13 @@ +// Limits for the admin "bulk block organizations" action. Shared so the admin +// dashboard gates on the same numbers the API enforces — the API is the only +// thing that actually protects the data, and a UI copy that drifts from it +// would show or hide the action for filters the server disagrees about. + +// A one or two character filter matches far too much to be a deliberate +// selection, so require something specific enough to identify a set of orgs. +export const MIN_BULK_BLOCK_SEARCH_LENGTH = 3; + +// Hard ceiling on a single bulk block. A filter matching more than this is +// treated as too broad to be an intentional selection and is rejected outright +// rather than partially applied. +export const MAX_BULK_BLOCK_ORGANIZATIONS = 500; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9d05e96a08..83cdaf3532 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -143,6 +143,11 @@ export { MARKETING_STATS, RUNWARE_PROMO } from "./marketing.js"; export { isContentFilterErrorText } from "./content-filter.js"; +export { + MAX_BULK_BLOCK_ORGANIZATIONS, + MIN_BULK_BLOCK_SEARCH_LENGTH, +} from "./bulk-block.js"; + export { CUSTOM_PROVIDER_NAME_MESSAGE, CUSTOM_PROVIDER_NAME_REGEX, From bbd391fc7a7118f4e17d8122b262e7768b44694c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 18:29:46 +0000 Subject: [PATCH 2/2] fix: separate preview and block error state A failed block re-resolves the preview so the admin confirms against current numbers. With a single error state that reload cleared the block error it was meant to accompany, so preview and block failures are now tracked separately and rendered independently. Also re-resolve the preview when the block request throws: it may have been applied server-side before the connection failed, leaving preview.blockable stale for the next confirmation. --- .../src/components/bulk-block-orgs-button.tsx | 43 ++++++++++++------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/ee/admin/src/components/bulk-block-orgs-button.tsx b/ee/admin/src/components/bulk-block-orgs-button.tsx index ba444fbcf5..37a4952018 100644 --- a/ee/admin/src/components/bulk-block-orgs-button.tsx +++ b/ee/admin/src/components/bulk-block-orgs-button.tsx @@ -53,7 +53,10 @@ export function BulkBlockOrgsButton({ const [preview, setPreview] = useState(null); const [confirmation, setConfirmation] = useState(""); const [blocking, setBlocking] = useState(false); - const [error, setError] = useState(null); + // Preview and block failures are tracked separately: a failed block re-resolves + // the preview, and that reload must not clear the block error it accompanies. + const [previewError, setPreviewError] = useState(null); + const [blockError, setBlockError] = useState(null); const [result, setResult] = useState(null); const trimmedSearch = search.trim(); @@ -62,7 +65,8 @@ export function BulkBlockOrgsButton({ const resetState = () => { setPreview(null); setConfirmation(""); - setError(null); + setPreviewError(null); + setBlockError(null); setResult(null); setPreviewLoading(false); }; @@ -70,16 +74,17 @@ export function BulkBlockOrgsButton({ const loadPreview = async () => { setPreview(null); setConfirmation(""); + setPreviewError(null); setPreviewLoading(true); try { const response = await onPreview(trimmedSearch); if (response.success && response.preview) { setPreview(response.preview); } else { - setError(response.error ?? "Failed to preview bulk block"); + setPreviewError(response.error ?? "Failed to preview bulk block"); } } catch (err) { - setError( + setPreviewError( err instanceof Error ? err.message : "Failed to preview bulk block", ); } finally { @@ -98,7 +103,7 @@ export function BulkBlockOrgsButton({ return; } setBlocking(true); - setError(null); + setBlockError(null); try { const response = await onBulkBlock(preview.search, preview.blockable); if (response.success) { @@ -106,14 +111,9 @@ export function BulkBlockOrgsButton({ router.refresh(); return; } - // A failure keeps the dialog on the confirmation step instead of showing - // a summary. The most likely cause is the server rejecting a count that - // no longer matches, so re-resolve the set: the admin then confirms - // against current numbers rather than resubmitting the stale one. - setError(response.error ?? "Failed to bulk block organizations"); - await loadPreview(); + setBlockError(response.error ?? "Failed to bulk block organizations"); } catch (err) { - setError( + setBlockError( err instanceof Error ? err.message : "Failed to bulk block organizations", @@ -121,6 +121,12 @@ export function BulkBlockOrgsButton({ } finally { setBlocking(false); } + // Only reached when the block failed — the success path returns above. The + // dialog stays on the confirmation step instead of showing a summary, and + // re-resolving the set makes the admin confirm against current numbers + // rather than resubmitting a stale one. This also covers a thrown request: + // it may still have been applied server-side before the connection failed. + await loadPreview(); }; // The admin has to retype the exact number of organizations the server @@ -269,10 +275,15 @@ export function BulkBlockOrgsButton({
)} - {error && ( -

- {error} -

+ {(blockError || previewError) && ( +
+ {blockError && ( +

{blockError}

+ )} + {previewError && ( +

{previewError}

+ )} +
)}