fix(mcp): подготовить доступ и отзыв разрешений для приёмки

This commit is contained in:
Denozordec committed 2026-11-03 03:05:24 +07:00
commit ef7bb79d4e
311 files changed
+73119

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "../../packages/ui/src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"registries": {
"@reui": {
"url": "https://reui.io/r/{style}/{name}.json",
"headers": {
"Authorization": "Bearer ${REUI_LICENSE_KEY}"
}
}
},
"aliases": {
"components": "@/components",
"utils": "@authportal/ui/lib/utils",
"ui": "@authportal/ui/components",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle"
}
+49
View File
@@ -0,0 +1,49 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist', 'src/routeTree.gen.ts', 'src/components/reui/**']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
rules: {
'react-hooks/set-state-in-effect': 'off',
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@/components/ui/*'],
message: 'Импортируйте примитивы через @authportal/ui/components/*',
},
],
},
],
},
},
{
files: ['src/routes/**/*.{ts,tsx}'],
rules: {
'react-refresh/only-export-components': 'off',
},
},
{
files: ['src/components/reui-kit/**/*.{ts,tsx}'],
rules: {
'react-refresh/only-export-components': 'off',
'react-hooks/incompatible-library': 'off',
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Auth Portal</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+61
View File
@@ -0,0 +1,61 @@
{
"name": "web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsr generate && vite build",
"typecheck": "tsr generate && tsc -b",
"test": "vitest run --passWithNoTests",
"lint": "eslint .",
"preview": "vite preview",
"analyze": "vite build --mode analyze"
},
"dependencies": {
"@authportal/shared": "workspace:*",
"@authportal/ui": "workspace:*",
"@base-ui/react": "catalog:",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.4.0",
"@simplewebauthn/browser": "^13.3.0",
"@tailwindcss/vite": "^4.3.1",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-router": "^1.170.15",
"@tanstack/react-table": "^9.3.1",
"@tanstack/react-virtual": "^3.14.21",
"@tanstack/router-vite-plugin": "^1.167.18",
"class-variance-authority": "^0.7.1",
"date-fns": "^4.4.0",
"lucide-react": "catalog:",
"next-themes": "^0.4.6",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-hook-form": "^7.79.0",
"sonner": "^2.0.7",
"tailwindcss": "^4.3.1",
"zod": "catalog:"
},
"devDependencies": {
"@eslint/js": "catalog:",
"@tanstack/router-cli": "^1.167.31",
"@types/node": "catalog:",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "catalog:",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "catalog:",
"jsdom": "^30.1.3",
"rollup-plugin-visualizer": "^7.1.1",
"tw-animate-css": "^1.0.0",
"typescript": "catalog:",
"typescript-eslint": "catalog:",
"vite": "^8.0.12",
"vitest": "catalog:"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated

After

Width:  |  Height:  |  Size: 9.3 KiB

+142
View File
@@ -0,0 +1,142 @@
import { Link, useRouterState } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import {
AppWindowIcon,
FingerprintIcon,
HistoryIcon,
KeyRoundIcon,
LayoutGridIcon,
LogInIcon,
UsersIcon,
} from 'lucide-react'
import { AppSwitcher } from '@/components/app-switcher'
import { NavUser } from '@/components/nav-user'
import { meQueryOptions } from '@/queries/auth'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@authportal/ui/components/sidebar'
function isActive(pathname: string, to: string, exact: boolean) {
if (exact) return pathname === to
return pathname === to || pathname.startsWith(`${to}/`)
}
export function AppSidebar() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const { data: me } = useQuery(meQueryOptions)
return (
<Sidebar collapsible="icon">
<SidebarHeader>
<AppSwitcher />
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Портал</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
tooltip="Приложения"
isActive={isActive(pathname, '/apps', true)}
render={<Link to="/apps" />}
>
<LayoutGridIcon className="size-4" />
<span>Приложения</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
tooltip="Безопасность"
isActive={isActive(pathname, '/account', true)}
render={<Link to="/account" />}
>
<FingerprintIcon className="size-4" />
<span>Безопасность</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{me?.is_admin ? (
<SidebarGroup>
<SidebarGroupLabel>Админ</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
tooltip="Пользователи"
isActive={
isActive(pathname, '/admin', false) &&
!pathname.startsWith('/admin/apps') &&
!pathname.startsWith('/admin/audit') &&
!pathname.startsWith('/admin/logins') &&
!pathname.startsWith('/admin/oidc')
}
render={<Link to="/admin" />}
>
<UsersIcon className="size-4" />
<span>Пользователи</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
tooltip="Входы"
isActive={isActive(pathname, '/admin/logins', false)}
render={<Link to="/admin/logins" />}
>
<LogInIcon className="size-4" />
<span>Входы</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
tooltip="Изменения"
isActive={isActive(pathname, '/admin/audit', false)}
render={<Link to="/admin/audit" />}
>
<HistoryIcon className="size-4" />
<span>Изменения</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
tooltip="Приложения"
isActive={isActive(pathname, '/admin/apps', false)}
render={<Link to="/admin/apps" />}
>
<AppWindowIcon className="size-4" />
<span>Ссылки приложений</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
tooltip="OIDC"
isActive={isActive(pathname, '/admin/oidc', false)}
render={<Link to="/admin/oidc" />}
>
<KeyRoundIcon className="size-4" />
<span>OIDC-клиенты</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
) : null}
</SidebarContent>
<SidebarFooter>
<NavUser />
</SidebarFooter>
</Sidebar>
)
}
+109
View File
@@ -0,0 +1,109 @@
import { useQuery } from '@tanstack/react-query'
import {
CheckIcon,
ChevronsUpDownIcon,
KeyRoundIcon,
CloudIcon,
ServerIcon,
NetworkIcon,
LayoutDashboardIcon,
ChartColumnIcon,
} from 'lucide-react'
import type { AppSwitcherIconName } from '@authportal/shared'
import { appSwitcherQueryOptions } from '@/queries/app-switcher'
import { ssoOpenApp } from '@/lib/auth'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu'
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@authportal/ui/components/sidebar'
const PORTAL = {
id: 'portal',
name: 'Auth Portal',
subtitle: 'shnt.top',
}
const ICON_MAP: Record<
AppSwitcherIconName,
React.ComponentType<{ className?: string }>
> = {
cloud: CloudIcon,
server: ServerIcon,
globe: NetworkIcon,
dashboard: LayoutDashboardIcon,
chart: ChartColumnIcon,
}
export function AppSwitcher() {
const { isMobile } = useSidebar()
const { data } = useQuery(appSwitcherQueryOptions)
const menuLabel = data?.menuLabel ?? 'Приложения'
const apps = (data?.apps ?? []).filter((a) => a.enabled !== false)
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />
}
>
<div
className="bg-primary text-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md"
aria-hidden
>
<KeyRoundIcon className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{PORTAL.name}</span>
<span className="text-muted-foreground truncate text-xs">
{PORTAL.subtitle}
</span>
</div>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="start"
sideOffset={4}
>
<div className="text-muted-foreground px-2 py-1.5 text-xs">
{menuLabel}
</div>
<DropdownMenuItem disabled>
<KeyRoundIcon />
Auth Portal
<CheckIcon className="ml-auto size-4" />
</DropdownMenuItem>
{apps.map((app) => {
const Icon = ICON_MAP[app.icon] ?? ServerIcon
return (
<DropdownMenuItem
key={app.id}
onClick={() => {
void ssoOpenApp(app.url, app.authMode ?? 'jwt').catch(() => {
window.location.href = app.url.replace(/\/$/, '')
})
}}
>
<Icon />
{app.name}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
+35
View File
@@ -0,0 +1,35 @@
import { cn } from "@authportal/ui/lib/utils"
import { Item, ItemMedia } from "@authportal/ui/components/item"
export function AuthLogo({ className }: { className?: string }) {
return (
<Item
variant="outline"
className={cn(
"p-0",
"text-primary flex size-8 shrink-0 items-center justify-center",
className
)}
aria-hidden="true"
>
<ItemMedia variant="icon" className="size-auto">
<svg
width="50"
height="50"
viewBox="25.668 25.1352 49.6644 50"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="size-4"
>
<circle cx="70.634" cy="29.8334" r="4.69799" fill="currentColor" />
<path
fillRule="evenodd"
clipRule="evenodd"
d="M25.668 57.0144V29.8332C25.668 27.2386 27.7713 25.1352 30.366 25.1352C32.9606 25.1352 35.0639 27.2386 35.0639 29.8332V57.0144C35.0639 61.833 38.9702 65.7392 43.7888 65.7392H57.2116C62.0302 65.7392 65.9364 61.833 65.9364 57.0144V43.7258C65.9364 41.1312 68.0398 39.0278 70.6344 39.0278C73.229 39.0278 75.3324 41.1312 75.3324 43.7258V57.0144C75.3324 67.0222 67.2194 75.1352 57.2116 75.1352H43.7888C33.7809 75.1352 25.668 67.0222 25.668 57.0144Z"
fill="currentColor"
/>
</svg>
</ItemMedia>
</Item>
)
}
@@ -0,0 +1,34 @@
import type { CSSProperties, ReactNode } from 'react'
import { AppSidebar } from '@/components/app-sidebar'
import { SiteHeader } from '@/components/layout/site-header'
import { TooltipProvider } from '@authportal/ui/components/tooltip'
import { SidebarInset, SidebarProvider } from '@authportal/ui/components/sidebar'
const SKIP_TO_CONTENT_CLASS =
'bg-background text-foreground ring-ring sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:rounded-md focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-sm focus:ring-2'
/** Shared ops chrome — etalon app-shell-12. */
export function AppShell({ children }: { children: ReactNode }) {
return (
<TooltipProvider delay={0}>
<SidebarProvider
style={
{
'--sidebar-width': '240px',
} as CSSProperties
}
>
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
К содержимому
</a>
<AppSidebar />
<SidebarInset id="main-content">
<SiteHeader />
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
{children}
</div>
</SidebarInset>
</SidebarProvider>
</TooltipProvider>
)
}
@@ -0,0 +1,80 @@
import type { ComponentType } from 'react'
import {
LayoutGridIcon,
KeyRoundIcon,
CloudIcon,
ServerIcon,
NetworkIcon,
ShieldIcon,
GlobeIcon,
LayoutDashboardIcon,
} from 'lucide-react'
import { APPS, type AppId } from '@authportal/shared'
import { Button } from '@authportal/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu'
const APP_ICONS: Record<
AppId,
ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
> = {
cfdm: CloudIcon,
vps: ServerIcon,
bgp: NetworkIcon,
fw: ShieldIcon,
dns: GlobeIcon,
cdn: CloudIcon,
mm: LayoutDashboardIcon,
}
export function AppsMenu() {
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon" aria-label="Приложения" />
}
>
<LayoutGridIcon className="size-4.5" aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" sideOffset={8} className="w-72">
<DropdownMenuGroup>
<DropdownMenuLabel>Приложения</DropdownMenuLabel>
<div className="grid grid-cols-2 gap-1 p-1">
<DropdownMenuItem
disabled
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
>
<span className="text-muted-foreground">
<KeyRoundIcon aria-hidden />
</span>
<span className="text-xs font-medium">Auth Portal</span>
</DropdownMenuItem>
{APPS.map((app) => {
const Icon = APP_ICONS[app.id]
return (
<DropdownMenuItem
key={app.id}
nativeButton={false}
render={<a href={app.url} target="_blank" rel="noreferrer" />}
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
>
<span className="text-muted-foreground">
<Icon aria-hidden />
</span>
<span className="text-xs font-medium">{app.title}</span>
</DropdownMenuItem>
)
})}
</div>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,211 @@
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import {
AppWindowIcon,
FingerprintIcon,
HistoryIcon,
KeyRoundIcon,
LayoutGridIcon,
LogInIcon,
PlusIcon,
UsersIcon,
} from 'lucide-react'
import { AppSwitcher } from '@/components/app-switcher'
import { CommandPalette, PortalHeader, PortalSidebar } from '@/components/portal/chrome'
import { ChromeConfigProvider, PortalUiProvider } from '@/components/portal/chrome-context'
import type { ChromeConfig, MonitorItem, MonitorMetric, NavItem, PaletteObject } from '@/components/portal/chrome-store'
import { auditQueryOptions } from '@/queries/audit'
import { catalogQueryOptions, meQueryOptions, usersQueryOptions } from '@/queries/auth'
import { oidcClientsQueryOptions } from '@/queries/oidc'
import { sessionsQueryOptions } from '@/queries/sessions'
import { Skeleton } from '@authportal/ui/components/skeleton'
import { SidebarInset, SidebarProvider } from '@authportal/ui/components/sidebar'
import { TooltipProvider } from '@authportal/ui/components/tooltip'
const SKIP_TO_CONTENT_CLASS =
'bg-background text-foreground ring-ring sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:rounded-md focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-sm focus:ring-2'
const FRESH: Record<string, { badge: string; text: string }> = {
'/apps': { badge: 'Портал', text: 'Права из базы портала, а не из токена · SSO открывает приложение с текущей сессией' },
'/account': { badge: 'WebAuthn', text: 'Passkeys привязаны к домену портала · пароль остаётся запасным входом' },
'/admin/logins': { badge: 'Журнал', text: 'GET /api/v1/admin/audit?kind=logins · хранение по настройке ротации журнала' },
'/admin/audit': { badge: 'Журнал', text: 'GET /api/v1/admin/audit?kind=changes · события портала и приложений через ingest' },
'/admin/oidc': { badge: 'OIDC', text: 'Discovery и JWKS публикует портал · секрет клиента показывается один раз' },
'/admin/apps': { badge: 'Портал', text: 'portal_settings.app_switcher_json · публичный GET /api/v1/app-switcher' },
'/admin': { badge: 'Портал', text: 'GET /api/v1/admin/users · права на приложения и разделы' },
}
function freshFor(pathname: string) {
const key = Object.keys(FRESH)
.sort((a, b) => b.length - a.length)
.find((k) => pathname === k || pathname.startsWith(`${k}/`))
return FRESH[key ?? '/apps']!
}
function initialsOf(name: string, email: string) {
const src = (name || email).trim()
const parts = src.split(/[\s@._-]+/).filter(Boolean)
return ((parts[0]?.[0] ?? '?') + (parts[1]?.[0] ?? '')).toUpperCase()
}
/** Меню портала: «Портал» и «Админ» (для администраторов). Точки проблем приходят из данных. */
function useChromeData(): ChromeConfig {
const navigate = useNavigate()
const meQ = useQuery(meQueryOptions)
const isAdmin = Boolean(meQ.data?.is_admin)
const catalogQ = useQuery(catalogQueryOptions)
const usersQ = useQuery({ ...usersQueryOptions, enabled: isAdmin, retry: false })
const oidcQ = useQuery({ ...oidcClientsQueryOptions, enabled: isAdmin, retry: false })
const sessionsQ = useQuery({ ...sessionsQueryOptions(), enabled: isAdmin, retry: false })
const loginsQ = useQuery({ ...auditQueryOptions({ kind: 'logins', limit: 100 }), enabled: isAdmin, retry: false })
return useMemo<ChromeConfig>(() => {
const users = usersQ.data ?? []
const noPasskey = users.filter((u) => !u.disabled && u.passkey_count === 0)
const clients = oidcQ.data ?? []
const hourAgo = loginsQ.dataUpdatedAt - 60 * 60 * 1000
const failed = (loginsQ.data ?? []).filter((e) => e.action === 'auth.login_failed' && new Date(e.created_at).getTime() >= hourAgo)
const navItems: NavItem[] = [
{ id: 'apps', label: 'Приложения', to: '/apps', icon: LayoutGridIcon, group: 'Портал' },
{ id: 'account', label: 'Безопасность', to: '/account', icon: FingerprintIcon, group: 'Портал' },
]
if (isAdmin) {
navItems.push(
{ id: 'users', label: 'Пользователи', to: '/admin', icon: UsersIcon, group: 'Админ' },
{
id: 'logins',
label: 'Входы',
to: '/admin/logins',
icon: LogInIcon,
group: 'Админ',
problem: failed.length >= 5 ? { tone: 'er', label: `${failed.length} неудачных входов за час` } : undefined,
},
{ id: 'audit', label: 'Изменения', to: '/admin/audit', icon: HistoryIcon, group: 'Админ' },
{ id: 'oidc', label: 'OIDC-клиенты', to: '/admin/oidc', icon: KeyRoundIcon, group: 'Админ' },
{ id: 'app-links', label: 'Ссылки приложений', to: '/admin/apps', icon: AppWindowIcon, group: 'Админ' },
)
}
const monitorItems: MonitorItem[] = []
if (failed.length >= 5) {
monitorItems.push({
id: 'bruteforce',
tone: 'er',
title: `Неудачных входов за час: ${failed.length}`,
text: 'Проверьте журнал входов и при необходимости отключите учётную запись',
age: 'час',
to: '/admin/logins',
})
}
if (noPasskey.length > 0) {
monitorItems.push({
id: 'nopasskey',
tone: 'wn',
title: `${noPasskey.length} без passkey`,
text: noPasskey.slice(0, 3).map((u) => u.email.split('@')[0]).join(', ') + ' · вход только по паролю',
age: 'сейчас',
to: '/admin',
})
}
monitorItems.push({ id: 'api', tone: 'ok', title: 'API портала', text: 'GET /ready', age: 'сейчас', to: '/apps' })
const enabledClients = clients.filter((c) => c.enabled).length
const metrics: MonitorMetric[] = isAdmin
? [
{ l: 'Пользователи без passkey', v: `${noPasskey.length} из ${users.length}`, p: users.length ? Math.round((noPasskey.length / users.length) * 100) : 0, tone: noPasskey.length ? 'warning' : 'success' },
{ l: 'Приложения доступны', v: String(catalogQ.data?.apps.length ?? 0), p: 100, tone: 'success' },
{ l: 'OIDC-клиенты', v: `${enabledClients} из ${clients.length}`, p: clients.length ? Math.round((enabledClients / clients.length) * 100) : 100, tone: enabledClients === clients.length ? 'success' : 'warning' },
{ l: 'Сессии', v: String(sessionsQ.data?.length ?? 0), p: Math.min(100, (sessionsQ.data?.length ?? 0) * 10), tone: 'success' },
]
: [{ l: 'Приложения доступны', v: String(catalogQ.data?.apps.length ?? 0), p: 100, tone: 'success' }]
const objects: PaletteObject[] = [
...(isAdmin
? users.map<PaletteObject>((u) => ({
id: `user-${u.id}`,
group: 'Пользователи',
label: u.email,
hint: u.disabled ? 'Отключён' : u.is_admin ? 'Админ' : 'Пользователь',
dot: u.disabled ? 'mu' : u.passkey_count === 0 ? 'er' : 'ok',
to: `/admin/users/${u.id}`,
}))
: []),
...(catalogQ.data?.apps ?? []).map<PaletteObject>((a) => ({ id: `app-${a.id}`, group: 'Приложения', label: a.title, hint: a.url.replace(/^https?:\/\//, ''), dot: 'mu', to: '/apps' })),
...(isAdmin
? clients.map<PaletteObject>((c) => ({ id: `oidc-${c.id}`, group: 'OIDC-клиенты', label: c.name, hint: `client_id ${c.client_id}`, dot: 'mu', to: '/admin/oidc' }))
: []),
]
const me = meQ.data
return {
navItems,
navGroups: isAdmin ? ['Портал', 'Админ'] : ['Портал'],
brand: <AppSwitcher />,
user: { name: me?.name ?? '…', email: me?.email ?? '', initials: initialsOf(me?.name ?? '', me?.email ?? '') },
securityTo: '/account',
onLogout: () => void navigate({ to: '/logout' }),
fresh: freshFor,
monitor: { items: monitorItems, metrics, updated: 'обновлено сейчас', footerTo: isAdmin ? '/admin/logins' : '/apps', footerLabel: isAdmin ? 'Журнал входов' : 'Приложения' },
palette: {
actions: isAdmin
? [
{ id: 'act-user', label: 'Создать пользователя', hint: 'U', icon: PlusIcon, to: '/admin' },
{ id: 'act-client', label: 'Создать OIDC-клиент', hint: 'O', icon: PlusIcon, to: '/admin/oidc' },
{ id: 'act-passkey', label: 'Добавить passkey', hint: 'K', icon: PlusIcon, to: '/account' },
{ id: 'act-logins', label: 'Открыть журнал входов', hint: 'L', icon: LogInIcon, to: '/admin/logins' },
]
: [{ id: 'act-passkey', label: 'Добавить passkey', hint: 'K', icon: PlusIcon, to: '/account' }],
objects,
assistantTo: isAdmin ? '/admin/logins' : '/apps',
example: 'Подсказка: «неудачные входы ops за сутки»',
},
}
}, [navigate, meQ.data, isAdmin, catalogQ.data, usersQ.data, oidcQ.data, sessionsQ.data, loginsQ.data, loginsQ.dataUpdatedAt])
}
/** Оболочка портала: боковое меню, шапка, палитра ⌘K. Макет — `design-preview` на тех же блоках. */
export function PortalShell({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(true)
const [mounted, setMounted] = useState(false)
useEffect(() => {
// До 1100px меню сворачивается в колонку иконок (на телефоне его заменяет Sheet).
const mq = window.matchMedia('(min-width: 1100px)')
const sync = () => setOpen(mq.matches)
sync()
mq.addEventListener('change', sync)
setMounted(true)
return () => mq.removeEventListener('change', sync)
}, [])
const config = useChromeData()
if (!mounted) {
return (
<div className="flex min-h-svh flex-col gap-4 p-6" aria-busy>
<Skeleton className="h-12 w-full" />
<Skeleton className="h-64 w-full" />
</div>
)
}
return (
<PortalUiProvider>
<ChromeConfigProvider value={config}>
<TooltipProvider delay={0}>
<SidebarProvider open={open} onOpenChange={setOpen} style={{ '--sidebar-width': '240px' } as CSSProperties}>
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
К содержимому
</a>
<PortalSidebar />
<SidebarInset id="main-content" className="min-w-0">
<PortalHeader />
<div className="flex w-full min-w-0 flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">{children}</div>
</SidebarInset>
<CommandPalette />
</SidebarProvider>
</TooltipProvider>
</ChromeConfigProvider>
</PortalUiProvider>
)
}
@@ -0,0 +1,83 @@
import { Fragment, useMemo } from 'react'
import { Link, useRouterState } from '@tanstack/react-router'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@authportal/ui/components/breadcrumb'
import { Separator } from '@authportal/ui/components/separator'
import { SidebarTrigger } from '@authportal/ui/components/sidebar'
import { AppsMenu } from '@/components/layout/apps-menu'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
function breadcrumbs(pathname: string) {
if (pathname.startsWith('/admin/apps')) {
return [{ label: 'Ссылки приложений', href: '/admin/apps' }]
}
if (pathname.startsWith('/admin/oidc')) {
return [{ label: 'OIDC-клиенты', href: '/admin/oidc' }]
}
if (pathname.startsWith('/admin/logins')) {
return [{ label: 'Журнал входов', href: '/admin/logins' }]
}
if (pathname.startsWith('/admin/audit')) {
return [{ label: 'Журнал изменений', href: '/admin/audit' }]
}
if (pathname.startsWith('/admin/users/')) {
return [
{ label: 'Пользователи', href: '/admin' },
{ label: 'Права доступа', href: pathname },
]
}
if (pathname.startsWith('/admin')) {
return [{ label: 'Пользователи', href: '/admin' }]
}
if (pathname.startsWith('/apps')) {
return [{ label: 'Приложения', href: '/apps' }]
}
return [{ label: 'Auth Portal', href: '/apps' }]
}
export function SiteHeader() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const crumbs = useMemo(() => breadcrumbs(pathname), [pathname])
return (
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
className="mr-2 data-[orientation=vertical]:h-4"
/>
<Breadcrumb>
<BreadcrumbList>
{crumbs.map((crumb, i) => {
const last = i === crumbs.length - 1
return (
<Fragment key={crumb.href}>
{i > 0 ? <BreadcrumbSeparator /> : null}
<BreadcrumbItem>
{last ? (
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
) : (
<BreadcrumbLink render={<Link to={crumb.href} />}>
{crumb.label}
</BreadcrumbLink>
)}
</BreadcrumbItem>
</Fragment>
)
})}
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex items-center gap-2">
<AppsMenu />
<SystemMonitorPopover />
</div>
</header>
)
}
@@ -0,0 +1,255 @@
import { useMemo, useState, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Activity, AppWindow, KeyRound, Users } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { cn } from '@authportal/ui/lib/utils'
import { Item, ItemMedia } from '@authportal/ui/components/item'
import { Popover, PopoverContent, PopoverTrigger } from '@authportal/ui/components/popover'
import { catalogQueryOptions, meQueryOptions, usersQueryOptions } from '@/queries/auth'
import { oidcClientsQueryOptions } from '@/queries/oidc'
type MonitorMetric = {
id: string
label: string
value: string
unit: string
percent: number
icon: ReactNode
tone: 'success' | 'warning' | 'destructive' | 'info'
alert: boolean
}
function toneColor(tone: MonitorMetric['tone']) {
switch (tone) {
case 'success':
return 'var(--color-success)'
case 'warning':
return 'var(--color-warning)'
case 'destructive':
return 'var(--color-destructive)'
default:
return 'var(--color-info)'
}
}
function MetricBar({ percent, color }: { percent: number; color: string }) {
return (
<div className="bg-muted h-1 w-full overflow-hidden rounded-full">
<div
className="h-full rounded-full"
style={{
width: `${Math.min(100, Math.max(0, percent))}%`,
backgroundColor: color,
}}
/>
</div>
)
}
function MetricCell({ metric }: { metric: MonitorMetric }) {
const color = toneColor(metric.tone)
return (
<div className="flex flex-col gap-2 p-3">
<div className="flex items-center justify-between gap-1">
<div className="flex min-w-0 items-center gap-1.5">
<Item
className="flex size-5 shrink-0 items-center justify-center p-0"
style={{ backgroundColor: `${color}18` }}
>
<ItemMedia variant="icon" className="size-auto" style={{ color }}>
{metric.icon}
</ItemMedia>
</Item>
<span className="text-muted-foreground truncate text-[11px]">{metric.label}</span>
</div>
<span className="shrink-0 text-xs font-semibold tabular-nums" style={{ color }}>
{metric.value}
<span className="text-muted-foreground ml-0.5 text-[10px] font-normal">{metric.unit}</span>
</span>
</div>
<MetricBar percent={metric.percent} color={color} />
</div>
)
}
/** Live system monitor popover — app-shell-7, Auth Portal metrics. @see https://reui.io/preview/base/app-shell-7 */
export function SystemMonitorPopover() {
// Поллинг живёт только пока поповер открыт: закрытый монитор не должен
// каждые 30 секунд тянуть /ready, каталог и админские списки.
const [open, setOpen] = useState(false)
const meQ = useQuery(meQueryOptions)
const isAdmin = Boolean(meQ.data?.is_admin)
const readyQ = useQuery({
queryKey: ['ready', 'monitor'],
queryFn: async () => {
const res = await fetch('/ready')
if (!res.ok) throw new Error('not ready')
return (await res.json()) as { ok?: boolean }
},
enabled: open,
refetchInterval: 30_000,
retry: false,
})
const catalogQ = useQuery({
...catalogQueryOptions,
enabled: open,
refetchInterval: 30_000,
})
const usersQ = useQuery({
...usersQueryOptions,
enabled: isAdmin && open,
retry: false,
refetchInterval: 30_000,
})
const oidcQ = useQuery({
...oidcClientsQueryOptions,
enabled: isAdmin && open,
retry: false,
refetchInterval: 30_000,
})
const monitorFetched = readyQ.isFetched || catalogQ.isFetched
// До первого открытия данных нет — нейтральное состояние вместо ложной тревоги.
const apiOk = readyQ.isFetched
? readyQ.data?.ok === true || readyQ.isSuccess
: true
const appsCount = catalogQ.data?.apps.length ?? 0
const usersCount = isAdmin ? (usersQ.data?.length ?? 0) : null
const oidcCount = isAdmin ? (oidcQ.data?.length ?? 0) : null
const disabledUsers = isAdmin
? (usersQ.data ?? []).filter((u) => u.disabled).length
: 0
const metrics = useMemo<MonitorMetric[]>(
() => [
{
id: 'ready',
label: 'API',
value: apiOk ? 'OK' : '—',
unit: '',
percent: apiOk ? 100 : 15,
icon: <Activity aria-hidden />,
tone: apiOk ? 'success' : 'destructive',
alert: !apiOk,
},
{
id: 'apps',
label: 'Приложения',
value: String(appsCount),
unit: 'шт.',
percent: Math.min(100, appsCount * 20),
icon: <AppWindow aria-hidden />,
tone: appsCount > 0 ? 'success' : 'warning',
alert: appsCount === 0,
},
{
id: 'users',
label: 'Пользователи',
value: usersCount == null ? '—' : String(usersCount),
unit: usersCount == null ? '' : 'шт.',
percent:
usersCount == null ? 0 : Math.min(100, Math.max(20, usersCount * 10)),
icon: <Users aria-hidden />,
tone:
usersCount == null
? 'info'
: disabledUsers > 0
? 'warning'
: 'success',
alert: disabledUsers > 0,
},
{
id: 'oidc',
label: 'OIDC',
value: oidcCount == null ? '—' : String(oidcCount),
unit: oidcCount == null ? '' : 'шт.',
percent: oidcCount == null ? 0 : Math.min(100, Math.max(15, oidcCount * 25)),
icon: <KeyRound aria-hidden />,
tone: oidcCount == null ? 'info' : oidcCount > 0 ? 'success' : 'warning',
alert: false,
},
],
[apiOk, appsCount, disabledUsers, oidcCount, usersCount],
)
const spiking = monitorFetched && (metrics.some((m) => m.alert) || !apiOk)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<button
type="button"
aria-label="Монитор системы"
className={cn(
'relative inline-flex h-8 items-center gap-1.5 rounded-md border px-2 transition-colors outline-none',
'border-border hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring',
)}
/>
}
>
<span className="relative flex size-3.5 items-center justify-center">
<Activity
aria-hidden
className={cn(
'size-3.5 transition-colors',
spiking ? 'text-destructive' : 'text-muted-foreground',
)}
/>
{spiking ? (
<span className="bg-destructive/25 absolute inset-0 animate-ping rounded-full" aria-hidden />
) : null}
</span>
<span className="text-foreground hidden text-xs font-medium sm:inline">Система</span>
<Badge
variant={spiking ? 'destructive-light' : 'success-light'}
size="xs"
className="h-4 px-1.5 text-[10px]"
>
{spiking ? 'Внимание' : 'Норма'}
</Badge>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
<span className="text-foreground text-xs font-medium">Монитор Auth Portal</span>
<span className="text-muted-foreground text-[11px] tabular-nums">
{new Date().toLocaleTimeString('ru-RU')}
</span>
</div>
<div className="grid grid-cols-2">
{metrics.map((metric, i) => (
<div
key={metric.id}
className={cn(
i % 2 === 1 && 'border-border border-l',
i >= 2 && 'border-border border-t',
)}
>
<MetricCell metric={metric} />
</div>
))}
</div>
<div className="border-border text-muted-foreground border-t px-3 py-2 text-[11px]">
API:{' '}
<span className="text-foreground font-medium">{apiOk ? 'OK' : '—'}</span>
{isAdmin ? (
<>
{' · '}
Отключено:{' '}
<span className="text-foreground font-medium tabular-nums">{disabledUsers}</span>
</>
) : (
<>
{' · '}
Метрики пользователей — для админа
</>
)}
</div>
</PopoverContent>
</Popover>
)
}
+209
View File
@@ -0,0 +1,209 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
ChevronsUpDownIcon,
FingerprintIcon,
LogOutIcon,
MonitorIcon,
MoonIcon,
PaletteIcon,
SunIcon,
} from 'lucide-react'
import { useEffect, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useTheme } from 'next-themes'
import { Avatar, AvatarFallback } from '@authportal/ui/components/avatar'
import { Button } from '@authportal/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu'
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@authportal/ui/components/sidebar'
import { cn } from '@authportal/ui/lib/utils'
import { clearToken } from '@/lib/auth'
import { logout, meQueryOptions } from '@/queries/auth'
const THEMES = [
{
value: 'light',
label: 'Светлая',
icon: <SunIcon className="size-3.5" aria-hidden />,
},
{
value: 'dark',
label: 'Тёмная',
icon: <MoonIcon className="size-3.5" aria-hidden />,
},
{
value: 'system',
label: 'Системная',
icon: <MonitorIcon className="size-3.5" aria-hidden />,
},
] as const
function ThemeSegmentedToggle() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
const currentTheme = mounted ? (theme ?? 'system') : 'system'
return (
<div
role="radiogroup"
aria-label="Тема"
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
{THEMES.map(({ value, label, icon }) => {
const isActive = currentTheme === value
return (
<Button
key={value}
type="button"
role="radio"
aria-checked={isActive}
aria-label={label}
variant="ghost"
size="icon-xs"
onClick={() => setTheme(value)}
className={cn(
'rounded-full',
isActive
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{icon}
</Button>
)
})}
</div>
)
}
function initials(name: string, email: string): string {
const base = name.trim() || email.trim()
if (!base) return '?'
const parts = base.split(/\s+/).filter(Boolean)
if (parts.length >= 2) {
return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase()
}
return base.slice(0, 2).toUpperCase()
}
export function NavUser() {
const { isMobile } = useSidebar()
const { data: me } = useQuery(meQueryOptions)
const queryClient = useQueryClient()
const navigate = useNavigate()
const name = me?.name?.trim() || 'Пользователь'
const email = me?.email?.trim() || ''
const fallback = initials(name, email)
async function handleSignOut() {
try {
await logout()
} catch {
/* ignore */
}
clearToken()
queryClient.clear()
window.location.href = '/'
}
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton
size="lg"
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
/>
}
>
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg text-xs">
{fallback}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{name}</span>
<span className="truncate text-xs text-muted-foreground">
{email || '—'}
</span>
</div>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--anchor-width) min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="end"
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuLabel className="flex items-center gap-2 py-2 font-normal text-foreground">
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg text-xs">
{fallback}
</AvatarFallback>
</Avatar>
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{name}</span>
<span className="truncate text-xs text-muted-foreground">
{email || '—'}
</span>
</div>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => void navigate({ to: '/account' })}
>
<FingerprintIcon aria-hidden />
Безопасность
</DropdownMenuItem>
<DropdownMenuItem className="cursor-default focus:bg-transparent">
<PaletteIcon aria-hidden />
Тема
<div className="ml-auto">
<ThemeSegmentedToggle />
</div>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => void handleSignOut()}>
<LogOutIcon aria-hidden />
Выйти
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
+16
View File
@@ -0,0 +1,16 @@
import type { ReactNode } from 'react'
import { cn } from '@authportal/ui/lib/utils'
export function PageShell({
children,
className,
}: {
children: ReactNode
className?: string
}) {
return (
<div className={cn('flex flex-col gap-4 md:gap-6', className)}>
{children}
</div>
)
}
@@ -0,0 +1,299 @@
import { useEffect, useState, type FormEvent } from 'react'
import { useNavigate, useSearch } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { EyeIcon, EyeOffIcon, FingerprintIcon, HistoryIcon, ShieldIcon } from 'lucide-react'
import {
browserSupportsWebAuthn,
browserSupportsWebAuthnAutofill,
startAuthentication,
WebAuthnAbortService,
} from '@simplewebauthn/browser'
import type { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/browser'
import {
buildSsoRedirectUrl,
isPortalOidcAuthorizeUrl,
isReturnToAllowed,
type LoginResponse,
} from '@authportal/shared'
import { Button } from '@authportal/ui/components/button'
import { Field, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input'
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from '@authportal/ui/components/input-group'
import { Separator } from '@authportal/ui/components/separator'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { ensureAuthConfig, setToken } from '@/lib/auth'
import { ApiError } from '@/lib/api-client'
import { login, meQueryKey } from '@/queries/auth'
import { webauthnLogin, webauthnLoginOptions } from '@/queries/webauthn'
import { AuthLogo } from '@/components/auth-logo'
import { Badge } from '@/components/reui/badge'
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
const PERKS = [
{ icon: FingerprintIcon, title: 'Passkey вместо пароля', text: 'Отпечаток, Face ID или ключ безопасности' },
{ icon: ShieldIcon, title: 'Права по приложениям и разделам', text: 'Чтение, запись и администрирование для каждого сервиса' },
{ icon: HistoryIcon, title: 'Журнал входов и изменений', text: 'Кто, когда и откуда заходил в сервисы' },
] as const
export function PortalLoginForm() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const search = useSearch({ from: '/' }) as { return_to?: string }
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState<string | null>(null)
const [pending, setPending] = useState(false)
const [passkeySupported, setPasskeySupported] = useState(false)
async function applySession(res: LoginResponse) {
setToken(res.access_token)
queryClient.setQueryData(meQueryKey, res.user)
const returnTo = search.return_to
const { returnToAllowlist: allowlist, issuer } = await ensureAuthConfig()
if (returnTo && isReturnToAllowed(returnTo, allowlist)) {
if (isPortalOidcAuthorizeUrl(returnTo, issuer)) {
window.location.href = returnTo
return
}
window.location.href = buildSsoRedirectUrl(
returnTo,
res.access_token,
res.expires_at,
)
return
}
if (res.user.is_admin) {
await navigate({ to: '/admin' })
} else {
await navigate({ to: '/apps' })
}
}
async function runPasskeyLogin() {
const { challenge_id, options } = await webauthnLoginOptions()
const assertion = await startAuthentication({
optionsJSON: options as unknown as PublicKeyCredentialRequestOptionsJSON,
})
const res = await webauthnLogin(challenge_id, assertion, search.return_to)
await applySession(res)
}
useEffect(() => {
if (!browserSupportsWebAuthn()) return
setPasskeySupported(true)
let cancelled = false
async function startConditional() {
if (!(await browserSupportsWebAuthnAutofill())) return
try {
const { challenge_id, options } = await webauthnLoginOptions()
if (cancelled) return
const assertion = await startAuthentication({
optionsJSON:
options as unknown as PublicKeyCredentialRequestOptionsJSON,
useBrowserAutofill: true,
})
if (cancelled) return
setPending(true)
setError(null)
const res = await webauthnLogin(
challenge_id,
assertion,
search.return_to,
)
await applySession(res)
} catch {
/* abort / unsupported / user dismissed */
} finally {
if (!cancelled) setPending(false)
}
}
void startConditional()
return () => {
cancelled = true
WebAuthnAbortService.cancelCeremony()
}
// Login page mount only — return_to is stable for the visit.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
WebAuthnAbortService.cancelCeremony()
setError(null)
setPending(true)
const form = new FormData(event.currentTarget)
const email = String(form.get('email') ?? '')
const password = String(form.get('password') ?? '')
try {
const res = await login(email, password, search.return_to)
await applySession(res)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Не удалось войти')
} finally {
setPending(false)
}
}
async function handlePasskeyClick() {
WebAuthnAbortService.cancelCeremony()
setError(null)
setPending(true)
try {
await runPasskeyLogin()
} catch (err) {
setError(
err instanceof ApiError ? err.message : 'Не удалось войти с passkey',
)
} finally {
setPending(false)
}
}
const returnHost = (() => {
try {
return search.return_to ? new URL(search.return_to).host : null
} catch {
return null
}
})()
return (
<section className="bg-background flex min-h-svh min-w-0 items-center justify-center px-6 py-8">
<div className="grid w-full max-w-[1040px] items-center gap-12 md:grid-cols-2">
<div className="hidden flex-col gap-6 md:flex">
<div className="flex items-center gap-3">
<AuthLogo />
<div className="flex flex-col">
<b className="text-base font-semibold">Auth Portal</b>
<span className="text-muted-foreground text-[13px]">auth.shnt.top</span>
</div>
</div>
<h1 className="text-3xl leading-tight font-semibold tracking-[-0.02em] text-balance">
Один вход для всех сервисов shnt.top
</h1>
<p className="text-muted-foreground text-[15px] leading-relaxed text-pretty">
Войдите один раз — и открывайте CFDM, VPS Tracker, EvoBGP, EvoFirewall, CDN Manager, MikrotikManager и
Technitium DNS без повторного ввода пароля.
</p>
<Frame spacing="sm" className="max-w-[420px]">
<FramePanel className="p-1">
{PERKS.map((perk) => (
<div key={perk.title} className="flex items-start gap-3 px-4 py-2.5">
<IconTile variant="outline" aria-hidden className="shrink-0">
<perk.icon />
</IconTile>
<div className="flex flex-col">
<b className="text-sm font-medium">{perk.title}</b>
<span className="text-muted-foreground text-[13px]">{perk.text}</span>
</div>
</div>
))}
</FramePanel>
</Frame>
</div>
<Frame spacing="sm" className="w-full max-w-[420px] justify-self-center">
<FrameHeader className="gap-1 px-5 pt-5 pb-1">
<FrameTitle className="text-xl">Вход в портал</FrameTitle>
<FrameDescription className="text-sm">Введите email и пароль или используйте passkey</FrameDescription>
</FrameHeader>
<FramePanel className="flex flex-col gap-4 px-5 pt-4 pb-5">
{returnHost ? (
<div className="bg-muted/40 flex items-start gap-2.5 rounded-lg border px-3 py-2.5 text-[13px]" role="status">
<Badge variant="info-light" size="sm">
SSO
</Badge>
<span>
После входа вы вернётесь в <b className="font-semibold">{returnHost}</b>
</span>
</div>
) : null}
{error ? (
<Alert variant="destructive" role="alert">
<AlertTitle>Ошибка входа</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<FieldGroup className="gap-3.5">
<Field className="gap-2">
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
name="email"
type="email"
autoComplete="username webauthn"
placeholder="admin@shnt.top"
aria-invalid={error ? true : undefined}
required
/>
</Field>
<Field className="gap-2">
<FieldLabel htmlFor="password">Пароль</FieldLabel>
<InputGroup className="w-full">
<InputGroupInput
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
placeholder="Пароль"
aria-invalid={error ? true : undefined}
required
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
type="button"
size="icon-xs"
aria-label={showPassword ? 'Скрыть пароль' : 'Показать пароль'}
onClick={() => setShowPassword((v) => !v)}
>
{showPassword ? <EyeOffIcon /> : <EyeIcon />}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</Field>
</FieldGroup>
<Button type="submit" className="w-full" disabled={pending}>
{pending ? 'Вход…' : 'Войти'}
</Button>
</form>
{passkeySupported ? (
<>
<div className="flex items-center gap-3">
<Separator className="flex-1" />
<span className="text-muted-foreground text-xs">или</span>
<Separator className="flex-1" />
</div>
<Button type="button" variant="outline" className="w-full" disabled={pending} onClick={() => void handlePasskeyClick()}>
<FingerprintIcon aria-hidden="true" />
Войти с passkey
</Button>
<span className="text-muted-foreground text-center text-xs">
Пароль остаётся запасным входом. Passkey добавляется в разделе «Безопасность».
</span>
</>
) : null}
</FramePanel>
</Frame>
</div>
</section>
)
}
+481
View File
@@ -0,0 +1,481 @@
import { useState, type ReactNode } from 'react'
import { CircleAlertIcon, InfoIcon, TriangleAlertIcon, XIcon } from 'lucide-react'
import { toast } from 'sonner'
import { Alert, AlertAction, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { Badge } from '@/components/reui/badge'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@authportal/ui/components/alert-dialog'
import { Button } from '@authportal/ui/components/button'
import { cn } from '@authportal/ui/lib/utils'
import { Input } from '@authportal/ui/components/input'
import { Label } from '@authportal/ui/components/label'
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@authportal/ui/components/sheet'
import type { Tone } from './tone'
import { TONE_BADGE } from './tone'
const TONE_GLYPH: Record<Tone, string> = { ok: '●', wn: '▲', er: '✕', mu: '○', in: '' }
/** Плашка статуса: слово + глиф (● ▲ ✕ ○), чтобы цвет не был единственным признаком. */
export function StatusBadge({ tone, children }: { tone: Tone; children: ReactNode }) {
const glyph = TONE_GLYPH[tone]
return (
<Badge variant={TONE_BADGE[tone]} size="sm" className="whitespace-nowrap">
{glyph ? <span aria-hidden className="text-[0.55rem]">{glyph}</span> : null}
{children}
</Badge>
)
}
/** Заголовок страницы: название, описание, метка свежести и действия. */
export function PageHead({
title,
sub,
actions,
}: {
title: string
sub: string
actions?: ReactNode
}) {
return (
<header className="flex flex-wrap items-end justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<h1 className="text-xl font-semibold tracking-[-0.01em]">{title}</h1>
<p className="text-muted-foreground text-sm">{sub}</p>
</div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</header>
)
}
const ALERT_ICON = {
er: <CircleAlertIcon aria-hidden />,
wn: <TriangleAlertIcon aria-hidden />,
in: <InfoIcon aria-hidden />,
} as const
const ALERT_VARIANT = {
er: 'destructive',
wn: 'warning',
in: 'info',
} as const
export function SignalAlert({
tone,
title,
children,
action,
}: {
tone: 'er' | 'wn' | 'in'
title: string
children: ReactNode
action?: ReactNode
}) {
return (
<Alert variant={ALERT_VARIANT[tone]}>
{ALERT_ICON[tone]}
<AlertTitle>{title}</AlertTitle>
<AlertDescription>{children}</AlertDescription>
{action ? <AlertAction>{action}</AlertAction> : null}
</Alert>
)
}
/** Боковое окно (как CreateUserSheet): заголовок, прокручиваемое тело, подвал. */
export function PortalSheet({
open,
onOpenChange,
title,
description,
children,
footer,
size = 'md',
dirty,
}: {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description: string
children: ReactNode
footer?: ReactNode
/** Ширина панели: md — форма, lg — редактор с двумя колонками. */
size?: 'md' | 'lg'
/** Явный признак изменений (иначе ловим ввод в полях и переключатели). */
dirty?: boolean
}) {
const [touchedState, setTouched] = useState(false)
const touched = dirty ?? touchedState
const [guard, setGuard] = useState(false)
function requestChange(next: boolean) {
if (!next && touched) {
setGuard(true)
return
}
if (!next) setTouched(false)
onOpenChange(next)
}
return (
<>
<Sheet open={open} onOpenChange={requestChange}>
<SheetContent
side="right"
showCloseButton={false}
className={cn(
'inset-y-0 right-0 left-auto flex h-svh max-w-none flex-col gap-0 overflow-hidden rounded-none p-0 outline-none sm:max-w-none',
'data-[side=right]:sm:max-w-none',
size === 'lg'
? 'data-[side=right]:w-[min(45rem,100vw)]'
: 'data-[side=right]:w-[min(30rem,100vw)]',
)}
>
<SheetHeader className="shrink-0 gap-0 p-0">
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
<SheetTitle className="min-w-0 truncate text-base font-semibold">{title}</SheetTitle>
<SheetClose
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Закрыть"
className="text-muted-foreground hover:text-foreground"
/>
}
>
<XIcon aria-hidden="true" />
</SheetClose>
</div>
<SheetDescription className="text-muted-foreground px-4 pt-3 text-sm">
{description}
</SheetDescription>
</SheetHeader>
<div
className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 py-4"
onInput={() => setTouched(true)}
onClick={(e) => {
if ((e.target as HTMLElement).closest('[role=switch]')) setTouched(true)
}}
>
{children}
</div>
{footer ? (
<SheetFooter
className="flex-row flex-wrap justify-end gap-2 border-t px-4 py-3"
onClickCapture={(e) => {
// «Закрыть» и «Отмена» при изменённой форме тоже спрашивают подтверждение.
const label = (e.target as HTMLElement).closest('button')?.textContent?.trim()
if (touched && (label === 'Закрыть' || label === 'Отмена')) {
e.stopPropagation()
setGuard(true)
}
}}
>{footer}</SheetFooter>
) : null}
</SheetContent>
</Sheet>
<AlertDialog open={guard} onOpenChange={setGuard}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Закрыть без сохранения?</AlertDialogTitle>
<AlertDialogDescription>Изменения в форме будут потеряны.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Остаться</AlertDialogCancel>
<Button
type="button"
variant="destructive"
onClick={() => {
setGuard(false)
setTouched(false)
onOpenChange(false)
}}
>
Закрыть без сохранения
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
/** Подтверждение опасного действия вводом слова (email, «сбросить», «очистить»…). */
export function TypedConfirm({
open,
onOpenChange,
title,
description,
expected,
confirmLabel,
onConfirm,
}: {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description: ReactNode
expected: string
confirmLabel: string
onConfirm: () => void
}) {
const [value, setValue] = useState('')
const matches = value.trim().toLowerCase() === expected.toLowerCase()
return (
<AlertDialog
open={open}
onOpenChange={(next) => {
if (!next) setValue('')
onOpenChange(next)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex flex-col gap-2">
<Label htmlFor="typed-confirm">
Введите <span className="font-mono font-semibold">{expected}</span> для подтверждения
</Label>
<Input
id="typed-confirm"
value={value}
autoComplete="off"
onChange={(e) => setValue(e.target.value)}
aria-invalid={value.length > 0 && !matches}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<Button
type="button"
variant="destructive"
disabled={!matches}
onClick={() => {
setValue('')
onConfirm()
}}
>
{confirmLabel}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
export function KvFrame({
title,
description,
items,
}: {
title: string
description: string
items: Array<{ label: string; value: ReactNode }>
}) {
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full">
<FrameHeader>
<FrameTitle>{title}</FrameTitle>
<FrameDescription>{description}</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
<dl className="grid divide-y @container sm:grid-cols-2 sm:divide-y-0">
{items.map((item) => (
<div
key={item.label}
className="flex flex-col gap-0.5 border-b px-4 py-3 last:border-b-0 sm:odd:border-r"
>
<dt className="text-muted-foreground text-xs">{item.label}</dt>
<dd className="text-sm font-medium break-words">{item.value}</dd>
</div>
))}
</dl>
</FramePanel>
</Frame>
)
}
/** Список «значение + подпись + копировать» (параметры issuer). */
export function CopyList({
title,
description,
items,
}: {
title: string
description: string
items: Array<{ label: string; value: string }>
}) {
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full">
<FrameHeader>
<FrameTitle>{title}</FrameTitle>
<FrameDescription>{description}</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
<ul className="flex flex-col">
{items.map((item) => (
<li
key={item.label}
className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b px-4 py-2.5 last:border-b-0"
>
<code className="min-w-0 flex-1 basis-72 font-mono text-[13px] break-all">{item.value}</code>
<span className="text-muted-foreground min-w-0 flex-1 basis-48 text-[13px]">{item.label}</span>
<Button
type="button"
variant="outline"
aria-label={`Скопировать: ${item.label}`}
onClick={() => {
void navigator.clipboard?.writeText(item.value).catch(() => undefined)
toast.success(`${item.label} скопирован`)
}}
>
Копировать
</Button>
</li>
))}
</ul>
</FramePanel>
</Frame>
)
}
/** Компактный список «ключ — значение» для боковых окон. */
export function KvList({ items }: { items: Array<[string, string]> }) {
return (
<dl className="flex flex-col divide-y rounded-lg border">
{items.map(([k, v]) => (
<div key={k} className="flex flex-col gap-0.5 px-3 py-2">
<dt className="text-muted-foreground text-xs">{k}</dt>
<dd className="text-sm break-words">{v}</dd>
</div>
))}
</dl>
)
}
export function CodeBlock({ label, children }: { label: string; children: string }) {
return (
<div className="flex flex-col gap-1">
<span className="text-muted-foreground text-xs">{label}</span>
<pre className="bg-muted/40 overflow-x-auto rounded-md border p-3 font-mono text-xs leading-relaxed">{children}</pre>
</div>
)
}
/** Сегментированный переключатель на Tabs (вид таблицы, период). */
export function Segmented({
label,
options,
value,
onChange,
}: {
label: string
options: string[]
value: number
onChange: (index: number) => void
}) {
return (
<Tabs value={String(value)} onValueChange={(v) => onChange(Number(v))} aria-label={label} className="max-w-full">
<TabsList className="max-w-full justify-start overflow-x-auto">
{options.map((o, i) => (
<TabsTrigger key={o} value={String(i)}>
{o}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)
}
/** Плашка строки с накопленной правкой: «Не сохранено» или «Будет удалено». */
export function PendingChip({ mark }: { mark: 'new' | 'dirty' | 'deleted' }) {
return (
<Badge variant={mark === 'deleted' ? 'destructive-light' : 'warning-light'} size="sm" className="whitespace-nowrap">
{mark === 'deleted' ? 'Будет удалено' : 'Не сохранено'}
</Badge>
)
}
/** Вкладки страницы как на досках: линия-подчёркивание, число — мелким индексом. */
export function PageTabsList({ children }: { children: ReactNode }) {
return (
<TabsList variant="line" className="h-10! w-full justify-start gap-0.5 overflow-x-auto">
{children}
</TabsList>
)
}
export function PageTab({ value, label, count }: { value: string; label: string; count?: number | string }) {
return (
<TabsTrigger value={value} className="gap-1.5 px-4 text-sm">
{label}
{count !== undefined ? <span className="text-muted-foreground font-mono text-xs font-normal">{count}</span> : null}
</TabsTrigger>
)
}
/** Строка настройки как на досках: слева название и описание, справа элемент управления. */
export function SettingRow({ title, description, children }: { title: string; description: string; children: ReactNode }) {
return (
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b px-4 py-3.5 last:border-b-0">
<div className="flex min-w-0 flex-1 basis-64 flex-col gap-0.5">
<b className="text-sm font-medium">{title}</b>
<span className="text-muted-foreground text-[13px]">{description}</span>
</div>
<div className="shrink-0">{children}</div>
</div>
)
}
/** Frame с заголовком, итоговой плашкой справа и строками настроек (блок «form» досок). */
export function FormFrame({
title,
description,
badge,
tone = 'ok',
children,
}: {
title: string
description: string
badge: string
tone?: Tone
children: ReactNode
}) {
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<FrameTitle>{title}</FrameTitle>
<FrameDescription>{description}</FrameDescription>
</div>
<StatusBadge tone={tone}>{badge}</StatusBadge>
</FrameHeader>
<FramePanel className="p-0">{children}</FramePanel>
</Frame>
)
}
@@ -0,0 +1,142 @@
import type { ReactNode } from 'react'
import { Badge } from '@/components/reui/badge'
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import { cn } from '@authportal/ui/lib/utils'
import type { Tone } from './tone'
import { StatusBadge } from './bits'
export type AppCardItem = {
id: string
title: string
desc: string
badge: string
badgeVariant?: 'info-light' | 'warning-light' | 'outline'
meta: string
icon: ReactNode
onSelect?: () => void
}
/**
* Плитки приложений как на доске «Приложения»: Frame с шапкой, внутри сетка auto-fit от 260px,
* в плитке иконка, название, плашка действия, описание и адрес моноширинным шрифтом.
*/
export function AppCards({
title,
description,
items,
action,
}: {
title: string
description: string
items: AppCardItem[]
action?: ReactNode
}) {
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<FrameTitle>{title}</FrameTitle>
<FrameDescription className="text-pretty">{description}</FrameDescription>
</div>
{action}
</FrameHeader>
<FramePanel className="p-3">
<ul className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,16.25rem),1fr))] gap-3">
{items.map((c) => {
const body = (
<>
<div className="flex items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-2.5">
<IconTile variant="elevated" size="sm" aria-hidden className="text-muted-foreground">
{c.icon}
</IconTile>
<b className="min-w-0 text-sm leading-tight font-semibold">{c.title}</b>
</span>
<Badge variant={c.badgeVariant ?? 'info-light'} size="sm" className="shrink-0">
{c.badge}
</Badge>
</div>
<p className="text-muted-foreground mt-1.5 text-[13px] text-pretty">{c.desc}</p>
<p className="text-muted-foreground mt-2 truncate font-mono text-[11px]">{c.meta}</p>
</>
)
const cls = 'bg-muted/25 w-full rounded-lg border p-3.5 text-left'
return (
<li key={c.id} className="min-w-0">
{c.onSelect ? (
<button
type="button"
onClick={c.onSelect}
aria-label={`${c.title}: ${c.badge}`}
className={cn(cls, 'hover:bg-muted/50 focus-visible:ring-ring transition-colors outline-none focus-visible:ring-2')}
>
{body}
</button>
) : (
<div className={cls}>{body}</div>
)}
</li>
)
})}
</ul>
</FramePanel>
</Frame>
)
}
const DOT: Record<Tone, string> = {
ok: 'bg-success',
wn: 'bg-warning',
er: 'bg-destructive',
mu: 'bg-muted-foreground',
in: 'bg-chart-4',
}
/**
* Лента событий как на доске «Изменения»: время, точка важности с линией, плашка, заголовок
* и строка «кто · источник · IP».
*/
export function ActivityFeed({
title,
description,
items,
action,
}: {
title: string
description: string
items: Array<{ t: string; title: string; text: string; label: string; tone: Tone }>
action?: ReactNode
}) {
return (
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<FrameTitle>{title}</FrameTitle>
<FrameDescription className="text-pretty">{description}</FrameDescription>
</div>
{action}
</FrameHeader>
<FramePanel className="py-1.5">
<ol>
{items.map((e) => (
<li key={`${e.t}-${e.title}`} className="grid grid-cols-[3.9rem_1rem_minmax(0,1fr)] items-start gap-2.5 px-4 py-2 text-[13px]">
<span className="text-muted-foreground pt-px font-mono text-xs tabular-nums">{e.t}</span>
<span aria-hidden className="flex h-full flex-col items-center">
<i className={cn('mt-1 size-[9px] rounded-full', DOT[e.tone])} />
<u className="bg-border mt-0.5 min-h-4.5 w-px flex-1 no-underline" />
</span>
<span className="min-w-0">
<span className="mr-2 inline-block align-middle">
<StatusBadge tone={e.tone}>{e.label}</StatusBadge>
</span>
<b className="font-medium">{e.title}</b>
<span className="text-muted-foreground mt-0.5 block text-xs">{e.text}</span>
</span>
</li>
))}
</ol>
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,83 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { ChromeConfigContext, UiStoreContext, type ChromeConfig, type Contrast, type Density, type UiStore } from './chrome-store'
export function ChromeConfigProvider({ value, children }: { value: ChromeConfig; children: ReactNode }) {
return <ChromeConfigContext.Provider value={value}>{children}</ChromeConfigContext.Provider>
}
function readStored<T>(key: string, fallback: T): T {
try {
const raw = window.localStorage.getItem(key)
return raw ? (JSON.parse(raw) as T) : fallback
} catch {
return fallback
}
}
function writeStored(key: string, value: unknown) {
try {
window.localStorage.setItem(key, JSON.stringify(value))
} catch {
/* без сохранения настроек */
}
}
/** Плотность, контраст, закреплённые пункты и палитра ⌘K. Атрибуты html[data-density|contrast] читает portal-theme.css. */
export function PortalUiProvider({ children, storageKey = 'portal-ui' }: { children: ReactNode; storageKey?: string }) {
const [paletteOpen, setPaletteOpen] = useState(false)
const [density, setDensityState] = useState<Density>(() => readStored(`${storageKey}:density`, 'comfortable'))
const [contrast, setContrastState] = useState<Contrast>(() => readStored(`${storageKey}:contrast`, 'standard'))
const [pins, setPins] = useState<string[]>(() => readStored(`${storageKey}:pins`, []))
const setDensity = useCallback(
(v: Density) => {
setDensityState(v)
writeStored(`${storageKey}:density`, v)
},
[storageKey],
)
const setContrast = useCallback(
(v: Contrast) => {
setContrastState(v)
writeStored(`${storageKey}:contrast`, v)
},
[storageKey],
)
const togglePin = useCallback(
(id: string) => {
setPins((prev) => {
const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
writeStored(`${storageKey}:pins`, next)
return next
})
},
[storageKey],
)
useEffect(() => {
const root = document.documentElement
root.dataset.density = density
root.dataset.contrast = contrast
return () => {
delete root.dataset.density
delete root.dataset.contrast
}
}, [density, contrast])
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
setPaletteOpen((v) => !v)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
const value = useMemo<UiStore>(
() => ({ paletteOpen, setPaletteOpen, pins, togglePin, density, setDensity, contrast, setContrast }),
[paletteOpen, pins, togglePin, density, setDensity, contrast, setContrast],
)
return <UiStoreContext.Provider value={value}>{children}</UiStoreContext.Provider>
}
@@ -0,0 +1,86 @@
import { createContext, useContext, type ComponentType, type ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
export type NavItem = {
id: string
label: string
to: string
icon: LucideIcon
group: string
/** Точка проблемы в углу иконки. */
problem?: { tone: 'er' | 'wn'; label: string }
}
export type MonitorItem = {
id: string
tone: 'er' | 'wn' | 'ok'
title: string
text: string
age: string
to: string
}
export type MonitorMetric = { l: string; v: string; p: number; tone: 'warning' | 'success' }
export type PaletteAction = {
id: string
label: string
/** Горячая буква справа. */
hint: string
icon: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
to: string
}
export type PaletteObject = {
id: string
group: string
label: string
hint: string
dot?: 'ok' | 'er' | 'mu'
to: string
}
/** Данные оболочки: меню, монитор, палитра и пользователь. В макете — суррогатные, в портале — из API. */
export type ChromeConfig = {
navItems: NavItem[]
navGroups: string[]
/** Верх бокового меню: бренд и переключатель приложений. */
brand: ReactNode
user: { name: string; email: string; initials: string }
securityTo: string
onLogout: () => void
fresh: (pathname: string) => { badge: string; text: string }
crumbFor?: (pathname: string, label: string | undefined) => string
monitor: { items: MonitorItem[]; metrics: MonitorMetric[]; updated: string; footerTo: string; footerLabel: string }
palette: { actions: PaletteAction[]; objects: PaletteObject[]; assistantTo: string; example: string }
}
export const ChromeConfigContext = createContext<ChromeConfig | null>(null)
export function useChromeConfig(): ChromeConfig {
const ctx = useContext(ChromeConfigContext)
if (!ctx) throw new Error('ChromeConfigProvider отсутствует')
return ctx
}
export type Density = 'comfortable' | 'compact'
export type Contrast = 'standard' | 'high'
export type UiStore = {
paletteOpen: boolean
setPaletteOpen: (open: boolean) => void
pins: string[]
togglePin: (id: string) => void
density: Density
setDensity: (v: Density) => void
contrast: Contrast
setContrast: (v: Contrast) => void
}
export const UiStoreContext = createContext<UiStore | null>(null)
export function useUiStore(): UiStore {
const ctx = useContext(UiStoreContext)
if (!ctx) throw new Error('PortalUiProvider отсутствует')
return ctx
}
+569
View File
@@ -0,0 +1,569 @@
import {
useMemo,
useState,
type ComponentType,
} from 'react'
import {
Link,
useNavigate,
useRouterState,
} from '@tanstack/react-router'
import {
ActivityIcon,
ArrowRightIcon,
ChevronDownIcon,
ChevronsUpDownIcon,
FingerprintIcon,
InfoIcon,
LogOutIcon,
SearchIcon,
StarIcon,
MoonIcon,
} from 'lucide-react'
import {
useTheme,
} from 'next-themes'
import {
Badge,
} from '@/components/reui/badge'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
} from '@authportal/ui/components/breadcrumb'
import {
Button,
} from '@authportal/ui/components/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@authportal/ui/components/dialog'
import {
Kbd,
} from '@authportal/ui/components/kbd'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@authportal/ui/components/popover'
import {
Separator,
} from '@authportal/ui/components/separator'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarSeparator,
SidebarTrigger,
useSidebar,
} from '@authportal/ui/components/sidebar'
import {
cn,
} from '@authportal/ui/lib/utils'
import {
Segmented,
} from './bits'
import {
useChromeConfig,
useUiStore,
type NavItem,
} from './chrome-store'
const LABEL_CLASS = 'text-muted-foreground text-[11px] font-medium uppercase'
function PortalNavUser() {
const { isMobile } = useSidebar()
const { theme, setTheme, resolvedTheme } = useTheme()
const { density, setDensity, contrast, setContrast } = useUiStore()
const { user, securityTo, onLogout } = useChromeConfig()
const navigate = useNavigate()
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
return (
<SidebarMenu>
<SidebarMenuItem>
<Popover>
<PopoverTrigger
render={
<SidebarMenuButton
size="lg"
aria-label={`Профиль и настройки интерфейса: ${user.name}, ${user.email}`}
className="bg-muted/40 aria-expanded:bg-muted"
/>
}
>
<div className="bg-background text-foreground flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
{user.initials}
</div>
<div className="grid min-w-0 flex-1 text-left leading-tight">
<span className="truncate text-sm font-semibold">{user.name}</span>
<span className="text-muted-foreground truncate text-xs">{user.email}</span>
</div>
<ChevronsUpDownIcon className="text-muted-foreground ml-auto size-4" aria-hidden />
</PopoverTrigger>
<PopoverContent side={isMobile ? 'top' : 'right'} align="end" sideOffset={8} className="w-72 gap-3">
<div className="flex items-center gap-2.5">
<div className="bg-background flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium">
{user.initials}
</div>
<div className="grid leading-tight">
<span className="text-sm font-semibold">{user.name}</span>
<span className="text-muted-foreground text-xs">{user.email}</span>
</div>
</div>
<Separator />
<button
type="button"
onClick={() => void navigate({ to: securityTo })}
className="hover:bg-muted flex items-center gap-2 rounded-md px-1 py-1 text-left text-sm"
>
<FingerprintIcon className="text-muted-foreground size-4" aria-hidden />
Безопасность
</button>
<div className="flex flex-col gap-1.5">
<span className={LABEL_CLASS}>Тема</span>
<Segmented
label="Тема"
options={['Тёмная', 'Светлая']}
value={current === 'light' ? 1 : 0}
onChange={(i) => setTheme(i === 1 ? 'light' : 'dark')}
/>
<span className={LABEL_CLASS}>Плотность</span>
<Segmented
label="Плотность"
options={['Обычная', 'Компактная']}
value={density === 'compact' ? 1 : 0}
onChange={(i) => setDensity(i === 1 ? 'compact' : 'comfortable')}
/>
<span className={LABEL_CLASS}>Контраст</span>
<Segmented
label="Контраст"
options={['Стандартный', 'Высокий']}
value={contrast === 'high' ? 1 : 0}
onChange={(i) => setContrast(i === 1 ? 'high' : 'standard')}
/>
</div>
<Separator />
<button
type="button"
onClick={onLogout}
className="text-muted-foreground hover:text-foreground flex items-center gap-2 text-left text-sm"
>
<LogOutIcon className="size-4" aria-hidden />
Выйти
</button>
</PopoverContent>
</Popover>
</SidebarMenuItem>
</SidebarMenu>
)
}
const DOT_CLASS = { er: 'bg-destructive', wn: 'bg-warning' } as const
function NavEntry({ item, pathname, pinned, onTogglePin }: { item: NavItem; pathname: string; pinned: boolean; onTogglePin: () => void }) {
const Icon = item.icon
const active = pathname === item.to || pathname.startsWith(`${item.to}/`)
return (
<SidebarMenuItem>
<SidebarMenuButton
tooltip={item.problem ? `${item.label} · ${item.problem.label}` : item.label}
isActive={active}
className="relative pr-8 group-data-[collapsible=icon]:pr-2!"
render={<Link to={item.to} />}
>
<Icon className="size-4" />
<span>{item.label}</span>
{item.problem ? (
<span
role="img"
aria-label={item.problem.label}
className={cn(
'ml-auto size-2 shrink-0 rounded-full group-data-[collapsible=icon]:absolute group-data-[collapsible=icon]:top-1 group-data-[collapsible=icon]:right-1 group-data-[collapsible=icon]:ml-0 group-data-[collapsible=icon]:size-1.5 group-data-[collapsible=icon]:ring-2 group-data-[collapsible=icon]:ring-sidebar',
DOT_CLASS[item.problem.tone],
)}
/>
) : null}
</SidebarMenuButton>
<SidebarMenuAction
showOnHover={!pinned}
aria-label={pinned ? `Открепить ${item.label}` : `Закрепить ${item.label}`}
aria-pressed={pinned}
onClick={onTogglePin}
>
<StarIcon className={cn(pinned && 'fill-current')} aria-hidden />
</SidebarMenuAction>
</SidebarMenuItem>
)
}
export function PortalSidebar() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const { pins, togglePin } = useUiStore()
const { navItems, navGroups, brand } = useChromeConfig()
const [closed, setClosed] = useState<Record<string, boolean>>({})
const pinned = navItems.filter((i) => pins.includes(i.id))
return (
<Sidebar collapsible="icon">
<SidebarHeader className="pb-1.5">
{brand}
</SidebarHeader>
<SidebarContent>
{pinned.length > 0 ? (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel className="text-[11px] font-semibold tracking-wide uppercase">Закреплённые</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{pinned.map((item) => (
<NavEntry key={item.id} item={item} pathname={pathname} pinned onTogglePin={() => togglePin(item.id)} />
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
) : null}
{navGroups.map((group, gi) => (
<div key={group} className="flex flex-col">
{gi > 0 ? <SidebarSeparator className="mx-2 my-1 h-px shrink-0 hidden group-data-[collapsible=icon]:block" /> : null}
<SidebarGroup className="py-0">
<SidebarGroupLabel
render={
<button
type="button"
aria-expanded={!closed[group]}
onClick={() => setClosed((c) => ({ ...c, [group]: !c[group] }))}
/>
}
className="h-7 cursor-pointer justify-between text-[11px] font-semibold tracking-wide uppercase"
>
{group}
<ChevronDownIcon className={cn('size-3.5 transition-transform', closed[group] && '-rotate-90')} aria-hidden />
</SidebarGroupLabel>
<SidebarGroupContent className={cn(closed[group] && 'group-data-[collapsible=icon]:block hidden')}>
<SidebarMenu className="gap-px">
{navItems.filter((i) => i.group === group).map((item) => (
<NavEntry
key={item.id}
item={item}
pathname={pathname}
pinned={pins.includes(item.id)}
onTogglePin={() => togglePin(item.id)}
/>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</div>
))}
</SidebarContent>
<SidebarFooter>
<PortalNavUser />
</SidebarFooter>
</Sidebar>
)
}
/** Единый центр состояния: внимание + сводка + список проблем. Адаптивен по ширине окна, как на досках. */
function MonitorPopover() {
const { monitor } = useChromeConfig()
const MONITOR_ITEMS = monitor.items
const MONITOR_METRICS = monitor.metrics
const bad = MONITOR_ITEMS.filter((m) => m.tone !== 'ok')
const errors = bad.filter((m) => m.tone === 'er').length
const warnings = bad.length - errors
const ok = MONITOR_ITEMS.length - bad.length
return (
<Popover>
<PopoverTrigger
render={
<Button
variant="outline"
type="button"
aria-label={`Состояние системы: внимание, ${errors} сбой, ${warnings} предупреждения`}
className="border-warning/40 bg-warning/10 hover:bg-warning/20 aria-expanded:bg-warning/20 relative h-8 gap-2 px-2 max-[760px]:w-8 max-[760px]:border-transparent max-[760px]:bg-transparent max-[760px]:px-0"
/>
}
>
<ActivityIcon className="text-warning size-4" aria-hidden />
<span className="text-warning hidden text-sm font-medium min-[1000px]:inline">Внимание</span>
<span className="hidden items-center gap-1.5 text-sm tabular-nums min-[1240px]:inline-flex">
<span className="bg-destructive size-1.5 rounded-full" aria-hidden />
{errors}
<span className="bg-warning size-1.5 rounded-full" aria-hidden />
{warnings}
</span>
<span className="bg-destructive hidden size-4.5 min-w-4.5 items-center justify-center rounded-full px-1 text-[11px] font-semibold text-white min-[760px]:inline-flex min-[1240px]:hidden">
{bad.length}
</span>
<span className="bg-destructive ring-background absolute top-1 right-1 size-2 rounded-full ring-2 min-[760px]:hidden" aria-hidden />
</PopoverTrigger>
<PopoverContent align="end" className="flex w-[24rem] max-w-[calc(100vw-1.5rem)] flex-col gap-2 p-2">
<div className="flex items-start justify-between gap-2 px-1.5 pt-1.5">
<div className="grid leading-tight">
<b className="text-sm font-semibold">Состояние системы</b>
<span className="text-muted-foreground text-xs">{monitor.updated}</span>
</div>
<Badge variant={errors > 0 ? "destructive-light" : warnings > 0 ? "warning-light" : "success-light"} size="sm">{errors > 0 ? "Сбой" : warnings > 0 ? "Внимание" : "Норма"}</Badge>
</div>
<div className="grid grid-cols-3 gap-1.5">
{[
{ n: errors, l: 'сбой', c: 'text-destructive' },
{ n: warnings, l: 'предупреждения', c: 'text-warning' },
{ n: ok, l: 'в норме', c: 'text-success' },
].map((x) => (
<span key={x.l} className="bg-muted/55 text-muted-foreground flex flex-col rounded-lg px-2.5 py-2 text-[11.5px]">
<b className={cn('text-lg leading-tight font-semibold tabular-nums', x.c)}>{x.n}</b>
{x.l}
</span>
))}
</div>
<ul className="flex flex-col gap-0.5">
{MONITOR_ITEMS.filter((m) => m.tone !== 'ok').map((m) => (
<li key={m.id}>
<Link to={m.to} className="hover:bg-muted/55 flex items-center gap-2.5 rounded-md px-1.5 py-2 outline-none focus-visible:bg-muted/55">
<span aria-hidden className={cn('size-2 shrink-0 rounded-full', m.tone === 'er' ? 'bg-destructive' : 'bg-warning')} />
<span className="grid min-w-0 flex-1 leading-tight">
<b className="truncate text-[13px] font-medium">{m.title}</b>
<span className="text-muted-foreground truncate text-xs">{m.text}</span>
</span>
<span className="text-muted-foreground shrink-0 text-[11.5px]">{m.age}</span>
</Link>
</li>
))}
</ul>
<div className="grid grid-cols-2 gap-1.5">
{MONITOR_METRICS.map((m) => (
<div key={m.l} className="bg-muted/55 flex min-w-0 flex-col gap-1.5 rounded-lg px-3 py-2.5">
<span className="text-muted-foreground text-xs leading-snug">{m.l}</span>
<b className={cn('text-base font-semibold whitespace-nowrap tabular-nums', m.tone === 'success' ? 'text-success' : 'text-warning')}>{m.v}</b>
<div className="bg-muted-foreground/25 mt-auto h-[3px] overflow-hidden rounded-full">
<i className={cn('block h-full rounded-full', m.tone === 'success' ? 'bg-success' : 'bg-warning')} style={{ width: `${m.p}%` }} />
</div>
</div>
))}
</div>
<Link to={monitor.footerTo} className="text-muted-foreground hover:text-foreground flex items-center justify-between px-1.5 pt-1 pb-0.5 text-sm">
{monitor.footerLabel}
<ArrowRightIcon className="size-3.5" aria-hidden />
</Link>
</PopoverContent>
</Popover>
)
}
function FreshnessPopover() {
const pathname = useRouterState({ select: (st) => st.location.pathname })
const { fresh: freshFor } = useChromeConfig()
const fresh = freshFor(pathname)
return (
<Popover>
<PopoverTrigger
render={
<Button type="button" variant="ghost" size="icon" aria-label="Источники и свежесть данных" title="Источники и свежесть данных" />
}
>
<InfoIcon aria-hidden />
</PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2">
<Badge variant="success-light" size="sm" className="w-fit">{fresh.badge}</Badge>
<p className="text-muted-foreground text-sm">{fresh.text}</p>
</PopoverContent>
</Popover>
)
}
export function PortalHeader() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const { setPaletteOpen } = useUiStore()
const { navItems, crumbFor } = useChromeConfig()
const current = navItems.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`))
return (
<header className="bg-background/80 sticky top-0 z-20 flex min-h-12 shrink-0 items-center gap-2 border-b px-4 py-1.5 backdrop-blur md:px-6">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb className="min-w-0">
<BreadcrumbList className="flex-nowrap">
<BreadcrumbItem className="min-w-0">
<BreadcrumbPage className="truncate">{crumbFor ? crumbFor(pathname, current?.label) : (current?.label ?? 'Auth Portal')}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex shrink-0 items-center gap-2">
<FreshnessPopover />
<Button
type="button"
variant="outline"
size="icon"
aria-label="Командная палитра (⌘K)"
className="md:hidden"
onClick={() => setPaletteOpen(true)}
>
<SearchIcon aria-hidden />
</Button>
<MonitorPopover />
</div>
</header>
)
}
type PaletteEntry = {
id: string
group: string
label: string
hint: string
icon?: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
dot?: 'ok' | 'er' | 'mu'
run: () => void
}
const DOT_BG = { ok: 'bg-success', er: 'bg-destructive', mu: 'bg-muted-foreground' } as const
/** Палитра ⌘K как на доске: 640px, группы заглавными, подсказки моноширинным справа, подвал с подсказкой. */
export function CommandPalette() {
const { paletteOpen, setPaletteOpen } = useUiStore()
const { navItems, palette } = useChromeConfig()
const navigate = useNavigate()
const { theme, setTheme, resolvedTheme } = useTheme()
const [query, setQuery] = useState('')
const [active, setActive] = useState(0)
const shown = useMemo(() => {
const q = query.trim().toLowerCase()
const match = (...t: string[]) => !q || t.some((x) => x.toLowerCase().includes(q))
const go = (to: string) => () => void navigate({ to })
const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
const list: PaletteEntry[] = [
...[
...palette.actions.map((x) => ({ id: x.id, label: x.label, hint: x.hint, icon: x.icon, run: go(x.to) })),
{ id: 'act-theme', label: 'Переключить тему', hint: 'T', icon: MoonIcon, run: () => setTheme(current === 'dark' ? 'light' : 'dark') },
]
.filter((e) => match(e.label))
.map((e) => ({ ...e, group: 'Действия' })),
...navItems.filter((i) => match(i.label, i.group))
.slice(0, 7)
.map((i) => ({ id: `nav-${i.id}`, group: 'Разделы', label: i.label, hint: i.group, icon: i.icon, run: go(i.to) })),
...Object.values(
palette.objects
.filter((o) => match(o.label, o.hint))
.reduce<Record<string, typeof palette.objects>>((acc, o) => ((acc[o.group] ??= []).push(o), acc), {}),
).flatMap((g) => g.slice(0, 5).map((o) => ({ id: o.id, group: o.group, label: o.label, hint: o.hint, dot: o.dot, run: go(o.to) }))),
]
// Запрос из трёх и более слов — черновик ассистента (как на доске «Палитра ⌘K»).
if (query.trim().split(/\s+/).filter(Boolean).length >= 3) {
return [
{
id: 'ai',
group: 'Ассистент · запрос на естественном языке',
label: `Понял так: «${query.trim()}»`,
hint: 'Черновик: запрос разобран автоматически, результат нужно проверить.',
run: () => void navigate({ to: palette.assistantTo }),
} as PaletteEntry,
...list,
]
}
return list
}, [query, navigate, theme, resolvedTheme, setTheme, navItems, palette])
const groups = useMemo(() => {
const map = new Map<string, Array<PaletteEntry & { index: number }>>()
shown.forEach((e, index) => {
const list = map.get(e.group) ?? []
list.push({ ...e, index })
map.set(e.group, list)
})
return [...map.entries()]
}, [shown])
function close() {
setPaletteOpen(false)
setQuery('')
setActive(0)
}
return (
<Dialog open={paletteOpen} onOpenChange={(o) => (o ? setPaletteOpen(true) : close())}>
<DialogContent className="top-[90px] w-[min(40rem,94vw)] max-w-none translate-y-0 gap-0 overflow-hidden p-0 sm:max-w-none" showCloseButton={false}>
<DialogTitle className="sr-only">Командная палитра</DialogTitle>
<DialogDescription className="sr-only">Разделы, пользователи, приложения и действия</DialogDescription>
<div className="flex items-center gap-2.5 border-b px-4 py-3.5">
<SearchIcon className="text-muted-foreground size-[18px]" aria-hidden />
<input
autoFocus
value={query}
aria-label="Поиск и команды"
placeholder="Раздел, пользователь, приложение или действие…"
className="min-w-0 flex-1 bg-transparent text-[15px] outline-none"
onChange={(e) => {
setQuery(e.target.value)
setActive(0)
}}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') {
e.preventDefault()
setActive((a) => Math.min(a + 1, shown.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActive((a) => Math.max(a - 1, 0))
} else if (e.key === 'Enter') {
e.preventDefault()
shown[active]?.run()
close()
}
}}
/>
<Kbd>Esc</Kbd>
</div>
<div className="max-h-[430px] overflow-y-auto" role="listbox" aria-label="Результаты">
{groups.length === 0 ? (
<p className="text-muted-foreground px-4 py-6 text-center text-sm">Ничего не найдено</p>
) : (
groups.map(([group, list]) => (
<div key={group} className="flex flex-col">
<div className="text-muted-foreground px-4 pt-2.5 pb-1 text-[11px] tracking-[0.05em] uppercase">{group}</div>
{list.map((e) => {
const Icon = e.icon
return (
<button
key={e.id}
type="button"
role="option"
aria-selected={e.index === active}
onMouseEnter={() => setActive(e.index)}
onClick={() => {
e.run()
close()
}}
className={cn('flex w-full items-center gap-3 px-4 py-[9px] text-left text-sm outline-none', e.index === active && 'bg-muted/60')}
>
{Icon ? <Icon className="size-4 shrink-0" aria-hidden /> : null}
{e.dot ? <span className={cn('size-2 shrink-0 rounded-full', DOT_BG[e.dot])} aria-hidden /> : null}
<span className="min-w-0 truncate">{e.label}</span>
<span className="text-muted-foreground ml-auto truncate font-mono text-xs">{e.hint}</span>
</button>
)
})}
</div>
))
)}
</div>
<div className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 border-t px-4 py-2 text-[11.5px]">
<span>↑↓ выбор</span>
<span>↵ открыть</span>
<span>Esc закрыть</span>
<span className="ml-auto">{palette.example}</span>
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,26 @@
import type { DataGridFeatures, DataGridProps } from '@/components/reui/data-grid/data-grid'
export const DATA_GRID_CELL_PAD = 'py-3'
export const DATA_GRID_CELL_PAD_FIRST = 'pl-5 py-3'
export const DATA_GRID_CELL_PAD_LAST = 'pr-4 py-3'
export const DATA_GRID_LAYOUT: NonNullable<DataGridProps<DataGridFeatures, object>['tableLayout']> = {
rowBorder: true,
headerBackground: true,
headerBorder: true,
columnsResizable: false,
}
export const DATA_GRID_CLASS_NAMES: NonNullable<DataGridProps<DataGridFeatures, object>['tableClassNames']> = {
headerRow: 'border-b border-border',
bodyRow: 'group/row',
}
export const DATA_GRID_CONTAINER_CLASS = 'w-full rounded-none border-0'
/** Отступы ячейки по положению колонки: первая — pl-5, последняя — pr-4. */
export function cellPad(index: number, count: number) {
if (index === 0) return DATA_GRID_CELL_PAD_FIRST
if (index === count - 1) return DATA_GRID_CELL_PAD_LAST
return DATA_GRID_CELL_PAD
}
@@ -0,0 +1,123 @@
/**
* Каркас списочной страницы как в MikrotikManager (/servers): карточка Frame без полей, тулбар
* «сегменты · фильтры · поиск · счётчик», таблица с подложкой шапки и ячейками py-3.
*/
import type { ReactNode } from 'react'
import type { Column } from '@tanstack/react-table'
import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon, SearchIcon } from 'lucide-react'
import { Frame, FramePanel } from '@/components/reui/frame'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@authportal/ui/components/input-group'
import { cn } from '@authportal/ui/lib/utils'
import type { DataGridFeatures } from '@/components/reui/data-grid/data-grid'
export function DataPageCard({ children, className }: { children: ReactNode; className?: string }) {
return (
<Frame dense className={cn('w-full min-w-0', className)}>
<FramePanel className="flex w-full min-w-0 flex-col gap-0 overflow-hidden p-0 py-0 shadow-none">{children}</FramePanel>
</Frame>
)
}
export function SegmentedControl<T extends string>({
value,
onChange,
options,
label,
}: {
value: T
onChange: (value: T) => void
options: { value: T; label: string; count?: number }[]
label?: string
}) {
return (
<div role="group" aria-label={label} className="border-border bg-muted/40 flex w-fit items-center gap-1 rounded-md border p-0.5">
{options.map((o) => (
<button
key={o.value}
type="button"
aria-pressed={value === o.value}
onClick={() => onChange(o.value)}
className={cn(
'flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors',
value === o.value ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
)}
>
{o.label}
{o.count != null ? <span className="text-xs tabular-nums">{o.count}</span> : null}
</button>
))}
</div>
)
}
export function DataPageToolbar({
leading,
segmented,
filterControl,
search,
onSearchChange,
searchPlaceholder = 'Поиск…',
countLabel,
actions,
}: {
leading?: ReactNode
segmented?: { value: string; onChange: (value: string) => void; options: { value: string; label: string; count?: number }[]; label?: string }
filterControl?: ReactNode
search?: string
onSearchChange?: (value: string) => void
searchPlaceholder?: string
countLabel?: string
actions?: ReactNode
}) {
return (
<div className="flex flex-wrap items-center gap-3 border-b px-5 py-3">
{leading}
{segmented ? <SegmentedControl {...segmented} /> : null}
{filterControl}
{onSearchChange ? (
<InputGroup className="max-w-sm min-w-[220px]">
<InputGroupAddon>
<SearchIcon className="size-3.5" aria-hidden />
</InputGroupAddon>
<InputGroupInput aria-label={searchPlaceholder} placeholder={searchPlaceholder} value={search ?? ''} onChange={(e) => onSearchChange(e.target.value)} />
</InputGroup>
) : null}
{countLabel ? <span className="text-muted-foreground ml-auto text-sm">{countLabel}</span> : null}
{actions}
</div>
)
}
export function DataGridSortHeader<TData extends object>({
column,
title,
className,
}: {
column: Column<DataGridFeatures, TData, unknown>
title: string
className?: string
}) {
const sorted = column.getIsSorted()
if (!column.getCanSort()) {
return <span className={cn('text-muted-foreground text-xs font-medium', className)}>{title}</span>
}
return (
<button
type="button"
className={cn(
'text-muted-foreground hover:text-foreground focus-visible:outline-ring -ml-1 inline-flex items-center gap-1.5 rounded-md px-1 py-0.5 text-xs font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
className,
)}
onClick={column.getToggleSortingHandler()}
>
{title}
{sorted === 'asc' ? (
<ArrowUpIcon aria-hidden className="text-foreground size-3" />
) : sorted === 'desc' ? (
<ArrowDownIcon aria-hidden className="text-foreground size-3" />
) : (
<ArrowUpDownIcon aria-hidden className="size-3 opacity-35" />
)}
</button>
)
}
@@ -0,0 +1,88 @@
import { useState, type ReactNode } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { Button } from '@authportal/ui/components/button'
import { cn } from '@authportal/ui/lib/utils'
import { CodeBlock, PortalSheet, SignalAlert, StatusBadge } from './bits'
/**
* Баннер доски: заголовок и пояснение, справа «Объяснить» — открывает панель
* «Ассистент · разбор» (черновик с причинами и тем, что проверить).
*/
export function ExplainBanner({
tone,
title,
children,
text,
}: {
tone: 'er' | 'wn'
title: string
/** Текст для панели разбора (то же, что в баннере). */
text: string
children: ReactNode
}) {
const [open, setOpen] = useState(false)
const navigate = useNavigate()
return (
<>
<div
role="status"
className={cn(
'flex flex-wrap items-start gap-3 rounded-lg border px-3 py-2.5 text-sm',
tone === 'er' ? 'border-destructive/30 bg-destructive/10' : 'border-warning/30 bg-warning/10',
)}
>
<div className="min-w-0 flex-1 basis-80 text-pretty">
<b className="font-semibold">{title}</b> {children}
</div>
<Button type="button" variant="outline" onClick={() => setOpen(true)}>
Объяснить
</Button>
</div>
<PortalSheet
open={open}
onOpenChange={setOpen}
title="Ассистент · разбор"
description="Черновик: проверьте перед применением"
footer={
<>
<Button variant="outline" onClick={() => setOpen(false)}>
Закрыть
</Button>
<Button
onClick={() => {
setOpen(false)
void navigate({
// В прод-образе preview-маршруты вырезаны (см. Dockerfile) — ведём в реальный журнал.
to: window.location.pathname.startsWith('/design-preview')
? '/design-preview/logins'
: '/admin/logins',
})
}}
>
Открыть журнал входов
</Button>
</>
}
>
<SignalAlert tone="in" title="Что случилось">
{title} {text}
</SignalAlert>
<div className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs">Вероятные причины</span>
<div className="flex flex-wrap gap-1.5">
<StatusBadge tone="er">Подбор пароля</StatusBadge>
<StatusBadge tone="wn">Устаревший passkey</StatusBadge>
<StatusBadge tone="mu">Смена IP</StatusBadge>
</div>
<span className="text-muted-foreground text-xs">Отсортированы по вероятности по журналу входов за 24 часа.</span>
</div>
<CodeBlock label="Что проверить">
{'GET /api/v1/admin/audit?kind=logins&action=auth.login_failed&range=24h\nGET /api/v1/admin/sessions?user=ops@shnt.top'}
</CodeBlock>
<SignalAlert tone="wn" title="Ассистент ничего не меняет сам">
Блокировка и сброс passkeys выполняются вручную с подтверждением и попадают в журнал «Изменения».
</SignalAlert>
</PortalSheet>
</>
)
}
+45
View File
@@ -0,0 +1,45 @@
const DATE = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' })
const SHORT = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit' })
const TIME = new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' })
function parse(iso: string | null | undefined): Date | null {
if (!iso) return null
const d = new Date(iso)
return Number.isNaN(d.getTime()) ? null : d
}
/** 12.09.2026 */
export function fmtDate(iso: string | null | undefined, empty = '—') {
const d = parse(iso)
return d ? DATE.format(d) : empty
}
/** «сегодня 09:41», «вчера 22:15», иначе «12.09 09:41». */
export function fmtWhen(iso: string | null | undefined, empty = '—', now = Date.now()) {
const d = parse(iso)
if (!d) return empty
const dayMs = 24 * 60 * 60 * 1000
const startOfToday = new Date(now)
startOfToday.setHours(0, 0, 0, 0)
const diff = startOfToday.getTime() - new Date(d).setHours(0, 0, 0, 0)
if (diff === 0) return `сегодня ${TIME.format(d)}`
if (diff === dayMs) return `вчера ${TIME.format(d)}`
return `${SHORT.format(d)} ${TIME.format(d)}`
}
/** Время дня 14:02. */
export function fmtTime(iso: string | null | undefined, empty = '—') {
const d = parse(iso)
return d ? TIME.format(d) : empty
}
/** «5 дн назад», «3 ч назад», «только что». */
export function fmtAgo(iso: string | null | undefined, empty = '—', now = Date.now()) {
const d = parse(iso)
if (!d) return empty
const sec = Math.max(0, Math.round((now - d.getTime()) / 1000))
if (sec < 60) return 'только что'
if (sec < 3600) return `${Math.floor(sec / 60)} мин назад`
if (sec < 86400) return `${Math.floor(sec / 3600)} ч назад`
return `${Math.floor(sec / 86400)} дн назад`
}
@@ -0,0 +1,74 @@
import { Badge } from '@/components/reui/badge'
import { IconTile } from '@/components/reui/icon-tile'
import { Frame, FramePanel } from '@/components/reui/frame'
import { cn } from '@authportal/ui/lib/utils'
import type { KpiItem, KpiTone } from './kpi'
const VALUE: Record<KpiTone, string> = {
neutral: 'text-foreground',
ok: 'text-foreground',
in: 'text-foreground',
wn: 'text-warning',
er: 'text-destructive',
}
/** Колонки по числу ячеек и ширине окна (на досках — ширина приложения): 4 → 4/2/1, 3 → 3/1, 2 → 2/1. */
const COLS: Record<number, string> = {
1: 'grid-cols-1',
2: 'grid-cols-2 max-[640px]:grid-cols-1',
3: 'grid-cols-1 min-[1240px]:grid-cols-3',
4: 'grid-cols-1 min-[640px]:grid-cols-2 min-[1240px]:grid-cols-4',
}
const TILE: Record<KpiTone, string> = {
neutral: 'text-muted-foreground',
ok: 'text-success',
in: 'text-info',
wn: 'text-warning',
er: 'text-destructive',
}
const GLYPH: Record<KpiTone, string> = { neutral: '', ok: '●', in: '', wn: '▲', er: '✕' }
const BADGE = {
neutral: 'outline',
ok: 'success-light',
in: 'info-light',
wn: 'warning-light',
er: 'destructive-light',
} as const
/**
* Полоса показателей как на досках: один Frame, ячейки разделены линиями,
* в ячейке подпись, крупное значение и плашка-подсказка.
*/
export function KpiStrip({ items, 'aria-label': ariaLabel }: { items: KpiItem[]; 'aria-label'?: string }) {
return (
<Frame spacing="sm" className="w-full min-w-0" aria-label={ariaLabel}>
<FramePanel className="overflow-hidden p-0">
<div className={cn('bg-border grid gap-px', COLS[Math.min(Math.max(items.length, 1), 4)])}>
{items.map((k) => {
const Icon = k.icon
return (
<div key={k.id} className="bg-card flex min-w-0 items-center gap-3 px-[18px] py-3.5">
<IconTile variant="outline" aria-hidden className={cn('shrink-0', TILE[k.tone])}>
<Icon />
</IconTile>
<div className="flex min-w-0 flex-col gap-1">
<div className="text-muted-foreground text-[13px]">{k.label}</div>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span className={cn('text-2xl font-semibold tracking-tight tabular-nums', VALUE[k.tone])}>{k.value}</span>
<Badge variant={BADGE[k.tone]} size="sm" className="whitespace-nowrap">
{GLYPH[k.tone] ? <span aria-hidden className="text-[0.55rem]">{GLYPH[k.tone]}</span> : null}
{k.hint}
</Badge>
</div>
</div>
</div>
)
})}
</div>
</FramePanel>
</Frame>
)
}
+24
View File
@@ -0,0 +1,24 @@
import type { LucideIcon } from 'lucide-react'
export type KpiTone = 'neutral' | 'ok' | 'wn' | 'er' | 'in'
export type KpiItem = {
id: string
label: string
value: string
/** Плашка-подсказка рядом со значением. */
hint: string
tone: KpiTone
/** Иконка показателя: плитка слева от подписи. */
icon: LucideIcon
}
/** Плитка KPI: иконка, подпись, значение (цветом при wn/er) и плашка-подсказка. */
export function kpi(
id: string,
label: string,
value: string,
opts: { icon: LucideIcon; tone?: KpiTone; badge: string },
): KpiItem {
return { id, label, value, hint: opts.badge, tone: opts.tone ?? 'neutral', icon: opts.icon }
}
@@ -0,0 +1,488 @@
import { useMemo, useState, type ReactNode } from 'react'
import { Columns3Icon, MoreHorizontalIcon } from 'lucide-react'
import { toast } from 'sonner'
import { useTable, type ColumnDef, type RowSelectionState, type SortingState } from '@tanstack/react-table'
import { DATA_GRID_RU } from '@/lib/reui-ru'
import { DataGrid, dataGridFeatures, type DataGridFeatures } from '@/components/reui/data-grid/data-grid'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import {
DataGridTableDndRowHandle,
DataGridTableDndRows,
} from '@/components/reui/data-grid/data-grid-table-dnd-rows'
import type { DragEndEvent } from '@dnd-kit/core'
import {
DataGridTable,
DataGridTableRowSelect,
DataGridTableRowSelectAll,
} from '@/components/reui/data-grid/data-grid-table'
import { Button } from '@authportal/ui/components/button'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu'
import { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import { cn } from '@authportal/ui/lib/utils'
import { StatusBadge } from './bits'
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
DATA_GRID_CLASS_NAMES,
DATA_GRID_LAYOUT,
} from './data-page-layout'
import {
DataGridSortHeader,
DataPageCard,
DataPageToolbar,
} from './data-page'
import type { Tone } from './tone'
import type { RowMark } from './tone'
import { TONE_DOT } from './tone'
export type GridColumn<T> = {
id: string
title: string
/** Ширина колонки; сумма задаёт минимальную ширину таблицы. */
size: number
cell: (row: T) => ReactNode
/** Ключ сортировки; без него колонка не сортируется. */
sortValue?: (row: T) => string | number
}
export type GridView<T> = { label: string; match: (row: T) => boolean }
/** Карточка строки для режима «Карточки» (как на доске «Пользователи»). */
export type GridCard = {
name: string
sub?: string
tone: Tone
status: string
cells: Array<{ l: string; v: string }>
}
/** Действия меню строки «⋮»: на досках — Открыть, Включить/Выключить, Дублировать, Удалить…. */
export type RowMenuConfig = {
onOpen?: () => void
toggleLabel?: string
onToggle?: () => void
onDuplicate?: () => void
onDelete?: () => void
}
export type GridBulk = {
onLabel?: string
offLabel?: string
onRun?: (ids: string[]) => void
offRun?: (ids: string[]) => void
onDelete?: (ids: string[]) => void
}
const SELECT_COLUMN_SIZE = 44
const DRAG_COLUMN_SIZE = 44
const ACTIONS_COLUMN_SIZE = 120
/**
* Секция-таблица по доскам Auth Portal: шапка (название, действия, «Таблица/Карточки», поиск, «Колонки»),
* сохранённые виды линией со счётчиками, панель массовых действий, таблица со статусом и меню строки,
* подвал «Показано N из M». Ширина таблицы не меньше суммы колонок — прокрутка внутри контейнера.
*/
export function SectionGrid<T extends object>({
title,
description,
rows,
columns,
getRowId,
views,
searchText,
searchPlaceholder = 'Поиск…',
onRowClick,
selectable = true,
bulk,
tools,
card,
rowSwitch,
rowMenu,
onReorder,
rowStatus,
pageSize = 50,
emptyMessage = 'Нет записей по текущим фильтрам.',
className,
}: {
title: string
description: string
rows: T[]
columns: GridColumn<T>[]
getRowId: (row: T) => string
views?: GridView<T>[]
searchText?: (row: T) => string
searchPlaceholder?: string
onRowClick?: (row: T) => void
selectable?: boolean
bulk?: GridBulk
/** Кнопки действий в шапке; функция получает id выбранных строк. */
tools?: ReactNode | ((selectedIds: string[]) => ReactNode)
/** Включает переключатель «Таблица / Карточки». */
card?: (row: T) => GridCard
/** Переключатель «включено» в последней колонке (если у строки он есть). */
rowSwitch?: { value: (row: T) => boolean | undefined; onChange: (row: T, on: boolean) => void }
rowMenu?: (row: T) => RowMenuConfig
/** Включает перетаскивание строк (dnd-kit). Передаёт id строки и id строки, на место которой её бросили. */
onReorder?: (activeId: string, overId: string) => void
/** Пометка строки: «Не сохранено» (dirty/new) или «Будет удалено» (deleted). */
rowStatus?: (row: T) => RowMark | undefined
pageSize?: number
/** Свой текст подвала вместо «Показано N из M» (как «Показано 8 из 148» на доске). */
footer?: string
emptyMessage?: string
className?: string
}) {
const storageKey = `design-preview:view:${title}`
const [view, setViewState] = useState(() => {
try {
const v = Number(window.localStorage.getItem(storageKey))
return Number.isInteger(v) && v >= 0 && v < (views?.length ?? 0) ? v : 0
} catch {
return 0
}
})
const setView = (i: number) => {
setViewState(i)
try {
window.localStorage.setItem(storageKey, String(i))
} catch {
/* без сохранения вида */
}
}
const [query, setQuery] = useState('')
const [sorting, setSorting] = useState<SortingState>([])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize })
const [hidden, setHidden] = useState<Record<string, boolean>>({})
const [cards, setCards] = useState(false)
const data = useMemo(() => {
const matchView = views?.[view]?.match
const q = query.trim().toLowerCase()
return rows.filter((row) => {
if (matchView && !matchView(row)) return false
if (q && searchText && !searchText(row).toLowerCase().includes(q)) return false
return true
})
}, [rows, views, view, query, searchText])
const visibleColumns = useMemo(() => columns.filter((c, i) => i === 0 || !hidden[c.id]), [columns, hidden])
const defs = useMemo<ColumnDef<DataGridFeatures, T>[]>(() => {
const list: ColumnDef<DataGridFeatures, T>[] = []
if (onReorder) {
list.push({
id: 'drag',
header: () => <span className="sr-only">Порядок</span>,
cell: () => <DataGridTableDndRowHandle />,
enableSorting: false,
enableResizing: false,
size: DRAG_COLUMN_SIZE,
})
}
if (selectable) {
list.push({
id: 'select',
header: () => <DataGridTableRowSelectAll />,
cell: ({ row }) => (
<div onClick={(e) => e.stopPropagation()}>
<DataGridTableRowSelect row={row} />
</div>
),
enableSorting: false,
enableResizing: false,
size: SELECT_COLUMN_SIZE,
meta: { headerClassName: 'pl-5 py-3', cellClassName: 'pl-5 py-3' },
})
}
visibleColumns.forEach((col, i) => {
const pad = i === 0 && !selectable && !onReorder ? DATA_GRID_CELL_PAD_FIRST : DATA_GRID_CELL_PAD
list.push({
id: col.id,
accessorFn: col.sortValue ?? (() => ''),
header: ({ column }) => <DataGridSortHeader column={column} title={col.title} />,
cell: ({ row }) => col.cell(row.original),
enableSorting: Boolean(col.sortValue),
enableResizing: false,
size: col.size,
meta: { headerTitle: col.title, headerClassName: pad, cellClassName: pad },
})
})
list.push({
id: '__actions',
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => {
const r = row.original
const on = rowSwitch?.value(r)
const menu: RowMenuConfig = rowMenu?.(r) ?? {}
const soon = (what: string) => () => toast(`${what} (макет)`)
return (
<div className="flex items-center justify-end gap-2" onClick={(e) => e.stopPropagation()}>
{on !== undefined && rowSwitch ? (
<Switch size="sm" checked={on} onCheckedChange={(v) => rowSwitch.onChange(r, v)} aria-label="Включено" />
) : null}
<DropdownMenu>
<DropdownMenuTrigger render={<Button type="button" variant="ghost" size="icon" aria-label="Действия" />}>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-48">
<DropdownMenuItem onClick={menu.onOpen ?? (() => onRowClick?.(r))}>Открыть</DropdownMenuItem>
<DropdownMenuItem onClick={menu.onToggle ?? soon('Действие')}>
{menu.toggleLabel ?? (on === undefined ? 'Повторить проверку' : on ? 'Выключить' : 'Включить')}
</DropdownMenuItem>
<DropdownMenuItem onClick={menu.onDuplicate ?? soon('Дублирование')}>Дублировать</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={menu.onDelete ?? soon('Удаление')}>
Удалить…
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
},
enableSorting: false,
enableResizing: false,
size: ACTIONS_COLUMN_SIZE,
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
})
return list
}, [visibleColumns, selectable, onReorder, rowSwitch, rowMenu, onRowClick])
const table = useTable({
features: dataGridFeatures,
columns: defs,
data,
getRowId,
state: { sorting, rowSelection, pagination },
enableRowSelection: selectable,
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
})
const dataIds = table.getRowModel().rows.map((r) => r.id)
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!onReorder || !active || !over || active.id === over.id) return
onReorder(String(active.id), String(over.id))
}
// Колонки сжимаются под ширину рамки (текст переносится, как на досках); прокрутка — только на узких экранах.
const minWidth = Math.round(
visibleColumns.reduce(
(sum, c) => sum + c.size,
(selectable ? SELECT_COLUMN_SIZE : 0) + (onReorder ? DRAG_COLUMN_SIZE : 0) + ACTIONS_COLUMN_SIZE,
) * 0.7,
)
const selectedIds = table.getSelectedRowModel().rows.map((r) => r.id)
const clear = () => setRowSelection({})
const paged = data.length > pageSize
const showCards = Boolean(card) && cards
const countLabel = `${data.length} ${data.length === rows.length ? 'записей' : `из ${rows.length}`}`
return (
<DataGrid
table={table}
i18n={DATA_GRID_RU}
recordCount={data.length}
onRowClick={onRowClick}
getRowStatus={rowStatus}
emptyMessage={emptyMessage}
tableLayout={{ ...DATA_GRID_LAYOUT, rowsDraggable: Boolean(onReorder) }}
tableClassNames={DATA_GRID_CLASS_NAMES}
>
<DataPageCard className={className}>
<h2 className="sr-only">{title}</h2>
<DataPageToolbar
segmented={
views
? {
value: String(view),
label: description,
onChange: (v) => {
setView(Number(v))
setPagination((p) => ({ ...p, pageIndex: 0 }))
},
options: views.map((v, i) => ({ value: String(i), label: v.label, count: rows.filter(v.match).length })),
}
: undefined
}
leading={
card ? (
<Tabs value={cards ? 'cards' : 'table'} onValueChange={(v) => setCards(v === 'cards')} aria-label="Вид списка">
<TabsList className="h-9! gap-0.5 p-[3px]">
<TabsTrigger value="table" className="h-7! px-2.5 text-[12.5px]">
Таблица
</TabsTrigger>
<TabsTrigger value="cards" className="h-7! px-2.5 text-[12.5px]">
Карточки
</TabsTrigger>
</TabsList>
</Tabs>
) : undefined
}
search={query}
onSearchChange={
searchText
? (v) => {
setQuery(v)
setPagination((p) => ({ ...p, pageIndex: 0 }))
}
: undefined
}
searchPlaceholder={searchPlaceholder}
countLabel={countLabel}
actions={
<>
{typeof tools === 'function' ? tools(selectedIds) : tools}
{columns.length > 1 ? (
<DropdownMenu>
<DropdownMenuTrigger
render={<Button type="button" variant="outline" size="icon" aria-label="Выбрать колонки" title="Колонки" />}
>
<Columns3Icon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-52">
{columns.slice(1).map((c) => (
<DropdownMenuCheckboxItem key={c.id} checked={!hidden[c.id]} onCheckedChange={(v) => setHidden((h) => ({ ...h, [c.id]: !v }))}>
{c.title}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</>
}
/>
{selectable && selectedIds.length > 0 ? (
<div
role="toolbar"
aria-label="Массовые действия"
className="bg-primary/10 flex flex-wrap items-center gap-2 border-b px-3.5 py-2 text-[13px]"
>
<b className="mr-1.5 font-semibold">Выбрано: {selectedIds.length}</b>
<Button
type="button"
variant="outline"
onClick={() => {
if (bulk?.onRun) bulk.onRun(selectedIds)
else toast(`${bulk?.onLabel ?? 'Включить'}: ${selectedIds.length} (макет)`)
clear()
}}
>
{bulk?.onLabel ?? 'Включить'}
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
if (bulk?.offRun) bulk.offRun(selectedIds)
else toast(`${bulk?.offLabel ?? 'Выключить'}: ${selectedIds.length} (макет)`)
clear()
}}
>
{bulk?.offLabel ?? 'Выключить'}
</Button>
<Button
type="button"
variant="outline"
className="text-destructive"
onClick={() => {
if (bulk?.onDelete) bulk.onDelete(selectedIds)
else toast(`Удаление: ${selectedIds.length} (макет)`)
}}
>
Удалить…
</Button>
<span className="flex-1" />
<Button type="button" variant="outline" onClick={clear}>
Снять выбор
</Button>
</div>
) : null}
{showCards && card ? (
<ul className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,15rem),1fr))] gap-3 p-3">
{data.map((r) => {
const c = card(r)
const id = getRowId(r)
const marked = rowStatus?.(r)
return (
<li
key={id}
className={cn('bg-muted/25 flex flex-col gap-2 rounded-lg border p-3', marked === 'deleted' && 'opacity-60')}
>
<div className="flex items-center justify-between gap-2">
<input
type="checkbox"
aria-label={`Выбрать ${c.name}`}
checked={Boolean(rowSelection[id])}
onChange={(e) =>
setRowSelection((s) => {
const next = { ...s }
if (e.target.checked) next[id] = true
else delete next[id]
return next
})
}
className="accent-primary size-4"
/>
<StatusBadge tone={c.tone}>{c.status}</StatusBadge>
</div>
<button
type="button"
onClick={() => onRowClick?.(r)}
className="flex min-w-0 items-start gap-2 text-left"
aria-label={`Открыть: ${c.name}`}
>
<span aria-hidden className={cn('mt-1.5 size-2 shrink-0 rounded-full', TONE_DOT[c.tone])} />
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{c.name}</span>
{c.sub ? <span className="text-muted-foreground truncate font-mono text-[11px]">{c.sub}</span> : null}
</span>
</button>
<dl className="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs">
{c.cells.map((x) => (
<div key={x.l} className="min-w-0">
<dt className="text-muted-foreground">{x.l}</dt>
<dd className="truncate font-mono">{x.v}</dd>
</div>
))}
</dl>
</li>
)
})}
</ul>
) : (
<DataGridScrollArea>
<div style={{ minWidth }}>
{onReorder ? <DataGridTableDndRows dataIds={dataIds} handleDragEnd={handleDragEnd} /> : <DataGridTable />}
</div>
</DataGridScrollArea>
)}
{paged && !showCards ? <DataGridPagination className="px-5 py-3" sizes={[8, 16, 32]} /> : null}
</DataPageCard>
</DataGrid>
)
}
/** Двухстрочная ячейка: заголовок + подпись. */
export function TwoLineCell({ title, sub, dot }: { title: ReactNode; sub?: ReactNode; dot?: Tone }) {
return (
<div className="flex min-w-0 items-start gap-2">
{dot ? <span aria-hidden className={cn('mt-1.5 size-2 shrink-0 rounded-full', TONE_DOT[dot])} /> : null}
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{title}</span>
{sub ? <span className="text-muted-foreground truncate font-mono text-[11px]">{sub}</span> : null}
</div>
</div>
)
}
+28
View File
@@ -0,0 +1,28 @@
export type Tone = 'ok' | 'wn' | 'er' | 'mu' | 'in'
/** Пометка строки с накопленной правкой. */
export type RowMark = 'new' | 'dirty' | 'deleted'
type BadgeVariant =
| 'success-light'
| 'warning-light'
| 'destructive-light'
| 'info-light'
| 'outline'
export const TONE_BADGE: Record<Tone, BadgeVariant> = {
ok: 'success-light',
wn: 'warning-light',
er: 'destructive-light',
in: 'info-light',
mu: 'outline',
}
export const TONE_DOT: Record<Tone, string> = {
ok: 'bg-success',
wn: 'bg-warning',
er: 'bg-destructive',
in: 'bg-success',
mu: 'bg-muted-foreground/50',
}
@@ -0,0 +1,912 @@
/**
* Admin users grid — DNA solution-users-1 (members-grid, columns, bulk-action-bar).
* Preview: https://reui.io/preview/base/solution-users-1
* Docs: https://reui.io/docs/components/base/filters · https://reui.io/docs/components/base/data-grid
*/
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import type { AdminUser, AppId } from '@authportal/shared'
import {
CircleDotIcon,
FilterIcon,
FunnelXIcon,
FingerprintIcon,
LockIcon,
MailIcon,
MoreHorizontalIcon,
PauseCircleIcon,
PencilIcon,
ScrollTextIcon,
Settings2Icon,
ShieldCheckIcon,
Trash2Icon,
TriangleAlertIcon,
UserIcon,
UserPlusIcon,
} from 'lucide-react'
import {
type ColumnDef,
type PaginationState,
type Row,
type RowSelectionState,
type SortingState,
type ColumnVisibilityState,
useTable,
} from '@tanstack/react-table'
import { Badge } from '@/components/reui/badge'
import { DataGrid, dataGridFeatures, type DataGridFeatures } from '@/components/reui/data-grid/data-grid'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridColumnVisibility } from '@/components/reui/data-grid/data-grid-column-visibility'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import {
DataGridTable,
DataGridTableRowSelect,
DataGridTableRowSelectAll,
} from '@/components/reui/data-grid/data-grid-table'
import { DATA_GRID_RU, FILTERS_RU, FILTER_OPERATORS_RU } from '@/lib/reui-ru'
import { cellPad } from '@/components/portal/data-page-layout'
import {
DATA_GRID_CLASS_NAMES,
DATA_GRID_LAYOUT,
} from '@/components/portal/data-page-layout'
import { DataPageCard, DataPageToolbar } from '@/components/portal/data-page'
import { cn } from '@authportal/ui/lib/utils'
import { Filters } from '@/components/reui/filters/filters'
import {
createFilterQuery,
flattenFilterConditions,
type FilterCondition,
} from '@/components/reui/filters/filters-query'
import type {
FilterField,
FilterQuery,
} from '@/components/reui/filters/filters-types'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@authportal/ui/components/alert-dialog'
import {
Avatar,
AvatarFallback,
} from '@authportal/ui/components/avatar'
import { Button } from '@authportal/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@authportal/ui/components/select'
import { Skeleton } from '@authportal/ui/components/skeleton'
import { TooltipProvider } from '@authportal/ui/components/tooltip'
const BULK_ROLE_OPTIONS = [
{ value: 'admin', label: 'Админ' },
{ value: 'user', label: 'Пользователь' },
] as const
type BulkRole = (typeof BULK_ROLE_OPTIONS)[number]['value']
export interface AdminUsersGridProps {
users: AdminUser[]
isLoading?: boolean
onCreateClick: () => void
onOpenAudit: (user: AdminUser) => void
onOpenAccess: (user: AdminUser) => void
onDeactivate: (user: AdminUser) => void
onDelete: (user: AdminUser) => void
onResetPasskeys: (user: AdminUser) => void
onBulkSetRole: (userIds: string[], isAdmin: boolean) => void
onBulkDeactivate: (userIds: string[]) => void
}
function serializeConditionsKey(conditions: FilterCondition[]) {
return JSON.stringify(
conditions.map((c) => ({
field: c.field,
operator: c.operator,
values: c.values,
negated: c.negated,
})),
)
}
function filterFieldValue(item: AdminUser, field: string): unknown {
if (field === 'name') return item.name
if (field === 'email') return item.email
if (field === 'role') return item.is_admin ? 'admin' : 'user'
if (field === 'status') return item.disabled ? 'disabled' : 'active'
if (field === 'apps') return item.apps.join(' ')
return undefined
}
function matchesCondition(item: AdminUser, condition: FilterCondition): boolean {
const { field, operator, values } = condition
const raw = filterFieldValue(item, field)
const fieldValue = raw != null ? String(raw) : ''
const lower = fieldValue.toLowerCase()
const tokens = values.map((v) => String(v).trim()).filter(Boolean)
switch (operator) {
case 'is':
return values.map(String).includes(fieldValue)
case 'is_not':
return !values.map(String).includes(fieldValue)
case 'is_any_of':
return values.map(String).includes(fieldValue)
case 'is_none_of':
return !values.map(String).includes(fieldValue)
case 'contains':
return tokens.length === 0 || tokens.some((t) => lower.includes(t.toLowerCase()))
case 'not_contains':
return !tokens.some((t) => lower.includes(t.toLowerCase()))
case 'starts_with':
return tokens.some((t) => lower.startsWith(t.toLowerCase()))
case 'ends_with':
return tokens.some((t) => lower.endsWith(t.toLowerCase()))
case 'empty':
return fieldValue === ''
case 'not_empty':
return fieldValue !== ''
default:
return true
}
}
function applyConditionsToUsers(data: AdminUser[], conditions: FilterCondition[]): AdminUser[] {
if (conditions.length === 0) return data
return data.filter((item) =>
conditions.every((c) => {
const hit = matchesCondition(item, c)
return c.negated ? !hit : hit
}),
)
}
function userInitials(name: string, email: string) {
const parts = name.trim().split(/\s+/).filter(Boolean)
if (parts.length >= 2) {
return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase()
}
return (name || email).slice(0, 2).toUpperCase()
}
function formatLastActive(iso: string | null) {
if (!iso) return '—'
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('ru-RU', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date)
}
function RoleBadge({ isAdmin }: { isAdmin: boolean }) {
return isAdmin ? (
<Badge variant="info-outline">Админ</Badge>
) : (
<Badge variant="secondary">Пользователь</Badge>
)
}
function StatusBadge({ disabled }: { disabled: boolean }) {
return disabled ? (
<Badge variant="outline">
<span className="bg-muted-foreground size-1.5 shrink-0 rounded-full!" />
Отключён
</Badge>
) : (
<Badge variant="outline">
<span className="bg-success size-1.5 shrink-0 rounded-full!" />
Активен
</Badge>
)
}
const AppTags = memo(function AppTags({ apps }: { apps: AppId[] }) {
if (apps.length === 0) {
return <span className="text-muted-foreground text-sm">—</span>
}
return (
<div className="flex flex-wrap items-center gap-1">
{apps.map((appId) => (
<Badge key={appId} variant="secondary" className="border-0">
{appId.toUpperCase()}
</Badge>
))}
</div>
)
})
const MemberCell = memo(function MemberCell({ row }: { row: Row<DataGridFeatures, AdminUser> }) {
const user = row.original
return (
<div className="flex min-w-0 items-center gap-2">
<Avatar className="size-8 shrink-0">
<AvatarFallback className="text-xs">
{userInitials(user.name, user.email)}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="text-foreground line-clamp-1 font-medium">
{user.name}
</div>
<div
className="text-muted-foreground line-clamp-1 text-xs"
title={user.email}
>
{user.email}
</div>
</div>
</div>
)
})
function AdminUsersBulkActionBar({
selectedCount,
roleValue,
onRoleChange,
onChangeRole,
onDeactivate,
onClear,
}: {
selectedCount: number
roleValue: BulkRole
onRoleChange: (value: BulkRole) => void
onChangeRole: () => void
onDeactivate: () => void
onClear: () => void
}) {
return (
<div className="bg-muted/25 flex flex-col gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py) lg:flex-row lg:items-center lg:justify-between">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-sm font-medium">
Выбрано: {selectedCount}
</span>
<span className="text-muted-foreground text-xs">
Массовое изменение роли или отключение доступа.
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<Select
value={roleValue}
onValueChange={(value) => {
if (!value) return
onRoleChange(value as BulkRole)
}}
items={BULK_ROLE_OPTIONS.map((o) => ({
value: o.value,
label: o.label,
}))}
>
<SelectTrigger size="sm" className="w-[164px]">
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectGroup>
{BULK_ROLE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button type="button" size="sm" variant="outline" onClick={onChangeRole}>
<PencilIcon data-icon="inline-start" aria-hidden="true" />
Сменить роль
</Button>
<Button type="button" size="sm" onClick={onDeactivate}>
<PauseCircleIcon data-icon="inline-start" aria-hidden="true" />
Отключить
</Button>
<Button type="button" size="sm" variant="ghost" onClick={onClear}>
Сбросить
</Button>
</div>
</div>
)
}
function ActionsCell({
row,
onOpenAudit,
onOpenAccess,
onDeactivate,
onDelete,
onResetPasskeys,
}: {
row: Row<DataGridFeatures, AdminUser>
onOpenAudit: (user: AdminUser) => void
onOpenAccess: (user: AdminUser) => void
onDeactivate: (user: AdminUser) => void
onDelete: (user: AdminUser) => void
onResetPasskeys: (user: AdminUser) => void
}) {
const [deleteOpen, setDeleteOpen] = useState(false)
const [resetOpen, setResetOpen] = useState(false)
const user = row.original
return (
<div onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
size="icon"
variant="ghost"
className="size-7"
aria-label="Действия"
/>
}
>
<MoreHorizontalIcon aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-44">
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => onOpenAudit(user)}>
<ScrollTextIcon className="size-4" aria-hidden="true" />
Журнал
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onOpenAccess(user)}>
<ShieldCheckIcon className="size-4" aria-hidden="true" />
Права
</DropdownMenuItem>
{!user.disabled ? (
<DropdownMenuItem onClick={() => onDeactivate(user)}>
<PauseCircleIcon className="size-4" aria-hidden="true" />
Отключить
</DropdownMenuItem>
) : null}
{(user.passkey_count ?? 0) > 0 ? (
<DropdownMenuItem onClick={() => setResetOpen(true)}>
<FingerprintIcon className="size-4" aria-hidden="true" />
Сбросить passkeys
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => setDeleteOpen(true)}
>
<Trash2Icon className="size-4" aria-hidden="true" />
Удалить
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog open={resetOpen} onOpenChange={setResetOpen}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Сбросить passkeys?</AlertDialogTitle>
<AlertDialogDescription>
Все ключи пользователя{' '}
<span className="text-foreground font-medium">{user.email}</span>{' '}
будут удалены. Вход останется по паролю.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
setResetOpen(false)
onResetPasskeys(user)
}}
>
Сбросить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Удалить пользователя?</AlertDialogTitle>
<AlertDialogDescription>
Учётная запись{' '}
<span className="text-foreground font-medium">{user.email}</span>{' '}
будет удалена без возможности восстановления.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
setDeleteOpen(false)
onDelete(user)
}}
>
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
function createAdminUserColumns(handlers: {
onOpenAudit: (user: AdminUser) => void
onOpenAccess: (user: AdminUser) => void
onDeactivate: (user: AdminUser) => void
onDelete: (user: AdminUser) => void
onResetPasskeys: (user: AdminUser) => void
}): ColumnDef<DataGridFeatures, AdminUser>[] {
return [
{
id: 'select',
header: () => (
<div onClick={(e) => e.stopPropagation()}>
<DataGridTableRowSelectAll />
</div>
),
cell: ({ row }) => (
<div onClick={(e) => e.stopPropagation()}>
<DataGridTableRowSelect row={row} />
</div>
),
enableSorting: false,
size: 40,
enableResizing: false,
enableHiding: false,
meta: {
skeleton: <Skeleton className="mx-auto size-5" />,
headerClassName:
'[--data-grid-header-cell-ps:var(--frame-panel-header-px)]',
cellClassName: '[--data-grid-body-cell-ps:var(--frame-panel-px)]',
},
},
{
accessorKey: 'name',
id: 'name',
header: ({ column }) => (
<DataGridColumnHeader title="Участник" visibility column={column} />
),
cell: ({ row }) => <MemberCell row={row} />,
enableSorting: true,
enableHiding: false,
enableResizing: true,
minSize: 220,
meta: {
headerTitle: 'Участник',
autoSize: true,
skeleton: (
<div className="flex min-w-0 items-center gap-2">
<Skeleton className="size-8 shrink-0 rounded-full" />
<div className="flex min-w-0 flex-col gap-0.5">
<Skeleton className="h-3.5 w-32" />
<Skeleton className="h-3 w-40" />
</div>
</div>
),
},
},
{
id: 'role',
accessorFn: (row) => (row.is_admin ? 'admin' : 'user'),
header: ({ column }) => (
<DataGridColumnHeader title="Роль" visibility column={column} />
),
cell: ({ row }) => <RoleBadge isAdmin={row.original.is_admin} />,
size: 130,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: 'Роль',
skeleton: <Skeleton className="h-6 w-20 rounded-full" />,
},
},
{
id: 'apps',
accessorFn: (row) => row.apps.length,
header: ({ column }) => (
<DataGridColumnHeader title="Apps" visibility column={column} />
),
cell: ({ row }) => <AppTags apps={row.original.apps} />,
size: 160,
enableSorting: false,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: 'Apps',
skeleton: (
<div className="flex flex-wrap items-center gap-1">
<Skeleton className="h-5 w-12 rounded-full" />
<Skeleton className="h-5 w-12 rounded-full" />
</div>
),
},
},
{
id: 'status',
accessorFn: (row) => (row.disabled ? 'disabled' : 'active'),
header: ({ column }) => (
<DataGridColumnHeader title="Статус" visibility column={column} />
),
cell: ({ row }) => <StatusBadge disabled={row.original.disabled} />,
size: 130,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: 'Статус',
skeleton: <Skeleton className="h-6 w-24 rounded-full" />,
},
},
{
id: 'auth',
header: ({ column }) => (
<DataGridColumnHeader title="Auth" visibility column={column} />
),
cell: () => (
<Badge variant="warning-outline">
<LockIcon aria-hidden="true" />
Локальный
</Badge>
),
size: 120,
enableSorting: false,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: 'Auth',
skeleton: <Skeleton className="h-5 w-20 rounded-full" />,
},
},
{
id: 'twoFactor',
accessorFn: (row) => (row.passkey_count ?? 0) > 0,
header: ({ column }) => (
<DataGridColumnHeader title="2FA" visibility column={column} />
),
cell: ({ row }) =>
(row.original.passkey_count ?? 0) > 0 ? (
<Badge variant="success-outline">
<FingerprintIcon aria-hidden="true" />
Passkey
</Badge>
) : (
<Badge variant="destructive-outline">
<TriangleAlertIcon aria-hidden="true" />
Выкл
</Badge>
),
size: 100,
enableSorting: false,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: '2FA',
skeleton: <Skeleton className="h-5 w-16 rounded-full" />,
},
},
{
accessorKey: 'last_login_at',
id: 'lastActive',
header: ({ column }) => (
<DataGridColumnHeader
title="Last Active"
visibility
column={column}
/>
),
cell: ({ row }) => (
<span className="text-muted-foreground text-sm">
{formatLastActive(row.original.last_login_at)}
</span>
),
size: 150,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: 'Last Active',
skeleton: <Skeleton className="h-4 w-28" />,
},
},
{
accessorKey: 'last_login_ip',
id: 'lastIp',
header: ({ column }) => (
<DataGridColumnHeader title="Последний IP" visibility column={column} />
),
cell: ({ row }) => (
<span className="text-muted-foreground font-mono text-xs tabular-nums">
{row.original.last_login_ip ?? '—'}
</span>
),
size: 140,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: 'Последний IP',
skeleton: <Skeleton className="h-4 w-24" />,
},
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<ActionsCell
row={row}
onOpenAudit={handlers.onOpenAudit}
onOpenAccess={handlers.onOpenAccess}
onDeactivate={handlers.onDeactivate}
onDelete={handlers.onDelete}
onResetPasskeys={handlers.onResetPasskeys}
/>
),
size: 60,
enableSorting: false,
enableHiding: false,
enableResizing: false,
meta: {
skeleton: <Skeleton className="mx-auto size-7 rounded-md" />,
headerClassName:
'[--data-grid-header-cell-pe:var(--frame-panel-header-px)]',
cellClassName: '[--data-grid-body-cell-pe:var(--frame-panel-px)]',
},
},
]
}
export function AdminUsersGrid({
users,
isLoading = false,
onCreateClick,
onOpenAudit,
onOpenAccess,
onDeactivate,
onDelete,
onResetPasskeys,
onBulkSetRole,
onBulkDeactivate,
}: AdminUsersGridProps) {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
const [sorting, setSorting] = useState<SortingState>([
{ id: 'name', desc: false },
])
const [columnVisibility, setColumnVisibility] = useState<ColumnVisibilityState>({})
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
const [bulkRole, setBulkRole] = useState<BulkRole>('user')
const lastAppliedKey = useRef<string>(serializeConditionsKey([]))
const filterFields: FilterField[] = useMemo(
() => [
{
id: 'name',
label: 'Участник',
icon: <UserIcon className="size-3.5" aria-hidden />,
type: 'text',
placeholder: 'Поиск…',
},
{
id: 'email',
label: 'Email',
icon: <MailIcon className="size-3.5" aria-hidden />,
type: 'text',
placeholder: 'Поиск…',
},
{
id: 'role',
label: 'Роль',
icon: <ShieldCheckIcon className="size-3.5" aria-hidden />,
type: 'select',
searchable: false,
options: BULK_ROLE_OPTIONS.map((o) => ({ value: o.value, label: o.label })),
},
{
id: 'status',
label: 'Статус',
icon: <CircleDotIcon className="size-3.5" aria-hidden />,
type: 'select',
searchable: false,
options: [
{ value: 'active', label: 'Активен' },
{ value: 'disabled', label: 'Отключён' },
],
},
],
[],
)
const conditions = useMemo(() => flattenFilterConditions(query), [query])
const filteredData = useMemo(() => applyConditionsToUsers(users, conditions), [users, conditions])
const handleQueryChange = useCallback((next: FilterQuery) => {
setQuery(next)
const nextKey = serializeConditionsKey(flattenFilterConditions(next))
if (nextKey === lastAppliedKey.current) return
lastAppliedKey.current = nextKey
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
setRowSelection({})
}, [])
const columns = useMemo(
() =>
createAdminUserColumns({
onOpenAudit,
onOpenAccess,
onDeactivate,
onDelete,
onResetPasskeys,
}).map((col, i, all) => {
const pad = cellPad(i, all.length)
return {
...col,
meta: {
...col.meta,
headerClassName: cn(col.meta?.headerClassName, pad),
cellClassName: cn(col.meta?.cellClassName, pad),
},
}
}),
[onOpenAudit, onOpenAccess, onDeactivate, onDelete, onResetPasskeys],
)
const [columnOrder, setColumnOrder] = useState<string[]>(
() => columns.map((c) => c.id as string),
)
const table = useTable({
features: dataGridFeatures,
columns,
data: filteredData,
pageCount: Math.ceil(filteredData.length / pagination.pageSize),
getRowId: (row) => row.id,
state: {
pagination,
sorting,
columnOrder,
columnVisibility,
rowSelection,
},
enableRowSelection: true,
columnResizeMode: 'onChange',
onColumnOrderChange: setColumnOrder,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
})
const selectedCount = table.getSelectedRowModel().rows.length
const selectedIds = table
.getSelectedRowModel()
.rows.map((row) => row.original.id)
const showClearButton = conditions.length > 0
const gridLoading = isLoading
const handleClearSelection = useCallback(() => setRowSelection({}), [])
const handleChangeRole = useCallback(() => {
if (selectedCount === 0) return
onBulkSetRole(selectedIds, bulkRole === 'admin')
setRowSelection({})
}, [bulkRole, onBulkSetRole, selectedCount, selectedIds])
const handleBulkDeactivate = useCallback(() => {
if (selectedCount === 0) return
onBulkDeactivate(selectedIds)
setRowSelection({})
}, [onBulkDeactivate, selectedCount, selectedIds])
const handleClearFilters = useCallback(() => {
lastAppliedKey.current = serializeConditionsKey([])
setQuery(createFilterQuery())
setRowSelection({})
}, [])
return (
<TooltipProvider delay={200}>
<DataGrid
table={table}
i18n={DATA_GRID_RU}
isLoading={gridLoading}
loadingMode="skeleton"
recordCount={filteredData.length}
onRowClick={onOpenAudit}
emptyMessage={
!gridLoading && filteredData.length === 0
? 'Нет пользователей по текущим фильтрам. Сбросьте фильтры или измените условия.'
: undefined
}
tableLayout={{
...DATA_GRID_LAYOUT,
columnsResizable: true,
columnsMovable: true,
columnsVisibility: true,
headerSticky: true,
}}
tableClassNames={DATA_GRID_CLASS_NAMES}
>
<DataPageCard>
<DataPageToolbar
filterControl={
<Filters
query={query}
fields={filterFields}
onQueryChange={handleQueryChange}
labels={FILTERS_RU}
operatorLabels={FILTER_OPERATORS_RU}
size="default"
trigger={
<Button type="button" size="default" variant="outline" aria-label="Фильтры">
<FilterIcon aria-hidden />
Фильтры
</Button>
}
/>
}
countLabel={`${filteredData.length} пользователей`}
actions={
<>
{showClearButton ? (
<Button type="button" size="default" variant="outline" className="shrink-0" onClick={handleClearFilters} disabled={gridLoading}>
<FunnelXIcon aria-hidden />
Сбросить
</Button>
) : null}
<DataGridColumnVisibility
table={table}
trigger={
<Button type="button" size="default" variant="outline" aria-label="Настройки таблицы">
<Settings2Icon aria-hidden="true" />
Колонки
</Button>
}
/>
<Button type="button" size="default" onClick={onCreateClick}>
<UserPlusIcon aria-hidden="true" />
Создать
</Button>
</>
}
/>
{selectedCount > 0 ? (
<AdminUsersBulkActionBar
selectedCount={selectedCount}
roleValue={bulkRole}
onRoleChange={setBulkRole}
onChangeRole={handleChangeRole}
onDeactivate={handleBulkDeactivate}
onClear={handleClearSelection}
/>
) : null}
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<DataGridPagination className="px-5 py-3" sizes={[10, 20, 30]} />
</DataPageCard>
</DataGrid>
</TooltipProvider>
)
}
@@ -0,0 +1,175 @@
/**
* Admin editor for portal App Switcher URLs.
* Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3
*/
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import {
APP_IDS,
appSwitcherConfigSchema,
type AppId,
type AppSwitcherConfig,
type AppSwitcherIconName,
} from '@authportal/shared'
import { Button } from '@authportal/ui/components/button'
import { Field, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@authportal/ui/components/select'
import { Switch } from '@authportal/ui/components/switch'
import { ItemSeparator } from '@authportal/ui/components/item'
const ICON_OPTIONS: AppSwitcherIconName[] = [
'server',
'cloud',
'globe',
'dashboard',
'chart',
]
interface AppSwitcherAdminEditorProps {
defaultValues: AppSwitcherConfig
onSave: (values: AppSwitcherConfig) => void
isSaving?: boolean
}
export function AppSwitcherAdminEditor({
defaultValues,
onSave,
isSaving,
}: AppSwitcherAdminEditorProps) {
const form = useForm({
resolver: zodResolver(appSwitcherConfigSchema),
defaultValues,
})
useEffect(() => {
form.reset(defaultValues)
}, [defaultValues, form])
return (
<form
className="flex flex-col gap-5"
onSubmit={(e) =>
void form.handleSubmit((values) => onSave(values))(e)
}
>
<FieldGroup>
<Field>
<FieldLabel htmlFor="menu-label">Заголовок меню</FieldLabel>
<Input id="menu-label" {...form.register('menuLabel')} />
</Field>
{APP_IDS.map((appId, index) => {
const apps = form.watch('apps')
const appIndex = apps.findIndex((a) => a.id === appId)
if (appIndex < 0) return null
return (
<div key={appId} className="flex flex-col gap-3">
{index > 0 ? <ItemSeparator /> : null}
<p className="text-sm font-medium tracking-tight">
{appId.toUpperCase()}
</p>
<div className="grid gap-3 sm:grid-cols-2">
<Field className="sm:col-span-2">
<FieldLabel htmlFor={`name-${appId}`}>Название</FieldLabel>
<Input
id={`name-${appId}`}
{...form.register(`apps.${appIndex}.name`)}
/>
</Field>
<Field className="sm:col-span-2">
<FieldLabel htmlFor={`url-${appId}`}>URL</FieldLabel>
<Input
id={`url-${appId}`}
{...form.register(`apps.${appIndex}.url`)}
placeholder="https://…"
/>
</Field>
{appId === 'bgp' ? (
<Field className="sm:col-span-2">
<FieldLabel htmlFor={`tenant-${appId}`}>
EvoBGP tenant ID
</FieldLabel>
<Input
id={`tenant-${appId}`}
{...form.register(`apps.${appIndex}.tenantId`)}
placeholder="UUID тенанта из EvoBGP"
/>
<p className="text-muted-foreground text-xs">
Попадает в JWT (`bgp_tenant_id` / `tenants.bgp`). Публичный
switcher его не отдаёт.
</p>
</Field>
) : null}
<Field className="sm:col-span-2">
<FieldLabel htmlFor={`subtitle-${appId}`}>
Описание
</FieldLabel>
<Input
id={`subtitle-${appId}`}
{...form.register(`apps.${appIndex}.subtitle`)}
/>
</Field>
<Field>
<FieldLabel htmlFor={`icon-${appId}`}>Иконка</FieldLabel>
<Select
value={form.watch(`apps.${appIndex}.icon`)}
onValueChange={(v) =>
form.setValue(
`apps.${appIndex}.icon`,
(v ?? 'server') as AppSwitcherIconName,
{ shouldDirty: true },
)
}
>
<SelectTrigger id={`icon-${appId}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ICON_OPTIONS.map((icon) => (
<SelectItem key={icon} value={icon}>
{icon}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field orientation="horizontal" className="items-center pt-6">
<Switch
checked={form.watch(`apps.${appIndex}.enabled`) !== false}
onCheckedChange={(v) =>
form.setValue(`apps.${appIndex}.enabled`, v, {
shouldDirty: true,
})
}
/>
<FieldLabel className="font-normal">Включено в switcher</FieldLabel>
</Field>
</div>
<input
type="hidden"
{...form.register(`apps.${appIndex}.id`)}
value={appId satisfies AppId}
/>
</div>
)
})}
</FieldGroup>
<Button
type="submit"
className="w-fit"
disabled={isSaving || !form.formState.isDirty}
>
{isSaving ? 'Сохранение…' : 'Сохранить'}
</Button>
</form>
)
}
@@ -0,0 +1,237 @@
import type { AuditLogEntry, AuditSeverity, AuditSourceApp } from '@authportal/shared'
import {
KeyRoundIcon,
LogInIcon,
LogOutIcon,
SettingsIcon,
ShieldAlertIcon,
Trash2Icon,
UserCogIcon,
UserPlusIcon,
UserXIcon,
type LucideIcon,
} from 'lucide-react'
import type { BadgeProps } from '@/components/reui/badge'
export type AuditFilterId = 'all' | 'auth' | 'users' | 'settings'
export const AUDIT_FILTER_OPTIONS: { value: AuditFilterId; label: string }[] = [
{ value: 'all', label: 'Все' },
{ value: 'auth', label: 'Auth' },
{ value: 'users', label: 'Пользователи' },
{ value: 'settings', label: 'Настройки' },
]
export const RANGE_OPTIONS = [
{ value: '24h', label: 'За 24 часа' },
{ value: '7d', label: 'За 7 дней' },
{ value: '30d', label: 'За 30 дней' },
{ value: '90d', label: 'За 90 дней' },
{ value: 'all', label: 'Весь период' },
] as const
export type AuditRange = (typeof RANGE_OPTIONS)[number]['value']
export const SOURCE_APP_OPTIONS: {
value: AuditSourceApp | 'all'
label: string
}[] = [
{ value: 'all', label: 'Все приложения' },
{ value: 'portal', label: 'Auth Portal' },
{ value: 'vps', label: 'VPS Tracker' },
{ value: 'cfdm', label: 'CFDM' },
{ value: 'bgp', label: 'EvoBGP' },
{ value: 'fw', label: 'EvoFirewall' },
{ value: 'dns', label: 'Technitium DNS' },
{ value: 'cdn', label: 'CDN Manager' },
{ value: 'mm', label: 'MikrotikManager' },
]
export const severityVariant: Record<AuditSeverity, BadgeProps['variant']> = {
info: 'success-outline',
warning: 'warning-outline',
critical: 'destructive-outline',
}
export const severityLabel: Record<AuditSeverity, string> = {
info: 'Info',
warning: 'Warning',
critical: 'Critical',
}
export const severityDotClass: Record<AuditSeverity, string> = {
info: 'bg-success',
warning: 'bg-warning',
critical: 'bg-destructive',
}
const ACTION_META: Record<
string,
{ label: string; icon: LucideIcon; filter: AuditFilterId }
> = {
'auth.login': { label: 'Вход', icon: LogInIcon, filter: 'auth' },
'auth.login_failed': {
label: 'Неудачный вход',
icon: ShieldAlertIcon,
filter: 'auth',
},
'auth.logout': { label: 'Выход', icon: LogOutIcon, filter: 'auth' },
'auth.sso_handoff': {
label: 'SSO',
icon: KeyRoundIcon,
filter: 'auth',
},
'user.create': { label: 'Создание пользователя', icon: UserPlusIcon, filter: 'users' },
'user.update': { label: 'Изменение пользователя', icon: UserCogIcon, filter: 'users' },
'user.delete': { label: 'Удаление пользователя', icon: UserXIcon, filter: 'users' },
'user.access_update': {
label: 'Обновление прав',
icon: KeyRoundIcon,
filter: 'users',
},
'app_switcher.update': {
label: 'App Switcher',
icon: SettingsIcon,
filter: 'settings',
},
'audit.settings_update': {
label: 'Срок хранения журнала',
icon: SettingsIcon,
filter: 'settings',
},
'audit.purge': { label: 'Очистка журнала', icon: Trash2Icon, filter: 'settings' },
}
export function actionMeta(action: string) {
return (
ACTION_META[action] ?? {
label: action,
icon: SettingsIcon,
filter: 'settings' as AuditFilterId,
}
)
}
export function matchesFilter(entry: AuditLogEntry, filter: AuditFilterId) {
if (filter === 'all') return true
return actionMeta(entry.action).filter === filter
}
export function matchesSourceApp(
entry: AuditLogEntry,
source: AuditSourceApp | 'all',
) {
if (source === 'all') return true
return entry.source_app === source
}
export function sourceAppLabel(app: AuditSourceApp | string): string {
return (
SOURCE_APP_OPTIONS.find((o) => o.value === app)?.label ?? String(app)
)
}
export function detailString(
details: Record<string, unknown> | null | undefined,
key: string,
): string | null {
if (!details) return null
const v = details[key]
if (typeof v === 'string' && v.trim()) return v
return null
}
export function matchesRange(entry: AuditLogEntry, range: AuditRange) {
if (range === 'all') return true
const ms =
range === '24h'
? 24 * 60 * 60 * 1000
: range === '7d'
? 7 * 24 * 60 * 60 * 1000
: range === '30d'
? 30 * 24 * 60 * 60 * 1000
: 90 * 24 * 60 * 60 * 1000
return Date.now() - new Date(entry.created_at).getTime() <= ms
}
export function initials(name: string | null, email: string | null) {
const source = (name ?? email ?? '?').trim()
const parts = source.split(/\s+/).filter(Boolean)
if (parts.length >= 2) {
return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase()
}
return source.slice(0, 2).toUpperCase()
}
export type AuditDayGroup = {
key: string
label: string
events: AuditLogEntry[]
}
export function groupByDay(entries: AuditLogEntry[]): AuditDayGroup[] {
const map = new Map<string, AuditLogEntry[]>()
for (const entry of entries) {
const d = new Date(entry.created_at)
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
const list = map.get(key) ?? []
list.push(entry)
map.set(key, list)
}
return [...map.entries()].map(([key, events]) => ({
key,
label: new Date(events[0]!.created_at).toLocaleDateString('ru-RU', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
}),
events,
}))
}
export function formatEventTime(iso: string) {
return new Date(iso).toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
export function exportAuditCsv(entries: AuditLogEntry[]) {
const header = [
'created_at',
'action',
'severity',
'actor_email',
'actor_name',
'summary',
'target_type',
'target_id',
'ip',
]
const rows = entries.map((e) =>
[
e.created_at,
e.action,
e.severity,
e.actor_email ?? '',
e.actor_name ?? '',
e.summary,
e.target_type ?? '',
e.target_id ?? '',
e.ip ?? '',
]
.map((v) => `"${String(v).replaceAll('"', '""')}"`)
.join(','),
)
const blob = new Blob([[header.join(','), ...rows].join('\n')], {
type: 'text/csv;charset=utf-8',
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `audit-log-${new Date().toISOString().slice(0, 10)}.csv`
a.click()
URL.revokeObjectURL(url)
}
@@ -0,0 +1,475 @@
/**
* Audit log timeline — adapted from @reui/solution-users-6.
* Preview: https://reui.io/preview/base/solution-users-6
* Docs: https://reui.io/blocks · Timeline: https://reui.io/docs/components/base/timeline
*/
import { useMemo, useState } from 'react'
import type { AuditLogEntry, AuditSourceApp } from '@authportal/shared'
import { toast } from 'sonner'
import {
CalendarIcon,
ChevronRightIcon,
CopyIcon,
DownloadIcon,
FilterIcon,
} from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import {
Frame,
FrameHeader,
FramePanel,
} from '@/components/reui/frame'
import {
Timeline,
TimelineContent,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
} from '@/components/reui/timeline'
import { cn } from '@authportal/ui/lib/utils'
import { Avatar, AvatarFallback } from '@authportal/ui/components/avatar'
import { Button } from '@authportal/ui/components/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@authportal/ui/components/collapsible'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@authportal/ui/components/empty'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@authportal/ui/components/select'
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import {
AUDIT_FILTER_OPTIONS,
RANGE_OPTIONS,
SOURCE_APP_OPTIONS,
actionMeta,
detailString,
exportAuditCsv,
formatEventTime,
groupByDay,
initials,
matchesFilter,
matchesRange,
matchesSourceApp,
severityDotClass,
severityLabel,
severityVariant,
sourceAppLabel,
type AuditFilterId,
type AuditRange,
} from './audit-log-helpers'
function copyValue(value: string) {
if (typeof navigator !== 'undefined' && navigator.clipboard) {
void navigator.clipboard.writeText(value).catch(() => undefined)
}
}
function DetailRow({
label,
children,
}: {
label: string
children: React.ReactNode
}) {
return (
<div className="flex min-w-0 flex-col gap-0.5">
<dt className="text-muted-foreground text-xs">{label}</dt>
<dd className="flex min-w-0 items-center text-sm">{children}</dd>
</div>
)
}
function EventRow({
event,
isLast,
step,
defaultOpen,
}: {
event: AuditLogEntry
isLast: boolean
step: number
defaultOpen: boolean
}) {
const [open, setOpen] = useState(defaultOpen)
const meta = actionMeta(event.action)
const Icon = meta.icon
const actorName = event.actor_name ?? event.actor_email ?? 'Система'
const userAgent = detailString(event.details, 'user_agent')
const targetApp =
detailString(event.details, 'target_app') ?? event.source_app
const reason = detailString(event.details, 'reason')
return (
<TimelineItem step={step} className={cn('ms-10 min-w-0', isLast ? 'pb-0' : 'pb-6')}>
<TimelineHeader className="flex min-w-0 items-center justify-between gap-2.5">
<TimelineSeparator className="bg-border! group-data-[orientation=vertical]/timeline:-left-7 group-data-[orientation=vertical]/timeline:h-[calc(100%-1.5rem-0.5rem)] group-data-[orientation=vertical]/timeline:translate-y-7" />
<div className="flex flex-wrap items-center gap-2">
<TimelineTitle className="text-sm font-semibold">
{meta.label}
</TimelineTitle>
<Badge variant={severityVariant[event.severity]} className="gap-1.5">
<span
className={cn(
'size-1.5 shrink-0 rounded-full',
severityDotClass[event.severity],
)}
aria-hidden="true"
/>
{severityLabel[event.severity]}
</Badge>
<Badge variant="outline" size="sm">
{sourceAppLabel(event.source_app)}
</Badge>
<span className="text-muted-foreground text-xs tabular-nums">
{formatEventTime(event.created_at)}
</span>
</div>
<TimelineIndicator className="border-border bg-background text-muted-foreground flex size-6 items-center justify-center border shadow-xs group-data-[orientation=vertical]/timeline:-left-7 [&_svg]:size-3.5">
<Icon aria-hidden="true" />
</TimelineIndicator>
</TimelineHeader>
<TimelineContent className="mt-2 min-w-0">
<Frame stacked dense spacing="sm" className="min-w-0">
<Collapsible
open={open}
onOpenChange={setOpen}
className="group/collapsible"
>
<CollapsibleTrigger
type="button"
className="flex w-full min-w-0"
aria-label={`Подробности: ${meta.label}`}
>
<FrameHeader className="flex min-w-0 grow flex-row items-center justify-between gap-2">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<Avatar className="size-5">
<AvatarFallback className="text-[10px]">
{initials(event.actor_name, event.actor_email)}
</AvatarFallback>
</Avatar>
<span className="text-foreground min-w-0 truncate text-sm font-medium">
{event.summary || actorName}
</span>
{event.ip ? (
<span className="text-muted-foreground font-mono text-xs tabular-nums">
{event.ip}
</span>
) : null}
{targetApp && targetApp !== 'portal' ? (
<Badge variant="info-outline" size="sm">
{sourceAppLabel(targetApp)}
</Badge>
) : null}
</div>
<ChevronRightIcon
className="text-muted-foreground size-4 shrink-0 transition-transform duration-200 group-data-open/collapsible:rotate-90"
aria-hidden="true"
/>
</FrameHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<FramePanel className="flex flex-col gap-3">
<dl className="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
<DetailRow label="Логин">
<span className="text-foreground truncate font-medium">
{event.actor_email ?? '—'}
</span>
</DetailRow>
<DetailRow label="IP">
<span className="text-foreground truncate font-medium tabular-nums font-mono text-xs">
{event.ip ?? '—'}
</span>
</DetailRow>
<DetailRow label="User-Agent">
<span
className="text-foreground line-clamp-2 text-xs"
title={userAgent ?? undefined}
>
{userAgent ?? '—'}
</span>
</DetailRow>
<DetailRow label="Приложение">
<span className="text-foreground truncate font-medium">
{sourceAppLabel(targetApp)}
</span>
</DetailRow>
{reason ? (
<DetailRow label="Причина">
<span className="text-foreground truncate font-medium">
{reason}
</span>
</DetailRow>
) : null}
<DetailRow label="Действие">
<span className="text-foreground truncate font-mono text-xs">
{event.action}
</span>
</DetailRow>
</dl>
{event.details ? (
<pre className="bg-muted/50 text-muted-foreground max-h-40 overflow-auto rounded-md p-2 text-xs">
{JSON.stringify(event.details, null, 2)}
</pre>
) : null}
<div className="flex flex-wrap items-center justify-between gap-2.5 border-t pt-2.5">
<Badge variant="outline" className="gap-1.5 font-mono">
{event.id.slice(0, 8)}
</Badge>
<div className="flex flex-wrap gap-1">
{userAgent ? (
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
copyValue(userAgent)
toast.success('User-Agent скопирован')
}}
>
<CopyIcon className="opacity-60" aria-hidden="true" />
UA
</Button>
) : null}
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
copyValue(event.id)
toast.success('ID скопирован')
}}
>
<CopyIcon className="opacity-60" aria-hidden="true" />
ID
</Button>
</div>
</div>
</FramePanel>
</CollapsibleContent>
</Collapsible>
</Frame>
</TimelineContent>
</TimelineItem>
)
}
export function AuditLogTimeline({
entries,
totalCount,
compact = false,
lockedUserId,
sourceAppFilter,
onSourceAppFilterChange,
}: {
entries: AuditLogEntry[]
totalCount: number
compact?: boolean
lockedUserId?: string
sourceAppFilter?: AuditSourceApp | 'all'
onSourceAppFilterChange?: (v: AuditSourceApp | 'all') => void
}) {
const [filter, setFilter] = useState<AuditFilterId>('all')
const [range, setRange] = useState<AuditRange>(compact ? 'all' : '7d')
const [localSource, setLocalSource] = useState<AuditSourceApp | 'all'>(
sourceAppFilter ?? 'all',
)
const source = sourceAppFilter ?? localSource
const setSource = onSourceAppFilterChange ?? setLocalSource
const visible = useMemo(
() =>
entries.filter(
(e) =>
matchesFilter(e, filter) &&
matchesRange(e, range) &&
matchesSourceApp(e, source),
),
[entries, filter, range, source],
)
const days = useMemo(() => groupByDay(visible), [visible])
return (
<section className="w-full" aria-labelledby="audit-log-title">
<div className={cn('flex flex-col gap-4', compact ? 'mb-4' : 'mb-6')}>
{!compact ? (
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="flex min-w-0 flex-col gap-1">
<h1
id="audit-log-title"
className="text-xl font-semibold tracking-tight"
>
Журнал изменений
</h1>
<p className="text-muted-foreground text-sm leading-5">
{totalCount} событий · показано {visible.length}
{lockedUserId ? ' · пользователь' : ''}
</p>
</div>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Select
value={source}
onValueChange={(value) => {
if (value) setSource(value as AuditSourceApp | 'all')
}}
items={[...SOURCE_APP_OPTIONS]}
>
<SelectTrigger size="sm" className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent align="end" alignItemWithTrigger={false}>
<SelectGroup>
{SOURCE_APP_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Select
value={range}
onValueChange={(value) => {
if (value) setRange(value as AuditRange)
}}
items={[...RANGE_OPTIONS]}
>
<SelectTrigger size="sm" className="w-44">
<CalendarIcon
className="text-muted-foreground size-4"
aria-hidden="true"
/>
<SelectValue />
</SelectTrigger>
<SelectContent align="end" alignItemWithTrigger={false}>
<SelectGroup>
{RANGE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
size="sm"
type="button"
variant="outline"
onClick={() => {
exportAuditCsv(visible)
toast.success('CSV экспортирован', {
description: `${visible.length} событий`,
})
}}
>
<DownloadIcon aria-hidden="true" />
<span className="hidden sm:inline">Export CSV</span>
</Button>
</div>
</div>
) : (
<p
id="audit-log-title"
className="text-muted-foreground text-sm leading-5"
>
{visible.length} из {totalCount} событий
</p>
)}
<Tabs
value={filter}
onValueChange={(value) => {
if (value) setFilter(value as AuditFilterId)
}}
>
<TabsList
variant="line"
aria-label="Фильтр по типу"
className="h-10! w-full justify-start gap-6 overflow-x-auto border-b"
>
{AUDIT_FILTER_OPTIONS.map((option) => (
<TabsTrigger
key={option.value}
value={option.value}
className="px-1 text-sm after:-bottom-px!"
>
{option.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
{days.length === 0 ? (
<Empty className="min-h-[280px] border-0 bg-transparent">
<EmptyHeader>
<EmptyMedia variant="icon">
<FilterIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Нет событий</EmptyTitle>
<EmptyDescription>
Нет записей по текущему фильтру. Сбросьте фильтр или расширьте
период.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button
type="button"
variant="outline"
onClick={() => {
setFilter('all')
setRange('all')
}}
>
Сбросить
</Button>
</EmptyContent>
</Empty>
) : (
<div className="flex flex-col gap-8">
{days.map((day, dayIndex) => (
<div key={day.key} className="flex flex-col gap-4">
<h2 className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
{day.label}
</h2>
<Timeline>
{day.events.map((event, index) => (
<EventRow
key={event.id}
event={event}
step={index + 1}
isLast={index === day.events.length - 1}
defaultOpen={dayIndex === 0 && index < 2}
/>
))}
</Timeline>
</div>
))}
</div>
)}
</section>
)
}
@@ -0,0 +1,132 @@
/**
* Retention settings row — DNA settings-2.
* Preview: https://reui.io/preview/base/settings-2
*/
import { useEffect, useState } from 'react'
import { HistoryIcon } from 'lucide-react'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Button } from '@authportal/ui/components/button'
import { Input } from '@authportal/ui/components/input'
import {
Item,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from '@authportal/ui/components/item'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@authportal/ui/components/alert-dialog'
export function AuditRetentionPanel({
retentionDays,
saving,
purging,
onSave,
onPurge,
}: {
retentionDays: number
saving: boolean
purging: boolean
onSave: (days: number) => void
onPurge: () => void
}) {
const [days, setDays] = useState(String(retentionDays))
const [confirmOpen, setConfirmOpen] = useState(false)
useEffect(() => {
setDays(String(retentionDays))
}, [retentionDays])
const parsed = Number.parseInt(days, 10)
const valid = Number.isFinite(parsed) && parsed >= 7 && parsed <= 3650
return (
<>
<Frame className="w-full">
<FrameHeader>
<FrameTitle>Ротация журнала</FrameTitle>
<FrameDescription>
Записи старше N дней удаляются автоматически каждый час
</FrameDescription>
</FrameHeader>
<FramePanel className="flex flex-col gap-4">
<Item variant="outline" className="items-start">
<ItemMedia variant="icon">
<HistoryIcon className="size-4" aria-hidden="true" />
</ItemMedia>
<ItemContent>
<ItemTitle>Срок хранения</ItemTitle>
<ItemDescription>
От 7 до 3650 дней. Сейчас: {retentionDays} дн.
</ItemDescription>
</ItemContent>
<div className="flex shrink-0 items-center gap-2">
<Input
type="number"
min={7}
max={3650}
value={days}
onChange={(e) => setDays(e.target.value)}
className="w-24 tabular-nums"
aria-label="Дней хранения"
/>
<Button
size="sm"
disabled={!valid || saving || parsed === retentionDays}
onClick={() => onSave(parsed)}
>
{saving ? 'Сохранение…' : 'Сохранить'}
</Button>
<Button
size="sm"
variant="outline"
disabled={purging}
onClick={() => setConfirmOpen(true)}
>
Очистить сейчас
</Button>
</div>
</Item>
</FramePanel>
</Frame>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Очистить старые записи?</AlertDialogTitle>
<AlertDialogDescription>
Будут удалены события старше {retentionDays} дней. Это действие
необратимо.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
setConfirmOpen(false)
onPurge()
}}
>
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
@@ -0,0 +1,174 @@
/**
* Create OIDC client Sheet — auth-portal admin.
* Preview: https://reui.io/preview/base/sheet-8 · https://reui.io/preview/base/solution-users-1
* Docs: https://reui.io/blocks · https://ui.shadcn.com/docs/components/base/sheet
*/
import { useEffect, useState } from 'react'
import { XIcon } from 'lucide-react'
import { Button } from '@authportal/ui/components/button'
import { Field, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input'
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@authportal/ui/components/sheet'
import { Switch } from '@authportal/ui/components/switch'
import { Textarea } from '@authportal/ui/components/textarea'
const mutedIconButtonClassName = 'text-muted-foreground hover:text-foreground'
export type CreateOidcClientValues = {
name: string
redirect_uris: string[]
scopes: Array<'openid' | 'profile' | 'email' | 'groups'>
enabled: boolean
}
export function CreateOidcClientSheet({
open,
onOpenChange,
pending,
onSubmit,
}: {
open: boolean
onOpenChange: (open: boolean) => void
pending: boolean
onSubmit: (values: CreateOidcClientValues) => void
}) {
const [name, setName] = useState('Technitium DNS')
const [redirectUris, setRedirectUris] = useState(
'https://dns.shnt.top/sso/callback',
)
const [enabled, setEnabled] = useState(true)
useEffect(() => {
if (!open) {
setName('Technitium DNS')
setRedirectUris('https://dns.shnt.top/sso/callback')
setEnabled(true)
}
}, [open])
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
showCloseButton={false}
className="inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] w-[min(24rem,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none sm:max-w-none"
>
<SheetHeader className="shrink-0 gap-0 p-0">
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
<SheetTitle className="min-w-0 truncate text-base font-semibold">
Новый OIDC-клиент
</SheetTitle>
<SheetClose
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Закрыть"
className={mutedIconButtonClassName}
>
<XIcon aria-hidden="true" />
</Button>
}
/>
</div>
<SheetDescription className="text-muted-foreground border-b px-4 py-2 text-sm">
Redirect URI для Technitium:{' '}
<code className="text-xs">https://&lt;host&gt;/sso/callback</code>
</SheetDescription>
</SheetHeader>
<form
id="create-oidc-client-form"
className="flex min-h-0 flex-1 flex-col"
onSubmit={(e) => {
e.preventDefault()
const uris = redirectUris
.split('\n')
.map((s) => s.trim())
.filter(Boolean)
onSubmit({
name: name.trim(),
redirect_uris: uris,
scopes: ['openid', 'profile', 'email', 'groups'],
enabled,
})
}}
>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5">
<FieldGroup>
<Field>
<FieldLabel htmlFor="oidc-name">Название</FieldLabel>
<Input
id="oidc-name"
name="name"
value={name}
onChange={(e) => setName(e.target.value)}
required
autoComplete="off"
/>
</Field>
<Field>
<FieldLabel htmlFor="oidc-redirects">
Redirect URIs
</FieldLabel>
<Textarea
id="oidc-redirects"
name="redirect_uris"
className="min-h-24"
value={redirectUris}
onChange={(e) => setRedirectUris(e.target.value)}
placeholder="https://dns.example.com/sso/callback"
required
/>
<p className="text-muted-foreground text-xs">
По одному URI на строку
</p>
</Field>
<Field orientation="horizontal" className="items-center justify-between">
<FieldLabel htmlFor="oidc-enabled" className="font-normal">
Включён
</FieldLabel>
<Switch
id="oidc-enabled"
checked={enabled}
onCheckedChange={(v: boolean) => setEnabled(v === true)}
/>
</Field>
</FieldGroup>
</div>
<SheetFooter className="bg-background shrink-0 border-t">
<div className="flex w-full gap-2">
<Button
type="button"
variant="outline"
className="min-w-0 flex-1"
disabled={pending}
onClick={() => onOpenChange(false)}
>
Отмена
</Button>
<Button
type="submit"
form="create-oidc-client-form"
className="min-w-0 flex-1"
disabled={pending || !name.trim() || !redirectUris.trim()}
>
{pending ? 'Создание…' : 'Создать'}
</Button>
</div>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,172 @@
/**
* Create user Sheet — auth-portal admin.
* Preview: https://reui.io/preview/base/solution-users-1
*/
import { useEffect, useState } from 'react'
import type { CreateUserRequest } from '@authportal/shared'
import { XIcon } from 'lucide-react'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { Button } from '@authportal/ui/components/button'
import { Checkbox } from '@authportal/ui/components/checkbox'
import { Field, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input'
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@authportal/ui/components/sheet'
const mutedIconButtonClassName = 'text-muted-foreground hover:text-foreground'
export function CreateUserSheet({
open,
onOpenChange,
pending,
error,
onSubmit,
}: {
open: boolean
onOpenChange: (open: boolean) => void
pending: boolean
error: string | null
onSubmit: (values: CreateUserRequest) => void
}) {
const [isAdmin, setIsAdmin] = useState(false)
useEffect(() => {
if (!open) setIsAdmin(false)
}, [open])
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
showCloseButton={false}
className="inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] w-[min(24rem,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none sm:max-w-none"
>
<SheetHeader className="shrink-0 gap-0 p-0">
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
<SheetTitle className="min-w-0 truncate text-base font-semibold">
Новый пользователь
</SheetTitle>
<SheetClose
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Закрыть"
className={mutedIconButtonClassName}
>
<XIcon aria-hidden="true" />
</Button>
}
/>
</div>
<SheetDescription className="sr-only">
Создание учётной записи портала
</SheetDescription>
</SheetHeader>
<form
id="create-user-form"
className="flex min-h-0 flex-1 flex-col"
onSubmit={(e) => {
e.preventDefault()
const fd = new FormData(e.currentTarget)
onSubmit({
email: String(fd.get('email') ?? ''),
name: String(fd.get('name') ?? ''),
password: String(fd.get('password') ?? ''),
is_admin: isAdmin,
apps: [],
permissions: [],
})
}}
>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5">
<FieldGroup>
<Field>
<FieldLabel htmlFor="create-name">Имя</FieldLabel>
<Input
id="create-name"
name="name"
autoComplete="name"
required
/>
</Field>
<Field>
<FieldLabel htmlFor="create-email">Email</FieldLabel>
<Input
id="create-email"
name="email"
type="email"
autoComplete="email"
required
/>
</Field>
<Field>
<FieldLabel htmlFor="create-password">Пароль</FieldLabel>
<Input
id="create-password"
name="password"
type="password"
autoComplete="new-password"
minLength={6}
required
/>
</Field>
<Field orientation="horizontal">
<Checkbox
id="create-is-admin"
checked={isAdmin}
onCheckedChange={(v) => setIsAdmin(v === true)}
/>
<FieldLabel htmlFor="create-is-admin" className="font-normal">
Администратор портала
</FieldLabel>
</Field>
</FieldGroup>
{error ? (
<Alert variant="destructive" className="mt-5">
<AlertTitle>Ошибка</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
</div>
<SheetFooter className="bg-background shrink-0 border-t">
<div className="flex w-full gap-2">
<Button
type="button"
variant="outline"
className="min-w-0 flex-1"
disabled={pending}
onClick={() => onOpenChange(false)}
>
Отмена
</Button>
<Button
type="submit"
form="create-user-form"
className="min-w-0 flex-1"
disabled={pending}
>
{pending ? 'Создание…' : 'Создать'}
</Button>
</div>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
)
}
+18
View File
@@ -0,0 +1,18 @@
export {
KpiStatGrid,
KpiStatCard,
KpiStatCardTile,
kpiStatItemKey,
type KpiStatItem,
type KpiStatCardData,
type KpiStatCard as KpiStatCardType,
type KpiStatVariant,
type OpsKpiCard,
} from './kpi-stat-grid'
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
export { AppSwitcherAdminEditor } from './app-switcher-admin-editor'
export { UserAccessSheet } from './user-access-sheet'
export { UserAuditSheet } from './user-audit-sheet'
export { CreateUserSheet } from './create-user-sheet'
export { CreateOidcClientSheet } from './create-oidc-client-sheet'
export { AdminUsersGrid, type AdminUsersGridProps } from './admin-users-grid'
@@ -0,0 +1,10 @@
/** Shared grid column classes for hybrid KPI / Quick Actions tiles. */
export function kpiCols(count: number): string {
if (count <= 1) return 'grid-cols-1'
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
if (count === 4) return 'grid-cols-1 @xl:grid-cols-2 @6xl:grid-cols-4'
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
}
@@ -0,0 +1,322 @@
import type { KeyboardEvent, ReactNode } from 'react'
import { Link } from '@tanstack/react-router'
import { Frame, FramePanel } from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { cn } from '@authportal/ui/lib/utils'
import { kpiCols } from './kpi-cols'
import { IconTile } from '@/components/reui/icon-tile'
import { Skeleton } from '@authportal/ui/components/skeleton'
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
/**
* KPI tile data — horizontal compact hybrid (icon left + label/Badge + value).
* @see https://reui.io/preview/base/stats-12
*/
export type KpiStatItem = {
id?: string
label: ReactNode
value: ReactNode
hint?: ReactNode
to?: string
search?: Record<string, unknown>
onSelect?: () => void
onClick?: () => void
selected?: boolean
active?: boolean
icon?: ReactNode
iconClassName?: string
variant?: KpiStatVariant
footer?: ReactNode
}
/** CFDM-compatible card shape (id required). */
export type KpiStatCardData = KpiStatItem & { id: string }
/** @deprecated Use KpiStatCardData */
export type OpsKpiCard = KpiStatCardData
/** @deprecated Use KpiStatCardData — type alias for CFDM kit parity */
export type KpiStatCard = KpiStatCardData
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
default: 'text-foreground',
warning: 'text-warning',
destructive: 'text-destructive',
}
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onActivate()
}
}
function resolveActivate(item: KpiStatItem): (() => void) | undefined {
return item.onClick ?? item.onSelect
}
function isSelected(item: KpiStatItem): boolean {
return Boolean(item.selected ?? item.active)
}
function resolveFooter(item: KpiStatItem): ReactNode {
if (item.footer) return item.footer
if (typeof item.hint === 'string') {
return (
<Badge variant="outline" size="sm">
{item.hint}
</Badge>
)
}
if (item.hint) return item.hint
return null
}
function KpiStatCardBody({ item }: { item: KpiStatItem }) {
const footer = resolveFooter(item)
const valueVariant = item.variant ?? 'default'
return (
<div className="relative z-10 flex h-full items-start gap-3">
{item.icon ? (
<IconTile
variant="elevated"
aria-hidden="true"
className={cn('size-10.5', item.iconClassName ?? DEFAULT_ICON_CLASS)}
>
{item.icon}
</IconTile>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-start justify-between gap-2">
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
{footer ? <div className="shrink-0">{footer}</div> : null}
</div>
<div
className={cn(
'text-2xl leading-none font-bold tabular-nums',
VALUE_VARIANT_CLASS[valueVariant],
)}
>
{item.value}
</div>
</div>
</div>
)
}
function panelClassName(item: KpiStatItem, className?: string) {
const onActivate = resolveActivate(item)
const clickable = Boolean(item.to || onActivate)
const selected = isSelected(item)
return cn(
'relative isolate flex h-full flex-col',
clickable &&
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
selected && 'ring-primary/30 bg-muted/30 ring-1',
className,
)
}
/** Single KPI tile — used for embedded / standalone contexts. */
export function KpiStatCardTile({
item,
embedded = false,
className,
}: {
item: KpiStatItem
embedded?: boolean
className?: string
}) {
const onActivate = resolveActivate(item)
const panelClass = panelClassName(item, className)
let panel: ReactNode
if (item.to) {
panel = (
<FramePanel className={panelClass}>
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
<KpiStatCardBody item={item} />
</Link>
</FramePanel>
)
} else if (onActivate) {
panel = (
<FramePanel
className={panelClass}
onClick={onActivate}
role="button"
tabIndex={0}
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
>
<KpiStatCardBody item={item} />
</FramePanel>
)
} else {
panel = (
<FramePanel className={panelClass}>
<KpiStatCardBody item={item} />
</FramePanel>
)
}
if (embedded) {
return <Frame className="h-full ring-1 ring-foreground/10">{panel}</Frame>
}
return <Frame className="h-full">{panel}</Frame>
}
/** @deprecated Prefer KpiStatCardTile — kept for existing imports */
export function KpiStatCard({
item,
embedded = false,
className,
}: {
item: KpiStatItem
embedded?: boolean
className?: string
}) {
return <KpiStatCardTile item={item} embedded={embedded} className={className} />
}
function KpiStatGridSkeleton({ count }: { count: number }) {
return (
<Frame className="@container w-full">
<div className={cn('grid gap-2', kpiCols(count))}>
{Array.from({ length: count }).map((_, index) => (
<FramePanel key={index} className="flex items-start gap-3">
<Skeleton className="size-10.5 shrink-0 rounded-lg" />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="flex items-center justify-between gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4.5 w-14 rounded-full" />
</div>
<Skeleton className="h-7 w-16" />
</div>
</FramePanel>
))}
</div>
</Frame>
)
}
interface KpiStatGridProps {
/** EvoBGP primary API */
items?: KpiStatItem[]
/** CFDM-compatible API */
cards?: KpiStatCardData[]
isLoading?: boolean
emptyMessage?: ReactNode
emptyIcon?: ReactNode
className?: string
skeletonCount?: number
/** Wrap each tile in its own Frame (analytics panels). */
embedded?: boolean
'aria-label'?: string
}
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
const onActivate = resolveActivate(item)
const panelClass = panelClassName(item)
if (item.to) {
return (
<FramePanel className={panelClass}>
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
<KpiStatCardBody item={item} />
</Link>
</FramePanel>
)
}
if (onActivate) {
return (
<FramePanel
className={panelClass}
onClick={onActivate}
role="button"
tabIndex={0}
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
>
<KpiStatCardBody item={item} />
</FramePanel>
)
}
return (
<FramePanel className={panelClass}>
<KpiStatCardBody item={item} />
</FramePanel>
)
}
/**
* Hybrid KPI — EvoBGP visual + horizontal compact layout (icon left).
* Preview: https://reui.io/preview/base/stats-12
*/
export function KpiStatGrid({
items,
cards,
isLoading = false,
emptyMessage,
emptyIcon,
className,
skeletonCount = 4,
embedded = false,
'aria-label': ariaLabel,
}: KpiStatGridProps) {
if (isLoading) {
return <KpiStatGridSkeleton count={skeletonCount} />
}
const list = items ?? cards ?? []
if (list.length === 0 && (emptyMessage || emptyIcon)) {
return (
<Frame dense spacing="sm" className={cn('w-full', className)}>
<FramePanel className="flex items-center gap-3 p-4">
{emptyIcon}
{emptyMessage ? (
<p className="text-muted-foreground text-sm">{emptyMessage}</p>
) : null}
</FramePanel>
</Frame>
)
}
if (embedded) {
return (
<section aria-label={ariaLabel} className={cn('@container w-full', className)}>
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
{list.map((item, index) => (
<KpiStatCardTile key={kpiStatItemKey(item, index)} item={item} embedded />
))}
</div>
</section>
)
}
return (
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}>
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
{list.map((item, index) => (
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
))}
</div>
</Frame>
)
}
export function kpiStatItemKey(item: KpiStatItem, index: number): string {
if (item.id) return item.id
if (typeof item.label === 'string') return item.label
return `kpi-${index}`
}
@@ -0,0 +1,142 @@
import type { ReactNode } from 'react'
import { Link } from '@tanstack/react-router'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { IconTile } from '@/components/reui/icon-tile'
import { cn } from '@authportal/ui/lib/utils'
import { kpiCols } from './kpi-cols'
export interface QuickActionItem {
id: string
title: string
description: string
to?: string
search?: Record<string, unknown>
onSelect?: () => void
badgeLabel?: string
/** Вариант бейджа действия (по умолчанию outline). */
badgeVariant?: 'outline' | 'info-light' | 'warning-light'
/** Моноширинная подпись под описанием: адрес · способ входа. */
meta?: string
icon?: ReactNode
iconClassName?: string
}
interface QuickActionGridProps {
actions: QuickActionItem[]
title?: string
description?: string
className?: string
/** Плитки в Frame с отступами и сеткой auto-fit от 17rem (как KPI-полоса): без вылета за границы. */
autoFit?: boolean
}
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
function QuickActionBody({ action }: { action: QuickActionItem }) {
return (
<div className="relative z-10 flex h-full items-start gap-3">
{action.icon ? (
<IconTile
variant="elevated"
aria-hidden="true"
className={cn('size-10.5', action.iconClassName ?? DEFAULT_ICON_CLASS)}
>
{action.icon}
</IconTile>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-start justify-between gap-2">
<span className="text-foreground text-sm font-medium">{action.title}</span>
<Badge variant={action.badgeVariant ?? 'outline'} size="sm" className="shrink-0">
{action.badgeLabel ?? 'Перейти'}
</Badge>
</div>
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
{action.description}
</p>
{action.meta ? (
<p className="text-muted-foreground truncate font-mono text-[11px]">{action.meta}</p>
) : null}
</div>
</div>
)
}
function QuickActionHit({ action }: { action: QuickActionItem }) {
const label = `${action.title}: ${action.description}`
const body = <QuickActionBody action={action} />
if (action.onSelect) {
return (
<button
type="button"
className="w-full text-left focus-visible:outline-none"
aria-label={label}
onClick={action.onSelect}
>
{body}
</button>
)
}
if (!action.to) return body
return (
<Link
to={action.to}
search={action.search}
className="focus-visible:outline-none"
aria-label={label}
>
{body}
</Link>
)
}
/**
* KPI-like quick actions strip (horizontal Frame tiles).
* Preview: https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/card-12
*/
export function QuickActionGrid({
actions,
title = 'Быстрые действия',
description,
className,
autoFit = false,
}: QuickActionGridProps) {
if (actions.length === 0) return null
return (
<Frame dense={!autoFit} spacing="sm" className={cn('@container w-full', className)}>
{(title || description) && (
<FrameHeader>
{title ? <FrameTitle>{title}</FrameTitle> : null}
{description ? <FrameDescription>{description}</FrameDescription> : null}
</FrameHeader>
)}
<div
className={cn(
'grid gap-2',
autoFit ? 'grid-cols-[repeat(auto-fit,minmax(min(100%,17rem),1fr))]' : kpiCols(actions.length),
)}
>
{actions.map((action) => (
<FramePanel
key={action.id}
className="relative isolate flex h-full flex-col hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2"
>
<QuickActionHit action={action} />
</FramePanel>
))}
</div>
</Frame>
)
}
@@ -0,0 +1,13 @@
/**
* Portal lists are not ResourcePage.
* Users: `AdminUsersGrid` — DNA [solution-users-1](https://reui.io/preview/base/solution-users-1).
* Other admin tables: PageShell + Frame + DataGrid in the route.
* Types kept for kit parity with ops apps — do not add a ResourcePage component here.
*/
export type ResourcePageTab = {
id: string
label: string
count?: number
}
export type ResourcePageProps = Record<string, never>
@@ -0,0 +1,507 @@
/**
* User access editor — adapted from ReUI PRO solution-users-1 member detail sheet.
* Preview: https://reui.io/preview/base/solution-users-1
* Docs: https://reui.io/blocks
*/
import { useEffect, useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { InfoIcon, XIcon } from 'lucide-react'
import {
APP_IDS,
APPS,
PERMISSION_CATALOG,
PERMISSION_ACTION_LABELS,
isIndependentPermissionSection,
withIndependentPermission,
permissionKey,
type AdminUser,
type AppId,
type CatalogSection,
type PermissionAction,
} from '@authportal/shared'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { Badge } from '@/components/reui/badge'
import {
Avatar,
AvatarFallback,
} from '@authportal/ui/components/avatar'
import { Button } from '@authportal/ui/components/button'
import { ButtonGroup } from '@authportal/ui/components/button-group'
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemTitle,
} from '@authportal/ui/components/item'
import { ScrollArea } from '@authportal/ui/components/scroll-area'
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@authportal/ui/components/sheet'
import { Skeleton } from '@authportal/ui/components/skeleton'
import { Switch } from '@authportal/ui/components/switch'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@authportal/ui/components/tabs'
import { cn } from '@authportal/ui/lib/utils'
import { api, ApiError } from '@/lib/api-client'
import { userQueryOptions, usersQueryKey } from '@/queries/auth'
type AccessLevel = 'none' | 'read' | 'write' | 'admin'
const LEVEL_LABELS: Record<AccessLevel, string> = {
none: 'Нет',
read: 'Чтение',
write: 'Редактирование',
admin: 'Админ',
}
const mutedIconButtonClassName = 'text-muted-foreground hover:text-foreground'
function levelsForSection(section: CatalogSection): AccessLevel[] {
const levels: AccessLevel[] = ['none']
if (section.actions.includes('read')) levels.push('read')
if (section.actions.includes('write')) levels.push('write')
if (section.actions.includes('admin')) levels.push('admin')
return levels
}
function getSectionLevel(
appId: AppId,
section: CatalogSection,
permissionSet: Set<string>,
): AccessLevel {
const has = (action: PermissionAction) =>
permissionSet.has(permissionKey(appId, section.id, action))
if (section.actions.includes('admin') && has('admin')) return 'admin'
if (section.actions.includes('write') && has('write')) return 'write'
if (section.actions.includes('read') && has('read')) return 'read'
return 'none'
}
function keysForLevel(
appId: AppId,
section: CatalogSection,
level: AccessLevel,
): string[] {
if (level === 'none') return []
if (level === 'read') {
return section.actions.includes('read')
? [permissionKey(appId, section.id, 'read')]
: []
}
if (level === 'write') {
const keys: string[] = []
if (section.actions.includes('read')) {
keys.push(permissionKey(appId, section.id, 'read'))
}
if (section.actions.includes('write')) {
keys.push(permissionKey(appId, section.id, 'write'))
}
return keys
}
return section.actions.map((action) =>
permissionKey(appId, section.id, action),
)
}
export function UserAccessSheet({
userId,
open,
onOpenChange,
}: {
userId: string | null
open: boolean
onOpenChange: (open: boolean) => void
}) {
const queryClient = useQueryClient()
const enabled = open && Boolean(userId)
const { data: user, isLoading, error } = useQuery({
...userQueryOptions(userId ?? ''),
enabled,
})
const [apps, setApps] = useState<AppId[]>([])
const [permissions, setPermissions] = useState<string[]>([])
const [activeApp, setActiveApp] = useState<AppId>('cfdm')
useEffect(() => {
if (!user) return
setApps(user.apps)
setPermissions(user.permissions)
const firstEnabled =
APP_IDS.find((id) => user.apps.includes(id)) ?? APP_IDS[0]
setActiveApp(firstEnabled)
}, [user])
const baseline = useMemo(() => {
if (!user) return null
return {
apps: [...user.apps].sort().join(','),
permissions: [...user.permissions].sort().join(','),
}
}, [user])
const dirty = useMemo(() => {
if (!baseline) return false
const current = {
apps: [...apps].sort().join(','),
permissions: [...permissions].sort().join(','),
}
return (
current.apps !== baseline.apps ||
current.permissions !== baseline.permissions
)
}, [apps, permissions, baseline])
const saveMutation = useMutation({
mutationFn: () =>
api.put<AdminUser>(`/api/v1/admin/users/${userId}/access`, {
apps,
permissions,
}),
onSuccess: async (updated) => {
await queryClient.invalidateQueries({ queryKey: usersQueryKey })
if (userId) {
queryClient.setQueryData(userQueryOptions(userId).queryKey, updated)
}
onOpenChange(false)
},
})
const permissionSet = useMemo(() => new Set(permissions), [permissions])
function toggleApp(appId: AppId, enabledApp: boolean) {
setApps((prev) => {
if (enabledApp) return prev.includes(appId) ? prev : [...prev, appId]
return prev.filter((id) => id !== appId)
})
if (!enabledApp) {
setPermissions((prev) => prev.filter((p) => !p.startsWith(`${appId}:`)))
} else {
setActiveApp(appId)
}
}
function setSectionLevel(
appId: AppId,
section: CatalogSection,
level: AccessLevel,
) {
const sectionPrefix = `${appId}:${section.id}:`
const nextKeys = keysForLevel(appId, section, level)
setPermissions((prev) => {
const kept = prev.filter((p) => !p.startsWith(sectionPrefix))
return [...kept, ...nextKeys]
})
}
function resetChanges() {
if (!user) return
setApps(user.apps)
setPermissions(user.permissions)
}
const initials = (user?.name ?? '?')
.split(/\s+/)
.map((p) => p[0])
.join('')
.slice(0, 2)
.toUpperCase()
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
showCloseButton={false}
className="inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] w-[min(36rem,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none sm:max-w-none"
>
<SheetHeader className="shrink-0 gap-0 p-0">
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
<SheetTitle className="min-w-0 truncate text-base font-semibold">
Права доступа
</SheetTitle>
<SheetClose
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Закрыть"
className={mutedIconButtonClassName}
>
<XIcon aria-hidden="true" />
</Button>
}
/>
</div>
<SheetDescription className="sr-only">
Настройка доступа к приложениям и разделам
</SheetDescription>
</SheetHeader>
<div className="min-h-0 flex-1">
<ScrollArea className="h-full">
{isLoading ? (
<div className="flex flex-col gap-3 p-4">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-48 w-full" />
</div>
) : error || !user ? (
<div className="p-4">
<Alert variant="destructive">
<InfoIcon />
<AlertTitle>Ошибка</AlertTitle>
<AlertDescription>
{error instanceof ApiError
? error.message
: 'Пользователь не найден'}
</AlertDescription>
</Alert>
</div>
) : (
<div className="flex flex-col pb-4">
<div className="flex items-center gap-3 px-4 py-5">
<Avatar className="size-12 shrink-0">
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<div className="text-foreground truncate text-sm font-semibold">
{user.name}
</div>
{user.is_admin ? (
<Badge variant="warning-light" size="sm">
Админ
</Badge>
) : null}
{user.disabled ? (
<Badge variant="destructive-light" size="sm">
Отключён
</Badge>
) : (
<Badge variant="success-light" size="sm">
Активен
</Badge>
)}
</div>
<div className="text-muted-foreground truncate text-xs">
{user.email}
</div>
</div>
</div>
<div className="flex flex-col gap-4 border-t px-4 pt-4">
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Доступ к приложениям</p>
<div className="flex flex-col gap-2">
{APP_IDS.map((appId) => {
const meta = APPS.find((a) => a.id === appId)
const checked = apps.includes(appId)
return (
<Item
key={appId}
variant="outline"
className={cn(
'items-start gap-3',
checked && 'border-primary/40',
)}
>
<ItemContent className="gap-1">
<ItemTitle className="text-sm">
{meta?.title ?? appId}
</ItemTitle>
<ItemDescription className="text-xs">
{meta?.description ?? appId}
</ItemDescription>
</ItemContent>
<ItemActions className="ml-auto self-center">
<Switch
checked={checked}
onCheckedChange={(v) => toggleApp(appId, v)}
aria-label={meta?.title ?? appId}
/>
</ItemActions>
</Item>
)
})}
</div>
</div>
<div className="flex flex-col gap-3">
<p className="text-sm font-medium">Права по разделам</p>
<Tabs
value={activeApp}
onValueChange={(v) => setActiveApp(v as AppId)}
className="w-full gap-3"
>
<TabsList
variant="line"
className="h-auto w-full flex-wrap justify-start gap-4 border-b"
>
{PERMISSION_CATALOG.map((app) => {
const enabledApp = apps.includes(app.appId)
return (
<TabsTrigger
key={app.appId}
value={app.appId}
className="text-muted-foreground hover:text-foreground data-active:bg-transparent data-active:text-foreground dark:data-active:border-transparent dark:data-active:bg-transparent h-auto flex-none gap-1.5 rounded-none bg-transparent px-0 pb-2.5 text-sm shadow-none after:bottom-0 after:h-0.5 data-active:shadow-none"
>
<span className="max-w-28 truncate sm:max-w-none">
{app.title}
</span>
<span className="bg-muted text-muted-foreground rounded-md px-1.5 py-0.5 text-[0.625rem] tabular-nums">
{enabledApp ? 'вкл' : 'выкл'}
</span>
</TabsTrigger>
)
})}
</TabsList>
{PERMISSION_CATALOG.map((app) => (
<TabsContent
key={app.appId}
value={app.appId}
className="mt-0 w-full"
>
{!apps.includes(app.appId) ? (
<Alert variant="info" className="mb-3">
<InfoIcon />
<AlertTitle>Приложение выключено</AlertTitle>
<AlertDescription>
Включите «{app.title}», чтобы настроить разделы.
</AlertDescription>
</Alert>
) : null}
<div
className={cn(
'grid grid-cols-1 gap-2',
!apps.includes(app.appId) &&
'pointer-events-none opacity-50',
)}
>
{app.sections.map((section) => {
const level = getSectionLevel(
app.appId,
section,
permissionSet,
)
const levels = levelsForSection(section)
return (
<Item
key={section.id}
variant="outline"
className="items-start gap-2"
>
<ItemContent className="gap-2">
<ItemTitle className="text-sm">
{section.title}
</ItemTitle>
<ItemDescription className="text-xs">
{section.description}
</ItemDescription>
{isIndependentPermissionSection(app.appId, section.id) ? (
<div className="flex flex-col gap-2">
{section.actions.map((action) => {
const key = permissionKey(app.appId, section.id, action)
return (
<label key={action} className="flex items-center justify-between gap-3">
<span className="text-sm">{PERMISSION_ACTION_LABELS[action]}</span>
<Switch
checked={permissionSet.has(key)}
disabled={!apps.includes(app.appId)}
aria-label={`${section.title}: ${PERMISSION_ACTION_LABELS[action]}`}
onCheckedChange={(enabled) => setPermissions((prev) => withIndependentPermission(prev, app.appId, section.id, action, enabled))}
/>
</label>
)
})}
</div>
) : (
<ButtonGroup className="mt-0.5 flex-wrap">
{levels.map((lvl) => (
<Button
key={lvl}
size="sm"
variant={
level === lvl
? 'default'
: 'outline'
}
disabled={!apps.includes(app.appId)}
onClick={() =>
setSectionLevel(
app.appId,
section,
lvl,
)
}
>
{LEVEL_LABELS[lvl]}
</Button>
))}
</ButtonGroup>
)}
</ItemContent>
</Item>
)
})}
</div>
</TabsContent>
))}
</Tabs>
</div>
</div>
</div>
)}
</ScrollArea>
</div>
<SheetFooter className="bg-background shrink-0 border-t">
{saveMutation.error ? (
<p className="text-destructive w-full text-sm">
{saveMutation.error instanceof ApiError
? saveMutation.error.message
: 'Ошибка сохранения'}
</p>
) : null}
<div className="flex w-full gap-2">
<Button
type="button"
variant="outline"
className="min-w-0 flex-1"
disabled={!dirty || saveMutation.isPending}
onClick={resetChanges}
>
Отменить
</Button>
<Button
type="button"
className="min-w-0 flex-1"
disabled={!dirty || saveMutation.isPending || !user}
onClick={() => saveMutation.mutate()}
>
{saveMutation.isPending ? 'Сохранение…' : 'Сохранить'}
</Button>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,276 @@
/**
* User-scoped audit Sheet — chrome DNA solution-users-1 / solution-users-2.
* Preview: https://reui.io/preview/base/solution-users-1 · https://reui.io/preview/base/solution-users-2
* Timeline: https://reui.io/preview/base/solution-users-6
*/
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { AdminUser } from '@authportal/shared'
import { toast } from 'sonner'
import { Trash2Icon, XIcon } from 'lucide-react'
import { AuditLogTimeline } from '@/components/reui-kit/audit-log-timeline'
import { formatEventTime } from '@/components/reui-kit/audit-log-helpers'
import { auditQueryOptions } from '@/queries/audit'
import {
sessionsListQueryKey,
sessionsQueryOptions,
} from '@/queries/sessions'
import { api, ApiError } from '@/lib/api-client'
import { Button } from '@authportal/ui/components/button'
import { ScrollArea } from '@authportal/ui/components/scroll-area'
import { Skeleton } from '@authportal/ui/components/skeleton'
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@authportal/ui/components/sheet'
const mutedIconButtonClassName = 'text-muted-foreground hover:text-foreground'
export function UserAuditSheet({
user,
open,
onOpenChange,
}: {
user: AdminUser | null
open: boolean
onOpenChange: (open: boolean) => void
}) {
const queryClient = useQueryClient()
const userId = user?.id
const email = user?.email
const [tab, setTab] = useState<'logins' | 'changes' | 'sessions'>('logins')
const loginsQuery = useQuery({
...auditQueryOptions({
userId,
actorEmail: email,
kind: 'logins',
limit: 200,
}),
enabled: open && Boolean(userId) && tab === 'logins',
})
const changesQuery = useQuery({
...auditQueryOptions({ userId, kind: 'changes', limit: 200 }),
enabled: open && Boolean(userId) && tab === 'changes',
})
const sessionsQuery = useQuery({
...sessionsQueryOptions({ userId }),
enabled: open && Boolean(userId) && tab === 'sessions',
})
const revokeAll = useMutation({
mutationFn: () =>
api.post<{ revoked: number }>(
`/api/v1/admin/users/${userId}/sessions/revoke-all`,
),
onSuccess: async (data) => {
await queryClient.invalidateQueries({
queryKey: sessionsListQueryKey(userId),
})
toast.success(`Отозвано сессий: ${data.revoked}`)
},
onError: (err) => {
toast.error(
err instanceof ApiError ? err.message : 'Не удалось отозвать сессии',
)
},
})
const revokeOne = useMutation({
mutationFn: (id: string) => api.delete(`/api/v1/admin/sessions/${id}`),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: sessionsListQueryKey(userId),
})
toast.success('Сессия отозвана')
},
onError: (err) => {
toast.error(
err instanceof ApiError ? err.message : 'Не удалось отозвать сессию',
)
},
})
const active =
tab === 'logins'
? loginsQuery
: tab === 'changes'
? changesQuery
: sessionsQuery
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
showCloseButton={false}
className="inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] w-[min(36rem,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none sm:max-w-none"
>
<SheetHeader className="shrink-0 gap-0 p-0">
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
<SheetTitle className="min-w-0 truncate text-base font-semibold">
Журнал: {user?.name ?? '…'}
</SheetTitle>
<SheetClose
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Закрыть"
className={mutedIconButtonClassName}
>
<XIcon aria-hidden="true" />
</Button>
}
/>
</div>
<SheetDescription className="text-muted-foreground border-b px-4 py-2 text-sm">
{user?.email ?? 'События пользователя'}
{user?.last_login_ip
? ` · последний IP ${user.last_login_ip}`
: ''}
</SheetDescription>
<div className="border-b px-4">
<Tabs
value={tab}
onValueChange={(v) => {
if (v === 'logins' || v === 'changes' || v === 'sessions') {
setTab(v)
}
}}
>
<TabsList
variant="line"
className="h-10! w-full justify-start gap-5"
>
<TabsTrigger
value="logins"
className="px-0 text-sm after:-bottom-px!"
>
Входы
</TabsTrigger>
<TabsTrigger
value="changes"
className="px-0 text-sm after:-bottom-px!"
>
Изменения
</TabsTrigger>
<TabsTrigger
value="sessions"
className="px-0 text-sm after:-bottom-px!"
>
Сессии
</TabsTrigger>
</TabsList>
</Tabs>
</div>
</SheetHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="p-4">
{active.error ? (
<div className="flex flex-col gap-2">
<p className="text-destructive text-sm">
{active.error instanceof ApiError
? active.error.message
: 'Ошибка загрузки'}
</p>
<Button
variant="outline"
size="sm"
className="w-fit"
onClick={() => active.refetch()}
>
Повторить
</Button>
</div>
) : active.isLoading ? (
<div className="flex flex-col gap-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
) : tab === 'sessions' ? (
<div className="flex flex-col gap-3">
<div className="flex justify-end">
<Button
type="button"
size="sm"
variant="outline"
disabled={
revokeAll.isPending ||
(sessionsQuery.data?.length ?? 0) === 0
}
onClick={() => revokeAll.mutate()}
>
Отозвать все
</Button>
</div>
{(sessionsQuery.data ?? []).length === 0 ? (
<p className="text-muted-foreground text-sm">
Нет активных сессий
</p>
) : (
(sessionsQuery.data ?? []).map((s) => (
<div
key={s.id}
className="border-border flex flex-col gap-1 rounded-lg border p-3"
>
<div className="flex items-start justify-between gap-2">
<span className="font-mono text-xs tabular-nums">
{s.ip ?? '—'}
</span>
<Button
type="button"
size="sm"
variant="ghost"
disabled={revokeOne.isPending}
onClick={() => revokeOne.mutate(s.id)}
>
<Trash2Icon aria-hidden="true" />
</Button>
</div>
<p
className="text-muted-foreground line-clamp-2 text-xs"
title={s.user_agent ?? undefined}
>
{s.user_agent ?? '—'}
</p>
<p className="text-muted-foreground text-xs tabular-nums">
{formatEventTime(s.created_at)} · до{' '}
{formatEventTime(s.expires_at)}
</p>
</div>
))
)}
</div>
) : (
<AuditLogTimeline
entries={
(tab === 'logins' ? loginsQuery.data : changesQuery.data) ??
[]
}
totalCount={
(
(tab === 'logins'
? loginsQuery.data
: changesQuery.data) ?? []
).length
}
compact
lockedUserId={userId}
/>
)}
</div>
</ScrollArea>
</SheetContent>
</Sheet>
)
}
+92
View File
@@ -0,0 +1,92 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@authportal/ui/lib/utils"
const alertVariants = cva(
[
"relative w-full text-sm border has-[>svg]:grid-cols-[calc(var(--spacing)*3)_1fr] grid-cols-[0_1fr] grid gap-y-0.5 items-center [&>svg:not([class*=size-])]:size-4",
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:[&_[data-slot=alert-action]]:sm:row-end-3",
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:items-start",
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:[&_svg]:translate-y-0.5",
"rounded-lg",
"px-3",
"py-2.5",
"has-[>svg]:gap-x-2.5",
],
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"border-destructive/30 bg-destructive/4 [&>svg]:text-destructive",
info: "border-info/30 bg-info/4 [&>svg]:text-info",
success: "border-success/30 bg-success/4 [&>svg]:text-success",
warning: "border-warning/30 bg-warning/4 [&>svg]:text-warning",
invert:
"border-invert bg-invert text-invert-foreground [&_[data-slot=alert-description]]:text-invert-foreground/70",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn(
"flex gap-1.5 max-sm:col-start-2 max-sm:mt-2 max-sm:justify-start sm:col-start-3 sm:row-start-1 sm:justify-end sm:self-center",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }
+102
View File
@@ -0,0 +1,102 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@authportal/ui/lib/utils"
const badgeVariants = cva(
[
"relative inline-flex shrink-0 items-center justify-center w-fit border border-transparent font-medium whitespace-nowrap outline-none transition-shadow",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-3",
],
{
variants: {
variant: {
default: "bg-primary text-primary-foreground",
outline: "border-border bg-transparent dark:bg-input/32",
secondary: "bg-secondary text-secondary-foreground",
info: "bg-info text-white",
success: "bg-success text-white",
warning: "bg-warning text-white",
destructive: "bg-destructive text-white",
focus: "bg-focus text-focus-foreground",
invert: "bg-invert text-invert-foreground",
"primary-light":
"border-primary/10 bg-primary/10 text-primary dark:border-primary/25 dark:bg-primary/15 dark:text-primary",
"warning-light":
"border-warning/15 bg-warning/10 text-warning-foreground dark:border-warning/25 dark:bg-warning/15 dark:text-warning",
"success-light":
"border-success/15 bg-success/10 text-success-foreground dark:border-success/25 dark:bg-success/15 dark:text-success",
"info-light":
"border-info/15 bg-info/10 text-info-foreground dark:border-info/25 dark:bg-info/15 dark:text-info",
"destructive-light":
"border-destructive/15 bg-destructive/10 text-destructive-foreground dark:border-destructive/25 dark:bg-destructive/15 dark:text-destructive",
"invert-light":
"border-invert/15 bg-invert/10 text-foreground dark:border-invert/45 dark:bg-invert/35 dark:text-invert-foreground",
"focus-light":
"border-focus/15 bg-focus/10 text-focus-foreground dark:border-focus/25 dark:bg-focus/15 dark:text-focus",
"primary-outline":
"bg-background border-border text-primary dark:bg-input/30",
"warning-outline":
"bg-background border-border text-warning-foreground dark:bg-input/30",
"success-outline":
"bg-background border-border text-success-foreground dark:bg-input/30",
"info-outline":
"bg-background border-border text-info-foreground dark:bg-input/30",
"destructive-outline":
"bg-background border-border text-destructive-foreground dark:bg-input/30",
"invert-outline":
"bg-background border-border text-invert-foreground dark:bg-input/30",
"focus-outline":
"bg-background border-border text-focus-foreground dark:bg-input/30",
},
size: {
xs: "px-1 py-0.25 text-[0.6rem] leading-none h-4 min-w-4 gap-1",
sm: "px-1 py-0.25 text-[0.625rem] leading-none h-4.5 min-w-4.5 gap-1",
default: "px-1.25 py-0.5 text-xs h-5 min-w-5 gap-1",
lg: "px-1.5 py-0.5 text-xs h-5.5 min-w-5.5 gap-1",
xl: "px-2 py-0.75 text-sm h-6 min-w-6 gap-1.5",
},
/** `default`: active style radius. `full`: pill radius. */
radius: {
default:
"rounded-sm",
full: "rounded-full",
},
},
defaultVariants: {
variant: "default",
size: "default",
radius: "default",
},
}
)
interface BadgeProps extends useRender.ComponentProps<"span"> {
variant?: VariantProps<typeof badgeVariants>["variant"]
size?: VariantProps<typeof badgeVariants>["size"]
radius?: VariantProps<typeof badgeVariants>["radius"]
}
function Badge({
className,
variant,
size,
radius,
render,
...props
}: BadgeProps) {
const defaultProps = {
"data-slot": "badge",
className: cn(badgeVariants({ variant, size, radius, className })),
}
return useRender({
defaultTagName: "span",
render,
props: mergeProps<"span">(defaultProps, props),
})
}
export { Badge, badgeVariants, type BadgeProps }
@@ -0,0 +1,926 @@
import * as React from "react"
import { useCascaderState } from "@/components/reui/cascader/cascader-context"
import {
CASCADER_ROOT_KEY,
isCascaderMoreNode,
} from "@/components/reui/cascader/cascader-lib"
import type {
CascaderIndex,
CascaderLoadContext,
CascaderLoadReason,
CascaderLoadResult,
CascaderLoadState,
CascaderNode,
CascaderSearchContext,
} from "@/components/reui/cascader/cascader-types"
/**
* Async data for the cascader. Loaded pages live in their own store and are
* MERGED onto the index built from `items`, never folded into that build:
* `items` changes identity on any parent re-render, and folding would discard
* every level the user drilled into. Map membership is the load discriminator:
* no `states` entry means never fetched, while an entry with no `loading`, no
* `error` and no children means empty for real.
*/
/* -------------------------------------------------------------------------- */
/* Types */
/* -------------------------------------------------------------------------- */
/**
* Fetches one level. `node` is `null` for the root level, and only
* `{ value, label: value }`, no `data`, for a level on screen whose node has
* not loaded: a deep link before the level above loads, or a page-loaded
* level after a `loadKey` change.
*/
export type CascaderGetChildren<T = unknown> = (
node: CascaderNode<T> | null,
context: CascaderLoadContext
) =>
| CascaderNode<T>[]
| CascaderLoadResult<T>
| Promise<CascaderNode<T>[] | CascaderLoadResult<T>>
/** Server-side search, replacing the local index scan while the query is set. */
export type CascaderOnSearch<T = unknown> = (
query: string,
context: CascaderSearchContext
) =>
| CascaderNode<T>[]
| CascaderLoadResult<T>
| Promise<CascaderNode<T>[] | CascaderLoadResult<T>>
/** Resolves a selected value to its ancestor chain, root first, node last. */
export type CascaderResolveValue<T = unknown> = (
value: string,
context: CascaderLoadContext
) => CascaderNode<T>[] | Promise<CascaderNode<T>[]>
export interface CascaderLoaderStore<T = unknown> {
/** Keyed by LEVEL: a parent's value, or `CASCADER_ROOT_KEY` for the root. */
pages: Map<string, CascaderNode<T>[]>
states: Map<string, CascaderLoadState>
/** Keyed by node value: search hits and resolved selections, level-less. */
detached: Map<string, CascaderNode<T>>
/**
* A resolved chain's parent links, node value to level key. Kept apart from
* `pages`: a level's first real page replaced a chain stub there, and a node
* that page left out lost its parent, and the trigger its path.
*/
links: Map<string, string>
}
export interface UseCascaderLoaderOptions<T = unknown> {
/** The index built from `items`, before any pages are merged in. */
base: CascaderIndex<T>
getChildren?: CascaderGetChildren<T>
onSearch?: CascaderOnSearch<T>
resolveValue?: CascaderResolveValue<T>
/** Milliseconds of quiet before `onSearch` fires. */
searchDebounce?: number
/** Changing this drops every cached page, state and detached node. */
loadKey?: unknown
/** Speculatively fetch a branch's children when it is highlighted. */
prefetch?: boolean
/** Called when a request fails. Never for an aborted or superseded one. */
onLoadError?: (
error: unknown,
context: { parent: string | null; reason: string }
) => void
/**
* Whether the panel is live: the popup is open, or the cascader is inline.
* Gates levels, paging and search. `resolveValue` runs regardless, and
* retries a failed value when this turns true.
*/
enabled: boolean
query: string
/** Level keys that are currently on screen. Root is `CASCADER_ROOT_KEY`. */
levels: string[]
/** Handed to `onSearch` as its scope and read for nothing else: the
* navigation path, or `[]` when the search covers the whole tree. */
path: string[]
/** Current selection, for `resolveValue`. */
values: string[]
}
export interface CascaderLoader<T = unknown> {
/** Whether a `getChildren` loader is configured at all. */
active: boolean
store: CascaderLoaderStore<T>
states: ReadonlyMap<string, CascaderLoadState>
/**
* Async search hits, `null` when no `onSearch` is running. EMPTY while the
* first request is in flight, so the level behind is not shown as the answer.
*/
searchResults: CascaderNode<T>[] | null
searchState: CascaderLoadState | null
/**
* Fetches a level's FIRST page. No-ops on a `states` entry (in flight,
* loaded or failed) or when `items` fills the level. The level effect fires
* only for on-screen levels, so a branch merely PRESSED is asked for by hand.
*/
ensureLevel: (parentKey: string, reason: CascaderLoadReason) => void
/** Fetches the next page of a level. No-ops unless one is available. */
loadMore: (parentKey: string) => void
/** Refires a failed level. No-ops unless that level is in an error state. */
retryLevel: (parentKey: string) => void
/** Refires the server search for the current query. No-ops without one. */
retrySearch: () => void
/** Schedules a speculative fetch. Safe to call on every highlight move. */
prefetchNode: (node: CascaderNode<T> | null | undefined) => void
/**
* Evicts ONE level: aborts its request, drops its `states`/`pages` entries
* and paging latch, so the level effect refetches it. `null` = root level.
*/
invalidateLevel: (value: string | null) => void
}
/* -------------------------------------------------------------------------- */
/* Constants */
/* -------------------------------------------------------------------------- */
/**
* Highlight dwell before `prefetch` fetches: long enough that holding ArrowDown
* does not fire a request per row, short enough to beat the ArrowRight press.
*/
const PREFETCH_DELAY = 150
/** Request keys for the two non-level requests. Never collide with a value. */
const SEARCH_KEY = "\u0000search"
const RESOLVE_PREFIX = "\u0000resolve:"
const NO_STATE: CascaderLoadState = {
loading: false,
error: false,
hasMore: false,
}
/** Stable empty result, so an idle search never churns the state context. */
const NO_RESULTS: CascaderNode<never>[] = []
function createStore<T>(): CascaderLoaderStore<T> {
return {
pages: new Map(),
states: new Map(),
detached: new Map(),
links: new Map(),
}
}
function sameLoadState(a: CascaderLoadState, b: CascaderLoadState): boolean {
return (
a.loading === b.loading &&
a.error === b.error &&
a.hasMore === b.hasMore &&
a.cursor === b.cursor
)
}
/* -------------------------------------------------------------------------- */
/* Store transitions */
/* -------------------------------------------------------------------------- */
/**
* Copy-on-write, and a NO-OP when nothing changed: the merged index is memoised
* on store identity, so a fresh object for an unchanged state would rebuild it.
*/
function withLoadState<T>(
store: CascaderLoaderStore<T>,
key: string,
update: (state: CascaderLoadState) => CascaderLoadState
): CascaderLoaderStore<T> {
const current = store.states.get(key) ?? NO_STATE
const next = update(current)
if (store.states.has(key) && sameLoadState(current, next)) return store
const states = new Map(store.states)
states.set(key, next)
return { ...store, states }
}
function withPage<T>(
store: CascaderLoaderStore<T>,
key: string,
items: readonly CascaderNode<T>[],
options: { append: boolean; hasMore: boolean; cursor?: string }
): CascaderLoaderStore<T> {
const previous = options.append ? (store.pages.get(key) ?? []) : []
const seen = new Set(previous.map((node) => node.value))
const merged = previous.slice()
for (const item of items) {
if (seen.has(item.value)) continue
seen.add(item.value)
merged.push(item)
}
const pages = new Map(store.pages)
pages.set(key, merged)
const states = new Map(store.states)
states.set(key, {
loading: false,
error: false,
hasMore: options.hasMore,
cursor: options.cursor,
})
return { ...store, pages, states }
}
function withDetached<T>(
store: CascaderLoaderStore<T>,
items: readonly CascaderNode<T>[]
): CascaderLoaderStore<T> {
let detached: Map<string, CascaderNode<T>> | null = null
for (const item of items) {
if (store.detached.get(item.value) === item) continue
detached = detached ?? new Map(store.detached)
detached.set(item.value, item)
}
if (!detached) return store
return { ...store, detached }
}
/**
* Records a resolved ancestor chain, root first, as `detached` nodes plus
* `links`, never as rows in `pages`: a stub there made an unloaded level read
* as loaded. Writes no `states`, so every level on it still fetches for real.
*/
function withChain<T>(
store: CascaderLoaderStore<T>,
chain: readonly CascaderNode<T>[]
): CascaderLoaderStore<T> {
if (chain.length === 0) return store
const detached = new Map(store.detached)
const links = new Map(store.links)
let parentKey = CASCADER_ROOT_KEY
for (const node of chain) {
detached.set(node.value, node)
links.set(node.value, parentKey)
parentKey = node.value
}
return { ...store, detached, links }
}
/* -------------------------------------------------------------------------- */
/* Hook */
/* -------------------------------------------------------------------------- */
interface CascaderLoaderLatest<T> {
base: CascaderIndex<T>
store: CascaderLoaderStore<T>
getChildren?: CascaderGetChildren<T>
onSearch?: CascaderOnSearch<T>
resolveValue?: CascaderResolveValue<T>
onLoadError?: (
error: unknown,
context: { parent: string | null; reason: string }
) => void
prefetch: boolean
path: string[]
query: string
}
interface CascaderSearchSlice<T> {
query: string
results: CascaderNode<T>[]
loading: boolean
error: boolean
}
/**
* The loader. A SIBLING of the `buildCascaderIndex` memo, never inside it: the
* build stays pure in `items`, the merge pure in that build plus this store.
*/
export function useCascaderLoader<T = unknown>({
base,
getChildren,
onSearch,
resolveValue,
searchDebounce = 250,
loadKey,
prefetch = false,
onLoadError,
enabled,
query,
levels,
path,
values,
}: UseCascaderLoaderOptions<T>): CascaderLoader<T> {
const [store, setStore] = React.useState<CascaderLoaderStore<T>>(createStore)
const [search, setSearch] = React.useState<CascaderSearchSlice<T> | null>(
null
)
/**
* Latest callbacks, WRITTEN IN AN EFFECT: `getChildren` is inline in most
* consumers, so closing over it would refire every in-flight request per
* re-render. The ref is what keeps the request machinery `[]`-dep. Declared
* FIRST, since effects run in declaration order, so the level effect below
* already sees the current commit.
*/
const latest = React.useRef<CascaderLoaderLatest<T>>({
base,
store,
getChildren,
onSearch,
resolveValue,
onLoadError,
prefetch,
path,
query,
})
React.useEffect(() => {
latest.current = {
base,
store,
getChildren,
onSearch,
resolveValue,
onLoadError,
prefetch,
path,
query,
}
})
/** One AbortController PER KEY: columns mode runs several levels at once. */
const controllers = React.useRef(new Map<string, AbortController>())
/** Monotonic per key. The stale guard for out-of-order responses. */
const requestIds = React.useRef(new Map<string, number>())
/** In-flight `(level, cursor)` signatures, so a duplicate ask is free. */
const inflight = React.useRef(new Map<string, string>())
/**
* The `(child count, cursor)` signature at the last paging fire, per level.
* Guards what `hasMore` cannot: a page of zero new items while the server
* still says `hasMore`. Cursor is IN the signature because an all-duplicates
* page advances it while the count stands still - real progress, which a
* count-only latch would brick forever.
*/
const moreLatch = React.useRef(new Map<string, string>())
/** Values `resolveValue` has already been asked about, so it asks once. */
const attempted = React.useRef(new Set<string>())
/**
* Values whose resolve failed. They stay `attempted` until the next open
* rather than the next values pass: with inline `items` every parent render
* is one, and an `onLoadError` that set parent state looped at network speed.
*/
const failed = React.useRef(new Set<string>())
/** Every node the loader has seen, so a level key can name its own node. */
const known = React.useRef(new Map<string, CascaderNode<T>>())
const timers = React.useRef<{
prefetch: ReturnType<typeof setTimeout> | null
}>({ prefetch: null })
/** Bumped by a `loadKey` change, so responses from before it are dropped. */
const epoch = React.useRef(0)
const remember = React.useCallback((nodes: readonly CascaderNode<T>[]) => {
const map = known.current
const walk = (list: readonly CascaderNode<T>[]) => {
for (const node of list) {
map.set(node.value, node)
if (node.children?.length) walk(node.children)
}
}
walk(nodes)
}, [])
const abortKey = React.useCallback((key: string) => {
const controller = controllers.current.get(key)
if (!controller) return
controllers.current.delete(key)
inflight.current.delete(key)
controller.abort()
}, [])
/* ------------------------------- level load ------------------------------ */
const runLoad = React.useCallback(
(
key: string,
reason: CascaderLoadReason,
cursor: string | undefined,
append: boolean
) => {
const { getChildren: loader, base: currentBase } = latest.current
if (!loader) return
const signature = `${append ? "1" : "0"}:${cursor ?? ""}`
if (inflight.current.get(key) === signature) return
abortKey(key)
const controller = new AbortController()
controllers.current.set(key, controller)
inflight.current.set(key, signature)
const requestId = (requestIds.current.get(key) ?? 0) + 1
requestIds.current.set(key, requestId)
const startEpoch = epoch.current
// A deep link (`path`, `expanded`) can put a level on screen before the
// level above it loads, and a `loadKey` reset forgets every node. `null`
// is the ROOT's argument, and a loader answered it with the root rows,
// so the level gets its value instead.
const node =
key === CASCADER_ROOT_KEY
? null
: (currentBase.byValue.get(key) ??
known.current.get(key) ?? { value: key, label: key })
setStore((prev) =>
withLoadState(prev, key, (state) => ({
...state,
loading: true,
error: false,
}))
)
const settle = () => {
if (inflight.current.get(key) === signature)
inflight.current.delete(key)
if (controllers.current.get(key) === controller) {
controllers.current.delete(key)
}
}
const stale = () =>
controller.signal.aborted ||
startEpoch !== epoch.current ||
requestId !== requestIds.current.get(key)
// `Promise.resolve().then(...)`, not a direct call: it normalises a
// SYNCHRONOUS throw into a rejection instead of taking the render down.
Promise.resolve()
.then(() => loader(node, { signal: controller.signal, cursor, reason }))
.then((result) => {
settle()
if (stale()) return
const items = Array.isArray(result) ? result : result.items
const nextCursor = Array.isArray(result)
? undefined
: result.nextCursor
const hasMore = Array.isArray(result)
? false
: (result.hasMore ?? nextCursor != null)
if (!append) moreLatch.current.delete(key)
remember(items)
setStore((prev) =>
withPage(prev, key, items, { append, hasMore, cursor: nextCursor })
)
})
.catch((error: unknown) => {
settle()
if (stale()) return
setStore((prev) =>
withLoadState(prev, key, (state) => ({
...state,
loading: false,
error: true,
}))
)
// Behind the stale guard: an abort is navigation, not a failure.
latest.current.onLoadError?.(error, {
parent: key === CASCADER_ROOT_KEY ? null : key,
reason,
})
})
},
[abortKey, remember]
)
const ensureLevel = React.useCallback(
(key: string, reason: CascaderLoadReason) => {
const {
getChildren: loader,
store: current,
base: index,
} = latest.current
if (!loader) return
if (current.states.has(key)) return
// A level `items` already fills is not the loader's business: that is how
// a static root plus `getChildren` for the branches works with no flag.
if (index.childrenOf.has(key)) return
runLoad(key, reason, undefined, false)
},
[runLoad]
)
const loadMore = React.useCallback(
(key: string) => {
const { getChildren: loader, store: current } = latest.current
if (!loader) return
const state = current.states.get(key)
if (!state || state.loading || !state.hasMore) return
const loaded = current.pages.get(key)?.length ?? 0
const signature = `${loaded}:${state.cursor ?? ""}`
if (moreLatch.current.get(key) === signature) return
moreLatch.current.set(key, signature)
runLoad(key, "more", state.cursor, true)
},
[runLoad]
)
const retryLevel = React.useCallback(
(key: string) => {
const { getChildren: loader, store: current } = latest.current
if (!loader) return
const state = current.states.get(key)
if (!state?.error) return
const loaded = current.pages.get(key)?.length ?? 0
// A retry must be able to re-fire the page the latch just blocked.
moreLatch.current.delete(key)
runLoad(key, "retry", loaded > 0 ? state.cursor : undefined, loaded > 0)
},
[runLoad]
)
/**
* `detached` and `links` are deliberately untouched: chains and search hits
* belong to no level, and the trigger needs their path while the new page is
* in flight.
*/
const invalidateLevel = React.useCallback(
(value: string | null) => {
const key = value ?? CASCADER_ROOT_KEY
abortKey(key)
// Bump the id too: a response past its signal check must still be stale.
requestIds.current.set(key, (requestIds.current.get(key) ?? 0) + 1)
moreLatch.current.delete(key)
setStore((prev) => {
if (!prev.states.has(key) && !prev.pages.has(key)) return prev
const states = new Map(prev.states)
states.delete(key)
const pages = new Map(prev.pages)
pages.delete(key)
return { ...prev, pages, states }
})
},
[abortKey]
)
const prefetchNode = React.useCallback(
(node: CascaderNode<T> | null | undefined) => {
const {
prefetch: on,
getChildren: loader,
store: current,
} = latest.current
if (!on || !loader || !node) return
if (isCascaderMoreNode(node)) return
if (!node.hasChildren) return
if (current.states.has(node.value)) return
const holder = timers.current
if (holder.prefetch) clearTimeout(holder.prefetch)
// A TIMEOUT, not a direct call: `onItemHighlighted` fires from a layout
// effect, where a synchronous setState is a render-phase cascade.
holder.prefetch = setTimeout(() => {
holder.prefetch = null
ensureLevel(node.value, "prefetch")
}, PREFETCH_DELAY)
},
[ensureLevel]
)
/* --------------------------------- search -------------------------------- */
const runSearch = React.useCallback(
(text: string) => {
const { onSearch: searcher, path: currentPath } = latest.current
if (!searcher) return
abortKey(SEARCH_KEY)
const controller = new AbortController()
controllers.current.set(SEARCH_KEY, controller)
const requestId = (requestIds.current.get(SEARCH_KEY) ?? 0) + 1
requestIds.current.set(SEARCH_KEY, requestId)
const startEpoch = epoch.current
setSearch((prev) => ({
query: text,
results: prev?.results ?? [],
loading: true,
error: false,
}))
const settle = () => {
if (controllers.current.get(SEARCH_KEY) === controller) {
controllers.current.delete(SEARCH_KEY)
}
}
const stale = () =>
controller.signal.aborted ||
startEpoch !== epoch.current ||
requestId !== requestIds.current.get(SEARCH_KEY)
Promise.resolve()
.then(() =>
searcher(text, { signal: controller.signal, path: currentPath })
)
.then((result) => {
settle()
if (stale()) return
const items = Array.isArray(result) ? result : result.items
remember(items)
// Search hits go to `detached`, never a level: a hit lives anywhere in
// the tree, and filing it under the open level would misplace it.
setStore((prev) => withDetached(prev, items))
setSearch({
query: text,
results: items,
loading: false,
error: false,
})
})
.catch((error: unknown) => {
settle()
if (stale()) return
setSearch((prev) => ({
query: text,
results: prev?.results ?? [],
loading: false,
error: true,
}))
latest.current.onLoadError?.(error, {
parent: null,
reason: "search",
})
})
},
[abortKey, remember]
)
const retrySearch = React.useCallback(() => {
const text = latest.current.query.trim()
if (text) runSearch(text)
}, [runSearch])
/* --------------------------------- resolve ------------------------------- */
const runResolve = React.useCallback(
(value: string) => {
const { resolveValue: resolver } = latest.current
if (!resolver) return
const key = `${RESOLVE_PREFIX}${value}`
abortKey(key)
const controller = new AbortController()
controllers.current.set(key, controller)
const startEpoch = epoch.current
const settle = () => {
if (controllers.current.get(key) === controller) {
controllers.current.delete(key)
}
}
Promise.resolve()
.then(() =>
resolver(value, { signal: controller.signal, reason: "resolve" })
)
.then((chain) => {
settle()
if (controller.signal.aborted || startEpoch !== epoch.current) return
if (!chain?.length) return
remember(chain)
setStore((prev) => withChain(prev, chain))
})
.catch((error: unknown) => {
// Read BEFORE `settle`. A newer ask or a reset that replaced this
// request owns the value's retry, so only a live failure is parked.
const live = controllers.current.get(key) === controller
settle()
if (live) failed.current.add(value)
if (controller.signal.aborted || startEpoch !== epoch.current) return
latest.current.onLoadError?.(error, {
parent: null,
reason: "resolve",
})
})
},
[abortKey, remember]
)
/* --------------------------------- resets -------------------------------- */
// Aborts what the PANEL asked for. A resolve keeps running: it feeds the
// trigger, which shows with the popup closed.
const cancelPanel = React.useCallback(() => {
const keys = Array.from(controllers.current.keys()).filter(
(key) => !key.startsWith(RESOLVE_PREFIX)
)
for (const key of keys) {
controllers.current.get(key)?.abort()
controllers.current.delete(key)
}
inflight.current.clear()
const holder = timers.current
if (holder.prefetch) {
clearTimeout(holder.prefetch)
holder.prefetch = null
}
if (keys.length === 0) return
setStore((prev) => {
let states: Map<string, CascaderLoadState> | null = null
for (const key of keys) {
const state = prev.states.get(key)
if (!state?.loading) continue
states = states ?? new Map(prev.states)
if ((prev.pages.get(key)?.length ?? 0) > 0) {
states.set(key, { ...state, loading: false })
} else {
// No entry AT ALL: membership is what says "loaded", so a stranded
// `loading: true` would read as loaded-and-empty forever.
states.delete(key)
}
}
return states ? { ...prev, states } : prev
})
setSearch((prev) => (prev?.loading ? { ...prev, loading: false } : prev))
}, [])
// A `loadKey` change drops everything. Declared BEFORE the level effect, so
// a reset always lands before the levels are asked for again.
const loadKeyRef = React.useRef<{ key: unknown } | null>(null)
React.useEffect(() => {
const previous = loadKeyRef.current
loadKeyRef.current = { key: loadKey }
if (!previous || Object.is(previous.key, loadKey)) return
epoch.current += 1
for (const controller of controllers.current.values()) controller.abort()
controllers.current.clear()
inflight.current.clear()
// `requestIds` is deliberately NOT cleared: the ids are stale-response
// guards, not cache. Reusing an aborted predecessor's id would leave only
// the epoch bump and the abort to reject the old response; monotonic per
// key keeps all three guards independent. The unmount cleanup clears them.
moreLatch.current.clear()
attempted.current.clear()
failed.current.clear()
known.current.clear()
const holder = timers.current
if (holder.prefetch) {
clearTimeout(holder.prefetch)
holder.prefetch = null
}
setStore(createStore)
setSearch(null)
}, [loadKey])
// Closing the popup aborts the panel's requests but does NOT drop the cache:
// reopening onto an already-loaded level is the point of keeping it.
React.useEffect(() => {
if (enabled) return
if (controllers.current.size === 0) return
cancelPanel()
}, [enabled, cancelPanel])
React.useEffect(() => {
const active = controllers.current
const holder = timers.current
const pending = inflight.current
const ids = requestIds.current
const tried = attempted.current
const parked = failed.current
return () => {
for (const controller of active.values()) controller.abort()
active.clear()
// Cleared WITH the controllers, for StrictMode's dev remount: a stale
// inflight signature would make `runLoad` skip the refetch forever, and
// a value still marked as asked would never be resolved again. The
// aborted promises are stale-guarded, so emptying the ids is safe.
pending.clear()
ids.clear()
tried.clear()
parked.clear()
if (holder.prefetch) clearTimeout(holder.prefetch)
}
}, [])
/* ------------------------------- the effects ----------------------------- */
const hasLoader = typeof getChildren === "function"
const hasSearch = typeof onSearch === "function"
const hasResolve = typeof resolveValue === "function"
const trimmed = query.trim()
// Serialised so the effects key on CONTENT, not on the array identity.
const levelsKey = JSON.stringify(levels)
const valuesKey = JSON.stringify(values)
// THE load trigger: one declarative effect on the levels on screen, not a
// call from `pushLevel`/`navigate`, which a controlled `path` never touches.
React.useEffect(() => {
if (!enabled || !hasLoader) return
for (const key of levels) ensureLevel(key, "level")
// `levels` enters through `levelsKey`; `store` re-runs after a load lands.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, hasLoader, levelsKey, store, ensureLevel])
React.useEffect(() => {
if (!hasSearch) return undefined
if (!enabled || !trimmed) {
abortKey(SEARCH_KEY)
setSearch((prev) => (prev === null ? prev : null))
return undefined
}
const timer = setTimeout(() => runSearch(trimmed), searchDebounce)
return () => {
clearTimeout(timer)
abortKey(SEARCH_KEY)
}
}, [hasSearch, enabled, trimmed, searchDebounce, abortKey, runSearch])
// Opening is the retry point for a failed resolve: un-ask it here, and the
// values pass below, which also re-runs on `enabled`, asks again.
React.useEffect(() => {
if (!enabled || failed.current.size === 0) return
for (const value of failed.current) attempted.current.delete(value)
failed.current.clear()
}, [enabled])
// NOT gated on `enabled`: the trigger shows the selection with the popup
// closed, so a saved value is pathed on load, not on the first open.
// `loadKey` re-runs it, because the reset dropped every resolved chain.
React.useEffect(() => {
if (!hasResolve) return
for (const value of values) {
if (!value) continue
if (attempted.current.has(value)) continue
if (base.byValue.has(value) || known.current.has(value)) continue
attempted.current.add(value)
runResolve(value)
}
// `values` is depended on through `valuesKey`.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, hasResolve, valuesKey, base, runResolve, loadKey])
/* -------------------------------- the value ------------------------------ */
const searchResults = React.useMemo(() => {
if (!hasSearch || !trimmed) return null
if (!search || search.query !== trimmed) {
return NO_RESULTS as CascaderNode<T>[]
}
return search.results
}, [hasSearch, trimmed, search])
const searchState = React.useMemo<CascaderLoadState | null>(() => {
if (!hasSearch || !trimmed) return null
const settled = search?.query === trimmed
return {
loading: !settled || !!search?.loading,
error: settled && !!search?.error,
hasMore: false,
}
}, [hasSearch, trimmed, search])
return React.useMemo(
() => ({
active: hasLoader,
store,
states: store.states,
searchResults,
searchState,
ensureLevel,
loadMore,
retryLevel,
retrySearch,
invalidateLevel,
prefetchNode,
}),
[
hasLoader,
store,
searchResults,
searchState,
ensureLevel,
loadMore,
retryLevel,
retrySearch,
invalidateLevel,
prefetchNode,
]
)
}
/* -------------------------------------------------------------------------- */
/* Consumers */
/* -------------------------------------------------------------------------- */
/** One level's load state, `null` when never fetched. Omit for the root. */
export function useCascaderLoadState(
parent?: string | null
): CascaderLoadState | null {
const { loadStates } = useCascaderState()
return loadStates.get(parent ?? CASCADER_ROOT_KEY) ?? null
}
@@ -0,0 +1,274 @@
"use client"
import * as React from "react"
import {
useCascaderActions,
useCascaderState,
} from "@/components/reui/cascader/cascader-context"
import type { CascaderColumn } from "@/components/reui/cascader/cascader-context"
import {
CascaderItem,
getCascaderMoreProps,
} from "@/components/reui/cascader/cascader-item"
import {
CASCADER_LIST_HEIGHT_CLASS,
CASCADER_LIST_PAD_CLASS,
CASCADER_ROOT_KEY,
CASCADER_ROWS_CLASS,
CASCADER_SCROLL_CLASS,
focusCascaderHome,
warnCascaderOnce,
} from "@/components/reui/cascader/cascader-lib"
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { cn } from "@authportal/ui/lib/utils"
import { ScrollArea } from "@authportal/ui/components/scroll-area"
import { LoaderCircleIcon } from "lucide-react"
export interface CascaderColumnsProps extends Omit<
React.ComponentProps<"div">,
"children"
> {
/** Width of each column. */
columnWidth?: number | string
/** Height CAP per column. Falls back to the root `maxHeight`, then 24rem. */
maxHeight?: number | string
/** Replaces the default panel; the seam a windowed column plugs into. */
children?: (column: CascaderColumn) => React.ReactNode
}
/**
* Miller columns: the open trail side by side, one panel per level. Only the
* DEEPEST column is a real listbox (Base UI owns exactly one list); the trail
* behind is plain buttons, which keeps one state machine instead of a second,
* 2D one. `CascaderInput` moves between columns with ArrowLeft/ArrowRight.
*/
function CascaderColumns({
className,
columnWidth = 220,
maxHeight: maxHeightProp,
children,
...props
}: CascaderColumnsProps) {
const { maxHeight, mode, labels } = useCascaderActions()
const { columns } = useCascaderState()
// Before the early return, so the hook count is the same in both modes.
React.useEffect(() => {
if (process.env.NODE_ENV === "production") return
if (mode === "columns") return
warnCascaderOnce(
`columns-outside-columns-mode:${mode}`,
`\`CascaderColumns\` renders nothing in \`mode="${mode}"\`, so \`columnWidth\` and everything else on it does nothing. Set \`mode="columns"\` on the root, or render \`CascaderList\` instead.`
)
}, [mode])
if (mode !== "columns") return null
const height = maxHeightProp ?? maxHeight
const toCss = (value: number | string) =>
typeof value === "number" ? `${value}px` : value
return (
<div
data-slot="cascader-columns"
role="group"
aria-label={labels.columnsLabel}
style={
{
"--cascader-column-width": toCss(columnWidth),
/* Set only from an EXPLICIT cap: unset means "24rem or what the
viewport leaves", via the `min()` fallback on the panel. A `?? 280`
default here ignored short viewports and wasted tall ones. */
...(height != null
? { "--cascader-max-height": toCss(height) }
: null),
} as React.CSSProperties
}
className={cn(
/* `max-h-full` with `min-h-0`: the trail is the panel's shrinking
child, and the columns inside it size against this box. */
"flex max-h-full min-h-0 items-stretch overflow-x-auto overscroll-x-contain",
CASCADER_LIST_PAD_CLASS,
className
)}
{...props}
>
{columns.map((column) =>
children ? (
<React.Fragment key={column.depth}>{children(column)}</React.Fragment>
) : (
<CascaderColumnPanel key={column.depth} column={column} />
)
)}
</div>
)
}
/**
* One column's box. `columnWidth` is the width of the LIST, not the box: under
* `border-box` the 1px inline-start divider on every column but the first would
* come out of the rows, so bordered columns are widened by that pixel. The box
* is also the BOUND, the same `min(--available-height, cap)` the single list
* uses, and the `ScrollArea` inside scrolls, so every column shows a thumb.
*/
const PANEL_CLASS = `flex w-(--cascader-column-width) shrink-0 flex-col overscroll-contain not-first:w-[calc(var(--cascader-column-width)_+_1px)] not-first:border-border/60 not-first:border-s ${CASCADER_LIST_HEIGHT_CLASS}`
export interface CascaderColumnPanelProps {
column: CascaderColumn
/** Replaces the panel's rows; the empty state still wins on an empty column. */
children?: React.ReactNode
/** Containing block for the windowed column's absolutely positioned rows. */
virtualized?: boolean
}
function CascaderColumnPanel({
column,
children,
virtualized,
}: CascaderColumnPanelProps) {
const {
labels,
baseId,
isBranch,
isSelectable,
isSelected,
isIndeterminate,
retryLevel,
searchScope,
} = useCascaderActions()
const { loadStates, deepResults } = useCascaderState()
// Keyed per level, not one global flag: columns load and land independently.
const columnKey = column.parent?.value ?? CASCADER_ROOT_KEY
const loadState = loadStates.get(columnKey)
let emptyBody: React.ReactNode = labels.empty
if (loadState?.error) {
emptyBody = (
<>
{labels.error}{" "}
<button
type="button"
data-slot="cascader-retry"
onClick={(event) => {
// Same hand-off as the panel's Retry: a trail column, or an
// inline one, can hold focus, and the loading state unmounts this.
focusCascaderHome(event.currentTarget)
retryLevel(columnKey)
}}
className="text-foreground hover:bg-accent focus-visible:ring-ring/50 rounded-md px-1 font-medium outline-hidden transition-colors focus-visible:ring-2"
>
{labels.retry}
</button>
</>
)
} else if (loadState?.loading) {
emptyBody = (
<span className="flex items-center gap-1.5">
<LoaderCircleIcon className="size-3.5 animate-spin" aria-hidden />
{labels.loading}
</span>
)
}
const rows =
column.items.length === 0 ? (
<p
data-slot="cascader-column-empty"
data-state={
loadState?.error ? "error" : loadState?.loading ? "loading" : "empty"
}
className="text-muted-foreground px-2 py-1.5 text-sm"
>
{emptyBody}
</p>
) : (
(children ??
column.items.map((node, i) => {
const open = node.value === column.activeValue
return (
<CascaderItem
key={node.value}
node={node}
/* A trail row must not compete for `aria-activedescendant`. */
as={column.active ? "option" : "button"}
depth={column.depth}
/* Answered here, not in the row: the trail rows are memoised too. */
branch={isBranch(node)}
selectable={isSelectable(node)}
selected={isSelected(node)}
indeterminate={isIndeterminate(node)}
{...getCascaderMoreProps(node, loadStates)}
data-open={open || undefined}
className={open ? "bg-accent/60 text-accent-foreground" : undefined}
/* Set metadata is option-only; a trail row is a `role="button"`. */
{...(column.active
? {
"aria-setsize": column.items.length,
"aria-posinset": i + 1,
}
: null)}
{...(!column.active && open
? {
"aria-expanded": true,
"aria-controls": `${baseId}-column-${column.depth + 1}`,
}
: null)}
/>
)
}))
)
const shared = {
"data-slot": "cascader-column",
"data-active": column.active || undefined,
"data-depth": column.depth,
// Addressable so the opening trail row can point `aria-controls` here, and
// named even at the root, which has no parent label to borrow.
id: `${baseId}-column-${column.depth}`,
// Global hits come from the whole tree, so the parent would understate them.
"aria-label":
column.active && searchScope === "global" && deepResults
? labels.rootLevel
: (column.parent?.label ?? labels.rootLevel),
// Conditional spread, never an explicit `undefined`: the active column is a
// Base UI element, and its `mergeProps` iterates own keys.
...(virtualized ? { "data-virtualized": true } : null),
}
// A windowed row is absolutely positioned, so the ROWS' box is the containing
// block, not the scrollport: it carries the padding the geometry is measured
// against.
const rowsClass = cn(CASCADER_ROWS_CLASS, virtualized && "relative")
// The active column IS the Combobox list: only rows inside `Combobox.List`
// reach the CompositeList, arrow-key navigation and `aria-activedescendant`.
const body = column.active ? (
<ComboboxPrimitive.List {...shared} className={rowsClass}>
{rows}
</ComboboxPrimitive.List>
) : (
// A named `group`, not a second listbox competing with the active column.
<div {...shared} role="group" className={rowsClass}>
{rows}
</div>
)
return (
<div
data-slot="cascader-column-bounds"
/* Repeated from `shared`: this box, not the semantic element, owns the
width, the divider and the height, so style hooks must reach it, and
`:first-child` on the semantic element no longer means "first column"
(it is its own scrollport's only child). */
data-active={column.active || undefined}
data-depth={column.depth}
className={PANEL_CLASS}
>
<ScrollArea className={CASCADER_SCROLL_CLASS}>{body}</ScrollArea>
</div>
)
}
export { CascaderColumnPanel, CascaderColumns }
@@ -0,0 +1,326 @@
"use client"
import * as React from "react"
import type {
CascaderActionItem,
CascaderChangeReason,
CascaderFlatNode,
CascaderIndex,
CascaderLabels,
CascaderLoadState,
CascaderMode,
CascaderNode,
CascaderSearchScope,
} from "@/components/reui/cascader/cascader-types"
/**
* Four contexts, not one: actions (config and callbacks, near-stable), state
* (every keystroke), render (`renderItem` identity) and highlight (every arrow
* key and pointer move). One combined context re-rendered every row on every
* keystroke, which is what made `React.memo` on the row worth nothing.
*/
/* -------------------------------------------------------------------------- */
/* Shared types */
/* -------------------------------------------------------------------------- */
export interface CascaderColumn<T = unknown> {
parent: CascaderNode<T> | null
items: CascaderNode<T>[]
depth: number
/** The node in this column that is drilled into, if any. */
activeValue: string | null
/** Whether this is the deepest column, the one Base UI owns. */
active: boolean
}
export interface CascaderItemState<T = unknown> {
branch: boolean
selected: boolean
/** The node's own flag or a disabled ancestor's, as `isCascaderDisabled`. */
disabled: boolean
depth: number
count: number
/** Ancestor chain, root first. Populated for deep and global search rows. */
path: CascaderNode<T>[]
}
/* -------------------------------------------------------------------------- */
/* State */
/* -------------------------------------------------------------------------- */
/**
* Everything derived from the query, the path and the selection. One keystroke
* rebuilds most of it, so never subscribe to it from a row.
*/
export interface CascaderStateContextValue<T = unknown> {
/** Same `useMemo` identity as the actions context's `index`. */
index: CascaderIndex<T>
path: string[]
expanded: ReadonlySet<string>
query: string
currentParent: CascaderNode<T> | null
/** Rows for the current level, already filtered. */
levelItems: CascaderNode<T>[]
deepResults: CascaderNode<T>[] | null
/** What `Combobox.Root` is currently rendering, in render order. */
renderedItems: CascaderNode<T>[]
columns: CascaderColumn<T>[]
treeRows: CascaderFlatNode<T>[]
selectedValues: string[]
/** Selected nodes below each value, at any depth. Absent means zero. */
selectedDescendants: ReadonlyMap<string, number>
/**
* Per level, keyed by parent value or `CASCADER_ROOT_KEY`. MEMBERSHIP is the
* discriminator: no entry means never fetched, an entry with no `loading`, no
* `error` and no children means fetched and genuinely empty.
*/
loadStates: ReadonlyMap<string, CascaderLoadState>
/** Async search, `null` when idle. Separate: a search belongs to no level. */
searchState: CascaderLoadState | null
announcement: string
}
const CascaderStateContext = React.createContext<
CascaderStateContextValue | undefined
>(undefined)
/**
* One provider serves every `T`, so the context holds an erased `unknown` value
* and this cast restores it. The primitive never inspects the payload.
*/
export function useCascaderState<T = unknown>(): CascaderStateContextValue<T> {
const context = React.useContext(CascaderStateContext)
if (!context) {
throw new Error("useCascaderState must be used within a Cascader")
}
return context as unknown as CascaderStateContextValue<T>
}
/* -------------------------------------------------------------------------- */
/* Actions */
/* -------------------------------------------------------------------------- */
/**
* Config and callbacks, slow enough that a memoised row can subscribe: the
* mutators are `[]`-dep callbacks over a latest-props ref. The predicates are
* the exception, read DURING RENDER where a ref written in an effect would
* return the previous commit's answer, so each is memoised on its own input.
*/
export interface CascaderActionsContextValue<T = unknown> {
index: CascaderIndex<T>
mode: CascaderMode
multiple: boolean
/** Multi-select only: a commit propagates over the LOADED subtree. */
cascade: boolean
/** Whether a BRANCH is committable. Per-LIST: the check gutter is a COLUMN. */
branchesSelectable: boolean
/** Draws the SINGLE-SELECT check and its gutter. Ignored in multi-select. */
indicator: boolean
expandTrigger?: "click" | "hover"
actions: CascaderActionItem[]
searchScope: CascaderSearchScope
maxHeight?: number | string
inline: boolean
invalid: boolean
/**
* Id prefix; columns are `${baseId}-column-${depth}`. The SCHEME is contract:
* the filters primitive's `FilterMenuPinKeeper` restores its highlight across
* a live re-pin through `${baseId}-column-0` and the `cascader-item` slot.
*/
baseId: string
labels: CascaderLabels
/**
* Whether rows are WINDOWED. Also handed to `Combobox.Root`, which is what
* makes an explicit row `index` legal: forwarding one while this is false
* makes `aria-activedescendant` resolve to nothing. Latched per level.
*/
virtualized: boolean
/** Mounts a windowing renderer, returns its unregister. LAYOUT effect only. */
registerVirtualRenderer: () => () => void
/** The root's `virtualize` prop. `undefined` means "decide by count". */
virtualize?: boolean
virtualizeThreshold: number
estimateRowSize: number
overscan: number
/** Tells "this level is empty" from "not fetched yet" before a load state. */
hasLoader: boolean
/** Next page of a level. Latched: a page with nothing new is not re-asked. */
loadMore: (parentKey: string) => void
retryLevel: (parentKey: string) => void
/** Re-runs a failed server search (`onSearch`) for the current query. */
retrySearch: () => void
/**
* Evicts one level's async cache, `null` for the root, so membership reads
* never-loaded. A level that is on screen when evicted refetches at once.
*/
invalidateLevel: (value: string | null) => void
/** Index as of the last commit. Use in the stable callbacks, not `index`. */
getIndex: () => CascaderIndex<T>
getState: () => CascaderStateContextValue<T>
/** Highlighted row or null. A getter: the highlight moves per arrow key. */
getHighlighted: () => CascaderNode<T> | null
setPath: (next: string[] | ((prev: string[]) => string[])) => void
pushLevel: (value: string) => void
popLevel: () => void
goToDepth: (depth: number) => void
toggleExpanded: (value: string) => void
/**
* Registers a footer submenu as open or closed. `Combobox` has no
* `FloatingTree`, so one Escape would dismiss the flyout AND the cascader;
* the root's `onOpenChange` guard cancels the close while any is open, from a
* ref so it reads as of that event without re-rendering the root.
*/
setFlyoutOpen: (key: string, open: boolean) => void
hasOpenFlyout: () => boolean
setQuery: (next: string) => void
/**
* Replaces the selection. `onValueChange` diffs it against the current one
* for its node and reason; pass `reason` only when the caller knows better.
*/
setSelection: (values: string[], reason?: CascaderChangeReason) => void
/** Commits a node, for rows outside the listbox such as ancestor columns. */
commit: (node: CascaderNode<T>) => void
navigate: (node: CascaderNode<T>) => void
/** Into `node` as a child of `depth`, replacing anything deeper. */
navigateAt: (node: CascaderNode<T>, depth: number) => void
/** Never undefined: falls back to a remembered label, then a synthetic node. */
resolveNode: (value: string) => CascaderNode<T>
isBranch: (node: CascaderNode<T>) => boolean
isSelectable: (node: CascaderNode<T>) => boolean
/**
* The node or an ancestor is `disabled`. Rows render it disabled; row
* presses, keys, `commit`, `navigate`, `navigateAt` and a held navigation
* refuse it. `pushLevel`, `setPath`, `toggleExpanded` and `setSelection` are
* raw setters and do not ask.
*/
isDisabled: (node: CascaderNode<T>) => boolean
isSelected: (node: CascaderNode<T>) => boolean
/** Always `false` without `cascade`: partial selection needs propagation. */
isIndeterminate: (node: CascaderNode<T>) => boolean
/**
* O(1) read of `selectedDescendants`; a memoised row may not subscribe.
* Unlike `isIndeterminate` this answers in every mode.
*/
selectedDescendantCount: (node: CascaderNode<T>) => number
}
const CascaderActionsContext = React.createContext<
CascaderActionsContextValue | undefined
>(undefined)
export function useCascaderActions<
T = unknown,
>(): CascaderActionsContextValue<T> {
const context = React.useContext(CascaderActionsContext)
if (!context) {
throw new Error("useCascaderActions must be used within a Cascader")
}
return context as unknown as CascaderActionsContextValue<T>
}
/* -------------------------------------------------------------------------- */
/* Render props */
/* -------------------------------------------------------------------------- */
/**
* Their own context, republished every render. They cannot ride on the actions
* context: an inline closure read off a memoised object is whichever closure
* that object captured, so the row would call a stale prop over stale state.
*/
export interface CascaderRenderContextValue<T = unknown> {
renderItem?: (
node: CascaderNode<T>,
state: CascaderItemState<T>
) => React.ReactNode
renderLabel?: (
node: CascaderNode<T>,
state: CascaderItemState<T>
) => React.ReactNode
}
const CascaderRenderContext = React.createContext<CascaderRenderContextValue>(
{}
)
export function useCascaderRender<
T = unknown,
>(): CascaderRenderContextValue<T> {
return React.useContext(
CascaderRenderContext
) as CascaderRenderContextValue<T>
}
/* -------------------------------------------------------------------------- */
/* Highlight store */
/* -------------------------------------------------------------------------- */
export interface CascaderHighlight {
index: number
value: string | null
}
/**
* An external store, deliberately NOT React state: `onItemHighlighted` fires on
* every arrow key AND every pointer move over the list, so `setState` would
* re-render the whole root at mousemove rate. A store re-renders subscribers
* only, which inside the primitive is the virtualizer.
*/
export interface CascaderHighlightStore {
subscribe: (onStoreChange: () => void) => () => void
getSnapshot: () => CascaderHighlight
set: (next: CascaderHighlight) => void
}
const NO_HIGHLIGHT: CascaderHighlight = { index: -1, value: null }
export function createCascaderHighlightStore(): CascaderHighlightStore {
let snapshot: CascaderHighlight = NO_HIGHLIGHT
const listeners = new Set<() => void>()
return {
subscribe(onStoreChange) {
listeners.add(onStoreChange)
return () => {
listeners.delete(onStoreChange)
}
},
// The SAME object until something changes; `useSyncExternalStore` needs it.
getSnapshot() {
return snapshot
},
set(next) {
if (next.index === snapshot.index && next.value === snapshot.value) return
snapshot = next
for (const listener of listeners) listener()
},
}
}
/** Shared and permanently empty, so the hook degrades outside a `Cascader`. */
const FALLBACK_HIGHLIGHT_STORE = createCascaderHighlightStore()
const CascaderHighlightContext = React.createContext<CascaderHighlightStore>(
FALLBACK_HIGHLIGHT_STORE
)
/** Subscribes to the highlight. Re-renders ONLY the calling component. */
export function useCascaderHighlight(): CascaderHighlight {
const store = React.useContext(CascaderHighlightContext)
return React.useSyncExternalStore(
store.subscribe,
store.getSnapshot,
store.getSnapshot
)
}
export {
CascaderActionsContext,
CascaderHighlightContext,
CascaderRenderContext,
CascaderStateContext,
}
@@ -0,0 +1,731 @@
import * as React from "react"
import { useCascaderActions } from "@/components/reui/cascader/cascader-context"
import {
CASCADER_ACTION_CLASS,
CascaderGroup,
CascaderLabel,
} from "@/components/reui/cascader/cascader-item"
import {
CASCADER_LIST_PAD_CLASS,
getCascaderFooterStops,
isCascaderRtl,
} from "@/components/reui/cascader/cascader-lib"
import type { CascaderActionItem } from "@/components/reui/cascader/cascader-types"
import { Popover as PopoverPrimitive } from "@base-ui/react"
import { useDirection } from "@base-ui/react/direction-provider"
import { cn } from "@authportal/ui/lib/utils"
import { ChevronRightIcon } from "lucide-react"
/**
* The pinned footer, and the side-anchored flyout a footer row can open. These
* are COMMANDS: nothing here joins the selection, the filter set or the
* highlight. The flyout is a Base UI `Popover` rendered as a REACT CHILD of
* `Combobox.Popup` with its OWN `Portal` and NO `container`: a nested portal
* resolves to the parent portal node, so it is a DOM sibling of the combobox
* popup (not clipped, not `aria-hidden`) but a React descendant, which is what
* the outside-press and focus-out whitelists read. `Combobox` builds no
* `FloatingTree`, so the flyout is not consulted first and one Escape would
* dismiss both; hence `CascaderSubmenu` registering with the root to turn one
* Escape into two. `Combobox.List` clicks its highlighted row on Enter, hence
* the footer sitting outside `CascaderList`. And a `Positioner` throws without
* its `Portal`, while `modal` stays `false` on the `Root` so the combobox
* keeps its own dismissal behaviour.
*/
/* -------------------------------------------------------------------------- */
/* Footer */
/* -------------------------------------------------------------------------- */
/**
* Keys the option list acts on, swallowed at the footer boundary. Escape and
* Tab are absent on purpose: Escape must reach the root, Tab must keep moving.
*/
const FOOTER_SWALLOWED_KEYS = new Set([
"Enter",
" ",
"ArrowUp",
"ArrowDown",
"Home",
"End",
"PageUp",
"PageDown",
])
export type CascaderFooterProps = React.ComponentProps<"div">
/**
* Actions pinned below the list, a SIBLING of `CascaderList`. Children win
* over the root's `actions` prop; with neither it renders nothing.
*/
function CascaderFooter({
className,
children,
onKeyDown,
...props
}: CascaderFooterProps) {
const { actions, labels } = useCascaderActions()
const hasChildren = React.Children.count(children) > 0
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event)
if (event.defaultPrevented) return
if (!FOOTER_SWALLOWED_KEYS.has(event.key)) return
event.stopPropagation()
// The strip's own vertical movement, and the way back from the list's
// hand-off: either end returns focus to the search field, from which
// Base UI's empty highlight resumes the list. Down wraps to the FIELD,
// not a command (traps the arrows) or a row (no imperative highlight).
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return
const footer = event.currentTarget
const stops = getCascaderFooterStops(footer)
const active = document.activeElement as HTMLElement | null
const index = active ? stops.indexOf(active) : -1
if (index === -1) return
event.preventDefault()
const next =
event.key === "ArrowDown" ? stops[index + 1] : stops[index - 1]
if (next) {
next.focus()
return
}
if (event.key === "ArrowUp" && index > 0) return
footer
.closest<HTMLElement>('[data-slot="cascader-panel"]')
?.querySelector<HTMLElement>('[data-slot="cascader-input"]')
?.focus()
},
[onKeyDown]
)
if (!hasChildren && actions.length === 0) return null
return (
<div
data-slot="cascader-footer"
/* Named, not a bare div: without it a screen reader reaches the actions
with nothing to say they are not more options. */
role="group"
aria-label={labels.actionsLabel}
onKeyDown={handleKeyDown}
className={cn(
"border-border/60 flex shrink-0 flex-col gap-0.5 border-t",
/* The LIST's padding, not a flat `p-1`: with a padding of its own the
two columns of text were 2px out in luma and sera and 4px out in
lyra. It also gives a separator in here a number to cancel. */
CASCADER_LIST_PAD_CLASS,
"p-(--cascader-list-pad,4px)",
className
)}
{...props}
>
{hasChildren ? children : <CascaderFooterActions actions={actions} />}
</div>
)
}
function CascaderFooterActions({ actions }: { actions: CascaderActionItem[] }) {
return (
<>
{actions.map((action, i) =>
action.items?.length ? (
<CascaderSubmenu key={actionKey(action, i)}>
<CascaderSubmenuTrigger
icon={action.icon}
disabled={action.disabled}
>
{action.label}
</CascaderSubmenuTrigger>
<CascaderSubmenuContent>
<CascaderActionList items={action.items} />
</CascaderSubmenuContent>
</CascaderSubmenu>
) : (
<CascaderAction
key={actionKey(action, i)}
icon={action.icon}
disabled={action.disabled}
onSelect={action.onSelect}
>
{action.label}
</CascaderAction>
)
)}
</>
)
}
function actionKey(action: CascaderActionItem, index: number): string {
if (action.value != null) return action.value
if (typeof action.label === "string") return action.label
return String(index)
}
/**
* Consecutive entries sharing a `group`, as runs not buckets: two separated
* runs with the same name stay two, so the author's order survives.
*/
function groupActionRuns(
items: CascaderActionItem[]
): { group?: string; items: CascaderActionItem[] }[] {
const runs: { group?: string; items: CascaderActionItem[] }[] = []
for (const item of items) {
const last = runs[runs.length - 1]
if (last && last.group === item.group) last.items.push(item)
else runs.push({ group: item.group, items: [item] })
}
return runs
}
/**
* Flyout body for a data-driven submenu. A named run becomes a real
* `CascaderGroup`; unnamed runs stay unwrapped, as an unnamed group is noise.
*/
function CascaderActionList({ items }: { items: CascaderActionItem[] }) {
const { close } = useCascaderSubmenu()
const runs = React.useMemo(() => groupActionRuns(items), [items])
const renderAction = (item: CascaderActionItem, i: number) => (
<CascaderAction
key={actionKey(item, i)}
icon={item.icon}
disabled={item.disabled}
onSelect={() => {
item.onSelect?.()
/* Closes behind the command, or the entries would read as toggles. */
close()
}}
>
{item.label}
</CascaderAction>
)
return (
<>
{runs.map((run, runIndex) =>
run.group ? (
<CascaderGroup key={`${run.group}-${runIndex}`} className="gap-0.5">
<CascaderLabel>{run.group}</CascaderLabel>
{run.items.map(renderAction)}
</CascaderGroup>
) : (
<React.Fragment key={`run-${runIndex}`}>
{run.items.map(renderAction)}
</React.Fragment>
)
)}
</>
)
}
/* -------------------------------------------------------------------------- */
/* Action */
/* -------------------------------------------------------------------------- */
export interface CascaderActionProps extends Omit<
React.ComponentProps<"button">,
"onSelect"
> {
icon?: React.ReactNode
/** Fires on press, after `onClick`, and not at all when disabled. */
onSelect?: () => void
}
/**
* The cascader popup's panel per style, spelled with `style-<name>:` variants
* rather than ReUI theme CSS so an installed footer needs only Tailwind.
*/
const FLYOUT_SURFACE_CLASS =
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-72 overflow-hidden ring-1 duration-100 ring-foreground/10 shadow-md rounded-lg"
/**
* One footer command, shaped like a row and deliberately NOT one. A real
* `<button>`, never a `Combobox.Item`: an item would join the arrow-key ring,
* appear in `filteredItems`, and vanish the moment a query matched nothing -
* exactly when "Create new attribute" is most useful. Disabled is
* `aria-disabled`, never the native attribute: that one is not a tab stop and
* the panel's Tab order reads `button:not([disabled])`, so a footer whose ONLY
* row is a disabled command had no stop after the search field (measured on
* `c-cascader-8`). Staying focusable costs the guards below, plus the
* greyed-out look: `CASCADER_ACTION_CLASS` keys that off `aria-disabled`
* instead of `:disabled`.
*/
function CascaderAction({
className,
icon,
children,
onSelect,
onClick,
onKeyDown,
disabled,
...props
}: CascaderActionProps) {
// Set by `CascaderSubmenuContent`: a plain button in the footer's Tab ring,
// a roving-focus `menuitem` inside a flyout, never both.
const inMenu = React.useContext(CascaderMenuContext)
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
// Before the consumer's handler: a disabled command runs none of them.
if (disabled) {
event.preventDefault()
return
}
onClick?.(event)
if (event.defaultPrevented) return
onSelect?.()
},
[disabled, onClick, onSelect]
)
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (disabled) {
// The two keys a `<button>` activates on, and only those.
if (event.key === "Enter" || event.key === " ") event.preventDefault()
return
}
onKeyDown?.(event)
},
[disabled, onKeyDown]
)
return (
<button
type="button"
data-slot="cascader-action"
/* Conditional spread: `false` would publish `aria-disabled="false"`. */
{...(disabled ? { "aria-disabled": true, "data-disabled": "" } : null)}
onClick={handleClick}
onKeyDown={handleKeyDown}
/* Conditional spread, so a consumer's own `role` or `tabIndex` wins. */
{...(inMenu ? { role: "menuitem" as const, tabIndex: -1 } : null)}
className={cn(CASCADER_ACTION_CLASS, className)}
{...props}
>
{icon ? (
<span
data-slot="cascader-action-icon"
className="text-muted-foreground flex shrink-0 items-center justify-center"
>
{icon}
</span>
) : null}
<span className="min-w-0 flex-1 truncate text-start">{children}</span>
</button>
)
}
/* -------------------------------------------------------------------------- */
/* Submenu */
/* -------------------------------------------------------------------------- */
interface CascaderSubmenuContextValue {
rowRef: React.RefObject<HTMLButtonElement | null>
open: boolean
setOpen: (open: boolean) => void
close: () => void
/** Names the flyout: a menu is labelled by the control that opens it. */
triggerId: string
/**
* Whether the pending open came from the KEYBOARD. Base UI's own `openType`
* calls the opening arrow a POINTER open, because the trigger intercepts it
* and calls `setOpen` (measured: focus landed on the popup, not the first
* entry). A ref, so reading it in the focus phase cannot render.
*/
keyboardRef: React.RefObject<boolean>
}
/** Marks the subtree INSIDE a flyout. See `inMenu` in `CascaderAction`. */
const CascaderMenuContext = React.createContext(false)
/**
* Every entry a menu's roving focus may land on, in DOM order, read from the
* DOM because the entries are whatever the consumer composed. A disabled
* `CascaderAction` is INCLUDED: it carries `aria-disabled`, not the native
* attribute, so `:not([disabled])` excludes only a consumer's own natively
* disabled `menuitem`, which cannot take focus.
*/
function menuItems(popup: HTMLElement | null): HTMLElement[] {
if (!popup) return []
return Array.from(
popup.querySelectorAll<HTMLElement>('[role="menuitem"]:not([disabled])')
)
}
const CascaderSubmenuContext = React.createContext<
CascaderSubmenuContextValue | undefined
>(undefined)
/** The flyout's own state. `close()` is the one a custom entry usually wants. */
export function useCascaderSubmenu(): CascaderSubmenuContextValue {
const context = React.useContext(CascaderSubmenuContext)
if (!context) {
throw new Error("useCascaderSubmenu must be used within a CascaderSubmenu")
}
return context
}
export interface CascaderSubmenuProps {
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
children?: React.ReactNode
}
/**
* A footer row plus the flyout it opens. Registers with the cascader root
* while open, which turns one Escape into two. Cleared in an EFFECT, so the
* flyout still reads as open during the event that closed it.
*/
function CascaderSubmenu({
open: openProp,
defaultOpen = false,
onOpenChange,
children,
}: CascaderSubmenuProps) {
const { setFlyoutOpen } = useCascaderActions()
const key = React.useId()
const triggerId = React.useId()
const rowRef = React.useRef<HTMLButtonElement | null>(null)
const keyboardRef = React.useRef(false)
const [uncontrolled, setUncontrolled] = React.useState(defaultOpen)
const open = openProp ?? uncontrolled
const setOpen = React.useCallback(
(next: boolean) => {
if (openProp == null) setUncontrolled(next)
onOpenChange?.(next)
},
[openProp, onOpenChange]
)
const close = React.useCallback(() => setOpen(false), [setOpen])
React.useEffect(() => {
setFlyoutOpen(key, open)
return () => setFlyoutOpen(key, false)
}, [setFlyoutOpen, key, open])
const context = React.useMemo<CascaderSubmenuContextValue>(
() => ({ rowRef, open, setOpen, close, triggerId, keyboardRef }),
[open, setOpen, close, triggerId]
)
return (
<CascaderSubmenuContext.Provider value={context}>
<PopoverPrimitive.Root
open={open}
onOpenChange={setOpen}
/* NEVER modal: it would disable the combobox that owns it. */
modal={false}
>
{children}
</PopoverPrimitive.Root>
</CascaderSubmenuContext.Provider>
)
}
export interface CascaderSubmenuTriggerProps extends Omit<
React.ComponentProps<"button">,
"onSelect"
> {
icon?: React.ReactNode
}
/** Carries the handler-veto hook, derived so a Base UI bump cannot drift. */
type CascaderSubmenuTriggerClickEvent = Parameters<
NonNullable<PopoverPrimitive.Trigger.Props["onClick"]>
>[0]
/**
* The footer row that opens the flyout, and its anchor. `aria-haspopup="menu"`
* rather than the `dialog` Base UI would announce: what opens is a list of
* commands with roving focus. `disabled` is intercepted, not forwarded:
* `Popover.Trigger` runs it through `useButton`, which writes the NATIVE
* attribute for a native `<button>` and takes the row out of the panel's Tab
* ring, the defect `CascaderAction` documents, and this component exposes no
* `focusableWhenDisabled` to opt out. Published as `aria-disabled`, with the
* arrow keys, Enter and Space, and the click closed by hand (`useClick`
* ignores `defaultPrevented`).
*/
function CascaderSubmenuTrigger({
className,
icon,
children,
onKeyDown,
onClick,
disabled,
...props
}: CascaderSubmenuTriggerProps) {
const { labels } = useCascaderActions()
const { rowRef, setOpen, triggerId, keyboardRef } = useCascaderSubmenu()
const direction = useDirection()
const handleClick = React.useCallback(
(event: CascaderSubmenuTriggerClickEvent) => {
if (disabled) {
// `mergeProps` runs right to left, so this drops Base UI's own handler.
event.preventDefault()
event.preventBaseUIHandler()
return
}
onClick?.(event)
},
[disabled, onClick]
)
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (disabled) {
if (event.key === "Enter" || event.key === " ") event.preventDefault()
return
}
onKeyDown?.(event)
if (event.defaultPrevented) return
// The opening key points AT the flyout, so it flips with the writing
// direction; ArrowDown stays "next command". Resolved per keydown, never
// `useDirection()` alone: with no provider that answers "ltr" in RTL.
const openKey = isCascaderRtl(event.currentTarget, direction)
? "ArrowLeft"
: "ArrowRight"
// Enter and Space open through the button's own click, so only FLAGGED.
if (event.key === "Enter" || event.key === " ") {
keyboardRef.current = true
return
}
if (event.key !== openKey) return
event.preventDefault()
keyboardRef.current = true
setOpen(true)
},
[disabled, onKeyDown, direction, setOpen, keyboardRef]
)
return (
<PopoverPrimitive.Trigger
ref={rowRef}
id={triggerId}
data-slot="cascader-submenu-trigger"
aria-haspopup="menu"
/* NOT `disabled={disabled}`: Base UI writes the native attribute. */
{...(disabled ? { "aria-disabled": true, "data-disabled": "" } : null)}
onClick={handleClick}
onKeyDown={handleKeyDown}
className={cn(
CASCADER_ACTION_CLASS,
/* Painted while the flyout is open, as shadcn paints a SubTrigger.
Keyed off `aria-expanded`, not shadcn's `data-[state=open]`: the
popover trigger carries `aria-expanded` in both states. */
"aria-expanded:bg-accent aria-expanded:text-accent-foreground",
className
)}
{...props}
>
{icon ? (
<span
data-slot="cascader-action-icon"
className="text-muted-foreground flex shrink-0 items-center justify-center"
>
{icon}
</span>
) : null}
<span className="min-w-0 flex-1 truncate text-start">{children}</span>
{/* Nothing else says the row opens a MENU, not another tree level. */}
<span className="sr-only">, {labels.submenuAffordance}</span>
<ChevronRightIcon aria-hidden="true" className="text-muted-foreground -me-0.5 size-4 shrink-0 rtl:-scale-x-100" />
</PopoverPrimitive.Trigger>
)
}
export interface CascaderSubmenuContentProps
extends
PopoverPrimitive.Popup.Props,
Pick<
PopoverPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset"
> {}
/** Carries Base UI's handler-veto hook, so a plain React event will not do. */
type CascaderSubmenuKeyEvent = Parameters<
NonNullable<PopoverPrimitive.Popup.Props["onKeyDown"]>
>[0]
/**
* The flyout itself. `Portal` with NO `container`: a nested portal resolves to
* the parent portal node, the mechanism the file header describes.
*/
function CascaderSubmenuContent({
className,
children,
onKeyDown,
side = "inline-end",
align = "end",
sideOffset = 8,
alignOffset = 0,
...props
}: CascaderSubmenuContentProps) {
const { rowRef, close, triggerId, keyboardRef } = useCascaderSubmenu()
const direction = useDirection()
const popupRef = React.useRef<HTMLDivElement | null>(null)
const typeaheadRef = React.useRef({ buffer: "", at: 0 })
/**
* Keyboard opens land on the first entry; a pointer open falls through to
* Base UI's default, the popup, so a click paints no focus ring. Not an
* effect of our own: Base UI's focus manager runs on open and wins the race.
*/
const initialFocus = React.useCallback(() => {
const byKeyboard = keyboardRef.current
keyboardRef.current = false
if (!byKeyboard) return true
return menuItems(popupRef.current)[0] ?? true
}, [keyboardRef])
const closeAndReturn = React.useCallback(() => {
close()
rowRef.current?.focus()
}, [close, rowRef])
const handleKeyDown = React.useCallback(
(event: CascaderSubmenuKeyEvent) => {
onKeyDown?.(event)
// A DOM sibling of the combobox popup but a REACT descendant, so keys in
// here reach its handlers unless stopped. Enter would commit a row.
event.stopPropagation()
if (event.defaultPrevented) return
const popup = popupRef.current
const items = menuItems(popup)
if (items.length === 0) return
const active = document.activeElement as HTMLElement | null
const index = active ? items.indexOf(active) : -1
const move = (next: number) => {
event.preventDefault()
items[(next + items.length) % items.length]?.focus()
}
// Mirrors the open key on the SAME per-keydown answer as the trigger.
const closeKey = isCascaderRtl(event.currentTarget, direction)
? "ArrowRight"
: "ArrowLeft"
switch (event.key) {
case "ArrowDown":
return move(index + 1)
case "ArrowUp":
// From the popup itself (a pointer open) Up means the LAST entry.
return move(index === -1 ? items.length - 1 : index - 1)
case "Home":
return move(0)
case "End":
return move(items.length - 1)
case closeKey:
event.preventDefault()
return closeAndReturn()
case "Tab":
// A menu never holds Tab: it closes and focus carries on from its row.
close()
rowRef.current?.focus()
return
default:
break
}
// Typeahead, after the switch so it can never swallow a navigation key.
if (
event.key.length !== 1 ||
event.metaKey ||
event.ctrlKey ||
event.altKey
)
return
const now = event.timeStamp
const state = typeaheadRef.current
state.buffer = now - state.at > 500 ? event.key : state.buffer + event.key
state.at = now
const prefix = state.buffer.toLowerCase()
const from = index === -1 ? 0 : index
// Starts AFTER the current entry so one letter cycles rather than sticks.
const ordered = [
...items.slice(state.buffer.length > 1 ? from : from + 1),
...items.slice(0, state.buffer.length > 1 ? from : from + 1),
]
const hit = ordered.find((item) =>
(item.textContent ?? "").trim().toLowerCase().startsWith(prefix)
)
if (hit) {
event.preventDefault()
hit.focus()
}
},
[onKeyDown, direction, close, closeAndReturn, rowRef]
)
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
/* The ROW, not whatever Base UI last treated as the trigger. */
anchor={rowRef}
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
ref={popupRef}
initialFocus={initialFocus}
data-slot="cascader-submenu-content"
/* Roving focus, named by the row that opens it. `tabIndex={-1}` lets
a pointer open park focus here without adding a Tab stop. */
role="menu"
aria-orientation="vertical"
aria-labelledby={triggerId}
tabIndex={-1}
onKeyDown={handleKeyDown}
/* Marks a menu surface for the docs design-system picker, which
repaints menus by walking the DOM. An attribute, not a class. */
data-menu-target=""
className={cn(
FLYOUT_SURFACE_CLASS,
"flex max-w-(--available-width) min-w-48 flex-col gap-0.5 outline-hidden",
CASCADER_LIST_PAD_CLASS,
"p-(--cascader-list-pad,4px)",
className
)}
{...props}
>
<CascaderMenuContext.Provider value={true}>
{children}
</CascaderMenuContext.Provider>
</PopoverPrimitive.Popup>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}
/**
* Whether the footer would render anything, for a wrapper (a separator, a grid
* row) that has to make the same call `CascaderFooter` already makes.
*/
export function useCascaderHasActions(): boolean {
const { actions } = useCascaderActions()
return actions.length > 0
}
export {
CascaderAction,
CascaderFooter,
CascaderSubmenu,
CascaderSubmenuContent,
CascaderSubmenuTrigger,
}
@@ -0,0 +1,92 @@
import type {
CascaderLabels,
CascaderMode,
} from "@/components/reui/cascader/cascader-types"
/** Name of the root level. Hoisted so the root announcement can reuse it. */
const ROOT_LEVEL = "Top level"
/** Hoisted for the same reason: several defaults end in an item count. */
const itemCount = (count: number) =>
`${count} ${count === 1 ? "item" : "items"}`
/**
* English defaults. Every string the primitive can render lives here, so a
* consumer can translate the whole surface by passing `labels`.
*/
export const CASCADER_LABELS: CascaderLabels = {
// Deliberately NOT lowercased. `toLowerCase()` is locale-hostile - it maps
// Turkish "İ" to a two-code-point sequence and German "İstanbul" style
// proper nouns lose their casing - and a label is already written the way
// its author wants it read.
search: (parentLabel) =>
parentLabel ? `Search ${parentLabel}...` : "Search...",
back: "Back",
loading: "Loading...",
loadingMore: "Loading more...",
loadMore: "Load more",
error: "Could not load items.",
retry: "Retry",
empty: "No results found.",
selectedCount: (count) => `${count} selected`,
breadcrumbLabel: "Breadcrumb",
chipsLabel: "Selected items",
removeChip: (label) => `Remove ${label}`,
pathSeparator: "/",
rootLevel: ROOT_LEVEL,
itemCount,
branchAffordance: "submenu",
selectedState: "selected",
partiallySelectedState: "partially selected",
columnsLabel: "Levels",
actionsLabel: "Actions",
submenuAffordance: "opens a menu",
panelLabel: "Options",
keyboardHint: (mode: CascaderMode, dir: "ltr" | "rtl") => {
// "Deeper" is the direction the text runs, so the level keys mirror in
// RTL and the hint has to name the mirrored pair there - an LTR-worded
// hint would teach exactly the wrong keys.
const open = dir === "rtl" ? "Left" : "Right"
const back = dir === "rtl" ? "Right" : "Left"
if (mode === "tree") {
return `Use the ${open} Arrow key to expand and the ${back} Arrow key to collapse.`
}
if (mode === "columns") {
return `Use the ${open} Arrow key to open the next column and the ${back} Arrow key to go back.`
}
return `Use the ${open} Arrow key to open a branch and the ${back} Arrow key to go back.`
},
rootAnnouncement: (count) => `${ROOT_LEVEL}, ${itemCount(count)}`,
expandedAnnouncement: (label, count) =>
`${label} expanded, ${itemCount(count)}`,
collapsedAnnouncement: (label) => `${label} collapsed`,
levelAnnouncement: (parentLabel, depth, count) =>
`${parentLabel}, level ${depth}, ${itemCount(count)}`,
resultsAnnouncement: (count) =>
count === 1 ? "1 result" : `${count} results`,
maxReachedAnnouncement: (max) => `Selection limit of ${max} reached`,
cascadeAnnouncement: (label, count, selecting) =>
`${label} ${selecting ? "selected" : "deselected"}, ${itemCount(count)} followed`,
searchingAnnouncement: "Searching...",
}
/**
* Shallow-merges consumer overrides over the defaults, so `labels` can carry a
* single key without restating the rest.
*/
export function resolveCascaderLabels(
labels?: Partial<CascaderLabels>
): CascaderLabels {
if (!labels) return CASCADER_LABELS
return { ...CASCADER_LABELS, ...labels }
}
/** Resolves the search placeholder, which may be a string or a function. */
export function resolveCascaderSearchLabel(
labels: CascaderLabels,
parentLabel?: string
): string {
return typeof labels.search === "function"
? labels.search(parentLabel)
: labels.search
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,649 @@
import * as React from "react"
import {
useCascaderActions,
useCascaderState,
} from "@/components/reui/cascader/cascader-context"
import { resolveCascaderSearchLabel } from "@/components/reui/cascader/cascader-i18n"
import {
collapseCascaderPath,
getCascaderFooterStops,
getCascaderPath,
isCascaderRtl,
} from "@/components/reui/cascader/cascader-lib"
import type {
CascaderCollapse,
CascaderNode,
CascaderValueDisplay,
} from "@/components/reui/cascader/cascader-types"
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { useDirection } from "@base-ui/react/direction-provider"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "@authportal/ui/lib/utils"
import { ChevronRightIcon, ChevronLeftIcon } from "lucide-react"
/* -------------------------------------------------------------------------- */
/* Separator */
/* -------------------------------------------------------------------------- */
function PathChevron() {
return (
<ChevronRightIcon className="size-3 shrink-0 opacity-50 rtl:-scale-x-100" />
)
}
/* -------------------------------------------------------------------------- */
/* Nav */
/* -------------------------------------------------------------------------- */
export type CascaderNavProps = useRender.ComponentProps<"div">
/**
* Header: back control, search input, and the separator under them. The
* breadcrumb belongs with the list below: it describes the rows, not the field.
*/
function CascaderNav({ className, ...props }: CascaderNavProps) {
const defaultProps = {
"data-slot": "cascader-nav",
className: cn(
// `py-1`, not `py-1.5`: the row inside sets its own per-style height, so
// even padding read too tall. 6px beside the field, 4px above and below.
"border-border/60 flex shrink-0 flex-col gap-1 border-b px-1.5 py-1",
className
),
}
return useRender({
defaultTagName: "div",
render: props.render,
props: mergeProps<"div">(defaultProps, props),
})
}
/* -------------------------------------------------------------------------- */
/* Back */
/* -------------------------------------------------------------------------- */
export interface CascaderBackProps extends Omit<
useRender.ComponentProps<"button">,
"children"
> {
children?: React.ReactNode
}
/** Pops one level. Renders nothing at the root, so no dead header space. */
function CascaderBack({ className, children, ...props }: CascaderBackProps) {
const { popLevel, labels, mode } = useCascaderActions()
const { path } = useCascaderState()
// Checked after `useRender`, never before it: an early return would change
// the hook count between the root level and any deeper one.
const hidden = mode !== "drill" || path.length === 0
const defaultProps = {
"data-slot": "cascader-back",
type: "button" as const,
"aria-label": labels.back,
onClick: () => popLevel(),
className: cn(
"text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring/50 flex shrink-0 items-center justify-center rounded-md outline-hidden transition-colors focus-visible:ring-2",
// A notch under the row height: an affordance, not a second field.
"size-6",
className
),
children: children ?? (
<ChevronLeftIcon className="size-4 rtl:-scale-x-100" />
),
}
const element = useRender({
defaultTagName: "button",
render: props.render,
props: mergeProps<"button">(defaultProps, props),
})
return hidden ? null : element
}
/* -------------------------------------------------------------------------- */
/* Breadcrumb */
/* -------------------------------------------------------------------------- */
export interface CascaderBreadcrumbProps extends Omit<
useRender.ComponentProps<"nav">,
"children"
> {
/** Maximum visible node segments before the middle collapses. */
maxSegments?: number
collapse?: CascaderCollapse
/** Clicking a segment navigates back to that level. Defaults to true. */
interactive?: boolean
}
/**
* Compact trail of the current level AND its ancestors, collapsed by the same
* helper as `CascaderValue` so panel and trigger cannot disagree. The current
* level IS the last crumb: dropping it read as a rendering bug, a trail naming
* only the places you are not.
*/
function CascaderBreadcrumb({
className,
maxSegments = 3,
collapse = "middle",
interactive = true,
...props
}: CascaderBreadcrumbProps) {
const { goToDepth, mode, labels } = useCascaderActions()
const { path, index } = useCascaderState()
// The WHOLE path, current level included; unresolvable entries drop out.
const nodes = path
.map((value) => index.byValue.get(value))
.filter(Boolean) as CascaderNode[]
const segments = collapseCascaderPath(nodes, { maxSegments, collapse })
// `collapseCascaderPath` never collapses the last node away, so this is
// always the final segment - derived rather than assumed, so a `collapse`
// mode that ever changes that cannot mark an ancestor as the current page.
const currentValue = nodes.length ? nodes[nodes.length - 1].value : null
// See `CascaderBack`: the hide check must not short-circuit past `useRender`.
const hidden = mode !== "drill" || nodes.length === 0
const goTo = (node: CascaderNode) => {
const depth = path.indexOf(node.value)
if (depth < 0) return
// `goToDepth`, not a bare `setPath`: it reports `reason: "breadcrumb"`,
// clears the query and drops any pending navigation.
goToDepth(depth + 1)
}
const defaultProps = {
"data-slot": "cascader-breadcrumb",
"aria-label": labels.breadcrumbLabel,
className: cn(
"text-muted-foreground flex min-w-0 shrink-0 items-center gap-0.5 pt-1.5 pb-0.5 text-xs",
// Lines up with the ROWS, not the header: each style's list padding plus
// its row inset. vega/mira/rhea `p-1`+`pl-2`, nova `p-1`+`pl-1.5`, maia
// `p-1`+`pl-3`, lyra `pl-2`, luma/sera `p-1.5`+`pl-3`, per style-*.css.
"px-2.5",
className
),
children: segments.map((segment, i) => (
<React.Fragment
key={segment.type === "node" ? segment.node.value : `gap-${i}`}
>
{i > 0 ? <PathChevron /> : null}
{segment.type === "ellipsis" ? (
<span
data-slot="cascader-breadcrumb-ellipsis"
title={segment.hidden
.map((n) => n.label)
.join(` ${labels.pathSeparator} `)}
className="shrink-0"
>
&hellip;
</span>
) : interactive && segment.node.value !== currentValue ? (
<button
type="button"
data-slot="cascader-breadcrumb-item"
onClick={() => goTo(segment.node)}
className="hover:text-foreground focus-visible:ring-ring/50 max-w-32 truncate rounded-sm outline-hidden transition-colors focus-visible:ring-2"
>
{segment.node.label}
</button>
) : (
<span
data-slot="cascader-breadcrumb-item"
/* The level on screen: a span because it goes nowhere, plus
`aria-current="page"` so it is not read as one of a flat list. */
{...(segment.node.value === currentValue
? { "aria-current": "page" as const }
: null)}
className={cn(
"max-w-32 truncate",
segment.node.value === currentValue &&
"text-foreground font-medium"
)}
>
{segment.node.label}
</span>
)}
</React.Fragment>
)),
}
const element = useRender({
defaultTagName: "nav",
render: props.render,
props: mergeProps<"nav">(defaultProps, props),
})
return hidden ? null : element
}
/* -------------------------------------------------------------------------- */
/* Input */
/* -------------------------------------------------------------------------- */
export interface CascaderInputProps extends ComboboxPrimitive.Input.Props {
/** Renders the back control inline, before the field. Defaults to true. */
showBack?: boolean
}
/** Base UI hands its input handlers an event carrying the veto hook. */
type CascaderInputKeyEvent = Parameters<
NonNullable<ComboboxPrimitive.Input.Props["onKeyDown"]>
>[0]
/**
* Search field. MUST render inside the positioner: only there does Base UI skip
* the refill from the committed selection that would fight every level swap.
*/
function CascaderInput({
className,
showBack = true,
placeholder,
onKeyDown,
"aria-describedby": ariaDescribedBy,
...props
}: CascaderInputProps) {
const {
labels,
popLevel,
mode,
getHighlighted,
isBranch,
isDisabled,
navigate,
index,
toggleExpanded,
inline,
invalid,
baseId,
searchScope,
} = useCascaderActions()
const direction = useDirection()
const { currentParent, query, path, expanded, renderedItems, treeRows } =
useCascaderState()
// Root wording under a global search: naming the level on screen would
// promise a narrower search than the one that runs.
const resolvedPlaceholder =
placeholder ??
resolveCascaderSearchLabel(
labels,
searchScope === "global" ? undefined : currentParent?.label
)
// Not `showBack`: `CascaderBack` renders nothing at the root or outside
// drill mode, where the field's own leading `px-1.5` is wanted. Beside the
// button it stacks with the row's `gap-1` into a 10px hole, hence `ps-0`.
const backVisible = showBack && mode === "drill" && path.length > 0
const hintId = `${baseId}-hint`
// Base UI names no list while inline; columns mode moves the real listbox to
// the deepest panel, which is the `path.length` one.
const listId = `${baseId}-column-${mode === "columns" ? path.length : 0}`
// The direction the HINT is worded for. `handleKeyDown` re-reads
// `isCascaderRtl` per keystroke; this is text on screen before any key, so
// the same check runs once against the mounted DOM (on the hint span, so no
// second ref into Base UI's input). SSR renders the "ltr" default and
// corrects on hydration. `direction` is the only dep: a `dir` attribute and
// the stylesheet are declarations, not state, so nothing to resubscribe to.
const hintRef = React.useRef<HTMLSpanElement>(null)
const [hintDir, setHintDir] = React.useState<"ltr" | "rtl">("ltr")
React.useLayoutEffect(() => {
const hint = hintRef.current
if (!hint) return
setHintDir(isCascaderRtl(hint, direction) ? "rtl" : "ltr")
}, [direction])
/**
* Moves the highlight to `targetIndex` in `treeRows`. Base UI has no
* imperative setter (`actionsRef` is `{ unmount }`), so the move is arrow
* presses, sound only because `useListNavigation` fires `onItemHighlighted`
* SYNCHRONOUSLY per keydown. A press that fails to close the gap ends it.
*/
const moveHighlightTo = (field: HTMLInputElement, targetIndex: number) => {
const rowIndex = () => {
const highlighted = getHighlighted()
if (!highlighted) return -1
return treeRows.findIndex((row) => row.node.value === highlighted.value)
}
let current = rowIndex()
for (let step = 0; step < treeRows.length; step += 1) {
if (current === -1 || current === targetIndex) return
const key = current > targetIndex ? "ArrowUp" : "ArrowDown"
field.dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true })
)
const next = rowIndex()
const progressed =
key === "ArrowUp"
? next < current && next >= targetIndex
: next > current && next <= targetIndex
if (next === -1 || !progressed) return
current = next
}
}
/**
* The tree pattern's two level keys; Base UI leaves both alone (list
* navigation is vertical-only, the chip handler no-ops without
* `Combobox.Chips`). Passed in rather than hardcoded because RTL swaps them:
* APG defines them as "toward the children", not as a physical arrow.
*/
const handleTreeKeyDown = (
event: CascaderInputKeyEvent,
field: HTMLInputElement,
caretAtStart: boolean,
caretAtEnd: boolean,
forwardKey: "ArrowLeft" | "ArrowRight",
backKey: "ArrowLeft" | "ArrowRight"
) => {
const forward = event.key === forwardKey
if (!forward && event.key !== backKey) return
if (forward ? !caretAtEnd : !caretAtStart) return
const highlighted = getHighlighted()
if (!highlighted) return
const rowIndex = treeRows.findIndex(
(row) => row.node.value === highlighted.value
)
if (rowIndex < 0) return
const row = treeRows[rowIndex]
if (forward) {
if (!row.branch) return
event.preventDefault()
if (!row.expanded) {
// `navigate`, not `toggleExpanded`: it waits for an unloaded branch's
// children, so the keyboard path in matches the expander.
navigate(row.node)
return
}
// `flattenCascaderTree` emits children right after their parent, so the
// first child is the next row. The depth check covers an empty branch.
const child = treeRows[rowIndex + 1]
if (child && child.depth === row.depth + 1) {
moveHighlightTo(field, rowIndex + 1)
}
return
}
// A query holds every hit's ancestors open without adding them to
// `expanded`, so the toggle would ADD such a row: it would open a disabled
// one for good. That row moves to its parent instead, as a closed one does.
const heldByQuery = isDisabled(row.node) && !expanded.has(row.node.value)
if (row.branch && row.expanded && !heldByQuery) {
event.preventDefault()
toggleExpanded(row.node.value)
return
}
const parentValue = index.parentOf.get(row.node.value)
if (!parentValue) return
const parentIndex = treeRows.findIndex(
(entry) => entry.node.value === parentValue
)
if (parentIndex < 0) return
event.preventDefault()
moveHighlightTo(field, parentIndex)
}
const handleKeyDown = (event: CascaderInputKeyEvent) => {
onKeyDown?.(event)
if (event.defaultPrevented) return
const field = event.currentTarget
const caretAtStart = field.selectionStart === 0 && field.selectionEnd === 0
const caretAtEnd =
field.selectionStart === query.length &&
field.selectionEnd === query.length
// ArrowDown at the END of the list hands real focus to the pinned footer,
// the key a combobox is actually navigated with; Tab still works and still
// skips the scroll area. An EMPTY list hands off at once, and the footer
// owns the way back. Counted from state, so a windowed list still answers.
if (event.key === "ArrowDown" && !event.altKey) {
const rowCount = mode === "tree" ? treeRows.length : renderedItems.length
const lastValue =
mode === "tree"
? treeRows[rowCount - 1]?.node.value
: renderedItems[rowCount - 1]?.value
const highlighted = getHighlighted()
const atEnd =
rowCount === 0 ||
(highlighted != null && highlighted.value === lastValue)
if (atEnd) {
const panel = field.closest<HTMLElement>('[data-slot="cascader-panel"]')
const stop = panel ? getCascaderFooterStops(panel)[0] : undefined
if (stop) {
event.preventDefault()
// Base UI's handler is deliberately NOT vetoed: its wrap's first
// press CLEARS the highlight, so no stale row stays active behind the
// focused command, and the emptied highlight completes the ring.
stop.focus()
return
}
}
}
// The level keys are LOGICAL, not physical: in RTL, ArrowLeft opens a
// branch and ArrowRight goes back. They act only at the caret edge, and
// those guards do NOT mirror: `selectionStart === 0` is the logical start.
const rtl = isCascaderRtl(field, direction)
const forwardKey = rtl ? "ArrowLeft" : "ArrowRight"
const backKey = rtl ? "ArrowRight" : "ArrowLeft"
if (mode === "tree") {
handleTreeKeyDown(
event,
field,
caretAtStart,
caretAtEnd,
forwardKey,
backKey
)
return
}
if (event.key === forwardKey && caretAtEnd) {
const highlighted = getHighlighted()
if (highlighted && isBranch(highlighted)) {
event.preventDefault()
navigate(highlighted)
return
}
}
// Backspace on an empty query pops a level too, so the keyboard way out of
// a level is symmetric with typing into it.
if (
(event.key === backKey && caretAtStart) ||
(event.key === "Backspace" && query.length === 0)
) {
if (path.length > 0) {
event.preventDefault()
popLevel()
}
}
}
return (
<div
data-slot="cascader-input-row"
className={cn(
"flex shrink-0 items-center gap-1",
/* Each style sizes a combobox search field only as a DIRECT child of
the popup; this row is nested, so the ladder is mirrored here rather
than hardcoding one height for all eight styles. */
"h-8"
)}
>
{showBack ? <CascaderBack /> : null}
<ComboboxPrimitive.Input
data-slot="cascader-input"
placeholder={resolvedPlaceholder}
onKeyDown={handleKeyDown}
/* Nothing on screen says the level keys exist. */
aria-describedby={[ariaDescribedBy, hintId].filter(Boolean).join(" ")}
/* Conditional: an explicit `undefined` deletes Base UI's own value. */
{...(mode === "tree" ? { "aria-haspopup": "tree" as const } : null)}
/* Base UI omits BOTH `aria-expanded` and `aria-controls` while inline,
and an embedded panel is permanently expanded, so supply both. */
{...(inline
? { "aria-expanded": true, "aria-controls": listId }
: null)}
/* No trigger to carry the invalid state in the `inline` case. */
{...(invalid ? { "aria-invalid": true, "data-invalid": "" } : null)}
className={cn(
"placeholder:text-muted-foreground h-full w-full min-w-0 flex-1 bg-transparent px-1.5 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
backVisible && "ps-0",
className
)}
{...props}
/>
<span id={hintId} ref={hintRef} className="sr-only">
{/* `hintDir`, not `direction`: the context alone misses an RTL app that
uses a `dir` attribute instead of `DirectionProvider`. */}
{labels.keyboardHint(mode, hintDir)}
</span>
</div>
)
}
/* -------------------------------------------------------------------------- */
/* Value */
/* -------------------------------------------------------------------------- */
export interface CascaderValueProps extends Omit<
useRender.ComponentProps<"span">,
"children"
> {
/** `path` shows the full trail, `leaf` only the node, `count` only a total. */
display?: CascaderValueDisplay
maxSegments?: number
collapse?: CascaderCollapse
separator?: React.ReactNode
/** Renders the selected node's icon before the trail. */
showIcon?: boolean
placeholder?: React.ReactNode
/** Replaces the whole rendering. Receives the resolved selection and path. */
children?: (selected: CascaderNode[], path: CascaderNode[]) => React.ReactNode
}
/**
* Trigger display. Defaults to the collapsed selection path rather than a bare
* leaf label, which in a nested picker is frequently ambiguous.
*/
function CascaderValue({
className,
display = "path",
maxSegments = 3,
collapse = "middle",
separator,
showIcon = true,
placeholder,
children,
...props
}: CascaderValueProps) {
const { labels, multiple, resolveNode } = useCascaderActions()
const { index, selectedValues } = useCascaderState()
// `resolveNode`, not `index.byValue`: a selection missing from `items` (async
// children, a removed item) must still render a label, not an empty trigger.
const selected = selectedValues.map(resolveNode)
const resolvedPath =
selectedValues.length === 1 ? getCascaderPath(index, selectedValues[0]) : []
// No ancestor chain: fall back to the node so `display="path"` shows a leaf.
const path =
resolvedPath.length > 0
? resolvedPath
: selected.length === 1
? selected
: []
let content: React.ReactNode
if (children) {
content = children(selected, path)
} else if (selectedValues.length === 0) {
content = (
<span className="text-muted-foreground truncate">{placeholder}</span>
)
} else if (display === "count" || (multiple && selectedValues.length > 1)) {
content = labels.selectedCount(selectedValues.length)
} else {
const leaf = path[path.length - 1] ?? selected[0]
const segments =
display === "leaf"
? [{ type: "node" as const, node: leaf }]
: collapseCascaderPath(path, { maxSegments, collapse })
content = (
<>
{showIcon && leaf?.icon ? (
<span
data-slot="cascader-value-icon"
className="text-muted-foreground flex shrink-0 items-center"
>
{leaf.icon}
</span>
) : null}
{segments.map((segment, i) => (
<React.Fragment
key={segment.type === "node" ? segment.node.value : `gap-${i}`}
>
{i > 0 ? (separator ?? <PathChevron />) : null}
{segment.type === "ellipsis" ? (
<span
data-slot="cascader-value-ellipsis"
title={segment.hidden
.map((n) => n.label)
.join(` ${labels.pathSeparator} `)}
className="text-muted-foreground shrink-0"
>
&hellip;
</span>
) : (
<span
className={cn(
"truncate",
i < segments.length - 1 && "text-muted-foreground"
)}
>
{segment.node?.label}
</span>
)}
</React.Fragment>
))}
</>
)
}
const defaultProps = {
"data-slot": "cascader-value",
className: cn("flex min-w-0 items-center gap-1 truncate", className),
children: content,
}
return useRender({
defaultTagName: "span",
render: props.render,
props: mergeProps<"span">(defaultProps, props),
})
}
export {
CascaderNav,
CascaderBack,
CascaderBreadcrumb,
CascaderInput,
CascaderValue,
}
@@ -0,0 +1,220 @@
import type * as React from "react"
/**
* A node in the cascader tree. Two input shapes normalize to one internal
* index: nested (nodes carry `children`), or flat adjacency plus the root's
* `getParent`, which skips the re-nesting step for large datasets.
*/
export interface CascaderNode<T = unknown> {
/** Stable id. Also the committed selection value. */
value: string
label: string
icon?: React.ReactNode
description?: string
children?: CascaderNode<T>[]
/** Declares a branch before load; async nodes read as leaves without it. */
hasChildren?: boolean
/** Trailing count. Defaults to known children; set it for async nodes. */
count?: number
/** Listed, never selected or opened; on a branch, its whole subtree too. */
disabled?: boolean
keywords?: string[]
/**
* Only read on a node nested inside a `getChildren` result. For the level a
* node OWNS the authoritative signal is that level's `CascaderLoadResult`.
*/
hasMore?: boolean
data?: T
}
/**
* One footer ACTION. Deliberately not a `CascaderNode`, so a command can never
* be passed where an option is expected and join the ring, filter or selection.
*/
export interface CascaderActionItem {
/** Stable key. Falls back to the label when it is a string, then the index. */
value?: string
label: React.ReactNode
icon?: React.ReactNode
disabled?: boolean
/** Ignored when `items` is present - a flyout opens instead. */
onSelect?: () => void
/** Turns the row into a submenu trigger. One level deep on purpose. */
items?: CascaderActionItem[]
/** Consecutive entries sharing a heading are drawn under one. */
group?: string
}
/** Panel layout. See the docs for the keyboard map of each. */
export type CascaderMode = "drill" | "columns" | "tree"
export type CascaderSearchScope = "level" | "deep" | "global"
/**
* Which nodes may be committed. The predicate arm is generic over the payload
* rather than quantified per call: a per-call `<T>` would force every predicate
* to accept EVERY payload, so `(node: CascaderNode<Member>) => boolean` could
* never satisfy it.
*/
export type CascaderSelectable<T = unknown> =
| "leaf"
| "any"
| ((node: CascaderNode<T>) => boolean)
export type CascaderCollapse = "middle" | "start" | "none"
export type CascaderValueDisplay = "path" | "leaf" | "count"
export type CascaderChangeReason = "select" | "deselect" | "clear"
/** Second argument to `onValueChange`: resolved nodes, so no re-lookup. */
export interface CascaderChangeDetails<T = unknown> {
/** The node committed or toggled. Null when the selection was cleared. */
node: CascaderNode<T> | null
/** Ancestor chain of `node`, root first, node last. */
path: CascaderNode<T>[]
nodes: CascaderNode<T>[]
reason: CascaderChangeReason
}
/** Normalized tree, built once per `items` identity, shared by every mode. */
export interface CascaderIndex<T = unknown> {
byValue: Map<string, CascaderNode<T>>
/** Children by parent value. Root children are keyed by `ROOT_KEY`. */
childrenOf: Map<string, CascaderNode<T>[]>
parentOf: Map<string, string | null>
/** Zero-based depth by node value. */
depthOf: Map<string, number>
/** Top level nodes, in input order. */
roots: CascaderNode<T>[]
/** Every node in a stable, depth-first order. Used by deep search. */
all: CascaderNode<T>[]
/** Resolved chain nodes listed last in a level that may still return them. */
pinned?: ReadonlySet<string>
/** Resolved chain nodes whose level loaded completely without them: they
* keep their path for the trigger, and are no row, hit or cascade child. */
hidden?: ReadonlySet<string>
}
export interface CascaderFlatNode<T = unknown> {
node: CascaderNode<T>
depth: number
/** Known children, or a declared `hasChildren`. */
branch: boolean
expanded: boolean
/** Sibling count, plus one when the level has a paging row. */
setSize: number
/** One-based index among siblings. */
posInSet: number
}
/** One segment of a rendered path, after collapsing. */
export type CascaderPathSegment<T = unknown> =
| { type: "node"; node: CascaderNode<T> }
| { type: "ellipsis"; hidden: CascaderNode<T>[] }
/**
* Async load state for one node's children. Deliberately WITHOUT a `status`
* field: "declared a branch, never fetched" and "fetched and genuinely empty"
* are told apart by MAP MEMBERSHIP, and a second source of that truth would
* eventually disagree with the first.
*/
export interface CascaderLoadState {
loading: boolean
error: boolean
hasMore: boolean
/** Opaque cursor handed back to `getChildren` for the next page. */
cursor?: string
}
/** Why a level was fetched. `resolve`: `resolveValue` hit an unloaded node. */
export type CascaderLoadReason =
| "level"
| "more"
| "prefetch"
| "retry"
| "resolve"
/** Argument handed to `getChildren` and `resolveValue`. */
export interface CascaderLoadContext {
/**
* Aborted when the request is superseded, `loadKey` changes or the cascader
* unmounts, and for a `getChildren` load also when the popup closes.
*/
signal: AbortSignal
cursor?: string
reason?: CascaderLoadReason
}
/** Value returned by `getChildren`. A bare array is also accepted. */
export interface CascaderLoadResult<T = unknown> {
items: CascaderNode<T>[]
nextCursor?: string
/** Defaults to whether `nextCursor` was supplied. */
hasMore?: boolean
}
/** Argument handed to `onSearch`. */
export interface CascaderSearchContext {
signal: AbortSignal
/** The path the user is searching within, deepest last. Empty under a
* global search, whose scope is the whole tree. */
path: string[]
}
/**
* Every user facing string, so the primitive ships no hardcoded copy. The
* callbacks take plain labels, not nodes: a `CascaderNode<T>` parameter would
* force this object to carry the item generic. `*Announcement` is live-region.
*/
export interface CascaderLabels {
/** `parentLabel` is absent at the root and under a global search. */
search: string | ((parentLabel?: string) => string)
back: string
/** A level's FIRST page. `loadingMore` is the next, `loadMore` its idle row. */
loading: string
loadingMore: string
loadMore: string
error: string
retry: string
empty: string
/** Rendered by `CascaderValue` when `display="count"`. */
selectedCount: (count: number) => string
breadcrumbLabel: string
chipsLabel: string
removeChip: (label: string) => string
/** Trail separator. Not every locale writes one with a slash. */
pathSeparator: string
/** Names the root level wherever there is no parent node to name it. */
rootLevel: string
itemCount: (count: number) => string
/** Appended to a branch row's name outside tree mode: it opens another list. */
branchAffordance: string
/** Columns-trail rows are plain buttons, so they carry no `aria-selected`. */
selectedState: string
/** Same for the mixed state, which a `role="button"` row may not carry. */
partiallySelectedState: string
columnsLabel: string
actionsLabel: string
submenuAffordance: string
panelLabel: string
/** Read per mode AND per direction: the level keys mirror in RTL. */
keyboardHint: (mode: CascaderMode, dir: "ltr" | "rtl") => string
rootAnnouncement: (count: number) => string
expandedAnnouncement: (label: string, count: number) => string
collapsedAnnouncement: (label: string) => string
levelAnnouncement: (
parentLabel: string,
depth: number,
count: number
) => string
resultsAnnouncement: (count: number) => string
maxReachedAnnouncement: (max: number) => string
/** `count` is the descendants swept along; `selecting` is the direction. */
cascadeAnnouncement: (
label: string,
count: number,
selecting: boolean
) => string
searchingAnnouncement: string
}
@@ -0,0 +1,470 @@
"use client"
import * as React from "react"
import { CascaderColumnPanel } from "@/components/reui/cascader/cascader-columns"
import {
useCascaderActions,
useCascaderHighlight,
useCascaderState,
} from "@/components/reui/cascader/cascader-context"
import type { CascaderColumn } from "@/components/reui/cascader/cascader-context"
import {
CascaderItem,
CascaderItems,
getCascaderMoreProps,
} from "@/components/reui/cascader/cascader-item"
import {
defaultRangeExtractor,
useVirtualizer,
type Range,
type Virtualizer,
} from "@tanstack/react-virtual"
/**
* Windowing for the cascader, in its own file so the primitive's own install
* never pulls `@tanstack/react-virtual` in. Base UI forces three rules:
*
* 1. A windowed row MUST carry an explicit `index`; the fallback
* `findItemIndex` is O(n) per row and returns `-1` on the frame a level swap
* renders empty, dangling `aria-activedescendant`.
* 2. That index is DESTRUCTIVE while unvirtualized (`useCompositeListItem`
* skips registration, `CompositeList` truncates `elementsRef` to zero and
* the first arrow key highlights nothing), so rows stay plain until the root
* flips `virtualized`, gated inside `CascaderItem`.
* 3. No `Combobox.Collection` and no FUNCTION CHILD on `Combobox.List`, which
* implicitly wraps one in a Collection: a Collection renders every filtered
* item.
*/
/* -------------------------------------------------------------------------- */
/* Virtualizer */
/* -------------------------------------------------------------------------- */
export interface UseCascaderVirtualizerOptions {
count: number
getScrollElement: () => HTMLElement | null
estimateSize?: number
overscan?: number
/** Key rows by node value, never by index: a tree expand shifts every index. */
getItemKey: (index: number) => string | number
/** Render index of the highlighted row, or `-1`. Pinned into the window. */
activeIndex?: number
}
export type CascaderVirtualizer = Virtualizer<HTMLElement, HTMLElement>
/**
* The cascader's virtualizer: TanStack plus the highlight pinning and
* scroll-into-view a combobox list needs. `activeIndex` is clamped here, not by
* the caller: emitted from a layout effect, it can be one commit ahead of a
* level that just shrank and would otherwise scroll to a row that is gone.
*/
export function useCascaderVirtualizer({
count,
getScrollElement,
estimateSize = 32,
overscan = 8,
getItemKey,
activeIndex = -1,
}: UseCascaderVirtualizerOptions): CascaderVirtualizer {
const active = activeIndex >= 0 && activeIndex < count ? activeIndex : -1
// Keeps the highlighted row mounted so `aria-activedescendant` never dangles.
const rangeExtractor = React.useCallback(
(range: Range) => {
const indices = new Set(defaultRangeExtractor(range))
if (active !== -1) indices.add(active)
return Array.from(indices).sort((a, b) => a - b)
},
[active]
)
const measureEstimate = React.useCallback(() => estimateSize, [estimateSize])
// React Compiler bails on `useVirtualizer` HERE, and only here - the bail does not
// propagate to the components that call this hook. They opt out themselves.
// eslint-disable-next-line react-hooks/incompatible-library
const virtualizer = useVirtualizer<HTMLElement, HTMLElement>({
count,
getScrollElement,
estimateSize: measureEstimate,
overscan,
getItemKey,
rangeExtractor,
})
// Base UI's scroll-into-view no-ops on a windowed-out row (empty `listRef`).
React.useEffect(() => {
if (active === -1) return
virtualizer.scrollToIndex(active, { align: "auto" })
}, [active, count, virtualizer])
return virtualizer
}
/* -------------------------------------------------------------------------- */
/* Geometry */
/* -------------------------------------------------------------------------- */
interface CascaderVirtualGutter {
block: number
inline: number
}
const NO_GUTTER: CascaderVirtualGutter = { block: 0, inline: 0 }
/**
* The ROWS' BOX's own padding, never the scrollport's (separate element, reads
* a flat zero). Measured because every style sets its own: a windowed row is
* positioned against this box's PADDING box while the scroll-height spacer is
* an in-flow child of its CONTENT box, so without it rows sit flush.
*/
function useCascaderVirtualGutter(
contentElement: HTMLElement | null
): CascaderVirtualGutter {
const [gutter, setGutter] = React.useState<CascaderVirtualGutter>(NO_GUTTER)
React.useLayoutEffect(() => {
if (!contentElement) return
const styles = getComputedStyle(contentElement)
const block = Number.parseFloat(styles.paddingTop) || 0
const inline =
Number.parseFloat(styles.paddingInlineStart || styles.paddingLeft) || 0
setGutter((previous) =>
previous.block === block && previous.inline === inline
? previous
: { block, inline }
)
}, [contentElement])
return gutter
}
/** Logical insets, not `left` / `width`, so RTL needs no second code path. */
function cascaderVirtualRowStyle(
start: number,
gutter: CascaderVirtualGutter
): React.CSSProperties {
return {
position: "absolute",
top: 0,
insetInlineStart: gutter.inline,
insetInlineEnd: gutter.inline,
transform: `translateY(${start + gutter.block}px)`,
}
}
/**
* The element that actually SCROLLS: the `ScrollArea` viewport above the rows'
* box, not the rows' box itself, which never overflows, so a virtualizer aimed
* at it would read a full `clientHeight` and render every row. `closest` rather
* than `parentElement`, plus a fallback to the content element, keeps a
* hand-written `overflow-y-auto` container working.
*/
function cascaderScrollElement(
content: HTMLElement | null
): HTMLElement | null {
if (!content) return null
return (
content.closest<HTMLElement>('[data-slot="scroll-area-viewport"]') ??
content
)
}
/**
* `role="presentation"` keeps the listbox owning nothing but options, and this
* node's parent IS the rows' box, saving a ref threaded through `CascaderList`.
*/
function CascaderVirtualSpacer({
height,
onContentElement,
}: {
height: number
onContentElement: (element: HTMLElement | null) => void
}) {
return (
<div
ref={(node) => onContentElement(node?.parentElement ?? null)}
role="presentation"
data-slot="cascader-virtual-spacer"
style={{ height }}
/>
)
}
/* -------------------------------------------------------------------------- */
/* Rows */
/* -------------------------------------------------------------------------- */
export interface CascaderVirtualItemsProps {
/** Row height before measurement. Defaults to the root `estimateRowSize`. */
estimateSize?: number
/** Rows rendered beyond each edge. Defaults to the root `overscan`. */
overscan?: number
}
/**
* Windowed replacement for `CascaderItems`, dropped inside `CascaderList`. The
* registration is a LAYOUT effect because the root derives `virtualized` from
* it and the flip has to land before the browser paints. Until the root has
* seen it, this renders exactly what `CascaderItems` does, so no frame carries
* indexed rows while Base UI still owns the composite list.
*/
function CascaderVirtualItems(props: CascaderVirtualItemsProps) {
const { virtualized, registerVirtualRenderer } = useCascaderActions()
React.useLayoutEffect(
() => registerVirtualRenderer(),
[registerVirtualRenderer]
)
if (!virtualized) return <CascaderItems />
return <CascaderVirtualRows {...props} />
}
function CascaderVirtualRows({
estimateSize,
overscan,
}: CascaderVirtualItemsProps) {
/* The `useCascaderVirtualizer` bail does NOT reach here: the compiler caches
`getVirtualItems()` on the virtualizer, whose identity never changes, so the
window freezes on scroll. Inert where no compiler runs. */
"use no memo"
const {
estimateRowSize,
overscan: rootOverscan,
mode,
isBranch,
isSelectable,
isSelected,
isIndeterminate,
} = useCascaderActions()
const { renderedItems, treeRows, deepResults, loadStates } =
useCascaderState()
// Re-renders on every highlight change, and has to: the pinned row and the
// scroll target are both derived from it.
const highlight = useCascaderHighlight()
const [contentElement, setContentElement] =
React.useState<HTMLElement | null>(null)
const gutter = useCascaderVirtualGutter(contentElement)
const tree = mode === "tree"
const count = tree ? treeRows.length : renderedItems.length
const showPath = !tree && deepResults !== null
const getItemKey = React.useCallback(
(index: number) =>
(tree ? treeRows[index]?.node.value : renderedItems[index]?.value) ??
index,
[tree, treeRows, renderedItems]
)
const virtualizer = useCascaderVirtualizer({
count,
getScrollElement: () => cascaderScrollElement(contentElement),
estimateSize: estimateSize ?? estimateRowSize,
overscan: overscan ?? rootOverscan,
getItemKey,
activeIndex: highlight.index,
})
return (
<>
<CascaderVirtualSpacer
height={virtualizer.getTotalSize()}
onContentElement={setContentElement}
/>
{virtualizer.getVirtualItems().map((row) => {
const flat = tree ? treeRows[row.index] : undefined
const node = tree ? flat?.node : renderedItems[row.index]
// For one commit after a query narrows the level, the window can name
// a row it no longer has; the count recomputes on the same tick.
if (!node) return null
return (
<CascaderItem
key={row.key}
/* Measured, not estimated: rows are two lines with a `description`
and three in deep or global search, and row height is per
style. An estimate alone would make `scrollToIndex` land on the
wrong row. */
ref={virtualizer.measureElement}
data-index={row.index}
style={cascaderVirtualRowStyle(row.start, gutter)}
node={node}
index={row.index}
depth={flat?.depth}
expanded={flat?.expanded}
branch={flat ? flat.branch : isBranch(node)}
selectable={isSelectable(node)}
selected={isSelected(node)}
indeterminate={isIndeterminate(node)}
{...getCascaderMoreProps(node, loadStates)}
showPath={showPath}
aria-setsize={flat ? flat.setSize : count}
aria-posinset={flat ? flat.posInSet : row.index + 1}
/>
)
})}
</>
)
}
/* -------------------------------------------------------------------------- */
/* Column */
/* -------------------------------------------------------------------------- */
export interface CascaderVirtualColumnProps extends CascaderVirtualItemsProps {
column: CascaderColumn
}
/**
* Windowed replacement for one Miller column, passed through the
* `CascaderColumns` render slot. One virtualizer PER COLUMN, since columns
* scroll independently, and only the deepest is Base UI's listbox: the trail
* behind is `as="button"` rows outside Base UI, so it windows on its own row
* count rather than waiting for the root to flip.
*/
function CascaderVirtualColumn({
column,
estimateSize,
overscan,
}: CascaderVirtualColumnProps) {
const {
virtualized,
registerVirtualRenderer,
virtualize,
virtualizeThreshold,
} = useCascaderActions()
React.useLayoutEffect(
() => registerVirtualRenderer(),
[registerVirtualRenderer]
)
const windowed = column.active
? virtualized
: (virtualize ?? column.items.length >= virtualizeThreshold)
if (!windowed) return <CascaderColumnPanel column={column} />
return (
<CascaderColumnPanel column={column} virtualized>
{column.active ? (
<CascaderVirtualActiveColumnRows
column={column}
estimateSize={estimateSize}
overscan={overscan}
/>
) : (
<CascaderVirtualColumnRows
column={column}
estimateSize={estimateSize}
overscan={overscan}
activeIndex={-1}
/>
)}
</CascaderColumnPanel>
)
}
/** Only the active column subscribes to the highlight; a trail column must not. */
function CascaderVirtualActiveColumnRows(props: CascaderVirtualColumnProps) {
const highlight = useCascaderHighlight()
return <CascaderVirtualColumnRows {...props} activeIndex={highlight.index} />
}
function CascaderVirtualColumnRows({
column,
estimateSize,
overscan,
activeIndex,
}: CascaderVirtualColumnProps & { activeIndex: number }) {
/* The `useCascaderVirtualizer` bail does NOT reach here: the compiler caches
`getVirtualItems()` on the virtualizer, whose identity never changes, so the
window freezes on scroll. Inert where no compiler runs. */
"use no memo"
const {
estimateRowSize,
overscan: rootOverscan,
baseId,
isBranch,
isSelectable,
isSelected,
isIndeterminate,
} = useCascaderActions()
const { loadStates } = useCascaderState()
const [contentElement, setContentElement] =
React.useState<HTMLElement | null>(null)
const gutter = useCascaderVirtualGutter(contentElement)
const items = column.items
const getItemKey = React.useCallback(
(index: number) => items[index]?.value ?? index,
[items]
)
const virtualizer = useCascaderVirtualizer({
count: items.length,
getScrollElement: () => cascaderScrollElement(contentElement),
estimateSize: estimateSize ?? estimateRowSize,
overscan: overscan ?? rootOverscan,
getItemKey,
activeIndex,
})
return (
<>
<CascaderVirtualSpacer
height={virtualizer.getTotalSize()}
onContentElement={setContentElement}
/>
{virtualizer.getVirtualItems().map((row) => {
const node = items[row.index]
if (!node) return null
const open = node.value === column.activeValue
return (
<CascaderItem
key={row.key}
ref={virtualizer.measureElement}
data-index={row.index}
style={cascaderVirtualRowStyle(row.start, gutter)}
node={node}
as={column.active ? "option" : "button"}
depth={column.depth}
branch={isBranch(node)}
selectable={isSelectable(node)}
selected={isSelected(node)}
indeterminate={isIndeterminate(node)}
{...getCascaderMoreProps(node, loadStates)}
data-open={open || undefined}
className={open ? "bg-accent/60 text-accent-foreground" : undefined}
/* Only the listbox column may carry an index or listbox metadata.
A trail row is a `role="button"`, which allows neither. */
{...(column.active
? {
index: row.index,
"aria-setsize": items.length,
"aria-posinset": row.index + 1,
}
: null)}
{...(!column.active && open
? {
"aria-expanded": true,
"aria-controls": `${baseId}-column-${column.depth + 1}`,
}
: null)}
/>
)
})}
</>
)
}
export { CascaderVirtualColumn, CascaderVirtualItems }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,189 @@
"use no memo"
import { useMemo, useState } from "react"
import { Badge } from "@/components/reui/badge"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
import type { Column } from "@tanstack/react-table"
import { cn } from "@authportal/ui/lib/utils"
import { Button } from "@authportal/ui/components/button"
import { Input } from "@authportal/ui/components/input"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@authportal/ui/components/popover"
import { Separator } from "@authportal/ui/components/separator"
import { CirclePlusIcon, CheckIcon } from "lucide-react"
interface DataGridColumnFilterProps<TData extends object, TValue> {
column?: Column<DataGridFeatures, TData, TValue>
title?: string
options: {
label: string
value: string
icon?: React.ComponentType<{ className?: string }>
}[]
}
function DataGridColumnFilter<TData extends object, TValue>({
column,
title,
options,
}: DataGridColumnFilterProps<TData, TValue>) {
const { i18n } = useDataGrid()
const facets = column?.getFacetedUniqueValues()
const filterValue = column?.getFilterValue()
const selectedValues = new Set(
Array.isArray(filterValue) ? (filterValue as string[]) : []
)
const [searchQuery, setSearchQuery] = useState("")
const filteredOptions = useMemo(() => {
if (!searchQuery) return options
return options.filter((option) =>
option.label.toLowerCase().includes(searchQuery.toLowerCase())
)
}, [options, searchQuery])
return (
<Popover>
<PopoverTrigger
render={
<Button variant="outline" size="sm">
<CirclePlusIcon className="size-4" />
{title}
{selectedValues?.size > 0 && (
<>
<Separator orientation="vertical" className="mx-2 h-4" />
<Badge
variant="secondary"
className="px-1 font-normal lg:hidden"
>
{selectedValues.size}
</Badge>
<div className="hidden gap-x-1 lg:flex">
{selectedValues.size > 2 ? (
<Badge variant="secondary" className="px-1 font-normal">
{i18n.labels.filterSelectedCount(selectedValues.size)}
</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge
variant="secondary"
key={option.value}
className="px-1 font-normal"
>
{option.label}
</Badge>
))
)}
</div>
</>
)}
</Button>
}
/>
<PopoverContent className="w-[200px] p-0" align="start">
<div className="p-2">
<Input
placeholder={title}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8"
/>
</div>
<div className="max-h-[300px] overflow-y-auto">
{filteredOptions.length === 0 ? (
<div className="text-muted-foreground py-6 text-center text-sm">
{i18n.labels.filterNoResults}
</div>
) : (
<div className="p-1">
{filteredOptions.map((option) => {
const isSelected = selectedValues.has(option.value)
const facetCount = facets?.get(option.value)
const toggleOption = () => {
if (isSelected) {
selectedValues.delete(option.value)
} else {
selectedValues.add(option.value)
}
const filterValues = Array.from(selectedValues)
column?.setFilterValue(
filterValues.length ? filterValues : undefined
)
}
return (
<div
key={option.value}
role="button"
tabIndex={0}
aria-pressed={isSelected}
onClick={toggleOption}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
toggleOption()
}
}}
className={cn(
"rounded-md relative flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm outline-hidden select-none",
"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
)}
>
<div
className={cn(
"border-primary rounded-sm flex h-4 w-4 items-center justify-center border",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}
>
<CheckIcon className="h-4 w-4" />
</div>
{option.icon && (
<option.icon className="text-muted-foreground h-4 w-4" />
)}
<span>{option.label}</span>
{facetCount !== undefined && (
<span className="ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
{facetCount}
</span>
)}
</div>
)
})}
</div>
)}
{selectedValues.size > 0 && (
<>
<div className="bg-border -mx-1 my-1 h-px" />
<div className="p-1">
<div
role="button"
tabIndex={0}
onClick={() => column?.setFilterValue(undefined)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
column?.setFilterValue(undefined)
}
}}
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none"
>
{i18n.labels.filterClear}
</div>
</div>
</>
)}
</div>
</PopoverContent>
</Popover>
)
}
export { DataGridColumnFilter, type DataGridColumnFilterProps }
@@ -0,0 +1,509 @@
"use client"
// Opted out of React Compiler with the rest of the grid (see data-grid.tsx).
"use no memo"
import { memo, useLayoutEffect, useMemo, useRef } from "react"
import type { HTMLAttributes, ReactNode } from "react"
import {
getColumnHeaderLabel,
getDataGridHeaderSpans,
useDataGrid,
} from "@/components/reui/data-grid/data-grid"
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
import { Subscribe } from "@tanstack/react-table"
import type { Column } from "@tanstack/react-table"
import { cn } from "@authportal/ui/lib/utils"
import { Button } from "@authportal/ui/components/button"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@authportal/ui/components/dropdown-menu"
import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, CheckIcon, ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowLeftIcon, ArrowRightIcon, Settings2Icon, PinOffIcon } from "lucide-react"
interface DataGridColumnHeaderProps<
TData extends object,
TValue,
> extends HTMLAttributes<HTMLDivElement> {
column: Column<DataGridFeatures, TData, TValue>
/** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */
title?: string
icon?: ReactNode
/** Reserved; pin controls are gated by tableLayout.columnsPinnable + column.getCanPin(). */
pinnable?: boolean
filter?: ReactNode
visibility?: boolean
}
// Unpinning moves a column into another header list (pinned, windowed center),
// which remounts its header, so the focus request outlives the button: the next
// header rendered for that column takes it. `column` is a stable reference.
const pendingUnpinFocus = new WeakSet<object>()
function DataGridColumnHeaderInner<TData extends object, TValue>({
column,
title,
icon,
className,
filter,
visibility = false,
}: DataGridColumnHeaderProps<TData, TValue>) {
const { i18n, isLoading, table, props } = useDataGrid()
const resolvedTitle = title ?? getColumnHeaderLabel(column)
// The order a move rewrites: the consumer's columnOrder (TanStack defaults it
// to []), then every leaf it leaves out, in definition order - the same
// completion TanStack applies when rendering, so a rendered neighbour is
// always present to re-seat beside, even after columns are added later.
const columnOrderState = table.state.columnOrder
const definitionOrder = table
.getAllColumns()
.flatMap((topColumn) => topColumn.getLeafColumns())
.map((leafColumn) => leafColumn.id)
const columnOrder = [
...columnOrderState,
...definitionOrder.filter((id) => !columnOrderState.includes(id)),
]
const columnVisibilityKey =
props.tableLayout?.columnsVisibility && visibility
? JSON.stringify(table.state.columnVisibility)
: ""
const isSorted = column.getIsSorted()
const isPinned = column.getIsPinned()
const menuTriggerRef = useRef<HTMLButtonElement>(null)
useLayoutEffect(() => {
if (pendingUnpinFocus.delete(column)) menuTriggerRef.current?.focus()
})
const canSort = column.getCanSort()
const canPin = column.getCanPin()
const canResize = column.getCanResize()
// Move neighbours come from what is RENDERED: the column's own pin bucket,
// visible columns only. Stepping through the raw columnOrder would trade
// places with a hidden or pinned column - an enabled click that moves nothing.
// With grouping in TanStack's default "reorder" mode, grouped columns render
// first whatever columnOrder says: they neither move nor serve as a target.
const groupedColumnMode = (
table.options as { groupedColumnMode?: false | "reorder" | "remove" }
).groupedColumnMode
const isHoistedByGrouping = (target: object) =>
groupedColumnMode !== false &&
typeof (target as { getIsGrouped?: unknown }).getIsGrouped === "function" &&
(target as { getIsGrouped: () => boolean }).getIsGrouped()
const renderedLeaves = (
isPinned === "start"
? table.getStartVisibleLeafColumns()
: isPinned === "end"
? table.getEndVisibleLeafColumns()
: table.getCenterVisibleLeafColumns()
)
.filter((leafColumn) => !isHoistedByGrouping(leafColumn))
.map((leafColumn) => leafColumn.id)
const renderedOrder = new Map(renderedLeaves.map((id, index) => [id, index]))
const byRenderedOrder = (a: string, b: string) =>
renderedOrder.get(a)! - renderedOrder.get(b)!
// A peer is a sibling at this column's header level with its rendered
// leaves, so a group or a meta.headerColSpan span moves as one.
const headerSpans = getDataGridHeaderSpans(table)
const coveredIds = new Set(
[...headerSpans.values()].flat().map((leafColumn) => leafColumn.id)
)
// Siblings only: a leaf stepping out of its group would split its header.
const renderedPeers = (column.parent?.columns ?? table.getAllColumns())
.filter((sibling) => !coveredIds.has(sibling.id))
.map((sibling) =>
[...sibling.getLeafColumns(), ...(headerSpans.get(sibling.id) ?? [])]
.map((leafColumn) => leafColumn.id)
.filter((id) => renderedOrder.has(id))
.sort(byRenderedOrder)
)
.filter((leaves) => leaves.length > 0)
.sort((a, b) => byRenderedOrder(a[0]!, b[0]!))
const renderedIndex = column.columns.length
? -1
: renderedPeers.findIndex((leaves) => leaves[0] === column.id)
const leftNeighbour =
renderedIndex > 0 ? renderedPeers[renderedIndex - 1] : undefined
const rightNeighbour =
renderedIndex !== -1 && renderedIndex < renderedPeers.length - 1
? renderedPeers[renderedIndex + 1]
: undefined
const canMoveLeft = leftNeighbour !== undefined
const canMoveRight = rightNeighbour !== undefined
/** Re-seats this column, with the leaves its header spans, beside a rendered
* neighbour; every other column, hidden or pinned, keeps its place. */
const moveBeside = (neighbour: string[], side: "before" | "after") => {
const moving = renderedPeers[renderedIndex] ?? [column.id]
const newOrder = columnOrder.filter((id) => !moving.includes(id))
const anchor =
side === "before" ? neighbour[0] : neighbour[neighbour.length - 1]
const at = anchor === undefined ? -1 : newOrder.indexOf(anchor)
if (at === -1) return
newOrder.splice(side === "before" ? at : at + 1, 0, ...moving)
table.setColumnOrder(newOrder)
}
// column.toggleSorting has no getCanSort() guard of its own, and the
// direct button also renders for columns that are only resizable.
const handleSort = () => {
if (!canSort) return
if (isSorted === "asc") {
column.toggleSorting(true)
} else if (isSorted === "desc") {
column.clearSorting()
} else {
column.toggleSorting(false)
}
}
const headerLabelClassName = cn(
"text-secondary-foreground/80 inline-flex h-full items-center gap-1.5 font-normal [&_svg]:opacity-60 text-[0.8125rem] leading-[calc(1.125/0.8125)] [&_svg]:size-3.5",
className
)
const headerButtonClassName = cn(
"text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground px-2 font-normal h-6 rounded-lg",
className
)
const sortIcon =
canSort &&
(isSorted === "desc" ? (
<ArrowDownIcon className="size-3.25" aria-hidden="true" />
) : isSorted === "asc" ? (
<ArrowUpIcon className="size-3.25" aria-hidden="true" />
) : (
<ChevronsUpDownIcon className="mt-px size-3.25" aria-hidden="true" />
))
// A start-pinned column's resize handle covers the last 20px of its cell
// (the column that renders last already reserves it with pe-8, and a
// start-pinned one renders last only when nothing follows its group), so
// the unpin button steps in until it clears the handle in both densities.
// The step is padding on a wrapper that shrinks first: a column too narrow
// for label, button and clearance gives the clearance back, never the label.
const unpinClearsResizeHandle =
props.tableLayout?.columnsResizable &&
canResize &&
isPinned === "start" &&
!(
column.getIsLastColumn("start") &&
table.getCenterVisibleLeafColumns().length === 0 &&
table.getEndVisibleLeafColumns().length === 0
)
const hasControls =
props.tableLayout?.columnsMovable ||
(props.tableLayout?.columnsVisibility && visibility) ||
(props.tableLayout?.columnsPinnable && canPin) ||
filter
const menuItems = useMemo(() => {
const items: ReactNode[] = []
let hasPreviousSection = false
// Filter section
if (filter) {
items.push(
<DropdownMenuGroup key="group-filter">
<DropdownMenuLabel key="filter">{filter}</DropdownMenuLabel>
</DropdownMenuGroup>
)
hasPreviousSection = true
}
// Sort section
if (canSort) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-sort" />)
}
items.push(
<DropdownMenuItem
key="sort-asc"
onClick={() => {
if (isSorted === "asc") {
column.clearSorting()
} else {
column.toggleSorting(false)
}
}}
disabled={!canSort}
>
<ArrowUpIcon className="size-3.5!" />
<span className="grow">{i18n.labels.sortAscending}</span>
{isSorted === "asc" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>,
<DropdownMenuItem
key="sort-desc"
onClick={() => {
if (isSorted === "desc") {
column.clearSorting()
} else {
column.toggleSorting(true)
}
}}
disabled={!canSort}
>
<ArrowDownIcon className="size-3.5!" />
<span className="grow">{i18n.labels.sortDescending}</span>
{isSorted === "desc" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>
)
hasPreviousSection = true
}
// Pin section
if (props.tableLayout?.columnsPinnable && canPin) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-pin" />)
}
items.push(
<DropdownMenuItem
key="pin-left"
onClick={() => column.pin(isPinned === "start" ? false : "start")}
>
<ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" />
<span className="grow">{i18n.labels.pinColumnStart}</span>
{isPinned === "start" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>,
<DropdownMenuItem
key="pin-right"
onClick={() => column.pin(isPinned === "end" ? false : "end")}
>
<ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" />
<span className="grow">{i18n.labels.pinColumnEnd}</span>
{isPinned === "end" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>
)
hasPreviousSection = true
}
// Move section
if (props.tableLayout?.columnsMovable) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-move" />)
}
items.push(
<DropdownMenuItem
key="move-left"
onClick={() => {
if (leftNeighbour) moveBeside(leftNeighbour, "before")
}}
disabled={!canMoveLeft || isPinned !== false}
>
<ArrowLeftIcon className="size-3.5!" aria-hidden="true" />
<span>{i18n.labels.moveColumnStart}</span>
</DropdownMenuItem>,
<DropdownMenuItem
key="move-right"
onClick={() => {
if (rightNeighbour) moveBeside(rightNeighbour, "after")
}}
disabled={!canMoveRight || isPinned !== false}
>
<ArrowRightIcon className="size-3.5!" aria-hidden="true" />
<span>{i18n.labels.moveColumnEnd}</span>
</DropdownMenuItem>
)
hasPreviousSection = true
}
// Visibility section
if (props.tableLayout?.columnsVisibility && visibility) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-visibility" />)
}
items.push(
<DropdownMenuSub key="visibility">
<DropdownMenuSubTrigger>
<Settings2Icon className="size-3.5!" />
<span>{i18n.labels.columnsMenu}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent side="right">
{table
.getAllColumns()
.filter((col) => col.getCanHide())
.map((col) => (
<DropdownMenuCheckboxItem
key={col.id}
checked={col.getIsVisible()}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(value) => col.toggleVisibility(!!value)}
className={
getColumnHeaderLabel(col) === col.id
? "capitalize"
: undefined
}
>
{getColumnHeaderLabel(col)}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
}
return items
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
filter,
canSort,
isSorted,
column,
props.tableLayout?.columnsPinnable,
props.tableLayout?.columnsMovable,
props.tableLayout?.columnsVisibility,
canPin,
isPinned,
canMoveLeft,
canMoveRight,
visibility,
table,
leftNeighbour,
rightNeighbour,
columnOrder,
columnVisibilityKey, // Needed to update checkbox states when visibility changes
])
if (hasControls) {
return (
<div className="-ms-2 flex h-full items-center justify-between gap-1.5">
<DropdownMenu>
<DropdownMenuTrigger
ref={menuTriggerRef}
render={
<Button
variant="ghost"
className={headerButtonClassName}
disabled={isLoading}
>
{icon && icon}
{resolvedTitle}
{sortIcon}
</Button>
}
/>
<DropdownMenuContent className="w-40" align="start">
{menuItems}
</DropdownMenuContent>
</DropdownMenu>
{props.tableLayout?.columnsPinnable && canPin && isPinned && (
<span
className={cn(
"-me-1 flex min-w-7 shrink-[999]",
unpinClearsResizeHandle && "pe-4"
)}
>
<Button
size="icon-sm"
variant="ghost"
className="rounded-lg size-7"
onClick={(event) => {
const scrollArea = event.currentTarget.closest<HTMLElement>(
'[data-slot="scroll-area-viewport"]'
)
pendingUnpinFocus.add(column)
column.pin(false)
// A column the virtual window leaves unmounted claims nothing,
// so the grid's scroll area takes focus instead of the page.
requestAnimationFrame(() => {
if (pendingUnpinFocus.delete(column)) scrollArea?.focus()
})
}}
aria-label={i18n.labels.unpinColumn(resolvedTitle)}
title={i18n.labels.unpinColumn(resolvedTitle)}
>
<PinOffIcon className="size-3.5! opacity-50!" aria-hidden="true" />
</Button>
</span>
)}
</div>
)
}
if (canSort || (props.tableLayout?.columnsResizable && canResize)) {
return (
<div className="-ms-2 flex h-full items-center">
<Button
variant="ghost"
className={headerButtonClassName}
disabled={isLoading}
onClick={handleSort}
>
{icon && icon}
{resolvedTitle}
{sortIcon}
</Button>
</div>
)
}
return (
<div className={headerLabelClassName}>
{icon && icon}
{resolvedTitle}
</div>
)
}
const DataGridColumnHeaderMemo = memo(DataGridColumnHeaderInner) as <
TData extends object,
TValue,
>(
props: DataGridColumnHeaderProps<TData, TValue> & {
/** Internal: the state slices the header re-renders on. Not part of the public API. */
subscribedState?: unknown
}
) => ReactNode
/**
* Sort and pin state reaches this header through builder calls on `column`
* (`getIsSorted()`, `getIsPinned()`), and `column` keeps its identity across
* renders, so anything memoized against it (the `memo` below, or a consumer's
* compiled header template) never re-runs those reads: frozen sort arrows and
* pin controls. The `Subscribe` below turns the slices this header actually
* reads into a real reactive dependency, and threading the selection through
* as a prop is what lets it past the `memo`.
*/
function DataGridColumnHeader<TData extends object, TValue>(
props: DataGridColumnHeaderProps<TData, TValue>
) {
const { table } = useDataGrid()
return (
<Subscribe
source={table.store}
selector={(state) => ({
sorting: state.sorting,
columnPinning: state.columnPinning,
columnOrder: state.columnOrder,
columnVisibility: state.columnVisibility,
})}
>
{(subscribed) => (
<DataGridColumnHeaderMemo {...props} subscribedState={subscribed} />
)}
</Subscribe>
)
}
export { DataGridColumnHeader, type DataGridColumnHeaderProps }
@@ -0,0 +1,65 @@
"use no memo"
import type { ReactElement } from "react"
import {
getColumnHeaderLabel,
useDataGrid,
} from "@/components/reui/data-grid/data-grid"
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
import type { Table } from "@tanstack/react-table"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@authportal/ui/components/dropdown-menu"
function DataGridColumnVisibility<TData extends object>({
table,
trigger,
}: {
table: Table<DataGridFeatures, TData>
trigger: ReactElement<Record<string, unknown>>
}) {
const { i18n } = useDataGrid()
return (
<DropdownMenu>
<DropdownMenuTrigger render={trigger} />
<DropdownMenuContent align="end" className="min-w-[150px]">
<DropdownMenuGroup>
<DropdownMenuLabel className="font-medium">
{i18n.labels.toggleColumns}
</DropdownMenuLabel>
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
// Title-casing is for a raw column id only; an authored
// label keeps its own casing.
className={
getColumnHeaderLabel(column) === column.id
? "capitalize"
: undefined
}
checked={column.getIsVisible()}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{getColumnHeaderLabel(column)}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export { DataGridColumnVisibility }
@@ -0,0 +1,99 @@
export interface DataGridI18nLabels {
/* The column header menu. */
sortAscending: string
sortDescending: string
pinColumnStart: string
pinColumnEnd: string
moveColumnStart: string
moveColumnEnd: string
columnsMenu: string
unpinColumn: (title: string) => string
toggleColumns: string
/* Row and cell affordances. */
rowCreate: string
pinRow: string
unpinRow: string
selectRow: string
selectAll: string
expandRow: string
collapseRow: string
dragToReorder: string
dragToReorderRow: string
reorderingUnavailable: string
/* Grid states. */
loading: string
empty: string
allRowsLoaded: string
/* Pagination. */
rowsPerPage: string
paginationInfo: (info: { from: number; to: number; count: number }) => string
previousPage: string
nextPage: string
goToPage: (page: number) => string
paginationEllipsis: string
/* The faceted column filter. */
filterSelectedCount: (count: number) => string
filterNoResults: string
filterClear: string
}
export interface DataGridI18nConfig {
labels: DataGridI18nLabels
}
export type DataGridI18nOverrides = {
labels?: Partial<DataGridI18nLabels>
}
const DEFAULT_DATA_GRID_LABELS: DataGridI18nLabels = {
sortAscending: "Asc",
sortDescending: "Desc",
pinColumnStart: "Pin to left",
pinColumnEnd: "Pin to right",
moveColumnStart: "Move to left",
moveColumnEnd: "Move to right",
columnsMenu: "Columns",
unpinColumn: (title) => `Unpin ${title} column`,
toggleColumns: "Toggle Columns",
rowCreate: "Add row",
pinRow: "Pin row",
unpinRow: "Unpin row",
selectRow: "Select row",
selectAll: "Select all",
expandRow: "Expand row",
collapseRow: "Collapse row",
dragToReorder: "Drag to reorder",
dragToReorderRow: "Drag to reorder row",
reorderingUnavailable: "Reordering unavailable",
loading: "Loading...",
empty: "No data available",
allRowsLoaded: "All records loaded",
rowsPerPage: "Rows per page",
paginationInfo: ({ from, to, count }) => `${from} - ${to} of ${count}`,
previousPage: "Go to previous page",
nextPage: "Go to next page",
goToPage: (page) => `Go to page ${page}`,
paginationEllipsis: "...",
filterSelectedCount: (count) => `${count} selected`,
filterNoResults: "No results found.",
filterClear: "Clear filters",
}
const DEFAULT_DATA_GRID_I18N: DataGridI18nConfig = Object.freeze({
labels: Object.freeze(DEFAULT_DATA_GRID_LABELS),
})
/**
* A shallow merge per section, deliberately: a deep merge would leak a
* default back into a function-valued label the consumer replaced. With no
* overrides the frozen default is returned as-is, so the merge is free to
* run on every render without producing a new identity.
*/
export function mergeDataGridI18n(
overrides?: DataGridI18nOverrides
): DataGridI18nConfig {
if (!overrides?.labels) return DEFAULT_DATA_GRID_I18N
return {
labels: { ...DEFAULT_DATA_GRID_LABELS, ...overrides.labels },
}
}
@@ -0,0 +1,309 @@
"use client"
// Opted out of React Compiler with the rest of the grid (see data-grid.tsx).
"use no memo"
import type { JSX, ReactNode } from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import { cn } from "@authportal/ui/lib/utils"
import { Button } from "@authportal/ui/components/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@authportal/ui/components/select"
import { Skeleton } from "@authportal/ui/components/skeleton"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
interface DataGridPaginationProps {
sizes?: number[]
sizesInfo?: string
sizesLabel?: string
sizesDescription?: string
sizesSkeleton?: ReactNode
more?: boolean
/** Page buttons the truncated middle shows; the row adds first, last and
* an ellipsis for each hidden stretch. Clamped to a minimum of 3. */
moreLimit?: number
info?: string
infoSkeleton?: ReactNode
className?: string
rowsPerPageLabel?: string
previousPageLabel?: string
nextPageLabel?: string
ellipsisText?: string
}
/**
* One slot in the page row: a page button, or an ellipsis that jumps over the
* pages it stands for.
*/
type DataGridPaginationItem =
| { type: "page"; index: number }
| { type: "ellipsis"; direction: "previous"; target: number }
| { type: "ellipsis"; direction: "next"; target: number }
/**
* The adaptive window: first page, the run around the current one, last page,
* with an ellipsis standing in for each hidden stretch. It replaced fixed
* BLOCKS (1-5, then 6-10) because a block never shows the last page, so on a
* 50-page grid there was no way to reach the end - the primitive wires only
* `previousPage()` / `nextPage()`, so there is no last-page arrow either.
*
* `limit` is `moreLimit`: how many page buttons the truncated middle shows.
* At the default of 5 this emits exactly the same rows as the block version
* did at `moreLimit: 5`, so the prop keeps both its meaning and its default.
* An ellipsis never stands for a single page, because the early return covers
* every count a full row could hold.
*/
function getDataGridPaginationItems(
pageIndex: number,
pageCount: number,
limit: number
): DataGridPaginationItem[] {
// Pages flanking the current one, per side. The run is rebuilt from it so
// the count is always odd: an even `limit` would make the head and tail
// rows one slot wider than the middle, and the footer would twitch as the
// user pages through.
const sibling = Math.max(0, Math.floor(((Math.floor(limit) || 3) - 3) / 2))
const pages = sibling * 2 + 3
// Two ellipses cost the width of two pages, so below this there is nothing
// to gain by hiding any.
if (pageCount <= pages + 2) {
return Array.from({ length: pageCount }, (_, index) => ({
type: "page" as const,
index,
}))
}
if (pageIndex <= sibling + 2) {
return [
...Array.from({ length: pages }, (_, index) => ({
type: "page" as const,
index,
})),
{ type: "ellipsis", direction: "next", target: pages },
{ type: "page", index: pageCount - 1 },
]
}
if (pageIndex >= pageCount - sibling - 3) {
return [
{ type: "page", index: 0 },
{
type: "ellipsis",
direction: "previous",
target: pageCount - pages - 1,
},
...Array.from({ length: pages }, (_, offset) => ({
type: "page" as const,
index: pageCount - pages + offset,
})),
]
}
return [
{ type: "page", index: 0 },
{
type: "ellipsis",
direction: "previous",
target: pageIndex - sibling - 1,
},
...Array.from({ length: sibling * 2 + 1 }, (_, offset) => ({
type: "page" as const,
index: pageIndex - sibling + offset,
})),
{ type: "ellipsis", direction: "next", target: pageIndex + sibling + 1 },
{ type: "page", index: pageCount - 1 },
]
}
/**
* A page button is square at one digit and grows from there, so the row does
* not reflow when the count crosses 10 or 100. The floor is each style's own
* `icon-sm` square, which is also its `sm` HEIGHT, so the numbers stay square
* and keep the arrows' height. Hardcoding one value would be right in three
* styles and wrong in five.
*/
const PAGE_BUTTON_WIDTH_CLASS =
"min-w-7"
function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
const { i18n, table, recordCount, isLoading } = useDataGrid()
const defaultProps: Partial<DataGridPaginationProps> = {
sizes: [5, 10, 25, 50, 100],
sizesSkeleton: <Skeleton className="h-8 w-44" />,
moreLimit: 5,
infoSkeleton: <Skeleton className="h-8 w-60" />,
rowsPerPageLabel: i18n.labels.rowsPerPage,
previousPageLabel: i18n.labels.previousPage,
nextPageLabel: i18n.labels.nextPage,
ellipsisText: i18n.labels.paginationEllipsis,
}
const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props }
const btnBaseClasses = "p-0 text-sm"
const btnArrowClasses = btnBaseClasses + " rtl:transform rtl:rotate-180"
const pageIndex = table.state.pagination.pageIndex
const pageSize = table.state.pagination.pageSize
const from = recordCount === 0 ? 0 : pageIndex * pageSize + 1
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
const pageCount = table.getPageCount()
// A supplied `info` keeps its placeholder-template contract; the default
// routes through the i18n label function, where word order is free.
const paginationInfo = mergedProps.info
? mergedProps.info
.replaceAll("{from}", from.toString())
.replaceAll("{to}", to.toString())
.replaceAll("{count}", recordCount.toString())
: i18n.labels.paginationInfo({ from, to, count: recordCount })
const paginationItems = getDataGridPaginationItems(
pageIndex,
pageCount,
mergedProps.moreLimit ?? 5
)
return (
<div
data-slot="data-grid-pagination"
className={cn(
"flex grow flex-col flex-wrap items-center justify-between gap-2.5 py-2.5 sm:flex-row sm:py-0",
mergedProps.className
)}
>
<div className="order-2 flex flex-wrap items-center gap-x-2.5 pb-2.5 sm:order-1 sm:pb-0">
{isLoading ? (
mergedProps.sizesSkeleton
) : (
<>
<div className="text-muted-foreground text-sm">
{mergedProps.rowsPerPageLabel}
</div>
<Select
value={`${pageSize}`}
onValueChange={(value) => {
const newPageSize = Number(value)
table.setPageSize(newPageSize)
}}
>
{/* w-fit with a min, never a fixed width: a fixed w-16 clipped
the value "100" by 1px at nova's paddings, while fit-content
grows the trigger for 3-digit sizes and the min keeps the
1-2 digit ones from collapsing narrower than 64px. */}
<SelectTrigger
aria-label={mergedProps.rowsPerPageLabel}
className="w-fit min-w-16"
size="sm"
>
<SelectValue />
</SelectTrigger>
<SelectContent
align="start"
alignItemWithTrigger={false}
className="min-w-(--anchor-width)"
>
{mergedProps.sizes?.map((size: number) => (
<SelectItem key={size} value={`${size}`}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
</div>
<div className="order-1 flex flex-col items-center justify-center gap-2.5 pt-2.5 sm:order-2 sm:flex-row sm:justify-end sm:pt-0">
{isLoading ? (
mergedProps.infoSkeleton
) : (
<>
<div className="text-muted-foreground order-2 text-sm text-nowrap sm:order-1">
{paginationInfo}
</div>
{pageCount > 1 && (
<div className="order-1 flex flex-wrap items-center justify-center gap-1 sm:flex-nowrap">
<Button
size="icon-sm"
variant="ghost"
className={btnArrowClasses}
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">
{mergedProps.previousPageLabel}
</span>
<ChevronLeftIcon className="size-4" />
</Button>
{paginationItems.map((item) =>
item.type === "page" ? (
<Button
key={`page-${item.index}`}
size="sm"
variant="ghost"
aria-label={i18n.labels.goToPage(item.index + 1)}
aria-current={
pageIndex === item.index ? "page" : undefined
}
className={cn(
PAGE_BUTTON_WIDTH_CLASS,
"px-1.5 text-sm",
"text-muted-foreground",
{
"bg-accent text-accent-foreground":
pageIndex === item.index,
}
)}
onClick={() => {
if (pageIndex !== item.index) {
table.setPageIndex(item.index)
}
}}
>
{item.index + 1}
</Button>
) : (
/* Clickable, unlike the shadcn PaginationEllipsis, which is
an aria-hidden span. This one MOVES the user, so it needs
a name saying where. */
<Button
key={`ellipsis-${item.direction}`}
size="icon-sm"
className={btnBaseClasses}
variant="ghost"
aria-label={i18n.labels.goToPage(item.target + 1)}
onClick={() => table.setPageIndex(item.target)}
>
{mergedProps.ellipsisText}
</Button>
)
)}
<Button
size="icon-sm"
variant="ghost"
className={btnArrowClasses}
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">{mergedProps.nextPageLabel}</span>
<ChevronRightIcon className="size-4" />
</Button>
</div>
)}
</>
)}
</div>
</div>
)
}
export { DataGridPagination, type DataGridPaginationProps }
@@ -0,0 +1,554 @@
"use no memo"
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react"
import type { PointerEvent, ReactNode } from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DirectionProvider,
useDirection,
} from "@base-ui/react/direction-provider"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@authportal/ui/lib/utils"
const MIN_THUMB_SIZE = 24
const FALLBACK_SCROLLBAR_SIZE = 12
// Gap between each track and the grid edge: the mb-px / me-px on the tracks and
// the overlay's inset-e-px. The overlay's height math adds it below the bar.
const SCROLLBAR_EDGE_GAP = 1
const INITIAL_METRICS = {
hasVerticalOverflow: false,
headerHeight: 0,
horizontalScrollbarSize: 0,
thumbHeight: 0,
thumbTop: 0,
trackHeight: 0,
} as const
// Track footprint: --data-grid-scrollbar-size (10px by default) in both
// orientations, less 1px for the transparent border and 2px for p-px, so every
// thumb lands at that size less 3px (7px), held 1px off the grid edge. Thinner
// was hard to grab; the tracks overlay the last row and column, so the default
// stays under 12px. The sticky-header overlay below follows the same variable.
const SCROLLBAR_CLASSNAME =
"flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:mb-px data-[orientation=horizontal]:h-[var(--data-grid-scrollbar-size,0.625rem)] data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:me-px data-[orientation=vertical]:h-full data-[orientation=vertical]:w-[var(--data-grid-scrollbar-size,0.625rem)] data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
// The ::before reaches the track's outer edge, so the whole track thickness
// grabs the thumb rather than only its painted 7px.
const SCROLLBAR_THUMB_CLASSNAME =
"bg-border rounded-full relative flex-1 before:absolute before:-inset-0.5"
type DataGridScrollAreaOrientation = "horizontal" | "vertical" | "both"
type ScrollbarMetrics = {
hasVerticalOverflow: boolean
headerHeight: number
horizontalScrollbarSize: number
thumbHeight: number
thumbTop: number
trackHeight: number
}
type ObservedElements = {
header: HTMLElement | null
horizontalScrollbar: HTMLElement | null
table: HTMLElement | null
tableViewport: HTMLElement | null
}
type DataGridScrollAreaProps = Omit<
ScrollAreaPrimitive.Root.Props,
"children"
> & {
children: ReactNode
orientation?: DataGridScrollAreaOrientation
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
function areMetricsEqual(next: ScrollbarMetrics, prev: ScrollbarMetrics) {
return (
next.hasVerticalOverflow === prev.hasVerticalOverflow &&
next.headerHeight === prev.headerHeight &&
next.horizontalScrollbarSize === prev.horizontalScrollbarSize &&
next.thumbHeight === prev.thumbHeight &&
next.thumbTop === prev.thumbTop &&
next.trackHeight === prev.trackHeight
)
}
// A footer held at the bottom (its tfoot or its cells are sticky, however the
// consumer made them so) takes the track's lower end the way the sticky header
// takes its top, so the thumb never paints over the totals.
function getStickyFooterHeight(table: HTMLElement | null) {
const footer = table instanceof HTMLTableElement ? table.tFoot : null
if (!footer) return 0
const isSticky = [footer, footer.rows[0]?.cells[0]].some(
(element) => element && getComputedStyle(element).position === "sticky"
)
return isSticky ? footer.getBoundingClientRect().height : 0
}
function applyMetrics(element: HTMLElement, metrics: ScrollbarMetrics) {
element.style.setProperty(
"--data-grid-scrollbar-header-height",
`${metrics.headerHeight}px`
)
element.style.setProperty(
"--data-grid-scrollbar-thumb-height",
`${metrics.thumbHeight}px`
)
element.style.setProperty(
"--data-grid-scrollbar-thumb-top",
`${metrics.thumbTop}px`
)
element.style.setProperty(
"--data-grid-scrollbar-track-height",
`${metrics.trackHeight}px`
)
}
function DataGridScrollArea({
children,
className,
orientation = "both",
...props
}: DataGridScrollAreaProps) {
const { props: dataGridProps, table } = useDataGrid()
const containerRef = useRef<HTMLDivElement>(null)
const overlayRef = useRef<HTMLDivElement | null>(null)
const viewportRef = useRef<HTMLDivElement | null>(null)
const dragRef = useRef<{
pointerId: number
startScrollTop: number
startY: number
} | null>(null)
const metricsRef = useRef<ScrollbarMetrics>(INITIAL_METRICS)
const observedElementsRef = useRef<ObservedElements>({
header: null,
horizontalScrollbar: null,
table: null,
tableViewport: null,
})
const showHorizontal = orientation !== "vertical"
const showVertical = orientation !== "horizontal"
const usesCustomVerticalScrollbar =
showVertical && !!dataGridProps.tableLayout?.headerSticky
// Pinned columns are sticky and never scroll, so the horizontal scrollbar
// track is inset to span only the scrollable center region between them.
const isColumnsPinnable = !!dataGridProps.tableLayout?.columnsPinnable
const scrollbarInsetStart = isColumnsPinnable ? table.getStartTotalSize() : 0
const scrollbarInsetEnd = isColumnsPinnable ? table.getEndTotalSize() : 0
const [hasCustomVerticalOverflow, setHasCustomVerticalOverflow] =
useState(false)
// Base UI's thumb math reads direction from its own context, which a Radix
// app or a bare dir="rtl" page never provides, so RTL scrolled with LTR math
// and the thumb froze. RTL is read off the layout instead, so no provider of
// any kind is needed. LTR passes the inherited value through untouched: no
// state change and no extra render, exactly as before this existed.
const inheritedDirection = useDirection()
const [isLayoutRtl, setIsLayoutRtl] = useState(false)
useLayoutEffect(() => {
const container = containerRef.current
if (!container) return
const readDirection = () => {
const next = getComputedStyle(container).direction === "rtl"
setIsLayoutRtl((prev) => (prev === next ? prev : next))
}
readDirection()
if (typeof MutationObserver === "undefined") return
// Only a dir attribute flips it at runtime (an RTL toggle on <html>).
const observer = new MutationObserver(readDirection)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["dir"],
subtree: true,
})
return () => observer.disconnect()
}, [])
const clearDragState = useCallback(() => {
dragRef.current = null
document.body.style.userSelect = ""
document.body.style.webkitUserSelect = ""
}, [])
// The overlay is mounted one commit after the sync that detected overflow,
// so it misses that sync's write. Seeding it from the ref callback lands the
// geometry during commit, before the browser paints the track.
const setOverlayRef = useCallback((node: HTMLDivElement | null) => {
overlayRef.current = node
if (node) applyMetrics(node, metricsRef.current)
}, [])
const resetMetrics = useCallback(() => {
if (!areMetricsEqual(INITIAL_METRICS, metricsRef.current)) {
metricsRef.current = INITIAL_METRICS
if (overlayRef.current) applyMetrics(overlayRef.current, INITIAL_METRICS)
}
setHasCustomVerticalOverflow((prev) => (prev ? false : prev))
}, [])
const syncCustomVerticalScrollbar = useCallback(() => {
const container = containerRef.current
const viewport = viewportRef.current
if (!container || !viewport || !usesCustomVerticalScrollbar) {
resetMetrics()
return
}
const { header, horizontalScrollbar, table } = observedElementsRef.current
const headerHeight = header?.getBoundingClientRect().height ?? 0
const footerHeight = getStickyFooterHeight(table)
const viewportHeight = viewport.clientHeight
const viewportWidth = viewport.clientWidth
const scrollHeight = viewport.scrollHeight
const scrollWidth = viewport.scrollWidth
const hasHorizontalOverflow =
showHorizontal && scrollWidth > viewportWidth + 0.5
const horizontalScrollbarSize = hasHorizontalOverflow
? (horizontalScrollbar?.offsetHeight || FALLBACK_SCROLLBAR_SIZE) +
SCROLLBAR_EDGE_GAP
: 0
// The horizontal bar overlays the bottom of the viewport, which a sticky
// footer already covers, so only the larger of the two is taken off.
const trackHeight = Math.max(
0,
viewportHeight -
headerHeight -
Math.max(footerHeight, horizontalScrollbarSize)
)
const maxScroll = Math.max(0, scrollHeight - viewportHeight)
let nextMetrics: ScrollbarMetrics
if (trackHeight === 0 || maxScroll === 0) {
nextMetrics = {
hasVerticalOverflow: false,
headerHeight,
horizontalScrollbarSize,
thumbHeight: trackHeight,
thumbTop: 0,
trackHeight,
}
} else {
const bodyContentHeight = Math.max(
trackHeight,
scrollHeight - headerHeight - footerHeight
)
const thumbHeight = clamp(
trackHeight * (trackHeight / bodyContentHeight),
MIN_THUMB_SIZE,
trackHeight
)
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
const thumbTop =
maxThumbTop > 0 ? (viewport.scrollTop / maxScroll) * maxThumbTop : 0
nextMetrics = {
hasVerticalOverflow: true,
headerHeight,
horizontalScrollbarSize,
thumbHeight,
thumbTop,
trackHeight,
}
}
if (!areMetricsEqual(nextMetrics, metricsRef.current)) {
metricsRef.current = nextMetrics
// Scoped to the overlay, never to the container. These four properties
// inherit, and thumbTop changes on essentially every scroll frame, so
// writing them on the element that wraps the whole grid invalidates
// computed style for every row and cell each frame. The overlay subtree
// is their only reader.
if (overlayRef.current) applyMetrics(overlayRef.current, nextMetrics)
}
setHasCustomVerticalOverflow((prev) =>
prev === nextMetrics.hasVerticalOverflow
? prev
: nextMetrics.hasVerticalOverflow
)
}, [resetMetrics, showHorizontal, usesCustomVerticalScrollbar])
useEffect(() => {
const container = containerRef.current
const viewport = viewportRef.current
if (!container || !viewport) return
if (!usesCustomVerticalScrollbar) {
resetMetrics()
return
}
let frame = 0
const scheduleSync = () => {
cancelAnimationFrame(frame)
frame = window.requestAnimationFrame(syncCustomVerticalScrollbar)
}
const observer =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(scheduleSync)
const observed = new Set<HTMLElement>()
const observeElement = (element: HTMLElement | null) => {
if (element && observer && !observed.has(element)) {
observer.observe(element)
observed.add(element)
}
}
const resolveObservedElements = () => {
observedElementsRef.current = {
header: container.querySelector(
'[data-slot="data-grid-table"] thead'
) as HTMLElement | null,
horizontalScrollbar: container.querySelector(
'[data-slot="data-grid-scrollbar"][data-orientation="horizontal"]'
) as HTMLElement | null,
table: container.querySelector(
'[data-slot="data-grid-table"]'
) as HTMLElement | null,
tableViewport: container.querySelector(
'[data-slot="data-grid-table-viewport"]'
) as HTMLElement | null,
}
observeElement(observedElementsRef.current.header)
observeElement(observedElementsRef.current.table)
observeElement(observedElementsRef.current.tableViewport)
return !!(
observedElementsRef.current.header && observedElementsRef.current.table
)
}
observeElement(viewport)
const resolvedOnMount = resolveObservedElements()
scheduleSync()
viewport.addEventListener("scroll", scheduleSync, { passive: true })
// A table that mounts after this effect (empty state swapped for data)
// would otherwise never be observed and the custom scrollbar would
// overlap the sticky header. One-shot: disconnects once resolved.
let mutationObserver: MutationObserver | null = null
if (!resolvedOnMount && typeof MutationObserver !== "undefined") {
mutationObserver = new MutationObserver(() => {
if (resolveObservedElements()) {
mutationObserver?.disconnect()
mutationObserver = null
scheduleSync()
}
})
mutationObserver.observe(container, { childList: true, subtree: true })
}
return () => {
cancelAnimationFrame(frame)
observer?.disconnect()
mutationObserver?.disconnect()
viewport.removeEventListener("scroll", scheduleSync)
clearDragState()
}
}, [
clearDragState,
resetMetrics,
syncCustomVerticalScrollbar,
usesCustomVerticalScrollbar,
])
const scrollToThumbOffset = (nextThumbTop: number) => {
const viewport = viewportRef.current
const { thumbHeight, trackHeight } = metricsRef.current
if (!viewport) return
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
if (maxScroll === 0 || maxThumbTop === 0) {
viewport.scrollTop = 0
return
}
const ratio = clamp(nextThumbTop, 0, maxThumbTop) / maxThumbTop
viewport.scrollTop = ratio * maxScroll
}
const handleThumbPointerDown = (event: PointerEvent<HTMLDivElement>) => {
const viewport = viewportRef.current
if (!viewport) return
event.preventDefault()
event.stopPropagation()
event.currentTarget.setPointerCapture(event.pointerId)
dragRef.current = {
pointerId: event.pointerId,
startScrollTop: viewport.scrollTop,
startY: event.clientY,
}
document.body.style.userSelect = "none"
document.body.style.webkitUserSelect = "none"
}
const handleThumbPointerMove = (event: PointerEvent<HTMLDivElement>) => {
const viewport = viewportRef.current
const dragState = dragRef.current
const { thumbHeight, trackHeight } = metricsRef.current
if (!viewport || !dragState || dragState.pointerId !== event.pointerId) {
return
}
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
if (maxThumbTop === 0 || maxScroll === 0) return
const deltaY = event.clientY - dragState.startY
const nextScrollTop =
dragState.startScrollTop + (deltaY / maxThumbTop) * maxScroll
viewport.scrollTop = clamp(nextScrollTop, 0, maxScroll)
}
const handleThumbPointerUp = (event: PointerEvent<HTMLDivElement>) => {
if (dragRef.current?.pointerId !== event.pointerId) return
clearDragState()
}
const handleTrackPointerDown = (event: PointerEvent<HTMLDivElement>) => {
const { thumbHeight } = metricsRef.current
if (event.target !== event.currentTarget) return
event.preventDefault()
event.stopPropagation()
const rect = event.currentTarget.getBoundingClientRect()
const offsetY = event.clientY - rect.top - thumbHeight / 2
scrollToThumbOffset(offsetY)
}
return (
<DirectionProvider direction={isLayoutRtl ? "rtl" : inheritedDirection}>
<div ref={containerRef} className="relative">
<ScrollAreaPrimitive.Root
data-slot="data-grid-scroll-area"
// Styling hook: present while the sticky-header scroll mode detects
// vertical overflow, so consumers can style scrollable vs short
// grids with a plain ancestor attribute selector.
data-overflow-vertical={
hasCustomVerticalOverflow ? "true" : undefined
}
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
ref={viewportRef}
data-slot="scroll-area-viewport"
className="size-full"
>
<ScrollAreaPrimitive.Content data-slot="scroll-area-content">
{children}
</ScrollAreaPrimitive.Content>
</ScrollAreaPrimitive.Viewport>
{showHorizontal && (
<ScrollAreaPrimitive.Scrollbar
data-slot="data-grid-scrollbar"
data-orientation="horizontal"
orientation="horizontal"
className={SCROLLBAR_CLASSNAME}
style={
scrollbarInsetStart > 0 || scrollbarInsetEnd > 0
? {
marginInlineStart: scrollbarInsetStart || undefined,
marginInlineEnd: scrollbarInsetEnd || undefined,
}
: undefined
}
>
<ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb"
className={SCROLLBAR_THUMB_CLASSNAME}
/>
</ScrollAreaPrimitive.Scrollbar>
)}
{showVertical && !usesCustomVerticalScrollbar && (
<ScrollAreaPrimitive.Scrollbar
data-slot="data-grid-scrollbar"
data-orientation="vertical"
orientation="vertical"
className={SCROLLBAR_CLASSNAME}
>
<ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb"
className={SCROLLBAR_THUMB_CLASSNAME}
/>
</ScrollAreaPrimitive.Scrollbar>
)}
</ScrollAreaPrimitive.Root>
{usesCustomVerticalScrollbar && hasCustomVerticalOverflow && (
<div
ref={setOverlayRef}
aria-hidden="true"
className="pointer-events-none absolute inset-e-px top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
>
<div
className="pointer-events-auto relative h-full w-[var(--data-grid-scrollbar-size,0.625rem)] touch-none p-px"
onPointerDown={handleTrackPointerDown}
>
<div
className={cn(
"bg-border absolute end-px w-[calc(var(--data-grid-scrollbar-size,0.625rem)-0.1875rem)]",
"top-(--data-grid-scrollbar-thumb-top) h-(--data-grid-scrollbar-thumb-height)",
"rounded-full before:absolute before:-inset-0.5"
)}
onLostPointerCapture={clearDragState}
onPointerCancel={handleThumbPointerUp}
onPointerDown={handleThumbPointerDown}
onPointerMove={handleThumbPointerMove}
onPointerUp={handleThumbPointerUp}
/>
</div>
</div>
)}
</div>
</DirectionProvider>
)
}
export { DataGridScrollArea }
export type { DataGridScrollAreaOrientation, DataGridScrollAreaProps }
@@ -0,0 +1,823 @@
"use client"
// Opted out of React Compiler with the rest of the grid (see data-grid.tsx).
"use no memo"
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react"
import type { CSSProperties, ReactNode } from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import type {
DataGridFeatures,
DataGridTableInstance,
} from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableBodyRow,
DataGridTableBodyRowCell,
DataGridTableBodyRowExpandded,
DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty,
DataGridTableFillBodyCell,
DataGridTableFillHeadCell,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRowSpacer,
DataGridTableViewport,
getDataGridHeadCellResizeHeader,
getDataGridTableMergedHeaderGroups,
renderDataGridTableHeadCellContent,
} from "@/components/reui/data-grid/data-grid-table"
import {
closestCenter,
DndContext,
DragOverlay,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type CollisionDetection,
type DndContextProps,
type DragCancelEvent,
type DragEndEvent,
type DragMoveEvent,
type DragOverEvent,
type DragStartEvent,
type Modifier,
type UniqueIdentifier,
} from "@dnd-kit/core"
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
type SortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { flexRender } from "@tanstack/react-table"
import type { Cell, Header, Row } from "@tanstack/react-table"
import { cn } from "@authportal/ui/lib/utils"
import { createPortal } from "react-dom"
import { Button } from "@authportal/ui/components/button"
import { GripHorizontalIcon } from "lucide-react"
// Context to share sortable listeners from row to handle
type SortableContextValue = ReturnType<typeof useSortable>
const SortableRowContext = createContext<Pick<
SortableContextValue,
"attributes" | "listeners"
> | null>(null)
/**
* Tree metadata attached to every sortable row, readable from
* `active.data.current` / `over.data.current` in any drag event. Cross-parent
* drops can be resolved from it without re-deriving the shape of the table.
*/
type DataGridTableDndRowData = {
type: "data-grid-row"
/** Tree depth, 0 for root rows. */
depth: number
/** Index within the parent's children, or within the root rows. */
index: number
/** Parent row id, or null for root rows. */
parentId: string | null
}
/**
* Per-row render slot for drop indicators and depth guides. The returned node
* is positioned over the row, so it never adds a column, shifts striping, or
* gets clipped by a truncating resizable cell.
*/
type DataGridTableDndRowDecoration<TData extends object> = (context: {
row: Row<DataGridFeatures, TData>
isDragging: boolean
isOver: boolean
}) => ReactNode
function DataGridTableDndRowHandle({
className,
disabled,
disabledLabel,
}: {
className?: string
/**
* Renders the grip inert instead of withdrawing it. A grid that reorders on
* one truth (manual order) and sorts on another cannot honour both at once,
* but dropping the handle entirely collapses the gutter and reads as broken
* rather than as unavailable. Keep the column's shape, mute the control.
*/
disabled?: boolean
/** Announced and shown on hover in place of the drag affordance. */
disabledLabel?: string
}) {
const { i18n } = useDataGrid()
const context = useContext(SortableRowContext)
const resolvedDisabledLabel =
disabledLabel ?? i18n.labels.reorderingUnavailable
if (!context || disabled) {
return (
<Button
variant="ghost"
size="icon-sm"
className={cn(
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
// The Button's own disabled treatment supplies the muting; only the
// cursor needs saying, so the grip reads as unavailable rather than
// merely unresponsive.
disabled && "cursor-not-allowed",
className
)}
aria-label={
disabled ? resolvedDisabledLabel : i18n.labels.dragToReorderRow
}
title={disabled ? resolvedDisabledLabel : undefined}
disabled
>
<GripHorizontalIcon aria-hidden="true" />
</Button>
)
}
return (
<Button
variant="ghost"
size="icon-sm"
className={cn(
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className
)}
aria-label={i18n.labels.dragToReorderRow}
{...context.attributes}
{...context.listeners}
>
<GripHorizontalIcon aria-hidden="true" />
</Button>
)
}
/**
* The rows do not move while one is being carried.
*
* Sliding the siblings apart opens a gap the carried row could go into, which
* reads well in a list of identical rows and badly in a table: the gap is the
* height of the row you are holding, so with rows of unequal height it never
* matches the slot it claims to be, and the row you picked up slides away from
* where it started - which is exactly the position you need to remember if you
* decide not to drop it.
*
* Holding everything still keeps the origin legible, and nothing is lost: the
* DragOverlay clone follows the pointer and the drop indicator names the seam.
* Pass `verticalListSortingStrategy` as `sortingStrategy` for the old feel.
*/
const holdRowsInPlaceStrategy: SortingStrategy = () => null
function DataGridTableDndRow<TData extends object>({
row,
renderRowDecoration,
dropIndicator = true,
}: {
row: Row<DataGridFeatures, TData>
renderRowDecoration?: DataGridTableDndRowDecoration<TData>
dropIndicator?: boolean
}) {
const rowData: DataGridTableDndRowData = {
type: "data-grid-row",
depth: row.depth,
index: row.index,
parentId: row.getParentRow()?.id ?? null,
}
const {
transform,
setNodeRef,
isDragging,
isOver,
attributes,
listeners,
index,
activeIndex,
overIndex,
} = useSortable({
id: row.id,
data: rowData,
})
// Which edge of THIS row the carried row would land on, or null when it is
// not the drop target. Nothing slides apart any more, so the bar is the only
// thing that says where the drop goes: it marks the row at the destination
// index, on the side the carried row comes to rest.
//
// Dragging down it lands after the target, dragging up before it, so the
// edge follows the direction of travel.
const dropEdge =
dropIndicator && activeIndex !== -1 && index === overIndex && !isDragging
? activeIndex < overIndex
? "bottom"
: "top"
: null
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
// dnd-kit's transition is deliberately dropped. A transition on a transform
// property of a `tr` does not merely fail to animate in Chrome, it stops the
// transform applying at all: the element sits at the start value forever.
// The drag source escapes it because dnd-kit disables its own transition
// while it is being dragged, which is why the carried row used to be the
// ONLY one that moved and every other row silently refused to open a gap.
// Displacement therefore lands in one step, which is what a table wants.
zIndex: isDragging ? 1 : 0,
position: "relative",
cursor: isDragging ? "grabbing" : undefined,
// The row you are holding is drawn by the DragOverlay, so the one left
// behind is not a second copy of it - it is the slot you came from, and it
// stays exactly where it was. Fading alone read as "this row is busy";
// the outline says "this is the space you are moving out of", which is the
// thing you need if you change your mind mid-drag.
...(isDragging && {
opacity: 0.4,
// Inset so the dashes sit inside the row box and cannot be clipped by
// the neighbouring row's border.
outline: "1px dashed var(--border)",
outlineOffset: "-1px",
}),
}
const decoration = renderRowDecoration?.({ row, isDragging, isOver })
const cells = row.getVisibleCells()
const lastCell = cells[cells.length - 1]
const renderCell = (cell: Cell<DataGridFeatures, TData, unknown>) => (
<DataGridTableBodyRowCell cell={cell} key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
{decoration && cell === lastCell ? (
// Rides inside the last cell rather than in a `td` of its own.
// An absolutely positioned `td` is still a cell as far as table
// layout is concerned, so it added a NINTH column with no width
// of its own, and under `table-layout: fixed` that new column
// swallowed the whole surplus the real columns had been sharing,
// so every column snapped back to its declared size and the row's
// content visibly narrowed the moment a drag began. A plain
// element adds no column. It anchors to the ROW, the nearest
// positioned ancestor, and spans the full width, unless the last
// cell is a sticky end-pinned one: that cell is positioned itself
// and confines the decoration to its own width.
<div
aria-hidden="true"
data-slot="data-grid-table-row-decoration"
className="pointer-events-none absolute inset-0"
>
{decoration}
</div>
) : null}
{dropEdge && cell === lastCell ? (
// Same anchoring trick as the decoration above: a plain
// element inside the last cell, so it adds no column and
// cannot disturb `table-layout: fixed`. It spans the row, with
// the same sticky end-pinned exception.
<div
aria-hidden="true"
data-slot="data-grid-table-row-drop-indicator"
data-edge={dropEdge}
className="pointer-events-none absolute inset-0 z-20"
>
{/* Two solid pixels down the leading edge, the same marker
the tree drag uses for its drop target. A wash across
the row has to stay faint enough not to read as a
selected row, and in the achromatic styles primary
carries no chroma at all, so faint plus colourless is
just grey. The bar reads at any weight and leaves the
row's own background to hover and selection.
The bar is the whole indicator: the gap the rows have
already opened says which side, so a rule across the
seam as well only competes with the row borders it sits
between. `data-edge` still carries the direction for
anyone styling their own. */}
<span className="bg-primary absolute inset-y-0 start-0 w-0.5" />
</div>
) : null}
</DataGridTableBodyRowCell>
)
return (
<SortableRowContext.Provider value={{ attributes, listeners }}>
<DataGridTableBodyRow
row={row}
dndRef={setNodeRef}
dndStyle={style}
dndDragging={isDragging}
>
{/* One keyed list with the fill in the middle, as in the head row. */}
{[
...cells
.filter((cell) => cell.column.getIsPinned() !== "end")
.map(renderCell),
<DataGridTableFillBodyCell key="__data-grid-fill" />,
...cells
.filter((cell) => cell.column.getIsPinned() === "end")
.map(renderCell),
]}
</DataGridTableBodyRow>
{row.getIsExpanded() && <DataGridTableBodyRowExpandded row={row} />}
</SortableRowContext.Provider>
)
}
function DataGridTableDndRowsBody<TData extends object>({
table,
dataIds,
renderRowDecoration,
dropIndicator,
sortingStrategy,
}: {
table: DataGridTableInstance<TData>
dataIds: UniqueIdentifier[]
renderRowDecoration?: DataGridTableDndRowDecoration<TData>
dropIndicator?: boolean
sortingStrategy: SortingStrategy
}) {
const { isLoading, props } = useDataGrid()
const pagination = table.state.pagination
if (props.loadingMode === "skeleton" && isLoading && pagination?.pageSize) {
return (
<>
{Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
<DataGridTableBodyRowSkeleton key={rowIndex}>
{[
...[
...table.getStartVisibleLeafColumns(),
...table.getCenterVisibleLeafColumns(),
].map((column) => (
<DataGridTableBodyRowSkeletonCell
column={column}
key={column.id}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)),
<DataGridTableFillBodyCell key="__data-grid-fill" />,
...table.getEndVisibleLeafColumns().map((column) => (
<DataGridTableBodyRowSkeletonCell
column={column}
key={column.id}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)),
]}
</DataGridTableBodyRowSkeleton>
))}
</>
)
}
if (!table.getRowModel().rows.length) return <DataGridTableEmpty />
return (
<SortableContext items={dataIds} strategy={sortingStrategy}>
{table.getRowModel().rows.map((row: Row<DataGridFeatures, TData>) => {
return (
<DataGridTableDndRow
row={row}
renderRowDecoration={renderRowDecoration}
dropIndicator={dropIndicator}
key={row.id}
/>
)
})}
</SortableContext>
)
}
/**
* Memoized body rows: skip re-renders during active column resize.
* Column widths update via CSS variables on the <table> element,
* so the browser handles width changes without React re-renders.
*/
const MemoizedDataGridTableDndRowsBody = memo(
DataGridTableDndRowsBody,
(_prev, next) => !!next.table.state.columnResizing?.isResizingColumn
) as typeof DataGridTableDndRowsBody
function DataGridTableDndRows<TData extends object>({
handleDragEnd,
dataIds,
footerContent,
collisionDetection = closestCenter,
modifiers,
sortingStrategy = holdRowsInPlaceStrategy,
renderRowDecoration,
dropIndicator = true,
onDragStart,
onDragMove,
onDragOver,
onDragCancel,
accessibility,
}: {
handleDragEnd: (event: DragEndEvent) => void
dataIds: UniqueIdentifier[]
footerContent?: ReactNode
/** Overrides the default `closestCenter` strategy. */
collisionDetection?: CollisionDetection
/**
* Replaces the default axis restriction, e.g. drop `restrictToVerticalAxis`
* to allow the horizontal gesture that tree re-parenting relies on. The
* table container clamp is always applied after these, so a dragged row
* cannot leave the grid.
*/
modifiers?: Modifier[]
/**
* Replaces the default `verticalListSortingStrategy`. Return null from a
* strategy to leave every row exactly where it is. A tree needs that: its drop
* is either INTO the hovered row or BETWEEN two rows, and which one it is
* flips as the pointer crosses a single row, so a gap that opens for one and
* shuts for the other flickers the whole surface. Such a caller draws its own
* insertion line instead, and pairs this with a modifier that holds the
* carried row still, since a gap nothing moves into is just a hole.
*/
sortingStrategy?: SortingStrategy
/** Per-row slot for drop indicators and depth guides. */
renderRowDecoration?: DataGridTableDndRowDecoration<TData>
/**
* Draws a line on the seam the carried row would land on. On by default;
* pass `false` when `renderRowDecoration` paints its own insertion affordance
* and the two would compete.
*/
dropIndicator?: boolean
onDragStart?: (event: DragStartEvent) => void
onDragMove?: (event: DragMoveEvent) => void
onDragOver?: (event: DragOverEvent) => void
onDragCancel?: (event: DragCancelEvent) => void
/**
* Forwarded to dnd-kit's `DndContext`: its screen-reader instructions and
* live-region announcements, which default to dnd-kit's English strings.
*/
accessibility?: DndContextProps["accessibility"]
}) {
const { table, props } = useDataGrid<TData>()
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
const tableContainerRef = useRef<HTMLDivElement>(null)
const [isDraggingRow, setIsDraggingRow] = useState(false)
// The overlay is portalled to the document body. dnd-kit renders DragOverlay
// in place, and it positions with `position: fixed` against viewport
// coordinates - so any ancestor that establishes a containing block for fixed
// descendants silently re-anchors it. `content-visibility`, `contain`,
// `transform`, `filter` and `will-change` all do that, and the first two are
// exactly what a card grid uses to defer off-screen work. The clone then
// lands offset by that ancestor's own top/left, and the container clamp
// below mis-clamps too, because its rects are measured in viewport space.
//
// Resolved in an effect rather than read at render so the server and the
// first client render agree. A drag cannot start before hydration, so the
// overlay being absent for one frame costs nothing.
const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null)
useEffect(() => {
setPortalTarget(document.body)
}, [])
// The row being carried, plus the column widths measured off the header the
// moment the drag starts. The clone lives outside the table, so it has no
// columns of its own and has to be told what they are.
const [carried, setCarried] = useState<{
id: UniqueIdentifier
width: number
height: number
columns: number[]
padding: { start: string; end: string }[]
} | null>(null)
const pickUpRow = useCallback((id: UniqueIdentifier) => {
const container = tableContainerRef.current
const head = container?.querySelector("thead tr")
if (!container || !head) {
setCarried(null)
return
}
// The clone has to be exactly as tall as the row it was lifted from.
// A fixed height reads as the grid growing under the pointer the moment
// you pick a row up, and it is wrong in both directions: rows whose
// content wraps are taller than any constant, and dense rows are shorter.
const source = Array.from(
container.querySelectorAll<HTMLElement>("tbody tr[data-row-id]")
).find((candidate) => candidate.dataset.rowId === String(id))
const height = source?.getBoundingClientRect().height ?? 0
// The fill cell is a spacer that soaks up the surplus a column resize
// leaves behind, and the clone renders data cells only. Measuring it in
// would make the clone's table wider than the cells it actually holds,
// and `table-fixed` hands that orphaned width back out across every column
// -- the carried row comes out visibly wider than the row it was lifted
// from. So the width is the sum of what we render, never the header's own.
// Read off the source row, one cell per column: a head cell can span
// several (a group, a merged or a meta.headerColSpan cell).
const columns = Array.from(source?.children ?? head.children)
.filter(
(cell) =>
cell.getAttribute("data-slot") !== "data-grid-table-fill-body-cell" &&
cell.getAttribute("data-slot") !== "data-grid-table-fill-head-cell"
)
.map((cell) => cell.getBoundingClientRect().width)
// Inline padding is read off the source cells for the same reason. A fixed
// px-3 ignored edgeCell and cellClassName, so a 44px grip column holding a
// 28px handle overflowed by 8px and truncate painted an ellipsis beside it.
const padding = Array.from(source?.children ?? [])
.filter(
(cell) =>
cell.getAttribute("data-slot") !== "data-grid-table-fill-body-cell"
)
.map((cell) => {
const style = getComputedStyle(cell)
return { start: style.paddingInlineStart, end: style.paddingInlineEnd }
})
setCarried({
id,
width: columns.reduce((total, width) => total + width, 0),
height,
columns,
padding,
})
}, [])
const carriedRow = carried
? table
.getRowModel()
.rows.find((row: Row<DataGridFeatures, TData>) => row.id === carried.id)
: undefined
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
// Keyboard reordering moves one sortable position per keypress instead
// of the sensor's raw 25px default.
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
useEffect(() => {
if (!isDraggingRow) return
const { body, documentElement } = document
const previousBodyCursor = body.style.cursor
const previousDocumentCursor = documentElement.style.cursor
body.style.cursor = "grabbing"
documentElement.style.cursor = "grabbing"
return () => {
body.style.cursor = previousBodyCursor
documentElement.style.cursor = previousDocumentCursor
}
}, [isDraggingRow])
const resolvedModifiers = useMemo(() => {
const restrictToTableContainer: Modifier = ({
transform,
draggingNodeRect,
}) => {
if (!tableContainerRef.current || !draggingNodeRect) {
return transform
}
const containerRect = tableContainerRef.current.getBoundingClientRect()
const { x, y } = transform
const minX = containerRect.left - draggingNodeRect.left
const maxX = containerRect.right - draggingNodeRect.right
const minY = containerRect.top - draggingNodeRect.top
const maxY = containerRect.bottom - draggingNodeRect.bottom
return {
...transform,
// The horizontal rail only engages while the default axis restriction
// is in force. A row is exactly as wide as the viewport, so minX and
// maxX both collapse to 0 and clamping x erases it entirely: harmless
// under restrictToVerticalAxis, which zeroes x anyway, but fatal for a
// caller that replaced the restriction precisely to READ x, as a tree
// does to resolve drop depth. Vertical is railed either way, which is
// what actually keeps a dragged row inside the grid.
x: modifiers ? x : Math.max(minX, Math.min(maxX, x)),
y: Math.max(minY, Math.min(maxY, y)),
}
}
// The container clamp is a safety rail rather than a policy, so it stays
// applied even when the caller replaces the axis restriction.
return [
...(modifiers ?? [restrictToVerticalAxis]),
restrictToTableContainer,
]
}, [modifiers])
return (
<DndContext
id={useId()}
accessibility={accessibility}
collisionDetection={collisionDetection}
modifiers={resolvedModifiers}
onDragCancel={(event) => {
setIsDraggingRow(false)
setCarried(null)
onDragCancel?.(event)
}}
onDragEnd={(event) => {
setIsDraggingRow(false)
setCarried(null)
handleDragEnd(event)
}}
onDragMove={onDragMove}
onDragOver={onDragOver}
onDragStart={(event) => {
setIsDraggingRow(true)
pickUpRow(event.active.id)
onDragStart?.(event)
}}
sensors={sensors}
>
<DataGridTableViewport
viewportRef={tableContainerRef}
className={
isDraggingRow
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
: "relative"
}
>
<DataGridTableBase>
<DataGridTableHead>
{mergedHeaderGroups.map((headerGroup, index) => {
const renderHeader = (
header: Header<DataGridFeatures, TData, unknown>
) => {
const coveredHeaders = headerGroup.coveredHeaders.get(header.id)
const resizeHeader = getDataGridHeadCellResizeHeader(
header,
coveredHeaders
)
return (
<DataGridTableHeadRowCell
header={header}
coveredHeaders={coveredHeaders}
key={header.id}
>
{renderDataGridTableHeadCellContent(header)}
{props.tableLayout?.columnsResizable &&
resizeHeader.column.getCanResize() && (
<DataGridTableHeadRowCellResize header={resizeHeader} />
)}
</DataGridTableHeadRowCell>
)
}
return (
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
{/* Every row follows DataGridTableBase's colgroup, which
puts the fill col between the center and end-pinned
groups; a fill cell appended last hands its width to
the end-pinned column. One keyed list, not three, so
a column pinned into or out of the end group moves
instead of remounting, which would drop keyboard
focus. The fill spans every head row, so only the
first renders it. */}
{[
...[
...headerGroup.startHeaders,
...headerGroup.centerHeaders,
].map(renderHeader),
...(index === 0
? [
<DataGridTableFillHeadCell
key="__data-grid-fill"
rowSpan={mergedHeaderGroups.length}
/>,
]
: []),
...headerGroup.endHeaders.map(renderHeader),
]}
</DataGridTableHeadRow>
)
})}
</DataGridTableHead>
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
<MemoizedDataGridTableDndRowsBody
table={table}
dataIds={dataIds}
renderRowDecoration={renderRowDecoration}
dropIndicator={dropIndicator}
sortingStrategy={sortingStrategy}
/>
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
{/* The row you are actually holding. It is a real clone rendered outside
the table, which is the only way a dragged row can follow the pointer
without disturbing the grid: it adds no cell, so it cannot alter the
column widths, and it floats above the rows rather than through them.
Its presence also tells dnd-kit to stop translating the source row, so
the row left behind simply dims in place.
Portalled to the body so the fixed positioning resolves against the
viewport wherever the grid is mounted. React context crosses a portal,
so DndContext still reaches it. */}
{portalTarget
? createPortal(
<DragOverlay dropAnimation={null}>
{carried && carriedRow ? (
<table
aria-hidden="true"
style={{ width: carried.width, tableLayout: "fixed" }}
className="bg-background border-border pointer-events-none cursor-grabbing rounded-md border shadow-lg"
>
<tbody>
{/* Padding rides on the inner element, not the cell. A `td` can
never render narrower than its own horizontal padding, so a
column resized below that would silently widen here and the
clone would stop matching the row it came from. Height comes
from the measured source row for the same reason the widths
do: the clone has no row of its own to inherit it from. */}
<tr
style={{ height: carried.height || undefined }}
className="[&>td]:p-0 [&>td]:align-middle"
>
{carriedRow
.getVisibleCells()
.map(
(
cell: Cell<DataGridFeatures, TData, unknown>,
index: number
) => (
<td
key={cell.id}
// Falls back to the column's own size so an unforeseen
// header/cell count mismatch degrades to a real width
// rather than to `auto`.
style={{
width:
carried.columns[index] ??
cell.column.getSize(),
}}
>
<div
className={cn(
"truncate",
!carried.padding[index] && "px-3"
)}
style={{
paddingInlineStart:
carried.padding[index]?.start,
paddingInlineEnd: carried.padding[index]?.end,
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</div>
</td>
)
)}
</tr>
</tbody>
</table>
) : null}
</DragOverlay>,
portalTarget
)
: null}
</DndContext>
)
}
export { DataGridTableDndRowHandle, DataGridTableDndRows }
export type { DataGridTableDndRowData, DataGridTableDndRowDecoration }
@@ -0,0 +1,551 @@
"use no memo"
import {
Fragment,
memo,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react"
import type { CSSProperties, ReactNode } from "react"
import {
getDataGridHeaderSpans,
useDataGrid,
} from "@/components/reui/data-grid/data-grid"
import type {
DataGridFeatures,
DataGridTableInstance,
} from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableBodyRow,
DataGridTableBodyRowCell,
DataGridTableBodyRowExpandded,
DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty,
DataGridTableFillBodyCell,
DataGridTableFillHeadCell,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRowSpacer,
DataGridTableViewport,
getDataGridHeadCellResizeHeader,
getDataGridHeadCellSizeVar,
getDataGridTableMergedHeaderGroups,
renderDataGridTableHeadCellContent,
} from "@/components/reui/data-grid/data-grid-table"
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DndContextProps,
type DragEndEvent,
type KeyboardSensorProps,
type Modifier,
} from "@dnd-kit/core"
import {
horizontalListSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { flexRender } from "@tanstack/react-table"
import type { Cell, Header, Row } from "@tanstack/react-table"
import { Button } from "@authportal/ui/components/button"
import { GripVerticalIcon } from "lucide-react"
function DataGridTableDndHeader<TData extends object>({
header,
coveredHeaders = [],
}: {
header: Header<DataGridFeatures, TData, unknown>
/** Headers this cell spans through meta.headerColSpan. */
coveredHeaders?: Header<DataGridFeatures, TData, unknown>[]
}) {
const { i18n, props } = useDataGrid()
const { column } = header
// Pinning decides where a pinned column renders whatever columnOrder says,
// so dragging one could only rewrite hidden order. It gets no grip and is
// neither a drag source nor a drop target. Neither is a group cell: its
// id is not in columnOrder, which holds leaves only.
const isPinned = !!column.getIsPinned()
const isGroup = column.columns.length > 0
// A span is no drag source: a drop moves one leaf, never its covered ones.
const isSpan = coveredHeaders.length > 0
const canOrder =
(column.columnDef as { enableColumnOrdering?: boolean })
.enableColumnOrdering !== false &&
!isPinned &&
!isGroup &&
!isSpan
const {
attributes,
isDragging,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({
id: header.column.id,
disabled: isPinned || isGroup || isSpan,
})
const resizeHeader = getDataGridHeadCellResizeHeader(header, coveredHeaders)
// This style spreads after getPinningStyles, so a sticky pinned cell must
// not take the drag keys: relative and z-index 0 would unstick it and sink
// it under the centre cells. Omitted, not undefined, which would still win.
const isSticky =
!!props.tableLayout?.columnsPinnable && column.getCanPin() && isPinned
const style: CSSProperties = {
opacity: isDragging ? 0.8 : 1,
...(!isSticky && {
position: "relative",
transform: CSS.Translate.toString(transform),
zIndex: isDragging ? 1 : 0,
}),
transition,
cursor: isDragging ? "grabbing" : undefined,
whiteSpace: "nowrap",
width: props.tableLayout?.columnsResizable
? getDataGridHeadCellSizeVar(header, coveredHeaders)
: coveredHeaders.reduce(
(sum, covered) => sum + covered.column.getSize(),
header.column.getSize()
),
}
return (
<DataGridTableHeadRowCell
header={header}
coveredHeaders={coveredHeaders}
dndStyle={style}
dndRef={setNodeRef}
>
<div className="flex items-center justify-start gap-0.5">
{canOrder && (
<Button
size="icon-sm"
variant="ghost"
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
{...attributes}
{...listeners}
aria-label={i18n.labels.dragToReorder}
>
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
</Button>
)}
<div className="grow">{renderDataGridTableHeadCellContent(header)}</div>
{props.tableLayout?.columnsResizable &&
resizeHeader.column.getCanResize() && (
<DataGridTableHeadRowCellResize header={resizeHeader} />
)}
</div>
</DataGridTableHeadRowCell>
)
}
function DataGridTableDndCell<TData extends object>({
cell,
}: {
cell: Cell<DataGridFeatures, TData, unknown>
}) {
const { props } = useDataGrid()
const { isDragging, setNodeRef, transform, transition } = useSortable({
id: cell.column.id,
disabled: !!cell.column.getIsPinned(),
})
// Same rule as the header: a sticky pinned cell keeps its pinning styles.
const isSticky =
!!props.tableLayout?.columnsPinnable &&
cell.column.getCanPin() &&
!!cell.column.getIsPinned()
const style: CSSProperties = {
opacity: isDragging ? 0.8 : 1,
...(!isSticky && {
position: "relative",
transform: CSS.Translate.toString(transform),
zIndex: isDragging ? 1 : 0,
}),
transition,
cursor: isDragging ? "grabbing" : undefined,
width: props.tableLayout?.columnsResizable
? `calc(var(--col-${cell.column.id}-size) * 1px)`
: cell.column.getSize(),
}
return (
<DataGridTableBodyRowCell cell={cell} dndStyle={style} dndRef={setNodeRef}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataGridTableBodyRowCell>
)
}
function DataGridTableDndBodyRows<TData extends object>({
table,
}: {
table: DataGridTableInstance<TData>
}) {
const { isLoading, props } = useDataGrid()
const pagination = table.state.pagination
if (props.loadingMode === "skeleton" && isLoading && pagination?.pageSize) {
return (
<>
{Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
<DataGridTableBodyRowSkeleton key={rowIndex}>
{[
...[
...table.getStartVisibleLeafColumns(),
...table.getCenterVisibleLeafColumns(),
].map((column) => (
<DataGridTableBodyRowSkeletonCell
column={column}
key={column.id}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)),
<DataGridTableFillBodyCell key="__data-grid-fill" />,
...table.getEndVisibleLeafColumns().map((column) => (
<DataGridTableBodyRowSkeletonCell
column={column}
key={column.id}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)),
]}
</DataGridTableBodyRowSkeleton>
))}
</>
)
}
if (!table.getRowModel().rows.length) return <DataGridTableEmpty />
return (
<>
{table.getRowModel().rows.map((row: Row<DataGridFeatures, TData>) => {
return (
<Fragment key={row.id}>
<DataGridTableBodyRow row={row}>
<SortableContext
items={table.state.columnOrder}
strategy={horizontalListSortingStrategy}
>
{/* One keyed list with the fill in the middle, as in the
head row below. */}
{[
...[
...row.getStartVisibleCells(),
...row.getCenterVisibleCells(),
].map((cell: Cell<DataGridFeatures, TData, unknown>) => (
<DataGridTableDndCell cell={cell} key={cell.id} />
)),
<DataGridTableFillBodyCell key="__data-grid-fill" />,
...row
.getEndVisibleCells()
.map((cell: Cell<DataGridFeatures, TData, unknown>) => (
<DataGridTableDndCell cell={cell} key={cell.id} />
)),
]}
</SortableContext>
</DataGridTableBodyRow>
{row.getIsExpanded() && <DataGridTableBodyRowExpandded row={row} />}
</Fragment>
)
})}
</>
)
}
/**
* Memoized body rows: skip re-renders during active column resize.
* Column widths update via CSS variables on the <table> element,
* so the browser handles width changes without React re-renders.
*/
const MemoizedDataGridTableDndBodyRows = memo(
DataGridTableDndBodyRows,
(_prev, next) => !!next.table.state.columnResizing?.isResizingColumn
) as typeof DataGridTableDndBodyRows
// dnd-kit's KeyboardSensor scrolls instead of moving when the target sits
// past the middle of a scroller, and assumes scrollLeft runs 0 to max. RTL
// runs -max to 0, so from the start edge ArrowRight became a scrollTo that
// clamps to 0 and swallowed the keypress. The sensor gets an LTR-shaped view
// of RTL scrollers, scrolled instantly: a smooth scroll drops a key pressed
// before it settles. The document scroller and the coordinate getter's copy
// stay real elements: both are matched by identity inside dnd-kit.
function toLtrScrollView(element: Element): Element {
if (
element === element.ownerDocument.scrollingElement ||
getComputedStyle(element).direction !== "rtl"
) {
return element
}
const offset = () => element.scrollWidth - element.clientWidth
return new Proxy(element, {
get(target, key) {
if (key === "scrollLeft") return target.scrollLeft + offset()
if (key === "scrollTo") {
return (options: ScrollToOptions) =>
target.scrollTo(
options.left === undefined
? options
: {
...options,
left: options.left - offset(),
behavior: "instant",
}
)
}
const value = Reflect.get(target, key)
return typeof value === "function" ? value.bind(target) : value
},
})
}
/** A drop on a span's leaves targets its far edge, after its last leaf moving
* forward and before its owner moving back, so no move lands inside it. */
function getDataGridDropBesideSpans<TData extends object>(
table: DataGridTableInstance<TData>,
event: DragEndEvent
): DragEndEvent {
const { active, over } = event
if (!over || active.id === over.id) return event
const rendered = [
...table.getStartVisibleLeafColumns(),
...table.getCenterVisibleLeafColumns(),
...table.getEndVisibleLeafColumns(),
].map((column) => column.id)
const from = rendered.indexOf(String(active.id))
const to = rendered.indexOf(String(over.id))
if (from === -1 || to === -1) return event
for (const [ownerId, covered] of getDataGridHeaderSpans(table)) {
const leaves = [ownerId, ...covered.map((column) => column.id)]
if (!leaves.includes(String(over.id))) continue
if (leaves.includes(String(active.id))) return event
const edge = from < to ? leaves[leaves.length - 1]! : leaves[0]!
return edge === String(over.id)
? event
: { ...event, over: { ...over, id: edge } }
}
return event
}
class DataGridKeyboardSensor extends KeyboardSensor {
constructor(props: KeyboardSensorProps) {
const { context, options } = props
const getCoordinates = options.coordinateGetter
super({
...props,
context: {
get current() {
const current = context.current
return {
...current,
scrollableAncestors:
current.scrollableAncestors.map(toLtrScrollView),
}
},
},
options: getCoordinates
? {
...options,
coordinateGetter: (event, args) =>
getCoordinates(event, { ...args, context: context.current }),
}
: options,
})
}
}
function DataGridTableDnd<TData extends object>({
handleDragEnd,
footerContent,
accessibility,
}: {
handleDragEnd: (event: DragEndEvent) => void
footerContent?: ReactNode
/**
* Forwarded to dnd-kit's `DndContext`: its screen-reader instructions and
* live-region announcements, which default to dnd-kit's English strings.
*/
accessibility?: DndContextProps["accessibility"]
}) {
const { table, props } = useDataGrid()
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
const containerRef = useRef<HTMLDivElement>(null)
const [isDraggingColumn, setIsDraggingColumn] = useState(false)
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
// Keyboard reordering moves one sortable position per keypress instead
// of the sensor's raw 25px default.
useSensor(DataGridKeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
useEffect(() => {
if (!isDraggingColumn) return
const { body, documentElement } = document
const previousBodyCursor = body.style.cursor
const previousDocumentCursor = documentElement.style.cursor
body.style.cursor = "grabbing"
documentElement.style.cursor = "grabbing"
return () => {
body.style.cursor = previousBodyCursor
documentElement.style.cursor = previousDocumentCursor
}
}, [isDraggingColumn])
// Custom modifier to restrict dragging within table bounds with edge offset
const modifiers = useMemo(() => {
const restrictToTableBounds: Modifier = ({
draggingNodeRect,
transform,
}) => {
if (!draggingNodeRect || !containerRef.current) {
return { ...transform, y: 0 }
}
const containerRect = containerRef.current.getBoundingClientRect()
const edgeOffset = 0
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
const maxX =
containerRect.right -
draggingNodeRect.left -
draggingNodeRect.width +
edgeOffset
return {
...transform,
x: Math.min(Math.max(transform.x, minX), maxX),
y: 0, // Lock vertical movement
}
}
return [restrictToTableBounds]
}, [])
return (
<DndContext
accessibility={accessibility}
collisionDetection={closestCenter}
id={useId()}
modifiers={modifiers}
onDragCancel={() => setIsDraggingColumn(false)}
onDragEnd={(event) => {
setIsDraggingColumn(false)
handleDragEnd(getDataGridDropBesideSpans(table, event))
}}
onDragStart={() => setIsDraggingColumn(true)}
sensors={sensors}
>
<DataGridTableViewport
viewportRef={containerRef}
className={
isDraggingColumn
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
: "relative"
}
>
<DataGridTableBase>
<DataGridTableHead>
{mergedHeaderGroups.map((headerGroup, index) => {
return (
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
<SortableContext
items={table.state.columnOrder}
strategy={horizontalListSortingStrategy}
>
{/* Every row follows DataGridTableBase's colgroup,
which puts the fill col between the center and
end-pinned groups; a fill cell appended last hands
its width to the end-pinned column. One keyed
list, not three, so a column pinned into or out of
the end group moves instead of remounting, which
would drop keyboard focus. The fill spans every
head row, so only the first renders it. */}
{[
...[
...headerGroup.startHeaders,
...headerGroup.centerHeaders,
].map((header) => (
<DataGridTableDndHeader
header={header}
coveredHeaders={headerGroup.coveredHeaders.get(
header.id
)}
key={header.id}
/>
)),
...(index === 0
? [
<DataGridTableFillHeadCell
key="__data-grid-fill"
rowSpan={mergedHeaderGroups.length}
/>,
]
: []),
...headerGroup.endHeaders.map((header) => (
<DataGridTableDndHeader
header={header}
coveredHeaders={headerGroup.coveredHeaders.get(
header.id
)}
key={header.id}
/>
)),
]}
</SortableContext>
</DataGridTableHeadRow>
)
})}
</DataGridTableHead>
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
<MemoizedDataGridTableDndBodyRows table={table} />
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
</DndContext>
)
}
export { DataGridTableDnd }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,303 @@
import * as React from "react"
import {
Cascader,
CascaderEmpty,
CascaderList,
CascaderPanel,
CascaderStatus,
} from "@/components/reui/cascader/cascader"
import { CascaderFooter } from "@/components/reui/cascader/cascader-footer"
import {
CascaderBreadcrumb,
CascaderInput,
CascaderNav,
} from "@/components/reui/cascader/cascader-nav"
import type {
CascaderActionItem,
CascaderLabels,
CascaderNode,
} from "@/components/reui/cascader/cascader-types"
import { CascaderVirtualItems } from "@/components/reui/cascader/cascader-virtual"
import {
filterControlSizes,
filterReadOnlyProps,
useFilterActions,
useFilterState,
} from "@/components/reui/filters/filters-context"
import {
FILTER_FIELD_PICKER_CLASS,
getFilterField,
getFilterFieldCount,
joinFilterPath,
splitFilterPath,
} from "@/components/reui/filters/filters-lib"
import { getDefaultFilterOperator } from "@/components/reui/filters/filters-operators"
import type { FilterField } from "@/components/reui/filters/filters-types"
import { cn } from "@authportal/ui/lib/utils"
import { Button } from "@authportal/ui/components/button"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@authportal/ui/components/popover"
import { ListFilterPlusIcon } from "lucide-react"
/**
* Projects ONLY the field tree: operators or values as nodes would force the
* wizard through a combobox that owns the arrows and `aria-activedescendant`.
*/
function toCascaderNodes<V, O>(
fields: readonly FilterField<V, O>[],
parentPath: string[] = []
): CascaderNode<FilterField<V, O>>[] {
return fields.map((field) => {
const path = [...parentPath, field.id]
const node: CascaderNode<FilterField<V, O>> = {
value: joinFilterPath(path),
label: field.label,
icon: field.icon,
description: field.description,
keywords: field.keywords,
disabled: field.disabled,
data: field,
}
if (field.fields?.length) {
node.children = toCascaderNodes(field.fields, path)
node.count = getFilterFieldCount(field)
}
return node
})
}
export interface FilterFieldPickerProps {
/** The level being browsed. Not the chosen field. */
path: string[]
onPathChange: (path: string[]) => void
query: string
onQueryChange: (query: string) => void
/** A field was chosen, with its starting operator already resolved. */
onSelect: (path: string[], defaultOperator: string | null) => void
/** Viewport height before the list scrolls. */
maxHeight?: number
/** Cascader-only strings; the `FilterLabels` bridge below covers the rest. */
labels?: Partial<CascaderLabels>
/** Pinned footer rows, OUT of the option ring so arrows never land on one. */
actions?: CascaderActionItem[]
}
/**
* Fully controlled, not draft-driven, so ONE picker serves both the create
* popover (driven by the draft reducer) and an advanced row (its own state).
*/
export function FilterFieldPicker<V, O>({
path,
onPathChange,
query,
onQueryChange,
onSelect,
maxHeight = 260,
labels: labelsProp,
actions: actionItems,
}: FilterFieldPickerProps) {
const actions = useFilterActions<V, O>()
const items = React.useMemo(
() => toCascaderNodes(actions.index.roots),
[actions.index]
)
// Bridges `FilterLabels` onto the cascader's own key names. `labelsProp` is
// spread LAST, so a consumer override always wins.
const cascaderLabels = React.useMemo<Partial<CascaderLabels>>(
() => ({
search: actions.labels.searchFields,
back: actions.labels.back,
empty: actions.labels.empty,
pathSeparator: actions.labels.pathSeparator.trim() || "/",
itemCount: actions.labels.itemCount,
branchAffordance: actions.labels.branchAffordance,
rootLevel: actions.labels.fieldsLabel,
panelLabel: actions.labels.fieldsLabel,
resultsAnnouncement: actions.labels.resultsAnnouncement,
actionsLabel: actions.labels.actionsLabel,
...labelsProp,
}),
[actions.labels, labelsProp]
)
return (
<Cascader
inline
/* Pinned open: inline renders no popup and forces `open`, so the
single-select commit's `setOpen(false)` dismisses nothing. That avoids
the `multiple` plus `max` workaround and its `aria-multiselectable`. */
open
onOpenChange={() => {}}
items={items}
/* No `selectable` predicate: LEAVES commit, branches navigate. Honouring
a branch's opt-in made one click mean both drill and commit. */
searchScope="deep"
/* Nothing is ever selected, so the ~24px check gutter is dead space. */
indicator={false}
path={path}
onPathChange={onPathChange}
/* Controlled: the single-select arm of `commit()` never clears the query,
so an uncontrolled one would bleed into the next step. */
inputValue={query}
onInputValueChange={onQueryChange}
value=""
onValueChange={(value) => {
const nextPath = splitFilterPath(value)
const field = getFilterField(actions.index, nextPath)
if (!field) return
onSelect(
nextPath,
getDefaultFilterOperator(field, actions.resolveOperators(field))
)
}}
labels={cascaderLabels}
actions={actionItems}
/* Set here only: `CascaderList` takes its own `maxHeight` and that WINS,
so a second copy on the list is a divergence waiting to happen. */
maxHeight={maxHeight}
>
<CascaderPanel>
<CascaderNav>
<CascaderInput placeholder={actions.labels.searchFields} />
</CascaderNav>
<CascaderBreadcrumb />
<CascaderEmpty />
<CascaderList>
{/* WINDOWED: the picker is where scale lives and nothing here pins.
Below the threshold it renders what `CascaderItems` does. */}
<CascaderVirtualItems />
</CascaderList>
<CascaderFooter />
<CascaderStatus />
</CascaderPanel>
</Cascader>
)
}
function FieldStep<V, O>() {
const actions = useFilterActions<V, O>()
const { draft } = useFilterState<V>()
return (
<FilterFieldPicker<V, O>
path={draft?.cascaderPath ?? []}
onPathChange={(next) =>
actions.dispatchDraft({ type: "setCascaderPath", path: next })
}
query={draft?.query ?? ""}
onQueryChange={(query) =>
actions.dispatchDraft({ type: "setQuery", query })
}
onSelect={(path, defaultOperator) =>
actions.dispatchDraft({ type: "selectField", path, defaultOperator })
}
/>
)
}
export interface FiltersBuilderProps {
/** Replaces the default Add filter button. */
trigger?: React.ReactNode
className?: string
}
/**
* The Add filter popover. ONE panel, the field step: picking a field commits
* the rule and opens the operator menu on the new chip. `open` derives from the
* draft and `openCreate` is gated, so read-only and disabled shut every route.
*/
export function FiltersBuilder<V, O>({
trigger,
className,
}: FiltersBuilderProps) {
const actions = useFilterActions<V, O>()
const sizes = filterControlSizes(actions)
const { draft, ruleCount } = useFilterState<V>()
const open = draft !== null && draft.ruleId === null
// The HANDOFF close, the one that must not FADE: the panel is a 224px card
// dissolving over the very menu the user is now meant to read.
const committing =
draft !== null && draft.ruleId === null && draft.status === "ready"
// LATCHED, because `committing` is true for one render only, before the exit
// it suppresses begins. Adjusted during render; an effect is one frame late.
const [instantExit, setInstantExit] = React.useState(false)
if (committing && !instantExit) setInstantExit(true)
else if (open && !committing && instantExit) setInstantExit(false)
// Icon-only once chips sit beside it. Both states come off ONE size ladder,
// so they share height and radius in every style at every bar size. The
// hardcoded `icon`/`default` pair this replaced IS the `default` rung, so it
// drifted the moment the bar was `sm`.
const compact = ruleCount > 0
// Base UI's own microtask-then-rAF ordering hands focus to the new chip, NOT
// anything this file arranges. Suppressing the close restore with
// `finalFocus` only removes the fallback that keeps focus off the BODY.
return (
<Popover
open={open}
onOpenChange={(next) => {
if (!next) actions.closeDraft()
else actions.openCreate()
}}
>
<PopoverTrigger
/* On the TRIGGER so a consumer's own `trigger` wears the state too. */
disabled={actions.disabled}
{...filterReadOnlyProps(actions)}
render={
trigger ? (
(trigger as React.ReactElement)
) : (
<Button
variant="outline"
size={compact ? sizes.icon : sizes.button}
aria-label={compact ? actions.labels.addFilter : undefined}
/* THE RESET BOUNCE: the button's base class carries
`transition-all`, so flipping the size class eased padding 0 to
`px-6` in sera over 150ms. Naming a transition REPLACES it. */
className="transition-[color,background-color,border-color,box-shadow]"
>
{/* No filter-plus glyph in phosphor or remixicon. */}
<ListFilterPlusIcon
/>
{compact ? null : actions.labels.addFilter}
</Button>
)
}
/>
{/* The default, then the root override, then this `className` last, so
the specific wins. */}
<PopoverContent
align="start"
className={cn(
FILTER_FIELD_PICKER_CLASS,
/* See `instantExit`. Both PROPERTIES (an `exit` animation AND a
transition each paint over the successor) and both twins (Base UI
`data-ending-style`, Radix `data-[state=closed]`); only one can
match in a given build. The string stays identical on both sides
because listing only the Base UI half left the radix twin fading
a 224px card over its successor. */
instantExit &&
cn(
"data-ending-style:animate-none data-ending-style:transition-none",
"data-[state=closed]:animate-none data-[state=closed]:transition-none"
),
actions.fieldPickerClassName,
className
)}
>
<FieldStep<V, O> />
</PopoverContent>
</Popover>
)
}
/** Exported for custom field pickers. */
export { toCascaderNodes }
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,662 @@
"use client"
import * as React from "react"
import type { FilterDraftAction } from "@/components/reui/filters/filters-draft"
import type { FilterPathCollapse } from "@/components/reui/filters/filters-lib"
import type {
FilterCombinator,
FilterDraft,
FilterDraftStep,
FilterEditor,
FilterEditorRegistry,
FilterEmptyStateContext,
FilterField,
FilterIndex,
FilterLabels,
FilterOperator,
FilterOption,
FilterQuery,
FilterRule,
FilterValueDisplayContext,
FilterValueType,
} from "@/components/reui/filters/filters-types"
/** Four publishing channels, not one, so a chip never re-renders at the rate
* of the fastest: `actions` republishes on a schema, labels or config change,
* `state` on every query edit and keystroke, `render` on a `renderValue` /
* `renderChip` identity change, `focus` on every arrow key. The reorder flag
* and the row-state store further down are contexts too, but not channels the
* query is published through. */
/* -------------------------------------------------------------------------- */
/* Actions */
/* -------------------------------------------------------------------------- */
/** Stable for the life of the component, bar a config change. Every mutator
* reads a latest-props ref rather than closing over the state it needs, so a
* keystroke rebuilds no handler and no memoized chip re-renders. The two lock
* flags are published rather than kept only in that ref: a refusal that lives
* in a ref arrives one commit late. The two resolvers are the deliberate
* exception: they are read DURING render, so they memoize on the values they
* read instead of going through the ref. Every mutator is also the MUTATION
* BOUNDARY - `disabled` and `readOnly` are enforced here, not at the call
* sites that draw the buttons, so a route added next month is refused by
* construction rather than by remembering. See `isFilterLocked`. */
export interface FilterActionsContextValue<V = unknown, O = unknown> {
/** Normalized schema. Also on the state context, from the same memo. */
index: FilterIndex<V, O>
labels: FilterLabels
operatorCatalog: Record<FilterValueType, FilterOperator[]>
editors: FilterEditorRegistry
size: "sm" | "default"
/** The bar is off: nothing operable, and the controls leave the tab order. */
disabled: boolean
/** Readable and navigable, but not changeable. See `isFilterLocked`. */
readOnly: boolean
/** Which chrome is drawing, so a chip kebab can hide its convert row. */
variant: "basic" | "advanced"
/** Consumer classes for the menus and the field picker, merged AFTER the
* primitive's own defaults so a consumer `w-*` wins through tailwind-merge
* rather than on source order. On the context: four chromes mount the same
* menu, and a per-chrome prop is set in four places and missed in a fifth. */
menuClassName: string | undefined
fieldPickerClassName: string | undefined
/** Turns on the chip kebab's "Convert to advanced filter" row: present means
* offered. A callback with no boolean beside it, since only the consumer
* can switch chromes. Changes no query, so it skips the mutation boundary,
* which is safe only because it is handed no setter: a consumer's handler
* has no route to the query at all. */
convertToAdvanced: (() => void) | undefined
/** Path shortening, published so BOTH chromes render the same path. */
pathCollapse: FilterPathCollapse
maxPathSegments: number
/** Operators for a field, memoized on the catalog and the schema. */
resolveOperators: (field: FilterField<V, O>) => FilterOperator[]
resolveEditor: (
field: FilterField<V, O>,
operator: FilterOperator | undefined
) => FilterEditor<V, O> | undefined
/** Value-to-label store shared by every `useFilterOptions` under the root. */
resolution: FilterResolutionStore
/** Appends a rule. Defaults to the ROOT group. The parent is a parameter
* rather than always the root because a nested group's add button is the
* keyboard path into that group, and routing it through the root then
* moving it would be two edits and two announcements for one action. */
addRule: (rule: FilterRule<V>, parentId?: string) => void
/** Appends an empty group and returns its id, so the caller can focus it.
* Returns an EMPTY STRING when the bar is disabled or read only, the one
* mutator that has to report its refusal: focusing a group that was never
* created strands the tab stop. `""` rather than `string | null` keeps the
* callers that already test the id honest without widening a published
* signature. */
addGroup: (parentId?: string, combinator?: FilterCombinator) => string
updateRule: (
id: string,
updates: Partial<Omit<FilterRule<V>, "id" | "type">>
) => void
/** Removes a rule OR a group, and prunes the groups that empties. Node-level
* rather than rule-level because a chip only ever removes a rule, while a
* builder row's trash and a group header's trash are the same button on two
* kinds of node. */
removeNode: (id: string) => void
duplicateNode: (id: string) => void
negateRule: (id: string) => void
/** Reorders within the node's own parent. Out of range moves are no-ops. */
moveNode: (id: string, delta: number) => void
/** Moves a node into another group, at an index. The drag-and-drop half. */
moveNodeTo: (id: string, parentId: string, index: number) => void
/** Copies a node into another group, at an index. The Alt-drag half, apart
* from `duplicateNode` (which copies BESIDE, with no destination): the two
* composed would emit two queries for one gesture, and a controlled
* consumer would persist a tree the user never asked for. */
copyNodeTo: (id: string, parentId: string, index: number) => void
/** Nests one node in a new group. The keyboard half of the same idea. */
wrapNodeInGroup: (id: string, combinator?: FilterCombinator) => void
/** Dissolves a group into its parent. The inverse of `wrapNodeInGroup`. */
unwrapGroup: (groupId: string) => void
setCombinator: (groupId: string, combinator: FilterCombinator) => void
toggleCombinator: (groupId: string) => void
clearQuery: () => void
openCreate: () => void
openAmend: (id: string, step: FilterDraftStep) => void
closeDraft: () => void
dispatchDraft: (action: FilterDraftAction<V>) => void
/** Writes the bar's live region, for a change no visible surface reports: an
* editor's popover is the one place something destructive happens to rows
* the user is not on, with focus, name and text unchanged afterwards. Not a
* query write, so it skips `emit` and asks the lock nothing. */
announce: (message: string) => void
/** Getters for event handlers, so a handler never closes over stale state. */
getQuery: () => FilterQuery<V>
getDraft: () => FilterDraft<V> | null
/** Fresh id from the SSR-safe factory. */
nextId: () => string
}
/* -------------------------------------------------------------------------- */
/* The mutation lock */
/* -------------------------------------------------------------------------- */
/** `disabled` and `readOnly` are NOT two words for one state. `disabled` is
* the native attribute: not operable, out of the tab order. `readOnly` blocks
* MUTATION and preserves NAVIGATION, because a read-only bar exists so a
* keyboard or screen reader user can walk the chips and find out what the
* view is filtered by. Collapsing the two once put the native attribute on
* all thirteen advanced-builder cell controls while the roving tab stop sat
* on a disabled element, so not one row could be reached from the keyboard.
*
* So a mutating control keeps its tab stop and wears `aria-disabled` plus
* `data-readonly` (`filterReadOnlyProps`), this repo's convention for
* "present, focusable, not operable". `aria-readonly` is never used, being
* disallowed on the button, group and toolbar roles, so the BAR says it in
* prose through `labels.readOnly`. The refusal itself is enforced once, where
* all fourteen query writes pass through `emit` in `filters.tsx`. */
export function isFilterLocked(state: {
disabled: boolean
readOnly: boolean
}): boolean {
return state.disabled || state.readOnly
}
/** What a MUTATING control wears while the bar is read only. `null` when the
* bar is disabled, because the native attribute already says it. A
* conditional spread, not explicit `undefined`s: `aria-disabled="false"` on
* an enabled control is noise, and `data-readonly` is a presence hook. */
export function filterReadOnlyProps(state: {
disabled: boolean
readOnly: boolean
}) {
if (state.disabled || !state.readOnly) return null
return { "aria-disabled": true, "data-readonly": "" } as const
}
/* -------------------------------------------------------------------------- */
/* The size ladder */
/* -------------------------------------------------------------------------- */
/** The two shadcn button sizes one filters size resolves to. Two rungs because
* shadcn ships a separate ladder for labelled and for icon-only buttons, and
* pairing them keeps a row's kebab as tall as the cell beside it. */
export interface FilterControlSizes {
/** Labelled buttons: the row cells, both triggers, the panel footer. */
button: "sm" | "default"
/** Icon-only buttons, and also the CHIP's height: a chip is an
* `items-stretch` `ButtonGroup` and no style gives its text segment a
* height, so the segments stretch to the kebab, the one child that has one.
* Sizing the kebab sizes the pill. */
icon: "icon-sm" | "icon"
}
/** ONE ladder, keyed off `size`, for every control the chrome renders, because
* the alternative already happened: five advanced-builder cells took
* `actions.size` while seven sites hardcoded `icon-sm` and three `sm`, giving
* one row three heights. Nothing here is a pixel - each value is a shadcn
* size NAME that `Button` resolves per style, since the control-height ladder
* is per style (nova 7/8, sera 9/10, mira 6/7, and so on), and the glyph size
* rides the same name, so pinning an icon size in here would fight the style
* rather than match it. Two rungs only: `lg` would make the bar taller than
* the style's own default control height (want taller, pick a taller STYLE),
* and `icon-xs` is a 20-24px square in most styles, too small for a chip's
* own label to clear. */
const FILTER_CONTROL_SIZES: Record<"sm" | "default", FilterControlSizes> = {
sm: { button: "sm", icon: "icon-sm" },
default: { button: "default", icon: "icon" },
}
/** The pair for a bar's size, off anything with a `size`, so a
* consumer-composed chrome uses the same ladder as the shipped one. The
* fallback is for JavaScript callers: a `"lg"` TypeScript would have rejected
* must still draw buttons rather than throw on `.button`. */
export function filterControlSizes(state: {
size: "sm" | "default"
}): FilterControlSizes {
return FILTER_CONTROL_SIZES[state.size] ?? FILTER_CONTROL_SIZES.default
}
const FilterActionsContext =
React.createContext<FilterActionsContextValue | null>(null)
export function useFilterActions<
V = unknown,
O = unknown,
>(): FilterActionsContextValue<V, O> {
const context = React.useContext(FilterActionsContext)
if (!context) {
throw new Error("useFilterActions must be used inside <Filters>")
}
return context as unknown as FilterActionsContextValue<V, O>
}
/* -------------------------------------------------------------------------- */
/* State */
/* -------------------------------------------------------------------------- */
/** Volatile by construction: typing one character into a value editor
* republishes it. Subscribe from the bar and the panel, never from a chip. */
export interface FilterStateContextValue<V = unknown> {
query: FilterQuery<V>
draft: FilterDraft<V> | null
ruleCount: number
/** Live region text. Empty except immediately after an announced change. */
announcement: string
/** How many announcements have been made, so a REPEATED one is still heard:
* `aria-live` reports a DOM mutation and React writes nothing when the
* string is unchanged, so the chrome keys the region's contents on this. A
* counter rather than a timestamp: it is compared for identity, never read
* as a value, and is stable across a rerender that a clock is not. */
announcementSeq: number
}
const FilterStateContext = React.createContext<FilterStateContextValue | null>(
null
)
export function useFilterState<V = unknown>(): FilterStateContextValue<V> {
const context = React.useContext(FilterStateContext)
if (!context) {
throw new Error("useFilterState must be used inside <Filters>")
}
return context as unknown as FilterStateContextValue<V>
}
/* -------------------------------------------------------------------------- */
/* Render */
/* -------------------------------------------------------------------------- */
/** Consumer render overrides, on their own channel. They must be
* always-current closures yet change identity on every parent render when
* written inline, so isolating them re-renders only what calls them. */
export interface FilterRenderContextValue<V = unknown, O = unknown> {
renderValue?: (context: FilterValueDisplayContext<V, O>) => React.ReactNode
renderChip?: (rule: FilterRule<V>) => React.ReactNode
renderEmpty?: (context: FilterEmptyStateContext) => React.ReactNode
}
const FilterRenderContext = React.createContext<FilterRenderContextValue>({})
export function useFilterRender<
V = unknown,
O = unknown,
>(): FilterRenderContextValue<V, O> {
return React.useContext(FilterRenderContext) as FilterRenderContextValue<V, O>
}
/* -------------------------------------------------------------------------- */
/* Focus store */
/* -------------------------------------------------------------------------- */
/** Which chip currently owns the row's single tab stop. */
export interface FilterFocus {
id: string | null
/** Which cell inside that row, for restoring focus after an edit. A chip
* draws only `field`, `operator`, `value` and `menu`; the rest are advanced
* builder cells, where a GROUP header is a row too (its `field` is the
* combinator sentence). One union, because the chromes share the store. */
segment:
| "combinator"
| "field"
| "operator"
| "value"
| "add"
| "menu"
| "ungroup"
| "remove"
| "drag"
| null
/** Open that segment's popover, not merely focus it. Picking a field commits
* the rule straight away and the chip appears with no condition yet, so the
* operator menu has to open ON THE CHIP without a second click. */
autoOpen: boolean
}
/** An external store, deliberately NOT React state. A roving tabindex
* republishes on every arrow key, and `setState` would re-render the whole
* bar to move one outline. The SELECTOR hooks below each narrow to one value,
* so arrowing across forty chips re-renders two components. `useFilterFocus`
* is the exception: it returns the whole snapshot. */
export interface FilterFocusStore {
subscribe: (onStoreChange: () => void) => () => void
getSnapshot: () => FilterFocus
/** No-ops when nothing changed. */
set: (next: FilterFocus) => void
}
const NO_FOCUS: FilterFocus = { id: null, segment: null, autoOpen: false }
export function createFilterFocusStore(): FilterFocusStore {
let snapshot: FilterFocus = NO_FOCUS
const listeners = new Set<() => void>()
return {
subscribe(onStoreChange) {
listeners.add(onStoreChange)
return () => {
listeners.delete(onStoreChange)
}
},
// The SAME object until something actually changes, which is what
// `useSyncExternalStore` requires to avoid an infinite render loop.
getSnapshot() {
return snapshot
},
set(next) {
if (
next.id === snapshot.id &&
next.segment === snapshot.segment &&
next.autoOpen === snapshot.autoOpen
) {
return
}
snapshot = next
for (const listener of listeners) listener()
},
}
}
/** A shared, permanently empty store, so the hook degrades to "nothing
* focused" outside a `Filters`. Nothing ever writes to it: each root creates
* and writes its own. */
const FALLBACK_FOCUS_STORE = createFilterFocusStore()
const FilterFocusContext =
React.createContext<FilterFocusStore>(FALLBACK_FOCUS_STORE)
/* -------------------------------------------------------------------------- */
/* Reordering */
/* -------------------------------------------------------------------------- */
/** Whether rows may be reordered at all, published once for the subtree. Two
* files have to agree about it: the builder gates the grip and Alt+Arrow,
* while the row and group menus commit the same mutator by a third route, so
* gating only the builder left it off for a pointer and on from a menu. */
const FilterReorderContext = React.createContext(false)
export const FilterReorderProvider = FilterReorderContext.Provider
export function useFilterReorderable(): boolean {
return React.useContext(FilterReorderContext)
}
/* -------------------------------------------------------------------------- */
/* Touched */
/* -------------------------------------------------------------------------- */
/** Which rules the user has actually edited the VALUE of, which decides WHEN
* an error may appear: "Add filter" mints a row with no value, so flagging
* every invalid row turns the builder red before the user did anything wrong.
* An issue is still COLLECTED for every rule - the footer count,
* `onQueryChange` and tree validity do not change - and only DRAWN once the
* user has committed a value. An external store for the focus store's reason,
* keyed by rule id and never pruned: a stale id costs one Set entry, and
* reconciling against the query on every commit is hot-path work. */
export interface FilterRowStateStore {
subscribe: (listener: () => void) => () => void
/** Bumped on every write. What a memo depends on, since the Sets are mutable. */
version: () => number
/** Whether this rule has had a value committed to it by the user. */
has: (id: string) => boolean
mark: (id: string) => void
/** Starts this rule over. Changing a rule's ATTRIBUTE resets its operator
* and its value, so it has to reset this too, or the row stays warned about
* a value that no longer exists on a field the user navigated away from. */
unmark: (id: string) => void
/** Whether this rule is still being CREATED: minted by Add filter and not
* yet given an attribute. The row enters the query with a guessed field to
* keep the tree valid, so while pending the builder draws only that cell. */
isPending: (id: string) => boolean
markPending: (id: string) => void
/** The attribute was chosen. The rest of the row appears. */
resolvePending: (id: string) => void
/** Forgets everything. The bar's own Clear all, and a whole-tree replace. */
reset: () => void
}
export function createFilterRowStateStore(): FilterRowStateStore {
const touched = new Set<string>()
const pending = new Set<string>()
const listeners = new Set<() => void>()
let version = 0
const notify = () => {
version += 1
for (const listener of listeners) listener()
}
return {
subscribe: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
version: () => version,
has: (id) => touched.has(id),
mark: (id) => {
if (touched.has(id)) return
touched.add(id)
notify()
},
unmark: (id) => {
if (!touched.delete(id)) return
notify()
},
isPending: (id) => pending.has(id),
markPending: (id) => {
if (pending.has(id)) return
pending.add(id)
notify()
},
resolvePending: (id) => {
if (!pending.delete(id)) return
notify()
},
reset: () => {
if (touched.size === 0 && pending.size === 0) return
touched.clear()
pending.clear()
notify()
},
}
}
const FALLBACK_ROW_STATE_STORE = createFilterRowStateStore()
const FilterRowStateContext = React.createContext<FilterRowStateStore>(
FALLBACK_ROW_STATE_STORE
)
export const FilterRowStateProvider = FilterRowStateContext.Provider
/** The store itself, for handlers that write without subscribing. */
export function useFilterRowStateStore(): FilterRowStateStore {
return React.useContext(FilterRowStateContext)
}
/** Whether THIS rule is still waiting for its attribute. */
export function useFilterRowPending(id: string): boolean {
const store = React.useContext(FilterRowStateContext)
return React.useSyncExternalStore(
store.subscribe,
() => store.isPending(id),
() => false
)
}
/** Whether THIS rule may show an error yet. */
export function useFilterTouched(id: string): boolean {
const store = React.useContext(FilterRowStateContext)
return React.useSyncExternalStore(
store.subscribe,
() => store.has(id),
() => false
)
}
/** The whole focus snapshot, so the caller re-renders on EVERY move anywhere
* in the row. A chip wants `useFilterChipFocused` or `useFilterSegmentFocus`
* below instead. */
export function useFilterFocus(): FilterFocus {
const store = React.useContext(FilterFocusContext)
return React.useSyncExternalStore(
store.subscribe,
store.getSnapshot,
store.getSnapshot
)
}
/** The store itself, for event handlers that write without subscribing. */
export function useFilterFocusStore(): FilterFocusStore {
return React.useContext(FilterFocusContext)
}
/** The segment of THIS chip that should open itself, or null. */
export function useFilterChipAutoOpen(
id: string
): FilterFocus["segment"] | null {
const store = React.useContext(FilterFocusContext)
return React.useSyncExternalStore(
store.subscribe,
() => {
const snapshot = store.getSnapshot()
return snapshot.autoOpen && snapshot.id === id ? snapshot.segment : null
},
() => null
)
}
/** Whether the row holds no focus, so the first chip keeps the tab stop. */
export function useFilterFocusEmpty(): boolean {
const store = React.useContext(FilterFocusContext)
return React.useSyncExternalStore(
store.subscribe,
() => store.getSnapshot().id === null,
() => true
)
}
/** Whether THIS chip owns the row's tab stop. */
export function useFilterChipFocused(id: string): boolean {
const store = React.useContext(FilterFocusContext)
return React.useSyncExternalStore(
store.subscribe,
() => store.getSnapshot().id === id,
() => false
)
}
/** Which segment of THIS rule owns the tab stop, or null when another rule
* does. A chip needs one boolean, but an advanced row is a grid ROW whose tab
* stop is a (row, column) pair, and the column has to come from this store
* too or the chromes disagree about where focus is after an edit. */
export function useFilterSegmentFocus(id: string): FilterFocus["segment"] {
const store = React.useContext(FilterFocusContext)
return React.useSyncExternalStore(
store.subscribe,
() => {
const snapshot = store.getSnapshot()
return snapshot.id === id ? snapshot.segment : null
},
() => null
)
}
/* -------------------------------------------------------------------------- */
/* Resolution store */
/* -------------------------------------------------------------------------- */
/** The instance-wide value-to-label store. `useFilterOptions` caches per HOOK
* INSTANCE, and a chip's display and its editor are two instances, so a
* `loadOptions`-only field rendered its raw id the moment the menu closed.
* Every instance under one root writes here, and `resolveValues` results land
* here too. External rather than React state because labels arrive from
* effects and promises at their own pace, and the version bumps only when a
* NEW label lands, so a subscriber re-renders once per page of results. */
export interface FilterResolutionStore {
subscribe: (onStoreChange: () => void) => () => void
getVersion: () => number
/** The option behind a stored value, or undefined while unresolved. */
get: (fieldKey: string, value: string) => FilterOption | undefined
/** Records options under a field key. New values bump the version. */
set: (fieldKey: string, options: readonly FilterOption[]) => void
/** Marks values as resolving and returns the subset nobody has claimed yet,
* so two chips holding the same id issue ONE request. Claims are permanent
* for values a fulfilled resolve did not return, which stops an id the
* server does not know from being re-asked forever. */
claim: (fieldKey: string, values: readonly string[]) => string[]
/** Releases claims after a FAILED resolve, so a later mount may retry. */
release: (fieldKey: string, values: readonly string[]) => void
}
export function createFilterResolutionStore(): FilterResolutionStore {
const resolved = new Map<string, Map<string, FilterOption>>()
const claimed = new Map<string, Set<string>>()
const listeners = new Set<() => void>()
let version = 0
const bucket = (fieldKey: string) => {
let map = resolved.get(fieldKey)
if (!map) {
map = new Map()
resolved.set(fieldKey, map)
}
return map
}
return {
subscribe(onStoreChange) {
listeners.add(onStoreChange)
return () => {
listeners.delete(onStoreChange)
}
},
getVersion() {
return version
},
get(fieldKey, value) {
return resolved.get(fieldKey)?.get(value)
},
set(fieldKey, options) {
const map = bucket(fieldKey)
let landed = false
for (const option of options) {
if (!map.has(option.value)) landed = true
map.set(option.value, option)
}
if (!landed) return
version += 1
for (const listener of listeners) listener()
},
claim(fieldKey, values) {
let set = claimed.get(fieldKey)
if (!set) {
set = new Set()
claimed.set(fieldKey, set)
}
const map = resolved.get(fieldKey)
const fresh: string[] = []
for (const value of values) {
if (set.has(value) || map?.has(value)) continue
set.add(value)
fresh.push(value)
}
return fresh
},
release(fieldKey, values) {
const set = claimed.get(fieldKey)
if (!set) return
for (const value of values) set.delete(value)
},
}
}
export {
FilterActionsContext,
FilterFocusContext,
FilterRenderContext,
FilterStateContext,
}
@@ -0,0 +1,213 @@
import {
addDays,
addMonths,
addWeeks,
addYears,
format,
isValid,
parse,
startOfDay,
} from "date-fns"
/**
* A date filter value. NOT a `Date`: a filter gets persisted (saved views,
* shared URLs), and a resolved `Date` would freeze "created today" into one
* fixed day. An absolute pick still stores one day, because "on 14 August"
* does mean that day.
*/
export interface FilterDateValue {
/** An absolute day, `yyyy-MM-dd`. Mutually exclusive with `relative`. */
date?: string
/** A token re-resolved on every read. */
relative?: FilterRelativeDate
/** Optional time of day, `HH:mm`. */
time?: string
}
/** A calendar offset from now, resolved at read time. */
export interface FilterRelativeDate {
unit: "day" | "week" | "month" | "year"
offset: number
}
export const FILTER_DATE_FORMAT = "yyyy-MM-dd"
export const RELATIVE_TODAY: FilterRelativeDate = { unit: "day", offset: 0 }
export const RELATIVE_TOMORROW: FilterRelativeDate = { unit: "day", offset: 1 }
export const RELATIVE_YESTERDAY: FilterRelativeDate = {
unit: "day",
offset: -1,
}
export const RELATIVE_NEXT_WEEK: FilterRelativeDate = {
unit: "week",
offset: 1,
}
export function applyFilterRelative(
relative: FilterRelativeDate,
now: Date
): Date {
const base = startOfDay(now)
if (relative.unit === "day") return addDays(base, relative.offset)
if (relative.unit === "week") return addWeeks(base, relative.offset)
if (relative.unit === "month") return addMonths(base, relative.offset)
return addYears(base, relative.offset)
}
export function resolveFilterDate(
value: FilterDateValue | undefined,
now: Date = new Date()
): Date | null {
if (!value) return null
if (value.relative) return applyFilterRelative(value.relative, now)
if (value.date) {
const parsed = parse(value.date, FILTER_DATE_FORMAT, now)
return isValid(parsed) ? parsed : null
}
return null
}
export function toFilterDateValue(date: Date, time?: string): FilterDateValue {
const value: FilterDateValue = { date: format(date, FILTER_DATE_FORMAT) }
if (time) value.time = time
return value
}
// ----- natural language input -----
const WEEKDAYS = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
]
/**
* Typed formats, most specific first. `date-selector.tsx` overlaps on a few
* of these, but its parser is a callback inside the component returning a
* `DateSelectorValue`, so there is nothing importable.
*/
const EXPLICIT_FORMATS = [
"yyyy-MM-dd",
"MM/dd/yyyy",
"M/d/yyyy",
"dd/MM/yyyy",
"MMMM d, yyyy",
"MMM d, yyyy",
"MMMM d",
"MMM d",
"d MMMM yyyy",
"d MMM yyyy",
]
/**
* Parses a typed phrase, or null when nothing matches. Relative phrasing gives
* a RELATIVE value and an explicit date an ABSOLUTE one; flattening the two is
* what freezes a saved view.
*/
export function parseFilterDate(
text: string,
now: Date = new Date()
): FilterDateValue | null {
const input = text.trim().toLowerCase().replace(/\s+/g, " ")
if (!input) return null
if (input === "today" || input === "now") return { relative: RELATIVE_TODAY }
if (input === "tomorrow") return { relative: RELATIVE_TOMORROW }
if (input === "yesterday") return { relative: RELATIVE_YESTERDAY }
const nextLast = input.match(/^(next|last|this) (day|week|month|year)$/)
if (nextLast) {
const direction =
nextLast[1] === "next" ? 1 : nextLast[1] === "last" ? -1 : 0
return {
relative: {
unit: nextLast[2] as FilterRelativeDate["unit"],
offset: direction,
},
}
}
// "in 3 days" / "3 days ago" / "in 2 weeks"
const counted = input.match(
/^(?:in )?(\d+) (day|week|month|year)s?(?: ago)?$/
)
if (counted) {
const magnitude = Number.parseInt(counted[1], 10)
const past = input.endsWith("ago")
return {
relative: {
unit: counted[2] as FilterRelativeDate["unit"],
offset: past ? -magnitude : magnitude,
},
}
}
// "next tuesday" / "last friday" / bare "tuesday"
const weekday = input.match(/^(?:(next|last|this) )?([a-z]+)$/)
if (weekday) {
const index = WEEKDAYS.indexOf(weekday[2])
if (index !== -1) {
const today = startOfDay(now)
const current = today.getDay()
let delta = index - current
const qualifier = weekday[1]
if (qualifier === "last") {
// Always strictly in the past.
if (delta >= 0) delta -= 7
} else {
// "next"/"this" and a bare weekday all mean the NEXT one, not today.
if (delta <= 0) delta += 7
}
return { relative: { unit: "day", offset: delta } }
}
}
for (const pattern of EXPLICIT_FORMATS) {
const parsed = parse(input, pattern, now)
if (isValid(parsed)) {
// "Aug 14" has no year, so it takes the reference year.
return toFilterDateValue(parsed)
}
}
return null
}
/**
* Human wording for a value. Relative values render as their phrase, not the
* resolved day, so a chip reading "is today" does not go stale tomorrow.
*/
export function formatFilterDate(
value: FilterDateValue | undefined,
now: Date = new Date(),
pattern = "MMM d, yyyy"
): string {
if (!value) return ""
if (value.relative) {
const { unit, offset } = value.relative
if (unit === "day" && offset === 0) return "today"
if (unit === "day" && offset === 1) return "tomorrow"
if (unit === "day" && offset === -1) return "yesterday"
const plural = Math.abs(offset) === 1 ? unit : `${unit}s`
if (offset > 0) return `in ${offset} ${plural}`
if (offset < 0) return `${Math.abs(offset)} ${plural} ago`
return `this ${unit}`
}
const resolved = resolveFilterDate(value, now)
if (!resolved) return ""
const day = format(resolved, pattern)
return value.time ? `${day} ${value.time}` : day
}
export function isFilterTime(time: string): boolean {
return /^([01]\d|2[0-3]):[0-5]\d$/.test(time)
}
@@ -0,0 +1,700 @@
import * as React from "react"
/**
* Gesture model copied from `event-calendar-dnd.tsx`, deliberately not
* imported: filters installs on its own and must not pull a whole calendar into
* `shadcn add filters`. Vanilla-DOM overlay, so a drag renders no React.
*/
export const FILTER_DND_ACTIVATION = {
/** Mouse travel before a press becomes a drag. Below this it stays a click. */
moveDistancePx: 5,
/** Touch long-press before a drag starts, so a swipe still scrolls. */
touchDelayMs: 250,
touchTolerancePx: 5,
autoScrollEdgePx: 40,
autoScrollMaxStepPx: 12,
/** A seed only: `createCarry` measures the real grab offset before paint. */
carryPointerOffsetPx: 12,
} as const
/** One rule row, or one group's header row. Both are drag sources and targets. */
export const FILTER_ROW_SELECTOR = '[data-slot="filter-row"]'
/** Explicit "drop INSIDE": a row hit test can only ever say "beside". */
export const FILTER_DROP_ZONE_SELECTOR = "[data-drop-parent]"
/** Marked alongside the zone, so lighting the group costs no `:has()`. */
export const FILTER_GROUP_SELECTOR = '[data-slot="filter-group"]'
/** Geometry only, so resolution is a pure function and a table test. */
export interface FilterDropBox {
top: number
bottom: number
left: number
right: number
/** The group the drop writes into. */
parentId: string
/** Where in that group's children the node lands, not a slot in this list. */
index: number
/** How many rows enclose this box. Deeper wins. */
depth: number
}
export interface FilterDropResolution<T extends FilterDropBox> {
target: T
/** Null for a zone: INTO the group rather than beside anything. */
edge: "before" | "after" | null
}
function contains(box: FilterDropBox, x: number, y: number, grow = 0): boolean {
return (
x >= box.left &&
x <= box.right &&
y >= box.top - grow &&
y <= box.bottom + grow
)
}
/**
* How far a ROW reaches past its own rect when hit tested: HALF the sibling
* gap, which is `gap-3`, so six. `FILTER_ROW_GAP_CLASS` is the other half of
* one measurement; this moves whenever that does.
*
* The seam between adjacent rows is inside NEITHER rect, so a pointer aimed
* between two rows fell through to whatever did enclose it: at the top level
* nothing did, so the cursor said `no-drop`; one level in it was the group.
* Half the gap makes siblings tile, and the depth sort keeps the child ahead of
* that group. Rows only: zones are explicit strips.
*/
export const FILTER_ROW_SEAM_PX = 6
/**
* Zones before rows, then the DEEPEST box: a group's rect encloses everything
* it holds, so a nested condition would otherwise resolve to its whole group.
* `seam` is a parameter, not the constant inlined, so the tiling rule itself is
* a table test.
*/
export function resolveFilterDrop<T extends FilterDropBox>(
zones: T[],
rows: T[],
x: number,
y: number,
seam: number = FILTER_ROW_SEAM_PX
): FilterDropResolution<T> | null {
let zone: T | null = null
for (const candidate of zones) {
if (!contains(candidate, x, y)) continue
if (!zone || candidate.depth > zone.depth) zone = candidate
}
if (zone) return { target: zone, edge: null }
let row: T | null = null
for (const candidate of rows) {
if (!contains(candidate, x, y, seam)) continue
if (!row || candidate.depth > row.depth) row = candidate
}
if (!row) return null
// The midpoint, not the nearest edge: a row is the width of the panel, so a
// proximity test would leave the indicator undecided across most of one.
const after = y > row.top + (row.bottom - row.top) / 2
return {
target: { ...row, index: row.index + (after ? 1 : 0) },
edge: after ? "after" : "before",
}
}
/** Where a dragged node started, which is what makes a drop a no-op or not. */
export interface FilterDropOrigin {
parentId: string
index: number
}
/**
* Both indices name the slot the node already occupies, because
* `moveFilterNodeTo` closes the gap the detach leaves. Copy is never a no-op.
*/
export function isFilterDropNoop(
resolution: FilterDropResolution<FilterDropBox> | null,
origin: FilterDropOrigin | null,
copy: boolean
): boolean {
if (!resolution || !origin || copy) return false
if (resolution.target.parentId !== origin.parentId) return false
return (
resolution.target.index === origin.index ||
resolution.target.index === origin.index + 1
)
}
/** Rows unmount mid-gesture, so it is the PANEL leaving that aborts a drag. */
const activeCancels = new Set<() => void>()
/** Cancel and fully revert every in-flight filter row drag. */
export function cancelActiveFilterDrags(): void {
for (const cancel of [...activeCancels]) cancel()
}
let lastDragEndedAt = 0
/**
* Whether a drag ended within the last few frames. Exported: a consumer's own
* row click handler needs the same answer the suppressor below needs.
*/
export function wasRecentFilterDrag(): boolean {
return performance.now() - lastDragEndedAt < 250
}
/**
* NOT `{ once: true }` alone: a drag that ends over a different element fires
* no click at all, and a stale arming would eat the user's next click. The
* timestamp is what makes that stale arming harmless.
*/
function suppressTrailingClick(event: MouseEvent) {
window.removeEventListener("click", suppressTrailingClick, true)
if (!wasRecentFilterDrag()) return
event.stopPropagation()
event.preventDefault()
}
/** The carry is a compositing layer: a subpixel translate blurs its text. */
function snapToPixel(value: number): number {
const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1
return Math.round(value * dpr) / dpr
}
/**
* `card` is a ZONE's group, resolved once rather than walked per frame. Null on
* a row, which stands for itself.
*/
type MeasuredBox = FilterDropBox & {
el: HTMLElement
card: HTMLElement | null
}
interface Surface {
zones: MeasuredBox[]
rows: MeasuredBox[]
/**
* Rects are captured ONCE, so one subtraction against this scrollTop corrects
* for scrolling instead of a per-move `getBoundingClientRect`. THE ELEMENT,
* NOT A MIRRORED NUMBER: a mirror tracks only this engine's own scrolling, so
* a wheel mid-gesture left the rects stale while the correction read zero,
* painting the indicator 60px off the pointer.
*/
viewport: HTMLElement | null
viewportRect: DOMRect | null
/** The one number kept: where the rects were taken, which cannot change. */
startScrollTop: number
}
/**
* The nearest scroller, THIS ONE INCLUDED: the panel is exactly what a consumer
* caps with `overflow-y: auto`, and a walk from the parent answered null for it.
*/
function findScrollParent(el: HTMLElement | null): HTMLElement | null {
let node = el ?? null
while (node) {
const style = getComputedStyle(node)
const overflow = `${style.overflowY} ${style.overflow}`
if (
/(auto|scroll|overlay)/.test(overflow) &&
node.scrollHeight > node.clientHeight
) {
return node
}
node = node.parentElement
}
return null
}
function rowDepth(el: HTMLElement, root: HTMLElement): number {
let depth = 0
let node = el.parentElement
while (node && node !== root) {
if (node.matches(FILTER_ROW_SELECTOR)) depth++
node = node.parentElement
}
return depth
}
/**
* Measured once. DESCENDANTS of the dragged node are excluded rather than
* refused later: an indicator the drop ignores is a broken promise. The dragged
* row ITSELF is kept, so its own slot reads as a no-op, not as a hole.
*/
function collectSurface(root: HTMLElement, dragged: HTMLElement): Surface {
const zones: MeasuredBox[] = []
const rows: MeasuredBox[] = []
for (const el of root.querySelectorAll<HTMLElement>(
FILTER_DROP_ZONE_SELECTOR
)) {
if (dragged.contains(el)) continue
const parentId = el.dataset.dropParent
if (!parentId) continue
const rect = el.getBoundingClientRect()
zones.push({
el,
card: el.closest<HTMLElement>(FILTER_GROUP_SELECTOR),
top: rect.top,
bottom: rect.bottom,
left: rect.left,
right: rect.right,
parentId,
index: Number(el.dataset.dropIndex ?? 0),
depth: rowDepth(el, root),
})
}
for (const el of root.querySelectorAll<HTMLElement>(FILTER_ROW_SELECTOR)) {
if (el !== dragged && dragged.contains(el)) continue
const parentId = el.dataset.parentId
if (!parentId) continue
const rect = el.getBoundingClientRect()
rows.push({
el,
card: null,
top: rect.top,
bottom: rect.bottom,
left: rect.left,
right: rect.right,
parentId,
index: Number(el.dataset.index ?? 0),
depth: rowDepth(el, root),
})
}
const viewport = findScrollParent(root)
return {
zones,
rows,
viewport,
viewportRect: viewport?.getBoundingClientRect() ?? null,
startScrollTop: viewport?.scrollTop ?? 0,
}
}
export interface FilterDropDetails {
parentId: string
index: number
/** Alt was held at release: copy the node rather than moving it. */
copy: boolean
}
export interface FilterRowDragOptions {
/** The PANEL, not its body: the top-level append zone is in the footer. */
root: () => HTMLElement | null
onDrop: (nodeId: string, details: FilterDropDetails) => void
disabled?: boolean
}
/**
* Straight to the DOM: a React state write per move is a tree render. A zone
* marks TWO elements, itself and its card, and both clear every frame.
*/
function paint(
surface: Surface,
hit: FilterDropResolution<MeasuredBox> | null
) {
for (const box of surface.zones) {
delete box.el.dataset.dropInto
if (box.card) delete box.card.dataset.dropInto
}
for (const box of surface.rows) delete box.el.dataset.dropEdge
if (!hit) return
if (hit.edge) {
hit.target.el.dataset.dropEdge = hit.edge
return
}
hit.target.el.dataset.dropInto = ""
if (hit.target.card) hit.target.card.dataset.dropInto = ""
}
function startDrag(
event: React.PointerEvent<HTMLElement>,
nodeId: string,
options: FilterRowDragOptions
) {
const handle = event.currentTarget
const root = options.root()
const source = handle.closest<HTMLElement>(FILTER_ROW_SELECTOR)
if (!root || !source) return
const pointerId = event.pointerId
const isTouch = event.pointerType === "touch"
const startX = event.clientX
const startY = event.clientY
// Off the row's own attributes, which it already publishes for the hit test.
// Asking the panel would mean threading a position through the drag context.
const origin: FilterDropOrigin | null = source.dataset.parentId
? {
parentId: source.dataset.parentId,
index: Number(source.dataset.index ?? 0),
}
: null
let active = false
let finished = false
let surface: Surface | null = null
let hit: FilterDropResolution<MeasuredBox> | null = null
let copy = event.altKey
let lastEvent: PointerEvent | null = null
let touchTimer: ReturnType<typeof setTimeout> | null = null
let rafScroll = 0
// Three states, not a boolean: `"ok"` lands somewhere new, `"noop"` puts the
// node back where it came from, `"none"` is over nothing at all.
let dropState: "ok" | "noop" | "none" = "none"
let carryEl: HTMLDivElement | null = null
let badgeEl: HTMLSpanElement | null = null
// Where inside the row the pointer went down, so the copy holds that spot.
// Measured in `createCarry`, which runs before anything reads these.
let grabOffsetX = FILTER_DND_ACTIVATION.carryPointerOffsetPx
let grabOffsetY = FILTER_DND_ACTIVATION.carryPointerOffsetPx
const createCarry = () => {
const rect = source.getBoundingClientRect()
grabOffsetX = startX - rect.left
grabOffsetY = startY - rect.top
carryEl = document.createElement("div")
carryEl.setAttribute("data-slot", "filters-drag-carry")
carryEl.setAttribute("aria-hidden", "true")
// `data-drop-invalid` rather than a toggled class name, so the style stays
// one static string Tailwind can see at build time.
//
// Muted and translucent, no border or shadow: the clone carries the row's
// own controls, and a box around them read as a card leaving the page. Only
// the invalid state marks the clone, since nothing else says the drop is
// dead, and it RINGS rather than going dashed: dashed is this builder's
// word for "a row can land here", and the clone is not one.
carryEl.className =
"pointer-events-none fixed " +
"top-0 left-0 z-100 " +
"opacity-95 will-change-transform " +
"data-drop-invalid:ring-destructive/70 data-drop-invalid:ring-2 " +
"data-drop-invalid:opacity-60"
// A bare anchor: the clip box below owns the size, and no overflow lets the
// badge overhang.
carryEl.style.position = "fixed"
const clone = source.cloneNode(true) as HTMLElement
// The source is faded while in flight; its copy must not inherit that.
delete clone.dataset.dragging
delete clone.dataset.dropNoop
delete clone.dataset.dropEdge
if (rect.width > 0) clone.style.width = `${rect.width}px`
/*
SANITIZED, because the clone lives on `<body>`: two elements would claim
one node id, `aria-hidden` leaves real buttons in the tab order and must
never wrap a focused one, and cloned ids make a `for` resolve to a copy.
*/
const strip = (el: HTMLElement) => {
el.removeAttribute("id")
el.removeAttribute("data-node-id")
el.removeAttribute("data-filter-cell")
if (el.dataset.slot === "filter-row") el.removeAttribute("data-slot")
// `-1` rather than removed: the attribute is also how the roving scheme
// marks its cells, and a row copied mid-gesture has one cell at `0`.
el.setAttribute("tabindex", "-1")
}
strip(clone)
for (const el of clone.querySelectorAll<HTMLElement>("*")) strip(el)
/*
The CLIPPING box is the inner one: the carry is the badge's containing
block, so `overflow-hidden` there clipped the badge that overhangs it.
*/
const clip = document.createElement("div")
clip.className = "bg-background overflow-hidden rounded-md"
// Sized only when the rect is real: a document that is not being laid out
// measures zero, and a copy pinned to `0px` is invisible.
if (rect.width > 0) clip.style.width = `${rect.width}px`
if (rect.height > 0) clip.style.height = `${rect.height}px`
clip.appendChild(clone)
carryEl.appendChild(clip)
badgeEl = document.createElement("span")
badgeEl.setAttribute("data-slot", "filters-drag-copy")
badgeEl.className =
"bg-primary text-primary-foreground absolute -end-1.5 -top-1.5 flex " +
"size-4 items-center justify-center rounded-full text-[10px] leading-none"
badgeEl.textContent = "+"
// Legible BEFORE the release, or Alt is discoverable only by error.
badgeEl.hidden = !copy
carryEl.appendChild(badgeEl)
// <body>, not the panel: a transform or a `content-visibility` on a wrapper
// becomes the containing block for `position: fixed`, and the clone jumps.
document.body.appendChild(carryEl)
positionCarry(startX, startY)
}
const positionCarry = (x: number, y: number) => {
if (!carryEl) return
// THE GRAB OFFSET, not a fixed nudge: the grip sits at the row's trailing
// edge, so anchoring at the pointer hung the copy a row's width away.
carryEl.style.transform = `translate3d(${snapToPixel(x - grabOffsetX)}px, ${snapToPixel(y - grabOffsetY)}px, 0)`
}
// Two things decide the cursor and the RESOLUTION outranks Alt, which is why
// `none` is tested first: `copy` over nothing is still nothing.
const paintCursor = () => {
if (!active) return
document.body.style.cursor =
dropState === "none" ? "no-drop" : copy ? "copy" : "grabbing"
}
// Deliberately NOT guarded on `dropState === next`: the first resolution of a
// gesture is routinely `"none"`, which is also the initial value, so a guard
// left the first frame of a drag onto nothing wearing no mark at all.
const setDropState = (next: typeof dropState) => {
dropState = next
// On the SOURCE: "it stays here" is a statement about the row carried.
if (next === "noop") source.dataset.dropNoop = ""
else delete source.dataset.dropNoop
// And on the CARRY: over nothing there is no target left to mark.
if (carryEl) {
if (next === "none") carryEl.dataset.dropInvalid = ""
else delete carryEl.dataset.dropInvalid
}
paintCursor()
}
const setCopy = (next: boolean) => {
if (copy === next) return
copy = next
if (badgeEl) badgeEl.hidden = !next
paintCursor()
// Alt changes the ANSWER, not just the cursor: a release beside yourself is
// a no-op while moving and a duplicate in place while copying.
if (lastEvent) hitTest(lastEvent.clientX, lastEvent.clientY)
}
const hitTest = (x: number, y: number) => {
if (!surface) return
// One subtraction instead of a re-measure. Read off the ELEMENT every time:
// see `Surface.viewport`, a mirror misses the user's own scroll.
const scrolled = surface.viewport?.scrollTop ?? surface.startScrollTop
const drift = scrolled - surface.startScrollTop
const resolved = resolveFilterDrop(
surface.zones,
surface.rows,
x,
y + drift
)
const noop = isFilterDropNoop(resolved, origin, copy)
// A no-op commits as NOTHING, not as a move the tree would discard.
hit = noop ? null : resolved
paint(surface, hit)
setDropState(noop ? "noop" : hit ? "ok" : "none")
}
const autoScroll = (y: number) => {
if (rafScroll) cancelAnimationFrame(rafScroll)
rafScroll = 0
const viewport = surface?.viewport
const rect = surface?.viewportRect
if (!viewport || !rect) return
const edge = FILTER_DND_ACTIVATION.autoScrollEdgePx
const step = FILTER_DND_ACTIVATION.autoScrollMaxStepPx
let delta = 0
if (y >= rect.top && y < rect.top + edge) {
delta = -step * ((rect.top + edge - y) / edge)
} else if (y <= rect.bottom && y > rect.bottom - edge) {
delta = step * Math.min(1, (y - (rect.bottom - edge)) / edge)
}
if (delta === 0) return
const tick = () => {
// The read-back STOPS the loop: the browser clamps at the scroll extent,
// so a panel at its limit would keep asking for unusable frames.
const before = viewport.scrollTop
viewport.scrollTop = before + delta
if (viewport.scrollTop === before) return
if (lastEvent) hitTest(lastEvent.clientX, lastEvent.clientY)
rafScroll = requestAnimationFrame(tick)
}
rafScroll = requestAnimationFrame(tick)
}
/**
* A still pointer fires no `pointermove`, so a wheel mid-drag would leave the
* last answer painted while the rows slid out from under it. The overlap
* with the autoScroll tick is deliberate: this is arithmetic over boxes
* already measured, and one redundant pass per frame is cheaper than a rule
* about who owns the repaint.
*/
const onViewportScroll = () => {
if (!active || !lastEvent) return
hitTest(lastEvent.clientX, lastEvent.clientY)
}
const activate = () => {
if (active) return
active = true
surface = collectSurface(root, source)
// Passive: nothing here calls `preventDefault`, and a non-passive listener
// on a scroller is a frame of jank per notch.
surface.viewport?.addEventListener("scroll", onViewportScroll, {
passive: true,
})
source.dataset.dragging = ""
document.body.style.cursor = copy ? "copy" : "grabbing"
document.body.style.userSelect = "none"
createCarry()
// Armed with the GESTURE, not the press: a press that never travels far
// enough strands no overlay, no body style and no listener.
window.addEventListener("blur", onWindowBlur)
}
// Idempotent. pointerup, pointercancel, Escape, window blur and the panel's
// own teardown can all race; whichever lands first wins and the rest no-op.
const cleanup = () => {
if (finished) return
finished = true
activeCancels.delete(cancel)
window.removeEventListener("pointermove", onPointerMove)
window.removeEventListener("pointerup", onPointerUp)
window.removeEventListener("pointercancel", onPointerCancel)
window.removeEventListener("keydown", onKeyDown, true)
window.removeEventListener("keyup", onKeyUp, true)
window.removeEventListener("blur", onWindowBlur)
surface?.viewport?.removeEventListener("scroll", onViewportScroll)
if (touchTimer) clearTimeout(touchTimer)
if (rafScroll) cancelAnimationFrame(rafScroll)
if (surface) paint(surface, null)
delete source.dataset.dragging
delete source.dataset.dropNoop
carryEl?.remove()
carryEl = null
badgeEl = null
document.body.style.cursor = ""
document.body.style.userSelect = ""
}
/** A bare teardown: nothing is committed until release, so no snapshot. */
const cancel = () => {
if (active) lastDragEndedAt = performance.now()
cleanup()
}
const onWindowBlur = () => cancel()
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && active) {
// Stopped, or the popover the builder may be living in closes underneath
// the gesture and takes the panel the drop was aimed at with it.
e.stopPropagation()
e.preventDefault()
cancel()
return
}
if (e.key === "Alt") setCopy(true)
}
// Alt tracked on its own keys too: reaching for the modifier without moving
// the mouse fires no pointermove, and the badge has to appear regardless.
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === "Alt") setCopy(false)
}
const onPointerMove = (e: PointerEvent) => {
// A second finger must not drive or cancel the gesture this one started.
if (e.pointerId !== pointerId) return
lastEvent = e
if (!active) {
const distance = Math.hypot(e.clientX - startX, e.clientY - startY)
if (isTouch) {
// Past the tolerance the user is scrolling, so the press stays a scroll.
if (distance > FILTER_DND_ACTIVATION.touchTolerancePx) cancel()
return
}
if (distance < FILTER_DND_ACTIVATION.moveDistancePx) return
activate()
}
setCopy(e.altKey)
positionCarry(e.clientX, e.clientY)
hitTest(e.clientX, e.clientY)
autoScroll(e.clientY)
}
const onPointerUp = (e: PointerEvent) => {
if (e.pointerId !== pointerId) return
const landed = active ? hit : null
const wasCopy = copy
cleanup()
if (!active) return
lastDragEndedAt = performance.now()
// The trailing click belongs to the gesture, not to whatever it ended over.
window.addEventListener("click", suppressTrailingClick, true)
if (!landed) return
options.onDrop(nodeId, {
parentId: landed.target.parentId,
index: landed.target.index,
copy: wasCopy,
})
}
const onPointerCancel = (e: PointerEvent) => {
if (e.pointerId !== pointerId) return
cancel()
}
window.addEventListener("pointermove", onPointerMove)
window.addEventListener("pointerup", onPointerUp)
window.addEventListener("pointercancel", onPointerCancel)
window.addEventListener("keydown", onKeyDown, true)
window.addEventListener("keyup", onKeyUp, true)
activeCancels.add(cancel)
if (isTouch) {
touchTimer = setTimeout(() => {
activate()
if (lastEvent) {
positionCarry(lastEvent.clientX, lastEvent.clientY)
hitTest(lastEvent.clientX, lastEvent.clientY)
}
}, FILTER_DND_ACTIVATION.touchDelayMs)
}
}
/**
* Only `onPointerDown`: the handle is a real button that Tab reaches, so it must
* not also be `draggable` and race the browser's HTML5 drag, with its ghost, no
* copy affordance and no way to report a drop into a nested target.
*/
export function useFilterRowDrag(options: FilterRowDragOptions) {
// Read at gesture time, not captured at wiring time, so the handler identity
// is stable and a memoized row never re-renders on a re-created callback.
const latest = React.useRef(options)
React.useEffect(() => {
latest.current = options
})
// A gesture outlives its row by design, but never the panel: its listeners
// are on `window` and its carry on `document.body`.
React.useEffect(() => cancelActiveFilterDrags, [])
return React.useCallback(
(nodeId: string) => ({
onPointerDown: (event: React.PointerEvent<HTMLElement>) => {
if (latest.current.disabled) return
// Primary button only: a right press strands the drag under a menu.
if (event.button !== 0) return
startDrag(event, nodeId, latest.current)
},
}),
[]
)
}
@@ -0,0 +1,187 @@
import type {
FilterDraft,
FilterDraftStep,
FilterOperatorArity,
} from "@/components/reui/filters/filters-types"
/**
* The filter builder's step machine. Pure: no React, Base UI or shadcn import,
* so the whole create and amend flow is a table test in node with no DOM.
*
* Arity arrives ON THE ACTION, which keeps the reducer free of the schema index
* while it still owns the branch that skips the value step. The shipped chrome
* drives `openCreate`, browse, query, `selectField` and `close`; `openAmend`,
* `selectOperator`, `setValue`, `commit`, `back` and `goto` are published
* surface for a consumer composing their own panel.
*/
export type FilterDraftAction<V = unknown> =
| { type: "openCreate"; cascaderPath?: string[] }
| {
type: "openAmend"
ruleId: string
step: FilterDraftStep
path: string[]
operator: string | null
value: V | undefined
cascaderPath?: string[]
}
| { type: "close" }
/** Where the user is BROWSING, not the selected field's path. */
| { type: "setCascaderPath"; path: string[] }
| { type: "setQuery"; query: string }
| {
type: "selectField"
path: string[]
defaultOperator: string | null
}
| {
type: "selectOperator"
operator: string
arity: FilterOperatorArity
/** Carried across the operator change, already coerced. */
value: V | undefined
}
| { type: "setValue"; value: V | undefined }
| { type: "commit"; value?: V }
| { type: "back" }
| { type: "goto"; step: FilterDraftStep }
export function createFilterDraft<V = unknown>(
cascaderPath: string[] = []
): FilterDraft<V> {
return {
step: "field",
status: "editing",
ruleId: null,
path: [],
cascaderPath,
operator: null,
value: undefined,
query: "",
}
}
/** null means Back closes the builder rather than stepping back. */
function previousStep(step: FilterDraftStep): FilterDraftStep | null {
if (step === "value") return "operator"
if (step === "operator") return "field"
return null
}
/**
* `null` is the closed state, not a separate boolean: `{ open, draft }` would
* make "open with no draft" and "closed but still holding one" representable.
*/
export function filterDraftReducer<V = unknown>(
state: FilterDraft<V> | null,
action: FilterDraftAction<V>
): FilterDraft<V> | null {
switch (action.type) {
case "openCreate":
return createFilterDraft<V>(action.cascaderPath ?? [])
case "openAmend":
return {
step: action.step,
status: "editing",
ruleId: action.ruleId,
path: action.path,
// Defaults the picker to the field's own level, so amend opens beside
// its siblings, not at the root. Callers may override.
cascaderPath: action.cascaderPath ?? action.path.slice(0, -1),
operator: action.operator,
value: action.value,
query: "",
}
case "close":
return null
case "setCascaderPath":
if (!state) return state
if (state.cascaderPath === action.path) return state
return { ...state, cascaderPath: action.path }
case "setQuery":
if (!state) return state
if (state.query === action.query) return state
return { ...state, query: action.query }
case "selectField": {
if (!state) return state
// Choosing a field COMMITS at once; the condition is picked on the chip,
// so the popover never holds a second and third step to walk.
return {
...state,
path: action.path,
operator: action.defaultOperator,
status: "ready",
// A field change invalidates the value: "Active" means nothing once
// the field becomes "Created at".
value: undefined,
step: "operator",
query: "",
}
}
case "selectOperator": {
if (!state) return state
// arity "none" is the whole filter; a value step would be an empty panel.
if (action.arity === "none") {
return {
...state,
operator: action.operator,
value: undefined,
status: "ready",
query: "",
}
}
return {
...state,
operator: action.operator,
value: action.value,
step: "value",
status: "editing",
query: "",
}
}
case "setValue":
if (!state) return state
return { ...state, value: action.value }
case "commit":
if (!state) return state
return {
...state,
value: action.value === undefined ? state.value : action.value,
status: "ready",
}
case "back": {
if (!state) return state
const step = previousStep(state.step)
if (!step) return null
return { ...state, step, status: "editing", query: "" }
}
case "goto":
if (!state) return state
if (state.step === action.step) return state
return { ...state, step: action.step, status: "editing", query: "" }
default:
return state
}
}
/**
* A path is enough. Not an operator: the flow commits when the field is picked,
* so `operator: ""` is a legitimate committed state. Not a value either, since
* `arity: "none"` has none and an editor may commit `undefined` to clear.
*/
export function isFilterDraftCommittable<V>(
draft: FilterDraft<V> | null
): draft is FilterDraft<V> {
return Boolean(draft && draft.path.length > 0)
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,151 @@
import type {
FilterDraftStep,
FilterIssue,
FilterIssueReason,
FilterLabels,
} from "@/components/reui/filters/filters-types"
/**
* The shipped English copy. `stepAnnouncement` (consumer-composed create
* wizard) and `showRecords` (a consumer's own panel heading) are unread by the
* shipped chrome and kept on purpose; do not prune them as dead keys.
*/
export const DEFAULT_FILTER_LABELS: FilterLabels = {
addFilter: "Add filter",
advancedFilter: "Advanced filter",
showRecords: "In this view, show records",
builderEmpty: "No filters yet",
builderEmptyHint: "Add a filter to narrow down what you see.",
addCondition: "Add filter",
addConditionGroup: "Add group",
addToGroup: "Add filter to this group",
removeGroup: "Remove group",
wrapInGroup: "Wrap in group",
ungroup: "Ungroup",
moveToTopLevel: "Move to top",
moveToGroup: (position) => `Move to group ${position}`,
reorder: "Reorder",
reorderHint: "Press Alt with Arrow Up or Arrow Down to reorder",
groupAll: "All of the following are true...",
groupAny: "Any of the following are true...",
groupPlaceholder: "Drag filters here",
rowLabel: (condition, depth) => `${condition}, level ${depth}`,
groupLabel: (description, depth) => `${description} level ${depth}`,
groupAnnouncement: (added) => (added ? "Group added" : "Group removed"),
reorderAnnouncement: (label, position, total) =>
`${label} moved to position ${position} of ${total}`,
// Destination FIRST after the verb: it is the half a plain reorder cannot
// say and the half the user cannot see once the row has stopped moving.
// Position stays last, so this still ends on the "of N" a reorder ends on.
moveAnnouncement: (label, destination, position, total) =>
`${label} moved into ${destination}, position ${position} of ${total}`,
clearAll: "Clear all",
groupMenu: "Group options",
searchFields: "Search attributes...",
searchOperators: "Search operators...",
searchOptions: "Search...",
back: "Back",
clear: "Clear",
apply: "Apply",
discard: "Discard changes",
empty: "No results",
loading: "Loading...",
loadingMore: "Loading more...",
loadMore: "Load more",
error: "Could not load",
retry: "Retry",
where: "Where",
and: "And",
or: "Or",
combinator: "Change combinator",
// The word first: it is what the control SAYS and what a truncated pill
// stops showing. The action follows, so the name still names an action.
combinatorLabel: (word) => `${word}, change combinator`,
duplicate: "Duplicate",
negate: "Negate",
convertToAdvanced: "Advanced editor",
remove: "Remove",
chipMenu: (fieldLabel) => `${fieldLabel} filter options`,
filtersLabel: "Filters",
filterLabel: (condition) => condition,
readOnly: "Read only. These filters cannot be changed.",
pathSeparator: " > ",
valuePlaceholder: "enter text...",
selectPlaceholder: "Select...",
noValue: "no value",
selectCondition: "Select condition",
incomplete: "incomplete filter",
branchAffordance: "opens a list",
exclusiveHint: "cannot be combined with the other options",
exclusiveAnnouncement: (label, cleared) =>
cleared === 1
? `${label} selected. 1 other selection cleared.`
: `${label} selected. ${cleared} other selections cleared.`,
itemCount: (count) => `${count} items`,
fieldsLabel: "Attributes",
resultsAnnouncement: (count) =>
count === 1 ? "1 result" : `${count} results`,
actionsLabel: "Actions",
stepAnnouncement: (step, label) => {
if (step === "field") return `Choose an attribute. ${label}`
if (step === "operator") return `Choose a condition for ${label}`
return `Enter a value for ${label}`
},
countAnnouncement: (count) =>
count === 1 ? "1 filter applied" : `${count} filters applied`,
valueCount: (count) => `${count} selected`,
valueDetail: (summary, values) => `${summary}: ${values.join(", ")}`,
valueRange: (from, to) => `${from} to ${to}`,
rangeFrom: (fieldLabel) => `${fieldLabel} from`,
rangeTo: (fieldLabel) => `${fieldLabel} to`,
rangeSeparator: "to",
negated: (operatorLabel) => `not ${operatorLabel}`,
issueOperator: "Choose a condition",
issueValue: "Enter a value",
issueRange: "Enter both ends of the range",
issueRangeOrder: "The end of the range comes before its start",
issueEmptyGroup: "This group has no conditions yet",
// "Row", not "condition": an empty GROUP is counted here too and a group is
// not a condition. Rule and group divs alike carry `data-slot="filter-row"`.
issueSummary: (count) =>
count === 1 ? "1 row needs attention" : `${count} rows need attention`,
}
/**
* Shallow merge over the defaults, like the cascader's. A deep merge would leak
* the default back into a replaced function-valued label for the arguments the
* consumer did not think about.
*/
export function resolveFilterLabels(
labels?: Partial<FilterLabels>
): FilterLabels {
if (!labels) return DEFAULT_FILTER_LABELS
return { ...DEFAULT_FILTER_LABELS, ...labels }
}
/**
* The sentence for one issue. Takes the ISSUE, not just the reason: a field's
* `validate` supplies its own words, so `custom` has nothing to look up.
*/
export function filterIssueLabel(
issue: Pick<FilterIssue, "reason" | "message"> | FilterIssueReason,
labels: FilterLabels
): string {
const reason = typeof issue === "string" ? issue : issue.reason
if (typeof issue !== "string" && issue.message) return issue.message
if (reason === "missing-operator") return labels.issueOperator
if (reason === "incomplete-range") return labels.issueRange
if (reason === "reversed-range") return labels.issueRangeOrder
if (reason === "empty-group") return labels.issueEmptyGroup
return labels.issueValue
}
/** The search placeholder for a step's panel. */
export function stepPlaceholder(
step: FilterDraftStep,
labels: FilterLabels
): string {
if (step === "field") return labels.searchFields
if (step === "operator") return labels.searchOperators
return labels.searchOptions
}
@@ -0,0 +1,539 @@
import { collapseCascaderPath } from "@/components/reui/cascader/cascader-lib"
import type { CascaderCollapse } from "@/components/reui/cascader/cascader-types"
import type {
FilterField,
FilterIndex,
FilterOperator,
FilterOption,
} from "@/components/reui/filters/filters-types"
/* -------------------------------------------------------------------------- */
/* Menus */
/* -------------------------------------------------------------------------- */
/**
* One menu size for the chip's kebab and the builder's three menus, which draw
* the same rows. `w-max` OVERRIDES the shadcn content width, which pins to the
* anchor and hides horizontal overflow, so a menu hung off a 28px icon button
* CUT every longer row. Floor 128px, down from 224px: the rows run about 130px,
* and five of the eight styles already floor a dropdown there (luma, maia and
* sera sit at 192px). The 24rem cap stops a pathological label pushing the menu
* off the side of a phone. Past it a row truncates rather than wraps, since a
* wrapped row changes HEIGHT and would slide the destructive row under the
* pointer: `min-w-0` lets a row shrink below its content at all (a flex child's
* floor is its content otherwise) and the label span carries `truncate`, see
* `FILTER_MENU_LABEL_CLASS`.
*/
export const FILTER_MENU_CLASS =
"w-max min-w-32 max-w-[min(24rem,calc(100vw-2rem))] [&_[data-slot=dropdown-menu-item]]:min-w-0"
/**
* What a menu row's LABEL wears, and it has to be a real span: `text-overflow`
* needs a block container, not the anonymous flex item a bare text child is.
* `min-w-0` so the span can shrink below its own content.
*/
export const FILTER_MENU_LABEL_CLASS = "min-w-0 truncate"
/**
* The FIELD PICKER's panel, shared by the Add filter popover and the advanced
* row's attribute cell so one schema is never drawn at two widths. `w-auto`
* grows to the longest row, so the 224px floor (down from 256px) only decides
* the reported case: a flat schema of short names in a mostly empty panel.
* Measured: 224px still clears the search input, the breadcrumb and a leaf
* row's icon-plus-label.
*/
export const FILTER_FIELD_PICKER_CLASS = "w-auto min-w-56 p-0"
/* -------------------------------------------------------------------------- */
/* Paths */
/* -------------------------------------------------------------------------- */
/** Root key for `childrenOf`; a control char cannot collide with a field id. */
export const FILTER_ROOT_KEY = "\u0000root"
/** Separator between path segments in the flat map keys. */
const PATH_SEPARATOR = "\u0000"
export function joinFilterPath(path: readonly string[]): string {
return path.join(PATH_SEPARATOR)
}
export function splitFilterPath(key: string): string[] {
return key === "" ? [] : key.split(PATH_SEPARATOR)
}
/** The joined key of a path's parent, or `FILTER_ROOT_KEY` at the top level. */
export function parentFilterKey(path: readonly string[]): string {
return path.length <= 1 ? FILTER_ROOT_KEY : joinFilterPath(path.slice(0, -1))
}
/* -------------------------------------------------------------------------- */
/* Signature */
/* -------------------------------------------------------------------------- */
/**
* A content hash of the schema, used to skip a rebuild. Identity alone is
* useless: every block and example declares `fields` as an inline literal, so a
* memo keyed on the array misses on every parent render. Render props (`icon`,
* `renderValue`, a component-valued `editor`) are hashed by PRESENCE only,
* because inline JSX recreates them: swapping one's body keeps serving the
* previous field objects, so change a structural field or give `fields` a
* stable identity.
*/
export function computeFilterSchemaSignature<V, O>(
fields: readonly FilterField<V, O>[]
): string {
const parts: string[] = []
const walk = (list: readonly FilterField<V, O>[], depth: number) => {
for (const field of list) {
parts.push(
depth +
":" +
field.id +
"|" +
(field.label ?? "") +
"|" +
(field.type ?? "") +
"|" +
(field.defaultOperator ?? "") +
"|" +
(field.column ?? "") +
"|" +
(field.selectable ? "1" : "0") +
(field.disabled ? "1" : "0") +
(field.icon ? "1" : "0") +
(field.renderValue ? "1" : "0") +
(field.loadOptions ? "1" : "0") +
(field.resolveValues ? "1" : "0") +
"|" +
(typeof field.editor === "string"
? field.editor
: field.editor
? "fn"
: "") +
"|" +
(field.keywords?.join(",") ?? "") +
"|" +
(field.count ?? "") +
"|" +
// Options are part of the shape, and `exclusive` rides along: it
// decides what a pick DOES, so turning it on must force a rebuild.
(field.options
? field.options
.map(
(option) =>
option.value +
"~" +
option.label +
(option.exclusive ? "~x" : "")
)
.join(",")
: "") +
"|" +
(Array.isArray(field.operators)
? field.operators.map((operator) => operator.value).join(",")
: field.operators
? "fn"
: "")
)
if (field.fields?.length) walk(field.fields, depth + 1)
}
}
walk(fields, 0)
return parts.join("\n")
}
/* -------------------------------------------------------------------------- */
/* Index */
/* -------------------------------------------------------------------------- */
const EMPTY_INDEX: FilterIndex<never, never> = {
byPath: new Map(),
childrenOf: new Map(),
parentOf: new Map(),
all: [],
roots: [],
signature: "",
}
/**
* Normalizes a field schema into flat maps. When `previous`'s signature
* matches, the PREVIOUS OBJECT is returned: an equal-but-new one would leave
* every downstream memo missing exactly as often as before. Duplicate sibling
* ids are ignored after the first, as the cascader does for node values, and
* `findFilterSchemaIssues` reports them in development.
*/
export function buildFilterIndex<V = unknown, O = unknown>(
fields: readonly FilterField<V, O>[],
previous?: FilterIndex<V, O> | null,
/** A signature the caller already computed, to avoid walking twice. */
precomputedSignature?: string
): FilterIndex<V, O> {
const signature = precomputedSignature ?? computeFilterSchemaSignature(fields)
if (previous && previous.signature === signature) return previous
if (fields.length === 0) {
return { ...(EMPTY_INDEX as unknown as FilterIndex<V, O>), signature }
}
const byPath = new Map<string, FilterField<V, O>>()
const childrenOf = new Map<string, FilterField<V, O>[]>()
const parentOf = new Map<string, string>()
const all: { field: FilterField<V, O>; path: string[] }[] = []
const roots: FilterField<V, O>[] = []
const walk = (
list: readonly FilterField<V, O>[],
parentPath: string[],
parentKey: string
) => {
const accepted: FilterField<V, O>[] = []
const seen = new Set<string>()
for (const field of list) {
if (seen.has(field.id)) continue
seen.add(field.id)
const path = [...parentPath, field.id]
const key = joinFilterPath(path)
if (byPath.has(key)) continue
byPath.set(key, field)
parentOf.set(key, parentKey === FILTER_ROOT_KEY ? "" : parentKey)
all.push({ field, path })
accepted.push(field)
if (field.fields?.length) walk(field.fields, path, key)
}
childrenOf.set(parentKey, accepted)
if (parentKey === FILTER_ROOT_KEY) roots.push(...accepted)
}
walk(fields, [], FILTER_ROOT_KEY)
return { byPath, childrenOf, parentOf, all, roots, signature }
}
export function getFilterField<V, O>(
index: FilterIndex<V, O>,
path: readonly string[]
): FilterField<V, O> | undefined {
return index.byPath.get(joinFilterPath(path))
}
/** The ancestor chain for a path, root first and the field itself last. */
export function getFilterFieldChain<V, O>(
index: FilterIndex<V, O>,
path: readonly string[]
): FilterField<V, O>[] {
const chain: FilterField<V, O>[] = []
for (let i = 1; i <= path.length; i++) {
const field = index.byPath.get(joinFilterPath(path.slice(0, i)))
if (!field) break
chain.push(field)
}
return chain
}
/** Child fields of a path. Pass an empty path for the root level. */
export function getFilterChildren<V, O>(
index: FilterIndex<V, O>,
path: readonly string[]
): FilterField<V, O>[] {
const key = path.length === 0 ? FILTER_ROOT_KEY : joinFilterPath(path)
return index.childrenOf.get(key) ?? []
}
export function isFilterBranch<V, O>(field: FilterField<V, O>): boolean {
return Boolean(field.fields?.length)
}
/**
* Whether a field DECLARES itself filterable on: leaves always, a branch only
* with `selectable`. The shipped pickers ignore it, see `isFilterFieldPickable`.
*/
export function isFilterFieldSelectable<V, O>(
field: FilterField<V, O>
): boolean {
if (field.disabled) return false
return isFilterBranch(field) ? Boolean(field.selectable) : true
}
/**
* Whether the SHIPPED pickers may commit a field: leaves only. A row that both
* drills in and commits cannot disambiguate a click - its chevron and child
* count promise "opens a list", so the press that opened it also filtered on
* the parent and dismissed the picker. `selectable` stays honoured by
* `isFilterFieldSelectable` for a chrome that separates the two.
*/
export function isFilterFieldPickable<V, O>(field: FilterField<V, O>): boolean {
if (field.disabled) return false
return !isFilterBranch(field)
}
/** Trailing count for a branch row. */
export function getFilterFieldCount<V, O>(field: FilterField<V, O>): number {
return field.count ?? field.fields?.length ?? 0
}
/** Renders a path as "Name > First" using the caller's separator. */
export function formatFilterPath<V, O>(
index: FilterIndex<V, O>,
path: readonly string[],
separator: string
): string {
const chain = getFilterFieldChain(index, path)
if (chain.length === 0) return path.join(separator)
return chain.map((field) => field.label).join(separator)
}
/** How a path is shortened; the cascader's own union, so the two cannot drift. */
export type FilterPathCollapse = CascaderCollapse
export type FilterPathSegment<V = unknown, O = unknown> =
| { type: "field"; field: FilterField<V, O> }
/** The run that was elided, kept so a host can surface it. */
| { type: "ellipsis"; hidden: FilterField<V, O>[] }
/**
* Shortens an ancestor chain to at most `maxSegments` names, on the cascader's
* arithmetic so the chip's path and the builder's attribute cell cannot round
* differently. The adapter is POSITIONAL because a `FilterField`'s `id` is
* unique only among its siblings, so the index is the only key that survives a
* collapser keyed on `value`. Default `"none"`: no upgrade shortens a path.
*/
export function collapseFilterPath<V, O>(
chain: readonly FilterField<V, O>[],
options: { maxSegments?: number; collapse?: FilterPathCollapse } = {}
): FilterPathSegment<V, O>[] {
const segments = collapseCascaderPath(
chain.map((field, index) => ({ value: String(index), label: field.label })),
{ maxSegments: options.maxSegments, collapse: options.collapse ?? "none" }
)
return segments.map((segment) =>
segment.type === "node"
? { type: "field" as const, field: chain[Number(segment.node.value)] }
: {
type: "ellipsis" as const,
hidden: segment.hidden.map((node) => chain[Number(node.value)]),
}
)
}
/* -------------------------------------------------------------------------- */
/* Combinator */
/* -------------------------------------------------------------------------- */
export type FilterCombinatorSlot = "where" | "toggle" | "echo"
/**
* Which of the three forms the combinator slot before rule `index` takes. A
* group has exactly ONE combinator, so only one slot may be interactive: three
* editable "and"s down a column would imply three independent choices.
*/
export function filterCombinatorSlot(index: number): FilterCombinatorSlot {
if (index === 0) return "where"
return index === 1 ? "toggle" : "echo"
}
/* -------------------------------------------------------------------------- */
/* Matching */
/* -------------------------------------------------------------------------- */
/** `toLocaleLowerCase`, so Turkish dotted and dotless i fold as a reader expects. */
export function foldFilterText(text: string): string {
return text.toLocaleLowerCase()
}
export function normalizeFilterQuery(query: string): string {
return foldFilterText(query.trim())
}
/** Whether a field matches a normalized query, by label or keywords. */
export function matchesFilterQuery<V, O>(
field: FilterField<V, O>,
normalizedQuery: string
): boolean {
if (normalizedQuery === "") return true
if (foldFilterText(field.label).includes(normalizedQuery)) return true
if (field.keywords) {
for (const keyword of field.keywords) {
if (foldFilterText(keyword).includes(normalizedQuery)) return true
}
}
return false
}
/**
* Filters one level, preserving input order. Unused here like
* `searchFilterDeep`: kept so a consumer's own picker need not re-implement the
* fold and the keyword matching.
*/
export function filterFilterLevel<V, O>(
fields: readonly FilterField<V, O>[],
normalizedQuery: string
): FilterField<V, O>[] {
if (normalizedQuery === "") return fields as FilterField<V, O>[]
return fields.filter((field) => matchesFilterQuery(field, normalizedQuery))
}
/**
* Searches every SELECTABLE field at any depth, because a result the user
* cannot pick is a dead end. Unused here - the shipped picker is the cascader
* and delegates deep search to it - but it keeps a consumer's own picker from
* re-implementing the fold, the keyword matching and the selectable rule.
*/
export function searchFilterDeep<V, O>(
index: FilterIndex<V, O>,
normalizedQuery: string,
limit = 200
): { field: FilterField<V, O>; path: string[] }[] {
if (normalizedQuery === "") return []
const results: { field: FilterField<V, O>; path: string[] }[] = []
for (const entry of index.all) {
if (results.length >= limit) break
if (!isFilterFieldSelectable(entry.field)) continue
if (matchesFilterQuery(entry.field, normalizedQuery)) results.push(entry)
}
return results
}
/** Filters an option list by a normalized query, by label or keywords. */
export function filterFilterOptions<O>(
options: readonly FilterOption<O>[],
normalizedQuery: string
): FilterOption<O>[] {
if (normalizedQuery === "") return options as FilterOption<O>[]
return options.filter((option) => {
if (foldFilterText(option.label).includes(normalizedQuery)) return true
if (option.keywords) {
for (const keyword of option.keywords) {
if (foldFilterText(keyword).includes(normalizedQuery)) return true
}
}
return false
})
}
/* -------------------------------------------------------------------------- */
/* Exclusive options */
/* -------------------------------------------------------------------------- */
/**
* The None rule, as pure algebra over two selections: pick the `exclusive`
* option and it is the only pick, pick anything else and it goes. Written
* against the DELTA, not the result, which is what makes it total: an empty
* delta is a removal, including UNTICKING the exclusive row; an exclusive
* arrival is the whole answer; an ordinary arrival drops every exclusive value,
* restored ones included; both at once gives it to the LAST exclusive.
* `isExclusive` is a lookup, not an array, because a query hiding the None row
* while it stays SELECTED would answer "not exclusive" for the value the click
* must clear. Never applied on read - a saved view is the consumer's data - so
* it heals on the first ordinary pick, and only while `isExclusive` can ANSWER:
* a None row on a `loadOptions` field belongs in the static `options`.
*/
export function applyFilterExclusiveSelection(
next: readonly string[],
previous: readonly string[],
isExclusive: (value: string) => boolean
): string[] {
const held = new Set(previous)
const added = next.filter((value) => !held.has(value))
if (added.length === 0) return next as string[]
let arrived: string | null = null
for (const value of added) {
if (isExclusive(value)) arrived = value
}
if (arrived !== null) return [arrived]
const kept = next.filter((value) => !isExclusive(value))
// The same array when nothing was exclusive, which is nearly every call: a
// fresh one would commit a new value on every toggle of an ordinary row.
return kept.length === next.length ? (next as string[]) : kept
}
/* -------------------------------------------------------------------------- */
/* Ids */
/* -------------------------------------------------------------------------- */
/**
* Builds a deterministic id generator. `Date.now()` and `Math.random()`, as the
* old `createFilter` used, give the server and the client different ids for the
* same query, so any page shipping default filters hydrated mismatched.
*/
export function createFilterIdFactory(seed: string): () => string {
let counter = 0
return () => {
counter += 1
return `${seed}${counter}`
}
}
/* -------------------------------------------------------------------------- */
/* Development checks */
/* -------------------------------------------------------------------------- */
const warned = new Set<string>()
/** Warns once per key. Never throws, and is a no-op in production. */
export function warnFilterOnce(key: string, message: string): void {
if (process.env.NODE_ENV === "production") return
if (warned.has(key)) return
warned.add(key)
console.warn(`[filters] ${message}`)
}
/** Clears the warn-once memory. For tests. */
export function resetFilterWarnings(): void {
warned.clear()
}
export interface FilterSchemaIssues {
/** Ids that are empty, or that collide with a sibling. */
duplicatePaths: string[]
emptyIds: string[]
/** Branches that declare `selectable` but hold no children. */
emptyBranches: string[]
/** Fields whose `defaultOperator` is not in their operator list. */
unknownDefaultOperators: string[]
}
/** Reported, never thrown: a bad schema must degrade, not blank the page. */
export function findFilterSchemaIssues<V, O>(
fields: readonly FilterField<V, O>[],
resolveOperators: (field: FilterField<V, O>) => FilterOperator[]
): FilterSchemaIssues {
const duplicatePaths: string[] = []
const emptyIds: string[] = []
const emptyBranches: string[] = []
const unknownDefaultOperators: string[] = []
const walk = (list: readonly FilterField<V, O>[], parentPath: string[]) => {
const seen = new Set<string>()
for (const field of list) {
const label = [...parentPath, field.id].join(".")
if (!field.id) emptyIds.push(label)
else if (seen.has(field.id)) duplicatePaths.push(label)
seen.add(field.id)
if (field.selectable && field.fields && field.fields.length === 0) {
emptyBranches.push(label)
}
if (field.defaultOperator) {
const operators = resolveOperators(field)
if (!operators.some((op) => op.value === field.defaultOperator)) {
unknownDefaultOperators.push(`${label} -> ${field.defaultOperator}`)
}
}
if (field.fields?.length) walk(field.fields, [...parentPath, field.id])
}
}
walk(fields, [])
return { duplicatePaths, emptyIds, emptyBranches, unknownDefaultOperators }
}
@@ -0,0 +1,227 @@
import type {
FilterField,
FilterOperator,
FilterValueType,
} from "@/components/reui/filters/filters-types"
/**
* Every operator label, keyed by value. Split from `FilterLabels`: operator
* wording is reworded per domain, chrome copy is translated once per app.
*/
export type FilterOperatorLabels = Record<string, string>
export const DEFAULT_FILTER_OPERATOR_LABELS: FilterOperatorLabels = {
contains: "contains",
not_contains: "does not contain",
starts_with: "starts with",
ends_with: "ends with",
is: "is",
is_not: "is not",
is_any_of: "is any of",
is_none_of: "is none of",
has_any_of: "has any of",
has_all_of: "has all of",
has_none_of: "has none of",
eq: "equals",
neq: "does not equal",
gt: "is greater than",
gte: "is greater than or equal to",
lt: "is less than",
lte: "is less than or equal to",
between: "is between",
not_between: "is not between",
is_before: "is before",
is_after: "is after",
is_on_or_before: "is on or before",
is_on_or_after: "is on or after",
empty: "is empty",
not_empty: "is not empty",
}
/**
* Operator catalog per value type: value, arity, inverse, labels resolved at
* build time so translating one operator never restates the catalog. `arity`
* of "none" drops the chip's value segment and the wizard's value step, in a
* consumer's own `operators` array as much as in this catalog.
*/
const CATALOG: Record<
FilterValueType,
{ value: string; arity?: FilterOperator["arity"]; inverse?: string }[]
> = {
text: [
{ value: "contains", inverse: "not_contains" },
{ value: "not_contains", inverse: "contains" },
{ value: "starts_with" },
{ value: "ends_with" },
{ value: "is", inverse: "is_not" },
{ value: "is_not", inverse: "is" },
{ value: "empty", arity: "none", inverse: "not_empty" },
{ value: "not_empty", arity: "none", inverse: "empty" },
],
number: [
{ value: "eq", inverse: "neq" },
{ value: "neq", inverse: "eq" },
{ value: "gt", inverse: "lte" },
{ value: "gte", inverse: "lt" },
{ value: "lt", inverse: "gte" },
{ value: "lte", inverse: "gt" },
{ value: "between", arity: "range", inverse: "not_between" },
{ value: "not_between", arity: "range", inverse: "between" },
{ value: "empty", arity: "none", inverse: "not_empty" },
{ value: "not_empty", arity: "none", inverse: "empty" },
],
range: [
{ value: "between", arity: "range", inverse: "not_between" },
{ value: "not_between", arity: "range", inverse: "between" },
{ value: "empty", arity: "none", inverse: "not_empty" },
{ value: "not_empty", arity: "none", inverse: "empty" },
],
select: [
{ value: "is", inverse: "is_not" },
{ value: "is_not", inverse: "is" },
{ value: "is_any_of", arity: "many", inverse: "is_none_of" },
{ value: "is_none_of", arity: "many", inverse: "is_any_of" },
{ value: "empty", arity: "none", inverse: "not_empty" },
{ value: "not_empty", arity: "none", inverse: "empty" },
],
multiselect: [
{ value: "has_any_of", arity: "many", inverse: "has_none_of" },
{ value: "has_all_of", arity: "many" },
{ value: "has_none_of", arity: "many", inverse: "has_any_of" },
{ value: "empty", arity: "none", inverse: "not_empty" },
{ value: "not_empty", arity: "none", inverse: "empty" },
],
boolean: [
{ value: "is", inverse: "is_not" },
{ value: "is_not", inverse: "is" },
{ value: "empty", arity: "none", inverse: "not_empty" },
{ value: "not_empty", arity: "none", inverse: "empty" },
],
}
export const DEFAULT_FILTER_VALUE_TYPE: FilterValueType = "text"
/**
* Builds the operator catalog for one set of labels. Callers memoize on the
* label object: the old primitive built its equivalent inside
* `getOperatorsForField`, keyed on a `values` array that was fresh after every
* filter update, so every miss reallocated four arrays of 27 objects each.
*/
export function createFilterOperators(
labels: FilterOperatorLabels
): Record<FilterValueType, FilterOperator[]> {
const built = {} as Record<FilterValueType, FilterOperator[]>
for (const type of Object.keys(CATALOG) as FilterValueType[]) {
built[type] = CATALOG[type].map((entry) => ({
value: entry.value,
label: labels[entry.value] ?? entry.value,
arity: entry.arity ?? "one",
inverse: entry.inverse,
}))
}
return built
}
export const DEFAULT_FILTER_OPERATORS: Record<
FilterValueType,
FilterOperator[]
> = createFilterOperators(DEFAULT_FILTER_OPERATOR_LABELS)
/**
* The operators for a field: its own `operators` wins, array or function,
* otherwise the catalog entry for its `type`. No implicit select to multiselect
* promotion by selection count: that changed the list as the user picked.
*/
export function resolveFilterOperators<V, O>(
field: FilterField<V, O>,
catalog: Record<FilterValueType, FilterOperator[]> = DEFAULT_FILTER_OPERATORS
): FilterOperator[] {
if (typeof field.operators === "function") return field.operators(field)
if (field.operators) return field.operators
return catalog[field.type ?? DEFAULT_FILTER_VALUE_TYPE] ?? catalog.text
}
export function visibleFilterOperators(
operators: readonly FilterOperator[]
): FilterOperator[] {
return operators.filter((operator) => !operator.hidden)
}
export function getFilterOperator(
operators: readonly FilterOperator[],
value: string | null | undefined
): FilterOperator | undefined {
if (!value) return undefined
return operators.find((operator) => operator.value === value)
}
export function getFilterArity(
operator: FilterOperator | undefined
): FilterOperator["arity"] {
return operator?.arity ?? "one"
}
export function operatorTakesValue(
operator: FilterOperator | undefined
): boolean {
return getFilterArity(operator) !== "none"
}
/**
* The operator a field starts with: `defaultOperator` when it names one the
* field offers, otherwise the first visible operator. Falling back rather than
* trusting the schema keeps the rule out of a state its own list cannot show.
*/
export function getDefaultFilterOperator<V, O>(
field: FilterField<V, O>,
operators: readonly FilterOperator[]
): string | null {
if (field.defaultOperator) {
const named = getFilterOperator(operators, field.defaultOperator)
if (named) return named.value
}
const visible = visibleFilterOperators(operators)
return visible[0]?.value ?? operators[0]?.value ?? null
}
/**
* Negating a rule: swap to the declared `inverse` so the chip reads "does not
* contain", else toggle `negated` so Negate still works without an inverse.
*/
export function negateFilterOperator(
operator: FilterOperator | undefined,
operators: readonly FilterOperator[],
negated: boolean | undefined
): { operator: string | null; negated: boolean } {
if (operator?.inverse) {
const inverse = getFilterOperator(operators, operator.inverse)
if (inverse) return { operator: inverse.value, negated: Boolean(negated) }
}
return { operator: operator?.value ?? null, negated: !negated }
}
/**
* Reshapes a value when arity changes: widening "is" to "is any of" keeps what
* the user picked, narrowing keeps the first of them, `"none"` always drops it.
*/
export function coerceFilterValue(
value: unknown,
from: FilterOperator | undefined,
to: FilterOperator | undefined
): unknown {
const nextArity = getFilterArity(to)
if (nextArity === "none") return undefined
if (value === undefined || value === null) return value
const previousArity = getFilterArity(from)
if (previousArity === nextArity) return value
const asArray = Array.isArray(value) ? value : [value]
if (nextArity === "many") return asArray
if (nextArity === "one") return asArray[0]
if (nextArity === "range") {
return [asArray[0], asArray[1]] as [unknown, unknown]
}
return value
}
@@ -0,0 +1,701 @@
import type {
FilterCombinator,
FilterGroupNode,
FilterIssue,
FilterNode,
FilterOperator,
FilterQuery,
FilterRule,
} from "@/components/reui/filters/filters-types"
export function isFilterRule<V>(node: FilterNode<V>): node is FilterRule<V> {
return node.type === "rule"
}
export function isFilterGroup<V>(
node: FilterNode<V>
): node is FilterGroupNode<V> {
return node.type === "group"
}
/**
* A rule. `id` is passed in, never generated here: a non-deterministic value in
* a pure function broke hydration. Callers use `createFilterIdFactory`.
*/
export function createFilterRule<V = unknown>(input: {
id: string
path: string[]
operator: string
value?: V
negated?: boolean
}): FilterRule<V> {
const rule: FilterRule<V> = {
id: input.id,
type: "rule",
path: input.path,
operator: input.operator,
value: input.value,
}
if (input.negated) rule.negated = true
return rule
}
export function createFilterGroup<V = unknown>(input: {
id: string
combinator?: FilterCombinator
rules?: FilterNode<V>[]
}): FilterGroupNode<V> {
return {
id: input.id,
type: "group",
combinator: input.combinator ?? "and",
rules: input.rules ?? [],
}
}
/** An empty query. The root is always a group, never a bare array. */
export function createFilterQuery<V = unknown>(
rules: FilterNode<V>[] = [],
combinator: FilterCombinator = "and",
id = "root"
): FilterQuery<V> {
return { id, type: "group", combinator, rules }
}
/** Every rule in the tree, depth first. Groups are flattened away. */
export function flattenFilterRules<V>(query: FilterQuery<V>): FilterRule<V>[] {
const out: FilterRule<V>[] = []
const walk = (node: FilterNode<V>) => {
if (isFilterRule(node)) {
out.push(node)
return
}
for (const child of node.rules) walk(child)
}
walk(query)
return out
}
/**
* One rule, flattened for a predicate. `values` is always an array even though
* `FilterRule.value` is singular, so no caller re-derives arity.
*/
export interface FilterCondition {
/** Full field path, `["name", "first"]`. */
path: string[]
/** First path segment, for the common flat-schema case. */
field: string
operator: string
/** `[]` for an operator that takes no value. */
values: unknown[]
negated: boolean
}
/**
* Whether a rule says anything yet. A rule exists as soon as an attribute is
* picked, so `operator: ""` is a real state: `flattenFilterConditions` leaves
* it out, `countFilterRules` still counts it, and `collectFilterIssues` reports
* it as `missing-operator`.
*/
export function isFilterRuleComplete<V>(rule: FilterRule<V>): boolean {
return rule.operator !== ""
}
/**
* Flattens a query to conditions. Lossy: safe only when the query is flat or
* every group shares the root's combinator. Read `query.combinator` and walk
* the tree yourself for anything else. Incomplete rules are left out.
*/
export function flattenFilterConditions<V>(
query: FilterQuery<V>
): FilterCondition[] {
return flattenFilterRules(query)
.filter(isFilterRuleComplete)
.map((rule) => ({
path: rule.path,
field: rule.path[0],
operator: rule.operator,
values:
rule.value === undefined || rule.value === null
? []
: Array.isArray(rule.value)
? (rule.value as unknown[])
: [rule.value],
negated: Boolean(rule.negated),
}))
}
/** How many rules the query holds, at any depth. */
export function countFilterRules<V>(query: FilterQuery<V>): number {
let count = 0
const walk = (node: FilterNode<V>) => {
if (isFilterRule(node)) {
count += 1
return
}
for (const child of node.rules) walk(child)
}
walk(query)
return count
}
/** Whether the query would match everything. */
export function isFilterQueryEmpty<V>(query: FilterQuery<V>): boolean {
return countFilterRules(query) === 0
}
/**
* How an arity is answered for one rule. The caller resolves it, since arity
* lives on the FIELD's operator list. `null` is a rule it cannot judge.
*/
export type FilterArityResolver<V> = (
rule: FilterRule<V>
) => FilterOperator["arity"] | null
/** Runs a field's own `validate`. A resolver like `arityOf`: no schema here. */
export type FilterValidateResolver<V> = (
rule: FilterRule<V>
) => string | null | undefined | false
/** A value slot the user has not filled in. `false` and `0` are values. */
function isBlankFilterValue(value: unknown): boolean {
return value === undefined || value === null || value === ""
}
/**
* Compares two range bounds, or gives up. Numbers, dates and ISO strings only:
* inventing an order for a range of colour names would flag a correct filter as
* reversed. `Date.parse` must parse BOTH strings before they are compared.
*/
function compareFilterBounds(from: unknown, to: unknown): number | null {
if (typeof from === "number" && typeof to === "number") {
return Number.isNaN(from) || Number.isNaN(to) ? null : from - to
}
if (from instanceof Date && to instanceof Date) {
const a = from.getTime()
const b = to.getTime()
return Number.isNaN(a) || Number.isNaN(b) ? null : a - b
}
if (typeof from === "string" && typeof to === "string") {
const a = Date.parse(from)
const b = Date.parse(to)
return Number.isNaN(a) || Number.isNaN(b) ? null : a - b
}
return null
}
/**
* Every reason a query cannot be run as written, in document order: the five
* ways this builder can hold a condition that SILENTLY does the wrong thing.
* `missing-operator` and `empty-group` carry no predicate and are dropped by
* `flattenFilterConditions`; `missing-value` reaches the consumer with a
* `values` array that is empty or all blank, which most backends read as
* "match nothing"; `incomplete-range` is the same for a range and often
* arrives with one bound FILLED, so an emptiness check will not catch it;
* `reversed-range` is legal and matches nothing anywhere. A group of exactly
* ONE node is NOT an issue (it is what "Convert to group" produces), and the
* root is exempt from `empty-group`.
*/
export function collectFilterIssues<V>(
query: FilterQuery<V>,
arityOf: FilterArityResolver<V>,
validateOf?: FilterValidateResolver<V>
): FilterIssue[] {
const issues: FilterIssue[] = []
const visit = (group: FilterGroupNode<V>, isRoot: boolean) => {
if (!isRoot && group.rules.length === 0) {
issues.push({
nodeId: group.id,
column: "group",
reason: "empty-group",
})
}
for (const child of group.rules) {
if (isFilterGroup(child)) {
visit(child, false)
continue
}
// Asked FIRST: `null` means the caller cannot judge this rule at all, so
// even `missing-operator` would point at a control that is not on screen.
const arity = arityOf(child)
if (arity === null) continue
if (!isFilterRuleComplete(child)) {
issues.push({
nodeId: child.id,
column: "operator",
reason: "missing-operator",
})
continue
}
if (arity === "none") continue
const values =
child.value === undefined || child.value === null
? []
: Array.isArray(child.value)
? (child.value as unknown[])
: [child.value]
if (arity === "range") {
if (
values.length < 2 ||
isBlankFilterValue(values[0]) ||
isBlankFilterValue(values[1])
) {
issues.push({
nodeId: child.id,
column: "value",
reason: "incomplete-range",
})
continue
}
const order = compareFilterBounds(values[0], values[1])
if (order !== null && order > 0) {
issues.push({
nodeId: child.id,
column: "value",
reason: "reversed-range",
})
}
continue
}
// `many` and `one` collapse here: both are unsatisfied by an empty list,
// and `one` normalises to a single-element list above.
if (values.length === 0 || values.every(isBlankFilterValue)) {
issues.push({
nodeId: child.id,
column: "value",
reason: "missing-value",
})
continue
}
// LAST, and only on a rule the primitive is already happy with: stacking
// a second message on one cell breaks the one-issue-per-node shape.
const message = validateOf?.(child)
if (message) {
issues.push({
nodeId: child.id,
column: "value",
reason: "custom",
message,
})
}
}
}
visit(query, true)
return issues
}
/** Locates a node and its parent. Returns null when the id is unknown. */
export function findFilterNode<V>(
query: FilterQuery<V>,
id: string
): {
node: FilterNode<V>
parent: FilterGroupNode<V> | null
index: number
} | null {
if (query.id === id) return { node: query, parent: null, index: -1 }
const walk = (
group: FilterGroupNode<V>
): {
node: FilterNode<V>
parent: FilterGroupNode<V>
index: number
} | null => {
for (let i = 0; i < group.rules.length; i++) {
const child = group.rules[i]
if (child.id === id) return { node: child, parent: group, index: i }
if (isFilterGroup(child)) {
const found = walk(child)
if (found) return found
}
}
return null
}
return walk(query)
}
/** The rule with this id, or null when the id names a group or is unknown. */
export function findFilterRule<V>(
query: FilterQuery<V>,
id: string
): FilterRule<V> | null {
const found = findFilterNode(query, id)
if (!found || !isFilterRule(found.node)) return null
return found.node
}
/**
* Rebuilds the tree, applying `transform` to the group holding `id`. An
* unchanged subtree comes back BY IDENTITY, so `React.memo` holds for all but
* the moved chip. Tests assert it with `toBe`. `removeFilterNode` and
* `detachFilterNode` drop children rather than replace a group, so they
* hand-roll the same identity-preserving walk.
*/
function rewriteGroup<V>(
group: FilterGroupNode<V>,
shouldRewrite: (group: FilterGroupNode<V>) => boolean,
transform: (group: FilterGroupNode<V>) => FilterGroupNode<V>
): FilterGroupNode<V> {
if (shouldRewrite(group)) return transform(group)
let changed = false
const rules = group.rules.map((child) => {
if (!isFilterGroup(child)) return child
const next = rewriteGroup(child, shouldRewrite, transform)
if (next !== child) changed = true
return next
})
return changed ? { ...group, rules } : group
}
/** Replaces a rule's fields. Unknown ids return the query unchanged. */
export function updateFilterRule<V>(
query: FilterQuery<V>,
id: string,
updates: Partial<Omit<FilterRule<V>, "id" | "type">>
): FilterQuery<V> {
return rewriteGroup(
query,
(group) =>
group.rules.some((child) => child.id === id && isFilterRule(child)),
(group) => ({
...group,
rules: group.rules.map((child) =>
child.id === id && isFilterRule(child)
? { ...child, ...updates }
: child
),
})
) as FilterQuery<V>
}
/**
* Removes a node, plus any group it empties, all the way up (not the root): an
* empty group is invisible in the flat UI yet still compiles to parentheses.
*/
export function removeFilterNode<V>(
query: FilterQuery<V>,
id: string
): FilterQuery<V> {
const prune = (group: FilterGroupNode<V>): FilterGroupNode<V> => {
let changed = false
const rules: FilterNode<V>[] = []
for (const child of group.rules) {
if (child.id === id) {
changed = true
continue
}
if (isFilterGroup(child)) {
const next = prune(child)
if (next !== child) changed = true
if (next.rules.length === 0) continue
rules.push(next)
continue
}
rules.push(child)
}
return changed ? { ...group, rules } : group
}
return prune(query) as FilterQuery<V>
}
/** Appends a node to a group, defaulting to the root. */
export function insertFilterNode<V>(
query: FilterQuery<V>,
node: FilterNode<V>,
parentId?: string,
index?: number
): FilterQuery<V> {
const targetId = parentId ?? query.id
return rewriteGroup(
query,
(group) => group.id === targetId,
(group) => {
const rules = [...group.rules]
const at =
index === undefined
? rules.length
: Math.max(0, Math.min(index, rules.length))
rules.splice(at, 0, node)
return { ...group, rules }
}
) as FilterQuery<V>
}
/**
* A deep copy under fresh ids, the whole way down: children keeping their old
* ids would give two live nodes one id, and every lookup here is by id.
*/
function cloneFilterNode<V>(
node: FilterNode<V>,
nextId: () => string
): FilterNode<V> {
return isFilterRule(node)
? { ...node, id: nextId() }
: {
...node,
id: nextId(),
rules: node.rules.map((child) => cloneFilterNode(child, nextId)),
}
}
/** Copies a node in beside the original. */
export function duplicateFilterNode<V>(
query: FilterQuery<V>,
id: string,
nextId: () => string
): FilterQuery<V> {
const found = findFilterNode(query, id)
if (!found || !found.parent) return query
return insertFilterNode(
query,
cloneFilterNode(found.node, nextId),
found.parent.id,
found.index + 1
)
}
export function setFilterCombinator<V>(
query: FilterQuery<V>,
groupId: string,
combinator: FilterCombinator
): FilterQuery<V> {
return rewriteGroup(
query,
(group) => group.id === groupId,
(group) =>
group.combinator === combinator ? group : { ...group, combinator }
) as FilterQuery<V>
}
export function toggleFilterCombinator<V>(
query: FilterQuery<V>,
groupId: string
): FilterQuery<V> {
const found = findFilterNode(query, groupId)
if (!found || !isFilterGroup(found.node)) return query
return setFilterCombinator(
query,
groupId,
found.node.combinator === "and" ? "or" : "and"
)
}
/** Moves a node within its own group. Out of range moves are no-ops. */
export function moveFilterNode<V>(
query: FilterQuery<V>,
id: string,
delta: number
): FilterQuery<V> {
const found = findFilterNode(query, id)
if (!found || !found.parent) return query
const from = found.index
const to = from + delta
if (to < 0 || to >= found.parent.rules.length || delta === 0) return query
return rewriteGroup(
query,
(group) => group.id === found.parent!.id,
(group) => {
const rules = [...group.rules]
const [moved] = rules.splice(from, 1)
rules.splice(to, 0, moved)
return { ...group, rules }
}
) as FilterQuery<V>
}
/** Whether `id` names `node` itself or anything beneath it. */
function containsFilterNode<V>(node: FilterNode<V>, id: string): boolean {
if (node.id === id) return true
if (isFilterRule(node)) return false
return node.rules.some((child) => containsFilterNode(child, id))
}
/**
* Removes a node WITHOUT pruning what it empties. A move cannot reuse
* `removeFilterNode`: it would delete the group the drop is aimed at.
*/
function detachFilterNode<V>(
group: FilterGroupNode<V>,
id: string
): FilterGroupNode<V> {
let changed = false
const rules: FilterNode<V>[] = []
for (const child of group.rules) {
if (child.id === id) {
changed = true
continue
}
if (isFilterGroup(child)) {
const next = detachFilterNode(child, id)
if (next !== child) changed = true
rules.push(next)
continue
}
rules.push(child)
}
return changed ? { ...group, rules } : group
}
/**
* Moves a node into another group, at an index: the cross-parent form of
* `moveFilterNode`, which only reorders within one parent. Refuses a group into
* itself or a descendant, which would leave a cycle, and refuses the root.
*/
export function moveFilterNodeTo<V>(
query: FilterQuery<V>,
id: string,
parentId: string,
index: number
): FilterQuery<V> {
const found = findFilterNode(query, id)
if (!found || !found.parent) return query
if (containsFilterNode(found.node, parentId)) return query
const destination = findFilterNode(query, parentId)
if (!destination || !isFilterGroup(destination.node)) return query
// Within one parent the node's own slot disappears when it detaches, so
// indexes after it shift down by one: without this a drag down is a no-op.
const sameParent = found.parent.id === parentId
const target = sameParent && found.index < index ? index - 1 : index
if (sameParent && target === found.index) return query
return insertFilterNode(
detachFilterNode(query, id),
found.node,
parentId,
target
)
}
/**
* Copies a node into a group at a position: the Alt path of the drag layer. Not
* `duplicateFilterNode` then `moveFilterNodeTo`, which emits two queries for one
* gesture and needs the id the first step minted and never returned. The clone
* is taken BEFORE the insert, so copying a group into itself stays finite.
*/
export function copyFilterNodeTo<V>(
query: FilterQuery<V>,
id: string,
parentId: string,
index: number,
nextId: () => string
): FilterQuery<V> {
const found = findFilterNode(query, id)
if (!found || !found.parent) return query
const destination = findFilterNode(query, parentId)
if (!destination || !isFilterGroup(destination.node)) return query
return insertFilterNode(
query,
cloneFilterNode(found.node, nextId),
parentId,
index
)
}
/**
* Wraps a node in a new group: the "Wrap in condition group" action, and the
* keyboard path to nesting for a user who cannot drag.
*/
export function wrapFilterNodeInGroup<V>(
query: FilterQuery<V>,
id: string,
groupId: string,
combinator: FilterCombinator = "or"
): FilterQuery<V> {
const found = findFilterNode(query, id)
if (!found || !found.parent) return query
return rewriteGroup(
query,
(group) => group.id === found.parent!.id,
(group) => ({
...group,
rules: group.rules.map((child) =>
child.id === id
? createFilterGroup<V>({ id: groupId, combinator, rules: [child] })
: child
),
})
) as FilterQuery<V>
}
/**
* Dissolves a group into its parent, splicing its rules in at the position the
* group held so wrap and unwrap round-trip. The root, a rule id and an unknown
* id each return the query unchanged.
*/
export function unwrapFilterGroup<V>(
query: FilterQuery<V>,
groupId: string
): FilterQuery<V> {
if (query.id === groupId) return query
const found = findFilterNode(query, groupId)
if (!found || !found.parent || !isFilterGroup(found.node)) return query
const dissolved = found.node
return rewriteGroup(
query,
(group) => group.id === found.parent!.id,
(group) => {
const rules = [...group.rules]
rules.splice(found.index, 1, ...dissolved.rules)
return { ...group, rules }
}
) as FilterQuery<V>
}
/** Empties the query, keeping the root's identity fields. */
export function clearFilterQuery<V>(query: FilterQuery<V>): FilterQuery<V> {
return query.rules.length === 0 ? query : { ...query, rules: [] }
}
/**
* Drops empty groups and collapses a group whose only child is a group. Not
* automatic: a user mid-edit may hold an almost-empty group. Call on persist.
*/
export function pruneFilterQuery<V>(query: FilterQuery<V>): FilterQuery<V> {
const prune = (node: FilterNode<V>): FilterNode<V> | null => {
if (isFilterRule(node)) return node
const rules: FilterNode<V>[] = []
for (const child of node.rules) {
const next = prune(child)
if (next) rules.push(next)
}
if (rules.length === 0) return null
if (rules.length === 1 && isFilterGroup(rules[0])) return rules[0]
return { ...node, rules }
}
const rules: FilterNode<V>[] = []
for (const child of query.rules) {
const next = prune(child)
if (next) rules.push(next)
}
return { ...query, rules }
}
@@ -0,0 +1,566 @@
import type * as React from "react"
/* -------------------------------------------------------------------------- */
/* Query tree */
/* -------------------------------------------------------------------------- */
export type FilterCombinator = "and" | "or"
/**
* One condition. `path` maps one to one onto the cascader's `details.path`, so
* a field selection commits untranslated. `value` is SINGULAR and the
* operator's `arity` decides its shape: `"many"` holds an array, `"range"` a
* tuple, `"none"` undefined.
*/
export interface FilterRule<V = unknown> {
id: string
type: "rule"
/** Field path, root first. `["name", "first"]` for a nested attribute. */
path: string[]
operator: string
value: V | undefined
/** Flips the meaning in place. Set by Negate when there is no `inverse`. */
negated?: boolean
}
/** Rules under one combinator; nests, so nesting is never a later break. */
export interface FilterGroupNode<V = unknown> {
id: string
type: "group"
combinator: FilterCombinator
rules: FilterNode<V>[]
}
export type FilterNode<V = unknown> = FilterRule<V> | FilterGroupNode<V>
/** A whole query. Always a group, so flat and nested are one code path. */
export type FilterQuery<V = unknown> = FilterGroupNode<V>
export type FilterChangeReason =
| "add"
| "update"
| "remove"
| "duplicate"
| "negate"
| "reorder"
| "combinator"
| "clear"
/** Second argument to `onQueryChange`, so nobody has to diff two trees. */
export interface FilterChangeDetails<V = unknown, O = unknown> {
reason: FilterChangeReason
/** The rule that changed, or null for whole-query changes like `clear`. */
rule: FilterRule<V> | null
/** The field the rule points at, resolved. Null when the path is unknown. */
field: FilterField<V, O> | null
}
/* -------------------------------------------------------------------------- */
/* Operators */
/* -------------------------------------------------------------------------- */
/**
* How many values an operator takes. Answers "does this need a value editor"
* for every operator, a consumer's own included, in place of the hardcoded
* `operator === "empty"` checks it replaced.
*/
export type FilterOperatorArity = "none" | "one" | "many" | "range"
export interface FilterOperator {
value: string
label: string
/** Defaults to `"one"`. */
arity?: FilterOperatorArity
/** The opposite operator. Negate flips to it, else sets `rule.negated`. */
inverse?: string
/** Hidden from the operator list but still valid in a restored query. */
hidden?: boolean
}
/* -------------------------------------------------------------------------- */
/* Fields */
/* -------------------------------------------------------------------------- */
/**
* Which built-in editor a field uses by DEFAULT: `editor` overrides it, and an
* operator may override both. No date type on purpose - every product wants a
* different date control, and a built-in one would add a calendar dependency to
* every install, so a date ships as an `editor`.
*/
export type FilterValueType =
| "text"
| "number"
| "range"
| "select"
| "multiselect"
| "boolean"
export interface FilterOption<O = unknown> {
value: string
label: string
icon?: React.ReactNode
description?: string
keywords?: string[]
disabled?: boolean
/**
* The row that means NONE OF THE ABOVE: Unassigned, No label, No due date.
* Picking it clears every other pick and picking anything else clears it, and
* it is drawn apart under a rule of its own, because a list that wipes a
* selection must not look like one that does not; hide that line with
* `"[&_[data-slot=filter-menu-divider]]:hidden"`. Applied under EVERY
* operator, negative ones included. The value must be RESOLVABLE, since the
* rule runs through the option service: an option that exists only in an
* unfetched `loadOptions` page is invisible to it.
*/
exclusive?: boolean
/** Arbitrary payload, carried untouched through every render callback. */
data?: O
}
export interface FilterLoadContext {
/** Aborted when the query changes, the editor closes, or a load supersedes. */
signal: AbortSignal
cursor?: string
}
/** Result of `loadOptions`, or a bare array. Shape-locked to the cascader. */
export interface FilterLoadResult<O = unknown> {
items: FilterOption<O>[]
nextCursor?: string
/** Defaults to whether `nextCursor` was supplied. */
hasMore?: boolean
}
/** A field, or a branch. A group is a field with `fields`, not `selectable`. */
export interface FilterField<V = unknown, O = unknown> {
/** Stable id. Unique among its siblings; the full path must be unique. */
id: string
label: string
icon?: React.ReactNode
description?: string
keywords?: string[]
/** Trailing count. Defaults to known children; set it when they are lazy. */
count?: number
fields?: FilterField<V, O>[]
/** Whether a BRANCH is itself filterable. Leaves always are. */
selectable?: boolean
disabled?: boolean
type?: FilterValueType
options?: FilterOption<O>[]
/** Async options, paged via `cursor`. Any `options` also seed the cache. */
loadOptions?: (
query: string,
context: FilterLoadContext
) => FilterOption<O>[] | Promise<FilterOption<O>[] | FilterLoadResult<O>>
/** Resolves stored values the loader never returned, for restored chips. */
resolveValues?: (
values: string[]
) => FilterOption<O>[] | Promise<FilterOption<O>[]>
/** Operators for this field. Falls back to the catalog for its `type`. */
operators?:
| FilterOperator[]
| ((field: FilterField<V, O>) => FilterOperator[])
/**
* The operator this field FALLS BACK to, not a way to preselect one. Both
* create paths deliberately start a rule on `operator: ""` so the condition
* menu opens with nothing chosen, so this is read when an existing rule
* changes field, and it is the value `FilterFieldPicker` hands to `onSelect`.
* Ignored, with a dev warning, when it names an operator the field does not
* offer; the first visible operator wins instead.
*/
defaultOperator?: string
/** Overrides the editor chosen from `type`. See `FilterEditorProps`. */
editor?: FilterEditorRef<V, O>
renderValue?: (context: FilterValueDisplayContext<V, O>) => React.ReactNode
/** The value as PLAIN TEXT for a11y; the default is `String(value)`. */
valueText?: (context: FilterValueDisplayContext<V, O>) => string
/** Placeholder for the value editor's input, or an option list's search. */
placeholder?: string
/** SHOWS the search box (default true). Off keeps it, hidden: it owns focus. */
searchable?: boolean
/**
* Whether an option-backed editor STACKS the picks at the top of its list.
* Off by default, because a short closed list is read as a whole and lifting
* a row out of a memorised order costs more than it buys. The partition is
* taken LIVE unless `sortSelected: "snapshot"`, so ticking a row moves the
* rows below it and the highlight is carried across by VALUE. Exclusive
* options never join the stack; they are grouped by ROLE.
*/
pinSelected?: boolean
/**
* How an option list is ordered INSIDE each group, plus WHEN the partition is
* taken under `pinSelected`. With pinning off there is no partition, so
* `"snapshot"` is the same thing as `"none"`.
*
* - `"none"` (default) keeps declaration order. Not alphabetical, because
* option order is usually semantic (To do, In progress, Done).
* - `"label"` sorts with `localeCompare`, so "Ålesund" files next to
* "Alesund" rather than after "Zurich".
* - `"snapshot"` keeps declaration order too, and freezes the partition as
* the menu opened it: a steady pointer target where the other two re-pin
* live.
*/
sortSelected?: "none" | "label" | "snapshot"
/**
* This field's own validity check. Return a MESSAGE to mark the value cell
* invalid, or `null` / `undefined` / `false` when the value is fine. A string
* rather than a schema object keeps the primitive library-agnostic.
*
* ORDER MATTERS: the built-in checks run first and this runs only when they
* pass, so a validator never re-answers "is there a value at all". Not called
* for a valueless operator, nor for a rule whose field the schema no longer
* has, and shown only once the user has committed a value to that rule.
*/
validate?: (
context: FilterValidateContext<V, O>
) => string | null | undefined | false
/**
* Reaches the value editor's PANEL. Merged last through tailwind-merge, so a
* `w-*` here beats the default rather than losing to it on source order.
*
* HEIGHT IS A VARIABLE, NOT A UTILITY: a `max-h-*` here bounds the PANEL and
* does nothing to the list inside it, which owns its own `max-height`. Write
* `--cascader-max-height: 28rem` as an arbitrary property instead; the cap is
* the SMALLER of that and the space the popup has.
*/
className?: string
/** The storage column when it differs from the UI path. Never read here. */
column?: string
data?: unknown
}
/* -------------------------------------------------------------------------- */
/* Editors */
/* -------------------------------------------------------------------------- */
/**
* Where an editor renders. The SAME component serves both: `"create"` is the
* wizard step, which has Back and advances on commit, `"amend"` the
* chip-anchored popover, which offers Discard instead.
*/
export type FilterEditorHost = "create" | "amend"
export interface FilterOptionsState<O = unknown> {
items: FilterOption<O>[]
loading: boolean
error: boolean
hasMore: boolean
/** Current search text. Debounced before it reaches `loadOptions`. */
query: string
setQuery: (query: string) => void
loadMore: () => void
retry: () => void
/** Resolves a stored value to its option, from cache when possible. */
resolve: (value: string) => FilterOption<O> | undefined
}
export interface FilterCommitOptions {
/** Dismiss the host after writing. Defaults to true. */
close?: boolean
}
export interface FilterEditorProps<V = unknown, O = unknown> {
field: FilterField<V, O>
operator: FilterOperator
/** The DRAFT value. An editor edits a draft; the host commits it. */
value: V | undefined
onValueChange: (value: V | undefined) => void
host: FilterEditorHost
/**
* Spread onto whichever element should take focus, so no editor reaches for
* `setTimeout`. A CALLBACK ref at `HTMLElement` is the one shape assignable
* to every element's own ref prop, so it spreads onto an input, a slider or a
* button without a cast.
*/
autoFocusProps: {
ref: React.RefCallback<HTMLElement>
autoFocus: boolean
}
/** Accept the draft. `{ close: false }` writes through without dismissing. */
commit: (value?: V, options?: FilterCommitOptions) => void
cancel: () => void
/** Step back. Only meaningful when `host === "create"`. */
back: () => void
options: FilterOptionsState<O>
labels: FilterLabels
}
export type FilterEditor<V = unknown, O = unknown> = React.ComponentType<
FilterEditorProps<V, O>
>
/**
* An editor with its generics erased, for the registry. `unknown` rather than
* `never`: props are contravariant, so a `FilterEditor<never, never>` registry
* accepts nothing at all. The single widening cast happens where it renders.
*/
export type AnyFilterEditor = React.ComponentType<
FilterEditorProps<unknown, unknown>
>
export type FilterEditorRegistry = Record<string, AnyFilterEditor>
/**
* A registered editor's name, or a component. The `any` arm lets a CONCRETE
* editor sit on an unknown-typed field without a cast: props are contravariant,
* so `FilterEditor<DateValue>` is not a `FilterEditor<unknown>`. The widening
* happens once, inside `resolveFilterEditor`.
*/
export type FilterEditorRef<V = unknown, O = unknown> =
| string
| FilterEditor<V, O>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
| FilterEditor<any, any>
/** Context for a custom builder empty state. Its actions ARE the footer's. */
export interface FilterEmptyStateContext {
labels: FilterLabels
/** The bar is locked. A custom state should not offer an action here. */
readOnly: boolean
/** Which box the builder is in, for a state that wants to be denser inline. */
mode: "popover" | "inline"
/** Appends a condition and opens its attribute picker. */
addFilter: () => void
addGroup: () => void
}
export interface FilterValueDisplayContext<V = unknown, O = unknown> {
value: V | undefined
/** `value` normalised to an array, so callbacks never re-derive it. */
values: unknown[]
field: FilterField<V, O>
operator: FilterOperator
/** Options already resolved for `value`, when the field is option-backed. */
options: FilterOption<O>[]
labels: FilterLabels
}
/* -------------------------------------------------------------------------- */
/* Index */
/* -------------------------------------------------------------------------- */
/** Normalized schema, keyed by SIGNATURE: call sites inline the array. */
export interface FilterIndex<V = unknown, O = unknown> {
/** Every field by its joined path, `"name.first"`. */
byPath: Map<string, FilterField<V, O>>
/** Child fields by parent path. Root fields are keyed by `FILTER_ROOT_KEY`. */
childrenOf: Map<string, FilterField<V, O>[]>
/** Parent path by path. Empty string for a root field. */
parentOf: Map<string, string>
/** Every field in stable, depth-first order. Deep search walks it, and the
* builder seeds a new row from the FIRST pickable entry, so order is load
* bearing, not incidental. */
all: { field: FilterField<V, O>; path: string[] }[]
/** Top level fields, in input order. */
roots: FilterField<V, O>[]
/** Content hash; equal schemas share one, so a rebuild reuses the index. */
signature: string
}
/* -------------------------------------------------------------------------- */
/* Draft */
/* -------------------------------------------------------------------------- */
export type FilterDraftStep = "field" | "operator" | "value"
/**
* The in-flight filter. `cascaderPath` is kept SEPARATE from `path`: `path` is
* what the user chose, `cascaderPath` is where they were browsing when they
* chose it, and Back has to return there. Deriving it from `path` breaks the
* moment a deep search jumps across the tree.
*/
export interface FilterDraft<V = unknown> {
step: FilterDraftStep
/** `"ready"` = complete: the host writes it into the query and closes. The
* pure reducer decides that, not a click handler. */
status: "editing" | "ready"
/** Set when amending an existing rule, null when creating a new one. */
ruleId: string | null
/** The chosen field path. Empty until the field step commits. */
path: string[]
cascaderPath: string[]
operator: string | null
value: V | undefined
query: string
}
/* -------------------------------------------------------------------------- */
/* Labels */
/* -------------------------------------------------------------------------- */
/** Every user facing string. `stepAnnouncement` alone is headless-only. */
export interface FilterLabels {
addFilter: string
advancedFilter: string
/** The line above the builder's rows, "In this view, show records". */
showRecords: string
/** Empty-state title. The HINT below is withheld from a read-only bar. */
builderEmpty: string
builderEmptyHint: string
/** Appends a condition to the root group, from the builder's footer. */
addCondition: string
addConditionGroup: string
/** A group's own add button: the only keyboard route into a nested group. It
* NAMES a button that shows `addCondition`, so a translation must contain
* that string, which is what WCAG Label in Name asks for. */
addToGroup: string
removeGroup: string
wrapInGroup: string
/** Dissolves a group into its parent. The inverse of `wrapInGroup`. */
ungroup: string
/** Moves a condition to the root group. The keyboard path to that drag. */
moveToTopLevel: string
/** Groups have no names, so they are numbered in document order, one-based. */
moveToGroup: (position: number) => string
/** Accessible name of a row's or a group's drag handle. */
reorder: string
/** Description on the drag handle, teaching the Alt+Arrow keyboard model. */
reorderHint: string
groupAll: string
groupAny: string
groupPlaceholder: string
/** Names a builder row. Depth is in it: indentation is invisible to AT. */
rowLabel: (condition: string, depth: number) => string
groupLabel: (description: string, depth: number) => string
groupAnnouncement: (added: boolean) => string
/** Alt+Arrow is otherwise silent. The total says whether this is the end. */
reorderAnnouncement: (
label: string,
position: number,
total: number
) => string
/** A cross-PARENT move; a plain reorder announcement cannot be told apart.
* `destination` is the group's own headline, or the bar's label at the top
* level. */
moveAnnouncement: (
label: string,
destination: string,
position: number,
total: number
) => string
clearAll: string
/** Names a group's menu; distinct from `chipMenu`, which is per-rule. */
groupMenu: string
searchFields: string
searchOperators: string
searchOptions: string
back: string
clear: string
apply: string
discard: string
empty: string
loading: string
loadingMore: string
loadMore: string
error: string
retry: string
/** Leading word before the first chip, where a combinator would otherwise go. */
where: string
and: string
or: string
/** Accessible name of the combinator toggle between two chips. */
combinator: string
/** Builder combinator toggle. English "and" wants 58.72px of a 64px track,
* so the name must CONTAIN the word: truncated, the pill shows "a...". */
combinatorLabel: (word: string) => string
duplicate: string
negate: string
/** Chip kebab's route into the builder. Needs `onConvertToAdvanced`. */
convertToAdvanced: string
remove: string
/** Names a chip's menu button. The builder's row menu reads the same key. */
chipMenu: (fieldLabel: string) => string
filtersLabel: string
filterLabel: (condition: string) => string
/** Prose, not ARIA: `aria-readonly` is invalid on toolbar, group and button. */
readOnly: string
/** Joins ancestors in a nested field path, "Name > First", for the names
* `formatFilterPath` builds. The chip draws a decorative chevron instead. */
pathSeparator: string
valuePlaceholder: string
/** Empty word for an OPTION value; `placeholder` is the search prompt. */
selectPlaceholder: string
/** Spoken for an empty value: "contains enter text..." is not a name. */
noValue: string
/** Shown in the operator segment before a condition has been chosen. */
selectCondition: string
/** Appended to a chip with no condition, matching the dashed outline. */
incomplete: string
/** Appended to a branch row's accessible name in the field picker. */
branchAffordance: string
/** A FRAGMENT, appended after a comma to an exclusive row's accessible name.
* Warns before the press; `exclusiveAnnouncement` is the receipt after. */
exclusiveHint: string
/** The clearing moves nothing on screen, so it is otherwise silent. */
exclusiveAnnouncement: (label: string, cleared: number) => string
itemCount: (count: number) => string
fieldsLabel: string
/** Live-region text after a query narrows an option list or the picker. */
resultsAnnouncement: (count: number) => string
/** Accessible name of an option menu's footer (Load more, Retry). */
actionsLabel: string
/** For a CONSUMER-composed wizard; the shipped flow announces counts. */
stepAnnouncement: (step: FilterDraftStep, label: string) => string
countAnnouncement: (count: number) => string
valueCount: (count: number) => string
/** Spells out the list behind the `valueCount` summary, and contains it. */
valueDetail: (summary: string, values: string[]) => string
valueRange: (from: string, to: string) => string
rangeFrom: (fieldLabel: string) => string
rangeTo: (fieldLabel: string) => string
rangeSeparator: string
/** Rendered for a `negated` rule, wrapping the operator label. */
negated: (operatorLabel: string) => string
/** Guidance ("Choose a condition"), not diagnosis: shown three ways over. */
issueOperator: string
issueValue: string
issueRange: string
issueRangeOrder: string
issueEmptyGroup: string
/** The roll-up, and the name of the button that jumps to the first issue. */
issueSummary: (count: number) => string
}
/* -------------------------------------------------------------------------- */
/* Validation */
/* -------------------------------------------------------------------------- */
/** Why a node cannot run as written. `collectFilterIssues` produces these. */
export type FilterIssueReason =
| "missing-operator"
| "missing-value"
| "incomplete-range"
| "reversed-range"
| "empty-group"
/** The one reason whose message is the validator's, not `FilterLabels`. */
| "custom"
export interface FilterIssue {
nodeId: string
/** WHICH cell to mark. Two reasons share the value cell; groups have none. */
column: "operator" | "value" | "group"
reason: FilterIssueReason
/** Set only for `reason: "custom"`; the rest look up `FilterLabels`. */
message?: string
}
/**
* What a field's `validate` is handed. Mirrors `FilterValueDisplayContext`.
* SYNCHRONOUS: issues are collected in a pure memo pass, so a check that has to
* hit a server belongs in the consumer's own submit path.
*/
export interface FilterValidateContext<V = unknown, O = unknown> {
value: V | undefined
values: unknown[]
field: FilterField<V, O>
operator: FilterOperator
/** How many values this operator takes, already resolved. */
arity: FilterOperatorArity
rule: FilterRule<V>
labels: FilterLabels
}
File diff suppressed because it is too large. Load diff
+180
View File
@@ -0,0 +1,180 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@authportal/ui/lib/utils"
/**
* Frame sets --frame-panel-bg and --frame-panel-border-color; FramePanel reads
* them back as bg-(--frame-panel-bg) and border-(--frame-panel-border-color).
* So variant="inverse" re-points every panel from one place, and a consumer's
* own bg-* on a panel still wins on source order, with no :not() and no `!`.
*/
const frameVariants = cva(
[
"relative flex flex-col bg-muted/50 gap-(--frame-gap) px-(--frame-px) py-(--frame-py) rounded-(--frame-radius)",
// Each rung is the radius that style's own shadcn Card resolves through, so
// a Frame and a Card side by side agree. lyra/sera are 0px, NOT
// var(--radius-none): no such token exists, and it only reached 0 by being
// invalid, which also left --frame-radius empty for anything reading it.
"[--frame-radius:var(--radius-xl)]",
"[--frame-gap:--spacing(0.75)] [--frame-px:--spacing(0.75)] [--frame-py:--spacing(0.75)] [--frame-panel-header-gap:0rem] [--frame-panel-footer-gap:--spacing(1)]",
"[--frame-panel-px-adjust:0px] [--frame-panel-py-adjust:0px] [--frame-panel-header-px-adjust:0px] [--frame-panel-header-py-adjust:0px] [--frame-panel-footer-px-adjust:0px] [--frame-panel-footer-py-adjust:0px]",
"[--frame-panel-px:calc(var(--frame-panel-px-base)+var(--frame-panel-px-adjust))] [--frame-panel-py:calc(var(--frame-panel-py-base)+var(--frame-panel-py-adjust))] [--frame-panel-header-px:calc(var(--frame-panel-header-px-base)+var(--frame-panel-header-px-adjust))] [--frame-panel-header-py:calc(var(--frame-panel-header-py-base)+var(--frame-panel-header-py-adjust))] [--frame-panel-footer-px:calc(var(--frame-panel-footer-px-base)+var(--frame-panel-footer-px-adjust))] [--frame-panel-footer-py:calc(var(--frame-panel-footer-py-base)+var(--frame-panel-footer-py-adjust))]",
// Luma alone re-times the frame: wider gap and padding, roomier bars.
"",
"[--frame-panel-bg:var(--color-card)] [--frame-panel-border-color:var(--color-border)] [--frame-border-color:var(--color-border)]",
// Concentric: the panel nests inside the frame's corner rather than copying
// it. It is inset by the 1px border plus --frame-px, so subtracting exactly
// that keeps the two arcs parallel. `ghost` drops the border term (no
// border), `dense` pins it to the frame radius (panels sit flush).
"[--frame-panel-radius:calc(var(--frame-radius)-var(--frame-px)-1px)]",
],
{
variants: {
variant: {
default: "border border-[var(--frame-border-color)] bg-clip-padding",
inverse:
"[--frame-panel-bg:color-mix(in_oklch,var(--color-muted)_40%,transparent)] border border-[var(--frame-border-color)] bg-background bg-clip-padding",
ghost: "[--frame-panel-radius:calc(var(--frame-radius)-var(--frame-px))]",
},
// Bars read as chrome, not a second content block: py runs 0.5/1.5/2/2.5
// against a body py of 2/3.5/4/5, while px stays level with the body so
// header, content and footer left-align. xs floors at 0.5 (2px), below
// which it stops reading as padding. No style-*.css overrides these.
spacing: {
xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(0.5)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(0.5)]",
sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(1.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(1.5)]",
default:
"[--frame-panel-px-base:--spacing(4)] [--frame-panel-py-base:--spacing(4)] [--frame-panel-header-px-base:--spacing(4)] [--frame-panel-header-py-base:--spacing(2)] [--frame-panel-footer-px-base:--spacing(4)] [--frame-panel-footer-py-base:--spacing(2)]",
lg: "[--frame-panel-px-base:--spacing(5)] [--frame-panel-py-base:--spacing(5)] [--frame-panel-header-px-base:--spacing(5)] [--frame-panel-header-py-base:--spacing(2.5)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(2.5)]",
},
stacked: {
true: [
"gap-0 *:has-[+[data-slot=frame-panel]]:rounded-b-none",
"*:has-[+[data-slot=frame-panel]]:before:hidden",
"*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:rounded-t-none",
"*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:border-t-0",
],
false: [
"data-[spacing=sm]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-0.5",
"data-[spacing=default]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-1",
"data-[spacing=lg]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-2",
],
},
dense: {
// Parent selectors, not CSS vars: these are positional. Panels are
// pulled flush (-mx-px), so corners align with the frame's own radius.
true: "p-0 gap-0 border-[var(--frame-border-color)] [--frame-panel-radius:var(--frame-radius)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px [&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:-mt-px",
false: "",
},
},
defaultVariants: {
variant: "default",
spacing: "default",
stacked: false,
dense: false,
},
}
)
function Frame({
className,
variant,
spacing,
stacked,
dense,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof frameVariants>) {
return (
<div
className={cn(
frameVariants({ variant, spacing, stacked, dense }),
className
)}
data-slot="frame"
data-spacing={spacing}
{...props}
/>
)
}
function FramePanel({
className,
fit,
...props
}: React.ComponentProps<"div"> & { fit?: boolean }) {
return (
<div
className={cn(
"relative overflow-hidden rounded-(--frame-panel-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs",
/* `fit` sizes the panel to its content; otherwise it grows to fill
the frame. */
!fit && "grow",
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-panel-radius)-1px)] before:shadow-black/5",
"dark:bg-clip-border dark:before:shadow-white/5",
"px-(--frame-panel-px) py-(--frame-panel-py)",
className
)}
data-slot="frame-panel"
{...props}
/>
)
}
function FrameHeader({ className, ...props }: React.ComponentProps<"header">) {
return (
<header
className={cn(
"flex flex-col gap-(--frame-panel-header-gap) px-(--frame-panel-header-px) py-(--frame-panel-header-py)",
className
)}
data-slot="frame-panel-header"
{...props}
/>
)
}
function FrameTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("text-sm font-semibold", className)}
data-slot="frame-panel-title"
{...props}
/>
)
}
function FrameDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn("text-muted-foreground text-sm", className)}
data-slot="frame-panel-description"
{...props}
/>
)
}
function FrameFooter({ className, ...props }: React.ComponentProps<"footer">) {
return (
<footer
className={cn(
"flex flex-col gap-(--frame-panel-footer-gap) px-(--frame-panel-footer-px) py-(--frame-panel-footer-py)",
className
)}
data-slot="frame-panel-footer"
{...props}
/>
)
}
export {
Frame,
FramePanel,
FrameHeader,
FrameTitle,
FrameDescription,
FrameFooter,
frameVariants,
}
@@ -0,0 +1,90 @@
import { cn } from "@authportal/ui/lib/utils"
type IconStackProps = React.ComponentProps<"div">
function IconStack({ className, children, style, ...props }: IconStackProps) {
return (
<div
data-slot="icon-stack"
className={cn(
"text-foreground **:data-[slot=icon-stack-layer]:fill-background relative h-20 w-18",
className
)}
style={
{
"--icon-stack-content-x": "71%",
"--icon-stack-content-y": "58%",
...style,
} as React.CSSProperties
}
{...props}
>
<svg
aria-hidden="true"
viewBox="0 0 72 81"
fill="none"
className="h-full w-full overflow-visible"
>
<ellipse
cx="36"
cy="76"
rx="30"
ry="7"
fill="currentColor"
fillOpacity="0.055"
className="blur-[4px]"
/>
<IconStackLayer opacity="0.4" />
<IconStackLayer opacity="0.6" x={13.65} y={6.04} />
<IconStackLayer opacity="0.8" x={27.32} y={12.08} active />
</svg>
{children ? (
<div
data-slot="icon-stack-content"
className="text-muted-foreground pointer-events-none absolute top-[var(--icon-stack-content-y)] left-[var(--icon-stack-content-x)] flex -translate-x-1/2 -translate-y-1/2 scale-x-90 -skew-y-26 items-center justify-center"
>
{children}
</div>
) : null}
</div>
)
}
function IconStackLayer({
active = false,
opacity,
x = 0,
y = 0,
}: {
active?: boolean
opacity: string
x?: number
y?: number
}) {
return (
<g opacity={opacity} transform={`translate(${x} ${y})`}>
<path
data-slot="icon-stack-layer"
d="M42.2538 2.046C41.4408 1.6325 40.3965 1.6677 39.2612 2.2424L7.9616 18.1934C5.3895 19.5039 3.301 23.1064 3.301 26.2322V64.3226C3.301 66.0677 3.9458 67.2943 4.962 67.8199L1.8363 66.229C0.8201 65.7104 0.1753 64.4771 0.1753 62.732V24.6412C0.1753 21.5085 2.2638 17.913 4.8359 16.6024L36.1355 0.6515C37.2778 0.0698 38.322 0.0416 39.128 0.4551L42.2538 2.046Z"
stroke="currentColor"
strokeOpacity={active ? "0.3" : "0.2"}
strokeWidth="0.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
data-slot="icon-stack-layer"
d="M42.2545 2.0456C43.2707 2.5643 43.9155 3.7979 43.9155 5.543V43.6337C43.9155 46.7665 41.827 50.3616 39.2549 51.6722L7.9554 67.6235C6.813 68.2052 5.7687 68.2331 4.9628 67.8196C3.9465 67.301 3.3018 66.0673 3.3018 64.3222V26.2318C3.3018 23.0991 5.3903 19.5036 7.9624 18.193L39.2619 2.2421C40.4043 1.6604 41.4486 1.6321 42.2545 2.0456Z"
stroke="currentColor"
strokeOpacity={active ? "0.3" : "0.2"}
strokeWidth="0.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
)
}
export { IconStack, type IconStackProps }
+126
View File
@@ -0,0 +1,126 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@authportal/ui/lib/utils"
/**
* CSS variable architecture:
*
* The root owns four variables so every part of the tile stays in proportion
* and stays overridable from a single `className`:
*
* --icon-tile-size tile width/height
* --icon-tile-icon-size glyph size applied to child svgs
* --icon-tile-radius corner radius (also drives the nested inner card)
* --icon-tile-inset gap between the outer ring and the inner card
*
* The `frame` and `soft` variants paint their inner card with an `::after`
* pseudo element instead of a wrapper node. `isolate` makes the root a stacking
* context, so the negative z-index pseudo paints above the root background but
* below the in-flow icon - no extra DOM, and `render` composition keeps working.
*
* Tone: `soft` and `solid` derive every fill and border from `currentColor`, so
* a single text color class (e.g. `text-success`) retints the whole tile. They
* default to `text-primary`; override it to recolor without touching internals.
*/
const iconTileVariants = cva(
[
"relative inline-flex shrink-0 items-center justify-center align-middle",
"size-(--icon-tile-size) rounded-[var(--icon-tile-radius,min(var(--radius-md),calc(var(--icon-tile-size)/3)))]",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-(--icon-tile-icon-size)",
],
{
variants: {
variant: {
/** Plain bordered surface. The quiet default for list rows and toolbars. */
outline: "border border-border bg-background dark:bg-input/32",
/** Raised muted fill with a background-colored ring. Reads as a physical chip. */
elevated:
"border-2 border-background bg-muted text-accent-foreground shadow-[0_1px_3px_0_rgb(0_0_0/0.14)] dark:border",
/**
* Tinted double container: an opacity-filled outer ring with no border
* around a bordered inner card, all derived from `currentColor`. The
* quiet, colorful sibling of `frame`. Retint with a text color class.
*/
soft: [
"isolate p-(--icon-tile-inset) text-primary bg-current/10",
"after:absolute after:-z-10 after:inset-(--icon-tile-inset)",
"after:rounded-[calc(var(--icon-tile-radius,min(var(--radius-md),calc(var(--icon-tile-size)/3)))-var(--icon-tile-inset))]",
"after:border after:border-current/20 after:bg-current/5",
],
/** Filled tone with a contrasting glyph. Retint with `bg-*` + a text color. */
solid: "bg-primary text-primary-foreground",
/** Double container - a muted ring around an inset card, matching Frame. */
frame: [
"isolate border border-border bg-muted/50 p-(--icon-tile-inset)",
"after:absolute after:-z-10 after:inset-(--icon-tile-inset)",
"after:rounded-[calc(var(--icon-tile-radius,min(var(--radius-md),calc(var(--icon-tile-size)/3)))-var(--icon-tile-inset))]",
"after:border after:border-border after:bg-card after:shadow-xs",
],
},
size: {
xs: "[--icon-tile-size:--spacing(6)] [--icon-tile-icon-size:--spacing(3.5)] [--icon-tile-inset:--spacing(0.5)]",
sm: "[--icon-tile-size:--spacing(8)] [--icon-tile-icon-size:--spacing(4)] [--icon-tile-inset:--spacing(0.5)]",
default:
"[--icon-tile-size:--spacing(10)] [--icon-tile-icon-size:--spacing(4.5)] [--icon-tile-inset:--spacing(0.75)]",
lg: "[--icon-tile-size:--spacing(12)] [--icon-tile-icon-size:--spacing(5.5)] [--icon-tile-inset:--spacing(0.75)]",
xl: "[--icon-tile-size:--spacing(14)] [--icon-tile-icon-size:--spacing(7)] [--icon-tile-inset:--spacing(1)]",
},
/**
* `default`: active style radius. `full`: circular.
* No bare rung beside the ladder: the registry transform writes the
* installed style's rung bare, and a second bare rung would leave class
* order to pick the winner. The no-style fallback lives in the var()
* that reads the radius instead.
*
* Each style token is clamped to a fraction of the tile. A flat radius
* is a circle once it reaches half the box, so the soft styles used to
* render `xs` (24px) and `sm` (32px) as plain circles and swallow the
* `full` variant's meaning. Clamping keeps one corner ratio at every
* size instead, so a tile still reads as the same shape when it scales.
* Luma and Rhea take the gentler quarter ratio; Maia stays at a third.
*/
radius: {
default:
"[--icon-tile-radius:min(var(--radius-md),calc(var(--icon-tile-size)/3))]",
full: "[--icon-tile-radius:calc(infinity*1px)]",
},
},
defaultVariants: {
variant: "outline",
size: "default",
radius: "default",
},
}
)
interface IconTileProps extends useRender.ComponentProps<"span"> {
variant?: VariantProps<typeof iconTileVariants>["variant"]
size?: VariantProps<typeof iconTileVariants>["size"]
radius?: VariantProps<typeof iconTileVariants>["radius"]
}
function IconTile({
className,
variant = "outline",
size = "default",
radius = "default",
render,
...props
}: IconTileProps) {
const defaultProps = {
"data-slot": "icon-tile",
"data-variant": variant,
"data-size": size,
className: cn(iconTileVariants({ variant, size, radius, className })),
}
return useRender({
defaultTagName: "span",
render,
props: mergeProps<"span">(defaultProps, props),
})
}
export { IconTile, iconTileVariants, type IconTileProps }
+256
View File
@@ -0,0 +1,256 @@
import { createContext, useCallback, useContext, useState } from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "@authportal/ui/lib/utils"
// Types
type TimelineContextValue = {
activeStep: number
setActiveStep: (step: number) => void
}
// Context
const TimelineContext = createContext<TimelineContextValue | undefined>(
undefined
)
const useTimeline = () => {
const context = useContext(TimelineContext)
if (!context) {
throw new Error("useTimeline must be used within a Timeline")
}
return context
}
// Components
interface TimelineProps extends useRender.ComponentProps<"div"> {
defaultValue?: number
value?: number
onValueChange?: (value: number) => void
orientation?: "horizontal" | "vertical"
}
function Timeline({
defaultValue = 1,
value,
onValueChange,
orientation = "vertical",
className,
render,
children,
...props
}: TimelineProps) {
const [activeStep, setInternalStep] = useState(defaultValue)
const setActiveStep = useCallback(
(step: number) => {
if (value === undefined) {
setInternalStep(step)
}
onValueChange?.(step)
},
[value, onValueChange]
)
const currentStep = value ?? activeStep
const defaultProps = {
className: cn(
"group/timeline flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col",
className
),
"data-orientation": orientation,
"data-slot": "timeline",
children,
}
return (
<TimelineContext.Provider
value={{ activeStep: currentStep, setActiveStep }}
>
{useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})}
</TimelineContext.Provider>
)
}
// TimelineContent
function TimelineContent({
className,
render,
children,
...props
}: useRender.ComponentProps<"div">) {
const defaultProps = {
className: cn("text-muted-foreground text-sm", className),
"data-slot": "timeline-content",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineDate
type TimelineDateProps = useRender.ComponentProps<"time">
function TimelineDate({
className,
render,
children,
...props
}: TimelineDateProps) {
const defaultProps = {
className: cn(
"mb-1 block font-medium text-muted-foreground text-xs group-data-[orientation=vertical]/timeline:max-sm:h-4",
className
),
"data-slot": "timeline-date",
children,
}
return useRender({
defaultTagName: "time",
render,
props: mergeProps<"time">(defaultProps, props),
})
}
// TimelineHeader
function TimelineHeader({
className,
render,
children,
...props
}: useRender.ComponentProps<"div">) {
const defaultProps = {
className: cn(className),
"data-slot": "timeline-header",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineIndicator
type TimelineIndicatorProps = useRender.ComponentProps<"div">
function TimelineIndicator({
className,
children,
render,
...props
}: TimelineIndicatorProps) {
const defaultProps = {
"aria-hidden": true,
className: cn(
"group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute size-4 rounded-full border-2 border-primary/20 group-data-[orientation=vertical]/timeline:top-0 group-data-[orientation=horizontal]/timeline:left-0 group-data-completed/timeline-item:border-primary",
className
),
"data-slot": "timeline-indicator",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineItem
interface TimelineItemProps extends useRender.ComponentProps<"div"> {
step: number
}
function TimelineItem({
step,
className,
render,
children,
...props
}: TimelineItemProps) {
const { activeStep } = useTimeline()
const defaultProps = {
className: cn(
"group/timeline-item relative flex flex-1 flex-col gap-0.5 group-data-[orientation=vertical]/timeline:ms-8 group-data-[orientation=horizontal]/timeline:mt-8 group-data-[orientation=horizontal]/timeline:not-last:pe-8 group-data-[orientation=vertical]/timeline:not-last:pb-6 has-[+[data-completed]]:**:data-[slot=timeline-separator]:bg-primary",
className
),
"data-completed": step <= activeStep || undefined,
"data-slot": "timeline-item",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineSeparator
function TimelineSeparator({
className,
render,
children,
...props
}: useRender.ComponentProps<"div">) {
const defaultProps = {
"aria-hidden": true,
className: cn(
"group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute self-start bg-primary/10 group-last/timeline-item:hidden group-data-[orientation=horizontal]/timeline:h-0.5 group-data-[orientation=vertical]/timeline:h-[calc(100%-1rem-0.25rem)] group-data-[orientation=horizontal]/timeline:w-[calc(100%-1rem-0.25rem)] group-data-[orientation=vertical]/timeline:w-0.5 group-data-[orientation=horizontal]/timeline:translate-x-4.5 group-data-[orientation=vertical]/timeline:translate-y-4.5",
className
),
"data-slot": "timeline-separator",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineTitle
function TimelineTitle({
className,
render,
children,
...props
}: useRender.ComponentProps<"h3">) {
const defaultProps = {
className: cn("font-medium text-sm", className),
"data-slot": "timeline-title",
children,
}
return useRender({
defaultTagName: "h3",
render,
props: mergeProps<"h3">(defaultProps, props),
})
}
export {
Timeline,
TimelineContent,
TimelineDate,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
}
@@ -0,0 +1,141 @@
import { Field, FieldDescription, FieldLabel } from '@authportal/ui/components/field'
import { APPS, PERMISSION_CATALOG, PERMISSION_ACTION_LABELS, isIndependentPermissionSection, withIndependentPermission, permissionKey, type AdminUser, type AppId } from '@authportal/shared'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@authportal/ui/components/select'
import { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import { CodeBlock, SignalAlert } from '@/components/portal/bits'
import { withLevel, levelOf, type AccessValue, type Level } from './access-model'
import { APP_SHORT, USER_JOURNAL } from './mock'
const LEVEL_NAME: Record<Level, string> = { none: 'Нет доступа', read: 'Чтение', write: 'Запись', admin: 'Админ' }
/** Приложения, у которых на доске есть вкладка с разделами. */
const PERM_TABS: AppId[] = ['cfdm', 'vps', 'bgp', 'fw', 'mm']
/**
* Редактор прав по доске «Права доступа»: вкладки «Приложения», по приложению с разделами и «Журнал».
* Раздел — строка с названием, подсказкой и уровнем «Нет доступа / Чтение / Запись / Админ».
*/
export function AccessEditor({
user,
value,
onChange,
}: {
user: AdminUser
value: AccessValue
onChange: (next: AccessValue) => void
}) {
return (
<div className="flex min-h-0 flex-1 flex-col gap-3">
{user.is_admin ? (
<SignalAlert tone="in" title="Администратор">
Полный доступ ко всем приложениям и разделам не зависит от настроек ниже.
</SignalAlert>
) : null}
<Tabs defaultValue="apps" className="min-h-0 min-w-0 flex-1">
<TabsList variant="line" className="h-10! w-full justify-start gap-5 overflow-x-auto">
<TabsTrigger value="apps" className="px-1 text-sm">
Приложения
</TabsTrigger>
{PERM_TABS.map((id) => (
<TabsTrigger key={id} value={id} className="px-1 text-sm">
{id === 'vps' ? 'VPS Tracker' : APP_SHORT[id]}
</TabsTrigger>
))}
<TabsTrigger value="journal" className="px-1 text-sm">
Журнал
</TabsTrigger>
</TabsList>
<TabsContent value="apps" className="mt-4 min-h-0 overflow-y-auto">
<ul className="flex flex-col divide-y rounded-lg border">
{APPS.map((a) => (
<li key={a.id} className="flex items-center justify-between gap-3 px-3.5 py-3">
<label htmlFor={`acc-${a.id}`} className="flex min-w-0 flex-col">
<span className="text-sm font-medium">{a.title}</span>
<span className="text-muted-foreground text-xs">{a.description}</span>
</label>
<Switch
id={`acc-${a.id}`}
checked={value.apps.includes(a.id)}
onCheckedChange={(on) =>
onChange({ ...value, apps: on ? [...value.apps, a.id] : value.apps.filter((x) => x !== a.id) })
}
/>
</li>
))}
</ul>
</TabsContent>
{PERM_TABS.map((appId) => {
const catalog = PERMISSION_CATALOG.find((c) => c.appId === appId)
if (!catalog) return null
return (
<TabsContent key={appId} value={appId} className="mt-4 min-h-0 overflow-y-auto">
<div className="flex flex-col gap-4">
{catalog.sections.map((section) => {
const level = levelOf(value.permissions, appId, section.id, section.actions)
const options: Level[] = ['none', ...section.actions.filter((action): action is Exclude<Level, 'none'> => action === 'read' || action === 'write' || action === 'admin')]
return (
<Field key={section.id}>
<FieldLabel>{section.title}</FieldLabel>
{isIndependentPermissionSection(appId, section.id) ? (
<div className="flex flex-col gap-2">
{section.actions.map((action) => (
<label key={action} className="flex items-center justify-between gap-3">
<span className="text-sm">{PERMISSION_ACTION_LABELS[action]}</span>
<Switch
checked={value.permissions.includes(permissionKey(appId, section.id, action))}
disabled={!value.apps.includes(appId)}
aria-label={`${section.title}: ${PERMISSION_ACTION_LABELS[action]}`}
onCheckedChange={(enabled) => onChange({ ...value, permissions: withIndependentPermission(value.permissions, appId, section.id, action, enabled) })}
/>
</label>
))}
</div>
) : (
<Select
value={level}
onValueChange={(v) =>
v && onChange({ ...value, permissions: withLevel(value.permissions, appId, section.id, section.actions, v as Level) })
}
items={options.map((o) => ({ value: o, label: LEVEL_NAME[o] }))}
>
<SelectTrigger className="w-full" aria-label={`${section.title}: уровень доступа`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{options.map((o) => (
<SelectItem key={o} value={o}>
{LEVEL_NAME[o]}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)}
<FieldDescription>{isIndependentPermissionSection(appId, section.id) ? 'Администрирование включает чтение; одобрение и пауза отдельны. Включение приложения не выдаёт права MCP.' : level === 'none' ? 'Раздел скрыт в приложении' : 'Иерархия: админ ⊃ запись ⊃ чтение'}</FieldDescription>
</Field>
)
})}
</div>
</TabsContent>
)
})}
<TabsContent value="journal" className="mt-4 flex min-h-0 flex-col gap-3 overflow-y-auto">
<p className="text-muted-foreground text-sm">Последние события пользователя из журнала портала.</p>
<CodeBlock label={`${user.email} · 24 часа`}>{USER_JOURNAL}</CodeBlock>
</TabsContent>
</Tabs>
</div>
)
}
@@ -0,0 +1,53 @@
import { StatusBadge } from '@/components/portal/bits'
import type { Tone } from './mock'
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
type MatrixRow = {
id: string
name: string
sub: string
tone: Tone
status: string
total: string
read: string
write: string
admin: string
}
/** Данные доски «Доступ по приложениям»: сколько пользователей имеет доступ и с каким уровнем. */
const ROWS: MatrixRow[] = [
{ id: 'cfdm', name: 'Cloudflare Domain Manager', sub: 'cfdm · JWT', tone: 'ok', status: 'Выдан', total: '3', read: '1', write: '2', admin: '2' },
{ id: 'vps', name: 'VPS Tracker', sub: 'vps · JWT', tone: 'ok', status: 'Выдан', total: '3', read: '1', write: '1', admin: '1' },
{ id: 'bgp', name: 'EvoBGP', sub: 'bgp · JWT', tone: 'ok', status: 'Выдан', total: '3', read: '0', write: '1', admin: '2' },
{ id: 'fw', name: 'EvoFirewall', sub: 'fw · JWT', tone: 'ok', status: 'Выдан', total: '3', read: '0', write: '1', admin: '2' },
{ id: 'dns', name: 'Technitium DNS', sub: 'dns · OIDC', tone: 'ok', status: 'Выдан', total: '2', read: '0', write: '1', admin: '1' },
{ id: 'cdn', name: 'CDN Manager', sub: 'cdn · JWT', tone: 'wn', status: 'Мало админов', total: '3', read: '1', write: '1', admin: '1' },
{ id: 'mm', name: 'MikrotikManager', sub: 'mm · JWT', tone: 'ok', status: 'Выдан', total: '3', read: '1', write: '1', admin: '1' },
]
const mono = (v: string) => <span className="font-mono text-[12.5px]">{v}</span>
const COLUMNS: GridColumn<MatrixRow>[] = [
{ id: 'app', title: 'Приложение', size: 260, sortValue: (r) => r.name, cell: (r) => <TwoLineCell dot={r.tone} title={r.name} sub={r.sub} /> },
{ id: 'status', title: 'Статус', size: 150, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> },
{ id: 'total', title: 'Пользователей', size: 140, cell: (r) => mono(r.total) },
{ id: 'read', title: 'Чтение', size: 100, cell: (r) => mono(r.read) },
{ id: 'write', title: 'Запись', size: 100, cell: (r) => mono(r.write) },
{ id: 'admin', title: 'Админ', size: 100, cell: (r) => mono(r.admin) },
]
/** Матрица доступа по доске: сколько пользователей имеет доступ к каждому приложению и с каким уровнем. */
export function AccessMatrix() {
return (
<SectionGrid
title="Матрица доступа"
description="Сколько пользователей имеет доступ к каждому приложению и с каким уровнем"
rows={ROWS}
columns={COLUMNS}
getRowId={(r) => r.id}
views={[{ label: 'Все', match: () => true }]}
searchText={(r) => `${r.name} ${r.id}`}
bulk={{ onLabel: 'Выдать доступ', offLabel: 'Снять доступ' }}
/>
)
}
@@ -0,0 +1,72 @@
import {
PERMISSION_CATALOG,
isIndependentPermissionSection,
hasPermission,
permissionKey,
type AppId,
type PermissionAction,
} from '@authportal/shared'
import { APP_SHORT } from './mock'
export type AccessValue = { apps: AppId[]; permissions: string[] }
export function diffAccess(before: AccessValue, after: AccessValue): string[] {
const lines: string[] = []
for (const id of after.apps) if (!before.apps.includes(id)) lines.push(`+ приложение ${APP_SHORT[id]}`)
for (const id of before.apps) if (!after.apps.includes(id)) lines.push(`− приложение ${APP_SHORT[id]}`)
for (const key of after.permissions) if (!before.permissions.includes(key)) lines.push(`+ ${key}`)
for (const key of before.permissions) if (!after.permissions.includes(key)) lines.push(`− ${key}`)
return lines
}
export type Level = 'none' | 'read' | 'write' | 'admin'
export const LEVEL_LABEL: Record<Level, string> = {
none: 'Нет',
read: 'Чтение',
write: 'Запись',
admin: 'Админ',
}
/** Текущий уровень раздела: иерархия админ ⊃ запись ⊃ чтение. */
export function levelOf(
perms: readonly string[],
appId: AppId,
sectionId: string,
actions: readonly PermissionAction[],
): Level {
for (const action of ['admin', 'write', 'read'] as const) {
if (actions.includes(action) && hasPermission(perms, permissionKey(appId, sectionId, action))) return action
}
return 'none'
}
/** Ключи прав для уровня раздела. */
export function keysForLevel(appId: AppId, sectionId: string, actions: readonly PermissionAction[], level: Level): string[] {
if (level === 'none') return []
const order: PermissionAction[] = ['read', 'write', 'admin']
return order
.slice(0, order.indexOf(level) + 1)
.filter((a) => actions.includes(a))
.map((a) => permissionKey(appId, sectionId, a))
}
/** Сколько разделов приложения открыто (уровень выше «Нет»). */
export function grantCount(perms: readonly string[], appId: AppId): { granted: number; total: number } {
const catalog = PERMISSION_CATALOG.find((c) => c.appId === appId)
const sections = catalog?.sections ?? []
const granted = sections.filter((s) => isIndependentPermissionSection(appId, s.id) ? s.actions.some(action => perms.includes(permissionKey(appId, s.id, action))) : levelOf(perms, appId, s.id, s.actions) !== 'none').length
return { granted, total: sections.length }
}
/** Новое множество прав после смены уровня одного раздела. */
export function withLevel(
perms: readonly string[],
appId: AppId,
sectionId: string,
actions: readonly PermissionAction[],
level: Level,
): string[] {
const prefix = `${appId}:${sectionId}:`
return [...perms.filter((k) => !k.startsWith(prefix)), ...keysForLevel(appId, sectionId, actions, level)].sort()
}
@@ -0,0 +1,52 @@
import { useMemo, useState } from 'react'
import type { AdminUser } from '@authportal/shared'
import { Button } from '@authportal/ui/components/button'
import { AccessEditor } from './access-editor'
import { diffAccess, type AccessValue } from './access-model'
import { PortalSheet } from '@/components/portal/bits'
/** Окно «Права доступа»: широкая правая панель, чтобы таблица оставалась в контексте. */
export function AccessSheet({
user,
onClose,
onSave,
}: {
user: AdminUser
onClose: () => void
onSave: (value: AccessValue) => 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])
return (
<PortalSheet
open
onOpenChange={(o) => !o && onClose()}
size="lg"
dirty={changes > 0}
title={`Права доступа · ${user.email}`}
description="Доступ к приложениям и разделам · иерархия: админ ⊃ запись ⊃ чтение"
footer={
<>
<span className="text-muted-foreground mr-auto self-center text-xs">
{changes > 0 ? `Изменений: ${changes}` : 'Без изменений'}
</span>
<Button variant="outline" onClick={onClose}>
Отмена
</Button>
<Button
disabled={changes === 0}
onClick={() => {
onSave(value)
onClose()
}}
>
Сохранить права
</Button>
</>
}
>
<AccessEditor user={user} value={value} onChange={setValue} />
</PortalSheet>
)
}
@@ -0,0 +1,194 @@
import { useState } from 'react'
import type { CreateUserRequest } from '@authportal/shared'
import type { CreateOidcClientValues } from '@/components/reui-kit/create-oidc-client-sheet'
import { Button } from '@authportal/ui/components/button'
import { Field, FieldDescription, FieldError, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input'
import { Switch } from '@authportal/ui/components/switch'
import { Textarea } from '@authportal/ui/components/textarea'
import { PortalSheet, SignalAlert } from '@/components/portal/bits'
/** Окно «Создать пользователя» по доске: email, имя, временный пароль, администратор и заметка про журнал. */
export function CreateUserPortalSheet({
open,
onOpenChange,
onSubmit,
}: {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (values: CreateUserRequest) => void
}) {
const [email, setEmail] = useState('')
const [name, setName] = useState('')
const [password, setPassword] = useState('')
const [admin, setAdmin] = useState(false)
const [touched, setTouched] = useState(false)
const passwordError = password.length < 12 ? 'Минимум 12 символов' : null
const emailError = !/^\S+@\S+\.\S+$/.test(email) ? 'Укажите email' : null
const valid = !passwordError && !emailError
function reset() {
setEmail('')
setName('')
setPassword('')
setAdmin(false)
setTouched(false)
}
return (
<PortalSheet
open={open}
onOpenChange={(o) => {
if (!o) reset()
onOpenChange(o)
}}
dirty={Boolean(email || name || password || admin)}
title="Создать пользователя"
description="После создания откроются права доступа"
footer={
<>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<Button
disabled={touched && !valid}
onClick={() => {
setTouched(true)
if (!valid) return
onSubmit({ email, name: name || email.split('@')[0]!, password, is_admin: admin, apps: [], permissions: [] })
reset()
}}
>
Создать
</Button>
</>
}
>
<Field data-invalid={touched && Boolean(emailError)}>
<FieldLabel htmlFor="cu-email">
Email <span aria-hidden>*</span>
</FieldLabel>
<Input id="cu-email" type="email" value={email} placeholder="new.user@shnt.top" aria-invalid={touched && Boolean(emailError)} onChange={(e) => setEmail(e.target.value)} />
{touched && emailError ? <FieldError>{emailError}</FieldError> : <FieldDescription>Станет логином</FieldDescription>}
</Field>
<Field>
<FieldLabel htmlFor="cu-name">Имя</FieldLabel>
<Input id="cu-name" value={name} placeholder="Новый пользователь" onChange={(e) => setName(e.target.value)} />
</Field>
<Field data-invalid={touched && Boolean(passwordError)}>
<FieldLabel htmlFor="cu-pw">
Временный пароль <span aria-hidden>*</span>
</FieldLabel>
<Input id="cu-pw" type="password" value={password} aria-invalid={touched && Boolean(passwordError)} onChange={(e) => setPassword(e.target.value)} />
{touched && passwordError ? <FieldError>✕ {passwordError}</FieldError> : <FieldDescription>Пользователь заменит его на passkey</FieldDescription>}
</Field>
<Field orientation="horizontal">
<Switch id="cu-admin" checked={admin} onCheckedChange={setAdmin} />
<div className="flex flex-col">
<FieldLabel htmlFor="cu-admin">Администратор</FieldLabel>
<FieldDescription>Полный доступ к порталу и ко всем приложениям</FieldDescription>
</div>
</Field>
<SignalAlert tone="wn" title="Журнал">
Создание записывается в журнал «Изменения» с вашим именем и IP.
</SignalAlert>
</PortalSheet>
)
}
/** Окно «Создать OIDC-клиент» по доске: название, redirect URI, включён. */
export function CreateOidcPortalSheet({
open,
onOpenChange,
onSubmit,
}: {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (values: CreateOidcClientValues) => void
}) {
const [name, setName] = useState('')
const [uris, setUris] = useState('')
const [enabled, setEnabled] = useState(true)
const [touched, setTouched] = useState(false)
const list = uris
.split('\n')
.map((x) => x.trim())
.filter(Boolean)
const urisError = list.length === 0 || list.some((u) => !/^https:\/\/[^\s*]+$/.test(u)) ? 'Укажите точный https-адрес колбэка приложения.' : null
const nameError = name.trim() ? null : 'Введите название'
const valid = !urisError && !nameError
function reset() {
setName('')
setUris('')
setEnabled(true)
setTouched(false)
}
return (
<PortalSheet
open={open}
onOpenChange={(o) => {
if (!o) reset()
onOpenChange(o)
}}
dirty={Boolean(name || uris)}
title="Создать OIDC-клиент"
description="Секрет будет показан один раз после создания"
footer={
<>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<Button
disabled={touched && !valid}
onClick={() => {
setTouched(true)
if (!valid) return
onSubmit({ name: name.trim(), redirect_uris: list, scopes: ['openid', 'profile', 'email'], enabled })
reset()
}}
>
Создать
</Button>
</>
}
>
<Field data-invalid={touched && Boolean(nameError)}>
<FieldLabel htmlFor="co-name">
Название <span aria-hidden>*</span>
</FieldLabel>
<Input id="co-name" value={name} placeholder="Technitium DNS" aria-invalid={touched && Boolean(nameError)} onChange={(e) => setName(e.target.value)} />
{touched && nameError ? <FieldError>{nameError}</FieldError> : null}
</Field>
<Field data-invalid={touched && Boolean(urisError)}>
<FieldLabel htmlFor="co-uri">
Redirect URI <span aria-hidden>*</span>
</FieldLabel>
<Textarea
id="co-uri"
rows={4}
value={uris}
placeholder="https://dns.example.com/sso/callback"
aria-invalid={touched && Boolean(urisError)}
className="font-mono"
onChange={(e) => setUris(e.target.value)}
/>
{touched && urisError ? (
<FieldError>✕ {urisError}</FieldError>
) : (
<FieldDescription>По одному на строку · только https · точное совпадение</FieldDescription>
)}
</Field>
<Field orientation="horizontal">
<Switch id="co-on" checked={enabled} onCheckedChange={setEnabled} />
<div className="flex flex-col">
<FieldLabel htmlFor="co-on">Клиент включён</FieldLabel>
<FieldDescription>Выключенный клиент не может получить токены</FieldDescription>
</div>
</Field>
</PortalSheet>
)
}
+502
View File
@@ -0,0 +1,502 @@
/**
* Суррогатные данные живого макета /design-preview.
* Названия приложений, разделов и действий берутся из контрактов
* @authportal/shared; значения (люди, IP, даты) — вымышленные.
*/
import {
AppWindowIcon,
FingerprintIcon,
HistoryIcon,
KeyRoundIcon,
LayoutGridIcon,
LogInIcon,
UsersIcon,
type LucideIcon,
} from 'lucide-react'
import {
APPS,
defaultAppSwitcherConfig,
type AdminUser,
type AppId,
type AuditLogEntry,
} from '@authportal/shared'
import type { Tone } from '@/components/portal/tone'
export type { Tone }
export type NavItem = {
id: string
label: string
to: string
icon: LucideIcon
group: 'Портал' | 'Админ'
/** Точка проблемы в углу иконки. */
problem?: { tone: 'er' | 'wn'; label: string }
}
export const NAV_ITEMS: NavItem[] = [
{ id: 'apps', label: 'Приложения', to: '/design-preview/apps', icon: LayoutGridIcon, group: 'Портал' },
{ id: 'account', label: 'Безопасность', to: '/design-preview/account', icon: FingerprintIcon, group: 'Портал' },
{ id: 'users', label: 'Пользователи', to: '/design-preview/users', icon: UsersIcon, group: 'Админ' },
{
id: 'logins',
label: 'Входы',
to: '/design-preview/logins',
icon: LogInIcon,
group: 'Админ',
problem: { tone: 'er', label: 'Подбор пароля: 6 неудачных входов за 10 минут' },
},
{ id: 'audit', label: 'Изменения', to: '/design-preview/audit', icon: HistoryIcon, group: 'Админ' },
{
id: 'oidc',
label: 'OIDC-клиенты',
to: '/design-preview/oidc',
icon: KeyRoundIcon,
group: 'Админ',
problem: { tone: 'wn', label: 'Секрет клиента старше 90 дней' },
},
{ id: 'app-links', label: 'Ссылки приложений', to: '/design-preview/app-links', icon: AppWindowIcon, group: 'Админ' },
]
export const NAV_GROUPS = ['Портал', 'Админ'] as const
export const APP_TITLE: Record<AppId, string> = Object.fromEntries(
APPS.map((app) => [app.id, app.title]),
) as Record<AppId, string>
export const APP_SHORT: Record<AppId, string> = {
cfdm: 'CFDM',
vps: 'VPS',
bgp: 'EvoBGP',
fw: 'EvoFirewall',
dns: 'Technitium DNS',
cdn: 'CDN Manager',
mm: 'MikrotikManager',
}
export const ALL_APP_IDS = APPS.map((a) => a.id)
export const MONITOR_ITEMS: Array<{
id: string
tone: 'er' | 'wn' | 'ok'
title: string
text: string
age: string
to: string
}> = [
{
id: 'bruteforce',
tone: 'er',
title: 'Подбор пароля: 6 неудачных входов',
text: 'ops@shnt.top · 203.0.113.58 · за 10 минут',
age: '11 мин',
to: '/design-preview/logins',
},
{
id: 'secret',
tone: 'wn',
title: 'Секрет Technitium DNS старше 90 дней',
text: 'Обновите client_secret в разделе OIDC',
age: '3 дн',
to: '/design-preview/oidc',
},
{
id: 'nopasskey',
tone: 'wn',
title: '3 пользователя без passkey',
text: 'ops, pavel, dns-bot · вход только по паролю',
age: '1 дн',
to: '/design-preview/users',
},
{ id: 'api', tone: 'ok', title: 'API портала', text: '/ready · ответ 18 мс', age: 'сейчас', to: '/design-preview/apps' },
{ id: 'jwks', tone: 'ok', title: 'JWKS и discovery', text: 'Подпись RS256 · ключ активен', age: 'сейчас', to: '/design-preview/oidc' },
]
const ALL: AppId[] = [...ALL_APP_IDS]
function user(
n: number,
email: string,
name: string,
isAdmin: boolean,
apps: AppId[],
permissions: string[],
extra: Partial<AdminUser> = {},
): AdminUser {
return {
id: `u-${n}`,
email,
name,
is_admin: isAdmin,
disabled: false,
apps,
permissions,
last_login_at: '2026-10-17T09:41:00.000Z',
last_login_ip: '203.0.113.10',
passkey_count: 0,
created_at: '2026-02-01T08:00:00.000Z',
updated_at: '2026-10-17T09:41:00.000Z',
...extra,
}
}
export const MOCK_USERS: AdminUser[] = [
user(1, 'admin@shnt.top', 'Admin', true, ALL, [], { passkey_count: 2 }),
user(2, 'denis@shnt.top', 'Денис', true, ALL, [], {
passkey_count: 1,
last_login_at: '2026-10-16T22:15:00.000Z',
last_login_ip: '198.51.100.4',
}),
user(
3,
'ops@shnt.top',
'Operations',
false,
['bgp', 'fw', 'mm', 'dns'],
['bgp:dashboard:read', 'bgp:modules:write', 'fw:agents:write', 'fw:lists:write', 'fw:policies:read', 'mm:servers:read'],
{
created_at: '2026-03-14T08:00:00.000Z',
last_login_at: '2026-10-17T14:02:00.000Z',
last_login_ip: '203.0.113.58',
},
),
user(
4,
'anna@shnt.top',
'Анна',
false,
['cfdm', 'vps'],
['cfdm:domains:write', 'cfdm:dns:write', 'cfdm:certificates:read', 'vps:dashboard:read', 'vps:vps:read'],
{
passkey_count: 1,
created_at: '2026-05-02T08:00:00.000Z',
last_login_at: '2026-10-16T18:40:00.000Z',
last_login_ip: '192.0.2.7',
},
),
user(5, 'pavel@shnt.top', 'Павел', false, ['cdn'], ['cdn:dashboard:read', 'cdn:nodes:read'], {
created_at: '2026-06-19T08:00:00.000Z',
last_login_at: '2026-10-12T11:10:00.000Z',
last_login_ip: '192.0.2.19',
}),
user(6, 'dns-bot@shnt.top', 'Сервисная запись', false, ['dns'], ['dns:dns:write'], {
created_at: '2026-07-01T08:00:00.000Z',
last_login_at: '2026-10-17T06:00:00.000Z',
last_login_ip: '10.0.0.5',
}),
user(7, 'former@shnt.top', 'Бывший сотрудник', false, [], [], {
disabled: true,
created_at: '2026-01-10T08:00:00.000Z',
last_login_at: '2026-10-02T10:00:00.000Z',
last_login_ip: '192.0.2.88',
}),
]
export const USER_JOURNAL = [
'14:02 auth.login_failed 203.0.113.58 неверный пароль',
'13:58 auth.login_failed 203.0.113.58 неверный пароль',
'13:51 auth.login 198.51.100.4 пароль',
'09:12 user.access_update admin +fw:agents:write',
].join('\n')
export type PasskeyRow = {
id: string
name: string
note: string
kind: string
added: string
lastUsed: string
tone: Tone
status: string
}
export const MOCK_PASSKEYS: PasskeyRow[] = [
{
id: 'pk-1',
name: 'MacBook Pro · Touch ID',
note: 'Синхронизируется через iCloud',
kind: 'Платформенный',
added: '12.09.2026',
lastUsed: 'сегодня 09:41',
tone: 'ok',
status: 'Активен',
},
{
id: 'pk-2',
name: 'YubiKey 5C',
note: 'Ключ безопасности · USB-C',
kind: 'Ключ безопасности',
added: '03.07.2026',
lastUsed: '41 день назад',
tone: 'wn',
status: 'Давно не использовался',
},
]
export type LoginRow = {
id: string
login: string
client: string
time: string
ip: string
app: string
reason: string
tone: Tone
status: string
}
export const MOCK_LOGINS: LoginRow[] = [
{ id: 'l1', login: 'ops@shnt.top', client: 'Chrome 130 · Windows 11', time: '14:02', ip: '203.0.113.58', app: 'Auth Portal', reason: 'неверный пароль', tone: 'er', status: 'Неудача' },
{ id: 'l2', login: 'ops@shnt.top', client: 'Chrome 130 · Windows 11', time: '13:58', ip: '203.0.113.58', app: 'Auth Portal', reason: 'неверный пароль', tone: 'er', status: 'Неудача' },
{ id: 'l3', login: 'admin@shnt.top', client: 'Chrome 130 · macOS', time: '09:41', ip: '203.0.113.10', app: 'Auth Portal', reason: 'passkey', tone: 'ok', status: 'Вход' },
{ id: 'l4', login: 'admin@shnt.top', client: 'Chrome 130 · macOS', time: '09:42', ip: '203.0.113.10', app: 'EvoFirewall', reason: 'SSO handoff', tone: 'in', status: 'SSO' },
{ id: 'l5', login: 'anna@shnt.top', client: 'Safari 18 · iPhone', time: 'вчера 18:40', ip: '192.0.2.7', app: 'Auth Portal', reason: 'пароль', tone: 'ok', status: 'Вход' },
{ id: 'l6', login: 'anna@shnt.top', client: 'Safari 18 · iPhone', time: 'вчера 19:12', ip: '192.0.2.7', app: 'Auth Portal', reason: 'выход', tone: 'wn', status: 'Выход' },
{ id: 'l7', login: 'dns-bot@shnt.top', client: 'Technitium 13', time: 'вчера 06:00', ip: '10.0.0.5', app: 'Technitium DNS', reason: 'OIDC', tone: 'in', status: 'SSO' },
{ id: 'l8', login: 'former@shnt.top', client: 'Firefox 131', time: '15 дн назад', ip: '192.0.2.88', app: 'Auth Portal', reason: 'учётная запись отключена', tone: 'er', status: 'Неудача' },
]
export type SessionRow = {
id: string
user: string
note: string
ipClient: string
created: string
expires: string
tone: Tone
status: string
}
export const MOCK_SESSIONS: SessionRow[] = [
{ id: 's1', user: 'admin@shnt.top', note: 'текущая сессия', ipClient: '203.0.113.10 · Chrome · macOS', created: 'сегодня 09:41', expires: 'через 8 ч', tone: 'ok', status: 'Активна' },
{ id: 's2', user: 'admin@shnt.top', note: 'мобильное устройство', ipClient: '198.51.100.4 · Safari · iPhone', created: 'вчера 22:15', expires: 'через 3 ч', tone: 'ok', status: 'Активна' },
{ id: 's3', user: 'denis@shnt.top', note: '', ipClient: '198.51.100.4 · Chrome · Windows', created: 'вчера 22:15', expires: 'через 3 ч', tone: 'ok', status: 'Активна' },
{ id: 's4', user: 'anna@shnt.top', note: '', ipClient: '192.0.2.7 · Safari · iPhone', created: 'вчера 18:40', expires: 'через 1 ч', tone: 'ok', status: 'Активна' },
{ id: 's5', user: 'dns-bot@shnt.top', note: 'OIDC-клиент', ipClient: '10.0.0.5 · Technitium 13', created: 'вчера 06:00', expires: 'через 6 д', tone: 'in', status: 'OIDC' },
]
function entry(
n: number,
p: Pick<AuditLogEntry, 'source_app' | 'action' | 'severity' | 'summary' | 'created_at'> &
Partial<AuditLogEntry>,
): AuditLogEntry {
return {
id: `a-${n}`,
event_id: null,
actor_user_id: 'u-1',
actor_email: 'admin@shnt.top',
actor_name: 'Admin',
target_type: null,
target_id: null,
details: null,
ip: '203.0.113.10',
...p,
}
}
const NOW = Date.now()
const ago = (minutes: number) => new Date(NOW - minutes * 60_000).toISOString()
export const MOCK_AUDIT: AuditLogEntry[] = [
entry(1, {
source_app: 'portal',
action: 'user.update',
severity: 'warning',
summary: 'Отключён пользователь former@shnt.top',
created_at: ago(20),
target_type: 'user',
target_id: 'u-7',
details: { before: { disabled: false }, after: { disabled: true } },
}),
entry(2, {
source_app: 'portal',
action: 'user.access_update',
severity: 'info',
summary: 'Права обновлены: ops@shnt.top (+fw:agents:write)',
created_at: ago(32),
target_type: 'user',
target_id: 'u-3',
}),
entry(3, {
source_app: 'fw',
action: 'fw.policy_apply',
severity: 'critical',
summary: 'Применена политика firewall (поколение 128) — ошибка применения на db-01',
created_at: ago(111),
actor_user_id: null,
actor_email: null,
actor_name: 'system',
ip: null,
target_type: 'system',
}),
entry(4, {
source_app: 'portal',
action: 'oidc.client_update',
severity: 'info',
summary: 'Обновлён OIDC-клиент Technitium DNS: новый client_secret',
created_at: ago(310),
target_type: 'settings',
}),
entry(5, {
source_app: 'portal',
action: 'app_switcher.update',
severity: 'info',
summary: 'Изменён список приложений в переключателе (+CDN Manager)',
created_at: ago(1200),
target_type: 'settings',
}),
entry(6, {
source_app: 'mm',
action: 'mm.server_delete',
severity: 'warning',
summary: 'Удалён сервер old-edge',
created_at: ago(1400),
actor_email: 'denis@shnt.top',
actor_name: 'Денис',
ip: '198.51.100.4',
target_type: 'app_resource',
}),
entry(7, {
source_app: 'portal',
action: 'auth.login_failed',
severity: 'warning',
summary: 'ops@shnt.top: неверный пароль',
created_at: ago(21),
ip: '203.0.113.58',
actor_email: 'ops@shnt.top',
actor_name: 'Operations',
}),
entry(8, {
source_app: 'portal',
action: 'user.create',
severity: 'info',
summary: 'Создан pavel@shnt.top',
created_at: ago(60 * 24 * 5),
target_type: 'user',
target_id: 'u-5',
}),
entry(9, {
source_app: 'portal',
action: 'audit.purge',
severity: 'warning',
summary: 'Очищено 212 записей старше 90 дней',
created_at: ago(60 * 24 * 16),
target_type: 'system',
}),
]
export type OidcClientRow = {
id: string
name: string
hint: string
clientId: string
redirect: string
secret: string
created: string
enabled: boolean
stale: boolean
}
export const MOCK_OIDC_CLIENTS: OidcClientRow[] = [
{
id: 'c1',
name: 'Technitium DNS',
hint: 'dns.shnt.top · authorization_code',
clientId: 'dns-shnt',
redirect: 'https://dns.shnt.top/sso/callback',
secret: 'старше 90 дн.',
created: '10.07.2026',
enabled: true,
stale: true,
},
{
id: 'c2',
name: 'Grafana (тест)',
hint: 'grafana.shnt.top · authorization_code',
clientId: 'grafana-test',
redirect: 'https://grafana.shnt.top/login/generic_oauth',
secret: 'действует',
created: '02.10.2026',
enabled: false,
stale: false,
},
]
export const OIDC_ISSUER = 'https://auth.shnt.top'
export const OIDC_ENDPOINTS: Array<{ label: string; value: string }> = [
{ label: 'Issuer', value: OIDC_ISSUER },
{ label: 'Discovery', value: `${OIDC_ISSUER}/.well-known/openid-configuration` },
{ label: 'JWKS', value: `${OIDC_ISSUER}/.well-known/jwks.json` },
{ label: 'Authorization endpoint', value: `${OIDC_ISSUER}/oauth/authorize` },
{ label: 'Token endpoint', value: `${OIDC_ISSUER}/oauth/token` },
{ label: 'Userinfo endpoint', value: `${OIDC_ISSUER}/oauth/userinfo` },
]
export const OIDC_CAPABILITIES: Array<{ label: string; value: string }> = [
{ label: 'Flow', value: 'authorization_code + PKCE' },
{ label: 'Scopes', value: 'openid, profile, email, groups' },
{ label: 'Подпись', value: 'RS256' },
{ label: 'Claims', value: 'sub, email, name, groups' },
{ label: 'Время жизни ID-токена', value: '10 минут' },
{ label: 'Refresh-токены', value: 'не выдаются' },
]
export const MOCK_CLIENT_SECRET = 'kP9x-3vQ_t7Lw2Zr0mYdN8aUe5HcJf1B'
export const MOCK_SWITCHER = defaultAppSwitcherConfig()
/** Подписи и активность строк каталога — как в канвасе. */
export const USER_META: Record<string, { sub: string; activity: string }> = {
'u-1': { sub: 'Admin · создан 01.02.2026', activity: 'сегодня 09:41 · 203.0.113.10' },
'u-2': { sub: 'Денис · создан 01.02.2026', activity: 'вчера 22:15 · 198.51.100.4' },
'u-3': { sub: 'Operations · создан 14.03.2026', activity: '14:02 · 203.0.113.58' },
'u-4': { sub: 'Анна · создан 02.05.2026', activity: 'вчера 18:40 · 192.0.2.7' },
'u-5': { sub: 'Павел · создан 19.06.2026', activity: '5 дн назад · 192.0.2.19' },
'u-6': { sub: 'Сервисная запись · OIDC', activity: '3 ч назад · 10.0.0.5' },
'u-7': { sub: 'Бывший сотрудник · отключён 12.10', activity: '15 дн назад · 192.0.2.88' },
}
/** Источник и свежесть данных по разделам — для панели «Источники и свежесть данных» в шапке. */
export const FRESH_BY_PAGE: Record<string, { badge: string; text: string }> = {
apps: { badge: 'Портал', text: 'Получено 14:02:11 · сессия действует до 22:02 · права из базы портала, не из токена' },
account: { badge: 'WebAuthn', text: 'Получено 14:02:11 · браузер поддерживает passkeys · домен auth.shnt.top' },
users: { badge: 'Портал', text: 'Получено 14:02:11 · GET /api/v1/admin/users · 7 учётных записей' },
logins: { badge: 'Журнал', text: 'Получено 14:02:11 · GET /api/v1/admin/audit?kind=logins · хранение 90 дней' },
audit: { badge: 'Журнал', text: 'Получено 14:02:11 · GET /api/v1/admin/audit?kind=changes · ingest от 5 приложений' },
oidc: { badge: 'OIDC', text: 'Получено 14:02:11 · discovery и JWKS доступны · подпись RS256' },
'app-links': { badge: 'Портал', text: 'Получено 14:02:11 · portal_settings.app_switcher_json · публичный GET /api/v1/app-switcher' },
}
export type AuditRow = {
id: string
action: string
summary: string
tone: Tone
label: string
source: string
who: string
ip: string
when: string
}
/** Таблица журнала с доски «Изменения» (значения те же, что на доске). */
export const AUDIT_ROWS: AuditRow[] = [
{ id: 'e1', action: 'user.update', summary: 'Отключён former@shnt.top', tone: 'wn', label: 'Warning', source: 'Auth Portal', who: 'admin', ip: '203.0.113.10', when: '14:02' },
{ id: 'e2', action: 'user.access_update', summary: 'ops@shnt.top · +EvoFirewall:agents:write', tone: 'ok', label: 'Info', source: 'Auth Portal', who: 'admin', ip: '203.0.113.10', when: '13:50' },
{ id: 'e3', action: 'fw.policy_apply', summary: 'db-01 · поколение 128 · ошибка nft', tone: 'er', label: 'Critical', source: 'EvoFirewall', who: 'system', ip: '—', when: '12:31' },
{ id: 'e4', action: 'oidc.client_update', summary: 'Technitium DNS · новый секрет', tone: 'ok', label: 'Info', source: 'Auth Portal', who: 'admin', ip: '203.0.113.10', when: '09:12' },
{ id: 'e5', action: 'app_switcher.update', summary: '+CDN Manager в меню приложений', tone: 'ok', label: 'Info', source: 'Auth Portal', who: 'admin', ip: '203.0.113.10', when: 'вчера 18:20' },
{ id: 'e6', action: 'mm.server_delete', summary: 'Удалён сервер old-edge', tone: 'wn', label: 'Warning', source: 'MikrotikManager', who: 'denis', ip: '198.51.100.4', when: 'вчера 15:02' },
{ id: 'e7', action: 'user.create', summary: 'Создан pavel@shnt.top', tone: 'ok', label: 'Info', source: 'Auth Portal', who: 'admin', ip: '203.0.113.10', when: '19.06' },
{ id: 'e8', action: 'audit.purge', summary: 'Очищено 212 записей старше 90 дн.', tone: 'wn', label: 'Warning', source: 'Auth Portal', who: 'admin', ip: '203.0.113.10', when: '01.10' },
]
/** Лента с доски «Изменения». */
export const AUDIT_FEED: Array<{ t: string; title: string; text: string; label: string; tone: Tone }> = [
{ t: '14:02', title: 'Отключён пользователь former@shnt.top', text: 'admin · Auth Portal · 203.0.113.10', label: 'Warning', tone: 'wn' },
{ t: '13:50', title: 'Права обновлены: ops@shnt.top', text: '+EvoFirewall:agents:write · admin · Auth Portal', label: 'Info', tone: 'ok' },
{ t: '12:31', title: 'Применена политика firewall (поколение 128)', text: 'system · EvoFirewall · db-01 — ошибка применения', label: 'Critical', tone: 'er' },
{ t: '09:12', title: 'Обновлён OIDC-клиент Technitium DNS', text: 'новый client_secret · admin · Auth Portal', label: 'Info', tone: 'ok' },
{ t: 'вчера', title: 'Изменён список приложений в переключателе', text: '+CDN Manager · admin · Auth Portal', label: 'Info', tone: 'in' },
]
@@ -0,0 +1,329 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChartColumnIcon, CloudIcon, EyeIcon, KeyRoundIcon, LayoutDashboardIcon, LayoutGridIcon, LinkIcon, NetworkIcon, ServerIcon, type LucideIcon } from 'lucide-react'
import { toast } from 'sonner'
import type { AppId, AppSwitcherIconName } from '@authportal/shared'
import { Button } from '@authportal/ui/components/button'
import { Field, FieldDescription, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@authportal/ui/components/select'
import { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { KpiStrip } from '@/components/portal/kpi-strip'
import { PageHead, PortalSheet, StatusBadge, PageTab, PageTabsList, FormFrame, SettingRow } from '@/components/portal/bits'
import { AppCards } from '@/components/portal/canvas-blocks'
import { kpi } from '@/components/portal/kpi'
import { MOCK_SWITCHER } from '../mock'
import { usePreviewStore } from '../preview-store'
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
type LinkRow = {
id: AppId
name: string
url: string
icon: AppSwitcherIconName
mode: 'JWT' | 'OIDC'
enabled: boolean
}
const ICONS: Record<AppSwitcherIconName, { label: string; Icon: LucideIcon }> = {
cloud: { label: 'Облако', Icon: CloudIcon },
server: { label: 'Сервер', Icon: ServerIcon },
globe: { label: 'Сеть', Icon: NetworkIcon },
dashboard: { label: 'Дашборд', Icon: LayoutDashboardIcon },
chart: { label: 'График', Icon: ChartColumnIcon }
}
/** Названия иконок, как они подписаны на доске. */
const BOARD_ICON: Record<string, string> = { cfdm: 'cloud', vps: 'server', bgp: 'globe', fw: 'shield', dns: 'globe', cdn: 'cloud', mm: 'layout' }
const MODES = [
{ value: 'JWT', label: 'JWT SSO (fragment)' },
{ value: 'OIDC', label: 'OIDC' },
] as const
const INITIAL: LinkRow[] = MOCK_SWITCHER.apps.map((a) => ({
id: a.id,
name: a.name,
url: a.url,
icon: a.icon,
mode: (a.authMode ?? 'jwt').toUpperCase() as 'JWT' | 'OIDC',
enabled: a.id !== 'mm'
}))
export function AppLinksPage() {
const { addChange, marksFor } = usePreviewStore()
const marks = marksFor('app-links')
const [rows, setRows] = useState<LinkRow[]>(INITIAL)
const rowsRef = useRef(rows)
useEffect(() => {
rowsRef.current = rows
}, [rows])
const [editing, setEditing] = useState<LinkRow | null>(null)
const [menuLabel, setMenuLabel] = useState('Приложения')
const [showCurrent, setShowCurrent] = useState(true)
const [newTab, setNewTab] = useState(false)
const setEnabled = useCallback(
(ids: AppId[], enabled: boolean) => {
const before = rowsRef.current
const names = before.filter((r) => ids.includes(r.id) && r.enabled !== enabled).map((r) => r.name)
if (names.length === 0) return
setRows((prev) => prev.map((r) => (ids.includes(r.id) ? { ...r, enabled } : r)))
addChange({
kind: 'chg',
title: enabled ? 'Показать в меню' : 'Скрыть из меню',
lines: names.map((n) => `${enabled ? '+' : '−'} ${n}`),
resource: 'app-links',
marks: Object.fromEntries(ids.map((i) => [i, 'dirty' as const])),
revert: () => setRows(before)
})
},
[addChange],
)
/** Переставляет строку: порядок строк = порядок в меню. */
const moveTo = useCallback(
(id: AppId, target: (index: number, length: number) => number) => {
const before = rowsRef.current
const from = before.findIndex((r) => r.id === id)
const to = Math.max(0, Math.min(before.length - 1, target(from, before.length)))
if (from < 0 || from === to) return
const next = [...before]
const [item] = next.splice(from, 1)
next.splice(to, 0, item!)
setRows(next)
addChange({
kind: 'chg',
title: 'Порядок изменён',
lines: [` ${item!.name}`, `− позиция ${from + 1}`, `+ позиция ${to + 1}`],
resource: 'app-links',
marks: { [item!.id]: 'dirty' },
revert: () => setRows(before)
})
},
[addChange],
)
/** Перетаскивание: строка встаёт на место той, на которую её бросили. */
const dropOn = useCallback(
(activeId: string, overId: string) => {
const to = rowsRef.current.findIndex((r) => r.id === overId)
if (to >= 0) moveTo(activeId as AppId, () => to)
},
[moveTo],
)
const resetOrder = useCallback(() => {
const before = rowsRef.current
if (before.every((r, i) => r.id === INITIAL[i]?.id)) return
setRows(INITIAL.map((i) => before.find((r) => r.id === i.id) ?? i))
addChange({
kind: 'chg',
title: 'Порядок сброшен',
lines: ['− пользовательский порядок', '+ порядок каталога'],
resource: 'app-links',
marks: Object.fromEntries(before.map((r) => [r.id, 'dirty' as const])),
revert: () => setRows(before)
})
}, [addChange])
const columns = useMemo<GridColumn<LinkRow>[]>(
() => [
{ id: 'name', title: 'Приложение', size: 260, cell: (r) => <TwoLineCell dot={r.enabled ? 'ok' : 'mu'} title={r.name} sub={r.id} /> },
{ id: 'status', title: 'Статус', size: 130, cell: (r) => <StatusBadge tone={r.enabled ? 'ok' : 'mu'}>{r.enabled ? 'В меню' : 'Скрыто'}</StatusBadge> },
{ id: 'icon', title: 'Иконка', size: 120, cell: (r) => <span className="font-mono text-[12.5px]">{BOARD_ICON[r.id] ?? r.icon}</span> },
{ id: 'url', title: 'URL', size: 260, cell: (r) => <span className="block truncate font-mono text-[12.5px]">{r.url}</span> },
{ id: 'mode', title: 'Вход', size: 90, cell: (r) => <span className="font-mono text-[12.5px]">{r.mode}</span> },
],
[],
)
const shown = rows.filter((r) => r.enabled)
return (
<>
<PageHead
title="Ссылки приложений"
sub="Каталог приложений и переключатель в шапке · общий для всех сервисов семейства"
actions={
<Button
variant="outline"
onClick={() =>
toast('Добавление недоступно в макете', {
description: 'Каталог фиксирован контрактом: cfdm · vps · bgp · fw · dns · cdn · mm.'
})
}
>
Добавить приложение
</Button>
}
/>
<KpiStrip
aria-label="Показатели"
items={[
kpi('n', 'Приложений', String(rows.length), { icon: LayoutGridIcon, badge: 'в каталоге' }),
kpi('sw', 'В переключателе', String(shown.length), { icon: EyeIcon, tone: 'ok', badge: 'видны всем' }),
kpi('jwt', 'JWT SSO', String(rows.filter((r) => r.mode === 'JWT').length), { icon: KeyRoundIcon, badge: 'fragment-токен' }),
kpi('oidc', 'OIDC', String(rows.filter((r) => r.mode === 'OIDC').length), { icon: LinkIcon, badge: 'Technitium DNS' }),
]}
/>
<Tabs defaultValue="list" className="min-w-0">
<PageTabsList>
<PageTab value="list" label="Приложения" count={rows.length} />
<PageTab value="menu" label="Меню" />
</PageTabsList>
<TabsContent value="list" className="min-w-0">
<SectionGrid
title="Ссылки сервисов"
description="Порядок строк = порядок в меню · переключатель управляет видимостью · изменения применяются после сохранения"
rows={rows}
columns={columns}
getRowId={(r) => r.id}
pageSize={10}
searchText={(r) => `${r.name} ${r.id} ${r.url}`}
searchPlaceholder="Название или адрес"
views={[
{ label: 'Все', match: () => true },
{ label: 'В меню', match: (r) => r.enabled },
{ label: 'Скрытые', match: (r) => !r.enabled },
]}
tools={
<Button type="button" variant="outline" onClick={resetOrder}>
Сбросить порядок
</Button>
}
bulk={{
onLabel: 'Показать в меню',
offLabel: 'Скрыть',
onRun: (ids) => setEnabled(ids as AppId[], true),
offRun: (ids) => setEnabled(ids as AppId[], false)
}}
rowSwitch={{ value: (r) => r.enabled, onChange: (r, on) => setEnabled([r.id], on) }}
rowMenu={(r) => ({
onOpen: () => setEditing(r),
toggleLabel: r.enabled ? 'Скрыть из меню' : 'Показать в меню',
onToggle: () => setEnabled([r.id], !r.enabled)
})}
onReorder={dropOn}
rowStatus={(r) => marks[r.id]}
onRowClick={setEditing}
/>
</TabsContent>
<TabsContent value="menu" className="flex min-w-0 flex-col gap-4">
<FormFrame title="Переключатель приложений" description="Как выглядит меню в шапке каждого приложения" badge="Опубликовано">
<SettingRow title="Заголовок меню" description="Показывается над списком приложений">
<Input value={menuLabel} onChange={(e) => setMenuLabel(e.target.value)} aria-label="Заголовок меню" className="w-56 max-w-full" />
</SettingRow>
<SettingRow title="Показывать текущее приложение" description="Отмечать галочкой приложение, из которого открыто меню">
<Switch checked={showCurrent} onCheckedChange={setShowCurrent} aria-label="Показывать текущее приложение" />
</SettingRow>
<SettingRow title="Открывать в новой вкладке" description="Иначе переход в текущей вкладке">
<Switch checked={newTab} onCheckedChange={setNewTab} aria-label="Открывать в новой вкладке" />
</SettingRow>
</FormFrame>
<AppCards
title="Предпросмотр меню"
description={`«${menuLabel}» · так увидят пользователи с доступом ко всем приложениям`}
items={shown.slice(0, 4).map((r) => {
const { Icon } = ICONS[r.icon]
return {
id: r.id,
title: r.name,
desc: r.url.replace('https://', ''),
badge: r.mode,
badgeVariant: r.mode === 'OIDC' ? ('warning-light' as const) : ('info-light' as const),
meta: r.id,
icon: <Icon />
}
})}
/>
</TabsContent>
</Tabs>
<PortalSheet
open={editing !== null}
onOpenChange={(o) => !o && setEditing(null)}
title="Приложение"
description="Название, иконка и адрес в переключателе"
footer={
<>
<Button variant="outline" onClick={() => setEditing(null)}>Отмена</Button>
<Button
onClick={() => {
setEditing(null)
toast.success('Приложение сохранено (макет)')
}}
>
Сохранить
</Button>
</>
}
>
{editing ? (
<>
<Field>
<FieldLabel htmlFor="al-name">Название <span aria-hidden>*</span></FieldLabel>
<Input id="al-name" defaultValue={editing.name} />
</Field>
<Field>
<FieldLabel htmlFor="al-icon">Иконка</FieldLabel>
<Select
defaultValue={editing.icon}
items={Object.entries(ICONS).map(([value, v]) => ({ value, label: v.label }))}
>
<SelectTrigger id="al-icon" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{Object.entries(ICONS).map(([value, v]) => (
<SelectItem key={value} value={value}>
{v.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="al-url">URL <span aria-hidden>*</span></FieldLabel>
<Input id="al-url" defaultValue={editing.url} className="font-mono" />
<FieldDescription>https · домен из allowlist return_to</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="al-mode">Режим входа</FieldLabel>
<Select defaultValue={editing.mode} items={[...MODES]}>
<SelectTrigger id="al-mode" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{MODES.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field orientation="horizontal">
<Switch id="al-on" defaultChecked={editing.enabled} />
<div className="flex flex-col">
<FieldLabel htmlFor="al-on">Включено в переключатель</FieldLabel>
<FieldDescription>Скрытое приложение остаётся доступным по прямой ссылке</FieldDescription>
</div>
</Field>
</>
) : null}
</PortalSheet>
</>
)
}
@@ -0,0 +1,88 @@
import { Link } from '@tanstack/react-router'
import { BadgeCheckIcon, CloudIcon, GlobeIcon, KeyRoundIcon, LayoutDashboardIcon, LayoutGridIcon, ServerIcon, ShieldIcon } from 'lucide-react'
import { toast } from 'sonner'
import { APPS, type AppId } from '@authportal/shared'
import { KpiStrip } from '@/components/portal/kpi-strip'
import { Button } from '@authportal/ui/components/button'
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { PageHead, PageTab, PageTabsList } from '@/components/portal/bits'
import { AppCards, type AppCardItem } from '@/components/portal/canvas-blocks'
import { kpi } from '@/components/portal/kpi'
const ICONS: Record<AppId, typeof CloudIcon> = {
cfdm: CloudIcon,
vps: ServerIcon,
bgp: GlobeIcon,
fw: ShieldIcon,
dns: GlobeIcon,
cdn: CloudIcon,
mm: LayoutDashboardIcon
}
const NO_ACCESS: AppId[] = ['cdn']
export function AppsPage() {
const allowed = APPS.filter((a) => !NO_ACCESS.includes(a.id))
const denied = APPS.filter((a) => NO_ACCESS.includes(a.id))
const toCard = (app: (typeof APPS)[number], open: boolean): AppCardItem => {
const Icon = ICONS[app.id]
const meta = `${app.url.replace('https://', '')} · ${app.authMode === 'oidc' ? 'OIDC' : 'JWT'}`
return {
id: app.id,
title: app.title,
desc: app.description,
badge: open ? 'Открыть' : 'Нет доступа',
badgeVariant: open ? 'info-light' : 'outline',
meta,
icon: <Icon />,
onSelect: () =>
open
? toast(`Открыть ${app.title}`, { description: `${meta} (макет)` })
: toast.warning(`Нет доступа к ${app.title}`, { description: 'Запросите права у администратора.' })
}
}
return (
<>
<PageHead
title="Приложения"
sub="Открыть приложение с текущей сессией · SSO handoff без повторного ввода пароля"
actions={
<Button variant="outline" render={<Link to="/design-preview/app-links" />} nativeButton={false}>
Ссылки приложений
</Button>
}
/>
<KpiStrip
aria-label="Показатели"
items={[
kpi('avail', 'Доступно приложений', String(allowed.length), { icon: LayoutGridIcon, tone: 'ok', badge: `из ${APPS.length}` }),
kpi('jwt', 'JWT SSO', String(allowed.filter((a) => a.authMode === 'jwt').length), { icon: KeyRoundIcon, badge: 'fragment-токен' }),
kpi('oidc', 'OIDC', String(allowed.filter((a) => a.authMode === 'oidc').length), { icon: BadgeCheckIcon, badge: 'Technitium DNS' }),
kpi('role', 'Роль', 'Админ', { icon: ShieldIcon, badge: 'is_admin' }),
]}
/>
<Tabs defaultValue="all" className="min-w-0">
<PageTabsList>
<PageTab value="all" label="Доступные" count={allowed.length} />
<PageTab value="no" label="Без доступа" count={denied.length} />
</PageTabsList>
<TabsContent value="all" className="min-w-0">
<AppCards
title="Мои приложения"
description="Клик открывает приложение в новой вкладке · портал заново выпускает токен с актуальными правами"
items={allowed.map((a) => toCard(a, true))}
/>
</TabsContent>
<TabsContent value="no" className="min-w-0">
<AppCards
title="Недоступные приложения"
description="Права не выданы · запросите у администратора"
items={denied.map((a) => toCard(a, false))}
/>
</TabsContent>
</Tabs>
</>
)
}
@@ -0,0 +1,156 @@
import { HistoryIcon, NetworkIcon, OctagonAlertIcon, UserCogIcon } from 'lucide-react'
import { useState } from 'react'
import { toast } from 'sonner'
import { KpiStrip } from '@/components/portal/kpi-strip'
import { Button } from '@authportal/ui/components/button'
import { Input } from '@authportal/ui/components/input'
import { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { CodeBlock, FormFrame, KvList, PageHead, PageTab, PageTabsList, PortalSheet, SettingRow, StatusBadge, TypedConfirm } from '@/components/portal/bits'
import { ActivityFeed } from '@/components/portal/canvas-blocks'
import { kpi } from '@/components/portal/kpi'
import { AUDIT_FEED, AUDIT_ROWS, type AuditRow } from '../mock'
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
const mono = (v: string) => <span className="font-mono text-[12.5px] whitespace-nowrap">{v}</span>
const columns: GridColumn<AuditRow>[] = [
{ id: 'action', title: 'Событие', size: 340, sortValue: (r) => r.action, cell: (r) => <TwoLineCell dot={r.tone} title={<span className="font-mono text-[13px]">{r.action}</span>} sub={r.summary} /> },
{ id: 'status', title: 'Статус', size: 120, cell: (r) => <StatusBadge tone={r.tone}>{r.label}</StatusBadge> },
{ id: 'src', title: 'Источник', size: 160, sortValue: (r) => r.source, cell: (r) => mono(r.source) },
{ id: 'who', title: 'Кто', size: 120, cell: (r) => mono(r.who) },
{ id: 'ip', title: 'IP', size: 140, cell: (r) => mono(r.ip) },
{ id: 'when', title: 'Когда', size: 140, cell: (r) => mono(r.when) },
]
const VIEWS = [
{ label: 'Все', match: () => true },
{ label: 'Warning и выше', match: (r: AuditRow) => r.tone === 'wn' || r.tone === 'er' },
{ label: 'Critical', match: (r: AuditRow) => r.tone === 'er' },
]
export function AuditPage() {
const [entry, setEntry] = useState<AuditRow | null>(null)
const [purgeOpen, setPurgeOpen] = useState(false)
const [tab, setTab] = useState('feed')
const [retention, setRetention] = useState(90)
return (
<>
<PageHead
title="Изменения"
sub="Журнал действий администраторов и приложений · портал, EvoFirewall, EvoBGP, MikrotikManager и другие через ingest"
actions={
<Button variant="outline" onClick={() => setPurgeOpen(true)}>
Очистить старые записи…
</Button>
}
/>
<KpiStrip
aria-label="Показатели"
items={[
kpi('all', 'Событий', '148', { icon: HistoryIcon, badge: 'за 7 дней' }),
kpi('acc', 'Изменений прав', '22', { icon: UserCogIcon, tone: 'ok', badge: 'user.access_update' }),
kpi('crit', 'Критичных', '3', { icon: OctagonAlertIcon, tone: 'er', badge: 'critical' }),
kpi('src', 'Источников', '6', { icon: NetworkIcon, badge: 'портал +5 приложений' }),
]}
/>
<Tabs value={tab} onValueChange={setTab} className="min-w-0">
<PageTabsList>
<PageTab value="feed" label="Лента" />
<PageTab value="table" label="Таблица" count={148} />
<PageTab value="retention" label="Ротация" />
</PageTabsList>
<TabsContent value="feed" className="min-w-0">
<ActivityFeed
title="Лента изменений"
description="Последние события · иконка отражает тип, цвет — важность"
items={AUDIT_FEED}
action={
<Button variant="outline" onClick={() => setTab('table')}>
Весь журнал
</Button>
}
/>
</TabsContent>
<TabsContent value="table" className="min-w-0">
<SectionGrid
title="Журнал изменений"
description="Поиск по событию, пользователю и IP · виды по важности"
rows={AUDIT_ROWS}
columns={columns}
getRowId={(r) => r.id}
searchText={(r) => `${r.action} ${r.summary} ${r.who} ${r.ip}`}
views={VIEWS}
footer="Показано 8 из 148"
onRowClick={setEntry}
/>
</TabsContent>
<TabsContent value="retention">
<FormFrame title="Ротация журнала" description="Сколько хранить записи и как очищать" badge={`${retention} дней`}>
<SettingRow title="Срок хранения" description="Записи старше будут удаляться автоматически">
<Input
key={retention}
defaultValue={`${retention} дней`}
aria-label="Срок хранения"
className="w-40 max-w-full font-mono"
onBlur={(e) => {
const n = Number.parseInt(e.target.value, 10)
if (Number.isFinite(n) && n >= 7 && n <= 3650 && n !== retention) {
setRetention(n)
toast.success(`Срок хранения: ${n} дн. (макет)`)
}
}}
/>
</SettingRow>
<SettingRow title="Автоматическая очистка" description="Раз в сутки в 03:00">
<Switch defaultChecked aria-label="Автоматическая очистка" />
</SettingRow>
<SettingRow title="Записей в журнале" description="Из них старше срока хранения — 212">
<StatusBadge tone="mu">1 482</StatusBadge>
</SettingRow>
</FormFrame>
</TabsContent>
</Tabs>
<PortalSheet
open={entry !== null}
onOpenChange={(o) => !o && setEntry(null)}
title="Событие журнала"
description="Детали записи"
footer={<Button variant="outline" onClick={() => setEntry(null)}>Закрыть</Button>}
>
{entry ? (
<>
<KvList items={[['Событие', `${entry.action} · ${entry.summary}`]]} />
<div>
<StatusBadge tone={entry.tone}>{entry.label}</StatusBadge>
</div>
<KvList
items={[
['Источник', entry.source],
['Кто', `${entry.who}@shnt.top · ${entry.ip}`],
]}
/>
<CodeBlock label="Подробности">
{'target_type: user\ntarget_id: 7c1e…a3\nbefore: { disabled: false }\nafter: { disabled: true }'}
</CodeBlock>
</>
) : null}
</PortalSheet>
<TypedConfirm
open={purgeOpen}
onOpenChange={setPurgeOpen}
title="Очистить старые записи?"
description={`Будут безвозвратно удалены 212 записей старше ${retention} дней. Само действие попадёт в журнал как audit.purge.`}
expected="очистить"
confirmLabel="Очистить"
onConfirm={() => {
setPurgeOpen(false)
toast.success('Очищено 212 записей (макет)')
}}
/>
</>
)
}
@@ -0,0 +1,150 @@
import { useState, type FormEvent } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { EyeIcon, EyeOffIcon, FingerprintIcon, HistoryIcon, ShieldCheckIcon } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import { AuthLogo } from '@/components/auth-logo'
import { Button } from '@authportal/ui/components/button'
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input'
import { Separator } from '@authportal/ui/components/separator'
import { SignalAlert } from '@/components/portal/bits'
const PERKS = [
{ icon: FingerprintIcon, title: 'Passkey вместо пароля', text: 'Отпечаток, Face ID или ключ безопасности' },
{ icon: ShieldCheckIcon, title: 'Права по приложениям и разделам', text: 'Чтение, запись и администрирование для каждого сервиса' },
{ icon: HistoryIcon, title: 'Журнал входов и изменений', text: 'Кто, когда и откуда заходил в сервисы' },
]
/** Страница входа вне оболочки. Данные не отправляются: «Войти» имитирует запрос. */
export function LoginPage() {
const navigate = useNavigate()
const [shown, setShown] = useState(false)
const [error, setError] = useState(false)
const [pending, setPending] = useState(false)
function submit(e: FormEvent) {
e.preventDefault()
setPending(true)
window.setTimeout(() => {
setPending(false)
if (error) {
void navigate({ to: '/design-preview/apps' })
} else {
setError(true)
}
}, 600)
}
return (
<main className="bg-background flex min-h-svh items-center justify-center px-4 py-8 md:px-6">
<div className="grid w-full max-w-5xl items-center gap-12 lg:grid-cols-2">
<aside className="hidden flex-col gap-6 lg:flex">
<div className="flex items-center gap-3">
<AuthLogo className="size-10" />
<div className="flex flex-col">
<b className="text-base font-semibold">Auth Portal</b>
<span className="text-muted-foreground text-sm">auth.shnt.top</span>
</div>
</div>
<h1 className="text-3xl font-semibold tracking-tight text-balance">
Один вход для всех сервисов shnt.top
</h1>
<p className="text-muted-foreground text-pretty">
Войдите один раз — и открывайте CFDM, VPS Tracker, EvoBGP, EvoFirewall, CDN Manager, MikrotikManager и
Technitium DNS без повторного ввода пароля.
</p>
<Frame dense spacing="sm" className="max-w-md">
<FramePanel className="flex flex-col gap-3 py-3">
{PERKS.map(({ icon: Icon, title, text }) => (
<div key={title} className="flex items-start gap-3">
<IconTile variant="elevated" size="sm" aria-hidden className="text-muted-foreground">
<Icon />
</IconTile>
<div className="flex flex-col">
<b className="text-sm font-medium">{title}</b>
<span className="text-muted-foreground text-xs">{text}</span>
</div>
</div>
))}
</FramePanel>
</Frame>
</aside>
<Frame className="mx-auto w-full max-w-md">
<FrameHeader>
<FrameTitle className="text-xl">Вход в портал</FrameTitle>
<FrameDescription>Введите email и пароль или используйте passkey</FrameDescription>
</FrameHeader>
<FramePanel>
<form className="flex flex-col gap-4" onSubmit={submit} noValidate>
<div className="flex items-start gap-2 text-sm" role="status">
<Badge variant="info-light" size="sm">SSO</Badge>
<span>
После входа вы вернётесь в <b className="font-semibold">EvoFirewall</b> · fw.shnt.top
</span>
</div>
{error ? (
<SignalAlert tone="er" title="Ошибка входа">
Неверный email или пароль. Осталось 4 попытки.
</SignalAlert>
) : null}
<FieldGroup>
<Field data-invalid={error}>
<FieldLabel htmlFor="dp-email">Email</FieldLabel>
<Input id="dp-email" type="email" defaultValue="admin@shnt.top" autoComplete="username" aria-invalid={error} />
</Field>
<Field data-invalid={error}>
<FieldLabel htmlFor="dp-password">Пароль</FieldLabel>
<div className="relative">
<Input
id="dp-password"
type={shown ? 'text' : 'password'}
defaultValue="correct-horse"
autoComplete="current-password"
aria-invalid={error}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-pressed={shown}
aria-label={shown ? 'Скрыть пароль' : 'Показать пароль'}
className="absolute top-1/2 right-1 -translate-y-1/2"
onClick={() => setShown((v) => !v)}
>
{shown ? <EyeOffIcon aria-hidden /> : <EyeIcon aria-hidden />}
</Button>
</div>
{error ? <FieldError>Проверьте пароль и повторите попытку</FieldError> : null}
</Field>
</FieldGroup>
<Button type="submit" disabled={pending} className="w-full">
{pending ? 'Вход…' : 'Войти'}
</Button>
<div className="text-muted-foreground flex items-center gap-3 text-xs">
<Separator className="flex-1" />
или
<Separator className="flex-1" />
</div>
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => void navigate({ to: '/design-preview/apps' })}
>
<FingerprintIcon aria-hidden />
Войти с passkey
</Button>
<FieldDescription className="text-center">
Пароль остаётся запасным входом. Passkey добавляется в разделе «Безопасность».
</FieldDescription>
</form>
</FramePanel>
</Frame>
</div>
</main>
)
}
@@ -0,0 +1,154 @@
import { CircleCheckIcon, GlobeIcon, MonitorSmartphoneIcon, ShieldAlertIcon } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { toast } from 'sonner'
import { KpiStrip } from '@/components/portal/kpi-strip'
import { Button } from '@authportal/ui/components/button'
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { ExplainBanner } from '@/components/portal/explain-banner'
import { Segmented, CodeBlock, KvList, PageHead, PortalSheet, StatusBadge, PageTab, PageTabsList } from '@/components/portal/bits'
import { kpi } from '@/components/portal/kpi'
import { MOCK_LOGINS, MOCK_SESSIONS, type LoginRow, type SessionRow } from '../mock'
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
const PERIODS = ['24 ч', '7 д', '30 д', '90 д']
export function LoginsPage() {
const navigate = useNavigate()
const [period, setPeriod] = useState(0)
const [event, setEvent] = useState<LoginRow | null>(null)
const [sessions, setSessions] = useState<SessionRow[]>(MOCK_SESSIONS)
const loginCols = useMemo<GridColumn<LoginRow>[]>(
() => [
{ id: 'login', title: 'Логин', size: 260, sortValue: (r) => r.login, cell: (r) => <TwoLineCell dot={r.tone} title={r.login} sub={r.client} /> },
{ id: 'status', title: 'Статус', size: 120, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> },
{ id: 'time', title: 'Время', size: 130, cell: (r) => <span className="font-mono text-[12.5px] whitespace-nowrap">{r.time}</span> },
{ id: 'ip', title: 'IP', size: 150, sortValue: (r) => r.ip, cell: (r) => <span className="font-mono text-[12.5px]">{r.ip}</span> },
{ id: 'app', title: 'Приложение', size: 160, cell: (r) => <span className="font-mono text-[12.5px]">{r.app}</span> },
{ id: 'reason', title: 'Причина', size: 200, cell: (r) => <span className="font-mono text-[12.5px]">{r.reason}</span> },
],
[],
)
const sessionCols = useMemo<GridColumn<SessionRow>[]>(
() => [
{ id: 'user', title: 'Пользователь', size: 240, sortValue: (r) => r.user, cell: (r) => <TwoLineCell dot={r.tone} title={r.user} sub={r.note || undefined} /> },
{ id: 'status', title: 'Статус', size: 110, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> },
{ id: 'ip', title: 'IP · клиент', size: 260, cell: (r) => <span className="font-mono text-[12.5px]">{r.ipClient}</span> },
{ id: 'created', title: 'Создана', size: 140, cell: (r) => <span className="font-mono text-[12.5px] whitespace-nowrap">{r.created}</span> },
{ id: 'exp', title: 'Истекает', size: 120, cell: (r) => <span className="font-mono text-[12.5px] whitespace-nowrap">{r.expires}</span> },
],
[],
)
return (
<>
<PageHead
title="Входы"
sub="Журнал входов и активные сессии · портал и SSO в приложения"
actions={
<Button variant="outline" onClick={() => toast.success('CSV сформирован (макет)')}>
Экспорт CSV
</Button>
}
/>
<ExplainBanner
tone="er"
title="Подозрительная серия неудач: ops@shnt.top."
text="6 неудачных входов с 203.0.113.58 за 10 минут. Действия: «Показать», отключить пользователя в разделе «Пользователи»."
>
6 неудачных входов с 203.0.113.58 за 10 минут. Действия: «Показать», отключить пользователя в разделе «Пользователи».
</ExplainBanner>
<Segmented label="Период" options={PERIODS} value={period} onChange={setPeriod} />
<KpiStrip
aria-label="Показатели"
items={[
kpi('ok', 'Успешные (24ч)', '38', { icon: CircleCheckIcon, tone: 'ok', badge: 'входы и SSO' }),
kpi('fail', 'Неудачи (24ч)', '6', { icon: ShieldAlertIcon, tone: 'er', badge: 'неверный пароль' }),
kpi('ip', 'Уникальные IP (24ч)', '9', { icon: GlobeIcon, badge: '2 новых' }),
kpi('sess', 'Активные сессии', String(sessions.length), { icon: MonitorSmartphoneIcon, badge: '3 пользователя' }),
]}
/>
<Tabs defaultValue="hist" className="min-w-0">
<PageTabsList>
<PageTab value="hist" label="История" count={'44'} />
<PageTab value="sess" label="Активные сессии" count={sessions.length} />
</PageTabsList>
<TabsContent value="hist" className="flex min-w-0 flex-col gap-4">
<SectionGrid
title="Журнал входов"
description="Поиск по логину и IP · фильтры: результат, приложение, пользователь · период выбирается выше"
rows={MOCK_LOGINS}
columns={loginCols}
getRowId={(r) => r.id}
footer="Показано 8 из 44"
searchText={(r) => `${r.login} ${r.ip} ${r.app}`}
searchPlaceholder="Логин или IP"
views={[
{ label: 'Все', match: () => true },
{ label: 'Успешные', match: (r) => r.tone === 'ok' || r.tone === 'in' },
{ label: 'Неудачные', match: (r) => r.tone === 'er' || r.tone === 'wn' },
]}
onRowClick={setEvent}
/>
</TabsContent>
<TabsContent value="sess" className="flex min-w-0 flex-col gap-4">
<SectionGrid
title="Активные сессии"
description="Токены, действующие сейчас · завершение сессии выкидывает пользователя из портала и приложений"
rows={sessions}
columns={sessionCols}
getRowId={(r) => r.id}
emptyMessage="Нет активных сессий."
rowMenu={(r) => ({
toggleLabel: 'Завершить сессию',
onToggle: () => {
setSessions((s) => s.filter((x) => x.id !== r.id))
toast.success(`Сессия ${r.user} завершена (макет)`)
}
})}
/>
</TabsContent>
</Tabs>
<PortalSheet
open={event !== null}
onOpenChange={(o) => !o && setEvent(null)}
title="Событие входа"
description="Детали записи журнала"
footer={
<>
<Button variant="outline" onClick={() => setEvent(null)}>Закрыть</Button>
<Button
variant="outline"
onClick={() => {
setEvent(null)
void navigate({ to: '/design-preview/users' })
}}
>
Открыть пользователя
</Button>
</>
}
>
{event ? (
<>
<div>
<StatusBadge tone={event.tone}>{event.status}</StatusBadge>
</div>
<KvList
items={[
['Логин', event.login],
['IP', `${event.ip} · AS64500`],
['Клиент', event.client],
['Причина', event.reason],
]}
/>
<CodeBlock label="Событие">{`action: ${event.tone === 'er' ? 'auth.login_failed' : 'auth.login'}\nsource_app: portal\nseverity: ${event.tone === 'er' ? 'warning' : 'info'}\nip: ${event.ip}`}</CodeBlock>
</>
) : null}
</PortalSheet>
</>
)
}
@@ -0,0 +1,245 @@
import { useMemo, useState } from 'react'
import { CircleCheckIcon, CopyIcon, KeyRoundIcon, LockKeyholeIcon, TicketIcon } from 'lucide-react'
import { toast } from 'sonner'
import type { CreateOidcClientValues } from '@/components/reui-kit/create-oidc-client-sheet'
import { CreateOidcPortalSheet } from '../create-sheets'
import { KpiStrip } from '@/components/portal/kpi-strip'
import { Button } from '@authportal/ui/components/button'
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@authportal/ui/components/alert-dialog'
import { Field, FieldDescription, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input'
import { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { Textarea } from '@authportal/ui/components/textarea'
import { ExplainBanner } from '@/components/portal/explain-banner'
import { CopyList, KvFrame, PageHead, PortalSheet, StatusBadge, TypedConfirm, PageTab, PageTabsList } from '@/components/portal/bits'
import { kpi } from '@/components/portal/kpi'
import {
MOCK_CLIENT_SECRET,
MOCK_OIDC_CLIENTS,
OIDC_CAPABILITIES,
OIDC_ENDPOINTS,
type OidcClientRow,
type Tone
} from '../mock'
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
function tone(c: OidcClientRow): Tone {
return !c.enabled ? 'mu' : c.stale ? 'wn' : 'ok'
}
function statusLabel(c: OidcClientRow) {
return !c.enabled ? 'Выключен' : c.stale ? 'Секрет устарел' : 'Включён'
}
export function OidcPage() {
const [clients, setClients] = useState<OidcClientRow[]>(MOCK_OIDC_CLIENTS)
const [createOpen, setCreateOpen] = useState(false)
const [editing, setEditing] = useState<OidcClientRow | null>(null)
const [rotating, setRotating] = useState<OidcClientRow | null>(null)
const [secret, setSecret] = useState<string | null>(null)
const columns = useMemo<GridColumn<OidcClientRow>[]>(
() => [
{ id: 'name', title: 'Клиент', size: 260, sortValue: (r) => r.name, cell: (r) => <TwoLineCell dot={tone(r)} title={r.name} sub={r.hint} /> },
{ id: 'status', title: 'Статус', size: 150, cell: (r) => <StatusBadge tone={tone(r)}>{statusLabel(r)}</StatusBadge> },
{ id: 'cid', title: 'client_id', size: 150, cell: (r) => <code className="font-mono text-[12.5px]">{r.clientId}</code> },
{ id: 'redirect', title: 'Redirect URI', size: 320, cell: (r) => <code className="block truncate font-mono text-[12.5px]">{r.redirect}</code> },
{ id: 'secret', title: 'Секрет', size: 140, cell: (r) => <span className="font-mono text-[12.5px]">{r.secret}</span> },
{ id: 'created', title: 'Создан', size: 120, cell: (r) => <span className="font-mono text-[12.5px]">{r.created}</span> },
],
[],
)
function onCreate(values: CreateOidcClientValues) {
const created: OidcClientRow = {
id: `c-${clients.length + 1}`,
name: values.name,
hint: `${values.redirect_uris[0]?.replace(/^https:\/\//, '').split('/')[0] ?? ''} · authorization_code`,
clientId: values.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'client',
redirect: values.redirect_uris[0] ?? '',
secret: 'действует',
created: '17.10.2026',
enabled: values.enabled,
stale: false
}
setClients((c) => [...c, created])
setCreateOpen(false)
setSecret(MOCK_CLIENT_SECRET)
}
const staleCount = clients.filter((c) => c.stale && c.enabled).length
return (
<>
<PageHead
title="OIDC-клиенты"
sub="Портал как провайдер OpenID Connect · внешние приложения входят через auth.shnt.top"
actions={
<Button onClick={() => setCreateOpen(true)}>
Создать клиента
</Button>
}
/>
{staleCount > 0 ? (
<ExplainBanner
tone="wn"
title="Секрет клиента Technitium DNS старше 90 дней."
text="Обновите client_secret и внесите новое значение в Technitium (Settings → SSO). Старый секрет перестанет работать сразу после обновления."
>
Обновите client_secret и внесите новое значение в Technitium (Settings → SSO). Старый секрет перестанет работать сразу после обновления.
</ExplainBanner>
) : null}
<KpiStrip
aria-label="Показатели"
items={[
kpi('n', 'Клиентов', String(clients.length), { icon: KeyRoundIcon, badge: 'зарегистрировано' }),
kpi('on', 'Включено', String(clients.filter((c) => c.enabled).length), { icon: CircleCheckIcon, tone: 'ok', badge: 'принимают вход' }),
kpi('tok', 'Токенов за 24 ч', '41', { icon: TicketIcon, badge: 'authorization_code' }),
kpi('key', 'Ключ подписи', 'RS256', { icon: LockKeyholeIcon, badge: 'ротация вручную' }),
]}
/>
<Tabs defaultValue="clients" className="min-w-0">
<PageTabsList>
<PageTab value="clients" label="Клиенты" count={clients.length} />
<PageTab value="meta" label="Параметры issuer" />
</PageTabsList>
<TabsContent value="clients" className="flex min-w-0 flex-col gap-4">
<SectionGrid
title="Зарегистрированные клиенты"
description="Каждый клиент — внешнее приложение (RP) · секрет показывается один раз при создании и обновлении"
rows={clients}
columns={columns}
getRowId={(r) => r.id}
searchText={(r) => `${r.name} ${r.clientId} ${r.redirect}`}
searchPlaceholder="Название или client_id"
views={[
{ label: 'Все', match: () => true },
{ label: 'Включённые', match: (r) => r.enabled },
{ label: 'Выключенные', match: (r) => !r.enabled },
]}
tools={
<Button
type="button"
variant="outline"
onClick={() => setRotating(clients.find((c) => c.stale) ?? clients[0] ?? null)}
>
Обновить секрет…
</Button>
}
rowSwitch={{ value: (r) => r.enabled, onChange: (r, on) => setClients((c) => c.map((x) => (x.id === r.id ? { ...x, enabled: on } : x))) }}
rowMenu={(r) => ({
onOpen: () => setEditing(r),
toggleLabel: r.enabled ? 'Выключить' : 'Включить',
onToggle: () => setClients((c) => c.map((x) => (x.id === r.id ? { ...x, enabled: !x.enabled } : x)))
})}
onRowClick={setEditing}
/>
</TabsContent>
<TabsContent value="meta" className="flex min-w-0 flex-col gap-4">
<CopyList title="Параметры для подключения приложения" description="Скопируйте в настройки OIDC внешнего приложения" items={OIDC_ENDPOINTS} />
<KvFrame title="Поддерживаемые возможности" description="Из discovery-документа" items={OIDC_CAPABILITIES} />
</TabsContent>
</Tabs>
<CreateOidcPortalSheet open={createOpen} onOpenChange={setCreateOpen} onSubmit={onCreate} />
<PortalSheet
open={editing !== null}
onOpenChange={(o) => !o && setEditing(null)}
title="OIDC-клиент"
description="Параметры клиента"
footer={
<>
<Button variant="outline" onClick={() => setEditing(null)}>Закрыть</Button>
<Button
onClick={() => {
setEditing(null)
toast.success('Клиент сохранён (макет)')
}}
>
Сохранить
</Button>
</>
}
>
{editing ? (
<>
<Field>
<FieldLabel htmlFor="oc-name">Название</FieldLabel>
<Input id="oc-name" defaultValue={editing.name} />
</Field>
<Field>
<FieldLabel htmlFor="oc-id">client_id</FieldLabel>
<Input id="oc-id" value={editing.clientId} readOnly />
<FieldDescription>Не меняется</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="oc-uri">Redirect URI</FieldLabel>
<Textarea id="oc-uri" defaultValue={editing.redirect} rows={3} />
</Field>
<Field orientation="horizontal">
<Switch id="oc-on" defaultChecked={editing.enabled} />
<div className="flex flex-col">
<FieldLabel htmlFor="oc-on">Клиент включён</FieldLabel>
<FieldDescription>Выключенный клиент не может получить токены</FieldDescription>
</div>
</Field>
</>
) : null}
</PortalSheet>
<TypedConfirm
open={rotating !== null}
onOpenChange={(o) => !o && setRotating(null)}
title="Обновить client_secret?"
description="Выпустится новый секрет, старый перестанет работать сразу. После подтверждения новое значение будет показано один раз — скопируйте его в приложение."
expected="обновить"
confirmLabel="Обновить секрет"
onConfirm={() => {
const target = rotating
setRotating(null)
if (target) setClients((c) => c.map((x) => (x.id === target.id ? { ...x, stale: false, secret: 'действует' } : x)))
setSecret(MOCK_CLIENT_SECRET)
}}
/>
<AlertDialog open={secret !== null} onOpenChange={(o) => !o && setSecret(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Сохраните client_secret</AlertDialogTitle>
<AlertDialogDescription>
Секрет показывается один раз. Потом восстановить его нельзя — только выпустить заново.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex items-center gap-2 rounded-md border p-2">
<code className="min-w-0 flex-1 font-mono text-[13px] break-all">{secret}</code>
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label="Скопировать секрет"
onClick={() => {
void navigator.clipboard?.writeText(secret ?? '').catch(() => undefined)
toast.success('Секрет скопирован')
}}
>
<CopyIcon aria-hidden />
</Button>
</div>
<AlertDialogFooter>
<Button type="button" onClick={() => setSecret(null)}>
Я скопировал секрет
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
@@ -0,0 +1,233 @@
import { useMemo, useState } from 'react'
import { ClockIcon, FingerprintIcon, KeyRoundIcon, LockIcon, MonitorSmartphoneIcon, TrashIcon } from 'lucide-react'
import { toast } from 'sonner'
import { KpiStrip } from '@/components/portal/kpi-strip'
import { Button } from '@authportal/ui/components/button'
import { Field, FieldDescription, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input'
import { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { ExplainBanner } from '@/components/portal/explain-banner'
import { KvFrame, KvList, PageHead, PortalSheet, SignalAlert, StatusBadge, TypedConfirm, PageTab, PageTabsList, FormFrame, SettingRow } from '@/components/portal/bits'
import { kpi } from '@/components/portal/kpi'
import { MOCK_PASSKEYS, type PasskeyRow } from '../mock'
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
export function SecurityPage() {
const [keys, setKeys] = useState<PasskeyRow[]>(MOCK_PASSKEYS)
const [addOpen, setAddOpen] = useState(false)
const [newName, setNewName] = useState('Рабочий ноутбук')
const [selected, setSelected] = useState<PasskeyRow | null>(null)
const [renameValue, setRenameValue] = useState('')
const [deleting, setDeleting] = useState<PasskeyRow | null>(null)
const [revoked, setRevoked] = useState<string[]>([])
const view = useMemo(
() => keys.map((k) => (revoked.includes(k.id) ? { ...k, tone: 'mu' as const, status: 'Отозван' } : k)),
[keys, revoked],
)
const columns = useMemo<GridColumn<PasskeyRow>[]>(
() => [
{ id: 'name', title: 'Passkey', size: 280, sortValue: (r) => r.name, cell: (r) => <TwoLineCell dot={r.tone} title={r.name} sub={r.note} /> },
{ id: 'status', title: 'Статус', size: 210, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> },
{ id: 'kind', title: 'Тип', size: 160, cell: (r) => <span className="font-mono text-[12.5px]">{r.kind}</span> },
{ id: 'added', title: 'Добавлен', size: 120, cell: (r) => <span className="font-mono text-[12.5px]">{r.added}</span> },
{ id: 'last', title: 'Последний вход', size: 160, cell: (r) => <span className="font-mono text-[12.5px]">{r.lastUsed}</span> },
],
[],
)
return (
<>
<PageHead
title="Безопасность"
sub="Passkey как альтернатива паролю · пароль остаётся запасным входом"
actions={
<Button onClick={() => setAddOpen(true)}>
Добавить passkey
</Button>
}
/>
<ExplainBanner
tone="wn"
title="Один passkey — единственная копия."
text="Если устройство будет потеряно, вход возможен только по паролю. Добавьте второй ключ на другом устройстве или ключ безопасности."
>
Если устройство будет потеряно, вход возможен только по паролю. Добавьте второй ключ на другом устройстве или ключ безопасности.
</ExplainBanner>
<KpiStrip
aria-label="Показатели"
items={[
kpi('keys', 'Passkeys', String(keys.length), { icon: FingerprintIcon, tone: 'ok', badge: 'активны' }),
kpi('last', 'Последний вход', 'сегодня', { icon: ClockIcon, badge: 'MacBook Touch ID' }),
kpi('pw', 'Пароль', 'Задан', { icon: LockIcon, badge: 'запасной вход' }),
kpi('sess', 'Сессий', '3', { icon: MonitorSmartphoneIcon, badge: 'устройства' }),
]}
/>
<Tabs defaultValue="keys" className="min-w-0">
<PageTabsList>
<PageTab value="keys" label="Passkeys" count={keys.length} />
<PageTab value="pass" label="Пароль" />
<PageTab value="acc" label="Учётная запись" />
</PageTabsList>
<TabsContent value="keys" className="flex min-w-0 flex-col gap-4">
<SectionGrid
title="Мои passkeys"
description="Вход без пароля: отпечаток, Face ID, PIN или ключ безопасности · удаление необратимо"
rows={view}
columns={columns}
getRowId={(r) => r.id}
searchText={(r) => `${r.name} ${r.note} ${r.kind}`}
views={[
{ label: 'Все', match: () => true },
{ label: 'Давно не используются', match: (r) => r.tone === 'wn' },
]}
rowSwitch={{
value: (r) => r.status !== 'Отозван',
onChange: (r, on) => {
setRevoked((list) => (on ? list.filter((id) => id !== r.id) : [...list, r.id]))
toast(on ? 'Passkey включён (макет)' : 'Passkey отозван (макет)')
}
}}
rowMenu={(r) => ({
onOpen: () => {
setSelected(r)
setRenameValue(r.name)
},
toggleLabel: r.status === 'Отозван' ? 'Включить' : 'Отозвать',
onToggle: () => setRevoked((list) => (list.includes(r.id) ? list.filter((id) => id !== r.id) : [...list, r.id])),
onDelete: () => setDeleting(r)
})}
onRowClick={(r) => {
setSelected(r)
setRenameValue(r.name)
}}
/>
</TabsContent>
<TabsContent value="pass">
<FormFrame title="Пароль" description="Запасной вход, если passkey недоступен" badge="Задан">
<SettingRow title="Последняя смена" description="Рекомендуем менять раз в год">
<StatusBadge tone="mu">11.03.2026</StatusBadge>
</SettingRow>
<SettingRow title="Текущий пароль" description="Для подтверждения смены">
<Input type="password" defaultValue="••••••••••" autoComplete="current-password" aria-label="Текущий пароль" className="w-56 max-w-full font-mono" />
</SettingRow>
<SettingRow title="Новый пароль" description="Не короче 12 символов">
<Input type="password" autoComplete="new-password" aria-label="Новый пароль" className="w-56 max-w-full font-mono" />
</SettingRow>
<SettingRow title="Завершить остальные сессии" description="После смены пароля выйти на всех других устройствах">
<Switch defaultChecked aria-label="Завершить остальные сессии" />
</SettingRow>
</FormFrame>
</TabsContent>
<TabsContent value="acc">
<KvFrame
title="admin@shnt.top"
description="Данные учётной записи"
items={[
{ label: 'Роль', value: 'Администратор' },
{ label: 'Приложений', value: '7 из 7' },
{ label: 'Создана', value: '01.02.2026' },
{ label: 'Последний вход', value: 'сегодня 09:41 · 203.0.113.10' },
{ label: 'Двухфакторная защита', value: `Passkey ×${keys.length}` },
{ label: 'Статус', value: 'Активна' },
]}
/>
</TabsContent>
</Tabs>
<PortalSheet
open={addOpen}
onOpenChange={setAddOpen}
title="Добавить passkey"
description="Браузер предложит создать ключ для auth.shnt.top"
footer={
<>
<Button variant="outline" onClick={() => setAddOpen(false)}>Отмена</Button>
<Button
onClick={() => {
setKeys((k) => [
...k,
{ id: `pk-${k.length + 1}`, name: newName || 'Новый ключ', note: 'Только что добавлен', kind: 'Платформенный', added: '17.10.2026', lastUsed: 'никогда', tone: 'ok', status: 'Активен' },
])
setAddOpen(false)
toast.success('Passkey добавлен (макет)')
}}
>
<KeyRoundIcon aria-hidden />
Создать ключ
</Button>
</>
}
>
<Field>
<FieldLabel htmlFor="pk-name">Название ключа</FieldLabel>
<Input id="pk-name" value={newName} onChange={(e) => setNewName(e.target.value)} />
<FieldDescription>Чтобы отличать устройства в списке</FieldDescription>
</Field>
<SignalAlert tone="in" title="Что произойдёт дальше">
Откроется системный диалог: подтвердите отпечатком, Face ID, PIN или ключом безопасности. Закрытый ключ не покидает устройство.
</SignalAlert>
</PortalSheet>
<PortalSheet
open={selected !== null}
onOpenChange={(o) => !o && setSelected(null)}
title="Passkey"
description="Название и сведения о ключе"
footer={
<>
<Button
variant="outline"
className="mr-auto"
onClick={() => {
setDeleting(selected)
setSelected(null)
}}
>
<TrashIcon aria-hidden />
Удалить
</Button>
<Button variant="outline" onClick={() => setSelected(null)}>Закрыть</Button>
<Button
onClick={() => {
setKeys((k) => k.map((x) => (x.id === selected?.id ? { ...x, name: renameValue } : x)))
setSelected(null)
toast.success('Название сохранено')
}}
>
Сохранить
</Button>
</>
}
>
<Field>
<FieldLabel htmlFor="pk-rename">Название</FieldLabel>
<Input id="pk-rename" value={renameValue} onChange={(e) => setRenameValue(e.target.value)} />
</Field>
<KvList
items={[
['Тип', selected?.kind ?? ''],
['Добавлен', `${selected?.added ?? ''} · Chrome 130, macOS`],
['Последний вход', `${selected?.lastUsed ?? ''} · 203.0.113.10`],
]}
/>
</PortalSheet>
<TypedConfirm
open={deleting !== null}
onOpenChange={(o) => !o && setDeleting(null)}
title="Удалить passkey?"
description={`Ключ «${deleting?.name ?? ''}» будет удалён без возможности восстановления. Войти по нему больше не получится.`}
expected="удалить"
confirmLabel="Удалить ключ"
onConfirm={() => {
setKeys((k) => k.filter((x) => x.id !== deleting?.id))
setDeleting(null)
toast.success('Passkey удалён (макет)')
}}
/>
</>
)
}
@@ -0,0 +1,214 @@
import { FingerprintIcon, ShieldIcon, UserXIcon, UsersIcon } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { AdminUser, CreateUserRequest } from '@authportal/shared'
import { CreateUserPortalSheet } from '../create-sheets'
import { KpiStrip } from '@/components/portal/kpi-strip'
import { Button } from '@authportal/ui/components/button'
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
import { AccessSheet } from '../access-sheet'
import { diffAccess, type AccessValue } from '../access-model'
import { PageHead, TypedConfirm, PageTab, PageTabsList } from '@/components/portal/bits'
import { kpi } from '@/components/portal/kpi'
import { ALL_APP_IDS } from '../mock'
import { ExplainBanner } from '@/components/portal/explain-banner'
import { AccessMatrix } from '../access-matrix'
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 [createOpen, setCreateOpen] = useState(false)
const [accessFor, setAccessFor] = useState<AdminUser | null>(null)
const [deleteFor, setDeleteFor] = useState<AdminUser | null>(null)
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[] | null>(null)
const [bulkResetIds, setBulkResetIds] = 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 onOpenAccess = useCallback((user: AdminUser) => setAccessFor(user), [])
const handlers: UsersGridHandlers = {
onOpenAccess: onOpenAccess,
onToggleDisabled: onDeactivate,
onDelete,
onBulkDelete: (ids) => setBulkDeleteIds(ids),
onBulkResetPasskeys: (ids) => setBulkResetIds(ids),
onBulkDisabled: (ids, disabled) =>
patch(ids, (u) => ({ ...u, disabled }), `${disabled ? 'Отключено' : 'Включено'} пользователей: ${ids.length}`, () => [
`− disabled: ${String(!disabled)}`,
`+ disabled: ${String(disabled)}`,
])
}
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)}>
Создать пользователя
</Button>
}
/>
<ExplainBanner
tone="er"
title="Подозрительная активность: ops@shnt.top."
text="6 неудачных входов с 203.0.113.58 за 10 минут. Проверьте журнал входов и при необходимости отключите учётную запись. Действие: «Показать»."
>
6 неудачных входов с 203.0.113.58 за 10 минут. Проверьте журнал входов и при необходимости отключите учётную запись. Действие: «Показать».
</ExplainBanner>
<KpiStrip
aria-label="Показатели"
items={[
kpi('total', 'Пользователей', String(users.length), { icon: UsersIcon, badge: 'в каталоге' }),
kpi('admins', 'Администраторов', String(admins), { icon: ShieldIcon, tone: 'wn', badge: 'is_admin' }),
kpi('nopk', 'Без passkey', String(noPasskey), { icon: FingerprintIcon, tone: 'wn', badge: 'только пароль' }),
kpi('off', 'Отключённых', String(disabled), { icon: UserXIcon, badge: 'вход закрыт' }),
]}
/>
<Tabs defaultValue="users" className="min-w-0">
<PageTabsList>
<PageTab value="users" label="Участники" count={users.length} />
<PageTab value="matrix" label="Доступ по приложениям" count={ALL_APP_IDS.length} />
</PageTabsList>
<TabsContent value="users" className="min-w-0">
<UsersGrid users={users} handlers={handlers} marks={marks} />
</TabsContent>
<TabsContent value="matrix" className="min-w-0">
<AccessMatrix />
</TabsContent>
</Tabs>
<CreateUserPortalSheet open={createOpen} onOpenChange={setCreateOpen} onSubmit={onCreate} />
{accessFor ? (
<AccessSheet
key={accessFor.id}
user={accessFor}
onClose={() => setAccessFor(null)}
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}
<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={bulkResetIds !== null}
onOpenChange={(o) => !o && setBulkResetIds(null)}
title="Сбросить passkeys выбранных пользователей?"
description={`Все ключи у выбранных учётных записей (${bulkResetIds?.length ?? 0}) будут удалены. Войти можно будет только по паролю, пока не добавлен новый ключ. Действие попадёт в журнал.`}
expected="сбросить"
confirmLabel="Сбросить"
onConfirm={() => {
const ids = bulkResetIds ?? []
patch(ids, (u) => ({ ...u, passkey_count: 0 }), `Сброс passkeys: ${ids.length}`, (b) => [`− passkeys: ${b.passkey_count}`, '+ passkeys: 0'])
setBulkResetIds(null)
}}
/>
</>
)
}
Loaded 100 of 311 files, more files were not shown because too many files have changed in this diff. Show more