feat(design-preview): матрица доступа «пользователи × приложения» (solution-users-3)
Переключатели в ячейках, счётчик выданных доступов в заголовке колонки, заблокированные ячейки администраторов, пометки «Не сохранено». Исправлена вёрстка рейки вкладок профиля. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
007ece4575
commit
c8351424f4
3 files changed
+97
-29
No files matched your search
@@ -0,0 +1,82 @@
|
||||
import { useMemo } from 'react'
|
||||
import { LockIcon } from 'lucide-react'
|
||||
import { APPS, type AdminUser, type AppId } from '@authportal/shared'
|
||||
import { Switch } from '@authportal/ui/components/switch'
|
||||
import { PendingChip, SignalAlert } from './bits'
|
||||
import { APP_SHORT } from './mock'
|
||||
import type { RowMark } from './preview-store'
|
||||
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from './section-grid'
|
||||
|
||||
/**
|
||||
* Матрица доступа (solution-users-3): пользователи в строках, приложения в колонках,
|
||||
* переключатели в ячейках. Администраторам доступ выдан ко всем приложениям — их ячейки заблокированы.
|
||||
*/
|
||||
export function AccessMatrix({
|
||||
users,
|
||||
marks,
|
||||
onToggle,
|
||||
}: {
|
||||
users: AdminUser[]
|
||||
marks: Record<string, RowMark>
|
||||
onToggle: (user: AdminUser, appId: AppId, on: boolean) => void
|
||||
}) {
|
||||
const columns = useMemo<GridColumn<AdminUser>[]>(() => {
|
||||
const granted = (id: AppId) => users.filter((u) => u.is_admin || u.apps.includes(id)).length
|
||||
return [
|
||||
{
|
||||
id: 'user',
|
||||
title: 'Пользователь',
|
||||
size: 260,
|
||||
sortValue: (u) => u.email,
|
||||
cell: (u) => (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<TwoLineCell dot={u.disabled ? 'mu' : 'ok'} title={u.email} sub={u.is_admin ? 'Админ · все приложения' : `${u.apps.length} из ${APPS.length}`} />
|
||||
{marks[u.id] ? <PendingChip mark={marks[u.id]!} /> : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
...APPS.map<GridColumn<AdminUser>>((a) => ({
|
||||
id: a.id,
|
||||
title: `${APP_SHORT[a.id]} · ${granted(a.id)}`,
|
||||
size: 128,
|
||||
cell: (u) =>
|
||||
u.is_admin ? (
|
||||
<span className="text-muted-foreground flex items-center gap-1.5 text-xs" title="Администратор: доступ ко всем приложениям">
|
||||
<Switch size="sm" checked disabled aria-label={`${u.email}: ${a.title} (заблокировано)`} />
|
||||
<LockIcon className="size-3" aria-hidden />
|
||||
</span>
|
||||
) : (
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={u.apps.includes(a.id)}
|
||||
onCheckedChange={(on) => onToggle(u, a.id, on)}
|
||||
aria-label={`${u.email}: ${a.title}`}
|
||||
/>
|
||||
),
|
||||
})),
|
||||
]
|
||||
}, [users, marks, onToggle])
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<SignalAlert tone="in" title="Администраторы видят все приложения">
|
||||
Их ячейки заблокированы. Тонкую настройку разделов откройте в правах пользователя.
|
||||
</SignalAlert>
|
||||
<PreviewSectionGrid
|
||||
title="Матрица доступа"
|
||||
description="В заголовке колонки — сколько пользователей имеют доступ к приложению"
|
||||
rows={users}
|
||||
columns={columns}
|
||||
getRowId={(u) => u.id}
|
||||
rowStatus={(u) => marks[u.id]}
|
||||
searchText={(u) => u.email}
|
||||
searchPlaceholder="Email"
|
||||
views={[
|
||||
{ label: 'Все', match: () => true },
|
||||
{ label: 'Пользователи', match: (u) => !u.is_admin },
|
||||
{ label: 'Админы', match: (u) => u.is_admin },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -120,8 +120,8 @@ export function UserProfilePage() {
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
<Tabs defaultValue="access" orientation="vertical" className="min-w-0 flex-col gap-4 md:flex-row md:items-start">
|
||||
<TabsList className="flex h-auto w-full flex-row flex-wrap justify-start md:w-56 md:shrink-0 md:flex-col md:items-stretch">
|
||||
<Tabs defaultValue="access" className="min-w-0 flex-col gap-4 md:flex-row md:items-start">
|
||||
<TabsList className="h-auto! w-full flex-row flex-wrap justify-start md:w-56 md:shrink-0 md:flex-col md:items-stretch">
|
||||
<TabsTrigger value="access" className="justify-start">
|
||||
<ShieldCheckIcon aria-hidden />
|
||||
Права доступа
|
||||
|
||||
@@ -12,9 +12,8 @@ import { AccessSheet } from '../access-sheet'
|
||||
import { diffAccess, type AccessValue } from '../access-model'
|
||||
import { CodeBlock, KvList, PageHead, PreviewSheet, TypedConfirm } from '../bits'
|
||||
import { kpi } from '../kpi'
|
||||
import { ALL_APP_IDS, APP_SHORT, USER_JOURNAL, type Tone } from '../mock'
|
||||
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid'
|
||||
import { StatusBadge } from '../bits'
|
||||
import { ALL_APP_IDS, APP_SHORT, USER_JOURNAL } from '../mock'
|
||||
import { AccessMatrix } from '../access-matrix'
|
||||
import { UsersGrid, type UsersGridHandlers } from '../users-grid'
|
||||
import { usePreviewStore } from '../preview-store'
|
||||
import { usePatchUsers } from '../user-actions'
|
||||
@@ -96,13 +95,6 @@ export function UsersPage() {
|
||||
]),
|
||||
}
|
||||
|
||||
const matrix: MatrixRow[] = ALL_APP_IDS.map((id) => {
|
||||
const mine = users.filter((u) => u.apps.includes(id))
|
||||
const level = (action: string) => mine.filter((u) => u.is_admin || u.permissions.some((k) => k.startsWith(`${id}:`) && k.endsWith(`:${action}`))).length
|
||||
const admins = mine.filter((u) => u.is_admin || u.permissions.some((k) => k.startsWith(`${id}:`) && k.endsWith(':admin'))).length
|
||||
return { id, name: APP_SHORT[id], total: mine.length, read: level('read'), write: level('write'), admin: admins, tone: admins < 2 ? 'wn' : 'ok' }
|
||||
})
|
||||
|
||||
const admins = users.filter((u) => u.is_admin).length
|
||||
const noPasskey = users.filter((u) => !u.disabled && u.passkey_count === 0).length
|
||||
const disabled = users.filter((u) => u.disabled).length
|
||||
@@ -137,12 +129,17 @@ export function UsersPage() {
|
||||
<UsersGrid users={users} handlers={handlers} marks={marks} />
|
||||
</TabsContent>
|
||||
<TabsContent value="matrix" className="min-w-0">
|
||||
<PreviewSectionGrid
|
||||
title="Матрица доступа"
|
||||
description="Сколько пользователей имеет доступ к каждому приложению и с каким уровнем"
|
||||
rows={matrix}
|
||||
columns={matrixColumns}
|
||||
getRowId={(r) => r.id}
|
||||
<AccessMatrix
|
||||
users={users}
|
||||
marks={marks}
|
||||
onToggle={(u, appId, on) =>
|
||||
patch(
|
||||
[u.id],
|
||||
(x) => ({ ...x, apps: on ? [...x.apps, appId] : x.apps.filter((id) => id !== appId) }),
|
||||
`${on ? 'Доступ выдан' : 'Доступ снят'}: ${u.email}`,
|
||||
() => [`${on ? '+' : '−'} приложение ${APP_SHORT[appId]}`],
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
@@ -251,14 +248,3 @@ export function UsersPage() {
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type MatrixRow = { id: string; name: string; total: number; read: number; write: number; admin: number; tone: Tone }
|
||||
|
||||
const matrixColumns: GridColumn<MatrixRow>[] = [
|
||||
{ id: 'app', title: 'Приложение', size: 240, sortValue: (r) => r.name, cell: (r) => <TwoLineCell dot={r.tone} title={r.name} sub={r.id} /> },
|
||||
{ id: 'state', title: 'Статус', size: 160, cell: (r) => <StatusBadge tone={r.tone}>{r.tone === 'wn' ? 'Мало админов' : 'Выдан'}</StatusBadge> },
|
||||
{ id: 'total', title: 'Пользователей', size: 140, sortValue: (r) => r.total, cell: (r) => <span className="text-sm tabular-nums">{r.total}</span> },
|
||||
{ id: 'read', title: 'Чтение', size: 100, cell: (r) => <span className="text-sm tabular-nums">{r.read}</span> },
|
||||
{ id: 'write', title: 'Запись', size: 100, cell: (r) => <span className="text-sm tabular-nums">{r.write}</span> },
|
||||
{ id: 'admin', title: 'Админ', size: 100, cell: (r) => <span className="text-sm tabular-nums">{r.admin}</span> },
|
||||
]
|
||||
Reference in new issue
Block a user