Datalumina

Foundation

Authentication

Scaffold the React frontend and wire Supabase Auth end to end, from JWT verification in FastAPI to protected routes in the browser.

This phase gets a real login working end to end. An analyst signs in with email and password on a fresh React frontend, the Supabase session token travels with every API call, and the FastAPI backend rejects anything without a valid JWT. It is the first vertical slice that touches both sides of the stack, which is why Phase 2 in docs/todos.md is simply called "Auth (full stack)".

daveebbelaar/document-copilot1:08:00

Scaffold the frontend

The frontend starts from the official Vite template rather than letting AI generate a project from scratch. There is a lot of setup a template gets right for free, and Dave is upfront about this side of the codebase. "I'm a backend engineer, worked as a data scientist, AI engineer. Front end has always been a little bit foreign for me... on this part of the codebase you'll see me vibe code a little bit more."

Follow docs/guides/frontend-setup.md from the empty frontend/ folder:

cd frontend
pnpm create vite . --template react-ts
pnpm install
pnpm add react-router-dom @supabase/supabase-js
pnpm add -D tailwindcss @tailwindcss/vite
pnpm dlx shadcn@latest init

The shadcn/ui init asks which preset you want. Dave previews the options at ui.shadcn.com/create and picks the noir preset with Lucide icons and the Geist font, "it doesn't really matter as much, and we can always change it later." Expect a small error after installing Tailwind; it still needs to be wired into the Vite config and CSS entry point, plus the @ path alias in tsconfig. This is exactly the kind of config plumbing AI is good at, so let it fix the imports, then verify pnpm dev renders the demo page with a shadcn button.

Backend Supabase clients

Before the auth dependency, the backend needs its Supabase clients. backend/app/database/supabase.py builds two kinds, and the distinction matters for the rest of the project:

async def get_service_role_client() -> AsyncClient:
    """Return a shared client that bypasses RLS (backend-only privileged access)."""
    global _service_role_client

    if _service_role_client is None:
        _service_role_client = await acreate_client(
            settings.supabase_url,
            settings.supabase_service_role_key,
            options=_server_client_options(),
        )

    return _service_role_client

async def create_user_client(access_token: str) -> AsyncClient:
    """Return a request-scoped client that enforces RLS for the authenticated user."""
    return await acreate_client(
        settings.supabase_url,
        settings.supabase_anon_key,
        options=_server_client_options(
            access_token=_normalize_access_token(access_token),
        ),
    )

The service role client uses the service role key, bypasses the row level security policies from the previous phase, and never leaves the backend. The user client uses the anon key plus the caller's own JWT in the Authorization header, so Postgres enforces RLS as that user. When in doubt, use the user client; reach for service role only where the backend genuinely needs to act above user permissions.

Verify the JWT in FastAPI

backend/app/auth/dependencies.py is the gate every protected endpoint goes through. It pulls the bearer token from the Authorization header, asks Supabase Auth to verify it, and returns a small CurrentUser value:

async def get_current_user(
    access_token: str = Depends(get_access_token),
) -> CurrentUser:
    client = await acreate_client(
        settings.supabase_url,
        settings.supabase_anon_key,
        options=AsyncClientOptions(
            auto_refresh_token=False,
            persist_session=False,
        ),
    )

    try:
        response = await client.auth.get_user(jwt=access_token)
    except AuthApiError as exc:
        raise _unauthorized("Invalid or expired token") from exc

    if response is None or response.user is None or not response.user.email:
        raise _unauthorized("Invalid or expired token")

    return CurrentUser(
        id=uuid.UUID(str(response.user.id)),
        email=response.user.email,
    )

Any endpoint that declares user: CurrentUser = Depends(get_current_user) now returns 401 before doing any chat or retrieval work. Note the verification choice. Instead of validating the JWT signature locally with the project's secret, the dependency calls Supabase's get_user with the token. It costs a network round trip per request, but it is simpler, has no key management, and immediately reflects revoked sessions. A fine trade for an internal tool; you can swap in local validation later if latency ever matters.

Configure Supabase Auth for an internal tool

Document Copilot is an internal tool for a handful of analysts, not a public product, and the Supabase dashboard settings should reflect that. Under Authentication, sign in / providers, keep email as the only provider (no Google, no SSO) and change two settings:

Disable "Confirm email". For local development you do not want a confirmation email round trip blocking every test login.

Disable "Allow new users to sign up". Dave is explicit about why, "otherwise if we deploy this, if we don't put it behind some type of firewall or VPN, anyone can access it and anyone can create an account on the Driftwood Capital website."

Add your local frontend URL under Authentication, URL configuration, redirect URLs.

Create users manually under Authentication, Users. Click "Add user", enter an email like dave@driftwood.com and a password, and check auto confirm.

For an internal tool, disabling public signups and manually adding users sidesteps the whole world of confirmation emails, callback URLs, and abuse handling. The signup page in frontend/src/pages/SignUp.tsx still exists, but with signups disabled it is effectively inert until you choose to open it up.

Frontend env validation and the API client

The frontend applies the same fail-fast principle as the backend config module. frontend/src/lib/env.ts reads the Vite env vars once at boot and throws if any is missing, so a misconfigured deploy dies loudly instead of failing on the first API call:

function requireEnv(name: keyof ImportMetaEnv): string {
  const value = import.meta.env[name]
  if (typeof value !== 'string' || value.trim() === '') {
    throw new Error(`Missing required environment variable: ${name}`)
  }
  return value.trim()
}

export const env = {
  apiBaseUrl: requireEnv('VITE_API_BASE_URL').replace(/\/$/, ''),
  supabaseUrl: requireEnv('VITE_SUPABASE_URL'),
  supabaseAnonKey: requireEnv('VITE_SUPABASE_ANON_KEY'),
} as const

frontend/src/lib/supabase.ts creates the browser client from those values. On top of that sit two thin layers. http.ts is a fetch wrapper that sets the Authorization: Bearer <token> header when given a token, normalizes errors into an ApiError (status, detail, network flag), and applies a 30 second timeout; api.ts fetches the current session token from Supabase and injects it into every call automatically:

export async function getAccessToken(): Promise<string | null> {
  const { data } = await supabase.auth.getSession()
  return data.session?.access_token ?? null
}

async function withAuth<T>(
  path: string,
  options: Omit<RequestOptions, 'accessToken'> = {},
): Promise<T> {
  return request<T>(path, {
    ...options,
    accessToken: await getAccessToken(),
  })
}

The rest of the app never touches tokens. Components call api.get('/threads') and the bearer header is already there.

Login and route guards

frontend/src/pages/Login.tsx is a straightforward shadcn form that calls supabase.auth.signInWithPassword and navigates to /chats on success; Supabase error messages surface directly in the form. Routing is guarded from both directions with two small components built on a shared useSession hook (frontend/src/hooks/useSession.ts), which subscribes to onAuthStateChange and returns undefined while the session is still loading:

export function ProtectedRoute({ children }: ProtectedRouteProps) {
  const session = useSession()

  if (session === undefined) {
    return (
      <div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
        Loading session…
      </div>
    )
  }

  if (!session) {
    return <Navigate to="/login" replace />
  }

  return children
}

PublicRoute in frontend/src/components/PublicRoute.tsx is the mirror image. A signed-in user visiting /login is redirected to /chats. The undefined loading state matters; without it, every page refresh would flash a redirect to login before the session loads from storage.

Now run both sides and test the slice end to end:

# terminal 1
cd backend && uv run uvicorn app.main:app --reload

# terminal 2
cd frontend && pnpm dev

Sign in with the user you created in the dashboard. Then try garbage credentials; Dave does exactly this in the video and gets "invalid login credentials" back, which is the negative test that proves the gate is real.

Checkpoint

Before moving on, confirm:

  • pnpm dev serves the app, and visiting a protected path while signed out redirects to /login
  • Signing in as your manually created user (for example dave@driftwood.com) lands on the app; wrong credentials show an error
  • A request without a token to a protected backend endpoint returns 401
  • After login, requests from the frontend carry Authorization: Bearer <token> and the backend accepts them
  • Public signups are disabled in the Supabase dashboard

Next: Chat shell

Build the stubbed chat vertical slice, with thread endpoints on the backend and a streaming chat UI skeleton in front.

On this page