diff --git a/CONTEXT.md b/CONTEXT.md
index 7b89caa..4bcab8c 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -161,7 +161,7 @@ yield: 4.8 units/kg
* The production-order screen uses a compact list with inline expansion rather than a side detail page. Each OP has one status control (`Em aberto`, `Em andamento`, `Finalizada`, `Cancelada`), and expanded details show product markers, composition, and material consumption context. Material consumption is an OP-level action, relevant once production has started rather than a permanent page-level form.
### 7.2 Olist V3 connection and composition synchronisation
-* Graphs has an **Administração** section available to every signed-in user. Its **Olist** monitor at `/#/admin/olist` owns the connection action, manual sync, reconnection, stop action, live status, run history, run logs, and affected-product view; Cadastros does not own the Olist connection flow. The same section includes user management at `/#/admin/users`.
+* Graphs has a Super Admin **Olist** monitor at `/#/admin/olist`. The connection action, manual sync, reconnection, stop action, live status, run history, run logs, and affected-product view are all on this page; Cadastros does not own the Olist connection flow.
* OAuth uses Olist/Tiny V3 with an authorization code and refresh token. Tokens are encrypted before storage in `olist_connections`; access tokens are refreshed automatically before expiry. The monitor shows the token expiry as an operational status, not an indication that the connection has failed.
* Required production variables are `OLIST_CLIENT_ID`, `OLIST_CLIENT_SECRET`, `OLIST_REDIRECT_URI`, `OLIST_FRONTEND_URL`, `OLIST_TOKEN_ENCRYPTION_KEY`, and `OLIST_SYNC_ENABLED=true`. The encryption key must be stable (a 32-byte base64 key or 64-character hex key): changing it makes already stored tokens unreadable and requires reconnection.
* The OAuth callback route is `GET /api/olist/oauth/callback`; `OLIST_REDIRECT_URI` must be exactly that publicly reachable backend URL. After authorization Graphs redirects to `/#/admin/olist` on `OLIST_FRONTEND_URL`.
diff --git a/backend/routes/olistRoutes.js b/backend/routes/olistRoutes.js
index bef3e00..6ba67af 100644
--- a/backend/routes/olistRoutes.js
+++ b/backend/routes/olistRoutes.js
@@ -1,5 +1,5 @@
const express = require('express');
-const { verifyToken } = require('../auth');
+const { verifySuperAdmin } = require('../auth');
const {
completeAuthorization,
createAuthorizationUrl,
@@ -13,7 +13,7 @@ const { exportMissingProductionOrderDataCsv, importFinalizedProductionOrderCsv,
const router = express.Router();
-router.get('/olist/status', verifyToken, async (req, res, next) => {
+router.get('/olist/status', verifySuperAdmin, async (req, res, next) => {
try {
res.json(await getOlistStatus({
runsPage: req.query.runsPage,
@@ -24,7 +24,7 @@ router.get('/olist/status', verifyToken, async (req, res, next) => {
}
});
-router.post('/olist/authorization-url', verifyToken, async (req, res, next) => {
+router.post('/olist/authorization-url', verifySuperAdmin, async (req, res, next) => {
try {
res.json({ url: await createAuthorizationUrl() });
} catch (error) {
@@ -32,7 +32,7 @@ router.post('/olist/authorization-url', verifyToken, async (req, res, next) => {
}
});
-router.post('/olist/sync', verifyToken, async (req, res, next) => {
+router.post('/olist/sync', verifySuperAdmin, async (req, res, next) => {
try {
const result = await startOlistSync({
trigger: 'manual',
@@ -44,7 +44,7 @@ router.post('/olist/sync', verifyToken, async (req, res, next) => {
}
});
-router.post('/olist/sync/stop', verifyToken, async (req, res, next) => {
+router.post('/olist/sync/stop', verifySuperAdmin, async (req, res, next) => {
try {
res.status(202).json(await requestOlistSyncStop());
} catch (error) {
@@ -52,7 +52,7 @@ router.post('/olist/sync/stop', verifyToken, async (req, res, next) => {
}
});
-router.get('/olist/production-order-imports', verifyToken, async (req, res, next) => {
+router.get('/olist/production-order-imports', verifySuperAdmin, async (req, res, next) => {
try {
res.json(await listProductionOrderImportData({
runsPage: req.query.runsPage,
@@ -63,7 +63,7 @@ router.get('/olist/production-order-imports', verifyToken, async (req, res, next
}
});
-router.get('/olist/production-order-imports/pending-csv', verifyToken, async (req, res, next) => {
+router.get('/olist/production-order-imports/pending-csv', verifySuperAdmin, async (req, res, next) => {
try {
res.json(await exportMissingProductionOrderDataCsv());
} catch (error) {
@@ -71,7 +71,7 @@ router.get('/olist/production-order-imports/pending-csv', verifyToken, async (re
}
});
-router.post('/olist/production-order-imports', verifyToken, async (req, res, next) => {
+router.post('/olist/production-order-imports', verifySuperAdmin, async (req, res, next) => {
try {
res.status(201).json(await importFinalizedProductionOrderCsv(req.body || {}));
} catch (error) {
@@ -79,7 +79,7 @@ router.post('/olist/production-order-imports', verifyToken, async (req, res, nex
}
});
-router.get('/olist/runs/:runId', verifyToken, async (req, res, next) => {
+router.get('/olist/runs/:runId', verifySuperAdmin, async (req, res, next) => {
try {
res.json(await getOlistRunDetails(req.params.runId, {
eventsPage: req.query.eventsPage,
diff --git a/backend/routes/userRoutes.js b/backend/routes/userRoutes.js
index 5269f05..dd0bf4c 100644
--- a/backend/routes/userRoutes.js
+++ b/backend/routes/userRoutes.js
@@ -1,5 +1,5 @@
const express = require('express');
-const { verifyToken } = require('../auth');
+const { verifySuperAdmin, verifyToken } = require('../auth');
const { createUser, deleteUser, listActiveOperationalUsers, listUsers, updateUser } = require('../services/userService');
const router = express.Router();
@@ -12,7 +12,7 @@ router.get('/users/operational', verifyToken, async (req, res, next) => {
}
});
-router.get('/users', verifyToken, async (req, res, next) => {
+router.get('/users', verifySuperAdmin, async (req, res, next) => {
try {
const users = await listUsers();
res.json({ users });
@@ -21,7 +21,7 @@ router.get('/users', verifyToken, async (req, res, next) => {
}
});
-router.post('/users', verifyToken, async (req, res, next) => {
+router.post('/users', verifySuperAdmin, async (req, res, next) => {
try {
const { name, email, password } = req.body || {};
const { user, password: userPassword, generatedPassword } = await createUser({ name, email, password });
@@ -40,7 +40,7 @@ router.post('/users', verifyToken, async (req, res, next) => {
}
});
-router.patch('/users/:id', verifyToken, async (req, res, next) => {
+router.patch('/users/:id', verifySuperAdmin, async (req, res, next) => {
try {
const user = await updateUser(req.params.id, req.body || {});
res.json({ user });
@@ -49,7 +49,7 @@ router.patch('/users/:id', verifyToken, async (req, res, next) => {
}
});
-router.delete('/users/:id', verifyToken, async (req, res, next) => {
+router.delete('/users/:id', verifySuperAdmin, async (req, res, next) => {
try {
await deleteUser(req.params.id);
res.status(204).send();
diff --git a/backend/services/userService.js b/backend/services/userService.js
index 84abf6f..9f7c1a5 100644
--- a/backend/services/userService.js
+++ b/backend/services/userService.js
@@ -62,7 +62,8 @@ const listUsers = async () => {
};
// This deliberately exposes only the identity needed to assign operational
-// responsibility. It is available to every signed-in Graphs user.
+// responsibility. It is available to signed-in Graphs users, unlike the full
+// user-management list which remains super-admin only.
const listActiveOperationalUsers = async () => {
const result = await pool.query(
`SELECT id, name
diff --git a/src/App.tsx b/src/App.tsx
index d18a6e4..08b5249 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -2,7 +2,7 @@ import React, { Suspense } from 'react';
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { Loader2 } from 'lucide-react';
import Layout from './components/Layout';
-import { isAuthenticated } from './dataService';
+import { isAuthenticated, isSuperAdmin } from './dataService';
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const Products = React.lazy(() => import('./pages/Products'));
@@ -34,6 +34,13 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
return children;
}
+function SuperAdminRoute({ children }: { children: React.ReactNode }) {
+ if (!isSuperAdmin()) {
+ return