diff --git a/ordinis-admin-ui/src/lib/useUserDisplay.tsx b/ordinis-admin-ui/src/lib/useUserDisplay.tsx index a328d9b..a631a86 100644 --- a/ordinis-admin-ui/src/lib/useUserDisplay.tsx +++ b/ordinis-admin-ui/src/lib/useUserDisplay.tsx @@ -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. + * + *
Stale-while-revalidate: TanStack Query кэширует 5 минут, refetch + * on focus. Cheap — endpoint = O(1) hash lookup, no DB hit. + * + *
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 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).
+ * 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.
*
* Используется везде где 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. Используется в таблицах.
+ *
+ * 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 anonymous
+ const tooltip = [fullId, name, email].filter(Boolean).join('\n')
return (
{display}
diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/auth/JwtUserCaptureFilter.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/auth/JwtUserCaptureFilter.java
new file mode 100644
index 0000000..ed78e8a
--- /dev/null
+++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/auth/JwtUserCaptureFilter.java
@@ -0,0 +1,59 @@
+package cloud.nstart.terravault.ordinis.restapi.auth;
+
+import cloud.nstart.terravault.ordinis.restapi.service.UserDisplayService;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+
+/**
+ * Capture-only filter: на каждом authenticated HTTP запросе достаёт
+ * {@code sub} / {@code preferred_username} / {@code name} / {@code email}
+ * из JWT и upsert'ит в {@link UserDisplayService}. Это позволяет резолвить
+ * UUID → display name для UserCell на frontend без Keycloak Admin API call.
+ *
+ * Runs после Spring Security JWT processing (он создаёт
+ * {@code JwtAuthenticationToken} в SecurityContext). Если auth.token = null
+ * (anonymous request, или non-OIDC auth), filter — no-op.
+ *
+ * Cost: 1 hash put per authenticated request. Cache size capped в
+ * service до 10k entries → ~1MB max RAM.
+ */
+@Component
+public class JwtUserCaptureFilter extends OncePerRequestFilter {
+
+ private final UserDisplayService displayService;
+
+ public JwtUserCaptureFilter(UserDisplayService displayService) {
+ this.displayService = displayService;
+ }
+
+ @Override
+ protected void doFilterInternal(
+ HttpServletRequest request,
+ HttpServletResponse response,
+ FilterChain chain) throws ServletException, IOException {
+ captureIfJwt();
+ chain.doFilter(request, response);
+ }
+
+ private void captureIfJwt() {
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+ if (!(auth instanceof JwtAuthenticationToken jwt)) return;
+ Jwt token = jwt.getToken();
+ String sub = token.getSubject();
+ if (sub == null || sub.isBlank()) return;
+ String preferredUsername = token.getClaimAsString("preferred_username");
+ String name = token.getClaimAsString("name");
+ String email = token.getClaimAsString("email");
+ displayService.put(sub, preferredUsername, name, email);
+ }
+}
diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/UserDisplayService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/UserDisplayService.java
new file mode 100644
index 0000000..2bac04f
--- /dev/null
+++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/UserDisplayService.java
@@ -0,0 +1,79 @@
+package cloud.nstart.terravault.ordinis.restapi.service;
+
+import org.springframework.stereotype.Service;
+
+import java.time.OffsetDateTime;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * In-memory кэш user display info (sub → preferredUsername / name / email),
+ * заполняемый из JWT claims на каждом authenticated запросе через
+ * {@link cloud.nstart.terravault.ordinis.restapi.auth.JwtUserCaptureFilter}.
+ *
+ * Зачем: audit log + record drafts хранят actor как JWT
+ * {@code sub} UUID. Frontend (UserCell в audit / reviews) показывает short
+ * UUID + tooltip — плохой UX. Этот сервис позволяет резолвить sub →
+ * читаемое имя для display, не запрашивая Keycloak Admin API на каждое
+ * отображение.
+ *
+ * Trade-offs:
+ * Альтернатива (на будущее): миграция 0023 user_display_cache table
+ * + periodic Keycloak sync. Сделать когда станет реально нужно
+ * (пока display name никто не редактирует постоянно).
+ */
+@Service
+public class UserDisplayService {
+
+ /** Cap чтобы не держать миллионы entries в RAM при abuse. ~100 байт per
+ * entry → 10k = 1MB. Eviction policy — LRU не нужен, hash без bound — fine. */
+ private static final int SOFT_CAP = 10_000;
+
+ private final ConcurrentHashMap 404 {@code user_not_cached} — sub неизвестен (user ни разу не делал
+ * authenticated request на этом backend instance). Frontend fallback'нёт
+ * на short UUID.
+ *
+ * Backed by {@link UserDisplayService} — in-memory cache,
+ * заполняется через {@link cloud.nstart.terravault.ordinis.restapi.auth.JwtUserCaptureFilter}
+ * на каждом authenticated request.
+ */
+@RestController
+@RequestMapping("/api/v1/admin/users")
+public class UserDisplayController {
+
+ private final UserDisplayService service;
+ private final ScopeContext scopeContext;
+
+ public UserDisplayController(UserDisplayService service, ScopeContext scopeContext) {
+ this.service = service;
+ this.scopeContext = scopeContext;
+ }
+
+ /** INTERNAL+ scope для лookup — admin staff feature, не для anonymous. */
+ private void requireInternal() {
+ if (!scopeContext.canAccess(DataScope.INTERNAL)) {
+ throw OrdinisException.forbidden(
+ "user_display_internal_required",
+ "User display lookup доступен только с INTERNAL или RESTRICTED scope.");
+ }
+ }
+
+ @GetMapping("/{sub}/display")
+ public UserDisplayResponse get(@PathVariable String sub) {
+ requireInternal();
+ return service.find(sub)
+ .map(UserDisplayResponse::from)
+ .orElseThrow(() -> OrdinisException.notFound(
+ "user_not_cached",
+ "User " + sub + " not cached. User должен сделать хотя бы один " +
+ "authenticated request на этом backend'е чтобы попасть в кэш."));
+ }
+
+ public record UserDisplayResponse(
+ String sub,
+ String preferredUsername,
+ String name,
+ String email,
+ OffsetDateTime updatedAt) {
+
+ static UserDisplayResponse from(UserDisplayService.UserDisplayInfo info) {
+ return new UserDisplayResponse(
+ info.sub(),
+ info.preferredUsername(),
+ info.name(),
+ info.email(),
+ info.updatedAt());
+ }
+ }
+}
*
*
- *
+ *
+ *
+ *