feat: Update database models, API documentation and frontend authentication system

This commit is contained in:
JoaoVitorMS0 2026-01-12 16:41:50 -03:00
parent 7010e8e7d9
commit 3430d6bab5
7 changed files with 328 additions and 37 deletions

View file

@ -1,4 +1,4 @@
import React from "react";
import React, { useState, useEffect } from "react";
import {
BrowserRouter,
Routes,
@ -28,6 +28,9 @@ import { LGPD } from "./pages/LGPD";
import { AuthProvider, useAuth } from "./contexts/AuthContext";
import { DataProvider } from "./contexts/DataContext";
import { UserRole } from "./types";
import { verifyAccessCode } from "./services/apiService";
import { Button } from "./components/Button";
import { X } from "lucide-react";
import { ShieldAlert } from "lucide-react";
// Componente de acesso negado
@ -55,6 +58,188 @@ const AccessDenied: React.FC = () => {
);
};
// Componente de rota protegida por código de acesso
interface AccessCodeProtectedRouteProps {
children: React.ReactNode;
type: 'client' | 'professional';
}
const AccessCodeProtectedRoute: React.FC<AccessCodeProtectedRouteProps> = ({ children, type }) => {
const [showAccessCodeModal, setShowAccessCodeModal] = useState(false);
const [accessCode, setAccessCode] = useState("");
const [codeError, setCodeError] = useState("");
const [isValidated, setIsValidated] = useState(false);
useEffect(() => {
const checkAccess = () => {
if (type === 'client') {
const isValidated = sessionStorage.getItem('accessCodeValidated');
const accessData = sessionStorage.getItem('accessCodeData');
if (!isValidated || !accessData) {
setShowAccessCodeModal(true);
return;
}
setIsValidated(true);
} else if (type === 'professional') {
const isValidated = sessionStorage.getItem('professionalAccessValidated');
const accessData = sessionStorage.getItem('professionalAccessData');
if (!isValidated || !accessData) {
setShowAccessCodeModal(true);
return;
}
setIsValidated(true);
}
};
checkAccess();
}, [type]);
const handleVerifyCode = async () => {
if (accessCode.trim() === "") {
setCodeError("Por favor, digite o código de acesso");
return;
}
try {
const res = await verifyAccessCode(accessCode.toUpperCase());
if (res.data && res.data.valid) {
// Marcar na sessão que o código foi validado
if (type === 'client') {
sessionStorage.setItem('accessCodeValidated', 'true');
sessionStorage.setItem('accessCodeData', JSON.stringify({
code: accessCode.toUpperCase(),
empresa_id: res.data.empresa_id,
empresa_nome: res.data.empresa_nome
}));
} else if (type === 'professional') {
sessionStorage.setItem('professionalAccessValidated', 'true');
sessionStorage.setItem('professionalAccessData', JSON.stringify({
code: accessCode.toUpperCase()
}));
}
setShowAccessCodeModal(false);
setIsValidated(true);
// Se tem empresa e é cliente, atualizar URL com parâmetros
if (type === 'client' && res.data.empresa_id) {
window.history.replaceState({}, '', `${window.location.pathname}?empresa_id=${res.data.empresa_id}&empresa_nome=${encodeURIComponent(res.data.empresa_nome || '')}`);
}
} else {
setCodeError(res.data?.error || "Código de acesso inválido ou expirado");
}
} catch (e) {
setCodeError("Erro ao verificar código");
}
};
const resetCodeForm = () => {
setAccessCode("");
setCodeError("");
};
// Se ainda não foi validado, mostrar modal de código
if (!isValidated) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
{/* Modal de código de acesso */}
{showAccessCodeModal && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4"
onClick={() => setShowAccessCodeModal(false)}
>
<div
className="bg-white rounded-2xl shadow-2xl max-w-md w-full p-6 sm:p-8 transform transition-all duration-300 ease-out scale-100"
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900">
Código de Acesso
</h2>
<button
onClick={() => window.history.back()}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<X size={24} />
</button>
</div>
<p className="text-gray-600 mb-6">
Digite o código de acesso fornecido pela empresa para continuar
com o cadastro.
</p>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Código de Acesso *
</label>
<input
type="text"
value={accessCode}
onChange={(e) => {
setAccessCode(e.target.value.toUpperCase());
resetCodeForm();
}}
onKeyPress={(e) => e.key === "Enter" && handleVerifyCode()}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#B9CF33] focus:border-transparent uppercase"
placeholder="Digite o código"
autoFocus
/>
{codeError && (
<p className="text-red-500 text-sm mt-2">{codeError}</p>
)}
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<p className="text-sm text-yellow-800">
<strong>Atenção:</strong> O código de acesso é fornecido pela
empresa e tem validade definida pela empresa.
</p>
</div>
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => window.history.back()}
className="flex-1"
>
Cancelar
</Button>
<Button onClick={handleVerifyCode} className="flex-1">
Verificar
</Button>
</div>
</div>
</div>
)}
{/* Conteúdo de fundo quando modal não está visível */}
{!showAccessCodeModal && (
<div className="max-w-md text-center">
<div className="mb-8">
<div className="w-16 h-16 bg-[#492E61] rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Acesso Restrito</h1>
<p className="text-gray-600">
Esta página requer um código de acesso válido para continuar.
</p>
</div>
</div>
)}
</div>
);
}
return <>{children}</>;
};
// Componente de rota protegida
interface ProtectedRouteProps {
children: React.ReactNode;
@ -365,10 +550,21 @@ const AppContent: React.FC = () => {
{/* Rotas Públicas */}
<Route path="/" element={<HomeWithRouter />} />
<Route path="/entrar" element={<LoginWithRouter />} />
<Route path="/cadastro" element={<RegisterWithRouter />} />
<Route
path="/cadastro"
element={
<AccessCodeProtectedRoute type="client">
<RegisterWithRouter />
</AccessCodeProtectedRoute>
}
/>
<Route
path="/cadastro-profissional"
element={<ProfessionalRegisterWithRouter />}
element={
<AccessCodeProtectedRoute type="professional">
<ProfessionalRegisterWithRouter />
</AccessCodeProtectedRoute>
}
/>
<Route
path="/privacidade"

View file

@ -1968,12 +1968,12 @@
}
},
"node_modules/jws": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
"integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.0",
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
@ -2306,9 +2306,9 @@
}
},
"node_modules/react-router": {
"version": "7.9.6",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.6.tgz",
"integrity": "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA==",
"version": "7.12.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
"integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@ -2328,12 +2328,12 @@
}
},
"node_modules/react-router-dom": {
"version": "7.9.6",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.6.tgz",
"integrity": "sha512-2MkC2XSXq6HjGcihnx1s0DBWQETI4mlis4Ux7YTLvP67xnGxCvq+BcCQSO81qQHVUTM1V53tl4iVVaY5sReCOA==",
"version": "7.12.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz",
"integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==",
"license": "MIT",
"dependencies": {
"react-router": "7.9.6"
"react-router": "7.12.0"
},
"engines": {
"node": ">=20.0.0"

View file

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { createAccessCode, listAccessCodes, deleteAccessCode } from '../services/apiService';
import { Plus, Trash2, Copy, Check } from 'lucide-react';
import { createAccessCode, listAccessCodes, deleteAccessCode, getCompanies } from '../services/apiService';
import { Plus, Trash2, Copy, Check, ChevronDown } from 'lucide-react';
import { UserRole } from '../types';
interface AccessCode {
@ -13,6 +13,8 @@ interface AccessCode {
expira_em: string;
ativo: boolean;
usos: number;
empresa_id?: string;
empresa_nome?: string;
}
export const AccessCodes: React.FC = () => {
@ -20,17 +22,31 @@ export const AccessCodes: React.FC = () => {
const [codes, setCodes] = useState<AccessCode[]>([]);
const [loading, setLoading] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [companies, setCompanies] = useState<Array<{id: string; nome: string}>>([]);
// Form State
const [newCode, setNewCode] = useState('');
const [days, setDays] = useState(30);
const [customDays, setCustomDays] = useState('');
const [copiedId, setCopiedId] = useState<string | null>(null);
const [selectedCompanyId, setSelectedCompanyId] = useState<string>('');
useEffect(() => {
if (token) fetchCodes();
}, [token]);
const fetchCompanies = async () => {
const res = await getCompanies();
if (res.data) {
setCompanies(res.data);
}
};
const openCreateModal = () => {
setIsCreateModalOpen(true);
fetchCompanies();
};
const fetchCodes = async () => {
setLoading(true);
const res = await listAccessCodes(token!);
@ -44,17 +60,24 @@ export const AccessCodes: React.FC = () => {
e.preventDefault();
const codeToCreate = newCode.trim() || generateRandomCode();
const res = await createAccessCode(token!, {
const payload: any = {
codigo: codeToCreate,
validade_dias: days,
descricao: 'Gerado via Painel'
});
};
if (selectedCompanyId) {
payload.empresa_id = selectedCompanyId;
}
const res = await createAccessCode(token!, payload);
if (res.error) {
alert('Erro ao criar: ' + res.error);
} else {
setIsCreateModalOpen(false);
setNewCode('');
setSelectedCompanyId('');
fetchCodes();
}
};
@ -91,14 +114,15 @@ export const AccessCodes: React.FC = () => {
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="min-h-screen bg-gray-50 pt-20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="mb-8">
<h1 className="text-3xl font-serif font-bold text-gray-900 mb-2">Gerenciar Códigos de Acesso</h1>
<p className="text-gray-600">Crie e gerencie códigos de acesso temporários para novos cadastros</p>
<h1 className="text-3xl font-bold text-gray-900">Gerenciar Códigos de Acesso</h1>
<p className="mt-2 text-gray-600">Crie e gerencie códigos de acesso temporários para novos cadastros</p>
</div>
<button
onClick={() => setIsCreateModalOpen(true)}
onClick={openCreateModal}
className="bg-[#492E61] text-white px-6 py-3 rounded-lg font-medium hover:bg-[#5a3a7a] transition-colors flex items-center gap-2 mb-8 shadow-sm"
>
<Plus size={20} />
@ -112,6 +136,7 @@ export const AccessCodes: React.FC = () => {
<tr>
<th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">Código</th>
<th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">Empresa</th>
<th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">Validade (Dias)</th>
<th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">Data de Criação</th>
<th className="px-6 py-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">Data de Expiração</th>
@ -120,9 +145,9 @@ export const AccessCodes: React.FC = () => {
</thead>
<tbody className="divide-y divide-gray-100">
{loading ? (
<tr><td colSpan={6} className="px-6 py-8 text-center text-gray-500">Carregando...</td></tr>
<tr><td colSpan={7} className="px-6 py-8 text-center text-gray-500">Carregando...</td></tr>
) : codes.length === 0 ? (
<tr><td colSpan={6} className="px-6 py-8 text-center text-gray-500">Nenhum código gerado.</td></tr>
<tr><td colSpan={7} className="px-6 py-8 text-center text-gray-500">Nenhum código gerado.</td></tr>
) : (
codes.map(code => (
<tr key={code.id} className="hover:bg-gray-50 transition-colors">
@ -143,6 +168,9 @@ export const AccessCodes: React.FC = () => {
</button>
</div>
</td>
<td className="px-6 py-4 text-gray-600">
{code.empresa_nome || <span className="text-gray-400 italic">Todas as empresas</span>}
</td>
<td className="px-6 py-4 text-gray-600">{code.validade_dias === -1 ? 'Nunca expira' : `${code.validade_dias} dias`}</td>
<td className="px-6 py-4 text-sm text-gray-500">{formatDate(code.criado_em)}</td>
<td className="px-6 py-4 text-sm text-gray-500">{code.validade_dias === -1 ? '-' : formatDate(code.expira_em)}</td>
@ -180,6 +208,22 @@ export const AccessCodes: React.FC = () => {
/>
<p className="text-xs text-gray-500 mt-1">Deixe em branco para gerar automaticamente.</p>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">Empresa (Opcional)</label>
<select
value={selectedCompanyId}
onChange={e => setSelectedCompanyId(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-[#492E61] focus:border-transparent outline-none appearance-none bg-white"
>
<option value="">Todas as empresas</option>
{companies.map(company => (
<option key={company.id} value={company.id}>
{company.nome}
</option>
))}
</select>
<p className="text-xs text-gray-500 mt-1">Deixe em branco para permitir uso por todas as empresas.</p>
</div>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-1">Validade</label>
<div className="space-y-3">
@ -225,7 +269,10 @@ export const AccessCodes: React.FC = () => {
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => setIsCreateModalOpen(false)}
onClick={() => {
setIsCreateModalOpen(false);
setSelectedCompanyId('');
}}
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg"
>
Cancelar
@ -241,6 +288,7 @@ export const AccessCodes: React.FC = () => {
</div>
</div>
)}
</div>
</div>
);
};

View file

@ -21,6 +21,7 @@ export const Login: React.FC<LoginProps> = ({ onNavigate }) => {
const [showAccessCodeModal, setShowAccessCodeModal] = useState(false);
const [accessCode, setAccessCode] = useState("");
const [showProfessionalPrompt, setShowProfessionalPrompt] = useState(false);
const [selectedCadastroType, setSelectedCadastroType] = useState<'professional' | 'client' | null>(null);
const [codeError, setCodeError] = useState("");
// const MOCK_ACCESS_CODE = "PHOTUM2025"; // Removed mock
@ -39,8 +40,27 @@ export const Login: React.FC<LoginProps> = ({ onNavigate }) => {
try {
const res = await verifyAccessCode(accessCode.toUpperCase());
if (res.data && res.data.valid) {
// Marcar na sessão que o código foi validado
sessionStorage.setItem('accessCodeValidated', 'true');
sessionStorage.setItem('accessCodeData', JSON.stringify({
code: accessCode.toUpperCase(),
empresa_id: res.data.empresa_id,
empresa_nome: res.data.empresa_nome
}));
setShowAccessCodeModal(false);
window.location.href = "/cadastro";
// Redirecionar baseado no tipo de cadastro selecionado
if (selectedCadastroType === 'professional') {
window.location.href = "/cadastro-profissional";
} else {
// Para cliente, se o código tem empresa associada, passa na URL
if (res.data.empresa_id) {
window.location.href = `/cadastro?empresa_id=${res.data.empresa_id}&empresa_nome=${encodeURIComponent(res.data.empresa_nome || '')}`;
} else {
window.location.href = "/cadastro";
}
}
} else {
setCodeError(res.data?.error || "Código de acesso inválido ou expirado");
}
@ -51,11 +71,8 @@ export const Login: React.FC<LoginProps> = ({ onNavigate }) => {
const handleProfessionalChoice = (isProfessional: boolean) => {
setShowProfessionalPrompt(false);
if (isProfessional) {
window.location.href = "/cadastro-profissional";
} else {
setShowAccessCodeModal(true);
}
setSelectedCadastroType(isProfessional ? 'professional' : 'client');
setShowAccessCodeModal(true);
};
const handleLogin = async (e: React.FormEvent) => {
@ -349,14 +366,17 @@ export const Login: React.FC<LoginProps> = ({ onNavigate }) => {
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<p className="text-sm text-yellow-800">
<strong>Atenção:</strong> O código de acesso é fornecido pela
empresa e tem validade temporária.
empresa e tem validade definida pela empresa.
</p>
</div>
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => setShowAccessCodeModal(false)}
onClick={() => {
setShowAccessCodeModal(false);
setSelectedCadastroType(null);
}}
className="flex-1"
>
Cancelar

View file

@ -98,6 +98,9 @@ export const ProfessionalRegister: React.FC<ProfessionalRegisterProps> = ({
}
console.log("Profissional cadastrado com sucesso!");
// Limpar dados de sessão após cadastro bem-sucedido
sessionStorage.removeItem('professionalAccessValidated');
sessionStorage.removeItem('professionalAccessData');
setIsSuccess(true);
} catch (error: any) {
console.error("Erro ao cadastrar profissional:", error);

View file

@ -39,6 +39,19 @@ export const Register: React.FC<RegisterProps> = ({ onNavigate }) => {
loadCompanies();
}, []);
// Verifica se tem empresa pré-selecionada via URL (código de acesso)
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const empresaId = urlParams.get('empresa_id');
const empresaNome = urlParams.get('empresa_nome');
if (empresaId) {
setFormData(prev => ({ ...prev, empresaId }));
// Limpa os parâmetros da URL após usar
window.history.replaceState({}, document.title, window.location.pathname);
}
}, [companies]);
const handleChange = (field: string, value: string | boolean) => {
setFormData((prev) => ({ ...prev, [field]: value }));
setError("");
@ -78,6 +91,9 @@ export const Register: React.FC<RegisterProps> = ({ onNavigate }) => {
role: "EVENT_OWNER", // Client Role
empresaId: formData.empresaId,
});
// Limpar dados de sessão após cadastro bem-sucedido
sessionStorage.removeItem('accessCodeValidated');
sessionStorage.removeItem('accessCodeData');
setIsLoading(false);
setIsPending(true);
} catch (err: any) {
@ -230,6 +246,11 @@ export const Register: React.FC<RegisterProps> = ({ onNavigate }) => {
administração e selecione <strong>"Não Cadastrado"</strong>{" "}
abaixo.
</p>
{formData.empresaId && companies.find(c => c.id === formData.empresaId) && (
<p className="text-[10px] text-green-600 mb-2 leading-tight">
Empresa pré-selecionada baseada no seu código de acesso
</p>
)}
{isLoadingCompanies ? (
<p className="text-xs sm:text-sm text-gray-500">
Carregando empresas...
@ -241,8 +262,11 @@ export const Register: React.FC<RegisterProps> = ({ onNavigate }) => {
onChange={(e) => handleChange("empresaId", e.target.value)}
className="w-full px-2.5 sm:px-3 md:px-4 py-1.5 sm:py-2 text-xs sm:text-sm border border-gray-300 rounded-lg focus:ring-2 focus:border-transparent"
style={{ focusRing: "2px solid #B9CF33" }}
disabled={!!formData.empresaId && !!companies.find(c => c.id === formData.empresaId)}
>
<option value="">Selecione uma empresa</option>
<option value="">
{formData.empresaId ? "Empresa selecionada" : "Selecione uma empresa"}
</option>
{companies
.sort((a, b) => {
const nameA = a.nome.toLowerCase();

View file

@ -847,7 +847,7 @@ export async function getUploadURL(filename: string, contentType: string): Promi
}
// Access Codes
export async function createAccessCode(token: string, data: { codigo: string; descricao?: string; validade_dias: number }) {
export async function createAccessCode(token: string, data: { codigo: string; descricao?: string; validade_dias: number; empresa_id?: string }) {
return mutationFetch(`${API_BASE_URL}/api/codigos-acesso`, "POST", data, token);
}
@ -1088,6 +1088,6 @@ export async function listPassengers(carroId: string, token: string) {
/**
* Verifica se um código de acesso é válido
*/
export async function verifyAccessCode(code: string): Promise<ApiResponse<{ valid: boolean; error?: string }>> {
return fetchFromBackend<{ valid: boolean; error?: string }>(`/api/public/codigos-acesso/verificar?code=${encodeURIComponent(code)}`);
export async function verifyAccessCode(code: string): Promise<ApiResponse<{ valid: boolean; error?: string; empresa_id?: string; empresa_nome?: string }>> {
return fetchFromBackend<{ valid: boolean; error?: string; empresa_id?: string; empresa_nome?: string }>(`/api/public/codigos-acesso/verificar?code=${encodeURIComponent(code)}`);
}