sts-sponsors team mailing list archive
-
sts-sponsors team
-
Mailing list archive
-
Message #06852
[Merge] ~petermakowski/maas-site-manager:setup-authentication-MAASENG-1521 into maas-site-manager:main
Peter Makowski has proposed merging ~petermakowski/maas-site-manager:setup-authentication-MAASENG-1521 into maas-site-manager:main.
Requested reviews:
MAAS Committers (maas-committers)
For more details, see:
https://code.launchpad.net/~petermakowski/maas-site-manager/+git/site-manager/+merge/440752
QA Steps
Go to `http://localhost:8405/`
Make sure a login page is displayed
Enter admin as username and password as password
Ensure the error message for incorrect credentials has been displayed
Enter admin as username and test as password
Ensure you were logged in and redirected to /sites
--
Your team MAAS Committers is requested to review the proposed merge of ~petermakowski/maas-site-manager:setup-authentication-MAASENG-1521 into maas-site-manager:main.
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index cf9fcfd..eba5f46 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -2,7 +2,7 @@
import App from "./App";
import { allResolvers } from "@/mocks/resolvers";
-import { waitFor, render, within, setupServer } from "@/test-utils";
+import { waitFor, render, setupServer } from "@/test-utils";
const mockServer = setupServer(...allResolvers);
beforeAll(() => {
@@ -22,5 +22,4 @@ it("renders vanilla layout components correctly", async () => {
expect(application.querySelector(".l-navigation-bar")).toBeInTheDocument();
expect(application.querySelector(".l-main")).toBeInTheDocument();
expect(application.querySelector(".l-navigation-bar")).toBeInTheDocument();
- expect(within(container).getByRole("heading", { name: /MAAS Site Manager/i })).toBeInTheDocument();
});
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 4ee3b92..cee9a8b 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -2,9 +2,11 @@ import "./App.scss";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
-import { AppContextProvider } from "./context";
+import { AppContextProvider, AuthContextProvider } from "./context";
import routes from "./routes";
+import apiClient from "@/api";
+
const queryClient = new QueryClient();
const router = createBrowserRouter(routes);
@@ -12,7 +14,9 @@ const App: React.FC = () => {
return (
<QueryClientProvider client={queryClient}>
<AppContextProvider>
- <RouterProvider router={router} />
+ <AuthContextProvider apiClient={apiClient}>
+ <RouterProvider router={router} />
+ </AuthContextProvider>
</AppContextProvider>
</QueryClientProvider>
);
diff --git a/frontend/src/api/handlers.ts b/frontend/src/api/handlers.ts
index e96bc59..56dc656 100644
--- a/frontend/src/api/handlers.ts
+++ b/frontend/src/api/handlers.ts
@@ -3,6 +3,32 @@ import urls from "./urls";
import { customParamSerializer } from "@/utils";
+export type PostLoginData = {
+ username: string;
+ password: string;
+};
+
+export const postLogin = async (data: PostLoginData) => {
+ if (!data?.username || !data?.password) {
+ throw Error("Missing required fields");
+ }
+ try {
+ const formData = new FormData();
+ formData.append("username", data.username);
+ formData.append("password", data.password);
+
+ const response = await api.post(urls.login, formData, { headers: { "Content-Type": "multipart/form-data" } });
+ return response.data;
+ } catch (error) {
+ throw error;
+ }
+};
+
+export type PostRegisterData = {
+ username: string;
+ password: string;
+};
+
export type PaginationParams = {
page: string;
size: string;
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts
index eeca836..780d3c5 100644
--- a/frontend/src/api/types.ts
+++ b/frontend/src/api/types.ts
@@ -1,3 +1,8 @@
+export type AccessToken = {
+ access_token: string;
+ token_type: "bearer";
+};
+
export type Site = {
identifier: string;
name: string;
diff --git a/frontend/src/api/urls.ts b/frontend/src/api/urls.ts
index a588d4e..05d8b47 100644
--- a/frontend/src/api/urls.ts
+++ b/frontend/src/api/urls.ts
@@ -1,6 +1,8 @@
import { getApiUrl } from "./utils";
const urls = {
+ login: getApiUrl("/login"),
+ logout: getApiUrl("/logout"),
sites: getApiUrl("/sites"),
tokens: getApiUrl("/tokens"),
enrollmentRequests: getApiUrl("/requests"),
diff --git a/frontend/src/base/routesConfig.ts b/frontend/src/base/routesConfig.ts
new file mode 100644
index 0000000..0df83b8
--- /dev/null
+++ b/frontend/src/base/routesConfig.ts
@@ -0,0 +1,25 @@
+export const protectedRoutes = {
+ sites: {
+ path: "/sites",
+ title: "Regions",
+ },
+ requests: {
+ path: "/requests",
+ title: "Requests",
+ },
+ tokens: {
+ path: "/tokens",
+ title: "Tokens",
+ },
+};
+export const publicRoutes = {
+ login: {
+ path: "/login",
+ title: "Login",
+ },
+};
+
+export const routesConfig = { ...publicRoutes, ...protectedRoutes } as const;
+
+type RoutesConfig = typeof routesConfig;
+export type RoutePath = RoutesConfig[keyof RoutesConfig]["path"];
diff --git a/frontend/src/components/LoginForm/LoginForm.test.tsx b/frontend/src/components/LoginForm/LoginForm.test.tsx
index 6c0e35f..7f3af5d 100644
--- a/frontend/src/components/LoginForm/LoginForm.test.tsx
+++ b/frontend/src/components/LoginForm/LoginForm.test.tsx
@@ -1,16 +1,16 @@
import LoginForm from "./LoginForm";
-import { render, screen, userEvent } from "@/test-utils";
+import { renderWithMemoryRouter, screen, userEvent } from "@/test-utils";
describe("LoginForm", () => {
it("renders", () => {
- render(<LoginForm />);
+ renderWithMemoryRouter(<LoginForm />);
expect(screen.getByRole("form", { name: "Login" })).toBeInTheDocument();
});
it("displays an error if the username input is left empty", async () => {
- render(<LoginForm />);
+ renderWithMemoryRouter(<LoginForm />);
const usernameInput = screen.getByRole("textbox", { name: "Username" });
@@ -22,7 +22,7 @@ describe("LoginForm", () => {
});
it("displays an error if the password input is left empty", async () => {
- render(<LoginForm />);
+ renderWithMemoryRouter(<LoginForm />);
const passwordInput = screen.getByLabelText("Password");
@@ -34,7 +34,7 @@ describe("LoginForm", () => {
});
it("disables the 'Login' button if a username and password are not present", async () => {
- render(<LoginForm />);
+ renderWithMemoryRouter(<LoginForm />);
const usernameInput = screen.getByRole("textbox", { name: "Username" });
const passwordInput = screen.getByLabelText("Password");
diff --git a/frontend/src/components/LoginForm/LoginForm.tsx b/frontend/src/components/LoginForm/LoginForm.tsx
index 34068fa..85bd262 100644
--- a/frontend/src/components/LoginForm/LoginForm.tsx
+++ b/frontend/src/components/LoginForm/LoginForm.tsx
@@ -1,7 +1,13 @@
-import { Col, Row, Strip, Input, useId, Label, Card, Button } from "@canonical/react-components";
+import { useCallback, useEffect } from "react";
+
+import { Notification, Col, Row, Strip, Input, useId, Label, Card, Button } from "@canonical/react-components";
+import type { FormikHelpers } from "formik";
import { Field, Form, Formik } from "formik";
+import { useNavigate, useSearchParams } from "react-router-dom";
import * as Yup from "yup";
+import { useAuthContext } from "@/context";
+
const initialValues = {
username: "",
password: "",
@@ -19,56 +25,83 @@ const LoginForm = () => {
const headingId = `heading-${id}`;
const usernameId = `username-${id}`;
const passwordId = `password=${id}`;
+ const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
+ const { login, isError, failureReason, status } = useAuthContext();
+ const handleRedirect = useCallback(() => {
+ // send user back to the page they tried to visit
+ // { replace: true } avoids going back to login page once authenticated
+ navigate(searchParams.get("redirectTo") ?? "/sites", { replace: true });
+ }, [searchParams, navigate]);
- const handleSubmit = (values: LoginFormValues) => {
- // 1. send values to backend
- // 2. if error, return error and display
- // 3. if all good, set cookie and navigate to /sites
+ const handleSubmit = async (
+ { username, password }: LoginFormValues,
+ { setSubmitting }: FormikHelpers<LoginFormValues>,
+ ) => {
+ await login({ username, password });
+ setSubmitting(false);
};
+ useEffect(() => {
+ if (status === "authenticated") {
+ handleRedirect();
+ }
+ }, [handleRedirect, status]);
+
return (
- <Strip>
- <Row>
- <Col emptyLarge={4} size={6}>
- <Card>
- <h1 className="p-card__title p-heading--3" id={headingId}>
- Login
- </h1>
- <Formik<LoginFormValues>
- initialValues={initialValues}
- onSubmit={handleSubmit}
- validationSchema={LoginFormSchema}
- >
- {({ isSubmitting, errors, touched, isValid, dirty }) => (
- <Form aria-labelledby={headingId}>
- <Label htmlFor={usernameId}>Username</Label>
- <Field
- as={Input}
- error={touched.username && errors.username}
- id={usernameId}
- name="username"
- required
- type="text"
- />
- <Label htmlFor={passwordId}>Password</Label>
- <Field
- as={Input}
- error={touched.password && errors.password}
- id={passwordId}
- name="password"
- required
- type="password"
- />
- <Button appearance="positive" disabled={!dirty || !isValid || isSubmitting} type="submit">
- Login
- </Button>
- </Form>
- )}
- </Formik>
- </Card>
- </Col>
- </Row>
- </Strip>
+ <>
+ <Strip>
+ {isError ? (
+ <Row>
+ <Col emptyLarge={4} size={6}>
+ <Notification role="alert" severity="negative">
+ {failureReason?.response?.data?.detail ?? "An unknown error occurred."}
+ </Notification>
+ </Col>
+ </Row>
+ ) : null}
+ <Row>
+ <Col emptyLarge={4} size={6}>
+ <Card>
+ <h1 className="p-card__title p-heading--3" id={headingId}>
+ Login
+ </h1>
+ <Formik<LoginFormValues>
+ initialValues={initialValues}
+ onSubmit={handleSubmit}
+ validationSchema={LoginFormSchema}
+ >
+ {({ isSubmitting, errors, touched, isValid, dirty }) => (
+ <Form aria-labelledby={headingId}>
+ <Label htmlFor={usernameId}>Username</Label>
+ <Field
+ as={Input}
+ error={touched.username && errors.username}
+ id={usernameId}
+ name="username"
+ required
+ type="text"
+ />
+ <Label htmlFor={passwordId}>Password</Label>
+ <Field
+ as={Input}
+ error={touched.password && errors.password}
+ id={passwordId}
+ name="password"
+ required
+ type="password"
+ />
+ <Button appearance="positive" disabled={!dirty || !isValid || isSubmitting} type="submit">
+ Login
+ </Button>
+ </Form>
+ )}
+ </Formik>
+ </Card>
+ </Col>
+ </Row>
+ </Strip>
+ </>
);
};
diff --git a/frontend/src/components/MainLayout/MainLayout.tsx b/frontend/src/components/MainLayout/MainLayout.tsx
index b2ce99a..20802ee 100644
--- a/frontend/src/components/MainLayout/MainLayout.tsx
+++ b/frontend/src/components/MainLayout/MainLayout.tsx
@@ -1,17 +1,18 @@
import type { PropsWithChildren } from "react";
import { useEffect } from "react";
-import { Col, Row, usePrevious } from "@canonical/react-components";
+import { Col, Row, Strip, usePrevious } from "@canonical/react-components";
import classNames from "classnames";
import { Outlet, useLocation } from "react-router-dom";
+import { routesConfig } from "@/base/routesConfig";
+import type { RoutePath } from "@/base/routesConfig";
import DocumentTitle from "@/components/DocumentTitle/DocumentTitle";
import Navigation from "@/components/Navigation";
+import NavigationBanner from "@/components/Navigation/NavigationBanner";
import RemoveRegions from "@/components/RemoveRegions";
import { useAppContext } from "@/context";
import TokensCreate from "@/pages/tokens/create";
-import type { RoutePath } from "@/routes";
-import { routesConfig } from "@/routes";
export const sidebarLabels: Record<"removeRegions" | "createToken", string> = {
removeRegions: "Remove regions",
@@ -37,6 +38,29 @@ const getPageTitle = (pathname: RoutePath) => {
return title ? `${title} | MAAS Site Manager` : "MAAS Site Manager";
};
+const LoginLayout: React.FC = () => {
+ return (
+ <div className="l-application">
+ <header className="l-navigation-bar is-pinned">
+ <div className="p-panel is-dark">
+ <div className="p-panel__header">
+ <NavigationBanner />
+ </div>
+ </div>
+ </header>
+ <main className="l-main">
+ <div>
+ <Strip element="section" includeCol={false} shallow>
+ <Col size={12}>
+ <Outlet />
+ </Col>
+ </Strip>
+ </div>
+ </main>
+ </div>
+ );
+};
+
const MainLayout: React.FC = () => {
const { sidebar, setSidebar } = useAppContext();
const { pathname } = useLocation();
@@ -51,7 +75,6 @@ const MainLayout: React.FC = () => {
return (
<>
- <DocumentTitle>{getPageTitle(pathname as RoutePath)}</DocumentTitle>
<div className="l-application">
<Navigation />
<main className="l-main is-maas-site-manager">
@@ -74,4 +97,14 @@ const MainLayout: React.FC = () => {
);
};
-export default MainLayout;
+const Layout = () => {
+ const { pathname } = useLocation();
+ return (
+ <>
+ <DocumentTitle>{getPageTitle(pathname as RoutePath)}</DocumentTitle>
+ {pathname === "/login" ? <LoginLayout /> : <MainLayout />}
+ </>
+ );
+};
+
+export default Layout;
diff --git a/frontend/src/components/Navigation/Navigation.tsx b/frontend/src/components/Navigation/Navigation.tsx
index 4d1fea9..a61ee34 100644
--- a/frontend/src/components/Navigation/Navigation.tsx
+++ b/frontend/src/components/Navigation/Navigation.tsx
@@ -5,8 +5,8 @@ import useLocalStorageState from "use-local-storage-state";
import NavigationBanner from "./NavigationBanner";
import NavigationCollapseToggle from "./NavigationCollapseToggle";
-import NavigationItems from "./NavigationItems";
-import type { NavLink } from "./types";
+import NavigationList from "./NavigationList";
+import type { NavGroup, NavLink } from "./types";
export const navItems: NavLink[] = [
{
@@ -21,15 +21,16 @@ export const navBottomItems: NavLink[] = [
{ label: "Requests", url: "/requests" },
];
-const navItemsBottom = [
+const navItemsBottom: NavGroup[] = [
{
groupTitle: "Enrolment",
groupIcon: "settings",
navLinks: navBottomItems,
- highlight: ["/tokens", "/users", "/requests"],
},
];
+const navItemsAccount = [{ label: "Log out", url: "/logout" }];
+
const Navigation = (): JSX.Element => {
const [isCollapsed, setIsCollapsed] = useLocalStorageState<boolean>("appSideNavIsCollapsed", { defaultValue: true });
const location = useLocation();
@@ -69,12 +70,9 @@ const Navigation = (): JSX.Element => {
</NavigationBanner>
</div>
<div className="p-panel__content">
- <div className="p-side-navigation--icons is-dark">
- <NavigationItems items={navItems} path={path} />
- </div>
- <div className="p-side-navigation--icons is-dark">
- <NavigationItems items={navItemsBottom} path={path} />
- </div>
+ <NavigationList hasIcons isDark items={navItems} path={path} />
+ <NavigationList hasIcons isDark items={navItemsBottom} path={path} />
+ <NavigationList hasIcons isDark items={navItemsAccount} path={path} />
</div>
</div>
</div>
diff --git a/frontend/src/components/Navigation/NavigationItems/index.ts b/frontend/src/components/Navigation/NavigationItems/index.ts
deleted file mode 100644
index 1f708a3..0000000
--- a/frontend/src/components/Navigation/NavigationItems/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from "./NavigationItems";
diff --git a/frontend/src/components/Navigation/NavigationItems/NavigationItems.tsx b/frontend/src/components/Navigation/NavigationList/NavigationList.tsx
similarity index 83%
rename from frontend/src/components/Navigation/NavigationItems/NavigationItems.tsx
rename to frontend/src/components/Navigation/NavigationList/NavigationList.tsx
index b077840..03732c5 100644
--- a/frontend/src/components/Navigation/NavigationItems/NavigationItems.tsx
+++ b/frontend/src/components/Navigation/NavigationList/NavigationList.tsx
@@ -3,11 +3,13 @@ import { useId, useMemo } from "react";
import { Icon } from "@canonical/react-components";
import classNames from "classnames";
-import NavigationItem from "../NavigationItem/NavigationItem";
-import type { NavGroup, NavItem } from "../types";
-import { isNavGroup, isSelected } from "../utils";
+import NavigationItem from "@/components/Navigation/NavigationItem/NavigationItem";
+import type { NavGroup, NavItem } from "@/components/Navigation/types";
+import { isNavGroup, isSelected } from "@/components/Navigation/utils";
type Props = {
+ isDark?: boolean;
+ hasIcons?: boolean;
items: NavItem[];
logout?: () => void;
path: string;
@@ -49,18 +51,18 @@ const NavigationItemGroup = ({ group, path }: { group: NavGroup } & Pick<Props,
);
};
-const NavigationItems = ({ items, logout, path }: Props): JSX.Element => {
+const NavigationList = ({ items, logout, path, isDark, hasIcons }: Props): JSX.Element => {
return (
- <>
+ <div className={classNames({ "is-dark": isDark, "p-side-navigation--icons": hasIcons })}>
<ul className="p-side-navigation__list">
{items.map((item, i) => {
if (isNavGroup(item)) {
return <NavigationItemGroup group={item} key={`${i}-${item.groupTitle}`} path={path} />;
- } else return <NavigationItem key={i} navLink={item} path={path} />;
+ } else return <NavigationItem key={`${i}-${item.label}`} navLink={item} path={path} />;
})}
</ul>
- </>
+ </div>
);
};
-export default NavigationItems;
+export default NavigationList;
diff --git a/frontend/src/components/Navigation/NavigationList/index.ts b/frontend/src/components/Navigation/NavigationList/index.ts
new file mode 100644
index 0000000..d84ffe6
--- /dev/null
+++ b/frontend/src/components/Navigation/NavigationList/index.ts
@@ -0,0 +1 @@
+export { default } from "./NavigationList";
diff --git a/frontend/src/context.tsx b/frontend/src/context.tsx
index c7c41ec..9cd0980 100644
--- a/frontend/src/context.tsx
+++ b/frontend/src/context.tsx
@@ -1,6 +1,11 @@
-import { createContext, useContext, useState } from "react";
+import { createContext, useContext, useEffect, useState } from "react";
import type { OnChangeFn, RowSelectionState } from "@tanstack/react-table";
+import type { AxiosInstance } from "axios";
+import useLocalStorageState from "use-local-storage-state";
+
+import type { LoginError } from "@/hooks/api";
+import { useLoginMutation } from "@/hooks/api";
export const AppContext = createContext<{
rowSelection: RowSelectionState;
@@ -23,4 +28,96 @@ export const AppContextProvider = ({ children }: { children: React.ReactNode })
);
};
+type AuthStatus = "initial" | "authenticated" | "unauthorised";
+
+interface AuthContextType {
+ status: AuthStatus;
+ setStatus: (status: AuthStatus) => void;
+ setAuthToken: (token: string) => void;
+ removeAuthToken: VoidFunction;
+ login: ({ username, password }: { username: string; password: string }) => void;
+ logout: (callback: VoidFunction) => void;
+ isError: boolean;
+ failureReason: LoginError | null;
+}
+
+export const AuthContext = createContext<AuthContextType>({
+ status: "initial",
+ setStatus: () => null,
+ setAuthToken: () => null,
+ removeAuthToken: () => null,
+ login: () => null,
+ logout: () => null,
+ isError: false,
+ failureReason: null,
+});
+
+export const AuthContextProvider = ({
+ apiClient,
+ children,
+}: {
+ apiClient: AxiosInstance;
+ children: React.ReactNode;
+}) => {
+ const [authToken, setAuthToken, { removeItem: removeAuthToken }] = useLocalStorageState("jwtToken");
+ const [status, setStatus] = useState<AuthStatus>("initial");
+ const { mutateAsync, isError, failureReason } = useLoginMutation();
+
+ useEffect(() => {
+ if (!authToken) {
+ setStatus("unauthorised");
+ } else {
+ setStatus("authenticated");
+ }
+ }, [apiClient, authToken]);
+
+ useEffect(() => {
+ if (authToken) {
+ apiClient.interceptors.request.use(function (config) {
+ if (authToken) {
+ config.headers.Authorization = `Bearer ${authToken}`;
+ }
+ return config;
+ });
+ }
+ }, [apiClient, authToken]);
+
+ useEffect(() => {
+ apiClient.interceptors.response.use(
+ (response) => response,
+ (error) => {
+ if (error.response.status === 401) {
+ removeAuthToken();
+ }
+ return Promise.reject(error);
+ },
+ );
+ }, [apiClient, removeAuthToken]);
+
+ const login = async ({ username, password }: { username: string; password: string }) => {
+ try {
+ const response = await mutateAsync({ username, password });
+ setAuthToken(response.access_token);
+ setStatus("authenticated");
+ } catch (error) {
+ setStatus("unauthorised");
+ }
+ };
+
+ const logout = (callback: VoidFunction) => {
+ removeAuthToken();
+ setStatus("unauthorised");
+ callback();
+ };
+
+ return (
+ <AuthContext.Provider
+ value={{ status, setStatus, setAuthToken, removeAuthToken, login, logout, isError, failureReason }}
+ >
+ {children}
+ </AuthContext.Provider>
+ );
+};
+
export const useAppContext = () => useContext(AppContext);
+export const useAuthContext = () => useContext(AuthContext);
diff --git a/frontend/src/hooks/api.ts b/frontend/src/hooks/api.ts
index a4770dc..74d685f 100644
--- a/frontend/src/hooks/api.ts
+++ b/frontend/src/hooks/api.ts
@@ -1,14 +1,23 @@
-import type { UseMutationOptions } from "@tanstack/react-query";
+import type { UseMutationOptions, UseMutationResult } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
+import type { AxiosError } from "axios";
import type {
GetEnrollmentRequestsQueryParams,
GetSitesQueryParams,
GetTokensQueryParams,
PostEnrollmentRequestsData,
+ PostLoginData,
} from "@/api/handlers";
-import { patchEnrollmentRequests, getEnrollmentRequests, postTokens, getSites, getTokens } from "@/api/handlers";
-import type { SitesQueryResult, PostTokensResult, EnrollmentRequestsQueryResult } from "@/api/types";
+import {
+ postLogin,
+ patchEnrollmentRequests,
+ getEnrollmentRequests,
+ postTokens,
+ getSites,
+ getTokens,
+} from "@/api/handlers";
+import type { SitesQueryResult, PostTokensResult, EnrollmentRequestsQueryResult, AccessToken } from "@/api/types";
export type UseSitesQueryResult = ReturnType<typeof useSitesQuery>;
@@ -52,3 +61,6 @@ export const useRequestsCountQuery = () =>
export const useEnrollmentRequestsMutation = (
options: UseMutationOptions<unknown, unknown, PostEnrollmentRequestsData, unknown>,
) => useMutation(patchEnrollmentRequests, options);
+
+export type LoginError = AxiosError<{ detail?: string }>;
+export const useLoginMutation = (): UseMutationResult<AccessToken, LoginError, PostLoginData> => useMutation(postLogin);
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 785c02c..6f3f20a 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -8,7 +8,11 @@ import { isDev } from "./constants";
/* c8 ignore next 4 */
if (isDev) {
const { worker } = await import("./mocks/browser");
- await worker.start();
+ await worker.start({
+ onUnhandledRequest(req) {
+ console.error("Found an unhandled %s request to %s", req.method, req.url.href);
+ },
+ });
}
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
diff --git a/frontend/src/mocks/browser.ts b/frontend/src/mocks/browser.ts
index 0bc74ac..9b18bbb 100644
--- a/frontend/src/mocks/browser.ts
+++ b/frontend/src/mocks/browser.ts
@@ -1,5 +1,19 @@
import { setupWorker } from "msw";
-import { getSites, getTokens, getEnrollmentRequests, patchEnrollmentRequests, postTokens } from "./resolvers";
+import {
+ postLogin,
+ getSites,
+ getTokens,
+ getEnrollmentRequests,
+ patchEnrollmentRequests,
+ postTokens,
+} from "./resolvers";
-export const worker = setupWorker(getSites, postTokens, getEnrollmentRequests, patchEnrollmentRequests, getTokens);
+export const worker = setupWorker(
+ postLogin,
+ getSites,
+ postTokens,
+ getEnrollmentRequests,
+ patchEnrollmentRequests,
+ getTokens,
+);
diff --git a/frontend/src/mocks/factories.ts b/frontend/src/mocks/factories.ts
index 3daf037..85894dd 100644
--- a/frontend/src/mocks/factories.ts
+++ b/frontend/src/mocks/factories.ts
@@ -2,7 +2,7 @@ import Chance from "chance";
import { Factory } from "fishery";
import { uniqueNamesGenerator, adjectives, colors, animals } from "unique-names-generator";
-import type { EnrollmentRequest, PaginatedQueryResult, Site, Token } from "@/api/types";
+import type { AccessToken, EnrollmentRequest, PaginatedQueryResult, Site, Token } from "@/api/types";
export const connections: Site["connection"][] = ["stable", "lost", "unknown"];
@@ -58,6 +58,14 @@ export const tokenFactory = Factory.define<Token>(({ sequence }) => {
};
});
+export const accessTokenFactory = Factory.define<AccessToken>(({ sequence }) => {
+ const chance = new Chance(`maas-${sequence}`);
+ return {
+ access_token: chance.hash({ length: 64 }),
+ token_type: "bearer",
+ };
+});
+
export const enrollmentRequestFactory = Factory.define<EnrollmentRequest>(({ sequence }) => {
const chance = new Chance(`maas-${sequence}`);
const name = uniqueNamesGenerator({
diff --git a/frontend/src/mocks/resolvers.ts b/frontend/src/mocks/resolvers.ts
index f35c0ad..385d828 100644
--- a/frontend/src/mocks/resolvers.ts
+++ b/frontend/src/mocks/resolvers.ts
@@ -1,17 +1,33 @@
import { rest } from "msw";
import type { RestRequest, restContext, ResponseResolver } from "msw";
-import { siteFactory, tokenFactory, enrollmentRequestFactory } from "./factories";
+import { siteFactory, tokenFactory, enrollmentRequestFactory, accessTokenFactory } from "./factories";
import type { GetSitesQueryParams, PostTokensData } from "@/api/handlers";
import urls from "@/api/urls";
+import { isDev } from "@/constants";
+export const mockResponseDelay = isDev ? 0 : 0;
export const sitesList = siteFactory.buildList(155);
-export const tokensList = tokenFactory.buildList(100);
+export const tokensList = tokenFactory.buildList(150);
export const enrollmentRequestsList = [
enrollmentRequestFactory.build({ created: undefined }),
...enrollmentRequestFactory.buildList(100),
];
+const accessToken = accessTokenFactory.build();
+
+export const createMockLoginResolver =
+ (): ResponseResolver<RestRequest<any, any>, typeof restContext> => async (req, res, ctx) => {
+ const { username, password } = await req.body;
+ if (username === "admin" && password === "test") {
+ return res(ctx.json(accessToken));
+ }
+ return res(
+ ctx.status(401),
+ ctx.set("WWW-Authenticate", "Bearer"),
+ ctx.json({ detail: "Incorrect username or password" }),
+ );
+ };
type SitesResponseResolver = ResponseResolver<RestRequest<never, GetSitesQueryParams>, typeof restContext>;
export const createMockSitesResolver =
@@ -91,6 +107,7 @@ export const createMockPostEnrollmentRequestsResolver =
}
};
+export const postLogin = rest.post(urls.login, createMockLoginResolver());
export const getSites = rest.get(urls.sites, createMockSitesResolver());
export const postTokens = rest.post(urls.tokens, createMockTokensResolver());
export const getTokens = rest.get(urls.tokens, createMockGetTokensResolver());
diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx
index 67e9b12..cdfea33 100644
--- a/frontend/src/pages/login.tsx
+++ b/frontend/src/pages/login.tsx
@@ -1,29 +1,7 @@
-import { Col, Strip } from "@canonical/react-components";
-
import LoginForm from "@/components/LoginForm";
-import NavigationBanner from "@/components/Navigation/NavigationBanner";
const Login: React.FC = () => {
- return (
- <div className="l-application">
- <header className="l-navigation-bar is-pinned">
- <div className="p-panel is-dark">
- <div className="p-panel__header">
- <NavigationBanner />
- </div>
- </div>
- </header>
- <main className="l-main">
- <div>
- <Strip element="section" includeCol={false} shallow>
- <Col size={12}>
- <LoginForm />
- </Col>
- </Strip>
- </div>
- </main>
- </div>
- );
+ return <LoginForm />;
};
export default Login;
diff --git a/frontend/src/pages/logout.tsx b/frontend/src/pages/logout.tsx
index d79c65b..6b09b82 100644
--- a/frontend/src/pages/logout.tsx
+++ b/frontend/src/pages/logout.tsx
@@ -1,7 +1,14 @@
-const Logout: React.FC = () => (
- <section>
- <h2>Logout</h2>
- </section>
-);
+import { useAuthContext } from "@/context";
+
+const Logout = () => {
+ const navigate = useNavigate();
+ const { logout } = useAuthContext();
+
+ useEffect(() => {
+ logout(() => navigate("/login"));
+ }, [navigate, logout]);
+
+ return null;
+};
export default Logout;
diff --git a/frontend/src/routes.test.tsx b/frontend/src/routes.test.tsx
index d8dd90b..8607e87 100644
--- a/frontend/src/routes.test.tsx
+++ b/frontend/src/routes.test.tsx
@@ -1,8 +1,9 @@
import { createMemoryRouter, RouterProvider } from "react-router-dom";
import { allResolvers } from "./mocks/resolvers";
-import routes, { routesConfig } from "./routes";
+import { routesConfig } from "@/base/routesConfig";
+import routes from "@/routes";
import { render, waitFor, setupServer } from "@/test-utils";
const mockServer = setupServer(...allResolvers);
@@ -10,6 +11,7 @@ const mockServer = setupServer(...allResolvers);
describe("router", () => {
beforeAll(() => {
mockServer.listen();
+ localStorage.setItem("jwtToken", "test");
});
afterEach(() => {
mockServer.resetHandlers();
diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx
deleted file mode 100644
index 5d196d5..0000000
--- a/frontend/src/routes.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import { createRoutesFromElements, Route, redirect } from "react-router-dom";
-
-import MainLayout from "@/components/MainLayout";
-import Login from "@/pages/login";
-import Requests from "@/pages/requests";
-import SitesList from "@/pages/sites";
-import Tokens from "@/pages/tokens/tokens";
-
-export const routesConfig = {
- sites: {
- path: "/sites",
- title: "Regions",
- },
- requests: {
- path: "/requests",
- title: "Requests",
- },
- tokens: {
- path: "/tokens",
- title: "Tokens",
- },
-} as const;
-
-type RoutesConfig = typeof routesConfig;
-export type RoutePath = RoutesConfig[keyof RoutesConfig]["path"];
-
-export const routes = createRoutesFromElements(
- <>
- <Route element={<Login />} path="/login" />
- <Route element={<MainLayout />} path="/">
- {/*
- TODO: redirect to /login when unauthenticated
- https://warthogs.atlassian.net/browse/MAASENG-1450
- */}
- <Route index loader={() => redirect("sites")} />
- <Route path="logout" />
- <Route element={<SitesList />} path="sites" />
- <Route element={<Requests />} path="requests" />
- <Route element={<Tokens />} path="tokens" />
- <Route path="users" />
- </Route>
- ,
- </>,
-);
-
-export default routes;
diff --git a/frontend/src/routes/RequireLogin/RequireLogin.test.tsx b/frontend/src/routes/RequireLogin/RequireLogin.test.tsx
new file mode 100644
index 0000000..b9a2278
--- /dev/null
+++ b/frontend/src/routes/RequireLogin/RequireLogin.test.tsx
@@ -0,0 +1,20 @@
+import * as reactRouter from "react-router";
+import { vi } from "vitest";
+
+import RequireLogin from "./RequireLogin";
+
+import api from "@/api";
+import { AuthContextProvider } from "@/context";
+import { renderWithMemoryRouter } from "@/test-utils";
+
+it("should redirect to login page if user is not authenticated", () => {
+ const navigate = vi.fn();
+ vi.spyOn(reactRouter, "useNavigate").mockImplementation(() => navigate);
+ renderWithMemoryRouter(
+ <AuthContextProvider apiClient={api}>
+ <RequireLogin />
+ </AuthContextProvider>,
+ { initialEntries: ["/sites"] },
+ );
+ expect(navigate).toHaveBeenCalledWith({ pathname: "/login", search: "?redirectTo=%2Fsites" });
+});
diff --git a/frontend/src/routes/RequireLogin/RequireLogin.tsx b/frontend/src/routes/RequireLogin/RequireLogin.tsx
new file mode 100644
index 0000000..3c63157
--- /dev/null
+++ b/frontend/src/routes/RequireLogin/RequireLogin.tsx
@@ -0,0 +1,28 @@
+import { useEffect } from "react";
+
+import { createSearchParams, useLocation, useNavigate } from "react-router-dom";
+
+import { useAuthContext } from "@/context";
+
+const RequireLogin = ({ children }: { children?: React.ReactNode }) => {
+ const location = useLocation();
+ const navigate = useNavigate();
+ const { status } = useAuthContext();
+
+ useEffect(() => {
+ if (status === "unauthorised") {
+ navigate({
+ pathname: "/login",
+ search: `?${createSearchParams({ redirectTo: location.pathname })}`,
+ });
+ }
+ }, [location, navigate, status]);
+
+ if (status !== "authenticated") {
+ return null;
+ }
+
+ return <>{children}</>;
+};
+
+export default RequireLogin;
diff --git a/frontend/src/routes/RequireLogin/index.ts b/frontend/src/routes/RequireLogin/index.ts
new file mode 100644
index 0000000..dd0bd31
--- /dev/null
+++ b/frontend/src/routes/RequireLogin/index.ts
@@ -0,0 +1 @@
+export { default } from "./RequireLogin";
diff --git a/frontend/src/routes/index.ts b/frontend/src/routes/index.ts
new file mode 100644
index 0000000..1f524d9
--- /dev/null
+++ b/frontend/src/routes/index.ts
@@ -0,0 +1 @@
+export { default } from "./routes";
diff --git a/frontend/src/routes/routes.tsx b/frontend/src/routes/routes.tsx
new file mode 100644
index 0000000..b1ca0de
--- /dev/null
+++ b/frontend/src/routes/routes.tsx
@@ -0,0 +1,45 @@
+import { createRoutesFromElements, Route, redirect } from "react-router-dom";
+
+import RequireLogin from "./RequireLogin";
+
+import MainLayout from "@/components/MainLayout";
+import Login from "@/pages/login";
+import Logout from "@/pages/logout";
+import Requests from "@/pages/requests";
+import SitesList from "@/pages/sites";
+import Tokens from "@/pages/tokens/tokens";
+
+export const routes = createRoutesFromElements(
+ <Route element={<MainLayout />} path="/">
+ <Route index loader={() => redirect("sites")} />
+ <Route element={<Logout />} path="logout" />
+ <Route element={<Login />} path="login" />
+ <Route
+ element={
+ <RequireLogin>
+ <SitesList />
+ </RequireLogin>
+ }
+ path="sites"
+ />
+ <Route
+ element={
+ <RequireLogin>
+ <Requests />
+ </RequireLogin>
+ }
+ path="requests"
+ />
+ <Route
+ element={
+ <RequireLogin>
+ <Tokens />
+ </RequireLogin>
+ }
+ path="tokens"
+ />
+ <Route path="users" />
+ </Route>,
+);
+
+export default routes;
diff --git a/frontend/src/test-utils.tsx b/frontend/src/test-utils.tsx
index 3152211..2d72a39 100644
--- a/frontend/src/test-utils.tsx
+++ b/frontend/src/test-utils.tsx
@@ -7,7 +7,9 @@ import { screen, render } from "@testing-library/react";
import type { MemoryRouterProps } from "react-router-dom";
import { MemoryRouter } from "react-router-dom";
-import { AppContextProvider } from "./context";
+import { AppContextProvider, AuthContextProvider } from "./context";
+
+import apiClient from "@/api";
const queryClient = new QueryClient({
defaultOptions: {
@@ -28,7 +30,9 @@ const makeProvidersWithMemoryRouter =
return (
<Providers>
<MemoryRouter {...memoryRouterProps}>
- <AppContextProvider>{children}</AppContextProvider>
+ <AppContextProvider>
+ <AuthContextProvider apiClient={apiClient}>{children}</AuthContextProvider>
+ </AppContextProvider>
</MemoryRouter>
</Providers>
);
diff --git a/frontend/tests/authentication.spec.ts b/frontend/tests/authentication.spec.ts
new file mode 100644
index 0000000..676ccdb
--- /dev/null
+++ b/frontend/tests/authentication.spec.ts
@@ -0,0 +1,33 @@
+import { protectedRoutes, routesConfig } from "@/base/routesConfig";
+import { test, expect, Page } from "@playwright/test";
+
+const login = async ({ page }: { page: Page }) => {
+ await page.getByRole("textbox", { name: "Username" }).type("admin");
+ await page.getByRole("textbox", { name: "Password" }).type("test");
+ await page.getByRole("button", { name: "Login" }).click();
+};
+
+test.afterEach(async ({ page }) => {
+ await page.getByRole("link", { name: "Log out" }).click();
+});
+
+Object.keys(protectedRoutes).forEach((url) => {
+ test(`user is redirected to login page when attempting to visit ${url}`, async ({ page }) => {
+ await page.goto(url);
+ await expect(page).toHaveURL(`${routesConfig.login.path}?redirectTo=${encodeURIComponent("/" + url)}`);
+ await login({ page });
+ await expect(page).toHaveURL(url);
+ });
+});
+
+test("user is redirected to enrolled sites list after login", async ({ page }) => {
+ await page.goto(routesConfig.login.path);
+ await login({ page });
+ await expect(page).toHaveURL(routesConfig.sites.path);
+});
+
+test("user is redirected to the URL they wanted to visit", async ({ page }) => {
+ await page.goto(routesConfig.requests.path);
+ await login({ page });
+ await expect(page).toHaveURL(routesConfig.requests.path);
+});
Follow ups
-
[Merge] ~petermakowski/maas-site-manager:setup-authentication-MAASENG-1521 into maas-site-manager:main
From: MAAS Lander, 2023-04-12
-
[Merge] ~petermakowski/maas-site-manager:setup-authentication-MAASENG-1521 into maas-site-manager:main
From: Peter Makowski, 2023-04-12
-
Re: [Merge] ~petermakowski/maas-site-manager:setup-authentication-MAASENG-1521 into maas-site-manager:main
From: Nick De Villiers, 2023-04-12
-
Re: [UNITTESTS] -b setup-authentication-MAASENG-1521 lp:~petermakowski/maas-site-manager/+git/site-manager into -b main lp:~maas-committers/maas-site-manager - TESTS PASS
From: MAAS Lander, 2023-04-12
-
Re: [UNITTESTS] -b setup-authentication-MAASENG-1521 lp:~petermakowski/maas-site-manager/+git/site-manager into -b main lp:~maas-committers/maas-site-manager - TESTS FAILED
From: MAAS Lander, 2023-04-12
-
[Merge] ~petermakowski/maas-site-manager:setup-authentication-MAASENG-1521 into maas-site-manager:main
From: Peter Makowski, 2023-04-12
-
Re: [UNITTESTS] -b setup-authentication-MAASENG-1521 lp:~petermakowski/maas-site-manager/+git/site-manager into -b main lp:~maas-committers/maas-site-manager - TESTS FAILED
From: MAAS Lander, 2023-04-12
-
Re: [Merge] ~petermakowski/maas-site-manager:setup-authentication-MAASENG-1521 into maas-site-manager:main
From: Nick De Villiers, 2023-04-12
-
[Merge] ~petermakowski/maas-site-manager:setup-authentication-MAASENG-1521 into maas-site-manager:main
From: Peter Makowski, 2023-04-12
-
[Merge] ~petermakowski/maas-site-manager:setup-authentication-MAASENG-1521 into maas-site-manager:main
From: Peter Makowski, 2023-04-12
-
Re: [UNITTESTS] -b setup-authentication-MAASENG-1521 lp:~petermakowski/maas-site-manager/+git/site-manager into -b main lp:~maas-committers/maas-site-manager - TESTS FAILED
From: MAAS Lander, 2023-04-11