Atualização

This commit is contained in:
guisilvagomes 2025-10-22 22:28:22 -03:00
parent 376e344506
commit b85a43dd3e
28 changed files with 1600 additions and 7973 deletions

View File

@ -0,0 +1,20 @@
# Script de limpeza de dependências não utilizadas
# Execute este arquivo no PowerShell
Write-Host "🧹 Limpando dependências não utilizadas..." -ForegroundColor Cyan
# Remover pacotes não utilizados
Write-Host "`n📦 Removendo @lumi.new/sdk..." -ForegroundColor Yellow
pnpm remove @lumi.new/sdk
Write-Host "`n📦 Removendo node-fetch..." -ForegroundColor Yellow
pnpm remove node-fetch
Write-Host "`n📦 Removendo react-toastify..." -ForegroundColor Yellow
pnpm remove react-toastify
Write-Host "`n✅ Limpeza concluída!" -ForegroundColor Green
Write-Host "📊 Verificando tamanho de node_modules..." -ForegroundColor Cyan
$size = (Get-ChildItem "node_modules" -Recurse -File -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum / 1MB
Write-Host "Tamanho atual: $([math]::Round($size, 2)) MB" -ForegroundColor White

View File

@ -12,6 +12,7 @@ const SUPABASE_ANON_KEY =
interface MagicLinkRequest { interface MagicLinkRequest {
email: string; email: string;
redirect_url?: string;
} }
export const handler: Handler = async (event: HandlerEvent) => { export const handler: Handler = async (event: HandlerEvent) => {
@ -61,6 +62,11 @@ export const handler: Handler = async (event: HandlerEvent) => {
}, },
body: JSON.stringify({ body: JSON.stringify({
email: body.email, email: body.email,
options: {
emailRedirectTo:
body.redirect_url ||
"https://mediconnectbrasil.netlify.app/auth/callback",
},
}), }),
}); });

View File

@ -0,0 +1,223 @@
/**
* Netlify Function: Create User With Password
* POST /create-user-with-password - Cria usuário com senha
* Usa Edge Function do Supabase (não Admin API)
* Requer permissão de admin, gestor ou secretaria
*/
import type { Handler, HandlerEvent } from "@netlify/functions";
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const SUPABASE_ANON_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
export const handler: Handler = async (event: HandlerEvent) => {
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type, Authorization, apikey",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
if (event.httpMethod === "OPTIONS") {
return {
statusCode: 200,
headers,
body: "",
};
}
if (event.httpMethod !== "POST") {
return {
statusCode: 405,
headers,
body: JSON.stringify({ error: "Method Not Allowed" }),
};
}
try {
const authHeader =
event.headers.authorization || event.headers.Authorization;
if (!authHeader) {
console.error("[create-user-with-password] Token não fornecido!");
return {
statusCode: 401,
headers,
body: JSON.stringify({
error: "Token de autenticação é obrigatório",
}),
};
}
const body = JSON.parse(event.body || "{}");
console.log(
"[create-user-with-password] Recebido:",
JSON.stringify({ ...body, password: "***" }, null, 2)
);
// Validações
if (!body.email || !body.password || !body.full_name) {
return {
statusCode: 400,
headers,
body: JSON.stringify({
error: "Campos obrigatórios: email, password, full_name",
}),
};
}
if (body.password.length < 6) {
return {
statusCode: 400,
headers,
body: JSON.stringify({
error: "Senha deve ter no mínimo 6 caracteres",
}),
};
}
// 1. Criar usuário via Edge Function do Supabase
console.log(
"[create-user-with-password] Chamando Edge Function do Supabase..."
);
console.log(
"[create-user-with-password] URL:",
`${SUPABASE_URL}/functions/v1/create-user`
);
console.log("[create-user-with-password] Payload:", {
email: body.email,
has_password: !!body.password,
full_name: body.full_name,
});
const createUserResponse = await fetch(
`${SUPABASE_URL}/functions/v1/create-user`,
{
method: "POST",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: authHeader,
"Content-Type": "application/json",
},
body: JSON.stringify({
email: body.email,
password: body.password,
full_name: body.full_name,
phone: body.phone || null,
role: body.role || "user",
}),
}
);
console.log(
"[create-user-with-password] Status da resposta:",
createUserResponse.status
);
console.log(
"[create-user-with-password] Status text:",
createUserResponse.statusText
);
// Sempre tenta ler a resposta como JSON
let responseData;
try {
responseData = await createUserResponse.json();
console.log(
"[create-user-with-password] Resposta JSON:",
JSON.stringify(responseData, null, 2)
);
} catch (error) {
const responseText = await createUserResponse.text();
console.error(
"[create-user-with-password] Resposta não é JSON:",
responseText
);
console.error("[create-user-with-password] Erro ao parsear JSON:", error);
return {
statusCode: 500,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify({
error: "Erro ao processar resposta do Supabase",
details: responseText,
}),
};
}
if (!createUserResponse.ok) {
console.error(
"[create-user-with-password] Erro ao criar usuário:",
JSON.stringify(responseData, null, 2)
);
return {
statusCode: createUserResponse.status,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify({
error:
responseData.msg || responseData.message || "Erro ao criar usuário",
details: responseData,
}),
};
}
// Verificar se a Edge Function retornou sucesso
if (!responseData.success) {
console.error(
"[create-user-with-password] Edge Function retornou erro:",
JSON.stringify(responseData, null, 2)
);
return {
statusCode: 400,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify({
error: responseData.error || "Erro ao criar usuário",
details: responseData,
}),
};
}
const userData = responseData.user;
console.log(
"[create-user-with-password] Usuário criado com sucesso:",
userData.id
);
console.log(
"[create-user-with-password] Resposta completa:",
JSON.stringify(responseData, null, 2)
);
// A Edge Function já cria o perfil e atribui a role automaticamente
// Retornar sucesso
return {
statusCode: 201,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify({
success: true,
user: userData,
message: responseData.message || "Usuário criado com sucesso",
}),
};
} catch (error) {
console.error("[create-user-with-password] Erro:", error);
return {
statusCode: 500,
headers,
body: JSON.stringify({
error: "Erro interno no servidor",
message: error instanceof Error ? error.message : "Erro desconhecido",
}),
};
}
};

View File

@ -0,0 +1,127 @@
/**
* Netlify Function: Delete User (Hard Delete)
* POST /delete-user - Deleta usuário permanentemente
* OPERAÇÃO IRREVERSÍVEL - Use com extremo cuidado!
* Requer permissão de admin ou gestor
* Usa Admin API do Supabase
*/
import type { Handler, HandlerEvent } from "@netlify/functions";
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const SUPABASE_SERVICE_ROLE_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc1NDk1NDM2OSwiZXhwIjoyMDcwNTMwMzY5fQ.Dez8PQkV8vWv7VkL_fZe-lY-Xs9P5VptNvRRnhkxoXw";
export const handler: Handler = async (event: HandlerEvent) => {
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type, Authorization, apikey",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
if (event.httpMethod === "OPTIONS") {
return {
statusCode: 200,
headers,
body: "",
};
}
if (event.httpMethod !== "POST") {
return {
statusCode: 405,
headers,
body: JSON.stringify({ error: "Method Not Allowed" }),
};
}
try {
const authHeader =
event.headers.authorization || event.headers.Authorization;
if (!authHeader) {
return {
statusCode: 401,
headers,
body: JSON.stringify({
error: "Token de autenticação é obrigatório",
}),
};
}
const body = JSON.parse(event.body || "{}");
console.log("[delete-user] ATENÇÃO: Tentativa de hard delete:", {
userId: body.userId,
requestedBy: "via Netlify Function",
});
// Validação
if (!body.userId) {
return {
statusCode: 400,
headers,
body: JSON.stringify({
error: "userId é obrigatório",
}),
};
}
// TODO: Aqui deveria verificar se o usuário tem permissão de admin/gestor
// Verificando o token JWT que foi passado
// Deletar usuário via Admin API do Supabase
const response = await fetch(
`${SUPABASE_URL}/auth/v1/admin/users/${body.userId}`,
{
method: "DELETE",
headers: {
apikey: SUPABASE_SERVICE_ROLE_KEY,
Authorization: `Bearer ${SUPABASE_SERVICE_ROLE_KEY}`,
"Content-Type": "application/json",
},
}
);
if (response.ok) {
console.log("[delete-user] Usuário deletado com sucesso:", body.userId);
return {
statusCode: 200,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify({
success: true,
message: "Usuário deletado permanentemente",
userId: body.userId,
}),
};
}
const errorData = await response.json();
console.error("[delete-user] Erro ao deletar:", errorData);
return {
statusCode: response.status,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify({
error: errorData.msg || errorData.message || "Erro ao deletar usuário",
details: errorData,
}),
};
} catch (error) {
console.error("[delete-user] Erro:", error);
return {
statusCode: 500,
headers,
body: JSON.stringify({
error: "Erro interno no servidor",
message: error instanceof Error ? error.message : "Erro desconhecido",
}),
};
}
};

View File

@ -0,0 +1,97 @@
/**
* Netlify Function: Register Patient (Public)
* POST /register-patient - Registro público de paciente
* Não requer autenticação - função pública
* Validações rigorosas (CPF, rate limiting, rollback)
*/
import type { Handler, HandlerEvent } from "@netlify/functions";
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const SUPABASE_ANON_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
export const handler: Handler = async (event: HandlerEvent) => {
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
if (event.httpMethod === "OPTIONS") {
return {
statusCode: 200,
headers,
body: "",
};
}
try {
if (event.httpMethod === "POST") {
const body = JSON.parse(event.body || "{}");
console.log(
"[register-patient] Recebido body:",
JSON.stringify(body, null, 2)
);
// Validação dos campos obrigatórios
if (!body.email || !body.full_name || !body.cpf || !body.phone_mobile) {
return {
statusCode: 400,
headers,
body: JSON.stringify({
error: "Campos obrigatórios: email, full_name, cpf, phone_mobile",
}),
};
}
// Chama a Edge Function pública do Supabase
const response = await fetch(
`${SUPABASE_URL}/functions/v1/register-patient`,
{
method: "POST",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: `Bearer ${SUPABASE_ANON_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
}
);
const data = await response.json();
console.log("[register-patient] Resposta do Supabase:", {
status: response.status,
data: JSON.stringify(data, null, 2),
});
return {
statusCode: response.status,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
};
}
return {
statusCode: 405,
headers,
body: JSON.stringify({ error: "Method Not Allowed" }),
};
} catch (error) {
console.error("[register-patient] Erro na API:", error);
return {
statusCode: 500,
headers,
body: JSON.stringify({
error: "Erro interno do servidor",
message: error instanceof Error ? error.message : "Erro desconhecido",
}),
};
}
};

View File

@ -0,0 +1,116 @@
/**
* Netlify Function: Request Password Reset
* POST /request-password-reset - Solicita reset de senha via email (público)
* Não requer autenticação - endpoint público
*/
import type { Handler, HandlerEvent } from "@netlify/functions";
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const SUPABASE_ANON_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
interface PasswordResetRequest {
email: string;
redirect_url?: string;
}
export const handler: Handler = async (event: HandlerEvent) => {
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
if (event.httpMethod === "OPTIONS") {
return {
statusCode: 200,
headers,
body: "",
};
}
if (event.httpMethod !== "POST") {
return {
statusCode: 405,
headers,
body: JSON.stringify({ error: "Method Not Allowed" }),
};
}
try {
const body: PasswordResetRequest = JSON.parse(event.body || "{}");
console.log("[request-password-reset] Recebido:", {
email: body.email,
hasRedirectUrl: !!body.redirect_url,
});
if (!body.email) {
return {
statusCode: 400,
headers,
body: JSON.stringify({ error: "Email é obrigatório" }),
};
}
// Chama a API do Supabase para enviar email de reset
const response = await fetch(`${SUPABASE_URL}/auth/v1/recover`, {
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: SUPABASE_ANON_KEY,
},
body: JSON.stringify({
email: body.email,
options: {
redirectTo:
body.redirect_url ||
"https://mediconnectbrasil.netlify.app/reset-password",
},
}),
});
const data = await response.json();
console.log("[request-password-reset] Resposta Supabase:", {
status: response.status,
data,
});
// Supabase sempre retorna 200 mesmo se o email não existir (por segurança)
if (response.ok) {
return {
statusCode: 200,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify({
success: true,
message:
"Email de reset de senha enviado com sucesso. Verifique sua caixa de entrada.",
}),
};
}
return {
statusCode: response.status,
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
};
} catch (error) {
console.error("[request-password-reset] Erro:", error);
return {
statusCode: 500,
headers,
body: JSON.stringify({
error: "Erro ao solicitar reset de senha",
details: error instanceof Error ? error.message : "Erro desconhecido",
}),
};
}
};

View File

@ -12,16 +12,14 @@
"deploy:netlify:build": "pnpm build && netlify deploy --prod --dir=dist" "deploy:netlify:build": "pnpm build && netlify deploy --prod --dir=dist"
}, },
"dependencies": { "dependencies": {
"@lumi.new/sdk": "^0.1.5", "@supabase/supabase-js": "^2.76.1",
"axios": "^1.12.2", "axios": "^1.12.2",
"date-fns": "^2.30.0", "date-fns": "^2.30.0",
"lucide-react": "^0.540.0", "lucide-react": "^0.540.0",
"node-fetch": "^2.7.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hot-toast": "^2.4.1", "react-hot-toast": "^2.4.1",
"react-router-dom": "^6.26.0", "react-router-dom": "^6.26.0",
"react-toastify": "^11.0.5",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {

File diff suppressed because it is too large Load Diff

View File

@ -12,7 +12,6 @@ import Home from "./pages/Home";
import LoginPaciente from "./pages/LoginPaciente"; import LoginPaciente from "./pages/LoginPaciente";
import LoginSecretaria from "./pages/LoginSecretaria"; import LoginSecretaria from "./pages/LoginSecretaria";
import LoginMedico from "./pages/LoginMedico"; import LoginMedico from "./pages/LoginMedico";
import CadastroMedico from "./pages/CadastroMedico";
import AgendamentoPaciente from "./pages/AgendamentoPaciente"; import AgendamentoPaciente from "./pages/AgendamentoPaciente";
import AcompanhamentoPaciente from "./pages/AcompanhamentoPaciente"; import AcompanhamentoPaciente from "./pages/AcompanhamentoPaciente";
import PainelMedico from "./pages/PainelMedico"; import PainelMedico from "./pages/PainelMedico";
@ -26,6 +25,7 @@ import CentralAjudaRouter from "./pages/CentralAjudaRouter";
import PerfilMedico from "./pages/PerfilMedico"; import PerfilMedico from "./pages/PerfilMedico";
import PerfilPaciente from "./pages/PerfilPaciente"; import PerfilPaciente from "./pages/PerfilPaciente";
import ClearCache from "./pages/ClearCache"; import ClearCache from "./pages/ClearCache";
import AuthCallback from "./pages/AuthCallback";
function App() { function App() {
return ( return (
@ -47,10 +47,10 @@ function App() {
<Routes> <Routes>
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
<Route path="/clear-cache" element={<ClearCache />} /> <Route path="/clear-cache" element={<ClearCache />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/paciente" element={<LoginPaciente />} /> <Route path="/paciente" element={<LoginPaciente />} />
<Route path="/login-secretaria" element={<LoginSecretaria />} /> <Route path="/login-secretaria" element={<LoginSecretaria />} />
<Route path="/login-medico" element={<LoginMedico />} /> <Route path="/login-medico" element={<LoginMedico />} />
<Route path="/cadastro/medico" element={<CadastroMedico />} />
<Route path="/dev/token" element={<TokenInspector />} /> <Route path="/dev/token" element={<TokenInspector />} />
<Route path="/admin/diagnostico" element={<AdminDiagnostico />} /> <Route path="/admin/diagnostico" element={<AdminDiagnostico />} />
{/* <Route path="/teste-squad18" element={<TesteCadastroSquad18 />} /> */} {/* <Route path="/teste-squad18" element={<TesteCadastroSquad18 />} /> */}

View File

@ -0,0 +1,132 @@
import React, { useState } from "react";
import { X, AlertTriangle } from "lucide-react";
interface ConfirmDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string | React.ReactNode;
confirmText?: string;
cancelText?: string;
requireTypedConfirmation?: boolean;
confirmationWord?: string;
isDangerous?: boolean;
}
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = "Confirmar",
cancelText = "Cancelar",
requireTypedConfirmation = false,
confirmationWord = "CONFIRMAR",
isDangerous = false,
}) => {
const [typedConfirmation, setTypedConfirmation] = useState("");
if (!isOpen) return null;
const handleConfirm = () => {
if (requireTypedConfirmation && typedConfirmation !== confirmationWord) {
return;
}
onConfirm();
setTypedConfirmation("");
onClose();
};
const handleCancel = () => {
setTypedConfirmation("");
onClose();
};
const isConfirmDisabled =
requireTypedConfirmation && typedConfirmation !== confirmationWord;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full mx-4 overflow-hidden animate-in fade-in duration-200">
{/* Header */}
<div
className={`px-6 py-4 border-b flex items-center justify-between ${
isDangerous
? "bg-red-50 border-red-200"
: "bg-blue-50 border-blue-200"
}`}
>
<div className="flex items-center gap-3">
{isDangerous ? (
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-red-600" />
</div>
) : (
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-blue-600" />
</div>
)}
<h3
className={`text-lg font-semibold ${
isDangerous ? "text-red-900" : "text-blue-900"
}`}
>
{title}
</h3>
</div>
<button
onClick={handleCancel}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="px-6 py-5">
<div className="text-gray-700 mb-4">{message}</div>
{requireTypedConfirmation && (
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Digite <span className="font-bold">{confirmationWord}</span>{" "}
para confirmar:
</label>
<input
type="text"
value={typedConfirmation}
onChange={(e) => setTypedConfirmation(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder={confirmationWord}
autoFocus
/>
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 bg-gray-50 border-t border-gray-200 flex items-center justify-end gap-3">
<button
onClick={handleCancel}
className="px-5 py-2.5 text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors font-medium"
>
{cancelText}
</button>
<button
onClick={handleConfirm}
disabled={isConfirmDisabled}
className={`px-5 py-2.5 text-white rounded-lg font-medium transition-all ${
isDangerous
? "bg-red-600 hover:bg-red-700 disabled:bg-red-300"
: "bg-blue-600 hover:bg-blue-700 disabled:bg-blue-300"
} disabled:cursor-not-allowed`}
>
{confirmText}
</button>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,19 @@
/**
* Supabase Client Configuration
* Usado para processar Magic Links e gerenciar sessões
*/
import { createClient } from "@supabase/supabase-js";
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const SUPABASE_ANON_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
auth: {
storage: localStorage,
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: true, // Importante para Magic Link
},
});

View File

@ -4,9 +4,6 @@ import "./index.css";
import App from "./App.tsx"; import App from "./App.tsx";
import { AuthProvider } from "./context/AuthContext"; import { AuthProvider } from "./context/AuthContext";
import "react-toastify/dist/ReactToastify.css";
import { ToastContainer } from "react-toastify";
// Apply accessibility preferences before React mounts to avoid FOUC and ensure persistence across reloads. // Apply accessibility preferences before React mounts to avoid FOUC and ensure persistence across reloads.
// This also helps E2E test detect classes after reload. // This also helps E2E test detect classes after reload.
(() => { (() => {
@ -48,17 +45,5 @@ createRoot(document.getElementById("root")!).render(
<AuthProvider> <AuthProvider>
<App /> <App />
</AuthProvider> </AuthProvider>
<ToastContainer
position="top-right"
autoClose={3000}
hideProgressBar={false}
newestOnTop={false}
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
theme="colored"
/>
</StrictMode> </StrictMode>
); );

View File

@ -0,0 +1,128 @@
/**
* Página de Callback do Magic Link
* Processa o token do magic link e autentica o usuário
*/
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { supabase } from "../lib/supabase";
import { Loader2, CheckCircle, XCircle } from "lucide-react";
import { useAuth } from "../hooks/useAuth";
import toast from "react-hot-toast";
export default function AuthCallback() {
const navigate = useNavigate();
const { loginComEmailSenha } = useAuth();
const [status, setStatus] = useState<"loading" | "success" | "error">(
"loading"
);
const [message, setMessage] = useState("Processando autenticação...");
useEffect(() => {
const handleCallback = async () => {
try {
console.log("[AuthCallback] Iniciando processamento do magic link");
// Supabase automaticamente processa os query params
const {
data: { session },
error,
} = await supabase.auth.getSession();
if (error) {
console.error("[AuthCallback] Erro ao obter sessão:", error);
throw error;
}
if (!session) {
throw new Error(
"Nenhuma sessão encontrada. O link pode ter expirado."
);
}
console.log("[AuthCallback] Sessão obtida:", {
user: session.user.email,
role: session.user.role,
});
// Fazer login no contexto da aplicação
const loginOk = await loginComEmailSenha(session.user.email!, "");
if (!loginOk) {
throw new Error("Erro ao processar login no sistema");
}
setStatus("success");
setMessage("Autenticado com sucesso! Redirecionando...");
toast.success("Login realizado com sucesso!");
// Redirecionar baseado no role
setTimeout(() => {
const userRole = session.user.user_metadata?.role || "paciente";
switch (userRole) {
case "medico":
navigate("/painel-medico");
break;
case "secretaria":
navigate("/painel-secretaria");
break;
case "paciente":
default:
navigate("/acompanhamento");
break;
}
}, 1500);
} catch (err: any) {
console.error("[AuthCallback] Erro:", err);
setStatus("error");
setMessage(err.message || "Erro ao processar autenticação");
toast.error(err.message || "Erro na autenticação");
}
};
handleCallback();
}, [navigate, loginComEmailSenha]);
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-white dark:from-gray-900 dark:to-gray-950 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8 text-center">
{status === "loading" && (
<>
<Loader2 className="w-16 h-16 text-blue-600 dark:text-blue-400 mx-auto mb-4 animate-spin" />
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
Autenticando
</h1>
<p className="text-gray-600 dark:text-gray-400">{message}</p>
</>
)}
{status === "success" && (
<>
<CheckCircle className="w-16 h-16 text-green-600 dark:text-green-400 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
Sucesso!
</h1>
<p className="text-gray-600 dark:text-gray-400">{message}</p>
</>
)}
{status === "error" && (
<>
<XCircle className="w-16 h-16 text-red-600 dark:text-red-400 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
Erro na Autenticação
</h1>
<p className="text-gray-600 dark:text-gray-400 mb-6">{message}</p>
<button
onClick={() => navigate("/")}
className="px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
>
Voltar ao Início
</button>
</>
)}
</div>
</div>
);
}

View File

@ -1,203 +0,0 @@
import React, { useState } from "react";
import { Mail, User, Phone, Stethoscope, ArrowLeft } from "lucide-react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { userService } from "../services";
const CadastroMedico: React.FC = () => {
const [formData, setFormData] = useState({
nome: "",
email: "",
telefone: "",
});
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const handleCadastro = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
// Validações básicas
if (!formData.nome.trim()) {
toast.error("Nome completo é obrigatório");
setLoading(false);
return;
}
if (!formData.email.trim() || !formData.email.includes("@")) {
toast.error("Email válido é obrigatório");
setLoading(false);
return;
}
// Usar create-user (flexível, validações mínimas)
// Cria entrada básica em doctors (sem CRM, CPF vazios)
console.log("[CadastroMedico] Enviando dados para create-user:", {
email: formData.email,
full_name: formData.nome,
phone: formData.telefone || null,
role: "medico",
});
const response = await userService.createUser(
{
email: formData.email,
full_name: formData.nome,
phone: formData.telefone || null,
role: "medico",
},
true
); // true = registro público (sem token)
console.log("[CadastroMedico] Resposta do create-user:", response);
toast.success(
"Cadastro realizado com sucesso! Verifique seu email para ativar a conta. Complete seu perfil depois de ativar.",
{ duration: 6000 }
);
// Limpa formulário e volta para login
setFormData({ nome: "", email: "", telefone: "" });
setTimeout(() => navigate("/login-medico"), 2000);
} catch (error: unknown) {
console.error("Erro ao cadastrar médico:", error);
const errorMsg =
(error as any)?.response?.data?.error ||
(error as Error)?.message ||
"Erro ao criar conta";
toast.error(errorMsg);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-indigo-50 to-white dark:from-gray-900 dark:to-gray-950 flex items-center justify-center p-4 transition-colors">
<div className="max-w-md w-full">
<div className="text-center mb-8">
<div className="bg-gradient-to-r from-indigo-600 to-indigo-400 dark:from-indigo-700 dark:to-indigo-500 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4 shadow-md">
<Stethoscope className="w-8 h-8 text-white" />
</div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2">
Cadastro de Médico
</h1>
<p className="text-gray-600 dark:text-gray-400">
Crie sua conta para acessar o sistema
</p>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8 border border-transparent dark:border-gray-700 transition-colors">
<form onSubmit={handleCadastro} className="space-y-6" noValidate>
<div>
<label
htmlFor="nome"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Nome Completo *
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
id="nome"
type="text"
value={formData.nome}
onChange={(e) =>
setFormData((prev) => ({ ...prev, nome: e.target.value }))
}
className="form-input pl-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
placeholder="Dr. Seu Nome"
required
autoComplete="name"
/>
</div>
</div>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Email *
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
id="email"
type="email"
value={formData.email}
onChange={(e) =>
setFormData((prev) => ({ ...prev, email: e.target.value }))
}
className="form-input pl-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
placeholder="seu@email.com"
required
autoComplete="email"
/>
</div>
</div>
<div>
<label
htmlFor="telefone"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Telefone (Opcional)
</label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
id="telefone"
type="tel"
value={formData.telefone}
onChange={(e) =>
setFormData((prev) => ({
...prev,
telefone: e.target.value,
}))
}
className="form-input pl-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
placeholder="(00) 00000-0000"
autoComplete="tel"
/>
</div>
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-lg space-y-2">
<p className="text-sm text-blue-800 dark:text-blue-200">
🔐 <strong>Ativação por Email:</strong> Você receberá um link
mágico (magic link) no seu email para ativar a conta e definir
sua senha.
</p>
<p className="text-sm text-blue-800 dark:text-blue-200">
📋 <strong>Completar Perfil:</strong> Após ativar, você poderá
adicionar CRM, CPF, especialidade e outros dados no seu perfil.
</p>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-gradient-to-r from-indigo-600 to-indigo-400 text-white py-3 px-4 rounded-lg font-medium hover:from-indigo-700 hover:to-indigo-500 hover:scale-105 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2"
>
{loading ? "Criando conta..." : "Criar Conta"}
</button>
<div className="text-center">
<button
type="button"
onClick={() => navigate("/login-medico")}
className="inline-flex items-center gap-2 text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300 font-medium text-sm transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Voltar para o login
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default CadastroMedico;

View File

@ -1,184 +0,0 @@
import React, { useState } from "react";
import { Mail, Lock, User, Phone, Clipboard, ArrowLeft } from "lucide-react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { userService } from "../services";
const CadastroSecretaria: React.FC = () => {
const [formData, setFormData] = useState({
nome: "",
email: "",
telefone: "",
});
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const handleCadastro = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
// Validações básicas
if (!formData.nome.trim()) {
toast.error("Nome completo é obrigatório");
setLoading(false);
return;
}
if (!formData.email.trim() || !formData.email.includes("@")) {
toast.error("Email válido é obrigatório");
setLoading(false);
return;
}
// Usar create-user (flexível, validações mínimas)
await userService.createUser({
email: formData.email,
full_name: formData.nome,
phone: formData.telefone || null,
role: "secretaria",
});
toast.success(
"Cadastro realizado com sucesso! Verifique seu email para ativar a conta.",
{ duration: 5000 }
);
// Limpa formulário e volta para login
setFormData({ nome: "", email: "", telefone: "" });
setTimeout(() => navigate("/login-secretaria"), 2000);
} catch (error: any) {
console.error("Erro ao cadastrar secretária:", error);
const errorMsg =
error?.response?.data?.error || error?.message || "Erro ao criar conta";
toast.error(errorMsg);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-green-50 to-white dark:from-gray-900 dark:to-gray-950 flex items-center justify-center p-4 transition-colors">
<div className="max-w-md w-full">
<div className="text-center mb-8">
<div className="bg-gradient-to-r from-green-600 to-green-400 dark:from-green-700 dark:to-green-500 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4 shadow-md">
<Clipboard className="w-8 h-8 text-white" />
</div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2">
Cadastro de Secretária
</h1>
<p className="text-gray-600 dark:text-gray-400">
Crie sua conta para acessar o sistema
</p>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8 border border-transparent dark:border-gray-700 transition-colors">
<form onSubmit={handleCadastro} className="space-y-6" noValidate>
<div>
<label
htmlFor="nome"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Nome Completo *
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
id="nome"
type="text"
value={formData.nome}
onChange={(e) =>
setFormData((prev) => ({ ...prev, nome: e.target.value }))
}
className="form-input pl-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
placeholder="Seu nome completo"
required
autoComplete="name"
/>
</div>
</div>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Email *
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
id="email"
type="email"
value={formData.email}
onChange={(e) =>
setFormData((prev) => ({ ...prev, email: e.target.value }))
}
className="form-input pl-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
placeholder="seu@email.com"
required
autoComplete="email"
/>
</div>
</div>
<div>
<label
htmlFor="telefone"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Telefone (Opcional)
</label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
id="telefone"
type="tel"
value={formData.telefone}
onChange={(e) =>
setFormData((prev) => ({
...prev,
telefone: e.target.value,
}))
}
className="form-input pl-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
placeholder="(00) 00000-0000"
autoComplete="tel"
/>
</div>
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-lg">
<p className="text-sm text-blue-800 dark:text-blue-200">
🔐 <strong>Ativação por Email:</strong> Você receberá um link
mágico (magic link) no seu email para ativar a conta e definir
sua senha.
</p>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-gradient-to-r from-green-600 to-green-400 text-white py-3 px-4 rounded-lg font-medium hover:from-green-700 hover:to-green-500 hover:scale-105 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-green-500 focus-visible:ring-offset-2"
>
{loading ? "Criando conta..." : "Criar Conta"}
</button>
<div className="text-center">
<button
type="button"
onClick={() => navigate("/login-secretaria")}
className="inline-flex items-center gap-2 text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 font-medium text-sm transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Voltar para o login
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default CadastroSecretaria;

View File

@ -22,26 +22,21 @@ const LoginMedico: React.FC = () => {
try { try {
console.log("[LoginMedico] Fazendo login com email:", formData.email); console.log("[LoginMedico] Fazendo login com email:", formData.email);
await authService.login({
email: formData.email,
password: formData.senha,
});
console.log("[LoginMedico] Login bem-sucedido!");
const ok = await loginComEmailSenha(formData.email, formData.senha); const ok = await loginComEmailSenha(formData.email, formData.senha);
if (ok) { if (ok) {
console.log("[LoginMedico] Navegando para /painel-medico"); console.log(
"[LoginMedico] Login bem-sucedido! Navegando para /painel-medico"
);
toast.success("Login realizado com sucesso!"); toast.success("Login realizado com sucesso!");
navigate("/painel-medico"); navigate("/painel-medico");
} else { } else {
console.error("[LoginMedico] loginComEmailSenha retornou false"); console.error("[LoginMedico] loginComEmailSenha retornou false");
toast.error("Erro ao processar login"); toast.error("Credenciais inválidas ou usuário sem permissão");
} }
} catch (error) { } catch (error) {
console.error("[LoginMedico] Erro no login:", error); console.error("[LoginMedico] Erro no login:", error);
toast.error("Erro ao fazer login. Tente novamente."); toast.error("Erro ao fazer login. Verifique suas credenciais.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -113,6 +108,29 @@ const LoginMedico: React.FC = () => {
autoComplete="current-password" autoComplete="current-password"
/> />
</div> </div>
<div className="text-right mt-2">
<button
type="button"
onClick={async () => {
if (!formData.email.trim()) {
toast.error("Digite seu email primeiro");
return;
}
try {
await authService.sendMagicLink(
formData.email,
"https://mediconnectbrasil.netlify.app/medico/painel"
);
toast.success("Link de acesso enviado para seu email!");
} catch {
toast.error("Erro ao enviar link");
}
}}
className="text-sm text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300 hover:underline transition-colors"
>
Esqueceu a senha?
</button>
</div>
</div> </div>
<button <button
@ -123,15 +141,47 @@ const LoginMedico: React.FC = () => {
{loading ? "Entrando..." : "Entrar"} {loading ? "Entrando..." : "Entrar"}
</button> </button>
<div className="text-center mt-4"> {/* Divisor OU */}
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300 dark:border-gray-600"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400">
OU
</span>
</div>
</div>
{/* Botão Magic Link */}
<button <button
type="button" type="button"
onClick={() => navigate("/cadastro/medico")} onClick={async () => {
className="text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300 font-medium text-sm transition-colors" if (!formData.email.trim()) {
toast.error("Digite seu email primeiro");
return;
}
setLoading(true);
try {
await authService.sendMagicLink(
formData.email,
"https://mediconnectbrasil.netlify.app/medico/painel"
);
toast.success(
"Link de acesso enviado para seu email! Verifique sua caixa de entrada.",
{ duration: 6000 }
);
} catch {
toast.error("Erro ao enviar link");
} finally {
setLoading(false);
}
}}
disabled={loading}
className="w-full bg-white dark:bg-gray-700 text-indigo-700 dark:text-indigo-400 border-2 border-indigo-700 dark:border-indigo-400 py-3 px-4 rounded-lg font-medium hover:bg-indigo-50 dark:hover:bg-gray-600 hover:scale-105 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2"
> >
Não tem conta? Cadastre-se aqui {loading ? "Enviando..." : "Entrar sem senha (Magic Link)"}
</button> </button>
</div>
</form> </form>
</div> </div>
</div> </div>

View File

@ -3,7 +3,7 @@ import { User, Mail, Lock } from "lucide-react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth"; import { useAuth } from "../hooks/useAuth";
import { authService, patientService, userService } from "../services"; import { authService, patientService } from "../services";
const LoginPaciente: React.FC = () => { const LoginPaciente: React.FC = () => {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@ -15,45 +15,11 @@ const LoginPaciente: React.FC = () => {
const [cadastroData, setCadastroData] = useState({ const [cadastroData, setCadastroData] = useState({
nome: "", nome: "",
email: "", email: "",
senha: "",
confirmarSenha: "",
telefone: "", telefone: "",
cpf: "", cpf: "",
dataNascimento: "", dataNascimento: "",
convenio: "",
altura: "",
peso: "",
cep: "",
logradouro: "",
bairro: "",
cidade: "",
estado: "",
}); });
// Função para buscar endereço pelo CEP
const buscarEnderecoPorCEP = async (cep: string) => {
if (!cep || cep.replace(/\D/g, "").length < 8) return;
try {
const response = await fetch(
`https://viacep.com.br/ws/${cep.replace(/\D/g, "")}/json/`
);
const data = await response.json();
if (data.erro) {
toast.error("CEP não encontrado");
return;
}
setCadastroData((prev) => ({
...prev,
logradouro: data.logradouro || "",
bairro: data.bairro || "",
cidade: data.localidade || "",
estado: data.uf || "",
}));
} catch {
toast.error("Erro ao buscar CEP");
}
};
const navigate = useNavigate(); const navigate = useNavigate();
const { loginPaciente } = useAuth(); const { loginPaciente } = useAuth();
@ -139,38 +105,33 @@ const LoginPaciente: React.FC = () => {
setLoading(true); setLoading(true);
try { try {
// Cadastro público usando create-user com create_patient_record // Usar novo endpoint register-patient (público, validações rigorosas)
await userService.createUser({ const registerData = {
email: cadastroData.email.trim(), email: cadastroData.email.trim(),
full_name: cadastroData.nome.trim(), full_name: cadastroData.nome.trim(),
role: "paciente",
phone: cadastroData.telefone.trim(),
cpf: cadastroData.cpf.trim().replace(/\D/g, ""), cpf: cadastroData.cpf.trim().replace(/\D/g, ""),
phone_mobile: cadastroData.telefone.trim().replace(/\D/g, ""), phone_mobile: cadastroData.telefone.trim().replace(/\D/g, ""),
create_patient_record: true, birth_date: cadastroData.dataNascimento || undefined,
}); redirect_url:
"https://mediconnectbrasil.netlify.app/paciente/agendamento",
};
console.log("[LoginPaciente] Dados que serão enviados:", registerData);
await patientService.register(registerData);
toast.success( toast.success(
"Cadastro realizado! Verifique seu email para ativar a conta e definir sua senha." "Cadastro realizado! Verifique seu email para ativar a conta e definir sua senha.",
{ duration: 6000 }
); );
// Limpar formulário e voltar para tela de login // Limpar formulário e voltar para tela de login
setCadastroData({ setCadastroData({
nome: "", nome: "",
email: "", email: "",
senha: "",
confirmarSenha: "",
telefone: "", telefone: "",
cpf: "", cpf: "",
dataNascimento: "", dataNascimento: "",
convenio: "",
altura: "",
peso: "",
cep: "",
logradouro: "",
bairro: "",
cidade: "",
estado: "",
}); });
// Preencher email no formulário de login // Preencher email no formulário de login
@ -181,8 +142,13 @@ const LoginPaciente: React.FC = () => {
setShowCadastro(false); setShowCadastro(false);
} catch (error: any) { } catch (error: any) {
console.error("Erro ao cadastrar:", error); console.error("[LoginPaciente] Erro ao cadastrar:", {
error,
response: error?.response,
data: error?.response?.data,
});
const errorMessage = const errorMessage =
error?.response?.data?.error ||
error?.response?.data?.message || error?.response?.data?.message ||
error?.message || error?.message ||
"Erro ao realizar cadastro"; "Erro ao realizar cadastro";
@ -332,6 +298,29 @@ const LoginPaciente: React.FC = () => {
autoComplete="current-password" autoComplete="current-password"
/> />
</div> </div>
<div className="text-right mt-2">
<button
type="button"
onClick={async () => {
if (!formData.email.trim()) {
toast.error("Digite seu email primeiro");
return;
}
try {
await authService.sendMagicLink(
formData.email,
"https://mediconnectbrasil.netlify.app/paciente/agendamento"
);
toast.success("Link de acesso enviado para seu email!");
} catch {
toast.error("Erro ao enviar link");
}
}}
className="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline transition-colors"
>
Esqueceu a senha?
</button>
</div>
</div> </div>
{/** Botão original (remoto) comentado a pedido **/} {/** Botão original (remoto) comentado a pedido **/}
@ -354,6 +343,48 @@ const LoginPaciente: React.FC = () => {
{loading ? "Entrando..." : "Entrar"} {loading ? "Entrando..." : "Entrar"}
</button> </button>
{/* Divisor OU */}
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300 dark:border-gray-600"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400">
OU
</span>
</div>
</div>
{/* Botão Magic Link */}
<button
type="button"
onClick={async () => {
if (!formData.email.trim()) {
toast.error("Digite seu email primeiro");
return;
}
setLoading(true);
try {
await authService.sendMagicLink(
formData.email,
"https://mediconnectbrasil.netlify.app/paciente/agendamento"
);
toast.success(
"Link de acesso enviado para seu email! Verifique sua caixa de entrada.",
{ duration: 6000 }
);
} catch (error: any) {
toast.error(error?.message || "Erro ao enviar link");
} finally {
setLoading(false);
}
}}
disabled={loading}
className="w-full bg-white dark:bg-gray-700 text-blue-700 dark:text-blue-400 border-2 border-blue-700 dark:border-blue-400 py-3 px-4 rounded-lg font-medium hover:bg-blue-50 dark:hover:bg-gray-600 hover:scale-105 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
>
{loading ? "Enviando..." : "Entrar sem senha (Magic Link)"}
</button>
<div className="text-center mt-4"> <div className="text-center mt-4">
<button <button
type="button" type="button"
@ -365,15 +396,15 @@ const LoginPaciente: React.FC = () => {
</div> </div>
</form> </form>
) : ( ) : (
/* Formulário de Cadastro */ /* Formulário de Cadastro - Apenas campos da API */
<form onSubmit={handleCadastro} className="space-y-4" noValidate> <form onSubmit={handleCadastro} className="space-y-4" noValidate>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> {/* Nome Completo */}
<div> <div>
<label <label
htmlFor="cad_nome" htmlFor="cad_nome"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
> >
Nome Completo Nome Completo *
</label> </label>
<input <input
id="cad_nome" id="cad_nome"
@ -388,15 +419,43 @@ const LoginPaciente: React.FC = () => {
className="form-input dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100" className="form-input dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
required required
autoComplete="name" autoComplete="name"
placeholder="João Silva"
/> />
</div> </div>
{/* Email */}
<div>
<label
htmlFor="cad_email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Email *
</label>
<input
id="cad_email"
type="email"
value={cadastroData.email}
onChange={(e) =>
setCadastroData((prev) => ({
...prev,
email: e.target.value,
}))
}
className="form-input dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
required
autoComplete="email"
placeholder="seu@email.com"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* CPF */}
<div> <div>
<label <label
htmlFor="cad_cpf" htmlFor="cad_cpf"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
> >
CPF CPF *
</label> </label>
<input <input
id="cad_cpf" id="cad_cpf"
@ -415,158 +474,8 @@ const LoginPaciente: React.FC = () => {
pattern="^\d{3}\.\d{3}\.\d{3}-\d{2}$|^\d{11}$" pattern="^\d{3}\.\d{3}\.\d{3}-\d{2}$|^\d{11}$"
/> />
</div> </div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> {/* Telefone Celular */}
<div>
<label
htmlFor="cad_cep"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
CEP
</label>
<input
id="cad_cep"
type="text"
value={cadastroData.cep}
onChange={(e) =>
setCadastroData((prev) => ({
...prev,
cep: e.target.value,
}))
}
onBlur={() => buscarEnderecoPorCEP(cadastroData.cep)}
className="form-input dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
placeholder="00000-000"
required
inputMode="numeric"
pattern="^\d{5}-?\d{3}$"
autoComplete="postal-code"
/>
</div>
<div>
<label
htmlFor="cad_logradouro"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Logradouro
</label>
<input
id="cad_logradouro"
type="text"
value={cadastroData.logradouro}
onChange={(e) =>
setCadastroData((prev) => ({
...prev,
logradouro: e.target.value,
}))
}
className="form-input dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
required
autoComplete="address-line1"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label
htmlFor="cad_bairro"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Bairro
</label>
<input
id="cad_bairro"
type="text"
value={cadastroData.bairro}
onChange={(e) =>
setCadastroData((prev) => ({
...prev,
bairro: e.target.value,
}))
}
className="form-input dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
required
autoComplete="address-line2"
/>
</div>
<div>
<label
htmlFor="cad_cidade"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Cidade
</label>
<input
id="cad_cidade"
type="text"
value={cadastroData.cidade}
onChange={(e) =>
setCadastroData((prev) => ({
...prev,
cidade: e.target.value,
}))
}
className="form-input dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
required
autoComplete="address-level2"
/>
</div>
</div>
<div>
<label
htmlFor="cad_estado"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Estado
</label>
<input
id="cad_estado"
type="text"
value={cadastroData.estado}
onChange={(e) =>
setCadastroData((prev) => ({
...prev,
estado: e.target.value,
}))
}
className="form-input dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
required
autoComplete="address-level1"
/>
</div>
<div>
<label
htmlFor="cad_email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Email
</label>
<input
id="cad_email"
type="email"
value={cadastroData.email}
onChange={(e) =>
setCadastroData((prev) => ({
...prev,
email: e.target.value,
}))
}
className="form-input dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
required
autoComplete="email"
/>
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3 mb-4">
<p className="text-sm text-blue-800 dark:text-blue-200">
Após o cadastro, você receberá um email com link para
ativar sua conta e definir sua senha.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label <label
htmlFor="cad_telefone" htmlFor="cad_telefone"
@ -592,13 +501,15 @@ const LoginPaciente: React.FC = () => {
autoComplete="tel" autoComplete="tel"
/> />
</div> </div>
</div>
{/* Data de Nascimento */}
<div> <div>
<label <label
htmlFor="cad_data_nasc" htmlFor="cad_data_nasc"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
> >
Data de Nascimento Data de Nascimento (opcional)
</label> </label>
<input <input
id="cad_data_nasc" id="cad_data_nasc"
@ -611,96 +522,24 @@ const LoginPaciente: React.FC = () => {
})) }))
} }
className="form-input dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100" className="form-input dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
required
autoComplete="bday" autoComplete="bday"
/> />
</div> </div>
{/* Info sobre Magic Link */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
<p className="text-sm text-blue-800 dark:text-blue-200">
Após o cadastro, você receberá um email com link para
ativar sua conta e definir sua senha.
</p>
</div> </div>
<div> {/* Botões */}
<label
htmlFor="cad_convenio"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Convênio
</label>
<select
id="cad_convenio"
value={cadastroData.convenio}
onChange={(e) =>
setCadastroData((prev) => ({
...prev,
convenio: e.target.value,
}))
}
className="form-input dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
>
<option value="">Selecione</option>
<option value="Particular">Particular</option>
<option value="Unimed">Unimed</option>
<option value="Bradesco Saúde">Bradesco Saúde</option>
<option value="SulAmérica">SulAmérica</option>
<option value="Amil">Amil</option>
<option value="NotreDame">NotreDame</option>
</select>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label
htmlFor="cad_altura"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Altura (cm)
</label>
<input
id="cad_altura"
type="number"
value={cadastroData.altura}
onChange={(e) =>
setCadastroData((prev) => ({
...prev,
altura: e.target.value,
}))
}
className="form-input dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
placeholder="170"
min="50"
max="250"
/>
</div>
<div>
<label
htmlFor="cad_peso"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Peso (kg)
</label>
<input
id="cad_peso"
type="number"
value={cadastroData.peso}
onChange={(e) =>
setCadastroData((prev) => ({
...prev,
peso: e.target.value,
}))
}
className="form-input dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
placeholder="70"
min="20"
max="300"
step="0.1"
/>
</div>
</div>
<div className="flex space-x-4"> <div className="flex space-x-4">
<button <button
type="button" type="button"
onClick={() => setShowCadastro(false)} onClick={() => setShowCadastro(false)}
className="flex-1 bg-gray-100 text-gray-700 py-3 px-4 rounded-lg font-medium hover:bg-gray-200 transition-colors" className="flex-1 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 py-3 px-4 rounded-lg font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
> >
Voltar Voltar
</button> </button>

View File

@ -22,26 +22,21 @@ const LoginSecretaria: React.FC = () => {
try { try {
console.log("[LoginSecretaria] Fazendo login com email:", formData.email); console.log("[LoginSecretaria] Fazendo login com email:", formData.email);
await authService.login({
email: formData.email,
password: formData.senha,
});
console.log("[LoginSecretaria] Login bem-sucedido!");
const ok = await loginComEmailSenha(formData.email, formData.senha); const ok = await loginComEmailSenha(formData.email, formData.senha);
if (ok) { if (ok) {
console.log("[LoginSecretaria] Navegando para /painel-secretaria"); console.log(
"[LoginSecretaria] Login bem-sucedido! Navegando para /painel-secretaria"
);
toast.success("Login realizado com sucesso!"); toast.success("Login realizado com sucesso!");
navigate("/painel-secretaria"); navigate("/painel-secretaria");
} else { } else {
console.error("[LoginSecretaria] loginComEmailSenha retornou false"); console.error("[LoginSecretaria] loginComEmailSenha retornou false");
toast.error("Erro ao processar login"); toast.error("Credenciais inválidas ou usuário sem permissão");
} }
} catch (error) { } catch (error) {
console.error("[LoginSecretaria] Erro no login:", error); console.error("[LoginSecretaria] Erro no login:", error);
toast.error("Erro ao fazer login. Tente novamente."); toast.error("Erro ao fazer login. Verifique suas credenciais.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -113,6 +108,29 @@ const LoginSecretaria: React.FC = () => {
autoComplete="current-password" autoComplete="current-password"
/> />
</div> </div>
<div className="text-right mt-2">
<button
type="button"
onClick={async () => {
if (!formData.email.trim()) {
toast.error("Digite seu email primeiro");
return;
}
try {
await authService.sendMagicLink(
formData.email,
"https://mediconnectbrasil.netlify.app/secretaria/painel"
);
toast.success("Link de acesso enviado para seu email!");
} catch {
toast.error("Erro ao enviar link");
}
}}
className="text-sm text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline transition-colors"
>
Esqueceu a senha?
</button>
</div>
</div> </div>
<button <button
@ -122,6 +140,48 @@ const LoginSecretaria: React.FC = () => {
> >
{loading ? "Entrando..." : "Entrar"} {loading ? "Entrando..." : "Entrar"}
</button> </button>
{/* Divisor OU */}
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300 dark:border-gray-600"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400">
OU
</span>
</div>
</div>
{/* Botão Magic Link */}
<button
type="button"
onClick={async () => {
if (!formData.email.trim()) {
toast.error("Digite seu email primeiro");
return;
}
setLoading(true);
try {
await authService.sendMagicLink(
formData.email,
"https://mediconnectbrasil.netlify.app/secretaria/painel"
);
toast.success(
"Link de acesso enviado para seu email! Verifique sua caixa de entrada.",
{ duration: 6000 }
);
} catch {
toast.error("Erro ao enviar link");
} finally {
setLoading(false);
}
}}
disabled={loading}
className="w-full bg-white dark:bg-gray-700 text-green-700 dark:text-green-400 border-2 border-green-700 dark:border-green-400 py-3 px-4 rounded-lg font-medium hover:bg-green-50 dark:hover:bg-gray-600 hover:scale-105 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-green-500 focus-visible:ring-offset-2"
>
{loading ? "Enviando..." : "Entrar sem senha (Magic Link)"}
</button>
</form> </form>
</div> </div>
</div> </div>

View File

@ -16,6 +16,7 @@ import {
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth"; import { useAuth } from "../hooks/useAuth";
import { ConfirmDialog } from "../components/ui/ConfirmDialog";
import { import {
patientService, patientService,
type Patient, type Patient,
@ -86,6 +87,29 @@ const PainelAdmin: React.FC = () => {
phone: "", phone: "",
role: "user", role: "user",
}); });
const [userPassword, setUserPassword] = useState(""); // Senha opcional
const [usePassword, setUsePassword] = useState(false); // Toggle para criar com senha
// Estados para dialog de confirmação
const [confirmDialog, setConfirmDialog] = useState<{
isOpen: boolean;
title: string;
message: string | React.ReactNode;
confirmText?: string;
cancelText?: string;
onConfirm: () => void;
requireTypedConfirmation: boolean;
confirmationWord: string;
isDangerous: boolean;
}>({
isOpen: false,
title: "",
message: "",
onConfirm: () => {},
requireTypedConfirmation: false,
confirmationWord: "",
isDangerous: false,
});
// Estados para médicos // Estados para médicos
const [medicos, setMedicos] = useState<Doctor[]>([]); const [medicos, setMedicos] = useState<Doctor[]>([]);
@ -124,6 +148,9 @@ const PainelAdmin: React.FC = () => {
// Carregar dados conforme aba ativa // Carregar dados conforme aba ativa
useEffect(() => { useEffect(() => {
// Só carrega se não estiver já carregando
if (loading) return;
if (activeTab === "pacientes") { if (activeTab === "pacientes") {
loadPacientes(); loadPacientes();
} else if (activeTab === "usuarios") { } else if (activeTab === "usuarios") {
@ -131,6 +158,7 @@ const PainelAdmin: React.FC = () => {
} else if (activeTab === "medicos") { } else if (activeTab === "medicos") {
loadMedicos(); loadMedicos();
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab]); }, [activeTab]);
const loadUsuarios = async () => { const loadUsuarios = async () => {
@ -225,11 +253,47 @@ const PainelAdmin: React.FC = () => {
setLoading(true); setLoading(true);
try { try {
// isPublicRegistration = false porque é admin criando // Determina redirect_url baseado no role
const newUser = await userService.createUser(formUser, false); let redirectUrl = "https://mediconnectbrasil.netlify.app/";
toast.success(`Usuário ${formUser.full_name} criado com sucesso!`); if (formUser.role === "medico") {
redirectUrl = "https://mediconnectbrasil.netlify.app/medico/painel";
} else if (formUser.role === "paciente") {
redirectUrl =
"https://mediconnectbrasil.netlify.app/paciente/agendamento";
} else if (formUser.role === "secretaria") {
redirectUrl = "https://mediconnectbrasil.netlify.app/secretaria/painel";
} else if (formUser.role === "admin" || formUser.role === "gestor") {
redirectUrl = "https://mediconnectbrasil.netlify.app/admin/painel";
}
// Criar com senha OU magic link
if (usePassword && userPassword.trim()) {
// Criar com senha
await userService.createUserWithPassword({
email: formUser.email,
password: userPassword,
full_name: formUser.full_name,
phone: formUser.phone,
role: formUser.role,
});
toast.success(
`Usuário ${formUser.full_name} criado com sucesso! Email de confirmação enviado.`
);
} else {
// Criar com magic link (padrão)
await userService.createUser(
{ ...formUser, redirect_url: redirectUrl },
false
);
toast.success(
`Usuário ${formUser.full_name} criado com sucesso! Magic link enviado para o email.`
);
}
setShowUserModal(false); setShowUserModal(false);
resetFormUser(); resetFormUser();
setUserPassword("");
setUsePassword(false);
loadUsuarios(); loadUsuarios();
} catch (error) { } catch (error) {
console.error("Erro ao criar usuário:", error); console.error("Erro ao criar usuário:", error);
@ -289,19 +353,55 @@ const PainelAdmin: React.FC = () => {
// } // }
}; };
const handleDeleteUser = async (_userId: string, _userName: string) => { const handleDeleteUser = async (userId: string, userName: string) => {
toast.error("Função de deletar usuário ainda não implementada"); // Abre o dialog customizado de confirmação
// TODO: Implement adminUserService.deleteUser endpoint setConfirmDialog({
// if (!confirm(`Tem certeza que deseja deletar o usuário "${userName}"?`)) return; isOpen: true,
// try { title: "ATENÇÃO: OPERAÇÃO IRREVERSÍVEL!",
// const result = await adminUserService.deleteUser(userId); message: (
// if (result.success) { <div className="space-y-3">
// toast.success("Usuário deletado com sucesso!"); <p className="font-medium">
// loadUsuarios(); Deseja deletar permanentemente o usuário{" "}
// } <span className="font-bold text-red-600">"{userName}"</span>?
// } catch { </p>
// toast.error("Erro ao deletar usuário"); <div className="bg-red-50 border border-red-200 rounded-lg p-3">
// } <p className="text-sm font-semibold text-red-900 mb-2">
Isso irá deletar:
</p>
<ul className="text-sm text-red-800 space-y-1 list-disc list-inside">
<li>Conta de autenticação</li>
<li>Perfil do usuário</li>
<li>Todas as roles e permissões</li>
<li>Dados relacionados (cascata)</li>
</ul>
</div>
<p className="text-sm font-bold text-red-700">
Esta ação NÃO pode ser desfeita!
</p>
</div>
),
confirmText: "Deletar Permanentemente",
cancelText: "Cancelar",
requireTypedConfirmation: true,
confirmationWord: "DELETAR",
isDangerous: true,
onConfirm: async () => {
setLoading(true);
try {
await userService.deleteUser(userId);
toast.success(`Usuário "${userName}" deletado permanentemente!`);
loadUsuarios();
} catch (error: unknown) {
console.error("Erro ao deletar usuário:", error);
const errorMessage =
(error as { response?: { data?: { error?: string } } })?.response
?.data?.error || "Erro ao deletar usuário";
toast.error(errorMessage);
} finally {
setLoading(false);
}
},
});
}; };
// Funções de gerenciamento de roles // Funções de gerenciamento de roles
@ -423,6 +523,8 @@ const PainelAdmin: React.FC = () => {
create_patient_record: true, create_patient_record: true,
cpf: patientData.cpf, cpf: patientData.cpf,
phone_mobile: patientData.phone_mobile, phone_mobile: patientData.phone_mobile,
redirect_url:
"https://mediconnectbrasil.netlify.app/paciente/agendamento",
}, },
false false
); );
@ -515,6 +617,7 @@ const PainelAdmin: React.FC = () => {
full_name: medicoData.full_name, full_name: medicoData.full_name,
phone: medicoData.phone_mobile, phone: medicoData.phone_mobile,
role: "medico", role: "medico",
redirect_url: "https://mediconnectbrasil.netlify.app/medico/painel",
}, },
false false
); );
@ -1399,6 +1502,51 @@ const PainelAdmin: React.FC = () => {
</select> </select>
</div> </div>
{/* Toggle para criar com senha */}
<div className="border-t pt-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={usePassword}
onChange={(e) => setUsePassword(e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm font-medium">
Criar com senha (alternativa ao Magic Link)
</span>
</label>
</div>
{/* Campo de senha (condicional) */}
{usePassword && (
<div>
<label className="block text-sm font-medium mb-1">
Senha *
</label>
<input
type="password"
required={usePassword}
value={userPassword}
onChange={(e) => setUserPassword(e.target.value)}
minLength={6}
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40"
placeholder="Mínimo 6 caracteres"
/>
<p className="text-xs text-gray-500 mt-1">
O usuário precisará confirmar o email antes de fazer login
</p>
</div>
)}
{!usePassword && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<p className="text-xs text-blue-700">
Um Magic Link será enviado para o email do usuário para
ativação da conta
</p>
</div>
)}
<div className="flex gap-2 justify-end pt-4"> <div className="flex gap-2 justify-end pt-4">
<button <button
type="button" type="button"
@ -1822,6 +1970,20 @@ const PainelAdmin: React.FC = () => {
</div> </div>
</div> </div>
)} )}
{/* Dialog de Confirmação Customizado */}
<ConfirmDialog
isOpen={confirmDialog.isOpen}
onClose={() => setConfirmDialog((prev) => ({ ...prev, isOpen: false }))}
onConfirm={confirmDialog.onConfirm}
title={confirmDialog.title}
message={confirmDialog.message}
confirmText={confirmDialog.confirmText}
cancelText={confirmDialog.cancelText}
requireTypedConfirmation={confirmDialog.requireTypedConfirmation}
confirmationWord={confirmDialog.confirmationWord}
isDangerous={confirmDialog.isDangerous}
/>
</div> </div>
); );
}; };

View File

@ -37,6 +37,17 @@ class ApiClient {
if (token && config.headers) { if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
console.log(
`[ApiClient] Request: ${config.method?.toUpperCase()} ${
config.url
} - Token presente: ${token.substring(0, 20)}...`
);
} else {
console.warn(
`[ApiClient] Request: ${config.method?.toUpperCase()} ${
config.url
} - SEM TOKEN!`
);
} }
return config; return config;
@ -52,6 +63,12 @@ class ApiClient {
async (error) => { async (error) => {
const originalRequest = error.config; const originalRequest = error.config;
console.error(`[ApiClient] Erro na requisição:`, {
url: originalRequest?.url,
status: error.response?.status,
message: error.message,
});
// Se retornar 401 e não for uma requisição de refresh/login // Se retornar 401 e não for uma requisição de refresh/login
if ( if (
error.response?.status === 401 && error.response?.status === 401 &&
@ -61,6 +78,8 @@ class ApiClient {
) { ) {
originalRequest._retry = true; originalRequest._retry = true;
console.log("[ApiClient] Recebeu 401, tentando renovar token...");
try { try {
// Tenta renovar o token // Tenta renovar o token
const refreshToken = localStorage.getItem( const refreshToken = localStorage.getItem(
@ -68,6 +87,7 @@ class ApiClient {
); );
if (refreshToken) { if (refreshToken) {
console.log("[ApiClient] Refresh token encontrado, renovando...");
const response = await this.client.post("/auth-refresh", { const response = await this.client.post("/auth-refresh", {
refresh_token: refreshToken, refresh_token: refreshToken,
}); });
@ -78,6 +98,8 @@ class ApiClient {
user, user,
} = response.data; } = response.data;
console.log("[ApiClient] Token renovado com sucesso!");
// Atualiza tokens // Atualiza tokens
localStorage.setItem( localStorage.setItem(
API_CONFIG.STORAGE_KEYS.ACCESS_TOKEN, API_CONFIG.STORAGE_KEYS.ACCESS_TOKEN,
@ -97,8 +119,14 @@ class ApiClient {
// Refaz a requisição original // Refaz a requisição original
return this.client(originalRequest); return this.client(originalRequest);
} else {
console.warn("[ApiClient] Nenhum refresh token disponível!");
} }
} catch (refreshError) { } catch (refreshError) {
console.error(
"[ApiClient] Falha ao renovar token, fazendo logout...",
refreshError
);
// Se refresh falhar, limpa tudo e redireciona para home // Se refresh falhar, limpa tudo e redireciona para home
localStorage.removeItem(API_CONFIG.STORAGE_KEYS.ACCESS_TOKEN); localStorage.removeItem(API_CONFIG.STORAGE_KEYS.ACCESS_TOKEN);
localStorage.removeItem(API_CONFIG.STORAGE_KEYS.REFRESH_TOKEN); localStorage.removeItem(API_CONFIG.STORAGE_KEYS.REFRESH_TOKEN);
@ -116,19 +144,13 @@ class ApiClient {
} }
} }
// Se não conseguir renovar, limpa e redireciona para home // Se não conseguir renovar, apenas loga o erro mas NÃO limpa a sessão
// Deixa o componente decidir o que fazer
if (error.response?.status === 401) { if (error.response?.status === 401) {
localStorage.removeItem(API_CONFIG.STORAGE_KEYS.ACCESS_TOKEN); console.error(
localStorage.removeItem(API_CONFIG.STORAGE_KEYS.REFRESH_TOKEN); "[ApiClient] 401 não tratado - requisição:",
localStorage.removeItem(API_CONFIG.STORAGE_KEYS.USER); originalRequest?.url
);
// Redireciona para home ao invés de login específico
if (
!window.location.pathname.includes("/login") &&
window.location.pathname !== "/"
) {
window.location.href = "/";
}
} }
return Promise.reject(error); return Promise.reject(error);

View File

@ -4,11 +4,11 @@
*/ */
// Em desenvolvimento, Netlify Dev roda na porta 8888 // Em desenvolvimento, Netlify Dev roda na porta 8888
// Em produção, usa caminho relativo // Em produção, usa URL completa do Netlify
const isDevelopment = import.meta.env.DEV; const isDevelopment = import.meta.env.DEV;
const BASE_URL = isDevelopment const BASE_URL = isDevelopment
? "http://localhost:8888/.netlify/functions" ? "http://localhost:8888/.netlify/functions"
: "/.netlify/functions"; : "https://mediconnectbrasil.netlify.app/.netlify/functions";
export const API_CONFIG = { export const API_CONFIG = {
// Base URL aponta para suas Netlify Functions // Base URL aponta para suas Netlify Functions

View File

@ -46,6 +46,52 @@ class AuthService {
} }
} }
/**
* Envia magic link para o email do usuário
* POST /auth/v1/otp
*/
async sendMagicLink(
email: string,
redirectUrl?: string
): Promise<{ success: boolean; message: string }> {
try {
const response = await apiClient.post<{
success: boolean;
message: string;
}>("/auth-magic-link", {
email,
redirect_url: redirectUrl,
});
return response.data;
} catch (error) {
console.error("Erro ao enviar magic link:", error);
throw error;
}
}
/**
* Solicita reset de senha via email (público)
* POST /request-password-reset
*/
async requestPasswordReset(
email: string,
redirectUrl?: string
): Promise<{ success: boolean; message: string }> {
try {
const response = await (apiClient as any).postPublic<{
success: boolean;
message: string;
}>("/request-password-reset", {
email,
redirect_url: redirectUrl,
});
return response.data;
} catch (error) {
console.error("Erro ao solicitar reset de senha:", error);
throw error;
}
}
/** /**
* Faz logout (invalida sessão no servidor e limpa localStorage) * Faz logout (invalida sessão no servidor e limpa localStorage)
*/ */

View File

@ -48,6 +48,8 @@ export type {
CreatePatientInput, CreatePatientInput,
UpdatePatientInput, UpdatePatientInput,
PatientFilters, PatientFilters,
RegisterPatientInput,
RegisterPatientResponse,
} from "./patients/types"; } from "./patients/types";
// Profiles // Profiles

View File

@ -8,6 +8,8 @@ import type {
CreatePatientInput, CreatePatientInput,
UpdatePatientInput, UpdatePatientInput,
PatientFilters, PatientFilters,
RegisterPatientInput,
RegisterPatientResponse,
} from "./types"; } from "./types";
class PatientService { class PatientService {
@ -95,6 +97,31 @@ class PatientService {
throw error; throw error;
} }
} }
/**
* Registro público de paciente (não requer autenticação)
* Usa o endpoint register-patient que tem validações rigorosas
*/
async register(data: RegisterPatientInput): Promise<RegisterPatientResponse> {
try {
console.log("[patientService.register] Enviando dados:", data);
// Usa postPublic para não enviar token de autenticação
const response = await (
apiClient as any
).postPublic<RegisterPatientResponse>("/register-patient", data);
console.log("[patientService.register] Resposta:", response.data);
return response.data;
} catch (error: any) {
console.error("[patientService.register] Erro completo:", {
message: error.message,
response: error.response?.data,
status: error.response?.status,
});
throw error;
}
}
} }
export const patientService = new PatientService(); export const patientService = new PatientService();

View File

@ -47,6 +47,21 @@ export interface CreatePatientInput {
cep?: string | null; cep?: string | null;
} }
export interface RegisterPatientInput {
email: string;
full_name: string;
phone_mobile: string;
cpf: string;
birth_date?: string;
redirect_url?: string; // URL para redirecionar após clicar no magic link
}
export interface RegisterPatientResponse {
user_id: string;
patient_id: string;
message: string;
}
export interface UpdatePatientInput { export interface UpdatePatientInput {
full_name?: string; full_name?: string;
cpf?: string; cpf?: string;

View File

@ -59,6 +59,7 @@ export interface CreateUserInput {
create_patient_record?: boolean; // Novo campo opcional create_patient_record?: boolean; // Novo campo opcional
cpf?: string; // Obrigatório se create_patient_record=true cpf?: string; // Obrigatório se create_patient_record=true
phone_mobile?: string; // Obrigatório se create_patient_record=true phone_mobile?: string; // Obrigatório se create_patient_record=true
redirect_url?: string; // URL para redirecionar após clicar no magic link
} }
export interface CreateUserResponse { export interface CreateUserResponse {
@ -83,6 +84,7 @@ export interface CreateDoctorInput {
crm_uf: string; // Padrão: ^[A-Z]{2}$ crm_uf: string; // Padrão: ^[A-Z]{2}$
specialty?: string; specialty?: string;
phone_mobile?: string; phone_mobile?: string;
redirect_url?: string; // URL para redirecionar após clicar no magic link
// password foi REMOVIDO da API // password foi REMOVIDO da API
} }

View File

@ -115,6 +115,49 @@ class UserService {
); );
return response.data; return response.data;
} }
/**
* Cria usuário com email e senha (alternativa ao Magic Link)
* Requer permissão de admin, gestor ou secretaria
* O usuário precisa confirmar o email antes de fazer login
*/
async createUserWithPassword(data: {
email: string;
password: string;
full_name: string;
phone?: string;
role: string;
create_patient_record?: boolean;
cpf?: string;
phone_mobile?: string;
}): Promise<{
success: boolean;
user: {
id: string;
email: string;
full_name: string;
roles: string[];
};
message: string;
}> {
const response = await apiClient.post("/create-user-with-password", data);
return response.data;
}
/**
* Deleta usuário permanentemente (Hard Delete)
* OPERAÇÃO IRREVERSÍVEL! Use apenas em desenvolvimento/QA
* Requer permissão de admin ou gestor
* Deleta em cascata: profiles, user_roles, doctors, patients, etc.
*/
async deleteUser(userId: string): Promise<{
success: boolean;
message: string;
userId: string;
}> {
const response = await apiClient.post("/delete-user", { userId });
return response.data;
}
} }
export const userService = new UserService(); export const userService = new UserService();

View File

@ -1,57 +0,0 @@
/**
* Script de teste para create-user
* Mostra request e response detalhados
*/
const testCreateUser = async () => {
const requestData = {
email: "guilhermesilvagomes1020@gmail.com",
full_name: "Guilherme Silva Gomes Teste",
role: "paciente",
create_patient_record: true,
cpf: "12345678901",
phone_mobile: "11999999999",
};
console.log("=== REQUEST ===");
console.log("URL:", "http://localhost:8888/.netlify/functions/create-user");
console.log("Method:", "POST");
console.log("Headers:", {
"Content-Type": "application/json",
});
console.log("Body:", JSON.stringify(requestData, null, 2));
console.log("\n");
try {
const response = await fetch(
"http://localhost:8888/.netlify/functions/create-user",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestData),
}
);
console.log("=== RESPONSE ===");
console.log("Status:", response.status, response.statusText);
console.log("Headers:", Object.fromEntries(response.headers.entries()));
const data = await response.json();
console.log("Body:", JSON.stringify(data, null, 2));
if (!response.ok) {
console.log(
"\n❌ ERRO:",
data.error || data.message || "Erro desconhecido"
);
} else {
console.log("\n✅ SUCESSO!");
}
} catch (error) {
console.error("\n❌ ERRO NA REQUISIÇÃO:", error.message);
}
};
testCreateUser();