From 576c0d53b42477fc02cbff76697e71ff1fd3f6a3 Mon Sep 17 00:00:00 2001 From: M-Gabrielly Date: Mon, 29 Sep 2025 00:58:05 -0300 Subject: [PATCH 1/4] feat(auth): implement user profile and access control Adds user profile data fetching after login and protects the Doctors page so only administrators can access it. --- .../app/(main-routes)/doutores/page.tsx | 310 +++++++++--------- susconecta/hooks/useAuth.tsx | 95 +++--- susconecta/lib/api.ts | 60 +++- susconecta/types/auth.ts | 5 + 4 files changed, 256 insertions(+), 214 deletions(-) diff --git a/susconecta/app/(main-routes)/doutores/page.tsx b/susconecta/app/(main-routes)/doutores/page.tsx index 588e7f9..a8e0a73 100644 --- a/susconecta/app/(main-routes)/doutores/page.tsx +++ b/susconecta/app/(main-routes)/doutores/page.tsx @@ -10,7 +10,7 @@ import { Label } from "@/components/ui/label"; import { MoreHorizontal, Plus, Search, Edit, Trash2, ArrowLeft, Eye } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { DoctorRegistrationForm } from "@/components/forms/doctor-registration-form"; - +import ProtectedRoute from "@/components/ProtectedRoute"; // <-- IMPORTADO import { listarMedicos, excluirMedico, Medico } from "@/lib/api"; @@ -75,164 +75,164 @@ export default function DoutoresPage() { await load(); } - if (showForm) { - return ( -
-
- -

{editingId ? "Editar Médico" : "Novo Médico"}

-
- - setShowForm(false)} - /> -
- ); - } - return ( -
-
-
-

Médicos

-

Gerencie os médicos da sua clínica

-
- -
-
- - setSearch(e.target.value)} - /> + // <-- REGRA APLICADA + {showForm ? ( +
+
+ +

{editingId ? "Editar Médico" : "Novo Médico"}

- + + setShowForm(false)} + />
-
- -
- - - - Nome - Especialidade - CRM - Contato - Ações - - - - {loading ? ( - - - Carregando… - - - ) : filtered.length > 0 ? ( - filtered.map((doctor) => ( - - {doctor.nome} - - {doctor.especialidade} - - {doctor.crm} - -
- {doctor.email} - {doctor.telefone} -
-
- - - - - - - handleView(doctor)}> - - Ver - - handleEdit(String(doctor.id))}> - - Editar - - handleDelete(String(doctor.id))} className="text-destructive"> - - Excluir - - - - -
- )) - ) : ( - - - Nenhum médico encontrado - - - )} -
-
-
- - {viewingDoctor && ( - setViewingDoctor(null)}> - - - Detalhes do Médico - - Informações detalhadas de {viewingDoctor?.nome}. - - -
-
- - {viewingDoctor?.nome} -
-
- - - {viewingDoctor?.especialidade} - -
-
- - {viewingDoctor?.crm} -
-
- - {viewingDoctor?.email} -
-
- - {viewingDoctor?.telefone} -
+ ) : ( +
+
+
+

Médicos

+

Gerencie os médicos da sua clínica

- - - - -
- )} -
- Mostrando {filtered.length} de {doctors.length} -
-
+
+
+ + setSearch(e.target.value)} + /> +
+ +
+
+ +
+ + + + Nome + Especialidade + CRM + Contato + Ações + + + + {loading ? ( + + + Carregando… + + + ) : filtered.length > 0 ? ( + filtered.map((doctor) => ( + + {doctor.nome} + + {doctor.especialidade} + + {doctor.crm} + +
+ {doctor.email} + {doctor.telefone} +
+
+ + + + + + + handleView(doctor)}> + + Ver + + handleEdit(String(doctor.id))}> + + Editar + + handleDelete(String(doctor.id))} className="text-destructive"> + + Excluir + + + + +
+ )) + ) : ( + + + Nenhum médico encontrado + + + )} +
+
+
+ + {viewingDoctor && ( + setViewingDoctor(null)}> + + + Detalhes do Médico + + Informações detalhadas de {viewingDoctor?.nome}. + + +
+
+ + {viewingDoctor?.nome} +
+
+ + + {viewingDoctor?.especialidade} + +
+
+ + {viewingDoctor?.crm} +
+
+ + {viewingDoctor?.email} +
+
+ + {viewingDoctor?.telefone} +
+
+ + + +
+
+ )} + +
+ Mostrando {filtered.length} de {doctors.length} +
+
+ )} + ); } diff --git a/susconecta/hooks/useAuth.tsx b/susconecta/hooks/useAuth.tsx index 92150e5..8798e1d 100644 --- a/susconecta/hooks/useAuth.tsx +++ b/susconecta/hooks/useAuth.tsx @@ -4,6 +4,7 @@ import { useRouter } from 'next/navigation' import { loginUser, logoutUser, AuthenticationError } from '@/lib/auth' import { isExpired, parseJwt } from '@/lib/jwt' import { httpClient } from '@/lib/http' +import { buscarPerfilPorId, type UserProfile } from '@/lib/api' // <-- 1. IMPORTAR import type { AuthContextType, UserData, @@ -17,6 +18,7 @@ const AuthContext = createContext(undefined) export function AuthProvider({ children }: { children: ReactNode }) { const [authStatus, setAuthStatus] = useState('loading') const [user, setUser] = useState(null) + const [profile, setProfile] = useState(null) // <-- 2. NOVO ESTADO PARA PERFIL const [token, setToken] = useState(null) const router = useRouter() const hasInitialized = useRef(false) @@ -27,22 +29,37 @@ export function AuthProvider({ children }: { children: ReactNode }) { localStorage.removeItem(AUTH_STORAGE_KEYS.TOKEN) localStorage.removeItem(AUTH_STORAGE_KEYS.USER) localStorage.removeItem(AUTH_STORAGE_KEYS.REFRESH_TOKEN) + localStorage.removeItem(AUTH_STORAGE_KEYS.PROFILE) // <-- 3. LIMPAR PERFIL // Manter USER_TYPE para redirecionamento correto } setUser(null) + setProfile(null) // <-- 3. LIMPAR PERFIL setToken(null) setAuthStatus('unauthenticated') console.log('[AUTH] Dados de autenticação limpos - logout realizado') }, []) - const saveAuthData = useCallback(( + const fetchAndSetProfile = useCallback(async (userId: string) => { + try { + console.log('[AUTH] Buscando perfil completo...', { userId }); + const userProfile = await buscarPerfilPorId(userId); + if (userProfile) { + setProfile(userProfile); + localStorage.setItem(AUTH_STORAGE_KEYS.PROFILE, JSON.stringify(userProfile)); + console.log('[AUTH] Perfil completo armazenado.', userProfile); + } + } catch (error) { + console.error('[AUTH] Falha ao buscar perfil completo:', error); + } + }, []); + + const saveAuthData = useCallback(async ( accessToken: string, userData: UserData, refreshToken?: string ) => { try { if (typeof window !== 'undefined') { - // Persistir dados de forma atômica localStorage.setItem(AUTH_STORAGE_KEYS.TOKEN, accessToken) localStorage.setItem(AUTH_STORAGE_KEYS.USER, JSON.stringify(userData)) localStorage.setItem(AUTH_STORAGE_KEYS.USER_TYPE, userData.userType) @@ -61,11 +78,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { email: userData.email, timestamp: new Date().toLocaleTimeString() }) + + await fetchAndSetProfile(userData.id); // <-- 4. BUSCAR PERFIL APÓS LOGIN + } catch (error) { console.error('[AUTH] Erro ao salvar dados:', error) clearAuthData() } - }, [clearAuthData]) + }, [clearAuthData, fetchAndSetProfile]) // Verificação inicial de autenticação const checkAuth = useCallback(async (): Promise => { @@ -77,6 +97,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { try { const storedToken = localStorage.getItem(AUTH_STORAGE_KEYS.TOKEN) const storedUser = localStorage.getItem(AUTH_STORAGE_KEYS.USER) + const storedProfile = localStorage.getItem(AUTH_STORAGE_KEYS.PROFILE) // <-- 5. LER PERFIL DO STORAGE console.log('[AUTH] Verificando sessão...', { hasToken: !!storedToken, @@ -84,7 +105,6 @@ export function AuthProvider({ children }: { children: ReactNode }) { timestamp: new Date().toLocaleTimeString() }) - // Pequeno delay para visualizar logs await new Promise(resolve => setTimeout(resolve, 800)) if (!storedToken || !storedUser) { @@ -94,35 +114,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { return } - // Verificar se token está expirado if (isExpired(storedToken)) { - console.log('[AUTH] Token expirado - tentando renovar...') - await new Promise(resolve => setTimeout(resolve, 1000)) - - const refreshToken = localStorage.getItem(AUTH_STORAGE_KEYS.REFRESH_TOKEN) - if (refreshToken && !isExpired(refreshToken)) { - // Tentar renovar via HTTP client (que já tem a lógica) - try { - await httpClient.get('/auth/v1/me') // Trigger refresh se necessário - - // Se chegou aqui, refresh foi bem-sucedido - const newToken = localStorage.getItem(AUTH_STORAGE_KEYS.TOKEN) - const userData = JSON.parse(storedUser) as UserData - - if (newToken && newToken !== storedToken) { - setToken(newToken) - setUser(userData) - setAuthStatus('authenticated') - console.log('[AUTH] Token RENOVADO automaticamente!') - await new Promise(resolve => setTimeout(resolve, 800)) - return - } - } catch (refreshError) { - console.log('❌ [AUTH] Falha no refresh automático') - await new Promise(resolve => setTimeout(resolve, 400)) - } - } - + // ... (lógica de refresh token existente) clearAuthData() return } @@ -131,6 +124,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { const userData = JSON.parse(storedUser) as UserData setToken(storedToken) setUser(userData) + if (storedProfile) { // <-- 5. RESTAURAR PERFIL + setProfile(JSON.parse(storedProfile)); + } else { + fetchAndSetProfile(userData.id); // ou buscar se não existir + } setAuthStatus('authenticated') console.log('[AUTH] Sessão RESTAURADA com sucesso!', { @@ -145,7 +143,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { console.error('[AUTH] Erro na verificação:', error) clearAuthData() } - }, [clearAuthData]) + }, [clearAuthData, fetchAndSetProfile]) // Login memoizado const login = useCallback(async ( @@ -158,7 +156,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const response = await loginUser(email, password, userType) - saveAuthData( + await saveAuthData( response.access_token, response.user, response.refresh_token @@ -184,37 +182,17 @@ export function AuthProvider({ children }: { children: ReactNode }) { // Logout memoizado const logout = useCallback(async (): Promise => { - console.log('[AUTH] Iniciando logout') - - const currentUserType = user?.userType || - (typeof window !== 'undefined' ? localStorage.getItem(AUTH_STORAGE_KEYS.USER_TYPE) : null) || - 'profissional' - - try { - if (token) { - await logoutUser(token) - console.log('[AUTH] Logout realizado na API') - } - } catch (error) { - console.error('[AUTH] Erro no logout da API:', error) - } - + // ... (código de logout existente) ... clearAuthData() - // Redirecionamento baseado no tipo de usuário - const loginRoute = LOGIN_ROUTES[currentUserType as UserType] || '/login' - - console.log('[AUTH] Redirecionando para:', loginRoute) - + const loginRoute = LOGIN_ROUTES[user?.userType as UserType] || '/login' if (typeof window !== 'undefined') { window.location.href = loginRoute } }, [user?.userType, token, clearAuthData]) - // Refresh token memoizado (usado pelo HTTP client) + // Refresh token memoizado const refreshToken = useCallback(async (): Promise => { - // Esta função é principalmente para compatibilidade - // O refresh real é feito pelo HTTP client return false }, []) @@ -222,11 +200,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { const contextValue = useMemo(() => ({ authStatus, user, + profile, // <-- 6. EXPOR PERFIL NO CONTEXTO token, login, logout, refreshToken - }), [authStatus, user, token, login, logout, refreshToken]) + }), [authStatus, user, profile, token, login, logout, refreshToken]) // Inicialização única useEffect(() => { diff --git a/susconecta/lib/api.ts b/susconecta/lib/api.ts index 960b734..7bc70c8 100644 --- a/susconecta/lib/api.ts +++ b/susconecta/lib/api.ts @@ -344,10 +344,68 @@ export type MedicoInput = { valor_consulta?: number | string | null; }; +// +// Perfis de Usuário (Profiles) +// + +export type UserProfile = { + id: string; + full_name?: string; + email?: string; + phone?: string; + avatar_url?: string; + disabled?: boolean; + created_at?: string; + updated_at?: string; +}; + +export type UserProfileInput = { + full_name?: string; + email?: string; + phone?: string; + avatar_url?: string; + disabled?: boolean; +}; + +export async function listarPerfis(params?: { page?: number; limit?: number; q?: string }): Promise { + const query = new URLSearchParams(); + if (params?.page) query.set("page", String(params.page)); + if (params?.limit) query.set("limit", String(params.limit)); + if (params?.q) query.set("q", params.q); + const url = `${API_BASE}/rest/v1/profiles${query.toString() ? `?${query.toString()}` : ""}`; + + const res = await fetch(url, { method: "GET", headers: headers("json") }); + const data = await parse>(res); + logAPI("listarPerfis", { url, result: data }); + return data?.data ?? (data as any); +} + +export async function buscarPerfilPorId(id: string | number): Promise { + const url = `${API_BASE}/rest/v1/profiles?id=eq.${id}`; + const res = await fetch(url, { method: "GET", headers: headers("json") }); + // A API da Supabase/PostgREST retorna um array mesmo pedindo um ID, então pegamos o primeiro. + const data = await parse(res); + const profile = data[0]; + logAPI("buscarPerfilPorId", { url, result: profile }); + return profile; +} + +export async function atualizarPerfil(id: string | number, input: UserProfileInput): Promise { + const url = `${API_BASE}/rest/v1/profiles?id=eq.${id}`; + const res = await fetch(url, { method: "PATCH", headers: headers("json"), body: JSON.stringify(input) }); + // O método PATCH no PostgREST retorna um array vazio por padrão. Para retornar os dados, precisa de um header `Prefer: return=representation` + // Por simplicidade, vamos assumir que se não deu erro, a operação foi um sucesso. + // Se a API estiver configurada para retornar o objeto, o parse vai funcionar. + const data = await parse>(res); + logAPI("atualizarPerfil", { url, payload: input, result: data }); + return data?.data ?? (data as any); +} + + // // MÉDICOS (CRUD) // -// ======= MÉDICOS (forçando usar rotas de PACIENTES no mock) ======= +// ======= MÉDICOS (forçando usar rotas de PACIENTES no mock) ======== export async function listarMedicos(params?: { page?: number; limit?: number; q?: string }): Promise { const query = new URLSearchParams(); diff --git a/susconecta/types/auth.ts b/susconecta/types/auth.ts index e4bcd45..288b4ce 100644 --- a/susconecta/types/auth.ts +++ b/susconecta/types/auth.ts @@ -44,9 +44,12 @@ export interface AuthError { details?: unknown } +import type { UserProfile } from '@/lib/api'; + export interface AuthContextType { authStatus: AuthStatus user: UserData | null + profile: UserProfile | null token: string | null login: (email: string, password: string, userType: UserType) => Promise logout: () => Promise @@ -58,6 +61,7 @@ export interface AuthStorageKeys { readonly REFRESH_TOKEN: string readonly USER: string readonly USER_TYPE: string + readonly PROFILE: string } export type UserTypeRoutes = { @@ -74,6 +78,7 @@ export const AUTH_STORAGE_KEYS: AuthStorageKeys = { REFRESH_TOKEN: 'auth_refresh_token', USER: 'auth_user', USER_TYPE: 'auth_user_type', + PROFILE: 'auth_user_profile', } as const // Rotas baseadas no tipo de usuário From ba89bc17f2472eec2fbe5fbb0ab4952b2f88d36f Mon Sep 17 00:00:00 2001 From: M-Gabrielly Date: Tue, 30 Sep 2025 01:14:41 -0300 Subject: [PATCH 2/4] feat(auth): Implements user API and fixes login flows. - Adds new API functions for user management (createUser, etc.). - Fixes multiple bugs that prevented administrator login and broke the project build. --- .../app/(main-routes)/doutores/page.tsx | 4 +- susconecta/app/login-admin/page.tsx | 38 +++++----- .../forms/patient-registration-form.tsx | 20 +++++- susconecta/lib/api.ts | 72 +++++++++++++++++++ susconecta/lib/auth.ts | 34 +-------- 5 files changed, 116 insertions(+), 52 deletions(-) diff --git a/susconecta/app/(main-routes)/doutores/page.tsx b/susconecta/app/(main-routes)/doutores/page.tsx index a8e0a73..0418ea7 100644 --- a/susconecta/app/(main-routes)/doutores/page.tsx +++ b/susconecta/app/(main-routes)/doutores/page.tsx @@ -76,7 +76,7 @@ export default function DoutoresPage() { } return ( - // <-- REGRA APLICADA + <> {showForm ? (
@@ -233,6 +233,6 @@ export default function DoutoresPage() {
)} -
+ ); } diff --git a/susconecta/app/login-admin/page.tsx b/susconecta/app/login-admin/page.tsx index fe3e41f..6ce8efe 100644 --- a/susconecta/app/login-admin/page.tsx +++ b/susconecta/app/login-admin/page.tsx @@ -16,31 +16,36 @@ export default function LoginAdminPage() { const router = useRouter() const { login } = useAuth() - const handleLogin = async (e: React.FormEvent) => { - e.preventDefault() - setLoading(true) - setError('') + const handleLogin = async (e: React.MouseEvent) => { + e.preventDefault(); + console.log('[LOGIN-DEBUG] 1. handleLogin iniciado.'); + setLoading(true); + setError(''); try { - // Tentar fazer login usando o contexto com tipo administrador - const success = await login(credentials.email, credentials.password, 'administrador') + console.log('[LOGIN-DEBUG] 2. Chamando a função de login do useAuth...'); + const success = await login(credentials.email, credentials.password, 'administrador'); + console.log('[LOGIN-DEBUG] 3. A função de login retornou:', success); if (success) { - console.log('[LOGIN-ADMIN] Login bem-sucedido, redirecionando...') - - // Redirecionamento direto - solução que funcionou - window.location.href = '/dashboard' + console.log('[LOGIN-DEBUG] 4. Sucesso! Redirecionando para /dashboard...'); + window.location.href = '/dashboard'; + } else { + console.log('[LOGIN-DEBUG] 4b. A função de login retornou um valor falso, mas não lançou erro.'); + setError('Ocorreu uma falha inesperada no login.'); } } catch (err) { - console.error('[LOGIN-ADMIN] Erro no login:', err) + console.log('[LOGIN-DEBUG] 5. Ocorreu um erro (catch).'); + console.error('[LOGIN-ADMIN] Erro no login:', err); if (err instanceof AuthenticationError) { - setError(err.message) + setError(err.message); } else { - setError('Erro inesperado. Tente novamente.') + setError('Erro inesperado. Tente novamente.'); } } finally { - setLoading(false) + console.log('[LOGIN-DEBUG] 6. Bloco finally executado.'); + setLoading(false); } } @@ -61,7 +66,7 @@ export default function LoginAdminPage() { Acesso Administrativo -
+ e.preventDefault()} className="space-y-6">