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
131 changes: 103 additions & 28 deletions src/app/(dashboard)/dashboard/providers/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1471,6 +1471,8 @@ export default function ProviderDetailPage() {
const [savingCodexGlobalServiceMode, setSavingCodexGlobalServiceMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [batchDeleting, setBatchDeleting] = useState(false);
const [batchUpdating, setBatchUpdating] = useState<"activate" | "deactivate" | null>(null);
const [batchRetesting, setBatchRetesting] = useState(false);
const commandCodeAuthWindowRef = useRef<Window | null>(null);
const commandCodeAuthTimerRef = useRef<number | null>(null);
const pendingRiskActionRef = useRef<(() => void) | null>(null);
Expand Down Expand Up @@ -1981,6 +1983,35 @@ export default function ProviderDetailPage() {
}
};

const handleBatchSetActive = async (isActive: boolean) => {
if (selectedIds.size === 0 || batchUpdating) return;
setBatchUpdating(isActive ? "activate" : "deactivate");
try {
const res = await fetch("/api/providers", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: Array.from(selectedIds), isActive }),
});

if (res.ok) {
const data = await res.json();
await fetchConnections();
notify.success(
isActive
? t("batchActivateSuccess", { count: data.updated })
: t("batchDeactivateSuccess", { count: data.updated })
);
} else {
const data = await res.json().catch(() => ({}));
notify.error(data.error?.message || data.error || "Batch update failed");
}
} catch {
notify.error("Network error during batch update");
} finally {
setBatchUpdating(null);
}
};

const handleOAuthSuccess = useCallback(() => {
fetchConnections();
setShowOAuthModal(false);
Expand Down Expand Up @@ -2836,18 +2867,16 @@ export default function ProviderDetailPage() {
}
};

// Batch test all connections for this provider
const handleBatchTestAll = async () => {
if (batchTesting || connections.length === 0) return;
setBatchTesting(true);
// Shared runner for batch connection tests (all-for-provider or selected IDs)
const runBatchTest = async (payload: Record<string, unknown>) => {
setBatchTestResults(null);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 120_000); // 2min max
try {
const res = await fetch("/api/providers/test-batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode: "provider", providerId }),
body: JSON.stringify(payload),
signal: controller.signal,
});
let data: any;
Expand Down Expand Up @@ -2878,10 +2907,31 @@ export default function ProviderDetailPage() {
notify.error(msg);
} finally {
clearTimeout(timeoutId);
}
};

// Batch test all connections for this provider
const handleBatchTestAll = async () => {
if (batchTesting || connections.length === 0) return;
setBatchTesting(true);
try {
await runBatchTest({ mode: "provider", providerId });
} finally {
setBatchTesting(false);
}
};

// Batch retest only the selected connections
const handleBatchRetest = async () => {
if (batchRetesting || selectedIds.size === 0) return;
setBatchRetesting(true);
try {
await runBatchTest({ mode: "selected", connectionIds: Array.from(selectedIds) });
} finally {
setBatchRetesting(false);
}
};

// T12: Manual token refresh
const [refreshingId, setRefreshingId] = useState<string | null>(null);

Expand Down Expand Up @@ -4504,6 +4554,51 @@ export default function ProviderDetailPage() {
);
const allSelected = selectedIds.size === connections.length && connections.length > 0;
const someSelected = selectedIds.size > 0 && selectedIds.size < connections.length;
const bulkBusy = batchUpdating !== null || batchRetesting || batchDeleting;
const bulkActions = selectedIds.size > 0 && (
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
variant="secondary"
size="sm"
icon="toggle_on"
loading={batchUpdating === "activate"}
disabled={bulkBusy && batchUpdating !== "activate"}
onClick={() => handleBatchSetActive(true)}
>
{t("batchActivateSelected")}
</Button>
<Button
variant="secondary"
size="sm"
icon="toggle_off"
loading={batchUpdating === "deactivate"}
disabled={bulkBusy && batchUpdating !== "deactivate"}
onClick={() => handleBatchSetActive(false)}
>
{t("batchDeactivateSelected")}
</Button>
<Button
variant="secondary"
size="sm"
icon="play_arrow"
loading={batchRetesting}
disabled={(bulkBusy && !batchRetesting) || !!retestingId}
onClick={handleBatchRetest}
>
{t("batchRetestSelected")}
</Button>
<Button
variant="danger"
size="sm"
icon="delete"
loading={batchDeleting}
disabled={bulkBusy && !batchDeleting}
onClick={handleBatchDelete}
>
{t("batchDeleteSelected", { count: selectedIds.size })}
</Button>
</div>
);

if (!hasAnyTag) {
return (
Expand All @@ -4526,17 +4621,7 @@ export default function ProviderDetailPage() {
</span>
</label>

{selectedIds.size > 0 && (
<Button
variant="danger"
size="sm"
icon="delete"
loading={batchDeleting}
onClick={handleBatchDelete}
>
{t("batchDeleteSelected", { count: selectedIds.size })}
</Button>
)}
{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) => (
Expand Down Expand Up @@ -4685,7 +4770,7 @@ export default function ProviderDetailPage() {
</span>
</label>

<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center justify-end gap-2">
{selectedIds.size === 0 && connections.length > 0 && (
<Button
variant="secondary"
Expand All @@ -4697,17 +4782,7 @@ export default function ProviderDetailPage() {
{t("distributeProxies")}
</Button>
)}
{selectedIds.size > 0 && (
<Button
variant="danger"
size="sm"
icon="delete"
loading={batchDeleting}
onClick={handleBatchDelete}
>
{t("batchDeleteSelected", { count: selectedIds.size })}
</Button>
)}
{bulkActions}
</div>
</div>
) : null}
Expand Down
62 changes: 61 additions & 1 deletion src/app/api/providers/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
getProviderConnections,
createProviderConnection,
deleteProviderConnections,
updateProviderConnection,
getProviderNodeById,
isCloudEnabled,
} from "@/models";
Expand All @@ -18,7 +19,10 @@ import {
} from "@/shared/constants/providers";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/lib/cloudSync";
import { createProviderSchema } from "@/shared/validation/schemas";
import {
createProviderSchema,
batchUpdateProviderConnectionsSchema,
} from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { normalizeQoderPatProviderData } from "@omniroute/open-sse/services/qoderCli";
import {
Expand Down Expand Up @@ -195,6 +199,62 @@ export async function POST(request: Request) {
}
}

// PATCH /api/providers - Bulk activate/deactivate connections
export async function PATCH(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;

const auditContext = getAuditRequestContext(request);

let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}

const validation = validateBody(batchUpdateProviderConnectionsSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { ids, isActive } = validation.data;

try {
// Partial-failure semantics: report unknown IDs instead of failing the whole batch
const updatedIds: string[] = [];
const notFoundIds: string[] = [];
for (const id of ids) {
const updated = await updateProviderConnection(id, { isActive });
if (updated) updatedIds.push(id);
else notFoundIds.push(id);
}

await syncToCloudIfEnabled();

logAuditEvent({
action: "provider.credentials.batch_updated",
actor: "admin",
resourceType: "provider_credentials",
status: "success",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: Audit event status is always "success" even when notFoundIds contains items (partial failure). Consider either logging a separate audit event for unknown IDs, or changing the status to reflect partial success (e.g., "partial_success"). The metadata includes all requested IDs plus the notFound array which helps, but the status should accurately reflect the outcome.

ipAddress: auditContext.ipAddress || undefined,
requestId: auditContext.requestId,
metadata: { isActive, updated: updatedIds.length, notFound: notFoundIds, ids },
});

return NextResponse.json(
{
message: `${isActive ? "Activated" : "Deactivated"} ${updatedIds.length} connection(s)`,
updated: updatedIds.length,
notFound: notFoundIds,
},
{ status: 200 }
);
} catch (error) {
console.log("Error batch updating connections:", error);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CRITICAL: Error logging uses console.log instead of console.error for error-level logging. Additionally, error objects are logged directly which could expose sensitive information in production logs. Use a structured logger or sanitize error details before logging.

return NextResponse.json({ error: "Failed to batch update connections" }, { status: 500 });
}
}

export async function DELETE(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
Expand Down
18 changes: 13 additions & 5 deletions src/app/api/providers/test-batch/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,22 @@ export async function POST(request) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { mode, providerId } = validation.data;
const { mode, providerId, connectionIds } = validation.data;

// Fetch all active connections
const allConnections = await getProviderConnections({ isActive: true });
// Fetch connections to test. mode=selected targets explicit IDs and must
// also reach inactive connections (matching single-connection retest);
// every other mode tests active connections only.
const allConnections =
mode === "selected"
? await getProviderConnections()
: await getProviderConnections({ isActive: true });

// Filter based on mode
let connectionsToTest = [];
if (mode === "provider" && providerId) {
if (mode === "selected") {
const idSet = new Set(connectionIds || []);
connectionsToTest = allConnections.filter((c) => idSet.has(c.id));
} else if (mode === "provider" && providerId) {
connectionsToTest = allConnections.filter((c) => c.provider === providerId);
} else if (mode === "oauth") {
connectionsToTest = allConnections.filter((c) => {
Expand Down Expand Up @@ -121,7 +129,7 @@ export async function POST(request) {
return NextResponse.json(
{
error:
"Invalid mode. Use: provider, oauth, free, no-auth, apikey, compatible, all, web-cookie, search, audio, local, upstream-proxy, cloud-agent, ide",
"Invalid mode. Use: provider, oauth, free, no-auth, apikey, compatible, all, web-cookie, search, audio, local, upstream-proxy, cloud-agent, ide, selected",
},
{ status: 400 }
);
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -3745,6 +3745,11 @@
"batchDeleteSelected": "حذف المحدد ({count})",
"batchDeleteConfirm": "هل تريد حذف {count} اتصال(ات)؟ لا يمكن التراجع عن هذا الإجراء.",
"batchDeleteSuccess": "تم حذف {count} اتصال(ات)",
"batchActivateSelected": "__MISSING__:Activate",
"batchDeactivateSelected": "__MISSING__:Deactivate",
"batchRetestSelected": "__MISSING__:Retest",
"batchActivateSuccess": "__MISSING__:Activated {count} connection(s)",
"batchDeactivateSuccess": "__MISSING__:Deactivated {count} connection(s)",
"failedSetAlias": "فشل في تعيين الاسم المستعار",
"failedSaveConnection": "فشل حفظ الاتصال",
"failedSaveConnectionRetry": "فشل حفظ الاتصال. يرجى المحاولة مرة أخرى.",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/messages/az.json
Original file line number Diff line number Diff line change
Expand Up @@ -3745,6 +3745,11 @@
"batchDeleteSelected": "Delete Selected ({count})",
"batchDeleteConfirm": "Delete {count} connection(s)? This action cannot be undone.",
"batchDeleteSuccess": "Deleted {count} connection(s)",
"batchActivateSelected": "__MISSING__:Activate",
"batchDeactivateSelected": "__MISSING__:Deactivate",
"batchRetestSelected": "__MISSING__:Retest",
"batchActivateSuccess": "__MISSING__:Activated {count} connection(s)",
"batchDeactivateSuccess": "__MISSING__:Deactivated {count} connection(s)",
"failedSetAlias": "Failed to set alias",
"failedSaveConnection": "Failed to save connection",
"failedSaveConnectionRetry": "Failed to save connection. Please try again.",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/messages/bg.json
Original file line number Diff line number Diff line change
Expand Up @@ -3745,6 +3745,11 @@
"batchDeleteSelected": "Delete Selected ({count})",
"batchDeleteConfirm": "Delete {count} connection(s)? This action cannot be undone.",
"batchDeleteSuccess": "Deleted {count} connection(s)",
"batchActivateSelected": "__MISSING__:Activate",
"batchDeactivateSelected": "__MISSING__:Deactivate",
"batchRetestSelected": "__MISSING__:Retest",
"batchActivateSuccess": "__MISSING__:Activated {count} connection(s)",
"batchDeactivateSuccess": "__MISSING__:Deactivated {count} connection(s)",
"failedSetAlias": "Неуспешно задаване на псевдоним",
"failedSaveConnection": "Неуспешно запазване на връзката",
"failedSaveConnectionRetry": "Неуспешно запазване на връзката. Моля, опитайте отново.",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/messages/bn.json
Original file line number Diff line number Diff line change
Expand Up @@ -3745,6 +3745,11 @@
"batchDeleteSelected": "Delete Selected ({count})",
"batchDeleteConfirm": "Delete {count} connection(s)? This action cannot be undone.",
"batchDeleteSuccess": "Deleted {count} connection(s)",
"batchActivateSelected": "__MISSING__:Activate",
"batchDeactivateSelected": "__MISSING__:Deactivate",
"batchRetestSelected": "__MISSING__:Retest",
"batchActivateSuccess": "__MISSING__:Activated {count} connection(s)",
"batchDeactivateSuccess": "__MISSING__:Deactivated {count} connection(s)",
"failedSetAlias": "Failed to set alias",
"failedSaveConnection": "Failed to save connection",
"failedSaveConnectionRetry": "Failed to save connection. Please try again.",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/messages/cs.json
Original file line number Diff line number Diff line change
Expand Up @@ -3745,6 +3745,11 @@
"batchDeleteSelected": "Delete Selected ({count})",
"batchDeleteConfirm": "Delete {count} connection(s)? This action cannot be undone.",
"batchDeleteSuccess": "Deleted {count} connection(s)",
"batchActivateSelected": "__MISSING__:Activate",
"batchDeactivateSelected": "__MISSING__:Deactivate",
"batchRetestSelected": "__MISSING__:Retest",
"batchActivateSuccess": "__MISSING__:Activated {count} connection(s)",
"batchDeactivateSuccess": "__MISSING__:Deactivated {count} connection(s)",
"failedSetAlias": "Nastavení aliasu selhalo",
"failedSaveConnection": "Uložení připojení selhalo",
"failedSaveConnectionRetry": "Uložení připojení selhalo. Zkuste to prosím znovu.",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/messages/da.json
Original file line number Diff line number Diff line change
Expand Up @@ -3745,6 +3745,11 @@
"batchDeleteSelected": "Delete Selected ({count})",
"batchDeleteConfirm": "Delete {count} connection(s)? This action cannot be undone.",
"batchDeleteSuccess": "Deleted {count} connection(s)",
"batchActivateSelected": "__MISSING__:Activate",
"batchDeactivateSelected": "__MISSING__:Deactivate",
"batchRetestSelected": "__MISSING__:Retest",
"batchActivateSuccess": "__MISSING__:Activated {count} connection(s)",
"batchDeactivateSuccess": "__MISSING__:Deactivated {count} connection(s)",
"failedSetAlias": "Aliaset kunne ikke indstilles",
"failedSaveConnection": "Forbindelsen kunne ikke gemmes",
"failedSaveConnectionRetry": "Forbindelsen kunne ikke gemmes. Prøv venligst igen.",
Expand Down
Loading