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:
parent
770b0b1d38
commit
8384ad9432
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
@ -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: [] };
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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 }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 />}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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;
|
|
||||||
};
|
|
||||||
|
|
@ -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: [] };
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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: [] };
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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 }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 />}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
|
||||||
};
|
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue