mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
fix: update the permission for create and read the api keys
This commit is contained in:
@@ -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"} />;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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
@@ -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 || []) {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user