191 lines
6.5 KiB
Markdown
191 lines
6.5 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[] | 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
|