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:
@@ -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.
|
||||
|
||||
20
server.js
20
server.js
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user