mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 12:22:14 +03:00
feat: add connection pagination, health filter, batch delete confirmation, and custom banned keywords (#3454)
Integrated into release/v3.8.17
This commit is contained in:
@@ -125,6 +125,19 @@ export const ACCOUNT_DEACTIVATED_SIGNALS = [
|
||||
"this service has been disabled in this account",
|
||||
];
|
||||
|
||||
// Custom banned signals — loaded from DB settings at runtime.
|
||||
// Combined with ACCOUNT_DEACTIVATED_SIGNALS in isAccountDeactivated().
|
||||
let _customBannedSignals: string[] = [];
|
||||
|
||||
export function setCustomBannedSignals(signals: string[]): void {
|
||||
_customBannedSignals = signals;
|
||||
}
|
||||
|
||||
export function getMergedBannedSignals(): string[] {
|
||||
if (_customBannedSignals.length === 0) return ACCOUNT_DEACTIVATED_SIGNALS;
|
||||
return [...ACCOUNT_DEACTIVATED_SIGNALS, ..._customBannedSignals];
|
||||
}
|
||||
|
||||
// T10 (sub2api PR #1169): Signals that indicate billing credits are exhausted.
|
||||
// Distinct from rate-limit 429 — the account won't recover until credits are added.
|
||||
export const CREDITS_EXHAUSTED_SIGNALS = [
|
||||
@@ -240,7 +253,7 @@ const MALFORMED_REQUEST_PATTERNS = [
|
||||
*/
|
||||
export function isAccountDeactivated(errorText: string): boolean {
|
||||
const lower = String(errorText || "").toLowerCase();
|
||||
return ACCOUNT_DEACTIVATED_SIGNALS.some((sig) => lower.includes(sig));
|
||||
return getMergedBannedSignals().some((sig) => lower.includes(sig));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Badge,
|
||||
Input,
|
||||
Modal,
|
||||
ConfirmModal,
|
||||
CardSkeleton,
|
||||
OAuthModal,
|
||||
KiroOAuthWrapper,
|
||||
@@ -1508,6 +1509,10 @@ export default function ProviderDetailPage() {
|
||||
const [batchDeleting, setBatchDeleting] = useState(false);
|
||||
const [batchUpdating, setBatchUpdating] = useState<"activate" | "deactivate" | null>(null);
|
||||
const [batchRetesting, setBatchRetesting] = useState(false);
|
||||
const [healthFilter, setHealthFilter] = useState<string>("all");
|
||||
const [page, setPage] = useState(0);
|
||||
const PAGE_SIZE = 50;
|
||||
const [batchDeleteConfirmOpen, setBatchDeleteConfirmOpen] = useState(false);
|
||||
const commandCodeAuthWindowRef = useRef<Window | null>(null);
|
||||
const commandCodeAuthTimerRef = useRef<number | null>(null);
|
||||
const pendingRiskActionRef = useRef<(() => void) | null>(null);
|
||||
@@ -2077,10 +2082,13 @@ export default function ProviderDetailPage() {
|
||||
});
|
||||
}, [connections]);
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
const handleBatchDeleteOpenModal = () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
if (!confirm(t("batchDeleteConfirm", { count: selectedIds.size }))) return;
|
||||
setBatchDeleteConfirmOpen(true);
|
||||
};
|
||||
|
||||
const handleBatchDeleteConfirm = async () => {
|
||||
setBatchDeleteConfirmOpen(false);
|
||||
setBatchDeleting(true);
|
||||
try {
|
||||
const res = await fetch("/api/providers", {
|
||||
@@ -4778,50 +4786,149 @@ export default function ProviderDetailPage() {
|
||||
icon="delete"
|
||||
loading={batchDeleting}
|
||||
disabled={bulkBusy && !batchDeleting}
|
||||
onClick={handleBatchDelete}
|
||||
onClick={handleBatchDeleteOpenModal}
|
||||
>
|
||||
{t("batchDeleteSelected", { count: selectedIds.size })}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const isHealthy = (c: ConnectionRowConnection): boolean => {
|
||||
const s = c.testStatus;
|
||||
return c.isActive !== false && (!s || s === "active" || s === "success");
|
||||
};
|
||||
const STATUS_FILTER_OPTIONS = [
|
||||
{ value: "all", label: t("filterAll", "All") },
|
||||
{ value: "active", label: t("filterActive", "Active") },
|
||||
{ value: "error", label: t("filterError", "Error") },
|
||||
{ value: "banned", label: t("filterBanned", "Banned") },
|
||||
{ value: "credits_exhausted", label: t("filterCreditsExhausted", "Credits Exhausted") },
|
||||
];
|
||||
const filtered = healthFilter === "all"
|
||||
? sorted
|
||||
: sorted.filter((c) => {
|
||||
if (healthFilter === "active") return isHealthy(c);
|
||||
if (healthFilter === "error") return !isHealthy(c) && c.testStatus !== "banned" && c.testStatus !== "credits_exhausted";
|
||||
return c.testStatus === healthFilter;
|
||||
});
|
||||
|
||||
const totalFilteredPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||
const clampedPage = Math.min(page, totalFilteredPages - 1);
|
||||
const pageStart = clampedPage * PAGE_SIZE;
|
||||
const pageEnd = pageStart + PAGE_SIZE;
|
||||
|
||||
const filterPills = (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{STATUS_FILTER_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => {
|
||||
setHealthFilter(opt.value);
|
||||
setPage(0);
|
||||
setSelectedIds(new Set());
|
||||
}}
|
||||
className={`px-2.5 py-1 text-xs rounded-full font-medium transition-colors ${
|
||||
healthFilter === opt.value
|
||||
? "bg-primary text-white"
|
||||
: "bg-muted/60 text-text-muted hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const paginationBar = totalFilteredPages > 1 ? (
|
||||
<div className="flex items-center justify-between px-3 py-2 border-t border-border">
|
||||
<span className="text-xs text-text-muted">
|
||||
{pageStart + 1}–{Math.min(pageEnd, filtered.length)} / {filtered.length}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="chevron_left"
|
||||
disabled={clampedPage === 0}
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
/>
|
||||
<span className="text-xs text-text-muted min-w-[4rem] text-center">
|
||||
{clampedPage + 1} / {totalFilteredPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="chevron_right"
|
||||
disabled={clampedPage >= totalFilteredPages - 1}
|
||||
onClick={() => setPage((p) => Math.min(totalFilteredPages - 1, p + 1))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
if (!hasAnyTag) {
|
||||
const pageConnections = filtered.slice(pageStart, pageEnd);
|
||||
const allSelected = pageConnections.length > 0 && pageConnections.every((c) => selectedIds.has(c.id));
|
||||
const someSelected = pageConnections.some((c) => selectedIds.has(c.id));
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 rounded-t-lg border border-b-0 border-border">
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someSelected;
|
||||
}}
|
||||
onChange={handleToggleSelectAll}
|
||||
className="w-4 h-4 rounded border-border text-primary focus:ring-primary/30 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm font-medium text-text-muted">
|
||||
{selectedIds.size > 0
|
||||
? providerCountText(
|
||||
t,
|
||||
"selectedCount",
|
||||
selectedIds.size,
|
||||
"{count} selected",
|
||||
"{count} selected"
|
||||
)
|
||||
: providerCountText(
|
||||
t,
|
||||
"accountsCount",
|
||||
connections.length,
|
||||
"{count} account",
|
||||
"{count} accounts"
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 px-3 py-2 bg-muted/50 rounded-t-lg border border-b-0 border-border">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someSelected;
|
||||
}}
|
||||
onChange={() => {
|
||||
if (allSelected) {
|
||||
const toRemove = new Set(pageConnections.map((c) => c.id));
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of toRemove) next.delete(id);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const c of pageConnections) next.add(c.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 rounded border-border text-primary focus:ring-primary/30 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm font-medium text-text-muted">
|
||||
{selectedIds.size > 0
|
||||
? providerCountText(
|
||||
t,
|
||||
"selectedCount",
|
||||
selectedIds.size,
|
||||
"{count} selected",
|
||||
"{count} selected"
|
||||
)
|
||||
: providerCountText(
|
||||
t,
|
||||
"accountsCount",
|
||||
filtered.length,
|
||||
"{count} account",
|
||||
"{count} accounts"
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
{filterPills}
|
||||
</div>
|
||||
|
||||
{bulkActions}
|
||||
</div>
|
||||
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03] border border-t-0 border-border rounded-b-lg overflow-hidden">
|
||||
{sorted.map((conn, index) => (
|
||||
{pageConnections.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-sm text-text-muted">
|
||||
{t("noFilteredConnections", "No connections match the current filter.")}
|
||||
</div>
|
||||
) : (
|
||||
pageConnections.map((conn, index) => (
|
||||
<ConnectionRow
|
||||
key={conn.id}
|
||||
connection={conn}
|
||||
@@ -4829,7 +4936,7 @@ export default function ProviderDetailPage() {
|
||||
isClaude={providerId === "claude"}
|
||||
codexGlobalServiceMode={codexGlobalServiceMode}
|
||||
isFirst={index === 0}
|
||||
isLast={index === sorted.length - 1}
|
||||
isLast={index === pageConnections.length - 1}
|
||||
isSelected={selectedIds.has(conn.id)}
|
||||
onToggleSelect={() => handleToggleSelectOne(conn.id)}
|
||||
onMoveUp={() => handleSwapPriority(conn, sorted[index - 1])}
|
||||
@@ -4927,15 +5034,17 @@ export default function ProviderDetailPage() {
|
||||
perKeyProxyEnabled={readBooleanToggle(conn.perKeyProxyEnabled, false)}
|
||||
onTogglePerKeyProxyEnabled={(enabled) => handleTogglePerKeyProxyEnabled(conn.id, enabled)}
|
||||
/>
|
||||
))}
|
||||
)))
|
||||
}
|
||||
</div>
|
||||
{paginationBar}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Build ordered tag groups: untagged first, then alphabetically
|
||||
const groupMap = new Map<string, ConnectionRowConnection[]>();
|
||||
for (const conn of sorted) {
|
||||
for (const conn of filtered) {
|
||||
const tag = (conn.providerSpecificData?.tag as string | undefined)?.trim() || "";
|
||||
if (!groupMap.has(tag)) groupMap.set(tag, []);
|
||||
groupMap.get(tag)!.push(conn);
|
||||
@@ -4949,35 +5058,38 @@ export default function ProviderDetailPage() {
|
||||
return (
|
||||
<>
|
||||
{selectedIds.size > 0 || connections.length > 0 ? (
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 rounded-t-lg border border-b-0 border-border">
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someSelected;
|
||||
}}
|
||||
onChange={handleToggleSelectAll}
|
||||
className="w-4 h-4 rounded border-border text-primary focus:ring-primary/30 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm font-medium text-text-muted">
|
||||
{selectedIds.size > 0
|
||||
? providerCountText(
|
||||
t,
|
||||
"selectedCount",
|
||||
selectedIds.size,
|
||||
"{count} selected",
|
||||
"{count} selected"
|
||||
)
|
||||
: providerCountText(
|
||||
t,
|
||||
"accountsCount",
|
||||
connections.length,
|
||||
"{count} account",
|
||||
"{count} accounts"
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 px-3 py-2 bg-muted/50 rounded-t-lg border border-b-0 border-border">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someSelected;
|
||||
}}
|
||||
onChange={handleToggleSelectAll}
|
||||
className="w-4 h-4 rounded border-border text-primary focus:ring-primary/30 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm font-medium text-text-muted">
|
||||
{selectedIds.size > 0
|
||||
? providerCountText(
|
||||
t,
|
||||
"selectedCount",
|
||||
selectedIds.size,
|
||||
"{count} selected",
|
||||
"{count} selected"
|
||||
)
|
||||
: providerCountText(
|
||||
t,
|
||||
"accountsCount",
|
||||
filtered.length,
|
||||
"{count} account",
|
||||
"{count} accounts"
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
{filterPills}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{/* Distribute Proxies lives in the provider toolbar (top action bar);
|
||||
@@ -5313,6 +5425,16 @@ export default function ProviderDetailPage() {
|
||||
onClose={handleCloseAddApiKeyModal}
|
||||
/>
|
||||
)}
|
||||
<ConfirmModal
|
||||
isOpen={batchDeleteConfirmOpen}
|
||||
onClose={() => setBatchDeleteConfirmOpen(false)}
|
||||
onConfirm={handleBatchDeleteConfirm}
|
||||
title={t("batchDeleteConfirmTitle", "Delete connections")}
|
||||
message={t("batchDeleteConfirm", { count: selectedIds.size })}
|
||||
confirmText={t("batchDeleteConfirmButton", "Delete")}
|
||||
cancelText={t("cancel", "Cancel")}
|
||||
loading={batchDeleting}
|
||||
/>
|
||||
{providerId === "codex" && applyCodexModalConnectionId && (
|
||||
<ApplyCodexAuthModal
|
||||
key={applyCodexModalConnectionId}
|
||||
|
||||
@@ -20,6 +20,7 @@ export default function SecurityTab() {
|
||||
const [requireLoginPassword, setRequireLoginPassword] = useState("");
|
||||
const [requireLoginError, setRequireLoginError] = useState("");
|
||||
const [requireLoginLoading, setRequireLoginLoading] = useState(false);
|
||||
const [newBannedKeyword, setNewBannedKeyword] = useState("");
|
||||
|
||||
const t = useTranslations("settings");
|
||||
const tc = useTranslations("common");
|
||||
@@ -112,6 +113,20 @@ export default function SecurityTab() {
|
||||
updateSetting("blockedProviders", updated);
|
||||
};
|
||||
|
||||
const customBannedSignals: string[] = settings.customBannedSignals || [];
|
||||
|
||||
const addBannedKeyword = () => {
|
||||
const keyword = newBannedKeyword.trim().toLowerCase();
|
||||
if (!keyword || customBannedSignals.includes(keyword)) return;
|
||||
updateSetting("customBannedSignals", [...customBannedSignals, keyword]);
|
||||
setNewBannedKeyword("");
|
||||
};
|
||||
|
||||
const removeBannedKeyword = (index: number) => {
|
||||
const updated = customBannedSignals.filter((_, i) => i !== index);
|
||||
updateSetting("customBannedSignals", updated);
|
||||
};
|
||||
|
||||
const handlePasswordChange = async (e) => {
|
||||
e.preventDefault();
|
||||
if (passwords.new !== passwords.confirm) {
|
||||
@@ -368,6 +383,59 @@ export default function SecurityTab() {
|
||||
<SessionInfoCard />
|
||||
<IPFilterSection />
|
||||
<AuthzSection />
|
||||
|
||||
{/* Custom Banned Keywords */}
|
||||
<Card>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="font-medium">{t("customBannedSignals", "Banned Keywords")}</p>
|
||||
<p className="text-sm text-text-muted">
|
||||
{t("customBannedSignalsDesc", "Additional keywords that trigger permanent account ban detection. Built-in keywords always apply.")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t("customBannedSignalsPlaceholder", "e.g. api key revoked")}
|
||||
value={newBannedKeyword}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewBannedKeyword(e.target.value)}
|
||||
onKeyDown={(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") addBannedKeyword();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="add"
|
||||
onClick={addBannedKeyword}
|
||||
disabled={!newBannedKeyword.trim()}
|
||||
>
|
||||
{t("add", "Add")}
|
||||
</Button>
|
||||
</div>
|
||||
{customBannedSignals.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{customBannedSignals.map((keyword, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium border bg-red-500/10 border-red-500/30 text-red-600 dark:text-red-400"
|
||||
>
|
||||
{keyword}
|
||||
<button
|
||||
onClick={() => removeBannedKeyword(index)}
|
||||
className="material-symbols-outlined text-[12px] hover:opacity-70"
|
||||
>
|
||||
close
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-text-muted">
|
||||
{t("noCustomBannedSignals", "No custom keywords. Only built-in keywords are active.")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4000,6 +4000,14 @@
|
||||
"batchRetestSelected": "Retest",
|
||||
"batchActivateSuccess": "Activated {count} connection(s)",
|
||||
"batchDeactivateSuccess": "Deactivated {count} connection(s)",
|
||||
"batchDeleteConfirmTitle": "Delete connections",
|
||||
"batchDeleteConfirmButton": "Delete",
|
||||
"filterAll": "All",
|
||||
"filterActive": "Active",
|
||||
"filterError": "Error",
|
||||
"filterBanned": "Banned",
|
||||
"filterCreditsExhausted": "Credits Exhausted",
|
||||
"noFilteredConnections": "No connections match the current filter.",
|
||||
"failedSetAlias": "Failed to set alias",
|
||||
"setAliasSuccess": "Alias {alias} set",
|
||||
"deleteAliasSuccess": "Alias {alias} deleted",
|
||||
|
||||
@@ -3966,6 +3966,14 @@
|
||||
"batchRetestSelected": "__MISSING__:Retest",
|
||||
"batchActivateSuccess": "__MISSING__:Activated {count} connection(s)",
|
||||
"batchDeactivateSuccess": "__MISSING__:Deactivated {count} connection(s)",
|
||||
"batchDeleteConfirmTitle": "删除连接",
|
||||
"batchDeleteConfirmButton": "删除",
|
||||
"filterAll": "全部",
|
||||
"filterActive": "健康",
|
||||
"filterError": "错误",
|
||||
"filterBanned": "封禁",
|
||||
"filterCreditsExhausted": "额度耗尽",
|
||||
"noFilteredConnections": "没有符合当前筛选条件的连接。",
|
||||
"failedSetAlias": "设置别名失败",
|
||||
"failedSaveConnection": "保存连接失败",
|
||||
"failedSaveConnectionRetry": "无法保存连接。请再试一次。",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { clearHealthCheckLogCache } from "@/lib/tokenHealthCheck";
|
||||
import { setCustomBannedSignals } from "@omniroute/open-sse/services/accountFallback.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -15,7 +16,8 @@ export type RuntimeReloadSection =
|
||||
| "corsOrigins"
|
||||
| "ccBridgeTransforms"
|
||||
| "systemTransforms"
|
||||
| "authzBypass";
|
||||
| "authzBypass"
|
||||
| "bannedSignals";
|
||||
|
||||
export interface RuntimeReloadChange {
|
||||
section: RuntimeReloadSection;
|
||||
@@ -42,6 +44,7 @@ interface RuntimeSettingsSnapshot {
|
||||
ccBridgeTransforms: unknown;
|
||||
systemTransforms: unknown;
|
||||
authzBypass: AuthzBypassSnapshot;
|
||||
customBannedSignals: string[];
|
||||
}
|
||||
|
||||
// Default bypass policy: kill-switch on, `/api/mcp/` bypassable. Mirrors the
|
||||
@@ -67,6 +70,7 @@ const DEFAULT_RUNTIME_SETTINGS_SNAPSHOT: RuntimeSettingsSnapshot = {
|
||||
ccBridgeTransforms: null,
|
||||
systemTransforms: null,
|
||||
authzBypass: DEFAULT_AUTHZ_BYPASS_SNAPSHOT,
|
||||
customBannedSignals: [],
|
||||
};
|
||||
|
||||
let lastAppliedSnapshot: RuntimeSettingsSnapshot | null = null;
|
||||
@@ -246,6 +250,7 @@ export function buildRuntimeSettingsSnapshot(
|
||||
ccBridgeTransforms: parseStoredJson(settings.ccBridgeTransforms, "ccBridgeTransforms"),
|
||||
systemTransforms: parseStoredJson(settings.systemTransforms, "systemTransforms"),
|
||||
authzBypass: normalizeAuthzBypass(settings),
|
||||
customBannedSignals: normalizeStringArray(settings.customBannedSignals),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -526,6 +531,11 @@ export async function applyRuntimeSettings(
|
||||
markChanged("authzBypass");
|
||||
}
|
||||
|
||||
if (force || hasChanged(currentSnapshot.customBannedSignals, previousSnapshot.customBannedSignals)) {
|
||||
setCustomBannedSignals(currentSnapshot.customBannedSignals);
|
||||
markChanged("bannedSignals");
|
||||
}
|
||||
|
||||
lastAppliedSnapshot = currentSnapshot;
|
||||
return changes;
|
||||
}
|
||||
|
||||
@@ -123,6 +123,7 @@ export async function getSettings() {
|
||||
// `applyAuthzBypassSection` → `getAuthzBypassSnapshot()`.
|
||||
localOnlyManageScopeBypassEnabled: true,
|
||||
localOnlyManageScopeBypassPrefixes: ["/api/mcp/"],
|
||||
customBannedSignals: [],
|
||||
proxyEnabled: true,
|
||||
perKeyProxyEnabled: false,
|
||||
};
|
||||
|
||||
@@ -42,6 +42,7 @@ export const updateSettingsSchema = z.object({
|
||||
showProviderTopologyOnHome: z.boolean().optional(),
|
||||
localOnlyManageScopeBypassEnabled: z.boolean().optional(),
|
||||
localOnlyManageScopeBypassPrefixes: z.array(z.string().max(200)).optional(),
|
||||
customBannedSignals: z.array(z.string().max(200)).optional(),
|
||||
debugMode: z.boolean().optional(),
|
||||
hiddenSidebarItems: z.array(z.enum(HIDEABLE_SIDEBAR_ITEM_IDS)).optional(),
|
||||
sidebarSectionOrder: z
|
||||
|
||||
@@ -1186,3 +1186,64 @@ test("G-02: five consecutive 503 service_not_running do NOT trip provider circui
|
||||
);
|
||||
clearProviderFailure("9router"); // cleanup
|
||||
});
|
||||
|
||||
// ── Custom banned signals (PR #3454) ──────────────────────────────────────────
|
||||
// Operators can extend ACCOUNT_DEACTIVATED_SIGNALS with provider-specific
|
||||
// permanent-ban phrasing via Settings → Security. These persist in the
|
||||
// key_value settings store and are applied at boot + on hot-reload through
|
||||
// setCustomBannedSignals(). Regression guard for the merge/detection behavior.
|
||||
|
||||
const {
|
||||
setCustomBannedSignals,
|
||||
getMergedBannedSignals,
|
||||
isAccountDeactivated,
|
||||
ACCOUNT_DEACTIVATED_SIGNALS,
|
||||
} = accountFallback;
|
||||
|
||||
test("getMergedBannedSignals returns built-in list unchanged when no custom signals", () => {
|
||||
setCustomBannedSignals([]);
|
||||
const merged = getMergedBannedSignals();
|
||||
assert.deepEqual(merged, ACCOUNT_DEACTIVATED_SIGNALS);
|
||||
});
|
||||
|
||||
test("getMergedBannedSignals appends custom signals to the built-in list", () => {
|
||||
setCustomBannedSignals(["api key revoked", "tenant suspended"]);
|
||||
const merged = getMergedBannedSignals();
|
||||
// Built-ins still present
|
||||
for (const sig of ACCOUNT_DEACTIVATED_SIGNALS) {
|
||||
assert.ok(merged.includes(sig), `built-in signal "${sig}" must survive merge`);
|
||||
}
|
||||
// Custom appended
|
||||
assert.ok(merged.includes("api key revoked"));
|
||||
assert.ok(merged.includes("tenant suspended"));
|
||||
setCustomBannedSignals([]); // cleanup
|
||||
});
|
||||
|
||||
test("isAccountDeactivated still matches built-in signals when custom list is empty", () => {
|
||||
setCustomBannedSignals([]);
|
||||
assert.equal(isAccountDeactivated("Your account has been suspended"), true);
|
||||
assert.equal(isAccountDeactivated("rate limit exceeded, retry later"), false);
|
||||
});
|
||||
|
||||
test("isAccountDeactivated matches a custom signal after setCustomBannedSignals", () => {
|
||||
setCustomBannedSignals([]);
|
||||
// Before registration the custom phrase is not a ban signal
|
||||
assert.equal(
|
||||
isAccountDeactivated("Error: API key revoked by administrator"),
|
||||
false,
|
||||
"custom phrase must not match before it is registered"
|
||||
);
|
||||
|
||||
setCustomBannedSignals(["api key revoked"]);
|
||||
// Case-insensitive substring match against the merged list
|
||||
assert.equal(
|
||||
isAccountDeactivated("Error: API key revoked by administrator"),
|
||||
true,
|
||||
"custom phrase must match once registered (case-insensitive substring)"
|
||||
);
|
||||
|
||||
// Built-ins remain matchable alongside custom signals
|
||||
assert.equal(isAccountDeactivated("account_deactivated"), true);
|
||||
|
||||
setCustomBannedSignals([]); // cleanup — restore module state for other tests
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user