Feat/update login flow reload page (#467)

* update spacing event modal

* update spacing create calendar modal

* fix evenchip description color

* using variant instead of sx

* update login flow

* fix: add useEffect import back to Calendar.tsx
This commit is contained in:
lenhanphung
2026-01-26 17:13:26 +07:00
committed by GitHub
parent 18c0ffb8bf
commit 7058f1c2d6
9 changed files with 284 additions and 80 deletions
+53 -15
View File
@@ -28,21 +28,50 @@ describe("HandleLogin", () => {
jest.spyOn(oidcAuth, "Auth").mockResolvedValue(loginUrlMock); jest.spyOn(oidcAuth, "Auth").mockResolvedValue(loginUrlMock);
renderWithProviders(<HandleLogin />); renderWithProviders(<HandleLogin />, {
user: {
await waitFor(() => { userData: null,
expect(oidcAuth.Auth).toHaveBeenCalled(); tokens: null,
expect(sessionStorage.getItem("redirectState")).toEqual( loading: false,
JSON.stringify({ error: null,
code_verifier: "verifier123", },
state: "state123", calendars: {
}) list: {},
); pending: false,
expect(apiUtils.redirectTo).toHaveBeenCalledWith(loginUrlMock.redirectTo); error: null,
},
}); });
await waitFor(
() => {
expect(oidcAuth.Auth).toHaveBeenCalled();
},
{ timeout: 3000 }
);
await waitFor(
() => {
expect(sessionStorage.getItem("redirectState")).toEqual(
JSON.stringify({
code_verifier: "verifier123",
state: "state123",
})
);
},
{ timeout: 3000 }
);
await waitFor(
() => {
expect(apiUtils.redirectTo).toHaveBeenCalledWith(
loginUrlMock.redirectTo
);
},
{ timeout: 3000 }
);
}); });
test("shows Loading when userData exists and calendars pending is true", () => { test("does not render loading element when userData exists and calendars pending is true", () => {
const preloadedState = { const preloadedState = {
user: { user: {
userData: { userData: {
@@ -51,14 +80,18 @@ describe("HandleLogin", () => {
sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro", sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro",
openpaasId: "667037022b752d0026472254", openpaasId: "667037022b752d0026472254",
}, },
tokens: { access_token: "test" },
loading: false,
}, },
calendars: { list: {}, pending: true }, calendars: { list: {}, pending: true },
loading: { isLoading: true },
}; };
renderWithProviders(<HandleLogin />, preloadedState); renderWithProviders(<HandleLogin />, preloadedState);
expect(screen.getByTestId("loading")).toBeInTheDocument(); // HandleLogin now returns null, loading is shown at App level via appLoading state
expect(screen.queryByTestId("loading")).not.toBeInTheDocument();
}); });
test("shows Loading when userData exists and calendars pending is false", () => { test("does not render loading element when userData exists and calendars pending is false", () => {
const preloadedState = { const preloadedState = {
user: { user: {
userData: { userData: {
@@ -67,11 +100,16 @@ describe("HandleLogin", () => {
sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro", sid: "aiYbWZSk2g0F+LrQeD7Dg4QcUMR8R/zTZdZBiA7N6Ro",
openpaasId: "667037022b752d0026472254", openpaasId: "667037022b752d0026472254",
}, },
tokens: { access_token: "test" },
loading: false,
}, },
calendars: { list: {}, pending: false },
loading: { isLoading: false },
}; };
renderWithProviders(<HandleLogin />, preloadedState); renderWithProviders(<HandleLogin />, preloadedState);
expect(screen.getByTestId("loading")).toBeInTheDocument(); // HandleLogin now returns null, loading is shown at App level via appLoading state
expect(screen.queryByTestId("loading")).not.toBeInTheDocument();
}); });
test("goes to error page when there is error in user data", () => { test("goes to error page when there is error in user data", () => {
const dispatch = appHooks.useAppDispatch(); const dispatch = appHooks.useAppDispatch();
+73 -8
View File
@@ -1,4 +1,4 @@
import { useAppDispatch } from "@/app/hooks"; import { useAppDispatch, useAppSelector } from "@/app/hooks";
import { getCalendarsListAsync } from "@/features/Calendars/services/getCalendarsListAsync"; import { getCalendarsListAsync } from "@/features/Calendars/services/getCalendarsListAsync";
import { CallbackResume } from "@/features/User/LoginCallback"; import { CallbackResume } from "@/features/User/LoginCallback";
import * as oidcAuth from "@/features/User/oidcAuth"; import * as oidcAuth from "@/features/User/oidcAuth";
@@ -8,13 +8,26 @@ import {
setUserData, setUserData,
} from "@/features/User/userSlice"; } from "@/features/User/userSlice";
import { render, waitFor } from "@testing-library/react"; import { render, waitFor } from "@testing-library/react";
import { push } from "redux-first-history"; import { replace } from "redux-first-history";
import { renderWithProviders } from "../../utils/Renderwithproviders"; import { renderWithProviders } from "../../utils/Renderwithproviders";
import { setAppLoading } from "@/app/loadingSlice";
// Mocks // Mocks
jest.mock("@/app/hooks", () => ({ jest.mock("@/app/hooks", () => ({
useAppDispatch: jest.fn(), useAppDispatch: jest.fn(),
useAppSelector: jest.fn(() => ({})), useAppSelector: jest.fn(() => ({
user: {
userData: null,
tokens: null,
loading: false,
error: null,
},
calendars: {
list: {},
pending: false,
error: null,
},
})),
})); }));
jest.mock("@/features/User/oidcAuth", () => ({ jest.mock("@/features/User/oidcAuth", () => ({
@@ -55,10 +68,33 @@ jest.mock("@/features/Calendars/services/getCalendarsListAsync", () => {
describe("CallbackResume", () => { describe("CallbackResume", () => {
const dispatch = jest.fn(); const dispatch = jest.fn();
let mockUserState: any;
let mockCalendarsState: any;
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
(useAppDispatch as unknown as jest.Mock).mockReturnValue(dispatch); (useAppDispatch as unknown as jest.Mock).mockReturnValue(dispatch);
// Initialize mock states
mockUserState = {
userData: null,
tokens: null,
loading: false,
error: null,
};
mockCalendarsState = {
list: {},
pending: false,
error: null,
};
(useAppSelector as jest.Mock).mockImplementation((selector) => {
const state = {
user: mockUserState,
calendars: mockCalendarsState,
};
return selector(state);
});
}); });
it("should call Callback and dispatch necessary actions", async () => { it("should call Callback and dispatch necessary actions", async () => {
@@ -77,11 +113,14 @@ describe("CallbackResume", () => {
JSON.stringify({ code_verifier: "verifier123", state: "state456" }) JSON.stringify({ code_verifier: "verifier123", state: "state456" })
); );
render(<CallbackResume />); const { rerender } = render(<CallbackResume />);
await waitFor(() => { await waitFor(() => {
expect(oidcAuth.Callback).toHaveBeenCalledWith("verifier123", "state456"); expect(oidcAuth.Callback).toHaveBeenCalledWith("verifier123", "state456");
}); });
await waitFor(() => {
expect(dispatch).toHaveBeenCalledWith(setAppLoading(true));
});
await waitFor(() => { await waitFor(() => {
expect(dispatch).toHaveBeenCalledWith(setUserData(mockUserInfo)); expect(dispatch).toHaveBeenCalledWith(setUserData(mockUserInfo));
}); });
@@ -94,9 +133,35 @@ describe("CallbackResume", () => {
await waitFor(() => { await waitFor(() => {
expect(dispatch).toHaveBeenCalledWith(getCalendarsListAsync()); expect(dispatch).toHaveBeenCalledWith(getCalendarsListAsync());
}); });
await waitFor(() => {
expect(dispatch).toHaveBeenCalledWith(push("/")); // Simulate async actions completing by updating mock state
}); mockUserState = {
userData: mockUserInfo,
tokens: mockTokenSet,
loading: false,
error: null,
};
mockCalendarsState = {
list: { calendar1: {} },
pending: false,
error: null,
};
// Re-render to trigger navigation effect
rerender(<CallbackResume />);
await waitFor(
() => {
expect(dispatch).toHaveBeenCalledWith(setAppLoading(false));
},
{ timeout: 3000 }
);
await waitFor(
() => {
expect(dispatch).toHaveBeenCalledWith(replace("/calendar"));
},
{ timeout: 3000 }
);
await waitFor(() => { await waitFor(() => {
expect(sessionStorage.getItem("redirectState")).toBe(null); expect(sessionStorage.getItem("redirectState")).toBe(null);
}); });
@@ -112,7 +177,7 @@ describe("CallbackResume", () => {
renderWithProviders(<CallbackResume />); renderWithProviders(<CallbackResume />);
await waitFor(() => { await waitFor(() => {
expect(dispatch).toHaveBeenCalledWith(push("/")); expect(dispatch).toHaveBeenCalledWith(replace("/"));
}); });
}); });
}); });
+2
View File
@@ -42,6 +42,7 @@ const isValidLanguage = (
function App() { function App() {
const error = useAppSelector((state) => state.user.error); const error = useAppSelector((state) => state.user.error);
const appLoading = useAppSelector((state) => state.loading.isLoading);
const userLanguage = useAppSelector( const userLanguage = useAppSelector(
(state) => state.user.coreConfig.language (state) => state.user.coreConfig.language
); );
@@ -80,6 +81,7 @@ function App() {
</Router> </Router>
<ErrorSnackbar error={error} type="user" /> <ErrorSnackbar error={error} type="user" />
</Suspense> </Suspense>
{appLoading && <Loading />}
</I18n> </I18n>
</TwakeMuiThemeProvider> </TwakeMuiThemeProvider>
); );
+30
View File
@@ -0,0 +1,30 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
interface LoadingState {
isLoading: boolean;
message?: string;
}
const initialState: LoadingState = {
isLoading: false,
message: undefined,
};
const loadingSlice = createSlice({
name: "loading",
initialState,
reducers: {
setAppLoading: (state, action: PayloadAction<boolean>) => {
state.isLoading = action.payload;
if (!action.payload) {
state.message = undefined;
}
},
setLoadingMessage: (state, action: PayloadAction<string | undefined>) => {
state.message = action.payload;
},
},
});
export const { setAppLoading, setLoadingMessage } = loadingSlice.actions;
export default loadingSlice.reducer;
+2
View File
@@ -3,6 +3,7 @@ import searchResultReducer from "@/features/Search/SearchSlice";
import settingsReducer from "@/features/Settings/SettingsSlice"; import settingsReducer from "@/features/Settings/SettingsSlice";
import userReducer from "@/features/User/userSlice"; import userReducer from "@/features/User/userSlice";
import { combineReducers, configureStore } from "@reduxjs/toolkit"; import { combineReducers, configureStore } from "@reduxjs/toolkit";
import loadingReducer from "./loadingSlice";
import { createBrowserHistory } from "history"; import { createBrowserHistory } from "history";
import { createReduxHistoryContext } from "redux-first-history"; import { createReduxHistoryContext } from "redux-first-history";
@@ -15,6 +16,7 @@ const rootReducer = combineReducers({
calendars: eventsCalendar, calendars: eventsCalendar,
settings: settingsReducer, settings: settingsReducer,
searchResult: searchResultReducer, searchResult: searchResultReducer,
loading: loadingReducer,
}); });
export const setupStore = (preloadedState?: Partial<RootState>) => { export const setupStore = (preloadedState?: Partial<RootState>) => {
-7
View File
@@ -26,7 +26,6 @@ import timeGridPlugin from "@fullcalendar/timegrid";
import { Box, Button, radius, useTheme } from "@linagora/twake-mui"; import { Box, Button, radius, useTheme } from "@linagora/twake-mui";
import AddIcon from "@mui/icons-material/Add"; import AddIcon from "@mui/icons-material/Add";
import { MutableRefObject, useEffect, useMemo, useRef, useState } from "react"; import { MutableRefObject, useEffect, useMemo, useRef, useState } from "react";
import { push } from "redux-first-history";
import { useI18n } from "twake-i18n"; import { useI18n } from "twake-i18n";
import { User } from "../Attendees/PeopleSearch"; import { User } from "../Attendees/PeopleSearch";
import { EventErrorSnackbar } from "../Error/ErrorSnackbar"; import { EventErrorSnackbar } from "../Error/ErrorSnackbar";
@@ -67,16 +66,10 @@ export default function CalendarApp({
}: CalendarAppProps) { }: CalendarAppProps) {
const [selectedDate, setSelectedDate] = useState(new Date()); const [selectedDate, setSelectedDate] = useState(new Date());
const [selectedMiniDate, setSelectedMiniDate] = useState(new Date()); const [selectedMiniDate, setSelectedMiniDate] = useState(new Date());
const tokens = useAppSelector((state) => state.user.tokens);
const userId = const userId =
useAppSelector((state) => state.user.userData?.openpaasId) ?? ""; useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const theme = useTheme(); const theme = useTheme();
useEffect(() => {
if (!tokens || !userId) {
dispatch(push("/"));
}
}, [dispatch, tokens, userId]);
const view = useAppSelector((state) => state.settings.view); const view = useAppSelector((state) => state.settings.view);
const userData = useAppSelector((state) => state.user.userData); const userData = useAppSelector((state) => state.user.userData);
const hideDeclinedEvents = useAppSelector( const hideDeclinedEvents = useAppSelector(
+25 -19
View File
@@ -1,26 +1,26 @@
import { useAppDispatch, useAppSelector } from "@/app/hooks"; import { useAppDispatch, useAppSelector } from "@/app/hooks";
import { Loading } from "@/components/Loading/Loading";
import { redirectTo } from "@/utils/apiUtils"; import { redirectTo } from "@/utils/apiUtils";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { push } from "redux-first-history"; import { push } from "redux-first-history";
import { getCalendarsListAsync } from "../Calendars/services"; import { getCalendarsListAsync } from "../Calendars/services";
import { Auth } from "./oidcAuth"; import { Auth } from "./oidcAuth";
import { getOpenPaasUserDataAsync, setTokens, setUserData } from "./userSlice"; import { getOpenPaasUserDataAsync, setTokens, setUserData } from "./userSlice";
import { setAppLoading } from "@/app/loadingSlice";
export function HandleLogin() { export function HandleLogin() {
const userData = useAppSelector((state) => state.user); const userData = useAppSelector((state) => state.user);
const calendars = useAppSelector((state) => state.calendars); const calendars = useAppSelector((state) => state.calendars);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const hasInitiatedRef = useRef(false); const hasInitiatedRef = useRef(false);
const calendarsLoadingRef = useRef(false); const hasNavigatedRef = useRef(false);
// Initiate login or load saved data
useEffect(() => { useEffect(() => {
if (hasInitiatedRef.current) return; if (hasInitiatedRef.current) return;
if (userData.userData) return; if (userData.userData && !calendars.pending) return;
if (Object.keys(calendars.list).length > 0) return;
if (calendarsLoadingRef.current) return;
hasInitiatedRef.current = true; hasInitiatedRef.current = true;
const initiateLogin = async () => { const initiateLogin = async () => {
const savedToken = sessionStorage.getItem("tokenSet") const savedToken = sessionStorage.getItem("tokenSet")
? JSON.parse(sessionStorage.getItem("tokenSet")!) ? JSON.parse(sessionStorage.getItem("tokenSet")!)
@@ -30,13 +30,11 @@ export function HandleLogin() {
: null; : null;
if (savedToken && savedUser) { if (savedToken && savedUser) {
dispatch(setAppLoading(true));
dispatch(setTokens(savedToken)); dispatch(setTokens(savedToken));
dispatch(setUserData(savedUser)); dispatch(setUserData(savedUser));
await dispatch(getOpenPaasUserDataAsync()); await dispatch(getOpenPaasUserDataAsync());
calendarsLoadingRef.current = true;
await dispatch(getCalendarsListAsync()); await dispatch(getCalendarsListAsync());
calendarsLoadingRef.current = false;
dispatch(push("/calendar"));
return; return;
} }
@@ -55,26 +53,34 @@ export function HandleLogin() {
initiateLogin(); initiateLogin();
}, [userData.userData, calendars.list, dispatch]); }, [userData.userData, calendars.list, dispatch]);
// Navigate to /calendar only when all data is ready
useEffect(() => { useEffect(() => {
if (userData.error) { if (hasNavigatedRef.current) return;
if (userData.loading || calendars.pending) return;
if (userData.error || calendars.error) {
dispatch(setAppLoading(false));
dispatch(push("/error")); dispatch(push("/error"));
return;
} }
if ( if (!userData.userData || !userData.tokens) return;
!calendars.pending && // Calendars list can be empty, that's valid - just need to finish loading
!userData.loading &&
!userData.error && // All data is ready, navigate to calendar
!calendars.error hasNavigatedRef.current = true;
) { dispatch(setAppLoading(false));
dispatch(push("/calendar")); dispatch(push("/calendar"));
}
}, [ }, [
calendars.pending,
userData.loading, userData.loading,
userData.userData,
userData.tokens,
userData.error, userData.error,
calendars.pending,
calendars.error, calendars.error,
dispatch, dispatch,
]); ]);
return <Loading />;
return null;
} }
export default HandleLogin; export default HandleLogin;
+85 -30
View File
@@ -1,7 +1,6 @@
import { useAppDispatch } from "@/app/hooks"; import { useAppDispatch, useAppSelector } from "@/app/hooks";
import { Loading } from "@/components/Loading/Loading";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { push } from "redux-first-history"; import { replace } from "redux-first-history";
import { getCalendarsListAsync } from "../Calendars/services"; import { getCalendarsListAsync } from "../Calendars/services";
import { Callback } from "./oidcAuth"; import { Callback } from "./oidcAuth";
import { import {
@@ -10,57 +9,113 @@ import {
setUserData, setUserData,
setUserError, setUserError,
} from "./userSlice"; } from "./userSlice";
import { setAppLoading } from "@/app/loadingSlice";
export function CallbackResume() { export function CallbackResume() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const hasRun = useRef(false); const hasRun = useRef(false);
const calendarsLoadingRef = useRef(false); const hasNavigated = useRef(false);
const saved = sessionStorage.getItem("redirectState") const userData = useAppSelector((state) => state.user);
? JSON.parse(sessionStorage.getItem("redirectState")!) const calendars = useAppSelector((state) => state.calendars);
: null;
// Process callback and load data
useEffect(() => { useEffect(() => {
if (hasRun.current) { if (hasRun.current) {
return; return;
} }
hasRun.current = true; hasRun.current = true;
const runCallback = async () => { const runCallback = async () => {
// Read redirectState inside useEffect to avoid stale closures
const saved = sessionStorage.getItem("redirectState")
? JSON.parse(sessionStorage.getItem("redirectState")!)
: null;
// Check if we have saved tokens (already logged in)
const savedToken = sessionStorage.getItem("tokenSet")
? JSON.parse(sessionStorage.getItem("tokenSet")!)
: null;
// If no redirectState but we have saved session, just go home
// This can happen if user refreshes callback page or gets redirected here after already logged in
if (!saved?.code_verifier) {
if (savedToken) {
sessionStorage.removeItem("redirectState");
dispatch(replace("/"));
return;
}
console.warn("Missing redirectState");
sessionStorage.removeItem("redirectState");
dispatch(replace("/"));
return;
}
try { try {
const data = await Callback(saved?.code_verifier, saved?.state); dispatch(setAppLoading(true));
const data = await Callback(saved.code_verifier, saved.state);
if (!data || !data.userinfo || !data.tokenSet) { if (!data || !data.userinfo || !data.tokenSet) {
throw new Error("OAuth callback failed"); throw new Error("OAuth callback failed");
} }
dispatch(setUserData(data?.userinfo));
dispatch(setTokens(data?.tokenSet)); // IMPORTANT: Save tokens to sessionStorage FIRST before making any API calls
// because API calls will read token from sessionStorage
sessionStorage.setItem("tokenSet", JSON.stringify(data.tokenSet));
sessionStorage.setItem("userData", JSON.stringify(data.userinfo));
dispatch(setUserData(data.userinfo));
dispatch(setTokens(data.tokenSet));
await dispatch(getOpenPaasUserDataAsync()); await dispatch(getOpenPaasUserDataAsync());
if (!calendarsLoadingRef.current) { await dispatch(getCalendarsListAsync());
calendarsLoadingRef.current = true;
await dispatch(getCalendarsListAsync());
calendarsLoadingRef.current = false;
}
sessionStorage.removeItem("redirectState"); sessionStorage.removeItem("redirectState");
sessionStorage.setItem("tokenSet", JSON.stringify(data?.tokenSet));
sessionStorage.setItem("userData", JSON.stringify(data?.userinfo));
// Redirect to main page after successful callback
dispatch(push("/"));
} catch (e) { } catch (e) {
console.error("OIDC callback error:", e); console.error("OIDC callback error:", e);
// Redirect to error page after error dispatch(setAppLoading(false));
dispatch( dispatch(
setUserError(e instanceof Error ? e.message : "OAuth callback failed") setUserError(e instanceof Error ? e.message : "OAuth callback failed")
); );
dispatch(push("/error")); dispatch(replace("/error"));
} }
}; };
if (saved?.code_verifier) { runCallback();
runCallback(); }, [dispatch]);
} else {
console.warn("Missing redirectState");
sessionStorage.removeItem("redirectState");
dispatch(push("/"));
}
}, [dispatch, saved]);
return <Loading />; // Navigate to /calendar only when all data is ready
useEffect(() => {
if (hasNavigated.current) return;
if (userData.loading || calendars.pending) return;
if (userData.error || calendars.error) {
dispatch(setAppLoading(false));
dispatch(replace("/error"));
return;
}
if (!userData.userData || !userData.tokens) return;
// Calendars list can be empty, that's valid - just need to finish loading
// All data is ready, navigate to calendar
hasNavigated.current = true;
dispatch(setAppLoading(false));
// Clear any query params from URL first, then navigate
if (window.location.search) {
window.history.replaceState({}, "", window.location.pathname);
}
dispatch(replace("/calendar"));
}, [
userData.loading,
userData.userData,
userData.tokens,
userData.error,
calendars.pending,
calendars.error,
dispatch,
]);
return null;
} }
+14 -1
View File
@@ -50,7 +50,19 @@ export const api = ky.extend({
afterResponse: [ afterResponse: [
async (request, options, response) => { async (request, options, response) => {
if (response.status === 401) { if (response.status === 401) {
// Attempt token refresh on unauthorized response // Check if we're already on login flow to prevent redirect loop
const currentPath = window.location.pathname;
if (currentPath === "/" || currentPath === "/callback") {
return response;
}
// Check if we have a token in the request
const hasAuthHeader = request.headers.has("Authorization");
if (!hasAuthHeader) {
return response;
}
// Only redirect to SSO if we're sure token is invalid (not just missing)
const loginurl = await Auth(); const loginurl = await Auth();
sessionStorage.setItem( sessionStorage.setItem(
@@ -62,6 +74,7 @@ export const api = ky.extend({
); );
redirectTo(loginurl.redirectTo); redirectTo(loginurl.redirectTo);
} }
return response;
}, },
], ],
}, },