Права, вход и passkeys, сессии, активность и опасная зона. Каталог пользователей вынесен в общее состояние макета, поэтому правки видны и в списке, и в профиле. Переходы: меню строки «Профиль», «Открыть профиль» в панели прав, хлебные крошки в шапке. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
265 lines
12 KiB
TypeScript
265 lines
12 KiB
TypeScript
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> },
|
||
]
|