Перейти к основному содержимому

RBAC-гейт в UI

Как показывать и прятать элементы UI по ролям пользователя — кнопки, пункты меню, целые экраны. Рецепт строится на хуке useCan (и чистой функции can) из @mineflow/client-react, 7 канонических ролях SystemRole и маппинге Keycloak-ролей через mapKeycloakRoles.

UI-гейт — не безопасность

useCan/can управляют только видимостью в интерфейсе. Это удобство (не показывать кнопку, которую всё равно нельзя нажать), а не контроль доступа. Сервер заново проверяет роли и object-scope по JWT и вернёт 403, если запрос недопустим. Никогда не полагайтесь на UI-гейт как на единственную защиту: пользователь может выполнить запрос мимо вашего UI.

7 канонических ролей

Источник истины — бэкенд (SystemRole), на фронт тип приходит без дрейфа. Канонические роли всегда в PascalCase:

import type { SystemRole } from '@mineflow/client-core';

// 'CEO' | 'Engineer' | 'Foreman' | 'Mechanic' | 'Admin' | 'OtibSpecialist' | 'Supply'
РольНазначение
CEOРуководство
EngineerИнженер БВР
ForemanБригадир (сменные рапорты)
MechanicМеханик (обслуживание техники)
AdminАдминистратор системы
OtibSpecialistСпециалист ОТиБ
SupplyСнабжение (ТМЦ, ГСМ)
PascalCase, не lowercase

В Keycloak realm-роли пишутся в lowercase (foreman, otib), но во фронте используются только канонические PascalCase-имена. Конвертация — разовая, через mapKeycloakRoles (см. ниже). Передавать в useCan строки вида 'foreman' нельзя — TypeScript не пропустит, а на рантайме гейт молча не сработает.

useCan — гейт в компоненте

useCan(required) берёт роли текущего пользователя из <MineflowProvider> и возвращает boolean: есть ли у пользователя хотя бы одна из требуемых ролей (OR-семантика).

import { useCan } from '@mineflow/client-react';

function ApproveButton({ reportId }: { reportId: string }) {
// Утвердить рапорт могут CEO и Engineer
const canApprove = useCan(['CEO', 'Engineer']);
if (!canApprove) return null;

return <button onClick={() => approve(reportId)}>Утвердить</button>;
}
Пустой массив = любой аутентифицированный

useCan([])can(roles, [])) всегда возвращает true. Это сознательное поведение: «требуется только вход в систему, конкретная роль не важна». Используйте для элементов, доступных всем залогиненным пользователям.

const canSeeProfile = useCan([]); // true для любого аутентифицированного

useCan работает только внутри <MineflowProvider> — он читает roles из контекста (см. гайд client-react). Вне провайдера хук бросит ошибку useMineflow must be used within <MineflowProvider>.

can — чистая функция вне React

Если роли уже на руках (например, в роутер-guard, в утилите или в тесте), берите чистую функцию can(userRoles, required) из того же пакета — она не требует контекста и React:

import { can } from '@mineflow/client-react';
import type { SystemRole } from '@mineflow/client-core';

function guardRoute(userRoles: readonly SystemRole[]): boolean {
// секция снабжения: Supply или Admin
return can(userRoles, ['Supply', 'Admin']);
}

useCan — это просто can(roles из контекста, required), так что семантика идентична: OR между ролями, пустой requiredtrue.

Откуда берутся роли: mapKeycloakRoles

<MineflowProvider> ждёт роли в каноническом PascalCase. Auth-адаптер (@mineflow/auth-web для web, @mineflow/auth-native для RN) обычно резолвит их сам через getRoles(). Под капотом lowercase realm-роли Keycloak конвертируются в SystemRole функцией mapKeycloakRoles — она же реэкспортируется из auth-web для платформенного паритета:

import { mapKeycloakRoles } from '@mineflow/client-core';

// realm-роли из JWT (lowercase) → канонические SystemRole, с дедупликацией
mapKeycloakRoles(['foreman', 'otib', 'unknown']); // → ['Foreman', 'OtibSpecialist']

Неизвестные realm-роли (нет в маппинге) просто отбрасываются. Дубликаты схлопываются.

import Keycloak from 'keycloak-js';
import { KeycloakTokenProvider } from '@mineflow/auth-web';
import { MineflowProvider } from '@mineflow/client-react';

const tokenProvider = new KeycloakTokenProvider({ keycloak });

function App() {
return (
<MineflowProvider
baseUrl={import.meta.env.VITE_API_BASE}
tokenProvider={tokenProvider}
generateId={() => crypto.randomUUID()}
roles={tokenProvider.getRoles()} // уже PascalCase SystemRole[]
>
<Screens />
</MineflowProvider>
);
}
Один источник истины

mapKeycloakRoles живёт в platform-agnostic ядре @mineflow/client-core (используется и web-, и RN-адаптерами). Это единственная допущенная копия серверного маппинга — она покрыта тестом, который сторожит все 7 ролей. Если на бэке появится 8-я роль, маппинг и тип SystemRole обновятся синхронно.

Рецепты по сценариям

Прятать кнопку

function DecommissionButton() {
const canDecommission = useCan(['Engineer', 'Admin']);
return canDecommission ? <button>Списать актив</button> : null;
}

Дизейблить вместо скрытия

Иногда лучше показать действие, но заблокировать его (чтобы пользователь видел, что функция существует):

function RegisterAssetButton() {
const canRegister = useCan(['Engineer', 'Admin']);
return (
<button disabled={!canRegister} title={canRegister ? '' : 'Недостаточно прав'}>
Зарегистрировать
</button>
);
}

Гейт целого экрана

function SupplyScreen() {
const canEnter = useCan(['Supply', 'Admin']);
if (!canEnter) return <AccessDenied />;
return <SupplyDashboard />;
}

Комбинация с FSM-видимостью

RBAC и состояние сущности — независимые оси. Кнопку показываем, только если переход доступен в текущем статусе (FSM) и у пользователя есть роль:

import { useAvailableActions, useCan } from '@mineflow/client-react';
import { shiftReportMachine } from '@mineflow/api-schemas';

function ApproveAction({ status }: { status: string }) {
const actions = useAvailableActions(shiftReportMachine, status);
const canApprove = useCan(['CEO', 'Engineer']);
if (!actions.includes('APPROVE') || !canApprove) return null;
return <button>Утвердить</button>;
}

Подробнее про FSM-кнопки — в рецепте FSM-кнопки в UI.

Гейт ≠ object-scope

useCan проверяет только наличие роли, но не объектную привязку. Например, Foreman может редактировать рапорты своих участков, но не чужих — это object-scope, его enforce'ит только сервер (по JWT, без org_id/scope в URL). На отказ по scope сервер вернёт 403 — обрабатывайте его как обычную ошибку (см. рецепт Обработка ошибок), не пытайтесь воспроизвести scope-логику на фронте.

Что дальше