feat: multi-user auth with role-based access
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 6s

- 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) <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-31 22:17:44 +03:00
parent 8cf6f9eedd
commit 70af4962a6
12 changed files with 242 additions and 43 deletions

View File

@@ -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}`);

View File

@@ -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<string, { createdAt: number }>();
interface UserRecord {
Id: number;
Name: string;
PIN: string;
Role: string;
}
interface Session {
name: string;
role: string;
createdAt: number;
}
const sessions = new Map<string, Session>();
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<UserRecord>(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);

View File

@@ -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<string> {
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;