Skip to content

Commit a55eec5

Browse files
authored
FEAT [GUI] Display signed-in user info in top bar and populate operator label with username (microsoft#1636)
1 parent ccfc9a2 commit a55eec5

6 files changed

Lines changed: 327 additions & 7 deletions

File tree

frontend/src/App.test.tsx

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
77
import App from "./App";
88
import { attacksApi } from "./services/api";
99

10+
const mockGetActiveAccount = jest.fn();
11+
1012
// Mock MSAL — App uses useMsal() to wire the instance into the API client
1113
jest.mock("@azure/msal-react", () => ({
12-
useMsal: () => ({ instance: { getActiveAccount: () => null, getAllAccounts: () => [] } }),
14+
useMsal: () => ({ instance: { getActiveAccount: mockGetActiveAccount, getAllAccounts: () => [] } }),
1315
}));
1416

1517
jest.mock("./services/api", () => ({
@@ -86,17 +88,21 @@ jest.mock("./components/Chat/ChatWindow", () => {
8688
conversationId,
8789
onConversationCreated,
8890
onSelectConversation,
91+
labels,
8992
}: {
9093
onNewAttack: () => void;
9194
activeTarget: unknown;
9295
conversationId: string | null;
9396
onConversationCreated: (attackResultId: string, conversationId: string) => void;
9497
onSelectConversation: (convId: string) => void;
98+
labels: Record<string, string>;
9599
}) => {
96100
return (
97101
<div data-testid="chat-window">
98102
<span data-testid="conversation-id">{conversationId ?? "none"}</span>
99103
<span data-testid="has-target">{activeTarget ? "yes" : "no"}</span>
104+
<span data-testid="labels-operator">{labels.operator ?? ""}</span>
105+
<span data-testid="labels-json">{JSON.stringify(labels)}</span>
100106
<button onClick={onNewAttack} data-testid="new-attack">
101107
New Attack
102108
</button>
@@ -183,6 +189,11 @@ jest.mock("./components/History/AttackHistory", () => {
183189
});
184190

185191
describe("App", () => {
192+
beforeEach(() => {
193+
jest.clearAllMocks();
194+
mockGetActiveAccount.mockReturnValue(null);
195+
});
196+
186197
it("renders with FluentProvider and MainLayout", () => {
187198
render(<App />);
188199
expect(screen.getByTestId("main-layout")).toBeInTheDocument();
@@ -337,6 +348,41 @@ describe("App", () => {
337348
await waitFor(() => {
338349
expect(mockedVersionApi.getVersion).toHaveBeenCalled();
339350
});
351+
352+
await waitFor(() => {
353+
expect(screen.getByTestId("labels-operator")).toHaveTextContent("default_user");
354+
expect(screen.getByTestId("labels-json")).toHaveTextContent('"custom":"value"');
355+
});
356+
});
357+
358+
it("sets operator label from active account alias when backend has no operator", async () => {
359+
mockGetActiveAccount.mockReturnValue({ username: "Test.User@contoso.com" });
360+
mockedVersionApi.getVersion.mockResolvedValueOnce({
361+
version: "2.0.0",
362+
default_labels: { custom: "value" },
363+
});
364+
365+
render(<App />);
366+
367+
await waitFor(() => {
368+
expect(screen.getByTestId("labels-operator")).toHaveTextContent("test.user");
369+
expect(screen.getByTestId("labels-json")).toHaveTextContent('"custom":"value"');
370+
});
371+
});
372+
373+
it("prefers active account alias over backend operator when both are provided", async () => {
374+
mockGetActiveAccount.mockReturnValue({ username: "override_user@contoso.com" });
375+
mockedVersionApi.getVersion.mockResolvedValueOnce({
376+
version: "2.0.0",
377+
default_labels: { operator: "backend_user", custom: "value" },
378+
});
379+
380+
render(<App />);
381+
382+
await waitFor(() => {
383+
expect(screen.getByTestId("labels-operator")).toHaveTextContent("override_user");
384+
expect(screen.getByTestId("labels-json")).toHaveTextContent('"custom":"value"');
385+
});
340386
});
341387

342388
it("stores attack target when conversation is created with active target", () => {

frontend/src/App.tsx

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useState, useCallback, useEffect } from 'react'
22
import { FluentProvider, webLightTheme, webDarkTheme } from '@fluentui/react-components'
3+
import { useMsal } from '@azure/msal-react'
34
import MainLayout from './components/Layout/MainLayout'
45
import ChatWindow from './components/Chat/ChatWindow'
56
import TargetConfig from './components/Config/TargetConfig'
@@ -36,6 +37,7 @@ function ConnectionBannerContainer() {
3637
}
3738

3839
function App() {
40+
const { instance } = useMsal()
3941
const [isDarkMode, setIsDarkMode] = useState(true)
4042
const [currentView, setCurrentView] = useState<ViewName>('chat')
4143
const [activeTarget, setActiveTarget] = useState<TargetInstance | null>(null)
@@ -45,16 +47,38 @@ function App() {
4547
/** Persisted filter state for the history view */
4648
const [historyFilters, setHistoryFilters] = useState<HistoryFilters>({ ...DEFAULT_HISTORY_FILTERS })
4749

48-
// Fetch default labels from backend configuration on startup
50+
// Fetch default labels from backend, then override operator with active account if available
4951
useEffect(() => {
50-
versionApi.getVersion()
51-
.then((data) => {
52+
let ignore = false
53+
54+
async function initLabels() {
55+
let defaultLabels: Record<string, string> = {}
56+
try {
57+
const data = await versionApi.getVersion()
5258
if (data.default_labels && Object.keys(data.default_labels).length > 0) {
53-
setGlobalLabels(prev => ({ ...prev, ...data.default_labels }))
59+
defaultLabels = data.default_labels
60+
}
61+
} catch {
62+
/* version fetch handled elsewhere */
63+
}
64+
65+
if (ignore) return
66+
67+
const account = instance.getActiveAccount?.()
68+
const alias = account?.username ? account.username.split('@')[0].toLowerCase() : null
69+
70+
setGlobalLabels(prev => {
71+
const next = { ...prev, ...defaultLabels }
72+
if (alias) {
73+
next.operator = alias
5474
}
75+
return next
5576
})
56-
.catch(() => { /* version fetch handled elsewhere */ })
57-
}, [])
77+
}
78+
79+
initLabels()
80+
return () => { ignore = true }
81+
}, [instance])
5882

5983
const handleSetActiveTarget = useCallback((target: TargetInstance) => {
6084
setActiveTarget(prev => {

frontend/src/components/Layout/MainLayout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
} from '@fluentui/react-components'
66
import { versionApi } from '../../services/api'
77
import Navigation, { type ViewName } from '../Sidebar/Navigation'
8+
import { UserAccountButton } from '../UserAccountButton'
89
import { useMainLayoutStyles } from './MainLayout.styles'
910

1011
interface MainLayoutProps {
@@ -47,6 +48,7 @@ export default function MainLayout({
4748
</Tooltip>
4849
<Text className={styles.title}>Co-PyRIT</Text>
4950
<Text className={styles.subtitle}>Python Risk Identification Tool</Text>
51+
<UserAccountButton />
5052
</div>
5153
<div className={styles.contentArea}>
5254
<aside className={styles.sidebar}>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
import { makeStyles, tokens } from '@fluentui/react-components'
5+
6+
export const useUserAccountButtonStyles = makeStyles({
7+
wrapper: {
8+
marginLeft: 'auto',
9+
display: 'flex',
10+
alignItems: 'center',
11+
},
12+
popoverContent: {
13+
display: 'flex',
14+
flexDirection: 'column',
15+
gap: tokens.spacingVerticalS,
16+
},
17+
accountName: {
18+
fontWeight: tokens.fontWeightSemibold,
19+
},
20+
accountEmail: {
21+
fontSize: tokens.fontSizeBase200,
22+
color: tokens.colorNeutralForeground3,
23+
},
24+
})
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { render, screen } from '@testing-library/react'
2+
import userEvent from '@testing-library/user-event'
3+
import { FluentProvider, webLightTheme } from '@fluentui/react-components'
4+
import { UserAccountButton } from './UserAccountButton'
5+
6+
const mockLoginRedirect = jest.fn()
7+
const mockLogoutRedirect = jest.fn()
8+
const mockGetActiveAccount = jest.fn()
9+
let mockAccounts: { name?: string; username?: string }[] = []
10+
11+
jest.mock('@azure/msal-react', () => ({
12+
useMsal: () => ({
13+
instance: {
14+
getActiveAccount: mockGetActiveAccount,
15+
loginRedirect: mockLoginRedirect,
16+
logoutRedirect: mockLogoutRedirect,
17+
},
18+
accounts: mockAccounts,
19+
}),
20+
}))
21+
22+
let mockAuthConfig = { clientId: '', tenantId: '', allowedGroupIds: '' }
23+
24+
jest.mock('../auth/AuthConfigContext', () => ({
25+
useAuthConfig: () => mockAuthConfig,
26+
}))
27+
28+
jest.mock('../auth/msalConfig', () => ({
29+
buildLoginRequest: (clientId: string) => ({ scopes: [`${clientId}/.default`] }),
30+
}))
31+
32+
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
33+
<FluentProvider theme={webLightTheme}>{children}</FluentProvider>
34+
)
35+
36+
describe('UserAccountButton', () => {
37+
beforeEach(() => {
38+
jest.clearAllMocks()
39+
mockGetActiveAccount.mockReturnValue(null)
40+
mockLoginRedirect.mockResolvedValue(undefined)
41+
mockLogoutRedirect.mockResolvedValue(undefined)
42+
mockAuthConfig = { clientId: '', tenantId: '', allowedGroupIds: '' }
43+
mockAccounts = []
44+
})
45+
46+
it('returns null when auth is disabled (no clientId)', () => {
47+
render(
48+
<TestWrapper>
49+
<UserAccountButton />
50+
</TestWrapper>
51+
)
52+
53+
// UserAccountButton renders null — no buttons appear
54+
expect(screen.queryByRole('button')).toBeNull()
55+
})
56+
57+
it('renders Log In button when auth enabled but no account', () => {
58+
mockAuthConfig = { clientId: 'test-client-id', tenantId: 'test-tenant', allowedGroupIds: '' }
59+
60+
render(
61+
<TestWrapper>
62+
<UserAccountButton />
63+
</TestWrapper>
64+
)
65+
66+
expect(screen.getByRole('button', { name: /log in/i })).toBeInTheDocument()
67+
})
68+
69+
it('calls loginRedirect when Log In is clicked', async () => {
70+
mockAuthConfig = { clientId: 'test-client-id', tenantId: 'test-tenant', allowedGroupIds: '' }
71+
const user = userEvent.setup()
72+
73+
render(
74+
<TestWrapper>
75+
<UserAccountButton />
76+
</TestWrapper>
77+
)
78+
79+
await user.click(screen.getByRole('button', { name: /log in/i }))
80+
81+
expect(mockLoginRedirect).toHaveBeenCalledWith({ scopes: ['test-client-id/.default'] })
82+
})
83+
84+
it('renders user display name and Sign Out when account exists', () => {
85+
mockAuthConfig = { clientId: 'test-client-id', tenantId: 'test-tenant', allowedGroupIds: '' }
86+
mockGetActiveAccount.mockReturnValue({
87+
name: 'Alice Smith',
88+
username: 'alice@example.com',
89+
})
90+
91+
render(
92+
<TestWrapper>
93+
<UserAccountButton />
94+
</TestWrapper>
95+
)
96+
97+
expect(screen.getByText('Alice Smith')).toBeInTheDocument()
98+
})
99+
100+
it('renders user display name when account comes from accounts[0] (no active account)', () => {
101+
mockAuthConfig = { clientId: 'test-client-id', tenantId: 'test-tenant', allowedGroupIds: '' }
102+
mockAccounts = [{ name: 'Bob Jones', username: 'bob@example.com' }]
103+
104+
render(
105+
<TestWrapper>
106+
<UserAccountButton />
107+
</TestWrapper>
108+
)
109+
110+
expect(screen.getByText('Bob Jones')).toBeInTheDocument()
111+
})
112+
113+
it('calls logoutRedirect when Sign Out is clicked', async () => {
114+
mockAuthConfig = { clientId: 'test-client-id', tenantId: 'test-tenant', allowedGroupIds: '' }
115+
mockGetActiveAccount.mockReturnValue({
116+
name: 'Alice Smith',
117+
username: 'alice@example.com',
118+
})
119+
const user = userEvent.setup()
120+
121+
render(
122+
<TestWrapper>
123+
<UserAccountButton />
124+
</TestWrapper>
125+
)
126+
127+
// Open the popover by clicking the user button
128+
await user.click(screen.getByRole('button', { name: /alice smith/i }))
129+
130+
await user.click(screen.getByRole('button', { name: /sign out/i }))
131+
132+
expect(mockLogoutRedirect).toHaveBeenCalled()
133+
})
134+
})

0 commit comments

Comments
 (0)