diff --git a/src/App.jsx b/src/App.jsx index 348b3d1..50ffac2 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,9 +1,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react' -import { authRepository } from './repositories/authRepository.js' - import './App.css' import { AppShell } from './components/AppShell.jsx' +import { canAccess } from './config/permissions.js' +import { useAuth } from './hooks/useAuth.js' import { AgendaPage } from './pages/AgendaPage.jsx' import { AnalyticsPage } from './pages/AnalyticsPage.jsx' import { ForgotPasswordPage, LoginPage, RegisterPage } from './pages/AuthPages.jsx' @@ -16,11 +16,13 @@ import { ProfilePage } from './pages/ProfilePage.jsx' import { ReportsPage } from './pages/ReportsPage.jsx' import { SettingsPage } from './pages/SettingsPage.jsx' import { TeamPage } from './pages/TeamPage.jsx' +import { UsersPage } from './pages/UsersPage.jsx' import { VisitsPage } from './pages/VisitsPage.jsx' import { patientRepository } from './repositories/patientRepository.js' function App() { const [location, setLocation] = useState(() => readLocation()) + const { isAuthenticated, role, loading: authLoading } = useAuth() const navigate = useCallback((to, options = {}) => { if (options.replace) { @@ -49,25 +51,47 @@ function App() { return () => window.removeEventListener('popstate', handlePopState) }, []) - const route = useMemo(() => resolveRoute(location.pathname, navigate), [location.pathname, navigate]) - const isAuthenticated = authRepository.isAuthenticated() + const route = useMemo( + () => resolveRoute(location.pathname, navigate, role), + [location.pathname, navigate, role], + ) + // Tela de carregamento enquanto busca o role do usuário + if (authLoading) { + return ( +
+

Carregando...

+
+ ) + } + + // Rotas públicas (sem shell) if (!route.withShell) { return route.element } + // Usuário não autenticado if (!isAuthenticated) { return } + // Usuário autenticado mas sem permissão para a rota + if (role && !canAccess(role, location.pathname)) { + return ( + + + + ) + } + return ( - + {route.element} ) } -function resolveRoute(pathname, navigate) { +function resolveRoute(pathname, navigate, role) { if (pathname === '/' || pathname === '/login') { return { element: , @@ -102,7 +126,7 @@ function resolveRoute(pathname, navigate) { if (pathname === '/agenda') { return { - element: , + element: , title: 'Agenda', withShell: true, } @@ -110,7 +134,7 @@ function resolveRoute(pathname, navigate) { if (pathname === '/pacientes') { return { - element: , + element: , title: 'Pacientes', withShell: true, } @@ -126,7 +150,6 @@ function resolveRoute(pathname, navigate) { if (pathname.startsWith('/pacientes/')) { const patientId = pathname.split('/')[2] - return { element: , title: 'Paciente', @@ -145,7 +168,7 @@ function resolveRoute(pathname, navigate) { if (pathname === '/laudos') { return { element: , - title: 'Relatorios medicos', + title: 'Relatórios médicos', withShell: true, } } @@ -174,6 +197,14 @@ function resolveRoute(pathname, navigate) { } } + if (pathname === '/usuarios') { + return { + element: , + title: 'Usuários', + withShell: true, + } + } + if (pathname === '/perfil') { return { element: , @@ -192,7 +223,7 @@ function resolveRoute(pathname, navigate) { return { element: , - title: 'Tela nao encontrada', + title: 'Página não encontrada', withShell: true, } } @@ -204,7 +235,8 @@ function PatientDetailRoute({ navigate, patientId }) { useEffect(() => { let active = true - patientRepository.getById(patientId) + patientRepository + .getById(patientId) .then((data) => { if (active) setPatient(data) }) @@ -221,7 +253,30 @@ function PatientDetailRoute({ navigate, patientId }) { return
Carregando paciente...
} - return patient ? : + return patient ? ( + + ) : ( + + ) +} + +function UnauthorizedPage({ navigate }) { + return ( +
+

🔒

+

Acesso não permitido

+

+ Você não tem permissão para acessar esta página. +

+ +
+ ) } function readLocation() { diff --git a/src/components/AppShell.jsx b/src/components/AppShell.jsx index f44c0c7..baa058c 100644 --- a/src/components/AppShell.jsx +++ b/src/components/AppShell.jsx @@ -1,9 +1,11 @@ import { useEffect, useMemo, useState } from 'react' +import { ROLE_LABELS, ROLE_NAV_ITEMS } from '../config/permissions.js' import { profileRepository } from '../repositories/profileRepository.js' import { BrandLogo } from './Brand.jsx' -const navItems = [ +// Todos os itens de navegação com seus ícones e metadados +const ALL_NAV_ITEMS = [ { href: '/inicio', label: 'Painel', icon: 'pulse', activePaths: ['/inicio', '/home', '/dashboard'] }, { href: '/agenda', label: 'Agenda', icon: 'calendar' }, { href: '/pacientes', label: 'Pacientes', icon: 'users', exact: true }, @@ -16,6 +18,8 @@ const navItems = [ activePaths: ['/camunicacao', '/comunicacao', '/mensagens'], }, { href: '/relatorios', label: 'Relatorios', icon: 'chart' }, + { href: '/profissionais', label: 'Profissionais', icon: 'users' }, + { href: '/usuarios', label: 'Usuarios', icon: 'shield' }, { href: '/configuracoes', label: 'Configuracoes', icon: 'settings', activePaths: ['/configuracoes', '/config'] }, ] @@ -36,9 +40,10 @@ const titles = { '/perfil': 'Perfil', '/configuracoes': 'Configuracoes', '/config': 'Configuracoes', + '/usuarios': 'Usuarios', } -export function AppShell({ children, currentPath, navigate, routeTitle }) { +export function AppShell({ children, currentPath, navigate, role, routeTitle }) { const [menuOpen, setMenuOpen] = useState(false) const [quickSearch, setQuickSearch] = useState('') const [viewerProfile, setViewerProfile] = useState({ name: 'Usuario', role: 'Usuario do Sistema' }) @@ -51,24 +56,46 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) { return routeTitle || titles[currentPath] || 'MediConnect' }, [currentPath, routeTitle]) + // Filtra os itens de navegação com base no role do usuário + const navItems = useMemo(() => { + if (!role) return [] + + const allowedPaths = ROLE_NAV_ITEMS[role]?.map((item) => item.path) ?? [] + + return ALL_NAV_ITEMS.filter((item) => + allowedPaths.some( + (allowed) => item.href === allowed || item.activePaths?.includes(allowed), + ), + ) + }, [role]) + useEffect(() => { let active = true - profileRepository.getCurrentUserProfile() + profileRepository + .getCurrentUserProfile() .then((profile) => { if (!active || !profile) return setViewerProfile({ name: profile.name || 'Usuario', - role: profile.role || 'Usuario do Sistema', + role: ROLE_LABELS[role] || profile.role || 'Usuario do Sistema', }) }) - .catch(() => {}) + .catch(() => { + // Fallback: usa o label do role diretamente + if (active && role) { + setViewerProfile((prev) => ({ + ...prev, + role: ROLE_LABELS[role] || 'Usuario do Sistema', + })) + } + }) return () => { active = false } - }, []) + }, [role]) function goTo(path) { setMenuOpen(false) @@ -303,10 +330,10 @@ function AppIcon({ className = 'size-5', name }) { ) } - if (name === 'dollar') { + if (name === 'shield') { return ( - + ) } @@ -361,4 +388,4 @@ function getInitials(name) { .map((part) => part[0]) .join('') .toUpperCase() -} +} \ No newline at end of file diff --git a/src/config/permissions.js b/src/config/permissions.js new file mode 100644 index 0000000..29bc4e6 --- /dev/null +++ b/src/config/permissions.js @@ -0,0 +1,169 @@ +// Roles disponíveis no sistema +export const ROLES = { + ADMIN: 'admin', + GESTOR: 'gestor', + MEDICO: 'medico', + SECRETARIA: 'secretaria', + PACIENTE: 'paciente', +} + +// Rotas permitidas por role ('*' = todas) +const ROLE_ROUTES = { + admin: '*', + gestor: [ + '/inicio', '/home', '/dashboard', + '/agenda', + '/pacientes', + '/prontuario', + '/laudos', + '/relatorios', + '/comunicacao', '/mensagens', '/camunicacao', + '/profissionais', + '/configuracoes', '/config', + '/consultas', + '/usuarios', + '/perfil', + ], + medico: [ + '/inicio', '/home', '/dashboard', + '/agenda', + '/prontuario', + '/laudos', + '/comunicacao', '/mensagens', '/camunicacao', + '/relatorios', + '/perfil', + ], + secretaria: [ + '/inicio', '/home', '/dashboard', + '/agenda', + '/pacientes', + '/comunicacao', '/mensagens', '/camunicacao', + '/perfil', + ], + paciente: [ + '/inicio', '/home', '/dashboard', + '/perfil', + ], +} + +// Capacidades especiais por role +export const ROLE_CAPABILITIES = { + admin: { + manageUsers: true, + hardDeletePatients: true, + accessSettings: true, + ownAppointmentsOnly: false, + canEditPatients: true, + canViewReports: true, + canViewMedicalRecords: true, + }, + gestor: { + manageUsers: true, + hardDeletePatients: true, + accessSettings: true, + ownAppointmentsOnly: false, + canEditPatients: true, + canViewReports: true, + canViewMedicalRecords: true, + }, + medico: { + manageUsers: false, + hardDeletePatients: false, + accessSettings: false, + ownAppointmentsOnly: true, + canEditPatients: false, + canViewReports: true, + canViewMedicalRecords: true, + }, + secretaria: { + manageUsers: false, + hardDeletePatients: false, + accessSettings: false, + ownAppointmentsOnly: false, + canEditPatients: true, + canViewReports: false, + canViewMedicalRecords: false, + }, + paciente: { + manageUsers: false, + hardDeletePatients: false, + accessSettings: false, + ownAppointmentsOnly: false, + canEditPatients: false, + canViewReports: false, + canViewMedicalRecords: false, + }, +} + +// Itens do menu por role (para o AppShell) +export const ROLE_NAV_ITEMS = { + admin: [ + { path: '/inicio', label: 'Painel' }, + { path: '/agenda', label: 'Agenda' }, + { path: '/pacientes', label: 'Pacientes' }, + { path: '/prontuario', label: 'Prontuário' }, + { path: '/laudos', label: 'Laudos' }, + { path: '/relatorios', label: 'Relatórios' }, + { path: '/comunicacao', label: 'Comunicação' }, + { path: '/profissionais', label: 'Profissionais' }, + { path: '/usuarios', label: 'Usuários' }, + { path: '/configuracoes', label: 'Configurações' }, + ], + gestor: [ + { path: '/inicio', label: 'Painel' }, + { path: '/agenda', label: 'Agenda' }, + { path: '/pacientes', label: 'Pacientes' }, + { path: '/prontuario', label: 'Prontuário' }, + { path: '/laudos', label: 'Laudos' }, + { path: '/relatorios', label: 'Relatórios' }, + { path: '/comunicacao', label: 'Comunicação' }, + { path: '/profissionais', label: 'Profissionais' }, + { path: '/usuarios', label: 'Usuários' }, + { path: '/configuracoes', label: 'Configurações' }, + ], + medico: [ + { path: '/inicio', label: 'Painel' }, + { path: '/agenda', label: 'Agenda' }, + { path: '/prontuario', label: 'Prontuário' }, + { path: '/laudos', label: 'Laudos' }, + { path: '/comunicacao', label: 'Comunicação' }, + { path: '/relatorios', label: 'Relatórios' }, + ], + secretaria: [ + { path: '/inicio', label: 'Painel' }, + { path: '/agenda', label: 'Agenda' }, + { path: '/pacientes', label: 'Pacientes' }, + { path: '/comunicacao', label: 'Comunicação' }, + ], + paciente: [ + { path: '/inicio', label: 'Painel' }, + ], +} + +// Verifica se um role pode acessar uma rota +export function canAccess(role, pathname) { + if (!role) return false + const allowed = ROLE_ROUTES[role] + if (allowed === '*') return true + return allowed.some((route) => pathname === route || pathname.startsWith(route + '/')) +} + +// Verifica se um role tem uma capacidade específica +export function hasCapability(role, capability) { + return ROLE_CAPABILITIES[role]?.[capability] ?? false +} + +// Rótulos amigáveis para cada role +export const ROLE_LABELS = { + admin: 'Administrador', + gestor: 'Gestão / Coordenação', + medico: 'Médico', + secretaria: 'Secretária', + paciente: 'Paciente', +} + +// Roles que um gestor pode criar +export const GESTOR_CREATABLE_ROLES = ['medico', 'secretaria', 'paciente'] + +// Roles que um admin pode criar +export const ADMIN_CREATABLE_ROLES = ['admin', 'gestor', 'medico', 'secretaria', 'paciente'] diff --git a/src/hooks/useAuth.js b/src/hooks/useAuth.js new file mode 100644 index 0000000..3ac32cf --- /dev/null +++ b/src/hooks/useAuth.js @@ -0,0 +1,56 @@ +import { useEffect, useState } from 'react' + +import { getAuthSession, saveAuthSession } from '../config/api.js' +import { authRepository } from '../repositories/authRepository.js' + +export function useAuth() { + const [state, setState] = useState(() => { + const session = getAuthSession() + return { + user: session?.user ?? null, + role: session?.role ?? null, + profile: session?.profile ?? null, + isAuthenticated: !!session?.access_token, + loading: !!session?.access_token && !session?.role, + } + }) + + useEffect(() => { + // Se não está autenticado ou já tem role salvo, não busca + if (!state.isAuthenticated || state.role) { + setState((s) => ({ ...s, loading: false })) + return + } + + let cancelled = false + + authRepository + .getUser() + .then((data) => { + if (cancelled || !data) return + + // Suporta diferentes formatos de resposta da API + const role = + Array.isArray(data.roles) ? data.roles[0] + : (data.role ?? data.user_metadata?.role ?? data.app_metadata?.role ?? null) + + const profile = data.profile ?? null + const user = data.user ?? data ?? null + + // Persiste na sessão para evitar nova busca a cada reload + const session = getAuthSession() + saveAuthSession({ ...session, role, profile, user: user || session?.user }) + + setState((s) => ({ ...s, role, profile, user: user || s.user, loading: false })) + }) + .catch(() => { + if (!cancelled) setState((s) => ({ ...s, loading: false })) + }) + + return () => { + cancelled = true + } + }, [state.isAuthenticated, state.role]) + + return state +} \ No newline at end of file diff --git a/src/pages/UsersPage.jsx b/src/pages/UsersPage.jsx new file mode 100644 index 0000000..dc1a381 --- /dev/null +++ b/src/pages/UsersPage.jsx @@ -0,0 +1,316 @@ +import { useEffect, useState } from 'react' + +import { ADMIN_CREATABLE_ROLES, GESTOR_CREATABLE_ROLES, ROLE_LABELS } from '../config/permissions.js' +import { userRepository } from '../repositories/userRepository.js' + +const darkInput = + 'h-10 w-full rounded-lg border border-[#404040] bg-[#1a1a1a] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#737373] focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6]' +const darkLabel = 'mb-1.5 block text-xs font-medium text-[#e5e5e5]' + +export function UsersPage({ role: currentRole }) { + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [modalOpen, setModalOpen] = useState(false) + const [saving, setSaving] = useState(false) + const [deletingId, setDeletingId] = useState(null) + const [form, setForm] = useState({ + email: '', + full_name: '', + role: '', + create_patient_record: false, + cpf: '', + phone_mobile: '', + }) + + const creatableRoles = currentRole === 'admin' ? ADMIN_CREATABLE_ROLES : GESTOR_CREATABLE_ROLES + + useEffect(() => { + loadUsers() + }, []) + + async function loadUsers() { + setLoading(true) + setError(null) + try { + const data = await userRepository.getAll() + setUsers(data) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + + function handleFormChange(event) { + const { checked, name, type, value } = event.target + setForm((current) => ({ ...current, [name]: type === 'checkbox' ? checked : value })) + } + +async function handleCreate(event) { + event.preventDefault() + if (!form.email || !form.full_name || !form.role) { + window.alert('Preencha email, nome completo e perfil.') + return + } + setSaving(true) + try { + const result = await userRepository.create(form) + console.log('Usuário criado:', result) + window.alert(`Usuário criado! Magic Link enviado para ${form.email}.`) + setModalOpen(false) + setForm({ email: '', full_name: '', role: '', create_patient_record: false, cpf: '', phone_mobile: '' }) + loadUsers() + } catch (err) { + console.error('Erro completo:', err) + window.alert(`Erro ao criar usuário: ${err.message}`) + } finally { + setSaving(false) + } +} + + async function handleDelete(user) { + const confirmed = window.confirm( + `⚠️ ATENÇÃO: Esta operação é IRREVERSÍVEL!\n\nO usuário "${user.full_name || user.email}" e TODOS os dados relacionados (perfil, agendamentos, registros) serão deletados permanentemente.\n\nDeseja continuar?` + ) + if (!confirmed) return + + setDeletingId(user.id) + try { + await userRepository.remove(user.id) + setUsers((current) => current.filter((u) => u.id !== user.id)) + } catch (err) { + window.alert(`Erro ao deletar usuário: ${err.message}`) + } finally { + setDeletingId(null) + } + } + + return ( +
+
+
+

Usuários do Sistema

+

Gerencie os usuários e seus perfis de acesso

+
+ +
+ + {loading ? ( +

Carregando usuários...

+ ) : error ? ( +

Erro ao carregar usuários: {error}

+ ) : ( +
+
+ + + + + + + + + + + + {users.length ? ( + users.map((user) => { + const userRole = Array.isArray(user.roles) ? user.roles[0] : (user.role ?? '—') + return ( + + + + + + + + ) + }) + ) : ( + + + + )} + +
NomeEmailPerfilStatusAções
+
+ + {(user.full_name || user.email || '?').charAt(0).toUpperCase()} + + {user.full_name || '—'} +
+
{user.email} + + + + {user.email_confirmed_at ? 'Ativo' : 'Pendente'} + + + +
+ Nenhum usuário encontrado. +
+
+
+ )} + + {modalOpen ? ( +
setModalOpen(false)}> +
e.stopPropagation()} + > +
+
+

Novo Usuário

+

Um Magic Link será enviado para o email cadastrado.

+
+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + + {form.create_patient_record ? ( +
+
+ + +
+
+ + +
+
+ ) : null} + +
+ + +
+
+
+
+ ) : null} +
+ ) +} + +function RoleBadge({ role }) { + const styles = { + admin: 'bg-purple-500/20 text-purple-400', + gestor: 'bg-blue-500/20 text-blue-400', + medico: 'bg-emerald-500/20 text-emerald-400', + secretaria: 'bg-amber-500/20 text-amber-400', + paciente: 'bg-[#303030] text-[#a3a3a3]', + } + + return ( + + {ROLE_LABELS[role] || role} + + ) +} diff --git a/src/repositories/userRepository.js b/src/repositories/userRepository.js new file mode 100644 index 0000000..3273735 --- /dev/null +++ b/src/repositories/userRepository.js @@ -0,0 +1,72 @@ +import { apiConfig, getAuthenticatedHeaders } from '../config/api.js' + +export const userRepository = { + // Listar todos os usuários (admin/gestor) + async getAll() { + const response = await fetch(`${apiConfig.functionsUrl}/user-info`, { + method: 'POST', + headers: getAuthenticatedHeaders(), + }) + if (!response.ok) throw new Error('Erro ao listar usuários') + const data = await response.json() + console.log('Resposta de user-info:', data) + return Array.isArray(data) ? data : (data.users ?? [data]) + }, + // Buscar usuário por ID (admin/gestor) + async getById(userId) { + const response = await fetch(`${apiConfig.functionsUrl}/user-info-by-id`, { + method: 'POST', + headers: getAuthenticatedHeaders(), + body: JSON.stringify({ user_id: userId }), + }) + if (!response.ok) throw new Error('Erro ao buscar usuário') + return response.json() + }, + + // Criar usuário com magic link (admin/gestor/secretaria) + async create(data) { + const body = { + email: data.email, + full_name: data.full_name, + role: data.role, + } + + if (data.create_patient_record) { + body.create_patient_record = true + body.cpf = data.cpf + body.phone_mobile = data.phone_mobile + } + + console.log('Enviando para create-user:', body) + + const response = await fetch(`${apiConfig.functionsUrl}/create-user`, { + method: 'POST', + headers: getAuthenticatedHeaders(), + body: JSON.stringify(body), + }) + + if (!response.ok) { + const error = await response.json().catch(() => ({})) + console.error('Resposta de erro da API:', error) + throw new Error(error.message || error.error || error.msg || JSON.stringify(error)) + } + + return response.json() +}, + + // Deletar usuário permanentemente — IRREVERSÍVEL (admin/gestor) + async remove(userId) { + const response = await fetch(`${apiConfig.functionsUrl}/delete-user`, { + method: 'POST', + headers: getAuthenticatedHeaders(), + body: JSON.stringify({ user_id: userId }), + }) + + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new Error(error.message || 'Erro ao deletar usuário') + } + + return true + }, +}