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:
M-Gabrielly 2025-10-13 02:18:49 -03:00
parent ca84c563b6
commit 05123e6c8f
8 changed files with 191 additions and 162 deletions

View File

@ -8,16 +8,54 @@ import { USER_TYPE_ROUTES, LOGIN_ROUTES, AUTH_STORAGE_KEYS } from '@/types/auth'
interface ProtectedRouteProps {
children: React.ReactNode
requiredUserType?: UserType[]
requiredRoles?: string[]
}
export default function ProtectedRoute({
children,
requiredUserType
requiredUserType,
requiredRoles
}: ProtectedRouteProps) {
const { authStatus, user } = useAuth()
const router = useRouter()
const isRedirecting = useRef(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(() => {
// 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
if (authStatus === 'authenticated' && user && requiredUserType && !requiredUserType.includes(user.userType)) {
isRedirecting.current = true
console.log('[PROTECTED-ROUTE] Usuário SEM permissão para esta página', {
if (authStatus === 'authenticated' && user && requiredUserType && !hasPermission) {
console.log('[PROTECTED-ROUTE] Usuário SEM permissão para esta página (accessDenied)', {
userType: user.userType,
userRoles: (user as any).roles || [],
requiredTypes: requiredUserType
})
const correctRoute = USER_TYPE_ROUTES[user.userType]
console.log('[PROTECTED-ROUTE] Redirecionando para área correta:', correctRoute)
router.push(correctRoute)
// Marcar acesso negado para renderizar fallback sem redirecionamentos/speinners infinitos
setAccessDenied(true)
return
}
@ -85,7 +120,7 @@ export default function ProtectedRoute({
})
isRedirecting.current = false
}
}, [authStatus, user, requiredUserType, router])
}, [authStatus, user, requiredUserType, router, hasPermission])
// Durante loading, mostrar spinner
if (authStatus === 'loading') {
@ -102,8 +137,8 @@ export default function ProtectedRoute({
)
}
// Se não autenticado ou redirecionando, mostrar spinner
if (authStatus === 'unauthenticated' || isRedirecting.current) {
// Se não autenticado ou redirecionando para login, mostrar spinner
if (authStatus === 'unauthenticated' || (isRedirecting.current && !accessDenied)) {
// evitar render no servidor para não causar mismatch de hidratação
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)
if (requiredUserType && user && !requiredUserType.includes(user.userType)) {
// Se usuário não tem permissão, mostrar fallback (baseado em hasPermission)
if (requiredUserType && user && !hasPermission) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
@ -130,6 +165,8 @@ export default function ProtectedRoute({
Tipo de acesso necessário: {requiredUserType.join(' ou ')}
<br />
Seu tipo de acesso: {user.userType}
<br />
Seus papéis: {(user as any).roles ? (user as any).roles.join(', ') : '—'}
</p>
<button
onClick={() => router.push(USER_TYPE_ROUTES[user.userType])}

View File

@ -48,7 +48,7 @@ export function PagesHeader({ title = "", subtitle = "" }: { title?: string, sub
<SimpleThemeToggle />
<Button
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
></Button>
{/* Avatar Dropdown Simples */}

View File

@ -288,6 +288,14 @@ function setField<T extends keyof FormData>(k: T, v: FormData[T]) {
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) {
v = v.replace(/\D/g, "").slice(0, 9);
v = v.replace(/(\d{2})(\d)/, "$1.$2");
@ -542,13 +550,6 @@ async function handleSubmit(ev: React.FormEvent) {
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">
<Collapsible open={expanded.dados} onOpenChange={() => setExpanded((s) => ({ ...s, dados: !s.dados }))}>
<Card>
@ -565,53 +566,11 @@ async function handleSubmit(ev: React.FormEvent) {
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4 pt-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">
{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>
<div className="space-y-2">
<Label>Nome completo</Label>
<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 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="space-y-2">
@ -693,10 +652,8 @@ async function handleSubmit(ev: React.FormEvent) {
<Input
placeholder="dd/mm/aaaa"
value={form.data_nascimento}
onChange={(e) => {
const v = e.target.value.replace(/[^0-9\/]/g, "").slice(0, 10);
setField("data_nascimento", v);
}}
onChange={(e) => setField("data_nascimento", formatDateInput(e.target.value))}
maxLength={10}
onBlur={() => {
const raw = form.data_nascimento;
const parts = raw.split(/\D+/).filter(Boolean);
@ -708,6 +665,7 @@ async function handleSubmit(ev: React.FormEvent) {
/>
</div>
</div>
</CardContent>
</CollapsibleContent>
</Card>
@ -792,39 +750,29 @@ async function handleSubmit(ev: React.FormEvent) {
<Label>E-mail</Label>
<Input value={form.email} onChange={(e) => setField("email", e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Telefone</Label>
<Input
value={form.telefone}
onChange={(e) => setField("telefone", formatPhone(e.target.value))}
placeholder="(XX) XXXXX-XXXX"
/>
</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 className="space-y-2">
<Label>Telefone</Label>
<Input
value={form.telefone}
onChange={(e) => setField("telefone", formatPhone(e.target.value))}
placeholder="(XX) XXXXX-XXXX"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Celular</Label>
<Input
value={form.celular}
<Input
value={form.celular}
onChange={(e) => setField("celular", formatPhone(e.target.value))}
placeholder="(XX) XXXXX-XXXX"
/>
</div>
<div className="space-y-2">
<Label>Contato de Emergência</Label>
<Input
value={form.contato_emergencia}
<Input
value={form.contato_emergencia}
onChange={(e) => setField("contato_emergencia", formatPhone(e.target.value))}
placeholder="(XX) XXXXX-XXXX"
/>

View File

@ -12,6 +12,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
import { Alert, AlertDescription } from "@/components/ui/alert";
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 Image from 'next/image';
import {
Paciente,
@ -165,6 +166,14 @@ export function PatientRegistrationForm({
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) {
const n = v.replace(/\D/g, "").slice(0, 8);
return n.replace(/(\d{5})(\d{0,3})/, (_, a, b) => `${a}${b ? "-" + b : ""}`);
@ -188,6 +197,26 @@ export function PatientRegistrationForm({
} finally {
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 {
@ -447,7 +476,7 @@ export function PatientRegistrationForm({
<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">
{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" />
)}
@ -497,7 +526,7 @@ export function PatientRegistrationForm({
</div>
<div className="space-y-2">
<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>
@ -520,10 +549,7 @@ export function PatientRegistrationForm({
<Input
placeholder="dd/mm/aaaa"
value={form.birth_date}
onChange={(e) => {
const v = e.target.value.replace(/[^0-9\/]/g, "").slice(0, 10);
setField("birth_date", v);
}}
onChange={(e) => setField("birth_date", formatDateInput(e.target.value))}
onBlur={() => {
const raw = form.birth_date;
const parts = raw.split(/\D+/).filter(Boolean);
@ -560,7 +586,7 @@ export function PatientRegistrationForm({
</div>
<div className="space-y-2">
<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>
</CardContent>

View File

@ -46,8 +46,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
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)
// Garantir que roles também sejam persistidos se presentes
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) {
localStorage.setItem(AUTH_STORAGE_KEYS.REFRESH_TOKEN, refreshToken)
@ -55,7 +60,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
setToken(accessToken)
setUser(userData)
// Garantir que o estado mantenha roles também
setUser(userData as any)
setAuthStatus('authenticated')
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
try {
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))
}
} catch (e) {
@ -218,6 +228,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
response.user.userType = 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 {
console.warn('[AUTH] Falha ao obter user-info para reconciliar roles:', infoRes.status)
}

View File

@ -1,6 +1,6 @@
// hooks/useReports.ts
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import {
Report,
CreateReportData,
@ -43,11 +43,11 @@ export function useReports(): UseReportsReturn {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 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 doctorsCacheRef = (globalThis as any).__reportsDoctorsCache__ || new Map<string, any>();
// store back to globalThis so cache persiste entre hot reloads
(globalThis as any).__reportsPatientsCache__ = patientsCacheRef;
(globalThis as any).__reportsDoctorsCache__ = doctorsCacheRef;
const patientsCacheRef = useRef<Map<string, any>>((globalThis as any).__reportsPatientsCache__ || new Map<string, any>());
const doctorsCacheRef = useRef<Map<string, any>>((globalThis as any).__reportsDoctorsCache__ || new Map<string, any>());
// store back to globalThis so cache persists between hot reloads
(globalThis as any).__reportsPatientsCache__ = patientsCacheRef.current;
(globalThis as any).__reportsDoctorsCache__ = doctorsCacheRef.current;
// Função para tratar erros
const handleError = useCallback((error: any) => {
@ -80,10 +80,10 @@ export function useReports(): UseReportsReturn {
for (const r of arr) {
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;
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>();
@ -92,7 +92,7 @@ export function useReports(): UseReportsReturn {
const patients = await buscarPacientesPorIds(patientIds);
for (const p of patients) {
patientsById.set(String(p.id), p);
patientsCacheRef.set(String(p.id), p);
patientsCacheRef.current.set(String(p.id), p);
}
} catch (e) {
// ignore batch failure
@ -100,14 +100,14 @@ export function useReports(): UseReportsReturn {
}
// 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) {
await Promise.all(unresolvedPatientIds.map(async (id) => {
try {
const p = await buscarPacientePorId(id);
if (p) {
patientsById.set(String(id), p);
patientsCacheRef.set(String(id), p);
patientsCacheRef.current.set(String(id), p);
}
} catch (e) {
// ignore individual failure
@ -121,21 +121,21 @@ export function useReports(): UseReportsReturn {
const doctors = await buscarMedicosPorIds(doctorIds);
for (const d of doctors) {
doctorsById.set(String(d.id), d);
doctorsCacheRef.set(String(d.id), d);
doctorsCacheRef.current.set(String(d.id), d);
}
} catch (e) {
// 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) {
await Promise.all(unresolvedDoctorIds.map(async (id) => {
try {
const d = await buscarMedicoPorId(id);
if (d) {
doctorsById.set(String(id), d);
doctorsCacheRef.set(String(id), d);
doctorsCacheRef.current.set(String(id), d);
}
} catch (e) {
// ignore
@ -156,7 +156,7 @@ export function useReports(): UseReportsReturn {
const pid = r.patient_id ?? r.patient ?? r.paciente;
if (!copy.paciente && 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
@ -167,7 +167,7 @@ export function useReports(): UseReportsReturn {
const did = r.requested_by ?? r.created_by ?? r.executante;
if (did) {
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));
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
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));
}
}

View File

@ -240,9 +240,10 @@ async function parse<T>(res: Response): Promise<T> {
// Mensagens amigáveis para erros comuns
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')) {
// organizar casos por checagens em ordem específica
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.';
} else if (msg?.includes('already registered')) {
@ -251,41 +252,42 @@ async function parse<T>(res: Response): Promise<T> {
friendlyMessage = 'Tipo de acesso inválido.';
} else if (msg?.includes('Missing required fields')) {
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 {
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 {
const [key, val] = String(query).split('=');
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');
const url = `${REST}/patients?${params.toString()}`;
const headers = baseHeaders();
@ -713,9 +715,9 @@ export async function buscarMedicos(termo: string): Promise<Medico[]> {
try {
// Build the URL safely using URLSearchParams so special characters (like @) are encoded correctly
// query is like 'nome_social=ilike.*something*' -> split into key/value
const [key, val] = String(query).split('=');
const params = new URLSearchParams();
if (key && typeof val !== 'undefined') params.set(key, val);
const [key, val] = String(query).split('=');
const params = new URLSearchParams();
if (key && val !== undefined) params.set(key, val);
params.set('limit', '10');
const url = `${REST}/doctors?${params.toString()}`;
const headers = baseHeaders();
@ -749,7 +751,8 @@ export async function buscarMedicoPorId(id: string | number): Promise<Medico | n
const sId = String(id);
// 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 {
// 1) Se parece UUID, busca por id direto

View File

@ -17,6 +17,7 @@ export interface UserData {
telefone?: string
foto_url?: string
}
roles?: string[]
}
export interface LoginRequest {