Compare commits
No commits in common. "dd39a7aff213d586a1ff75e25d47ce5726ad3be6" and "d9fecb6914972780d8320d380d1695d3a83ede0c" have entirely different histories.
dd39a7aff2
...
d9fecb6914
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)',
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
@ -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', {
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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": [
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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' ? (
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
||||||
|
|
|
||||||
|
|
@ -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) || '/';
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)',
|
||||||
|
|
|
||||||
|
|
@ -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 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'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
@ -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', {
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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": [
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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' ? (
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Reference in New Issue