feat(rbac): hide unauthorized menu sections and enforce route guards
This commit is contained in:
@@ -245,6 +245,23 @@ export const api = {
|
||||
},
|
||||
createTask: (pluginId, params) => postApi('/tasks', { plugin_id: pluginId, params }),
|
||||
|
||||
// Profile
|
||||
getProfilePreferences: () => fetchApi('/profile/preferences'),
|
||||
updateProfilePreferences: (payload) => requestApi('/profile/preferences', 'PATCH', payload),
|
||||
lookupSupersetAccounts: (environmentId, options = {}) => {
|
||||
const normalizedEnvironmentId = String(environmentId || '').trim();
|
||||
if (!normalizedEnvironmentId) {
|
||||
throw new Error('environmentId is required for Superset account lookup');
|
||||
}
|
||||
const params = new URLSearchParams({ environment_id: normalizedEnvironmentId });
|
||||
if (options.search) params.append('search', options.search);
|
||||
if (options.page_index != null) params.append('page_index', String(options.page_index));
|
||||
if (options.page_size != null) params.append('page_size', String(options.page_size));
|
||||
if (options.sort_column) params.append('sort_column', options.sort_column);
|
||||
if (options.sort_order) params.append('sort_order', options.sort_order);
|
||||
return fetchApi(`/profile/superset-accounts?${params.toString()}`);
|
||||
},
|
||||
|
||||
// Settings
|
||||
getSettings: () => fetchApi('/settings'),
|
||||
updateGlobalSettings: (settings) => requestApi('/settings/global', 'PATCH', settings),
|
||||
@@ -268,6 +285,13 @@ export const api = {
|
||||
if (options.search) params.append('search', options.search);
|
||||
if (options.page) params.append('page', options.page);
|
||||
if (options.page_size) params.append('page_size', options.page_size);
|
||||
if (options.page_context) params.append('page_context', options.page_context);
|
||||
if (options.apply_profile_default != null) {
|
||||
params.append('apply_profile_default', String(Boolean(options.apply_profile_default)));
|
||||
}
|
||||
if (options.override_show_all != null) {
|
||||
params.append('override_show_all', String(Boolean(options.override_show_all)));
|
||||
}
|
||||
if (options.filters?.title) {
|
||||
for (const value of options.filters.title) params.append('filter_title', value);
|
||||
}
|
||||
@@ -330,6 +354,9 @@ export const getPlugins = api.getPlugins;
|
||||
export const getTasks = api.getTasks;
|
||||
export const getTask = api.getTask;
|
||||
export const createTask = api.createTask;
|
||||
export const getProfilePreferences = api.getProfilePreferences;
|
||||
export const updateProfilePreferences = api.updateProfilePreferences;
|
||||
export const lookupSupersetAccounts = api.lookupSupersetAccounts;
|
||||
export const getSettings = api.getSettings;
|
||||
export const updateGlobalSettings = api.updateGlobalSettings;
|
||||
export const getEnvironments = api.getEnvironments;
|
||||
|
||||
103
frontend/src/lib/auth/__tests__/permissions.test.js
Normal file
103
frontend/src/lib/auth/__tests__/permissions.test.js
Normal file
@@ -0,0 +1,103 @@
|
||||
// [DEF:frontend.src.lib.auth.__tests__.permissions:Module]
|
||||
// @TIER: STANDARD
|
||||
// @SEMANTICS: tests, auth, permissions, rbac
|
||||
// @PURPOSE: Verifies frontend RBAC permission parsing and access checks.
|
||||
// @LAYER: UI (Tests)
|
||||
// @RELATION: TESTS -> frontend/src/lib/auth/permissions.js
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
normalizePermissionRequirement,
|
||||
isAdminUser,
|
||||
hasPermission,
|
||||
} from "../permissions.js";
|
||||
|
||||
describe("auth.permissions", () => {
|
||||
it("normalizes resource-only requirement with default READ action", () => {
|
||||
expect(normalizePermissionRequirement("admin:settings")).toEqual({
|
||||
resource: "admin:settings",
|
||||
action: "READ",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes explicit resource:action requirement", () => {
|
||||
expect(normalizePermissionRequirement("admin:settings:write")).toEqual({
|
||||
resource: "admin:settings",
|
||||
action: "WRITE",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects admin role case-insensitively", () => {
|
||||
const user = {
|
||||
roles: [{ name: "ADMIN" }],
|
||||
};
|
||||
expect(isAdminUser(user)).toBe(true);
|
||||
});
|
||||
|
||||
it("denies when user is absent and permission is required", () => {
|
||||
expect(hasPermission(null, "tasks", "READ")).toBe(false);
|
||||
});
|
||||
|
||||
it("grants when permission object matches resource and action", () => {
|
||||
const user = {
|
||||
roles: [
|
||||
{
|
||||
name: "Operator",
|
||||
permissions: [{ resource: "tasks", action: "READ" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(hasPermission(user, "tasks", "READ")).toBe(true);
|
||||
});
|
||||
|
||||
it("grants when requirement is provided as resource:action", () => {
|
||||
const user = {
|
||||
roles: [
|
||||
{
|
||||
name: "Operator",
|
||||
permissions: [{ resource: "admin:settings", action: "READ" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(hasPermission(user, "admin:settings:READ")).toBe(true);
|
||||
});
|
||||
|
||||
it("grants when string permission entry matches", () => {
|
||||
const user = {
|
||||
roles: [
|
||||
{
|
||||
name: "Operator",
|
||||
permissions: ["plugin:migration:READ"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(hasPermission(user, "plugin:migration", "READ")).toBe(true);
|
||||
});
|
||||
|
||||
it("denies when action does not match", () => {
|
||||
const user = {
|
||||
roles: [
|
||||
{
|
||||
name: "Operator",
|
||||
permissions: [{ resource: "tasks", action: "READ" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(hasPermission(user, "tasks", "WRITE")).toBe(false);
|
||||
});
|
||||
|
||||
it("always grants for admin role regardless of explicit permissions", () => {
|
||||
const adminUser = {
|
||||
roles: [{ name: "Admin", permissions: [] }],
|
||||
};
|
||||
|
||||
expect(hasPermission(adminUser, "admin:users", "READ")).toBe(true);
|
||||
expect(hasPermission(adminUser, "plugin:migration", "EXECUTE")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// [/DEF:frontend.src.lib.auth.__tests__.permissions:Module]
|
||||
103
frontend/src/lib/auth/permissions.js
Normal file
103
frontend/src/lib/auth/permissions.js
Normal file
@@ -0,0 +1,103 @@
|
||||
// [DEF:frontend.src.lib.auth.permissions:Module]
|
||||
// @TIER: STANDARD
|
||||
// @SEMANTICS: auth, permissions, rbac, roles
|
||||
// @PURPOSE: Shared frontend RBAC utilities for route guards and menu visibility.
|
||||
// @LAYER: Domain
|
||||
// @RELATION: USED_BY -> frontend.src.components.auth.ProtectedRoute
|
||||
// @RELATION: USED_BY -> frontend.src.lib.components.layout.Sidebar
|
||||
// @INVARIANT: Admin role always bypasses explicit permission checks.
|
||||
|
||||
const KNOWN_ACTIONS = new Set(["READ", "WRITE", "EXECUTE", "DELETE"]);
|
||||
|
||||
function normalizeAction(action, fallback = "READ") {
|
||||
const normalized = String(action || "").trim().toUpperCase();
|
||||
if (!normalized) return fallback;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// [DEF:normalizePermissionRequirement:Function]
|
||||
// @PURPOSE: Convert permission requirement string to canonical resource/action tuple.
|
||||
// @PRE: Permission can be "resource" or "resource:ACTION" where resource itself may contain ":".
|
||||
// @POST: Returns normalized object with action in uppercase.
|
||||
export function normalizePermissionRequirement(permission, defaultAction = "READ") {
|
||||
const fallbackAction = normalizeAction(defaultAction, "READ");
|
||||
const rawPermission = String(permission || "").trim();
|
||||
|
||||
if (!rawPermission) {
|
||||
return { resource: null, action: fallbackAction };
|
||||
}
|
||||
|
||||
const parts = rawPermission.split(":");
|
||||
if (parts.length > 1) {
|
||||
const tail = normalizeAction(parts[parts.length - 1], fallbackAction);
|
||||
if (KNOWN_ACTIONS.has(tail)) {
|
||||
const resource = parts.slice(0, -1).join(":");
|
||||
return { resource, action: tail };
|
||||
}
|
||||
}
|
||||
|
||||
return { resource: rawPermission, action: fallbackAction };
|
||||
}
|
||||
// [/DEF:normalizePermissionRequirement:Function]
|
||||
|
||||
// [DEF:isAdminUser:Function]
|
||||
// @PURPOSE: Determine whether user has Admin role.
|
||||
// @PRE: user can be null or partially populated.
|
||||
// @POST: Returns true when at least one role name equals "Admin" (case-insensitive).
|
||||
export function isAdminUser(user) {
|
||||
const roles = Array.isArray(user?.roles) ? user.roles : [];
|
||||
return roles.some(
|
||||
(role) => String(role?.name || "").trim().toLowerCase() === "admin",
|
||||
);
|
||||
}
|
||||
// [/DEF:isAdminUser:Function]
|
||||
|
||||
// [DEF:hasPermission:Function]
|
||||
// @PURPOSE: Check if user has a required resource/action permission.
|
||||
// @PRE: user contains roles with permissions from /api/auth/me payload.
|
||||
// @POST: Returns true when requirement is empty, user is admin, or matching permission exists.
|
||||
export function hasPermission(user, requirement, action = "READ") {
|
||||
if (!requirement) return true;
|
||||
if (!user) return false;
|
||||
if (isAdminUser(user)) return true;
|
||||
|
||||
const { resource, action: requiredAction } = normalizePermissionRequirement(
|
||||
requirement,
|
||||
action,
|
||||
);
|
||||
if (!resource) return true;
|
||||
|
||||
const roles = Array.isArray(user.roles) ? user.roles : [];
|
||||
for (const role of roles) {
|
||||
const permissions = Array.isArray(role?.permissions) ? role.permissions : [];
|
||||
for (const permission of permissions) {
|
||||
if (typeof permission === "string") {
|
||||
const normalized = normalizePermissionRequirement(
|
||||
permission,
|
||||
requiredAction,
|
||||
);
|
||||
if (
|
||||
normalized.resource === resource &&
|
||||
normalized.action === requiredAction
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const permissionResource = String(permission?.resource || "").trim();
|
||||
const permissionAction = normalizeAction(permission?.action, "");
|
||||
if (
|
||||
permissionResource === resource &&
|
||||
permissionAction === requiredAction
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
// [/DEF:hasPermission:Function]
|
||||
|
||||
// [/DEF:frontend.src.lib.auth.permissions:Module]
|
||||
@@ -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]
|
||||
@@ -69,7 +69,8 @@
|
||||
"admin_users": "User Management",
|
||||
"admin_roles": "Role Management",
|
||||
"admin_settings": "ADFS Configuration",
|
||||
"admin_llm": "LLM Providers"
|
||||
"admin_llm": "LLM Providers",
|
||||
"profile": "Profile"
|
||||
},
|
||||
"llm": {
|
||||
"providers_title": "LLM Providers",
|
||||
@@ -398,6 +399,32 @@
|
||||
"status_error": "Error",
|
||||
"empty": "No dashboards found"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profile",
|
||||
"description": "Manage your dashboard filter preferences.",
|
||||
"dashboard_preferences": "Dashboard Preferences",
|
||||
"superset_environment": "Superset Environment",
|
||||
"superset_environment_placeholder": "Select environment",
|
||||
"superset_account": "Your Apache Superset Account",
|
||||
"superset_account_placeholder": "Enter your Apache Superset username",
|
||||
"show_only_my_dashboards": "Show only my dashboards by default",
|
||||
"save_preferences": "Save Preferences",
|
||||
"lookup_loading": "Loading Superset accounts...",
|
||||
"lookup_error": "Cannot load Superset accounts for this environment right now. You can enter username manually.",
|
||||
"save_success": "Preferences saved",
|
||||
"save_error": "Failed to save preferences. Please try again.",
|
||||
"invalid_username": "Username should not contain spaces. Please enter a valid Apache Superset username.",
|
||||
"username_required": "Superset username is required when default filter is enabled.",
|
||||
"filter_badge_active": "My Dashboards Only",
|
||||
"filter_badge_override": "Showing all dashboards temporarily",
|
||||
"filter_empty_state": "No dashboards found for your account. Try adjusting your filter settings.",
|
||||
"filter_show_all_temporarily": "Show all dashboards temporarily",
|
||||
"filter_restore_default": "Restore default filter",
|
||||
"saving": "Saving...",
|
||||
"lookup_button": "Lookup",
|
||||
"lookup_search_placeholder": "Search account candidates",
|
||||
"lookup_no_results": "No account candidates found. You can enter username manually."
|
||||
},
|
||||
"reports": {
|
||||
"title": "Reports",
|
||||
"empty": "No reports available.",
|
||||
|
||||
@@ -69,7 +69,8 @@
|
||||
"admin_users": "Управление пользователями",
|
||||
"admin_roles": "Управление ролями",
|
||||
"admin_settings": "Настройка ADFS",
|
||||
"admin_llm": "Провайдеры LLM"
|
||||
"admin_llm": "Провайдеры LLM",
|
||||
"profile": "Профиль"
|
||||
},
|
||||
"llm": {
|
||||
"providers_title": "Провайдеры LLM",
|
||||
@@ -396,6 +397,32 @@
|
||||
"status_error": "Ошибка",
|
||||
"empty": "Дашборды не найдены"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Профиль",
|
||||
"description": "Управляйте настройками фильтра дашбордов.",
|
||||
"dashboard_preferences": "Настройки дашбордов",
|
||||
"superset_environment": "Окружение Superset",
|
||||
"superset_environment_placeholder": "Выберите окружение",
|
||||
"superset_account": "Ваш аккаунт Apache Superset",
|
||||
"superset_account_placeholder": "Введите имя пользователя Apache Superset",
|
||||
"show_only_my_dashboards": "Показывать только мои дашборды по умолчанию",
|
||||
"save_preferences": "Сохранить настройки",
|
||||
"lookup_loading": "Загрузка аккаунтов Superset...",
|
||||
"lookup_error": "Сейчас не удается загрузить аккаунты Superset для этого окружения. Вы можете ввести имя пользователя вручную.",
|
||||
"save_success": "Настройки сохранены",
|
||||
"save_error": "Не удалось сохранить настройки. Попробуйте снова.",
|
||||
"invalid_username": "Имя пользователя не должно содержать пробелы. Введите корректное имя пользователя Apache Superset.",
|
||||
"username_required": "Имя пользователя Superset обязательно, когда фильтр по умолчанию включен.",
|
||||
"filter_badge_active": "Только мои дашборды",
|
||||
"filter_badge_override": "Временно показаны все дашборды",
|
||||
"filter_empty_state": "Для вашего аккаунта дашборды не найдены. Попробуйте изменить настройки фильтра.",
|
||||
"filter_show_all_temporarily": "Временно показать все дашборды",
|
||||
"filter_restore_default": "Вернуть фильтр по умолчанию",
|
||||
"saving": "Сохранение...",
|
||||
"lookup_button": "Найти",
|
||||
"lookup_search_placeholder": "Поиск среди аккаунтов",
|
||||
"lookup_no_results": "Кандидаты аккаунтов не найдены. Вы можете ввести имя пользователя вручную."
|
||||
},
|
||||
"reports": {
|
||||
"title": "Отчеты",
|
||||
"empty": "Отчеты отсутствуют.",
|
||||
|
||||
Reference in New Issue
Block a user