diff --git a/docs/superpowers/specs/2026-04-08-museum-channel-access-control-design.md b/docs/superpowers/specs/2026-04-08-museum-channel-access-control-design.md new file mode 100644 index 0000000..b6dd38b --- /dev/null +++ b/docs/superpowers/specs/2026-04-08-museum-channel-access-control-design.md @@ -0,0 +1,188 @@ +# 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[]; // [] = unrestricted + allowedChannels: string[]; // [] = unrestricted +} +``` + +**Convention:** empty array = full access. 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[]` (with safe fallback to `[]` on parse error) +- 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(() => { + 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 | Falls back to `[]` (unrestricted, fail-open) | +| 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