Harden auth and clean project setup
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
PORT=3001
|
PORT=3001
|
||||||
|
CORS_ORIGIN=http://localhost:3001
|
||||||
DB_HOST=db
|
DB_HOST=db
|
||||||
DB_USER=root
|
DB_USER=root
|
||||||
DB_PASSWORD=root_password
|
DB_PASSWORD=root_password
|
||||||
@@ -11,3 +12,4 @@ SMTP_PORT=587
|
|||||||
SMTP_USER=nao-responda@blyzer.com.br
|
SMTP_USER=nao-responda@blyzer.com.br
|
||||||
SMTP_PASS=your_smtp_password_here
|
SMTP_PASS=your_smtp_password_here
|
||||||
MAIL_FROM=nao-responda@blyzer.com.br
|
MAIL_FROM=nao-responda@blyzer.com.br
|
||||||
|
SMTP_DEBUG=false
|
||||||
|
|||||||
88
CONTEXT.md
Normal file
88
CONTEXT.md
Normal 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.
|
||||||
48
README.md
48
README.md
@@ -1,20 +1,42 @@
|
|||||||
<div align="center">
|
# Fasto
|
||||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
# 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:
|
Start the app and database:
|
||||||
`npm install`
|
|
||||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
```bash
|
||||||
3. Run the app:
|
docker-compose -f docker-compose.local.yml up -d --build
|
||||||
`npm run dev`
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const mysql = require('mysql2/promise');
|
|||||||
const dbConfig = {
|
const dbConfig = {
|
||||||
host: process.env.DB_HOST || 'localhost',
|
host: process.env.DB_HOST || 'localhost',
|
||||||
user: process.env.DB_USER || 'root',
|
user: process.env.DB_USER || 'root',
|
||||||
password: process.env.DB_PASSWORD || 'password',
|
password: process.env.DB_PASSWORD || '',
|
||||||
database: process.env.DB_NAME || 'agenciac_comia',
|
database: process.env.DB_NAME || 'agenciac_comia',
|
||||||
waitForConnections: true,
|
waitForConnections: true,
|
||||||
connectionLimit: 10,
|
connectionLimit: 10,
|
||||||
|
|||||||
132
backend/index.js
132
backend/index.js
@@ -13,21 +13,32 @@ const pool = require('./db');
|
|||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
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 stripEnvQuotes = (value = '') => value.replace(/^"|"$/g, '');
|
||||||
|
const hashSecret = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
||||||
|
const maskSecret = (id, value) => `masked:${id}:${value.slice(-6)}`;
|
||||||
|
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({
|
const transporter = nodemailer.createTransport({
|
||||||
host: process.env.SMTP_HOST || 'mail.blyzer.com.br',
|
host: process.env.SMTP_HOST || 'mail.blyzer.com.br',
|
||||||
port: parseInt(process.env.SMTP_PORT) || 587,
|
port: parseInt(process.env.SMTP_PORT) || 587,
|
||||||
secure: false, // false para 587 (STARTTLS)
|
secure: false, // false para 587 (STARTTLS)
|
||||||
auth: {
|
auth: {
|
||||||
user: (process.env.SMTP_USER || 'nao-responda@blyzer.com.br').replace(/^"|"$/g, ''),
|
user: stripEnvQuotes(process.env.SMTP_USER || 'nao-responda@blyzer.com.br'),
|
||||||
pass: (process.env.SMTP_PASS || 'Compor@2017#').replace(/^"|"$/g, ''),
|
pass: stripEnvQuotes(process.env.SMTP_PASS || ''),
|
||||||
},
|
},
|
||||||
tls: {
|
tls: {
|
||||||
ciphers: 'SSLv3',
|
ciphers: 'SSLv3',
|
||||||
rejectUnauthorized: false
|
rejectUnauthorized: false
|
||||||
},
|
},
|
||||||
debug: true,
|
debug: process.env.SMTP_DEBUG === 'true',
|
||||||
logger: true
|
logger: process.env.SMTP_DEBUG === 'true'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper para obter a URL base
|
// Helper para obter a URL base
|
||||||
@@ -53,7 +64,20 @@ const getStartupBaseUrl = () => {
|
|||||||
return 'http://localhost:3001';
|
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());
|
app.use(express.json());
|
||||||
|
|
||||||
// Logger de Requisições
|
// Logger de Requisições
|
||||||
@@ -111,7 +135,10 @@ const authenticateToken = async (req, res, next) => {
|
|||||||
if (authHeader && authHeader.startsWith('Bearer fasto_sk_')) {
|
if (authHeader && authHeader.startsWith('Bearer fasto_sk_')) {
|
||||||
const apiKey = authHeader.split(' ')[1];
|
const apiKey = authHeader.split(' ')[1];
|
||||||
try {
|
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.' });
|
if (keys.length === 0) return res.status(401).json({ error: 'Chave de API inválida.' });
|
||||||
|
|
||||||
// Update last used timestamp
|
// Update last used timestamp
|
||||||
@@ -247,8 +274,8 @@ apiRouter.post('/auth/login', async (req, res) => {
|
|||||||
|
|
||||||
// Store Refresh Token in database (expires in 30 days)
|
// Store Refresh Token in database (expires in 30 days)
|
||||||
await pool.query(
|
await pool.query(
|
||||||
'INSERT INTO refresh_tokens (id, user_id, token, expires_at) VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY))',
|
'INSERT INTO refresh_tokens (id, user_id, token, token_hash, expires_at) VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY))',
|
||||||
[refreshId, user.id, refreshToken]
|
[refreshId, user.id, maskSecret(refreshId, refreshToken), hashSecret(refreshToken)]
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -269,25 +296,25 @@ apiRouter.post('/auth/refresh', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
// Verifies if the token exists and hasn't expired
|
// Verifies if the token exists and hasn't expired
|
||||||
const [tokens] = await pool.query(
|
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()',
|
'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()',
|
||||||
[refreshToken]
|
[hashSecret(refreshToken), refreshToken]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (tokens.length === 0) {
|
if (tokens.length === 0) {
|
||||||
// If invalid, optionally delete the bad token if it's just expired
|
// 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.' });
|
return res.status(401).json({ error: 'Sessão expirada. Faça login novamente.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = tokens[0];
|
const user = tokens[0];
|
||||||
|
|
||||||
if (user.status !== 'active') {
|
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.' });
|
return res.status(403).json({ error: 'Sua conta está inativa.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sliding Expiration: Extend the refresh token's life by another 30 days
|
// 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
|
// Issue a new short-lived access token
|
||||||
const newAccessToken = jwt.sign(
|
const newAccessToken = jwt.sign(
|
||||||
@@ -307,7 +334,7 @@ apiRouter.post('/auth/logout', async (req, res) => {
|
|||||||
const { refreshToken } = req.body;
|
const { refreshToken } = req.body;
|
||||||
try {
|
try {
|
||||||
if (refreshToken) {
|
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.' });
|
res.json({ message: 'Logout bem-sucedido.' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -433,7 +460,7 @@ apiRouter.get('/users', async (req, res) => {
|
|||||||
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;
|
||||||
|
|
||||||
let q = 'SELECT * FROM users';
|
let q = `SELECT ${USER_PUBLIC_FIELDS} FROM users`;
|
||||||
const params = [];
|
const params = [];
|
||||||
if (effectiveTenantId && effectiveTenantId !== 'all') {
|
if (effectiveTenantId && effectiveTenantId !== 'all') {
|
||||||
q += ' WHERE tenant_id = ?';
|
q += ' WHERE tenant_id = ?';
|
||||||
@@ -458,13 +485,23 @@ apiRouter.get('/users', async (req, res) => {
|
|||||||
|
|
||||||
apiRouter.get('/users/:idOrSlug', async (req, res) => {
|
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 ${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 (!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 || !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.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.user.role === 'agent' && rows[0].id !== req.user.id) {
|
||||||
|
return res.status(403).json({ error: 'Acesso negado.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.user.role === 'manager') {
|
||||||
|
const canSeeUser = rows[0].id === req.user.id || (req.user.team_id && rows[0].team_id === req.user.team_id);
|
||||||
|
if (!canSeeUser) return res.status(403).json({ error: 'Acesso negado.' });
|
||||||
|
}
|
||||||
|
|
||||||
res.json(rows[0]);
|
res.json(rows[0]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in GET /users/:idOrSlug:', error);
|
console.error('Error in GET /users/:idOrSlug:', error);
|
||||||
@@ -482,6 +519,7 @@ apiRouter.post('/users', requireRole(['admin', 'manager', 'super_admin']), async
|
|||||||
let finalTeamId = team_id || null;
|
let finalTeamId = team_id || null;
|
||||||
|
|
||||||
if (req.user.role === 'manager') {
|
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
|
finalRole = 'agent'; // Force manager creations to be agents
|
||||||
finalTeamId = req.user.team_id; // Force assignment to manager's team
|
finalTeamId = req.user.team_id; // Force assignment to manager's team
|
||||||
}
|
}
|
||||||
@@ -557,8 +595,14 @@ apiRouter.put('/users/:id', async (req, res) => {
|
|||||||
// Only Admins can change roles and teams. Managers can only edit basic info of their team members.
|
// 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 finalRole = isAdmin && role !== undefined ? role : existing[0].role;
|
||||||
const finalTeamId = isAdmin && team_id !== undefined ? team_id : existing[0].team_id;
|
const finalTeamId = isAdmin && team_id !== undefined ? team_id : existing[0].team_id;
|
||||||
const finalStatus = isManagerOrAdmin && status !== undefined ? status : existing[0].status;
|
if (req.user.role === 'manager') {
|
||||||
const finalEmail = email !== undefined ? email : existing[0].email;
|
const canEditUser = existing[0].id !== req.user.id && req.user.team_id && existing[0].team_id === req.user.team_id && existing[0].role === 'agent';
|
||||||
|
if (!isSelf && !canEditUser) return res.status(403).json({ error: 'Acesso negado.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalStatus = isAdmin && status !== undefined ? status : existing[0].status;
|
||||||
|
const canChangeEmail = isSelf || isAdmin;
|
||||||
|
const finalEmail = canChangeEmail && 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);
|
||||||
|
|
||||||
if (finalEmail !== existing[0].email) {
|
if (finalEmail !== existing[0].email) {
|
||||||
@@ -1076,13 +1120,24 @@ apiRouter.get('/attendances', async (req, res) => {
|
|||||||
|
|
||||||
apiRouter.get('/attendances/:id', async (req, res) => {
|
apiRouter.get('/attendances/:id', async (req, res) => {
|
||||||
try {
|
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 (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) {
|
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.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.user.role === 'agent' && rows[0].user_id !== req.user.id) {
|
||||||
|
return res.status(403).json({ error: 'Acesso negado.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.user.role === 'manager' && (!req.user.team_id || rows[0].team_id !== req.user.team_id)) {
|
||||||
|
return res.status(403).json({ error: 'Acesso negado.' });
|
||||||
|
}
|
||||||
|
|
||||||
const r = rows[0];
|
const r = rows[0];
|
||||||
res.json({
|
res.json({
|
||||||
...r,
|
...r,
|
||||||
@@ -1100,7 +1155,10 @@ apiRouter.get('/api-keys', requireRole(['admin', 'super_admin']), async (req, re
|
|||||||
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
|
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
|
||||||
if (!effectiveTenantId || effectiveTenantId === 'all') return res.json([]);
|
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);
|
res.json(rows);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
@@ -1116,8 +1174,8 @@ apiRouter.post('/api-keys', requireRole(['admin', 'super_admin']), async (req, r
|
|||||||
const secretKey = `fasto_sk_${crypto.randomBytes(32).toString('hex')}`;
|
const secretKey = `fasto_sk_${crypto.randomBytes(32).toString('hex')}`;
|
||||||
|
|
||||||
await pool.query(
|
await pool.query(
|
||||||
'INSERT INTO api_keys (id, tenant_id, name, secret_key) VALUES (?, ?, ?, ?)',
|
'INSERT INTO api_keys (id, tenant_id, name, secret_key, secret_hash) VALUES (?, ?, ?, ?, ?)',
|
||||||
[id, effectiveTenantId, name || 'Nova Integração API', secretKey]
|
[id, effectiveTenantId, name || 'Nova Integração API', maskSecret(id, secretKey), hashSecret(secretKey)]
|
||||||
);
|
);
|
||||||
|
|
||||||
// We only return the actual secret key ONCE during creation.
|
// We only return the actual secret key ONCE during creation.
|
||||||
@@ -1639,28 +1697,56 @@ const provisionSuperAdmin = async (retries = 10, delay = 10000) => {
|
|||||||
tenant_id varchar(36) NOT NULL,
|
tenant_id varchar(36) NOT NULL,
|
||||||
name varchar(255) NOT NULL,
|
name varchar(255) NOT NULL,
|
||||||
secret_key varchar(255) NOT NULL,
|
secret_key varchar(255) NOT NULL,
|
||||||
|
secret_hash varchar(64) DEFAULT NULL,
|
||||||
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
last_used_at timestamp NULL DEFAULT NULL,
|
last_used_at timestamp NULL DEFAULT NULL,
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY secret_key (secret_key),
|
UNIQUE KEY secret_key (secret_key),
|
||||||
|
UNIQUE KEY secret_hash (secret_hash),
|
||||||
KEY tenant_id (tenant_id)
|
KEY tenant_id (tenant_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) 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
|
// Create refresh_tokens table for persistent sessions
|
||||||
await connection.query(`
|
await connection.query(`
|
||||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
id varchar(36) NOT NULL,
|
id varchar(36) NOT NULL,
|
||||||
user_id varchar(36) NOT NULL,
|
user_id varchar(36) NOT NULL,
|
||||||
token varchar(255) NOT NULL,
|
token varchar(255) NOT NULL,
|
||||||
|
token_hash varchar(64) DEFAULT NULL,
|
||||||
expires_at timestamp NOT NULL,
|
expires_at timestamp NOT NULL,
|
||||||
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY token (token),
|
UNIQUE KEY token (token),
|
||||||
|
UNIQUE KEY token_hash (token_hash),
|
||||||
KEY user_id (user_id)
|
KEY user_id (user_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) 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
|
// 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");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "CTMS - Commercial Team Management System",
|
"name": "Fasto",
|
||||||
"description": "A multi-tenant dashboard for commercial teams to track sales performance, analyzing attendance scores and funnel metrics.",
|
"description": "A multi-tenant commercial team management system for sales performance, attendance analysis, funnels, lead origins, and external integrations.",
|
||||||
"requestFramePermissions": []
|
"requestFramePermissions": []
|
||||||
}
|
}
|
||||||
53
src/App.tsx
53
src/App.tsx
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { lazy, Suspense, useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
HashRouter as Router,
|
HashRouter as Router,
|
||||||
Routes,
|
Routes,
|
||||||
@@ -7,24 +7,33 @@ import {
|
|||||||
useLocation,
|
useLocation,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import { Layout } from "./components/Layout";
|
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 { getUserById, logout } from "./services/dataService";
|
||||||
import { User } from "./types";
|
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[] }> = ({
|
const AuthGuard: React.FC<{ children: React.ReactNode; roles?: string[] }> = ({
|
||||||
children,
|
children,
|
||||||
roles,
|
roles,
|
||||||
@@ -107,9 +116,12 @@ const AuthGuard: React.FC<{ children: React.ReactNode; roles?: string[] }> = ({
|
|||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<Router>
|
<Router>
|
||||||
<Routes>
|
<Suspense fallback={<PageLoader />}>
|
||||||
<Route path="/login" element={<Login />} />
|
<Routes>
|
||||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
<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="/reset-password" element={<ResetPassword />} />
|
||||||
<Route path="/setup-account" element={<SetupAccount />} />
|
<Route path="/setup-account" element={<SetupAccount />} />
|
||||||
<Route
|
<Route
|
||||||
@@ -201,7 +213,8 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
</Router>
|
</Router>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const Register: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const success = await register(formData);
|
const success = await register(formData);
|
||||||
if (success) {
|
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) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Erro ao criar conta.');
|
setError(err.message || 'Erro ao criar conta.');
|
||||||
|
|||||||
@@ -1,23 +1,16 @@
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { defineConfig, loadEnv } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig({
|
||||||
const env = loadEnv(mode, '.', '');
|
server: {
|
||||||
return {
|
port: 3000,
|
||||||
server: {
|
host: '0.0.0.0',
|
||||||
port: 3000,
|
},
|
||||||
host: '0.0.0.0',
|
plugins: [react()],
|
||||||
},
|
resolve: {
|
||||||
plugins: [react()],
|
alias: {
|
||||||
define: {
|
'@': path.resolve(__dirname, '.'),
|
||||||
'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, '.'),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user