feat(design-preview): матрица доступа «пользователи × приложения» (solution-users-3)

Переключатели в ячейках, счётчик выданных доступов в заголовке колонки, заблокированные ячейки
администраторов, пометки «Не сохранено». Исправлена вёрстка рейки вкладок профиля.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-18 02:39:06 +07:00
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> },
]