Compare commits

..

No commits in common. "dd39a7aff213d586a1ff75e25d47ce5726ad3be6" and "d9fecb6914972780d8320d380d1695d3a83ede0c" have entirely different histories.

189 changed files with 58819 additions and 4322 deletions

View File

@ -139,7 +139,7 @@ Im Frontend wird die API-URL zur **Build-Zeit** gesetzt. Für Production:
frontend:
build:
args:
- VITE_API_URL=https://api.yourdomain.com
- REACT_APP_API_URL=https://api.yourdomain.com
```
2. Rebuild erforderlich:

View File

@ -19,16 +19,3 @@ CORS_ORIGIN=http://localhost:3000
GEOCODE_URL=https://nominatim.openstreetmap.org/search
GEOCODE_USER_AGENT=drohnenfuehrer-app/1.0 (admin@localhost)
GEOCODE_MIN_DELAY_MS=1100
# Basis-URL der App fuer Links in E-Mails (Passwort-Reset).
# MUSS den Unterpfad enthalten, unter dem die App ausgeliefert wird.
# Ohne diesen Wert wird er aus CORS_ORIGIN + "/drohnenfuehrer" zusammengesetzt.
APP_URL=http://localhost:8081/drohnenfuehrer
# SMTP fuer Passwort-Reset-Mails (optional).
# Fehlt die Konfiguration, wird der Reset-Link nur ins Log geschrieben.
# SMTP_HOST=smtp.example.com
# SMTP_PORT=587
# SMTP_USER=noreply@example.com
# SMTP_PASS=
# SMTP_FROM=noreply@example.com

View File

@ -10,10 +10,8 @@ const connectDB = async () => {
});
logger.info('MongoDB verbunden');
} catch (error) {
// Nicht process.exit(): der Aufrufer (server.js) implementiert einen Retry.
// Ein Exit hier hat den Retry zu totem Code gemacht.
logger.error('MongoDB Verbindungsfehler:', error.message);
throw error;
process.exit(1);
}
};

View File

@ -14,12 +14,7 @@ const config = {
geocodeUserAgent: process.env.GEOCODE_USER_AGENT || 'drohnenfuehrer-app/1.0 (admin@localhost)',
geocodeMinDelayMs: parseInt(process.env.GEOCODE_MIN_DELAY_MS || '1100', 10),
smtpConfigured: !!(process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS),
// Basis fuer Links in E-Mails (Passwort-Reset). MUSS den Unterpfad enthalten,
// unter dem die App ausgeliefert wird - der CORS_ORIGIN-Rueckfall kennt ihn
// nicht und erzeugt sonst Links, die auf dem Portal statt in der App landen.
appUrl: (process.env.APP_URL
|| `${(process.env.CORS_ORIGIN?.split(',')[0] || 'http://localhost:8081').replace(/\/+$/, '')}/${APP_NAME}`
).replace(/\/+$/, '')
appUrl: process.env.APP_URL || process.env.CORS_ORIGIN?.split(',')[0] || 'http://localhost:8081'
};
// Validate required environment variables
@ -51,21 +46,11 @@ if (config.nodeEnv === 'production') {
}
});
// Check for insecure defaults in production.
// Nicht nur der eine Default-String: podman-compose.yml setzt z. B.
// CHANGE_ME_IN_PRODUCTION, was eine reine Gleichheitspruefung durchlaesst.
if (/change[-_ ]?me|change-in-production|your-secret|secret-key/i.test(config.jwtSecret)) {
console.error('❌ Fehler: JWT_SECRET verwendet einen Platzhalter-Wert!');
console.error(' Bitte ein zufaelliges Secret setzen, z. B. mit: openssl rand -hex 32');
// Check for insecure defaults in production
if (config.jwtSecret === 'your-secret-key-change-in-production') {
console.error('❌ Fehler: JWT_SECRET verwendet unsicheren Default-Wert!');
process.exit(1);
}
// Ohne APP_URL wird der Link in der Passwort-Reset-Mail aus CORS_ORIGIN
// zusammengesetzt. Das funktioniert nur, solange der Unterpfad dem App-Namen
// entspricht - bei abweichendem Deployment fuehrt der Link ins Leere.
if (!process.env.APP_URL) {
console.warn(`⚠️ Warnung: APP_URL ist nicht gesetzt. Reset-Links verwenden "${config.appUrl}".`);
}
}
module.exports = config;

View File

@ -1,7 +1,5 @@
const mongoose = require('mongoose');
const AuditLog = require('../models/AuditLog');
const logger = require('../utils/logger');
const { escapeCell } = require('../utils/csv');
/**
* Get all audit logs with pagination and filtering
@ -101,13 +99,8 @@ const getAdminActivity = async (req, res) => {
try {
const { adminId } = req.params;
if (!mongoose.isValidObjectId(adminId)) {
return res.status(400).json({ success: false, message: 'Ungültige Admin-ID' });
}
const stats = await AuditLog.aggregate([
// ObjectId ist seit bson 5 eine echte Klasse und braucht new.
{ $match: { adminId: new mongoose.Types.ObjectId(adminId) } },
{ $match: { adminId: require('mongoose').Types.ObjectId(adminId) } },
{
$group: {
_id: '$action',
@ -221,6 +214,13 @@ const exportAuditLogs = async (req, res) => {
.limit(10000)
.lean();
const escapeCell = (val) => {
if (val == null) return '';
const str = String(val);
return str.includes(',') || str.includes('"') || str.includes('\n')
? `"${str.replace(/"/g, '""')}"` : str;
};
const header = [
'Zeitstempel', 'Aktion', 'Ressource', 'Ressourcen-Name', 'Admin',
'IP-Adresse', 'Methode', 'Pfad', 'Status-Code', 'Dauer (ms)',

View File

@ -40,7 +40,7 @@ const login = async (req, res) => {
// Generate token
const token = jwt.sign(
{ id: admin._id, username: admin.username, app: config.appName, role: 'admin' },
{ id: admin._id, username: admin.username, app: config.appName },
config.jwtSecret,
{ expiresIn: config.jwtExpiresIn }
);
@ -81,7 +81,7 @@ const logout = async (req, res) => {
try {
// Log logout (get username from token if available)
const username = req.user?.username || 'unknown';
await auditAuth(req, true, username, null, 'LOGOUT');
await auditAuth(req, true, username, null);
// Clear the token cookie
res.clearCookie('token', {
@ -119,9 +119,7 @@ const forgotPassword = async (req, res) => {
}
// Find admin
// case-insensitive wie beim Login: ein als "Thorsten" angelegtes Konto
// konnte sich als "thorsten" anmelden, aber kein Passwort zuruecksetzen.
const admin = await Admin.findOne({ username: { $regex: new RegExp(`^${String(username).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i') } });
const admin = await Admin.findOne({ username });
// Don't reveal if user exists (security best practice)
if (!admin) {
@ -195,13 +193,11 @@ const resetPassword = async (req, res) => {
});
}
// Muss zu minlength im Admin-Schema passen. Vorher stand hier 6: Passwoerter
// mit 6-11 Zeichen kamen durch und scheiterten erst an der Mongoose-
// Validierung, was als 500 "Serverfehler" beim Nutzer ankam.
if (newPassword.length < 12) {
// Validate password length
if (newPassword.length < 6) {
return res.status(400).json({
success: false,
message: 'Passwort muss mindestens 12 Zeichen lang sein'
message: 'Passwort muss mindestens 6 Zeichen lang sein'
});
}

View File

@ -1,7 +1,6 @@
const User = require('../models/User');
const { geocodeAddress } = require('../utils/geocode');
const logger = require('../utils/logger');
const { escapeCell } = require('../utils/csv');
const config = require('../config/env');
const ALLOWED_USER_FIELDS = ['name', 'type', 'address', 'phone', 'landline', 'email', 'available', 'gps', 'notes'];
@ -286,9 +285,7 @@ const getPublicUsers = async (req, res) => {
.sort(req.query.search ? { score: { $meta: 'textScore' } } : { name: 1 })
.skip(skip)
.limit(limit)
// Kontaktdaten sind der Zweck der oeffentlichen Liste; E-Mail, Hashes
// und Invite-Felder bleiben ausgeschlossen.
.select('name type available gps phone landline address photo'),
.select('name type available gps'),
User.countDocuments(filter)
]);
@ -414,6 +411,14 @@ const exportUsers = async (req, res) => {
.select('-__v -passwordHash -deleted -deletedAt -deletedBy');
if (format === 'csv') {
const escapeCell = (val) => {
if (val == null) return '';
const str = String(val);
return str.includes(',') || str.includes('"') || str.includes('\n')
? `"${str.replace(/"/g, '""')}"`
: str;
};
const csv = [
['Name', 'Adresse', 'Telefon', 'Festnetz', 'E-Mail', 'Typ', 'Verfügbar', 'Latitude', 'Longitude'].join(','),
...users.map(user => [
@ -585,10 +590,8 @@ const bulkUpdateUsers = async (req, res) => {
}
// Perform bulk update
// Der pre(/^find/)-Hook des Modells greift bei updateMany nicht,
// der Soft-Delete-Filter muss hier explizit gesetzt werden.
const result = await User.updateMany(
{ _id: { $in: ids }, deleted: { $ne: true } },
{ _id: { $in: ids } },
{ $set: updateFields }
);
@ -626,10 +629,8 @@ const bulkDeleteUsers = async (req, res) => {
}
// Soft delete all users
// Bereits geloeschte Eintraege bleiben unangetastet, damit
// deletedAt/deletedBy nicht ueberschrieben werden.
const result = await User.updateMany(
{ _id: { $in: ids }, deleted: { $ne: true } },
{ _id: { $in: ids } },
{
$set: {
deleted: true,

View File

@ -141,12 +141,10 @@ const auditLog = (action, resource) => {
/**
* Log authentication attempts (success and failure)
*/
// `action` überschreibt die Vorbelegung z. B. 'LOGOUT' für die Abmeldung,
// die sonst fälschlich als LOGIN im Protokoll landen würde.
const auditAuth = async (req, isSuccess, username, errorMessage = null, action = null) => {
const auditAuth = async (req, isSuccess, username, errorMessage = null) => {
try {
await AuditLog.log({
action: action || (isSuccess ? 'LOGIN' : 'LOGIN_FAILED'),
action: isSuccess ? 'LOGIN' : 'LOGIN_FAILED',
resource: 'Admin',
adminUsername: username,
ipAddress: req.ip || req.connection?.remoteAddress,

View File

@ -1,7 +1,6 @@
const jwt = require('jsonwebtoken');
const config = require('../config/env');
// Verifiziert das Token und stellt sicher, dass es sich um ein Admin-Token handelt.
const authenticateToken = (req, res, next) => {
// Try to get token from cookie first (new secure method)
let token = req.cookies?.token;
@ -21,7 +20,6 @@ const authenticateToken = (req, res, next) => {
try {
const decoded = jwt.verify(token, config.jwtSecret);
// Reject tokens issued by a different app (C-01 cross-app auth fix)
if (decoded.app && decoded.app !== config.appName) {
return res.status(403).json({
@ -29,20 +27,6 @@ const authenticateToken = (req, res, next) => {
message: 'Ungültiger oder abgelaufener Token.'
});
}
// Rollenprüfung. Handler-Tokens werden mit demselben Secret signiert; die
// app-Prüfung oben greift bei ihnen nicht, weil sie keinen app-Claim tragen.
// Ohne diese Zeilen kann ein eingeloggter Hundeführer seinen Bearer-Token
// gegen /api/users, /api/config und /api/audit-logs schicken und hat volle
// Admin-Rechte. Tokens ohne role stammen aus der Zeit davor und wurden
// ausschließlich für Admins ausgestellt.
if (decoded.role && decoded.role !== 'admin') {
return res.status(403).json({
success: false,
message: 'Zugriff verweigert. Keine Administratorrechte.'
});
}
req.user = decoded;
next();
} catch (error) {
@ -53,30 +37,4 @@ const authenticateToken = (req, res, next) => {
}
};
// Wie authenticateToken, blockiert aber nicht: setzt req.user wenn ein gültiges
// Admin-Token vorliegt und ruft ansonsten einfach next(). Für Endpunkte wie
// /logout, die auch mit abgelaufenem Token funktionieren müssen.
const attachUserIfPresent = (req, res, next) => {
let token = req.cookies?.token;
if (!token) {
const authHeader = req.headers['authorization'];
token = authHeader && authHeader.split(' ')[1];
}
if (token) {
try {
const decoded = jwt.verify(token, config.jwtSecret);
const appOk = !decoded.app || decoded.app === config.appName;
const roleOk = !decoded.role || decoded.role === 'admin';
if (appOk && roleOk) {
req.user = decoded;
}
} catch (error) {
// Ungültiges Token ist hier kein Fehler der Aufrufer wird ohne req.user bedient.
}
}
next();
};
module.exports = { authenticateToken, attachUserIfPresent };
module.exports = { authenticateToken };

View File

@ -43,7 +43,7 @@ const authLimiter = rateLimit({
// Strict rate limiter for invite / set-password endpoints
const inviteLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
max: 10, // Max 10 attempts per windowMs
skipSuccessfulRequests: true,
message: {
success: false,
@ -60,32 +60,8 @@ const inviteLimiter = rateLimit({
}
});
// Limiter für die öffentliche PLZ-Suche.
// Der Endpunkt stößt ausgehende Anfragen an Nominatim an und teilt sich mit dem
// Geocoding im Admin-Bereich die globale Mindestwartezeit von geocodeMinDelayMs.
// Ohne eigenes Limit können anonyme Aufrufe das Anlegen von Führern ausbremsen
// und die Nominatim-Nutzungsregeln verletzen.
const geocodeLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 20,
message: {
success: false,
message: 'Zu viele PLZ-Abfragen. Bitte warten Sie einen Moment.'
},
standardHeaders: true,
legacyHeaders: false,
handler: (req, res) => {
logger.warn(`Geocode rate limit exceeded for IP: ${req.ip}`);
res.status(429).json({
success: false,
message: 'Zu viele PLZ-Abfragen. Bitte warten Sie einen Moment.'
});
}
});
module.exports = {
apiLimiter,
authLimiter,
inviteLimiter,
geocodeLimiter
inviteLimiter
};

View File

@ -1,11 +1,11 @@
{
"name": "drohnenfuehrer-backend",
"name": "tracking-leaders-backend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "drohnenfuehrer-backend",
"name": "tracking-leaders-backend",
"version": "1.0.0",
"dependencies": {
"bcryptjs": "^2.4.3",
@ -15,12 +15,9 @@
"express": "^4.18.2",
"express-rate-limit": "^8.2.1",
"express-validator": "^7.3.1",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
"mongoose": "^7.5.0",
"nodemailer": "^6.9.16",
"winston": "^3.19.0",
"winston-daily-rotate-file": "^5.0.0"
"winston": "^3.19.0"
},
"devDependencies": {
"jest": "^30.2.0",
@ -2883,15 +2880,6 @@
"integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==",
"license": "MIT"
},
"node_modules/file-stream-rotator": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/file-stream-rotator/-/file-stream-rotator-0.6.1.tgz",
"integrity": "sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==",
"license": "MIT",
"dependencies": {
"moment": "^2.29.1"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@ -3254,18 +3242,6 @@
"node": ">= 0.4"
}
},
"node_modules/helmet": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
"integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"url": "https://github.com/sponsors/EvanHahn"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@ -4603,15 +4579,6 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/mongodb": {
"version": "5.9.2",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-5.9.2.tgz",
@ -4787,15 +4754,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemon": {
"version": "3.1.11",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
@ -4882,15 +4840,6 @@
"node": ">=0.10.0"
}
},
"node_modules/object-hash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@ -6300,24 +6249,6 @@
"node": ">= 12.0.0"
}
},
"node_modules/winston-daily-rotate-file": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/winston-daily-rotate-file/-/winston-daily-rotate-file-5.0.0.tgz",
"integrity": "sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==",
"license": "MIT",
"dependencies": {
"file-stream-rotator": "^0.6.1",
"object-hash": "^3.0.0",
"triple-beam": "^1.4.1",
"winston-transport": "^4.7.0"
},
"engines": {
"node": ">=8"
},
"peerDependencies": {
"winston": "^3"
}
},
"node_modules/winston-transport": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz",

View File

@ -2,12 +2,9 @@ const express = require('express');
const router = express.Router();
const { login, logout, forgotPassword, resetPassword } = require('../controllers/authController');
const { validateLogin } = require('../middleware/validator');
const { attachUserIfPresent } = require('../middleware/auth');
router.post('/login', validateLogin, login);
// attachUserIfPresent statt authenticateToken: der Logout muss auch mit
// abgelaufenem Token funktionieren, soll den Benutzernamen aber protokollieren.
router.post('/logout', attachUserIfPresent, logout);
router.post('/logout', logout);
router.post('/forgot-password', forgotPassword);
router.post('/reset-password', resetPassword);

View File

@ -3,7 +3,6 @@ const router = express.Router();
const { authenticateToken } = require('../middleware/auth');
const { auditLog } = require('../middleware/auditLogger');
const { validateGPS, validateAvailability } = require('../middleware/validator');
const { geocodeLimiter } = require('../middleware/rateLimiter');
const {
getAllUsers,
getUserById,
@ -26,19 +25,13 @@ const {
// Public routes
router.get('/public/users', getPublicUsers);
// Eigenes, engeres Limit: der Endpunkt loest ausgehende Nominatim-Anfragen aus.
router.get('/public/geocode', geocodeLimiter, getGeocodeByPostalCode);
router.get('/public/geocode', getGeocodeByPostalCode);
// Protected routes (require authentication)
router.get('/users', authenticateToken, getAllUsers);
router.get('/users/export', authenticateToken, auditLog('EXPORT', 'User'), exportUsers);
router.post('/users/import', authenticateToken, auditLog('IMPORT', 'User'), importUsers);
router.get('/users/deleted', authenticateToken, getDeletedUsers);
// Bulk-Operationen MÜSSEN vor /users/:id stehen, sonst schluckt die
// :id-Route den Pfad /users/bulk und die Massen-Löschung läuft ins Leere.
router.patch('/users/bulk', authenticateToken, auditLog('BULK_UPDATE', 'User'), bulkUpdateUsers);
router.delete('/users/bulk', authenticateToken, auditLog('BULK_DELETE', 'User'), bulkDeleteUsers);
router.get('/users/:id', authenticateToken, getUserById);
router.post('/users', authenticateToken, auditLog('CREATE', 'User'), createUser);
router.put('/users/:id', authenticateToken, auditLog('UPDATE', 'User'), updateUser);
@ -52,5 +45,7 @@ router.post('/users/:id/photo', authenticateToken, auditLog('UPDATE', 'User'), u
router.delete('/users/:id/photo', authenticateToken, auditLog('UPDATE', 'User'), deleteUserPhoto);
// Bulk operations
router.patch('/users/bulk', authenticateToken, auditLog('BULK_UPDATE', 'User'), bulkUpdateUsers);
router.delete('/users/bulk', authenticateToken, auditLog('BULK_DELETE', 'User'), bulkDeleteUsers);
module.exports = router;

View File

@ -9,10 +9,7 @@ const users = [];
const seedDatabase = async () => {
try {
// Beim Aufruf aus server.js besteht die Verbindung bereits.
if (mongoose.connection.readyState !== 1) {
await mongoose.connect(config.mongoUri);
}
logger.info('MongoDB verbunden für Seeding...');
@ -54,8 +51,6 @@ const seedDatabase = async () => {
}
// Seed config - always update userTypes + sections + rules
// Config NUR anlegen, niemals ueberschreiben. Vorher hat jeder Neustart
// die im Admin-Panel gepflegten Texte, Regeln und den App-Namen zurueckgesetzt.
const existingConfig = await Config.findOne();
const configData = {
userTypes: [
@ -95,23 +90,22 @@ const seedDatabase = async () => {
await Config.create(configData);
logger.info('✅ Konfiguration erstellt');
} else {
logger.info(' Konfiguration existiert bereits, bleibt unveraendert');
await Config.findOneAndUpdate({}, configData, { new: true });
logger.info('✅ Konfiguration aktualisiert');
}
logger.info('✅ Datenbank-Seeding abgeschlossen');
await mongoose.connection.close();
process.exit(0);
} catch (error) {
logger.error('❌ Fehler beim Seeding:', error);
throw error;
await mongoose.connection.close();
process.exit(1);
}
};
// Verbindung schliessen und den Prozess beenden darf nur der CLI-Aufruf
// (npm run seed). server.js ruft seedDatabase() im selben Prozess auf ein
// process.exit(0) hier hat den frisch gestarteten Server sofort wieder beendet.
if (require.main === module) {
seedDatabase()
.then(async () => { await mongoose.connection.close(); process.exit(0); })
.catch(async () => { await mongoose.connection.close(); process.exit(1); });
seedDatabase();
}
module.exports = seedDatabase;

View File

@ -16,17 +16,11 @@ const connectWithRetry = async () => {
try {
await connectDB();
// Seeding legt Admin-Konto und Grundkonfiguration an. Die Bedingung darf sich
// NICHT an der User-Zahl orientieren: die Seed-Liste ist bewusst leer, dadurch
// lief das Seeding bei jedem Start erneut.
const Admin = require('./models/Admin');
const Config = require('./models/Config');
const [adminCount, configCount] = await Promise.all([
Admin.countDocuments(),
Config.countDocuments()
]);
if (adminCount === 0 || configCount === 0) {
logger.info('Admin oder Konfiguration fehlt, starte Seeding...');
// Seed database if empty (runs in all environments on first start)
const User = require('./models/User');
const userCount = await User.countDocuments();
if (userCount === 0) {
logger.info('Datenbank ist leer, starte Seeding...');
try {
const seed = require('./seed');
await seed();
@ -89,24 +83,17 @@ app.get('/health', async (req, res) => {
});
});
// Error handler (must be last)
app.use(errorHandler);
const PORT = config.port;
const server = app.listen(PORT, () => {
logger.info(`Server läuft auf Port ${PORT} (${config.nodeEnv})`);
});
// Graceful shutdown (Docker stop / Kubernetes rolling restart)
let shuttingDown = false;
const shutdown = (signal) => {
if (shuttingDown) return;
shuttingDown = true;
logger.info(`${signal} empfangen, fahre Server herunter...`);
const forceExit = setTimeout(() => {
logger.warn('Shutdown-Timeout erreicht, beende Prozess hart');
process.exit(1);
}, 10000);
forceExit.unref();
// Graceful shutdown on SIGTERM (Docker stop / Kubernetes rolling restart)
process.on('SIGTERM', () => {
logger.info('SIGTERM empfangen, fahre Server herunter...');
server.close(() => {
logger.info('HTTP-Server geschlossen');
mongoose.connection.close(false).then(() => {
@ -114,13 +101,9 @@ const shutdown = (signal) => {
process.exit(0);
}).catch(() => process.exit(1));
});
};
});
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// If a frontend build exists, serve it as static files (useful for local testing).
// Muss vor dem errorHandler stehen der gehoert als letztes Middleware registriert.
// If a frontend build exists, serve it as static files (useful for local testing)
const path = require('path');
const fs = require('fs');
const buildPath = path.join(__dirname, '..', 'frontend', 'build');
@ -134,6 +117,3 @@ if (fs.existsSync(buildPath)) {
res.sendFile(path.join(buildPath, 'index.html'));
});
}
// Error handler (must be last)
app.use(errorHandler);

View File

@ -1,19 +0,0 @@
/**
* Escaped eine einzelne CSV-Zelle.
*
* Neben dem üblichen Quoting werden Werte neutralisiert, die mit =, +, - oder @
* beginnen: Excel und LibreOffice würden sie sonst als Formel auswerten
* (CSV-Injection über einen frei wählbaren Namen oder eine Adresse).
*/
const escapeCell = (val) => {
if (val == null) return '';
let str = String(val);
if (/^[=+\-@\t\r]/.test(str)) {
str = `'${str}`;
}
return /["\n\r,]/.test(str) ? `"${str.replace(/"/g, '""')}"` : str;
};
module.exports = { escapeCell };

View File

@ -6,41 +6,17 @@ const logger = require('./logger');
const CACHE_FILE = path.join(__dirname, '..', 'geocode-cache.json');
const CACHE_SAVE_INTERVAL = 60000; // Save every 60 seconds
const CACHE_MAX_ENTRIES = 1000;
const cache = new Map();
let lastRequestTime = 0;
let cacheModified = false;
/**
* Einziger Schreibpfad in den Cache, inklusive Größenbegrenzung.
*
* Vorher war nur der Erfolgspfad begrenzt; die beiden Negativ-Pfade
* (Adresse nicht gefunden / unbrauchbare Koordinaten) haben ungebremst
* geschrieben. Über den öffentlichen /api/public/geocode genügten damit
* erfundene Postleitzahlen, um Speicher und Cache-Datei beliebig wachsen
* zu lassen bei --max_old_space_size=256 eine reale Grenze.
*/
const rememberInCache = (key, value) => {
// Map behält die Einfügereihenfolge: ein vorhandener Schlüssel muss neu
// eingefügt werden, damit er als "zuletzt benutzt" ans Ende rückt.
cache.delete(key);
while (cache.size >= CACHE_MAX_ENTRIES) {
cache.delete(cache.keys().next().value);
}
cache.set(key, value);
cacheModified = true;
};
// Load cache from file on startup
const loadCache = async () => {
try {
const data = await fs.readFile(CACHE_FILE, 'utf8');
const parsed = JSON.parse(data);
// Nur die letzten CACHE_MAX_ENTRIES übernehmen eine früher unbegrenzt
// gewachsene Datei darf den Cache nicht wieder aufblähen.
const entries = Object.entries(parsed).slice(-CACHE_MAX_ENTRIES);
entries.forEach(([key, value]) => {
Object.entries(parsed).forEach(([key, value]) => {
cache.set(key, value);
});
logger.info(`Geocoding cache loaded: ${cache.size} entries`);
@ -69,13 +45,14 @@ const saveCache = async () => {
setInterval(saveCache, CACHE_SAVE_INTERVAL).unref();
// Save on process exit
// Cache beim Herunterfahren sichern ohne process.exit(): das Beenden gehört
// dem Shutdown-Handler in server.js, der sonst mittendrin abgeschnitten wird.
const flushOnShutdown = () => {
saveCache().catch(err => logger.error('Failed to flush geocoding cache:', err.message));
};
process.on('SIGINT', flushOnShutdown);
process.on('SIGTERM', flushOnShutdown);
process.on('SIGINT', async () => {
await saveCache();
process.exit(0);
});
process.on('SIGTERM', async () => {
await saveCache();
process.exit(0);
});
// Initialize cache loading
loadCache().catch(err => logger.error('Cache initialization error:', err));
@ -112,12 +89,7 @@ const geocodeAddress = async (address) => {
const cacheKey = normalized.toLowerCase();
if (cache.has(cacheKey)) {
// Treffer ans Ende rücken, damit die Verdrängung wirklich den am längsten
// ungenutzten Eintrag trifft und nicht bloß den ältesten eingefügten.
const hit = cache.get(cacheKey);
cache.delete(cacheKey);
cache.set(cacheKey, hit);
return hit;
return cache.get(cacheKey);
}
const elapsed = Date.now() - lastRequestTime;
@ -135,7 +107,8 @@ const geocodeAddress = async (address) => {
lastRequestTime = Date.now();
if (!Array.isArray(results) || results.length === 0) {
rememberInCache(cacheKey, null);
cache.set(cacheKey, null);
cacheModified = true;
return null;
}
@ -144,12 +117,15 @@ const geocodeAddress = async (address) => {
const lng = parseFloat(hit.lon);
if (Number.isNaN(lat) || Number.isNaN(lng)) {
rememberInCache(cacheKey, null);
cache.set(cacheKey, null);
cacheModified = true;
return null;
}
const coords = { lat, lng };
rememberInCache(cacheKey, coords);
if (cache.size >= 1000) { cache.delete(cache.keys().next().value); }
cache.set(cacheKey, coords);
cacheModified = true;
return coords;
} catch (error) {
logger.warn('Geocoding fehlgeschlagen', {

View File

@ -45,8 +45,7 @@ services:
# - SMTP_USER=user@example.com
# - SMTP_PASS=${SMTP_PASS}
# - SMTP_FROM=drohnenfuehrer@example.com
# Basis fuer Links in Passwort-Reset-Mails. MUSS den Unterpfad enthalten.
- APP_URL=${APP_URL:-http://localhost:8081/drohnenfuehrer}
# - APP_URL=https://example.com/drohnenfuehrer
depends_on:
mongo:
condition: service_healthy
@ -62,7 +61,7 @@ services:
context: ./frontend
args:
- PUBLIC_URL=/drohnenfuehrer/
- VITE_API_URL=${VITE_API_URL:-}
- REACT_APP_API_URL=${REACT_APP_API_URL:-}
container_name: drohnenfuehrer-frontend
restart: unless-stopped
ports:

View File

@ -183,4 +183,4 @@ frontend/src/
- `CORS_ORIGIN`: Erlaubter CORS-Origin
### Frontend (.env)
- `VITE_API_URL`: Backend-API-URL (Standard: http://localhost:5000)
- `REACT_APP_API_URL`: Backend-API-URL (Standard: http://localhost:5000)

View File

@ -1,9 +1,9 @@
# API Configuration
# For local development
VITE_API_URL=http://localhost:5000
REACT_APP_API_URL=http://localhost:5000
# For production, use your actual backend URL
# VITE_API_URL=https://api.yourdomain.com
# REACT_APP_API_URL=https://api.yourdomain.com
# Admin path (optional, defaults to /verwaltung)
# VITE_ADMIN_PATH=/verwaltung
# REACT_APP_ADMIN_PATH=/verwaltung

View File

@ -10,8 +10,8 @@ COPY . .
ARG PUBLIC_URL=/drohnenfuehrer/
ENV PUBLIC_URL=$PUBLIC_URL
ARG VITE_API_URL=
ENV VITE_API_URL=$VITE_API_URL
ARG REACT_APP_API_URL=
ENV REACT_APP_API_URL=$REACT_APP_API_URL
# Limit Node.js heap during build to prevent OOM kills
ENV NODE_OPTIONS="--max_old_space_size=512"

View File

@ -1,18 +1,16 @@
<!DOCTYPE html>
<html lang="de">
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%BASE_URL%icons/icon-192.png" />
<link rel="icon" href="%BASE_URL%favicon.ico" />
<link rel="apple-touch-icon" href="%BASE_URL%icons/icon-192.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#1a3d1a" />
<meta name="theme-color" content="#2d6a2d" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Drohnenführer" />
<meta name="mobile-web-app-capable" content="yes" />
<!-- Pfad relativ zur Deployment-Basis: ein absolutes "/manifest.json" wuerde
das Portal-Manifest laden und die App als Portal installieren. -->
<link rel="manifest" href="%BASE_URL%manifest.json" />
<link rel="manifest" href="/manifest.json" />
<meta
name="description"
content="Drohnenführer Heidekreis Übersicht der Drohnenführer"

View File

@ -27,28 +27,19 @@ server {
gzip_types text/plain text/xml application/xml+rss application/json;
gzip_disable "msie6";
# Security headers.
# ACHTUNG: nginx vererbt add_header nicht in Bloecke, die eigene add_header
# setzen - deshalb sind diese drei Zeilen in jedem solchen location-Block
# wiederholt. Beim Anlegen neuer Bloecke mit add_header daran denken.
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Cache static assets (images, fonts)
location ~* \.(jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$ {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
expires 1y;
add_header Cache-Control "public, immutable";
}
# JS and CSS - no compression, short cache
location ~* \.(js|css)$ {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
expires 1h;
add_header Cache-Control "public, no-transform";
gzip off;
@ -56,9 +47,6 @@ server {
# Service Worker - never cache
location = /sw.js {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Cache-Control "no-cache, no-store, must-revalidate";
expires 0;
}
@ -91,9 +79,6 @@ server {
# Service Worker for subpath deployment
location = /drohnenfuehrer/sw.js {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Cache-Control "no-cache, no-store, must-revalidate";
expires 0;
alias /usr/share/nginx/html/sw.js;
@ -101,9 +86,6 @@ server {
# index.html - never cache so new builds are picked up immediately
location = /index.html {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
expires 0;
@ -111,9 +93,6 @@ server {
# SPA fallback for subpath deployment (/drohnenfuehrer)
location ^~ /drohnenfuehrer/ {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
rewrite ^/drohnenfuehrer(/.*)$ $1 break;
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
@ -123,9 +102,6 @@ server {
# SPA fallback - serve index.html for all routes
location / {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
@ -134,9 +110,6 @@ server {
# Health check endpoint
location /health {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
access_log off;
return 200 "OK\n";
add_header Content-Type text/plain;

File diff suppressed because it is too large Load Diff

View File

@ -3,16 +3,28 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.3.0",
"@testing-library/user-event": "^13.5.0",
"axios": "^1.13.5",
"leaflet": "^1.9.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-leaflet": "^4.2.1"
"react-leaflet": "^4.2.1",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "vite",
"start": "react-scripts start",
"build": "vite build",
"preview": "vite preview"
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [

View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/icons/icon-192.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#2d6a2d" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Drohnenführer Heidekreis" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<meta
name="description"
content="Drohnenführer Heidekreis Übersicht der Drohnenführer"
/>
<title>Drohnenführer Heidekreis</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 692 KiB

View File

@ -2,6 +2,11 @@
"short_name": "Drohnenführer",
"name": "Drohnenführer Heidekreis",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "icons/icon-192.png",
"sizes": "192x192",
@ -19,7 +24,7 @@
"scope": "/drohnenfuehrer/",
"display": "standalone",
"orientation": "portrait",
"theme_color": "#1a3d1a",
"background_color": "#e8e8e2",
"theme_color": "#2d6a2d",
"background_color": "#ffffff",
"description": "Drohnenführer Heidekreis Übersicht der Drohnenführer"
}

View File

@ -1,93 +1,16 @@
<!DOCTYPE html>
<html lang="de">
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#1a3d1a" />
<title>Offline Jagd Apps Heidekreis</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offline - Drohnenführer</title>
<style>
/* Eigenständige Seite: sie wird vom Service Worker ausgeliefert, wenn das
Netz fehlt, und kann deshalb keine Stylesheets der App nachladen.
Palette und Schriftmodell entsprechen dem Portal, inklusive Nachtansicht. */
* { box-sizing: border-box; }
:root {
--bg: #e8e8e2;
--card: #f4f4ee;
--text: #1a1a1a;
--muted: #55554c;
--border: #c9c9b8;
--green: #2d5a2d;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14180f;
--card: #1e241a;
--text: #e9e7dd;
--muted: #a6a698;
--border: #333b28;
--green: #7fb36f;
}
}
body {
margin: 0;
min-height: 100dvh;
padding: 2rem 1.25rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
text-align: center;
}
.card {
max-width: 26rem;
padding: 2rem 1.5rem;
background: var(--card);
border: 1px solid var(--border);
border-radius: 2px;
}
.icon { font-size: 3rem; line-height: 1; margin-bottom: 1rem; }
h1 {
margin: 0 0 0.5rem;
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.5rem;
color: var(--text);
}
p { margin: 0 0 1.5rem; color: var(--muted); font-size: 1rem; line-height: 1.5; }
button {
min-height: 44px;
padding: 0.6rem 1.4rem;
background: var(--green);
color: var(--bg);
border: none;
border-radius: 2px;
font: inherit;
font-weight: 600;
cursor: pointer;
}
button:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
h1 { color: #333; }
</style>
</head>
<body>
<div class="card">
<div class="icon" role="img" aria-label="Kein Empfang">📡</div>
<h1>Keine Verbindung</h1>
<p>
Im Funkloch sind die zuletzt geladenen Daten nicht verfügbar.
Sobald wieder Empfang besteht, lädt die Seite normal.
</p>
<button type="button" onclick="location.reload()">Erneut versuchen</button>
</div>
<h1>Du bist offline</h1>
<p>Die App ist derzeit nicht verfügbar. Bitte überprüfe deine Internetverbindung.</p>
</body>
</html>

View File

@ -2,203 +2,43 @@
box-sizing: border-box;
}
/*
Design-Tokens einzige Farbquelle der App.
Palette und Formensprache folgen dem Portal (portal/index.html), damit der
Wechsel vom Portal in eine App nicht wie ein Produktwechsel wirkt.
Schriftmodell wie im Portal: Serif für die Marken-/Überschriftenebene,
Sans für funktionale UI-Texte (auf kleinen Displays besser lesbar).
Alle Textpaare erfüllen WCAG AA (>= 4.5:1), funktionale Rahmen >= 3.0:1
in beiden Varianten nachgerechnet.
*/
:root {
--font-display: Georgia, 'Times New Roman', serif;
--font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
--font-mono: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
/* Flächen */
--color-bg: #e8e8e2;
--color-surface: #f4f4ee;
--color-surface-alt: #dedcd2;
--color-muted-bg: #dedcd2;
/* Text */
--color-primary: #2e8b2e;
--color-primary-dark: #1e6b1e;
--color-primary-accent: #8B0D12;
--color-secondary: #6c757d;
--color-secondary-dark: #5a6268;
--color-success: #28a745;
--color-success-dark: #218838;
--color-danger: #dc3545;
--color-danger-dark: #c82333;
--color-bg: #f5f5f0;
--color-surface: #ffffff;
--color-text: #1a1a1a;
--color-text-muted: #55554c;
/* Rahmen: -border ist dekorativ (Trennlinien, Karten),
-border-strong begrenzt Bedienelemente und erfüllt die 3:1-Anforderung. */
--color-border: #c9c9b8;
--color-border-strong: #7d7d68;
/* Aktionsfarben jeweils mit der zugehörigen Textfarbe, damit die
Dunkelvariante nicht auf weißem Text auf hellem Grund landet. */
--color-primary: #2d5a2d;
--color-primary-dark: #1a3d1a;
--color-on-primary: #ffffff;
--color-secondary: #5c5c50;
--color-secondary-dark: #46463c;
--color-on-secondary: #ffffff;
--color-success: #1f6b34;
--color-success-dark: #175128;
--color-on-success: #ffffff;
--color-danger: #a32020;
--color-danger-dark: #821919;
--color-on-danger: #ffffff;
--color-warning: #7a5200;
--color-on-warning: #ffffff;
--color-accent: #8b0d12;
--color-on-accent: #ffffff;
/* Altname, wird von RulesDisplay noch benutzt */
--color-primary-accent: #8b0d12;
/* Getönte Status-Flächen (Meldungen, Badges, Zustands-Karten).
Der Block wird durch seine Füllung erkannt, der Rahmen ist Zierde. */
--color-success-bg: #dfeedd;
--color-success-border: #a9cba4;
--color-success-text: #1a4a24;
--color-danger-bg: #f6e0e0;
--color-danger-border: #d9a9a9;
--color-danger-text: #7d1a1a;
--color-warning-bg: #f7edd4;
--color-warning-border: #d9c48a;
--color-warning-text: #5c3d00;
--color-info: #24608f;
--color-info-dark: #1b4a6e;
--color-on-info: #ffffff;
--color-info-bg: #dde8f1;
--color-info-border: #a5bfd4;
--color-info-text: #14405f;
/* Als RGB-Tripel fuer rgba()-Anwendungen (Puls-Animation im Admin-Panel) */
--color-primary-rgb: 45, 90, 45;
--color-focus: rgba(45, 90, 45, 0.35);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.15);
--shadow-md: 0 2px 6px rgba(0, 0, 0, 0.2);
/* Kantige Ecken wie im Portal */
--radius-sm: 2px;
--radius-md: 2px;
--radius-pill: 2px;
--color-text-muted: #555;
--color-border: #b8d4b8;
--color-border-strong: #88b888;
--color-focus: rgba(46, 139, 46, 0.2);
--color-muted-bg: #f5f5f0;
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.2);
--radius-sm: 4px;
--radius-md: 8px;
--radius-pill: 12px;
--space-1: 0.5rem;
--space-2: 1rem;
--space-3: 1.5rem;
--space-4: 2rem;
/* Mindestgröße für Bedienelemente auf Touchgeräten */
--touch-target: 44px;
}
/* Nachtvariante. Nachsuchen laufen in der Dämmerung und nachts ein weißes
Vollbild blendet dann und kostet die Dunkeladaption der Augen. Warme, sehr
dunkle Grüntöne statt reinem Schwarz. */
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #14180f;
--color-surface: #1e241a;
--color-surface-alt: #2a3124;
--color-muted-bg: #2a3124;
--color-text: #e9e7dd;
--color-text-muted: #a6a698;
--color-border: #333b28;
--color-border-strong: #758566;
--color-primary: #7fb36f;
--color-primary-dark: #9ccb8c;
--color-on-primary: #10140c;
--color-secondary: #8d8d80;
--color-secondary-dark: #a3a396;
--color-on-secondary: #10140c;
--color-success: #79c48c;
--color-success-dark: #93d3a3;
--color-on-success: #10140c;
--color-danger: #ea8b8b;
--color-danger-dark: #f2a5a5;
--color-on-danger: #10140c;
--color-warning: #d6b25f;
--color-on-warning: #10140c;
--color-accent: #e88a8f;
--color-on-accent: #10140c;
--color-primary-accent: #e88a8f;
--color-success-bg: #1d2c20;
--color-success-border: #3d5c43;
--color-success-text: #93d3a3;
--color-danger-bg: #33201f;
--color-danger-border: #6b4040;
--color-danger-text: #f2a5a5;
--color-warning-bg: #322a15;
--color-warning-border: #63552c;
--color-warning-text: #e0c37c;
--color-info: #7fb0dc;
--color-info-dark: #9cc4e8;
--color-on-info: #10140c;
--color-info-bg: #1a2530;
--color-info-border: #3d5468;
--color-info-text: #9cc4e8;
--color-primary-rgb: 127, 179, 111;
--color-focus: rgba(127, 179, 111, 0.45);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.5);
--shadow-md: 0 2px 6px rgba(0, 0, 0, 0.6);
}
}
body {
margin: 0;
font-family: var(--font-ui);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: var(--color-bg);
color: var(--color-text);
/* Damit auch vom Browser gestellte Bedienelemente (Bildlaufleisten,
Datumsauswahl, Autofill) der gewählten Ansicht folgen. */
color-scheme: light dark;
}
/* Formularfelder brauchen ausdrücklich Farben: ohne sie nimmt der Browser
seinen Standard (weiß) in der Nachtansicht leuchtet dann jedes Eingabefeld. */
input:not([type='checkbox']):not([type='radio']):not([type='range']):not([type='file']),
select,
textarea {
background: var(--color-surface);
color: var(--color-text);
}
input::placeholder,
textarea::placeholder {
color: var(--color-text-muted);
opacity: 1;
}
h1, h2, h3 {
font-family: var(--font-display);
}
code {
font-family: var(--font-mono);
}
/* ── Bedienelemente ─────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-1);
min-height: var(--touch-target);
padding: 0.5rem 1rem;
border: none;
border-radius: var(--radius-sm);
font-family: var(--font-ui);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
@ -206,24 +46,15 @@ code {
}
.btn:disabled {
background: var(--color-surface-alt);
background: var(--color-border);
color: var(--color-text-muted);
cursor: not-allowed;
box-shadow: none;
}
/* Sichtbarer Tastaturfokus. Ohne das war die Tastaturbedienung unsichtbar
Buttons hatten nur einen :hover-Stil. */
.btn:focus-visible,
.nav-button:focus-visible,
a:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.btn-primary {
background: var(--color-primary);
color: var(--color-on-primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
@ -233,7 +64,7 @@ a:focus-visible {
.btn-secondary {
background: var(--color-secondary);
color: var(--color-on-secondary);
color: white;
}
.btn-secondary:hover:not(:disabled) {
@ -241,9 +72,19 @@ a:focus-visible {
transform: translateY(-1px);
}
.btn-success {
background: var(--color-success);
color: white;
}
.btn-success:hover:not(:disabled) {
background: var(--color-success-dark);
transform: translateY(-1px);
}
.btn-danger {
background: var(--color-danger);
color: var(--color-on-danger);
color: white;
}
.btn-danger:hover:not(:disabled) {
@ -255,13 +96,9 @@ a:focus-visible {
.select,
.textarea {
width: 100%;
min-height: var(--touch-target);
padding: 0.75rem 1rem;
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-sm);
font-family: var(--font-ui);
font-size: 1rem;
box-sizing: border-box;
}
@ -274,19 +111,33 @@ a:focus-visible {
box-shadow: 0 0 0 3px var(--color-focus);
}
.panel {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-3);
box-shadow: var(--shadow-sm);
}
.panel-title {
margin: 0 0 var(--space-2);
font-family: var(--font-display);
font-size: 1.1rem;
color: var(--color-text);
}
/* ── Grundgerüst ────────────────────────────────────────────────────────── */
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: var(--color-bg);
color: var(--color-text);
}
.App {
/* dvh statt vh: mit ein- und ausblendender Adressleiste auf Mobilgeräten
entsteht mit vh sonst Überlauf. */
min-height: 100dvh;
min-height: 100vh;
display: flex;
flex-direction: column;
}
@ -300,26 +151,44 @@ a:focus-visible {
display: flex;
justify-content: center;
align-items: center;
min-height: 100dvh;
min-height: 100vh;
font-size: 1.2rem;
color: var(--color-text-muted);
}
/* Wer Bewegung reduziert haben möchte, bekommt keine Animationen. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
.login-prompt {
position: fixed;
bottom: 20px;
right: 20px;
}
.login-button-header {
padding: 0.75rem 1.5rem;
background: var(--color-primary);
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
transition: all 0.2s;
}
.login-button-header:hover {
background: var(--color-primary-dark);
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3);
}
@media (max-width: 768px) {
.login-prompt {
bottom: 10px;
right: 10px;
}
.btn:hover:not(:disabled),
.btn-primary:hover:not(:disabled),
.btn-secondary:hover:not(:disabled),
.btn-danger:hover:not(:disabled) {
transform: none;
.login-button-header {
padding: 0.6rem 1.2rem;
font-size: 0.9rem;
}
}

View File

@ -11,15 +11,13 @@ import Admin from './pages/Admin';
import DrohnenfuehrerLogin from './components/drohnenfuehrer/DrohnenfuehrerLogin';
import DrohnenfuehrerDashboard from './components/drohnenfuehrer/DrohnenfuehrerDashboard';
import InstallBanner from './components/common/InstallBanner';
import { ADMIN_PATH, RESET_PASSWORD_PATH, withBase, normalizePath } from './utils/constants';
import './App.css';
function App() {
// Die Pfade muessen den Deployment-Basispfad enthalten: produktiv laeuft die
// App unter /<app>/, ein Vergleich gegen '/verwaltung' traefe dort nie zu.
const currentPath = normalizePath(window.location.pathname);
const isAdminRoute = currentPath === normalizePath(withBase(ADMIN_PATH));
const isResetPasswordRoute = currentPath === normalizePath(withBase(RESET_PASSWORD_PATH));
const adminPath = process.env.REACT_APP_ADMIN_PATH || '/verwaltung';
const resetPasswordPath = '/passwort-zuruecksetzen';
const isAdminRoute = window.location.pathname === adminPath;
const isResetPasswordRoute = window.location.pathname === resetPasswordPath;
const [view, setView] = useState('public');
const [drohnenfuehrerUser, setDrohnenfuehrerUser] = useState(null);
const { isAuthenticated, loading: authLoading, login, logout } = useAuth();
@ -99,6 +97,7 @@ function App() {
isAdmin={false}
currentView={view}
onViewChange={handleViewChange}
onDrohnenfuehrerLogin={handleDrohnenfuehrerLogout}
/>
<main className="app-main">
{isAdminRoute || view === 'login' ? (

View File

@ -25,7 +25,7 @@
.tab-button.active {
background: var(--color-primary);
color: var(--color-on-primary);
color: white;
}
.settings-panel {
@ -59,9 +59,9 @@
.unsaved-badge {
display: inline-block;
background: var(--color-warning-bg);
color: var(--color-warning-text);
border: 1px solid var(--color-warning);
background: #fff3cd;
color: #856404;
border: 1px solid #ffc107;
border-radius: var(--radius-sm);
padding: 0.2rem 0.6rem;
font-size: 0.8rem;
@ -102,7 +102,7 @@
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--color-muted-bg, var(--color-surface-alt));
background: var(--color-muted-bg, #f5f5f0);
border: none;
padding: 0.75rem var(--space-2);
cursor: pointer;
@ -111,7 +111,7 @@
}
.settings-section-header:hover {
background: var(--color-focus, var(--color-success-bg));
background: var(--color-focus, #eef2e6);
}
.settings-section-header .settings-heading {
@ -193,7 +193,7 @@
.skeleton-line {
height: 1rem;
border-radius: var(--radius-sm);
background: linear-gradient(90deg, var(--color-surface-alt) 25%, var(--color-surface-alt) 50%, var(--color-surface-alt) 75%);
background: linear-gradient(90deg, #e8e8e4 25%, #f0f0ec 50%, #e8e8e4 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.4s infinite;
margin-bottom: 0.75rem;
@ -323,17 +323,17 @@
.notification.success {
background: var(--color-success);
color: var(--color-on-success);
color: white;
}
.notification.error {
background: var(--color-danger);
color: var(--color-on-danger);
color: white;
}
.notification.warning {
background: var(--color-warning);
color: var(--color-on-warning);
background: #e67e00;
color: white;
}
@keyframes slideIn {

View File

@ -3,7 +3,7 @@ import UserList from '../users/UserList';
import ExportButton from './ExportButton';
import Trash from './Trash';
import AuditLogs from './AuditLogs';
import { updateAvailability, updateGPS, createUser, updateUser, deleteUser, uploadUserPhoto, deleteUserPhoto, generateInviteToken } from '../../services/users';
import { updateAvailability, updateGPS, createUser, updateUser, deleteUser, uploadUserPhoto, deleteUserPhoto } from '../../services/users';
import { getFullConfig, updateConfig, uploadLogo, deleteLogo } from '../../services/config';
import ErrorMessage from '../common/ErrorMessage';
import './AdminPanel.css';
@ -127,16 +127,6 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
}
};
const handleGenerateInvite = async (id) => {
const result = await generateInviteToken(id);
if (result.success) {
showNotification('success', 'Einladungs-Token erzeugt (7 Tage gueltig)');
} else {
showNotification('error', result.message || 'Fehler beim Erzeugen des Einladungs-Tokens');
}
return result;
};
const handleConfigUpdate = async (updatedConfig) => {
try {
const response = await updateConfig(updatedConfig);
@ -197,7 +187,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
const file = e.target.files[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
showNotification('error', 'Nur Bilddateien erlaubt (JPG, PNG, WebP)');
showNotification('error', 'Nur Bilddateien erlaubt (JPG, PNG, SVG, ...)');
return;
}
if (file.size > 500 * 1024) {
@ -301,7 +291,6 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
onUserDelete={handleUserDelete}
onPhotoUpload={handleUserPhotoUpload}
onPhotoDelete={handleUserPhotoDelete}
onGenerateInvite={handleGenerateInvite}
/>
</>
)}
@ -419,7 +408,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
</button>
)}
</div>
<p className="settings-hint">Erlaubt: PNG, JPG oder WebP, max. 500 KB (SVG wird aus Sicherheitsgruenden abgelehnt)</p>
<p className="settings-hint">Empfohlen: PNG, SVG oder JPG, max. 500 KB</p>
</div>
)}
</div>

View File

@ -13,8 +13,8 @@
gap: 12px;
flex-wrap: wrap;
}
.audit-header h2 { margin: 0 0 4px 0; color: var(--color-text); font-size: 26px; }
.audit-description { margin: 0; color: var(--color-text-muted); font-size: 13px; }
.audit-header h2 { margin: 0 0 4px 0; color: #333; font-size: 26px; }
.audit-description { margin: 0; color: #666; font-size: 13px; }
.audit-header-actions {
display: flex;
@ -32,24 +32,24 @@
}
.btn-stats-toggle {
padding: 6px 14px;
background: var(--color-surface-alt);
border: 1px solid var(--color-border);
background: #f0f4f8;
border: 1px solid #d0d7de;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
color: var(--color-text-muted);
color: #444;
}
.btn-stats-toggle:hover { background: var(--color-surface-alt); }
.btn-stats-toggle:hover { background: #e2e8f0; }
.stats-days-select { font-size: 13px; }
.audit-stats {
background: var(--color-surface-alt);
border: 1px solid var(--color-border);
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 16px;
margin-bottom: 16px;
}
.stats-loading { color: var(--color-text-muted); font-size: 13px; padding: 8px 0; }
.stats-loading { color: #888; font-size: 13px; padding: 8px 0; }
.stats-row {
display: flex;
@ -60,18 +60,18 @@
.stat-card {
flex: 1;
min-width: 100px;
background: var(--color-surface);
background: white;
border-radius: 8px;
padding: 12px 16px;
text-align: center;
border: 1px solid var(--color-border);
border: 1px solid #e2e8f0;
}
.stat-number { font-size: 28px; font-weight: 700; line-height: 1; }
.stat-label { font-size: 11px; color: var(--color-text-muted); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
.stat-total .stat-number { color: var(--color-info-text); }
.stat-failed .stat-number { color: var(--color-danger-text); }
.stat-success .stat-number { color: var(--color-success-text); }
.stat-period .stat-number { color: var(--color-text-muted); font-size: 20px; }
.stat-label { font-size: 11px; color: #666; margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
.stat-total .stat-number { color: #1976d2; }
.stat-failed .stat-number { color: #c62828; }
.stat-success .stat-number { color: #2e7d32; }
.stat-period .stat-number { color: #555; font-size: 20px; }
.stats-details {
display: flex;
@ -79,7 +79,7 @@
flex-wrap: wrap;
}
.stats-col { flex: 1; min-width: 180px; }
.stats-col h4 { margin: 0 0 8px 0; font-size: 12px; text-transform: uppercase; color: var(--color-text-muted); letter-spacing: 0.5px; }
.stats-col h4 { margin: 0 0 8px 0; font-size: 12px; text-transform: uppercase; color: #888; letter-spacing: 0.5px; }
.stat-bar-row {
display: flex;
@ -88,8 +88,8 @@
gap: 8px;
margin-bottom: 5px;
}
.stat-bar-label { font-size: 13px; color: var(--color-text-muted); }
.stat-bar-count { font-size: 13px; font-weight: 600; color: var(--color-text); flex-shrink: 0; }
.stat-bar-label { font-size: 13px; color: #444; }
.stat-bar-count { font-size: 13px; font-weight: 600; color: #333; flex-shrink: 0; }
/* Mini bar chart */
.stats-chart-col { flex: 2; min-width: 220px; }
@ -98,7 +98,7 @@
align-items: flex-end;
gap: 3px;
height: 80px;
background: var(--color-surface-alt);
background: #f0f4f8;
border-radius: 6px;
padding: 6px 6px 0;
}
@ -110,7 +110,7 @@
}
.chart-bar {
width: 100%;
background: var(--color-info);
background: #1976d2;
border-radius: 2px 2px 0 0;
position: relative;
min-height: 4px;
@ -121,7 +121,7 @@
bottom: 0;
left: 0;
width: 100%;
background: var(--color-danger);
background: #e53935;
}
/* ── Filters ─────────────────────────────────────────────────────── */
@ -134,52 +134,52 @@
}
.filter-select {
padding: 8px 12px;
border: 1.5px solid var(--color-border);
border: 1.5px solid #d0d7de;
border-radius: 6px;
font-size: 13px;
background: var(--color-surface);
background: white;
cursor: pointer;
}
.filter-select:focus { outline: none; border-color: var(--color-primary); }
.filter-select:focus { outline: none; border-color: #1976d2; }
.filter-input {
padding: 8px 12px;
border: 1.5px solid var(--color-border);
border: 1.5px solid #d0d7de;
border-radius: 6px;
font-size: 13px;
background: var(--color-surface);
background: white;
min-width: 140px;
}
.filter-input:focus { outline: none; border-color: var(--color-primary); }
.filter-input:focus { outline: none; border-color: #1976d2; }
.filter-date { min-width: 130px; }
.btn-refresh {
padding: 8px 14px;
background: var(--color-info);
color: var(--color-on-info);
background: #1976d2;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
}
.btn-refresh:hover:not(:disabled) { background: var(--color-info-dark); }
.btn-refresh:disabled { background: var(--color-surface-alt); color: var(--color-text-muted); cursor: not-allowed; }
.btn-refresh:hover:not(:disabled) { background: #1565c0; }
.btn-refresh:disabled { background: #bdbdbd; cursor: not-allowed; }
.btn-reset {
padding: 8px 12px;
background: var(--color-surface);
color: var(--color-text-muted);
border: 1.5px solid var(--color-border);
background: white;
color: #666;
border: 1.5px solid #d0d7de;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.btn-reset:hover { background: var(--color-surface-alt); }
.btn-reset:hover { background: #f5f5f5; }
.btn-export {
padding: 8px 16px;
background: var(--color-success);
color: var(--color-on-success);
background: #2e7d32;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
@ -187,15 +187,15 @@
font-weight: 500;
white-space: nowrap;
}
.btn-export:hover:not(:disabled) { background: var(--color-success-dark); }
.btn-export:disabled { background: var(--color-surface-alt); color: var(--color-text-muted); cursor: not-allowed; }
.btn-export:hover:not(:disabled) { background: #1b5e20; }
.btn-export:disabled { background: #bdbdbd; cursor: not-allowed; }
.auto-refresh-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--color-text-muted);
color: #555;
cursor: pointer;
white-space: nowrap;
}
@ -206,7 +206,7 @@
justify-content: space-between;
align-items: center;
font-size: 13px;
color: var(--color-text-muted);
color: #666;
margin-bottom: 10px;
}
.limit-select { padding: 4px 8px; font-size: 13px; }
@ -224,16 +224,16 @@
}
.badge-create { background: #e8f5e9; color: #2e7d32; }
.badge-update { background: #e3f2fd; color: #1565c0; }
.badge-delete { background: #ffebee; color: var(--color-danger-text); }
.badge-restore { background: #fff3e0; color: #a83a00; }
.badge-delete { background: #ffebee; color: #c62828; }
.badge-restore { background: #fff3e0; color: #e65100; }
.badge-login { background: #f3e5f5; color: #6a1b9a; }
.badge-logout { background: #fce4ec; color: #880e4f; }
.badge-login-failed { background: #ffcdd2; color: #b71c1c; font-weight: 700; }
.badge-import { background: #e0f7fa; color: #00695c; }
.badge-export { background: #e8f5e9; color: #1b5e20; }
.badge-bulk-update { background: var(--color-surface-alt); color: #283593; }
.badge-bulk-update { background: #e8eaf6; color: #283593; }
.badge-bulk-delete { background: #fbe9e7; color: #bf360c; }
.badge-password { background: #fff8e1; color: #9c4e00; }
.badge-password { background: #fff8e1; color: #f57f17; }
.badge-default { background: #f5f5f5; color: #616161; }
.status-code {
@ -244,16 +244,16 @@
font-family: monospace;
}
.status-ok { background: #e8f5e9; color: #2e7d32; }
.status-redirect { background: #fff3e0; color: #a83a00; }
.status-error { background: #ffebee; color: var(--color-danger-text); }
.status-redirect { background: #fff3e0; color: #e65100; }
.status-error { background: #ffebee; color: #c62828; }
.status-server-error { background: #f3e5f5; color: #6a1b9a; }
.duration-badge {
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
background: var(--color-surface-alt);
color: var(--color-text-muted);
background: #f5f5f5;
color: #777;
font-family: monospace;
}
@ -261,24 +261,24 @@
.audit-empty {
text-align: center;
padding: 40px;
background: var(--color-surface-alt);
background: #f9f9f9;
border-radius: 8px;
color: var(--color-text-muted);
color: #888;
}
.audit-error { text-align: center; padding: 20px; }
.audit-error p { color: var(--color-danger-text); margin: 0; }
.audit-error p { color: #d32f2f; margin: 0; }
.audit-list { display: flex; flex-direction: column; gap: 8px; }
.audit-item {
background: var(--color-surface);
border: 1px solid var(--color-border);
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
transition: box-shadow 0.15s;
}
.audit-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.audit-item-failed { border-left: 4px solid var(--color-danger); }
.audit-item-failed { border-left: 4px solid #e53935; }
.audit-item-header {
display: flex;
@ -286,18 +286,18 @@
gap: 8px;
padding: 10px 14px;
flex-wrap: wrap;
background: var(--color-surface-alt);
border-bottom: 1px solid var(--color-border);
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
}
.audit-time { margin-left: auto; color: var(--color-text-muted); font-size: 12px; white-space: nowrap; }
.expand-toggle { color: var(--color-text-muted); font-size: 11px; cursor: pointer; padding: 0 4px; }
.audit-time { margin-left: auto; color: #999; font-size: 12px; white-space: nowrap; }
.expand-toggle { color: #aaa; font-size: 11px; cursor: pointer; padding: 0 4px; }
.audit-resource {
padding: 3px 8px;
background: var(--color-surface-alt);
background: #f0f0f0;
border-radius: 4px;
font-size: 12px;
color: var(--color-text-muted);
color: #555;
}
.audit-item-body {
@ -308,16 +308,16 @@
font-size: 13px;
align-items: center;
}
.audit-admin { color: var(--color-text); }
.audit-resource-name { color: var(--color-text-muted); }
.audit-ip { color: var(--color-text-muted); font-family: monospace; font-size: 12px; }
.audit-failed-badge { color: var(--color-danger-text); font-weight: 600; font-size: 12px; }
.audit-admin { color: #333; }
.audit-resource-name { color: #555; }
.audit-ip { color: #999; font-family: monospace; font-size: 12px; }
.audit-failed-badge { color: #c62828; font-weight: 600; font-size: 12px; }
/* ── Expanded detail ─────────────────────────────────────────────── */
.audit-item-detail {
padding: 10px 14px 14px;
border-top: 1px dashed var(--color-border);
background: var(--color-surface);
border-top: 1px dashed #e0e0e0;
background: #fefefe;
display: flex;
flex-direction: column;
gap: 8px;
@ -330,7 +330,7 @@
}
.detail-label {
font-weight: 600;
color: var(--color-text-muted);
color: #888;
font-size: 11px;
text-transform: uppercase;
min-width: 70px;
@ -338,23 +338,23 @@
}
.detail-row code {
font-family: monospace;
background: var(--color-surface-alt);
background: #f0f0f0;
padding: 2px 6px;
border-radius: 3px;
font-size: 12px;
}
.detail-ua-full {
color: var(--color-text-muted);
color: #aaa;
cursor: help;
font-size: 12px;
}
.detail-error { color: var(--color-danger-text); }
.detail-error .detail-label { color: var(--color-danger-text); }
.detail-error { color: #c62828; }
.detail-error .detail-label { color: #c62828; }
.meta-tag {
display: inline-block;
background: var(--color-surface-alt);
color: var(--color-info-text);
background: #e8eaf6;
color: #283593;
border-radius: 4px;
padding: 2px 8px;
font-size: 12px;
@ -370,17 +370,17 @@
margin-top: 6px;
}
.diff-table th {
background: var(--color-surface-alt);
background: #f0f0f0;
padding: 5px 10px;
text-align: left;
font-size: 11px;
text-transform: uppercase;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
color: #666;
border: 1px solid #e0e0e0;
}
.diff-table td { padding: 5px 10px; border: 1px solid var(--color-border); vertical-align: top; }
.diff-field { font-weight: 600; color: var(--color-text-muted); font-family: monospace; white-space: nowrap; background: var(--color-surface-alt); }
.diff-before { color: var(--color-danger-text); background: #fff5f5; font-family: monospace; word-break: break-all; }
.diff-table td { padding: 5px 10px; border: 1px solid #e8e8e8; vertical-align: top; }
.diff-field { font-weight: 600; color: #444; font-family: monospace; white-space: nowrap; background: #fafafa; }
.diff-before { color: #c62828; background: #fff5f5; font-family: monospace; word-break: break-all; }
.diff-after { color: #2e7d32; background: #f5fff5; font-family: monospace; word-break: break-all; }
/* ── Pagination ──────────────────────────────────────────────────── */
@ -393,16 +393,16 @@
}
.btn-page {
padding: 7px 14px;
border: 1.5px solid var(--color-border);
border: 1.5px solid #d0d7de;
border-radius: 6px;
background: var(--color-surface);
background: white;
cursor: pointer;
font-size: 13px;
color: var(--color-text);
color: #333;
}
.btn-page:hover:not(:disabled) { background: var(--color-surface-alt); border-color: var(--color-primary); }
.btn-page:disabled { color: var(--color-text-muted); cursor: not-allowed; border-color: var(--color-border); }
.page-info { font-size: 13px; color: var(--color-text-muted); padding: 0 8px; }
.btn-page:hover:not(:disabled) { background: #f0f4f8; border-color: #1976d2; }
.btn-page:disabled { color: #bbb; cursor: not-allowed; border-color: #eee; }
.page-info { font-size: 13px; color: #666; padding: 0 8px; }
.audit-header {
@ -411,13 +411,13 @@
.audit-header h2 {
margin: 0 0 8px 0;
color: var(--color-text);
color: #333;
font-size: 28px;
}
.audit-description {
margin: 0;
color: var(--color-text-muted);
color: #666;
font-size: 14px;
}
@ -428,7 +428,7 @@
}
.audit-error p {
color: var(--color-danger-text);
color: #d32f2f;
margin: 0;
}
@ -441,23 +441,23 @@
.filter-select {
padding: 10px 14px;
border: 2px solid var(--color-border);
border: 2px solid #e0e0e0;
border-radius: 6px;
font-size: 14px;
background: var(--color-surface);
background: white;
cursor: pointer;
transition: border-color 0.2s;
}
.filter-select:focus {
outline: none;
border-color: var(--color-primary);
border-color: #1976d2;
}
.btn-refresh {
padding: 10px 16px;
background: var(--color-info);
color: var(--color-on-info);
background: #1976d2;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
@ -467,21 +467,20 @@
}
.btn-refresh:hover:not(:disabled) {
background: var(--color-info-dark);
background: #1565c0;
}
.btn-refresh:disabled {
background: var(--color-surface-alt);
color: var(--color-text-muted);
background: #bdbdbd;
cursor: not-allowed;
}
.audit-empty {
text-align: center;
padding: 40px;
background: var(--color-surface-alt);
background: #f5f5f5;
border-radius: 8px;
color: var(--color-text-muted);
color: #666;
}
.audit-list {
@ -491,8 +490,8 @@
}
.audit-item {
background: var(--color-surface);
border: 1px solid var(--color-border);
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 16px;
transition: box-shadow 0.2s;
@ -520,18 +519,58 @@
gap: 4px;
}
.badge-create {
background: #e8f5e9;
color: #2e7d32;
}
.badge-update {
background: #e3f2fd;
color: #1565c0;
}
.badge-delete {
background: #ffebee;
color: #c62828;
}
.badge-restore {
background: #fff3e0;
color: #e65100;
}
.badge-login {
background: #f3e5f5;
color: #6a1b9a;
}
.badge-logout {
background: #fce4ec;
color: #880e4f;
}
.badge-login-failed {
background: #ffcdd2;
color: #b71c1c;
font-weight: 700;
}
.badge-default {
background: #f5f5f5;
color: #616161;
}
.audit-resource {
padding: 4px 10px;
background: var(--color-surface-alt);
background: #f5f5f5;
border-radius: 4px;
font-size: 13px;
color: var(--color-text-muted);
color: #666;
}
.audit-time {
margin-left: auto;
color: var(--color-text-muted);
color: #999;
font-size: 13px;
}
@ -543,19 +582,23 @@
}
.audit-admin {
color: var(--color-text);
color: #333;
}
.audit-resource-name {
color: var(--color-text-muted);
color: #666;
}
.audit-ip {
color: var(--color-text-muted);
color: #888;
font-family: monospace;
font-size: 13px;
}
.audit-error-message {
color: #d32f2f;
font-weight: 500;
}
.audit-pagination {
display: flex;
@ -564,15 +607,15 @@
gap: 16px;
margin-top: 24px;
padding: 16px;
background: var(--color-surface);
background: white;
border-radius: 8px;
border: 1px solid var(--color-border);
border: 1px solid #e0e0e0;
}
.btn-page {
padding: 8px 16px;
background: var(--color-info);
color: var(--color-on-info);
background: #1976d2;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
@ -581,17 +624,16 @@
}
.btn-page:hover:not(:disabled) {
background: var(--color-info-dark);
background: #1565c0;
}
.btn-page:disabled {
background: var(--color-surface-alt);
color: var(--color-text-muted);
background: #bdbdbd;
cursor: not-allowed;
}
.page-info {
color: var(--color-text-muted);
color: #666;
font-size: 14px;
}
@ -614,33 +656,3 @@
width: 100%;
}
}
/* Nachtvariante
Die Badges kodieren die Aktionsart über den Farbton der bleibt erhalten,
nur Helligkeit und Sättigung drehen sich um. Alle Paare >= 6.4:1. */
@media (prefers-color-scheme: dark) {
.badge-create { background: #162d17; color: #97d99a; }
.badge-update { background: #16202d; color: #89b5e6; }
.badge-delete { background: #2d1616; color: #e68989; }
.badge-restore { background: #2d1e16; color: #e6aa89; }
.badge-login { background: #24162d; color: #c389e6; }
.badge-logout { background: #2d1622; color: #e689bb; }
.badge-login-failed { background: #2d1616; color: #e68989; }
.badge-import { background: #162d2a; color: #89e6db; }
.badge-export { background: #162d17; color: #90df96; }
.badge-bulk-update { background: #16182d; color: #8f99e0; }
.badge-bulk-delete { background: #2d1b16; color: #e69f89; }
.badge-password { background: #2d2016; color: #e6b589; }
.badge-default { background: var(--color-surface-alt); color: var(--color-text-muted); }
.status-ok { background: #162d17; color: #97d99a; }
.status-redirect { background: #2d1e16; color: #e6aa89; }
.status-error { background: #2d1616; color: #e68989; }
.status-server-error { background: #24162d; color: #c389e6; }
/* Diff-Tabelle: die roten/gruenen Vorher-Nachher-Felder */
.diff-table th { background: var(--color-surface-alt); }
.diff-before { color: #e68989; background: #2d1616; }
.diff-after { color: #97d99a; background: #162d17; }
.diff-field { background: var(--color-surface-alt); color: var(--color-text); }
}

View File

@ -14,7 +14,7 @@
.export-button {
padding: 0.5rem 1rem;
background: var(--color-success);
color: var(--color-on-success);
color: white;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
@ -41,8 +41,8 @@
.import-button {
padding: 0.5rem 1rem;
background: var(--color-primary, var(--color-info));
color: var(--color-on-primary);
background: var(--color-primary, #2563eb);
color: white;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
@ -52,7 +52,7 @@
}
.import-button:hover:not(:disabled) {
background: var(--color-primary-dark, var(--color-info-dark));
background: var(--color-primary-dark, #1d4ed8);
}
.import-button:disabled {
@ -65,7 +65,7 @@
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
color: var(--color-text-muted);
color: var(--color-text-muted, #6b7280);
cursor: pointer;
white-space: nowrap;
}

View File

@ -10,13 +10,13 @@
.trash-header h2 {
margin: 0 0 8px 0;
color: var(--color-text);
color: #333;
font-size: 28px;
}
.trash-description {
margin: 0;
color: var(--color-text-muted);
color: #666;
font-size: 14px;
}
@ -27,14 +27,14 @@
}
.trash-error h3 {
color: var(--color-danger-text);
color: #d32f2f;
margin-bottom: 12px;
}
.btn-retry {
padding: 10px 20px;
background: var(--color-info);
color: var(--color-on-info);
background: #1976d2;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
@ -43,13 +43,13 @@
}
.btn-retry:hover {
background: var(--color-info-dark);
background: #1565c0;
}
.trash-empty {
text-align: center;
padding: 60px 20px;
background: var(--color-surface-alt);
background: #f5f5f5;
border-radius: 12px;
}
@ -60,7 +60,7 @@
}
.trash-empty p {
color: var(--color-text-muted);
color: #666;
font-size: 16px;
margin: 0;
}
@ -72,8 +72,8 @@
}
.trash-item {
background: var(--color-surface);
border: 2px solid var(--color-border);
background: #fff;
border: 2px solid #e0e0e0;
border-radius: 8px;
padding: 16px 20px;
display: flex;
@ -84,7 +84,7 @@
.trash-item:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border-color: var(--color-border);
border-color: #bdbdbd;
}
.trash-item-info {
@ -93,7 +93,7 @@
.trash-item-info h3 {
margin: 0 0 8px 0;
color: var(--color-text);
color: #333;
font-size: 18px;
font-weight: 600;
}
@ -106,8 +106,8 @@
}
.trash-item-type {
background: var(--color-info-bg);
color: var(--color-info-text);
background: #e3f2fd;
color: #1976d2;
padding: 4px 10px;
border-radius: 4px;
font-size: 13px;
@ -116,12 +116,12 @@
.trash-item-address,
.trash-item-phone {
color: var(--color-text-muted);
color: #666;
font-size: 14px;
}
.trash-item-meta {
color: var(--color-text-muted);
color: #999;
font-size: 13px;
}
@ -140,8 +140,8 @@
align-items: center;
gap: 6px;
padding: 10px 16px;
background: var(--color-success);
color: var(--color-on-success);
background: #4caf50;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
@ -151,7 +151,7 @@
}
.btn-restore:hover:not(:disabled) {
background: var(--color-success-dark);
background: #45a049;
transform: translateY(-1px);
}
@ -160,7 +160,7 @@
}
.btn-restore:disabled {
background: var(--color-border-strong);
background: #bdbdbd;
cursor: not-allowed;
}

View File

@ -56,7 +56,7 @@
width: 100%;
padding: 0.75rem;
background: var(--color-primary);
color: var(--color-on-primary);
color: white;
border: none;
border-radius: var(--radius-sm);
font-size: 1rem;
@ -76,8 +76,8 @@
.login-error {
padding: 0.75rem;
background: var(--color-danger-bg);
border: 1px solid var(--color-danger-border);
background: #ffebee;
border: 1px solid #ffcdd2;
border-radius: var(--radius-sm);
color: var(--color-danger);
margin-bottom: 1rem;

View File

@ -1,6 +1,5 @@
.password-reset-container {
/* dvh: mit ein- und ausblendender Adressleiste entsteht mit vh Überlauf. */
min-height: 100dvh;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
@ -9,7 +8,7 @@
}
.password-reset-card {
background: var(--color-surface);
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
padding: 40px;
@ -19,7 +18,7 @@
.password-reset-card h2 {
margin: 0 0 24px 0;
color: var(--color-text);
color: #333;
font-size: 28px;
text-align: center;
}
@ -33,15 +32,15 @@
}
.message.success {
background-color: var(--color-success-bg);
color: var(--color-success-text);
border: 1px solid var(--color-success-border);
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message.error {
background-color: var(--color-danger-bg);
color: var(--color-danger-text);
border: 1px solid var(--color-danger-border);
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.form-group {
@ -51,7 +50,7 @@
.form-group label {
display: block;
margin-bottom: 8px;
color: var(--color-text-muted);
color: #555;
font-weight: 500;
font-size: 14px;
}
@ -59,7 +58,7 @@
.form-group input {
width: 100%;
padding: 12px 16px;
border: 2px solid var(--color-border);
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 15px;
transition: border-color 0.3s;
@ -72,14 +71,14 @@
}
.form-group input:disabled {
background-color: var(--color-surface-alt);
background-color: #f5f5f5;
cursor: not-allowed;
}
.form-group small {
display: block;
margin-top: 6px;
color: var(--color-text-muted);
color: #888;
font-size: 13px;
}
@ -87,7 +86,7 @@
width: 100%;
padding: 14px;
background: var(--color-primary);
color: var(--color-on-primary);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;

View File

@ -1,29 +1,11 @@
import React, { useState } from 'react';
import { requestPasswordReset, resetPassword } from '../../services/passwordReset';
import { BASE_PATH } from '../../utils/constants';
import './PasswordReset.css';
// Ziel nach dem Zuruecksetzen bzw. fuer "Zurueck zum Login": die App selbst.
// Ein absolutes "/" landet beim Unterpfad-Deployment auf dem Portal.
const APP_HOME = `${BASE_PATH}/`;
// Token aus dem Link der Reset-Mail lesen (…/passwort-zuruecksetzen?token=…).
// Ohne das landete jeder, der auf den gemailten Link klickt, wieder auf Schritt 1
// mit leerem Token-Feld der Mailversand war damit wirkungslos.
const tokenFromUrl = () => {
if (typeof window === 'undefined') return '';
try {
return new URLSearchParams(window.location.search).get('token') || '';
} catch {
return '';
}
};
function PasswordReset() {
const initialToken = tokenFromUrl();
const [step, setStep] = useState(initialToken ? 'reset' : 'request');
const [step, setStep] = useState('request'); // 'request' or 'reset'
const [username, setUsername] = useState('');
const [token, setToken] = useState(initialToken);
const [token, setToken] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
@ -80,7 +62,7 @@ function PasswordReset() {
setToken('');
setNewPassword('');
setConfirmPassword('');
window.location.href = APP_HOME; // Redirect to login
window.location.href = '/'; // Redirect to login
}, 2000);
} else {
setMessage({ type: 'error', text: result.message });
@ -118,7 +100,7 @@ function PasswordReset() {
</button>
<div className="form-footer">
<a href={APP_HOME}>Zurück zum Login</a>
<a href="/">Zurück zum Login</a>
</div>
</form>
) : (

View File

@ -4,10 +4,10 @@
gap: 0.5rem;
padding: 1rem;
margin: 1rem 0;
background-color: var(--color-danger-bg);
border: 1px solid var(--color-danger-border);
background-color: #fee;
border: 1px solid #fcc;
border-radius: 4px;
color: var(--color-danger-text);
color: #c33;
}
.error-icon {
@ -22,7 +22,7 @@
background: none;
border: none;
font-size: 1.5rem;
color: var(--color-danger-text);
color: #c33;
cursor: pointer;
padding: 0;
width: 24px;
@ -34,5 +34,5 @@
}
.error-close:hover {
color: var(--color-danger-text);
color: #a22;
}

View File

@ -1,6 +1,6 @@
.app-header {
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
background: #ffffff;
border-bottom: 1px solid #e5e5e5;
padding: 0.75rem 2rem;
display: flex;
align-items: center;
@ -27,7 +27,7 @@
.portal-back-link:hover {
background: var(--color-primary);
color: var(--color-on-primary);
color: #fff;
}
.app-brand {
@ -40,18 +40,18 @@
height: 52px;
width: auto;
object-fit: contain;
flex-shrink: 0;
}
.app-logo-ljn {
height: 44px;
}
.app-title {
margin: 0;
font-family: var(--font-display);
font-size: 1.05rem;
font-weight: 700;
color: var(--color-text);
/* Kein nowrap: lange App-Namen haben die Kopfzeile sonst über die
Bildschirmbreite hinaus geschoben. */
overflow-wrap: anywhere;
white-space: nowrap;
}
.app-nav {
@ -61,14 +61,11 @@
}
.nav-button {
display: inline-flex;
align-items: center;
min-height: var(--touch-target);
padding: 0.5rem 0.875rem;
background: none;
border: none;
border-radius: var(--radius-sm);
color: var(--color-text);
color: #333;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
@ -100,19 +97,12 @@
.app-header {
flex-direction: column;
align-items: flex-start;
padding: 0.5rem 1rem;
gap: 0.4rem;
/* Auf schmalen Geräten nimmt die umgebrochene Kopfzeile viel Höhe ein.
Klebrig bleibt sie nur dort, wo genug Platz ist. */
position: static;
padding: 0.75rem 1rem;
gap: 0.5rem;
}
.app-title {
font-size: 0.95rem;
}
.app-logo {
height: 36px;
font-size: 0.9rem;
}
.app-nav {
@ -121,31 +111,7 @@
}
.nav-button {
font-size: 0.9rem;
font-size: 0.85rem;
padding: 0.4rem 0.7rem;
}
}
@media (max-width: 480px) {
.app-header {
padding: 0.5rem 0.75rem;
}
.app-brand {
gap: 0.5rem;
}
.app-logo {
height: 30px;
}
.app-nav {
width: 100%;
}
.nav-button {
flex: 1 1 45%;
justify-content: center;
padding: 0.4rem 0.5rem;
}
}

View File

@ -2,8 +2,8 @@
position: sticky;
top: 0;
z-index: 1000;
background-color: var(--color-primary);
color: var(--color-on-primary);
background-color: #2d6a2d;
color: #fff;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
@ -22,8 +22,8 @@
}
.install-banner__btn {
background: var(--color-surface);
color: var(--color-primary);
background: #fff;
color: #2d6a2d;
border: none;
border-radius: 4px;
padding: 0.35rem 0.85rem;
@ -34,7 +34,7 @@
}
.install-banner__btn:hover {
background: var(--color-success-bg);
background: #e8f5e9;
}
.install-banner__close {
@ -49,7 +49,7 @@
}
.install-banner__close:hover {
color: var(--color-on-primary);
color: #fff;
}
.install-banner__share-icon {

View File

@ -8,8 +8,8 @@
}
.spinner {
border: 4px solid var(--color-border);
border-top: 4px solid var(--color-primary);
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
width: 50px;
height: 50px;
@ -23,6 +23,6 @@
.loading-message {
margin-top: 1rem;
color: var(--color-text-muted);
color: #666;
font-size: 0.9rem;
}

View File

@ -5,7 +5,7 @@
}
.drohnenfuehrer-dashboard-card {
background: var(--color-surface);
background: white;
border-radius: 10px;
padding: 2rem;
width: 100%;
@ -46,7 +46,7 @@
.btn-drohnenfuehrer-logout:hover {
background: var(--color-danger);
color: var(--color-on-danger);
color: white;
}
.drohnenfuehrer-message {
@ -56,8 +56,8 @@
font-size: 0.9rem;
}
.drohnenfuehrer-message-success { background: var(--color-success-bg); color: var(--color-success-text); }
.drohnenfuehrer-message-error { background: var(--color-danger-bg); color: var(--color-danger-text); }
.drohnenfuehrer-message-success { background: #dcfce7; color: #166534; }
.drohnenfuehrer-message-error { background: #fee2e2; color: #b91c1c; }
.drohnenfuehrer-availability-section {
background: var(--color-muted-bg);
@ -92,15 +92,15 @@
.availability-big-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-green {
background: var(--color-success-bg);
color: var(--color-success-text);
border-color: var(--color-success-border);
background: #dcfce7;
color: #15803d;
border-color: #86efac;
}
.btn-red {
background: var(--color-danger-bg);
color: var(--color-danger-text);
border-color: var(--color-danger-border);
background: #fee2e2;
color: #b91c1c;
border-color: #fca5a5;
}
.status-dot-lg {
@ -110,8 +110,8 @@
flex-shrink: 0;
}
.dot-green { background: var(--color-success); }
.dot-red { background: var(--color-danger); }
.dot-green { background: #22c55e; }
.dot-red { background: #ef4444; }
.availability-hint {
font-size: 0.82rem;
@ -147,7 +147,7 @@
font-size: 0.9rem;
}
.btn-drohnenfuehrer-edit:hover { background: var(--color-primary); color: var(--color-on-primary); }
.btn-drohnenfuehrer-edit:hover { background: var(--color-primary); color: white; }
.drohnenfuehrer-info-list { display: flex; flex-direction: column; gap: 0.6rem; }
@ -206,7 +206,7 @@
flex: 1;
padding: 0.65rem;
background: var(--color-primary);
color: var(--color-on-primary);
color: white;
border: none;
border-radius: 6px;
font-size: 0.95rem;

View File

@ -6,7 +6,7 @@
}
.drohnenfuehrer-login-card {
background: var(--color-surface);
background: white;
border-radius: 10px;
padding: 2rem;
width: 100%;
@ -48,7 +48,7 @@
.drohnenfuehrer-mode-toggle button.active {
background: var(--color-primary);
color: var(--color-on-primary);
color: white;
font-weight: 600;
}
@ -82,7 +82,7 @@
width: 100%;
padding: 0.75rem;
background: var(--color-primary);
color: var(--color-on-primary);
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
@ -102,8 +102,8 @@
}
.drohnenfuehrer-error {
background: var(--color-danger-bg);
color: var(--color-danger-text);
background: #fee2e2;
color: #b91c1c;
border-radius: 4px;
padding: 0.6rem 0.9rem;
margin-bottom: 1rem;
@ -111,8 +111,8 @@
}
.drohnenfuehrer-success {
background: var(--color-success-bg);
color: var(--color-success-text);
background: #dcfce7;
color: #166534;
border-radius: 4px;
padding: 0.6rem 0.9rem;
margin-bottom: 1rem;

View File

@ -13,32 +13,32 @@
left: 12px;
z-index: 500;
background: rgba(255, 255, 255, 0.95);
color: var(--color-text-muted);
color: #444;
padding: 0.45rem 0.7rem;
border-radius: 6px;
font-size: 0.85rem;
border: 1px solid var(--color-border);
border: 1px solid #dcdcdc;
}
.map-no-data {
padding: 3rem;
text-align: center;
color: var(--color-text-muted);
background: var(--color-surface);
color: #666;
background: white;
border-radius: 8px;
margin: 1rem 0;
}
.map-popup h3 {
margin: 0 0 0.5rem 0;
color: var(--color-text);
color: #333;
font-size: 1rem;
}
.map-popup p {
margin: 0.25rem 0;
font-size: 0.85rem;
color: var(--color-text-muted);
color: #666;
}
.map-popup a {
@ -54,40 +54,8 @@
display: inline-block;
padding: 0.2rem 0.5rem;
background: var(--color-primary);
color: var(--color-on-primary);
color: white;
border-radius: 4px;
font-size: 0.75rem;
margin-top: 0.5rem;
}
/* Nachtansicht
Die OSM-Kacheln sind immer taghell. In der Dämmerung ist die Karte damit
die mit Abstand hellste Fläche der App und blendet genau die Situation,
in der Nachsuchen stattfinden. Die Kacheln werden deshalb abgedunkelt;
Marker und Bedienelemente bleiben unangetastet, damit sie lesbar sind. */
@media (prefers-color-scheme: dark) {
.leaflet-tile-pane {
filter: brightness(0.62) saturate(0.75) contrast(1.05);
}
.leaflet-container {
background: var(--color-surface-alt);
}
/* Hoehere Spezifitaet als Leaflets eigene Regeln, die spaeter geladen werden */
.leaflet-container .leaflet-control-zoom a,
.leaflet-container .leaflet-control-attribution {
background: var(--color-surface);
color: var(--color-text);
}
.leaflet-container .leaflet-control-attribution a {
color: var(--color-primary);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: var(--color-surface);
color: var(--color-text);
}
}

View File

@ -14,7 +14,7 @@
}
.public-user-list h2 {
color: var(--color-text);
color: #1a1a1a;
margin: 0;
font-size: 1.5rem;
font-weight: 700;
@ -23,7 +23,7 @@
.toggle-map-button {
padding: 0.5rem 1rem;
background: var(--color-primary);
color: var(--color-on-primary);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
@ -44,8 +44,8 @@
.filter-toggle-button {
padding: 0.5rem 1rem;
background: var(--color-secondary);
color: var(--color-on-secondary);
background: #6c757d;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
@ -55,12 +55,12 @@
}
.filter-toggle-button:hover {
background: var(--color-secondary-dark);
background: #5a6268;
}
.location-panel {
background: var(--color-surface);
border: 1px solid var(--color-border);
background: white;
border: 1px solid #e5e5e5;
border-radius: 8px;
padding: 1rem 1.5rem;
margin-bottom: 1.5rem;
@ -78,7 +78,7 @@
.location-button {
padding: 0.5rem 1rem;
background: var(--color-primary);
color: var(--color-on-primary);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
@ -92,7 +92,7 @@
}
.location-button:disabled {
background: var(--color-border-strong);
background: #b6b6b6;
cursor: not-allowed;
}
@ -103,13 +103,13 @@
}
.location-status.success {
background: var(--color-success-bg);
color: var(--color-success-text);
background: #e8f5e9;
color: var(--color-primary-dark);
}
.location-status.error {
background: var(--color-danger-bg);
color: var(--color-danger-text);
background: #ffebee;
color: #c62828;
}
.postal-search {
@ -120,21 +120,21 @@
.postal-search input[type="text"] {
padding: 0.5rem;
border: 1px solid var(--color-border);
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
width: 150px;
}
.postal-search input[type="text"]:disabled {
background: var(--color-surface-alt);
background: #f5f5f5;
cursor: not-allowed;
}
.postal-search button {
padding: 0.5rem 1rem;
background: var(--color-primary);
color: var(--color-on-primary);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
@ -148,14 +148,14 @@
}
.postal-search button:disabled {
background: var(--color-border-strong);
background: #b6b6b6;
cursor: not-allowed;
}
.radius-filter label {
display: block;
font-size: 0.9rem;
color: var(--color-text-muted);
color: #555;
margin-bottom: 0.5rem;
font-weight: 500;
}
@ -174,26 +174,26 @@
.radius-inputs input[type="number"] {
width: 80px;
padding: 0.4rem 0.5rem;
border: 1px solid var(--color-border);
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
}
.radius-inputs input:disabled {
background: var(--color-surface-alt);
background: #f5f5f5;
cursor: not-allowed;
}
.radius-hint {
font-size: 0.85rem;
color: var(--color-text-muted);
color: #777;
margin-top: 0.5rem;
}
.gps-hint {
margin-top: 0.5rem;
font-size: 0.85rem;
color: var(--color-text-muted);
color: #777;
}
.user-grid {
@ -205,8 +205,8 @@
/* Jägerschaft-style card */
.user-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
background: white;
border: 1px solid #e5e5e5;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
@ -232,9 +232,9 @@
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1);
}
.public-user-list .user-name {
.user-name {
margin: 0 0 0.2rem;
color: var(--color-text);
color: #1a1a1a;
font-size: 1rem;
font-weight: 700;
text-transform: uppercase;
@ -250,15 +250,15 @@
color: var(--color-primary);
}
.public-user-list .user-info {
.user-info {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.public-user-list .user-address {
.user-address {
margin: 0;
color: var(--color-text-muted);
color: #666;
font-size: 0.9rem;
}
@ -266,80 +266,72 @@
margin: 0.5rem 0 0;
}
.public-user-list .type-badge {
.type-badge {
display: inline-block;
padding: 0.2rem 0.6rem;
background: var(--color-surface-alt);
color: var(--color-text-muted);
background: #f0f0f0;
color: #444;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.03em;
}
/* Der Anruf beim Drohnenführer ist der Zweck dieser Liste und wird fast
immer mit dem Daumen auf dem Handy ausgelöst. Vorher war das ein 14 px hoher
Textlink; jetzt ein vollwertiges Ziel mit der empfohlenen Mindesthöhe. */
.user-phone {
display: flex;
align-items: center;
gap: 0.5rem;
min-height: var(--touch-target);
margin: 0.15rem 0;
padding: 0.35rem 0.6rem;
background: var(--color-surface-alt);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-primary);
text-decoration: none;
font-weight: 600;
font-size: 1rem;
font-weight: 500;
font-size: 0.9rem;
}
.public-user-list .user-phone:hover,
.public-user-list .user-phone:focus-visible {
background: var(--color-surface);
border-color: var(--color-border-strong);
.user-phone:hover {
text-decoration: underline;
}
.user-gps {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #e5e5e5;
font-size: 0.85rem;
color: #888;
}
.user-distance {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--color-border);
border-top: 1px solid #e5e5e5;
font-size: 0.85rem;
color: var(--color-text-muted);
color: #555;
font-weight: 500;
}
.no-users {
text-align: center;
padding: 3rem;
color: var(--color-text-muted);
color: #666;
}
.loading {
text-align: center;
padding: 3rem;
color: var(--color-text-muted);
color: #666;
}
@media (max-width: 768px) {
.user-grid {
.user-grid {
grid-template-columns: 1fr;
}
.public-user-list {
.public-user-list {
padding: 1rem;
}
.radius-inputs {
.radius-inputs {
flex-direction: column;
align-items: stretch;
}
.radius-inputs input[type="number"] {
.radius-inputs input[type="number"] {
width: 100%;
}
}

View File

@ -1,4 +1,4 @@
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import React, { useState, useMemo, useEffect, useCallback } from 'react';
import { useConfigContext } from '../../contexts/ConfigContext';
import MapView from '../map/MapView';
import FilterPanel from '../users/FilterPanel';
@ -6,32 +6,18 @@ import { calculateDistance } from '../../utils/helpers';
import { getGeocodeByPostalCode } from '../../services/users';
import './PublicUserList.css';
const LOCATION_KEY = 'userLocation';
// localStorage kann werfen (privater Modus, blockierte Site-Data) und der Inhalt
// kann beschaedigt sein. Beides darf die Seite nicht mitreissen.
const readStoredLocation = () => {
try {
const raw = localStorage.getItem(LOCATION_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
return (typeof parsed?.lat === 'number' && typeof parsed?.lng === 'number') ? parsed : null;
} catch {
return null;
}
};
const PublicUserList = ({ users, loading, onRefetch }) => {
const { userTypeLabels } = useConfigContext();
// Load saved location from localStorage on mount
const savedLocation = localStorage.getItem('userLocation');
const initialCoords = savedLocation ? JSON.parse(savedLocation) : null;
const [showMap, setShowMap] = useState(true);
const [showFilters, setShowFilters] = useState(false);
// Gespeicherten Standort lazy und abgesichert lesen. Vorher stand ein
// ungeschuetztes JSON.parse im Render-Pfad: ein beschaedigter Eintrag - oder ein
// Browser, der Site-Data blockiert - hat die gesamte Liste weiss werden lassen.
const [coords, setCoords] = useState(readStoredLocation);
const [locationStatus, setLocationStatus] = useState(coords ? 'granted' : 'idle');
const [locationStatus, setLocationStatus] = useState(initialCoords ? 'granted' : 'idle');
const [locationError, setLocationError] = useState('');
const [coords, setCoords] = useState(initialCoords);
const [radiusKm, setRadiusKm] = useState(100);
const [postalCode, setPostalCode] = useState('');
const [postalSearching, setPostalSearching] = useState(false);
@ -42,11 +28,8 @@ const PublicUserList = ({ users, loading, onRefetch }) => {
// Save location to localStorage whenever it changes
useEffect(() => {
if (!coords) return;
try {
localStorage.setItem(LOCATION_KEY, JSON.stringify(coords));
} catch {
// Speicher nicht verfuegbar - der Standort gilt dann nur fuer diese Sitzung.
if (coords) {
localStorage.setItem('userLocation', JSON.stringify(coords));
}
}, [coords]);
@ -54,15 +37,8 @@ const PublicUserList = ({ users, loading, onRefetch }) => {
setFilters(prev => ({ ...prev, [key]: value }));
}, []);
// Re-fetch from backend when type filter changes (server-side filtering).
// Der erste Lauf wird uebersprungen: useUsers laedt beim Mounten bereits selbst,
// sonst setzt jeder Seitenaufruf zwei identische Anfragen ab.
const skipInitialRefetch = useRef(true);
// Re-fetch from backend when type filter changes (server-side filtering)
useEffect(() => {
if (skipInitialRefetch.current) {
skipInitialRefetch.current = false;
return;
}
if (onRefetch) {
onRefetch(filters.type ? { type: filters.type } : {});
}
@ -291,7 +267,7 @@ const PublicUserList = ({ users, loading, onRefetch }) => {
<div className="user-info">
<p className="user-address">📍 {user.address}</p>
<a href={`tel:${user.phone}`} className="user-phone">
📱 {user.phone}
<EFBFBD> {user.phone}
</a>
{user.landline && (
<a href={`tel:${user.landline}`} className="user-phone">

View File

@ -33,7 +33,7 @@
}
.allgemeines-tab-btn.active {
background: var(--color-surface);
background: white;
color: var(--color-primary);
font-weight: 700;
border-color: var(--color-primary);
@ -41,7 +41,7 @@
}
.allgemeines-content {
background: var(--color-surface);
background: white;
border: 1px solid var(--color-primary);
border-top: none;
border-radius: 0 0 8px 8px;

View File

@ -25,7 +25,7 @@
border-bottom: 1px solid var(--color-border);
}
.user-card-admin .user-name-row {
.user-name-row {
display: flex;
align-items: center;
gap: 0.5rem;
@ -39,10 +39,10 @@
box-shadow: 0 0 4px rgba(0,0,0,0.2);
}
.dot-green { background: var(--color-success); }
.dot-red { background: var(--color-danger); }
.dot-green { background: #22c55e; }
.dot-red { background: #ef4444; }
.user-card-admin .user-name {
.user-name {
margin: 0;
color: var(--color-text);
font-size: 1.2rem;
@ -67,40 +67,40 @@
font-weight: 600;
}
.label-green { color: var(--color-success); }
.label-green { color: #16a34a; }
.label-red { color: var(--color-danger); }
.user-card-admin .user-info {
.user-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.user-card-admin .user-address {
.user-address {
margin: 0;
color: var(--color-text-muted);
font-size: 0.9rem;
}
.user-card-admin .type-badge {
.type-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
background: var(--color-primary);
color: var(--color-on-primary);
color: white;
border-radius: var(--radius-pill);
font-size: 0.85rem;
font-weight: 500;
}
.user-card-admin .user-phone {
.user-phone {
color: var(--color-primary);
text-decoration: none;
font-weight: 500;
font-size: 1rem;
}
.user-card-admin .user-phone:hover {
.user-phone:hover {
text-decoration: underline;
}
@ -183,7 +183,7 @@
.edit-gps-button {
padding: 0.25rem 0.75rem;
background: var(--color-primary);
color: var(--color-on-primary);
color: white;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
@ -229,7 +229,7 @@
.save-gps-button {
background: var(--color-success);
color: var(--color-on-success);
color: white;
}
.save-gps-button:hover:not(:disabled) {
@ -243,7 +243,7 @@
.cancel-gps-button {
background: var(--color-secondary);
color: var(--color-on-secondary);
color: white;
}
.cancel-gps-button:hover {
@ -272,7 +272,7 @@
.edit-button {
background: var(--color-primary);
color: var(--color-on-primary);
color: white;
}
.edit-button:hover {
@ -281,7 +281,7 @@
.delete-button {
background: var(--color-danger);
color: var(--color-on-danger);
color: white;
}
.delete-button:hover {
@ -289,44 +289,17 @@
}
@media (max-width: 768px) {
.user-header {
.user-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.gps-inputs {
.gps-inputs {
flex-direction: column;
}
.card-actions {
.card-actions {
flex-direction: column;
}
}
.invite-section {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border);
}
.invite-section h4 {
margin: 0 0 0.5rem 0;
font-size: 0.9rem;
color: var(--color-text-muted);
font-weight: 600;
}
.invite-result {
margin-top: 0.5rem;
}
.invite-token {
display: block;
margin-top: 0.35rem;
padding: 0.4rem 0.5rem;
background: var(--color-surface-alt);
border: 1px solid var(--color-border);
border-radius: 2px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.72rem;
word-break: break-all;
user-select: all;
}

View File

@ -2,14 +2,12 @@ import React, { useState, useRef } from 'react';
import { useConfigContext } from '../../contexts/ConfigContext';
import './UserCard.css';
const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, onPhotoUpload, onPhotoDelete, onGenerateInvite }) => {
const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, onPhotoUpload, onPhotoDelete }) => {
const { userTypeLabels } = useConfigContext();
const [lat, setLat] = useState(user.gps?.lat?.toString() || '');
const [lng, setLng] = useState(user.gps?.lng?.toString() || '');
const [isEditingGPS, setIsEditingGPS] = useState(false);
const [photoUploading, setPhotoUploading] = useState(false);
const [invite, setInvite] = useState(null);
const [inviteLoading, setInviteLoading] = useState(false);
const photoInputRef = useRef(null);
const handleGPSUpdate = () => {
@ -52,16 +50,6 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o
}
};
const handleGenerateInvite = async () => {
if (!onGenerateInvite) return;
setInviteLoading(true);
const result = await onGenerateInvite(user._id);
if (result?.success) {
setInvite(result.data);
}
setInviteLoading(false);
};
return (
<div className={`user-card-admin ${user.available ? 'card-available' : 'card-unavailable'}`}>
<div className="user-header">
@ -132,7 +120,6 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o
<div className="gps-section">
<h4>GPS-Koordinaten</h4>
{!isEditingGPS ? (
<div className="gps-display">
{user.gps && user.gps.lat && user.gps.lng ? (
<span>
@ -191,37 +178,6 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o
)}
</div>
{/* Einladungs-Token: einmalig gültiger Code, mit dem der Hundeführer sein
eigenes Passwort setzt. Ohne hinterlegte E-Mail nicht möglich. */}
<div className="invite-section">
<h4>Zugang für den Drohnenführer</h4>
{!user.email ? (
<p className="settings-hint">
Keine E-Mail hinterlegt ohne E-Mail ist kein Login möglich.
</p>
) : (
<>
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={handleGenerateInvite}
disabled={inviteLoading}
>
{inviteLoading ? 'Erzeuge…' : '🔑 Einladungs-Token erzeugen'}
</button>
{invite && (
<div className="invite-result">
<p className="settings-hint">
Diesen Token an <strong>{invite.email}</strong> weitergeben. Gültig bis{' '}
{new Date(invite.expiresAt).toLocaleDateString('de-DE')}.
</p>
<code className="invite-token">{invite.inviteToken}</code>
</div>
)}
</>
)}
</div>
<div className="card-actions">
<button
className="edit-button"

View File

@ -91,7 +91,7 @@
top: 100%;
left: 0;
right: 0;
background: var(--color-surface);
background: white;
border: 1px solid var(--color-border-strong);
border-top: none;
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
@ -163,7 +163,7 @@
.btn-primary {
background: var(--color-primary);
color: var(--color-on-primary);
color: white;
}
.btn-primary:hover {
@ -172,7 +172,7 @@
.btn-secondary {
background: var(--color-secondary);
color: var(--color-on-secondary);
color: white;
}
.btn-secondary:hover {

View File

@ -34,7 +34,7 @@
.create-button {
padding: 0.5rem 1rem;
background: var(--color-success);
color: var(--color-on-success);
color: white;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;

View File

@ -7,7 +7,7 @@ import Loading from '../common/Loading';
import ErrorMessage from '../common/ErrorMessage';
import './UserList.css';
const UserList = ({ users, loading, error, onAvailabilityToggle, onGPSUpdate, onUserCreate, onUserUpdate, onUserDelete, onPhotoUpload, onPhotoDelete, onGenerateInvite }) => {
const UserList = ({ users, loading, error, onAvailabilityToggle, onGPSUpdate, onUserCreate, onUserUpdate, onUserDelete, onPhotoUpload, onPhotoDelete }) => {
const [editingUser, setEditingUser] = useState(null);
const [showCreateForm, setShowCreateForm] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
@ -145,7 +145,6 @@ const UserList = ({ users, loading, error, onAvailabilityToggle, onGPSUpdate, on
onDelete={handleDelete}
onPhotoUpload={onPhotoUpload}
onPhotoDelete={onPhotoDelete}
onGenerateInvite={onGenerateInvite}
/>
))}
</div>

View File

@ -15,22 +15,15 @@ export const useUsers = (isAdmin = false) => {
// Keep a ref so callbacks always call the latest fetchUsers
const fetchUsersRef = useRef(null);
// Zuletzt verwendete Filter, damit „mehr laden" nicht auf die ungefilterte
// Liste zurückfällt und Fremdeinträge an das Ergebnis anhängt.
const activeFiltersRef = useRef({});
const fetchUsers = useCallback(async (page = 1, reset = false, filters = null) => {
const fetchUsers = useCallback(async (page = 1, reset = false, filters = {}) => {
setLoading(true);
setError(null);
if (filters !== null) {
activeFiltersRef.current = filters;
}
const params = {
page,
limit: 50,
...activeFiltersRef.current
...filters
};
const result = isAdmin ? await getUsers(params) : await getPublicUsers(params);
@ -47,7 +40,7 @@ export const useUsers = (isAdmin = false) => {
fetchUsersRef.current = fetchUsers;
useEffect(() => {
fetchUsers(1, true, {}); // Initial load, reset users and filters
fetchUsers(1, true); // Initial load, reset users
}, [fetchUsers]);
const loadMore = useCallback(() => {
@ -69,3 +62,4 @@ export const useUsers = (isAdmin = false) => {
refetch
};
};

View File

@ -1,8 +1,13 @@
/* Basisstile stehen in App.css, wo auch die Design-Tokens definiert sind.
Hier stand vorher eine zweite body-Regel, die von App.css vollständig
überschrieben wurde. */
html {
/* Verhindert, dass iOS beim Drehen die Schrift eigenmächtig vergrößert. */
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

View File

@ -10,14 +10,10 @@ root.render(
</React.StrictMode>
);
// Register service worker.
// BASE_URL endet immer auf '/' und ist bei Unterpfad-Deployments '/nachsuche/'.
// Ein absolutes '/sw.js' würde stattdessen den Service Worker des Portals
// registrieren und dessen Scope übernehmen.
// Register service worker
if ('serviceWorker' in navigator) {
const base = import.meta.env.BASE_URL || '/';
window.addEventListener('load', () => {
navigator.serviceWorker.register(`${base}sw.js`, { scope: base })
navigator.serviceWorker.register('./sw.js')
.then(registration => {
console.log('SW registered: ', registration);
})

View File

@ -38,14 +38,8 @@ api.interceptors.response.use(
return Promise.reject(error);
}
// Ohne Request-Config (z. B. Fehler schon beim Aufbau der Anfrage) gibt es
// nichts zu wiederholen vorher lief das in einen TypeError auf undefined.
if (!config) {
return Promise.reject(error);
}
// Retry logic for network errors or 5xx errors
if (config.retry === undefined) {
if (!config || !config.retry) {
config.retry = 2; // Default: 2 retries
config.retryCount = 0;
}

View File

@ -214,20 +214,6 @@ export const uploadUserPhoto = async (id, photoDataUrl) => {
}
};
// Einmal-Token, mit dem ein Fuehrer sein erstes Passwort setzt.
// Laeuft ueber die Admin-Session (Cookie), nicht ueber den Fuehrer-Token.
export const generateInviteToken = async (id) => {
try {
const response = await api.post(`/drohnenfuehrer/${id}/invite-token`);
return { success: true, data: response.data.data };
} catch (error) {
return {
success: false,
message: error.response?.data?.message || 'Fehler beim Erzeugen des Einladungs-Tokens'
};
}
};
export const deleteUserPhoto = async (id) => {
try {
await api.delete(`/users/${id}/photo`);

View File

@ -1,34 +1,34 @@
// Der Build läuft über Vite: Umgebungsvariablen kommen aus import.meta.env und
// müssen mit VITE_ beginnen. `process.env` existiert im Browser-Bundle NICHT
// ein Zugriff darauf wirft "process is not defined" und die App bleibt weiß.
const env = import.meta.env;
// Fallback, falls ein Build ohne PUBLIC_URL/VITE_BASE_PATH unter einem Unterpfad
// ausgeliefert wird: dann steht in BASE_URL nur '/'.
// Use configured API URL when set, otherwise auto-detect common subpath deployment (/drohnenfuehrer)
const detectRuntimeBasePath = () => {
if (typeof window === 'undefined') return '';
const path = window.location.pathname || '';
const knownBasePaths = ['/nachsuche', '/drohnenfuehrer', '/stoeberhunde'];
const match = knownBasePaths.find(basePath => path === basePath || path.startsWith(`${basePath}/`));
return match || '';
if (path === '/drohnenfuehrer' || path.startsWith('/drohnenfuehrer/')) {
return '/drohnenfuehrer';
}
return '';
};
const stripTrailingSlash = (value) => String(value || '').replace(/\/+$/, '');
const configuredApiBaseUrl = process.env.REACT_APP_API_URL;
export const API_BASE_URL = (typeof configuredApiBaseUrl === 'string' && configuredApiBaseUrl.trim().length > 0)
? configuredApiBaseUrl
: detectRuntimeBasePath();
// Basis-Pfad des Deployments ohne abschließenden Slash: '/nachsuche' bzw. '' im Root.
export const BASE_PATH = stripTrailingSlash(env.BASE_URL) || detectRuntimeBasePath();
export const USER_TYPES = {
DF: 'DF',
WK: 'WK',
RGB: 'RGB'
};
// Vollständige API-Basis. VITE_API_URL überschreibt (z. B. eigene API-Domain).
export const API_BASE_URL = (typeof env.VITE_API_URL === 'string' && env.VITE_API_URL.trim())
? stripTrailingSlash(env.VITE_API_URL.trim())
: BASE_PATH;
export const USER_TYPE_LABELS = {
[USER_TYPES.DF]: 'Drohnenführer',
[USER_TYPES.WK]: 'Wärmebildkamera',
[USER_TYPES.RGB]: 'RGB-Kamera'
};
export const ADMIN_PATH = env.VITE_ADMIN_PATH || '/verwaltung';
export const RESET_PASSWORD_PATH = '/passwort-zuruecksetzen';
// Hängt den Deployment-Basispfad vor einen App-Pfad: '/nachsuche/verwaltung'.
export const withBase = (appPath) =>
`${BASE_PATH}${appPath.startsWith('/') ? appPath : `/${appPath}`}`;
// Vergleichbare Normalform eines Pfads (ohne abschließenden Slash).
export const normalizePath = (pathname) => stripTrailingSlash(pathname) || '/';
export const RULES = [
"Drohnenflug nur mit gültigem Drohnenführerschein (A1/A3 oder A2).",
"Informieren Sie den zuständigen Revierinhaber vor jedem Einsatz.",
"Halten Sie die Datenschutzbestimmungen beim Einsatz von Wärmebildkameras ein.",
"Geben Sie keine Aufnahmen ohne Zustimmung des Revierinhabers weiter.",
"Melden Sie Ihren Einsatz unverzüglich an die koordinierende Stelle."
];

View File

@ -28,7 +28,7 @@ services:
build:
context: ./frontend
args:
- VITE_API_URL=http://localhost:5000
- REACT_APP_API_URL=http://localhost:5000
container_name: drohnenfuehrer-frontend
ports:
- "8080:80"

View File

@ -139,7 +139,7 @@ Im Frontend wird die API-URL zur **Build-Zeit** gesetzt. Für Production:
frontend:
build:
args:
- VITE_API_URL=https://api.yourdomain.com
- REACT_APP_API_URL=https://api.yourdomain.com
```
2. Rebuild erforderlich:

View File

@ -19,16 +19,3 @@ CORS_ORIGIN=http://localhost:3000
GEOCODE_URL=https://nominatim.openstreetmap.org/search
GEOCODE_USER_AGENT=tracking-leaders-app/1.0 (admin@localhost)
GEOCODE_MIN_DELAY_MS=1100
# Basis-URL der App fuer Links in E-Mails (Passwort-Reset).
# MUSS den Unterpfad enthalten, unter dem die App ausgeliefert wird.
# Ohne diesen Wert wird er aus CORS_ORIGIN + "/nachsuche" zusammengesetzt.
APP_URL=http://localhost:8080/nachsuche
# SMTP fuer Passwort-Reset-Mails (optional).
# Fehlt die Konfiguration, wird der Reset-Link nur ins Log geschrieben.
# SMTP_HOST=smtp.example.com
# SMTP_PORT=587
# SMTP_USER=noreply@example.com
# SMTP_PASS=
# SMTP_FROM=noreply@example.com

View File

@ -10,10 +10,8 @@ const connectDB = async () => {
});
logger.info('MongoDB verbunden');
} catch (error) {
// Nicht process.exit(): der Aufrufer (server.js) implementiert einen Retry.
// Ein Exit hier hat den Retry zu totem Code gemacht.
logger.error('MongoDB Verbindungsfehler:', error.message);
throw error;
process.exit(1);
}
};

View File

@ -15,12 +15,7 @@ const config = {
geocodeMinDelayMs: parseInt(process.env.GEOCODE_MIN_DELAY_MS || '1100', 10),
// E-Mail / SMTP (required for password-reset emails; optional otherwise)
smtpConfigured: !!(process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS),
// Basis für Links in E-Mails (Passwort-Reset). MUSS den Unterpfad enthalten,
// unter dem die App ausgeliefert wird der CORS_ORIGIN-Rückfall kennt ihn
// nicht und erzeugt sonst Links, die auf dem Portal statt in der App landen.
appUrl: (process.env.APP_URL
|| `${(process.env.CORS_ORIGIN?.split(',')[0] || 'http://localhost:8080').replace(/\/+$/, '')}/${APP_NAME}`
).replace(/\/+$/, '')
appUrl: process.env.APP_URL || process.env.CORS_ORIGIN?.split(',')[0] || 'http://localhost:8080'
};
// Validate required environment variables
@ -52,21 +47,11 @@ if (config.nodeEnv === 'production') {
}
});
// Check for insecure defaults in production.
// Nicht nur der eine Default-String: podman-compose.yml setzt z. B.
// CHANGE_ME_IN_PRODUCTION, was eine reine Gleichheitsprüfung durchlässt.
if (/change[-_ ]?me|change-in-production|your-secret|changeme|secret-key/i.test(config.jwtSecret)) {
console.error('❌ Fehler: JWT_SECRET verwendet einen Platzhalter-Wert!');
console.error(' Bitte ein zufälliges Secret setzen, z. B. mit: openssl rand -hex 32');
// Check for insecure defaults in production
if (config.jwtSecret === 'your-secret-key-change-in-production') {
console.error('❌ Fehler: JWT_SECRET verwendet unsicheren Default-Wert!');
process.exit(1);
}
// Ohne APP_URL wird der Link in der Passwort-Reset-Mail aus CORS_ORIGIN
// zusammengesetzt. Das funktioniert nur, solange der Unterpfad dem App-Namen
// entspricht bei abweichendem Deployment führt der Link ins Leere.
if (!process.env.APP_URL) {
console.warn(`⚠️ Warnung: APP_URL ist nicht gesetzt. Reset-Links verwenden "${config.appUrl}".`);
}
}
module.exports = config;

View File

@ -1,7 +1,5 @@
const mongoose = require('mongoose');
const AuditLog = require('../models/AuditLog');
const logger = require('../utils/logger');
const { escapeCell } = require('../utils/csv');
/**
* Get all audit logs with pagination and filtering
@ -101,13 +99,8 @@ const getAdminActivity = async (req, res) => {
try {
const { adminId } = req.params;
if (!mongoose.isValidObjectId(adminId)) {
return res.status(400).json({ success: false, message: 'Ungültige Admin-ID' });
}
const stats = await AuditLog.aggregate([
// ObjectId ist seit bson 5 eine echte Klasse und braucht new.
{ $match: { adminId: new mongoose.Types.ObjectId(adminId) } },
{ $match: { adminId: require('mongoose').Types.ObjectId(adminId) } },
{
$group: {
_id: '$action',
@ -221,6 +214,13 @@ const exportAuditLogs = async (req, res) => {
.limit(10000)
.lean();
const escapeCell = (val) => {
if (val == null) return '';
const str = String(val);
return str.includes(',') || str.includes('"') || str.includes('\n')
? `"${str.replace(/"/g, '""')}"` : str;
};
const header = [
'Zeitstempel', 'Aktion', 'Ressource', 'Ressourcen-Name', 'Admin',
'IP-Adresse', 'Methode', 'Pfad', 'Status-Code', 'Dauer (ms)',

View File

@ -40,7 +40,7 @@ const login = async (req, res) => {
// Generate token
const token = jwt.sign(
{ id: admin._id, username: admin.username, app: config.appName, role: 'admin' },
{ id: admin._id, username: admin.username, app: config.appName },
config.jwtSecret,
{ expiresIn: config.jwtExpiresIn }
);
@ -81,7 +81,7 @@ const logout = async (req, res) => {
try {
// Log logout (get username from token if available)
const username = req.user?.username || 'unknown';
await auditAuth(req, true, username, null, 'LOGOUT');
await auditAuth(req, true, username, null);
const secureCookie = config.nodeEnv === 'production'
? (req.secure || req.headers['x-forwarded-proto'] === 'https')
@ -123,9 +123,7 @@ const forgotPassword = async (req, res) => {
}
// Find admin
// case-insensitive wie beim Login: ein als "Thorsten" angelegtes Konto
// konnte sich als "thorsten" anmelden, aber kein Passwort zuruecksetzen.
const admin = await Admin.findOne({ username: { $regex: new RegExp(`^${String(username).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i') } });
const admin = await Admin.findOne({ username });
// Don't reveal if user exists (security best practice)
if (!admin) {
@ -202,13 +200,11 @@ const resetPassword = async (req, res) => {
});
}
// Muss zu minlength im Admin-Schema passen. Vorher stand hier 6: Passwörter
// mit 611 Zeichen kamen durch und scheiterten erst an der Mongoose-
// Validierung, was als 500 "Serverfehler" beim Nutzer ankam.
if (newPassword.length < 12) {
// Validate password length
if (newPassword.length < 6) {
return res.status(400).json({
success: false,
message: 'Passwort muss mindestens 12 Zeichen lang sein'
message: 'Passwort muss mindestens 6 Zeichen lang sein'
});
}

View File

@ -1,7 +1,6 @@
const User = require('../models/User');
const { geocodeAddress } = require('../utils/geocode');
const logger = require('../utils/logger');
const { escapeCell } = require('../utils/csv');
const config = require('../config/env');
const ALLOWED_USER_FIELDS = ['name', 'type', 'address', 'phone', 'landline', 'email', 'available', 'gps', 'notes'];
@ -289,9 +288,7 @@ const getPublicUsers = async (req, res) => {
.sort(req.query.search ? { score: { $meta: 'textScore' } } : { name: 1 })
.skip(skip)
.limit(limit)
// Kontaktdaten sind der Zweck der oeffentlichen Liste; E-Mail, Hashes
// und Invite-Felder bleiben ausgeschlossen.
.select('name type available gps phone landline address photo'),
.select('name type available gps'),
User.countDocuments(filter)
]);
@ -417,6 +414,14 @@ const exportUsers = async (req, res) => {
.select('-__v -passwordHash -deleted -deletedAt -deletedBy');
if (format === 'csv') {
const escapeCell = (val) => {
if (val == null) return '';
const str = String(val);
return str.includes(',') || str.includes('"') || str.includes('\n')
? `"${str.replace(/"/g, '""')}"`
: str;
};
const csv = [
['Name', 'Adresse', 'Telefon', 'Festnetz', 'E-Mail', 'Typ', 'Verfügbar', 'Latitude', 'Longitude'].join(','),
...users.map(user => [
@ -588,10 +593,8 @@ const bulkUpdateUsers = async (req, res) => {
}
// Perform bulk update
// Der pre(/^find/)-Hook des Modells greift bei updateMany nicht,
// der Soft-Delete-Filter muss hier explizit gesetzt werden.
const result = await User.updateMany(
{ _id: { $in: ids }, deleted: { $ne: true } },
{ _id: { $in: ids } },
{ $set: updateFields }
);
@ -629,10 +632,8 @@ const bulkDeleteUsers = async (req, res) => {
}
// Soft delete all users
// Bereits geloeschte Eintraege bleiben unangetastet, damit
// deletedAt/deletedBy nicht ueberschrieben werden.
const result = await User.updateMany(
{ _id: { $in: ids }, deleted: { $ne: true } },
{ _id: { $in: ids } },
{
$set: {
deleted: true,

View File

@ -141,12 +141,10 @@ const auditLog = (action, resource) => {
/**
* Log authentication attempts (success and failure)
*/
// `action` überschreibt die Vorbelegung z. B. 'LOGOUT' für die Abmeldung,
// die sonst fälschlich als LOGIN im Protokoll landen würde.
const auditAuth = async (req, isSuccess, username, errorMessage = null, action = null) => {
const auditAuth = async (req, isSuccess, username, errorMessage = null) => {
try {
await AuditLog.log({
action: action || (isSuccess ? 'LOGIN' : 'LOGIN_FAILED'),
action: isSuccess ? 'LOGIN' : 'LOGIN_FAILED',
resource: 'Admin',
adminUsername: username,
ipAddress: req.ip || req.connection?.remoteAddress,

View File

@ -1,7 +1,6 @@
const jwt = require('jsonwebtoken');
const config = require('../config/env');
// Verifiziert das Token und stellt sicher, dass es sich um ein Admin-Token handelt.
const authenticateToken = (req, res, next) => {
// Try to get token from cookie first (new secure method)
let token = req.cookies?.token;
@ -21,7 +20,6 @@ const authenticateToken = (req, res, next) => {
try {
const decoded = jwt.verify(token, config.jwtSecret);
// Reject tokens issued by a different app (C-01 cross-app auth fix)
if (decoded.app && decoded.app !== config.appName) {
return res.status(403).json({
@ -29,20 +27,6 @@ const authenticateToken = (req, res, next) => {
message: 'Ungültiger oder abgelaufener Token.'
});
}
// Rollenprüfung. Handler-Tokens werden mit demselben Secret signiert; die
// app-Prüfung oben greift bei ihnen nicht, weil sie keinen app-Claim tragen.
// Ohne diese Zeilen kann ein eingeloggter Hundeführer seinen Bearer-Token
// gegen /api/users, /api/config und /api/audit-logs schicken und hat volle
// Admin-Rechte. Tokens ohne role stammen aus der Zeit davor und wurden
// ausschließlich für Admins ausgestellt.
if (decoded.role && decoded.role !== 'admin') {
return res.status(403).json({
success: false,
message: 'Zugriff verweigert. Keine Administratorrechte.'
});
}
req.user = decoded;
next();
} catch (error) {
@ -53,30 +37,4 @@ const authenticateToken = (req, res, next) => {
}
};
// Wie authenticateToken, blockiert aber nicht: setzt req.user wenn ein gültiges
// Admin-Token vorliegt und ruft ansonsten einfach next(). Für Endpunkte wie
// /logout, die auch mit abgelaufenem Token funktionieren müssen.
const attachUserIfPresent = (req, res, next) => {
let token = req.cookies?.token;
if (!token) {
const authHeader = req.headers['authorization'];
token = authHeader && authHeader.split(' ')[1];
}
if (token) {
try {
const decoded = jwt.verify(token, config.jwtSecret);
const appOk = !decoded.app || decoded.app === config.appName;
const roleOk = !decoded.role || decoded.role === 'admin';
if (appOk && roleOk) {
req.user = decoded;
}
} catch (error) {
// Ungültiges Token ist hier kein Fehler der Aufrufer wird ohne req.user bedient.
}
}
next();
};
module.exports = { authenticateToken, attachUserIfPresent };
module.exports = { authenticateToken };

View File

@ -60,32 +60,8 @@ const inviteLimiter = rateLimit({
}
});
// Limiter für die öffentliche PLZ-Suche.
// Der Endpunkt stößt ausgehende Anfragen an Nominatim an und teilt sich mit dem
// Geocoding im Admin-Bereich die globale Mindestwartezeit von geocodeMinDelayMs.
// Ohne eigenes Limit können anonyme Aufrufe das Anlegen von Führern ausbremsen
// und die Nominatim-Nutzungsregeln verletzen.
const geocodeLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 20,
message: {
success: false,
message: 'Zu viele PLZ-Abfragen. Bitte warten Sie einen Moment.'
},
standardHeaders: true,
legacyHeaders: false,
handler: (req, res) => {
logger.warn(`Geocode rate limit exceeded for IP: ${req.ip}`);
res.status(429).json({
success: false,
message: 'Zu viele PLZ-Abfragen. Bitte warten Sie einen Moment.'
});
}
});
module.exports = {
apiLimiter,
authLimiter,
inviteLimiter,
geocodeLimiter
inviteLimiter
};

View File

@ -15,12 +15,9 @@
"express": "^4.18.2",
"express-rate-limit": "^8.2.1",
"express-validator": "^7.3.1",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
"mongoose": "^7.5.0",
"nodemailer": "^6.9.16",
"winston": "^3.19.0",
"winston-daily-rotate-file": "^5.0.0"
"winston": "^3.19.0"
},
"devDependencies": {
"jest": "^30.2.0",
@ -2883,15 +2880,6 @@
"integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==",
"license": "MIT"
},
"node_modules/file-stream-rotator": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/file-stream-rotator/-/file-stream-rotator-0.6.1.tgz",
"integrity": "sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==",
"license": "MIT",
"dependencies": {
"moment": "^2.29.1"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@ -3254,18 +3242,6 @@
"node": ">= 0.4"
}
},
"node_modules/helmet": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
"integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"url": "https://github.com/sponsors/EvanHahn"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@ -4603,15 +4579,6 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/mongodb": {
"version": "5.9.2",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-5.9.2.tgz",
@ -4787,15 +4754,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemon": {
"version": "3.1.11",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
@ -4882,15 +4840,6 @@
"node": ">=0.10.0"
}
},
"node_modules/object-hash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@ -6300,24 +6249,6 @@
"node": ">= 12.0.0"
}
},
"node_modules/winston-daily-rotate-file": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/winston-daily-rotate-file/-/winston-daily-rotate-file-5.0.0.tgz",
"integrity": "sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==",
"license": "MIT",
"dependencies": {
"file-stream-rotator": "^0.6.1",
"object-hash": "^3.0.0",
"triple-beam": "^1.4.1",
"winston-transport": "^4.7.0"
},
"engines": {
"node": ">=8"
},
"peerDependencies": {
"winston": "^3"
}
},
"node_modules/winston-transport": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz",

View File

@ -2,12 +2,9 @@ const express = require('express');
const router = express.Router();
const { login, logout, forgotPassword, resetPassword } = require('../controllers/authController');
const { validateLogin } = require('../middleware/validator');
const { attachUserIfPresent } = require('../middleware/auth');
router.post('/login', validateLogin, login);
// attachUserIfPresent statt authenticateToken: der Logout muss auch mit
// abgelaufenem Token funktionieren, soll den Benutzernamen aber protokollieren.
router.post('/logout', attachUserIfPresent, logout);
router.post('/logout', logout);
router.post('/forgot-password', forgotPassword);
router.post('/reset-password', resetPassword);

View File

@ -3,7 +3,6 @@ const router = express.Router();
const { authenticateToken } = require('../middleware/auth');
const { auditLog } = require('../middleware/auditLogger');
const { validateGPS, validateAvailability } = require('../middleware/validator');
const { geocodeLimiter } = require('../middleware/rateLimiter');
const {
getAllUsers,
getUserById,
@ -26,19 +25,13 @@ const {
// Public routes
router.get('/public/users', getPublicUsers);
// Eigenes, engeres Limit: der Endpunkt loest ausgehende Nominatim-Anfragen aus.
router.get('/public/geocode', geocodeLimiter, getGeocodeByPostalCode);
router.get('/public/geocode', getGeocodeByPostalCode);
// Protected routes (require authentication)
router.get('/users', authenticateToken, getAllUsers);
router.get('/users/export', authenticateToken, auditLog('EXPORT', 'User'), exportUsers);
router.post('/users/import', authenticateToken, auditLog('IMPORT', 'User'), importUsers);
router.get('/users/deleted', authenticateToken, getDeletedUsers);
// Bulk-Operationen MÜSSEN vor /users/:id stehen, sonst schluckt die
// :id-Route den Pfad /users/bulk und die Massen-Löschung läuft ins Leere.
router.patch('/users/bulk', authenticateToken, auditLog('BULK_UPDATE', 'User'), bulkUpdateUsers);
router.delete('/users/bulk', authenticateToken, auditLog('BULK_DELETE', 'User'), bulkDeleteUsers);
router.get('/users/:id', authenticateToken, getUserById);
router.post('/users', authenticateToken, auditLog('CREATE', 'User'), createUser);
router.put('/users/:id', authenticateToken, auditLog('UPDATE', 'User'), updateUser);
@ -52,5 +45,7 @@ router.post('/users/:id/photo', authenticateToken, auditLog('UPDATE', 'User'), u
router.delete('/users/:id/photo', authenticateToken, auditLog('UPDATE', 'User'), deleteUserPhoto);
// Bulk operations
router.patch('/users/bulk', authenticateToken, auditLog('BULK_UPDATE', 'User'), bulkUpdateUsers);
router.delete('/users/bulk', authenticateToken, auditLog('BULK_DELETE', 'User'), bulkDeleteUsers);
module.exports = router;

View File

@ -12,10 +12,7 @@ const users = [];
const seedDatabase = async () => {
try {
// Beim Aufruf aus server.js besteht die Verbindung bereits.
if (mongoose.connection.readyState !== 1) {
await mongoose.connect(config.mongoUri);
}
logger.info('MongoDB verbunden für Seeding...');
@ -57,8 +54,6 @@ const seedDatabase = async () => {
}
// Seed config - always update userTypes + sections + rules
// Config NUR anlegen, niemals ueberschreiben. Vorher hat jeder Neustart
// die im Admin-Panel gepflegten Texte, Regeln und den App-Namen zurueckgesetzt.
const existingConfig = await Config.findOne();
const configData = {
userTypes: [
@ -68,7 +63,7 @@ const seedDatabase = async () => {
{ code: 'LAB', label: 'Labrador' }
],
rules: [
"Verbrechen Sie den Standort und den Anschuss.",
"Verbreiten Sie den Standort und den Anschuss.",
"Vertreten Sie keine Pirschzeichen.",
"Versuchen Sie die Nachsuche möglichst nicht erst mit ungeübten Hunden.",
"Benachrichtigen Sie unverzüglich den Nachsuchenführer und die evtl. betroffenen Revierinhaber der Nachbarjagdbezirke.",
@ -102,23 +97,22 @@ const seedDatabase = async () => {
await Config.create(configData);
logger.info('✅ Konfiguration erstellt');
} else {
logger.info(' Konfiguration existiert bereits, bleibt unveraendert');
await Config.findOneAndUpdate({}, configData, { new: true });
logger.info('✅ Konfiguration aktualisiert');
}
logger.info('✅ Datenbank-Seeding abgeschlossen');
await mongoose.connection.close();
process.exit(0);
} catch (error) {
logger.error('❌ Fehler beim Seeding:', error);
throw error;
await mongoose.connection.close();
process.exit(1);
}
};
// Verbindung schliessen und den Prozess beenden darf nur der CLI-Aufruf
// (npm run seed). server.js ruft seedDatabase() im selben Prozess auf ein
// process.exit(0) hier hat den frisch gestarteten Server sofort wieder beendet.
if (require.main === module) {
seedDatabase()
.then(async () => { await mongoose.connection.close(); process.exit(0); })
.catch(async () => { await mongoose.connection.close(); process.exit(1); });
seedDatabase();
}
module.exports = seedDatabase;

View File

@ -16,17 +16,11 @@ const connectWithRetry = async () => {
try {
await connectDB();
// Seeding legt Admin-Konto und Grundkonfiguration an. Die Bedingung darf sich
// NICHT an der User-Zahl orientieren: die Seed-Liste ist bewusst leer, dadurch
// lief das Seeding bei jedem Start erneut.
const Admin = require('./models/Admin');
const Config = require('./models/Config');
const [adminCount, configCount] = await Promise.all([
Admin.countDocuments(),
Config.countDocuments()
]);
if (adminCount === 0 || configCount === 0) {
logger.info('Admin oder Konfiguration fehlt, starte Seeding...');
// Seed database if empty (runs in all environments on first start)
const User = require('./models/User');
const userCount = await User.countDocuments();
if (userCount === 0) {
logger.info('Datenbank ist leer, starte Seeding...');
try {
const seed = require('./seed');
await seed();
@ -89,24 +83,17 @@ app.get('/health', async (req, res) => {
});
});
// Error handler (must be last)
app.use(errorHandler);
const PORT = config.port;
const server = app.listen(PORT, () => {
logger.info(`Server läuft auf Port ${PORT} (${config.nodeEnv})`);
});
// Graceful shutdown (Docker stop / Kubernetes rolling restart)
let shuttingDown = false;
const shutdown = (signal) => {
if (shuttingDown) return;
shuttingDown = true;
logger.info(`${signal} empfangen, fahre Server herunter...`);
const forceExit = setTimeout(() => {
logger.warn('Shutdown-Timeout erreicht, beende Prozess hart');
process.exit(1);
}, 10000);
forceExit.unref();
// Graceful shutdown on SIGTERM (Docker stop / Kubernetes rolling restart)
process.on('SIGTERM', () => {
logger.info('SIGTERM empfangen, fahre Server herunter...');
server.close(() => {
logger.info('HTTP-Server geschlossen');
mongoose.connection.close(false).then(() => {
@ -114,13 +101,9 @@ const shutdown = (signal) => {
process.exit(0);
}).catch(() => process.exit(1));
});
};
});
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// If a frontend build exists, serve it as static files (useful for local testing).
// Muss vor dem errorHandler stehen der gehoert als letztes Middleware registriert.
// If a frontend build exists, serve it as static files (useful for local testing)
const path = require('path');
const fs = require('fs');
const buildPath = path.join(__dirname, '..', 'frontend', 'build');
@ -134,6 +117,3 @@ if (fs.existsSync(buildPath)) {
res.sendFile(path.join(buildPath, 'index.html'));
});
}
// Error handler (must be last)
app.use(errorHandler);

View File

@ -1,19 +0,0 @@
/**
* Escaped eine einzelne CSV-Zelle.
*
* Neben dem üblichen Quoting werden Werte neutralisiert, die mit =, +, - oder @
* beginnen: Excel und LibreOffice würden sie sonst als Formel auswerten
* (CSV-Injection über einen frei wählbaren Namen oder eine Adresse).
*/
const escapeCell = (val) => {
if (val == null) return '';
let str = String(val);
if (/^[=+\-@\t\r]/.test(str)) {
str = `'${str}`;
}
return /["\n\r,]/.test(str) ? `"${str.replace(/"/g, '""')}"` : str;
};
module.exports = { escapeCell };

View File

@ -6,41 +6,17 @@ const logger = require('./logger');
const CACHE_FILE = path.join(__dirname, '..', 'geocode-cache.json');
const CACHE_SAVE_INTERVAL = 60000; // Save every 60 seconds
const CACHE_MAX_ENTRIES = 1000;
const cache = new Map();
let lastRequestTime = 0;
let cacheModified = false;
/**
* Einziger Schreibpfad in den Cache, inklusive Größenbegrenzung.
*
* Vorher war nur der Erfolgspfad begrenzt; die beiden Negativ-Pfade
* (Adresse nicht gefunden / unbrauchbare Koordinaten) haben ungebremst
* geschrieben. Über den öffentlichen /api/public/geocode genügten damit
* erfundene Postleitzahlen, um Speicher und Cache-Datei beliebig wachsen
* zu lassen bei --max_old_space_size=256 eine reale Grenze.
*/
const rememberInCache = (key, value) => {
// Map behält die Einfügereihenfolge: ein vorhandener Schlüssel muss neu
// eingefügt werden, damit er als "zuletzt benutzt" ans Ende rückt.
cache.delete(key);
while (cache.size >= CACHE_MAX_ENTRIES) {
cache.delete(cache.keys().next().value);
}
cache.set(key, value);
cacheModified = true;
};
// Load cache from file on startup
const loadCache = async () => {
try {
const data = await fs.readFile(CACHE_FILE, 'utf8');
const parsed = JSON.parse(data);
// Nur die letzten CACHE_MAX_ENTRIES übernehmen eine früher unbegrenzt
// gewachsene Datei darf den Cache nicht wieder aufblähen.
const entries = Object.entries(parsed).slice(-CACHE_MAX_ENTRIES);
entries.forEach(([key, value]) => {
Object.entries(parsed).forEach(([key, value]) => {
cache.set(key, value);
});
logger.info(`Geocoding cache loaded: ${cache.size} entries`);
@ -69,13 +45,14 @@ const saveCache = async () => {
setInterval(saveCache, CACHE_SAVE_INTERVAL).unref();
// Save on process exit
// Cache beim Herunterfahren sichern ohne process.exit(): das Beenden gehört
// dem Shutdown-Handler in server.js, der sonst mittendrin abgeschnitten wird.
const flushOnShutdown = () => {
saveCache().catch(err => logger.error('Failed to flush geocoding cache:', err.message));
};
process.on('SIGINT', flushOnShutdown);
process.on('SIGTERM', flushOnShutdown);
process.on('SIGINT', async () => {
await saveCache();
process.exit(0);
});
process.on('SIGTERM', async () => {
await saveCache();
process.exit(0);
});
// Initialize cache loading
loadCache().catch(err => logger.error('Cache initialization error:', err));
@ -112,12 +89,7 @@ const geocodeAddress = async (address) => {
const cacheKey = normalized.toLowerCase();
if (cache.has(cacheKey)) {
// Treffer ans Ende rücken, damit die Verdrängung wirklich den am längsten
// ungenutzten Eintrag trifft und nicht bloß den ältesten eingefügten.
const hit = cache.get(cacheKey);
cache.delete(cacheKey);
cache.set(cacheKey, hit);
return hit;
return cache.get(cacheKey);
}
const elapsed = Date.now() - lastRequestTime;
@ -135,7 +107,8 @@ const geocodeAddress = async (address) => {
lastRequestTime = Date.now();
if (!Array.isArray(results) || results.length === 0) {
rememberInCache(cacheKey, null);
cache.set(cacheKey, null);
cacheModified = true;
return null;
}
@ -144,12 +117,15 @@ const geocodeAddress = async (address) => {
const lng = parseFloat(hit.lon);
if (Number.isNaN(lat) || Number.isNaN(lng)) {
rememberInCache(cacheKey, null);
cache.set(cacheKey, null);
cacheModified = true;
return null;
}
const coords = { lat, lng };
rememberInCache(cacheKey, coords);
if (cache.size >= 1000) { cache.delete(cache.keys().next().value); }
cache.set(cacheKey, coords);
cacheModified = true;
return coords;
} catch (error) {
logger.warn('Geocoding fehlgeschlagen', {

View File

@ -47,8 +47,7 @@ services:
# - SMTP_USER=user@example.com
# - SMTP_PASS=${SMTP_PASS}
# - SMTP_FROM=nachsuche@example.com
# Basis fuer Links in Passwort-Reset-Mails. MUSS den Unterpfad enthalten.
- APP_URL=${APP_URL:-http://localhost:8080/nachsuche}
# - APP_URL=https://example.com/nachsuche
depends_on:
mongo:
condition: service_healthy
@ -64,7 +63,7 @@ services:
context: ./frontend
args:
- PUBLIC_URL=/nachsuche/
- VITE_API_URL=${VITE_API_URL:-}
- REACT_APP_API_URL=${REACT_APP_API_URL:-}
container_name: nachsuche-frontend
restart: unless-stopped
ports:

View File

@ -183,4 +183,4 @@ frontend/src/
- `CORS_ORIGIN`: Erlaubter CORS-Origin
### Frontend (.env)
- `VITE_API_URL`: Backend-API-URL (Standard: http://localhost:5000)
- `REACT_APP_API_URL`: Backend-API-URL (Standard: http://localhost:5000)

View File

@ -1,9 +1,9 @@
# API Configuration
# For local development
VITE_API_URL=http://localhost:5000
REACT_APP_API_URL=http://localhost:5000
# For production, use your actual backend URL
# VITE_API_URL=https://api.yourdomain.com
# REACT_APP_API_URL=https://api.yourdomain.com
# Admin path (optional, defaults to /verwaltung)
# VITE_ADMIN_PATH=/verwaltung
# REACT_APP_ADMIN_PATH=/verwaltung

View File

@ -10,8 +10,8 @@ COPY . .
ARG PUBLIC_URL=/nachsuche/
ENV PUBLIC_URL=$PUBLIC_URL
ARG VITE_API_URL=
ENV VITE_API_URL=$VITE_API_URL
ARG REACT_APP_API_URL=
ENV REACT_APP_API_URL=$REACT_APP_API_URL
# Limit Node.js heap during build to prevent OOM kills
ENV NODE_OPTIONS="--max_old_space_size=512"

View File

@ -1,18 +1,16 @@
<!DOCTYPE html>
<html lang="de">
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%BASE_URL%icons/icon-192.png" />
<link rel="icon" href="%BASE_URL%favicon.ico" />
<link rel="apple-touch-icon" href="%BASE_URL%icons/icon-192.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#1a3d1a" />
<meta name="theme-color" content="#2d6a2d" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="NSS Heidekreis" />
<meta name="mobile-web-app-capable" content="yes" />
<!-- Pfad relativ zur Deployment-Basis: ein absolutes "/manifest.json" wuerde
das Portal-Manifest laden und die App als Portal installieren. -->
<link rel="manifest" href="%BASE_URL%manifest.json" />
<link rel="manifest" href="/manifest.json" />
<meta
name="description"
content="Nachsuchenstation Heidekreis Übersicht der Nachsuchenführer"

View File

@ -27,28 +27,19 @@ server {
gzip_types text/plain text/xml application/xml+rss application/json;
gzip_disable "msie6";
# Security headers.
# ACHTUNG: nginx vererbt add_header nicht in Bloecke, die eigene add_header
# setzen - deshalb sind diese drei Zeilen in jedem solchen location-Block
# wiederholt. Beim Anlegen neuer Bloecke mit add_header daran denken.
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Cache static assets (images, fonts)
location ~* \.(jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$ {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
expires 1y;
add_header Cache-Control "public, immutable";
}
# JS and CSS - no compression, short cache
location ~* \.(js|css)$ {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
expires 1h;
add_header Cache-Control "public, no-transform";
gzip off;
@ -56,9 +47,6 @@ server {
# Service Worker - never cache
location = /sw.js {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Cache-Control "no-cache, no-store, must-revalidate";
expires 0;
}
@ -91,9 +79,6 @@ server {
# Service Worker for subpath deployment
location = /nachsuche/sw.js {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Cache-Control "no-cache, no-store, must-revalidate";
expires 0;
alias /usr/share/nginx/html/sw.js;
@ -101,9 +86,6 @@ server {
# index.html - never cache so new builds are picked up immediately
location = /index.html {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
expires 0;
@ -111,9 +93,6 @@ server {
# SPA fallback for subpath deployment (/nachsuche)
location ^~ /nachsuche/ {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
rewrite ^/nachsuche(/.*)$ $1 break;
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
@ -123,9 +102,6 @@ server {
# SPA fallback - serve index.html for all routes
location / {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
@ -134,9 +110,6 @@ server {
# Health check endpoint
location /health {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
access_log off;
return 200 "OK\n";
add_header Content-Type text/plain;

File diff suppressed because it is too large Load Diff

View File

@ -3,16 +3,28 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.3.0",
"@testing-library/user-event": "^13.5.0",
"axios": "^1.13.5",
"leaflet": "^1.9.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-leaflet": "^4.2.1"
"react-leaflet": "^4.2.1",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "vite",
"start": "react-scripts start",
"build": "vite build",
"preview": "vite preview"
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [

View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/icons/icon-192.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#2d6a2d" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="NSS Heidekreis" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<meta
name="description"
content="Nachsuchenstation Heidekreis Übersicht der Nachsuchenführer"
/>
<title>NSS Heidekreis</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 692 KiB

View File

@ -2,6 +2,11 @@
"short_name": "NSS Heidekreis",
"name": "Nachsuchenstation Heidekreis",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "icons/icon-192.png",
"sizes": "192x192",
@ -19,7 +24,7 @@
"scope": "/nachsuche/",
"display": "standalone",
"orientation": "portrait",
"theme_color": "#1a3d1a",
"background_color": "#e8e8e2",
"theme_color": "#2d6a2d",
"background_color": "#ffffff",
"description": "Nachsuchenstation Heidekreis Übersicht der Nachsuchenführer"
}

View File

@ -1,93 +1,16 @@
<!DOCTYPE html>
<html lang="de">
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#1a3d1a" />
<title>Offline Jagd Apps Heidekreis</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offline - Nachsuchenführer</title>
<style>
/* Eigenständige Seite: sie wird vom Service Worker ausgeliefert, wenn das
Netz fehlt, und kann deshalb keine Stylesheets der App nachladen.
Palette und Schriftmodell entsprechen dem Portal, inklusive Nachtansicht. */
* { box-sizing: border-box; }
:root {
--bg: #e8e8e2;
--card: #f4f4ee;
--text: #1a1a1a;
--muted: #55554c;
--border: #c9c9b8;
--green: #2d5a2d;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14180f;
--card: #1e241a;
--text: #e9e7dd;
--muted: #a6a698;
--border: #333b28;
--green: #7fb36f;
}
}
body {
margin: 0;
min-height: 100dvh;
padding: 2rem 1.25rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
text-align: center;
}
.card {
max-width: 26rem;
padding: 2rem 1.5rem;
background: var(--card);
border: 1px solid var(--border);
border-radius: 2px;
}
.icon { font-size: 3rem; line-height: 1; margin-bottom: 1rem; }
h1 {
margin: 0 0 0.5rem;
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.5rem;
color: var(--text);
}
p { margin: 0 0 1.5rem; color: var(--muted); font-size: 1rem; line-height: 1.5; }
button {
min-height: 44px;
padding: 0.6rem 1.4rem;
background: var(--green);
color: var(--bg);
border: none;
border-radius: 2px;
font: inherit;
font-weight: 600;
cursor: pointer;
}
button:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
h1 { color: #333; }
</style>
</head>
<body>
<div class="card">
<div class="icon" role="img" aria-label="Kein Empfang">📡</div>
<h1>Keine Verbindung</h1>
<p>
Im Funkloch sind die zuletzt geladenen Daten nicht verfügbar.
Sobald wieder Empfang besteht, lädt die Seite normal.
</p>
<button type="button" onclick="location.reload()">Erneut versuchen</button>
</div>
<h1>Du bist offline</h1>
<p>Die App ist derzeit nicht verfügbar. Bitte überprüfe deine Internetverbindung.</p>
</body>
</html>

View File

@ -2,203 +2,43 @@
box-sizing: border-box;
}
/*
Design-Tokens einzige Farbquelle der App.
Palette und Formensprache folgen dem Portal (portal/index.html), damit der
Wechsel vom Portal in eine App nicht wie ein Produktwechsel wirkt.
Schriftmodell wie im Portal: Serif für die Marken-/Überschriftenebene,
Sans für funktionale UI-Texte (auf kleinen Displays besser lesbar).
Alle Textpaare erfüllen WCAG AA (>= 4.5:1), funktionale Rahmen >= 3.0:1
in beiden Varianten nachgerechnet.
*/
:root {
--font-display: Georgia, 'Times New Roman', serif;
--font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
--font-mono: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
/* Flächen */
--color-bg: #e8e8e2;
--color-surface: #f4f4ee;
--color-surface-alt: #dedcd2;
--color-muted-bg: #dedcd2;
/* Text */
--color-primary: #2e8b2e;
--color-primary-dark: #1e6b1e;
--color-primary-accent: #8B0D12;
--color-secondary: #6c757d;
--color-secondary-dark: #5a6268;
--color-success: #28a745;
--color-success-dark: #218838;
--color-danger: #dc3545;
--color-danger-dark: #c82333;
--color-bg: #f5f5f0;
--color-surface: #ffffff;
--color-text: #1a1a1a;
--color-text-muted: #55554c;
/* Rahmen: -border ist dekorativ (Trennlinien, Karten),
-border-strong begrenzt Bedienelemente und erfüllt die 3:1-Anforderung. */
--color-border: #c9c9b8;
--color-border-strong: #7d7d68;
/* Aktionsfarben jeweils mit der zugehörigen Textfarbe, damit die
Dunkelvariante nicht auf weißem Text auf hellem Grund landet. */
--color-primary: #2d5a2d;
--color-primary-dark: #1a3d1a;
--color-on-primary: #ffffff;
--color-secondary: #5c5c50;
--color-secondary-dark: #46463c;
--color-on-secondary: #ffffff;
--color-success: #1f6b34;
--color-success-dark: #175128;
--color-on-success: #ffffff;
--color-danger: #a32020;
--color-danger-dark: #821919;
--color-on-danger: #ffffff;
--color-warning: #7a5200;
--color-on-warning: #ffffff;
--color-accent: #8b0d12;
--color-on-accent: #ffffff;
/* Altname, wird von RulesDisplay noch benutzt */
--color-primary-accent: #8b0d12;
/* Getönte Status-Flächen (Meldungen, Badges, Zustands-Karten).
Der Block wird durch seine Füllung erkannt, der Rahmen ist Zierde. */
--color-success-bg: #dfeedd;
--color-success-border: #a9cba4;
--color-success-text: #1a4a24;
--color-danger-bg: #f6e0e0;
--color-danger-border: #d9a9a9;
--color-danger-text: #7d1a1a;
--color-warning-bg: #f7edd4;
--color-warning-border: #d9c48a;
--color-warning-text: #5c3d00;
--color-info: #24608f;
--color-info-dark: #1b4a6e;
--color-on-info: #ffffff;
--color-info-bg: #dde8f1;
--color-info-border: #a5bfd4;
--color-info-text: #14405f;
/* Als RGB-Tripel fuer rgba()-Anwendungen (Puls-Animation im Admin-Panel) */
--color-primary-rgb: 45, 90, 45;
--color-focus: rgba(45, 90, 45, 0.35);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.15);
--shadow-md: 0 2px 6px rgba(0, 0, 0, 0.2);
/* Kantige Ecken wie im Portal */
--radius-sm: 2px;
--radius-md: 2px;
--radius-pill: 2px;
--color-text-muted: #555;
--color-border: #b8d4b8;
--color-border-strong: #88b888;
--color-focus: rgba(46, 139, 46, 0.2);
--color-muted-bg: #f5f5f0;
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.2);
--radius-sm: 4px;
--radius-md: 8px;
--radius-pill: 12px;
--space-1: 0.5rem;
--space-2: 1rem;
--space-3: 1.5rem;
--space-4: 2rem;
/* Mindestgröße für Bedienelemente auf Touchgeräten */
--touch-target: 44px;
}
/* Nachtvariante. Nachsuchen laufen in der Dämmerung und nachts ein weißes
Vollbild blendet dann und kostet die Dunkeladaption der Augen. Warme, sehr
dunkle Grüntöne statt reinem Schwarz. */
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #14180f;
--color-surface: #1e241a;
--color-surface-alt: #2a3124;
--color-muted-bg: #2a3124;
--color-text: #e9e7dd;
--color-text-muted: #a6a698;
--color-border: #333b28;
--color-border-strong: #758566;
--color-primary: #7fb36f;
--color-primary-dark: #9ccb8c;
--color-on-primary: #10140c;
--color-secondary: #8d8d80;
--color-secondary-dark: #a3a396;
--color-on-secondary: #10140c;
--color-success: #79c48c;
--color-success-dark: #93d3a3;
--color-on-success: #10140c;
--color-danger: #ea8b8b;
--color-danger-dark: #f2a5a5;
--color-on-danger: #10140c;
--color-warning: #d6b25f;
--color-on-warning: #10140c;
--color-accent: #e88a8f;
--color-on-accent: #10140c;
--color-primary-accent: #e88a8f;
--color-success-bg: #1d2c20;
--color-success-border: #3d5c43;
--color-success-text: #93d3a3;
--color-danger-bg: #33201f;
--color-danger-border: #6b4040;
--color-danger-text: #f2a5a5;
--color-warning-bg: #322a15;
--color-warning-border: #63552c;
--color-warning-text: #e0c37c;
--color-info: #7fb0dc;
--color-info-dark: #9cc4e8;
--color-on-info: #10140c;
--color-info-bg: #1a2530;
--color-info-border: #3d5468;
--color-info-text: #9cc4e8;
--color-primary-rgb: 127, 179, 111;
--color-focus: rgba(127, 179, 111, 0.45);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.5);
--shadow-md: 0 2px 6px rgba(0, 0, 0, 0.6);
}
}
body {
margin: 0;
font-family: var(--font-ui);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: var(--color-bg);
color: var(--color-text);
/* Damit auch vom Browser gestellte Bedienelemente (Bildlaufleisten,
Datumsauswahl, Autofill) der gewählten Ansicht folgen. */
color-scheme: light dark;
}
/* Formularfelder brauchen ausdrücklich Farben: ohne sie nimmt der Browser
seinen Standard (weiß) in der Nachtansicht leuchtet dann jedes Eingabefeld. */
input:not([type='checkbox']):not([type='radio']):not([type='range']):not([type='file']),
select,
textarea {
background: var(--color-surface);
color: var(--color-text);
}
input::placeholder,
textarea::placeholder {
color: var(--color-text-muted);
opacity: 1;
}
h1, h2, h3 {
font-family: var(--font-display);
}
code {
font-family: var(--font-mono);
}
/* ── Bedienelemente ─────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-1);
min-height: var(--touch-target);
padding: 0.5rem 1rem;
border: none;
border-radius: var(--radius-sm);
font-family: var(--font-ui);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
@ -206,24 +46,15 @@ code {
}
.btn:disabled {
background: var(--color-surface-alt);
background: var(--color-border);
color: var(--color-text-muted);
cursor: not-allowed;
box-shadow: none;
}
/* Sichtbarer Tastaturfokus. Ohne das war die Tastaturbedienung unsichtbar
Buttons hatten nur einen :hover-Stil. */
.btn:focus-visible,
.nav-button:focus-visible,
a:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.btn-primary {
background: var(--color-primary);
color: var(--color-on-primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
@ -233,7 +64,7 @@ a:focus-visible {
.btn-secondary {
background: var(--color-secondary);
color: var(--color-on-secondary);
color: white;
}
.btn-secondary:hover:not(:disabled) {
@ -241,9 +72,19 @@ a:focus-visible {
transform: translateY(-1px);
}
.btn-success {
background: var(--color-success);
color: white;
}
.btn-success:hover:not(:disabled) {
background: var(--color-success-dark);
transform: translateY(-1px);
}
.btn-danger {
background: var(--color-danger);
color: var(--color-on-danger);
color: white;
}
.btn-danger:hover:not(:disabled) {
@ -255,13 +96,9 @@ a:focus-visible {
.select,
.textarea {
width: 100%;
min-height: var(--touch-target);
padding: 0.75rem 1rem;
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-sm);
font-family: var(--font-ui);
font-size: 1rem;
box-sizing: border-box;
}
@ -274,19 +111,33 @@ a:focus-visible {
box-shadow: 0 0 0 3px var(--color-focus);
}
.panel {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-3);
box-shadow: var(--shadow-sm);
}
.panel-title {
margin: 0 0 var(--space-2);
font-family: var(--font-display);
font-size: 1.1rem;
color: var(--color-text);
}
/* ── Grundgerüst ────────────────────────────────────────────────────────── */
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: var(--color-bg);
color: var(--color-text);
}
.App {
/* dvh statt vh: mit ein- und ausblendender Adressleiste auf Mobilgeräten
entsteht mit vh sonst Überlauf. */
min-height: 100dvh;
min-height: 100vh;
display: flex;
flex-direction: column;
}
@ -300,26 +151,44 @@ a:focus-visible {
display: flex;
justify-content: center;
align-items: center;
min-height: 100dvh;
min-height: 100vh;
font-size: 1.2rem;
color: var(--color-text-muted);
}
/* Wer Bewegung reduziert haben möchte, bekommt keine Animationen. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
.login-prompt {
position: fixed;
bottom: 20px;
right: 20px;
}
.login-button-header {
padding: 0.75rem 1.5rem;
background: var(--color-primary);
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
transition: all 0.2s;
}
.login-button-header:hover {
background: var(--color-primary-dark);
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3);
}
@media (max-width: 768px) {
.login-prompt {
bottom: 10px;
right: 10px;
}
.btn:hover:not(:disabled),
.btn-primary:hover:not(:disabled),
.btn-secondary:hover:not(:disabled),
.btn-danger:hover:not(:disabled) {
transform: none;
.login-button-header {
padding: 0.6rem 1.2rem;
font-size: 0.9rem;
}
}

View File

@ -11,15 +11,13 @@ import Admin from './pages/Admin';
import HandlerLogin from './components/handler/HandlerLogin';
import HandlerDashboard from './components/handler/HandlerDashboard';
import InstallBanner from './components/common/InstallBanner';
import { ADMIN_PATH, RESET_PASSWORD_PATH, withBase, normalizePath } from './utils/constants';
import './App.css';
function App() {
// Die Pfade muessen den Deployment-Basispfad enthalten: produktiv laeuft die
// App unter /<app>/, ein Vergleich gegen '/verwaltung' traefe dort nie zu.
const currentPath = normalizePath(window.location.pathname);
const isAdminRoute = currentPath === normalizePath(withBase(ADMIN_PATH));
const isResetPasswordRoute = currentPath === normalizePath(withBase(RESET_PASSWORD_PATH));
const adminPath = process.env.REACT_APP_ADMIN_PATH || '/verwaltung';
const resetPasswordPath = '/passwort-zuruecksetzen';
const isAdminRoute = window.location.pathname === adminPath;
const isResetPasswordRoute = window.location.pathname === resetPasswordPath;
const [view, setView] = useState('public');
const [handlerUser, setHandlerUser] = useState(null);
const { isAuthenticated, loading: authLoading, login, logout } = useAuth();
@ -99,6 +97,7 @@ function App() {
isAdmin={false}
currentView={view}
onViewChange={handleViewChange}
onHandlerLogin={handleHandlerLogout}
/>
<main className="app-main">
{isAdminRoute || view === 'login' ? (

View File

@ -25,7 +25,7 @@
.tab-button.active {
background: var(--color-primary);
color: var(--color-on-primary);
color: white;
}
.settings-panel {
@ -59,9 +59,9 @@
.unsaved-badge {
display: inline-block;
background: var(--color-warning-bg);
color: var(--color-warning-text);
border: 1px solid var(--color-warning);
background: #fff3cd;
color: #856404;
border: 1px solid #ffc107;
border-radius: var(--radius-sm);
padding: 0.2rem 0.6rem;
font-size: 0.8rem;
@ -102,7 +102,7 @@
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--color-muted-bg, var(--color-surface-alt));
background: var(--color-muted-bg, #f5f5f0);
border: none;
padding: 0.75rem var(--space-2);
cursor: pointer;
@ -111,7 +111,7 @@
}
.settings-section-header:hover {
background: var(--color-focus, var(--color-success-bg));
background: var(--color-focus, #eef2e6);
}
.settings-section-header .settings-heading {
@ -193,7 +193,7 @@
.skeleton-line {
height: 1rem;
border-radius: var(--radius-sm);
background: linear-gradient(90deg, var(--color-surface-alt) 25%, var(--color-surface-alt) 50%, var(--color-surface-alt) 75%);
background: linear-gradient(90deg, #e8e8e4 25%, #f0f0ec 50%, #e8e8e4 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.4s infinite;
margin-bottom: 0.75rem;
@ -323,17 +323,17 @@
.notification.success {
background: var(--color-success);
color: var(--color-on-success);
color: white;
}
.notification.error {
background: var(--color-danger);
color: var(--color-on-danger);
color: white;
}
.notification.warning {
background: var(--color-warning);
color: var(--color-on-warning);
background: #e67e00;
color: white;
}
@keyframes slideIn {

View File

@ -3,7 +3,7 @@ import UserList from '../users/UserList';
import ExportButton from './ExportButton';
import Trash from './Trash';
import AuditLogs from './AuditLogs';
import { updateAvailability, updateGPS, createUser, updateUser, deleteUser, uploadUserPhoto, deleteUserPhoto, generateInviteToken } from '../../services/users';
import { updateAvailability, updateGPS, createUser, updateUser, deleteUser, uploadUserPhoto, deleteUserPhoto } from '../../services/users';
import { getFullConfig, updateConfig, uploadLogo, deleteLogo } from '../../services/config';
import ErrorMessage from '../common/ErrorMessage';
import './AdminPanel.css';
@ -127,16 +127,6 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
}
};
const handleGenerateInvite = async (id) => {
const result = await generateInviteToken(id);
if (result.success) {
showNotification('success', 'Einladungs-Token erzeugt (7 Tage gueltig)');
} else {
showNotification('error', result.message || 'Fehler beim Erzeugen des Einladungs-Tokens');
}
return result;
};
const handleConfigUpdate = async (updatedConfig) => {
try {
const response = await updateConfig(updatedConfig);
@ -197,7 +187,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
const file = e.target.files[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
showNotification('error', 'Nur Bilddateien erlaubt (JPG, PNG, WebP)');
showNotification('error', 'Nur Bilddateien erlaubt (JPG, PNG, SVG, ...)');
return;
}
if (file.size > 500 * 1024) {
@ -301,7 +291,6 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
onUserDelete={handleUserDelete}
onPhotoUpload={handleUserPhotoUpload}
onPhotoDelete={handleUserPhotoDelete}
onGenerateInvite={handleGenerateInvite}
/>
</>
)}
@ -419,7 +408,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
</button>
)}
</div>
<p className="settings-hint">Erlaubt: PNG, JPG oder WebP, max. 500 KB (SVG wird aus Sicherheitsgruenden abgelehnt)</p>
<p className="settings-hint">Empfohlen: PNG, SVG oder JPG, max. 500 KB</p>
</div>
)}
</div>

View File

@ -13,8 +13,8 @@
gap: 12px;
flex-wrap: wrap;
}
.audit-header h2 { margin: 0 0 4px 0; color: var(--color-text); font-size: 26px; }
.audit-description { margin: 0; color: var(--color-text-muted); font-size: 13px; }
.audit-header h2 { margin: 0 0 4px 0; color: #333; font-size: 26px; }
.audit-description { margin: 0; color: #666; font-size: 13px; }
.audit-header-actions {
display: flex;
@ -32,24 +32,24 @@
}
.btn-stats-toggle {
padding: 6px 14px;
background: var(--color-surface-alt);
border: 1px solid var(--color-border);
background: #f0f4f8;
border: 1px solid #d0d7de;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
color: var(--color-text-muted);
color: #444;
}
.btn-stats-toggle:hover { background: var(--color-surface-alt); }
.btn-stats-toggle:hover { background: #e2e8f0; }
.stats-days-select { font-size: 13px; }
.audit-stats {
background: var(--color-surface-alt);
border: 1px solid var(--color-border);
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 16px;
margin-bottom: 16px;
}
.stats-loading { color: var(--color-text-muted); font-size: 13px; padding: 8px 0; }
.stats-loading { color: #888; font-size: 13px; padding: 8px 0; }
.stats-row {
display: flex;
@ -60,18 +60,18 @@
.stat-card {
flex: 1;
min-width: 100px;
background: var(--color-surface);
background: white;
border-radius: 8px;
padding: 12px 16px;
text-align: center;
border: 1px solid var(--color-border);
border: 1px solid #e2e8f0;
}
.stat-number { font-size: 28px; font-weight: 700; line-height: 1; }
.stat-label { font-size: 11px; color: var(--color-text-muted); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
.stat-total .stat-number { color: var(--color-info-text); }
.stat-failed .stat-number { color: var(--color-danger-text); }
.stat-success .stat-number { color: var(--color-success-text); }
.stat-period .stat-number { color: var(--color-text-muted); font-size: 20px; }
.stat-label { font-size: 11px; color: #666; margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
.stat-total .stat-number { color: #1976d2; }
.stat-failed .stat-number { color: #c62828; }
.stat-success .stat-number { color: #2e7d32; }
.stat-period .stat-number { color: #555; font-size: 20px; }
.stats-details {
display: flex;
@ -79,7 +79,7 @@
flex-wrap: wrap;
}
.stats-col { flex: 1; min-width: 180px; }
.stats-col h4 { margin: 0 0 8px 0; font-size: 12px; text-transform: uppercase; color: var(--color-text-muted); letter-spacing: 0.5px; }
.stats-col h4 { margin: 0 0 8px 0; font-size: 12px; text-transform: uppercase; color: #888; letter-spacing: 0.5px; }
.stat-bar-row {
display: flex;
@ -88,8 +88,8 @@
gap: 8px;
margin-bottom: 5px;
}
.stat-bar-label { font-size: 13px; color: var(--color-text-muted); }
.stat-bar-count { font-size: 13px; font-weight: 600; color: var(--color-text); flex-shrink: 0; }
.stat-bar-label { font-size: 13px; color: #444; }
.stat-bar-count { font-size: 13px; font-weight: 600; color: #333; flex-shrink: 0; }
/* Mini bar chart */
.stats-chart-col { flex: 2; min-width: 220px; }
@ -98,7 +98,7 @@
align-items: flex-end;
gap: 3px;
height: 80px;
background: var(--color-surface-alt);
background: #f0f4f8;
border-radius: 6px;
padding: 6px 6px 0;
}
@ -110,7 +110,7 @@
}
.chart-bar {
width: 100%;
background: var(--color-info);
background: #1976d2;
border-radius: 2px 2px 0 0;
position: relative;
min-height: 4px;
@ -121,7 +121,7 @@
bottom: 0;
left: 0;
width: 100%;
background: var(--color-danger);
background: #e53935;
}
/* ── Filters ─────────────────────────────────────────────────────── */
@ -134,52 +134,52 @@
}
.filter-select {
padding: 8px 12px;
border: 1.5px solid var(--color-border);
border: 1.5px solid #d0d7de;
border-radius: 6px;
font-size: 13px;
background: var(--color-surface);
background: white;
cursor: pointer;
}
.filter-select:focus { outline: none; border-color: var(--color-primary); }
.filter-select:focus { outline: none; border-color: #1976d2; }
.filter-input {
padding: 8px 12px;
border: 1.5px solid var(--color-border);
border: 1.5px solid #d0d7de;
border-radius: 6px;
font-size: 13px;
background: var(--color-surface);
background: white;
min-width: 140px;
}
.filter-input:focus { outline: none; border-color: var(--color-primary); }
.filter-input:focus { outline: none; border-color: #1976d2; }
.filter-date { min-width: 130px; }
.btn-refresh {
padding: 8px 14px;
background: var(--color-info);
color: var(--color-on-info);
background: #1976d2;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
}
.btn-refresh:hover:not(:disabled) { background: var(--color-info-dark); }
.btn-refresh:disabled { background: var(--color-surface-alt); color: var(--color-text-muted); cursor: not-allowed; }
.btn-refresh:hover:not(:disabled) { background: #1565c0; }
.btn-refresh:disabled { background: #bdbdbd; cursor: not-allowed; }
.btn-reset {
padding: 8px 12px;
background: var(--color-surface);
color: var(--color-text-muted);
border: 1.5px solid var(--color-border);
background: white;
color: #666;
border: 1.5px solid #d0d7de;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.btn-reset:hover { background: var(--color-surface-alt); }
.btn-reset:hover { background: #f5f5f5; }
.btn-export {
padding: 8px 16px;
background: var(--color-success);
color: var(--color-on-success);
background: #2e7d32;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
@ -187,15 +187,15 @@
font-weight: 500;
white-space: nowrap;
}
.btn-export:hover:not(:disabled) { background: var(--color-success-dark); }
.btn-export:disabled { background: var(--color-surface-alt); color: var(--color-text-muted); cursor: not-allowed; }
.btn-export:hover:not(:disabled) { background: #1b5e20; }
.btn-export:disabled { background: #bdbdbd; cursor: not-allowed; }
.auto-refresh-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--color-text-muted);
color: #555;
cursor: pointer;
white-space: nowrap;
}
@ -206,7 +206,7 @@
justify-content: space-between;
align-items: center;
font-size: 13px;
color: var(--color-text-muted);
color: #666;
margin-bottom: 10px;
}
.limit-select { padding: 4px 8px; font-size: 13px; }
@ -224,16 +224,16 @@
}
.badge-create { background: #e8f5e9; color: #2e7d32; }
.badge-update { background: #e3f2fd; color: #1565c0; }
.badge-delete { background: #ffebee; color: var(--color-danger-text); }
.badge-restore { background: #fff3e0; color: #a83a00; }
.badge-delete { background: #ffebee; color: #c62828; }
.badge-restore { background: #fff3e0; color: #e65100; }
.badge-login { background: #f3e5f5; color: #6a1b9a; }
.badge-logout { background: #fce4ec; color: #880e4f; }
.badge-login-failed { background: #ffcdd2; color: #b71c1c; font-weight: 700; }
.badge-import { background: #e0f7fa; color: #00695c; }
.badge-export { background: #e8f5e9; color: #1b5e20; }
.badge-bulk-update { background: var(--color-surface-alt); color: #283593; }
.badge-bulk-update { background: #e8eaf6; color: #283593; }
.badge-bulk-delete { background: #fbe9e7; color: #bf360c; }
.badge-password { background: #fff8e1; color: #9c4e00; }
.badge-password { background: #fff8e1; color: #f57f17; }
.badge-default { background: #f5f5f5; color: #616161; }
.status-code {
@ -244,16 +244,16 @@
font-family: monospace;
}
.status-ok { background: #e8f5e9; color: #2e7d32; }
.status-redirect { background: #fff3e0; color: #a83a00; }
.status-error { background: #ffebee; color: var(--color-danger-text); }
.status-redirect { background: #fff3e0; color: #e65100; }
.status-error { background: #ffebee; color: #c62828; }
.status-server-error { background: #f3e5f5; color: #6a1b9a; }
.duration-badge {
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
background: var(--color-surface-alt);
color: var(--color-text-muted);
background: #f5f5f5;
color: #777;
font-family: monospace;
}
@ -261,24 +261,24 @@
.audit-empty {
text-align: center;
padding: 40px;
background: var(--color-surface-alt);
background: #f9f9f9;
border-radius: 8px;
color: var(--color-text-muted);
color: #888;
}
.audit-error { text-align: center; padding: 20px; }
.audit-error p { color: var(--color-danger-text); margin: 0; }
.audit-error p { color: #d32f2f; margin: 0; }
.audit-list { display: flex; flex-direction: column; gap: 8px; }
.audit-item {
background: var(--color-surface);
border: 1px solid var(--color-border);
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
transition: box-shadow 0.15s;
}
.audit-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.audit-item-failed { border-left: 4px solid var(--color-danger); }
.audit-item-failed { border-left: 4px solid #e53935; }
.audit-item-header {
display: flex;
@ -286,18 +286,18 @@
gap: 8px;
padding: 10px 14px;
flex-wrap: wrap;
background: var(--color-surface-alt);
border-bottom: 1px solid var(--color-border);
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
}
.audit-time { margin-left: auto; color: var(--color-text-muted); font-size: 12px; white-space: nowrap; }
.expand-toggle { color: var(--color-text-muted); font-size: 11px; cursor: pointer; padding: 0 4px; }
.audit-time { margin-left: auto; color: #999; font-size: 12px; white-space: nowrap; }
.expand-toggle { color: #aaa; font-size: 11px; cursor: pointer; padding: 0 4px; }
.audit-resource {
padding: 3px 8px;
background: var(--color-surface-alt);
background: #f0f0f0;
border-radius: 4px;
font-size: 12px;
color: var(--color-text-muted);
color: #555;
}
.audit-item-body {
@ -308,16 +308,16 @@
font-size: 13px;
align-items: center;
}
.audit-admin { color: var(--color-text); }
.audit-resource-name { color: var(--color-text-muted); }
.audit-ip { color: var(--color-text-muted); font-family: monospace; font-size: 12px; }
.audit-failed-badge { color: var(--color-danger-text); font-weight: 600; font-size: 12px; }
.audit-admin { color: #333; }
.audit-resource-name { color: #555; }
.audit-ip { color: #999; font-family: monospace; font-size: 12px; }
.audit-failed-badge { color: #c62828; font-weight: 600; font-size: 12px; }
/* ── Expanded detail ─────────────────────────────────────────────── */
.audit-item-detail {
padding: 10px 14px 14px;
border-top: 1px dashed var(--color-border);
background: var(--color-surface);
border-top: 1px dashed #e0e0e0;
background: #fefefe;
display: flex;
flex-direction: column;
gap: 8px;
@ -330,7 +330,7 @@
}
.detail-label {
font-weight: 600;
color: var(--color-text-muted);
color: #888;
font-size: 11px;
text-transform: uppercase;
min-width: 70px;
@ -338,23 +338,23 @@
}
.detail-row code {
font-family: monospace;
background: var(--color-surface-alt);
background: #f0f0f0;
padding: 2px 6px;
border-radius: 3px;
font-size: 12px;
}
.detail-ua-full {
color: var(--color-text-muted);
color: #aaa;
cursor: help;
font-size: 12px;
}
.detail-error { color: var(--color-danger-text); }
.detail-error .detail-label { color: var(--color-danger-text); }
.detail-error { color: #c62828; }
.detail-error .detail-label { color: #c62828; }
.meta-tag {
display: inline-block;
background: var(--color-surface-alt);
color: var(--color-info-text);
background: #e8eaf6;
color: #283593;
border-radius: 4px;
padding: 2px 8px;
font-size: 12px;
@ -370,17 +370,17 @@
margin-top: 6px;
}
.diff-table th {
background: var(--color-surface-alt);
background: #f0f0f0;
padding: 5px 10px;
text-align: left;
font-size: 11px;
text-transform: uppercase;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
color: #666;
border: 1px solid #e0e0e0;
}
.diff-table td { padding: 5px 10px; border: 1px solid var(--color-border); vertical-align: top; }
.diff-field { font-weight: 600; color: var(--color-text-muted); font-family: monospace; white-space: nowrap; background: var(--color-surface-alt); }
.diff-before { color: var(--color-danger-text); background: #fff5f5; font-family: monospace; word-break: break-all; }
.diff-table td { padding: 5px 10px; border: 1px solid #e8e8e8; vertical-align: top; }
.diff-field { font-weight: 600; color: #444; font-family: monospace; white-space: nowrap; background: #fafafa; }
.diff-before { color: #c62828; background: #fff5f5; font-family: monospace; word-break: break-all; }
.diff-after { color: #2e7d32; background: #f5fff5; font-family: monospace; word-break: break-all; }
/* ── Pagination ──────────────────────────────────────────────────── */
@ -393,16 +393,16 @@
}
.btn-page {
padding: 7px 14px;
border: 1.5px solid var(--color-border);
border: 1.5px solid #d0d7de;
border-radius: 6px;
background: var(--color-surface);
background: white;
cursor: pointer;
font-size: 13px;
color: var(--color-text);
color: #333;
}
.btn-page:hover:not(:disabled) { background: var(--color-surface-alt); border-color: var(--color-primary); }
.btn-page:disabled { color: var(--color-text-muted); cursor: not-allowed; border-color: var(--color-border); }
.page-info { font-size: 13px; color: var(--color-text-muted); padding: 0 8px; }
.btn-page:hover:not(:disabled) { background: #f0f4f8; border-color: #1976d2; }
.btn-page:disabled { color: #bbb; cursor: not-allowed; border-color: #eee; }
.page-info { font-size: 13px; color: #666; padding: 0 8px; }
.audit-header {
@ -411,13 +411,13 @@
.audit-header h2 {
margin: 0 0 8px 0;
color: var(--color-text);
color: #333;
font-size: 28px;
}
.audit-description {
margin: 0;
color: var(--color-text-muted);
color: #666;
font-size: 14px;
}
@ -428,7 +428,7 @@
}
.audit-error p {
color: var(--color-danger-text);
color: #d32f2f;
margin: 0;
}
@ -441,23 +441,23 @@
.filter-select {
padding: 10px 14px;
border: 2px solid var(--color-border);
border: 2px solid #e0e0e0;
border-radius: 6px;
font-size: 14px;
background: var(--color-surface);
background: white;
cursor: pointer;
transition: border-color 0.2s;
}
.filter-select:focus {
outline: none;
border-color: var(--color-primary);
border-color: #1976d2;
}
.btn-refresh {
padding: 10px 16px;
background: var(--color-info);
color: var(--color-on-info);
background: #1976d2;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
@ -467,21 +467,20 @@
}
.btn-refresh:hover:not(:disabled) {
background: var(--color-info-dark);
background: #1565c0;
}
.btn-refresh:disabled {
background: var(--color-surface-alt);
color: var(--color-text-muted);
background: #bdbdbd;
cursor: not-allowed;
}
.audit-empty {
text-align: center;
padding: 40px;
background: var(--color-surface-alt);
background: #f5f5f5;
border-radius: 8px;
color: var(--color-text-muted);
color: #666;
}
.audit-list {
@ -491,8 +490,8 @@
}
.audit-item {
background: var(--color-surface);
border: 1px solid var(--color-border);
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 16px;
transition: box-shadow 0.2s;
@ -520,18 +519,58 @@
gap: 4px;
}
.badge-create {
background: #e8f5e9;
color: #2e7d32;
}
.badge-update {
background: #e3f2fd;
color: #1565c0;
}
.badge-delete {
background: #ffebee;
color: #c62828;
}
.badge-restore {
background: #fff3e0;
color: #e65100;
}
.badge-login {
background: #f3e5f5;
color: #6a1b9a;
}
.badge-logout {
background: #fce4ec;
color: #880e4f;
}
.badge-login-failed {
background: #ffcdd2;
color: #b71c1c;
font-weight: 700;
}
.badge-default {
background: #f5f5f5;
color: #616161;
}
.audit-resource {
padding: 4px 10px;
background: var(--color-surface-alt);
background: #f5f5f5;
border-radius: 4px;
font-size: 13px;
color: var(--color-text-muted);
color: #666;
}
.audit-time {
margin-left: auto;
color: var(--color-text-muted);
color: #999;
font-size: 13px;
}
@ -543,19 +582,23 @@
}
.audit-admin {
color: var(--color-text);
color: #333;
}
.audit-resource-name {
color: var(--color-text-muted);
color: #666;
}
.audit-ip {
color: var(--color-text-muted);
color: #888;
font-family: monospace;
font-size: 13px;
}
.audit-error-message {
color: #d32f2f;
font-weight: 500;
}
.audit-pagination {
display: flex;
@ -564,15 +607,15 @@
gap: 16px;
margin-top: 24px;
padding: 16px;
background: var(--color-surface);
background: white;
border-radius: 8px;
border: 1px solid var(--color-border);
border: 1px solid #e0e0e0;
}
.btn-page {
padding: 8px 16px;
background: var(--color-info);
color: var(--color-on-info);
background: #1976d2;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
@ -581,17 +624,16 @@
}
.btn-page:hover:not(:disabled) {
background: var(--color-info-dark);
background: #1565c0;
}
.btn-page:disabled {
background: var(--color-surface-alt);
color: var(--color-text-muted);
background: #bdbdbd;
cursor: not-allowed;
}
.page-info {
color: var(--color-text-muted);
color: #666;
font-size: 14px;
}
@ -614,33 +656,3 @@
width: 100%;
}
}
/* Nachtvariante
Die Badges kodieren die Aktionsart über den Farbton der bleibt erhalten,
nur Helligkeit und Sättigung drehen sich um. Alle Paare >= 6.4:1. */
@media (prefers-color-scheme: dark) {
.badge-create { background: #162d17; color: #97d99a; }
.badge-update { background: #16202d; color: #89b5e6; }
.badge-delete { background: #2d1616; color: #e68989; }
.badge-restore { background: #2d1e16; color: #e6aa89; }
.badge-login { background: #24162d; color: #c389e6; }
.badge-logout { background: #2d1622; color: #e689bb; }
.badge-login-failed { background: #2d1616; color: #e68989; }
.badge-import { background: #162d2a; color: #89e6db; }
.badge-export { background: #162d17; color: #90df96; }
.badge-bulk-update { background: #16182d; color: #8f99e0; }
.badge-bulk-delete { background: #2d1b16; color: #e69f89; }
.badge-password { background: #2d2016; color: #e6b589; }
.badge-default { background: var(--color-surface-alt); color: var(--color-text-muted); }
.status-ok { background: #162d17; color: #97d99a; }
.status-redirect { background: #2d1e16; color: #e6aa89; }
.status-error { background: #2d1616; color: #e68989; }
.status-server-error { background: #24162d; color: #c389e6; }
/* Diff-Tabelle: die roten/gruenen Vorher-Nachher-Felder */
.diff-table th { background: var(--color-surface-alt); }
.diff-before { color: #e68989; background: #2d1616; }
.diff-after { color: #97d99a; background: #162d17; }
.diff-field { background: var(--color-surface-alt); color: var(--color-text); }
}

View File

@ -14,7 +14,7 @@
.export-button {
padding: 0.5rem 1rem;
background: var(--color-success);
color: var(--color-on-success);
color: white;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
@ -41,8 +41,8 @@
.import-button {
padding: 0.5rem 1rem;
background: var(--color-primary, var(--color-info));
color: var(--color-on-primary);
background: var(--color-primary, #2563eb);
color: white;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
@ -52,7 +52,7 @@
}
.import-button:hover:not(:disabled) {
background: var(--color-primary-dark, var(--color-info-dark));
background: var(--color-primary-dark, #1d4ed8);
}
.import-button:disabled {
@ -65,7 +65,7 @@
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
color: var(--color-text-muted);
color: var(--color-text-muted, #6b7280);
cursor: pointer;
white-space: nowrap;
}

View File

@ -10,13 +10,13 @@
.trash-header h2 {
margin: 0 0 8px 0;
color: var(--color-text);
color: #333;
font-size: 28px;
}
.trash-description {
margin: 0;
color: var(--color-text-muted);
color: #666;
font-size: 14px;
}
@ -27,14 +27,14 @@
}
.trash-error h3 {
color: var(--color-danger-text);
color: #d32f2f;
margin-bottom: 12px;
}
.btn-retry {
padding: 10px 20px;
background: var(--color-info);
color: var(--color-on-info);
background: #1976d2;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
@ -43,13 +43,13 @@
}
.btn-retry:hover {
background: var(--color-info-dark);
background: #1565c0;
}
.trash-empty {
text-align: center;
padding: 60px 20px;
background: var(--color-surface-alt);
background: #f5f5f5;
border-radius: 12px;
}
@ -60,7 +60,7 @@
}
.trash-empty p {
color: var(--color-text-muted);
color: #666;
font-size: 16px;
margin: 0;
}
@ -72,8 +72,8 @@
}
.trash-item {
background: var(--color-surface);
border: 2px solid var(--color-border);
background: #fff;
border: 2px solid #e0e0e0;
border-radius: 8px;
padding: 16px 20px;
display: flex;
@ -84,7 +84,7 @@
.trash-item:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border-color: var(--color-border);
border-color: #bdbdbd;
}
.trash-item-info {
@ -93,7 +93,7 @@
.trash-item-info h3 {
margin: 0 0 8px 0;
color: var(--color-text);
color: #333;
font-size: 18px;
font-weight: 600;
}
@ -106,8 +106,8 @@
}
.trash-item-type {
background: var(--color-info-bg);
color: var(--color-info-text);
background: #e3f2fd;
color: #1976d2;
padding: 4px 10px;
border-radius: 4px;
font-size: 13px;
@ -116,12 +116,12 @@
.trash-item-address,
.trash-item-phone {
color: var(--color-text-muted);
color: #666;
font-size: 14px;
}
.trash-item-meta {
color: var(--color-text-muted);
color: #999;
font-size: 13px;
}
@ -140,8 +140,8 @@
align-items: center;
gap: 6px;
padding: 10px 16px;
background: var(--color-success);
color: var(--color-on-success);
background: #4caf50;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
@ -151,7 +151,7 @@
}
.btn-restore:hover:not(:disabled) {
background: var(--color-success-dark);
background: #45a049;
transform: translateY(-1px);
}
@ -160,7 +160,7 @@
}
.btn-restore:disabled {
background: var(--color-border-strong);
background: #bdbdbd;
cursor: not-allowed;
}

View File

@ -56,7 +56,7 @@
width: 100%;
padding: 0.75rem;
background: var(--color-primary);
color: var(--color-on-primary);
color: white;
border: none;
border-radius: var(--radius-sm);
font-size: 1rem;
@ -76,8 +76,8 @@
.login-error {
padding: 0.75rem;
background: var(--color-danger-bg);
border: 1px solid var(--color-danger-border);
background: #ffebee;
border: 1px solid #ffcdd2;
border-radius: var(--radius-sm);
color: var(--color-danger);
margin-bottom: 1rem;

Some files were not shown because too many files have changed in this diff Show More