Compare commits

..

4 Commits

Author SHA1 Message Date
thomas 2950f09485 chore: JSON-404 fuer die API, Login-Validator entschlacken, Altlasten entfernen
- Unbekannte Pfade unter /api fielen bis zum Express-Standard durch und
  lieferten eine HTML-Seite ("Cannot GET /api/foo"), mit der ein JSON-Client
  nichts anfangen kann. Es gab keinen 404-Handler; errorHandler greift nur
  bei next(err). Jetzt antwortet /api mit JSON und Status 404.
- validateLogin verlangte fuer das EINGEGEBENE Passwort mindestens 6 Zeichen.
  Eine Policy gehoert nicht in den Login-Pfad: kurze Eingaben lieferten 400
  "Validierungsfehler" statt 401 und verrieten damit etwas ueber die Regeln;
  ausserdem widersprach der Wert dem minlength: 12 des Admin-Schemas.
  Geprueft: kurzes Passwort liefert jetzt 401.
- d9fecb6 hatte versehentlich ein backend/-Verzeichnis im Repo-Wurzel
  eingecheckt (leere .gitignore, zwei 0-Byte-Logdateien) — ausserhalb jeder
  App. Entfernt und im Wurzel-.gitignore gegen Wiederkehr abgesichert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 20:48:10 +02:00
thomas e2af87a7e5 fix(a11y): Beschriftungen verknuepfen, Reiter umbrechen, Badge-Farbton zurueck
- Alle 14 <label> ohne Bezug haben jetzt htmlFor/id — davon 9 in den
  Fuehrer-Formularen, also genau dort, wo am Handy getippt wird. Die
  GPS-Gruppenueberschrift im Benutzerformular ist keine Feldbeschriftung
  mehr, sondern eine Ueberschrift.
- Das Auf- und Zuklappen im Audit-Log lag auf einem <div> und war mit der
  Tastatur nicht erreichbar; jetzt ein <button> mit aria-expanded, das bei
  fehlenden Details disabled ist. .audit-item-header war ausserdem zweimal
  in derselben Datei definiert — zusammengefasst.
- Die Reiter im Admin-Panel standen in einem inline-flex ohne Umbruch: bei
  360 px war die Leiste 395 px breit und die Seite lief horizontal ueber.
  Die Datei hatte gar keine Media-Query. Jetzt Umbruch, ein Breakpoint fuer
  <=480px und 44 px Mindesthoehe je Reiter.
- REGRESSION der letzten Runde: die geskriptete Token-Migration hat #e8eaf6
  (Indigo-Toenung) als neutrale Flaeche eingestuft, wodurch .badge-bulk-update
  im Hellmodus nicht mehr von .badge-default zu unterscheiden war. Farbton
  zurueckgesetzt.
- .settings-section-count und .settings-rule-index nutzten --color-border als
  Flaeche und lagen bei 4,49:1 — jetzt --color-surface-alt.
- Checkbox, Radio und Schieberegler folgen ueber accent-color der Markenfarbe
  statt dem Browser-Blau.

Geprueft: rechnerischer Kontrast-Durchlauf ueber alle Regeln mit Flaeche und
Text in beiden Ansichten; kein Ueberlauf mehr bei 360 px in Admin-Panel und
Fuehrer-Dashboard, hell wie dunkel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 20:47:44 +02:00
thomas 87011eee3c fix(pwa): Offline-Seite ausliefern, Fremdabhaengigkeiten der Karte entfernen
- Der Service-Worker-Cache wurde nie invalidiert: die letzte Runde hat
  offline.html in allen vier Zielen ersetzt, sw.js aber nicht angefasst.
  Ein Browser installiert einen Service Worker nur neu, wenn sich sw.js
  selbst aendert — bestehende Installationen haetten die alte, englische,
  ungestaltete Offline-Seite dauerhaft behalten. CACHE_NAME hochgezaehlt.
  Dabei faengt der Install jetzt einen fehlgeschlagenen Abruf ab; vorher
  wurde bei einem Fehler der ganze Service Worker nicht aktiv.
- Die Leaflet-Marker kamen von cdnjs.cloudflare.com — in einer PWA, die im
  Wald ohne Empfang benutzt wird, waren sie damit kaputt, und die IP jedes
  Besuchers ging an ein fremdes CDN. Verwiesen wurde ausserdem auf Leaflet
  1.7.1 bei installiertem 1.9.4. Die Bilder liegen im Paket und werden jetzt
  gebuendelt; Vite bettet sie als data:-URI ein (zusammen ~4 KB).
- Die Adress-Autovervollstaendigung im Benutzerformular rief Nominatim
  weiterhin direkt aus dem Browser auf, obwohl d9fecb6 dafuer einen Proxy
  eingefuehrt hat — nur die PLZ-Suche nutzte ihn. Neu: searchAddresses() in
  utils/geocode.js (teilt Wartezeit und Cache mit geocodeAddress) plus
  GET /api/public/geocode/search unter demselben geocodeLimiter. Die Antwort
  ist auf die Felder beschraenkt, die das Formular liest. Der Debounce steigt
  von 400 auf 600 ms, weil die Anfragen jetzt durch die serverseitige
  Mindestwartezeit laufen.

Geprueft: im Bundle kein cdnjs und kein nominatim mehr; der neue Endpunkt
liefert Treffer mit den erwarteten Feldern, zu kurze Begriffe 400.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 20:47:26 +02:00
thomas d0e48f3385 fix(deploy): Backend startet wieder — JWT-Secret kam leer im Container an
Startblocker aus d9fecb6: die Compose-Dateien lesen seither
${NACHSUCHE_JWT_SECRET} (bzw. DROHNENFUEHRER_/STOEBERHUNDE_), die .env-Dateien
definieren aber weiterhin JWT_SECRET. Compose ersetzte die unbekannte Variable
still durch einen Leerstring, config/env.js beendete den Prozess daraufhin mit
Exit-Code 1 — mit restart: unless-stopped ein Crash-Loop. Nachgewiesen mit
`docker compose config` (JWT_SECRET: "") und einem Startversuch.

- Compose nutzt jetzt ${..._JWT_SECRET:?...}: fehlt der Wert, bricht compose
  mit einer Meldung ab, statt ihn stillschweigend zu leeren.
- Die .env-Dateien (nicht versioniert) tragen die korrekten Namen und je App
  ein EIGENES 32-Byte-Secret. Vorher stand in allen drei derselbe 16-Zeichen-
  Wert, wodurch die App-Trennung aus d9fecb6 wirkungslos war.
  FOLGE: alle laufenden Admin-Sitzungen und Fuehrer-Tokens sind ungueltig,
  alle muessen sich einmal neu anmelden.
- Fuehrer-Tokens tragen jetzt app: config.appName, und handlerAuth.js sowie
  die umbenannten Varianten pruefen den Claim. Vorher trugen sie nur
  { id, role } — die app-Pruefung in middleware/auth.js lief bei ihnen ins
  Leere, weil sie an `decoded.app &&` haengt.
  Geprueft: Fuehrer-Token aus App A gegen /me von App B -> 403, gegen die
  eigene -> 200.
- APP_URL steht in Compose wieder auf ${APP_URL:-}. Der Default aus der
  letzten Runde hat die Warnung aus config/env.js unterdrueckt, die auf eine
  fehlende Konfiguration hinweisen sollte.
- .env.example dokumentiert die neuen Namen, den openssl-Aufruf und dass
  CORS_ORIGIN/APP_URL produktiv nicht auf localhost stehen duerfen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 20:47:02 +02:00
66 changed files with 611 additions and 206 deletions

4
.gitignore vendored
View File

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

1
backend/.gitignore vendored
View File

@ -1 +0,0 @@

View File

View File

@ -6,13 +6,17 @@ NODE_ENV=development
MONGO_URI=mongodb://127.0.0.1:27017/drohnenfuehrer
# JWT Configuration
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
# Der Name MUSS zu docker-compose.yml passen. Je App ein EIGENES Secret:
# openssl rand -hex 32
DROHNENFUEHRER_JWT_SECRET=
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)
@ -23,7 +27,9 @@ 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.
APP_URL=http://localhost:8081/drohnenfuehrer
# Produktiv die echte oeffentliche URL inkl. Unterpfad eintragen.
# Leer lassen -> wird aus CORS_ORIGIN + "/drohnenfuehrer" gebildet (mit Warnung).
APP_URL=
# 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' },
{ id: user._id.toString(), role: 'drohnenfuehrer', app: config.appName },
config.jwtSecret,
{ expiresIn: '12h' }
);

View File

@ -1,5 +1,5 @@
const User = require('../models/User');
const { geocodeAddress } = require('../utils/geocode');
const { geocodeAddress, searchAddresses } = require('../utils/geocode');
const logger = require('../utils/logger');
const { escapeCell } = require('../utils/csv');
const config = require('../config/env');
@ -729,6 +729,32 @@ 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,
@ -746,5 +772,6 @@ module.exports = {
bulkDeleteUsers,
uploadUserPhoto,
deleteUserPhoto,
getGeocodeByPostalCode
getGeocodeByPostalCode,
searchAddressSuggestions
};

View File

@ -14,6 +14,11 @@ 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,11 +17,13 @@ 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')
.isLength({ min: 6 })
.withMessage('Passwort muss mindestens 6 Zeichen lang sein'),
.withMessage('Passwort ist erforderlich'),
handleValidationErrors
];

View File

@ -21,13 +21,15 @@ const {
bulkDeleteUsers,
uploadUserPhoto,
deleteUserPhoto,
getGeocodeByPostalCode
getGeocodeByPostalCode,
searchAddressSuggestions
} = 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,6 +73,13 @@ 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,7 +32,9 @@ services:
environment:
- NODE_ENV=production
- MONGO_URI=mongodb://drohnenfuehrer:${MONGO_PASSWORD}@mongo:27017/drohnenfuehrer?authSource=admin
- JWT_SECRET=${DROHNENFUEHRER_JWT_SECRET}
# :? 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_EXPIRES_IN=24h
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:8081}
- ADMIN_THORSTEN_PASSWORD=${ADMIN_THORSTEN_PASSWORD}
@ -46,7 +48,9 @@ services:
# - SMTP_PASS=${SMTP_PASS}
# - SMTP_FROM=drohnenfuehrer@example.com
# Basis fuer Links in Passwort-Reset-Mails. MUSS den Unterpfad enthalten.
- APP_URL=${APP_URL:-http://localhost:8081/drohnenfuehrer}
# Leer lassen, wenn nicht konfiguriert: config/env.js baut den Wert
# dann aus CORS_ORIGIN + Unterpfad und warnt sichtbar darueber.
- APP_URL=${APP_URL:-}
depends_on:
mongo:
condition: service_healthy

View File

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

View File

@ -13,6 +13,11 @@
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;
@ -159,9 +164,6 @@ 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
@ -179,6 +181,13 @@ 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,6 +4,9 @@
.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);
@ -14,6 +17,7 @@
}
.tab-button {
min-height: var(--touch-target);
padding: 0.5rem 1rem;
border: none;
background: transparent;
@ -122,7 +126,7 @@
.settings-section-count {
font-size: 0.8rem;
color: var(--color-text-muted);
background: var(--color-border);
background: var(--color-surface-alt);
border-radius: 999px;
padding: 0.1rem 0.5rem;
}
@ -164,7 +168,7 @@
font-size: 0.75rem;
font-weight: 700;
color: var(--color-text-muted);
background: var(--color-border);
background: var(--color-surface-alt);
border-radius: 50%;
flex-shrink: 0;
}
@ -346,3 +350,16 @@
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">Name der App (wird in der Kopfzeile angezeigt)</label>
<input
<label className="settings-label" htmlFor="settings-appname">Name der App (wird in der Kopfzeile angezeigt)</label>
<input id="settings-appname"
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">{label}</label>
<textarea
<label className="settings-label" htmlFor={`settings-section-${key}`}>{label}</label>
<textarea id={`settings-section-${key}`}
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: var(--color-surface-alt); color: #283593; }
.badge-bulk-update { background: #e8eaf6; color: #283593; }
.badge-bulk-delete { background: #fbe9e7; color: #bf360c; }
.badge-password { background: #fff8e1; color: #9c4e00; }
.badge-default { background: #f5f5f5; color: #616161; }
@ -280,14 +280,25 @@
.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; }
@ -502,13 +513,6 @@
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,8 +228,13 @@ function AuditLogs() {
return (
<div key={log._id} className={`audit-item ${!log.success ? 'audit-item-failed' : ''}`}>
<div className="audit-item-header" onClick={() => hasDetail && toggleExpand(log._id)}
style={{ cursor: hasDetail ? 'pointer' : 'default' }}>
<button
type="button"
className="audit-item-header"
onClick={() => toggleExpand(log._id)}
disabled={!hasDetail}
aria-expanded={hasDetail ? isExpanded : undefined}
>
<span className={`audit-badge ${ACTION_BADGE[log.action] || 'badge-default'}`}>
{ACTION_ICONS[log.action]} {ACTION_LABELS[log.action] || log.action}
</span>
@ -244,7 +249,7 @@ function AuditLogs() {
{hasDetail && (
<span className="expand-toggle">{isExpanded ? '▲' : '▼'}</span>
)}
</div>
</button>
<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>Adresse</label>
<input type="text" value={formData.address}
<label htmlFor="profil-adresse">Adresse</label>
<input id="profil-adresse" type="text" value={formData.address}
onChange={e => setFormData({ ...formData, address: e.target.value })} required />
</div>
<div className="form-group">
<label>Mobilnummer</label>
<input type="tel" value={formData.phone}
<label htmlFor="profil-mobil">Mobilnummer</label>
<input id="profil-mobil" type="tel" value={formData.phone}
onChange={e => setFormData({ ...formData, phone: e.target.value })} required />
</div>
<div className="form-group">
<label>Festnetz (optional)</label>
<input type="tel" value={formData.landline}
<label htmlFor="profil-festnetz">Festnetz (optional)</label>
<input id="profil-festnetz" 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>E-Mail</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
<label htmlFor="login-email">E-Mail</label>
<input id="login-email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
</div>
<div className="form-group">
<label>Passwort</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
<label htmlFor="login-password">Passwort</label>
<input id="login-password" 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>E-Mail</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
<label htmlFor="setpw-email">E-Mail</label>
<input id="setpw-email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
</div>
<div className="form-group">
<label>Einladungs-Token</label>
<input type="text" value={inviteToken} onChange={e => setInviteToken(e.target.value)} required />
<label htmlFor="setpw-token">Einladungs-Token</label>
<input id="setpw-token" type="text" value={inviteToken} onChange={e => setInviteToken(e.target.value)} required />
</div>
<div className="form-group">
<label>Neues Passwort (min. 8 Zeichen)</label>
<input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={8} />
<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} />
</div>
<div className="form-group">
<label>Passwort wiederholen</label>
<input type="password" value={newPassword2} onChange={e => setNewPassword2(e.target.value)} required />
<label htmlFor="setpw-repeat">Passwort wiederholen</label>
<input id="setpw-repeat" 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,14 +2,20 @@ 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';
// Fix für Standard-Marker-Icons in Leaflet
// 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.
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
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',
iconRetinaUrl: markerIcon2x,
iconUrl: markerIcon,
shadowUrl: markerShadow,
});
// Komponente zum Aktualisieren der Kartenansicht

View File

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

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef } from 'react';
import { useConfigContext } from '../../contexts/ConfigContext';
import { searchAddresses } from '../../services/users';
import './UserForm.css';
const formatSuggestionAddress = (addr) => {
@ -125,17 +126,13 @@ const UserForm = ({ user, onSave, onCancel }) => {
searchTimer.current = setTimeout(async () => {
try {
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 : []);
const result = await searchAddresses(value);
setSuggestions(result.data);
setShowSuggestions(true);
} catch {
setSuggestions([]);
}
}, 400);
}, 600);
};
const selectSuggestion = (s) => {
@ -233,7 +230,8 @@ const UserForm = ({ user, onSave, onCancel }) => {
</div>
<div className="form-group">
<label>GPS-Koordinaten</label>
{/* Gruppenueberschrift: gehoert nicht zu einem einzelnen Feld */}
<span className="form-group-heading">GPS-Koordinaten</span>
{gpsAutoSet ? (
<p className="gps-hint gps-hint-success"> GPS automatisch aus Adresse übernommen</p>
) : (

View File

@ -214,6 +214,22 @@ 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,13 +6,17 @@ NODE_ENV=development
MONGO_URI=mongodb://127.0.0.1:27017/tracking-leaders
# JWT Configuration
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
# Der Name MUSS zu docker-compose.yml passen. Je App ein EIGENES Secret:
# openssl rand -hex 32
NACHSUCHE_JWT_SECRET=
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)
@ -23,7 +27,9 @@ 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.
APP_URL=http://localhost:8080/nachsuche
# Produktiv die echte oeffentliche URL inkl. Unterpfad eintragen.
# Leer lassen -> wird aus CORS_ORIGIN + "/nachsuche" gebildet (mit Warnung).
APP_URL=
# 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' },
{ id: user._id.toString(), role: 'handler', app: config.appName },
config.jwtSecret,
{ expiresIn: '12h' }
);

View File

@ -1,5 +1,5 @@
const User = require('../models/User');
const { geocodeAddress } = require('../utils/geocode');
const { geocodeAddress, searchAddresses } = require('../utils/geocode');
const logger = require('../utils/logger');
const { escapeCell } = require('../utils/csv');
const config = require('../config/env');
@ -733,6 +733,32 @@ 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,
@ -750,5 +776,6 @@ module.exports = {
bulkDeleteUsers,
uploadUserPhoto,
deleteUserPhoto,
getGeocodeByPostalCode
getGeocodeByPostalCode,
searchAddressSuggestions
};

View File

@ -14,6 +14,11 @@ 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,11 +17,13 @@ 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')
.isLength({ min: 6 })
.withMessage('Passwort muss mindestens 6 Zeichen lang sein'),
.withMessage('Passwort ist erforderlich'),
handleValidationErrors
];

View File

@ -21,13 +21,15 @@ const {
bulkDeleteUsers,
uploadUserPhoto,
deleteUserPhoto,
getGeocodeByPostalCode
getGeocodeByPostalCode,
searchAddressSuggestions
} = 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,6 +73,13 @@ 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,6 +161,76 @@ const geocodeAddress = async (address) => {
}
};
module.exports = {
geocodeAddress
// 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
};

View File

@ -34,7 +34,9 @@ services:
environment:
- NODE_ENV=production
- MONGO_URI=mongodb://nachsuche:${MONGO_PASSWORD}@mongo:27017/nachsuche?authSource=admin
- JWT_SECRET=${NACHSUCHE_JWT_SECRET}
# :? 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_EXPIRES_IN=24h
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:8080}
- ADMIN_THORSTEN_PASSWORD=${ADMIN_THORSTEN_PASSWORD}
@ -48,7 +50,9 @@ services:
# - SMTP_PASS=${SMTP_PASS}
# - SMTP_FROM=nachsuche@example.com
# Basis fuer Links in Passwort-Reset-Mails. MUSS den Unterpfad enthalten.
- APP_URL=${APP_URL:-http://localhost:8080/nachsuche}
# Leer lassen, wenn nicht konfiguriert: config/env.js baut den Wert
# dann aus CORS_ORIGIN + Unterpfad und warnt sichtbar darueber.
- APP_URL=${APP_URL:-}
depends_on:
mongo:
condition: service_healthy

View File

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

View File

@ -13,6 +13,11 @@
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;
@ -159,9 +164,6 @@ 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
@ -179,6 +181,13 @@ 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,6 +4,9 @@
.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);
@ -14,6 +17,7 @@
}
.tab-button {
min-height: var(--touch-target);
padding: 0.5rem 1rem;
border: none;
background: transparent;
@ -122,7 +126,7 @@
.settings-section-count {
font-size: 0.8rem;
color: var(--color-text-muted);
background: var(--color-border);
background: var(--color-surface-alt);
border-radius: 999px;
padding: 0.1rem 0.5rem;
}
@ -164,7 +168,7 @@
font-size: 0.75rem;
font-weight: 700;
color: var(--color-text-muted);
background: var(--color-border);
background: var(--color-surface-alt);
border-radius: 50%;
flex-shrink: 0;
}
@ -346,3 +350,16 @@
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">Name der App (wird in der Kopfzeile angezeigt)</label>
<input
<label className="settings-label" htmlFor="settings-appname">Name der App (wird in der Kopfzeile angezeigt)</label>
<input id="settings-appname"
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">{label}</label>
<textarea
<label className="settings-label" htmlFor={`settings-section-${key}`}>{label}</label>
<textarea id={`settings-section-${key}`}
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: var(--color-surface-alt); color: #283593; }
.badge-bulk-update { background: #e8eaf6; color: #283593; }
.badge-bulk-delete { background: #fbe9e7; color: #bf360c; }
.badge-password { background: #fff8e1; color: #9c4e00; }
.badge-default { background: #f5f5f5; color: #616161; }
@ -280,14 +280,25 @@
.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; }
@ -502,13 +513,6 @@
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,8 +228,13 @@ function AuditLogs() {
return (
<div key={log._id} className={`audit-item ${!log.success ? 'audit-item-failed' : ''}`}>
<div className="audit-item-header" onClick={() => hasDetail && toggleExpand(log._id)}
style={{ cursor: hasDetail ? 'pointer' : 'default' }}>
<button
type="button"
className="audit-item-header"
onClick={() => toggleExpand(log._id)}
disabled={!hasDetail}
aria-expanded={hasDetail ? isExpanded : undefined}
>
<span className={`audit-badge ${ACTION_BADGE[log.action] || 'badge-default'}`}>
{ACTION_ICONS[log.action]} {ACTION_LABELS[log.action] || log.action}
</span>
@ -244,7 +249,7 @@ function AuditLogs() {
{hasDetail && (
<span className="expand-toggle">{isExpanded ? '▲' : '▼'}</span>
)}
</div>
</button>
<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>Adresse</label>
<input type="text" value={formData.address}
<label htmlFor="profil-adresse">Adresse</label>
<input id="profil-adresse" type="text" value={formData.address}
onChange={e => setFormData({ ...formData, address: e.target.value })} required />
</div>
<div className="form-group">
<label>Mobilnummer</label>
<input type="tel" value={formData.phone}
<label htmlFor="profil-mobil">Mobilnummer</label>
<input id="profil-mobil" type="tel" value={formData.phone}
onChange={e => setFormData({ ...formData, phone: e.target.value })} required />
</div>
<div className="form-group">
<label>Festnetz (optional)</label>
<input type="tel" value={formData.landline}
<label htmlFor="profil-festnetz">Festnetz (optional)</label>
<input id="profil-festnetz" 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>E-Mail</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
<label htmlFor="login-email">E-Mail</label>
<input id="login-email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
</div>
<div className="form-group">
<label>Passwort</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
<label htmlFor="login-password">Passwort</label>
<input id="login-password" 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>E-Mail</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
<label htmlFor="setpw-email">E-Mail</label>
<input id="setpw-email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
</div>
<div className="form-group">
<label>Einladungs-Token</label>
<input type="text" value={inviteToken} onChange={e => setInviteToken(e.target.value)} required />
<label htmlFor="setpw-token">Einladungs-Token</label>
<input id="setpw-token" type="text" value={inviteToken} onChange={e => setInviteToken(e.target.value)} required />
</div>
<div className="form-group">
<label>Neues Passwort (min. 8 Zeichen)</label>
<input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={8} />
<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} />
</div>
<div className="form-group">
<label>Passwort wiederholen</label>
<input type="password" value={newPassword2} onChange={e => setNewPassword2(e.target.value)} required />
<label htmlFor="setpw-repeat">Passwort wiederholen</label>
<input id="setpw-repeat" 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,14 +2,20 @@ 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';
// Fix für Standard-Marker-Icons in Leaflet
// 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.
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
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',
iconRetinaUrl: markerIcon2x,
iconUrl: markerIcon,
shadowUrl: markerShadow,
});
// Komponente zum Aktualisieren der Kartenansicht

View File

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

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef } from 'react';
import { useConfigContext } from '../../contexts/ConfigContext';
import { searchAddresses } from '../../services/users';
import './UserForm.css';
const formatSuggestionAddress = (addr) => {
@ -125,17 +126,13 @@ const UserForm = ({ user, onSave, onCancel }) => {
searchTimer.current = setTimeout(async () => {
try {
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 : []);
const result = await searchAddresses(value);
setSuggestions(result.data);
setShowSuggestions(true);
} catch {
setSuggestions([]);
}
}, 400);
}, 600);
};
const selectSuggestion = (s) => {
@ -233,7 +230,8 @@ const UserForm = ({ user, onSave, onCancel }) => {
</div>
<div className="form-group">
<label>GPS-Koordinaten</label>
{/* Gruppenueberschrift: gehoert nicht zu einem einzelnen Feld */}
<span className="form-group-heading">GPS-Koordinaten</span>
{gpsAutoSet ? (
<p className="gps-hint gps-hint-success"> GPS automatisch aus Adresse übernommen</p>
) : (

View File

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

View File

@ -6,13 +6,17 @@ NODE_ENV=development
MONGO_URI=mongodb://127.0.0.1:27017/stoeberhunde
# JWT Configuration
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
# Der Name MUSS zu docker-compose.yml passen. Je App ein EIGENES Secret:
# openssl rand -hex 32
STOEBERHUNDE_JWT_SECRET=
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)
@ -23,7 +27,9 @@ 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.
APP_URL=http://localhost:8082/stoeberhunde
# Produktiv die echte oeffentliche URL inkl. Unterpfad eintragen.
# Leer lassen -> wird aus CORS_ORIGIN + "/stoeberhunde" gebildet (mit Warnung).
APP_URL=
# 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' },
{ id: user._id.toString(), role: 'stoeberhundefuehrer', app: config.appName },
config.jwtSecret,
{ expiresIn: '12h' }
);

View File

@ -1,5 +1,5 @@
const User = require('../models/User');
const { geocodeAddress } = require('../utils/geocode');
const { geocodeAddress, searchAddresses } = require('../utils/geocode');
const logger = require('../utils/logger');
const { escapeCell } = require('../utils/csv');
const config = require('../config/env');
@ -729,6 +729,32 @@ 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,
@ -746,5 +772,6 @@ module.exports = {
bulkDeleteUsers,
uploadUserPhoto,
deleteUserPhoto,
getGeocodeByPostalCode
getGeocodeByPostalCode,
searchAddressSuggestions
};

View File

@ -14,6 +14,11 @@ 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,11 +17,13 @@ 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')
.isLength({ min: 6 })
.withMessage('Passwort muss mindestens 6 Zeichen lang sein'),
.withMessage('Passwort ist erforderlich'),
handleValidationErrors
];

View File

@ -21,13 +21,15 @@ const {
bulkDeleteUsers,
uploadUserPhoto,
deleteUserPhoto,
getGeocodeByPostalCode
getGeocodeByPostalCode,
searchAddressSuggestions
} = 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,6 +73,13 @@ 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,7 +32,9 @@ services:
environment:
- NODE_ENV=production
- MONGO_URI=mongodb://stoeberhunde:${MONGO_PASSWORD}@mongo:27017/stoeberhunde?authSource=admin
- JWT_SECRET=${STOEBERHUNDE_JWT_SECRET}
# :? 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_EXPIRES_IN=24h
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:8082}
- ADMIN_THORSTEN_PASSWORD=${ADMIN_THORSTEN_PASSWORD}
@ -46,7 +48,9 @@ services:
# - SMTP_PASS=${SMTP_PASS}
# - SMTP_FROM=stoeberhunde@example.com
# Basis fuer Links in Passwort-Reset-Mails. MUSS den Unterpfad enthalten.
- APP_URL=${APP_URL:-http://localhost:8082/stoeberhunde}
# Leer lassen, wenn nicht konfiguriert: config/env.js baut den Wert
# dann aus CORS_ORIGIN + Unterpfad und warnt sichtbar darueber.
- APP_URL=${APP_URL:-}
depends_on:
mongo:
condition: service_healthy

View File

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

View File

@ -13,6 +13,11 @@
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;
@ -159,9 +164,6 @@ 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
@ -179,6 +181,13 @@ 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,6 +4,9 @@
.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);
@ -14,6 +17,7 @@
}
.tab-button {
min-height: var(--touch-target);
padding: 0.5rem 1rem;
border: none;
background: transparent;
@ -122,7 +126,7 @@
.settings-section-count {
font-size: 0.8rem;
color: var(--color-text-muted);
background: var(--color-border);
background: var(--color-surface-alt);
border-radius: 999px;
padding: 0.1rem 0.5rem;
}
@ -164,7 +168,7 @@
font-size: 0.75rem;
font-weight: 700;
color: var(--color-text-muted);
background: var(--color-border);
background: var(--color-surface-alt);
border-radius: 50%;
flex-shrink: 0;
}
@ -346,3 +350,16 @@
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">Name der App (wird in der Kopfzeile angezeigt)</label>
<input
<label className="settings-label" htmlFor="settings-appname">Name der App (wird in der Kopfzeile angezeigt)</label>
<input id="settings-appname"
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">{label}</label>
<textarea
<label className="settings-label" htmlFor={`settings-section-${key}`}>{label}</label>
<textarea id={`settings-section-${key}`}
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: var(--color-surface-alt); color: #283593; }
.badge-bulk-update { background: #e8eaf6; color: #283593; }
.badge-bulk-delete { background: #fbe9e7; color: #bf360c; }
.badge-password { background: #fff8e1; color: #9c4e00; }
.badge-default { background: #f5f5f5; color: #616161; }
@ -280,14 +280,25 @@
.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; }
@ -502,13 +513,6 @@
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,8 +228,13 @@ function AuditLogs() {
return (
<div key={log._id} className={`audit-item ${!log.success ? 'audit-item-failed' : ''}`}>
<div className="audit-item-header" onClick={() => hasDetail && toggleExpand(log._id)}
style={{ cursor: hasDetail ? 'pointer' : 'default' }}>
<button
type="button"
className="audit-item-header"
onClick={() => toggleExpand(log._id)}
disabled={!hasDetail}
aria-expanded={hasDetail ? isExpanded : undefined}
>
<span className={`audit-badge ${ACTION_BADGE[log.action] || 'badge-default'}`}>
{ACTION_ICONS[log.action]} {ACTION_LABELS[log.action] || log.action}
</span>
@ -244,7 +249,7 @@ function AuditLogs() {
{hasDetail && (
<span className="expand-toggle">{isExpanded ? '▲' : '▼'}</span>
)}
</div>
</button>
<div className="audit-item-body">
<span className="audit-admin">👤 <strong>{log.adminUsername || '—'}</strong></span>

View File

@ -2,14 +2,20 @@ 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';
// Fix für Standard-Marker-Icons in Leaflet
// 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.
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
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',
iconRetinaUrl: markerIcon2x,
iconUrl: markerIcon,
shadowUrl: markerShadow,
});
// 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>Adresse</label>
<input type="text" value={formData.address}
<label htmlFor="profil-adresse">Adresse</label>
<input id="profil-adresse" type="text" value={formData.address}
onChange={e => setFormData({ ...formData, address: e.target.value })} required />
</div>
<div className="form-group">
<label>Mobilnummer</label>
<input type="tel" value={formData.phone}
<label htmlFor="profil-mobil">Mobilnummer</label>
<input id="profil-mobil" type="tel" value={formData.phone}
onChange={e => setFormData({ ...formData, phone: e.target.value })} required />
</div>
<div className="form-group">
<label>Festnetz (optional)</label>
<input type="tel" value={formData.landline}
<label htmlFor="profil-festnetz">Festnetz (optional)</label>
<input id="profil-festnetz" 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>E-Mail</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
<label htmlFor="login-email">E-Mail</label>
<input id="login-email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
</div>
<div className="form-group">
<label>Passwort</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
<label htmlFor="login-password">Passwort</label>
<input id="login-password" 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>E-Mail</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
<label htmlFor="setpw-email">E-Mail</label>
<input id="setpw-email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
</div>
<div className="form-group">
<label>Einladungs-Token</label>
<input type="text" value={inviteToken} onChange={e => setInviteToken(e.target.value)} required />
<label htmlFor="setpw-token">Einladungs-Token</label>
<input id="setpw-token" type="text" value={inviteToken} onChange={e => setInviteToken(e.target.value)} required />
</div>
<div className="form-group">
<label>Neues Passwort (min. 8 Zeichen)</label>
<input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={8} />
<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} />
</div>
<div className="form-group">
<label>Passwort wiederholen</label>
<input type="password" value={newPassword2} onChange={e => setNewPassword2(e.target.value)} required />
<label htmlFor="setpw-repeat">Passwort wiederholen</label>
<input id="setpw-repeat" 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>Typ:</label>
<select
<label htmlFor="filter-typ">Typ:</label>
<select id="filter-typ"
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>Sortierung:</label>
<select
<label htmlFor="filter-sortierung">Sortierung:</label>
<select id="filter-sortierung"
value={filters.sortBy || 'name'}
onChange={(e) => onFilterChange('sortBy', e.target.value)}
className="filter-select"

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef } from 'react';
import { useConfigContext } from '../../contexts/ConfigContext';
import { searchAddresses } from '../../services/users';
import './UserForm.css';
const formatSuggestionAddress = (addr) => {
@ -125,17 +126,13 @@ const UserForm = ({ user, onSave, onCancel }) => {
searchTimer.current = setTimeout(async () => {
try {
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 : []);
const result = await searchAddresses(value);
setSuggestions(result.data);
setShowSuggestions(true);
} catch {
setSuggestions([]);
}
}, 400);
}, 600);
};
const selectSuggestion = (s) => {
@ -233,7 +230,8 @@ const UserForm = ({ user, onSave, onCancel }) => {
</div>
<div className="form-group">
<label>GPS-Koordinaten</label>
{/* Gruppenueberschrift: gehoert nicht zu einem einzelnen Feld */}
<span className="form-group-heading">GPS-Koordinaten</span>
{gpsAutoSet ? (
<p className="gps-hint gps-hint-success"> GPS automatisch aus Adresse übernommen</p>
) : (

View File

@ -214,6 +214,22 @@ 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) => {