feat: restrict admin areas and show mobile pickups

Limit monitoring and journal access to admins, reduce regular-user navigation,
and show the latest pickup directly in mobile configuration cards.
This commit is contained in:
2026-08-05 20:10:22 +02:00
parent 713f52caa0
commit a64bfe2d3f
10 changed files with 103 additions and 47 deletions

View File

@@ -1,4 +1,4 @@
feat: refresh stale last pickup information feat: restrict admin areas and show mobile pickups
Track the successful member-list check time and refresh stale pickup information Limit monitoring and journal access to admins, reduce regular-user navigation,
asynchronously after the GUI loads without triggering dormant warnings. and show the latest pickup directly in mobile configuration cards.

View File

@@ -1020,7 +1020,7 @@ app.get('/api/location/nearest-store', requireAuth, async (req, res) => {
} }
}); });
app.get('/api/store-watch/regions', requireAuth, async (req, res) => { app.get('/api/store-watch/regions', requireAuth, requireAdmin, async (req, res) => {
try { try {
const details = await fetchProfileWithCache(req.session); const details = await fetchProfileWithCache(req.session);
const regions = Array.isArray(details?.regions) const regions = Array.isArray(details?.regions)
@@ -1033,7 +1033,7 @@ app.get('/api/store-watch/regions', requireAuth, async (req, res) => {
} }
}); });
app.get('/api/store-watch/regions/:regionId/stores', requireAuth, async (req, res) => { app.get('/api/store-watch/regions/:regionId/stores', requireAuth, requireAdmin, async (req, res) => {
const { regionId } = req.params; const { regionId } = req.params;
if (!regionId) { if (!regionId) {
return res.status(400).json({ error: 'Region-ID fehlt' }); return res.status(400).json({ error: 'Region-ID fehlt' });
@@ -1086,12 +1086,12 @@ app.get('/api/store-watch/regions/:regionId/stores', requireAuth, async (req, re
}); });
}); });
app.get('/api/store-watch/subscriptions', requireAuth, (req, res) => { app.get('/api/store-watch/subscriptions', requireAuth, requireAdmin, (req, res) => {
const stores = readStoreWatch(req.session.profile.id); const stores = readStoreWatch(req.session.profile.id);
res.json({ stores }); res.json({ stores });
}); });
app.post('/api/store-watch/subscriptions', requireAuth, (req, res) => { app.post('/api/store-watch/subscriptions', requireAuth, requireAdmin, (req, res) => {
if (!req.body || !Array.isArray(req.body.stores)) { if (!req.body || !Array.isArray(req.body.stores)) {
return res.status(400).json({ error: 'Erwartet eine Liste von Betrieben' }); return res.status(400).json({ error: 'Erwartet eine Liste von Betrieben' });
} }
@@ -1121,7 +1121,7 @@ app.post('/api/store-watch/subscriptions', requireAuth, (req, res) => {
res.json({ success: true, stores: persisted }); res.json({ success: true, stores: persisted });
}); });
app.post('/api/store-watch/check', requireAuth, async (req, res) => { app.post('/api/store-watch/check', requireAuth, requireAdmin, async (req, res) => {
try { try {
maintenanceMode.ensureInactive('Ad-hoc-Store-Watch ist während des Wartungsmodus deaktiviert.'); maintenanceMode.ensureInactive('Ad-hoc-Store-Watch ist während des Wartungsmodus deaktiviert.');
const settings = adminConfig.readSettings(); const settings = adminConfig.readSettings();
@@ -1191,7 +1191,7 @@ app.post('/api/user/preferences/location', requireAuth, (req, res) => {
res.json({ location: updated.location }); res.json({ location: updated.location });
}); });
app.get('/api/journal', requireAuth, (req, res) => { app.get('/api/journal', requireAuth, requireAdmin, (req, res) => {
const entries = readJournal(req.session.profile.id); const entries = readJournal(req.session.profile.id);
const normalized = entries.map((entry) => ({ const normalized = entries.map((entry) => ({
...entry, ...entry,
@@ -1205,7 +1205,7 @@ app.get('/api/journal', requireAuth, (req, res) => {
res.json(normalized); res.json(normalized);
}); });
app.post('/api/journal', requireAuth, (req, res) => { app.post('/api/journal', requireAuth, requireAdmin, (req, res) => {
const profileId = req.session.profile.id; const profileId = req.session.profile.id;
const { storeId, storeName, pickupDate, note, reminder, images } = req.body || {}; const { storeId, storeName, pickupDate, note, reminder, images } = req.body || {};
if (!storeId || !pickupDate) { if (!storeId || !pickupDate) {
@@ -1276,7 +1276,7 @@ app.post('/api/journal', requireAuth, (req, res) => {
}); });
}); });
app.put('/api/journal/:id', requireAuth, (req, res) => { app.put('/api/journal/:id', requireAuth, requireAdmin, (req, res) => {
const profileId = req.session.profile.id; const profileId = req.session.profile.id;
const { storeId, storeName, pickupDate, note, reminder, images, keepImageIds } = req.body || {}; const { storeId, storeName, pickupDate, note, reminder, images, keepImageIds } = req.body || {};
if (!storeId || !pickupDate) { if (!storeId || !pickupDate) {
@@ -1358,7 +1358,7 @@ app.put('/api/journal/:id', requireAuth, (req, res) => {
}); });
}); });
app.delete('/api/journal/:id', requireAuth, (req, res) => { app.delete('/api/journal/:id', requireAuth, requireAdmin, (req, res) => {
const profileId = req.session.profile.id; const profileId = req.session.profile.id;
const entries = readJournal(profileId); const entries = readJournal(profileId);
const filtered = entries.filter((entry) => entry.id !== req.params.id); const filtered = entries.filter((entry) => entry.id !== req.params.id);
@@ -1373,7 +1373,7 @@ app.delete('/api/journal/:id', requireAuth, (req, res) => {
res.json({ success: true }); res.json({ success: true });
}); });
app.get('/api/journal/images/:imageId', requireAuth, (req, res) => { app.get('/api/journal/images/:imageId', requireAuth, requireAdmin, (req, res) => {
const profileId = req.session.profile.id; const profileId = req.session.profile.id;
const entries = readJournal(profileId); const entries = readJournal(profileId);
const imageEntry = entries const imageEntry = entries

View File

@@ -1037,7 +1037,7 @@ async function checkWatchedStores(sessionId, settings = DEFAULT_SETTINGS, option
return []; return [];
} }
const session = sessionStore.get(sessionId); const session = sessionStore.get(sessionId);
if (!session?.profile?.id) { if (!session?.profile?.id || !session.isAdmin) {
return []; return [];
} }
const watchers = readStoreWatch(session.profile.id); const watchers = readStoreWatch(session.profile.id);
@@ -1132,7 +1132,7 @@ async function checkWatchedStores(sessionId, settings = DEFAULT_SETTINGS, option
function scheduleStoreWatchers(sessionId, settings) { function scheduleStoreWatchers(sessionId, settings) {
const effectiveSettings = settings || DEFAULT_SETTINGS; const effectiveSettings = settings || DEFAULT_SETTINGS;
const session = sessionStore.get(sessionId); const session = sessionStore.get(sessionId);
if (!session?.profile?.id) { if (!session?.profile?.id || !session.isAdmin) {
return false; return false;
} }
const watchers = readStoreWatch(session.profile.id); const watchers = readStoreWatch(session.profile.id);

View File

@@ -910,6 +910,7 @@ function App() {
<Route <Route
path="/store-watch" path="/store-watch"
element={ element={
session?.isAdmin ? (
<StoreWatchPage <StoreWatchPage
authorizedFetch={authorizedFetch} authorizedFetch={authorizedFetch}
knownStores={stores} knownStores={stores}
@@ -919,13 +920,14 @@ function App() {
notificationPanelOpen={notificationPanelOpen} notificationPanelOpen={notificationPanelOpen}
onToggleNotificationPanel={() => setNotificationPanelOpen((prev) => !prev)} onToggleNotificationPanel={() => setNotificationPanelOpen((prev) => !prev)}
notificationProps={sharedNotificationProps} notificationProps={sharedNotificationProps}
isAdmin={Boolean(session?.isAdmin)} isAdmin
/> />
) : <Navigate to="/" replace />
} }
/> />
<Route <Route
path="/journal" path="/journal"
element={<JournalPage authorizedFetch={authorizedFetch} stores={stores} />} element={session?.isAdmin ? <JournalPage authorizedFetch={authorizedFetch} stores={stores} /> : <Navigate to="/" replace />}
/> />
<Route <Route
path="/debug" path="/debug"

View File

@@ -121,7 +121,8 @@
} }
.mobile-config-card__id, .mobile-config-card__id,
.mobile-config-card__slots { .mobile-config-card__slots,
.mobile-config-card__last-pickup {
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 400; font-weight: 400;
} }
@@ -130,6 +131,16 @@
margin-top: 0.45rem !important; margin-top: 0.45rem !important;
} }
.mobile-config-card__last-pickup {
margin-top: 0.35rem !important;
color: #475569 !important;
}
.mobile-config-card__last-pickup strong {
color: #334155;
font-weight: 600;
}
.mobile-config-card__store-link { .mobile-config-card__store-link {
flex: 0 0 auto; flex: 0 0 auto;
width: 2.5rem; width: 2.5rem;

View File

@@ -1,4 +1,6 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { formatDistanceToNowStrict } from 'date-fns';
import { de } from 'date-fns/locale';
import './MobileConfigList.css'; import './MobileConfigList.css';
const FILTER_OPTIONS = [ const FILTER_OPTIONS = [
@@ -7,6 +9,17 @@ const FILTER_OPTIONS = [
{ value: 'inactive', label: 'Inaktiv' } { value: 'inactive', label: 'Inaktiv' }
]; ];
const formatLastPickup = (value) => {
const date = new Date(value);
if (!value || Number.isNaN(date.getTime())) {
return { relative: 'Unbekannt', exact: null };
}
return {
relative: formatDistanceToNowStrict(date, { addSuffix: true, locale: de }),
exact: date.toLocaleDateString('de-DE')
};
};
const MobileConfigList = ({ const MobileConfigList = ({
entries, entries,
regularPickupMap, regularPickupMap,
@@ -84,6 +97,7 @@ const MobileConfigList = ({
{filteredEntries.map((entry) => { {filteredEntries.map((entry) => {
const range = entry.desiredDateRange || (entry.desiredDate ? { start: entry.desiredDate, end: entry.desiredDate } : null); const range = entry.desiredDateRange || (entry.desiredDate ? { start: entry.desiredDate, end: entry.desiredDate } : null);
const slots = formatRegularPickup(regularPickupMap?.[entry.id]); const slots = formatRegularPickup(regularPickupMap?.[entry.id]);
const lastPickup = formatLastPickup(entry.lastPickupAt);
return ( return (
<article <article
key={entry.id} key={entry.id}
@@ -95,6 +109,9 @@ const MobileConfigList = ({
<h3 className="mobile-config-card__title">{entry.label || `Store ${entry.id}`}</h3> <h3 className="mobile-config-card__title">{entry.label || `Store ${entry.id}`}</h3>
<p className="mobile-config-card__id">#{entry.id}</p> <p className="mobile-config-card__id">#{entry.id}</p>
{slots && <p className="mobile-config-card__slots">Slots: {slots}</p>} {slots && <p className="mobile-config-card__slots">Slots: {slots}</p>}
<p className="mobile-config-card__last-pickup" title={lastPickup.exact || undefined}>
Letzte Abholung: <strong>{lastPickup.relative}</strong>
</p>
</div> </div>
<a <a
href={`https://foodsharing.de/store/${entry.id}`} href={`https://foodsharing.de/store/${entry.id}`}

View File

@@ -8,7 +8,8 @@ const entry = {
autoDeactivate: true, autoDeactivate: true,
checkProfileId: true, checkProfileId: true,
onlyNotify: false, onlyNotify: false,
skipDormantCheck: false skipDormantCheck: false,
lastPickupAt: '2026-08-01T10:00:00.000Z'
}; };
const props = { const props = {
@@ -48,6 +49,12 @@ describe('MobileConfigList', () => {
expect(screen.queryByText('Donat')).not.toBeInTheDocument(); expect(screen.queryByText('Donat')).not.toBeInTheDocument();
}); });
it('shows the last pickup directly in the card header', () => {
render(<MobileConfigList {...props} />);
expect(screen.getAllByText(/Letzte Abholung:/)[0]).toBeInTheDocument();
});
it('uses the existing configuration callbacks', () => { it('uses the existing configuration callbacks', () => {
render(<MobileConfigList {...props} />); render(<MobileConfigList {...props} />);

View File

@@ -2,6 +2,10 @@
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.app-navigation--compact {
margin-bottom: 0;
}
.app-navigation__mobile-header, .app-navigation__mobile-header,
.app-navigation__mobile-menu { .app-navigation__mobile-menu {
display: none; display: none;
@@ -37,6 +41,10 @@
margin: 0 -0.25rem 0.75rem; margin: 0 -0.25rem 0.75rem;
} }
.app-navigation--compact {
margin-bottom: 0.35rem;
}
.app-navigation__desktop { .app-navigation__desktop {
display: none; display: none;
} }

View File

@@ -17,17 +17,16 @@ const NavigationTabs = ({
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
const tabs = useMemo(() => { const tabs = useMemo(() => {
const items = [ const items = [{ to: '/', label: 'Slots buchen' }];
{ to: '/', label: 'Slots buchen' },
{ to: '/store-watch', label: 'Betriebs-Monitoring' },
{ to: '/journal', label: 'Abhol-Journal' }
];
if (isAdmin) { if (isAdmin) {
items.push({ to: '/store-watch', label: 'Betriebs-Monitoring' });
items.push({ to: '/journal', label: 'Abhol-Journal' });
items.push({ to: '/debug', label: 'Debug' }); items.push({ to: '/debug', label: 'Debug' });
items.push({ to: '/admin', label: 'Admin' }); items.push({ to: '/admin', label: 'Admin' });
} }
return items; return items;
}, [isAdmin]); }, [isAdmin]);
const hasNavigation = tabs.length > 1;
const handleClick = (event, to) => { const handleClick = (event, to) => {
event.preventDefault(); event.preventDefault();
@@ -68,13 +67,13 @@ const NavigationTabs = ({
}; };
return ( return (
<nav className="app-navigation" aria-label="Navigation"> <nav className={`app-navigation ${hasNavigation ? '' : 'app-navigation--compact'}`} aria-label="Navigation">
<div className="app-navigation__mobile-header"> <div className="app-navigation__mobile-header">
<span className="app-navigation__product-name">Foodsharing Manager</span> <span className="app-navigation__product-name">Foodsharing Manager</span>
<button <button
type="button" type="button"
className="app-navigation__menu-toggle" className="app-navigation__menu-toggle"
aria-label={mobileOpen ? 'Navigation schließen' : 'Navigation öffnen'} aria-label={mobileOpen ? 'Menü schließen' : 'Menü öffnen'}
aria-expanded={mobileOpen} aria-expanded={mobileOpen}
aria-controls="mobile-navigation" aria-controls="mobile-navigation"
onClick={() => setMobileOpen((open) => !open)} onClick={() => setMobileOpen((open) => !open)}
@@ -98,9 +97,11 @@ const NavigationTabs = ({
{mobileOpen && ( {mobileOpen && (
<div id="mobile-navigation" className="app-navigation__mobile-menu"> <div id="mobile-navigation" className="app-navigation__mobile-menu">
{hasNavigation && (
<div className="app-navigation__mobile-links"> <div className="app-navigation__mobile-links">
{tabs.map((tab) => renderLink(tab, 'app-navigation__mobile-link'))} {tabs.map((tab) => renderLink(tab, 'app-navigation__mobile-link'))}
</div> </div>
)}
{location.pathname === '/' && (onRefresh || onToggleNotifications) && ( {location.pathname === '/' && (onRefresh || onToggleNotifications) && (
<div className="app-navigation__tools"> <div className="app-navigation__tools">
<span>Werkzeuge</span> <span>Werkzeuge</span>
@@ -139,9 +140,11 @@ const NavigationTabs = ({
</div> </div>
)} )}
{hasNavigation && (
<div className="app-navigation__desktop"> <div className="app-navigation__desktop">
{tabs.map((tab) => renderLink(tab, 'app-navigation__desktop-link'))} {tabs.map((tab) => renderLink(tab, 'app-navigation__desktop-link'))}
</div> </div>
)}
</nav> </nav>
); );
}; };

View File

@@ -6,7 +6,7 @@ describe('NavigationTabs', () => {
const onLogout = jest.fn(); const onLogout = jest.fn();
render(<NavigationTabs profileName="Meik" onLogout={onLogout} />); render(<NavigationTabs profileName="Meik" onLogout={onLogout} />);
fireEvent.click(screen.getByRole('button', { name: 'Navigation öffnen' })); fireEvent.click(screen.getByRole('button', { name: 'Menü öffnen' }));
expect(screen.getByText('Meik')).toBeInTheDocument(); expect(screen.getByText('Meik')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Logout' })); fireEvent.click(screen.getByRole('button', { name: 'Logout' }));
@@ -15,9 +15,9 @@ describe('NavigationTabs', () => {
it('uses the existing protected navigation callback', () => { it('uses the existing protected navigation callback', () => {
const onProtectedNavigate = jest.fn(); const onProtectedNavigate = jest.fn();
render(<NavigationTabs onProtectedNavigate={onProtectedNavigate} />); render(<NavigationTabs isAdmin onProtectedNavigate={onProtectedNavigate} />);
fireEvent.click(screen.getByRole('button', { name: 'Navigation öffnen' })); fireEvent.click(screen.getByRole('button', { name: 'Menü öffnen' }));
fireEvent.click(screen.getAllByText('Betriebs-Monitoring')[0]); fireEvent.click(screen.getAllByText('Betriebs-Monitoring')[0]);
expect(onProtectedNavigate).toHaveBeenCalledWith( expect(onProtectedNavigate).toHaveBeenCalledWith(
@@ -31,14 +31,22 @@ describe('NavigationTabs', () => {
const onToggleNotifications = jest.fn(); const onToggleNotifications = jest.fn();
render(<NavigationTabs onRefresh={onRefresh} onToggleNotifications={onToggleNotifications} />); render(<NavigationTabs onRefresh={onRefresh} onToggleNotifications={onToggleNotifications} />);
fireEvent.click(screen.getByRole('button', { name: 'Navigation öffnen' })); fireEvent.click(screen.getByRole('button', { name: 'Menü öffnen' }));
fireEvent.click(screen.getByRole('button', { name: 'Betriebe aktualisieren' })); fireEvent.click(screen.getByRole('button', { name: 'Betriebe aktualisieren' }));
expect(onRefresh).toHaveBeenCalledTimes(1); expect(onRefresh).toHaveBeenCalledTimes(1);
expect(screen.queryByText('Werkzeuge')).not.toBeInTheDocument(); expect(screen.queryByText('Werkzeuge')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Navigation öffnen' })); fireEvent.click(screen.getByRole('button', { name: 'Menü öffnen' }));
fireEvent.click(screen.getByRole('button', { name: 'Benachrichtigungen' })); fireEvent.click(screen.getByRole('button', { name: 'Benachrichtigungen' }));
expect(onToggleNotifications).toHaveBeenCalledTimes(1); expect(onToggleNotifications).toHaveBeenCalledTimes(1);
}); });
it('hides admin-only sections and the desktop navigation for regular users', () => {
render(<NavigationTabs />);
expect(screen.queryByText('Betriebs-Monitoring')).not.toBeInTheDocument();
expect(screen.queryByText('Abhol-Journal')).not.toBeInTheDocument();
expect(screen.queryByText('Slots buchen')).not.toBeInTheDocument();
});
}); });