diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js index a7183741..792a568f 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js @@ -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 (
{/* Endpoint Card */} - -

- api - API Endpoint -

+ +
+
+ api +
+
+

API Endpoint

+

Use this address to connect compatible clients.

+
+
{/* Endpoint rows */}
@@ -720,9 +725,11 @@ export default function APIPageClient({ machineId }) { copied={copied} onCopy={copy} /> + {/* Cloudflare Tunnel and Tailscale are administrator-managed endpoints. */} + {isAdmin && <> {/* Cloudflare Tunnel */}
- Tunnel {tunnelEnabled && !tunnelLoading && tunnelReachable ? ( @@ -814,7 +821,7 @@ export default function APIPageClient({ machineId }) {
{/* Tailscale */}
- Tailscale {tsEnabled && !tsLoading && tsReachable ? ( @@ -896,10 +903,11 @@ export default function APIPageClient({ machineId }) { )}
+ }
{/* Pre-enable security gate banner */} - {isLoginUnsafe && !tunnelEnabled && !tsEnabled && ( + {isAdmin && isLoginUnsafe && !tunnelEnabled && !tsEnabled && (
{!requireApiKey && ( {/* API Keys */} - -
-

- vpn_key - API Keys -

+ +
+
+
+ vpn_key +
+
+

API Keys

+

Manage keys created from this account.

+
+
-
+ {isAdmin &&

Require API key

@@ -971,9 +984,9 @@ export default function APIPageClient({ machineId }) { checked={requireApiKey} onChange={() => handleRequireApiKey(!requireApiKey)} /> -

+
} - {isRemoteHost && !requireApiKey && ( + {isAdmin && isRemoteHost && !requireApiKey && (
@@ -991,21 +1004,26 @@ export default function APIPageClient({ machineId }) {
) : ( -
+
{keys.map((key) => (
-

{key.name}

-
- +
+

{key.name}

+ + {key.isActive === false ? "Paused" : "Active"} + +
+
+ {visibleKeys.has(key.id) ? key.key : maskKey(key.key)}
-

- Created {new Date(key.createdAt).toLocaleDateString()} -

- {key.isActive === false && ( -

Paused

- )}
-
+

+ Created
{new Date(key.createdAt).toLocaleDateString()} +

+
@@ -1131,7 +1148,7 @@ export default function APIPageClient({ machineId }) { {/* Enable Tunnel Modal */} - setShowEnableTunnelModal(false)} @@ -1172,10 +1189,10 @@ export default function APIPageClient({ machineId }) {
- + } {/* Disable Cloudflare Tunnel Modal */} - !tunnelLoading && setShowDisableTunnelModal(false)} @@ -1189,10 +1206,10 @@ export default function APIPageClient({ machineId }) {
- + } {/* Tailscale Modal */} - { if (!tsInstalling) { setShowTsModal(false); setTsSudoPassword(""); setTsStatus(null); } }} @@ -1257,10 +1274,10 @@ export default function APIPageClient({ machineId }) { {tsStatus && }
- + } {/* Disable Tailscale Modal */} - !tsLoading && setShowDisableTsModal(false)} @@ -1274,7 +1291,7 @@ export default function APIPageClient({ machineId }) {
- + } {/* Confirm Modal */} - + {label} - + diff --git a/src/app/(dashboard)/dashboard/endpoint/page.js b/src/app/(dashboard)/dashboard/endpoint/page.js index 96a3e31e..f90fcbc8 100644 --- a/src/app/(dashboard)/dashboard/endpoint/page.js +++ b/src/app/(dashboard)/dashboard/endpoint/page.js @@ -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 ; + const [machineId, user] = await Promise.all([getMachineId(), getCurrentDashboardUser()]); + return ; } diff --git a/src/app/api/keys/[id]/route.js b/src/app/api/keys/[id]/route.js index 1d22596a..14d6561a 100644 --- a/src/app/api/keys/[id]/route.js +++ b/src/app/api/keys/[id]/route.js @@ -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 }); } diff --git a/src/app/api/keys/route.js b/src/app/api/keys/route.js index ab0470ae..1a887869 100644 --- a/src/app/api/keys/route.js +++ b/src/app/api/keys/route.js @@ -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 }); } diff --git a/src/app/api/settings/route.js b/src/app/api/settings/route.js index 49d2f4e7..5ba3944d 100644 --- a/src/app/api/settings/route.js +++ b/src/app/api/settings/route.js @@ -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]; diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js index 4603b8af..6b88f6e9 100644 --- a/src/dashboardGuard.js +++ b/src/dashboardGuard.js @@ -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 = [ diff --git a/src/lib/db/index.js b/src/lib/db/index.js index eac2a331..ce08aaa1 100644 --- a/src/lib/db/index.js +++ b/src/lib/db/index.js @@ -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 || []) { diff --git a/src/lib/db/migrate.js b/src/lib/db/migrate.js index 0cca4da0..bae24205 100644 --- a/src/lib/db/migrate.js +++ b/src/lib/db/migrate.js @@ -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 })); diff --git a/src/lib/db/migrations/003-api-key-owners.js b/src/lib/db/migrations/003-api-key-owners.js new file mode 100644 index 00000000..d3445cc1 --- /dev/null +++ b/src/lib/db/migrations/003-api-key-owners.js @@ -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]); + } + }, +}; \ No newline at end of file diff --git a/src/lib/db/migrations/index.js b/src/lib/db/migrations/index.js index 24c32dbe..96a509a3 100644 --- a/src/lib/db/migrations/index.js +++ b/src/lib/db/migrations/index.js @@ -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; diff --git a/src/lib/db/repos/apiKeysRepo.js b/src/lib/db/repos/apiKeysRepo.js index ff09d926..0bb87bc1 100644 --- a/src/lib/db/repos/apiKeysRepo.js +++ b/src/lib/db/repos/apiKeysRepo.js @@ -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; }); diff --git a/src/lib/db/schema.js b/src/lib/db/schema.js index b9584e82..c197b577 100644 --- a/src/lib/db/schema.js +++ b/src/lib/db/schema.js @@ -3,7 +3,7 @@ // pre-change safety backup in migrate.js: when the stored version is lower, // one lightweight DB backup is taken before applying schema changes. Forgetting // to bump only skips that backup — it does NOT break the additive auto-sync. -export const SCHEMA_VERSION = 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: { diff --git a/src/lib/localDb.js b/src/lib/localDb.js index e9e1d915..33d97564 100644 --- a/src/lib/localDb.js +++ b/src/lib/localDb.js @@ -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, diff --git a/tests/unit/db-sqlite-vs-lowdb.test.js b/tests/unit/db-sqlite-vs-lowdb.test.js index 52a80884..68e4df6c 100644 --- a/tests/unit/db-sqlite-vs-lowdb.test.js +++ b/tests/unit/db-sqlite-vs-lowdb.test.js @@ -65,6 +65,18 @@ describe("DB SQLite layer — public API parity", () => { expect(await sqliteDb.getApiKeyById(k.id)).toBeNull(); }); + it("apiKeys: scopes retrieval to key owner", async () => { + const ownerOne = await sqliteDb.createUser({ username: "key-owner-one", password: "password", role: "user" }); + const ownerTwo = await sqliteDb.createUser({ username: "key-owner-two", password: "password", role: "user" }); + const firstKey = await sqliteDb.createApiKey("owner-one-key", "machine-abc", ownerOne.id); + const secondKey = await sqliteDb.createApiKey("owner-two-key", "machine-abc", ownerTwo.id); + + const firstOwnerKeys = await sqliteDb.getApiKeysByOwnerId(ownerOne.id); + expect(firstOwnerKeys.map((key) => key.id)).toContain(firstKey.id); + expect(firstOwnerKeys.map((key) => key.id)).not.toContain(secondKey.id); + expect(await sqliteDb.getApiKeyByIdAndOwnerId(firstKey.id, ownerTwo.id)).toBeNull(); + }); + it("providerConnections: CRUD + reorder by priority", async () => { const c1 = await sqliteDb.createProviderConnection({ provider: "test", authType: "apikey", name: "a", apiKey: "k1" }); const c2 = await sqliteDb.createProviderConnection({ provider: "test", authType: "apikey", name: "b", apiKey: "k2" });