diff --git a/ordinis-admin-ui/src/api/queries.ts b/ordinis-admin-ui/src/api/queries.ts index 0365fef..236ea34 100644 --- a/ordinis-admin-ui/src/api/queries.ts +++ b/ordinis-admin-ui/src/api/queries.ts @@ -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 => { + try { + const { data } = await apiClient.get('/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, diff --git a/ordinis-admin-ui/src/components/audit/UserPicker.tsx b/ordinis-admin-ui/src/components/audit/UserPicker.tsx new file mode 100644 index 0000000..f0da2f9 --- /dev/null +++ b/ordinis-admin-ui/src/components/audit/UserPicker.tsx @@ -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. + * + *

Источник: GET /api/v1/admin/users (cached user_display table). + * INTERNAL+ scope требуется на backend; non-INTERNAL получит пустой + * список и падает на input fallback (поле остаётся редактируемым). + * + *

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() + 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 ( +

+ {label && {label}} + + + + )} + + + + + + +
+ + +
+ + + {usersQ.isLoading ? 'Загрузка…' : 'Пользователи не найдены'} + + + {(usersQ.data ?? []).map((u) => { + const sec = secondaryFor(u) + return ( + { + 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', + )} + > + + + {labelFor(u)} + {sec && ( + {sec} + )} + + + ) + })} + + +
+
+
+
+ ) +} diff --git a/ordinis-admin-ui/src/routes/audit.tsx b/ordinis-admin-ui/src/routes/audit.tsx index 8a4857b..c406a28 100644 --- a/ordinis-admin-ui/src/routes/audit.tsx +++ b/ordinis-admin-ui/src/routes/audit.tsx @@ -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) || '')} /> - set('userId', e.target.value || undefined)} - onKeyDown={handleEnter} + value={filters.userId} + onChange={(sub) => set('userId', sub)} /> 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, diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/UserDisplayController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/UserDisplayController.java index 1252ffc..17ed108 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/UserDisplayController.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/UserDisplayController.java @@ -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 list( + @RequestParam(defaultValue = "500") int limit) { + requireInternal(); + return service.listAll(limit).stream() + .map(UserDisplayResponse::from) + .toList(); + } + public record UserDisplayResponse( String sub, String preferredUsername,