feat(design-preview): права доступа — широкая боковая панель с двумя колонками

Слева приложения с переключателем и счётчиком выданных разделов, справа разделы с уровнем
«Нет / Чтение / Запись / Админ». «Сохранить права» активна только при изменении,
закрытие с правками спрашивает подтверждение.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-18 02:33:06 +07:00
1 parent e02cfad0b1
commit f0a257220f
6 files changed
+290 -201

No files matched your search

@@ -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<Level, string> = {
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<AppId[]>(user.apps)
const [permissions, setPermissions] = useState<string[]>(user.permissions)
const next = useMemo<AccessValue>(() => ({ 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 (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="flex max-h-[calc(100svh-2rem)] w-[min(52rem,calc(100vw-2rem))] max-w-none flex-col gap-4 sm:max-w-none">
<DialogHeader>
<DialogTitle>Права доступа · {user.email}</DialogTitle>
<DialogDescription>Настройка доступа к приложениям и разделам</DialogDescription>
</DialogHeader>
{user.is_admin ? (
<SignalAlert tone="in" title="Администратор">
Полный доступ ко всем приложениям и разделам не зависит от настроек ниже.
</SignalAlert>
) : null}
<Tabs defaultValue="apps" className="min-h-0 min-w-0 flex-1">
<TabsList className="max-w-full justify-start overflow-x-auto">
<TabsTrigger value="apps">Приложения</TabsTrigger>
{PERMISSION_CATALOG.map((c) => (
<TabsTrigger key={c.appId} value={c.appId}>
{APP_SHORT[c.appId]}
</TabsTrigger>
))}
<TabsTrigger value="journal">Журнал</TabsTrigger>
</TabsList>
<TabsContent value="apps" className="min-h-0 overflow-y-auto">
<ul className="flex flex-col divide-y rounded-lg border">
{APPS.map((app) => (
<li key={app.id} className="flex items-center justify-between gap-3 px-3 py-2.5">
<label htmlFor={`app-${app.id}`} className="flex min-w-0 flex-col">
<span className="text-sm font-medium">{app.title}</span>
<span className="text-muted-foreground text-xs">{app.description}</span>
</label>
<Switch
id={`app-${app.id}`}
checked={apps.includes(app.id)}
onCheckedChange={(on) =>
setApps((prev) => (on ? [...prev, app.id] : prev.filter((x) => x !== app.id)))
}
/>
</li>
))}
</ul>
</TabsContent>
{PERMISSION_CATALOG.map((catalog) => (
<TabsContent key={catalog.appId} value={catalog.appId} className="min-h-0 overflow-y-auto">
{!apps.includes(catalog.appId) ? (
<p className="text-muted-foreground mb-2 text-xs">
Приложение отключено на вкладке «Приложения» — права разделов не действуют.
</p>
) : null}
<ul className="flex flex-col divide-y rounded-lg border">
{catalog.sections.map((section) => {
const level = levelOf(permissions, catalog.appId, section.id, section.actions)
const options: Level[] = ['none', ...section.actions]
return (
<li key={section.id} className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 px-3 py-2.5">
<div className="flex min-w-0 flex-1 basis-48 flex-col">
<span className="text-sm font-medium">{section.title}</span>
<span className="text-muted-foreground text-xs">{section.description}</span>
</div>
<Select
value={level}
onValueChange={(v) => v && setLevel(catalog.appId, section.id, section.actions, v as Level)}
items={options.map((o) => ({ value: o, label: LEVEL_LABEL[o] }))}
>
<SelectTrigger size="sm" className="w-40" aria-label={`${section.title}: уровень доступа`}>
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectGroup>
{options.map((o) => (
<SelectItem key={o} value={o}>
{LEVEL_LABEL[o]}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</li>
)
})}
</ul>
</TabsContent>
))}
<TabsContent value="journal" className="min-h-0 overflow-y-auto">
<CodeBlock label={`${user.email} · 24 часа`}>{USER_JOURNAL}</CodeBlock>
</TabsContent>
</Tabs>
<DialogFooter className="items-center">
<span className="text-muted-foreground mr-auto text-xs">
{lines.length > 0 ? `Изменений: ${lines.length}` : 'Без изменений'}
</span>
<Button variant="outline" onClick={onClose}>
Отмена
</Button>
<Button
disabled={lines.length === 0}
onClick={() => {
onSave(next)
onClose()
}}
>
Сохранить права
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -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<Pane>(() => 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 (
<div className="flex min-h-0 flex-1 flex-col gap-4">
{user.is_admin ? (
<SignalAlert tone="in" title="Администратор">
Полный доступ ко всем приложениям и разделам не зависит от настроек ниже.
</SignalAlert>
) : null}
<div className="grid min-h-0 flex-1 gap-4 md:grid-cols-[15.5rem_minmax(0,1fr)]">
<nav aria-label="Приложения" className="flex flex-col gap-1.5">
{APPS.map((a) => {
const { granted, total } = grantCount(value.permissions, a.id)
const on = value.apps.includes(a.id)
const active = pane === a.id
return (
<div
key={a.id}
className={cn(
'flex items-center gap-2 rounded-lg border px-2.5 py-2 transition-colors',
active ? 'bg-muted border-border' : 'border-transparent hover:bg-muted/50',
)}
>
<Switch
size="sm"
checked={on}
onCheckedChange={(next) =>
onChange({ ...value, apps: next ? [...value.apps, a.id] : value.apps.filter((x) => x !== a.id) })
}
aria-label={`${a.title}: доступ к приложению`}
/>
<button
type="button"
aria-current={active ? 'true' : undefined}
onClick={() => setPane(a.id)}
className="focus-visible:ring-ring flex min-w-0 flex-1 items-center gap-2 rounded-md text-left outline-none focus-visible:ring-2"
>
<span className={cn('min-w-0 flex-1 truncate text-sm font-medium', !on && 'text-muted-foreground')}>
{a.title}
</span>
<Badge variant={on && granted > 0 ? 'info-light' : 'outline'} size="sm" className="shrink-0 tabular-nums">
{granted}/{total}
</Badge>
</button>
</div>
)
})}
<button
type="button"
aria-current={pane === 'journal' ? 'true' : undefined}
onClick={() => setPane('journal')}
className={cn(
'focus-visible:ring-ring mt-1 flex items-center gap-2 rounded-lg border px-2.5 py-2 text-left text-sm font-medium outline-none focus-visible:ring-2',
pane === 'journal' ? 'bg-muted border-border' : 'text-muted-foreground hover:bg-muted/50 border-transparent',
)}
>
<ScrollTextIcon className="size-4" aria-hidden />
Журнал
<ChevronRightIcon className="ml-auto size-4" aria-hidden />
</button>
</nav>
<section className="flex min-w-0 flex-col gap-3" aria-label={app?.title ?? 'Журнал'}>
{pane === 'journal' ? (
<CodeBlock label={`${user.email} · 24 часа`}>{USER_JOURNAL}</CodeBlock>
) : catalog && app ? (
<>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex min-w-0 flex-col gap-0.5">
<h3 className="text-sm font-semibold">{app.title}</h3>
<p className="text-muted-foreground text-xs">{app.description}</p>
</div>
<div className="flex shrink-0 gap-1.5">
<Button type="button" size="xs" variant="outline" onClick={() => setAll('read')}>
Чтение везде
</Button>
<Button type="button" size="xs" variant="outline" onClick={() => setAll('none')}>
Сбросить
</Button>
</div>
</div>
{!appOn ? (
<p className="text-muted-foreground bg-muted/40 rounded-md border px-3 py-2 text-xs">
Приложение выключено слева — права разделов не действуют, пока оно не включено.
</p>
) : null}
<ul className={cn('flex flex-col divide-y rounded-lg border', !appOn && 'opacity-60')}>
{catalog.sections.map((section) => {
const level = levelOf(value.permissions, catalog.appId, section.id, section.actions)
const options: Level[] = ['none', ...section.actions]
return (
<li key={section.id} className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 px-3 py-2.5">
<div className="flex min-w-0 flex-1 basis-40 flex-col">
<span className="text-sm font-medium">{section.title}</span>
<span className="text-muted-foreground text-xs">{section.description}</span>
</div>
<Tabs value={level} onValueChange={(v) => setLevel(catalog.appId, section.id, section.actions, v as Level)}>
<TabsList aria-label={`${section.title}: уровень доступа`}>
{options.map((o) => (
<TabsTrigger key={o} value={o}>
{LEVEL_LABEL[o]}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</li>
)
})}
</ul>
</>
) : null}
</section>
</div>
</div>
)
}
+59 -1
View File
@@ -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<Level, string> = {
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()
}
@@ -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<AccessValue>({ apps: user.apps, permissions: user.permissions })
const changes = useMemo(() => diffAccess({ apps: user.apps, permissions: user.permissions }, value).length, [user, value])
return (
<PreviewSheet
open
onOpenChange={(o) => !o && onClose()}
size="lg"
dirty={changes > 0}
title={`Права доступа · ${user.email}`}
description="Доступ к приложениям и разделам · иерархия: админ ⊃ запись ⊃ чтение"
footer={
<>
<span className="text-muted-foreground mr-auto self-center text-xs">
{changes > 0 ? `Изменений: ${changes}` : 'Без изменений'}
</span>
<Button variant="outline" onClick={onClose}>
Отмена
</Button>
<Button
disabled={changes === 0}
onClick={() => {
onSave(value)
onClose()
}}
>
Сохранить права
</Button>
</>
}
>
<AccessEditor user={user} value={value} onChange={setValue} />
</PreviewSheet>
)
}
+16 -2
View File
@@ -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({
<SheetContent
side="right"
showCloseButton={false}
className="inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] w-[min(26rem,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none sm:max-w-none"
className={cn(
'inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none sm:max-w-none',
'data-[side=right]:sm:max-w-none',
size === 'lg'
? 'data-[side=right]:w-[min(56rem,calc(100vw-2rem))]'
: 'data-[side=right]:w-[min(26rem,calc(100vw-2rem))]',
)}
>
<SheetHeader className="shrink-0 gap-0 p-0">
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
@@ -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() {
<CreateUserSheet open={createOpen} onOpenChange={setCreateOpen} pending={false} error={null} onSubmit={onCreate} />
{accessFor ? (
<AccessDialog
<AccessSheet
key={accessFor.id}
user={accessFor}
onClose={() => setAccessFor(null)}