feat: add About Me dialog showing user info, roles, and groups
- Add GET /api/v1/auth/me endpoint returning current user's UserDetail - Add AboutMeDialog component with role badges and group memberships - Add userMenuItems prop to TopBar via design-system update - Wire "About Me" menu item into user dropdown above Logout Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import com.cameleer3.server.core.admin.AuditResult;
|
|||||||
import com.cameleer3.server.core.admin.AuditService;
|
import com.cameleer3.server.core.admin.AuditService;
|
||||||
import com.cameleer3.server.core.rbac.RbacService;
|
import com.cameleer3.server.core.rbac.RbacService;
|
||||||
import com.cameleer3.server.core.rbac.SystemRole;
|
import com.cameleer3.server.core.rbac.SystemRole;
|
||||||
|
import com.cameleer3.server.core.rbac.UserDetail;
|
||||||
import com.cameleer3.server.core.security.JwtService;
|
import com.cameleer3.server.core.security.JwtService;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import com.cameleer3.server.core.security.JwtService.JwtValidationResult;
|
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.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
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.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
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<UserDetail> 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 LoginRequest(String username, String password) {}
|
||||||
public record RefreshRequest(String refreshToken) {}
|
public record RefreshRequest(String refreshToken) {}
|
||||||
}
|
}
|
||||||
|
|||||||
8
ui/package-lock.json
generated
8
ui/package-lock.json
generated
@@ -9,7 +9,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cameleer/design-system": "^0.1.38",
|
"@cameleer/design-system": "^0.0.0-snapshot.20260408.7e54514",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"openapi-fetch": "^0.17.0",
|
"openapi-fetch": "^0.17.0",
|
||||||
@@ -279,9 +279,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@cameleer/design-system": {
|
"node_modules/@cameleer/design-system": {
|
||||||
"version": "0.1.38",
|
"version": "0.0.0-snapshot.20260408.7e54514",
|
||||||
"resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.1.38/design-system-0.1.38.tgz",
|
"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-8tsWZTYkLg3JbvA8p+MVP05nsuJnIXZZvgx6d71e7BO3rtoI8bpvQn/ZElag6tQnBEbeqStQqqDnZ5TAWN2pvw==",
|
"integrity": "sha512-YgwpQfwewx7gljOnl2s6V4Ixp4D1R0t+rMf7h06Tc6dOGikibZ9GL37nxqY/6YxwrfqMRkj6DBDEfjrv0iFF7g==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|||||||
@@ -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')\""
|
"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": {
|
"dependencies": {
|
||||||
"@cameleer/design-system": "^0.1.38",
|
"@cameleer/design-system": "^0.0.0-snapshot.20260408.7e54514",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"openapi-fetch": "^0.17.0",
|
"openapi-fetch": "^0.17.0",
|
||||||
|
|||||||
48
ui/src/api/queries/auth.ts
Normal file
48
ui/src/api/queries/auth.ts
Normal file
@@ -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<UserDetail> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
57
ui/src/components/AboutMeDialog.module.css
Normal file
57
ui/src/components/AboutMeDialog.module.css
Normal file
@@ -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);
|
||||||
|
}
|
||||||
74
ui/src/components/AboutMeDialog.tsx
Normal file
74
ui/src/components/AboutMeDialog.tsx
Normal file
@@ -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 (
|
||||||
|
<Modal open={open} onClose={onClose} title="About Me" size="sm">
|
||||||
|
{isLoading || !user ? (
|
||||||
|
<div className={styles.loading}><Spinner size="md" /></div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.content}>
|
||||||
|
<div className={styles.metaGrid}>
|
||||||
|
<span className={styles.metaLabel}>Name</span>
|
||||||
|
<span className={styles.metaValue}>{user.displayName}</span>
|
||||||
|
<span className={styles.metaLabel}>User ID</span>
|
||||||
|
<MonoText size="xs">{user.userId}</MonoText>
|
||||||
|
<span className={styles.metaLabel}>Provider</span>
|
||||||
|
<span className={styles.metaValue}>{user.provider}</span>
|
||||||
|
{user.email && (
|
||||||
|
<>
|
||||||
|
<span className={styles.metaLabel}>Email</span>
|
||||||
|
<span className={styles.metaValue}>{user.email}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className={styles.metaLabel}>Created</span>
|
||||||
|
<span className={styles.metaValue}>
|
||||||
|
{new Date(user.createdAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionHeader>Roles</SectionHeader>
|
||||||
|
<div className={styles.tagList}>
|
||||||
|
{user.effectiveRoles.length === 0 && (
|
||||||
|
<span className={styles.empty}>No roles assigned</span>
|
||||||
|
)}
|
||||||
|
{user.effectiveRoles.map((role) => (
|
||||||
|
<Badge
|
||||||
|
key={role.id}
|
||||||
|
label={role.name}
|
||||||
|
color={role.name === 'ADMIN' ? 'error' : role.name === 'OPERATOR' ? 'warning' : 'auto'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{user.effectiveRoles.some((r) => r.source !== 'direct') && (
|
||||||
|
<div className={styles.sourceList}>
|
||||||
|
{user.effectiveRoles.map((role) => (
|
||||||
|
<span key={role.id} className={styles.sourceNote}>
|
||||||
|
{role.name}: {role.source}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SectionHeader>Groups</SectionHeader>
|
||||||
|
<div className={styles.tagList}>
|
||||||
|
{user.effectiveGroups.length === 0 && (
|
||||||
|
<span className={styles.empty}>No group memberships</span>
|
||||||
|
)}
|
||||||
|
{user.effectiveGroups.map((group) => (
|
||||||
|
<Badge key={group.id} label={group.name} color="auto" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,9 +15,10 @@ import {
|
|||||||
useGlobalFilters,
|
useGlobalFilters,
|
||||||
useStarred,
|
useStarred,
|
||||||
} from '@cameleer/design-system';
|
} 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 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 css from './LayoutShell.module.css';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useRouteCatalog } from '../api/queries/catalog';
|
import { useRouteCatalog } from '../api/queries/catalog';
|
||||||
@@ -437,6 +438,12 @@ function LayoutContent() {
|
|||||||
|
|
||||||
const effectiveSelectedPath = sidebarRevealPath ?? location.pathname;
|
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 -----------------
|
// --- Exchange full-text search via command palette -----------------
|
||||||
const [paletteQuery, setPaletteQuery] = useState('');
|
const [paletteQuery, setPaletteQuery] = useState('');
|
||||||
const debouncedQuery = useDebouncedValue(paletteQuery, 300);
|
const debouncedQuery = useDebouncedValue(paletteQuery, 300);
|
||||||
@@ -724,8 +731,10 @@ function LayoutContent() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
user={username ? { name: username } : undefined}
|
user={username ? { name: username } : undefined}
|
||||||
|
userMenuItems={userMenuItems}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
/>
|
/>
|
||||||
|
<AboutMeDialog open={aboutMeOpen} onClose={() => setAboutMeOpen(false)} />
|
||||||
<CommandPalette
|
<CommandPalette
|
||||||
key={isAdminPage ? 'admin' : 'ops'}
|
key={isAdminPage ? 'admin' : 'ops'}
|
||||||
open={paletteOpen}
|
open={paletteOpen}
|
||||||
|
|||||||
Reference in New Issue
Block a user