feat(rbac): hide unauthorized menu sections and enforce route guards
This commit is contained in:
@@ -28,7 +28,6 @@
|
||||
* @TEST_INVARIANT navigation -> verifies: [idle_state]
|
||||
*/
|
||||
|
||||
import { onMount } from "svelte";
|
||||
import { page } from "$app/stores";
|
||||
import {
|
||||
sidebarStore,
|
||||
@@ -37,65 +36,12 @@
|
||||
closeMobile,
|
||||
} from "$lib/stores/sidebar.js";
|
||||
import { t } from "$lib/i18n";
|
||||
import { auth } from "$lib/auth/store.js";
|
||||
import { buildSidebarCategories } from "$lib/components/layout/sidebarNavigation.js";
|
||||
import { browser } from "$app/environment";
|
||||
import Icon from "$lib/ui/Icon.svelte";
|
||||
|
||||
function buildCategories() {
|
||||
return [
|
||||
{
|
||||
id: "dashboards",
|
||||
label: $t.nav?.dashboards,
|
||||
icon: "dashboard",
|
||||
tone: "from-sky-100 to-sky-200 text-sky-700 ring-sky-200",
|
||||
path: "/dashboards",
|
||||
subItems: [{ label: $t.nav?.overview, path: "/dashboards" }],
|
||||
},
|
||||
{
|
||||
id: "datasets",
|
||||
label: $t.nav?.datasets,
|
||||
icon: "database",
|
||||
tone: "from-emerald-100 to-emerald-200 text-emerald-700 ring-emerald-200",
|
||||
path: "/datasets",
|
||||
subItems: [{ label: $t.nav?.all_datasets, path: "/datasets" }],
|
||||
},
|
||||
{
|
||||
id: "storage",
|
||||
label: $t.nav?.storage,
|
||||
icon: "storage",
|
||||
tone: "from-amber-100 to-amber-200 text-amber-800 ring-amber-200",
|
||||
path: "/storage",
|
||||
subItems: [
|
||||
{ label: $t.nav?.backups, path: "/storage/backups" },
|
||||
{
|
||||
label: $t.nav?.repositories,
|
||||
path: "/storage/repos",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "reports",
|
||||
label: $t.nav?.reports,
|
||||
icon: "reports",
|
||||
tone: "from-violet-100 to-fuchsia-100 text-violet-700 ring-violet-200",
|
||||
path: "/reports",
|
||||
subItems: [{ label: $t.nav?.reports, path: "/reports" }],
|
||||
},
|
||||
{
|
||||
id: "admin",
|
||||
label: $t.nav?.admin,
|
||||
icon: "admin",
|
||||
tone: "from-rose-100 to-rose-200 text-rose-700 ring-rose-200",
|
||||
path: "/admin",
|
||||
subItems: [
|
||||
{ label: $t.nav?.admin_users, path: "/admin/users" },
|
||||
{ label: $t.nav?.admin_roles, path: "/admin/roles" },
|
||||
{ label: $t.nav?.settings, path: "/settings" },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
let categories = buildCategories();
|
||||
let categories = [];
|
||||
|
||||
let isExpanded = true;
|
||||
let activeCategory = "dashboards";
|
||||
@@ -111,14 +57,34 @@
|
||||
isMobileOpen = $sidebarStore.isMobileOpen;
|
||||
}
|
||||
|
||||
// Reactive categories to update translations
|
||||
$: categories = buildCategories();
|
||||
// Reactive categories to update translations and apply RBAC visibility.
|
||||
$: categories = buildSidebarCategories($t, $auth?.user || null);
|
||||
|
||||
// Keep active category valid after RBAC filtering.
|
||||
$: if (
|
||||
categories.length > 0 &&
|
||||
!categories.some((category) => category.id === activeCategory)
|
||||
) {
|
||||
const fallbackCategory = categories[0];
|
||||
const fallbackPath =
|
||||
fallbackCategory.subItems?.[0]?.path || fallbackCategory.path;
|
||||
setActiveItem(fallbackCategory.id, fallbackPath);
|
||||
}
|
||||
|
||||
// Keep active category expanded after route/user change.
|
||||
$: if (activeCategory && !expandedCategories.has(activeCategory)) {
|
||||
expandedCategories.add(activeCategory);
|
||||
expandedCategories = expandedCategories;
|
||||
}
|
||||
|
||||
// Update active item when page changes
|
||||
$: if ($page && $page.url.pathname !== activeItem) {
|
||||
const matched = categories.find((cat) =>
|
||||
$page.url.pathname.startsWith(cat.path),
|
||||
);
|
||||
const matched = categories.find((cat) => {
|
||||
if ($page.url.pathname.startsWith(cat.path)) return true;
|
||||
return (cat.subItems || []).some((item) =>
|
||||
$page.url.pathname.startsWith(item.path),
|
||||
);
|
||||
});
|
||||
if (matched) {
|
||||
activeCategory = matched.id;
|
||||
activeItem = $page.url.pathname;
|
||||
@@ -255,7 +221,7 @@
|
||||
<Icon
|
||||
name="chevronDown"
|
||||
size={16}
|
||||
class="text-gray-400 transition-transform duration-200 {expandedCategories.has(
|
||||
className="text-gray-400 transition-transform duration-200 {expandedCategories.has(
|
||||
category.id,
|
||||
)
|
||||
? 'rotate-180'
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
import { sidebarStore, toggleMobileSidebar } from "$lib/stores/sidebar.js";
|
||||
import { t } from "$lib/i18n";
|
||||
import { auth } from "$lib/auth/store.js";
|
||||
import { hasPermission } from "$lib/auth/permissions.js";
|
||||
import { toggleAssistantChat } from "$lib/stores/assistantChat.js";
|
||||
import Icon from "$lib/ui/Icon.svelte";
|
||||
import LanguageSwitcher from "$lib/ui/LanguageSwitcher.svelte";
|
||||
@@ -72,6 +73,7 @@
|
||||
$: activeCount = $activityStore?.activeCount || 0;
|
||||
$: recentTasks = $activityStore?.recentTasks || [];
|
||||
$: user = $auth?.user || null;
|
||||
$: canOpenSettings = hasPermission(user, "admin:settings", "READ");
|
||||
$: globalEnvironments = $environmentContextStore?.environments || [];
|
||||
$: globalSelectedEnvId = $environmentContextStore?.selectedEnvId || "";
|
||||
$: globalSelectedEnv = $selectedEnvironmentStore;
|
||||
@@ -316,8 +318,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await initializeEnvironmentContext();
|
||||
onMount(() => {
|
||||
void initializeEnvironmentContext();
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("click", handleDocumentClick);
|
||||
}
|
||||
@@ -508,19 +510,21 @@
|
||||
<strong>{user?.username || $t.common?.user}</strong>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 my-1"></div>
|
||||
<div
|
||||
class="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer"
|
||||
on:click={() => {
|
||||
window.location.href = "/settings";
|
||||
}}
|
||||
on:keydown={(e) =>
|
||||
(e.key === "Enter" || e.key === " ") &&
|
||||
(window.location.href = "/settings")}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{$t.nav?.settings}
|
||||
</div>
|
||||
{#if canOpenSettings}
|
||||
<div
|
||||
class="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer"
|
||||
on:click={() => {
|
||||
window.location.href = "/settings";
|
||||
}}
|
||||
on:keydown={(e) =>
|
||||
(e.key === "Enter" || e.key === " ") &&
|
||||
(window.location.href = "/settings")}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{$t.nav?.settings}
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="px-4 py-2 text-sm text-destructive hover:bg-destructive-light cursor-pointer"
|
||||
on:click={handleLogout}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// [DEF:frontend.src.lib.components.layout.__tests__.sidebarNavigation:Module]
|
||||
// @TIER: STANDARD
|
||||
// @SEMANTICS: tests, sidebar, navigation, rbac, permissions
|
||||
// @PURPOSE: Verifies RBAC-based sidebar category and subitem visibility.
|
||||
// @LAYER: UI (Tests)
|
||||
// @RELATION: TESTS -> frontend/src/lib/components/layout/sidebarNavigation.js
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildSidebarCategories } from "../sidebarNavigation.js";
|
||||
|
||||
const i18nState = {
|
||||
nav: {
|
||||
dashboards: "Dashboards",
|
||||
overview: "Overview",
|
||||
datasets: "Datasets",
|
||||
all_datasets: "All datasets",
|
||||
storage: "Storage",
|
||||
backups: "Backups",
|
||||
repositories: "Repositories",
|
||||
reports: "Reports",
|
||||
profile: "Profile",
|
||||
admin: "Admin",
|
||||
admin_users: "User management",
|
||||
admin_roles: "Role management",
|
||||
settings: "Settings",
|
||||
},
|
||||
};
|
||||
|
||||
function makeUser(roles) {
|
||||
return { roles };
|
||||
}
|
||||
|
||||
describe("sidebarNavigation", () => {
|
||||
it("shows only categories available to a non-admin user", () => {
|
||||
const user = makeUser([
|
||||
{
|
||||
name: "Operator",
|
||||
permissions: [
|
||||
{ resource: "plugin:migration", action: "READ" },
|
||||
{ resource: "tasks", action: "READ" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const categories = buildSidebarCategories(i18nState, user);
|
||||
const categoryIds = categories.map((category) => category.id);
|
||||
|
||||
expect(categoryIds).toEqual(["dashboards", "datasets", "reports", "profile"]);
|
||||
});
|
||||
|
||||
it("hides admin category when user has no admin permissions", () => {
|
||||
const user = makeUser([
|
||||
{
|
||||
name: "Viewer",
|
||||
permissions: [{ resource: "plugin:migration", action: "READ" }],
|
||||
},
|
||||
]);
|
||||
|
||||
const categories = buildSidebarCategories(i18nState, user);
|
||||
const adminCategory = categories.find((category) => category.id === "admin");
|
||||
|
||||
expect(adminCategory).toBeUndefined();
|
||||
});
|
||||
|
||||
it("shows full admin category for admin role", () => {
|
||||
const user = makeUser([
|
||||
{
|
||||
name: "Admin",
|
||||
permissions: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const categories = buildSidebarCategories(i18nState, user);
|
||||
const adminCategory = categories.find((category) => category.id === "admin");
|
||||
|
||||
expect(adminCategory).toBeDefined();
|
||||
expect(adminCategory.subItems.map((item) => item.path)).toEqual([
|
||||
"/admin/users",
|
||||
"/admin/roles",
|
||||
"/settings",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps profile visible even without explicit plugin permissions", () => {
|
||||
const user = makeUser([
|
||||
{
|
||||
name: "Basic",
|
||||
permissions: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const categories = buildSidebarCategories(i18nState, user);
|
||||
const categoryIds = categories.map((category) => category.id);
|
||||
|
||||
expect(categoryIds).toEqual(["profile"]);
|
||||
});
|
||||
});
|
||||
|
||||
// [/DEF:frontend.src.lib.components.layout.__tests__.sidebarNavigation:Module]
|
||||
166
frontend/src/lib/components/layout/sidebarNavigation.js
Normal file
166
frontend/src/lib/components/layout/sidebarNavigation.js
Normal file
@@ -0,0 +1,166 @@
|
||||
// [DEF:frontend.src.lib.components.layout.sidebarNavigation:Module]
|
||||
// @TIER: STANDARD
|
||||
// @SEMANTICS: navigation, sidebar, rbac, menu, filtering
|
||||
// @PURPOSE: Build sidebar navigation categories filtered by current user permissions.
|
||||
// @LAYER: UI
|
||||
// @RELATION: DEPENDS_ON -> frontend.src.lib.auth.permissions.hasPermission
|
||||
// @RELATION: USED_BY -> frontend.src.lib.components.layout.Sidebar
|
||||
// @INVARIANT: Admin role can access all categories and subitems through permission utility.
|
||||
|
||||
import { hasPermission } from "$lib/auth/permissions.js";
|
||||
|
||||
// [DEF:isItemAllowed:Function]
|
||||
// @PURPOSE: Check whether a single menu node can be shown for a given user.
|
||||
// @PRE: item can contain optional requiredPermission/requiredAction.
|
||||
// @POST: Returns true when no permission is required or permission check passes.
|
||||
function isItemAllowed(user, item) {
|
||||
if (!item?.requiredPermission) return true;
|
||||
return hasPermission(
|
||||
user,
|
||||
item.requiredPermission,
|
||||
item.requiredAction || "READ",
|
||||
);
|
||||
}
|
||||
// [/DEF:isItemAllowed:Function]
|
||||
|
||||
// [DEF:buildSidebarCategories:Function]
|
||||
// @PURPOSE: Build translated sidebar categories and filter them by RBAC permissions.
|
||||
// @PRE: i18nState provides nav labels; user can be null.
|
||||
// @POST: Returns only categories/subitems available for provided user.
|
||||
export function buildSidebarCategories(i18nState, user) {
|
||||
const nav = i18nState?.nav || {};
|
||||
|
||||
const categories = [
|
||||
{
|
||||
id: "dashboards",
|
||||
label: nav.dashboards,
|
||||
icon: "dashboard",
|
||||
tone: "from-sky-100 to-sky-200 text-sky-700 ring-sky-200",
|
||||
path: "/dashboards",
|
||||
requiredPermission: "plugin:migration",
|
||||
requiredAction: "READ",
|
||||
subItems: [
|
||||
{
|
||||
label: nav.overview,
|
||||
path: "/dashboards",
|
||||
requiredPermission: "plugin:migration",
|
||||
requiredAction: "READ",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "datasets",
|
||||
label: nav.datasets,
|
||||
icon: "database",
|
||||
tone: "from-emerald-100 to-emerald-200 text-emerald-700 ring-emerald-200",
|
||||
path: "/datasets",
|
||||
requiredPermission: "plugin:migration",
|
||||
requiredAction: "READ",
|
||||
subItems: [
|
||||
{
|
||||
label: nav.all_datasets,
|
||||
path: "/datasets",
|
||||
requiredPermission: "plugin:migration",
|
||||
requiredAction: "READ",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "storage",
|
||||
label: nav.storage,
|
||||
icon: "storage",
|
||||
tone: "from-amber-100 to-amber-200 text-amber-800 ring-amber-200",
|
||||
path: "/storage",
|
||||
requiredPermission: "plugin:storage",
|
||||
requiredAction: "READ",
|
||||
subItems: [
|
||||
{
|
||||
label: nav.backups,
|
||||
path: "/storage/backups",
|
||||
requiredPermission: "plugin:storage",
|
||||
requiredAction: "READ",
|
||||
},
|
||||
{
|
||||
label: nav.repositories,
|
||||
path: "/storage/repos",
|
||||
requiredPermission: "plugin:storage",
|
||||
requiredAction: "READ",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "reports",
|
||||
label: nav.reports,
|
||||
icon: "reports",
|
||||
tone: "from-violet-100 to-fuchsia-100 text-violet-700 ring-violet-200",
|
||||
path: "/reports",
|
||||
requiredPermission: "tasks",
|
||||
requiredAction: "READ",
|
||||
subItems: [
|
||||
{
|
||||
label: nav.reports,
|
||||
path: "/reports",
|
||||
requiredPermission: "tasks",
|
||||
requiredAction: "READ",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "profile",
|
||||
label: nav.profile,
|
||||
icon: "admin",
|
||||
tone: "from-indigo-100 to-indigo-200 text-indigo-700 ring-indigo-200",
|
||||
path: "/profile",
|
||||
subItems: [{ label: nav.profile, path: "/profile" }],
|
||||
},
|
||||
{
|
||||
id: "admin",
|
||||
label: nav.admin,
|
||||
icon: "admin",
|
||||
tone: "from-rose-100 to-rose-200 text-rose-700 ring-rose-200",
|
||||
path: "/admin",
|
||||
subItems: [
|
||||
{
|
||||
label: nav.admin_users,
|
||||
path: "/admin/users",
|
||||
requiredPermission: "admin:users",
|
||||
requiredAction: "READ",
|
||||
},
|
||||
{
|
||||
label: nav.admin_roles,
|
||||
path: "/admin/roles",
|
||||
requiredPermission: "admin:roles",
|
||||
requiredAction: "READ",
|
||||
},
|
||||
{
|
||||
label: nav.settings,
|
||||
path: "/settings",
|
||||
requiredPermission: "admin:settings",
|
||||
requiredAction: "READ",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return categories
|
||||
.map((category) => {
|
||||
const visibleSubItems = (category.subItems || []).filter((subItem) =>
|
||||
isItemAllowed(user, subItem),
|
||||
);
|
||||
return {
|
||||
...category,
|
||||
subItems: visibleSubItems,
|
||||
};
|
||||
})
|
||||
.filter((category) => {
|
||||
const categoryVisible = isItemAllowed(user, category);
|
||||
if (!categoryVisible) return false;
|
||||
|
||||
const hasVisibleSubItems =
|
||||
Array.isArray(category.subItems) && category.subItems.length > 0;
|
||||
return hasVisibleSubItems;
|
||||
});
|
||||
}
|
||||
// [/DEF:buildSidebarCategories:Function]
|
||||
|
||||
// [/DEF:frontend.src.lib.components.layout.sidebarNavigation:Module]
|
||||
Reference in New Issue
Block a user