Skip to content

Commit 08e30c9

Browse files
Merge pull request #968 from microsoft/LogoutFunctionality_Akhileswar
feat: implementation of logout functionality
2 parents 61ff047 + 1f01d64 commit 08e30c9

7 files changed

Lines changed: 222 additions & 21 deletions

File tree

src/App/src/App.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
1-
import React from 'react';
1+
import React, { useEffect } from 'react';
22
import './App.css';
33
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
44
import { HomePage, PlanPage } from './pages';
55
import { useWebSocket } from './hooks/useWebSocket';
6+
import { useAppDispatch } from './store/hooks';
7+
import { fetchCurrentUser } from './store/slices/appSlice';
68

79
function App() {
810
useWebSocket();
11+
const dispatch = useAppDispatch();
12+
13+
useEffect(() => {
14+
dispatch(fetchCurrentUser());
15+
}, [dispatch]);
916

1017
return (
1118
<Router>

src/App/src/commonComponents/components/Panels/PanelFooter.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ const PanelFooter: React.FC<{ children: React.ReactNode }> = ({ children }) => {
44
return (
55
<div
66
style={{
7-
padding: "24px 16px",
7+
padding: "24px 8px",
88
width:'100%'
99
}}
1010
>

src/App/src/commonComponents/components/Panels/PanelLeft.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ const PanelLeft: React.FC<PanelLeftProps> = ({
9898
{content}
9999
</div>
100100

101-
{footer && <div>{footer}</div>}
101+
{footer && <div style={{ position: 'relative', zIndex: 2 }}>{footer}</div>}
102102

103103
{panelResize && (
104104
<div
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import React, { useCallback } from 'react';
2+
import {
3+
Avatar,
4+
Menu,
5+
MenuTrigger,
6+
MenuPopover,
7+
MenuList,
8+
MenuItem,
9+
MenuButton,
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+
menuButton: {
18+
minWidth: 'auto',
19+
paddingLeft: tokens.spacingHorizontalXS,
20+
paddingRight: tokens.spacingHorizontalS,
21+
border: 'none',
22+
background: 'transparent',
23+
},
24+
menuItem: {
25+
paddingLeft: tokens.spacingHorizontalM,
26+
paddingRight: tokens.spacingHorizontalM,
27+
},
28+
userInfo: {
29+
display: 'flex',
30+
flexDirection: 'column',
31+
alignItems: 'flex-start',
32+
gap: tokens.spacingVerticalXXS,
33+
},
34+
userName: {
35+
fontWeight: tokens.fontWeightSemibold,
36+
fontSize: tokens.fontSizeBase200,
37+
},
38+
userEmail: {
39+
fontSize: tokens.fontSizeBase100,
40+
color: tokens.colorNeutralForeground2,
41+
},
42+
triggerContainer: {
43+
display: 'flex',
44+
alignItems: 'center',
45+
gap: tokens.spacingHorizontalS,
46+
},
47+
displayName: {
48+
fontSize: tokens.fontSizeBase300,
49+
fontWeight: tokens.fontWeightSemibold,
50+
color: tokens.colorNeutralForeground1,
51+
},
52+
});
53+
54+
const getUserInitials = (name: string | undefined): string => {
55+
if (!name) return 'U';
56+
const cleanName = name.replace(/\s*\([^)]*\)/g, '').trim();
57+
if (!cleanName) return 'U';
58+
const parts = cleanName.split(/\s+/).filter(Boolean);
59+
if (parts.length >= 2) {
60+
return (parts[0][0] + parts[1][0]).toUpperCase();
61+
}
62+
return cleanName.charAt(0).toUpperCase();
63+
};
64+
65+
interface LoginButtonProps {
66+
showName?: boolean;
67+
}
68+
69+
const LoginButton: React.FC<LoginButtonProps> = ({ showName = false }) => {
70+
const styles = useStyles();
71+
const userName = useAppSelector(state => state.app.userName);
72+
const userId = useAppSelector(state => state.app.userId);
73+
const userEmail = useAppSelector(state => state.app.userEmail);
74+
const isAuthenticated = Boolean(userId && userId !== 'anonymous');
75+
76+
const login = useCallback(() => {
77+
window.location.href = '/.auth/login/aad';
78+
}, []);
79+
80+
const logout = useCallback(() => {
81+
const logoutUrl = '/.auth/logout?post_logout_redirect_uri=' + encodeURIComponent('/.auth/login/aad');
82+
window.location.href = logoutUrl;
83+
}, []);
84+
85+
const displayName = isAuthenticated ? userName || userId || 'User' : 'Guest';
86+
87+
if (!isAuthenticated) {
88+
return (
89+
<div className={styles.triggerContainer} style={{ cursor: 'default' }}>
90+
<Avatar
91+
name="Guest"
92+
initials="G"
93+
size={28}
94+
color="colorful"
95+
style={{ fontWeight: 'bold' }}
96+
/>
97+
{showName && <span className={styles.displayName}>Guest</span>}
98+
</div>
99+
);
100+
}
101+
102+
return (
103+
<Menu positioning="above-end">
104+
<MenuTrigger disableButtonEnhancement>
105+
<MenuButton
106+
appearance="subtle"
107+
className={styles.menuButton}
108+
title={`Signed in as ${displayName}`}
109+
aria-label={`User menu for ${displayName}`}
110+
icon={
111+
<Avatar
112+
name={displayName}
113+
initials={getUserInitials(displayName)}
114+
size={28}
115+
color="colorful"
116+
style={{ fontWeight: 'bold' }}
117+
/>
118+
}
119+
>
120+
{showName ? <span className={styles.displayName}>{displayName}</span> : undefined}
121+
</MenuButton>
122+
</MenuTrigger>
123+
124+
<MenuPopover>
125+
<MenuList>
126+
<MenuItem className={styles.menuItem} icon={<Person20Regular />} disabled style={{ cursor: 'default' }}>
127+
<div className={styles.userInfo}>
128+
<div className={styles.userName}>{displayName}</div>
129+
{userEmail && <div className={styles.userEmail}>{userEmail}</div>}
130+
</div>
131+
</MenuItem>
132+
<MenuItem
133+
className={styles.menuItem}
134+
icon={<SignOut24Regular />}
135+
onClick={logout}
136+
>
137+
Sign out
138+
</MenuItem>
139+
</MenuList>
140+
</MenuPopover>
141+
</Menu>
142+
);
143+
};
144+
145+
export default LoginButton;

src/App/src/components/content/PlanPanelLeft.tsx

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from "react";
22
import PanelLeft from "@/commonComponents/components/Panels/PanelLeft";
33
import PanelLeftToolbar from "@/commonComponents/components/Panels/PanelLeftToolbar";
4+
import PanelFooter from "@/commonComponents/components/Panels/PanelFooter";
45
import {
56
Body1Strong,
67
Toast,
@@ -16,14 +17,12 @@ import {
1617
import TaskList from "./TaskList";
1718
import { useCallback, useEffect, useState } from "react";
1819
import { useNavigate, useParams } from "react-router-dom";
19-
import { Plan, PlanPanelLefProps, Task, UserInfo } from "@/models";
20+
import { Plan, PlanPanelLefProps, Task } from "@/models";
2021
import { apiService } from "@/api";
2122
import { TaskService } from "@/store";
2223
import ContosoLogo from "../../commonComponents/imports/ContosoLogo";
2324
import "../../styles/PlanPanelLeft.css";
24-
import PanelFooter from "@/commonComponents/components/Panels/PanelFooter";
25-
import PanelUserCard from "../../commonComponents/components/Panels/UserCard";
26-
import { getUserInfoGlobal } from "@/api/config";
25+
import LoginButton from "../auth/LoginButton";
2726
import TeamSelector from "../common/TeamSelector";
2827
import { TeamConfig } from "../../models/Team";
2928
import TeamSelected from "../common/TeamSelected";
@@ -47,9 +46,6 @@ const PlanPanelLeft: React.FC<PlanPanelLefProps> = ({
4746
const [plans, setPlans] = useState<Plan[] | null>(null);
4847
const [plansLoading, setPlansLoading] = useState<boolean>(false);
4948
const [plansError, setPlansError] = useState<Error | null>(null);
50-
const [userInfo, setUserInfo] = useState<UserInfo | null>(
51-
getUserInfoGlobal()
52-
);
5349

5450
// Use parent's selected team if provided, otherwise use local state
5551
const [localSelectedTeam, setLocalSelectedTeam] = useState<TeamConfig | null>(null);
@@ -86,15 +82,14 @@ const PlanPanelLeft: React.FC<PlanPanelLefProps> = ({
8682

8783
useEffect(() => {
8884
loadPlansData();
89-
setUserInfo(getUserInfoGlobal());
90-
}, [loadPlansData, setUserInfo]);
85+
}, [loadPlansData]);
9186

9287

9388
useEffect(() => {
9489
if (reloadTasks) {
9590
loadPlansData(true); // Force refresh when reloadTasks is true
9691
}
97-
}, [loadPlansData, setUserInfo, reloadTasks]);
92+
}, [loadPlansData, reloadTasks]);
9893
useEffect(() => {
9994
if (plans) {
10095
const { completed } =
@@ -250,12 +245,7 @@ const PlanPanelLeft: React.FC<PlanPanelLefProps> = ({
250245

251246
<PanelFooter>
252247
<div className="panel-footer-content">
253-
{/* User Card */}
254-
<PanelUserCard
255-
name={userInfo?.user_first_last_name || "Guest"}
256-
// alias={userInfo ? userInfo.user_email : ""}
257-
size={32}
258-
/>
248+
<LoginButton showName />
259249
</div>
260250
</PanelFooter>
261251
</PanelLeft>

src/App/src/store/slices/appSlice.ts

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
/**
2-
* App Slice — global application state: config, theme, WebSocket connection.
2+
* App Slice — global application state: config, theme, WebSocket connection, auth.
33
*/
4-
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
4+
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
55
import type { RootState } from '../store';
6+
import { getUserInfo } from '../../api/config';
67

78
export interface AppState {
89
/** Has the runtime config been loaded from /config? */
@@ -11,14 +12,57 @@ export interface AppState {
1112
isDarkMode: boolean;
1213
/** Is the global WebSocket connected? */
1314
wsConnected: boolean;
15+
/** Current user ID from EasyAuth */
16+
userId: string;
17+
/** Current user display name */
18+
userName: string;
19+
/** Current user email */
20+
userEmail: string;
1421
}
1522

1623
const initialState: AppState = {
1724
configLoaded: false,
1825
isDarkMode: window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false,
1926
wsConnected: false,
27+
userId: '',
28+
userName: '',
29+
userEmail: '',
2030
};
2131

32+
export const fetchCurrentUser = createAsyncThunk(
33+
'app/fetchCurrentUser',
34+
async (_arg, { rejectWithValue }) => {
35+
try {
36+
const userInfo = await getUserInfo();
37+
38+
if (!userInfo.user_id) {
39+
return rejectWithValue('No user identity found');
40+
}
41+
42+
// Extract email from claims (preferred_username, email, or UPN)
43+
const userClaims = userInfo.user_claims || [];
44+
let emailVal = userInfo.user_email || '';
45+
for (const claim of userClaims) {
46+
if (claim.typ === 'preferred_username' ||
47+
claim.typ === 'email' ||
48+
claim.typ === 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress' ||
49+
claim.typ === 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn') {
50+
emailVal = claim.val;
51+
break;
52+
}
53+
}
54+
55+
return {
56+
userId: userInfo.user_id || 'anonymous',
57+
userName: userInfo.user_first_last_name || '',
58+
userEmail: emailVal,
59+
};
60+
} catch (error) {
61+
return rejectWithValue('Failed to fetch user info');
62+
}
63+
}
64+
);
65+
2266
const appSlice = createSlice({
2367
name: 'app',
2468
initialState,
@@ -33,6 +77,19 @@ const appSlice = createSlice({
3377
state.wsConnected = action.payload;
3478
},
3579
},
80+
extraReducers: (builder) => {
81+
builder
82+
.addCase(fetchCurrentUser.fulfilled, (state, action) => {
83+
state.userId = action.payload.userId;
84+
state.userName = action.payload.userName;
85+
state.userEmail = action.payload.userEmail;
86+
})
87+
.addCase(fetchCurrentUser.rejected, (state) => {
88+
state.userId = 'anonymous';
89+
state.userName = '';
90+
state.userEmail = '';
91+
});
92+
},
3693
});
3794

3895
export const {

src/App/src/styles/PlanPanelLeft.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ color: #2F2F4A; */
5656
flex-direction: column;
5757
gap: 12px;
5858
width: 100%;
59+
align-items: flex-start;
60+
padding-left: 8px;
5961
}
6062

6163
/* TASKLIST */

0 commit comments

Comments
 (0)