Files
mdm-ordinis/ordinis-admin-ui/src/lib/useUserDisplay.tsx
T
Zimin A.N. 8c0b74cbf8 fix(admin-ui): не резолвить «anonymous» через /admin/users/{sub}/display
Backend currentUserId() возвращает литерал "anonymous" когда JWT отсутствует
(ORDINIS_AUTH_REQUIRED=false fallback). Audit / draft entries сохраняют
этот sub в БД. Frontend UserCell дёргал /admin/users/anonymous/display
→ 404 → catch → display "—". Логически ок, но в консоли DevTools мусорный
404 на каждом рендере таблицы.

Skip endpoint когда uuid === "anonymous":
- useResolveUserDisplay: enabled = uuid && uuid !== 'anonymous'
- useUserDisplay: short-circuit ветка возвращает display: 'anonymous'

UX без изменений (раньше показывал «—» с full UUID в tooltip; теперь
показывает «anonymous» без 404 в консоли).
2026-05-25 18:34:20 +03:00

145 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
*
* <p>Stale-while-revalidate: TanStack Query кэширует 5 минут, refetch
* on focus. Cheap — endpoint = O(1) hash lookup, no DB hit.
*
* <p>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,
// Не дёргаем endpoint для литерала "anonymous" — это не UUID, а маркер
// отсутствия JWT subject (см. backend currentUserId fallback когда
// ORDINIS_AUTH_REQUIRED=false). Резолв даёт 404, в консоли мусор.
// Вместо запроса фронт сам подменит на «anonymous» в useUserDisplay ниже.
enabled: Boolean(uuid) && uuid !== 'anonymous',
staleTime: 5 * 60 * 1000,
retry: false,
queryFn: async (): Promise<UserDisplayInfo | null> => {
if (!uuid) return null
try {
const { data } = await apiClient.get<UserDisplayInfo>(
`/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.
*
* <p>Cases:
* <ul>
* <li><b>uuid matches current user</b> → {@code "Я • <preferred_username>"}
* (или just {@code "Я"} если profile неполный).</li>
* <li><b>different user, cached на backend</b> → preferred_username из кэша.
* Full ID + email + name доступны через extended fields.</li>
* <li><b>different user, не cached</b> → {@code "—"} (em dash placeholder).
* Full ID в {@code fullId} чтобы consumer мог положить в
* {@code title=...}. Раньше был short UUID prefix (8 chars), но это
* выглядело как "uuid в черновиках" baseline UI noise — теперь dash
* честно говорит "не знаем кто", а consumer открывает tooltip за
* full UUID если нужно.</li>
* <li><b>null/undefined uuid</b> → {@code "anonymous"}.</li>
* </ul>
*
* <p>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.
*
* <p>Используется везде где 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: '' }
}
// "anonymous" — литерал из backend currentUserId() когда нет JWT
// (ORDINIS_AUTH_REQUIRED=false и токен не дошёл). Не UUID, не резолвим
// через /admin/users/{sub}/display — там будет 404, шум в консоли.
if (fullId === 'anonymous') {
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 || '—',
isMe,
fullId,
preferredUsername: info.preferredUsername,
name: info.name,
email: info.email,
}
}
// Fallback — кэш empty или user ещё не появлялся на этом backend'е.
// Показываем em dash вместо raw UUID prefix; full ID доступен в tooltip
// через fullId (см. UserCell title=...).
return { display: '—', isMe, fullId }
}
/**
* UserCell — convenience component для тех мест где нужен readonly displaу
* с tooltip. Используется в таблицах.
*
* <p>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 <span className="text-mute italic">anonymous</span>
const tooltip = [fullId, name, email].filter(Boolean).join('\n')
return (
<span
className={isMe ? 'text-ink' : 'font-mono text-ink-2'}
title={tooltip || fullId}
>
{display}
</span>
)
}