Compare commits

..

3 Commits

Author SHA1 Message Date
Cauê Faleiros
aa59e642af Add backend policy tests and API client split
All checks were successful
Build and Deploy / build-and-push (push) Successful in 3m8s
2026-05-28 16:00:30 -03:00
Cauê Faleiros
5648dc7986 Wire runtime secrets in compose 2026-05-28 15:49:26 -03:00
Cauê Faleiros
2c102eb2dd Harden auth and clean project setup 2026-05-28 14:51:00 -03:00
21 changed files with 559 additions and 223 deletions

View File

@@ -1,4 +1,5 @@
PORT=3001
CORS_ORIGIN=http://localhost:3001
DB_HOST=db
DB_USER=root
DB_PASSWORD=root_password
@@ -11,3 +12,4 @@ SMTP_PORT=587
SMTP_USER=nao-responda@blyzer.com.br
SMTP_PASS=your_smtp_password_here
MAIL_FROM=nao-responda@blyzer.com.br
SMTP_DEBUG=false

88
CONTEXT.md Normal file
View File

@@ -0,0 +1,88 @@
# Fasto Project Documentation
## Overview
Fasto is a commercial team management system built with React (Vite) on the frontend and Node.js (Express) on the backend. It uses a MySQL database. It features a complete multi-tenant architecture designed to securely host multiple client organizations within a single deployment.
## 🚀 Recent Major Changes (March 2026)
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.
- **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 (Zinc scale).
- Fully collapsible interactive sidebar with memory (`localStorage`).
- All Date/Time displays localized to strict Brazilian formatting (`pt-BR`, 24h, `DD/MM/YY`).
## 📌 Roadmap / To-Do
- [ ] **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` 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
- Docker & Docker Compose
- Node.js (for local development outside Docker)
## ⚙️ Setup & Running
### 1. Environment Variables
Copy `.env.example` to `.env` and adjust values:
```bash
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 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:
```bash
docker-compose -f docker-compose.local.yml up -d --build
```
- **App**: http://localhost:3001
- **Database**: Port 3306
### 4. Gitea Runner
The `docker-compose.yml` includes a service for a Gitea Runner (`fasto-runner`).
- Persistent data is in `./fasto_runner/data`.
## 🔄 CI/CD Pipeline
The project uses Gitea Actions defined in `.gitea/workflows/build-deploy.yaml`.
- **Triggers**: Push to `main` or `master`.
- **Steps**:
1. Checkout code.
2. Build Docker image.
3. Push to `gitea.blyzer.com.br`.
4. Trigger Portainer webhook.
## 💻 Development
The Dockerfile uses a unified root structure. Both the frontend build and the backend Node.js server are hosted from the same container image.

View File

@@ -17,15 +17,17 @@ WORKDIR /app
ENV NODE_ENV=production
# Copy backend package.json as main package.json
COPY backend/package.json ./package.json
# Copy backend package metadata as runtime package metadata
COPY backend/package.json backend/package-lock.json ./
# Install dependencies
RUN npm install --omit=dev
RUN npm ci --omit=dev
# Copy backend source directly into root
COPY backend/index.js ./index.js
COPY backend/db.js ./db.js
COPY backend/policies ./policies
COPY backend/utils ./utils
# Copy built frontend
COPY --from=builder /app/dist ./dist

View File

@@ -1,20 +1,42 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Fasto
# Run and deploy your AI Studio app
Fasto is a multi-tenant commercial team management system for tracking sales attendance, seller performance, funnels, lead origins, and external n8n/AI integrations.
This contains everything you need to run your app locally.
## Stack
View your app in AI Studio: https://ai.studio/apps/3aacfdea-56fa-4154-a6b9-09ebdedfa306
- Frontend: React, TypeScript, Vite, TailwindCSS, Recharts, Lucide React
- Backend: Node.js, Express, MySQL2, Nodemailer
- Database: MySQL 8
- Local runtime: Docker Compose
## Run Locally
## Local Setup
**Prerequisites:** Node.js
Copy the environment template and adjust values:
```bash
cp .env.example .env
```
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`
Start the app and database:
```bash
docker-compose -f docker-compose.local.yml up -d --build
```
The app runs at `http://localhost:3001`.
For frontend-only development:
```bash
npm install
npm run dev
```
The Vite dev server runs at `http://localhost:3000` and calls the backend at `http://localhost:3001/api`.
## Notes
- `JWT_SECRET` is required in production.
- Set `CORS_ORIGIN` in production if the frontend is served from a different origin.
- Backend startup currently applies non-destructive schema updates for existing deployments.
- See `CONTEXT.md` for the fuller architecture and project history.

View File

@@ -6,7 +6,7 @@ const mysql = require('mysql2/promise');
const dbConfig = {
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || 'password',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'agenciac_comia',
waitForConnections: true,
connectionLimit: 10,

View File

@@ -10,24 +10,41 @@ const multer = require('multer');
const { v4: uuidv4 } = require('uuid');
const fs = require('fs');
const pool = require('./db');
const { stripEnvQuotes, hashSecret, maskSecret } = require('./utils/security');
const {
canReadUser,
canUpdateUser,
canManageUserStatus,
canChangeUserEmail,
canManageUserRoleOrTeam,
canReadAttendance,
} = require('./policies/accessPolicy');
const app = express();
const PORT = process.env.PORT || 3001;
const JWT_SECRET = process.env.JWT_SECRET || 'fasto_super_secret_key';
const isProduction = process.env.NODE_ENV === 'production';
const JWT_SECRET = process.env.JWT_SECRET || (isProduction ? null : 'fasto_dev_secret_change_me');
if (!JWT_SECRET) {
throw new Error('JWT_SECRET is required in production.');
}
const USER_PUBLIC_FIELDS = 'id, tenant_id, team_id, name, email, slug, role, status, bio, avatar_url, sound_enabled, created_at';
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || 'mail.blyzer.com.br',
port: parseInt(process.env.SMTP_PORT) || 587,
secure: false, // false para 587 (STARTTLS)
auth: {
user: (process.env.SMTP_USER || 'nao-responda@blyzer.com.br').replace(/^"|"$/g, ''),
pass: (process.env.SMTP_PASS || 'Compor@2017#').replace(/^"|"$/g, ''),
user: stripEnvQuotes(process.env.SMTP_USER || 'nao-responda@blyzer.com.br'),
pass: stripEnvQuotes(process.env.SMTP_PASS || ''),
},
tls: {
ciphers: 'SSLv3',
rejectUnauthorized: false
},
debug: true,
logger: true
debug: process.env.SMTP_DEBUG === 'true',
logger: process.env.SMTP_DEBUG === 'true'
});
// Helper para obter a URL base
@@ -53,7 +70,20 @@ const getStartupBaseUrl = () => {
return 'http://localhost:3001';
};
app.use(cors());
const allowedOrigins = (process.env.CORS_ORIGIN || '')
.split(',')
.map(origin => origin.trim())
.filter(Boolean);
app.use(cors({
origin: (origin, callback) => {
if (!origin || !isProduction || allowedOrigins.includes(origin)) {
return callback(null, true);
}
return callback(new Error('Origem não permitida pelo CORS.'));
},
credentials: true
}));
app.use(express.json());
// Logger de Requisições
@@ -111,7 +141,10 @@ const authenticateToken = async (req, res, next) => {
if (authHeader && authHeader.startsWith('Bearer fasto_sk_')) {
const apiKey = authHeader.split(' ')[1];
try {
const [keys] = await pool.query('SELECT * FROM api_keys WHERE secret_key = ?', [apiKey]);
const [keys] = await pool.query(
'SELECT * FROM api_keys WHERE secret_hash = ? OR secret_key = ?',
[hashSecret(apiKey), apiKey]
);
if (keys.length === 0) return res.status(401).json({ error: 'Chave de API inválida.' });
// Update last used timestamp
@@ -247,8 +280,8 @@ apiRouter.post('/auth/login', async (req, res) => {
// 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]
'INSERT INTO refresh_tokens (id, user_id, token, token_hash, expires_at) VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY))',
[refreshId, user.id, maskSecret(refreshId, refreshToken), hashSecret(refreshToken)]
);
res.json({
@@ -269,25 +302,25 @@ apiRouter.post('/auth/refresh', async (req, res) => {
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]
'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_hash = ? OR r.token = ?) AND r.expires_at > NOW()',
[hashSecret(refreshToken), 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]);
await pool.query('DELETE FROM refresh_tokens WHERE token_hash = ? OR token = ?', [hashSecret(refreshToken), 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]);
await pool.query('DELETE FROM refresh_tokens WHERE token_hash = ? OR token = ?', [hashSecret(refreshToken), 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]);
await pool.query('UPDATE refresh_tokens SET expires_at = DATE_ADD(NOW(), INTERVAL 30 DAY) WHERE token_hash = ? OR token = ?', [hashSecret(refreshToken), refreshToken]);
// Issue a new short-lived access token
const newAccessToken = jwt.sign(
@@ -307,7 +340,7 @@ apiRouter.post('/auth/logout', async (req, res) => {
const { refreshToken } = req.body;
try {
if (refreshToken) {
await pool.query('DELETE FROM refresh_tokens WHERE token = ?', [refreshToken]);
await pool.query('DELETE FROM refresh_tokens WHERE token_hash = ? OR token = ?', [hashSecret(refreshToken), refreshToken]);
}
res.json({ message: 'Logout bem-sucedido.' });
} catch (error) {
@@ -433,7 +466,7 @@ apiRouter.get('/users', async (req, res) => {
const { tenantId } = req.query;
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
let q = 'SELECT * FROM users';
let q = `SELECT ${USER_PUBLIC_FIELDS} FROM users`;
const params = [];
if (effectiveTenantId && effectiveTenantId !== 'all') {
q += ' WHERE tenant_id = ?';
@@ -458,13 +491,12 @@ apiRouter.get('/users', async (req, res) => {
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]);
const [rows] = await pool.query(`SELECT ${USER_PUBLIC_FIELDS} 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 || !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.' });
}
if (!canReadUser(req.user, rows[0])) return res.status(403).json({ error: 'Acesso negado.' });
res.json(rows[0]);
} catch (error) {
console.error('Error in GET /users/:idOrSlug:', error);
@@ -482,6 +514,7 @@ apiRouter.post('/users', requireRole(['admin', 'manager', 'super_admin']), async
let finalTeamId = team_id || null;
if (req.user.role === 'manager') {
if (!req.user.team_id) return res.status(403).json({ error: 'Gerente sem time não pode criar membros.' });
finalRole = 'agent'; // Force manager creations to be agents
finalTeamId = req.user.team_id; // Force assignment to manager's team
}
@@ -542,24 +575,14 @@ apiRouter.put('/users/:id', async (req, res) => {
const [existing] = await pool.query('SELECT * FROM users WHERE id = ?', [req.params.id]);
if (existing.length === 0) return res.status(404).json({ error: 'Not found' });
const isSelf = req.user.id === req.params.id;
const isManagerOrAdmin = ['admin', 'manager', 'super_admin'].includes(req.user.role);
const isAdmin = ['admin', 'super_admin'].includes(req.user.role);
if (!isSelf && !isManagerOrAdmin) {
return res.status(403).json({ error: 'Acesso negado.' });
}
if (req.user.role !== 'super_admin' && existing[0].tenant_id !== req.user.tenant_id) {
return res.status(403).json({ error: 'Acesso negado.' });
}
if (!canUpdateUser(req.user, existing[0])) return res.status(403).json({ error: 'Acesso negado.' });
// Only Admins can change roles and teams. Managers can only edit basic info of their team members.
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 finalEmail = email !== undefined ? email : existing[0].email;
const finalSoundEnabled = isSelf && sound_enabled !== undefined ? sound_enabled : (existing[0].sound_enabled ?? true);
const finalRole = canManageUserRoleOrTeam(req.user) && role !== undefined ? role : existing[0].role;
const finalTeamId = canManageUserRoleOrTeam(req.user) && team_id !== undefined ? team_id : existing[0].team_id;
const finalStatus = canManageUserStatus(req.user) && status !== undefined ? status : existing[0].status;
const finalEmail = canChangeUserEmail(req.user, existing[0]) && email !== undefined ? email : existing[0].email;
const finalSoundEnabled = req.user.id === req.params.id && sound_enabled !== undefined ? sound_enabled : (existing[0].sound_enabled ?? true);
if (finalEmail !== existing[0].email) {
const [emailCheck] = await pool.query('SELECT id FROM users WHERE email = ? AND id != ?', [finalEmail, req.params.id]);
@@ -1076,12 +1099,13 @@ apiRouter.get('/attendances', async (req, res) => {
apiRouter.get('/attendances/:id', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM attendances WHERE id = ?', [req.params.id]);
const [rows] = await pool.query(
'SELECT a.*, u.team_id FROM attendances a JOIN users u ON a.user_id = u.id WHERE a.id = ?',
[req.params.id]
);
if (rows.length === 0) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'super_admin' && rows[0].tenant_id !== req.user.tenant_id) {
return res.status(403).json({ error: 'Acesso negado.' });
}
if (!canReadAttendance(req.user, rows[0])) return res.status(403).json({ error: 'Acesso negado.' });
const r = rows[0];
res.json({
@@ -1100,7 +1124,10 @@ apiRouter.get('/api-keys', requireRole(['admin', 'super_admin']), async (req, re
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
if (!effectiveTenantId || effectiveTenantId === 'all') return res.json([]);
const [rows] = await pool.query('SELECT id, name, created_at, last_used_at, CONCAT(SUBSTRING(secret_key, 1, 14), "...") as masked_key FROM api_keys WHERE tenant_id = ?', [effectiveTenantId]);
const [rows] = await pool.query(
'SELECT id, name, created_at, last_used_at, CASE WHEN secret_key LIKE "masked:%" THEN CONCAT("fasto_sk_", RIGHT(secret_key, 6), "...") ELSE CONCAT(SUBSTRING(secret_key, 1, 14), "...") END as masked_key FROM api_keys WHERE tenant_id = ?',
[effectiveTenantId]
);
res.json(rows);
} catch (error) {
res.status(500).json({ error: error.message });
@@ -1116,8 +1143,8 @@ apiRouter.post('/api-keys', requireRole(['admin', 'super_admin']), async (req, r
const secretKey = `fasto_sk_${crypto.randomBytes(32).toString('hex')}`;
await pool.query(
'INSERT INTO api_keys (id, tenant_id, name, secret_key) VALUES (?, ?, ?, ?)',
[id, effectiveTenantId, name || 'Nova Integração API', secretKey]
'INSERT INTO api_keys (id, tenant_id, name, secret_key, secret_hash) VALUES (?, ?, ?, ?, ?)',
[id, effectiveTenantId, name || 'Nova Integração API', maskSecret(id, secretKey), hashSecret(secretKey)]
);
// We only return the actual secret key ONCE during creation.
@@ -1639,28 +1666,56 @@ const provisionSuperAdmin = async (retries = 10, delay = 10000) => {
tenant_id varchar(36) NOT NULL,
name varchar(255) NOT NULL,
secret_key varchar(255) NOT NULL,
secret_hash varchar(64) DEFAULT NULL,
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at timestamp NULL DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY secret_key (secret_key),
UNIQUE KEY secret_hash (secret_hash),
KEY tenant_id (tenant_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
try {
await connection.query("ALTER TABLE api_keys ADD COLUMN secret_hash VARCHAR(64) DEFAULT NULL");
} catch (err) {
if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (api_keys.secret_hash):', err.message);
}
try {
await connection.query("ALTER TABLE api_keys ADD UNIQUE KEY secret_hash (secret_hash)");
} catch (err) {
if (err.code !== 'ER_DUP_KEYNAME') console.log('Schema update note (api_keys.secret_hash index):', err.message);
}
// 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,
token_hash varchar(64) DEFAULT NULL,
expires_at timestamp NOT NULL,
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY token (token),
UNIQUE KEY token_hash (token_hash),
KEY user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
try {
await connection.query("ALTER TABLE refresh_tokens ADD COLUMN token_hash VARCHAR(64) DEFAULT NULL");
} catch (err) {
if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (refresh_tokens.token_hash):', err.message);
}
try {
await connection.query("ALTER TABLE refresh_tokens ADD UNIQUE KEY token_hash (token_hash)");
} catch (err) {
if (err.code !== 'ER_DUP_KEYNAME') console.log('Schema update note (refresh_tokens.token_hash index):', err.message);
}
// Add funnel_id to teams
try {
await connection.query("ALTER TABLE teams ADD COLUMN funnel_id VARCHAR(36) DEFAULT NULL");

View File

@@ -73,9 +73,9 @@
}
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
"version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
@@ -86,7 +86,7 @@
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.14.0",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
@@ -331,9 +331,9 @@
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -358,14 +358,14 @@
}
},
"node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.3",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
@@ -384,7 +384,7 @@
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.14.0",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
@@ -519,9 +519,9 @@
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -771,9 +771,9 @@
"license": "MIT"
},
"node_modules/multer": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.0.tgz",
"integrity": "sha512-TBm6j41rxNohqawsxlsWsNNh/VdV4QFXcBvRcPhXaA05EZ79z0qJ2bQFpync6JBoHTeNY5Q1JpG7AlTjdlfAEA==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
@@ -849,9 +849,9 @@
}
},
"node_modules/nodemailer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
"version": "8.0.9",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.9.tgz",
"integrity": "sha512-5ofa7BUN8+C+Hckh5V2GjeeOGRQBx0CJQA6KxrvuZfC8iU4/q7sLn8XrtEEhJkjV6HdyIiQs7Bba6bTao8JhkA==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
@@ -900,9 +900,9 @@
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/proxy-addr": {
@@ -919,9 +919,9 @@
}
},
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
@@ -1080,13 +1080,13 @@
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
@@ -1233,9 +1233,9 @@
}
},
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"version": "13.0.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
"integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"

View File

@@ -3,15 +3,18 @@
"version": "1.0.0",
"type": "commonjs",
"main": "index.js",
"scripts": {
"test": "node --test"
},
"dependencies": {
"bcryptjs": "^3.0.3",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.3",
"multer": "^1.4.5-lts.1",
"multer": "^2.1.0",
"mysql2": "^3.9.1",
"nodemailer": "^8.0.1",
"uuid": "^9.0.1"
"uuid": "^13.0.0"
}
}

View File

@@ -0,0 +1,42 @@
const sameTenant = (actor, resource) => actor.role === 'super_admin' || actor.tenant_id === resource.tenant_id;
const canReadUser = (actor, targetUser) => {
if (!actor || !targetUser || !sameTenant(actor, targetUser)) return false;
if (actor.role === 'super_admin' || actor.role === 'admin') return true;
if (actor.role === 'agent') return targetUser.id === actor.id;
if (actor.role === 'manager') {
return targetUser.id === actor.id || Boolean(actor.team_id && targetUser.team_id === actor.team_id);
}
return false;
};
const canUpdateUser = (actor, targetUser) => {
if (!actor || !targetUser || !sameTenant(actor, targetUser)) return false;
if (actor.id === targetUser.id) return true;
if (actor.role === 'super_admin' || actor.role === 'admin') return true;
if (actor.role === 'manager') {
return Boolean(actor.team_id && targetUser.team_id === actor.team_id && targetUser.role === 'agent');
}
return false;
};
const canManageUserStatus = (actor) => actor.role === 'super_admin' || actor.role === 'admin';
const canChangeUserEmail = (actor, targetUser) => actor.id === targetUser.id || actor.role === 'super_admin' || actor.role === 'admin';
const canManageUserRoleOrTeam = (actor) => actor.role === 'super_admin' || actor.role === 'admin';
const canReadAttendance = (actor, attendance) => {
if (!actor || !attendance || !sameTenant(actor, attendance)) return false;
if (actor.role === 'super_admin' || actor.role === 'admin') return true;
if (actor.role === 'agent') return attendance.user_id === actor.id;
if (actor.role === 'manager') return Boolean(actor.team_id && attendance.team_id === actor.team_id);
return false;
};
module.exports = {
canReadUser,
canUpdateUser,
canManageUserStatus,
canChangeUserEmail,
canManageUserRoleOrTeam,
canReadAttendance,
};

View File

@@ -0,0 +1,64 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {
canReadUser,
canUpdateUser,
canManageUserStatus,
canChangeUserEmail,
canManageUserRoleOrTeam,
canReadAttendance,
} = require('../policies/accessPolicy');
const admin = { id: 'u_admin', tenant_id: 'tenant_a', role: 'admin', team_id: null };
const manager = { id: 'u_manager', tenant_id: 'tenant_a', role: 'manager', team_id: 'team_a' };
const managerWithoutTeam = { id: 'u_manager_2', tenant_id: 'tenant_a', role: 'manager', team_id: null };
const agent = { id: 'u_agent', tenant_id: 'tenant_a', role: 'agent', team_id: 'team_a' };
const otherAgent = { id: 'u_other_agent', tenant_id: 'tenant_a', role: 'agent', team_id: 'team_b' };
const foreignAgent = { id: 'u_foreign', tenant_id: 'tenant_b', role: 'agent', team_id: 'team_x' };
const superAdmin = { id: 'u_super', tenant_id: 'system', role: 'super_admin', team_id: null };
test('user read policy keeps tenants isolated', () => {
assert.equal(canReadUser(admin, agent), true);
assert.equal(canReadUser(admin, foreignAgent), false);
assert.equal(canReadUser(superAdmin, foreignAgent), true);
});
test('agents can only read and update themselves', () => {
assert.equal(canReadUser(agent, agent), true);
assert.equal(canReadUser(agent, otherAgent), false);
assert.equal(canUpdateUser(agent, agent), true);
assert.equal(canUpdateUser(agent, otherAgent), false);
});
test('managers can read their team and update only team agents', () => {
assert.equal(canReadUser(manager, agent), true);
assert.equal(canReadUser(manager, otherAgent), false);
assert.equal(canReadUser(managerWithoutTeam, agent), false);
assert.equal(canUpdateUser(manager, agent), true);
assert.equal(canUpdateUser(manager, admin), false);
assert.equal(canUpdateUser(manager, otherAgent), false);
});
test('only admins can manage role, team, and status fields', () => {
assert.equal(canManageUserRoleOrTeam(admin), true);
assert.equal(canManageUserRoleOrTeam(manager), false);
assert.equal(canManageUserStatus(admin), true);
assert.equal(canManageUserStatus(manager), false);
assert.equal(canChangeUserEmail(agent, agent), true);
assert.equal(canChangeUserEmail(manager, agent), false);
});
test('attendance detail policy matches role boundaries', () => {
const ownAttendance = { id: 'att_1', tenant_id: 'tenant_a', user_id: 'u_agent', team_id: 'team_a' };
const teamAttendance = { id: 'att_2', tenant_id: 'tenant_a', user_id: 'u_another', team_id: 'team_a' };
const otherTeamAttendance = { id: 'att_3', tenant_id: 'tenant_a', user_id: 'u_other_agent', team_id: 'team_b' };
const foreignAttendance = { id: 'att_4', tenant_id: 'tenant_b', user_id: 'u_foreign', team_id: 'team_x' };
assert.equal(canReadAttendance(agent, ownAttendance), true);
assert.equal(canReadAttendance(agent, teamAttendance), false);
assert.equal(canReadAttendance(manager, teamAttendance), true);
assert.equal(canReadAttendance(manager, otherTeamAttendance), false);
assert.equal(canReadAttendance(admin, otherTeamAttendance), true);
assert.equal(canReadAttendance(admin, foreignAttendance), false);
assert.equal(canReadAttendance(superAdmin, foreignAttendance), true);
});

View File

@@ -0,0 +1,22 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { stripEnvQuotes, hashSecret, maskSecret } = require('../utils/security');
test('stripEnvQuotes removes leading and trailing double quotes from env values', () => {
assert.equal(stripEnvQuotes('"secret"'), 'secret');
assert.equal(stripEnvQuotes('secret'), 'secret');
assert.equal(stripEnvQuotes('"partly'), 'partly');
});
test('hashSecret returns a stable sha256 digest without exposing the secret', () => {
const first = hashSecret('fasto_sk_example');
const second = hashSecret('fasto_sk_example');
assert.equal(first, second);
assert.equal(first.length, 64);
assert.notEqual(first, 'fasto_sk_example');
});
test('maskSecret stores only an id and secret suffix', () => {
assert.equal(maskSecret('rt_123', 'abcdefghijklmnopqrstuvwxyz'), 'masked:rt_123:uvwxyz');
});

11
backend/utils/security.js Normal file
View File

@@ -0,0 +1,11 @@
const crypto = require('crypto');
const stripEnvQuotes = (value = '') => value.replace(/^"|"$/g, '');
const hashSecret = (value) => crypto.createHash('sha256').update(value).digest('hex');
const maskSecret = (id, value) => `masked:${id}:${value.slice(-6)}`;
module.exports = {
stripEnvQuotes,
hashSecret,
maskSecret,
};

View File

@@ -12,11 +12,14 @@ services:
- DB_USER=${DB_USER:-root}
- DB_PASSWORD=${DB_PASSWORD:-root_password}
- DB_NAME=${DB_NAME:-agenciac_comia}
- JWT_SECRET=${JWT_SECRET:-fasto_local_dev_secret}
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:3001}
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT}
- SMTP_USER=${SMTP_USER}
- SMTP_PASS=${SMTP_PASS}
- MAIL_FROM=${MAIL_FROM}
- SMTP_DEBUG=${SMTP_DEBUG:-false}
volumes:
- ./dist:/app/dist # Map local build to container
- ./backend/index.js:/app/index.js

View File

@@ -17,6 +17,8 @@ services:
- MAIL_FROM=${MAIL_FROM}
- APP_URL=${APP_URL}
- JWT_SECRET=${JWT_SECRET}
- CORS_ORIGIN=${CORS_ORIGIN}
- SMTP_DEBUG=${SMTP_DEBUG:-false}
ports:
- "3001:3001"
deploy:

View File

@@ -1,5 +1,5 @@
{
"name": "CTMS - Commercial Team Management System",
"description": "A multi-tenant dashboard for commercial teams to track sales performance, analyzing attendance scores and funnel metrics.",
"name": "Fasto",
"description": "A multi-tenant commercial team management system for sales performance, attendance analysis, funnels, lead origins, and external integrations.",
"requestFramePermissions": []
}

View File

@@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test:backend": "npm --prefix backend test"
},
"dependencies": {
"bcryptjs": "^3.0.3",

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react";
import React, { lazy, Suspense, useState, useEffect } from "react";
import {
HashRouter as Router,
Routes,
@@ -7,24 +7,33 @@ import {
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 { Ranking } from "./pages/Ranking";
import { getUserById, logout } from "./services/dataService";
import { User } from "./types";
const Dashboard = lazy(() => import("./pages/Dashboard").then((module) => ({ default: module.Dashboard })));
const UserDetail = lazy(() => import("./pages/UserDetail").then((module) => ({ default: module.UserDetail })));
const AttendanceDetail = lazy(() => import("./pages/AttendanceDetail").then((module) => ({ default: module.AttendanceDetail })));
const SuperAdmin = lazy(() => import("./pages/SuperAdmin").then((module) => ({ default: module.SuperAdmin })));
const ApiKeys = lazy(() => import("./pages/ApiKeys").then((module) => ({ default: module.ApiKeys })));
const TeamManagement = lazy(() => import("./pages/TeamManagement").then((module) => ({ default: module.TeamManagement })));
const Teams = lazy(() => import("./pages/Teams").then((module) => ({ default: module.Teams })));
const Funnels = lazy(() => import("./pages/Funnels").then((module) => ({ default: module.Funnels })));
const Origins = lazy(() => import("./pages/Origins").then((module) => ({ default: module.Origins })));
const Login = lazy(() => import("./pages/Login").then((module) => ({ default: module.Login })));
const Register = lazy(() => import("./pages/Register").then((module) => ({ default: module.Register })));
const VerifyCode = lazy(() => import("./pages/VerifyCode").then((module) => ({ default: module.VerifyCode })));
const ForgotPassword = lazy(() => import("./pages/ForgotPassword").then((module) => ({ default: module.ForgotPassword })));
const ResetPassword = lazy(() => import("./pages/ResetPassword").then((module) => ({ default: module.ResetPassword })));
const SetupAccount = lazy(() => import("./pages/SetupAccount").then((module) => ({ default: module.SetupAccount })));
const UserProfile = lazy(() => import("./pages/UserProfile").then((module) => ({ default: module.UserProfile })));
const Ranking = lazy(() => import("./pages/Ranking").then((module) => ({ default: module.Ranking })));
const PageLoader = () => (
<div className="flex h-screen items-center justify-center bg-zinc-50 dark:bg-zinc-950 text-zinc-400">
Carregando...
</div>
);
const AuthGuard: React.FC<{ children: React.ReactNode; roles?: string[] }> = ({
children,
roles,
@@ -107,8 +116,11 @@ const AuthGuard: React.FC<{ children: React.ReactNode; roles?: string[] }> = ({
const App: React.FC = () => {
return (
<Router>
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/verify-code" element={<VerifyCode />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/setup-account" element={<SetupAccount />} />
@@ -202,6 +214,7 @@ const App: React.FC = () => {
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</Router>
);
};

View File

@@ -22,7 +22,7 @@ export const Register: React.FC = () => {
try {
const success = await register(formData);
if (success) {
navigate('/login', { state: { message: 'Conta criada! Verifique seu e-mail para o código de ativação.' } });
navigate('/verify-code', { state: { email: formData.email } });
}
} catch (err: any) {
setError(err.message || 'Erro ao criar conta.');

93
src/services/apiClient.ts Normal file
View File

@@ -0,0 +1,93 @@
// Em produção, usa caminho relativo porque o backend serve o frontend.
// Em desenvolvimento, aponta para o backend local.
export const API_URL = import.meta.env.PROD ? '/api' : 'http://localhost:3001/api';
export const getHeaders = (customToken?: string) => {
const token = customToken || localStorage.getItem('ctms_token');
if (!token || token === 'undefined' || token === 'null') return { 'Content-Type': 'application/json' };
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
};
};
let isRefreshing = false;
let refreshSubscribers: ((token: string) => void)[] = [];
let sessionExpiredHandler: (() => void) | null = null;
export const setSessionExpiredHandler = (handler: () => void) => {
sessionExpiredHandler = handler;
};
const onRefreshed = (token: string) => {
refreshSubscribers.forEach(cb => cb(token));
refreshSubscribers = [];
};
const addRefreshSubscriber = (cb: (token: string) => void) => {
refreshSubscribers.push(cb);
};
const expireSession = () => {
if (sessionExpiredHandler) {
sessionExpiredHandler();
return;
}
localStorage.removeItem('ctms_token');
localStorage.removeItem('ctms_refresh_token');
localStorage.removeItem('ctms_user_id');
localStorage.removeItem('ctms_tenant_id');
};
export const apiFetch = async (url: string, options: RequestInit = {}): Promise<Response> => {
let response = await fetch(url, options);
if (response.status === 401 && !url.includes('/auth/login') && !url.includes('/auth/refresh')) {
const refreshToken = localStorage.getItem('ctms_refresh_token');
if (!refreshToken) {
expireSession();
return response;
}
if (isRefreshing) {
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);
options.headers = getHeaders(data.token);
response = await fetch(url, options);
onRefreshed(data.token);
} catch (err) {
console.error("Session expired or refresh failed:", err);
expireSession();
} finally {
isRefreshing = false;
}
}
return response;
};

View File

@@ -1,88 +1,6 @@
import { Attendance, DashboardFilter, User } from '../types';
// URL do Backend
// Em produção (import.meta.env.PROD), usa caminho relativo '/api' pois o backend serve o frontend
// Em desenvolvimento, aponta para o localhost:3001
const API_URL = import.meta.env.PROD ? '/api' : 'http://localhost:3001/api';
const getHeaders = (customToken?: string) => {
const token = customToken || localStorage.getItem('ctms_token');
// Evitar enviar "undefined" ou "null" como strings se o localStorage estiver corrompido
if (!token || token === 'undefined' || token === 'null') return { 'Content-Type': 'application/json' };
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
};
};
// 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;
};
import { API_URL, apiFetch, getHeaders, setSessionExpiredHandler } from './apiClient';
export const getNotifications = async (): Promise<any[]> => {
try {
@@ -693,6 +611,8 @@ export const logout = () => {
}
};
setSessionExpiredHandler(logout);
export const login = async (credentials: any): Promise<any> => {
const response = await fetch(`${API_URL}/auth/login`, {
method: 'POST',

View File

@@ -1,23 +1,16 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
export default defineConfig({
server: {
port: 3000,
host: '0.0.0.0',
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
},
},
});