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 {
|
||||
email: string;
|
||||
redirect_url?: string;
|
||||
}
|
||||
|
||||
export const handler: Handler = async (event: HandlerEvent) => {
|
||||
@ -61,6 +62,11 @@ export const handler: Handler = async (event: HandlerEvent) => {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lumi.new/sdk": "^0.1.5",
|
||||
"@supabase/supabase-js": "^2.76.1",
|
||||
"axios": "^1.12.2",
|
||||
"date-fns": "^2.30.0",
|
||||
"lucide-react": "^0.540.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"react-toastify": "^11.0.5",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"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 LoginSecretaria from "./pages/LoginSecretaria";
|
||||
import LoginMedico from "./pages/LoginMedico";
|
||||
import CadastroMedico from "./pages/CadastroMedico";
|
||||
import AgendamentoPaciente from "./pages/AgendamentoPaciente";
|
||||
import AcompanhamentoPaciente from "./pages/AcompanhamentoPaciente";
|
||||
import PainelMedico from "./pages/PainelMedico";
|
||||
@ -26,6 +25,7 @@ import CentralAjudaRouter from "./pages/CentralAjudaRouter";
|
||||
import PerfilMedico from "./pages/PerfilMedico";
|
||||
import PerfilPaciente from "./pages/PerfilPaciente";
|
||||
import ClearCache from "./pages/ClearCache";
|
||||
import AuthCallback from "./pages/AuthCallback";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@ -47,10 +47,10 @@ function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/clear-cache" element={<ClearCache />} />
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
<Route path="/paciente" element={<LoginPaciente />} />
|
||||
<Route path="/login-secretaria" element={<LoginSecretaria />} />
|
||||
<Route path="/login-medico" element={<LoginMedico />} />
|
||||
<Route path="/cadastro/medico" element={<CadastroMedico />} />
|
||||
<Route path="/dev/token" element={<TokenInspector />} />
|
||||
<Route path="/admin/diagnostico" element={<AdminDiagnostico />} />
|
||||
{/* <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 { 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.
|
||||
// This also helps E2E test detect classes after reload.
|
||||
(() => {
|
||||
@ -48,17 +45,5 @@ createRoot(document.getElementById("root")!).render(
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
<ToastContainer
|
||||
position="top-right"
|
||||
autoClose={3000}
|
||||
hideProgressBar={false}
|
||||
newestOnTop={false}
|
||||
closeOnClick
|
||||
rtl={false}
|
||||
pauseOnFocusLoss
|
||||
draggable
|
||||
pauseOnHover
|
||||
theme="colored"
|
||||
/>
|
||||
</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 {
|
||||
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);
|
||||
|
||||
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!");
|
||||
navigate("/painel-medico");
|
||||
} else {
|
||||
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) {
|
||||
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 {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -113,6 +108,29 @@ const LoginMedico: React.FC = () => {
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<button
|
||||
@ -123,15 +141,47 @@ const LoginMedico: React.FC = () => {
|
||||
{loading ? "Entrando..." : "Entrar"}
|
||||
</button>
|
||||
|
||||
<div className="text-center mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/cadastro/medico")}
|
||||
className="text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300 font-medium text-sm transition-colors"
|
||||
>
|
||||
Não tem conta? Cadastre-se aqui
|
||||
</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/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"
|
||||
>
|
||||
{loading ? "Enviando..." : "Entrar sem senha (Magic Link)"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -3,7 +3,7 @@ import { User, Mail, Lock } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import { authService, patientService, userService } from "../services";
|
||||
import { authService, patientService } from "../services";
|
||||
|
||||
const LoginPaciente: React.FC = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
@ -15,45 +15,11 @@ const LoginPaciente: React.FC = () => {
|
||||
const [cadastroData, setCadastroData] = useState({
|
||||
nome: "",
|
||||
email: "",
|
||||
senha: "",
|
||||
confirmarSenha: "",
|
||||
telefone: "",
|
||||
cpf: "",
|
||||
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 { loginPaciente } = useAuth();
|
||||
@ -139,38 +105,33 @@ const LoginPaciente: React.FC = () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Cadastro público usando create-user com create_patient_record
|
||||
await userService.createUser({
|
||||
// Usar novo endpoint register-patient (público, validações rigorosas)
|
||||
const registerData = {
|
||||
email: cadastroData.email.trim(),
|
||||
full_name: cadastroData.nome.trim(),
|
||||
role: "paciente",
|
||||
phone: cadastroData.telefone.trim(),
|
||||
cpf: cadastroData.cpf.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(
|
||||
"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
|
||||
setCadastroData({
|
||||
nome: "",
|
||||
email: "",
|
||||
senha: "",
|
||||
confirmarSenha: "",
|
||||
telefone: "",
|
||||
cpf: "",
|
||||
dataNascimento: "",
|
||||
convenio: "",
|
||||
altura: "",
|
||||
peso: "",
|
||||
cep: "",
|
||||
logradouro: "",
|
||||
bairro: "",
|
||||
cidade: "",
|
||||
estado: "",
|
||||
});
|
||||
|
||||
// Preencher email no formulário de login
|
||||
@ -181,8 +142,13 @@ const LoginPaciente: React.FC = () => {
|
||||
|
||||
setShowCadastro(false);
|
||||
} 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 =
|
||||
error?.response?.data?.error ||
|
||||
error?.response?.data?.message ||
|
||||
error?.message ||
|
||||
"Erro ao realizar cadastro";
|
||||
@ -332,6 +298,29 @@ const LoginPaciente: React.FC = () => {
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/** Botão original (remoto) comentado a pedido **/}
|
||||
@ -354,6 +343,48 @@ const LoginPaciente: React.FC = () => {
|
||||
{loading ? "Entrando..." : "Entrar"}
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
@ -365,38 +396,66 @@ const LoginPaciente: React.FC = () => {
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
/* Formulário de Cadastro */
|
||||
/* Formulário de Cadastro - Apenas campos da API */
|
||||
<form onSubmit={handleCadastro} className="space-y-4" noValidate>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="cad_nome"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Nome Completo
|
||||
</label>
|
||||
<input
|
||||
id="cad_nome"
|
||||
type="text"
|
||||
value={cadastroData.nome}
|
||||
onChange={(e) =>
|
||||
setCadastroData((prev) => ({
|
||||
...prev,
|
||||
nome: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="form-input dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
|
||||
required
|
||||
autoComplete="name"
|
||||
/>
|
||||
</div>
|
||||
{/* Nome Completo */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="cad_nome"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Nome Completo *
|
||||
</label>
|
||||
<input
|
||||
id="cad_nome"
|
||||
type="text"
|
||||
value={cadastroData.nome}
|
||||
onChange={(e) =>
|
||||
setCadastroData((prev) => ({
|
||||
...prev,
|
||||
nome: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="form-input dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
|
||||
required
|
||||
autoComplete="name"
|
||||
placeholder="João Silva"
|
||||
/>
|
||||
</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>
|
||||
<label
|
||||
htmlFor="cad_cpf"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
CPF
|
||||
CPF *
|
||||
</label>
|
||||
<input
|
||||
id="cad_cpf"
|
||||
@ -415,158 +474,8 @@ const LoginPaciente: React.FC = () => {
|
||||
pattern="^\d{3}\.\d{3}\.\d{3}-\d{2}$|^\d{11}$"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<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">
|
||||
{/* Telefone Celular */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="cad_telefone"
|
||||
@ -592,115 +501,45 @@ const LoginPaciente: React.FC = () => {
|
||||
autoComplete="tel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="cad_data_nasc"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Data de Nascimento
|
||||
</label>
|
||||
<input
|
||||
id="cad_data_nasc"
|
||||
type="date"
|
||||
value={cadastroData.dataNascimento}
|
||||
onChange={(e) =>
|
||||
setCadastroData((prev) => ({
|
||||
...prev,
|
||||
dataNascimento: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="form-input dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
|
||||
required
|
||||
autoComplete="bday"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data de Nascimento */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="cad_convenio"
|
||||
htmlFor="cad_data_nasc"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Convênio
|
||||
Data de Nascimento (opcional)
|
||||
</label>
|
||||
<select
|
||||
id="cad_convenio"
|
||||
value={cadastroData.convenio}
|
||||
<input
|
||||
id="cad_data_nasc"
|
||||
type="date"
|
||||
value={cadastroData.dataNascimento}
|
||||
onChange={(e) =>
|
||||
setCadastroData((prev) => ({
|
||||
...prev,
|
||||
convenio: e.target.value,
|
||||
dataNascimento: 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>
|
||||
autoComplete="bday"
|
||||
/>
|
||||
</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>
|
||||
{/* 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>
|
||||
|
||||
{/* Botões */}
|
||||
<div className="flex space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
|
||||
@ -22,26 +22,21 @@ const LoginSecretaria: React.FC = () => {
|
||||
try {
|
||||
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);
|
||||
|
||||
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!");
|
||||
navigate("/painel-secretaria");
|
||||
} else {
|
||||
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) {
|
||||
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 {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -113,6 +108,29 @@ const LoginSecretaria: React.FC = () => {
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<button
|
||||
@ -122,6 +140,48 @@ const LoginSecretaria: React.FC = () => {
|
||||
>
|
||||
{loading ? "Entrando..." : "Entrar"}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -16,6 +16,7 @@ import {
|
||||
import toast from "react-hot-toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import { ConfirmDialog } from "../components/ui/ConfirmDialog";
|
||||
import {
|
||||
patientService,
|
||||
type Patient,
|
||||
@ -86,6 +87,29 @@ const PainelAdmin: React.FC = () => {
|
||||
phone: "",
|
||||
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
|
||||
const [medicos, setMedicos] = useState<Doctor[]>([]);
|
||||
@ -124,6 +148,9 @@ const PainelAdmin: React.FC = () => {
|
||||
|
||||
// Carregar dados conforme aba ativa
|
||||
useEffect(() => {
|
||||
// Só carrega se não estiver já carregando
|
||||
if (loading) return;
|
||||
|
||||
if (activeTab === "pacientes") {
|
||||
loadPacientes();
|
||||
} else if (activeTab === "usuarios") {
|
||||
@ -131,6 +158,7 @@ const PainelAdmin: React.FC = () => {
|
||||
} else if (activeTab === "medicos") {
|
||||
loadMedicos();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTab]);
|
||||
|
||||
const loadUsuarios = async () => {
|
||||
@ -225,11 +253,47 @@ const PainelAdmin: React.FC = () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// isPublicRegistration = false porque é admin criando
|
||||
const newUser = await userService.createUser(formUser, false);
|
||||
toast.success(`Usuário ${formUser.full_name} criado com sucesso!`);
|
||||
// Determina redirect_url baseado no role
|
||||
let redirectUrl = "https://mediconnectbrasil.netlify.app/";
|
||||
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);
|
||||
resetFormUser();
|
||||
setUserPassword("");
|
||||
setUsePassword(false);
|
||||
loadUsuarios();
|
||||
} catch (error) {
|
||||
console.error("Erro ao criar usuário:", error);
|
||||
@ -289,19 +353,55 @@ const PainelAdmin: React.FC = () => {
|
||||
// }
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (_userId: string, _userName: string) => {
|
||||
toast.error("Função de deletar usuário ainda não implementada");
|
||||
// TODO: Implement adminUserService.deleteUser endpoint
|
||||
// if (!confirm(`Tem certeza que deseja deletar o usuário "${userName}"?`)) return;
|
||||
// try {
|
||||
// const result = await adminUserService.deleteUser(userId);
|
||||
// if (result.success) {
|
||||
// toast.success("Usuário deletado com sucesso!");
|
||||
// loadUsuarios();
|
||||
// }
|
||||
// } catch {
|
||||
// toast.error("Erro ao deletar usuário");
|
||||
// }
|
||||
const handleDeleteUser = async (userId: string, userName: string) => {
|
||||
// Abre o dialog customizado de confirmação
|
||||
setConfirmDialog({
|
||||
isOpen: true,
|
||||
title: "ATENÇÃO: OPERAÇÃO IRREVERSÍVEL!",
|
||||
message: (
|
||||
<div className="space-y-3">
|
||||
<p className="font-medium">
|
||||
Deseja deletar permanentemente o usuário{" "}
|
||||
<span className="font-bold text-red-600">"{userName}"</span>?
|
||||
</p>
|
||||
<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
|
||||
@ -423,6 +523,8 @@ const PainelAdmin: React.FC = () => {
|
||||
create_patient_record: true,
|
||||
cpf: patientData.cpf,
|
||||
phone_mobile: patientData.phone_mobile,
|
||||
redirect_url:
|
||||
"https://mediconnectbrasil.netlify.app/paciente/agendamento",
|
||||
},
|
||||
false
|
||||
);
|
||||
@ -515,6 +617,7 @@ const PainelAdmin: React.FC = () => {
|
||||
full_name: medicoData.full_name,
|
||||
phone: medicoData.phone_mobile,
|
||||
role: "medico",
|
||||
redirect_url: "https://mediconnectbrasil.netlify.app/medico/painel",
|
||||
},
|
||||
false
|
||||
);
|
||||
@ -1399,6 +1502,51 @@ const PainelAdmin: React.FC = () => {
|
||||
</select>
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
@ -1822,6 +1970,20 @@ const PainelAdmin: React.FC = () => {
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -37,6 +37,17 @@ class ApiClient {
|
||||
|
||||
if (token && config.headers) {
|
||||
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;
|
||||
@ -52,6 +63,12 @@ class ApiClient {
|
||||
async (error) => {
|
||||
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
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
@ -61,6 +78,8 @@ class ApiClient {
|
||||
) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
console.log("[ApiClient] Recebeu 401, tentando renovar token...");
|
||||
|
||||
try {
|
||||
// Tenta renovar o token
|
||||
const refreshToken = localStorage.getItem(
|
||||
@ -68,6 +87,7 @@ class ApiClient {
|
||||
);
|
||||
|
||||
if (refreshToken) {
|
||||
console.log("[ApiClient] Refresh token encontrado, renovando...");
|
||||
const response = await this.client.post("/auth-refresh", {
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
@ -78,6 +98,8 @@ class ApiClient {
|
||||
user,
|
||||
} = response.data;
|
||||
|
||||
console.log("[ApiClient] Token renovado com sucesso!");
|
||||
|
||||
// Atualiza tokens
|
||||
localStorage.setItem(
|
||||
API_CONFIG.STORAGE_KEYS.ACCESS_TOKEN,
|
||||
@ -97,8 +119,14 @@ class ApiClient {
|
||||
|
||||
// Refaz a requisição original
|
||||
return this.client(originalRequest);
|
||||
} else {
|
||||
console.warn("[ApiClient] Nenhum refresh token disponível!");
|
||||
}
|
||||
} catch (refreshError) {
|
||||
console.error(
|
||||
"[ApiClient] Falha ao renovar token, fazendo logout...",
|
||||
refreshError
|
||||
);
|
||||
// Se refresh falhar, limpa tudo e redireciona para home
|
||||
localStorage.removeItem(API_CONFIG.STORAGE_KEYS.ACCESS_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) {
|
||||
localStorage.removeItem(API_CONFIG.STORAGE_KEYS.ACCESS_TOKEN);
|
||||
localStorage.removeItem(API_CONFIG.STORAGE_KEYS.REFRESH_TOKEN);
|
||||
localStorage.removeItem(API_CONFIG.STORAGE_KEYS.USER);
|
||||
|
||||
// Redireciona para home ao invés de login específico
|
||||
if (
|
||||
!window.location.pathname.includes("/login") &&
|
||||
window.location.pathname !== "/"
|
||||
) {
|
||||
window.location.href = "/";
|
||||
}
|
||||
console.error(
|
||||
"[ApiClient] 401 não tratado - requisição:",
|
||||
originalRequest?.url
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
|
||||
@ -4,11 +4,11 @@
|
||||
*/
|
||||
|
||||
// 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 BASE_URL = isDevelopment
|
||||
? "http://localhost:8888/.netlify/functions"
|
||||
: "/.netlify/functions";
|
||||
: "https://mediconnectbrasil.netlify.app/.netlify/functions";
|
||||
|
||||
export const API_CONFIG = {
|
||||
// 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)
|
||||
*/
|
||||
|
||||
@ -48,6 +48,8 @@ export type {
|
||||
CreatePatientInput,
|
||||
UpdatePatientInput,
|
||||
PatientFilters,
|
||||
RegisterPatientInput,
|
||||
RegisterPatientResponse,
|
||||
} from "./patients/types";
|
||||
|
||||
// Profiles
|
||||
|
||||
@ -8,6 +8,8 @@ import type {
|
||||
CreatePatientInput,
|
||||
UpdatePatientInput,
|
||||
PatientFilters,
|
||||
RegisterPatientInput,
|
||||
RegisterPatientResponse,
|
||||
} from "./types";
|
||||
|
||||
class PatientService {
|
||||
@ -95,6 +97,31 @@ class PatientService {
|
||||
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();
|
||||
|
||||
@ -47,6 +47,21 @@ export interface CreatePatientInput {
|
||||
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 {
|
||||
full_name?: string;
|
||||
cpf?: string;
|
||||
|
||||
@ -59,6 +59,7 @@ export interface CreateUserInput {
|
||||
create_patient_record?: boolean; // Novo campo opcional
|
||||
cpf?: 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 {
|
||||
@ -83,6 +84,7 @@ export interface CreateDoctorInput {
|
||||
crm_uf: string; // Padrão: ^[A-Z]{2}$
|
||||
specialty?: string;
|
||||
phone_mobile?: string;
|
||||
redirect_url?: string; // URL para redirecionar após clicar no magic link
|
||||
// password foi REMOVIDO da API
|
||||
}
|
||||
|
||||
|
||||
@ -115,6 +115,49 @@ class UserService {
|
||||
);
|
||||
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();
|
||||
|
||||
@ -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