Skip to content

Commit

Permalink
feat: display OIDC login form
Browse files Browse the repository at this point in the history
  • Loading branch information
huwshimi committed Jun 11, 2024
1 parent 5531adf commit e2f0b42
Show file tree
Hide file tree
Showing 14 changed files with 161 additions and 10 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import type { RootState } from "store/store";

import { Label } from "../types";

import { TestId } from "./types";

type Props = {
userIsLoggedIn: boolean;
};
Expand All @@ -21,7 +23,11 @@ const IdentityProviderForm = ({ userIsLoggedIn }: Props) => {
});

return visitURL ? (
<AuthenticationButton appearance="positive" visitURL={visitURL}>
<AuthenticationButton
appearance="positive"
visitURL={visitURL}
data-testid={TestId.CANDID_LOGIN}
>
{Label.LOGIN_TO_DASHBOARD}
</AuthenticationButton>
) : (
Expand Down
1 change: 1 addition & 0 deletions src/components/LogIn/IdentityProviderForm/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { default } from "./IdentityProviderForm";
export { TestId as IdentityProviderFormTestId } from "./types";
3 changes: 3 additions & 0 deletions src/components/LogIn/IdentityProviderForm/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export enum TestId {
CANDID_LOGIN = "candid-login",
}
21 changes: 20 additions & 1 deletion src/components/LogIn/LogIn.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { configFactory, generalStateFactory } from "testing/factories/general";
import { rootStateFactory } from "testing/factories/root";
import { renderComponent } from "testing/utils";

import { IdentityProviderFormTestId } from "./IdentityProviderForm";
import LogIn from "./LogIn";
import { OIDCFormTestId } from "./OIDCForm";
import { ErrorResponse, Label } from "./types";

describe("LogIn", () => {
Expand Down Expand Up @@ -49,7 +51,24 @@ describe("LogIn", () => {
renderComponent(<LogIn>App content</LogIn>, { state });
expect(
screen.getByRole("link", { name: Label.LOGIN_TO_DASHBOARD }),
).toBeInTheDocument();
).toHaveAttribute("data-testid", IdentityProviderFormTestId.CANDID_LOGIN);
expect(
screen.queryByRole("textbox", { name: "Username" }),
).not.toBeInTheDocument();
});

it("renders an OIDC login UI if the user is not logged in", () => {
const state = rootStateFactory.build({
general: generalStateFactory.withConfig().build({
config: configFactory.build({
authMethod: AuthMethod.OIDC,
}),
}),
});
renderComponent(<LogIn>App content</LogIn>, { state });
expect(
screen.getByRole("link", { name: Label.LOGIN_TO_DASHBOARD }),
).toHaveAttribute("data-testid", OIDCFormTestId.OIDC_LOGIN);
expect(
screen.queryByRole("textbox", { name: "Username" }),
).not.toBeInTheDocument();
Expand Down
22 changes: 16 additions & 6 deletions src/components/LogIn/LogIn.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { PropsWithChildren } from "react";
import type { PropsWithChildren, ReactNode } from "react";
import { useEffect, useRef } from "react";
import reactHotToast from "react-hot-toast";
import { useSelector } from "react-redux";
Expand All @@ -20,6 +20,7 @@ import { useAppSelector } from "store/store";

import "./_login.scss";
import IdentityProviderForm from "./IdentityProviderForm";
import OIDCForm from "./OIDCForm";
import UserPassForm from "./UserPassForm";
import { ErrorResponse, Label } from "./types";

Expand Down Expand Up @@ -69,18 +70,27 @@ export default function LogIn({ children }: PropsWithChildren) {
});
}, [visitURLs]);

let form: ReactNode = null;
switch (config?.authMethod) {
case AuthMethod.CANDID:
form = <IdentityProviderForm userIsLoggedIn={userIsLoggedIn} />;
break;
case AuthMethod.OIDC:
form = <OIDCForm />;
break;
default:
form = <UserPassForm />;
break;
}

return (
<>
{!userIsLoggedIn && (
<div className="login">
<FadeUpIn isActive={!userIsLoggedIn}>
<div className="login__inner p-card--highlighted">
<Logo className="login__logo" dark isJuju={isJuju} />
{config?.authMethod === AuthMethod.CANDID ? (
<IdentityProviderForm userIsLoggedIn={userIsLoggedIn} />
) : (
<UserPassForm />
)}
{form}
{generateErrorMessage(loginError)}
</div>
</FadeUpIn>
Expand Down
17 changes: 17 additions & 0 deletions src/components/LogIn/OIDCForm/OIDCForm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { screen } from "@testing-library/react";

import { endpoints } from "juju/jimm/api";
import { renderComponent } from "testing/utils";

import { Label } from "../types";

import OIDCForm from "./OIDCForm";

describe("OIDCForm", () => {
it("should render a login link", () => {
renderComponent(<OIDCForm />);
expect(
screen.getByRole("link", { name: Label.LOGIN_TO_DASHBOARD }),
).toHaveAttribute("href", endpoints.login);
});
});
22 changes: 22 additions & 0 deletions src/components/LogIn/OIDCForm/OIDCForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Button } from "@canonical/react-components";

import { endpoints } from "juju/jimm/api";

import { Label } from "../types";

import { TestId } from "./types";

const OIDCForm = () => {
return (
<Button
appearance="positive"
element="a"
href={endpoints.login}
data-testid={TestId.OIDC_LOGIN}
>
{Label.LOGIN_TO_DASHBOARD}
</Button>
);
};

export default OIDCForm;
2 changes: 2 additions & 0 deletions src/components/LogIn/OIDCForm/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default } from "./OIDCForm";
export { TestId as OIDCFormTestId } from "./types";
3 changes: 3 additions & 0 deletions src/components/LogIn/OIDCForm/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export enum TestId {
OIDC_LOGIN = "oidc-login",
}
21 changes: 21 additions & 0 deletions src/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -216,9 +216,30 @@ describe("renderApp", () => {
expect(dispatch).toHaveBeenCalledWith({ type: "connectAndStartPolling" });
});

it("connects when using OIDC", async () => {
// Mock the result of the thunk a normal action so that it can be tested
// for. This is necessary because we don't have a full store set up which
// can dispatch thunks (and we don't need to handle the thunk, just know it
// was dispatched).
vi.spyOn(appThunks, "connectAndStartPolling").mockImplementation(
vi.fn().mockReturnValue({ type: "connectAndStartPolling" }),
);
const dispatch = vi
.spyOn(storeModule.default, "dispatch")
.mockImplementation(vi.fn().mockResolvedValue({ catch: vi.fn() }));
const config = configFactory.build({
controllerAPIEndpoint: "wss://example.com/api",
isJuju: false,
});
window.jujuDashboardConfig = config;
renderApp();
expect(dispatch).toHaveBeenCalledWith({ type: "connectAndStartPolling" });
});

it("renders the app", async () => {
const config = configFactory.build({
controllerAPIEndpoint: "wss://example.com/api",
isJuju: true,
});
window.jujuDashboardConfig = config;
renderApp();
Expand Down
2 changes: 1 addition & 1 deletion src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ function bootstrap() {
reduxStore.dispatch(generalActions.storeConfig(config));
reduxStore.dispatch(generalActions.storeVersion(appVersion));

if (config.authMethod === AuthMethod.CANDID) {
if ([AuthMethod.CANDID, AuthMethod.OIDC].includes(config.authMethod)) {
// If using Candid authentication then try and connect automatically
// If not then wait for the login UI to trigger this
reduxStore
Expand Down
3 changes: 2 additions & 1 deletion src/juju/jimm/thunks.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { LoginResult } from "@canonical/jujulib/dist/api/facades/admin/AdminV3";
import { createAsyncThunk } from "@reduxjs/toolkit";

import type { RootState } from "store/store";
Expand Down Expand Up @@ -29,7 +30,7 @@ export const logout = createAsyncThunk<
Get the authenticated user from the JIMM API.
*/
export const whoami = createAsyncThunk<
void,
LoginResult | null,
void,
{
state: RootState;
Expand Down
37 changes: 37 additions & 0 deletions src/store/middleware/model-poller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,43 @@ describe("model poller", () => {
);
});

it("stops when not logged in and is using OIDC", async () => {
vi.spyOn(fakeStore, "getState").mockReturnValue(storeState);
vi.spyOn(fakeStore, "dispatch").mockReturnValue({ type: "whoami" });
const loginWithBakerySpy = vi.spyOn(jujuModule, "loginWithBakery");
await runMiddleware(
appActions.connectAndPollControllers({
controllers: [[wsControllerURL, undefined, AuthMethod.OIDC]],
isJuju: true,
poll: 0,
}),
);
expect(loginWithBakerySpy).not.toHaveBeenCalled();
});

it("continues when not logged in and is using OIDC", async () => {
vi.spyOn(fakeStore, "getState").mockReturnValue(storeState);
vi.spyOn(fakeStore, "dispatch").mockReturnValue({
type: "whoami",
payload: { user: "user-test@external" },
});
const loginWithBakerySpy = vi
.spyOn(jujuModule, "loginWithBakery")
.mockImplementation(async () => ({
conn,
intervalId,
juju,
}));
await runMiddleware(
appActions.connectAndPollControllers({
controllers: [[wsControllerURL, undefined, AuthMethod.OIDC]],
isJuju: true,
poll: 0,
}),
);
expect(loginWithBakerySpy).toHaveBeenCalled();
});

it("fetches and stores data", async () => {
const fetchControllerList = vi.spyOn(jujuModule, "fetchControllerList");
conn.facades.modelManager.listModels.mockResolvedValue({
Expand Down
9 changes: 9 additions & 0 deletions src/store/middleware/model-poller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Client } from "@canonical/jujulib";
import { unwrapResult } from "@reduxjs/toolkit";
import * as Sentry from "@sentry/browser";
import { isAction, type Middleware } from "redux";

Expand All @@ -12,6 +13,7 @@ import {
setModelSharingPermissions,
} from "juju/api";
import { JIMMRelation } from "juju/jimm/JIMMV4";
import { whoami } from "juju/jimm/thunks";
import type { ConnectionWithFacades } from "juju/types";
import { actions as appActions, thunks as appThunks } from "store/app";
import { actions as generalActions } from "store/general";
Expand Down Expand Up @@ -77,6 +79,13 @@ export const modelPollerMiddleware: Middleware<
let juju: Client | undefined;
let error: unknown;
let intervalId: number | null | undefined;
if (authMethod === AuthMethod.OIDC) {
const whoamiResponse = await reduxStore.dispatch(whoami());
const user = unwrapResult(whoamiResponse);
if (!user) {
return;
}
}
try {
({ conn, error, juju, intervalId } = await loginWithBakery(
wsControllerURL,
Expand Down

0 comments on commit e2f0b42

Please sign in to comment.