diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eeee73b8..6c595609c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ The types of changes are: * Add consent request api [#1387](https://github.com/ethyca/fidesops/pull/1387) * Add authenticated route to get consent preferences [#1402](https://github.com/ethyca/fidesops/pull/1402) * Access and erasure support for Braze [#1248](https://github.com/ethyca/fidesops/pull/1248) +* Admin UI: Persist Redux store to localStorage [#1401](https://github.com/ethyca/fidesops/pull/1409) ### Removed diff --git a/clients/ops/admin-ui/package-lock.json b/clients/ops/admin-ui/package-lock.json index 68b2a7af1..0acf039d0 100644 --- a/clients/ops/admin-ui/package-lock.json +++ b/clients/ops/admin-ui/package-lock.json @@ -29,6 +29,7 @@ "react-dom": "^17.0.2", "react-feature-flags": "^1.0.0", "react-redux": "^7.2.6", + "redux-persist": "^6.0.0", "whatwg-fetch": "^3.6.2", "yup": "^0.32.11" }, @@ -6078,9 +6079,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001406", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001406.tgz", - "integrity": "sha512-bWTlaXUy/rq0BBtYShc/jArYfBPjEV95euvZ8JVtO43oQExEN/WquoqpufFjNu4kSpi5cy5kMbNvzztWDfv1Jg==", + "version": "1.0.30001412", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001412.tgz", + "integrity": "sha512-+TeEIee1gS5bYOiuf+PS/kp2mrXic37Hl66VY6EAfxasIk5fELTktK2oOezYed12H8w7jt3s512PpulQidPjwA==", "funding": [ { "type": "opencollective", @@ -11112,6 +11113,14 @@ "@babel/runtime": "^7.9.2" } }, + "node_modules/redux-persist": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/redux-persist/-/redux-persist-6.0.0.tgz", + "integrity": "sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==", + "peerDependencies": { + "redux": ">4.0.0" + } + }, "node_modules/redux-thunk": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.1.tgz", @@ -17197,9 +17206,9 @@ "dev": true }, "caniuse-lite": { - "version": "1.0.30001406", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001406.tgz", - "integrity": "sha512-bWTlaXUy/rq0BBtYShc/jArYfBPjEV95euvZ8JVtO43oQExEN/WquoqpufFjNu4kSpi5cy5kMbNvzztWDfv1Jg==" + "version": "1.0.30001412", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001412.tgz", + "integrity": "sha512-+TeEIee1gS5bYOiuf+PS/kp2mrXic37Hl66VY6EAfxasIk5fELTktK2oOezYed12H8w7jt3s512PpulQidPjwA==" }, "chalk": { "version": "4.1.2", @@ -20816,6 +20825,12 @@ "@babel/runtime": "^7.9.2" } }, + "redux-persist": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/redux-persist/-/redux-persist-6.0.0.tgz", + "integrity": "sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==", + "requires": {} + }, "redux-thunk": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.1.tgz", diff --git a/clients/ops/admin-ui/package.json b/clients/ops/admin-ui/package.json index 6dbff223f..0bc9bf0f4 100644 --- a/clients/ops/admin-ui/package.json +++ b/clients/ops/admin-ui/package.json @@ -43,6 +43,7 @@ "react-dom": "^17.0.2", "react-feature-flags": "^1.0.0", "react-redux": "^7.2.6", + "redux-persist": "^6.0.0", "whatwg-fetch": "^3.6.2", "yup": "^0.32.11" }, diff --git a/clients/ops/admin-ui/src/app/store.ts b/clients/ops/admin-ui/src/app/store.ts index 5a28cd747..181e704ec 100644 --- a/clients/ops/admin-ui/src/app/store.ts +++ b/clients/ops/admin-ui/src/app/store.ts @@ -1,13 +1,24 @@ -import { configureStore, StateFromReducersMapObject } from "@reduxjs/toolkit"; +import { + AnyAction, + combineReducers, + configureStore, + StateFromReducersMapObject, +} from "@reduxjs/toolkit"; import { setupListeners } from "@reduxjs/toolkit/query/react"; - -import { STORED_CREDENTIALS_KEY } from "../constants"; import { - authApi, - AuthState, - credentialStorage, - reducer as authReducer, -} from "../features/auth"; + FLUSH, + PAUSE, + PERSIST, + persistReducer, + persistStore, + PURGE, + REGISTER, + REHYDRATE, +} from "redux-persist"; +import createWebStorage from "redux-persist/lib/storage/createWebStorage"; + +import { STORAGE_ROOT_KEY } from "../constants"; +import { authApi, AuthState, reducer as authReducer } from "../features/auth"; import { connectionTypeApi, reducer as connectionTypeReducer, @@ -25,27 +36,83 @@ import { userApi, } from "../features/user-management"; -const reducer = { - [privacyRequestApi.reducerPath]: privacyRequestApi.reducer, - subjectRequests: privacyRequestsReducer, - [userApi.reducerPath]: userApi.reducer, +/** + * To prevent the "redux-perist failed to create sync storage. falling back to noop storage" + * console message within Next.js, the following snippet is required. + * {@https://mightycoders.xyz/redux-persist-failed-to-create-sync-storage-falling-back-to-noop-storage} + */ +const createNoopStorage = () => ({ + getItem() { + return Promise.resolve(null); + }, + setItem(_key: any, value: any) { + return Promise.resolve(value); + }, + removeItem() { + return Promise.resolve(); + }, +}); + +const storage = + typeof window !== "undefined" + ? createWebStorage("local") + : createNoopStorage(); + +const reducerMap = { [authApi.reducerPath]: authApi.reducer, - userManagement: userManagementReducer, - [datastoreConnectionApi.reducerPath]: datastoreConnectionApi.reducer, - datastoreConnections: datastoreConnectionReducer, auth: authReducer, [connectionTypeApi.reducerPath]: connectionTypeApi.reducer, connectionType: connectionTypeReducer, + [datastoreConnectionApi.reducerPath]: datastoreConnectionApi.reducer, + datastoreConnections: datastoreConnectionReducer, + [privacyRequestApi.reducerPath]: privacyRequestApi.reducer, + subjectRequests: privacyRequestsReducer, + [userApi.reducerPath]: userApi.reducer, + userManagement: userManagementReducer, }; -export type RootState = StateFromReducersMapObject; +const allReducers = combineReducers(reducerMap); + +const rootReducer = (state: any, action: AnyAction) => { + let newState = { ...state }; + if (action.type === "auth/logout") { + storage.removeItem(STORAGE_ROOT_KEY); + newState = undefined; + } + return allReducers(newState, action); +}; + +const persistConfig = { + key: "root", + storage, + /* + NOTE: It is also strongly recommended to blacklist any api(s) that you have configured with RTK Query. + If the api slice reducer is not blacklisted, the api cache will be automatically persisted + and restored which could leave you with phantom subscriptions from components that do not exist any more. + (https://redux-toolkit.js.org/usage/usage-guide#use-with-redux-persist) + */ + blacklist: [ + authApi.reducerPath, + connectionTypeApi.reducerPath, + datastoreConnectionApi.reducerPath, + privacyRequestApi.reducerPath, + userApi.reducerPath, + ], +}; + +const persistedReducer = persistReducer(persistConfig, rootReducer); + +export type RootState = StateFromReducersMapObject; export const makeStore = (preloadedState?: Partial) => configureStore({ - reducer, + reducer: persistedReducer, middleware: (getDefaultMiddleware) => - getDefaultMiddleware().concat( - credentialStorage.middleware, + getDefaultMiddleware({ + serializableCheck: { + ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER], + }, + }).concat( privacyRequestApi.middleware, userApi.middleware, authApi.middleware, @@ -58,7 +125,7 @@ export const makeStore = (preloadedState?: Partial) => let storedAuthState: AuthState | undefined; if (typeof window !== "undefined" && "localStorage" in window) { - const storedAuthStateString = localStorage.getItem(STORED_CREDENTIALS_KEY); + const storedAuthStateString = localStorage.getItem(STORAGE_ROOT_KEY); if (storedAuthStateString) { try { storedAuthState = JSON.parse(storedAuthStateString); @@ -74,6 +141,8 @@ const store = makeStore({ auth: storedAuthState, }); +export const persistor = persistStore(store); + setupListeners(store.dispatch); export default store; diff --git a/clients/ops/admin-ui/src/constants.ts b/clients/ops/admin-ui/src/constants.ts index 1c2104598..baff53885 100644 --- a/clients/ops/admin-ui/src/constants.ts +++ b/clients/ops/admin-ui/src/constants.ts @@ -6,7 +6,10 @@ const API_URL = process.env.NEXT_PUBLIC_FIDESOPS_API : ""; export const BASE_URL = API_URL + BASE_API_URN; -export const STORED_CREDENTIALS_KEY = "auth.fidesops-admin-ui"; +/** + * Redux-persist storage root key + */ +export const STORAGE_ROOT_KEY = "persist:root"; export const USER_PRIVILEGES: UserPrivileges[] = [ { diff --git a/clients/ops/admin-ui/src/features/auth/__tests__/auth.slice.test.ts b/clients/ops/admin-ui/src/features/auth/__tests__/auth.slice.test.ts deleted file mode 100644 index f90d25eac..000000000 --- a/clients/ops/admin-ui/src/features/auth/__tests__/auth.slice.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { makeStore } from "../../../app/store"; -import { STORED_CREDENTIALS_KEY } from "../../../constants"; -import { login, logout } from "../auth.slice"; - -describe("Auth", () => { - it("should persist auth state to localStorage on login", () => { - jest.spyOn(Object.getPrototypeOf(window.localStorage), "setItem"); - const store = makeStore(); - - store.dispatch( - login({ - user_data: { - username: "Test", - }, - token_data: { - access_token: "test-access-token", - }, - }) - ); - - expect(window.localStorage.setItem).toHaveBeenCalledWith( - STORED_CREDENTIALS_KEY, - JSON.stringify({ - token: "test-access-token", - user: { - username: "Test", - }, - }) - ); - }); - - it("should remove auth state from localStorage on logout", () => { - jest.spyOn(Object.getPrototypeOf(window.localStorage), "removeItem"); - const store = makeStore(); - - store.dispatch(logout()); - - expect(window.localStorage.removeItem).toHaveBeenCalledWith( - STORED_CREDENTIALS_KEY - ); - }); -}); diff --git a/clients/ops/admin-ui/src/features/auth/auth.slice.ts b/clients/ops/admin-ui/src/features/auth/auth.slice.ts index 06f968998..aba06d253 100644 --- a/clients/ops/admin-ui/src/features/auth/auth.slice.ts +++ b/clients/ops/admin-ui/src/features/auth/auth.slice.ts @@ -1,12 +1,8 @@ -import { - createListenerMiddleware, - createSlice, - PayloadAction, -} from "@reduxjs/toolkit"; +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; import type { RootState } from "../../app/store"; -import { BASE_URL, STORED_CREDENTIALS_KEY } from "../../constants"; +import { BASE_URL } from "../../constants"; import { addCommonHeaders } from "../common/CommonHeaders"; import { utf8ToB64 } from "../common/utils"; import { User } from "../user-management/types"; @@ -56,27 +52,6 @@ export const selectToken = (state: RootState) => selectAuth(state).token; export const { login, logout } = authSlice.actions; -export const credentialStorage = createListenerMiddleware(); -credentialStorage.startListening({ - actionCreator: login, - effect: (action, { getState }) => { - if (window && window.localStorage) { - localStorage.setItem( - STORED_CREDENTIALS_KEY, - JSON.stringify(selectAuth(getState() as RootState)) - ); - } - }, -}); -credentialStorage.startListening({ - actionCreator: logout, - effect: () => { - if (window && window.localStorage) { - localStorage.removeItem(STORED_CREDENTIALS_KEY); - } - }, -}); - // Auth API export const authApi = createApi({ reducerPath: "authApi", diff --git a/clients/ops/admin-ui/src/features/datastore-connections/add-connection/AddConnection.tsx b/clients/ops/admin-ui/src/features/datastore-connections/add-connection/AddConnection.tsx index ee28a34b7..577d092d7 100644 --- a/clients/ops/admin-ui/src/features/datastore-connections/add-connection/AddConnection.tsx +++ b/clients/ops/admin-ui/src/features/datastore-connections/add-connection/AddConnection.tsx @@ -18,35 +18,11 @@ import { AddConnectionStep } from "./types"; const AddConnection: React.FC = () => { const dispatch = useDispatch(); const router = useRouter(); - const { connectorType, key, step: currentStep } = router.query; + const { connectorType, step: currentStep } = router.query; const { connectionOption, step } = useAppSelector(selectConnectionTypeState); - /** - * NOTE: If the user reloads the web page via F5, the react redux store state is lost. - * By default its persisted in internal memory. As a result, a runtime exception occurs - * which impedes the page rendering. - * - * @example - * The above error occurred in the component - * - * For now, a temporary solution is to redirect the user - * to the "Choose your connection" step. This allows a better overall user experience. - * A permanent solution will be to persist the react redux store state to either local storage - * or session storage. Once completed, this method can be deleted. - */ - const reload = useCallback(() => { - if ( - key && - currentStep && - (currentStep as unknown as number) !== step?.stepId - ) { - window.location.href = STEPS[1].href; - } - }, [currentStep, key, step?.stepId]); - useEffect(() => { - reload(); if (connectorType) { dispatch(setConnectionOption(JSON.parse(connectorType as string))); } @@ -55,19 +31,7 @@ const AddConnection: React.FC = () => { dispatch(setStep(item || STEPS[1])); } return () => {}; - }, [connectorType, currentStep, dispatch, reload, router.query.step]); - - const getComponent = useCallback(() => { - switch (step.stepId) { - case 1: - return ; - case 2: - case 3: - return ; - default: - return ; - } - }, [step.stepId]); + }, [connectorType, currentStep, dispatch, router.query.step]); const getLabel = useCallback( (s: AddConnectionStep): string => { @@ -108,7 +72,17 @@ const AddConnection: React.FC = () => { {!connectionOption && {getLabel(step)}} - {getComponent()} + {(() => { + switch (step.stepId) { + case 1: + return ; + case 2: + case 3: + return ; + default: + return ; + } + })()} ); }; diff --git a/clients/ops/admin-ui/src/pages/_app.tsx b/clients/ops/admin-ui/src/pages/_app.tsx index 188390a36..4fe31232f 100644 --- a/clients/ops/admin-ui/src/pages/_app.tsx +++ b/clients/ops/admin-ui/src/pages/_app.tsx @@ -9,8 +9,9 @@ import React from "react"; // @ts-ignore import { FlagsProvider } from "react-feature-flags"; import { Provider } from "react-redux"; +import { PersistGate } from "redux-persist/integration/react"; -import store from "../app/store"; +import store, { persistor } from "../app/store"; import flags from "../flags.json"; import theme from "../theme"; @@ -29,9 +30,11 @@ const MyApp = ({ Component, pageProps }: AppProps) => ( - - - + + + + +