Redirect clients to the clients app
This commit is contained in:
parent
46d2eb0277
commit
cab790dd2a
6 changed files with 264 additions and 21 deletions
67
apps/main/src/components/AuthenticationGateway.test.tsx
Normal file
67
apps/main/src/components/AuthenticationGateway.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
};
|
||||
|
|
|
|||
24
apps/main/src/lib/clientPortal.ts
Normal file
24
apps/main/src/lib/clientPortal.ts
Normal 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));
|
||||
}
|
||||
Loading…
Reference in a new issue