Compare commits
17 Commits
8f7e5ee487
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
509ed4a0d9 | ||
| 07cb43d0e3 | |||
|
|
b8541c0d24 | ||
|
|
062864364a | ||
|
|
d148984028 | ||
|
|
fa683ab28c | ||
|
|
83e6da2d56 | ||
|
|
4dbd7c62cd | ||
|
|
b5c8e97701 | ||
|
|
f65ff97434 | ||
|
|
958a2cdbd9 | ||
|
|
eb483f903b | ||
|
|
9ffcfcdcc8 | ||
|
|
3663d03cb9 | ||
|
|
2317c46ac9 | ||
|
|
4489f0a74d | ||
|
|
327ad064a4 |
160
App.tsx
160
App.tsx
@@ -1,34 +1,48 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { HashRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||
import { Layout } from './components/Layout';
|
||||
import { Dashboard } from './pages/Dashboard';
|
||||
import { UserDetail } from './pages/UserDetail';
|
||||
import { AttendanceDetail } from './pages/AttendanceDetail';
|
||||
import { SuperAdmin } from './pages/SuperAdmin';
|
||||
import { ApiKeys } from './pages/ApiKeys';
|
||||
import { TeamManagement } from './pages/TeamManagement';
|
||||
import { Teams } from './pages/Teams';
|
||||
import { Funnels } from './pages/Funnels';
|
||||
import { Origins } from './pages/Origins';
|
||||
import { Login } from './pages/Login';
|
||||
import { ForgotPassword } from './pages/ForgotPassword';
|
||||
import { ResetPassword } from './pages/ResetPassword';
|
||||
import { SetupAccount } from './pages/SetupAccount';
|
||||
import { UserProfile } from './pages/UserProfile';
|
||||
import { getUserById, logout } from './services/dataService';
|
||||
import { User } from './types';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
HashRouter as Router,
|
||||
Routes,
|
||||
Route,
|
||||
Navigate,
|
||||
useLocation,
|
||||
} from "react-router-dom";
|
||||
import { Layout } from "./components/Layout";
|
||||
import { Dashboard } from "./pages/Dashboard";
|
||||
import { UserDetail } from "./pages/UserDetail";
|
||||
import { AttendanceDetail } from "./pages/AttendanceDetail";
|
||||
import { SuperAdmin } from "./pages/SuperAdmin";
|
||||
import { ApiKeys } from "./pages/ApiKeys";
|
||||
import { TeamManagement } from "./pages/TeamManagement";
|
||||
import { Teams } from "./pages/Teams";
|
||||
import { Funnels } from "./pages/Funnels";
|
||||
import { Origins } from "./pages/Origins";
|
||||
import { Login } from "./pages/Login";
|
||||
import { ForgotPassword } from "./pages/ForgotPassword";
|
||||
import { ResetPassword } from "./pages/ResetPassword";
|
||||
import { SetupAccount } from "./pages/SetupAccount";
|
||||
import { UserProfile } from "./pages/UserProfile";
|
||||
import { getUserById, logout } from "./services/dataService";
|
||||
import { User } from "./types";
|
||||
|
||||
const AuthGuard: React.FC<{ children: React.ReactNode, roles?: string[] }> = ({ children, roles }) => {
|
||||
const AuthGuard: React.FC<{ children: React.ReactNode; roles?: string[] }> = ({
|
||||
children,
|
||||
roles,
|
||||
}) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
const storedUserId = localStorage.getItem('ctms_user_id');
|
||||
const storedToken = localStorage.getItem('ctms_token');
|
||||
const storedUserId = localStorage.getItem("ctms_user_id");
|
||||
const storedToken = localStorage.getItem("ctms_token");
|
||||
|
||||
if (!storedUserId || !storedToken || storedToken === 'undefined' || storedToken === 'null') {
|
||||
if (
|
||||
!storedUserId ||
|
||||
!storedToken ||
|
||||
storedToken === "undefined" ||
|
||||
storedToken === "null"
|
||||
) {
|
||||
if (storedToken) logout(); // Limpar se for "undefined" string
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -37,7 +51,7 @@ const AuthGuard: React.FC<{ children: React.ReactNode, roles?: string[] }> = ({
|
||||
try {
|
||||
const fetchedUser = await getUserById(storedUserId);
|
||||
if (fetchedUser) {
|
||||
if (fetchedUser.status === 'active') {
|
||||
if (fetchedUser.status === "active") {
|
||||
setUser(fetchedUser);
|
||||
} else {
|
||||
// User explicitly marked inactive or deleted
|
||||
@@ -66,7 +80,11 @@ const AuthGuard: React.FC<{ children: React.ReactNode, roles?: string[] }> = ({
|
||||
}, [location.pathname]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex h-screen items-center justify-center bg-zinc-50 dark:bg-zinc-950 text-zinc-400">Carregando...</div>;
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-zinc-50 dark:bg-zinc-950 text-zinc-400">
|
||||
Carregando...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
@@ -78,7 +96,7 @@ const AuthGuard: React.FC<{ children: React.ReactNode, roles?: string[] }> = ({
|
||||
}
|
||||
|
||||
// Auto-redirect Super Admins away from the standard dashboard to their specific panel
|
||||
if (location.pathname === '/' && user.role === 'super_admin') {
|
||||
if (location.pathname === "/" && user.role === "super_admin") {
|
||||
return <Navigate to="/super-admin" replace />;
|
||||
}
|
||||
|
||||
@@ -93,16 +111,86 @@ const App: React.FC = () => {
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
<Route path="/setup-account" element={<SetupAccount />} />
|
||||
<Route path="/" element={<AuthGuard><Dashboard /></AuthGuard>} />
|
||||
<Route path="/admin/users" element={<AuthGuard roles={['super_admin', 'admin', 'manager']}><TeamManagement /></AuthGuard>} />
|
||||
<Route path="/admin/teams" element={<AuthGuard roles={['super_admin', 'admin', 'manager']}><Teams /></AuthGuard>} />
|
||||
<Route path="/admin/funnels" element={<AuthGuard roles={['super_admin', 'admin', 'manager']}><Funnels /></AuthGuard>} />
|
||||
<Route path="/admin/origins" element={<AuthGuard roles={['super_admin', 'admin', 'manager']}><Origins /></AuthGuard>} />
|
||||
<Route path="/users/:id" element={<AuthGuard><UserDetail /></AuthGuard>} />
|
||||
<Route path="/attendances/:id" element={<AuthGuard><AttendanceDetail /></AuthGuard>} />
|
||||
<Route path="/super-admin" element={<AuthGuard roles={['super_admin']}><SuperAdmin /></AuthGuard>} />
|
||||
<Route path="/super-admin/api-keys" element={<AuthGuard roles={['super_admin']}><ApiKeys /></AuthGuard>} />
|
||||
<Route path="/profile" element={<AuthGuard><UserProfile /></AuthGuard>} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<AuthGuard>
|
||||
<Dashboard />
|
||||
</AuthGuard>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/users"
|
||||
element={
|
||||
<AuthGuard roles={["super_admin", "admin", "manager"]}>
|
||||
<TeamManagement />
|
||||
</AuthGuard>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/teams"
|
||||
element={
|
||||
<AuthGuard roles={["super_admin", "admin", "manager"]}>
|
||||
<Teams />
|
||||
</AuthGuard>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/funnels"
|
||||
element={
|
||||
<AuthGuard roles={["super_admin", "admin"]}>
|
||||
<Funnels />
|
||||
</AuthGuard>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/origins"
|
||||
element={
|
||||
<AuthGuard roles={["super_admin", "admin"]}>
|
||||
<Origins />
|
||||
</AuthGuard>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users/:id"
|
||||
element={
|
||||
<AuthGuard>
|
||||
<UserDetail />
|
||||
</AuthGuard>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/attendances/:id"
|
||||
element={
|
||||
<AuthGuard>
|
||||
<AttendanceDetail />
|
||||
</AuthGuard>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/super-admin"
|
||||
element={
|
||||
<AuthGuard roles={["super_admin"]}>
|
||||
<SuperAdmin />
|
||||
</AuthGuard>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/super-admin/api-keys"
|
||||
element={
|
||||
<AuthGuard roles={["super_admin"]}>
|
||||
<ApiKeys />
|
||||
</AuthGuard>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/profile"
|
||||
element={
|
||||
<AuthGuard>
|
||||
<UserProfile />
|
||||
</AuthGuard>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
|
||||
50
GEMINI.md
50
GEMINI.md
@@ -7,38 +7,44 @@ Fasto is a commercial team management system built with React (Vite) on the fron
|
||||
We have transitioned from a mock-based prototype to a **secure, multi-tenant production architecture**:
|
||||
|
||||
- **Multi-Tenancy & Data Isolation:** All backend routes (Users, Teams, Attendances) now strictly enforce `tenant_id` checks. It is technically impossible for one organization to query data from another.
|
||||
- **God Mode (Tenant Impersonation):** Super Admins can securely impersonate Tenant Admins via a specialized, temporary JWT (`/api/impersonate/:tenantId`). This allows seamless cross-domain support without storing passwords, while strictly maintaining state isolation through forced React reloads and locking mechanisms.
|
||||
- **Dynamic Funnel Customization:**
|
||||
- Funnel stages are no longer hardcoded ENUMs. Each tenant can create multiple dynamic funnels via the `funnels` and `funnel_stages` tables.
|
||||
- Managers can assign specific Teams to specific Funnels.
|
||||
- The Dashboard, User Detail, and Attendance Detail pages now dynamically map and color-code stages based on the active team's assigned funnel.
|
||||
- **Real-Time Notification System:**
|
||||
- Built a persistent notification tray (`/api/notifications`) with real-time polling (10s intervals) and a hidden HTML5 `<audio>` player for cross-browser sound playback.
|
||||
- Automated Triggers: Super Admins are notified of new organizations; Tenant Admins/Managers are notified of new user setups; Users are notified of team assignment changes.
|
||||
- **Role-Based Access Control (RBAC):**
|
||||
- **Super Admin:** Global management of all tenants and users (via the hidden `system` tenant).
|
||||
- **Admin/Manager:** Full control over members and teams within their specific organization.
|
||||
- **Advanced 2-Token Authentication (Rolling Sessions):**
|
||||
- Replaced the vulnerable 1-year static JWT with a highly secure dual-token system.
|
||||
- Generates a short-lived `AccessToken` (15 min) and a stateful `RefreshToken` (30 days) stored in the DB (`refresh_tokens` table).
|
||||
- Built an Axios-like `apiFetch` interceptor on the frontend that automatically catches 401 Unauthorized errors, fetches a new Access Token in the background, extends the Refresh Token by another 30 days (Sliding Expiration), and retries the original request without logging the user out.
|
||||
- Full remote revocation capability (Logout drops the token from the DB immediately).
|
||||
- **God Mode (Tenant Impersonation):** Super Admins can securely impersonate Tenant Admins via a specialized, temporary JWT (`/api/impersonate/:tenantId`). This allows seamless cross-domain support without storing passwords.
|
||||
- **Role-Based Access Control (RBAC) Simplification:**
|
||||
- Removed the redundant 'owner' role. The system now strictly relies on 4 tiers:
|
||||
- **Super Admin:** Global management of all tenants and API keys (via the hidden `system` tenant).
|
||||
- **Admin:** Full control over members, teams, funnels, and origins within their specific organization.
|
||||
- **Manager:** Mid-level control. Can edit basic info of users in their specific team, but cannot change user roles or re-assign users to different teams (only Admins can).
|
||||
- **Agent:** Restricted access. Can only view their own performance metrics and historical attendances.
|
||||
- **Dynamic Funnel & Origin Managers:**
|
||||
- Funnel stages and Lead Origins are no longer hardcoded ENUMs. Each tenant can create multiple dynamic funnel/origin groups via relational tables (`funnels`, `funnel_stages`, `origin_groups`, `origin_items`).
|
||||
- Admins can customize the exact Tailwind color class (e.g., "bg-green-100") for each stage and origin via visual UI pickers.
|
||||
- Admins assign specific Teams to specific Funnels/Origin Groups.
|
||||
- The Dashboard pie charts and data tables strictly filter and color-code data based on the active team's configuration. Deleted data falls back to an "Outros" category to prevent chart breakage.
|
||||
- **n8n / External API Webhooks (Completed):**
|
||||
- Super Admins can generate persistent `api_keys` for specific tenants.
|
||||
- `GET /api/integration/users`, `/funnels`, and `/origins` allow the n8n AI to dynamically map the tenant's actual agents and workflow stages before processing a chat.
|
||||
- `POST /api/integration/attendances` accepts the AI's final JSON payload (including the `full_summary` text) and injects it directly into the dashboard.
|
||||
- **Real-Time Notification System:**
|
||||
- Built a persistent notification tray (`/api/notifications`) with real-time polling (10s intervals) and a hidden HTML5 `<audio>` player for cross-browser sound playback (custom `.mp3` loaded via Vite).
|
||||
- Automated Triggers: Super Admins are notified of new organizations; Admins/Super Admins are notified of new user setups; Agents are notified of team assignment changes; Managers get "Venda Fechada" alerts when n8n posts a converted lead.
|
||||
- **Enhanced UI/UX:**
|
||||
- Premium "Onyx & Gold" True Black dark mode.
|
||||
- Premium "Onyx & Gold" True Black dark mode (Zinc scale).
|
||||
- Fully collapsible interactive sidebar with memory (`localStorage`).
|
||||
- Action buttons across all data tables are permanently visible for faster discoverability (removed hover requirements).
|
||||
- Loading states embedded directly into action buttons to prevent double-submissions.
|
||||
- **Secure File Uploads:** Profile avatars use `multer` with strict mimetype validation (JPG/PNG/WEBP), 2MB size limits, and UUID generation.
|
||||
- All Date/Time displays localized to strict Brazilian formatting (`pt-BR`, 24h, `DD/MM/YY`).
|
||||
|
||||
## 📌 Roadmap / To-Do
|
||||
- [ ] **n8n / External API Integration (Priority 1):**
|
||||
- Create an `api_keys` table to allow generating persistent, secure API keys for each Tenant.
|
||||
- Build `GET /api/integration/users` so n8n can map its chat agents to Fasto's internal `user_id`s.
|
||||
- Build `POST /api/integration/attendances` to allow n8n to programmatically create new attendances linked to specific tenants and users.
|
||||
- [ ] **Sales & Quality Notification Triggers:** Implement backend logic to automatically notify Managers when an attendance is marked as "Won" (Ganhos), receives a critically low quality score, or breaches a specific Response Time SLA.
|
||||
- [ ] **Advanced AI Notification Triggers:** Implement backend logic to automatically notify Managers when an attendance payload from n8n receives a critically low quality score (`score < 50`), or breaches a specific Response Time SLA (e.g., `first_response_time_min > 60`).
|
||||
- [ ] **Data Export/Reporting:** Allow Admins to export attendance and KPI data to CSV/Excel.
|
||||
- [ ] **Billing/Subscription Management:** Integrate a payment gateway (e.g., Stripe/Asaas) to manage tenant trial periods and active statuses dynamically.
|
||||
|
||||
## 🛠 Architecture
|
||||
- **Frontend**: React 19, TypeScript, Vite, TailwindCSS (CDN), Recharts, Lucide React.
|
||||
- **Backend**: Node.js, Express, MySQL2 (Pool-based), Nodemailer.
|
||||
- **Database**: MySQL 8.0 (Schema: `fasto_db`).
|
||||
- **Database**: MySQL 8.0 (Schema: `fasto_db` or `agenciac_comia` depending on `.env`).
|
||||
- **Deployment**: Docker Compose for local development; Gitea Actions for CI/CD pushing to a Gitea Registry and deploying via Portainer webhook.
|
||||
|
||||
## 📋 Prerequisites
|
||||
@@ -55,7 +61,7 @@ cp .env.example .env
|
||||
*Note:* The backend automatically strips literal quotes from Docker `.env` string values (like `SMTP_PASS`) to prevent authentication crashes.
|
||||
|
||||
### 2. Database
|
||||
The project expects a MySQL database. The `docker-compose.local.yml` initializes it. The Node.js backend automatically runs non-destructive schema migrations on startup (adding tables like `notifications`, `funnels`, `funnel_stages`, and modifying `attendances`).
|
||||
The project expects a MySQL database. The Node.js backend automatically runs non-destructive schema migrations on startup (adding tables like `refresh_tokens`, `api_keys`, `origin_groups`, etc.).
|
||||
|
||||
### 3. Running Locally (Docker Compose)
|
||||
To start the application and database locally:
|
||||
|
||||
169
backend/index.js
169
backend/index.js
@@ -136,14 +136,17 @@ const authenticateToken = async (req, res, next) => {
|
||||
|
||||
if (!token) return res.status(401).json({ error: 'Token não fornecido.' });
|
||||
|
||||
jwt.verify(token, JWT_SECRET, (err, user) => {
|
||||
if (err) return res.status(403).json({ error: 'Token inválido ou expirado.' });
|
||||
try {
|
||||
const user = jwt.verify(token, JWT_SECRET);
|
||||
req.user = user;
|
||||
next();
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(401).json({ error: 'Token inválido ou expirado.' });
|
||||
}
|
||||
};
|
||||
|
||||
const requireRole = (roles) => (req, res, next) => {
|
||||
if (!req.user || !req.user.role) return res.status(401).json({ error: 'Não autenticado.' });
|
||||
if (!roles.includes(req.user.role)) return res.status(403).json({ error: 'Acesso negado. Você não tem permissão para realizar esta ação.' });
|
||||
next();
|
||||
};
|
||||
@@ -206,8 +209,7 @@ apiRouter.post('/auth/verify', async (req, res) => {
|
||||
await connection.query('INSERT INTO tenants (id, name, slug, admin_email) VALUES (?, ?, ?, ?)',
|
||||
[tenantId, data.organization_name, data.organization_name.toLowerCase().replace(/ /g, '-'), email]);
|
||||
await connection.query('INSERT INTO users (id, tenant_id, name, email, password_hash, role) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[userId, tenantId, data.full_name, email, data.password_hash, 'owner']);
|
||||
await connection.query('DELETE FROM pending_registrations WHERE email = ?', [email]);
|
||||
[userId, tenantId, data.full_name, email, data.password_hash, 'admin']); await connection.query('DELETE FROM pending_registrations WHERE email = ?', [email]);
|
||||
|
||||
await connection.commit();
|
||||
res.json({ message: 'Sucesso.' });
|
||||
@@ -236,8 +238,78 @@ apiRouter.post('/auth/login', async (req, res) => {
|
||||
const valid = await bcrypt.compare(password, user.password_hash);
|
||||
if (!valid) return res.status(401).json({ error: 'Credenciais inválidas.' });
|
||||
|
||||
const token = jwt.sign({ id: user.id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug }, JWT_SECRET, { expiresIn: '24h' });
|
||||
res.json({ token, user: { id: user.id, name: user.name, email: user.email, role: user.role, tenant_id: user.tenant_id, team_id: user.team_id, slug: user.slug } });
|
||||
// Generate Access Token (short-lived)
|
||||
const token = jwt.sign({ id: user.id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug }, JWT_SECRET, { expiresIn: '15m' });
|
||||
|
||||
// Generate Refresh Token (long-lived)
|
||||
const refreshToken = crypto.randomBytes(40).toString('hex');
|
||||
const refreshId = `rt_${crypto.randomUUID().split('-')[0]}`;
|
||||
|
||||
// Store Refresh Token in database (expires in 30 days)
|
||||
await pool.query(
|
||||
'INSERT INTO refresh_tokens (id, user_id, token, expires_at) VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY))',
|
||||
[refreshId, user.id, refreshToken]
|
||||
);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
refreshToken,
|
||||
user: { id: user.id, name: user.name, email: user.email, role: user.role, tenant_id: user.tenant_id, team_id: user.team_id, slug: user.slug }
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh Token
|
||||
apiRouter.post('/auth/refresh', async (req, res) => {
|
||||
const { refreshToken } = req.body;
|
||||
if (!refreshToken) return res.status(400).json({ error: 'Refresh token não fornecido.' });
|
||||
|
||||
try {
|
||||
// Verifies if the token exists and hasn't expired
|
||||
const [tokens] = await pool.query(
|
||||
'SELECT r.user_id, u.tenant_id, u.role, u.team_id, u.slug, u.status FROM refresh_tokens r JOIN users u ON r.user_id = u.id WHERE r.token = ? AND r.expires_at > NOW()',
|
||||
[refreshToken]
|
||||
);
|
||||
|
||||
if (tokens.length === 0) {
|
||||
// If invalid, optionally delete the bad token if it's just expired
|
||||
await pool.query('DELETE FROM refresh_tokens WHERE token = ?', [refreshToken]);
|
||||
return res.status(401).json({ error: 'Sessão expirada. Faça login novamente.' });
|
||||
}
|
||||
|
||||
const user = tokens[0];
|
||||
|
||||
if (user.status !== 'active') {
|
||||
await pool.query('DELETE FROM refresh_tokens WHERE token = ?', [refreshToken]);
|
||||
return res.status(403).json({ error: 'Sua conta está inativa.' });
|
||||
}
|
||||
|
||||
// Sliding Expiration: Extend the refresh token's life by another 30 days
|
||||
await pool.query('UPDATE refresh_tokens SET expires_at = DATE_ADD(NOW(), INTERVAL 30 DAY) WHERE token = ?', [refreshToken]);
|
||||
|
||||
// Issue a new short-lived access token
|
||||
const newAccessToken = jwt.sign(
|
||||
{ id: user.user_id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '15m' }
|
||||
);
|
||||
|
||||
res.json({ token: newAccessToken });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Logout (Revoke Refresh Token)
|
||||
apiRouter.post('/auth/logout', async (req, res) => {
|
||||
const { refreshToken } = req.body;
|
||||
try {
|
||||
if (refreshToken) {
|
||||
await pool.query('DELETE FROM refresh_tokens WHERE token = ?', [refreshToken]);
|
||||
}
|
||||
res.json({ message: 'Logout bem-sucedido.' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
@@ -388,7 +460,7 @@ apiRouter.get('/users/:idOrSlug', async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.query('SELECT * FROM users WHERE id = ? OR slug = ?', [req.params.idOrSlug, req.params.idOrSlug]);
|
||||
if (!rows || rows.length === 0) return res.status(404).json({ error: 'Not found' });
|
||||
if (!req.user) return res.status(401).json({ error: 'Não autenticado' });
|
||||
if (!req.user || !req.user.role) return res.status(401).json({ error: 'Não autenticado' });
|
||||
|
||||
if (req.user.role !== 'super_admin' && rows[0].tenant_id !== req.user.tenant_id) {
|
||||
return res.status(403).json({ error: 'Acesso negado.' });
|
||||
@@ -471,7 +543,8 @@ apiRouter.put('/users/:id', async (req, res) => {
|
||||
if (existing.length === 0) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
const isSelf = req.user.id === req.params.id;
|
||||
const isManagerOrAdmin = ['admin', 'owner', 'manager', 'super_admin'].includes(req.user.role);
|
||||
const isManagerOrAdmin = ['admin', 'manager', 'super_admin'].includes(req.user.role);
|
||||
const isAdmin = ['admin', 'super_admin'].includes(req.user.role);
|
||||
|
||||
if (!isSelf && !isManagerOrAdmin) {
|
||||
return res.status(403).json({ error: 'Acesso negado.' });
|
||||
@@ -481,8 +554,9 @@ apiRouter.put('/users/:id', async (req, res) => {
|
||||
return res.status(403).json({ error: 'Acesso negado.' });
|
||||
}
|
||||
|
||||
const finalRole = isManagerOrAdmin && role !== undefined ? role : existing[0].role;
|
||||
const finalTeamId = isManagerOrAdmin && team_id !== undefined ? team_id : existing[0].team_id;
|
||||
// Only Admins can change roles and teams. Managers can only edit basic info of their team members.
|
||||
const finalRole = isAdmin && role !== undefined ? role : existing[0].role;
|
||||
const finalTeamId = isAdmin && team_id !== undefined ? team_id : existing[0].team_id;
|
||||
const finalStatus = isManagerOrAdmin && status !== undefined ? status : existing[0].status;
|
||||
const finalEmail = email !== undefined ? email : existing[0].email;
|
||||
const finalSoundEnabled = isSelf && sound_enabled !== undefined ? sound_enabled : (existing[0].sound_enabled ?? true);
|
||||
@@ -514,7 +588,7 @@ apiRouter.put('/users/:id', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.delete('/users/:id', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => {
|
||||
apiRouter.delete('/users/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
try {
|
||||
const [existing] = await pool.query('SELECT tenant_id FROM users WHERE id = ?', [req.params.id]);
|
||||
if (existing.length === 0) return res.status(404).json({ error: 'Not found' });
|
||||
@@ -663,7 +737,7 @@ apiRouter.get('/origins', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.post('/origins', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => {
|
||||
apiRouter.post('/origins', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
const { name, tenantId } = req.body;
|
||||
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
|
||||
try {
|
||||
@@ -675,7 +749,7 @@ apiRouter.post('/origins', requireRole(['admin', 'owner', 'manager', 'super_admi
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.put('/origins/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => {
|
||||
apiRouter.put('/origins/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
const { name, teamIds } = req.body;
|
||||
try {
|
||||
if (name) {
|
||||
@@ -693,7 +767,7 @@ apiRouter.put('/origins/:id', requireRole(['admin', 'owner', 'manager', 'super_a
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.delete('/origins/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => {
|
||||
apiRouter.delete('/origins/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
try {
|
||||
await pool.query('DELETE FROM origin_items WHERE origin_group_id = ?', [req.params.id]);
|
||||
await pool.query('UPDATE teams SET origin_group_id = NULL WHERE origin_group_id = ?', [req.params.id]);
|
||||
@@ -704,7 +778,7 @@ apiRouter.delete('/origins/:id', requireRole(['admin', 'owner', 'manager', 'supe
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.post('/origins/:id/items', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => {
|
||||
apiRouter.post('/origins/:id/items', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
const { name, color_class } = req.body;
|
||||
try {
|
||||
const oid = `oriitm_${crypto.randomUUID().split('-')[0]}`;
|
||||
@@ -718,7 +792,7 @@ apiRouter.post('/origins/:id/items', requireRole(['admin', 'owner', 'manager', '
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.put('/origin_items/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => {
|
||||
apiRouter.put('/origin_items/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
const { name, color_class } = req.body;
|
||||
try {
|
||||
const [existing] = await pool.query('SELECT * FROM origin_items WHERE id = ?', [req.params.id]);
|
||||
@@ -731,7 +805,7 @@ apiRouter.put('/origin_items/:id', requireRole(['admin', 'owner', 'manager', 'su
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.delete('/origin_items/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => {
|
||||
apiRouter.delete('/origin_items/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
try {
|
||||
await pool.query('DELETE FROM origin_items WHERE id = ?', [req.params.id]);
|
||||
res.json({ message: 'Origin item deleted.' });
|
||||
@@ -792,7 +866,7 @@ apiRouter.get('/funnels', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.post('/funnels', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => {
|
||||
apiRouter.post('/funnels', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
const { name, tenantId } = req.body;
|
||||
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
|
||||
try {
|
||||
@@ -804,7 +878,7 @@ apiRouter.post('/funnels', requireRole(['admin', 'owner', 'manager', 'super_admi
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.put('/funnels/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => {
|
||||
apiRouter.put('/funnels/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
const { name, teamIds } = req.body;
|
||||
try {
|
||||
if (name) {
|
||||
@@ -822,7 +896,7 @@ apiRouter.put('/funnels/:id', requireRole(['admin', 'owner', 'manager', 'super_a
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.delete('/funnels/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => {
|
||||
apiRouter.delete('/funnels/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
try {
|
||||
await pool.query('DELETE FROM funnel_stages WHERE funnel_id = ?', [req.params.id]);
|
||||
await pool.query('UPDATE teams SET funnel_id = NULL WHERE funnel_id = ?', [req.params.id]);
|
||||
@@ -833,7 +907,7 @@ apiRouter.delete('/funnels/:id', requireRole(['admin', 'owner', 'manager', 'supe
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.post('/funnels/:id/stages', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => {
|
||||
apiRouter.post('/funnels/:id/stages', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
const { name, color_class, order_index } = req.body;
|
||||
try {
|
||||
const sid = `stage_${crypto.randomUUID().split('-')[0]}`;
|
||||
@@ -847,7 +921,7 @@ apiRouter.post('/funnels/:id/stages', requireRole(['admin', 'owner', 'manager',
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.put('/funnel_stages/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => {
|
||||
apiRouter.put('/funnel_stages/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
const { name, color_class, order_index } = req.body;
|
||||
try {
|
||||
const [existing] = await pool.query('SELECT * FROM funnel_stages WHERE id = ?', [req.params.id]);
|
||||
@@ -863,7 +937,7 @@ apiRouter.put('/funnel_stages/:id', requireRole(['admin', 'owner', 'manager', 's
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.delete('/funnel_stages/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => {
|
||||
apiRouter.delete('/funnel_stages/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
try {
|
||||
await pool.query('DELETE FROM funnel_stages WHERE id = ?', [req.params.id]);
|
||||
res.json({ message: 'Stage deleted.' });
|
||||
@@ -888,7 +962,7 @@ apiRouter.get('/search', async (req, res) => {
|
||||
|
||||
if (req.user.role === 'super_admin') {
|
||||
// No extra filters
|
||||
} else if (req.user.role === 'admin' || req.user.role === 'owner') {
|
||||
} else if (req.user.role === 'admin') {
|
||||
membersQ += ' AND tenant_id = ?';
|
||||
membersParams.push(req.user.tenant_id);
|
||||
} else if (req.user.role === 'manager') {
|
||||
@@ -906,7 +980,7 @@ apiRouter.get('/search', async (req, res) => {
|
||||
|
||||
if (req.user.role === 'super_admin') {
|
||||
// No extra filters
|
||||
} else if (req.user.role === 'admin' || req.user.role === 'owner') {
|
||||
} else if (req.user.role === 'admin') {
|
||||
teamsQ += ' AND tenant_id = ?';
|
||||
teamsParams.push(req.user.tenant_id);
|
||||
} else if (req.user.role === 'manager') {
|
||||
@@ -929,7 +1003,7 @@ apiRouter.get('/search', async (req, res) => {
|
||||
|
||||
if (req.user.role === 'super_admin') {
|
||||
// No extra filters
|
||||
} else if (req.user.role === 'admin' || req.user.role === 'owner') {
|
||||
} else if (req.user.role === 'admin') {
|
||||
attendancesQ += ' AND a.tenant_id = ?';
|
||||
attendancesParams.push(req.user.tenant_id);
|
||||
} else if (req.user.role === 'manager') {
|
||||
@@ -1020,7 +1094,7 @@ apiRouter.get('/attendances/:id', async (req, res) => {
|
||||
});
|
||||
|
||||
// --- API Key Management Routes ---
|
||||
apiRouter.get('/api-keys', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => {
|
||||
apiRouter.get('/api-keys', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
try {
|
||||
const { tenantId } = req.query;
|
||||
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
|
||||
@@ -1033,7 +1107,7 @@ apiRouter.get('/api-keys', requireRole(['admin', 'owner', 'super_admin']), async
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.post('/api-keys', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => {
|
||||
apiRouter.post('/api-keys', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
const { name, tenantId } = req.body;
|
||||
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
|
||||
try {
|
||||
@@ -1053,7 +1127,7 @@ apiRouter.post('/api-keys', requireRole(['admin', 'owner', 'super_admin']), asyn
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.delete('/api-keys/:id', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => {
|
||||
apiRouter.delete('/api-keys/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
try {
|
||||
const [existing] = await pool.query('SELECT tenant_id FROM api_keys WHERE id = ?', [req.params.id]);
|
||||
if (existing.length === 0) return res.status(404).json({ error: 'Chave não encontrada' });
|
||||
@@ -1083,8 +1157,19 @@ apiRouter.get('/integration/users', requireRole(['admin']), async (req, res) =>
|
||||
apiRouter.get('/integration/origins', requireRole(['admin']), async (req, res) => {
|
||||
if (!req.user.is_api_key) return res.status(403).json({ error: 'Endpoint restrito a chaves de API.' });
|
||||
try {
|
||||
const [origins] = await pool.query('SELECT name FROM origins WHERE tenant_id = ? ORDER BY created_at ASC', [req.user.tenant_id]);
|
||||
res.json(origins.map(o => o.name));
|
||||
const [groups] = await pool.query('SELECT id, name FROM origin_groups WHERE tenant_id = ?', [req.user.tenant_id]);
|
||||
if (groups.length === 0) return res.json([]);
|
||||
|
||||
const [items] = await pool.query('SELECT origin_group_id, name FROM origin_items WHERE origin_group_id IN (?) ORDER BY created_at ASC', [groups.map(g => g.id)]);
|
||||
const [teams] = await pool.query('SELECT id as team_id, name as team_name, origin_group_id FROM teams WHERE tenant_id = ? AND origin_group_id IS NOT NULL', [req.user.tenant_id]);
|
||||
|
||||
const result = groups.map(g => ({
|
||||
group_name: g.name,
|
||||
origins: items.filter(i => i.origin_group_id === g.id).map(i => i.name),
|
||||
assigned_teams: teams.filter(t => t.origin_group_id === g.id).map(t => ({ id: t.team_id, name: t.team_name }))
|
||||
}));
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
@@ -1231,7 +1316,7 @@ apiRouter.get('/teams', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.post('/teams', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => {
|
||||
apiRouter.post('/teams', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
const { name, description, tenantId } = req.body;
|
||||
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
|
||||
try {
|
||||
@@ -1247,7 +1332,7 @@ apiRouter.post('/teams', requireRole(['admin', 'owner', 'super_admin']), async (
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.put('/teams/:id', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => {
|
||||
apiRouter.put('/teams/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
const { name, description } = req.body;
|
||||
try {
|
||||
const [existing] = await pool.query('SELECT tenant_id FROM teams WHERE id = ?', [req.params.id]);
|
||||
@@ -1267,7 +1352,7 @@ apiRouter.put('/teams/:id', requireRole(['admin', 'owner', 'super_admin']), asyn
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.delete('/teams/:id', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => {
|
||||
apiRouter.delete('/teams/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
try {
|
||||
const [existing] = await pool.query('SELECT tenant_id FROM teams WHERE id = ?', [req.params.id]);
|
||||
if (existing.length === 0) return res.status(404).json({ error: 'Not found' });
|
||||
@@ -1562,6 +1647,20 @@ const provisionSuperAdmin = async (retries = 10, delay = 10000) => {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`);
|
||||
|
||||
// Create refresh_tokens table for persistent sessions
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id varchar(36) NOT NULL,
|
||||
user_id varchar(36) NOT NULL,
|
||||
token varchar(255) NOT NULL,
|
||||
expires_at timestamp NOT NULL,
|
||||
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY token (token),
|
||||
KEY user_id (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`);
|
||||
|
||||
// Add funnel_id to teams
|
||||
try {
|
||||
await connection.query("ALTER TABLE teams ADD COLUMN funnel_id VARCHAR(36) DEFAULT NULL");
|
||||
|
||||
@@ -239,12 +239,16 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
||||
<>
|
||||
<SidebarItem to="/admin/users" icon={Users} label="Membros" collapsed={isSidebarCollapsed} />
|
||||
<SidebarItem to="/admin/teams" icon={Building2} label={currentUser.role === 'manager' ? 'Meu Time' : 'Times'} collapsed={isSidebarCollapsed} />
|
||||
{currentUser.role !== 'manager' && (
|
||||
<>
|
||||
<SidebarItem to="/admin/funnels" icon={Layers} label="Gerenciar Funis" collapsed={isSidebarCollapsed} />
|
||||
<SidebarItem to="/admin/origins" icon={Target} label="Origens de Lead" collapsed={isSidebarCollapsed} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Super Admin Links */}
|
||||
{isSuperAdmin && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
version: '3.8'
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
app:
|
||||
@@ -43,12 +43,31 @@ services:
|
||||
networks:
|
||||
- fasto-net
|
||||
|
||||
backup-mysql:
|
||||
image: fradelg/mysql-cron-backup
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
environment:
|
||||
MYSQL_HOST: db
|
||||
MYSQL_USER: root
|
||||
MYSQL_PASS: ${DB_PASSWORD:-root_password}
|
||||
CRON_TIME: "55 2 * * *" # Roda todo dia exatamente às 02:55 da manhã
|
||||
MAX_BACKUPS: 3 # Mantém apenas os 3 últimos dias
|
||||
INIT_BACKUP: "1" # Faz um backup imediatamente ao ligar o container
|
||||
volumes:
|
||||
- /opt/backups_db/fasto:/backup
|
||||
networks:
|
||||
- fasto-net
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
|
||||
configs:
|
||||
init_sql:
|
||||
file: ./agenciac_comia.sql
|
||||
name: init_sql_v2
|
||||
|
||||
networks:
|
||||
fasto-net:
|
||||
|
||||
@@ -202,9 +202,17 @@ export const UserProfile: React.FC = () => {
|
||||
id="fullName"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-zinc-200 dark:border-zinc-800 rounded-lg bg-white dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none focus:ring-2 focus:ring-yellow-400/20 focus:border-yellow-400 transition-all sm:text-sm"
|
||||
disabled={user.role === 'agent'}
|
||||
className={`block w-full pl-10 pr-3 py-2 border border-zinc-200 dark:border-zinc-800 rounded-lg sm:text-sm transition-all ${
|
||||
user.role === 'agent'
|
||||
? 'bg-zinc-50 dark:bg-zinc-900/50 text-zinc-500 dark:text-zinc-500 cursor-not-allowed'
|
||||
: 'bg-white dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none focus:ring-2 focus:ring-yellow-400/20 focus:border-yellow-400'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{user.role === 'agent' && (
|
||||
<p className="text-xs text-zinc-400 dark:text-zinc-500 mt-1">Contate um administrador para alterar seu nome.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -6,8 +6,8 @@ import { Attendance, DashboardFilter, User } from '../types';
|
||||
// Em desenvolvimento, aponta para o localhost:3001
|
||||
const API_URL = import.meta.env.PROD ? '/api' : 'http://localhost:3001/api';
|
||||
|
||||
const getHeaders = () => {
|
||||
const token = localStorage.getItem('ctms_token');
|
||||
const getHeaders = (customToken?: string) => {
|
||||
const token = customToken || localStorage.getItem('ctms_token');
|
||||
// Evitar enviar "undefined" ou "null" como strings se o localStorage estiver corrompido
|
||||
if (!token || token === 'undefined' || token === 'null') return { 'Content-Type': 'application/json' };
|
||||
|
||||
@@ -17,9 +17,76 @@ const getHeaders = () => {
|
||||
};
|
||||
};
|
||||
|
||||
// Global flag to prevent multiple simultaneous refresh attempts
|
||||
let isRefreshing = false;
|
||||
let refreshSubscribers: ((token: string) => void)[] = [];
|
||||
|
||||
const onRefreshed = (token: string) => {
|
||||
refreshSubscribers.forEach(cb => cb(token));
|
||||
refreshSubscribers = [];
|
||||
};
|
||||
|
||||
const addRefreshSubscriber = (cb: (token: string) => void) => {
|
||||
refreshSubscribers.push(cb);
|
||||
};
|
||||
|
||||
export const apiFetch = async (url: string, options: RequestInit = {}): Promise<Response> => {
|
||||
let response = await fetch(url, options);
|
||||
|
||||
// If unauthorized, attempt to refresh the token
|
||||
if (response.status === 401 && !url.includes('/auth/login') && !url.includes('/auth/refresh')) {
|
||||
const refreshToken = localStorage.getItem('ctms_refresh_token');
|
||||
|
||||
if (!refreshToken) {
|
||||
logout();
|
||||
return response;
|
||||
}
|
||||
|
||||
if (isRefreshing) {
|
||||
// If a refresh is already in progress, wait for it to finish and retry
|
||||
return new Promise(resolve => {
|
||||
addRefreshSubscriber((newToken) => {
|
||||
options.headers = getHeaders(newToken);
|
||||
resolve(fetch(url, options));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
const refreshResponse = await fetch(`${API_URL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken })
|
||||
});
|
||||
|
||||
if (!refreshResponse.ok) {
|
||||
throw new Error('Refresh token invalid');
|
||||
}
|
||||
|
||||
const data = await refreshResponse.json();
|
||||
localStorage.setItem('ctms_token', data.token);
|
||||
|
||||
// Retry the original request
|
||||
options.headers = getHeaders(data.token);
|
||||
response = await fetch(url, options);
|
||||
|
||||
onRefreshed(data.token);
|
||||
} catch (err) {
|
||||
console.error("Session expired or refresh failed:", err);
|
||||
logout();
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
export const getNotifications = async (): Promise<any[]> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/notifications`, {
|
||||
const response = await apiFetch(`${API_URL}/notifications`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to fetch notifications');
|
||||
@@ -32,7 +99,7 @@ export const getNotifications = async (): Promise<any[]> => {
|
||||
|
||||
export const markNotificationAsRead = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/notifications/${id}`, {
|
||||
const response = await apiFetch(`${API_URL}/notifications/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders()
|
||||
});
|
||||
@@ -45,7 +112,7 @@ export const markNotificationAsRead = async (id: string): Promise<boolean> => {
|
||||
|
||||
export const markAllNotificationsAsRead = async (): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/notifications/read-all`, {
|
||||
const response = await apiFetch(`${API_URL}/notifications/read-all`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders()
|
||||
});
|
||||
@@ -58,7 +125,7 @@ export const markAllNotificationsAsRead = async (): Promise<boolean> => {
|
||||
|
||||
export const deleteNotification = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/notifications/${id}`, {
|
||||
const response = await apiFetch(`${API_URL}/notifications/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
@@ -71,7 +138,7 @@ export const deleteNotification = async (id: string): Promise<boolean> => {
|
||||
|
||||
export const clearAllNotifications = async (): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/notifications/clear-all`, {
|
||||
const response = await apiFetch(`${API_URL}/notifications/clear-all`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
@@ -85,7 +152,7 @@ export const clearAllNotifications = async (): Promise<boolean> => {
|
||||
// --- Funnels Functions ---
|
||||
export const getFunnels = async (tenantId: string): Promise<any[]> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/funnels?tenantId=${tenantId}`, {
|
||||
const response = await apiFetch(`${API_URL}/funnels?tenantId=${tenantId}`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (!response.ok) throw new Error('Falha ao buscar funis');
|
||||
@@ -97,7 +164,7 @@ export const getFunnels = async (tenantId: string): Promise<any[]> => {
|
||||
};
|
||||
|
||||
export const createFunnel = async (data: { name: string, tenantId: string }): Promise<any> => {
|
||||
const response = await fetch(`${API_URL}/funnels`, {
|
||||
const response = await apiFetch(`${API_URL}/funnels`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
@@ -111,7 +178,7 @@ export const createFunnel = async (data: { name: string, tenantId: string }): Pr
|
||||
|
||||
export const updateFunnel = async (id: string, data: { name?: string, teamIds?: string[] }): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/funnels/${id}`, {
|
||||
const response = await apiFetch(`${API_URL}/funnels/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
@@ -125,7 +192,7 @@ export const updateFunnel = async (id: string, data: { name?: string, teamIds?:
|
||||
|
||||
export const deleteFunnel = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/funnels/${id}`, {
|
||||
const response = await apiFetch(`${API_URL}/funnels/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
@@ -137,7 +204,7 @@ export const deleteFunnel = async (id: string): Promise<boolean> => {
|
||||
};
|
||||
|
||||
export const createFunnelStage = async (funnelId: string, data: any): Promise<any> => {
|
||||
const response = await fetch(`${API_URL}/funnels/${funnelId}/stages`, {
|
||||
const response = await apiFetch(`${API_URL}/funnels/${funnelId}/stages`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
@@ -151,7 +218,7 @@ export const createFunnelStage = async (funnelId: string, data: any): Promise<an
|
||||
|
||||
export const updateFunnelStage = async (id: string, data: any): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/funnel_stages/${id}`, {
|
||||
const response = await apiFetch(`${API_URL}/funnel_stages/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
@@ -165,7 +232,7 @@ export const updateFunnelStage = async (id: string, data: any): Promise<boolean>
|
||||
|
||||
export const deleteFunnelStage = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/funnel_stages/${id}`, {
|
||||
const response = await apiFetch(`${API_URL}/funnel_stages/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
@@ -179,7 +246,7 @@ export const deleteFunnelStage = async (id: string): Promise<boolean> => {
|
||||
// --- Origins Functions ---
|
||||
export const getOrigins = async (tenantId: string): Promise<any[]> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/origins?tenantId=${tenantId}`, {
|
||||
const response = await apiFetch(`${API_URL}/origins?tenantId=${tenantId}`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (!response.ok) throw new Error('Falha ao buscar origens');
|
||||
@@ -191,7 +258,7 @@ export const getOrigins = async (tenantId: string): Promise<any[]> => {
|
||||
};
|
||||
|
||||
export const createOriginGroup = async (data: { name: string, tenantId: string }): Promise<any> => {
|
||||
const response = await fetch(`${API_URL}/origins`, {
|
||||
const response = await apiFetch(`${API_URL}/origins`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
@@ -205,7 +272,7 @@ export const createOriginGroup = async (data: { name: string, tenantId: string }
|
||||
|
||||
export const updateOriginGroup = async (id: string, data: { name?: string, teamIds?: string[] }): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/origins/${id}`, {
|
||||
const response = await apiFetch(`${API_URL}/origins/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
@@ -219,7 +286,7 @@ export const updateOriginGroup = async (id: string, data: { name?: string, teamI
|
||||
|
||||
export const deleteOriginGroup = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/origins/${id}`, {
|
||||
const response = await apiFetch(`${API_URL}/origins/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
@@ -231,7 +298,7 @@ export const deleteOriginGroup = async (id: string): Promise<boolean> => {
|
||||
};
|
||||
|
||||
export const createOriginItem = async (groupId: string, data: { name: string, color_class?: string }): Promise<any> => {
|
||||
const response = await fetch(`${API_URL}/origins/${groupId}/items`, {
|
||||
const response = await apiFetch(`${API_URL}/origins/${groupId}/items`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
@@ -245,7 +312,7 @@ export const createOriginItem = async (groupId: string, data: { name: string, co
|
||||
|
||||
export const updateOriginItem = async (id: string, data: { name: string, color_class?: string }): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/origin_items/${id}`, {
|
||||
const response = await apiFetch(`${API_URL}/origin_items/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
@@ -259,7 +326,7 @@ export const updateOriginItem = async (id: string, data: { name: string, color_c
|
||||
|
||||
export const deleteOriginItem = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/origin_items/${id}`, {
|
||||
const response = await apiFetch(`${API_URL}/origin_items/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
@@ -273,7 +340,7 @@ export const deleteOriginItem = async (id: string): Promise<boolean> => {
|
||||
// --- API Keys Functions ---
|
||||
export const getApiKeys = async (tenantId: string): Promise<any[]> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api-keys?tenantId=${tenantId}`, {
|
||||
const response = await apiFetch(`${API_URL}/api-keys?tenantId=${tenantId}`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (!response.ok) throw new Error('Falha ao buscar chaves');
|
||||
@@ -285,7 +352,7 @@ export const getApiKeys = async (tenantId: string): Promise<any[]> => {
|
||||
};
|
||||
|
||||
export const createApiKey = async (data: { name: string, tenantId: string }): Promise<any> => {
|
||||
const response = await fetch(`${API_URL}/api-keys`, {
|
||||
const response = await apiFetch(`${API_URL}/api-keys`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
@@ -299,7 +366,7 @@ export const createApiKey = async (data: { name: string, tenantId: string }): Pr
|
||||
|
||||
export const deleteApiKey = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api-keys/${id}`, {
|
||||
const response = await apiFetch(`${API_URL}/api-keys/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
@@ -312,7 +379,7 @@ export const deleteApiKey = async (id: string): Promise<boolean> => {
|
||||
|
||||
export const searchGlobal = async (query: string): Promise<{ members: User[], teams: any[], attendances: any[], organizations?: any[] }> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/search?q=${encodeURIComponent(query)}`, {
|
||||
const response = await apiFetch(`${API_URL}/search?q=${encodeURIComponent(query)}`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (!response.ok) throw new Error('Search failed');
|
||||
@@ -336,7 +403,7 @@ export const getAttendances = async (tenantId: string, filter: DashboardFilter):
|
||||
if (filter.funnelStage && filter.funnelStage !== 'all') params.append('funnelStage', filter.funnelStage);
|
||||
if (filter.origin && filter.origin !== 'all') params.append('origin', filter.origin);
|
||||
|
||||
const response = await fetch(`${API_URL}/attendances?${params.toString()}`, {
|
||||
const response = await apiFetch(`${API_URL}/attendances?${params.toString()}`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
|
||||
@@ -357,7 +424,7 @@ export const getUsers = async (tenantId: string): Promise<User[]> => {
|
||||
const params = new URLSearchParams();
|
||||
if (tenantId !== 'all') params.append('tenantId', tenantId);
|
||||
|
||||
const response = await fetch(`${API_URL}/users?${params.toString()}`, {
|
||||
const response = await apiFetch(`${API_URL}/users?${params.toString()}`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (!response.ok) throw new Error('Falha ao buscar usuários');
|
||||
@@ -371,7 +438,7 @@ export const getUsers = async (tenantId: string): Promise<User[]> => {
|
||||
|
||||
export const getUserById = async (id: string): Promise<User | undefined> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/users/${id}`, {
|
||||
const response = await apiFetch(`${API_URL}/users/${id}`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -393,7 +460,7 @@ export const getUserById = async (id: string): Promise<User | undefined> => {
|
||||
|
||||
export const updateUser = async (id: string, userData: any): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/users/${id}`, {
|
||||
const response = await apiFetch(`${API_URL}/users/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(userData)
|
||||
@@ -417,7 +484,7 @@ export const uploadAvatar = async (id: string, file: File): Promise<string | nul
|
||||
formData.append('avatar', file);
|
||||
|
||||
const token = localStorage.getItem('ctms_token');
|
||||
const response = await fetch(`${API_URL}/users/${id}/avatar`, {
|
||||
const response = await apiFetch(`${API_URL}/users/${id}/avatar`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
@@ -436,7 +503,7 @@ export const uploadAvatar = async (id: string, file: File): Promise<string | nul
|
||||
|
||||
export const createMember = async (userData: any): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/users`, {
|
||||
const response = await apiFetch(`${API_URL}/users`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(userData)
|
||||
@@ -456,7 +523,7 @@ export const createMember = async (userData: any): Promise<boolean> => {
|
||||
|
||||
export const deleteUser = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/users/${id}`, {
|
||||
const response = await apiFetch(`${API_URL}/users/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
@@ -469,7 +536,7 @@ export const deleteUser = async (id: string): Promise<boolean> => {
|
||||
|
||||
export const getAttendanceById = async (id: string): Promise<Attendance | undefined> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/attendances/${id}`, {
|
||||
const response = await apiFetch(`${API_URL}/attendances/${id}`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (!response.ok) return undefined;
|
||||
@@ -487,7 +554,7 @@ export const getAttendanceById = async (id: string): Promise<Attendance | undefi
|
||||
|
||||
export const getTenants = async (): Promise<any[]> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/tenants`, {
|
||||
const response = await apiFetch(`${API_URL}/tenants`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (!response.ok) throw new Error('Falha ao buscar tenants');
|
||||
@@ -505,7 +572,7 @@ export const getTenants = async (): Promise<any[]> => {
|
||||
|
||||
export const getTeams = async (tenantId: string): Promise<any[]> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/teams?tenantId=${tenantId}`, {
|
||||
const response = await apiFetch(`${API_URL}/teams?tenantId=${tenantId}`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (!response.ok) throw new Error('Falha ao buscar equipes');
|
||||
@@ -518,7 +585,7 @@ export const getTeams = async (tenantId: string): Promise<any[]> => {
|
||||
|
||||
export const createTeam = async (teamData: any): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/teams`, {
|
||||
const response = await apiFetch(`${API_URL}/teams`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(teamData)
|
||||
@@ -532,7 +599,7 @@ export const createTeam = async (teamData: any): Promise<boolean> => {
|
||||
|
||||
export const updateTeam = async (id: string, teamData: any): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/teams/${id}`, {
|
||||
const response = await apiFetch(`${API_URL}/teams/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(teamData)
|
||||
@@ -546,7 +613,7 @@ export const updateTeam = async (id: string, teamData: any): Promise<boolean> =>
|
||||
|
||||
export const deleteTeam = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/teams/${id}`, {
|
||||
const response = await apiFetch(`${API_URL}/teams/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
@@ -559,7 +626,7 @@ export const deleteTeam = async (id: string): Promise<boolean> => {
|
||||
|
||||
export const createTenant = async (tenantData: any): Promise<{ success: boolean; message?: string }> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/tenants`, {
|
||||
const response = await apiFetch(`${API_URL}/tenants`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(tenantData)
|
||||
@@ -575,7 +642,7 @@ export const createTenant = async (tenantData: any): Promise<{ success: boolean;
|
||||
|
||||
export const updateTenant = async (id: string, tenantData: any): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/tenants/${id}`, {
|
||||
const response = await apiFetch(`${API_URL}/tenants/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(tenantData)
|
||||
@@ -589,7 +656,7 @@ export const updateTenant = async (id: string, tenantData: any): Promise<boolean
|
||||
|
||||
export const deleteTenant = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/tenants/${id}`, {
|
||||
const response = await apiFetch(`${API_URL}/tenants/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
@@ -607,9 +674,23 @@ 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> => {
|
||||
@@ -630,6 +711,7 @@ export const login = async (credentials: any): Promise<any> => {
|
||||
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 || '');
|
||||
}
|
||||
@@ -637,7 +719,7 @@ export const login = async (credentials: any): Promise<any> => {
|
||||
};
|
||||
|
||||
export const impersonateTenant = async (tenantId: string): Promise<any> => {
|
||||
const response = await fetch(`${API_URL}/impersonate/${tenantId}`, {
|
||||
const response = await apiFetch(`${API_URL}/impersonate/${tenantId}`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders()
|
||||
});
|
||||
@@ -705,7 +787,7 @@ export const returnToSuperAdmin = (): boolean => {
|
||||
};
|
||||
|
||||
export const register = async (userData: any): Promise<boolean> => {
|
||||
const response = await fetch(`${API_URL}/auth/register`, {
|
||||
const response = await apiFetch(`${API_URL}/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(userData)
|
||||
@@ -718,7 +800,7 @@ export const register = async (userData: any): Promise<boolean> => {
|
||||
};
|
||||
|
||||
export const verifyCode = async (data: any): Promise<boolean> => {
|
||||
const response = await fetch(`${API_URL}/auth/verify`, {
|
||||
const response = await apiFetch(`${API_URL}/auth/verify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
@@ -731,7 +813,7 @@ export const verifyCode = async (data: any): Promise<boolean> => {
|
||||
};
|
||||
|
||||
export const forgotPassword = async (email: string): Promise<string> => {
|
||||
const response = await fetch(`${API_URL}/auth/forgot-password`, {
|
||||
const response = await apiFetch(`${API_URL}/auth/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email })
|
||||
@@ -750,7 +832,7 @@ export const forgotPassword = async (email: string): Promise<string> => {
|
||||
};
|
||||
|
||||
export const resetPassword = async (password: string, token: string, name?: string): Promise<string> => {
|
||||
const response = await fetch(`${API_URL}/auth/reset-password`, {
|
||||
const response = await apiFetch(`${API_URL}/auth/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password, token, name })
|
||||
|
||||
Reference in New Issue
Block a user