From ac3395f4de71a7b97ea453899c41d771e0b338e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=97=D0=B8=D0=BC=D0=B8=D0=BD?= Date: Tue, 12 May 2026 11:20:29 +0000 Subject: [PATCH] =?UTF-8?q?feat(schema-workflow):=20Phase=201c=20=E2=80=94?= =?UTF-8?q?=20CreateModal=20+=20/reviews=20extension=20+=20RBAC=20stub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ordinis-admin-ui/src/auth/usePermissions.ts | 41 +++ .../workflow/CreateSchemaDraftModal.tsx | 238 ++++++++++++++++++ .../components/workflow/SchemaDraftDrawer.tsx | 10 +- ordinis-admin-ui/src/i18n.ts | 53 ++++ .../src/routes/dictionaries.$name.tsx | 36 ++- ordinis-admin-ui/src/routes/reviews.tsx | 81 +++++- 6 files changed, 455 insertions(+), 4 deletions(-) create mode 100644 ordinis-admin-ui/src/auth/usePermissions.ts create mode 100644 ordinis-admin-ui/src/components/workflow/CreateSchemaDraftModal.tsx diff --git a/ordinis-admin-ui/src/auth/usePermissions.ts b/ordinis-admin-ui/src/auth/usePermissions.ts new file mode 100644 index 0000000..24391e3 --- /dev/null +++ b/ordinis-admin-ui/src/auth/usePermissions.ts @@ -0,0 +1,41 @@ +import { useAuth } from 'react-oidc-context' + +/** + * Permission helpers для schema workflow. Phase 1c — stub: проверяем + * только что пользователь authenticated (любой logged-in юзер может + * approve/publish). Backend всё равно отрежет: + * + * + *

Phase 1d: подключим decode JWT → realm_access.roles, и эти hook'и + * начнут возвращать false для users без \`ordinis-schema-reviewer\` / + * \`ordinis-schema-publisher\` ролей. UI hide'ит соответствующие кнопки. + * + *

До тех пор все 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 +} diff --git a/ordinis-admin-ui/src/components/workflow/CreateSchemaDraftModal.tsx b/ordinis-admin-ui/src/components/workflow/CreateSchemaDraftModal.tsx new file mode 100644 index 0000000..8ba1c2b --- /dev/null +++ b/ordinis-admin-ui/src/components/workflow/CreateSchemaDraftModal.tsx @@ -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. + * + *

Initial value seeded из текущего {@code detail.schemaJson}. User editing + * правит proposedSchema, добавляет {@code reason}, submit'ит. + * + *

Backend errors surface'ятся как toast: + *

+ */ +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(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 ( + +
+ + {t('createDraft.intro', { + defaultValue: + 'Изменения сохранятся как черновик от текущей версии. Live-схема не меняется до публикации после ревью.', + })} + + + {/* Reason */} +
+ +