Dogwalk: primeiros componentes de autenticação
🐶 Dogwalk·

Dogwalk: primeiros componentes de autenticação

📖 9 min de leitura← Voltar para timeline

Contexto

O Dogwalk (PataPass) é um marketplace com dois perfis de usuário: tutor (quem contrata passeios) e walker (quem faz os passeios). Cada um vê um dashboard completamente diferente, com permissões e fluxos distintos.

Isso significa que a autenticação não podia ser um login simples + redirect. Precisava ser:

  • Role-based — tutor vs walker, cada um com rota própria
  • Segura — JWT com refresh token, Supabase como backend
  • Fluida — OAuth Google/GitHub pra não perder usuário no cadastro
  • Responsiva — funcionar em mobile (360px) e desktop

Esse post conta como construí cada peça.

O fluxo de autenticação

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

AuthContext — o coração

O estado de autenticação vive num React Context que envolve toda a aplicação:

// 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(() => {
    // Restaura sessão ao carregar a página
    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();

    // Escuta mudanças de auth (login/logout/refresh automático)
    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>;
}

O onAuthStateChange do Supabase já trata refresh automático do token — não preciso implementar refresh manual. Economizou umas 50 linhas de código.

ProtectedRoute — quem pode ver o quê

// 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!)) {
    // Redireciona pro dashboard correto se tiver role errada
    const correctPath = role === 'walker' ? '/walker/dashboard' : '/tutor/dashboard';
    return fallback || <Navigate to={correctPath} replace />;
  }

  return <>{children}</>;
}

// Uso nas rotas:
<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>

Layout condicional por role

Cada role vê um layout diferente — navegação inferior (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>
  );
}

A BottomNav muda completamente entre tutor e walker:

  • Tutor: Home → Buscar → Agendamentos → Perfil
  • Walker: Home → Disponibilidade → Passeios → Ganhos → Perfil

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 Vantagem
Google Client ID + Secret no Supabase Maior adoção (80% dos users)
GitHub OAuth App no GitHub Settings Devs testando a plataforma
Email/Senha Supabase nativo Fallback universal

Tratamento de erros de auth

Nem tudo são flores — erros de autenticação são frequentes e precisam de UX boa:

// src/hooks/useAuthError.ts
const AUTH_ERROR_MAP: Record<string, string> = {
  'Invalid login credentials': 'Email ou senha incorretos',
  'Email not confirmed': 'Confirme seu email antes de fazer login',
  'User already registered': 'Este email já está cadastrado',
  'Invalid email': 'Email inválido',
  'Rate limit exceeded': 'Muitas tentativas. Aguarde alguns minutos',
  'refresh_token_not_found': 'Sessão expirou. Faça login novamente',
};

export function useAuthError() {
  const translateError = (error: { message: string }) => {
    return AUTH_ERROR_MAP[error.message] || 'Erro inesperado. Tente novamente';
  };
  return { translateError };
}

Aprendizados

1. Supabase trata refresh token sozinho

No começo implementei refresh manual com setInterval. Depois descobri que o supabase-js já faz isso automático via onAuthStateChange. Removi 40 linhas de código.

2. Role no JWT vs role no banco

Tentei colocar role no JWT (custom claim). Mas se o admin muda a role do usuário, o JWT ainda tem a role velha até expirar. Solução: buscar role do banco no onAuthStateChange, não confiar no JWT claim.

3. OAuth redirect é frágil no mobile

O Google OAuth no mobile WebView às vezes não redireciona de volta. Solução: redirectTo com URL absoluta + ? em vez de # no callback.

4. Loading skeleton > spinner

O isLoading do AuthContext é crítico. Se não tratar, o usuário vê flash de conteúdo não-autenticado antes do redirect. Skeleton loading resolve — mostra estrutura vazia por 200-400ms enquanto verifica sessão.

Os números frios

Componente Linhas Arquivos Testes
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 testes de autenticação, 0 bugs de login nos últimos 60 dias. Tô satisfeito — mas ainda quero adicionar 2FA via TOTP.


Comandos úteis

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