diff --git a/ordinis-admin-ui/src/auth/usePermissions.ts b/ordinis-admin-ui/src/auth/usePermissions.ts index 84af018..aa56000 100644 --- a/ordinis-admin-ui/src/auth/usePermissions.ts +++ b/ordinis-admin-ui/src/auth/usePermissions.ts @@ -105,12 +105,51 @@ function tokenRoles(accessToken: string | undefined | null): string[] { } /** - * True если у actor'а есть указанная role или super-role {@link ROLE_ADMIN}. - * Admin grants full workflow access — convenience override для small teams. + * True если role — admin-эквивалент. Tail-match по `-`/`_`/`:` → + * `ADMIN`. Зеркало backend `WorkflowRoles.isAdminRole`. + * + *

Matches: `admin`, `ordinis:admin`, `ordinis-admin`, `org:admin`, + * `global:admin`, `app_admin`. Юзер может настроить Keycloak с любой из + * этих conventions — UI gate должен работать одинаково. + */ +function isAdminRoleString(role: string): boolean { + if (!role) return false + const s = role.trim().toUpperCase() + const sep = Math.max(s.lastIndexOf('-'), s.lastIndexOf('_'), s.lastIndexOf(':')) + const tail = sep >= 0 ? s.substring(sep + 1) : s + return tail === 'ADMIN' +} + +/** + * True если у актора есть admin-эквивалентная роль (tail-match) в любом + * claim path. Используется для UI gate'ов где нужно знать "может ли actor + * обойти workflow restriction" — e.g. self-approve override. + */ +function hasAdminRole(accessToken: string | undefined | null): boolean { + return tokenRoles(accessToken).some(isAdminRoleString) +} + +/** + * True если у actor'а есть указанная role или **любая** admin-эквивалентная + * super-role (tail-match → ADMIN). Раньше matched только exact + * `'ordinis:admin'` — юзер с plain ролью `admin` не считался админом и UI + * блокировал Approve button. Теперь mirror'ит backend tail-match. */ function hasRole(accessToken: string | undefined | null, role: string): boolean { const roles = tokenRoles(accessToken) - return roles.includes(role) || roles.includes(ROLE_ADMIN) + if (roles.includes(role)) return true + return roles.some(isAdminRoleString) +} + +/** + * Hook variant: true если currentSub имеет admin-эквивалентную роль. + * Mirror backend `WorkflowRoles.isAdmin()` — используется для self-approve + * override и других UI gate'ов где admin = bypass workflow rules. + */ +export function useIsAdmin(): boolean { + const auth = useAuth() + if (!auth.isAuthenticated) return false + return hasAdminRole(auth.user?.access_token) } /** Может ли актор approve / request_changes / reject schema draft. */ diff --git a/ordinis-admin-ui/src/routes/reviews.tsx b/ordinis-admin-ui/src/routes/reviews.tsx index 7673031..215bab3 100644 --- a/ordinis-admin-ui/src/routes/reviews.tsx +++ b/ordinis-admin-ui/src/routes/reviews.tsx @@ -25,6 +25,7 @@ import { } from '@/ui' import axios from 'axios' import { useAuth } from '@/auth/useAuth' +import { useIsAdmin } from '@/auth/usePermissions' import { ArrowCounterClockwiseIcon, CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react' import { useDictionaries, @@ -693,11 +694,16 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) { // 1. `isPending` = false: terminal draft (APPROVED/REJECTED/WITHDRAWN). // Никаких actions — read-only diff + decision banner. // 2. `isMineAndPending`: maker открыл свой собственный pending draft из - // "Мои" таба. Approve self запрещён backend'ом (self_approve_forbidden), - // reject своего черновика бессмыслен — показываем Withdraw. + // "Мои" таба. Для regular maker'а Approve self запрещён backend'ом + // (self_approve_forbidden), показываем Withdraw. Admin override + // (MR !280): backend разрешает self-approve если у actor'а admin + // роль → frontend тоже снимает блокировку, иначе UI скрывает кнопку + // которую backend бы пропустил. // 3. Иначе — reviewer flow: approve/reject как было. + const isAdmin = useIsAdmin() const isPending = draft?.status === 'PENDING' - const isMineAndPending = isPending && Boolean(currentSub) && draft?.makerId === currentSub + const isMineAndPending = + isPending && Boolean(currentSub) && draft?.makerId === currentSub && !isAdmin const handleApprove = () => { if (!draftId) return