Redirect clients to the clients app

This commit is contained in:
Arthur Belleville 2026-04-19 11:33:30 +02:00
parent 46d2eb0277
commit cab790dd2a
No known key found for this signature in database
6 changed files with 264 additions and 21 deletions

View file

@ -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(
<TestUserStoreProvider
user={{
id: "123",
short_user_id: "123",
name: "Client User",
first_name: "Client",
last_name: "User",
email: "client@example.com",
avatar_url: null,
is_temporary: false,
is_client: true,
client_onboarded_at: new Date().toISOString(),
last_signed_in: null,
plan: "none" as const,
created_at: new Date().toISOString(),
}}
>
<SessionTestProvider
testUser={{
id: "123",
app_metadata: {},
user_metadata: {
full_name: "Client User",
email: "client@example.com",
email_verified: true,
first_name: "Client",
last_name: "User",
},
aud: "authenticated",
created_at: new Date().toISOString(),
}}
>
<Routes>
<Route element={<AuthenticationGateway />}>
<Route path="/login" element={<div>Login Page</div>} />
</Route>
</Routes>
</SessionTestProvider>
</TestUserStoreProvider>,
{ route: "/login" }
);
await waitFor(() => {
expect(redirectClientUserToPortal).toHaveBeenCalledWith("/");
});
expect(screen.queryByText("Login Page")).not.toBeInTheDocument();
});
});

View file

@ -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", () => <LoadingSpinner />)
.with("should-redirect", () => <Navigate to="/" replace />)
.with("should-redirect-client", () => <ClientPortalRedirect />)
.with("should-pass", () => <Outlet />)
.exhaustive();
};
const ClientPortalRedirect = () => {
useEffect(() => {
redirectClientUserToPortal("/");
}, []);
return <LoadingSpinner />;
};

View file

@ -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(
<SessionTestProvider
testUser={{
<TestUserStoreProvider
user={{
id: "123",
app_metadata: {},
user_metadata: {
full_name: "Test User",
email: "test@example.com",
email_verified: true,
first_name: "Test",
last_name: "User",
business_name: "Test Business",
},
aud: "authenticated",
short_user_id: "123",
name: "Test User",
first_name: "Test",
last_name: "User",
email: "test@example.com",
avatar_url: null,
is_temporary: false,
is_client: false,
client_onboarded_at: null,
last_signed_in: null,
plan: "none" as const,
created_at: new Date().toISOString(),
}}
>
<Routes>
<Route element={<AuthenticationGateway />}>
<Route path="/login" element={<div>Login Page</div>} />
</Route>
<Route path="/" element={<div>Home Page</div>} />
</Routes>
</SessionTestProvider>,
<SessionTestProvider
testUser={{
id: "123",
app_metadata: {},
user_metadata: {
full_name: "Test User",
email: "test@example.com",
email_verified: true,
first_name: "Test",
last_name: "User",
business_name: "Test Business",
},
aud: "authenticated",
created_at: new Date().toISOString(),
}}
>
<Routes>
<Route element={<AuthenticationGateway />}>
<Route path="/login" element={<div>Login Page</div>} />
</Route>
<Route path="/" element={<div>Home Page</div>} />
</Routes>
</SessionTestProvider>
</TestUserStoreProvider>,
{ route: "/login" }
);
@ -70,6 +98,56 @@ describe("PublicRoute", () => {
});
});
it("redirects authenticated client users to the client portal instead of main auth pages", async () => {
renderWithRouter(
<TestUserStoreProvider
user={{
id: "123",
short_user_id: "123",
name: "Client User",
first_name: "Client",
last_name: "User",
email: "client@example.com",
avatar_url: null,
is_temporary: false,
is_client: true,
client_onboarded_at: new Date().toISOString(),
last_signed_in: null,
plan: "none" as const,
created_at: new Date().toISOString(),
}}
>
<SessionTestProvider
testUser={{
id: "123",
app_metadata: {},
user_metadata: {
full_name: "Client User",
email: "client@example.com",
email_verified: true,
first_name: "Client",
last_name: "User",
},
aud: "authenticated",
created_at: new Date().toISOString(),
}}
>
<Routes>
<Route element={<AuthenticationGateway />}>
<Route path="/login" element={<div>Login Page</div>} />
</Route>
</Routes>
</SessionTestProvider>
</TestUserStoreProvider>,
{ 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(
<SessionTestProvider testUser={undefined}>

View file

@ -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(
<TestUserStoreProvider
user={{
id: "123",
name: "Client User",
email: "client@example.com",
avatar_url: "https://example.com/avatar.jpg",
short_user_id: "123",
first_name: "Client",
last_name: "User",
is_temporary: false,
is_client: true,
client_onboarded_at: new Date().toISOString(),
last_signed_in: null,
plan: "none" as const,
created_at: new Date().toISOString(),
}}
>
<SessionTestProvider>
<Routes>
<Route element={<ProtectedRoute />}>
<Route path="/" element={<div>Protected Content</div>} />
</Route>
</Routes>
</SessionTestProvider>
</TestUserStoreProvider>,
{ route: "/" }
);
await waitFor(() => {
expect(redirectClientUserToPortal).toHaveBeenCalledWith("/");
});
expect(screen.queryByText("Protected Content")).not.toBeInTheDocument();
});
});

View file

@ -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", () => <LoadingSpinner />)
.with("should-land-user", () => <Navigate to="/landing" replace />)
.with("should-redirect", () => <Navigate to={redirectUrl} replace />)
.with("should-redirect-client", () => <ClientPortalRedirect />)
.with("should-pass", () => <Outlet />)
.exhaustive();
};
const ClientPortalRedirect = () => {
useEffect(() => {
redirectClientUserToPortal("/");
}, []);
return <LoadingSpinner />;
};

View file

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