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

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 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:

// 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:

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)

  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