From cab790dd2a5d3ea2cad49c5ae517e3318a2a30f8 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 19 Apr 2026 11:33:30 +0200 Subject: [PATCH] Redirect clients to the clients app --- .../components/AuthenticationGateway.test.tsx | 67 ++++++++++ .../src/components/AuthenticationGateway.tsx | 14 ++- .../components/AuthenticationGateway.unit.tsx | 116 +++++++++++++++--- .../src/components/ProtectedRoute.test.tsx | 45 +++++++ apps/main/src/components/ProtectedRoute.tsx | 19 ++- apps/main/src/lib/clientPortal.ts | 24 ++++ 6 files changed, 264 insertions(+), 21 deletions(-) create mode 100644 apps/main/src/components/AuthenticationGateway.test.tsx create mode 100644 apps/main/src/lib/clientPortal.ts diff --git a/apps/main/src/components/AuthenticationGateway.test.tsx b/apps/main/src/components/AuthenticationGateway.test.tsx new file mode 100644 index 0000000..655a495 --- /dev/null +++ b/apps/main/src/components/AuthenticationGateway.test.tsx @@ -0,0 +1,67 @@ +import { screen, waitFor } from "@testing-library/react"; +import { AuthenticationGateway } from "@ui/components/AuthenticationGateway"; +import { SessionTestProvider } from "@xtablo/shared/contexts/SessionContext"; +import { Route, Routes } from "react-router-dom"; +import { vi } from "vitest"; +import { TestUserStoreProvider } from "../providers/UserStoreProvider"; +import { renderWithRouter } from "../utils/testHelpers"; + +const { redirectClientUserToPortal } = vi.hoisted(() => ({ + redirectClientUserToPortal: vi.fn(), +})); + +vi.mock("../lib/clientPortal", () => ({ + redirectClientUserToPortal, +})); + +describe("AuthenticationGateway", () => { + it("redirects authenticated client users to the client portal instead of main auth pages", async () => { + renderWithRouter( + + + + }> + Login Page} /> + + + + , + { route: "/login" } + ); + + await waitFor(() => { + expect(redirectClientUserToPortal).toHaveBeenCalledWith("/"); + }); + expect(screen.queryByText("Login Page")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/main/src/components/AuthenticationGateway.tsx b/apps/main/src/components/AuthenticationGateway.tsx index 4c8e124..ba3a1b5 100644 --- a/apps/main/src/components/AuthenticationGateway.tsx +++ b/apps/main/src/components/AuthenticationGateway.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { Navigate, Outlet, useSearchParams } from "react-router-dom"; import { match } from "ts-pattern"; +import { redirectClientUserToPortal } from "../lib/clientPortal"; import { useMaybeUser } from "../providers/UserStoreProvider"; import { LoadingSpinner } from "./LoadingSpinner"; @@ -22,9 +23,11 @@ export const AuthenticationGateway = () => { return () => clearTimeout(timer); }, [user]); - let status: "loading" | "should-redirect" | "should-pass" = "loading"; + let status: "loading" | "should-redirect" | "should-redirect-client" | "should-pass" = "loading"; if (isLoading) { status = "loading"; + } else if (user?.is_client) { + status = "should-redirect-client"; } else if (user) { status = "should-redirect"; } else { @@ -34,6 +37,15 @@ export const AuthenticationGateway = () => { return match(status) .with("loading", () => ) .with("should-redirect", () => ) + .with("should-redirect-client", () => ) .with("should-pass", () => ) .exhaustive(); }; + +const ClientPortalRedirect = () => { + useEffect(() => { + redirectClientUserToPortal("/"); + }, []); + + return ; +}; diff --git a/apps/main/src/components/AuthenticationGateway.unit.tsx b/apps/main/src/components/AuthenticationGateway.unit.tsx index ee07143..77515e0 100644 --- a/apps/main/src/components/AuthenticationGateway.unit.tsx +++ b/apps/main/src/components/AuthenticationGateway.unit.tsx @@ -2,8 +2,18 @@ import { screen, waitFor } from "@testing-library/react"; import { AuthenticationGateway } from "@ui/components/AuthenticationGateway"; import { SessionTestProvider } from "@xtablo/shared/contexts/SessionContext"; import { Route, Routes } from "react-router-dom"; +import { vi } from "vitest"; +import { TestUserStoreProvider } from "../providers/UserStoreProvider"; import { renderWithRouter } from "../utils/testHelpers"; +const { redirectClientUserToPortal } = vi.hoisted(() => ({ + redirectClientUserToPortal: vi.fn(), +})); + +vi.mock("../lib/clientPortal", () => ({ + redirectClientUserToPortal, +})); + describe("PublicRoute", () => { it("shows loading state initially", () => { renderWithRouter( @@ -38,29 +48,47 @@ describe("PublicRoute", () => { it("redirect to home when user is authenticated", async () => { renderWithRouter( - - - }> - Login Page} /> - - Home Page} /> - - , + + + }> + Login Page} /> + + Home Page} /> + + + , { route: "/login" } ); @@ -70,6 +98,56 @@ describe("PublicRoute", () => { }); }); + it("redirects authenticated client users to the client portal instead of main auth pages", async () => { + renderWithRouter( + + + + }> + Login Page} /> + + + + , + { route: "/login" } + ); + + await waitFor(() => { + expect(redirectClientUserToPortal).toHaveBeenCalledWith("/"); + }); + expect(screen.queryByText("Login Page")).not.toBeInTheDocument(); + }); + it("renders public content when user is not authenticated", async () => { renderWithRouter( diff --git a/apps/main/src/components/ProtectedRoute.test.tsx b/apps/main/src/components/ProtectedRoute.test.tsx index c7e6375..3a1de85 100644 --- a/apps/main/src/components/ProtectedRoute.test.tsx +++ b/apps/main/src/components/ProtectedRoute.test.tsx @@ -1,10 +1,19 @@ import { screen, waitFor } from "@testing-library/react"; +import { vi } from "vitest"; import { ProtectedRoute } from "@ui/components/ProtectedRoute"; import { SessionTestProvider } from "@xtablo/shared/contexts/SessionContext"; import { Route, Routes } from "react-router-dom"; import { TestUserStoreProvider } from "../providers/UserStoreProvider"; import { renderWithRouter } from "../utils/testHelpers"; +const { redirectClientUserToPortal } = vi.hoisted(() => ({ + redirectClientUserToPortal: vi.fn(), +})); + +vi.mock("../lib/clientPortal", () => ({ + redirectClientUserToPortal, +})); + describe("ProtectedRoute", () => { beforeEach(() => { localStorage.setItem("xtablo-has-seen-landing-page", "true"); @@ -127,4 +136,40 @@ describe("ProtectedRoute", () => { expect(screen.getByText("Custom Login Page")).toBeInTheDocument(); }); }); + + it("redirects client users to the client portal instead of rendering main app content", async () => { + renderWithRouter( + + + + }> + Protected Content} /> + + + + , + { route: "/" } + ); + + await waitFor(() => { + expect(redirectClientUserToPortal).toHaveBeenCalledWith("/"); + }); + expect(screen.queryByText("Protected Content")).not.toBeInTheDocument(); + }); }); diff --git a/apps/main/src/components/ProtectedRoute.tsx b/apps/main/src/components/ProtectedRoute.tsx index adaeb24..c9c64a7 100644 --- a/apps/main/src/components/ProtectedRoute.tsx +++ b/apps/main/src/components/ProtectedRoute.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { Navigate, Outlet } from "react-router-dom"; import { match } from "ts-pattern"; +import { redirectClientUserToPortal } from "../lib/clientPortal"; import { useMaybeUser } from "../providers/UserStoreProvider"; import { LoadingSpinner } from "./LoadingSpinner"; @@ -28,7 +29,12 @@ export const ProtectedRoute = ({ return () => clearTimeout(timer); }, [user, fallback]); - let status: "loading" | "should-land-user" | "should-redirect" | "should-pass" = "loading"; + let status: + | "loading" + | "should-land-user" + | "should-redirect" + | "should-redirect-client" + | "should-pass" = "loading"; const isFirstTimeUser = localStorage.getItem("xtablo-has-seen-landing-page") === null; @@ -38,6 +44,8 @@ export const ProtectedRoute = ({ status = "should-land-user"; } else if (!user) { status = "should-redirect"; + } else if (user.is_client) { + status = "should-redirect-client"; } else if (onlyRegularUser && user.is_temporary) { status = "should-redirect"; } else { @@ -56,6 +64,15 @@ export const ProtectedRoute = ({ .with("loading", () => ) .with("should-land-user", () => ) .with("should-redirect", () => ) + .with("should-redirect-client", () => ) .with("should-pass", () => ) .exhaustive(); }; + +const ClientPortalRedirect = () => { + useEffect(() => { + redirectClientUserToPortal("/"); + }, []); + + return ; +}; diff --git a/apps/main/src/lib/clientPortal.ts b/apps/main/src/lib/clientPortal.ts new file mode 100644 index 0000000..9e026ca --- /dev/null +++ b/apps/main/src/lib/clientPortal.ts @@ -0,0 +1,24 @@ +const DEFAULT_CLIENTS_APP_URL = "https://clients.xtablo.com"; + +const trimTrailingSlash = (value: string) => value.replace(/\/+$/, ""); + +export function getClientPortalUrl(path = "/") { + const configuredUrl = import.meta.env.VITE_CLIENTS_APP_URL as string | undefined; + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + + const baseUrl = configuredUrl + ? trimTrailingSlash(configuredUrl) + : window.location.hostname === "app.xtablo.com" + ? DEFAULT_CLIENTS_APP_URL + : window.location.hostname.startsWith("app.") + ? `${window.location.protocol}//clients.${window.location.hostname.slice(4)}${ + window.location.port ? `:${window.location.port}` : "" + }` + : DEFAULT_CLIENTS_APP_URL; + + return `${baseUrl}${normalizedPath}`; +} + +export function redirectClientUserToPortal(path = "/") { + window.location.replace(getClientPortalUrl(path)); +}