Fix app labels: replace Nachsuchen with app-specific names in drohnenfuehrer and stoeberhunde

- drohnenfuehrer: all Nachsuchenführer/Hundeführer labels replaced with Drohnenführer
- stoeberhunde: all Nachsuchenführer/Hundeführer labels replaced with Stöberhundeführer
- Fixed backend config, controllers, logger, env, package.json, seed.js
- Fixed frontend components: Header, UserList, UserForm, PublicUserList, HandlerLogin, AdminPanel, RulesDisplay
- Fixed Dockerfiles (PUBLIC_URL), nginx configs, podman-compose.yml, CONTAINER.md, docs
- Fixed service worker registration path: /sw.js -> ./sw.js
- Fixed portal/index.html
This commit is contained in:
thomas 2026-05-03 08:23:19 +02:00
parent edcce520d4
commit 770b0b1d38
55 changed files with 433 additions and 475 deletions

View File

@ -1,4 +1,4 @@
# 🐳 Container-Betrieb - Tracking Leaders App # 🐳 Container-Betrieb - Drohnenführer App
## Schnellstart mit Podman/Docker ## Schnellstart mit Podman/Docker
@ -62,9 +62,9 @@ podman-compose logs -f frontend
| Service | Container Name | Port | Beschreibung | | Service | Container Name | Port | Beschreibung |
|---------|---------------|------|--------------| |---------|---------------|------|--------------|
| Frontend | tracking-leaders-frontend | 8080 | React App mit Nginx | | Frontend | drohnenfuehrer-frontend | 8080 | React App mit Nginx |
| Backend | tracking-leaders-backend | 5000 | Node.js Express API | | Backend | drohnenfuehrer-backend | 5000 | Node.js Express API |
| MongoDB | tracking-leaders-mongo | 27017 | MongoDB Datenbank | | MongoDB | drohnenfuehrer-mongo | 27017 | MongoDB Datenbank |
## Nützliche Befehle ## Nützliche Befehle
@ -86,15 +86,15 @@ podman-compose down -v
### In Container einsteigen ### In Container einsteigen
```bash ```bash
# Backend # Backend
podman exec -it tracking-leaders-backend sh podman exec -it drohnenfuehrer-backend sh
# MongoDB # MongoDB
podman exec -it tracking-leaders-mongo mongosh podman exec -it drohnenfuehrer-mongo mongosh
``` ```
### Datenbank seeden (manuell) ### Datenbank seeden (manuell)
```bash ```bash
podman exec -it tracking-leaders-backend node seed.js podman exec -it drohnenfuehrer-backend node seed.js
``` ```
### Health Checks prüfen ### Health Checks prüfen
@ -161,7 +161,7 @@ Für Production solltest du einen Reverse Proxy (z.B. Traefik, Nginx) vorschalte
podman volume ls podman volume ls
# Backup erstellen # Backup erstellen
podman run --rm -v tracking-leaders_mongo-data:/data -v $(pwd):/backup alpine tar czf /backup/mongo-backup-$(date +%Y%m%d).tar.gz -C /data . podman run --rm -v drohnenfuehrer_mongo-data:/data -v $(pwd):/backup alpine tar czf /backup/mongo-backup-$(date +%Y%m%d).tar.gz -C /data .
``` ```
## Troubleshooting ## Troubleshooting
@ -188,7 +188,7 @@ curl http://localhost:5000/health
### Datenbank leer ### Datenbank leer
```bash ```bash
# Seed-Skript ausführen # Seed-Skript ausführen
podman exec -it tracking-leaders-backend node seed.js podman exec -it drohnenfuehrer-backend node seed.js
``` ```
### Port bereits belegt ### Port bereits belegt
@ -205,7 +205,7 @@ Beide Dockerfiles nutzen bereits Multi-Stage Builds für minimale Image-Größen
### Image-Größen prüfen ### Image-Größen prüfen
```bash ```bash
podman images | grep tracking-leaders podman images | grep drohnenfuehrer
``` ```
Erwartete Größen: Erwartete Größen:
@ -245,7 +245,7 @@ podman stats
# Alle Services # Alle Services
for svc in backend frontend mongo; do for svc in backend frontend mongo; do
echo "=== $svc ===" echo "=== $svc ==="
podman inspect tracking-leaders-$svc | grep -A5 Health podman inspect drohnenfuehrer-$svc | grep -A5 Health
done done
``` ```
@ -255,10 +255,10 @@ Wenn du von lokalem MongoDB zu Container wechselst:
```bash ```bash
# 1. Export aus lokalem MongoDB # 1. Export aus lokalem MongoDB
mongodump --db tracking-leaders --out ./dump mongodump --db drohnenfuehrer --out ./dump
# 2. Import in Container # 2. Import in Container
podman exec -i tracking-leaders-mongo mongorestore --drop /dump podman exec -i drohnenfuehrer-mongo mongorestore --drop /dump
``` ```
## Weitere Informationen ## Weitere Informationen

View File

@ -3,7 +3,7 @@ PORT=5000
NODE_ENV=development NODE_ENV=development
# Database Configuration # Database Configuration
MONGO_URI=mongodb://127.0.0.1:27017/tracking-leaders MONGO_URI=mongodb://127.0.0.1:27017/drohnenfuehrer
# JWT Configuration # JWT Configuration
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
@ -17,5 +17,5 @@ CORS_ORIGIN=http://localhost:3000
# Geocoding Configuration (OpenStreetMap Nominatim) # Geocoding Configuration (OpenStreetMap Nominatim)
GEOCODE_URL=https://nominatim.openstreetmap.org/search GEOCODE_URL=https://nominatim.openstreetmap.org/search
GEOCODE_USER_AGENT=tracking-leaders-app/1.0 (admin@localhost) GEOCODE_USER_AGENT=drohnenfuehrer-app/1.0 (admin@localhost)
GEOCODE_MIN_DELAY_MS=1100 GEOCODE_MIN_DELAY_MS=1100

View File

@ -2,13 +2,13 @@ require('dotenv').config();
const config = { const config = {
port: process.env.PORT || 5000, port: process.env.PORT || 5000,
mongoUri: process.env.MONGO_URI || 'mongodb://127.0.0.1:27017/tracking-leaders', mongoUri: process.env.MONGO_URI || 'mongodb://127.0.0.1:27017/drohnenfuehrer',
jwtSecret: process.env.JWT_SECRET || 'your-secret-key-change-in-production', jwtSecret: process.env.JWT_SECRET || 'your-secret-key-change-in-production',
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '24h', jwtExpiresIn: process.env.JWT_EXPIRES_IN || '24h',
nodeEnv: process.env.NODE_ENV || 'development', nodeEnv: process.env.NODE_ENV || 'development',
corsOrigin: process.env.CORS_ORIGIN ? process.env.CORS_ORIGIN.split(',') : ['http://localhost:5000'], corsOrigin: process.env.CORS_ORIGIN ? process.env.CORS_ORIGIN.split(',') : ['http://localhost:5000'],
geocodeUrl: process.env.GEOCODE_URL || 'https://nominatim.openstreetmap.org/search', geocodeUrl: process.env.GEOCODE_URL || 'https://nominatim.openstreetmap.org/search',
geocodeUserAgent: process.env.GEOCODE_USER_AGENT || 'tracking-leaders-app/1.0 (admin@localhost)', geocodeUserAgent: process.env.GEOCODE_USER_AGENT || 'drohnenfuehrer-app/1.0 (admin@localhost)',
geocodeMinDelayMs: parseInt(process.env.GEOCODE_MIN_DELAY_MS || '1100', 10) geocodeMinDelayMs: parseInt(process.env.GEOCODE_MIN_DELAY_MS || '1100', 10)
}; };

View File

@ -45,15 +45,11 @@ const login = async (req, res) => {
); );
// Set token in httpOnly cookie (XSS protection) // Set token in httpOnly cookie (XSS protection)
const secureCookie = config.nodeEnv === 'production'
? (req.secure || req.headers['x-forwarded-proto'] === 'https')
: false;
res.cookie('token', token, { res.cookie('token', token, {
httpOnly: true, httpOnly: true, // Not accessible via JavaScript (XSS protection)
secure: secureCookie, secure: false, // Allow over HTTP in development (localhost)
sameSite: 'lax', sameSite: 'lax', // Relaxed CSRF protection (allows cross-port cookies on localhost)
path: '/', path: '/', // Ensure cookie is sent for all app routes, including /drohnenfuehrer/api
maxAge: 24 * 60 * 60 * 1000 // 24 hours in milliseconds maxAge: 24 * 60 * 60 * 1000 // 24 hours in milliseconds
}); });
@ -82,14 +78,10 @@ const logout = async (req, res) => {
const username = req.user?.username || 'unknown'; const username = req.user?.username || 'unknown';
await auditAuth(req, true, username, null); await auditAuth(req, true, username, null);
const secureCookie = config.nodeEnv === 'production'
? (req.secure || req.headers['x-forwarded-proto'] === 'https')
: false;
// Clear the token cookie // Clear the token cookie
res.clearCookie('token', { res.clearCookie('token', {
httpOnly: true, httpOnly: true,
secure: secureCookie, secure: false,
sameSite: 'lax', sameSite: 'lax',
path: '/' path: '/'
}); });
@ -148,11 +140,13 @@ const forgotPassword = async (req, res) => {
}); });
// In production, send email here // In production, send email here
logger.info(`Password reset token generiert für Benutzer: ${username}`); // For development, log the token
logger.info(`Password reset token for ${username}: ${token} (expires in 1 hour)`);
res.json({ res.json({
success: true, success: true,
message: 'Reset-Token wurde generiert. Bitte kontaktieren Sie den Administrator.' message: 'Reset-Token wurde generiert. In der Entwicklungsumgebung finden Sie den Token in den Logs.',
...(config.nodeEnv === 'development' && { token }) // Only in dev!
}); });
} catch (error) { } catch (error) {
logger.error('Fehler bei Passwort-Reset-Anfrage:', error); logger.error('Fehler bei Passwort-Reset-Anfrage:', error);

View File

@ -1,6 +1,5 @@
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const User = require('../models/User'); const User = require('../models/User');
const config = require('../config/env'); const config = require('../config/env');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
@ -50,42 +49,22 @@ const handlerLogin = async (req, res) => {
} }
}; };
// POST /api/handler/set-password — set first password using a one-time invite token // POST /api/handler/set-password — set first password (handler provides token + new password)
// Admin must generate the invite token via POST /api/users/:id/invite-token first // Admin first sets email, then the handler can set their own password
const setHandlerPassword = async (req, res) => { const setHandlerPassword = async (req, res) => {
try { try {
const { email, inviteToken, newPassword } = req.body; const { email, newPassword } = req.body;
if (!email || !inviteToken || !newPassword || newPassword.length < 8) { if (!email || !newPassword || newPassword.length < 6) {
return res.status(400).json({ success: false, message: 'E-Mail, Einladungs-Token und Passwort (min. 8 Zeichen) erforderlich' }); return res.status(400).json({ success: false, message: 'E-Mail und Passwort (min. 6 Zeichen) erforderlich' });
} }
const user = await User.findOne({ email: email.trim().toLowerCase(), deleted: false }) const user = await User.findOne({ email: email.trim().toLowerCase(), deleted: false });
.select('+inviteToken +inviteTokenExpiry'); if (!user) {
return res.status(404).json({ success: false, message: 'Kein Drohnenführer mit dieser E-Mail gefunden' });
// Always return the same error to prevent user enumeration
const invalidMsg = 'Ungültiger oder abgelaufener Einladungs-Token';
if (!user || !user.inviteToken || !user.inviteTokenExpiry) {
return res.status(401).json({ success: false, message: invalidMsg });
} }
if (user.inviteTokenExpiry < new Date()) { user.passwordHash = await bcrypt.hash(newPassword, 12);
user.inviteToken = null;
user.inviteTokenExpiry = null;
await user.save();
return res.status(401).json({ success: false, message: invalidMsg });
}
// Timing-safe comparison to prevent timing attacks
const incoming = Buffer.from(inviteToken.trim());
const stored = Buffer.from(user.inviteToken);
if (incoming.length !== stored.length || !crypto.timingSafeEqual(incoming, stored)) {
return res.status(401).json({ success: false, message: invalidMsg });
}
user.passwordHash = await bcrypt.hash(newPassword, 12);
user.inviteToken = null;
user.inviteTokenExpiry = null;
await user.save(); await user.save();
res.json({ success: true, message: 'Passwort erfolgreich gesetzt' }); res.json({ success: true, message: 'Passwort erfolgreich gesetzt' });
@ -114,7 +93,7 @@ const updateHandlerSelf = async (req, res) => {
); );
if (!user) { if (!user) {
return res.status(404).json({ success: false, message: 'Hundeführer nicht gefunden' }); return res.status(404).json({ success: false, message: 'Drohnenführer nicht gefunden' });
} }
res.json({ success: true, data: user }); res.json({ success: true, data: user });
@ -135,32 +114,4 @@ const getHandlerSelf = async (req, res) => {
} }
}; };
// POST /api/users/:id/invite-token (admin only) — generate a one-time invite token for a handler module.exports = { handlerLogin, setHandlerPassword, updateHandlerSelf, getHandlerSelf };
const generateInviteToken = async (req, res) => {
try {
const user = await User.findOne({ _id: req.params.id, deleted: false })
.select('+inviteToken +inviteTokenExpiry');
if (!user) {
return res.status(404).json({ success: false, message: 'Benutzer nicht gefunden' });
}
if (!user.email) {
return res.status(400).json({ success: false, message: 'Kein E-Mail für diesen Benutzer hinterlegt' });
}
const token = crypto.randomBytes(32).toString('hex');
user.inviteToken = token;
user.inviteTokenExpiry = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
await user.save();
res.json({
success: true,
message: 'Einladungs-Token generiert (gültig 7 Tage)',
data: { inviteToken: token, expiresAt: user.inviteTokenExpiry, email: user.email }
});
} catch (error) {
logger.error('Fehler beim Generieren des Einladungs-Tokens:', error);
res.status(500).json({ success: false, message: 'Serverfehler' });
}
};
module.exports = { handlerLogin, setHandlerPassword, generateInviteToken, updateHandlerSelf, getHandlerSelf };

View File

@ -425,7 +425,7 @@ const exportUsers = async (req, res) => {
res.setHeader('Content-Type', 'text/csv; charset=utf-8'); res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', res.setHeader('Content-Disposition',
`attachment; filename="nachsuchenfuehrer_export_${new Date().toISOString().slice(0, 10)}.csv"`); `attachment; filename="drohnenfuehrer_export_${new Date().toISOString().slice(0, 10)}.csv"`);
return res.send('\uFEFF' + csv); // BOM für korrekte UTF-8-Darstellung in Excel return res.send('\uFEFF' + csv); // BOM für korrekte UTF-8-Darstellung in Excel
} }
@ -442,7 +442,7 @@ const exportUsers = async (req, res) => {
})); }));
res.setHeader('Content-Disposition', res.setHeader('Content-Disposition',
`attachment; filename="nachsuchenfuehrer_export_${new Date().toISOString().slice(0, 10)}.json"`); `attachment; filename="drohnenfuehrer_export_${new Date().toISOString().slice(0, 10)}.json"`);
res.json({ res.json({
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
count: exportData.length, count: exportData.length,

View File

@ -1,7 +1,7 @@
{ {
"name": "tracking-leaders-backend", "name": "drohnenfuehrer-backend",
"version": "1.0.0", "version": "1.0.0",
"description": "Backend for tracking leaders app", "description": "Backend for drohnenfuehrer app",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js", "start": "node server.js",

View File

@ -17,7 +17,7 @@ const seedDatabase = async () => {
const existingUserCount = await User.countDocuments(); const existingUserCount = await User.countDocuments();
if (existingUserCount === 0) { if (existingUserCount === 0) {
await User.insertMany(users); await User.insertMany(users);
logger.info(`${users.length} Nachsuchenführer wurden erstellt`); logger.info(`${users.length} Drohnenführer wurden erstellt`);
} else { } else {
logger.info(` Benutzer bereits vorhanden (${existingUserCount}), überspringe Seeding`); logger.info(` Benutzer bereits vorhanden (${existingUserCount}), überspringe Seeding`);
} }

View File

@ -19,7 +19,7 @@ const logger = winston.createLogger({
winston.format.splat(), winston.format.splat(),
winston.format.json() winston.format.json()
), ),
defaultMeta: { service: 'tracking-leaders-api' }, defaultMeta: { service: 'drohnenfuehrer-api' },
transports: [ transports: [
rotateTransport('error', 'error'), rotateTransport('error', 'error'),
rotateTransport('info', 'combined') rotateTransport('info', 'combined')

View File

@ -16,7 +16,7 @@ Authorization: Bearer <token>
## Öffentliche Endpunkte ## Öffentliche Endpunkte
### GET /api/public/users ### GET /api/public/users
Ruft alle verfügbaren Nachsuchenführer ab. Ruft alle verfügbaren Drohnenführer ab.
**Response:** **Response:**
```json ```json
@ -69,7 +69,7 @@ Admin-Login.
## Geschützte Endpunkte (Admin) ## Geschützte Endpunkte (Admin)
### GET /api/users ### GET /api/users
Ruft alle Nachsuchenführer ab (auch nicht verfügbare). Ruft alle Drohnenführer ab (auch nicht verfügbare).
**Headers:** **Headers:**
- `Authorization: Bearer <token>` - `Authorization: Bearer <token>`
@ -83,7 +83,7 @@ Ruft alle Nachsuchenführer ab (auch nicht verfügbare).
``` ```
### GET /api/users/:id ### GET /api/users/:id
Ruft einen einzelnen Nachsuchenführer ab. Ruft einen einzelnen Drohnenführer ab.
**Headers:** **Headers:**
- `Authorization: Bearer <token>` - `Authorization: Bearer <token>`
@ -97,7 +97,7 @@ Ruft einen einzelnen Nachsuchenführer ab.
``` ```
### POST /api/users ### POST /api/users
Erstellt einen neuen Nachsuchenführer. Erstellt einen neuen Drohnenführer.
**Headers:** **Headers:**
- `Authorization: Bearer <token>` - `Authorization: Bearer <token>`
@ -126,7 +126,7 @@ Erstellt einen neuen Nachsuchenführer.
``` ```
### PUT /api/users/:id ### PUT /api/users/:id
Aktualisiert einen Nachsuchenführer. Aktualisiert einen Drohnenführer.
**Headers:** **Headers:**
- `Authorization: Bearer <token>` - `Authorization: Bearer <token>`
@ -149,7 +149,7 @@ Aktualisiert einen Nachsuchenführer.
``` ```
### DELETE /api/users/:id ### DELETE /api/users/:id
Löscht einen Nachsuchenführer. Löscht einen Drohnenführer.
**Headers:** **Headers:**
- `Authorization: Bearer <token>` - `Authorization: Bearer <token>`
@ -163,7 +163,7 @@ Löscht einen Nachsuchenführer.
``` ```
### PUT /api/users/:id/availability ### PUT /api/users/:id/availability
Aktualisiert die Verfügbarkeit eines Nachsuchenführers. Aktualisiert die Verfügbarkeit eines Drohnenführers.
**Headers:** **Headers:**
- `Authorization: Bearer <token>` - `Authorization: Bearer <token>`
@ -184,7 +184,7 @@ Aktualisiert die Verfügbarkeit eines Nachsuchenführers.
``` ```
### PUT /api/users/:id/gps ### PUT /api/users/:id/gps
Aktualisiert die GPS-Koordinaten eines Nachsuchenführers. Aktualisiert die GPS-Koordinaten eines Drohnenführers.
**Headers:** **Headers:**
- `Authorization: Bearer <token>` - `Authorization: Bearer <token>`
@ -206,7 +206,7 @@ Aktualisiert die GPS-Koordinaten eines Nachsuchenführers.
``` ```
### GET /api/users/export ### GET /api/users/export
Exportiert alle Nachsuchenführer. Exportiert alle Drohnenführer.
**Headers:** **Headers:**
- `Authorization: Bearer <token>` - `Authorization: Bearer <token>`

View File

@ -2,7 +2,7 @@
## Übersicht ## Übersicht
Die Nachsuchenführer-App besteht aus einem Node.js/Express-Backend und einem React-Frontend. Die Drohnenführer-App besteht aus einem Node.js/Express-Backend und einem React-Frontend.
## Backend-Architektur ## Backend-Architektur
@ -23,7 +23,7 @@ backend/
│ └── validator.js # Input-Validierung │ └── validator.js # Input-Validierung
├── models/ ├── models/
│ ├── Admin.js # Admin-Modell │ ├── Admin.js # Admin-Modell
│ └── User.js # Nachsuchenführer-Modell │ └── User.js # Drohnenführer-Modell
├── routes/ ├── routes/
│ ├── authRoutes.js # Authentifizierungs-Routes │ ├── authRoutes.js # Authentifizierungs-Routes
│ └── userRoutes.js # Benutzer-Routes │ └── userRoutes.js # Benutzer-Routes
@ -35,7 +35,7 @@ backend/
### Datenmodell ### Datenmodell
#### User (Nachsuchenführer) #### User (Drohnenführer)
- `name`: String (erforderlich) - `name`: String (erforderlich)
- `address`: String (erforderlich) - `address`: String (erforderlich)
- `phone`: String (erforderlich) - `phone`: String (erforderlich)

View File

@ -8,7 +8,7 @@ RUN npm install
COPY . . COPY . .
ARG PUBLIC_URL=/nachsuche/ ARG PUBLIC_URL=/drohnenfuehrer/
ENV PUBLIC_URL=$PUBLIC_URL ENV PUBLIC_URL=$PUBLIC_URL
ARG REACT_APP_API_URL= ARG REACT_APP_API_URL=
ENV REACT_APP_API_URL=$REACT_APP_API_URL ENV REACT_APP_API_URL=$REACT_APP_API_URL

View File

@ -1,5 +1,5 @@
{ {
"name": "tracking-leaders-frontend", "name": "drohnenfuehrer-frontend",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {

View File

@ -8,14 +8,14 @@
<meta name="theme-color" content="#2d6a2d" /> <meta name="theme-color" content="#2d6a2d" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" /> <meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="NSS Heidekreis" /> <meta name="apple-mobile-web-app-title" content="Drohnenführer Heidekreis" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<meta <meta
name="description" name="description"
content="Nachsuchenstation Heidekreis Übersicht der Nachsuchenführer" content="Drohnenführer Heidekreis Übersicht der Drohnenführer"
/> />
<title>NSS Heidekreis</title> <title>Drohnenführer Heidekreis</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offline - Nachsuchenführer</title> <title>Offline - Drohnenführer</title>
<style> <style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; } body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
h1 { color: #333; } h1 { color: #333; }

View File

@ -75,7 +75,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
if (result.warning) { if (result.warning) {
showNotification('warning', `Erstellt GPS konnte nicht automatisch ermittelt werden. Bitte manuell setzen.`); showNotification('warning', `Erstellt GPS konnte nicht automatisch ermittelt werden. Bitte manuell setzen.`);
} else { } else {
showNotification('success', 'Nachsuchenführer erfolgreich erstellt (GPS gesetzt)'); showNotification('success', 'Drohnenführer erfolgreich erstellt (GPS gesetzt)');
} }
onRefetch(); onRefetch();
} else { } else {
@ -89,7 +89,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
if (result.warning) { if (result.warning) {
showNotification('warning', `Gespeichert GPS konnte nicht automatisch ermittelt werden. Bitte manuell setzen.`); showNotification('warning', `Gespeichert GPS konnte nicht automatisch ermittelt werden. Bitte manuell setzen.`);
} else { } else {
showNotification('success', 'Nachsuchenführer erfolgreich aktualisiert'); showNotification('success', 'Drohnenführer erfolgreich aktualisiert');
} }
onRefetch(); onRefetch();
} else { } else {
@ -100,7 +100,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
const handleUserDelete = async (id) => { const handleUserDelete = async (id) => {
const result = await deleteUser(id); const result = await deleteUser(id);
if (result.success) { if (result.success) {
showNotification('success', 'Nachsuchenführer erfolgreich gelöscht'); showNotification('success', 'Drohnenführer erfolgreich gelöscht');
onRefetch(); onRefetch();
} else { } else {
showNotification('error', result.message || 'Fehler beim Löschen'); showNotification('error', result.message || 'Fehler beim Löschen');
@ -149,7 +149,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = `nachsuche-einstellungen-${new Date().toISOString().slice(0, 10)}.json`; a.download = `drohnenfuehrer-einstellungen-${new Date().toISOString().slice(0, 10)}.json`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
showNotification('success', 'Einstellungen exportiert'); showNotification('success', 'Einstellungen exportiert');
@ -366,7 +366,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
className="settings-input" className="settings-input"
value={config.appName || ''} value={config.appName || ''}
onChange={(e) => setConfig({ ...config, appName: e.target.value })} onChange={(e) => setConfig({ ...config, appName: e.target.value })}
placeholder="z.B. Nachsuchenstation Heidekreis" placeholder="z.B. Drohnenführer Heidekreis"
maxLength={80} maxLength={80}
/> />
</div> </div>
@ -423,7 +423,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
{!collapsed.sections && ( {!collapsed.sections && (
<div className="settings-section-body"> <div className="settings-section-body">
{[ {[
{ key: 'ueber-uns', label: 'Nachsuchenstation Heidekreis' }, { key: 'ueber-uns', label: 'Drohnenführer Heidekreis' },
{ key: 'anwendung', label: 'Anwendung der App' }, { key: 'anwendung', label: 'Anwendung der App' },
{ key: 'ansprechpartner', label: 'Ansprechpartner und Koordination' } { key: 'ansprechpartner', label: 'Ansprechpartner und Koordination' }
].map(({ key, label }) => ( ].map(({ key, label }) => (
@ -487,11 +487,11 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
)} )}
</div> </div>
{/* Hunderassen / Benutzertypen */} {/* Einsatzgebiete / Benutzertypen */}
<div className="settings-section"> <div className="settings-section">
<button type="button" className="settings-section-header" onClick={() => toggleSection('userTypes')}> <button type="button" className="settings-section-header" onClick={() => toggleSection('userTypes')}>
<h4 className="settings-heading">Hunderassen</h4> <h4 className="settings-heading">Einsatzgebiete</h4>
<span className="settings-section-count">{config.userTypes.length} Rassen</span> <span className="settings-section-count">{config.userTypes.length} Einträge</span>
<span className={`settings-chevron ${collapsed.userTypes ? 'collapsed' : ''}`}></span> <span className={`settings-chevron ${collapsed.userTypes ? 'collapsed' : ''}`}></span>
</button> </button>
{!collapsed.userTypes && ( {!collapsed.userTypes && (
@ -504,7 +504,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
<div key={index} className="settings-row"> <div key={index} className="settings-row">
<input <input
type="text" type="text"
placeholder="z.B. HS" placeholder="z.B. DF"
value={type.code} value={type.code}
onChange={(e) => { onChange={(e) => {
const newTypes = [...config.userTypes]; const newTypes = [...config.userTypes];
@ -528,7 +528,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
<button <button
type="button" type="button"
className="btn btn-icon btn-danger" className="btn btn-icon btn-danger"
title="Hunderasse entfernen" title="Einsatzgebiet entfernen"
onClick={() => { onClick={() => {
const newTypes = config.userTypes.filter((_, i) => i !== index); const newTypes = config.userTypes.filter((_, i) => i !== index);
setConfig({ ...config, userTypes: newTypes }); setConfig({ ...config, userTypes: newTypes });
@ -543,7 +543,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
className="btn btn-secondary btn-sm" className="btn btn-secondary btn-sm"
onClick={() => setConfig({ ...config, userTypes: [...config.userTypes, { code: '', label: '' }] })} onClick={() => setConfig({ ...config, userTypes: [...config.userTypes, { code: '', label: '' }] })}
> >
+ Hunderasse hinzufügen + Einsatzgebiet hinzufügen
</button> </button>
</div> </div>
)} )}

View File

@ -33,7 +33,7 @@ const Header = ({ isAdmin, onLogout, currentView, onViewChange }) => {
className={`nav-button ${currentView === 'handler-login' || currentView === 'handler-dashboard' ? 'active' : ''}`} className={`nav-button ${currentView === 'handler-login' || currentView === 'handler-dashboard' ? 'active' : ''}`}
onClick={() => onViewChange('handler-login')} onClick={() => onViewChange('handler-login')}
> >
Hundeführer Login Drohnenführer-Login
</button> </button>
<button <button
className={`nav-button ${currentView === 'login' ? 'active' : ''}`} className={`nav-button ${currentView === 'login' ? 'active' : ''}`}

View File

@ -59,7 +59,7 @@ const HandlerLogin = ({ onLogin }) => {
return ( return (
<div className="handler-login-container"> <div className="handler-login-container">
<div className="handler-login-card"> <div className="handler-login-card">
<h2>Hundeführer-Login</h2> <h2>Drohnenführer-Login</h2>
<p className="handler-login-subtitle"> <p className="handler-login-subtitle">
Melden Sie sich an, um Ihre Verfügbarkeit und Kontaktdaten zu verwalten. Melden Sie sich an, um Ihre Verfügbarkeit und Kontaktdaten zu verwalten.
</p> </p>

View File

@ -77,7 +77,7 @@ const PublicUserList = ({ users, loading }) => {
`https://nominatim.openstreetmap.org/search?postalcode=${encodeURIComponent(postalCode)}&country=Germany&format=json&limit=1`, `https://nominatim.openstreetmap.org/search?postalcode=${encodeURIComponent(postalCode)}&country=Germany&format=json&limit=1`,
{ {
headers: { headers: {
'User-Agent': 'tracking-leaders-app/1.0' 'User-Agent': 'drohnenfuehrer-app/1.0'
} }
} }
); );
@ -108,7 +108,7 @@ const PublicUserList = ({ users, loading }) => {
const { list: filteredAndSortedUsers, missingGpsCount, allUsersWithGPS } = useMemo(() => { const { list: filteredAndSortedUsers, missingGpsCount, allUsersWithGPS } = useMemo(() => {
let filtered = [...users]; let filtered = [...users];
// Nur verfügbare Nachsuchenführer // Nur verfügbare Drohnenführer
filtered = filtered.filter(user => user.available); filtered = filtered.filter(user => user.available);
// Type filter // Type filter
@ -162,7 +162,7 @@ const PublicUserList = ({ users, loading }) => {
if (!users || users.length === 0) { if (!users || users.length === 0) {
return ( return (
<div className="no-users"> <div className="no-users">
<p>Derzeit sind keine Nachsuchenführer verfügbar.</p> <p>Derzeit sind keine Drohnenführer verfügbar.</p>
</div> </div>
); );
} }
@ -170,7 +170,7 @@ const PublicUserList = ({ users, loading }) => {
return ( return (
<div className="public-user-list"> <div className="public-user-list">
<div className="list-header"> <div className="list-header">
<h2>Verfügbare Nachsuchenführer</h2> <h2>Verfügbare Drohnenführer</h2>
<button <button
className="toggle-map-button" className="toggle-map-button"
onClick={() => setShowMap(!showMap)} onClick={() => setShowMap(!showMap)}
@ -271,7 +271,7 @@ const PublicUserList = ({ users, loading }) => {
)} )}
{filteredAndSortedUsers.length === 0 ? ( {filteredAndSortedUsers.length === 0 ? (
<div className="no-users"> <div className="no-users">
<p>Keine Nachsuchenführer im gewählten Radius gefunden.</p> <p>Keine Drohnenführer im gewählten Radius gefunden.</p>
</div> </div>
) : ( ) : (
<div className="user-grid"> <div className="user-grid">
@ -283,7 +283,7 @@ const PublicUserList = ({ users, loading }) => {
</div> </div>
)} )}
<h3 className="user-name">{user.name}</h3> <h3 className="user-name">{user.name}</h3>
<p className="user-role">Nachsuchenführer</p> <p className="user-role">Drohnenführer</p>
<div className="user-info"> <div className="user-info">
<p className="user-address">📍 {user.address}</p> <p className="user-address">📍 {user.address}</p>
<a href={`tel:${user.phone}`} className="user-phone"> <a href={`tel:${user.phone}`} className="user-phone">

View File

@ -3,7 +3,7 @@ import { useConfigContext } from '../../contexts/ConfigContext';
import './RulesDisplay.css'; import './RulesDisplay.css';
const TABS = [ const TABS = [
{ key: 'ueber-uns', label: 'Nachsuchenstation Heidekreis' }, { key: 'ueber-uns', label: 'Drohnenführer Heidekreis' },
{ key: 'anwendung', label: 'Anwendung der App' }, { key: 'anwendung', label: 'Anwendung der App' },
{ key: 'regeln', label: 'Verhaltensregeln' }, { key: 'regeln', label: 'Verhaltensregeln' },
{ key: 'ansprechpartner', label: 'Ansprechpartner' } { key: 'ansprechpartner', label: 'Ansprechpartner' }
@ -39,7 +39,7 @@ const RulesDisplay = () => {
<div className="allgemeines-content"> <div className="allgemeines-content">
{activeTab === 'ueber-uns' && ( {activeTab === 'ueber-uns' && (
<div className="section-text"> <div className="section-text">
<h2>Die Nachsuchenstation Heidekreis</h2> <h2>Die Drohnenführer Heidekreis</h2>
<div className="section-body"> <div className="section-body">
{getSection('ueber-uns').split('\n').map((line, i) => ( {getSection('ueber-uns').split('\n').map((line, i) => (
<p key={i}>{line || '\u00a0'}</p> <p key={i}>{line || '\u00a0'}</p>

View File

@ -127,7 +127,7 @@ const UserForm = ({ user, onSave, onCancel }) => {
try { try {
const res = await fetch( const res = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&countrycodes=de&addressdetails=1&limit=5&q=${encodeURIComponent(value)}`, `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)' } } { headers: { 'User-Agent': 'drohnenfuehrer-app/1.0 (admin@kasimirat.de)' } }
); );
const data = await res.json(); const data = await res.json();
setSuggestions(Array.isArray(data) ? data : []); setSuggestions(Array.isArray(data) ? data : []);
@ -152,7 +152,7 @@ const UserForm = ({ user, onSave, onCancel }) => {
return ( return (
<form className="user-form" onSubmit={handleSubmit}> <form className="user-form" onSubmit={handleSubmit}>
<h3>{user ? 'Nachsuchenführer bearbeiten' : 'Neuen Nachsuchenführer erstellen'}</h3> <h3>{user ? 'Drohnenführer bearbeiten' : 'Neuen Drohnenführer erstellen'}</h3>
<div className="form-group"> <div className="form-group">
<label htmlFor="name">Name <span className="required">*</span></label> <label htmlFor="name">Name <span className="required">*</span></label>
@ -208,19 +208,19 @@ const UserForm = ({ user, onSave, onCancel }) => {
</div> </div>
<div className="form-group"> <div className="form-group">
<label htmlFor="email">E-Mail (für Hundeführer-Login)</label> <label htmlFor="email">E-Mail (für Drohnenführer-Login)</label>
<input type="email" id="email" name="email" value={formData.email} onChange={handleChange} <input type="email" id="email" name="email" value={formData.email} onChange={handleChange}
placeholder="optional" /> placeholder="optional" />
</div> </div>
<div className="form-group"> <div className="form-group">
<label htmlFor="type">Hunderasse <span className="required">*</span></label> <label htmlFor="type">Einsatzgebiet <span className="required">*</span></label>
<select id="type" name="type" value={formData.type} onChange={handleChange} required> <select id="type" name="type" value={formData.type} onChange={handleChange} required>
{typeOptions.length > 0 {typeOptions.length > 0
? typeOptions.map(code => ( ? typeOptions.map(code => (
<option key={code} value={code}>{userTypeLabels[code] || code}</option> <option key={code} value={code}>{userTypeLabels[code] || code}</option>
)) ))
: <option value="HS">Hannoverscher Schweißhund</option> : <option value="DF">Drohnenführer</option>
} }
</select> </select>
</div> </div>

View File

@ -61,7 +61,7 @@ const UserList = ({ users, loading, error, onAvailabilityToggle, onGPSUpdate, on
}, [users, searchTerm, filters]); }, [users, searchTerm, filters]);
if (loading) { if (loading) {
return <Loading message="Lädt Nachsuchenführer..." />; return <Loading message="Lädt Drohnenführer..." />;
} }
if (error) { if (error) {
@ -98,7 +98,7 @@ const UserList = ({ users, loading, error, onAvailabilityToggle, onGPSUpdate, on
return ( return (
<div className="user-list-admin"> <div className="user-list-admin">
<div className="list-header-admin"> <div className="list-header-admin">
<h2>Alle Nachsuchenführer</h2> <h2>Alle Drohnenführer</h2>
<div className="header-actions"> <div className="header-actions">
<div className="results-count"> <div className="results-count">
{filteredAndSortedUsers.length} von {users.length} Führern {filteredAndSortedUsers.length} von {users.length} Führern
@ -111,7 +111,7 @@ const UserList = ({ users, loading, error, onAvailabilityToggle, onGPSUpdate, on
setEditingUser(null); setEditingUser(null);
}} }}
> >
+ Neuer Nachsuchenführer + Neuer Drohnenführer
</button> </button>
)} )}
</div> </div>
@ -131,7 +131,7 @@ const UserList = ({ users, loading, error, onAvailabilityToggle, onGPSUpdate, on
<FilterPanel filters={filters} onFilterChange={handleFilterChange} /> <FilterPanel filters={filters} onFilterChange={handleFilterChange} />
{filteredAndSortedUsers.length === 0 ? ( {filteredAndSortedUsers.length === 0 ? (
<div className="no-users"> <div className="no-users">
<p>Keine Nachsuchenführer gefunden.</p> <p>Keine Drohnenführer gefunden.</p>
</div> </div>
) : ( ) : (
<div className="user-grid-admin"> <div className="user-grid-admin">

View File

@ -13,7 +13,7 @@ root.render(
// Register service worker // Register service worker
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
window.addEventListener('load', () => { window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js') navigator.serviceWorker.register('./sw.js')
.then(registration => { .then(registration => {
console.log('SW registered: ', registration); console.log('SW registered: ', registration);
}) })

View File

@ -115,7 +115,7 @@ export const updateGPS = async (id, lat, lng) => {
export const exportUsers = async (format = 'csv') => { export const exportUsers = async (format = 'csv') => {
try { try {
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
const filename = `nachsuchenfuehrer_export_${today}.${format}`; const filename = `drohnenfuehrer_export_${today}.${format}`;
const response = await api.get(`/users/export?format=${format}`, { const response = await api.get(`/users/export?format=${format}`, {
responseType: 'blob' responseType: 'blob'

View File

@ -1,10 +1,11 @@
// Use configured API URL when set, otherwise auto-detect common subpath deployment // Use configured API URL when set, otherwise auto-detect common subpath deployment (/drohnenfuehrer)
const detectRuntimeBasePath = () => { const detectRuntimeBasePath = () => {
if (typeof window === 'undefined') return ''; if (typeof window === 'undefined') return '';
const path = window.location.pathname || ''; const path = window.location.pathname || '';
const knownBasePaths = ['/nachsuche', '/drohnenfuehrer', '/stoeberhunde']; if (path === '/drohnenfuehrer' || path.startsWith('/drohnenfuehrer/')) {
const match = knownBasePaths.find(basePath => path === basePath || path.startsWith(`${basePath}/`)); return '/drohnenfuehrer';
return match || ''; }
return '';
}; };
const configuredApiBaseUrl = process.env.REACT_APP_API_URL; const configuredApiBaseUrl = process.env.REACT_APP_API_URL;
@ -13,26 +14,21 @@ export const API_BASE_URL = (typeof configuredApiBaseUrl === 'string' && configu
: detectRuntimeBasePath(); : detectRuntimeBasePath();
export const USER_TYPES = { export const USER_TYPES = {
HS: 'HS', DF: 'DF',
BGS: 'BGS', WK: 'WK',
SB: 'SB', RGB: 'RGB'
LAB: 'LAB'
}; };
export const USER_TYPE_LABELS = { export const USER_TYPE_LABELS = {
[USER_TYPES.HS]: 'Hannoverscher Schweißhund', [USER_TYPES.DF]: 'Drohnenführer',
[USER_TYPES.BGS]: 'Bayerischer Gebirgsschweißhund', [USER_TYPES.WK]: 'Wärmebildkamera',
[USER_TYPES.SB]: 'Schweißhund (allgemein)', [USER_TYPES.RGB]: 'RGB-Kamera'
[USER_TYPES.LAB]: 'Labrador'
}; };
export const RULES = [ export const RULES = [
"1. Verbrechen Sie den Standort und den Anschuss.", "Drohnenflug nur mit gültigem Drohnenführerschein (A1/A3 oder A2).",
"2. Vertreten Sie keine Pirschzeichen.", "Informieren Sie den zuständigen Revierinhaber vor jedem Einsatz.",
"3. Versuchen Sie die Nachsuche möglichst nicht erst mit ungeübten Hunden.", "Halten Sie die Datenschutzbestimmungen beim Einsatz von Wärmebildkameras ein.",
"4. Benachrichtigen Sie unverzüglich den Nachsuchenführer und die evtl. betroffenen Revierinhaber der Nachbarjagdbezirke.", "Geben Sie keine Aufnahmen ohne Zustimmung des Revierinhabers weiter.",
"5. Der Hundeführer gibt den Fangschuss auf das gestellte Stück ab.", "Melden Sie Ihren Einsatz unverzüglich an die koordinierende Stelle."
"6. Bei der Nachsuche hat der Hundeführer die Stellung eines Jagdleiters.",
"7. Der anfordernde Revierinhaber muss die betroffenen Nachbarreviere verständigen.",
"Auf Empfehlung der Jägerschaften Soltau und Fallingbostel ist bei Inanspruchnahme eine pauschale Aufwandsentschädigung an den Nachsuchenführer von 50,00 € zu entrichten."
]; ];

View File

@ -2,8 +2,8 @@ server {
listen 80; listen 80;
server_name localhost; server_name localhost;
# Support subpath deployment (/nachsuche) # Support subpath deployment (/drohnenfuehrer)
location /nachsuche/api/public/ { location /drohnenfuehrer/api/public/ {
proxy_pass http://backend:5000/api/public/; proxy_pass http://backend:5000/api/public/;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
@ -11,7 +11,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location /nachsuche/api/ { location /drohnenfuehrer/api/ {
proxy_pass http://backend:5000/api/; proxy_pass http://backend:5000/api/;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
@ -19,7 +19,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location = /nachsuche/sw.js { location = /drohnenfuehrer/sw.js {
proxy_pass http://frontend:80/sw.js; proxy_pass http://frontend:80/sw.js;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
@ -27,7 +27,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location /nachsuche/ { location /drohnenfuehrer/ {
proxy_pass http://frontend:80/; proxy_pass http://frontend:80/;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;

View File

@ -2,17 +2,17 @@ version: '3.8'
services: services:
backend: backend:
build: ./backend build: ./backend
container_name: tracking-leaders-backend container_name: drohnenfuehrer-backend
ports: ports:
- "5000:5000" - "5000:5000"
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- MONGO_URI=mongodb://mongo:27017/tracking-leaders - MONGO_URI=mongodb://mongo:27017/drohnenfuehrer
- JWT_SECRET=${JWT_SECRET:-CHANGE_ME_IN_PRODUCTION} - JWT_SECRET=${JWT_SECRET:-CHANGE_ME_IN_PRODUCTION}
- JWT_EXPIRES_IN=24h - JWT_EXPIRES_IN=24h
- CORS_ORIGIN=http://localhost:8080 - CORS_ORIGIN=http://localhost:8080
- GEOCODE_URL=https://nominatim.openstreetmap.org/search - GEOCODE_URL=https://nominatim.openstreetmap.org/search
- GEOCODE_USER_AGENT=tracking-leaders-app/1.0 - GEOCODE_USER_AGENT=drohnenfuehrer-app/1.0
- GEOCODE_MIN_DELAY_MS=1100 - GEOCODE_MIN_DELAY_MS=1100
depends_on: depends_on:
- mongo - mongo
@ -29,7 +29,7 @@ services:
context: ./frontend context: ./frontend
args: args:
- REACT_APP_API_URL=http://localhost:5000 - REACT_APP_API_URL=http://localhost:5000
container_name: tracking-leaders-frontend container_name: drohnenfuehrer-frontend
ports: ports:
- "8080:80" - "8080:80"
depends_on: depends_on:
@ -43,7 +43,7 @@ services:
mongo: mongo:
image: mongo:7.0 image: mongo:7.0
container_name: tracking-leaders-mongo container_name: drohnenfuehrer-mongo
# Port intentionally NOT exposed externally # Port intentionally NOT exposed externally
volumes: volumes:
- mongo-data:/data/db - mongo-data:/data/db

View File

@ -11,191 +11,267 @@
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<meta name="description" content="Jagd Apps Heidekreis Nachsuche, Drohnenführer, Stöberhunde" /> <meta name="description" content="Jagd Apps Heidekreis Nachsuche, Drohnenführer, Stöberhunde" />
<title>Jagd Apps Heidekreis</title> <title>Jagd Apps Heidekreis</title>
<script>
window.__installPrompt = null;
window.addEventListener('beforeinstallprompt', function(e) {
e.preventDefault();
window.__installPrompt = e;
});
</script>
<style> <style>
*, *::before, *::after { box-sizing: border-box; } *, *::before, *::after { box-sizing: border-box; }
:root { :root {
--green: #2d6a2d; --green: #1a3d1a;
--green-mid: #2d5a2d;
--green-accent: #3a7a3a;
--text: #1a1a1a; --text: #1a1a1a;
--muted: #555; --muted: #666;
--bg: #f5f5f0; --bg: #e8e8e2;
--card-bg: #ffffff; --card-bg: #f4f4ee;
--border: #d4e8d4; --border: #bbbba8;
--shadow: 0 2px 8px rgba(0,0,0,0.08); --shadow: 0 1px 3px rgba(0,0,0,0.15);
--radius: 12px; --radius: 2px;
}
html, body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: Georgia, 'Times New Roman', serif;
min-height: 100vh;
}
header {
background: var(--green);
color: #e8e8d8;
padding: 1.5rem 1.5rem 1.2rem;
border-bottom: 3px solid var(--green-accent);
}
header h1 {
margin: 0;
font-size: 1.25rem;
font-weight: normal;
letter-spacing: 0.06em;
text-transform: uppercase;
}
header p {
margin: 0.4rem 0 0;
font-size: 0.78rem;
opacity: 0.7;
letter-spacing: 0.04em;
text-transform: uppercase;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
main {
max-width: 520px;
margin: 0 auto;
padding: 2rem 1rem 3rem;
display: flex;
flex-direction: column;
gap: 0;
border-left: 1px solid var(--border);
border-right: 1px solid var(--border);
background: var(--card-bg);
}
.app-card {
background: var(--card-bg);
border-bottom: 1px solid var(--border);
border-left: 4px solid var(--green-mid);
border-radius: var(--radius);
box-shadow: var(--shadow);
display: flex;
align-items: center;
gap: 1rem;
padding: 1.1rem 1.25rem;
text-decoration: none;
color: var(--text);
transition: background 0.1s, border-left-color 0.1s;
-webkit-tap-highlight-color: transparent;
}
.app-card:first-child {
border-top: 1px solid var(--border);
}
.app-card:active {
background: #eaeae2;
}
@media (hover: hover) {
.app-card:hover {
background: #ececdf;
border-left-color: var(--green-accent);
}
}
.app-icon {
display: none;
}
.app-info {
flex: 1;
}
.app-name {
font-size: 1rem;
font-weight: bold;
margin: 0 0 0.15rem;
letter-spacing: 0.01em;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.app-desc {
font-size: 0.8rem;
color: var(--muted);
margin: 0;
line-height: 1.4;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.app-arrow {
color: var(--green-mid);
font-size: 1.1rem;
flex-shrink: 0;
}
footer {
max-width: 520px;
margin: 0 auto;
text-align: center;
padding: 1.2rem 1rem;
font-size: 0.72rem;
color: var(--muted);
letter-spacing: 0.04em;
text-transform: uppercase;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
border-left: 1px solid var(--border);
border-right: 1px solid var(--border);
}
/* Install banner */
#install-banner {
display: none;
position: sticky;
top: 0;
z-index: 100;
background: var(--green);
color: #fff;
padding: 0.6rem 1rem;
font-size: 0.88rem;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
} }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; min-height: 100vh; }
header { background: var(--green); color: #fff; padding: 1.25rem 1.5rem; text-align: center; }
header h1 { margin: 0; font-size: 1.35rem; font-weight: 700; display: flex; align-items: center; justify-content: center; gap: 0.6rem; }
header h1 img { height: 2.2rem; width: auto; vertical-align: middle; }
header p { margin: 0.3rem 0 0; font-size: 0.9rem; opacity: 0.85; }
main { max-width: 520px; margin: 0 auto; padding: 1.5rem 1rem 3rem; display: flex; flex-direction: column; gap: 1rem; }
.app-card { background: var(--card-bg); border: 1px solid var(--border); border-left: 5px solid var(--green); border-radius: var(--radius); box-shadow: var(--shadow); display: flex; align-items: center; gap: 1.25rem; padding: 1.25rem; text-decoration: none; color: var(--text); transition: transform 0.15s, box-shadow 0.15s; -webkit-tap-highlight-color: transparent; }
.app-card:active { transform: scale(0.98); }
@media (hover: hover) { .app-card:hover { transform: translateY(-2px); box-shadow: 0 6px 18px rgba(0,0,0,0.12); } }
.app-icon { flex-shrink: 0; width: 52px; display: flex; align-items: center; justify-content: center; }
.app-info { flex: 1; }
.app-name { font-size: 1.1rem; font-weight: 700; margin: 0 0 0.2rem; }
.app-desc { font-size: 0.85rem; color: var(--muted); margin: 0; line-height: 1.4; }
.app-arrow { color: var(--green); font-size: 1.3rem; flex-shrink: 0; }
footer { text-align: center; padding: 1rem; font-size: 0.75rem; color: var(--muted); }
#install-banner { display: none; position: sticky; top: 0; z-index: 100; background: var(--green); color: #fff; padding: 0.6rem 1rem; font-size: 0.88rem; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
#install-banner.visible { display: flex; } #install-banner.visible { display: flex; }
#install-banner span { flex: 1; } #install-banner span { flex: 1; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
#install-btn { background: #fff; color: var(--green); border: none; border-radius: 4px; padding: 0.3rem 0.8rem; font-weight: 600; font-size: 0.85rem; cursor: pointer; white-space: nowrap; } #install-btn {
#dismiss-btn { background: transparent; border: none; color: rgba(255,255,255,0.8); font-size: 1rem; cursor: pointer; padding: 0.2rem 0.3rem; flex-shrink: 0; } background: #fff;
color: var(--green);
border: none;
border-radius: 4px;
padding: 0.3rem 0.8rem;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
white-space: nowrap;
}
#dismiss-btn {
background: transparent;
border: none;
color: rgba(255,255,255,0.8);
font-size: 1rem;
cursor: pointer;
padding: 0.2rem 0.3rem;
flex-shrink: 0;
}
</style> </style>
</head> </head>
<body> <body>
<div id="install-banner" role="banner"> <div id="install-banner" role="banner">
<span id="install-text">📲 App auf dem Homescreen installieren</span> <span id="install-text">App auf dem Homescreen installieren</span>
<button id="install-btn" style="display:none">Installieren</button> <button id="install-btn">Installieren</button>
<button id="dismiss-btn" aria-label="Schließen"></button> <button id="dismiss-btn" aria-label="Schließen"></button>
</div> </div>
<header> <header>
<h1><img src="/icon-192.png?v=2" alt="Logo"> Jagd Apps Heidekreis</h1> <h1>Jagd Apps Heidekreis</h1>
<p>Wählen Sie Ihre App</p> <p>Jägerschaft Fallingbostel e.V.</p>
</header> </header>
<main> <main>
<a href="/nachsuche/" class="app-card"> <a href="/nachsuche/" class="app-card">
<div class="app-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 44" width="44" height="44" aria-hidden="true">
<!-- floppy ears -->
<ellipse cx="9" cy="24" rx="5" ry="9" fill="#2d6a2d"/>
<ellipse cx="35" cy="24" rx="5" ry="9" fill="#2d6a2d"/>
<!-- head -->
<circle cx="22" cy="21" r="13" fill="#2d6a2d"/>
<!-- muzzle -->
<ellipse cx="22" cy="28" rx="7" ry="5" fill="#3a7a3a"/>
<!-- nose -->
<ellipse cx="22" cy="25" rx="4.5" ry="2.5" fill="#1a3a1a"/>
<!-- eyes -->
<circle cx="17" cy="18" r="2.5" fill="white"/>
<circle cx="17" cy="18" r="1.2" fill="#111"/>
<circle cx="27" cy="18" r="2.5" fill="white"/>
<circle cx="27" cy="18" r="1.2" fill="#111"/>
</svg>
</div>
<div class="app-info"> <div class="app-info">
<p class="app-name">Nachsuche</p> <p class="app-name">Nachsuche</p>
<p class="app-desc">Nachsuchenstation Heidekreis Übersicht der Nachsuchenführer und Verfügbarkeiten</p> <p class="app-desc">Nachsuchenstation Heidekreis &ndash; Verfügbare Nachsuchenführer und Kontaktdaten</p>
</div> </div>
<span class="app-arrow"></span> <span class="app-arrow">&rsaquo;</span>
</a> </a>
<a href="/drohnenfuehrer/" class="app-card"> <a href="/drohnenfuehrer/" class="app-card">
<div class="app-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 44" width="44" height="44" aria-hidden="true">
<!-- arms -->
<line x1="22" y1="22" x2="9" y2="9" stroke="#2d6a2d" stroke-width="3" stroke-linecap="round"/>
<line x1="22" y1="22" x2="35" y2="9" stroke="#2d6a2d" stroke-width="3" stroke-linecap="round"/>
<line x1="22" y1="22" x2="9" y2="35" stroke="#2d6a2d" stroke-width="3" stroke-linecap="round"/>
<line x1="22" y1="22" x2="35" y2="35" stroke="#2d6a2d" stroke-width="3" stroke-linecap="round"/>
<!-- rotors -->
<circle cx="9" cy="9" r="5" fill="none" stroke="#2d6a2d" stroke-width="2.5"/>
<circle cx="35" cy="9" r="5" fill="none" stroke="#2d6a2d" stroke-width="2.5"/>
<circle cx="9" cy="35" r="5" fill="none" stroke="#2d6a2d" stroke-width="2.5"/>
<circle cx="35" cy="35" r="5" fill="none" stroke="#2d6a2d" stroke-width="2.5"/>
<!-- body -->
<rect x="17" y="17" width="10" height="10" rx="2" fill="#2d6a2d"/>
<!-- camera -->
<circle cx="22" cy="22" r="2.5" fill="white"/>
</svg>
</div>
<div class="app-info"> <div class="app-info">
<p class="app-name">Drohnenführer</p> <p class="app-name">Drohnenführer</p>
<p class="app-desc">Drohnenführer Heidekreis Übersicht der zertifizierten Drohnenführer im Einsatz</p> <p class="app-desc">Drohnenführer Heidekreis &ndash; Zertifizierte Drohnenführer für den jagdlichen Einsatz</p>
</div> </div>
<span class="app-arrow"></span> <span class="app-arrow">&rsaquo;</span>
</a> </a>
<a href="/stoeberhunde/" class="app-card"> <a href="/stoeberhunde/" class="app-card">
<div class="app-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 44" width="44" height="44" aria-hidden="true">
<!-- main pad -->
<ellipse cx="22" cy="32" rx="10" ry="8" fill="#2d6a2d"/>
<!-- toe pads -->
<ellipse cx="11" cy="21" rx="3.5" ry="4.5" fill="#2d6a2d" transform="rotate(-20 11 21)"/>
<ellipse cx="18" cy="16" rx="3.5" ry="4.5" fill="#2d6a2d"/>
<ellipse cx="26" cy="16" rx="3.5" ry="4.5" fill="#2d6a2d"/>
<ellipse cx="33" cy="21" rx="3.5" ry="4.5" fill="#2d6a2d" transform="rotate(20 33 21)"/>
</svg>
</div>
<div class="app-info"> <div class="app-info">
<p class="app-name">Stöberhunde</p> <p class="app-name">Stöberhunde</p>
<p class="app-desc">Stöberhunde Heidekreis Übersicht der Stöberhundleiter und Einsatzmöglichkeiten</p> <p class="app-desc">Stöberhunde Heidekreis &ndash; Stöberhundleiter und Verfügbarkeit für die Drückjagd</p>
</div> </div>
<span class="app-arrow"></span> <span class="app-arrow">&rsaquo;</span>
</a> </a>
</main> </main>
<footer>Jägerschaft Fallingbostel e.V.</footer> <footer>Jägerschaft Fallingbostel e.V.</footer>
<script> <script>
// Register service worker
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {}); navigator.serviceWorker.register('/sw.js').catch(() => {});
} }
const banner = document.getElementById('install-banner'); // PWA install banner
const banner = document.getElementById('install-banner');
const installBtn = document.getElementById('install-btn'); const installBtn = document.getElementById('install-btn');
const dismissBtn = document.getElementById('dismiss-btn'); const dismissBtn = document.getElementById('dismiss-btn');
const installText = document.getElementById('install-text'); const installText = document.getElementById('install-text');
const DISMISSED_KEY = 'portal-pwa-dismissed';
const isStandalone = () => let deferredPrompt = null;
const isInStandalone = () =>
window.matchMedia('(display-mode: standalone)').matches || window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true; window.navigator.standalone === true;
const isIOS = () => const isIOS = () =>
/iphone|ipad|ipod/i.test(navigator.userAgent) && !window.MSStream; /iphone|ipad|ipod/i.test(navigator.userAgent) && !window.MSStream;
const isSamsung = () => if (!isInStandalone() && !localStorage.getItem(DISMISSED_KEY)) {
/SamsungBrowser/i.test(navigator.userAgent);
function attachInstall(prompt) {
installBtn.style.display = '';
installBtn.onclick = async () => {
prompt.prompt();
const { outcome } = await prompt.userChoice;
if (outcome === 'accepted') banner.classList.remove('visible');
};
}
if (!isStandalone() && !sessionStorage.getItem('portal-pwa-dismissed')) {
if (isIOS()) { if (isIOS()) {
installText.textContent = '📲 Zum Homescreen: Teilen ⬆️ → „Zum Home-Bildschirm"'; installText.textContent = 'Zum Homescreen hinzufügen: Teilen → „Zum Home-Bildschirm"';
installBtn.style.display = 'none';
banner.classList.add('visible'); banner.classList.add('visible');
} else if (isSamsung()) {
installText.textContent = '📲 App installieren: Menü ⋮ → „Seite hinzufügen" → „Auf Startbildschirm"';
banner.classList.add('visible');
if (window.__installPrompt) {
attachInstall(window.__installPrompt);
} else {
window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); attachInstall(e); });
}
} else { } else {
const activate = (e) => { window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault(); e.preventDefault();
installText.textContent = '📲 App auf dem Homescreen installieren'; deferredPrompt = e;
attachInstall(e);
banner.classList.add('visible'); banner.classList.add('visible');
}; });
if (window.__installPrompt) {
activate(window.__installPrompt);
} else {
window.addEventListener('beforeinstallprompt', activate);
}
} }
} }
installBtn.addEventListener('click', async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') banner.classList.remove('visible');
deferredPrompt = null;
});
dismissBtn.addEventListener('click', () => { dismissBtn.addEventListener('click', () => {
sessionStorage.setItem('portal-pwa-dismissed', '1'); localStorage.setItem(DISMISSED_KEY, '1');
banner.classList.remove('visible'); banner.classList.remove('visible');
}); });
</script> </script>

View File

@ -1,4 +1,4 @@
# 🐳 Container-Betrieb - Tracking Leaders App # 🐳 Container-Betrieb - Stöberhunde App
## Schnellstart mit Podman/Docker ## Schnellstart mit Podman/Docker
@ -62,9 +62,9 @@ podman-compose logs -f frontend
| Service | Container Name | Port | Beschreibung | | Service | Container Name | Port | Beschreibung |
|---------|---------------|------|--------------| |---------|---------------|------|--------------|
| Frontend | tracking-leaders-frontend | 8080 | React App mit Nginx | | Frontend | stoeberhunde-frontend | 8080 | React App mit Nginx |
| Backend | tracking-leaders-backend | 5000 | Node.js Express API | | Backend | stoeberhunde-backend | 5000 | Node.js Express API |
| MongoDB | tracking-leaders-mongo | 27017 | MongoDB Datenbank | | MongoDB | stoeberhunde-mongo | 27017 | MongoDB Datenbank |
## Nützliche Befehle ## Nützliche Befehle
@ -86,15 +86,15 @@ podman-compose down -v
### In Container einsteigen ### In Container einsteigen
```bash ```bash
# Backend # Backend
podman exec -it tracking-leaders-backend sh podman exec -it stoeberhunde-backend sh
# MongoDB # MongoDB
podman exec -it tracking-leaders-mongo mongosh podman exec -it stoeberhunde-mongo mongosh
``` ```
### Datenbank seeden (manuell) ### Datenbank seeden (manuell)
```bash ```bash
podman exec -it tracking-leaders-backend node seed.js podman exec -it stoeberhunde-backend node seed.js
``` ```
### Health Checks prüfen ### Health Checks prüfen
@ -161,7 +161,7 @@ Für Production solltest du einen Reverse Proxy (z.B. Traefik, Nginx) vorschalte
podman volume ls podman volume ls
# Backup erstellen # Backup erstellen
podman run --rm -v tracking-leaders_mongo-data:/data -v $(pwd):/backup alpine tar czf /backup/mongo-backup-$(date +%Y%m%d).tar.gz -C /data . podman run --rm -v stoeberhunde_mongo-data:/data -v $(pwd):/backup alpine tar czf /backup/mongo-backup-$(date +%Y%m%d).tar.gz -C /data .
``` ```
## Troubleshooting ## Troubleshooting
@ -188,7 +188,7 @@ curl http://localhost:5000/health
### Datenbank leer ### Datenbank leer
```bash ```bash
# Seed-Skript ausführen # Seed-Skript ausführen
podman exec -it tracking-leaders-backend node seed.js podman exec -it stoeberhunde-backend node seed.js
``` ```
### Port bereits belegt ### Port bereits belegt
@ -205,7 +205,7 @@ Beide Dockerfiles nutzen bereits Multi-Stage Builds für minimale Image-Größen
### Image-Größen prüfen ### Image-Größen prüfen
```bash ```bash
podman images | grep tracking-leaders podman images | grep stoeberhunde
``` ```
Erwartete Größen: Erwartete Größen:
@ -245,7 +245,7 @@ podman stats
# Alle Services # Alle Services
for svc in backend frontend mongo; do for svc in backend frontend mongo; do
echo "=== $svc ===" echo "=== $svc ==="
podman inspect tracking-leaders-$svc | grep -A5 Health podman inspect stoeberhunde-$svc | grep -A5 Health
done done
``` ```
@ -255,10 +255,10 @@ Wenn du von lokalem MongoDB zu Container wechselst:
```bash ```bash
# 1. Export aus lokalem MongoDB # 1. Export aus lokalem MongoDB
mongodump --db tracking-leaders --out ./dump mongodump --db stoeberhunde --out ./dump
# 2. Import in Container # 2. Import in Container
podman exec -i tracking-leaders-mongo mongorestore --drop /dump podman exec -i stoeberhunde-mongo mongorestore --drop /dump
``` ```
## Weitere Informationen ## Weitere Informationen

View File

@ -3,7 +3,7 @@ PORT=5000
NODE_ENV=development NODE_ENV=development
# Database Configuration # Database Configuration
MONGO_URI=mongodb://127.0.0.1:27017/tracking-leaders MONGO_URI=mongodb://127.0.0.1:27017/stoeberhunde
# JWT Configuration # JWT Configuration
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
@ -17,5 +17,5 @@ CORS_ORIGIN=http://localhost:3000
# Geocoding Configuration (OpenStreetMap Nominatim) # Geocoding Configuration (OpenStreetMap Nominatim)
GEOCODE_URL=https://nominatim.openstreetmap.org/search GEOCODE_URL=https://nominatim.openstreetmap.org/search
GEOCODE_USER_AGENT=tracking-leaders-app/1.0 (admin@localhost) GEOCODE_USER_AGENT=stoeberhunde-app/1.0 (admin@localhost)
GEOCODE_MIN_DELAY_MS=1100 GEOCODE_MIN_DELAY_MS=1100

View File

@ -2,13 +2,13 @@ require('dotenv').config();
const config = { const config = {
port: process.env.PORT || 5000, port: process.env.PORT || 5000,
mongoUri: process.env.MONGO_URI || 'mongodb://127.0.0.1:27017/tracking-leaders', mongoUri: process.env.MONGO_URI || 'mongodb://127.0.0.1:27017/stoeberhunde',
jwtSecret: process.env.JWT_SECRET || 'your-secret-key-change-in-production', jwtSecret: process.env.JWT_SECRET || 'your-secret-key-change-in-production',
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '24h', jwtExpiresIn: process.env.JWT_EXPIRES_IN || '24h',
nodeEnv: process.env.NODE_ENV || 'development', nodeEnv: process.env.NODE_ENV || 'development',
corsOrigin: process.env.CORS_ORIGIN ? process.env.CORS_ORIGIN.split(',') : ['http://localhost:5000'], corsOrigin: process.env.CORS_ORIGIN ? process.env.CORS_ORIGIN.split(',') : ['http://localhost:5000'],
geocodeUrl: process.env.GEOCODE_URL || 'https://nominatim.openstreetmap.org/search', geocodeUrl: process.env.GEOCODE_URL || 'https://nominatim.openstreetmap.org/search',
geocodeUserAgent: process.env.GEOCODE_USER_AGENT || 'tracking-leaders-app/1.0 (admin@localhost)', geocodeUserAgent: process.env.GEOCODE_USER_AGENT || 'stoeberhunde-app/1.0 (admin@localhost)',
geocodeMinDelayMs: parseInt(process.env.GEOCODE_MIN_DELAY_MS || '1100', 10) geocodeMinDelayMs: parseInt(process.env.GEOCODE_MIN_DELAY_MS || '1100', 10)
}; };

View File

@ -45,15 +45,11 @@ const login = async (req, res) => {
); );
// Set token in httpOnly cookie (XSS protection) // Set token in httpOnly cookie (XSS protection)
const secureCookie = config.nodeEnv === 'production'
? (req.secure || req.headers['x-forwarded-proto'] === 'https')
: false;
res.cookie('token', token, { res.cookie('token', token, {
httpOnly: true, httpOnly: true, // Not accessible via JavaScript (XSS protection)
secure: secureCookie, secure: false, // Allow over HTTP in development (localhost)
sameSite: 'lax', sameSite: 'lax', // Relaxed CSRF protection (allows cross-port cookies on localhost)
path: '/', path: '/', // Ensure cookie is sent for all app routes, including /stoeberhunde/api
maxAge: 24 * 60 * 60 * 1000 // 24 hours in milliseconds maxAge: 24 * 60 * 60 * 1000 // 24 hours in milliseconds
}); });
@ -82,14 +78,10 @@ const logout = async (req, res) => {
const username = req.user?.username || 'unknown'; const username = req.user?.username || 'unknown';
await auditAuth(req, true, username, null); await auditAuth(req, true, username, null);
const secureCookie = config.nodeEnv === 'production'
? (req.secure || req.headers['x-forwarded-proto'] === 'https')
: false;
// Clear the token cookie // Clear the token cookie
res.clearCookie('token', { res.clearCookie('token', {
httpOnly: true, httpOnly: true,
secure: secureCookie, secure: false,
sameSite: 'lax', sameSite: 'lax',
path: '/' path: '/'
}); });
@ -148,11 +140,13 @@ const forgotPassword = async (req, res) => {
}); });
// In production, send email here // In production, send email here
logger.info(`Password reset token generiert für Benutzer: ${username}`); // For development, log the token
logger.info(`Password reset token for ${username}: ${token} (expires in 1 hour)`);
res.json({ res.json({
success: true, success: true,
message: 'Reset-Token wurde generiert. Bitte kontaktieren Sie den Administrator.' message: 'Reset-Token wurde generiert. In der Entwicklungsumgebung finden Sie den Token in den Logs.',
...(config.nodeEnv === 'development' && { token }) // Only in dev!
}); });
} catch (error) { } catch (error) {
logger.error('Fehler bei Passwort-Reset-Anfrage:', error); logger.error('Fehler bei Passwort-Reset-Anfrage:', error);

View File

@ -1,6 +1,5 @@
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const User = require('../models/User'); const User = require('../models/User');
const config = require('../config/env'); const config = require('../config/env');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
@ -50,42 +49,22 @@ const handlerLogin = async (req, res) => {
} }
}; };
// POST /api/handler/set-password — set first password using a one-time invite token // POST /api/handler/set-password — set first password (handler provides token + new password)
// Admin must generate the invite token via POST /api/users/:id/invite-token first // Admin first sets email, then the handler can set their own password
const setHandlerPassword = async (req, res) => { const setHandlerPassword = async (req, res) => {
try { try {
const { email, inviteToken, newPassword } = req.body; const { email, newPassword } = req.body;
if (!email || !inviteToken || !newPassword || newPassword.length < 8) { if (!email || !newPassword || newPassword.length < 6) {
return res.status(400).json({ success: false, message: 'E-Mail, Einladungs-Token und Passwort (min. 8 Zeichen) erforderlich' }); return res.status(400).json({ success: false, message: 'E-Mail und Passwort (min. 6 Zeichen) erforderlich' });
} }
const user = await User.findOne({ email: email.trim().toLowerCase(), deleted: false }) const user = await User.findOne({ email: email.trim().toLowerCase(), deleted: false });
.select('+inviteToken +inviteTokenExpiry'); if (!user) {
return res.status(404).json({ success: false, message: 'Kein Stöberhundeführer mit dieser E-Mail gefunden' });
// Always return the same error to prevent user enumeration
const invalidMsg = 'Ungültiger oder abgelaufener Einladungs-Token';
if (!user || !user.inviteToken || !user.inviteTokenExpiry) {
return res.status(401).json({ success: false, message: invalidMsg });
} }
if (user.inviteTokenExpiry < new Date()) { user.passwordHash = await bcrypt.hash(newPassword, 12);
user.inviteToken = null;
user.inviteTokenExpiry = null;
await user.save();
return res.status(401).json({ success: false, message: invalidMsg });
}
// Timing-safe comparison to prevent timing attacks
const incoming = Buffer.from(inviteToken.trim());
const stored = Buffer.from(user.inviteToken);
if (incoming.length !== stored.length || !crypto.timingSafeEqual(incoming, stored)) {
return res.status(401).json({ success: false, message: invalidMsg });
}
user.passwordHash = await bcrypt.hash(newPassword, 12);
user.inviteToken = null;
user.inviteTokenExpiry = null;
await user.save(); await user.save();
res.json({ success: true, message: 'Passwort erfolgreich gesetzt' }); res.json({ success: true, message: 'Passwort erfolgreich gesetzt' });
@ -114,7 +93,7 @@ const updateHandlerSelf = async (req, res) => {
); );
if (!user) { if (!user) {
return res.status(404).json({ success: false, message: 'Hundeführer nicht gefunden' }); return res.status(404).json({ success: false, message: 'Stöberhundeführer nicht gefunden' });
} }
res.json({ success: true, data: user }); res.json({ success: true, data: user });
@ -135,32 +114,4 @@ const getHandlerSelf = async (req, res) => {
} }
}; };
// POST /api/users/:id/invite-token (admin only) — generate a one-time invite token for a handler module.exports = { handlerLogin, setHandlerPassword, updateHandlerSelf, getHandlerSelf };
const generateInviteToken = async (req, res) => {
try {
const user = await User.findOne({ _id: req.params.id, deleted: false })
.select('+inviteToken +inviteTokenExpiry');
if (!user) {
return res.status(404).json({ success: false, message: 'Benutzer nicht gefunden' });
}
if (!user.email) {
return res.status(400).json({ success: false, message: 'Kein E-Mail für diesen Benutzer hinterlegt' });
}
const token = crypto.randomBytes(32).toString('hex');
user.inviteToken = token;
user.inviteTokenExpiry = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
await user.save();
res.json({
success: true,
message: 'Einladungs-Token generiert (gültig 7 Tage)',
data: { inviteToken: token, expiresAt: user.inviteTokenExpiry, email: user.email }
});
} catch (error) {
logger.error('Fehler beim Generieren des Einladungs-Tokens:', error);
res.status(500).json({ success: false, message: 'Serverfehler' });
}
};
module.exports = { handlerLogin, setHandlerPassword, generateInviteToken, updateHandlerSelf, getHandlerSelf };

View File

@ -425,7 +425,7 @@ const exportUsers = async (req, res) => {
res.setHeader('Content-Type', 'text/csv; charset=utf-8'); res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', res.setHeader('Content-Disposition',
`attachment; filename="nachsuchenfuehrer_export_${new Date().toISOString().slice(0, 10)}.csv"`); `attachment; filename="stoeberhundefuehrer_export_${new Date().toISOString().slice(0, 10)}.csv"`);
return res.send('\uFEFF' + csv); // BOM für korrekte UTF-8-Darstellung in Excel return res.send('\uFEFF' + csv); // BOM für korrekte UTF-8-Darstellung in Excel
} }
@ -442,7 +442,7 @@ const exportUsers = async (req, res) => {
})); }));
res.setHeader('Content-Disposition', res.setHeader('Content-Disposition',
`attachment; filename="nachsuchenfuehrer_export_${new Date().toISOString().slice(0, 10)}.json"`); `attachment; filename="stoeberhundefuehrer_export_${new Date().toISOString().slice(0, 10)}.json"`);
res.json({ res.json({
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
count: exportData.length, count: exportData.length,

View File

@ -1,5 +1,5 @@
{ {
"name": "tracking-leaders-backend", "name": "stoeberhunde-backend",
"version": "1.0.0", "version": "1.0.0",
"description": "Backend for tracking leaders app", "description": "Backend for tracking leaders app",
"main": "server.js", "main": "server.js",

View File

@ -17,7 +17,7 @@ const seedDatabase = async () => {
const existingUserCount = await User.countDocuments(); const existingUserCount = await User.countDocuments();
if (existingUserCount === 0) { if (existingUserCount === 0) {
await User.insertMany(users); await User.insertMany(users);
logger.info(`${users.length} Nachsuchenführer wurden erstellt`); logger.info(`${users.length} Stöberhundeführer wurden erstellt`);
} else { } else {
logger.info(` Benutzer bereits vorhanden (${existingUserCount}), überspringe Seeding`); logger.info(` Benutzer bereits vorhanden (${existingUserCount}), überspringe Seeding`);
} }

View File

@ -19,7 +19,7 @@ const logger = winston.createLogger({
winston.format.splat(), winston.format.splat(),
winston.format.json() winston.format.json()
), ),
defaultMeta: { service: 'tracking-leaders-api' }, defaultMeta: { service: 'stoeberhunde-api' },
transports: [ transports: [
rotateTransport('error', 'error'), rotateTransport('error', 'error'),
rotateTransport('info', 'combined') rotateTransport('info', 'combined')

View File

@ -16,7 +16,7 @@ Authorization: Bearer <token>
## Öffentliche Endpunkte ## Öffentliche Endpunkte
### GET /api/public/users ### GET /api/public/users
Ruft alle verfügbaren Nachsuchenführer ab. Ruft alle verfügbaren Stöberhundeführer ab.
**Response:** **Response:**
```json ```json
@ -69,7 +69,7 @@ Admin-Login.
## Geschützte Endpunkte (Admin) ## Geschützte Endpunkte (Admin)
### GET /api/users ### GET /api/users
Ruft alle Nachsuchenführer ab (auch nicht verfügbare). Ruft alle Stöberhundeführer ab (auch nicht verfügbare).
**Headers:** **Headers:**
- `Authorization: Bearer <token>` - `Authorization: Bearer <token>`
@ -83,7 +83,7 @@ Ruft alle Nachsuchenführer ab (auch nicht verfügbare).
``` ```
### GET /api/users/:id ### GET /api/users/:id
Ruft einen einzelnen Nachsuchenführer ab. Ruft einen einzelnen Stöberhundeführer ab.
**Headers:** **Headers:**
- `Authorization: Bearer <token>` - `Authorization: Bearer <token>`
@ -97,7 +97,7 @@ Ruft einen einzelnen Nachsuchenführer ab.
``` ```
### POST /api/users ### POST /api/users
Erstellt einen neuen Nachsuchenführer. Erstellt einen neuen Stöberhundeführer.
**Headers:** **Headers:**
- `Authorization: Bearer <token>` - `Authorization: Bearer <token>`
@ -126,7 +126,7 @@ Erstellt einen neuen Nachsuchenführer.
``` ```
### PUT /api/users/:id ### PUT /api/users/:id
Aktualisiert einen Nachsuchenführer. Aktualisiert einen Stöberhundeführer.
**Headers:** **Headers:**
- `Authorization: Bearer <token>` - `Authorization: Bearer <token>`
@ -149,7 +149,7 @@ Aktualisiert einen Nachsuchenführer.
``` ```
### DELETE /api/users/:id ### DELETE /api/users/:id
Löscht einen Nachsuchenführer. Löscht einen Stöberhundeführer.
**Headers:** **Headers:**
- `Authorization: Bearer <token>` - `Authorization: Bearer <token>`
@ -163,7 +163,7 @@ Löscht einen Nachsuchenführer.
``` ```
### PUT /api/users/:id/availability ### PUT /api/users/:id/availability
Aktualisiert die Verfügbarkeit eines Nachsuchenführers. Aktualisiert die Verfügbarkeit eines Stöberhundeführers.
**Headers:** **Headers:**
- `Authorization: Bearer <token>` - `Authorization: Bearer <token>`
@ -184,7 +184,7 @@ Aktualisiert die Verfügbarkeit eines Nachsuchenführers.
``` ```
### PUT /api/users/:id/gps ### PUT /api/users/:id/gps
Aktualisiert die GPS-Koordinaten eines Nachsuchenführers. Aktualisiert die GPS-Koordinaten eines Stöberhundeführers.
**Headers:** **Headers:**
- `Authorization: Bearer <token>` - `Authorization: Bearer <token>`
@ -206,7 +206,7 @@ Aktualisiert die GPS-Koordinaten eines Nachsuchenführers.
``` ```
### GET /api/users/export ### GET /api/users/export
Exportiert alle Nachsuchenführer. Exportiert alle Stöberhundeführer.
**Headers:** **Headers:**
- `Authorization: Bearer <token>` - `Authorization: Bearer <token>`

View File

@ -2,7 +2,7 @@
## Übersicht ## Übersicht
Die Nachsuchenführer-App besteht aus einem Node.js/Express-Backend und einem React-Frontend. Die Stöberhundeführer-App besteht aus einem Node.js/Express-Backend und einem React-Frontend.
## Backend-Architektur ## Backend-Architektur
@ -23,7 +23,7 @@ backend/
│ └── validator.js # Input-Validierung │ └── validator.js # Input-Validierung
├── models/ ├── models/
│ ├── Admin.js # Admin-Modell │ ├── Admin.js # Admin-Modell
│ └── User.js # Nachsuchenführer-Modell │ └── User.js # Stöberhundeführer-Modell
├── routes/ ├── routes/
│ ├── authRoutes.js # Authentifizierungs-Routes │ ├── authRoutes.js # Authentifizierungs-Routes
│ └── userRoutes.js # Benutzer-Routes │ └── userRoutes.js # Benutzer-Routes
@ -35,7 +35,7 @@ backend/
### Datenmodell ### Datenmodell
#### User (Nachsuchenführer) #### User (Stöberhundeführer)
- `name`: String (erforderlich) - `name`: String (erforderlich)
- `address`: String (erforderlich) - `address`: String (erforderlich)
- `phone`: String (erforderlich) - `phone`: String (erforderlich)

View File

@ -8,7 +8,7 @@ RUN npm install
COPY . . COPY . .
ARG PUBLIC_URL=/nachsuche/ ARG PUBLIC_URL=/stoeberhunde/
ENV PUBLIC_URL=$PUBLIC_URL ENV PUBLIC_URL=$PUBLIC_URL
ARG REACT_APP_API_URL= ARG REACT_APP_API_URL=
ENV REACT_APP_API_URL=$REACT_APP_API_URL ENV REACT_APP_API_URL=$REACT_APP_API_URL

View File

@ -1,5 +1,5 @@
{ {
"name": "tracking-leaders-frontend", "name": "stoeberhunde-frontend",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {

View File

@ -8,14 +8,14 @@
<meta name="theme-color" content="#2d6a2d" /> <meta name="theme-color" content="#2d6a2d" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" /> <meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="NSS Heidekreis" /> <meta name="apple-mobile-web-app-title" content="Stöberhunde Heidekreis" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<meta <meta
name="description" name="description"
content="Nachsuchenstation Heidekreis Übersicht der Nachsuchenführer" content="Stöberhunde Heidekreis Übersicht der Stöberhundeführer"
/> />
<title>NSS Heidekreis</title> <title>Stöberhunde Heidekreis</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offline - Nachsuchenführer</title> <title>Offline - Stöberhundeführer</title>
<style> <style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; } body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
h1 { color: #333; } h1 { color: #333; }

View File

@ -75,7 +75,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
if (result.warning) { if (result.warning) {
showNotification('warning', `Erstellt GPS konnte nicht automatisch ermittelt werden. Bitte manuell setzen.`); showNotification('warning', `Erstellt GPS konnte nicht automatisch ermittelt werden. Bitte manuell setzen.`);
} else { } else {
showNotification('success', 'Nachsuchenführer erfolgreich erstellt (GPS gesetzt)'); showNotification('success', 'Stöberhundeführer erfolgreich erstellt (GPS gesetzt)');
} }
onRefetch(); onRefetch();
} else { } else {
@ -89,7 +89,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
if (result.warning) { if (result.warning) {
showNotification('warning', `Gespeichert GPS konnte nicht automatisch ermittelt werden. Bitte manuell setzen.`); showNotification('warning', `Gespeichert GPS konnte nicht automatisch ermittelt werden. Bitte manuell setzen.`);
} else { } else {
showNotification('success', 'Nachsuchenführer erfolgreich aktualisiert'); showNotification('success', 'Stöberhundeführer erfolgreich aktualisiert');
} }
onRefetch(); onRefetch();
} else { } else {
@ -100,7 +100,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
const handleUserDelete = async (id) => { const handleUserDelete = async (id) => {
const result = await deleteUser(id); const result = await deleteUser(id);
if (result.success) { if (result.success) {
showNotification('success', 'Nachsuchenführer erfolgreich gelöscht'); showNotification('success', 'Stöberhundeführer erfolgreich gelöscht');
onRefetch(); onRefetch();
} else { } else {
showNotification('error', result.message || 'Fehler beim Löschen'); showNotification('error', result.message || 'Fehler beim Löschen');
@ -149,7 +149,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = `nachsuche-einstellungen-${new Date().toISOString().slice(0, 10)}.json`; a.download = `stoeberhunde-einstellungen-${new Date().toISOString().slice(0, 10)}.json`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
showNotification('success', 'Einstellungen exportiert'); showNotification('success', 'Einstellungen exportiert');
@ -366,7 +366,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
className="settings-input" className="settings-input"
value={config.appName || ''} value={config.appName || ''}
onChange={(e) => setConfig({ ...config, appName: e.target.value })} onChange={(e) => setConfig({ ...config, appName: e.target.value })}
placeholder="z.B. Nachsuchenstation Heidekreis" placeholder="z.B. Stöberhunde Heidekreis"
maxLength={80} maxLength={80}
/> />
</div> </div>
@ -423,7 +423,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
{!collapsed.sections && ( {!collapsed.sections && (
<div className="settings-section-body"> <div className="settings-section-body">
{[ {[
{ key: 'ueber-uns', label: 'Nachsuchenstation Heidekreis' }, { key: 'ueber-uns', label: 'Stöberhunde Heidekreis' },
{ key: 'anwendung', label: 'Anwendung der App' }, { key: 'anwendung', label: 'Anwendung der App' },
{ key: 'ansprechpartner', label: 'Ansprechpartner und Koordination' } { key: 'ansprechpartner', label: 'Ansprechpartner und Koordination' }
].map(({ key, label }) => ( ].map(({ key, label }) => (
@ -504,7 +504,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
<div key={index} className="settings-row"> <div key={index} className="settings-row">
<input <input
type="text" type="text"
placeholder="z.B. HS" placeholder="z.B. SH"
value={type.code} value={type.code}
onChange={(e) => { onChange={(e) => {
const newTypes = [...config.userTypes]; const newTypes = [...config.userTypes];

View File

@ -33,7 +33,7 @@ const Header = ({ isAdmin, onLogout, currentView, onViewChange }) => {
className={`nav-button ${currentView === 'handler-login' || currentView === 'handler-dashboard' ? 'active' : ''}`} className={`nav-button ${currentView === 'handler-login' || currentView === 'handler-dashboard' ? 'active' : ''}`}
onClick={() => onViewChange('handler-login')} onClick={() => onViewChange('handler-login')}
> >
Hundeführer Login Stöberhundeführer-Login
</button> </button>
<button <button
className={`nav-button ${currentView === 'login' ? 'active' : ''}`} className={`nav-button ${currentView === 'login' ? 'active' : ''}`}

View File

@ -59,7 +59,7 @@ const HandlerLogin = ({ onLogin }) => {
return ( return (
<div className="handler-login-container"> <div className="handler-login-container">
<div className="handler-login-card"> <div className="handler-login-card">
<h2>Hundeführer-Login</h2> <h2>Stöberhundeführer-Login</h2>
<p className="handler-login-subtitle"> <p className="handler-login-subtitle">
Melden Sie sich an, um Ihre Verfügbarkeit und Kontaktdaten zu verwalten. Melden Sie sich an, um Ihre Verfügbarkeit und Kontaktdaten zu verwalten.
</p> </p>

View File

@ -77,7 +77,7 @@ const PublicUserList = ({ users, loading }) => {
`https://nominatim.openstreetmap.org/search?postalcode=${encodeURIComponent(postalCode)}&country=Germany&format=json&limit=1`, `https://nominatim.openstreetmap.org/search?postalcode=${encodeURIComponent(postalCode)}&country=Germany&format=json&limit=1`,
{ {
headers: { headers: {
'User-Agent': 'tracking-leaders-app/1.0' 'User-Agent': 'stoeberhunde-app/1.0'
} }
} }
); );
@ -108,7 +108,7 @@ const PublicUserList = ({ users, loading }) => {
const { list: filteredAndSortedUsers, missingGpsCount, allUsersWithGPS } = useMemo(() => { const { list: filteredAndSortedUsers, missingGpsCount, allUsersWithGPS } = useMemo(() => {
let filtered = [...users]; let filtered = [...users];
// Nur verfügbare Nachsuchenführer // Nur verfügbare Stöberhundeführer
filtered = filtered.filter(user => user.available); filtered = filtered.filter(user => user.available);
// Type filter // Type filter
@ -162,7 +162,7 @@ const PublicUserList = ({ users, loading }) => {
if (!users || users.length === 0) { if (!users || users.length === 0) {
return ( return (
<div className="no-users"> <div className="no-users">
<p>Derzeit sind keine Nachsuchenführer verfügbar.</p> <p>Derzeit sind keine Stöberhundeführer verfügbar.</p>
</div> </div>
); );
} }
@ -170,7 +170,7 @@ const PublicUserList = ({ users, loading }) => {
return ( return (
<div className="public-user-list"> <div className="public-user-list">
<div className="list-header"> <div className="list-header">
<h2>Verfügbare Nachsuchenführer</h2> <h2>Verfügbare Stöberhundeführer</h2>
<button <button
className="toggle-map-button" className="toggle-map-button"
onClick={() => setShowMap(!showMap)} onClick={() => setShowMap(!showMap)}
@ -271,7 +271,7 @@ const PublicUserList = ({ users, loading }) => {
)} )}
{filteredAndSortedUsers.length === 0 ? ( {filteredAndSortedUsers.length === 0 ? (
<div className="no-users"> <div className="no-users">
<p>Keine Nachsuchenführer im gewählten Radius gefunden.</p> <p>Keine Stöberhundeführer im gewählten Radius gefunden.</p>
</div> </div>
) : ( ) : (
<div className="user-grid"> <div className="user-grid">
@ -283,7 +283,7 @@ const PublicUserList = ({ users, loading }) => {
</div> </div>
)} )}
<h3 className="user-name">{user.name}</h3> <h3 className="user-name">{user.name}</h3>
<p className="user-role">Nachsuchenführer</p> <p className="user-role">Stöberhundeführer</p>
<div className="user-info"> <div className="user-info">
<p className="user-address">📍 {user.address}</p> <p className="user-address">📍 {user.address}</p>
<a href={`tel:${user.phone}`} className="user-phone"> <a href={`tel:${user.phone}`} className="user-phone">

View File

@ -3,7 +3,7 @@ import { useConfigContext } from '../../contexts/ConfigContext';
import './RulesDisplay.css'; import './RulesDisplay.css';
const TABS = [ const TABS = [
{ key: 'ueber-uns', label: 'Nachsuchenstation Heidekreis' }, { key: 'ueber-uns', label: 'Stöberhunde Heidekreis' },
{ key: 'anwendung', label: 'Anwendung der App' }, { key: 'anwendung', label: 'Anwendung der App' },
{ key: 'regeln', label: 'Verhaltensregeln' }, { key: 'regeln', label: 'Verhaltensregeln' },
{ key: 'ansprechpartner', label: 'Ansprechpartner' } { key: 'ansprechpartner', label: 'Ansprechpartner' }
@ -39,7 +39,7 @@ const RulesDisplay = () => {
<div className="allgemeines-content"> <div className="allgemeines-content">
{activeTab === 'ueber-uns' && ( {activeTab === 'ueber-uns' && (
<div className="section-text"> <div className="section-text">
<h2>Die Nachsuchenstation Heidekreis</h2> <h2>Die Stöberhunde Heidekreis</h2>
<div className="section-body"> <div className="section-body">
{getSection('ueber-uns').split('\n').map((line, i) => ( {getSection('ueber-uns').split('\n').map((line, i) => (
<p key={i}>{line || '\u00a0'}</p> <p key={i}>{line || '\u00a0'}</p>

View File

@ -127,7 +127,7 @@ const UserForm = ({ user, onSave, onCancel }) => {
try { try {
const res = await fetch( const res = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&countrycodes=de&addressdetails=1&limit=5&q=${encodeURIComponent(value)}`, `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)' } } { headers: { 'User-Agent': 'stoeberhunde-app/1.0 (admin@kasimirat.de)' } }
); );
const data = await res.json(); const data = await res.json();
setSuggestions(Array.isArray(data) ? data : []); setSuggestions(Array.isArray(data) ? data : []);
@ -152,7 +152,7 @@ const UserForm = ({ user, onSave, onCancel }) => {
return ( return (
<form className="user-form" onSubmit={handleSubmit}> <form className="user-form" onSubmit={handleSubmit}>
<h3>{user ? 'Nachsuchenführer bearbeiten' : 'Neuen Nachsuchenführer erstellen'}</h3> <h3>{user ? 'Stöberhundeführer bearbeiten' : 'Neuen Stöberhundeführer erstellen'}</h3>
<div className="form-group"> <div className="form-group">
<label htmlFor="name">Name <span className="required">*</span></label> <label htmlFor="name">Name <span className="required">*</span></label>
@ -208,7 +208,7 @@ const UserForm = ({ user, onSave, onCancel }) => {
</div> </div>
<div className="form-group"> <div className="form-group">
<label htmlFor="email">E-Mail (für Hundeführer-Login)</label> <label htmlFor="email">E-Mail (für Stöberhundeführer-Login)</label>
<input type="email" id="email" name="email" value={formData.email} onChange={handleChange} <input type="email" id="email" name="email" value={formData.email} onChange={handleChange}
placeholder="optional" /> placeholder="optional" />
</div> </div>
@ -220,7 +220,7 @@ const UserForm = ({ user, onSave, onCancel }) => {
? typeOptions.map(code => ( ? typeOptions.map(code => (
<option key={code} value={code}>{userTypeLabels[code] || code}</option> <option key={code} value={code}>{userTypeLabels[code] || code}</option>
)) ))
: <option value="HS">Hannoverscher Schweißhund</option> : <option value="SH">Stöberhundleiter</option>
} }
</select> </select>
</div> </div>

View File

@ -61,7 +61,7 @@ const UserList = ({ users, loading, error, onAvailabilityToggle, onGPSUpdate, on
}, [users, searchTerm, filters]); }, [users, searchTerm, filters]);
if (loading) { if (loading) {
return <Loading message="Lädt Nachsuchenführer..." />; return <Loading message="Lädt Stöberhundeführer..." />;
} }
if (error) { if (error) {
@ -98,7 +98,7 @@ const UserList = ({ users, loading, error, onAvailabilityToggle, onGPSUpdate, on
return ( return (
<div className="user-list-admin"> <div className="user-list-admin">
<div className="list-header-admin"> <div className="list-header-admin">
<h2>Alle Nachsuchenführer</h2> <h2>Alle Stöberhundeführer</h2>
<div className="header-actions"> <div className="header-actions">
<div className="results-count"> <div className="results-count">
{filteredAndSortedUsers.length} von {users.length} Führern {filteredAndSortedUsers.length} von {users.length} Führern
@ -111,7 +111,7 @@ const UserList = ({ users, loading, error, onAvailabilityToggle, onGPSUpdate, on
setEditingUser(null); setEditingUser(null);
}} }}
> >
+ Neuer Nachsuchenführer + Neuer Stöberhundeführer
</button> </button>
)} )}
</div> </div>
@ -131,7 +131,7 @@ const UserList = ({ users, loading, error, onAvailabilityToggle, onGPSUpdate, on
<FilterPanel filters={filters} onFilterChange={handleFilterChange} /> <FilterPanel filters={filters} onFilterChange={handleFilterChange} />
{filteredAndSortedUsers.length === 0 ? ( {filteredAndSortedUsers.length === 0 ? (
<div className="no-users"> <div className="no-users">
<p>Keine Nachsuchenführer gefunden.</p> <p>Keine Stöberhundeführer gefunden.</p>
</div> </div>
) : ( ) : (
<div className="user-grid-admin"> <div className="user-grid-admin">

View File

@ -13,7 +13,7 @@ root.render(
// Register service worker // Register service worker
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
window.addEventListener('load', () => { window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js') navigator.serviceWorker.register('./sw.js')
.then(registration => { .then(registration => {
console.log('SW registered: ', registration); console.log('SW registered: ', registration);
}) })

View File

@ -115,7 +115,7 @@ export const updateGPS = async (id, lat, lng) => {
export const exportUsers = async (format = 'csv') => { export const exportUsers = async (format = 'csv') => {
try { try {
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
const filename = `nachsuchenfuehrer_export_${today}.${format}`; const filename = `stoeberhundefuehrer_export_${today}.${format}`;
const response = await api.get(`/users/export?format=${format}`, { const response = await api.get(`/users/export?format=${format}`, {
responseType: 'blob' responseType: 'blob'

View File

@ -1,10 +1,11 @@
// Use configured API URL when set, otherwise auto-detect common subpath deployment // Use configured API URL when set, otherwise auto-detect common subpath deployment (/stoeberhunde)
const detectRuntimeBasePath = () => { const detectRuntimeBasePath = () => {
if (typeof window === 'undefined') return ''; if (typeof window === 'undefined') return '';
const path = window.location.pathname || ''; const path = window.location.pathname || '';
const knownBasePaths = ['/nachsuche', '/drohnenfuehrer', '/stoeberhunde']; if (path === '/stoeberhunde' || path.startsWith('/stoeberhunde/')) {
const match = knownBasePaths.find(basePath => path === basePath || path.startsWith(`${basePath}/`)); return '/stoeberhunde';
return match || ''; }
return '';
}; };
const configuredApiBaseUrl = process.env.REACT_APP_API_URL; const configuredApiBaseUrl = process.env.REACT_APP_API_URL;
@ -13,26 +14,21 @@ export const API_BASE_URL = (typeof configuredApiBaseUrl === 'string' && configu
: detectRuntimeBasePath(); : detectRuntimeBasePath();
export const USER_TYPES = { export const USER_TYPES = {
HS: 'HS', SH: 'SH',
BGS: 'BGS', BSH: 'BSH',
SB: 'SB', SHK: 'SHK'
LAB: 'LAB'
}; };
export const USER_TYPE_LABELS = { export const USER_TYPE_LABELS = {
[USER_TYPES.HS]: 'Hannoverscher Schweißhund', [USER_TYPES.SH]: 'Stöberhundleiter',
[USER_TYPES.BGS]: 'Bayerischer Gebirgsschweißhund', [USER_TYPES.BSH]: 'Begleithund',
[USER_TYPES.SB]: 'Schweißhund (allgemein)', [USER_TYPES.SHK]: 'Stöberhund kombiniert'
[USER_TYPES.LAB]: 'Labrador'
}; };
export const RULES = [ export const RULES = [
"1. Verbrechen Sie den Standort und den Anschuss.", "Informieren Sie den Revierinhaber vor jedem Einsatz.",
"2. Vertreten Sie keine Pirschzeichen.", "Der Stöberhundleiter ist verantwortlich für den sicheren Einsatz seines Hundes.",
"3. Versuchen Sie die Nachsuche möglichst nicht erst mit ungeübten Hunden.", "Halten Sie die vereinbarten Gebietsabgrenzungen ein.",
"4. Benachrichtigen Sie unverzüglich den Nachsuchenführer und die evtl. betroffenen Revierinhaber der Nachbarjagdbezirke.", "Melden Sie das Ergebnis unverzüglich an den Revierinhaber.",
"5. Der Hundeführer gibt den Fangschuss auf das gestellte Stück ab.", "Bei Inanspruchnahme ist eine pauschale Aufwandsentschädigung zu entrichten."
"6. Bei der Nachsuche hat der Hundeführer die Stellung eines Jagdleiters.",
"7. Der anfordernde Revierinhaber muss die betroffenen Nachbarreviere verständigen.",
"Auf Empfehlung der Jägerschaften Soltau und Fallingbostel ist bei Inanspruchnahme eine pauschale Aufwandsentschädigung an den Nachsuchenführer von 50,00 € zu entrichten."
]; ];

View File

@ -2,8 +2,8 @@ server {
listen 80; listen 80;
server_name localhost; server_name localhost;
# Support subpath deployment (/nachsuche) # Support subpath deployment (/stoeberhunde)
location /nachsuche/api/public/ { location /stoeberhunde/api/public/ {
proxy_pass http://backend:5000/api/public/; proxy_pass http://backend:5000/api/public/;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
@ -11,7 +11,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location /nachsuche/api/ { location /stoeberhunde/api/ {
proxy_pass http://backend:5000/api/; proxy_pass http://backend:5000/api/;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
@ -19,7 +19,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location = /nachsuche/sw.js { location = /stoeberhunde/sw.js {
proxy_pass http://frontend:80/sw.js; proxy_pass http://frontend:80/sw.js;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
@ -27,7 +27,7 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location /nachsuche/ { location /stoeberhunde/ {
proxy_pass http://frontend:80/; proxy_pass http://frontend:80/;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;

View File

@ -2,17 +2,17 @@ version: '3.8'
services: services:
backend: backend:
build: ./backend build: ./backend
container_name: tracking-leaders-backend container_name: stoeberhunde-backend
ports: ports:
- "5000:5000" - "5000:5000"
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- MONGO_URI=mongodb://mongo:27017/tracking-leaders - MONGO_URI=mongodb://mongo:27017/stoeberhunde
- JWT_SECRET=${JWT_SECRET:-CHANGE_ME_IN_PRODUCTION} - JWT_SECRET=${JWT_SECRET:-CHANGE_ME_IN_PRODUCTION}
- JWT_EXPIRES_IN=24h - JWT_EXPIRES_IN=24h
- CORS_ORIGIN=http://localhost:8080 - CORS_ORIGIN=http://localhost:8080
- GEOCODE_URL=https://nominatim.openstreetmap.org/search - GEOCODE_URL=https://nominatim.openstreetmap.org/search
- GEOCODE_USER_AGENT=tracking-leaders-app/1.0 - GEOCODE_USER_AGENT=stoeberhunde-app/1.0
- GEOCODE_MIN_DELAY_MS=1100 - GEOCODE_MIN_DELAY_MS=1100
depends_on: depends_on:
- mongo - mongo
@ -29,7 +29,7 @@ services:
context: ./frontend context: ./frontend
args: args:
- REACT_APP_API_URL=http://localhost:5000 - REACT_APP_API_URL=http://localhost:5000
container_name: tracking-leaders-frontend container_name: stoeberhunde-frontend
ports: ports:
- "8080:80" - "8080:80"
depends_on: depends_on:
@ -43,7 +43,7 @@ services:
mongo: mongo:
image: mongo:7.0 image: mongo:7.0
container_name: tracking-leaders-mongo container_name: stoeberhunde-mongo
# Port intentionally NOT exposed externally # Port intentionally NOT exposed externally
volumes: volumes:
- mongo-data:/data/db - mongo-data:/data/db