6.5 KiB
Per-User Museum & Channel Access Control
Date: 2026-04-08
Status: Approved
Overview
Allow admins to restrict client-facing users to specific museums (events) and channels. When a restricted user logs in, the dashboard only shows data and filter options for their allowed scope — enforced at the UI and data layers (client-side). The enforcement is intentional client-side filtering; this is an internal analytics tool and not a security boundary against a determined attacker.
Data Model
User type (src/types/index.ts)
Add two fields to the existing User interface (keeping PascalCase to match NocoDB conventions used throughout the codebase):
interface User {
Id?: number;
Name: string;
PIN: string;
Role: 'admin' | 'viewer';
AllowedMuseums: string; // JSON-serialized string array, e.g. '["Museum A","Museum B"]'
AllowedChannels: string; // JSON-serialized string array
}
Parsed into a runtime shape used in app state:
interface ParsedUser {
id: number;
name: string;
role: 'admin' | 'viewer';
allowedMuseums: string[] | null; // [] = unrestricted, null = parse error (show nothing)
allowedChannels: string[] | null; // [] = unrestricted, null = parse error (show nothing)
}
Convention: [] = full access (admins), string[] = restricted to list, null = corrupted value (fail-closed: no data shown). Existing users require no migration (missing fields parsed as []).
NocoDB Users table
Add two new fields:
AllowedMuseums— Text field, stores JSON string (e.g.'["Museum A"]')AllowedChannels— Text field, stores JSON string
Both default to "[]" (unrestricted).
Components & Changes
1. src/services/usersService.ts
- Update
fetchUsers()to parseAllowedMuseumsandAllowedChannelsJSON strings intostring[]; on parse error returnnull(fail-closed — no data shown to the user) - Update
createUser()to serializeallowedMuseums/allowedChannelsarrays as JSON strings - Add
updateUser(id, fields)— new function required (see Prerequisites below)
2. server/src/routes/users.ts + nocodbClient.ts
Prerequisite: updateUser does not exist yet. Required additions:
nocodbClient.updateRecord(tableId, rowId, fields)— calls NocoDBPATCH /api/v2/tables/{tableId}/recordsPUT /api/users/:idroute on the server — validates fields and callsupdateRecordupdateUser(id, fields)inusersService.ts— calls the new route
3. server/src/routes/auth.ts
The session object currently stores only { name, role, createdAt }. Extend it to also persist allowedMuseums and allowedChannels at login time:
// On POST /auth/login — after matching user by PIN:
session.allowedMuseums = parsedUser.allowedMuseums;
session.allowedChannels = parsedUser.allowedChannels;
// On GET /auth/check — return alongside existing fields:
res.json({
authenticated: true,
name: session.name,
role: session.role,
allowedMuseums: session.allowedMuseums ?? [],
allowedChannels: session.allowedChannels ?? [],
});
This ensures page reload restores the correct access scope without re-fetching NocoDB.
4. src/App.tsx
- Store
allowedMuseumsandallowedChannelsin app state (alongsideuserRole,userName) - Set them from both the
/auth/loginresponse and the/auth/checkresponse - Pass them as props to both
DashboardandComparisoncomponents - Also pass
allMuseumsandallChannels(unique values extracted from rawdata) as props toSettings
5. src/components/Settings.tsx
Accept two new props: allMuseums: string[] and allChannels: string[].
In the add/edit user form, add two checkbox picker sections (hidden for admin users):
- Allowed Events — checkbox list from
allMuseums - Allowed Channels — checkbox list from
allChannels
UI behavior:
- Empty selection = full access, shown as
"All access"label - Partial selection shown as
"N events"/"N channels"badge - Admin users: section hidden, shown as
"Full access (admin)"static label
6. src/components/Dashboard.tsx
Accept two new props: allowedMuseums: string[] and allowedChannels: string[].
Two enforcement layers applied before any render:
Layer 1 — Filter options restricted:
const visibleMuseums = allowedMuseums.length > 0
? availableMuseums.filter(m => allowedMuseums.includes(m))
: availableMuseums;
const visibleChannels = allowedChannels.length > 0
? channels.filter(c => allowedChannels.includes(c))
: channels;
Layer 2 — Data filtered at base:
const permissionFilteredData = useMemo(() => {
// null = corrupted stored value → show nothing (fail-closed)
if (allowedMuseums === null || allowedChannels === null) return [];
let d = data;
if (allowedMuseums.length > 0)
d = d.filter(r => allowedMuseums.includes(r.museum_name));
if (allowedChannels.length > 0)
d = d.filter(r => allowedChannels.includes(r.channel));
return d;
}, [data, allowedMuseums, allowedChannels]);
Replace data with permissionFilteredData as the base for all subsequent filtering and chart rendering.
7. src/components/Comparison.tsx
Apply the same Layer 2 base filter to Comparison (same props: allowedMuseums, allowedChannels). Restricted users must not see unfiltered data on the comparison page.
Auth Flow
User enters PIN
→ POST /auth/login → server matches user, stores allowedMuseums/allowedChannels in session
→ App stores them in state, passes to Dashboard + Comparison
Page reload
→ GET /auth/check → server returns allowedMuseums/allowedChannels from session
→ App restores state correctly
Edge Cases
| Scenario | Behavior |
|---|---|
| User has 1 allowed museum | Filter dropdown shows with 1 option only |
| User has all museums allowed (empty array) | No change from today |
| Admin user | allowedMuseums: [], allowedChannels: [] — full access |
| URL param references disallowed museum | Base filter removes it silently |
| New museum added to data, not in user's list | Not visible to restricted user |
| JSON parse error on stored value | null returned → no data shown (fail-closed) |
| Page reload | Session restores access lists from server |
Prerequisites (must be built first)
nocodbClient.updateRecord()methodPUT /api/users/:idserver routeupdateUser()inusersService.ts
Out of Scope
- District-level access control
- Role-based permission templates
- Audit logging of access
- Server-side data API enforcement