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]);