fix(ui): shared UserCell для display UUID actor'ов везде

Browser QA на staging показал что UUID actor'а render'ится raw во многих
местах кроме drawer (MR !158 — там был inline fix):
- /reviews Records tab — column AUTHOR
- /reviews Schemas tab — column AUTHOR
- /audit log — column USER

77ec2cc8-98e3-4d70-8037-94038fcbde67 — нечитаемо для оператора. Тот же
паттерн что я делал в MR !158, теперь shared helper:

src/lib/useUserDisplay.tsx:
- useUserDisplay(uuid) hook — returns { display, isMe, fullId }
- UserCell component — readonly cell с title=fullId tooltip
- Match current user (JWT sub claim) → 'Я • <preferred_username>'
- Other user → short UUID (8 chars) + full в title
- null/undefined → 'anonymous'

Применено:
- reviews.tsx:334,625 — records + schemas table AUTHOR cells
- audit.tsx:476 — USER cell
- SchemaDraftDrawer.tsx — refactor (был inline в MR !158 → теперь shared)

TODO Phase 1d: backend endpoint /admin/users/{sub}/display чтобы и для
не-current-user отображать username.
This commit is contained in:
Zimin A.N.
2026-05-13 11:43:04 +03:00
parent 38764648bf
commit e6294ce79c
4 changed files with 69 additions and 27 deletions
@@ -28,6 +28,7 @@ import {
useWithdrawSchemaDraft,
} from '@/api/mutations'
import { useCanPublishSchema, useCanReviewSchema } from '@/auth/usePermissions'
import { UserCell } from '@/lib/useUserDisplay'
type Props = {
open: boolean
@@ -321,33 +322,10 @@ function StatusBadge({ status }: { status: SchemaDraftStatus }) {
function DraftMeta({ draft }: { draft: SchemaDraft }) {
const { t } = useTranslation()
const auth = useAuth()
// Resolve author display: если maker = current user, показываем имя текущего
// юзера ("Я • <username>"). Иначе — short UUID (первые 8 chars) с full
// tooltip. Полное UUID показывать бессмысленно — это Keycloak sub claim,
// нечитаемый для оператора.
//
// TODO Phase 1d: backend endpoint /admin/users/{sub}/display чтобы для
// не-current-user тоже показывать username. Пока — best-effort: current
// user resolved через JWT profile claim, другие — UUID prefix.
const currentSub = auth.user?.profile?.sub
const isMe = currentSub && currentSub === draft.makerId
const myDisplay =
auth.user?.profile?.preferred_username ||
auth.user?.profile?.name ||
auth.user?.profile?.email
const authorDisplay = isMe
? `Я${myDisplay ? `${myDisplay}` : ''}`
: draft.makerId.substring(0, 8)
return (
<dl className="grid grid-cols-[120px_1fr] gap-y-1 gap-x-3 text-cell">
<Row label={t('workflow.schemaDraft.maker', { defaultValue: 'Автор' })}>
<span
className={isMe ? 'text-ink' : 'font-mono text-mute'}
title={draft.makerId}
>
{authorDisplay}
</span>
<UserCell uuid={draft.makerId} />
</Row>
<Row label={t('workflow.schemaDraft.from', { defaultValue: 'От версии' })}>
v{draft.branchedFromVersion}
@@ -0,0 +1,62 @@
import { useAuth } from 'react-oidc-context'
/**
* 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</b> → first 8 chars of UUID. Full ID в {@code fullId}
* чтобы consumer мог положить в {@code title=...} для tooltip.</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>Используется везде где 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
} {
const auth = useAuth()
const fullId = uuid ?? ''
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 ||
auth.user?.profile?.name ||
auth.user?.profile?.email
return { display: me ? `Я • ${me}` : 'Я', isMe, fullId }
}
return { display: fullId.substring(0, 8), isMe, fullId }
}
/**
* UserCell — convenience component для тех мест где нужен readonly displaу
* с tooltip. Используется в таблицах.
*/
export function UserCell({ uuid }: { uuid: string | null | undefined }) {
const { display, isMe, fullId } = useUserDisplay(uuid)
if (!fullId) return <span className="text-mute italic">anonymous</span>
return (
<span
className={isMe ? 'text-ink' : 'font-mono text-ink-2'}
title={fullId}
>
{display}
</span>
)
}
+2 -1
View File
@@ -28,6 +28,7 @@ import {
XIcon,
} from '@phosphor-icons/react'
import { buildAuditExportUrl, useAudit, useDictionaries } from '@/api/queries'
import { UserCell } from '@/lib/useUserDisplay'
import type { AuditAction, AuditEntry, AuditFilters } from '@/api/client'
import { localTzOffset, parseFormDate } from '@/lib/dates'
@@ -473,7 +474,7 @@ function AuditRow({ row }: { row: AuditEntry }) {
<TableCell>
<span className=" text-mono">{row.businessKey ?? '—'}</span>
</TableCell>
<TableCell>{row.userId ?? 'anonymous'}</TableCell>
<TableCell><UserCell uuid={row.userId} /></TableCell>
<TableCell>
{row.userScope ? <Badge variant="info">{row.userScope}</Badge> : null}
</TableCell>
+3 -2
View File
@@ -27,6 +27,7 @@ import { CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react'
import { useDraft, useLiveRecord, useReviewQueue, useSchemaReviewQueue } from '@/api/queries'
import { useApproveDraft, useRejectDraft } from '@/api/mutations'
import type { DraftOperation, DraftResponse } from '@/api/client'
import { UserCell } from '@/lib/useUserDisplay'
export const Route = createFileRoute('/reviews')({
component: ReviewsPage,
@@ -331,7 +332,7 @@ function ReviewsPage() {
<TableCell>
<Badge variant={operationVariant(d.operation)}>{d.operation}</Badge>
</TableCell>
<TableCell className="text-mono">{d.makerId}</TableCell>
<TableCell><UserCell uuid={d.makerId} /></TableCell>
<TableCell className="text-cell text-ink-2">
{new Date(d.submittedAt).toLocaleString()}
</TableCell>
@@ -622,7 +623,7 @@ function SchemaReviewQueuePanel() {
</Link>
</TableCell>
<TableCell className="font-mono tabular-nums">v{d.branchedFromVersion}</TableCell>
<TableCell className="font-mono">{d.makerId}</TableCell>
<TableCell><UserCell uuid={d.makerId} /></TableCell>
<TableCell className="tabular-nums">
{d.submittedAt ? new Date(d.submittedAt).toLocaleString() : '—'}
</TableCell>