Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions apps/api/src/routes/admin-bulk-block.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 2 additions & 8 deletions apps/api/src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ import {
type DevPlanTier,
getDevPlanPremiumWeeklyLimit,
getIncludedResetPassesRemaining,
MAX_BULK_BLOCK_ORGANIZATIONS,
MIN_BULK_BLOCK_SEARCH_LENGTH,
} from "@llmgateway/shared";
import {
getResendClient,
Expand Down Expand Up @@ -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(),
Expand Down
11 changes: 4 additions & 7 deletions ee/admin/src/app/organizations/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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,
Expand Down Expand Up @@ -252,15 +249,15 @@ export default async function OrganizationsPage({
</form>
</header>

{search.trim().length >= BULK_BLOCK_MIN_SEARCH_LENGTH && (
{search.trim().length >= MIN_BULK_BLOCK_SEARCH_LENGTH && (
<div className="flex flex-col items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
Bulk actions apply to every organization matching the current
filter, not just this page.
</p>
<BulkBlockOrgsButton
search={search}
minSearchLength={BULK_BLOCK_MIN_SEARCH_LENGTH}
minSearchLength={MIN_BULK_BLOCK_SEARCH_LENGTH}
onPreview={handlePreviewBulkBlock}
onBulkBlock={handleBulkBlock}
/>
Expand Down
67 changes: 48 additions & 19 deletions ee/admin/src/components/bulk-block-orgs-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,10 @@ export function BulkBlockOrgsButton({
const [preview, setPreview] = useState<BulkBlockPreview | null>(null);
const [confirmation, setConfirmation] = useState("");
const [blocking, setBlocking] = useState(false);
const [error, setError] = useState<string | null>(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<string | null>(null);
const [blockError, setBlockError] = useState<string | null>(null);
const [result, setResult] = useState<BulkBlockResult | null>(null);

const trimmedSearch = search.trim();
Expand All @@ -62,47 +65,68 @@ export function BulkBlockOrgsButton({
const resetState = () => {
setPreview(null);
setConfirmation("");
setError(null);
setPreviewError(null);
setBlockError(null);
setResult(null);
setPreviewLoading(false);
};

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 {
setPreviewError(response.error ?? "Failed to preview bulk block");
}
Comment on lines +79 to +85

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bbd391f, though not by clearing the shared error — that would have broken the other half of this flow. handleConfirm deliberately calls loadPreview() after a failure, so clearing the error on a successful preview would have wiped the block error the reload is meant to accompany.

Split into previewError and blockError instead. loadPreview clears and sets only previewError, so a successful preview drops the stale preview error; the block error survives the reload and both render if they coexist.


Generated by Claude Code

} catch (err) {
setPreviewError(
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 () => {
if (!preview) {
return;
}
setBlocking(true);
setError(null);
setBlockError(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;
}
setBlockError(response.error ?? "Failed to bulk block organizations");
} catch (err) {
setError(
setBlockError(
err instanceof Error
? err.message
: "Failed to bulk block organizations",
);
} 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
Expand Down Expand Up @@ -251,10 +275,15 @@ export function BulkBlockOrgsButton({
</div>
)}

{error && (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
{(blockError || previewError) && (
<div className="space-y-1" role="alert">
{blockError && (
<p className="text-sm text-destructive">{blockError}</p>
)}
{previewError && (
<p className="text-sm text-destructive">{previewError}</p>
)}
</div>
)}

<DialogFooter>
Expand Down
13 changes: 13 additions & 0 deletions packages/shared/src/bulk-block.ts
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 5 additions & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading