Files
hihala-dashboard/docs/superpowers/specs/2026-04-08-museum-channel-access-control-design.md
2026-04-08 17:20:54 +03:00

189 lines
6.2 KiB
Markdown

# 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