Compare commits
4 Commits
dd39a7aff2
...
2950f09485
| Author | SHA1 | Date |
|---|---|---|
|
|
2950f09485 | |
|
|
e2af87a7e5 | |
|
|
87011eee3c | |
|
|
d0e48f3385 |
|
|
@ -17,3 +17,7 @@ portal/ssl/
|
|||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Versehentlich angelegte Log-Verzeichnisse
|
||||
logs/
|
||||
*.log
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue