Skip to content

Commit

Permalink
feat(arcgis): auth slice
Browse files Browse the repository at this point in the history
  • Loading branch information
mspivak-actionengine committed Dec 11, 2023
1 parent 2ac8d60 commit 74be2e1
Show file tree
Hide file tree
Showing 11 changed files with 239 additions and 11 deletions.
2 changes: 1 addition & 1 deletion .env
Original file line number Diff line number Diff line change
@@ -1 +1 @@
REACT_APP_ARCGIS_REST_CLIENT_ID=abcdef
REACT_APP_ARCGIS_REST_CLIENT_ID=abcd
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
"author": "",
"license": "ISC",
"dependencies": {
"@esri/arcgis-rest-auth": "^3.7.0",
"@esri/arcgis-rest-request": "^4.2.0",
"@fortawesome/fontawesome-svg-core": "^1.2.36",
"@fortawesome/free-solid-svg-icons": "^5.15.4",
"@fortawesome/react-fontawesome": "^0.1.17",
Expand Down
1 change: 1 addition & 0 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ export const App = () => {
<Route path={"dashboard"} element={<Pages.Dashboard />} />
<Route path={"viewer"} element={<Pages.ViewerApp />} />
<Route path={"debug"} element={<Pages.DebugApp />} />
<Route path={"auth"} element={<Pages.AuthApp />} />
<Route
path={"compare-across-layers"}
element={
Expand Down
21 changes: 13 additions & 8 deletions src/components/layers-panel/layers-control-panel.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { act, screen } from "@testing-library/react";
import { renderWithTheme } from "../../utils/testing-utils/render-with-theme";
import { renderWithThemeProviders } from "../../utils/testing-utils/render-with-theme";
import { LayersControlPanel } from "./layers-control-panel";

// Mocked components
import { PlusButton } from "../plus-button/plus-button";
import { DeleteConfirmation } from "./delete-confirmation";
import { LayerOptionsMenu } from "./layer-options-menu/layer-options-menu";
import { ListItem } from "./list-item/list-item";
import { setupStore } from "../../redux/store";

jest.mock("./list-item/list-item");
jest.mock("../plus-button/plus-button");
Expand Down Expand Up @@ -39,7 +40,7 @@ const onSelectLayerMock = jest.fn();
const onLayerSettingsClickMock = jest.fn();
const onPointToLayerMock = jest.fn();

const callRender = (renderFunc, props = {}) => {
const callRender = (renderFunc, props = {}, store = setupStore()) => {
return renderFunc(
<LayersControlPanel
onSceneInsertClick={onInsertSceneMock}
Expand All @@ -53,13 +54,15 @@ const callRender = (renderFunc, props = {}) => {
onPointToLayer={onPointToLayerMock}
deleteLayer={onDeleteLayerMock}
{...props}
/>
/>,
store
);
};

describe("Layers Control Panel", () => {
it("Should render LayersControlPanel without layers", () => {
const { container } = callRender(renderWithTheme);
const store = setupStore();
const { container } = callRender(renderWithThemeProviders, undefined, store);
expect(container).toBeInTheDocument();

// Insert Buttons should be present
Expand All @@ -68,7 +71,8 @@ describe("Layers Control Panel", () => {
});

it("Should render LayersControlPanel with layers", () => {
const { container } = callRender(renderWithTheme, {
const store = setupStore();
const { container } = callRender(renderWithThemeProviders, {
layers: [
{ id: "first", name: "first name", url: "https://first-url.com" },
{ id: "second", name: "second name", url: "https://second-url.com" },
Expand Down Expand Up @@ -98,7 +102,8 @@ describe("Layers Control Panel", () => {
],
},
],
});
},
store);
expect(container).toBeInTheDocument();

expect(screen.getByText("ListItem-first")).toBeInTheDocument();
Expand All @@ -111,7 +116,7 @@ describe("Layers Control Panel", () => {
});

it("Should be able to call functions", () => {
const { container } = callRender(renderWithTheme, {
const { container } = callRender(renderWithThemeProviders, {
layers: [
{ id: "first", name: "first name", mapUrl: "https://first-url.com" },
],
Expand Down Expand Up @@ -139,7 +144,7 @@ describe("Layers Control Panel", () => {
});

it("Should render conformation panel", () => {
callRender(renderWithTheme, {
callRender(renderWithThemeProviders, {
layers: [
{ id: "first", name: "first name", mapUrl: "https://first-url.com" },
// Candidate to delete
Expand Down
43 changes: 41 additions & 2 deletions src/components/layers-panel/layers-control-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Fragment, ReactEventHandler, useState } from "react";
import { Fragment, ReactEventHandler, useState, useEffect } from "react";
import styled from "styled-components";

import { SelectionState, LayerExample, LayerViewState, ListItemType } from "../../types";
Expand All @@ -11,6 +11,10 @@ import { LayerOptionsMenu } from "./layer-options-menu/layer-options-menu";
import { handleSelectAllLeafsInGroup } from "../../utils/layer-utils";
import { ButtonSize } from "../../types";


import { arcGisLogin, arcGisLogout, selectUser } from "../../redux/slices/arcgis-auth-slice";
import { useAppDispatch, useAppSelector } from "../../redux/hooks";

type LayersControlPanelProps = {
layers: LayerExample[];
selectedLayerIds: string[];
Expand Down Expand Up @@ -69,6 +73,28 @@ export const LayersControlPanel = ({
onPointToLayer,
deleteLayer,
}: LayersControlPanelProps) => {

// stub {
const dispatch = useAppDispatch();

const handleArcGisLogin = () => {
dispatch(arcGisLogin());
};

const handleArcGisLogout = () => {
dispatch(arcGisLogout());
};

const username = useAppSelector(selectUser);
const [showLogin, setShowLoginButton] = useState<boolean>(!username);
const [showLogout, setShowLogoutButton] = useState<boolean>(!!username);

useEffect(() => {
setShowLoginButton(!username);
setShowLogoutButton(!!username);
}, [username]);
// stub }

const [settingsLayerId, setSettingsLayerId] = useState<string>("");
const [showLayerSettings, setShowLayerSettings] = useState<boolean>(false);
const [layerToDeleteId, setLayerToDeleteId] = useState<string>("");
Expand Down Expand Up @@ -184,7 +210,20 @@ export const LayersControlPanel = ({
<PlusButton buttonSize={ButtonSize.Small} onClick={onSceneInsertClick}>
Insert scene
</PlusButton>
</InsertButtons>
</InsertButtons>

{ showLogin && (
<PlusButton buttonSize={ButtonSize.Small} onClick={handleArcGisLogin}>
Login to ArcGIS
</PlusButton>
) }
{ showLogout && (
<PlusButton buttonSize={ButtonSize.Small} onClick={handleArcGisLogout}>
{username} Logout
</PlusButton>
) }


</LayersContainer>
);
};
58 changes: 58 additions & 0 deletions src/pages/arcgis-auth-popup/arcgis-auth-popup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import Background from "../../../public/images/tools-background.webp";
import styled from "styled-components";
import {
getCurrentLayoutProperty,
useAppLayout,
} from "../../utils/hooks/layout";

import { ArcGISIdentityManager } from '@esri/arcgis-rest-request';
import { getAuthOptions } from "../../utils/arcgis-auth";

export type LayoutProps = {
layout: string;
};

const AuthContainer = styled.div<LayoutProps>`
display: flex;
flex-direction: column;
width: 100%;
overflow: auto;
overflow-x: hidden;
background: url(${Background});
background-attachment: fixed;
background-size: cover;
height: ${getCurrentLayoutProperty({
desktop: "calc(100vh - 65px)",
tablet: "calc(100vh - 65px)",
mobile: "calc(100vh - 58px)",
})};
margin-top: ${getCurrentLayoutProperty({
desktop: "65px",
tablet: "65px",
mobile: "58px",
})};
`;

export const AuthApp = () => {
const layout = useAppLayout();

const { redirectUrl, clientId } = getAuthOptions();
if (!clientId) {
console.error("The ClientId is not defined in .env file.");
} else {
const options = {
clientId: clientId,
redirectUri: redirectUrl,
popup: true,
pkce: true
}
ArcGISIdentityManager.completeOAuth2(options);
}
return (
<AuthContainer id="dashboard-container" layout={layout}>
</AuthContainer>
);

}
1 change: 1 addition & 0 deletions src/pages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export { Dashboard } from "./dashboard/dashboard";
export { ViewerApp } from "./viewer-app/viewer-app";
export { DebugApp } from "./debug-app/debug-app";
export { Comparison } from './comparison/comparison';
export { AuthApp } from './arcgis-auth-popup/arcgis-auth-popup';

46 changes: 46 additions & 0 deletions src/redux/slices/arcgis-auth-slice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import { RootState } from "../store";
import { getAuthenticatedUser, arcGisRequestLogin, arcGisRequestLogout } from '../../utils/arcgis-auth';

// Define a type for the slice state
export interface ArcGisAuthState {
user: string;
}

const initialState: ArcGisAuthState = {
user: getAuthenticatedUser(),
}

const arcGisAuthSlice = createSlice({
name: "arcGisAuth",
initialState,
reducers: {
},
extraReducers: (builder) => {
builder.addCase(arcGisLogin.fulfilled, (state, action) => {
state.user = action.payload || '';
})
.addCase(arcGisLogout.fulfilled, (state) => {
state.user = '';
})
},
});

export const arcGisLogin = createAsyncThunk(
'arcGisLogin',
async () => {
return await arcGisRequestLogin();
}
);

export const arcGisLogout = createAsyncThunk(
'arcGisLogout',
async () => {
return await arcGisRequestLogout();
}
);

export const selectUser = (state: RootState): string =>
state.arcGisAuth.user;

export default arcGisAuthSlice.reducer;
3 changes: 3 additions & 0 deletions src/redux/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import debugOptionsSliceReducer from "./slices/debug-options-slice";
import i3sStatsSliceReducer from "./slices/i3s-stats-slice";
import baseMapsSliceReducer from "./slices/base-maps-slice";
import symbolizationSliceReducer from "./slices/symbolization-slice";
import arcGisAuthSliceReducer from "./slices/arcgis-auth-slice";

// Create the root reducer separately so we can extract the RootState type
const rootReducer = combineReducers({
Expand All @@ -20,6 +21,8 @@ const rootReducer = combineReducers({
baseMaps: baseMapsSliceReducer,
symbolization: symbolizationSliceReducer,
i3sStats: i3sStatsSliceReducer,
arcGisAuth: arcGisAuthSliceReducer,

});

export const setupStore = (preloadedState?: PreloadedState<RootState>) => {
Expand Down
72 changes: 72 additions & 0 deletions src/utils/arcgis-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { ArcGISIdentityManager } from '@esri/arcgis-rest-request';

const ARCGIS_REST_USER_SESSION = '__ARCGIS_REST_USER_SESSION__';
const ARCGIS_REST_USER_INFO = '__ARCGIS_REST_USER_INFO__';

const updateSessionInfo = async (session?: ArcGISIdentityManager): Promise<string> => {
let email: string = '';

Check failure on line 7 in src/utils/arcgis-auth.ts

View workflow job for this annotation

GitHub Actions / Linter

Type string trivially inferred from a string literal, remove type annotation
if (session) {
localStorage.setItem(ARCGIS_REST_USER_SESSION, session.serialize());
const user = await session.getUser();
email = user.email || '';
localStorage.setItem(ARCGIS_REST_USER_INFO, email);
} else {
localStorage.removeItem(ARCGIS_REST_USER_SESSION);
localStorage.removeItem(ARCGIS_REST_USER_INFO);
}
return email;
}

/**
* Gets the redirection URL and the client ID to use in the ArcGIS authentication workflow.
* @returns the redirection URL and the client ID.
*/
export const getAuthOptions = () => {
return {
redirectUrl: `${window.location.protocol}//${window.location.hostname}:${window.location.port}/auth`,
clientId: process.env.REACT_APP_ARCGIS_REST_CLIENT_ID
}
}

/**
* Gets the email of the currently logged in user.
* @returns the user's email or an empty string if the user is not logged in.
*/
export const getAuthenticatedUser = (): string => {
return localStorage.getItem(ARCGIS_REST_USER_INFO) || '';
}

/**
*
* @returns
*/
export const arcGisRequestLogin = async () => {
const { redirectUrl, clientId } = getAuthOptions();

if (!clientId) {
console.error("The ClientId is not defined in .env file.");
return '';
}
const options = {
clientId: clientId,
redirectUri: redirectUrl,
popup: true,
pkce: true
}

let email = '';
let session: ArcGISIdentityManager | undefined;
try {
session = await ArcGISIdentityManager.beginOAuth2(options);
}
finally {
// In case of an exception the session is not set.
// So the following call will remove any session stored in the local storage.
email = await updateSessionInfo(session);
}
return email;
};

export const arcGisRequestLogout = async () => {
return await updateSessionInfo();
};
1 change: 1 addition & 0 deletions webpack.dev.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ module.exports = (env) => {
devServer: {
open: true,
port: 3000,
server: 'https',
client: {
overlay: {
errors: true,
Expand Down

0 comments on commit 74be2e1

Please sign in to comment.