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 { HashRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { Layout } from './components/Layout';
import { Dashboard } from './pages/Dashboard';
import { UserDetail } from './pages/UserDetail';
import { AttendanceDetail } from './pages/AttendanceDetail';
import { SuperAdmin } from './pages/SuperAdmin';
import { ApiKeys } from './pages/ApiKeys';
import { TeamManagement } from './pages/TeamManagement';
import { Teams } from './pages/Teams';
import { Funnels } from './pages/Funnels';
import { Origins } from './pages/Origins';
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';
import React, { useState, useEffect } from "react";
import {
HashRouter as Router,
Routes,
Route,
Navigate,
useLocation,
} from "react-router-dom";
import { Layout } from "./components/Layout";
import { Dashboard } from "./pages/Dashboard";
import { UserDetail } from "./pages/UserDetail";
import { AttendanceDetail } from "./pages/AttendanceDetail";
import { SuperAdmin } from "./pages/SuperAdmin";
import { ApiKeys } from "./pages/ApiKeys";
import { TeamManagement } from "./pages/TeamManagement";
import { Teams } from "./pages/Teams";
import { Funnels } from "./pages/Funnels";
import { Origins } from "./pages/Origins";
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 [loading, setLoading] = useState(true);
const location = useLocation();
useEffect(() => {
const checkAuth = async () => {
const storedUserId = localStorage.getItem('ctms_user_id');
const storedToken = localStorage.getItem('ctms_token');
const storedUserId = localStorage.getItem("ctms_user_id");
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
setLoading(false);
return;
@@ -37,7 +51,7 @@ const AuthGuard: React.FC<{ children: React.ReactNode, roles?: string[] }> = ({
try {
const fetchedUser = await getUserById(storedUserId);
if (fetchedUser) {
if (fetchedUser.status === 'active') {
if (fetchedUser.status === "active") {
setUser(fetchedUser);
} else {
// User explicitly marked inactive or deleted
@@ -66,7 +80,11 @@ const AuthGuard: React.FC<{ children: React.ReactNode, roles?: string[] }> = ({
}, [location.pathname]);
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) {
@@ -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
if (location.pathname === '/' && user.role === 'super_admin') {
if (location.pathname === "/" && user.role === "super_admin") {
return <Navigate to="/super-admin" replace />;
}
@@ -93,16 +111,86 @@ const App: React.FC = () => {
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/setup-account" element={<SetupAccount />} />
<Route path="/" element={<AuthGuard><Dashboard /></AuthGuard>} />
<Route 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', 'manager']}><Funnels /></AuthGuard>} />
<Route path="/admin/origins" element={<AuthGuard roles={['super_admin', 'admin', 'manager']}><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={
<AuthGuard>
<Dashboard />
</AuthGuard>
}
/>
<Route
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 />} />
</Routes>
</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**:
- **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.
- **Dynamic Funnel Customization:**
- Funnel stages are no longer hardcoded ENUMs. Each tenant can create multiple dynamic funnels via the `funnels` and `funnel_stages` tables.
- Managers can assign specific Teams to specific Funnels.
- The Dashboard, User Detail, and Attendance Detail pages now dynamically map and color-code stages based on the active team's assigned funnel.
- **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.
- 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.
- **Role-Based Access Control (RBAC):**
- **Super Admin:** Global management of all tenants and users (via the hidden `system` tenant).
- **Admin/Manager:** Full control over members and teams within their specific organization.
- **Advanced 2-Token Authentication (Rolling Sessions):**
- Replaced the vulnerable 1-year static JWT with a highly secure dual-token system.
- Generates a short-lived `AccessToken` (15 min) and a stateful `RefreshToken` (30 days) stored in the DB (`refresh_tokens` table).
- 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.
- Full remote revocation capability (Logout drops the token from the DB immediately).
- **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.
- **Role-Based Access Control (RBAC) Simplification:**
- Removed the redundant 'owner' role. The system now strictly relies on 4 tiers:
- **Super Admin:** Global management of all tenants and API keys (via the hidden `system` tenant).
- **Admin:** Full control over members, teams, funnels, and origins 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.
- **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:**
- Premium "Onyx & Gold" True Black dark mode.
- Premium "Onyx & Gold" True Black dark mode (Zinc scale).
- Fully collapsible interactive sidebar with memory (`localStorage`).
- Action buttons across all data tables are permanently visible for faster discoverability (removed hover requirements).
- 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.
- All Date/Time displays localized to strict Brazilian formatting (`pt-BR`, 24h, `DD/MM/YY`).
## 📌 Roadmap / To-Do
- [ ] **n8n / External API Integration (Priority 1):**
- 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.
- [ ] **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`).
- [ ] **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.
## 🛠 Architecture
- **Frontend**: React 19, TypeScript, Vite, TailwindCSS (CDN), Recharts, Lucide React.
- **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.
## 📋 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.
### 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)
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.' });
jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) return res.status(401).json({ error: 'Token inválido ou expirado.' });
try {
const user = jwt.verify(token, JWT_SECRET);
req.user = user;
next();
});
} catch (err) {
return res.status(401).json({ error: 'Token inválido ou expirado.' });
}
};
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.' });
next();
};
@@ -457,7 +460,7 @@ apiRouter.get('/users/:idOrSlug', async (req, res) => {
try {
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 (!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) {
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/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/origins" icon={Target} label="Origens de Lead" collapsed={isSidebarCollapsed} />
</>
)}
</>
)}
</>
)}
{/* Super Admin Links */}
{isSuperAdmin && (

View File

@@ -1,4 +1,4 @@
version: '3.8'
version: "3.8"
services:
app:
@@ -43,6 +43,23 @@ services:
networks:
- 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:
db_data:

View File

@@ -202,9 +202,17 @@ export const UserProfile: React.FC = () => {
id="fullName"
value={name}
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>
{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 className="space-y-2">