Skip to content

Auth Flow

This page describes how authentication works end-to-end in Workbench AI: from login/signup to role resolution and route gating.


Overview

Authentication is handled by Supabase Auth with a thin application layer on top:

  • Supabase Auth manages users, sessions, and provider flows.
  • A user_profiles table stores application-level roles and tenant/company links.
  • The frontend uses AuthProvider + useAuth() to expose user, role, and tenant info.
  • Routes are gated with ProtectedRoute, which checks:
    • Logged-in state
    • Role (owner, tenant, admin)
    • Subscription status and feature access

UI pages

Login (/loginsrc/pages/auth/Login.tsx)

Responsibilities:

  • Collects email and password.
  • Calls supabase.auth.signInWithPassword({ email, password }).
  • On success:
    1. Calls supabase.rpc('get_tenant_info_for_user', { user_id_param: data.user.id }) to fetch:
    2. Role (owner / tenant / admin)
    3. tenant_id
    4. Optionally fetches company.name from companies using tenant_id.
    5. Writes a snapshot of this profile to localStorage under sb-user-profile.
    6. Redirects based on role:
    7. owner/owner-dashboard
    8. others → /dashboard

If the user is already logged in (via useAuth()), Login immediately redirects to the appropriate dashboard using getRedirectPath(role).

Signup (/signupsrc/pages/auth/Signup.tsx)

Responsibilities:

  • Collects account + company info via SignupForm.
  • Coordinates with:
    • useSignup() – creates Supabase user and initial tenant/company records.
    • useCheckout() – starts Stripe checkout for the selected plan.
    • useSignupSession() – keeps signup state across page reloads.
  • Flow:
    1. User selects a plan on /pricing (stored in query param plan).
    2. User fills signup form and submits.
    3. handleSignup creates user + tenant/company and stores signupData.
    4. TrialModal confirms trial/plan; on agree, handleCheckout starts Stripe checkout.
    5. After checkout, the auth callback and Stripe flows determine the final redirect.

Auth callback (/auth/callbacksrc/pages/auth/AuthCallback.tsx)

Responsibilities:

  • Handles cases where Supabase returns the user to the app with hash or query params (PKCE flow).
  • Steps:
    1. On mount, checks window.location.hash for access_token / error.
    2. Calls supabase.auth.getSession() to let Supabase process the callback and populate session.
    3. Clears the URL hash.
  • After Supabase session is established:
    • Waits on useAuth() to load user + role.
    • If no user → redirect to /login.
    • If user but role is not yet available:
    • After a small timeout, directly calls supabase.rpc('get_tenant_info_for_user', ...) and redirects based on role.
    • If role exists:
    • Checks sessionStorage.getItem('selectedPlan') (set during signup) and, if present, redirects to /upgrade?plan=...&source=signup.
    • Otherwise, redirects using getRedirectPath(role).

Clear session (/clear-sessionsrc/pages/auth/ClearSession.tsx)

Responsibilities:

  • Provides a hard reset of local auth state.
  • Flow:
    1. Preserves Rewardful tracking data (via preserveRewardfulData()).
    2. Clears localStorage and sessionStorage.
    3. Restores Rewardful data to avoid breaking referral tracking.
    4. Calls supabase.auth.signOut().
    5. Redirects to /login after a short delay.

This route is useful for support/debugging scenarios where a user’s local session is corrupted.


AuthProvider and session handling

File: src/contexts/AuthContext.tsx

The AuthProvider wraps the app and exposes:

  • user: Supabase auth.users record.
  • session: current Supabase session.
  • tenantId: current tenant/company ID.
  • companyName: human-friendly company name.
  • role: 'owner' | 'tenant' | 'admin' | null.
  • loading: whether auth info is being resolved.

Role & tenant resolution

A helper fetchTenantInfo(userId, useStoredOnly) implements the unified resolution flow:

  1. Read stored profile (optional)

    • Tries localStorage['sb-user-profile'] for a cached { userId, role, tenantId, companyName }.
    • If useStoredOnly is true and a matching profile exists, uses it and skips DB calls.
  2. Fetch from database (normal path)

    • Calls supabase.rpc('get_tenant_info_for_user', { user_id_param: userId }) with retry + timeout logic.
    • Uses the RPC’s result to populate role and tenantId.
    • Fetches company.name from companies using tenantId.
    • Writes the profile back to localStorage for next time.
  3. Fallbacks

    • If RPC or DB lookups fail but a stored profile exists, use the stored profile.
    • If neither DB nor stored profile is available, clears role/tenant/company and clears stored profile.

Lifecycle

On app startup / refresh:

  • AuthProvider reads the current session via Supabase.
  • For a logged-in user:
    • First tries fetchTenantInfo(userId, useStoredOnly = true) to use cached data.
    • If needed, falls back to a full fetch.
  • For guests:
    • user and role are null, loading eventually becomes false.

Routes and components consume this via the useAuth() hook.


Route gating (ProtectedRoute)

File: src/components/ProtectedRoute.tsx

ProtectedRoute is a wrapper used in App.tsx for all protected routes.

Checks performed:

  1. Loading

    • If auth or subscription state is loading, renders a full-screen Loading... view.
  2. Authentication

    • If user is null, redirects to /login.
  3. Role-based access

    • If requiredRole is set and hasRequiredRole(role, requiredRole) is false, redirects to /dashboard.
  4. Owner bypass

    • If isOwner or role === 'owner', returns children without subscription checks.
  5. Subscription & feature gating (non-owner)

    • If no subscription exists → redirect to /billing with state.reason = 'no_subscription'.
    • If subscription is inactive → redirect to /billing with a lockout reason from getLockoutReason(subscription).
    • If a feature is specified and canAccess(feature) is false:
      • Renders an "Upgrade Required" card with UpgradePrompt to encourage upgrading to the necessary plan.

Public vs protected routes

  • Public/auth routes like /, /pricing, /login, /signup, /auth/callback, /clear-session are not wrapped in ProtectedRoute.
  • Dashboard and tools (e.g. /dashboard, /orders, /clients, /design/*, /payments/*) are wrapped and enforced via this component.

End-to-end flow

Login & role resolution

flowchart LR
  U[User Browser] --> LOGIN[/Login page /]
  LOGIN -->|email + password| SBAUTH[Supabase Auth]
  SBAUTH -->|session| SPA[AuthProvider]
  SPA -->|RPC: get_tenant_info_for_user| SBD[(Supabase DB)]
  SBD --> SPA
  SPA --> ROUTES[Protected routes]

  click LOGIN "../architecture/frontend/" "Frontend auth pages (Login)"
  click SBAUTH "../architecture/supabase/" "Supabase auth & roles"
  click SBD "../architecture/supabase/" "Supabase schema overview"
  click ROUTES "../architecture/frontend/" "Route map & ProtectedRoute"

Narrative:

  1. User submits login form on /login.
  2. supabase.auth.signInWithPassword creates/returns a session.
  3. AuthProvider (or the login page) uses get_tenant_info_for_user to resolve tenant and role from the DB.
  4. Role, tenant, and company info are cached in localStorage and stored in context.
  5. ProtectedRoute uses this context to gate access to protected routes.

Signup + callback + upgrade (high level)

flowchart LR
  PRICING[/Pricing page/] --> SIGNUP[/Signup page/]
  SIGNUP -->|create user + tenant| SBAUTH[Supabase Auth]
  SIGNUP -->|start checkout| ST[Stripe Checkout]
  ST -->|redirect on success| CALLBACK[/AuthCallback/]
  CALLBACK --> SPA[AuthProvider]
  SPA -->|role + tenant| ROUTES[Protected routes]

  click SIGNUP "../architecture/frontend/" "Signup page & flows"
  click SBAUTH "../architecture/supabase/" "Supabase auth & roles"
  click ST "./billing-stripe/" "Billing & Stripe flows"
  click CALLBACK "../architecture/frontend/" "AuthCallback route"
  • The signup flow creates accounts in both Supabase Auth and the app’s schema.
  • Stripe handles payment details and trial logic.
  • Auth callback and AuthProvider ensure the session is valid and the user is redirected to the correct dashboard or upgrade flow.

Where to look next