fix(design-preview): убрать страницу пользователя — дублировала панель прав

Права, сброс passkeys, отключение и удаление уже есть в панели прав и меню строки,
сессии и события — в «Входах» и «Изменениях». Страница откроется из коммита 007ece4.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-18 03:11:43 +07:00
1 parent c8351424f4
commit 8bbaab62e4
7 files changed
+4 -373

No files matched your search

@@ -1,5 +1,4 @@
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'
@@ -11,12 +10,10 @@ 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])
@@ -31,12 +28,6 @@ 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>
@@ -1,304 +0,0 @@
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" 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 />
Права доступа
</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,6 +1,5 @@
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'
@@ -26,7 +25,6 @@ export function UsersPage() {
usersRef.current = users
}, [users])
const patch = usePatchUsers()
const navigate = useNavigate()
const [createOpen, setCreateOpen] = useState(false)
const [journalFor, setJournalFor] = useState<AdminUser | null>(null)
@@ -86,7 +84,6 @@ 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}`, () => [
@@ -151,9 +148,6 @@ 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(
+4 -20
View File
@@ -31,10 +31,8 @@ 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 {
@@ -474,9 +472,7 @@ function FreshnessPopover() {
export function PreviewHeader() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const { setPaletteOpen, users } = usePreviewStore()
const profileId = /^\/design-preview\/users\/([^/]+)/.exec(pathname)?.[1]
const profileUser = profileId ? users.find((u) => u.id === profileId) : undefined
const { setPaletteOpen } = usePreviewStore()
const current = NAV_ITEMS.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`))
return (
@@ -485,21 +481,9 @@ 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>
)}
<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">
@@ -6,7 +6,6 @@ import {
ScrollTextIcon,
ShieldCheckIcon,
Trash2Icon,
UserRoundIcon,
FingerprintIcon,
} from 'lucide-react'
import type { AdminUser } from '@authportal/shared'
@@ -43,7 +42,6 @@ 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
@@ -91,10 +89,6 @@ 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 />
Журнал
-22
View File
@@ -32,7 +32,6 @@ import { Route as DesignPreviewShellLoginsRouteImport } from './routes/design-pr
import { Route as DesignPreviewShellOidcRouteImport } from './routes/design-preview._shell.oidc'
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: '/',
@@ -151,12 +150,6 @@ const DesignPreviewShellUsersIndexRoute =
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
@@ -179,7 +172,6 @@ export interface FileRoutesByFullPath {
'/design-preview/oidc': typeof DesignPreviewShellOidcRoute
'/admin/': typeof AuthAdminIndexRoute
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
'/design-preview/users/$userId': typeof DesignPreviewShellUsersUserIdRoute
'/design-preview/users/': typeof DesignPreviewShellUsersIndexRoute
}
export interface FileRoutesByTo {
@@ -201,7 +193,6 @@ export interface FileRoutesByTo {
'/design-preview/oidc': typeof DesignPreviewShellOidcRoute
'/admin': typeof AuthAdminIndexRoute
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
'/design-preview/users/$userId': typeof DesignPreviewShellUsersUserIdRoute
'/design-preview/users': typeof DesignPreviewShellUsersIndexRoute
}
export interface FileRoutesById {
@@ -228,7 +219,6 @@ export interface FileRoutesById {
'/design-preview/_shell/oidc': typeof DesignPreviewShellOidcRoute
'/_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 {
@@ -254,7 +244,6 @@ export interface FileRouteTypes {
| '/design-preview/oidc'
| '/admin/'
| '/admin/users/$userId'
| '/design-preview/users/$userId'
| '/design-preview/users/'
fileRoutesByTo: FileRoutesByTo
to:
@@ -276,7 +265,6 @@ export interface FileRouteTypes {
| '/design-preview/oidc'
| '/admin'
| '/admin/users/$userId'
| '/design-preview/users/$userId'
| '/design-preview/users'
id:
| '__root__'
@@ -302,7 +290,6 @@ export interface FileRouteTypes {
| '/design-preview/_shell/oidc'
| '/_auth/admin/'
| '/_auth/admin/users/$userId'
| '/design-preview/_shell/users/$userId'
| '/design-preview/_shell/users/'
fileRoutesById: FileRoutesById
}
@@ -476,13 +463,6 @@ declare module '@tanstack/react-router' {
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
}
}
}
@@ -529,7 +509,6 @@ interface DesignPreviewShellRouteChildren {
DesignPreviewShellAuditRoute: typeof DesignPreviewShellAuditRoute
DesignPreviewShellLoginsRoute: typeof DesignPreviewShellLoginsRoute
DesignPreviewShellOidcRoute: typeof DesignPreviewShellOidcRoute
DesignPreviewShellUsersUserIdRoute: typeof DesignPreviewShellUsersUserIdRoute
DesignPreviewShellUsersIndexRoute: typeof DesignPreviewShellUsersIndexRoute
}
@@ -540,7 +519,6 @@ const DesignPreviewShellRouteChildren: DesignPreviewShellRouteChildren = {
DesignPreviewShellAuditRoute: DesignPreviewShellAuditRoute,
DesignPreviewShellLoginsRoute: DesignPreviewShellLoginsRoute,
DesignPreviewShellOidcRoute: DesignPreviewShellOidcRoute,
DesignPreviewShellUsersUserIdRoute: DesignPreviewShellUsersUserIdRoute,
DesignPreviewShellUsersIndexRoute: DesignPreviewShellUsersIndexRoute,
}
@@ -1,6 +0,0 @@
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,
})