feat: update the permission for view combos page

This commit is contained in:
2026-07-11 16:45:29 +07:00
parent 3b354166eb
commit 1e8204ebab
3 changed files with 57 additions and 5 deletions
+10 -1
View File
@@ -46,7 +46,11 @@ const ALWAYS_PROTECTED = [
// User administration is never exposed to normal users, even if dashboard login // User administration is never exposed to normal users, even if dashboard login
// is disabled for local single-user deployments. // is disabled for local single-user deployments.
const ADMIN_ONLY_PATHS = ["/api/users", "/api/tunnel"]; const ADMIN_ONLY_PATHS = ["/api/users", "/api/tunnel", "/api/combos"];
// Combo definitions affect routing and fallback behavior, so only administrators
// may view or change them.
const ADMIN_ONLY_DASHBOARD_PATHS = ["/dashboard/combos"];
// Require auth, but allow through if requireLogin is disabled // Require auth, but allow through if requireLogin is disabled
const PROTECTED_API_PATHS = [ const PROTECTED_API_PATHS = [
@@ -240,6 +244,11 @@ export async function proxy(request) {
// Protect all dashboard routes // Protect all dashboard routes
if (pathname.startsWith("/dashboard")) { if (pathname.startsWith("/dashboard")) {
if (ADMIN_ONLY_DASHBOARD_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`))) {
if (await isAdmin(request)) return NextResponse.next();
return NextResponse.redirect(new URL("/dashboard", request.url));
}
let requireLogin = true; let requireLogin = true;
let tunnelDashboardAccess = true; let tunnelDashboardAccess = true;
+2 -2
View File
@@ -22,7 +22,7 @@ const navItems = [
{ href: "/dashboard/endpoint", label: "Endpoint & Key", icon: "api" }, { href: "/dashboard/endpoint", label: "Endpoint & Key", icon: "api" },
{ href: "/dashboard/providers", label: "Providers", icon: "dns" }, { href: "/dashboard/providers", label: "Providers", icon: "dns" },
// { href: "/dashboard/basic-chat", label: "Basic Chat", icon: "chat" }, // Hidden // { href: "/dashboard/basic-chat", label: "Basic Chat", icon: "chat" }, // Hidden
{ href: "/dashboard/combos", label: "Combos", icon: "layers" }, { href: "/dashboard/combos", label: "Combos", icon: "layers", adminOnly: true },
{ href: "/dashboard/usage", label: "Usage", icon: "bar_chart" }, { href: "/dashboard/usage", label: "Usage", icon: "bar_chart" },
{ href: "/dashboard/quota", label: "Quota Tracker", icon: "data_usage" }, { href: "/dashboard/quota", label: "Quota Tracker", icon: "data_usage" },
{ href: "/dashboard/token-saver", label: "Token Saver", icon: "savings" }, { href: "/dashboard/token-saver", label: "Token Saver", icon: "savings" },
@@ -165,7 +165,7 @@ export default function Sidebar({ onClose }) {
{/* Navigation */} {/* Navigation */}
<nav className="flex-1 px-4 py-2 space-y-0.5 overflow-y-auto custom-scrollbar"> <nav className="flex-1 px-4 py-2 space-y-0.5 overflow-y-auto custom-scrollbar">
{navItems.map((item) => ( {navItems.filter((item) => !item.adminOnly || user?.role === "admin").map((item) => (
<Link <Link
key={item.href} key={item.href}
href={item.href} href={item.href}
+45 -2
View File
@@ -7,8 +7,10 @@ const mocks = vi.hoisted(() => ({
body, body,
})), })),
getSettings: vi.fn(), getSettings: vi.fn(),
getUserById: vi.fn(),
validateApiKey: vi.fn(), validateApiKey: vi.fn(),
getConsistentMachineId: vi.fn(), getConsistentMachineId: vi.fn(),
getDashboardAuthSession: vi.fn(),
verifyDashboardAuthToken: vi.fn(), verifyDashboardAuthToken: vi.fn(),
})); }));
@@ -22,6 +24,7 @@ vi.mock("next/server", () => ({
vi.mock("@/lib/localDb", () => ({ vi.mock("@/lib/localDb", () => ({
getSettings: mocks.getSettings, getSettings: mocks.getSettings,
getUserById: mocks.getUserById,
validateApiKey: mocks.validateApiKey, validateApiKey: mocks.validateApiKey,
})); }));
@@ -30,17 +33,18 @@ vi.mock("@/shared/utils/machineId", () => ({
})); }));
vi.mock("@/lib/auth/dashboardSession", () => ({ vi.mock("@/lib/auth/dashboardSession", () => ({
getDashboardAuthSession: mocks.getDashboardAuthSession,
verifyDashboardAuthToken: mocks.verifyDashboardAuthToken, verifyDashboardAuthToken: mocks.verifyDashboardAuthToken,
})); }));
const { proxy, __test__ } = await import("../../src/dashboardGuard.js"); const { proxy, __test__ } = await import("../../src/dashboardGuard.js");
function request(pathname, headers = {}) { function request(pathname, headers = {}, authToken) {
const normalizedHeaders = new Headers(headers); const normalizedHeaders = new Headers(headers);
return { return {
nextUrl: { pathname, searchParams: new URL(`http://localhost${pathname}`).searchParams }, nextUrl: { pathname, searchParams: new URL(`http://localhost${pathname}`).searchParams },
headers: normalizedHeaders, headers: normalizedHeaders,
cookies: { get: vi.fn(() => undefined) }, cookies: { get: vi.fn(() => authToken ? { value: authToken } : undefined) },
url: `http://localhost${pathname}`, url: `http://localhost${pathname}`,
}; };
} }
@@ -49,8 +53,10 @@ describe("dashboard guard public LLM API access", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mocks.getSettings.mockResolvedValue({ requireLogin: true }); mocks.getSettings.mockResolvedValue({ requireLogin: true });
mocks.getUserById.mockResolvedValue(null);
mocks.validateApiKey.mockResolvedValue(false); mocks.validateApiKey.mockResolvedValue(false);
mocks.getConsistentMachineId.mockResolvedValue("cli-token"); mocks.getConsistentMachineId.mockResolvedValue("cli-token");
mocks.getDashboardAuthSession.mockResolvedValue(null);
mocks.verifyDashboardAuthToken.mockResolvedValue(false); mocks.verifyDashboardAuthToken.mockResolvedValue(false);
}); });
@@ -192,8 +198,10 @@ describe("dashboard guard local-only access", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mocks.getSettings.mockResolvedValue({ requireLogin: true }); mocks.getSettings.mockResolvedValue({ requireLogin: true });
mocks.getUserById.mockResolvedValue(null);
mocks.validateApiKey.mockResolvedValue(false); mocks.validateApiKey.mockResolvedValue(false);
mocks.getConsistentMachineId.mockResolvedValue("cli-token"); mocks.getConsistentMachineId.mockResolvedValue("cli-token");
mocks.getDashboardAuthSession.mockResolvedValue(null);
mocks.verifyDashboardAuthToken.mockResolvedValue(false); mocks.verifyDashboardAuthToken.mockResolvedValue(false);
}); });
@@ -258,6 +266,41 @@ describe("dashboard guard local-only access", () => {
}); });
}); });
describe("dashboard guard combo administration access", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getSettings.mockResolvedValue({ requireLogin: true });
mocks.getUserById.mockResolvedValue({ id: "user-1", isActive: true, role: "user" });
mocks.validateApiKey.mockResolvedValue(false);
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
mocks.getDashboardAuthSession.mockResolvedValue({ userId: "user-1" });
mocks.verifyDashboardAuthToken.mockResolvedValue(true);
});
it("rejects normal users from every combos API operation", async () => {
for (const pathname of ["/api/combos", "/api/combos/combo-1"]) {
const response = await proxy(request(pathname, { host: "localhost:20128" }, "user-token"));
expect(response.status).toBe(403);
expect(response.body.error).toBe("Administrator access required");
}
});
it("redirects normal users away from the combos dashboard page", async () => {
const response = await proxy(request("/dashboard/combos", { host: "localhost:20128" }, "user-token"));
expect(response.status).toBe(307);
expect(response.url).toBe("http://localhost/dashboard");
});
it("allows administrators to access the combos page and API", async () => {
mocks.getUserById.mockResolvedValue({ id: "user-1", isActive: true, role: "admin" });
expect(await proxy(request("/dashboard/combos", { host: "localhost:20128" }, "admin-token"))).toBe(mocks.nextResponse);
expect(await proxy(request("/api/combos", { host: "localhost:20128" }, "admin-token"))).toBe(mocks.nextResponse);
});
});
describe("dashboard guard helpers", () => { describe("dashboard guard helpers", () => {
it("extracts bearer API keys before x-api-key", () => { it("extracts bearer API keys before x-api-key", () => {
const apiRequest = request("/v1/chat/completions", { const apiRequest = request("/v1/chat/completions", {