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:
@@ -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<string, Session>();
|
||||
@@ -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 ?? '[]',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
15
src/App.css
15
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;
|
||||
|
||||
21
src/App.tsx
21
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<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>
|
||||
|
||||
@@ -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
|
||||
@@ -266,13 +275,13 @@ function Comparison({ data, seasons, showDataLabels, setShowDataLabels, includeV
|
||||
}, [startDate, endDate, preset, seasons]);
|
||||
|
||||
const prevData = useMemo(() =>
|
||||
filterDataByDateRange(data, ranges.prev.start, ranges.prev.end, filters),
|
||||
[data, ranges.prev, filters]
|
||||
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]
|
||||
filterDataByDateRange(permissionFilteredData, ranges.curr.start, ranges.curr.end, filters),
|
||||
[permissionFilteredData, ranges.curr, filters]
|
||||
);
|
||||
|
||||
const prevMetrics = useMemo(() => calculateMetrics(prevData, includeVAT), [prevData, includeVAT]);
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -3,6 +3,24 @@ export interface User {
|
||||
Name: string;
|
||||
PIN: string;
|
||||
Role: string;
|
||||
AllowedMuseums: string; // JSON-serialized string[], '[]' = unrestricted
|
||||
AllowedChannels: string; // JSON-serialized string[]
|
||||
}
|
||||
|
||||
// null = parse error → fail-closed (show nothing)
|
||||
// [] = unrestricted (admin or no restriction set)
|
||||
// string[] = restricted to this list
|
||||
export function parseAllowed(raw: string | undefined | null): string[] | null {
|
||||
if (raw == null) return []; // field not set → unrestricted
|
||||
if (raw === '[]') return []; // explicit empty → unrestricted
|
||||
if (raw === '') return null; // blank string → corrupted → fail-closed
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
return parsed as string[];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchUsers(): Promise<User[]> {
|
||||
@@ -25,6 +43,15 @@ export async function createUser(user: Omit<User, 'Id'>): Promise<User> {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateUser(id: number, fields: Partial<User>): Promise<void> {
|
||||
const res = await fetch(`/api/users/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(fields),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to update user');
|
||||
}
|
||||
|
||||
export async function deleteUser(id: number): Promise<void> {
|
||||
const res = await fetch(`/api/users/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error('Failed to delete user');
|
||||
|
||||
@@ -136,6 +136,8 @@ export interface DashboardProps {
|
||||
setShowDataLabels: (value: boolean) => void;
|
||||
includeVAT: boolean;
|
||||
setIncludeVAT: (value: boolean) => void;
|
||||
allowedMuseums: string[] | null;
|
||||
allowedChannels: string[] | null;
|
||||
}
|
||||
|
||||
export interface ComparisonProps {
|
||||
@@ -145,6 +147,8 @@ export interface ComparisonProps {
|
||||
setShowDataLabels: (value: boolean) => void;
|
||||
includeVAT: boolean;
|
||||
setIncludeVAT: (value: boolean) => void;
|
||||
allowedMuseums: string[] | null;
|
||||
allowedChannels: string[] | null;
|
||||
}
|
||||
|
||||
export interface SlidesProps {
|
||||
|
||||
Reference in New Issue
Block a user