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>
This commit is contained in:
fahed
2026-04-08 18:03:19 +03:00
parent d4ce5b6478
commit e41cff831b
10 changed files with 259 additions and 51 deletions

View File

@@ -10,12 +10,16 @@ interface UserRecord {
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>();
@@ -46,9 +50,9 @@ router.post('/login', async (req: Request, res: Response) => {
// 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() });
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' });
res.json({ ok: true, name: 'Admin', role: 'admin', allowedMuseums: '[]', allowedChannels: '[]' });
return;
}
@@ -60,9 +64,21 @@ router.post('/login', async (req: Request, res: Response) => {
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() });
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' });
res.json({
ok: true,
name: user.Name,
role: user.Role || 'viewer',
allowedMuseums: user.AllowedMuseums || '[]',
allowedChannels: user.AllowedChannels || '[]',
});
return;
}
}
@@ -81,6 +97,8 @@ router.get('/check', (req: Request, res: Response) => {
authenticated: !!session,
name: session?.name || null,
role: session?.role || null,
allowedMuseums: session?.allowedMuseums ?? '[]',
allowedChannels: session?.allowedChannels ?? '[]',
});
});

View File

@@ -1,5 +1,5 @@
import { Router, Request, Response } from 'express';
import { discoverTableIds, fetchAllRecords, createRecord, deleteRecord } from '../services/nocodbClient';
import { discoverTableIds, fetchAllRecords, createRecord, updateRecord, deleteRecord } from '../services/nocodbClient';
const router = Router();
@@ -32,6 +32,17 @@ router.post('/', async (req: Request, res: Response) => {
}
});
// PATCH /api/users/:id
router.patch('/:id', async (req: Request, res: Response) => {
try {
const tableId = await getUsersTableId();
await updateRecord(tableId, parseInt(req.params.id), req.body);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// DELETE /api/users/:id
router.delete('/:id', async (req: Request, res: Response) => {
try {