Compare commits
5 Commits
be8f056434
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39ac833495 | ||
|
|
c512809a38 | ||
|
|
aa59e642af | ||
|
|
5648dc7986 | ||
|
|
2c102eb2dd |
@@ -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
|
||||||
|
|||||||
10
Dockerfile
10
Dockerfile
@@ -17,15 +17,19 @@ WORKDIR /app
|
|||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
# Copy backend package.json as main package.json
|
# Copy backend package metadata as runtime package metadata
|
||||||
COPY backend/package.json ./package.json
|
COPY backend/package.json backend/package-lock.json ./
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN npm install --omit=dev
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
# Copy backend source directly into root
|
# Copy backend source directly into root
|
||||||
COPY backend/index.js ./index.js
|
COPY backend/index.js ./index.js
|
||||||
COPY backend/db.js ./db.js
|
COPY backend/db.js ./db.js
|
||||||
|
COPY backend/config ./config
|
||||||
|
COPY backend/policies ./policies
|
||||||
|
COPY backend/services ./services
|
||||||
|
COPY backend/utils ./utils
|
||||||
|
|
||||||
# Copy built frontend
|
# Copy built frontend
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|||||||
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.
|
||||||
|
|||||||
13
backend/config/cors.js
Normal file
13
backend/config/cors.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
const cors = require('cors');
|
||||||
|
|
||||||
|
const createCorsMiddleware = ({ allowedOrigins, isProduction }) => 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
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = { createCorsMiddleware };
|
||||||
50
backend/config/runtime.js
Normal file
50
backend/config/runtime.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
const { stripEnvQuotes } = require('../utils/security');
|
||||||
|
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
const port = process.env.PORT || 3001;
|
||||||
|
const jwtSecret = process.env.JWT_SECRET || (isProduction ? null : 'fasto_dev_secret_change_me');
|
||||||
|
|
||||||
|
if (!jwtSecret) {
|
||||||
|
throw new Error('JWT_SECRET is required in production.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedOrigins = (process.env.CORS_ORIGIN || '')
|
||||||
|
.split(',')
|
||||||
|
.map(origin => origin.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const smtp = {
|
||||||
|
host: process.env.SMTP_HOST || 'mail.blyzer.com.br',
|
||||||
|
port: parseInt(process.env.SMTP_PORT, 10) || 587,
|
||||||
|
user: stripEnvQuotes(process.env.SMTP_USER || 'nao-responda@blyzer.com.br'),
|
||||||
|
pass: stripEnvQuotes(process.env.SMTP_PASS || ''),
|
||||||
|
debug: process.env.SMTP_DEBUG === 'true',
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBaseUrl = (req) => {
|
||||||
|
if (process.env.APP_URL) return process.env.APP_URL;
|
||||||
|
|
||||||
|
const host = req ? (req.get('host') || 'localhost:3001') : 'localhost:3001';
|
||||||
|
const protocol = (req && (req.protocol === 'https' || req.get('x-forwarded-proto') === 'https')) ? 'https' : 'http';
|
||||||
|
|
||||||
|
if (isProduction && !host.includes('localhost')) {
|
||||||
|
return `https://${host}`;
|
||||||
|
}
|
||||||
|
return `${protocol}://${host}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStartupBaseUrl = () => {
|
||||||
|
if (process.env.APP_URL) return process.env.APP_URL;
|
||||||
|
if (isProduction) return 'https://fasto.blyzer.com.br';
|
||||||
|
return 'http://localhost:3001';
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
allowedOrigins,
|
||||||
|
getBaseUrl,
|
||||||
|
getStartupBaseUrl,
|
||||||
|
isProduction,
|
||||||
|
jwtSecret,
|
||||||
|
port,
|
||||||
|
smtp,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
156
backend/index.js
156
backend/index.js
@@ -1,59 +1,30 @@
|
|||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const nodemailer = require('nodemailer');
|
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const pool = require('./db');
|
const pool = require('./db');
|
||||||
|
const { hashSecret, maskSecret } = require('./utils/security');
|
||||||
|
const transporter = require('./services/mailer');
|
||||||
|
const { createCorsMiddleware } = require('./config/cors');
|
||||||
|
const { allowedOrigins, getBaseUrl, getStartupBaseUrl, isProduction, jwtSecret: JWT_SECRET, port: PORT } = require('./config/runtime');
|
||||||
|
const {
|
||||||
|
canReadUser,
|
||||||
|
canUpdateUser,
|
||||||
|
canManageUserStatus,
|
||||||
|
canChangeUserEmail,
|
||||||
|
canManageUserRoleOrTeam,
|
||||||
|
canReadAttendance,
|
||||||
|
} = require('./policies/accessPolicy');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const USER_PUBLIC_FIELDS = 'id, tenant_id, team_id, name, email, slug, role, status, bio, avatar_url, sound_enabled, created_at';
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'fasto_super_secret_key';
|
|
||||||
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, ''),
|
|
||||||
},
|
|
||||||
tls: {
|
|
||||||
ciphers: 'SSLv3',
|
|
||||||
rejectUnauthorized: false
|
|
||||||
},
|
|
||||||
debug: true,
|
|
||||||
logger: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helper para obter a URL base
|
app.use(createCorsMiddleware({ allowedOrigins, isProduction }));
|
||||||
const getBaseUrl = (req) => {
|
|
||||||
// Use explicit environment variable if set
|
|
||||||
if (process.env.APP_URL) return process.env.APP_URL;
|
|
||||||
|
|
||||||
// Otherwise, attempt to construct it from the request object dynamically
|
|
||||||
const host = req ? (req.get('host') || 'localhost:3001') : 'localhost:3001';
|
|
||||||
const protocol = (req && (req.protocol === 'https' || req.get('x-forwarded-proto') === 'https')) ? 'https' : 'http';
|
|
||||||
|
|
||||||
// Se estivermos em produção e o host não for localhost, force HTTPS
|
|
||||||
if (process.env.NODE_ENV === 'production' && !host.includes('localhost')) {
|
|
||||||
return `https://${host}`;
|
|
||||||
}
|
|
||||||
return `${protocol}://${host}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Quando não temos a request (ex: startup), usamos uma versão simplificada
|
|
||||||
const getStartupBaseUrl = () => {
|
|
||||||
if (process.env.APP_URL) return process.env.APP_URL;
|
|
||||||
if (process.env.NODE_ENV === 'production') return 'https://fasto.blyzer.com.br';
|
|
||||||
return 'http://localhost:3001';
|
|
||||||
};
|
|
||||||
|
|
||||||
app.use(cors());
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
// Logger de Requisições
|
// Logger de Requisições
|
||||||
@@ -111,7 +82,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 +221,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 +243,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 +281,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 +407,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 +432,12 @@ 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 (!canReadUser(req.user, rows[0])) return res.status(403).json({ error: 'Acesso negado.' });
|
||||||
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 +455,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
|
||||||
}
|
}
|
||||||
@@ -542,24 +516,14 @@ apiRouter.put('/users/:id', async (req, res) => {
|
|||||||
const [existing] = await pool.query('SELECT * FROM users WHERE id = ?', [req.params.id]);
|
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' });
|
if (existing.length === 0) return res.status(404).json({ error: 'Not found' });
|
||||||
|
|
||||||
const isSelf = req.user.id === req.params.id;
|
if (!canUpdateUser(req.user, existing[0])) return res.status(403).json({ error: 'Acesso negado.' });
|
||||||
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.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 = canManageUserRoleOrTeam(req.user) && role !== undefined ? role : existing[0].role;
|
||||||
const finalTeamId = isAdmin && team_id !== undefined ? team_id : existing[0].team_id;
|
const finalTeamId = canManageUserRoleOrTeam(req.user) && team_id !== undefined ? team_id : existing[0].team_id;
|
||||||
const finalStatus = isManagerOrAdmin && status !== undefined ? status : existing[0].status;
|
const finalStatus = canManageUserStatus(req.user) && status !== undefined ? status : existing[0].status;
|
||||||
const finalEmail = email !== undefined ? email : existing[0].email;
|
const finalEmail = canChangeUserEmail(req.user, existing[0]) && email !== undefined ? email : existing[0].email;
|
||||||
const finalSoundEnabled = isSelf && sound_enabled !== undefined ? sound_enabled : (existing[0].sound_enabled ?? true);
|
const finalSoundEnabled = req.user.id === req.params.id && sound_enabled !== undefined ? sound_enabled : (existing[0].sound_enabled ?? true);
|
||||||
|
|
||||||
if (finalEmail !== existing[0].email) {
|
if (finalEmail !== existing[0].email) {
|
||||||
const [emailCheck] = await pool.query('SELECT id FROM users WHERE email = ? AND id != ?', [finalEmail, req.params.id]);
|
const [emailCheck] = await pool.query('SELECT id FROM users WHERE email = ? AND id != ?', [finalEmail, req.params.id]);
|
||||||
@@ -1076,12 +1040,13 @@ 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 (!canReadAttendance(req.user, rows[0])) return res.status(403).json({ error: 'Acesso negado.' });
|
||||||
return res.status(403).json({ error: 'Acesso negado.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const r = rows[0];
|
const r = rows[0];
|
||||||
res.json({
|
res.json({
|
||||||
@@ -1100,7 +1065,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 +1084,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 +1607,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");
|
||||||
|
|||||||
68
backend/package-lock.json
generated
68
backend/package-lock.json
generated
@@ -73,9 +73,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.4",
|
"version": "1.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
||||||
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
|
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bytes": "~3.1.2",
|
"bytes": "~3.1.2",
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
"http-errors": "~2.0.1",
|
"http-errors": "~2.0.1",
|
||||||
"iconv-lite": "~0.4.24",
|
"iconv-lite": "~0.4.24",
|
||||||
"on-finished": "~2.4.1",
|
"on-finished": "~2.4.1",
|
||||||
"qs": "~6.14.0",
|
"qs": "~6.15.1",
|
||||||
"raw-body": "~2.5.3",
|
"raw-body": "~2.5.3",
|
||||||
"type-is": "~1.6.18",
|
"type-is": "~1.6.18",
|
||||||
"unpipe": "~1.0.0"
|
"unpipe": "~1.0.0"
|
||||||
@@ -331,9 +331,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/es-object-atoms": {
|
"node_modules/es-object-atoms": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0"
|
"es-errors": "^1.3.0"
|
||||||
@@ -358,14 +358,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express": {
|
"node_modules/express": {
|
||||||
"version": "4.22.1",
|
"version": "4.22.2",
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "~1.3.8",
|
"accepts": "~1.3.8",
|
||||||
"array-flatten": "1.1.1",
|
"array-flatten": "1.1.1",
|
||||||
"body-parser": "~1.20.3",
|
"body-parser": "~1.20.5",
|
||||||
"content-disposition": "~0.5.4",
|
"content-disposition": "~0.5.4",
|
||||||
"content-type": "~1.0.4",
|
"content-type": "~1.0.4",
|
||||||
"cookie": "~0.7.1",
|
"cookie": "~0.7.1",
|
||||||
@@ -384,7 +384,7 @@
|
|||||||
"parseurl": "~1.3.3",
|
"parseurl": "~1.3.3",
|
||||||
"path-to-regexp": "~0.1.12",
|
"path-to-regexp": "~0.1.12",
|
||||||
"proxy-addr": "~2.0.7",
|
"proxy-addr": "~2.0.7",
|
||||||
"qs": "~6.14.0",
|
"qs": "~6.15.1",
|
||||||
"range-parser": "~1.2.1",
|
"range-parser": "~1.2.1",
|
||||||
"safe-buffer": "5.2.1",
|
"safe-buffer": "5.2.1",
|
||||||
"send": "~0.19.0",
|
"send": "~0.19.0",
|
||||||
@@ -519,9 +519,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
@@ -771,9 +771,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/multer": {
|
"node_modules/multer": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
|
||||||
"integrity": "sha512-TBm6j41rxNohqawsxlsWsNNh/VdV4QFXcBvRcPhXaA05EZ79z0qJ2bQFpync6JBoHTeNY5Q1JpG7AlTjdlfAEA==",
|
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"append-field": "^1.0.0",
|
"append-field": "^1.0.0",
|
||||||
@@ -849,9 +849,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemailer": {
|
"node_modules/nodemailer": {
|
||||||
"version": "8.0.1",
|
"version": "8.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.9.tgz",
|
||||||
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
|
"integrity": "sha512-5ofa7BUN8+C+Hckh5V2GjeeOGRQBx0CJQA6KxrvuZfC8iU4/q7sLn8XrtEEhJkjV6HdyIiQs7Bba6bTao8JhkA==",
|
||||||
"license": "MIT-0",
|
"license": "MIT-0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
@@ -900,9 +900,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/path-to-regexp": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "0.1.12",
|
"version": "0.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/proxy-addr": {
|
"node_modules/proxy-addr": {
|
||||||
@@ -919,9 +919,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.2",
|
"version": "6.15.2",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||||
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.1.0"
|
"side-channel": "^1.1.0"
|
||||||
@@ -1080,13 +1080,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/side-channel-list": {
|
"node_modules/side-channel-list": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
"object-inspect": "^1.13.3"
|
"object-inspect": "^1.13.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -1233,9 +1233,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "13.0.0",
|
"version": "13.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
|
||||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
"integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==",
|
||||||
"funding": [
|
"funding": [
|
||||||
"https://github.com/sponsors/broofa",
|
"https://github.com/sponsors/broofa",
|
||||||
"https://github.com/sponsors/ctavan"
|
"https://github.com/sponsors/ctavan"
|
||||||
|
|||||||
@@ -3,15 +3,18 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^2.1.0",
|
||||||
"mysql2": "^3.9.1",
|
"mysql2": "^3.9.1",
|
||||||
"nodemailer": "^8.0.1",
|
"nodemailer": "^8.0.1",
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^13.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
42
backend/policies/accessPolicy.js
Normal file
42
backend/policies/accessPolicy.js
Normal 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,
|
||||||
|
};
|
||||||
20
backend/services/mailer.js
Normal file
20
backend/services/mailer.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
const nodemailer = require('nodemailer');
|
||||||
|
const { smtp } = require('../config/runtime');
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: smtp.host,
|
||||||
|
port: smtp.port,
|
||||||
|
secure: false,
|
||||||
|
auth: {
|
||||||
|
user: smtp.user,
|
||||||
|
pass: smtp.pass,
|
||||||
|
},
|
||||||
|
tls: {
|
||||||
|
ciphers: 'SSLv3',
|
||||||
|
rejectUnauthorized: false
|
||||||
|
},
|
||||||
|
debug: smtp.debug,
|
||||||
|
logger: smtp.debug
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = transporter;
|
||||||
64
backend/test/accessPolicy.test.js
Normal file
64
backend/test/accessPolicy.test.js
Normal 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);
|
||||||
|
});
|
||||||
22
backend/test/security.test.js
Normal file
22
backend/test/security.test.js
Normal 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
11
backend/utils/security.js
Normal 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,
|
||||||
|
};
|
||||||
@@ -12,11 +12,14 @@ services:
|
|||||||
- DB_USER=${DB_USER:-root}
|
- DB_USER=${DB_USER:-root}
|
||||||
- DB_PASSWORD=${DB_PASSWORD:-root_password}
|
- DB_PASSWORD=${DB_PASSWORD:-root_password}
|
||||||
- DB_NAME=${DB_NAME:-agenciac_comia}
|
- 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_HOST=${SMTP_HOST}
|
||||||
- SMTP_PORT=${SMTP_PORT}
|
- SMTP_PORT=${SMTP_PORT}
|
||||||
- SMTP_USER=${SMTP_USER}
|
- SMTP_USER=${SMTP_USER}
|
||||||
- SMTP_PASS=${SMTP_PASS}
|
- SMTP_PASS=${SMTP_PASS}
|
||||||
- MAIL_FROM=${MAIL_FROM}
|
- MAIL_FROM=${MAIL_FROM}
|
||||||
|
- SMTP_DEBUG=${SMTP_DEBUG:-false}
|
||||||
volumes:
|
volumes:
|
||||||
- ./dist:/app/dist # Map local build to container
|
- ./dist:/app/dist # Map local build to container
|
||||||
- ./backend/index.js:/app/index.js
|
- ./backend/index.js:/app/index.js
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ services:
|
|||||||
- MAIL_FROM=${MAIL_FROM}
|
- MAIL_FROM=${MAIL_FROM}
|
||||||
- APP_URL=${APP_URL}
|
- APP_URL=${APP_URL}
|
||||||
- JWT_SECRET=${JWT_SECRET}
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
|
- CORS_ORIGIN=${CORS_ORIGIN}
|
||||||
|
- SMTP_DEBUG=${SMTP_DEBUG:-false}
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "3001:3001"
|
||||||
deploy:
|
deploy:
|
||||||
|
|||||||
@@ -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": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test:backend": "npm --prefix backend test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
|||||||
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -235,7 +235,11 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
|||||||
{!isSuperAdmin && (
|
{!isSuperAdmin && (
|
||||||
<>
|
<>
|
||||||
<SidebarItem to="/" icon={LayoutDashboard} label="Dashboard" collapsed={isSidebarCollapsed} />
|
<SidebarItem to="/" icon={LayoutDashboard} label="Dashboard" collapsed={isSidebarCollapsed} />
|
||||||
|
|
||||||
|
{currentUser.role === 'agent' && (
|
||||||
|
<SidebarItem to={`/users/${currentUser.slug || currentUser.id}`} icon={MessageSquare} label="Seus Atendimentos" collapsed={isSidebarCollapsed} />
|
||||||
|
)}
|
||||||
|
|
||||||
{currentUser.role !== 'agent' && (
|
{currentUser.role !== 'agent' && (
|
||||||
<>
|
<>
|
||||||
<SidebarItem to="/ranking" icon={Trophy} label="Ranking de Vendedores" collapsed={isSidebarCollapsed} />
|
<SidebarItem to="/ranking" icon={Trophy} label="Ranking de Vendedores" collapsed={isSidebarCollapsed} />
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
|||||||
93
src/services/apiClient.ts
Normal file
93
src/services/apiClient.ts
Normal 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;
|
||||||
|
};
|
||||||
48
src/services/attendancesService.ts
Normal file
48
src/services/attendancesService.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Attendance, DashboardFilter } from '../types';
|
||||||
|
import { API_URL, apiFetch, getHeaders } from './apiClient';
|
||||||
|
|
||||||
|
export const getAttendances = async (tenantId: string, filter: DashboardFilter): Promise<Attendance[]> => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append('tenantId', tenantId);
|
||||||
|
|
||||||
|
if (filter.dateRange.start) params.append('startDate', filter.dateRange.start.toISOString());
|
||||||
|
if (filter.dateRange.end) params.append('endDate', filter.dateRange.end.toISOString());
|
||||||
|
|
||||||
|
if (filter.userId && filter.userId !== 'all') params.append('userId', filter.userId);
|
||||||
|
if (filter.teamId && filter.teamId !== 'all') params.append('teamId', filter.teamId);
|
||||||
|
if (filter.funnelStage && filter.funnelStage !== 'all') params.append('funnelStage', filter.funnelStage);
|
||||||
|
if (filter.origin && filter.origin !== 'all') params.append('origin', filter.origin);
|
||||||
|
|
||||||
|
const response = await apiFetch(`${API_URL}/attendances?${params.toString()}`, {
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Falha ao buscar atendimentos do servidor');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (getAttendances):", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAttendanceById = async (id: string): Promise<Attendance | undefined> => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_URL}/attendances/${id}`, {
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
if (!response.ok) return undefined;
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type");
|
||||||
|
if (contentType && contentType.indexOf("application/json") !== -1) {
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (getAttendanceById):", error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
180
src/services/authService.ts
Normal file
180
src/services/authService.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { API_URL, apiFetch, getHeaders, setSessionExpiredHandler } from './apiClient';
|
||||||
|
|
||||||
|
export let isReloadingForImpersonation = false;
|
||||||
|
|
||||||
|
export const logout = () => {
|
||||||
|
if (isReloadingForImpersonation) return;
|
||||||
|
|
||||||
|
const refreshToken = localStorage.getItem('ctms_refresh_token');
|
||||||
|
|
||||||
|
localStorage.removeItem('ctms_token');
|
||||||
|
localStorage.removeItem('ctms_refresh_token');
|
||||||
|
localStorage.removeItem('ctms_user_id');
|
||||||
|
localStorage.removeItem('ctms_tenant_id');
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setSessionExpiredHandler(logout);
|
||||||
|
|
||||||
|
export const login = async (credentials: any): Promise<any> => {
|
||||||
|
const response = await fetch(`${API_URL}/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(credentials)
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type");
|
||||||
|
const isJson = contentType && contentType.indexOf("application/json") !== -1;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = isJson ? await response.json() : { error: 'Erro no servidor' };
|
||||||
|
throw new Error(error.error || 'Erro no login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = isJson ? await response.json() : null;
|
||||||
|
if (data && 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_tenant_id', data.user.tenant_id || '');
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const impersonateTenant = async (tenantId: string): Promise<any> => {
|
||||||
|
const response = await apiFetch(`${API_URL}/impersonate/${tenantId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type");
|
||||||
|
const isJson = contentType && contentType.indexOf("application/json") !== -1;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = isJson ? await response.json() : { error: 'Erro no servidor' };
|
||||||
|
throw new Error(errorData.error || 'Erro ao assumir identidade');
|
||||||
|
}
|
||||||
|
|
||||||
|
isReloadingForImpersonation = true;
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const oldToken = localStorage.getItem('ctms_token');
|
||||||
|
if (oldToken) {
|
||||||
|
localStorage.setItem('ctms_super_admin_token', oldToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('ctms_token', data.token);
|
||||||
|
localStorage.setItem('ctms_user_id', data.user.id);
|
||||||
|
localStorage.setItem('ctms_tenant_id', data.user.tenant_id || '');
|
||||||
|
|
||||||
|
window.location.hash = '#/';
|
||||||
|
window.location.reload();
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const returnToSuperAdmin = (): boolean => {
|
||||||
|
const superAdminToken = localStorage.getItem('ctms_super_admin_token');
|
||||||
|
if (superAdminToken) {
|
||||||
|
try {
|
||||||
|
isReloadingForImpersonation = true;
|
||||||
|
|
||||||
|
const base64Url = superAdminToken.split('.')[1];
|
||||||
|
let base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const pad = base64.length % 4;
|
||||||
|
if (pad) {
|
||||||
|
base64 += '='.repeat(4 - pad);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
|
||||||
|
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
|
||||||
|
}).join(''));
|
||||||
|
const payload = JSON.parse(jsonPayload);
|
||||||
|
|
||||||
|
localStorage.setItem('ctms_token', superAdminToken);
|
||||||
|
localStorage.setItem('ctms_user_id', payload.id);
|
||||||
|
localStorage.setItem('ctms_tenant_id', payload.tenant_id || 'system');
|
||||||
|
localStorage.removeItem('ctms_super_admin_token');
|
||||||
|
|
||||||
|
window.location.hash = '#/super-admin';
|
||||||
|
window.location.reload();
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
isReloadingForImpersonation = false;
|
||||||
|
console.error("Failed to restore super admin token", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const register = async (userData: any): Promise<boolean> => {
|
||||||
|
const response = await apiFetch(`${API_URL}/auth/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(userData)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || 'Erro no registro');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyCode = async (data: any): Promise<boolean> => {
|
||||||
|
const response = await apiFetch(`${API_URL}/auth/verify`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || 'Código inválido ou expirado');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const forgotPassword = async (email: string): Promise<string> => {
|
||||||
|
const response = await apiFetch(`${API_URL}/auth/forgot-password`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email })
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type");
|
||||||
|
const isJson = contentType && contentType.indexOf("application/json") !== -1;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = isJson ? await response.json() : { error: 'Erro no servidor' };
|
||||||
|
throw new Error(error.error || 'Erro ao processar solicitação');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = isJson ? await response.json() : { message: 'Solicitação processada' };
|
||||||
|
return data.message;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resetPassword = async (password: string, token: string, name?: string): Promise<string> => {
|
||||||
|
const response = await apiFetch(`${API_URL}/auth/reset-password`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password, token, name })
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type");
|
||||||
|
const isJson = contentType && contentType.indexOf("application/json") !== -1;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = isJson ? await response.json() : { error: 'Erro no servidor' };
|
||||||
|
throw new Error(error.error || 'Erro ao resetar senha');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = isJson ? await response.json() : { message: 'Senha redefinida' };
|
||||||
|
return data.message;
|
||||||
|
};
|
||||||
@@ -1,851 +1,10 @@
|
|||||||
|
export * from './apiClient';
|
||||||
import { Attendance, DashboardFilter, User } from '../types';
|
export * from './attendancesService';
|
||||||
|
export * from './authService';
|
||||||
// URL do Backend
|
export * from './funnelsService';
|
||||||
// Em produção (import.meta.env.PROD), usa caminho relativo '/api' pois o backend serve o frontend
|
export * from './integrationsService';
|
||||||
// Em desenvolvimento, aponta para o localhost:3001
|
export * from './notificationsService';
|
||||||
const API_URL = import.meta.env.PROD ? '/api' : 'http://localhost:3001/api';
|
export * from './originsService';
|
||||||
|
export * from './teamsService';
|
||||||
const getHeaders = (customToken?: string) => {
|
export * from './tenantsService';
|
||||||
const token = customToken || localStorage.getItem('ctms_token');
|
export * from './usersService';
|
||||||
// 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;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getNotifications = async (): Promise<any[]> => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_URL}/notifications`, {
|
|
||||||
headers: getHeaders()
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error('Failed to fetch notifications');
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("API Error (getNotifications):", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const markNotificationAsRead = async (id: string): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_URL}/notifications/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: getHeaders()
|
|
||||||
});
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("API Error (markNotificationAsRead):", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const markAllNotificationsAsRead = async (): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_URL}/notifications/read-all`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: getHeaders()
|
|
||||||
});
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("API Error (markAllNotificationsAsRead):", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteNotification = async (id: string): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_URL}/notifications/${id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: getHeaders()
|
|
||||||
});
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("API Error (deleteNotification):", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const clearAllNotifications = async (): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_URL}/notifications/clear-all`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: getHeaders()
|
|
||||||
});
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("API Error (clearAllNotifications):", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Funnels Functions ---
|
|
||||||
export const getFunnels = async (tenantId: string): Promise<any[]> => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_URL}/funnels?tenantId=${tenantId}`, {
|
|
||||||
headers: getHeaders()
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error('Falha ao buscar funis');
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("API Error (getFunnels):", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createFunnel = async (data: { name: string, tenantId: string }): Promise<any> => {
|
|
||||||
const response = await apiFetch(`${API_URL}/funnels`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getHeaders(),
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.error || 'Erro ao criar funil');
|
|
||||||
}
|
|
||||||
return await response.json();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateFunnel = async (id: string, data: { name?: string, teamIds?: string[] }): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_URL}/funnels/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: getHeaders(),
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("API Error (updateFunnel):", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteFunnel = async (id: string): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_URL}/funnels/${id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: getHeaders()
|
|
||||||
});
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("API Error (deleteFunnel):", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createFunnelStage = async (funnelId: string, data: any): Promise<any> => {
|
|
||||||
const response = await apiFetch(`${API_URL}/funnels/${funnelId}/stages`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getHeaders(),
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.error || 'Erro ao criar etapa');
|
|
||||||
}
|
|
||||||
return await response.json();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateFunnelStage = async (id: string, data: any): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_URL}/funnel_stages/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: getHeaders(),
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("API Error (updateFunnelStage):", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteFunnelStage = async (id: string): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_URL}/funnel_stages/${id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: getHeaders()
|
|
||||||
});
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("API Error (deleteFunnelStage):", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Origins Functions ---
|
|
||||||
export const getOrigins = async (tenantId: string): Promise<any[]> => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_URL}/origins?tenantId=${tenantId}`, {
|
|
||||||
headers: getHeaders()
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error('Falha ao buscar origens');
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("API Error (getOrigins):", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createOriginGroup = async (data: { name: string, tenantId: string }): Promise<any> => {
|
|
||||||
const response = await apiFetch(`${API_URL}/origins`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getHeaders(),
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.error || 'Erro ao criar grupo de origens');
|
|
||||||
}
|
|
||||||
return await response.json();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateOriginGroup = async (id: string, data: { name?: string, teamIds?: string[] }): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_URL}/origins/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: getHeaders(),
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("API Error (updateOriginGroup):", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteOriginGroup = async (id: string): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_URL}/origins/${id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: getHeaders()
|
|
||||||
});
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("API Error (deleteOriginGroup):", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createOriginItem = async (groupId: string, data: { name: string, color_class?: string }): Promise<any> => {
|
|
||||||
const response = await apiFetch(`${API_URL}/origins/${groupId}/items`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getHeaders(),
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.error || 'Erro ao criar item de origem');
|
|
||||||
}
|
|
||||||
return await response.json();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateOriginItem = async (id: string, data: { name: string, color_class?: string }): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_URL}/origin_items/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: getHeaders(),
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("API Error (updateOriginItem):", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteOriginItem = async (id: string): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_URL}/origin_items/${id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: getHeaders()
|
|
||||||
});
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("API Error (deleteOriginItem):", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- API Keys Functions ---
|
|
||||||
export const getApiKeys = async (tenantId: string): Promise<any[]> => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_URL}/api-keys?tenantId=${tenantId}`, {
|
|
||||||
headers: getHeaders()
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error('Falha ao buscar chaves');
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("API Error (getApiKeys):", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createApiKey = async (data: { name: string, tenantId: string }): Promise<any> => {
|
|
||||||
const response = await apiFetch(`${API_URL}/api-keys`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getHeaders(),
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.error || 'Erro ao criar chave de API');
|
|
||||||
}
|
|
||||||
return await response.json();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteApiKey = async (id: string): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_URL}/api-keys/${id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: getHeaders()
|
|
||||||
});
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("API Error (deleteApiKey):", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const searchGlobal = async (query: string): Promise<{ members: User[], teams: any[], attendances: any[], organizations?: any[] }> => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_URL}/search?q=${encodeURIComponent(query)}`, {
|
|
||||||
headers: getHeaders()
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error('Search failed');
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("API Error (searchGlobal):", error);
|
|
||||||
return { members: [], teams: [], attendances: [], organizations: [] };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getAttendances = async (tenantId: string, filter: DashboardFilter): Promise<Attendance[]> => {
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.append('tenantId', tenantId);
|
|
||||||
|
|
||||||
if (filter.dateRange.start) params.append('startDate', filter.dateRange.start.toISOString());
|
|
||||||
if (filter.dateRange.end) params.append('endDate', filter.dateRange.end.toISOString());
|
|
||||||
|
|
||||||
if (filter.userId && filter.userId !== 'all') params.append('userId', filter.userId);
|
|
||||||
if (filter.teamId && filter.teamId !== 'all') params.append('teamId', filter.teamId);
|
|
||||||
if (filter.funnelStage && filter.funnelStage !== 'all') params.append('funnelStage', filter.funnelStage);
|
|
||||||
if (filter.origin && filter.origin !== 'all') params.append('origin', filter.origin);
|
|
||||||
|
|
||||||
const response = await apiFetch(`${API_URL}/attendances?${params.toString()}`, {
|
|
||||||
headers: getHeaders()
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Falha ao buscar atendimentos do servidor');
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("API Error (getAttendances):", error);
|
|
||||||
// Fallback vazio ou lançar erro para a UI tratar
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getUsers = async (tenantId: string): Promise<User[]> => {
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (tenantId !== 'all') params.append('tenantId', tenantId);
|
|
||||||
|
|
||||||
const response = await apiFetch(`${API_URL}/users?${params.toString()}`, {
|
|
||||||
headers: getHeaders()
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error('Falha ao buscar usuários');
|
|
||||||
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("API Error (getUsers):", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getUserById = async (id: string): Promise<User | undefined> => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_URL}/users/${id}`, {
|
|
||||||
headers: getHeaders()
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401 || response.status === 403 || response.status === 404) {
|
|
||||||
return undefined; // Invalid user or token
|
|
||||||
}
|
|
||||||
throw new Error(`Server error: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType = response.headers.get("content-type");
|
|
||||||
if (contentType && contentType.indexOf("application/json") !== -1) {
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("API Error (getUserById):", error);
|
|
||||||
throw error; // Rethrow so AuthGuard catches it and doesn't wipe tokens
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateUser = async (id: string, userData: any): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_URL}/users/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: getHeaders(),
|
|
||||||
body: JSON.stringify(userData)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => null);
|
|
||||||
throw new Error(errorData?.error || 'Erro ao atualizar usuário no servidor');
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("API Error (updateUser):", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const uploadAvatar = async (id: string, file: File): Promise<string | null> => {
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('avatar', file);
|
|
||||||
|
|
||||||
const token = localStorage.getItem('ctms_token');
|
|
||||||
const response = await apiFetch(`${API_URL}/users/${id}/avatar`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
|
||||||
},
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Falha no upload');
|
|
||||||
const data = await response.json();
|
|
||||||
return data.avatarUrl;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("API Error (uploadAvatar):", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createMember = async (userData: any): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_URL}/users`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getHeaders(),
|
|
||||||
body: JSON.stringify(userData)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => null);
|
|
||||||
throw new Error(errorData?.error || 'Erro ao criar membro no servidor');
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("API Error (createMember):", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteUser = async (id: string): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_URL}/users/${id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: getHeaders()
|
|
||||||
});
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("API Error (deleteUser):", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getAttendanceById = async (id: string): Promise<Attendance | undefined> => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_URL}/attendances/${id}`, {
|
|
||||||
headers: getHeaders()
|
|
||||||
});
|
|
||||||
if (!response.ok) return undefined;
|
|
||||||
|
|
||||||
const contentType = response.headers.get("content-type");
|
|
||||||
if (contentType && contentType.indexOf("application/json") !== -1) {
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("API Error (getAttendanceById):", error);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getTenants = async (): Promise<any[]> => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_URL}/tenants`, {
|
|
||||||
headers: getHeaders()
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error('Falha ao buscar tenants');
|
|
||||||
|
|
||||||
const contentType = response.headers.get("content-type");
|
|
||||||
if (contentType && contentType.indexOf("application/json") !== -1) {
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error("API Error (getTenants):", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getTeams = async (tenantId: string): Promise<any[]> => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_URL}/teams?tenantId=${tenantId}`, {
|
|
||||||
headers: getHeaders()
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error('Falha ao buscar equipes');
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("API Error (getTeams):", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createTeam = async (teamData: any): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_URL}/teams`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getHeaders(),
|
|
||||||
body: JSON.stringify(teamData)
|
|
||||||
});
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("API Error (createTeam):", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateTeam = async (id: string, teamData: any): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_URL}/teams/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: getHeaders(),
|
|
||||||
body: JSON.stringify(teamData)
|
|
||||||
});
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("API Error (updateTeam):", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteTeam = async (id: string): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_URL}/teams/${id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: getHeaders()
|
|
||||||
});
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("API Error (deleteTeam):", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createTenant = async (tenantData: any): Promise<{ success: boolean; message?: string }> => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_URL}/tenants`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getHeaders(),
|
|
||||||
body: JSON.stringify(tenantData)
|
|
||||||
});
|
|
||||||
const data = await response.json().catch(() => null);
|
|
||||||
if (!response.ok) throw new Error(data?.error || 'Erro ao criar organização');
|
|
||||||
return { success: true, message: data?.message || 'Organização criada!' };
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("API Error (createTenant):", error);
|
|
||||||
return { success: false, message: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateTenant = async (id: string, tenantData: any): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_URL}/tenants/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: getHeaders(),
|
|
||||||
body: JSON.stringify(tenantData)
|
|
||||||
});
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("API Error (updateTenant):", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteTenant = async (id: string): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch(`${API_URL}/tenants/${id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: getHeaders()
|
|
||||||
});
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("API Error (deleteTenant):", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Auth Functions ---
|
|
||||||
|
|
||||||
// Flag to prevent background fetches from throwing 401 and logging out during impersonation handoffs
|
|
||||||
export let isReloadingForImpersonation = false;
|
|
||||||
|
|
||||||
export const logout = () => {
|
|
||||||
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_refresh_token');
|
|
||||||
localStorage.removeItem('ctms_user_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> => {
|
|
||||||
const response = await fetch(`${API_URL}/auth/login`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(credentials)
|
|
||||||
});
|
|
||||||
|
|
||||||
const contentType = response.headers.get("content-type");
|
|
||||||
const isJson = contentType && contentType.indexOf("application/json") !== -1;
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = isJson ? await response.json() : { error: 'Erro no servidor' };
|
|
||||||
throw new Error(error.error || 'Erro no login');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = isJson ? await response.json() : null;
|
|
||||||
if (data && 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_tenant_id', data.user.tenant_id || '');
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const impersonateTenant = async (tenantId: string): Promise<any> => {
|
|
||||||
const response = await apiFetch(`${API_URL}/impersonate/${tenantId}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getHeaders()
|
|
||||||
});
|
|
||||||
|
|
||||||
const contentType = response.headers.get("content-type");
|
|
||||||
const isJson = contentType && contentType.indexOf("application/json") !== -1;
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = isJson ? await response.json() : { error: 'Erro no servidor' };
|
|
||||||
throw new Error(errorData.error || 'Erro ao assumir identidade');
|
|
||||||
}
|
|
||||||
|
|
||||||
isReloadingForImpersonation = true; // Block logouts
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
const oldToken = localStorage.getItem('ctms_token');
|
|
||||||
if (oldToken) {
|
|
||||||
localStorage.setItem('ctms_super_admin_token', oldToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.setItem('ctms_token', data.token);
|
|
||||||
localStorage.setItem('ctms_user_id', data.user.id);
|
|
||||||
localStorage.setItem('ctms_tenant_id', data.user.tenant_id || '');
|
|
||||||
|
|
||||||
window.location.hash = '#/';
|
|
||||||
window.location.reload();
|
|
||||||
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const returnToSuperAdmin = (): boolean => {
|
|
||||||
const superAdminToken = localStorage.getItem('ctms_super_admin_token');
|
|
||||||
if (superAdminToken) {
|
|
||||||
try {
|
|
||||||
isReloadingForImpersonation = true; // Block logouts
|
|
||||||
|
|
||||||
// Correctly decode Base64Url JWT payload with proper padding
|
|
||||||
const base64Url = superAdminToken.split('.')[1];
|
|
||||||
let base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
|
||||||
const pad = base64.length % 4;
|
|
||||||
if (pad) {
|
|
||||||
base64 += '='.repeat(4 - pad);
|
|
||||||
}
|
|
||||||
|
|
||||||
const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
|
|
||||||
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
|
|
||||||
}).join(''));
|
|
||||||
const payload = JSON.parse(jsonPayload);
|
|
||||||
|
|
||||||
localStorage.setItem('ctms_token', superAdminToken);
|
|
||||||
localStorage.setItem('ctms_user_id', payload.id);
|
|
||||||
localStorage.setItem('ctms_tenant_id', payload.tenant_id || 'system');
|
|
||||||
localStorage.removeItem('ctms_super_admin_token');
|
|
||||||
|
|
||||||
window.location.hash = '#/super-admin';
|
|
||||||
window.location.reload();
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
isReloadingForImpersonation = false;
|
|
||||||
console.error("Failed to restore super admin token", e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const register = async (userData: any): Promise<boolean> => {
|
|
||||||
const response = await apiFetch(`${API_URL}/auth/register`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(userData)
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.error || 'Erro no registro');
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const verifyCode = async (data: any): Promise<boolean> => {
|
|
||||||
const response = await apiFetch(`${API_URL}/auth/verify`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.error || 'Código inválido ou expirado');
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const forgotPassword = async (email: string): Promise<string> => {
|
|
||||||
const response = await apiFetch(`${API_URL}/auth/forgot-password`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ email })
|
|
||||||
});
|
|
||||||
|
|
||||||
const contentType = response.headers.get("content-type");
|
|
||||||
const isJson = contentType && contentType.indexOf("application/json") !== -1;
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = isJson ? await response.json() : { error: 'Erro no servidor' };
|
|
||||||
throw new Error(error.error || 'Erro ao processar solicitação');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = isJson ? await response.json() : { message: 'Solicitação processada' };
|
|
||||||
return data.message;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const resetPassword = async (password: string, token: string, name?: string): Promise<string> => {
|
|
||||||
const response = await apiFetch(`${API_URL}/auth/reset-password`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ password, token, name })
|
|
||||||
});
|
|
||||||
|
|
||||||
const contentType = response.headers.get("content-type");
|
|
||||||
const isJson = contentType && contentType.indexOf("application/json") !== -1;
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = isJson ? await response.json() : { error: 'Erro no servidor' };
|
|
||||||
throw new Error(error.error || 'Erro ao resetar senha');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = isJson ? await response.json() : { message: 'Senha redefinida' };
|
|
||||||
return data.message;
|
|
||||||
};
|
|
||||||
|
|||||||
94
src/services/funnelsService.ts
Normal file
94
src/services/funnelsService.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { API_URL, apiFetch, getHeaders } from './apiClient';
|
||||||
|
|
||||||
|
export const getFunnels = async (tenantId: string): Promise<any[]> => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_URL}/funnels?tenantId=${tenantId}`, {
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Falha ao buscar funis');
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (getFunnels):", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createFunnel = async (data: { name: string, tenantId: string }): Promise<any> => {
|
||||||
|
const response = await apiFetch(`${API_URL}/funnels`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || 'Erro ao criar funil');
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateFunnel = async (id: string, data: { name?: string, teamIds?: string[] }): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_URL}/funnels/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (updateFunnel):", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteFunnel = async (id: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_URL}/funnels/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (deleteFunnel):", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createFunnelStage = async (funnelId: string, data: any): Promise<any> => {
|
||||||
|
const response = await apiFetch(`${API_URL}/funnels/${funnelId}/stages`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || 'Erro ao criar etapa');
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateFunnelStage = async (id: string, data: any): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_URL}/funnel_stages/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (updateFunnelStage):", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteFunnelStage = async (id: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_URL}/funnel_stages/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (deleteFunnelStage):", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
54
src/services/integrationsService.ts
Normal file
54
src/services/integrationsService.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { User } from '../types';
|
||||||
|
import { API_URL, apiFetch, getHeaders } from './apiClient';
|
||||||
|
|
||||||
|
export const getApiKeys = async (tenantId: string): Promise<any[]> => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_URL}/api-keys?tenantId=${tenantId}`, {
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Falha ao buscar chaves');
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (getApiKeys):", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createApiKey = async (data: { name: string, tenantId: string }): Promise<any> => {
|
||||||
|
const response = await apiFetch(`${API_URL}/api-keys`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || 'Erro ao criar chave de API');
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteApiKey = async (id: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_URL}/api-keys/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (deleteApiKey):", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const searchGlobal = async (query: string): Promise<{ members: User[], teams: any[], attendances: any[], organizations?: any[] }> => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_URL}/search?q=${encodeURIComponent(query)}`, {
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Search failed');
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (searchGlobal):", error);
|
||||||
|
return { members: [], teams: [], attendances: [], organizations: [] };
|
||||||
|
}
|
||||||
|
};
|
||||||
66
src/services/notificationsService.ts
Normal file
66
src/services/notificationsService.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { API_URL, apiFetch, getHeaders } from './apiClient';
|
||||||
|
|
||||||
|
export const getNotifications = async (): Promise<any[]> => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_URL}/notifications`, {
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch notifications');
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (getNotifications):", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const markNotificationAsRead = async (id: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_URL}/notifications/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (markNotificationAsRead):", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const markAllNotificationsAsRead = async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_URL}/notifications/read-all`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (markAllNotificationsAsRead):", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteNotification = async (id: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_URL}/notifications/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (deleteNotification):", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearAllNotifications = async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_URL}/notifications/clear-all`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (clearAllNotifications):", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
94
src/services/originsService.ts
Normal file
94
src/services/originsService.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { API_URL, apiFetch, getHeaders } from './apiClient';
|
||||||
|
|
||||||
|
export const getOrigins = async (tenantId: string): Promise<any[]> => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_URL}/origins?tenantId=${tenantId}`, {
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Falha ao buscar origens');
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (getOrigins):", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createOriginGroup = async (data: { name: string, tenantId: string }): Promise<any> => {
|
||||||
|
const response = await apiFetch(`${API_URL}/origins`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || 'Erro ao criar grupo de origens');
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateOriginGroup = async (id: string, data: { name?: string, teamIds?: string[] }): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_URL}/origins/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (updateOriginGroup):", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteOriginGroup = async (id: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_URL}/origins/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (deleteOriginGroup):", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createOriginItem = async (groupId: string, data: { name: string, color_class?: string }): Promise<any> => {
|
||||||
|
const response = await apiFetch(`${API_URL}/origins/${groupId}/items`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || 'Erro ao criar item de origem');
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateOriginItem = async (id: string, data: { name: string, color_class?: string }): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_URL}/origin_items/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (updateOriginItem):", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteOriginItem = async (id: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_URL}/origin_items/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (deleteOriginItem):", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
55
src/services/teamsService.ts
Normal file
55
src/services/teamsService.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { API_URL, apiFetch, getHeaders } from './apiClient';
|
||||||
|
|
||||||
|
export const getTeams = async (tenantId: string): Promise<any[]> => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_URL}/teams?tenantId=${tenantId}`, {
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Falha ao buscar equipes');
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (getTeams):", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createTeam = async (teamData: any): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_URL}/teams`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(teamData)
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (createTeam):", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateTeam = async (id: string, teamData: any): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_URL}/teams/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(teamData)
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (updateTeam):", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteTeam = async (id: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_URL}/teams/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (deleteTeam):", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
62
src/services/tenantsService.ts
Normal file
62
src/services/tenantsService.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { API_URL, apiFetch, getHeaders } from './apiClient';
|
||||||
|
|
||||||
|
export const getTenants = async (): Promise<any[]> => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_URL}/tenants`, {
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Falha ao buscar tenants');
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type");
|
||||||
|
if (contentType && contentType.indexOf("application/json") !== -1) {
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (getTenants):", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createTenant = async (tenantData: any): Promise<{ success: boolean; message?: string }> => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_URL}/tenants`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(tenantData)
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) throw new Error(data?.error || 'Erro ao criar organização');
|
||||||
|
return { success: true, message: data?.message || 'Organização criada!' };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("API Error (createTenant):", error);
|
||||||
|
return { success: false, message: error.message };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateTenant = async (id: string, tenantData: any): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_URL}/tenants/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(tenantData)
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (updateTenant):", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteTenant = async (id: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_URL}/tenants/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (deleteTenant):", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
117
src/services/usersService.ts
Normal file
117
src/services/usersService.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { User } from '../types';
|
||||||
|
import { API_URL, apiFetch, getHeaders } from './apiClient';
|
||||||
|
|
||||||
|
export const getUsers = async (tenantId: string): Promise<User[]> => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (tenantId !== 'all') params.append('tenantId', tenantId);
|
||||||
|
|
||||||
|
const response = await apiFetch(`${API_URL}/users?${params.toString()}`, {
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Falha ao buscar usuários');
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (getUsers):", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUserById = async (id: string): Promise<User | undefined> => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_URL}/users/${id}`, {
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401 || response.status === 403 || response.status === 404) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
throw new Error(`Server error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type");
|
||||||
|
if (contentType && contentType.indexOf("application/json") !== -1) {
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (getUserById):", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateUser = async (id: string, userData: any): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_URL}/users/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(userData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => null);
|
||||||
|
throw new Error(errorData?.error || 'Erro ao atualizar usuário no servidor');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (updateUser):", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uploadAvatar = async (id: string, file: File): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('avatar', file);
|
||||||
|
|
||||||
|
const token = localStorage.getItem('ctms_token');
|
||||||
|
const response = await apiFetch(`${API_URL}/users/${id}/avatar`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Falha no upload');
|
||||||
|
const data = await response.json();
|
||||||
|
return data.avatarUrl;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (uploadAvatar):", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createMember = async (userData: any): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_URL}/users`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(userData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => null);
|
||||||
|
throw new Error(errorData?.error || 'Erro ao criar membro no servidor');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (createMember):", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteUser = async (id: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`${API_URL}/users/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error (deleteUser):", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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