Atualização
This commit is contained in:
parent
376e344506
commit
b85a43dd3e
20
MEDICONNECT 2/cleanup-deps.ps1
Normal file
20
MEDICONNECT 2/cleanup-deps.ps1
Normal 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
|
||||||
@ -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",
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
223
MEDICONNECT 2/netlify/functions/create-user-with-password.ts
Normal file
223
MEDICONNECT 2/netlify/functions/create-user-with-password.ts
Normal 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",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
127
MEDICONNECT 2/netlify/functions/delete-user.ts
Normal file
127
MEDICONNECT 2/netlify/functions/delete-user.ts
Normal 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",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
97
MEDICONNECT 2/netlify/functions/register-patient.ts
Normal file
97
MEDICONNECT 2/netlify/functions/register-patient.ts
Normal 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",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
116
MEDICONNECT 2/netlify/functions/request-password-reset.ts
Normal file
116
MEDICONNECT 2/netlify/functions/request-password-reset.ts
Normal 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",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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": {
|
||||||
|
|||||||
7220
MEDICONNECT 2/pnpm-lock.yaml
generated
7220
MEDICONNECT 2/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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 />} /> */}
|
||||||
|
|||||||
132
MEDICONNECT 2/src/components/ui/ConfirmDialog.tsx
Normal file
132
MEDICONNECT 2/src/components/ui/ConfirmDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
19
MEDICONNECT 2/src/lib/supabase.ts
Normal file
19
MEDICONNECT 2/src/lib/supabase.ts
Normal 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
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
128
MEDICONNECT 2/src/pages/AuthCallback.tsx
Normal file
128
MEDICONNECT 2/src/pages/AuthCallback.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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;
|
|
||||||
@ -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;
|
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -48,6 +48,8 @@ export type {
|
|||||||
CreatePatientInput,
|
CreatePatientInput,
|
||||||
UpdatePatientInput,
|
UpdatePatientInput,
|
||||||
PatientFilters,
|
PatientFilters,
|
||||||
|
RegisterPatientInput,
|
||||||
|
RegisterPatientResponse,
|
||||||
} from "./patients/types";
|
} from "./patients/types";
|
||||||
|
|
||||||
// Profiles
|
// Profiles
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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();
|
|
||||||
Loading…
x
Reference in New Issue
Block a user