From 70af4962a65c229e870477a11854a345423b4853 Mon Sep 17 00:00:00 2001 From: fahed Date: Tue, 31 Mar 2026 22:17:44 +0300 Subject: [PATCH] feat: multi-user auth with role-based access - Server checks PIN against env (super admin) + NocoDB Users table - Session stores name + role (admin/viewer) - Admin: sees Settings page (seasons + users management) - Viewer: sees Dashboard + Comparison only, no Settings - Users CRUD on Settings page: add name + PIN + role, delete - Settings link + nav hidden for non-admin users Co-Authored-By: Claude Opus 4.6 (1M context) --- server/src/index.ts | 2 + server/src/routes/auth.ts | 81 ++++++++++++++++++++++++------------ server/src/routes/users.ts | 46 ++++++++++++++++++++ src/App.tsx | 26 ++++++++---- src/components/Dashboard.tsx | 6 +-- src/components/Login.tsx | 5 ++- src/components/Settings.tsx | 67 ++++++++++++++++++++++++++++- src/locales/ar.json | 8 +++- src/locales/en.json | 8 +++- src/services/usersService.ts | 31 ++++++++++++++ src/types/index.ts | 1 + vite.config.ts | 4 ++ 12 files changed, 242 insertions(+), 43 deletions(-) create mode 100644 server/src/routes/users.ts create mode 100644 src/services/usersService.ts diff --git a/server/src/index.ts b/server/src/index.ts index f78c821..6721faa 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -6,6 +6,7 @@ import authRoutes from './routes/auth'; import erpRoutes from './routes/erp'; import etlRoutes from './routes/etl'; import seasonsRoutes from './routes/seasons'; +import usersRoutes from './routes/users'; const app = express(); app.use(cors({ origin: true, credentials: true })); @@ -17,6 +18,7 @@ app.use('/auth', authRoutes); app.use('/api/erp', erpRoutes); app.use('/api/etl', etlRoutes); app.use('/api/seasons', seasonsRoutes); +app.use('/api/users', usersRoutes); app.listen(server.port, () => { console.log(`\nServer running on http://localhost:${server.port}`); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index d65f56c..553c4b9 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -1,61 +1,90 @@ import { Router, Request, Response } from 'express'; import crypto from 'crypto'; import { auth } from '../config'; +import { discoverTableIds, fetchAllRecords } from '../services/nocodbClient'; const router = Router(); -// In-memory session store (simple — works for single server) -const sessions = new Map(); +interface UserRecord { + Id: number; + Name: string; + PIN: string; + Role: string; +} + +interface Session { + name: string; + role: string; + createdAt: number; +} + +const sessions = new Map(); const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days function generateSessionId(): string { return crypto.randomBytes(32).toString('hex'); } -function isValidSession(sessionId: string): boolean { +function getSession(sessionId: string): Session | null { const session = sessions.get(sessionId); - if (!session) return false; + if (!session) return null; if (Date.now() - session.createdAt > SESSION_MAX_AGE) { sessions.delete(sessionId); - return false; + return null; } - return true; + return session; } -// POST /auth/login — verify PIN, set session cookie -router.post('/login', (req: Request, res: Response) => { +// POST /auth/login +router.post('/login', async (req: Request, res: Response) => { const { pin } = req.body; - - if (!auth.adminPin) { - res.status(503).json({ error: 'Auth not configured' }); + if (!pin) { + res.status(400).json({ error: 'PIN required' }); return; } - if (pin !== auth.adminPin) { - res.status(401).json({ error: 'Invalid PIN' }); + // Check super admin PIN from env first + if (auth.adminPin && pin === auth.adminPin) { + const sessionId = generateSessionId(); + sessions.set(sessionId, { name: 'Admin', role: 'admin', createdAt: Date.now() }); + res.cookie('hihala_session', sessionId, { httpOnly: true, sameSite: 'lax', maxAge: SESSION_MAX_AGE, path: '/' }); + res.json({ ok: true, name: 'Admin', role: 'admin' }); return; } - const sessionId = generateSessionId(); - sessions.set(sessionId, { createdAt: Date.now() }); + // Check NocoDB Users table + try { + const tables = await discoverTableIds(); + if (tables['Users']) { + const users = await fetchAllRecords(tables['Users']); + const user = users.find(u => u.PIN === pin); + if (user) { + const sessionId = generateSessionId(); + sessions.set(sessionId, { name: user.Name, role: user.Role || 'viewer', createdAt: Date.now() }); + res.cookie('hihala_session', sessionId, { httpOnly: true, sameSite: 'lax', maxAge: SESSION_MAX_AGE, path: '/' }); + res.json({ ok: true, name: user.Name, role: user.Role || 'viewer' }); + return; + } + } + } catch (err) { + console.warn('Failed to check Users table:', (err as Error).message); + } - res.cookie('hihala_session', sessionId, { - httpOnly: true, - sameSite: 'lax', - maxAge: SESSION_MAX_AGE, - path: '/', - }); - - res.json({ ok: true }); + res.status(401).json({ error: 'Invalid PIN' }); }); -// GET /auth/check — check if session is valid +// GET /auth/check router.get('/check', (req: Request, res: Response) => { const sessionId = req.cookies?.hihala_session; - res.json({ authenticated: !!sessionId && isValidSession(sessionId) }); + const session = sessionId ? getSession(sessionId) : null; + res.json({ + authenticated: !!session, + name: session?.name || null, + role: session?.role || null, + }); }); -// POST /auth/logout — destroy session +// POST /auth/logout router.post('/logout', (req: Request, res: Response) => { const sessionId = req.cookies?.hihala_session; if (sessionId) sessions.delete(sessionId); diff --git a/server/src/routes/users.ts b/server/src/routes/users.ts new file mode 100644 index 0000000..c4e670e --- /dev/null +++ b/server/src/routes/users.ts @@ -0,0 +1,46 @@ +import { Router, Request, Response } from 'express'; +import { discoverTableIds, fetchAllRecords, createRecord, deleteRecord } from '../services/nocodbClient'; + +const router = Router(); + +async function getUsersTableId(): Promise { + const tables = await discoverTableIds(); + const id = tables['Users']; + if (!id) throw new Error("NocoDB table 'Users' not found"); + return id; +} + +// GET /api/users +router.get('/', async (_req: Request, res: Response) => { + try { + const tableId = await getUsersTableId(); + const records = await fetchAllRecords(tableId); + res.json(records); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// POST /api/users +router.post('/', async (req: Request, res: Response) => { + try { + const tableId = await getUsersTableId(); + const result = await createRecord(tableId, req.body); + res.json(result); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// DELETE /api/users/:id +router.delete('/:id', async (req: Request, res: Response) => { + try { + const tableId = await getUsersTableId(); + await deleteRecord(tableId, parseInt(req.params.id)); + res.json({ ok: true }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +export default router; diff --git a/src/App.tsx b/src/App.tsx index dd73d3a..38c1a42 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -38,6 +38,8 @@ interface DataSource { function App() { const { t, dir, switchLanguage } = useLanguage(); const [authenticated, setAuthenticated] = useState(null); + const [userRole, setUserRole] = useState('viewer'); + const [userName, setUserName] = useState(''); const [data, setData] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); @@ -114,6 +116,8 @@ function App() { .then(d => { setAuthenticated(d.authenticated); if (d.authenticated) { + setUserRole(d.role || 'viewer'); + setUserName(d.name || ''); loadData(); loadSeasons(); } @@ -122,8 +126,10 @@ function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const handleLogin = () => { + const handleLogin = (name: string, role: string) => { setAuthenticated(true); + setUserName(name); + setUserRole(role); loadData(); loadSeasons(); }; @@ -281,9 +287,9 @@ function App() {
}> - } /> + } /> } /> - } /> + {userRole === 'admin' && } />}
@@ -307,12 +313,14 @@ function App() { {t('nav.compare')} - - - {t('nav.settings')} - + {userRole === 'admin' && ( + + + {t('nav.settings')} + + )}