Compare commits

..

No commits in common. "2950f094850eb3cec30a8ce49a9ec0e224197f48" and "dd39a7aff213d586a1ff75e25d47ce5726ad3be6" have entirely different histories.

66 changed files with 205 additions and 610 deletions

4
.gitignore vendored
View File

@ -17,7 +17,3 @@ portal/ssl/
# OS
.DS_Store
Thumbs.db
# Versehentlich angelegte Log-Verzeichnisse
logs/
*.log

1
backend/.gitignore vendored Normal file
View File

@ -0,0 +1 @@

View File

0
backend/logs/error.log Normal file
View File

View File

@ -6,17 +6,13 @@ NODE_ENV=development
MONGO_URI=mongodb://127.0.0.1:27017/drohnenfuehrer
# JWT Configuration
# Der Name MUSS zu docker-compose.yml passen. Je App ein EIGENES Secret:
# openssl rand -hex 32
DROHNENFUEHRER_JWT_SECRET=
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
JWT_EXPIRES_IN=24h
# Admin Initial Password (used by seed.js if admin doesn't exist)
# ADMIN_INITIAL_PASSWORD=secure-password-here
# CORS Configuration (comma-separated for multiple origins)
# Produktiv die echte oeffentliche Herkunft eintragen, nicht localhost —
# APP_URL wird daraus abgeleitet, wenn es nicht gesetzt ist.
CORS_ORIGIN=http://localhost:3000
# Geocoding Configuration (OpenStreetMap Nominatim)
@ -27,9 +23,7 @@ GEOCODE_MIN_DELAY_MS=1100
# Basis-URL der App fuer Links in E-Mails (Passwort-Reset).
# MUSS den Unterpfad enthalten, unter dem die App ausgeliefert wird.
# Ohne diesen Wert wird er aus CORS_ORIGIN + "/drohnenfuehrer" zusammengesetzt.
# Produktiv die echte oeffentliche URL inkl. Unterpfad eintragen.
# Leer lassen -> wird aus CORS_ORIGIN + "/drohnenfuehrer" gebildet (mit Warnung).
APP_URL=
APP_URL=http://localhost:8081/drohnenfuehrer
# SMTP fuer Passwort-Reset-Mails (optional).
# Fehlt die Konfiguration, wird der Reset-Link nur ins Log geschrieben.

View File

@ -25,7 +25,7 @@ const drohnenfuehrerLogin = async (req, res) => {
}
const token = jwt.sign(
{ id: user._id.toString(), role: 'drohnenfuehrer', app: config.appName },
{ id: user._id.toString(), role: 'drohnenfuehrer' },
config.jwtSecret,
{ expiresIn: '12h' }
);

View File

@ -1,5 +1,5 @@
const User = require('../models/User');
const { geocodeAddress, searchAddresses } = require('../utils/geocode');
const { geocodeAddress } = require('../utils/geocode');
const logger = require('../utils/logger');
const { escapeCell } = require('../utils/csv');
const config = require('../config/env');
@ -729,32 +729,6 @@ const getGeocodeByPostalCode = async (req, res) => {
}
};
/**
* GET /api/public/geocode/search?q=...
* Freitext-Adresssuche fuer die Autovervollstaendigung im Benutzerformular.
* Laeuft ueber den Server, damit Mindestwartezeit, Cache und der von Nominatim
* geforderte User-Agent greifen und keine Nutzer-IP beim Dienst landet.
*/
const searchAddressSuggestions = async (req, res) => {
const { q } = req.query;
const query = String(q || '').trim();
if (query.length < 3) {
return res.status(400).json({ success: false, message: 'Suchbegriff zu kurz (min. 3 Zeichen)' });
}
if (query.length > 200) {
return res.status(400).json({ success: false, message: 'Suchbegriff zu lang' });
}
try {
const results = await searchAddresses(query, req.query.limit);
res.json({ success: true, data: results });
} catch (error) {
logger.error('Fehler bei der Adresssuche:', error);
res.status(500).json({ success: false, message: 'Fehler bei der Adresssuche' });
}
};
module.exports = {
getAllUsers,
getUserById,
@ -772,6 +746,5 @@ module.exports = {
bulkDeleteUsers,
uploadUserPhoto,
deleteUserPhoto,
getGeocodeByPostalCode,
searchAddressSuggestions
getGeocodeByPostalCode
};

View File

@ -14,11 +14,6 @@ const authenticateDrohnenfuehrer = (req, res, next) => {
if (decoded.role !== 'drohnenfuehrer') {
return res.status(403).json({ success: false, message: 'Zugriff verweigert' });
}
// Token einer anderen App ablehnen. Greift auch dann, wenn versehentlich
// wieder ein gemeinsames JWT-Secret konfiguriert wird.
if (decoded.app && decoded.app !== config.appName) {
return res.status(403).json({ success: false, message: 'Zugriff verweigert' });
}
req.drohnenfuehrerUser = decoded;
next();
} catch (error) {

View File

@ -17,13 +17,11 @@ const validateLogin = [
.trim()
.notEmpty()
.withMessage('Benutzername ist erforderlich'),
// Keine Laengenpruefung: eine Passwort-Policy gehoert nicht in den
// Login-Pfad. Sie liefert 400 statt 401 und verraet damit unnoetig etwas
// ueber die Regeln; ausserdem widersprach der Wert dem minlength des
// Admin-Schemas.
body('password')
.notEmpty()
.withMessage('Passwort ist erforderlich'),
.withMessage('Passwort ist erforderlich')
.isLength({ min: 6 })
.withMessage('Passwort muss mindestens 6 Zeichen lang sein'),
handleValidationErrors
];

View File

@ -21,15 +21,13 @@ const {
bulkDeleteUsers,
uploadUserPhoto,
deleteUserPhoto,
getGeocodeByPostalCode,
searchAddressSuggestions
getGeocodeByPostalCode
} = require('../controllers/userController');
// Public routes
router.get('/public/users', getPublicUsers);
// Eigenes, engeres Limit: der Endpunkt loest ausgehende Nominatim-Anfragen aus.
router.get('/public/geocode', geocodeLimiter, getGeocodeByPostalCode);
router.get('/public/geocode/search', geocodeLimiter, searchAddressSuggestions);
// Protected routes (require authentication)
router.get('/users', authenticateToken, getAllUsers);

View File

@ -73,13 +73,6 @@ app.use('/api', require('./routes/auditRoutes'));
app.use('/api/config', require('./routes/configRoutes'));
app.use('/api/drohnenfuehrer', require('./routes/drohnenfuehrerRoutes'));
// Unbekannte API-Pfade als JSON beantworten. Ohne das faellt die Anfrage bis zum
// Express-Standard durch und liefert eine HTML-Seite ("Cannot GET /api/foo"),
// mit der ein JSON-Client nichts anfangen kann.
app.use('/api', (req, res) => {
res.status(404).json({ success: false, message: 'Endpunkt nicht gefunden' });
});
// Health check with basic system info
app.get('/health', async (req, res) => {
const dbStatus = mongoose.connection.readyState === 1 ? 'connected' : 'disconnected';

View File

@ -32,9 +32,7 @@ services:
environment:
- NODE_ENV=production
- MONGO_URI=mongodb://drohnenfuehrer:${MONGO_PASSWORD}@mongo:27017/drohnenfuehrer?authSource=admin
# :? statt stiller Leerersetzung — sonst startet das Backend mit
# leerem Secret und beendet sich sofort wieder (Crash-Loop).
- JWT_SECRET=${DROHNENFUEHRER_JWT_SECRET:?DROHNENFUEHRER_JWT_SECRET muss in .env gesetzt sein}
- JWT_SECRET=${DROHNENFUEHRER_JWT_SECRET}
- JWT_EXPIRES_IN=24h
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:8081}
- ADMIN_THORSTEN_PASSWORD=${ADMIN_THORSTEN_PASSWORD}
@ -48,9 +46,7 @@ services:
# - SMTP_PASS=${SMTP_PASS}
# - SMTP_FROM=drohnenfuehrer@example.com
# Basis fuer Links in Passwort-Reset-Mails. MUSS den Unterpfad enthalten.
# Leer lassen, wenn nicht konfiguriert: config/env.js baut den Wert
# dann aus CORS_ORIGIN + Unterpfad und warnt sichtbar darueber.
- APP_URL=${APP_URL:-}
- APP_URL=${APP_URL:-http://localhost:8081/drohnenfuehrer}
depends_on:
mongo:
condition: service_healthy

View File

@ -1,13 +1,11 @@
// Service Worker: nur Offline-Fallback, keine Asset-Caches
// Vite erzeugt content-addressierte Hashes, kein manuelles Caching nötig
const CACHE_NAME = 'drohnenfuehrer-offline-v2';
const CACHE_NAME = 'drohnenfuehrer-offline-v1';
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.add('offline.html'))
.catch(err => console.warn('Offline-Seite konnte nicht gecacht werden:', err))
caches.open(CACHE_NAME).then(cache => cache.add('offline.html'))
);
self.skipWaiting();
});

View File

@ -13,11 +13,6 @@
in beiden Varianten nachgerechnet.
*/
:root {
/* Sagt dem Browser, dass die Seite beide Ansichten kann. Native Bedienelemente
Checkboxen, Auswahllisten, Bildlaufleisten passen sich dadurch an,
statt in der Nachtansicht hell aufzublitzen. */
color-scheme: light dark;
--font-display: Georgia, 'Times New Roman', serif;
--font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
@ -164,6 +159,9 @@ body {
-moz-osx-font-smoothing: grayscale;
background: var(--color-bg);
color: var(--color-text);
/* Damit auch vom Browser gestellte Bedienelemente (Bildlaufleisten,
Datumsauswahl, Autofill) der gewählten Ansicht folgen. */
color-scheme: light dark;
}
/* Formularfelder brauchen ausdrücklich Farben: ohne sie nimmt der Browser
@ -181,13 +179,6 @@ textarea::placeholder {
opacity: 1;
}
/* Checkbox, Radio und Schieberegler in der Markenfarbe statt im Browser-Blau. */
input[type="checkbox"],
input[type="radio"],
input[type="range"] {
accent-color: var(--color-primary);
}
h1, h2, h3 {
font-family: var(--font-display);
}

View File

@ -4,9 +4,6 @@
.admin-tabs {
display: inline-flex;
/* Vier Reiter passen auf schmalen Displays nicht in eine Zeile ohne
Umbruch lief die Seite bei 360 px horizontal ueber. */
flex-wrap: wrap;
gap: 0;
background: var(--color-surface);
border: 1px solid var(--color-border);
@ -17,7 +14,6 @@
}
.tab-button {
min-height: var(--touch-target);
padding: 0.5rem 1rem;
border: none;
background: transparent;
@ -126,7 +122,7 @@
.settings-section-count {
font-size: 0.8rem;
color: var(--color-text-muted);
background: var(--color-surface-alt);
background: var(--color-border);
border-radius: 999px;
padding: 0.1rem 0.5rem;
}
@ -168,7 +164,7 @@
font-size: 0.75rem;
font-weight: 700;
color: var(--color-text-muted);
background: var(--color-surface-alt);
background: var(--color-border);
border-radius: 50%;
flex-shrink: 0;
}
@ -350,16 +346,3 @@
opacity: 1;
}
}
@media (max-width: 480px) {
.admin-tabs {
display: flex;
width: 100%;
}
.tab-button {
flex: 1 1 45%;
padding: 0.5rem 0.6rem;
font-size: 0.9rem;
}
}

View File

@ -371,8 +371,8 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
</div>
<div className="settings-section-body">
<div className="settings-field">
<label className="settings-label" htmlFor="settings-appname">Name der App (wird in der Kopfzeile angezeigt)</label>
<input id="settings-appname"
<label className="settings-label">Name der App (wird in der Kopfzeile angezeigt)</label>
<input
type="text"
className="settings-input"
value={config.appName || ''}
@ -439,8 +439,8 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
{ key: 'ansprechpartner', label: 'Ansprechpartner und Koordination' }
].map(({ key, label }) => (
<div key={key} className="settings-field">
<label className="settings-label" htmlFor={`settings-section-${key}`}>{label}</label>
<textarea id={`settings-section-${key}`}
<label className="settings-label">{label}</label>
<textarea
value={getSectionContent(key)}
onChange={(e) => updateSection(key, e.target.value)}
className="settings-textarea settings-textarea-lg"

View File

@ -231,7 +231,7 @@
.badge-login-failed { background: #ffcdd2; color: #b71c1c; font-weight: 700; }
.badge-import { background: #e0f7fa; color: #00695c; }
.badge-export { background: #e8f5e9; color: #1b5e20; }
.badge-bulk-update { background: #e8eaf6; color: #283593; }
.badge-bulk-update { background: var(--color-surface-alt); color: #283593; }
.badge-bulk-delete { background: #fbe9e7; color: #bf360c; }
.badge-password { background: #fff8e1; color: #9c4e00; }
.badge-default { background: #f5f5f5; color: #616161; }
@ -280,25 +280,14 @@
.audit-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.audit-item-failed { border-left: 4px solid var(--color-danger); }
/* Als <button> ausgezeichnet, damit das Auf- und Zuklappen auch mit der
Tastatur erreichbar ist. Die Button-Grundstile werden hier zurueckgesetzt. */
.audit-item-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
flex-wrap: wrap;
width: 100%;
text-align: left;
font: inherit;
color: inherit;
background: var(--color-surface-alt);
border: none;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
}
.audit-item-header:disabled {
cursor: default;
}
.audit-time { margin-left: auto; color: var(--color-text-muted); font-size: 12px; white-space: nowrap; }
.expand-toggle { color: var(--color-text-muted); font-size: 11px; cursor: pointer; padding: 0 4px; }
@ -513,6 +502,13 @@
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.audit-item-header {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 12px;
flex-wrap: wrap;
}
.audit-badge {
padding: 6px 12px;

View File

@ -228,13 +228,8 @@ function AuditLogs() {
return (
<div key={log._id} className={`audit-item ${!log.success ? 'audit-item-failed' : ''}`}>
<button
type="button"
className="audit-item-header"
onClick={() => toggleExpand(log._id)}
disabled={!hasDetail}
aria-expanded={hasDetail ? isExpanded : undefined}
>
<div className="audit-item-header" onClick={() => hasDetail && toggleExpand(log._id)}
style={{ cursor: hasDetail ? 'pointer' : 'default' }}>
<span className={`audit-badge ${ACTION_BADGE[log.action] || 'badge-default'}`}>
{ACTION_ICONS[log.action]} {ACTION_LABELS[log.action] || log.action}
</span>
@ -249,7 +244,7 @@ function AuditLogs() {
{hasDetail && (
<span className="expand-toggle">{isExpanded ? '▲' : '▼'}</span>
)}
</button>
</div>
<div className="audit-item-body">
<span className="audit-admin">👤 <strong>{log.adminUsername || '—'}</strong></span>

View File

@ -115,18 +115,18 @@ const DrohnenfuehrerDashboard = ({ drohnenfuehrerUser, onLogout }) => {
) : (
<form onSubmit={handleSave} className="drohnenfuehrer-edit-form">
<div className="form-group">
<label htmlFor="profil-adresse">Adresse</label>
<input id="profil-adresse" type="text" value={formData.address}
<label>Adresse</label>
<input type="text" value={formData.address}
onChange={e => setFormData({ ...formData, address: e.target.value })} required />
</div>
<div className="form-group">
<label htmlFor="profil-mobil">Mobilnummer</label>
<input id="profil-mobil" type="tel" value={formData.phone}
<label>Mobilnummer</label>
<input type="tel" value={formData.phone}
onChange={e => setFormData({ ...formData, phone: e.target.value })} required />
</div>
<div className="form-group">
<label htmlFor="profil-festnetz">Festnetz (optional)</label>
<input id="profil-festnetz" type="tel" value={formData.landline}
<label>Festnetz (optional)</label>
<input type="tel" value={formData.landline}
onChange={e => setFormData({ ...formData, landline: e.target.value })} />
</div>
<div className="drohnenfuehrer-form-actions">

View File

@ -87,12 +87,12 @@ const DrohnenfuehrerLogin = ({ onLogin }) => {
{mode === 'login' ? (
<form onSubmit={handleLogin} className="drohnenfuehrer-form">
<div className="form-group">
<label htmlFor="login-email">E-Mail</label>
<input id="login-email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
<label>E-Mail</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
</div>
<div className="form-group">
<label htmlFor="login-password">Passwort</label>
<input id="login-password" type="password" value={password} onChange={e => setPassword(e.target.value)} required />
<label>Passwort</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
</div>
<button type="submit" className="btn-drohnenfuehrer-primary" disabled={loading}>
{loading ? 'Anmelden...' : 'Anmelden'}
@ -104,20 +104,20 @@ const DrohnenfuehrerLogin = ({ onLogin }) => {
Der Admin hat Ihre E-Mail-Adresse hinterlegt. Geben Sie hier Ihre E-Mail, den Einladungs-Token und ein neues Passwort ein.
</p>
<div className="form-group">
<label htmlFor="setpw-email">E-Mail</label>
<input id="setpw-email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
<label>E-Mail</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
</div>
<div className="form-group">
<label htmlFor="setpw-token">Einladungs-Token</label>
<input id="setpw-token" type="text" value={inviteToken} onChange={e => setInviteToken(e.target.value)} required />
<label>Einladungs-Token</label>
<input type="text" value={inviteToken} onChange={e => setInviteToken(e.target.value)} required />
</div>
<div className="form-group">
<label htmlFor="setpw-password">Neues Passwort (min. 8 Zeichen)</label>
<input id="setpw-password" type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={8} />
<label>Neues Passwort (min. 8 Zeichen)</label>
<input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={8} />
</div>
<div className="form-group">
<label htmlFor="setpw-repeat">Passwort wiederholen</label>
<input id="setpw-repeat" type="password" value={newPassword2} onChange={e => setNewPassword2(e.target.value)} required />
<label>Passwort wiederholen</label>
<input type="password" value={newPassword2} onChange={e => setNewPassword2(e.target.value)} required />
</div>
<button type="submit" className="btn-drohnenfuehrer-primary" disabled={loading}>
{loading ? 'Wird gesetzt...' : 'Passwort setzen'}

View File

@ -2,20 +2,14 @@ import React, { useEffect } from 'react';
import { MapContainer, TileLayer, Marker, Popup, Circle, useMap } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
import markerIcon from 'leaflet/dist/images/marker-icon.png';
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
import './MapView.css';
// Standard-Marker-Icons aus dem installierten Leaflet-Paket buendeln.
// Vorher kamen sie von cdnjs — in einer Offline-PWA fuer den Wald waren die
// Marker damit ohne Empfang kaputt, und die IP jedes Besuchers ging an ein
// fremdes CDN. Die Dateien liegen ohnehin in leaflet/dist/images.
// Fix für Standard-Marker-Icons in Leaflet
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: markerIcon2x,
iconUrl: markerIcon,
shadowUrl: markerShadow,
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
});
// Komponente zum Aktualisieren der Kartenansicht

View File

@ -20,8 +20,8 @@ const FilterPanel = ({ filters, onFilterChange, showAvailableFilter = true }) =>
</div>
)}
<div className="filter-group">
<label htmlFor="filter-typ">Typ:</label>
<select id="filter-typ"
<label>Typ:</label>
<select
value={filters.type || ''}
onChange={(e) => onFilterChange('type', e.target.value || null)}
className="filter-select"
@ -35,8 +35,8 @@ const FilterPanel = ({ filters, onFilterChange, showAvailableFilter = true }) =>
</select>
</div>
<div className="filter-group">
<label htmlFor="filter-sortierung">Sortierung:</label>
<select id="filter-sortierung"
<label>Sortierung:</label>
<select
value={filters.sortBy || 'name'}
onChange={(e) => onFilterChange('sortBy', e.target.value)}
className="filter-select"

View File

@ -1,6 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
import { useConfigContext } from '../../contexts/ConfigContext';
import { searchAddresses } from '../../services/users';
import './UserForm.css';
const formatSuggestionAddress = (addr) => {
@ -126,13 +125,17 @@ const UserForm = ({ user, onSave, onCancel }) => {
searchTimer.current = setTimeout(async () => {
try {
const result = await searchAddresses(value);
setSuggestions(result.data);
const res = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&countrycodes=de&addressdetails=1&limit=5&q=${encodeURIComponent(value)}`,
{ headers: { 'User-Agent': 'drohnenfuehrer-app/1.0 (admin@kasimirat.de)' } }
);
const data = await res.json();
setSuggestions(Array.isArray(data) ? data : []);
setShowSuggestions(true);
} catch {
setSuggestions([]);
}
}, 600);
}, 400);
};
const selectSuggestion = (s) => {
@ -230,8 +233,7 @@ const UserForm = ({ user, onSave, onCancel }) => {
</div>
<div className="form-group">
{/* Gruppenueberschrift: gehoert nicht zu einem einzelnen Feld */}
<span className="form-group-heading">GPS-Koordinaten</span>
<label>GPS-Koordinaten</label>
{gpsAutoSet ? (
<p className="gps-hint gps-hint-success"> GPS automatisch aus Adresse übernommen</p>
) : (

View File

@ -214,22 +214,6 @@ export const uploadUserPhoto = async (id, photoDataUrl) => {
}
};
// Freitext-Adresssuche ueber das eigene Backend statt direkt bei Nominatim:
// dort greifen Mindestwartezeit, Cache und der vorgeschriebene User-Agent,
// und es wandert keine Nutzer-IP zu einem fremden Dienst.
export const searchAddresses = async (query) => {
try {
const response = await api.get('/public/geocode/search', { params: { q: query } });
return { success: true, data: response.data.data || [] };
} catch (error) {
return {
success: false,
data: [],
message: error.response?.data?.message || 'Fehler bei der Adresssuche'
};
}
};
// Einmal-Token, mit dem ein Fuehrer sein erstes Passwort setzt.
// Laeuft ueber die Admin-Session (Cookie), nicht ueber den Fuehrer-Token.
export const generateInviteToken = async (id) => {

View File

@ -6,17 +6,13 @@ NODE_ENV=development
MONGO_URI=mongodb://127.0.0.1:27017/tracking-leaders
# JWT Configuration
# Der Name MUSS zu docker-compose.yml passen. Je App ein EIGENES Secret:
# openssl rand -hex 32
NACHSUCHE_JWT_SECRET=
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
JWT_EXPIRES_IN=24h
# Admin Initial Password (used by seed.js if admin doesn't exist)
# ADMIN_INITIAL_PASSWORD=secure-password-here
# CORS Configuration (comma-separated for multiple origins)
# Produktiv die echte oeffentliche Herkunft eintragen, nicht localhost —
# APP_URL wird daraus abgeleitet, wenn es nicht gesetzt ist.
CORS_ORIGIN=http://localhost:3000
# Geocoding Configuration (OpenStreetMap Nominatim)
@ -27,9 +23,7 @@ GEOCODE_MIN_DELAY_MS=1100
# Basis-URL der App fuer Links in E-Mails (Passwort-Reset).
# MUSS den Unterpfad enthalten, unter dem die App ausgeliefert wird.
# Ohne diesen Wert wird er aus CORS_ORIGIN + "/nachsuche" zusammengesetzt.
# Produktiv die echte oeffentliche URL inkl. Unterpfad eintragen.
# Leer lassen -> wird aus CORS_ORIGIN + "/nachsuche" gebildet (mit Warnung).
APP_URL=
APP_URL=http://localhost:8080/nachsuche
# SMTP fuer Passwort-Reset-Mails (optional).
# Fehlt die Konfiguration, wird der Reset-Link nur ins Log geschrieben.

View File

@ -25,7 +25,7 @@ const handlerLogin = async (req, res) => {
}
const token = jwt.sign(
{ id: user._id.toString(), role: 'handler', app: config.appName },
{ id: user._id.toString(), role: 'handler' },
config.jwtSecret,
{ expiresIn: '12h' }
);

View File

@ -1,5 +1,5 @@
const User = require('../models/User');
const { geocodeAddress, searchAddresses } = require('../utils/geocode');
const { geocodeAddress } = require('../utils/geocode');
const logger = require('../utils/logger');
const { escapeCell } = require('../utils/csv');
const config = require('../config/env');
@ -733,32 +733,6 @@ const getGeocodeByPostalCode = async (req, res) => {
}
};
/**
* GET /api/public/geocode/search?q=...
* Freitext-Adresssuche fuer die Autovervollstaendigung im Benutzerformular.
* Laeuft ueber den Server, damit Mindestwartezeit, Cache und der von Nominatim
* geforderte User-Agent greifen und keine Nutzer-IP beim Dienst landet.
*/
const searchAddressSuggestions = async (req, res) => {
const { q } = req.query;
const query = String(q || '').trim();
if (query.length < 3) {
return res.status(400).json({ success: false, message: 'Suchbegriff zu kurz (min. 3 Zeichen)' });
}
if (query.length > 200) {
return res.status(400).json({ success: false, message: 'Suchbegriff zu lang' });
}
try {
const results = await searchAddresses(query, req.query.limit);
res.json({ success: true, data: results });
} catch (error) {
logger.error('Fehler bei der Adresssuche:', error);
res.status(500).json({ success: false, message: 'Fehler bei der Adresssuche' });
}
};
module.exports = {
getAllUsers,
getUserById,
@ -776,6 +750,5 @@ module.exports = {
bulkDeleteUsers,
uploadUserPhoto,
deleteUserPhoto,
getGeocodeByPostalCode,
searchAddressSuggestions
getGeocodeByPostalCode
};

View File

@ -14,11 +14,6 @@ const authenticateHandler = (req, res, next) => {
if (decoded.role !== 'handler') {
return res.status(403).json({ success: false, message: 'Zugriff verweigert' });
}
// Token einer anderen App ablehnen. Greift auch dann, wenn versehentlich
// wieder ein gemeinsames JWT-Secret konfiguriert wird.
if (decoded.app && decoded.app !== config.appName) {
return res.status(403).json({ success: false, message: 'Zugriff verweigert' });
}
req.handlerUser = decoded;
next();
} catch (error) {

View File

@ -17,13 +17,11 @@ const validateLogin = [
.trim()
.notEmpty()
.withMessage('Benutzername ist erforderlich'),
// Keine Laengenpruefung: eine Passwort-Policy gehoert nicht in den
// Login-Pfad. Sie liefert 400 statt 401 und verraet damit unnoetig etwas
// ueber die Regeln; ausserdem widersprach der Wert dem minlength des
// Admin-Schemas.
body('password')
.notEmpty()
.withMessage('Passwort ist erforderlich'),
.withMessage('Passwort ist erforderlich')
.isLength({ min: 6 })
.withMessage('Passwort muss mindestens 6 Zeichen lang sein'),
handleValidationErrors
];

View File

@ -21,15 +21,13 @@ const {
bulkDeleteUsers,
uploadUserPhoto,
deleteUserPhoto,
getGeocodeByPostalCode,
searchAddressSuggestions
getGeocodeByPostalCode
} = require('../controllers/userController');
// Public routes
router.get('/public/users', getPublicUsers);
// Eigenes, engeres Limit: der Endpunkt loest ausgehende Nominatim-Anfragen aus.
router.get('/public/geocode', geocodeLimiter, getGeocodeByPostalCode);
router.get('/public/geocode/search', geocodeLimiter, searchAddressSuggestions);
// Protected routes (require authentication)
router.get('/users', authenticateToken, getAllUsers);

View File

@ -73,13 +73,6 @@ app.use('/api', require('./routes/auditRoutes'));
app.use('/api/config', require('./routes/configRoutes'));
app.use('/api/handler', require('./routes/handlerRoutes'));
// Unbekannte API-Pfade als JSON beantworten. Ohne das faellt die Anfrage bis zum
// Express-Standard durch und liefert eine HTML-Seite ("Cannot GET /api/foo"),
// mit der ein JSON-Client nichts anfangen kann.
app.use('/api', (req, res) => {
res.status(404).json({ success: false, message: 'Endpunkt nicht gefunden' });
});
// Health check with basic system info
app.get('/health', async (req, res) => {
const dbStatus = mongoose.connection.readyState === 1 ? 'connected' : 'disconnected';

View File

@ -161,76 +161,6 @@ const geocodeAddress = async (address) => {
}
};
// Nur die Felder, die das Adressformular im Frontend tatsächlich auswertet.
// Alles andere aus der Nominatim-Antwort wird verworfen.
const ADDRESS_PARTS = [
'road', 'house_number', 'postcode',
'city', 'town', 'village', 'municipality', 'hamlet',
'county', 'state'
];
const trimSuggestion = (hit) => {
const address = {};
for (const key of ADDRESS_PARTS) {
if (hit.address && hit.address[key]) address[key] = hit.address[key];
}
return {
lat: hit.lat,
lon: hit.lon,
display_name: hit.display_name,
address
};
};
/**
* Freitext-Adresssuche mit mehreren Treffern für die Autovervollständigung
* im Benutzerformular.
*
* Läuft bewusst über den Server: vorher hat der Browser Nominatim direkt
* angefragt, wodurch weder die Mindestwartezeit noch der vorgeschriebene
* User-Agent griffen und die IP jedes Admins beim Dienst landete.
*
* Teilt sich Wartezeit und Cache mit geocodeAddress; der Cache-Schlüssel ist
* mit einem Präfix versehen, damit die Einträge sich nicht überschneiden.
*/
const searchAddresses = async (query, limit = 5) => {
const normalized = (query || '').trim();
if (normalized.length < 3) {
return [];
}
const count = Math.min(Math.max(parseInt(limit, 10) || 5, 1), 10);
const cacheKey = `search:${count}:${normalized.toLowerCase()}`;
if (cache.has(cacheKey)) {
const hit = cache.get(cacheKey);
cache.delete(cacheKey);
cache.set(cacheKey, hit);
return hit || [];
}
const elapsed = Date.now() - lastRequestTime;
if (elapsed < config.geocodeMinDelayMs) {
await sleep(config.geocodeMinDelayMs - elapsed);
}
const url = `${config.geocodeUrl}?format=json&addressdetails=1&limit=${count}`
+ `&countrycodes=de&q=${encodeURIComponent(normalized)}`;
try {
const results = await fetchJson(url, { 'User-Agent': config.geocodeUserAgent });
lastRequestTime = Date.now();
const list = Array.isArray(results) ? results.map(trimSuggestion) : [];
rememberInCache(cacheKey, list);
return list;
} catch (error) {
logger.warn('Adresssuche fehlgeschlagen', { query: normalized, error: error.message });
lastRequestTime = Date.now();
return [];
}
};
module.exports = {
geocodeAddress,
searchAddresses
geocodeAddress
};

View File

@ -34,9 +34,7 @@ services:
environment:
- NODE_ENV=production
- MONGO_URI=mongodb://nachsuche:${MONGO_PASSWORD}@mongo:27017/nachsuche?authSource=admin
# :? statt stiller Leerersetzung — sonst startet das Backend mit
# leerem Secret und beendet sich sofort wieder (Crash-Loop).
- JWT_SECRET=${NACHSUCHE_JWT_SECRET:?NACHSUCHE_JWT_SECRET muss in .env gesetzt sein}
- JWT_SECRET=${NACHSUCHE_JWT_SECRET}
- JWT_EXPIRES_IN=24h
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:8080}
- ADMIN_THORSTEN_PASSWORD=${ADMIN_THORSTEN_PASSWORD}
@ -50,9 +48,7 @@ services:
# - SMTP_PASS=${SMTP_PASS}
# - SMTP_FROM=nachsuche@example.com
# Basis fuer Links in Passwort-Reset-Mails. MUSS den Unterpfad enthalten.
# Leer lassen, wenn nicht konfiguriert: config/env.js baut den Wert
# dann aus CORS_ORIGIN + Unterpfad und warnt sichtbar darueber.
- APP_URL=${APP_URL:-}
- APP_URL=${APP_URL:-http://localhost:8080/nachsuche}
depends_on:
mongo:
condition: service_healthy

View File

@ -1,13 +1,11 @@
// Service Worker: nur Offline-Fallback, keine Asset-Caches
// Vite erzeugt content-addressierte Hashes, kein manuelles Caching nötig
const CACHE_NAME = 'nachsuchenfuehrer-offline-v3';
const CACHE_NAME = 'nachsuchenfuehrer-offline-v2';
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.add('offline.html'))
.catch(err => console.warn('Offline-Seite konnte nicht gecacht werden:', err))
caches.open(CACHE_NAME).then(cache => cache.add('offline.html'))
);
self.skipWaiting();
});

View File

@ -13,11 +13,6 @@
in beiden Varianten nachgerechnet.
*/
:root {
/* Sagt dem Browser, dass die Seite beide Ansichten kann. Native Bedienelemente
Checkboxen, Auswahllisten, Bildlaufleisten passen sich dadurch an,
statt in der Nachtansicht hell aufzublitzen. */
color-scheme: light dark;
--font-display: Georgia, 'Times New Roman', serif;
--font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
@ -164,6 +159,9 @@ body {
-moz-osx-font-smoothing: grayscale;
background: var(--color-bg);
color: var(--color-text);
/* Damit auch vom Browser gestellte Bedienelemente (Bildlaufleisten,
Datumsauswahl, Autofill) der gewählten Ansicht folgen. */
color-scheme: light dark;
}
/* Formularfelder brauchen ausdrücklich Farben: ohne sie nimmt der Browser
@ -181,13 +179,6 @@ textarea::placeholder {
opacity: 1;
}
/* Checkbox, Radio und Schieberegler in der Markenfarbe statt im Browser-Blau. */
input[type="checkbox"],
input[type="radio"],
input[type="range"] {
accent-color: var(--color-primary);
}
h1, h2, h3 {
font-family: var(--font-display);
}

View File

@ -4,9 +4,6 @@
.admin-tabs {
display: inline-flex;
/* Vier Reiter passen auf schmalen Displays nicht in eine Zeile ohne
Umbruch lief die Seite bei 360 px horizontal ueber. */
flex-wrap: wrap;
gap: 0;
background: var(--color-surface);
border: 1px solid var(--color-border);
@ -17,7 +14,6 @@
}
.tab-button {
min-height: var(--touch-target);
padding: 0.5rem 1rem;
border: none;
background: transparent;
@ -126,7 +122,7 @@
.settings-section-count {
font-size: 0.8rem;
color: var(--color-text-muted);
background: var(--color-surface-alt);
background: var(--color-border);
border-radius: 999px;
padding: 0.1rem 0.5rem;
}
@ -168,7 +164,7 @@
font-size: 0.75rem;
font-weight: 700;
color: var(--color-text-muted);
background: var(--color-surface-alt);
background: var(--color-border);
border-radius: 50%;
flex-shrink: 0;
}
@ -350,16 +346,3 @@
opacity: 1;
}
}
@media (max-width: 480px) {
.admin-tabs {
display: flex;
width: 100%;
}
.tab-button {
flex: 1 1 45%;
padding: 0.5rem 0.6rem;
font-size: 0.9rem;
}
}

View File

@ -371,8 +371,8 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
</div>
<div className="settings-section-body">
<div className="settings-field">
<label className="settings-label" htmlFor="settings-appname">Name der App (wird in der Kopfzeile angezeigt)</label>
<input id="settings-appname"
<label className="settings-label">Name der App (wird in der Kopfzeile angezeigt)</label>
<input
type="text"
className="settings-input"
value={config.appName || ''}
@ -439,8 +439,8 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
{ key: 'ansprechpartner', label: 'Ansprechpartner und Koordination' }
].map(({ key, label }) => (
<div key={key} className="settings-field">
<label className="settings-label" htmlFor={`settings-section-${key}`}>{label}</label>
<textarea id={`settings-section-${key}`}
<label className="settings-label">{label}</label>
<textarea
value={getSectionContent(key)}
onChange={(e) => updateSection(key, e.target.value)}
className="settings-textarea settings-textarea-lg"

View File

@ -231,7 +231,7 @@
.badge-login-failed { background: #ffcdd2; color: #b71c1c; font-weight: 700; }
.badge-import { background: #e0f7fa; color: #00695c; }
.badge-export { background: #e8f5e9; color: #1b5e20; }
.badge-bulk-update { background: #e8eaf6; color: #283593; }
.badge-bulk-update { background: var(--color-surface-alt); color: #283593; }
.badge-bulk-delete { background: #fbe9e7; color: #bf360c; }
.badge-password { background: #fff8e1; color: #9c4e00; }
.badge-default { background: #f5f5f5; color: #616161; }
@ -280,25 +280,14 @@
.audit-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.audit-item-failed { border-left: 4px solid var(--color-danger); }
/* Als <button> ausgezeichnet, damit das Auf- und Zuklappen auch mit der
Tastatur erreichbar ist. Die Button-Grundstile werden hier zurueckgesetzt. */
.audit-item-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
flex-wrap: wrap;
width: 100%;
text-align: left;
font: inherit;
color: inherit;
background: var(--color-surface-alt);
border: none;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
}
.audit-item-header:disabled {
cursor: default;
}
.audit-time { margin-left: auto; color: var(--color-text-muted); font-size: 12px; white-space: nowrap; }
.expand-toggle { color: var(--color-text-muted); font-size: 11px; cursor: pointer; padding: 0 4px; }
@ -513,6 +502,13 @@
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.audit-item-header {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 12px;
flex-wrap: wrap;
}
.audit-badge {
padding: 6px 12px;

View File

@ -228,13 +228,8 @@ function AuditLogs() {
return (
<div key={log._id} className={`audit-item ${!log.success ? 'audit-item-failed' : ''}`}>
<button
type="button"
className="audit-item-header"
onClick={() => toggleExpand(log._id)}
disabled={!hasDetail}
aria-expanded={hasDetail ? isExpanded : undefined}
>
<div className="audit-item-header" onClick={() => hasDetail && toggleExpand(log._id)}
style={{ cursor: hasDetail ? 'pointer' : 'default' }}>
<span className={`audit-badge ${ACTION_BADGE[log.action] || 'badge-default'}`}>
{ACTION_ICONS[log.action]} {ACTION_LABELS[log.action] || log.action}
</span>
@ -249,7 +244,7 @@ function AuditLogs() {
{hasDetail && (
<span className="expand-toggle">{isExpanded ? '▲' : '▼'}</span>
)}
</button>
</div>
<div className="audit-item-body">
<span className="audit-admin">👤 <strong>{log.adminUsername || '—'}</strong></span>

View File

@ -115,18 +115,18 @@ const HandlerDashboard = ({ handlerUser, onLogout }) => {
) : (
<form onSubmit={handleSave} className="handler-edit-form">
<div className="form-group">
<label htmlFor="profil-adresse">Adresse</label>
<input id="profil-adresse" type="text" value={formData.address}
<label>Adresse</label>
<input type="text" value={formData.address}
onChange={e => setFormData({ ...formData, address: e.target.value })} required />
</div>
<div className="form-group">
<label htmlFor="profil-mobil">Mobilnummer</label>
<input id="profil-mobil" type="tel" value={formData.phone}
<label>Mobilnummer</label>
<input type="tel" value={formData.phone}
onChange={e => setFormData({ ...formData, phone: e.target.value })} required />
</div>
<div className="form-group">
<label htmlFor="profil-festnetz">Festnetz (optional)</label>
<input id="profil-festnetz" type="tel" value={formData.landline}
<label>Festnetz (optional)</label>
<input type="tel" value={formData.landline}
onChange={e => setFormData({ ...formData, landline: e.target.value })} />
</div>
<div className="handler-form-actions">

View File

@ -87,12 +87,12 @@ const HandlerLogin = ({ onLogin }) => {
{mode === 'login' ? (
<form onSubmit={handleLogin} className="handler-form">
<div className="form-group">
<label htmlFor="login-email">E-Mail</label>
<input id="login-email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
<label>E-Mail</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
</div>
<div className="form-group">
<label htmlFor="login-password">Passwort</label>
<input id="login-password" type="password" value={password} onChange={e => setPassword(e.target.value)} required />
<label>Passwort</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
</div>
<button type="submit" className="btn-handler-primary" disabled={loading}>
{loading ? 'Anmelden...' : 'Anmelden'}
@ -104,20 +104,20 @@ const HandlerLogin = ({ onLogin }) => {
Der Admin hat Ihre E-Mail-Adresse hinterlegt. Geben Sie hier Ihre E-Mail, den Einladungs-Token und ein neues Passwort ein.
</p>
<div className="form-group">
<label htmlFor="setpw-email">E-Mail</label>
<input id="setpw-email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
<label>E-Mail</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
</div>
<div className="form-group">
<label htmlFor="setpw-token">Einladungs-Token</label>
<input id="setpw-token" type="text" value={inviteToken} onChange={e => setInviteToken(e.target.value)} required />
<label>Einladungs-Token</label>
<input type="text" value={inviteToken} onChange={e => setInviteToken(e.target.value)} required />
</div>
<div className="form-group">
<label htmlFor="setpw-password">Neues Passwort (min. 8 Zeichen)</label>
<input id="setpw-password" type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={8} />
<label>Neues Passwort (min. 8 Zeichen)</label>
<input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={8} />
</div>
<div className="form-group">
<label htmlFor="setpw-repeat">Passwort wiederholen</label>
<input id="setpw-repeat" type="password" value={newPassword2} onChange={e => setNewPassword2(e.target.value)} required />
<label>Passwort wiederholen</label>
<input type="password" value={newPassword2} onChange={e => setNewPassword2(e.target.value)} required />
</div>
<button type="submit" className="btn-handler-primary" disabled={loading}>
{loading ? 'Wird gesetzt...' : 'Passwort setzen'}

View File

@ -2,20 +2,14 @@ import React, { useEffect } from 'react';
import { MapContainer, TileLayer, Marker, Popup, Circle, useMap } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
import markerIcon from 'leaflet/dist/images/marker-icon.png';
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
import './MapView.css';
// Standard-Marker-Icons aus dem installierten Leaflet-Paket buendeln.
// Vorher kamen sie von cdnjs — in einer Offline-PWA fuer den Wald waren die
// Marker damit ohne Empfang kaputt, und die IP jedes Besuchers ging an ein
// fremdes CDN. Die Dateien liegen ohnehin in leaflet/dist/images.
// Fix für Standard-Marker-Icons in Leaflet
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: markerIcon2x,
iconUrl: markerIcon,
shadowUrl: markerShadow,
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
});
// Komponente zum Aktualisieren der Kartenansicht

View File

@ -20,8 +20,8 @@ const FilterPanel = ({ filters, onFilterChange, showAvailableFilter = true }) =>
</div>
)}
<div className="filter-group">
<label htmlFor="filter-typ">Typ:</label>
<select id="filter-typ"
<label>Typ:</label>
<select
value={filters.type || ''}
onChange={(e) => onFilterChange('type', e.target.value || null)}
className="filter-select"
@ -35,8 +35,8 @@ const FilterPanel = ({ filters, onFilterChange, showAvailableFilter = true }) =>
</select>
</div>
<div className="filter-group">
<label htmlFor="filter-sortierung">Sortierung:</label>
<select id="filter-sortierung"
<label>Sortierung:</label>
<select
value={filters.sortBy || 'name'}
onChange={(e) => onFilterChange('sortBy', e.target.value)}
className="filter-select"

View File

@ -1,6 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
import { useConfigContext } from '../../contexts/ConfigContext';
import { searchAddresses } from '../../services/users';
import './UserForm.css';
const formatSuggestionAddress = (addr) => {
@ -126,13 +125,17 @@ const UserForm = ({ user, onSave, onCancel }) => {
searchTimer.current = setTimeout(async () => {
try {
const result = await searchAddresses(value);
setSuggestions(result.data);
const res = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&countrycodes=de&addressdetails=1&limit=5&q=${encodeURIComponent(value)}`,
{ headers: { 'User-Agent': 'nachsuche-app/1.0 (admin@kasimirat.de)' } }
);
const data = await res.json();
setSuggestions(Array.isArray(data) ? data : []);
setShowSuggestions(true);
} catch {
setSuggestions([]);
}
}, 600);
}, 400);
};
const selectSuggestion = (s) => {
@ -230,8 +233,7 @@ const UserForm = ({ user, onSave, onCancel }) => {
</div>
<div className="form-group">
{/* Gruppenueberschrift: gehoert nicht zu einem einzelnen Feld */}
<span className="form-group-heading">GPS-Koordinaten</span>
<label>GPS-Koordinaten</label>
{gpsAutoSet ? (
<p className="gps-hint gps-hint-success"> GPS automatisch aus Adresse übernommen</p>
) : (

View File

@ -214,22 +214,6 @@ export const uploadUserPhoto = async (id, photoDataUrl) => {
}
};
// Freitext-Adresssuche ueber das eigene Backend statt direkt bei Nominatim:
// dort greifen Mindestwartezeit, Cache und der vorgeschriebene User-Agent,
// und es wandert keine Nutzer-IP zu einem fremden Dienst.
export const searchAddresses = async (query) => {
try {
const response = await api.get('/public/geocode/search', { params: { q: query } });
return { success: true, data: response.data.data || [] };
} catch (error) {
return {
success: false,
data: [],
message: error.response?.data?.message || 'Fehler bei der Adresssuche'
};
}
};
// Einmal-Token, mit dem ein Fuehrer sein erstes Passwort setzt.
// Laeuft ueber die Admin-Session (Cookie), nicht ueber den Fuehrer-Token.
export const generateInviteToken = async (id) => {

View File

@ -1,11 +1,9 @@
// Service Worker: Offline-Fallback für Portal-Selektor
const CACHE_NAME = 'portal-offline-v3';
const CACHE_NAME = 'portal-offline-v2';
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.add('offline.html'))
.catch(err => console.warn('Offline-Seite konnte nicht gecacht werden:', err))
caches.open(CACHE_NAME).then(cache => cache.add('offline.html'))
);
self.skipWaiting();
});

View File

@ -6,17 +6,13 @@ NODE_ENV=development
MONGO_URI=mongodb://127.0.0.1:27017/stoeberhunde
# JWT Configuration
# Der Name MUSS zu docker-compose.yml passen. Je App ein EIGENES Secret:
# openssl rand -hex 32
STOEBERHUNDE_JWT_SECRET=
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
JWT_EXPIRES_IN=24h
# Admin Initial Password (used by seed.js if admin doesn't exist)
# ADMIN_INITIAL_PASSWORD=secure-password-here
# CORS Configuration (comma-separated for multiple origins)
# Produktiv die echte oeffentliche Herkunft eintragen, nicht localhost —
# APP_URL wird daraus abgeleitet, wenn es nicht gesetzt ist.
CORS_ORIGIN=http://localhost:3000
# Geocoding Configuration (OpenStreetMap Nominatim)
@ -27,9 +23,7 @@ GEOCODE_MIN_DELAY_MS=1100
# Basis-URL der App fuer Links in E-Mails (Passwort-Reset).
# MUSS den Unterpfad enthalten, unter dem die App ausgeliefert wird.
# Ohne diesen Wert wird er aus CORS_ORIGIN + "/stoeberhunde" zusammengesetzt.
# Produktiv die echte oeffentliche URL inkl. Unterpfad eintragen.
# Leer lassen -> wird aus CORS_ORIGIN + "/stoeberhunde" gebildet (mit Warnung).
APP_URL=
APP_URL=http://localhost:8082/stoeberhunde
# SMTP fuer Passwort-Reset-Mails (optional).
# Fehlt die Konfiguration, wird der Reset-Link nur ins Log geschrieben.

View File

@ -25,7 +25,7 @@ const stoeberhundefuehrerLogin = async (req, res) => {
}
const token = jwt.sign(
{ id: user._id.toString(), role: 'stoeberhundefuehrer', app: config.appName },
{ id: user._id.toString(), role: 'stoeberhundefuehrer' },
config.jwtSecret,
{ expiresIn: '12h' }
);

View File

@ -1,5 +1,5 @@
const User = require('../models/User');
const { geocodeAddress, searchAddresses } = require('../utils/geocode');
const { geocodeAddress } = require('../utils/geocode');
const logger = require('../utils/logger');
const { escapeCell } = require('../utils/csv');
const config = require('../config/env');
@ -729,32 +729,6 @@ const getGeocodeByPostalCode = async (req, res) => {
}
};
/**
* GET /api/public/geocode/search?q=...
* Freitext-Adresssuche fuer die Autovervollstaendigung im Benutzerformular.
* Laeuft ueber den Server, damit Mindestwartezeit, Cache und der von Nominatim
* geforderte User-Agent greifen und keine Nutzer-IP beim Dienst landet.
*/
const searchAddressSuggestions = async (req, res) => {
const { q } = req.query;
const query = String(q || '').trim();
if (query.length < 3) {
return res.status(400).json({ success: false, message: 'Suchbegriff zu kurz (min. 3 Zeichen)' });
}
if (query.length > 200) {
return res.status(400).json({ success: false, message: 'Suchbegriff zu lang' });
}
try {
const results = await searchAddresses(query, req.query.limit);
res.json({ success: true, data: results });
} catch (error) {
logger.error('Fehler bei der Adresssuche:', error);
res.status(500).json({ success: false, message: 'Fehler bei der Adresssuche' });
}
};
module.exports = {
getAllUsers,
getUserById,
@ -772,6 +746,5 @@ module.exports = {
bulkDeleteUsers,
uploadUserPhoto,
deleteUserPhoto,
getGeocodeByPostalCode,
searchAddressSuggestions
getGeocodeByPostalCode
};

View File

@ -14,11 +14,6 @@ const authenticateStoeberhundefuehrer = (req, res, next) => {
if (decoded.role !== 'stoeberhundefuehrer') {
return res.status(403).json({ success: false, message: 'Zugriff verweigert' });
}
// Token einer anderen App ablehnen. Greift auch dann, wenn versehentlich
// wieder ein gemeinsames JWT-Secret konfiguriert wird.
if (decoded.app && decoded.app !== config.appName) {
return res.status(403).json({ success: false, message: 'Zugriff verweigert' });
}
req.stoeberhundefuehrerUser = decoded;
next();
} catch (error) {

View File

@ -17,13 +17,11 @@ const validateLogin = [
.trim()
.notEmpty()
.withMessage('Benutzername ist erforderlich'),
// Keine Laengenpruefung: eine Passwort-Policy gehoert nicht in den
// Login-Pfad. Sie liefert 400 statt 401 und verraet damit unnoetig etwas
// ueber die Regeln; ausserdem widersprach der Wert dem minlength des
// Admin-Schemas.
body('password')
.notEmpty()
.withMessage('Passwort ist erforderlich'),
.withMessage('Passwort ist erforderlich')
.isLength({ min: 6 })
.withMessage('Passwort muss mindestens 6 Zeichen lang sein'),
handleValidationErrors
];

View File

@ -21,15 +21,13 @@ const {
bulkDeleteUsers,
uploadUserPhoto,
deleteUserPhoto,
getGeocodeByPostalCode,
searchAddressSuggestions
getGeocodeByPostalCode
} = require('../controllers/userController');
// Public routes
router.get('/public/users', getPublicUsers);
// Eigenes, engeres Limit: der Endpunkt loest ausgehende Nominatim-Anfragen aus.
router.get('/public/geocode', geocodeLimiter, getGeocodeByPostalCode);
router.get('/public/geocode/search', geocodeLimiter, searchAddressSuggestions);
// Protected routes (require authentication)
router.get('/users', authenticateToken, getAllUsers);

View File

@ -73,13 +73,6 @@ app.use('/api', require('./routes/auditRoutes'));
app.use('/api/config', require('./routes/configRoutes'));
app.use('/api/stoeberhundefuehrer', require('./routes/stoeberhundefuehrerRoutes'));
// Unbekannte API-Pfade als JSON beantworten. Ohne das faellt die Anfrage bis zum
// Express-Standard durch und liefert eine HTML-Seite ("Cannot GET /api/foo"),
// mit der ein JSON-Client nichts anfangen kann.
app.use('/api', (req, res) => {
res.status(404).json({ success: false, message: 'Endpunkt nicht gefunden' });
});
// Health check with basic system info
app.get('/health', async (req, res) => {
const dbStatus = mongoose.connection.readyState === 1 ? 'connected' : 'disconnected';

View File

@ -32,9 +32,7 @@ services:
environment:
- NODE_ENV=production
- MONGO_URI=mongodb://stoeberhunde:${MONGO_PASSWORD}@mongo:27017/stoeberhunde?authSource=admin
# :? statt stiller Leerersetzung — sonst startet das Backend mit
# leerem Secret und beendet sich sofort wieder (Crash-Loop).
- JWT_SECRET=${STOEBERHUNDE_JWT_SECRET:?STOEBERHUNDE_JWT_SECRET muss in .env gesetzt sein}
- JWT_SECRET=${STOEBERHUNDE_JWT_SECRET}
- JWT_EXPIRES_IN=24h
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:8082}
- ADMIN_THORSTEN_PASSWORD=${ADMIN_THORSTEN_PASSWORD}
@ -48,9 +46,7 @@ services:
# - SMTP_PASS=${SMTP_PASS}
# - SMTP_FROM=stoeberhunde@example.com
# Basis fuer Links in Passwort-Reset-Mails. MUSS den Unterpfad enthalten.
# Leer lassen, wenn nicht konfiguriert: config/env.js baut den Wert
# dann aus CORS_ORIGIN + Unterpfad und warnt sichtbar darueber.
- APP_URL=${APP_URL:-}
- APP_URL=${APP_URL:-http://localhost:8082/stoeberhunde}
depends_on:
mongo:
condition: service_healthy

View File

@ -1,13 +1,11 @@
// Service Worker: nur Offline-Fallback, keine Asset-Caches
// Vite erzeugt content-addressierte Hashes, kein manuelles Caching nötig
const CACHE_NAME = 'stoeberhunde-offline-v2';
const CACHE_NAME = 'stoeberhunde-offline-v1';
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.add('offline.html'))
.catch(err => console.warn('Offline-Seite konnte nicht gecacht werden:', err))
caches.open(CACHE_NAME).then(cache => cache.add('offline.html'))
);
self.skipWaiting();
});

View File

@ -13,11 +13,6 @@
in beiden Varianten nachgerechnet.
*/
:root {
/* Sagt dem Browser, dass die Seite beide Ansichten kann. Native Bedienelemente
Checkboxen, Auswahllisten, Bildlaufleisten passen sich dadurch an,
statt in der Nachtansicht hell aufzublitzen. */
color-scheme: light dark;
--font-display: Georgia, 'Times New Roman', serif;
--font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
@ -164,6 +159,9 @@ body {
-moz-osx-font-smoothing: grayscale;
background: var(--color-bg);
color: var(--color-text);
/* Damit auch vom Browser gestellte Bedienelemente (Bildlaufleisten,
Datumsauswahl, Autofill) der gewählten Ansicht folgen. */
color-scheme: light dark;
}
/* Formularfelder brauchen ausdrücklich Farben: ohne sie nimmt der Browser
@ -181,13 +179,6 @@ textarea::placeholder {
opacity: 1;
}
/* Checkbox, Radio und Schieberegler in der Markenfarbe statt im Browser-Blau. */
input[type="checkbox"],
input[type="radio"],
input[type="range"] {
accent-color: var(--color-primary);
}
h1, h2, h3 {
font-family: var(--font-display);
}

View File

@ -4,9 +4,6 @@
.admin-tabs {
display: inline-flex;
/* Vier Reiter passen auf schmalen Displays nicht in eine Zeile ohne
Umbruch lief die Seite bei 360 px horizontal ueber. */
flex-wrap: wrap;
gap: 0;
background: var(--color-surface);
border: 1px solid var(--color-border);
@ -17,7 +14,6 @@
}
.tab-button {
min-height: var(--touch-target);
padding: 0.5rem 1rem;
border: none;
background: transparent;
@ -126,7 +122,7 @@
.settings-section-count {
font-size: 0.8rem;
color: var(--color-text-muted);
background: var(--color-surface-alt);
background: var(--color-border);
border-radius: 999px;
padding: 0.1rem 0.5rem;
}
@ -168,7 +164,7 @@
font-size: 0.75rem;
font-weight: 700;
color: var(--color-text-muted);
background: var(--color-surface-alt);
background: var(--color-border);
border-radius: 50%;
flex-shrink: 0;
}
@ -350,16 +346,3 @@
opacity: 1;
}
}
@media (max-width: 480px) {
.admin-tabs {
display: flex;
width: 100%;
}
.tab-button {
flex: 1 1 45%;
padding: 0.5rem 0.6rem;
font-size: 0.9rem;
}
}

View File

@ -371,8 +371,8 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
</div>
<div className="settings-section-body">
<div className="settings-field">
<label className="settings-label" htmlFor="settings-appname">Name der App (wird in der Kopfzeile angezeigt)</label>
<input id="settings-appname"
<label className="settings-label">Name der App (wird in der Kopfzeile angezeigt)</label>
<input
type="text"
className="settings-input"
value={config.appName || ''}
@ -439,8 +439,8 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
{ key: 'ansprechpartner', label: 'Ansprechpartner und Koordination' }
].map(({ key, label }) => (
<div key={key} className="settings-field">
<label className="settings-label" htmlFor={`settings-section-${key}`}>{label}</label>
<textarea id={`settings-section-${key}`}
<label className="settings-label">{label}</label>
<textarea
value={getSectionContent(key)}
onChange={(e) => updateSection(key, e.target.value)}
className="settings-textarea settings-textarea-lg"

View File

@ -231,7 +231,7 @@
.badge-login-failed { background: #ffcdd2; color: #b71c1c; font-weight: 700; }
.badge-import { background: #e0f7fa; color: #00695c; }
.badge-export { background: #e8f5e9; color: #1b5e20; }
.badge-bulk-update { background: #e8eaf6; color: #283593; }
.badge-bulk-update { background: var(--color-surface-alt); color: #283593; }
.badge-bulk-delete { background: #fbe9e7; color: #bf360c; }
.badge-password { background: #fff8e1; color: #9c4e00; }
.badge-default { background: #f5f5f5; color: #616161; }
@ -280,25 +280,14 @@
.audit-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.audit-item-failed { border-left: 4px solid var(--color-danger); }
/* Als <button> ausgezeichnet, damit das Auf- und Zuklappen auch mit der
Tastatur erreichbar ist. Die Button-Grundstile werden hier zurueckgesetzt. */
.audit-item-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
flex-wrap: wrap;
width: 100%;
text-align: left;
font: inherit;
color: inherit;
background: var(--color-surface-alt);
border: none;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
}
.audit-item-header:disabled {
cursor: default;
}
.audit-time { margin-left: auto; color: var(--color-text-muted); font-size: 12px; white-space: nowrap; }
.expand-toggle { color: var(--color-text-muted); font-size: 11px; cursor: pointer; padding: 0 4px; }
@ -513,6 +502,13 @@
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.audit-item-header {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 12px;
flex-wrap: wrap;
}
.audit-badge {
padding: 6px 12px;

View File

@ -228,13 +228,8 @@ function AuditLogs() {
return (
<div key={log._id} className={`audit-item ${!log.success ? 'audit-item-failed' : ''}`}>
<button
type="button"
className="audit-item-header"
onClick={() => toggleExpand(log._id)}
disabled={!hasDetail}
aria-expanded={hasDetail ? isExpanded : undefined}
>
<div className="audit-item-header" onClick={() => hasDetail && toggleExpand(log._id)}
style={{ cursor: hasDetail ? 'pointer' : 'default' }}>
<span className={`audit-badge ${ACTION_BADGE[log.action] || 'badge-default'}`}>
{ACTION_ICONS[log.action]} {ACTION_LABELS[log.action] || log.action}
</span>
@ -249,7 +244,7 @@ function AuditLogs() {
{hasDetail && (
<span className="expand-toggle">{isExpanded ? '▲' : '▼'}</span>
)}
</button>
</div>
<div className="audit-item-body">
<span className="audit-admin">👤 <strong>{log.adminUsername || '—'}</strong></span>

View File

@ -2,20 +2,14 @@ import React, { useEffect } from 'react';
import { MapContainer, TileLayer, Marker, Popup, Circle, useMap } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
import markerIcon from 'leaflet/dist/images/marker-icon.png';
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
import './MapView.css';
// Standard-Marker-Icons aus dem installierten Leaflet-Paket buendeln.
// Vorher kamen sie von cdnjs — in einer Offline-PWA fuer den Wald waren die
// Marker damit ohne Empfang kaputt, und die IP jedes Besuchers ging an ein
// fremdes CDN. Die Dateien liegen ohnehin in leaflet/dist/images.
// Fix für Standard-Marker-Icons in Leaflet
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: markerIcon2x,
iconUrl: markerIcon,
shadowUrl: markerShadow,
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
});
// Komponente zum Aktualisieren der Kartenansicht

View File

@ -115,18 +115,18 @@ const StoeberhundefuehrerDashboard = ({ stoeberhundefuehrerUser, onLogout }) =>
) : (
<form onSubmit={handleSave} className="stoeberhundefuehrer-edit-form">
<div className="form-group">
<label htmlFor="profil-adresse">Adresse</label>
<input id="profil-adresse" type="text" value={formData.address}
<label>Adresse</label>
<input type="text" value={formData.address}
onChange={e => setFormData({ ...formData, address: e.target.value })} required />
</div>
<div className="form-group">
<label htmlFor="profil-mobil">Mobilnummer</label>
<input id="profil-mobil" type="tel" value={formData.phone}
<label>Mobilnummer</label>
<input type="tel" value={formData.phone}
onChange={e => setFormData({ ...formData, phone: e.target.value })} required />
</div>
<div className="form-group">
<label htmlFor="profil-festnetz">Festnetz (optional)</label>
<input id="profil-festnetz" type="tel" value={formData.landline}
<label>Festnetz (optional)</label>
<input type="tel" value={formData.landline}
onChange={e => setFormData({ ...formData, landline: e.target.value })} />
</div>
<div className="stoeberhundefuehrer-form-actions">

View File

@ -87,12 +87,12 @@ const StoeberhundefuehrerLogin = ({ onLogin }) => {
{mode === 'login' ? (
<form onSubmit={handleLogin} className="stoeberhundefuehrer-form">
<div className="form-group">
<label htmlFor="login-email">E-Mail</label>
<input id="login-email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
<label>E-Mail</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
</div>
<div className="form-group">
<label htmlFor="login-password">Passwort</label>
<input id="login-password" type="password" value={password} onChange={e => setPassword(e.target.value)} required />
<label>Passwort</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
</div>
<button type="submit" className="btn-stoeberhundefuehrer-primary" disabled={loading}>
{loading ? 'Anmelden...' : 'Anmelden'}
@ -104,20 +104,20 @@ const StoeberhundefuehrerLogin = ({ onLogin }) => {
Der Admin hat Ihre E-Mail-Adresse hinterlegt. Geben Sie hier Ihre E-Mail, den Einladungs-Token und ein neues Passwort ein.
</p>
<div className="form-group">
<label htmlFor="setpw-email">E-Mail</label>
<input id="setpw-email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
<label>E-Mail</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
</div>
<div className="form-group">
<label htmlFor="setpw-token">Einladungs-Token</label>
<input id="setpw-token" type="text" value={inviteToken} onChange={e => setInviteToken(e.target.value)} required />
<label>Einladungs-Token</label>
<input type="text" value={inviteToken} onChange={e => setInviteToken(e.target.value)} required />
</div>
<div className="form-group">
<label htmlFor="setpw-password">Neues Passwort (min. 8 Zeichen)</label>
<input id="setpw-password" type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={8} />
<label>Neues Passwort (min. 8 Zeichen)</label>
<input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={8} />
</div>
<div className="form-group">
<label htmlFor="setpw-repeat">Passwort wiederholen</label>
<input id="setpw-repeat" type="password" value={newPassword2} onChange={e => setNewPassword2(e.target.value)} required />
<label>Passwort wiederholen</label>
<input type="password" value={newPassword2} onChange={e => setNewPassword2(e.target.value)} required />
</div>
<button type="submit" className="btn-stoeberhundefuehrer-primary" disabled={loading}>
{loading ? 'Wird gesetzt...' : 'Passwort setzen'}

View File

@ -20,8 +20,8 @@ const FilterPanel = ({ filters, onFilterChange, showAvailableFilter = true }) =>
</div>
)}
<div className="filter-group">
<label htmlFor="filter-typ">Typ:</label>
<select id="filter-typ"
<label>Typ:</label>
<select
value={filters.type || ''}
onChange={(e) => onFilterChange('type', e.target.value || null)}
className="filter-select"
@ -35,8 +35,8 @@ const FilterPanel = ({ filters, onFilterChange, showAvailableFilter = true }) =>
</select>
</div>
<div className="filter-group">
<label htmlFor="filter-sortierung">Sortierung:</label>
<select id="filter-sortierung"
<label>Sortierung:</label>
<select
value={filters.sortBy || 'name'}
onChange={(e) => onFilterChange('sortBy', e.target.value)}
className="filter-select"

View File

@ -1,6 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
import { useConfigContext } from '../../contexts/ConfigContext';
import { searchAddresses } from '../../services/users';
import './UserForm.css';
const formatSuggestionAddress = (addr) => {
@ -126,13 +125,17 @@ const UserForm = ({ user, onSave, onCancel }) => {
searchTimer.current = setTimeout(async () => {
try {
const result = await searchAddresses(value);
setSuggestions(result.data);
const res = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&countrycodes=de&addressdetails=1&limit=5&q=${encodeURIComponent(value)}`,
{ headers: { 'User-Agent': 'stoeberhunde-app/1.0 (admin@kasimirat.de)' } }
);
const data = await res.json();
setSuggestions(Array.isArray(data) ? data : []);
setShowSuggestions(true);
} catch {
setSuggestions([]);
}
}, 600);
}, 400);
};
const selectSuggestion = (s) => {
@ -230,8 +233,7 @@ const UserForm = ({ user, onSave, onCancel }) => {
</div>
<div className="form-group">
{/* Gruppenueberschrift: gehoert nicht zu einem einzelnen Feld */}
<span className="form-group-heading">GPS-Koordinaten</span>
<label>GPS-Koordinaten</label>
{gpsAutoSet ? (
<p className="gps-hint gps-hint-success"> GPS automatisch aus Adresse übernommen</p>
) : (

View File

@ -214,22 +214,6 @@ export const uploadUserPhoto = async (id, photoDataUrl) => {
}
};
// Freitext-Adresssuche ueber das eigene Backend statt direkt bei Nominatim:
// dort greifen Mindestwartezeit, Cache und der vorgeschriebene User-Agent,
// und es wandert keine Nutzer-IP zu einem fremden Dienst.
export const searchAddresses = async (query) => {
try {
const response = await api.get('/public/geocode/search', { params: { q: query } });
return { success: true, data: response.data.data || [] };
} catch (error) {
return {
success: false,
data: [],
message: error.response?.data?.message || 'Fehler bei der Adresssuche'
};
}
};
// Einmal-Token, mit dem ein Fuehrer sein erstes Passwort setzt.
// Laeuft ueber die Admin-Session (Cookie), nicht ueber den Fuehrer-Token.
export const generateInviteToken = async (id) => {