diff --git a/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js b/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js
index 0a272045..11c9be78 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js
+++ b/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js
@@ -47,7 +47,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
const [mode, setMode] = useState("single"); // "single" | "bulk"
const [bulkText, setBulkText] = useState("");
- const [bulkResult, setBulkResult] = useState(null); // { success, failed }
+ const [bulkResult, setBulkResult] = useState(null); // { success, failed, duplicateLines }
const buildProviderSpecificData = () => {
if (isOllamaLocal && formData.ollamaHostUrl.trim()) {
@@ -137,6 +137,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
setBulkResult(null);
let success = 0;
let failed = 0;
+ const duplicateLines = [];
for (let i = 0; i < lines.length; i++) {
const parts = lines[i].split("|");
const baseName = parts.length >= 2 ? parts[0].trim() : "Key";
@@ -165,14 +166,20 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
...(providerSpecificData ? { providerSpecificData } : {}),
}),
});
- if (res.ok) success++;
- else failed++;
+ if (res.ok) {
+ success++;
+ } else {
+ failed++;
+ const data = await res.json().catch(() => null);
+ if (res.status === 409) duplicateLines.push(i + 1);
+ if (data?.error) console.log(`Bulk API key add failed on line ${i + 1}:`, data.error);
+ }
} catch {
failed++;
}
}
setSaving(false);
- setBulkResult({ success, failed });
+ setBulkResult({ success, failed, duplicateLines });
if (success > 0 && onBulkDone) onBulkDone();
};
@@ -203,7 +210,14 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
/>
{bulkResult && (
0 ? "text-yellow-400" : "text-green-400"}`}>
- ✓ {bulkResult.success} added{bulkResult.failed > 0 ? `, ✗ ${bulkResult.failed} failed` : ""}
+
+ ✓ {bulkResult.success} added{bulkResult.failed > 0 ? `, ✗ ${bulkResult.failed} failed` : ""}
+
+ {bulkResult.duplicateLines.length > 0 && (
+
+ Account/API key already exists in the system (line{bulkResult.duplicateLines.length > 1 ? "s" : ""} {bulkResult.duplicateLines.join(", ")}).
+
+ )}
)}
diff --git a/src/app/(dashboard)/dashboard/providers/page.js b/src/app/(dashboard)/dashboard/providers/page.js
index dd134f33..958c4f10 100644
--- a/src/app/(dashboard)/dashboard/providers/page.js
+++ b/src/app/(dashboard)/dashboard/providers/page.js
@@ -22,6 +22,7 @@ import Link from "next/link";
import { getErrorCode, getRelativeTime } from "@/shared/utils";
import { useNotificationStore } from "@/store/notificationStore";
import { useHeaderSearchStore } from "@/store/headerSearchStore";
+import useUserStore from "@/store/userStore";
import ModelAvailabilityBadge from "./components/ModelAvailabilityBadge";
import AddCompatibleModal from "./components/AddCompatibleModal";
@@ -105,6 +106,7 @@ export default function ProvidersPage() {
const [testingMode, setTestingMode] = useState(null);
const [testResults, setTestResults] = useState(null);
const notify = useNotificationStore();
+ const user = useUserStore((state) => state.user);
const searchQuery = useHeaderSearchStore((s) => s.query);
const registerSearch = useHeaderSearchStore((s) => s.register);
const unregisterSearch = useHeaderSearchStore((s) => s.unregister);
@@ -281,12 +283,18 @@ export default function ProvidersPage() {
"oauth",
);
const freeEntries = Object.entries(FREE_PROVIDERS)
- .filter(([, info]) => !info.hidden && matchSearch(info.name))
+ .filter(
+ ([, info]) =>
+ !info.hidden &&
+ (user?.role === "admin" || !info.noAuth) &&
+ matchSearch(info.name),
+ )
.sort(([, a], [, b]) => (b.noAuth ? 1 : 0) - (a.noAuth ? 1 : 0));
const freeTierEntries = sortByPriority(
Object.entries(FREE_TIER_PROVIDERS).filter(
([, info]) =>
!info.hidden &&
+ (user?.role === "admin" || !info.noAuth) &&
matchSearch(info.name) &&
(info.serviceKinds ?? ["llm"]).includes("llm"),
),
diff --git a/src/app/api/oauth/[provider]/[action]/route.js b/src/app/api/oauth/[provider]/[action]/route.js
index 57bff272..2edc3932 100644
--- a/src/app/api/oauth/[provider]/[action]/route.js
+++ b/src/app/api/oauth/[provider]/[action]/route.js
@@ -7,6 +7,7 @@ import {
pollForToken
} from "@/lib/oauth/providers";
import { createProviderConnection } from "@/models";
+import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess";
import {
startCodexProxy,
stopCodexProxy,
@@ -20,7 +21,7 @@ import {
clearXaiSession,
} from "@/lib/oauth/utils/server";
-async function completeXaiManualCode(code, state) {
+async function completeXaiManualCode(code, state, ownerId) {
const session = state ? getXaiSessionStatus(state) : null;
if (!session) {
throw new Error("xAI OAuth session not found; restart the login flow and paste the code again");
@@ -38,6 +39,7 @@ async function completeXaiManualCode(code, state) {
const connection = await createProviderConnection({
provider: "xai",
authType: "oauth",
+ ownerId,
...tokenData,
expiresAt: tokenData.expiresIn
? new Date(Date.now() + tokenData.expiresIn * 1000).toISOString()
@@ -68,6 +70,7 @@ async function completeXaiManualCode(code, state) {
// GET /api/oauth/[provider]/device-code - Request device code (for device_code flow)
export async function GET(request, { params }) {
try {
+ const { user } = await getProviderConnectionAccess();
const { provider, action } = await params;
const { searchParams } = new URL(request.url);
@@ -98,8 +101,8 @@ export async function GET(request, { params }) {
let serverSide = false;
if (result.success && state && codeVerifier && redirectUri) {
serverSide = provider === "xai"
- ? registerXaiSession({ state, codeVerifier, redirectUri })
- : registerCodexSession({ state, codeVerifier, redirectUri });
+ ? registerXaiSession({ state, codeVerifier, redirectUri, ownerId: user.id })
+ : registerCodexSession({ state, codeVerifier, redirectUri, ownerId: user.id });
}
return NextResponse.json({ ...result, serverSide });
}
@@ -178,6 +181,9 @@ export async function GET(request, { params }) {
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
} catch (error) {
+ if (error.message === "Unauthorized") {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
console.log("OAuth GET error:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
@@ -187,6 +193,7 @@ export async function GET(request, { params }) {
// POST /api/oauth/[provider]/poll - Poll for token (device_code flow)
export async function POST(request, { params }) {
try {
+ const { user } = await getProviderConnectionAccess();
const { provider, action } = await params;
let body;
try {
@@ -223,6 +230,7 @@ export async function POST(request, { params }) {
const connection = await createProviderConnection({
provider,
authType: "access_token",
+ ownerId: user.id,
accessToken: code,
email: email || null,
providerSpecificData,
@@ -253,6 +261,7 @@ export async function POST(request, { params }) {
const connection = await createProviderConnection({
provider,
authType: "oauth",
+ ownerId: user.id,
...tokenData,
expiresAt: tokenData.expiresIn
? new Date(Date.now() + tokenData.expiresIn * 1000).toISOString()
@@ -307,6 +316,7 @@ export async function POST(request, { params }) {
const connection = await createProviderConnection({
provider,
authType: "oauth",
+ ownerId: user.id,
...result.tokens,
expiresAt: result.tokens.expiresIn
? new Date(Date.now() + result.tokens.expiresIn * 1000).toISOString()
@@ -339,13 +349,20 @@ export async function POST(request, { params }) {
return NextResponse.json({ error: "Manual code only supported for xai" }, { status: 400 });
}
const { code, state } = body;
- const connection = await completeXaiManualCode(String(code || "").trim(), String(state || "").trim());
+ const connection = await completeXaiManualCode(
+ String(code || "").trim(),
+ String(state || "").trim(),
+ user.id,
+ );
return NextResponse.json({ success: true, connection });
}
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
} catch (error) {
+ if (error.message === "Unauthorized") {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
console.log("OAuth POST error:", error);
- return NextResponse.json({ error: error.message }, { status: 500 });
+ return NextResponse.json({ error: error.message }, { status: error.status || 500 });
}
}
diff --git a/src/app/api/oauth/codex/bulk-import/route.js b/src/app/api/oauth/codex/bulk-import/route.js
index 42d5341f..5cba4f48 100644
--- a/src/app/api/oauth/codex/bulk-import/route.js
+++ b/src/app/api/oauth/codex/bulk-import/route.js
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { createProviderConnection } from "@/models";
import { extractCodexAccountInfo } from "@/lib/oauth/providers";
+import { requireCurrentDashboardUser } from "@/lib/auth/currentUser";
/**
* POST /api/oauth/codex/bulk-import
@@ -17,6 +18,16 @@ import { extractCodexAccountInfo } from "@/lib/oauth/providers";
* Tokens are NEVER echoed back in the response.
*/
export async function POST(request) {
+ let user;
+ try {
+ user = await requireCurrentDashboardUser();
+ } catch (error) {
+ if (error.message === "Unauthorized") {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+ return NextResponse.json({ error: "Failed to authenticate user" }, { status: 500 });
+ }
+
let body;
try {
body = await request.json();
@@ -64,6 +75,7 @@ export async function POST(request) {
id: _id,
provider: _provider,
authType: _authType,
+ ownerId: _ownerId,
createdAt: _createdAt,
updatedAt: _updatedAt,
...item
@@ -106,13 +118,14 @@ export async function POST(request) {
const created = await createProviderConnection({
provider: "codex",
authType: "oauth",
+ ownerId: user.id,
...item,
});
results.push({ index: i, ok: true, id: created.id });
success++;
} catch (e) {
- results.push({ index: i, ok: false, error: e.message || "Unknown error" });
+ results.push({ index: i, ok: false, error: e.message || "Unknown error", status: e.status || null });
failed++;
}
}
diff --git a/src/app/api/oauth/codex/import-token/route.js b/src/app/api/oauth/codex/import-token/route.js
index 73e80d52..84458898 100644
--- a/src/app/api/oauth/codex/import-token/route.js
+++ b/src/app/api/oauth/codex/import-token/route.js
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { createProviderConnection } from "@/models";
import { extractCodexAccountInfo } from "@/lib/oauth/providers";
+import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess";
/**
* POST /api/oauth/codex/import-token
@@ -11,6 +12,7 @@ import { extractCodexAccountInfo } from "@/lib/oauth/providers";
*/
export async function POST(request) {
try {
+ const { user } = await getProviderConnectionAccess();
const { accessToken, name } = await request.json();
if (!accessToken || typeof accessToken !== "string") {
@@ -71,6 +73,7 @@ export async function POST(request) {
const connection = await createProviderConnection({
provider: "codex",
authType: "access_token",
+ ownerId: user.id,
accessToken: token,
name: connectionName,
email: email,
@@ -90,7 +93,10 @@ export async function POST(request) {
},
});
} catch (error) {
+ if (error.message === "Unauthorized") {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
console.log("Codex access token import error:", error);
- return NextResponse.json({ error: error.message }, { status: 500 });
+ return NextResponse.json({ error: error.message }, { status: error.status || 500 });
}
}
diff --git a/src/app/api/oauth/cursor/import/route.js b/src/app/api/oauth/cursor/import/route.js
index a1695e92..68240453 100644
--- a/src/app/api/oauth/cursor/import/route.js
+++ b/src/app/api/oauth/cursor/import/route.js
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { CursorService } from "@/lib/oauth/services/cursor";
import { createProviderConnection } from "@/models";
+import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess";
/**
* POST /api/oauth/cursor/import
@@ -12,6 +13,7 @@ import { createProviderConnection } from "@/models";
*/
export async function POST(request) {
try {
+ const { user } = await getProviderConnectionAccess();
const { accessToken, machineId } = await request.json();
if (!accessToken || typeof accessToken !== "string") {
@@ -43,6 +45,7 @@ export async function POST(request) {
const connection = await createProviderConnection({
provider: "cursor",
authType: "oauth",
+ ownerId: user.id,
accessToken: tokenData.accessToken,
refreshToken: null, // Cursor doesn't have public refresh endpoint
expiresAt: new Date(Date.now() + tokenData.expiresIn * 1000).toISOString(),
@@ -65,8 +68,11 @@ export async function POST(request) {
},
});
} catch (error) {
+ if (error.message === "Unauthorized") {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
console.log("Cursor import token error:", error);
- return NextResponse.json({ error: error.message }, { status: 500 });
+ return NextResponse.json({ error: error.message }, { status: error.status || 500 });
}
}
diff --git a/src/app/api/oauth/gitlab/pat/route.js b/src/app/api/oauth/gitlab/pat/route.js
index 286ddc24..e901b525 100644
--- a/src/app/api/oauth/gitlab/pat/route.js
+++ b/src/app/api/oauth/gitlab/pat/route.js
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { createProviderConnection } from "@/models";
+import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess";
const GITLAB_DEFAULT_BASE = "https://gitlab.com";
@@ -9,6 +10,7 @@ const GITLAB_DEFAULT_BASE = "https://gitlab.com";
*/
export async function POST(request) {
try {
+ const { user: dashboardUser } = await getProviderConnectionAccess();
let body;
try {
body = await request.json();
@@ -39,6 +41,7 @@ export async function POST(request) {
await createProviderConnection({
provider: "gitlab",
authType: "oauth",
+ ownerId: dashboardUser.id,
accessToken: token.trim(),
refreshToken: null,
expiresAt: null,
@@ -56,7 +59,10 @@ export async function POST(request) {
return NextResponse.json({ success: true });
} catch (error) {
+ if (error.message === "Unauthorized") {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
console.error("GitLab PAT auth error:", error);
- return NextResponse.json({ error: error.message }, { status: 500 });
+ return NextResponse.json({ error: error.message }, { status: error.status || 500 });
}
}
diff --git a/src/app/api/oauth/iflow/cookie/route.js b/src/app/api/oauth/iflow/cookie/route.js
index fe0ea979..f164be41 100644
--- a/src/app/api/oauth/iflow/cookie/route.js
+++ b/src/app/api/oauth/iflow/cookie/route.js
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { createProviderConnection } from "@/models";
+import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess";
/**
* iFlow Cookie-Based Authentication
@@ -8,6 +9,7 @@ import { createProviderConnection } from "@/models";
*/
export async function POST(request) {
try {
+ const { user } = await getProviderConnectionAccess();
const { cookie } = await request.json();
if (!cookie || typeof cookie !== "string") {
@@ -109,6 +111,7 @@ export async function POST(request) {
const connection = await createProviderConnection({
provider: "iflow",
authType: "cookie",
+ ownerId: user.id,
name: refreshedKey.name || keyData.name,
email: refreshedKey.name || keyData.name,
apiKey: refreshedKey.apiKey,
@@ -131,7 +134,10 @@ export async function POST(request) {
},
});
} catch (error) {
+ if (error.message === "Unauthorized") {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
console.error("iFlow cookie auth error:", error);
- return NextResponse.json({ error: error.message }, { status: 500 });
+ return NextResponse.json({ error: error.message }, { status: error.status || 500 });
}
}
diff --git a/src/app/api/oauth/kiro/api-key/route.js b/src/app/api/oauth/kiro/api-key/route.js
index 139df9b5..a5a417da 100644
--- a/src/app/api/oauth/kiro/api-key/route.js
+++ b/src/app/api/oauth/kiro/api-key/route.js
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { KiroService } from "@/lib/oauth/services/kiro";
import { createProviderConnection } from "@/models";
+import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess";
/**
* POST /api/oauth/kiro/api-key
@@ -10,6 +11,7 @@ import { createProviderConnection } from "@/models";
*/
export async function POST(request) {
try {
+ const { user } = await getProviderConnectionAccess();
const { apiKey, region } = await request.json();
if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) {
@@ -35,6 +37,7 @@ export async function POST(request) {
const connection = await createProviderConnection({
provider: "kiro",
authType: "api_key",
+ ownerId: user.id,
accessToken: credential.accessToken,
refreshToken: null,
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),
@@ -57,7 +60,13 @@ export async function POST(request) {
},
});
} catch (error) {
+ if (error.message === "Unauthorized") {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
console.log("Kiro API key import error:", error);
+ if (error.status === 409) {
+ return NextResponse.json({ error: error.message }, { status: 409 });
+ }
// Do not reflect upstream response body to the client (SSRF hardening)
return NextResponse.json(
{ error: "API key validation failed" },
diff --git a/src/app/api/oauth/kiro/import-cli-proxy/route.js b/src/app/api/oauth/kiro/import-cli-proxy/route.js
index d71d6a7c..7572fd88 100644
--- a/src/app/api/oauth/kiro/import-cli-proxy/route.js
+++ b/src/app/api/oauth/kiro/import-cli-proxy/route.js
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { createProviderConnection } from "@/models";
import { normalizeKiroExternalIdpAuth } from "@/lib/oauth/kiroExternalIdp";
+import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess";
/**
* POST /api/oauth/kiro/import-cli-proxy
@@ -8,6 +9,7 @@ import { normalizeKiroExternalIdpAuth } from "@/lib/oauth/kiroExternalIdp";
*/
export async function POST(request) {
try {
+ const { user } = await getProviderConnectionAccess();
const body = await request.json();
const rawAuth = body?.cliProxyAuth ?? body?.auth ?? body?.json ?? body;
const tokenData = normalizeKiroExternalIdpAuth(rawAuth);
@@ -15,6 +17,7 @@ export async function POST(request) {
const connection = await createProviderConnection({
provider: "kiro",
authType: "oauth",
+ ownerId: user.id,
accessToken: tokenData.accessToken,
refreshToken: tokenData.refreshToken,
expiresAt: tokenData.expiresAt,
@@ -32,9 +35,12 @@ export async function POST(request) {
},
});
} catch (error) {
+ if (error.message === "Unauthorized") {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
return NextResponse.json(
{ error: error?.message || "CLIProxyAPI import failed" },
- { status: 400 }
+ { status: error?.status || 400 }
);
}
}
diff --git a/src/app/api/oauth/kiro/import/route.js b/src/app/api/oauth/kiro/import/route.js
index 46383410..df1b17ef 100644
--- a/src/app/api/oauth/kiro/import/route.js
+++ b/src/app/api/oauth/kiro/import/route.js
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { KiroService } from "@/lib/oauth/services/kiro";
import { createProviderConnection } from "@/models";
+import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess";
/**
* POST /api/oauth/kiro/import
@@ -10,6 +11,7 @@ import { createProviderConnection } from "@/models";
*/
export async function POST(request) {
try {
+ const { user } = await getProviderConnectionAccess();
const { refreshToken, clientId, clientSecret, region, authMethod, profileArn } = await request.json();
if (!refreshToken || typeof refreshToken !== "string") {
@@ -38,6 +40,7 @@ export async function POST(request) {
const connection = await createProviderConnection({
provider: "kiro",
authType: "oauth",
+ ownerId: user.id,
accessToken: tokenData.accessToken,
refreshToken: tokenData.refreshToken || refreshToken.trim(),
expiresAt: new Date(Date.now() + (tokenData.expiresIn || 3600) * 1000).toISOString(),
@@ -60,7 +63,10 @@ export async function POST(request) {
},
});
} catch (error) {
+ if (error.message === "Unauthorized") {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
console.log("Kiro import token error:", error);
- return NextResponse.json({ error: error.message }, { status: 500 });
+ return NextResponse.json({ error: error.message }, { status: error.status || 500 });
}
}
diff --git a/src/app/api/oauth/kiro/social-exchange/route.js b/src/app/api/oauth/kiro/social-exchange/route.js
index a99f276e..d4a79a0f 100644
--- a/src/app/api/oauth/kiro/social-exchange/route.js
+++ b/src/app/api/oauth/kiro/social-exchange/route.js
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { KiroService } from "@/lib/oauth/services/kiro";
import { createProviderConnection } from "@/models";
+import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess";
/**
* POST /api/oauth/kiro/social-exchange
@@ -9,6 +10,7 @@ import { createProviderConnection } from "@/models";
*/
export async function POST(request) {
try {
+ const { user } = await getProviderConnectionAccess();
const { code, codeVerifier, provider } = await request.json();
if (!code || !codeVerifier) {
@@ -40,6 +42,7 @@ export async function POST(request) {
const connection = await createProviderConnection({
provider: "kiro",
authType: "oauth",
+ ownerId: user.id,
accessToken: tokenData.accessToken,
refreshToken: tokenData.refreshToken,
expiresAt: new Date(Date.now() + tokenData.expiresIn * 1000).toISOString(),
@@ -61,7 +64,10 @@ export async function POST(request) {
},
});
} catch (error) {
+ if (error.message === "Unauthorized") {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
console.log("Kiro social exchange error:", error);
- return NextResponse.json({ error: error.message }, { status: 500 });
+ return NextResponse.json({ error: error.message }, { status: error.status || 500 });
}
}
diff --git a/src/app/api/providers/[id]/models/route.js b/src/app/api/providers/[id]/models/route.js
index 52f0291b..c8d35d72 100644
--- a/src/app/api/providers/[id]/models/route.js
+++ b/src/app/api/providers/[id]/models/route.js
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { getProviderConnectionById } from "@/models";
+import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess";
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
import { GEMINI_CONFIG } from "@/lib/oauth/constants/oauth";
import { refreshGoogleToken, updateProviderCredentials } from "@/sse/services/tokenRefresh";
@@ -393,7 +394,8 @@ const PROVIDER_MODELS_CONFIG = {
export async function GET(request, { params }) {
try {
const { id } = await params;
- const connection = await getProviderConnectionById(id);
+ const { ownerId } = await getProviderConnectionAccess();
+ const connection = await getProviderConnectionById(id, ownerId);
if (!connection) {
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
@@ -546,6 +548,9 @@ export async function GET(request, { params }) {
models
});
} catch (error) {
+ if (error.message === "Unauthorized") {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
console.log("Error fetching provider models:", error);
return NextResponse.json({ error: "Failed to fetch models" }, { status: 500 });
}
diff --git a/src/app/api/providers/[id]/route.js b/src/app/api/providers/[id]/route.js
index 6ab51797..0d980f22 100644
--- a/src/app/api/providers/[id]/route.js
+++ b/src/app/api/providers/[id]/route.js
@@ -5,6 +5,7 @@ import {
updateProviderConnection,
deleteProviderConnection,
} from "@/models";
+import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess";
function normalizeProxyConfig(body = {}) {
const hasAnyProxyField =
@@ -63,7 +64,8 @@ function shouldMergeProviderSpecificData(existing, incoming, hasLegacyProxy, has
export async function GET(request, { params }) {
try {
const { id } = await params;
- const connection = await getProviderConnectionById(id);
+ const { ownerId } = await getProviderConnectionAccess();
+ const connection = await getProviderConnectionById(id, ownerId);
if (!connection) {
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
@@ -78,6 +80,9 @@ export async function GET(request, { params }) {
return NextResponse.json({ connection: result });
} catch (error) {
+ if (error.message === "Unauthorized") {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
console.log("Error fetching connection:", error);
return NextResponse.json({ error: "Failed to fetch connection" }, { status: 500 });
}
@@ -87,6 +92,7 @@ export async function GET(request, { params }) {
export async function PUT(request, { params }) {
try {
const { id } = await params;
+ const { ownerId } = await getProviderConnectionAccess();
const body = await request.json();
const {
name,
@@ -101,7 +107,7 @@ export async function PUT(request, { params }) {
providerSpecificData
} = body;
- const existing = await getProviderConnectionById(id);
+ const existing = await getProviderConnectionById(id, ownerId);
if (!existing) {
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
}
@@ -166,6 +172,9 @@ export async function PUT(request, { params }) {
return NextResponse.json({ connection: result });
} catch (error) {
+ if (error.message === "Unauthorized") {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
console.log("Error updating connection:", error);
return NextResponse.json({ error: "Failed to update connection" }, { status: 500 });
}
@@ -175,6 +184,12 @@ export async function PUT(request, { params }) {
export async function DELETE(request, { params }) {
try {
const { id } = await params;
+ const { ownerId } = await getProviderConnectionAccess();
+
+ const existing = await getProviderConnectionById(id, ownerId);
+ if (!existing) {
+ return NextResponse.json({ error: "Connection not found" }, { status: 404 });
+ }
const deleted = await deleteProviderConnection(id);
if (!deleted) {
@@ -183,6 +198,9 @@ export async function DELETE(request, { params }) {
return NextResponse.json({ message: "Connection deleted successfully" });
} catch (error) {
+ if (error.message === "Unauthorized") {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
console.log("Error deleting connection:", error);
return NextResponse.json({ error: "Failed to delete connection" }, { status: 500 });
}
diff --git a/src/app/api/providers/[id]/test-models/route.js b/src/app/api/providers/[id]/test-models/route.js
index 84946396..235827aa 100644
--- a/src/app/api/providers/[id]/test-models/route.js
+++ b/src/app/api/providers/[id]/test-models/route.js
@@ -4,6 +4,7 @@ import { getProviderModels, PROVIDER_ID_TO_ALIAS } from "open-sse/config/provide
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
import { UPDATER_CONFIG } from "@/shared/constants/config";
import { pingModelByKind } from "@/app/api/models/test/ping";
+import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess";
/**
* POST /api/providers/[id]/test-models
@@ -13,7 +14,8 @@ import { pingModelByKind } from "@/app/api/models/test/ping";
export async function POST(request, { params }) {
try {
const { id } = await params;
- const connection = await getProviderConnectionById(id);
+ const { ownerId } = await getProviderConnectionAccess();
+ const connection = await getProviderConnectionById(id, ownerId);
if (!connection) {
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
}
@@ -60,6 +62,9 @@ export async function POST(request, { params }) {
return NextResponse.json({ provider: providerId, connectionId: id, results });
} catch (error) {
+ if (error.message === "Unauthorized") {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
console.log("Error testing models:", error);
return NextResponse.json({ error: "Test failed" }, { status: 500 });
}
diff --git a/src/app/api/providers/[id]/test/route.js b/src/app/api/providers/[id]/test/route.js
index 0fa641a3..a3f65e2c 100644
--- a/src/app/api/providers/[id]/test/route.js
+++ b/src/app/api/providers/[id]/test/route.js
@@ -1,10 +1,17 @@
import { NextResponse } from "next/server";
import { testSingleConnection } from "./testUtils.js";
+import { getProviderConnectionById } from "@/lib/localDb";
+import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess";
// POST /api/providers/[id]/test - Test connection
export async function POST(request, { params }) {
try {
const { id } = await params;
+ const { ownerId } = await getProviderConnectionAccess();
+ const connection = await getProviderConnectionById(id, ownerId);
+ if (!connection) {
+ return NextResponse.json({ error: "Connection not found" }, { status: 404 });
+ }
const result = await testSingleConnection(id);
if (result.error === "Connection not found") {
@@ -17,6 +24,9 @@ export async function POST(request, { params }) {
refreshed: result.refreshed || false,
});
} catch (error) {
+ if (error.message === "Unauthorized") {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
console.log("Error testing connection:", error);
return NextResponse.json({ error: "Test failed" }, { status: 500 });
}
diff --git a/src/app/api/providers/client/route.js b/src/app/api/providers/client/route.js
index be5342c1..81e67fe5 100644
--- a/src/app/api/providers/client/route.js
+++ b/src/app/api/providers/client/route.js
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { getProviderConnections } from "@/lib/localDb";
import { backfillCodexEmails } from "@/lib/oauth/providers";
import { USAGE_APIKEY_PROVIDERS, USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
+import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess";
const SAFE_FIELDS = [
"id", "provider", "authType", "name", "email", "displayName",
@@ -76,6 +77,7 @@ function sortConnections(connections, sort) {
export async function GET(request) {
try {
+ const { ownerId } = await getProviderConnectionAccess();
await backfillCodexEmails();
const { searchParams } = new URL(request.url);
@@ -85,7 +87,7 @@ export async function GET(request) {
const page = parsePositiveInt(searchParams.get("page"), 1);
const pageSize = Math.min(parsePositiveInt(searchParams.get("pageSize"), DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE);
- const allConnections = await getProviderConnections();
+ const allConnections = await getProviderConnections(ownerId ? { ownerId } : {});
const eligibleConnections = allConnections.filter(isUsageEligible);
const providerOptions = Array.from(new Set(eligibleConnections.map((conn) => conn.provider))).sort();
@@ -121,6 +123,9 @@ export async function GET(request) {
},
});
} catch (error) {
+ if (error.message === "Unauthorized") {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
console.log("Error fetching providers for client:", error);
return NextResponse.json({ error: "Failed to fetch providers" }, { status: 500 });
}
diff --git a/src/app/api/providers/route.js b/src/app/api/providers/route.js
index 5885472b..a1b5b054 100644
--- a/src/app/api/providers/route.js
+++ b/src/app/api/providers/route.js
@@ -9,6 +9,7 @@ import {
import { APIKEY_PROVIDERS } from "@/shared/constants/config";
import { AI_PROVIDERS, FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, isCustomEmbeddingProvider } from "@/shared/constants/providers";
import { normalizeProviderId, normalizeProviderSpecificData } from "@/lib/providerNormalization";
+import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess";
export const dynamic = "force-dynamic";
@@ -49,7 +50,8 @@ async function normalizeProxyPoolId(proxyPoolId) {
// GET /api/providers - List all connections
export async function GET() {
try {
- const connections = await getProviderConnections();
+ const { ownerId } = await getProviderConnectionAccess();
+ const connections = await getProviderConnections(ownerId ? { ownerId } : {});
// Build nodeNameMap for compatible providers (id → name)
let nodeNameMap = {};
@@ -78,6 +80,9 @@ export async function GET() {
return NextResponse.json({ connections: safeConnections });
} catch (error) {
+ if (error.message === "Unauthorized") {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
console.log("Error fetching providers:", error);
return NextResponse.json({ error: "Failed to fetch providers" }, { status: 500 });
}
@@ -86,6 +91,7 @@ export async function GET() {
// POST /api/providers - Create new connection (API Key only, OAuth via separate flow)
export async function POST(request) {
try {
+ const { user } = await getProviderConnectionAccess();
const body = await request.json();
const provider = normalizeProviderId(body.provider);
const { apiKey, name, displayName, priority, globalPriority, defaultModel, testStatus } = body;
@@ -175,6 +181,7 @@ export async function POST(request) {
const newConnection = await createProviderConnection({
provider,
authType: isWebCookieProvider ? "cookie" : "apikey",
+ ownerId: user.id,
name: connectionName,
apiKey: apiKey || "",
priority: priority || 1,
@@ -191,7 +198,13 @@ export async function POST(request) {
return NextResponse.json({ connection: result }, { status: 201 });
} catch (error) {
+ if (error.message === "Unauthorized") {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
console.log("Error creating provider:", error);
- return NextResponse.json({ error: "Failed to create provider" }, { status: 500 });
+ return NextResponse.json(
+ { error: error.status === 409 ? error.message : "Failed to create provider" },
+ { status: error.status || 500 },
+ );
}
}
diff --git a/src/app/api/providers/test-batch/route.js b/src/app/api/providers/test-batch/route.js
index da020cc7..deba3303 100644
--- a/src/app/api/providers/test-batch/route.js
+++ b/src/app/api/providers/test-batch/route.js
@@ -8,6 +8,7 @@ import {
ANTHROPIC_COMPATIBLE_PREFIX,
} from "@/shared/constants/providers";
import { testSingleConnection } from "../[id]/test/testUtils.js";
+import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess";
function getAuthGroup(providerId, connection = null) {
// Prioritize authType from connection if available
@@ -42,6 +43,7 @@ function isCompatibleProvider(providerId) {
// POST /api/providers/test-batch - Test multiple connections by group
export async function POST(request) {
try {
+ const { ownerId } = await getProviderConnectionAccess();
const body = await request.json();
const { mode, providerId } = body;
@@ -49,7 +51,10 @@ export async function POST(request) {
return NextResponse.json({ error: "mode is required" }, { status: 400 });
}
- const allConnections = await getProviderConnections({ isActive: true });
+ const allConnections = await getProviderConnections({
+ isActive: true,
+ ...(ownerId ? { ownerId } : {}),
+ });
let connectionsToTest = [];
if (mode === "provider" && providerId) {
@@ -125,6 +130,9 @@ export async function POST(request) {
},
});
} catch (error) {
+ if (error.message === "Unauthorized") {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
console.log("Error in batch test:", error);
return NextResponse.json({ error: "Batch test failed" }, { status: 500 });
}
diff --git a/src/lib/db/index.js b/src/lib/db/index.js
index ce08aaa1..a843de32 100644
--- a/src/lib/db/index.js
+++ b/src/lib/db/index.js
@@ -82,7 +82,7 @@ export async function exportDb() {
const out = {
settings: await exportSettings(),
users: db.all(`SELECT id, username, password, role, isActive, createdAt, updatedAt FROM users`).map((r) => ({ ...r, isActive: r.isActive === 1 || r.isActive === true })),
- providerConnections: db.all(`SELECT * FROM providerConnections`).map((r) => ({ ...parseJson(r.data, {}), id: r.id, provider: r.provider, authType: r.authType, name: r.name, email: r.email, priority: r.priority, isActive: r.isActive === 1, createdAt: r.createdAt, updatedAt: r.updatedAt })),
+ providerConnections: db.all(`SELECT * FROM providerConnections`).map((r) => ({ ...parseJson(r.data, {}), id: r.id, provider: r.provider, authType: r.authType, name: r.name, email: r.email, ownerId: r.ownerId, priority: r.priority, isActive: r.isActive === 1, createdAt: r.createdAt, updatedAt: r.updatedAt })),
providerNodes: db.all(`SELECT * FROM providerNodes`).map((r) => ({ ...parseJson(r.data, {}), id: r.id, type: r.type, name: r.name, createdAt: r.createdAt, updatedAt: r.updatedAt })),
proxyPools: db.all(`SELECT * FROM proxyPools`).map((r) => ({ ...parseJson(r.data, {}), id: r.id, isActive: r.isActive === 1, testStatus: r.testStatus, createdAt: r.createdAt, updatedAt: r.updatedAt })),
apiKeys: db.all(`SELECT * FROM apiKeys`).map((r) => ({ id: r.id, key: r.key, name: r.name, machineId: r.machineId, ownerId: r.ownerId, isActive: r.isActive === 1, createdAt: r.createdAt })),
@@ -144,11 +144,12 @@ export async function importDb(payload) {
}
}
+ const fallbackOwnerId = db.get(`SELECT id FROM users WHERE role = 'admin' ORDER BY createdAt ASC LIMIT 1`)?.id || null;
for (const c of payload.providerConnections || []) {
- const { id, provider, authType, name, email, priority, isActive, createdAt, updatedAt, ...rest } = c;
+ const { id, provider, authType, name, email, ownerId, priority, isActive, createdAt, updatedAt, ...rest } = c;
db.run(
- `INSERT OR REPLACE INTO providerConnections(id, provider, authType, name, email, priority, isActive, data, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- [id, provider, authType || "oauth", name || null, email || null, priority || null, isActive === false ? 0 : 1, stringifyJson(rest), createdAt || new Date().toISOString(), updatedAt || new Date().toISOString()]
+ `INSERT OR REPLACE INTO providerConnections(id, provider, authType, name, email, ownerId, priority, isActive, data, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [id, provider, authType || "oauth", name || null, email || null, ownerId || fallbackOwnerId, priority || null, isActive === false ? 0 : 1, stringifyJson(rest), createdAt || new Date().toISOString(), updatedAt || new Date().toISOString()]
);
}
for (const n of payload.providerNodes || []) {
diff --git a/src/lib/db/migrations/004-provider-connection-owners.js b/src/lib/db/migrations/004-provider-connection-owners.js
new file mode 100644
index 00000000..620a87a5
--- /dev/null
+++ b/src/lib/db/migrations/004-provider-connection-owners.js
@@ -0,0 +1,20 @@
+// Provider connections belong to the dashboard account that created them.
+// Existing installations retain their connections under the first admin.
+export default {
+ version: 4,
+ name: "provider-connection-owners",
+ up(db) {
+ const columns = db.all(`PRAGMA table_info(providerConnections)`);
+ if (!columns.some((column) => column.name === "ownerId")) {
+ db.exec(`ALTER TABLE providerConnections ADD COLUMN ownerId TEXT`);
+ }
+
+ const admin = db.get(`SELECT id FROM users WHERE role = 'admin' ORDER BY createdAt ASC LIMIT 1`);
+ if (admin) {
+ db.run(
+ `UPDATE providerConnections SET ownerId = ? WHERE ownerId IS NULL OR ownerId = ''`,
+ [admin.id],
+ );
+ }
+ },
+};
\ No newline at end of file
diff --git a/src/lib/db/migrations/index.js b/src/lib/db/migrations/index.js
index 96a509a3..53a3c52c 100644
--- a/src/lib/db/migrations/index.js
+++ b/src/lib/db/migrations/index.js
@@ -4,8 +4,9 @@
import m001 from "./001-initial.js";
import m002 from "./002-users-table.js";
import m003 from "./003-api-key-owners.js";
+import m004 from "./004-provider-connection-owners.js";
-export const MIGRATIONS = [m001, m002, m003].sort((a, b) => a.version - b.version);
+export const MIGRATIONS = [m001, m002, m003, m004].sort((a, b) => a.version - b.version);
export function latestVersion() {
return MIGRATIONS.length ? MIGRATIONS[MIGRATIONS.length - 1].version : 0;
diff --git a/src/lib/db/repos/connectionsRepo.js b/src/lib/db/repos/connectionsRepo.js
index 4181843f..beb0e8df 100644
--- a/src/lib/db/repos/connectionsRepo.js
+++ b/src/lib/db/repos/connectionsRepo.js
@@ -20,6 +20,7 @@ function rowToConn(row) {
authType: row.authType,
name: row.name,
email: row.email,
+ ownerId: row.ownerId,
priority: row.priority,
isActive: row.isActive === 1 || row.isActive === true,
createdAt: row.createdAt,
@@ -28,13 +29,14 @@ function rowToConn(row) {
}
function connToRow(c) {
- const { id, provider, authType, name, email, priority, isActive, createdAt, updatedAt, ...rest } = c;
+ const { id, provider, authType, name, email, ownerId, priority, isActive, createdAt, updatedAt, ...rest } = c;
return {
id,
provider,
authType,
name: name ?? null,
email: email ?? null,
+ ownerId: ownerId ?? null,
priority: priority ?? null,
isActive: isActive === false ? 0 : 1,
data: stringifyJson(rest),
@@ -46,13 +48,13 @@ function connToRow(c) {
function upsert(db, c) {
const r = connToRow(c);
db.run(
- `INSERT INTO providerConnections(id, provider, authType, name, email, priority, isActive, data, createdAt, updatedAt)
- VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `INSERT INTO providerConnections(id, provider, authType, name, email, ownerId, priority, isActive, data, createdAt, updatedAt)
+ VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
provider=excluded.provider, authType=excluded.authType, name=excluded.name,
- email=excluded.email, priority=excluded.priority, isActive=excluded.isActive,
+ email=excluded.email, ownerId=excluded.ownerId, priority=excluded.priority, isActive=excluded.isActive,
data=excluded.data, updatedAt=excluded.updatedAt`,
- [r.id, r.provider, r.authType, r.name, r.email, r.priority, r.isActive, r.data, r.createdAt, r.updatedAt]
+ [r.id, r.provider, r.authType, r.name, r.email, r.ownerId, r.priority, r.isActive, r.data, r.createdAt, r.updatedAt]
);
}
@@ -67,11 +69,54 @@ function deriveConnectionName(data, fallbackName) {
return fallbackName;
}
+function findDuplicateAccount(connections, data) {
+ const incomingCredential = data.accessToken || data.apiKey || data.refreshToken;
+ if (incomingCredential) {
+ const credentialMatch = connections.find((connection) => (
+ connection.accessToken === incomingCredential ||
+ connection.apiKey === incomingCredential ||
+ connection.refreshToken === incomingCredential
+ ));
+ if (credentialMatch) return credentialMatch;
+ }
+
+ if (!data.email) return null;
+
+ const incomingUsername = data.providerSpecificData?.username;
+ const incomingWorkspace = data.providerSpecificData?.chatgptAccountId;
+
+ return connections.find((connection) => {
+ if (connection.email !== data.email) return false;
+
+ const existingWorkspace = connection.providerSpecificData?.chatgptAccountId;
+ if (incomingWorkspace && existingWorkspace) {
+ return incomingWorkspace === existingWorkspace;
+ }
+ if (incomingWorkspace || existingWorkspace) return false;
+
+ const existingUsername = connection.providerSpecificData?.username;
+ if (incomingUsername && existingUsername) {
+ return incomingUsername === existingUsername;
+ }
+ if (incomingUsername || existingUsername) return false;
+
+ return true;
+ });
+}
+
+function duplicateAccountError() {
+ const error = new Error("Account already exists in the system");
+ error.code = "PROVIDER_ACCOUNT_EXISTS";
+ error.status = 409;
+ return error;
+}
+
export async function getProviderConnections(filter = {}) {
const db = await getAdapter();
const where = [];
const params = [];
if (filter.provider) { where.push("provider = ?"); params.push(filter.provider); }
+ if (filter.ownerId) { where.push("ownerId = ?"); params.push(filter.ownerId); }
if (filter.isActive !== undefined) { where.push("isActive = ?"); params.push(filter.isActive ? 1 : 0); }
const sql = `SELECT * FROM providerConnections${where.length ? ` WHERE ${where.join(" AND ")}` : ""}`;
const rows = db.all(sql, params);
@@ -80,9 +125,15 @@ export async function getProviderConnections(filter = {}) {
return list;
}
-export async function getProviderConnectionById(id) {
+export async function getProviderConnectionById(id, ownerId = null) {
const db = await getAdapter();
- const row = db.get(`SELECT * FROM providerConnections WHERE id = ?`, [id]);
+ const where = ["id = ?"];
+ const params = [id];
+ if (ownerId) {
+ where.push("ownerId = ?");
+ params.push(ownerId);
+ }
+ const row = db.get(`SELECT * FROM providerConnections WHERE ${where.join(" AND ")}`, params);
return rowToConn(row);
}
@@ -105,52 +156,17 @@ export async function createProviderConnection(data) {
let result;
db.transaction(() => {
- const all = db.all(`SELECT * FROM providerConnections WHERE provider = ?`, [data.provider]).map(rowToConn);
+ const allProviderConnections = db.all(`SELECT * FROM providerConnections WHERE provider = ?`, [data.provider]).map(rowToConn);
+ const all = data.ownerId
+ ? allProviderConnections.filter((connection) => connection.ownerId === data.ownerId)
+ : allProviderConnections;
- let existing = null;
- if (data.authType === "oauth" && data.email) {
- const incomingUsername = data.providerSpecificData?.username;
- const incomingWs = data.providerSpecificData?.chatgptAccountId;
- existing = all.find(c => {
- if (c.authType !== "oauth" || c.email !== data.email) return false;
-
- // Codex/OpenAI can issue multiple OAuth grants for the same email.
- // Refresh tokens are rotated single-use; collapsing a new login onto an
- // existing bare-email row overwrites the first account's token pair and
- // makes it look "invalid" after adding a second account. Only update an
- // existing Codex row when both rows expose the same ChatGPT account ID.
- if (data.provider === "codex") {
- const existingWs = c.providerSpecificData?.chatgptAccountId;
- return !!incomingWs && !!existingWs && incomingWs === existingWs;
- }
-
- // Workspace providers use workspace ID when both sides have it
- const existingWs = c.providerSpecificData?.chatgptAccountId;
- if (incomingWs && existingWs) return incomingWs === existingWs;
- if (incomingWs && !existingWs) return false;
- if (!incomingWs && existingWs) return false;
- // Non-workspace providers: match on (email + username) so cross-IdP
- // accounts don't overwrite each other. Require username on both sides
- // — if only one side has it, treat as a distinct identity rather than
- // collapsing onto the bare-email fallback (which would re-introduce
- // the cross-IdP overwrite).
- const existingUsername = c.providerSpecificData?.username;
- if (incomingUsername && existingUsername) {
- return incomingUsername === existingUsername;
- }
- if (incomingUsername || existingUsername) return false;
- return true;
- });
- } else if (data.authType === "apikey" && data.name) {
- existing = all.find(c => c.authType === "apikey" && c.name === data.name);
- }
- // access_token: never dedup — user manages duplicates manually
-
- if (existing) {
- const merged = { ...existing, ...data, updatedAt: now };
- upsert(db, merged);
- result = merged;
- return;
+ // Account identities are global: two dashboard users must not register the
+ // same provider account. API-key connections without an account identity
+ // or matching credential remain private and may use the same display name.
+ const existingAccount = findDuplicateAccount(allProviderConnections, data);
+ if (existingAccount) {
+ throw duplicateAccountError();
}
let connectionName = data.name || null;
@@ -167,6 +183,7 @@ export async function createProviderConnection(data) {
provider: data.provider,
authType: data.authType || "oauth",
name: connectionName,
+ ownerId: data.ownerId || null,
priority: connectionPriority,
isActive: data.isActive !== undefined ? data.isActive : true,
createdAt: now,
diff --git a/src/lib/db/schema.js b/src/lib/db/schema.js
index c197b577..785043db 100644
--- a/src/lib/db/schema.js
+++ b/src/lib/db/schema.js
@@ -3,7 +3,7 @@
// pre-change safety backup in migrate.js: when the stored version is lower,
// one lightweight DB backup is taken before applying schema changes. Forgetting
// to bump only skips that backup — it does NOT break the additive auto-sync.
-export const SCHEMA_VERSION = 3;
+export const SCHEMA_VERSION = 4;
export const PRAGMA_SQL = `
PRAGMA journal_mode = WAL;
@@ -53,6 +53,7 @@ export const TABLES = {
authType: "TEXT NOT NULL",
name: "TEXT",
email: "TEXT",
+ ownerId: "TEXT",
priority: "INTEGER",
isActive: "INTEGER DEFAULT 1",
data: "TEXT NOT NULL",
@@ -61,6 +62,7 @@ export const TABLES = {
},
indexes: [
"CREATE INDEX IF NOT EXISTS idx_pc_provider ON providerConnections(provider)",
+ "CREATE INDEX IF NOT EXISTS idx_pc_owner ON providerConnections(ownerId)",
"CREATE INDEX IF NOT EXISTS idx_pc_provider_active ON providerConnections(provider, isActive)",
"CREATE INDEX IF NOT EXISTS idx_pc_priority ON providerConnections(provider, priority)",
],
diff --git a/src/lib/oauth/utils/server.js b/src/lib/oauth/utils/server.js
index b11a9a44..6ef44385 100644
--- a/src/lib/oauth/utils/server.js
+++ b/src/lib/oauth/utils/server.js
@@ -129,11 +129,12 @@ const pendingExchanges = new Map();
* Register a pending exchange session for server-side mode.
* Modal client calls this before opening popup.
*/
-export function registerCodexSession({ state, codeVerifier, redirectUri }) {
- if (!state || !codeVerifier || !redirectUri) return false;
+export function registerCodexSession({ state, codeVerifier, redirectUri, ownerId }) {
+ if (!state || !codeVerifier || !redirectUri || !ownerId) return false;
pendingExchanges.set(state, {
codeVerifier,
redirectUri,
+ ownerId,
status: "pending",
createdAt: Date.now(),
});
@@ -224,6 +225,7 @@ export function startCodexProxy(appPort) {
const connection = await createProviderConnection({
provider: "codex",
authType: "oauth",
+ ownerId: session.ownerId,
...tokenData,
expiresAt: tokenData.expiresIn
? new Date(Date.now() + tokenData.expiresIn * 1000).toISOString()
@@ -297,11 +299,12 @@ const XAI_PROXY_TIMEOUT_MS = 300000; // 5 minutes
const XAI_PROXY_PORT = 56121;
const xaiPendingExchanges = new Map();
-export function registerXaiSession({ state, codeVerifier, redirectUri }) {
- if (!state || !codeVerifier || !redirectUri) return false;
+export function registerXaiSession({ state, codeVerifier, redirectUri, ownerId }) {
+ if (!state || !codeVerifier || !redirectUri || !ownerId) return false;
xaiPendingExchanges.set(state, {
codeVerifier,
redirectUri,
+ ownerId,
status: "pending",
createdAt: Date.now(),
});
@@ -366,6 +369,7 @@ export function startXaiProxy(appPort) {
const connection = await createProviderConnection({
provider: "xai",
authType: "oauth",
+ ownerId: session.ownerId,
...tokenData,
expiresAt: tokenData.expiresIn
? new Date(Date.now() + tokenData.expiresIn * 1000).toISOString()
diff --git a/src/lib/providers/connectionAccess.js b/src/lib/providers/connectionAccess.js
new file mode 100644
index 00000000..ea1ce3e3
--- /dev/null
+++ b/src/lib/providers/connectionAccess.js
@@ -0,0 +1,9 @@
+import { requireCurrentDashboardUser } from "@/lib/auth/currentUser";
+
+export async function getProviderConnectionAccess() {
+ const user = await requireCurrentDashboardUser();
+ return {
+ user,
+ ownerId: user.role === "admin" ? null : user.id,
+ };
+}
diff --git a/src/shared/components/OAuthModal.js b/src/shared/components/OAuthModal.js
index 6d7b2bba..bc2b246b 100644
--- a/src/shared/components/OAuthModal.js
+++ b/src/shared/components/OAuthModal.js
@@ -130,6 +130,10 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
return;
}
+ if (!res.ok) {
+ throw new Error(data.error || "OAuth connection failed");
+ }
+
if (data.error === "expired_token" || data.error === "access_denied") {
throw new Error(data.errorDescription || data.error);
}
diff --git a/tests/unit/db-migration-chain.test.js b/tests/unit/db-migration-chain.test.js
index 4a0a4c51..b4393afa 100644
--- a/tests/unit/db-migration-chain.test.js
+++ b/tests/unit/db-migration-chain.test.js
@@ -37,6 +37,8 @@ describe("Schema migrations", () => {
"_meta", "settings", "providerConnections", "providerNodes",
"proxyPools", "apiKeys", "combos", "kv", "usageHistory", "usageDaily", "requestDetails",
]));
+ expect(db.all(`PRAGMA table_info(providerConnections)`).map((column) => column.name)).toContain("ownerId");
+ expect(db.all(`PRAGMA index_list(providerConnections)`).map((index) => index.name)).toContain("idx_pc_owner");
});
it("existing DB at older schemaVersion → re-applies pending migrations on restart", async () => {
diff --git a/tests/unit/db-sqlite-vs-lowdb.test.js b/tests/unit/db-sqlite-vs-lowdb.test.js
index 68e4df6c..ba8ddba6 100644
--- a/tests/unit/db-sqlite-vs-lowdb.test.js
+++ b/tests/unit/db-sqlite-vs-lowdb.test.js
@@ -113,6 +113,86 @@ describe("DB SQLite layer — public API parity", () => {
expect(back.providerSpecificData).toEqual({ foo: "bar" });
});
+ it("providerConnections: scopes retrieval and lookup to connection owner", async () => {
+ const ownerOne = await sqliteDb.createUser({ username: "connection-owner-one", password: "password", role: "user" });
+ const ownerTwo = await sqliteDb.createUser({ username: "connection-owner-two", password: "password", role: "user" });
+ const firstConnection = await sqliteDb.createProviderConnection({
+ provider: "owner-test-one",
+ authType: "apikey",
+ name: "owner-one-connection",
+ apiKey: "key-one",
+ ownerId: ownerOne.id,
+ });
+ const secondConnection = await sqliteDb.createProviderConnection({
+ provider: "owner-test-two",
+ authType: "apikey",
+ name: "owner-two-connection",
+ apiKey: "key-two",
+ ownerId: ownerTwo.id,
+ });
+ const account = await sqliteDb.createProviderConnection({
+ provider: "owner-test-account",
+ authType: "oauth",
+ email: "shared@example.com",
+ accessToken: "token-one",
+ ownerId: ownerOne.id,
+ });
+
+ const ownerOneConnections = await sqliteDb.getProviderConnections({ ownerId: ownerOne.id });
+ expect(ownerOneConnections.map((connection) => connection.id)).toContain(firstConnection.id);
+ expect(ownerOneConnections.map((connection) => connection.id)).not.toContain(secondConnection.id);
+ await expect(sqliteDb.createProviderConnection({
+ provider: "owner-test-account",
+ authType: "oauth",
+ email: "shared@example.com",
+ accessToken: "token-two",
+ ownerId: ownerTwo.id,
+ })).rejects.toMatchObject({
+ code: "PROVIDER_ACCOUNT_EXISTS",
+ status: 409,
+ });
+ expect(account.ownerId).toBe(ownerOne.id);
+
+ await sqliteDb.createProviderConnection({
+ provider: "owner-test-token",
+ authType: "access_token",
+ accessToken: "shared-token",
+ ownerId: ownerOne.id,
+ });
+ await expect(sqliteDb.createProviderConnection({
+ provider: "owner-test-token",
+ authType: "access_token",
+ accessToken: "shared-token",
+ ownerId: ownerTwo.id,
+ })).rejects.toMatchObject({
+ code: "PROVIDER_ACCOUNT_EXISTS",
+ status: 409,
+ });
+
+ await sqliteDb.createProviderConnection({
+ provider: "owner-test-api-key",
+ authType: "apikey",
+ name: "first-key-name",
+ apiKey: "shared-api-key",
+ ownerId: ownerOne.id,
+ });
+ await expect(sqliteDb.createProviderConnection({
+ provider: "owner-test-api-key",
+ authType: "apikey",
+ name: "different-key-name",
+ apiKey: "shared-api-key",
+ ownerId: ownerTwo.id,
+ })).rejects.toMatchObject({
+ code: "PROVIDER_ACCOUNT_EXISTS",
+ status: 409,
+ });
+ expect(await sqliteDb.getProviderConnectionById(firstConnection.id, ownerTwo.id)).toBeNull();
+ expect(await sqliteDb.getProviderConnectionById(firstConnection.id, ownerOne.id)).toMatchObject({
+ id: firstConnection.id,
+ ownerId: ownerOne.id,
+ });
+ });
+
it("providerConnections: GitHub OAuth uses account identity as fallback name", async () => {
const c = await sqliteDb.createProviderConnection({
provider: "github",