feat(users): /admin/users/{sub}/display endpoint + UserCell uses it
This commit is contained in:
@@ -1,4 +1,48 @@
|
||||
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) к
|
||||
@@ -8,15 +52,17 @@ import { useAuth } from 'react-oidc-context'
|
||||
* <ul>
|
||||
* <li><b>uuid matches current user</b> → {@code "Я • <preferred_username>"}
|
||||
* (или just {@code "Я"} если profile неполный).</li>
|
||||
* <li><b>different user</b> → first 8 chars of UUID. Full ID в {@code fullId}
|
||||
* чтобы consumer мог положить в {@code title=...} для tooltip.</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>TODO Phase 1d: для не-current-user backend endpoint
|
||||
* {@code /admin/users/{sub}/display} → resolve в username. Пока best-effort:
|
||||
* current user resolved через JWT profile claim, остальные — UUID prefix
|
||||
* (achievable via JWT alone).
|
||||
* <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
|
||||
@@ -26,9 +72,13 @@ 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: '' }
|
||||
}
|
||||
@@ -36,25 +86,41 @@ export function useUserDisplay(uuid: string | null | undefined): {
|
||||
const isMe = Boolean(currentSub) && currentSub === fullId
|
||||
if (isMe) {
|
||||
const me =
|
||||
auth.user?.profile?.preferred_username ||
|
||||
auth.user?.profile?.name ||
|
||||
auth.user?.profile?.email
|
||||
(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 } = useUserDisplay(uuid)
|
||||
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={fullId}
|
||||
title={tooltip || fullId}
|
||||
>
|
||||
{display}
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user