diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java index 6049514b..e508f1d3 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java @@ -7,6 +7,7 @@ import com.cameleer3.server.core.admin.AuditResult; import com.cameleer3.server.core.admin.AuditService; import com.cameleer3.server.core.rbac.RbacService; import com.cameleer3.server.core.rbac.SystemRole; +import com.cameleer3.server.core.rbac.UserDetail; import com.cameleer3.server.core.security.JwtService; import jakarta.servlet.http.HttpServletRequest; import com.cameleer3.server.core.security.JwtService.JwtValidationResult; @@ -22,6 +23,8 @@ import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -149,6 +152,21 @@ public class UiAuthController { } } + @GetMapping("/me") + @Operation(summary = "Get current user details") + @ApiResponse(responseCode = "200", description = "Current user details") + @ApiResponse(responseCode = "401", description = "Not authenticated") + public ResponseEntity me(Authentication authentication) { + if (authentication == null || authentication.getName() == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated"); + } + UserDetail detail = rbacService.getUser(authentication.getName()); + if (detail == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found"); + } + return ResponseEntity.ok(detail); + } + public record LoginRequest(String username, String password) {} public record RefreshRequest(String refreshToken) {} } diff --git a/ui/package-lock.json b/ui/package-lock.json index 25b594fe..32726a83 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "hasInstallScript": true, "dependencies": { - "@cameleer/design-system": "^0.1.38", + "@cameleer/design-system": "^0.0.0-snapshot.20260408.7e54514", "@tanstack/react-query": "^5.90.21", "lucide-react": "^1.7.0", "openapi-fetch": "^0.17.0", @@ -279,9 +279,9 @@ } }, "node_modules/@cameleer/design-system": { - "version": "0.1.38", - "resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.1.38/design-system-0.1.38.tgz", - "integrity": "sha512-8tsWZTYkLg3JbvA8p+MVP05nsuJnIXZZvgx6d71e7BO3rtoI8bpvQn/ZElag6tQnBEbeqStQqqDnZ5TAWN2pvw==", + "version": "0.0.0-snapshot.20260408.7e54514", + "resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.0.0-snapshot.20260408.7e54514/design-system-0.0.0-snapshot.20260408.7e54514.tgz", + "integrity": "sha512-YgwpQfwewx7gljOnl2s6V4Ixp4D1R0t+rMf7h06Tc6dOGikibZ9GL37nxqY/6YxwrfqMRkj6DBDEfjrv0iFF7g==", "dependencies": { "lucide-react": "^1.7.0", "react": "^19.0.0", diff --git a/ui/package.json b/ui/package.json index 7247b447..79795a46 100644 --- a/ui/package.json +++ b/ui/package.json @@ -15,7 +15,7 @@ "postinstall": "node -e \"const fs=require('fs');fs.mkdirSync('public',{recursive:true});fs.copyFileSync('node_modules/@cameleer/design-system/assets/cameleer3-logo.svg','public/favicon.svg')\"" }, "dependencies": { - "@cameleer/design-system": "^0.1.38", + "@cameleer/design-system": "^0.0.0-snapshot.20260408.7e54514", "@tanstack/react-query": "^5.90.21", "lucide-react": "^1.7.0", "openapi-fetch": "^0.17.0", diff --git a/ui/src/api/queries/auth.ts b/ui/src/api/queries/auth.ts new file mode 100644 index 00000000..343f462b --- /dev/null +++ b/ui/src/api/queries/auth.ts @@ -0,0 +1,48 @@ +import { useQuery } from '@tanstack/react-query'; +import { config } from '../../config'; +import { useAuthStore } from '../../auth/auth-store'; + +export interface RoleSummary { + id: string; + name: string; + system: boolean; + source: string; +} + +export interface GroupSummary { + id: string; + name: string; +} + +export interface UserDetail { + userId: string; + provider: string; + email: string; + displayName: string; + createdAt: string; + directRoles: RoleSummary[]; + directGroups: GroupSummary[]; + effectiveRoles: RoleSummary[]; + effectiveGroups: GroupSummary[]; +} + +async function fetchMe(): Promise { + const token = useAuthStore.getState().accessToken; + const res = await fetch(`${config.apiBaseUrl}/auth/me`, { + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + 'X-Cameleer-Protocol-Version': '1', + }, + }); + if (!res.ok) throw new Error(`API error: ${res.status}`); + return res.json(); +} + +export function useMe(enabled = false) { + return useQuery({ + queryKey: ['auth', 'me'], + queryFn: fetchMe, + enabled, + staleTime: 30_000, + }); +} diff --git a/ui/src/components/AboutMeDialog.module.css b/ui/src/components/AboutMeDialog.module.css new file mode 100644 index 00000000..b46f5853 --- /dev/null +++ b/ui/src/components/AboutMeDialog.module.css @@ -0,0 +1,57 @@ +.loading { + display: flex; + justify-content: center; + padding: 2rem; +} + +.content { + display: flex; + flex-direction: column; + gap: 4px; +} + +.metaGrid { + display: grid; + grid-template-columns: auto 1fr; + gap: 6px 16px; + font-size: 12px; + font-family: var(--font-body); + margin-bottom: 8px; +} + +.metaLabel { + color: var(--text-muted); + font-weight: 500; +} + +.metaValue { + color: var(--text-primary); +} + +.tagList { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 4px; + margin-bottom: 8px; +} + +.sourceList { + display: flex; + flex-direction: column; + gap: 2px; + margin-bottom: 8px; +} + +.sourceNote { + font-size: 11px; + color: var(--text-muted); + font-family: var(--font-mono); +} + +.empty { + font-size: 12px; + color: var(--text-muted); + font-style: italic; + font-family: var(--font-body); +} diff --git a/ui/src/components/AboutMeDialog.tsx b/ui/src/components/AboutMeDialog.tsx new file mode 100644 index 00000000..0af25fc1 --- /dev/null +++ b/ui/src/components/AboutMeDialog.tsx @@ -0,0 +1,74 @@ +import { Modal, Badge, MonoText, Spinner, SectionHeader } from '@cameleer/design-system'; +import { useMe } from '../api/queries/auth'; +import styles from './AboutMeDialog.module.css'; + +interface AboutMeDialogProps { + open: boolean; + onClose: () => void; +} + +export function AboutMeDialog({ open, onClose }: AboutMeDialogProps) { + const { data: user, isLoading } = useMe(open); + + return ( + + {isLoading || !user ? ( +
+ ) : ( +
+
+ Name + {user.displayName} + User ID + {user.userId} + Provider + {user.provider} + {user.email && ( + <> + Email + {user.email} + + )} + Created + + {new Date(user.createdAt).toLocaleDateString()} + +
+ + Roles +
+ {user.effectiveRoles.length === 0 && ( + No roles assigned + )} + {user.effectiveRoles.map((role) => ( + + ))} +
+ {user.effectiveRoles.some((r) => r.source !== 'direct') && ( +
+ {user.effectiveRoles.map((role) => ( + + {role.name}: {role.source} + + ))} +
+ )} + + Groups +
+ {user.effectiveGroups.length === 0 && ( + No group memberships + )} + {user.effectiveGroups.map((group) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/ui/src/components/LayoutShell.tsx b/ui/src/components/LayoutShell.tsx index 040c06da..b5dfca2b 100644 --- a/ui/src/components/LayoutShell.tsx +++ b/ui/src/components/LayoutShell.tsx @@ -15,9 +15,10 @@ import { useGlobalFilters, useStarred, } from '@cameleer/design-system'; -import type { SearchResult, SidebarTreeNode } from '@cameleer/design-system'; +import type { SearchResult, SidebarTreeNode, DropdownItem } from '@cameleer/design-system'; import sidebarLogo from '@cameleer/design-system/assets/cameleer3-logo.svg'; -import { Box, Settings, FileText, ChevronRight, Square, Pause, Star, X } from 'lucide-react'; +import { Box, Settings, FileText, ChevronRight, Square, Pause, Star, X, User } from 'lucide-react'; +import { AboutMeDialog } from './AboutMeDialog'; import css from './LayoutShell.module.css'; import { useQueryClient } from '@tanstack/react-query'; import { useRouteCatalog } from '../api/queries/catalog'; @@ -437,6 +438,12 @@ function LayoutContent() { const effectiveSelectedPath = sidebarRevealPath ?? location.pathname; + // --- About Me dialog ----------------------------------------------- + const [aboutMeOpen, setAboutMeOpen] = useState(false); + const userMenuItems: DropdownItem[] = useMemo(() => [ + { label: 'About Me', icon: createElement(User, { size: 14 }), onClick: () => setAboutMeOpen(true) }, + ], []); + // --- Exchange full-text search via command palette ----------------- const [paletteQuery, setPaletteQuery] = useState(''); const debouncedQuery = useDebouncedValue(paletteQuery, 300); @@ -724,8 +731,10 @@ function LayoutContent() { /> } user={username ? { name: username } : undefined} + userMenuItems={userMenuItems} onLogout={handleLogout} /> + setAboutMeOpen(false)} />