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: frontend:
build: build:
args: args:
- VITE_API_URL=https://api.yourdomain.com - REACT_APP_API_URL=https://api.yourdomain.com
``` ```
2. Rebuild erforderlich: 2. Rebuild erforderlich:

View File

@ -19,16 +19,3 @@ CORS_ORIGIN=http://localhost:3000
GEOCODE_URL=https://nominatim.openstreetmap.org/search GEOCODE_URL=https://nominatim.openstreetmap.org/search
GEOCODE_USER_AGENT=drohnenfuehrer-app/1.0 (admin@localhost) GEOCODE_USER_AGENT=drohnenfuehrer-app/1.0 (admin@localhost)
GEOCODE_MIN_DELAY_MS=1100 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'); logger.info('MongoDB verbunden');
} catch (error) { } 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); 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)', geocodeUserAgent: process.env.GEOCODE_USER_AGENT || 'drohnenfuehrer-app/1.0 (admin@localhost)',
geocodeMinDelayMs: parseInt(process.env.GEOCODE_MIN_DELAY_MS || '1100', 10), geocodeMinDelayMs: parseInt(process.env.GEOCODE_MIN_DELAY_MS || '1100', 10),
smtpConfigured: !!(process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS), 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, appUrl: process.env.APP_URL || process.env.CORS_ORIGIN?.split(',')[0] || 'http://localhost:8081'
// 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(/\/+$/, '')
}; };
// Validate required environment variables // Validate required environment variables
@ -51,21 +46,11 @@ if (config.nodeEnv === 'production') {
} }
}); });
// Check for insecure defaults in production. // Check for insecure defaults in production
// Nicht nur der eine Default-String: podman-compose.yml setzt z. B. if (config.jwtSecret === 'your-secret-key-change-in-production') {
// CHANGE_ME_IN_PRODUCTION, was eine reine Gleichheitspruefung durchlaesst. console.error('❌ Fehler: JWT_SECRET verwendet unsicheren Default-Wert!');
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');
process.exit(1); 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; module.exports = config;

View File

@ -1,7 +1,5 @@
const mongoose = require('mongoose');
const AuditLog = require('../models/AuditLog'); const AuditLog = require('../models/AuditLog');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { escapeCell } = require('../utils/csv');
/** /**
* Get all audit logs with pagination and filtering * Get all audit logs with pagination and filtering
@ -101,13 +99,8 @@ const getAdminActivity = async (req, res) => {
try { try {
const { adminId } = req.params; 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([ const stats = await AuditLog.aggregate([
// ObjectId ist seit bson 5 eine echte Klasse und braucht new. { $match: { adminId: require('mongoose').Types.ObjectId(adminId) } },
{ $match: { adminId: new mongoose.Types.ObjectId(adminId) } },
{ {
$group: { $group: {
_id: '$action', _id: '$action',
@ -221,6 +214,13 @@ const exportAuditLogs = async (req, res) => {
.limit(10000) .limit(10000)
.lean(); .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 = [ const header = [
'Zeitstempel', 'Aktion', 'Ressource', 'Ressourcen-Name', 'Admin', 'Zeitstempel', 'Aktion', 'Ressource', 'Ressourcen-Name', 'Admin',
'IP-Adresse', 'Methode', 'Pfad', 'Status-Code', 'Dauer (ms)', 'IP-Adresse', 'Methode', 'Pfad', 'Status-Code', 'Dauer (ms)',

View File

@ -40,7 +40,7 @@ const login = async (req, res) => {
// Generate token // Generate token
const token = jwt.sign( 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, config.jwtSecret,
{ expiresIn: config.jwtExpiresIn } { expiresIn: config.jwtExpiresIn }
); );
@ -81,7 +81,7 @@ const logout = async (req, res) => {
try { try {
// Log logout (get username from token if available) // Log logout (get username from token if available)
const username = req.user?.username || 'unknown'; const username = req.user?.username || 'unknown';
await auditAuth(req, true, username, null, 'LOGOUT'); await auditAuth(req, true, username, null);
// Clear the token cookie // Clear the token cookie
res.clearCookie('token', { res.clearCookie('token', {
@ -119,9 +119,7 @@ const forgotPassword = async (req, res) => {
} }
// Find admin // Find admin
// case-insensitive wie beim Login: ein als "Thorsten" angelegtes Konto const admin = await Admin.findOne({ username });
// konnte sich als "thorsten" anmelden, aber kein Passwort zuruecksetzen.
const admin = await Admin.findOne({ username: { $regex: new RegExp(`^${String(username).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i') } });
// Don't reveal if user exists (security best practice) // Don't reveal if user exists (security best practice)
if (!admin) { if (!admin) {
@ -195,13 +193,11 @@ const resetPassword = async (req, res) => {
}); });
} }
// Muss zu minlength im Admin-Schema passen. Vorher stand hier 6: Passwoerter // Validate password length
// mit 6-11 Zeichen kamen durch und scheiterten erst an der Mongoose- if (newPassword.length < 6) {
// Validierung, was als 500 "Serverfehler" beim Nutzer ankam.
if (newPassword.length < 12) {
return res.status(400).json({ return res.status(400).json({
success: false, 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 User = require('../models/User');
const { geocodeAddress } = require('../utils/geocode'); const { geocodeAddress } = require('../utils/geocode');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { escapeCell } = require('../utils/csv');
const config = require('../config/env'); const config = require('../config/env');
const ALLOWED_USER_FIELDS = ['name', 'type', 'address', 'phone', 'landline', 'email', 'available', 'gps', 'notes']; 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 }) .sort(req.query.search ? { score: { $meta: 'textScore' } } : { name: 1 })
.skip(skip) .skip(skip)
.limit(limit) .limit(limit)
// Kontaktdaten sind der Zweck der oeffentlichen Liste; E-Mail, Hashes .select('name type available gps'),
// und Invite-Felder bleiben ausgeschlossen.
.select('name type available gps phone landline address photo'),
User.countDocuments(filter) User.countDocuments(filter)
]); ]);
@ -414,6 +411,14 @@ const exportUsers = async (req, res) => {
.select('-__v -passwordHash -deleted -deletedAt -deletedBy'); .select('-__v -passwordHash -deleted -deletedAt -deletedBy');
if (format === 'csv') { 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 = [ const csv = [
['Name', 'Adresse', 'Telefon', 'Festnetz', 'E-Mail', 'Typ', 'Verfügbar', 'Latitude', 'Longitude'].join(','), ['Name', 'Adresse', 'Telefon', 'Festnetz', 'E-Mail', 'Typ', 'Verfügbar', 'Latitude', 'Longitude'].join(','),
...users.map(user => [ ...users.map(user => [
@ -585,10 +590,8 @@ const bulkUpdateUsers = async (req, res) => {
} }
// Perform bulk update // 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( const result = await User.updateMany(
{ _id: { $in: ids }, deleted: { $ne: true } }, { _id: { $in: ids } },
{ $set: updateFields } { $set: updateFields }
); );
@ -626,10 +629,8 @@ const bulkDeleteUsers = async (req, res) => {
} }
// Soft delete all users // Soft delete all users
// Bereits geloeschte Eintraege bleiben unangetastet, damit
// deletedAt/deletedBy nicht ueberschrieben werden.
const result = await User.updateMany( const result = await User.updateMany(
{ _id: { $in: ids }, deleted: { $ne: true } }, { _id: { $in: ids } },
{ {
$set: { $set: {
deleted: true, deleted: true,

View File

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

View File

@ -1,7 +1,6 @@
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const config = require('../config/env'); const config = require('../config/env');
// Verifiziert das Token und stellt sicher, dass es sich um ein Admin-Token handelt.
const authenticateToken = (req, res, next) => { const authenticateToken = (req, res, next) => {
// Try to get token from cookie first (new secure method) // Try to get token from cookie first (new secure method)
let token = req.cookies?.token; let token = req.cookies?.token;
@ -21,7 +20,6 @@ const authenticateToken = (req, res, next) => {
try { try {
const decoded = jwt.verify(token, config.jwtSecret); const decoded = jwt.verify(token, config.jwtSecret);
// Reject tokens issued by a different app (C-01 cross-app auth fix) // Reject tokens issued by a different app (C-01 cross-app auth fix)
if (decoded.app && decoded.app !== config.appName) { if (decoded.app && decoded.app !== config.appName) {
return res.status(403).json({ return res.status(403).json({
@ -29,20 +27,6 @@ const authenticateToken = (req, res, next) => {
message: 'Ungültiger oder abgelaufener Token.' 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; req.user = decoded;
next(); next();
} catch (error) { } catch (error) {
@ -53,30 +37,4 @@ const authenticateToken = (req, res, next) => {
} }
}; };
// Wie authenticateToken, blockiert aber nicht: setzt req.user wenn ein gültiges module.exports = { authenticateToken };
// 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 };

View File

@ -43,7 +43,7 @@ const authLimiter = rateLimit({
// Strict rate limiter for invite / set-password endpoints // Strict rate limiter for invite / set-password endpoints
const inviteLimiter = rateLimit({ const inviteLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, max: 10, // Max 10 attempts per windowMs
skipSuccessfulRequests: true, skipSuccessfulRequests: true,
message: { message: {
success: false, 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 = { module.exports = {
apiLimiter, apiLimiter,
authLimiter, authLimiter,
inviteLimiter, inviteLimiter
geocodeLimiter
}; };

View File

@ -1,11 +1,11 @@
{ {
"name": "drohnenfuehrer-backend", "name": "tracking-leaders-backend",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "drohnenfuehrer-backend", "name": "tracking-leaders-backend",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
@ -15,12 +15,9 @@
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^8.2.1", "express-rate-limit": "^8.2.1",
"express-validator": "^7.3.1", "express-validator": "^7.3.1",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"mongoose": "^7.5.0", "mongoose": "^7.5.0",
"nodemailer": "^6.9.16", "winston": "^3.19.0"
"winston": "^3.19.0",
"winston-daily-rotate-file": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
"jest": "^30.2.0", "jest": "^30.2.0",
@ -2883,15 +2880,6 @@
"integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==",
"license": "MIT" "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": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@ -3254,18 +3242,6 @@
"node": ">= 0.4" "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": { "node_modules/html-escaper": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@ -4603,15 +4579,6 @@
"node": ">=16 || 14 >=14.17" "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": { "node_modules/mongodb": {
"version": "5.9.2", "version": "5.9.2",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-5.9.2.tgz", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-5.9.2.tgz",
@ -4787,15 +4754,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/nodemon": {
"version": "3.1.11", "version": "3.1.11",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
@ -4882,15 +4840,6 @@
"node": ">=0.10.0" "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": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@ -6300,24 +6249,6 @@
"node": ">= 12.0.0" "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": { "node_modules/winston-transport": {
"version": "4.9.0", "version": "4.9.0",
"resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", "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 router = express.Router();
const { login, logout, forgotPassword, resetPassword } = require('../controllers/authController'); const { login, logout, forgotPassword, resetPassword } = require('../controllers/authController');
const { validateLogin } = require('../middleware/validator'); const { validateLogin } = require('../middleware/validator');
const { attachUserIfPresent } = require('../middleware/auth');
router.post('/login', validateLogin, login); router.post('/login', validateLogin, login);
// attachUserIfPresent statt authenticateToken: der Logout muss auch mit router.post('/logout', logout);
// abgelaufenem Token funktionieren, soll den Benutzernamen aber protokollieren.
router.post('/logout', attachUserIfPresent, logout);
router.post('/forgot-password', forgotPassword); router.post('/forgot-password', forgotPassword);
router.post('/reset-password', resetPassword); router.post('/reset-password', resetPassword);

View File

@ -3,7 +3,6 @@ const router = express.Router();
const { authenticateToken } = require('../middleware/auth'); const { authenticateToken } = require('../middleware/auth');
const { auditLog } = require('../middleware/auditLogger'); const { auditLog } = require('../middleware/auditLogger');
const { validateGPS, validateAvailability } = require('../middleware/validator'); const { validateGPS, validateAvailability } = require('../middleware/validator');
const { geocodeLimiter } = require('../middleware/rateLimiter');
const { const {
getAllUsers, getAllUsers,
getUserById, getUserById,
@ -26,19 +25,13 @@ const {
// Public routes // Public routes
router.get('/public/users', getPublicUsers); router.get('/public/users', getPublicUsers);
// Eigenes, engeres Limit: der Endpunkt loest ausgehende Nominatim-Anfragen aus. router.get('/public/geocode', getGeocodeByPostalCode);
router.get('/public/geocode', geocodeLimiter, getGeocodeByPostalCode);
// Protected routes (require authentication) // Protected routes (require authentication)
router.get('/users', authenticateToken, getAllUsers); router.get('/users', authenticateToken, getAllUsers);
router.get('/users/export', authenticateToken, auditLog('EXPORT', 'User'), exportUsers); router.get('/users/export', authenticateToken, auditLog('EXPORT', 'User'), exportUsers);
router.post('/users/import', authenticateToken, auditLog('IMPORT', 'User'), importUsers); router.post('/users/import', authenticateToken, auditLog('IMPORT', 'User'), importUsers);
router.get('/users/deleted', authenticateToken, getDeletedUsers); 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.get('/users/:id', authenticateToken, getUserById);
router.post('/users', authenticateToken, auditLog('CREATE', 'User'), createUser); router.post('/users', authenticateToken, auditLog('CREATE', 'User'), createUser);
router.put('/users/:id', authenticateToken, auditLog('UPDATE', 'User'), updateUser); 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); router.delete('/users/:id/photo', authenticateToken, auditLog('UPDATE', 'User'), deleteUserPhoto);
// Bulk operations // 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; module.exports = router;

View File

@ -9,10 +9,7 @@ const users = [];
const seedDatabase = async () => { const seedDatabase = async () => {
try { try {
// Beim Aufruf aus server.js besteht die Verbindung bereits.
if (mongoose.connection.readyState !== 1) {
await mongoose.connect(config.mongoUri); await mongoose.connect(config.mongoUri);
}
logger.info('MongoDB verbunden für Seeding...'); logger.info('MongoDB verbunden für Seeding...');
@ -54,8 +51,6 @@ const seedDatabase = async () => {
} }
// Seed config - always update userTypes + sections + rules // 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 existingConfig = await Config.findOne();
const configData = { const configData = {
userTypes: [ userTypes: [
@ -95,23 +90,22 @@ const seedDatabase = async () => {
await Config.create(configData); await Config.create(configData);
logger.info('✅ Konfiguration erstellt'); logger.info('✅ Konfiguration erstellt');
} else { } else {
logger.info(' Konfiguration existiert bereits, bleibt unveraendert'); await Config.findOneAndUpdate({}, configData, { new: true });
logger.info('✅ Konfiguration aktualisiert');
} }
logger.info('✅ Datenbank-Seeding abgeschlossen'); logger.info('✅ Datenbank-Seeding abgeschlossen');
await mongoose.connection.close();
process.exit(0);
} catch (error) { } catch (error) {
logger.error('❌ Fehler beim Seeding:', 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) { if (require.main === module) {
seedDatabase() seedDatabase();
.then(async () => { await mongoose.connection.close(); process.exit(0); })
.catch(async () => { await mongoose.connection.close(); process.exit(1); });
} }
module.exports = seedDatabase; module.exports = seedDatabase;

View File

@ -16,17 +16,11 @@ const connectWithRetry = async () => {
try { try {
await connectDB(); await connectDB();
// Seeding legt Admin-Konto und Grundkonfiguration an. Die Bedingung darf sich // Seed database if empty (runs in all environments on first start)
// NICHT an der User-Zahl orientieren: die Seed-Liste ist bewusst leer, dadurch const User = require('./models/User');
// lief das Seeding bei jedem Start erneut. const userCount = await User.countDocuments();
const Admin = require('./models/Admin'); if (userCount === 0) {
const Config = require('./models/Config'); logger.info('Datenbank ist leer, starte Seeding...');
const [adminCount, configCount] = await Promise.all([
Admin.countDocuments(),
Config.countDocuments()
]);
if (adminCount === 0 || configCount === 0) {
logger.info('Admin oder Konfiguration fehlt, starte Seeding...');
try { try {
const seed = require('./seed'); const seed = require('./seed');
await 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 PORT = config.port;
const server = app.listen(PORT, () => { const server = app.listen(PORT, () => {
logger.info(`Server läuft auf Port ${PORT} (${config.nodeEnv})`); logger.info(`Server läuft auf Port ${PORT} (${config.nodeEnv})`);
}); });
// Graceful shutdown (Docker stop / Kubernetes rolling restart) // Graceful shutdown on SIGTERM (Docker stop / Kubernetes rolling restart)
let shuttingDown = false; process.on('SIGTERM', () => {
const shutdown = (signal) => { logger.info('SIGTERM empfangen, fahre Server herunter...');
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();
server.close(() => { server.close(() => {
logger.info('HTTP-Server geschlossen'); logger.info('HTTP-Server geschlossen');
mongoose.connection.close(false).then(() => { mongoose.connection.close(false).then(() => {
@ -114,13 +101,9 @@ const shutdown = (signal) => {
process.exit(0); process.exit(0);
}).catch(() => process.exit(1)); }).catch(() => process.exit(1));
}); });
}; });
process.on('SIGTERM', () => shutdown('SIGTERM')); // If a frontend build exists, serve it as static files (useful for local testing)
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.
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const buildPath = path.join(__dirname, '..', 'frontend', 'build'); const buildPath = path.join(__dirname, '..', 'frontend', 'build');
@ -134,6 +117,3 @@ if (fs.existsSync(buildPath)) {
res.sendFile(path.join(buildPath, 'index.html')); 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_FILE = path.join(__dirname, '..', 'geocode-cache.json');
const CACHE_SAVE_INTERVAL = 60000; // Save every 60 seconds const CACHE_SAVE_INTERVAL = 60000; // Save every 60 seconds
const CACHE_MAX_ENTRIES = 1000;
const cache = new Map(); const cache = new Map();
let lastRequestTime = 0; let lastRequestTime = 0;
let cacheModified = false; 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 // Load cache from file on startup
const loadCache = async () => { const loadCache = async () => {
try { try {
const data = await fs.readFile(CACHE_FILE, 'utf8'); const data = await fs.readFile(CACHE_FILE, 'utf8');
const parsed = JSON.parse(data); const parsed = JSON.parse(data);
// Nur die letzten CACHE_MAX_ENTRIES übernehmen eine früher unbegrenzt Object.entries(parsed).forEach(([key, value]) => {
// gewachsene Datei darf den Cache nicht wieder aufblähen.
const entries = Object.entries(parsed).slice(-CACHE_MAX_ENTRIES);
entries.forEach(([key, value]) => {
cache.set(key, value); cache.set(key, value);
}); });
logger.info(`Geocoding cache loaded: ${cache.size} entries`); logger.info(`Geocoding cache loaded: ${cache.size} entries`);
@ -69,13 +45,14 @@ const saveCache = async () => {
setInterval(saveCache, CACHE_SAVE_INTERVAL).unref(); setInterval(saveCache, CACHE_SAVE_INTERVAL).unref();
// Save on process exit // Save on process exit
// Cache beim Herunterfahren sichern ohne process.exit(): das Beenden gehört process.on('SIGINT', async () => {
// dem Shutdown-Handler in server.js, der sonst mittendrin abgeschnitten wird. await saveCache();
const flushOnShutdown = () => { process.exit(0);
saveCache().catch(err => logger.error('Failed to flush geocoding cache:', err.message)); });
}; process.on('SIGTERM', async () => {
process.on('SIGINT', flushOnShutdown); await saveCache();
process.on('SIGTERM', flushOnShutdown); process.exit(0);
});
// Initialize cache loading // Initialize cache loading
loadCache().catch(err => logger.error('Cache initialization error:', err)); loadCache().catch(err => logger.error('Cache initialization error:', err));
@ -112,12 +89,7 @@ const geocodeAddress = async (address) => {
const cacheKey = normalized.toLowerCase(); const cacheKey = normalized.toLowerCase();
if (cache.has(cacheKey)) { if (cache.has(cacheKey)) {
// Treffer ans Ende rücken, damit die Verdrängung wirklich den am längsten return cache.get(cacheKey);
// ungenutzten Eintrag trifft und nicht bloß den ältesten eingefügten.
const hit = cache.get(cacheKey);
cache.delete(cacheKey);
cache.set(cacheKey, hit);
return hit;
} }
const elapsed = Date.now() - lastRequestTime; const elapsed = Date.now() - lastRequestTime;
@ -135,7 +107,8 @@ const geocodeAddress = async (address) => {
lastRequestTime = Date.now(); lastRequestTime = Date.now();
if (!Array.isArray(results) || results.length === 0) { if (!Array.isArray(results) || results.length === 0) {
rememberInCache(cacheKey, null); cache.set(cacheKey, null);
cacheModified = true;
return null; return null;
} }
@ -144,12 +117,15 @@ const geocodeAddress = async (address) => {
const lng = parseFloat(hit.lon); const lng = parseFloat(hit.lon);
if (Number.isNaN(lat) || Number.isNaN(lng)) { if (Number.isNaN(lat) || Number.isNaN(lng)) {
rememberInCache(cacheKey, null); cache.set(cacheKey, null);
cacheModified = true;
return null; return null;
} }
const coords = { lat, lng }; 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; return coords;
} catch (error) { } catch (error) {
logger.warn('Geocoding fehlgeschlagen', { logger.warn('Geocoding fehlgeschlagen', {

View File

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

View File

@ -183,4 +183,4 @@ frontend/src/
- `CORS_ORIGIN`: Erlaubter CORS-Origin - `CORS_ORIGIN`: Erlaubter CORS-Origin
### Frontend (.env) ### 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 # API Configuration
# For local development # For local development
VITE_API_URL=http://localhost:5000 REACT_APP_API_URL=http://localhost:5000
# For production, use your actual backend URL # 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) # 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/ ARG PUBLIC_URL=/drohnenfuehrer/
ENV PUBLIC_URL=$PUBLIC_URL ENV PUBLIC_URL=$PUBLIC_URL
ARG VITE_API_URL= ARG REACT_APP_API_URL=
ENV VITE_API_URL=$VITE_API_URL ENV REACT_APP_API_URL=$REACT_APP_API_URL
# Limit Node.js heap during build to prevent OOM kills # Limit Node.js heap during build to prevent OOM kills
ENV NODE_OPTIONS="--max_old_space_size=512" ENV NODE_OPTIONS="--max_old_space_size=512"

View File

@ -1,18 +1,16 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <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" /> <link rel="apple-touch-icon" href="%BASE_URL%icons/icon-192.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <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-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" /> <meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Drohnenführer" /> <meta name="apple-mobile-web-app-title" content="Drohnenführer" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<!-- Pfad relativ zur Deployment-Basis: ein absolutes "/manifest.json" wuerde <link rel="manifest" href="/manifest.json" />
das Portal-Manifest laden und die App als Portal installieren. -->
<link rel="manifest" href="%BASE_URL%manifest.json" />
<meta <meta
name="description" name="description"
content="Drohnenführer Heidekreis Übersicht der Drohnenführer" 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_types text/plain text/xml application/xml+rss application/json;
gzip_disable "msie6"; gzip_disable "msie6";
# Security headers. # 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.
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;
# Cache static assets (images, fonts) # Cache static assets (images, fonts)
location ~* \.(jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$ { 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; expires 1y;
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
} }
# JS and CSS - no compression, short cache # JS and CSS - no compression, short cache
location ~* \.(js|css)$ { 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; expires 1h;
add_header Cache-Control "public, no-transform"; add_header Cache-Control "public, no-transform";
gzip off; gzip off;
@ -56,9 +47,6 @@ server {
# Service Worker - never cache # Service Worker - never cache
location = /sw.js { 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"; add_header Cache-Control "no-cache, no-store, must-revalidate";
expires 0; expires 0;
} }
@ -91,9 +79,6 @@ server {
# Service Worker for subpath deployment # Service Worker for subpath deployment
location = /drohnenfuehrer/sw.js { 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"; add_header Cache-Control "no-cache, no-store, must-revalidate";
expires 0; expires 0;
alias /usr/share/nginx/html/sw.js; alias /usr/share/nginx/html/sw.js;
@ -101,9 +86,6 @@ server {
# index.html - never cache so new builds are picked up immediately # index.html - never cache so new builds are picked up immediately
location = /index.html { 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 Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache"; add_header Pragma "no-cache";
expires 0; expires 0;
@ -111,9 +93,6 @@ server {
# SPA fallback for subpath deployment (/drohnenfuehrer) # SPA fallback for subpath deployment (/drohnenfuehrer)
location ^~ /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; rewrite ^/drohnenfuehrer(/.*)$ $1 break;
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header Cache-Control "no-cache, no-store, must-revalidate";
@ -123,9 +102,6 @@ server {
# SPA fallback - serve index.html for all routes # SPA fallback - serve index.html for all routes
location / { 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; try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache"; add_header Pragma "no-cache";
@ -134,9 +110,6 @@ server {
# Health check endpoint # Health check endpoint
location /health { 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; access_log off;
return 200 "OK\n"; return 200 "OK\n";
add_header Content-Type text/plain; 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", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "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", "axios": "^1.13.5",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^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": { "scripts": {
"start": "vite", "start": "react-scripts start",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
}, },
"browserslist": { "browserslist": {
"production": [ "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", "short_name": "Drohnenführer",
"name": "Drohnenführer Heidekreis", "name": "Drohnenführer Heidekreis",
"icons": [ "icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{ {
"src": "icons/icon-192.png", "src": "icons/icon-192.png",
"sizes": "192x192", "sizes": "192x192",
@ -19,7 +24,7 @@
"scope": "/drohnenfuehrer/", "scope": "/drohnenfuehrer/",
"display": "standalone", "display": "standalone",
"orientation": "portrait", "orientation": "portrait",
"theme_color": "#1a3d1a", "theme_color": "#2d6a2d",
"background_color": "#e8e8e2", "background_color": "#ffffff",
"description": "Drohnenführer Heidekreis Übersicht der Drohnenführer" "description": "Drohnenführer Heidekreis Übersicht der Drohnenführer"
} }

View File

@ -1,93 +1,16 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#1a3d1a" /> <title>Offline - Drohnenführer</title>
<title>Offline Jagd Apps Heidekreis</title>
<style> <style>
/* Eigenständige Seite: sie wird vom Service Worker ausgeliefert, wenn das body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
Netz fehlt, und kann deshalb keine Stylesheets der App nachladen. h1 { color: #333; }
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; }
</style> </style>
</head> </head>
<body> <body>
<div class="card"> <h1>Du bist offline</h1>
<div class="icon" role="img" aria-label="Kein Empfang">📡</div> <p>Die App ist derzeit nicht verfügbar. Bitte überprüfe deine Internetverbindung.</p>
<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>
</body> </body>
</html> </html>

View File

@ -2,203 +2,43 @@
box-sizing: border-box; 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 { :root {
--font-display: Georgia, 'Times New Roman', serif; --color-primary: #2e8b2e;
--font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', --color-primary-dark: #1e6b1e;
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; --color-primary-accent: #8B0D12;
--font-mono: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; --color-secondary: #6c757d;
--color-secondary-dark: #5a6268;
/* Flächen */ --color-success: #28a745;
--color-bg: #e8e8e2; --color-success-dark: #218838;
--color-surface: #f4f4ee; --color-danger: #dc3545;
--color-surface-alt: #dedcd2; --color-danger-dark: #c82333;
--color-muted-bg: #dedcd2; --color-bg: #f5f5f0;
--color-surface: #ffffff;
/* Text */
--color-text: #1a1a1a; --color-text: #1a1a1a;
--color-text-muted: #55554c; --color-text-muted: #555;
--color-border: #b8d4b8;
/* Rahmen: -border ist dekorativ (Trennlinien, Karten), --color-border-strong: #88b888;
-border-strong begrenzt Bedienelemente und erfüllt die 3:1-Anforderung. */ --color-focus: rgba(46, 139, 46, 0.2);
--color-border: #c9c9b8; --color-muted-bg: #f5f5f0;
--color-border-strong: #7d7d68; --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.2);
/* Aktionsfarben jeweils mit der zugehörigen Textfarbe, damit die --radius-sm: 4px;
Dunkelvariante nicht auf weißem Text auf hellem Grund landet. */ --radius-md: 8px;
--color-primary: #2d5a2d; --radius-pill: 12px;
--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;
--space-1: 0.5rem; --space-1: 0.5rem;
--space-2: 1rem; --space-2: 1rem;
--space-3: 1.5rem; --space-3: 1.5rem;
--space-4: 2rem; --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 { .btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: var(--space-1); gap: var(--space-1);
min-height: var(--touch-target);
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border: none; border: none;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-family: var(--font-ui);
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
@ -206,24 +46,15 @@ code {
} }
.btn:disabled { .btn:disabled {
background: var(--color-surface-alt); background: var(--color-border);
color: var(--color-text-muted); color: var(--color-text-muted);
cursor: not-allowed; cursor: not-allowed;
box-shadow: none; 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 { .btn-primary {
background: var(--color-primary); background: var(--color-primary);
color: var(--color-on-primary); color: white;
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
@ -233,7 +64,7 @@ a:focus-visible {
.btn-secondary { .btn-secondary {
background: var(--color-secondary); background: var(--color-secondary);
color: var(--color-on-secondary); color: white;
} }
.btn-secondary:hover:not(:disabled) { .btn-secondary:hover:not(:disabled) {
@ -241,9 +72,19 @@ a:focus-visible {
transform: translateY(-1px); 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 { .btn-danger {
background: var(--color-danger); background: var(--color-danger);
color: var(--color-on-danger); color: white;
} }
.btn-danger:hover:not(:disabled) { .btn-danger:hover:not(:disabled) {
@ -255,13 +96,9 @@ a:focus-visible {
.select, .select,
.textarea { .textarea {
width: 100%; width: 100%;
min-height: var(--touch-target);
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border-strong); border: 1px solid var(--color-border-strong);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-family: var(--font-ui);
font-size: 1rem; font-size: 1rem;
box-sizing: border-box; box-sizing: border-box;
} }
@ -274,19 +111,33 @@ a:focus-visible {
box-shadow: 0 0 0 3px var(--color-focus); 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 { .panel-title {
margin: 0 0 var(--space-2); margin: 0 0 var(--space-2);
font-family: var(--font-display);
font-size: 1.1rem; font-size: 1.1rem;
color: var(--color-text); 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 { .App {
/* dvh statt vh: mit ein- und ausblendender Adressleiste auf Mobilgeräten min-height: 100vh;
entsteht mit vh sonst Überlauf. */
min-height: 100dvh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@ -300,26 +151,44 @@ a:focus-visible {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
min-height: 100dvh; min-height: 100vh;
font-size: 1.2rem; font-size: 1.2rem;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
/* Wer Bewegung reduziert haben möchte, bekommt keine Animationen. */ .login-prompt {
@media (prefers-reduced-motion: reduce) { position: fixed;
*, bottom: 20px;
*::before, right: 20px;
*::after { }
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important; .login-button-header {
transition-duration: 0.01ms !important; padding: 0.75rem 1.5rem;
scroll-behavior: auto !important; 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), .login-button-header {
.btn-primary:hover:not(:disabled), padding: 0.6rem 1.2rem;
.btn-secondary:hover:not(:disabled), font-size: 0.9rem;
.btn-danger:hover:not(:disabled) {
transform: none;
} }
} }

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import UserList from '../users/UserList';
import ExportButton from './ExportButton'; import ExportButton from './ExportButton';
import Trash from './Trash'; import Trash from './Trash';
import AuditLogs from './AuditLogs'; 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 { getFullConfig, updateConfig, uploadLogo, deleteLogo } from '../../services/config';
import ErrorMessage from '../common/ErrorMessage'; import ErrorMessage from '../common/ErrorMessage';
import './AdminPanel.css'; 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) => { const handleConfigUpdate = async (updatedConfig) => {
try { try {
const response = await updateConfig(updatedConfig); const response = await updateConfig(updatedConfig);
@ -197,7 +187,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
const file = e.target.files[0]; const file = e.target.files[0];
if (!file) return; if (!file) return;
if (!file.type.startsWith('image/')) { if (!file.type.startsWith('image/')) {
showNotification('error', 'Nur Bilddateien erlaubt (JPG, PNG, WebP)'); showNotification('error', 'Nur Bilddateien erlaubt (JPG, PNG, SVG, ...)');
return; return;
} }
if (file.size > 500 * 1024) { if (file.size > 500 * 1024) {
@ -301,7 +291,6 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
onUserDelete={handleUserDelete} onUserDelete={handleUserDelete}
onPhotoUpload={handleUserPhotoUpload} onPhotoUpload={handleUserPhotoUpload}
onPhotoDelete={handleUserPhotoDelete} onPhotoDelete={handleUserPhotoDelete}
onGenerateInvite={handleGenerateInvite}
/> />
</> </>
)} )}
@ -419,7 +408,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
</button> </button>
)} )}
</div> </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>
)} )}
</div> </div>

View File

@ -13,8 +13,8 @@
gap: 12px; gap: 12px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.audit-header h2 { margin: 0 0 4px 0; color: var(--color-text); font-size: 26px; } .audit-header h2 { margin: 0 0 4px 0; color: #333; font-size: 26px; }
.audit-description { margin: 0; color: var(--color-text-muted); font-size: 13px; } .audit-description { margin: 0; color: #666; font-size: 13px; }
.audit-header-actions { .audit-header-actions {
display: flex; display: flex;
@ -32,24 +32,24 @@
} }
.btn-stats-toggle { .btn-stats-toggle {
padding: 6px 14px; padding: 6px 14px;
background: var(--color-surface-alt); background: #f0f4f8;
border: 1px solid var(--color-border); border: 1px solid #d0d7de;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
font-size: 13px; 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; } .stats-days-select { font-size: 13px; }
.audit-stats { .audit-stats {
background: var(--color-surface-alt); background: #f8fafc;
border: 1px solid var(--color-border); border: 1px solid #e2e8f0;
border-radius: 10px; border-radius: 10px;
padding: 16px; padding: 16px;
margin-bottom: 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 { .stats-row {
display: flex; display: flex;
@ -60,18 +60,18 @@
.stat-card { .stat-card {
flex: 1; flex: 1;
min-width: 100px; min-width: 100px;
background: var(--color-surface); background: white;
border-radius: 8px; border-radius: 8px;
padding: 12px 16px; padding: 12px 16px;
text-align: center; 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-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-label { font-size: 11px; color: #666; margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
.stat-total .stat-number { color: var(--color-info-text); } .stat-total .stat-number { color: #1976d2; }
.stat-failed .stat-number { color: var(--color-danger-text); } .stat-failed .stat-number { color: #c62828; }
.stat-success .stat-number { color: var(--color-success-text); } .stat-success .stat-number { color: #2e7d32; }
.stat-period .stat-number { color: var(--color-text-muted); font-size: 20px; } .stat-period .stat-number { color: #555; font-size: 20px; }
.stats-details { .stats-details {
display: flex; display: flex;
@ -79,7 +79,7 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.stats-col { flex: 1; min-width: 180px; } .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 { .stat-bar-row {
display: flex; display: flex;
@ -88,8 +88,8 @@
gap: 8px; gap: 8px;
margin-bottom: 5px; margin-bottom: 5px;
} }
.stat-bar-label { font-size: 13px; color: var(--color-text-muted); } .stat-bar-label { font-size: 13px; color: #444; }
.stat-bar-count { font-size: 13px; font-weight: 600; color: var(--color-text); flex-shrink: 0; } .stat-bar-count { font-size: 13px; font-weight: 600; color: #333; flex-shrink: 0; }
/* Mini bar chart */ /* Mini bar chart */
.stats-chart-col { flex: 2; min-width: 220px; } .stats-chart-col { flex: 2; min-width: 220px; }
@ -98,7 +98,7 @@
align-items: flex-end; align-items: flex-end;
gap: 3px; gap: 3px;
height: 80px; height: 80px;
background: var(--color-surface-alt); background: #f0f4f8;
border-radius: 6px; border-radius: 6px;
padding: 6px 6px 0; padding: 6px 6px 0;
} }
@ -110,7 +110,7 @@
} }
.chart-bar { .chart-bar {
width: 100%; width: 100%;
background: var(--color-info); background: #1976d2;
border-radius: 2px 2px 0 0; border-radius: 2px 2px 0 0;
position: relative; position: relative;
min-height: 4px; min-height: 4px;
@ -121,7 +121,7 @@
bottom: 0; bottom: 0;
left: 0; left: 0;
width: 100%; width: 100%;
background: var(--color-danger); background: #e53935;
} }
/* ── Filters ─────────────────────────────────────────────────────── */ /* ── Filters ─────────────────────────────────────────────────────── */
@ -134,52 +134,52 @@
} }
.filter-select { .filter-select {
padding: 8px 12px; padding: 8px 12px;
border: 1.5px solid var(--color-border); border: 1.5px solid #d0d7de;
border-radius: 6px; border-radius: 6px;
font-size: 13px; font-size: 13px;
background: var(--color-surface); background: white;
cursor: pointer; cursor: pointer;
} }
.filter-select:focus { outline: none; border-color: var(--color-primary); } .filter-select:focus { outline: none; border-color: #1976d2; }
.filter-input { .filter-input {
padding: 8px 12px; padding: 8px 12px;
border: 1.5px solid var(--color-border); border: 1.5px solid #d0d7de;
border-radius: 6px; border-radius: 6px;
font-size: 13px; font-size: 13px;
background: var(--color-surface); background: white;
min-width: 140px; 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; } .filter-date { min-width: 130px; }
.btn-refresh { .btn-refresh {
padding: 8px 14px; padding: 8px 14px;
background: var(--color-info); background: #1976d2;
color: var(--color-on-info); color: white;
border: none; border: none;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
} }
.btn-refresh:hover:not(:disabled) { background: var(--color-info-dark); } .btn-refresh:hover:not(:disabled) { background: #1565c0; }
.btn-refresh:disabled { background: var(--color-surface-alt); color: var(--color-text-muted); cursor: not-allowed; } .btn-refresh:disabled { background: #bdbdbd; cursor: not-allowed; }
.btn-reset { .btn-reset {
padding: 8px 12px; padding: 8px 12px;
background: var(--color-surface); background: white;
color: var(--color-text-muted); color: #666;
border: 1.5px solid var(--color-border); border: 1.5px solid #d0d7de;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 13px;
} }
.btn-reset:hover { background: var(--color-surface-alt); } .btn-reset:hover { background: #f5f5f5; }
.btn-export { .btn-export {
padding: 8px 16px; padding: 8px 16px;
background: var(--color-success); background: #2e7d32;
color: var(--color-on-success); color: white;
border: none; border: none;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
@ -187,15 +187,15 @@
font-weight: 500; font-weight: 500;
white-space: nowrap; white-space: nowrap;
} }
.btn-export:hover:not(:disabled) { background: var(--color-success-dark); } .btn-export:hover:not(:disabled) { background: #1b5e20; }
.btn-export:disabled { background: var(--color-surface-alt); color: var(--color-text-muted); cursor: not-allowed; } .btn-export:disabled { background: #bdbdbd; cursor: not-allowed; }
.auto-refresh-toggle { .auto-refresh-toggle {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
font-size: 13px; font-size: 13px;
color: var(--color-text-muted); color: #555;
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
} }
@ -206,7 +206,7 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
font-size: 13px; font-size: 13px;
color: var(--color-text-muted); color: #666;
margin-bottom: 10px; margin-bottom: 10px;
} }
.limit-select { padding: 4px 8px; font-size: 13px; } .limit-select { padding: 4px 8px; font-size: 13px; }
@ -224,16 +224,16 @@
} }
.badge-create { background: #e8f5e9; color: #2e7d32; } .badge-create { background: #e8f5e9; color: #2e7d32; }
.badge-update { background: #e3f2fd; color: #1565c0; } .badge-update { background: #e3f2fd; color: #1565c0; }
.badge-delete { background: #ffebee; color: var(--color-danger-text); } .badge-delete { background: #ffebee; color: #c62828; }
.badge-restore { background: #fff3e0; color: #a83a00; } .badge-restore { background: #fff3e0; color: #e65100; }
.badge-login { background: #f3e5f5; color: #6a1b9a; } .badge-login { background: #f3e5f5; color: #6a1b9a; }
.badge-logout { background: #fce4ec; color: #880e4f; } .badge-logout { background: #fce4ec; color: #880e4f; }
.badge-login-failed { background: #ffcdd2; color: #b71c1c; font-weight: 700; } .badge-login-failed { background: #ffcdd2; color: #b71c1c; font-weight: 700; }
.badge-import { background: #e0f7fa; color: #00695c; } .badge-import { background: #e0f7fa; color: #00695c; }
.badge-export { background: #e8f5e9; color: #1b5e20; } .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-bulk-delete { background: #fbe9e7; color: #bf360c; }
.badge-password { background: #fff8e1; color: #9c4e00; } .badge-password { background: #fff8e1; color: #f57f17; }
.badge-default { background: #f5f5f5; color: #616161; } .badge-default { background: #f5f5f5; color: #616161; }
.status-code { .status-code {
@ -244,16 +244,16 @@
font-family: monospace; font-family: monospace;
} }
.status-ok { background: #e8f5e9; color: #2e7d32; } .status-ok { background: #e8f5e9; color: #2e7d32; }
.status-redirect { background: #fff3e0; color: #a83a00; } .status-redirect { background: #fff3e0; color: #e65100; }
.status-error { background: #ffebee; color: var(--color-danger-text); } .status-error { background: #ffebee; color: #c62828; }
.status-server-error { background: #f3e5f5; color: #6a1b9a; } .status-server-error { background: #f3e5f5; color: #6a1b9a; }
.duration-badge { .duration-badge {
padding: 2px 8px; padding: 2px 8px;
border-radius: 4px; border-radius: 4px;
font-size: 11px; font-size: 11px;
background: var(--color-surface-alt); background: #f5f5f5;
color: var(--color-text-muted); color: #777;
font-family: monospace; font-family: monospace;
} }
@ -261,24 +261,24 @@
.audit-empty { .audit-empty {
text-align: center; text-align: center;
padding: 40px; padding: 40px;
background: var(--color-surface-alt); background: #f9f9f9;
border-radius: 8px; border-radius: 8px;
color: var(--color-text-muted); color: #888;
} }
.audit-error { text-align: center; padding: 20px; } .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-list { display: flex; flex-direction: column; gap: 8px; }
.audit-item { .audit-item {
background: var(--color-surface); background: white;
border: 1px solid var(--color-border); border: 1px solid #e0e0e0;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
transition: box-shadow 0.15s; transition: box-shadow 0.15s;
} }
.audit-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); } .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 { .audit-item-header {
display: flex; display: flex;
@ -286,18 +286,18 @@
gap: 8px; gap: 8px;
padding: 10px 14px; padding: 10px 14px;
flex-wrap: wrap; flex-wrap: wrap;
background: var(--color-surface-alt); background: #fafafa;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid #f0f0f0;
} }
.audit-time { margin-left: auto; color: var(--color-text-muted); font-size: 12px; white-space: nowrap; } .audit-time { margin-left: auto; color: #999; font-size: 12px; white-space: nowrap; }
.expand-toggle { color: var(--color-text-muted); font-size: 11px; cursor: pointer; padding: 0 4px; } .expand-toggle { color: #aaa; font-size: 11px; cursor: pointer; padding: 0 4px; }
.audit-resource { .audit-resource {
padding: 3px 8px; padding: 3px 8px;
background: var(--color-surface-alt); background: #f0f0f0;
border-radius: 4px; border-radius: 4px;
font-size: 12px; font-size: 12px;
color: var(--color-text-muted); color: #555;
} }
.audit-item-body { .audit-item-body {
@ -308,16 +308,16 @@
font-size: 13px; font-size: 13px;
align-items: center; align-items: center;
} }
.audit-admin { color: var(--color-text); } .audit-admin { color: #333; }
.audit-resource-name { color: var(--color-text-muted); } .audit-resource-name { color: #555; }
.audit-ip { color: var(--color-text-muted); font-family: monospace; font-size: 12px; } .audit-ip { color: #999; font-family: monospace; font-size: 12px; }
.audit-failed-badge { color: var(--color-danger-text); font-weight: 600; font-size: 12px; } .audit-failed-badge { color: #c62828; font-weight: 600; font-size: 12px; }
/* ── Expanded detail ─────────────────────────────────────────────── */ /* ── Expanded detail ─────────────────────────────────────────────── */
.audit-item-detail { .audit-item-detail {
padding: 10px 14px 14px; padding: 10px 14px 14px;
border-top: 1px dashed var(--color-border); border-top: 1px dashed #e0e0e0;
background: var(--color-surface); background: #fefefe;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
@ -330,7 +330,7 @@
} }
.detail-label { .detail-label {
font-weight: 600; font-weight: 600;
color: var(--color-text-muted); color: #888;
font-size: 11px; font-size: 11px;
text-transform: uppercase; text-transform: uppercase;
min-width: 70px; min-width: 70px;
@ -338,23 +338,23 @@
} }
.detail-row code { .detail-row code {
font-family: monospace; font-family: monospace;
background: var(--color-surface-alt); background: #f0f0f0;
padding: 2px 6px; padding: 2px 6px;
border-radius: 3px; border-radius: 3px;
font-size: 12px; font-size: 12px;
} }
.detail-ua-full { .detail-ua-full {
color: var(--color-text-muted); color: #aaa;
cursor: help; cursor: help;
font-size: 12px; font-size: 12px;
} }
.detail-error { color: var(--color-danger-text); } .detail-error { color: #c62828; }
.detail-error .detail-label { color: var(--color-danger-text); } .detail-error .detail-label { color: #c62828; }
.meta-tag { .meta-tag {
display: inline-block; display: inline-block;
background: var(--color-surface-alt); background: #e8eaf6;
color: var(--color-info-text); color: #283593;
border-radius: 4px; border-radius: 4px;
padding: 2px 8px; padding: 2px 8px;
font-size: 12px; font-size: 12px;
@ -370,17 +370,17 @@
margin-top: 6px; margin-top: 6px;
} }
.diff-table th { .diff-table th {
background: var(--color-surface-alt); background: #f0f0f0;
padding: 5px 10px; padding: 5px 10px;
text-align: left; text-align: left;
font-size: 11px; font-size: 11px;
text-transform: uppercase; text-transform: uppercase;
color: var(--color-text-muted); color: #666;
border: 1px solid var(--color-border); border: 1px solid #e0e0e0;
} }
.diff-table td { padding: 5px 10px; border: 1px solid var(--color-border); vertical-align: top; } .diff-table td { padding: 5px 10px; border: 1px solid #e8e8e8; 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-field { font-weight: 600; color: #444; font-family: monospace; white-space: nowrap; background: #fafafa; }
.diff-before { color: var(--color-danger-text); background: #fff5f5; font-family: monospace; word-break: break-all; } .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; } .diff-after { color: #2e7d32; background: #f5fff5; font-family: monospace; word-break: break-all; }
/* ── Pagination ──────────────────────────────────────────────────── */ /* ── Pagination ──────────────────────────────────────────────────── */
@ -393,16 +393,16 @@
} }
.btn-page { .btn-page {
padding: 7px 14px; padding: 7px 14px;
border: 1.5px solid var(--color-border); border: 1.5px solid #d0d7de;
border-radius: 6px; border-radius: 6px;
background: var(--color-surface); background: white;
cursor: pointer; cursor: pointer;
font-size: 13px; 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:hover:not(:disabled) { background: #f0f4f8; border-color: #1976d2; }
.btn-page:disabled { color: var(--color-text-muted); cursor: not-allowed; border-color: var(--color-border); } .btn-page:disabled { color: #bbb; cursor: not-allowed; border-color: #eee; }
.page-info { font-size: 13px; color: var(--color-text-muted); padding: 0 8px; } .page-info { font-size: 13px; color: #666; padding: 0 8px; }
.audit-header { .audit-header {
@ -411,13 +411,13 @@
.audit-header h2 { .audit-header h2 {
margin: 0 0 8px 0; margin: 0 0 8px 0;
color: var(--color-text); color: #333;
font-size: 28px; font-size: 28px;
} }
.audit-description { .audit-description {
margin: 0; margin: 0;
color: var(--color-text-muted); color: #666;
font-size: 14px; font-size: 14px;
} }
@ -428,7 +428,7 @@
} }
.audit-error p { .audit-error p {
color: var(--color-danger-text); color: #d32f2f;
margin: 0; margin: 0;
} }
@ -441,23 +441,23 @@
.filter-select { .filter-select {
padding: 10px 14px; padding: 10px 14px;
border: 2px solid var(--color-border); border: 2px solid #e0e0e0;
border-radius: 6px; border-radius: 6px;
font-size: 14px; font-size: 14px;
background: var(--color-surface); background: white;
cursor: pointer; cursor: pointer;
transition: border-color 0.2s; transition: border-color 0.2s;
} }
.filter-select:focus { .filter-select:focus {
outline: none; outline: none;
border-color: var(--color-primary); border-color: #1976d2;
} }
.btn-refresh { .btn-refresh {
padding: 10px 16px; padding: 10px 16px;
background: var(--color-info); background: #1976d2;
color: var(--color-on-info); color: white;
border: none; border: none;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
@ -467,21 +467,20 @@
} }
.btn-refresh:hover:not(:disabled) { .btn-refresh:hover:not(:disabled) {
background: var(--color-info-dark); background: #1565c0;
} }
.btn-refresh:disabled { .btn-refresh:disabled {
background: var(--color-surface-alt); background: #bdbdbd;
color: var(--color-text-muted);
cursor: not-allowed; cursor: not-allowed;
} }
.audit-empty { .audit-empty {
text-align: center; text-align: center;
padding: 40px; padding: 40px;
background: var(--color-surface-alt); background: #f5f5f5;
border-radius: 8px; border-radius: 8px;
color: var(--color-text-muted); color: #666;
} }
.audit-list { .audit-list {
@ -491,8 +490,8 @@
} }
.audit-item { .audit-item {
background: var(--color-surface); background: white;
border: 1px solid var(--color-border); border: 1px solid #e0e0e0;
border-radius: 8px; border-radius: 8px;
padding: 16px; padding: 16px;
transition: box-shadow 0.2s; transition: box-shadow 0.2s;
@ -520,18 +519,58 @@
gap: 4px; 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 { .audit-resource {
padding: 4px 10px; padding: 4px 10px;
background: var(--color-surface-alt); background: #f5f5f5;
border-radius: 4px; border-radius: 4px;
font-size: 13px; font-size: 13px;
color: var(--color-text-muted); color: #666;
} }
.audit-time { .audit-time {
margin-left: auto; margin-left: auto;
color: var(--color-text-muted); color: #999;
font-size: 13px; font-size: 13px;
} }
@ -543,19 +582,23 @@
} }
.audit-admin { .audit-admin {
color: var(--color-text); color: #333;
} }
.audit-resource-name { .audit-resource-name {
color: var(--color-text-muted); color: #666;
} }
.audit-ip { .audit-ip {
color: var(--color-text-muted); color: #888;
font-family: monospace; font-family: monospace;
font-size: 13px; font-size: 13px;
} }
.audit-error-message {
color: #d32f2f;
font-weight: 500;
}
.audit-pagination { .audit-pagination {
display: flex; display: flex;
@ -564,15 +607,15 @@
gap: 16px; gap: 16px;
margin-top: 24px; margin-top: 24px;
padding: 16px; padding: 16px;
background: var(--color-surface); background: white;
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--color-border); border: 1px solid #e0e0e0;
} }
.btn-page { .btn-page {
padding: 8px 16px; padding: 8px 16px;
background: var(--color-info); background: #1976d2;
color: var(--color-on-info); color: white;
border: none; border: none;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
@ -581,17 +624,16 @@
} }
.btn-page:hover:not(:disabled) { .btn-page:hover:not(:disabled) {
background: var(--color-info-dark); background: #1565c0;
} }
.btn-page:disabled { .btn-page:disabled {
background: var(--color-surface-alt); background: #bdbdbd;
color: var(--color-text-muted);
cursor: not-allowed; cursor: not-allowed;
} }
.page-info { .page-info {
color: var(--color-text-muted); color: #666;
font-size: 14px; font-size: 14px;
} }
@ -614,33 +656,3 @@
width: 100%; 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 { .export-button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background: var(--color-success); background: var(--color-success);
color: var(--color-on-success); color: white;
border: none; border: none;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
cursor: pointer; cursor: pointer;
@ -41,8 +41,8 @@
.import-button { .import-button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background: var(--color-primary, var(--color-info)); background: var(--color-primary, #2563eb);
color: var(--color-on-primary); color: white;
border: none; border: none;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
cursor: pointer; cursor: pointer;
@ -52,7 +52,7 @@
} }
.import-button:hover:not(:disabled) { .import-button:hover:not(:disabled) {
background: var(--color-primary-dark, var(--color-info-dark)); background: var(--color-primary-dark, #1d4ed8);
} }
.import-button:disabled { .import-button:disabled {
@ -65,7 +65,7 @@
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4rem;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--color-text-muted); color: var(--color-text-muted, #6b7280);
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
} }

View File

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

View File

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

View File

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

View File

@ -1,29 +1,11 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { requestPasswordReset, resetPassword } from '../../services/passwordReset'; import { requestPasswordReset, resetPassword } from '../../services/passwordReset';
import { BASE_PATH } from '../../utils/constants';
import './PasswordReset.css'; 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() { function PasswordReset() {
const initialToken = tokenFromUrl(); const [step, setStep] = useState('request'); // 'request' or 'reset'
const [step, setStep] = useState(initialToken ? 'reset' : 'request');
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [token, setToken] = useState(initialToken); const [token, setToken] = useState('');
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -80,7 +62,7 @@ function PasswordReset() {
setToken(''); setToken('');
setNewPassword(''); setNewPassword('');
setConfirmPassword(''); setConfirmPassword('');
window.location.href = APP_HOME; // Redirect to login window.location.href = '/'; // Redirect to login
}, 2000); }, 2000);
} else { } else {
setMessage({ type: 'error', text: result.message }); setMessage({ type: 'error', text: result.message });
@ -118,7 +100,7 @@ function PasswordReset() {
</button> </button>
<div className="form-footer"> <div className="form-footer">
<a href={APP_HOME}>Zurück zum Login</a> <a href="/">Zurück zum Login</a>
</div> </div>
</form> </form>
) : ( ) : (

View File

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

View File

@ -1,6 +1,6 @@
.app-header { .app-header {
background: var(--color-surface); background: #ffffff;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid #e5e5e5;
padding: 0.75rem 2rem; padding: 0.75rem 2rem;
display: flex; display: flex;
align-items: center; align-items: center;
@ -27,7 +27,7 @@
.portal-back-link:hover { .portal-back-link:hover {
background: var(--color-primary); background: var(--color-primary);
color: var(--color-on-primary); color: #fff;
} }
.app-brand { .app-brand {
@ -40,18 +40,18 @@
height: 52px; height: 52px;
width: auto; width: auto;
object-fit: contain; object-fit: contain;
flex-shrink: 0; }
.app-logo-ljn {
height: 44px;
} }
.app-title { .app-title {
margin: 0; margin: 0;
font-family: var(--font-display);
font-size: 1.05rem; font-size: 1.05rem;
font-weight: 700; font-weight: 700;
color: var(--color-text); color: var(--color-text);
/* Kein nowrap: lange App-Namen haben die Kopfzeile sonst über die white-space: nowrap;
Bildschirmbreite hinaus geschoben. */
overflow-wrap: anywhere;
} }
.app-nav { .app-nav {
@ -61,14 +61,11 @@
} }
.nav-button { .nav-button {
display: inline-flex;
align-items: center;
min-height: var(--touch-target);
padding: 0.5rem 0.875rem; padding: 0.5rem 0.875rem;
background: none; background: none;
border: none; border: none;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
color: var(--color-text); color: #333;
cursor: pointer; cursor: pointer;
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 500; font-weight: 500;
@ -100,19 +97,12 @@
.app-header { .app-header {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
padding: 0.5rem 1rem; padding: 0.75rem 1rem;
gap: 0.4rem; gap: 0.5rem;
/* Auf schmalen Geräten nimmt die umgebrochene Kopfzeile viel Höhe ein.
Klebrig bleibt sie nur dort, wo genug Platz ist. */
position: static;
} }
.app-title { .app-title {
font-size: 0.95rem; font-size: 0.9rem;
}
.app-logo {
height: 36px;
} }
.app-nav { .app-nav {
@ -121,31 +111,7 @@
} }
.nav-button { .nav-button {
font-size: 0.9rem; font-size: 0.85rem;
padding: 0.4rem 0.7rem; 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; position: sticky;
top: 0; top: 0;
z-index: 1000; z-index: 1000;
background-color: var(--color-primary); background-color: #2d6a2d;
color: var(--color-on-primary); color: #fff;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
} }
@ -22,8 +22,8 @@
} }
.install-banner__btn { .install-banner__btn {
background: var(--color-surface); background: #fff;
color: var(--color-primary); color: #2d6a2d;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
padding: 0.35rem 0.85rem; padding: 0.35rem 0.85rem;
@ -34,7 +34,7 @@
} }
.install-banner__btn:hover { .install-banner__btn:hover {
background: var(--color-success-bg); background: #e8f5e9;
} }
.install-banner__close { .install-banner__close {
@ -49,7 +49,7 @@
} }
.install-banner__close:hover { .install-banner__close:hover {
color: var(--color-on-primary); color: #fff;
} }
.install-banner__share-icon { .install-banner__share-icon {

View File

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

View File

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

View File

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

View File

@ -13,32 +13,32 @@
left: 12px; left: 12px;
z-index: 500; z-index: 500;
background: rgba(255, 255, 255, 0.95); background: rgba(255, 255, 255, 0.95);
color: var(--color-text-muted); color: #444;
padding: 0.45rem 0.7rem; padding: 0.45rem 0.7rem;
border-radius: 6px; border-radius: 6px;
font-size: 0.85rem; font-size: 0.85rem;
border: 1px solid var(--color-border); border: 1px solid #dcdcdc;
} }
.map-no-data { .map-no-data {
padding: 3rem; padding: 3rem;
text-align: center; text-align: center;
color: var(--color-text-muted); color: #666;
background: var(--color-surface); background: white;
border-radius: 8px; border-radius: 8px;
margin: 1rem 0; margin: 1rem 0;
} }
.map-popup h3 { .map-popup h3 {
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 0;
color: var(--color-text); color: #333;
font-size: 1rem; font-size: 1rem;
} }
.map-popup p { .map-popup p {
margin: 0.25rem 0; margin: 0.25rem 0;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--color-text-muted); color: #666;
} }
.map-popup a { .map-popup a {
@ -54,40 +54,8 @@
display: inline-block; display: inline-block;
padding: 0.2rem 0.5rem; padding: 0.2rem 0.5rem;
background: var(--color-primary); background: var(--color-primary);
color: var(--color-on-primary); color: white;
border-radius: 4px; border-radius: 4px;
font-size: 0.75rem; font-size: 0.75rem;
margin-top: 0.5rem; 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 { .public-user-list h2 {
color: var(--color-text); color: #1a1a1a;
margin: 0; margin: 0;
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 700; font-weight: 700;
@ -23,7 +23,7 @@
.toggle-map-button { .toggle-map-button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background: var(--color-primary); background: var(--color-primary);
color: var(--color-on-primary); color: white;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
@ -44,8 +44,8 @@
.filter-toggle-button { .filter-toggle-button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background: var(--color-secondary); background: #6c757d;
color: var(--color-on-secondary); color: white;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
@ -55,12 +55,12 @@
} }
.filter-toggle-button:hover { .filter-toggle-button:hover {
background: var(--color-secondary-dark); background: #5a6268;
} }
.location-panel { .location-panel {
background: var(--color-surface); background: white;
border: 1px solid var(--color-border); border: 1px solid #e5e5e5;
border-radius: 8px; border-radius: 8px;
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
@ -78,7 +78,7 @@
.location-button { .location-button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background: var(--color-primary); background: var(--color-primary);
color: var(--color-on-primary); color: white;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
@ -92,7 +92,7 @@
} }
.location-button:disabled { .location-button:disabled {
background: var(--color-border-strong); background: #b6b6b6;
cursor: not-allowed; cursor: not-allowed;
} }
@ -103,13 +103,13 @@
} }
.location-status.success { .location-status.success {
background: var(--color-success-bg); background: #e8f5e9;
color: var(--color-success-text); color: var(--color-primary-dark);
} }
.location-status.error { .location-status.error {
background: var(--color-danger-bg); background: #ffebee;
color: var(--color-danger-text); color: #c62828;
} }
.postal-search { .postal-search {
@ -120,21 +120,21 @@
.postal-search input[type="text"] { .postal-search input[type="text"] {
padding: 0.5rem; padding: 0.5rem;
border: 1px solid var(--color-border); border: 1px solid #ddd;
border-radius: 4px; border-radius: 4px;
font-size: 0.9rem; font-size: 0.9rem;
width: 150px; width: 150px;
} }
.postal-search input[type="text"]:disabled { .postal-search input[type="text"]:disabled {
background: var(--color-surface-alt); background: #f5f5f5;
cursor: not-allowed; cursor: not-allowed;
} }
.postal-search button { .postal-search button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background: var(--color-primary); background: var(--color-primary);
color: var(--color-on-primary); color: white;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
@ -148,14 +148,14 @@
} }
.postal-search button:disabled { .postal-search button:disabled {
background: var(--color-border-strong); background: #b6b6b6;
cursor: not-allowed; cursor: not-allowed;
} }
.radius-filter label { .radius-filter label {
display: block; display: block;
font-size: 0.9rem; font-size: 0.9rem;
color: var(--color-text-muted); color: #555;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
font-weight: 500; font-weight: 500;
} }
@ -174,26 +174,26 @@
.radius-inputs input[type="number"] { .radius-inputs input[type="number"] {
width: 80px; width: 80px;
padding: 0.4rem 0.5rem; padding: 0.4rem 0.5rem;
border: 1px solid var(--color-border); border: 1px solid #ddd;
border-radius: 4px; border-radius: 4px;
font-size: 0.9rem; font-size: 0.9rem;
} }
.radius-inputs input:disabled { .radius-inputs input:disabled {
background: var(--color-surface-alt); background: #f5f5f5;
cursor: not-allowed; cursor: not-allowed;
} }
.radius-hint { .radius-hint {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--color-text-muted); color: #777;
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.gps-hint { .gps-hint {
margin-top: 0.5rem; margin-top: 0.5rem;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--color-text-muted); color: #777;
} }
.user-grid { .user-grid {
@ -205,8 +205,8 @@
/* Jägerschaft-style card */ /* Jägerschaft-style card */
.user-card { .user-card {
background: var(--color-surface); background: white;
border: 1px solid var(--color-border); border: 1px solid #e5e5e5;
border-radius: 8px; border-radius: 8px;
padding: 1.5rem; padding: 1.5rem;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06); 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); box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1);
} }
.public-user-list .user-name { .user-name {
margin: 0 0 0.2rem; margin: 0 0 0.2rem;
color: var(--color-text); color: #1a1a1a;
font-size: 1rem; font-size: 1rem;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
@ -250,15 +250,15 @@
color: var(--color-primary); color: var(--color-primary);
} }
.public-user-list .user-info { .user-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.375rem; gap: 0.375rem;
} }
.public-user-list .user-address { .user-address {
margin: 0; margin: 0;
color: var(--color-text-muted); color: #666;
font-size: 0.9rem; font-size: 0.9rem;
} }
@ -266,80 +266,72 @@
margin: 0.5rem 0 0; margin: 0.5rem 0 0;
} }
.public-user-list .type-badge { .type-badge {
display: inline-block; display: inline-block;
padding: 0.2rem 0.6rem; padding: 0.2rem 0.6rem;
background: var(--color-surface-alt); background: #f0f0f0;
color: var(--color-text-muted); color: #444;
border-radius: 4px; border-radius: 4px;
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 600; font-weight: 600;
letter-spacing: 0.03em; 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 { .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); color: var(--color-primary);
text-decoration: none; text-decoration: none;
font-weight: 600; font-weight: 500;
font-size: 1rem; font-size: 0.9rem;
} }
.public-user-list .user-phone:hover, .user-phone:hover {
.public-user-list .user-phone:focus-visible {
background: var(--color-surface);
border-color: var(--color-border-strong);
text-decoration: underline; text-decoration: underline;
} }
.user-gps {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #e5e5e5;
font-size: 0.85rem;
color: #888;
}
.user-distance { .user-distance {
margin-top: 0.75rem; margin-top: 0.75rem;
padding-top: 0.75rem; padding-top: 0.75rem;
border-top: 1px solid var(--color-border); border-top: 1px solid #e5e5e5;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--color-text-muted); color: #555;
font-weight: 500; font-weight: 500;
} }
.no-users { .no-users {
text-align: center; text-align: center;
padding: 3rem; padding: 3rem;
color: var(--color-text-muted); color: #666;
} }
.loading { .loading {
text-align: center; text-align: center;
padding: 3rem; padding: 3rem;
color: var(--color-text-muted); color: #666;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.user-grid { .user-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.public-user-list { .public-user-list {
padding: 1rem; padding: 1rem;
} }
.radius-inputs { .radius-inputs {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
} }
.radius-inputs input[type="number"] { .radius-inputs input[type="number"] {
width: 100%; 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 { useConfigContext } from '../../contexts/ConfigContext';
import MapView from '../map/MapView'; import MapView from '../map/MapView';
import FilterPanel from '../users/FilterPanel'; import FilterPanel from '../users/FilterPanel';
@ -6,32 +6,18 @@ import { calculateDistance } from '../../utils/helpers';
import { getGeocodeByPostalCode } from '../../services/users'; import { getGeocodeByPostalCode } from '../../services/users';
import './PublicUserList.css'; 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 PublicUserList = ({ users, loading, onRefetch }) => {
const { userTypeLabels } = useConfigContext(); 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 [showMap, setShowMap] = useState(true);
const [showFilters, setShowFilters] = useState(false); const [showFilters, setShowFilters] = useState(false);
// Gespeicherten Standort lazy und abgesichert lesen. Vorher stand ein const [locationStatus, setLocationStatus] = useState(initialCoords ? 'granted' : 'idle');
// 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 [locationError, setLocationError] = useState(''); const [locationError, setLocationError] = useState('');
const [coords, setCoords] = useState(initialCoords);
const [radiusKm, setRadiusKm] = useState(100); const [radiusKm, setRadiusKm] = useState(100);
const [postalCode, setPostalCode] = useState(''); const [postalCode, setPostalCode] = useState('');
const [postalSearching, setPostalSearching] = useState(false); const [postalSearching, setPostalSearching] = useState(false);
@ -42,11 +28,8 @@ const PublicUserList = ({ users, loading, onRefetch }) => {
// Save location to localStorage whenever it changes // Save location to localStorage whenever it changes
useEffect(() => { useEffect(() => {
if (!coords) return; if (coords) {
try { localStorage.setItem('userLocation', JSON.stringify(coords));
localStorage.setItem(LOCATION_KEY, JSON.stringify(coords));
} catch {
// Speicher nicht verfuegbar - der Standort gilt dann nur fuer diese Sitzung.
} }
}, [coords]); }, [coords]);
@ -54,15 +37,8 @@ const PublicUserList = ({ users, loading, onRefetch }) => {
setFilters(prev => ({ ...prev, [key]: value })); setFilters(prev => ({ ...prev, [key]: value }));
}, []); }, []);
// Re-fetch from backend when type filter changes (server-side filtering). // 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);
useEffect(() => { useEffect(() => {
if (skipInitialRefetch.current) {
skipInitialRefetch.current = false;
return;
}
if (onRefetch) { if (onRefetch) {
onRefetch(filters.type ? { type: filters.type } : {}); onRefetch(filters.type ? { type: filters.type } : {});
} }
@ -291,7 +267,7 @@ const PublicUserList = ({ users, loading, onRefetch }) => {
<div className="user-info"> <div className="user-info">
<p className="user-address">📍 {user.address}</p> <p className="user-address">📍 {user.address}</p>
<a href={`tel:${user.phone}`} className="user-phone"> <a href={`tel:${user.phone}`} className="user-phone">
📱 {user.phone} <EFBFBD> {user.phone}
</a> </a>
{user.landline && ( {user.landline && (
<a href={`tel:${user.landline}`} className="user-phone"> <a href={`tel:${user.landline}`} className="user-phone">

View File

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

View File

@ -25,7 +25,7 @@
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
} }
.user-card-admin .user-name-row { .user-name-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
@ -39,10 +39,10 @@
box-shadow: 0 0 4px rgba(0,0,0,0.2); box-shadow: 0 0 4px rgba(0,0,0,0.2);
} }
.dot-green { background: var(--color-success); } .dot-green { background: #22c55e; }
.dot-red { background: var(--color-danger); } .dot-red { background: #ef4444; }
.user-card-admin .user-name { .user-name {
margin: 0; margin: 0;
color: var(--color-text); color: var(--color-text);
font-size: 1.2rem; font-size: 1.2rem;
@ -67,40 +67,40 @@
font-weight: 600; font-weight: 600;
} }
.label-green { color: var(--color-success); } .label-green { color: #16a34a; }
.label-red { color: var(--color-danger); } .label-red { color: var(--color-danger); }
.user-card-admin .user-info { .user-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.user-card-admin .user-address { .user-address {
margin: 0; margin: 0;
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: 0.9rem; font-size: 0.9rem;
} }
.user-card-admin .type-badge { .type-badge {
display: inline-block; display: inline-block;
padding: 0.25rem 0.75rem; padding: 0.25rem 0.75rem;
background: var(--color-primary); background: var(--color-primary);
color: var(--color-on-primary); color: white;
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 500; font-weight: 500;
} }
.user-card-admin .user-phone { .user-phone {
color: var(--color-primary); color: var(--color-primary);
text-decoration: none; text-decoration: none;
font-weight: 500; font-weight: 500;
font-size: 1rem; font-size: 1rem;
} }
.user-card-admin .user-phone:hover { .user-phone:hover {
text-decoration: underline; text-decoration: underline;
} }
@ -183,7 +183,7 @@
.edit-gps-button { .edit-gps-button {
padding: 0.25rem 0.75rem; padding: 0.25rem 0.75rem;
background: var(--color-primary); background: var(--color-primary);
color: var(--color-on-primary); color: white;
border: none; border: none;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
cursor: pointer; cursor: pointer;
@ -229,7 +229,7 @@
.save-gps-button { .save-gps-button {
background: var(--color-success); background: var(--color-success);
color: var(--color-on-success); color: white;
} }
.save-gps-button:hover:not(:disabled) { .save-gps-button:hover:not(:disabled) {
@ -243,7 +243,7 @@
.cancel-gps-button { .cancel-gps-button {
background: var(--color-secondary); background: var(--color-secondary);
color: var(--color-on-secondary); color: white;
} }
.cancel-gps-button:hover { .cancel-gps-button:hover {
@ -272,7 +272,7 @@
.edit-button { .edit-button {
background: var(--color-primary); background: var(--color-primary);
color: var(--color-on-primary); color: white;
} }
.edit-button:hover { .edit-button:hover {
@ -281,7 +281,7 @@
.delete-button { .delete-button {
background: var(--color-danger); background: var(--color-danger);
color: var(--color-on-danger); color: white;
} }
.delete-button:hover { .delete-button:hover {
@ -289,44 +289,17 @@
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.user-header { .user-header {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: 0.5rem; gap: 0.5rem;
} }
.gps-inputs { .gps-inputs {
flex-direction: column; flex-direction: column;
} }
.card-actions { .card-actions {
flex-direction: column; 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 { useConfigContext } from '../../contexts/ConfigContext';
import './UserCard.css'; 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 { userTypeLabels } = useConfigContext();
const [lat, setLat] = useState(user.gps?.lat?.toString() || ''); const [lat, setLat] = useState(user.gps?.lat?.toString() || '');
const [lng, setLng] = useState(user.gps?.lng?.toString() || ''); const [lng, setLng] = useState(user.gps?.lng?.toString() || '');
const [isEditingGPS, setIsEditingGPS] = useState(false); const [isEditingGPS, setIsEditingGPS] = useState(false);
const [photoUploading, setPhotoUploading] = useState(false); const [photoUploading, setPhotoUploading] = useState(false);
const [invite, setInvite] = useState(null);
const [inviteLoading, setInviteLoading] = useState(false);
const photoInputRef = useRef(null); const photoInputRef = useRef(null);
const handleGPSUpdate = () => { 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 ( return (
<div className={`user-card-admin ${user.available ? 'card-available' : 'card-unavailable'}`}> <div className={`user-card-admin ${user.available ? 'card-available' : 'card-unavailable'}`}>
<div className="user-header"> <div className="user-header">
@ -132,7 +120,6 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o
<div className="gps-section"> <div className="gps-section">
<h4>GPS-Koordinaten</h4> <h4>GPS-Koordinaten</h4>
{!isEditingGPS ? (
<div className="gps-display"> <div className="gps-display">
{user.gps && user.gps.lat && user.gps.lng ? ( {user.gps && user.gps.lat && user.gps.lng ? (
<span> <span>
@ -191,37 +178,6 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o
)} )}
</div> </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"> <div className="card-actions">
<button <button
className="edit-button" className="edit-button"

View File

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

View File

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

View File

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

View File

@ -15,22 +15,15 @@ export const useUsers = (isAdmin = false) => {
// Keep a ref so callbacks always call the latest fetchUsers // Keep a ref so callbacks always call the latest fetchUsers
const fetchUsersRef = useRef(null); 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); setLoading(true);
setError(null); setError(null);
if (filters !== null) {
activeFiltersRef.current = filters;
}
const params = { const params = {
page, page,
limit: 50, limit: 50,
...activeFiltersRef.current ...filters
}; };
const result = isAdmin ? await getUsers(params) : await getPublicUsers(params); const result = isAdmin ? await getUsers(params) : await getPublicUsers(params);
@ -47,7 +40,7 @@ export const useUsers = (isAdmin = false) => {
fetchUsersRef.current = fetchUsers; fetchUsersRef.current = fetchUsers;
useEffect(() => { useEffect(() => {
fetchUsers(1, true, {}); // Initial load, reset users and filters fetchUsers(1, true); // Initial load, reset users
}, [fetchUsers]); }, [fetchUsers]);
const loadMore = useCallback(() => { const loadMore = useCallback(() => {
@ -69,3 +62,4 @@ export const useUsers = (isAdmin = false) => {
refetch refetch
}; };
}; };

View File

@ -1,8 +1,13 @@
/* Basisstile stehen in App.css, wo auch die Design-Tokens definiert sind. body {
Hier stand vorher eine zweite body-Regel, die von App.css vollständig margin: 0;
überschrieben wurde. */ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
html { 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
/* Verhindert, dass iOS beim Drehen die Schrift eigenmächtig vergrößert. */ sans-serif;
-webkit-text-size-adjust: 100%; -webkit-font-smoothing: antialiased;
text-size-adjust: 100%; -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> </React.StrictMode>
); );
// Register service worker. // 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.
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
const base = import.meta.env.BASE_URL || '/';
window.addEventListener('load', () => { window.addEventListener('load', () => {
navigator.serviceWorker.register(`${base}sw.js`, { scope: base }) navigator.serviceWorker.register('./sw.js')
.then(registration => { .then(registration => {
console.log('SW registered: ', registration); console.log('SW registered: ', registration);
}) })

View File

@ -38,14 +38,8 @@ api.interceptors.response.use(
return Promise.reject(error); 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 // Retry logic for network errors or 5xx errors
if (config.retry === undefined) { if (!config || !config.retry) {
config.retry = 2; // Default: 2 retries config.retry = 2; // Default: 2 retries
config.retryCount = 0; 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) => { export const deleteUserPhoto = async (id) => {
try { try {
await api.delete(`/users/${id}/photo`); 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 // Use configured API URL when set, otherwise auto-detect common subpath deployment (/drohnenfuehrer)
// 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 '/'.
const detectRuntimeBasePath = () => { const detectRuntimeBasePath = () => {
if (typeof window === 'undefined') return ''; if (typeof window === 'undefined') return '';
const path = window.location.pathname || ''; const path = window.location.pathname || '';
const knownBasePaths = ['/nachsuche', '/drohnenfuehrer', '/stoeberhunde']; if (path === '/drohnenfuehrer' || path.startsWith('/drohnenfuehrer/')) {
const match = knownBasePaths.find(basePath => path === basePath || path.startsWith(`${basePath}/`)); return '/drohnenfuehrer';
return match || ''; }
return '';
}; };
const 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 USER_TYPES = {
export const BASE_PATH = stripTrailingSlash(env.BASE_URL) || detectRuntimeBasePath(); DF: 'DF',
WK: 'WK',
RGB: 'RGB'
};
// Vollständige API-Basis. VITE_API_URL überschreibt (z. B. eigene API-Domain). export const USER_TYPE_LABELS = {
export const API_BASE_URL = (typeof env.VITE_API_URL === 'string' && env.VITE_API_URL.trim()) [USER_TYPES.DF]: 'Drohnenführer',
? stripTrailingSlash(env.VITE_API_URL.trim()) [USER_TYPES.WK]: 'Wärmebildkamera',
: BASE_PATH; [USER_TYPES.RGB]: 'RGB-Kamera'
};
export const ADMIN_PATH = env.VITE_ADMIN_PATH || '/verwaltung'; export const RULES = [
export const RESET_PASSWORD_PATH = '/passwort-zuruecksetzen'; "Drohnenflug nur mit gültigem Drohnenführerschein (A1/A3 oder A2).",
"Informieren Sie den zuständigen Revierinhaber vor jedem Einsatz.",
// Hängt den Deployment-Basispfad vor einen App-Pfad: '/nachsuche/verwaltung'. "Halten Sie die Datenschutzbestimmungen beim Einsatz von Wärmebildkameras ein.",
export const withBase = (appPath) => "Geben Sie keine Aufnahmen ohne Zustimmung des Revierinhabers weiter.",
`${BASE_PATH}${appPath.startsWith('/') ? appPath : `/${appPath}`}`; "Melden Sie Ihren Einsatz unverzüglich an die koordinierende Stelle."
];
// Vergleichbare Normalform eines Pfads (ohne abschließenden Slash).
export const normalizePath = (pathname) => stripTrailingSlash(pathname) || '/';

View File

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

View File

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

View File

@ -19,16 +19,3 @@ CORS_ORIGIN=http://localhost:3000
GEOCODE_URL=https://nominatim.openstreetmap.org/search GEOCODE_URL=https://nominatim.openstreetmap.org/search
GEOCODE_USER_AGENT=tracking-leaders-app/1.0 (admin@localhost) GEOCODE_USER_AGENT=tracking-leaders-app/1.0 (admin@localhost)
GEOCODE_MIN_DELAY_MS=1100 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'); logger.info('MongoDB verbunden');
} catch (error) { } 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); 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), geocodeMinDelayMs: parseInt(process.env.GEOCODE_MIN_DELAY_MS || '1100', 10),
// E-Mail / SMTP (required for password-reset emails; optional otherwise) // E-Mail / SMTP (required for password-reset emails; optional otherwise)
smtpConfigured: !!(process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS), 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, appUrl: process.env.APP_URL || process.env.CORS_ORIGIN?.split(',')[0] || 'http://localhost:8080'
// 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(/\/+$/, '')
}; };
// Validate required environment variables // Validate required environment variables
@ -52,21 +47,11 @@ if (config.nodeEnv === 'production') {
} }
}); });
// Check for insecure defaults in production. // Check for insecure defaults in production
// Nicht nur der eine Default-String: podman-compose.yml setzt z. B. if (config.jwtSecret === 'your-secret-key-change-in-production') {
// CHANGE_ME_IN_PRODUCTION, was eine reine Gleichheitsprüfung durchlässt. console.error('❌ Fehler: JWT_SECRET verwendet unsicheren Default-Wert!');
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');
process.exit(1); 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; module.exports = config;

View File

@ -1,7 +1,5 @@
const mongoose = require('mongoose');
const AuditLog = require('../models/AuditLog'); const AuditLog = require('../models/AuditLog');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { escapeCell } = require('../utils/csv');
/** /**
* Get all audit logs with pagination and filtering * Get all audit logs with pagination and filtering
@ -101,13 +99,8 @@ const getAdminActivity = async (req, res) => {
try { try {
const { adminId } = req.params; 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([ const stats = await AuditLog.aggregate([
// ObjectId ist seit bson 5 eine echte Klasse und braucht new. { $match: { adminId: require('mongoose').Types.ObjectId(adminId) } },
{ $match: { adminId: new mongoose.Types.ObjectId(adminId) } },
{ {
$group: { $group: {
_id: '$action', _id: '$action',
@ -221,6 +214,13 @@ const exportAuditLogs = async (req, res) => {
.limit(10000) .limit(10000)
.lean(); .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 = [ const header = [
'Zeitstempel', 'Aktion', 'Ressource', 'Ressourcen-Name', 'Admin', 'Zeitstempel', 'Aktion', 'Ressource', 'Ressourcen-Name', 'Admin',
'IP-Adresse', 'Methode', 'Pfad', 'Status-Code', 'Dauer (ms)', 'IP-Adresse', 'Methode', 'Pfad', 'Status-Code', 'Dauer (ms)',

View File

@ -40,7 +40,7 @@ const login = async (req, res) => {
// Generate token // Generate token
const token = jwt.sign( 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, config.jwtSecret,
{ expiresIn: config.jwtExpiresIn } { expiresIn: config.jwtExpiresIn }
); );
@ -81,7 +81,7 @@ const logout = async (req, res) => {
try { try {
// Log logout (get username from token if available) // Log logout (get username from token if available)
const username = req.user?.username || 'unknown'; const username = req.user?.username || 'unknown';
await auditAuth(req, true, username, null, 'LOGOUT'); await auditAuth(req, true, username, null);
const secureCookie = config.nodeEnv === 'production' const secureCookie = config.nodeEnv === 'production'
? (req.secure || req.headers['x-forwarded-proto'] === 'https') ? (req.secure || req.headers['x-forwarded-proto'] === 'https')
@ -123,9 +123,7 @@ const forgotPassword = async (req, res) => {
} }
// Find admin // Find admin
// case-insensitive wie beim Login: ein als "Thorsten" angelegtes Konto const admin = await Admin.findOne({ username });
// konnte sich als "thorsten" anmelden, aber kein Passwort zuruecksetzen.
const admin = await Admin.findOne({ username: { $regex: new RegExp(`^${String(username).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i') } });
// Don't reveal if user exists (security best practice) // Don't reveal if user exists (security best practice)
if (!admin) { if (!admin) {
@ -202,13 +200,11 @@ const resetPassword = async (req, res) => {
}); });
} }
// Muss zu minlength im Admin-Schema passen. Vorher stand hier 6: Passwörter // Validate password length
// mit 611 Zeichen kamen durch und scheiterten erst an der Mongoose- if (newPassword.length < 6) {
// Validierung, was als 500 "Serverfehler" beim Nutzer ankam.
if (newPassword.length < 12) {
return res.status(400).json({ return res.status(400).json({
success: false, 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 User = require('../models/User');
const { geocodeAddress } = require('../utils/geocode'); const { geocodeAddress } = require('../utils/geocode');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { escapeCell } = require('../utils/csv');
const config = require('../config/env'); const config = require('../config/env');
const ALLOWED_USER_FIELDS = ['name', 'type', 'address', 'phone', 'landline', 'email', 'available', 'gps', 'notes']; 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 }) .sort(req.query.search ? { score: { $meta: 'textScore' } } : { name: 1 })
.skip(skip) .skip(skip)
.limit(limit) .limit(limit)
// Kontaktdaten sind der Zweck der oeffentlichen Liste; E-Mail, Hashes .select('name type available gps'),
// und Invite-Felder bleiben ausgeschlossen.
.select('name type available gps phone landline address photo'),
User.countDocuments(filter) User.countDocuments(filter)
]); ]);
@ -417,6 +414,14 @@ const exportUsers = async (req, res) => {
.select('-__v -passwordHash -deleted -deletedAt -deletedBy'); .select('-__v -passwordHash -deleted -deletedAt -deletedBy');
if (format === 'csv') { 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 = [ const csv = [
['Name', 'Adresse', 'Telefon', 'Festnetz', 'E-Mail', 'Typ', 'Verfügbar', 'Latitude', 'Longitude'].join(','), ['Name', 'Adresse', 'Telefon', 'Festnetz', 'E-Mail', 'Typ', 'Verfügbar', 'Latitude', 'Longitude'].join(','),
...users.map(user => [ ...users.map(user => [
@ -588,10 +593,8 @@ const bulkUpdateUsers = async (req, res) => {
} }
// Perform bulk update // 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( const result = await User.updateMany(
{ _id: { $in: ids }, deleted: { $ne: true } }, { _id: { $in: ids } },
{ $set: updateFields } { $set: updateFields }
); );
@ -629,10 +632,8 @@ const bulkDeleteUsers = async (req, res) => {
} }
// Soft delete all users // Soft delete all users
// Bereits geloeschte Eintraege bleiben unangetastet, damit
// deletedAt/deletedBy nicht ueberschrieben werden.
const result = await User.updateMany( const result = await User.updateMany(
{ _id: { $in: ids }, deleted: { $ne: true } }, { _id: { $in: ids } },
{ {
$set: { $set: {
deleted: true, deleted: true,

View File

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

View File

@ -1,7 +1,6 @@
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const config = require('../config/env'); const config = require('../config/env');
// Verifiziert das Token und stellt sicher, dass es sich um ein Admin-Token handelt.
const authenticateToken = (req, res, next) => { const authenticateToken = (req, res, next) => {
// Try to get token from cookie first (new secure method) // Try to get token from cookie first (new secure method)
let token = req.cookies?.token; let token = req.cookies?.token;
@ -21,7 +20,6 @@ const authenticateToken = (req, res, next) => {
try { try {
const decoded = jwt.verify(token, config.jwtSecret); const decoded = jwt.verify(token, config.jwtSecret);
// Reject tokens issued by a different app (C-01 cross-app auth fix) // Reject tokens issued by a different app (C-01 cross-app auth fix)
if (decoded.app && decoded.app !== config.appName) { if (decoded.app && decoded.app !== config.appName) {
return res.status(403).json({ return res.status(403).json({
@ -29,20 +27,6 @@ const authenticateToken = (req, res, next) => {
message: 'Ungültiger oder abgelaufener Token.' 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; req.user = decoded;
next(); next();
} catch (error) { } catch (error) {
@ -53,30 +37,4 @@ const authenticateToken = (req, res, next) => {
} }
}; };
// Wie authenticateToken, blockiert aber nicht: setzt req.user wenn ein gültiges module.exports = { authenticateToken };
// 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 };

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 = { module.exports = {
apiLimiter, apiLimiter,
authLimiter, authLimiter,
inviteLimiter, inviteLimiter
geocodeLimiter
}; };

View File

@ -15,12 +15,9 @@
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^8.2.1", "express-rate-limit": "^8.2.1",
"express-validator": "^7.3.1", "express-validator": "^7.3.1",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"mongoose": "^7.5.0", "mongoose": "^7.5.0",
"nodemailer": "^6.9.16", "winston": "^3.19.0"
"winston": "^3.19.0",
"winston-daily-rotate-file": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
"jest": "^30.2.0", "jest": "^30.2.0",
@ -2883,15 +2880,6 @@
"integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==",
"license": "MIT" "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": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@ -3254,18 +3242,6 @@
"node": ">= 0.4" "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": { "node_modules/html-escaper": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@ -4603,15 +4579,6 @@
"node": ">=16 || 14 >=14.17" "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": { "node_modules/mongodb": {
"version": "5.9.2", "version": "5.9.2",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-5.9.2.tgz", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-5.9.2.tgz",
@ -4787,15 +4754,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/nodemon": {
"version": "3.1.11", "version": "3.1.11",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
@ -4882,15 +4840,6 @@
"node": ">=0.10.0" "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": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@ -6300,24 +6249,6 @@
"node": ">= 12.0.0" "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": { "node_modules/winston-transport": {
"version": "4.9.0", "version": "4.9.0",
"resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", "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 router = express.Router();
const { login, logout, forgotPassword, resetPassword } = require('../controllers/authController'); const { login, logout, forgotPassword, resetPassword } = require('../controllers/authController');
const { validateLogin } = require('../middleware/validator'); const { validateLogin } = require('../middleware/validator');
const { attachUserIfPresent } = require('../middleware/auth');
router.post('/login', validateLogin, login); router.post('/login', validateLogin, login);
// attachUserIfPresent statt authenticateToken: der Logout muss auch mit router.post('/logout', logout);
// abgelaufenem Token funktionieren, soll den Benutzernamen aber protokollieren.
router.post('/logout', attachUserIfPresent, logout);
router.post('/forgot-password', forgotPassword); router.post('/forgot-password', forgotPassword);
router.post('/reset-password', resetPassword); router.post('/reset-password', resetPassword);

View File

@ -3,7 +3,6 @@ const router = express.Router();
const { authenticateToken } = require('../middleware/auth'); const { authenticateToken } = require('../middleware/auth');
const { auditLog } = require('../middleware/auditLogger'); const { auditLog } = require('../middleware/auditLogger');
const { validateGPS, validateAvailability } = require('../middleware/validator'); const { validateGPS, validateAvailability } = require('../middleware/validator');
const { geocodeLimiter } = require('../middleware/rateLimiter');
const { const {
getAllUsers, getAllUsers,
getUserById, getUserById,
@ -26,19 +25,13 @@ const {
// Public routes // Public routes
router.get('/public/users', getPublicUsers); router.get('/public/users', getPublicUsers);
// Eigenes, engeres Limit: der Endpunkt loest ausgehende Nominatim-Anfragen aus. router.get('/public/geocode', getGeocodeByPostalCode);
router.get('/public/geocode', geocodeLimiter, getGeocodeByPostalCode);
// Protected routes (require authentication) // Protected routes (require authentication)
router.get('/users', authenticateToken, getAllUsers); router.get('/users', authenticateToken, getAllUsers);
router.get('/users/export', authenticateToken, auditLog('EXPORT', 'User'), exportUsers); router.get('/users/export', authenticateToken, auditLog('EXPORT', 'User'), exportUsers);
router.post('/users/import', authenticateToken, auditLog('IMPORT', 'User'), importUsers); router.post('/users/import', authenticateToken, auditLog('IMPORT', 'User'), importUsers);
router.get('/users/deleted', authenticateToken, getDeletedUsers); 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.get('/users/:id', authenticateToken, getUserById);
router.post('/users', authenticateToken, auditLog('CREATE', 'User'), createUser); router.post('/users', authenticateToken, auditLog('CREATE', 'User'), createUser);
router.put('/users/:id', authenticateToken, auditLog('UPDATE', 'User'), updateUser); 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); router.delete('/users/:id/photo', authenticateToken, auditLog('UPDATE', 'User'), deleteUserPhoto);
// Bulk operations // 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; module.exports = router;

View File

@ -12,10 +12,7 @@ const users = [];
const seedDatabase = async () => { const seedDatabase = async () => {
try { try {
// Beim Aufruf aus server.js besteht die Verbindung bereits.
if (mongoose.connection.readyState !== 1) {
await mongoose.connect(config.mongoUri); await mongoose.connect(config.mongoUri);
}
logger.info('MongoDB verbunden für Seeding...'); logger.info('MongoDB verbunden für Seeding...');
@ -57,8 +54,6 @@ const seedDatabase = async () => {
} }
// Seed config - always update userTypes + sections + rules // 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 existingConfig = await Config.findOne();
const configData = { const configData = {
userTypes: [ userTypes: [
@ -68,7 +63,7 @@ const seedDatabase = async () => {
{ code: 'LAB', label: 'Labrador' } { code: 'LAB', label: 'Labrador' }
], ],
rules: [ rules: [
"Verbrechen Sie den Standort und den Anschuss.", "Verbreiten Sie den Standort und den Anschuss.",
"Vertreten Sie keine Pirschzeichen.", "Vertreten Sie keine Pirschzeichen.",
"Versuchen Sie die Nachsuche möglichst nicht erst mit ungeübten Hunden.", "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.", "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); await Config.create(configData);
logger.info('✅ Konfiguration erstellt'); logger.info('✅ Konfiguration erstellt');
} else { } else {
logger.info(' Konfiguration existiert bereits, bleibt unveraendert'); await Config.findOneAndUpdate({}, configData, { new: true });
logger.info('✅ Konfiguration aktualisiert');
} }
logger.info('✅ Datenbank-Seeding abgeschlossen'); logger.info('✅ Datenbank-Seeding abgeschlossen');
await mongoose.connection.close();
process.exit(0);
} catch (error) { } catch (error) {
logger.error('❌ Fehler beim Seeding:', 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) { if (require.main === module) {
seedDatabase() seedDatabase();
.then(async () => { await mongoose.connection.close(); process.exit(0); })
.catch(async () => { await mongoose.connection.close(); process.exit(1); });
} }
module.exports = seedDatabase; module.exports = seedDatabase;

View File

@ -16,17 +16,11 @@ const connectWithRetry = async () => {
try { try {
await connectDB(); await connectDB();
// Seeding legt Admin-Konto und Grundkonfiguration an. Die Bedingung darf sich // Seed database if empty (runs in all environments on first start)
// NICHT an der User-Zahl orientieren: die Seed-Liste ist bewusst leer, dadurch const User = require('./models/User');
// lief das Seeding bei jedem Start erneut. const userCount = await User.countDocuments();
const Admin = require('./models/Admin'); if (userCount === 0) {
const Config = require('./models/Config'); logger.info('Datenbank ist leer, starte Seeding...');
const [adminCount, configCount] = await Promise.all([
Admin.countDocuments(),
Config.countDocuments()
]);
if (adminCount === 0 || configCount === 0) {
logger.info('Admin oder Konfiguration fehlt, starte Seeding...');
try { try {
const seed = require('./seed'); const seed = require('./seed');
await 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 PORT = config.port;
const server = app.listen(PORT, () => { const server = app.listen(PORT, () => {
logger.info(`Server läuft auf Port ${PORT} (${config.nodeEnv})`); logger.info(`Server läuft auf Port ${PORT} (${config.nodeEnv})`);
}); });
// Graceful shutdown (Docker stop / Kubernetes rolling restart) // Graceful shutdown on SIGTERM (Docker stop / Kubernetes rolling restart)
let shuttingDown = false; process.on('SIGTERM', () => {
const shutdown = (signal) => { logger.info('SIGTERM empfangen, fahre Server herunter...');
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();
server.close(() => { server.close(() => {
logger.info('HTTP-Server geschlossen'); logger.info('HTTP-Server geschlossen');
mongoose.connection.close(false).then(() => { mongoose.connection.close(false).then(() => {
@ -114,13 +101,9 @@ const shutdown = (signal) => {
process.exit(0); process.exit(0);
}).catch(() => process.exit(1)); }).catch(() => process.exit(1));
}); });
}; });
process.on('SIGTERM', () => shutdown('SIGTERM')); // If a frontend build exists, serve it as static files (useful for local testing)
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.
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const buildPath = path.join(__dirname, '..', 'frontend', 'build'); const buildPath = path.join(__dirname, '..', 'frontend', 'build');
@ -134,6 +117,3 @@ if (fs.existsSync(buildPath)) {
res.sendFile(path.join(buildPath, 'index.html')); 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_FILE = path.join(__dirname, '..', 'geocode-cache.json');
const CACHE_SAVE_INTERVAL = 60000; // Save every 60 seconds const CACHE_SAVE_INTERVAL = 60000; // Save every 60 seconds
const CACHE_MAX_ENTRIES = 1000;
const cache = new Map(); const cache = new Map();
let lastRequestTime = 0; let lastRequestTime = 0;
let cacheModified = false; 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 // Load cache from file on startup
const loadCache = async () => { const loadCache = async () => {
try { try {
const data = await fs.readFile(CACHE_FILE, 'utf8'); const data = await fs.readFile(CACHE_FILE, 'utf8');
const parsed = JSON.parse(data); const parsed = JSON.parse(data);
// Nur die letzten CACHE_MAX_ENTRIES übernehmen eine früher unbegrenzt Object.entries(parsed).forEach(([key, value]) => {
// gewachsene Datei darf den Cache nicht wieder aufblähen.
const entries = Object.entries(parsed).slice(-CACHE_MAX_ENTRIES);
entries.forEach(([key, value]) => {
cache.set(key, value); cache.set(key, value);
}); });
logger.info(`Geocoding cache loaded: ${cache.size} entries`); logger.info(`Geocoding cache loaded: ${cache.size} entries`);
@ -69,13 +45,14 @@ const saveCache = async () => {
setInterval(saveCache, CACHE_SAVE_INTERVAL).unref(); setInterval(saveCache, CACHE_SAVE_INTERVAL).unref();
// Save on process exit // Save on process exit
// Cache beim Herunterfahren sichern ohne process.exit(): das Beenden gehört process.on('SIGINT', async () => {
// dem Shutdown-Handler in server.js, der sonst mittendrin abgeschnitten wird. await saveCache();
const flushOnShutdown = () => { process.exit(0);
saveCache().catch(err => logger.error('Failed to flush geocoding cache:', err.message)); });
}; process.on('SIGTERM', async () => {
process.on('SIGINT', flushOnShutdown); await saveCache();
process.on('SIGTERM', flushOnShutdown); process.exit(0);
});
// Initialize cache loading // Initialize cache loading
loadCache().catch(err => logger.error('Cache initialization error:', err)); loadCache().catch(err => logger.error('Cache initialization error:', err));
@ -112,12 +89,7 @@ const geocodeAddress = async (address) => {
const cacheKey = normalized.toLowerCase(); const cacheKey = normalized.toLowerCase();
if (cache.has(cacheKey)) { if (cache.has(cacheKey)) {
// Treffer ans Ende rücken, damit die Verdrängung wirklich den am längsten return cache.get(cacheKey);
// ungenutzten Eintrag trifft und nicht bloß den ältesten eingefügten.
const hit = cache.get(cacheKey);
cache.delete(cacheKey);
cache.set(cacheKey, hit);
return hit;
} }
const elapsed = Date.now() - lastRequestTime; const elapsed = Date.now() - lastRequestTime;
@ -135,7 +107,8 @@ const geocodeAddress = async (address) => {
lastRequestTime = Date.now(); lastRequestTime = Date.now();
if (!Array.isArray(results) || results.length === 0) { if (!Array.isArray(results) || results.length === 0) {
rememberInCache(cacheKey, null); cache.set(cacheKey, null);
cacheModified = true;
return null; return null;
} }
@ -144,12 +117,15 @@ const geocodeAddress = async (address) => {
const lng = parseFloat(hit.lon); const lng = parseFloat(hit.lon);
if (Number.isNaN(lat) || Number.isNaN(lng)) { if (Number.isNaN(lat) || Number.isNaN(lng)) {
rememberInCache(cacheKey, null); cache.set(cacheKey, null);
cacheModified = true;
return null; return null;
} }
const coords = { lat, lng }; 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; return coords;
} catch (error) { } catch (error) {
logger.warn('Geocoding fehlgeschlagen', { logger.warn('Geocoding fehlgeschlagen', {

View File

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

View File

@ -183,4 +183,4 @@ frontend/src/
- `CORS_ORIGIN`: Erlaubter CORS-Origin - `CORS_ORIGIN`: Erlaubter CORS-Origin
### Frontend (.env) ### 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 # API Configuration
# For local development # For local development
VITE_API_URL=http://localhost:5000 REACT_APP_API_URL=http://localhost:5000
# For production, use your actual backend URL # 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) # 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/ ARG PUBLIC_URL=/nachsuche/
ENV PUBLIC_URL=$PUBLIC_URL ENV PUBLIC_URL=$PUBLIC_URL
ARG VITE_API_URL= ARG REACT_APP_API_URL=
ENV VITE_API_URL=$VITE_API_URL ENV REACT_APP_API_URL=$REACT_APP_API_URL
# Limit Node.js heap during build to prevent OOM kills # Limit Node.js heap during build to prevent OOM kills
ENV NODE_OPTIONS="--max_old_space_size=512" ENV NODE_OPTIONS="--max_old_space_size=512"

View File

@ -1,18 +1,16 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <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" /> <link rel="apple-touch-icon" href="%BASE_URL%icons/icon-192.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <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-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" /> <meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="NSS Heidekreis" /> <meta name="apple-mobile-web-app-title" content="NSS Heidekreis" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<!-- Pfad relativ zur Deployment-Basis: ein absolutes "/manifest.json" wuerde <link rel="manifest" href="/manifest.json" />
das Portal-Manifest laden und die App als Portal installieren. -->
<link rel="manifest" href="%BASE_URL%manifest.json" />
<meta <meta
name="description" name="description"
content="Nachsuchenstation Heidekreis Übersicht der Nachsuchenführer" 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_types text/plain text/xml application/xml+rss application/json;
gzip_disable "msie6"; gzip_disable "msie6";
# Security headers. # 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.
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;
# Cache static assets (images, fonts) # Cache static assets (images, fonts)
location ~* \.(jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$ { 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; expires 1y;
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
} }
# JS and CSS - no compression, short cache # JS and CSS - no compression, short cache
location ~* \.(js|css)$ { 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; expires 1h;
add_header Cache-Control "public, no-transform"; add_header Cache-Control "public, no-transform";
gzip off; gzip off;
@ -56,9 +47,6 @@ server {
# Service Worker - never cache # Service Worker - never cache
location = /sw.js { 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"; add_header Cache-Control "no-cache, no-store, must-revalidate";
expires 0; expires 0;
} }
@ -91,9 +79,6 @@ server {
# Service Worker for subpath deployment # Service Worker for subpath deployment
location = /nachsuche/sw.js { 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"; add_header Cache-Control "no-cache, no-store, must-revalidate";
expires 0; expires 0;
alias /usr/share/nginx/html/sw.js; alias /usr/share/nginx/html/sw.js;
@ -101,9 +86,6 @@ server {
# index.html - never cache so new builds are picked up immediately # index.html - never cache so new builds are picked up immediately
location = /index.html { 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 Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache"; add_header Pragma "no-cache";
expires 0; expires 0;
@ -111,9 +93,6 @@ server {
# SPA fallback for subpath deployment (/nachsuche) # SPA fallback for subpath deployment (/nachsuche)
location ^~ /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; rewrite ^/nachsuche(/.*)$ $1 break;
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header Cache-Control "no-cache, no-store, must-revalidate";
@ -123,9 +102,6 @@ server {
# SPA fallback - serve index.html for all routes # SPA fallback - serve index.html for all routes
location / { 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; try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache"; add_header Pragma "no-cache";
@ -134,9 +110,6 @@ server {
# Health check endpoint # Health check endpoint
location /health { 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; access_log off;
return 200 "OK\n"; return 200 "OK\n";
add_header Content-Type text/plain; 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", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "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", "axios": "^1.13.5",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^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": { "scripts": {
"start": "vite", "start": "react-scripts start",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
}, },
"browserslist": { "browserslist": {
"production": [ "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", "short_name": "NSS Heidekreis",
"name": "Nachsuchenstation Heidekreis", "name": "Nachsuchenstation Heidekreis",
"icons": [ "icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{ {
"src": "icons/icon-192.png", "src": "icons/icon-192.png",
"sizes": "192x192", "sizes": "192x192",
@ -19,7 +24,7 @@
"scope": "/nachsuche/", "scope": "/nachsuche/",
"display": "standalone", "display": "standalone",
"orientation": "portrait", "orientation": "portrait",
"theme_color": "#1a3d1a", "theme_color": "#2d6a2d",
"background_color": "#e8e8e2", "background_color": "#ffffff",
"description": "Nachsuchenstation Heidekreis Übersicht der Nachsuchenführer" "description": "Nachsuchenstation Heidekreis Übersicht der Nachsuchenführer"
} }

View File

@ -1,93 +1,16 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#1a3d1a" /> <title>Offline - Nachsuchenführer</title>
<title>Offline Jagd Apps Heidekreis</title>
<style> <style>
/* Eigenständige Seite: sie wird vom Service Worker ausgeliefert, wenn das body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
Netz fehlt, und kann deshalb keine Stylesheets der App nachladen. h1 { color: #333; }
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; }
</style> </style>
</head> </head>
<body> <body>
<div class="card"> <h1>Du bist offline</h1>
<div class="icon" role="img" aria-label="Kein Empfang">📡</div> <p>Die App ist derzeit nicht verfügbar. Bitte überprüfe deine Internetverbindung.</p>
<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>
</body> </body>
</html> </html>

View File

@ -2,203 +2,43 @@
box-sizing: border-box; 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 { :root {
--font-display: Georgia, 'Times New Roman', serif; --color-primary: #2e8b2e;
--font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', --color-primary-dark: #1e6b1e;
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; --color-primary-accent: #8B0D12;
--font-mono: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; --color-secondary: #6c757d;
--color-secondary-dark: #5a6268;
/* Flächen */ --color-success: #28a745;
--color-bg: #e8e8e2; --color-success-dark: #218838;
--color-surface: #f4f4ee; --color-danger: #dc3545;
--color-surface-alt: #dedcd2; --color-danger-dark: #c82333;
--color-muted-bg: #dedcd2; --color-bg: #f5f5f0;
--color-surface: #ffffff;
/* Text */
--color-text: #1a1a1a; --color-text: #1a1a1a;
--color-text-muted: #55554c; --color-text-muted: #555;
--color-border: #b8d4b8;
/* Rahmen: -border ist dekorativ (Trennlinien, Karten), --color-border-strong: #88b888;
-border-strong begrenzt Bedienelemente und erfüllt die 3:1-Anforderung. */ --color-focus: rgba(46, 139, 46, 0.2);
--color-border: #c9c9b8; --color-muted-bg: #f5f5f0;
--color-border-strong: #7d7d68; --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.2);
/* Aktionsfarben jeweils mit der zugehörigen Textfarbe, damit die --radius-sm: 4px;
Dunkelvariante nicht auf weißem Text auf hellem Grund landet. */ --radius-md: 8px;
--color-primary: #2d5a2d; --radius-pill: 12px;
--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;
--space-1: 0.5rem; --space-1: 0.5rem;
--space-2: 1rem; --space-2: 1rem;
--space-3: 1.5rem; --space-3: 1.5rem;
--space-4: 2rem; --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 { .btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: var(--space-1); gap: var(--space-1);
min-height: var(--touch-target);
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border: none; border: none;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-family: var(--font-ui);
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
@ -206,24 +46,15 @@ code {
} }
.btn:disabled { .btn:disabled {
background: var(--color-surface-alt); background: var(--color-border);
color: var(--color-text-muted); color: var(--color-text-muted);
cursor: not-allowed; cursor: not-allowed;
box-shadow: none; 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 { .btn-primary {
background: var(--color-primary); background: var(--color-primary);
color: var(--color-on-primary); color: white;
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
@ -233,7 +64,7 @@ a:focus-visible {
.btn-secondary { .btn-secondary {
background: var(--color-secondary); background: var(--color-secondary);
color: var(--color-on-secondary); color: white;
} }
.btn-secondary:hover:not(:disabled) { .btn-secondary:hover:not(:disabled) {
@ -241,9 +72,19 @@ a:focus-visible {
transform: translateY(-1px); 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 { .btn-danger {
background: var(--color-danger); background: var(--color-danger);
color: var(--color-on-danger); color: white;
} }
.btn-danger:hover:not(:disabled) { .btn-danger:hover:not(:disabled) {
@ -255,13 +96,9 @@ a:focus-visible {
.select, .select,
.textarea { .textarea {
width: 100%; width: 100%;
min-height: var(--touch-target);
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border-strong); border: 1px solid var(--color-border-strong);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-family: var(--font-ui);
font-size: 1rem; font-size: 1rem;
box-sizing: border-box; box-sizing: border-box;
} }
@ -274,19 +111,33 @@ a:focus-visible {
box-shadow: 0 0 0 3px var(--color-focus); 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 { .panel-title {
margin: 0 0 var(--space-2); margin: 0 0 var(--space-2);
font-family: var(--font-display);
font-size: 1.1rem; font-size: 1.1rem;
color: var(--color-text); 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 { .App {
/* dvh statt vh: mit ein- und ausblendender Adressleiste auf Mobilgeräten min-height: 100vh;
entsteht mit vh sonst Überlauf. */
min-height: 100dvh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@ -300,26 +151,44 @@ a:focus-visible {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
min-height: 100dvh; min-height: 100vh;
font-size: 1.2rem; font-size: 1.2rem;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
/* Wer Bewegung reduziert haben möchte, bekommt keine Animationen. */ .login-prompt {
@media (prefers-reduced-motion: reduce) { position: fixed;
*, bottom: 20px;
*::before, right: 20px;
*::after { }
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important; .login-button-header {
transition-duration: 0.01ms !important; padding: 0.75rem 1.5rem;
scroll-behavior: auto !important; 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), .login-button-header {
.btn-primary:hover:not(:disabled), padding: 0.6rem 1.2rem;
.btn-secondary:hover:not(:disabled), font-size: 0.9rem;
.btn-danger:hover:not(:disabled) {
transform: none;
} }
} }

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import UserList from '../users/UserList';
import ExportButton from './ExportButton'; import ExportButton from './ExportButton';
import Trash from './Trash'; import Trash from './Trash';
import AuditLogs from './AuditLogs'; 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 { getFullConfig, updateConfig, uploadLogo, deleteLogo } from '../../services/config';
import ErrorMessage from '../common/ErrorMessage'; import ErrorMessage from '../common/ErrorMessage';
import './AdminPanel.css'; 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) => { const handleConfigUpdate = async (updatedConfig) => {
try { try {
const response = await updateConfig(updatedConfig); const response = await updateConfig(updatedConfig);
@ -197,7 +187,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
const file = e.target.files[0]; const file = e.target.files[0];
if (!file) return; if (!file) return;
if (!file.type.startsWith('image/')) { if (!file.type.startsWith('image/')) {
showNotification('error', 'Nur Bilddateien erlaubt (JPG, PNG, WebP)'); showNotification('error', 'Nur Bilddateien erlaubt (JPG, PNG, SVG, ...)');
return; return;
} }
if (file.size > 500 * 1024) { if (file.size > 500 * 1024) {
@ -301,7 +291,6 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
onUserDelete={handleUserDelete} onUserDelete={handleUserDelete}
onPhotoUpload={handleUserPhotoUpload} onPhotoUpload={handleUserPhotoUpload}
onPhotoDelete={handleUserPhotoDelete} onPhotoDelete={handleUserPhotoDelete}
onGenerateInvite={handleGenerateInvite}
/> />
</> </>
)} )}
@ -419,7 +408,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
</button> </button>
)} )}
</div> </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>
)} )}
</div> </div>

View File

@ -13,8 +13,8 @@
gap: 12px; gap: 12px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.audit-header h2 { margin: 0 0 4px 0; color: var(--color-text); font-size: 26px; } .audit-header h2 { margin: 0 0 4px 0; color: #333; font-size: 26px; }
.audit-description { margin: 0; color: var(--color-text-muted); font-size: 13px; } .audit-description { margin: 0; color: #666; font-size: 13px; }
.audit-header-actions { .audit-header-actions {
display: flex; display: flex;
@ -32,24 +32,24 @@
} }
.btn-stats-toggle { .btn-stats-toggle {
padding: 6px 14px; padding: 6px 14px;
background: var(--color-surface-alt); background: #f0f4f8;
border: 1px solid var(--color-border); border: 1px solid #d0d7de;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
font-size: 13px; 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; } .stats-days-select { font-size: 13px; }
.audit-stats { .audit-stats {
background: var(--color-surface-alt); background: #f8fafc;
border: 1px solid var(--color-border); border: 1px solid #e2e8f0;
border-radius: 10px; border-radius: 10px;
padding: 16px; padding: 16px;
margin-bottom: 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 { .stats-row {
display: flex; display: flex;
@ -60,18 +60,18 @@
.stat-card { .stat-card {
flex: 1; flex: 1;
min-width: 100px; min-width: 100px;
background: var(--color-surface); background: white;
border-radius: 8px; border-radius: 8px;
padding: 12px 16px; padding: 12px 16px;
text-align: center; 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-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-label { font-size: 11px; color: #666; margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
.stat-total .stat-number { color: var(--color-info-text); } .stat-total .stat-number { color: #1976d2; }
.stat-failed .stat-number { color: var(--color-danger-text); } .stat-failed .stat-number { color: #c62828; }
.stat-success .stat-number { color: var(--color-success-text); } .stat-success .stat-number { color: #2e7d32; }
.stat-period .stat-number { color: var(--color-text-muted); font-size: 20px; } .stat-period .stat-number { color: #555; font-size: 20px; }
.stats-details { .stats-details {
display: flex; display: flex;
@ -79,7 +79,7 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.stats-col { flex: 1; min-width: 180px; } .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 { .stat-bar-row {
display: flex; display: flex;
@ -88,8 +88,8 @@
gap: 8px; gap: 8px;
margin-bottom: 5px; margin-bottom: 5px;
} }
.stat-bar-label { font-size: 13px; color: var(--color-text-muted); } .stat-bar-label { font-size: 13px; color: #444; }
.stat-bar-count { font-size: 13px; font-weight: 600; color: var(--color-text); flex-shrink: 0; } .stat-bar-count { font-size: 13px; font-weight: 600; color: #333; flex-shrink: 0; }
/* Mini bar chart */ /* Mini bar chart */
.stats-chart-col { flex: 2; min-width: 220px; } .stats-chart-col { flex: 2; min-width: 220px; }
@ -98,7 +98,7 @@
align-items: flex-end; align-items: flex-end;
gap: 3px; gap: 3px;
height: 80px; height: 80px;
background: var(--color-surface-alt); background: #f0f4f8;
border-radius: 6px; border-radius: 6px;
padding: 6px 6px 0; padding: 6px 6px 0;
} }
@ -110,7 +110,7 @@
} }
.chart-bar { .chart-bar {
width: 100%; width: 100%;
background: var(--color-info); background: #1976d2;
border-radius: 2px 2px 0 0; border-radius: 2px 2px 0 0;
position: relative; position: relative;
min-height: 4px; min-height: 4px;
@ -121,7 +121,7 @@
bottom: 0; bottom: 0;
left: 0; left: 0;
width: 100%; width: 100%;
background: var(--color-danger); background: #e53935;
} }
/* ── Filters ─────────────────────────────────────────────────────── */ /* ── Filters ─────────────────────────────────────────────────────── */
@ -134,52 +134,52 @@
} }
.filter-select { .filter-select {
padding: 8px 12px; padding: 8px 12px;
border: 1.5px solid var(--color-border); border: 1.5px solid #d0d7de;
border-radius: 6px; border-radius: 6px;
font-size: 13px; font-size: 13px;
background: var(--color-surface); background: white;
cursor: pointer; cursor: pointer;
} }
.filter-select:focus { outline: none; border-color: var(--color-primary); } .filter-select:focus { outline: none; border-color: #1976d2; }
.filter-input { .filter-input {
padding: 8px 12px; padding: 8px 12px;
border: 1.5px solid var(--color-border); border: 1.5px solid #d0d7de;
border-radius: 6px; border-radius: 6px;
font-size: 13px; font-size: 13px;
background: var(--color-surface); background: white;
min-width: 140px; 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; } .filter-date { min-width: 130px; }
.btn-refresh { .btn-refresh {
padding: 8px 14px; padding: 8px 14px;
background: var(--color-info); background: #1976d2;
color: var(--color-on-info); color: white;
border: none; border: none;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
} }
.btn-refresh:hover:not(:disabled) { background: var(--color-info-dark); } .btn-refresh:hover:not(:disabled) { background: #1565c0; }
.btn-refresh:disabled { background: var(--color-surface-alt); color: var(--color-text-muted); cursor: not-allowed; } .btn-refresh:disabled { background: #bdbdbd; cursor: not-allowed; }
.btn-reset { .btn-reset {
padding: 8px 12px; padding: 8px 12px;
background: var(--color-surface); background: white;
color: var(--color-text-muted); color: #666;
border: 1.5px solid var(--color-border); border: 1.5px solid #d0d7de;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 13px;
} }
.btn-reset:hover { background: var(--color-surface-alt); } .btn-reset:hover { background: #f5f5f5; }
.btn-export { .btn-export {
padding: 8px 16px; padding: 8px 16px;
background: var(--color-success); background: #2e7d32;
color: var(--color-on-success); color: white;
border: none; border: none;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
@ -187,15 +187,15 @@
font-weight: 500; font-weight: 500;
white-space: nowrap; white-space: nowrap;
} }
.btn-export:hover:not(:disabled) { background: var(--color-success-dark); } .btn-export:hover:not(:disabled) { background: #1b5e20; }
.btn-export:disabled { background: var(--color-surface-alt); color: var(--color-text-muted); cursor: not-allowed; } .btn-export:disabled { background: #bdbdbd; cursor: not-allowed; }
.auto-refresh-toggle { .auto-refresh-toggle {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
font-size: 13px; font-size: 13px;
color: var(--color-text-muted); color: #555;
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
} }
@ -206,7 +206,7 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
font-size: 13px; font-size: 13px;
color: var(--color-text-muted); color: #666;
margin-bottom: 10px; margin-bottom: 10px;
} }
.limit-select { padding: 4px 8px; font-size: 13px; } .limit-select { padding: 4px 8px; font-size: 13px; }
@ -224,16 +224,16 @@
} }
.badge-create { background: #e8f5e9; color: #2e7d32; } .badge-create { background: #e8f5e9; color: #2e7d32; }
.badge-update { background: #e3f2fd; color: #1565c0; } .badge-update { background: #e3f2fd; color: #1565c0; }
.badge-delete { background: #ffebee; color: var(--color-danger-text); } .badge-delete { background: #ffebee; color: #c62828; }
.badge-restore { background: #fff3e0; color: #a83a00; } .badge-restore { background: #fff3e0; color: #e65100; }
.badge-login { background: #f3e5f5; color: #6a1b9a; } .badge-login { background: #f3e5f5; color: #6a1b9a; }
.badge-logout { background: #fce4ec; color: #880e4f; } .badge-logout { background: #fce4ec; color: #880e4f; }
.badge-login-failed { background: #ffcdd2; color: #b71c1c; font-weight: 700; } .badge-login-failed { background: #ffcdd2; color: #b71c1c; font-weight: 700; }
.badge-import { background: #e0f7fa; color: #00695c; } .badge-import { background: #e0f7fa; color: #00695c; }
.badge-export { background: #e8f5e9; color: #1b5e20; } .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-bulk-delete { background: #fbe9e7; color: #bf360c; }
.badge-password { background: #fff8e1; color: #9c4e00; } .badge-password { background: #fff8e1; color: #f57f17; }
.badge-default { background: #f5f5f5; color: #616161; } .badge-default { background: #f5f5f5; color: #616161; }
.status-code { .status-code {
@ -244,16 +244,16 @@
font-family: monospace; font-family: monospace;
} }
.status-ok { background: #e8f5e9; color: #2e7d32; } .status-ok { background: #e8f5e9; color: #2e7d32; }
.status-redirect { background: #fff3e0; color: #a83a00; } .status-redirect { background: #fff3e0; color: #e65100; }
.status-error { background: #ffebee; color: var(--color-danger-text); } .status-error { background: #ffebee; color: #c62828; }
.status-server-error { background: #f3e5f5; color: #6a1b9a; } .status-server-error { background: #f3e5f5; color: #6a1b9a; }
.duration-badge { .duration-badge {
padding: 2px 8px; padding: 2px 8px;
border-radius: 4px; border-radius: 4px;
font-size: 11px; font-size: 11px;
background: var(--color-surface-alt); background: #f5f5f5;
color: var(--color-text-muted); color: #777;
font-family: monospace; font-family: monospace;
} }
@ -261,24 +261,24 @@
.audit-empty { .audit-empty {
text-align: center; text-align: center;
padding: 40px; padding: 40px;
background: var(--color-surface-alt); background: #f9f9f9;
border-radius: 8px; border-radius: 8px;
color: var(--color-text-muted); color: #888;
} }
.audit-error { text-align: center; padding: 20px; } .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-list { display: flex; flex-direction: column; gap: 8px; }
.audit-item { .audit-item {
background: var(--color-surface); background: white;
border: 1px solid var(--color-border); border: 1px solid #e0e0e0;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
transition: box-shadow 0.15s; transition: box-shadow 0.15s;
} }
.audit-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); } .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 { .audit-item-header {
display: flex; display: flex;
@ -286,18 +286,18 @@
gap: 8px; gap: 8px;
padding: 10px 14px; padding: 10px 14px;
flex-wrap: wrap; flex-wrap: wrap;
background: var(--color-surface-alt); background: #fafafa;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid #f0f0f0;
} }
.audit-time { margin-left: auto; color: var(--color-text-muted); font-size: 12px; white-space: nowrap; } .audit-time { margin-left: auto; color: #999; font-size: 12px; white-space: nowrap; }
.expand-toggle { color: var(--color-text-muted); font-size: 11px; cursor: pointer; padding: 0 4px; } .expand-toggle { color: #aaa; font-size: 11px; cursor: pointer; padding: 0 4px; }
.audit-resource { .audit-resource {
padding: 3px 8px; padding: 3px 8px;
background: var(--color-surface-alt); background: #f0f0f0;
border-radius: 4px; border-radius: 4px;
font-size: 12px; font-size: 12px;
color: var(--color-text-muted); color: #555;
} }
.audit-item-body { .audit-item-body {
@ -308,16 +308,16 @@
font-size: 13px; font-size: 13px;
align-items: center; align-items: center;
} }
.audit-admin { color: var(--color-text); } .audit-admin { color: #333; }
.audit-resource-name { color: var(--color-text-muted); } .audit-resource-name { color: #555; }
.audit-ip { color: var(--color-text-muted); font-family: monospace; font-size: 12px; } .audit-ip { color: #999; font-family: monospace; font-size: 12px; }
.audit-failed-badge { color: var(--color-danger-text); font-weight: 600; font-size: 12px; } .audit-failed-badge { color: #c62828; font-weight: 600; font-size: 12px; }
/* ── Expanded detail ─────────────────────────────────────────────── */ /* ── Expanded detail ─────────────────────────────────────────────── */
.audit-item-detail { .audit-item-detail {
padding: 10px 14px 14px; padding: 10px 14px 14px;
border-top: 1px dashed var(--color-border); border-top: 1px dashed #e0e0e0;
background: var(--color-surface); background: #fefefe;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
@ -330,7 +330,7 @@
} }
.detail-label { .detail-label {
font-weight: 600; font-weight: 600;
color: var(--color-text-muted); color: #888;
font-size: 11px; font-size: 11px;
text-transform: uppercase; text-transform: uppercase;
min-width: 70px; min-width: 70px;
@ -338,23 +338,23 @@
} }
.detail-row code { .detail-row code {
font-family: monospace; font-family: monospace;
background: var(--color-surface-alt); background: #f0f0f0;
padding: 2px 6px; padding: 2px 6px;
border-radius: 3px; border-radius: 3px;
font-size: 12px; font-size: 12px;
} }
.detail-ua-full { .detail-ua-full {
color: var(--color-text-muted); color: #aaa;
cursor: help; cursor: help;
font-size: 12px; font-size: 12px;
} }
.detail-error { color: var(--color-danger-text); } .detail-error { color: #c62828; }
.detail-error .detail-label { color: var(--color-danger-text); } .detail-error .detail-label { color: #c62828; }
.meta-tag { .meta-tag {
display: inline-block; display: inline-block;
background: var(--color-surface-alt); background: #e8eaf6;
color: var(--color-info-text); color: #283593;
border-radius: 4px; border-radius: 4px;
padding: 2px 8px; padding: 2px 8px;
font-size: 12px; font-size: 12px;
@ -370,17 +370,17 @@
margin-top: 6px; margin-top: 6px;
} }
.diff-table th { .diff-table th {
background: var(--color-surface-alt); background: #f0f0f0;
padding: 5px 10px; padding: 5px 10px;
text-align: left; text-align: left;
font-size: 11px; font-size: 11px;
text-transform: uppercase; text-transform: uppercase;
color: var(--color-text-muted); color: #666;
border: 1px solid var(--color-border); border: 1px solid #e0e0e0;
} }
.diff-table td { padding: 5px 10px; border: 1px solid var(--color-border); vertical-align: top; } .diff-table td { padding: 5px 10px; border: 1px solid #e8e8e8; 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-field { font-weight: 600; color: #444; font-family: monospace; white-space: nowrap; background: #fafafa; }
.diff-before { color: var(--color-danger-text); background: #fff5f5; font-family: monospace; word-break: break-all; } .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; } .diff-after { color: #2e7d32; background: #f5fff5; font-family: monospace; word-break: break-all; }
/* ── Pagination ──────────────────────────────────────────────────── */ /* ── Pagination ──────────────────────────────────────────────────── */
@ -393,16 +393,16 @@
} }
.btn-page { .btn-page {
padding: 7px 14px; padding: 7px 14px;
border: 1.5px solid var(--color-border); border: 1.5px solid #d0d7de;
border-radius: 6px; border-radius: 6px;
background: var(--color-surface); background: white;
cursor: pointer; cursor: pointer;
font-size: 13px; 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:hover:not(:disabled) { background: #f0f4f8; border-color: #1976d2; }
.btn-page:disabled { color: var(--color-text-muted); cursor: not-allowed; border-color: var(--color-border); } .btn-page:disabled { color: #bbb; cursor: not-allowed; border-color: #eee; }
.page-info { font-size: 13px; color: var(--color-text-muted); padding: 0 8px; } .page-info { font-size: 13px; color: #666; padding: 0 8px; }
.audit-header { .audit-header {
@ -411,13 +411,13 @@
.audit-header h2 { .audit-header h2 {
margin: 0 0 8px 0; margin: 0 0 8px 0;
color: var(--color-text); color: #333;
font-size: 28px; font-size: 28px;
} }
.audit-description { .audit-description {
margin: 0; margin: 0;
color: var(--color-text-muted); color: #666;
font-size: 14px; font-size: 14px;
} }
@ -428,7 +428,7 @@
} }
.audit-error p { .audit-error p {
color: var(--color-danger-text); color: #d32f2f;
margin: 0; margin: 0;
} }
@ -441,23 +441,23 @@
.filter-select { .filter-select {
padding: 10px 14px; padding: 10px 14px;
border: 2px solid var(--color-border); border: 2px solid #e0e0e0;
border-radius: 6px; border-radius: 6px;
font-size: 14px; font-size: 14px;
background: var(--color-surface); background: white;
cursor: pointer; cursor: pointer;
transition: border-color 0.2s; transition: border-color 0.2s;
} }
.filter-select:focus { .filter-select:focus {
outline: none; outline: none;
border-color: var(--color-primary); border-color: #1976d2;
} }
.btn-refresh { .btn-refresh {
padding: 10px 16px; padding: 10px 16px;
background: var(--color-info); background: #1976d2;
color: var(--color-on-info); color: white;
border: none; border: none;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
@ -467,21 +467,20 @@
} }
.btn-refresh:hover:not(:disabled) { .btn-refresh:hover:not(:disabled) {
background: var(--color-info-dark); background: #1565c0;
} }
.btn-refresh:disabled { .btn-refresh:disabled {
background: var(--color-surface-alt); background: #bdbdbd;
color: var(--color-text-muted);
cursor: not-allowed; cursor: not-allowed;
} }
.audit-empty { .audit-empty {
text-align: center; text-align: center;
padding: 40px; padding: 40px;
background: var(--color-surface-alt); background: #f5f5f5;
border-radius: 8px; border-radius: 8px;
color: var(--color-text-muted); color: #666;
} }
.audit-list { .audit-list {
@ -491,8 +490,8 @@
} }
.audit-item { .audit-item {
background: var(--color-surface); background: white;
border: 1px solid var(--color-border); border: 1px solid #e0e0e0;
border-radius: 8px; border-radius: 8px;
padding: 16px; padding: 16px;
transition: box-shadow 0.2s; transition: box-shadow 0.2s;
@ -520,18 +519,58 @@
gap: 4px; 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 { .audit-resource {
padding: 4px 10px; padding: 4px 10px;
background: var(--color-surface-alt); background: #f5f5f5;
border-radius: 4px; border-radius: 4px;
font-size: 13px; font-size: 13px;
color: var(--color-text-muted); color: #666;
} }
.audit-time { .audit-time {
margin-left: auto; margin-left: auto;
color: var(--color-text-muted); color: #999;
font-size: 13px; font-size: 13px;
} }
@ -543,19 +582,23 @@
} }
.audit-admin { .audit-admin {
color: var(--color-text); color: #333;
} }
.audit-resource-name { .audit-resource-name {
color: var(--color-text-muted); color: #666;
} }
.audit-ip { .audit-ip {
color: var(--color-text-muted); color: #888;
font-family: monospace; font-family: monospace;
font-size: 13px; font-size: 13px;
} }
.audit-error-message {
color: #d32f2f;
font-weight: 500;
}
.audit-pagination { .audit-pagination {
display: flex; display: flex;
@ -564,15 +607,15 @@
gap: 16px; gap: 16px;
margin-top: 24px; margin-top: 24px;
padding: 16px; padding: 16px;
background: var(--color-surface); background: white;
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--color-border); border: 1px solid #e0e0e0;
} }
.btn-page { .btn-page {
padding: 8px 16px; padding: 8px 16px;
background: var(--color-info); background: #1976d2;
color: var(--color-on-info); color: white;
border: none; border: none;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
@ -581,17 +624,16 @@
} }
.btn-page:hover:not(:disabled) { .btn-page:hover:not(:disabled) {
background: var(--color-info-dark); background: #1565c0;
} }
.btn-page:disabled { .btn-page:disabled {
background: var(--color-surface-alt); background: #bdbdbd;
color: var(--color-text-muted);
cursor: not-allowed; cursor: not-allowed;
} }
.page-info { .page-info {
color: var(--color-text-muted); color: #666;
font-size: 14px; font-size: 14px;
} }
@ -614,33 +656,3 @@
width: 100%; 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 { .export-button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background: var(--color-success); background: var(--color-success);
color: var(--color-on-success); color: white;
border: none; border: none;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
cursor: pointer; cursor: pointer;
@ -41,8 +41,8 @@
.import-button { .import-button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background: var(--color-primary, var(--color-info)); background: var(--color-primary, #2563eb);
color: var(--color-on-primary); color: white;
border: none; border: none;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
cursor: pointer; cursor: pointer;
@ -52,7 +52,7 @@
} }
.import-button:hover:not(:disabled) { .import-button:hover:not(:disabled) {
background: var(--color-primary-dark, var(--color-info-dark)); background: var(--color-primary-dark, #1d4ed8);
} }
.import-button:disabled { .import-button:disabled {
@ -65,7 +65,7 @@
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4rem;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--color-text-muted); color: var(--color-text-muted, #6b7280);
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
} }

View File

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

View File

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

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