feat(design-preview): права доступа — широкая боковая панель с двумя колонками
Слева приложения с переключателем и счётчиком выданных разделов, справа разделы с уровнем «Нет / Чтение / Запись / Админ». «Сохранить права» активна только при изменении, закрытие с правками спрашивает подтверждение. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'
|
import { APP_SHORT } from './mock'
|
||||||
|
|
||||||
export type AccessValue = { apps: AppId[]; permissions: string[] }
|
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}`)
|
for (const key of before.permissions) if (!after.permissions.includes(key)) lines.push(`− ${key}`)
|
||||||
return lines
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@authportal/ui/components/alert-dialog'
|
} from '@authportal/ui/components/alert-dialog'
|
||||||
import { Button } from '@authportal/ui/components/button'
|
import { Button } from '@authportal/ui/components/button'
|
||||||
|
import { cn } from '@authportal/ui/lib/utils'
|
||||||
import { Input } from '@authportal/ui/components/input'
|
import { Input } from '@authportal/ui/components/input'
|
||||||
import { Label } from '@authportal/ui/components/label'
|
import { Label } from '@authportal/ui/components/label'
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
||||||
@@ -110,6 +111,8 @@ export function PreviewSheet({
|
|||||||
description,
|
description,
|
||||||
children,
|
children,
|
||||||
footer,
|
footer,
|
||||||
|
size = 'md',
|
||||||
|
dirty,
|
||||||
}: {
|
}: {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
@@ -117,8 +120,13 @@ export function PreviewSheet({
|
|||||||
description: string
|
description: string
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
footer?: 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)
|
const [guard, setGuard] = useState(false)
|
||||||
|
|
||||||
function requestChange(next: boolean) {
|
function requestChange(next: boolean) {
|
||||||
@@ -136,7 +144,13 @@ export function PreviewSheet({
|
|||||||
<SheetContent
|
<SheetContent
|
||||||
side="right"
|
side="right"
|
||||||
showCloseButton={false}
|
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">
|
<SheetHeader className="shrink-0 gap-0 p-0">
|
||||||
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
|
<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 { Button } from '@authportal/ui/components/button'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
||||||
import { UserPlusIcon } from 'lucide-react'
|
import { UserPlusIcon } from 'lucide-react'
|
||||||
import { AccessDialog } from '../access-dialog'
|
import { AccessSheet } from '../access-sheet'
|
||||||
import { diffAccess, type AccessValue } from '../access-model'
|
import { diffAccess, type AccessValue } from '../access-model'
|
||||||
import { CodeBlock, KvList, PageHead, PreviewSheet, TypedConfirm } from '../bits'
|
import { CodeBlock, KvList, PageHead, PreviewSheet, TypedConfirm } from '../bits'
|
||||||
import { kpi } from '../kpi'
|
import { kpi } from '../kpi'
|
||||||
@@ -165,7 +165,7 @@ export function UsersPage() {
|
|||||||
<CreateUserSheet open={createOpen} onOpenChange={setCreateOpen} pending={false} error={null} onSubmit={onCreate} />
|
<CreateUserSheet open={createOpen} onOpenChange={setCreateOpen} pending={false} error={null} onSubmit={onCreate} />
|
||||||
|
|
||||||
{accessFor ? (
|
{accessFor ? (
|
||||||
<AccessDialog
|
<AccessSheet
|
||||||
key={accessFor.id}
|
key={accessFor.id}
|
||||||
user={accessFor}
|
user={accessFor}
|
||||||
onClose={() => setAccessFor(null)}
|
onClose={() => setAccessFor(null)}
|
||||||
|
|||||||
Reference in new issue
Block a user