diff --git a/ordinis-admin-ui/src/components/workflow/SchemaDraftDrawer.tsx b/ordinis-admin-ui/src/components/workflow/SchemaDraftDrawer.tsx index abe9ebd..ad88987 100644 --- a/ordinis-admin-ui/src/components/workflow/SchemaDraftDrawer.tsx +++ b/ordinis-admin-ui/src/components/workflow/SchemaDraftDrawer.tsx @@ -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, показываем имя текущего - // юзера ("Я • "). Иначе — 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 (
- - {authorDisplay} - + v{draft.branchedFromVersion} diff --git a/ordinis-admin-ui/src/lib/useUserDisplay.tsx b/ordinis-admin-ui/src/lib/useUserDisplay.tsx new file mode 100644 index 0000000..a328d9b --- /dev/null +++ b/ordinis-admin-ui/src/lib/useUserDisplay.tsx @@ -0,0 +1,62 @@ +import { useAuth } from 'react-oidc-context' + +/** + * Resolve UUID sub claim (Keycloak {@code sub} → makerId / userId) к + * human-friendly display string. + * + *

Cases: + *

    + *
  • uuid matches current user → {@code "Я • "} + * (или just {@code "Я"} если profile неполный).
  • + *
  • different user → first 8 chars of UUID. Full ID в {@code fullId} + * чтобы consumer мог положить в {@code title=...} для tooltip.
  • + *
  • null/undefined uuid → {@code "anonymous"}.
  • + *
+ * + *

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). + * + *

Используется везде где 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 anonymous + return ( + + {display} + + ) +} diff --git a/ordinis-admin-ui/src/routes/audit.tsx b/ordinis-admin-ui/src/routes/audit.tsx index d1512e0..61cd2b1 100644 --- a/ordinis-admin-ui/src/routes/audit.tsx +++ b/ordinis-admin-ui/src/routes/audit.tsx @@ -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 }) { {row.businessKey ?? '—'} - {row.userId ?? 'anonymous'} + {row.userScope ? {row.userScope} : null} diff --git a/ordinis-admin-ui/src/routes/reviews.tsx b/ordinis-admin-ui/src/routes/reviews.tsx index 5adecab..836d40c 100644 --- a/ordinis-admin-ui/src/routes/reviews.tsx +++ b/ordinis-admin-ui/src/routes/reviews.tsx @@ -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() { {d.operation} - {d.makerId} + {new Date(d.submittedAt).toLocaleString()} @@ -622,7 +623,7 @@ function SchemaReviewQueuePanel() { v{d.branchedFromVersion} - {d.makerId} + {d.submittedAt ? new Date(d.submittedAt).toLocaleString() : '—'}