fix: update the permission for create and read the api keys

This commit is contained in:
2026-07-11 15:28:52 +07:00
parent 8cd10aefdc
commit 4ac342c5a2
15 changed files with 187 additions and 72 deletions
@@ -17,7 +17,7 @@ import EndpointRow from "./components/EndpointRow";
import StatusAlert from "./components/StatusAlert";
import Tooltip from "./components/Tooltip";
import SecurityWarning from "./components/SecurityWarning";
export default function APIPageClient({ machineId }) {
export default function APIPageClient({ machineId, isAdmin }) {
const [keys, setKeys] = useState([]);
const [loading, setLoading] = useState(true);
const [showAddModal, setShowAddModal] = useState(false);
@@ -99,7 +99,7 @@ export default function APIPageClient({ machineId }) {
useEffect(() => {
fetchData();
loadSettings();
if (isAdmin) loadSettings();
}, []);
// Status poll: only while degraded (not yet reachable). Stop once healthy to avoid spam.
@@ -704,11 +704,16 @@ export default function APIPageClient({ machineId }) {
return (
<div className="flex flex-col gap-8">
{/* Endpoint Card */}
<Card>
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<span className="material-symbols-outlined text-primary">api</span>
API Endpoint
</h2>
<Card className="overflow-hidden">
<div className="mb-5 flex items-center gap-3 border-b border-border-subtle pb-4">
<div className="grid size-9 place-items-center rounded-xl bg-primary/10 text-primary">
<span className="material-symbols-outlined text-[20px]">api</span>
</div>
<div>
<h2 className="text-lg font-semibold leading-tight">API Endpoint</h2>
<p className="mt-1 text-xs text-text-muted">Use this address to connect compatible clients.</p>
</div>
</div>
{/* Endpoint rows */}
<div className="flex flex-col gap-2">
@@ -720,9 +725,11 @@ export default function APIPageClient({ machineId }) {
copied={copied}
onCopy={copy}
/>
{/* Cloudflare Tunnel and Tailscale are administrator-managed endpoints. */}
{isAdmin && <>
{/* Cloudflare Tunnel */}
<div className="flex items-center gap-2">
<span className={`text-xs font-mono px-1.5 py-0.5 rounded shrink-0 min-w-[88px] text-center ${
<span className={`text-xs font-mono px-1.5 py-0.5 rounded shrink-0 min-w-22 text-center ${
tunnelEnabled ? "bg-primary/10 text-primary" : "bg-surface-2 text-text-muted"
}`}>Tunnel</span>
{tunnelEnabled && !tunnelLoading && tunnelReachable ? (
@@ -814,7 +821,7 @@ export default function APIPageClient({ machineId }) {
</div>
{/* Tailscale */}
<div className="flex items-center gap-2">
<span className={`text-xs font-mono px-1.5 py-0.5 rounded shrink-0 min-w-[88px] text-center ${
<span className={`text-xs font-mono px-1.5 py-0.5 rounded shrink-0 min-w-22 text-center ${
tsEnabled ? "bg-primary/10 text-primary" : "bg-surface-2 text-text-muted"
}`}>Tailscale</span>
{tsEnabled && !tsLoading && tsReachable ? (
@@ -896,10 +903,11 @@ export default function APIPageClient({ machineId }) {
</Button>
)}
</div>
</>}
</div>
{/* Pre-enable security gate banner */}
{isLoginUnsafe && !tunnelEnabled && !tsEnabled && (
{isAdmin && isLoginUnsafe && !tunnelEnabled && !tsEnabled && (
<div className="mt-4">
<SecurityWarning
message={unsafeReason}
@@ -909,7 +917,7 @@ export default function APIPageClient({ machineId }) {
)}
{/* Security warnings when tunnel or tailscale is active */}
{(tunnelEnabled || tsEnabled) && (
{isAdmin && (tunnelEnabled || tsEnabled) && (
<div className="mt-4 flex flex-col gap-2">
{!requireApiKey && (
<SecurityWarning
@@ -934,7 +942,7 @@ export default function APIPageClient({ machineId }) {
)}
{/* Tunnel dashboard access option */}
{(tunnelEnabled || tsEnabled) && (
{isAdmin && (tunnelEnabled || tsEnabled) && (
<div className="mt-4 pt-4 border-t border-border flex items-center gap-3">
<Toggle
checked={tunnelDashboardAccess}
@@ -949,18 +957,23 @@ export default function APIPageClient({ machineId }) {
</Card>
{/* API Keys */}
<Card id="require-api-key">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<span className="material-symbols-outlined text-primary">vpn_key</span>
API Keys
</h2>
<Card id="require-api-key" className="overflow-hidden">
<div className="mb-5 flex items-center justify-between gap-4 border-b border-border-subtle pb-4">
<div className="flex min-w-0 items-center gap-3">
<div className="grid size-9 shrink-0 place-items-center rounded-xl bg-primary/10 text-primary">
<span className="material-symbols-outlined text-[20px]">vpn_key</span>
</div>
<div>
<h2 className="text-lg font-semibold leading-tight">API Keys</h2>
<p className="mt-1 text-xs text-text-muted">Manage keys created from this account.</p>
</div>
</div>
<Button icon="add" onClick={() => setShowAddModal(true)}>
Create Key
</Button>
</div>
<div className="flex items-center justify-between pb-4 mb-4 border-b border-border">
{isAdmin && <div className="mb-4 flex items-center justify-between rounded-xl border border-border-subtle bg-surface-2/40 px-4 py-3">
<div>
<p className="font-medium">Require API key</p>
<p className="text-sm text-text-muted">
@@ -971,9 +984,9 @@ export default function APIPageClient({ machineId }) {
checked={requireApiKey}
onChange={() => handleRequireApiKey(!requireApiKey)}
/>
</div>
</div>}
{isRemoteHost && !requireApiKey && (
{isAdmin && isRemoteHost && !requireApiKey && (
<div className="mb-4 -mt-2">
<SecurityWarning message="Endpoint is exposed without an API key." />
</div>
@@ -991,21 +1004,26 @@ export default function APIPageClient({ machineId }) {
</Button>
</div>
) : (
<div className="flex flex-col">
<div className="overflow-hidden rounded-xl border border-border-subtle">
{keys.map((key) => (
<div
key={key.id}
className={`group flex items-center justify-between py-3 border-b border-black/[0.03] dark:border-white/[0.03] last:border-b-0 ${key.isActive === false ? "opacity-60" : ""}`}
className={`group grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4 px-4 py-3.5 transition-colors hover:bg-surface-2/45 sm:grid-cols-[minmax(0,1fr)_auto_auto] ${key.isActive === false ? "opacity-60" : ""}`}
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{key.name}</p>
<div className="flex items-center gap-2 mt-1">
<code className="text-xs text-text-muted font-mono">
<div className="flex min-w-0 items-center gap-2">
<p className="truncate text-sm font-semibold">{key.name}</p>
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${key.isActive === false ? "bg-surface-3 text-text-muted" : "bg-primary/10 text-primary"}`}>
{key.isActive === false ? "Paused" : "Active"}
</span>
</div>
<div className="mt-1.5 flex min-w-0 items-center gap-1.5">
<code className="truncate text-xs text-text-muted font-mono">
{visibleKeys.has(key.id) ? key.key : maskKey(key.key)}
</code>
<button
onClick={() => toggleKeyVisibility(key.id)}
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-all"
className="grid size-6 shrink-0 place-items-center rounded-md text-text-muted transition-colors hover:bg-primary/10 hover:text-primary"
title={visibleKeys.has(key.id) ? "Hide key" : "Show key"}
>
<span className="material-symbols-outlined text-[14px]">
@@ -1014,21 +1032,19 @@ export default function APIPageClient({ machineId }) {
</button>
<button
onClick={() => copy(key.key, key.id)}
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-all"
className="grid size-6 shrink-0 place-items-center rounded-md text-text-muted transition-colors hover:bg-primary/10 hover:text-primary"
title="Copy API key"
>
<span className="material-symbols-outlined text-[14px]">
{copied === key.id ? "check" : "content_copy"}
</span>
</button>
</div>
<p className="text-xs text-text-muted mt-1">
Created {new Date(key.createdAt).toLocaleDateString()}
</p>
{key.isActive === false && (
<p className="text-xs text-orange-500 mt-1">Paused</p>
)}
</div>
<div className="flex items-center gap-2">
<p className="hidden text-right text-xs text-text-muted sm:block">
Created<br />{new Date(key.createdAt).toLocaleDateString()}
</p>
<div className="flex items-center gap-1.5">
<Toggle
size="sm"
checked={key.isActive ?? true}
@@ -1050,7 +1066,8 @@ export default function APIPageClient({ machineId }) {
/>
<button
onClick={() => handleDeleteKey(key.id)}
className="p-2 hover:bg-red-500/10 rounded text-red-500 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-all"
className="grid size-8 place-items-center rounded-lg text-red-500 transition-colors hover:bg-red-500/10"
title="Delete API key"
>
<span className="material-symbols-outlined text-[18px]">delete</span>
</button>
@@ -1131,7 +1148,7 @@ export default function APIPageClient({ machineId }) {
</Modal>
{/* Enable Tunnel Modal */}
<Modal
{isAdmin && <Modal
isOpen={showEnableTunnelModal}
title="Enable Tunnel"
onClose={() => setShowEnableTunnelModal(false)}
@@ -1172,10 +1189,10 @@ export default function APIPageClient({ machineId }) {
<Button onClick={() => setShowEnableTunnelModal(false)} variant="ghost" fullWidth>Cancel</Button>
</div>
</div>
</Modal>
</Modal>}
{/* Disable Cloudflare Tunnel Modal */}
<Modal
{isAdmin && <Modal
isOpen={showDisableTunnelModal}
title="Disable Tunnel"
onClose={() => !tunnelLoading && setShowDisableTunnelModal(false)}
@@ -1189,10 +1206,10 @@ export default function APIPageClient({ machineId }) {
<Button onClick={() => setShowDisableTunnelModal(false)} variant="ghost" fullWidth disabled={tunnelLoading}>Cancel</Button>
</div>
</div>
</Modal>
</Modal>}
{/* Tailscale Modal */}
<Modal
{isAdmin && <Modal
isOpen={showTsModal}
title="Tailscale Funnel"
onClose={() => { if (!tsInstalling) { setShowTsModal(false); setTsSudoPassword(""); setTsStatus(null); } }}
@@ -1257,10 +1274,10 @@ export default function APIPageClient({ machineId }) {
{tsStatus && <StatusAlert status={tsStatus} />}
</div>
</Modal>
</Modal>}
{/* Disable Tailscale Modal */}
<Modal
{isAdmin && <Modal
isOpen={showDisableTsModal}
title="Disable Tailscale"
onClose={() => !tsLoading && setShowDisableTsModal(false)}
@@ -1274,7 +1291,7 @@ export default function APIPageClient({ machineId }) {
<Button onClick={() => setShowDisableTsModal(false)} variant="ghost" fullWidth disabled={tsLoading}>Cancel</Button>
</div>
</div>
</Modal>
</Modal>}
{/* Confirm Modal */}
<ConfirmModal
@@ -1292,4 +1309,5 @@ export default function APIPageClient({ machineId }) {
APIPageClient.propTypes = {
machineId: PropTypes.string.isRequired,
isAdmin: PropTypes.bool.isRequired,
};
@@ -5,14 +5,15 @@ import { Input } from "@/shared/components";
/** Reusable endpoint row component */
export default function EndpointRow({ label, url, copyId, copied, onCopy, badge, actions }) {
return (
<div className="flex items-center gap-2">
<span className={`text-xs font-mono px-1.5 py-0.5 rounded shrink-0 min-w-[88px] text-center ${
<div className="grid grid-cols-[5.5rem_minmax(0,1fr)_auto] items-center gap-3 rounded-xl border border-border-subtle bg-surface-2/40 p-2.5">
<span className={`rounded-md px-2 py-1 text-center font-mono text-[11px] font-medium tracking-wide ${
(badge === "CF" || badge === "TS") ? "bg-primary/10 text-primary" : "bg-surface-2 text-text-muted"
}`}>{label}</span>
<Input value={url} readOnly className="flex-1 font-mono text-sm" />
<Input value={url} readOnly className="min-w-0 font-mono text-sm" />
<button
onClick={() => onCopy(url, copyId)}
className="p-2 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors shrink-0"
className="grid size-9 place-items-center rounded-lg text-text-muted transition-colors hover:bg-primary/10 hover:text-primary"
title={copied === copyId ? "Copied" : "Copy endpoint"}
>
<span className="material-symbols-outlined text-[18px]">{copied === copyId ? "check" : "content_copy"}</span>
</button>
@@ -1,7 +1,8 @@
import { getMachineId } from "@/shared/utils/machine";
import { getCurrentDashboardUser } from "@/lib/auth/currentUser";
import EndpointPageClient from "./EndpointPageClient";
export default async function EndpointPage() {
const machineId = await getMachineId();
return <EndpointPageClient machineId={machineId} />;
const [machineId, user] = await Promise.all([getMachineId(), getCurrentDashboardUser()]);
return <EndpointPageClient machineId={machineId} isAdmin={user?.role !== "user"} />;
}
+22 -3
View File
@@ -1,16 +1,25 @@
import { NextResponse } from "next/server";
import { deleteApiKey, getApiKeyById, updateApiKey } from "@/lib/localDb";
import { deleteApiKey, getApiKeyByIdAndOwnerId, updateApiKey } from "@/lib/localDb";
import { requireCurrentDashboardUser } from "@/lib/auth/currentUser";
async function getOwnedApiKey(id) {
const user = await requireCurrentDashboardUser();
return getApiKeyByIdAndOwnerId(id, user.id);
}
// GET /api/keys/[id] - Get single key
export async function GET(request, { params }) {
try {
const { id } = await params;
const key = await getApiKeyById(id);
const key = await getOwnedApiKey(id);
if (!key) {
return NextResponse.json({ error: "Key not found" }, { status: 404 });
}
return NextResponse.json({ key });
} catch (error) {
if (error.message === "Unauthorized") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
console.log("Error fetching key:", error);
return NextResponse.json({ error: "Failed to fetch key" }, { status: 500 });
}
@@ -23,7 +32,7 @@ export async function PUT(request, { params }) {
const body = await request.json();
const { isActive } = body;
const existing = await getApiKeyById(id);
const existing = await getOwnedApiKey(id);
if (!existing) {
return NextResponse.json({ error: "Key not found" }, { status: 404 });
}
@@ -35,6 +44,9 @@ export async function PUT(request, { params }) {
return NextResponse.json({ key: updated });
} catch (error) {
if (error.message === "Unauthorized") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
console.log("Error updating key:", error);
return NextResponse.json({ error: "Failed to update key" }, { status: 500 });
}
@@ -45,6 +57,10 @@ export async function DELETE(request, { params }) {
try {
const { id } = await params;
const existing = await getOwnedApiKey(id);
if (!existing) {
return NextResponse.json({ error: "Key not found" }, { status: 404 });
}
const deleted = await deleteApiKey(id);
if (!deleted) {
return NextResponse.json({ error: "Key not found" }, { status: 404 });
@@ -52,6 +68,9 @@ export async function DELETE(request, { params }) {
return NextResponse.json({ message: "Key deleted successfully" });
} catch (error) {
if (error.message === "Unauthorized") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
console.log("Error deleting key:", error);
return NextResponse.json({ error: "Failed to delete key" }, { status: 500 });
}
+12 -3
View File
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { getApiKeys, createApiKey } from "@/lib/localDb";
import { getApiKeysByOwnerId, createApiKey } from "@/lib/localDb";
import { requireCurrentDashboardUser } from "@/lib/auth/currentUser";
import { getConsistentMachineId } from "@/shared/utils/machineId";
export const dynamic = "force-dynamic";
@@ -7,9 +8,13 @@ export const dynamic = "force-dynamic";
// GET /api/keys - List API keys
export async function GET() {
try {
const keys = await getApiKeys();
const user = await requireCurrentDashboardUser();
const keys = await getApiKeysByOwnerId(user.id);
return NextResponse.json({ keys });
} catch (error) {
if (error.message === "Unauthorized") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
console.log("Error fetching keys:", error);
return NextResponse.json({ error: "Failed to fetch keys" }, { status: 500 });
}
@@ -18,6 +23,7 @@ export async function GET() {
// POST /api/keys - Create new API key
export async function POST(request) {
try {
const user = await requireCurrentDashboardUser();
const body = await request.json();
const { name } = body;
@@ -27,7 +33,7 @@ export async function POST(request) {
// Always get machineId from server
const machineId = await getConsistentMachineId();
const apiKey = await createApiKey(name, machineId);
const apiKey = await createApiKey(name, machineId, user.id);
return NextResponse.json({
key: apiKey.key,
@@ -36,6 +42,9 @@ export async function POST(request) {
machineId: apiKey.machineId,
}, { status: 201 });
} catch (error) {
if (error.message === "Unauthorized") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
console.log("Error creating key:", error);
return NextResponse.json({ error: "Failed to create key" }, { status: 500 });
}
+15
View File
@@ -41,6 +41,21 @@ export async function PATCH(request) {
try {
const body = await request.json();
if (
Object.prototype.hasOwnProperty.call(body, "requireApiKey") ||
Object.prototype.hasOwnProperty.call(body, "tunnelDashboardAccess")
) {
let user;
try {
user = await requireCurrentDashboardUser();
} catch {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (user.role !== "admin") {
return NextResponse.json({ error: "Administrator access required" }, { status: 403 });
}
}
// Strip protected secrets before any internal handling sets them
for (const key of PROTECTED_SETTING_KEYS) delete body[key];
+1 -1
View File
@@ -46,7 +46,7 @@ const ALWAYS_PROTECTED = [
// User administration is never exposed to normal users, even if dashboard login
// is disabled for local single-user deployments.
const ADMIN_ONLY_PATHS = ["/api/users"];
const ADMIN_ONLY_PATHS = ["/api/users", "/api/tunnel"];
// Require auth, but allow through if requireLogin is disabled
const PROTECTED_API_PATHS = [
+6 -4
View File
@@ -35,7 +35,8 @@ export {
// API keys
export {
getApiKeys, getApiKeyById, createApiKey, updateApiKey, deleteApiKey, validateApiKey,
getApiKeys, getApiKeysByOwnerId, getApiKeyById, getApiKeyByIdAndOwnerId,
createApiKey, updateApiKey, deleteApiKey, validateApiKey,
} from "./repos/apiKeysRepo.js";
// Combos
@@ -84,7 +85,7 @@ export async function exportDb() {
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 })),
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, isActive: r.isActive === 1, createdAt: r.createdAt })),
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 })),
combos: db.all(`SELECT * FROM combos`).map((r) => ({ id: r.id, name: r.name, kind: r.kind, models: parseJson(r.models, []), createdAt: r.createdAt, updatedAt: r.updatedAt })),
modelAliases: {},
customModels: [],
@@ -164,10 +165,11 @@ export async function importDb(payload) {
[id, isActive === false ? 0 : 1, testStatus || "unknown", stringifyJson(rest), createdAt || new Date().toISOString(), updatedAt || new Date().toISOString()]
);
}
const defaultKeyOwner = db.get(`SELECT id FROM users WHERE role = 'admin' ORDER BY createdAt ASC LIMIT 1`)?.id || null;
for (const k of payload.apiKeys || []) {
db.run(
`INSERT OR REPLACE INTO apiKeys(id, key, name, machineId, isActive, createdAt) VALUES(?, ?, ?, ?, ?, ?)`,
[k.id, k.key, k.name || null, k.machineId || null, k.isActive === false ? 0 : 1, k.createdAt || new Date().toISOString()]
`INSERT OR REPLACE INTO apiKeys(id, key, name, machineId, ownerId, isActive, createdAt) VALUES(?, ?, ?, ?, ?, ?, ?)`,
[k.id, k.key, k.name || null, k.machineId || null, k.ownerId || defaultKeyOwner, k.isActive === false ? 0 : 1, k.createdAt || new Date().toISOString()]
);
}
for (const c of payload.combos || []) {
+3 -2
View File
@@ -111,6 +111,7 @@ function syncSchemaFromTables(adapter) {
// ─── Legacy JSON import (one-time) ───────────────────────────────────────
function importLegacyMain(adapter, data) {
if (!data || typeof data !== "object") return;
const defaultKeyOwner = adapter.get(`SELECT id FROM users WHERE role = 'admin' ORDER BY createdAt ASC LIMIT 1`)?.id || null;
if (data.settings) {
adapter.run(`INSERT INTO settings(id, data) VALUES(1, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data`, [stringifyJson(data.settings)]);
@@ -142,8 +143,8 @@ function importLegacyMain(adapter, data) {
importWithAssertion(adapter, "apiKeys", data.apiKeys || [], (k) => {
adapter.run(
`INSERT OR REPLACE INTO apiKeys(id, key, name, machineId, isActive, createdAt) VALUES(?, ?, ?, ?, ?, ?)`,
[k.id, k.key, k.name || null, k.machineId || null, k.isActive === false ? 0 : 1, k.createdAt || new Date().toISOString()]
`INSERT OR REPLACE INTO apiKeys(id, key, name, machineId, ownerId, isActive, createdAt) VALUES(?, ?, ?, ?, ?, ?, ?)`,
[k.id, k.key, k.name || null, k.machineId || null, k.ownerId || defaultKeyOwner, k.isActive === false ? 0 : 1, k.createdAt || new Date().toISOString()]
);
}, (k) => ({ id: k.id ?? null, name: k.name ?? null }));
@@ -0,0 +1,17 @@
// API keys are private to the dashboard account that created them. Keys from
// pre-multi-user installations are retained as keys owned by the first admin.
export default {
version: 3,
name: "api-key-owners",
up(db) {
const columns = db.all(`PRAGMA table_info(apiKeys)`);
if (!columns.some((column) => column.name === "ownerId")) {
db.exec(`ALTER TABLE apiKeys 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 apiKeys SET ownerId = ? WHERE ownerId IS NULL OR ownerId = ''`, [admin.id]);
}
},
};
+2 -1
View File
@@ -3,8 +3,9 @@
// Versions MUST be unique and monotonically increasing.
import m001 from "./001-initial.js";
import m002 from "./002-users-table.js";
import m003 from "./003-api-key-owners.js";
export const MIGRATIONS = [m001, m002].sort((a, b) => a.version - b.version);
export const MIGRATIONS = [m001, m002, m003].sort((a, b) => a.version - b.version);
export function latestVersion() {
return MIGRATIONS.length ? MIGRATIONS[MIGRATIONS.length - 1].version : 0;
+19 -5
View File
@@ -8,6 +8,7 @@ function rowToKey(row) {
key: row.key,
name: row.name,
machineId: row.machineId,
ownerId: row.ownerId,
isActive: row.isActive === 1 || row.isActive === true,
createdAt: row.createdAt,
};
@@ -19,13 +20,25 @@ export async function getApiKeys() {
return rows.map(rowToKey);
}
export async function getApiKeysByOwnerId(ownerId) {
const db = await getAdapter();
const rows = db.all(`SELECT * FROM apiKeys WHERE ownerId = ? ORDER BY createdAt ASC`, [ownerId]);
return rows.map(rowToKey);
}
export async function getApiKeyById(id) {
const db = await getAdapter();
const row = db.get(`SELECT * FROM apiKeys WHERE id = ?`, [id]);
return rowToKey(row);
}
export async function createApiKey(name, machineId) {
export async function getApiKeyByIdAndOwnerId(id, ownerId) {
const db = await getAdapter();
const row = db.get(`SELECT * FROM apiKeys WHERE id = ? AND ownerId = ?`, [id, ownerId]);
return rowToKey(row);
}
export async function createApiKey(name, machineId, ownerId = null) {
if (!machineId) throw new Error("machineId is required");
const db = await getAdapter();
const { generateApiKeyWithMachine } = await import("@/shared/utils/apiKey");
@@ -35,12 +48,13 @@ export async function createApiKey(name, machineId) {
name,
key: result.key,
machineId,
ownerId,
isActive: true,
createdAt: new Date().toISOString(),
};
db.run(
`INSERT INTO apiKeys(id, key, name, machineId, isActive, createdAt) VALUES(?, ?, ?, ?, ?, ?)`,
[apiKey.id, apiKey.key, apiKey.name, apiKey.machineId, 1, apiKey.createdAt]
`INSERT INTO apiKeys(id, key, name, machineId, ownerId, isActive, createdAt) VALUES(?, ?, ?, ?, ?, ?, ?)`,
[apiKey.id, apiKey.key, apiKey.name, apiKey.machineId, apiKey.ownerId, 1, apiKey.createdAt]
);
return apiKey;
}
@@ -53,8 +67,8 @@ export async function updateApiKey(id, data) {
if (!row) return;
const merged = { ...rowToKey(row), ...data };
db.run(
`UPDATE apiKeys SET key = ?, name = ?, machineId = ?, isActive = ? WHERE id = ?`,
[merged.key, merged.name, merged.machineId, merged.isActive ? 1 : 0, id]
`UPDATE apiKeys SET key = ?, name = ?, machineId = ?, ownerId = ?, isActive = ? WHERE id = ?`,
[merged.key, merged.name, merged.machineId, merged.ownerId, merged.isActive ? 1 : 0, id]
);
result = merged;
});
+6 -2
View File
@@ -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 = 2;
export const SCHEMA_VERSION = 3;
export const PRAGMA_SQL = `
PRAGMA journal_mode = WAL;
@@ -96,10 +96,14 @@ export const TABLES = {
key: "TEXT UNIQUE NOT NULL",
name: "TEXT",
machineId: "TEXT",
ownerId: "TEXT",
isActive: "INTEGER DEFAULT 1",
createdAt: "TEXT NOT NULL",
},
indexes: ["CREATE INDEX IF NOT EXISTS idx_ak_key ON apiKeys(key)"],
indexes: [
"CREATE INDEX IF NOT EXISTS idx_ak_key ON apiKeys(key)",
"CREATE INDEX IF NOT EXISTS idx_ak_owner ON apiKeys(ownerId)",
],
},
combos: {
columns: {
+2 -1
View File
@@ -12,7 +12,8 @@ export {
createProviderNode, updateProviderNode, deleteProviderNode,
getProxyPools, getProxyPoolById,
createProxyPool, updateProxyPool, deleteProxyPool,
getApiKeys, getApiKeyById, createApiKey, updateApiKey, deleteApiKey, validateApiKey,
getApiKeys, getApiKeysByOwnerId, getApiKeyById, getApiKeyByIdAndOwnerId,
createApiKey, updateApiKey, deleteApiKey, validateApiKey,
getCombos, getComboById, getComboByName,
createCombo, updateCombo, deleteCombo,
getModelAliases, setModelAlias, deleteModelAlias,