import { useQuery } from '@tanstack/react-query' import { useAuth } from 'react-oidc-context' import { apiClient } from '@/api/client' /** * Lookup user display info по UUID (JWT sub). Backed by backend in-memory * cache (заполняется на каждом authenticated request через * JwtUserCaptureFilter). Returns null если sub не в кэше — frontend * fallback'нёт на short UUID. * *

Stale-while-revalidate: TanStack Query кэширует 5 минут, refetch * on focus. Cheap — endpoint = O(1) hash lookup, no DB hit. * *

404 → данных нет; не считаем error (через retry: false + handle null). */ type UserDisplayInfo = { sub: string preferredUsername?: string | null name?: string | null email?: string | null updatedAt: string } const useResolveUserDisplay = (uuid: string | null | undefined) => useQuery({ queryKey: ['user-display', uuid] as const, enabled: Boolean(uuid), staleTime: 5 * 60 * 1000, retry: false, queryFn: async (): Promise => { if (!uuid) return null try { const { data } = await apiClient.get( `/admin/users/${encodeURIComponent(uuid)}/display`, ) return data } catch (e: unknown) { const status = (e as { response?: { status?: number } })?.response?.status // 404 = user не в кэше; 403 = у actor'а scope insufficient — оба // не error, просто нет данных. Frontend fallback'нёт на short UUID. if (status === 404 || status === 403) return null throw e } }, }) /** * Resolve UUID sub claim (Keycloak {@code sub} → makerId / userId) к * human-friendly display string. * *

Cases: *

* *

Backend endpoint: {@code GET /api/v1/admin/users/{sub}/display} — backed * by in-memory cache (заполняется JwtUserCaptureFilter на каждом authenticated * request). Если backend pod restart'нулся, кэш пуст — postepenно заполняется * обратно. Frontend gracefully fallback'ит на short UUID. * *

Используется везде где UI показывает actor: AUTHOR в таблицах * /reviews (record + schema), USER в /audit log, AUTHOR в schema draft * drawer. */ export function useUserDisplay(uuid: string | null | undefined): { display: string isMe: boolean fullId: string preferredUsername?: string | null name?: string | null email?: string | null } { const auth = useAuth() const fullId = uuid ?? '' const resolved = useResolveUserDisplay(fullId) if (!fullId) { return { display: 'anonymous', isMe: false, fullId: '' } } const currentSub = auth.user?.profile?.sub const isMe = Boolean(currentSub) && currentSub === fullId if (isMe) { const me = (auth.user?.profile?.preferred_username as string | undefined) || (auth.user?.profile?.name as string | undefined) || (auth.user?.profile?.email as string | undefined) return { display: me ? `Я • ${me}` : 'Я', isMe, fullId } } const info = resolved.data if (info?.preferredUsername || info?.name) { return { display: info.preferredUsername || info.name || fullId.substring(0, 8), isMe, fullId, preferredUsername: info.preferredUsername, name: info.name, email: info.email, } } // Fallback — кэш empty или user ещё не появлялся на этом backend'е. return { display: fullId.substring(0, 8), isMe, fullId } } /** * UserCell — convenience component для тех мест где нужен readonly displaу * с tooltip. Используется в таблицах. * *

Tooltip содержит full UUID + email + name (если есть в кэше) для * disambiguation: short UUID prefix может collide между users. */ export function UserCell({ uuid }: { uuid: string | null | undefined }) { const { display, isMe, fullId, name, email } = useUserDisplay(uuid) if (!fullId) return anonymous const tooltip = [fullId, name, email].filter(Boolean).join('\n') return ( {display} ) }