feat: /audit поле «Пользователь» — typeahead picker из user-cache
Backend: GET /api/v1/admin/users (INTERNAL+ scope) — list cached юзеров из user_display_cache. UserDisplayService.listAll(limit) сортирует по preferred_username, hard-cap 1000. Контроллер: UserDisplayController.list(). Frontend: - queries.ts → useAllUsers() / allUsersQuery (5min stale, 403/404 → []). - components/audit/UserPicker.tsx (новый, ~140 строк) — cmdk-powered combobox: trigger показывает username (или UUID prefix fallback), popover c поиском по preferredUsername/email/UUID. X сбрасывает selection. - routes/audit.tsx — TextInput → UserPicker. UX: раньше юзер вручную копипастил UUID из audit-row'а; теперь поиск по имени/email типа `solov` → находит `solovyev.da`.
This commit is contained in:
@@ -782,6 +782,39 @@ export const useLiveRecord = (dict: string | undefined, businessKey: string | un
|
||||
export const useDictionaries = () => useQuery(dictionariesQuery)
|
||||
export const useDictionaryDetail = (name: string | undefined) =>
|
||||
useQuery({ ...dictionaryDetailQuery(name ?? ''), enabled: Boolean(name) })
|
||||
|
||||
/**
|
||||
* Список всех закэшированных юзеров — для пикеров «Пользователь» в
|
||||
* фильтрах audit/reviews. INTERNAL+ scope; non-INTERNAL получит 403,
|
||||
* fallback на пустой массив (retry: false).
|
||||
*/
|
||||
export type UserListItem = {
|
||||
sub: string
|
||||
preferredUsername?: string | null
|
||||
name?: string | null
|
||||
email?: string | null
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export const allUsersQuery = queryOptions({
|
||||
queryKey: ['users', 'all'] as const,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
queryFn: async (): Promise<UserListItem[]> => {
|
||||
try {
|
||||
const { data } = await apiClient.get<UserListItem[]>('/admin/users', {
|
||||
params: { limit: 500 },
|
||||
})
|
||||
return data
|
||||
} catch (e: unknown) {
|
||||
const status = (e as { response?: { status?: number } })?.response?.status
|
||||
if (status === 403 || status === 404) return []
|
||||
throw e
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const useAllUsers = () => useQuery(allUsersQuery)
|
||||
export const useRecords = (
|
||||
dictionaryName: string,
|
||||
scopeCsv: string,
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import * as React from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { CaretDownIcon, MagnifyingGlassIcon, UserIcon, XIcon } from '@phosphor-icons/react'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from 'cmdk'
|
||||
import { Popover, PopoverTrigger, PopoverContent, FieldLabel } from '@/ui'
|
||||
import { useAllUsers, type UserListItem } from '@/api/queries'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Single-value user picker — cmdk-powered combobox для audit-фильтра
|
||||
* «Пользователь». Заменяет ручной TextInput на typeahead по username/email/UUID.
|
||||
*
|
||||
* <p>Источник: GET /api/v1/admin/users (cached user_display table).
|
||||
* INTERNAL+ scope требуется на backend; non-INTERNAL получит пустой
|
||||
* список и падает на input fallback (поле остаётся редактируемым).
|
||||
*
|
||||
* <p>UX: trigger показывает username (или UUID prefix как fallback);
|
||||
* X сбрасывает selection inline. В popover'е — поиск по subname/email/UUID.
|
||||
*/
|
||||
|
||||
const labelFor = (u: UserListItem): string =>
|
||||
u.preferredUsername || u.name || `${u.sub.slice(0, 8)}…`
|
||||
|
||||
const secondaryFor = (u: UserListItem): string | null => {
|
||||
if (u.email && u.email !== u.preferredUsername) return u.email
|
||||
return null
|
||||
}
|
||||
|
||||
export type UserPickerProps = {
|
||||
value: string | undefined
|
||||
onChange: (sub: string | undefined) => void
|
||||
label?: React.ReactNode
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function UserPicker({ value, onChange, label, placeholder, disabled }: UserPickerProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const usersQ = useAllUsers()
|
||||
|
||||
const usersBySub = useMemo(() => {
|
||||
const m = new Map<string, UserListItem>()
|
||||
for (const u of usersQ.data ?? []) m.set(u.sub, u)
|
||||
return m
|
||||
}, [usersQ.data])
|
||||
|
||||
const selected = value ? usersBySub.get(value) : undefined
|
||||
const triggerLabel = useMemo(() => {
|
||||
if (!value) return placeholder ?? 'Любой'
|
||||
if (selected) return labelFor(selected)
|
||||
// Value есть, но юзера нет в кеше — показываем UUID prefix.
|
||||
return `${value.slice(0, 8)}…`
|
||||
}, [value, selected, placeholder])
|
||||
|
||||
// Поиск: substring по preferredUsername / name / email / sub. cmdk
|
||||
// делает client-side фильтрацию по value атрибуту CommandItem'а;
|
||||
// концатенируем все поля в один searchable string.
|
||||
const searchableValue = (u: UserListItem): string =>
|
||||
[u.preferredUsername, u.name, u.email, u.sub].filter(Boolean).join(' ')
|
||||
|
||||
const clear = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onChange(undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{label && <FieldLabel>{label}</FieldLabel>}
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-2 h-9 w-full rounded-md border border-line bg-surface px-3 text-body text-ink',
|
||||
'hover:border-line-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<UserIcon size={14} weight="regular" className="shrink-0 text-mute" />
|
||||
<span className={cn('truncate text-left', !value && 'text-mute')}>
|
||||
{triggerLabel}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1 shrink-0">
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Сбросить пользователя"
|
||||
onClick={clear}
|
||||
className="p-0.5 rounded hover:bg-line/40 text-mute hover:text-ink"
|
||||
>
|
||||
<XIcon size={12} weight="bold" />
|
||||
</button>
|
||||
)}
|
||||
<CaretDownIcon size={12} weight="regular" className="text-mute" />
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="p-0 w-[min(95vw,360px)]"
|
||||
>
|
||||
<Command className="bg-surface rounded-md">
|
||||
<div className="flex items-center gap-2 px-2 border-b border-line">
|
||||
<MagnifyingGlassIcon size={14} weight="regular" className="text-mute shrink-0" />
|
||||
<CommandInput
|
||||
placeholder="Поиск по имени, email или UUID…"
|
||||
className="h-9 w-full bg-transparent outline-none text-body text-ink placeholder:text-mute"
|
||||
/>
|
||||
</div>
|
||||
<CommandList className="max-h-72 overflow-y-auto py-1">
|
||||
<CommandEmpty className="px-3 py-2 text-cell text-mute">
|
||||
{usersQ.isLoading ? 'Загрузка…' : 'Пользователи не найдены'}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{(usersQ.data ?? []).map((u) => {
|
||||
const sec = secondaryFor(u)
|
||||
return (
|
||||
<CommandItem
|
||||
key={u.sub}
|
||||
value={searchableValue(u)}
|
||||
onSelect={() => {
|
||||
onChange(u.sub)
|
||||
setOpen(false)
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-2.5 py-1.5 rounded cursor-pointer text-body',
|
||||
value === u.sub
|
||||
? 'bg-accent-bg text-accent'
|
||||
: 'text-ink hover:bg-surface-2 aria-selected:bg-surface-2',
|
||||
)}
|
||||
>
|
||||
<UserIcon size={14} weight="regular" className="shrink-0 text-mute" />
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block truncate">{labelFor(u)}</span>
|
||||
{sec && (
|
||||
<span className="block truncate text-cap text-mute">{sec}</span>
|
||||
)}
|
||||
</span>
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import { UserCell } from '@/lib/useUserDisplay'
|
||||
import type { AuditAction, AuditEntry, AuditFilters } from '@/api/client'
|
||||
import { localTzOffset, parseFormDate } from '@/lib/dates'
|
||||
import { PeriodFilter } from '@/components/audit/PeriodFilter'
|
||||
import { UserPicker } from '@/components/audit/UserPicker'
|
||||
|
||||
// Все 12 audit action types — должны matchить backend (AuditLogger,
|
||||
// SchemaDraftService.applyDecision, DraftService.emitDraftEvent).
|
||||
@@ -390,11 +391,14 @@ function FilterPanel({
|
||||
value={filters.action ?? ''}
|
||||
onChange={(id) => set('action', (id as AuditAction) || '')}
|
||||
/>
|
||||
<TextInput
|
||||
{/* UserPicker — cmdk-typeahead из user-cache (/admin/users). Раньше
|
||||
был TextInput где юзер вручную копипастил UUID; теперь поиск по
|
||||
preferredUsername/email/UUID. INTERNAL+ scope, non-INTERNAL получает
|
||||
пустой список (fallback в UserPicker: показывает UUID-prefix). */}
|
||||
<UserPicker
|
||||
label={t('audit.filter.user')}
|
||||
value={filters.userId ?? ''}
|
||||
onChange={(e) => set('userId', e.target.value || undefined)}
|
||||
onKeyDown={handleEnter}
|
||||
value={filters.userId}
|
||||
onChange={(sub) => set('userId', sub)}
|
||||
/>
|
||||
<TextInput
|
||||
label={t('audit.filter.businessKey')}
|
||||
|
||||
+19
@@ -10,6 +10,7 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@@ -125,6 +126,24 @@ public class UserDisplayService {
|
||||
return hotCache.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Список всех закэшированных юзеров — для admin-UI пикеров (audit
|
||||
* filter «Пользователь», review reviewer selector и т.п.). Сортируется
|
||||
* по preferred_username для предсказуемой выдачи; hard-capped лимит
|
||||
* чтобы не вытащить всю таблицу когда cache разрастётся до 10k+
|
||||
* (в этот момент перейдём на server-side search; пока 1000 хватит).
|
||||
*/
|
||||
public List<UserDisplayInfo> listAll(int limit) {
|
||||
int cap = Math.min(Math.max(limit, 1), 1000);
|
||||
return repository.findAll().stream()
|
||||
.map(UserDisplayService::toInfo)
|
||||
.sorted(java.util.Comparator.comparing(
|
||||
(UserDisplayInfo i) -> i.preferredUsername() == null ? "" : i.preferredUsername(),
|
||||
String.CASE_INSENSITIVE_ORDER))
|
||||
.limit(cap)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// --- internals ---
|
||||
|
||||
private UserDisplayInfo upsert(String sub, String preferredUsername, String name,
|
||||
|
||||
+16
@@ -7,9 +7,11 @@ import cloud.nstart.terravault.ordinis.restapi.service.UserDisplayService;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Resolve UUID (JWT sub) → human-readable display info. Frontend UserCell
|
||||
@@ -57,6 +59,20 @@ public class UserDisplayController {
|
||||
"authenticated request на этом backend'е чтобы попасть в кэш."));
|
||||
}
|
||||
|
||||
/**
|
||||
* Список всех закэшированных юзеров — для admin-UI пикеров (audit
|
||||
* filter «Пользователь», reviewer selectors и т.п.). INTERNAL+ scope,
|
||||
* hard-cap limit 1000.
|
||||
*/
|
||||
@GetMapping("")
|
||||
public List<UserDisplayResponse> list(
|
||||
@RequestParam(defaultValue = "500") int limit) {
|
||||
requireInternal();
|
||||
return service.listAll(limit).stream()
|
||||
.map(UserDisplayResponse::from)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public record UserDisplayResponse(
|
||||
String sub,
|
||||
String preferredUsername,
|
||||
|
||||
Reference in New Issue
Block a user