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:
@@ -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();
|
||||||
|
|||||||
@@ -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("/"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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>) => {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user