Files
mdm-ordinis/ordinis-admin-ui/src/lib/useUserDisplay.tsx
T
2026-05-13 16:42:46 +00:00

129 lines
4.9 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,
enabled: Boolean(uuid),
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> → first 8 chars of UUID. Full ID в
* {@code fullId} чтобы consumer мог положить в {@code title=...}.</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: '' }
}
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. Используется в таблицах.
*
* <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>
)
}