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
asynchronously after the GUI loads without triggering dormant warnings.
Limit monitoring and journal access to admins, reduce regular-user navigation,
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 {
const details = await fetchProfileWithCache(req.session);
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;
if (!regionId) {
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);
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)) {
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 });
});
app.post('/api/store-watch/check', requireAuth, async (req, res) => {
app.post('/api/store-watch/check', requireAuth, requireAdmin, async (req, res) => {
try {
maintenanceMode.ensureInactive('Ad-hoc-Store-Watch ist während des Wartungsmodus deaktiviert.');
const settings = adminConfig.readSettings();
@@ -1191,7 +1191,7 @@ app.post('/api/user/preferences/location', requireAuth, (req, res) => {
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 normalized = entries.map((entry) => ({
...entry,
@@ -1205,7 +1205,7 @@ app.get('/api/journal', requireAuth, (req, res) => {
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 { storeId, storeName, pickupDate, note, reminder, images } = req.body || {};
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 { storeId, storeName, pickupDate, note, reminder, images, keepImageIds } = req.body || {};
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 entries = readJournal(profileId);
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 });
});
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 entries = readJournal(profileId);
const imageEntry = entries

View File

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

View File

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

View File

@@ -121,7 +121,8 @@
}
.mobile-config-card__id,
.mobile-config-card__slots {
.mobile-config-card__slots,
.mobile-config-card__last-pickup {
font-size: 0.75rem;
font-weight: 400;
}
@@ -130,6 +131,16 @@
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 {
flex: 0 0 auto;
width: 2.5rem;

View File

@@ -1,4 +1,6 @@
import { useMemo, useState } from 'react';
import { formatDistanceToNowStrict } from 'date-fns';
import { de } from 'date-fns/locale';
import './MobileConfigList.css';
const FILTER_OPTIONS = [
@@ -7,6 +9,17 @@ const FILTER_OPTIONS = [
{ 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 = ({
entries,
regularPickupMap,
@@ -84,6 +97,7 @@ const MobileConfigList = ({
{filteredEntries.map((entry) => {
const range = entry.desiredDateRange || (entry.desiredDate ? { start: entry.desiredDate, end: entry.desiredDate } : null);
const slots = formatRegularPickup(regularPickupMap?.[entry.id]);
const lastPickup = formatLastPickup(entry.lastPickupAt);
return (
<article
key={entry.id}
@@ -95,6 +109,9 @@ const MobileConfigList = ({
<h3 className="mobile-config-card__title">{entry.label || `Store ${entry.id}`}</h3>
<p className="mobile-config-card__id">#{entry.id}</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>
<a
href={`https://foodsharing.de/store/${entry.id}`}

View File

@@ -8,7 +8,8 @@ const entry = {
autoDeactivate: true,
checkProfileId: true,
onlyNotify: false,
skipDormantCheck: false
skipDormantCheck: false,
lastPickupAt: '2026-08-01T10:00:00.000Z'
};
const props = {
@@ -48,6 +49,12 @@ describe('MobileConfigList', () => {
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', () => {
render(<MobileConfigList {...props} />);

View File

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

View File

@@ -17,17 +17,16 @@ const NavigationTabs = ({
const [mobileOpen, setMobileOpen] = useState(false);
const tabs = useMemo(() => {
const items = [
{ to: '/', label: 'Slots buchen' },
{ to: '/store-watch', label: 'Betriebs-Monitoring' },
{ to: '/journal', label: 'Abhol-Journal' }
];
const items = [{ to: '/', label: 'Slots buchen' }];
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: '/admin', label: 'Admin' });
}
return items;
}, [isAdmin]);
const hasNavigation = tabs.length > 1;
const handleClick = (event, to) => {
event.preventDefault();
@@ -68,13 +67,13 @@ const NavigationTabs = ({
};
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">
<span className="app-navigation__product-name">Foodsharing Manager</span>
<button
type="button"
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-controls="mobile-navigation"
onClick={() => setMobileOpen((open) => !open)}
@@ -98,9 +97,11 @@ const NavigationTabs = ({
{mobileOpen && (
<div id="mobile-navigation" className="app-navigation__mobile-menu">
{hasNavigation && (
<div className="app-navigation__mobile-links">
{tabs.map((tab) => renderLink(tab, 'app-navigation__mobile-link'))}
</div>
)}
{location.pathname === '/' && (onRefresh || onToggleNotifications) && (
<div className="app-navigation__tools">
<span>Werkzeuge</span>
@@ -139,9 +140,11 @@ const NavigationTabs = ({
</div>
)}
{hasNavigation && (
<div className="app-navigation__desktop">
{tabs.map((tab) => renderLink(tab, 'app-navigation__desktop-link'))}
</div>
)}
</nav>
);
};

View File

@@ -6,7 +6,7 @@ describe('NavigationTabs', () => {
const onLogout = jest.fn();
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();
fireEvent.click(screen.getByRole('button', { name: 'Logout' }));
@@ -15,9 +15,9 @@ describe('NavigationTabs', () => {
it('uses the existing protected navigation callback', () => {
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]);
expect(onProtectedNavigate).toHaveBeenCalledWith(
@@ -31,14 +31,22 @@ describe('NavigationTabs', () => {
const onToggleNotifications = jest.fn();
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' }));
expect(onRefresh).toHaveBeenCalledTimes(1);
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' }));
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();
});
});