feat(design-preview): страница пользователя с рейкой вкладок (solution-users-2)
Права, вход и passkeys, сессии, активность и опасная зона. Каталог пользователей вынесен в общее состояние макета, поэтому правки видны и в списке, и в профиле. Переходы: меню строки «Профиль», «Открыть профиль» в панели прав, хлебные крошки в шапке. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
f0a257220f
commit
007ece4575
11 files changed
+444
-48
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { ArrowUpRightIcon } from 'lucide-react'
|
||||
import type { AdminUser } from '@authportal/shared'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { AccessEditor } from './access-editor'
|
||||
@@ -10,10 +11,12 @@ export function AccessSheet({
|
||||
user,
|
||||
onClose,
|
||||
onSave,
|
||||
onOpenProfile,
|
||||
}: {
|
||||
user: AdminUser
|
||||
onClose: () => void
|
||||
onSave: (value: AccessValue) => void
|
||||
onOpenProfile?: () => void
|
||||
}) {
|
||||
const [value, setValue] = useState<AccessValue>({ apps: user.apps, permissions: user.permissions })
|
||||
const changes = useMemo(() => diffAccess({ apps: user.apps, permissions: user.permissions }, value).length, [user, value])
|
||||
@@ -28,6 +31,12 @@ export function AccessSheet({
|
||||
description="Доступ к приложениям и разделам · иерархия: админ ⊃ запись ⊃ чтение"
|
||||
footer={
|
||||
<>
|
||||
{onOpenProfile ? (
|
||||
<Button variant="ghost" onClick={onOpenProfile}>
|
||||
Открыть профиль
|
||||
<ArrowUpRightIcon aria-hidden />
|
||||
</Button>
|
||||
) : null}
|
||||
<span className="text-muted-foreground mr-auto self-center text-xs">
|
||||
{changes > 0 ? `Изменений: ${changes}` : 'Без изменений'}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from '@tanstack/react-router'
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
FingerprintIcon,
|
||||
HistoryIcon,
|
||||
MonitorSmartphoneIcon,
|
||||
ShieldCheckIcon,
|
||||
TriangleAlertIcon,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { AdminUser } from '@authportal/shared'
|
||||
import { AuditLogTimeline } from '@/components/reui-kit/audit-log-timeline'
|
||||
import { Frame, FrameDescription, FrameFooter, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
|
||||
import { Avatar, AvatarFallback } from '@authportal/ui/components/avatar'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
||||
import { AccessEditor } from '../access-editor'
|
||||
import { diffAccess, type AccessValue } from '../access-model'
|
||||
import { KvFrame, PendingChip, StatusBadge, TypedConfirm } from '../bits'
|
||||
import { MOCK_AUDIT, MOCK_SESSIONS, USER_META, type SessionRow } from '../mock'
|
||||
import { usePreviewStore } from '../preview-store'
|
||||
import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid'
|
||||
import { usePatchUsers } from '../user-actions'
|
||||
|
||||
function initials(u: AdminUser) {
|
||||
const base = (u.name || u.email).trim()
|
||||
const parts = base.split(/\s+/)
|
||||
return (parts.length > 1 ? `${parts[0]![0]}${parts[1]![0]}` : base.slice(0, 2)).toUpperCase()
|
||||
}
|
||||
|
||||
function statusOf(u: AdminUser) {
|
||||
if (u.disabled) return { tone: 'mu' as const, label: 'Отключён' }
|
||||
if (u.passkey_count === 0) return { tone: 'wn' as const, label: 'Без passkey' }
|
||||
return { tone: 'ok' as const, label: 'Активен' }
|
||||
}
|
||||
|
||||
/** Страница пользователя (solution-users-2): рейка вкладок — права, вход, сессии, активность, опасная зона. */
|
||||
export function UserProfilePage() {
|
||||
const { userId } = useParams({ from: '/design-preview/_shell/users/$userId' })
|
||||
const { users, setUsers, addChange, marksFor } = usePreviewStore()
|
||||
const navigate = useNavigate()
|
||||
const patch = usePatchUsers()
|
||||
const user = users.find((u) => u.id === userId)
|
||||
const mark = marksFor('users')[userId]
|
||||
|
||||
const [draft, setDraft] = useState<{ id: string; value: AccessValue } | null>(null)
|
||||
const [sessions, setSessions] = useState<SessionRow[]>(MOCK_SESSIONS)
|
||||
const [resetOpen, setResetOpen] = useState(false)
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
|
||||
const sessionRows = useMemo(() => sessions.filter((s) => s.user === user?.email), [sessions, user])
|
||||
const events = useMemo(
|
||||
() => MOCK_AUDIT.filter((e) => e.actor_email === user?.email || e.summary.includes(user?.email ?? '\0')),
|
||||
[user],
|
||||
)
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<Frame spacing="sm" className="w-full">
|
||||
<FramePanel className="flex flex-col items-start gap-3 py-8">
|
||||
<p className="font-medium">Пользователь не найден</p>
|
||||
<p className="text-muted-foreground text-sm">Возможно, учётная запись уже удалена.</p>
|
||||
<Button variant="outline" render={<Link to="/design-preview/users" />} nativeButton={false}>
|
||||
<ArrowLeftIcon aria-hidden />К списку пользователей
|
||||
</Button>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
const status = statusOf(user)
|
||||
const base: AccessValue = { apps: user.apps, permissions: user.permissions }
|
||||
const value = draft?.id === user.id ? draft.value : base
|
||||
const changes = diffAccess(base, value).length
|
||||
|
||||
const sessionColumns: GridColumn<SessionRow>[] = [
|
||||
{ id: 'ip', title: 'IP · клиент', size: 260, cell: (r) => <TwoLineCell dot={r.tone} title={r.ipClient} sub={r.note || undefined} /> },
|
||||
{ id: 'created', title: 'Создана', size: 140, cell: (r) => <span className="text-sm whitespace-nowrap">{r.created}</span> },
|
||||
{ id: 'exp', title: 'Истекает', size: 120, cell: (r) => <span className="text-sm whitespace-nowrap">{r.expires}</span> },
|
||||
{
|
||||
id: 'act',
|
||||
title: '',
|
||||
size: 130,
|
||||
cell: (r) => (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSessions((s) => s.filter((x) => x.id !== r.id))
|
||||
toast.success('Сессия завершена (макет)')
|
||||
}}
|
||||
>
|
||||
Завершить
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<Frame spacing="sm" className="w-full">
|
||||
<FramePanel className="flex flex-wrap items-center gap-x-4 gap-y-3">
|
||||
<Avatar className="size-12 rounded-xl">
|
||||
<AvatarFallback className="rounded-xl text-sm">{initials(user)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex min-w-0 flex-1 basis-60 flex-col gap-1">
|
||||
<h1 className="truncate text-xl font-semibold tracking-tight">{user.email}</h1>
|
||||
<p className="text-muted-foreground truncate font-mono text-[11px]">{USER_META[user.id]?.sub ?? user.name}</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<StatusBadge tone={status.tone}>{status.label}</StatusBadge>
|
||||
<StatusBadge tone={user.is_admin ? 'wn' : 'in'}>{user.is_admin ? 'Админ' : 'Пользователь'}</StatusBadge>
|
||||
{mark ? <PendingChip mark={mark} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" render={<Link to="/design-preview/users" />} nativeButton={false}>
|
||||
<ArrowLeftIcon aria-hidden />К списку
|
||||
</Button>
|
||||
</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">
|
||||
<TabsTrigger value="access" className="justify-start">
|
||||
<ShieldCheckIcon aria-hidden />
|
||||
Права доступа
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="auth" className="justify-start">
|
||||
<FingerprintIcon aria-hidden />
|
||||
Вход и passkeys
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sessions" className="justify-start">
|
||||
<MonitorSmartphoneIcon aria-hidden />
|
||||
Сессии · {sessionRows.length}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="activity" className="justify-start">
|
||||
<HistoryIcon aria-hidden />
|
||||
Активность
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="danger" className="justify-start">
|
||||
<TriangleAlertIcon aria-hidden />
|
||||
Опасная зона
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<TabsContent value="access" className="min-w-0">
|
||||
<Frame spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Права доступа</FrameTitle>
|
||||
<FrameDescription>Приложения и разделы · иерархия: админ ⊃ запись ⊃ чтение</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<AccessEditor user={user} value={value} onChange={(next) => setDraft({ id: user.id, value: next })} />
|
||||
</FramePanel>
|
||||
<FrameFooter className="flex-wrap justify-end gap-2">
|
||||
<span className="text-muted-foreground mr-auto text-xs">{changes > 0 ? `Изменений: ${changes}` : 'Без изменений'}</span>
|
||||
<Button variant="outline" disabled={changes === 0} onClick={() => setDraft(null)}>
|
||||
Сбросить
|
||||
</Button>
|
||||
<Button
|
||||
disabled={changes === 0}
|
||||
onClick={() => {
|
||||
patch(
|
||||
[user.id],
|
||||
(u) => ({ ...u, apps: value.apps, permissions: value.permissions }),
|
||||
`Права обновлены: ${user.email}`,
|
||||
() => diffAccess(base, value),
|
||||
)
|
||||
setDraft(null)
|
||||
}}
|
||||
>
|
||||
Сохранить права
|
||||
</Button>
|
||||
</FrameFooter>
|
||||
</Frame>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="auth" className="flex min-w-0 flex-col gap-4">
|
||||
<KvFrame
|
||||
title="Вход"
|
||||
description="Способы входа учётной записи"
|
||||
items={[
|
||||
{ label: 'Пароль', value: 'Задан · запасной вход' },
|
||||
{ label: 'Passkeys', value: user.passkey_count === 0 ? 'нет' : `${user.passkey_count} шт.` },
|
||||
{ label: 'Последний вход', value: USER_META[user.id]?.activity ?? '—' },
|
||||
{ label: 'Статус', value: status.label },
|
||||
]}
|
||||
/>
|
||||
<Frame spacing="sm" className="w-full">
|
||||
<FramePanel className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="text-sm font-medium">Сбросить passkeys…</span>
|
||||
<span className="text-muted-foreground text-xs">Войти можно будет только по паролю, пока не добавлен новый ключ.</span>
|
||||
</div>
|
||||
<Button variant="outline" disabled={user.passkey_count === 0} onClick={() => setResetOpen(true)}>
|
||||
Сбросить
|
||||
</Button>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sessions" className="min-w-0">
|
||||
<PreviewSectionGrid
|
||||
title="Активные сессии"
|
||||
description="Завершение сессии выкидывает пользователя из портала и приложений"
|
||||
rows={sessionRows}
|
||||
columns={sessionColumns}
|
||||
getRowId={(r) => r.id}
|
||||
emptyMessage="Нет активных сессий."
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="activity" className="min-w-0">
|
||||
<Frame spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Активность</FrameTitle>
|
||||
<FrameDescription>События пользователя из журнала портала</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
{events.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">Событий нет.</p>
|
||||
) : (
|
||||
<AuditLogTimeline entries={events} totalCount={events.length} compact lockedUserId={user.id} />
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="danger" className="min-w-0">
|
||||
<Frame spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Опасная зона</FrameTitle>
|
||||
<FrameDescription>Действия попадают в «Не сохранено» и в журнал</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="text-sm font-medium">{user.disabled ? 'Включить учётную запись' : 'Отключить учётную запись'}</span>
|
||||
<span className="text-muted-foreground text-xs">Вход закрывается, учётная запись и права сохраняются.</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
patch([user.id], (u) => ({ ...u, disabled: !u.disabled }), `${user.disabled ? 'Включён' : 'Отключён'} ${user.email}`, (b, a) => [
|
||||
`− disabled: ${String(b.disabled)}`,
|
||||
`+ disabled: ${String(a.disabled)}`,
|
||||
])
|
||||
}
|
||||
>
|
||||
{user.disabled ? 'Включить' : 'Отключить'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="text-sm font-medium">Удалить пользователя…</span>
|
||||
<span className="text-muted-foreground text-xs">Учётная запись и все права будут удалены безвозвратно.</span>
|
||||
</div>
|
||||
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
|
||||
Удалить…
|
||||
</Button>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
<TypedConfirm
|
||||
open={resetOpen}
|
||||
onOpenChange={setResetOpen}
|
||||
title="Сбросить passkeys пользователя?"
|
||||
description={`Все ключи ${user.email} будут удалены. Войти можно будет только по паролю. Действие попадёт в журнал.`}
|
||||
expected="сбросить"
|
||||
confirmLabel="Сбросить"
|
||||
onConfirm={() => {
|
||||
patch([user.id], (u) => ({ ...u, passkey_count: 0 }), `Сброс passkeys: ${user.email}`, (b) => [`− passkeys: ${b.passkey_count}`, '+ passkeys: 0'])
|
||||
setResetOpen(false)
|
||||
}}
|
||||
/>
|
||||
<TypedConfirm
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
title="Удалить пользователя?"
|
||||
description={`Учётная запись ${user.email} и её права будут удалены. Действие попадёт в панель «Не сохранено» и в журнал.`}
|
||||
expected={user.email}
|
||||
confirmLabel="Удалить"
|
||||
onConfirm={() => {
|
||||
addChange({
|
||||
kind: 'del',
|
||||
title: `Удалён ${user.email}`,
|
||||
lines: [`− email: ${user.email}`, `− apps: ${user.apps.length}`],
|
||||
resource: 'users',
|
||||
marks: { [user.id]: 'deleted' },
|
||||
commit: () => setUsers((prev) => prev.filter((u) => u.id !== user.id)),
|
||||
})
|
||||
setDeleteOpen(false)
|
||||
void navigate({ to: '/design-preview/users' })
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
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'
|
||||
@@ -11,20 +12,22 @@ 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, MOCK_USERS, USER_JOURNAL, type Tone } from '../mock'
|
||||
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 } = usePreviewStore()
|
||||
const { addChange, marksFor, users, setUsers } = usePreviewStore()
|
||||
const marks = marksFor('users')
|
||||
const [users, setUsers] = useState<AdminUser[]>(MOCK_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)
|
||||
@@ -33,25 +36,6 @@ export function UsersPage() {
|
||||
const [resetFor, setResetFor] = useState<AdminUser | null>(null)
|
||||
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[] | null>(null)
|
||||
|
||||
/** Меняет поля у выбранных пользователей и кладёт изменение в «Не сохранено». */
|
||||
const patch = useCallback(
|
||||
(ids: string[], apply: (u: AdminUser) => AdminUser, title: string, lineFor: (before: AdminUser, after: AdminUser) => string[]) => {
|
||||
const before = usersRef.current.filter((u) => ids.includes(u.id))
|
||||
const after = before.map(apply)
|
||||
const lines = before.flatMap((b, i) => [` ${b.email}`, ...lineFor(b, after[i]!)])
|
||||
setUsers((prev) => prev.map((u) => after.find((a) => a.id === u.id) ?? u))
|
||||
addChange({
|
||||
kind: 'chg',
|
||||
title,
|
||||
lines,
|
||||
resource: 'users',
|
||||
marks: Object.fromEntries(before.map((b) => [b.id, 'dirty' as const])),
|
||||
revert: () => setUsers((prev) => prev.map((u) => before.find((b) => b.id === u.id) ?? u)),
|
||||
})
|
||||
},
|
||||
[addChange],
|
||||
)
|
||||
|
||||
const onDeactivate = useCallback(
|
||||
(user: AdminUser) =>
|
||||
patch([user.id], (u) => ({ ...u, disabled: !u.disabled }), `${user.disabled ? 'Включён' : 'Отключён'} ${user.email}`, (b, a) => [
|
||||
@@ -89,7 +73,7 @@ export function UsersPage() {
|
||||
setCreateOpen(false)
|
||||
setAccessFor(created)
|
||||
},
|
||||
[addChange],
|
||||
[addChange, setUsers],
|
||||
)
|
||||
|
||||
const onDelete = useCallback((user: AdminUser) => setDeleteFor(user), [])
|
||||
@@ -103,6 +87,7 @@ export function UsersPage() {
|
||||
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}`, () => [
|
||||
@@ -169,6 +154,9 @@ export function UsersPage() {
|
||||
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(
|
||||
|
||||
@@ -31,8 +31,10 @@ import { Avatar, AvatarFallback } from '@authportal/ui/components/avatar'
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@authportal/ui/components/breadcrumb'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import {
|
||||
@@ -472,7 +474,9 @@ function FreshnessPopover() {
|
||||
|
||||
export function PreviewHeader() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const { setPaletteOpen } = usePreviewStore()
|
||||
const { setPaletteOpen, users } = usePreviewStore()
|
||||
const profileId = /^\/design-preview\/users\/([^/]+)/.exec(pathname)?.[1]
|
||||
const profileUser = profileId ? users.find((u) => u.id === profileId) : undefined
|
||||
const current = NAV_ITEMS.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`))
|
||||
|
||||
return (
|
||||
@@ -481,9 +485,21 @@ export function PreviewHeader() {
|
||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||
<Breadcrumb className="min-w-0">
|
||||
<BreadcrumbList className="flex-nowrap">
|
||||
{profileUser ? (
|
||||
<>
|
||||
<BreadcrumbItem className="hidden @md/header:inline-flex">
|
||||
<BreadcrumbLink render={<Link to="/design-preview/users" />}>Пользователи</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden @md/header:block" />
|
||||
<BreadcrumbItem className="min-w-0">
|
||||
<BreadcrumbPage className="truncate">{profileUser.email}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</>
|
||||
) : (
|
||||
<BreadcrumbItem className="min-w-0">
|
||||
<BreadcrumbPage className="truncate">{current?.label ?? 'Макет'}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
)}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import type { AdminUser } from '@authportal/shared'
|
||||
import { MOCK_USERS } from './mock'
|
||||
import {
|
||||
PreviewStoreContext,
|
||||
type Contrast,
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
export function PreviewProvider({ children }: { children: ReactNode }) {
|
||||
const [pending, setPending] = useState<PendingChange[]>([])
|
||||
const [paletteOpen, setPaletteOpen] = useState(false)
|
||||
const [users, setUsers] = useState<AdminUser[]>(MOCK_USERS)
|
||||
const [density, setDensity] = useState<Density>('comfortable')
|
||||
const [contrast, setContrast] = useState<Contrast>('standard')
|
||||
const [pins, setPins] = useState<string[]>(() => {
|
||||
@@ -109,6 +112,8 @@ export function PreviewProvider({ children }: { children: ReactNode }) {
|
||||
applyAll,
|
||||
paletteOpen,
|
||||
setPaletteOpen,
|
||||
users,
|
||||
setUsers,
|
||||
pins,
|
||||
togglePin,
|
||||
density,
|
||||
@@ -116,7 +121,7 @@ export function PreviewProvider({ children }: { children: ReactNode }) {
|
||||
contrast,
|
||||
setContrast,
|
||||
}),
|
||||
[pending, marksFor, addChange, discardAll, applyAll, paletteOpen, pins, togglePin, density, contrast],
|
||||
[pending, marksFor, addChange, discardAll, applyAll, paletteOpen, users, pins, togglePin, density, contrast],
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
import { createContext, useContext, type Dispatch, type SetStateAction } from 'react'
|
||||
import type { AdminUser } from '@authportal/shared'
|
||||
|
||||
export type ChangeKind = 'add' | 'chg' | 'del'
|
||||
|
||||
@@ -32,6 +33,9 @@ export type PreviewStore = {
|
||||
applyAll: () => void
|
||||
paletteOpen: boolean
|
||||
setPaletteOpen: (open: boolean) => void
|
||||
/** Каталог пользователей: общий для списка и профиля. */
|
||||
users: AdminUser[]
|
||||
setUsers: Dispatch<SetStateAction<AdminUser[]>>
|
||||
pins: string[]
|
||||
togglePin: (id: string) => void
|
||||
density: Density
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import type { AdminUser } from '@authportal/shared'
|
||||
import { usePreviewStore } from './preview-store'
|
||||
|
||||
/** Меняет поля у выбранных пользователей и кладёт правку в «Не сохранено» (с откатом). */
|
||||
export function usePatchUsers() {
|
||||
const { users, setUsers, addChange } = usePreviewStore()
|
||||
const usersRef = useRef(users)
|
||||
useEffect(() => {
|
||||
usersRef.current = users
|
||||
}, [users])
|
||||
|
||||
return useCallback(
|
||||
(
|
||||
ids: string[],
|
||||
apply: (u: AdminUser) => AdminUser,
|
||||
title: string,
|
||||
lineFor: (before: AdminUser, after: AdminUser) => string[],
|
||||
) => {
|
||||
const before = usersRef.current.filter((u) => ids.includes(u.id))
|
||||
const after = before.map(apply)
|
||||
const lines = before.flatMap((b, i) => [` ${b.email}`, ...lineFor(b, after[i]!)])
|
||||
setUsers((prev) => prev.map((u) => after.find((a) => a.id === u.id) ?? u))
|
||||
addChange({
|
||||
kind: 'chg',
|
||||
title,
|
||||
lines,
|
||||
resource: 'users',
|
||||
marks: Object.fromEntries(before.map((b) => [b.id, 'dirty' as const])),
|
||||
revert: () => setUsers((prev) => prev.map((u) => before.find((b) => b.id === u.id) ?? u)),
|
||||
})
|
||||
},
|
||||
[addChange, setUsers],
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ScrollTextIcon,
|
||||
ShieldCheckIcon,
|
||||
Trash2Icon,
|
||||
UserRoundIcon,
|
||||
FingerprintIcon,
|
||||
} from 'lucide-react'
|
||||
import type { AdminUser } from '@authportal/shared'
|
||||
@@ -42,6 +43,7 @@ function passkeyLabel(n: number) {
|
||||
|
||||
export type UsersGridHandlers = {
|
||||
onOpenAccess: (u: AdminUser) => void
|
||||
onOpenProfile: (u: AdminUser) => void
|
||||
onOpenJournal: (u: AdminUser) => void
|
||||
onToggleDisabled: (u: AdminUser) => void
|
||||
onResetPasskeys: (u: AdminUser) => void
|
||||
@@ -89,6 +91,10 @@ export function UsersGrid({ users, handlers, marks }: { users: AdminUser[]; hand
|
||||
<ShieldCheckIcon aria-hidden />
|
||||
Права доступа
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handlers.onOpenProfile(u)}>
|
||||
<UserRoundIcon aria-hidden />
|
||||
Профиль
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handlers.onOpenJournal(u)}>
|
||||
<ScrollTextIcon aria-hidden />
|
||||
Журнал
|
||||
|
||||
@@ -30,8 +30,9 @@ import { Route as DesignPreviewShellAppsRouteImport } from './routes/design-prev
|
||||
import { Route as DesignPreviewShellAuditRouteImport } from './routes/design-preview._shell.audit'
|
||||
import { Route as DesignPreviewShellLoginsRouteImport } from './routes/design-preview._shell.logins'
|
||||
import { Route as DesignPreviewShellOidcRouteImport } from './routes/design-preview._shell.oidc'
|
||||
import { Route as DesignPreviewShellUsersRouteImport } from './routes/design-preview._shell.users'
|
||||
import { Route as AuthAdminUsersUserIdRouteImport } from './routes/_auth.admin.users.$userId'
|
||||
import { Route as DesignPreviewShellUsersIndexRouteImport } from './routes/design-preview._shell.users.index'
|
||||
import { Route as DesignPreviewShellUsersUserIdRouteImport } from './routes/design-preview._shell.users.$userId'
|
||||
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
@@ -139,16 +140,23 @@ const DesignPreviewShellOidcRoute = DesignPreviewShellOidcRouteImport.update({
|
||||
path: '/oidc',
|
||||
getParentRoute: () => DesignPreviewShellRoute,
|
||||
} as any)
|
||||
const DesignPreviewShellUsersRoute = DesignPreviewShellUsersRouteImport.update({
|
||||
id: '/users',
|
||||
path: '/users',
|
||||
getParentRoute: () => DesignPreviewShellRoute,
|
||||
} as any)
|
||||
const AuthAdminUsersUserIdRoute = AuthAdminUsersUserIdRouteImport.update({
|
||||
id: '/users/$userId',
|
||||
path: '/users/$userId',
|
||||
getParentRoute: () => AuthAdminRoute,
|
||||
} as any)
|
||||
const DesignPreviewShellUsersIndexRoute =
|
||||
DesignPreviewShellUsersIndexRouteImport.update({
|
||||
id: '/users/',
|
||||
path: '/users/',
|
||||
getParentRoute: () => DesignPreviewShellRoute,
|
||||
} as any)
|
||||
const DesignPreviewShellUsersUserIdRoute =
|
||||
DesignPreviewShellUsersUserIdRouteImport.update({
|
||||
id: '/users/$userId',
|
||||
path: '/users/$userId',
|
||||
getParentRoute: () => DesignPreviewShellRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
@@ -169,9 +177,10 @@ export interface FileRoutesByFullPath {
|
||||
'/design-preview/audit': typeof DesignPreviewShellAuditRoute
|
||||
'/design-preview/logins': typeof DesignPreviewShellLoginsRoute
|
||||
'/design-preview/oidc': typeof DesignPreviewShellOidcRoute
|
||||
'/design-preview/users': typeof DesignPreviewShellUsersRoute
|
||||
'/admin/': typeof AuthAdminIndexRoute
|
||||
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
|
||||
'/design-preview/users/$userId': typeof DesignPreviewShellUsersUserIdRoute
|
||||
'/design-preview/users/': typeof DesignPreviewShellUsersIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
@@ -190,9 +199,10 @@ export interface FileRoutesByTo {
|
||||
'/design-preview/audit': typeof DesignPreviewShellAuditRoute
|
||||
'/design-preview/logins': typeof DesignPreviewShellLoginsRoute
|
||||
'/design-preview/oidc': typeof DesignPreviewShellOidcRoute
|
||||
'/design-preview/users': typeof DesignPreviewShellUsersRoute
|
||||
'/admin': typeof AuthAdminIndexRoute
|
||||
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
|
||||
'/design-preview/users/$userId': typeof DesignPreviewShellUsersUserIdRoute
|
||||
'/design-preview/users': typeof DesignPreviewShellUsersIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
@@ -216,9 +226,10 @@ export interface FileRoutesById {
|
||||
'/design-preview/_shell/audit': typeof DesignPreviewShellAuditRoute
|
||||
'/design-preview/_shell/logins': typeof DesignPreviewShellLoginsRoute
|
||||
'/design-preview/_shell/oidc': typeof DesignPreviewShellOidcRoute
|
||||
'/design-preview/_shell/users': typeof DesignPreviewShellUsersRoute
|
||||
'/_auth/admin/': typeof AuthAdminIndexRoute
|
||||
'/_auth/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
|
||||
'/design-preview/_shell/users/$userId': typeof DesignPreviewShellUsersUserIdRoute
|
||||
'/design-preview/_shell/users/': typeof DesignPreviewShellUsersIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
@@ -241,9 +252,10 @@ export interface FileRouteTypes {
|
||||
| '/design-preview/audit'
|
||||
| '/design-preview/logins'
|
||||
| '/design-preview/oidc'
|
||||
| '/design-preview/users'
|
||||
| '/admin/'
|
||||
| '/admin/users/$userId'
|
||||
| '/design-preview/users/$userId'
|
||||
| '/design-preview/users/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
@@ -262,9 +274,10 @@ export interface FileRouteTypes {
|
||||
| '/design-preview/audit'
|
||||
| '/design-preview/logins'
|
||||
| '/design-preview/oidc'
|
||||
| '/design-preview/users'
|
||||
| '/admin'
|
||||
| '/admin/users/$userId'
|
||||
| '/design-preview/users/$userId'
|
||||
| '/design-preview/users'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
@@ -287,9 +300,10 @@ export interface FileRouteTypes {
|
||||
| '/design-preview/_shell/audit'
|
||||
| '/design-preview/_shell/logins'
|
||||
| '/design-preview/_shell/oidc'
|
||||
| '/design-preview/_shell/users'
|
||||
| '/_auth/admin/'
|
||||
| '/_auth/admin/users/$userId'
|
||||
| '/design-preview/_shell/users/$userId'
|
||||
| '/design-preview/_shell/users/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
@@ -448,13 +462,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof DesignPreviewShellOidcRouteImport
|
||||
parentRoute: typeof DesignPreviewShellRoute
|
||||
}
|
||||
'/design-preview/_shell/users': {
|
||||
id: '/design-preview/_shell/users'
|
||||
path: '/users'
|
||||
fullPath: '/design-preview/users'
|
||||
preLoaderRoute: typeof DesignPreviewShellUsersRouteImport
|
||||
parentRoute: typeof DesignPreviewShellRoute
|
||||
}
|
||||
'/_auth/admin/users/$userId': {
|
||||
id: '/_auth/admin/users/$userId'
|
||||
path: '/users/$userId'
|
||||
@@ -462,6 +469,20 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthAdminUsersUserIdRouteImport
|
||||
parentRoute: typeof AuthAdminRoute
|
||||
}
|
||||
'/design-preview/_shell/users/': {
|
||||
id: '/design-preview/_shell/users/'
|
||||
path: '/users'
|
||||
fullPath: '/design-preview/users/'
|
||||
preLoaderRoute: typeof DesignPreviewShellUsersIndexRouteImport
|
||||
parentRoute: typeof DesignPreviewShellRoute
|
||||
}
|
||||
'/design-preview/_shell/users/$userId': {
|
||||
id: '/design-preview/_shell/users/$userId'
|
||||
path: '/users/$userId'
|
||||
fullPath: '/design-preview/users/$userId'
|
||||
preLoaderRoute: typeof DesignPreviewShellUsersUserIdRouteImport
|
||||
parentRoute: typeof DesignPreviewShellRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,7 +529,8 @@ interface DesignPreviewShellRouteChildren {
|
||||
DesignPreviewShellAuditRoute: typeof DesignPreviewShellAuditRoute
|
||||
DesignPreviewShellLoginsRoute: typeof DesignPreviewShellLoginsRoute
|
||||
DesignPreviewShellOidcRoute: typeof DesignPreviewShellOidcRoute
|
||||
DesignPreviewShellUsersRoute: typeof DesignPreviewShellUsersRoute
|
||||
DesignPreviewShellUsersUserIdRoute: typeof DesignPreviewShellUsersUserIdRoute
|
||||
DesignPreviewShellUsersIndexRoute: typeof DesignPreviewShellUsersIndexRoute
|
||||
}
|
||||
|
||||
const DesignPreviewShellRouteChildren: DesignPreviewShellRouteChildren = {
|
||||
@@ -518,7 +540,8 @@ const DesignPreviewShellRouteChildren: DesignPreviewShellRouteChildren = {
|
||||
DesignPreviewShellAuditRoute: DesignPreviewShellAuditRoute,
|
||||
DesignPreviewShellLoginsRoute: DesignPreviewShellLoginsRoute,
|
||||
DesignPreviewShellOidcRoute: DesignPreviewShellOidcRoute,
|
||||
DesignPreviewShellUsersRoute: DesignPreviewShellUsersRoute,
|
||||
DesignPreviewShellUsersUserIdRoute: DesignPreviewShellUsersUserIdRoute,
|
||||
DesignPreviewShellUsersIndexRoute: DesignPreviewShellUsersIndexRoute,
|
||||
}
|
||||
|
||||
const DesignPreviewShellRouteWithChildren =
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { UserProfilePage } from '@/design-preview/pages/user-profile-page'
|
||||
|
||||
export const Route = createFileRoute('/design-preview/_shell/users/$userId')({
|
||||
component: UserProfilePage,
|
||||
})
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { UsersPage } from '@/design-preview/pages/users-page'
|
||||
|
||||
export const Route = createFileRoute('/design-preview/_shell/users')({
|
||||
export const Route = createFileRoute('/design-preview/_shell/users/')({
|
||||
component: UsersPage,
|
||||
})
|
||||
Reference in new issue
Block a user