feat: per-user museum and channel access control

- PATCH /api/users/:id route to update user permissions
- Auth session stores and returns allowedMuseums/allowedChannels
- User type gains AllowedMuseums/AllowedChannels (JSON string fields)
- parseAllowed() with fail-closed semantics (empty string → null → no data)
- Dashboard/Comparison apply permission base filter before user filters
- Filter dropdowns (museums, channels, years, districts) derived from
  permission-filtered data — restricted users only see their allowed options
- Settings UserRow component with inline checkbox pickers for access config
- Access badges in users table showing current restriction summary

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
fahed
2026-04-08 18:03:19 +03:00
parent d4ce5b6478
commit e41cff831b
10 changed files with 259 additions and 51 deletions
+15 -6
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback, ReactNode, lazy, Suspense } from 'react';
import React, { useState, useEffect, useCallback, useMemo, ReactNode, lazy, Suspense } from 'react';
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom';
const Dashboard = lazy(() => import('./components/Dashboard'));
@@ -6,8 +6,9 @@ const Comparison = lazy(() => import('./components/Comparison'));
const Settings = lazy(() => import('./components/Settings'));
import Login from './components/Login';
import LoadingSkeleton from './components/shared/LoadingSkeleton';
import { fetchData, getCacheStatus, refreshData } from './services/dataService';
import { fetchData, getCacheStatus, refreshData, getUniqueMuseums, getUniqueChannels } from './services/dataService';
import { fetchSeasons } from './services/seasonsService';
import { parseAllowed } from './services/usersService';
import { useLanguage } from './contexts/LanguageContext';
import type { MuseumRecord, Season, CacheStatus, DataErrorType } from './types';
import { DataError } from './types';
@@ -40,7 +41,11 @@ function App() {
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
const [userRole, setUserRole] = useState<string>('viewer');
const [userName, setUserName] = useState<string>('');
const [allowedMuseums, setAllowedMuseums] = useState<string[] | null>([]);
const [allowedChannels, setAllowedChannels] = useState<string[] | null>([]);
const [data, setData] = useState<MuseumRecord[]>([]);
const allMuseumsList = useMemo(() => getUniqueMuseums(data), [data]);
const allChannelsList = useMemo(() => getUniqueChannels(data), [data]);
const [loading, setLoading] = useState<boolean>(true);
const [refreshing, setRefreshing] = useState<boolean>(false);
const [error, setError] = useState<{ message: string; type: DataErrorType } | null>(null);
@@ -118,6 +123,8 @@ function App() {
if (d.authenticated) {
setUserRole(d.role || 'viewer');
setUserName(d.name || '');
setAllowedMuseums(parseAllowed(d.allowedMuseums));
setAllowedChannels(parseAllowed(d.allowedChannels));
loadData();
loadSeasons();
}
@@ -126,10 +133,12 @@ function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleLogin = (name: string, role: string) => {
const handleLogin = (name: string, role: string, rawMuseums: string, rawChannels: string) => {
setAuthenticated(true);
setUserName(name);
setUserRole(role);
setAllowedMuseums(parseAllowed(rawMuseums));
setAllowedChannels(parseAllowed(rawChannels));
loadData();
loadSeasons();
};
@@ -287,9 +296,9 @@ function App() {
<main>
<Suspense fallback={<LoadingSkeleton />}>
<Routes>
<Route path="/" element={<Dashboard data={data} seasons={seasons} userRole={userRole} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} />} />
<Route path="/comparison" element={<Comparison data={data} seasons={seasons} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} />} />
{userRole === 'admin' && <Route path="/settings" element={<Settings onSeasonsChange={loadSeasons} />} />}
<Route path="/" element={<Dashboard data={data} seasons={seasons} userRole={userRole} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
<Route path="/comparison" element={<Comparison data={data} seasons={seasons} showDataLabels={showDataLabels} setShowDataLabels={setShowDataLabels} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
{userRole === 'admin' && <Route path="/settings" element={<Settings onSeasonsChange={loadSeasons} allMuseums={allMuseumsList} allChannels={allChannelsList} />} />}
</Routes>
</Suspense>
</main>