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));
+}