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'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
Reference in new issue
Block a user