Merge branch 'fix/admin-self-approve-frontend' into 'main'

fix(reviews): admin может self-approve record draft через UI

See merge request 2-6/2-6-4/terravault/ordinis!290
This commit is contained in:
Александр Зимин
2026-06-02 13:02:13 +00:00
2 changed files with 51 additions and 6 deletions
+42 -3
View File
@@ -105,12 +105,51 @@ function tokenRoles(accessToken: string | undefined | null): string[] {
} }
/** /**
* True если у actor'а есть указанная role или super-role {@link ROLE_ADMIN}. * True если role — admin-эквивалент. Tail-match по `-`/`_`/`:` →
* Admin grants full workflow access — convenience override для small teams. * `ADMIN`. Зеркало backend `WorkflowRoles.isAdminRole`.
*
* <p>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 { function hasRole(accessToken: string | undefined | null, role: string): boolean {
const roles = tokenRoles(accessToken) 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. */ /** Может ли актор approve / request_changes / reject schema draft. */
+9 -3
View File
@@ -25,6 +25,7 @@ import {
} from '@/ui' } from '@/ui'
import axios from 'axios' import axios from 'axios'
import { useAuth } from '@/auth/useAuth' import { useAuth } from '@/auth/useAuth'
import { useIsAdmin } from '@/auth/usePermissions'
import { ArrowCounterClockwiseIcon, CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react' import { ArrowCounterClockwiseIcon, CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react'
import { import {
useDictionaries, useDictionaries,
@@ -693,11 +694,16 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
// 1. `isPending` = false: terminal draft (APPROVED/REJECTED/WITHDRAWN). // 1. `isPending` = false: terminal draft (APPROVED/REJECTED/WITHDRAWN).
// Никаких actions — read-only diff + decision banner. // Никаких actions — read-only diff + decision banner.
// 2. `isMineAndPending`: maker открыл свой собственный pending draft из // 2. `isMineAndPending`: maker открыл свой собственный pending draft из
// "Мои" таба. Approve self запрещён backend'ом (self_approve_forbidden), // "Мои" таба. Для regular maker'а Approve self запрещён backend'ом
// reject своего черновика бессмыслен — показываем Withdraw. // (self_approve_forbidden), показываем Withdraw. Admin override
// (MR !280): backend разрешает self-approve если у actor'а admin
// роль → frontend тоже снимает блокировку, иначе UI скрывает кнопку
// которую backend бы пропустил.
// 3. Иначе — reviewer flow: approve/reject как было. // 3. Иначе — reviewer flow: approve/reject как было.
const isAdmin = useIsAdmin()
const isPending = draft?.status === 'PENDING' const isPending = draft?.status === 'PENDING'
const isMineAndPending = isPending && Boolean(currentSub) && draft?.makerId === currentSub const isMineAndPending =
isPending && Boolean(currentSub) && draft?.makerId === currentSub && !isAdmin
const handleApprove = () => { const handleApprove = () => {
if (!draftId) return if (!draftId) return