Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 4 additions & 10 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ import {
import { FeedbackWidget } from "./components/feedback";
import { ProtectedAppLayout } from "./components/layout/ProtectedAppLayout";
import { KeyboardShortcutsModal } from "./components/settings";
import { PageSkeleton } from "./components/ui";
import { PageSkeleton, type PageSkeletonVariant } from "./components/ui";
import { OnboardingLoadingScreen } from "./components/ui/onboarding-state";
import { WorkspaceChooserPage } from "./pages/WorkspaceChooserPage";

const LoginPage = lazy(() =>
import("./pages/LoginPage").then((m) => ({ default: m.LoginPage })),
Expand Down Expand Up @@ -42,11 +43,6 @@ const CompanySetupPage = lazy(() =>
default: m.CompanySetupPage,
})),
);
const WorkspaceChooserPage = lazy(() =>
import("./pages/WorkspaceChooserPage").then((m) => ({
default: m.WorkspaceChooserPage,
})),
);
const OAuthConsentPage = lazy(() =>
import("./pages/OAuthConsentPage").then((m) => ({
default: m.OAuthConsentPage,
Expand Down Expand Up @@ -86,7 +82,7 @@ function LazyPage({
variant = "default",
}: {
children: ReactNode;
variant?: "auth" | "chat" | "team" | "settings" | "dashboard" | "default";
variant?: PageSkeletonVariant;
}) {
return (
<Suspense
Expand Down Expand Up @@ -172,9 +168,7 @@ function App() {
path="/workspaces"
element={
<ProtectedRoute workspaceMode="chooser">
<LazyPage>
<WorkspaceChooserPage />
</LazyPage>
<WorkspaceChooserPage />
</ProtectedRoute>
}
/>
Expand Down
20 changes: 17 additions & 3 deletions apps/web/src/components/auth/ProtectedRoute.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ let auth: AuthStub = {
};

const navigateTargets: string[] = [];
let pathname = "/inbox";

// `mock.module` replaces a module for every test file that runs after this one
// in the same process, so each stub keeps the real exports and overrides only
Expand Down Expand Up @@ -67,15 +68,17 @@ mock.module("react-router", () => ({
navigateTargets.push(to);
return null;
},
useLocation: () => ({ pathname: "/inbox" }),
useLocation: () => ({ pathname }),
}));

const { ProtectedRoute } = await import("./ProtectedRoute");

function render(): string {
function render(
workspaceMode: "required" | "chooser" | "setup" = "required",
): string {
navigateTargets.length = 0;
return renderToStaticMarkup(
<ProtectedRoute>
<ProtectedRoute workspaceMode={workspaceMode}>
<p>protected content</p>
</ProtectedRoute>,
);
Expand Down Expand Up @@ -106,4 +109,15 @@ describe("ProtectedRoute session handling", () => {
expect(navigateTargets).toEqual([]);
expect(markup).toContain("protected content");
});

test("keeps the workspace chooser visible while the session restores", () => {
pathname = "/workspaces";
auth = { ...auth, isLoading: true };
const markup = render("chooser");

expect(navigateTargets).toEqual([]);
expect(markup).toContain('aria-label="Loading your workspaces…"');
expect(markup).toContain("WATeamInbox");
expect(markup).not.toContain("We’re restoring your session");
});
});
3 changes: 3 additions & 0 deletions apps/web/src/components/auth/ProtectedRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ export function ProtectedRoute({
/>
);
}
if (location.pathname === "/workspaces") {
return <PageSkeleton variant="workspace-chooser" />;
}
return (
<OnboardingLoadingScreen
message={
Expand Down
65 changes: 64 additions & 1 deletion apps/web/src/components/ui/page-skeleton.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { BrandMark } from "../brand/BrandMark";
import { Skeleton } from "./skeleton";

export type PageSkeletonVariant =
Expand All @@ -8,7 +9,8 @@ export type PageSkeletonVariant =
| "settings"
| "dashboard"
| "auth"
| "team";
| "team"
| "workspace-chooser";

/**
* The skeleton a protected path should show while its guards resolve.
Expand Down Expand Up @@ -62,6 +64,8 @@ export function PageSkeleton({
return <AuthPageSkeleton className={className} />;
case "team":
return <TeamPageSkeleton className={className} />;
case "workspace-chooser":
return <WorkspaceChooserSkeleton className={className} />;
default:
return <DefaultPageSkeleton className={className} />;
}
Expand Down Expand Up @@ -384,6 +388,65 @@ function DashboardPageSkeleton({ className }: { className?: string }) {
);
}

/**
* Workspace chooser skeleton - preserves the chooser's frame while auth and
* memberships restore. This route used to borrow the onboarding/auth loading
* screen, which looked like a brief jump back to login before the chooser
* appeared.
*/
function WorkspaceChooserSkeleton({ className }: { className?: string }) {
const { t } = useTranslation();

return (
<main
className={cn(
"relative min-h-dvh overflow-hidden bg-[#f5f7f4] px-5 py-10 text-[#10211b] dark:bg-dark-primary dark:text-dark-text-primary",
className,
)}
role="status"
aria-label={t("auth.loadingWorkspaces", "Loading your workspaces…")}
>
<div
className="pointer-events-none absolute -right-32 -top-32 h-96 w-96 rounded-full bg-[#dcefe7] blur-3xl dark:bg-emerald-500/10"
aria-hidden="true"
/>
<div className="relative mx-auto max-w-2xl">
<div className="mb-10 flex items-center justify-between">
<div className="flex items-center gap-3">
<BrandMark className="h-10 w-10 shrink-0 rounded-xl object-contain" />
<span className="text-sm font-semibold tracking-tight">
WATeamInbox
</span>
</div>
<Skeleton className="h-9 w-24 rounded-lg" />
</div>

<Skeleton className="h-3 w-36 rounded-full bg-[#0b7a55]/20 dark:bg-emerald-400/15" />
<Skeleton className="mt-5 h-10 w-full max-w-md rounded-lg" />
<Skeleton className="mt-4 h-5 w-full max-w-lg rounded-md" />

<div className="mt-9 grid gap-3">
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="flex items-center gap-4 rounded-2xl border border-[#dce3de] bg-white p-4 shadow-[0_1px_2px_rgba(16,33,27,.03)] dark:border-dark-border dark:bg-dark-elevated"
>
<Skeleton className="h-12 w-12 shrink-0 rounded-xl" />
<div className="min-w-0 flex-1">
<Skeleton
className={cn("h-4", index === 1 ? "w-36" : "w-28")}
/>
<Skeleton className="mt-2 h-3 w-14" />
</div>
<Skeleton className="h-5 w-5 shrink-0 rounded-md" />
</div>
))}
</div>
</div>
</main>
);
}

/**
* Auth page skeleton - centered form card
*/
Expand Down
Loading