Files
auth-portal/apps/web/src/design-preview/pages/users-page.tsx
T
DenozordecandClaude Sonnet 5.5 007ece4575 feat(design-preview): страница пользователя с рейкой вкладок (solution-users-2)
Права, вход и passkeys, сессии, активность и опасная зона. Каталог пользователей вынесен в общее
состояние макета, поэтому правки видны и в списке, и в профиле. Переходы: меню строки «Профиль»,
«Открыть профиль» в панели прав, хлебные крошки в шапке.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 02:35:57 +07:00

265 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { UsersIcon, ShieldCheckIcon, FingerprintIcon, UserXIcon } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { toast } from 'sonner'
import type { AdminUser, CreateUserRequest } from '@authportal/shared'
import { CreateUserSheet } from '@/components/reui-kit/create-user-sheet'
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 { 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 { UsersGrid, type UsersGridHandlers } from '../users-grid'
import { usePreviewStore } from '../preview-store'
import { usePatchUsers } from '../user-actions'
export function UsersPage() {
const { addChange, marksFor, users, setUsers } = usePreviewStore()
const marks = marksFor('users')
const usersRef = useRef(users)
useEffect(() => {
usersRef.current = users
}, [users])
const patch = usePatchUsers()
const navigate = useNavigate()
const [createOpen, setCreateOpen] = useState(false)
const [journalFor, setJournalFor] = useState<AdminUser | null>(null)
const [accessFor, setAccessFor] = useState<AdminUser | null>(null)
const [deleteFor, setDeleteFor] = useState<AdminUser | null>(null)
const [resetFor, setResetFor] = useState<AdminUser | null>(null)
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[] | null>(null)
const onDeactivate = useCallback(
(user: AdminUser) =>
patch([user.id], (u) => ({ ...u, disabled: !u.disabled }), `${user.disabled ? 'Включён' : 'Отключён'} ${user.email}`, (b, a) => [
`− disabled: ${String(b.disabled)}`,
`+ disabled: ${String(a.disabled)}`,
]),
[patch],
)
const onCreate = useCallback(
(values: CreateUserRequest) => {
const created: AdminUser = {
id: `u-new-${Date.now()}`,
email: values.email,
name: values.name,
is_admin: values.is_admin,
disabled: false,
apps: [],
permissions: [],
last_login_at: null,
last_login_ip: null,
passkey_count: 0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}
setUsers((prev) => [...prev, created])
addChange({
kind: 'add',
title: `Создан ${created.email}`,
lines: [`+ email: ${created.email}`, `+ name: ${created.name}`, `+ is_admin: ${String(created.is_admin)}`],
resource: 'users',
marks: { [created.id]: 'new' },
revert: () => setUsers((prev) => prev.filter((u) => u.id !== created.id)),
})
setCreateOpen(false)
setAccessFor(created)
},
[addChange, setUsers],
)
const onDelete = useCallback((user: AdminUser) => setDeleteFor(user), [])
const onResetPasskeys = useCallback((user: AdminUser) => setResetFor(user), [])
const onOpenAudit = useCallback((user: AdminUser) => setJournalFor(user), [])
const onOpenAccess = useCallback((user: AdminUser) => setAccessFor(user), [])
const handlers: UsersGridHandlers = {
onOpenAccess: onOpenAccess,
onOpenJournal: onOpenAudit,
onToggleDisabled: onDeactivate,
onResetPasskeys,
onDelete,
onOpenProfile: (u) => void navigate({ to: '/design-preview/users/$userId', params: { userId: u.id } }),
onBulkDelete: (ids) => setBulkDeleteIds(ids),
onBulkDisabled: (ids, disabled) =>
patch(ids, (u) => ({ ...u, disabled }), `${disabled ? 'Отключено' : 'Включено'} пользователей: ${ids.length}`, () => [
`− disabled: ${String(!disabled)}`,
`+ disabled: ${String(disabled)}`,
]),
}
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
return (
<>
<PageHead
title="Пользователи"
sub="Каталог учётных записей · роли, приложения, passkeys · изменения прав записываются в журнал"
actions={
<Button onClick={() => setCreateOpen(true)}>
<UserPlusIcon aria-hidden />
Создать пользователя
</Button>
}
/>
<KpiStatGrid
aria-label="Показатели"
items={[
kpi('total', 'Пользователей', String(users.length), { icon: UsersIcon, badge: 'в каталоге', sub: 'учётных записей' }),
kpi('admins', 'Администраторов', String(admins), { icon: ShieldCheckIcon, tone: 'wn', badge: 'is_admin', sub: 'полный доступ' }),
kpi('nopk', 'Без passkey', String(noPasskey), { icon: FingerprintIcon, tone: noPasskey > 0 ? 'wn' : 'ok', badge: 'только пароль', sub: 'нет резервного входа' }),
kpi('off', 'Отключённых', String(disabled), { icon: UserXIcon, badge: 'вход закрыт', sub: 'не удаляются' }),
]}
/>
<Tabs defaultValue="users" className="min-w-0">
<TabsList>
<TabsTrigger value="users">Участники · {users.length}</TabsTrigger>
<TabsTrigger value="matrix">Доступ по приложениям · {ALL_APP_IDS.length}</TabsTrigger>
</TabsList>
<TabsContent value="users" className="min-w-0">
<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}
/>
</TabsContent>
</Tabs>
<CreateUserSheet open={createOpen} onOpenChange={setCreateOpen} pending={false} error={null} onSubmit={onCreate} />
{accessFor ? (
<AccessSheet
key={accessFor.id}
user={accessFor}
onClose={() => setAccessFor(null)}
onOpenProfile={() => {
void navigate({ to: '/design-preview/users/$userId', params: { userId: accessFor.id } })
}}
onSave={(value: AccessValue) => {
const target = accessFor
patch(
[target.id],
(u) => ({ ...u, apps: value.apps, permissions: value.permissions }),
`Права обновлены: ${target.email}`,
() => diffAccess({ apps: target.apps, permissions: target.permissions }, value),
)
}}
/>
) : null}
<PreviewSheet
open={journalFor !== null}
onOpenChange={(o) => !o && setJournalFor(null)}
title="Журнал пользователя"
description="Последние события пользователя из журнала портала"
footer={<Button variant="outline" onClick={() => setJournalFor(null)}>Закрыть</Button>}
>
<KvList
items={[
['Email', journalFor?.email ?? ''],
['Последний вход', `${journalFor?.last_login_ip ?? '—'}`],
['Passkeys', String(journalFor?.passkey_count ?? 0)],
]}
/>
<CodeBlock label={`${journalFor?.email ?? ''} · 24 часа`}>{USER_JOURNAL}</CodeBlock>
</PreviewSheet>
<TypedConfirm
open={deleteFor !== null}
onOpenChange={(o) => !o && setDeleteFor(null)}
title="Удалить пользователя?"
description={`Учётная запись ${deleteFor?.email ?? ''} и её права будут удалены. Действие попадёт в панель «Не сохранено» и в журнал.`}
expected={deleteFor?.email ?? ''}
confirmLabel="Удалить"
onConfirm={() => {
const target = deleteFor
if (!target) return
addChange({
kind: 'del',
title: `Удалён ${target.email}`,
lines: [`− email: ${target.email}`, `− apps: ${target.apps.length}`],
resource: 'users',
marks: { [target.id]: 'deleted' },
commit: () => setUsers((prev) => prev.filter((u) => u.id !== target.id)),
})
setDeleteFor(null)
}}
/>
<TypedConfirm
open={bulkDeleteIds !== null}
onOpenChange={(o) => !o && setBulkDeleteIds(null)}
title="Удалить выбранных пользователей?"
description={`Будут удалены учётные записи: ${bulkDeleteIds?.length ?? 0}. Действие попадёт в панель «Не сохранено» и в журнал.`}
expected="удалить"
confirmLabel="Удалить"
onConfirm={() => {
const ids = bulkDeleteIds ?? []
const targets = usersRef.current.filter((u) => ids.includes(u.id))
addChange({
kind: 'del',
title: `Удалено пользователей: ${targets.length}`,
lines: targets.map((t) => `− ${t.email}`),
resource: 'users',
marks: Object.fromEntries(targets.map((t) => [t.id, 'deleted' as const])),
commit: () => setUsers((prev) => prev.filter((u) => !ids.includes(u.id))),
})
setBulkDeleteIds(null)
}}
/>
<TypedConfirm
open={resetFor !== null}
onOpenChange={(o) => !o && setResetFor(null)}
title="Сбросить passkeys пользователя?"
description={`Все ключи ${resetFor?.email ?? ''} будут удалены. Войти можно будет только по паролю, пока не добавлен новый ключ. Действие попадёт в журнал.`}
expected="сбросить"
confirmLabel="Сбросить"
onConfirm={() => {
const target = resetFor
if (!target) return
patch([target.id], (u) => ({ ...u, passkey_count: 0 }), `Сброс passkeys: ${target.email}`, (b) => [
`− passkeys: ${b.passkey_count}`,
'+ passkeys: 0',
])
toast.warning('Passkeys сброшены в черновике', { description: 'Сохраните изменения, чтобы применить.' })
setResetFor(null)
}}
/>
</>
)
}
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> },
]