feat(rbac): hide unauthorized menu sections and enforce route guards

This commit is contained in:
2026-03-06 10:50:28 +03:00
parent a5086f3eef
commit e7cb5237d3
43 changed files with 4071 additions and 245 deletions

View File

@@ -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;

View 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]

View 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]

View File

@@ -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'

View File

@@ -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}

View File

@@ -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]

View 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]

View File

@@ -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.",

View File

@@ -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": "Отчеты отсутствуют.",