Compare commits
No commits in common. "2950f094850eb3cec30a8ce49a9ec0e224197f48" and "dd39a7aff213d586a1ff75e25d47ce5726ad3be6" have entirely different histories.
2950f09485
...
dd39a7aff2
|
|
@ -17,7 +17,3 @@ portal/ssl/
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Versehentlich angelegte Log-Verzeichnisse
|
|
||||||
logs/
|
|
||||||
*.log
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -6,17 +6,13 @@ NODE_ENV=development
|
||||||
MONGO_URI=mongodb://127.0.0.1:27017/drohnenfuehrer
|
MONGO_URI=mongodb://127.0.0.1:27017/drohnenfuehrer
|
||||||
|
|
||||||
# JWT Configuration
|
# JWT Configuration
|
||||||
# Der Name MUSS zu docker-compose.yml passen. Je App ein EIGENES Secret:
|
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
||||||
# openssl rand -hex 32
|
|
||||||
DROHNENFUEHRER_JWT_SECRET=
|
|
||||||
JWT_EXPIRES_IN=24h
|
JWT_EXPIRES_IN=24h
|
||||||
|
|
||||||
# Admin Initial Password (used by seed.js if admin doesn't exist)
|
# Admin Initial Password (used by seed.js if admin doesn't exist)
|
||||||
# ADMIN_INITIAL_PASSWORD=secure-password-here
|
# ADMIN_INITIAL_PASSWORD=secure-password-here
|
||||||
|
|
||||||
# CORS Configuration (comma-separated for multiple origins)
|
# 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
|
CORS_ORIGIN=http://localhost:3000
|
||||||
|
|
||||||
# Geocoding Configuration (OpenStreetMap Nominatim)
|
# Geocoding Configuration (OpenStreetMap Nominatim)
|
||||||
|
|
@ -27,9 +23,7 @@ GEOCODE_MIN_DELAY_MS=1100
|
||||||
# Basis-URL der App fuer Links in E-Mails (Passwort-Reset).
|
# Basis-URL der App fuer Links in E-Mails (Passwort-Reset).
|
||||||
# MUSS den Unterpfad enthalten, unter dem die App ausgeliefert wird.
|
# MUSS den Unterpfad enthalten, unter dem die App ausgeliefert wird.
|
||||||
# Ohne diesen Wert wird er aus CORS_ORIGIN + "/drohnenfuehrer" zusammengesetzt.
|
# Ohne diesen Wert wird er aus CORS_ORIGIN + "/drohnenfuehrer" zusammengesetzt.
|
||||||
# Produktiv die echte oeffentliche URL inkl. Unterpfad eintragen.
|
APP_URL=http://localhost:8081/drohnenfuehrer
|
||||||
# Leer lassen -> wird aus CORS_ORIGIN + "/drohnenfuehrer" gebildet (mit Warnung).
|
|
||||||
APP_URL=
|
|
||||||
|
|
||||||
# SMTP fuer Passwort-Reset-Mails (optional).
|
# SMTP fuer Passwort-Reset-Mails (optional).
|
||||||
# Fehlt die Konfiguration, wird der Reset-Link nur ins Log geschrieben.
|
# Fehlt die Konfiguration, wird der Reset-Link nur ins Log geschrieben.
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ const drohnenfuehrerLogin = async (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = jwt.sign(
|
const token = jwt.sign(
|
||||||
{ id: user._id.toString(), role: 'drohnenfuehrer', app: config.appName },
|
{ id: user._id.toString(), role: 'drohnenfuehrer' },
|
||||||
config.jwtSecret,
|
config.jwtSecret,
|
||||||
{ expiresIn: '12h' }
|
{ expiresIn: '12h' }
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
const User = require('../models/User');
|
const User = require('../models/User');
|
||||||
const { geocodeAddress, searchAddresses } = require('../utils/geocode');
|
const { geocodeAddress } = require('../utils/geocode');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { escapeCell } = require('../utils/csv');
|
const { escapeCell } = require('../utils/csv');
|
||||||
const config = require('../config/env');
|
const config = require('../config/env');
|
||||||
|
|
@ -729,32 +729,6 @@ const getGeocodeByPostalCode = async (req, res) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/public/geocode/search?q=...
|
|
||||||
* Freitext-Adresssuche fuer die Autovervollstaendigung im Benutzerformular.
|
|
||||||
* Laeuft ueber den Server, damit Mindestwartezeit, Cache und der von Nominatim
|
|
||||||
* geforderte User-Agent greifen und keine Nutzer-IP beim Dienst landet.
|
|
||||||
*/
|
|
||||||
const searchAddressSuggestions = async (req, res) => {
|
|
||||||
const { q } = req.query;
|
|
||||||
const query = String(q || '').trim();
|
|
||||||
|
|
||||||
if (query.length < 3) {
|
|
||||||
return res.status(400).json({ success: false, message: 'Suchbegriff zu kurz (min. 3 Zeichen)' });
|
|
||||||
}
|
|
||||||
if (query.length > 200) {
|
|
||||||
return res.status(400).json({ success: false, message: 'Suchbegriff zu lang' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const results = await searchAddresses(query, req.query.limit);
|
|
||||||
res.json({ success: true, data: results });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Fehler bei der Adresssuche:', error);
|
|
||||||
res.status(500).json({ success: false, message: 'Fehler bei der Adresssuche' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getAllUsers,
|
getAllUsers,
|
||||||
getUserById,
|
getUserById,
|
||||||
|
|
@ -772,6 +746,5 @@ module.exports = {
|
||||||
bulkDeleteUsers,
|
bulkDeleteUsers,
|
||||||
uploadUserPhoto,
|
uploadUserPhoto,
|
||||||
deleteUserPhoto,
|
deleteUserPhoto,
|
||||||
getGeocodeByPostalCode,
|
getGeocodeByPostalCode
|
||||||
searchAddressSuggestions
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,6 @@ const authenticateDrohnenfuehrer = (req, res, next) => {
|
||||||
if (decoded.role !== 'drohnenfuehrer') {
|
if (decoded.role !== 'drohnenfuehrer') {
|
||||||
return res.status(403).json({ success: false, message: 'Zugriff verweigert' });
|
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;
|
req.drohnenfuehrerUser = decoded;
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,11 @@ const validateLogin = [
|
||||||
.trim()
|
.trim()
|
||||||
.notEmpty()
|
.notEmpty()
|
||||||
.withMessage('Benutzername ist erforderlich'),
|
.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')
|
body('password')
|
||||||
.notEmpty()
|
.notEmpty()
|
||||||
.withMessage('Passwort ist erforderlich'),
|
.withMessage('Passwort ist erforderlich')
|
||||||
|
.isLength({ min: 6 })
|
||||||
|
.withMessage('Passwort muss mindestens 6 Zeichen lang sein'),
|
||||||
handleValidationErrors
|
handleValidationErrors
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,15 +21,13 @@ const {
|
||||||
bulkDeleteUsers,
|
bulkDeleteUsers,
|
||||||
uploadUserPhoto,
|
uploadUserPhoto,
|
||||||
deleteUserPhoto,
|
deleteUserPhoto,
|
||||||
getGeocodeByPostalCode,
|
getGeocodeByPostalCode
|
||||||
searchAddressSuggestions
|
|
||||||
} = require('../controllers/userController');
|
} = require('../controllers/userController');
|
||||||
|
|
||||||
// Public routes
|
// Public routes
|
||||||
router.get('/public/users', getPublicUsers);
|
router.get('/public/users', getPublicUsers);
|
||||||
// Eigenes, engeres Limit: der Endpunkt loest ausgehende Nominatim-Anfragen aus.
|
// Eigenes, engeres Limit: der Endpunkt loest ausgehende Nominatim-Anfragen aus.
|
||||||
router.get('/public/geocode', geocodeLimiter, getGeocodeByPostalCode);
|
router.get('/public/geocode', geocodeLimiter, getGeocodeByPostalCode);
|
||||||
router.get('/public/geocode/search', geocodeLimiter, searchAddressSuggestions);
|
|
||||||
|
|
||||||
// Protected routes (require authentication)
|
// Protected routes (require authentication)
|
||||||
router.get('/users', authenticateToken, getAllUsers);
|
router.get('/users', authenticateToken, getAllUsers);
|
||||||
|
|
|
||||||
|
|
@ -73,13 +73,6 @@ app.use('/api', require('./routes/auditRoutes'));
|
||||||
app.use('/api/config', require('./routes/configRoutes'));
|
app.use('/api/config', require('./routes/configRoutes'));
|
||||||
app.use('/api/drohnenfuehrer', require('./routes/drohnenfuehrerRoutes'));
|
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
|
// Health check with basic system info
|
||||||
app.get('/health', async (req, res) => {
|
app.get('/health', async (req, res) => {
|
||||||
const dbStatus = mongoose.connection.readyState === 1 ? 'connected' : 'disconnected';
|
const dbStatus = mongoose.connection.readyState === 1 ? 'connected' : 'disconnected';
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,7 @@ services:
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- MONGO_URI=mongodb://drohnenfuehrer:${MONGO_PASSWORD}@mongo:27017/drohnenfuehrer?authSource=admin
|
- MONGO_URI=mongodb://drohnenfuehrer:${MONGO_PASSWORD}@mongo:27017/drohnenfuehrer?authSource=admin
|
||||||
# :? statt stiller Leerersetzung — sonst startet das Backend mit
|
- JWT_SECRET=${DROHNENFUEHRER_JWT_SECRET}
|
||||||
# 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
|
- JWT_EXPIRES_IN=24h
|
||||||
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:8081}
|
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:8081}
|
||||||
- ADMIN_THORSTEN_PASSWORD=${ADMIN_THORSTEN_PASSWORD}
|
- ADMIN_THORSTEN_PASSWORD=${ADMIN_THORSTEN_PASSWORD}
|
||||||
|
|
@ -48,9 +46,7 @@ services:
|
||||||
# - SMTP_PASS=${SMTP_PASS}
|
# - SMTP_PASS=${SMTP_PASS}
|
||||||
# - SMTP_FROM=drohnenfuehrer@example.com
|
# - SMTP_FROM=drohnenfuehrer@example.com
|
||||||
# Basis fuer Links in Passwort-Reset-Mails. MUSS den Unterpfad enthalten.
|
# Basis fuer Links in Passwort-Reset-Mails. MUSS den Unterpfad enthalten.
|
||||||
# Leer lassen, wenn nicht konfiguriert: config/env.js baut den Wert
|
- APP_URL=${APP_URL:-http://localhost:8081/drohnenfuehrer}
|
||||||
# dann aus CORS_ORIGIN + Unterpfad und warnt sichtbar darueber.
|
|
||||||
- APP_URL=${APP_URL:-}
|
|
||||||
depends_on:
|
depends_on:
|
||||||
mongo:
|
mongo:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
// Service Worker: nur Offline-Fallback, keine Asset-Caches
|
// Service Worker: nur Offline-Fallback, keine Asset-Caches
|
||||||
// Vite erzeugt content-addressierte Hashes, kein manuelles Caching nötig
|
// Vite erzeugt content-addressierte Hashes, kein manuelles Caching nötig
|
||||||
|
|
||||||
const CACHE_NAME = 'drohnenfuehrer-offline-v2';
|
const CACHE_NAME = 'drohnenfuehrer-offline-v1';
|
||||||
|
|
||||||
self.addEventListener('install', event => {
|
self.addEventListener('install', event => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(CACHE_NAME)
|
caches.open(CACHE_NAME).then(cache => cache.add('offline.html'))
|
||||||
.then(cache => cache.add('offline.html'))
|
|
||||||
.catch(err => console.warn('Offline-Seite konnte nicht gecacht werden:', err))
|
|
||||||
);
|
);
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,6 @@
|
||||||
in beiden Varianten nachgerechnet.
|
in beiden Varianten nachgerechnet.
|
||||||
────────────────────────────────────────────────────────────────────────── */
|
────────────────────────────────────────────────────────────────────────── */
|
||||||
:root {
|
: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-display: Georgia, 'Times New Roman', serif;
|
||||||
--font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
--font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||||
|
|
@ -164,6 +159,9 @@ body {
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
color: var(--color-text);
|
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
|
/* Formularfelder brauchen ausdrücklich Farben: ohne sie nimmt der Browser
|
||||||
|
|
@ -181,13 +179,6 @@ textarea::placeholder {
|
||||||
opacity: 1;
|
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 {
|
h1, h2, h3 {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,6 @@
|
||||||
|
|
||||||
.admin-tabs {
|
.admin-tabs {
|
||||||
display: inline-flex;
|
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;
|
gap: 0;
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
|
|
@ -17,7 +14,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-button {
|
.tab-button {
|
||||||
min-height: var(--touch-target);
|
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
@ -126,7 +122,7 @@
|
||||||
.settings-section-count {
|
.settings-section-count {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
background: var(--color-surface-alt);
|
background: var(--color-border);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.1rem 0.5rem;
|
padding: 0.1rem 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
@ -168,7 +164,7 @@
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
background: var(--color-surface-alt);
|
background: var(--color-border);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -350,16 +346,3 @@
|
||||||
opacity: 1;
|
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>
|
||||||
<div className="settings-section-body">
|
<div className="settings-section-body">
|
||||||
<div className="settings-field">
|
<div className="settings-field">
|
||||||
<label className="settings-label" htmlFor="settings-appname">Name der App (wird in der Kopfzeile angezeigt)</label>
|
<label className="settings-label">Name der App (wird in der Kopfzeile angezeigt)</label>
|
||||||
<input id="settings-appname"
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="settings-input"
|
className="settings-input"
|
||||||
value={config.appName || ''}
|
value={config.appName || ''}
|
||||||
|
|
@ -439,8 +439,8 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
|
||||||
{ key: 'ansprechpartner', label: 'Ansprechpartner und Koordination' }
|
{ key: 'ansprechpartner', label: 'Ansprechpartner und Koordination' }
|
||||||
].map(({ key, label }) => (
|
].map(({ key, label }) => (
|
||||||
<div key={key} className="settings-field">
|
<div key={key} className="settings-field">
|
||||||
<label className="settings-label" htmlFor={`settings-section-${key}`}>{label}</label>
|
<label className="settings-label">{label}</label>
|
||||||
<textarea id={`settings-section-${key}`}
|
<textarea
|
||||||
value={getSectionContent(key)}
|
value={getSectionContent(key)}
|
||||||
onChange={(e) => updateSection(key, e.target.value)}
|
onChange={(e) => updateSection(key, e.target.value)}
|
||||||
className="settings-textarea settings-textarea-lg"
|
className="settings-textarea settings-textarea-lg"
|
||||||
|
|
|
||||||
|
|
@ -231,7 +231,7 @@
|
||||||
.badge-login-failed { background: #ffcdd2; color: #b71c1c; font-weight: 700; }
|
.badge-login-failed { background: #ffcdd2; color: #b71c1c; font-weight: 700; }
|
||||||
.badge-import { background: #e0f7fa; color: #00695c; }
|
.badge-import { background: #e0f7fa; color: #00695c; }
|
||||||
.badge-export { background: #e8f5e9; color: #1b5e20; }
|
.badge-export { background: #e8f5e9; color: #1b5e20; }
|
||||||
.badge-bulk-update { background: #e8eaf6; color: #283593; }
|
.badge-bulk-update { background: var(--color-surface-alt); color: #283593; }
|
||||||
.badge-bulk-delete { background: #fbe9e7; color: #bf360c; }
|
.badge-bulk-delete { background: #fbe9e7; color: #bf360c; }
|
||||||
.badge-password { background: #fff8e1; color: #9c4e00; }
|
.badge-password { background: #fff8e1; color: #9c4e00; }
|
||||||
.badge-default { background: #f5f5f5; color: #616161; }
|
.badge-default { background: #f5f5f5; color: #616161; }
|
||||||
|
|
@ -280,25 +280,14 @@
|
||||||
.audit-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
.audit-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||||
.audit-item-failed { border-left: 4px solid var(--color-danger); }
|
.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 {
|
.audit-item-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
font: inherit;
|
|
||||||
color: inherit;
|
|
||||||
background: var(--color-surface-alt);
|
background: var(--color-surface-alt);
|
||||||
border: none;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
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; }
|
.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; }
|
.expand-toggle { color: var(--color-text-muted); font-size: 11px; cursor: pointer; padding: 0 4px; }
|
||||||
|
|
@ -513,6 +502,13 @@
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
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 {
|
.audit-badge {
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
|
|
|
||||||
|
|
@ -228,13 +228,8 @@ function AuditLogs() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={log._id} className={`audit-item ${!log.success ? 'audit-item-failed' : ''}`}>
|
<div key={log._id} className={`audit-item ${!log.success ? 'audit-item-failed' : ''}`}>
|
||||||
<button
|
<div className="audit-item-header" onClick={() => hasDetail && toggleExpand(log._id)}
|
||||||
type="button"
|
style={{ cursor: hasDetail ? 'pointer' : 'default' }}>
|
||||||
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'}`}>
|
<span className={`audit-badge ${ACTION_BADGE[log.action] || 'badge-default'}`}>
|
||||||
{ACTION_ICONS[log.action]} {ACTION_LABELS[log.action] || log.action}
|
{ACTION_ICONS[log.action]} {ACTION_LABELS[log.action] || log.action}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -249,7 +244,7 @@ function AuditLogs() {
|
||||||
{hasDetail && (
|
{hasDetail && (
|
||||||
<span className="expand-toggle">{isExpanded ? '▲' : '▼'}</span>
|
<span className="expand-toggle">{isExpanded ? '▲' : '▼'}</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
<div className="audit-item-body">
|
<div className="audit-item-body">
|
||||||
<span className="audit-admin">👤 <strong>{log.adminUsername || '—'}</strong></span>
|
<span className="audit-admin">👤 <strong>{log.adminUsername || '—'}</strong></span>
|
||||||
|
|
|
||||||
|
|
@ -115,18 +115,18 @@ const DrohnenfuehrerDashboard = ({ drohnenfuehrerUser, onLogout }) => {
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleSave} className="drohnenfuehrer-edit-form">
|
<form onSubmit={handleSave} className="drohnenfuehrer-edit-form">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="profil-adresse">Adresse</label>
|
<label>Adresse</label>
|
||||||
<input id="profil-adresse" type="text" value={formData.address}
|
<input type="text" value={formData.address}
|
||||||
onChange={e => setFormData({ ...formData, address: e.target.value })} required />
|
onChange={e => setFormData({ ...formData, address: e.target.value })} required />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="profil-mobil">Mobilnummer</label>
|
<label>Mobilnummer</label>
|
||||||
<input id="profil-mobil" type="tel" value={formData.phone}
|
<input type="tel" value={formData.phone}
|
||||||
onChange={e => setFormData({ ...formData, phone: e.target.value })} required />
|
onChange={e => setFormData({ ...formData, phone: e.target.value })} required />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="profil-festnetz">Festnetz (optional)</label>
|
<label>Festnetz (optional)</label>
|
||||||
<input id="profil-festnetz" type="tel" value={formData.landline}
|
<input type="tel" value={formData.landline}
|
||||||
onChange={e => setFormData({ ...formData, landline: e.target.value })} />
|
onChange={e => setFormData({ ...formData, landline: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
<div className="drohnenfuehrer-form-actions">
|
<div className="drohnenfuehrer-form-actions">
|
||||||
|
|
|
||||||
|
|
@ -87,12 +87,12 @@ const DrohnenfuehrerLogin = ({ onLogin }) => {
|
||||||
{mode === 'login' ? (
|
{mode === 'login' ? (
|
||||||
<form onSubmit={handleLogin} className="drohnenfuehrer-form">
|
<form onSubmit={handleLogin} className="drohnenfuehrer-form">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="login-email">E-Mail</label>
|
<label>E-Mail</label>
|
||||||
<input id="login-email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
|
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="login-password">Passwort</label>
|
<label>Passwort</label>
|
||||||
<input id="login-password" type="password" value={password} onChange={e => setPassword(e.target.value)} required />
|
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" className="btn-drohnenfuehrer-primary" disabled={loading}>
|
<button type="submit" className="btn-drohnenfuehrer-primary" disabled={loading}>
|
||||||
{loading ? 'Anmelden...' : 'Anmelden'}
|
{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.
|
Der Admin hat Ihre E-Mail-Adresse hinterlegt. Geben Sie hier Ihre E-Mail, den Einladungs-Token und ein neues Passwort ein.
|
||||||
</p>
|
</p>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="setpw-email">E-Mail</label>
|
<label>E-Mail</label>
|
||||||
<input id="setpw-email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
|
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="setpw-token">Einladungs-Token</label>
|
<label>Einladungs-Token</label>
|
||||||
<input id="setpw-token" type="text" value={inviteToken} onChange={e => setInviteToken(e.target.value)} required />
|
<input type="text" value={inviteToken} onChange={e => setInviteToken(e.target.value)} required />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="setpw-password">Neues Passwort (min. 8 Zeichen)</label>
|
<label>Neues Passwort (min. 8 Zeichen)</label>
|
||||||
<input id="setpw-password" type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={8} />
|
<input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={8} />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="setpw-repeat">Passwort wiederholen</label>
|
<label>Passwort wiederholen</label>
|
||||||
<input id="setpw-repeat" type="password" value={newPassword2} onChange={e => setNewPassword2(e.target.value)} required />
|
<input type="password" value={newPassword2} onChange={e => setNewPassword2(e.target.value)} required />
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" className="btn-drohnenfuehrer-primary" disabled={loading}>
|
<button type="submit" className="btn-drohnenfuehrer-primary" disabled={loading}>
|
||||||
{loading ? 'Wird gesetzt...' : 'Passwort setzen'}
|
{loading ? 'Wird gesetzt...' : 'Passwort setzen'}
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,14 @@ import React, { useEffect } from 'react';
|
||||||
import { MapContainer, TileLayer, Marker, Popup, Circle, useMap } from 'react-leaflet';
|
import { MapContainer, TileLayer, Marker, Popup, Circle, useMap } from 'react-leaflet';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
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';
|
import './MapView.css';
|
||||||
|
|
||||||
// Standard-Marker-Icons aus dem installierten Leaflet-Paket buendeln.
|
// Fix für Standard-Marker-Icons in Leaflet
|
||||||
// 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;
|
delete L.Icon.Default.prototype._getIconUrl;
|
||||||
L.Icon.Default.mergeOptions({
|
L.Icon.Default.mergeOptions({
|
||||||
iconRetinaUrl: markerIcon2x,
|
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
|
||||||
iconUrl: markerIcon,
|
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
|
||||||
shadowUrl: markerShadow,
|
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Komponente zum Aktualisieren der Kartenansicht
|
// Komponente zum Aktualisieren der Kartenansicht
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ const FilterPanel = ({ filters, onFilterChange, showAvailableFilter = true }) =>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="filter-group">
|
<div className="filter-group">
|
||||||
<label htmlFor="filter-typ">Typ:</label>
|
<label>Typ:</label>
|
||||||
<select id="filter-typ"
|
<select
|
||||||
value={filters.type || ''}
|
value={filters.type || ''}
|
||||||
onChange={(e) => onFilterChange('type', e.target.value || null)}
|
onChange={(e) => onFilterChange('type', e.target.value || null)}
|
||||||
className="filter-select"
|
className="filter-select"
|
||||||
|
|
@ -35,8 +35,8 @@ const FilterPanel = ({ filters, onFilterChange, showAvailableFilter = true }) =>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="filter-group">
|
<div className="filter-group">
|
||||||
<label htmlFor="filter-sortierung">Sortierung:</label>
|
<label>Sortierung:</label>
|
||||||
<select id="filter-sortierung"
|
<select
|
||||||
value={filters.sortBy || 'name'}
|
value={filters.sortBy || 'name'}
|
||||||
onChange={(e) => onFilterChange('sortBy', e.target.value)}
|
onChange={(e) => onFilterChange('sortBy', e.target.value)}
|
||||||
className="filter-select"
|
className="filter-select"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { useConfigContext } from '../../contexts/ConfigContext';
|
import { useConfigContext } from '../../contexts/ConfigContext';
|
||||||
import { searchAddresses } from '../../services/users';
|
|
||||||
import './UserForm.css';
|
import './UserForm.css';
|
||||||
|
|
||||||
const formatSuggestionAddress = (addr) => {
|
const formatSuggestionAddress = (addr) => {
|
||||||
|
|
@ -126,13 +125,17 @@ const UserForm = ({ user, onSave, onCancel }) => {
|
||||||
|
|
||||||
searchTimer.current = setTimeout(async () => {
|
searchTimer.current = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await searchAddresses(value);
|
const res = await fetch(
|
||||||
setSuggestions(result.data);
|
`https://nominatim.openstreetmap.org/search?format=json&countrycodes=de&addressdetails=1&limit=5&q=${encodeURIComponent(value)}`,
|
||||||
|
{ headers: { 'User-Agent': 'drohnenfuehrer-app/1.0 (admin@kasimirat.de)' } }
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
setSuggestions(Array.isArray(data) ? data : []);
|
||||||
setShowSuggestions(true);
|
setShowSuggestions(true);
|
||||||
} catch {
|
} catch {
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
}
|
}
|
||||||
}, 600);
|
}, 400);
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectSuggestion = (s) => {
|
const selectSuggestion = (s) => {
|
||||||
|
|
@ -230,8 +233,7 @@ const UserForm = ({ user, onSave, onCancel }) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
{/* Gruppenueberschrift: gehoert nicht zu einem einzelnen Feld */}
|
<label>GPS-Koordinaten</label>
|
||||||
<span className="form-group-heading">GPS-Koordinaten</span>
|
|
||||||
{gpsAutoSet ? (
|
{gpsAutoSet ? (
|
||||||
<p className="gps-hint gps-hint-success">✓ GPS automatisch aus Adresse übernommen</p>
|
<p className="gps-hint gps-hint-success">✓ GPS automatisch aus Adresse übernommen</p>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -214,22 +214,6 @@ export const uploadUserPhoto = async (id, photoDataUrl) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Freitext-Adresssuche ueber das eigene Backend statt direkt bei Nominatim:
|
|
||||||
// dort greifen Mindestwartezeit, Cache und der vorgeschriebene User-Agent,
|
|
||||||
// und es wandert keine Nutzer-IP zu einem fremden Dienst.
|
|
||||||
export const searchAddresses = async (query) => {
|
|
||||||
try {
|
|
||||||
const response = await api.get('/public/geocode/search', { params: { q: query } });
|
|
||||||
return { success: true, data: response.data.data || [] };
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
data: [],
|
|
||||||
message: error.response?.data?.message || 'Fehler bei der Adresssuche'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Einmal-Token, mit dem ein Fuehrer sein erstes Passwort setzt.
|
// Einmal-Token, mit dem ein Fuehrer sein erstes Passwort setzt.
|
||||||
// Laeuft ueber die Admin-Session (Cookie), nicht ueber den Fuehrer-Token.
|
// Laeuft ueber die Admin-Session (Cookie), nicht ueber den Fuehrer-Token.
|
||||||
export const generateInviteToken = async (id) => {
|
export const generateInviteToken = async (id) => {
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,13 @@ NODE_ENV=development
|
||||||
MONGO_URI=mongodb://127.0.0.1:27017/tracking-leaders
|
MONGO_URI=mongodb://127.0.0.1:27017/tracking-leaders
|
||||||
|
|
||||||
# JWT Configuration
|
# JWT Configuration
|
||||||
# Der Name MUSS zu docker-compose.yml passen. Je App ein EIGENES Secret:
|
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
||||||
# openssl rand -hex 32
|
|
||||||
NACHSUCHE_JWT_SECRET=
|
|
||||||
JWT_EXPIRES_IN=24h
|
JWT_EXPIRES_IN=24h
|
||||||
|
|
||||||
# Admin Initial Password (used by seed.js if admin doesn't exist)
|
# Admin Initial Password (used by seed.js if admin doesn't exist)
|
||||||
# ADMIN_INITIAL_PASSWORD=secure-password-here
|
# ADMIN_INITIAL_PASSWORD=secure-password-here
|
||||||
|
|
||||||
# CORS Configuration (comma-separated for multiple origins)
|
# 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
|
CORS_ORIGIN=http://localhost:3000
|
||||||
|
|
||||||
# Geocoding Configuration (OpenStreetMap Nominatim)
|
# Geocoding Configuration (OpenStreetMap Nominatim)
|
||||||
|
|
@ -27,9 +23,7 @@ GEOCODE_MIN_DELAY_MS=1100
|
||||||
# Basis-URL der App fuer Links in E-Mails (Passwort-Reset).
|
# Basis-URL der App fuer Links in E-Mails (Passwort-Reset).
|
||||||
# MUSS den Unterpfad enthalten, unter dem die App ausgeliefert wird.
|
# MUSS den Unterpfad enthalten, unter dem die App ausgeliefert wird.
|
||||||
# Ohne diesen Wert wird er aus CORS_ORIGIN + "/nachsuche" zusammengesetzt.
|
# Ohne diesen Wert wird er aus CORS_ORIGIN + "/nachsuche" zusammengesetzt.
|
||||||
# Produktiv die echte oeffentliche URL inkl. Unterpfad eintragen.
|
APP_URL=http://localhost:8080/nachsuche
|
||||||
# Leer lassen -> wird aus CORS_ORIGIN + "/nachsuche" gebildet (mit Warnung).
|
|
||||||
APP_URL=
|
|
||||||
|
|
||||||
# SMTP fuer Passwort-Reset-Mails (optional).
|
# SMTP fuer Passwort-Reset-Mails (optional).
|
||||||
# Fehlt die Konfiguration, wird der Reset-Link nur ins Log geschrieben.
|
# Fehlt die Konfiguration, wird der Reset-Link nur ins Log geschrieben.
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ const handlerLogin = async (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = jwt.sign(
|
const token = jwt.sign(
|
||||||
{ id: user._id.toString(), role: 'handler', app: config.appName },
|
{ id: user._id.toString(), role: 'handler' },
|
||||||
config.jwtSecret,
|
config.jwtSecret,
|
||||||
{ expiresIn: '12h' }
|
{ expiresIn: '12h' }
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
const User = require('../models/User');
|
const User = require('../models/User');
|
||||||
const { geocodeAddress, searchAddresses } = require('../utils/geocode');
|
const { geocodeAddress } = require('../utils/geocode');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { escapeCell } = require('../utils/csv');
|
const { escapeCell } = require('../utils/csv');
|
||||||
const config = require('../config/env');
|
const config = require('../config/env');
|
||||||
|
|
@ -733,32 +733,6 @@ const getGeocodeByPostalCode = async (req, res) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/public/geocode/search?q=...
|
|
||||||
* Freitext-Adresssuche fuer die Autovervollstaendigung im Benutzerformular.
|
|
||||||
* Laeuft ueber den Server, damit Mindestwartezeit, Cache und der von Nominatim
|
|
||||||
* geforderte User-Agent greifen und keine Nutzer-IP beim Dienst landet.
|
|
||||||
*/
|
|
||||||
const searchAddressSuggestions = async (req, res) => {
|
|
||||||
const { q } = req.query;
|
|
||||||
const query = String(q || '').trim();
|
|
||||||
|
|
||||||
if (query.length < 3) {
|
|
||||||
return res.status(400).json({ success: false, message: 'Suchbegriff zu kurz (min. 3 Zeichen)' });
|
|
||||||
}
|
|
||||||
if (query.length > 200) {
|
|
||||||
return res.status(400).json({ success: false, message: 'Suchbegriff zu lang' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const results = await searchAddresses(query, req.query.limit);
|
|
||||||
res.json({ success: true, data: results });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Fehler bei der Adresssuche:', error);
|
|
||||||
res.status(500).json({ success: false, message: 'Fehler bei der Adresssuche' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getAllUsers,
|
getAllUsers,
|
||||||
getUserById,
|
getUserById,
|
||||||
|
|
@ -776,6 +750,5 @@ module.exports = {
|
||||||
bulkDeleteUsers,
|
bulkDeleteUsers,
|
||||||
uploadUserPhoto,
|
uploadUserPhoto,
|
||||||
deleteUserPhoto,
|
deleteUserPhoto,
|
||||||
getGeocodeByPostalCode,
|
getGeocodeByPostalCode
|
||||||
searchAddressSuggestions
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,6 @@ const authenticateHandler = (req, res, next) => {
|
||||||
if (decoded.role !== 'handler') {
|
if (decoded.role !== 'handler') {
|
||||||
return res.status(403).json({ success: false, message: 'Zugriff verweigert' });
|
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;
|
req.handlerUser = decoded;
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,11 @@ const validateLogin = [
|
||||||
.trim()
|
.trim()
|
||||||
.notEmpty()
|
.notEmpty()
|
||||||
.withMessage('Benutzername ist erforderlich'),
|
.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')
|
body('password')
|
||||||
.notEmpty()
|
.notEmpty()
|
||||||
.withMessage('Passwort ist erforderlich'),
|
.withMessage('Passwort ist erforderlich')
|
||||||
|
.isLength({ min: 6 })
|
||||||
|
.withMessage('Passwort muss mindestens 6 Zeichen lang sein'),
|
||||||
handleValidationErrors
|
handleValidationErrors
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,15 +21,13 @@ const {
|
||||||
bulkDeleteUsers,
|
bulkDeleteUsers,
|
||||||
uploadUserPhoto,
|
uploadUserPhoto,
|
||||||
deleteUserPhoto,
|
deleteUserPhoto,
|
||||||
getGeocodeByPostalCode,
|
getGeocodeByPostalCode
|
||||||
searchAddressSuggestions
|
|
||||||
} = require('../controllers/userController');
|
} = require('../controllers/userController');
|
||||||
|
|
||||||
// Public routes
|
// Public routes
|
||||||
router.get('/public/users', getPublicUsers);
|
router.get('/public/users', getPublicUsers);
|
||||||
// Eigenes, engeres Limit: der Endpunkt loest ausgehende Nominatim-Anfragen aus.
|
// Eigenes, engeres Limit: der Endpunkt loest ausgehende Nominatim-Anfragen aus.
|
||||||
router.get('/public/geocode', geocodeLimiter, getGeocodeByPostalCode);
|
router.get('/public/geocode', geocodeLimiter, getGeocodeByPostalCode);
|
||||||
router.get('/public/geocode/search', geocodeLimiter, searchAddressSuggestions);
|
|
||||||
|
|
||||||
// Protected routes (require authentication)
|
// Protected routes (require authentication)
|
||||||
router.get('/users', authenticateToken, getAllUsers);
|
router.get('/users', authenticateToken, getAllUsers);
|
||||||
|
|
|
||||||
|
|
@ -73,13 +73,6 @@ app.use('/api', require('./routes/auditRoutes'));
|
||||||
app.use('/api/config', require('./routes/configRoutes'));
|
app.use('/api/config', require('./routes/configRoutes'));
|
||||||
app.use('/api/handler', require('./routes/handlerRoutes'));
|
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
|
// Health check with basic system info
|
||||||
app.get('/health', async (req, res) => {
|
app.get('/health', async (req, res) => {
|
||||||
const dbStatus = mongoose.connection.readyState === 1 ? 'connected' : 'disconnected';
|
const dbStatus = mongoose.connection.readyState === 1 ? 'connected' : 'disconnected';
|
||||||
|
|
|
||||||
|
|
@ -161,76 +161,6 @@ const geocodeAddress = async (address) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Nur die Felder, die das Adressformular im Frontend tatsächlich auswertet.
|
|
||||||
// Alles andere aus der Nominatim-Antwort wird verworfen.
|
|
||||||
const ADDRESS_PARTS = [
|
|
||||||
'road', 'house_number', 'postcode',
|
|
||||||
'city', 'town', 'village', 'municipality', 'hamlet',
|
|
||||||
'county', 'state'
|
|
||||||
];
|
|
||||||
|
|
||||||
const trimSuggestion = (hit) => {
|
|
||||||
const address = {};
|
|
||||||
for (const key of ADDRESS_PARTS) {
|
|
||||||
if (hit.address && hit.address[key]) address[key] = hit.address[key];
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
lat: hit.lat,
|
|
||||||
lon: hit.lon,
|
|
||||||
display_name: hit.display_name,
|
|
||||||
address
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Freitext-Adresssuche mit mehreren Treffern — für die Autovervollständigung
|
|
||||||
* im Benutzerformular.
|
|
||||||
*
|
|
||||||
* Läuft bewusst über den Server: vorher hat der Browser Nominatim direkt
|
|
||||||
* angefragt, wodurch weder die Mindestwartezeit noch der vorgeschriebene
|
|
||||||
* User-Agent griffen und die IP jedes Admins beim Dienst landete.
|
|
||||||
*
|
|
||||||
* Teilt sich Wartezeit und Cache mit geocodeAddress; der Cache-Schlüssel ist
|
|
||||||
* mit einem Präfix versehen, damit die Einträge sich nicht überschneiden.
|
|
||||||
*/
|
|
||||||
const searchAddresses = async (query, limit = 5) => {
|
|
||||||
const normalized = (query || '').trim();
|
|
||||||
if (normalized.length < 3) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const count = Math.min(Math.max(parseInt(limit, 10) || 5, 1), 10);
|
|
||||||
const cacheKey = `search:${count}:${normalized.toLowerCase()}`;
|
|
||||||
if (cache.has(cacheKey)) {
|
|
||||||
const hit = cache.get(cacheKey);
|
|
||||||
cache.delete(cacheKey);
|
|
||||||
cache.set(cacheKey, hit);
|
|
||||||
return hit || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const elapsed = Date.now() - lastRequestTime;
|
|
||||||
if (elapsed < config.geocodeMinDelayMs) {
|
|
||||||
await sleep(config.geocodeMinDelayMs - elapsed);
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `${config.geocodeUrl}?format=json&addressdetails=1&limit=${count}`
|
|
||||||
+ `&countrycodes=de&q=${encodeURIComponent(normalized)}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const results = await fetchJson(url, { 'User-Agent': config.geocodeUserAgent });
|
|
||||||
lastRequestTime = Date.now();
|
|
||||||
|
|
||||||
const list = Array.isArray(results) ? results.map(trimSuggestion) : [];
|
|
||||||
rememberInCache(cacheKey, list);
|
|
||||||
return list;
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn('Adresssuche fehlgeschlagen', { query: normalized, error: error.message });
|
|
||||||
lastRequestTime = Date.now();
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
geocodeAddress,
|
geocodeAddress
|
||||||
searchAddresses
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -34,9 +34,7 @@ services:
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- MONGO_URI=mongodb://nachsuche:${MONGO_PASSWORD}@mongo:27017/nachsuche?authSource=admin
|
- MONGO_URI=mongodb://nachsuche:${MONGO_PASSWORD}@mongo:27017/nachsuche?authSource=admin
|
||||||
# :? statt stiller Leerersetzung — sonst startet das Backend mit
|
- JWT_SECRET=${NACHSUCHE_JWT_SECRET}
|
||||||
# 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
|
- JWT_EXPIRES_IN=24h
|
||||||
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:8080}
|
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:8080}
|
||||||
- ADMIN_THORSTEN_PASSWORD=${ADMIN_THORSTEN_PASSWORD}
|
- ADMIN_THORSTEN_PASSWORD=${ADMIN_THORSTEN_PASSWORD}
|
||||||
|
|
@ -50,9 +48,7 @@ services:
|
||||||
# - SMTP_PASS=${SMTP_PASS}
|
# - SMTP_PASS=${SMTP_PASS}
|
||||||
# - SMTP_FROM=nachsuche@example.com
|
# - SMTP_FROM=nachsuche@example.com
|
||||||
# Basis fuer Links in Passwort-Reset-Mails. MUSS den Unterpfad enthalten.
|
# Basis fuer Links in Passwort-Reset-Mails. MUSS den Unterpfad enthalten.
|
||||||
# Leer lassen, wenn nicht konfiguriert: config/env.js baut den Wert
|
- APP_URL=${APP_URL:-http://localhost:8080/nachsuche}
|
||||||
# dann aus CORS_ORIGIN + Unterpfad und warnt sichtbar darueber.
|
|
||||||
- APP_URL=${APP_URL:-}
|
|
||||||
depends_on:
|
depends_on:
|
||||||
mongo:
|
mongo:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
// Service Worker: nur Offline-Fallback, keine Asset-Caches
|
// Service Worker: nur Offline-Fallback, keine Asset-Caches
|
||||||
// Vite erzeugt content-addressierte Hashes, kein manuelles Caching nötig
|
// Vite erzeugt content-addressierte Hashes, kein manuelles Caching nötig
|
||||||
|
|
||||||
const CACHE_NAME = 'nachsuchenfuehrer-offline-v3';
|
const CACHE_NAME = 'nachsuchenfuehrer-offline-v2';
|
||||||
|
|
||||||
self.addEventListener('install', event => {
|
self.addEventListener('install', event => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(CACHE_NAME)
|
caches.open(CACHE_NAME).then(cache => cache.add('offline.html'))
|
||||||
.then(cache => cache.add('offline.html'))
|
|
||||||
.catch(err => console.warn('Offline-Seite konnte nicht gecacht werden:', err))
|
|
||||||
);
|
);
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,6 @@
|
||||||
in beiden Varianten nachgerechnet.
|
in beiden Varianten nachgerechnet.
|
||||||
────────────────────────────────────────────────────────────────────────── */
|
────────────────────────────────────────────────────────────────────────── */
|
||||||
:root {
|
: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-display: Georgia, 'Times New Roman', serif;
|
||||||
--font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
--font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||||
|
|
@ -164,6 +159,9 @@ body {
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
color: var(--color-text);
|
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
|
/* Formularfelder brauchen ausdrücklich Farben: ohne sie nimmt der Browser
|
||||||
|
|
@ -181,13 +179,6 @@ textarea::placeholder {
|
||||||
opacity: 1;
|
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 {
|
h1, h2, h3 {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,6 @@
|
||||||
|
|
||||||
.admin-tabs {
|
.admin-tabs {
|
||||||
display: inline-flex;
|
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;
|
gap: 0;
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
|
|
@ -17,7 +14,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-button {
|
.tab-button {
|
||||||
min-height: var(--touch-target);
|
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
@ -126,7 +122,7 @@
|
||||||
.settings-section-count {
|
.settings-section-count {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
background: var(--color-surface-alt);
|
background: var(--color-border);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.1rem 0.5rem;
|
padding: 0.1rem 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
@ -168,7 +164,7 @@
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
background: var(--color-surface-alt);
|
background: var(--color-border);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -350,16 +346,3 @@
|
||||||
opacity: 1;
|
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>
|
||||||
<div className="settings-section-body">
|
<div className="settings-section-body">
|
||||||
<div className="settings-field">
|
<div className="settings-field">
|
||||||
<label className="settings-label" htmlFor="settings-appname">Name der App (wird in der Kopfzeile angezeigt)</label>
|
<label className="settings-label">Name der App (wird in der Kopfzeile angezeigt)</label>
|
||||||
<input id="settings-appname"
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="settings-input"
|
className="settings-input"
|
||||||
value={config.appName || ''}
|
value={config.appName || ''}
|
||||||
|
|
@ -439,8 +439,8 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
|
||||||
{ key: 'ansprechpartner', label: 'Ansprechpartner und Koordination' }
|
{ key: 'ansprechpartner', label: 'Ansprechpartner und Koordination' }
|
||||||
].map(({ key, label }) => (
|
].map(({ key, label }) => (
|
||||||
<div key={key} className="settings-field">
|
<div key={key} className="settings-field">
|
||||||
<label className="settings-label" htmlFor={`settings-section-${key}`}>{label}</label>
|
<label className="settings-label">{label}</label>
|
||||||
<textarea id={`settings-section-${key}`}
|
<textarea
|
||||||
value={getSectionContent(key)}
|
value={getSectionContent(key)}
|
||||||
onChange={(e) => updateSection(key, e.target.value)}
|
onChange={(e) => updateSection(key, e.target.value)}
|
||||||
className="settings-textarea settings-textarea-lg"
|
className="settings-textarea settings-textarea-lg"
|
||||||
|
|
|
||||||
|
|
@ -231,7 +231,7 @@
|
||||||
.badge-login-failed { background: #ffcdd2; color: #b71c1c; font-weight: 700; }
|
.badge-login-failed { background: #ffcdd2; color: #b71c1c; font-weight: 700; }
|
||||||
.badge-import { background: #e0f7fa; color: #00695c; }
|
.badge-import { background: #e0f7fa; color: #00695c; }
|
||||||
.badge-export { background: #e8f5e9; color: #1b5e20; }
|
.badge-export { background: #e8f5e9; color: #1b5e20; }
|
||||||
.badge-bulk-update { background: #e8eaf6; color: #283593; }
|
.badge-bulk-update { background: var(--color-surface-alt); color: #283593; }
|
||||||
.badge-bulk-delete { background: #fbe9e7; color: #bf360c; }
|
.badge-bulk-delete { background: #fbe9e7; color: #bf360c; }
|
||||||
.badge-password { background: #fff8e1; color: #9c4e00; }
|
.badge-password { background: #fff8e1; color: #9c4e00; }
|
||||||
.badge-default { background: #f5f5f5; color: #616161; }
|
.badge-default { background: #f5f5f5; color: #616161; }
|
||||||
|
|
@ -280,25 +280,14 @@
|
||||||
.audit-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
.audit-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||||
.audit-item-failed { border-left: 4px solid var(--color-danger); }
|
.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 {
|
.audit-item-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
font: inherit;
|
|
||||||
color: inherit;
|
|
||||||
background: var(--color-surface-alt);
|
background: var(--color-surface-alt);
|
||||||
border: none;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
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; }
|
.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; }
|
.expand-toggle { color: var(--color-text-muted); font-size: 11px; cursor: pointer; padding: 0 4px; }
|
||||||
|
|
@ -513,6 +502,13 @@
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
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 {
|
.audit-badge {
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
|
|
|
||||||
|
|
@ -228,13 +228,8 @@ function AuditLogs() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={log._id} className={`audit-item ${!log.success ? 'audit-item-failed' : ''}`}>
|
<div key={log._id} className={`audit-item ${!log.success ? 'audit-item-failed' : ''}`}>
|
||||||
<button
|
<div className="audit-item-header" onClick={() => hasDetail && toggleExpand(log._id)}
|
||||||
type="button"
|
style={{ cursor: hasDetail ? 'pointer' : 'default' }}>
|
||||||
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'}`}>
|
<span className={`audit-badge ${ACTION_BADGE[log.action] || 'badge-default'}`}>
|
||||||
{ACTION_ICONS[log.action]} {ACTION_LABELS[log.action] || log.action}
|
{ACTION_ICONS[log.action]} {ACTION_LABELS[log.action] || log.action}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -249,7 +244,7 @@ function AuditLogs() {
|
||||||
{hasDetail && (
|
{hasDetail && (
|
||||||
<span className="expand-toggle">{isExpanded ? '▲' : '▼'}</span>
|
<span className="expand-toggle">{isExpanded ? '▲' : '▼'}</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
<div className="audit-item-body">
|
<div className="audit-item-body">
|
||||||
<span className="audit-admin">👤 <strong>{log.adminUsername || '—'}</strong></span>
|
<span className="audit-admin">👤 <strong>{log.adminUsername || '—'}</strong></span>
|
||||||
|
|
|
||||||
|
|
@ -115,18 +115,18 @@ const HandlerDashboard = ({ handlerUser, onLogout }) => {
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleSave} className="handler-edit-form">
|
<form onSubmit={handleSave} className="handler-edit-form">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="profil-adresse">Adresse</label>
|
<label>Adresse</label>
|
||||||
<input id="profil-adresse" type="text" value={formData.address}
|
<input type="text" value={formData.address}
|
||||||
onChange={e => setFormData({ ...formData, address: e.target.value })} required />
|
onChange={e => setFormData({ ...formData, address: e.target.value })} required />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="profil-mobil">Mobilnummer</label>
|
<label>Mobilnummer</label>
|
||||||
<input id="profil-mobil" type="tel" value={formData.phone}
|
<input type="tel" value={formData.phone}
|
||||||
onChange={e => setFormData({ ...formData, phone: e.target.value })} required />
|
onChange={e => setFormData({ ...formData, phone: e.target.value })} required />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="profil-festnetz">Festnetz (optional)</label>
|
<label>Festnetz (optional)</label>
|
||||||
<input id="profil-festnetz" type="tel" value={formData.landline}
|
<input type="tel" value={formData.landline}
|
||||||
onChange={e => setFormData({ ...formData, landline: e.target.value })} />
|
onChange={e => setFormData({ ...formData, landline: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
<div className="handler-form-actions">
|
<div className="handler-form-actions">
|
||||||
|
|
|
||||||
|
|
@ -87,12 +87,12 @@ const HandlerLogin = ({ onLogin }) => {
|
||||||
{mode === 'login' ? (
|
{mode === 'login' ? (
|
||||||
<form onSubmit={handleLogin} className="handler-form">
|
<form onSubmit={handleLogin} className="handler-form">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="login-email">E-Mail</label>
|
<label>E-Mail</label>
|
||||||
<input id="login-email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
|
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="login-password">Passwort</label>
|
<label>Passwort</label>
|
||||||
<input id="login-password" type="password" value={password} onChange={e => setPassword(e.target.value)} required />
|
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" className="btn-handler-primary" disabled={loading}>
|
<button type="submit" className="btn-handler-primary" disabled={loading}>
|
||||||
{loading ? 'Anmelden...' : 'Anmelden'}
|
{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.
|
Der Admin hat Ihre E-Mail-Adresse hinterlegt. Geben Sie hier Ihre E-Mail, den Einladungs-Token und ein neues Passwort ein.
|
||||||
</p>
|
</p>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="setpw-email">E-Mail</label>
|
<label>E-Mail</label>
|
||||||
<input id="setpw-email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
|
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="setpw-token">Einladungs-Token</label>
|
<label>Einladungs-Token</label>
|
||||||
<input id="setpw-token" type="text" value={inviteToken} onChange={e => setInviteToken(e.target.value)} required />
|
<input type="text" value={inviteToken} onChange={e => setInviteToken(e.target.value)} required />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="setpw-password">Neues Passwort (min. 8 Zeichen)</label>
|
<label>Neues Passwort (min. 8 Zeichen)</label>
|
||||||
<input id="setpw-password" type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={8} />
|
<input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={8} />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="setpw-repeat">Passwort wiederholen</label>
|
<label>Passwort wiederholen</label>
|
||||||
<input id="setpw-repeat" type="password" value={newPassword2} onChange={e => setNewPassword2(e.target.value)} required />
|
<input type="password" value={newPassword2} onChange={e => setNewPassword2(e.target.value)} required />
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" className="btn-handler-primary" disabled={loading}>
|
<button type="submit" className="btn-handler-primary" disabled={loading}>
|
||||||
{loading ? 'Wird gesetzt...' : 'Passwort setzen'}
|
{loading ? 'Wird gesetzt...' : 'Passwort setzen'}
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,14 @@ import React, { useEffect } from 'react';
|
||||||
import { MapContainer, TileLayer, Marker, Popup, Circle, useMap } from 'react-leaflet';
|
import { MapContainer, TileLayer, Marker, Popup, Circle, useMap } from 'react-leaflet';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
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';
|
import './MapView.css';
|
||||||
|
|
||||||
// Standard-Marker-Icons aus dem installierten Leaflet-Paket buendeln.
|
// Fix für Standard-Marker-Icons in Leaflet
|
||||||
// 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;
|
delete L.Icon.Default.prototype._getIconUrl;
|
||||||
L.Icon.Default.mergeOptions({
|
L.Icon.Default.mergeOptions({
|
||||||
iconRetinaUrl: markerIcon2x,
|
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
|
||||||
iconUrl: markerIcon,
|
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
|
||||||
shadowUrl: markerShadow,
|
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Komponente zum Aktualisieren der Kartenansicht
|
// Komponente zum Aktualisieren der Kartenansicht
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ const FilterPanel = ({ filters, onFilterChange, showAvailableFilter = true }) =>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="filter-group">
|
<div className="filter-group">
|
||||||
<label htmlFor="filter-typ">Typ:</label>
|
<label>Typ:</label>
|
||||||
<select id="filter-typ"
|
<select
|
||||||
value={filters.type || ''}
|
value={filters.type || ''}
|
||||||
onChange={(e) => onFilterChange('type', e.target.value || null)}
|
onChange={(e) => onFilterChange('type', e.target.value || null)}
|
||||||
className="filter-select"
|
className="filter-select"
|
||||||
|
|
@ -35,8 +35,8 @@ const FilterPanel = ({ filters, onFilterChange, showAvailableFilter = true }) =>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="filter-group">
|
<div className="filter-group">
|
||||||
<label htmlFor="filter-sortierung">Sortierung:</label>
|
<label>Sortierung:</label>
|
||||||
<select id="filter-sortierung"
|
<select
|
||||||
value={filters.sortBy || 'name'}
|
value={filters.sortBy || 'name'}
|
||||||
onChange={(e) => onFilterChange('sortBy', e.target.value)}
|
onChange={(e) => onFilterChange('sortBy', e.target.value)}
|
||||||
className="filter-select"
|
className="filter-select"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { useConfigContext } from '../../contexts/ConfigContext';
|
import { useConfigContext } from '../../contexts/ConfigContext';
|
||||||
import { searchAddresses } from '../../services/users';
|
|
||||||
import './UserForm.css';
|
import './UserForm.css';
|
||||||
|
|
||||||
const formatSuggestionAddress = (addr) => {
|
const formatSuggestionAddress = (addr) => {
|
||||||
|
|
@ -126,13 +125,17 @@ const UserForm = ({ user, onSave, onCancel }) => {
|
||||||
|
|
||||||
searchTimer.current = setTimeout(async () => {
|
searchTimer.current = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await searchAddresses(value);
|
const res = await fetch(
|
||||||
setSuggestions(result.data);
|
`https://nominatim.openstreetmap.org/search?format=json&countrycodes=de&addressdetails=1&limit=5&q=${encodeURIComponent(value)}`,
|
||||||
|
{ headers: { 'User-Agent': 'nachsuche-app/1.0 (admin@kasimirat.de)' } }
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
setSuggestions(Array.isArray(data) ? data : []);
|
||||||
setShowSuggestions(true);
|
setShowSuggestions(true);
|
||||||
} catch {
|
} catch {
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
}
|
}
|
||||||
}, 600);
|
}, 400);
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectSuggestion = (s) => {
|
const selectSuggestion = (s) => {
|
||||||
|
|
@ -230,8 +233,7 @@ const UserForm = ({ user, onSave, onCancel }) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
{/* Gruppenueberschrift: gehoert nicht zu einem einzelnen Feld */}
|
<label>GPS-Koordinaten</label>
|
||||||
<span className="form-group-heading">GPS-Koordinaten</span>
|
|
||||||
{gpsAutoSet ? (
|
{gpsAutoSet ? (
|
||||||
<p className="gps-hint gps-hint-success">✓ GPS automatisch aus Adresse übernommen</p>
|
<p className="gps-hint gps-hint-success">✓ GPS automatisch aus Adresse übernommen</p>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -214,22 +214,6 @@ export const uploadUserPhoto = async (id, photoDataUrl) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Freitext-Adresssuche ueber das eigene Backend statt direkt bei Nominatim:
|
|
||||||
// dort greifen Mindestwartezeit, Cache und der vorgeschriebene User-Agent,
|
|
||||||
// und es wandert keine Nutzer-IP zu einem fremden Dienst.
|
|
||||||
export const searchAddresses = async (query) => {
|
|
||||||
try {
|
|
||||||
const response = await api.get('/public/geocode/search', { params: { q: query } });
|
|
||||||
return { success: true, data: response.data.data || [] };
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
data: [],
|
|
||||||
message: error.response?.data?.message || 'Fehler bei der Adresssuche'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Einmal-Token, mit dem ein Fuehrer sein erstes Passwort setzt.
|
// Einmal-Token, mit dem ein Fuehrer sein erstes Passwort setzt.
|
||||||
// Laeuft ueber die Admin-Session (Cookie), nicht ueber den Fuehrer-Token.
|
// Laeuft ueber die Admin-Session (Cookie), nicht ueber den Fuehrer-Token.
|
||||||
export const generateInviteToken = async (id) => {
|
export const generateInviteToken = async (id) => {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
// Service Worker: Offline-Fallback für Portal-Selektor
|
// Service Worker: Offline-Fallback für Portal-Selektor
|
||||||
const CACHE_NAME = 'portal-offline-v3';
|
const CACHE_NAME = 'portal-offline-v2';
|
||||||
|
|
||||||
self.addEventListener('install', event => {
|
self.addEventListener('install', event => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(CACHE_NAME)
|
caches.open(CACHE_NAME).then(cache => cache.add('offline.html'))
|
||||||
.then(cache => cache.add('offline.html'))
|
|
||||||
.catch(err => console.warn('Offline-Seite konnte nicht gecacht werden:', err))
|
|
||||||
);
|
);
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,13 @@ NODE_ENV=development
|
||||||
MONGO_URI=mongodb://127.0.0.1:27017/stoeberhunde
|
MONGO_URI=mongodb://127.0.0.1:27017/stoeberhunde
|
||||||
|
|
||||||
# JWT Configuration
|
# JWT Configuration
|
||||||
# Der Name MUSS zu docker-compose.yml passen. Je App ein EIGENES Secret:
|
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
||||||
# openssl rand -hex 32
|
|
||||||
STOEBERHUNDE_JWT_SECRET=
|
|
||||||
JWT_EXPIRES_IN=24h
|
JWT_EXPIRES_IN=24h
|
||||||
|
|
||||||
# Admin Initial Password (used by seed.js if admin doesn't exist)
|
# Admin Initial Password (used by seed.js if admin doesn't exist)
|
||||||
# ADMIN_INITIAL_PASSWORD=secure-password-here
|
# ADMIN_INITIAL_PASSWORD=secure-password-here
|
||||||
|
|
||||||
# CORS Configuration (comma-separated for multiple origins)
|
# 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
|
CORS_ORIGIN=http://localhost:3000
|
||||||
|
|
||||||
# Geocoding Configuration (OpenStreetMap Nominatim)
|
# Geocoding Configuration (OpenStreetMap Nominatim)
|
||||||
|
|
@ -27,9 +23,7 @@ GEOCODE_MIN_DELAY_MS=1100
|
||||||
# Basis-URL der App fuer Links in E-Mails (Passwort-Reset).
|
# Basis-URL der App fuer Links in E-Mails (Passwort-Reset).
|
||||||
# MUSS den Unterpfad enthalten, unter dem die App ausgeliefert wird.
|
# MUSS den Unterpfad enthalten, unter dem die App ausgeliefert wird.
|
||||||
# Ohne diesen Wert wird er aus CORS_ORIGIN + "/stoeberhunde" zusammengesetzt.
|
# Ohne diesen Wert wird er aus CORS_ORIGIN + "/stoeberhunde" zusammengesetzt.
|
||||||
# Produktiv die echte oeffentliche URL inkl. Unterpfad eintragen.
|
APP_URL=http://localhost:8082/stoeberhunde
|
||||||
# Leer lassen -> wird aus CORS_ORIGIN + "/stoeberhunde" gebildet (mit Warnung).
|
|
||||||
APP_URL=
|
|
||||||
|
|
||||||
# SMTP fuer Passwort-Reset-Mails (optional).
|
# SMTP fuer Passwort-Reset-Mails (optional).
|
||||||
# Fehlt die Konfiguration, wird der Reset-Link nur ins Log geschrieben.
|
# Fehlt die Konfiguration, wird der Reset-Link nur ins Log geschrieben.
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ const stoeberhundefuehrerLogin = async (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = jwt.sign(
|
const token = jwt.sign(
|
||||||
{ id: user._id.toString(), role: 'stoeberhundefuehrer', app: config.appName },
|
{ id: user._id.toString(), role: 'stoeberhundefuehrer' },
|
||||||
config.jwtSecret,
|
config.jwtSecret,
|
||||||
{ expiresIn: '12h' }
|
{ expiresIn: '12h' }
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
const User = require('../models/User');
|
const User = require('../models/User');
|
||||||
const { geocodeAddress, searchAddresses } = require('../utils/geocode');
|
const { geocodeAddress } = require('../utils/geocode');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { escapeCell } = require('../utils/csv');
|
const { escapeCell } = require('../utils/csv');
|
||||||
const config = require('../config/env');
|
const config = require('../config/env');
|
||||||
|
|
@ -729,32 +729,6 @@ const getGeocodeByPostalCode = async (req, res) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/public/geocode/search?q=...
|
|
||||||
* Freitext-Adresssuche fuer die Autovervollstaendigung im Benutzerformular.
|
|
||||||
* Laeuft ueber den Server, damit Mindestwartezeit, Cache und der von Nominatim
|
|
||||||
* geforderte User-Agent greifen und keine Nutzer-IP beim Dienst landet.
|
|
||||||
*/
|
|
||||||
const searchAddressSuggestions = async (req, res) => {
|
|
||||||
const { q } = req.query;
|
|
||||||
const query = String(q || '').trim();
|
|
||||||
|
|
||||||
if (query.length < 3) {
|
|
||||||
return res.status(400).json({ success: false, message: 'Suchbegriff zu kurz (min. 3 Zeichen)' });
|
|
||||||
}
|
|
||||||
if (query.length > 200) {
|
|
||||||
return res.status(400).json({ success: false, message: 'Suchbegriff zu lang' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const results = await searchAddresses(query, req.query.limit);
|
|
||||||
res.json({ success: true, data: results });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Fehler bei der Adresssuche:', error);
|
|
||||||
res.status(500).json({ success: false, message: 'Fehler bei der Adresssuche' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getAllUsers,
|
getAllUsers,
|
||||||
getUserById,
|
getUserById,
|
||||||
|
|
@ -772,6 +746,5 @@ module.exports = {
|
||||||
bulkDeleteUsers,
|
bulkDeleteUsers,
|
||||||
uploadUserPhoto,
|
uploadUserPhoto,
|
||||||
deleteUserPhoto,
|
deleteUserPhoto,
|
||||||
getGeocodeByPostalCode,
|
getGeocodeByPostalCode
|
||||||
searchAddressSuggestions
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,6 @@ const authenticateStoeberhundefuehrer = (req, res, next) => {
|
||||||
if (decoded.role !== 'stoeberhundefuehrer') {
|
if (decoded.role !== 'stoeberhundefuehrer') {
|
||||||
return res.status(403).json({ success: false, message: 'Zugriff verweigert' });
|
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;
|
req.stoeberhundefuehrerUser = decoded;
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,11 @@ const validateLogin = [
|
||||||
.trim()
|
.trim()
|
||||||
.notEmpty()
|
.notEmpty()
|
||||||
.withMessage('Benutzername ist erforderlich'),
|
.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')
|
body('password')
|
||||||
.notEmpty()
|
.notEmpty()
|
||||||
.withMessage('Passwort ist erforderlich'),
|
.withMessage('Passwort ist erforderlich')
|
||||||
|
.isLength({ min: 6 })
|
||||||
|
.withMessage('Passwort muss mindestens 6 Zeichen lang sein'),
|
||||||
handleValidationErrors
|
handleValidationErrors
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,15 +21,13 @@ const {
|
||||||
bulkDeleteUsers,
|
bulkDeleteUsers,
|
||||||
uploadUserPhoto,
|
uploadUserPhoto,
|
||||||
deleteUserPhoto,
|
deleteUserPhoto,
|
||||||
getGeocodeByPostalCode,
|
getGeocodeByPostalCode
|
||||||
searchAddressSuggestions
|
|
||||||
} = require('../controllers/userController');
|
} = require('../controllers/userController');
|
||||||
|
|
||||||
// Public routes
|
// Public routes
|
||||||
router.get('/public/users', getPublicUsers);
|
router.get('/public/users', getPublicUsers);
|
||||||
// Eigenes, engeres Limit: der Endpunkt loest ausgehende Nominatim-Anfragen aus.
|
// Eigenes, engeres Limit: der Endpunkt loest ausgehende Nominatim-Anfragen aus.
|
||||||
router.get('/public/geocode', geocodeLimiter, getGeocodeByPostalCode);
|
router.get('/public/geocode', geocodeLimiter, getGeocodeByPostalCode);
|
||||||
router.get('/public/geocode/search', geocodeLimiter, searchAddressSuggestions);
|
|
||||||
|
|
||||||
// Protected routes (require authentication)
|
// Protected routes (require authentication)
|
||||||
router.get('/users', authenticateToken, getAllUsers);
|
router.get('/users', authenticateToken, getAllUsers);
|
||||||
|
|
|
||||||
|
|
@ -73,13 +73,6 @@ app.use('/api', require('./routes/auditRoutes'));
|
||||||
app.use('/api/config', require('./routes/configRoutes'));
|
app.use('/api/config', require('./routes/configRoutes'));
|
||||||
app.use('/api/stoeberhundefuehrer', require('./routes/stoeberhundefuehrerRoutes'));
|
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
|
// Health check with basic system info
|
||||||
app.get('/health', async (req, res) => {
|
app.get('/health', async (req, res) => {
|
||||||
const dbStatus = mongoose.connection.readyState === 1 ? 'connected' : 'disconnected';
|
const dbStatus = mongoose.connection.readyState === 1 ? 'connected' : 'disconnected';
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,7 @@ services:
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- MONGO_URI=mongodb://stoeberhunde:${MONGO_PASSWORD}@mongo:27017/stoeberhunde?authSource=admin
|
- MONGO_URI=mongodb://stoeberhunde:${MONGO_PASSWORD}@mongo:27017/stoeberhunde?authSource=admin
|
||||||
# :? statt stiller Leerersetzung — sonst startet das Backend mit
|
- JWT_SECRET=${STOEBERHUNDE_JWT_SECRET}
|
||||||
# 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
|
- JWT_EXPIRES_IN=24h
|
||||||
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:8082}
|
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:8082}
|
||||||
- ADMIN_THORSTEN_PASSWORD=${ADMIN_THORSTEN_PASSWORD}
|
- ADMIN_THORSTEN_PASSWORD=${ADMIN_THORSTEN_PASSWORD}
|
||||||
|
|
@ -48,9 +46,7 @@ services:
|
||||||
# - SMTP_PASS=${SMTP_PASS}
|
# - SMTP_PASS=${SMTP_PASS}
|
||||||
# - SMTP_FROM=stoeberhunde@example.com
|
# - SMTP_FROM=stoeberhunde@example.com
|
||||||
# Basis fuer Links in Passwort-Reset-Mails. MUSS den Unterpfad enthalten.
|
# Basis fuer Links in Passwort-Reset-Mails. MUSS den Unterpfad enthalten.
|
||||||
# Leer lassen, wenn nicht konfiguriert: config/env.js baut den Wert
|
- APP_URL=${APP_URL:-http://localhost:8082/stoeberhunde}
|
||||||
# dann aus CORS_ORIGIN + Unterpfad und warnt sichtbar darueber.
|
|
||||||
- APP_URL=${APP_URL:-}
|
|
||||||
depends_on:
|
depends_on:
|
||||||
mongo:
|
mongo:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
// Service Worker: nur Offline-Fallback, keine Asset-Caches
|
// Service Worker: nur Offline-Fallback, keine Asset-Caches
|
||||||
// Vite erzeugt content-addressierte Hashes, kein manuelles Caching nötig
|
// Vite erzeugt content-addressierte Hashes, kein manuelles Caching nötig
|
||||||
|
|
||||||
const CACHE_NAME = 'stoeberhunde-offline-v2';
|
const CACHE_NAME = 'stoeberhunde-offline-v1';
|
||||||
|
|
||||||
self.addEventListener('install', event => {
|
self.addEventListener('install', event => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(CACHE_NAME)
|
caches.open(CACHE_NAME).then(cache => cache.add('offline.html'))
|
||||||
.then(cache => cache.add('offline.html'))
|
|
||||||
.catch(err => console.warn('Offline-Seite konnte nicht gecacht werden:', err))
|
|
||||||
);
|
);
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,6 @@
|
||||||
in beiden Varianten nachgerechnet.
|
in beiden Varianten nachgerechnet.
|
||||||
────────────────────────────────────────────────────────────────────────── */
|
────────────────────────────────────────────────────────────────────────── */
|
||||||
:root {
|
: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-display: Georgia, 'Times New Roman', serif;
|
||||||
--font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
--font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||||
|
|
@ -164,6 +159,9 @@ body {
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
color: var(--color-text);
|
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
|
/* Formularfelder brauchen ausdrücklich Farben: ohne sie nimmt der Browser
|
||||||
|
|
@ -181,13 +179,6 @@ textarea::placeholder {
|
||||||
opacity: 1;
|
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 {
|
h1, h2, h3 {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,6 @@
|
||||||
|
|
||||||
.admin-tabs {
|
.admin-tabs {
|
||||||
display: inline-flex;
|
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;
|
gap: 0;
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
|
|
@ -17,7 +14,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-button {
|
.tab-button {
|
||||||
min-height: var(--touch-target);
|
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
@ -126,7 +122,7 @@
|
||||||
.settings-section-count {
|
.settings-section-count {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
background: var(--color-surface-alt);
|
background: var(--color-border);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.1rem 0.5rem;
|
padding: 0.1rem 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
@ -168,7 +164,7 @@
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
background: var(--color-surface-alt);
|
background: var(--color-border);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -350,16 +346,3 @@
|
||||||
opacity: 1;
|
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>
|
||||||
<div className="settings-section-body">
|
<div className="settings-section-body">
|
||||||
<div className="settings-field">
|
<div className="settings-field">
|
||||||
<label className="settings-label" htmlFor="settings-appname">Name der App (wird in der Kopfzeile angezeigt)</label>
|
<label className="settings-label">Name der App (wird in der Kopfzeile angezeigt)</label>
|
||||||
<input id="settings-appname"
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="settings-input"
|
className="settings-input"
|
||||||
value={config.appName || ''}
|
value={config.appName || ''}
|
||||||
|
|
@ -439,8 +439,8 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
|
||||||
{ key: 'ansprechpartner', label: 'Ansprechpartner und Koordination' }
|
{ key: 'ansprechpartner', label: 'Ansprechpartner und Koordination' }
|
||||||
].map(({ key, label }) => (
|
].map(({ key, label }) => (
|
||||||
<div key={key} className="settings-field">
|
<div key={key} className="settings-field">
|
||||||
<label className="settings-label" htmlFor={`settings-section-${key}`}>{label}</label>
|
<label className="settings-label">{label}</label>
|
||||||
<textarea id={`settings-section-${key}`}
|
<textarea
|
||||||
value={getSectionContent(key)}
|
value={getSectionContent(key)}
|
||||||
onChange={(e) => updateSection(key, e.target.value)}
|
onChange={(e) => updateSection(key, e.target.value)}
|
||||||
className="settings-textarea settings-textarea-lg"
|
className="settings-textarea settings-textarea-lg"
|
||||||
|
|
|
||||||
|
|
@ -231,7 +231,7 @@
|
||||||
.badge-login-failed { background: #ffcdd2; color: #b71c1c; font-weight: 700; }
|
.badge-login-failed { background: #ffcdd2; color: #b71c1c; font-weight: 700; }
|
||||||
.badge-import { background: #e0f7fa; color: #00695c; }
|
.badge-import { background: #e0f7fa; color: #00695c; }
|
||||||
.badge-export { background: #e8f5e9; color: #1b5e20; }
|
.badge-export { background: #e8f5e9; color: #1b5e20; }
|
||||||
.badge-bulk-update { background: #e8eaf6; color: #283593; }
|
.badge-bulk-update { background: var(--color-surface-alt); color: #283593; }
|
||||||
.badge-bulk-delete { background: #fbe9e7; color: #bf360c; }
|
.badge-bulk-delete { background: #fbe9e7; color: #bf360c; }
|
||||||
.badge-password { background: #fff8e1; color: #9c4e00; }
|
.badge-password { background: #fff8e1; color: #9c4e00; }
|
||||||
.badge-default { background: #f5f5f5; color: #616161; }
|
.badge-default { background: #f5f5f5; color: #616161; }
|
||||||
|
|
@ -280,25 +280,14 @@
|
||||||
.audit-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
.audit-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||||
.audit-item-failed { border-left: 4px solid var(--color-danger); }
|
.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 {
|
.audit-item-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
font: inherit;
|
|
||||||
color: inherit;
|
|
||||||
background: var(--color-surface-alt);
|
background: var(--color-surface-alt);
|
||||||
border: none;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
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; }
|
.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; }
|
.expand-toggle { color: var(--color-text-muted); font-size: 11px; cursor: pointer; padding: 0 4px; }
|
||||||
|
|
@ -513,6 +502,13 @@
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
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 {
|
.audit-badge {
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
|
|
|
||||||
|
|
@ -228,13 +228,8 @@ function AuditLogs() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={log._id} className={`audit-item ${!log.success ? 'audit-item-failed' : ''}`}>
|
<div key={log._id} className={`audit-item ${!log.success ? 'audit-item-failed' : ''}`}>
|
||||||
<button
|
<div className="audit-item-header" onClick={() => hasDetail && toggleExpand(log._id)}
|
||||||
type="button"
|
style={{ cursor: hasDetail ? 'pointer' : 'default' }}>
|
||||||
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'}`}>
|
<span className={`audit-badge ${ACTION_BADGE[log.action] || 'badge-default'}`}>
|
||||||
{ACTION_ICONS[log.action]} {ACTION_LABELS[log.action] || log.action}
|
{ACTION_ICONS[log.action]} {ACTION_LABELS[log.action] || log.action}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -249,7 +244,7 @@ function AuditLogs() {
|
||||||
{hasDetail && (
|
{hasDetail && (
|
||||||
<span className="expand-toggle">{isExpanded ? '▲' : '▼'}</span>
|
<span className="expand-toggle">{isExpanded ? '▲' : '▼'}</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
<div className="audit-item-body">
|
<div className="audit-item-body">
|
||||||
<span className="audit-admin">👤 <strong>{log.adminUsername || '—'}</strong></span>
|
<span className="audit-admin">👤 <strong>{log.adminUsername || '—'}</strong></span>
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,14 @@ import React, { useEffect } from 'react';
|
||||||
import { MapContainer, TileLayer, Marker, Popup, Circle, useMap } from 'react-leaflet';
|
import { MapContainer, TileLayer, Marker, Popup, Circle, useMap } from 'react-leaflet';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
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';
|
import './MapView.css';
|
||||||
|
|
||||||
// Standard-Marker-Icons aus dem installierten Leaflet-Paket buendeln.
|
// Fix für Standard-Marker-Icons in Leaflet
|
||||||
// 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;
|
delete L.Icon.Default.prototype._getIconUrl;
|
||||||
L.Icon.Default.mergeOptions({
|
L.Icon.Default.mergeOptions({
|
||||||
iconRetinaUrl: markerIcon2x,
|
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
|
||||||
iconUrl: markerIcon,
|
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
|
||||||
shadowUrl: markerShadow,
|
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Komponente zum Aktualisieren der Kartenansicht
|
// Komponente zum Aktualisieren der Kartenansicht
|
||||||
|
|
|
||||||
|
|
@ -115,18 +115,18 @@ const StoeberhundefuehrerDashboard = ({ stoeberhundefuehrerUser, onLogout }) =>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleSave} className="stoeberhundefuehrer-edit-form">
|
<form onSubmit={handleSave} className="stoeberhundefuehrer-edit-form">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="profil-adresse">Adresse</label>
|
<label>Adresse</label>
|
||||||
<input id="profil-adresse" type="text" value={formData.address}
|
<input type="text" value={formData.address}
|
||||||
onChange={e => setFormData({ ...formData, address: e.target.value })} required />
|
onChange={e => setFormData({ ...formData, address: e.target.value })} required />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="profil-mobil">Mobilnummer</label>
|
<label>Mobilnummer</label>
|
||||||
<input id="profil-mobil" type="tel" value={formData.phone}
|
<input type="tel" value={formData.phone}
|
||||||
onChange={e => setFormData({ ...formData, phone: e.target.value })} required />
|
onChange={e => setFormData({ ...formData, phone: e.target.value })} required />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="profil-festnetz">Festnetz (optional)</label>
|
<label>Festnetz (optional)</label>
|
||||||
<input id="profil-festnetz" type="tel" value={formData.landline}
|
<input type="tel" value={formData.landline}
|
||||||
onChange={e => setFormData({ ...formData, landline: e.target.value })} />
|
onChange={e => setFormData({ ...formData, landline: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
<div className="stoeberhundefuehrer-form-actions">
|
<div className="stoeberhundefuehrer-form-actions">
|
||||||
|
|
|
||||||
|
|
@ -87,12 +87,12 @@ const StoeberhundefuehrerLogin = ({ onLogin }) => {
|
||||||
{mode === 'login' ? (
|
{mode === 'login' ? (
|
||||||
<form onSubmit={handleLogin} className="stoeberhundefuehrer-form">
|
<form onSubmit={handleLogin} className="stoeberhundefuehrer-form">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="login-email">E-Mail</label>
|
<label>E-Mail</label>
|
||||||
<input id="login-email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
|
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="login-password">Passwort</label>
|
<label>Passwort</label>
|
||||||
<input id="login-password" type="password" value={password} onChange={e => setPassword(e.target.value)} required />
|
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" className="btn-stoeberhundefuehrer-primary" disabled={loading}>
|
<button type="submit" className="btn-stoeberhundefuehrer-primary" disabled={loading}>
|
||||||
{loading ? 'Anmelden...' : 'Anmelden'}
|
{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.
|
Der Admin hat Ihre E-Mail-Adresse hinterlegt. Geben Sie hier Ihre E-Mail, den Einladungs-Token und ein neues Passwort ein.
|
||||||
</p>
|
</p>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="setpw-email">E-Mail</label>
|
<label>E-Mail</label>
|
||||||
<input id="setpw-email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
|
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="setpw-token">Einladungs-Token</label>
|
<label>Einladungs-Token</label>
|
||||||
<input id="setpw-token" type="text" value={inviteToken} onChange={e => setInviteToken(e.target.value)} required />
|
<input type="text" value={inviteToken} onChange={e => setInviteToken(e.target.value)} required />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="setpw-password">Neues Passwort (min. 8 Zeichen)</label>
|
<label>Neues Passwort (min. 8 Zeichen)</label>
|
||||||
<input id="setpw-password" type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={8} />
|
<input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={8} />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="setpw-repeat">Passwort wiederholen</label>
|
<label>Passwort wiederholen</label>
|
||||||
<input id="setpw-repeat" type="password" value={newPassword2} onChange={e => setNewPassword2(e.target.value)} required />
|
<input type="password" value={newPassword2} onChange={e => setNewPassword2(e.target.value)} required />
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" className="btn-stoeberhundefuehrer-primary" disabled={loading}>
|
<button type="submit" className="btn-stoeberhundefuehrer-primary" disabled={loading}>
|
||||||
{loading ? 'Wird gesetzt...' : 'Passwort setzen'}
|
{loading ? 'Wird gesetzt...' : 'Passwort setzen'}
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ const FilterPanel = ({ filters, onFilterChange, showAvailableFilter = true }) =>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="filter-group">
|
<div className="filter-group">
|
||||||
<label htmlFor="filter-typ">Typ:</label>
|
<label>Typ:</label>
|
||||||
<select id="filter-typ"
|
<select
|
||||||
value={filters.type || ''}
|
value={filters.type || ''}
|
||||||
onChange={(e) => onFilterChange('type', e.target.value || null)}
|
onChange={(e) => onFilterChange('type', e.target.value || null)}
|
||||||
className="filter-select"
|
className="filter-select"
|
||||||
|
|
@ -35,8 +35,8 @@ const FilterPanel = ({ filters, onFilterChange, showAvailableFilter = true }) =>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="filter-group">
|
<div className="filter-group">
|
||||||
<label htmlFor="filter-sortierung">Sortierung:</label>
|
<label>Sortierung:</label>
|
||||||
<select id="filter-sortierung"
|
<select
|
||||||
value={filters.sortBy || 'name'}
|
value={filters.sortBy || 'name'}
|
||||||
onChange={(e) => onFilterChange('sortBy', e.target.value)}
|
onChange={(e) => onFilterChange('sortBy', e.target.value)}
|
||||||
className="filter-select"
|
className="filter-select"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { useConfigContext } from '../../contexts/ConfigContext';
|
import { useConfigContext } from '../../contexts/ConfigContext';
|
||||||
import { searchAddresses } from '../../services/users';
|
|
||||||
import './UserForm.css';
|
import './UserForm.css';
|
||||||
|
|
||||||
const formatSuggestionAddress = (addr) => {
|
const formatSuggestionAddress = (addr) => {
|
||||||
|
|
@ -126,13 +125,17 @@ const UserForm = ({ user, onSave, onCancel }) => {
|
||||||
|
|
||||||
searchTimer.current = setTimeout(async () => {
|
searchTimer.current = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await searchAddresses(value);
|
const res = await fetch(
|
||||||
setSuggestions(result.data);
|
`https://nominatim.openstreetmap.org/search?format=json&countrycodes=de&addressdetails=1&limit=5&q=${encodeURIComponent(value)}`,
|
||||||
|
{ headers: { 'User-Agent': 'stoeberhunde-app/1.0 (admin@kasimirat.de)' } }
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
setSuggestions(Array.isArray(data) ? data : []);
|
||||||
setShowSuggestions(true);
|
setShowSuggestions(true);
|
||||||
} catch {
|
} catch {
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
}
|
}
|
||||||
}, 600);
|
}, 400);
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectSuggestion = (s) => {
|
const selectSuggestion = (s) => {
|
||||||
|
|
@ -230,8 +233,7 @@ const UserForm = ({ user, onSave, onCancel }) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
{/* Gruppenueberschrift: gehoert nicht zu einem einzelnen Feld */}
|
<label>GPS-Koordinaten</label>
|
||||||
<span className="form-group-heading">GPS-Koordinaten</span>
|
|
||||||
{gpsAutoSet ? (
|
{gpsAutoSet ? (
|
||||||
<p className="gps-hint gps-hint-success">✓ GPS automatisch aus Adresse übernommen</p>
|
<p className="gps-hint gps-hint-success">✓ GPS automatisch aus Adresse übernommen</p>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -214,22 +214,6 @@ export const uploadUserPhoto = async (id, photoDataUrl) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Freitext-Adresssuche ueber das eigene Backend statt direkt bei Nominatim:
|
|
||||||
// dort greifen Mindestwartezeit, Cache und der vorgeschriebene User-Agent,
|
|
||||||
// und es wandert keine Nutzer-IP zu einem fremden Dienst.
|
|
||||||
export const searchAddresses = async (query) => {
|
|
||||||
try {
|
|
||||||
const response = await api.get('/public/geocode/search', { params: { q: query } });
|
|
||||||
return { success: true, data: response.data.data || [] };
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
data: [],
|
|
||||||
message: error.response?.data?.message || 'Fehler bei der Adresssuche'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Einmal-Token, mit dem ein Fuehrer sein erstes Passwort setzt.
|
// Einmal-Token, mit dem ein Fuehrer sein erstes Passwort setzt.
|
||||||
// Laeuft ueber die Admin-Session (Cookie), nicht ueber den Fuehrer-Token.
|
// Laeuft ueber die Admin-Session (Cookie), nicht ueber den Fuehrer-Token.
|
||||||
export const generateInviteToken = async (id) => {
|
export const generateInviteToken = async (id) => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue