feat(auth): persist roles and support multi-role in ProtectedRoute
- Persist `roles` array in localStorage on login and session restore. - Reconcile `userType` from roles returned by the `user-info` function. - `ProtectedRoute` now accepts `requiredUserType?: UserType[]` and `requiredRoles?: string[]` and evaluates multi-role permission (OR semantics). - Minor adjustments in `useAuth` and debug logs to ensure consistent `profile` and `roles` restoration. - Main files changed: `hooks/useAuth.tsx`, `components/ProtectedRoute.tsx`, `types/auth.ts.
This commit is contained in:
parent
ca84c563b6
commit
05123e6c8f
@ -8,16 +8,54 @@ import { USER_TYPE_ROUTES, LOGIN_ROUTES, AUTH_STORAGE_KEYS } from '@/types/auth'
|
|||||||
interface ProtectedRouteProps {
|
interface ProtectedRouteProps {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
requiredUserType?: UserType[]
|
requiredUserType?: UserType[]
|
||||||
|
requiredRoles?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProtectedRoute({
|
export default function ProtectedRoute({
|
||||||
children,
|
children,
|
||||||
requiredUserType
|
requiredUserType,
|
||||||
|
requiredRoles
|
||||||
}: ProtectedRouteProps) {
|
}: ProtectedRouteProps) {
|
||||||
const { authStatus, user } = useAuth()
|
const { authStatus, user } = useAuth()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const isRedirecting = useRef(false)
|
const isRedirecting = useRef(false)
|
||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
|
const [accessDenied, setAccessDenied] = useState(false)
|
||||||
|
|
||||||
|
// Computa permissões de forma síncrona a partir do user e dos roles/userType
|
||||||
|
const computeHasPermission = () => {
|
||||||
|
// sem requisitos, permite
|
||||||
|
if ((!requiredUserType || requiredUserType.length === 0) && (!requiredRoles || requiredRoles.length === 0)) return true
|
||||||
|
if (!user) return false
|
||||||
|
|
||||||
|
const userRoles = (user as any).roles || []
|
||||||
|
|
||||||
|
// checa requiredRoles (strings arbitrárias de papéis)
|
||||||
|
const rolesOk = requiredRoles && requiredRoles.length > 0
|
||||||
|
? requiredRoles.some((req: string) => {
|
||||||
|
if (user.userType === req) return true
|
||||||
|
if (req === 'profissional' && (userRoles.includes('medico') || userRoles.includes('enfermeiro'))) return true
|
||||||
|
if (req === 'administrador' && (userRoles.includes('admin') || userRoles.includes('gestor') || userRoles.includes('secretaria'))) return true
|
||||||
|
if (req === 'paciente' && userRoles.includes('paciente')) return true
|
||||||
|
return userRoles.includes(req)
|
||||||
|
})
|
||||||
|
: false
|
||||||
|
|
||||||
|
// checa requiredUserType (compatibilidade com tipos altos do sistema)
|
||||||
|
const userTypeOk = requiredUserType && requiredUserType.length > 0
|
||||||
|
? requiredUserType.some((req: UserType) => {
|
||||||
|
if (user.userType === req) return true
|
||||||
|
if (req === 'profissional' && (userRoles.includes('medico') || userRoles.includes('enfermeiro'))) return true
|
||||||
|
if (req === 'administrador' && (userRoles.includes('admin') || userRoles.includes('gestor') || userRoles.includes('secretaria'))) return true
|
||||||
|
if (req === 'paciente' && userRoles.includes('paciente')) return true
|
||||||
|
return userRoles.includes(req)
|
||||||
|
})
|
||||||
|
: false
|
||||||
|
|
||||||
|
return rolesOk || userTypeOk
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPermission = computeHasPermission()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// marca que o componente já montou no cliente
|
// marca que o componente já montou no cliente
|
||||||
@ -61,18 +99,15 @@ export default function ProtectedRoute({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Se autenticado mas não tem permissão para esta página
|
// Se autenticado mas não tem permissão para esta página
|
||||||
if (authStatus === 'authenticated' && user && requiredUserType && !requiredUserType.includes(user.userType)) {
|
if (authStatus === 'authenticated' && user && requiredUserType && !hasPermission) {
|
||||||
isRedirecting.current = true
|
console.log('[PROTECTED-ROUTE] Usuário SEM permissão para esta página (accessDenied)', {
|
||||||
|
|
||||||
console.log('[PROTECTED-ROUTE] Usuário SEM permissão para esta página', {
|
|
||||||
userType: user.userType,
|
userType: user.userType,
|
||||||
|
userRoles: (user as any).roles || [],
|
||||||
requiredTypes: requiredUserType
|
requiredTypes: requiredUserType
|
||||||
})
|
})
|
||||||
|
|
||||||
const correctRoute = USER_TYPE_ROUTES[user.userType]
|
// Marcar acesso negado para renderizar fallback sem redirecionamentos/speinners infinitos
|
||||||
console.log('[PROTECTED-ROUTE] Redirecionando para área correta:', correctRoute)
|
setAccessDenied(true)
|
||||||
|
|
||||||
router.push(correctRoute)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -85,7 +120,7 @@ export default function ProtectedRoute({
|
|||||||
})
|
})
|
||||||
isRedirecting.current = false
|
isRedirecting.current = false
|
||||||
}
|
}
|
||||||
}, [authStatus, user, requiredUserType, router])
|
}, [authStatus, user, requiredUserType, router, hasPermission])
|
||||||
|
|
||||||
// Durante loading, mostrar spinner
|
// Durante loading, mostrar spinner
|
||||||
if (authStatus === 'loading') {
|
if (authStatus === 'loading') {
|
||||||
@ -102,8 +137,8 @@ export default function ProtectedRoute({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Se não autenticado ou redirecionando, mostrar spinner
|
// Se não autenticado ou redirecionando para login, mostrar spinner
|
||||||
if (authStatus === 'unauthenticated' || isRedirecting.current) {
|
if (authStatus === 'unauthenticated' || (isRedirecting.current && !accessDenied)) {
|
||||||
// evitar render no servidor para não causar mismatch de hidratação
|
// evitar render no servidor para não causar mismatch de hidratação
|
||||||
if (!mounted) return null
|
if (!mounted) return null
|
||||||
|
|
||||||
@ -117,8 +152,8 @@ export default function ProtectedRoute({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Se usuário não tem permissão, mostrar fallback (não deveria chegar aqui devido ao useEffect)
|
// Se usuário não tem permissão, mostrar fallback (baseado em hasPermission)
|
||||||
if (requiredUserType && user && !requiredUserType.includes(user.userType)) {
|
if (requiredUserType && user && !hasPermission) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@ -130,6 +165,8 @@ export default function ProtectedRoute({
|
|||||||
Tipo de acesso necessário: {requiredUserType.join(' ou ')}
|
Tipo de acesso necessário: {requiredUserType.join(' ou ')}
|
||||||
<br />
|
<br />
|
||||||
Seu tipo de acesso: {user.userType}
|
Seu tipo de acesso: {user.userType}
|
||||||
|
<br />
|
||||||
|
Seus papéis: {(user as any).roles ? (user as any).roles.join(', ') : '—'}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push(USER_TYPE_ROUTES[user.userType])}
|
onClick={() => router.push(USER_TYPE_ROUTES[user.userType])}
|
||||||
|
|||||||
@ -48,7 +48,7 @@ export function PagesHeader({ title = "", subtitle = "" }: { title?: string, sub
|
|||||||
<SimpleThemeToggle />
|
<SimpleThemeToggle />
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-primary border-primary bg-transparent shadow-sm shadow-blue-500/10 border border-blue-200 hover:bg-blue-50 dark:shadow-none dark:border-primary dark:hover:bg-primary dark:hover:text-primary-foreground"
|
className="text-primary bg-transparent shadow-sm shadow-blue-500/10 border border-blue-200 hover:bg-blue-50 dark:shadow-none dark:border-primary dark:hover:bg-primary dark:hover:text-primary-foreground"
|
||||||
asChild
|
asChild
|
||||||
></Button>
|
></Button>
|
||||||
{/* Avatar Dropdown Simples */}
|
{/* Avatar Dropdown Simples */}
|
||||||
|
|||||||
@ -288,6 +288,14 @@ function setField<T extends keyof FormData>(k: T, v: FormData[T]) {
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto formata input de data para dd/mm/aaaa enquanto digita
|
||||||
|
function formatDateInput(v: string) {
|
||||||
|
const nums = v.replace(/\D/g, "").slice(0, 8);
|
||||||
|
if (nums.length <= 2) return nums;
|
||||||
|
if (nums.length <= 4) return `${nums.slice(0, 2)}/${nums.slice(2)}`;
|
||||||
|
return `${nums.slice(0, 2)}/${nums.slice(2, 4)}/${nums.slice(4, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatRG(v: string) {
|
function formatRG(v: string) {
|
||||||
v = v.replace(/\D/g, "").slice(0, 9);
|
v = v.replace(/\D/g, "").slice(0, 9);
|
||||||
v = v.replace(/(\d{2})(\d)/, "$1.$2");
|
v = v.replace(/(\d{2})(\d)/, "$1.$2");
|
||||||
@ -542,13 +550,6 @@ async function handleSubmit(ev: React.FormEvent) {
|
|||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
{errors.submit && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>{errors.submit}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<Collapsible open={expanded.dados} onOpenChange={() => setExpanded((s) => ({ ...s, dados: !s.dados }))}>
|
<Collapsible open={expanded.dados} onOpenChange={() => setExpanded((s) => ({ ...s, dados: !s.dados }))}>
|
||||||
<Card>
|
<Card>
|
||||||
@ -565,53 +566,11 @@ async function handleSubmit(ev: React.FormEvent) {
|
|||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<CardContent className="space-y-4 pt-4">
|
<CardContent className="space-y-4 pt-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="space-y-2">
|
||||||
<div className="w-24 h-24 border-2 border-dashed border-muted-foreground rounded-lg flex items-center justify-center overflow-hidden">
|
<Label>Nome completo</Label>
|
||||||
{photoPreview ? (
|
|
||||||
<img src={photoPreview} alt="Preview" className="w-full h-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<FileImage className="h-8 w-8 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="photo" className="cursor-pointer rounded-md transition-colors">
|
|
||||||
<Button type="button" variant="ghost" asChild className="bg-primary text-primary-foreground border-transparent hover:bg-primary">
|
|
||||||
<span>
|
|
||||||
<Upload className="mr-2 h-4 w-4 text-primary-foreground" /> Carregar Foto
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</Label>
|
|
||||||
<Input id="photo" type="file" accept="image/*" className="hidden" onChange={handlePhoto} />
|
|
||||||
{errors.photo && <p className="text-sm text-destructive">{errors.photo}</p>}
|
|
||||||
<p className="text-xs text-muted-foreground">Máximo 5MB</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Nome *</Label>
|
|
||||||
<Input value={form.full_name} onChange={(e) => setField("full_name", e.target.value)} />
|
<Input value={form.full_name} onChange={(e) => setField("full_name", e.target.value)} />
|
||||||
|
|
||||||
|
|
||||||
{errors.full_name && <p className="text-sm text-destructive">{errors.full_name}</p>}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Nome Social</Label>
|
|
||||||
<Input value={form.nome_social} onChange={(e) => setField("nome_social", e.target.value)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>CRM *</Label>
|
|
||||||
<Input value={form.crm} onChange={(e) => setField("crm", e.target.value)} className={errors.crm ? "border-destructive" : ""} />
|
|
||||||
{errors.crm && <p className="text-sm text-destructive">{errors.crm}</p>}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Estado do CRM</Label>
|
|
||||||
<Input value={form.estado_crm} onChange={(e) => setField("estado_crm", e.target.value)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -693,10 +652,8 @@ async function handleSubmit(ev: React.FormEvent) {
|
|||||||
<Input
|
<Input
|
||||||
placeholder="dd/mm/aaaa"
|
placeholder="dd/mm/aaaa"
|
||||||
value={form.data_nascimento}
|
value={form.data_nascimento}
|
||||||
onChange={(e) => {
|
onChange={(e) => setField("data_nascimento", formatDateInput(e.target.value))}
|
||||||
const v = e.target.value.replace(/[^0-9\/]/g, "").slice(0, 10);
|
maxLength={10}
|
||||||
setField("data_nascimento", v);
|
|
||||||
}}
|
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
const raw = form.data_nascimento;
|
const raw = form.data_nascimento;
|
||||||
const parts = raw.split(/\D+/).filter(Boolean);
|
const parts = raw.split(/\D+/).filter(Boolean);
|
||||||
@ -708,6 +665,7 @@ async function handleSubmit(ev: React.FormEvent) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -792,26 +750,16 @@ async function handleSubmit(ev: React.FormEvent) {
|
|||||||
<Label>E-mail</Label>
|
<Label>E-mail</Label>
|
||||||
<Input value={form.email} onChange={(e) => setField("email", e.target.value)} />
|
<Input value={form.email} onChange={(e) => setField("email", e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="space-y-2">
|
||||||
<div className="space-y-2">
|
<Label>Telefone</Label>
|
||||||
<Label>Telefone</Label>
|
<Input
|
||||||
<Input
|
value={form.telefone}
|
||||||
value={form.telefone}
|
onChange={(e) => setField("telefone", formatPhone(e.target.value))}
|
||||||
onChange={(e) => setField("telefone", formatPhone(e.target.value))}
|
placeholder="(XX) XXXXX-XXXX"
|
||||||
placeholder="(XX) XXXXX-XXXX"
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Celular</Label>
|
|
||||||
<Input
|
|
||||||
value={form.celular}
|
|
||||||
onChange={(e) => setField("celular", formatPhone(e.target.value))}
|
|
||||||
placeholder="(XX) XXXXX-XXXX"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Celular</Label>
|
<Label>Celular</Label>
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
|
|||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { AlertCircle, ChevronDown, ChevronUp, FileImage, Loader2, Save, Upload, User, X, XCircle, Trash2 } from "lucide-react";
|
import { AlertCircle, ChevronDown, ChevronUp, FileImage, Loader2, Save, Upload, User, X, XCircle, Trash2 } from "lucide-react";
|
||||||
|
import Image from 'next/image';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Paciente,
|
Paciente,
|
||||||
@ -165,6 +166,14 @@ export function PatientRegistrationForm({
|
|||||||
setField("cpf", formatCPF(v));
|
setField("cpf", formatCPF(v));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatRG(v: string) {
|
||||||
|
let s = String(v || "").replace(/\D/g, "").slice(0, 9);
|
||||||
|
s = s.replace(/(\d{2})(\d)/, "$1.$2");
|
||||||
|
s = s.replace(/(\d{3})(\d)/, "$1.$2");
|
||||||
|
s = s.replace(/(\d{3})(\d{1,2})$/, "$1-$2");
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
function formatCEP(v: string) {
|
function formatCEP(v: string) {
|
||||||
const n = v.replace(/\D/g, "").slice(0, 8);
|
const n = v.replace(/\D/g, "").slice(0, 8);
|
||||||
return n.replace(/(\d{5})(\d{0,3})/, (_, a, b) => `${a}${b ? "-" + b : ""}`);
|
return n.replace(/(\d{5})(\d{0,3})/, (_, a, b) => `${a}${b ? "-" + b : ""}`);
|
||||||
@ -188,6 +197,26 @@ export function PatientRegistrationForm({
|
|||||||
} finally {
|
} finally {
|
||||||
setSearchingCEP(false);
|
setSearchingCEP(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formata telefone enquanto digita: (XX) XXXXX-XXXX
|
||||||
|
function formatPhone(v: string) {
|
||||||
|
const n = v.replace(/\D/g, "").slice(0, 11);
|
||||||
|
if (n.length > 6) {
|
||||||
|
return n.replace(/(\d{2})(\d{5})(\d{0,4})/, "($1) $2-$3");
|
||||||
|
} else if (n.length > 2) {
|
||||||
|
return n.replace(/(\d{2})(\d{0,5})/, "($1) $2");
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto formata input de data para dd/mm/aaaa enquanto digita
|
||||||
|
function formatDateInput(v: string) {
|
||||||
|
const nums = v.replace(/\D/g, "").slice(0, 8);
|
||||||
|
if (nums.length <= 2) return nums;
|
||||||
|
if (nums.length <= 4) return `${nums.slice(0,2)}/${nums.slice(2)}`;
|
||||||
|
return `${nums.slice(0,2)}/${nums.slice(2,4)}/${nums.slice(4,8)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateLocal(): boolean {
|
function validateLocal(): boolean {
|
||||||
@ -447,7 +476,7 @@ export function PatientRegistrationForm({
|
|||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="w-24 h-24 border-2 border-dashed border-muted-foreground rounded-lg flex items-center justify-center overflow-hidden">
|
<div className="w-24 h-24 border-2 border-dashed border-muted-foreground rounded-lg flex items-center justify-center overflow-hidden">
|
||||||
{photoPreview ? (
|
{photoPreview ? (
|
||||||
<img src={photoPreview} alt="Preview" className="w-full h-full object-cover" />
|
<Image src={photoPreview} alt="Preview" className="w-full h-full object-cover" width={400} height={300} />
|
||||||
) : (
|
) : (
|
||||||
<FileImage className="h-8 w-8 text-muted-foreground" />
|
<FileImage className="h-8 w-8 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
@ -497,7 +526,7 @@ export function PatientRegistrationForm({
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>RG</Label>
|
<Label>RG</Label>
|
||||||
<Input value={form.rg} onChange={(e) => setField("rg", e.target.value)} />
|
<Input value={form.rg} onChange={(e) => setField("rg", formatRG(e.target.value))} maxLength={12} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -520,10 +549,7 @@ export function PatientRegistrationForm({
|
|||||||
<Input
|
<Input
|
||||||
placeholder="dd/mm/aaaa"
|
placeholder="dd/mm/aaaa"
|
||||||
value={form.birth_date}
|
value={form.birth_date}
|
||||||
onChange={(e) => {
|
onChange={(e) => setField("birth_date", formatDateInput(e.target.value))}
|
||||||
const v = e.target.value.replace(/[^0-9\/]/g, "").slice(0, 10);
|
|
||||||
setField("birth_date", v);
|
|
||||||
}}
|
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
const raw = form.birth_date;
|
const raw = form.birth_date;
|
||||||
const parts = raw.split(/\D+/).filter(Boolean);
|
const parts = raw.split(/\D+/).filter(Boolean);
|
||||||
@ -560,7 +586,7 @@ export function PatientRegistrationForm({
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Telefone</Label>
|
<Label>Telefone</Label>
|
||||||
<Input value={form.telefone} onChange={(e) => setField("telefone", e.target.value)} />
|
<Input value={form.telefone} onChange={(e) => setField("telefone", formatPhone(e.target.value))} placeholder="(XX) XXXXX-XXXX" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -46,8 +46,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
// Persistir dados de forma atômica
|
// 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))
|
// Garantir que roles também sejam persistidos se presentes
|
||||||
localStorage.setItem(AUTH_STORAGE_KEYS.USER_TYPE, userData.userType)
|
const toStore = { ...userData } as any
|
||||||
|
if (userData && (userData as any).roles) {
|
||||||
|
toStore.roles = (userData as any).roles
|
||||||
|
}
|
||||||
|
localStorage.setItem(AUTH_STORAGE_KEYS.USER, JSON.stringify(toStore))
|
||||||
|
localStorage.setItem(AUTH_STORAGE_KEYS.USER_TYPE, toStore.userType)
|
||||||
|
|
||||||
if (refreshToken) {
|
if (refreshToken) {
|
||||||
localStorage.setItem(AUTH_STORAGE_KEYS.REFRESH_TOKEN, refreshToken)
|
localStorage.setItem(AUTH_STORAGE_KEYS.REFRESH_TOKEN, refreshToken)
|
||||||
@ -55,7 +60,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setToken(accessToken)
|
setToken(accessToken)
|
||||||
setUser(userData)
|
// Garantir que o estado mantenha roles também
|
||||||
|
setUser(userData as any)
|
||||||
setAuthStatus('authenticated')
|
setAuthStatus('authenticated')
|
||||||
|
|
||||||
console.log('[AUTH] LOGIN realizado - Dados salvos!', {
|
console.log('[AUTH] LOGIN realizado - Dados salvos!', {
|
||||||
@ -151,6 +157,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
// que 'auth_user.profile' fique vazio após um reload completo
|
// que 'auth_user.profile' fique vazio após um reload completo
|
||||||
try {
|
try {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
|
// Persistir também roles se disponíveis
|
||||||
|
if (info?.roles && Array.isArray(info.roles)) {
|
||||||
|
userData.roles = info.roles
|
||||||
|
}
|
||||||
localStorage.setItem(AUTH_STORAGE_KEYS.USER, JSON.stringify(userData))
|
localStorage.setItem(AUTH_STORAGE_KEYS.USER, JSON.stringify(userData))
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -218,6 +228,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
response.user.userType = derived
|
response.user.userType = derived
|
||||||
console.log('[AUTH] userType reconciled from roles ->', derived)
|
console.log('[AUTH] userType reconciled from roles ->', derived)
|
||||||
}
|
}
|
||||||
|
// Persistir roles no objeto user para suportar multi-role
|
||||||
|
if (response.user && roles.length) {
|
||||||
|
response.user.roles = roles
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn('[AUTH] Falha ao obter user-info para reconciliar roles:', infoRes.status)
|
console.warn('[AUTH] Falha ao obter user-info para reconciliar roles:', infoRes.status)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
// hooks/useReports.ts
|
// hooks/useReports.ts
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Report,
|
Report,
|
||||||
CreateReportData,
|
CreateReportData,
|
||||||
@ -43,11 +43,11 @@ export function useReports(): UseReportsReturn {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
// Caches em memória para evitar múltiplas buscas pelo mesmo ID durante a sessão
|
// Caches em memória para evitar múltiplas buscas pelo mesmo ID durante a sessão
|
||||||
const patientsCacheRef = (globalThis as any).__reportsPatientsCache__ || new Map<string, any>();
|
const patientsCacheRef = useRef<Map<string, any>>((globalThis as any).__reportsPatientsCache__ || new Map<string, any>());
|
||||||
const doctorsCacheRef = (globalThis as any).__reportsDoctorsCache__ || new Map<string, any>();
|
const doctorsCacheRef = useRef<Map<string, any>>((globalThis as any).__reportsDoctorsCache__ || new Map<string, any>());
|
||||||
// store back to globalThis so cache persiste entre hot reloads
|
// store back to globalThis so cache persists between hot reloads
|
||||||
(globalThis as any).__reportsPatientsCache__ = patientsCacheRef;
|
(globalThis as any).__reportsPatientsCache__ = patientsCacheRef.current;
|
||||||
(globalThis as any).__reportsDoctorsCache__ = doctorsCacheRef;
|
(globalThis as any).__reportsDoctorsCache__ = doctorsCacheRef.current;
|
||||||
|
|
||||||
// Função para tratar erros
|
// Função para tratar erros
|
||||||
const handleError = useCallback((error: any) => {
|
const handleError = useCallback((error: any) => {
|
||||||
@ -80,10 +80,10 @@ export function useReports(): UseReportsReturn {
|
|||||||
|
|
||||||
for (const r of arr) {
|
for (const r of arr) {
|
||||||
const pid = r.patient_id ?? r.patient ?? r.paciente;
|
const pid = r.patient_id ?? r.patient ?? r.paciente;
|
||||||
if (pid && typeof pid === 'string' && !patientIds.includes(pid) && !patientsCacheRef.has(String(pid))) patientIds.push(pid);
|
if (pid && typeof pid === 'string' && !patientIds.includes(pid) && !patientsCacheRef.current.has(String(pid))) patientIds.push(pid);
|
||||||
|
|
||||||
const did = r.requested_by ?? r.created_by ?? r.executante;
|
const did = r.requested_by ?? r.created_by ?? r.executante;
|
||||||
if (did && typeof did === 'string' && !doctorIds.includes(did) && !doctorsCacheRef.has(String(did))) doctorIds.push(did);
|
if (did && typeof did === 'string' && !doctorIds.includes(did) && !doctorsCacheRef.current.has(String(did))) doctorIds.push(did);
|
||||||
}
|
}
|
||||||
|
|
||||||
const patientsById = new Map<string, any>();
|
const patientsById = new Map<string, any>();
|
||||||
@ -92,7 +92,7 @@ export function useReports(): UseReportsReturn {
|
|||||||
const patients = await buscarPacientesPorIds(patientIds);
|
const patients = await buscarPacientesPorIds(patientIds);
|
||||||
for (const p of patients) {
|
for (const p of patients) {
|
||||||
patientsById.set(String(p.id), p);
|
patientsById.set(String(p.id), p);
|
||||||
patientsCacheRef.set(String(p.id), p);
|
patientsCacheRef.current.set(String(p.id), p);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore batch failure
|
// ignore batch failure
|
||||||
@ -100,14 +100,14 @@ export function useReports(): UseReportsReturn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fallback individual para quaisquer IDs que não foram resolvidos no batch
|
// fallback individual para quaisquer IDs que não foram resolvidos no batch
|
||||||
const unresolvedPatientIds = patientIds.filter(id => !patientsById.has(String(id)) && !patientsCacheRef.has(String(id)));
|
const unresolvedPatientIds = patientIds.filter(id => !patientsById.has(String(id)) && !patientsCacheRef.current.has(String(id)));
|
||||||
if (unresolvedPatientIds.length) {
|
if (unresolvedPatientIds.length) {
|
||||||
await Promise.all(unresolvedPatientIds.map(async (id) => {
|
await Promise.all(unresolvedPatientIds.map(async (id) => {
|
||||||
try {
|
try {
|
||||||
const p = await buscarPacientePorId(id);
|
const p = await buscarPacientePorId(id);
|
||||||
if (p) {
|
if (p) {
|
||||||
patientsById.set(String(id), p);
|
patientsById.set(String(id), p);
|
||||||
patientsCacheRef.set(String(id), p);
|
patientsCacheRef.current.set(String(id), p);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore individual failure
|
// ignore individual failure
|
||||||
@ -121,21 +121,21 @@ export function useReports(): UseReportsReturn {
|
|||||||
const doctors = await buscarMedicosPorIds(doctorIds);
|
const doctors = await buscarMedicosPorIds(doctorIds);
|
||||||
for (const d of doctors) {
|
for (const d of doctors) {
|
||||||
doctorsById.set(String(d.id), d);
|
doctorsById.set(String(d.id), d);
|
||||||
doctorsCacheRef.set(String(d.id), d);
|
doctorsCacheRef.current.set(String(d.id), d);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const unresolvedDoctorIds = doctorIds.filter(id => !doctorsById.has(String(id)) && !doctorsCacheRef.has(String(id)));
|
const unresolvedDoctorIds = doctorIds.filter(id => !doctorsById.has(String(id)) && !doctorsCacheRef.current.has(String(id)));
|
||||||
if (unresolvedDoctorIds.length) {
|
if (unresolvedDoctorIds.length) {
|
||||||
await Promise.all(unresolvedDoctorIds.map(async (id) => {
|
await Promise.all(unresolvedDoctorIds.map(async (id) => {
|
||||||
try {
|
try {
|
||||||
const d = await buscarMedicoPorId(id);
|
const d = await buscarMedicoPorId(id);
|
||||||
if (d) {
|
if (d) {
|
||||||
doctorsById.set(String(id), d);
|
doctorsById.set(String(id), d);
|
||||||
doctorsCacheRef.set(String(id), d);
|
doctorsCacheRef.current.set(String(id), d);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore
|
// ignore
|
||||||
@ -156,7 +156,7 @@ export function useReports(): UseReportsReturn {
|
|||||||
const pid = r.patient_id ?? r.patient ?? r.paciente;
|
const pid = r.patient_id ?? r.patient ?? r.paciente;
|
||||||
if (!copy.paciente && pid) {
|
if (!copy.paciente && pid) {
|
||||||
if (patientsById.has(String(pid))) copy.paciente = patientsById.get(String(pid));
|
if (patientsById.has(String(pid))) copy.paciente = patientsById.get(String(pid));
|
||||||
else if (patientsCacheRef.has(String(pid))) copy.paciente = patientsCacheRef.get(String(pid));
|
else if (patientsCacheRef.current.has(String(pid))) copy.paciente = patientsCacheRef.current.get(String(pid));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Executante: prefira campos de nome já fornecidos
|
// Executante: prefira campos de nome já fornecidos
|
||||||
@ -167,7 +167,7 @@ export function useReports(): UseReportsReturn {
|
|||||||
const did = r.requested_by ?? r.created_by ?? r.executante;
|
const did = r.requested_by ?? r.created_by ?? r.executante;
|
||||||
if (did) {
|
if (did) {
|
||||||
if (doctorsById.has(String(did))) copy.executante = doctorsById.get(String(did))?.full_name ?? doctorsById.get(String(did))?.nome ?? copy.executante;
|
if (doctorsById.has(String(did))) copy.executante = doctorsById.get(String(did))?.full_name ?? doctorsById.get(String(did))?.nome ?? copy.executante;
|
||||||
else if (doctorsCacheRef.has(String(did))) copy.executante = doctorsCacheRef.get(String(did))?.full_name ?? doctorsCacheRef.get(String(did))?.nome ?? copy.executante;
|
else if (doctorsCacheRef.current.has(String(did))) copy.executante = doctorsCacheRef.current.get(String(did))?.full_name ?? doctorsCacheRef.current.get(String(did))?.nome ?? copy.executante;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -183,7 +183,7 @@ export function useReports(): UseReportsReturn {
|
|||||||
if (pid && !r.paciente) unresolvedPatients.push(String(pid));
|
if (pid && !r.paciente) unresolvedPatients.push(String(pid));
|
||||||
const did = r.requested_by ?? r.created_by ?? r.executante;
|
const did = r.requested_by ?? r.created_by ?? r.executante;
|
||||||
// note: if executante was resolved to a name, r.executante will be string name; if still ID, it may be ID
|
// note: if executante was resolved to a name, r.executante will be string name; if still ID, it may be ID
|
||||||
if (did && (typeof r.executante === 'undefined' || (typeof r.executante === 'string' && r.executante.length > 30 && r.executante.includes('-')))) {
|
if (did && (r.executante === undefined || (typeof r.executante === 'string' && r.executante.length > 30 && r.executante.includes('-')))) {
|
||||||
unresolvedDoctors.push(String(did));
|
unresolvedDoctors.push(String(did));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -241,8 +241,9 @@ async function parse<T>(res: Response): Promise<T> {
|
|||||||
// Mensagens amigáveis para erros comuns
|
// Mensagens amigáveis para erros comuns
|
||||||
let friendlyMessage = msg;
|
let friendlyMessage = msg;
|
||||||
|
|
||||||
// Erros de criação de usuário
|
// Erros de criação de usuário ou códigos conhecidos
|
||||||
if (res.url?.includes('create-user')) {
|
if (res.url?.includes('create-user')) {
|
||||||
|
// organizar casos por checagens em ordem específica
|
||||||
if (msg?.includes('Failed to assign user role')) {
|
if (msg?.includes('Failed to assign user role')) {
|
||||||
friendlyMessage = 'O usuário foi criado mas houve falha ao atribuir permissões. Entre em contato com o administrador do sistema para verificar as configurações da Edge Function.';
|
friendlyMessage = 'O usuário foi criado mas houve falha ao atribuir permissões. Entre em contato com o administrador do sistema para verificar as configurações da Edge Function.';
|
||||||
} else if (msg?.includes('already registered')) {
|
} else if (msg?.includes('already registered')) {
|
||||||
@ -251,41 +252,42 @@ async function parse<T>(res: Response): Promise<T> {
|
|||||||
friendlyMessage = 'Tipo de acesso inválido.';
|
friendlyMessage = 'Tipo de acesso inválido.';
|
||||||
} else if (msg?.includes('Missing required fields')) {
|
} else if (msg?.includes('Missing required fields')) {
|
||||||
friendlyMessage = 'Campos obrigatórios não preenchidos.';
|
friendlyMessage = 'Campos obrigatórios não preenchidos.';
|
||||||
} else if (res.status === 401) {
|
|
||||||
friendlyMessage = 'Você não está autenticado. Faça login novamente.';
|
|
||||||
} else if (res.status === 403) {
|
|
||||||
friendlyMessage = 'Você não tem permissão para criar usuários.';
|
|
||||||
} else if (res.status === 500) {
|
|
||||||
friendlyMessage = 'Erro no servidor ao criar usuário. Entre em contato com o suporte.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Erro de CPF duplicado
|
|
||||||
else if (code === '23505' && msg.includes('patients_cpf_key')) {
|
|
||||||
friendlyMessage = 'Já existe um paciente cadastrado com este CPF. Por favor, verifique se o paciente já está registrado no sistema ou use um CPF diferente.';
|
|
||||||
}
|
|
||||||
// Erro de email duplicado (paciente)
|
|
||||||
else if (code === '23505' && msg.includes('patients_email_key')) {
|
|
||||||
friendlyMessage = 'Já existe um paciente cadastrado com este email. Por favor, use um email diferente.';
|
|
||||||
}
|
|
||||||
// Erro de CRM duplicado (médico)
|
|
||||||
else if (code === '23505' && msg.includes('doctors_crm')) {
|
|
||||||
friendlyMessage = 'Já existe um médico cadastrado com este CRM. Por favor, verifique se o médico já está registrado no sistema.';
|
|
||||||
}
|
|
||||||
// Erro de email duplicado (médico)
|
|
||||||
else if (code === '23505' && msg.includes('doctors_email_key')) {
|
|
||||||
friendlyMessage = 'Já existe um médico cadastrado com este email. Por favor, use um email diferente.';
|
|
||||||
}
|
|
||||||
// Outros erros de constraint unique
|
|
||||||
else if (code === '23505') {
|
|
||||||
friendlyMessage = 'Registro duplicado: já existe um cadastro com essas informações no sistema.';
|
|
||||||
}
|
|
||||||
// Erro de foreign key (registro referenciado em outra tabela)
|
|
||||||
else if (code === '23503') {
|
|
||||||
// Mensagem específica para pacientes com relatórios vinculados
|
|
||||||
if (msg && msg.toString().toLowerCase().includes('reports')) {
|
|
||||||
friendlyMessage = 'Não é possível excluir este paciente porque existem relatórios vinculados a ele. Exclua ou desvincule os relatórios antes de remover o paciente.';
|
|
||||||
} else {
|
} else {
|
||||||
friendlyMessage = 'Registro referenciado em outra tabela. Remova referências dependentes antes de tentar novamente.';
|
switch (res.status) {
|
||||||
|
case 401:
|
||||||
|
friendlyMessage = 'Você não está autenticado. Faça login novamente.';
|
||||||
|
break;
|
||||||
|
case 403:
|
||||||
|
friendlyMessage = 'Você não tem permissão para criar usuários.';
|
||||||
|
break;
|
||||||
|
case 500:
|
||||||
|
friendlyMessage = 'Erro no servidor ao criar usuário. Entre em contato com o suporte.';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// outros códigos comuns retornados pelo banco/edge
|
||||||
|
if (code === '23505') {
|
||||||
|
if (msg.includes('patients_cpf_key')) {
|
||||||
|
friendlyMessage = 'Já existe um paciente cadastrado com este CPF. Por favor, verifique se o paciente já está registrado no sistema ou use um CPF diferente.';
|
||||||
|
} else if (msg.includes('patients_email_key')) {
|
||||||
|
friendlyMessage = 'Já existe um paciente cadastrado com este email. Por favor, use um email diferente.';
|
||||||
|
} else if (msg.includes('doctors_crm')) {
|
||||||
|
friendlyMessage = 'Já existe um médico cadastrado com este CRM. Por favor, verifique se o médico já está registrado no sistema.';
|
||||||
|
} else if (msg.includes('doctors_email_key')) {
|
||||||
|
friendlyMessage = 'Já existe um médico cadastrado com este email. Por favor, use um email diferente.';
|
||||||
|
} else {
|
||||||
|
friendlyMessage = 'Registro duplicado: já existe um cadastro com essas informações no sistema.';
|
||||||
|
}
|
||||||
|
} else if (code === '23503') {
|
||||||
|
// Mensagem específica para pacientes com relatórios vinculados
|
||||||
|
if (msg && msg.toString().toLowerCase().includes('reports')) {
|
||||||
|
friendlyMessage = 'Não é possível excluir este paciente porque existem relatórios vinculados a ele. Exclua ou desvincule os relatórios antes de remover o paciente.';
|
||||||
|
} else {
|
||||||
|
friendlyMessage = 'Registro referenciado em outra tabela. Remova referências dependentes antes de tentar novamente.';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -374,7 +376,7 @@ export async function buscarPacientes(termo: string): Promise<Paciente[]> {
|
|||||||
try {
|
try {
|
||||||
const [key, val] = String(query).split('=');
|
const [key, val] = String(query).split('=');
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (key && typeof val !== 'undefined') params.set(key, val);
|
if (key && val !== undefined) params.set(key, val);
|
||||||
params.set('limit', '10');
|
params.set('limit', '10');
|
||||||
const url = `${REST}/patients?${params.toString()}`;
|
const url = `${REST}/patients?${params.toString()}`;
|
||||||
const headers = baseHeaders();
|
const headers = baseHeaders();
|
||||||
@ -713,9 +715,9 @@ export async function buscarMedicos(termo: string): Promise<Medico[]> {
|
|||||||
try {
|
try {
|
||||||
// Build the URL safely using URLSearchParams so special characters (like @) are encoded correctly
|
// Build the URL safely using URLSearchParams so special characters (like @) are encoded correctly
|
||||||
// query is like 'nome_social=ilike.*something*' -> split into key/value
|
// query is like 'nome_social=ilike.*something*' -> split into key/value
|
||||||
const [key, val] = String(query).split('=');
|
const [key, val] = String(query).split('=');
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (key && typeof val !== 'undefined') params.set(key, val);
|
if (key && val !== undefined) params.set(key, val);
|
||||||
params.set('limit', '10');
|
params.set('limit', '10');
|
||||||
const url = `${REST}/doctors?${params.toString()}`;
|
const url = `${REST}/doctors?${params.toString()}`;
|
||||||
const headers = baseHeaders();
|
const headers = baseHeaders();
|
||||||
@ -749,7 +751,8 @@ export async function buscarMedicoPorId(id: string | number): Promise<Medico | n
|
|||||||
const sId = String(id);
|
const sId = String(id);
|
||||||
|
|
||||||
// Helper para escape de aspas
|
// Helper para escape de aspas
|
||||||
const escapeQuotes = (v: string) => v.replace(/"/g, '\\"');
|
// escape double quotes for safe inclusion in quoted queries
|
||||||
|
const escapeQuotes = (v: string) => String(v).replace(/"/g, String.raw`"`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1) Se parece UUID, busca por id direto
|
// 1) Se parece UUID, busca por id direto
|
||||||
|
|||||||
@ -17,6 +17,7 @@ export interface UserData {
|
|||||||
telefone?: string
|
telefone?: string
|
||||||
foto_url?: string
|
foto_url?: string
|
||||||
}
|
}
|
||||||
|
roles?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginRequest {
|
export interface LoginRequest {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user