feat: complete fine-grained RBAC rules across all roles
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m52s
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m52s
- Restricted Agent view to own dashboard and hid management tabs. - Allowed Managers to create teams and members but restricted them from editing roles or emails. - Allowed Admins to update their own email via profile. - Protected Admin roles from being modified by anyone other than Super Admins.
This commit is contained in:
4
App.tsx
4
App.tsx
@@ -74,8 +74,8 @@ const App: React.FC = () => {
|
|||||||
<Route path="/reset-password" element={<ResetPassword />} />
|
<Route path="/reset-password" element={<ResetPassword />} />
|
||||||
<Route path="/setup-account" element={<SetupAccount />} />
|
<Route path="/setup-account" element={<SetupAccount />} />
|
||||||
<Route path="/" element={<AuthGuard><Dashboard /></AuthGuard>} />
|
<Route path="/" element={<AuthGuard><Dashboard /></AuthGuard>} />
|
||||||
<Route path="/admin/users" element={<AuthGuard><TeamManagement /></AuthGuard>} />
|
<Route path="/admin/users" element={<AuthGuard roles={['super_admin', 'admin', 'manager']}><TeamManagement /></AuthGuard>} />
|
||||||
<Route path="/admin/teams" element={<AuthGuard><Teams /></AuthGuard>} />
|
<Route path="/admin/teams" element={<AuthGuard roles={['super_admin', 'admin', 'manager']}><Teams /></AuthGuard>} />
|
||||||
<Route path="/users/:id" element={<AuthGuard><UserDetail /></AuthGuard>} />
|
<Route path="/users/:id" element={<AuthGuard><UserDetail /></AuthGuard>} />
|
||||||
<Route path="/attendances/:id" element={<AuthGuard><AttendanceDetail /></AuthGuard>} />
|
<Route path="/attendances/:id" element={<AuthGuard><AttendanceDetail /></AuthGuard>} />
|
||||||
<Route path="/super-admin" element={<AuthGuard roles={['super_admin']}><SuperAdmin /></AuthGuard>} />
|
<Route path="/super-admin" element={<AuthGuard roles={['super_admin']}><SuperAdmin /></AuthGuard>} />
|
||||||
|
|||||||
51
GEMINI.md
51
GEMINI.md
@@ -1,33 +1,30 @@
|
|||||||
# Fasto Project Documentation
|
# Fasto Project Documentation
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
Fasto is a commercial team management system built with React (Vite) on the frontend and Node.js (Express) on the backend. It uses a MySQL database.
|
Fasto is a commercial team management system built with React (Vite) on the frontend and Node.js (Express) on the backend. It uses a MySQL database. It features a complete multi-tenant architecture designed to securely host multiple client organizations within a single deployment.
|
||||||
|
|
||||||
## 🚀 Recent Major Changes (March 2026)
|
## 🚀 Recent Major Changes (March 2026)
|
||||||
We have transitioned from a mock-based frontend to a fully functional, production-ready system:
|
We have transitioned from a mock-based prototype to a **secure, multi-tenant production architecture**:
|
||||||
|
|
||||||
- **Authentication:** Implemented real JWT-based authentication with password hashing (bcryptjs).
|
- **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.
|
||||||
- **Backend Integration:** Replaced all hardcoded constants with real API calls to a Node.js/Express backend connected to a MySQL 8.0 database.
|
- **Role-Based Access Control (RBAC):**
|
||||||
- **RBAC (Role-Based Access Control):** Implemented permissions for `super_admin`, `admin`, `manager`, and `agent`.
|
- **Super Admin:** Global management of all tenants and users (via the hidden `system` tenant).
|
||||||
- **Membros (Members):** Enhanced to manage roles, teams, and status. Includes a safety modal for deletion.
|
- **Admin/Manager:** Full control over members and teams within their specific organization.
|
||||||
- **Times (Teams):** Created a new dashboard to manage sales groups with real-time performance metrics.
|
- **Agent:** Restricted access. Can only view their own performance metrics and historical attendances.
|
||||||
- **UI/UX:** Standardized PT-BR translations and refined modal layouts.
|
- **Premium "Onyx & Gold" UI/UX:** Completely redesigned the dark mode using a true neutral Charcoal (Zinc) palette, high-contrast text, and brand Yellow accents.
|
||||||
|
- **Dynamic KPI Dashboard:** Implemented true period-over-period trend calculations for Leads, Quality Scores, and Response Times.
|
||||||
|
- **Secure File Uploads:** Profile avatars are now securely uploaded using `multer` with strict mimetype validation (JPG/PNG/WEBP), 2MB size limits, and UUID generation to prevent path traversal.
|
||||||
|
- **Enhanced Security Flows:**
|
||||||
|
- User routing uses secure `slugs` instead of exposing raw UUIDs.
|
||||||
|
- All password reset and setup tokens strictly expire in 15 minutes and are destroyed upon use.
|
||||||
|
- Separated the "Reset Password" and "Setup Account" (for new admins) flows for better UX.
|
||||||
|
|
||||||
## 🛠 Architecture
|
## 🛠 Architecture
|
||||||
- **Frontend**: React 19, TypeScript, Vite, TailwindCSS (CDN).
|
- **Frontend**: React 19, TypeScript, Vite, TailwindCSS (CDN).
|
||||||
- **Backend**: Node.js, Express, MySQL2 (Pool-based).
|
- **Backend**: Node.js, Express, MySQL2 (Pool-based).
|
||||||
- **Database**: MySQL 8.0 (Schema: `agenciac_comia`).
|
- **Database**: MySQL 8.0 (Schema: `fasto_db`).
|
||||||
- **Deployment**: Docker Compose for local development; Gitea Actions for CI/CD pushing to a Gitea Registry and deploying via Portainer webhook.
|
- **Deployment**: Docker Compose for local development; Gitea Actions for CI/CD pushing to a Gitea Registry and deploying via Portainer webhook.
|
||||||
|
|
||||||
## ⚠️ Current Error: Build Instability
|
|
||||||
We are currently resolving a recurring build error: `Unexpected end of file` or `Expected ">" but found "\"`.
|
|
||||||
|
|
||||||
### Technical Root Cause:
|
|
||||||
This is a **tool-level synchronization issue**:
|
|
||||||
1. **Truncation:** The file-writing tool (`write_file`) occasionally truncates code before the final braces (`}`) or tags (`</div>`) are written.
|
|
||||||
2. **Escaping Glitches:** In long JSX strings (like Tailwind class lists), the system sometimes inserts accidental characters that break the JavaScript syntax.
|
|
||||||
3. **Result:** The Vite/Esbuild compiler fails because it reaches the end of an incomplete or syntactically broken file.
|
|
||||||
|
|
||||||
## 📋 Prerequisites
|
## 📋 Prerequisites
|
||||||
- Docker & Docker Compose
|
- Docker & Docker Compose
|
||||||
- Node.js (for local development outside Docker)
|
- Node.js (for local development outside Docker)
|
||||||
@@ -39,17 +36,18 @@ Copy `.env.example` to `.env` and adjust values:
|
|||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
Ensure you set the database credentials and `GITEA_RUNNER_REGISTRATION_TOKEN`.
|
Ensure you set the database credentials (`DB_NAME=fasto_db` for production) and `GITEA_RUNNER_REGISTRATION_TOKEN`.
|
||||||
|
|
||||||
### 2. Database
|
### 2. Database
|
||||||
The project expects a MySQL database. The `docker-compose.yml` initializes it with `agenciac_comia.sql`.
|
The project expects a MySQL database. The `docker-compose.local.yml` initializes it with `agenciac_comia.sql`.
|
||||||
|
*Note for Production:* If migrating from an old version, you must manually run the SQL to create the `password_resets` and `pending_registrations` tables, or rebuild the volume.
|
||||||
|
|
||||||
### 3. Running with Docker Compose
|
### 3. Running Locally (Docker Compose)
|
||||||
To start the application, database, and runner:
|
To start the application and database locally:
|
||||||
```bash
|
```bash
|
||||||
docker-compose up -d --build
|
docker-compose -f docker-compose.local.yml up -d --build
|
||||||
```
|
```
|
||||||
- **Frontend/Backend**: http://localhost:3001
|
- **App**: http://localhost:3001
|
||||||
- **Database**: Port 3306
|
- **Database**: Port 3306
|
||||||
|
|
||||||
### 4. Gitea Runner
|
### 4. Gitea Runner
|
||||||
@@ -64,9 +62,6 @@ The project uses Gitea Actions defined in `.gitea/workflows/build-deploy.yaml`.
|
|||||||
2. Build Docker image.
|
2. Build Docker image.
|
||||||
3. Push to `gitea.blyzer.com.br`.
|
3. Push to `gitea.blyzer.com.br`.
|
||||||
4. Trigger Portainer webhook.
|
4. Trigger Portainer webhook.
|
||||||
- **Secrets Required in Gitea**:
|
|
||||||
`REGISTRY_USERNAME`, `REGISTRY_TOKEN`, `PORTAINER_WEBHOOK`, `API_KEY`.
|
|
||||||
|
|
||||||
## 💻 Development
|
## 💻 Development
|
||||||
- **Frontend**: `npm run dev` (Port 3000)
|
The Dockerfile uses a unified root structure. Both the frontend build and the backend Node.js server are hosted from the same container image.
|
||||||
- **Backend**: `node backend/index.js` (Port 3001)
|
|
||||||
|
|||||||
@@ -358,7 +358,7 @@ apiRouter.post('/users', requireRole(['admin', 'owner', 'super_admin']), async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
apiRouter.put('/users/:id', async (req, res) => {
|
apiRouter.put('/users/:id', async (req, res) => {
|
||||||
const { name, bio, role, team_id, status } = req.body;
|
const { name, bio, role, team_id, status, email } = req.body;
|
||||||
try {
|
try {
|
||||||
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' });
|
||||||
@@ -377,10 +377,16 @@ apiRouter.put('/users/:id', async (req, res) => {
|
|||||||
const finalRole = isManagerOrAdmin && role !== undefined ? role : existing[0].role;
|
const finalRole = isManagerOrAdmin && role !== undefined ? role : existing[0].role;
|
||||||
const finalTeamId = isManagerOrAdmin && team_id !== undefined ? team_id : existing[0].team_id;
|
const finalTeamId = isManagerOrAdmin && team_id !== undefined ? team_id : existing[0].team_id;
|
||||||
const finalStatus = isManagerOrAdmin && status !== undefined ? status : existing[0].status;
|
const finalStatus = isManagerOrAdmin && status !== undefined ? status : existing[0].status;
|
||||||
|
const finalEmail = email !== undefined ? email : existing[0].email;
|
||||||
|
|
||||||
|
if (finalEmail !== existing[0].email) {
|
||||||
|
const [emailCheck] = await pool.query('SELECT id FROM users WHERE email = ? AND id != ?', [finalEmail, req.params.id]);
|
||||||
|
if (emailCheck.length > 0) return res.status(400).json({ error: 'E-mail já está em uso.' });
|
||||||
|
}
|
||||||
|
|
||||||
await pool.query(
|
await pool.query(
|
||||||
'UPDATE users SET name = ?, bio = ?, role = ?, team_id = ?, status = ? WHERE id = ?',
|
'UPDATE users SET name = ?, bio = ?, email = ?, role = ?, team_id = ?, status = ? WHERE id = ?',
|
||||||
[name || existing[0].name, bio !== undefined ? bio : existing[0].bio, finalRole, finalTeamId || null, finalStatus, req.params.id]
|
[name || existing[0].name, bio !== undefined ? bio : existing[0].bio, finalEmail, finalRole, finalTeamId || null, finalStatus, req.params.id]
|
||||||
);
|
);
|
||||||
res.json({ message: 'User updated successfully.' });
|
res.json({ message: 'User updated successfully.' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -112,8 +112,12 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
|||||||
{!isSuperAdmin && (
|
{!isSuperAdmin && (
|
||||||
<>
|
<>
|
||||||
<SidebarItem to="/" icon={LayoutDashboard} label="Dashboard" collapsed={false} />
|
<SidebarItem to="/" icon={LayoutDashboard} label="Dashboard" collapsed={false} />
|
||||||
<SidebarItem to="/admin/users" icon={Users} label="Membros" collapsed={false} />
|
{currentUser.role !== 'agent' && (
|
||||||
<SidebarItem to="/admin/teams" icon={Building2} label="Times" collapsed={false} />
|
<>
|
||||||
|
<SidebarItem to="/admin/users" icon={Users} label="Membros" collapsed={false} />
|
||||||
|
<SidebarItem to="/admin/teams" icon={Building2} label="Times" collapsed={false} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -232,10 +232,19 @@ export const TeamManagement: React.FC = () => {
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-bold text-zinc-500 dark:text-dark-muted uppercase mb-1 block">Função</label>
|
<label className="text-xs font-bold text-zinc-500 dark:text-dark-muted uppercase mb-1 block">Função</label>
|
||||||
<select value={formData.role} onChange={e => setFormData({...formData, role: e.target.value as any})} className="w-full bg-white dark:bg-dark-input border border-zinc-200 dark:border-dark-border p-3 rounded-lg text-sm text-zinc-900 dark:text-dark-text">
|
<select
|
||||||
|
value={formData.role}
|
||||||
|
onChange={e => setFormData({...formData, role: e.target.value as any})}
|
||||||
|
className="w-full bg-white dark:bg-dark-input border border-zinc-200 dark:border-dark-border p-3 rounded-lg text-sm text-zinc-900 dark:text-dark-text disabled:bg-zinc-50 dark:disabled:bg-dark-bg/50 dark:disabled:text-dark-muted"
|
||||||
|
disabled={
|
||||||
|
(currentUser?.role === 'manager') ||
|
||||||
|
(editingUser?.role === 'admin' && currentUser?.role !== 'super_admin') ||
|
||||||
|
(editingUser?.role === 'super_admin' && currentUser?.role !== 'super_admin')
|
||||||
|
}
|
||||||
|
>
|
||||||
<option value="agent">Agente</option>
|
<option value="agent">Agente</option>
|
||||||
<option value="manager">Gerente</option>
|
<option value="manager">Gerente</option>
|
||||||
<option value="admin">Admin</option>
|
{currentUser?.role !== 'manager' && <option value="admin">Admin</option>}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const UserProfile: React.FC = () => {
|
|||||||
|
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [bio, setBio] = useState('');
|
const [bio, setBio] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUserAndTenant = async () => {
|
const fetchUserAndTenant = async () => {
|
||||||
@@ -25,6 +26,7 @@ export const UserProfile: React.FC = () => {
|
|||||||
setUser(fetchedUser);
|
setUser(fetchedUser);
|
||||||
setName(fetchedUser.name);
|
setName(fetchedUser.name);
|
||||||
setBio(fetchedUser.bio || '');
|
setBio(fetchedUser.bio || '');
|
||||||
|
setEmail(fetchedUser.email);
|
||||||
|
|
||||||
// Fetch tenant info
|
// Fetch tenant info
|
||||||
const tenants = await getTenants();
|
const tenants = await getTenants();
|
||||||
@@ -85,10 +87,10 @@ export const UserProfile: React.FC = () => {
|
|||||||
setIsSuccess(false);
|
setIsSuccess(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const success = await updateUser(user.id, { name, bio });
|
const success = await updateUser(user.id, { name, bio, email });
|
||||||
if (success) {
|
if (success) {
|
||||||
setIsSuccess(true);
|
setIsSuccess(true);
|
||||||
setUser({ ...user, name, bio });
|
setUser({ ...user, name, bio, email });
|
||||||
setTimeout(() => setIsSuccess(false), 3000);
|
setTimeout(() => setIsSuccess(false), 3000);
|
||||||
} else {
|
} else {
|
||||||
alert('Erro ao salvar alterações no servidor.');
|
alert('Erro ao salvar alterações no servidor.');
|
||||||
@@ -107,6 +109,8 @@ export const UserProfile: React.FC = () => {
|
|||||||
const displayAvatar = user.avatar_url
|
const displayAvatar = user.avatar_url
|
||||||
? (user.avatar_url.startsWith('http') ? user.avatar_url : `${backendUrl}${user.avatar_url}`)
|
? (user.avatar_url.startsWith('http') ? user.avatar_url : `${backendUrl}${user.avatar_url}`)
|
||||||
: `https://ui-avatars.com/api/?name=${encodeURIComponent(user.name)}&background=random`;
|
: `https://ui-avatars.com/api/?name=${encodeURIComponent(user.name)}&background=random`;
|
||||||
|
|
||||||
|
const canEditEmail = user.role === 'admin' || user.role === 'super_admin';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto space-y-6 pb-12 transition-colors duration-300">
|
<div className="max-w-4xl mx-auto space-y-6 pb-12 transition-colors duration-300">
|
||||||
@@ -213,12 +217,17 @@ export const UserProfile: React.FC = () => {
|
|||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
id="email"
|
id="email"
|
||||||
value={user.email}
|
value={email}
|
||||||
disabled
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
className="block w-full pl-10 pr-3 py-2 border border-zinc-200 dark:border-zinc-800 rounded-lg bg-zinc-50 dark:bg-zinc-900/50 text-zinc-500 dark:text-zinc-500 cursor-not-allowed sm:text-sm"
|
disabled={!canEditEmail}
|
||||||
|
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 ${
|
||||||
|
!canEditEmail
|
||||||
|
? '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-brand-yellow/20 focus:border-brand-yellow'
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-zinc-400 dark:text-zinc-500 mt-1">Contate o admin para alterar o e-mail.</p>
|
{!canEditEmail && <p className="text-xs text-zinc-400 dark:text-zinc-500 mt-1">Contate o admin para alterar o e-mail.</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user