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
@ -62,9 +62,9 @@ podman-compose logs -f frontend
| Service | Container Name | Port | Beschreibung |
|---------|---------------|------|--------------|
| Frontend | tracking-leaders-frontend | 8080 | React App mit Nginx |
| Backend | tracking-leaders-backend | 5000 | Node.js Express API |
| MongoDB | tracking-leaders-mongo | 27017 | MongoDB Datenbank |
| Frontend | drohnenfuehrer-frontend | 8080 | React App mit Nginx |
| Backend | drohnenfuehrer-backend | 5000 | Node.js Express API |
| MongoDB | drohnenfuehrer-mongo | 27017 | MongoDB Datenbank |
## Nützliche Befehle
@ -86,15 +86,15 @@ podman-compose down -v
### In Container einsteigen
```bash
# Backend
podman exec -it tracking-leaders-backend sh
podman exec -it drohnenfuehrer-backend sh
# MongoDB
podman exec -it tracking-leaders-mongo mongosh
podman exec -it drohnenfuehrer-mongo mongosh
```
### Datenbank seeden (manuell)
```bash
podman exec -it tracking-leaders-backend node seed.js
podman exec -it drohnenfuehrer-backend node seed.js
```
### Health Checks prüfen
@ -161,7 +161,7 @@ Für Production solltest du einen Reverse Proxy (z.B. Traefik, Nginx) vorschalte
podman volume ls
# 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
@ -188,7 +188,7 @@ curl http://localhost:5000/health
### Datenbank leer
```bash
# Seed-Skript ausführen
podman exec -it tracking-leaders-backend node seed.js
podman exec -it drohnenfuehrer-backend node seed.js
```
### 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
```bash
podman images | grep tracking-leaders
podman images | grep drohnenfuehrer
```
Erwartete Größen:
@ -245,7 +245,7 @@ podman stats
# Alle Services
for svc in backend frontend mongo; do
echo "=== $svc ==="
podman inspect tracking-leaders-$svc | grep -A5 Health
podman inspect drohnenfuehrer-$svc | grep -A5 Health
done
```
@ -255,10 +255,10 @@ Wenn du von lokalem MongoDB zu Container wechselst:
```bash
# 1. Export aus lokalem MongoDB
mongodump --db tracking-leaders --out ./dump
mongodump --db drohnenfuehrer --out ./dump
# 2. Import in Container
podman exec -i tracking-leaders-mongo mongorestore --drop /dump
podman exec -i drohnenfuehrer-mongo mongorestore --drop /dump
```
## Weitere Informationen

View File

@ -3,7 +3,7 @@ PORT=5000
NODE_ENV=development
# Database Configuration
MONGO_URI=mongodb://127.0.0.1:27017/tracking-leaders
MONGO_URI=mongodb://127.0.0.1:27017/drohnenfuehrer
# JWT Configuration
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
@ -17,5 +17,5 @@ CORS_ORIGIN=http://localhost:3000
# Geocoding Configuration (OpenStreetMap Nominatim)
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

View File

@ -2,13 +2,13 @@ require('dotenv').config();
const config = {
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',
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '24h',
nodeEnv: process.env.NODE_ENV || 'development',
corsOrigin: process.env.CORS_ORIGIN ? process.env.CORS_ORIGIN.split(',') : ['http://localhost:5000'],
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)
};

View File

@ -45,15 +45,11 @@ const login = async (req, res) => {
);
// 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, {
httpOnly: true,
secure: secureCookie,
sameSite: 'lax',
path: '/',
httpOnly: true, // Not accessible via JavaScript (XSS protection)
secure: false, // Allow over HTTP in development (localhost)
sameSite: 'lax', // Relaxed CSRF protection (allows cross-port cookies on localhost)
path: '/', // Ensure cookie is sent for all app routes, including /drohnenfuehrer/api
maxAge: 24 * 60 * 60 * 1000 // 24 hours in milliseconds
});
@ -82,14 +78,10 @@ const logout = async (req, res) => {
const username = req.user?.username || 'unknown';
await auditAuth(req, true, username, null);
const secureCookie = config.nodeEnv === 'production'
? (req.secure || req.headers['x-forwarded-proto'] === 'https')
: false;
// Clear the token cookie
res.clearCookie('token', {
httpOnly: true,
secure: secureCookie,
secure: false,
sameSite: 'lax',
path: '/'
});
@ -148,11 +140,13 @@ const forgotPassword = async (req, res) => {
});
// 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({
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) {
logger.error('Fehler bei Passwort-Reset-Anfrage:', error);

View File

@ -1,6 +1,5 @@
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const User = require('../models/User');
const config = require('../config/env');
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
// Admin must generate the invite token via POST /api/users/:id/invite-token first
// POST /api/handler/set-password — set first password (handler provides token + new password)
// Admin first sets email, then the handler can set their own password
const setHandlerPassword = async (req, res) => {
try {
const { email, inviteToken, newPassword } = req.body;
const { email, newPassword } = req.body;
if (!email || !inviteToken || !newPassword || newPassword.length < 8) {
return res.status(400).json({ success: false, message: 'E-Mail, Einladungs-Token und Passwort (min. 8 Zeichen) erforderlich' });
if (!email || !newPassword || newPassword.length < 6) {
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 })
.select('+inviteToken +inviteTokenExpiry');
// 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 });
const user = await User.findOne({ email: email.trim().toLowerCase(), deleted: false });
if (!user) {
return res.status(404).json({ success: false, message: 'Kein Drohnenführer mit dieser E-Mail gefunden' });
}
if (user.inviteTokenExpiry < new Date()) {
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;
user.passwordHash = await bcrypt.hash(newPassword, 12);
await user.save();
res.json({ success: true, message: 'Passwort erfolgreich gesetzt' });
@ -114,7 +93,7 @@ const updateHandlerSelf = async (req, res) => {
);
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 });
@ -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
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 };
module.exports = { handlerLogin, setHandlerPassword, 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-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
}
@ -442,7 +442,7 @@ const exportUsers = async (req, res) => {
}));
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({
exportedAt: new Date().toISOString(),
count: exportData.length,

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@
## Ü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
@ -23,7 +23,7 @@ backend/
│ └── validator.js # Input-Validierung
├── models/
│ ├── Admin.js # Admin-Modell
│ └── User.js # Nachsuchenführer-Modell
│ └── User.js # Drohnenführer-Modell
├── routes/
│ ├── authRoutes.js # Authentifizierungs-Routes
│ └── userRoutes.js # Benutzer-Routes
@ -35,7 +35,7 @@ backend/
### Datenmodell
#### User (Nachsuchenführer)
#### User (Drohnenführer)
- `name`: String (erforderlich)
- `address`: String (erforderlich)
- `phone`: String (erforderlich)

View File

@ -8,7 +8,7 @@ RUN npm install
COPY . .
ARG PUBLIC_URL=/nachsuche/
ARG PUBLIC_URL=/drohnenfuehrer/
ENV PUBLIC_URL=$PUBLIC_URL
ARG 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",
"private": true,
"dependencies": {

View File

@ -8,14 +8,14 @@
<meta name="theme-color" content="#2d6a2d" />
<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-title" content="NSS Heidekreis" />
<meta name="apple-mobile-web-app-title" content="Drohnenführer Heidekreis" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<meta
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>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

View File

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

View File

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

View File

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

View File

@ -59,7 +59,7 @@ const HandlerLogin = ({ onLogin }) => {
return (
<div className="handler-login-container">
<div className="handler-login-card">
<h2>Hundeführer-Login</h2>
<h2>Drohnenführer-Login</h2>
<p className="handler-login-subtitle">
Melden Sie sich an, um Ihre Verfügbarkeit und Kontaktdaten zu verwalten.
</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`,
{
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(() => {
let filtered = [...users];
// Nur verfügbare Nachsuchenführer
// Nur verfügbare Drohnenführer
filtered = filtered.filter(user => user.available);
// Type filter
@ -162,7 +162,7 @@ const PublicUserList = ({ users, loading }) => {
if (!users || users.length === 0) {
return (
<div className="no-users">
<p>Derzeit sind keine Nachsuchenführer verfügbar.</p>
<p>Derzeit sind keine Drohnenführer verfügbar.</p>
</div>
);
}
@ -170,7 +170,7 @@ const PublicUserList = ({ users, loading }) => {
return (
<div className="public-user-list">
<div className="list-header">
<h2>Verfügbare Nachsuchenführer</h2>
<h2>Verfügbare Drohnenführer</h2>
<button
className="toggle-map-button"
onClick={() => setShowMap(!showMap)}
@ -271,7 +271,7 @@ const PublicUserList = ({ users, loading }) => {
)}
{filteredAndSortedUsers.length === 0 ? (
<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 className="user-grid">
@ -283,7 +283,7 @@ const PublicUserList = ({ users, loading }) => {
</div>
)}
<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">
<p className="user-address">📍 {user.address}</p>
<a href={`tel:${user.phone}`} className="user-phone">

View File

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

View File

@ -127,7 +127,7 @@ const UserForm = ({ user, onSave, onCancel }) => {
try {
const res = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&countrycodes=de&addressdetails=1&limit=5&q=${encodeURIComponent(value)}`,
{ headers: { 'User-Agent': 'nachsuche-app/1.0 (admin@kasimirat.de)' } }
{ headers: { 'User-Agent': 'drohnenfuehrer-app/1.0 (admin@kasimirat.de)' } }
);
const data = await res.json();
setSuggestions(Array.isArray(data) ? data : []);
@ -152,7 +152,7 @@ const UserForm = ({ user, onSave, onCancel }) => {
return (
<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">
<label htmlFor="name">Name <span className="required">*</span></label>
@ -208,19 +208,19 @@ const UserForm = ({ user, onSave, onCancel }) => {
</div>
<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}
placeholder="optional" />
</div>
<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>
{typeOptions.length > 0
? typeOptions.map(code => (
<option key={code} value={code}>{userTypeLabels[code] || code}</option>
))
: <option value="HS">Hannoverscher Schweißhund</option>
: <option value="DF">Drohnenführer</option>
}
</select>
</div>

View File

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

View File

@ -13,7 +13,7 @@ root.render(
// Register service worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
navigator.serviceWorker.register('./sw.js')
.then(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') => {
try {
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}`, {
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 = () => {
if (typeof window === 'undefined') return '';
const path = window.location.pathname || '';
const knownBasePaths = ['/nachsuche', '/drohnenfuehrer', '/stoeberhunde'];
const match = knownBasePaths.find(basePath => path === basePath || path.startsWith(`${basePath}/`));
return match || '';
if (path === '/drohnenfuehrer' || path.startsWith('/drohnenfuehrer/')) {
return '/drohnenfuehrer';
}
return '';
};
const configuredApiBaseUrl = process.env.REACT_APP_API_URL;
@ -13,26 +14,21 @@ export const API_BASE_URL = (typeof configuredApiBaseUrl === 'string' && configu
: detectRuntimeBasePath();
export const USER_TYPES = {
HS: 'HS',
BGS: 'BGS',
SB: 'SB',
LAB: 'LAB'
DF: 'DF',
WK: 'WK',
RGB: 'RGB'
};
export const USER_TYPE_LABELS = {
[USER_TYPES.HS]: 'Hannoverscher Schweißhund',
[USER_TYPES.BGS]: 'Bayerischer Gebirgsschweißhund',
[USER_TYPES.SB]: 'Schweißhund (allgemein)',
[USER_TYPES.LAB]: 'Labrador'
[USER_TYPES.DF]: 'Drohnenführer',
[USER_TYPES.WK]: 'Wärmebildkamera',
[USER_TYPES.RGB]: 'RGB-Kamera'
};
export const RULES = [
"1. Verbrechen Sie den Standort und den Anschuss.",
"2. Vertreten Sie keine Pirschzeichen.",
"3. Versuchen Sie die Nachsuche möglichst nicht erst mit ungeübten Hunden.",
"4. Benachrichtigen Sie unverzüglich den Nachsuchenführer und die evtl. betroffenen Revierinhaber der Nachbarjagdbezirke.",
"5. Der Hundeführer gibt den Fangschuss auf das gestellte Stück ab.",
"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."
"Drohnenflug nur mit gültigem Drohnenführerschein (A1/A3 oder A2).",
"Informieren Sie den zuständigen Revierinhaber vor jedem Einsatz.",
"Halten Sie die Datenschutzbestimmungen beim Einsatz von Wärmebildkameras ein.",
"Geben Sie keine Aufnahmen ohne Zustimmung des Revierinhabers weiter.",
"Melden Sie Ihren Einsatz unverzüglich an die koordinierende Stelle."
];

View File

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

View File

@ -2,17 +2,17 @@ version: '3.8'
services:
backend:
build: ./backend
container_name: tracking-leaders-backend
container_name: drohnenfuehrer-backend
ports:
- "5000:5000"
environment:
- 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_EXPIRES_IN=24h
- CORS_ORIGIN=http://localhost:8080
- 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
depends_on:
- mongo
@ -29,7 +29,7 @@ services:
context: ./frontend
args:
- REACT_APP_API_URL=http://localhost:5000
container_name: tracking-leaders-frontend
container_name: drohnenfuehrer-frontend
ports:
- "8080:80"
depends_on:
@ -43,7 +43,7 @@ services:
mongo:
image: mongo:7.0
container_name: tracking-leaders-mongo
container_name: drohnenfuehrer-mongo
# Port intentionally NOT exposed externally
volumes:
- mongo-data:/data/db

View File

@ -11,191 +11,267 @@
<link rel="manifest" href="/manifest.json" />
<meta name="description" content="Jagd Apps Heidekreis Nachsuche, Drohnenführer, Stöberhunde" />
<title>Jagd Apps Heidekreis</title>
<script>
window.__installPrompt = null;
window.addEventListener('beforeinstallprompt', function(e) {
e.preventDefault();
window.__installPrompt = e;
});
</script>
<style>
*, *::before, *::after { box-sizing: border-box; }
:root {
--green: #2d6a2d;
--green: #1a3d1a;
--green-mid: #2d5a2d;
--green-accent: #3a7a3a;
--text: #1a1a1a;
--muted: #555;
--bg: #f5f5f0;
--card-bg: #ffffff;
--border: #d4e8d4;
--shadow: 0 2px 8px rgba(0,0,0,0.08);
--radius: 12px;
--muted: #666;
--bg: #e8e8e2;
--card-bg: #f4f4ee;
--border: #bbbba8;
--shadow: 0 1px 3px rgba(0,0,0,0.15);
--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 span { flex: 1; }
#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; }
#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; }
#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;
}
#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>
</head>
<body>
<div id="install-banner" role="banner">
<span id="install-text">📲 App auf dem Homescreen installieren</span>
<button id="install-btn" style="display:none">Installieren</button>
<span id="install-text">App auf dem Homescreen installieren</span>
<button id="install-btn">Installieren</button>
<button id="dismiss-btn" aria-label="Schließen"></button>
</div>
<header>
<h1><img src="/icon-192.png?v=2" alt="Logo"> Jagd Apps Heidekreis</h1>
<p>Wählen Sie Ihre App</p>
<h1>Jagd Apps Heidekreis</h1>
<p>Jägerschaft Fallingbostel e.V.</p>
</header>
<main>
<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">
<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>
<span class="app-arrow"></span>
<span class="app-arrow">&rsaquo;</span>
</a>
<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">
<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>
<span class="app-arrow"></span>
<span class="app-arrow">&rsaquo;</span>
</a>
<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">
<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>
<span class="app-arrow"></span>
<span class="app-arrow">&rsaquo;</span>
</a>
</main>
<footer>Jägerschaft Fallingbostel e.V.</footer>
<script>
// Register service worker
if ('serviceWorker' in navigator) {
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 dismissBtn = document.getElementById('dismiss-btn');
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.navigator.standalone === true;
const isIOS = () =>
/iphone|ipad|ipod/i.test(navigator.userAgent) && !window.MSStream;
const isSamsung = () =>
/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 (!isInStandalone() && !localStorage.getItem(DISMISSED_KEY)) {
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');
} 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 {
const activate = (e) => {
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
installText.textContent = '📲 App auf dem Homescreen installieren';
attachInstall(e);
deferredPrompt = e;
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', () => {
sessionStorage.setItem('portal-pwa-dismissed', '1');
localStorage.setItem(DISMISSED_KEY, '1');
banner.classList.remove('visible');
});
</script>

View File

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

View File

@ -3,7 +3,7 @@ PORT=5000
NODE_ENV=development
# Database Configuration
MONGO_URI=mongodb://127.0.0.1:27017/tracking-leaders
MONGO_URI=mongodb://127.0.0.1:27017/stoeberhunde
# JWT Configuration
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
@ -17,5 +17,5 @@ CORS_ORIGIN=http://localhost:3000
# Geocoding Configuration (OpenStreetMap Nominatim)
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

View File

@ -2,13 +2,13 @@ require('dotenv').config();
const config = {
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',
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '24h',
nodeEnv: process.env.NODE_ENV || 'development',
corsOrigin: process.env.CORS_ORIGIN ? process.env.CORS_ORIGIN.split(',') : ['http://localhost:5000'],
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)
};

View File

@ -45,15 +45,11 @@ const login = async (req, res) => {
);
// 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, {
httpOnly: true,
secure: secureCookie,
sameSite: 'lax',
path: '/',
httpOnly: true, // Not accessible via JavaScript (XSS protection)
secure: false, // Allow over HTTP in development (localhost)
sameSite: 'lax', // Relaxed CSRF protection (allows cross-port cookies on localhost)
path: '/', // Ensure cookie is sent for all app routes, including /stoeberhunde/api
maxAge: 24 * 60 * 60 * 1000 // 24 hours in milliseconds
});
@ -82,14 +78,10 @@ const logout = async (req, res) => {
const username = req.user?.username || 'unknown';
await auditAuth(req, true, username, null);
const secureCookie = config.nodeEnv === 'production'
? (req.secure || req.headers['x-forwarded-proto'] === 'https')
: false;
// Clear the token cookie
res.clearCookie('token', {
httpOnly: true,
secure: secureCookie,
secure: false,
sameSite: 'lax',
path: '/'
});
@ -148,11 +140,13 @@ const forgotPassword = async (req, res) => {
});
// 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({
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) {
logger.error('Fehler bei Passwort-Reset-Anfrage:', error);

View File

@ -1,6 +1,5 @@
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const User = require('../models/User');
const config = require('../config/env');
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
// Admin must generate the invite token via POST /api/users/:id/invite-token first
// POST /api/handler/set-password — set first password (handler provides token + new password)
// Admin first sets email, then the handler can set their own password
const setHandlerPassword = async (req, res) => {
try {
const { email, inviteToken, newPassword } = req.body;
const { email, newPassword } = req.body;
if (!email || !inviteToken || !newPassword || newPassword.length < 8) {
return res.status(400).json({ success: false, message: 'E-Mail, Einladungs-Token und Passwort (min. 8 Zeichen) erforderlich' });
if (!email || !newPassword || newPassword.length < 6) {
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 })
.select('+inviteToken +inviteTokenExpiry');
// 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 });
const user = await User.findOne({ email: email.trim().toLowerCase(), deleted: false });
if (!user) {
return res.status(404).json({ success: false, message: 'Kein Stöberhundeführer mit dieser E-Mail gefunden' });
}
if (user.inviteTokenExpiry < new Date()) {
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;
user.passwordHash = await bcrypt.hash(newPassword, 12);
await user.save();
res.json({ success: true, message: 'Passwort erfolgreich gesetzt' });
@ -114,7 +93,7 @@ const updateHandlerSelf = async (req, res) => {
);
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 });
@ -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
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 };
module.exports = { handlerLogin, setHandlerPassword, 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-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
}
@ -442,7 +442,7 @@ const exportUsers = async (req, res) => {
}));
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({
exportedAt: new Date().toISOString(),
count: exportData.length,

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@
## Ü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
@ -23,7 +23,7 @@ backend/
│ └── validator.js # Input-Validierung
├── models/
│ ├── Admin.js # Admin-Modell
│ └── User.js # Nachsuchenführer-Modell
│ └── User.js # Stöberhundeführer-Modell
├── routes/
│ ├── authRoutes.js # Authentifizierungs-Routes
│ └── userRoutes.js # Benutzer-Routes
@ -35,7 +35,7 @@ backend/
### Datenmodell
#### User (Nachsuchenführer)
#### User (Stöberhundeführer)
- `name`: String (erforderlich)
- `address`: String (erforderlich)
- `phone`: String (erforderlich)

View File

@ -8,7 +8,7 @@ RUN npm install
COPY . .
ARG PUBLIC_URL=/nachsuche/
ARG PUBLIC_URL=/stoeberhunde/
ENV PUBLIC_URL=$PUBLIC_URL
ARG 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",
"private": true,
"dependencies": {

View File

@ -8,14 +8,14 @@
<meta name="theme-color" content="#2d6a2d" />
<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-title" content="NSS Heidekreis" />
<meta name="apple-mobile-web-app-title" content="Stöberhunde Heidekreis" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<meta
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>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

View File

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

View File

@ -75,7 +75,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
if (result.warning) {
showNotification('warning', `Erstellt GPS konnte nicht automatisch ermittelt werden. Bitte manuell setzen.`);
} else {
showNotification('success', 'Nachsuchenführer erfolgreich erstellt (GPS gesetzt)');
showNotification('success', 'Stöberhundeführer erfolgreich erstellt (GPS gesetzt)');
}
onRefetch();
} else {
@ -89,7 +89,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
if (result.warning) {
showNotification('warning', `Gespeichert GPS konnte nicht automatisch ermittelt werden. Bitte manuell setzen.`);
} else {
showNotification('success', 'Nachsuchenführer erfolgreich aktualisiert');
showNotification('success', 'Stöberhundeführer erfolgreich aktualisiert');
}
onRefetch();
} else {
@ -100,7 +100,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
const handleUserDelete = async (id) => {
const result = await deleteUser(id);
if (result.success) {
showNotification('success', 'Nachsuchenführer erfolgreich gelöscht');
showNotification('success', 'Stöberhundeführer erfolgreich gelöscht');
onRefetch();
} else {
showNotification('error', result.message || 'Fehler beim Löschen');
@ -149,7 +149,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
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();
URL.revokeObjectURL(url);
showNotification('success', 'Einstellungen exportiert');
@ -366,7 +366,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
className="settings-input"
value={config.appName || ''}
onChange={(e) => setConfig({ ...config, appName: e.target.value })}
placeholder="z.B. Nachsuchenstation Heidekreis"
placeholder="z.B. Stöberhunde Heidekreis"
maxLength={80}
/>
</div>
@ -423,7 +423,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
{!collapsed.sections && (
<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: 'ansprechpartner', label: 'Ansprechpartner und Koordination' }
].map(({ key, label }) => (
@ -504,7 +504,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
<div key={index} className="settings-row">
<input
type="text"
placeholder="z.B. HS"
placeholder="z.B. SH"
value={type.code}
onChange={(e) => {
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' : ''}`}
onClick={() => onViewChange('handler-login')}
>
Hundeführer Login
Stöberhundeführer-Login
</button>
<button
className={`nav-button ${currentView === 'login' ? 'active' : ''}`}

View File

@ -59,7 +59,7 @@ const HandlerLogin = ({ onLogin }) => {
return (
<div className="handler-login-container">
<div className="handler-login-card">
<h2>Hundeführer-Login</h2>
<h2>Stöberhundeführer-Login</h2>
<p className="handler-login-subtitle">
Melden Sie sich an, um Ihre Verfügbarkeit und Kontaktdaten zu verwalten.
</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`,
{
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(() => {
let filtered = [...users];
// Nur verfügbare Nachsuchenführer
// Nur verfügbare Stöberhundeführer
filtered = filtered.filter(user => user.available);
// Type filter
@ -162,7 +162,7 @@ const PublicUserList = ({ users, loading }) => {
if (!users || users.length === 0) {
return (
<div className="no-users">
<p>Derzeit sind keine Nachsuchenführer verfügbar.</p>
<p>Derzeit sind keine Stöberhundeführer verfügbar.</p>
</div>
);
}
@ -170,7 +170,7 @@ const PublicUserList = ({ users, loading }) => {
return (
<div className="public-user-list">
<div className="list-header">
<h2>Verfügbare Nachsuchenführer</h2>
<h2>Verfügbare Stöberhundeführer</h2>
<button
className="toggle-map-button"
onClick={() => setShowMap(!showMap)}
@ -271,7 +271,7 @@ const PublicUserList = ({ users, loading }) => {
)}
{filteredAndSortedUsers.length === 0 ? (
<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 className="user-grid">
@ -283,7 +283,7 @@ const PublicUserList = ({ users, loading }) => {
</div>
)}
<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">
<p className="user-address">📍 {user.address}</p>
<a href={`tel:${user.phone}`} className="user-phone">

View File

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

View File

@ -127,7 +127,7 @@ const UserForm = ({ user, onSave, onCancel }) => {
try {
const res = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&countrycodes=de&addressdetails=1&limit=5&q=${encodeURIComponent(value)}`,
{ headers: { 'User-Agent': 'nachsuche-app/1.0 (admin@kasimirat.de)' } }
{ headers: { 'User-Agent': 'stoeberhunde-app/1.0 (admin@kasimirat.de)' } }
);
const data = await res.json();
setSuggestions(Array.isArray(data) ? data : []);
@ -152,7 +152,7 @@ const UserForm = ({ user, onSave, onCancel }) => {
return (
<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">
<label htmlFor="name">Name <span className="required">*</span></label>
@ -208,7 +208,7 @@ const UserForm = ({ user, onSave, onCancel }) => {
</div>
<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}
placeholder="optional" />
</div>
@ -220,7 +220,7 @@ const UserForm = ({ user, onSave, onCancel }) => {
? typeOptions.map(code => (
<option key={code} value={code}>{userTypeLabels[code] || code}</option>
))
: <option value="HS">Hannoverscher Schweißhund</option>
: <option value="SH">Stöberhundleiter</option>
}
</select>
</div>

View File

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

View File

@ -13,7 +13,7 @@ root.render(
// Register service worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
navigator.serviceWorker.register('./sw.js')
.then(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') => {
try {
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}`, {
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 = () => {
if (typeof window === 'undefined') return '';
const path = window.location.pathname || '';
const knownBasePaths = ['/nachsuche', '/drohnenfuehrer', '/stoeberhunde'];
const match = knownBasePaths.find(basePath => path === basePath || path.startsWith(`${basePath}/`));
return match || '';
if (path === '/stoeberhunde' || path.startsWith('/stoeberhunde/')) {
return '/stoeberhunde';
}
return '';
};
const configuredApiBaseUrl = process.env.REACT_APP_API_URL;
@ -13,26 +14,21 @@ export const API_BASE_URL = (typeof configuredApiBaseUrl === 'string' && configu
: detectRuntimeBasePath();
export const USER_TYPES = {
HS: 'HS',
BGS: 'BGS',
SB: 'SB',
LAB: 'LAB'
SH: 'SH',
BSH: 'BSH',
SHK: 'SHK'
};
export const USER_TYPE_LABELS = {
[USER_TYPES.HS]: 'Hannoverscher Schweißhund',
[USER_TYPES.BGS]: 'Bayerischer Gebirgsschweißhund',
[USER_TYPES.SB]: 'Schweißhund (allgemein)',
[USER_TYPES.LAB]: 'Labrador'
[USER_TYPES.SH]: 'Stöberhundleiter',
[USER_TYPES.BSH]: 'Begleithund',
[USER_TYPES.SHK]: 'Stöberhund kombiniert'
};
export const RULES = [
"1. Verbrechen Sie den Standort und den Anschuss.",
"2. Vertreten Sie keine Pirschzeichen.",
"3. Versuchen Sie die Nachsuche möglichst nicht erst mit ungeübten Hunden.",
"4. Benachrichtigen Sie unverzüglich den Nachsuchenführer und die evtl. betroffenen Revierinhaber der Nachbarjagdbezirke.",
"5. Der Hundeführer gibt den Fangschuss auf das gestellte Stück ab.",
"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."
"Informieren Sie den Revierinhaber vor jedem Einsatz.",
"Der Stöberhundleiter ist verantwortlich für den sicheren Einsatz seines Hundes.",
"Halten Sie die vereinbarten Gebietsabgrenzungen ein.",
"Melden Sie das Ergebnis unverzüglich an den Revierinhaber.",
"Bei Inanspruchnahme ist eine pauschale Aufwandsentschädigung zu entrichten."
];

View File

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

View File

@ -2,17 +2,17 @@ version: '3.8'
services:
backend:
build: ./backend
container_name: tracking-leaders-backend
container_name: stoeberhunde-backend
ports:
- "5000:5000"
environment:
- 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_EXPIRES_IN=24h
- CORS_ORIGIN=http://localhost:8080
- 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
depends_on:
- mongo
@ -29,7 +29,7 @@ services:
context: ./frontend
args:
- REACT_APP_API_URL=http://localhost:5000
container_name: tracking-leaders-frontend
container_name: stoeberhunde-frontend
ports:
- "8080:80"
depends_on:
@ -43,7 +43,7 @@ services:
mongo:
image: mongo:7.0
container_name: tracking-leaders-mongo
container_name: stoeberhunde-mongo
# Port intentionally NOT exposed externally
volumes:
- mongo-data:/data/db