Skip to content

← Back to DWS Receipts

DWS Receipts uses Supabase SMS OTP authentication. Users log in with their phone number, receive a 4-digit code via Twilio, and are routed based on their role.

1. User enters phone number
2. POST /api/auth/send-otp → Supabase sends SMS
3. User enters 4-digit code
4. POST /api/auth/verify-otp → Session created
5. Client calls supabase.auth.setSession()
6. Redirect based on role:
- employee → /employee
- admin → /dashboard
Step 1: Phone Entry
Login Phone
Step 2: OTP Verification
Login OTP
FilePurpose
app/login/page.tsxLogin UI with phone/OTP forms
app/api/auth/send-otp/route.tsSends OTP via Supabase
app/api/auth/verify-otp/route.tsVerifies OTP, creates session
app/page.tsxRoot redirect based on role
lib/phone.tsPhone number formatting

All phones stored in E.164 format: +12223334444

lib/phone.ts
formatUSPhoneNumber("5551234567") // → "+15551234567"
formatPhoneForDisplay("+15551234567") // → "(555) 123-4567"
  • Duration: 6 months (configured in supabaseServerClient.ts)
  • Storage: HTTP-only cookies (server) + localStorage (browser)
  • Refresh: Automatic via @supabase/ssr
lib/supabaseServerClient.ts
const SIX_MONTHS_SECONDS = 60 * 60 * 24 * 180;
cookies: {
maxAge: SIX_MONTHS_SECONDS,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production'
}

Roles stored in user_profiles.role: 'employee' or 'admin'

Each protected page checks auth in useEffect:

// Simplified pattern used in all protected pages
const { data: { session } } = await supabase.auth.getSession();
if (!session) redirect('/login');
const { data: profile } = await supabase
.from('user_profiles')
.select('role')
.eq('user_id', session.user.id)
.single();
if (profile.role !== 'admin') redirect('/employee');
RouteRequired Role
/loginNone (public)
/employeeemployee
/dashboardadmin
/batch-reviewadmin
/usersadmin

All API routes verify session:

const supabase = await createSupabaseServerClient();
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

Admin endpoints add role check:

const { data: profile } = await supabase
.from('user_profiles')
.select('role')
.eq('user_id', session.user.id)
.single();
if (profile.role !== 'admin') {
return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
}

If a user logs in without a profile, the employee page creates one:

// app/employee/page.tsx:158-168
await supabase.from('user_profiles').insert({
user_id: session.user.id,
role: 'employee', // Default role
full_name: session.user.phone
});

Pages subscribe to auth changes to handle logout:

supabase.auth.onAuthStateChange((event) => {
if (event === 'SIGNED_OUT') {
router.push('/login');
}
});