From f0a257220fe044a6ad41b9a9a2263e20c2018a98 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 18 Oct 2026 02:33:06 +0700 Subject: [PATCH] =?UTF-8?q?feat(design-preview):=20=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=B0=20=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF=D0=B0=20?= =?UTF-8?q?=E2=80=94=20=D1=88=D0=B8=D1=80=D0=BE=D0=BA=D0=B0=D1=8F=20=D0=B1?= =?UTF-8?q?=D0=BE=D0=BA=D0=BE=D0=B2=D0=B0=D1=8F=20=D0=BF=D0=B0=D0=BD=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=20=D1=81=20=D0=B4=D0=B2=D1=83=D0=BC=D1=8F=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BB=D0=BE=D0=BD=D0=BA=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Слева приложения с переключателем и счётчиком выданных разделов, справа разделы с уровнем «Нет / Чтение / Запись / Админ». «Сохранить права» активна только при изменении, закрытие с правками спрашивает подтверждение. Co-Authored-By: Claude Sonnet 5.5 --- apps/web/src/design-preview/access-dialog.tsx | 196 ------------------ apps/web/src/design-preview/access-editor.tsx | 161 ++++++++++++++ apps/web/src/design-preview/access-model.ts | 60 +++++- apps/web/src/design-preview/access-sheet.tsx | 52 +++++ apps/web/src/design-preview/bits.tsx | 18 +- .../src/design-preview/pages/users-page.tsx | 4 +- 6 files changed, 290 insertions(+), 201 deletions(-) delete mode 100644 apps/web/src/design-preview/access-dialog.tsx create mode 100644 apps/web/src/design-preview/access-editor.tsx create mode 100644 apps/web/src/design-preview/access-sheet.tsx diff --git a/apps/web/src/design-preview/access-dialog.tsx b/apps/web/src/design-preview/access-dialog.tsx deleted file mode 100644 index c22a5ee..0000000 --- a/apps/web/src/design-preview/access-dialog.tsx +++ /dev/null @@ -1,196 +0,0 @@ -import { useMemo, useState } from 'react' -import { - PERMISSION_CATALOG, - hasPermission, - permissionKey, - type AdminUser, - type AppId, - type PermissionAction, -} from '@authportal/shared' -import { APPS } from '@authportal/shared' -import { Button } from '@authportal/ui/components/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@authportal/ui/components/dialog' -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, -} from '@authportal/ui/components/select' -import { Switch } from '@authportal/ui/components/switch' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' -import { CodeBlock, SignalAlert } from './bits' -import { diffAccess, type AccessValue } from './access-model' -import { APP_SHORT, USER_JOURNAL } from './mock' - -type Level = 'none' | PermissionAction - -const LEVEL_LABEL: Record = { - none: 'Нет доступа', - read: 'Чтение', - write: 'Запись', - admin: 'Админ', -} - -function levelOf(perms: readonly string[], appId: AppId, sectionId: string, actions: PermissionAction[]): Level { - for (const action of ['admin', 'write', 'read'] as const) { - if (actions.includes(action) && hasPermission(perms, permissionKey(appId, sectionId, action))) { - return action - } - } - return 'none' -} - -/** Ключи для уровня: иерархия админ ⊃ запись ⊃ чтение внутри раздела. */ -function keysForLevel(appId: AppId, sectionId: string, actions: PermissionAction[], level: Level): string[] { - if (level === 'none') return [] - const order: PermissionAction[] = ['read', 'write', 'admin'] - const upto = order.indexOf(level) - return order - .slice(0, upto + 1) - .filter((a) => actions.includes(a)) - .map((a) => permissionKey(appId, sectionId, a)) -} - -/** Окно «Права доступа»: вкладки по приложениям и разделам, локальное состояние, без API. */ -export function AccessDialog({ - user, - onClose, - onSave, -}: { - user: AdminUser - onClose: () => void - onSave: (value: AccessValue) => void -}) { - const [apps, setApps] = useState(user.apps) - const [permissions, setPermissions] = useState(user.permissions) - - const next = useMemo(() => ({ apps, permissions }), [apps, permissions]) - const lines = useMemo(() => diffAccess({ apps: user.apps, permissions: user.permissions }, next), [user, next]) - - function setLevel(appId: AppId, sectionId: string, actions: PermissionAction[], level: Level) { - setPermissions((prev) => { - const prefix = `${appId}:${sectionId}:` - const rest = prev.filter((k) => !k.startsWith(prefix)) - return [...rest, ...keysForLevel(appId, sectionId, actions, level)].sort() - }) - } - - return ( - !o && onClose()}> - - - Права доступа · {user.email} - Настройка доступа к приложениям и разделам - - {user.is_admin ? ( - - Полный доступ ко всем приложениям и разделам не зависит от настроек ниже. - - ) : null} - - - Приложения - {PERMISSION_CATALOG.map((c) => ( - - {APP_SHORT[c.appId]} - - ))} - Журнал - - - -
    - {APPS.map((app) => ( -
  • - - - setApps((prev) => (on ? [...prev, app.id] : prev.filter((x) => x !== app.id))) - } - /> -
  • - ))} -
-
- - {PERMISSION_CATALOG.map((catalog) => ( - - {!apps.includes(catalog.appId) ? ( -

- Приложение отключено на вкладке «Приложения» — права разделов не действуют. -

- ) : null} -
    - {catalog.sections.map((section) => { - const level = levelOf(permissions, catalog.appId, section.id, section.actions) - const options: Level[] = ['none', ...section.actions] - return ( -
  • -
    - {section.title} - {section.description} -
    - -
  • - ) - })} -
-
- ))} - - - {USER_JOURNAL} - -
- - - {lines.length > 0 ? `Изменений: ${lines.length}` : 'Без изменений'} - - - - -
-
- ) -} diff --git a/apps/web/src/design-preview/access-editor.tsx b/apps/web/src/design-preview/access-editor.tsx new file mode 100644 index 0000000..0fcd6da --- /dev/null +++ b/apps/web/src/design-preview/access-editor.tsx @@ -0,0 +1,161 @@ +import { useState } from 'react' +import { ChevronRightIcon, ScrollTextIcon } from 'lucide-react' +import { APPS, PERMISSION_CATALOG, type AdminUser, type AppId } from '@authportal/shared' +import { Badge } from '@/components/reui/badge' +import { Button } from '@authportal/ui/components/button' +import { Switch } from '@authportal/ui/components/switch' +import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' +import { cn } from '@authportal/ui/lib/utils' +import { CodeBlock, SignalAlert } from './bits' +import { LEVEL_LABEL, grantCount, levelOf, withLevel, type AccessValue, type Level } from './access-model' +import { USER_JOURNAL } from './mock' + +type Pane = AppId | 'journal' + +/** + * Редактор прав: слева приложения (переключатель доступа и счётчик выданных разделов), + * справа разделы выбранного приложения с уровнем «Нет / Чтение / Запись / Админ». + */ +export function AccessEditor({ + user, + value, + onChange, +}: { + user: AdminUser + value: AccessValue + onChange: (next: AccessValue) => void +}) { + const [pane, setPane] = useState(() => value.apps[0] ?? 'cfdm') + const catalog = pane === 'journal' ? null : PERMISSION_CATALOG.find((c) => c.appId === pane) + const app = pane === 'journal' ? null : APPS.find((a) => a.id === pane) + const appOn = pane !== 'journal' && value.apps.includes(pane) + + function setLevel(appId: AppId, sectionId: string, actions: readonly ('read' | 'write' | 'admin')[], level: Level) { + onChange({ ...value, permissions: withLevel(value.permissions, appId, sectionId, actions, level) }) + } + + function setAll(level: Level) { + if (!catalog) return + let perms = value.permissions + for (const s of catalog.sections) { + const allowed: Level[] = ['none', ...s.actions] + const target: Level = allowed.includes(level) ? level : 'none' + perms = withLevel(perms, catalog.appId, s.id, s.actions, target) + } + onChange({ ...value, permissions: perms }) + } + + return ( +
+ {user.is_admin ? ( + + Полный доступ ко всем приложениям и разделам не зависит от настроек ниже. + + ) : null} +
+ + +
+ {pane === 'journal' ? ( + {USER_JOURNAL} + ) : catalog && app ? ( + <> +
+
+

{app.title}

+

{app.description}

+
+
+ + +
+
+ {!appOn ? ( +

+ Приложение выключено слева — права разделов не действуют, пока оно не включено. +

+ ) : null} +
    + {catalog.sections.map((section) => { + const level = levelOf(value.permissions, catalog.appId, section.id, section.actions) + const options: Level[] = ['none', ...section.actions] + return ( +
  • +
    + {section.title} + {section.description} +
    + setLevel(catalog.appId, section.id, section.actions, v as Level)}> + + {options.map((o) => ( + + {LEVEL_LABEL[o]} + + ))} + + +
  • + ) + })} +
+ + ) : null} +
+
+
+ ) +} diff --git a/apps/web/src/design-preview/access-model.ts b/apps/web/src/design-preview/access-model.ts index c445272..7a0a970 100644 --- a/apps/web/src/design-preview/access-model.ts +++ b/apps/web/src/design-preview/access-model.ts @@ -1,4 +1,10 @@ -import type { AppId } from '@authportal/shared' +import { + PERMISSION_CATALOG, + hasPermission, + permissionKey, + type AppId, + type PermissionAction, +} from '@authportal/shared' import { APP_SHORT } from './mock' export type AccessValue = { apps: AppId[]; permissions: string[] } @@ -11,3 +17,55 @@ export function diffAccess(before: AccessValue, after: AccessValue): string[] { for (const key of before.permissions) if (!after.permissions.includes(key)) lines.push(`− ${key}`) return lines } + +export type Level = 'none' | 'read' | 'write' | 'admin' + +export const LEVEL_LABEL: Record = { + none: 'Нет', + read: 'Чтение', + write: 'Запись', + admin: 'Админ', +} + +/** Текущий уровень раздела: иерархия админ ⊃ запись ⊃ чтение. */ +export function levelOf( + perms: readonly string[], + appId: AppId, + sectionId: string, + actions: readonly PermissionAction[], +): Level { + for (const action of ['admin', 'write', 'read'] as const) { + if (actions.includes(action) && hasPermission(perms, permissionKey(appId, sectionId, action))) return action + } + return 'none' +} + +/** Ключи прав для уровня раздела. */ +export function keysForLevel(appId: AppId, sectionId: string, actions: readonly PermissionAction[], level: Level): string[] { + if (level === 'none') return [] + const order: PermissionAction[] = ['read', 'write', 'admin'] + return order + .slice(0, order.indexOf(level) + 1) + .filter((a) => actions.includes(a)) + .map((a) => permissionKey(appId, sectionId, a)) +} + +/** Сколько разделов приложения открыто (уровень выше «Нет»). */ +export function grantCount(perms: readonly string[], appId: AppId): { granted: number; total: number } { + const catalog = PERMISSION_CATALOG.find((c) => c.appId === appId) + const sections = catalog?.sections ?? [] + const granted = sections.filter((s) => levelOf(perms, appId, s.id, s.actions) !== 'none').length + return { granted, total: sections.length } +} + +/** Новое множество прав после смены уровня одного раздела. */ +export function withLevel( + perms: readonly string[], + appId: AppId, + sectionId: string, + actions: readonly PermissionAction[], + level: Level, +): string[] { + const prefix = `${appId}:${sectionId}:` + return [...perms.filter((k) => !k.startsWith(prefix)), ...keysForLevel(appId, sectionId, actions, level)].sort() +} diff --git a/apps/web/src/design-preview/access-sheet.tsx b/apps/web/src/design-preview/access-sheet.tsx new file mode 100644 index 0000000..08e8d11 --- /dev/null +++ b/apps/web/src/design-preview/access-sheet.tsx @@ -0,0 +1,52 @@ +import { useMemo, useState } from 'react' +import type { AdminUser } from '@authportal/shared' +import { Button } from '@authportal/ui/components/button' +import { AccessEditor } from './access-editor' +import { diffAccess, type AccessValue } from './access-model' +import { PreviewSheet } from './bits' + +/** Окно «Права доступа»: широкая правая панель, чтобы таблица оставалась в контексте. */ +export function AccessSheet({ + user, + onClose, + onSave, +}: { + user: AdminUser + onClose: () => void + onSave: (value: AccessValue) => void +}) { + const [value, setValue] = useState({ apps: user.apps, permissions: user.permissions }) + const changes = useMemo(() => diffAccess({ apps: user.apps, permissions: user.permissions }, value).length, [user, value]) + + return ( + !o && onClose()} + size="lg" + dirty={changes > 0} + title={`Права доступа · ${user.email}`} + description="Доступ к приложениям и разделам · иерархия: админ ⊃ запись ⊃ чтение" + footer={ + <> + + {changes > 0 ? `Изменений: ${changes}` : 'Без изменений'} + + + + + } + > + + + ) +} diff --git a/apps/web/src/design-preview/bits.tsx b/apps/web/src/design-preview/bits.tsx index fb29db1..3135407 100644 --- a/apps/web/src/design-preview/bits.tsx +++ b/apps/web/src/design-preview/bits.tsx @@ -20,6 +20,7 @@ import { AlertDialogTitle, } from '@authportal/ui/components/alert-dialog' import { Button } from '@authportal/ui/components/button' +import { cn } from '@authportal/ui/lib/utils' import { Input } from '@authportal/ui/components/input' import { Label } from '@authportal/ui/components/label' import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' @@ -110,6 +111,8 @@ export function PreviewSheet({ description, children, footer, + size = 'md', + dirty, }: { open: boolean onOpenChange: (open: boolean) => void @@ -117,8 +120,13 @@ export function PreviewSheet({ description: string children: ReactNode footer?: ReactNode + /** Ширина панели: md — форма, lg — редактор с двумя колонками. */ + size?: 'md' | 'lg' + /** Явный признак изменений (иначе ловим ввод в полях и переключатели). */ + dirty?: boolean }) { - const [touched, setTouched] = useState(false) + const [touchedState, setTouched] = useState(false) + const touched = dirty ?? touchedState const [guard, setGuard] = useState(false) function requestChange(next: boolean) { @@ -136,7 +144,13 @@ export function PreviewSheet({
diff --git a/apps/web/src/design-preview/pages/users-page.tsx b/apps/web/src/design-preview/pages/users-page.tsx index aaa1fad..35e8507 100644 --- a/apps/web/src/design-preview/pages/users-page.tsx +++ b/apps/web/src/design-preview/pages/users-page.tsx @@ -7,7 +7,7 @@ import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' import { Button } from '@authportal/ui/components/button' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' import { UserPlusIcon } from 'lucide-react' -import { AccessDialog } from '../access-dialog' +import { AccessSheet } from '../access-sheet' import { diffAccess, type AccessValue } from '../access-model' import { CodeBlock, KvList, PageHead, PreviewSheet, TypedConfirm } from '../bits' import { kpi } from '../kpi' @@ -165,7 +165,7 @@ export function UsersPage() { {accessFor ? ( - setAccessFor(null)}