security: port binding, invite token auth, cookie flags, rate limiting

- Docker: bind all backend/frontend ports to 127.0.0.1 only (was 0.0.0.0)
- Docker: add shared jagd-network; portal uses container names instead of host ports
- Fix: set-password endpoints now require valid invite token (drohnenfuehrer, stoeberhunde)
- Fix: auth cookie secure flag enabled in production
- Fix: password reset token no longer logged in production
- Add: inviteLimiter (10/15min) on set-password routes in all three apps
- Add: importUsers capped at 500 entries to prevent DoS
- Refactor: rename handler -> drohnenfuehrer/stoeberhundefuehrer across all apps
This commit is contained in:
User 2026-05-03 10:15:03 +02:00
parent 770b0b1d38
commit 8384ad9432
51 changed files with 845 additions and 608 deletions

View File

@ -45,9 +45,13 @@ 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, // Not accessible via JavaScript (XSS protection) httpOnly: true, // Not accessible via JavaScript (XSS protection)
secure: false, // Allow over HTTP in development (localhost) secure: secureCookie,
sameSite: 'lax', // Relaxed CSRF protection (allows cross-port cookies on localhost) sameSite: 'lax', // Relaxed CSRF protection (allows cross-port cookies on localhost)
path: '/', // Ensure cookie is sent for all app routes, including /drohnenfuehrer/api 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
@ -81,7 +85,7 @@ const logout = async (req, res) => {
// Clear the token cookie // Clear the token cookie
res.clearCookie('token', { res.clearCookie('token', {
httpOnly: true, httpOnly: true,
secure: false, secure: config.nodeEnv === 'production',
sameSite: 'lax', sameSite: 'lax',
path: '/' path: '/'
}); });
@ -141,7 +145,11 @@ const forgotPassword = async (req, res) => {
// In production, send email here // In production, send email here
// For development, log the token // For development, log the token
logger.info(`Password reset token for ${username}: ${token} (expires in 1 hour)`); if (config.nodeEnv !== 'production') {
logger.info(`Password reset token for ${username}: ${token} (expires in 1 hour)`);
} else {
logger.info(`Password reset token generiert für Benutzer: ${username}`);
}
res.json({ res.json({
success: true, success: true,

View File

@ -0,0 +1,166 @@
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');
// POST /api/drohnenfuehrer/login — email + password
const drohnenfuehrerLogin = async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ success: false, message: 'E-Mail und Passwort erforderlich' });
}
const user = await User.findOne({ email: email.trim().toLowerCase(), deleted: false });
if (!user || !user.passwordHash) {
return res.status(401).json({ success: false, message: 'Ungültige Anmeldedaten oder kein Passwort hinterlegt' });
}
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
return res.status(401).json({ success: false, message: 'Ungültige Anmeldedaten' });
}
const token = jwt.sign(
{ id: user._id.toString(), role: 'drohnenfuehrer' },
config.jwtSecret,
{ expiresIn: '12h' }
);
res.json({
success: true,
token,
user: {
id: user._id,
name: user.name,
email: user.email,
available: user.available,
type: user.type,
phone: user.phone,
landline: user.landline,
address: user.address
}
});
} catch (error) {
logger.error('Drohnenführer-Login Fehler:', error);
res.status(500).json({ success: false, message: 'Serverfehler beim Login' });
}
};
// POST /api/drohnenfuehrer/set-password — set first password using a one-time invite token
// Admin must first generate an invite token; the Drohnenführer then uses it here.
const setDrohnenfuehrerPassword = async (req, res) => {
try {
const { email, inviteToken, 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' });
}
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 });
}
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;
await user.save();
res.json({ success: true, message: 'Passwort erfolgreich gesetzt' });
} catch (error) {
logger.error('Passwort setzen Fehler:', error);
res.status(500).json({ success: false, message: 'Serverfehler' });
}
};
// PUT /api/drohnenfuehrer/me — Drohnenführer updates own availability + contact data
const updateDrohnenfuehrerSelf = async (req, res) => {
try {
const userId = req.drohnenfuehrerUser.id;
const { available, phone, landline, address } = req.body;
const update = {};
if (typeof available === 'boolean') update.available = available;
if (phone && phone.trim()) update.phone = phone.trim();
if (landline !== undefined) update.landline = landline ? landline.trim() : null;
if (address && address.trim()) update.address = address.trim();
const user = await User.findByIdAndUpdate(
userId,
update,
{ new: true, select: '-passwordHash' }
);
if (!user) {
return res.status(404).json({ success: false, message: 'Drohnenführer nicht gefunden' });
}
res.json({ success: true, data: user });
} catch (error) {
logger.error('Drohnenführer-Update Fehler:', error);
res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren' });
}
};
// GET /api/drohnenfuehrer/me — get own profile
const getDrohnenfuehrerSelf = async (req, res) => {
try {
const user = await User.findById(req.drohnenfuehrerUser.id).select('-passwordHash');
if (!user) return res.status(404).json({ success: false, message: 'Nicht gefunden' });
res.json({ success: true, data: user });
} catch (error) {
res.status(500).json({ success: false, message: 'Serverfehler' });
}
};
// POST /api/drohnenfuehrer/:id/invite-token — Admin generates one-time invite token
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 = { drohnenfuehrerLogin, setDrohnenfuehrerPassword, generateInviteToken, updateDrohnenfuehrerSelf, getDrohnenfuehrerSelf };

View File

@ -1,117 +0,0 @@
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const User = require('../models/User');
const config = require('../config/env');
const logger = require('../utils/logger');
// POST /api/handler/login — email + password
const handlerLogin = async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ success: false, message: 'E-Mail und Passwort erforderlich' });
}
const user = await User.findOne({ email: email.trim().toLowerCase(), deleted: false });
if (!user || !user.passwordHash) {
return res.status(401).json({ success: false, message: 'Ungültige Anmeldedaten oder kein Passwort hinterlegt' });
}
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
return res.status(401).json({ success: false, message: 'Ungültige Anmeldedaten' });
}
const token = jwt.sign(
{ id: user._id.toString(), role: 'handler' },
config.jwtSecret,
{ expiresIn: '12h' }
);
res.json({
success: true,
token,
user: {
id: user._id,
name: user.name,
email: user.email,
available: user.available,
type: user.type,
phone: user.phone,
landline: user.landline,
address: user.address
}
});
} catch (error) {
logger.error('Handler-Login Fehler:', error);
res.status(500).json({ success: false, message: 'Serverfehler beim Login' });
}
};
// 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, newPassword } = req.body;
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 });
if (!user) {
return res.status(404).json({ success: false, message: 'Kein Drohnenführer mit dieser E-Mail gefunden' });
}
user.passwordHash = await bcrypt.hash(newPassword, 12);
await user.save();
res.json({ success: true, message: 'Passwort erfolgreich gesetzt' });
} catch (error) {
logger.error('Passwort setzen Fehler:', error);
res.status(500).json({ success: false, message: 'Serverfehler' });
}
};
// PUT /api/handler/me — handler updates own availability + contact data
const updateHandlerSelf = async (req, res) => {
try {
const userId = req.handlerUser.id;
const { available, phone, landline, address } = req.body;
const update = {};
if (typeof available === 'boolean') update.available = available;
if (phone && phone.trim()) update.phone = phone.trim();
if (landline !== undefined) update.landline = landline ? landline.trim() : null;
if (address && address.trim()) update.address = address.trim();
const user = await User.findByIdAndUpdate(
userId,
update,
{ new: true, select: '-passwordHash' }
);
if (!user) {
return res.status(404).json({ success: false, message: 'Drohnenführer nicht gefunden' });
}
res.json({ success: true, data: user });
} catch (error) {
logger.error('Handler-Update Fehler:', error);
res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren' });
}
};
// GET /api/handler/me — get own profile
const getHandlerSelf = async (req, res) => {
try {
const user = await User.findById(req.handlerUser.id).select('-passwordHash');
if (!user) return res.status(404).json({ success: false, message: 'Nicht gefunden' });
res.json({ success: true, data: user });
} catch (error) {
res.status(500).json({ success: false, message: 'Serverfehler' });
}
};
module.exports = { handlerLogin, setHandlerPassword, updateHandlerSelf, getHandlerSelf };

View File

@ -473,7 +473,12 @@ const importUsers = async (req, res) => {
}); });
} }
const overwrite = req.query.overwrite === 'true'; if (importList.length > 500) {
return res.status(400).json({
success: false,
message: 'Zu viele Einträge. Maximal 500 Benutzer pro Import erlaubt.'
});
}
const results = { imported: 0, skipped: 0, errors: [] }; const results = { imported: 0, skipped: 0, errors: [] };

View File

@ -1,7 +1,7 @@
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const config = require('../config/env'); const config = require('../config/env');
const authenticateHandler = (req, res, next) => { const authenticateDrohnenfuehrer = (req, res, next) => {
const authHeader = req.headers['authorization']; const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; const token = authHeader && authHeader.split(' ')[1];
@ -11,14 +11,14 @@ const authenticateHandler = (req, res, next) => {
try { try {
const decoded = jwt.verify(token, config.jwtSecret); const decoded = jwt.verify(token, config.jwtSecret);
if (decoded.role !== 'handler') { if (decoded.role !== 'drohnenfuehrer') {
return res.status(403).json({ success: false, message: 'Zugriff verweigert' }); return res.status(403).json({ success: false, message: 'Zugriff verweigert' });
} }
req.handlerUser = decoded; req.drohnenfuehrerUser = decoded;
next(); next();
} catch (error) { } catch (error) {
return res.status(403).json({ success: false, message: 'Ungültiger oder abgelaufener Token' }); return res.status(403).json({ success: false, message: 'Ungültiger oder abgelaufener Token' });
} }
}; };
module.exports = { authenticateHandler }; module.exports = { authenticateDrohnenfuehrer };

View File

@ -40,7 +40,28 @@ const authLimiter = rateLimit({
} }
}); });
// Strict rate limiter for invite / set-password endpoints
const inviteLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // Max 10 attempts per windowMs
skipSuccessfulRequests: true,
message: {
success: false,
message: 'Zu viele Versuche. Bitte warten Sie 15 Minuten.'
},
standardHeaders: true,
legacyHeaders: false,
handler: (req, res) => {
logger.warn(`Invite rate limit exceeded for IP: ${req.ip}`);
res.status(429).json({
success: false,
message: 'Zu viele Versuche. Bitte warten Sie 15 Minuten.'
});
}
});
module.exports = { module.exports = {
apiLimiter, apiLimiter,
authLimiter authLimiter,
inviteLimiter
}; };

View File

@ -15,7 +15,7 @@ const auditLogSchema = new mongoose.Schema({
resource: { resource: {
type: String, type: String,
required: true, required: true,
enum: ['User', 'Config', 'Admin', 'Handler'], enum: ['User', 'Config', 'Admin', 'Drohnenfuehrer'],
index: true index: true
}, },
resourceId: { resourceId: {

View File

@ -70,7 +70,7 @@ const userSchema = new mongoose.Schema({
ref: 'Admin', ref: 'Admin',
default: null default: null
}, },
// Handler invite token for initial password setup // Drohnenführer invite token for initial password setup
inviteToken: { inviteToken: {
type: String, type: String,
default: null, default: null,

View File

@ -0,0 +1,19 @@
const express = require('express');
const router = express.Router();
const { drohnenfuehrerLogin, setDrohnenfuehrerPassword, generateInviteToken, updateDrohnenfuehrerSelf, getDrohnenfuehrerSelf } = require('../controllers/drohnenfuehrerController');
const { authenticateToken } = require('../middleware/auth');
const { authenticateDrohnenfuehrer } = require('../middleware/drohnenfuehrerAuth');
const { inviteLimiter } = require('../middleware/rateLimiter');
// Public
router.post('/login', drohnenfuehrerLogin);
router.post('/set-password', inviteLimiter, setDrohnenfuehrerPassword);
// Admin only — generate a one-time invite token so a Drohnenführer can set their password
router.post('/:id/invite-token', authenticateToken, generateInviteToken);
// Authenticated Drohnenführer only
router.get('/me', authenticateDrohnenfuehrer, getDrohnenfuehrerSelf);
router.put('/me', authenticateDrohnenfuehrer, updateDrohnenfuehrerSelf);
module.exports = router;

View File

@ -1,18 +0,0 @@
const express = require('express');
const router = express.Router();
const { handlerLogin, setHandlerPassword, generateInviteToken, updateHandlerSelf, getHandlerSelf } = require('../controllers/handlerController');
const { authenticateToken } = require('../middleware/auth');
const { authenticateHandler } = require('../middleware/handlerAuth');
// Public
router.post('/login', handlerLogin);
router.post('/set-password', setHandlerPassword);
// Admin only — generate a one-time invite token so a handler can set their password
router.post('/:id/invite-token', authenticateToken, generateInviteToken);
// Authenticated handler only
router.get('/me', authenticateHandler, getHandlerSelf);
router.put('/me', authenticateHandler, updateHandlerSelf);
module.exports = router;

View File

@ -24,7 +24,7 @@ const seedDatabase = async () => {
// Seed admins (only if not exists) // Seed admins (only if not exists)
const adminAccounts = [ const adminAccounts = [
{ username: 'admin', passwordEnvVar: 'ADMIN_PASSWORD', defaultPassword: process.env.ADMIN_PASSWORD || 'admin123' }, { username: 'admin', passwordEnvVar: 'ADMIN_PASSWORD', defaultPassword: process.env.ADMIN_PASSWORD || null },
{ username: 'ThorstenMeyer', passwordEnvVar: 'ADMIN_THORSTEN_PASSWORD', defaultPassword: process.env.ADMIN_THORSTEN_PASSWORD || null } { username: 'ThorstenMeyer', passwordEnvVar: 'ADMIN_THORSTEN_PASSWORD', defaultPassword: process.env.ADMIN_THORSTEN_PASSWORD || null }
]; ];

View File

@ -68,7 +68,7 @@ app.use('/api/auth', authLimiter, require('./routes/authRoutes')); // Strict lim
app.use('/api', require('./routes/userRoutes')); app.use('/api', require('./routes/userRoutes'));
app.use('/api', require('./routes/auditRoutes')); app.use('/api', require('./routes/auditRoutes'));
app.use('/api/config', require('./routes/configRoutes')); app.use('/api/config', require('./routes/configRoutes'));
app.use('/api/handler', require('./routes/handlerRoutes')); app.use('/api/drohnenfuehrer', require('./routes/drohnenfuehrerRoutes'));
// Health check with basic system info // Health check with basic system info
app.get('/health', async (req, res) => { app.get('/health', async (req, res) => {

View File

@ -20,9 +20,13 @@ services:
build: ./backend build: ./backend
container_name: drohnenfuehrer-backend container_name: drohnenfuehrer-backend
restart: unless-stopped restart: unless-stopped
# Port 5011 only bound to localhost # Port 5011 bound to localhost only NOT accessible from external IPs.
# Portal nginx reaches the backend via the shared jagd-network (container name).
ports: ports:
- "0.0.0.0:5011:5000" - "127.0.0.1:5011:5000"
networks:
- default
- jagd-network
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- MONGO_URI=mongodb://drohnenfuehrer:${MONGO_PASSWORD}@mongo:27017/drohnenfuehrer?authSource=admin - MONGO_URI=mongodb://drohnenfuehrer:${MONGO_PASSWORD}@mongo:27017/drohnenfuehrer?authSource=admin
@ -52,7 +56,10 @@ services:
container_name: drohnenfuehrer-frontend container_name: drohnenfuehrer-frontend
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8081:80" - "127.0.0.1:8081:80"
networks:
- default
- jagd-network
depends_on: depends_on:
- backend - backend
healthcheck: healthcheck:
@ -64,3 +71,9 @@ services:
volumes: volumes:
mongo-data: mongo-data:
name: drohnenfuehrer-mongo-data name: drohnenfuehrer-mongo-data
networks:
default: {}
jagd-network:
external: true
name: jagd-network

View File

@ -8,8 +8,8 @@ import PasswordReset from './components/auth/PasswordReset';
import Home from './pages/Home'; import Home from './pages/Home';
import Rules from './pages/Rules'; import Rules from './pages/Rules';
import Admin from './pages/Admin'; import Admin from './pages/Admin';
import HandlerLogin from './components/handler/HandlerLogin'; import DrohnenfuehrerLogin from './components/drohnenfuehrer/DrohnenfuehrerLogin';
import HandlerDashboard from './components/handler/HandlerDashboard'; import DrohnenfuehrerDashboard from './components/drohnenfuehrer/DrohnenfuehrerDashboard';
import InstallBanner from './components/common/InstallBanner'; import InstallBanner from './components/common/InstallBanner';
import './App.css'; import './App.css';
@ -19,7 +19,7 @@ function App() {
const isAdminRoute = window.location.pathname === adminPath; const isAdminRoute = window.location.pathname === adminPath;
const isResetPasswordRoute = window.location.pathname === resetPasswordPath; const isResetPasswordRoute = window.location.pathname === resetPasswordPath;
const [view, setView] = useState('public'); const [view, setView] = useState('public');
const [handlerUser, setHandlerUser] = useState(null); const [drohnenfuehrerUser, setDrohnenfuehrerUser] = useState(null);
const { isAuthenticated, loading: authLoading, login, logout } = useAuth(); const { isAuthenticated, loading: authLoading, login, logout } = useAuth();
const { users, loading: usersLoading, error, refetch } = useUsers(isAuthenticated); const { users, loading: usersLoading, error, refetch } = useUsers(isAuthenticated);
@ -37,13 +37,13 @@ function App() {
setView('public'); setView('public');
}; };
const handleHandlerLogin = (userData) => { const handleDrohnenfuehrerLogin = (userData) => {
setHandlerUser(userData); setDrohnenfuehrerUser(userData);
setView('handler-dashboard'); setView('drohnenfuehrer-dashboard');
}; };
const handleHandlerLogout = () => { const handleDrohnenfuehrerLogout = () => {
setHandlerUser(null); setDrohnenfuehrerUser(null);
setView('public'); setView('public');
}; };
@ -97,15 +97,15 @@ function App() {
isAdmin={false} isAdmin={false}
currentView={view} currentView={view}
onViewChange={handleViewChange} onViewChange={handleViewChange}
onHandlerLogin={handleHandlerLogout} onDrohnenfuehrerLogin={handleDrohnenfuehrerLogout}
/> />
<main className="app-main"> <main className="app-main">
{isAdminRoute || view === 'login' ? ( {isAdminRoute || view === 'login' ? (
<LoginForm onLogin={handleLogin} loading={authLoading} /> <LoginForm onLogin={handleLogin} loading={authLoading} />
) : view === 'handler-login' ? ( ) : view === 'drohnenfuehrer-login' ? (
<HandlerLogin onLogin={handleHandlerLogin} /> <DrohnenfuehrerLogin onLogin={handleDrohnenfuehrerLogin} />
) : view === 'handler-dashboard' && handlerUser ? ( ) : view === 'drohnenfuehrer-dashboard' && drohnenfuehrerUser ? (
<HandlerDashboard handlerUser={handlerUser} onLogout={handleHandlerLogout} /> <DrohnenfuehrerDashboard drohnenfuehrerUser={drohnenfuehrerUser} onLogout={handleDrohnenfuehrerLogout} />
) : ( ) : (
<> <>
{view === 'rules' && <Rules />} {view === 'rules' && <Rules />}

View File

@ -348,7 +348,7 @@ function AuditLogs() {
<option value="User">Benutzer</option> <option value="User">Benutzer</option>
<option value="Admin">Admin</option> <option value="Admin">Admin</option>
<option value="Config">Konfiguration</option> <option value="Config">Konfiguration</option>
<option value="Handler">Handler</option> <option value="Drohnenfuehrer">Drohnenführer</option>
</select> </select>
<select value={filters.success} onChange={e => setFilter('success', e.target.value)} className="filter-select"> <select value={filters.success} onChange={e => setFilter('success', e.target.value)} className="filter-select">

View File

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

View File

@ -1,10 +1,10 @@
.handler-dashboard { .drohnenfuehrer-dashboard {
display: flex; display: flex;
justify-content: center; justify-content: center;
padding: 2rem 1rem; padding: 2rem 1rem;
} }
.handler-dashboard-card { .drohnenfuehrer-dashboard-card {
background: white; background: white;
border-radius: 10px; border-radius: 10px;
padding: 2rem; padding: 2rem;
@ -14,26 +14,26 @@
border-top: 4px solid var(--color-primary); border-top: 4px solid var(--color-primary);
} }
.handler-dashboard-header { .drohnenfuehrer-dashboard-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.handler-dashboard-header h2 { .drohnenfuehrer-dashboard-header h2 {
margin: 0 0 0.25rem; margin: 0 0 0.25rem;
color: var(--color-primary); color: var(--color-primary);
} }
.handler-name { .drohnenfuehrer-name {
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
margin: 0; margin: 0;
color: var(--color-text); color: var(--color-text);
} }
.btn-handler-logout { .btn-drohnenfuehrer-logout {
padding: 0.4rem 0.9rem; padding: 0.4rem 0.9rem;
background: none; background: none;
border: 1px solid var(--color-danger); border: 1px solid var(--color-danger);
@ -44,22 +44,22 @@
transition: background 0.2s; transition: background 0.2s;
} }
.btn-handler-logout:hover { .btn-drohnenfuehrer-logout:hover {
background: var(--color-danger); background: var(--color-danger);
color: white; color: white;
} }
.handler-message { .drohnenfuehrer-message {
padding: 0.6rem 1rem; padding: 0.6rem 1rem;
border-radius: 4px; border-radius: 4px;
margin-bottom: 1rem; margin-bottom: 1rem;
font-size: 0.9rem; font-size: 0.9rem;
} }
.handler-message-success { background: #dcfce7; color: #166534; } .drohnenfuehrer-message-success { background: #dcfce7; color: #166534; }
.handler-message-error { background: #fee2e2; color: #b91c1c; } .drohnenfuehrer-message-error { background: #fee2e2; color: #b91c1c; }
.handler-availability-section { .drohnenfuehrer-availability-section {
background: var(--color-muted-bg); background: var(--color-muted-bg);
border-radius: 8px; border-radius: 8px;
padding: 1.25rem; padding: 1.25rem;
@ -119,25 +119,25 @@
margin: 0.6rem 0 0; margin: 0.6rem 0 0;
} }
.handler-contact-section { .drohnenfuehrer-contact-section {
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
padding-top: 1.25rem; padding-top: 1.25rem;
} }
.handler-section-header { .drohnenfuehrer-section-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.handler-section-header h3 { .drohnenfuehrer-section-header h3 {
margin: 0; margin: 0;
font-size: 1rem; font-size: 1rem;
color: var(--color-text); color: var(--color-text);
} }
.btn-handler-edit { .btn-drohnenfuehrer-edit {
padding: 0.35rem 0.8rem; padding: 0.35rem 0.8rem;
background: none; background: none;
border: 1px solid var(--color-primary); border: 1px solid var(--color-primary);
@ -147,11 +147,11 @@
font-size: 0.9rem; font-size: 0.9rem;
} }
.btn-handler-edit:hover { background: var(--color-primary); color: white; } .btn-drohnenfuehrer-edit:hover { background: var(--color-primary); color: white; }
.handler-info-list { display: flex; flex-direction: column; gap: 0.6rem; } .drohnenfuehrer-info-list { display: flex; flex-direction: column; gap: 0.6rem; }
.handler-info-row { .drohnenfuehrer-info-row {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
font-size: 0.95rem; font-size: 0.95rem;
@ -163,25 +163,25 @@
min-width: 80px; min-width: 80px;
} }
.handler-info-row a { .drohnenfuehrer-info-row a {
color: var(--color-primary); color: var(--color-primary);
text-decoration: none; text-decoration: none;
} }
.handler-info-row a:hover { text-decoration: underline; } .drohnenfuehrer-info-row a:hover { text-decoration: underline; }
.handler-edit-form .form-group { .drohnenfuehrer-edit-form .form-group {
margin-bottom: 0.85rem; margin-bottom: 0.85rem;
} }
.handler-edit-form .form-group label { .drohnenfuehrer-edit-form .form-group label {
display: block; display: block;
font-weight: 600; font-weight: 600;
margin-bottom: 0.3rem; margin-bottom: 0.3rem;
font-size: 0.9rem; font-size: 0.9rem;
} }
.handler-edit-form .form-group input { .drohnenfuehrer-edit-form .form-group input {
width: 100%; width: 100%;
padding: 0.6rem 0.75rem; padding: 0.6rem 0.75rem;
border: 1px solid var(--color-border-strong); border: 1px solid var(--color-border-strong);
@ -190,19 +190,19 @@
box-sizing: border-box; box-sizing: border-box;
} }
.handler-edit-form .form-group input:focus { .drohnenfuehrer-edit-form .form-group input:focus {
outline: none; outline: none;
border-color: var(--color-primary); border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-focus); box-shadow: 0 0 0 3px var(--color-focus);
} }
.handler-form-actions { .drohnenfuehrer-form-actions {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
margin-top: 1rem; margin-top: 1rem;
} }
.btn-handler-primary { .btn-drohnenfuehrer-primary {
flex: 1; flex: 1;
padding: 0.65rem; padding: 0.65rem;
background: var(--color-primary); background: var(--color-primary);
@ -214,10 +214,10 @@
cursor: pointer; cursor: pointer;
} }
.btn-handler-primary:hover:not(:disabled) { background: var(--color-primary-dark); } .btn-drohnenfuehrer-primary:hover:not(:disabled) { background: var(--color-primary-dark); }
.btn-handler-primary:disabled { opacity: 0.6; cursor: not-allowed; } .btn-drohnenfuehrer-primary:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-handler-cancel { .btn-drohnenfuehrer-cancel {
flex: 1; flex: 1;
padding: 0.65rem; padding: 0.65rem;
background: none; background: none;
@ -228,4 +228,4 @@
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.btn-handler-cancel:hover { background: var(--color-muted-bg); } .btn-drohnenfuehrer-cancel:hover { background: var(--color-muted-bg); }

View File

@ -1,15 +1,15 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { updateHandlerMe } from '../../services/handler'; import { updateDrohnenfuehrerMe } from '../../services/drohnenfuehrer';
import './HandlerDashboard.css'; import './DrohnenfuehrerDashboard.css';
const HandlerDashboard = ({ handlerUser, onLogout }) => { const DrohnenfuehrerDashboard = ({ drohnenfuehrerUser, onLogout }) => {
const [userData, setUserData] = useState(handlerUser); const [userData, setUserData] = useState(drohnenfuehrerUser);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
phone: handlerUser.phone || '', phone: drohnenfuehrerUser.phone || '',
landline: handlerUser.landline || '', landline: drohnenfuehrerUser.landline || '',
address: handlerUser.address || '', address: drohnenfuehrerUser.address || '',
available: handlerUser.available available: drohnenfuehrerUser.available
}); });
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [message, setMessage] = useState(null); const [message, setMessage] = useState(null);
@ -19,7 +19,7 @@ const HandlerDashboard = ({ handlerUser, onLogout }) => {
setSaving(true); setSaving(true);
setMessage(null); setMessage(null);
try { try {
const result = await updateHandlerMe(formData); const result = await updateDrohnenfuehrerMe(formData);
if (result.success) { if (result.success) {
setUserData({ ...userData, ...formData }); setUserData({ ...userData, ...formData });
setEditing(false); setEditing(false);
@ -37,7 +37,7 @@ const HandlerDashboard = ({ handlerUser, onLogout }) => {
setSaving(true); setSaving(true);
setMessage(null); setMessage(null);
try { try {
const result = await updateHandlerMe({ available: newVal }); const result = await updateDrohnenfuehrerMe({ available: newVal });
if (result.success) { if (result.success) {
setUserData({ ...userData, available: newVal }); setUserData({ ...userData, available: newVal });
setFormData({ ...formData, available: newVal }); setFormData({ ...formData, available: newVal });
@ -51,27 +51,27 @@ const HandlerDashboard = ({ handlerUser, onLogout }) => {
}; };
const handleLogout = () => { const handleLogout = () => {
localStorage.removeItem('handlerToken'); localStorage.removeItem('drohnenfuehrerToken');
onLogout(); onLogout();
}; };
return ( return (
<div className="handler-dashboard"> <div className="drohnenfuehrer-dashboard">
<div className="handler-dashboard-card"> <div className="drohnenfuehrer-dashboard-card">
<div className="handler-dashboard-header"> <div className="drohnenfuehrer-dashboard-header">
<div> <div>
<h2>Mein Profil</h2> <h2>Mein Profil</h2>
<p className="handler-name">{userData.name}</p> <p className="drohnenfuehrer-name">{userData.name}</p>
</div> </div>
<button className="btn-handler-logout" onClick={handleLogout}>Abmelden</button> <button className="btn-drohnenfuehrer-logout" onClick={handleLogout}>Abmelden</button>
</div> </div>
{message && ( {message && (
<div className={`handler-message handler-message-${message.type}`}>{message.text}</div> <div className={`drohnenfuehrer-message drohnenfuehrer-message-${message.type}`}>{message.text}</div>
)} )}
{/* Availability Toggle */} {/* Availability Toggle */}
<div className="handler-availability-section"> <div className="drohnenfuehrer-availability-section">
<div className="availability-label">Meine Verfügbarkeit</div> <div className="availability-label">Meine Verfügbarkeit</div>
<button <button
className={`availability-big-btn ${userData.available ? 'btn-green' : 'btn-red'}`} className={`availability-big-btn ${userData.available ? 'btn-green' : 'btn-red'}`}
@ -82,38 +82,38 @@ const HandlerDashboard = ({ handlerUser, onLogout }) => {
{userData.available ? '✅ Verfügbar zum Wechsel klicken' : '🔴 Nicht verfügbar zum Wechsel klicken'} {userData.available ? '✅ Verfügbar zum Wechsel klicken' : '🔴 Nicht verfügbar zum Wechsel klicken'}
</button> </button>
<p className="availability-hint"> <p className="availability-hint">
Klicken Sie, um Ihre Verfügbarkeit zu ändern. Nur verfügbare Gespanne erscheinen in der öffentlichen Suchliste. Klicken Sie, um Ihre Verfügbarkeit zu ändern. Nur verfügbare Drohnenführer erscheinen in der öffentlichen Suchliste.
</p> </p>
</div> </div>
{/* Contact Data */} {/* Contact Data */}
<div className="handler-contact-section"> <div className="drohnenfuehrer-contact-section">
<div className="handler-section-header"> <div className="drohnenfuehrer-section-header">
<h3>Kontaktdaten</h3> <h3>Kontaktdaten</h3>
{!editing && ( {!editing && (
<button className="btn-handler-edit" onClick={() => setEditing(true)}>Bearbeiten</button> <button className="btn-drohnenfuehrer-edit" onClick={() => setEditing(true)}>Bearbeiten</button>
)} )}
</div> </div>
{!editing ? ( {!editing ? (
<div className="handler-info-list"> <div className="drohnenfuehrer-info-list">
<div className="handler-info-row"> <div className="drohnenfuehrer-info-row">
<span className="info-label">Adresse</span> <span className="info-label">Adresse</span>
<span>{userData.address}</span> <span>{userData.address}</span>
</div> </div>
<div className="handler-info-row"> <div className="drohnenfuehrer-info-row">
<span className="info-label">Mobil</span> <span className="info-label">Mobil</span>
<a href={`tel:${userData.phone}`}>{userData.phone}</a> <a href={`tel:${userData.phone}`}>{userData.phone}</a>
</div> </div>
{userData.landline && ( {userData.landline && (
<div className="handler-info-row"> <div className="drohnenfuehrer-info-row">
<span className="info-label">Festnetz</span> <span className="info-label">Festnetz</span>
<a href={`tel:${userData.landline}`}>{userData.landline}</a> <a href={`tel:${userData.landline}`}>{userData.landline}</a>
</div> </div>
)} )}
</div> </div>
) : ( ) : (
<form onSubmit={handleSave} className="handler-edit-form"> <form onSubmit={handleSave} className="drohnenfuehrer-edit-form">
<div className="form-group"> <div className="form-group">
<label>Adresse</label> <label>Adresse</label>
<input type="text" value={formData.address} <input type="text" value={formData.address}
@ -129,11 +129,11 @@ const HandlerDashboard = ({ handlerUser, onLogout }) => {
<input type="tel" value={formData.landline} <input type="tel" value={formData.landline}
onChange={e => setFormData({ ...formData, landline: e.target.value })} /> onChange={e => setFormData({ ...formData, landline: e.target.value })} />
</div> </div>
<div className="handler-form-actions"> <div className="drohnenfuehrer-form-actions">
<button type="submit" className="btn-handler-primary" disabled={saving}> <button type="submit" className="btn-drohnenfuehrer-primary" disabled={saving}>
{saving ? 'Speichert...' : 'Speichern'} {saving ? 'Speichert...' : 'Speichern'}
</button> </button>
<button type="button" className="btn-handler-cancel" onClick={() => setEditing(false)}> <button type="button" className="btn-drohnenfuehrer-cancel" onClick={() => setEditing(false)}>
Abbrechen Abbrechen
</button> </button>
</div> </div>
@ -145,4 +145,4 @@ const HandlerDashboard = ({ handlerUser, onLogout }) => {
); );
}; };
export default HandlerDashboard; export default DrohnenfuehrerDashboard;

View File

@ -1,11 +1,11 @@
.handler-login-container { .drohnenfuehrer-login-container {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: flex-start; align-items: flex-start;
padding: 2rem 1rem; padding: 2rem 1rem;
} }
.handler-login-card { .drohnenfuehrer-login-card {
background: white; background: white;
border-radius: 10px; border-radius: 10px;
padding: 2rem; padding: 2rem;
@ -15,18 +15,18 @@
border-top: 4px solid var(--color-primary); border-top: 4px solid var(--color-primary);
} }
.handler-login-card h2 { .drohnenfuehrer-login-card h2 {
margin: 0 0 0.5rem; margin: 0 0 0.5rem;
color: var(--color-primary); color: var(--color-primary);
} }
.handler-login-subtitle { .drohnenfuehrer-login-subtitle {
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: 0.9rem; font-size: 0.9rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.handler-mode-toggle { .drohnenfuehrer-mode-toggle {
display: flex; display: flex;
gap: 0; gap: 0;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
@ -35,7 +35,7 @@
overflow: hidden; overflow: hidden;
} }
.handler-mode-toggle button { .drohnenfuehrer-mode-toggle button {
flex: 1; flex: 1;
padding: 0.5rem; padding: 0.5rem;
border: none; border: none;
@ -46,24 +46,24 @@
transition: background 0.2s; transition: background 0.2s;
} }
.handler-mode-toggle button.active { .drohnenfuehrer-mode-toggle button.active {
background: var(--color-primary); background: var(--color-primary);
color: white; color: white;
font-weight: 600; font-weight: 600;
} }
.handler-form .form-group { .drohnenfuehrer-form .form-group {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.handler-form .form-group label { .drohnenfuehrer-form .form-group label {
display: block; display: block;
font-weight: 600; font-weight: 600;
margin-bottom: 0.3rem; margin-bottom: 0.3rem;
font-size: 0.9rem; font-size: 0.9rem;
} }
.handler-form .form-group input { .drohnenfuehrer-form .form-group input {
width: 100%; width: 100%;
padding: 0.6rem 0.75rem; padding: 0.6rem 0.75rem;
border: 1px solid var(--color-border-strong); border: 1px solid var(--color-border-strong);
@ -72,13 +72,13 @@
box-sizing: border-box; box-sizing: border-box;
} }
.handler-form .form-group input:focus { .drohnenfuehrer-form .form-group input:focus {
outline: none; outline: none;
border-color: var(--color-primary); border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-focus); box-shadow: 0 0 0 3px var(--color-focus);
} }
.btn-handler-primary { .btn-drohnenfuehrer-primary {
width: 100%; width: 100%;
padding: 0.75rem; padding: 0.75rem;
background: var(--color-primary); background: var(--color-primary);
@ -92,16 +92,16 @@
transition: background 0.2s; transition: background 0.2s;
} }
.btn-handler-primary:hover:not(:disabled) { .btn-drohnenfuehrer-primary:hover:not(:disabled) {
background: var(--color-primary-dark); background: var(--color-primary-dark);
} }
.btn-handler-primary:disabled { .btn-drohnenfuehrer-primary:disabled {
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
} }
.handler-error { .drohnenfuehrer-error {
background: #fee2e2; background: #fee2e2;
color: #b91c1c; color: #b91c1c;
border-radius: 4px; border-radius: 4px;
@ -110,7 +110,7 @@
font-size: 0.9rem; font-size: 0.9rem;
} }
.handler-success { .drohnenfuehrer-success {
background: #dcfce7; background: #dcfce7;
color: #166534; color: #166534;
border-radius: 4px; border-radius: 4px;
@ -119,7 +119,7 @@
font-size: 0.9rem; font-size: 0.9rem;
} }
.handler-hint { .drohnenfuehrer-hint {
font-size: 0.88rem; font-size: 0.88rem;
color: var(--color-text-muted); color: var(--color-text-muted);
margin-bottom: 1rem; margin-bottom: 1rem;

View File

@ -1,8 +1,8 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { handlerLogin, setHandlerPassword } from '../../services/handler'; import { drohnenfuehrerLogin, setDrohnenfuehrerPassword } from '../../services/drohnenfuehrer';
import './HandlerLogin.css'; import './DrohnenfuehrerLogin.css';
const HandlerLogin = ({ onLogin }) => { const DrohnenfuehrerLogin = ({ onLogin }) => {
const [mode, setMode] = useState('login'); // 'login' | 'set-password' const [mode, setMode] = useState('login'); // 'login' | 'set-password'
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@ -17,9 +17,9 @@ const HandlerLogin = ({ onLogin }) => {
setError(''); setError('');
setLoading(true); setLoading(true);
try { try {
const result = await handlerLogin(email, password); const result = await drohnenfuehrerLogin(email, password);
if (result.success) { if (result.success) {
localStorage.setItem('handlerToken', result.token); localStorage.setItem('drohnenfuehrerToken', result.token);
onLogin(result.user); onLogin(result.user);
} }
} catch (err) { } catch (err) {
@ -42,7 +42,7 @@ const HandlerLogin = ({ onLogin }) => {
} }
setLoading(true); setLoading(true);
try { try {
const result = await setHandlerPassword(email, newPassword); const result = await setDrohnenfuehrerPassword(email, newPassword);
if (result.success) { if (result.success) {
setSuccess('Passwort erfolgreich gesetzt. Sie können sich nun anmelden.'); setSuccess('Passwort erfolgreich gesetzt. Sie können sich nun anmelden.');
setMode('login'); setMode('login');
@ -57,14 +57,14 @@ const HandlerLogin = ({ onLogin }) => {
}; };
return ( return (
<div className="handler-login-container"> <div className="drohnenfuehrer-login-container">
<div className="handler-login-card"> <div className="drohnenfuehrer-login-card">
<h2>Drohnenführer-Login</h2> <h2>Drohnenführer-Login</h2>
<p className="handler-login-subtitle"> <p className="drohnenfuehrer-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>
<div className="handler-mode-toggle"> <div className="drohnenfuehrer-mode-toggle">
<button <button
className={mode === 'login' ? 'active' : ''} className={mode === 'login' ? 'active' : ''}
onClick={() => { setMode('login'); setError(''); setSuccess(''); }} onClick={() => { setMode('login'); setError(''); setSuccess(''); }}
@ -79,11 +79,11 @@ const HandlerLogin = ({ onLogin }) => {
</button> </button>
</div> </div>
{error && <div className="handler-error">{error}</div>} {error && <div className="drohnenfuehrer-error">{error}</div>}
{success && <div className="handler-success">{success}</div>} {success && <div className="drohnenfuehrer-success">{success}</div>}
{mode === 'login' ? ( {mode === 'login' ? (
<form onSubmit={handleLogin} className="handler-form"> <form onSubmit={handleLogin} className="drohnenfuehrer-form">
<div className="form-group"> <div className="form-group">
<label>E-Mail</label> <label>E-Mail</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus /> <input type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
@ -92,13 +92,13 @@ const HandlerLogin = ({ onLogin }) => {
<label>Passwort</label> <label>Passwort</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required /> <input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
</div> </div>
<button type="submit" className="btn-handler-primary" disabled={loading}> <button type="submit" className="btn-drohnenfuehrer-primary" disabled={loading}>
{loading ? 'Anmelden...' : 'Anmelden'} {loading ? 'Anmelden...' : 'Anmelden'}
</button> </button>
</form> </form>
) : ( ) : (
<form onSubmit={handleSetPassword} className="handler-form"> <form onSubmit={handleSetPassword} className="drohnenfuehrer-form">
<p className="handler-hint"> <p className="drohnenfuehrer-hint">
Der Admin hat Ihre E-Mail-Adresse hinterlegt. Geben Sie hier Ihre E-Mail und ein neues Passwort ein. Der Admin hat Ihre E-Mail-Adresse hinterlegt. Geben Sie hier Ihre E-Mail und ein neues Passwort ein.
</p> </p>
<div className="form-group"> <div className="form-group">
@ -113,7 +113,7 @@ const HandlerLogin = ({ onLogin }) => {
<label>Passwort wiederholen</label> <label>Passwort wiederholen</label>
<input type="password" value={newPassword2} onChange={e => setNewPassword2(e.target.value)} required /> <input type="password" value={newPassword2} onChange={e => setNewPassword2(e.target.value)} required />
</div> </div>
<button type="submit" className="btn-handler-primary" disabled={loading}> <button type="submit" className="btn-drohnenfuehrer-primary" disabled={loading}>
{loading ? 'Wird gesetzt...' : 'Passwort setzen'} {loading ? 'Wird gesetzt...' : 'Passwort setzen'}
</button> </button>
</form> </form>
@ -123,4 +123,4 @@ const HandlerLogin = ({ onLogin }) => {
); );
}; };
export default HandlerLogin; export default DrohnenfuehrerLogin;

View File

@ -0,0 +1,34 @@
import axios from 'axios';
import { API_BASE_URL } from '../utils/constants';
const drohnenfuehrerApi = axios.create({
baseURL: `${API_BASE_URL}/api/drohnenfuehrer`,
timeout: 10000,
headers: { 'Content-Type': 'application/json' }
});
drohnenfuehrerApi.interceptors.request.use((config) => {
const token = localStorage.getItem('drohnenfuehrerToken');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
export const drohnenfuehrerLogin = async (email, password) => {
const response = await drohnenfuehrerApi.post('/login', { email, password });
return response.data;
};
export const setDrohnenfuehrerPassword = async (email, newPassword) => {
const response = await drohnenfuehrerApi.post('/set-password', { email, newPassword });
return response.data;
};
export const getDrohnenfuehrerMe = async () => {
const response = await drohnenfuehrerApi.get('/me');
return response.data;
};
export const updateDrohnenfuehrerMe = async (data) => {
const response = await drohnenfuehrerApi.put('/me', data);
return response.data;
};

View File

@ -1,34 +0,0 @@
import axios from 'axios';
import { API_BASE_URL } from '../utils/constants';
const handlerApi = axios.create({
baseURL: `${API_BASE_URL}/api/handler`,
timeout: 10000,
headers: { 'Content-Type': 'application/json' }
});
handlerApi.interceptors.request.use((config) => {
const token = localStorage.getItem('handlerToken');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
export const handlerLogin = async (email, password) => {
const response = await handlerApi.post('/login', { email, password });
return response.data;
};
export const setHandlerPassword = async (email, newPassword) => {
const response = await handlerApi.post('/set-password', { email, newPassword });
return response.data;
};
export const getHandlerMe = async () => {
const response = await handlerApi.get('/me');
return response.data;
};
export const updateHandlerMe = async (data) => {
const response = await handlerApi.put('/me', data);
return response.data;
};

View File

@ -473,7 +473,12 @@ const importUsers = async (req, res) => {
}); });
} }
const overwrite = req.query.overwrite === 'true'; if (importList.length > 500) {
return res.status(400).json({
success: false,
message: 'Zu viele Einträge. Maximal 500 Benutzer pro Import erlaubt.'
});
}
const results = { imported: 0, skipped: 0, errors: [] }; const results = { imported: 0, skipped: 0, errors: [] };

View File

@ -40,7 +40,28 @@ const authLimiter = rateLimit({
} }
}); });
// Strict rate limiter for invite / set-password endpoints
const inviteLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
skipSuccessfulRequests: true,
message: {
success: false,
message: 'Zu viele Versuche. Bitte warten Sie 15 Minuten.'
},
standardHeaders: true,
legacyHeaders: false,
handler: (req, res) => {
logger.warn(`Invite rate limit exceeded for IP: ${req.ip}`);
res.status(429).json({
success: false,
message: 'Zu viele Versuche. Bitte warten Sie 15 Minuten.'
});
}
});
module.exports = { module.exports = {
apiLimiter, apiLimiter,
authLimiter authLimiter,
inviteLimiter
}; };

View File

@ -3,10 +3,11 @@ const router = express.Router();
const { handlerLogin, setHandlerPassword, generateInviteToken, updateHandlerSelf, getHandlerSelf } = require('../controllers/handlerController'); const { handlerLogin, setHandlerPassword, generateInviteToken, updateHandlerSelf, getHandlerSelf } = require('../controllers/handlerController');
const { authenticateToken } = require('../middleware/auth'); const { authenticateToken } = require('../middleware/auth');
const { authenticateHandler } = require('../middleware/handlerAuth'); const { authenticateHandler } = require('../middleware/handlerAuth');
const { inviteLimiter } = require('../middleware/rateLimiter');
// Public // Public
router.post('/login', handlerLogin); router.post('/login', handlerLogin);
router.post('/set-password', setHandlerPassword); router.post('/set-password', inviteLimiter, setHandlerPassword);
// Admin only — generate a one-time invite token so a handler can set their password // Admin only — generate a one-time invite token so a handler can set their password
router.post('/:id/invite-token', authenticateToken, generateInviteToken); router.post('/:id/invite-token', authenticateToken, generateInviteToken);

View File

@ -27,7 +27,7 @@ const seedDatabase = async () => {
// Seed admins (only if not exists) // Seed admins (only if not exists)
const adminAccounts = [ const adminAccounts = [
{ username: 'admin', passwordEnvVar: 'ADMIN_PASSWORD', defaultPassword: process.env.ADMIN_PASSWORD || 'admin123' }, { username: 'admin', passwordEnvVar: 'ADMIN_PASSWORD', defaultPassword: process.env.ADMIN_PASSWORD || null },
{ username: 'ThorstenMeyer', passwordEnvVar: 'ADMIN_THORSTEN_PASSWORD', defaultPassword: process.env.ADMIN_THORSTEN_PASSWORD || null } { username: 'ThorstenMeyer', passwordEnvVar: 'ADMIN_THORSTEN_PASSWORD', defaultPassword: process.env.ADMIN_THORSTEN_PASSWORD || null }
]; ];

View File

@ -22,9 +22,13 @@ services:
build: ./backend build: ./backend
container_name: nachsuche-backend container_name: nachsuche-backend
restart: unless-stopped restart: unless-stopped
# Port 5010 only bound to localhost - accessible by host nginx, NOT from external IPs # Port 5010 bound to localhost only NOT accessible from external IPs.
# Portal nginx reaches the backend via the shared jagd-network (container name).
ports: ports:
- "0.0.0.0:5010:5000" - "127.0.0.1:5010:5000"
networks:
- default
- jagd-network
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- MONGO_URI=mongodb://nachsuche:${MONGO_PASSWORD}@mongo:27017/nachsuche?authSource=admin - MONGO_URI=mongodb://nachsuche:${MONGO_PASSWORD}@mongo:27017/nachsuche?authSource=admin
@ -54,7 +58,10 @@ services:
container_name: nachsuche-frontend container_name: nachsuche-frontend
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8080:80" - "127.0.0.1:8080:80"
networks:
- default
- jagd-network
depends_on: depends_on:
- backend - backend
healthcheck: healthcheck:
@ -66,3 +73,9 @@ services:
volumes: volumes:
mongo-data: mongo-data:
name: nachsuche-mongo-data name: nachsuche-mongo-data
networks:
default: {}
jagd-network:
external: true
name: jagd-network

View File

@ -5,5 +5,12 @@ services:
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8090:80" - "8090:80"
extra_hosts: networks:
- "host.docker.internal:host-gateway" - default
- jagd-network
networks:
default: {}
jagd-network:
external: true
name: jagd-network

View File

@ -25,7 +25,7 @@ server {
# Nachsuche (/nachsuche/) # Nachsuche (/nachsuche/)
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
location /nachsuche/api/ { location /nachsuche/api/ {
proxy_pass http://host.docker.internal:5010/api/; proxy_pass http://nachsuche-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;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@ -36,7 +36,7 @@ server {
} }
location /nachsuche/ { location /nachsuche/ {
proxy_pass http://host.docker.internal:8080/; proxy_pass http://nachsuche-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;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@ -50,7 +50,7 @@ server {
# Drohnenführer (/drohnenfuehrer/) # Drohnenführer (/drohnenfuehrer/)
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
location /drohnenfuehrer/api/ { location /drohnenfuehrer/api/ {
proxy_pass http://host.docker.internal:5011/api/; proxy_pass http://drohnenfuehrer-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;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@ -61,7 +61,7 @@ server {
} }
location /drohnenfuehrer/ { location /drohnenfuehrer/ {
proxy_pass http://host.docker.internal:8081/; proxy_pass http://drohnenfuehrer-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;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@ -75,7 +75,7 @@ server {
# Stöberhunde (/stoeberhunde/) # Stöberhunde (/stoeberhunde/)
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
location /stoeberhunde/api/ { location /stoeberhunde/api/ {
proxy_pass http://host.docker.internal:5012/api/; proxy_pass http://stoeberhunde-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;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@ -86,7 +86,7 @@ server {
} }
location /stoeberhunde/ { location /stoeberhunde/ {
proxy_pass http://host.docker.internal:8082/; proxy_pass http://stoeberhunde-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;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

@ -81,7 +81,7 @@ const logout = async (req, res) => {
// Clear the token cookie // Clear the token cookie
res.clearCookie('token', { res.clearCookie('token', {
httpOnly: true, httpOnly: true,
secure: false, secure: config.nodeEnv === 'production',
sameSite: 'lax', sameSite: 'lax',
path: '/' path: '/'
}); });
@ -141,7 +141,11 @@ const forgotPassword = async (req, res) => {
// In production, send email here // In production, send email here
// For development, log the token // For development, log the token
logger.info(`Password reset token for ${username}: ${token} (expires in 1 hour)`); if (config.nodeEnv !== 'production') {
logger.info(`Password reset token for ${username}: ${token} (expires in 1 hour)`);
} else {
logger.info(`Password reset token generiert für Benutzer: ${username}`);
}
res.json({ res.json({
success: true, success: true,

View File

@ -1,117 +0,0 @@
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const User = require('../models/User');
const config = require('../config/env');
const logger = require('../utils/logger');
// POST /api/handler/login — email + password
const handlerLogin = async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ success: false, message: 'E-Mail und Passwort erforderlich' });
}
const user = await User.findOne({ email: email.trim().toLowerCase(), deleted: false });
if (!user || !user.passwordHash) {
return res.status(401).json({ success: false, message: 'Ungültige Anmeldedaten oder kein Passwort hinterlegt' });
}
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
return res.status(401).json({ success: false, message: 'Ungültige Anmeldedaten' });
}
const token = jwt.sign(
{ id: user._id.toString(), role: 'handler' },
config.jwtSecret,
{ expiresIn: '12h' }
);
res.json({
success: true,
token,
user: {
id: user._id,
name: user.name,
email: user.email,
available: user.available,
type: user.type,
phone: user.phone,
landline: user.landline,
address: user.address
}
});
} catch (error) {
logger.error('Handler-Login Fehler:', error);
res.status(500).json({ success: false, message: 'Serverfehler beim Login' });
}
};
// 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, newPassword } = req.body;
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 });
if (!user) {
return res.status(404).json({ success: false, message: 'Kein Stöberhundeführer mit dieser E-Mail gefunden' });
}
user.passwordHash = await bcrypt.hash(newPassword, 12);
await user.save();
res.json({ success: true, message: 'Passwort erfolgreich gesetzt' });
} catch (error) {
logger.error('Passwort setzen Fehler:', error);
res.status(500).json({ success: false, message: 'Serverfehler' });
}
};
// PUT /api/handler/me — handler updates own availability + contact data
const updateHandlerSelf = async (req, res) => {
try {
const userId = req.handlerUser.id;
const { available, phone, landline, address } = req.body;
const update = {};
if (typeof available === 'boolean') update.available = available;
if (phone && phone.trim()) update.phone = phone.trim();
if (landline !== undefined) update.landline = landline ? landline.trim() : null;
if (address && address.trim()) update.address = address.trim();
const user = await User.findByIdAndUpdate(
userId,
update,
{ new: true, select: '-passwordHash' }
);
if (!user) {
return res.status(404).json({ success: false, message: 'Stöberhundeführer nicht gefunden' });
}
res.json({ success: true, data: user });
} catch (error) {
logger.error('Handler-Update Fehler:', error);
res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren' });
}
};
// GET /api/handler/me — get own profile
const getHandlerSelf = async (req, res) => {
try {
const user = await User.findById(req.handlerUser.id).select('-passwordHash');
if (!user) return res.status(404).json({ success: false, message: 'Nicht gefunden' });
res.json({ success: true, data: user });
} catch (error) {
res.status(500).json({ success: false, message: 'Serverfehler' });
}
};
module.exports = { handlerLogin, setHandlerPassword, updateHandlerSelf, getHandlerSelf };

View File

@ -0,0 +1,166 @@
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');
// POST /api/stoeberhundefuehrer/login — email + password
const stoeberhundefuehrerLogin = async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ success: false, message: 'E-Mail und Passwort erforderlich' });
}
const user = await User.findOne({ email: email.trim().toLowerCase(), deleted: false });
if (!user || !user.passwordHash) {
return res.status(401).json({ success: false, message: 'Ungültige Anmeldedaten oder kein Passwort hinterlegt' });
}
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
return res.status(401).json({ success: false, message: 'Ungültige Anmeldedaten' });
}
const token = jwt.sign(
{ id: user._id.toString(), role: 'stoeberhundefuehrer' },
config.jwtSecret,
{ expiresIn: '12h' }
);
res.json({
success: true,
token,
user: {
id: user._id,
name: user.name,
email: user.email,
available: user.available,
type: user.type,
phone: user.phone,
landline: user.landline,
address: user.address
}
});
} catch (error) {
logger.error('Stöberhundeführer-Login Fehler:', error);
res.status(500).json({ success: false, message: 'Serverfehler beim Login' });
}
};
// POST /api/stoeberhundefuehrer/set-password — set first password
// Admin first sets email, then the Stöberhundeführer can set their own password
const setStoeberhundefuehrerPassword = async (req, res) => {
try {
const { email, inviteToken, 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' });
}
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 });
}
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;
await user.save();
res.json({ success: true, message: 'Passwort erfolgreich gesetzt' });
} catch (error) {
logger.error('Passwort setzen Fehler:', error);
res.status(500).json({ success: false, message: 'Serverfehler' });
}
};
// PUT /api/stoeberhundefuehrer/me — Stöberhundeführer updates own availability + contact data
const updateStoeberhundefuehrerSelf = async (req, res) => {
try {
const userId = req.stoeberhundefuehrerUser.id;
const { available, phone, landline, address } = req.body;
const update = {};
if (typeof available === 'boolean') update.available = available;
if (phone && phone.trim()) update.phone = phone.trim();
if (landline !== undefined) update.landline = landline ? landline.trim() : null;
if (address && address.trim()) update.address = address.trim();
const user = await User.findByIdAndUpdate(
userId,
update,
{ new: true, select: '-passwordHash' }
);
if (!user) {
return res.status(404).json({ success: false, message: 'Stöberhundeführer nicht gefunden' });
}
res.json({ success: true, data: user });
} catch (error) {
logger.error('Stöberhundeführer-Update Fehler:', error);
res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren' });
}
};
// GET /api/stoeberhundefuehrer/me — get own profile
const getStoeberhundefuehrerSelf = async (req, res) => {
try {
const user = await User.findById(req.stoeberhundefuehrerUser.id).select('-passwordHash');
if (!user) return res.status(404).json({ success: false, message: 'Nicht gefunden' });
res.json({ success: true, data: user });
} catch (error) {
res.status(500).json({ success: false, message: 'Serverfehler' });
}
};
// POST /api/stoeberhundefuehrer/:id/invite-token — Admin generates one-time invite token
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 = { stoeberhundefuehrerLogin, setStoeberhundefuehrerPassword, generateInviteToken, updateStoeberhundefuehrerSelf, getStoeberhundefuehrerSelf };

View File

@ -473,7 +473,12 @@ const importUsers = async (req, res) => {
}); });
} }
const overwrite = req.query.overwrite === 'true'; if (importList.length > 500) {
return res.status(400).json({
success: false,
message: 'Zu viele Einträge. Maximal 500 Benutzer pro Import erlaubt.'
});
}
const results = { imported: 0, skipped: 0, errors: [] }; const results = { imported: 0, skipped: 0, errors: [] };

View File

@ -40,7 +40,28 @@ const authLimiter = rateLimit({
} }
}); });
// Strict rate limiter for invite / set-password endpoints
const inviteLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
skipSuccessfulRequests: true,
message: {
success: false,
message: 'Zu viele Versuche. Bitte warten Sie 15 Minuten.'
},
standardHeaders: true,
legacyHeaders: false,
handler: (req, res) => {
logger.warn(`Invite rate limit exceeded for IP: ${req.ip}`);
res.status(429).json({
success: false,
message: 'Zu viele Versuche. Bitte warten Sie 15 Minuten.'
});
}
});
module.exports = { module.exports = {
apiLimiter, apiLimiter,
authLimiter authLimiter,
inviteLimiter
}; };

View File

@ -1,7 +1,7 @@
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const config = require('../config/env'); const config = require('../config/env');
const authenticateHandler = (req, res, next) => { const authenticateStoeberhundefuehrer = (req, res, next) => {
const authHeader = req.headers['authorization']; const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; const token = authHeader && authHeader.split(' ')[1];
@ -11,14 +11,14 @@ const authenticateHandler = (req, res, next) => {
try { try {
const decoded = jwt.verify(token, config.jwtSecret); const decoded = jwt.verify(token, config.jwtSecret);
if (decoded.role !== 'handler') { if (decoded.role !== 'stoeberhundefuehrer') {
return res.status(403).json({ success: false, message: 'Zugriff verweigert' }); return res.status(403).json({ success: false, message: 'Zugriff verweigert' });
} }
req.handlerUser = decoded; req.stoeberhundefuehrerUser = decoded;
next(); next();
} catch (error) { } catch (error) {
return res.status(403).json({ success: false, message: 'Ungültiger oder abgelaufener Token' }); return res.status(403).json({ success: false, message: 'Ungültiger oder abgelaufener Token' });
} }
}; };
module.exports = { authenticateHandler }; module.exports = { authenticateStoeberhundefuehrer };

View File

@ -15,7 +15,7 @@ const auditLogSchema = new mongoose.Schema({
resource: { resource: {
type: String, type: String,
required: true, required: true,
enum: ['User', 'Config', 'Admin', 'Handler'], enum: ['User', 'Config', 'Admin', 'Stoeberhundefuehrer'],
index: true index: true
}, },
resourceId: { resourceId: {

View File

@ -70,7 +70,7 @@ const userSchema = new mongoose.Schema({
ref: 'Admin', ref: 'Admin',
default: null default: null
}, },
// Handler invite token for initial password setup // Stöberhundeführer invite token for initial password setup
inviteToken: { inviteToken: {
type: String, type: String,
default: null, default: null,

View File

@ -1,18 +0,0 @@
const express = require('express');
const router = express.Router();
const { handlerLogin, setHandlerPassword, generateInviteToken, updateHandlerSelf, getHandlerSelf } = require('../controllers/handlerController');
const { authenticateToken } = require('../middleware/auth');
const { authenticateHandler } = require('../middleware/handlerAuth');
// Public
router.post('/login', handlerLogin);
router.post('/set-password', setHandlerPassword);
// Admin only — generate a one-time invite token so a handler can set their password
router.post('/:id/invite-token', authenticateToken, generateInviteToken);
// Authenticated handler only
router.get('/me', authenticateHandler, getHandlerSelf);
router.put('/me', authenticateHandler, updateHandlerSelf);
module.exports = router;

View File

@ -0,0 +1,19 @@
const express = require('express');
const router = express.Router();
const { stoeberhundefuehrerLogin, setStoeberhundefuehrerPassword, generateInviteToken, updateStoeberhundefuehrerSelf, getStoeberhundefuehrerSelf } = require('../controllers/stoeberhundefuehrerController');
const { authenticateToken } = require('../middleware/auth');
const { authenticateStoeberhundefuehrer } = require('../middleware/stoeberhundefuehrerAuth');
const { inviteLimiter } = require('../middleware/rateLimiter');
// Public
router.post('/login', stoeberhundefuehrerLogin);
router.post('/set-password', inviteLimiter, setStoeberhundefuehrerPassword);
// Admin only — generate a one-time invite token so a Stöberhundeführer can set their password
router.post('/:id/invite-token', authenticateToken, generateInviteToken);
// Authenticated Stöberhundeführer only
router.get('/me', authenticateStoeberhundefuehrer, getStoeberhundefuehrerSelf);
router.put('/me', authenticateStoeberhundefuehrer, updateStoeberhundefuehrerSelf);
module.exports = router;

View File

@ -24,7 +24,7 @@ const seedDatabase = async () => {
// Seed admins (only if not exists) // Seed admins (only if not exists)
const adminAccounts = [ const adminAccounts = [
{ username: 'admin', passwordEnvVar: 'ADMIN_PASSWORD', defaultPassword: process.env.ADMIN_PASSWORD || 'admin123' }, { username: 'admin', passwordEnvVar: 'ADMIN_PASSWORD', defaultPassword: process.env.ADMIN_PASSWORD || null },
{ username: 'ThorstenMeyer', passwordEnvVar: 'ADMIN_THORSTEN_PASSWORD', defaultPassword: process.env.ADMIN_THORSTEN_PASSWORD || null } { username: 'ThorstenMeyer', passwordEnvVar: 'ADMIN_THORSTEN_PASSWORD', defaultPassword: process.env.ADMIN_THORSTEN_PASSWORD || null }
]; ];

View File

@ -68,7 +68,7 @@ app.use('/api/auth', authLimiter, require('./routes/authRoutes')); // Strict lim
app.use('/api', require('./routes/userRoutes')); app.use('/api', require('./routes/userRoutes'));
app.use('/api', require('./routes/auditRoutes')); app.use('/api', require('./routes/auditRoutes'));
app.use('/api/config', require('./routes/configRoutes')); app.use('/api/config', require('./routes/configRoutes'));
app.use('/api/handler', require('./routes/handlerRoutes')); app.use('/api/stoeberhundefuehrer', require('./routes/stoeberhundefuehrerRoutes'));
// Health check with basic system info // Health check with basic system info
app.get('/health', async (req, res) => { app.get('/health', async (req, res) => {

View File

@ -20,9 +20,13 @@ services:
build: ./backend build: ./backend
container_name: stoeberhunde-backend container_name: stoeberhunde-backend
restart: unless-stopped restart: unless-stopped
# Port 5012 only bound to localhost # Port 5012 bound to localhost only NOT accessible from external IPs.
# Portal nginx reaches the backend via the shared jagd-network (container name).
ports: ports:
- "0.0.0.0:5012:5000" - "127.0.0.1:5012:5000"
networks:
- default
- jagd-network
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- MONGO_URI=mongodb://stoeberhunde:${MONGO_PASSWORD}@mongo:27017/stoeberhunde?authSource=admin - MONGO_URI=mongodb://stoeberhunde:${MONGO_PASSWORD}@mongo:27017/stoeberhunde?authSource=admin
@ -52,7 +56,10 @@ services:
container_name: stoeberhunde-frontend container_name: stoeberhunde-frontend
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8082:80" - "127.0.0.1:8082:80"
networks:
- default
- jagd-network
depends_on: depends_on:
- backend - backend
healthcheck: healthcheck:
@ -64,3 +71,9 @@ services:
volumes: volumes:
mongo-data: mongo-data:
name: stoeberhunde-mongo-data name: stoeberhunde-mongo-data
networks:
default: {}
jagd-network:
external: true
name: jagd-network

View File

@ -8,8 +8,8 @@ import PasswordReset from './components/auth/PasswordReset';
import Home from './pages/Home'; import Home from './pages/Home';
import Rules from './pages/Rules'; import Rules from './pages/Rules';
import Admin from './pages/Admin'; import Admin from './pages/Admin';
import HandlerLogin from './components/handler/HandlerLogin'; import StoeberhundefuehrerLogin from './components/stoeberhundefuehrer/StoeberhundefuehrerLogin';
import HandlerDashboard from './components/handler/HandlerDashboard'; import StoeberhundefuehrerDashboard from './components/stoeberhundefuehrer/StoeberhundefuehrerDashboard';
import InstallBanner from './components/common/InstallBanner'; import InstallBanner from './components/common/InstallBanner';
import './App.css'; import './App.css';
@ -19,7 +19,7 @@ function App() {
const isAdminRoute = window.location.pathname === adminPath; const isAdminRoute = window.location.pathname === adminPath;
const isResetPasswordRoute = window.location.pathname === resetPasswordPath; const isResetPasswordRoute = window.location.pathname === resetPasswordPath;
const [view, setView] = useState('public'); const [view, setView] = useState('public');
const [handlerUser, setHandlerUser] = useState(null); const [stoeberhundefuehrerUser, setStoeberhundefuehrerUser] = useState(null);
const { isAuthenticated, loading: authLoading, login, logout } = useAuth(); const { isAuthenticated, loading: authLoading, login, logout } = useAuth();
const { users, loading: usersLoading, error, refetch } = useUsers(isAuthenticated); const { users, loading: usersLoading, error, refetch } = useUsers(isAuthenticated);
@ -37,13 +37,13 @@ function App() {
setView('public'); setView('public');
}; };
const handleHandlerLogin = (userData) => { const handleStoeberhundefuehrerLogin = (userData) => {
setHandlerUser(userData); setStoeberhundefuehrerUser(userData);
setView('handler-dashboard'); setView('stoeberhundefuehrer-dashboard');
}; };
const handleHandlerLogout = () => { const handleStoeberhundefuehrerLogout = () => {
setHandlerUser(null); setStoeberhundefuehrerUser(null);
setView('public'); setView('public');
}; };
@ -97,15 +97,15 @@ function App() {
isAdmin={false} isAdmin={false}
currentView={view} currentView={view}
onViewChange={handleViewChange} onViewChange={handleViewChange}
onHandlerLogin={handleHandlerLogout} onStoeberhundefuehrerLogin={handleStoeberhundefuehrerLogout}
/> />
<main className="app-main"> <main className="app-main">
{isAdminRoute || view === 'login' ? ( {isAdminRoute || view === 'login' ? (
<LoginForm onLogin={handleLogin} loading={authLoading} /> <LoginForm onLogin={handleLogin} loading={authLoading} />
) : view === 'handler-login' ? ( ) : view === 'stoeberhundefuehrer-login' ? (
<HandlerLogin onLogin={handleHandlerLogin} /> <StoeberhundefuehrerLogin onLogin={handleStoeberhundefuehrerLogin} />
) : view === 'handler-dashboard' && handlerUser ? ( ) : view === 'stoeberhundefuehrer-dashboard' && stoeberhundefuehrerUser ? (
<HandlerDashboard handlerUser={handlerUser} onLogout={handleHandlerLogout} /> <StoeberhundefuehrerDashboard stoeberhundefuehrerUser={stoeberhundefuehrerUser} onLogout={handleStoeberhundefuehrerLogout} />
) : ( ) : (
<> <>
{view === 'rules' && <Rules />} {view === 'rules' && <Rules />}

View File

@ -348,7 +348,7 @@ function AuditLogs() {
<option value="User">Benutzer</option> <option value="User">Benutzer</option>
<option value="Admin">Admin</option> <option value="Admin">Admin</option>
<option value="Config">Konfiguration</option> <option value="Config">Konfiguration</option>
<option value="Handler">Handler</option> <option value="Stoeberhundefuehrer">Stöberhundeführer</option>
</select> </select>
<select value={filters.success} onChange={e => setFilter('success', e.target.value)} className="filter-select"> <select value={filters.success} onChange={e => setFilter('success', e.target.value)} className="filter-select">

View File

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

View File

@ -1,10 +1,10 @@
.handler-dashboard { .stoeberhundefuehrer-dashboard {
display: flex; display: flex;
justify-content: center; justify-content: center;
padding: 2rem 1rem; padding: 2rem 1rem;
} }
.handler-dashboard-card { .stoeberhundefuehrer-dashboard-card {
background: white; background: white;
border-radius: 10px; border-radius: 10px;
padding: 2rem; padding: 2rem;
@ -14,26 +14,26 @@
border-top: 4px solid var(--color-primary); border-top: 4px solid var(--color-primary);
} }
.handler-dashboard-header { .stoeberhundefuehrer-dashboard-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.handler-dashboard-header h2 { .stoeberhundefuehrer-dashboard-header h2 {
margin: 0 0 0.25rem; margin: 0 0 0.25rem;
color: var(--color-primary); color: var(--color-primary);
} }
.handler-name { .stoeberhundefuehrer-name {
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
margin: 0; margin: 0;
color: var(--color-text); color: var(--color-text);
} }
.btn-handler-logout { .btn-stoeberhundefuehrer-logout {
padding: 0.4rem 0.9rem; padding: 0.4rem 0.9rem;
background: none; background: none;
border: 1px solid var(--color-danger); border: 1px solid var(--color-danger);
@ -44,22 +44,22 @@
transition: background 0.2s; transition: background 0.2s;
} }
.btn-handler-logout:hover { .btn-stoeberhundefuehrer-logout:hover {
background: var(--color-danger); background: var(--color-danger);
color: white; color: white;
} }
.handler-message { .stoeberhundefuehrer-message {
padding: 0.6rem 1rem; padding: 0.6rem 1rem;
border-radius: 4px; border-radius: 4px;
margin-bottom: 1rem; margin-bottom: 1rem;
font-size: 0.9rem; font-size: 0.9rem;
} }
.handler-message-success { background: #dcfce7; color: #166534; } .stoeberhundefuehrer-message-success { background: #dcfce7; color: #166534; }
.handler-message-error { background: #fee2e2; color: #b91c1c; } .stoeberhundefuehrer-message-error { background: #fee2e2; color: #b91c1c; }
.handler-availability-section { .stoeberhundefuehrer-availability-section {
background: var(--color-muted-bg); background: var(--color-muted-bg);
border-radius: 8px; border-radius: 8px;
padding: 1.25rem; padding: 1.25rem;
@ -119,25 +119,25 @@
margin: 0.6rem 0 0; margin: 0.6rem 0 0;
} }
.handler-contact-section { .stoeberhundefuehrer-contact-section {
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
padding-top: 1.25rem; padding-top: 1.25rem;
} }
.handler-section-header { .stoeberhundefuehrer-section-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.handler-section-header h3 { .stoeberhundefuehrer-section-header h3 {
margin: 0; margin: 0;
font-size: 1rem; font-size: 1rem;
color: var(--color-text); color: var(--color-text);
} }
.btn-handler-edit { .btn-stoeberhundefuehrer-edit {
padding: 0.35rem 0.8rem; padding: 0.35rem 0.8rem;
background: none; background: none;
border: 1px solid var(--color-primary); border: 1px solid var(--color-primary);
@ -147,11 +147,11 @@
font-size: 0.9rem; font-size: 0.9rem;
} }
.btn-handler-edit:hover { background: var(--color-primary); color: white; } .btn-stoeberhundefuehrer-edit:hover { background: var(--color-primary); color: white; }
.handler-info-list { display: flex; flex-direction: column; gap: 0.6rem; } .stoeberhundefuehrer-info-list { display: flex; flex-direction: column; gap: 0.6rem; }
.handler-info-row { .stoeberhundefuehrer-info-row {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
font-size: 0.95rem; font-size: 0.95rem;
@ -163,25 +163,25 @@
min-width: 80px; min-width: 80px;
} }
.handler-info-row a { .stoeberhundefuehrer-info-row a {
color: var(--color-primary); color: var(--color-primary);
text-decoration: none; text-decoration: none;
} }
.handler-info-row a:hover { text-decoration: underline; } .stoeberhundefuehrer-info-row a:hover { text-decoration: underline; }
.handler-edit-form .form-group { .stoeberhundefuehrer-edit-form .form-group {
margin-bottom: 0.85rem; margin-bottom: 0.85rem;
} }
.handler-edit-form .form-group label { .stoeberhundefuehrer-edit-form .form-group label {
display: block; display: block;
font-weight: 600; font-weight: 600;
margin-bottom: 0.3rem; margin-bottom: 0.3rem;
font-size: 0.9rem; font-size: 0.9rem;
} }
.handler-edit-form .form-group input { .stoeberhundefuehrer-edit-form .form-group input {
width: 100%; width: 100%;
padding: 0.6rem 0.75rem; padding: 0.6rem 0.75rem;
border: 1px solid var(--color-border-strong); border: 1px solid var(--color-border-strong);
@ -190,19 +190,19 @@
box-sizing: border-box; box-sizing: border-box;
} }
.handler-edit-form .form-group input:focus { .stoeberhundefuehrer-edit-form .form-group input:focus {
outline: none; outline: none;
border-color: var(--color-primary); border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-focus); box-shadow: 0 0 0 3px var(--color-focus);
} }
.handler-form-actions { .stoeberhundefuehrer-form-actions {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
margin-top: 1rem; margin-top: 1rem;
} }
.btn-handler-primary { .btn-stoeberhundefuehrer-primary {
flex: 1; flex: 1;
padding: 0.65rem; padding: 0.65rem;
background: var(--color-primary); background: var(--color-primary);
@ -214,10 +214,10 @@
cursor: pointer; cursor: pointer;
} }
.btn-handler-primary:hover:not(:disabled) { background: var(--color-primary-dark); } .btn-stoeberhundefuehrer-primary:hover:not(:disabled) { background: var(--color-primary-dark); }
.btn-handler-primary:disabled { opacity: 0.6; cursor: not-allowed; } .btn-stoeberhundefuehrer-primary:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-handler-cancel { .btn-stoeberhundefuehrer-cancel {
flex: 1; flex: 1;
padding: 0.65rem; padding: 0.65rem;
background: none; background: none;
@ -228,4 +228,4 @@
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.btn-handler-cancel:hover { background: var(--color-muted-bg); } .btn-stoeberhundefuehrer-cancel:hover { background: var(--color-muted-bg); }

View File

@ -1,15 +1,15 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { updateHandlerMe } from '../../services/handler'; import { updateStoeberhundefuehrerMe } from '../../services/stoeberhundefuehrer';
import './HandlerDashboard.css'; import './StoeberhundefuehrerDashboard.css';
const HandlerDashboard = ({ handlerUser, onLogout }) => { const StoeberhundefuehrerDashboard = ({ stoeberhundefuehrerUser, onLogout }) => {
const [userData, setUserData] = useState(handlerUser); const [userData, setUserData] = useState(stoeberhundefuehrerUser);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
phone: handlerUser.phone || '', phone: stoeberhundefuehrerUser.phone || '',
landline: handlerUser.landline || '', landline: stoeberhundefuehrerUser.landline || '',
address: handlerUser.address || '', address: stoeberhundefuehrerUser.address || '',
available: handlerUser.available available: stoeberhundefuehrerUser.available
}); });
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [message, setMessage] = useState(null); const [message, setMessage] = useState(null);
@ -19,7 +19,7 @@ const HandlerDashboard = ({ handlerUser, onLogout }) => {
setSaving(true); setSaving(true);
setMessage(null); setMessage(null);
try { try {
const result = await updateHandlerMe(formData); const result = await updateStoeberhundefuehrerMe(formData);
if (result.success) { if (result.success) {
setUserData({ ...userData, ...formData }); setUserData({ ...userData, ...formData });
setEditing(false); setEditing(false);
@ -37,7 +37,7 @@ const HandlerDashboard = ({ handlerUser, onLogout }) => {
setSaving(true); setSaving(true);
setMessage(null); setMessage(null);
try { try {
const result = await updateHandlerMe({ available: newVal }); const result = await updateStoeberhundefuehrerMe({ available: newVal });
if (result.success) { if (result.success) {
setUserData({ ...userData, available: newVal }); setUserData({ ...userData, available: newVal });
setFormData({ ...formData, available: newVal }); setFormData({ ...formData, available: newVal });
@ -51,27 +51,27 @@ const HandlerDashboard = ({ handlerUser, onLogout }) => {
}; };
const handleLogout = () => { const handleLogout = () => {
localStorage.removeItem('handlerToken'); localStorage.removeItem('stoeberhundefuehrerToken');
onLogout(); onLogout();
}; };
return ( return (
<div className="handler-dashboard"> <div className="stoeberhundefuehrer-dashboard">
<div className="handler-dashboard-card"> <div className="stoeberhundefuehrer-dashboard-card">
<div className="handler-dashboard-header"> <div className="stoeberhundefuehrer-dashboard-header">
<div> <div>
<h2>Mein Profil</h2> <h2>Mein Profil</h2>
<p className="handler-name">{userData.name}</p> <p className="stoeberhundefuehrer-name">{userData.name}</p>
</div> </div>
<button className="btn-handler-logout" onClick={handleLogout}>Abmelden</button> <button className="btn-stoeberhundefuehrer-logout" onClick={handleLogout}>Abmelden</button>
</div> </div>
{message && ( {message && (
<div className={`handler-message handler-message-${message.type}`}>{message.text}</div> <div className={`stoeberhundefuehrer-message stoeberhundefuehrer-message-${message.type}`}>{message.text}</div>
)} )}
{/* Availability Toggle */} {/* Availability Toggle */}
<div className="handler-availability-section"> <div className="stoeberhundefuehrer-availability-section">
<div className="availability-label">Meine Verfügbarkeit</div> <div className="availability-label">Meine Verfügbarkeit</div>
<button <button
className={`availability-big-btn ${userData.available ? 'btn-green' : 'btn-red'}`} className={`availability-big-btn ${userData.available ? 'btn-green' : 'btn-red'}`}
@ -82,38 +82,38 @@ const HandlerDashboard = ({ handlerUser, onLogout }) => {
{userData.available ? '✅ Verfügbar zum Wechsel klicken' : '🔴 Nicht verfügbar zum Wechsel klicken'} {userData.available ? '✅ Verfügbar zum Wechsel klicken' : '🔴 Nicht verfügbar zum Wechsel klicken'}
</button> </button>
<p className="availability-hint"> <p className="availability-hint">
Klicken Sie, um Ihre Verfügbarkeit zu ändern. Nur verfügbare Gespanne erscheinen in der öffentlichen Suchliste. Klicken Sie, um Ihre Verfügbarkeit zu ändern. Nur verfügbare Stöberhundeführer erscheinen in der öffentlichen Suchliste.
</p> </p>
</div> </div>
{/* Contact Data */} {/* Contact Data */}
<div className="handler-contact-section"> <div className="stoeberhundefuehrer-contact-section">
<div className="handler-section-header"> <div className="stoeberhundefuehrer-section-header">
<h3>Kontaktdaten</h3> <h3>Kontaktdaten</h3>
{!editing && ( {!editing && (
<button className="btn-handler-edit" onClick={() => setEditing(true)}>Bearbeiten</button> <button className="btn-stoeberhundefuehrer-edit" onClick={() => setEditing(true)}>Bearbeiten</button>
)} )}
</div> </div>
{!editing ? ( {!editing ? (
<div className="handler-info-list"> <div className="stoeberhundefuehrer-info-list">
<div className="handler-info-row"> <div className="stoeberhundefuehrer-info-row">
<span className="info-label">Adresse</span> <span className="info-label">Adresse</span>
<span>{userData.address}</span> <span>{userData.address}</span>
</div> </div>
<div className="handler-info-row"> <div className="stoeberhundefuehrer-info-row">
<span className="info-label">Mobil</span> <span className="info-label">Mobil</span>
<a href={`tel:${userData.phone}`}>{userData.phone}</a> <a href={`tel:${userData.phone}`}>{userData.phone}</a>
</div> </div>
{userData.landline && ( {userData.landline && (
<div className="handler-info-row"> <div className="stoeberhundefuehrer-info-row">
<span className="info-label">Festnetz</span> <span className="info-label">Festnetz</span>
<a href={`tel:${userData.landline}`}>{userData.landline}</a> <a href={`tel:${userData.landline}`}>{userData.landline}</a>
</div> </div>
)} )}
</div> </div>
) : ( ) : (
<form onSubmit={handleSave} className="handler-edit-form"> <form onSubmit={handleSave} className="stoeberhundefuehrer-edit-form">
<div className="form-group"> <div className="form-group">
<label>Adresse</label> <label>Adresse</label>
<input type="text" value={formData.address} <input type="text" value={formData.address}
@ -129,11 +129,11 @@ const HandlerDashboard = ({ handlerUser, onLogout }) => {
<input type="tel" value={formData.landline} <input type="tel" value={formData.landline}
onChange={e => setFormData({ ...formData, landline: e.target.value })} /> onChange={e => setFormData({ ...formData, landline: e.target.value })} />
</div> </div>
<div className="handler-form-actions"> <div className="stoeberhundefuehrer-form-actions">
<button type="submit" className="btn-handler-primary" disabled={saving}> <button type="submit" className="btn-stoeberhundefuehrer-primary" disabled={saving}>
{saving ? 'Speichert...' : 'Speichern'} {saving ? 'Speichert...' : 'Speichern'}
</button> </button>
<button type="button" className="btn-handler-cancel" onClick={() => setEditing(false)}> <button type="button" className="btn-stoeberhundefuehrer-cancel" onClick={() => setEditing(false)}>
Abbrechen Abbrechen
</button> </button>
</div> </div>
@ -145,4 +145,4 @@ const HandlerDashboard = ({ handlerUser, onLogout }) => {
); );
}; };
export default HandlerDashboard; export default StoeberhundefuehrerDashboard;

View File

@ -1,11 +1,11 @@
.handler-login-container { .stoeberhundefuehrer-login-container {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: flex-start; align-items: flex-start;
padding: 2rem 1rem; padding: 2rem 1rem;
} }
.handler-login-card { .stoeberhundefuehrer-login-card {
background: white; background: white;
border-radius: 10px; border-radius: 10px;
padding: 2rem; padding: 2rem;
@ -15,18 +15,18 @@
border-top: 4px solid var(--color-primary); border-top: 4px solid var(--color-primary);
} }
.handler-login-card h2 { .stoeberhundefuehrer-login-card h2 {
margin: 0 0 0.5rem; margin: 0 0 0.5rem;
color: var(--color-primary); color: var(--color-primary);
} }
.handler-login-subtitle { .stoeberhundefuehrer-login-subtitle {
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: 0.9rem; font-size: 0.9rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.handler-mode-toggle { .stoeberhundefuehrer-mode-toggle {
display: flex; display: flex;
gap: 0; gap: 0;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
@ -35,7 +35,7 @@
overflow: hidden; overflow: hidden;
} }
.handler-mode-toggle button { .stoeberhundefuehrer-mode-toggle button {
flex: 1; flex: 1;
padding: 0.5rem; padding: 0.5rem;
border: none; border: none;
@ -46,24 +46,24 @@
transition: background 0.2s; transition: background 0.2s;
} }
.handler-mode-toggle button.active { .stoeberhundefuehrer-mode-toggle button.active {
background: var(--color-primary); background: var(--color-primary);
color: white; color: white;
font-weight: 600; font-weight: 600;
} }
.handler-form .form-group { .stoeberhundefuehrer-form .form-group {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.handler-form .form-group label { .stoeberhundefuehrer-form .form-group label {
display: block; display: block;
font-weight: 600; font-weight: 600;
margin-bottom: 0.3rem; margin-bottom: 0.3rem;
font-size: 0.9rem; font-size: 0.9rem;
} }
.handler-form .form-group input { .stoeberhundefuehrer-form .form-group input {
width: 100%; width: 100%;
padding: 0.6rem 0.75rem; padding: 0.6rem 0.75rem;
border: 1px solid var(--color-border-strong); border: 1px solid var(--color-border-strong);
@ -72,13 +72,13 @@
box-sizing: border-box; box-sizing: border-box;
} }
.handler-form .form-group input:focus { .stoeberhundefuehrer-form .form-group input:focus {
outline: none; outline: none;
border-color: var(--color-primary); border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-focus); box-shadow: 0 0 0 3px var(--color-focus);
} }
.btn-handler-primary { .btn-stoeberhundefuehrer-primary {
width: 100%; width: 100%;
padding: 0.75rem; padding: 0.75rem;
background: var(--color-primary); background: var(--color-primary);
@ -92,16 +92,16 @@
transition: background 0.2s; transition: background 0.2s;
} }
.btn-handler-primary:hover:not(:disabled) { .btn-stoeberhundefuehrer-primary:hover:not(:disabled) {
background: var(--color-primary-dark); background: var(--color-primary-dark);
} }
.btn-handler-primary:disabled { .btn-stoeberhundefuehrer-primary:disabled {
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
} }
.handler-error { .stoeberhundefuehrer-error {
background: #fee2e2; background: #fee2e2;
color: #b91c1c; color: #b91c1c;
border-radius: 4px; border-radius: 4px;
@ -110,7 +110,7 @@
font-size: 0.9rem; font-size: 0.9rem;
} }
.handler-success { .stoeberhundefuehrer-success {
background: #dcfce7; background: #dcfce7;
color: #166534; color: #166534;
border-radius: 4px; border-radius: 4px;
@ -119,7 +119,7 @@
font-size: 0.9rem; font-size: 0.9rem;
} }
.handler-hint { .stoeberhundefuehrer-hint {
font-size: 0.88rem; font-size: 0.88rem;
color: var(--color-text-muted); color: var(--color-text-muted);
margin-bottom: 1rem; margin-bottom: 1rem;

View File

@ -1,8 +1,8 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { handlerLogin, setHandlerPassword } from '../../services/handler'; import { stoeberhundefuehrerLogin, setStoeberhundefuehrerPassword } from '../../services/stoeberhundefuehrer';
import './HandlerLogin.css'; import './StoeberhundefuehrerLogin.css';
const HandlerLogin = ({ onLogin }) => { const StoeberhundefuehrerLogin = ({ onLogin }) => {
const [mode, setMode] = useState('login'); // 'login' | 'set-password' const [mode, setMode] = useState('login'); // 'login' | 'set-password'
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@ -17,9 +17,9 @@ const HandlerLogin = ({ onLogin }) => {
setError(''); setError('');
setLoading(true); setLoading(true);
try { try {
const result = await handlerLogin(email, password); const result = await stoeberhundefuehrerLogin(email, password);
if (result.success) { if (result.success) {
localStorage.setItem('handlerToken', result.token); localStorage.setItem('stoeberhundefuehrerToken', result.token);
onLogin(result.user); onLogin(result.user);
} }
} catch (err) { } catch (err) {
@ -42,7 +42,7 @@ const HandlerLogin = ({ onLogin }) => {
} }
setLoading(true); setLoading(true);
try { try {
const result = await setHandlerPassword(email, newPassword); const result = await setStoeberhundefuehrerPassword(email, newPassword);
if (result.success) { if (result.success) {
setSuccess('Passwort erfolgreich gesetzt. Sie können sich nun anmelden.'); setSuccess('Passwort erfolgreich gesetzt. Sie können sich nun anmelden.');
setMode('login'); setMode('login');
@ -57,14 +57,14 @@ const HandlerLogin = ({ onLogin }) => {
}; };
return ( return (
<div className="handler-login-container"> <div className="stoeberhundefuehrer-login-container">
<div className="handler-login-card"> <div className="stoeberhundefuehrer-login-card">
<h2>Stöberhundeführer-Login</h2> <h2>Stöberhundeführer-Login</h2>
<p className="handler-login-subtitle"> <p className="stoeberhundefuehrer-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>
<div className="handler-mode-toggle"> <div className="stoeberhundefuehrer-mode-toggle">
<button <button
className={mode === 'login' ? 'active' : ''} className={mode === 'login' ? 'active' : ''}
onClick={() => { setMode('login'); setError(''); setSuccess(''); }} onClick={() => { setMode('login'); setError(''); setSuccess(''); }}
@ -79,11 +79,11 @@ const HandlerLogin = ({ onLogin }) => {
</button> </button>
</div> </div>
{error && <div className="handler-error">{error}</div>} {error && <div className="stoeberhundefuehrer-error">{error}</div>}
{success && <div className="handler-success">{success}</div>} {success && <div className="stoeberhundefuehrer-success">{success}</div>}
{mode === 'login' ? ( {mode === 'login' ? (
<form onSubmit={handleLogin} className="handler-form"> <form onSubmit={handleLogin} className="stoeberhundefuehrer-form">
<div className="form-group"> <div className="form-group">
<label>E-Mail</label> <label>E-Mail</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus /> <input type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
@ -92,13 +92,13 @@ const HandlerLogin = ({ onLogin }) => {
<label>Passwort</label> <label>Passwort</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required /> <input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
</div> </div>
<button type="submit" className="btn-handler-primary" disabled={loading}> <button type="submit" className="btn-stoeberhundefuehrer-primary" disabled={loading}>
{loading ? 'Anmelden...' : 'Anmelden'} {loading ? 'Anmelden...' : 'Anmelden'}
</button> </button>
</form> </form>
) : ( ) : (
<form onSubmit={handleSetPassword} className="handler-form"> <form onSubmit={handleSetPassword} className="stoeberhundefuehrer-form">
<p className="handler-hint"> <p className="stoeberhundefuehrer-hint">
Der Admin hat Ihre E-Mail-Adresse hinterlegt. Geben Sie hier Ihre E-Mail und ein neues Passwort ein. Der Admin hat Ihre E-Mail-Adresse hinterlegt. Geben Sie hier Ihre E-Mail und ein neues Passwort ein.
</p> </p>
<div className="form-group"> <div className="form-group">
@ -113,7 +113,7 @@ const HandlerLogin = ({ onLogin }) => {
<label>Passwort wiederholen</label> <label>Passwort wiederholen</label>
<input type="password" value={newPassword2} onChange={e => setNewPassword2(e.target.value)} required /> <input type="password" value={newPassword2} onChange={e => setNewPassword2(e.target.value)} required />
</div> </div>
<button type="submit" className="btn-handler-primary" disabled={loading}> <button type="submit" className="btn-stoeberhundefuehrer-primary" disabled={loading}>
{loading ? 'Wird gesetzt...' : 'Passwort setzen'} {loading ? 'Wird gesetzt...' : 'Passwort setzen'}
</button> </button>
</form> </form>
@ -123,4 +123,4 @@ const HandlerLogin = ({ onLogin }) => {
); );
}; };
export default HandlerLogin; export default StoeberhundefuehrerLogin;

View File

@ -1,34 +0,0 @@
import axios from 'axios';
import { API_BASE_URL } from '../utils/constants';
const handlerApi = axios.create({
baseURL: `${API_BASE_URL}/api/handler`,
timeout: 10000,
headers: { 'Content-Type': 'application/json' }
});
handlerApi.interceptors.request.use((config) => {
const token = localStorage.getItem('handlerToken');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
export const handlerLogin = async (email, password) => {
const response = await handlerApi.post('/login', { email, password });
return response.data;
};
export const setHandlerPassword = async (email, newPassword) => {
const response = await handlerApi.post('/set-password', { email, newPassword });
return response.data;
};
export const getHandlerMe = async () => {
const response = await handlerApi.get('/me');
return response.data;
};
export const updateHandlerMe = async (data) => {
const response = await handlerApi.put('/me', data);
return response.data;
};

View File

@ -0,0 +1,34 @@
import axios from 'axios';
import { API_BASE_URL } from '../utils/constants';
const stoeberhundefuehrerApi = axios.create({
baseURL: `${API_BASE_URL}/api/stoeberhundefuehrer`,
timeout: 10000,
headers: { 'Content-Type': 'application/json' }
});
stoeberhundefuehrerApi.interceptors.request.use((config) => {
const token = localStorage.getItem('stoeberhundefuehrerToken');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
export const stoeberhundefuehrerLogin = async (email, password) => {
const response = await stoeberhundefuehrerApi.post('/login', { email, password });
return response.data;
};
export const setStoeberhundefuehrerPassword = async (email, newPassword) => {
const response = await stoeberhundefuehrerApi.post('/set-password', { email, newPassword });
return response.data;
};
export const getStoeberhundefuehrerMe = async () => {
const response = await stoeberhundefuehrerApi.get('/me');
return response.data;
};
export const updateStoeberhundefuehrerMe = async (data) => {
const response = await stoeberhundefuehrerApi.put('/me', data);
return response.data;
};