Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Authenticate with nmdc-server backend #26

Merged
merged 12 commits into from
Feb 9, 2024
Merged
3 changes: 2 additions & 1 deletion .env.local.example
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
VITE_NMDC_SERVER_API_URL=http://localhost:8000/api
VITE_NMDC_SERVER_API_URL=http://127.0.0.1:8000/api
VITE_NMDC_SERVER_LOGIN_URL=http://127.0.0.1:8080/login
9 changes: 9 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"dependencies": {
"@capacitor/android": "5.6.0",
"@capacitor/app": "5.0.6",
"@capacitor/browser": "^5.2.0",
"@capacitor/core": "5.6.0",
"@capacitor/haptics": "5.0.6",
"@capacitor/ios": "5.6.0",
Expand Down
102 changes: 10 additions & 92 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,9 @@
import React, { useEffect, useState } from "react";
import { Redirect, Route } from "react-router-dom";
import { Storage } from "@ionic/storage";
import { IonApp, IonRouterOutlet, setupIonicReact } from "@ionic/react";
import { IonReactRouter } from "@ionic/react-router";
import TutorialPage from "./pages/TutorialPage/TutorialPage";
import WelcomePage from "./pages/WelcomePage/WelcomePage";
import { QueryClient } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
import {
Persister,
PersistQueryClientProvider,
} from "@tanstack/react-query-persist-client";
import React from "react";
import { setupIonicReact } from "@ionic/react";

import Home from "./pages/Home";
import { addDefaultMutationFns } from "./queries";
import QueryClientProvider from "./QueryClientProvider";
import Routes from "./Routes";
import StoreProvider from "./Store";

/* Core CSS required for Ionic components to work properly */
import "@ionic/react/css/core.css";
Expand All @@ -37,84 +26,13 @@ import "./theme/variables.css";

setupIonicReact();

export const PATHS = {
HOME_PAGE: "/home",
TUTORIAL_PAGE: "/tutorial",
WELCOME_PAGE: "/welcome",
};

const queryClient = new QueryClient({
defaultOptions: {
mutations: {
networkMode: "online",
},
queries: {
networkMode: "online",
staleTime: 1000 * 20, // 20 seconds
gcTime: 1000 * 60 * 60 * 24 * 7, // 1 week
retry: 0,
},
},
});
addDefaultMutationFns(queryClient);

const App: React.FC = () => {
const [persister, setPersister] = useState<Persister | null>(null);

useEffect(() => {
async function initPersister() {
const store = new Storage();
await store.create();
setPersister(
createAsyncStoragePersister({
storage: {
getItem: async (key) => store.get(key),
setItem: async (key, value) => store.set(key, value),
removeItem: async (key) => store.remove(key),
},
}),
);
}

if (persister == null) {
initPersister();
}
}, []);

if (persister == null) {
return null;
}

return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister: persister, maxAge: Infinity }}
onSuccess={() => {
queryClient.resumePausedMutations().then(() => {
queryClient.invalidateQueries();
});
}}
>
<IonApp>
<IonReactRouter>
<IonRouterOutlet>
<Route exact path={PATHS.WELCOME_PAGE}>
<WelcomePage />
</Route>
<Route exact path={PATHS.TUTORIAL_PAGE}>
<TutorialPage />
</Route>
<Route exact path={PATHS.HOME_PAGE}>
<Home />
</Route>
<Route exact path="/">
<Redirect to={PATHS.WELCOME_PAGE} />
</Route>
</IonRouterOutlet>
</IonReactRouter>
</IonApp>
<ReactQueryDevtools initialIsOpen={false} />
</PersistQueryClientProvider>
<StoreProvider>
<QueryClientProvider>
<Routes />
pkalita-lbl marked this conversation as resolved.
Show resolved Hide resolved
</QueryClientProvider>
</StoreProvider>
);
};

Expand Down
58 changes: 58 additions & 0 deletions src/QueryClientProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import React, { PropsWithChildren, useMemo } from "react";
import {
Persister,
PersistQueryClientProvider,
} from "@tanstack/react-query-persist-client";
import { QueryClient } from "@tanstack/react-query";
import { addDefaultMutationFns } from "./queries";
import { useStore } from "./Store";
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";

const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 20, // 20 seconds
gcTime: 1000 * 60 * 60 * 24 * 7, // 1 week
retry: 0,
},
},
});
addDefaultMutationFns(queryClient);

const QueryClientProvider: React.FC<PropsWithChildren> = ({ children }) => {
const { store } = useStore();
const persister = useMemo<Persister | null>(() => {
if (!store) {
return null;
}
return createAsyncStoragePersister({
storage: {
getItem: async (key) => store.get(key),
setItem: async (key, value) => store.set(key, value),
removeItem: async (key) => store.remove(key),
},
});
}, [store]);

if (persister == null) {
return null;
}

return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister: persister, maxAge: Infinity }}
onSuccess={() => {
queryClient.resumePausedMutations().then(() => {
queryClient.invalidateQueries();
});
}}
>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</PersistQueryClientProvider>
);
};

export default QueryClientProvider;
57 changes: 57 additions & 0 deletions src/Routes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import React from "react";
import { IonReactRouter } from "@ionic/react-router";
import { IonApp, IonRouterOutlet } from "@ionic/react";
import { Redirect, Route } from "react-router-dom";
import LoginPage from "./pages/LoginPage/LoginPage";
import TokenPage from "./pages/TokenPage/TokenPage";
import WelcomePage from "./pages/WelcomePage/WelcomePage";
import TutorialPage from "./pages/TutorialPage/TutorialPage";
import AuthRoute from "./components/AuthRoute/AuthRoute";
import Home from "./pages/Home";
import { useStore } from "./Store";
import LogoutPage from "./pages/LogoutPage/LogoutPage";

export const PATHS = {
ROOT: "/",
HOME_PAGE: "/home",
TUTORIAL_PAGE: "/tutorial",
WELCOME_PAGE: "/welcome",
LOGIN_PAGE: "/login",
TOKEN_PAGE: "/token",
LOGOUT_PAGE: "/logout",
};

const Routes: React.FC = () => {
const { apiToken } = useStore();
return (
<IonApp>
<IonReactRouter>
<IonRouterOutlet>
<Route path={PATHS.LOGIN_PAGE}>
<LoginPage />
</Route>
<Route path={PATHS.LOGOUT_PAGE}>
<LogoutPage />
</Route>
<Route path={PATHS.TOKEN_PAGE}>
<TokenPage />
</Route>
<Route exact path={PATHS.WELCOME_PAGE}>
<WelcomePage />
</Route>
<Route exact path={PATHS.TUTORIAL_PAGE}>
<TutorialPage />
</Route>
<AuthRoute exact path={PATHS.HOME_PAGE}>
<Home />
</AuthRoute>
<Route exact path={PATHS.ROOT}>
<Redirect to={apiToken ? PATHS.HOME_PAGE : PATHS.WELCOME_PAGE} />
</Route>
</IonRouterOutlet>
</IonReactRouter>
</IonApp>
);
};

export default Routes;
102 changes: 102 additions & 0 deletions src/Store.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { fireEvent, render, waitFor } from "@testing-library/react";
import React from "react";
import StoreProvider, { useStore } from "./Store";
import { nmdcServerClient } from "./api";
import { vi } from "vitest";

const TestStoreConsumer: React.FC = () => {
const { apiToken, setApiToken, store } = useStore();
const [callbackComplete, setCallbackComplete] = React.useState(false);

const handleClick = async () => {
await setApiToken("test");
setCallbackComplete(true);
};

return (
<>
<div data-testid="store-status">
{store == null ? "waiting for store" : "store created"}
</div>
<div data-testid="api-token">{apiToken}</div>
<button data-testid="set-api-token" onClick={handleClick}>
Set Token
</button>
<div data-testid="callback-status">
{callbackComplete ? "callback complete" : "waiting for callback"}
</div>
</>
);
};

const renderTestStoreConsumer = () => {
return render(
<StoreProvider>
<TestStoreConsumer />
</StoreProvider>,
);
};

// This test directly accesses window.localStorage to verify that certain storage keys are saved to
// persistent storage and re-hydrated on initialization. This is a bit of internal implementation
// knowledge. By default, @ionic/storage uses IndexedDB, but jsdom does not implement it
// (https://github.com/jsdom/jsdom/issues/1748). Without it, @ionic/storage will fall back to
// using localStorage.
describe("Store", () => {
it("should provide a null apiToken by default", async () => {
// Set up API client spy and render test component
const spy = vi.spyOn(nmdcServerClient, "setBearerToken");
const { getByTestId } = renderTestStoreConsumer();

// Verify that the store gets initialized, the API token is not set, and the spy is not called
await waitFor(() =>
expect(getByTestId("store-status").textContent).toBe("store created"),
);
expect(getByTestId("api-token").textContent).toBe("");
expect(spy).not.toHaveBeenCalled();
});

it("should provide a function to update the apiToken", async () => {
// Set up API client spy and render test component
const spy = vi.spyOn(nmdcServerClient, "setBearerToken");
const { getByTestId } = renderTestStoreConsumer();

// Verify that the store gets initialized
await waitFor(() =>
expect(getByTestId("store-status").textContent).toBe("store created"),
);

// Click the button which sets the token
fireEvent.click(getByTestId("set-api-token"));
await waitFor(() =>
expect(getByTestId("callback-status").textContent).toBe(
"callback complete",
),
);

// Verify that the token was set, the spy to update the API client was called, and the token
// was saved to storage
expect(getByTestId("api-token").textContent).toBe("test");
expect(spy).toHaveBeenCalledWith("test");
expect(
window.localStorage.getItem("nmdc_field_notes/app_store/apiToken"),
).toBe('"test"');
});

it("should hydrate the apiToken from storage", async () => {
// Pre-populate the storage with a token
window.localStorage.setItem(
"nmdc_field_notes/app_store/apiToken",
'"from-storage"',
);

// Render the test component
const { getByTestId } = renderTestStoreConsumer();

// Verify that the store gets initialized with the pre-populated token
await waitFor(() =>
expect(getByTestId("store-status").textContent).toBe("store created"),
);
expect(getByTestId("api-token").textContent).toBe("from-storage");
});
});
Loading