
Dogwalk: primeiros componentes de autenticação
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 |
|---|---|---|
| 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.