# 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): ```typescript 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: ```typescript 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 parse `AllowedMuseums` and `AllowedChannels` JSON strings into `string[]`; on parse error return `null` (fail-closed — no data shown to the user) - Update `createUser()` to serialize `allowedMuseums`/`allowedChannels` arrays 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 NocoDB `PATCH /api/v2/tables/{tableId}/records` - `PUT /api/users/:id` route on the server — validates fields and calls `updateRecord` - `updateUser(id, fields)` in `usersService.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: ```typescript // 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 `allowedMuseums` and `allowedChannels` in app state (alongside `userRole`, `userName`) - Set them from both the `/auth/login` response and the `/auth/check` response - Pass them as props to **both** `Dashboard` and `Comparison` components - Also pass `allMuseums` and `allChannels` (unique values extracted from raw `data`) as props to `Settings` ### 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:** ```typescript 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:** ```typescript 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) 1. `nocodbClient.updateRecord()` method 2. `PUT /api/users/:id` server route 3. `updateUser()` in `usersService.ts` --- ## Out of Scope - District-level access control - Role-based permission templates - Audit logging of access - Server-side data API enforcement