feat(schema-workflow): Phase 1c — CreateModal + /reviews extension + RBAC stub

This commit is contained in:
Александр Зимин
2026-05-12 11:20:29 +00:00
parent 00fe254ccc
commit ac3395f4de
6 changed files with 455 additions and 4 deletions
@@ -0,0 +1,41 @@
import { useAuth } from 'react-oidc-context'
/**
* Permission helpers для schema workflow. Phase 1c — stub: проверяем
* только что пользователь authenticated (любой logged-in юзер может
* approve/publish). Backend всё равно отрежет:
* <ul>
* <li>{@code 403 self_approve_forbidden} — если maker == reviewer.</li>
* <li>{@code 403 forbidden_role} — когда Keycloak roles прорастут и
* backend начнёт проверять realm-роли (placeholder).</li>
* </ul>
*
* <p>Phase 1d: подключим decode JWT → realm_access.roles, и эти hook'и
* начнут возвращать false для users без \`ordinis-schema-reviewer\` /
* \`ordinis-schema-publisher\` ролей. UI hide'ит соответствующие кнопки.
*
* <p>До тех пор все logged-in юзеры могут жать любые workflow buttons —
* backend гейт остаётся primary defence.
*/
/** Может ли актор approve / request_changes / reject schema draft. */
export function useCanReviewSchema(): boolean {
const auth = useAuth()
// TODO Phase 1d: const roles = (auth.user?.profile as { realm_access?: { roles?: string[] } })?.realm_access?.roles ?? []
// return roles.includes('ordinis-schema-reviewer')
return auth.isAuthenticated
}
/** Может ли актор publish approved schema draft (bump live version). */
export function useCanPublishSchema(): boolean {
const auth = useAuth()
// TODO Phase 1d: roles.includes('ordinis-schema-publisher')
return auth.isAuthenticated
}
/** Может ли актор create schema draft (maker side). */
export function useCanCreateSchemaDraft(): boolean {
const auth = useAuth()
// TODO Phase 1d: roles.includes('ordinis-schema-maker') OR any authenticated
return auth.isAuthenticated
}
@@ -0,0 +1,238 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import axios from 'axios'
import {
Alert,
Button,
Modal,
TextArea,
toast,
} from '@/ui'
import { ArrowsClockwiseIcon, CheckSquareIcon } from '@phosphor-icons/react'
import type { DictionaryDetail } from '@/api/client'
import { useCreateSchemaDraft } from '@/api/mutations'
type Props = {
open: boolean
onClose: () => void
detail: DictionaryDetail
/** Optional callback после успешного create — parent может закрыть modal +
* открыть SchemaDraftDrawer чтоб maker сразу submit'нул. */
onCreated?: (draftId: string) => void
}
/**
* Создание schema draft'а с editor'ом proposed schema. Минимальная версия:
* JSON textarea с {@code Format} и {@code Validate} buttons. Phase 1d
* может upgrade'ить до Monaco / visual schema editor.
*
* <p>Initial value seeded из текущего {@code detail.schemaJson}. User editing
* правит proposedSchema, добавляет {@code reason}, submit'ит.
*
* <p>Backend errors surface'ятся как toast:
* <ul>
* <li>409 {@code version_mismatch} — кто-то опубликовал свой draft пока
* этот пользователь готовился. Refresh page + retry.</li>
* <li>409 {@code active_draft_exists} — уже есть non-terminal draft.
* Пользователь должен open existing.</li>
* <li>400 {@code invalid_schema} — proposedSchema не валидна. Backend
* вернёт detail в message.</li>
* </ul>
*/
export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Props) {
const { t } = useTranslation()
const initialJson = useMemo(
() => JSON.stringify(detail.schemaJson, null, 2),
[detail.schemaJson],
)
const [json, setJson] = useState(initialJson)
const [reason, setReason] = useState('')
const [parseError, setParseError] = useState<string | null>(null)
// Reset state при re-open чтобы не показывать stale данные с прошлого dict'а.
useEffect(() => {
if (open) {
setJson(initialJson)
setReason('')
setParseError(null)
}
}, [open, initialJson])
const createMut = useCreateSchemaDraft(detail.name)
const handleFormat = () => {
try {
const parsed = JSON.parse(json)
setJson(JSON.stringify(parsed, null, 2))
setParseError(null)
} catch (e) {
setParseError((e as Error).message)
}
}
const handleValidate = () => {
try {
const parsed = JSON.parse(json)
if (typeof parsed !== 'object' || parsed === null) {
setParseError(
t('createDraft.notObject', {
defaultValue: 'Schema должна быть JSON object',
}),
)
return false
}
setParseError(null)
toast.success(
t('createDraft.validationPassed', { defaultValue: 'JSON валиден' }),
)
return true
} catch (e) {
setParseError((e as Error).message)
return false
}
}
const handleSubmit = () => {
let parsed: unknown
try {
parsed = JSON.parse(json)
} catch (e) {
setParseError((e as Error).message)
return
}
createMut.mutate(
{
fromVersion: detail.schemaVersion,
reason: reason || undefined,
proposedSchema: parsed,
},
{
onSuccess: (draft) => {
toast.success(
t('createDraft.toast.created', { defaultValue: 'Черновик создан' }),
)
onCreated?.(draft.draftId)
onClose()
},
onError: (err) => {
let msg = t('createDraft.toast.failed', {
defaultValue: 'Не удалось создать черновик',
})
if (axios.isAxiosError(err)) {
const body = err.response?.data as
| { code?: string; message?: string }
| undefined
msg = body?.message ?? body?.code ?? msg
}
toast.error(msg)
},
},
)
}
return (
<Modal
isOpen={open}
onClose={onClose}
size="xl"
title={t('createDraft.title', { defaultValue: 'Создать черновик схемы' })}
description={`${detail.name} · v${detail.schemaVersion}`}
>
<div className="space-y-4">
<Alert variant="info">
{t('createDraft.intro', {
defaultValue:
'Изменения сохранятся как черновик от текущей версии. Live-схема не меняется до публикации после ревью.',
})}
</Alert>
{/* Reason */}
<div>
<label className="block text-cap text-mute font-display uppercase tracking-wider mb-1">
{t('createDraft.reason', { defaultValue: 'Причина изменений' })}
</label>
<TextArea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder={t('createDraft.reasonPlaceholder', {
defaultValue: 'Зачем эти изменения нужны (видно ревьюеру)',
})}
rows={2}
disabled={createMut.isPending}
/>
</div>
{/* JSON Schema editor */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-cap text-mute font-display uppercase tracking-wider">
{t('createDraft.schema', { defaultValue: 'Схема (JSON)' })}
</label>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleFormat}
disabled={createMut.isPending}
leftIcon={<ArrowsClockwiseIcon weight="regular" size={14} />}
>
{t('createDraft.format', { defaultValue: 'Format' })}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleValidate}
disabled={createMut.isPending}
leftIcon={<CheckSquareIcon weight="regular" size={14} />}
>
{t('createDraft.validate', { defaultValue: 'Validate' })}
</Button>
</div>
</div>
{/* Custom monospace textarea — для full Monaco editor'а Phase 1d.
Высота min-h-[400px] чтобы средняя schema ~30-50 полей помещалась
без скролла. */}
<textarea
value={json}
onChange={(e) => {
setJson(e.target.value)
if (parseError) setParseError(null)
}}
spellCheck={false}
disabled={createMut.isPending}
className="w-full min-h-[400px] max-h-[60vh] text-mono text-cell rounded-md border border-line bg-[#0c0a23] text-[#f0eaff] p-3 focus:outline-none focus:ring-1 focus:ring-accent"
/>
{parseError && (
<Alert variant="error" className="mt-2">
<span className="font-mono text-cell">{parseError}</span>
</Alert>
)}
</div>
{/* Footer actions */}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-line">
<Button
type="button"
variant="ghost"
onClick={onClose}
disabled={createMut.isPending}
>
{t('common.cancel', { defaultValue: 'Отмена' })}
</Button>
<Button
type="button"
variant="primary"
onClick={handleSubmit}
disabled={createMut.isPending || Boolean(parseError)}
>
{createMut.isPending
? t('createDraft.creating', { defaultValue: 'Создание…' })
: t('createDraft.submit', { defaultValue: 'Создать черновик' })}
</Button>
</div>
</div>
</Modal>
)
}
@@ -23,6 +23,7 @@ import {
useSubmitSchemaDraftForReview,
useWithdrawSchemaDraft,
} from '@/api/mutations'
import { useCanPublishSchema, useCanReviewSchema } from '@/auth/usePermissions'
type Props = {
open: boolean
@@ -391,6 +392,11 @@ function DraftActions({
hasCurrentVersion: boolean
}) {
const { t } = useTranslation()
// Phase 1c RBAC stub: для review_pending reviewer-only действия скрываем
// если нет approve role. Publish — отдельная role. Backend всё равно
// защищает. Phase 1d intergrate'нет Keycloak realm_access.roles.
const canReview = useCanReviewSchema()
const canPublish = useCanPublishSchema()
if (!isActiveSchemaDraft(status)) {
return (
@@ -421,7 +427,7 @@ function DraftActions({
})}
</Button>
)}
{status === 'review_pending' && (
{status === 'review_pending' && canReview && (
<>
<Button
variant="primary"
@@ -454,7 +460,7 @@ function DraftActions({
</Button>
</>
)}
{status === 'approved' && (
{status === 'approved' && canPublish && (
<Button
variant="primary"
size="sm"
+53
View File
@@ -476,6 +476,33 @@ i18n
'workflow.schemaDraft.toast.decideFailed': 'Не удалось зафиксировать решение',
'workflow.schemaDraft.toast.publishFailed': 'Не удалось опубликовать',
'workflow.schemaDraft.toast.withdrawFailed': 'Не удалось отозвать',
// === CreateSchemaDraftModal ===
'createDraft.title': 'Создать черновик схемы',
'createDraft.intro': 'Изменения сохранятся как черновик от текущей версии. Live-схема не меняется до публикации после ревью.',
'createDraft.openButton': 'Создать черновик схемы',
'createDraft.reason': 'Причина изменений',
'createDraft.reasonPlaceholder': 'Зачем эти изменения нужны (видно ревьюеру)',
'createDraft.schema': 'Схема (JSON)',
'createDraft.format': 'Format',
'createDraft.validate': 'Validate',
'createDraft.validationPassed': 'JSON валиден',
'createDraft.notObject': 'Schema должна быть JSON object',
'createDraft.submit': 'Создать черновик',
'createDraft.creating': 'Создание…',
'createDraft.toast.created': 'Черновик создан',
'createDraft.toast.failed': 'Не удалось создать черновик',
// === Schema review queue panel ===
'reviews.schema.title': 'Изменения схем',
'reviews.schema.label': 'Schema workflow',
'reviews.schema.queueTotal_one': '{{count}} draft ждёт ревью',
'reviews.schema.queueTotal_few': '{{count}} draft\'а ждут ревью',
'reviews.schema.queueTotal_many': '{{count}} draft\'ов ждут ревью',
'reviews.schema.dictionary': 'Словарь',
'reviews.schema.fromVersion': 'От версии',
'reviews.schema.maker': 'Автор',
'reviews.schema.submittedAt': 'Отправлен',
'reviews.schema.action': 'Действие',
'reviews.schema.open': 'Открыть',
// === Lineage (dict-relationships-v2 Phase 1) ===
'lineage.usedBy.title': 'На этот словарь ссылаются',
'lineage.usedBy.count_one': '{{count}} связь',
@@ -1068,6 +1095,32 @@ i18n
'workflow.schemaDraft.toast.decideFailed': 'Failed to record decision',
'workflow.schemaDraft.toast.publishFailed': 'Failed to publish',
'workflow.schemaDraft.toast.withdrawFailed': 'Failed to withdraw',
// === CreateSchemaDraftModal ===
'createDraft.title': 'Create schema draft',
'createDraft.intro': 'Changes are saved as a draft branched from the current version. Live schema is not touched until publish-after-review.',
'createDraft.openButton': 'Create schema draft',
'createDraft.reason': 'Reason for changes',
'createDraft.reasonPlaceholder': 'Why these changes are needed (visible to the reviewer)',
'createDraft.schema': 'Schema (JSON)',
'createDraft.format': 'Format',
'createDraft.validate': 'Validate',
'createDraft.validationPassed': 'JSON is valid',
'createDraft.notObject': 'Schema must be a JSON object',
'createDraft.submit': 'Create draft',
'createDraft.creating': 'Creating…',
'createDraft.toast.created': 'Draft created',
'createDraft.toast.failed': 'Failed to create draft',
// === Schema review queue panel ===
'reviews.schema.title': 'Schema changes',
'reviews.schema.label': 'Schema workflow',
'reviews.schema.queueTotal_one': '{{count}} draft awaiting review',
'reviews.schema.queueTotal_other': '{{count}} drafts awaiting review',
'reviews.schema.dictionary': 'Dictionary',
'reviews.schema.fromVersion': 'From version',
'reviews.schema.maker': 'Author',
'reviews.schema.submittedAt': 'Submitted',
'reviews.schema.action': 'Action',
'reviews.schema.open': 'Open',
// === Lineage (dict-relationships-v2 Phase 1) ===
'lineage.usedBy.title': 'Used by',
'lineage.usedBy.count_one': '{{count}} reference',
@@ -25,10 +25,11 @@ import {
TableRow,
TextInput,
} from '@/ui'
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, TrashIcon, CheckCircleIcon, WarningIcon, XIcon, CaretLeftIcon, CaretRightIcon, DotsThreeVerticalIcon } from '@phosphor-icons/react'
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, TrashIcon, CheckCircleIcon, WarningIcon, XIcon, CaretLeftIcon, CaretRightIcon, DotsThreeVerticalIcon, GitBranchIcon } from '@phosphor-icons/react'
import {
useAudit,
useChangelog,
useDictActiveSchemaDraft,
useDictionaryDetail,
useDictPendingDrafts,
useRecordRaw,
@@ -53,6 +54,7 @@ import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialo
import { TimeTravelModal } from '@/components/timetravel/TimeTravelModal'
import { ChangelogDiffModal } from '@/components/changelog/ChangelogDiffModal'
import { kindBadgeVariant, kindIcon, kindLabel, isVersioningKind } from '@/components/changelog/kind-meta'
import { CreateSchemaDraftModal } from '@/components/workflow/CreateSchemaDraftModal'
import { nowIsoLocal } from '@/lib/dates'
import { recordDisplayName } from '@/lib/locales'
import { SCOPE_BORDER_TOP } from '@/lib/scope-style'
@@ -132,6 +134,14 @@ function DictionaryDetail() {
const { t } = useTranslation()
const detailQuery = useDictionaryDetail(name)
const canMutate = useCanMutate()
// Phase 1c: show «Create draft» button only когда approvalRequired=true
// и нет active draft'а (иначе banner предложит open existing). Live (без
// approval flag) словари редактируются напрямую через EditorDialog —
// создавать draft не нужно.
const activeSchemaDraftQuery = useDictActiveSchemaDraft(name)
const shouldOfferCreateDraft =
detailQuery.data?.approvalRequired === true &&
!activeSchemaDraftQuery.data
// AOI: bbox из URL → bbox-AOI rendered initially; polygon AOI живёт
// только в local state (не сериализуем в URL — слишком длинно).
@@ -194,6 +204,7 @@ function DictionaryDetail() {
total: 0,
})
const [schemaEditOpen, setSchemaEditOpen] = useState(false)
const [createDraftOpen, setCreateDraftOpen] = useState(false)
const [historyKey, setHistoryKey] = useState<string | undefined>(undefined)
const [aoiOpen, setAoiOpen] = useState(false)
const [timeTravelOpen, setTimeTravelOpen] = useState(false)
@@ -703,6 +714,21 @@ function DictionaryDetail() {
actions={
canMutate ? (
<>
{/* approval-required dict без active draft: предлагаем
создать черновик. Direct edit через GearIcon оставляем
для админов, но primary path через workflow. */}
{shouldOfferCreateDraft && (
<IconButton
type="button"
label={t('createDraft.openButton', {
defaultValue: 'Создать черновик схемы',
})}
variant="default"
icon={<GitBranchIcon weight="regular" />}
disabled={!detailQuery.data}
onClick={() => setCreateDraftOpen(true)}
/>
)}
<IconButton
type="button"
label={t('schema.action.editSchema')}
@@ -1214,6 +1240,14 @@ function DictionaryDetail() {
/>
)}
{detailQuery.data && (
<CreateSchemaDraftModal
open={createDraftOpen}
onClose={() => setCreateDraftOpen(false)}
detail={detailQuery.data}
/>
)}
<RecordHistoryDrawer
open={Boolean(historyKey)}
onClose={() => setHistoryKey(undefined)}
+80 -1
View File
@@ -22,7 +22,7 @@ import {
TextInput,
} from '@/ui'
import { CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react'
import { useDraft, useLiveRecord, useReviewQueue } from '@/api/queries'
import { useDraft, useLiveRecord, useReviewQueue, useSchemaReviewQueue } from '@/api/queries'
import { useApproveDraft, useRejectDraft } from '@/api/mutations'
import type { DraftOperation, DraftResponse } from '@/api/client'
@@ -142,6 +142,8 @@ function ReviewsPage() {
description={t('reviews.description')}
/>
<SchemaReviewQueuePanel />
{queue.isLoading && <LoadingBlock size="md" label={t('loading')} />}
{queue.error && (
@@ -507,3 +509,80 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
</Drawer>
)
}
/**
* Schema review pool queue global view всех \`review_pending\` schema
* draft'ов через {@code GET /admin/schema-reviews/pending}. Reviewer
* жмёт «Открыть» переходит на dict detail page, banner+drawer
* автоматически подхватятся через {@code useDictActiveSchemaDraft}.
*
* <p>Скрывается если pool пуст экономит вертикальное место на странице
* для основного record-review queue.
*/
function SchemaReviewQueuePanel() {
const { t } = useTranslation()
const queue = useSchemaReviewQueue(0, 20)
const items = queue.data?.items ?? []
if (queue.isLoading || items.length === 0) return null
return (
<Panel>
<div className="flex items-center justify-between mb-3">
<div>
<h2 className="text-title-md font-semibold">
{t('reviews.schema.title', { defaultValue: 'Изменения схем' })}
</h2>
<p className="text-cell text-mute mt-0.5">
{t('reviews.schema.queueTotal', {
count: queue.data?.total ?? items.length,
defaultValue: `${queue.data?.total ?? items.length} draft(s) ждут ревью`,
})}
</p>
</div>
<Badge variant="info">
{t('reviews.schema.label', { defaultValue: 'Schema workflow' })}
</Badge>
</div>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>{t('reviews.schema.dictionary', { defaultValue: 'Словарь' })}</TableHeaderCell>
<TableHeaderCell>{t('reviews.schema.fromVersion', { defaultValue: 'От версии' })}</TableHeaderCell>
<TableHeaderCell>{t('reviews.schema.maker', { defaultValue: 'Автор' })}</TableHeaderCell>
<TableHeaderCell>{t('reviews.schema.submittedAt', { defaultValue: 'Отправлен' })}</TableHeaderCell>
<TableHeaderCell align="right">{t('reviews.schema.action', { defaultValue: 'Действие' })}</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((d) => (
<TableRow key={d.draftId}>
<TableCell>
<Link
to="/dictionaries/$name"
params={{ name: d.dictionaryName }}
className="text-accent hover:underline font-mono"
>
{d.dictionaryName}
</Link>
</TableCell>
<TableCell className="font-mono tabular-nums">v{d.branchedFromVersion}</TableCell>
<TableCell className="font-mono">{d.makerId}</TableCell>
<TableCell className="tabular-nums">
{d.submittedAt ? new Date(d.submittedAt).toLocaleString() : '—'}
</TableCell>
<TableCell align="right">
<Link
to="/dictionaries/$name"
params={{ name: d.dictionaryName }}
className="text-accent hover:underline"
>
{t('reviews.schema.open', { defaultValue: 'Открыть' })}
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Panel>
)
}