diff --git a/ui/src/App.tsx b/ui/src/App.tsx index f8c32ec4e..a39358f6d 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -13,9 +13,13 @@ import SchemaList from "pages/schemas/SchemaList"; import Layout from "components/Layout/Layout"; import { queryKeys } from "util/queryKeys"; import { axiosInstance } from "./api/axios"; +import { urls } from "urls"; +import { useNext } from "util/useNext"; const App: FC = () => { const queryClient = useQueryClient(); + // Redirect to the ?next=/... URL returned by the authentication step. + useNext(); useRef( axiosInstance.interceptors.response.use( @@ -43,14 +47,17 @@ const App: FC = () => { return ( - }> - } /> - } /> - } /> - } /> - } /> + }> } + /> + } /> + } /> + } /> + } /> + { screen.getByRole("link", { name: "Sign in to Identity platform" }), ).toBeInTheDocument(); }); + +test("passes the current path to the 'next' param", () => { + renderComponent(, { url: "/test" }); + expect( + screen + .getByRole("link", { name: "Sign in to Identity platform" }) + .getAttribute("href") + ?.endsWith("?next=%2ftest"), + ); +}); diff --git a/ui/src/components/Login/Login.tsx b/ui/src/components/Login/Login.tsx index 71aa5100e..c36e93f2e 100644 --- a/ui/src/components/Login/Login.tsx +++ b/ui/src/components/Login/Login.tsx @@ -10,6 +10,8 @@ import { FC, ReactNode } from "react"; import { SITE_NAME } from "consts"; import { Label } from "./types"; import { appendAPIBasePath } from "util/basePaths"; +import { useLocation } from "react-router-dom"; +import { getURLKey } from "util/getURLKey"; type Props = { isLoading?: boolean; @@ -17,6 +19,7 @@ type Props = { }; const Login: FC = ({ error, isLoading }) => { + const location = useLocation(); let loginContent: ReactNode; if (isLoading) { loginContent = ; @@ -32,6 +35,7 @@ const Login: FC = ({ error, isLoading }) => { /> ); } else { + const path = getURLKey(location.pathname); loginContent = ( diff --git a/ui/src/index.tsx b/ui/src/index.tsx index 7afedf917..992ef767e 100644 --- a/ui/src/index.tsx +++ b/ui/src/index.tsx @@ -5,6 +5,7 @@ import App from "./App"; import "./sass/styles.scss"; import { NotificationProvider } from "@canonical/react-components"; import { basePath } from "util/basePaths"; +import { removeTrailingSlash } from "util/removeTrailingSlash"; const queryClient = new QueryClient({ defaultOptions: { @@ -21,7 +22,7 @@ const rootElement = document.getElementById("app"); if (!rootElement) throw new Error("Failed to find the root element"); const root = createRoot(rootElement); root.render( - + diff --git a/ui/src/test/ComponentProviders.tsx b/ui/src/test/ComponentProviders.tsx index 520e03eca..637739a32 100644 --- a/ui/src/test/ComponentProviders.tsx +++ b/ui/src/test/ComponentProviders.tsx @@ -1,15 +1,31 @@ import { QueryClient } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query"; -import { type PropsWithChildren } from "react"; -import type { RouteObject } from "react-router-dom"; -import { createBrowserRouter, RouterProvider } from "react-router-dom"; +import { useEffect, type PropsWithChildren } from "react"; +import type { Location, RouteObject } from "react-router-dom"; +import { + createBrowserRouter, + RouterProvider, + useLocation, +} from "react-router-dom"; export type ComponentProps = { path: string; routeChildren?: RouteObject[]; queryClient?: QueryClient; + setLocation?: (location: Location) => void; } & PropsWithChildren; +const Wrapper = ({ + children, + setLocation, +}: PropsWithChildren & { setLocation?: (location: Location) => void }) => { + const location = useLocation(); + useEffect(() => { + setLocation?.(location); + }, [location, setLocation]); + return children; +}; + const ComponentProviders = ({ children, routeChildren, @@ -21,17 +37,18 @@ const ComponentProviders = ({ }, }, }), + setLocation, }: ComponentProps) => { const router = createBrowserRouter([ { path, - element: children, + element: {children}, children: routeChildren, }, { // Capture other paths to prevent warnings when navigating in tests. path: "*", - element: , + element: , }, ]); return ( diff --git a/ui/src/test/utils.tsx b/ui/src/test/utils.tsx index 7fa77257c..bc86fcb6c 100644 --- a/ui/src/test/utils.tsx +++ b/ui/src/test/utils.tsx @@ -1,5 +1,5 @@ import { QueryClient } from "@tanstack/react-query"; -import { render } from "@testing-library/react"; +import { render, renderHook } from "@testing-library/react"; import ComponentProviders from "./ComponentProviders"; import type { ComponentProps } from "./ComponentProviders"; @@ -9,6 +9,7 @@ type Options = { path?: string; routeChildren?: ComponentProps["routeChildren"]; queryClient?: QueryClient; + setLocation?: ComponentProps["setLocation"]; }; export const changeURL = (url: string) => window.happyDOM.setURL(url); @@ -37,6 +38,27 @@ export const renderComponent = ( routeChildren={options?.routeChildren} path={options?.path ?? "*"} queryClient={queryClient} + setLocation={options?.setLocation} + /> + ), + }); + return { changeURL, result, queryClient }; +}; + +export const renderWrappedHook = ( + hook: (initialProps: Props) => Result, + options?: Options | null, +) => { + const queryClient = getQueryClient(options); + changeURL(options?.url ?? "/"); + const { result } = renderHook(hook, { + wrapper: (props) => ( + ), }); diff --git a/ui/src/urls.ts b/ui/src/urls.ts new file mode 100644 index 000000000..3b055fd78 --- /dev/null +++ b/ui/src/urls.ts @@ -0,0 +1,21 @@ +import { urls as rebacURLs } from "@canonical/rebac-admin"; + +const rebacAdminURLS = rebacURLs("/"); + +export const urls = { + clients: { + index: "/client", + }, + groups: rebacAdminURLS.groups, + identities: { + index: "/identity", + }, + index: "/", + providers: { + index: "/provider", + }, + roles: rebacAdminURLS.roles, + schemas: { + index: "/schema", + }, +}; diff --git a/ui/src/util/basePaths.spec.ts b/ui/src/util/basePaths.spec.ts index adaf21769..367513a7a 100644 --- a/ui/src/util/basePaths.spec.ts +++ b/ui/src/util/basePaths.spec.ts @@ -21,6 +21,12 @@ describe("calculateBasePath", () => { expect(result).toBe("/ui/"); }); + it("resolves with ui path without trailing slash", () => { + vi.stubGlobal("location", { pathname: "/ui" }); + const result = calculateBasePath(); + expect(result).toBe("/ui/"); + }); + it("resolves with ui path and discards detail page location", () => { vi.stubGlobal("location", { pathname: "/ui/foo/bar" }); const result = calculateBasePath(); @@ -44,6 +50,12 @@ describe("calculateBasePath", () => { const result = calculateBasePath(); expect(result).toBe("/"); }); + + it("resolves with root path for partial ui substrings", () => { + vi.stubGlobal("location", { pathname: "/prefix/uipartial" }); + const result = calculateBasePath(); + expect(result).toBe("/"); + }); }); describe("appendBasePath", () => { diff --git a/ui/src/util/basePaths.ts b/ui/src/util/basePaths.ts index ae6f2127a..baa7e492f 100644 --- a/ui/src/util/basePaths.ts +++ b/ui/src/util/basePaths.ts @@ -1,11 +1,12 @@ +import { removeTrailingSlash } from "util/removeTrailingSlash"; type BasePath = `/${string}`; export const calculateBasePath = (): BasePath => { const path = window.location.pathname; // find first occurrence of /ui/ and return the string before it - const basePath = path.match(/(.*\/ui\/)/); + const basePath = path.match(/(.*\/ui(?:\/|$))/); if (basePath) { - return basePath[0] as BasePath; + return `${removeTrailingSlash(basePath[0])}/` as BasePath; } return "/"; }; @@ -14,7 +15,7 @@ export const basePath: BasePath = calculateBasePath(); export const apiBasePath: BasePath = `${basePath}../api/v0/`; export const appendBasePath = (path: string) => - `${basePath.replace(/\/$/, "")}/${path.replace(/^\//, "")}`; + `${removeTrailingSlash(basePath)}/${path.replace(/^\//, "")}`; export const appendAPIBasePath = (path: string) => - `${apiBasePath.replace(/\/$/, "")}/${path.replace(/^\//, "")}`; + `${removeTrailingSlash(apiBasePath)}/${path.replace(/^\//, "")}`; diff --git a/ui/src/util/getDomain.test.ts b/ui/src/util/getDomain.test.ts new file mode 100644 index 000000000..38063c5be --- /dev/null +++ b/ui/src/util/getDomain.test.ts @@ -0,0 +1,9 @@ +import { getDomain } from "./getDomain"; + +test("handles extracting domain", () => { + expect(getDomain("http://example.com/?next=/here#hash")).toBe("example.com"); +}); + +test("handles no domain", () => { + expect(getDomain("/a/path")).toBeUndefined(); +}); diff --git a/ui/src/util/getDomain.ts b/ui/src/util/getDomain.ts new file mode 100644 index 000000000..91bd6d81e --- /dev/null +++ b/ui/src/util/getDomain.ts @@ -0,0 +1,2 @@ +// Extract the domain from the URL. +export const getDomain = (url: string) => url.match(/(?<=.+:\/\/)[^/]+/)?.[0]; diff --git a/ui/src/util/getFullPath.test.ts b/ui/src/util/getFullPath.test.ts new file mode 100644 index 000000000..34ae15763 --- /dev/null +++ b/ui/src/util/getFullPath.test.ts @@ -0,0 +1,25 @@ +import { getFullPath } from "./getFullPath"; + +vi.mock("./basePaths", async () => { + const actual = await vi.importActual("./basePaths"); + return { + ...actual, + basePath: "/example/ui/", + }; +}); + +test("handles paths with query and hash", () => { + expect(getFullPath("http://example.com/?next=/here#hash")).toBe( + "/?next=/here#hash", + ); +}); + +test("removes base", () => { + expect( + getFullPath("http://example.com/example/ui/roles/?next=/here#hash", true), + ).toBe("/roles/?next=/here#hash"); +}); + +test("handles no path", () => { + expect(getFullPath("http://example.com/")).toBeUndefined(); +}); diff --git a/ui/src/util/getFullPath.ts b/ui/src/util/getFullPath.ts new file mode 100644 index 000000000..dad3f2706 --- /dev/null +++ b/ui/src/util/getFullPath.ts @@ -0,0 +1,10 @@ +import { basePath } from "./basePaths"; +import { removeTrailingSlash } from "./removeTrailingSlash"; + +// Extract the path from the URL including query params, hash etc. +export const getFullPath = (url: string, removeBase = false) => { + const path = url.match(/(? { + expect(getURLKey(urls.clients.index)).toBe("clients"); +}); + +test("handles named paths", () => { + expect(getURLKey(urls.roles.add)).toBe("roles.add"); +}); + +test("handles functions", () => { + expect(getURLKey(urls.roles.edit({ id: "role1" }))).toBe("roles.edit"); +}); + +test("handles paths that don't exist", () => { + expect(getURLKey("/nothing/here")).toBeNull(); +}); diff --git a/ui/src/util/getURLKey.ts b/ui/src/util/getURLKey.ts new file mode 100644 index 000000000..161a7e422 --- /dev/null +++ b/ui/src/util/getURLKey.ts @@ -0,0 +1,43 @@ +import { matchPath } from "react-router-dom"; +import { urls } from "urls"; + +export type URLS = { + [key: string]: + | string + | URLS + | ((args: unknown, relativeTo?: string) => string); +}; + +const findPath = ( + sections: URLS, + pathname: string, + keyPath = "", +): string | null => { + const entries = Object.entries(sections); + let path: string | null = null; + for (const entry of entries) { + const [key, section] = entry; + const thisPath = [keyPath, key].filter(Boolean).join("."); + if (typeof section === "string" && section === pathname) { + path = thisPath; + break; + } else if ( + typeof section === "function" && + !!matchPath(section(null), pathname) + ) { + path = thisPath; + break; + } else if (typeof section === "object") { + const matchingPath = findPath(section, pathname, thisPath); + if (matchingPath) { + path = matchingPath; + break; + } + } + } + // Don't expose the index key. The index is handled when the path is returned + // in the useNext hook. + return path ? path.replace(/\.index$/, "") : null; +}; + +export const getURLKey = (pathname: string) => findPath(urls as URLS, pathname); diff --git a/ui/src/util/keyToPath.test.ts b/ui/src/util/keyToPath.test.ts new file mode 100644 index 000000000..5230e1863 --- /dev/null +++ b/ui/src/util/keyToPath.test.ts @@ -0,0 +1,21 @@ +import { urls } from "urls"; +import { keyToPath } from "./keyToPath"; + +test("handles indexes", () => { + expect(keyToPath("clients")).toBe(urls.clients.index); +}); + +test("handles named routes", () => { + expect(keyToPath("roles.add")).toBe(urls.roles.add); +}); + +test("handles functions", () => { + expect(keyToPath("roles.edit")).toBe(urls.roles.edit(null)); +}); + +test("handles keys that don't exist", () => { + expect(keyToPath("roles.nothing")).toBeNull(); +}); +test("handles malformed keys", () => { + expect(keyToPath("alert('you have been hacked!');")).toBeNull(); +}); diff --git a/ui/src/util/keyToPath.ts b/ui/src/util/keyToPath.ts new file mode 100644 index 000000000..14780dab2 --- /dev/null +++ b/ui/src/util/keyToPath.ts @@ -0,0 +1,39 @@ +import { urls } from "urls"; +import { URLS } from "./getURLKey"; +import { ValueOf } from "@canonical/react-components"; + +export const keyToPath = (path: string) => { + // Restrict the key to safe values. + if (!path.match(/^[a-z|.]+$/)) { + return null; + } + let currentSection = urls as ValueOf; + const keys = path.split("."); + for (const [index, section] of keys.entries()) { + const isLast = index === keys.length - 1; + if (typeof currentSection === "object") { + if (section in currentSection) { + currentSection = currentSection[section]; + } else if (isLast) { + // If this is the last item and the object doesn't contain the key then + // exit and return null. + break; + } + } + // If this is the last item in the key path then try to get the URL. + if (isLast) { + // The trailing .index is removed from the key path so if the path leads + // to a object then get the index. + if (typeof currentSection === "object" && "index" in currentSection) { + currentSection = currentSection.index; + } + if (typeof currentSection === "string") { + return currentSection; + } else if (typeof currentSection === "function") { + return currentSection(null); + } + break; + } + } + return null; +}; diff --git a/ui/src/util/removeTrailingSlash.test.ts b/ui/src/util/removeTrailingSlash.test.ts new file mode 100644 index 000000000..eb0ed6f91 --- /dev/null +++ b/ui/src/util/removeTrailingSlash.test.ts @@ -0,0 +1,9 @@ +import { removeTrailingSlash } from "./removeTrailingSlash"; + +test("removes trailing slash", () => { + expect(removeTrailingSlash("/trailing/")).toBe("/trailing"); +}); + +test("handles no trailing slash", () => { + expect(removeTrailingSlash("/no-trailing")).toBe("/no-trailing"); +}); diff --git a/ui/src/util/removeTrailingSlash.ts b/ui/src/util/removeTrailingSlash.ts new file mode 100644 index 000000000..adb828f9c --- /dev/null +++ b/ui/src/util/removeTrailingSlash.ts @@ -0,0 +1 @@ +export const removeTrailingSlash = (path: string) => path.replace(/\/$/, ""); diff --git a/ui/src/util/useNext.test.ts b/ui/src/util/useNext.test.ts new file mode 100644 index 000000000..d9cd2ce93 --- /dev/null +++ b/ui/src/util/useNext.test.ts @@ -0,0 +1,46 @@ +import { renderWrappedHook } from "test/utils"; +import { useNext } from "./useNext"; +import { Location } from "react-router-dom"; + +vi.mock("./basePaths", async () => { + const actual = await vi.importActual("./basePaths"); + return { + ...actual, + basePath: "/example/ui/", + }; +}); + +test("handles the 'next' path param", () => { + let location: Location | null = null; + renderWrappedHook(() => useNext(), { + url: "/example/ui/?next=clients", + setLocation: (newLocation) => { + location = newLocation; + }, + }); + expect((location as Location | null)?.pathname).toBe("/client"); +}); + +test("handles no 'next' param", () => { + let location: Location | null = null; + renderWrappedHook(() => useNext(), { + url: "/example/ui/current/?search=query", + setLocation: (newLocation) => { + location = newLocation; + }, + }); + expect((location as Location | null)?.pathname).toBe("/example/ui/current/"); + expect((location as Location | null)?.search).toBe("?search=query"); +}); + +test("no redirect if the next param matches the current page", () => { + let location: Location | null = null; + renderWrappedHook(() => useNext(), { + url: "/example/ui/client/?next=clients", + setLocation: (newLocation) => { + location = newLocation; + }, + }); + expect((location as Location | null)?.pathname).toBe("/client"); + expect((location as Location | null)?.search).toBe(""); +}); diff --git a/ui/src/util/useNext.ts b/ui/src/util/useNext.ts new file mode 100644 index 000000000..4e22cef04 --- /dev/null +++ b/ui/src/util/useNext.ts @@ -0,0 +1,31 @@ +import { removeTrailingSlash } from "util/removeTrailingSlash"; +import { keyToPath } from "./keyToPath"; +import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; +import { useEffect } from "react"; + +export const useNext = () => { + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + const location = useLocation(); + + useEffect(() => { + const next = searchParams.get("next"); + if (!next) { + return; + } + const path = keyToPath(next); + if ( + // Don't redirect if a path matching the next key wasn't found. + !path || + // Don't redirect if the 'next' param is the same as the current path. + removeTrailingSlash(path) === removeTrailingSlash(location.pathname) + ) { + // Remove the query string from the URL as we don't need to do anything with + //the 'next' param. + searchParams.delete("next"); + setSearchParams(searchParams); + return; + } + navigate(path, { replace: true }); + }, [location, searchParams]); +};