Dogwalk: first authentication components
Dogwalk·

Dogwalk: first authentication components

Context

Dogwalk (PataPass) is a marketplace with two user profiles: tutor (who hires walks) and walker (who performs the walks). Each sees a completely different dashboard, with distinct permissions and flows.

This means authentication couldn’t be a simple login + redirect. It needed to be:

  • Role-based — tutor vs walker, each with their own route
  • Secure — JWT with refresh token, Supabase as backend
  • Seamless — Google/GitHub OAuth so users don’t drop off during signup
  • Responsive — work on mobile (360px) and desktop

This post covers how I built each piece.

The authentication flow

┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐
│  Login  │ ──▶ │ Supabase │ ──▶ │  JWT  │ ──▶ │  Role  │
│  Form  │  │  Auth  │  │ + Refresh│  │  Router  │
└──────────┘  └──────────┘  └──────────┘  └──────────┘
  │  │
  │ email/password  │ tutor → /tutor/dashboard
  │ OAuth Google  │ walker → /walker/dashboard
  │ OAuth GitHub  │ admin → /admin
  ▼  ▼
  ┌──────────┐  ┌──────────────┐
  │ 2FA (future)│  │ Conditional  │
  └──────────┘  │  Layout  │
  └──────────────┘

AuthContext — the heart

The authentication state lives in a React Context that wraps the entire application:

// src/contexts/AuthContext.tsx
interface AuthState {
  user: User | null;
  session: Session | null;
  role: 'tutor' | 'walker' | 'admin' | null;
  isLoading: boolean;
}

function AuthProvider({ children }: { children: React.ReactNode }) {
  const [state, setState] = useState<AuthState>({
  user: null,
  session: null,
  role: null,
  isLoading: true,
  });

  useEffect(() => {
  // Restores session when page loads
  const init = async () => {
  const { data: { session } } = await supabase.auth.getSession();
  if (session) {
  const role = await fetchUserRole(session.user.id);
  setState({ user: session.user, session, role, isLoading: false });
  } else {
  setState(prev => ({ ...prev, isLoading: false }));
  }
  };
  init();

  // Listens for auth changes (login/logout/automatic refresh)
  const { data: { subscription } } = supabase.auth.onAuthStateChange(
  async (event, session) => {
  if (session) {
  const role = await fetchUserRole(session.user.id);
  setState({ user: session.user, session, role, isLoading: false });
  } else {
  setState({ user: null, session: null, role: null, isLoading: false });
  }
  }
  );

  return () => subscription.unsubscribe();
  }, []);

  return <AuthContext.Provider value={state}>{children}</AuthContext.Provider>;
}

Supabase’s onAuthStateChange already handles automatic token refresh — I didn’t need to implement manual refresh. Saved about 50 lines of code.

ProtectedRoute — who can see what

// src/components/ProtectedRoute.tsx
interface Props {
  allowedRoles: Array<'tutor' | 'walker' | 'admin'>;
  children: React.ReactNode;
  fallback?: React.ReactNode;
}

function ProtectedRoute({ allowedRoles, children, fallback }: Props) {
  const { user, role, isLoading } = useAuth();

  if (isLoading) {
  return <LoadingSkeleton />;
  }

  if (!user) {
  return <Navigate to="/login" state={{ from: location }} replace />;
  }

  if (!allowedRoles.includes(role!)) {
  // Redirects to the correct dashboard if wrong role
  const correctPath = role === 'walker' ? '/walker/dashboard' : '/tutor/dashboard';
  return fallback || <Navigate to={correctPath} replace />;
  }

  return <>{children}</>;
}

// Usage in routes:
<Routes>
  <Route path="/tutor/dashboard" element={
  <ProtectedRoute allowedRoles={['tutor']}>
  <TutorDashboard />
  </ProtectedRoute>
  } />
  <Route path="/walker/dashboard" element={
  <ProtectedRoute allowedRoles={['walker']}>
  <WalkerDashboard />
  </ProtectedRoute>
  } />
  <Route path="/admin" element={
  <ProtectedRoute allowedRoles={['admin']}>
  <AdminPanel />
  </ProtectedRoute>
  } />
</Routes>

Conditional layout by role

Each role sees a different layout — bottom navigation (mobile), sidebar (desktop), header:

// src/layouts/RootLayout.tsx
function RootLayout() {
  const { role } = useAuth();

  const navigation = role === 'walker' ? WALKER_NAV : TUTOR_NAV;

  return (
  <div className="flex flex-col min-h-screen">
  <Header 
  actions={role === 'walker' ? walkerActions : tutorActions} 
  />
  <main className="flex-1 pb-16 md:pb-0">
  <Outlet />
  </main>
  <BottomNav items={navigation} />
  </div>
  );
}

The BottomNav changes completely between tutor and walker:

  • Tutor: Home → Search → Appointments → Profile
  • Walker: Home → Availability → Walks → Earnings → Profile

OAuth providers

// src/services/auth.ts
export async function signInWithProvider(provider: 'google' | 'github') {
  const { data, error } = await supabase.auth.signInWithOAuth({
  provider,
  options: {
  redirectTo: `${window.location.origin}/auth/callback`,
  queryParams: provider === 'google' 
  ? { access_type: 'offline', prompt: 'consent' }
  : undefined,
  },
  });
  
  if (error) throw error;
  return data;
}
Provider Setup Advantage
Google Client ID + Secret in Supabase Highest adoption (80% of users)
GitHub OAuth App in GitHub Settings Devs testing the platform
Email/Password Native Supabase Universal fallback

Auth error handling

Not everything is smooth sailing — auth errors are frequent and need good UX:

// src/hooks/useAuthError.ts
const AUTH_ERROR_MAP: Record<string, string> = {
  'Invalid login credentials': 'Incorrect email or password',
  'Email not confirmed': 'Confirm your email before logging in',
  'User already registered': 'This email is already registered',
  'Invalid email': 'Invalid email',
  'Rate limit exceeded': 'Too many attempts. Please wait a few minutes',
  'refresh_token_not_found': 'Session expired. Please log in again',
};

export function useAuthError() {
  const translateError = (error: { message: string }) => {
  return AUTH_ERROR_MAP[error.message] || 'Unexpected error. Please try again';
  };
  return { translateError };
}

Lessons learned

1. Supabase handles refresh token on its own

At first I implemented manual refresh with setInterval. Then I discovered that supabase-js already does this automatically via onAuthStateChange. Removed 40 lines of code.

2. Role in JWT vs role in database

I tried putting the role in the JWT (custom claim). But if an admin changes the user’s role, the JWT still has the old role until it expires. Solution: fetch role from the database in onAuthStateChange, don’t rely on the JWT claim.

3. OAuth redirect is fragile on mobile

Google OAuth in mobile WebView sometimes doesn’t redirect back. Solution: redirectTo with absolute URL + ? instead of # in the callback.

4. Loading skeleton > spinner

The isLoading from AuthContext is critical. If not handled, the user sees a flash of unauthenticated content before redirect. Skeleton loading solves it — shows an empty structure for 200-400ms while checking the session.

The cold numbers

Component Lines Files Tests
AuthContext 85 2 12
ProtectedRoute 52 1 8
Login Page 210 3 15
Register Page 195 3 12
OAuth handlers 78 1 6
Role utilities 45 2 8
Total 665 12 61

61 authentication tests, 0 login bugs in the last 60 days. I’m satisfied — but I still want to add 2FA via TOTP.


Useful commands

~/lifelog — bash
$cat about.txt
╔══════════════════════════════════════╗
║  Samuel Medeiros                    ║
║  Senior Software Engineer           ║
║  Stack: Python · TypeScript · Rust  ║
║  Projetos: Arachne, Dogwalk,        ║
║            Capivara, TatuEngine      ║
╚══════════════════════════════════════╝
      
$