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

View File

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

View File

@ -10,8 +10,10 @@ const connectDB = async () => {
});
logger.info('MongoDB verbunden');
} catch (error) {
// Nicht process.exit(): der Aufrufer (server.js) implementiert einen Retry.
// Ein Exit hier hat den Retry zu totem Code gemacht.
logger.error('MongoDB Verbindungsfehler:', error.message);
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)',
geocodeMinDelayMs: parseInt(process.env.GEOCODE_MIN_DELAY_MS || '1100', 10),
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
@ -46,11 +51,21 @@ if (config.nodeEnv === 'production') {
}
});
// Check for insecure defaults in production
if (config.jwtSecret === 'your-secret-key-change-in-production') {
console.error('❌ Fehler: JWT_SECRET verwendet unsicheren Default-Wert!');
// Check for insecure defaults in production.
// Nicht nur der eine Default-String: podman-compose.yml setzt z. B.
// CHANGE_ME_IN_PRODUCTION, was eine reine Gleichheitspruefung durchlaesst.
if (/change[-_ ]?me|change-in-production|your-secret|secret-key/i.test(config.jwtSecret)) {
console.error('❌ Fehler: JWT_SECRET verwendet einen Platzhalter-Wert!');
console.error(' Bitte ein zufaelliges Secret setzen, z. B. mit: openssl rand -hex 32');
process.exit(1);
}
// Ohne APP_URL wird der Link in der Passwort-Reset-Mail aus CORS_ORIGIN
// zusammengesetzt. Das funktioniert nur, solange der Unterpfad dem App-Namen
// entspricht - bei abweichendem Deployment fuehrt der Link ins Leere.
if (!process.env.APP_URL) {
console.warn(`⚠️ Warnung: APP_URL ist nicht gesetzt. Reset-Links verwenden "${config.appUrl}".`);
}
}
module.exports = config;

View File

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

View File

@ -40,7 +40,7 @@ const login = async (req, res) => {
// Generate token
const token = jwt.sign(
{ id: admin._id, username: admin.username, app: config.appName },
{ id: admin._id, username: admin.username, app: config.appName, role: 'admin' },
config.jwtSecret,
{ expiresIn: config.jwtExpiresIn }
);
@ -81,7 +81,7 @@ const logout = async (req, res) => {
try {
// Log logout (get username from token if available)
const username = req.user?.username || 'unknown';
await auditAuth(req, true, username, null);
await auditAuth(req, true, username, null, 'LOGOUT');
// Clear the token cookie
res.clearCookie('token', {
@ -119,7 +119,9 @@ const forgotPassword = async (req, res) => {
}
// 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)
if (!admin) {
@ -193,11 +195,13 @@ const resetPassword = async (req, res) => {
});
}
// Validate password length
if (newPassword.length < 6) {
// Muss zu minlength im Admin-Schema passen. Vorher stand hier 6: Passwoerter
// mit 6-11 Zeichen kamen durch und scheiterten erst an der Mongoose-
// Validierung, was als 500 "Serverfehler" beim Nutzer ankam.
if (newPassword.length < 12) {
return res.status(400).json({
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 { geocodeAddress } = require('../utils/geocode');
const logger = require('../utils/logger');
const { escapeCell } = require('../utils/csv');
const config = require('../config/env');
const ALLOWED_USER_FIELDS = ['name', 'type', 'address', 'phone', 'landline', 'email', 'available', 'gps', 'notes'];
@ -285,7 +286,9 @@ const getPublicUsers = async (req, res) => {
.sort(req.query.search ? { score: { $meta: 'textScore' } } : { name: 1 })
.skip(skip)
.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)
]);
@ -411,14 +414,6 @@ const exportUsers = async (req, res) => {
.select('-__v -passwordHash -deleted -deletedAt -deletedBy');
if (format === 'csv') {
const escapeCell = (val) => {
if (val == null) return '';
const str = String(val);
return str.includes(',') || str.includes('"') || str.includes('\n')
? `"${str.replace(/"/g, '""')}"`
: str;
};
const csv = [
['Name', 'Adresse', 'Telefon', 'Festnetz', 'E-Mail', 'Typ', 'Verfügbar', 'Latitude', 'Longitude'].join(','),
...users.map(user => [
@ -590,8 +585,10 @@ const bulkUpdateUsers = async (req, res) => {
}
// Perform bulk update
// Der pre(/^find/)-Hook des Modells greift bei updateMany nicht,
// der Soft-Delete-Filter muss hier explizit gesetzt werden.
const result = await User.updateMany(
{ _id: { $in: ids } },
{ _id: { $in: ids }, deleted: { $ne: true } },
{ $set: updateFields }
);
@ -629,8 +626,10 @@ const bulkDeleteUsers = async (req, res) => {
}
// Soft delete all users
// Bereits geloeschte Eintraege bleiben unangetastet, damit
// deletedAt/deletedBy nicht ueberschrieben werden.
const result = await User.updateMany(
{ _id: { $in: ids } },
{ _id: { $in: ids }, deleted: { $ne: true } },
{
$set: {
deleted: true,

View File

@ -141,10 +141,12 @@ const auditLog = (action, resource) => {
/**
* 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 {
await AuditLog.log({
action: isSuccess ? 'LOGIN' : 'LOGIN_FAILED',
action: action || (isSuccess ? 'LOGIN' : 'LOGIN_FAILED'),
resource: 'Admin',
adminUsername: username,
ipAddress: req.ip || req.connection?.remoteAddress,

View File

@ -1,6 +1,7 @@
const jwt = require('jsonwebtoken');
const config = require('../config/env');
// Verifiziert das Token und stellt sicher, dass es sich um ein Admin-Token handelt.
const authenticateToken = (req, res, next) => {
// Try to get token from cookie first (new secure method)
let token = req.cookies?.token;
@ -20,6 +21,7 @@ const authenticateToken = (req, res, next) => {
try {
const decoded = jwt.verify(token, config.jwtSecret);
// Reject tokens issued by a different app (C-01 cross-app auth fix)
if (decoded.app && decoded.app !== config.appName) {
return res.status(403).json({
@ -27,6 +29,20 @@ const authenticateToken = (req, res, next) => {
message: 'Ungültiger oder abgelaufener Token.'
});
}
// Rollenprüfung. Handler-Tokens werden mit demselben Secret signiert; die
// app-Prüfung oben greift bei ihnen nicht, weil sie keinen app-Claim tragen.
// Ohne diese Zeilen kann ein eingeloggter Hundeführer seinen Bearer-Token
// gegen /api/users, /api/config und /api/audit-logs schicken und hat volle
// Admin-Rechte. Tokens ohne role stammen aus der Zeit davor und wurden
// ausschließlich für Admins ausgestellt.
if (decoded.role && decoded.role !== 'admin') {
return res.status(403).json({
success: false,
message: 'Zugriff verweigert. Keine Administratorrechte.'
});
}
req.user = decoded;
next();
} catch (error) {
@ -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
const inviteLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // Max 10 attempts per windowMs
max: 10,
skipSuccessfulRequests: true,
message: {
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 = {
apiLimiter,
authLimiter,
inviteLimiter
inviteLimiter,
geocodeLimiter
};

View File

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

View File

@ -2,9 +2,12 @@ const express = require('express');
const router = express.Router();
const { login, logout, forgotPassword, resetPassword } = require('../controllers/authController');
const { validateLogin } = require('../middleware/validator');
const { attachUserIfPresent } = require('../middleware/auth');
router.post('/login', validateLogin, login);
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('/reset-password', resetPassword);

View File

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

View File

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

View File

@ -16,11 +16,17 @@ const connectWithRetry = async () => {
try {
await connectDB();
// Seed database if empty (runs in all environments on first start)
const User = require('./models/User');
const userCount = await User.countDocuments();
if (userCount === 0) {
logger.info('Datenbank ist leer, starte Seeding...');
// Seeding legt Admin-Konto und Grundkonfiguration an. Die Bedingung darf sich
// NICHT an der User-Zahl orientieren: die Seed-Liste ist bewusst leer, dadurch
// lief das Seeding bei jedem Start erneut.
const Admin = require('./models/Admin');
const Config = require('./models/Config');
const [adminCount, configCount] = await Promise.all([
Admin.countDocuments(),
Config.countDocuments()
]);
if (adminCount === 0 || configCount === 0) {
logger.info('Admin oder Konfiguration fehlt, starte Seeding...');
try {
const seed = require('./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 server = app.listen(PORT, () => {
logger.info(`Server läuft auf Port ${PORT} (${config.nodeEnv})`);
});
// Graceful shutdown on SIGTERM (Docker stop / Kubernetes rolling restart)
process.on('SIGTERM', () => {
logger.info('SIGTERM empfangen, fahre Server herunter...');
// Graceful shutdown (Docker stop / Kubernetes rolling restart)
let shuttingDown = false;
const shutdown = (signal) => {
if (shuttingDown) return;
shuttingDown = true;
logger.info(`${signal} empfangen, fahre Server herunter...`);
const forceExit = setTimeout(() => {
logger.warn('Shutdown-Timeout erreicht, beende Prozess hart');
process.exit(1);
}, 10000);
forceExit.unref();
server.close(() => {
logger.info('HTTP-Server geschlossen');
mongoose.connection.close(false).then(() => {
@ -101,9 +114,13 @@ process.on('SIGTERM', () => {
process.exit(0);
}).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 fs = require('fs');
const buildPath = path.join(__dirname, '..', 'frontend', 'build');
@ -117,3 +134,6 @@ if (fs.existsSync(buildPath)) {
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_SAVE_INTERVAL = 60000; // Save every 60 seconds
const CACHE_MAX_ENTRIES = 1000;
const cache = new Map();
let lastRequestTime = 0;
let cacheModified = false;
/**
* Einziger Schreibpfad in den Cache, inklusive Größenbegrenzung.
*
* Vorher war nur der Erfolgspfad begrenzt; die beiden Negativ-Pfade
* (Adresse nicht gefunden / unbrauchbare Koordinaten) haben ungebremst
* geschrieben. Über den öffentlichen /api/public/geocode genügten damit
* erfundene Postleitzahlen, um Speicher und Cache-Datei beliebig wachsen
* zu lassen bei --max_old_space_size=256 eine reale Grenze.
*/
const rememberInCache = (key, value) => {
// Map behält die Einfügereihenfolge: ein vorhandener Schlüssel muss neu
// eingefügt werden, damit er als "zuletzt benutzt" ans Ende rückt.
cache.delete(key);
while (cache.size >= CACHE_MAX_ENTRIES) {
cache.delete(cache.keys().next().value);
}
cache.set(key, value);
cacheModified = true;
};
// Load cache from file on startup
const loadCache = async () => {
try {
const data = await fs.readFile(CACHE_FILE, 'utf8');
const parsed = JSON.parse(data);
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);
});
logger.info(`Geocoding cache loaded: ${cache.size} entries`);
@ -45,14 +69,13 @@ const saveCache = async () => {
setInterval(saveCache, CACHE_SAVE_INTERVAL).unref();
// Save on process exit
process.on('SIGINT', async () => {
await saveCache();
process.exit(0);
});
process.on('SIGTERM', async () => {
await saveCache();
process.exit(0);
});
// Cache beim Herunterfahren sichern ohne process.exit(): das Beenden gehört
// dem Shutdown-Handler in server.js, der sonst mittendrin abgeschnitten wird.
const flushOnShutdown = () => {
saveCache().catch(err => logger.error('Failed to flush geocoding cache:', err.message));
};
process.on('SIGINT', flushOnShutdown);
process.on('SIGTERM', flushOnShutdown);
// Initialize cache loading
loadCache().catch(err => logger.error('Cache initialization error:', err));
@ -89,7 +112,12 @@ const geocodeAddress = async (address) => {
const cacheKey = normalized.toLowerCase();
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;
@ -107,8 +135,7 @@ const geocodeAddress = async (address) => {
lastRequestTime = Date.now();
if (!Array.isArray(results) || results.length === 0) {
cache.set(cacheKey, null);
cacheModified = true;
rememberInCache(cacheKey, null);
return null;
}
@ -117,15 +144,12 @@ const geocodeAddress = async (address) => {
const lng = parseFloat(hit.lon);
if (Number.isNaN(lat) || Number.isNaN(lng)) {
cache.set(cacheKey, null);
cacheModified = true;
rememberInCache(cacheKey, null);
return null;
}
const coords = { lat, lng };
if (cache.size >= 1000) { cache.delete(cache.keys().next().value); }
cache.set(cacheKey, coords);
cacheModified = true;
rememberInCache(cacheKey, coords);
return coords;
} catch (error) {
logger.warn('Geocoding fehlgeschlagen', {

View File

@ -45,7 +45,8 @@ services:
# - SMTP_USER=user@example.com
# - SMTP_PASS=${SMTP_PASS}
# - 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:
mongo:
condition: service_healthy
@ -61,7 +62,7 @@ services:
context: ./frontend
args:
- PUBLIC_URL=/drohnenfuehrer/
- REACT_APP_API_URL=${REACT_APP_API_URL:-}
- VITE_API_URL=${VITE_API_URL:-}
container_name: drohnenfuehrer-frontend
restart: unless-stopped
ports:

View File

@ -183,4 +183,4 @@ frontend/src/
- `CORS_ORIGIN`: Erlaubter CORS-Origin
### Frontend (.env)
- `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
# For local development
REACT_APP_API_URL=http://localhost:5000
VITE_API_URL=http://localhost:5000
# 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)
# REACT_APP_ADMIN_PATH=/verwaltung
# VITE_ADMIN_PATH=/verwaltung

View File

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

View File

@ -1,16 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<html lang="de">
<head>
<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" />
<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-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Drohnenführer" />
<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
name="description"
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_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-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Cache static assets (images, fonts)
location ~* \.(jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$ {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
expires 1y;
add_header Cache-Control "public, immutable";
}
# JS and CSS - no compression, short cache
location ~* \.(js|css)$ {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
expires 1h;
add_header Cache-Control "public, no-transform";
gzip off;
@ -47,6 +56,9 @@ server {
# Service Worker - never cache
location = /sw.js {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Cache-Control "no-cache, no-store, must-revalidate";
expires 0;
}
@ -79,6 +91,9 @@ server {
# Service Worker for subpath deployment
location = /drohnenfuehrer/sw.js {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Cache-Control "no-cache, no-store, must-revalidate";
expires 0;
alias /usr/share/nginx/html/sw.js;
@ -86,6 +101,9 @@ server {
# index.html - never cache so new builds are picked up immediately
location = /index.html {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
expires 0;
@ -93,6 +111,9 @@ server {
# SPA fallback for subpath deployment (/drohnenfuehrer)
location ^~ /drohnenfuehrer/ {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
rewrite ^/drohnenfuehrer(/.*)$ $1 break;
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
@ -102,6 +123,9 @@ server {
# SPA fallback - serve index.html for all routes
location / {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
@ -110,6 +134,9 @@ server {
# Health check endpoint
location /health {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
access_log off;
return 200 "OK\n";
add_header Content-Type text/plain;

File diff suppressed because it is too large Load Diff

View File

@ -3,28 +3,16 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.3.0",
"@testing-library/user-event": "^13.5.0",
"axios": "^1.13.5",
"leaflet": "^1.9.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-leaflet": "^4.2.1",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
"react-leaflet": "^4.2.1"
},
"scripts": {
"start": "react-scripts start",
"start": "vite",
"build": "vite build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
"preview": "vite preview"
},
"browserslist": {
"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",
"name": "Drohnenführer Heidekreis",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "icons/icon-192.png",
"sizes": "192x192",
@ -24,7 +19,7 @@
"scope": "/drohnenfuehrer/",
"display": "standalone",
"orientation": "portrait",
"theme_color": "#2d6a2d",
"background_color": "#ffffff",
"theme_color": "#1a3d1a",
"background_color": "#e8e8e2",
"description": "Drohnenführer Heidekreis Übersicht der Drohnenführer"
}

View File

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

View File

@ -2,43 +2,203 @@
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 {
--color-primary: #2e8b2e;
--color-primary-dark: #1e6b1e;
--color-primary-accent: #8B0D12;
--color-secondary: #6c757d;
--color-secondary-dark: #5a6268;
--color-success: #28a745;
--color-success-dark: #218838;
--color-danger: #dc3545;
--color-danger-dark: #c82333;
--color-bg: #f5f5f0;
--color-surface: #ffffff;
--font-display: Georgia, 'Times New Roman', serif;
--font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
--font-mono: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
/* Flächen */
--color-bg: #e8e8e2;
--color-surface: #f4f4ee;
--color-surface-alt: #dedcd2;
--color-muted-bg: #dedcd2;
/* Text */
--color-text: #1a1a1a;
--color-text-muted: #555;
--color-border: #b8d4b8;
--color-border-strong: #88b888;
--color-focus: rgba(46, 139, 46, 0.2);
--color-muted-bg: #f5f5f0;
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.2);
--radius-sm: 4px;
--radius-md: 8px;
--radius-pill: 12px;
--color-text-muted: #55554c;
/* Rahmen: -border ist dekorativ (Trennlinien, Karten),
-border-strong begrenzt Bedienelemente und erfüllt die 3:1-Anforderung. */
--color-border: #c9c9b8;
--color-border-strong: #7d7d68;
/* Aktionsfarben jeweils mit der zugehörigen Textfarbe, damit die
Dunkelvariante nicht auf weißem Text auf hellem Grund landet. */
--color-primary: #2d5a2d;
--color-primary-dark: #1a3d1a;
--color-on-primary: #ffffff;
--color-secondary: #5c5c50;
--color-secondary-dark: #46463c;
--color-on-secondary: #ffffff;
--color-success: #1f6b34;
--color-success-dark: #175128;
--color-on-success: #ffffff;
--color-danger: #a32020;
--color-danger-dark: #821919;
--color-on-danger: #ffffff;
--color-warning: #7a5200;
--color-on-warning: #ffffff;
--color-accent: #8b0d12;
--color-on-accent: #ffffff;
/* Altname, wird von RulesDisplay noch benutzt */
--color-primary-accent: #8b0d12;
/* Getönte Status-Flächen (Meldungen, Badges, Zustands-Karten).
Der Block wird durch seine Füllung erkannt, der Rahmen ist Zierde. */
--color-success-bg: #dfeedd;
--color-success-border: #a9cba4;
--color-success-text: #1a4a24;
--color-danger-bg: #f6e0e0;
--color-danger-border: #d9a9a9;
--color-danger-text: #7d1a1a;
--color-warning-bg: #f7edd4;
--color-warning-border: #d9c48a;
--color-warning-text: #5c3d00;
--color-info: #24608f;
--color-info-dark: #1b4a6e;
--color-on-info: #ffffff;
--color-info-bg: #dde8f1;
--color-info-border: #a5bfd4;
--color-info-text: #14405f;
/* Als RGB-Tripel fuer rgba()-Anwendungen (Puls-Animation im Admin-Panel) */
--color-primary-rgb: 45, 90, 45;
--color-focus: rgba(45, 90, 45, 0.35);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.15);
--shadow-md: 0 2px 6px rgba(0, 0, 0, 0.2);
/* Kantige Ecken wie im Portal */
--radius-sm: 2px;
--radius-md: 2px;
--radius-pill: 2px;
--space-1: 0.5rem;
--space-2: 1rem;
--space-3: 1.5rem;
--space-4: 2rem;
/* Mindestgröße für Bedienelemente auf Touchgeräten */
--touch-target: 44px;
}
/* Nachtvariante. Nachsuchen laufen in der Dämmerung und nachts ein weißes
Vollbild blendet dann und kostet die Dunkeladaption der Augen. Warme, sehr
dunkle Grüntöne statt reinem Schwarz. */
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #14180f;
--color-surface: #1e241a;
--color-surface-alt: #2a3124;
--color-muted-bg: #2a3124;
--color-text: #e9e7dd;
--color-text-muted: #a6a698;
--color-border: #333b28;
--color-border-strong: #758566;
--color-primary: #7fb36f;
--color-primary-dark: #9ccb8c;
--color-on-primary: #10140c;
--color-secondary: #8d8d80;
--color-secondary-dark: #a3a396;
--color-on-secondary: #10140c;
--color-success: #79c48c;
--color-success-dark: #93d3a3;
--color-on-success: #10140c;
--color-danger: #ea8b8b;
--color-danger-dark: #f2a5a5;
--color-on-danger: #10140c;
--color-warning: #d6b25f;
--color-on-warning: #10140c;
--color-accent: #e88a8f;
--color-on-accent: #10140c;
--color-primary-accent: #e88a8f;
--color-success-bg: #1d2c20;
--color-success-border: #3d5c43;
--color-success-text: #93d3a3;
--color-danger-bg: #33201f;
--color-danger-border: #6b4040;
--color-danger-text: #f2a5a5;
--color-warning-bg: #322a15;
--color-warning-border: #63552c;
--color-warning-text: #e0c37c;
--color-info: #7fb0dc;
--color-info-dark: #9cc4e8;
--color-on-info: #10140c;
--color-info-bg: #1a2530;
--color-info-border: #3d5468;
--color-info-text: #9cc4e8;
--color-primary-rgb: 127, 179, 111;
--color-focus: rgba(127, 179, 111, 0.45);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.5);
--shadow-md: 0 2px 6px rgba(0, 0, 0, 0.6);
}
}
body {
margin: 0;
font-family: var(--font-ui);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: var(--color-bg);
color: var(--color-text);
/* Damit auch vom Browser gestellte Bedienelemente (Bildlaufleisten,
Datumsauswahl, Autofill) der gewählten Ansicht folgen. */
color-scheme: light dark;
}
/* Formularfelder brauchen ausdrücklich Farben: ohne sie nimmt der Browser
seinen Standard (weiß) in der Nachtansicht leuchtet dann jedes Eingabefeld. */
input:not([type='checkbox']):not([type='radio']):not([type='range']):not([type='file']),
select,
textarea {
background: var(--color-surface);
color: var(--color-text);
}
input::placeholder,
textarea::placeholder {
color: var(--color-text-muted);
opacity: 1;
}
h1, h2, h3 {
font-family: var(--font-display);
}
code {
font-family: var(--font-mono);
}
/* ── Bedienelemente ─────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-1);
min-height: var(--touch-target);
padding: 0.5rem 1rem;
border: none;
border-radius: var(--radius-sm);
font-family: var(--font-ui);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
@ -46,15 +206,24 @@
}
.btn:disabled {
background: var(--color-border);
background: var(--color-surface-alt);
color: var(--color-text-muted);
cursor: not-allowed;
box-shadow: none;
}
/* Sichtbarer Tastaturfokus. Ohne das war die Tastaturbedienung unsichtbar
Buttons hatten nur einen :hover-Stil. */
.btn:focus-visible,
.nav-button:focus-visible,
a:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.btn-primary {
background: var(--color-primary);
color: white;
color: var(--color-on-primary);
}
.btn-primary:hover:not(:disabled) {
@ -64,7 +233,7 @@
.btn-secondary {
background: var(--color-secondary);
color: white;
color: var(--color-on-secondary);
}
.btn-secondary:hover:not(:disabled) {
@ -72,19 +241,9 @@
transform: translateY(-1px);
}
.btn-success {
background: var(--color-success);
color: white;
}
.btn-success:hover:not(:disabled) {
background: var(--color-success-dark);
transform: translateY(-1px);
}
.btn-danger {
background: var(--color-danger);
color: white;
color: var(--color-on-danger);
}
.btn-danger:hover:not(:disabled) {
@ -96,9 +255,13 @@
.select,
.textarea {
width: 100%;
min-height: var(--touch-target);
padding: 0.75rem 1rem;
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-sm);
font-family: var(--font-ui);
font-size: 1rem;
box-sizing: border-box;
}
@ -111,33 +274,19 @@
box-shadow: 0 0 0 3px var(--color-focus);
}
.panel {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-3);
box-shadow: var(--shadow-sm);
}
.panel-title {
margin: 0 0 var(--space-2);
font-family: var(--font-display);
font-size: 1.1rem;
color: var(--color-text);
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: var(--color-bg);
color: var(--color-text);
}
/* ── Grundgerüst ────────────────────────────────────────────────────────── */
.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;
flex-direction: column;
}
@ -151,44 +300,26 @@ body {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
min-height: 100dvh;
font-size: 1.2rem;
color: var(--color-text-muted);
}
.login-prompt {
position: fixed;
bottom: 20px;
right: 20px;
}
.login-button-header {
padding: 0.75rem 1.5rem;
background: var(--color-primary);
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
transition: all 0.2s;
}
.login-button-header:hover {
background: var(--color-primary-dark);
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3);
}
@media (max-width: 768px) {
.login-prompt {
bottom: 10px;
right: 10px;
/* Wer Bewegung reduziert haben möchte, bekommt keine Animationen. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
.login-button-header {
padding: 0.6rem 1.2rem;
font-size: 0.9rem;
.btn:hover:not(:disabled),
.btn-primary:hover:not(:disabled),
.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 DrohnenfuehrerDashboard from './components/drohnenfuehrer/DrohnenfuehrerDashboard';
import InstallBanner from './components/common/InstallBanner';
import { ADMIN_PATH, RESET_PASSWORD_PATH, withBase, normalizePath } from './utils/constants';
import './App.css';
function App() {
const adminPath = process.env.REACT_APP_ADMIN_PATH || '/verwaltung';
const resetPasswordPath = '/passwort-zuruecksetzen';
const isAdminRoute = window.location.pathname === adminPath;
const isResetPasswordRoute = window.location.pathname === resetPasswordPath;
// Die Pfade muessen den Deployment-Basispfad enthalten: produktiv laeuft die
// App unter /<app>/, ein Vergleich gegen '/verwaltung' traefe dort nie zu.
const currentPath = normalizePath(window.location.pathname);
const isAdminRoute = currentPath === normalizePath(withBase(ADMIN_PATH));
const isResetPasswordRoute = currentPath === normalizePath(withBase(RESET_PASSWORD_PATH));
const [view, setView] = useState('public');
const [drohnenfuehrerUser, setDrohnenfuehrerUser] = useState(null);
const { isAuthenticated, loading: authLoading, login, logout } = useAuth();
@ -97,7 +99,6 @@ function App() {
isAdmin={false}
currentView={view}
onViewChange={handleViewChange}
onDrohnenfuehrerLogin={handleDrohnenfuehrerLogout}
/>
<main className="app-main">
{isAdminRoute || view === 'login' ? (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import React, { useState, useMemo, useEffect, useCallback } from 'react';
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { useConfigContext } from '../../contexts/ConfigContext';
import MapView from '../map/MapView';
import FilterPanel from '../users/FilterPanel';
@ -6,18 +6,32 @@ import { calculateDistance } from '../../utils/helpers';
import { getGeocodeByPostalCode } from '../../services/users';
import './PublicUserList.css';
const LOCATION_KEY = 'userLocation';
// localStorage kann werfen (privater Modus, blockierte Site-Data) und der Inhalt
// kann beschaedigt sein. Beides darf die Seite nicht mitreissen.
const readStoredLocation = () => {
try {
const raw = localStorage.getItem(LOCATION_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
return (typeof parsed?.lat === 'number' && typeof parsed?.lng === 'number') ? parsed : null;
} catch {
return null;
}
};
const PublicUserList = ({ users, loading, onRefetch }) => {
const { userTypeLabels } = useConfigContext();
// Load saved location from localStorage on mount
const savedLocation = localStorage.getItem('userLocation');
const initialCoords = savedLocation ? JSON.parse(savedLocation) : null;
const [showMap, setShowMap] = useState(true);
const [showFilters, setShowFilters] = useState(false);
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 [coords, setCoords] = useState(initialCoords);
const [radiusKm, setRadiusKm] = useState(100);
const [postalCode, setPostalCode] = useState('');
const [postalSearching, setPostalSearching] = useState(false);
@ -28,8 +42,11 @@ const PublicUserList = ({ users, loading, onRefetch }) => {
// Save location to localStorage whenever it changes
useEffect(() => {
if (coords) {
localStorage.setItem('userLocation', JSON.stringify(coords));
if (!coords) return;
try {
localStorage.setItem(LOCATION_KEY, JSON.stringify(coords));
} catch {
// Speicher nicht verfuegbar - der Standort gilt dann nur fuer diese Sitzung.
}
}, [coords]);
@ -37,8 +54,15 @@ const PublicUserList = ({ users, loading, onRefetch }) => {
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(() => {
if (skipInitialRefetch.current) {
skipInitialRefetch.current = false;
return;
}
if (onRefetch) {
onRefetch(filters.type ? { type: filters.type } : {});
}
@ -267,7 +291,7 @@ const PublicUserList = ({ users, loading, onRefetch }) => {
<div className="user-info">
<p className="user-address">📍 {user.address}</p>
<a href={`tel:${user.phone}`} className="user-phone">
<EFBFBD> {user.phone}
📱 {user.phone}
</a>
{user.landline && (
<a href={`tel:${user.landline}`} className="user-phone">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,10 +10,14 @@ root.render(
</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) {
const base = import.meta.env.BASE_URL || '/';
window.addEventListener('load', () => {
navigator.serviceWorker.register('./sw.js')
navigator.serviceWorker.register(`${base}sw.js`, { scope: base })
.then(registration => {
console.log('SW registered: ', registration);
})

View File

@ -38,8 +38,14 @@ api.interceptors.response.use(
return Promise.reject(error);
}
// Ohne Request-Config (z. B. Fehler schon beim Aufbau der Anfrage) gibt es
// nichts zu wiederholen vorher lief das in einen TypeError auf undefined.
if (!config) {
return Promise.reject(error);
}
// Retry logic for network errors or 5xx errors
if (!config || !config.retry) {
if (config.retry === undefined) {
config.retry = 2; // Default: 2 retries
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) => {
try {
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 = () => {
if (typeof window === 'undefined') return '';
const path = window.location.pathname || '';
if (path === '/drohnenfuehrer' || path.startsWith('/drohnenfuehrer/')) {
return '/drohnenfuehrer';
}
return '';
const knownBasePaths = ['/nachsuche', '/drohnenfuehrer', '/stoeberhunde'];
const match = knownBasePaths.find(basePath => path === basePath || path.startsWith(`${basePath}/`));
return match || '';
};
const configuredApiBaseUrl = process.env.REACT_APP_API_URL;
export const API_BASE_URL = (typeof configuredApiBaseUrl === 'string' && configuredApiBaseUrl.trim().length > 0)
? configuredApiBaseUrl
: detectRuntimeBasePath();
const stripTrailingSlash = (value) => String(value || '').replace(/\/+$/, '');
export const USER_TYPES = {
DF: 'DF',
WK: 'WK',
RGB: 'RGB'
};
// Basis-Pfad des Deployments ohne abschließenden Slash: '/nachsuche' bzw. '' im Root.
export const BASE_PATH = stripTrailingSlash(env.BASE_URL) || detectRuntimeBasePath();
export const USER_TYPE_LABELS = {
[USER_TYPES.DF]: 'Drohnenführer',
[USER_TYPES.WK]: 'Wärmebildkamera',
[USER_TYPES.RGB]: 'RGB-Kamera'
};
// Vollständige API-Basis. VITE_API_URL überschreibt (z. B. eigene API-Domain).
export const API_BASE_URL = (typeof env.VITE_API_URL === 'string' && env.VITE_API_URL.trim())
? stripTrailingSlash(env.VITE_API_URL.trim())
: BASE_PATH;
export const RULES = [
"Drohnenflug nur mit gültigem Drohnenführerschein (A1/A3 oder A2).",
"Informieren Sie den zuständigen Revierinhaber vor jedem Einsatz.",
"Halten Sie die Datenschutzbestimmungen beim Einsatz von Wärmebildkameras ein.",
"Geben Sie keine Aufnahmen ohne Zustimmung des Revierinhabers weiter.",
"Melden Sie Ihren Einsatz unverzüglich an die koordinierende Stelle."
];
export const ADMIN_PATH = env.VITE_ADMIN_PATH || '/verwaltung';
export const RESET_PASSWORD_PATH = '/passwort-zuruecksetzen';
// Hängt den Deployment-Basispfad vor einen App-Pfad: '/nachsuche/verwaltung'.
export const withBase = (appPath) =>
`${BASE_PATH}${appPath.startsWith('/') ? appPath : `/${appPath}`}`;
// Vergleichbare Normalform eines Pfads (ohne abschließenden Slash).
export const normalizePath = (pathname) => stripTrailingSlash(pathname) || '/';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -40,7 +40,7 @@ const login = async (req, res) => {
// Generate token
const token = jwt.sign(
{ id: admin._id, username: admin.username, app: config.appName },
{ id: admin._id, username: admin.username, app: config.appName, role: 'admin' },
config.jwtSecret,
{ expiresIn: config.jwtExpiresIn }
);
@ -81,7 +81,7 @@ const logout = async (req, res) => {
try {
// Log logout (get username from token if available)
const username = req.user?.username || 'unknown';
await auditAuth(req, true, username, null);
await auditAuth(req, true, username, null, 'LOGOUT');
const secureCookie = config.nodeEnv === 'production'
? (req.secure || req.headers['x-forwarded-proto'] === 'https')
@ -123,7 +123,9 @@ const forgotPassword = async (req, res) => {
}
// 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)
if (!admin) {
@ -200,11 +202,13 @@ const resetPassword = async (req, res) => {
});
}
// Validate password length
if (newPassword.length < 6) {
// Muss zu minlength im Admin-Schema passen. Vorher stand hier 6: Passwörter
// mit 611 Zeichen kamen durch und scheiterten erst an der Mongoose-
// Validierung, was als 500 "Serverfehler" beim Nutzer ankam.
if (newPassword.length < 12) {
return res.status(400).json({
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 { geocodeAddress } = require('../utils/geocode');
const logger = require('../utils/logger');
const { escapeCell } = require('../utils/csv');
const config = require('../config/env');
const ALLOWED_USER_FIELDS = ['name', 'type', 'address', 'phone', 'landline', 'email', 'available', 'gps', 'notes'];
@ -288,7 +289,9 @@ const getPublicUsers = async (req, res) => {
.sort(req.query.search ? { score: { $meta: 'textScore' } } : { name: 1 })
.skip(skip)
.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)
]);
@ -414,14 +417,6 @@ const exportUsers = async (req, res) => {
.select('-__v -passwordHash -deleted -deletedAt -deletedBy');
if (format === 'csv') {
const escapeCell = (val) => {
if (val == null) return '';
const str = String(val);
return str.includes(',') || str.includes('"') || str.includes('\n')
? `"${str.replace(/"/g, '""')}"`
: str;
};
const csv = [
['Name', 'Adresse', 'Telefon', 'Festnetz', 'E-Mail', 'Typ', 'Verfügbar', 'Latitude', 'Longitude'].join(','),
...users.map(user => [
@ -593,8 +588,10 @@ const bulkUpdateUsers = async (req, res) => {
}
// Perform bulk update
// Der pre(/^find/)-Hook des Modells greift bei updateMany nicht,
// der Soft-Delete-Filter muss hier explizit gesetzt werden.
const result = await User.updateMany(
{ _id: { $in: ids } },
{ _id: { $in: ids }, deleted: { $ne: true } },
{ $set: updateFields }
);
@ -632,8 +629,10 @@ const bulkDeleteUsers = async (req, res) => {
}
// Soft delete all users
// Bereits geloeschte Eintraege bleiben unangetastet, damit
// deletedAt/deletedBy nicht ueberschrieben werden.
const result = await User.updateMany(
{ _id: { $in: ids } },
{ _id: { $in: ids }, deleted: { $ne: true } },
{
$set: {
deleted: true,

View File

@ -141,10 +141,12 @@ const auditLog = (action, resource) => {
/**
* 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 {
await AuditLog.log({
action: isSuccess ? 'LOGIN' : 'LOGIN_FAILED',
action: action || (isSuccess ? 'LOGIN' : 'LOGIN_FAILED'),
resource: 'Admin',
adminUsername: username,
ipAddress: req.ip || req.connection?.remoteAddress,

View File

@ -1,6 +1,7 @@
const jwt = require('jsonwebtoken');
const config = require('../config/env');
// Verifiziert das Token und stellt sicher, dass es sich um ein Admin-Token handelt.
const authenticateToken = (req, res, next) => {
// Try to get token from cookie first (new secure method)
let token = req.cookies?.token;
@ -20,6 +21,7 @@ const authenticateToken = (req, res, next) => {
try {
const decoded = jwt.verify(token, config.jwtSecret);
// Reject tokens issued by a different app (C-01 cross-app auth fix)
if (decoded.app && decoded.app !== config.appName) {
return res.status(403).json({
@ -27,6 +29,20 @@ const authenticateToken = (req, res, next) => {
message: 'Ungültiger oder abgelaufener Token.'
});
}
// Rollenprüfung. Handler-Tokens werden mit demselben Secret signiert; die
// app-Prüfung oben greift bei ihnen nicht, weil sie keinen app-Claim tragen.
// Ohne diese Zeilen kann ein eingeloggter Hundeführer seinen Bearer-Token
// gegen /api/users, /api/config und /api/audit-logs schicken und hat volle
// Admin-Rechte. Tokens ohne role stammen aus der Zeit davor und wurden
// ausschließlich für Admins ausgestellt.
if (decoded.role && decoded.role !== 'admin') {
return res.status(403).json({
success: false,
message: 'Zugriff verweigert. Keine Administratorrechte.'
});
}
req.user = decoded;
next();
} catch (error) {
@ -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 = {
apiLimiter,
authLimiter,
inviteLimiter
inviteLimiter,
geocodeLimiter
};

View File

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

View File

@ -2,9 +2,12 @@ const express = require('express');
const router = express.Router();
const { login, logout, forgotPassword, resetPassword } = require('../controllers/authController');
const { validateLogin } = require('../middleware/validator');
const { attachUserIfPresent } = require('../middleware/auth');
router.post('/login', validateLogin, login);
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('/reset-password', resetPassword);

View File

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

View File

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

View File

@ -16,11 +16,17 @@ const connectWithRetry = async () => {
try {
await connectDB();
// Seed database if empty (runs in all environments on first start)
const User = require('./models/User');
const userCount = await User.countDocuments();
if (userCount === 0) {
logger.info('Datenbank ist leer, starte Seeding...');
// Seeding legt Admin-Konto und Grundkonfiguration an. Die Bedingung darf sich
// NICHT an der User-Zahl orientieren: die Seed-Liste ist bewusst leer, dadurch
// lief das Seeding bei jedem Start erneut.
const Admin = require('./models/Admin');
const Config = require('./models/Config');
const [adminCount, configCount] = await Promise.all([
Admin.countDocuments(),
Config.countDocuments()
]);
if (adminCount === 0 || configCount === 0) {
logger.info('Admin oder Konfiguration fehlt, starte Seeding...');
try {
const seed = require('./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 server = app.listen(PORT, () => {
logger.info(`Server läuft auf Port ${PORT} (${config.nodeEnv})`);
});
// Graceful shutdown on SIGTERM (Docker stop / Kubernetes rolling restart)
process.on('SIGTERM', () => {
logger.info('SIGTERM empfangen, fahre Server herunter...');
// Graceful shutdown (Docker stop / Kubernetes rolling restart)
let shuttingDown = false;
const shutdown = (signal) => {
if (shuttingDown) return;
shuttingDown = true;
logger.info(`${signal} empfangen, fahre Server herunter...`);
const forceExit = setTimeout(() => {
logger.warn('Shutdown-Timeout erreicht, beende Prozess hart');
process.exit(1);
}, 10000);
forceExit.unref();
server.close(() => {
logger.info('HTTP-Server geschlossen');
mongoose.connection.close(false).then(() => {
@ -101,9 +114,13 @@ process.on('SIGTERM', () => {
process.exit(0);
}).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 fs = require('fs');
const buildPath = path.join(__dirname, '..', 'frontend', 'build');
@ -117,3 +134,6 @@ if (fs.existsSync(buildPath)) {
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_SAVE_INTERVAL = 60000; // Save every 60 seconds
const CACHE_MAX_ENTRIES = 1000;
const cache = new Map();
let lastRequestTime = 0;
let cacheModified = false;
/**
* Einziger Schreibpfad in den Cache, inklusive Größenbegrenzung.
*
* Vorher war nur der Erfolgspfad begrenzt; die beiden Negativ-Pfade
* (Adresse nicht gefunden / unbrauchbare Koordinaten) haben ungebremst
* geschrieben. Über den öffentlichen /api/public/geocode genügten damit
* erfundene Postleitzahlen, um Speicher und Cache-Datei beliebig wachsen
* zu lassen bei --max_old_space_size=256 eine reale Grenze.
*/
const rememberInCache = (key, value) => {
// Map behält die Einfügereihenfolge: ein vorhandener Schlüssel muss neu
// eingefügt werden, damit er als "zuletzt benutzt" ans Ende rückt.
cache.delete(key);
while (cache.size >= CACHE_MAX_ENTRIES) {
cache.delete(cache.keys().next().value);
}
cache.set(key, value);
cacheModified = true;
};
// Load cache from file on startup
const loadCache = async () => {
try {
const data = await fs.readFile(CACHE_FILE, 'utf8');
const parsed = JSON.parse(data);
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);
});
logger.info(`Geocoding cache loaded: ${cache.size} entries`);
@ -45,14 +69,13 @@ const saveCache = async () => {
setInterval(saveCache, CACHE_SAVE_INTERVAL).unref();
// Save on process exit
process.on('SIGINT', async () => {
await saveCache();
process.exit(0);
});
process.on('SIGTERM', async () => {
await saveCache();
process.exit(0);
});
// Cache beim Herunterfahren sichern ohne process.exit(): das Beenden gehört
// dem Shutdown-Handler in server.js, der sonst mittendrin abgeschnitten wird.
const flushOnShutdown = () => {
saveCache().catch(err => logger.error('Failed to flush geocoding cache:', err.message));
};
process.on('SIGINT', flushOnShutdown);
process.on('SIGTERM', flushOnShutdown);
// Initialize cache loading
loadCache().catch(err => logger.error('Cache initialization error:', err));
@ -89,7 +112,12 @@ const geocodeAddress = async (address) => {
const cacheKey = normalized.toLowerCase();
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;
@ -107,8 +135,7 @@ const geocodeAddress = async (address) => {
lastRequestTime = Date.now();
if (!Array.isArray(results) || results.length === 0) {
cache.set(cacheKey, null);
cacheModified = true;
rememberInCache(cacheKey, null);
return null;
}
@ -117,15 +144,12 @@ const geocodeAddress = async (address) => {
const lng = parseFloat(hit.lon);
if (Number.isNaN(lat) || Number.isNaN(lng)) {
cache.set(cacheKey, null);
cacheModified = true;
rememberInCache(cacheKey, null);
return null;
}
const coords = { lat, lng };
if (cache.size >= 1000) { cache.delete(cache.keys().next().value); }
cache.set(cacheKey, coords);
cacheModified = true;
rememberInCache(cacheKey, coords);
return coords;
} catch (error) {
logger.warn('Geocoding fehlgeschlagen', {

View File

@ -47,7 +47,8 @@ services:
# - SMTP_USER=user@example.com
# - SMTP_PASS=${SMTP_PASS}
# - 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:
mongo:
condition: service_healthy
@ -63,7 +64,7 @@ services:
context: ./frontend
args:
- PUBLIC_URL=/nachsuche/
- REACT_APP_API_URL=${REACT_APP_API_URL:-}
- VITE_API_URL=${VITE_API_URL:-}
container_name: nachsuche-frontend
restart: unless-stopped
ports:

View File

@ -183,4 +183,4 @@ frontend/src/
- `CORS_ORIGIN`: Erlaubter CORS-Origin
### Frontend (.env)
- `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
# For local development
REACT_APP_API_URL=http://localhost:5000
VITE_API_URL=http://localhost:5000
# 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)
# REACT_APP_ADMIN_PATH=/verwaltung
# VITE_ADMIN_PATH=/verwaltung

View File

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

View File

@ -1,16 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<html lang="de">
<head>
<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" />
<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-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="/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
name="description"
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_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-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Cache static assets (images, fonts)
location ~* \.(jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$ {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
expires 1y;
add_header Cache-Control "public, immutable";
}
# JS and CSS - no compression, short cache
location ~* \.(js|css)$ {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
expires 1h;
add_header Cache-Control "public, no-transform";
gzip off;
@ -47,6 +56,9 @@ server {
# Service Worker - never cache
location = /sw.js {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Cache-Control "no-cache, no-store, must-revalidate";
expires 0;
}
@ -79,6 +91,9 @@ server {
# Service Worker for subpath deployment
location = /nachsuche/sw.js {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Cache-Control "no-cache, no-store, must-revalidate";
expires 0;
alias /usr/share/nginx/html/sw.js;
@ -86,6 +101,9 @@ server {
# index.html - never cache so new builds are picked up immediately
location = /index.html {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
expires 0;
@ -93,6 +111,9 @@ server {
# SPA fallback for subpath deployment (/nachsuche)
location ^~ /nachsuche/ {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
rewrite ^/nachsuche(/.*)$ $1 break;
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
@ -102,6 +123,9 @@ server {
# SPA fallback - serve index.html for all routes
location / {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
@ -110,6 +134,9 @@ server {
# Health check endpoint
location /health {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
access_log off;
return 200 "OK\n";
add_header Content-Type text/plain;

File diff suppressed because it is too large Load Diff

View File

@ -3,28 +3,16 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.3.0",
"@testing-library/user-event": "^13.5.0",
"axios": "^1.13.5",
"leaflet": "^1.9.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-leaflet": "^4.2.1",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
"react-leaflet": "^4.2.1"
},
"scripts": {
"start": "react-scripts start",
"start": "vite",
"build": "vite build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
"preview": "vite preview"
},
"browserslist": {
"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",
"name": "Nachsuchenstation Heidekreis",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "icons/icon-192.png",
"sizes": "192x192",
@ -24,7 +19,7 @@
"scope": "/nachsuche/",
"display": "standalone",
"orientation": "portrait",
"theme_color": "#2d6a2d",
"background_color": "#ffffff",
"theme_color": "#1a3d1a",
"background_color": "#e8e8e2",
"description": "Nachsuchenstation Heidekreis Übersicht der Nachsuchenführer"
}

View File

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

View File

@ -2,43 +2,203 @@
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 {
--color-primary: #2e8b2e;
--color-primary-dark: #1e6b1e;
--color-primary-accent: #8B0D12;
--color-secondary: #6c757d;
--color-secondary-dark: #5a6268;
--color-success: #28a745;
--color-success-dark: #218838;
--color-danger: #dc3545;
--color-danger-dark: #c82333;
--color-bg: #f5f5f0;
--color-surface: #ffffff;
--font-display: Georgia, 'Times New Roman', serif;
--font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
--font-mono: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
/* Flächen */
--color-bg: #e8e8e2;
--color-surface: #f4f4ee;
--color-surface-alt: #dedcd2;
--color-muted-bg: #dedcd2;
/* Text */
--color-text: #1a1a1a;
--color-text-muted: #555;
--color-border: #b8d4b8;
--color-border-strong: #88b888;
--color-focus: rgba(46, 139, 46, 0.2);
--color-muted-bg: #f5f5f0;
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.2);
--radius-sm: 4px;
--radius-md: 8px;
--radius-pill: 12px;
--color-text-muted: #55554c;
/* Rahmen: -border ist dekorativ (Trennlinien, Karten),
-border-strong begrenzt Bedienelemente und erfüllt die 3:1-Anforderung. */
--color-border: #c9c9b8;
--color-border-strong: #7d7d68;
/* Aktionsfarben jeweils mit der zugehörigen Textfarbe, damit die
Dunkelvariante nicht auf weißem Text auf hellem Grund landet. */
--color-primary: #2d5a2d;
--color-primary-dark: #1a3d1a;
--color-on-primary: #ffffff;
--color-secondary: #5c5c50;
--color-secondary-dark: #46463c;
--color-on-secondary: #ffffff;
--color-success: #1f6b34;
--color-success-dark: #175128;
--color-on-success: #ffffff;
--color-danger: #a32020;
--color-danger-dark: #821919;
--color-on-danger: #ffffff;
--color-warning: #7a5200;
--color-on-warning: #ffffff;
--color-accent: #8b0d12;
--color-on-accent: #ffffff;
/* Altname, wird von RulesDisplay noch benutzt */
--color-primary-accent: #8b0d12;
/* Getönte Status-Flächen (Meldungen, Badges, Zustands-Karten).
Der Block wird durch seine Füllung erkannt, der Rahmen ist Zierde. */
--color-success-bg: #dfeedd;
--color-success-border: #a9cba4;
--color-success-text: #1a4a24;
--color-danger-bg: #f6e0e0;
--color-danger-border: #d9a9a9;
--color-danger-text: #7d1a1a;
--color-warning-bg: #f7edd4;
--color-warning-border: #d9c48a;
--color-warning-text: #5c3d00;
--color-info: #24608f;
--color-info-dark: #1b4a6e;
--color-on-info: #ffffff;
--color-info-bg: #dde8f1;
--color-info-border: #a5bfd4;
--color-info-text: #14405f;
/* Als RGB-Tripel fuer rgba()-Anwendungen (Puls-Animation im Admin-Panel) */
--color-primary-rgb: 45, 90, 45;
--color-focus: rgba(45, 90, 45, 0.35);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.15);
--shadow-md: 0 2px 6px rgba(0, 0, 0, 0.2);
/* Kantige Ecken wie im Portal */
--radius-sm: 2px;
--radius-md: 2px;
--radius-pill: 2px;
--space-1: 0.5rem;
--space-2: 1rem;
--space-3: 1.5rem;
--space-4: 2rem;
/* Mindestgröße für Bedienelemente auf Touchgeräten */
--touch-target: 44px;
}
/* Nachtvariante. Nachsuchen laufen in der Dämmerung und nachts ein weißes
Vollbild blendet dann und kostet die Dunkeladaption der Augen. Warme, sehr
dunkle Grüntöne statt reinem Schwarz. */
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #14180f;
--color-surface: #1e241a;
--color-surface-alt: #2a3124;
--color-muted-bg: #2a3124;
--color-text: #e9e7dd;
--color-text-muted: #a6a698;
--color-border: #333b28;
--color-border-strong: #758566;
--color-primary: #7fb36f;
--color-primary-dark: #9ccb8c;
--color-on-primary: #10140c;
--color-secondary: #8d8d80;
--color-secondary-dark: #a3a396;
--color-on-secondary: #10140c;
--color-success: #79c48c;
--color-success-dark: #93d3a3;
--color-on-success: #10140c;
--color-danger: #ea8b8b;
--color-danger-dark: #f2a5a5;
--color-on-danger: #10140c;
--color-warning: #d6b25f;
--color-on-warning: #10140c;
--color-accent: #e88a8f;
--color-on-accent: #10140c;
--color-primary-accent: #e88a8f;
--color-success-bg: #1d2c20;
--color-success-border: #3d5c43;
--color-success-text: #93d3a3;
--color-danger-bg: #33201f;
--color-danger-border: #6b4040;
--color-danger-text: #f2a5a5;
--color-warning-bg: #322a15;
--color-warning-border: #63552c;
--color-warning-text: #e0c37c;
--color-info: #7fb0dc;
--color-info-dark: #9cc4e8;
--color-on-info: #10140c;
--color-info-bg: #1a2530;
--color-info-border: #3d5468;
--color-info-text: #9cc4e8;
--color-primary-rgb: 127, 179, 111;
--color-focus: rgba(127, 179, 111, 0.45);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.5);
--shadow-md: 0 2px 6px rgba(0, 0, 0, 0.6);
}
}
body {
margin: 0;
font-family: var(--font-ui);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: var(--color-bg);
color: var(--color-text);
/* Damit auch vom Browser gestellte Bedienelemente (Bildlaufleisten,
Datumsauswahl, Autofill) der gewählten Ansicht folgen. */
color-scheme: light dark;
}
/* Formularfelder brauchen ausdrücklich Farben: ohne sie nimmt der Browser
seinen Standard (weiß) in der Nachtansicht leuchtet dann jedes Eingabefeld. */
input:not([type='checkbox']):not([type='radio']):not([type='range']):not([type='file']),
select,
textarea {
background: var(--color-surface);
color: var(--color-text);
}
input::placeholder,
textarea::placeholder {
color: var(--color-text-muted);
opacity: 1;
}
h1, h2, h3 {
font-family: var(--font-display);
}
code {
font-family: var(--font-mono);
}
/* ── Bedienelemente ─────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-1);
min-height: var(--touch-target);
padding: 0.5rem 1rem;
border: none;
border-radius: var(--radius-sm);
font-family: var(--font-ui);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
@ -46,15 +206,24 @@
}
.btn:disabled {
background: var(--color-border);
background: var(--color-surface-alt);
color: var(--color-text-muted);
cursor: not-allowed;
box-shadow: none;
}
/* Sichtbarer Tastaturfokus. Ohne das war die Tastaturbedienung unsichtbar
Buttons hatten nur einen :hover-Stil. */
.btn:focus-visible,
.nav-button:focus-visible,
a:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.btn-primary {
background: var(--color-primary);
color: white;
color: var(--color-on-primary);
}
.btn-primary:hover:not(:disabled) {
@ -64,7 +233,7 @@
.btn-secondary {
background: var(--color-secondary);
color: white;
color: var(--color-on-secondary);
}
.btn-secondary:hover:not(:disabled) {
@ -72,19 +241,9 @@
transform: translateY(-1px);
}
.btn-success {
background: var(--color-success);
color: white;
}
.btn-success:hover:not(:disabled) {
background: var(--color-success-dark);
transform: translateY(-1px);
}
.btn-danger {
background: var(--color-danger);
color: white;
color: var(--color-on-danger);
}
.btn-danger:hover:not(:disabled) {
@ -96,9 +255,13 @@
.select,
.textarea {
width: 100%;
min-height: var(--touch-target);
padding: 0.75rem 1rem;
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-sm);
font-family: var(--font-ui);
font-size: 1rem;
box-sizing: border-box;
}
@ -111,33 +274,19 @@
box-shadow: 0 0 0 3px var(--color-focus);
}
.panel {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-3);
box-shadow: var(--shadow-sm);
}
.panel-title {
margin: 0 0 var(--space-2);
font-family: var(--font-display);
font-size: 1.1rem;
color: var(--color-text);
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: var(--color-bg);
color: var(--color-text);
}
/* ── Grundgerüst ────────────────────────────────────────────────────────── */
.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;
flex-direction: column;
}
@ -151,44 +300,26 @@ body {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
min-height: 100dvh;
font-size: 1.2rem;
color: var(--color-text-muted);
}
.login-prompt {
position: fixed;
bottom: 20px;
right: 20px;
}
.login-button-header {
padding: 0.75rem 1.5rem;
background: var(--color-primary);
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
transition: all 0.2s;
}
.login-button-header:hover {
background: var(--color-primary-dark);
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3);
}
@media (max-width: 768px) {
.login-prompt {
bottom: 10px;
right: 10px;
/* Wer Bewegung reduziert haben möchte, bekommt keine Animationen. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
.login-button-header {
padding: 0.6rem 1.2rem;
font-size: 0.9rem;
.btn:hover:not(:disabled),
.btn-primary:hover:not(:disabled),
.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 HandlerDashboard from './components/handler/HandlerDashboard';
import InstallBanner from './components/common/InstallBanner';
import { ADMIN_PATH, RESET_PASSWORD_PATH, withBase, normalizePath } from './utils/constants';
import './App.css';
function App() {
const adminPath = process.env.REACT_APP_ADMIN_PATH || '/verwaltung';
const resetPasswordPath = '/passwort-zuruecksetzen';
const isAdminRoute = window.location.pathname === adminPath;
const isResetPasswordRoute = window.location.pathname === resetPasswordPath;
// Die Pfade muessen den Deployment-Basispfad enthalten: produktiv laeuft die
// App unter /<app>/, ein Vergleich gegen '/verwaltung' traefe dort nie zu.
const currentPath = normalizePath(window.location.pathname);
const isAdminRoute = currentPath === normalizePath(withBase(ADMIN_PATH));
const isResetPasswordRoute = currentPath === normalizePath(withBase(RESET_PASSWORD_PATH));
const [view, setView] = useState('public');
const [handlerUser, setHandlerUser] = useState(null);
const { isAuthenticated, loading: authLoading, login, logout } = useAuth();
@ -97,7 +99,6 @@ function App() {
isAdmin={false}
currentView={view}
onViewChange={handleViewChange}
onHandlerLogin={handleHandlerLogout}
/>
<main className="app-main">
{isAdminRoute || view === 'login' ? (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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