feat: multi-user auth with role-based access
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 6s
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:
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
46
server/src/routes/users.ts
Normal file
46
server/src/routes/users.ts
Normal 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;
|
||||
Reference in New Issue
Block a user