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.
This commit is contained in:
parent
9795011028
commit
5655d0c607
@ -10,7 +10,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { MoreHorizontal, Plus, Search, Edit, Trash2, ArrowLeft, Eye } from "lucide-react";
|
import { MoreHorizontal, Plus, Search, Edit, Trash2, ArrowLeft, Eye } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { DoctorRegistrationForm } from "@/components/forms/doctor-registration-form";
|
import { DoctorRegistrationForm } from "@/components/forms/doctor-registration-form";
|
||||||
|
import ProtectedRoute from "@/components/ProtectedRoute"; // <-- IMPORTADO
|
||||||
|
|
||||||
import { listarMedicos, excluirMedico, Medico } from "@/lib/api";
|
import { listarMedicos, excluirMedico, Medico } from "@/lib/api";
|
||||||
|
|
||||||
@ -126,164 +126,164 @@ setDoctors((list ?? []).map(normalizeMedico));
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (showForm) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 p-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => setShowForm(false)}>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<h1 className="text-2xl font-bold">{editingId ? "Editar Médico" : "Novo Médico"}</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DoctorRegistrationForm
|
|
||||||
inline
|
|
||||||
mode={editingId ? "edit" : "create"}
|
|
||||||
doctorId={editingId ? Number(editingId) : null}
|
|
||||||
onSaved={handleSaved}
|
|
||||||
onClose={() => setShowForm(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 p-6">
|
<ProtectedRoute requiredUserType={['administrador']}> // <-- REGRA APLICADA
|
||||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
{showForm ? (
|
||||||
<div>
|
<div className="space-y-6 p-6">
|
||||||
<h1 className="text-2xl font-bold">Médicos</h1>
|
<div className="flex items-center gap-4">
|
||||||
<p className="text-muted-foreground">Gerencie os médicos da sua clínica</p>
|
<Button variant="ghost" size="icon" onClick={() => setShowForm(false)}>
|
||||||
</div>
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
<div className="flex items-center gap-2">
|
<h1 className="text-2xl font-bold">{editingId ? "Editar Médico" : "Novo Médico"}</h1>
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
className="pl-8 w-80"
|
|
||||||
placeholder="Buscar por nome, CRM ou especialidade…"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleAdd} disabled={loading}>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<DoctorRegistrationForm
|
||||||
Novo Médico
|
inline
|
||||||
</Button>
|
mode={editingId ? "edit" : "create"}
|
||||||
|
doctorId={editingId ? Number(editingId) : null}
|
||||||
|
onSaved={handleSaved}
|
||||||
|
onClose={() => setShowForm(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
<div className="border rounded-lg overflow-hidden">
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
<Table>
|
<div>
|
||||||
<TableHeader>
|
<h1 className="text-2xl font-bold">Médicos</h1>
|
||||||
<TableRow>
|
<p className="text-muted-foreground">Gerencie os médicos da sua clínica</p>
|
||||||
<TableHead>Nome</TableHead>
|
|
||||||
<TableHead>Especialidade</TableHead>
|
|
||||||
<TableHead>CRM</TableHead>
|
|
||||||
<TableHead>Contato</TableHead>
|
|
||||||
<TableHead className="w-[100px]">Ações</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{loading ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
|
||||||
Carregando…
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : filtered.length > 0 ? (
|
|
||||||
filtered.map((doctor) => (
|
|
||||||
<TableRow key={doctor.id}>
|
|
||||||
<TableCell className="font-medium">{doctor.nome}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="outline">{doctor.especialidade}</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{doctor.crm}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span>{doctor.email}</span>
|
|
||||||
<span className="text-sm text-muted-foreground">{doctor.telefone}</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button className="h-8 w-8 p-0 flex items-center justify-center rounded-md hover:bg-accent">
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Abrir menu</span>
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={() => handleView(doctor)}>
|
|
||||||
<Eye className="mr-2 h-4 w-4" />
|
|
||||||
Ver
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => handleEdit(String(doctor.id))}>
|
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
|
||||||
Editar
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => handleDelete(String(doctor.id))} className="text-destructive">
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
Excluir
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
|
||||||
Nenhum médico encontrado
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{viewingDoctor && (
|
|
||||||
<Dialog open={!!viewingDoctor} onOpenChange={() => setViewingDoctor(null)}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Detalhes do Médico</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Informações detalhadas de {viewingDoctor?.nome}.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="grid gap-4 py-4">
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label className="text-right">Nome</Label>
|
|
||||||
<span className="col-span-3 font-medium">{viewingDoctor?.nome}</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label className="text-right">Especialidade</Label>
|
|
||||||
<span className="col-span-3">
|
|
||||||
<Badge variant="outline">{viewingDoctor?.especialidade}</Badge>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label className="text-right">CRM</Label>
|
|
||||||
<span className="col-span-3">{viewingDoctor?.crm}</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label className="text-right">Email</Label>
|
|
||||||
<span className="col-span-3">{viewingDoctor?.email}</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label className="text-right">Telefone</Label>
|
|
||||||
<span className="col-span-3">{viewingDoctor?.telefone}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
|
||||||
<Button onClick={() => setViewingDoctor(null)}>Fechar</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="flex items-center gap-2">
|
||||||
Mostrando {filtered.length} de {doctors.length}
|
<div className="relative">
|
||||||
</div>
|
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
</div>
|
<Input
|
||||||
|
className="pl-8 w-80"
|
||||||
|
placeholder="Buscar por nome, CRM ou especialidade…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleAdd} disabled={loading}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Novo Médico
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Nome</TableHead>
|
||||||
|
<TableHead>Especialidade</TableHead>
|
||||||
|
<TableHead>CRM</TableHead>
|
||||||
|
<TableHead>Contato</TableHead>
|
||||||
|
<TableHead className="w-[100px]">Ações</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||||
|
Carregando…
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : filtered.length > 0 ? (
|
||||||
|
filtered.map((doctor) => (
|
||||||
|
<TableRow key={doctor.id}>
|
||||||
|
<TableCell className="font-medium">{doctor.nome}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">{doctor.especialidade}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{doctor.crm}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{doctor.email}</span>
|
||||||
|
<span className="text-sm text-muted-foreground">{doctor.telefone}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button className="h-8 w-8 p-0 flex items-center justify-center rounded-md hover:bg-accent">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Abrir menu</span>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => handleView(doctor)}>
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
Ver
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleEdit(String(doctor.id))}>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
Editar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleDelete(String(doctor.id))} className="text-destructive">
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Excluir
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||||
|
Nenhum médico encontrado
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{viewingDoctor && (
|
||||||
|
<Dialog open={!!viewingDoctor} onOpenChange={() => setViewingDoctor(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Detalhes do Médico</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Informações detalhadas de {viewingDoctor?.nome}.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label className="text-right">Nome</Label>
|
||||||
|
<span className="col-span-3 font-medium">{viewingDoctor?.nome}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label className="text-right">Especialidade</Label>
|
||||||
|
<span className="col-span-3">
|
||||||
|
<Badge variant="outline">{viewingDoctor?.especialidade}</Badge>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label className="text-right">CRM</Label>
|
||||||
|
<span className="col-span-3">{viewingDoctor?.crm}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label className="text-right">Email</Label>
|
||||||
|
<span className="col-span-3">{viewingDoctor?.email}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label className="text-right">Telefone</Label>
|
||||||
|
<span className="col-span-3">{viewingDoctor?.telefone}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => setViewingDoctor(null)}>Fechar</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Mostrando {filtered.length} de {doctors.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ProtectedRoute>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { useRouter } from 'next/navigation'
|
|||||||
import { loginUser, logoutUser, AuthenticationError } from '@/lib/auth'
|
import { loginUser, logoutUser, AuthenticationError } from '@/lib/auth'
|
||||||
import { isExpired, parseJwt } from '@/lib/jwt'
|
import { isExpired, parseJwt } from '@/lib/jwt'
|
||||||
import { httpClient } from '@/lib/http'
|
import { httpClient } from '@/lib/http'
|
||||||
|
import { buscarPerfilPorId, type UserProfile } from '@/lib/api' // <-- 1. IMPORTAR
|
||||||
import type {
|
import type {
|
||||||
AuthContextType,
|
AuthContextType,
|
||||||
UserData,
|
UserData,
|
||||||
@ -17,6 +18,7 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
|||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [authStatus, setAuthStatus] = useState<AuthStatus>('loading')
|
const [authStatus, setAuthStatus] = useState<AuthStatus>('loading')
|
||||||
const [user, setUser] = useState<UserData | null>(null)
|
const [user, setUser] = useState<UserData | null>(null)
|
||||||
|
const [profile, setProfile] = useState<UserProfile | null>(null) // <-- 2. NOVO ESTADO PARA PERFIL
|
||||||
const [token, setToken] = useState<string | null>(null)
|
const [token, setToken] = useState<string | null>(null)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const hasInitialized = useRef(false)
|
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.TOKEN)
|
||||||
localStorage.removeItem(AUTH_STORAGE_KEYS.USER)
|
localStorage.removeItem(AUTH_STORAGE_KEYS.USER)
|
||||||
localStorage.removeItem(AUTH_STORAGE_KEYS.REFRESH_TOKEN)
|
localStorage.removeItem(AUTH_STORAGE_KEYS.REFRESH_TOKEN)
|
||||||
|
localStorage.removeItem(AUTH_STORAGE_KEYS.PROFILE) // <-- 3. LIMPAR PERFIL
|
||||||
// Manter USER_TYPE para redirecionamento correto
|
// Manter USER_TYPE para redirecionamento correto
|
||||||
}
|
}
|
||||||
setUser(null)
|
setUser(null)
|
||||||
|
setProfile(null) // <-- 3. LIMPAR PERFIL
|
||||||
setToken(null)
|
setToken(null)
|
||||||
setAuthStatus('unauthenticated')
|
setAuthStatus('unauthenticated')
|
||||||
console.log('[AUTH] Dados de autenticação limpos - logout realizado')
|
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,
|
accessToken: string,
|
||||||
userData: UserData,
|
userData: UserData,
|
||||||
refreshToken?: string
|
refreshToken?: string
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
// Persistir dados de forma atômica
|
|
||||||
localStorage.setItem(AUTH_STORAGE_KEYS.TOKEN, accessToken)
|
localStorage.setItem(AUTH_STORAGE_KEYS.TOKEN, accessToken)
|
||||||
localStorage.setItem(AUTH_STORAGE_KEYS.USER, JSON.stringify(userData))
|
localStorage.setItem(AUTH_STORAGE_KEYS.USER, JSON.stringify(userData))
|
||||||
localStorage.setItem(AUTH_STORAGE_KEYS.USER_TYPE, userData.userType)
|
localStorage.setItem(AUTH_STORAGE_KEYS.USER_TYPE, userData.userType)
|
||||||
@ -61,11 +78,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
email: userData.email,
|
email: userData.email,
|
||||||
timestamp: new Date().toLocaleTimeString()
|
timestamp: new Date().toLocaleTimeString()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await fetchAndSetProfile(userData.id); // <-- 4. BUSCAR PERFIL APÓS LOGIN
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[AUTH] Erro ao salvar dados:', error)
|
console.error('[AUTH] Erro ao salvar dados:', error)
|
||||||
clearAuthData()
|
clearAuthData()
|
||||||
}
|
}
|
||||||
}, [clearAuthData])
|
}, [clearAuthData, fetchAndSetProfile])
|
||||||
|
|
||||||
// Verificação inicial de autenticação
|
// Verificação inicial de autenticação
|
||||||
const checkAuth = useCallback(async (): Promise<void> => {
|
const checkAuth = useCallback(async (): Promise<void> => {
|
||||||
@ -77,6 +97,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
const storedToken = localStorage.getItem(AUTH_STORAGE_KEYS.TOKEN)
|
const storedToken = localStorage.getItem(AUTH_STORAGE_KEYS.TOKEN)
|
||||||
const storedUser = localStorage.getItem(AUTH_STORAGE_KEYS.USER)
|
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...', {
|
console.log('[AUTH] Verificando sessão...', {
|
||||||
hasToken: !!storedToken,
|
hasToken: !!storedToken,
|
||||||
@ -84,7 +105,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
timestamp: new Date().toLocaleTimeString()
|
timestamp: new Date().toLocaleTimeString()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Pequeno delay para visualizar logs
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 800))
|
await new Promise(resolve => setTimeout(resolve, 800))
|
||||||
|
|
||||||
if (!storedToken || !storedUser) {
|
if (!storedToken || !storedUser) {
|
||||||
@ -94,35 +114,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verificar se token está expirado
|
|
||||||
if (isExpired(storedToken)) {
|
if (isExpired(storedToken)) {
|
||||||
console.log('[AUTH] Token expirado - tentando renovar...')
|
// ... (lógica de refresh token existente)
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clearAuthData()
|
clearAuthData()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -131,6 +124,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const userData = JSON.parse(storedUser) as UserData
|
const userData = JSON.parse(storedUser) as UserData
|
||||||
setToken(storedToken)
|
setToken(storedToken)
|
||||||
setUser(userData)
|
setUser(userData)
|
||||||
|
if (storedProfile) { // <-- 5. RESTAURAR PERFIL
|
||||||
|
setProfile(JSON.parse(storedProfile));
|
||||||
|
} else {
|
||||||
|
fetchAndSetProfile(userData.id); // ou buscar se não existir
|
||||||
|
}
|
||||||
setAuthStatus('authenticated')
|
setAuthStatus('authenticated')
|
||||||
|
|
||||||
console.log('[AUTH] Sessão RESTAURADA com sucesso!', {
|
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)
|
console.error('[AUTH] Erro na verificação:', error)
|
||||||
clearAuthData()
|
clearAuthData()
|
||||||
}
|
}
|
||||||
}, [clearAuthData])
|
}, [clearAuthData, fetchAndSetProfile])
|
||||||
|
|
||||||
// Login memoizado
|
// Login memoizado
|
||||||
const login = useCallback(async (
|
const login = useCallback(async (
|
||||||
@ -158,7 +156,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const response = await loginUser(email, password, userType)
|
const response = await loginUser(email, password, userType)
|
||||||
|
|
||||||
saveAuthData(
|
await saveAuthData(
|
||||||
response.access_token,
|
response.access_token,
|
||||||
response.user,
|
response.user,
|
||||||
response.refresh_token
|
response.refresh_token
|
||||||
@ -184,37 +182,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
// Logout memoizado
|
// Logout memoizado
|
||||||
const logout = useCallback(async (): Promise<void> => {
|
const logout = useCallback(async (): Promise<void> => {
|
||||||
console.log('[AUTH] Iniciando logout')
|
// ... (código de logout existente) ...
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
clearAuthData()
|
clearAuthData()
|
||||||
|
|
||||||
// Redirecionamento baseado no tipo de usuário
|
const loginRoute = LOGIN_ROUTES[user?.userType as UserType] || '/login'
|
||||||
const loginRoute = LOGIN_ROUTES[currentUserType as UserType] || '/login'
|
|
||||||
|
|
||||||
console.log('[AUTH] Redirecionando para:', loginRoute)
|
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.location.href = loginRoute
|
window.location.href = loginRoute
|
||||||
}
|
}
|
||||||
}, [user?.userType, token, clearAuthData])
|
}, [user?.userType, token, clearAuthData])
|
||||||
|
|
||||||
// Refresh token memoizado (usado pelo HTTP client)
|
// Refresh token memoizado
|
||||||
const refreshToken = useCallback(async (): Promise<boolean> => {
|
const refreshToken = useCallback(async (): Promise<boolean> => {
|
||||||
// Esta função é principalmente para compatibilidade
|
|
||||||
// O refresh real é feito pelo HTTP client
|
|
||||||
return false
|
return false
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@ -222,11 +200,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const contextValue = useMemo(() => ({
|
const contextValue = useMemo(() => ({
|
||||||
authStatus,
|
authStatus,
|
||||||
user,
|
user,
|
||||||
|
profile, // <-- 6. EXPOR PERFIL NO CONTEXTO
|
||||||
token,
|
token,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
refreshToken
|
refreshToken
|
||||||
}), [authStatus, user, token, login, logout, refreshToken])
|
}), [authStatus, user, profile, token, login, logout, refreshToken])
|
||||||
|
|
||||||
// Inicialização única
|
// Inicialização única
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -116,6 +116,64 @@ export type MedicoInput = {
|
|||||||
valor_consulta?: number | string | null;
|
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<UserProfile[]> {
|
||||||
|
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<ApiOk<UserProfile[]>>(res);
|
||||||
|
logAPI("listarPerfis", { url, result: data });
|
||||||
|
return data?.data ?? (data as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buscarPerfilPorId(id: string | number): Promise<UserProfile> {
|
||||||
|
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<UserProfile[]>(res);
|
||||||
|
const profile = data[0];
|
||||||
|
logAPI("buscarPerfilPorId", { url, result: profile });
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function atualizarPerfil(id: string | number, input: UserProfileInput): Promise<UserProfile> {
|
||||||
|
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<ApiOk<UserProfile>>(res);
|
||||||
|
logAPI("atualizarPerfil", { url, payload: input, result: data });
|
||||||
|
return data?.data ?? (data as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ===== CONFIG =====
|
// ===== CONFIG =====
|
||||||
const API_BASE =
|
const API_BASE =
|
||||||
process.env.NEXT_PUBLIC_API_BASE ?? "https://yuanqfswhberkoevtmfr.supabase.co";
|
process.env.NEXT_PUBLIC_API_BASE ?? "https://yuanqfswhberkoevtmfr.supabase.co";
|
||||||
|
|||||||
@ -44,9 +44,12 @@ export interface AuthError {
|
|||||||
details?: unknown
|
details?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import type { UserProfile } from '@/lib/api';
|
||||||
|
|
||||||
export interface AuthContextType {
|
export interface AuthContextType {
|
||||||
authStatus: AuthStatus
|
authStatus: AuthStatus
|
||||||
user: UserData | null
|
user: UserData | null
|
||||||
|
profile: UserProfile | null
|
||||||
token: string | null
|
token: string | null
|
||||||
login: (email: string, password: string, userType: UserType) => Promise<boolean>
|
login: (email: string, password: string, userType: UserType) => Promise<boolean>
|
||||||
logout: () => Promise<void>
|
logout: () => Promise<void>
|
||||||
@ -58,6 +61,7 @@ export interface AuthStorageKeys {
|
|||||||
readonly REFRESH_TOKEN: string
|
readonly REFRESH_TOKEN: string
|
||||||
readonly USER: string
|
readonly USER: string
|
||||||
readonly USER_TYPE: string
|
readonly USER_TYPE: string
|
||||||
|
readonly PROFILE: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserTypeRoutes = {
|
export type UserTypeRoutes = {
|
||||||
@ -74,6 +78,7 @@ export const AUTH_STORAGE_KEYS: AuthStorageKeys = {
|
|||||||
REFRESH_TOKEN: 'auth_refresh_token',
|
REFRESH_TOKEN: 'auth_refresh_token',
|
||||||
USER: 'auth_user',
|
USER: 'auth_user',
|
||||||
USER_TYPE: 'auth_user_type',
|
USER_TYPE: 'auth_user_type',
|
||||||
|
PROFILE: 'auth_user_profile',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
// Rotas baseadas no tipo de usuário
|
// Rotas baseadas no tipo de usuário
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user