Compare commits

...

2 Commits

11 changed files with 413 additions and 536 deletions

View File

@ -58,6 +58,7 @@ npx wrangler pages deploy dist --project-name=mediconnect --branch=production
### Autenticação JWT com Supabase
O sistema usa **Supabase Auth** com tokens JWT. Todo login retorna:
- `access_token` (JWT, expira em 1 hora)
- `refresh_token` (para renovação automática)
@ -65,47 +66,51 @@ O sistema usa **Supabase Auth** com tokens JWT. Todo login retorna:
```typescript
// Adiciona token automaticamente em todas as requisições
axios.interceptors.request.use(config => {
const token = localStorage.getItem('access_token')
axios.interceptors.request.use((config) => {
const token = localStorage.getItem("access_token");
if (token) {
config.headers.Authorization = `Bearer ${token}`
config.headers.Authorization = `Bearer ${token}`;
}
return config
})
return config;
});
// Refresh automático quando token expira
axios.interceptors.response.use(
response => response,
async error => {
(response) => response,
async (error) => {
if (error.response?.status === 401) {
const refreshToken = localStorage.getItem('refresh_token')
const newTokens = await authService.refreshToken(refreshToken)
const refreshToken = localStorage.getItem("refresh_token");
const newTokens = await authService.refreshToken(refreshToken);
// Retry request original
}
}
)
);
```
### Roles e Permissões (RLS)
#### 👑 Admin/Gestor:
- ✅ **Acesso completo a todos os recursos**
- ✅ Criar/editar/deletar usuários, médicos, pacientes
- ✅ Visualizar todos os agendamentos e prontuários
#### 👨‍⚕️ Médicos:
- ✅ Veem **todos os pacientes**
- ✅ Veem apenas **seus próprios laudos** (filtro: `created_by = médico`)
- ✅ Veem apenas **seus próprios agendamentos** (filtro: `doctor_id = médico`)
- ✅ Editam apenas **seus próprios laudos e agendamentos**
#### 👤 Pacientes:
- ✅ Veem apenas **seus próprios dados**
- ✅ Veem apenas **seus próprios laudos** (filtro: `patient_id = paciente`)
- ✅ Veem apenas **seus próprios agendamentos**
- ✅ Podem agendar consultas
#### 👩‍💼 Secretárias:
- ✅ Veem **todos os pacientes**
- ✅ Veem **todos os agendamentos**
- ✅ Veem **todos os laudos**
@ -145,96 +150,96 @@ src/services/
```typescript
// Login
await authService.login({ email, password })
await authService.login({ email, password });
// Retorna: { access_token, refresh_token, user }
// Recuperação de senha
await authService.requestPasswordReset(email)
await authService.requestPasswordReset(email);
// Envia email com link de reset
// Atualizar senha
await authService.updatePassword(accessToken, newPassword)
await authService.updatePassword(accessToken, newPassword);
// Usado na página /reset-password
// Refresh token
await authService.refreshToken(refreshToken)
await authService.refreshToken(refreshToken);
```
#### 👤 Usuários (userService)
```typescript
// Buscar informações do usuário autenticado (com roles)
const userInfo = await userService.getUserInfo()
const userInfo = await userService.getUserInfo();
// Retorna: { id, email, full_name, roles: ['medico', 'admin'] }
// Criar usuário com role
await userService.createUser({
email: 'user@example.com',
full_name: 'Nome Completo',
role: 'medico' // ou 'admin', 'paciente', 'secretaria'
})
email: "user@example.com",
full_name: "Nome Completo",
role: "medico", // ou 'admin', 'paciente', 'secretaria'
});
// Deletar usuário
await userService.deleteUser(userId)
await userService.deleteUser(userId);
```
#### 🏥 Pacientes (patientService)
```typescript
// Listar pacientes
const patients = await patientService.list()
const patients = await patientService.list();
// Buscar por ID
const patient = await patientService.getById(id)
const patient = await patientService.getById(id);
// Criar paciente
await patientService.create({
email: 'paciente@example.com',
full_name: 'Nome Paciente',
cpf: '12345678900',
phone_mobile: '11999999999'
})
email: "paciente@example.com",
full_name: "Nome Paciente",
cpf: "12345678900",
phone_mobile: "11999999999",
});
// Atualizar paciente
await patientService.update(id, { phone_mobile: '11888888888' })
await patientService.update(id, { phone_mobile: "11888888888" });
// Deletar paciente
await patientService.delete(id)
await patientService.delete(id);
```
#### 👨‍⚕️ Médicos (doctorService)
```typescript
// Listar médicos
const doctors = await doctorService.list()
const doctors = await doctorService.list();
// Buscar por ID
const doctor = await doctorService.getById(id)
const doctor = await doctorService.getById(id);
// Buscar disponibilidade
const slots = await doctorService.getAvailableSlots(doctorId, date)
const slots = await doctorService.getAvailableSlots(doctorId, date);
```
#### 📅 Agendamentos (appointmentService)
```typescript
// Listar agendamentos (filtrado por role automaticamente)
const appointments = await appointmentService.list()
const appointments = await appointmentService.list();
// Criar agendamento
await appointmentService.create({
patient_id: 'uuid-paciente',
doctor_id: 'uuid-medico',
scheduled_at: '2025-10-25T10:00:00',
reason: 'Consulta de rotina'
})
patient_id: "uuid-paciente",
doctor_id: "uuid-medico",
scheduled_at: "2025-10-25T10:00:00",
reason: "Consulta de rotina",
});
// Atualizar status
await appointmentService.updateStatus(id, 'confirmed')
await appointmentService.updateStatus(id, "confirmed");
// Status: requested, confirmed, completed, cancelled
// Cancelar
await appointmentService.cancel(id, 'Motivo do cancelamento')
await appointmentService.cancel(id, "Motivo do cancelamento");
```
---
@ -253,9 +258,9 @@ export const API_CONFIG = {
STORAGE_KEYS: {
ACCESS_TOKEN: "mediconnect_access_token",
REFRESH_TOKEN: "mediconnect_refresh_token",
USER: "mediconnect_user"
}
}
USER: "mediconnect_user",
},
};
```
---
@ -292,11 +297,11 @@ npx wrangler pages deploy dist --project-name=mediconnect --commit-dirty=true
## 1. Variáveis de Ambiente (`.env` / `.env.local`)
| Variável | Obrigatória | Descrição |
| ------------------------ | ---------------- | --------------------------------------------------------------- |
| `VITE_SUPABASE_URL` | Sim | URL base do projeto Supabase (`https://<ref>.supabase.co`) |
| `VITE_SUPABASE_ANON_KEY` | Sim | Chave pública (anon) usada para Auth password grant e PostgREST |
| `VITE_APP_ENV` | Não | Identifica ambiente (ex: `dev`, `staging`, `prod`) |
| Variável | Obrigatória | Descrição |
| ------------------------ | ----------- | --------------------------------------------------------------- |
| `VITE_SUPABASE_URL` | Sim | URL base do projeto Supabase (`https://<ref>.supabase.co`) |
| `VITE_SUPABASE_ANON_KEY` | Sim | Chave pública (anon) usada para Auth password grant e PostgREST |
| `VITE_APP_ENV` | Não | Identifica ambiente (ex: `dev`, `staging`, `prod`) |
**Nota:** As variáveis já estão configuradas no código em `src/services/api/config.ts`. Não é necessário criar arquivo `.env` para desenvolvimento local.
@ -314,38 +319,37 @@ Boas práticas:
```typescript
// pages/LoginMedico.tsx
const handleLogin = async (e: FormEvent) => {
e.preventDefault()
e.preventDefault();
try {
// 1. Autenticar com Supabase
const loginResponse = await authService.login({ email, password })
const loginResponse = await authService.login({ email, password });
// 2. Salvar tokens
localStorage.setItem('access_token', loginResponse.access_token)
localStorage.setItem('refresh_token', loginResponse.refresh_token)
localStorage.setItem("access_token", loginResponse.access_token);
localStorage.setItem("refresh_token", loginResponse.refresh_token);
// 3. Buscar informações do usuário com roles
const userInfo = await userService.getUserInfo()
const roles = userInfo.roles || []
const userInfo = await userService.getUserInfo();
const roles = userInfo.roles || [];
// 4. Validar permissões
const isAdmin = roles.includes('admin')
const isGestor = roles.includes('gestor')
const isMedico = roles.includes('medico')
const isAdmin = roles.includes("admin");
const isGestor = roles.includes("gestor");
const isMedico = roles.includes("medico");
if (!isAdmin && !isGestor && !isMedico) {
toast.error("Você não tem permissão para acessar esta área")
await authService.logout()
return
toast.error("Você não tem permissão para acessar esta área");
await authService.logout();
return;
}
// 5. Redirecionar para o painel
navigate('/painel-medico')
navigate("/painel-medico");
} catch (error) {
toast.error("Email ou senha incorretos")
toast.error("Email ou senha incorretos");
}
}
};
```
---
@ -367,35 +371,35 @@ const handleLogin = async (e: FormEvent) => {
// Solicitar recuperação (páginas de login)
const handlePasswordReset = async () => {
try {
await authService.requestPasswordReset(email)
toast.success("Link de recuperação enviado para seu email")
await authService.requestPasswordReset(email);
toast.success("Link de recuperação enviado para seu email");
} catch (error) {
toast.error("Erro ao enviar email de recuperação")
toast.error("Erro ao enviar email de recuperação");
}
}
};
// Página de reset (ResetPassword.tsx)
useEffect(() => {
// Extrair token do URL
const hash = window.location.hash
const params = new URLSearchParams(hash.substring(1))
const token = params.get('access_token')
const hash = window.location.hash;
const params = new URLSearchParams(hash.substring(1));
const token = params.get("access_token");
if (token) {
setAccessToken(token)
setIsLoading(false)
setAccessToken(token);
setIsLoading(false);
}
}, [])
}, []);
const handleSubmit = async (e: FormEvent) => {
try {
await authService.updatePassword(accessToken, newPassword)
toast.success("Senha atualizada com sucesso!")
navigate('/login-paciente')
await authService.updatePassword(accessToken, newPassword);
toast.success("Senha atualizada com sucesso!");
navigate("/login-paciente");
} catch (error) {
toast.error("Erro ao atualizar senha")
toast.error("Erro ao atualizar senha");
}
}
};
```
---
@ -405,6 +409,7 @@ const handleSubmit = async (e: FormEvent) => {
### Tabelas Principais
#### `profiles`
```sql
- id (uuid, PK)
- email (text, unique)
@ -415,6 +420,7 @@ const handleSubmit = async (e: FormEvent) => {
```
#### `patients`
```sql
- id (uuid, PK, FK -> profiles)
- email (text, unique)
@ -427,6 +433,7 @@ const handleSubmit = async (e: FormEvent) => {
```
#### `doctors`
```sql
- id (uuid, PK, FK -> profiles)
- email (text, unique)
@ -438,6 +445,7 @@ const handleSubmit = async (e: FormEvent) => {
```
#### `appointments`
```sql
- id (uuid, PK)
- patient_id (uuid, FK -> patients)
@ -450,6 +458,7 @@ const handleSubmit = async (e: FormEvent) => {
```
#### `user_roles`
```sql
- user_id (uuid, FK -> profiles)
- role (text: admin, gestor, medico, secretaria, paciente)
@ -462,13 +471,14 @@ const handleSubmit = async (e: FormEvent) => {
O sistema usa uma estratégia híbrida para máxima segurança:
| Tipo | Local | Expiração Natural |
| -------------- | ----------------- | ------------------------------------ |
| Access Token | localStorage | 1 hora (renovado automaticamente) |
| Refresh Token | localStorage | 30 dias (ou revogação backend) |
| User Snapshot | localStorage | Limpo em logout |
| Tipo | Local | Expiração Natural |
| ------------- | ------------ | --------------------------------- |
| Access Token | localStorage | 1 hora (renovado automaticamente) |
| Refresh Token | localStorage | 30 dias (ou revogação backend) |
| User Snapshot | localStorage | Limpo em logout |
**Segurança:**
- Tokens são limpos automaticamente no logout
- Refresh automático quando access_token expira (401)
- Interceptors garantem tokens válidos em todas as requisições
@ -497,6 +507,7 @@ Fluxo de Refresh:
1. Requisição falha com 401.
2. Wrapper (`http.ts`) obtém refresh do `tokenStore`.
---
## 7. Scripts Utilitários
@ -545,9 +556,9 @@ node search-fernando.cjs
```typescript
// Imports
import React, { useState, useEffect } from "react"
import { useNavigate } from "react-router-dom"
import { serviceImport } from "../services"
import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { serviceImport } from "../services";
// Types
interface Props {
@ -557,28 +568,24 @@ interface Props {
// Component
const ComponentName: React.FC<Props> = ({ ...props }) => {
// Hooks
const navigate = useNavigate()
const [state, setState] = useState()
const navigate = useNavigate();
const [state, setState] = useState();
// Effects
useEffect(() => {
// ...
}, [])
}, []);
// Handlers
const handleAction = async () => {
// ...
}
// Render
return (
<div>
{/* JSX */}
</div>
)
}
};
export default ComponentName
// Render
return <div>{/* JSX */}</div>;
};
export default ComponentName;
```
---
@ -586,6 +593,7 @@ export default ComponentName
## 9. Tecnologias Utilizadas
### Frontend
- **React** 18.3.1 - Biblioteca UI
- **TypeScript** 5.9.3 - Tipagem estática
- **Vite** 7.1.10 - Build tool
@ -596,11 +604,13 @@ export default ComponentName
- **date-fns** 4.1.0 - Manipulação de datas
### Backend
- **Supabase** - Backend as a Service
- **PostgreSQL** - Banco de dados relacional
- **Supabase Auth** - Autenticação JWT
### Deploy
- **Cloudflare Pages** - Hospedagem frontend
- **Wrangler** 4.44.0 - CLI Cloudflare
@ -615,4 +625,3 @@ export default ComponentName
---
**Desenvolvido com ❤️ pela Squad 18**

View File

@ -1,76 +0,0 @@
const axios = require("axios");
const ANON_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
const BASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
(async () => {
try {
console.log("🔐 Fazendo login como admin...");
const loginRes = await axios.post(
`${BASE_URL}/auth/v1/token?grant_type=password`,
{
email: "riseup@popcode.com.br",
password: "riseup",
},
{
headers: {
"Content-Type": "application/json",
apikey: ANON_KEY,
},
}
);
console.log("✅ Login admin bem-sucedido!\n");
const token = loginRes.data.access_token;
// Buscar o ID do Fernando no profiles
console.log("🔍 Buscando ID do Fernando...");
const profileRes = await axios.get(
`${BASE_URL}/rest/v1/profiles?email=eq.fernando.pirichowski@souunit.com.br&select=*`,
{
headers: {
apikey: ANON_KEY,
Authorization: `Bearer ${token}`,
},
}
);
if (profileRes.data.length === 0) {
console.log("❌ Fernando não encontrado no profiles");
return;
}
const fernandoId = profileRes.data[0].id;
console.log("✅ Fernando encontrado! ID:", fernandoId);
// Criar entrada na tabela patients
console.log("\n📋 Criando entrada na tabela patients...");
const patientRes = await axios.post(
`${BASE_URL}/rest/v1/patients`,
{
id: fernandoId,
email: "fernando.pirichowski@souunit.com.br",
full_name: "Fernando Pirichowski",
phone_mobile: "51999999999",
cpf: "12345678909", // CPF válido fictício
},
{
headers: {
"Content-Type": "application/json",
apikey: ANON_KEY,
Authorization: `Bearer ${token}`,
Prefer: "return=representation",
},
}
);
console.log("✅ Entrada na tabela patients criada!");
console.log("\n🎉 Usuário Fernando Pirichowski completo!");
console.log("📧 Email: fernando.pirichowski@souunit.com.br");
console.log("🔑 Senha: fernando123");
console.log("\n✨ Agora você pode testar a recuperação de senha!");
} catch (err) {
console.error("❌ Erro:", err.response?.data || err.message);
}
})();

View File

@ -1,87 +0,0 @@
const axios = require("axios");
const ANON_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
const BASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
(async () => {
try {
console.log("🔐 Fazendo login como admin...");
const loginRes = await axios.post(
`${BASE_URL}/auth/v1/token?grant_type=password`,
{
email: "riseup@popcode.com.br",
password: "riseup",
},
{
headers: {
"Content-Type": "application/json",
apikey: ANON_KEY,
},
}
);
console.log("✅ Login admin bem-sucedido!\n");
const token = loginRes.data.access_token;
console.log("👤 Criando usuário Fernando Pirichowski...");
// Criar usuário via signup
const signupRes = await axios.post(
`${BASE_URL}/auth/v1/signup`,
{
email: "fernando.pirichowski@souunit.com.br",
password: "fernando123", // Senha temporária
options: {
data: {
full_name: "Fernando Pirichowski",
phone: "51999999999",
},
},
},
{
headers: {
"Content-Type": "application/json",
apikey: ANON_KEY,
},
}
);
console.log("✅ Usuário criado com sucesso!");
console.log("📧 Email:", signupRes.data.user.email);
console.log("🆔 ID:", signupRes.data.user.id);
console.log("🔑 Senha temporária: fernando123\n");
// Criar entrada na tabela patients
console.log("📋 Criando entrada na tabela patients...");
const patientRes = await axios.post(
`${BASE_URL}/rest/v1/patients`,
{
id: signupRes.data.user.id,
email: "fernando.pirichowski@souunit.com.br",
full_name: "Fernando Pirichowski",
phone_mobile: "51999999999",
cpf: "12345678909", // CPF válido fictício
},
{
headers: {
"Content-Type": "application/json",
apikey: ANON_KEY,
Authorization: `Bearer ${token}`,
Prefer: "return=representation",
},
}
);
console.log("✅ Entrada na tabela patients criada!");
console.log("\n🎉 Usuário Fernando Pirichowski criado com sucesso!");
console.log("📧 Email: fernando.pirichowski@souunit.com.br");
console.log("🔑 Senha: fernando123");
console.log("\n💡 Agora você pode testar a recuperação de senha!");
} catch (err) {
console.error("❌ Erro:", err.response?.data || err.message);
if (err.response?.data?.msg) {
console.error("Mensagem:", err.response.data.msg);
}
}
})();

View File

@ -1,86 +0,0 @@
const axios = require('axios');
const ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ';
const BASE_URL = 'https://yuanqfswhberkoevtmfr.supabase.co';
(async () => {
try {
console.log('🔐 Fazendo login como admin...');
const loginRes = await axios.post(`${BASE_URL}/auth/v1/token?grant_type=password`, {
email: 'riseup@popcode.com.br',
password: 'riseup'
}, {
headers: {
'Content-Type': 'application/json',
'apikey': ANON_KEY
}
});
console.log('✅ Login admin bem-sucedido!\n');
const token = loginRes.data.access_token;
console.log('🔍 Buscando usuário fernando na tabela profiles...');
const profilesRes = await axios.get(`${BASE_URL}/rest/v1/profiles?select=*`, {
headers: {
'apikey': ANON_KEY,
'Authorization': `Bearer ${token}`
}
});
console.log(`📊 Total de profiles: ${profilesRes.data.length}\n`);
const fernandoProfile = profilesRes.data.find(u =>
u.email && (
u.email.toLowerCase().includes('fernando') ||
u.full_name?.toLowerCase().includes('fernando')
)
);
if (fernandoProfile) {
console.log('✅ Fernando encontrado na tabela profiles:');
console.log(JSON.stringify(fernandoProfile, null, 2));
} else {
console.log('❌ Fernando NÃO encontrado na tabela profiles\n');
}
// Buscar nos pacientes também
console.log('\n🔍 Buscando fernando na tabela patients...');
const patientsRes = await axios.get(`${BASE_URL}/rest/v1/patients?select=*`, {
headers: {
'apikey': ANON_KEY,
'Authorization': `Bearer ${token}`
}
});
console.log(`📊 Total de patients: ${patientsRes.data.length}\n`);
const fernandoPatient = patientsRes.data.find(p =>
p.email && (
p.email.toLowerCase().includes('fernando') ||
p.full_name?.toLowerCase().includes('fernando')
)
);
if (fernandoPatient) {
console.log('✅ Fernando encontrado na tabela patients:');
console.log(JSON.stringify(fernandoPatient, null, 2));
} else {
console.log('❌ Fernando NÃO encontrado na tabela patients\n');
}
// Listar alguns emails para referência
if (!fernandoProfile && !fernandoPatient) {
console.log('\n📧 Alguns emails cadastrados nos profiles:');
profilesRes.data.slice(0, 10).forEach((u, i) => {
if (u.email) console.log(` ${i+1}. ${u.email} - ${u.full_name || 'sem nome'}`);
});
}
} catch (err) {
console.error('❌ Erro:', err.response?.data || err.message);
if (err.response) {
console.error('Status:', err.response.status);
console.error('Headers:', err.response.headers);
}
}
})();

View File

@ -0,0 +1,54 @@
import { useEffect } from "react";
import { useNavigate, useLocation } from "react-router-dom";
/**
* Componente que detecta tokens de recuperação na URL e redireciona para /reset-password
* Funciona tanto com query string (?token=xxx) quanto com hash (#access_token=xxx)
*/
const RecoveryRedirect: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
console.log("[RecoveryRedirect] Verificando URL...");
console.log("[RecoveryRedirect] Pathname:", location.pathname);
console.log("[RecoveryRedirect] Search:", location.search);
console.log("[RecoveryRedirect] Hash:", location.hash);
let shouldRedirect = false;
// Verificar query string: ?token=xxx&type=recovery
const searchParams = new URLSearchParams(location.search);
const queryToken = searchParams.get("token");
const queryType = searchParams.get("type");
if (queryToken && queryType === "recovery") {
console.log("[RecoveryRedirect] ✅ Token de recovery no query string detectado");
shouldRedirect = true;
}
// Verificar hash: #access_token=xxx&type=recovery
if (location.hash) {
const hashParams = new URLSearchParams(location.hash.substring(1));
const hashToken = hashParams.get("access_token");
const hashType = hashParams.get("type");
if (hashToken && hashType === "recovery") {
console.log("[RecoveryRedirect] ✅ Token de recovery no hash detectado");
shouldRedirect = true;
}
}
if (shouldRedirect) {
console.log("[RecoveryRedirect] 🔄 Redirecionando para /reset-password");
// Preservar os parâmetros e redirecionar
navigate(`/reset-password${location.search}${location.hash}`, { replace: true });
} else {
console.log("[RecoveryRedirect] Nenhum token de recovery detectado");
}
}, [location, navigate]);
return null; // Componente invisível
};
export default RecoveryRedirect;

View File

@ -21,7 +21,32 @@ export default function AuthCallback() {
useEffect(() => {
const handleCallback = async () => {
try {
console.log("[AuthCallback] Iniciando processamento do magic link");
console.log("[AuthCallback] Iniciando processamento");
// Verificar se é um token de recovery
const hash = window.location.hash;
const hashParams = new URLSearchParams(hash.substring(1));
const accessToken = hashParams.get("access_token");
const type = hashParams.get("type");
console.log("[AuthCallback] Hash:", hash);
console.log("[AuthCallback] Type:", type);
console.log("[AuthCallback] Access Token presente:", !!accessToken);
// Se for recovery, redirecionar para página de reset
if (type === "recovery" && accessToken) {
console.log("[AuthCallback] ✅ Token de recovery detectado, redirecionando para /reset-password");
setStatus("success");
setMessage("Redirecionando para página de redefinição de senha...");
// Redirecionar preservando o hash com o token
setTimeout(() => {
navigate(`/reset-password${hash}`, { replace: true });
}, 1000);
return;
}
console.log("[AuthCallback] Processando magic link normal");
// Supabase automaticamente processa os query params
const {

View File

@ -6,6 +6,7 @@ import { MetricCard } from "../components/MetricCard";
import { HeroBanner } from "../components/HeroBanner";
import { i18n } from "../i18n";
import { useAuth } from "../hooks/useAuth";
import RecoveryRedirect from "../components/auth/RecoveryRedirect";
const Home: React.FC = () => {
const [stats, setStats] = useState({
@ -97,6 +98,9 @@ const Home: React.FC = () => {
return (
<div className="space-y-8" id="main-content">
{/* Componente invisível que detecta tokens de recuperação e redireciona */}
<RecoveryRedirect />
{/* Hero Section com Background Rotativo */}
<HeroBanner />

View File

@ -14,35 +14,48 @@ const ResetPassword: React.FC = () => {
const navigate = useNavigate();
useEffect(() => {
// Extrair access_token do hash da URL
// Extrair access_token do hash da URL (#access_token=...)
// OU do query string (?token=...&type=recovery)
let token: string | null = null;
let type: string | null = null;
// Primeiro tenta no hash (formato padrão do Supabase após redirect)
const hash = window.location.hash;
console.log("[ResetPassword] Hash completo:", hash);
if (hash) {
const params = new URLSearchParams(hash.substring(1));
const token = params.get("access_token");
const type = params.get("type");
const hashParams = new URLSearchParams(hash.substring(1));
token = hashParams.get("access_token");
type = hashParams.get("type");
console.log("[ResetPassword] Token do hash:", token ? token.substring(0, 20) + "..." : "null");
console.log("[ResetPassword] Type do hash:", type);
}
console.log(
"[ResetPassword] Token extraído:",
token ? token.substring(0, 20) + "..." : "null"
);
console.log("[ResetPassword] Type:", type);
if (token) {
setAccessToken(token);
console.log(
"[ResetPassword] ✅ Token de recuperação detectado e armazenado"
);
} else {
console.error("[ResetPassword] ❌ Token não encontrado no hash");
toast.error("Link de recuperação inválido ou expirado");
setTimeout(() => navigate("/"), 3000);
// Se não encontrou no hash, tenta no query string
if (!token) {
const search = window.location.search;
console.log("[ResetPassword] Query string completo:", search);
if (search) {
const queryParams = new URLSearchParams(search);
token = queryParams.get("token");
type = queryParams.get("type");
console.log("[ResetPassword] Token do query:", token ? token.substring(0, 20) + "..." : "null");
console.log("[ResetPassword] Type do query:", type);
}
}
if (token) {
setAccessToken(token);
console.log("[ResetPassword] ✅ Token de recuperação detectado e armazenado");
console.log("[ResetPassword] Type:", type);
} else {
console.error("[ResetPassword] ❌ Nenhum hash encontrado na URL");
console.error("[ResetPassword] ❌ Token não encontrado no hash nem no query string");
console.log("[ResetPassword] URL completa:", window.location.href);
toast.error("Link de recuperação inválido");
toast.error("Link de recuperação inválido ou expirado");
setTimeout(() => navigate("/"), 3000);
}
}, [navigate]);
@ -93,10 +106,26 @@ const ResetPassword: React.FC = () => {
} catch (error: unknown) {
console.error("[ResetPassword] Erro ao atualizar senha:", error);
const err = error as {
response?: { data?: { error_description?: string; message?: string } };
response?: {
data?: {
error_description?: string;
message?: string;
msg?: string;
error_code?: string;
}
};
message?: string;
};
// Mensagem específica para senha igual
if (err?.response?.data?.error_code === "same_password") {
toast.error("A nova senha deve ser diferente da senha atual");
setLoading(false);
return;
}
const errorMessage =
err?.response?.data?.msg ||
err?.response?.data?.error_description ||
err?.response?.data?.message ||
err?.message ||

View File

@ -154,20 +154,31 @@ class AuthService {
/**
* Solicita reset de senha via email (público)
* POST /auth/v1/recover
*
* Envia redirectTo dentro de options para controlar para onde o Supabase redireciona
*/
async requestPasswordReset(
email: string,
redirectUrl?: string
email: string
): Promise<{ success: boolean; message: string }> {
try {
const redirectUrl = `${API_CONFIG.APP_URL}/reset-password`;
console.log("[authService.requestPasswordReset] Email:", email);
console.log("[authService.requestPasswordReset] Redirect URL:", redirectUrl);
console.log("[authService.requestPasswordReset] Usando endpoint /auth/v1/recover");
const payload = {
email,
options: {
redirectTo: redirectUrl,
},
};
console.log("[authService.requestPasswordReset] Payload:", JSON.stringify(payload, null, 2));
await axios.post(
`${API_CONFIG.AUTH_URL}/recover`,
{
email,
options: {
redirectTo: redirectUrl || `${API_CONFIG.APP_URL}/reset-password`,
},
},
payload,
{
headers: {
"Content-Type": "application/json",
@ -176,13 +187,15 @@ class AuthService {
}
);
console.log("[authService.requestPasswordReset] ✅ Email de recuperação enviado");
return {
success: true,
message:
"Email de recuperação de senha enviado com sucesso. Verifique sua caixa de entrada.",
message: "Email de recuperação de senha enviado com sucesso. Verifique sua caixa de entrada.",
};
} catch (error) {
console.error("Erro ao solicitar reset de senha:", error);
} catch (error: any) {
console.error("[authService.requestPasswordReset] ❌ Erro:", error);
console.error("[authService.requestPasswordReset] Response:", error.response?.data);
throw error;
}
}
@ -196,7 +209,12 @@ class AuthService {
newPassword: string
): Promise<{ success: boolean; message: string }> {
try {
await axios.put(
console.log("[authService.updatePassword] Iniciando atualização de senha");
console.log("[authService.updatePassword] Token (primeiros 30 chars):", accessToken.substring(0, 30));
console.log("[authService.updatePassword] Nova senha length:", newPassword.length);
console.log("[authService.updatePassword] URL:", `${API_CONFIG.AUTH_URL}/user`);
const response = await axios.put(
`${API_CONFIG.AUTH_URL}/user`,
{
password: newPassword,
@ -210,12 +228,35 @@ class AuthService {
}
);
console.log("[authService.updatePassword] ✅ Senha atualizada com sucesso");
console.log("[authService.updatePassword] Response status:", response.status);
return {
success: true,
message: "Senha atualizada com sucesso",
};
} catch (error) {
console.error("Erro ao atualizar senha:", error);
} catch (error: any) {
console.error("[authService.updatePassword] ❌ Erro ao atualizar senha:", error);
console.error("[authService.updatePassword] Error response:", error.response?.data);
console.error("[authService.updatePassword] Error response (stringified):", JSON.stringify(error.response?.data, null, 2));
console.error("[authService.updatePassword] Error status:", error.response?.status);
console.error("[authService.updatePassword] Error statusText:", error.response?.statusText);
console.error("[authService.updatePassword] Error headers:", error.response?.headers);
console.error("[authService.updatePassword] Request URL:", error.config?.url);
console.error("[authService.updatePassword] Request headers:", error.config?.headers);
console.error("[authService.updatePassword] Request data:", error.config?.data);
// Se for 422, mostrar mensagem específica
if (error.response?.status === 422) {
const errorMsg = error.response?.data?.msg || error.response?.data?.message || error.response?.data?.error_description || "Token inválido ou expirado";
console.error("[authService.updatePassword] ⚠️ Erro 422 - Possíveis causas:");
console.error(" 1. Token de recuperação expirado (válido por 1 hora)");
console.error(" 2. Token já foi usado");
console.error(" 3. Formato do token incorreto");
console.error(" 4. Usuário não existe");
console.error("[authService.updatePassword] Mensagem do servidor:", errorMsg);
}
throw error;
}
}

View File

@ -1,54 +0,0 @@
const axios = require("axios");
const ANON_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
const BASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
(async () => {
try {
console.log("\n=== TESTE DE RECUPERAÇÃO COM REDIRECT_TO CORRETO ===\n");
const email = "fernando.pirichowski@souunit.com.br";
const redirectTo = "https://mediconnectbrasil.app/reset-password";
console.log(`📧 Enviando email de recuperação para: ${email}`);
console.log(`🔗 Redirect URL: ${redirectTo}\n`);
const response = await axios.post(
`${BASE_URL}/auth/v1/recover`,
{
email: email,
options: {
redirectTo: redirectTo,
},
},
{
headers: {
"Content-Type": "application/json",
apikey: ANON_KEY,
},
}
);
console.log("✅ Email de recuperação enviado com sucesso!");
console.log("Status:", response.status);
console.log("Response:", JSON.stringify(response.data, null, 2));
console.log("\n📬 Verifique o email:", email);
console.log("🔗 O link DEVE redirecionar para:", redirectTo);
console.log("\n💡 IMPORTANTE: Se ainda vier o link errado, você precisa:");
console.log(" 1. Acessar o painel do Supabase");
console.log(" 2. Ir em Authentication > URL Configuration");
console.log(
' 3. Atualizar o "Site URL" para: https://mediconnectbrasil.app'
);
console.log(
' 4. Adicionar https://mediconnectbrasil.app/* nos "Redirect URLs"'
);
console.log("\n=== TESTE CONCLUÍDO ===\n");
} catch (error) {
console.error("❌ Erro ao enviar email de recuperação:");
console.error(error.response?.data || error.message);
}
})();

188
README.md
View File

@ -61,12 +61,14 @@ O MediConnect é uma plataforma web que conecta **pacientes**, **médicos** e **
### Camadas da Aplicação
1. **Apresentação (UI)**
- React 18.3.1 com TypeScript
- React Router para navegação
- Tailwind CSS para estilização
- Lucide React para ícones
2. **Lógica de Negócio (Services)**
- Services organizados por domínio
- Axios para requisições HTTP
- Interceptors para autenticação automática
@ -81,6 +83,7 @@ O MediConnect é uma plataforma web que conecta **pacientes**, **médicos** e **
## 🛠️ Tecnologias
### Frontend
- **React** 18.3.1 - Biblioteca UI
- **TypeScript** 5.9.3 - Tipagem estática
- **Vite** 7.1.10 - Build tool
@ -91,11 +94,13 @@ O MediConnect é uma plataforma web que conecta **pacientes**, **médicos** e **
- **date-fns** 4.1.0 - Manipulação de datas
### Backend
- **Supabase** - Backend as a Service
- **PostgreSQL** - Banco de dados relacional
- **Supabase Auth** - Autenticação JWT
### Deploy
- **Cloudflare Pages** - Hospedagem frontend
- **Wrangler** 4.44.0 - CLI Cloudflare
@ -187,106 +192,112 @@ export const API_CONFIG = {
STORAGE_KEYS: {
ACCESS_TOKEN: "mediconnect_access_token",
REFRESH_TOKEN: "mediconnect_refresh_token",
USER: "mediconnect_user"
}
}
USER: "mediconnect_user",
},
};
```
### Serviços Disponíveis
#### 1. **authService** - Autenticação
```typescript
// Login
await authService.login({ email, password })
await authService.login({ email, password });
// Registro
await authService.signup({ email, password, full_name })
await authService.signup({ email, password, full_name });
// Logout
await authService.logout()
await authService.logout();
// Recuperação de senha
await authService.requestPasswordReset(email)
await authService.updatePassword(accessToken, newPassword)
await authService.requestPasswordReset(email);
await authService.updatePassword(accessToken, newPassword);
// Refresh token
await authService.refreshToken(refreshToken)
await authService.refreshToken(refreshToken);
```
#### 2. **userService** - Usuários
```typescript
// Buscar informações do usuário autenticado
const userInfo = await userService.getUserInfo()
const userInfo = await userService.getUserInfo();
// Criar usuário com role
await userService.createUser({ email, full_name, role })
await userService.createUser({ email, full_name, role });
// Deletar usuário
await userService.deleteUser(userId)
await userService.deleteUser(userId);
```
#### 3. **patientService** - Pacientes
```typescript
// Listar pacientes
const patients = await patientService.list()
const patients = await patientService.list();
// Buscar por ID
const patient = await patientService.getById(id)
const patient = await patientService.getById(id);
// Criar paciente
await patientService.create({ email, full_name, cpf, phone })
await patientService.create({ email, full_name, cpf, phone });
// Atualizar
await patientService.update(id, data)
await patientService.update(id, data);
// Registrar paciente (público)
await patientService.register({ email, full_name, cpf })
await patientService.register({ email, full_name, cpf });
```
#### 4. **doctorService** - Médicos
```typescript
// Listar médicos
const doctors = await doctorService.list()
const doctors = await doctorService.list();
// Buscar por ID
const doctor = await doctorService.getById(id)
const doctor = await doctorService.getById(id);
// Buscar disponibilidade
const slots = await doctorService.getAvailableSlots(doctorId, date)
const slots = await doctorService.getAvailableSlots(doctorId, date);
```
#### 5. **appointmentService** - Consultas
```typescript
// Listar consultas
const appointments = await appointmentService.list()
const appointments = await appointmentService.list();
// Criar consulta
await appointmentService.create({
patient_id,
doctor_id,
scheduled_at,
reason
})
reason,
});
// Atualizar status
await appointmentService.updateStatus(id, status)
await appointmentService.updateStatus(id, status);
// Cancelar
await appointmentService.cancel(id, reason)
await appointmentService.cancel(id, reason);
```
#### 6. **availabilityService** - Disponibilidade
```typescript
// Gerenciar disponibilidade do médico
await availabilityService.create({
doctor_id,
day_of_week,
start_time,
end_time
})
end_time,
});
// Listar disponibilidade
const slots = await availabilityService.listByDoctor(doctorId)
const slots = await availabilityService.listByDoctor(doctorId);
```
---
@ -296,61 +307,65 @@ const slots = await availabilityService.listByDoctor(doctorId)
### Fluxo de Autenticação
1. **Login**
```typescript
// 1. Usuário envia credenciais
const response = await authService.login({ email, password })
const response = await authService.login({ email, password });
// 2. Recebe tokens JWT
localStorage.setItem('access_token', response.access_token)
localStorage.setItem('refresh_token', response.refresh_token)
localStorage.setItem("access_token", response.access_token);
localStorage.setItem("refresh_token", response.refresh_token);
// 3. Busca informações completas
const userInfo = await userService.getUserInfo()
const userInfo = await userService.getUserInfo();
// 4. Valida roles
if (userInfo.roles.includes('medico')) {
navigate('/painel-medico')
if (userInfo.roles.includes("medico")) {
navigate("/painel-medico");
}
```
2. **Interceptor Automático**
```typescript
// Todo request adiciona o token automaticamente
axios.interceptors.request.use(config => {
const token = localStorage.getItem('access_token')
axios.interceptors.request.use((config) => {
const token = localStorage.getItem("access_token");
if (token) {
config.headers.Authorization = `Bearer ${token}`
config.headers.Authorization = `Bearer ${token}`;
}
return config
})
return config;
});
```
3. **Refresh Token Automático**
```typescript
axios.interceptors.response.use(
response => response,
async error => {
(response) => response,
async (error) => {
if (error.response?.status === 401) {
// Token expirado, tenta refresh
const refreshToken = localStorage.getItem('refresh_token')
const newTokens = await authService.refreshToken(refreshToken)
const refreshToken = localStorage.getItem("refresh_token");
const newTokens = await authService.refreshToken(refreshToken);
// Retry request original
}
}
)
);
```
### Roles e Permissões
| Role | Acesso |
|------|--------|
| **admin** | Acesso total ao sistema |
| **gestor** | Gestão de médicos, secretárias e relatórios |
| **medico** | Painel médico, consultas, prontuários |
| **secretaria** | Agendamento, gestão de pacientes |
| **paciente** | Agendamento, visualização de consultas |
| Role | Acesso |
| -------------- | ------------------------------------------- |
| **admin** | Acesso total ao sistema |
| **gestor** | Gestão de médicos, secretárias e relatórios |
| **medico** | Painel médico, consultas, prontuários |
| **secretaria** | Agendamento, gestão de pacientes |
| **paciente** | Agendamento, visualização de consultas |
**Hierarquia de Roles:**
```
admin > gestor > medico/secretaria > paciente
```
@ -397,26 +412,26 @@ sequenceDiagram
// LoginMedico.tsx
const handleLogin = async () => {
// 1. Login
const loginResponse = await authService.login({ email, password })
const loginResponse = await authService.login({ email, password });
// 2. Buscar roles
const userInfo = await userService.getUserInfo()
const roles = userInfo.roles || []
const userInfo = await userService.getUserInfo();
const roles = userInfo.roles || [];
// 3. Validar permissão
const isAdmin = roles.includes('admin')
const isGestor = roles.includes('gestor')
const isMedico = roles.includes('medico')
const isAdmin = roles.includes("admin");
const isGestor = roles.includes("gestor");
const isMedico = roles.includes("medico");
if (!isAdmin && !isGestor && !isMedico) {
toast.error("Você não tem permissão para acessar esta área")
await authService.logout()
return
toast.error("Você não tem permissão para acessar esta área");
await authService.logout();
return;
}
// 4. Redirecionar
navigate('/painel-medico')
}
navigate("/painel-medico");
};
```
---
@ -462,7 +477,7 @@ npx wrangler pages deploy dist --project-name=mediconnect --commit-dirty=true
### Pré-requisitos
- Node.js 18+
- Node.js 18+
- pnpm 8+
- Git
@ -522,6 +537,7 @@ VITE_APP_URL=http://localhost:5173
### Tabelas Principais
#### `profiles`
```sql
- id (uuid, PK)
- email (text, unique)
@ -532,6 +548,7 @@ VITE_APP_URL=http://localhost:5173
```
#### `patients`
```sql
- id (uuid, PK, FK -> profiles)
- email (text, unique)
@ -544,6 +561,7 @@ VITE_APP_URL=http://localhost:5173
```
#### `doctors`
```sql
- id (uuid, PK, FK -> profiles)
- email (text, unique)
@ -555,6 +573,7 @@ VITE_APP_URL=http://localhost:5173
```
#### `appointments`
```sql
- id (uuid, PK)
- patient_id (uuid, FK -> patients)
@ -567,6 +586,7 @@ VITE_APP_URL=http://localhost:5173
```
#### `user_roles`
```sql
- user_id (uuid, FK -> profiles)
- role (text: admin, gestor, medico, secretaria, paciente)
@ -621,9 +641,9 @@ node search-fernando.cjs
```typescript
// Imports
import React, { useState, useEffect } from "react"
import { useNavigate } from "react-router-dom"
import { serviceImport } from "../services"
import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { serviceImport } from "../services";
// Types
interface Props {
@ -633,28 +653,24 @@ interface Props {
// Component
const ComponentName: React.FC<Props> = ({ ...props }) => {
// Hooks
const navigate = useNavigate()
const [state, setState] = useState()
const navigate = useNavigate();
const [state, setState] = useState();
// Effects
useEffect(() => {
// ...
}, [])
}, []);
// Handlers
const handleAction = async () => {
// ...
}
// Render
return (
<div>
{/* JSX */}
</div>
)
}
};
export default ComponentName
// Render
return <div>{/* JSX */}</div>;
};
export default ComponentName;
```
---
@ -670,6 +686,7 @@ export default ComponentName
## 📝 Changelog
### v2.0.0 (Outubro 2024)
- ✅ Migração completa de Netlify Functions para Supabase
- ✅ Implementação de recuperação de senha
- ✅ Deploy no Cloudflare Pages
@ -679,6 +696,7 @@ export default ComponentName
- ✅ Interface responsiva e dark mode
### v1.0.0 (Setembro 2024)
- ✅ Lançamento inicial
- ✅ Login de pacientes, médicos e secretárias
- ✅ Agendamento de consultas