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
+23 -14
View File
@@ -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<number>();
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]);
+18 -8
View File
@@ -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(() => {
+2 -2
View File
@@ -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);
+121 -16
View File
@@ -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<User>) => Promise<void>;
onDelete: (id: number) => Promise<void>;
}
function Settings({ onSeasonsChange }: SettingsProps) {
function UserRow({ user, allMuseums, allChannels, onUpdate, onDelete }: UserRowProps) {
const [editing, setEditing] = useState(false);
const [allowedMuseums, setAllowedMuseums] = useState<string[]>(() => {
try { return JSON.parse(user.AllowedMuseums || '[]'); } catch { return []; }
});
const [allowedChannels, setAllowedChannels] = useState<string[]>(() => {
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 (
<tr key={user.Id}>
<td>{user.Name}</td>
<td><code>{user.PIN}</code></td>
<td>{user.Role}</td>
<td>
{isAdmin ? (
<span className="access-badge access-badge--full">Full access</span>
) : (
<>
<span className="access-badge">{museumCount === 0 ? 'All events' : `${museumCount} events`}</span>
<span className="access-badge">{channelCount === 0 ? 'All channels' : `${channelCount} channels`}</span>
</>
)}
</td>
<td>
<div className="season-actions">
{!isAdmin && <button className="btn-small" onClick={() => setEditing(true)}>Edit access</button>}
<button className="btn-small btn-danger" onClick={() => onDelete(user.Id!)}>Delete</button>
</div>
</td>
</tr>
);
}
return (
<tr className="editing">
<td colSpan={5}>
<div style={{ padding: '12px 4px' }}>
<strong>{user.Name}</strong>
<div style={{ display: 'flex', gap: 32, marginTop: 12, flexWrap: 'wrap' }}>
<div>
<div style={{ fontWeight: 600, marginBottom: 8 }}>
Allowed Events {allowedMuseums.length === 0 && <span className="access-badge access-badge--full">All</span>}
</div>
{allMuseums.map(m => (
<label key={m} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6, cursor: 'pointer' }}>
<input type="checkbox" checked={allowedMuseums.includes(m)} onChange={() => toggleItem(allowedMuseums, setAllowedMuseums, m)} />
{m}
</label>
))}
</div>
<div>
<div style={{ fontWeight: 600, marginBottom: 8 }}>
Allowed Channels {allowedChannels.length === 0 && <span className="access-badge access-badge--full">All</span>}
</div>
{allChannels.map(c => (
<label key={c} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6, cursor: 'pointer' }}>
<input type="checkbox" checked={allowedChannels.includes(c)} onChange={() => toggleItem(allowedChannels, setAllowedChannels, c)} />
{c}
</label>
))}
</div>
</div>
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
<button className="btn-small btn-primary" onClick={handleSave}>Save</button>
<button className="btn-small" onClick={() => setEditing(false)}>Cancel</button>
</div>
</div>
</td>
</tr>
);
}
interface SettingsProps {
onSeasonsChange: () => void;
allMuseums: string[];
allChannels: string[];
}
function Settings({ onSeasonsChange, allMuseums, allChannels }: SettingsProps) {
const { t } = useLanguage();
const [seasons, setSeasons] = useState<Season[]>([]);
const [loading, setLoading] = useState(true);
@@ -80,7 +180,7 @@ function Settings({ onSeasonsChange }: SettingsProps) {
});
const [users, setUsers] = useState<User[]>([]);
const [newUser, setNewUser] = useState<Omit<User, 'Id'>>({ Name: '', PIN: '', Role: 'viewer' });
const [newUser, setNewUser] = useState<Omit<User, 'Id'>>({ 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<User>) => {
await updateUser(id, fields);
await loadUsers();
};
useEffect(() => { loadSeasons(); loadUsers(); }, []);
const handleCreate = async () => {
@@ -183,21 +288,20 @@ function Settings({ onSeasonsChange }: SettingsProps) {
<th>{t('settings.userName')}</th>
<th>{t('settings.userPin')}</th>
<th>{t('settings.userRole')}</th>
<th>Access</th>
<th>{t('settings.actions')}</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.Id}>
<td>{u.Name}</td>
<td><code>{u.PIN}</code></td>
<td>{u.Role}</td>
<td>
<button className="btn-small btn-danger" onClick={async () => { await deleteUser(u.Id!); await loadUsers(); }}>
{t('settings.delete') || 'Delete'}
</button>
</td>
</tr>
<UserRow
key={u.Id}
user={u}
allMuseums={allMuseums}
allChannels={allChannels}
onUpdate={handleUpdateUser}
onDelete={async (id) => { await deleteUser(id); await loadUsers(); }}
/>
))}
<tr className="add-row">
<td>
@@ -212,11 +316,12 @@ function Settings({ onSeasonsChange }: SettingsProps) {
<option value="admin">Admin</option>
</select>
</td>
<td></td>
<td>
<button className="btn-small btn-primary" onClick={async () => {
if (!newUser.Name || !newUser.PIN) return;
await createUser(newUser);
setNewUser({ Name: '', PIN: '', Role: 'viewer' });
setNewUser({ Name: '', PIN: '', Role: 'viewer', AllowedMuseums: '[]', AllowedChannels: '[]' });
await loadUsers();
}} disabled={!newUser.Name || !newUser.PIN}>
{t('settings.add')}