Skip to content

Add sidebar layout to dashboard with nav components #43

@imharrisonking

Description

@imharrisonking

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 /onboarding
  • user.plan !== null → Can access /app, redirect from /onboarding to /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.tsx layout route created with auth check in beforeLoad
  • SidebarProvider, AppSidebar, and SidebarInset wrap all /app/* routes
  • Dashboard page (/app) shows sidebar + content with NavigationHeader
  • Settings page (/app/settings) follows same pattern
  • Unauthenticated users redirect to /login
  • Auth state accessible in all nested routes via context
  • branding prop is optional in AppSidebar (no crashes without it)
  • /onboarding route exists as standalone page (no sidebar)
  • Users with user.plan === null are redirected to /onboarding
  • Users with any user.plan value are redirected from /onboarding to /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

  1. Sign in as a user with a plan
  2. Navigate to /app
  3. Verify: Sidebar is visible on the left
  4. Verify: NavigationHeader is visible at top
  5. Verify: Page content is in PageContainer
  6. Navigate to /app/settings
  7. Verify: Same sidebar layout

Test 2: Onboarding Standalone

  1. Sign in as a user WITHOUT a plan (plan: null)
  2. Verify: Automatically redirected to /onboarding
  3. Verify: NO sidebar is visible
  4. Verify: "Apply for early access" heading is visible
  5. Verify: Name input and button are present

Test 3: Plan-Based Redirects

  1. As user with plan, try to navigate to /onboarding
  2. Verify: Redirected to /app
  3. As user without plan, try to navigate to /app
  4. Verify: Redirected to /onboarding

Test 4: Onboarding Form Submission

  1. On /onboarding page, enter a name
  2. Click "I want early access"
  3. Verify: Button shows "Submitting..."
  4. Verify: Console logs "Early access request: { name: '...' }"
  5. Verify: Button returns to "I want early access" after 1s

Test 5: Unauthenticated Access

  1. Sign out
  2. Try to navigate to /app
  3. Verify: Redirected to /login
  4. Try to navigate to /onboarding
  5. 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 branding prop optional in AppSidebarProps interface
  • Provide a default stub branding object when branding is not provided
  • Update NavWorkspace call 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:

  1. Checks authentication in beforeLoad
  2. Checks user.plan and redirects to /onboarding if null
  3. Provides SidebarProvider, AppSidebar, and SidebarInset for /app/* routes
  4. 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 NavUser in sidebar)
  • Add NavigationHeader at 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 NavigationHeader at 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.tsx for the sidebar pattern
  • Reference: packages/app-example/src/routes/_authenticated/_dashboard/index.tsx for dashboard content pattern

Metadata

Metadata

Assignees

No one assigned

    Labels

    tracer-bulletEnd-to-end slice to validate approach

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions