Merge branch 'feat/phase2-direct-kc-admin' into 'main'

feat(auth): Phase 2 — audited-users UserPicker + Tier-3 Keycloak Admin lookup

See merge request 2-6/2-6-4/terravault/ordinis!272
This commit is contained in:
Александр Зимин
2026-05-26 12:01:10 +00:00
8 changed files with 142 additions and 122 deletions
+11 -4
View File
@@ -796,13 +796,20 @@ export type UserListItem = {
updatedAt: string
}
export const allUsersQuery = queryOptions({
queryKey: ['users', 'all'] as const,
/**
* Юзеры, реально появлявшиеся в audit_log — для UserPicker'а в /audit.
* Backend resolve'ит distinct user_id из audit_log JOIN с user_display_cache
* → отдаёт только тех, кто реально что-то делал в ordinis (не всех юзеров
* altum realm). Sub'ы которых нет в cache возвращаются с null display полями
* — UserPicker покажет UUID-prefix.
*/
export const auditedUsersQuery = queryOptions({
queryKey: ['users', 'audited'] as const,
staleTime: 5 * 60 * 1000,
retry: false,
queryFn: async (): Promise<UserListItem[]> => {
try {
const { data } = await apiClient.get<UserListItem[]>('/admin/users', {
const { data } = await apiClient.get<UserListItem[]>('/admin/users/audited', {
params: { limit: 500 },
})
return data
@@ -814,7 +821,7 @@ export const allUsersQuery = queryOptions({
},
})
export const useAllUsers = () => useQuery(allUsersQuery)
export const useAuditedUsers = () => useQuery(auditedUsersQuery)
export const useRecords = (
dictionaryName: string,
scopeCsv: string,
@@ -3,16 +3,19 @@ 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, TextInput } from '@/ui'
import { useAllUsers, type UserListItem } from '@/api/queries'
import { useAuditedUsers, 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>Источник: GET /api/v1/admin/users/audited — distinct user_id из audit_log
* JOIN с user_display_cache. Возвращает только тех, кто реально засветился в
* audit (не всех юзеров altum realm). INTERNAL+ scope требуется на backend;
* non-INTERNAL получит пустой список и падает на input fallback (поле
* остаётся редактируемым). Юзеры, которых ещё нет в user_display_cache,
* возвращаются с null username/name/email — здесь рендерится UUID-prefix.
*
* <p>UX: trigger показывает username (или UUID prefix как fallback);
* X сбрасывает selection inline. В popover'е — поиск по subname/email/UUID.
@@ -36,7 +39,7 @@ export type UserPickerProps = {
export function UserPicker({ value, onChange, label, placeholder, disabled }: UserPickerProps) {
const [open, setOpen] = useState(false)
const usersQ = useAllUsers()
const usersQ = useAuditedUsers()
// Fallback на TextInput если cache пустой или endpoint вернул 403/404 (юзер
// без INTERNAL scope, либо writer был только-что рестартован и cache не