-
Notifications
You must be signed in to change notification settings - Fork 0
Description
Goal
Create a minimal dashboard layout with sidebar navigation to validate the auth + sidebar pattern. This tracer bullet establishes the architectural pattern that all future authenticated routes will follow.
Additionally, implement an onboarding flow that gates access to the main app based on user plan status.
Minimal Scope
This tracer bullet is intentionally minimal:
- Combined layout route (
_auth.tsx) handling auth, sidebar, AND routing logic - Onboarding route (standalone, no sidebar) for users without a plan
- App route (with sidebar) for users with any plan value
- Only 2 app pages (dashboard, settings) to validate the pattern
- Hardcoded workspace values (future data integration)
- No error boundaries or loading states yet
Routing Logic:
user.plan === null→ Redirect to/onboardinguser.plan !== null→ Can access/app, redirect from/onboardingto/app
What we're NOT doing (yet):
- Multiple workspaces
- User profile editing
- Role-based navigation
- Dynamic branding
- Actual onboarding submission (API stub only)
Acceptance Criteria
- Combined
_auth.tsxlayout route created with auth check inbeforeLoad -
SidebarProvider,AppSidebar, andSidebarInsetwrap all/app/*routes - Dashboard page (
/app) shows sidebar + content withNavigationHeader - Settings page (
/app/settings) follows same pattern - Unauthenticated users redirect to
/login - Auth state accessible in all nested routes via context
-
brandingprop is optional inAppSidebar(no crashes without it) -
/onboardingroute exists as standalone page (no sidebar) - Users with
user.plan === nullare redirected to/onboarding - Users with any
user.planvalue are redirected from/onboardingto/app - Onboarding page has "Apply for early access" heading, name input, and "I want early access" button
- Onboarding form makes API call (stub that logs for now)
Testing Plan
Unit Tests
PageContainer Component
File: packages/web/src/components/layout/__tests__/page-container.test.tsx
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { PageContainer } from "../page-container";
describe("PageContainer", () => {
it("renders children", () => {
render(
<PageContainer>
<div>Test Content</div>
</PageContainer>
);
expect(screen.getByText("Test Content")).toBeInTheDocument();
});
it("applies custom className", () => {
const { container } = render(
<PageContainer className="custom-class">
<div>Content</div>
</PageContainer>
);
expect(container.querySelector(".custom-class")).toBeInTheDocument();
});
it("has correct layout classes", () => {
const { container } = render(
<PageContainer>
<div>Content</div>
</PageContainer>
);
const outer = container.firstChild;
expect(outer).toHaveClass("flex", "flex-col", "items-center", "h-full", "w-full");
});
});AppSidebar Component (optional branding)
File: packages/web/src/components/nav/__tests__/app-sidebar.test.tsx
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { AppSidebar } from "../app-sidebar";
describe("AppSidebar", () => {
const mockUser = {
id: "1",
name: "Test User",
email: "test@example.com",
image: null,
workspaceName: "Test Workspace",
plan: "free",
};
it("renders without branding prop (no crash)", () => {
render(<AppSidebar user={mockUser} currentPathname="/app" />);
expect(screen.getByText("Test Workspace")).toBeInTheDocument();
});
it("renders with branding prop", () => {
const branding = { name: "Acme", logo: "/logo.png" };
render(
<AppSidebar user={mockUser} currentPathname="/app" branding={branding} />
);
expect(screen.getByText("Acme")).toBeInTheDocument();
});
});Integration Tests (Routing)
Auth Layout Routing Logic
File: packages/web/src/routes/__tests__/_auth.routing.test.tsx
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { createMemoryHistory } from "@tanstack/react-router";
import { setupRouter } from "@/test-utils/router";
// Mock getAuth
vi.mock("@/lib/auth-server", () => ({
getAuth: vi.fn(),
}));
describe("Auth Layout Routing", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("Unauthenticated users", () => {
it("redirects to /login", async () => {
const { getAuth } = await import("@/lib/auth-server");
vi.mocked(getAuth).mockResolvedValue(null);
const history = createMemoryHistory({ initialEntries: ["/app"] });
// Setup router and render
await waitFor(() => {
expect(history.location.pathname).toBe("/login");
});
});
});
describe("Authenticated users without plan", () => {
it("redirects to /onboarding", async () => {
const { getAuth } = await import("@/lib/auth-server");
vi.mocked(getAuth).mockResolvedValue({
user: { id: "1", name: "Test", email: "test@example.com", plan: null },
session: { id: "s1" },
});
const history = createMemoryHistory({ initialEntries: ["/app"] });
// Setup router and render
await waitFor(() => {
expect(history.location.pathname).toBe("/onboarding");
});
});
it("can access /onboarding without redirect", async () => {
const { getAuth } = await import("@/lib/auth-server");
vi.mocked(getAuth).mockResolvedValue({
user: { id: "1", name: "Test", email: "test@example.com", plan: null },
session: { id: "s1" },
});
const history = createMemoryHistory({ initialEntries: ["/onboarding"] });
// Setup router and render
await waitFor(() => {
expect(history.location.pathname).toBe("/onboarding");
expect(screen.getByText("Apply for early access")).toBeInTheDocument();
});
});
});
describe("Authenticated users with plan", () => {
it("can access /app", async () => {
const { getAuth } = await import("@/lib/auth-server");
vi.mocked(getAuth).mockResolvedValue({
user: { id: "1", name: "Test", email: "test@example.com", plan: "free" },
session: { id: "s1" },
});
const history = createMemoryHistory({ initialEntries: ["/app"] });
// Setup router and render
await waitFor(() => {
expect(history.location.pathname).toBe("/app");
});
});
it("is redirected from /onboarding to /app", async () => {
const { getAuth } = await import("@/lib/auth-server");
vi.mocked(getAuth).mockResolvedValue({
user: { id: "1", name: "Test", email: "test@example.com", plan: "free" },
session: { id: "s1" },
});
const history = createMemoryHistory({ initialEntries: ["/onboarding"] });
// Setup router and render
await waitFor(() => {
expect(history.location.pathname).toBe("/app");
});
});
});
});Onboarding Page Form
File: packages/web/src/routes/_auth/__tests__/onboarding.test.tsx
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { OnboardingPage } from "../onboarding";
describe("OnboardingPage", () => {
it("renders heading", () => {
render(<OnboardingPage />);
expect(screen.getByText("Apply for early access")).toBeInTheDocument();
});
it("renders name input", () => {
render(<OnboardingPage />);
expect(screen.getByLabelText("Name")).toBeInTheDocument();
});
it("renders submit button", () => {
render(<OnboardingPage />);
expect(screen.getByRole("button", { name: /i want early access/i })).toBeInTheDocument();
});
it("disables button while submitting", async () => {
const consoleSpy = vi.spyOn(console, "log");
render(<OnboardingPage />);
const input = screen.getByLabelText("Name");
const button = screen.getByRole("button", { name: /i want early access/i });
fireEvent.change(input, { target: { value: "Test User" } });
fireEvent.click(button);
expect(button).toBeDisabled();
expect(screen.getByText("Submitting...")).toBeInTheDocument();
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith("Early access request:", { name: "Test User" });
});
});
});Manual/E2E Tests (Browser Verification)
After implementation, verify manually in browser:
Test 1: Sidebar Layout
- Sign in as a user with a plan
- Navigate to
/app - Verify: Sidebar is visible on the left
- Verify:
NavigationHeaderis visible at top - Verify: Page content is in
PageContainer - Navigate to
/app/settings - Verify: Same sidebar layout
Test 2: Onboarding Standalone
- Sign in as a user WITHOUT a plan (plan: null)
- Verify: Automatically redirected to
/onboarding - Verify: NO sidebar is visible
- Verify: "Apply for early access" heading is visible
- Verify: Name input and button are present
Test 3: Plan-Based Redirects
- As user with plan, try to navigate to
/onboarding - Verify: Redirected to
/app - As user without plan, try to navigate to
/app - Verify: Redirected to
/onboarding
Test 4: Onboarding Form Submission
- On
/onboardingpage, enter a name - Click "I want early access"
- Verify: Button shows "Submitting..."
- Verify: Console logs "Early access request: { name: '...' }"
- Verify: Button returns to "I want early access" after 1s
Test 5: Unauthenticated Access
- Sign out
- Try to navigate to
/app - Verify: Redirected to
/login - Try to navigate to
/onboarding - Verify: Redirected to
/login
Related Features
Unlocks:
- Full dashboard feature expansion
- Settings page features
- User profile management
- Workspace management
- Real onboarding flow integration
Tasks
Task 1: Make branding optional in AppSidebar
File: packages/web/src/components/nav/app-sidebar.tsx
- Make
brandingprop optional inAppSidebarPropsinterface - Provide a default stub branding object when branding is not provided
- Update
NavWorkspacecall to handle optional branding
Task 2: Create PageContainer component
File: packages/web/src/components/layout/page-container.tsx (new file)
import { cn } from "@/lib/utils";
export function PageContainer({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className="flex flex-col items-center h-full w-full">
<div className={cn("w-full h-full p-4 max-w-screen-xl", className)}>
{children}
</div>
</div>
);
}Task 3: Create combined auth layout route with routing logic
File: packages/web/src/routes/_auth.tsx (new file)
Single layout route that:
- Checks authentication in
beforeLoad - Checks
user.planand redirects to/onboardingif null - Provides
SidebarProvider,AppSidebar, andSidebarInsetfor/app/*routes - Uses
<Outlet />for nested routes
import { createFileRoute, Outlet, redirect, useLocation } from "@tanstack/react-router";
import { AppSidebar } from "@/components/nav/app-sidebar";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { getAuth } from "@/lib/auth-server";
export const Route = createFileRoute("/_auth")({
beforeLoad: async ({ location }) => {
const session = await getAuth();
if (!session) {
throw redirect({ to: "/login" });
}
// Redirect to onboarding if no plan
if (!session.user.plan && location.pathname !== "/onboarding") {
throw redirect({ to: "/onboarding" });
}
// Redirect to app if has plan and trying to access onboarding
if (session.user.plan && location.pathname === "/onboarding") {
throw redirect({ to: "/app" });
}
return { user: session.user, session: session.session };
},
component: AuthLayout,
});
function AuthLayout() {
const { user } = Route.useRouteContext();
const pathname = useLocation({ select: (loc) => loc.pathname });
// Onboarding is standalone (no sidebar)
if (pathname === "/onboarding") {
return <Outlet />;
}
const userModel = {
id: user.id,
name: user.name,
email: user.email,
image: user.image,
workspaceName: "My Workspace",
plan: user.plan,
};
return (
<SidebarProvider>
<AppSidebar user={userModel} currentPathname={pathname} />
<SidebarInset>
<Outlet />
</SidebarInset>
</SidebarProvider>
);
}Task 4: Create onboarding page
File: packages/web/src/routes/_auth/onboarding.tsx (new file)
Standalone onboarding page (no sidebar):
- "Apply for early access" heading
- Name input field
- "I want early access" button
- API call stub that logs the submission
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export const Route = createFileRoute("/_auth/onboarding")({
component: OnboardingPage,
});
function OnboardingPage() {
const [name, setName] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
// TODO: Replace with actual API call
console.log("Early access request:", { name });
// Simulate API delay
await new Promise((resolve) => setTimeout(resolve, 1000));
setIsSubmitting(false);
};
return (
<div className="flex min-h-screen items-center justify-center">
<div className="w-full max-w-md space-y-8 p-8">
<div className="text-center">
<h1 className="text-2xl font-bold">Apply for early access</h1>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter your name"
required
/>
</div>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? "Submitting..." : "I want early access"}
</Button>
</form>
</div>
</div>
);
}Task 5: Update dashboard page
File: packages/web/src/routes/_auth/app/index.tsx
- Remove
beforeLoad(now handled by parent_auth.tsx) - Remove inline logout button (handled by
NavUserin sidebar) - Add
NavigationHeaderat top - Wrap content in
PageContainer - Use context from parent route for user data
Task 6: Update settings page
File: packages/web/src/routes/_auth/app/settings.tsx
- Remove
beforeLoad(now handled by parent) - Add
NavigationHeaderat top - Wrap content in
PageContainer - Remove inline logout button
File Changes Summary
| File | Action | Description |
|---|---|---|
packages/web/src/components/nav/app-sidebar.tsx |
Modify | Make branding prop optional |
packages/web/src/components/layout/page-container.tsx |
Create | New container component |
packages/web/src/routes/_auth.tsx |
Create | Combined auth layout with routing logic |
packages/web/src/routes/_auth/onboarding.tsx |
Create | Standalone onboarding page |
packages/web/src/routes/_auth/app/index.tsx |
Modify | Add header/container, remove auth & logout |
packages/web/src/routes/_auth/app/settings.tsx |
Modify | Add header/container, remove auth & logout |
References
- Reference:
packages/app-example/src/routes/_authenticated/_dashboard.tsxfor the sidebar pattern - Reference:
packages/app-example/src/routes/_authenticated/_dashboard/index.tsxfor dashboard content pattern