Compare commits

..

17 Commits

Author SHA1 Message Date
Cauê Faleiros
509ed4a0d9 update docker compose again uhuu
All checks were successful
Build and Deploy / build-and-push (push) Successful in 57s
2026-03-25 16:43:07 -03:00
07cb43d0e3 revert b8541c0d24
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m2s
revert update docker compose again
2026-03-25 19:42:03 +00:00
Cauê Faleiros
b8541c0d24 update docker compose again
Some checks failed
Build and Deploy / build-and-push (push) Has been cancelled
2026-03-25 16:39:21 -03:00
Cauê Faleiros
062864364a update docker compose
Some checks failed
Build and Deploy / build-and-push (push) Has been cancelled
2026-03-25 16:38:23 -03:00
Cauê Faleiros
d148984028 chore: remove temporary postgres testing service
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m6s
- The portainer webhook testing service fulfilled its purpose and is no longer needed.
2026-03-25 15:03:39 -03:00
Cauê Faleiros
fa683ab28c chore: add dummy postgres service for portainer debugging
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m9s
- Added a simple postgres service to test if Portainer is successfully pulling and applying stack updates from the repository.
2026-03-25 14:48:24 -03:00
Cauê Faleiros
83e6da2d56 fix: add missing swarm deploy block to mysql backup service
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m9s
- Docker Swarm (Portainer) ignores 'restart: always' and often requires a 'deploy' block with replica counts to properly schedule a new service.

- Replaced standard docker-compose restart policy with a Swarm-compliant deploy block.
2026-03-25 14:43:03 -03:00
Cauê Faleiros
4dbd7c62cd fix: replace deprecated mysql backup image with modern cron backup container
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m5s
- The 'databack/mysql-backup' image crashes immediately when used in a pure cron context without legacy arguments and is incompatible with MySQL 8 caching_sha2_password.

- Switched to the actively maintained 'fradelg/mysql-cron-backup' image.

- Re-mapped environment variables to match the new image expectations (MYSQL_HOST, CRON_TIME, MAX_BACKUPS).

- Updated volume mapping destination to '/backup' as expected by the new image.
2026-03-25 14:34:36 -03:00
Cauê Faleiros
b5c8e97701 fix: revert backup volume path to exact specification
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m2s
- Updated the host bind mount path to exactly '/opt/backups_db' as explicitly required by the server configuration.
2026-03-25 14:20:05 -03:00
Cauê Faleiros
f65ff97434 fix: change mysql backup volume path to prevent permission denied errors
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m7s
- Updated the host bind mount from the restricted '/root' directory to '/opt/fasto_backups' to ensure the Docker daemon has the necessary permissions to read and write database dumps.
2026-03-25 14:18:16 -03:00
Cauê Faleiros
958a2cdbd9 chore: force recreate database init sql config in docker swarm
All checks were successful
Build and Deploy / build-and-push (push) Successful in 58s
- Appended a version suffix to the `init_sql` config name to bypass Docker Swarm's immutable config cache and force the cluster to pick up the latest database schema changes on deployment.
2026-03-25 13:55:31 -03:00
Cauê Faleiros
eb483f903b chore: remove swarm deployment constraint from mysql backup service
All checks were successful
Build and Deploy / build-and-push (push) Successful in 58s
- Dropped the 'node.role == manager' deployment constraint to allow the backup container to be scheduled on any available node or to run smoothly in non-swarm Docker Compose environments.
2026-03-25 13:23:34 -03:00
Cauê Faleiros
9ffcfcdcc8 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.
2026-03-25 12:40:53 -03:00
Cauê Faleiros
3663d03cb9 refactor(rbac): complete the removal of 'owner' role from backend routes and logic
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m48s
- Cleaned up the requireRole middleware across all Funnel and Origin API routes to strictly allow only 'admin' and 'super_admin' to perform structural changes.

- Updated the tenant creation script to assign the 'admin' role to new signups instead of 'owner'.
2026-03-23 15:40:36 -03:00
Cauê Faleiros
2317c46ac9 fix: change expired JWT response code to 401 to properly trigger frontend interceptor
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m42s
- The backend was returning 403 Forbidden when a token expired, causing the frontend apiFetch interceptor (which listens for 401) to ignore it and crash the session.
2026-03-19 16:31:20 -03:00
Cauê Faleiros
4489f0a74d refactor: completely remove 'owner' role from RBAC system
- The platform now strictly uses 'super_admin', 'admin', 'manager', and 'agent' to simplify permissions and match business requirements.
2026-03-19 15:33:16 -03:00
Cauê Faleiros
327ad064a4 feat: implement secure 2-token authentication with rolling sessions
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m43s
- Refactored POST /auth/login to issue a 15-minute Access Token and a 30-day Refresh Token.

- Added POST /auth/refresh endpoint to automatically issue new Access Tokens and extend the Refresh Token's lifespan by 30 days upon use (Sliding Expiration).

- Built an HTTP interceptor wrapper (apiFetch) in dataService.ts that automatically catches 401 Unauthorized errors, calls the refresh endpoint, updates localStorage, and silently retries the original request without logging the user out.
2026-03-19 14:45:53 -03:00
7 changed files with 451 additions and 145 deletions

162
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
@@ -53,7 +67,7 @@ const AuthGuard: React.FC<{ children: React.ReactNode, roles?: string[] }> = ({
} }
} catch (err) { } catch (err) {
console.error("Auth check failed (network/server error):", err); console.error("Auth check failed (network/server error):", err);
// DO NOT logout() here. If the server is offline or restarting, // DO NOT logout() here. If the server is offline or restarting,
// we shouldn't wipe the user's local storage tokens. // we shouldn't wipe the user's local storage tokens.
// We just leave the user as null, which will redirect them to login, // We just leave the user as null, which will redirect them to login,
// but their tokens remain so they can auto-login when the server is back. // but their tokens remain so they can auto-login when the server is back.
@@ -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(403).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();
}; };
@@ -203,11 +206,10 @@ apiRouter.post('/auth/verify', async (req, res) => {
const tenantId = `tenant_${crypto.randomUUID().split('-')[0]}`; const tenantId = `tenant_${crypto.randomUUID().split('-')[0]}`;
const userId = `u_${crypto.randomUUID().split('-')[0]}`; const userId = `u_${crypto.randomUUID().split('-')[0]}`;
await connection.query('INSERT INTO tenants (id, name, slug, admin_email) VALUES (?, ?, ?, ?)', await connection.query('INSERT INTO tenants (id, name, slug, admin_email) VALUES (?, ?, ?, ?)',
[tenantId, data.organization_name, data.organization_name.toLowerCase().replace(/ /g, '-'), email]); [tenantId, data.organization_name, data.organization_name.toLowerCase().replace(/ /g, '-'), email]);
await connection.query('INSERT INTO users (id, tenant_id, name, email, password_hash, role) VALUES (?, ?, ?, ?, ?, ?)', await connection.query('INSERT INTO users (id, tenant_id, name, email, password_hash, role) VALUES (?, ?, ?, ?, ?, ?)',
[userId, tenantId, data.full_name, email, data.password_hash, 'owner']); [userId, tenantId, data.full_name, email, data.password_hash, 'admin']); await connection.query('DELETE FROM pending_registrations WHERE email = ?', [email]);
await connection.query('DELETE FROM pending_registrations WHERE email = ?', [email]);
await connection.commit(); await connection.commit();
res.json({ message: 'Sucesso.' }); res.json({ message: 'Sucesso.' });
@@ -236,8 +238,78 @@ apiRouter.post('/auth/login', async (req, res) => {
const valid = await bcrypt.compare(password, user.password_hash); const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) return res.status(401).json({ error: 'Credenciais inválidas.' }); if (!valid) return res.status(401).json({ error: 'Credenciais inválidas.' });
const token = jwt.sign({ id: user.id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug }, JWT_SECRET, { expiresIn: '24h' }); // Generate Access Token (short-lived)
res.json({ token, user: { id: user.id, name: user.name, email: user.email, role: user.role, tenant_id: user.tenant_id, team_id: user.team_id, slug: user.slug } }); const token = jwt.sign({ id: user.id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug }, JWT_SECRET, { expiresIn: '15m' });
// Generate Refresh Token (long-lived)
const refreshToken = crypto.randomBytes(40).toString('hex');
const refreshId = `rt_${crypto.randomUUID().split('-')[0]}`;
// Store Refresh Token in database (expires in 30 days)
await pool.query(
'INSERT INTO refresh_tokens (id, user_id, token, expires_at) VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY))',
[refreshId, user.id, refreshToken]
);
res.json({
token,
refreshToken,
user: { id: user.id, name: user.name, email: user.email, role: user.role, tenant_id: user.tenant_id, team_id: user.team_id, slug: user.slug }
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Refresh Token
apiRouter.post('/auth/refresh', async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) return res.status(400).json({ error: 'Refresh token não fornecido.' });
try {
// Verifies if the token exists and hasn't expired
const [tokens] = await pool.query(
'SELECT r.user_id, u.tenant_id, u.role, u.team_id, u.slug, u.status FROM refresh_tokens r JOIN users u ON r.user_id = u.id WHERE r.token = ? AND r.expires_at > NOW()',
[refreshToken]
);
if (tokens.length === 0) {
// If invalid, optionally delete the bad token if it's just expired
await pool.query('DELETE FROM refresh_tokens WHERE token = ?', [refreshToken]);
return res.status(401).json({ error: 'Sessão expirada. Faça login novamente.' });
}
const user = tokens[0];
if (user.status !== 'active') {
await pool.query('DELETE FROM refresh_tokens WHERE token = ?', [refreshToken]);
return res.status(403).json({ error: 'Sua conta está inativa.' });
}
// Sliding Expiration: Extend the refresh token's life by another 30 days
await pool.query('UPDATE refresh_tokens SET expires_at = DATE_ADD(NOW(), INTERVAL 30 DAY) WHERE token = ?', [refreshToken]);
// Issue a new short-lived access token
const newAccessToken = jwt.sign(
{ id: user.user_id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug },
JWT_SECRET,
{ expiresIn: '15m' }
);
res.json({ token: newAccessToken });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Logout (Revoke Refresh Token)
apiRouter.post('/auth/logout', async (req, res) => {
const { refreshToken } = req.body;
try {
if (refreshToken) {
await pool.query('DELETE FROM refresh_tokens WHERE token = ?', [refreshToken]);
}
res.json({ message: 'Logout bem-sucedido.' });
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
@@ -388,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.' });
@@ -471,7 +543,8 @@ apiRouter.put('/users/:id', async (req, res) => {
if (existing.length === 0) return res.status(404).json({ error: 'Not found' }); if (existing.length === 0) return res.status(404).json({ error: 'Not found' });
const isSelf = req.user.id === req.params.id; const isSelf = req.user.id === req.params.id;
const isManagerOrAdmin = ['admin', 'owner', 'manager', 'super_admin'].includes(req.user.role); const isManagerOrAdmin = ['admin', 'manager', 'super_admin'].includes(req.user.role);
const isAdmin = ['admin', 'super_admin'].includes(req.user.role);
if (!isSelf && !isManagerOrAdmin) { if (!isSelf && !isManagerOrAdmin) {
return res.status(403).json({ error: 'Acesso negado.' }); return res.status(403).json({ error: 'Acesso negado.' });
@@ -481,8 +554,9 @@ apiRouter.put('/users/:id', async (req, res) => {
return res.status(403).json({ error: 'Acesso negado.' }); return res.status(403).json({ error: 'Acesso negado.' });
} }
const finalRole = isManagerOrAdmin && role !== undefined ? role : existing[0].role; // Only Admins can change roles and teams. Managers can only edit basic info of their team members.
const finalTeamId = isManagerOrAdmin && team_id !== undefined ? team_id : existing[0].team_id; const finalRole = isAdmin && role !== undefined ? role : existing[0].role;
const finalTeamId = isAdmin && team_id !== undefined ? team_id : existing[0].team_id;
const finalStatus = isManagerOrAdmin && status !== undefined ? status : existing[0].status; const finalStatus = isManagerOrAdmin && status !== undefined ? status : existing[0].status;
const finalEmail = email !== undefined ? email : existing[0].email; const finalEmail = email !== undefined ? email : existing[0].email;
const finalSoundEnabled = isSelf && sound_enabled !== undefined ? sound_enabled : (existing[0].sound_enabled ?? true); const finalSoundEnabled = isSelf && sound_enabled !== undefined ? sound_enabled : (existing[0].sound_enabled ?? true);
@@ -514,7 +588,7 @@ apiRouter.put('/users/:id', async (req, res) => {
} }
}); });
apiRouter.delete('/users/:id', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => { apiRouter.delete('/users/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
try { try {
const [existing] = await pool.query('SELECT tenant_id FROM users WHERE id = ?', [req.params.id]); const [existing] = await pool.query('SELECT tenant_id FROM users WHERE id = ?', [req.params.id]);
if (existing.length === 0) return res.status(404).json({ error: 'Not found' }); if (existing.length === 0) return res.status(404).json({ error: 'Not found' });
@@ -663,7 +737,7 @@ apiRouter.get('/origins', async (req, res) => {
} }
}); });
apiRouter.post('/origins', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { apiRouter.post('/origins', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, tenantId } = req.body; const { name, tenantId } = req.body;
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id; const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
try { try {
@@ -675,7 +749,7 @@ apiRouter.post('/origins', requireRole(['admin', 'owner', 'manager', 'super_admi
} }
}); });
apiRouter.put('/origins/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { apiRouter.put('/origins/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, teamIds } = req.body; const { name, teamIds } = req.body;
try { try {
if (name) { if (name) {
@@ -693,7 +767,7 @@ apiRouter.put('/origins/:id', requireRole(['admin', 'owner', 'manager', 'super_a
} }
}); });
apiRouter.delete('/origins/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { apiRouter.delete('/origins/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
try { try {
await pool.query('DELETE FROM origin_items WHERE origin_group_id = ?', [req.params.id]); await pool.query('DELETE FROM origin_items WHERE origin_group_id = ?', [req.params.id]);
await pool.query('UPDATE teams SET origin_group_id = NULL WHERE origin_group_id = ?', [req.params.id]); await pool.query('UPDATE teams SET origin_group_id = NULL WHERE origin_group_id = ?', [req.params.id]);
@@ -704,7 +778,7 @@ apiRouter.delete('/origins/:id', requireRole(['admin', 'owner', 'manager', 'supe
} }
}); });
apiRouter.post('/origins/:id/items', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { apiRouter.post('/origins/:id/items', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, color_class } = req.body; const { name, color_class } = req.body;
try { try {
const oid = `oriitm_${crypto.randomUUID().split('-')[0]}`; const oid = `oriitm_${crypto.randomUUID().split('-')[0]}`;
@@ -718,7 +792,7 @@ apiRouter.post('/origins/:id/items', requireRole(['admin', 'owner', 'manager', '
} }
}); });
apiRouter.put('/origin_items/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { apiRouter.put('/origin_items/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, color_class } = req.body; const { name, color_class } = req.body;
try { try {
const [existing] = await pool.query('SELECT * FROM origin_items WHERE id = ?', [req.params.id]); const [existing] = await pool.query('SELECT * FROM origin_items WHERE id = ?', [req.params.id]);
@@ -731,7 +805,7 @@ apiRouter.put('/origin_items/:id', requireRole(['admin', 'owner', 'manager', 'su
} }
}); });
apiRouter.delete('/origin_items/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { apiRouter.delete('/origin_items/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
try { try {
await pool.query('DELETE FROM origin_items WHERE id = ?', [req.params.id]); await pool.query('DELETE FROM origin_items WHERE id = ?', [req.params.id]);
res.json({ message: 'Origin item deleted.' }); res.json({ message: 'Origin item deleted.' });
@@ -792,7 +866,7 @@ apiRouter.get('/funnels', async (req, res) => {
} }
}); });
apiRouter.post('/funnels', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { apiRouter.post('/funnels', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, tenantId } = req.body; const { name, tenantId } = req.body;
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id; const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
try { try {
@@ -804,7 +878,7 @@ apiRouter.post('/funnels', requireRole(['admin', 'owner', 'manager', 'super_admi
} }
}); });
apiRouter.put('/funnels/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { apiRouter.put('/funnels/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, teamIds } = req.body; const { name, teamIds } = req.body;
try { try {
if (name) { if (name) {
@@ -822,7 +896,7 @@ apiRouter.put('/funnels/:id', requireRole(['admin', 'owner', 'manager', 'super_a
} }
}); });
apiRouter.delete('/funnels/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { apiRouter.delete('/funnels/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
try { try {
await pool.query('DELETE FROM funnel_stages WHERE funnel_id = ?', [req.params.id]); await pool.query('DELETE FROM funnel_stages WHERE funnel_id = ?', [req.params.id]);
await pool.query('UPDATE teams SET funnel_id = NULL WHERE funnel_id = ?', [req.params.id]); await pool.query('UPDATE teams SET funnel_id = NULL WHERE funnel_id = ?', [req.params.id]);
@@ -833,7 +907,7 @@ apiRouter.delete('/funnels/:id', requireRole(['admin', 'owner', 'manager', 'supe
} }
}); });
apiRouter.post('/funnels/:id/stages', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { apiRouter.post('/funnels/:id/stages', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, color_class, order_index } = req.body; const { name, color_class, order_index } = req.body;
try { try {
const sid = `stage_${crypto.randomUUID().split('-')[0]}`; const sid = `stage_${crypto.randomUUID().split('-')[0]}`;
@@ -847,7 +921,7 @@ apiRouter.post('/funnels/:id/stages', requireRole(['admin', 'owner', 'manager',
} }
}); });
apiRouter.put('/funnel_stages/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { apiRouter.put('/funnel_stages/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, color_class, order_index } = req.body; const { name, color_class, order_index } = req.body;
try { try {
const [existing] = await pool.query('SELECT * FROM funnel_stages WHERE id = ?', [req.params.id]); const [existing] = await pool.query('SELECT * FROM funnel_stages WHERE id = ?', [req.params.id]);
@@ -863,7 +937,7 @@ apiRouter.put('/funnel_stages/:id', requireRole(['admin', 'owner', 'manager', 's
} }
}); });
apiRouter.delete('/funnel_stages/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { apiRouter.delete('/funnel_stages/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
try { try {
await pool.query('DELETE FROM funnel_stages WHERE id = ?', [req.params.id]); await pool.query('DELETE FROM funnel_stages WHERE id = ?', [req.params.id]);
res.json({ message: 'Stage deleted.' }); res.json({ message: 'Stage deleted.' });
@@ -888,7 +962,7 @@ apiRouter.get('/search', async (req, res) => {
if (req.user.role === 'super_admin') { if (req.user.role === 'super_admin') {
// No extra filters // No extra filters
} else if (req.user.role === 'admin' || req.user.role === 'owner') { } else if (req.user.role === 'admin') {
membersQ += ' AND tenant_id = ?'; membersQ += ' AND tenant_id = ?';
membersParams.push(req.user.tenant_id); membersParams.push(req.user.tenant_id);
} else if (req.user.role === 'manager') { } else if (req.user.role === 'manager') {
@@ -906,7 +980,7 @@ apiRouter.get('/search', async (req, res) => {
if (req.user.role === 'super_admin') { if (req.user.role === 'super_admin') {
// No extra filters // No extra filters
} else if (req.user.role === 'admin' || req.user.role === 'owner') { } else if (req.user.role === 'admin') {
teamsQ += ' AND tenant_id = ?'; teamsQ += ' AND tenant_id = ?';
teamsParams.push(req.user.tenant_id); teamsParams.push(req.user.tenant_id);
} else if (req.user.role === 'manager') { } else if (req.user.role === 'manager') {
@@ -929,7 +1003,7 @@ apiRouter.get('/search', async (req, res) => {
if (req.user.role === 'super_admin') { if (req.user.role === 'super_admin') {
// No extra filters // No extra filters
} else if (req.user.role === 'admin' || req.user.role === 'owner') { } else if (req.user.role === 'admin') {
attendancesQ += ' AND a.tenant_id = ?'; attendancesQ += ' AND a.tenant_id = ?';
attendancesParams.push(req.user.tenant_id); attendancesParams.push(req.user.tenant_id);
} else if (req.user.role === 'manager') { } else if (req.user.role === 'manager') {
@@ -1020,7 +1094,7 @@ apiRouter.get('/attendances/:id', async (req, res) => {
}); });
// --- API Key Management Routes --- // --- API Key Management Routes ---
apiRouter.get('/api-keys', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => { apiRouter.get('/api-keys', requireRole(['admin', 'super_admin']), async (req, res) => {
try { try {
const { tenantId } = req.query; const { tenantId } = req.query;
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id; const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
@@ -1033,7 +1107,7 @@ apiRouter.get('/api-keys', requireRole(['admin', 'owner', 'super_admin']), async
} }
}); });
apiRouter.post('/api-keys', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => { apiRouter.post('/api-keys', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, tenantId } = req.body; const { name, tenantId } = req.body;
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id; const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
try { try {
@@ -1053,7 +1127,7 @@ apiRouter.post('/api-keys', requireRole(['admin', 'owner', 'super_admin']), asyn
} }
}); });
apiRouter.delete('/api-keys/:id', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => { apiRouter.delete('/api-keys/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
try { try {
const [existing] = await pool.query('SELECT tenant_id FROM api_keys WHERE id = ?', [req.params.id]); const [existing] = await pool.query('SELECT tenant_id FROM api_keys WHERE id = ?', [req.params.id]);
if (existing.length === 0) return res.status(404).json({ error: 'Chave não encontrada' }); if (existing.length === 0) return res.status(404).json({ error: 'Chave não encontrada' });
@@ -1083,8 +1157,19 @@ apiRouter.get('/integration/users', requireRole(['admin']), async (req, res) =>
apiRouter.get('/integration/origins', requireRole(['admin']), async (req, res) => { apiRouter.get('/integration/origins', requireRole(['admin']), async (req, res) => {
if (!req.user.is_api_key) return res.status(403).json({ error: 'Endpoint restrito a chaves de API.' }); if (!req.user.is_api_key) return res.status(403).json({ error: 'Endpoint restrito a chaves de API.' });
try { try {
const [origins] = await pool.query('SELECT name FROM origins WHERE tenant_id = ? ORDER BY created_at ASC', [req.user.tenant_id]); const [groups] = await pool.query('SELECT id, name FROM origin_groups WHERE tenant_id = ?', [req.user.tenant_id]);
res.json(origins.map(o => o.name)); if (groups.length === 0) return res.json([]);
const [items] = await pool.query('SELECT origin_group_id, name FROM origin_items WHERE origin_group_id IN (?) ORDER BY created_at ASC', [groups.map(g => g.id)]);
const [teams] = await pool.query('SELECT id as team_id, name as team_name, origin_group_id FROM teams WHERE tenant_id = ? AND origin_group_id IS NOT NULL', [req.user.tenant_id]);
const result = groups.map(g => ({
group_name: g.name,
origins: items.filter(i => i.origin_group_id === g.id).map(i => i.name),
assigned_teams: teams.filter(t => t.origin_group_id === g.id).map(t => ({ id: t.team_id, name: t.team_name }))
}));
res.json(result);
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
@@ -1231,7 +1316,7 @@ apiRouter.get('/teams', async (req, res) => {
} }
}); });
apiRouter.post('/teams', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => { apiRouter.post('/teams', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, description, tenantId } = req.body; const { name, description, tenantId } = req.body;
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id; const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
try { try {
@@ -1247,7 +1332,7 @@ apiRouter.post('/teams', requireRole(['admin', 'owner', 'super_admin']), async (
} }
}); });
apiRouter.put('/teams/:id', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => { apiRouter.put('/teams/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, description } = req.body; const { name, description } = req.body;
try { try {
const [existing] = await pool.query('SELECT tenant_id FROM teams WHERE id = ?', [req.params.id]); const [existing] = await pool.query('SELECT tenant_id FROM teams WHERE id = ?', [req.params.id]);
@@ -1267,7 +1352,7 @@ apiRouter.put('/teams/:id', requireRole(['admin', 'owner', 'super_admin']), asyn
} }
}); });
apiRouter.delete('/teams/:id', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => { apiRouter.delete('/teams/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
try { try {
const [existing] = await pool.query('SELECT tenant_id FROM teams WHERE id = ?', [req.params.id]); const [existing] = await pool.query('SELECT tenant_id FROM teams WHERE id = ?', [req.params.id]);
if (existing.length === 0) return res.status(404).json({ error: 'Not found' }); if (existing.length === 0) return res.status(404).json({ error: 'Not found' });
@@ -1562,6 +1647,20 @@ const provisionSuperAdmin = async (retries = 10, delay = 10000) => {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`); `);
// Create refresh_tokens table for persistent sessions
await connection.query(`
CREATE TABLE IF NOT EXISTS refresh_tokens (
id varchar(36) NOT NULL,
user_id varchar(36) NOT NULL,
token varchar(255) NOT NULL,
expires_at timestamp NOT NULL,
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY token (token),
KEY user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// Add funnel_id to teams // Add funnel_id to teams
try { try {
await connection.query("ALTER TABLE teams ADD COLUMN funnel_id VARCHAR(36) DEFAULT NULL"); await connection.query("ALTER TABLE teams ADD COLUMN funnel_id VARCHAR(36) DEFAULT NULL");

View File

@@ -239,8 +239,12 @@ 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} />
<SidebarItem to="/admin/funnels" icon={Layers} label="Gerenciar Funis" collapsed={isSidebarCollapsed} /> {currentUser.role !== 'manager' && (
<SidebarItem to="/admin/origins" icon={Target} label="Origens de Lead" 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} />
</>
)}
</> </>
)} )}
</> </>

View File

@@ -1,4 +1,4 @@
version: '3.8' version: "3.8"
services: services:
app: app:
@@ -43,12 +43,31 @@ services:
networks: networks:
- fasto-net - fasto-net
backup-mysql:
image: fradelg/mysql-cron-backup
deploy:
replicas: 1
restart_policy:
condition: on-failure
environment:
MYSQL_HOST: db
MYSQL_USER: root
MYSQL_PASS: ${DB_PASSWORD:-root_password}
CRON_TIME: "55 2 * * *" # Roda todo dia exatamente às 02:55 da manhã
MAX_BACKUPS: 3 # Mantém apenas os 3 últimos dias
INIT_BACKUP: "1" # Faz um backup imediatamente ao ligar o container
volumes:
- /opt/backups_db/fasto:/backup
networks:
- fasto-net
volumes: volumes:
db_data: db_data:
configs: configs:
init_sql: init_sql:
file: ./agenciac_comia.sql file: ./agenciac_comia.sql
name: init_sql_v2
networks: networks:
fasto-net: fasto-net:

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">

View File

@@ -6,8 +6,8 @@ import { Attendance, DashboardFilter, User } from '../types';
// Em desenvolvimento, aponta para o localhost:3001 // Em desenvolvimento, aponta para o localhost:3001
const API_URL = import.meta.env.PROD ? '/api' : 'http://localhost:3001/api'; const API_URL = import.meta.env.PROD ? '/api' : 'http://localhost:3001/api';
const getHeaders = () => { const getHeaders = (customToken?: string) => {
const token = localStorage.getItem('ctms_token'); const token = customToken || localStorage.getItem('ctms_token');
// Evitar enviar "undefined" ou "null" como strings se o localStorage estiver corrompido // Evitar enviar "undefined" ou "null" como strings se o localStorage estiver corrompido
if (!token || token === 'undefined' || token === 'null') return { 'Content-Type': 'application/json' }; if (!token || token === 'undefined' || token === 'null') return { 'Content-Type': 'application/json' };
@@ -17,9 +17,76 @@ const getHeaders = () => {
}; };
}; };
// Global flag to prevent multiple simultaneous refresh attempts
let isRefreshing = false;
let refreshSubscribers: ((token: string) => void)[] = [];
const onRefreshed = (token: string) => {
refreshSubscribers.forEach(cb => cb(token));
refreshSubscribers = [];
};
const addRefreshSubscriber = (cb: (token: string) => void) => {
refreshSubscribers.push(cb);
};
export const apiFetch = async (url: string, options: RequestInit = {}): Promise<Response> => {
let response = await fetch(url, options);
// If unauthorized, attempt to refresh the token
if (response.status === 401 && !url.includes('/auth/login') && !url.includes('/auth/refresh')) {
const refreshToken = localStorage.getItem('ctms_refresh_token');
if (!refreshToken) {
logout();
return response;
}
if (isRefreshing) {
// If a refresh is already in progress, wait for it to finish and retry
return new Promise(resolve => {
addRefreshSubscriber((newToken) => {
options.headers = getHeaders(newToken);
resolve(fetch(url, options));
});
});
}
isRefreshing = true;
try {
const refreshResponse = await fetch(`${API_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken })
});
if (!refreshResponse.ok) {
throw new Error('Refresh token invalid');
}
const data = await refreshResponse.json();
localStorage.setItem('ctms_token', data.token);
// Retry the original request
options.headers = getHeaders(data.token);
response = await fetch(url, options);
onRefreshed(data.token);
} catch (err) {
console.error("Session expired or refresh failed:", err);
logout();
} finally {
isRefreshing = false;
}
}
return response;
};
export const getNotifications = async (): Promise<any[]> => { export const getNotifications = async (): Promise<any[]> => {
try { try {
const response = await fetch(`${API_URL}/notifications`, { const response = await apiFetch(`${API_URL}/notifications`, {
headers: getHeaders() headers: getHeaders()
}); });
if (!response.ok) throw new Error('Failed to fetch notifications'); if (!response.ok) throw new Error('Failed to fetch notifications');
@@ -32,7 +99,7 @@ export const getNotifications = async (): Promise<any[]> => {
export const markNotificationAsRead = async (id: string): Promise<boolean> => { export const markNotificationAsRead = async (id: string): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/notifications/${id}`, { const response = await apiFetch(`${API_URL}/notifications/${id}`, {
method: 'PUT', method: 'PUT',
headers: getHeaders() headers: getHeaders()
}); });
@@ -45,7 +112,7 @@ export const markNotificationAsRead = async (id: string): Promise<boolean> => {
export const markAllNotificationsAsRead = async (): Promise<boolean> => { export const markAllNotificationsAsRead = async (): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/notifications/read-all`, { const response = await apiFetch(`${API_URL}/notifications/read-all`, {
method: 'PUT', method: 'PUT',
headers: getHeaders() headers: getHeaders()
}); });
@@ -58,7 +125,7 @@ export const markAllNotificationsAsRead = async (): Promise<boolean> => {
export const deleteNotification = async (id: string): Promise<boolean> => { export const deleteNotification = async (id: string): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/notifications/${id}`, { const response = await apiFetch(`${API_URL}/notifications/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: getHeaders() headers: getHeaders()
}); });
@@ -71,7 +138,7 @@ export const deleteNotification = async (id: string): Promise<boolean> => {
export const clearAllNotifications = async (): Promise<boolean> => { export const clearAllNotifications = async (): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/notifications/clear-all`, { const response = await apiFetch(`${API_URL}/notifications/clear-all`, {
method: 'DELETE', method: 'DELETE',
headers: getHeaders() headers: getHeaders()
}); });
@@ -85,7 +152,7 @@ export const clearAllNotifications = async (): Promise<boolean> => {
// --- Funnels Functions --- // --- Funnels Functions ---
export const getFunnels = async (tenantId: string): Promise<any[]> => { export const getFunnels = async (tenantId: string): Promise<any[]> => {
try { try {
const response = await fetch(`${API_URL}/funnels?tenantId=${tenantId}`, { const response = await apiFetch(`${API_URL}/funnels?tenantId=${tenantId}`, {
headers: getHeaders() headers: getHeaders()
}); });
if (!response.ok) throw new Error('Falha ao buscar funis'); if (!response.ok) throw new Error('Falha ao buscar funis');
@@ -97,7 +164,7 @@ export const getFunnels = async (tenantId: string): Promise<any[]> => {
}; };
export const createFunnel = async (data: { name: string, tenantId: string }): Promise<any> => { export const createFunnel = async (data: { name: string, tenantId: string }): Promise<any> => {
const response = await fetch(`${API_URL}/funnels`, { const response = await apiFetch(`${API_URL}/funnels`, {
method: 'POST', method: 'POST',
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify(data) body: JSON.stringify(data)
@@ -111,7 +178,7 @@ export const createFunnel = async (data: { name: string, tenantId: string }): Pr
export const updateFunnel = async (id: string, data: { name?: string, teamIds?: string[] }): Promise<boolean> => { export const updateFunnel = async (id: string, data: { name?: string, teamIds?: string[] }): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/funnels/${id}`, { const response = await apiFetch(`${API_URL}/funnels/${id}`, {
method: 'PUT', method: 'PUT',
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify(data) body: JSON.stringify(data)
@@ -125,7 +192,7 @@ export const updateFunnel = async (id: string, data: { name?: string, teamIds?:
export const deleteFunnel = async (id: string): Promise<boolean> => { export const deleteFunnel = async (id: string): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/funnels/${id}`, { const response = await apiFetch(`${API_URL}/funnels/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: getHeaders() headers: getHeaders()
}); });
@@ -137,7 +204,7 @@ export const deleteFunnel = async (id: string): Promise<boolean> => {
}; };
export const createFunnelStage = async (funnelId: string, data: any): Promise<any> => { export const createFunnelStage = async (funnelId: string, data: any): Promise<any> => {
const response = await fetch(`${API_URL}/funnels/${funnelId}/stages`, { const response = await apiFetch(`${API_URL}/funnels/${funnelId}/stages`, {
method: 'POST', method: 'POST',
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify(data) body: JSON.stringify(data)
@@ -151,7 +218,7 @@ export const createFunnelStage = async (funnelId: string, data: any): Promise<an
export const updateFunnelStage = async (id: string, data: any): Promise<boolean> => { export const updateFunnelStage = async (id: string, data: any): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/funnel_stages/${id}`, { const response = await apiFetch(`${API_URL}/funnel_stages/${id}`, {
method: 'PUT', method: 'PUT',
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify(data) body: JSON.stringify(data)
@@ -165,7 +232,7 @@ export const updateFunnelStage = async (id: string, data: any): Promise<boolean>
export const deleteFunnelStage = async (id: string): Promise<boolean> => { export const deleteFunnelStage = async (id: string): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/funnel_stages/${id}`, { const response = await apiFetch(`${API_URL}/funnel_stages/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: getHeaders() headers: getHeaders()
}); });
@@ -179,7 +246,7 @@ export const deleteFunnelStage = async (id: string): Promise<boolean> => {
// --- Origins Functions --- // --- Origins Functions ---
export const getOrigins = async (tenantId: string): Promise<any[]> => { export const getOrigins = async (tenantId: string): Promise<any[]> => {
try { try {
const response = await fetch(`${API_URL}/origins?tenantId=${tenantId}`, { const response = await apiFetch(`${API_URL}/origins?tenantId=${tenantId}`, {
headers: getHeaders() headers: getHeaders()
}); });
if (!response.ok) throw new Error('Falha ao buscar origens'); if (!response.ok) throw new Error('Falha ao buscar origens');
@@ -191,7 +258,7 @@ export const getOrigins = async (tenantId: string): Promise<any[]> => {
}; };
export const createOriginGroup = async (data: { name: string, tenantId: string }): Promise<any> => { export const createOriginGroup = async (data: { name: string, tenantId: string }): Promise<any> => {
const response = await fetch(`${API_URL}/origins`, { const response = await apiFetch(`${API_URL}/origins`, {
method: 'POST', method: 'POST',
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify(data) body: JSON.stringify(data)
@@ -205,7 +272,7 @@ export const createOriginGroup = async (data: { name: string, tenantId: string }
export const updateOriginGroup = async (id: string, data: { name?: string, teamIds?: string[] }): Promise<boolean> => { export const updateOriginGroup = async (id: string, data: { name?: string, teamIds?: string[] }): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/origins/${id}`, { const response = await apiFetch(`${API_URL}/origins/${id}`, {
method: 'PUT', method: 'PUT',
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify(data) body: JSON.stringify(data)
@@ -219,7 +286,7 @@ export const updateOriginGroup = async (id: string, data: { name?: string, teamI
export const deleteOriginGroup = async (id: string): Promise<boolean> => { export const deleteOriginGroup = async (id: string): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/origins/${id}`, { const response = await apiFetch(`${API_URL}/origins/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: getHeaders() headers: getHeaders()
}); });
@@ -231,7 +298,7 @@ export const deleteOriginGroup = async (id: string): Promise<boolean> => {
}; };
export const createOriginItem = async (groupId: string, data: { name: string, color_class?: string }): Promise<any> => { export const createOriginItem = async (groupId: string, data: { name: string, color_class?: string }): Promise<any> => {
const response = await fetch(`${API_URL}/origins/${groupId}/items`, { const response = await apiFetch(`${API_URL}/origins/${groupId}/items`, {
method: 'POST', method: 'POST',
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify(data) body: JSON.stringify(data)
@@ -245,7 +312,7 @@ export const createOriginItem = async (groupId: string, data: { name: string, co
export const updateOriginItem = async (id: string, data: { name: string, color_class?: string }): Promise<boolean> => { export const updateOriginItem = async (id: string, data: { name: string, color_class?: string }): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/origin_items/${id}`, { const response = await apiFetch(`${API_URL}/origin_items/${id}`, {
method: 'PUT', method: 'PUT',
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify(data) body: JSON.stringify(data)
@@ -259,7 +326,7 @@ export const updateOriginItem = async (id: string, data: { name: string, color_c
export const deleteOriginItem = async (id: string): Promise<boolean> => { export const deleteOriginItem = async (id: string): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/origin_items/${id}`, { const response = await apiFetch(`${API_URL}/origin_items/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: getHeaders() headers: getHeaders()
}); });
@@ -273,7 +340,7 @@ export const deleteOriginItem = async (id: string): Promise<boolean> => {
// --- API Keys Functions --- // --- API Keys Functions ---
export const getApiKeys = async (tenantId: string): Promise<any[]> => { export const getApiKeys = async (tenantId: string): Promise<any[]> => {
try { try {
const response = await fetch(`${API_URL}/api-keys?tenantId=${tenantId}`, { const response = await apiFetch(`${API_URL}/api-keys?tenantId=${tenantId}`, {
headers: getHeaders() headers: getHeaders()
}); });
if (!response.ok) throw new Error('Falha ao buscar chaves'); if (!response.ok) throw new Error('Falha ao buscar chaves');
@@ -285,7 +352,7 @@ export const getApiKeys = async (tenantId: string): Promise<any[]> => {
}; };
export const createApiKey = async (data: { name: string, tenantId: string }): Promise<any> => { export const createApiKey = async (data: { name: string, tenantId: string }): Promise<any> => {
const response = await fetch(`${API_URL}/api-keys`, { const response = await apiFetch(`${API_URL}/api-keys`, {
method: 'POST', method: 'POST',
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify(data) body: JSON.stringify(data)
@@ -299,7 +366,7 @@ export const createApiKey = async (data: { name: string, tenantId: string }): Pr
export const deleteApiKey = async (id: string): Promise<boolean> => { export const deleteApiKey = async (id: string): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/api-keys/${id}`, { const response = await apiFetch(`${API_URL}/api-keys/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: getHeaders() headers: getHeaders()
}); });
@@ -312,7 +379,7 @@ export const deleteApiKey = async (id: string): Promise<boolean> => {
export const searchGlobal = async (query: string): Promise<{ members: User[], teams: any[], attendances: any[], organizations?: any[] }> => { export const searchGlobal = async (query: string): Promise<{ members: User[], teams: any[], attendances: any[], organizations?: any[] }> => {
try { try {
const response = await fetch(`${API_URL}/search?q=${encodeURIComponent(query)}`, { const response = await apiFetch(`${API_URL}/search?q=${encodeURIComponent(query)}`, {
headers: getHeaders() headers: getHeaders()
}); });
if (!response.ok) throw new Error('Search failed'); if (!response.ok) throw new Error('Search failed');
@@ -336,7 +403,7 @@ export const getAttendances = async (tenantId: string, filter: DashboardFilter):
if (filter.funnelStage && filter.funnelStage !== 'all') params.append('funnelStage', filter.funnelStage); if (filter.funnelStage && filter.funnelStage !== 'all') params.append('funnelStage', filter.funnelStage);
if (filter.origin && filter.origin !== 'all') params.append('origin', filter.origin); if (filter.origin && filter.origin !== 'all') params.append('origin', filter.origin);
const response = await fetch(`${API_URL}/attendances?${params.toString()}`, { const response = await apiFetch(`${API_URL}/attendances?${params.toString()}`, {
headers: getHeaders() headers: getHeaders()
}); });
@@ -357,7 +424,7 @@ export const getUsers = async (tenantId: string): Promise<User[]> => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (tenantId !== 'all') params.append('tenantId', tenantId); if (tenantId !== 'all') params.append('tenantId', tenantId);
const response = await fetch(`${API_URL}/users?${params.toString()}`, { const response = await apiFetch(`${API_URL}/users?${params.toString()}`, {
headers: getHeaders() headers: getHeaders()
}); });
if (!response.ok) throw new Error('Falha ao buscar usuários'); if (!response.ok) throw new Error('Falha ao buscar usuários');
@@ -371,7 +438,7 @@ export const getUsers = async (tenantId: string): Promise<User[]> => {
export const getUserById = async (id: string): Promise<User | undefined> => { export const getUserById = async (id: string): Promise<User | undefined> => {
try { try {
const response = await fetch(`${API_URL}/users/${id}`, { const response = await apiFetch(`${API_URL}/users/${id}`, {
headers: getHeaders() headers: getHeaders()
}); });
if (!response.ok) { if (!response.ok) {
@@ -393,7 +460,7 @@ export const getUserById = async (id: string): Promise<User | undefined> => {
export const updateUser = async (id: string, userData: any): Promise<boolean> => { export const updateUser = async (id: string, userData: any): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/users/${id}`, { const response = await apiFetch(`${API_URL}/users/${id}`, {
method: 'PUT', method: 'PUT',
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify(userData) body: JSON.stringify(userData)
@@ -417,7 +484,7 @@ export const uploadAvatar = async (id: string, file: File): Promise<string | nul
formData.append('avatar', file); formData.append('avatar', file);
const token = localStorage.getItem('ctms_token'); const token = localStorage.getItem('ctms_token');
const response = await fetch(`${API_URL}/users/${id}/avatar`, { const response = await apiFetch(`${API_URL}/users/${id}/avatar`, {
method: 'POST', method: 'POST',
headers: { headers: {
...(token ? { 'Authorization': `Bearer ${token}` } : {}) ...(token ? { 'Authorization': `Bearer ${token}` } : {})
@@ -436,7 +503,7 @@ export const uploadAvatar = async (id: string, file: File): Promise<string | nul
export const createMember = async (userData: any): Promise<boolean> => { export const createMember = async (userData: any): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/users`, { const response = await apiFetch(`${API_URL}/users`, {
method: 'POST', method: 'POST',
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify(userData) body: JSON.stringify(userData)
@@ -456,7 +523,7 @@ export const createMember = async (userData: any): Promise<boolean> => {
export const deleteUser = async (id: string): Promise<boolean> => { export const deleteUser = async (id: string): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/users/${id}`, { const response = await apiFetch(`${API_URL}/users/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: getHeaders() headers: getHeaders()
}); });
@@ -469,7 +536,7 @@ export const deleteUser = async (id: string): Promise<boolean> => {
export const getAttendanceById = async (id: string): Promise<Attendance | undefined> => { export const getAttendanceById = async (id: string): Promise<Attendance | undefined> => {
try { try {
const response = await fetch(`${API_URL}/attendances/${id}`, { const response = await apiFetch(`${API_URL}/attendances/${id}`, {
headers: getHeaders() headers: getHeaders()
}); });
if (!response.ok) return undefined; if (!response.ok) return undefined;
@@ -487,7 +554,7 @@ export const getAttendanceById = async (id: string): Promise<Attendance | undefi
export const getTenants = async (): Promise<any[]> => { export const getTenants = async (): Promise<any[]> => {
try { try {
const response = await fetch(`${API_URL}/tenants`, { const response = await apiFetch(`${API_URL}/tenants`, {
headers: getHeaders() headers: getHeaders()
}); });
if (!response.ok) throw new Error('Falha ao buscar tenants'); if (!response.ok) throw new Error('Falha ao buscar tenants');
@@ -505,7 +572,7 @@ export const getTenants = async (): Promise<any[]> => {
export const getTeams = async (tenantId: string): Promise<any[]> => { export const getTeams = async (tenantId: string): Promise<any[]> => {
try { try {
const response = await fetch(`${API_URL}/teams?tenantId=${tenantId}`, { const response = await apiFetch(`${API_URL}/teams?tenantId=${tenantId}`, {
headers: getHeaders() headers: getHeaders()
}); });
if (!response.ok) throw new Error('Falha ao buscar equipes'); if (!response.ok) throw new Error('Falha ao buscar equipes');
@@ -518,7 +585,7 @@ export const getTeams = async (tenantId: string): Promise<any[]> => {
export const createTeam = async (teamData: any): Promise<boolean> => { export const createTeam = async (teamData: any): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/teams`, { const response = await apiFetch(`${API_URL}/teams`, {
method: 'POST', method: 'POST',
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify(teamData) body: JSON.stringify(teamData)
@@ -532,7 +599,7 @@ export const createTeam = async (teamData: any): Promise<boolean> => {
export const updateTeam = async (id: string, teamData: any): Promise<boolean> => { export const updateTeam = async (id: string, teamData: any): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/teams/${id}`, { const response = await apiFetch(`${API_URL}/teams/${id}`, {
method: 'PUT', method: 'PUT',
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify(teamData) body: JSON.stringify(teamData)
@@ -546,7 +613,7 @@ export const updateTeam = async (id: string, teamData: any): Promise<boolean> =>
export const deleteTeam = async (id: string): Promise<boolean> => { export const deleteTeam = async (id: string): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/teams/${id}`, { const response = await apiFetch(`${API_URL}/teams/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: getHeaders() headers: getHeaders()
}); });
@@ -559,7 +626,7 @@ export const deleteTeam = async (id: string): Promise<boolean> => {
export const createTenant = async (tenantData: any): Promise<{ success: boolean; message?: string }> => { export const createTenant = async (tenantData: any): Promise<{ success: boolean; message?: string }> => {
try { try {
const response = await fetch(`${API_URL}/tenants`, { const response = await apiFetch(`${API_URL}/tenants`, {
method: 'POST', method: 'POST',
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify(tenantData) body: JSON.stringify(tenantData)
@@ -575,7 +642,7 @@ export const createTenant = async (tenantData: any): Promise<{ success: boolean;
export const updateTenant = async (id: string, tenantData: any): Promise<boolean> => { export const updateTenant = async (id: string, tenantData: any): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/tenants/${id}`, { const response = await apiFetch(`${API_URL}/tenants/${id}`, {
method: 'PUT', method: 'PUT',
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify(tenantData) body: JSON.stringify(tenantData)
@@ -589,7 +656,7 @@ export const updateTenant = async (id: string, tenantData: any): Promise<boolean
export const deleteTenant = async (id: string): Promise<boolean> => { export const deleteTenant = async (id: string): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/tenants/${id}`, { const response = await apiFetch(`${API_URL}/tenants/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: getHeaders() headers: getHeaders()
}); });
@@ -607,9 +674,23 @@ export let isReloadingForImpersonation = false;
export const logout = () => { export const logout = () => {
if (isReloadingForImpersonation) return; // Prevent logout if we are just switching tokens if (isReloadingForImpersonation) return; // Prevent logout if we are just switching tokens
const refreshToken = localStorage.getItem('ctms_refresh_token');
// Clear local storage synchronously for instant UI update
localStorage.removeItem('ctms_token'); localStorage.removeItem('ctms_token');
localStorage.removeItem('ctms_refresh_token');
localStorage.removeItem('ctms_user_id'); localStorage.removeItem('ctms_user_id');
localStorage.removeItem('ctms_tenant_id'); localStorage.removeItem('ctms_tenant_id');
// Attempt to revoke in background
if (refreshToken) {
fetch(`${API_URL}/auth/logout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken })
}).catch(e => console.error("Failed to revoke refresh token", e));
}
}; };
export const login = async (credentials: any): Promise<any> => { export const login = async (credentials: any): Promise<any> => {
@@ -630,6 +711,7 @@ export const login = async (credentials: any): Promise<any> => {
const data = isJson ? await response.json() : null; const data = isJson ? await response.json() : null;
if (data && data.token) { if (data && data.token) {
localStorage.setItem('ctms_token', data.token); localStorage.setItem('ctms_token', data.token);
if (data.refreshToken) localStorage.setItem('ctms_refresh_token', data.refreshToken);
localStorage.setItem('ctms_user_id', data.user.id); localStorage.setItem('ctms_user_id', data.user.id);
localStorage.setItem('ctms_tenant_id', data.user.tenant_id || ''); localStorage.setItem('ctms_tenant_id', data.user.tenant_id || '');
} }
@@ -637,7 +719,7 @@ export const login = async (credentials: any): Promise<any> => {
}; };
export const impersonateTenant = async (tenantId: string): Promise<any> => { export const impersonateTenant = async (tenantId: string): Promise<any> => {
const response = await fetch(`${API_URL}/impersonate/${tenantId}`, { const response = await apiFetch(`${API_URL}/impersonate/${tenantId}`, {
method: 'POST', method: 'POST',
headers: getHeaders() headers: getHeaders()
}); });
@@ -705,7 +787,7 @@ export const returnToSuperAdmin = (): boolean => {
}; };
export const register = async (userData: any): Promise<boolean> => { export const register = async (userData: any): Promise<boolean> => {
const response = await fetch(`${API_URL}/auth/register`, { const response = await apiFetch(`${API_URL}/auth/register`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData) body: JSON.stringify(userData)
@@ -718,7 +800,7 @@ export const register = async (userData: any): Promise<boolean> => {
}; };
export const verifyCode = async (data: any): Promise<boolean> => { export const verifyCode = async (data: any): Promise<boolean> => {
const response = await fetch(`${API_URL}/auth/verify`, { const response = await apiFetch(`${API_URL}/auth/verify`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data) body: JSON.stringify(data)
@@ -731,7 +813,7 @@ export const verifyCode = async (data: any): Promise<boolean> => {
}; };
export const forgotPassword = async (email: string): Promise<string> => { export const forgotPassword = async (email: string): Promise<string> => {
const response = await fetch(`${API_URL}/auth/forgot-password`, { const response = await apiFetch(`${API_URL}/auth/forgot-password`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }) body: JSON.stringify({ email })
@@ -750,7 +832,7 @@ export const forgotPassword = async (email: string): Promise<string> => {
}; };
export const resetPassword = async (password: string, token: string, name?: string): Promise<string> => { export const resetPassword = async (password: string, token: string, name?: string): Promise<string> => {
const response = await fetch(`${API_URL}/auth/reset-password`, { const response = await apiFetch(`${API_URL}/auth/reset-password`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password, token, name }) body: JSON.stringify({ password, token, name })