Skip to main content

Authentication System

Visita uses an intent-based authentication model powered by Supabase Auth. This guide covers the architecture, implementation, and best practices for authentication and authorization.
Key Principle: We do not force users to log in to view public content. Authentication is only requested when a user attempts an action that requires identity.

Architecture Overview

Core Principles

  1. Public by Default
    • Homepage, Ward Maps, Business Directory, and Public Alerts are accessible to everyone
    • No global auth walls
  2. Auth on Intent
    • Login is triggered only when a user tries to:
      • Join a Ward
      • Post a Comment
      • Claim a Business
      • Access Analyst Tools
  3. Context Preservation
    • After logging in, users return to exactly where they were
    • The return_to parameter preserves user context
    • No generic dashboard redirects

User Contexts

Citizen

Business

Analyst

Enterprise


Implementation

1. Authentication Components

RequireAuth Component

Client-side guard for protected routes:
components/auth/RequireAuth.tsx
'use client';

import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect } from 'react';
import { useSupabase } from '@/lib/supabase/client';

interface RequireAuthProps {
  children: React.ReactNode;
  fallback?: React.ReactNode;
}

export function RequireAuth({ children, fallback }: RequireAuthProps) {
  const { user, isLoading } = useSupabase();
  const router = useRouter();
  const searchParams = useSearchParams();

  useEffect(() => {
    if (!isLoading && !user) {
      const returnTo = searchParams.get('return_to') || window.location.pathname;
      router.push(`/auth/login?return_to=${encodeURIComponent(returnTo)}`);
    }
  }, [user, isLoading, router, searchParams]);

  if (isLoading) {
    return fallback || <div>Loading...</div>;
  }

  if (!user) {
    return null;
  }

  return <>{children}</>;
}

RequireContext Component

Role and entitlement checker:
components/auth/RequireContext.tsx
'use client';

import { useUserContext } from '@/hooks/use-user-context';

interface RequireContextProps {
  children: React.ReactNode;
  roles?: string[];
  entitlements?: string[];
  fallback?: React.ReactNode;
}

export function RequireContext({ 
  children, 
  roles = [], 
  entitlements = [], 
  fallback 
}: RequireContextProps) {
  const { user, hasRole, hasEntitlement, isLoading } = useUserContext();

  if (isLoading) {
    return fallback || <div>Loading...</div>;
  }

  const hasRequiredRole = roles.length === 0 || roles.some(role => hasRole(role));
  const hasRequiredEntitlement = entitlements.length === 0 || 
    entitlements.some(entitlement => hasEntitlement(entitlement));

  if (!hasRequiredRole || !hasRequiredEntitlement) {
    return fallback || (
      <div className="p-4 text-center">
        <p>You don't have permission to access this resource.</p>
      </div>
    );
  }

  return <>{children}</>;
}

2. Server-Side Authentication

Creating Supabase Client

lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';

export async function createClient() {
  const cookieStore = await cookies();

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        get(name: string) {
          return cookieStore.get(name)?.value;
        },
        set(name: string, value: string, options: any) {
          cookieStore.set({ name, value, ...options });
        },
        remove(name: string, options: any) {
          cookieStore.set({ name, value: '', ...options });
        },
      },
    }
  );
}

Protected Server Actions

app/actions/protected-action.ts
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';

export async function protectedAction(data: any) {
  const supabase = await createClient();
  
  const { data: { user }, error: userError } = await supabase.auth.getUser();
  
  if (userError || !user) {
    return { 
      success: false, 
      error: 'Authentication required',
      code: 'UNAUTHENTICATED'
    };
  }

  // Perform authenticated action
  const { data: result, error } = await supabase
    .from('protected_table')
    .insert({ ...data, user_id: user.id });

  return { success: !error, data: result, error: error?.message };
}

3. Middleware Authentication

lib/supabase/middleware.ts
import { createServerClient } from '@supabase/ssr';
import { NextRequest, NextResponse } from 'next/server';

export async function updateSession(request: NextRequest) {
  let response = NextResponse.next({
    request: {
      headers: request.headers,
    },
  });

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        get(name: string) {
          return request.cookies.get(name)?.value;
        },
        set(name: string, value: string, options: any) {
          response.cookies.set({ name, value, ...options });
        },
        remove(name: string, options: any) {
          response.cookies.set({ name, value: '', ...options });
        },
      },
    }
  );

  const { data: { user } } = await supabase.auth.getUser();

  // Protect platform routes
  if (request.nextUrl.pathname.startsWith('/platform') && !user) {
    const url = new URL('/auth/login', request.url);
    url.searchParams.set('return_to', request.nextUrl.pathname);
    return NextResponse.redirect(url);
  }

  return response;
}

Authentication Flows

1. Login Flow

1
Step 1: User Attempts Protected Action
2
// User clicks "Join Ward" button
function handleJoinWard() {
  if (!user) {
    // Redirect to login with return_to parameter
    router.push(`/auth/login?return_to=/ward/${wardCode}`);
    return;
  }
  
  // Proceed with joining ward
  joinWard(wardCode);
}
3
Step 2: Login Page Preserves Context
4
'use client';

import { useSearchParams } from 'next/navigation';

export default function LoginPage() {
  const searchParams = useSearchParams();
  const returnTo = searchParams.get('return_to') || '/';

  return (
    <LoginForm returnTo={returnTo} />
  );
}
5
Step 3: After Login, Redirect Back
6
'use server';

import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';

export async function loginAction(formData: FormData, returnTo: string) {
  const supabase = await createClient();
  
  const { error } = await supabase.auth.signInWithPassword({
    email: formData.get('email') as string,
    password: formData.get('password') as string,
  });

  if (error) {
    return { success: false, error: error.message };
  }

  // Redirect to original destination
  redirect(returnTo);
}

2. Signup Flow

Visita supports deferred role selection. Users can sign up without choosing a role, and select it later when needed.
app/auth/signup/actions.ts
'use server';

export async function signupAction(formData: FormData, returnTo: string) {
  const supabase = await createClient();
  
  const { error } = await supabase.auth.signUp({
    email: formData.get('email') as string,
    password: formData.get('password') as string,
    options: {
      data: {
        // No role selected initially
        full_name: formData.get('full_name') as string,
      },
    },
  });

  if (error) {
    return { success: false, error: error.message };
  }

  // Redirect to role selection or original destination
  redirect(returnTo);
}

3. Role Selection Flow

app/onboarding/select-role/actions.ts
'use server';

export async function selectRoleAction(role: string) {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  
  if (!user) {
    return { success: false, error: 'User not authenticated' };
  }

  // Update user profile with selected role
  const { error } = await supabase
    .from('profiles')
    .update({ role })
    .eq('id', user.id);

  if (error) {
    return { success: false, error: error.message };
  }

  return { success: true };
}

Route Protection

1. Platform Routes

Protected routes under /platform/* require authentication:
middleware.ts
export function middleware(request: NextRequest) {
  const { user } = await updateSession(request);

  // Protect platform routes
  if (request.nextUrl.pathname.startsWith('/platform') && !user) {
    const url = new URL('/auth/login', request.url);
    url.searchParams.set('return_to', request.nextUrl.pathname);
    return NextResponse.redirect(url);
  }

  return response;
}

export const config = {
  matcher: ['/platform/:path*'],
};

2. Role-Based Access Control

Different roles have access to different sections:
// Analyst-only routes
export default function AnalystDashboard() {
  return (
    <RequireContext roles={['analyst', 'admin']}>
      <AnalystTools />
    </RequireContext>
  );
}

// Business-only routes
export default function BusinessDashboard() {
  return (
    <RequireContext roles={['business', 'admin']}>
      <BusinessTools />
    </RequireContext>
  );
}

Security & UX

1. Smart Redirects

The return_to parameter ensures users never lose their place in the application.
// Example: Joining a ward
const returnTo = `/ward/${wardCode}`;
router.push(`/auth/login?return_to=${encodeURIComponent(returnTo)}`);

// After login, user returns to the exact ward page

2. Session Management

Sessions are managed via httpOnly cookies for security:
lib/supabase/middleware.ts
cookies: {
  get(name: string) {
    return request.cookies.get(name)?.value;
  },
  set(name: string, value: string, options: any) {
    response.cookies.set({ 
      name, 
      value, 
      ...options,
      httpOnly: true,  // Prevent XSS
      secure: process.env.NODE_ENV === 'production',  // HTTPS only
      sameSite: 'lax'  // CSRF protection
    });
  },
}

3. Password Reset Flow

app/auth/forgot-password/actions.ts
'use server';

export async function resetPasswordAction(email: string) {
  const supabase = await createClient();
  
  const { error } = await supabase.auth.resetPasswordForEmail(email, {
    redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/update-password`,
  });

  if (error) {
    return { success: false, error: error.message };
  }

  return { success: true };
}

Best Practices

1. Always Check Authentication Server-Side

Never rely solely on client-side authentication checks. Always verify on the server.
// ❌ Bad: Client-side only
'use client';
function handleAction() {
  if (!user) return;
  // Perform action
}

// ✅ Good: Server-side check
'use server';
export async function protectedAction() {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  
  if (!user) {
    return { success: false, error: 'Unauthenticated' };
  }
  
  // Perform action
}

2. Use RLS (Row Level Security)

Enable RLS policies in Supabase for database-level security:
supabase/migrations/20251201000000_profiles_rls.sql
-- Enable RLS on profiles table
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;

-- Policy: Users can only see their own profile
CREATE POLICY "Users can view own profile" ON profiles
  FOR SELECT USING (auth.uid() = id);

-- Policy: Users can update their own profile
CREATE POLICY "Users can update own profile" ON profiles
  FOR UPDATE USING (auth.uid() = id);

3. Implement Proper Error Handling

export async function protectedAction() {
  try {
    const supabase = await createClient();
    const { data: { user }, error: authError } = await supabase.auth.getUser();
    
    if (authError || !user) {
      return { 
        success: false, 
        error: 'Authentication required',
        code: 'UNAUTHENTICATED'
      };
    }

    // Perform action
    const { data, error } = await supabase.from('table').select('*');
    
    if (error) {
      return { 
        success: false, 
        error: error.message,
        code: 'DATABASE_ERROR'
      };
    }

    return { success: true, data };
  } catch (error) {
    return { 
      success: false, 
      error: 'Internal server error',
      code: 'INTERNAL_ERROR'
    };
  }
}

Common Issues & Solutions

Issue: Session Not Persisting

Solution: Ensure cookies are configured correctly in middleware:
cookies: {
  set(name, value, options) {
    response.cookies.set({ 
      name, 
      value, 
      ...options,
      path: '/',  // Important: set path to root
      maxAge: 60 * 60 * 24 * 7,  // 7 days
    });
  },
}

Issue: Redirect Loop

Solution: Check middleware matcher and avoid protecting auth routes:
export const config = {
  matcher: ['/platform/:path*', '/((?!auth|api|_next/static|_next/image|favicon.ico).*)'],
};

Issue: CORS Errors

Solution: Configure Supabase CORS settings:
// In Supabase Dashboard > Settings > API
Allowed Origins: 
- http://localhost:3000
- https://your-production-domain.com

Testing Authentication

Unit Tests

__tests__/auth/RequireAuth.test.tsx
import { render, screen } from '@testing-library/react';
import { RequireAuth } from '@/components/auth/RequireAuth';

describe('RequireAuth', () => {
  it('redirects unauthenticated users', () => {
    // Mock useSupabase to return null user
    // Render component
    // Expect redirect to login
  });

  it('renders children for authenticated users', () => {
    // Mock useSupabase to return user
    // Render component
    // Expect children to be rendered
  });
});

Integration Tests

__tests__/auth/login.test.ts
import { test, expect } from '@playwright/test';

test('login flow preserves return_to parameter', async ({ page }) => {
  // Navigate to protected page
  await page.goto('/platform/dashboard');
  
  // Should redirect to login
  expect(page.url()).toContain('/auth/login');
  expect(page.url()).toContain('return_to');
  
  // Fill login form
  await page.fill('[name="email"]', '[email protected]');
  await page.fill('[name="password"]', 'password');
  await page.click('button[type="submit"]');
  
  // Should redirect back to dashboard
  expect(page.url()).toBe('http://localhost:3000/platform/dashboard');
});

Summary

Visita’s authentication system follows these key principles:
  1. Intent-Based: Only request auth when needed
  2. Context Preservation: Always return users to where they were
  3. Role-Based Access: Granular permissions via RBAC
  4. Security First: Server-side validation, RLS policies, httpOnly cookies
  5. User-Friendly: Smooth flows, clear error messages, helpful UX