chore: add automated database backup service and tighten backend security
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m56s

- Added `databack/mysql-backup` service to the production docker-compose Swarm stack, scheduling a daily 02:55 AM cron backup of the database with a 3-day local retention policy.

- Fixed a critical race condition in the backend JWT authentication middleware where an invalid token returning 401 could crash the response flow if the route executed before the defensive checks caught it.

- Added strict undefined defensive checks to the `getUserById` endpoint and RBAC middleware to gracefully reject requests that somehow bypass the token parser.

- Updated `GEMINI.md` technical documentation to fully match the real codebase logic.

- Fixed UX rule to prevent `manager` role from seeing Funnels or Origins tabs in the sidebar.

- Blocked `agent` role from modifying their own 'fullName' string in the Profile UI.
This commit is contained in:
Cauê Faleiros
2026-03-25 12:40:53 -03:00
parent 3663d03cb9
commit 9ffcfcdcc8
6 changed files with 193 additions and 67 deletions

160
App.tsx
View File

@@ -1,34 +1,48 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { HashRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom'; import {
import { Layout } from './components/Layout'; HashRouter as Router,
import { Dashboard } from './pages/Dashboard'; Routes,
import { UserDetail } from './pages/UserDetail'; Route,
import { AttendanceDetail } from './pages/AttendanceDetail'; Navigate,
import { SuperAdmin } from './pages/SuperAdmin'; useLocation,
import { ApiKeys } from './pages/ApiKeys'; } from "react-router-dom";
import { TeamManagement } from './pages/TeamManagement'; import { Layout } from "./components/Layout";
import { Teams } from './pages/Teams'; import { Dashboard } from "./pages/Dashboard";
import { Funnels } from './pages/Funnels'; import { UserDetail } from "./pages/UserDetail";
import { Origins } from './pages/Origins'; import { AttendanceDetail } from "./pages/AttendanceDetail";
import { Login } from './pages/Login'; import { SuperAdmin } from "./pages/SuperAdmin";
import { ForgotPassword } from './pages/ForgotPassword'; import { ApiKeys } from "./pages/ApiKeys";
import { ResetPassword } from './pages/ResetPassword'; import { TeamManagement } from "./pages/TeamManagement";
import { SetupAccount } from './pages/SetupAccount'; import { Teams } from "./pages/Teams";
import { UserProfile } from './pages/UserProfile'; import { Funnels } from "./pages/Funnels";
import { getUserById, logout } from './services/dataService'; import { Origins } from "./pages/Origins";
import { User } from './types'; import { Login } from "./pages/Login";
import { ForgotPassword } from "./pages/ForgotPassword";
import { ResetPassword } from "./pages/ResetPassword";
import { SetupAccount } from "./pages/SetupAccount";
import { UserProfile } from "./pages/UserProfile";
import { getUserById, logout } from "./services/dataService";
import { User } from "./types";
const AuthGuard: React.FC<{ children: React.ReactNode, roles?: string[] }> = ({ children, roles }) => { const AuthGuard: React.FC<{ children: React.ReactNode; roles?: string[] }> = ({
children,
roles,
}) => {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const location = useLocation(); const location = useLocation();
useEffect(() => { useEffect(() => {
const checkAuth = async () => { const checkAuth = async () => {
const storedUserId = localStorage.getItem('ctms_user_id'); const storedUserId = localStorage.getItem("ctms_user_id");
const storedToken = localStorage.getItem('ctms_token'); const storedToken = localStorage.getItem("ctms_token");
if (!storedUserId || !storedToken || storedToken === 'undefined' || storedToken === 'null') { if (
!storedUserId ||
!storedToken ||
storedToken === "undefined" ||
storedToken === "null"
) {
if (storedToken) logout(); // Limpar se for "undefined" string if (storedToken) logout(); // Limpar se for "undefined" string
setLoading(false); setLoading(false);
return; return;
@@ -37,7 +51,7 @@ const AuthGuard: React.FC<{ children: React.ReactNode, roles?: string[] }> = ({
try { try {
const fetchedUser = await getUserById(storedUserId); const fetchedUser = await getUserById(storedUserId);
if (fetchedUser) { if (fetchedUser) {
if (fetchedUser.status === 'active') { if (fetchedUser.status === "active") {
setUser(fetchedUser); setUser(fetchedUser);
} else { } else {
// User explicitly marked inactive or deleted // User explicitly marked inactive or deleted
@@ -66,7 +80,11 @@ const AuthGuard: React.FC<{ children: React.ReactNode, roles?: string[] }> = ({
}, [location.pathname]); }, [location.pathname]);
if (loading) { if (loading) {
return <div className="flex h-screen items-center justify-center bg-zinc-50 dark:bg-zinc-950 text-zinc-400">Carregando...</div>; return (
<div className="flex h-screen items-center justify-center bg-zinc-50 dark:bg-zinc-950 text-zinc-400">
Carregando...
</div>
);
} }
if (!user) { if (!user) {
@@ -78,7 +96,7 @@ const AuthGuard: React.FC<{ children: React.ReactNode, roles?: string[] }> = ({
} }
// Auto-redirect Super Admins away from the standard dashboard to their specific panel // Auto-redirect Super Admins away from the standard dashboard to their specific panel
if (location.pathname === '/' && user.role === 'super_admin') { if (location.pathname === "/" && user.role === "super_admin") {
return <Navigate to="/super-admin" replace />; return <Navigate to="/super-admin" replace />;
} }
@@ -93,16 +111,86 @@ const App: React.FC = () => {
<Route path="/forgot-password" element={<ForgotPassword />} /> <Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} /> <Route path="/reset-password" element={<ResetPassword />} />
<Route path="/setup-account" element={<SetupAccount />} /> <Route path="/setup-account" element={<SetupAccount />} />
<Route path="/" element={<AuthGuard><Dashboard /></AuthGuard>} /> <Route
<Route path="/admin/users" element={<AuthGuard roles={['super_admin', 'admin', 'manager']}><TeamManagement /></AuthGuard>} /> path="/"
<Route path="/admin/teams" element={<AuthGuard roles={['super_admin', 'admin', 'manager']}><Teams /></AuthGuard>} /> element={
<Route path="/admin/funnels" element={<AuthGuard roles={['super_admin', 'admin', 'manager']}><Funnels /></AuthGuard>} /> <AuthGuard>
<Route path="/admin/origins" element={<AuthGuard roles={['super_admin', 'admin', 'manager']}><Origins /></AuthGuard>} /> <Dashboard />
<Route path="/users/:id" element={<AuthGuard><UserDetail /></AuthGuard>} /> </AuthGuard>
<Route path="/attendances/:id" element={<AuthGuard><AttendanceDetail /></AuthGuard>} /> }
<Route path="/super-admin" element={<AuthGuard roles={['super_admin']}><SuperAdmin /></AuthGuard>} /> />
<Route path="/super-admin/api-keys" element={<AuthGuard roles={['super_admin']}><ApiKeys /></AuthGuard>} /> <Route
<Route path="/profile" element={<AuthGuard><UserProfile /></AuthGuard>} /> path="/admin/users"
element={
<AuthGuard roles={["super_admin", "admin", "manager"]}>
<TeamManagement />
</AuthGuard>
}
/>
<Route
path="/admin/teams"
element={
<AuthGuard roles={["super_admin", "admin", "manager"]}>
<Teams />
</AuthGuard>
}
/>
<Route
path="/admin/funnels"
element={
<AuthGuard roles={["super_admin", "admin"]}>
<Funnels />
</AuthGuard>
}
/>
<Route
path="/admin/origins"
element={
<AuthGuard roles={["super_admin", "admin"]}>
<Origins />
</AuthGuard>
}
/>
<Route
path="/users/:id"
element={
<AuthGuard>
<UserDetail />
</AuthGuard>
}
/>
<Route
path="/attendances/:id"
element={
<AuthGuard>
<AttendanceDetail />
</AuthGuard>
}
/>
<Route
path="/super-admin"
element={
<AuthGuard roles={["super_admin"]}>
<SuperAdmin />
</AuthGuard>
}
/>
<Route
path="/super-admin/api-keys"
element={
<AuthGuard roles={["super_admin"]}>
<ApiKeys />
</AuthGuard>
}
/>
<Route
path="/profile"
element={
<AuthGuard>
<UserProfile />
</AuthGuard>
}
/>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</Router> </Router>

View File

@@ -7,38 +7,44 @@ Fasto is a commercial team management system built with React (Vite) on the fron
We have transitioned from a mock-based prototype to a **secure, multi-tenant production architecture**: We have transitioned from a mock-based prototype to a **secure, multi-tenant production architecture**:
- **Multi-Tenancy & Data Isolation:** All backend routes (Users, Teams, Attendances) now strictly enforce `tenant_id` checks. It is technically impossible for one organization to query data from another. - **Multi-Tenancy & Data Isolation:** All backend routes (Users, Teams, Attendances) now strictly enforce `tenant_id` checks. It is technically impossible for one organization to query data from another.
- **God Mode (Tenant Impersonation):** Super Admins can securely impersonate Tenant Admins via a specialized, temporary JWT (`/api/impersonate/:tenantId`). This allows seamless cross-domain support without storing passwords, while strictly maintaining state isolation through forced React reloads and locking mechanisms. - **Advanced 2-Token Authentication (Rolling Sessions):**
- **Dynamic Funnel Customization:** - Replaced the vulnerable 1-year static JWT with a highly secure dual-token system.
- Funnel stages are no longer hardcoded ENUMs. Each tenant can create multiple dynamic funnels via the `funnels` and `funnel_stages` tables. - Generates a short-lived `AccessToken` (15 min) and a stateful `RefreshToken` (30 days) stored in the DB (`refresh_tokens` table).
- Managers can assign specific Teams to specific Funnels. - Built an Axios-like `apiFetch` interceptor on the frontend that automatically catches 401 Unauthorized errors, fetches a new Access Token in the background, extends the Refresh Token by another 30 days (Sliding Expiration), and retries the original request without logging the user out.
- The Dashboard, User Detail, and Attendance Detail pages now dynamically map and color-code stages based on the active team's assigned funnel. - Full remote revocation capability (Logout drops the token from the DB immediately).
- **Real-Time Notification System:** - **God Mode (Tenant Impersonation):** Super Admins can securely impersonate Tenant Admins via a specialized, temporary JWT (`/api/impersonate/:tenantId`). This allows seamless cross-domain support without storing passwords.
- Built a persistent notification tray (`/api/notifications`) with real-time polling (10s intervals) and a hidden HTML5 `<audio>` player for cross-browser sound playback. - **Role-Based Access Control (RBAC) Simplification:**
- Automated Triggers: Super Admins are notified of new organizations; Tenant Admins/Managers are notified of new user setups; Users are notified of team assignment changes. - Removed the redundant 'owner' role. The system now strictly relies on 4 tiers:
- **Role-Based Access Control (RBAC):** - **Super Admin:** Global management of all tenants and API keys (via the hidden `system` tenant).
- **Super Admin:** Global management of all tenants and users (via the hidden `system` tenant). - **Admin:** Full control over members, teams, funnels, and origins within their specific organization.
- **Admin/Manager:** Full control over members and teams within their specific organization. - **Manager:** Mid-level control. Can edit basic info of users in their specific team, but cannot change user roles or re-assign users to different teams (only Admins can).
- **Agent:** Restricted access. Can only view their own performance metrics and historical attendances. - **Agent:** Restricted access. Can only view their own performance metrics and historical attendances.
- **Dynamic Funnel & Origin Managers:**
- Funnel stages and Lead Origins are no longer hardcoded ENUMs. Each tenant can create multiple dynamic funnel/origin groups via relational tables (`funnels`, `funnel_stages`, `origin_groups`, `origin_items`).
- Admins can customize the exact Tailwind color class (e.g., "bg-green-100") for each stage and origin via visual UI pickers.
- Admins assign specific Teams to specific Funnels/Origin Groups.
- The Dashboard pie charts and data tables strictly filter and color-code data based on the active team's configuration. Deleted data falls back to an "Outros" category to prevent chart breakage.
- **n8n / External API Webhooks (Completed):**
- Super Admins can generate persistent `api_keys` for specific tenants.
- `GET /api/integration/users`, `/funnels`, and `/origins` allow the n8n AI to dynamically map the tenant's actual agents and workflow stages before processing a chat.
- `POST /api/integration/attendances` accepts the AI's final JSON payload (including the `full_summary` text) and injects it directly into the dashboard.
- **Real-Time Notification System:**
- Built a persistent notification tray (`/api/notifications`) with real-time polling (10s intervals) and a hidden HTML5 `<audio>` player for cross-browser sound playback (custom `.mp3` loaded via Vite).
- Automated Triggers: Super Admins are notified of new organizations; Admins/Super Admins are notified of new user setups; Agents are notified of team assignment changes; Managers get "Venda Fechada" alerts when n8n posts a converted lead.
- **Enhanced UI/UX:** - **Enhanced UI/UX:**
- Premium "Onyx & Gold" True Black dark mode. - Premium "Onyx & Gold" True Black dark mode (Zinc scale).
- Fully collapsible interactive sidebar with memory (`localStorage`). - Fully collapsible interactive sidebar with memory (`localStorage`).
- Action buttons across all data tables are permanently visible for faster discoverability (removed hover requirements). - All Date/Time displays localized to strict Brazilian formatting (`pt-BR`, 24h, `DD/MM/YY`).
- Loading states embedded directly into action buttons to prevent double-submissions.
- **Secure File Uploads:** Profile avatars use `multer` with strict mimetype validation (JPG/PNG/WEBP), 2MB size limits, and UUID generation.
## 📌 Roadmap / To-Do ## 📌 Roadmap / To-Do
- [ ] **n8n / External API Integration (Priority 1):** - [ ] **Advanced AI Notification Triggers:** Implement backend logic to automatically notify Managers when an attendance payload from n8n receives a critically low quality score (`score < 50`), or breaches a specific Response Time SLA (e.g., `first_response_time_min > 60`).
- Create an `api_keys` table to allow generating persistent, secure API keys for each Tenant.
- Build `GET /api/integration/users` so n8n can map its chat agents to Fasto's internal `user_id`s.
- Build `POST /api/integration/attendances` to allow n8n to programmatically create new attendances linked to specific tenants and users.
- [ ] **Sales & Quality Notification Triggers:** Implement backend logic to automatically notify Managers when an attendance is marked as "Won" (Ganhos), receives a critically low quality score, or breaches a specific Response Time SLA.
- [ ] **Data Export/Reporting:** Allow Admins to export attendance and KPI data to CSV/Excel. - [ ] **Data Export/Reporting:** Allow Admins to export attendance and KPI data to CSV/Excel.
- [ ] **Billing/Subscription Management:** Integrate a payment gateway (e.g., Stripe/Asaas) to manage tenant trial periods and active statuses dynamically. - [ ] **Billing/Subscription Management:** Integrate a payment gateway (e.g., Stripe/Asaas) to manage tenant trial periods and active statuses dynamically.
## 🛠 Architecture ## 🛠 Architecture
- **Frontend**: React 19, TypeScript, Vite, TailwindCSS (CDN), Recharts, Lucide React. - **Frontend**: React 19, TypeScript, Vite, TailwindCSS (CDN), Recharts, Lucide React.
- **Backend**: Node.js, Express, MySQL2 (Pool-based), Nodemailer. - **Backend**: Node.js, Express, MySQL2 (Pool-based), Nodemailer.
- **Database**: MySQL 8.0 (Schema: `fasto_db`). - **Database**: MySQL 8.0 (Schema: `fasto_db` or `agenciac_comia` depending on `.env`).
- **Deployment**: Docker Compose for local development; Gitea Actions for CI/CD pushing to a Gitea Registry and deploying via Portainer webhook. - **Deployment**: Docker Compose for local development; Gitea Actions for CI/CD pushing to a Gitea Registry and deploying via Portainer webhook.
## 📋 Prerequisites ## 📋 Prerequisites
@@ -55,7 +61,7 @@ cp .env.example .env
*Note:* The backend automatically strips literal quotes from Docker `.env` string values (like `SMTP_PASS`) to prevent authentication crashes. *Note:* The backend automatically strips literal quotes from Docker `.env` string values (like `SMTP_PASS`) to prevent authentication crashes.
### 2. Database ### 2. Database
The project expects a MySQL database. The `docker-compose.local.yml` initializes it. The Node.js backend automatically runs non-destructive schema migrations on startup (adding tables like `notifications`, `funnels`, `funnel_stages`, and modifying `attendances`). The project expects a MySQL database. The Node.js backend automatically runs non-destructive schema migrations on startup (adding tables like `refresh_tokens`, `api_keys`, `origin_groups`, etc.).
### 3. Running Locally (Docker Compose) ### 3. Running Locally (Docker Compose)
To start the application and database locally: To start the application and database locally:

View File

@@ -136,14 +136,17 @@ const authenticateToken = async (req, res, next) => {
if (!token) return res.status(401).json({ error: 'Token não fornecido.' }); if (!token) return res.status(401).json({ error: 'Token não fornecido.' });
jwt.verify(token, JWT_SECRET, (err, user) => { try {
if (err) return res.status(401).json({ error: 'Token inválido ou expirado.' }); const user = jwt.verify(token, JWT_SECRET);
req.user = user; req.user = user;
next(); next();
}); } catch (err) {
return res.status(401).json({ error: 'Token inválido ou expirado.' });
}
}; };
const requireRole = (roles) => (req, res, next) => { const requireRole = (roles) => (req, res, next) => {
if (!req.user || !req.user.role) return res.status(401).json({ error: 'Não autenticado.' });
if (!roles.includes(req.user.role)) return res.status(403).json({ error: 'Acesso negado. Você não tem permissão para realizar esta ação.' }); if (!roles.includes(req.user.role)) return res.status(403).json({ error: 'Acesso negado. Você não tem permissão para realizar esta ação.' });
next(); next();
}; };
@@ -457,7 +460,7 @@ apiRouter.get('/users/:idOrSlug', async (req, res) => {
try { try {
const [rows] = await pool.query('SELECT * FROM users WHERE id = ? OR slug = ?', [req.params.idOrSlug, req.params.idOrSlug]); const [rows] = await pool.query('SELECT * FROM users WHERE id = ? OR slug = ?', [req.params.idOrSlug, req.params.idOrSlug]);
if (!rows || rows.length === 0) return res.status(404).json({ error: 'Not found' }); if (!rows || rows.length === 0) return res.status(404).json({ error: 'Not found' });
if (!req.user) return res.status(401).json({ error: 'Não autenticado' }); if (!req.user || !req.user.role) return res.status(401).json({ error: 'Não autenticado' });
if (req.user.role !== 'super_admin' && rows[0].tenant_id !== req.user.tenant_id) { if (req.user.role !== 'super_admin' && rows[0].tenant_id !== req.user.tenant_id) {
return res.status(403).json({ error: 'Acesso negado.' }); return res.status(403).json({ error: 'Acesso negado.' });

View File

@@ -239,12 +239,16 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
<> <>
<SidebarItem to="/admin/users" icon={Users} label="Membros" collapsed={isSidebarCollapsed} /> <SidebarItem to="/admin/users" icon={Users} label="Membros" collapsed={isSidebarCollapsed} />
<SidebarItem to="/admin/teams" icon={Building2} label={currentUser.role === 'manager' ? 'Meu Time' : 'Times'} collapsed={isSidebarCollapsed} /> <SidebarItem to="/admin/teams" icon={Building2} label={currentUser.role === 'manager' ? 'Meu Time' : 'Times'} collapsed={isSidebarCollapsed} />
{currentUser.role !== 'manager' && (
<>
<SidebarItem to="/admin/funnels" icon={Layers} label="Gerenciar Funis" collapsed={isSidebarCollapsed} /> <SidebarItem to="/admin/funnels" icon={Layers} label="Gerenciar Funis" collapsed={isSidebarCollapsed} />
<SidebarItem to="/admin/origins" icon={Target} label="Origens de Lead" collapsed={isSidebarCollapsed} /> <SidebarItem to="/admin/origins" icon={Target} label="Origens de Lead" collapsed={isSidebarCollapsed} />
</> </>
)} )}
</> </>
)} )}
</>
)}
{/* Super Admin Links */} {/* Super Admin Links */}
{isSuperAdmin && ( {isSuperAdmin && (

View File

@@ -1,4 +1,4 @@
version: '3.8' version: "3.8"
services: services:
app: app:
@@ -43,6 +43,23 @@ services:
networks: networks:
- fasto-net - fasto-net
backup-mysql:
image: databack/mysql-backup
environment:
DB_SERVER: db
DB_USER: root
DB_PASS: ${DB_PASSWORD:-root_password}
DB_DUMP_CRON: "55 2 * * *" # Roda todo dia exatamente às 02:55 da manhã
DB_CLEANUP_TIME: 4320 # Apaga os locais mais velhos que 3 dias
volumes:
- /root/backups_db:/db
networks:
- fasto-net
deploy:
placement:
constraints:
- node.role == manager
volumes: volumes:
db_data: db_data:

View File

@@ -202,9 +202,17 @@ export const UserProfile: React.FC = () => {
id="fullName" id="fullName"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
className="block w-full pl-10 pr-3 py-2 border border-zinc-200 dark:border-zinc-800 rounded-lg bg-white dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none focus:ring-2 focus:ring-yellow-400/20 focus:border-yellow-400 transition-all sm:text-sm" disabled={user.role === 'agent'}
className={`block w-full pl-10 pr-3 py-2 border border-zinc-200 dark:border-zinc-800 rounded-lg sm:text-sm transition-all ${
user.role === 'agent'
? 'bg-zinc-50 dark:bg-zinc-900/50 text-zinc-500 dark:text-zinc-500 cursor-not-allowed'
: 'bg-white dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none focus:ring-2 focus:ring-yellow-400/20 focus:border-yellow-400'
}`}
/> />
</div> </div>
{user.role === 'agent' && (
<p className="text-xs text-zinc-400 dark:text-zinc-500 mt-1">Contate um administrador para alterar seu nome.</p>
)}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">