Skip to content

Commit

Permalink
Merge pull request #368 from huwshimi/oidc-login
Browse files Browse the repository at this point in the history
WD-13638 - feat: log in via OIDC
  • Loading branch information
huwshimi authored Aug 7, 2024
2 parents 9b01c22 + 2aede13 commit 191e1a4
Show file tree
Hide file tree
Showing 27 changed files with 585 additions and 243 deletions.
12 changes: 9 additions & 3 deletions ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@
"hooks-add": "husky install",
"hooks-remove": "husky uninstall",
"start": "concurrently --kill-others --raw 'yarn dev-serve' 'yarn proxy-serve'",
"test-js": "vitest --run",
"test-js": "vitest",
"dev-serve": "vite --host",
"proxy-serve": "./entrypoint"
},
"dependencies": {
"@canonical/react-components": "0.59.0",
"@canonical/rebac-admin": "0.0.1-alpha.4",
"@canonical/rebac-admin": "0.0.1-alpha.5",
"@tanstack/react-query": "^5.28.6",
"@use-it/event-listener": "0.1.7",
"axios": "1.6.8",
Expand All @@ -42,6 +42,10 @@
"@babel/preset-env": "7.24.3",
"@babel/preset-react": "7.24.1",
"@babel/preset-typescript": "7.24.1",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.4.8",
"@testing-library/react": "16.0.0",
"@testing-library/user-event": "14.5.2",
"@types/react": "18.2.67",
"@types/react-dom": "18.2.22",
"@types/react-router-dom": "5.3.3",
Expand All @@ -55,6 +59,7 @@
"eslint-config-prettier": "9.1.0",
"eslint-plugin-prettier": "5.1.3",
"eslint-plugin-react": "7.34.1",
"happy-dom": "14.12.3",
"husky": "9.0.11",
"lint-staged": "15.2.2",
"monaco-editor": "0.47.0",
Expand All @@ -70,7 +75,8 @@
"typescript": "5.4.3",
"vite": "5.2.8",
"vite-tsconfig-paths": "4.3.2",
"vitest": "1.2.1"
"vitest": "1.2.1",
"vitest-fetch-mock": "0.3.0"
},
"lint-staged": {
"src/**/*.{json,jsx,ts,tsx}": [
Expand Down
68 changes: 17 additions & 51 deletions ui/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,66 +1,32 @@
import { FC, Suspense } from "react";
import { FC } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { ApplicationLayout } from "@canonical/react-components";
import { ReBACAdmin } from "@canonical/rebac-admin";
import Loader from "components/Loader";
import Logo from "components/Logo";
import Navigation from "components/Navigation";
import ClientList from "pages/clients/ClientList";
import NoMatch from "components/NoMatch";
import ProviderList from "pages/providers/ProviderList";
import IdentityList from "pages/identities/IdentityList";
import SchemaList from "pages/schemas/SchemaList";
import Panels from "components/Panels";
import useLocalStorage from "util/useLocalStorage";
import { apiBasePath } from "util/basePaths";
import Layout from "components/Layout/Layout";

const App: FC = () => {
// Store a user token that will be passed to the API using the
// X-Authorization header so that the user can be identified. This will be
// replaced by API authentication when it has been implemented.
const [authUser, setAuthUser] = useLocalStorage<{
username: string;
token: string;
} | null>("user", null);
return (
<ApplicationLayout
aside={<Panels />}
id="app-layout"
logo={<Logo />}
sideNavigation={
<Navigation
username={authUser?.username}
logout={() => {
setAuthUser(null);
window.location.reload();
}}
<Routes>
<Route path="/" element={<Layout />}>
<Route path="/" element={<Navigate to="/provider" replace={true} />} />
<Route path="/provider" element={<ProviderList />} />
<Route path="/client" element={<ClientList />} />
<Route path="/identity" element={<IdentityList />} />
<Route path="/schema" element={<SchemaList />} />
<Route
path="/*"
element={
<ReBACAdmin apiURL={apiBasePath} asidePanelId="app-layout" />
}
/>
}
>
<Suspense fallback={<Loader />}>
<Routes>
<Route
path="/"
element={<Navigate to="/provider" replace={true} />}
/>
<Route path="/provider" element={<ProviderList />} />
<Route path="/client" element={<ClientList />} />
<Route path="/identity" element={<IdentityList />} />
<Route path="/schema" element={<SchemaList />} />
<Route
path="/*"
element={
<ReBACAdmin
apiURL={apiBasePath}
asidePanelId="app-layout"
authToken={authUser?.token}
/>
}
/>
<Route path="*" element={<NoMatch />} />
</Routes>
</Suspense>
</ApplicationLayout>
<Route path="*" element={<NoMatch />} />
</Route>
</Routes>
);
};

Expand Down
28 changes: 28 additions & 0 deletions ui/src/api/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { fetchMe } from "./auth";

beforeEach(() => {
fetchMock.resetMocks();
});

test("fetches a user", async () => {
const user = {
email: "email",
name: "name",
nonce: "nonce",
sid: "sid",
sub: "sub",
};
fetchMock.mockResponse(JSON.stringify(user), { status: 200 });
await expect(fetchMe()).resolves.toStrictEqual(user);
});

test("handles a non-authenticated user", async () => {
fetchMock.mockResponseOnce(JSON.stringify({}), { status: 401 });
await expect(fetchMe()).resolves.toBeNull();
});

test("catches errors", async () => {
const error = "Uh oh!";
fetchMock.mockRejectedValue(error);
await expect(fetchMe()).rejects.toBe(error);
});
24 changes: 24 additions & 0 deletions ui/src/api/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { apiBasePath } from "util/basePaths";
import type { UserPrincipal } from "types/auth";
import { handleResponse } from "util/api";

const BASE = `${apiBasePath}auth`;

export const authURLs = {
login: BASE,
me: `${BASE}/me`,
};

export const fetchMe = (): Promise<UserPrincipal> => {
return new Promise((resolve, reject) => {
fetch(authURLs.me)
.then((response: Response) =>
// If the user is not authenticated then return null instead of throwing an
// error. This is necessary so that a login screen can be displayed instead of displaying
// the error.
[401, 403].includes(response.status) ? null : handleResponse(response),
)
.then((result: UserPrincipal) => resolve(result))
.catch(reject);
});
};
39 changes: 39 additions & 0 deletions ui/src/components/Layout/Layout.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { renderComponent } from "test/utils";
import Layout from "./Layout";
import { screen } from "@testing-library/dom";
import { LoginLabel } from "components/Login";

beforeEach(() => {
fetchMock.resetMocks();
});

test("displays the login screen if the user is not authenticated", async () => {
fetchMock.mockResponseOnce(JSON.stringify({}), { status: 403 });
renderComponent(<Layout />);
expect(
await screen.findByRole("heading", { name: LoginLabel.TITLE }),
).toBeInTheDocument();
});

test("displays the layout and content if the user is authenticated", async () => {
const user = {
email: "email",
name: "name",
nonce: "nonce",
sid: "sid",
sub: "sub",
};
fetchMock.mockResponse(JSON.stringify(user), { status: 200 });
renderComponent(<Layout />, {
path: "/",
url: "/",
routeChildren: [
{
element: <h1>Content</h1>,
path: "/",
},
],
});
expect(await screen.findByRole("heading", { name: "Content" }));
expect(document.querySelector("#app-layout")).toBeInTheDocument();
});
38 changes: 38 additions & 0 deletions ui/src/components/Layout/Layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { FC, Suspense } from "react";
import { Outlet } from "react-router-dom";
import { ApplicationLayout } from "@canonical/react-components";
import Loader from "components/Loader";
import Login from "components/Login";
import Logo from "components/Logo";
import Navigation from "components/Navigation";
import Panels from "components/Panels";
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "util/queryKeys";
import { fetchMe } from "api/auth";

const Layout: FC = () => {
const {
data: user,
isLoading,
error,
} = useQuery({
queryKey: [queryKeys.auth],
queryFn: fetchMe,
});
return user ? (
<ApplicationLayout
aside={<Panels />}
id="app-layout"
logo={<Logo />}
sideNavigation={<Navigation username={user.name || user.email} />}
>
<Suspense fallback={<Loader />}>
<Outlet />
</Suspense>
</ApplicationLayout>
) : (
<Login isLoading={isLoading} error={error?.message}></Login>
);
};

export default Layout;
1 change: 1 addition & 0 deletions ui/src/components/Layout/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./Layout";
59 changes: 0 additions & 59 deletions ui/src/components/Login.tsx

This file was deleted.

23 changes: 23 additions & 0 deletions ui/src/components/Login/Login.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { renderComponent } from "test/utils";
import Login from "./Login";
import { screen, within } from "@testing-library/dom";

test("displays the loading state", () => {
renderComponent(<Login isLoading />);
expect(
within(screen.getByRole("alert")).getByText("Loading"),
).toBeInTheDocument();
});

test("displays errors", () => {
const error = "Uh oh!";
renderComponent(<Login error={error} />);
expect(within(screen.getByRole("code")).getByText(error)).toBeInTheDocument();
});

test("displays the login button", () => {
renderComponent(<Login />);
expect(
screen.getByRole("link", { name: "Sign in to Identity platform" }),
).toBeInTheDocument();
});
Loading

0 comments on commit 191e1a4

Please sign in to comment.