From 3b354166ebe88844cd906474783466ca5189b51c Mon Sep 17 00:00:00 2001 From: Loi Phan Date: Sat, 11 Jul 2026 16:21:12 +0700 Subject: [PATCH] fix: update the permission for add provider resource --- .../providers/[id]/AddApiKeyModal.js | 24 +++- .../(dashboard)/dashboard/providers/page.js | 10 +- .../api/oauth/[provider]/[action]/route.js | 27 +++- src/app/api/oauth/codex/bulk-import/route.js | 15 ++- src/app/api/oauth/codex/import-token/route.js | 8 +- src/app/api/oauth/cursor/import/route.js | 8 +- src/app/api/oauth/gitlab/pat/route.js | 8 +- src/app/api/oauth/iflow/cookie/route.js | 8 +- src/app/api/oauth/kiro/api-key/route.js | 9 ++ .../api/oauth/kiro/import-cli-proxy/route.js | 8 +- src/app/api/oauth/kiro/import/route.js | 8 +- .../api/oauth/kiro/social-exchange/route.js | 8 +- src/app/api/providers/[id]/models/route.js | 7 +- src/app/api/providers/[id]/route.js | 22 +++- .../api/providers/[id]/test-models/route.js | 7 +- src/app/api/providers/[id]/test/route.js | 10 ++ src/app/api/providers/client/route.js | 7 +- src/app/api/providers/route.js | 17 ++- src/app/api/providers/test-batch/route.js | 10 +- src/lib/db/index.js | 9 +- .../004-provider-connection-owners.js | 20 +++ src/lib/db/migrations/index.js | 3 +- src/lib/db/repos/connectionsRepo.js | 121 ++++++++++-------- src/lib/db/schema.js | 4 +- src/lib/oauth/utils/server.js | 12 +- src/lib/providers/connectionAccess.js | 9 ++ src/shared/components/OAuthModal.js | 4 + tests/unit/db-migration-chain.test.js | 2 + tests/unit/db-sqlite-vs-lowdb.test.js | 80 ++++++++++++ 29 files changed, 396 insertions(+), 89 deletions(-) create mode 100644 src/lib/db/migrations/004-provider-connection-owners.js create mode 100644 src/lib/providers/connectionAccess.js 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",