Files
hihala-dashboard/server/src/routes/auth.ts
T
fahed e41cff831b feat: per-user museum and channel access control
- PATCH /api/users/:id route to update user permissions
- Auth session stores and returns allowedMuseums/allowedChannels
- User type gains AllowedMuseums/AllowedChannels (JSON string fields)
- parseAllowed() with fail-closed semantics (empty string → null → no data)
- Dashboard/Comparison apply permission base filter before user filters
- Filter dropdowns (museums, channels, years, districts) derived from
  permission-filtered data — restricted users only see their allowed options
- Settings UserRow component with inline checkbox pickers for access config
- Access badges in users table showing current restriction summary

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 18:03:19 +03:00

114 lines
3.3 KiB
TypeScript

import { Router, Request, Response } from 'express';
import crypto from 'crypto';
import { auth } from '../config';
import { discoverTableIds, fetchAllRecords } from '../services/nocodbClient';
const router = Router();
interface UserRecord {
Id: number;
Name: string;
PIN: string;
Role: string;
AllowedMuseums?: string;
AllowedChannels?: string;
}
interface Session {
name: string;
role: string;
createdAt: number;
allowedMuseums: string;
allowedChannels: string;
}
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 getSession(sessionId: string): Session | null {
const session = sessions.get(sessionId);
if (!session) return null;
if (Date.now() - session.createdAt > SESSION_MAX_AGE) {
sessions.delete(sessionId);
return null;
}
return session;
}
// POST /auth/login
router.post('/login', async (req: Request, res: Response) => {
const { pin } = req.body;
if (!pin) {
res.status(400).json({ error: 'PIN required' });
return;
}
// 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(), allowedMuseums: '[]', allowedChannels: '[]' });
res.cookie('hihala_session', sessionId, { httpOnly: true, sameSite: 'lax', maxAge: SESSION_MAX_AGE, path: '/' });
res.json({ ok: true, name: 'Admin', role: 'admin', allowedMuseums: '[]', allowedChannels: '[]' });
return;
}
// 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(),
allowedMuseums: user.AllowedMuseums || '[]',
allowedChannels: user.AllowedChannels || '[]',
});
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',
allowedMuseums: user.AllowedMuseums || '[]',
allowedChannels: user.AllowedChannels || '[]',
});
return;
}
}
} catch (err) {
console.warn('Failed to check Users table:', (err as Error).message);
}
res.status(401).json({ error: 'Invalid PIN' });
});
// GET /auth/check
router.get('/check', (req: Request, res: Response) => {
const sessionId = req.cookies?.hihala_session;
const session = sessionId ? getSession(sessionId) : null;
res.json({
authenticated: !!session,
name: session?.name || null,
role: session?.role || null,
allowedMuseums: session?.allowedMuseums ?? '[]',
allowedChannels: session?.allowedChannels ?? '[]',
});
});
// POST /auth/logout
router.post('/logout', (req: Request, res: Response) => {
const sessionId = req.cookies?.hihala_session;
if (sessionId) sessions.delete(sessionId);
res.clearCookie('hihala_session', { path: '/' });
res.json({ ok: true });
});
export default router;