Compare commits

...

8 Commits

Author SHA1 Message Date
thomas dd39a7aff2 feat(design): Bildsprache des Portals übernehmen, Nachtansicht ergänzen
Die visuelle Ebene war bisher nie geprüft. Zwei Entscheidungen lagen dem
zugrunde: die Apps folgen der Bildsprache des Portals, und es gibt eine
Nachtansicht über prefers-color-scheme — Nachsuchen finden in der
Dämmerung und nachts statt, ein weißes Vollbild blendet dann.

Design-Tokens (App.css ist jetzt die einzige Farbquelle):
- Palette, Georgia-Serif für die Überschriftenebene und die kantigen
  2px-Ecken vom Portal übernommen; Sans bleibt für funktionale UI-Texte,
  wie es das Portal ebenfalls handhabt.
- Aktionsfarben haben jetzt eine zugehörige --color-on-*-Textfarbe. Ohne
  das wäre in der Nachtansicht weißer Text auf hellgrünem Grund gelandet.
- 307 fest verdrahtete Farbwerte pro App auf Tokens umgestellt,
  eigenschafts-abhängig (color/background/border getrennt). Das Schlüssel-
  wort "white" war dabei der gefährlichste Fall: als Textfarbe auf einer
  Vollton-Fläche musste es zu --color-on-* werden, als Fläche zu
  --color-surface.
- theme-color war dreifach verschieden (#2d6a2d gehörte zu keiner der
  beiden Paletten); Portal und alle Apps nutzen jetzt einen Wert.

Behobene Kontrastfehler (alle nachgerechnet, nicht geschätzt):
- .btn-success 3,13:1 und .btn-primary 4,33:1 lagen unter AA — der
  Hauptaktionsbutton der gesamten Anwendung.
- Feldrahmen 2,27:1 und Kartenrahmen 1,60:1 (Soll 3,0 für Bedienelemente).
- .badge-restore 3,46:1 und .badge-password 2,49:1 im Audit-Log.
Geprüft: 36 Paare in hell UND dunkel, 0 Verstöße.

Weitere visuelle Befunde:
- .type-badge war in PublicUserList.css und UserCard.css doppelt
  definiert. CSS ist global, die Admin-Variante gewann — die öffentliche
  Liste zeigte die falsche Badge-Optik. Fünf solcher Kollisionen zwischen
  öffentlicher Liste und Admin-Karte sind jetzt auf ihren Container
  eingegrenzt.
- Formularfelder hatten keine Hintergrundfarbe und nahmen den Browser-
  Standard: in der Nachtansicht leuchtete jedes Eingabefeld weiß. Fiel
  erst im Screenshot auf.
- Die Kartenkacheln sind immer taghell und waren nachts die mit Abstand
  hellste Fläche. Sie werden jetzt abgedunkelt, Marker bleiben hell.
- Die Badge-Definitionen im Audit-Log standen doppelt in derselben Datei
  (8 Regeln, identische Werte).

Mobiltauglichkeit:
- .user-phone war ein 14 px hoher Textlink — der tel:-Link ist der Zweck
  der öffentlichen Liste. Jetzt ein Ziel mit 44 px Mindesthöhe.
- Kopfzeile: Logo verkleinert, .app-title umbricht statt nowrap,
  Navigationsschaltflächen auf 44 px, Breakpoint für ≤480px ergänzt,
  auf schmalen Geräten nicht mehr klebend.
- 100vh -> 100dvh (Adressleiste erzeugte sonst Überlauf).
- :focus-visible für Buttons; prefers-reduced-motion respektiert.
- Geprüft mit Screenshots bei 360 px und 768 px, hell und dunkel:
  kein horizontaler Überlauf.

Aufgeräumt:
- logo-fallingbostel.png und logo-ljn.png entfernt: 898 KB pro App, von
  keiner Zeile Code referenziert, 60 % der Buildgröße und fast doppelt so
  groß wie der gesamte Anwendungscode. Build 1,5 MB -> 632 KB.
- Tote Stile (.login-prompt, .btn-success, .panel, .user-gps u. a.),
  doppelte body-Regel in index.css, Variablentippfehler --color-bg-muted.
- Die Offline-Seite der Apps war lang="en" und ungestaltet — genau die
  Seite, die im Funkloch erscheint. Jetzt gestaltet, deutsch, mit
  Nachtansicht, für Portal und Apps identisch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 19:43:57 +02:00
thomas f4939ef6c5 fix(frontend): Reset-Link auswerten, Absturz bei defektem localStorage verhindern
- PasswordReset las den Query-Parameter token nie aus. Wer auf den Link
  in der Reset-Mail klickte, landete wieder auf Schritt 1 mit leerem
  Token-Feld — der Mailversand aus d9fecb6 lief damit ins Leere. Der
  Token wird jetzt aus der URL übernommen und direkt Schritt 2 gezeigt.
- "Zurück zum Login" und die Weiterleitung nach dem Zurücksetzen zeigten
  auf "/" und damit beim Unterpfad-Deployment auf das Portal.
- PublicUserList rief JSON.parse(localStorage.getItem(...)) ungeschützt
  im Render-Pfad auf. Ein beschädigter Eintrag — oder ein Browser, der
  Site-Data blockiert — ließ die gesamte öffentliche Liste weiß werden.
  Lesen und Schreiben laufen jetzt über try/catch, und der Wert wird auf
  Plausibilität geprüft.
- Der Typfilter-Effekt feuerte auch beim Mounten, obwohl useUsers bereits
  selbst lädt: jeder Aufruf der öffentlichen Liste setzte zwei identische
  Anfragen ab. Der erste Lauf wird jetzt übersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 19:42:37 +02:00
thomas 868699a1bc fix(backend): Geocode-Cache begrenzen, Reset-Link reparieren
- Der Geocode-Cache war nur auf dem Erfolgspfad begrenzt. Die beiden
  Negativ-Pfade (cache.set(key, null)) schrieben ungebremst — über den
  öffentlichen GET /api/public/geocode genügten erfundene Postleitzahlen,
  um Speicher und die alle 60 s geschriebene Cache-Datei beliebig wachsen
  zu lassen. Bei NODE_OPTIONS=--max_old_space_size=256 eine reale Grenze.
  Alle Schreibpfade laufen jetzt über rememberInCache(); loadCache()
  beschneidet eine bereits gewachsene Datei auf das Limit.
  Geprüft: eine künstlich auf 5000 Einträge aufgeblähte Cache-Datei wird
  beim Start auf 1000 reduziert.
- Der Cache war trotz Kommentar kein LRU, sondern ein FIFO: ein Treffer
  hat den Eintrag nicht ans Ende gerückt, häufig genutzte Adressen flogen
  zuerst raus. Treffer werden jetzt neu einsortiert.
- /api/public/geocode bekommt einen eigenen Limiter (20/15 min). Der
  Endpunkt löst ausgehende Nominatim-Anfragen aus und teilt sich mit dem
  Geocoding im Admin-Bereich die globale Wartezeit von 1100 ms.
  Geprüft: Anfragen 21-24 liefern 429.
- resetPassword prüfte 6 Zeichen, das Admin-Schema verlangt 12. Passwörter
  dazwischen kamen durch und scheiterten erst an der Mongoose-Validierung
  — beim Nutzer kam "Serverfehler" (500) an. Jetzt 400 mit klarer Meldung.
- APP_URL wird aktiv gesetzt statt auskommentiert, und der Rückfallwert
  hängt den Unterpfad an. Vorher fiel appUrl auf CORS_ORIGIN zurück, und
  der Link in der Reset-Mail zeigte auf das Portal statt in die App.
  Geprüft: erzeugte URL lautet .../nachsuche/passwort-zuruecksetzen?token=…
- Die beim Zusammenführen verlorengegangene Platzhalter-Prüfung für
  JWT_SECRET ist wieder da (fängt auch CHANGE_ME_IN_PRODUCTION).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 19:41:08 +02:00
thomas 474383d17b fix(nginx): Security-Header in überschreibenden location-Blöcken wiederholen
nginx vererbt add_header nicht in Blöcke, die eigene add_header setzen.
Dadurch gingen X-Frame-Options, X-Content-Type-Options und
X-XSS-Protection ausgerechnet bei den HTML-Antworten verloren – jeder
location-Block mit Cache-Control hat sie stillschweigend abgeschaltet.

Verifiziert: nginx -t ist für alle drei Konfigurationen erfolgreich.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 08:12:15 +02:00
thomas 81b21da499 fix(frontend): kaputtes UserCard-JSX reparieren, Einladungs-Token erzeugbar machen
- UserCard.js ließ sich nicht übersetzen: die öffnende Hälfte des Ternary
  um den GPS-Bereich ({isEditingGPS ? () fehlte, während das ") : (" und
  das ")}" stehen geblieben waren. isEditingGPS wurde dadurch nur noch
  gesetzt, nie gelesen. Da die Datei in allen drei Apps identisch ist,
  scheiterte überall der Build.
- Der Einladungs-Flow aus 8384ad9 war nur zur Hälfte da: Backend und
  Login-Formular verlangen einen Invite-Token, aber kein Frontend hat
  /invite-token je aufgerufen – der Admin konnte gar keinen erzeugen und
  ein Führer ohne Passwort kam nicht ins System. Die Benutzerkarte hat
  jetzt einen Button dafür (nur bei hinterlegter E-Mail), verdrahtet über
  UserList und AdminPanel.
- api.js: bei einem Fehler ohne Request-Config lief der Retry-Zweig in
  "config.retry = 2" auf undefined und verdeckte den echten Fehler.
- useUsers: "mehr laden" hat die aktiven Filter verworfen und die
  ungefilterte zweite Seite angehängt. Mit dem serverseitigen Typfilter
  aus d9fecb6 fällt das jetzt deutlich stärker auf.
- Die öffentliche Liste zeigte statt des Telefon-Emojis ein kaputtes
  Ersatzzeichen.
- Der Logo-Upload nannte SVG als erlaubtes Format, das Backend lehnt es
  bewusst ab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 08:11:55 +02:00
thomas 63f6d97318 fix(frontend): Vite-Build reparieren und Unterpfad-Deployment korrigieren
Der Build läuft über Vite ("build": "vite build"), der Code las
Umgebungsvariablen aber über process.env – das gibt es im Browser-Bundle
nicht. Damit war der Build-Arg REACT_APP_API_URL wirkungslos und der
Zugriff selbst lief in "process is not defined".

- constants.js nutzt import.meta.env und leitet den Deployment-Basispfad
  aus import.meta.env.BASE_URL ab (Fallback: Laufzeit-Erkennung).
  REACT_APP_* heißt überall VITE_* (Dockerfile, Compose, .env.example,
  Doku).
- /verwaltung und /passwort-zuruecksetzen wurden gegen
  window.location.pathname ohne Basispfad verglichen und trafen unter
  /nachsuche/ nie zu – die Passwort-Reset-Seite war produktiv nicht
  erreichbar. Neue Helfer withBase()/normalizePath().
- Der Service Worker wurde als '/sw.js' registriert und war damit der des
  Portals mit Scope '/'. Registrierung läuft jetzt über BASE_URL, inkl.
  passendem Scope.
- index.html verlinkte '/manifest.json' absolut, also das Portal-Manifest:
  die App hätte sich als Portal installiert. Jetzt %BASE_URL%.
- public/index.html (CRA-Rest) kollidierte im Vite-Build mit der
  index.html im Projektwurzelverzeichnis und wurde entfernt.
- manifest.json verwies auf ein favicon.ico, das es nicht gibt.
- package.json: Scripts auf Vite umgestellt. react-scripts,
  @testing-library/* und web-vitals entfernt – nichts davon wird
  importiert, und react-scripts stand im Widerspruch zum Build.
- Die ungenutzten Konstanten USER_TYPES/USER_TYPE_LABELS/RULES sind
  entfallen (die echten Werte kommen aus der Datenbank).
- Header bekam eine Prop onXLogin, die er gar nicht entgegennimmt und die
  den Logout-Handler durchreichte.

Verifiziert: alle drei Frontends bauen, im Bundle kein process.env mehr,
alle Pfade korrekt auf den jeweiligen Basispfad.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 08:10:55 +02:00
thomas 718d84a545 fix(backend): Seeding beendet nicht mehr den Server, Routing und Shutdown korrigiert
- REGRESSION aus d9fecb6: server.js ruft seed.js seit dem Wechsel von
  exec('node seed.js') auf require('./seed') im selben Prozess auf.
  seedDatabase() endet aber mit mongoose.connection.close() und
  process.exit(0) – auf einer frischen Datenbank hat sich der Server damit
  unmittelbar nach dem Seeding selbst beendet. Verbindungsabbau und Exit
  macht jetzt nur noch der CLI-Aufruf (npm run seed); zusätzlich verbindet
  seed.js nicht erneut, wenn die Verbindung bereits steht.
- Seeding hing an User.countDocuments(). Die Seed-Liste ist bewusst leer,
  die Bedingung war also bei jedem Start wahr, und seed.js hat die Config
  per findOneAndUpdate überschrieben: App-Name, Regeln und Texte des
  Admins gingen bei jedem Neustart verloren. Bedingung hängt jetzt an
  Admin und Config, die Config wird nur noch angelegt, nie überschrieben.
- DELETE /api/users/bulk war nicht erreichbar: die Route stand hinter
  /users/:id, Express hat den Pfad an deleteUser mit id="bulk" gegeben
  (CastError -> 404). Bulk-Routen stehen jetzt davor.
- getAdminActivity rief mongoose.Types.ObjectId() ohne new auf. Seit
  bson 5 ist das eine echte Klasse, der Endpunkt lief immer in einen
  500er. Ungültige IDs liefern jetzt 400 statt 500.
- connectDB beendete den Prozess mit process.exit(1), womit die
  Retry-Schleife in server.js toter Code war. Jetzt wird geworfen.
- geocode.js rief in seinen Signal-Handlern process.exit(0) und hat den
  Graceful Shutdown mittendrin abgeschnitten. Der Cache wird nur noch
  geschrieben; das Beenden gehört server.js, das jetzt auch SIGINT
  behandelt und einen Timeout hat.
- errorHandler stand vor dem Static-/SPA-Fallback und war damit nicht
  mehr letzte Middleware.
- Jagdsprache in seed.js: "Verbreiten Sie den Standort und den Anschuss"
  -> "Verbrechen". Da der Seed die Config anlegt, ist das der Text, den
  die Nutzer sehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 08:09:53 +02:00
thomas 4b61db82fd security: Rechte-Eskalation über Handler-Tokens schließen
Ergänzt die Absicherung aus 8384ad9/d9fecb6 um die Lücken, die dort offen
geblieben sind.

- authenticateToken prüft jetzt die Rolle. Der C-01-Fix vergleicht nur
  `decoded.app`, und genau den Claim tragen Handler-Tokens nicht
  ({ id, role: 'handler' }) – `if (decoded.app && ...)` lief bei ihnen
  also ins Leere. Ein eingeloggter Hundeführer konnte seinen Bearer-Token
  weiterhin gegen /api/users, /api/config und /api/audit-logs schicken und
  hatte volle Admin-Rechte. Admin-Tokens tragen zusätzlich role: 'admin';
  Tokens ohne role bleiben bis zum Ablauf gültig, niemand wird ausgeloggt.
- Logout wird als LOGOUT protokolliert statt als LOGIN, und die Route
  liest den Benutzernamen über attachUserIfPresent (neu). Vorher lag gar
  keine Auth-Middleware auf /logout, der Eintrag lautete immer "unknown".
- forgotPassword sucht case-insensitive wie der Login. Ein als "Thorsten"
  angelegtes Konto konnte sich als "thorsten" anmelden, aber kein Passwort
  zurücksetzen.
- CSV-Export escaped führende =, +, -, @ (neues utils/csv.js, von
  userController und auditController gemeinsam genutzt). Ein Name wie
  =HYPERLINK(...) wurde sonst von Excel als Formel ausgeführt.
- Massen-Operationen filtern soft-deleted Einträge; der pre(/^find/)-Hook
  des Modells greift bei updateMany nicht.
- Die öffentliche Liste liefert wieder phone, landline, address und photo.
  Die UI zeigt diese Felder seit jeher an, die API lieferte sie seit dem
  PII-Commit nicht mehr – Anrufen war öffentlich nicht mehr möglich.
  E-Mail, Hashes und Invite-Felder bleiben ausgeschlossen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 08:09:34 +02:00
189 changed files with 4322 additions and 58819 deletions

View File

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

View File

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

View File

@ -10,8 +10,10 @@ 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);
process.exit(1); throw error;
} }
}; };

View File

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

View File

@ -1,5 +1,7 @@
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
@ -99,8 +101,13 @@ 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([
{ $match: { adminId: require('mongoose').Types.ObjectId(adminId) } }, // ObjectId ist seit bson 5 eine echte Klasse und braucht new.
{ $match: { adminId: new mongoose.Types.ObjectId(adminId) } },
{ {
$group: { $group: {
_id: '$action', _id: '$action',
@ -214,13 +221,6 @@ const exportAuditLogs = async (req, res) => {
.limit(10000) .limit(10000)
.lean(); .lean();
const escapeCell = (val) => {
if (val == null) return '';
const str = String(val);
return str.includes(',') || str.includes('"') || str.includes('\n')
? `"${str.replace(/"/g, '""')}"` : str;
};
const header = [ const header = [
'Zeitstempel', 'Aktion', 'Ressource', 'Ressourcen-Name', 'Admin', 'Zeitstempel', 'Aktion', 'Ressource', 'Ressourcen-Name', 'Admin',
'IP-Adresse', 'Methode', 'Pfad', 'Status-Code', 'Dauer (ms)', 'IP-Adresse', 'Methode', 'Pfad', 'Status-Code', 'Dauer (ms)',

View File

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

View File

@ -1,6 +1,7 @@
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'];
@ -285,7 +286,9 @@ 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)
.select('name type available gps'), // Kontaktdaten sind der Zweck der oeffentlichen Liste; E-Mail, Hashes
// und Invite-Felder bleiben ausgeschlossen.
.select('name type available gps phone landline address photo'),
User.countDocuments(filter) User.countDocuments(filter)
]); ]);
@ -411,14 +414,6 @@ 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 => [
@ -590,8 +585,10 @@ 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 } }, { _id: { $in: ids }, deleted: { $ne: true } },
{ $set: updateFields } { $set: updateFields }
); );
@ -629,8 +626,10 @@ 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 } }, { _id: { $in: ids }, deleted: { $ne: true } },
{ {
$set: { $set: {
deleted: true, deleted: true,

View File

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

View File

@ -1,6 +1,7 @@
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;
@ -20,6 +21,7 @@ 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({
@ -27,6 +29,20 @@ 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) {
@ -37,4 +53,30 @@ const authenticateToken = (req, res, next) => {
} }
}; };
module.exports = { authenticateToken }; // Wie authenticateToken, blockiert aber nicht: setzt req.user wenn ein gültiges
// Admin-Token vorliegt und ruft ansonsten einfach next(). Für Endpunkte wie
// /logout, die auch mit abgelaufenem Token funktionieren müssen.
const attachUserIfPresent = (req, res, next) => {
let token = req.cookies?.token;
if (!token) {
const authHeader = req.headers['authorization'];
token = authHeader && authHeader.split(' ')[1];
}
if (token) {
try {
const decoded = jwt.verify(token, config.jwtSecret);
const appOk = !decoded.app || decoded.app === config.appName;
const roleOk = !decoded.role || decoded.role === 'admin';
if (appOk && roleOk) {
req.user = decoded;
}
} catch (error) {
// Ungültiges Token ist hier kein Fehler der Aufrufer wird ohne req.user bedient.
}
}
next();
};
module.exports = { authenticateToken, attachUserIfPresent };

View File

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

View File

@ -1,11 +1,11 @@
{ {
"name": "tracking-leaders-backend", "name": "drohnenfuehrer-backend",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "tracking-leaders-backend", "name": "drohnenfuehrer-backend",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
@ -15,9 +15,12 @@
"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",
"winston": "^3.19.0" "nodemailer": "^6.9.16",
"winston": "^3.19.0",
"winston-daily-rotate-file": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
"jest": "^30.2.0", "jest": "^30.2.0",
@ -2880,6 +2883,15 @@
"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",
@ -3242,6 +3254,18 @@
"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",
@ -4579,6 +4603,15 @@
"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",
@ -4754,6 +4787,15 @@
"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",
@ -4840,6 +4882,15 @@
"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",
@ -6249,6 +6300,24 @@
"node": ">= 12.0.0" "node": ">= 12.0.0"
} }
}, },
"node_modules/winston-daily-rotate-file": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/winston-daily-rotate-file/-/winston-daily-rotate-file-5.0.0.tgz",
"integrity": "sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==",
"license": "MIT",
"dependencies": {
"file-stream-rotator": "^0.6.1",
"object-hash": "^3.0.0",
"triple-beam": "^1.4.1",
"winston-transport": "^4.7.0"
},
"engines": {
"node": ">=8"
},
"peerDependencies": {
"winston": "^3"
}
},
"node_modules/winston-transport": { "node_modules/winston-transport": {
"version": "4.9.0", "version": "4.9.0",
"resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz",

View File

@ -2,9 +2,12 @@ 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);
router.post('/logout', logout); // attachUserIfPresent statt authenticateToken: der Logout muss auch mit
// abgelaufenem Token funktionieren, soll den Benutzernamen aber protokollieren.
router.post('/logout', attachUserIfPresent, logout);
router.post('/forgot-password', forgotPassword); router.post('/forgot-password', forgotPassword);
router.post('/reset-password', resetPassword); router.post('/reset-password', resetPassword);

View File

@ -3,6 +3,7 @@ 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,
@ -25,13 +26,19 @@ const {
// Public routes // Public routes
router.get('/public/users', getPublicUsers); router.get('/public/users', getPublicUsers);
router.get('/public/geocode', getGeocodeByPostalCode); // Eigenes, engeres Limit: der Endpunkt loest ausgehende Nominatim-Anfragen aus.
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);
@ -45,7 +52,5 @@ router.post('/users/:id/photo', authenticateToken, auditLog('UPDATE', 'User'), u
router.delete('/users/:id/photo', authenticateToken, auditLog('UPDATE', 'User'), deleteUserPhoto); router.delete('/users/:id/photo', authenticateToken, auditLog('UPDATE', 'User'), deleteUserPhoto);
// Bulk operations // Bulk operations
router.patch('/users/bulk', authenticateToken, auditLog('BULK_UPDATE', 'User'), bulkUpdateUsers);
router.delete('/users/bulk', authenticateToken, auditLog('BULK_DELETE', 'User'), bulkDeleteUsers);
module.exports = router; module.exports = router;

View File

@ -9,7 +9,10 @@ const users = [];
const seedDatabase = async () => { const seedDatabase = async () => {
try { try {
await mongoose.connect(config.mongoUri); // Beim Aufruf aus server.js besteht die Verbindung bereits.
if (mongoose.connection.readyState !== 1) {
await mongoose.connect(config.mongoUri);
}
logger.info('MongoDB verbunden für Seeding...'); logger.info('MongoDB verbunden für Seeding...');
@ -51,6 +54,8 @@ 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: [
@ -90,22 +95,23 @@ const seedDatabase = async () => {
await Config.create(configData); await Config.create(configData);
logger.info('✅ Konfiguration erstellt'); logger.info('✅ Konfiguration erstellt');
} else { } else {
await Config.findOneAndUpdate({}, configData, { new: true }); logger.info(' Konfiguration existiert bereits, bleibt unveraendert');
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);
await mongoose.connection.close(); throw error;
process.exit(1);
} }
}; };
// Verbindung schliessen und den Prozess beenden darf nur der CLI-Aufruf
// (npm run seed). server.js ruft seedDatabase() im selben Prozess auf ein
// process.exit(0) hier hat den frisch gestarteten Server sofort wieder beendet.
if (require.main === module) { if (require.main === module) {
seedDatabase(); seedDatabase()
.then(async () => { await mongoose.connection.close(); process.exit(0); })
.catch(async () => { await mongoose.connection.close(); process.exit(1); });
} }
module.exports = seedDatabase; module.exports = seedDatabase;

View File

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

View File

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

View File

@ -6,17 +6,41 @@ 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);
Object.entries(parsed).forEach(([key, value]) => { // Nur die letzten CACHE_MAX_ENTRIES übernehmen eine früher unbegrenzt
// gewachsene Datei darf den Cache nicht wieder aufblähen.
const entries = Object.entries(parsed).slice(-CACHE_MAX_ENTRIES);
entries.forEach(([key, value]) => {
cache.set(key, value); cache.set(key, value);
}); });
logger.info(`Geocoding cache loaded: ${cache.size} entries`); logger.info(`Geocoding cache loaded: ${cache.size} entries`);
@ -45,14 +69,13 @@ const saveCache = async () => {
setInterval(saveCache, CACHE_SAVE_INTERVAL).unref(); setInterval(saveCache, CACHE_SAVE_INTERVAL).unref();
// Save on process exit // Save on process exit
process.on('SIGINT', async () => { // Cache beim Herunterfahren sichern ohne process.exit(): das Beenden gehört
await saveCache(); // dem Shutdown-Handler in server.js, der sonst mittendrin abgeschnitten wird.
process.exit(0); const flushOnShutdown = () => {
}); saveCache().catch(err => logger.error('Failed to flush geocoding cache:', err.message));
process.on('SIGTERM', async () => { };
await saveCache(); process.on('SIGINT', flushOnShutdown);
process.exit(0); process.on('SIGTERM', flushOnShutdown);
});
// Initialize cache loading // Initialize cache loading
loadCache().catch(err => logger.error('Cache initialization error:', err)); loadCache().catch(err => logger.error('Cache initialization error:', err));
@ -89,7 +112,12 @@ const geocodeAddress = async (address) => {
const cacheKey = normalized.toLowerCase(); const cacheKey = normalized.toLowerCase();
if (cache.has(cacheKey)) { if (cache.has(cacheKey)) {
return cache.get(cacheKey); // Treffer ans Ende rücken, damit die Verdrängung wirklich den am längsten
// ungenutzten Eintrag trifft und nicht bloß den ältesten eingefügten.
const hit = cache.get(cacheKey);
cache.delete(cacheKey);
cache.set(cacheKey, hit);
return hit;
} }
const elapsed = Date.now() - lastRequestTime; const elapsed = Date.now() - lastRequestTime;
@ -107,8 +135,7 @@ const geocodeAddress = async (address) => {
lastRequestTime = Date.now(); lastRequestTime = Date.now();
if (!Array.isArray(results) || results.length === 0) { if (!Array.isArray(results) || results.length === 0) {
cache.set(cacheKey, null); rememberInCache(cacheKey, null);
cacheModified = true;
return null; return null;
} }
@ -117,15 +144,12 @@ 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)) {
cache.set(cacheKey, null); rememberInCache(cacheKey, null);
cacheModified = true;
return null; return null;
} }
const coords = { lat, lng }; const coords = { lat, lng };
if (cache.size >= 1000) { cache.delete(cache.keys().next().value); } rememberInCache(cacheKey, coords);
cache.set(cacheKey, coords);
cacheModified = true;
return coords; return coords;
} catch (error) { } catch (error) {
logger.warn('Geocoding fehlgeschlagen', { logger.warn('Geocoding fehlgeschlagen', {

View File

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

View File

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

View File

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

View File

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

View File

@ -1,16 +1,18 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="de">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%BASE_URL%favicon.ico" /> <link rel="icon" href="%BASE_URL%icons/icon-192.png" />
<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="#2d6a2d" /> <meta name="theme-color" content="#1a3d1a" />
<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" />
<link rel="manifest" href="/manifest.json" /> <!-- Pfad relativ zur Deployment-Basis: ein absolutes "/manifest.json" wuerde
das Portal-Manifest laden und die App als Portal installieren. -->
<link rel="manifest" href="%BASE_URL%manifest.json" />
<meta <meta
name="description" name="description"
content="Drohnenführer Heidekreis Übersicht der Drohnenführer" content="Drohnenführer Heidekreis Übersicht der Drohnenführer"

View File

@ -27,19 +27,28 @@ 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;
@ -47,6 +56,9 @@ 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;
} }
@ -79,6 +91,9 @@ 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;
@ -86,6 +101,9 @@ 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;
@ -93,6 +111,9 @@ 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";
@ -102,6 +123,9 @@ 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";
@ -110,6 +134,9 @@ server {
# Health check endpoint # Health check endpoint
location /health { location /health {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
access_log off; access_log off;
return 200 "OK\n"; return 200 "OK\n";
add_header Content-Type text/plain; add_header Content-Type text/plain;

File diff suppressed because it is too large Load Diff

View File

@ -3,28 +3,16 @@
"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": "react-scripts start", "start": "vite",
"build": "vite build", "build": "vite build",
"test": "react-scripts test", "preview": "vite preview"
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
}, },
"browserslist": { "browserslist": {
"production": [ "production": [

View File

@ -1,24 +0,0 @@
<!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.

Before

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 692 KiB

View File

@ -2,11 +2,6 @@
"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",
@ -24,7 +19,7 @@
"scope": "/drohnenfuehrer/", "scope": "/drohnenfuehrer/",
"display": "standalone", "display": "standalone",
"orientation": "portrait", "orientation": "portrait",
"theme_color": "#2d6a2d", "theme_color": "#1a3d1a",
"background_color": "#ffffff", "background_color": "#e8e8e2",
"description": "Drohnenführer Heidekreis Übersicht der Drohnenführer" "description": "Drohnenführer Heidekreis Übersicht der Drohnenführer"
} }

View File

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

View File

@ -2,43 +2,203 @@
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 {
--color-primary: #2e8b2e; --font-display: Georgia, 'Times New Roman', serif;
--color-primary-dark: #1e6b1e; --font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
--color-primary-accent: #8B0D12; 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
--color-secondary: #6c757d; --font-mono: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
--color-secondary-dark: #5a6268;
--color-success: #28a745; /* Flächen */
--color-success-dark: #218838; --color-bg: #e8e8e2;
--color-danger: #dc3545; --color-surface: #f4f4ee;
--color-danger-dark: #c82333; --color-surface-alt: #dedcd2;
--color-bg: #f5f5f0; --color-muted-bg: #dedcd2;
--color-surface: #ffffff;
/* Text */
--color-text: #1a1a1a; --color-text: #1a1a1a;
--color-text-muted: #555; --color-text-muted: #55554c;
--color-border: #b8d4b8;
--color-border-strong: #88b888; /* Rahmen: -border ist dekorativ (Trennlinien, Karten),
--color-focus: rgba(46, 139, 46, 0.2); -border-strong begrenzt Bedienelemente und erfüllt die 3:1-Anforderung. */
--color-muted-bg: #f5f5f0; --color-border: #c9c9b8;
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1); --color-border-strong: #7d7d68;
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.2);
--radius-sm: 4px; /* Aktionsfarben jeweils mit der zugehörigen Textfarbe, damit die
--radius-md: 8px; Dunkelvariante nicht auf weißem Text auf hellem Grund landet. */
--radius-pill: 12px; --color-primary: #2d5a2d;
--color-primary-dark: #1a3d1a;
--color-on-primary: #ffffff;
--color-secondary: #5c5c50;
--color-secondary-dark: #46463c;
--color-on-secondary: #ffffff;
--color-success: #1f6b34;
--color-success-dark: #175128;
--color-on-success: #ffffff;
--color-danger: #a32020;
--color-danger-dark: #821919;
--color-on-danger: #ffffff;
--color-warning: #7a5200;
--color-on-warning: #ffffff;
--color-accent: #8b0d12;
--color-on-accent: #ffffff;
/* Altname, wird von RulesDisplay noch benutzt */
--color-primary-accent: #8b0d12;
/* Getönte Status-Flächen (Meldungen, Badges, Zustands-Karten).
Der Block wird durch seine Füllung erkannt, der Rahmen ist Zierde. */
--color-success-bg: #dfeedd;
--color-success-border: #a9cba4;
--color-success-text: #1a4a24;
--color-danger-bg: #f6e0e0;
--color-danger-border: #d9a9a9;
--color-danger-text: #7d1a1a;
--color-warning-bg: #f7edd4;
--color-warning-border: #d9c48a;
--color-warning-text: #5c3d00;
--color-info: #24608f;
--color-info-dark: #1b4a6e;
--color-on-info: #ffffff;
--color-info-bg: #dde8f1;
--color-info-border: #a5bfd4;
--color-info-text: #14405f;
/* Als RGB-Tripel fuer rgba()-Anwendungen (Puls-Animation im Admin-Panel) */
--color-primary-rgb: 45, 90, 45;
--color-focus: rgba(45, 90, 45, 0.35);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.15);
--shadow-md: 0 2px 6px rgba(0, 0, 0, 0.2);
/* Kantige Ecken wie im Portal */
--radius-sm: 2px;
--radius-md: 2px;
--radius-pill: 2px;
--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;
@ -46,15 +206,24 @@
} }
.btn:disabled { .btn:disabled {
background: var(--color-border); background: var(--color-surface-alt);
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: white; color: var(--color-on-primary);
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
@ -64,7 +233,7 @@
.btn-secondary { .btn-secondary {
background: var(--color-secondary); background: var(--color-secondary);
color: white; color: var(--color-on-secondary);
} }
.btn-secondary:hover:not(:disabled) { .btn-secondary:hover:not(:disabled) {
@ -72,19 +241,9 @@
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: white; color: var(--color-on-danger);
} }
.btn-danger:hover:not(:disabled) { .btn-danger:hover:not(:disabled) {
@ -96,9 +255,13 @@
.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;
} }
@ -111,33 +274,19 @@
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);
} }
body { /* ── Grundgerüst ────────────────────────────────────────────────────────── */
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 {
min-height: 100vh; /* dvh statt vh: mit ein- und ausblendender Adressleiste auf Mobilgeräten
entsteht mit vh sonst Überlauf. */
min-height: 100dvh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@ -151,44 +300,26 @@ body {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
min-height: 100vh; min-height: 100dvh;
font-size: 1.2rem; font-size: 1.2rem;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.login-prompt { /* Wer Bewegung reduziert haben möchte, bekommt keine Animationen. */
position: fixed; @media (prefers-reduced-motion: reduce) {
bottom: 20px; *,
right: 20px; *::before,
} *::after {
animation-duration: 0.01ms !important;
.login-button-header { animation-iteration-count: 1 !important;
padding: 0.75rem 1.5rem; transition-duration: 0.01ms !important;
background: var(--color-primary); scroll-behavior: auto !important;
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;
} }
.login-button-header { .btn:hover:not(:disabled),
padding: 0.6rem 1.2rem; .btn-primary:hover:not(:disabled),
font-size: 0.9rem; .btn-secondary:hover:not(:disabled),
.btn-danger:hover:not(:disabled) {
transform: none;
} }
} }

View File

@ -11,13 +11,15 @@ 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() {
const adminPath = process.env.REACT_APP_ADMIN_PATH || '/verwaltung'; // Die Pfade muessen den Deployment-Basispfad enthalten: produktiv laeuft die
const resetPasswordPath = '/passwort-zuruecksetzen'; // App unter /<app>/, ein Vergleich gegen '/verwaltung' traefe dort nie zu.
const isAdminRoute = window.location.pathname === adminPath; const currentPath = normalizePath(window.location.pathname);
const isResetPasswordRoute = window.location.pathname === resetPasswordPath; const isAdminRoute = currentPath === normalizePath(withBase(ADMIN_PATH));
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();
@ -97,7 +99,6 @@ function App() {
isAdmin={false} isAdmin={false}
currentView={view} currentView={view}
onViewChange={handleViewChange} onViewChange={handleViewChange}
onDrohnenfuehrerLogin={handleDrohnenfuehrerLogout}
/> />
<main className="app-main"> <main className="app-main">
{isAdminRoute || view === 'login' ? ( {isAdminRoute || view === 'login' ? (

View File

@ -25,7 +25,7 @@
.tab-button.active { .tab-button.active {
background: var(--color-primary); background: var(--color-primary);
color: white; color: var(--color-on-primary);
} }
.settings-panel { .settings-panel {
@ -59,9 +59,9 @@
.unsaved-badge { .unsaved-badge {
display: inline-block; display: inline-block;
background: #fff3cd; background: var(--color-warning-bg);
color: #856404; color: var(--color-warning-text);
border: 1px solid #ffc107; border: 1px solid var(--color-warning);
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, #f5f5f0); background: var(--color-muted-bg, var(--color-surface-alt));
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, #eef2e6); background: var(--color-focus, var(--color-success-bg));
} }
.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, #e8e8e4 25%, #f0f0ec 50%, #e8e8e4 75%); background: linear-gradient(90deg, var(--color-surface-alt) 25%, var(--color-surface-alt) 50%, var(--color-surface-alt) 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: white; color: var(--color-on-success);
} }
.notification.error { .notification.error {
background: var(--color-danger); background: var(--color-danger);
color: white; color: var(--color-on-danger);
} }
.notification.warning { .notification.warning {
background: #e67e00; background: var(--color-warning);
color: white; color: var(--color-on-warning);
} }
@keyframes slideIn { @keyframes slideIn {

View File

@ -3,7 +3,7 @@ import UserList from '../users/UserList';
import ExportButton from './ExportButton'; import ExportButton from './ExportButton';
import Trash from './Trash'; import Trash from './Trash';
import AuditLogs from './AuditLogs'; import AuditLogs from './AuditLogs';
import { updateAvailability, updateGPS, createUser, updateUser, deleteUser, uploadUserPhoto, deleteUserPhoto } from '../../services/users'; import { updateAvailability, updateGPS, createUser, updateUser, deleteUser, uploadUserPhoto, deleteUserPhoto, generateInviteToken } 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,6 +127,16 @@ 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);
@ -187,7 +197,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, SVG, ...)'); showNotification('error', 'Nur Bilddateien erlaubt (JPG, PNG, WebP)');
return; return;
} }
if (file.size > 500 * 1024) { if (file.size > 500 * 1024) {
@ -291,6 +301,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
onUserDelete={handleUserDelete} onUserDelete={handleUserDelete}
onPhotoUpload={handleUserPhotoUpload} onPhotoUpload={handleUserPhotoUpload}
onPhotoDelete={handleUserPhotoDelete} onPhotoDelete={handleUserPhotoDelete}
onGenerateInvite={handleGenerateInvite}
/> />
</> </>
)} )}
@ -408,7 +419,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
</button> </button>
)} )}
</div> </div>
<p className="settings-hint">Empfohlen: PNG, SVG oder JPG, max. 500 KB</p> <p className="settings-hint">Erlaubt: PNG, JPG oder WebP, max. 500 KB (SVG wird aus Sicherheitsgruenden abgelehnt)</p>
</div> </div>
)} )}
</div> </div>

View File

@ -13,8 +13,8 @@
gap: 12px; gap: 12px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.audit-header h2 { margin: 0 0 4px 0; color: #333; font-size: 26px; } .audit-header h2 { margin: 0 0 4px 0; color: var(--color-text); font-size: 26px; }
.audit-description { margin: 0; color: #666; font-size: 13px; } .audit-description { margin: 0; color: var(--color-text-muted); 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: #f0f4f8; background: var(--color-surface-alt);
border: 1px solid #d0d7de; border: 1px solid var(--color-border);
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 13px;
color: #444; color: var(--color-text-muted);
} }
.btn-stats-toggle:hover { background: #e2e8f0; } .btn-stats-toggle:hover { background: var(--color-surface-alt); }
.stats-days-select { font-size: 13px; } .stats-days-select { font-size: 13px; }
.audit-stats { .audit-stats {
background: #f8fafc; background: var(--color-surface-alt);
border: 1px solid #e2e8f0; border: 1px solid var(--color-border);
border-radius: 10px; border-radius: 10px;
padding: 16px; padding: 16px;
margin-bottom: 16px; margin-bottom: 16px;
} }
.stats-loading { color: #888; font-size: 13px; padding: 8px 0; } .stats-loading { color: var(--color-text-muted); 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: white; background: var(--color-surface);
border-radius: 8px; border-radius: 8px;
padding: 12px 16px; padding: 12px 16px;
text-align: center; text-align: center;
border: 1px solid #e2e8f0; border: 1px solid var(--color-border);
} }
.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: #666; margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; } .stat-label { font-size: 11px; color: var(--color-text-muted); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
.stat-total .stat-number { color: #1976d2; } .stat-total .stat-number { color: var(--color-info-text); }
.stat-failed .stat-number { color: #c62828; } .stat-failed .stat-number { color: var(--color-danger-text); }
.stat-success .stat-number { color: #2e7d32; } .stat-success .stat-number { color: var(--color-success-text); }
.stat-period .stat-number { color: #555; font-size: 20px; } .stat-period .stat-number { color: var(--color-text-muted); 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: #888; letter-spacing: 0.5px; } .stats-col h4 { margin: 0 0 8px 0; font-size: 12px; text-transform: uppercase; color: var(--color-text-muted); 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: #444; } .stat-bar-label { font-size: 13px; color: var(--color-text-muted); }
.stat-bar-count { font-size: 13px; font-weight: 600; color: #333; flex-shrink: 0; } .stat-bar-count { font-size: 13px; font-weight: 600; color: var(--color-text); 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: #f0f4f8; background: var(--color-surface-alt);
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: #1976d2; background: var(--color-info);
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: #e53935; background: var(--color-danger);
} }
/* ── Filters ─────────────────────────────────────────────────────── */ /* ── Filters ─────────────────────────────────────────────────────── */
@ -134,52 +134,52 @@
} }
.filter-select { .filter-select {
padding: 8px 12px; padding: 8px 12px;
border: 1.5px solid #d0d7de; border: 1.5px solid var(--color-border);
border-radius: 6px; border-radius: 6px;
font-size: 13px; font-size: 13px;
background: white; background: var(--color-surface);
cursor: pointer; cursor: pointer;
} }
.filter-select:focus { outline: none; border-color: #1976d2; } .filter-select:focus { outline: none; border-color: var(--color-primary); }
.filter-input { .filter-input {
padding: 8px 12px; padding: 8px 12px;
border: 1.5px solid #d0d7de; border: 1.5px solid var(--color-border);
border-radius: 6px; border-radius: 6px;
font-size: 13px; font-size: 13px;
background: white; background: var(--color-surface);
min-width: 140px; min-width: 140px;
} }
.filter-input:focus { outline: none; border-color: #1976d2; } .filter-input:focus { outline: none; border-color: var(--color-primary); }
.filter-date { min-width: 130px; } .filter-date { min-width: 130px; }
.btn-refresh { .btn-refresh {
padding: 8px 14px; padding: 8px 14px;
background: #1976d2; background: var(--color-info);
color: white; color: var(--color-on-info);
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: #1565c0; } .btn-refresh:hover:not(:disabled) { background: var(--color-info-dark); }
.btn-refresh:disabled { background: #bdbdbd; cursor: not-allowed; } .btn-refresh:disabled { background: var(--color-surface-alt); color: var(--color-text-muted); cursor: not-allowed; }
.btn-reset { .btn-reset {
padding: 8px 12px; padding: 8px 12px;
background: white; background: var(--color-surface);
color: #666; color: var(--color-text-muted);
border: 1.5px solid #d0d7de; border: 1.5px solid var(--color-border);
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 13px;
} }
.btn-reset:hover { background: #f5f5f5; } .btn-reset:hover { background: var(--color-surface-alt); }
.btn-export { .btn-export {
padding: 8px 16px; padding: 8px 16px;
background: #2e7d32; background: var(--color-success);
color: white; color: var(--color-on-success);
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: #1b5e20; } .btn-export:hover:not(:disabled) { background: var(--color-success-dark); }
.btn-export:disabled { background: #bdbdbd; cursor: not-allowed; } .btn-export:disabled { background: var(--color-surface-alt); color: var(--color-text-muted); 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: #555; color: var(--color-text-muted);
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: #666; color: var(--color-text-muted);
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: #c62828; } .badge-delete { background: #ffebee; color: var(--color-danger-text); }
.badge-restore { background: #fff3e0; color: #e65100; } .badge-restore { background: #fff3e0; color: #a83a00; }
.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: #e8eaf6; color: #283593; } .badge-bulk-update { background: var(--color-surface-alt); color: #283593; }
.badge-bulk-delete { background: #fbe9e7; color: #bf360c; } .badge-bulk-delete { background: #fbe9e7; color: #bf360c; }
.badge-password { background: #fff8e1; color: #f57f17; } .badge-password { background: #fff8e1; color: #9c4e00; }
.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: #e65100; } .status-redirect { background: #fff3e0; color: #a83a00; }
.status-error { background: #ffebee; color: #c62828; } .status-error { background: #ffebee; color: var(--color-danger-text); }
.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: #f5f5f5; background: var(--color-surface-alt);
color: #777; color: var(--color-text-muted);
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: #f9f9f9; background: var(--color-surface-alt);
border-radius: 8px; border-radius: 8px;
color: #888; color: var(--color-text-muted);
} }
.audit-error { text-align: center; padding: 20px; } .audit-error { text-align: center; padding: 20px; }
.audit-error p { color: #d32f2f; margin: 0; } .audit-error p { color: var(--color-danger-text); 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: white; background: var(--color-surface);
border: 1px solid #e0e0e0; border: 1px solid var(--color-border);
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 #e53935; } .audit-item-failed { border-left: 4px solid var(--color-danger); }
.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: #fafafa; background: var(--color-surface-alt);
border-bottom: 1px solid #f0f0f0; border-bottom: 1px solid var(--color-border);
} }
.audit-time { margin-left: auto; color: #999; font-size: 12px; white-space: nowrap; } .audit-time { margin-left: auto; color: var(--color-text-muted); font-size: 12px; white-space: nowrap; }
.expand-toggle { color: #aaa; font-size: 11px; cursor: pointer; padding: 0 4px; } .expand-toggle { color: var(--color-text-muted); font-size: 11px; cursor: pointer; padding: 0 4px; }
.audit-resource { .audit-resource {
padding: 3px 8px; padding: 3px 8px;
background: #f0f0f0; background: var(--color-surface-alt);
border-radius: 4px; border-radius: 4px;
font-size: 12px; font-size: 12px;
color: #555; color: var(--color-text-muted);
} }
.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: #333; } .audit-admin { color: var(--color-text); }
.audit-resource-name { color: #555; } .audit-resource-name { color: var(--color-text-muted); }
.audit-ip { color: #999; font-family: monospace; font-size: 12px; } .audit-ip { color: var(--color-text-muted); font-family: monospace; font-size: 12px; }
.audit-failed-badge { color: #c62828; font-weight: 600; font-size: 12px; } .audit-failed-badge { color: var(--color-danger-text); 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 #e0e0e0; border-top: 1px dashed var(--color-border);
background: #fefefe; background: var(--color-surface);
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: #888; color: var(--color-text-muted);
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: #f0f0f0; background: var(--color-surface-alt);
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: #aaa; color: var(--color-text-muted);
cursor: help; cursor: help;
font-size: 12px; font-size: 12px;
} }
.detail-error { color: #c62828; } .detail-error { color: var(--color-danger-text); }
.detail-error .detail-label { color: #c62828; } .detail-error .detail-label { color: var(--color-danger-text); }
.meta-tag { .meta-tag {
display: inline-block; display: inline-block;
background: #e8eaf6; background: var(--color-surface-alt);
color: #283593; color: var(--color-info-text);
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: #f0f0f0; background: var(--color-surface-alt);
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: #666; color: var(--color-text-muted);
border: 1px solid #e0e0e0; border: 1px solid var(--color-border);
} }
.diff-table td { padding: 5px 10px; border: 1px solid #e8e8e8; vertical-align: top; } .diff-table td { padding: 5px 10px; border: 1px solid var(--color-border); vertical-align: top; }
.diff-field { font-weight: 600; color: #444; font-family: monospace; white-space: nowrap; background: #fafafa; } .diff-field { font-weight: 600; color: var(--color-text-muted); font-family: monospace; white-space: nowrap; background: var(--color-surface-alt); }
.diff-before { color: #c62828; background: #fff5f5; font-family: monospace; word-break: break-all; } .diff-before { color: var(--color-danger-text); 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 #d0d7de; border: 1.5px solid var(--color-border);
border-radius: 6px; border-radius: 6px;
background: white; background: var(--color-surface);
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 13px;
color: #333; color: var(--color-text);
} }
.btn-page:hover:not(:disabled) { background: #f0f4f8; border-color: #1976d2; } .btn-page:hover:not(:disabled) { background: var(--color-surface-alt); border-color: var(--color-primary); }
.btn-page:disabled { color: #bbb; cursor: not-allowed; border-color: #eee; } .btn-page:disabled { color: var(--color-text-muted); cursor: not-allowed; border-color: var(--color-border); }
.page-info { font-size: 13px; color: #666; padding: 0 8px; } .page-info { font-size: 13px; color: var(--color-text-muted); 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: #333; color: var(--color-text);
font-size: 28px; font-size: 28px;
} }
.audit-description { .audit-description {
margin: 0; margin: 0;
color: #666; color: var(--color-text-muted);
font-size: 14px; font-size: 14px;
} }
@ -428,7 +428,7 @@
} }
.audit-error p { .audit-error p {
color: #d32f2f; color: var(--color-danger-text);
margin: 0; margin: 0;
} }
@ -441,23 +441,23 @@
.filter-select { .filter-select {
padding: 10px 14px; padding: 10px 14px;
border: 2px solid #e0e0e0; border: 2px solid var(--color-border);
border-radius: 6px; border-radius: 6px;
font-size: 14px; font-size: 14px;
background: white; background: var(--color-surface);
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: #1976d2; border-color: var(--color-primary);
} }
.btn-refresh { .btn-refresh {
padding: 10px 16px; padding: 10px 16px;
background: #1976d2; background: var(--color-info);
color: white; color: var(--color-on-info);
border: none; border: none;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
@ -467,20 +467,21 @@
} }
.btn-refresh:hover:not(:disabled) { .btn-refresh:hover:not(:disabled) {
background: #1565c0; background: var(--color-info-dark);
} }
.btn-refresh:disabled { .btn-refresh:disabled {
background: #bdbdbd; background: var(--color-surface-alt);
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: #f5f5f5; background: var(--color-surface-alt);
border-radius: 8px; border-radius: 8px;
color: #666; color: var(--color-text-muted);
} }
.audit-list { .audit-list {
@ -490,8 +491,8 @@
} }
.audit-item { .audit-item {
background: white; background: var(--color-surface);
border: 1px solid #e0e0e0; border: 1px solid var(--color-border);
border-radius: 8px; border-radius: 8px;
padding: 16px; padding: 16px;
transition: box-shadow 0.2s; transition: box-shadow 0.2s;
@ -519,58 +520,18 @@
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: #f5f5f5; background: var(--color-surface-alt);
border-radius: 4px; border-radius: 4px;
font-size: 13px; font-size: 13px;
color: #666; color: var(--color-text-muted);
} }
.audit-time { .audit-time {
margin-left: auto; margin-left: auto;
color: #999; color: var(--color-text-muted);
font-size: 13px; font-size: 13px;
} }
@ -582,23 +543,19 @@
} }
.audit-admin { .audit-admin {
color: #333; color: var(--color-text);
} }
.audit-resource-name { .audit-resource-name {
color: #666; color: var(--color-text-muted);
} }
.audit-ip { .audit-ip {
color: #888; color: var(--color-text-muted);
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;
@ -607,15 +564,15 @@
gap: 16px; gap: 16px;
margin-top: 24px; margin-top: 24px;
padding: 16px; padding: 16px;
background: white; background: var(--color-surface);
border-radius: 8px; border-radius: 8px;
border: 1px solid #e0e0e0; border: 1px solid var(--color-border);
} }
.btn-page { .btn-page {
padding: 8px 16px; padding: 8px 16px;
background: #1976d2; background: var(--color-info);
color: white; color: var(--color-on-info);
border: none; border: none;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
@ -624,16 +581,17 @@
} }
.btn-page:hover:not(:disabled) { .btn-page:hover:not(:disabled) {
background: #1565c0; background: var(--color-info-dark);
} }
.btn-page:disabled { .btn-page:disabled {
background: #bdbdbd; background: var(--color-surface-alt);
color: var(--color-text-muted);
cursor: not-allowed; cursor: not-allowed;
} }
.page-info { .page-info {
color: #666; color: var(--color-text-muted);
font-size: 14px; font-size: 14px;
} }
@ -656,3 +614,33 @@
width: 100%; width: 100%;
} }
} }
/* Nachtvariante
Die Badges kodieren die Aktionsart über den Farbton der bleibt erhalten,
nur Helligkeit und Sättigung drehen sich um. Alle Paare >= 6.4:1. */
@media (prefers-color-scheme: dark) {
.badge-create { background: #162d17; color: #97d99a; }
.badge-update { background: #16202d; color: #89b5e6; }
.badge-delete { background: #2d1616; color: #e68989; }
.badge-restore { background: #2d1e16; color: #e6aa89; }
.badge-login { background: #24162d; color: #c389e6; }
.badge-logout { background: #2d1622; color: #e689bb; }
.badge-login-failed { background: #2d1616; color: #e68989; }
.badge-import { background: #162d2a; color: #89e6db; }
.badge-export { background: #162d17; color: #90df96; }
.badge-bulk-update { background: #16182d; color: #8f99e0; }
.badge-bulk-delete { background: #2d1b16; color: #e69f89; }
.badge-password { background: #2d2016; color: #e6b589; }
.badge-default { background: var(--color-surface-alt); color: var(--color-text-muted); }
.status-ok { background: #162d17; color: #97d99a; }
.status-redirect { background: #2d1e16; color: #e6aa89; }
.status-error { background: #2d1616; color: #e68989; }
.status-server-error { background: #24162d; color: #c389e6; }
/* Diff-Tabelle: die roten/gruenen Vorher-Nachher-Felder */
.diff-table th { background: var(--color-surface-alt); }
.diff-before { color: #e68989; background: #2d1616; }
.diff-after { color: #97d99a; background: #162d17; }
.diff-field { background: var(--color-surface-alt); color: var(--color-text); }
}

View File

@ -14,7 +14,7 @@
.export-button { .export-button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background: var(--color-success); background: var(--color-success);
color: white; color: var(--color-on-success);
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, #2563eb); background: var(--color-primary, var(--color-info));
color: white; color: var(--color-on-primary);
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, #1d4ed8); background: var(--color-primary-dark, var(--color-info-dark));
} }
.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, #6b7280); color: var(--color-text-muted);
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
} }

View File

@ -10,13 +10,13 @@
.trash-header h2 { .trash-header h2 {
margin: 0 0 8px 0; margin: 0 0 8px 0;
color: #333; color: var(--color-text);
font-size: 28px; font-size: 28px;
} }
.trash-description { .trash-description {
margin: 0; margin: 0;
color: #666; color: var(--color-text-muted);
font-size: 14px; font-size: 14px;
} }
@ -27,14 +27,14 @@
} }
.trash-error h3 { .trash-error h3 {
color: #d32f2f; color: var(--color-danger-text);
margin-bottom: 12px; margin-bottom: 12px;
} }
.btn-retry { .btn-retry {
padding: 10px 20px; padding: 10px 20px;
background: #1976d2; background: var(--color-info);
color: white; color: var(--color-on-info);
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: #1565c0; background: var(--color-info-dark);
} }
.trash-empty { .trash-empty {
text-align: center; text-align: center;
padding: 60px 20px; padding: 60px 20px;
background: #f5f5f5; background: var(--color-surface-alt);
border-radius: 12px; border-radius: 12px;
} }
@ -60,7 +60,7 @@
} }
.trash-empty p { .trash-empty p {
color: #666; color: var(--color-text-muted);
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
} }
@ -72,8 +72,8 @@
} }
.trash-item { .trash-item {
background: #fff; background: var(--color-surface);
border: 2px solid #e0e0e0; border: 2px solid var(--color-border);
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: #bdbdbd; border-color: var(--color-border);
} }
.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: #333; color: var(--color-text);
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
} }
@ -106,8 +106,8 @@
} }
.trash-item-type { .trash-item-type {
background: #e3f2fd; background: var(--color-info-bg);
color: #1976d2; color: var(--color-info-text);
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: #666; color: var(--color-text-muted);
font-size: 14px; font-size: 14px;
} }
.trash-item-meta { .trash-item-meta {
color: #999; color: var(--color-text-muted);
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: #4caf50; background: var(--color-success);
color: white; color: var(--color-on-success);
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: #45a049; background: var(--color-success-dark);
transform: translateY(-1px); transform: translateY(-1px);
} }
@ -160,7 +160,7 @@
} }
.btn-restore:disabled { .btn-restore:disabled {
background: #bdbdbd; background: var(--color-border-strong);
cursor: not-allowed; cursor: not-allowed;
} }

View File

@ -56,7 +56,7 @@
width: 100%; width: 100%;
padding: 0.75rem; padding: 0.75rem;
background: var(--color-primary); background: var(--color-primary);
color: white; color: var(--color-on-primary);
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: #ffebee; background: var(--color-danger-bg);
border: 1px solid #ffcdd2; border: 1px solid var(--color-danger-border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
color: var(--color-danger); color: var(--color-danger);
margin-bottom: 1rem; margin-bottom: 1rem;

View File

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

View File

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

View File

@ -4,10 +4,10 @@
gap: 0.5rem; gap: 0.5rem;
padding: 1rem; padding: 1rem;
margin: 1rem 0; margin: 1rem 0;
background-color: #fee; background-color: var(--color-danger-bg);
border: 1px solid #fcc; border: 1px solid var(--color-danger-border);
border-radius: 4px; border-radius: 4px;
color: #c33; color: var(--color-danger-text);
} }
.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: #c33; color: var(--color-danger-text);
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
width: 24px; width: 24px;
@ -34,5 +34,5 @@
} }
.error-close:hover { .error-close:hover {
color: #a22; color: var(--color-danger-text);
} }

View File

@ -1,6 +1,6 @@
.app-header { .app-header {
background: #ffffff; background: var(--color-surface);
border-bottom: 1px solid #e5e5e5; border-bottom: 1px solid var(--color-border);
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: #fff; color: var(--color-on-primary);
} }
.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);
white-space: nowrap; /* Kein nowrap: lange App-Namen haben die Kopfzeile sonst über die
Bildschirmbreite hinaus geschoben. */
overflow-wrap: anywhere;
} }
.app-nav { .app-nav {
@ -61,11 +61,14 @@
} }
.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: #333; color: var(--color-text);
cursor: pointer; cursor: pointer;
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 500; font-weight: 500;
@ -97,12 +100,19 @@
.app-header { .app-header {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
padding: 0.75rem 1rem; padding: 0.5rem 1rem;
gap: 0.5rem; gap: 0.4rem;
/* Auf schmalen Geräten nimmt die umgebrochene Kopfzeile viel Höhe ein.
Klebrig bleibt sie nur dort, wo genug Platz ist. */
position: static;
} }
.app-title { .app-title {
font-size: 0.9rem; font-size: 0.95rem;
}
.app-logo {
height: 36px;
} }
.app-nav { .app-nav {
@ -111,7 +121,31 @@
} }
.nav-button { .nav-button {
font-size: 0.85rem; font-size: 0.9rem;
padding: 0.4rem 0.7rem; padding: 0.4rem 0.7rem;
} }
} }
@media (max-width: 480px) {
.app-header {
padding: 0.5rem 0.75rem;
}
.app-brand {
gap: 0.5rem;
}
.app-logo {
height: 30px;
}
.app-nav {
width: 100%;
}
.nav-button {
flex: 1 1 45%;
justify-content: center;
padding: 0.4rem 0.5rem;
}
}

View File

@ -2,8 +2,8 @@
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 1000; z-index: 1000;
background-color: #2d6a2d; background-color: var(--color-primary);
color: #fff; color: var(--color-on-primary);
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: #fff; background: var(--color-surface);
color: #2d6a2d; color: var(--color-primary);
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: #e8f5e9; background: var(--color-success-bg);
} }
.install-banner__close { .install-banner__close {
@ -49,7 +49,7 @@
} }
.install-banner__close:hover { .install-banner__close:hover {
color: #fff; color: var(--color-on-primary);
} }
.install-banner__share-icon { .install-banner__share-icon {

View File

@ -8,8 +8,8 @@
} }
.spinner { .spinner {
border: 4px solid #f3f3f3; border: 4px solid var(--color-border);
border-top: 4px solid #3498db; border-top: 4px solid var(--color-primary);
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: #666; color: var(--color-text-muted);
font-size: 0.9rem; font-size: 0.9rem;
} }

View File

@ -5,7 +5,7 @@
} }
.drohnenfuehrer-dashboard-card { .drohnenfuehrer-dashboard-card {
background: white; background: var(--color-surface);
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: white; color: var(--color-on-danger);
} }
.drohnenfuehrer-message { .drohnenfuehrer-message {
@ -56,8 +56,8 @@
font-size: 0.9rem; font-size: 0.9rem;
} }
.drohnenfuehrer-message-success { background: #dcfce7; color: #166534; } .drohnenfuehrer-message-success { background: var(--color-success-bg); color: var(--color-success-text); }
.drohnenfuehrer-message-error { background: #fee2e2; color: #b91c1c; } .drohnenfuehrer-message-error { background: var(--color-danger-bg); color: var(--color-danger-text); }
.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: #dcfce7; background: var(--color-success-bg);
color: #15803d; color: var(--color-success-text);
border-color: #86efac; border-color: var(--color-success-border);
} }
.btn-red { .btn-red {
background: #fee2e2; background: var(--color-danger-bg);
color: #b91c1c; color: var(--color-danger-text);
border-color: #fca5a5; border-color: var(--color-danger-border);
} }
.status-dot-lg { .status-dot-lg {
@ -110,8 +110,8 @@
flex-shrink: 0; flex-shrink: 0;
} }
.dot-green { background: #22c55e; } .dot-green { background: var(--color-success); }
.dot-red { background: #ef4444; } .dot-red { background: var(--color-danger); }
.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: white; } .btn-drohnenfuehrer-edit:hover { background: var(--color-primary); color: var(--color-on-primary); }
.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: white; color: var(--color-on-primary);
border: none; border: none;
border-radius: 6px; border-radius: 6px;
font-size: 0.95rem; font-size: 0.95rem;

View File

@ -6,7 +6,7 @@
} }
.drohnenfuehrer-login-card { .drohnenfuehrer-login-card {
background: white; background: var(--color-surface);
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: white; color: var(--color-on-primary);
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: white; color: var(--color-on-primary);
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: #fee2e2; background: var(--color-danger-bg);
color: #b91c1c; color: var(--color-danger-text);
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: #dcfce7; background: var(--color-success-bg);
color: #166534; color: var(--color-success-text);
border-radius: 4px; border-radius: 4px;
padding: 0.6rem 0.9rem; padding: 0.6rem 0.9rem;
margin-bottom: 1rem; margin-bottom: 1rem;

View File

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

View File

@ -14,7 +14,7 @@
} }
.public-user-list h2 { .public-user-list h2 {
color: #1a1a1a; color: var(--color-text);
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: white; color: var(--color-on-primary);
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: #6c757d; background: var(--color-secondary);
color: white; color: var(--color-on-secondary);
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: #5a6268; background: var(--color-secondary-dark);
} }
.location-panel { .location-panel {
background: white; background: var(--color-surface);
border: 1px solid #e5e5e5; border: 1px solid var(--color-border);
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: white; color: var(--color-on-primary);
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: #b6b6b6; background: var(--color-border-strong);
cursor: not-allowed; cursor: not-allowed;
} }
@ -103,13 +103,13 @@
} }
.location-status.success { .location-status.success {
background: #e8f5e9; background: var(--color-success-bg);
color: var(--color-primary-dark); color: var(--color-success-text);
} }
.location-status.error { .location-status.error {
background: #ffebee; background: var(--color-danger-bg);
color: #c62828; color: var(--color-danger-text);
} }
.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 #ddd; border: 1px solid var(--color-border);
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: #f5f5f5; background: var(--color-surface-alt);
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: white; color: var(--color-on-primary);
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: #b6b6b6; background: var(--color-border-strong);
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: #555; color: var(--color-text-muted);
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 #ddd; border: 1px solid var(--color-border);
border-radius: 4px; border-radius: 4px;
font-size: 0.9rem; font-size: 0.9rem;
} }
.radius-inputs input:disabled { .radius-inputs input:disabled {
background: #f5f5f5; background: var(--color-surface-alt);
cursor: not-allowed; cursor: not-allowed;
} }
.radius-hint { .radius-hint {
font-size: 0.85rem; font-size: 0.85rem;
color: #777; color: var(--color-text-muted);
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: #777; color: var(--color-text-muted);
} }
.user-grid { .user-grid {
@ -205,8 +205,8 @@
/* Jägerschaft-style card */ /* Jägerschaft-style card */
.user-card { .user-card {
background: white; background: var(--color-surface);
border: 1px solid #e5e5e5; border: 1px solid var(--color-border);
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);
} }
.user-name { .public-user-list .user-name {
margin: 0 0 0.2rem; margin: 0 0 0.2rem;
color: #1a1a1a; color: var(--color-text);
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);
} }
.user-info { .public-user-list .user-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.375rem; gap: 0.375rem;
} }
.user-address { .public-user-list .user-address {
margin: 0; margin: 0;
color: #666; color: var(--color-text-muted);
font-size: 0.9rem; font-size: 0.9rem;
} }
@ -266,72 +266,80 @@
margin: 0.5rem 0 0; margin: 0.5rem 0 0;
} }
.type-badge { .public-user-list .type-badge {
display: inline-block; display: inline-block;
padding: 0.2rem 0.6rem; padding: 0.2rem 0.6rem;
background: #f0f0f0; background: var(--color-surface-alt);
color: #444; color: var(--color-text-muted);
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: 500; font-weight: 600;
font-size: 0.9rem; font-size: 1rem;
} }
.user-phone:hover { .public-user-list .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 #e5e5e5; border-top: 1px solid var(--color-border);
font-size: 0.85rem; font-size: 0.85rem;
color: #555; color: var(--color-text-muted);
font-weight: 500; font-weight: 500;
} }
.no-users { .no-users {
text-align: center; text-align: center;
padding: 3rem; padding: 3rem;
color: #666; color: var(--color-text-muted);
} }
.loading { .loading {
text-align: center; text-align: center;
padding: 3rem; padding: 3rem;
color: #666; color: var(--color-text-muted);
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.user-grid { .user-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.public-user-list { .public-user-list {
padding: 1rem; padding: 1rem;
} }
.radius-inputs { .radius-inputs {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
} }
.radius-inputs input[type="number"] { .radius-inputs input[type="number"] {
width: 100%; width: 100%;
} }
} }

View File

@ -1,4 +1,4 @@
import React, { useState, useMemo, useEffect, useCallback } from 'react'; import React, { useState, useMemo, useEffect, useCallback, useRef } 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,18 +6,32 @@ 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);
const [locationStatus, setLocationStatus] = useState(initialCoords ? 'granted' : 'idle'); // Gespeicherten Standort lazy und abgesichert lesen. Vorher stand ein
// ungeschuetztes JSON.parse im Render-Pfad: ein beschaedigter Eintrag - oder ein
// Browser, der Site-Data blockiert - hat die gesamte Liste weiss werden lassen.
const [coords, setCoords] = useState(readStoredLocation);
const [locationStatus, setLocationStatus] = useState(coords ? 'granted' : 'idle');
const [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);
@ -28,8 +42,11 @@ const PublicUserList = ({ users, loading, onRefetch }) => {
// Save location to localStorage whenever it changes // Save location to localStorage whenever it changes
useEffect(() => { useEffect(() => {
if (coords) { if (!coords) return;
localStorage.setItem('userLocation', JSON.stringify(coords)); try {
localStorage.setItem(LOCATION_KEY, JSON.stringify(coords));
} catch {
// Speicher nicht verfuegbar - der Standort gilt dann nur fuer diese Sitzung.
} }
}, [coords]); }, [coords]);
@ -37,8 +54,15 @@ 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 } : {});
} }
@ -267,7 +291,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">
<EFBFBD> {user.phone} 📱 {user.phone}
</a> </a>
{user.landline && ( {user.landline && (
<a href={`tel:${user.landline}`} className="user-phone"> <a href={`tel:${user.landline}`} className="user-phone">

View File

@ -33,7 +33,7 @@
} }
.allgemeines-tab-btn.active { .allgemeines-tab-btn.active {
background: white; background: var(--color-surface);
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: white; background: var(--color-surface);
border: 1px solid var(--color-primary); border: 1px solid var(--color-primary);
border-top: none; border-top: none;
border-radius: 0 0 8px 8px; border-radius: 0 0 8px 8px;

View File

@ -25,7 +25,7 @@
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
} }
.user-name-row { .user-card-admin .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: #22c55e; } .dot-green { background: var(--color-success); }
.dot-red { background: #ef4444; } .dot-red { background: var(--color-danger); }
.user-name { .user-card-admin .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: #16a34a; } .label-green { color: var(--color-success); }
.label-red { color: var(--color-danger); } .label-red { color: var(--color-danger); }
.user-info { .user-card-admin .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-address { .user-card-admin .user-address {
margin: 0; margin: 0;
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: 0.9rem; font-size: 0.9rem;
} }
.type-badge { .user-card-admin .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: white; color: var(--color-on-primary);
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-phone { .user-card-admin .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-phone:hover { .user-card-admin .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: white; color: var(--color-on-primary);
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: white; color: var(--color-on-success);
} }
.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: white; color: var(--color-on-secondary);
} }
.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: white; color: var(--color-on-primary);
} }
.edit-button:hover { .edit-button:hover {
@ -281,7 +281,7 @@
.delete-button { .delete-button {
background: var(--color-danger); background: var(--color-danger);
color: white; color: var(--color-on-danger);
} }
.delete-button:hover { .delete-button:hover {
@ -289,17 +289,44 @@
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.user-header { .user-header {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: 0.5rem; gap: 0.5rem;
} }
.gps-inputs { .gps-inputs {
flex-direction: column; flex-direction: column;
} }
.card-actions { .card-actions {
flex-direction: column; flex-direction: column;
} }
} }
.invite-section {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border);
}
.invite-section h4 {
margin: 0 0 0.5rem 0;
font-size: 0.9rem;
color: var(--color-text-muted);
font-weight: 600;
}
.invite-result {
margin-top: 0.5rem;
}
.invite-token {
display: block;
margin-top: 0.35rem;
padding: 0.4rem 0.5rem;
background: var(--color-surface-alt);
border: 1px solid var(--color-border);
border-radius: 2px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.72rem;
word-break: break-all;
user-select: all;
}

View File

@ -2,12 +2,14 @@ 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 }) => { const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, onPhotoUpload, onPhotoDelete, onGenerateInvite }) => {
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 = () => {
@ -50,6 +52,16 @@ 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">
@ -120,6 +132,7 @@ 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>
@ -178,6 +191,37 @@ const UserCard = ({ user, onAvailabilityToggle, onGPSUpdate, onEdit, onDelete, o
)} )}
</div> </div>
{/* Einladungs-Token: einmalig gültiger Code, mit dem der Hundeführer sein
eigenes Passwort setzt. Ohne hinterlegte E-Mail nicht möglich. */}
<div className="invite-section">
<h4>Zugang für den Drohnenführer</h4>
{!user.email ? (
<p className="settings-hint">
Keine E-Mail hinterlegt ohne E-Mail ist kein Login möglich.
</p>
) : (
<>
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={handleGenerateInvite}
disabled={inviteLoading}
>
{inviteLoading ? 'Erzeuge…' : '🔑 Einladungs-Token erzeugen'}
</button>
{invite && (
<div className="invite-result">
<p className="settings-hint">
Diesen Token an <strong>{invite.email}</strong> weitergeben. Gültig bis{' '}
{new Date(invite.expiresAt).toLocaleDateString('de-DE')}.
</p>
<code className="invite-token">{invite.inviteToken}</code>
</div>
)}
</>
)}
</div>
<div className="card-actions"> <div className="card-actions">
<button <button
className="edit-button" className="edit-button"

View File

@ -91,7 +91,7 @@
top: 100%; top: 100%;
left: 0; left: 0;
right: 0; right: 0;
background: white; background: var(--color-surface);
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: white; color: var(--color-on-primary);
} }
.btn-primary:hover { .btn-primary:hover {
@ -172,7 +172,7 @@
.btn-secondary { .btn-secondary {
background: var(--color-secondary); background: var(--color-secondary);
color: white; color: var(--color-on-secondary);
} }
.btn-secondary:hover { .btn-secondary:hover {

View File

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

View File

@ -7,7 +7,7 @@ import Loading from '../common/Loading';
import ErrorMessage from '../common/ErrorMessage'; import ErrorMessage from '../common/ErrorMessage';
import './UserList.css'; import './UserList.css';
const UserList = ({ users, loading, error, onAvailabilityToggle, onGPSUpdate, onUserCreate, onUserUpdate, onUserDelete, onPhotoUpload, onPhotoDelete }) => { const UserList = ({ users, loading, error, onAvailabilityToggle, onGPSUpdate, onUserCreate, onUserUpdate, onUserDelete, onPhotoUpload, onPhotoDelete, onGenerateInvite }) => {
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,6 +145,7 @@ const UserList = ({ users, loading, error, onAvailabilityToggle, onGPSUpdate, on
onDelete={handleDelete} onDelete={handleDelete}
onPhotoUpload={onPhotoUpload} onPhotoUpload={onPhotoUpload}
onPhotoDelete={onPhotoDelete} onPhotoDelete={onPhotoDelete}
onGenerateInvite={onGenerateInvite}
/> />
))} ))}
</div> </div>

View File

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

View File

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

View File

@ -10,10 +10,14 @@ 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('./sw.js') navigator.serviceWorker.register(`${base}sw.js`, { scope: base })
.then(registration => { .then(registration => {
console.log('SW registered: ', registration); console.log('SW registered: ', registration);
}) })

View File

@ -38,8 +38,14 @@ 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 || !config.retry) { if (config.retry === undefined) {
config.retry = 2; // Default: 2 retries config.retry = 2; // Default: 2 retries
config.retryCount = 0; config.retryCount = 0;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,8 +10,10 @@ 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);
process.exit(1); throw error;
} }
}; };

View File

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

View File

@ -1,5 +1,7 @@
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
@ -99,8 +101,13 @@ 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([
{ $match: { adminId: require('mongoose').Types.ObjectId(adminId) } }, // ObjectId ist seit bson 5 eine echte Klasse und braucht new.
{ $match: { adminId: new mongoose.Types.ObjectId(adminId) } },
{ {
$group: { $group: {
_id: '$action', _id: '$action',
@ -214,13 +221,6 @@ const exportAuditLogs = async (req, res) => {
.limit(10000) .limit(10000)
.lean(); .lean();
const escapeCell = (val) => {
if (val == null) return '';
const str = String(val);
return str.includes(',') || str.includes('"') || str.includes('\n')
? `"${str.replace(/"/g, '""')}"` : str;
};
const header = [ const header = [
'Zeitstempel', 'Aktion', 'Ressource', 'Ressourcen-Name', 'Admin', 'Zeitstempel', 'Aktion', 'Ressource', 'Ressourcen-Name', 'Admin',
'IP-Adresse', 'Methode', 'Pfad', 'Status-Code', 'Dauer (ms)', 'IP-Adresse', 'Methode', 'Pfad', 'Status-Code', 'Dauer (ms)',

View File

@ -40,7 +40,7 @@ const login = async (req, res) => {
// Generate token // Generate token
const token = jwt.sign( const token = jwt.sign(
{ id: admin._id, username: admin.username, app: config.appName }, { id: admin._id, username: admin.username, app: config.appName, role: 'admin' },
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); await auditAuth(req, true, username, null, 'LOGOUT');
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,7 +123,9 @@ const forgotPassword = async (req, res) => {
} }
// Find admin // Find admin
const admin = await Admin.findOne({ username }); // case-insensitive wie beim Login: ein als "Thorsten" angelegtes Konto
// konnte sich als "thorsten" anmelden, aber kein Passwort zuruecksetzen.
const admin = await Admin.findOne({ username: { $regex: new RegExp(`^${String(username).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i') } });
// Don't reveal if user exists (security best practice) // Don't reveal if user exists (security best practice)
if (!admin) { if (!admin) {
@ -200,11 +202,13 @@ const resetPassword = async (req, res) => {
}); });
} }
// Validate password length // Muss zu minlength im Admin-Schema passen. Vorher stand hier 6: Passwörter
if (newPassword.length < 6) { // mit 611 Zeichen kamen durch und scheiterten erst an der Mongoose-
// 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 6 Zeichen lang sein' message: 'Passwort muss mindestens 12 Zeichen lang sein'
}); });
} }

View File

@ -1,6 +1,7 @@
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'];
@ -288,7 +289,9 @@ 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)
.select('name type available gps'), // Kontaktdaten sind der Zweck der oeffentlichen Liste; E-Mail, Hashes
// und Invite-Felder bleiben ausgeschlossen.
.select('name type available gps phone landline address photo'),
User.countDocuments(filter) User.countDocuments(filter)
]); ]);
@ -414,14 +417,6 @@ 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 => [
@ -593,8 +588,10 @@ 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 } }, { _id: { $in: ids }, deleted: { $ne: true } },
{ $set: updateFields } { $set: updateFields }
); );
@ -632,8 +629,10 @@ 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 } }, { _id: { $in: ids }, deleted: { $ne: true } },
{ {
$set: { $set: {
deleted: true, deleted: true,

View File

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

View File

@ -1,6 +1,7 @@
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;
@ -20,6 +21,7 @@ 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({
@ -27,6 +29,20 @@ 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) {
@ -37,4 +53,30 @@ const authenticateToken = (req, res, next) => {
} }
}; };
module.exports = { authenticateToken }; // Wie authenticateToken, blockiert aber nicht: setzt req.user wenn ein gültiges
// Admin-Token vorliegt und ruft ansonsten einfach next(). Für Endpunkte wie
// /logout, die auch mit abgelaufenem Token funktionieren müssen.
const attachUserIfPresent = (req, res, next) => {
let token = req.cookies?.token;
if (!token) {
const authHeader = req.headers['authorization'];
token = authHeader && authHeader.split(' ')[1];
}
if (token) {
try {
const decoded = jwt.verify(token, config.jwtSecret);
const appOk = !decoded.app || decoded.app === config.appName;
const roleOk = !decoded.role || decoded.role === 'admin';
if (appOk && roleOk) {
req.user = decoded;
}
} catch (error) {
// Ungültiges Token ist hier kein Fehler der Aufrufer wird ohne req.user bedient.
}
}
next();
};
module.exports = { authenticateToken, attachUserIfPresent };

View File

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

View File

@ -15,9 +15,12 @@
"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",
"winston": "^3.19.0" "nodemailer": "^6.9.16",
"winston": "^3.19.0",
"winston-daily-rotate-file": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
"jest": "^30.2.0", "jest": "^30.2.0",
@ -2880,6 +2883,15 @@
"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",
@ -3242,6 +3254,18 @@
"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",
@ -4579,6 +4603,15 @@
"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",
@ -4754,6 +4787,15 @@
"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",
@ -4840,6 +4882,15 @@
"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",
@ -6249,6 +6300,24 @@
"node": ">= 12.0.0" "node": ">= 12.0.0"
} }
}, },
"node_modules/winston-daily-rotate-file": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/winston-daily-rotate-file/-/winston-daily-rotate-file-5.0.0.tgz",
"integrity": "sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==",
"license": "MIT",
"dependencies": {
"file-stream-rotator": "^0.6.1",
"object-hash": "^3.0.0",
"triple-beam": "^1.4.1",
"winston-transport": "^4.7.0"
},
"engines": {
"node": ">=8"
},
"peerDependencies": {
"winston": "^3"
}
},
"node_modules/winston-transport": { "node_modules/winston-transport": {
"version": "4.9.0", "version": "4.9.0",
"resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz",

View File

@ -2,9 +2,12 @@ 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);
router.post('/logout', logout); // attachUserIfPresent statt authenticateToken: der Logout muss auch mit
// abgelaufenem Token funktionieren, soll den Benutzernamen aber protokollieren.
router.post('/logout', attachUserIfPresent, logout);
router.post('/forgot-password', forgotPassword); router.post('/forgot-password', forgotPassword);
router.post('/reset-password', resetPassword); router.post('/reset-password', resetPassword);

View File

@ -3,6 +3,7 @@ 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,
@ -25,13 +26,19 @@ const {
// Public routes // Public routes
router.get('/public/users', getPublicUsers); router.get('/public/users', getPublicUsers);
router.get('/public/geocode', getGeocodeByPostalCode); // Eigenes, engeres Limit: der Endpunkt loest ausgehende Nominatim-Anfragen aus.
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);
@ -45,7 +52,5 @@ router.post('/users/:id/photo', authenticateToken, auditLog('UPDATE', 'User'), u
router.delete('/users/:id/photo', authenticateToken, auditLog('UPDATE', 'User'), deleteUserPhoto); router.delete('/users/:id/photo', authenticateToken, auditLog('UPDATE', 'User'), deleteUserPhoto);
// Bulk operations // Bulk operations
router.patch('/users/bulk', authenticateToken, auditLog('BULK_UPDATE', 'User'), bulkUpdateUsers);
router.delete('/users/bulk', authenticateToken, auditLog('BULK_DELETE', 'User'), bulkDeleteUsers);
module.exports = router; module.exports = router;

View File

@ -12,7 +12,10 @@ const users = [];
const seedDatabase = async () => { const seedDatabase = async () => {
try { try {
await mongoose.connect(config.mongoUri); // Beim Aufruf aus server.js besteht die Verbindung bereits.
if (mongoose.connection.readyState !== 1) {
await mongoose.connect(config.mongoUri);
}
logger.info('MongoDB verbunden für Seeding...'); logger.info('MongoDB verbunden für Seeding...');
@ -54,6 +57,8 @@ 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: [
@ -63,7 +68,7 @@ const seedDatabase = async () => {
{ code: 'LAB', label: 'Labrador' } { code: 'LAB', label: 'Labrador' }
], ],
rules: [ rules: [
"Verbreiten Sie den Standort und den Anschuss.", "Verbrechen 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.",
@ -97,22 +102,23 @@ const seedDatabase = async () => {
await Config.create(configData); await Config.create(configData);
logger.info('✅ Konfiguration erstellt'); logger.info('✅ Konfiguration erstellt');
} else { } else {
await Config.findOneAndUpdate({}, configData, { new: true }); logger.info(' Konfiguration existiert bereits, bleibt unveraendert');
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);
await mongoose.connection.close(); throw error;
process.exit(1);
} }
}; };
// Verbindung schliessen und den Prozess beenden darf nur der CLI-Aufruf
// (npm run seed). server.js ruft seedDatabase() im selben Prozess auf ein
// process.exit(0) hier hat den frisch gestarteten Server sofort wieder beendet.
if (require.main === module) { if (require.main === module) {
seedDatabase(); seedDatabase()
.then(async () => { await mongoose.connection.close(); process.exit(0); })
.catch(async () => { await mongoose.connection.close(); process.exit(1); });
} }
module.exports = seedDatabase; module.exports = seedDatabase;

View File

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

View File

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

View File

@ -6,17 +6,41 @@ 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);
Object.entries(parsed).forEach(([key, value]) => { // Nur die letzten CACHE_MAX_ENTRIES übernehmen eine früher unbegrenzt
// gewachsene Datei darf den Cache nicht wieder aufblähen.
const entries = Object.entries(parsed).slice(-CACHE_MAX_ENTRIES);
entries.forEach(([key, value]) => {
cache.set(key, value); cache.set(key, value);
}); });
logger.info(`Geocoding cache loaded: ${cache.size} entries`); logger.info(`Geocoding cache loaded: ${cache.size} entries`);
@ -45,14 +69,13 @@ const saveCache = async () => {
setInterval(saveCache, CACHE_SAVE_INTERVAL).unref(); setInterval(saveCache, CACHE_SAVE_INTERVAL).unref();
// Save on process exit // Save on process exit
process.on('SIGINT', async () => { // Cache beim Herunterfahren sichern ohne process.exit(): das Beenden gehört
await saveCache(); // dem Shutdown-Handler in server.js, der sonst mittendrin abgeschnitten wird.
process.exit(0); const flushOnShutdown = () => {
}); saveCache().catch(err => logger.error('Failed to flush geocoding cache:', err.message));
process.on('SIGTERM', async () => { };
await saveCache(); process.on('SIGINT', flushOnShutdown);
process.exit(0); process.on('SIGTERM', flushOnShutdown);
});
// Initialize cache loading // Initialize cache loading
loadCache().catch(err => logger.error('Cache initialization error:', err)); loadCache().catch(err => logger.error('Cache initialization error:', err));
@ -89,7 +112,12 @@ const geocodeAddress = async (address) => {
const cacheKey = normalized.toLowerCase(); const cacheKey = normalized.toLowerCase();
if (cache.has(cacheKey)) { if (cache.has(cacheKey)) {
return cache.get(cacheKey); // Treffer ans Ende rücken, damit die Verdrängung wirklich den am längsten
// ungenutzten Eintrag trifft und nicht bloß den ältesten eingefügten.
const hit = cache.get(cacheKey);
cache.delete(cacheKey);
cache.set(cacheKey, hit);
return hit;
} }
const elapsed = Date.now() - lastRequestTime; const elapsed = Date.now() - lastRequestTime;
@ -107,8 +135,7 @@ const geocodeAddress = async (address) => {
lastRequestTime = Date.now(); lastRequestTime = Date.now();
if (!Array.isArray(results) || results.length === 0) { if (!Array.isArray(results) || results.length === 0) {
cache.set(cacheKey, null); rememberInCache(cacheKey, null);
cacheModified = true;
return null; return null;
} }
@ -117,15 +144,12 @@ 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)) {
cache.set(cacheKey, null); rememberInCache(cacheKey, null);
cacheModified = true;
return null; return null;
} }
const coords = { lat, lng }; const coords = { lat, lng };
if (cache.size >= 1000) { cache.delete(cache.keys().next().value); } rememberInCache(cacheKey, coords);
cache.set(cacheKey, coords);
cacheModified = true;
return coords; return coords;
} catch (error) { } catch (error) {
logger.warn('Geocoding fehlgeschlagen', { logger.warn('Geocoding fehlgeschlagen', {

View File

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

View File

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

View File

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

View File

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

View File

@ -1,16 +1,18 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="de">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%BASE_URL%favicon.ico" /> <link rel="icon" href="%BASE_URL%icons/icon-192.png" />
<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="#2d6a2d" /> <meta name="theme-color" content="#1a3d1a" />
<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" />
<link rel="manifest" href="/manifest.json" /> <!-- Pfad relativ zur Deployment-Basis: ein absolutes "/manifest.json" wuerde
das Portal-Manifest laden und die App als Portal installieren. -->
<link rel="manifest" href="%BASE_URL%manifest.json" />
<meta <meta
name="description" name="description"
content="Nachsuchenstation Heidekreis Übersicht der Nachsuchenführer" content="Nachsuchenstation Heidekreis Übersicht der Nachsuchenführer"

View File

@ -27,19 +27,28 @@ 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;
@ -47,6 +56,9 @@ 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;
} }
@ -79,6 +91,9 @@ 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;
@ -86,6 +101,9 @@ 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;
@ -93,6 +111,9 @@ 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";
@ -102,6 +123,9 @@ 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";
@ -110,6 +134,9 @@ server {
# Health check endpoint # Health check endpoint
location /health { location /health {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
access_log off; access_log off;
return 200 "OK\n"; return 200 "OK\n";
add_header Content-Type text/plain; add_header Content-Type text/plain;

File diff suppressed because it is too large Load Diff

View File

@ -3,28 +3,16 @@
"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": "react-scripts start", "start": "vite",
"build": "vite build", "build": "vite build",
"test": "react-scripts test", "preview": "vite preview"
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
}, },
"browserslist": { "browserslist": {
"production": [ "production": [

View File

@ -1,24 +0,0 @@
<!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.

Before

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 692 KiB

View File

@ -2,11 +2,6 @@
"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",
@ -24,7 +19,7 @@
"scope": "/nachsuche/", "scope": "/nachsuche/",
"display": "standalone", "display": "standalone",
"orientation": "portrait", "orientation": "portrait",
"theme_color": "#2d6a2d", "theme_color": "#1a3d1a",
"background_color": "#ffffff", "background_color": "#e8e8e2",
"description": "Nachsuchenstation Heidekreis Übersicht der Nachsuchenführer" "description": "Nachsuchenstation Heidekreis Übersicht der Nachsuchenführer"
} }

View File

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

View File

@ -2,43 +2,203 @@
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 {
--color-primary: #2e8b2e; --font-display: Georgia, 'Times New Roman', serif;
--color-primary-dark: #1e6b1e; --font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
--color-primary-accent: #8B0D12; 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
--color-secondary: #6c757d; --font-mono: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
--color-secondary-dark: #5a6268;
--color-success: #28a745; /* Flächen */
--color-success-dark: #218838; --color-bg: #e8e8e2;
--color-danger: #dc3545; --color-surface: #f4f4ee;
--color-danger-dark: #c82333; --color-surface-alt: #dedcd2;
--color-bg: #f5f5f0; --color-muted-bg: #dedcd2;
--color-surface: #ffffff;
/* Text */
--color-text: #1a1a1a; --color-text: #1a1a1a;
--color-text-muted: #555; --color-text-muted: #55554c;
--color-border: #b8d4b8;
--color-border-strong: #88b888; /* Rahmen: -border ist dekorativ (Trennlinien, Karten),
--color-focus: rgba(46, 139, 46, 0.2); -border-strong begrenzt Bedienelemente und erfüllt die 3:1-Anforderung. */
--color-muted-bg: #f5f5f0; --color-border: #c9c9b8;
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1); --color-border-strong: #7d7d68;
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.2);
--radius-sm: 4px; /* Aktionsfarben jeweils mit der zugehörigen Textfarbe, damit die
--radius-md: 8px; Dunkelvariante nicht auf weißem Text auf hellem Grund landet. */
--radius-pill: 12px; --color-primary: #2d5a2d;
--color-primary-dark: #1a3d1a;
--color-on-primary: #ffffff;
--color-secondary: #5c5c50;
--color-secondary-dark: #46463c;
--color-on-secondary: #ffffff;
--color-success: #1f6b34;
--color-success-dark: #175128;
--color-on-success: #ffffff;
--color-danger: #a32020;
--color-danger-dark: #821919;
--color-on-danger: #ffffff;
--color-warning: #7a5200;
--color-on-warning: #ffffff;
--color-accent: #8b0d12;
--color-on-accent: #ffffff;
/* Altname, wird von RulesDisplay noch benutzt */
--color-primary-accent: #8b0d12;
/* Getönte Status-Flächen (Meldungen, Badges, Zustands-Karten).
Der Block wird durch seine Füllung erkannt, der Rahmen ist Zierde. */
--color-success-bg: #dfeedd;
--color-success-border: #a9cba4;
--color-success-text: #1a4a24;
--color-danger-bg: #f6e0e0;
--color-danger-border: #d9a9a9;
--color-danger-text: #7d1a1a;
--color-warning-bg: #f7edd4;
--color-warning-border: #d9c48a;
--color-warning-text: #5c3d00;
--color-info: #24608f;
--color-info-dark: #1b4a6e;
--color-on-info: #ffffff;
--color-info-bg: #dde8f1;
--color-info-border: #a5bfd4;
--color-info-text: #14405f;
/* Als RGB-Tripel fuer rgba()-Anwendungen (Puls-Animation im Admin-Panel) */
--color-primary-rgb: 45, 90, 45;
--color-focus: rgba(45, 90, 45, 0.35);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.15);
--shadow-md: 0 2px 6px rgba(0, 0, 0, 0.2);
/* Kantige Ecken wie im Portal */
--radius-sm: 2px;
--radius-md: 2px;
--radius-pill: 2px;
--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;
@ -46,15 +206,24 @@
} }
.btn:disabled { .btn:disabled {
background: var(--color-border); background: var(--color-surface-alt);
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: white; color: var(--color-on-primary);
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
@ -64,7 +233,7 @@
.btn-secondary { .btn-secondary {
background: var(--color-secondary); background: var(--color-secondary);
color: white; color: var(--color-on-secondary);
} }
.btn-secondary:hover:not(:disabled) { .btn-secondary:hover:not(:disabled) {
@ -72,19 +241,9 @@
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: white; color: var(--color-on-danger);
} }
.btn-danger:hover:not(:disabled) { .btn-danger:hover:not(:disabled) {
@ -96,9 +255,13 @@
.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;
} }
@ -111,33 +274,19 @@
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);
} }
body { /* ── Grundgerüst ────────────────────────────────────────────────────────── */
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 {
min-height: 100vh; /* dvh statt vh: mit ein- und ausblendender Adressleiste auf Mobilgeräten
entsteht mit vh sonst Überlauf. */
min-height: 100dvh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@ -151,44 +300,26 @@ body {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
min-height: 100vh; min-height: 100dvh;
font-size: 1.2rem; font-size: 1.2rem;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.login-prompt { /* Wer Bewegung reduziert haben möchte, bekommt keine Animationen. */
position: fixed; @media (prefers-reduced-motion: reduce) {
bottom: 20px; *,
right: 20px; *::before,
} *::after {
animation-duration: 0.01ms !important;
.login-button-header { animation-iteration-count: 1 !important;
padding: 0.75rem 1.5rem; transition-duration: 0.01ms !important;
background: var(--color-primary); scroll-behavior: auto !important;
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;
} }
.login-button-header { .btn:hover:not(:disabled),
padding: 0.6rem 1.2rem; .btn-primary:hover:not(:disabled),
font-size: 0.9rem; .btn-secondary:hover:not(:disabled),
.btn-danger:hover:not(:disabled) {
transform: none;
} }
} }

View File

@ -11,13 +11,15 @@ 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() {
const adminPath = process.env.REACT_APP_ADMIN_PATH || '/verwaltung'; // Die Pfade muessen den Deployment-Basispfad enthalten: produktiv laeuft die
const resetPasswordPath = '/passwort-zuruecksetzen'; // App unter /<app>/, ein Vergleich gegen '/verwaltung' traefe dort nie zu.
const isAdminRoute = window.location.pathname === adminPath; const currentPath = normalizePath(window.location.pathname);
const isResetPasswordRoute = window.location.pathname === resetPasswordPath; const isAdminRoute = currentPath === normalizePath(withBase(ADMIN_PATH));
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();
@ -97,7 +99,6 @@ function App() {
isAdmin={false} isAdmin={false}
currentView={view} currentView={view}
onViewChange={handleViewChange} onViewChange={handleViewChange}
onHandlerLogin={handleHandlerLogout}
/> />
<main className="app-main"> <main className="app-main">
{isAdminRoute || view === 'login' ? ( {isAdminRoute || view === 'login' ? (

View File

@ -25,7 +25,7 @@
.tab-button.active { .tab-button.active {
background: var(--color-primary); background: var(--color-primary);
color: white; color: var(--color-on-primary);
} }
.settings-panel { .settings-panel {
@ -59,9 +59,9 @@
.unsaved-badge { .unsaved-badge {
display: inline-block; display: inline-block;
background: #fff3cd; background: var(--color-warning-bg);
color: #856404; color: var(--color-warning-text);
border: 1px solid #ffc107; border: 1px solid var(--color-warning);
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, #f5f5f0); background: var(--color-muted-bg, var(--color-surface-alt));
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, #eef2e6); background: var(--color-focus, var(--color-success-bg));
} }
.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, #e8e8e4 25%, #f0f0ec 50%, #e8e8e4 75%); background: linear-gradient(90deg, var(--color-surface-alt) 25%, var(--color-surface-alt) 50%, var(--color-surface-alt) 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: white; color: var(--color-on-success);
} }
.notification.error { .notification.error {
background: var(--color-danger); background: var(--color-danger);
color: white; color: var(--color-on-danger);
} }
.notification.warning { .notification.warning {
background: #e67e00; background: var(--color-warning);
color: white; color: var(--color-on-warning);
} }
@keyframes slideIn { @keyframes slideIn {

View File

@ -3,7 +3,7 @@ import UserList from '../users/UserList';
import ExportButton from './ExportButton'; import ExportButton from './ExportButton';
import Trash from './Trash'; import Trash from './Trash';
import AuditLogs from './AuditLogs'; import AuditLogs from './AuditLogs';
import { updateAvailability, updateGPS, createUser, updateUser, deleteUser, uploadUserPhoto, deleteUserPhoto } from '../../services/users'; import { updateAvailability, updateGPS, createUser, updateUser, deleteUser, uploadUserPhoto, deleteUserPhoto, generateInviteToken } 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,6 +127,16 @@ 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);
@ -187,7 +197,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, SVG, ...)'); showNotification('error', 'Nur Bilddateien erlaubt (JPG, PNG, WebP)');
return; return;
} }
if (file.size > 500 * 1024) { if (file.size > 500 * 1024) {
@ -291,6 +301,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
onUserDelete={handleUserDelete} onUserDelete={handleUserDelete}
onPhotoUpload={handleUserPhotoUpload} onPhotoUpload={handleUserPhotoUpload}
onPhotoDelete={handleUserPhotoDelete} onPhotoDelete={handleUserPhotoDelete}
onGenerateInvite={handleGenerateInvite}
/> />
</> </>
)} )}
@ -408,7 +419,7 @@ const AdminPanel = ({ users, loading, error, onRefetch }) => {
</button> </button>
)} )}
</div> </div>
<p className="settings-hint">Empfohlen: PNG, SVG oder JPG, max. 500 KB</p> <p className="settings-hint">Erlaubt: PNG, JPG oder WebP, max. 500 KB (SVG wird aus Sicherheitsgruenden abgelehnt)</p>
</div> </div>
)} )}
</div> </div>

View File

@ -13,8 +13,8 @@
gap: 12px; gap: 12px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.audit-header h2 { margin: 0 0 4px 0; color: #333; font-size: 26px; } .audit-header h2 { margin: 0 0 4px 0; color: var(--color-text); font-size: 26px; }
.audit-description { margin: 0; color: #666; font-size: 13px; } .audit-description { margin: 0; color: var(--color-text-muted); 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: #f0f4f8; background: var(--color-surface-alt);
border: 1px solid #d0d7de; border: 1px solid var(--color-border);
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 13px;
color: #444; color: var(--color-text-muted);
} }
.btn-stats-toggle:hover { background: #e2e8f0; } .btn-stats-toggle:hover { background: var(--color-surface-alt); }
.stats-days-select { font-size: 13px; } .stats-days-select { font-size: 13px; }
.audit-stats { .audit-stats {
background: #f8fafc; background: var(--color-surface-alt);
border: 1px solid #e2e8f0; border: 1px solid var(--color-border);
border-radius: 10px; border-radius: 10px;
padding: 16px; padding: 16px;
margin-bottom: 16px; margin-bottom: 16px;
} }
.stats-loading { color: #888; font-size: 13px; padding: 8px 0; } .stats-loading { color: var(--color-text-muted); 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: white; background: var(--color-surface);
border-radius: 8px; border-radius: 8px;
padding: 12px 16px; padding: 12px 16px;
text-align: center; text-align: center;
border: 1px solid #e2e8f0; border: 1px solid var(--color-border);
} }
.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: #666; margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; } .stat-label { font-size: 11px; color: var(--color-text-muted); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
.stat-total .stat-number { color: #1976d2; } .stat-total .stat-number { color: var(--color-info-text); }
.stat-failed .stat-number { color: #c62828; } .stat-failed .stat-number { color: var(--color-danger-text); }
.stat-success .stat-number { color: #2e7d32; } .stat-success .stat-number { color: var(--color-success-text); }
.stat-period .stat-number { color: #555; font-size: 20px; } .stat-period .stat-number { color: var(--color-text-muted); 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: #888; letter-spacing: 0.5px; } .stats-col h4 { margin: 0 0 8px 0; font-size: 12px; text-transform: uppercase; color: var(--color-text-muted); 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: #444; } .stat-bar-label { font-size: 13px; color: var(--color-text-muted); }
.stat-bar-count { font-size: 13px; font-weight: 600; color: #333; flex-shrink: 0; } .stat-bar-count { font-size: 13px; font-weight: 600; color: var(--color-text); 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: #f0f4f8; background: var(--color-surface-alt);
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: #1976d2; background: var(--color-info);
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: #e53935; background: var(--color-danger);
} }
/* ── Filters ─────────────────────────────────────────────────────── */ /* ── Filters ─────────────────────────────────────────────────────── */
@ -134,52 +134,52 @@
} }
.filter-select { .filter-select {
padding: 8px 12px; padding: 8px 12px;
border: 1.5px solid #d0d7de; border: 1.5px solid var(--color-border);
border-radius: 6px; border-radius: 6px;
font-size: 13px; font-size: 13px;
background: white; background: var(--color-surface);
cursor: pointer; cursor: pointer;
} }
.filter-select:focus { outline: none; border-color: #1976d2; } .filter-select:focus { outline: none; border-color: var(--color-primary); }
.filter-input { .filter-input {
padding: 8px 12px; padding: 8px 12px;
border: 1.5px solid #d0d7de; border: 1.5px solid var(--color-border);
border-radius: 6px; border-radius: 6px;
font-size: 13px; font-size: 13px;
background: white; background: var(--color-surface);
min-width: 140px; min-width: 140px;
} }
.filter-input:focus { outline: none; border-color: #1976d2; } .filter-input:focus { outline: none; border-color: var(--color-primary); }
.filter-date { min-width: 130px; } .filter-date { min-width: 130px; }
.btn-refresh { .btn-refresh {
padding: 8px 14px; padding: 8px 14px;
background: #1976d2; background: var(--color-info);
color: white; color: var(--color-on-info);
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: #1565c0; } .btn-refresh:hover:not(:disabled) { background: var(--color-info-dark); }
.btn-refresh:disabled { background: #bdbdbd; cursor: not-allowed; } .btn-refresh:disabled { background: var(--color-surface-alt); color: var(--color-text-muted); cursor: not-allowed; }
.btn-reset { .btn-reset {
padding: 8px 12px; padding: 8px 12px;
background: white; background: var(--color-surface);
color: #666; color: var(--color-text-muted);
border: 1.5px solid #d0d7de; border: 1.5px solid var(--color-border);
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 13px;
} }
.btn-reset:hover { background: #f5f5f5; } .btn-reset:hover { background: var(--color-surface-alt); }
.btn-export { .btn-export {
padding: 8px 16px; padding: 8px 16px;
background: #2e7d32; background: var(--color-success);
color: white; color: var(--color-on-success);
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: #1b5e20; } .btn-export:hover:not(:disabled) { background: var(--color-success-dark); }
.btn-export:disabled { background: #bdbdbd; cursor: not-allowed; } .btn-export:disabled { background: var(--color-surface-alt); color: var(--color-text-muted); 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: #555; color: var(--color-text-muted);
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: #666; color: var(--color-text-muted);
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: #c62828; } .badge-delete { background: #ffebee; color: var(--color-danger-text); }
.badge-restore { background: #fff3e0; color: #e65100; } .badge-restore { background: #fff3e0; color: #a83a00; }
.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: #e8eaf6; color: #283593; } .badge-bulk-update { background: var(--color-surface-alt); color: #283593; }
.badge-bulk-delete { background: #fbe9e7; color: #bf360c; } .badge-bulk-delete { background: #fbe9e7; color: #bf360c; }
.badge-password { background: #fff8e1; color: #f57f17; } .badge-password { background: #fff8e1; color: #9c4e00; }
.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: #e65100; } .status-redirect { background: #fff3e0; color: #a83a00; }
.status-error { background: #ffebee; color: #c62828; } .status-error { background: #ffebee; color: var(--color-danger-text); }
.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: #f5f5f5; background: var(--color-surface-alt);
color: #777; color: var(--color-text-muted);
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: #f9f9f9; background: var(--color-surface-alt);
border-radius: 8px; border-radius: 8px;
color: #888; color: var(--color-text-muted);
} }
.audit-error { text-align: center; padding: 20px; } .audit-error { text-align: center; padding: 20px; }
.audit-error p { color: #d32f2f; margin: 0; } .audit-error p { color: var(--color-danger-text); 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: white; background: var(--color-surface);
border: 1px solid #e0e0e0; border: 1px solid var(--color-border);
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 #e53935; } .audit-item-failed { border-left: 4px solid var(--color-danger); }
.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: #fafafa; background: var(--color-surface-alt);
border-bottom: 1px solid #f0f0f0; border-bottom: 1px solid var(--color-border);
} }
.audit-time { margin-left: auto; color: #999; font-size: 12px; white-space: nowrap; } .audit-time { margin-left: auto; color: var(--color-text-muted); font-size: 12px; white-space: nowrap; }
.expand-toggle { color: #aaa; font-size: 11px; cursor: pointer; padding: 0 4px; } .expand-toggle { color: var(--color-text-muted); font-size: 11px; cursor: pointer; padding: 0 4px; }
.audit-resource { .audit-resource {
padding: 3px 8px; padding: 3px 8px;
background: #f0f0f0; background: var(--color-surface-alt);
border-radius: 4px; border-radius: 4px;
font-size: 12px; font-size: 12px;
color: #555; color: var(--color-text-muted);
} }
.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: #333; } .audit-admin { color: var(--color-text); }
.audit-resource-name { color: #555; } .audit-resource-name { color: var(--color-text-muted); }
.audit-ip { color: #999; font-family: monospace; font-size: 12px; } .audit-ip { color: var(--color-text-muted); font-family: monospace; font-size: 12px; }
.audit-failed-badge { color: #c62828; font-weight: 600; font-size: 12px; } .audit-failed-badge { color: var(--color-danger-text); 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 #e0e0e0; border-top: 1px dashed var(--color-border);
background: #fefefe; background: var(--color-surface);
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: #888; color: var(--color-text-muted);
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: #f0f0f0; background: var(--color-surface-alt);
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: #aaa; color: var(--color-text-muted);
cursor: help; cursor: help;
font-size: 12px; font-size: 12px;
} }
.detail-error { color: #c62828; } .detail-error { color: var(--color-danger-text); }
.detail-error .detail-label { color: #c62828; } .detail-error .detail-label { color: var(--color-danger-text); }
.meta-tag { .meta-tag {
display: inline-block; display: inline-block;
background: #e8eaf6; background: var(--color-surface-alt);
color: #283593; color: var(--color-info-text);
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: #f0f0f0; background: var(--color-surface-alt);
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: #666; color: var(--color-text-muted);
border: 1px solid #e0e0e0; border: 1px solid var(--color-border);
} }
.diff-table td { padding: 5px 10px; border: 1px solid #e8e8e8; vertical-align: top; } .diff-table td { padding: 5px 10px; border: 1px solid var(--color-border); vertical-align: top; }
.diff-field { font-weight: 600; color: #444; font-family: monospace; white-space: nowrap; background: #fafafa; } .diff-field { font-weight: 600; color: var(--color-text-muted); font-family: monospace; white-space: nowrap; background: var(--color-surface-alt); }
.diff-before { color: #c62828; background: #fff5f5; font-family: monospace; word-break: break-all; } .diff-before { color: var(--color-danger-text); 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 #d0d7de; border: 1.5px solid var(--color-border);
border-radius: 6px; border-radius: 6px;
background: white; background: var(--color-surface);
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 13px;
color: #333; color: var(--color-text);
} }
.btn-page:hover:not(:disabled) { background: #f0f4f8; border-color: #1976d2; } .btn-page:hover:not(:disabled) { background: var(--color-surface-alt); border-color: var(--color-primary); }
.btn-page:disabled { color: #bbb; cursor: not-allowed; border-color: #eee; } .btn-page:disabled { color: var(--color-text-muted); cursor: not-allowed; border-color: var(--color-border); }
.page-info { font-size: 13px; color: #666; padding: 0 8px; } .page-info { font-size: 13px; color: var(--color-text-muted); 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: #333; color: var(--color-text);
font-size: 28px; font-size: 28px;
} }
.audit-description { .audit-description {
margin: 0; margin: 0;
color: #666; color: var(--color-text-muted);
font-size: 14px; font-size: 14px;
} }
@ -428,7 +428,7 @@
} }
.audit-error p { .audit-error p {
color: #d32f2f; color: var(--color-danger-text);
margin: 0; margin: 0;
} }
@ -441,23 +441,23 @@
.filter-select { .filter-select {
padding: 10px 14px; padding: 10px 14px;
border: 2px solid #e0e0e0; border: 2px solid var(--color-border);
border-radius: 6px; border-radius: 6px;
font-size: 14px; font-size: 14px;
background: white; background: var(--color-surface);
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: #1976d2; border-color: var(--color-primary);
} }
.btn-refresh { .btn-refresh {
padding: 10px 16px; padding: 10px 16px;
background: #1976d2; background: var(--color-info);
color: white; color: var(--color-on-info);
border: none; border: none;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
@ -467,20 +467,21 @@
} }
.btn-refresh:hover:not(:disabled) { .btn-refresh:hover:not(:disabled) {
background: #1565c0; background: var(--color-info-dark);
} }
.btn-refresh:disabled { .btn-refresh:disabled {
background: #bdbdbd; background: var(--color-surface-alt);
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: #f5f5f5; background: var(--color-surface-alt);
border-radius: 8px; border-radius: 8px;
color: #666; color: var(--color-text-muted);
} }
.audit-list { .audit-list {
@ -490,8 +491,8 @@
} }
.audit-item { .audit-item {
background: white; background: var(--color-surface);
border: 1px solid #e0e0e0; border: 1px solid var(--color-border);
border-radius: 8px; border-radius: 8px;
padding: 16px; padding: 16px;
transition: box-shadow 0.2s; transition: box-shadow 0.2s;
@ -519,58 +520,18 @@
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: #f5f5f5; background: var(--color-surface-alt);
border-radius: 4px; border-radius: 4px;
font-size: 13px; font-size: 13px;
color: #666; color: var(--color-text-muted);
} }
.audit-time { .audit-time {
margin-left: auto; margin-left: auto;
color: #999; color: var(--color-text-muted);
font-size: 13px; font-size: 13px;
} }
@ -582,23 +543,19 @@
} }
.audit-admin { .audit-admin {
color: #333; color: var(--color-text);
} }
.audit-resource-name { .audit-resource-name {
color: #666; color: var(--color-text-muted);
} }
.audit-ip { .audit-ip {
color: #888; color: var(--color-text-muted);
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;
@ -607,15 +564,15 @@
gap: 16px; gap: 16px;
margin-top: 24px; margin-top: 24px;
padding: 16px; padding: 16px;
background: white; background: var(--color-surface);
border-radius: 8px; border-radius: 8px;
border: 1px solid #e0e0e0; border: 1px solid var(--color-border);
} }
.btn-page { .btn-page {
padding: 8px 16px; padding: 8px 16px;
background: #1976d2; background: var(--color-info);
color: white; color: var(--color-on-info);
border: none; border: none;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
@ -624,16 +581,17 @@
} }
.btn-page:hover:not(:disabled) { .btn-page:hover:not(:disabled) {
background: #1565c0; background: var(--color-info-dark);
} }
.btn-page:disabled { .btn-page:disabled {
background: #bdbdbd; background: var(--color-surface-alt);
color: var(--color-text-muted);
cursor: not-allowed; cursor: not-allowed;
} }
.page-info { .page-info {
color: #666; color: var(--color-text-muted);
font-size: 14px; font-size: 14px;
} }
@ -656,3 +614,33 @@
width: 100%; width: 100%;
} }
} }
/* Nachtvariante
Die Badges kodieren die Aktionsart über den Farbton der bleibt erhalten,
nur Helligkeit und Sättigung drehen sich um. Alle Paare >= 6.4:1. */
@media (prefers-color-scheme: dark) {
.badge-create { background: #162d17; color: #97d99a; }
.badge-update { background: #16202d; color: #89b5e6; }
.badge-delete { background: #2d1616; color: #e68989; }
.badge-restore { background: #2d1e16; color: #e6aa89; }
.badge-login { background: #24162d; color: #c389e6; }
.badge-logout { background: #2d1622; color: #e689bb; }
.badge-login-failed { background: #2d1616; color: #e68989; }
.badge-import { background: #162d2a; color: #89e6db; }
.badge-export { background: #162d17; color: #90df96; }
.badge-bulk-update { background: #16182d; color: #8f99e0; }
.badge-bulk-delete { background: #2d1b16; color: #e69f89; }
.badge-password { background: #2d2016; color: #e6b589; }
.badge-default { background: var(--color-surface-alt); color: var(--color-text-muted); }
.status-ok { background: #162d17; color: #97d99a; }
.status-redirect { background: #2d1e16; color: #e6aa89; }
.status-error { background: #2d1616; color: #e68989; }
.status-server-error { background: #24162d; color: #c389e6; }
/* Diff-Tabelle: die roten/gruenen Vorher-Nachher-Felder */
.diff-table th { background: var(--color-surface-alt); }
.diff-before { color: #e68989; background: #2d1616; }
.diff-after { color: #97d99a; background: #162d17; }
.diff-field { background: var(--color-surface-alt); color: var(--color-text); }
}

View File

@ -14,7 +14,7 @@
.export-button { .export-button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background: var(--color-success); background: var(--color-success);
color: white; color: var(--color-on-success);
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, #2563eb); background: var(--color-primary, var(--color-info));
color: white; color: var(--color-on-primary);
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, #1d4ed8); background: var(--color-primary-dark, var(--color-info-dark));
} }
.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, #6b7280); color: var(--color-text-muted);
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
} }

View File

@ -10,13 +10,13 @@
.trash-header h2 { .trash-header h2 {
margin: 0 0 8px 0; margin: 0 0 8px 0;
color: #333; color: var(--color-text);
font-size: 28px; font-size: 28px;
} }
.trash-description { .trash-description {
margin: 0; margin: 0;
color: #666; color: var(--color-text-muted);
font-size: 14px; font-size: 14px;
} }
@ -27,14 +27,14 @@
} }
.trash-error h3 { .trash-error h3 {
color: #d32f2f; color: var(--color-danger-text);
margin-bottom: 12px; margin-bottom: 12px;
} }
.btn-retry { .btn-retry {
padding: 10px 20px; padding: 10px 20px;
background: #1976d2; background: var(--color-info);
color: white; color: var(--color-on-info);
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: #1565c0; background: var(--color-info-dark);
} }
.trash-empty { .trash-empty {
text-align: center; text-align: center;
padding: 60px 20px; padding: 60px 20px;
background: #f5f5f5; background: var(--color-surface-alt);
border-radius: 12px; border-radius: 12px;
} }
@ -60,7 +60,7 @@
} }
.trash-empty p { .trash-empty p {
color: #666; color: var(--color-text-muted);
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
} }
@ -72,8 +72,8 @@
} }
.trash-item { .trash-item {
background: #fff; background: var(--color-surface);
border: 2px solid #e0e0e0; border: 2px solid var(--color-border);
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: #bdbdbd; border-color: var(--color-border);
} }
.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: #333; color: var(--color-text);
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
} }
@ -106,8 +106,8 @@
} }
.trash-item-type { .trash-item-type {
background: #e3f2fd; background: var(--color-info-bg);
color: #1976d2; color: var(--color-info-text);
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: #666; color: var(--color-text-muted);
font-size: 14px; font-size: 14px;
} }
.trash-item-meta { .trash-item-meta {
color: #999; color: var(--color-text-muted);
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: #4caf50; background: var(--color-success);
color: white; color: var(--color-on-success);
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: #45a049; background: var(--color-success-dark);
transform: translateY(-1px); transform: translateY(-1px);
} }
@ -160,7 +160,7 @@
} }
.btn-restore:disabled { .btn-restore:disabled {
background: #bdbdbd; background: var(--color-border-strong);
cursor: not-allowed; cursor: not-allowed;
} }

View File

@ -56,7 +56,7 @@
width: 100%; width: 100%;
padding: 0.75rem; padding: 0.75rem;
background: var(--color-primary); background: var(--color-primary);
color: white; color: var(--color-on-primary);
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: #ffebee; background: var(--color-danger-bg);
border: 1px solid #ffcdd2; border: 1px solid var(--color-danger-border);
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