mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-23 04:09:49 +00:00
fix: update the permission for add provider resource
This commit is contained in:
@@ -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 && (
|
||||
<div className={`text-sm font-medium ${bulkResult.failed > 0 ? "text-yellow-400" : "text-green-400"}`}>
|
||||
✓ {bulkResult.success} added{bulkResult.failed > 0 ? `, ✗ ${bulkResult.failed} failed` : ""}
|
||||
<p>
|
||||
✓ {bulkResult.success} added{bulkResult.failed > 0 ? `, ✗ ${bulkResult.failed} failed` : ""}
|
||||
</p>
|
||||
{bulkResult.duplicateLines.length > 0 && (
|
||||
<p className="mt-1 text-red-500">
|
||||
Account/API key already exists in the system (line{bulkResult.duplicateLines.length > 1 ? "s" : ""} {bulkResult.duplicateLines.join(", ")}).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -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"),
|
||||
),
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
+5
-4
@@ -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 || []) {
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)",
|
||||
],
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user