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
-
Public by Default
- Homepage, Ward Maps, Business Directory, and Public Alerts are accessible to everyone
- No global auth walls
-
Auth on Intent
- Login is triggered only when a user tries to:
- Join a Ward
- Post a Comment
- Claim a Business
- Access Analyst Tools
-
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
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
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
Step 1: User Attempts Protected Action
// 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);
}
Step 2: Login Page Preserves Context
'use client';
import { useSearchParams } from 'next/navigation';
export default function LoginPage() {
const searchParams = useSearchParams();
const returnTo = searchParams.get('return_to') || '/';
return (
<LoginForm returnTo={returnTo} />
);
}
Step 3: After Login, Redirect Back
'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
Protected routes under /platform/* require authentication:
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:
- Intent-Based: Only request auth when needed
- Context Preservation: Always return users to where they were
- Role-Based Access: Granular permissions via RBAC
- Security First: Server-side validation, RLS policies, httpOnly cookies
- User-Friendly: Smooth flows, clear error messages, helpful UX