feat(design-preview): матрица доступа «пользователи × приложения» (solution-users-3)
Переключатели в ячейках, счётчик выданных доступов в заголовке колонки, заблокированные ячейки администраторов, пометки «Не сохранено». Исправлена вёрстка рейки вкладок профиля. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
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>
|
</FramePanel>
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|
||||||
<Tabs defaultValue="access" orientation="vertical" className="min-w-0 flex-col gap-4 md:flex-row md:items-start">
|
<Tabs defaultValue="access" 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">
|
<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">
|
<TabsTrigger value="access" className="justify-start">
|
||||||
<ShieldCheckIcon aria-hidden />
|
<ShieldCheckIcon aria-hidden />
|
||||||
Права доступа
|
Права доступа
|
||||||
|
|||||||
@@ -12,9 +12,8 @@ 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'
|
||||||
import { ALL_APP_IDS, APP_SHORT, USER_JOURNAL, type Tone } from '../mock'
|
import { ALL_APP_IDS, APP_SHORT, USER_JOURNAL } from '../mock'
|
||||||
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid'
|
import { AccessMatrix } from '../access-matrix'
|
||||||
import { StatusBadge } from '../bits'
|
|
||||||
import { UsersGrid, type UsersGridHandlers } from '../users-grid'
|
import { UsersGrid, type UsersGridHandlers } from '../users-grid'
|
||||||
import { usePreviewStore } from '../preview-store'
|
import { usePreviewStore } from '../preview-store'
|
||||||
import { usePatchUsers } from '../user-actions'
|
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 admins = users.filter((u) => u.is_admin).length
|
||||||
const noPasskey = users.filter((u) => !u.disabled && u.passkey_count === 0).length
|
const noPasskey = users.filter((u) => !u.disabled && u.passkey_count === 0).length
|
||||||
const disabled = users.filter((u) => u.disabled).length
|
const disabled = users.filter((u) => u.disabled).length
|
||||||
@@ -137,12 +129,17 @@ export function UsersPage() {
|
|||||||
<UsersGrid users={users} handlers={handlers} marks={marks} />
|
<UsersGrid users={users} handlers={handlers} marks={marks} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="matrix" className="min-w-0">
|
<TabsContent value="matrix" className="min-w-0">
|
||||||
<PreviewSectionGrid
|
<AccessMatrix
|
||||||
title="Матрица доступа"
|
users={users}
|
||||||
description="Сколько пользователей имеет доступ к каждому приложению и с каким уровнем"
|
marks={marks}
|
||||||
rows={matrix}
|
onToggle={(u, appId, on) =>
|
||||||
columns={matrixColumns}
|
patch(
|
||||||
getRowId={(r) => r.id}
|
[u.id],
|
||||||
|
(x) => ({ ...x, apps: on ? [...x.apps, appId] : x.apps.filter((id) => id !== appId) }),
|
||||||
|
`${on ? 'Доступ выдан' : 'Доступ снят'}: ${u.email}`,
|
||||||
|
() => [`${on ? '+' : '−'} приложение ${APP_SHORT[appId]}`],
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</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