Skip to content

Commit 7a5344e

Browse files
Merge pull request #838 from microsoft/LogoutFunctionality_Akhileswar
feat: implementation of logout functionality
2 parents def0444 + e4e4cc7 commit 7a5344e

5 files changed

Lines changed: 161 additions & 16 deletions

File tree

src/App/src/App.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ function App() {
1414
const dispatch = useAppDispatch();
1515

1616
// Select state from Redux store
17-
const { userName, imageGenerationEnabled, showChatHistory } = useAppSelector(state => state.app);
17+
const { imageGenerationEnabled, showChatHistory } = useAppSelector(state => state.app);
1818
const { conversationId, conversationTitle, messages, isLoading, generationStatus, historyRefreshTrigger } = useAppSelector(state => state.chat);
1919
const { pendingBrief, confirmedBrief, selectedProducts, availableProducts, generatedContent } = useAppSelector(state => state.content);
2020

@@ -44,7 +44,6 @@ function App() {
4444
<div className="app-container">
4545
{/* Header */}
4646
<AppHeader
47-
userName={userName}
4847
showChatHistory={showChatHistory}
4948
onToggleChatHistory={() => dispatch(toggleChatHistory())}
5049
/>

src/App/src/components/AppHeader.tsx

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import React from 'react';
77
import {
88
Text,
9-
Avatar,
109
Button,
1110
Tooltip,
1211
tokens,
@@ -16,15 +15,14 @@ import {
1615
History24Filled,
1716
} from '@fluentui/react-icons';
1817
import ContosoLogo from '../styles/images/contoso.svg';
18+
import LoginButton from './LoginButton';
1919

2020
interface AppHeaderProps {
21-
userName: string;
2221
showChatHistory: boolean;
2322
onToggleChatHistory: () => void;
2423
}
2524

2625
export const AppHeader = React.memo(function AppHeader({
27-
userName,
2826
showChatHistory,
2927
onToggleChatHistory,
3028
}: AppHeaderProps) {
@@ -53,11 +51,7 @@ export const AppHeader = React.memo(function AppHeader({
5351
aria-label={showChatHistory ? 'Hide chat history' : 'Show chat history'}
5452
/>
5553
</Tooltip>
56-
<Avatar
57-
name={userName || undefined}
58-
color="colorful"
59-
size={36}
60-
/>
54+
<LoginButton/>
6155
</div>
6256
</header>
6357
);
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import React, { useCallback } from 'react';
2+
import {
3+
Avatar,
4+
Menu,
5+
MenuTrigger,
6+
MenuPopover,
7+
MenuList,
8+
MenuItem,
9+
Button,
10+
makeStyles,
11+
tokens,
12+
} from '@fluentui/react-components';
13+
import { Person20Regular, SignOut24Regular } from '@fluentui/react-icons';
14+
import { useAppSelector } from '../store/hooks';
15+
16+
const useStyles = makeStyles({
17+
userButton: {
18+
minWidth: 'auto',
19+
paddingLeft: tokens.spacingHorizontalXS,
20+
paddingRight: tokens.spacingHorizontalXS,
21+
},
22+
menuItem: {
23+
paddingLeft: tokens.spacingHorizontalM,
24+
paddingRight: tokens.spacingHorizontalM,
25+
},
26+
userInfo: {
27+
display: 'flex',
28+
flexDirection: 'column',
29+
alignItems: 'flex-start',
30+
gap: tokens.spacingVerticalXXS,
31+
},
32+
userName: {
33+
fontWeight: tokens.fontWeightSemibold,
34+
fontSize: tokens.fontSizeBase200,
35+
},
36+
userEmail: {
37+
fontSize: tokens.fontSizeBase100,
38+
color: tokens.colorNeutralForeground2,
39+
},
40+
});
41+
42+
const getUserInitials = (name: string | undefined): string => {
43+
if (!name) return 'U';
44+
const cleanName = name.replace(/\s*\([^)]*\)/g, '').trim();
45+
if (!cleanName) return 'U';
46+
const parts = cleanName.split(/\s+/).filter(Boolean);
47+
if (parts.length >= 2) {
48+
return (parts[0][0] + parts[1][0]).toUpperCase();
49+
}
50+
return cleanName.charAt(0).toUpperCase();
51+
};
52+
53+
const LoginButton: React.FC = () => {
54+
const styles = useStyles();
55+
const userName = useAppSelector(state => state.app.userName);
56+
const userId = useAppSelector(state => state.app.userId);
57+
const userEmail = useAppSelector(state => state.app.userEmail);
58+
const isAuthenticated = Boolean(userId && userId !== 'anonymous');
59+
60+
const login = useCallback(() => {
61+
window.location.href = '/.auth/login/aad';
62+
}, []);
63+
64+
const logout = useCallback(() => {
65+
const logoutUrl = '/.auth/logout?post_logout_redirect_uri=' + encodeURIComponent('/');
66+
window.location.href = logoutUrl;
67+
}, []);
68+
69+
const displayName = isAuthenticated ? userName || userId || 'User' : 'User';
70+
71+
if (!isAuthenticated) {
72+
return (
73+
<Button
74+
appearance="subtle"
75+
className={styles.userButton}
76+
onClick={login}
77+
title="Sign in"
78+
aria-label="Sign in"
79+
icon={
80+
<Avatar
81+
name={displayName}
82+
initials={getUserInitials(displayName)}
83+
size={28}
84+
color="colorful"
85+
style={{ fontWeight: 'bold' }}
86+
/>
87+
}
88+
/>
89+
);
90+
}
91+
92+
return (
93+
<Menu>
94+
<MenuTrigger disableButtonEnhancement>
95+
<Button
96+
appearance="subtle"
97+
className={styles.userButton}
98+
title={`Signed in as ${displayName}`}
99+
aria-label={`User menu for ${displayName}`}
100+
icon={
101+
<Avatar
102+
name={displayName}
103+
initials={getUserInitials(displayName)}
104+
size={28}
105+
color="colorful"
106+
style={{ fontWeight: 'bold' }}
107+
/>
108+
}
109+
/>
110+
</MenuTrigger>
111+
112+
<MenuPopover>
113+
<MenuList>
114+
<MenuItem className={styles.menuItem} icon={<Person20Regular />} disabled style={{ cursor: 'default' }}>
115+
<div className={styles.userInfo}>
116+
<div className={styles.userName}>{displayName}</div>
117+
{userEmail && <div className={styles.userEmail}>{userEmail}</div>}
118+
</div>
119+
</MenuItem>
120+
<MenuItem
121+
className={styles.menuItem}
122+
icon={<SignOut24Regular />}
123+
onClick={logout}
124+
>
125+
Sign out
126+
</MenuItem>
127+
</MenuList>
128+
</MenuPopover>
129+
</Menu>
130+
);
131+
};
132+
133+
export default LoginButton;

src/App/src/store/appSlice.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@ import { httpClient } from '../utils/httpClient';
1111
interface AppState {
1212
userId: string;
1313
userName: string;
14+
userEmail: string;
1415
imageGenerationEnabled: boolean;
1516
showChatHistory: boolean;
1617
}
1718

1819
const initialState: AppState = {
1920
userId: '',
2021
userName: '',
22+
userEmail: '',
2123
imageGenerationEnabled: true,
2224
showChatHistory: true,
2325
};
@@ -30,13 +32,15 @@ export const fetchAppConfig = createAsyncThunk(
3032
}
3133
);
3234

35+
type AuthClaim = { typ: string; val: string };
36+
type AuthPayload = Array<{ user_id: string; user_claims: AuthClaim[] }>;
37+
3338
export const fetchCurrentUser = createAsyncThunk(
3439
'app/fetchCurrentUser',
3540
async () => {
3641
try {
37-
const payload = await httpClient.fetchExternal<Array<{
38-
user_claims?: Array<{ typ: string; val: string }>;
39-
}>>('/.auth/me');
42+
const payload = await httpClient.fetchExternal<AuthPayload>('/.auth/me');
43+
4044
const userClaims = payload[0]?.user_claims || [];
4145
const objectIdClaim = userClaims.find(
4246
(claim) =>
@@ -45,12 +49,25 @@ export const fetchCurrentUser = createAsyncThunk(
4549
const nameClaim = userClaims.find(
4650
(claim) => claim.typ === 'name'
4751
);
52+
53+
let emailVal = '';
54+
for (const claim of userClaims) {
55+
if (claim.typ === 'preferred_username' ||
56+
claim.typ === 'email' ||
57+
claim.typ === 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress' ||
58+
claim.typ === 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn') {
59+
emailVal = claim.val;
60+
break;
61+
}
62+
}
63+
4864
return {
49-
userId: objectIdClaim?.val || 'anonymous',
65+
userId: objectIdClaim?.val || payload[0]?.user_id || 'anonymous',
5066
userName: nameClaim?.val || '',
67+
userEmail: emailVal,
5168
};
5269
} catch {
53-
return { userId: 'anonymous', userName: '' };
70+
return { userId: 'anonymous', userName: '', userEmail: '' };
5471
}
5572
}
5673
);
@@ -75,10 +92,12 @@ const appSlice = createSlice({
7592
.addCase(fetchCurrentUser.fulfilled, (state, action) => {
7693
state.userId = action.payload.userId;
7794
state.userName = action.payload.userName;
95+
state.userEmail = action.payload.userEmail;
7896
})
7997
.addCase(fetchCurrentUser.rejected, (state) => {
8098
state.userId = 'anonymous';
8199
state.userName = '';
100+
state.userEmail = '';
82101
});
83102
},
84103
});

src/App/src/utils/httpClient.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ class HttpClient {
6969
throw new Error(`${config.method || 'GET'} ${url} failed: ${response.statusText}`);
7070
}
7171
const contentType = response.headers.get('content-type') || '';
72-
if (!contentType.toLowerCase().includes('application/json')) {
72+
if (!contentType.toLowerCase().includes('json')) {
7373
throw new Error(`${config.method || 'GET'} ${url} returned non-JSON response (content-type: ${contentType || 'unknown'})`);
7474
}
7575
return response.json();

0 commit comments

Comments
 (0)