From e41cff831bf5931819f185c280889c12c70b5e23 Mon Sep 17 00:00:00 2001 From: fahed Date: Wed, 8 Apr 2026 18:03:19 +0300 Subject: [PATCH] feat: per-user museum and channel access control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- server/src/routes/auth.ts | 26 ++++++- server/src/routes/users.ts | 13 +++- src/App.css | 15 ++++ src/App.tsx | 21 ++++-- src/components/Comparison.tsx | 37 +++++---- src/components/Dashboard.tsx | 26 +++++-- src/components/Login.tsx | 4 +- src/components/Settings.tsx | 137 ++++++++++++++++++++++++++++++---- src/services/usersService.ts | 27 +++++++ src/types/index.ts | 4 + 10 files changed, 259 insertions(+), 51 deletions(-) diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 553c4b9..74da7e5 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -10,12 +10,16 @@ interface UserRecord { Name: string; PIN: string; Role: string; + AllowedMuseums?: string; + AllowedChannels?: string; } interface Session { name: string; role: string; createdAt: number; + allowedMuseums: string; + allowedChannels: string; } const sessions = new Map(); @@ -46,9 +50,9 @@ router.post('/login', async (req: Request, res: Response) => { // Check super admin PIN from env first if (auth.adminPin && pin === auth.adminPin) { const sessionId = generateSessionId(); - sessions.set(sessionId, { name: 'Admin', role: 'admin', createdAt: Date.now() }); + sessions.set(sessionId, { name: 'Admin', role: 'admin', createdAt: Date.now(), allowedMuseums: '[]', allowedChannels: '[]' }); res.cookie('hihala_session', sessionId, { httpOnly: true, sameSite: 'lax', maxAge: SESSION_MAX_AGE, path: '/' }); - res.json({ ok: true, name: 'Admin', role: 'admin' }); + res.json({ ok: true, name: 'Admin', role: 'admin', allowedMuseums: '[]', allowedChannels: '[]' }); return; } @@ -60,9 +64,21 @@ router.post('/login', async (req: Request, res: Response) => { const user = users.find(u => u.PIN === pin); if (user) { const sessionId = generateSessionId(); - sessions.set(sessionId, { name: user.Name, role: user.Role || 'viewer', createdAt: Date.now() }); + sessions.set(sessionId, { + name: user.Name, + role: user.Role || 'viewer', + createdAt: Date.now(), + allowedMuseums: user.AllowedMuseums || '[]', + allowedChannels: user.AllowedChannels || '[]', + }); res.cookie('hihala_session', sessionId, { httpOnly: true, sameSite: 'lax', maxAge: SESSION_MAX_AGE, path: '/' }); - res.json({ ok: true, name: user.Name, role: user.Role || 'viewer' }); + res.json({ + ok: true, + name: user.Name, + role: user.Role || 'viewer', + allowedMuseums: user.AllowedMuseums || '[]', + allowedChannels: user.AllowedChannels || '[]', + }); return; } } @@ -81,6 +97,8 @@ router.get('/check', (req: Request, res: Response) => { authenticated: !!session, name: session?.name || null, role: session?.role || null, + allowedMuseums: session?.allowedMuseums ?? '[]', + allowedChannels: session?.allowedChannels ?? '[]', }); }); diff --git a/server/src/routes/users.ts b/server/src/routes/users.ts index c4e670e..bbdf43b 100644 --- a/server/src/routes/users.ts +++ b/server/src/routes/users.ts @@ -1,5 +1,5 @@ import { Router, Request, Response } from 'express'; -import { discoverTableIds, fetchAllRecords, createRecord, deleteRecord } from '../services/nocodbClient'; +import { discoverTableIds, fetchAllRecords, createRecord, updateRecord, deleteRecord } from '../services/nocodbClient'; const router = Router(); @@ -32,6 +32,17 @@ router.post('/', async (req: Request, res: Response) => { } }); +// PATCH /api/users/:id +router.patch('/:id', async (req: Request, res: Response) => { + try { + const tableId = await getUsersTableId(); + await updateRecord(tableId, parseInt(req.params.id), req.body); + res.json({ ok: true }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + // DELETE /api/users/:id router.delete('/:id', async (req: Request, res: Response) => { try { diff --git a/src/App.css b/src/App.css index 93136cc..6defc85 100644 --- a/src/App.css +++ b/src/App.css @@ -1006,6 +1006,21 @@ table tbody tr:hover { gap: 6px; } +.access-badge { + display: inline-block; + font-size: 0.7rem; + padding: 2px 7px; + border-radius: 10px; + background: var(--surface-raised, #f0f0f0); + color: var(--text-secondary, #666); + margin-right: 4px; +} + +.access-badge--full { + background: #d1fae5; + color: #065f46; +} + .btn-small { padding: 4px 10px; font-size: 0.75rem; diff --git a/src/App.tsx b/src/App.tsx index 38c1a42..5777453 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(null); const [userRole, setUserRole] = useState('viewer'); const [userName, setUserName] = useState(''); + const [allowedMuseums, setAllowedMuseums] = useState([]); + const [allowedChannels, setAllowedChannels] = useState([]); const [data, setData] = useState([]); + const allMuseumsList = useMemo(() => getUniqueMuseums(data), [data]); + const allChannelsList = useMemo(() => getUniqueChannels(data), [data]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(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() {
}> - } /> - } /> - {userRole === 'admin' && } />} + } /> + } /> + {userRole === 'admin' && } />}
diff --git a/src/components/Comparison.tsx b/src/components/Comparison.tsx index 315bb46..7404c8d 100644 --- a/src/components/Comparison.tsx +++ b/src/components/Comparison.tsx @@ -63,15 +63,24 @@ const generatePresetDates = (year: number): PresetDates => ({ 'full': { start: `${year}-01-01`, end: `${year}-12-31` } }); -function Comparison({ data, seasons, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: ComparisonProps) { +function Comparison({ data, seasons, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT, allowedMuseums, allowedChannels }: ComparisonProps) { const { t } = useLanguage(); const [searchParams, setSearchParams] = useSearchParams(); - + + // Permission base filter — applied before any user-facing filter + const permissionFilteredData = useMemo(() => { + 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]); + // Get available years from data - const latestYear = useMemo(() => parseInt(getLatestYear(data)), [data]); + const latestYear = useMemo(() => parseInt(getLatestYear(permissionFilteredData)), [permissionFilteredData]); const availableYears = useMemo((): number[] => { const yearsSet = new Set(); - data.forEach((r: MuseumRecord) => { + permissionFilteredData.forEach((r: MuseumRecord) => { const d = r.date || (r as any).Date; if (d) yearsSet.add(new Date(d).getFullYear()); }); @@ -236,9 +245,9 @@ function Comparison({ data, seasons, showDataLabels, setShowDataLabels, includeV }, [revenueField]); // Dynamic lists from data - const channels = useMemo(() => getUniqueChannels(data), [data]); - const districts = useMemo(() => getUniqueDistricts(data), [data]); - const availableMuseums = useMemo(() => getMuseumsForDistrict(data, filters.district), [data, filters.district]); + const channels = useMemo(() => getUniqueChannels(permissionFilteredData), [permissionFilteredData]); + const districts = useMemo(() => getUniqueDistricts(permissionFilteredData), [permissionFilteredData]); + const availableMuseums = useMemo(() => getMuseumsForDistrict(permissionFilteredData, filters.district), [permissionFilteredData, filters.district]); // Year-over-year comparison: same dates, previous year // For season presets, try to find the same season name from the previous hijri year @@ -265,14 +274,14 @@ function Comparison({ data, seasons, showDataLabels, setShowDataLabels, includeV return { curr, prev }; }, [startDate, endDate, preset, seasons]); - const prevData = useMemo(() => - filterDataByDateRange(data, ranges.prev.start, ranges.prev.end, filters), - [data, ranges.prev, filters] + const prevData = useMemo(() => + filterDataByDateRange(permissionFilteredData, ranges.prev.start, ranges.prev.end, filters), + [permissionFilteredData, ranges.prev, filters] ); - - const currData = useMemo(() => - filterDataByDateRange(data, ranges.curr.start, ranges.curr.end, filters), - [data, ranges.curr, filters] + + const currData = useMemo(() => + filterDataByDateRange(permissionFilteredData, ranges.curr.start, ranges.curr.end, filters), + [permissionFilteredData, ranges.curr, filters] ); const prevMetrics = useMemo(() => calculateMetrics(prevData, includeVAT), [prevData, includeVAT]); diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index fe00381..0024cf5 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -34,7 +34,7 @@ const defaultFilters: Filters = { const filterKeys: (keyof Filters)[] = ['year', 'district', 'quarter']; -function Dashboard({ data, seasons, userRole, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT }: DashboardProps) { +function Dashboard({ data, seasons, userRole, showDataLabels, setShowDataLabels, includeVAT, setIncludeVAT, allowedMuseums, allowedChannels }: DashboardProps) { const { t } = useLanguage(); const [searchParams, setSearchParams] = useSearchParams(); const [pilgrimLoaded, setPilgrimLoaded] = useState(false); @@ -87,7 +87,17 @@ function Dashboard({ data, seasons, userRole, showDataLabels, setShowDataLabels, const [districtChartType, setDistrictChartType] = useState<'bar' | 'pie'>('pie'); const [districtDisplayMode, setDistrictDisplayMode] = useState<'absolute' | 'percent'>('absolute'); - const filteredData = useMemo(() => filterData(data, filters), [data, filters]); + // Permission base filter — applied before any user-facing filter + // null = corrupted value → fail-closed (show nothing) + const permissionFilteredData = useMemo(() => { + 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]); + + const filteredData = useMemo(() => filterData(permissionFilteredData, filters), [permissionFilteredData, filters]); const seasonFilteredData = useMemo(() => { if (!selectedSeason) return filteredData; @@ -118,19 +128,19 @@ function Dashboard({ data, seasons, userRole, showDataLabels, setShowDataLabels, }, [t]); // Dynamic lists from data - const years = useMemo(() => getUniqueYears(data), [data]); - const districts = useMemo(() => getUniqueDistricts(data), [data]); - const channels = useMemo(() => getUniqueChannels(data), [data]); - const availableMuseums = useMemo(() => getMuseumsForDistrict(data, filters.district), [data, filters.district]); + const years = useMemo(() => getUniqueYears(permissionFilteredData), [permissionFilteredData]); + const districts = useMemo(() => getUniqueDistricts(permissionFilteredData), [permissionFilteredData]); + const channels = useMemo(() => getUniqueChannels(permissionFilteredData), [permissionFilteredData]); + const availableMuseums = useMemo(() => getMuseumsForDistrict(permissionFilteredData, filters.district), [permissionFilteredData, filters.district]); const yoyChange = useMemo(() => { if (filters.year === 'all') return null; const prevYear = String(parseInt(filters.year) - 1); - const prevData = data.filter((row: MuseumRecord) => row.year === prevYear); + const prevData = permissionFilteredData.filter((row: MuseumRecord) => row.year === prevYear); if (prevData.length === 0) return null; const prevMetrics = calculateMetrics(prevData, includeVAT); return prevMetrics.revenue > 0 ? ((metrics.revenue - prevMetrics.revenue) / prevMetrics.revenue * 100) : null; - }, [data, filters.year, metrics.revenue, includeVAT]); + }, [permissionFilteredData, filters.year, metrics.revenue, includeVAT]); // Revenue trend data (weekly or daily) const trendData = useMemo(() => { diff --git a/src/components/Login.tsx b/src/components/Login.tsx index 8bf030d..24ec0ac 100644 --- a/src/components/Login.tsx +++ b/src/components/Login.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { useLanguage } from '../contexts/LanguageContext'; interface LoginProps { - onLogin: (name: string, role: string) => void; + onLogin: (name: string, role: string, allowedMuseums: string, allowedChannels: string) => void; } function Login({ onLogin }: LoginProps) { @@ -31,7 +31,7 @@ function Login({ onLogin }: LoginProps) { } const data = await res.json(); - onLogin(data.name || '', data.role || 'viewer'); + onLogin(data.name || '', data.role || 'viewer', data.allowedMuseums ?? '[]', data.allowedChannels ?? '[]'); } catch { setError(t('login.error')); setLoading(false); diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index bde5746..3221a19 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { useLanguage } from '../contexts/LanguageContext'; import { fetchSeasons, createSeason, updateSeason, deleteSeason } from '../services/seasonsService'; -import { fetchUsers, createUser, deleteUser, type User } from '../services/usersService'; +import { fetchUsers, createUser, updateUser, deleteUser, type User } from '../services/usersService'; import type { Season } from '../types'; const DEFAULT_COLORS = ['#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899']; @@ -62,11 +62,111 @@ function SeasonRow({ season, onSave, onDelete }: SeasonRowProps) { ); } -interface SettingsProps { - onSeasonsChange: () => void; +interface UserRowProps { + user: User; + allMuseums: string[]; + allChannels: string[]; + onUpdate: (id: number, fields: Partial) => Promise; + onDelete: (id: number) => Promise; } -function Settings({ onSeasonsChange }: SettingsProps) { +function UserRow({ user, allMuseums, allChannels, onUpdate, onDelete }: UserRowProps) { + const [editing, setEditing] = useState(false); + const [allowedMuseums, setAllowedMuseums] = useState(() => { + try { return JSON.parse(user.AllowedMuseums || '[]'); } catch { return []; } + }); + const [allowedChannels, setAllowedChannels] = useState(() => { + try { return JSON.parse(user.AllowedChannels || '[]'); } catch { return []; } + }); + + const toggleItem = (list: string[], setList: (v: string[]) => void, item: string) => + setList(list.includes(item) ? list.filter(x => x !== item) : [...list, item]); + + const handleSave = async () => { + await onUpdate(user.Id!, { + AllowedMuseums: JSON.stringify(allowedMuseums), + AllowedChannels: JSON.stringify(allowedChannels), + }); + setEditing(false); + }; + + const isAdmin = user.Role === 'admin'; + + const museumCount = (() => { try { const a = JSON.parse(user.AllowedMuseums || '[]'); return Array.isArray(a) ? a.length : 0; } catch { return 0; } })(); + const channelCount = (() => { try { const a = JSON.parse(user.AllowedChannels || '[]'); return Array.isArray(a) ? a.length : 0; } catch { return 0; } })(); + + if (!editing) { + return ( + + {user.Name} + {user.PIN} + {user.Role} + + {isAdmin ? ( + Full access + ) : ( + <> + {museumCount === 0 ? 'All events' : `${museumCount} events`} + {channelCount === 0 ? 'All channels' : `${channelCount} channels`} + + )} + + +
+ {!isAdmin && } + +
+ + + ); + } + + return ( + + +
+ {user.Name} +
+
+
+ Allowed Events {allowedMuseums.length === 0 && All} +
+ {allMuseums.map(m => ( + + ))} +
+
+
+ Allowed Channels {allowedChannels.length === 0 && All} +
+ {allChannels.map(c => ( + + ))} +
+
+
+ + +
+
+ + + ); +} + +interface SettingsProps { + onSeasonsChange: () => void; + allMuseums: string[]; + allChannels: string[]; +} + +function Settings({ onSeasonsChange, allMuseums, allChannels }: SettingsProps) { const { t } = useLanguage(); const [seasons, setSeasons] = useState([]); const [loading, setLoading] = useState(true); @@ -80,7 +180,7 @@ function Settings({ onSeasonsChange }: SettingsProps) { }); const [users, setUsers] = useState([]); - const [newUser, setNewUser] = useState>({ Name: '', PIN: '', Role: 'viewer' }); + const [newUser, setNewUser] = useState>({ Name: '', PIN: '', Role: 'viewer', AllowedMuseums: '[]', AllowedChannels: '[]' }); const loadSeasons = async () => { setLoading(true); @@ -94,6 +194,11 @@ function Settings({ onSeasonsChange }: SettingsProps) { setUsers(data); }; + const handleUpdateUser = async (id: number, fields: Partial) => { + await updateUser(id, fields); + await loadUsers(); + }; + useEffect(() => { loadSeasons(); loadUsers(); }, []); const handleCreate = async () => { @@ -183,21 +288,20 @@ function Settings({ onSeasonsChange }: SettingsProps) { {t('settings.userName')} {t('settings.userPin')} {t('settings.userRole')} + Access {t('settings.actions')} {users.map(u => ( - - {u.Name} - {u.PIN} - {u.Role} - - - - + { await deleteUser(id); await loadUsers(); }} + /> ))} @@ -212,11 +316,12 @@ function Settings({ onSeasonsChange }: SettingsProps) { +