first commit

This commit is contained in:
2025-11-24 16:30:37 +00:00
commit 843e93a274
114 changed files with 25585 additions and 0 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
node_modules
.next
.git
.env
*.log
npm-debug.log*
.DS_Store
*.md
!README.md
!MQTT_INTEGRATION.md
data/*.sqlite*
.vscode
.idea

51
.env.example Normal file
View File

@@ -0,0 +1,51 @@
# Authentication
# Generate with: openssl rand -base64 32
AUTH_SECRET=your-secret-key-here
# NextAuth URL
# Development
NEXTAUTH_URL=http://localhost:3000
# Production (change to your domain)
# NEXTAUTH_URL=https://your-domain.com
# n8n API (optional - currently using client-side fetch)
N8N_API_URL=https://n8n.example.com/webhook/location
# SMTP Configuration (Fallback when DB config is empty)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
SMTP_FROM_EMAIL=noreply@example.com
SMTP_FROM_NAME=Location Tracker
# Encryption for SMTP passwords in database
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
ENCRYPTION_KEY=your-32-byte-hex-key-here
# Push Notifications (future feature)
# VAPID_PUBLIC_KEY=
# VAPID_PRIVATE_KEY=
# ======================================
# MQTT Configuration
# ======================================
# MQTT Broker URL
# Development (local): mqtt://localhost:1883
# Docker Compose: mqtt://mosquitto:1883
# Production: mqtt://your-mqtt-broker:1883
MQTT_BROKER_URL=mqtt://mosquitto:1883
# MQTT Admin Credentials (für MQTT Subscriber und Password File Generation)
MQTT_ADMIN_USERNAME=admin
MQTT_ADMIN_PASSWORD=admin
# Mosquitto Configuration File Paths
# Diese Pfade müssen vom App-Container aus erreichbar sein
MOSQUITTO_PASSWORD_FILE=/mosquitto/config/password.txt
MOSQUITTO_ACL_FILE=/mosquitto/config/acl.txt
# Mosquitto Container Name (für Config Reload via Docker)
MOSQUITTO_CONTAINER_NAME=mosquitto

55
.gitignore vendored Normal file
View File

@@ -0,0 +1,55 @@
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# Environment variables
.env.local
.env.production
# Database
/data/*.db
/data/*.sqlite
/data/*.db-*
/data/*.db-shm
/data/*.db-wal
/data/*.sqlite-shm
/data/*.sqlite-wal
!/data/.gitkeep
*.db
*.sqlite
# mosquitto
/mosquitto/config/*.txt
/mosquitto/logs/*.log

52
Dockerfile Normal file
View File

@@ -0,0 +1,52 @@
# Dockerfile für Location Tracker App mit MQTT Integration
FROM node:20-alpine AS base
# Installiere docker-cli für Mosquitto Container Management
RUN apk add --no-cache docker-cli
# Dependencies Stage
FROM base AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
# Builder Stage
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build Next.js App
RUN npm run build
# Initialisiere Datenbanken
RUN npm run db:init && \
node scripts/add-mqtt-tables.js
# Runner Stage
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
# Kopiere nur Production Dependencies
COPY package*.json ./
RUN npm ci --omit=dev
# Kopiere Build Artifacts
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/data ./data
# Kopiere App Code (benötigt für instrumentation.ts, lib/, etc.)
COPY --from=builder /app/instrumentation.ts ./
COPY --from=builder /app/lib ./lib
COPY --from=builder /app/scripts ./scripts
COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/middleware.ts ./
# Exponiere Port
EXPOSE 3000
# Start App
CMD ["npm", "start"]

559
MQTT_INTEGRATION.md Normal file
View File

@@ -0,0 +1,559 @@
# MQTT Provisioning Integration
Diese Anleitung beschreibt die Integration des MQTT Provisioning Systems (aus `mosquitto-automation`) in die Location Tracker App.
## 🎯 Übersicht
Die Integration vereint zwei vormals separate Systeme:
- **mosquitto-automation**: Device Provisioning und MQTT Credential Management
- **location-tracker-app**: GPS Tracking Visualisierung mit OwnTracks
### Was wurde integriert?
**MQTT Credentials Management** - Direkt im Admin Panel
**ACL (Access Control List) Management** - Feine Kontrolle über Topic-Berechtigungen
**Mosquitto Sync** - Password & ACL Files werden automatisch generiert
**MQTT Subscriber** - Direkte Verarbeitung von OwnTracks Messages (kein n8n mehr nötig)
**Docker Compose Setup** - All-in-One Deployment
---
## 📋 Features
### Admin Panel: MQTT Provisioning
**Route:** `/admin/mqtt`
#### Device Provisioning
- Erstelle MQTT Credentials für registrierte Devices
- Automatische Generierung von Username & Passwort
- Passwörter werden mit `mosquitto_passwd` gehasht
- Default ACL Regel: `owntracks/[device-id]/#` (readwrite)
#### Credentials Management
- Liste aller provisionierten Devices
- Enable/Disable MQTT Zugriff pro Device
- Passwort Regenerierung
- Credentials löschen (inkl. ACL Regeln)
#### ACL Management
- Custom Topic Patterns definieren
- Berechtigungen: `read`, `write`, `readwrite`
- Wildcard Support mit `#`
- Regeln pro Device verwalten
#### Mosquitto Sync
- **"Zu Mosquitto Syncen"** Button im Admin Panel
- Generiert `/mosquitto/config/password.txt`
- Generiert `/mosquitto/config/acl.txt`
- Sendet SIGHUP an Mosquitto Container (Config Reload)
- Zeigt ausstehende Änderungen an
---
## 🗄️ Datenbankschema
### Neue Tabellen
#### `mqtt_credentials`
```sql
CREATE TABLE mqtt_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL UNIQUE, -- Referenz zu Device Tabelle
mqtt_username TEXT NOT NULL UNIQUE,
mqtt_password_hash TEXT NOT NULL, -- Mosquitto-kompatible Hash
enabled INTEGER DEFAULT 1, -- 0 = disabled, 1 = enabled
created_at TEXT,
updated_at TEXT,
FOREIGN KEY (device_id) REFERENCES Device(id) ON DELETE CASCADE
);
```
#### `mqtt_acl_rules`
```sql
CREATE TABLE mqtt_acl_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
topic_pattern TEXT NOT NULL, -- z.B. "owntracks/device10/#"
permission TEXT NOT NULL, -- read | write | readwrite
created_at TEXT,
FOREIGN KEY (device_id) REFERENCES Device(id) ON DELETE CASCADE
);
```
#### `mqtt_sync_status`
```sql
CREATE TABLE mqtt_sync_status (
id INTEGER PRIMARY KEY CHECK (id = 1), -- Singleton
pending_changes INTEGER DEFAULT 0,
last_sync_at TEXT,
last_sync_status TEXT, -- success | error: ...
created_at TEXT,
updated_at TEXT
);
```
### Migration
```bash
# Datenbanken initialisieren
npm run db:init
# MQTT Tabellen hinzufügen
node scripts/add-mqtt-tables.js
```
---
## 🚀 Installation & Setup
### Voraussetzungen
- Docker & Docker Compose
- Node.js 20+ (für lokale Entwicklung)
### 1. Repository Setup
```bash
cd location-tracker-app
# Dependencies installieren
npm install
# .env Datei erstellen
cp .env.example .env
```
### 2. Environment Variables
Bearbeite `.env`:
```env
# MQTT Configuration
MQTT_BROKER_URL=mqtt://mosquitto:1883
MQTT_ADMIN_USERNAME=admin
MQTT_ADMIN_PASSWORD=dein-sicheres-passwort
MOSQUITTO_PASSWORD_FILE=/mosquitto/config/password.txt
MOSQUITTO_ACL_FILE=/mosquitto/config/acl.txt
MOSQUITTO_CONTAINER_NAME=mosquitto
# NextAuth
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=<generiere mit: openssl rand -base64 32>
# Verschlüsselung für SMTP Passwords
ENCRYPTION_KEY=<generiere mit: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))">
```
### 3. Docker Compose Start
```bash
# Build und Start
docker-compose up -d
# Logs verfolgen
docker-compose logs -f
# Status prüfen
docker-compose ps
```
Die App läuft auf: `http://localhost:3000`
Mosquitto MQTT Broker: `mqtt://localhost:1883`
### 4. Admin Zugang
**Default Credentials:**
- Username: `admin`
- Password: `admin123`
⚠️ **Ändere das Passwort nach dem ersten Login!**
---
## 🔧 Entwicklung
### Lokale Entwicklung (ohne Docker)
```bash
# 1. Mosquitto extern starten (oder Docker Compose nur Mosquitto)
docker run -d -p 1883:1883 -p 9001:9001 \
-v $(pwd)/mosquitto.conf:/mosquitto/config/mosquitto.conf \
-v mosquitto_data:/mosquitto/data \
eclipse-mosquitto:2
# 2. .env anpassen
MQTT_BROKER_URL=mqtt://localhost:1883
# 3. Datenbanken initialisieren
npm run db:init
node scripts/add-mqtt-tables.js
# 4. App starten
npm run dev
```
### Neue MQTT Credentials testen
```bash
# Mit mosquitto_sub testen
mosquitto_sub -h localhost -p 1883 \
-u "device_10_abc123" \
-P "dein-generiertes-passwort" \
-t "owntracks/10/#" \
-v
# OwnTracks Message simulieren
mosquitto_pub -h localhost -p 1883 \
-u "device_10_abc123" \
-P "dein-generiertes-passwort" \
-t "owntracks/10/device" \
-m '{"_type":"location","lat":52.5200,"lon":13.4050,"tst":1234567890,"batt":85,"vel":5.2}'
```
---
## 📡 MQTT Subscriber
Der MQTT Subscriber läuft automatisch beim App-Start und verarbeitet OwnTracks Messages.
### Implementierung
- **Service:** `lib/mqtt-subscriber.ts`
- **Startup:** `instrumentation.ts` (Next.js Hook)
- **Topic:** `owntracks/+/+`
- **Datenbank:** Schreibt direkt in `locations.sqlite`
### OwnTracks Message Format
```json
{
"_type": "location",
"tid": "XX",
"lat": 52.5200,
"lon": 13.4050,
"tst": 1234567890,
"batt": 85,
"vel": 5.2,
"acc": 10,
"alt": 50
}
```
### Logs
```bash
# Docker Logs
docker-compose logs -f app
# Du solltest sehen:
# ✓ Connected to MQTT broker
# ✓ Subscribed to owntracks/+/+
# ✓ Location saved: device10 at (52.52, 13.405)
```
---
## 🔐 Sicherheit
### Mosquitto Authentication
- **Keine Anonymous Connections:** `allow_anonymous false`
- **Password File:** Mosquitto-kompatible Hashes (SHA512)
- **ACL File:** Topic-basierte Access Control
### Best Practices
1. **Starke Admin Passwörter:** Ändere `MQTT_ADMIN_PASSWORD` in `.env`
2. **Device Passwörter:** Auto-generierte Passwörter haben 128 Bit Entropie
3. **ACL Regeln:** Gib Devices nur Zugriff auf ihre eigenen Topics
4. **Docker Socket:** Container benötigt Zugriff für Mosquitto Reload (optional)
### ACL Beispiele
```text
# Device darf nur in eigenes Topic schreiben
user device_10_abc123
topic readwrite owntracks/10/#
# Device mit zusätzlichem Read-only Topic
user device_11_xyz789
topic readwrite owntracks/11/#
topic read status/#
# Admin hat vollen Zugriff
user admin
topic readwrite #
```
---
## 🐛 Troubleshooting
### Problem: "Mosquitto configuration reloaded" fehlgeschlagen
**Symptom:** Nach Sync kommt Warnung "Could not reload Mosquitto automatically"
**Lösung:** Docker Socket Zugriff fehlt. Entweder:
```bash
# Option 1: Manuelle Mosquitto Neustart
docker-compose restart mosquitto
# Option 2: Docker Socket in docker-compose.yml freigeben (bereits konfiguriert)
volumes:
- /var/run/docker.sock:/var/run/docker.sock
```
### Problem: MQTT Subscriber verbindet nicht
**Debug Steps:**
```bash
# 1. Prüfe Mosquitto läuft
docker-compose ps mosquitto
# 2. Prüfe Mosquitto Logs
docker-compose logs mosquitto
# 3. Prüfe App Logs
docker-compose logs app | grep MQTT
# 4. Teste MQTT Verbindung manuell
mosquitto_sub -h localhost -p 1883 -u admin -P admin -t '#'
```
### Problem: Password Hash falsch
**Symptom:** Authentication failed im Mosquitto Log
**Lösung:** `mosquitto_passwd` Tool muss im Container verfügbar sein (ist im Dockerfile installiert)
```bash
# Im Container testen
docker exec -it location-tracker-app mosquitto_passwd -h
```
### Problem: ACL Regeln funktionieren nicht
**Debug:**
```bash
# ACL File prüfen
docker exec -it location-tracker-app cat /mosquitto/config/acl.txt
# Mosquitto Logs auf "Access denied" prüfen
docker-compose logs mosquitto | grep -i denied
```
---
## 📊 API Endpoints
### MQTT Credentials
```http
# Liste aller Credentials
GET /api/mqtt/credentials
# Credential für Device abrufen
GET /api/mqtt/credentials/{device_id}
# Neue Credentials erstellen
POST /api/mqtt/credentials
Content-Type: application/json
{
"device_id": "10",
"auto_generate": true
}
# Credentials aktualisieren
PATCH /api/mqtt/credentials/{device_id}
Content-Type: application/json
{
"regenerate_password": true,
"enabled": true
}
# Credentials löschen
DELETE /api/mqtt/credentials/{device_id}
```
### ACL Rules
```http
# ACL Regeln für Device
GET /api/mqtt/acl?device_id=10
# Neue ACL Regel erstellen
POST /api/mqtt/acl
Content-Type: application/json
{
"device_id": "10",
"topic_pattern": "owntracks/10/#",
"permission": "readwrite"
}
# ACL Regel löschen
DELETE /api/mqtt/acl/{rule_id}
```
### Mosquitto Sync
```http
# Sync Status abrufen
GET /api/mqtt/sync
# Sync triggern
POST /api/mqtt/sync
```
Alle Endpoints erfordern Admin-Authentifizierung (Role: ADMIN).
---
## 🔄 Migration von mosquitto-automation
Wenn du bereits Devices in `mosquitto-automation` hast:
### Automatische Migration (TODO)
```bash
# Script erstellen das aus der alten DB liest
node scripts/migrate-from-mosquitto-automation.js \
--old-db /pfad/zu/mosquitto-automation/data/devices.db
```
### Manuelle Migration
1. Exportiere Devices aus alter DB:
```sql
SELECT id, name, username, password_hash, permissions
FROM devices
WHERE active = 1;
```
2. Erstelle Devices im neuen System über Admin Panel
3. Provisioniere MQTT Credentials manuell
4. Importiere ACL Regeln
---
## 📚 Architektur
### Komponenten
```
┌─────────────────────────────────────────────────────┐
│ Location Tracker App (Next.js) │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ │
│ │ Admin Panel │ │ MQTT Service │ │ API │ │
│ │ /admin/mqtt │ │ Subscriber │ │ Routes │ │
│ └──────────────┘ └──────────────┘ └──────────┘ │
│ │ │ │ │
│ └──────────────────┴────────────────┘ │
│ │ │
│ ┌────▼────┐ │
│ │ SQLite │ │
│ │ DB │ │
│ └────┬────┘ │
│ │ │
│ ┌────▼────────┐ │
│ │ Mosquitto │ │
│ │ Sync Service│ │
│ └────┬────────┘ │
└─────────────────────────┼──────────────────────────┘
┌───────────▼───────────┐
│ Mosquitto Broker │
│ │
│ • password.txt │
│ • acl.txt │
└───────────┬───────────┘
┌───────────▼───────────┐
│ GPS Tracking │
│ Devices │
│ (OwnTracks) │
└───────────────────────┘
```
### Datei-Struktur
```
location-tracker-app/
├── app/
│ ├── admin/
│ │ └── mqtt/
│ │ └── page.tsx # MQTT Provisioning UI
│ └── api/
│ └── mqtt/
│ ├── credentials/ # Credentials Management
│ ├── acl/ # ACL Management
│ └── sync/ # Mosquitto Sync
├── lib/
│ ├── mqtt-db.ts # MQTT Datenbank Operations
│ ├── mqtt-subscriber.ts # MQTT Message Processing
│ ├── mosquitto-sync.ts # Config File Generation
│ └── startup.ts # Service Initialization
├── scripts/
│ └── add-mqtt-tables.js # Datenbank Migration
├── docker-compose.yml # Docker Setup
├── Dockerfile # App Container
├── mosquitto.conf # Mosquitto Config
└── instrumentation.ts # Next.js Startup Hook
```
---
## ✨ Vorteile der Integration
### Vorher (Separate Systeme)
```
GPS Device → MQTT → n8n → HTTP API → location-tracker-app → UI
mosquitto-automation (separate)
```
**Probleme:**
- n8n als zusätzliche Dependency
- Zwei separate Admin Panels
- Keine zentrale User/Device Verwaltung
- Komplexes Setup
### Nachher (Integriert)
```
GPS Device → MQTT → location-tracker-app → UI
(integriertes Provisioning)
```
**Vorteile:**
✅ Ein Admin Panel für alles
✅ Direkte MQTT Verarbeitung (schneller)
✅ Einfaches Docker Compose Setup
✅ Zentrale Datenbank
✅ Weniger Dependencies
---
## 🎉 Fertig!
Die Integration ist komplett. Du kannst jetzt:
1. **Devices verwalten** unter `/admin/devices`
2. **MQTT Credentials provisionieren** unter `/admin/mqtt`
3. **ACL Regeln definieren** im MQTT Panel
4. **Zu Mosquitto syncen** mit einem Klick
5. **GPS Tracking visualisieren** auf der Hauptseite
Bei Fragen oder Problemen: Siehe Troubleshooting oder check die Logs.
Happy Tracking! 🚀📍

349
N8N_INTEGRATION.md Normal file
View File

@@ -0,0 +1,349 @@
# n8n Integration Anleitung
## Übersicht
Die poc-app verwendet nun eine **Dual-Datenbank-Architektur** mit lokalem SQLite-Caching:
- **database.sqlite** - Benutzerkonten, Geräteverwaltung (kritische Daten, wenige Schreibvorgänge)
- **locations.sqlite** - Standortverfolgung Cache (viele Schreibvorgänge, temporär)
Diese Architektur bietet:
- **Performance**: Schnelle Abfragen aus lokalem SQLite anstatt NocoDB API
- **Skalierbarkeit**: Unbegrenzter Verlauf ohne Paginierungslimits
- **Resilienz**: Auth-System isoliert von der Tracking-Datenbank
- **Flexibilität**: Einfaches Löschen alter Daten
---
## Erforderliche n8n Workflow-Änderungen
### Aktueller Flow (ALT)
```
MQTT Trigger (owntracks/#)
MQTT Location verarbeiten (Parse & Transform)
Speichere in NocoDB
```
### Neuer Flow (ERFORDERLICH)
```
MQTT Trigger (owntracks/#)
MQTT Location verarbeiten (Parse & Transform)
Speichere in NocoDB
[NEU] Push to Next.js Cache (HTTP Request)
```
---
## Schritt-für-Schritt: HTTP Request Node hinzufügen
### 1. HTTP Request Node hinzufügen
Nach dem "Speichere in NocoDB" Node einen neuen **HTTP Request** Node hinzufügen:
**Node-Konfiguration:**
- **Name**: "Push to Next.js Cache"
- **Methode**: POST
- **URL**: `https://deine-nextjs-domain.com/api/locations/ingest`
- **Authentifizierung**: None (API-Key in Produktion hinzufügen!)
- **Body Content Type**: JSON
- **Specify Body**: Using Fields Below
### 2. Felder zuordnen
Im Bereich "Body Parameters" die folgenden Felder zuordnen:
| Parameter | Wert (Expression) | Beschreibung |
|-----------|-------------------|-------------|
| `latitude` | `{{ $json.latitude }}` | Geografischer Breitengrad |
| `longitude` | `{{ $json.longitude }}` | Geografischer Längengrad |
| `timestamp` | `{{ $json.timestamp }}` | ISO 8601 Zeitstempel |
| `user_id` | `{{ $json.user_id }}` | Benutzer-ID (0 für MQTT) |
| `first_name` | `{{ $json.first_name }}` | Tracker ID |
| `last_name` | `{{ $json.last_name }}` | Quelltyp |
| `username` | `{{ $json.username }}` | Geräte-Benutzername |
| `marker_label` | `{{ $json.marker_label }}` | Anzeige-Label |
| `display_time` | `{{ $json.display_time }}` | Formatierte Zeit |
| `chat_id` | `{{ $json.chat_id }}` | Chat-ID (0 für MQTT) |
| `battery` | `{{ $json.battery }}` | Batterieprozent |
| `speed` | `{{ $json.speed }}` | Geschwindigkeit (m/s) |
### 3. Fehlerbehandlung (Optional aber empfohlen)
Einen **Error Trigger** Node hinzufügen, um fehlgeschlagene API-Aufrufe zu behandeln:
- **Workflow**: Current Workflow
- **Error Type**: All Errors
- **Connected to**: Push to Next.js Cache node
Einen **Slack/Email** Benachrichtigungs-Node hinzufügen, um über Fehler informiert zu werden.
---
## Beispiel n8n HTTP Request Node (JSON)
```json
{
"parameters": {
"method": "POST",
"url": "https://deine-domain.com/api/locations/ingest",
"authentication": "none",
"options": {},
"bodyParametersJson": "={{ {\n \"latitude\": $json.latitude,\n \"longitude\": $json.longitude,\n \"timestamp\": $json.timestamp,\n \"user_id\": $json.user_id,\n \"first_name\": $json.first_name,\n \"last_name\": $json.last_name,\n \"username\": $json.username,\n \"marker_label\": $json.marker_label,\n \"display_time\": $json.display_time,\n \"chat_id\": $json.chat_id,\n \"battery\": $json.battery,\n \"speed\": $json.speed\n} }}"
},
"name": "Push to Next.js Cache",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [1200, 300]
}
```
---
## Testen
### 1. Ingest-Endpunkt testen
```bash
curl -X POST https://deine-domain.com/api/locations/ingest \
-H "Content-Type: application/json" \
-d '{
"latitude": 48.1351,
"longitude": 11.5820,
"timestamp": "2024-01-15T10:30:00Z",
"user_id": 0,
"username": "10",
"marker_label": "Test Gerät",
"battery": 85,
"speed": 2.5
}'
```
Erwartete Antwort:
```json
{
"success": true,
"inserted": 1,
"message": "Successfully stored 1 location(s)"
}
```
### 2. Daten überprüfen
```bash
curl https://deine-domain.com/api/locations?username=10&timeRangeHours=1
```
### 3. Statistiken prüfen
```bash
curl https://deine-domain.com/api/locations/ingest
```
---
## Produktions-Überlegungen
### 1. API-Key-Authentifizierung hinzufügen
**Aktualisieren** von `app/api/locations/ingest/route.ts`:
```typescript
export async function POST(request: NextRequest) {
// API-Key validieren
const apiKey = request.headers.get('x-api-key');
if (apiKey !== process.env.N8N_API_KEY) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// ... restlicher Code
}
```
**n8n HTTP Request Node aktualisieren:**
- Header hinzufügen: `x-api-key` = `dein-secret-key`
### 2. Automatisches Aufräumen einrichten
Einen Cron-Job hinzufügen, um alte Daten zu löschen (hält die Datenbankgröße überschaubar):
```bash
# /etc/crontab
# Tägliche Bereinigung um 2 Uhr morgens - löscht Daten älter als 7 Tage
0 2 * * * cd /pfad/zu/poc-app && node scripts/cleanup-old-locations.js 168
```
Oder verwende einen systemd Timer, PM2 Cron oder ähnliches.
### 3. Datenbankgröße überwachen
```bash
# Datenbankstatistiken prüfen
curl https://deine-domain.com/api/locations/ingest
# Erwartete Ausgabe:
# {
# "total": 5432,
# "oldest": "2024-01-08T10:00:00Z",
# "newest": "2024-01-15T10:30:00Z",
# "sizeKB": 1024
# }
```
### 4. Backup-Strategie
**database.sqlite** (kritisch):
- Tägliche automatisierte Backups
- 30-Tage Aufbewahrung
**locations.sqlite** (Cache):
- Optional: Wöchentliche Backups
- Oder keine Backups (Daten existieren in NocoDB)
---
## Migration: Vorhandene NocoDB-Daten importieren
Falls du bereits Standortdaten in NocoDB hast, kannst du diese importieren:
### Option 1: CSV aus NocoDB exportieren
1. Daten aus NocoDB als CSV exportieren
2. In JSON konvertieren
3. In Batches an `/api/locations/ingest` senden (POST)
### Option 2: Direkter NocoDB API Import (empfohlen)
Ein Skript `scripts/import-from-nocodb.js` erstellen:
```javascript
const fetch = require('node-fetch');
const { locationDb } = require('../lib/db');
async function importFromNocoDB() {
// Alle Daten von NocoDB API abrufen
const response = await fetch('https://n8n.example.com/webhook/location');
const data = await response.json();
// Bulk-Insert in locations.sqlite
const count = locationDb.createMany(data.history);
console.log(`Importiert: ${count} Standorte`);
}
importFromNocoDB().catch(console.error);
```
Ausführen: `node scripts/import-from-nocodb.js`
---
## Fehlerbehebung
### Problem: "directory does not exist" Fehler
**Lösung**: Init-Skript ausführen:
```bash
cd poc-app
node scripts/init-locations-db.js
```
### Problem: n8n gibt 500-Fehler beim Push zu Next.js zurück
**Prüfen**:
1. Next.js App läuft und ist erreichbar
2. URL in n8n ist korrekt
3. Next.js Logs prüfen: `pm2 logs` oder `docker logs`
### Problem: Keine Daten im Frontend sichtbar
**Überprüfen**:
1. Daten sind in SQLite: `curl https://deine-domain.com/api/locations/ingest`
2. API gibt Daten zurück: `curl https://deine-domain.com/api/locations`
3. Browser-Konsole auf Fehler prüfen
### Problem: Datenbank wird zu groß
**Lösung**: Cleanup-Skript ausführen:
```bash
node scripts/cleanup-old-locations.js 168 # Behalte 7 Tage
```
Oder die Aufbewahrungsdauer im Cron-Job reduzieren.
---
## Architektur-Diagramm
```
┌─────────────────┐
│ OwnTracks App │
└────────┬────────┘
│ MQTT
┌─────────────────┐
│ MQTT Broker │
└────────┬────────┘
┌─────────────────┐
│ n8n Workflow │
│ │
│ 1. Parse MQTT │
│ 2. Save NocoDB │───────────┐
│ 3. Push Next.js│ │
└────────┬────────┘ │
│ │
│ HTTP POST │ (Backup)
▼ ▼
┌─────────────────┐ ┌──────────────┐
│ Next.js API │ │ NocoDB │
│ /api/locations │ │ (Cloud DB) │
│ /ingest │ └──────────────┘
└────────┬────────┘
┌─────────────────┐
│ locations.sqlite│ (Lokaler Cache)
│ - Schnelle │
│ Abfragen │
│ - Auto Cleanup │
│ - Isoliert │
└────────┬────────┘
┌─────────────────┐
│ Frontend API │
│ /api/locations │
│ (Read-only) │
└────────┬────────┘
┌─────────────────┐
│ Map UI │
│ - 24h Verlauf │
│ - Schnelle │
│ Filter │
│ - Echtzeit │
└─────────────────┘
```
---
## Zusammenfassung
**Dual-Datenbank** isoliert kritische Auth von hochvolumiger Standortverfolgung
**Lokaler Cache** ermöglicht schnelle 24h+ Abfragen ohne NocoDB-Paginierungslimits
**WAL-Modus** bietet Absturzsicherheit und bessere Nebenläufigkeit
**Auto Cleanup** hält die Datenbankgröße überschaubar
**Rückwärtskompatibel** - gleiches API-Antwortformat wie n8n Webhook
🚀 **Die Next.js App ist jetzt produktionsreif mit unbegrenztem Standortverlauf!**

338
OWNTRACKS_SETUP.md Normal file
View File

@@ -0,0 +1,338 @@
# OwnTracks App Setup Anleitung
## Übersicht
Diese Anleitung erklärt Schritt-für-Schritt, wie Sie die OwnTracks App auf Ihrem Smartphone installieren und mit dem Location Tracker System verbinden.
---
## 1. Installation
### iOS (iPhone/iPad)
1. Öffnen Sie den **App Store**
2. Suchen Sie nach **"OwnTracks"**
3. Laden Sie die App herunter und installieren Sie sie
4. App-Link: https://apps.apple.com/app/owntracks/id692424691
### Android
1. Öffnen Sie den **Google Play Store**
2. Suchen Sie nach **"OwnTracks"**
3. Laden Sie die App herunter und installieren Sie sie
4. App-Link: https://play.google.com/store/apps/details?id=org.owntracks.android
---
## 2. MQTT Credentials erhalten
Bevor Sie die App konfigurieren können, benötigen Sie Ihre MQTT-Zugangsdaten:
1. Melden Sie sich im **Location Tracker** an: `http://192.168.10.118:3000/login`
2. Navigieren Sie zu: **Admin → MQTT Provisioning** (`/admin/mqtt`)
3. Klicken Sie auf **"Device Provisionieren"**
4. Wählen Sie Ihr Device aus der Liste
5. Aktivieren Sie **"Automatisch Username & Passwort generieren"**
6. Klicken Sie auf **"Erstellen"**
7. **WICHTIG:** Kopieren Sie sofort die angezeigten Credentials:
```
Username: device_10_abc123
Password: xxxxxxxxxxxxxxxx
```
8. Speichern Sie diese Daten sicher - das Passwort wird nur einmal angezeigt!
---
## 3. OwnTracks App Konfiguration
### Schritt 1: App öffnen
Starten Sie die OwnTracks App auf Ihrem Smartphone.
### Schritt 2: Zu Einstellungen navigieren
- **iOS:** Tippen Sie auf das ⚙️ Symbol (oben rechts)
- **Android:** Tippen Sie auf ☰ (Hamburger-Menü) → Einstellungen
### Schritt 3: Verbindung konfigurieren
#### 3.1 Modus auswählen
1. Gehen Sie zu **"Verbindung"** oder **"Connection"**
2. Wählen Sie **"Modus"** → **"MQTT"**
- ✅ **MQTT** (Private Server)
- ❌ Nicht: HTTP oder andere Modi
#### 3.2 MQTT Server-Einstellungen
Tragen Sie folgende Werte ein:
| Einstellung | Wert | Beschreibung |
|------------|------|--------------|
| **Hostname** | `192.168.10.118` | IP-Adresse Ihres Servers |
| **Port** | `1883` | Standard MQTT Port (ohne Websocket) |
| **Websockets nutzen** | ❌ **DEAKTIVIERT** | Websockets nur bei Port 9001 aktivieren |
| **TLS** | ❌ **DEAKTIVIERT** | TLS/SSL nicht aktivieren (lokales Netzwerk) |
| **Client ID** | Automatisch generiert | Kann leer gelassen werden |
#### 3.3 Authentifizierung
| Einstellung | Wert | Beispiel |
|------------|------|----------|
| **Benutzername** | Ihr MQTT Username | `device_10_f06e935e` |
| **Passwort** | Ihr MQTT Passwort | `n5DkMF+xEi9p56DFa7ewUg==` |
#### 3.4 Device Identifikation
| Einstellung | Wert | Beschreibung |
|------------|------|--------------|
| **Geräte ID** / **Device ID** | `10` | Muss mit Ihrer Device-ID im System übereinstimmen |
| **Tracker ID** | `10` | Identisch mit Device ID |
**WICHTIG:** Die Device ID und Tracker ID müssen mit der Device-ID übereinstimmen, die Sie im Location Tracker System konfiguriert haben (z.B. `10`, `11`, `12`, `15`).
---
## 4. Erweiterte Einstellungen (Optional)
### 4.1 Standort-Tracking Einstellungen
**Empfohlene Werte für präzises Tracking:**
| Einstellung | Empfohlener Wert | Beschreibung |
|------------|------------------|--------------|
| **Monitoring Modus** | Significant Changes | Spart Akku, trackt bei größeren Bewegungen |
| **Move Intervall** | 60 Sekunden | Sendet alle 60 Sekunden bei Bewegung |
| **Standby Intervall** | 300 Sekunden | Sendet alle 5 Minuten im Ruhezustand |
### 4.2 Benachrichtigungen
- **iOS:** Erlauben Sie Standortzugriff "Immer" für Hintergrund-Tracking
- **Android:** Aktivieren Sie "Standortzugriff im Hintergrund"
### 4.3 Akkuoptimierung (Android)
**WICHTIG für zuverlässiges Tracking:**
1. Gehen Sie zu **Systemeinstellungen → Apps → OwnTracks**
2. Wählen Sie **"Akku"** oder **"Akkuoptimierung"**
3. Wählen Sie **"Nicht optimieren"** oder deaktivieren Sie Akkuoptimierung
4. Dies verhindert, dass Android die App im Hintergrund beendet
---
## 5. Verbindung testen
### Schritt 1: Verbindung prüfen
1. Kehren Sie zum OwnTracks Hauptbildschirm zurück
2. Sie sollten ein **grünes Symbol** oder "Connected" sehen
3. Bei Problemen: Rotes Symbol oder "Disconnected"
### Schritt 2: Testpunkt senden
1. Tippen Sie auf den **Location-Button** (Fadenkreuz-Symbol)
2. Dies sendet sofort Ihre aktuelle Position
### Schritt 3: Im Location Tracker prüfen
1. Öffnen Sie den Location Tracker im Browser: `http://192.168.10.118:3000/map`
2. Ihre Position sollte jetzt auf der Karte erscheinen
3. Bei erfolgreicher Verbindung sehen Sie:
- Marker mit Ihrer Device-Farbe
- Aktuelle Koordinaten
- Zeitstempel der letzten Position
---
## 6. Port 1883 vs. Port 9001 - Was ist der Unterschied?
### Port 1883 (Standard MQTT)
- **Protokoll:** Standard MQTT (TCP)
- **Verwendung:** Normale MQTT-Clients (OwnTracks, IoT-Geräte)
- **Websockets:** ❌ Nein
- **Empfohlen für:** Mobile Apps, eingebettete Geräte
**Konfiguration:**
```
Port: 1883
Websockets: DEAKTIVIERT
```
### Port 9001 (MQTT over WebSockets)
- **Protokoll:** MQTT über WebSocket
- **Verwendung:** Browser-basierte Clients, Web-Anwendungen
- **Websockets:** ✅ Ja
- **Empfohlen für:** Web-Apps, JavaScript-Clients
**Konfiguration:**
```
Port: 9001
Websockets: AKTIVIERT
```
### Welchen Port sollten Sie verwenden?
| Client-Typ | Empfohlener Port | Websockets |
|-----------|------------------|------------|
| **OwnTracks App (iOS/Android)** | **1883** | ❌ Nein |
| **Browser/Web-App** | **9001** | ✅ Ja |
| **IoT-Geräte** | **1883** | ❌ Nein |
| **Node.js/Python Scripts** | **1883** | ❌ Nein |
**Für die OwnTracks Mobile App verwenden Sie immer Port 1883 ohne Websockets!**
---
## 7. Troubleshooting - Häufige Probleme
### Problem: "Verbindung fehlgeschlagen"
**Mögliche Ursachen und Lösungen:**
#### 1. Falsche IP-Adresse oder Port
- ✅ **Lösung:** Überprüfen Sie Hostname: `192.168.10.118` und Port: `1883`
- Stellen Sie sicher, dass Ihr Smartphone im selben Netzwerk ist
#### 2. TLS aktiviert (sollte deaktiviert sein)
- ✅ **Lösung:** Deaktivieren Sie TLS/SSL in den Verbindungseinstellungen
- Lokale Verbindungen benötigen kein TLS
#### 3. Websockets fälschlicherweise aktiviert
- ✅ **Lösung:** Deaktivieren Sie "Websockets nutzen" bei Port 1883
- Websockets nur bei Port 9001 verwenden
#### 4. Falsche Credentials
- ✅ **Lösung:** Überprüfen Sie Username und Passwort
- Regenerieren Sie ggf. das Passwort über `/admin/mqtt`
#### 5. Firewall blockiert Port 1883
- ✅ **Lösung:** Prüfen Sie Firewall-Einstellungen auf dem Server
- Port 1883 muss für eingehende Verbindungen geöffnet sein
### Problem: "Verbunden, aber keine Daten auf der Karte"
**Mögliche Ursachen:**
#### 1. Falsche Device ID / Tracker ID
- ✅ **Lösung:** Device ID und Tracker ID müssen mit dem konfigurierten Device im System übereinstimmen
- Beispiel: Wenn Sie "Device 10" provisioniert haben, muss Tracker ID `10` sein
#### 2. Standortberechtigungen nicht erteilt
- ✅ **Lösung (iOS):** Einstellungen → Datenschutz → Ortungsdienste → OwnTracks → "Immer"
- ✅ **Lösung (Android):** App-Einstellungen → Berechtigungen → Standort → "Immer zulassen"
#### 3. Akkuoptimierung beendet App (Android)
- ✅ **Lösung:** Akkuoptimierung für OwnTracks deaktivieren (siehe Abschnitt 4.3)
### Problem: "Tracking stoppt im Hintergrund"
**Lösungen:**
#### iOS
1. Einstellungen → Allgemein → Hintergrundaktualisierung → OwnTracks aktivieren
2. Standortzugriff: "Immer" (nicht "Beim Verwenden der App")
#### Android
1. Akkuoptimierung deaktivieren
2. Einstellungen → Apps → OwnTracks → Berechtigungen → Standort → "Immer zulassen"
3. Bei manchen Herstellern: "Autostart" erlauben
---
## 8. Sicherheitshinweise
### WICHTIG - Nur im lokalen Netzwerk verwenden!
**Aktuelle Konfiguration ist NICHT für öffentliches Internet geeignet:**
- ❌ **TLS ist deaktiviert** - Daten werden unverschlüsselt übertragen
- ❌ **Keine VPN-Verbindung** - Direkter Zugriff erforderlich
- ⚠️ **Nur im sicheren WLAN** verwenden
### Für Zugriff von außerhalb:
Wenn Sie von außerhalb Ihres Heimnetzwerks zugreifen möchten, sollten Sie:
1. **VPN einrichten** (z.B. WireGuard, OpenVPN)
2. **TLS/SSL aktivieren** für verschlüsselte Verbindung
3. **Starke Passwörter verwenden** (automatisch generiert durch System)
4. **Firewall korrekt konfigurieren** (nur VPN-Zugriff)
---
## 9. Konfigurationsübersicht
### ✅ Korrekte Konfiguration für OwnTracks Mobile App
```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Verbindungseinstellungen
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Modus: MQTT
Hostname: 192.168.10.118
Port: 1883
Websockets nutzen: ❌ NEIN
TLS: ❌ NEIN
Client ID: (automatisch)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Authentifizierung
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Benutzername: device_XX_xxxxxxxx
Passwort: ******************
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Device Identifikation
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Geräte ID: 10 (Beispiel)
Tracker ID: 10 (identisch)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```
---
## 10. Schnellstart-Checkliste
- [ ] OwnTracks App aus App Store / Play Store installiert
- [ ] MQTT Credentials über `/admin/mqtt` generiert
- [ ] Credentials sicher gespeichert
- [ ] **Modus auf MQTT** gesetzt
- [ ] **Hostname:** `192.168.10.118` eingetragen
- [ ] **Port:** `1883` eingetragen
- [ ] **Websockets:** ❌ Deaktiviert
- [ ] **TLS:** ❌ Deaktiviert
- [ ] **Benutzername** und **Passwort** eingetragen
- [ ] **Device ID** und **Tracker ID** korrekt gesetzt
- [ ] Standortberechtigungen "Immer" erteilt
- [ ] Akkuoptimierung deaktiviert (Android)
- [ ] Verbindung erfolgreich (grünes Symbol)
- [ ] Testpunkt gesendet
- [ ] Position auf Karte sichtbar unter `/map`
---
## 11. Weiterführende Informationen
### Offizielle OwnTracks Dokumentation
- Website: https://owntracks.org
- Dokumentation: https://owntracks.org/booklet/
- GitHub: https://github.com/owntracks
### Location Tracker System
- Dashboard: `http://192.168.10.118:3000/admin`
- Live-Karte: `http://192.168.10.118:3000/map`
- MQTT Provisioning: `http://192.168.10.118:3000/admin/mqtt`
---
## Support
Bei Problemen oder Fragen:
1. Überprüfen Sie zuerst die Troubleshooting-Sektion (Abschnitt 7)
2. Prüfen Sie die Verbindung im Location Tracker Dashboard
3. Kontrollieren Sie die Server-Logs auf Fehler
**Wichtige Logs prüfen:**
```bash
# Next.js Server Logs
npm run dev
# Mosquitto MQTT Broker Logs
docker logs mosquitto
```

875
README.md Normal file
View File

@@ -0,0 +1,875 @@
# Location Tracker - Next.js Anwendung
Eine moderne Location-Tracking Anwendung basierend auf Next.js 14 mit MQTT/OwnTracks Integration, SQLite-Datenbank, Admin-Panel und Authentifizierung.
![Location Tracker Screenshot](./pictures/n8n-MQTT-GPS-Tracking.png)
## 📋 Inhaltsverzeichnis
- [Features](#-features)
- [Tech Stack](#-tech-stack)
- [Installation](#-installation)
- [Datenbank-Setup](#-datenbank-setup)
- [Verwendung](#-verwendung)
- [Architektur](#-architektur)
- [API-Endpunkte](#-api-endpunkte)
- [Device Management](#-device-management)
- [Wartung](#-wartung)
- [Deployment](#-deployment)
---
## ✨ Features
### Öffentliche Features
- 🗺️ **Interaktive Karte** - Echtzeit-Standortverfolgung mit Leaflet.js
- 🎨 **Mehrere Kartenansichten** - Standard, Satellit, Dark Mode
- 🔍 **Device-Filterung** - Filtern nach Gerät und Zeitraum
- ⏱️ **Flexible Zeitfilter**:
- **Quick Filters:** 1h, 3h, 6h, 12h, 24h, All
- **Custom Range:** Benutzerdefinierter Zeitraum mit DateTime-Picker (z.B. 16.11.2025 16:00 - 17.11.2025 06:00)
- Kompakte UI - Custom Range nur sichtbar wenn aktiviert
- 🔄 **Auto-Refresh** - Automatische Aktualisierung alle 5 Sekunden mit Pause/Resume Button
- 🎯 **Auto-Center** - Karte zentriert automatisch auf neueste Position
- ⏸️ **Pause/Resume** - Toggle-Button zum Stoppen/Starten des Auto-Refresh
- 📱 **Responsive Design** - Optimiert für Desktop und Mobile
- 📊 **Polylines** - Bewegungspfade mit farbcodierter Darstellung
- 🎨 **Marker-Sortierung** - Neueste Position immer im Vordergrund (z-index optimiert)
- 📍 **Zoom-basierte Icon-Skalierung** - Marker passen sich automatisch an Zoom-Level an
### Admin-Panel (Login erforderlich)
- 🔐 **Authentifizierung** - NextAuth.js v5 mit bcrypt-Hashing
- 📊 **Dashboard** - Übersicht über Geräte, Statistiken und Datenbankstatus
- ⏱️ **System Status** - Live-Uptime, Memory Usage, Runtime Info
- 📱 **Device Management** - Geräte hinzufügen, bearbeiten, löschen
- 💾 **Datenbank-Wartung**:
- 🔄 Manueller Sync von n8n
- 🧹 Cleanup alter Daten (7, 15, 30, 90 Tage)
- ⚡ Datenbank-Optimierung (VACUUM)
- 📈 Detaillierte Statistiken
- 🟢 **Online/Offline Status** - Echtzeit-Status (< 10 Min = Online)
- 🔋 **Telemetrie-Daten** - Batterie, Geschwindigkeit, letzte Position (speed=0 wird korrekt behandelt)
---
## 🛠 Tech Stack
- **Framework:** Next.js 14 (App Router)
- **Sprache:** TypeScript 5.9
- **Styling:** Tailwind CSS v4
- **Karten:** Leaflet 1.9.4 + React-Leaflet 5.0
- **Authentifizierung:** NextAuth.js v5 (beta)
- **Datenbank:** SQLite (better-sqlite3)
- **Passwort-Hashing:** bcryptjs
- **Datenquelle:** n8n Webhook API + lokale SQLite-Cache
### Dual-Database Architektur
- **database.sqlite** - User, Geräte (kritische Daten)
- **locations.sqlite** - Location-Tracking (hohe Schreibrate, isoliert)
---
## 📦 Installation
### Voraussetzungen
- Node.js 18+
- npm oder yarn
### Schritte
1. **Repository klonen**
```bash
git clone https://github.com/yourusername/location-tracker-app.git
cd location-tracker-app
```
2. **Dependencies installieren**
```bash
npm install
```
3. **Datenbank initialisieren**
```bash
npm run db:init
```
Dies erstellt:
- `data/database.sqlite` (User + Devices)
- `data/locations.sqlite` (Location-Tracking)
- Standard Admin-User: `admin` / `admin123`
- Standard Devices (ID 10, 11)
4. **Development Server starten**
```bash
npm run dev
```
5. **Im Browser öffnen**
- Karte: http://localhost:3000
- Login: http://localhost:3000/login
- Admin: http://localhost:3000/admin
- Devices: http://localhost:3000/admin/devices
---
## 🗄️ Datenbank-Setup
### Initialisierung
**Beide Datenbanken erstellen:**
```bash
npm run db:init
```
**Nur database.sqlite (User/Devices):**
```bash
npm run db:init:app
```
**Nur locations.sqlite (Tracking):**
```bash
npm run db:init:locations
```
### Datenbank zurücksetzen
**Admin-User neu anlegen:**
```bash
node scripts/reset-admin.js
```
**Alte Locations löschen:**
```bash
npm run db:cleanup # Älter als 7 Tage
npm run db:cleanup:7d # Älter als 7 Tage
npm run db:cleanup:30d # Älter als 30 Tage
```
**Duplikate entfernen (falls vorhanden):**
```bash
node scripts/remove-duplicates.js
```
### Schema
#### **database.sqlite** (User & Devices)
**User Tabelle:**
```sql
id TEXT PRIMARY KEY
username TEXT UNIQUE NOT NULL
email TEXT
passwordHash TEXT NOT NULL
role TEXT NOT NULL DEFAULT 'VIEWER' -- ADMIN oder VIEWER
createdAt TEXT DEFAULT (datetime('now'))
updatedAt TEXT DEFAULT (datetime('now'))
lastLoginAt TEXT
```
**Device Tabelle:**
```sql
id TEXT PRIMARY KEY
name TEXT NOT NULL
color TEXT NOT NULL
ownerId TEXT -- FK zu User.id
isActive INTEGER DEFAULT 1 -- 0 oder 1
description TEXT
icon TEXT
createdAt TEXT DEFAULT (datetime('now'))
updatedAt TEXT DEFAULT (datetime('now'))
```
**Indexes:**
- `idx_user_username` ON User(username)
- `idx_device_owner` ON Device(ownerId)
- `idx_device_active` ON Device(isActive)
---
#### **locations.sqlite** (Tracking Data)
**Location Tabelle:**
```sql
id INTEGER PRIMARY KEY AUTOINCREMENT
latitude REAL NOT NULL -- -90 bis +90
longitude REAL NOT NULL -- -180 bis +180
timestamp TEXT NOT NULL -- ISO 8601 format
user_id INTEGER DEFAULT 0
first_name TEXT
last_name TEXT
username TEXT -- Device Tracker ID
marker_label TEXT
display_time TEXT -- Formatierte Zeit für UI
chat_id INTEGER DEFAULT 0
battery INTEGER -- Batteriestand in %
speed REAL -- Geschwindigkeit in km/h
created_at TEXT DEFAULT (datetime('now'))
```
**Indexes:**
- `idx_location_timestamp` ON Location(timestamp DESC)
- `idx_location_username` ON Location(username)
- `idx_location_user_id` ON Location(user_id)
- `idx_location_composite` ON Location(user_id, username, timestamp DESC)
- `idx_location_unique` UNIQUE ON Location(timestamp, username, latitude, longitude)
**Constraints:**
- Latitude: -90 bis +90
- Longitude: -180 bis +180
- UNIQUE: Kombination aus timestamp, username, latitude, longitude verhindert Duplikate
**WAL Mode:** Beide Datenbanken nutzen Write-Ahead Logging für bessere Concurrency
---
## 🚀 Verwendung
### Login
Standard-Zugangsdaten:
```
Benutzername: admin
Passwort: admin123
```
**Wichtig:** Für Production neuen User anlegen und Passwort ändern!
### Geräte hinzufügen
1. Admin-Panel öffnen: `/admin/devices`
2. "Add Device" klicken
3. Device ID (muss mit OwnTracks `tid` übereinstimmen)
4. Name und Farbe festlegen
5. Speichern
**Wichtig:** Die Device ID muss mit der OwnTracks Tracker-ID übereinstimmen!
### OwnTracks konfigurieren
In der OwnTracks App:
- **Tracker ID (tid):** z.B. `12`
- **Topic:** `owntracks/user/12`
- MQTT Broker wie gewohnt
Die n8n-Workflow holt die Daten, und die App synct automatisch alle 5 Sekunden.
### Zeitfilter verwenden
Die App bietet zwei Modi für die Zeitfilterung:
#### **Quick Filters** (Schnellauswahl)
Vordefinierte Zeiträume für schnellen Zugriff:
- **1 Hour** - Locations der letzten Stunde
- **3 Hours** - Locations der letzten 3 Stunden
- **6 Hours** - Locations der letzten 6 Stunden
- **12 Hours** - Locations der letzten 12 Stunden
- **24 Hours** - Locations der letzten 24 Stunden
- **All** - Alle verfügbaren Locations
**Verwendung:**
1. Im Header unter "Time:" gewünschten Zeitraum auswählen
2. Die Karte aktualisiert sich automatisch
#### **Custom Range** (Benutzerdefiniert)
Für spezifische Zeiträume, z.B. "Route von gestern Abend 18:00 bis heute Morgen 08:00":
**Verwendung:**
1. Auf den **"📅 Custom"** Button klicken
2. Custom Range Felder erscheinen:
- **From:** Start-Datum und -Zeit wählen (z.B. 16.11.2025 16:00)
- **To:** End-Datum und -Zeit wählen (z.B. 17.11.2025 06:00)
3. Die Route wird automatisch für den gewählten Zeitraum angezeigt
4. Zum Zurückschalten: **"📅 Quick"** Button klicken
**Hinweis:** Custom Range Controls sind nur sichtbar wenn aktiviert - spart Platz im Header!
---
## 🏗 Architektur
### Datenfluss
```mermaid
flowchart TD
A[📱 OwnTracks App] -->|MQTT Publish| B[🔌 MQTT Broker]
B -->|Subscribe| C[⚙️ n8n MQTT Trigger]
C -->|Store| D[💾 NocoDB]
D -->|Webhook API| E[🌐 n8n Webhook<br/>/webhook/location]
F[🖥️ Browser Client] -->|GET /api/locations<br/>alle 5 Sek| G[📡 Next.js API Route]
G -->|1. Fetch Fresh Data| E
E -->|JSON Response| G
G -->|2. Sync New Locations| H[(🗄️ SQLite Cache<br/>locations.sqlite)]
H -->|3. Query Filtered Data| G
G -->|JSON Response| F
F -->|Render| I[🗺️ React Leaflet Map]
J[👤 Admin User] -->|Login| K[🔐 NextAuth.js]
K -->|Authenticated| L[📊 Admin Panel]
L -->|CRUD Operations| M[(💼 SQLite DB<br/>database.sqlite)]
style A fill:#4CAF50
style B fill:#FF9800
style C fill:#2196F3
style D fill:#9C27B0
style E fill:#F44336
style G fill:#00BCD4
style H fill:#FFC107
style I fill:#8BC34A
style K fill:#E91E63
style M fill:#FFC107
```
### Komponenten-Übersicht
```mermaid
graph LR
subgraph "External Services"
A[OwnTracks App]
B[MQTT Broker]
C[n8n Automation]
D[NocoDB]
end
subgraph "Next.js Application"
E[Frontend<br/>React/Leaflet]
F[API Routes]
G[Auth Layer<br/>NextAuth.js]
end
subgraph "Data Layer"
H[locations.sqlite<br/>Tracking Data]
I[database.sqlite<br/>Users & Devices]
end
A -->|MQTT| B
B -->|Subscribe| C
C -->|Store| D
C -->|Webhook| F
E -->|HTTP| F
F -->|Read/Write| H
F -->|Read/Write| I
E -->|Auth| G
G -->|Validate| I
style A fill:#4CAF50,color:#fff
style B fill:#FF9800,color:#fff
style C fill:#2196F3,color:#fff
style D fill:#9C27B0,color:#fff
style E fill:#00BCD4,color:#000
style F fill:#00BCD4,color:#000
style G fill:#E91E63,color:#fff
style H fill:#FFC107,color:#000
style I fill:#FFC107,color:#000
```
### Datenbank-Architektur
```mermaid
erDiagram
USER ||--o{ DEVICE : owns
DEVICE ||--o{ LOCATION : tracks
USER {
string id PK
string username UK
string email
string passwordHash
string role
datetime createdAt
datetime lastLoginAt
}
DEVICE {
string id PK
string name
string color
string ownerId FK
boolean isActive
string description
datetime createdAt
}
LOCATION {
int id PK
float latitude
float longitude
datetime timestamp
string username FK
int user_id
string display_time
int battery
float speed
int chat_id
}
```
### Auto-Sync Mechanismus
Die App verwendet einen **Hybrid-Ansatz**:
1. **Frontend polling** (alle 5 Sek.) `/api/locations`
2. **API prüft** ob neue Daten in n8n verfügbar
3. **Nur neue Locations** werden in SQLite gespeichert
4. **Duplikate** werden durch UNIQUE Index verhindert
5. **Antwort** kommt aus lokalem SQLite Cache
**Vorteile:**
- Schnelle Antwortzeiten (SQLite statt n8n)
- Längere Zeiträume abrufbar (24h+)
- Funktioniert auch wenn n8n nicht erreichbar ist (Fallback auf n8n-Daten)
- Duplikate werden automatisch verhindert (UNIQUE Index)
### Datenvalidierung & Normalisierung
Die App behandelt spezielle Fälle bei speed/battery korrekt:
**speed = 0 Behandlung:**
- n8n sendet `speed: 0` (gültig - Gerät steht still)
- Wird mit `typeof === 'number'` Check explizit als `0` gespeichert
- Wird NICHT zu `null` konvertiert (wichtig für Telemetrie)
- Popup zeigt "Speed: 0.0 km/h" an
**n8n Fallback:**
- Wenn DB leer ist, gibt API direkt n8n-Daten zurück
- Alle Filter (username, timeRangeHours) funktionieren auch mit Fallback
- Ermöglicht sofortigen Betrieb ohne DB-Initialisierung
**Debug-Logging:**
- Server-Logs zeigen n8n Sync-Aktivität
- Browser Console zeigt Daten-Flow (MapView, Popup)
- Hilfreich für Troubleshooting
---
## 📡 API-Endpunkte
### Öffentlich
**GET /api/locations**
- Location-Daten abrufen (mit Auto-Sync)
- Query-Parameter:
- `username` - Device-Filter (z.B. "10", "11")
- **Zeitfilter (wähle eine Methode):**
- `timeRangeHours` - Quick Filter (1, 3, 6, 12, 24)
- `startTime` & `endTime` - Custom Range (ISO 8601 Format)
- `limit` - Max. Anzahl (Standard: 1000)
- `sync=false` - Nur Cache ohne n8n Sync
**Beispiele:**
```bash
# Quick Filter: Letzte 3 Stunden
GET /api/locations?timeRangeHours=3
# Custom Range: Spezifischer Zeitraum
GET /api/locations?startTime=2025-11-16T16:00:00.000Z&endTime=2025-11-17T06:00:00.000Z
# Kombiniert: Device + Custom Range
GET /api/locations?username=10&startTime=2025-11-16T16:00:00.000Z&endTime=2025-11-17T06:00:00.000Z
```
**GET /api/devices/public**
- Öffentliche Device-Liste (nur ID, Name, Color)
### Geschützt (Login erforderlich)
**GET /api/devices**
- Alle Geräte mit Latest Location und Telemetrie
**POST /api/devices**
- Neues Gerät erstellen
- Body: `{ id, name, color, description? }`
**PATCH /api/devices/:id**
- Gerät aktualisieren
- Body: `{ name?, color?, description? }`
**DELETE /api/devices/:id**
- Gerät löschen (soft delete)
**POST /api/locations/sync**
- Manueller Sync von n8n
- Gibt Anzahl neu eingefügter Locations zurück
**POST /api/locations/cleanup**
- Alte Locations löschen
- Body: `{ retentionHours }`
**POST /api/locations/optimize**
- VACUUM + ANALYZE ausführen
- Gibt freigegebenen Speicher zurück
**GET /api/locations/stats**
- Detaillierte DB-Statistiken
- Größe, Zeitraum, Locations pro Device
**GET /api/system/status**
- System-Status abrufen
- Returns: Uptime, Memory Usage, Node.js Version, Platform
- Auto-Refresh: Alle 10 Sekunden im Admin-Panel
---
## 📱 Device Management
### Device-Karte zeigt:
- 🟢/⚫ **Online/Offline Status**
- Online = letzte Location < 10 Minuten
- Offline = letzte Location > 10 Minuten
- 🕒 **Last Seen** - Zeitstempel letzter Location
- 🔋 **Batterie** - Prozent (Rot bei < 20%)
- 🚗 **Geschwindigkeit** - km/h (umgerechnet von m/s)
- 📍 **Koordinaten** - Lat/Lon mit 5 Dezimalen
### Auto-Refresh
- Devices-Seite aktualisiert sich alle 10 Sekunden
- Online/Offline Status wird automatisch aktualisiert
---
## 🧹 Wartung
### Datenbank aufräumen
**Via Admin-Panel:**
- `/admin` Database Maintenance Cleanup Buttons
**Via CLI:**
```bash
npm run db:cleanup # 7 Tage
npm run db:cleanup:30d # 30 Tage
```
### Datenbank optimieren
**Via Admin-Panel:**
- `/admin` Database Maintenance Optimize Button
**Via CLI:**
```bash
# Manuell
node scripts/optimize-db.js
```
**Was macht Optimize:**
- `VACUUM` - Speicherplatz freigeben
- `ANALYZE` - Query-Statistiken aktualisieren
### Sync von n8n
**Via Admin-Panel:**
- `/admin` Database Maintenance Sync Now
**Automatisch:**
- Passiert alle 5 Sekunden beim Abruf der Karte
### Logs prüfen
```bash
# Development Server Logs
npm run dev
# Production Logs (PM2)
pm2 logs location-tracker-app
```
### Zeitfilter debuggen
Bei Problemen mit der Zeitfilterung (z.B. alte Locations werden nicht ausgefiltert):
```bash
node scripts/test-time-filter.js
```
**Das Script zeigt:**
- Aktuelle Zeit (UTC und lokal)
- Letzte Locations in der Datenbank
- Welche Locations mit 1-Stunden-Filter angezeigt werden sollten
- Vergleich zwischen alter (SQLite datetime) und neuer (JavaScript) Methode
- Anzahl der gefilterten Locations
**Verwendung:**
1. Script ausführen um zu sehen, welche Locations in der DB sind
2. Überprüfen ob die Zeitfilterung korrekt funktioniert
3. Bei Problemen: App neu starten nach Code-Updates
---
## 🚀 Deployment
### Environment Variables
Erstelle `.env.local`:
```env
# NextAuth
AUTH_SECRET=<generiere-mit-openssl-rand-base64-32>
NEXTAUTH_URL=https://your-domain.com
# Optional: n8n API URL (Standard in Code definiert)
N8N_API_URL=https://n8n.example.com/webhook/location
```
**Secret generieren:**
```bash
openssl rand -base64 32
```
### Production Build
```bash
# Build
npm run build
# Start
npm run start
```
### Mit PM2 (empfohlen)
```bash
# PM2 installieren
npm install -g pm2
# App starten
pm2 start npm --name "location-tracker-app" -- start
# Auto-Start bei Server-Neustart
pm2 startup
pm2 save
```
### Nginx Reverse Proxy
```nginx
server {
listen 80;
server_name your-domain.com;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
```
---
## 🔒 Sicherheit
### Production Checklist
- [ ] `AUTH_SECRET` mit starkem Wert setzen
- [ ] `NEXTAUTH_URL` auf Production-Domain setzen
- [ ] Admin-Passwort ändern (nicht `admin123`)
- [ ] Ggf. weitere User anlegen mit VIEWER Rolle
- [ ] HTTPS aktivieren (Let's Encrypt)
- [ ] Firewall-Regeln prüfen
- [ ] Regelmäßige Backups einrichten
### User-Rollen
- **ADMIN** - Voller Zugriff auf alle Admin-Funktionen
- **VIEWER** - Nur lesender Zugriff (geplant, noch nicht implementiert)
---
## 📂 Projektstruktur
```
location-tracker-app/
├── app/
│ ├── api/
│ │ ├── auth/[...nextauth]/ # NextAuth API
│ │ ├── devices/ # Device CRUD
│ │ ├── locations/ # Location API + Sync/Cleanup/Stats
│ │ └── system/status/ # System Status (Uptime, Memory)
│ ├── admin/
│ │ ├── devices/ # Device Management
│ │ └── page.tsx # Dashboard
│ ├── login/ # Login-Seite
│ ├── page.tsx # Öffentliche Karte
│ └── layout.tsx # Root Layout
├── components/
│ └── map/
│ └── MapView.tsx # Leaflet Map Component
├── lib/
│ ├── auth.ts # NextAuth Config
│ └── db.ts # SQLite Database Layer
├── scripts/
│ ├── init-database.js # Database.sqlite Setup
│ ├── init-locations-db.js # Locations.sqlite Setup
│ ├── reset-admin.js # Admin User Reset
│ ├── remove-duplicates.js # Duplikate bereinigen
│ └── cleanup-old-locations.js # Alte Daten löschen
├── data/
│ ├── database.sqlite # User + Devices
│ └── locations.sqlite # Location Tracking
├── types/
│ └── location.ts # TypeScript Interfaces
└── middleware.ts # Route Protection
```
---
## 📝 Changelog
### Version 1.1.0 - November 2025
#### 🆕 Neue Features
- **Custom Date Range Filter**
- Benutzerdefinierte Zeiträume mit DateTime-Picker (z.B. 16.11.2025 16:00 - 17.11.2025 06:00)
- Toggle-Button zwischen Quick Filters und Custom Range
- Kompakte UI - Custom Range nur sichtbar wenn aktiviert
- Backend-Support mit `startTime` und `endTime` API-Parametern
#### 🐛 Bug Fixes
- **Zeitfilter-Bug behoben**
- Alte Locations (z.B. 6+ Stunden alt) werden jetzt korrekt ausgefiltert
- JavaScript-basierte Zeitberechnung statt SQLite `datetime('now')`
- Verhindert Zeitversatz-Probleme
#### 🛠️ Verbesserungen
- Zoom-basierte Icon-Skalierung für bessere Sichtbarkeit
- Optimierte Zeitfilter-Logik in Datenbank-Queries
- Debug-Script `test-time-filter.js` für Troubleshooting
#### 📚 Dokumentation
- README aktualisiert mit Custom Range Anleitung
- API-Endpunkte Dokumentation erweitert
- Wartungs-Abschnitt mit Debug-Script Information
---
## 🐛 Troubleshooting
### "Invalid username or password"
**Lösung:**
```bash
node scripts/reset-admin.js
```
### Datenbank-Dateien fehlen
**Lösung:**
```bash
npm run db:init
```
### Duplikate in locations.sqlite
**Lösung:**
```bash
# Erst Duplikate entfernen
node scripts/remove-duplicates.js
# Dann UNIQUE Index hinzufügen
node scripts/init-locations-db.js
```
### Map zeigt keine Daten
1. n8n Webhook erreichbar? `curl https://n8n.example.com/webhook/location`
2. Locations in Datenbank? `/admin` Database Statistics prüfen
3. Auto-Sync aktiv? Browser Console öffnen
### "ENOENT: no such file or directory, open 'data/database.sqlite'"
**Lösung:**
```bash
mkdir -p data
npm run db:init
```
---
## 📝 NPM Scripts
```bash
# Development
npm run dev # Dev Server starten
# Production
npm run build # Production Build
npm run start # Production Server
# Database
npm run db:init # Beide DBs initialisieren
npm run db:init:app # Nur database.sqlite
npm run db:init:locations # Nur locations.sqlite
npm run db:cleanup # Cleanup 7 Tage
npm run db:cleanup:7d # Cleanup 7 Tage
npm run db:cleanup:30d # Cleanup 30 Tage
# Linting
npm run lint # ESLint ausführen
```
---
## 📄 Lizenz
MIT License - Open Source
---
## 📜 Open Source Lizenzen
Diese Anwendung verwendet folgende Open-Source-Software:
### MIT License
- [Next.js](https://github.com/vercel/next.js) - Copyright (c) Vercel, Inc.
- [React](https://github.com/facebook/react) - Copyright (c) Meta Platforms, Inc.
- [React-DOM](https://github.com/facebook/react) - Copyright (c) Meta Platforms, Inc.
- [Tailwind CSS](https://github.com/tailwindlabs/tailwindcss) - Copyright (c) Tailwind Labs, Inc.
- [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) - Copyright (c) Joshua Wise
- [bcryptjs](https://github.com/dcodeIO/bcrypt.js) - Copyright (c) Daniel Wirtz
### Apache License 2.0
- [TypeScript](https://github.com/microsoft/TypeScript) - Copyright (c) Microsoft Corporation
### ISC License
- [NextAuth.js](https://github.com/nextauthjs/next-auth) - Copyright (c) NextAuth.js Contributors
### BSD-2-Clause License
- [Leaflet](https://github.com/Leaflet/Leaflet) - Copyright (c) Vladimir Agafonkin
### Hippocratic License 2.1
- [React-Leaflet](https://github.com/PaulLeCam/react-leaflet) - Copyright (c) Paul Le Cam
**Vollständige Lizenztexte:**
Alle vollständigen Lizenztexte der verwendeten Bibliotheken finden Sie in den jeweiligen GitHub-Repositories oder in der `node_modules` Directory nach Installation.
---
## 🙏 Credits
- **Next.js 14** - React Framework
- **Leaflet.js** - Karten-Bibliothek
- **NextAuth.js** - Authentifizierung
- **better-sqlite3** - SQLite für Node.js
- **Tailwind CSS** - Utility-First CSS
- **n8n** - Workflow Automation (Backend)
- **OwnTracks** - Location Tracking Apps
---
## 📞 Support
Bei Fragen oder Problemen:
1. Logs prüfen (`npm run dev` Output)
2. Browser Console öffnen (F12)
3. Datenbank-Status in `/admin` prüfen
4. Issues im Repository erstellen

595
app/admin/devices/page.tsx Normal file
View File

@@ -0,0 +1,595 @@
"use client";
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
interface Device {
id: string;
name: string;
color: string;
description?: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
owner?: {
id: string;
username: string;
};
latestLocation?: {
latitude: string | number;
longitude: string | number;
timestamp: string;
battery?: number;
speed?: number;
};
_count?: {
locations: number;
};
}
export default function DevicesPage() {
const { data: session } = useSession();
const userRole = (session?.user as any)?.role;
const isAdmin = userRole === 'ADMIN';
const [devices, setDevices] = useState<Device[]>([]);
const [loading, setLoading] = useState(true);
const [showAddModal, setShowAddModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [selectedDevice, setSelectedDevice] = useState<Device | null>(null);
const [error, setError] = useState<string | null>(null);
// Form state
const [formData, setFormData] = useState({
id: "",
name: "",
color: "#3498db",
description: "",
});
useEffect(() => {
fetchDevices();
// Auto-refresh every 10 seconds to update online/offline status
const interval = setInterval(fetchDevices, 10000);
return () => clearInterval(interval);
}, []);
const fetchDevices = async () => {
try {
const response = await fetch("/api/devices");
if (!response.ok) throw new Error("Failed to fetch devices");
const data = await response.json();
setDevices(data.devices || []);
setError(null);
} catch (err) {
console.error("Failed to fetch devices", err);
setError("Failed to load devices");
} finally {
setLoading(false);
}
};
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
try {
const response = await fetch("/api/devices", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to create device");
}
await fetchDevices();
setShowAddModal(false);
setFormData({ id: "", name: "", color: "#3498db", description: "" });
} catch (err: any) {
alert(err.message);
}
};
const handleEdit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedDevice) return;
try {
// If ID changed, we need to delete old and create new device
if (formData.id !== selectedDevice.id) {
// Delete old device
const deleteResponse = await fetch(`/api/devices/${selectedDevice.id}`, {
method: "DELETE",
});
if (!deleteResponse.ok) {
throw new Error("Failed to delete old device");
}
// Create new device with new ID
const createResponse = await fetch("/api/devices", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
if (!createResponse.ok) {
const error = await createResponse.json();
throw new Error(error.error || "Failed to create device with new ID");
}
} else {
// Just update existing device
const response = await fetch(`/api/devices/${selectedDevice.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: formData.name,
color: formData.color,
description: formData.description,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to update device");
}
}
await fetchDevices();
setShowEditModal(false);
setSelectedDevice(null);
} catch (err: any) {
alert(err.message);
}
};
const handleDelete = async () => {
if (!selectedDevice) return;
try {
const response = await fetch(`/api/devices/${selectedDevice.id}`, {
method: "DELETE",
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to delete device");
}
await fetchDevices();
setShowDeleteModal(false);
setSelectedDevice(null);
} catch (err: any) {
alert(err.message);
}
};
const openEditModal = (device: Device) => {
setSelectedDevice(device);
setFormData({
id: device.id,
name: device.name,
color: device.color,
description: device.description || "",
});
setShowEditModal(true);
};
const openDeleteModal = (device: Device) => {
setSelectedDevice(device);
setShowDeleteModal(true);
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-600">Loading devices...</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h2 className="text-3xl font-bold text-gray-900">Device Management</h2>
{!isAdmin && (
<p className="text-sm text-gray-600 mt-1">Read-only view</p>
)}
</div>
{isAdmin && (
<button
onClick={() => setShowAddModal(true)}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 font-medium"
>
+ Add Device
</button>
)}
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
{/* Device Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{devices.map((device) => {
const lastSeen = device.latestLocation
? new Date(device.latestLocation.timestamp)
: null;
const isRecent = lastSeen
? Date.now() - lastSeen.getTime() < 10 * 60 * 1000
: false;
return (
<div
key={device.id}
className="bg-white rounded-lg shadow-md p-6 space-y-4 border-2"
style={{ borderColor: device.isActive ? device.color : "#ccc" }}
>
<div className="flex justify-between items-start">
<div className="flex items-center gap-3">
<div
className="w-12 h-12 rounded-full border-2 border-white shadow-md flex items-center justify-center"
style={{ backgroundColor: device.color }}
>
<span className="text-white text-2xl">📱</span>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">
{device.name}
</h3>
<p className="text-sm text-gray-500">ID: {device.id}</p>
</div>
</div>
<span
className={`px-3 py-1 rounded-full text-xs font-medium ${
isRecent
? "bg-green-100 text-green-800"
: "bg-gray-100 text-gray-800"
}`}
>
{isRecent ? "Online" : "Offline"}
</span>
</div>
{device.description && (
<p className="text-sm text-gray-600">{device.description}</p>
)}
{device.latestLocation && (
<div className="border-t border-gray-200 pt-4 space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600 flex items-center gap-2">
<span className="text-lg">🕒</span>
Last Seen:
</span>
<span className="font-medium text-gray-900">
{new Date(device.latestLocation.timestamp).toLocaleString()}
</span>
</div>
{device.latestLocation.battery !== undefined && (
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600 flex items-center gap-2">
<span className="text-lg">🔋</span>
Battery:
</span>
<span className={`font-medium ${
device.latestLocation.battery > 20 ? 'text-green-600' : 'text-red-600'
}`}>
{device.latestLocation.battery}%
</span>
</div>
)}
{device.latestLocation.speed !== undefined && (
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600 flex items-center gap-2">
<span className="text-lg">🚗</span>
Speed:
</span>
<span className="font-medium text-gray-900">
{(Number(device.latestLocation.speed) * 3.6).toFixed(1)} km/h
</span>
</div>
)}
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600 flex items-center gap-2">
<span className="text-lg">📍</span>
Location:
</span>
<span className="font-medium text-gray-900">
{Number(device.latestLocation.latitude).toFixed(5)},{" "}
{Number(device.latestLocation.longitude).toFixed(5)}
</span>
</div>
</div>
)}
{device._count && (
<div className="text-sm text-gray-600">
{device._count.locations} location points
</div>
)}
{isAdmin && (
<div className="flex gap-2 pt-2">
<button
onClick={() => openEditModal(device)}
className="flex-1 px-3 py-2 bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200 text-sm font-medium"
>
Edit
</button>
<button
onClick={() => openDeleteModal(device)}
className="flex-1 px-3 py-2 bg-red-100 text-red-700 rounded-md hover:bg-red-200 text-sm font-medium"
>
Delete
</button>
</div>
)}
</div>
);
})}
</div>
{devices.length === 0 && (
<div className="bg-white rounded-lg shadow p-8 text-center text-gray-500">
No devices found. Add a device to get started.
</div>
)}
{/* Add Device Modal */}
{showAddModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg max-w-md w-full p-6 space-y-4">
<h3 className="text-xl font-bold text-gray-900">Add New Device</h3>
<form onSubmit={handleAdd} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Device ID *
</label>
<input
type="text"
required
value={formData.id}
onChange={(e) =>
setFormData({ ...formData, id: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="e.g., 12, 13"
/>
<p className="text-xs text-gray-500 mt-1">
Must match OwnTracks tracker ID (tid)
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Device Name *
</label>
<input
type="text"
required
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="e.g., iPhone 13"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Color
</label>
<div className="flex gap-2">
<input
type="color"
value={formData.color}
onChange={(e) =>
setFormData({ ...formData, color: e.target.value })
}
className="h-10 w-20 rounded border border-gray-300"
/>
<input
type="text"
value={formData.color}
onChange={(e) =>
setFormData({ ...formData, color: e.target.value })
}
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="#3498db"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description (optional)
</label>
<textarea
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={3}
placeholder="Additional notes about this device"
/>
</div>
<div className="flex gap-2 pt-4">
<button
type="button"
onClick={() => {
setShowAddModal(false);
setFormData({
id: "",
name: "",
color: "#3498db",
description: "",
});
}}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 font-medium"
>
Add Device
</button>
</div>
</form>
</div>
</div>
)}
{/* Edit Device Modal */}
{showEditModal && selectedDevice && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg max-w-md w-full p-6 space-y-4">
<h3 className="text-xl font-bold text-gray-900">
Edit Device
</h3>
<form onSubmit={handleEdit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Device ID *
</label>
<input
type="text"
required
value={formData.id}
onChange={(e) =>
setFormData({ ...formData, id: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<p className="text-xs text-amber-600 mt-1">
Changing ID will create a new device (location history stays with old ID)
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Device Name *
</label>
<input
type="text"
required
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Color
</label>
<div className="flex gap-2">
<input
type="color"
value={formData.color}
onChange={(e) =>
setFormData({ ...formData, color: e.target.value })
}
className="h-10 w-20 rounded border border-gray-300"
/>
<input
type="text"
value={formData.color}
onChange={(e) =>
setFormData({ ...formData, color: e.target.value })
}
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description (optional)
</label>
<textarea
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={3}
/>
</div>
<div className="flex gap-2 pt-4">
<button
type="button"
onClick={() => {
setShowEditModal(false);
setSelectedDevice(null);
}}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 font-medium"
>
Save Changes
</button>
</div>
</form>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{showDeleteModal && selectedDevice && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg max-w-md w-full p-6 space-y-4">
<h3 className="text-xl font-bold text-gray-900">Delete Device</h3>
<p className="text-gray-600">
Are you sure you want to delete <strong>{selectedDevice.name}</strong>{" "}
(ID: {selectedDevice.id})?
</p>
<p className="text-sm text-red-600">
This will also delete all location history for this device. This action
cannot be undone.
</p>
<div className="flex gap-2 pt-4">
<button
onClick={() => {
setShowDeleteModal(false);
setSelectedDevice(null);
}}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
>
Cancel
</button>
<button
onClick={handleDelete}
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 font-medium"
>
Delete Device
</button>
</div>
</div>
</div>
)}
</div>
);
}

171
app/admin/emails/page.tsx Normal file
View File

@@ -0,0 +1,171 @@
"use client";
import { useState } from "react";
import { EMAIL_TEMPLATES, EmailTemplate } from "@/lib/types/smtp";
export default function EmailsPage() {
const [selectedTemplate, setSelectedTemplate] = useState<string>('welcome');
const [testEmail, setTestEmail] = useState('');
const [sending, setSending] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [showSendModal, setShowSendModal] = useState(false);
const handleSendTest = async () => {
if (!testEmail) {
alert('Please enter a test email address');
return;
}
setSending(true);
setMessage(null);
try {
const response = await fetch('/api/admin/emails/send-test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
template: selectedTemplate,
email: testEmail,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to send');
}
setMessage({ type: 'success', text: data.message });
setShowSendModal(false);
setTestEmail('');
} catch (error: any) {
setMessage({ type: 'error', text: error.message || 'Failed to send test email' });
} finally {
setSending(false);
}
};
const previewUrl = `/api/admin/emails/preview?template=${selectedTemplate}`;
return (
<div>
<h2 className="text-3xl font-bold text-gray-900 mb-6">Email Templates</h2>
{/* Status Message */}
{message && (
<div
className={`mb-6 p-4 rounded ${
message.type === 'success'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{message.text}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Template List */}
<div className="lg:col-span-1">
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">Templates</h3>
</div>
<div className="p-4">
<div className="space-y-2">
{EMAIL_TEMPLATES.map((template) => (
<button
key={template.name}
onClick={() => setSelectedTemplate(template.name)}
className={`w-full text-left px-4 py-3 rounded-md transition-colors ${
selectedTemplate === template.name
? 'bg-blue-600 text-white'
: 'bg-gray-50 hover:bg-gray-100 text-gray-900'
}`}
>
<p className="font-medium">{template.subject}</p>
<p className={`text-sm mt-1 ${
selectedTemplate === template.name
? 'text-blue-100'
: 'text-gray-600'
}`}>
{template.description}
</p>
</button>
))}
</div>
</div>
</div>
{/* Send Test Button */}
<button
onClick={() => setShowSendModal(true)}
className="w-full mt-4 px-4 py-3 bg-green-600 text-white rounded-md hover:bg-green-700 font-medium"
>
Send Test Email
</button>
</div>
{/* Preview */}
<div className="lg:col-span-2">
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
<h3 className="text-lg font-semibold text-gray-900">Preview</h3>
<span className="text-sm text-gray-600">
{EMAIL_TEMPLATES.find(t => t.name === selectedTemplate)?.subject}
</span>
</div>
<div className="p-4">
<iframe
src={previewUrl}
className="w-full border border-gray-300 rounded"
style={{ height: '600px' }}
title="Email Preview"
/>
</div>
</div>
</div>
</div>
{/* Send Test Email Modal */}
{showSendModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h3 className="text-xl font-bold mb-4">Send Test Email</h3>
<p className="text-sm text-gray-600 mb-2">
Template: <strong>{EMAIL_TEMPLATES.find(t => t.name === selectedTemplate)?.subject}</strong>
</p>
<p className="text-sm text-gray-600 mb-4">
Enter your email address to receive a test email.
</p>
<input
type="email"
value={testEmail}
onChange={(e) => setTestEmail(e.target.value)}
placeholder="your-email@example.com"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 mb-4"
/>
<div className="flex gap-3">
<button
onClick={() => {
setShowSendModal(false);
setTestEmail('');
}}
className="flex-1 px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-100"
>
Cancel
</button>
<button
onClick={handleSendTest}
disabled={sending || !testEmail}
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:bg-gray-400"
>
{sending ? 'Sending...' : 'Send Test'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

106
app/admin/layout.tsx Normal file
View File

@@ -0,0 +1,106 @@
"use client";
import { signOut, useSession } from "next-auth/react";
import Link from "next/link";
import { usePathname } from "next/navigation";
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
const { data: session } = useSession();
const userRole = (session?.user as any)?.role;
const username = session?.user?.name || '';
const isAdmin = userRole === 'ADMIN';
const isSuperAdmin = username === 'admin';
const allNavigation = [
{ name: "Dashboard", href: "/admin", roles: ['ADMIN', 'VIEWER'], superAdminOnly: false },
{ name: "Devices", href: "/admin/devices", roles: ['ADMIN', 'VIEWER'], superAdminOnly: false },
{ name: "MQTT Provisioning", href: "/admin/mqtt", roles: ['ADMIN'], superAdminOnly: false },
{ name: "Setup Guide", href: "/admin/setup", roles: ['ADMIN', 'VIEWER'], superAdminOnly: false },
{ name: "Users", href: "/admin/users", roles: ['ADMIN'], superAdminOnly: false },
{ name: "Settings", href: "/admin/settings", roles: ['ADMIN'], superAdminOnly: true },
{ name: "Emails", href: "/admin/emails", roles: ['ADMIN'], superAdminOnly: true },
];
// Filter navigation based on user role and super admin status
const navigation = allNavigation.filter(item => {
const hasRole = item.roles.includes(userRole as string);
const hasAccess = item.superAdminOnly ? isSuperAdmin : true;
return hasRole && hasAccess;
});
return (
<div className="min-h-screen bg-gray-100">
{/* Header */}
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
{/* Top row: Title + User Info + Actions */}
<div className="flex justify-between items-center mb-3 lg:mb-0">
<div>
<h1 className="text-xl sm:text-2xl font-bold text-black">
{isAdmin ? 'Admin Panel' : 'Dashboard'}
</h1>
</div>
<div className="flex gap-2 sm:gap-4 items-center">
{/* User info */}
<div className="flex items-center gap-2 text-xs sm:text-sm">
<span className="text-gray-600">Angemeldet als:</span>
<span className="font-semibold text-black">{username || session?.user?.email}</span>
{!isAdmin && (
<span className="px-2 py-0.5 bg-blue-100 text-blue-800 rounded text-xs">Viewer</span>
)}
{isAdmin && (
<span className="px-2 py-0.5 bg-red-100 text-red-800 rounded text-xs">Admin</span>
)}
</div>
{/* Actions */}
<div className="flex gap-2 items-center border-l border-gray-300 pl-2 sm:pl-4">
<Link
href="/map"
className="px-2 sm:px-4 py-2 text-sm text-black font-semibold hover:text-blue-600"
>
Map
</Link>
<button
onClick={async () => {
await signOut({ redirect: false });
window.location.href = '/login';
}}
className="px-2 sm:px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 text-sm font-semibold"
>
Logout
</button>
</div>
</div>
</div>
{/* Navigation row (scrollable on mobile) */}
<nav className="flex gap-2 overflow-x-auto lg:gap-4 pb-2 lg:pb-0 -mx-4 px-4 sm:mx-0 sm:px-0">
{navigation.map((item) => (
<Link
key={item.href}
href={item.href}
className={`px-3 py-2 rounded-md text-sm font-semibold transition-colors whitespace-nowrap ${
pathname === item.href
? "bg-blue-600 text-white"
: "text-black hover:bg-gray-200"
}`}
>
{item.name}
</Link>
))}
</nav>
</div>
</header>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{children}
</main>
</div>
);
}

638
app/admin/mqtt/page.tsx Normal file
View File

@@ -0,0 +1,638 @@
"use client";
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
interface Device {
id: string;
name: string;
isActive: boolean;
}
interface MqttCredential {
id: number;
device_id: string;
mqtt_username: string;
mqtt_password_hash: string;
enabled: number;
created_at: string;
updated_at: string;
device_name: string;
mqtt_password?: string; // Nur bei Erstellung/Regenerierung vorhanden
}
interface AclRule {
id: number;
device_id: string;
topic_pattern: string;
permission: 'read' | 'write' | 'readwrite';
created_at: string;
}
interface SyncStatus {
pending_changes: number;
last_sync_at: string | null;
last_sync_status: string;
}
export default function MqttPage() {
const { data: session } = useSession();
const userRole = (session?.user as any)?.role;
const isAdmin = userRole === 'ADMIN';
const [devices, setDevices] = useState<Device[]>([]);
const [credentials, setCredentials] = useState<MqttCredential[]>([]);
const [aclRules, setAclRules] = useState<Record<string, AclRule[]>>({});
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
// Modal States
const [showAddModal, setShowAddModal] = useState(false);
const [showPasswordModal, setShowPasswordModal] = useState(false);
const [showAclModal, setShowAclModal] = useState(false);
const [selectedDevice, setSelectedDevice] = useState<string | null>(null);
const [generatedPassword, setGeneratedPassword] = useState<string>("");
// Form States
const [addFormData, setAddFormData] = useState({
device_id: "",
auto_generate: true,
});
const [aclFormData, setAclFormData] = useState({
device_id: "",
topic_pattern: "",
permission: "readwrite" as 'read' | 'write' | 'readwrite',
});
useEffect(() => {
if (isAdmin) {
fetchAll();
}
}, [isAdmin]);
const fetchAll = async () => {
try {
await Promise.all([
fetchDevices(),
fetchCredentials(),
fetchSyncStatus(),
]);
} finally {
setLoading(false);
}
};
const fetchDevices = async () => {
try {
const response = await fetch("/api/devices");
if (!response.ok) throw new Error("Failed to fetch devices");
const data = await response.json();
setDevices(data.devices || []);
} catch (err) {
console.error("Failed to fetch devices", err);
}
};
const fetchCredentials = async () => {
try {
const response = await fetch("/api/mqtt/credentials");
if (!response.ok) throw new Error("Failed to fetch credentials");
const data = await response.json();
setCredentials(data);
// Lade ACL Regeln für alle Devices mit Credentials
for (const cred of data) {
await fetchAclRules(cred.device_id);
}
} catch (err) {
console.error("Failed to fetch credentials", err);
}
};
const fetchAclRules = async (deviceId: string) => {
try {
const response = await fetch(`/api/mqtt/acl?device_id=${deviceId}`);
if (!response.ok) throw new Error("Failed to fetch ACL rules");
const rules = await response.json();
setAclRules(prev => ({ ...prev, [deviceId]: rules }));
} catch (err) {
console.error("Failed to fetch ACL rules", err);
}
};
const fetchSyncStatus = async () => {
try {
const response = await fetch("/api/mqtt/sync");
if (!response.ok) throw new Error("Failed to fetch sync status");
const status = await response.json();
setSyncStatus(status);
} catch (err) {
console.error("Failed to fetch sync status", err);
}
};
const handleAddCredentials = async (e: React.FormEvent) => {
e.preventDefault();
try {
const response = await fetch("/api/mqtt/credentials", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(addFormData),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to create credentials");
}
const newCred = await response.json();
// Zeige generiertes Passwort an
if (newCred.mqtt_password) {
setGeneratedPassword(`Username: ${newCred.mqtt_username}\nPassword: ${newCred.mqtt_password}`);
setSelectedDevice(addFormData.device_id);
setShowPasswordModal(true);
}
await fetchCredentials();
await fetchSyncStatus();
setShowAddModal(false);
setAddFormData({ device_id: "", auto_generate: true });
} catch (err: any) {
alert(err.message);
}
};
const handleDeleteCredentials = async (deviceId: string) => {
if (!confirm("MQTT Credentials für dieses Device löschen?")) return;
try {
const response = await fetch(`/api/mqtt/credentials/${deviceId}`, {
method: "DELETE",
});
if (!response.ok) throw new Error("Failed to delete credentials");
await fetchCredentials();
await fetchSyncStatus();
} catch (err: any) {
alert(err.message);
}
};
const handleRegeneratePassword = async (deviceId: string) => {
if (!confirm("Passwort neu generieren? Das alte Passwort wird ungültig.")) return;
try {
const response = await fetch(`/api/mqtt/credentials/${deviceId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ regenerate_password: true }),
});
if (!response.ok) throw new Error("Failed to regenerate password");
const updated = await response.json();
if (updated.mqtt_password) {
setGeneratedPassword(`Username: ${updated.mqtt_username}\nPassword: ${updated.mqtt_password}`);
setSelectedDevice(deviceId);
setShowPasswordModal(true);
}
await fetchCredentials();
await fetchSyncStatus();
} catch (err: any) {
alert(err.message);
}
};
const handleToggleEnabled = async (deviceId: string, enabled: boolean) => {
try {
const response = await fetch(`/api/mqtt/credentials/${deviceId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
if (!response.ok) throw new Error("Failed to update credentials");
await fetchCredentials();
await fetchSyncStatus();
} catch (err: any) {
alert(err.message);
}
};
const handleAddAclRule = async (e: React.FormEvent) => {
e.preventDefault();
try {
const response = await fetch("/api/mqtt/acl", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(aclFormData),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to create ACL rule");
}
await fetchAclRules(aclFormData.device_id);
await fetchSyncStatus();
setShowAclModal(false);
setAclFormData({ device_id: "", topic_pattern: "", permission: "readwrite" });
} catch (err: any) {
alert(err.message);
}
};
const handleDeleteAclRule = async (ruleId: number, deviceId: string) => {
if (!confirm("ACL Regel löschen?")) return;
try {
const response = await fetch(`/api/mqtt/acl/${ruleId}`, {
method: "DELETE",
});
if (!response.ok) throw new Error("Failed to delete ACL rule");
await fetchAclRules(deviceId);
await fetchSyncStatus();
} catch (err: any) {
alert(err.message);
}
};
const handleSync = async () => {
setSyncing(true);
try {
const response = await fetch("/api/mqtt/sync", {
method: "POST",
});
const result = await response.json();
if (result.success) {
alert(result.message);
} else {
alert(`Sync fehlgeschlagen: ${result.message}`);
}
await fetchSyncStatus();
} catch (err: any) {
alert("Sync fehlgeschlagen: " + err.message);
} finally {
setSyncing(false);
}
};
const handleSendCredentialsEmail = async () => {
if (!selectedDevice) return;
// Parse username and password from generatedPassword string
const lines = generatedPassword.split('\n');
const usernameLine = lines.find(l => l.startsWith('Username:'));
const passwordLine = lines.find(l => l.startsWith('Password:'));
if (!usernameLine || !passwordLine) {
alert('Fehler beim Extrahieren der Credentials');
return;
}
const mqttUsername = usernameLine.replace('Username:', '').trim();
const mqttPassword = passwordLine.replace('Password:', '').trim();
try {
const response = await fetch('/api/mqtt/send-credentials', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
deviceId: selectedDevice,
mqttUsername,
mqttPassword,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to send email');
}
const result = await response.json();
alert(result.message);
} catch (err: any) {
alert('Email senden fehlgeschlagen: ' + err.message);
}
};
if (!isAdmin) {
return <div className="p-8">Keine Berechtigung</div>;
}
if (loading) {
return <div className="p-8">Lade...</div>;
}
const devicesWithoutCredentials = devices.filter(
d => d.isActive && !credentials.find(c => c.device_id === d.id)
);
return (
<div className="p-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">MQTT Provisioning</h1>
<div className="flex gap-4 items-center">
{syncStatus && syncStatus.pending_changes > 0 && (
<span className="px-3 py-1 bg-yellow-100 text-yellow-800 rounded-md text-sm">
{syncStatus.pending_changes} ausstehende Änderungen
</span>
)}
<button
onClick={handleSync}
disabled={syncing || !syncStatus || syncStatus.pending_changes === 0}
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{syncing ? "Synchronisiere..." : "MQTT Sync"}
</button>
<button
onClick={() => setShowAddModal(true)}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Device Provisionieren
</button>
</div>
</div>
{syncStatus && (
<div className="mb-6 p-4 bg-gray-100 rounded-md">
<h3 className="font-semibold mb-2">Sync Status</h3>
<div className="text-sm">
<p>Status: <span className={syncStatus.last_sync_status === 'success' ? 'text-green-600' : 'text-red-600'}>{syncStatus.last_sync_status}</span></p>
{syncStatus.last_sync_at && (
<p>Letzter Sync: {new Date(syncStatus.last_sync_at).toLocaleString('de-DE')}</p>
)}
</div>
</div>
)}
<div className="space-y-6">
{credentials.map(cred => {
const deviceRules = aclRules[cred.device_id] || [];
return (
<div key={cred.id} className="border rounded-lg p-6 bg-white shadow-sm">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-xl font-semibold">{cred.device_name}</h3>
<p className="text-sm text-gray-500">Device ID: {cred.device_id}</p>
<p className="text-sm text-gray-600 mt-1">Username: <code className="bg-gray-100 px-2 py-1 rounded">{cred.mqtt_username}</code></p>
<p className="text-xs text-gray-500 mt-1">Erstellt: {new Date(cred.created_at).toLocaleString('de-DE')}</p>
</div>
<div className="flex gap-2">
<button
onClick={() => handleToggleEnabled(cred.device_id, !cred.enabled)}
className={`px-3 py-1 rounded text-sm ${cred.enabled ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}
>
{cred.enabled ? 'Aktiviert' : 'Deaktiviert'}
</button>
<button
onClick={() => handleRegeneratePassword(cred.device_id)}
className="px-3 py-1 bg-yellow-100 text-yellow-800 rounded text-sm hover:bg-yellow-200"
>
Passwort Reset
</button>
<button
onClick={() => {
setAclFormData({
device_id: cred.device_id,
topic_pattern: `owntracks/owntrack/${cred.device_id}`,
permission: "readwrite"
});
setShowAclModal(true);
}}
className="px-3 py-1 bg-blue-100 text-blue-800 rounded text-sm hover:bg-blue-200"
>
ACL Hinzufügen
</button>
<button
onClick={() => handleDeleteCredentials(cred.device_id)}
className="px-3 py-1 bg-red-100 text-red-800 rounded text-sm hover:bg-red-200"
>
Löschen
</button>
</div>
</div>
<div className="mt-4">
<h4 className="font-semibold mb-2 text-sm">ACL Regeln:</h4>
{deviceRules.length > 0 ? (
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left">Topic Pattern</th>
<th className="px-4 py-2 text-left">Berechtigung</th>
<th className="px-4 py-2 text-left">Erstellt</th>
<th className="px-4 py-2"></th>
</tr>
</thead>
<tbody>
{deviceRules.map(rule => (
<tr key={rule.id} className="border-t">
<td className="px-4 py-2"><code className="bg-gray-100 px-2 py-1 rounded">{rule.topic_pattern}</code></td>
<td className="px-4 py-2"><span className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs">{rule.permission}</span></td>
<td className="px-4 py-2 text-gray-500">{new Date(rule.created_at).toLocaleString('de-DE')}</td>
<td className="px-4 py-2">
<button
onClick={() => handleDeleteAclRule(rule.id, cred.device_id)}
className="text-red-600 hover:text-red-800 text-xs"
>
Löschen
</button>
</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="text-sm text-gray-500">Keine ACL Regeln definiert</p>
)}
</div>
</div>
);
})}
{credentials.length === 0 && (
<div className="text-center py-12 text-gray-500">
Noch keine Devices provisioniert
</div>
)}
</div>
{/* Add Credentials Modal */}
{showAddModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4">Device Provisionieren</h2>
<form onSubmit={handleAddCredentials}>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Device</label>
<select
value={addFormData.device_id}
onChange={(e) => setAddFormData({ ...addFormData, device_id: e.target.value })}
className="w-full px-3 py-2 border rounded-md"
required
>
<option value="">Device auswählen</option>
{devicesWithoutCredentials.map(d => (
<option key={d.id} value={d.id}>{d.name} (ID: {d.id})</option>
))}
</select>
</div>
<div className="mb-4">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={addFormData.auto_generate}
onChange={(e) => setAddFormData({ ...addFormData, auto_generate: e.target.checked })}
/>
<span className="text-sm">Automatisch Username & Passwort generieren</span>
</label>
</div>
<div className="flex gap-2">
<button type="submit" className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
Erstellen
</button>
<button
type="button"
onClick={() => setShowAddModal(false)}
className="flex-1 px-4 py-2 bg-gray-300 rounded-md hover:bg-gray-400"
>
Abbrechen
</button>
</div>
</form>
</div>
</div>
)}
{/* Password Display Modal */}
{showPasswordModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4">MQTT Credentials</h2>
<div className="mb-4 p-4 bg-gray-100 rounded-md">
<pre className="text-sm whitespace-pre-wrap">{generatedPassword}</pre>
</div>
<p className="text-sm text-red-600 mb-4">
Speichere diese Credentials! Das Passwort kann nicht nochmal angezeigt werden.
</p>
<button
onClick={async () => {
try {
// Moderne Clipboard API (bevorzugt)
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(generatedPassword);
alert("✓ In Zwischenablage kopiert!");
} else {
// Fallback für ältere Browser oder HTTP-Kontext
const textArea = document.createElement("textarea");
textArea.value = generatedPassword;
textArea.style.position = "fixed";
textArea.style.left = "-999999px";
textArea.style.top = "-999999px";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand('copy');
if (successful) {
alert("✓ In Zwischenablage kopiert!");
} else {
throw new Error("Copy failed");
}
} finally {
document.body.removeChild(textArea);
}
}
} catch (err) {
console.error("Clipboard error:", err);
alert("❌ Kopieren fehlgeschlagen. Bitte manuell kopieren:\n\n" + generatedPassword);
}
}}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 mb-2"
>
📋 In Zwischenablage kopieren
</button>
<button
onClick={handleSendCredentialsEmail}
className="w-full px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 mb-2"
>
Per Email senden
</button>
<button
onClick={() => {
setShowPasswordModal(false);
setSelectedDevice(null);
}}
className="w-full px-4 py-2 bg-gray-300 rounded-md hover:bg-gray-400"
>
Schließen
</button>
</div>
</div>
)}
{/* Add ACL Rule Modal */}
{showAclModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4">ACL Regel Hinzufügen</h2>
<form onSubmit={handleAddAclRule}>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Topic Pattern</label>
<input
type="text"
value={aclFormData.topic_pattern}
onChange={(e) => setAclFormData({ ...aclFormData, topic_pattern: e.target.value })}
className="w-full px-3 py-2 border rounded-md"
placeholder={`owntracks/owntrack/${aclFormData.device_id || '<DeviceID>'}`}
required
/>
<p className="text-xs text-gray-500 mt-1">
Format: owntracks/owntrack/&lt;DeviceID&gt; (z.B. owntracks/owntrack/10)
</p>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Berechtigung</label>
<select
value={aclFormData.permission}
onChange={(e) => setAclFormData({ ...aclFormData, permission: e.target.value as any })}
className="w-full px-3 py-2 border rounded-md"
>
<option value="read">Read (Lesen)</option>
<option value="write">Write (Schreiben)</option>
<option value="readwrite">Read/Write (Lesen & Schreiben)</option>
</select>
</div>
<div className="flex gap-2">
<button type="submit" className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
Hinzufügen
</button>
<button
type="button"
onClick={() => setShowAclModal(false)}
className="flex-1 px-4 py-2 bg-gray-300 rounded-md hover:bg-gray-400"
>
Abbrechen
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

519
app/admin/page.tsx Normal file
View File

@@ -0,0 +1,519 @@
"use client";
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import { LocationResponse } from "@/types/location";
interface DeviceInfo {
id: string;
name: string;
color: string;
}
export default function AdminDashboard() {
const { data: session } = useSession();
const userRole = (session?.user as any)?.role;
const username = session?.user?.name || '';
const isAdmin = userRole === 'ADMIN';
const isSuperAdmin = username === 'admin';
const [stats, setStats] = useState({
totalDevices: 0,
totalPoints: 0,
lastUpdated: "",
onlineDevices: 0,
});
const [devices, setDevices] = useState<DeviceInfo[]>([]);
const [cleanupStatus, setCleanupStatus] = useState<{
loading: boolean;
message: string;
type: 'success' | 'error' | '';
}>({
loading: false,
message: '',
type: '',
});
const [optimizeStatus, setOptimizeStatus] = useState<{
loading: boolean;
message: string;
type: 'success' | 'error' | '';
}>({
loading: false,
message: '',
type: '',
});
const [dbStats, setDbStats] = useState<any>(null);
const [systemStatus, setSystemStatus] = useState<any>(null);
// Fetch devices from API
useEffect(() => {
const fetchDevices = async () => {
try {
const response = await fetch("/api/devices/public");
if (response.ok) {
const data = await response.json();
setDevices(data.devices);
}
} catch (err) {
console.error("Failed to fetch devices:", err);
}
};
fetchDevices();
// Refresh devices every 30 seconds
const interval = setInterval(fetchDevices, 30000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
const fetchStats = async () => {
try {
// Fetch from local API (reads from SQLite cache)
const response = await fetch("/api/locations?sync=false"); // sync=false for faster response
const data: LocationResponse = await response.json();
// Calculate online devices (last location < 10 minutes)
const now = Date.now();
const tenMinutesAgo = now - 10 * 60 * 1000;
const recentDevices = new Set(
data.history
.filter((loc) => {
const locationTime = new Date(loc.timestamp).getTime();
return locationTime > tenMinutesAgo;
})
.map((loc) => loc.username)
);
setStats({
totalDevices: devices.length,
totalPoints: data.total_points || data.history.length,
lastUpdated: data.last_updated || new Date().toISOString(),
onlineDevices: recentDevices.size,
});
} catch (err) {
console.error("Failed to fetch stats", err);
}
};
if (devices.length > 0) {
fetchStats();
const interval = setInterval(fetchStats, 10000);
return () => clearInterval(interval);
}
}, [devices]);
// Fetch detailed database statistics
useEffect(() => {
const fetchDbStats = async () => {
try {
const response = await fetch('/api/locations/stats');
if (response.ok) {
const data = await response.json();
setDbStats(data);
}
} catch (err) {
console.error('Failed to fetch DB stats:', err);
}
};
fetchDbStats();
// Refresh DB stats every 30 seconds
const interval = setInterval(fetchDbStats, 30000);
return () => clearInterval(interval);
}, []);
// Fetch system status (uptime, memory)
useEffect(() => {
const fetchSystemStatus = async () => {
try {
const response = await fetch('/api/system/status');
if (response.ok) {
const data = await response.json();
setSystemStatus(data);
}
} catch (err) {
console.error('Failed to fetch system status:', err);
}
};
fetchSystemStatus();
// Refresh every 10 seconds for live uptime
const interval = setInterval(fetchSystemStatus, 10000);
return () => clearInterval(interval);
}, []);
// Cleanup old locations
const handleCleanup = async (retentionHours: number) => {
if (!confirm(`Delete all locations older than ${Math.round(retentionHours / 24)} days?`)) {
return;
}
setCleanupStatus({ loading: true, message: '', type: '' });
try {
const response = await fetch('/api/locations/cleanup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ retentionHours }),
});
const data = await response.json();
if (response.ok) {
setCleanupStatus({
loading: false,
message: `✓ Deleted ${data.deleted} records. Freed ${data.freedKB} KB.`,
type: 'success',
});
// Refresh stats
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
setCleanupStatus({
loading: false,
message: `Error: ${data.error}`,
type: 'error',
});
}
} catch (error) {
setCleanupStatus({
loading: false,
message: 'Failed to cleanup locations',
type: 'error',
});
}
// Clear message after 5 seconds
setTimeout(() => {
setCleanupStatus({ loading: false, message: '', type: '' });
}, 5000);
};
// Optimize database
const handleOptimize = async () => {
if (!confirm('Optimize database? This may take a few seconds.')) {
return;
}
setOptimizeStatus({ loading: true, message: '', type: '' });
try {
const response = await fetch('/api/locations/optimize', {
method: 'POST',
});
const data = await response.json();
if (response.ok) {
setOptimizeStatus({
loading: false,
message: `✓ Database optimized. Freed ${data.freedMB} MB. (${data.before.sizeMB}${data.after.sizeMB} MB)`,
type: 'success',
});
// Refresh stats
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
setOptimizeStatus({
loading: false,
message: `Error: ${data.error}`,
type: 'error',
});
}
} catch (error) {
setOptimizeStatus({
loading: false,
message: 'Failed to optimize database',
type: 'error',
});
}
// Clear message after 5 seconds
setTimeout(() => {
setOptimizeStatus({ loading: false, message: '', type: '' });
}, 5000);
};
const statCards = [
{
title: "Total Devices",
value: stats.totalDevices,
icon: "📱",
},
{
title: "Online Devices",
value: stats.onlineDevices,
icon: "🟢",
},
{
title: "Total Locations",
value: stats.totalPoints,
icon: "📍",
},
];
return (
<div className="space-y-6">
<h2 className="text-3xl font-bold text-gray-900">Dashboard</h2>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{statCards.map((stat) => (
<div
key={stat.title}
className="bg-white rounded-lg shadow p-6 flex items-center gap-4"
>
<div className="text-4xl">{stat.icon}</div>
<div>
<p className="text-sm font-medium text-gray-600">{stat.title}</p>
<p className="text-3xl font-bold text-gray-900">{stat.value}</p>
</div>
</div>
))}
</div>
{/* System Status */}
{systemStatus && (
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">
System Status
</h3>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-gray-50 p-4 rounded">
<p className="text-sm text-gray-600">App Uptime</p>
<p className="text-2xl font-bold text-gray-900">{systemStatus.uptime.formatted}</p>
<p className="text-xs text-gray-500 mt-1">Running since server start</p>
</div>
<div className="bg-gray-50 p-4 rounded">
<p className="text-sm text-gray-600">Memory Usage</p>
<p className="text-2xl font-bold text-gray-900">{systemStatus.memory.heapUsed} MB</p>
<p className="text-xs text-gray-500 mt-1">Heap: {systemStatus.memory.heapTotal} MB / RSS: {systemStatus.memory.rss} MB</p>
</div>
<div className="bg-gray-50 p-4 rounded">
<p className="text-sm text-gray-600">Runtime</p>
<p className="text-2xl font-bold text-gray-900">{systemStatus.nodejs}</p>
<p className="text-xs text-gray-500 mt-1">Platform: {systemStatus.platform}</p>
</div>
</div>
</div>
</div>
)}
{/* Database Statistics */}
{dbStats && (
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">
Database Statistics
</h3>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="bg-gray-50 p-4 rounded">
<p className="text-sm text-gray-600">Database Size</p>
<p className="text-2xl font-bold text-gray-900">{dbStats.sizeMB} MB</p>
<p className="text-xs text-gray-500 mt-1">WAL Mode: {dbStats.walMode}</p>
</div>
<div className="bg-gray-50 p-4 rounded">
<p className="text-sm text-gray-600">Time Range</p>
<p className="text-sm font-semibold text-gray-900">
{dbStats.oldest ? new Date(dbStats.oldest).toLocaleDateString() : 'N/A'}
</p>
<p className="text-xs text-gray-500">to</p>
<p className="text-sm font-semibold text-gray-900">
{dbStats.newest ? new Date(dbStats.newest).toLocaleDateString() : 'N/A'}
</p>
</div>
<div className="bg-gray-50 p-4 rounded">
<p className="text-sm text-gray-600">Average Per Day</p>
<p className="text-2xl font-bold text-gray-900">{dbStats.avgPerDay}</p>
<p className="text-xs text-gray-500 mt-1">locations (last 7 days)</p>
</div>
</div>
{/* Locations per Device */}
{dbStats.perDevice && dbStats.perDevice.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-3">Locations per Device</h4>
<div className="space-y-2">
{dbStats.perDevice.map((device: any) => (
<div key={device.username} className="flex items-center justify-between py-2 px-3 bg-gray-50 rounded">
<span className="text-sm font-medium text-gray-700">Device {device.username}</span>
<span className="text-sm text-gray-900">{device.count.toLocaleString()} locations</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
{/* Device List */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">
Configured Devices
</h3>
</div>
<div className="p-6">
<table className="min-w-full">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">
ID
</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">
Name
</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">
Color
</th>
</tr>
</thead>
<tbody>
{devices.map((device) => (
<tr
key={device.id}
className="border-b border-gray-100 hover:bg-gray-50"
>
<td className="py-3 px-4 text-sm text-gray-900">
{device.id}
</td>
<td className="py-3 px-4 text-sm text-gray-900">
{device.name}
</td>
<td className="py-3 px-4">
<div className="flex items-center gap-2">
<div
className="w-6 h-6 rounded-full border border-gray-300"
style={{ backgroundColor: device.color }}
/>
<span className="text-sm text-gray-600">
{device.color}
</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Database Maintenance - SUPER ADMIN ONLY (username "admin") */}
{isSuperAdmin && (
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">
Database Maintenance
</h3>
</div>
<div className="p-6 space-y-6">
{/* Cleanup Section */}
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-2">
Clean up old data
</h4>
<p className="text-sm text-gray-600 mb-3">
Delete old location data to keep the database size manageable.
</p>
{/* Cleanup Status Message */}
{cleanupStatus.message && (
<div
className={`mb-3 p-3 rounded ${
cleanupStatus.type === 'success'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{cleanupStatus.message}
</div>
)}
{/* Cleanup Buttons */}
<div className="flex flex-wrap gap-3">
<button
onClick={() => handleCleanup(168)}
disabled={cleanupStatus.loading}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{cleanupStatus.loading ? 'Cleaning...' : 'Delete > 7 days'}
</button>
<button
onClick={() => handleCleanup(360)}
disabled={cleanupStatus.loading}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{cleanupStatus.loading ? 'Cleaning...' : 'Delete > 15 days'}
</button>
<button
onClick={() => handleCleanup(720)}
disabled={cleanupStatus.loading}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{cleanupStatus.loading ? 'Cleaning...' : 'Delete > 30 days'}
</button>
<button
onClick={() => handleCleanup(2160)}
disabled={cleanupStatus.loading}
className="px-4 py-2 bg-orange-600 text-white rounded hover:bg-orange-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{cleanupStatus.loading ? 'Cleaning...' : 'Delete > 90 days'}
</button>
</div>
<p className="text-xs text-gray-500 mt-4">
Current database size: {stats.totalPoints} locations
</p>
</div>
{/* Optimize Section */}
<div className="border-t pt-6">
<h4 className="text-sm font-semibold text-gray-700 mb-2">
Optimize Database
</h4>
<p className="text-sm text-gray-600 mb-3">
Run VACUUM and ANALYZE to reclaim disk space and improve query performance. Recommended after cleanup.
</p>
{/* Optimize Status Message */}
{optimizeStatus.message && (
<div
className={`mb-3 p-3 rounded ${
optimizeStatus.type === 'success'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{optimizeStatus.message}
</div>
)}
<button
onClick={handleOptimize}
disabled={optimizeStatus.loading}
className="px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700 disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center gap-2"
>
<span>{optimizeStatus.loading ? '⚙️' : '⚡'}</span>
{optimizeStatus.loading ? 'Optimizing...' : 'Optimize Now'}
</button>
</div>
</div>
</div>
)}
{/* Last Updated */}
<div className="text-sm text-gray-500 text-right">
Last updated: {new Date(stats.lastUpdated).toLocaleString()}
</div>
</div>
);
}

411
app/admin/settings/page.tsx Normal file
View File

@@ -0,0 +1,411 @@
"use client";
import { useEffect, useState } from "react";
import { SMTPConfig, SMTPConfigResponse } from "@/lib/types/smtp";
export default function SettingsPage() {
const [activeTab, setActiveTab] = useState<'smtp'>('smtp');
const [config, setConfig] = useState<SMTPConfig>({
host: '',
port: 587,
secure: false,
auth: { user: '', pass: '' },
from: { email: '', name: 'Location Tracker' },
replyTo: '',
timeout: 10000,
});
const [source, setSource] = useState<'database' | 'env'>('env');
const [hasPassword, setHasPassword] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [testEmail, setTestEmail] = useState('');
const [showTestModal, setShowTestModal] = useState(false);
// Fetch current config
useEffect(() => {
fetchConfig();
}, []);
const fetchConfig = async () => {
try {
const response = await fetch('/api/admin/settings/smtp');
if (!response.ok) throw new Error('Failed to fetch config');
const data: SMTPConfigResponse = await response.json();
if (data.config) {
setConfig(data.config);
setHasPassword(data.config.auth.pass === '***');
}
setSource(data.source);
} catch (error) {
console.error('Failed to fetch SMTP config:', error);
setMessage({ type: 'error', text: 'Failed to load SMTP configuration' });
} finally {
setLoading(false);
}
};
// Save config
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setMessage(null);
try {
const response = await fetch('/api/admin/settings/smtp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to save');
}
setMessage({ type: 'success', text: 'SMTP settings saved successfully' });
setHasPassword(true);
setSource('database');
// Clear password field for security
setConfig({ ...config, auth: { ...config.auth, pass: '' } });
} catch (error: any) {
setMessage({ type: 'error', text: error.message || 'Failed to save settings' });
} finally {
setSaving(false);
}
};
// Reset to defaults
const handleReset = async () => {
if (!confirm('Reset to environment defaults? This will delete database configuration.')) {
return;
}
try {
const response = await fetch('/api/admin/settings/smtp', {
method: 'DELETE',
});
if (!response.ok) throw new Error('Failed to reset');
setMessage({ type: 'success', text: 'Reset to environment defaults' });
await fetchConfig();
} catch (error) {
setMessage({ type: 'error', text: 'Failed to reset settings' });
}
};
// Test connection
const handleTest = async () => {
if (!testEmail) {
alert('Please enter a test email address');
return;
}
setTesting(true);
setMessage(null);
try {
const response = await fetch('/api/admin/settings/smtp/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
config: hasPassword ? undefined : config,
testEmail,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Test failed');
}
setMessage({ type: 'success', text: data.message });
setShowTestModal(false);
setTestEmail('');
} catch (error: any) {
setMessage({ type: 'error', text: error.message || 'Connection test failed' });
} finally {
setTesting(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<p className="text-gray-600">Loading settings...</p>
</div>
);
}
return (
<div>
<h2 className="text-3xl font-bold text-gray-900 mb-6">Settings</h2>
{/* Tab Navigation */}
<div className="border-b border-gray-200 mb-6">
<nav className="flex gap-4">
<button
onClick={() => setActiveTab('smtp')}
className={`px-4 py-2 border-b-2 font-medium ${
activeTab === 'smtp'
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-600 hover:text-gray-900'
}`}
>
SMTP Settings
</button>
</nav>
</div>
{/* Status Message */}
{message && (
<div
className={`mb-6 p-4 rounded ${
message.type === 'success'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{message.text}
</div>
)}
{/* Config Source Info */}
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded">
<p className="text-sm text-blue-900">
<strong>Current source:</strong> {source === 'database' ? 'Database (Custom)' : 'Environment (.env)'}
</p>
</div>
{/* SMTP Form */}
<form onSubmit={handleSave} className="bg-white rounded-lg shadow p-6">
<div className="space-y-4">
{/* Host */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
SMTP Host *
</label>
<input
type="text"
required
value={config.host}
onChange={(e) => setConfig({ ...config, host: e.target.value.trim() })}
placeholder="smtp.gmail.com"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{config.host.includes('gmail') && (
<p className="mt-1 text-xs text-blue-600">
Gmail detected: Use App Password, not your regular password.
<a
href="https://myaccount.google.com/apppasswords"
target="_blank"
rel="noopener noreferrer"
className="underline ml-1"
>
Generate App Password
</a>
</p>
)}
</div>
{/* Port and Secure */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Port *
</label>
<input
type="number"
required
min="1"
max="65535"
value={config.port}
onChange={(e) => setConfig({ ...config, port: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex items-end">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={config.secure}
onChange={(e) => setConfig({ ...config, secure: e.target.checked })}
className="w-4 h-4 text-blue-600"
/>
<span className="text-sm text-gray-700">Use TLS/SSL</span>
</label>
</div>
</div>
{/* Username */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Username *
</label>
<input
type="text"
required
value={config.auth.user}
onChange={(e) => setConfig({ ...config, auth: { ...config.auth, user: e.target.value.trim() } })}
placeholder="your-email@example.com"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Password */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Password {hasPassword && '(leave empty to keep current)'}
{config.host.includes('gmail') && (
<span className="text-red-600 font-semibold ml-2">
- Use App Password!
</span>
)}
</label>
<input
type="password"
required={!hasPassword}
value={config.auth.pass}
onChange={(e) => setConfig({ ...config, auth: { ...config.auth, pass: e.target.value.trim() } })}
placeholder={hasPassword ? '••••••••' : (config.host.includes('gmail') ? 'Gmail App Password (16 chars)' : 'your-password')}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{config.host.includes('gmail') && (
<p className="mt-1 text-xs text-amber-600">
Do NOT use your Gmail password. Generate an App Password with 2FA enabled.
</p>
)}
</div>
{/* From Email */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
From Email *
</label>
<input
type="email"
required
value={config.from.email}
onChange={(e) => setConfig({ ...config, from: { ...config.from, email: e.target.value.trim() } })}
placeholder="noreply@example.com"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* From Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
From Name *
</label>
<input
type="text"
required
value={config.from.name}
onChange={(e) => setConfig({ ...config, from: { ...config.from, name: e.target.value } })}
placeholder="Location Tracker"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Reply-To */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Reply-To (optional)
</label>
<input
type="email"
value={config.replyTo || ''}
onChange={(e) => setConfig({ ...config, replyTo: e.target.value })}
placeholder="support@example.com"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Timeout */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Timeout (ms)
</label>
<input
type="number"
min="1000"
value={config.timeout}
onChange={(e) => setConfig({ ...config, timeout: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
{/* Buttons */}
<div className="flex gap-3 mt-6">
<button
type="submit"
disabled={saving}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400"
>
{saving ? 'Saving...' : 'Save Settings'}
</button>
<button
type="button"
onClick={() => setShowTestModal(true)}
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700"
>
Test Connection
</button>
{source === 'database' && (
<button
type="button"
onClick={handleReset}
className="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-100"
>
Reset to Defaults
</button>
)}
</div>
</form>
{/* Test Email Modal */}
{showTestModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h3 className="text-xl font-bold mb-4">Test SMTP Connection</h3>
<p className="text-sm text-gray-600 mb-4">
Enter your email address to receive a test email.
</p>
<input
type="email"
value={testEmail}
onChange={(e) => setTestEmail(e.target.value)}
placeholder="your-email@example.com"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 mb-4"
/>
<div className="flex gap-3">
<button
onClick={() => {
setShowTestModal(false);
setTestEmail('');
}}
className="flex-1 px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-100"
>
Cancel
</button>
<button
onClick={handleTest}
disabled={testing || !testEmail}
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:bg-gray-400"
>
{testing ? 'Sending...' : 'Send Test Email'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

461
app/admin/setup/page.tsx Normal file
View File

@@ -0,0 +1,461 @@
"use client";
import { useState } from "react";
export default function SetupGuidePage() {
const [openSections, setOpenSections] = useState<Record<string, boolean>>({
"1": true, // Installation section open by default
});
const toggleSection = (id: string) => {
setOpenSections(prev => ({ ...prev, [id]: !prev[id] }));
};
return (
<div className="max-w-4xl mx-auto">
<div className="bg-white rounded-lg shadow-lg p-8">
<h1 className="text-4xl font-bold text-gray-900 mb-4">
📱 OwnTracks App Setup Anleitung
</h1>
<p className="text-gray-600 mb-8">
Diese Anleitung erklärt Schritt-für-Schritt, wie Sie die OwnTracks App
auf Ihrem Smartphone installieren und mit dem Location Tracker System verbinden.
</p>
{/* Table of Contents */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8">
<h2 className="text-xl font-bold text-gray-900 mb-3">📋 Inhaltsverzeichnis</h2>
<ul className="space-y-2 text-sm">
<li><a href="#installation" className="text-blue-600 hover:underline">1. Installation</a></li>
<li><a href="#credentials" className="text-blue-600 hover:underline">2. MQTT Credentials erhalten</a></li>
<li><a href="#configuration" className="text-blue-600 hover:underline">3. App Konfiguration</a></li>
<li><a href="#testing" className="text-blue-600 hover:underline">5. Verbindung testen</a></li>
<li><a href="#ports" className="text-blue-600 hover:underline">6. Port 1883 vs. 9001</a></li>
<li><a href="#troubleshooting" className="text-blue-600 hover:underline">7. Troubleshooting</a></li>
</ul>
</div>
{/* Section 1: Installation */}
<Section
id="installation"
title="1. Installation"
icon="📥"
isOpen={openSections["1"]}
onToggle={() => toggleSection("1")}
>
<div className="grid md:grid-cols-2 gap-6">
<div className="border rounded-lg p-4">
<h4 className="font-bold text-lg mb-2">🍎 iOS (iPhone/iPad)</h4>
<ol className="list-decimal list-inside space-y-2 text-sm">
<li>Öffnen Sie den <strong>App Store</strong></li>
<li>Suchen Sie nach <strong>"OwnTracks"</strong></li>
<li>Laden Sie die App herunter</li>
</ol>
<a
href="https://apps.apple.com/app/owntracks/id692424691"
target="_blank"
rel="noopener noreferrer"
className="inline-block mt-3 text-blue-600 hover:underline text-sm"
>
App Store Link
</a>
</div>
<div className="border rounded-lg p-4">
<h4 className="font-bold text-lg mb-2">🤖 Android</h4>
<ol className="list-decimal list-inside space-y-2 text-sm">
<li>Öffnen Sie den <strong>Google Play Store</strong></li>
<li>Suchen Sie nach <strong>"OwnTracks"</strong></li>
<li>Laden Sie die App herunter</li>
</ol>
<a
href="https://play.google.com/store/apps/details?id=org.owntracks.android"
target="_blank"
rel="noopener noreferrer"
className="inline-block mt-3 text-blue-600 hover:underline text-sm"
>
Play Store Link
</a>
</div>
</div>
</Section>
{/* Section 2: Credentials */}
<Section
id="credentials"
title="2. MQTT Credentials erhalten"
icon="🔑"
isOpen={openSections["2"]}
onToggle={() => toggleSection("2")}
>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-4">
<p className="text-sm font-semibold"> Wichtig: Bevor Sie die App konfigurieren, benötigen Sie MQTT-Zugangsdaten!</p>
</div>
<ol className="list-decimal list-inside space-y-3 text-sm">
<li>Navigieren Sie zu <a href="/admin/mqtt" className="text-blue-600 hover:underline font-semibold">MQTT Provisioning</a></li>
<li>Klicken Sie auf <strong>"Device Provisionieren"</strong></li>
<li>Wählen Sie Ihr Device aus der Liste</li>
<li>Aktivieren Sie <strong>"Automatisch Username & Passwort generieren"</strong></li>
<li>Klicken Sie auf <strong>"Erstellen"</strong></li>
<li>
<strong className="text-red-600">Speichern Sie die Credentials sofort!</strong>
<div className="bg-gray-100 p-3 rounded mt-2 font-mono text-xs">
Username: device_10_abc123<br />
Password: ******************
</div>
</li>
</ol>
</Section>
{/* Section 3: Configuration */}
<Section
id="configuration"
title="3. OwnTracks App Konfiguration"
icon="⚙️"
isOpen={openSections["3"]}
onToggle={() => toggleSection("3")}
>
<div className="space-y-6">
<div>
<h4 className="font-bold mb-3">Schritt 1: Zu Einstellungen navigieren</h4>
<ul className="list-disc list-inside text-sm space-y-1">
<li><strong>iOS:</strong> Tippen Sie auf das Symbol (oben rechts)</li>
<li><strong>Android:</strong> Tippen Sie auf (Hamburger-Menü) Einstellungen</li>
</ul>
</div>
<div>
<h4 className="font-bold mb-3">Schritt 2: Modus auswählen</h4>
<p className="text-sm mb-2">Gehen Sie zu <strong>"Verbindung"</strong> oder <strong>"Connection"</strong></p>
<p className="text-sm">Wählen Sie <strong>"Modus"</strong> <strong className="text-green-600">MQTT</strong></p>
</div>
<div>
<h4 className="font-bold mb-3">Schritt 3: Server-Einstellungen</h4>
<ConfigTable />
</div>
<div>
<h4 className="font-bold mb-3">Schritt 4: Authentifizierung</h4>
<table className="w-full text-sm border">
<thead className="bg-gray-100">
<tr>
<th className="border p-2 text-left">Einstellung</th>
<th className="border p-2 text-left">Wert</th>
</tr>
</thead>
<tbody>
<tr>
<td className="border p-2">Benutzername</td>
<td className="border p-2 font-mono text-xs">device_XX_xxxxxxxx</td>
</tr>
<tr>
<td className="border p-2">Passwort</td>
<td className="border p-2 font-mono text-xs">Ihr generiertes Passwort</td>
</tr>
</tbody>
</table>
</div>
<div>
<h4 className="font-bold mb-3">Schritt 5: Device Identifikation</h4>
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-sm font-semibold text-red-800 mb-2"> Wichtig!</p>
<p className="text-sm">Die Device ID und Tracker ID müssen mit der Device-ID übereinstimmen, die Sie im System konfiguriert haben (z.B. <code className="bg-gray-200 px-1 rounded">10</code>, <code className="bg-gray-200 px-1 rounded">12</code>, <code className="bg-gray-200 px-1 rounded">15</code>).</p>
</div>
<table className="w-full text-sm border mt-4">
<thead className="bg-gray-100">
<tr>
<th className="border p-2 text-left">Einstellung</th>
<th className="border p-2 text-left">Wert</th>
</tr>
</thead>
<tbody>
<tr>
<td className="border p-2">Geräte ID / Device ID</td>
<td className="border p-2 font-mono">10</td>
</tr>
<tr>
<td className="border p-2">Tracker ID</td>
<td className="border p-2 font-mono">10</td>
</tr>
</tbody>
</table>
</div>
</div>
</Section>
{/* Section 5: Testing */}
<Section
id="testing"
title="5. Verbindung testen"
icon="✅"
isOpen={openSections["5"]}
onToggle={() => toggleSection("5")}
>
<ol className="list-decimal list-inside space-y-3 text-sm">
<li>
<strong>Verbindung prüfen:</strong> Sie sollten ein <span className="text-green-600 font-semibold">grünes Symbol</span> oder "Connected" sehen
</li>
<li>
<strong>Testpunkt senden:</strong> Tippen Sie auf den Location-Button (Fadenkreuz-Symbol)
</li>
<li>
<strong>Im Location Tracker prüfen:</strong>
<a href="/map" className="text-blue-600 hover:underline ml-1 font-semibold"> Zur Live-Karte</a>
<ul className="list-disc list-inside ml-6 mt-2 space-y-1">
<li>Marker mit Ihrer Device-Farbe</li>
<li>Aktuelle Koordinaten</li>
<li>Zeitstempel der letzten Position</li>
</ul>
</li>
</ol>
</Section>
{/* Section 6: Ports */}
<Section
id="ports"
title="6. Port 1883 vs. 9001 - Was ist der Unterschied?"
icon="🔌"
isOpen={openSections["6"]}
onToggle={() => toggleSection("6")}
>
<PortComparison />
</Section>
{/* Section 7: Troubleshooting */}
<Section
id="troubleshooting"
title="7. Troubleshooting - Häufige Probleme"
icon="🔧"
isOpen={openSections["7"]}
onToggle={() => toggleSection("7")}
>
<TroubleshootingSection />
</Section>
{/* Quick Start Checklist */}
<div className="bg-green-50 border border-green-200 rounded-lg p-6 mt-8">
<h3 className="text-xl font-bold text-gray-900 mb-4"> Schnellstart-Checkliste</h3>
<ChecklistItems />
</div>
{/* Support Section */}
<div className="bg-gray-50 border border-gray-200 rounded-lg p-6 mt-8">
<h3 className="text-xl font-bold text-gray-900 mb-3">📞 Weiterführende Informationen</h3>
<div className="grid md:grid-cols-2 gap-4 text-sm">
<div>
<h4 className="font-semibold mb-2">OwnTracks Dokumentation:</h4>
<ul className="space-y-1">
<li><a href="https://owntracks.org" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline"> Website</a></li>
<li><a href="https://owntracks.org/booklet/" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline"> Dokumentation</a></li>
</ul>
</div>
<div>
<h4 className="font-semibold mb-2">Location Tracker System:</h4>
<ul className="space-y-1">
<li><a href="/admin" className="text-blue-600 hover:underline"> Dashboard</a></li>
<li><a href="/map" className="text-blue-600 hover:underline"> Live-Karte</a></li>
<li><a href="/admin/mqtt" className="text-blue-600 hover:underline"> MQTT Provisioning</a></li>
</ul>
</div>
</div>
</div>
</div>
</div>
);
}
// Section Component
function Section({
id,
title,
icon,
isOpen,
onToggle,
children,
}: {
id: string;
title: string;
icon: string;
isOpen: boolean;
onToggle: () => void;
children: React.ReactNode;
}) {
return (
<div id={id} className="border-b border-gray-200 py-6">
<button
onClick={onToggle}
className="flex items-center justify-between w-full text-left hover:bg-gray-50 p-2 rounded"
>
<h2 className="text-2xl font-bold text-gray-900">
{icon} {title}
</h2>
<span className="text-2xl text-gray-400">
{isOpen ? "" : "+"}
</span>
</button>
{isOpen && <div className="mt-4">{children}</div>}
</div>
);
}
// Config Table Component
function ConfigTable() {
return (
<table className="w-full text-sm border">
<thead className="bg-gray-100">
<tr>
<th className="border p-2 text-left">Einstellung</th>
<th className="border p-2 text-left">Wert</th>
<th className="border p-2 text-left">Hinweis</th>
</tr>
</thead>
<tbody>
<tr>
<td className="border p-2">Hostname</td>
<td className="border p-2 font-mono">192.168.10.118</td>
<td className="border p-2 text-gray-600">IP-Adresse des Servers</td>
</tr>
<tr>
<td className="border p-2">Port</td>
<td className="border p-2 font-mono">1883</td>
<td className="border p-2 text-gray-600">Standard MQTT Port</td>
</tr>
<tr className="bg-red-50">
<td className="border p-2">Websockets nutzen</td>
<td className="border p-2 font-bold text-red-600"> DEAKTIVIERT</td>
<td className="border p-2 text-gray-600">Nur bei Port 9001!</td>
</tr>
<tr className="bg-red-50">
<td className="border p-2">TLS</td>
<td className="border p-2 font-bold text-red-600"> DEAKTIVIERT</td>
<td className="border p-2 text-gray-600">Lokales Netzwerk</td>
</tr>
<tr>
<td className="border p-2">Client ID</td>
<td className="border p-2 text-gray-500">Automatisch</td>
<td className="border p-2 text-gray-600">Kann leer bleiben</td>
</tr>
</tbody>
</table>
);
}
// Port Comparison Component
function PortComparison() {
return (
<div className="grid md:grid-cols-2 gap-6">
<div className="border-2 border-green-500 rounded-lg p-4 bg-green-50">
<h4 className="font-bold text-lg mb-3 text-green-800">Port 1883 (Standard MQTT)</h4>
<ul className="space-y-2 text-sm">
<li> <strong>Protokoll:</strong> Standard MQTT (TCP)</li>
<li> <strong>Verwendung:</strong> Mobile Apps, IoT-Geräte</li>
<li> <strong>Websockets:</strong> Nein</li>
<li className="mt-3 pt-3 border-t border-green-300">
<strong>Empfohlen für OwnTracks App!</strong>
</li>
</ul>
<div className="mt-4 bg-white p-2 rounded text-xs font-mono">
Port: 1883<br />
Websockets: DEAKTIVIERT
</div>
</div>
<div className="border-2 border-blue-500 rounded-lg p-4 bg-blue-50">
<h4 className="font-bold text-lg mb-3 text-blue-800">Port 9001 (MQTT over WebSockets)</h4>
<ul className="space-y-2 text-sm">
<li> <strong>Protokoll:</strong> MQTT über WebSocket</li>
<li> <strong>Verwendung:</strong> Browser, Web-Apps</li>
<li> <strong>Websockets:</strong> Ja</li>
<li className="mt-3 pt-3 border-t border-blue-300">
<strong>Für Web-Anwendungen</strong>
</li>
</ul>
<div className="mt-4 bg-white p-2 rounded text-xs font-mono">
Port: 9001<br />
Websockets: AKTIVIERT
</div>
</div>
</div>
);
}
// Troubleshooting Component
function TroubleshootingSection() {
return (
<div className="space-y-4">
<TroubleshootingItem
problem="Verbindung fehlgeschlagen"
solutions={[
"Überprüfen Sie Hostname (192.168.10.118) und Port (1883)",
"Stellen Sie sicher, dass Smartphone im selben Netzwerk ist",
"Deaktivieren Sie TLS/SSL",
"Deaktivieren Sie Websockets bei Port 1883",
"Prüfen Sie Username und Passwort",
]}
/>
<TroubleshootingItem
problem="Verbunden, aber keine Daten auf der Karte"
solutions={[
"Device ID und Tracker ID müssen übereinstimmen",
"Standortberechtigungen 'Immer' erteilen",
"Akkuoptimierung deaktivieren (Android)",
]}
/>
<TroubleshootingItem
problem="Tracking stoppt im Hintergrund"
solutions={[
"iOS: Hintergrundaktualisierung aktivieren",
"iOS: Standortzugriff auf 'Immer' setzen",
"Android: Akkuoptimierung deaktivieren",
"Android: Standort 'Immer zulassen'",
]}
/>
</div>
);
}
function TroubleshootingItem({ problem, solutions }: { problem: string; solutions: string[] }) {
return (
<div className="border border-gray-300 rounded-lg p-4">
<h4 className="font-bold text-red-600 mb-2"> {problem}</h4>
<ul className="space-y-1 text-sm">
{solutions.map((solution, i) => (
<li key={i} className="flex items-start gap-2">
<span className="text-green-600 font-bold"></span>
<span>{solution}</span>
</li>
))}
</ul>
</div>
);
}
// Checklist Items Component
function ChecklistItems() {
const items = [
"OwnTracks App installiert",
"MQTT Credentials generiert und gespeichert",
"Modus auf MQTT gesetzt",
"Hostname: 192.168.10.118 eingetragen",
"Port: 1883 eingetragen",
"Websockets: ❌ Deaktiviert",
"TLS: ❌ Deaktiviert",
"Benutzername und Passwort eingetragen",
"Device ID und Tracker ID korrekt gesetzt",
"Standortberechtigungen 'Immer' erteilt",
"Akkuoptimierung deaktiviert (Android)",
"Verbindung erfolgreich (grünes Symbol)",
"Position auf Karte sichtbar",
];
return (
<ul className="space-y-2 text-sm">
{items.map((item, i) => (
<li key={i} className="flex items-start gap-2">
<input type="checkbox" className="mt-1" />
<span>{item}</span>
</li>
))}
</ul>
);
}

543
app/admin/users/page.tsx Normal file
View File

@@ -0,0 +1,543 @@
"use client";
import { useEffect, useState } from "react";
interface User {
id: string;
username: string;
email: string | null;
role: string;
createdAt: string;
lastLoginAt: string | null;
}
export default function UsersPage() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showAddModal, setShowAddModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [formData, setFormData] = useState({
username: "",
email: "",
password: "",
role: "VIEWER",
});
// Fetch users
const fetchUsers = async () => {
try {
const response = await fetch("/api/users");
if (!response.ok) throw new Error("Failed to fetch users");
const data = await response.json();
setUsers(data.users);
setError(null);
} catch (err) {
setError("Failed to load users");
console.error(err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchUsers();
}, []);
// Handle Add User
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
try {
const response = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to create user");
}
await fetchUsers();
setShowAddModal(false);
setFormData({ username: "", email: "", password: "", role: "VIEWER" });
} catch (err: any) {
alert(err.message || "Failed to create user");
}
};
// Handle Edit User
const handleEdit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedUser) return;
try {
const updateData: any = {
username: formData.username,
email: formData.email || null,
role: formData.role,
};
// Only include password if it's been changed
if (formData.password) {
updateData.password = formData.password;
}
const response = await fetch(`/api/users/${selectedUser.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updateData),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to update user");
}
await fetchUsers();
setShowEditModal(false);
setSelectedUser(null);
setFormData({ username: "", email: "", password: "", role: "VIEWER" });
} catch (err: any) {
alert(err.message || "Failed to update user");
}
};
// Handle Delete User
const handleDelete = async () => {
if (!selectedUser) return;
try {
const response = await fetch(`/api/users/${selectedUser.id}`, {
method: "DELETE",
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to delete user");
}
await fetchUsers();
setShowDeleteModal(false);
setSelectedUser(null);
} catch (err: any) {
alert(err.message || "Failed to delete user");
}
};
// Resend welcome email
const handleResendWelcome = async (user: User) => {
if (!user.email) {
alert('This user has no email address');
return;
}
if (!confirm(`Send welcome email to ${user.email}?`)) {
return;
}
try {
const response = await fetch('/api/admin/emails/send-test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
template: 'welcome',
email: user.email,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to send email');
}
alert('Welcome email sent successfully');
} catch (err: any) {
alert(err.message || 'Failed to send welcome email');
}
};
// Send password reset
const handleSendPasswordReset = async (user: User) => {
if (!user.email) {
alert('This user has no email address');
return;
}
if (!confirm(`Send password reset email to ${user.email}?`)) {
return;
}
try {
const response = await fetch('/api/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: user.email }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to send email');
}
alert('Password reset email sent successfully');
} catch (err: any) {
alert(err.message || 'Failed to send password reset email');
}
};
// Open Edit Modal
const openEditModal = (user: User) => {
setSelectedUser(user);
setFormData({
username: user.username,
email: user.email || "",
password: "", // Leave empty unless user wants to change it
role: user.role,
});
setShowEditModal(true);
};
// Open Delete Modal
const openDeleteModal = (user: User) => {
setSelectedUser(user);
setShowDeleteModal(true);
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<p className="text-gray-600">Loading users...</p>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-64">
<p className="text-red-600">{error}</p>
</div>
);
}
return (
<div>
{/* Header */}
<div className="flex justify-between items-center mb-6">
<h2 className="text-3xl font-bold text-gray-900">User Management</h2>
<button
onClick={() => {
setFormData({ username: "", email: "", password: "", role: "VIEWER" });
setShowAddModal(true);
}}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Add User
</button>
</div>
{/* Users Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{users.map((user) => (
<div
key={user.id}
className="bg-white rounded-lg shadow-md p-6 border-l-4"
style={{
borderLeftColor: user.role === "ADMIN" ? "#ef4444" : "#3b82f6",
}}
>
<div className="flex items-start justify-between mb-4">
<span
className={`px-2 py-1 text-xs font-medium rounded ${
user.role === "ADMIN"
? "bg-red-100 text-red-800"
: "bg-blue-100 text-blue-800"
}`}
>
{user.role}
</span>
</div>
<div className="space-y-2 text-sm mb-4">
<p>
<span className="font-medium text-gray-700">Username:</span>{" "}
<span className="text-gray-900">{user.username}</span>
</p>
<p>
<span className="font-medium text-gray-700">Email:</span>{" "}
<span className="text-gray-900">{user.email || "—"}</span>
</p>
<p className="text-gray-600">
Created: {new Date(user.createdAt).toLocaleDateString()}
</p>
{user.lastLoginAt && (
<p className="text-gray-600">
Last login: {new Date(user.lastLoginAt).toLocaleString()}
</p>
)}
</div>
<div className="flex gap-2">
<button
onClick={() => openEditModal(user)}
className="flex-1 px-3 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
>
Edit
</button>
<button
onClick={() => openDeleteModal(user)}
className="flex-1 px-3 py-2 bg-red-600 text-white text-sm rounded-md hover:bg-red-700"
>
Delete
</button>
</div>
{/* Email Actions */}
{user.email && (
<div className="flex gap-2 mt-2">
<button
onClick={() => handleResendWelcome(user)}
className="flex-1 px-3 py-2 bg-green-600 text-white text-xs rounded-md hover:bg-green-700"
>
Resend Welcome
</button>
<button
onClick={() => handleSendPasswordReset(user)}
className="flex-1 px-3 py-2 bg-orange-600 text-white text-xs rounded-md hover:bg-orange-700"
>
Reset Password
</button>
</div>
)}
</div>
))}
</div>
{users.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-600">No users found. Create your first user!</p>
</div>
)}
{/* Add User Modal */}
{showAddModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h3 className="text-xl font-bold mb-4">Add New User</h3>
<form onSubmit={handleAdd}>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Username *
</label>
<input
type="text"
required
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
type="email"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Password *
</label>
<input
type="password"
required
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Role *
</label>
<select
value={formData.role}
onChange={(e) =>
setFormData({ ...formData, role: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="VIEWER">VIEWER</option>
<option value="ADMIN">ADMIN</option>
</select>
</div>
</div>
<div className="flex gap-3 mt-6">
<button
type="button"
onClick={() => {
setShowAddModal(false);
setFormData({ username: "", email: "", password: "", role: "VIEWER" });
}}
className="flex-1 px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-100"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Add User
</button>
</div>
</form>
</div>
</div>
)}
{/* Edit User Modal */}
{showEditModal && selectedUser && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h3 className="text-xl font-bold mb-4">Edit User</h3>
<form onSubmit={handleEdit}>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Username *
</label>
<input
type="text"
required
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
type="email"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
type="password"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<p className="text-xs text-gray-500 mt-1">
Leave empty to keep current password
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Role *
</label>
<select
value={formData.role}
onChange={(e) =>
setFormData({ ...formData, role: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="VIEWER">VIEWER</option>
<option value="ADMIN">ADMIN</option>
</select>
</div>
</div>
<div className="flex gap-3 mt-6">
<button
type="button"
onClick={() => {
setShowEditModal(false);
setSelectedUser(null);
setFormData({ username: "", email: "", password: "", role: "VIEWER" });
}}
className="flex-1 px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-100"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Save Changes
</button>
</div>
</form>
</div>
</div>
)}
{/* Delete User Modal */}
{showDeleteModal && selectedUser && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h3 className="text-xl font-bold mb-4 text-red-600">
Delete User
</h3>
<p className="text-gray-700 mb-6">
Are you sure you want to delete user <strong>{selectedUser.username}</strong>?
This action cannot be undone.
</p>
<div className="flex gap-3">
<button
onClick={() => {
setShowDeleteModal(false);
setSelectedUser(null);
}}
className="flex-1 px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-100"
>
Cancel
</button>
<button
onClick={handleDelete}
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
>
Delete
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { renderEmailTemplate } from '@/lib/email-renderer';
/**
* GET /api/admin/emails/preview?template=welcome
* Render email template with sample data for preview
*/
export async function GET(request: Request) {
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const template = searchParams.get('template');
if (!template) {
return NextResponse.json(
{ error: 'Template parameter is required' },
{ status: 400 }
);
}
// Sample data for each template
const sampleData: Record<string, any> = {
welcome: {
username: 'John Doe',
loginUrl: `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/login`,
temporaryPassword: 'TempPass123!',
},
'password-reset': {
username: 'John Doe',
resetUrl: `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/reset-password?token=sample-token-123`,
expiresIn: '1 hour',
},
};
if (!sampleData[template]) {
return NextResponse.json(
{ error: `Unknown template: ${template}` },
{ status: 400 }
);
}
const html = await renderEmailTemplate(template, sampleData[template]);
return new NextResponse(html, {
headers: { 'Content-Type': 'text/html' },
});
} catch (error) {
console.error('[API] Email preview failed:', error);
return NextResponse.json(
{ error: 'Failed to render email template' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,106 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { emailService } from '@/lib/email-service';
// Simple rate limiting (in-memory)
const rateLimitMap = new Map<string, number[]>();
const RATE_LIMIT = 5; // max requests
const RATE_WINDOW = 60 * 1000; // per minute
function checkRateLimit(ip: string): boolean {
const now = Date.now();
const requests = rateLimitMap.get(ip) || [];
// Filter out old requests
const recentRequests = requests.filter(time => now - time < RATE_WINDOW);
if (recentRequests.length >= RATE_LIMIT) {
return false;
}
recentRequests.push(now);
rateLimitMap.set(ip, recentRequests);
return true;
}
/**
* POST /api/admin/emails/send-test
* Send test email with specific template
*/
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Rate limiting
const ip = request.headers.get('x-forwarded-for') || 'unknown';
if (!checkRateLimit(ip)) {
return NextResponse.json(
{ error: 'Too many requests. Please wait a minute.' },
{ status: 429 }
);
}
const body = await request.json();
const { template, email } = body;
if (!template || !email) {
return NextResponse.json(
{ error: 'Template and email are required' },
{ status: 400 }
);
}
// Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json(
{ error: 'Invalid email address' },
{ status: 400 }
);
}
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
// Send appropriate template
switch (template) {
case 'welcome':
await emailService.sendWelcomeEmail({
email,
username: 'Test User',
loginUrl: `${baseUrl}/login`,
temporaryPassword: 'TempPass123!',
});
break;
case 'password-reset':
await emailService.sendPasswordResetEmail({
email,
username: 'Test User',
resetUrl: `${baseUrl}/reset-password?token=sample-token-123`,
expiresIn: '1 hour',
});
break;
default:
return NextResponse.json(
{ error: `Unknown template: ${template}` },
{ status: 400 }
);
}
return NextResponse.json({
success: true,
message: `Test email sent to ${email}`,
});
} catch (error) {
console.error('[API] Send test email failed:', error);
return NextResponse.json(
{ error: `Failed to send email: ${error instanceof Error ? error.message : 'Unknown error'}` },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,149 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { settingsDb } from '@/lib/settings-db';
import { emailService } from '@/lib/email-service';
import { SMTPConfig, SMTPConfigResponse } from '@/lib/types/smtp';
/**
* GET /api/admin/settings/smtp
* Returns current SMTP configuration (password masked)
*/
export async function GET() {
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const dbConfig = settingsDb.getSMTPConfig();
let response: SMTPConfigResponse;
if (dbConfig) {
// Mask password
const maskedConfig = {
...dbConfig,
auth: {
...dbConfig.auth,
pass: '***',
},
};
response = { config: maskedConfig, source: 'database' };
} else {
// Check if env config exists
const hasEnvConfig =
process.env.SMTP_HOST &&
process.env.SMTP_USER &&
process.env.SMTP_PASS;
if (hasEnvConfig) {
const envConfig: SMTPConfig = {
host: process.env.SMTP_HOST!,
port: parseInt(process.env.SMTP_PORT || '587', 10),
secure: process.env.SMTP_SECURE === 'true',
auth: {
user: process.env.SMTP_USER!,
pass: '***',
},
from: {
email: process.env.SMTP_FROM_EMAIL || '',
name: process.env.SMTP_FROM_NAME || 'Location Tracker',
},
replyTo: process.env.SMTP_REPLY_TO,
timeout: 10000,
};
response = { config: envConfig, source: 'env' };
} else {
response = { config: null, source: 'env' };
}
}
return NextResponse.json(response);
} catch (error) {
console.error('[API] Failed to get SMTP config:', error);
return NextResponse.json(
{ error: 'Failed to get SMTP configuration' },
{ status: 500 }
);
}
}
/**
* POST /api/admin/settings/smtp
* Save SMTP configuration to database
*/
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const config = body.config as SMTPConfig;
// Trim whitespace from credentials to prevent auth errors
if (config.host) config.host = config.host.trim();
if (config.auth?.user) config.auth.user = config.auth.user.trim();
if (config.auth?.pass) config.auth.pass = config.auth.pass.trim();
if (config.from?.email) config.from.email = config.from.email.trim();
// Validation
if (!config.host || !config.port || !config.auth?.user || !config.auth?.pass) {
return NextResponse.json(
{ error: 'Missing required SMTP configuration fields' },
{ status: 400 }
);
}
if (config.port < 1 || config.port > 65535) {
return NextResponse.json(
{ error: 'Port must be between 1 and 65535' },
{ status: 400 }
);
}
// Save to database (password will be encrypted)
settingsDb.setSMTPConfig(config);
// Reset the cached transporter to use new config
emailService.resetTransporter();
return NextResponse.json({ success: true });
} catch (error) {
console.error('[API] Failed to save SMTP config:', error);
return NextResponse.json(
{ error: 'Failed to save SMTP configuration' },
{ status: 500 }
);
}
}
/**
* DELETE /api/admin/settings/smtp
* Reset to environment config
*/
export async function DELETE() {
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
settingsDb.delete('smtp_config');
// Reset the cached transporter to use env config
emailService.resetTransporter();
return NextResponse.json({ success: true });
} catch (error) {
console.error('[API] Failed to delete SMTP config:', error);
return NextResponse.json(
{ error: 'Failed to reset SMTP configuration' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,78 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { emailService } from '@/lib/email-service';
import { SMTPConfig } from '@/lib/types/smtp';
/**
* POST /api/admin/settings/smtp/test
* Test SMTP configuration by sending a test email
*/
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { config, testEmail } = body as { config?: SMTPConfig; testEmail: string };
if (!testEmail) {
return NextResponse.json(
{ error: 'Test email address is required' },
{ status: 400 }
);
}
// Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(testEmail)) {
return NextResponse.json(
{ error: 'Invalid email address' },
{ status: 400 }
);
}
// Test connection
try {
await emailService.testConnection(config);
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : 'SMTP connection failed. Please check your settings.'
},
{ status: 500 }
);
}
// Send test email
try {
await emailService.sendWelcomeEmail({
email: testEmail,
username: 'Test User',
loginUrl: `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/login`,
temporaryPassword: undefined,
});
return NextResponse.json({
success: true,
message: `Test email sent successfully to ${testEmail}`,
});
} catch (sendError) {
console.error('[API] Test email send failed:', sendError);
return NextResponse.json(
{
error: `Email send failed: ${sendError instanceof Error ? sendError.message : 'Unknown error'}`,
},
{ status: 500 }
);
}
} catch (error) {
console.error('[API] SMTP test failed:', error);
return NextResponse.json(
{ error: 'SMTP test failed' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,3 @@
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;

View File

@@ -0,0 +1,77 @@
import { NextResponse } from 'next/server';
import { userDb } from '@/lib/db';
import { passwordResetDb } from '@/lib/password-reset-db';
import { emailService } from '@/lib/email-service';
/**
* POST /api/auth/forgot-password
* Request password reset email
*/
export async function POST(request: Request) {
try {
const body = await request.json();
const { email } = body;
if (!email) {
return NextResponse.json(
{ error: 'Email is required' },
{ status: 400 }
);
}
// Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json(
{ error: 'Invalid email address' },
{ status: 400 }
);
}
// Find user by email
const users = userDb.findAll();
const user = users.find(u => u.email?.toLowerCase() === email.toLowerCase());
// SECURITY: Always return success to prevent user enumeration
// Even if user doesn't exist, return success but don't send email
if (!user) {
console.log('[ForgotPassword] Email not found, but returning success (security)');
return NextResponse.json({
success: true,
message: 'If an account exists with this email, a password reset link has been sent.',
});
}
// Create password reset token
const token = passwordResetDb.create(user.id, 1); // 1 hour expiry
// Send password reset email
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
const resetUrl = `${baseUrl}/reset-password?token=${token}`;
try {
await emailService.sendPasswordResetEmail({
email: user.email!,
username: user.username,
resetUrl,
expiresIn: '1 hour',
});
console.log('[ForgotPassword] Password reset email sent to:', user.email);
} catch (emailError) {
console.error('[ForgotPassword] Failed to send email:', emailError);
// Don't fail the request if email fails - log and continue
}
return NextResponse.json({
success: true,
message: 'If an account exists with this email, a password reset link has been sent.',
});
} catch (error) {
console.error('[ForgotPassword] Error:', error);
return NextResponse.json(
{ error: 'An error occurred. Please try again later.' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,121 @@
import { NextResponse } from 'next/server';
import { userDb } from '@/lib/db';
import bcrypt from 'bcryptjs';
import { randomUUID } from 'crypto';
import { emailService } from '@/lib/email-service';
/**
* POST /api/auth/register
* Public user registration endpoint
*/
export async function POST(request: Request) {
try {
const body = await request.json();
const { username, email, password } = body;
// Validation
if (!username || !email || !password) {
return NextResponse.json(
{ error: 'Missing required fields: username, email, password' },
{ status: 400 }
);
}
// Username validation (at least 3 characters, alphanumeric + underscore)
if (username.length < 3) {
return NextResponse.json(
{ error: 'Username must be at least 3 characters long' },
{ status: 400 }
);
}
if (!/^[a-zA-Z0-9_]+$/.test(username)) {
return NextResponse.json(
{ error: 'Username can only contain letters, numbers, and underscores' },
{ status: 400 }
);
}
// Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json(
{ error: 'Invalid email address' },
{ status: 400 }
);
}
// Password validation (at least 6 characters)
if (password.length < 6) {
return NextResponse.json(
{ error: 'Password must be at least 6 characters long' },
{ status: 400 }
);
}
// Check if username already exists
const existingUser = userDb.findByUsername(username);
if (existingUser) {
return NextResponse.json(
{ error: 'Username already taken' },
{ status: 409 }
);
}
// Check if email already exists
const allUsers = userDb.findAll();
const emailExists = allUsers.find(u => u.email?.toLowerCase() === email.toLowerCase());
if (emailExists) {
return NextResponse.json(
{ error: 'Email already registered' },
{ status: 409 }
);
}
// Hash password
const passwordHash = await bcrypt.hash(password, 10);
// Create user with VIEWER role (new users get viewer access by default)
const user = userDb.create({
id: randomUUID(),
username,
email,
passwordHash,
role: 'VIEWER', // New registrations get VIEWER role
});
console.log('[Register] New user registered:', username);
// Send welcome email (don't fail registration if email fails)
try {
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
await emailService.sendWelcomeEmail({
email,
username,
loginUrl: `${baseUrl}/login`,
});
console.log('[Register] Welcome email sent to:', email);
} catch (emailError) {
console.error('[Register] Failed to send welcome email:', emailError);
// Don't fail registration if email fails
}
// Remove password hash from response
const { passwordHash: _, ...safeUser } = user;
return NextResponse.json(
{
success: true,
message: 'Account created successfully',
user: safeUser,
},
{ status: 201 }
);
} catch (error) {
console.error('[Register] Error:', error);
return NextResponse.json(
{ error: 'Registration failed. Please try again later.' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,106 @@
import { NextResponse } from 'next/server';
import { userDb } from '@/lib/db';
import { passwordResetDb } from '@/lib/password-reset-db';
import bcrypt from 'bcryptjs';
/**
* POST /api/auth/reset-password
* Reset password with token
*/
export async function POST(request: Request) {
try {
const body = await request.json();
const { token, newPassword } = body;
if (!token || !newPassword) {
return NextResponse.json(
{ error: 'Token and new password are required' },
{ status: 400 }
);
}
// Password validation
if (newPassword.length < 6) {
return NextResponse.json(
{ error: 'Password must be at least 6 characters' },
{ status: 400 }
);
}
// Validate token
if (!passwordResetDb.isValid(token)) {
return NextResponse.json(
{ error: 'Invalid or expired reset token' },
{ status: 400 }
);
}
// Get token details
const resetToken = passwordResetDb.findByToken(token);
if (!resetToken) {
return NextResponse.json(
{ error: 'Invalid reset token' },
{ status: 400 }
);
}
// Get user
const user = userDb.findById(resetToken.user_id);
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
// Hash new password
const passwordHash = await bcrypt.hash(newPassword, 10);
// Update user password
userDb.update(user.id, { passwordHash });
// Mark token as used
passwordResetDb.markUsed(token);
console.log('[ResetPassword] Password reset successful for user:', user.username);
return NextResponse.json({
success: true,
message: 'Password has been reset successfully',
});
} catch (error) {
console.error('[ResetPassword] Error:', error);
return NextResponse.json(
{ error: 'Failed to reset password' },
{ status: 500 }
);
}
}
/**
* GET /api/auth/reset-password?token=xxx
* Validate reset token (for checking if link is still valid)
*/
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const token = searchParams.get('token');
if (!token) {
return NextResponse.json(
{ error: 'Token is required' },
{ status: 400 }
);
}
const isValid = passwordResetDb.isValid(token);
return NextResponse.json({ valid: isValid });
} catch (error) {
console.error('[ResetPassword] Validation error:', error);
return NextResponse.json(
{ error: 'Failed to validate token' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,129 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { deviceDb } from "@/lib/db";
// GET /api/devices/[id] - Get single device
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const device = deviceDb.findById(id);
if (!device) {
return NextResponse.json({ error: "Device not found" }, { status: 404 });
}
return NextResponse.json({
device: {
id: device.id,
name: device.name,
color: device.color,
isActive: device.isActive === 1,
createdAt: device.createdAt,
updatedAt: device.updatedAt,
description: device.description,
icon: device.icon,
}
});
} catch (error) {
console.error("Error fetching device:", error);
return NextResponse.json(
{ error: "Failed to fetch device" },
{ status: 500 }
);
}
}
// PATCH /api/devices/[id] - Update device (ADMIN only)
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Only ADMIN can update devices
if ((session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
const device = deviceDb.findById(id);
if (!device) {
return NextResponse.json({ error: "Device not found" }, { status: 404 });
}
const body = await request.json();
const { name, color, description, icon } = body;
const updated = deviceDb.update(id, {
name,
color,
description,
icon,
});
if (!updated) {
return NextResponse.json({ error: "Failed to update device" }, { status: 500 });
}
return NextResponse.json({ device: updated });
} catch (error) {
console.error("Error updating device:", error);
return NextResponse.json(
{ error: "Failed to update device" },
{ status: 500 }
);
}
}
// DELETE /api/devices/[id] - Soft delete device (ADMIN only)
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Only ADMIN can delete devices
if ((session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
const device = deviceDb.findById(id);
if (!device) {
return NextResponse.json({ error: "Device not found" }, { status: 404 });
}
const success = deviceDb.delete(id);
if (!success) {
return NextResponse.json({ error: "Failed to delete device" }, { status: 500 });
}
return NextResponse.json({ message: "Device deleted successfully" });
} catch (error) {
console.error("Error deleting device:", error);
return NextResponse.json(
{ error: "Failed to delete device" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { deviceDb, userDb } from "@/lib/db";
// GET /api/devices/public - Authenticated endpoint for device names and colors
export async function GET() {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = (session.user as any).id;
const role = (session.user as any).role;
const username = session.user.name || '';
// Get list of device IDs the user is allowed to access
const allowedDeviceIds = userDb.getAllowedDeviceIds(userId, role, username);
// Fetch all active devices
const allDevices = deviceDb.findAll();
// Filter to only devices the user can access
const userDevices = allDevices.filter(device =>
allowedDeviceIds.includes(device.id)
);
// Return only public information (id, name, color)
const publicDevices = userDevices.map((device) => ({
id: device.id,
name: device.name,
color: device.color,
}));
return NextResponse.json({ devices: publicDevices });
} catch (error) {
console.error("Error fetching public devices:", error);
return NextResponse.json(
{ error: "Failed to fetch devices" },
{ status: 500 }
);
}
}

120
app/api/devices/route.ts Normal file
View File

@@ -0,0 +1,120 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { deviceDb, locationDb, userDb } from "@/lib/db";
// GET /api/devices - List all devices (from database)
export async function GET() {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Get devices from database (filtered by user ownership)
const userId = (session.user as any).id;
let targetUserId = userId;
// If user is a VIEWER with a parent, show parent's devices instead
const currentUser = userDb.findById(userId);
if (currentUser && currentUser.role === 'VIEWER' && currentUser.parent_user_id) {
targetUserId = currentUser.parent_user_id;
console.log(`[Devices] VIEWER ${currentUser.username} accessing parent's devices (parent_id: ${targetUserId})`);
}
const devices = deviceDb.findAll({ userId: targetUserId });
// Fetch location data from local SQLite cache (24h history)
const allLocations = locationDb.findMany({
user_id: 0, // MQTT devices only
timeRangeHours: 24, // Last 24 hours
limit: 10000,
});
// Merge devices with latest location data
const devicesWithLocation = devices.map((device) => {
// Find all locations for this device
const deviceLocations = allLocations.filter((loc) => loc.username === device.id);
// Get latest location (first one, already sorted by timestamp DESC)
const latestLocation = deviceLocations[0] || null;
return {
id: device.id,
name: device.name,
color: device.color,
isActive: device.isActive === 1,
createdAt: device.createdAt,
updatedAt: device.updatedAt,
description: device.description,
icon: device.icon,
latestLocation: latestLocation,
_count: {
locations: deviceLocations.length,
},
};
});
return NextResponse.json({ devices: devicesWithLocation });
} catch (error) {
console.error("Error fetching devices:", error);
return NextResponse.json(
{ error: "Failed to fetch devices" },
{ status: 500 }
);
}
}
// POST /api/devices - Create new device (ADMIN only)
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Only ADMIN can create devices
if ((session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await request.json();
const { id, name, color, description, icon } = body;
// Validation
if (!id || !name || !color) {
return NextResponse.json(
{ error: "Missing required fields: id, name, color" },
{ status: 400 }
);
}
// Check if device with this ID already exists
const existing = deviceDb.findById(id);
if (existing) {
return NextResponse.json(
{ error: "Device with this ID already exists" },
{ status: 409 }
);
}
// Create device
const device = deviceDb.create({
id,
name,
color,
ownerId: (session.user as any).id,
description,
icon,
});
return NextResponse.json({ device }, { status: 201 });
} catch (error) {
console.error("Error creating device:", error);
return NextResponse.json(
{ error: "Failed to create device" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { locationDb } from '@/lib/db';
/**
* POST /api/locations/cleanup (ADMIN only)
*
* Delete old location records and optimize database
*
* Body:
* {
* "retentionHours": 168 // 7 days default
* }
*/
export async function POST(request: NextRequest) {
try {
// Super admin only (username "admin")
const session = await auth();
const username = session?.user?.name || '';
if (!session?.user || username !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const retentionHours = body.retentionHours || 168; // Default: 7 days
// Validate retention period
if (retentionHours <= 0 || retentionHours > 8760) { // Max 1 year
return NextResponse.json(
{ error: 'Invalid retention period. Must be between 1 and 8760 hours (1 year)' },
{ status: 400 }
);
}
// Get stats before cleanup
const statsBefore = locationDb.getStats();
// Delete old records
const deletedCount = locationDb.deleteOlderThan(retentionHours);
// Get stats after cleanup
const statsAfter = locationDb.getStats();
return NextResponse.json({
success: true,
deleted: deletedCount,
retentionHours,
retentionDays: Math.round(retentionHours / 24),
before: {
total: statsBefore.total,
sizeKB: statsBefore.sizeKB,
oldest: statsBefore.oldest,
newest: statsBefore.newest,
},
after: {
total: statsAfter.total,
sizeKB: statsAfter.sizeKB,
oldest: statsAfter.oldest,
newest: statsAfter.newest,
},
freedKB: statsBefore.sizeKB - statsAfter.sizeKB,
});
} catch (error) {
console.error('Cleanup error:', error);
return NextResponse.json(
{
error: 'Failed to cleanup locations',
details: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,101 @@
import { NextRequest, NextResponse } from 'next/server';
import { locationDb, Location } from '@/lib/db';
/**
* POST /api/locations/ingest
*
* Endpoint for n8n to push location data to local SQLite cache.
* This is called AFTER n8n stores the data in NocoDB.
*
* Expected payload (single location or array):
* {
* "latitude": 48.1351,
* "longitude": 11.5820,
* "timestamp": "2024-01-15T10:30:00Z",
* "user_id": 0,
* "username": "10",
* "marker_label": "Device A",
* "battery": 85,
* "speed": 2.5,
* ...
* }
*
* Security: Add API key validation in production!
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Support both single location and array of locations
const locations = Array.isArray(body) ? body : [body];
if (locations.length === 0) {
return NextResponse.json(
{ error: 'No location data provided' },
{ status: 400 }
);
}
// Debug logging for speed and battery values
console.log('[Ingest Debug] Received locations:', locations.map(loc => ({
username: loc.username,
speed: loc.speed,
speed_type: typeof loc.speed,
battery: loc.battery,
battery_type: typeof loc.battery
})));
// Validate required fields
for (const loc of locations) {
if (!loc.latitude || !loc.longitude || !loc.timestamp) {
return NextResponse.json(
{ error: 'Missing required fields: latitude, longitude, timestamp' },
{ status: 400 }
);
}
}
// Insert into SQLite
let insertedCount = 0;
if (locations.length === 1) {
locationDb.create(locations[0] as Location);
insertedCount = 1;
} else {
insertedCount = locationDb.createMany(locations as Location[]);
}
return NextResponse.json({
success: true,
inserted: insertedCount,
message: `Successfully stored ${insertedCount} location(s)`
});
} catch (error) {
console.error('Location ingest error:', error);
return NextResponse.json(
{
error: 'Failed to store location data',
details: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
}
/**
* GET /api/locations/ingest/stats
*
* Get database statistics (for debugging)
*/
export async function GET() {
try {
const stats = locationDb.getStats();
return NextResponse.json(stats);
} catch (error) {
console.error('Stats error:', error);
return NextResponse.json(
{ error: 'Failed to get stats' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,62 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { getLocationsDb } from '@/lib/db';
/**
* POST /api/locations/optimize (ADMIN only)
*
* Optimize database by running VACUUM and ANALYZE
* This reclaims unused space and updates query planner statistics
*/
export async function POST() {
try {
// Super admin only (username "admin")
const session = await auth();
const username = session?.user?.name || '';
if (!session?.user || username !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const db = getLocationsDb();
// Get size before optimization
const sizeBefore = db.prepare(
"SELECT page_count * page_size / 1024 / 1024.0 as sizeMB FROM pragma_page_count(), pragma_page_size()"
).get() as { sizeMB: number };
// Run VACUUM to reclaim space
db.exec('VACUUM');
// Run ANALYZE to update query planner statistics
db.exec('ANALYZE');
// Get size after optimization
const sizeAfter = db.prepare(
"SELECT page_count * page_size / 1024 / 1024.0 as sizeMB FROM pragma_page_count(), pragma_page_size()"
).get() as { sizeMB: number };
db.close();
const freedMB = sizeBefore.sizeMB - sizeAfter.sizeMB;
return NextResponse.json({
success: true,
before: {
sizeMB: Math.round(sizeBefore.sizeMB * 100) / 100,
},
after: {
sizeMB: Math.round(sizeAfter.sizeMB * 100) / 100,
},
freedMB: Math.round(freedMB * 100) / 100,
});
} catch (error) {
console.error('Optimize error:', error);
return NextResponse.json(
{
error: 'Failed to optimize database',
details: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
}

230
app/api/locations/route.ts Normal file
View File

@@ -0,0 +1,230 @@
import { NextRequest, NextResponse } from "next/server";
import type { LocationResponse } from "@/types/location";
import { locationDb, Location, deviceDb, userDb } from "@/lib/db";
import { auth } from "@/lib/auth";
const N8N_API_URL = process.env.N8N_API_URL || "https://n8n.example.com/webhook/location";
/**
* GET /api/locations
*
* Hybrid approach:
* 1. Fetch fresh data from n8n webhook
* 2. Store new locations in local SQLite cache
* 3. Return filtered data from SQLite (enables 24h+ history)
*
* Query parameters:
* - username: Filter by device tracker ID
* - timeRangeHours: Filter by time range (e.g., 1, 3, 6, 12, 24)
* - startTime: Custom range start (ISO string)
* - endTime: Custom range end (ISO string)
* - limit: Maximum number of records (default: 1000)
* - sync: Set to 'false' to skip n8n fetch and read only from cache
*/
export async function GET(request: NextRequest) {
try {
// Check authentication
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Get user's allowed device IDs for filtering locations
const userId = (session.user as any).id;
const role = (session.user as any).role;
const sessionUsername = session.user.name || '';
// Get list of device IDs the user is allowed to access
const userDeviceIds = userDb.getAllowedDeviceIds(userId, role, sessionUsername);
// If user has no devices, return empty response
if (userDeviceIds.length === 0) {
return NextResponse.json({
success: true,
current: null,
history: [],
total_points: 0,
last_updated: new Date().toISOString(),
});
}
const searchParams = request.nextUrl.searchParams;
const username = searchParams.get('username') || undefined;
const timeRangeHours = searchParams.get('timeRangeHours')
? parseInt(searchParams.get('timeRangeHours')!, 10)
: undefined;
const startTime = searchParams.get('startTime') || undefined;
const endTime = searchParams.get('endTime') || undefined;
const limit = searchParams.get('limit')
? parseInt(searchParams.get('limit')!, 10)
: 1000;
const sync = searchParams.get('sync') !== 'false'; // Default: true
// Variable to store n8n data as fallback
let n8nData: LocationResponse | null = null;
// Step 1: Optionally fetch and sync from n8n
if (sync) {
try {
const response = await fetch(N8N_API_URL, {
cache: "no-store",
signal: AbortSignal.timeout(3000), // 3 second timeout
});
if (response.ok) {
const data: LocationResponse = await response.json();
// Debug: Log first location from n8n
if (data.history && data.history.length > 0) {
console.log('[N8N Debug] First location from n8n:', {
username: data.history[0].username,
speed: data.history[0].speed,
speed_type: typeof data.history[0].speed,
speed_exists: 'speed' in data.history[0],
battery: data.history[0].battery,
battery_type: typeof data.history[0].battery,
battery_exists: 'battery' in data.history[0]
});
}
// Normalize data: Ensure speed and battery fields exist (treat 0 explicitly)
if (data.history && Array.isArray(data.history)) {
data.history = data.history.map(loc => {
// Generate display_time in German locale (Europe/Berlin timezone)
const displayTime = new Date(loc.timestamp).toLocaleString('de-DE', {
timeZone: 'Europe/Berlin',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
return {
...loc,
display_time: displayTime,
// Explicit handling: 0 is valid, only undefined/null → null
speed: typeof loc.speed === 'number' ? loc.speed : (loc.speed !== undefined && loc.speed !== null ? Number(loc.speed) : null),
battery: typeof loc.battery === 'number' ? loc.battery : (loc.battery !== undefined && loc.battery !== null ? Number(loc.battery) : null),
};
});
}
// Store n8n data for fallback
n8nData = data;
// Store new locations in SQLite
if (data.history && Array.isArray(data.history) && data.history.length > 0) {
// Get latest timestamp from our DB
const stats = locationDb.getStats();
const lastLocalTimestamp = stats.newest || '1970-01-01T00:00:00Z';
// Filter for only newer locations
const newLocations = data.history.filter(loc =>
loc.timestamp > lastLocalTimestamp
);
if (newLocations.length > 0) {
const inserted = locationDb.createMany(newLocations as Location[]);
console.log(`[Location Sync] Inserted ${inserted} new locations from n8n`);
}
}
}
} catch (syncError) {
// n8n not reachable - that's ok, we'll use cached data
console.warn('[Location Sync] n8n webhook not reachable, using cache only:',
syncError instanceof Error ? syncError.message : 'Unknown error');
}
}
// Step 2: Read from local SQLite with filters
let locations = locationDb.findMany({
user_id: 0, // Always filter for MQTT devices
username,
timeRangeHours,
startTime,
endTime,
limit,
});
// Filter locations to only include user's devices
locations = locations.filter(loc => loc.username && userDeviceIds.includes(loc.username));
// Step 3: If DB is empty, use n8n data as fallback
if (locations.length === 0 && n8nData && n8nData.history) {
console.log('[API] DB empty, using n8n data as fallback');
// Filter n8n data if needed
let filteredHistory = n8nData.history;
// Filter by user's devices
filteredHistory = filteredHistory.filter(loc => loc.username && userDeviceIds.includes(loc.username));
if (username) {
filteredHistory = filteredHistory.filter(loc => loc.username === username);
}
// Apply time filters
if (startTime && endTime) {
// Custom range
filteredHistory = filteredHistory.filter(loc =>
loc.timestamp >= startTime && loc.timestamp <= endTime
);
} else if (timeRangeHours) {
// Quick filter
const cutoffTime = new Date(Date.now() - timeRangeHours * 60 * 60 * 1000).toISOString();
filteredHistory = filteredHistory.filter(loc => loc.timestamp >= cutoffTime);
}
return NextResponse.json({
...n8nData,
history: filteredHistory,
total_points: filteredHistory.length,
});
}
// Normalize locations: Ensure speed, battery, and display_time are correct
locations = locations.map(loc => {
// Generate display_time if missing or regenerate from timestamp
const displayTime = loc.display_time || new Date(loc.timestamp).toLocaleString('de-DE', {
timeZone: 'Europe/Berlin',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
return {
...loc,
display_time: displayTime,
speed: loc.speed !== undefined && loc.speed !== null ? Number(loc.speed) : null,
battery: loc.battery !== undefined && loc.battery !== null ? Number(loc.battery) : null,
};
});
// Get actual total count from database (not limited by 'limit' parameter)
const stats = locationDb.getStats();
// Step 4: Return data in n8n-compatible format
const response: LocationResponse = {
success: true,
current: locations.length > 0 ? locations[0] : null,
history: locations,
total_points: stats.total, // Use actual total from DB, not limited results
last_updated: locations.length > 0 ? locations[0].timestamp : new Date().toISOString(),
};
return NextResponse.json(response);
} catch (error) {
console.error("Error fetching locations:", error);
return NextResponse.json(
{
error: "Failed to fetch locations",
details: error instanceof Error ? error.message : "Unknown error"
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,77 @@
import { NextResponse } from 'next/server';
import { getLocationsDb } from '@/lib/db';
/**
* GET /api/locations/stats
*
* Get detailed database statistics
*/
export async function GET() {
try {
const db = getLocationsDb();
// Overall stats
const totalCount = db.prepare('SELECT COUNT(*) as count FROM Location').get() as { count: number };
// Time range
const timeRange = db.prepare(
'SELECT MIN(timestamp) as oldest, MAX(timestamp) as newest FROM Location'
).get() as { oldest: string | null; newest: string | null };
// Database size
const dbSize = db.prepare(
"SELECT page_count * page_size / 1024 / 1024.0 as sizeMB FROM pragma_page_count(), pragma_page_size()"
).get() as { sizeMB: number };
// WAL mode check
const walMode = db.prepare("PRAGMA journal_mode").get() as { journal_mode: string };
// Locations per device
const perDevice = db.prepare(`
SELECT username, COUNT(*) as count
FROM Location
WHERE user_id = 0
GROUP BY username
ORDER BY count DESC
`).all() as Array<{ username: string; count: number }>;
// Locations per day (last 7 days)
const perDay = db.prepare(`
SELECT
DATE(timestamp) as date,
COUNT(*) as count
FROM Location
WHERE timestamp >= datetime('now', '-7 days')
GROUP BY DATE(timestamp)
ORDER BY date DESC
`).all() as Array<{ date: string; count: number }>;
// Average locations per day
const avgPerDay = perDay.length > 0
? Math.round(perDay.reduce((sum, day) => sum + day.count, 0) / perDay.length)
: 0;
db.close();
return NextResponse.json({
total: totalCount.count,
oldest: timeRange.oldest,
newest: timeRange.newest,
sizeMB: Math.round(dbSize.sizeMB * 100) / 100,
walMode: walMode.journal_mode,
perDevice,
perDay,
avgPerDay,
});
} catch (error) {
console.error('Stats error:', error);
return NextResponse.json(
{
error: 'Failed to get database stats',
details: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,86 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { locationDb, Location } from '@/lib/db';
import type { LocationResponse } from "@/types/location";
const N8N_API_URL = process.env.N8N_API_URL || "https://n8n.example.com/webhook/location";
/**
* POST /api/locations/sync (ADMIN only)
*
* Manually sync location data from n8n webhook to local SQLite cache.
* This fetches all available data from n8n and stores only new records.
*
* Useful for:
* - Initial database population
* - Recovery after downtime
* - Manual refresh
*/
export async function POST() {
try {
// ADMIN only
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Get stats before sync
const statsBefore = locationDb.getStats();
// Fetch from n8n webhook
const response = await fetch(N8N_API_URL, {
cache: "no-store",
signal: AbortSignal.timeout(10000), // 10 second timeout for manual sync
});
if (!response.ok) {
throw new Error(`n8n webhook returned ${response.status}`);
}
const data: LocationResponse = await response.json();
let insertedCount = 0;
// Store new locations in SQLite
if (data.history && Array.isArray(data.history) && data.history.length > 0) {
// Get latest timestamp from our DB
const lastLocalTimestamp = statsBefore.newest || '1970-01-01T00:00:00Z';
// Filter for only newer locations
const newLocations = data.history.filter(loc =>
loc.timestamp > lastLocalTimestamp
);
if (newLocations.length > 0) {
insertedCount = locationDb.createMany(newLocations as Location[]);
console.log(`[Manual Sync] Inserted ${insertedCount} new locations from n8n`);
}
}
// Get stats after sync
const statsAfter = locationDb.getStats();
return NextResponse.json({
success: true,
synced: insertedCount,
n8nTotal: data.total_points || data.history.length,
before: {
total: statsBefore.total,
newest: statsBefore.newest,
},
after: {
total: statsAfter.total,
newest: statsAfter.newest,
},
});
} catch (error) {
console.error('Sync error:', error);
return NextResponse.json(
{
error: 'Failed to sync locations',
details: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,91 @@
import { NextResponse } from 'next/server';
import { locationDb } from '@/lib/db';
/**
* POST /api/locations/test
*
* Create a test location entry (for development/testing)
* Body: { username, latitude, longitude, speed?, battery? }
*/
export async function POST(request: Request) {
try {
const body = await request.json();
const { username, latitude, longitude, speed, battery } = body;
// Validation
if (!username || latitude === undefined || longitude === undefined) {
return NextResponse.json(
{ error: 'Missing required fields: username, latitude, longitude' },
{ status: 400 }
);
}
const lat = parseFloat(latitude);
const lon = parseFloat(longitude);
if (isNaN(lat) || isNaN(lon)) {
return NextResponse.json(
{ error: 'Invalid latitude or longitude' },
{ status: 400 }
);
}
if (lat < -90 || lat > 90) {
return NextResponse.json(
{ error: 'Latitude must be between -90 and 90' },
{ status: 400 }
);
}
if (lon < -180 || lon > 180) {
return NextResponse.json(
{ error: 'Longitude must be between -180 and 180' },
{ status: 400 }
);
}
// Create location
const now = new Date();
const location = locationDb.create({
latitude: lat,
longitude: lon,
timestamp: now.toISOString(),
user_id: 0,
username: String(username),
display_time: now.toLocaleString('de-DE', {
timeZone: 'Europe/Berlin',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}),
chat_id: 0,
first_name: null,
last_name: null,
marker_label: null,
battery: battery !== undefined ? Number(battery) : null,
speed: speed !== undefined ? Number(speed) : null,
});
if (!location) {
return NextResponse.json(
{ error: 'Failed to create location (possibly duplicate)' },
{ status: 409 }
);
}
return NextResponse.json({
success: true,
location,
message: 'Test location created successfully',
});
} catch (error) {
console.error('Test location creation error:', error);
return NextResponse.json(
{ error: 'Failed to create test location' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,146 @@
// API Route für einzelne ACL Regel
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { mqttAclRuleDb } from '@/lib/mqtt-db';
import { deviceDb } from '@/lib/db';
/**
* PATCH /api/mqtt/acl/[id]
* Aktualisiere eine ACL Regel
*/
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id: idParam } = await params;
const id = parseInt(idParam);
if (isNaN(id)) {
return NextResponse.json(
{ error: 'Invalid ACL rule ID' },
{ status: 400 }
);
}
const body = await request.json();
const { topic_pattern, permission } = body;
// Validation
if (permission && !['read', 'write', 'readwrite'].includes(permission)) {
return NextResponse.json(
{ error: 'Permission must be read, write, or readwrite' },
{ status: 400 }
);
}
// Get current ACL rule to check device ownership
const userId = (session.user as any).id;
const currentRule = mqttAclRuleDb.findByDeviceId(''); // We need to get by ID first
const aclRules = mqttAclRuleDb.findAll();
const rule = aclRules.find(r => r.id === id);
if (!rule) {
return NextResponse.json(
{ error: 'ACL rule not found' },
{ status: 404 }
);
}
// Check if device belongs to user
const device = deviceDb.findById(rule.device_id);
if (!device || device.ownerId !== userId) {
return NextResponse.json(
{ error: 'Access denied' },
{ status: 403 }
);
}
const updated = mqttAclRuleDb.update(id, {
topic_pattern,
permission
});
if (!updated) {
return NextResponse.json(
{ error: 'Failed to update ACL rule' },
{ status: 500 }
);
}
return NextResponse.json(updated);
} catch (error) {
console.error('Failed to update ACL rule:', error);
return NextResponse.json(
{ error: 'Failed to update ACL rule' },
{ status: 500 }
);
}
}
/**
* DELETE /api/mqtt/acl/[id]
* Lösche eine ACL Regel
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id: idParam } = await params;
const id = parseInt(idParam);
if (isNaN(id)) {
return NextResponse.json(
{ error: 'Invalid ACL rule ID' },
{ status: 400 }
);
}
// Get current ACL rule to check device ownership
const userId = (session.user as any).id;
const aclRules = mqttAclRuleDb.findAll();
const rule = aclRules.find(r => r.id === id);
if (!rule) {
return NextResponse.json(
{ error: 'ACL rule not found' },
{ status: 404 }
);
}
// Check if device belongs to user
const device = deviceDb.findById(rule.device_id);
if (!device || device.ownerId !== userId) {
return NextResponse.json(
{ error: 'Access denied' },
{ status: 403 }
);
}
const deleted = mqttAclRuleDb.delete(id);
if (!deleted) {
return NextResponse.json(
{ error: 'Failed to delete ACL rule' },
{ status: 500 }
);
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Failed to delete ACL rule:', error);
return NextResponse.json(
{ error: 'Failed to delete ACL rule' },
{ status: 500 }
);
}
}

104
app/api/mqtt/acl/route.ts Normal file
View File

@@ -0,0 +1,104 @@
// API Route für MQTT ACL Management
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { mqttAclRuleDb } from '@/lib/mqtt-db';
import { deviceDb } from '@/lib/db';
/**
* GET /api/mqtt/acl?device_id=xxx
* Hole ACL Regeln für ein Device
*/
export async function GET(request: NextRequest) {
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const searchParams = request.nextUrl.searchParams;
const device_id = searchParams.get('device_id');
if (!device_id) {
return NextResponse.json(
{ error: 'device_id query parameter is required' },
{ status: 400 }
);
}
// Check if device belongs to user
const userId = (session.user as any).id;
const device = deviceDb.findById(device_id);
if (!device || device.ownerId !== userId) {
return NextResponse.json(
{ error: 'Device not found or access denied' },
{ status: 404 }
);
}
const rules = mqttAclRuleDb.findByDeviceId(device_id);
return NextResponse.json(rules);
} catch (error) {
console.error('Failed to fetch ACL rules:', error);
return NextResponse.json(
{ error: 'Failed to fetch ACL rules' },
{ status: 500 }
);
}
}
/**
* POST /api/mqtt/acl
* Erstelle neue ACL Regel
*/
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { device_id, topic_pattern, permission } = body;
// Validierung
if (!device_id || !topic_pattern || !permission) {
return NextResponse.json(
{ error: 'device_id, topic_pattern, and permission are required' },
{ status: 400 }
);
}
if (!['read', 'write', 'readwrite'].includes(permission)) {
return NextResponse.json(
{ error: 'permission must be one of: read, write, readwrite' },
{ status: 400 }
);
}
// Check if device belongs to user
const userId = (session.user as any).id;
const device = deviceDb.findById(device_id);
if (!device || device.ownerId !== userId) {
return NextResponse.json(
{ error: 'Device not found or access denied' },
{ status: 404 }
);
}
const rule = mqttAclRuleDb.create({
device_id,
topic_pattern,
permission
});
return NextResponse.json(rule, { status: 201 });
} catch (error) {
console.error('Failed to create ACL rule:', error);
return NextResponse.json(
{ error: 'Failed to create ACL rule' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,143 @@
// API Route für einzelne MQTT Credentials
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { mqttCredentialDb, mqttAclRuleDb } from '@/lib/mqtt-db';
import { hashPassword } from '@/lib/mosquitto-sync';
import { randomBytes } from 'crypto';
/**
* GET /api/mqtt/credentials/[device_id]
* Hole MQTT Credentials für ein Device
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ device_id: string }> }
) {
const { device_id } = await params;
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const credential = mqttCredentialDb.findByDeviceId(device_id);
if (!credential) {
return NextResponse.json(
{ error: 'Credentials not found' },
{ status: 404 }
);
}
return NextResponse.json(credential);
} catch (error) {
console.error('Failed to fetch MQTT credentials:', error);
return NextResponse.json(
{ error: 'Failed to fetch credentials' },
{ status: 500 }
);
}
}
/**
* PATCH /api/mqtt/credentials/[device_id]
* Aktualisiere MQTT Credentials
*/
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ device_id: string }> }
) {
const { device_id } = await params;
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { regenerate_password, enabled } = body;
const credential = mqttCredentialDb.findByDeviceId(device_id);
if (!credential) {
return NextResponse.json(
{ error: 'Credentials not found' },
{ status: 404 }
);
}
let newPassword: string | undefined;
let updateData: any = {};
// Regeneriere Passwort wenn angefordert
if (regenerate_password) {
newPassword = randomBytes(16).toString('base64');
const password_hash = await hashPassword(newPassword);
updateData.mqtt_password_hash = password_hash;
}
// Update enabled Status
if (enabled !== undefined) {
updateData.enabled = enabled ? 1 : 0;
}
// Update Credentials
const updated = mqttCredentialDb.update(device_id, updateData);
if (!updated) {
return NextResponse.json(
{ error: 'Failed to update credentials' },
{ status: 500 }
);
}
return NextResponse.json({
...updated,
// Sende neues Passwort nur wenn regeneriert
...(newPassword && { mqtt_password: newPassword })
});
} catch (error) {
console.error('Failed to update MQTT credentials:', error);
return NextResponse.json(
{ error: 'Failed to update credentials' },
{ status: 500 }
);
}
}
/**
* DELETE /api/mqtt/credentials/[device_id]
* Lösche MQTT Credentials für ein Device
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ device_id: string }> }
) {
const { device_id } = await params;
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Lösche zuerst alle ACL Regeln
mqttAclRuleDb.deleteByDeviceId(device_id);
// Dann lösche Credentials
const deleted = mqttCredentialDb.delete(device_id);
if (!deleted) {
return NextResponse.json(
{ error: 'Credentials not found' },
{ status: 404 }
);
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Failed to delete MQTT credentials:', error);
return NextResponse.json(
{ error: 'Failed to delete credentials' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,126 @@
// API Route für MQTT Credentials Management
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { mqttCredentialDb, mqttAclRuleDb } from '@/lib/mqtt-db';
import { deviceDb } from '@/lib/db';
import { hashPassword } from '@/lib/mosquitto-sync';
import { randomBytes } from 'crypto';
/**
* GET /api/mqtt/credentials
* Liste alle MQTT Credentials
*/
export async function GET() {
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const userId = (session.user as any).id;
const credentials = mqttCredentialDb.findAll();
// Filter credentials to only show user's devices
const credentialsWithDevices = credentials
.map(cred => {
const device = deviceDb.findById(cred.device_id);
return {
...cred,
device_name: device?.name || 'Unknown Device',
device_owner: device?.ownerId
};
})
.filter(cred => cred.device_owner === userId);
return NextResponse.json(credentialsWithDevices);
} catch (error) {
console.error('Failed to fetch MQTT credentials:', error);
return NextResponse.json(
{ error: 'Failed to fetch credentials' },
{ status: 500 }
);
}
}
/**
* POST /api/mqtt/credentials
* Erstelle neue MQTT Credentials für ein Device
*/
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { device_id, mqtt_username, mqtt_password, auto_generate } = body;
// Validierung
if (!device_id) {
return NextResponse.json(
{ error: 'device_id is required' },
{ status: 400 }
);
}
// Prüfe ob Device existiert
const device = deviceDb.findById(device_id);
if (!device) {
return NextResponse.json(
{ error: 'Device not found' },
{ status: 404 }
);
}
// Prüfe ob bereits Credentials existieren
const existing = mqttCredentialDb.findByDeviceId(device_id);
if (existing) {
return NextResponse.json(
{ error: 'MQTT credentials already exist for this device' },
{ status: 409 }
);
}
// Generiere oder verwende übergebene Credentials
let username = mqtt_username;
let password = mqtt_password;
if (auto_generate || !username) {
// Generiere Username: device_[device-id]_[random]
username = `device_${device_id}_${randomBytes(4).toString('hex')}`;
}
if (auto_generate || !password) {
// Generiere sicheres Passwort
password = randomBytes(16).toString('base64');
}
// Hash Passwort
const password_hash = await hashPassword(password);
// Erstelle Credentials
const credential = mqttCredentialDb.create({
device_id,
mqtt_username: username,
mqtt_password_hash: password_hash,
enabled: 1
});
// Erstelle Default ACL Regel
mqttAclRuleDb.createDefaultRule(device_id);
return NextResponse.json({
...credential,
// Sende Plaintext-Passwort nur bei Erstellung zurück
mqtt_password: password,
device_name: device.name
}, { status: 201 });
} catch (error) {
console.error('Failed to create MQTT credentials:', error);
return NextResponse.json(
{ error: 'Failed to create credentials' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,90 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { emailService } from '@/lib/email-service';
import { deviceDb, userDb } from '@/lib/db';
// POST /api/mqtt/send-credentials - Send MQTT credentials via email
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Only admins can send credentials
if ((session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const { deviceId, mqttUsername, mqttPassword } = body;
if (!deviceId || !mqttUsername || !mqttPassword) {
return NextResponse.json(
{ error: 'Missing required fields: deviceId, mqttUsername, mqttPassword' },
{ status: 400 }
);
}
// Get device info
const device = deviceDb.findById(deviceId);
if (!device) {
return NextResponse.json({ error: 'Device not found' }, { status: 404 });
}
// Get device owner
if (!device.ownerId) {
return NextResponse.json(
{ error: 'Device has no owner assigned' },
{ status: 400 }
);
}
const owner = userDb.findById(device.ownerId);
if (!owner) {
return NextResponse.json(
{ error: 'Device owner not found' },
{ status: 404 }
);
}
if (!owner.email) {
return NextResponse.json(
{ error: 'Device owner has no email address' },
{ status: 400 }
);
}
// Parse broker URL from environment or use default
const brokerUrl = process.env.MQTT_BROKER_URL || 'mqtt://localhost:1883';
const brokerHost = brokerUrl.replace(/^mqtt:\/\//, '').replace(/:\d+$/, '');
const brokerPortMatch = brokerUrl.match(/:(\d+)$/);
const brokerPort = brokerPortMatch ? brokerPortMatch[1] : '1883';
// Send email
await emailService.sendMqttCredentialsEmail({
email: owner.email,
deviceName: device.name,
deviceId: device.id,
mqttUsername,
mqttPassword,
brokerUrl,
brokerHost,
brokerPort,
});
console.log(`[MQTT] Credentials sent via email to ${owner.email} for device ${device.name}`);
return NextResponse.json({
success: true,
message: `Credentials sent to ${owner.email}`,
});
} catch (error) {
console.error('Error sending MQTT credentials email:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to send email' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,50 @@
// API Route für Mosquitto Configuration Sync
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { syncMosquittoConfig, getMosquittoSyncStatus } from '@/lib/mosquitto-sync';
/**
* GET /api/mqtt/sync
* Hole den aktuellen Sync Status
*/
export async function GET() {
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const status = getMosquittoSyncStatus();
return NextResponse.json(status || { pending_changes: 0, last_sync_status: 'unknown' });
} catch (error) {
console.error('Failed to fetch sync status:', error);
return NextResponse.json(
{ error: 'Failed to fetch sync status' },
{ status: 500 }
);
}
}
/**
* POST /api/mqtt/sync
* Trigger Mosquitto Configuration Sync
*/
export async function POST() {
try {
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const result = await syncMosquittoConfig();
return NextResponse.json(result, {
status: result.success ? 200 : 500
});
} catch (error) {
console.error('Failed to sync Mosquitto config:', error);
return NextResponse.json(
{ error: 'Failed to sync Mosquitto configuration' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,42 @@
import { NextResponse } from 'next/server';
/**
* GET /api/system/status
*
* Returns system status information
*/
export async function GET() {
try {
const uptimeSeconds = process.uptime();
// Calculate days, hours, minutes, seconds
const days = Math.floor(uptimeSeconds / 86400);
const hours = Math.floor((uptimeSeconds % 86400) / 3600);
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
const seconds = Math.floor(uptimeSeconds % 60);
return NextResponse.json({
uptime: {
total: Math.floor(uptimeSeconds),
formatted: `${days}d ${hours}h ${minutes}m ${seconds}s`,
days,
hours,
minutes,
seconds,
},
memory: {
heapUsed: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
heapTotal: Math.round(process.memoryUsage().heapTotal / 1024 / 1024),
rss: Math.round(process.memoryUsage().rss / 1024 / 1024),
},
nodejs: process.version,
platform: process.platform,
});
} catch (error) {
console.error('System status error:', error);
return NextResponse.json(
{ error: 'Failed to get system status' },
{ status: 500 }
);
}
}

209
app/api/users/[id]/route.ts Normal file
View File

@@ -0,0 +1,209 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { userDb } from "@/lib/db";
import bcrypt from "bcryptjs";
// GET /api/users/[id] - Get single user (admin only)
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Only admins can view users
if ((session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
const user = userDb.findById(id);
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const currentUsername = session.user.name || '';
const currentUserId = (session.user as any).id || '';
// Only the "admin" user can view any user details
// ADMIN users can only view their own created viewers
if (currentUsername !== 'admin') {
// Check if this user is a child of the current user
if (user.parent_user_id !== currentUserId) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
}
// Remove password hash from response
const { passwordHash, ...safeUser } = user;
return NextResponse.json({ user: safeUser });
} catch (error) {
console.error("Error fetching user:", error);
return NextResponse.json(
{ error: "Failed to fetch user" },
{ status: 500 }
);
}
}
// PATCH /api/users/[id] - Update user (admin only)
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Only admins can update users
if ((session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
const user = userDb.findById(id);
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const currentUsername = session.user.name || '';
const currentUserId = (session.user as any).id || '';
// Only the "admin" user can modify any user
// ADMIN users can only modify their own created viewers
if (currentUsername !== 'admin') {
// Check if this user is a child of the current user
if (user.parent_user_id !== currentUserId) {
return NextResponse.json(
{ error: "Forbidden: Cannot modify this user" },
{ status: 403 }
);
}
}
const body = await request.json();
const { username, email, password, role } = body;
// Validation
if (role && !['ADMIN', 'VIEWER'].includes(role)) {
return NextResponse.json(
{ error: "Invalid role. Must be ADMIN or VIEWER" },
{ status: 400 }
);
}
// Check if username is taken by another user
if (username && username !== user.username) {
const existing = userDb.findByUsername(username);
if (existing && existing.id !== id) {
return NextResponse.json(
{ error: "Username already exists" },
{ status: 409 }
);
}
}
// Prepare update data
const updateData: {
username?: string;
email?: string | null;
passwordHash?: string;
role?: string;
} = {};
if (username !== undefined) updateData.username = username;
if (email !== undefined) updateData.email = email;
if (role !== undefined) updateData.role = role;
if (password) {
updateData.passwordHash = await bcrypt.hash(password, 10);
}
const updated = userDb.update(id, updateData);
if (!updated) {
return NextResponse.json({ error: "Failed to update user" }, { status: 500 });
}
// Remove password hash from response
const { passwordHash, ...safeUser } = updated;
return NextResponse.json({ user: safeUser });
} catch (error) {
console.error("Error updating user:", error);
return NextResponse.json(
{ error: "Failed to update user" },
{ status: 500 }
);
}
}
// DELETE /api/users/[id] - Delete user (admin only)
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Only admins can delete users
if ((session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
const user = userDb.findById(id);
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const currentUsername = session.user.name || '';
const currentUserId = (session.user as any).id || '';
// Only the "admin" user can delete any user
// ADMIN users can only delete their own created viewers
if (currentUsername !== 'admin') {
// Check if this user is a child of the current user
if (user.parent_user_id !== currentUserId) {
return NextResponse.json(
{ error: "Forbidden: Cannot delete this user" },
{ status: 403 }
);
}
}
// Prevent deleting yourself
if ((session.user as any).id === id) {
return NextResponse.json(
{ error: "Cannot delete your own account" },
{ status: 400 }
);
}
const success = userDb.delete(id);
if (!success) {
return NextResponse.json({ error: "Failed to delete user" }, { status: 500 });
}
return NextResponse.json({ message: "User deleted successfully" });
} catch (error) {
console.error("Error deleting user:", error);
return NextResponse.json(
{ error: "Failed to delete user" },
{ status: 500 }
);
}
}

155
app/api/users/route.ts Normal file
View File

@@ -0,0 +1,155 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { userDb } from "@/lib/db";
import bcrypt from "bcryptjs";
import { randomUUID } from "crypto";
import { emailService } from '@/lib/email-service';
// GET /api/users - List all users (admin only)
export async function GET() {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Only admins can view users
if ((session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const currentUsername = session.user.name || '';
const currentUserId = (session.user as any).id || '';
// Only the "admin" user can see all users
// Other ADMIN users see only their created viewers (parent-child relationship)
let users: any[];
if (currentUsername === 'admin') {
// Super admin sees all users
users = userDb.findAll();
} else if ((session.user as any).role === 'ADMIN') {
// ADMIN users see only their child viewers
users = userDb.findAll({ parentUserId: currentUserId });
} else {
// VIEWER users see nobody
users = [];
}
// Remove password hashes from response
const safeUsers = users.map(({ passwordHash, ...user }) => user);
return NextResponse.json({ users: safeUsers });
} catch (error) {
console.error("Error fetching users:", error);
return NextResponse.json(
{ error: "Failed to fetch users" },
{ status: 500 }
);
}
}
// POST /api/users - Create new user (admin only)
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Only admins can create users
if ((session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await request.json();
const { username, email, password, role } = body;
// Validation
if (!username || !password) {
return NextResponse.json(
{ error: "Missing required fields: username, password" },
{ status: 400 }
);
}
if (role && !['ADMIN', 'VIEWER'].includes(role)) {
return NextResponse.json(
{ error: "Invalid role. Must be ADMIN or VIEWER" },
{ status: 400 }
);
}
// Check if username already exists
const existing = userDb.findByUsername(username);
if (existing) {
return NextResponse.json(
{ error: "Username already exists" },
{ status: 409 }
);
}
// Hash password
const passwordHash = await bcrypt.hash(password, 10);
// Determine parent_user_id
// If current user is not "admin", set parent_user_id to current user's ID
// If creating a VIEWER, set parent_user_id to current user's ID
const currentUsername = session.user.name || '';
const currentUserId = (session.user as any).id || '';
let parent_user_id: string | null = null;
if (currentUsername !== 'admin') {
// Non-admin ADMIN users create viewers that belong to them
parent_user_id = currentUserId;
// Force role to VIEWER for non-admin ADMIN users
if (role && role !== 'VIEWER') {
return NextResponse.json(
{ error: 'You can only create VIEWER users' },
{ status: 403 }
);
}
}
// Create user
const user = userDb.create({
id: randomUUID(),
username,
email: email || null,
passwordHash,
role: role || 'VIEWER',
parent_user_id,
});
// Send welcome email (don't fail if email fails)
if (email) {
try {
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
await emailService.sendWelcomeEmail({
email,
username,
loginUrl: `${baseUrl}/login`,
temporaryPassword: password, // Send the original password
});
console.log('[UserCreate] Welcome email sent to:', email);
} catch (emailError) {
console.error('[UserCreate] Failed to send welcome email:', emailError);
// Don't fail user creation if email fails
}
}
// Remove password hash from response
const { passwordHash: _, ...safeUser } = user;
return NextResponse.json({ user: safeUser }, { status: 201 });
} catch (error) {
console.error("Error creating user:", error);
return NextResponse.json(
{ error: "Failed to create user" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,113 @@
"use client";
import { useState } from "react";
import Link from "next/link";
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const response = await fetch('/api/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to send reset email');
}
setSubmitted(true);
} catch (err: any) {
setError(err.message || 'An error occurred');
} finally {
setLoading(false);
}
};
if (submitted) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100 px-4">
<div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
<div className="text-center">
<div className="text-5xl mb-4"></div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">
Check Your Email
</h2>
<p className="text-gray-600 mb-6">
If an account exists with the email <strong>{email}</strong>, you will receive a password reset link shortly.
</p>
<Link
href="/login"
className="text-blue-600 hover:text-blue-700 font-medium"
>
Back to Login
</Link>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100 px-4">
<div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
<h2 className="text-2xl font-bold text-gray-900 mb-2">
Forgot Password
</h2>
<p className="text-gray-600 mb-6">
Enter your email address and we'll send you a link to reset your password.
</p>
{error && (
<div className="mb-4 p-3 bg-red-100 text-red-800 rounded">
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Email Address
</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="your-email@example.com"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 font-medium"
>
{loading ? 'Sending...' : 'Send Reset Link'}
</button>
<div className="mt-4 text-center">
<Link
href="/login"
className="text-blue-600 hover:text-blue-700 text-sm"
>
Back to Login
</Link>
</div>
</form>
</div>
</div>
);
}

26
app/globals.css Normal file
View File

@@ -0,0 +1,26 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
}
/* Leaflet CSS overrides */
.leaflet-container {
height: 100%;
width: 100%;
}

23
app/layout.tsx Normal file
View File

@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import "./globals.css";
import "leaflet/dist/leaflet.css";
import AuthProvider from "@/components/AuthProvider";
export const metadata: Metadata = {
title: "Location Tracker - POC",
description: "MQTT Location Tracking with Admin Panel",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body suppressHydrationWarning>
<AuthProvider>{children}</AuthProvider>
</body>
</html>
);
}

146
app/login/page.tsx Normal file
View File

@@ -0,0 +1,146 @@
"use client";
import { signIn } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation";
import { useState, Suspense } from "react";
import Link from "next/link";
function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const registered = searchParams.get("registered") === "true";
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
const result = await signIn("credentials", {
username,
password,
redirect: false,
});
if (result?.error) {
setError("Invalid username or password");
} else {
// Redirect to callbackUrl if present, otherwise to /map
const callbackUrl = searchParams.get("callbackUrl") || "/map";
console.log('[Login] Redirecting to:', callbackUrl);
router.push(callbackUrl);
// Force a hard refresh to ensure middleware is applied
router.refresh();
}
} catch (err) {
setError("An error occurred. Please try again.");
} finally {
setLoading(false);
}
};
return (
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8">
<h1 className="text-2xl font-bold text-center mb-6">
Location Tracker Admin
</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="username"
className="block text-sm font-medium text-gray-700 mb-1"
>
Username
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-1"
>
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div className="text-right mb-4">
<Link
href="/forgot-password"
className="text-sm text-blue-600 hover:text-blue-700"
>
Forgot Password?
</Link>
</div>
{registered && (
<div className="bg-green-50 text-green-700 px-4 py-2 rounded-md text-sm mb-4">
Account created successfully! Please sign in.
</div>
)}
{error && (
<div className="bg-red-50 text-red-600 px-4 py-2 rounded-md text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:bg-gray-400 transition-colors"
>
{loading ? "Signing in..." : "Sign In"}
</button>
</form>
<div className="mt-6 text-center text-sm">
<span className="text-gray-600">Don't have an account? </span>
<Link href="/register" className="text-blue-600 hover:text-blue-700 font-medium">
Sign up
</Link>
</div>
<div className="mt-4 text-center text-sm text-gray-600">
<p>Demo credentials:</p>
<p className="font-mono mt-1">
admin / admin123
</p>
</div>
</div>
);
}
export default function LoginPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<Suspense fallback={
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8">
<p className="text-center text-gray-600">Loading...</p>
</div>
}>
<LoginForm />
</Suspense>
</div>
);
}

174
app/map/page.tsx Normal file
View File

@@ -0,0 +1,174 @@
"use client";
import { useState, useEffect } from "react";
import dynamic from "next/dynamic";
import Link from "next/link";
const MapView = dynamic(() => import("@/components/map/MapView"), {
ssr: false,
loading: () => (
<div className="h-full flex items-center justify-center">
Loading map...
</div>
),
});
const TIME_FILTERS = [
{ label: "1 Hour", value: 1 },
{ label: "3 Hours", value: 3 },
{ label: "6 Hours", value: 6 },
{ label: "12 Hours", value: 12 },
{ label: "24 Hours", value: 24 },
{ label: "All", value: 0 },
];
interface DeviceInfo {
id: string;
name: string;
color: string;
}
export default function MapPage() {
const [selectedDevice, setSelectedDevice] = useState<string>("all");
const [timeFilter, setTimeFilter] = useState<number>(1); // Default 1 hour
const [isPaused, setIsPaused] = useState(false);
const [devices, setDevices] = useState<DeviceInfo[]>([]);
// Custom range state
const [filterMode, setFilterMode] = useState<"quick" | "custom">("quick");
const [startTime, setStartTime] = useState<string>("");
const [endTime, setEndTime] = useState<string>("");
// Fetch user's devices from API
useEffect(() => {
const fetchDevices = async () => {
try {
const response = await fetch("/api/devices/public");
if (response.ok) {
const data = await response.json();
setDevices(data.devices || []);
} else {
console.error("Failed to fetch devices:", response.status);
}
} catch (err) {
console.error("Failed to fetch devices:", err);
}
};
fetchDevices();
// Refresh devices every 30 seconds
const interval = setInterval(fetchDevices, 30000);
return () => clearInterval(interval);
}, []);
return (
<div className="h-screen flex flex-col">
{/* Header with controls */}
<div className="bg-white shadow-md p-3 sm:p-4">
<div className="flex flex-col gap-3">
{/* Top row: Title and Admin link */}
<div className="flex items-center justify-between">
<h1 className="text-lg sm:text-xl font-bold text-black">Location Tracker</h1>
<a
href="/admin"
className="px-3 py-1 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors whitespace-nowrap"
>
Admin
</a>
</div>
{/* Controls row - responsive grid */}
<div className="flex flex-col sm:flex-row gap-2 sm:gap-4 items-stretch sm:items-center">
{/* Device Filter */}
<div className="flex items-center gap-2">
<label className="text-xs sm:text-sm font-medium text-black whitespace-nowrap">Device:</label>
<select
value={selectedDevice}
onChange={(e) => setSelectedDevice(e.target.value)}
className="flex-1 sm:flex-none px-2 sm:px-3 py-1 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">All My Devices</option>
{devices.map((device) => (
<option key={device.id} value={device.id}>
{device.name}
</option>
))}
</select>
</div>
{/* Time Filter */}
<div className="flex items-center gap-2">
<label className="text-xs sm:text-sm font-medium text-black whitespace-nowrap">Time:</label>
<select
value={timeFilter}
onChange={(e) => setTimeFilter(Number(e.target.value))}
className="flex-1 sm:flex-none px-2 sm:px-3 py-1 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{TIME_FILTERS.map((filter) => (
<option key={filter.value} value={filter.value}>
{filter.label}
</option>
))}
</select>
<button
onClick={() => setFilterMode(filterMode === "quick" ? "custom" : "quick")}
className="px-2 sm:px-3 py-1 text-xs sm:text-sm font-medium bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 transition-colors whitespace-nowrap"
title="Toggle Custom Range"
>
📅 {filterMode === "quick" ? "Custom" : "Quick"}
</button>
</div>
{/* Pause/Resume Button */}
<button
onClick={() => setIsPaused(!isPaused)}
className={`px-3 sm:px-4 py-1 text-sm rounded-md font-semibold transition-colors whitespace-nowrap ${
isPaused
? "bg-green-500 hover:bg-green-600 text-white"
: "bg-red-500 hover:bg-red-600 text-white"
}`}
>
{isPaused ? "▶ Resume" : "⏸ Pause"}
</button>
</div>
{/* Custom Range (only visible when active) */}
{filterMode === "custom" && (
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 border border-blue-300 bg-blue-50 rounded-md p-2">
<div className="flex items-center gap-1">
<label className="text-xs font-medium text-black whitespace-nowrap">From:</label>
<input
type="datetime-local"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
className="flex-1 sm:flex-none px-2 py-1 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-xs"
/>
</div>
<div className="flex items-center gap-1">
<label className="text-xs font-medium text-black whitespace-nowrap">To:</label>
<input
type="datetime-local"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
className="flex-1 sm:flex-none px-2 py-1 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-xs"
/>
</div>
</div>
)}
</div>
</div>
{/* Map */}
<div className="flex-1">
<MapView
selectedDevice={selectedDevice}
timeFilter={timeFilter}
isPaused={isPaused}
filterMode={filterMode}
startTime={startTime}
endTime={endTime}
/>
</div>
</div>
);
}

253
app/page.tsx Normal file
View File

@@ -0,0 +1,253 @@
"use client";
import Link from "next/link";
import dynamic from 'next/dynamic';
// Dynamically import DemoMap (client-side only)
const DemoMap = dynamic(() => import('@/components/demo/DemoMap'), {
ssr: false,
loading: () => (
<div className="w-full h-full flex items-center justify-center">
<p className="text-gray-600">Loading interactive demo...</p>
</div>
),
});
export default function LandingPage() {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-blue-50">
{/* Header/Navigation */}
<header className="bg-white shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex justify-between items-center">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900">Location Tracker</h1>
</div>
<Link
href="/login"
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
Login
</Link>
</div>
</div>
</header>
{/* Hero Section */}
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 sm:py-24">
<div className="text-center">
<h2 className="text-4xl sm:text-5xl lg:text-6xl font-bold text-gray-900 mb-6">
Real-Time GPS Location Tracking
</h2>
<p className="text-xl sm:text-2xl text-gray-600 mb-8 max-w-3xl mx-auto">
Track multiple devices in real-time with our powerful MQTT-based location tracking system.
Monitor your fleet, family, or assets with precision and ease.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
href="/register"
className="px-8 py-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-semibold text-lg shadow-lg hover:shadow-xl"
>
Sign Up Free
</Link>
<Link
href="/login"
className="px-8 py-4 bg-white text-blue-600 border-2 border-blue-600 rounded-lg hover:bg-blue-50 transition-colors font-semibold text-lg"
>
Login
</Link>
</div>
</div>
</section>
{/* Features Section */}
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 bg-white rounded-2xl shadow-xl mb-16">
<h3 className="text-3xl font-bold text-center text-gray-900 mb-12">
Powerful Features
</h3>
<div className="grid md:grid-cols-3 gap-8">
{/* Feature 1 */}
<div className="text-center p-6">
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h4 className="text-xl font-semibold text-gray-900 mb-2">Real-Time Updates</h4>
<p className="text-gray-600">
Live location updates via MQTT protocol with automatic 5-second refresh intervals.
</p>
</div>
{/* Feature 2 */}
<div className="text-center p-6">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
</div>
<h4 className="text-xl font-semibold text-gray-900 mb-2">Interactive Map</h4>
<p className="text-gray-600">
OpenStreetMap integration with multiple layers, movement paths, and device markers.
</p>
</div>
{/* Feature 3 */}
<div className="text-center p-6">
<div className="w-16 h-16 bg-purple-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h4 className="text-xl font-semibold text-gray-900 mb-2">Time Filtering</h4>
<p className="text-gray-600">
Filter locations by time range with quick filters (1h, 3h, 6h) or custom date ranges.
</p>
</div>
{/* Feature 4 */}
<div className="text-center p-6">
<div className="w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
<h4 className="text-xl font-semibold text-gray-900 mb-2">Multi-Device Support</h4>
<p className="text-gray-600">
Track unlimited devices with color-coded markers and individual device filtering.
</p>
</div>
{/* Feature 5 */}
<div className="text-center p-6">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<h4 className="text-xl font-semibold text-gray-900 mb-2">Admin Controls</h4>
<p className="text-gray-600">
Comprehensive admin panel for device management, user access, and MQTT configuration.
</p>
</div>
{/* Feature 6 */}
<div className="text-center p-6">
<div className="w-16 h-16 bg-indigo-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
</svg>
</div>
<h4 className="text-xl font-semibold text-gray-900 mb-2">SQLite Storage</h4>
<p className="text-gray-600">
Dual-database system for high-performance location storage with automatic cleanup.
</p>
</div>
</div>
</section>
{/* Live Demo Section */}
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<h3 className="text-3xl font-bold text-center text-gray-900 mb-4">
See It In Action
</h3>
<p className="text-center text-gray-600 mb-12 max-w-2xl mx-auto">
Watch live as 3 demo devices move through Munich in real-time.
This is exactly how your own devices will appear on the map!
</p>
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden">
<div className="aspect-video bg-gray-100">
<DemoMap />
</div>
<div className="p-6 bg-gradient-to-r from-blue-50 to-blue-100">
<div className="flex flex-wrap justify-center gap-6">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full bg-blue-500"></div>
<span className="text-sm font-medium text-gray-700">City Tour</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full bg-green-500"></div>
<span className="text-sm font-medium text-gray-700">Olympiapark Route</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full bg-orange-500"></div>
<span className="text-sm font-medium text-gray-700">Isar Tour</span>
</div>
</div>
<p className="text-center text-sm text-gray-600 mt-4">
💡 Devices update every 3 seconds - just like real-time tracking!
</p>
</div>
</div>
<div className="grid md:grid-cols-2 gap-8 mt-8">
{/* Demo Feature 1 */}
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
<div className="aspect-video bg-gradient-to-br from-green-100 to-green-200 flex items-center justify-center">
<div className="text-center">
<svg className="w-16 h-16 text-green-600 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<p className="font-semibold text-gray-700">Device Analytics</p>
</div>
</div>
</div>
{/* Demo Feature 2 */}
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
<div className="aspect-video bg-gradient-to-br from-purple-100 to-purple-200 flex items-center justify-center">
<div className="text-center">
<svg className="w-16 h-16 text-purple-600 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<p className="font-semibold text-gray-700">Admin Dashboard</p>
</div>
</div>
</div>
</div>
</section>
{/* CTA Section */}
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center">
<div className="bg-gradient-to-r from-blue-600 to-blue-700 rounded-2xl shadow-2xl p-12">
<h3 className="text-3xl sm:text-4xl font-bold text-white mb-4">
Ready to Start Tracking?
</h3>
<p className="text-xl text-blue-100 mb-8 max-w-2xl mx-auto">
Create your free account or log in to access the real-time map and start monitoring your devices instantly.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
href="/register"
className="inline-block px-8 py-4 bg-white text-blue-600 rounded-lg hover:bg-gray-100 transition-colors font-semibold text-lg shadow-lg hover:shadow-xl"
>
Create Free Account
</Link>
<Link
href="/login"
className="inline-block px-8 py-4 bg-blue-500 text-white border-2 border-white rounded-lg hover:bg-blue-400 transition-colors font-semibold text-lg shadow-lg hover:shadow-xl"
>
Login
</Link>
</div>
</div>
</section>
{/* Footer */}
<footer className="bg-gray-900 text-gray-400 py-8 mt-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<p className="text-sm">
&copy; {new Date().getFullYear()} Location Tracker. Built with Next.js 14 and MQTT.
</p>
</div>
</footer>
</div>
);
}

166
app/register/page.tsx Normal file
View File

@@ -0,0 +1,166 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
export default function RegisterPage() {
const router = useRouter();
const [formData, setFormData] = useState({
username: "",
email: "",
password: "",
confirmPassword: "",
});
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
// Validation
if (formData.password !== formData.confirmPassword) {
setError("Passwords do not match");
return;
}
if (formData.password.length < 6) {
setError("Password must be at least 6 characters long");
return;
}
setLoading(true);
try {
const response = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: formData.username,
email: formData.email,
password: formData.password,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Registration failed");
}
// Success - redirect to login
router.push("/login?registered=true");
} catch (err: any) {
setError(err.message || "Registration failed. Please try again.");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-blue-50 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8">
<div className="text-center mb-6">
<div className="w-16 h-16 bg-blue-600 rounded-lg flex items-center justify-center mx-auto mb-4">
<svg className="w-10 h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900">Create Account</h1>
<p className="text-gray-600 mt-2">Sign up for Location Tracker</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
Username *
</label>
<input
id="username"
type="text"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
minLength={3}
/>
<p className="text-xs text-gray-500 mt-1">At least 3 characters</p>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email *
</label>
<input
id="email"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
Password *
</label>
<input
id="password"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
minLength={6}
/>
<p className="text-xs text-gray-500 mt-1">At least 6 characters</p>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-1">
Confirm Password *
</label>
<input
id="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
minLength={6}
/>
</div>
{error && (
<div className="bg-red-50 text-red-600 px-4 py-2 rounded-md text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Creating Account..." : "Create Account"}
</button>
</form>
<div className="mt-6 text-center text-sm">
<span className="text-gray-600">Already have an account? </span>
<Link href="/login" className="text-blue-600 hover:text-blue-700 font-medium">
Sign in
</Link>
</div>
<div className="mt-4 text-center">
<Link href="/" className="text-sm text-gray-600 hover:text-gray-700">
Back to Home
</Link>
</div>
</div>
</div>
);
}

216
app/reset-password/page.tsx Normal file
View File

@@ -0,0 +1,216 @@
"use client";
import { useEffect, useState, Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import Link from "next/link";
function ResetPasswordContent() {
const searchParams = useSearchParams();
const router = useRouter();
const token = searchParams.get('token');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [validating, setValidating] = useState(true);
const [tokenValid, setTokenValid] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
// Validate token on mount
useEffect(() => {
if (!token) {
setError('Invalid reset link');
setValidating(false);
return;
}
const validateToken = async () => {
try {
const response = await fetch(`/api/auth/reset-password?token=${token}`);
const data = await response.json();
if (data.valid) {
setTokenValid(true);
} else {
setError('This reset link is invalid or has expired');
}
} catch (err) {
setError('Failed to validate reset link');
} finally {
setValidating(false);
}
};
validateToken();
}, [token]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (newPassword !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (newPassword.length < 6) {
setError('Password must be at least 6 characters');
return;
}
setLoading(true);
try {
const response = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, newPassword }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to reset password');
}
setSuccess(true);
// Redirect to login after 3 seconds
setTimeout(() => {
router.push('/login');
}, 3000);
} catch (err: any) {
setError(err.message || 'An error occurred');
} finally {
setLoading(false);
}
};
if (validating) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<p className="text-gray-600">Validating reset link...</p>
</div>
);
}
if (!tokenValid) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100 px-4">
<div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
<div className="text-center">
<div className="text-5xl mb-4"></div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">
Invalid Reset Link
</h2>
<p className="text-gray-600 mb-6">
{error || 'This password reset link is invalid or has expired.'}
</p>
<Link
href="/forgot-password"
className="text-blue-600 hover:text-blue-700 font-medium"
>
Request New Reset Link
</Link>
</div>
</div>
</div>
);
}
if (success) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100 px-4">
<div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
<div className="text-center">
<div className="text-5xl mb-4"></div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">
Password Reset Successful
</h2>
<p className="text-gray-600 mb-6">
Your password has been reset successfully. Redirecting to login...
</p>
<Link
href="/login"
className="text-blue-600 hover:text-blue-700 font-medium"
>
Go to Login
</Link>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100 px-4">
<div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
<h2 className="text-2xl font-bold text-gray-900 mb-2">
Reset Password
</h2>
<p className="text-gray-600 mb-6">
Enter your new password below.
</p>
{error && (
<div className="mb-4 p-3 bg-red-100 text-red-800 rounded">
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
New Password
</label>
<input
type="password"
required
minLength={6}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="At least 6 characters"
/>
</div>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Confirm New Password
</label>
<input
type="password"
required
minLength={6}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Re-enter password"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 font-medium"
>
{loading ? 'Resetting...' : 'Reset Password'}
</button>
</form>
</div>
</div>
);
}
export default function ResetPasswordPage() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<p className="text-gray-600">Loading...</p>
</div>
}>
<ResetPasswordContent />
</Suspense>
);
}

74
app/unauthorized/page.tsx Normal file
View File

@@ -0,0 +1,74 @@
"use client";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Suspense } from "react";
function UnauthorizedContent() {
const searchParams = useSearchParams();
const attemptedUrl = searchParams.get('from');
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8 text-center">
<div className="mb-4">
<svg
className="mx-auto h-12 w-12 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">
Access Denied
</h1>
<p className="text-gray-600 mb-6">
You don't have permission to access this area. Admin privileges are required.
</p>
{attemptedUrl && (
<p className="text-sm text-gray-500 mb-6">
Attempted to access: <span className="font-mono">{attemptedUrl}</span>
</p>
)}
<div className="space-y-3">
<Link
href="/"
className="block w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 transition-colors"
>
Go to Homepage
</Link>
<Link
href="/login"
className="block w-full border border-gray-300 text-gray-700 py-2 px-4 rounded-md hover:bg-gray-50 transition-colors"
>
Login with Different Account
</Link>
</div>
</div>
</div>
);
}
export default function UnauthorizedPage() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<p className="text-gray-600">Loading...</p>
</div>
}>
<UnauthorizedContent />
</Suspense>
);
}

View File

@@ -0,0 +1,11 @@
"use client";
import { SessionProvider } from "next-auth/react";
export default function AuthProvider({
children,
}: {
children: React.ReactNode;
}) {
return <SessionProvider>{children}</SessionProvider>;
}

157
components/demo/DemoMap.tsx Normal file
View File

@@ -0,0 +1,157 @@
"use client";
import { useEffect, useState, useRef } from 'react';
import dynamic from 'next/dynamic';
import { DEMO_DEVICES, DEMO_MAP_CENTER, DEMO_MAP_ZOOM, DemoDevice } from '@/lib/demo-data';
// Dynamically import Leaflet components (client-side only)
const MapContainer = dynamic(
() => import('react-leaflet').then((mod) => mod.MapContainer),
{ ssr: false }
);
const TileLayer = dynamic(
() => import('react-leaflet').then((mod) => mod.TileLayer),
{ ssr: false }
);
const Marker = dynamic(
() => import('react-leaflet').then((mod) => mod.Marker),
{ ssr: false }
);
const Popup = dynamic(
() => import('react-leaflet').then((mod) => mod.Popup),
{ ssr: false }
);
const Polyline = dynamic(
() => import('react-leaflet').then((mod) => mod.Polyline),
{ ssr: false }
);
export default function DemoMap() {
const [devicePositions, setDevicePositions] = useState<Map<string, number>>(new Map());
const [isClient, setIsClient] = useState(false);
const iconCache = useRef<Map<string, any>>(new Map());
// Initialize on client side
useEffect(() => {
setIsClient(true);
// Initialize all devices at position 0
const initialPositions = new Map();
DEMO_DEVICES.forEach(device => {
initialPositions.set(device.id, 0);
});
setDevicePositions(initialPositions);
}, []);
// Animate device movements
useEffect(() => {
if (!isClient) return;
const interval = setInterval(() => {
setDevicePositions(prev => {
const next = new Map(prev);
DEMO_DEVICES.forEach(device => {
const currentPos = next.get(device.id) || 0;
const nextPos = (currentPos + 1) % device.route.length;
next.set(device.id, nextPos);
});
return next;
});
}, 3000); // Move every 3 seconds
return () => clearInterval(interval);
}, [isClient]);
// Create custom marker icons
const getDeviceIcon = (color: string) => {
if (typeof window === 'undefined') return null;
if (iconCache.current.has(color)) {
return iconCache.current.get(color);
}
const L = require('leaflet');
const icon = L.divIcon({
className: 'custom-marker',
html: `
<div style="
background-color: ${color};
width: 24px;
height: 24px;
border-radius: 50%;
border: 3px solid white;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
"></div>
`,
iconSize: [24, 24],
iconAnchor: [12, 12],
});
iconCache.current.set(color, icon);
return icon;
};
if (!isClient) {
return (
<div className="w-full h-full bg-gray-200 rounded-lg flex items-center justify-center">
<p className="text-gray-600">Loading demo map...</p>
</div>
);
}
return (
<div className="w-full h-full rounded-lg overflow-hidden shadow-lg">
<MapContainer
center={DEMO_MAP_CENTER}
zoom={DEMO_MAP_ZOOM}
style={{ height: '100%', width: '100%' }}
scrollWheelZoom={true}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{DEMO_DEVICES.map(device => {
const currentPosIdx = devicePositions.get(device.id) || 0;
const currentPos = device.route[currentPosIdx];
const pathSoFar = device.route.slice(0, currentPosIdx + 1);
return (
<div key={device.id}>
{/* Movement trail */}
{pathSoFar.length > 1 && (
<Polyline
positions={pathSoFar.map(loc => [loc.lat, loc.lng])}
color={device.color}
weight={3}
opacity={0.6}
/>
)}
{/* Current position marker */}
<Marker
position={[currentPos.lat, currentPos.lng]}
icon={getDeviceIcon(device.color)}
>
<Popup>
<div className="text-sm">
<p className="font-semibold">{device.name}</p>
<p className="text-xs text-gray-600">
Position: {currentPosIdx + 1}/{device.route.length}
</p>
<p className="text-xs text-gray-600">
Lat: {currentPos.lat.toFixed(4)}
</p>
<p className="text-xs text-gray-600">
Lng: {currentPos.lng.toFixed(4)}
</p>
</div>
</Popup>
</Marker>
</div>
);
})}
</MapContainer>
</div>
);
}

394
components/map/MapView.tsx Normal file
View File

@@ -0,0 +1,394 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { Location, LocationResponse } from "@/types/location";
import { getDevice, DEFAULT_DEVICE } from "@/lib/devices";
import L from "leaflet";
import {
MapContainer,
TileLayer,
Marker,
Popup,
Polyline,
LayersControl,
useMap,
} from "react-leaflet";
interface MapViewProps {
selectedDevice: string;
timeFilter: number; // in hours, 0 = all
isPaused: boolean;
filterMode: "quick" | "custom";
startTime: string; // datetime-local format
endTime: string; // datetime-local format
}
interface DeviceInfo {
id: string;
name: string;
color: string;
}
// Component to auto-center map to latest position and track zoom
function SetViewOnChange({
center,
zoom,
onZoomChange
}: {
center: [number, number] | null;
zoom: number;
onZoomChange: (zoom: number) => void;
}) {
const map = useMap();
useEffect(() => {
if (center) {
map.setView(center, zoom, { animate: true });
}
}, [center, zoom, map]);
useEffect(() => {
const handleZoom = () => {
onZoomChange(map.getZoom());
};
// Initial zoom
onZoomChange(map.getZoom());
// Listen to zoom changes
map.on('zoomend', handleZoom);
return () => {
map.off('zoomend', handleZoom);
};
}, [map, onZoomChange]);
return null;
}
export default function MapView({ selectedDevice, timeFilter, isPaused, filterMode, startTime, endTime }: MapViewProps) {
const [locations, setLocations] = useState<Location[]>([]);
const [devices, setDevices] = useState<Record<string, DeviceInfo>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [mapCenter, setMapCenter] = useState<[number, number] | null>(null);
const [currentZoom, setCurrentZoom] = useState(12);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
// Add animation styles for latest marker
useEffect(() => {
// Inject CSS animation for marker pulse effect
if (typeof document !== 'undefined') {
const styleId = 'marker-animation-styles';
if (!document.getElementById(styleId)) {
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
@keyframes marker-pulse {
0% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.15);
opacity: 0.8;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.latest-marker {
animation: marker-pulse 2s ease-in-out infinite;
}
`;
document.head.appendChild(style);
}
}
}, []);
// Fetch devices from API
useEffect(() => {
const fetchDevices = async () => {
try {
const response = await fetch("/api/devices/public");
if (response.ok) {
const data = await response.json();
const devicesMap = data.devices.reduce((acc: Record<string, DeviceInfo>, dev: DeviceInfo) => {
acc[dev.id] = dev;
return acc;
}, {});
setDevices(devicesMap);
}
} catch (err) {
console.error("Failed to fetch devices:", err);
// Fallback to hardcoded devices if API fails
}
};
fetchDevices();
// Refresh devices every 30 seconds (in case of updates)
const interval = setInterval(fetchDevices, 30000);
return () => clearInterval(interval);
}, []);
// Fetch locations
useEffect(() => {
const fetchLocations = async () => {
if (isPaused) return; // Skip fetching when paused
try {
// Build query params
const params = new URLSearchParams();
if (selectedDevice !== "all") {
params.set("username", selectedDevice);
}
// Apply time filter based on mode
if (filterMode === "custom" && startTime && endTime) {
// Convert datetime-local to ISO string
const startISO = new Date(startTime).toISOString();
const endISO = new Date(endTime).toISOString();
params.set("startTime", startISO);
params.set("endTime", endISO);
} else if (filterMode === "quick" && timeFilter > 0) {
params.set("timeRangeHours", timeFilter.toString());
}
params.set("limit", "5000"); // Fetch more data for better history
params.set("sync", "false"); // Disable n8n sync (using direct MQTT)
// Fetch from local SQLite API (MQTT subscriber writes directly)
const response = await fetch(`/api/locations?${params.toString()}`);
if (!response.ok) throw new Error("Failed to fetch locations");
const data: LocationResponse = await response.json();
// Debug: Log last 3 locations to see speed/battery values
if (data.history && data.history.length > 0) {
console.log('[MapView Debug] Last 3 locations:', data.history.slice(0, 3).map(loc => ({
username: loc.username,
timestamp: loc.timestamp,
speed: loc.speed,
speed_type: typeof loc.speed,
speed_is_null: loc.speed === null,
speed_is_undefined: loc.speed === undefined,
battery: loc.battery,
})));
// Auto-center to latest location
const latest = data.history[0];
if (latest && latest.latitude && latest.longitude) {
setMapCenter([Number(latest.latitude), Number(latest.longitude)]);
}
}
setLocations(data.history || []);
setError(null);
} catch (err) {
setError("Failed to load locations");
console.error(err);
} finally {
setLoading(false);
}
};
fetchLocations();
// Store interval reference for pause/resume control
if (!isPaused) {
intervalRef.current = setInterval(fetchLocations, 5000); // Refresh every 5s
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [selectedDevice, timeFilter, isPaused, filterMode, startTime, endTime]);
// No client-side filtering needed - API already filters by username and timeRangeHours
// Filter out locations without username (should not happen, but TypeScript safety)
const filteredLocations = locations.filter(loc => loc.username != null);
// Group by device
const deviceGroups = filteredLocations.reduce((acc, loc) => {
const deviceId = loc.username!; // Safe to use ! here because we filtered null above
if (!acc[deviceId]) acc[deviceId] = [];
acc[deviceId].push(loc);
return acc;
}, {} as Record<string, Location[]>);
if (loading) {
return (
<div className="h-full flex items-center justify-center bg-gray-100">
<p>Loading map...</p>
</div>
);
}
if (error) {
return (
<div className="h-full flex items-center justify-center bg-red-50">
<p className="text-red-600">{error}</p>
</div>
);
}
return (
<div className="h-full w-full">
<MapContainer
center={[48.1351, 11.582]}
zoom={12}
style={{ height: "100%", width: "100%" }}
>
{/* Auto-center to latest position and track zoom */}
<SetViewOnChange
center={mapCenter}
zoom={14}
onZoomChange={setCurrentZoom}
/>
<LayersControl position="topright">
<LayersControl.BaseLayer checked name="Standard">
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
/>
</LayersControl.BaseLayer>
<LayersControl.BaseLayer name="Satellite">
<TileLayer
url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
attribution="Esri"
/>
</LayersControl.BaseLayer>
<LayersControl.BaseLayer name="Dark">
<TileLayer
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
attribution='&copy; <a href="https://carto.com/">CartoDB</a>'
/>
</LayersControl.BaseLayer>
</LayersControl>
{Object.entries(deviceGroups).map(([deviceId, locs]) => {
// Use device from API if available, fallback to hardcoded
const device = devices[deviceId] || getDevice(deviceId);
// Sort DESC (newest first) - same as API
const sortedLocs = [...locs].sort(
(a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
return (
<div key={deviceId}>
{/* Polyline for path - reverse for chronological drawing (oldest to newest) */}
<Polyline
positions={[...sortedLocs].reverse().map((loc) => [
Number(loc.latitude),
Number(loc.longitude),
])}
color={device.color}
weight={2}
opacity={0.6}
/>
{/* Markers - render with explicit z-index (newest on top) */}
{sortedLocs.map((loc, idx) => {
const isLatest = idx === 0; // First in sorted array = newest (DESC order)
// Calculate z-index: newer locations get higher z-index
const zIndexOffset = sortedLocs.length - idx;
// Debug: Log for latest location only
if (isLatest) {
console.log('[Popup Debug] Latest location for', device.name, {
speed: loc.speed,
speed_type: typeof loc.speed,
speed_is_null: loc.speed === null,
speed_is_undefined: loc.speed === undefined,
condition_result: loc.speed != null,
display_time: loc.display_time
});
}
return (
<Marker
key={`${deviceId}-${loc.timestamp}-${idx}`}
position={[Number(loc.latitude), Number(loc.longitude)]}
icon={createCustomIcon(
device.color,
isLatest,
currentZoom
)}
zIndexOffset={zIndexOffset}
>
<Popup>
<div className="text-sm space-y-1">
<p className="font-bold text-base flex items-center gap-2">
<span className="text-lg">📱</span>
{device.name}
</p>
<p className="flex items-center gap-1">
<span>🕒</span> {loc.display_time}
</p>
{loc.battery != null && Number(loc.battery) > 0 && (
<p className="flex items-center gap-1">
<span>🔋</span> Battery: {loc.battery}%
</p>
)}
{loc.speed != null && (
<p className="flex items-center gap-1">
<span>🚗</span> Speed: {Number(loc.speed).toFixed(1)} km/h
</p>
)}
</div>
</Popup>
</Marker>
);
})}
</div>
);
})}
</MapContainer>
</div>
);
}
// Helper to create custom icon (similar to original)
function createCustomIcon(color: string, isLatest: boolean, zoom: number) {
// Base size - much bigger than before
const baseSize = isLatest ? 64 : 32;
// Zoom-based scaling: smaller at zoom 10, larger at zoom 18+
// zoom 10 = 0.6x, zoom 12 = 1.0x, zoom 15 = 1.45x, zoom 18 = 1.9x
const zoomScale = 0.6 + ((zoom - 10) * 0.15);
const clampedScale = Math.max(0.5, Math.min(2.5, zoomScale)); // Clamp between 0.5x and 2.5x
const size = Math.round(baseSize * clampedScale);
// Standard Location Pin Icon (wie Google Maps/Standard Marker)
const svg = `
<svg width="${size}" height="${size}" viewBox="0 0 24 36" xmlns="http://www.w3.org/2000/svg">
<!-- Outer pin shape -->
<path d="M12 0C5.4 0 0 5.4 0 12c0 7 12 24 12 24s12-17 12-24c0-6.6-5.4-12-12-12z"
fill="${color}"
stroke="white"
stroke-width="1.5"/>
<!-- Inner white circle -->
<circle cx="12" cy="12" r="5" fill="white" opacity="0.9"/>
<!-- Center dot -->
<circle cx="12" cy="12" r="2.5" fill="${color}"/>
</svg>
`;
return L.divIcon({
html: svg,
iconSize: [size, size * 1.5], // Height 1.5x width for pin shape
iconAnchor: [size / 2, size * 1.5], // Bottom center point
popupAnchor: [0, -size * 1.2], // Popup above the pin
className: isLatest ? "custom-marker-icon latest-marker" : "custom-marker-icon",
});
}

0
data/.gitkeep Normal file
View File

58
data/README.md Normal file
View File

@@ -0,0 +1,58 @@
# Database Directory
This directory contains SQLite database files for the application.
## Initial Setup
After cloning the repository, initialize the databases:
```bash
# Initialize BOTH databases (recommended)
npm run db:init
# Or initialize them separately:
npm run db:init:app # Creates database.sqlite (User + Device tables)
npm run db:init:locations # Creates locations.sqlite (Location cache)
```
**Default admin credentials:**
- Username: `admin`
- Password: `admin123`
**Default devices:**
- Device 10: "Device A" (red #e74c3c)
- Device 11: "Device B" (blue #3498db)
## Database Files
- `database.sqlite` - User accounts and device configuration (critical)
- `locations.sqlite` - Location tracking cache (disposable, can be regenerated)
- `.gitkeep` - Ensures this directory exists in git
**Note:** Database files (*.db, *.sqlite) are NOT tracked in git to avoid conflicts.
Only the schema (via init scripts) is versioned.
## Maintenance
```bash
# Clean up old location data (keeps last 7 days)
npm run db:cleanup:7d
# Clean up old location data (keeps last 30 days)
npm run db:cleanup:30d
# Get database statistics
curl http://localhost:3000/api/locations/ingest
```
## Backup Strategy
**database.sqlite** (critical):
- Daily automated backups recommended
- Contains user accounts and device configurations
- Keep 30-day retention
**locations.sqlite** (cache):
- Optional backups
- Can be regenerated from NocoDB if needed
- Or use automatic cleanup to keep size manageable

56
docker-compose.yml Normal file
View File

@@ -0,0 +1,56 @@
services:
# Location Tracker App (Next.js)
app:
build: .
container_name: location-tracker-app
env_file:
- .env
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- AUTH_SECRET=${AUTH_SECRET}
- NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:3000}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- MQTT_BROKER_URL=mqtt://mosquitto:1883
- MQTT_USERNAME=${MQTT_ADMIN_USERNAME:-admin}
- MQTT_PASSWORD=${MQTT_ADMIN_PASSWORD:-admin}
- MOSQUITTO_PASSWORD_FILE=/mosquitto/config/password.txt
- MOSQUITTO_ACL_FILE=/mosquitto/config/acl.txt
- MOSQUITTO_CONTAINER_NAME=mosquitto
- MOSQUITTO_ADMIN_USERNAME=${MQTT_ADMIN_USERNAME:-admin}
- MOSQUITTO_ADMIN_PASSWORD=${MQTT_ADMIN_PASSWORD:-admin}
# SMTP Configuration
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_SECURE=${SMTP_SECURE:-false}
- SMTP_USER=${SMTP_USER}
- SMTP_PASS=${SMTP_PASS}
- SMTP_FROM_EMAIL=${SMTP_FROM_EMAIL}
- SMTP_FROM_NAME=${SMTP_FROM_NAME:-Location Tracker}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
volumes:
- ./data:/app/data
- ./mosquitto/config:/mosquitto/config # Lokaler Ordner statt Docker Volume
- /var/run/docker.sock:/var/run/docker.sock # Für Mosquitto Reload
depends_on:
- mosquitto
restart: unless-stopped
# Eclipse Mosquitto MQTT Broker
mosquitto:
image: eclipse-mosquitto:2
container_name: mosquitto
ports:
- "1883:1883" # MQTT
- "9001:9001" # WebSocket
volumes:
- ./mosquitto/config:/mosquitto/config # Lokaler Ordner mit mosquitto.conf
- ./mosquitto/logs:/mosquitto/log # Lokaler Ordner für Logs
- mosquitto_data:/mosquitto/data
restart: unless-stopped
volumes:
# mosquitto_config und mosquitto_logs werden als bind mounts verwendet
mosquitto_data:
name: mosquitto_data

144
docs/SMTP-SETUP.md Normal file
View File

@@ -0,0 +1,144 @@
# SMTP Setup Guide
## Overview
This guide explains how to configure SMTP for email functionality in the Location Tracker app.
## Prerequisites
- SMTP server credentials (Gmail, SendGrid, Mailgun, etc.)
- For Gmail: App Password (not regular password)
## Configuration Methods
### Method 1: Environment Variables (Fallback)
1. Copy `.env.example` to `.env.local`:
```bash
cp .env.example .env.local
```
2. Generate encryption key:
```bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
3. Update SMTP settings in `.env.local`:
```env
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
SMTP_FROM_EMAIL=noreply@example.com
SMTP_FROM_NAME=Location Tracker
ENCRYPTION_KEY=<generated-key-from-step-2>
```
### Method 2: Admin Panel (Recommended)
**IMPORTANT:** The `ENCRYPTION_KEY` environment variable is **required** for database-stored SMTP configuration. Generate and set it before using the admin panel:
```bash
# Generate encryption key
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# Add to your environment variables (e.g., .env.local)
ENCRYPTION_KEY=<generated-key>
```
Steps:
1. Ensure `ENCRYPTION_KEY` is set in your environment
2. Restart the application server to load the new environment variable
3. Log in as admin
4. Navigate to **Settings** → **SMTP Settings**
5. Fill in SMTP configuration
6. Click **Test Connection** to verify
7. Click **Save Settings**
## Provider-Specific Setup
### Gmail
1. Enable 2-Factor Authentication
2. Generate App Password:
- Go to Google Account → Security → 2-Step Verification → App Passwords
- Select "Mail" and generate password
3. Use generated password in SMTP_PASS
Settings:
- Host: `smtp.gmail.com`
- Port: `587`
- Secure: `false` (uses STARTTLS)
### SendGrid
Settings:
- Host: `smtp.sendgrid.net`
- Port: `587`
- Secure: `false`
- User: `apikey`
- Pass: Your SendGrid API key
### Mailgun
Settings:
- Host: `smtp.mailgun.org`
- Port: `587`
- Secure: `false`
- User: Your Mailgun SMTP username
- Pass: Your Mailgun SMTP password
## Testing
### Via Script
```bash
node scripts/test-smtp.js your-email@example.com
```
### Via Admin Panel
1. Go to **Emails** page
2. Select a template
3. Click **Send Test Email**
4. Enter your email and send
## Troubleshooting
### Connection Timeout
- Check firewall settings
- Verify port is correct (587 for STARTTLS, 465 for SSL)
- Try toggling "Use TLS/SSL" setting
### Authentication Failed
- **For Gmail:** Use App Password, not account password
- Generate at: https://myaccount.google.com/apppasswords
- Enable 2FA first before creating App Passwords
- Verify username and password have no trailing spaces
- Check if SMTP is enabled for your account
- **Database config users:** Ensure `ENCRYPTION_KEY` is set and server was restarted
- If using database config after upgrading, click "Reset to Defaults" and re-enter credentials
### Emails Not Received
- Check spam/junk folder
- Verify "From Email" is valid
- Check provider sending limits
## Email Templates
Available templates:
- **Welcome Email**: Sent when new user is created
- **Password Reset**: Sent when user requests password reset
Templates can be previewed in **Admin → Emails**.
## Security Notes
- Passwords stored in database are encrypted using AES-256-GCM
- ENCRYPTION_KEY must be kept secret
- Never commit `.env.local` to git
- Use environment-specific SMTP credentials

View File

@@ -0,0 +1,555 @@
# SMTP Integration Design
**Datum:** 2025-11-17
**Feature:** SMTP Integration für E-Mail-Versand (Welcome Mails & Password Reset)
## Übersicht
Integration eines flexiblen SMTP-Systems in die Location Tracker App für automatische E-Mail-Benachrichtigungen. Hybrid-Ansatz mit .env-Fallback und Admin-Panel-Konfiguration.
## Anforderungen
### Funktionale Anforderungen
- Welcome-E-Mails beim Erstellen neuer User
- Password-Reset-Flow für User
- SMTP-Konfiguration über Admin-Panel
- Live-Vorschau aller E-Mail-Templates
- Test-E-Mail-Versand zur Validierung
- Fallback auf .env wenn DB-Config leer
### Nicht-funktionale Anforderungen
- Verschlüsselte Speicherung von SMTP-Passwörtern
- Rate-Limiting für Test-E-Mails
- Fehlertoleranz (User-Erstellung funktioniert auch bei E-Mail-Fehler)
- Mobile-responsive Admin-UI
## Architektur
### 1. Datenbank-Schema
**Neue Tabelle: `settings`** (in `database.sqlite`)
```sql
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
```
**SMTP-Config als JSON in `settings.value`:**
```json
{
"host": "smtp.gmail.com",
"port": 587,
"secure": false,
"auth": {
"user": "user@example.com",
"pass": "encrypted_password_here"
},
"from": {
"email": "noreply@example.com",
"name": "Location Tracker"
},
"replyTo": "support@example.com",
"timeout": 10000
}
```
**Neue Tabelle: `password_reset_tokens`** (in `database.sqlite`)
```sql
CREATE TABLE password_reset_tokens (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
expires_at TEXT NOT NULL,
used INTEGER DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
```
### 2. Umgebungsvariablen (.env)
Neue Variablen für Fallback-Config:
```env
# SMTP Configuration (Fallback)
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=user@example.com
SMTP_PASS=password
SMTP_FROM_EMAIL=noreply@example.com
SMTP_FROM_NAME=Location Tracker
# Security
ENCRYPTION_KEY=<32-byte-hex-string>
```
### 3. API-Struktur
#### Admin-APIs (Authentifizierung erforderlich, role: admin)
**`GET /api/admin/settings/smtp`**
- Gibt aktuelle SMTP-Config zurück (Passwort maskiert)
- Response: `{ config: SMTPConfig | null, source: 'database' | 'env' }`
**`POST /api/admin/settings/smtp`**
- Speichert SMTP-Einstellungen in Datenbank
- Body: `SMTPConfig`
- Validiert alle Felder
- Verschlüsselt Passwort vor Speicherung
**`POST /api/admin/settings/smtp/test`**
- Testet SMTP-Verbindung ohne zu speichern
- Body: `{ config: SMTPConfig, testEmail: string }`
- Sendet Test-E-Mail an angegebene Adresse
**`GET /api/admin/emails/preview?template=welcome`**
- Rendert E-Mail-Template als HTML
- Query: `template` ("welcome" | "password-reset")
- Verwendet Beispiel-Daten
**`POST /api/admin/emails/send-test`**
- Sendet Test-E-Mail mit echtem Template
- Body: `{ template: string, email: string }`
- Rate-Limit: 5 pro Minute
#### Auth-APIs (Public)
**`POST /api/auth/forgot-password`**
- Body: `{ email: string }`
- Generiert Token, sendet Reset-E-Mail
- Response: Immer Success (Security: kein User-Enumeration)
**`POST /api/auth/reset-password`**
- Body: `{ token: string, newPassword: string }`
- Validiert Token, setzt neues Passwort
- Markiert Token als "used"
### 4. E-Mail-Service
**Zentrale Klasse: `EmailService`** (`/lib/email-service.ts`)
```typescript
class EmailService {
async sendWelcomeEmail(user: User, temporaryPassword?: string): Promise<void>
async sendPasswordReset(user: User, token: string): Promise<void>
private async getConfig(): Promise<SMTPConfig>
private async sendEmail(to: string, subject: string, html: string): Promise<void>
}
```
**Funktionsweise:**
1. Lädt SMTP-Config aus DB (Key: `smtp_config`)
2. Falls leer → Lädt aus .env
3. Entschlüsselt Passwort
4. Erstellt Nodemailer-Transport
5. Rendert React Email Template → HTML
6. Sendet E-Mail mit Fehlerbehandlung
### 5. React Email Templates
**Verzeichnisstruktur:**
```
/emails/
├── components/
│ ├── email-layout.tsx # Basis-Layout für alle E-Mails
│ ├── email-header.tsx # Header mit Logo
│ └── email-footer.tsx # Footer mit Links
├── welcome.tsx # Welcome-E-Mail Template
└── password-reset.tsx # Password-Reset Template
```
**Template-Props:**
```typescript
// welcome.tsx
interface WelcomeEmailProps {
username: string;
loginUrl: string;
temporaryPassword?: string;
}
// password-reset.tsx
interface PasswordResetEmailProps {
username: string;
resetUrl: string;
expiresIn: string;
}
```
**Features:**
- Inline-Styles für maximale Kompatibilität
- Responsive Design
- Automatische Plain-Text-Alternative
- Wiederverwendbare Komponenten
### 6. Admin-Panel UI
#### Neue Navigation
Erweitere `/admin/layout.tsx`:
```typescript
const navigation = [
{ name: "Dashboard", href: "/admin" },
{ name: "Devices", href: "/admin/devices" },
{ name: "Users", href: "/admin/users" },
{ name: "Settings", href: "/admin/settings" }, // NEU
{ name: "Emails", href: "/admin/emails" }, // NEU
];
```
#### `/admin/settings` - SMTP-Konfiguration
**Layout:**
- Tab-Navigation: "SMTP Settings" (weitere Tabs vorbereitet)
- Formular mit Feldern:
- Host (Text)
- Port (Number)
- Secure (Toggle: TLS/SSL)
- Username (Text)
- Password (Password, zeigt *** wenn gesetzt)
- From Email (Email)
- From Name (Text)
- Reply-To (Email, optional)
- Timeout (Number, ms)
**Buttons:**
- "Test Connection" → Modal für Test-E-Mail-Adresse
- "Save Settings" → Speichert in DB
- "Reset to Defaults" → Lädt .env-Werte
**Validierung:**
- Live-Validierung bei Eingabe
- Port: 1-65535
- E-Mail-Format prüfen
- Required-Felder markieren
#### `/admin/emails` - E-Mail-Vorschau
**Layout:**
- Links: Liste aller Templates
- Rechts: Live-Vorschau (iframe mit gerendertem HTML)
- Mobile: Gestapelt
**Features:**
- Template auswählen → Vorschau aktualisiert
- "Send Test Email" Button → Modal für E-Mail-Adresse
- "Edit Template" Link (führt zur Datei, Info-Text)
#### `/admin/users` - Erweiterungen
Neue Buttons pro User:
- "Resend Welcome Email"
- "Send Password Reset"
### 7. User-facing Pages
#### `/forgot-password`
**UI:**
- E-Mail-Eingabefeld
- "Send Reset Link" Button
- Link zurück zu `/login`
**Flow:**
1. User gibt E-Mail ein
2. POST zu `/api/auth/forgot-password`
3. Success-Message (immer, auch bei ungültiger E-Mail)
4. E-Mail mit Reset-Link wird versendet
#### `/reset-password?token=xxx`
**UI:**
- Neues Passwort eingeben (2x)
- "Reset Password" Button
**Validierung:**
- Token gültig?
- Token nicht abgelaufen?
- Token nicht bereits verwendet?
- Passwort-Stärke prüfen
**Flow:**
1. Token validieren (onLoad)
2. Bei ungültig: Fehler anzeigen
3. User gibt neues Passwort ein
4. POST zu `/api/auth/reset-password`
5. Erfolg → Redirect zu `/login`
#### `/login` - Erweiterung
Neuer Link unter Login-Form:
```tsx
<Link href="/forgot-password">Forgot Password?</Link>
```
## Sicherheit
### Verschlüsselung
**SMTP-Passwort:**
- Algorithmus: AES-256-GCM
- Key: `process.env.ENCRYPTION_KEY` (32-Byte-Hex)
- Verschlüsselt vor DB-Speicherung
- Entschlüsselt beim Abrufen
**Implementierung:** `/lib/crypto-utils.ts`
```typescript
function encrypt(text: string): string
function decrypt(encryptedText: string): string
```
### Authentifizierung & Autorisierung
- Alle `/api/admin/*` prüfen `next-auth` Session
- User muss `role: "admin"` haben
- Unauthorized → 401 Response
### Rate-Limiting
**Test-E-Mail-Versand:**
- Max. 5 Test-E-Mails pro Minute
- Pro IP-Adresse
- 429 Response bei Überschreitung
### Input-Validierung
- E-Mail-Adressen: RFC 5322 Format
- Port: 1-65535
- Timeout: > 0
- SQL-Injection-Schutz durch Prepared Statements
### Token-Sicherheit
**Password-Reset-Tokens:**
- UUID v4 (kryptografisch sicher)
- Gültigkeitsdauer: 1 Stunde
- One-Time-Use (used-Flag)
- Automatisches Cleanup alter Tokens (Cron-Job)
## Fehlerbehandlung
### E-Mail-Versand-Fehler
**Strategie: Fail-Soft**
- User-Erstellung schlägt NICHT fehl bei E-Mail-Fehler
- Fehler wird geloggt
- Admin-Benachrichtigung im Dashboard (später)
- "Resend" Option verfügbar
**Error-Logging:**
```typescript
console.error('[EmailService] Failed to send email:', {
type: 'welcome' | 'password-reset',
recipient: user.email,
error: error.message
});
```
### SMTP-Verbindungsfehler
**Im Admin-Panel:**
- Detaillierte Fehlermeldung anzeigen
- Vorschläge zur Fehlerbehebung
- Link zur SMTP-Provider-Dokumentation
**Typische Fehler:**
- Authentication failed → Credentials prüfen
- Connection timeout → Firewall/Port prüfen
- TLS error → secure-Flag anpassen
## Testing
### Development
**Mailtrap/Ethereal für SMTP-Tests:**
```env
SMTP_HOST=smtp.ethereal.email
SMTP_PORT=587
SMTP_USER=<ethereal-user>
SMTP_PASS=<ethereal-pass>
```
**React Email Dev-Server:**
```bash
npm run email:dev
```
Öffnet Browser mit Vorschau aller Templates
### Admin-Panel Testing
1. SMTP-Config eingeben
2. "Test Connection" klicken
3. Test-E-Mail-Adresse eingeben
4. E-Mail empfangen & Inhalt prüfen
5. "Save Settings" klicken
6. Page-Reload → Config bleibt erhalten
### Integration Testing
**Welcome-E-Mail:**
1. Neuen User in `/admin/users` erstellen
2. Welcome-E-Mail wird versendet
3. E-Mail empfangen & prüfen
**Password-Reset:**
1. `/forgot-password` öffnen
2. E-Mail eingeben & absenden
3. Reset-E-Mail empfangen
4. Link klicken → `/reset-password` öffnet
5. Neues Passwort setzen
6. Mit neuem Passwort einloggen
## Deployment
### Checklist
- [ ] `npm install` für neue Dependencies
- [ ] `ENCRYPTION_KEY` in `.env` generieren
- [ ] SMTP-Credentials in `.env` setzen (optional, Fallback)
- [ ] `npm run db:init:app` (neue Tabellen erstellen)
- [ ] Server-Neustart
- [ ] SMTP im Admin-Panel konfigurieren
- [ ] Test-E-Mail senden & empfangen
- [ ] Password-Reset-Flow einmal komplett testen
### Migration-Script
Erweitere `/scripts/init-database.js`:
```javascript
// Neue Tabellen erstellen
db.exec(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS password_reset_tokens (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
expires_at TEXT NOT NULL,
used INTEGER DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`);
// Index für Performance
db.exec(`
CREATE INDEX IF NOT EXISTS idx_reset_tokens_user_id
ON password_reset_tokens(user_id);
`);
```
## Dependencies
### NPM-Packages
```json
{
"dependencies": {
"nodemailer": "^6.9.8",
"react-email": "^2.1.0",
"@react-email/components": "^0.0.14"
},
"devDependencies": {
"@types/nodemailer": "^6.4.14"
}
}
```
### Scripts (package.json)
```json
{
"scripts": {
"email:dev": "email dev"
}
}
```
## Dateistruktur
```
/location-tracker-app/
├── docs/
│ └── plans/
│ └── 2025-11-17-smtp-integration-design.md
├── emails/
│ ├── components/
│ │ ├── email-layout.tsx
│ │ ├── email-header.tsx
│ │ └── email-footer.tsx
│ ├── welcome.tsx
│ └── password-reset.tsx
├── lib/
│ ├── email-service.ts
│ ├── crypto-utils.ts
│ └── email-renderer.ts
├── app/
│ ├── admin/
│ │ ├── settings/
│ │ │ └── page.tsx
│ │ ├── emails/
│ │ │ └── page.tsx
│ │ └── users/
│ │ └── page.tsx (erweitern)
│ ├── forgot-password/
│ │ └── page.tsx
│ ├── reset-password/
│ │ └── page.tsx
│ └── api/
│ ├── admin/
│ │ ├── settings/
│ │ │ └── smtp/
│ │ │ ├── route.ts
│ │ │ └── test/route.ts
│ │ └── emails/
│ │ ├── preview/route.ts
│ │ └── send-test/route.ts
│ └── auth/
│ ├── forgot-password/route.ts
│ └── reset-password/route.ts
└── scripts/
└── init-database.js (erweitern)
```
## Zukünftige Erweiterungen
### Phase 2 (Optional)
- Weitere E-Mail-Templates (Geofence-Alerts, Reports)
- E-Mail-Queue für Bulk-Versand
- E-Mail-Versand-Statistiken im Dashboard
- Attachments-Support
- Multiple SMTP-Provider (Failover)
- E-Mail-Template-Editor im Admin-Panel
### Phase 3 (Optional)
- Alternative zu SMTP: SendGrid/Mailgun/Resend API
- Webhook für E-Mail-Events (Delivered, Opened, Clicked)
- Unsubscribe-Verwaltung
- E-Mail-Preferences pro User
## Offene Fragen
- ✅ SMTP-Parameter: Erweiterte Konfiguration gewählt
- ✅ E-Mail-Bibliothek: Nodemailer gewählt
- ✅ Template-Engine: React Email gewählt
- ✅ Vorschau-Strategie: Live-Vorschau im Admin-Panel
- ✅ Config-Speicherung: Hybrid-Ansatz (DB + .env Fallback)
## Abnahmekriterien
- [ ] Admin kann SMTP-Settings im Panel konfigurieren
- [ ] Test-E-Mail-Versand funktioniert
- [ ] Welcome-E-Mail wird bei User-Erstellung gesendet
- [ ] Password-Reset-Flow funktioniert komplett
- [ ] E-Mail-Vorschau zeigt alle Templates korrekt an
- [ ] SMTP-Passwort wird verschlüsselt gespeichert
- [ ] Fallback auf .env funktioniert
- [ ] Fehlerbehandlung zeigt sinnvolle Meldungen
- [ ] Mobile-responsive Admin-UI
- [ ] Alle Tests erfolgreich

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,208 @@
# Device Access Control Security Fix
**Date:** 2025-11-23
**Issue:** Users could see all devices in the system, regardless of ownership
**Severity:** Medium (Information Disclosure)
## Problem Statement
The `/admin` dashboard and `/map` page were calling `/api/devices/public`, which returned **all devices** without authentication or filtering. This meant:
- Any authenticated user could see device names, IDs, and colors for all devices
- Regular ADMIN users could see devices owned by other users
- VIEWER users could see devices they shouldn't have access to
## Root Cause
The `/api/devices/public` endpoint:
1. Had no authentication check
2. Called `deviceDb.findAll()` without any `userId` filter
3. Was designed as a "public" endpoint despite containing sensitive information
## Solution Implemented
### 1. Created Centralized Access Control Helper (`lib/db.ts`)
Added `userDb.getAllowedDeviceIds(userId, role, username)` function that implements role-based access control:
```typescript
/**
* Get list of device IDs that a user is allowed to access
* @param userId - The user's ID
* @param role - The user's role (ADMIN, VIEWER)
* @param username - The user's username (for super admin check)
* @returns Array of device IDs the user can access
*/
getAllowedDeviceIds: (userId: string, role: string, username: string): string[]
```
**Access Control Rules:**
- **Super Admin** (`username === "admin"`): Sees ALL devices
- **VIEWER** (with `parent_user_id`): Sees parent user's devices only
- **Regular ADMIN**: Sees only their own devices (`ownerId = userId`)
- **Others**: No access (empty array)
### 2. Secured `/api/devices/public` Endpoint
**Changes to `app/api/devices/public/route.ts`:**
- Added `auth()` check at the beginning
- Returns 401 if no session
- Uses `userDb.getAllowedDeviceIds()` to filter devices
- Maintains backward-compatible response format
**Before:**
```typescript
export async function GET() {
const devices = deviceDb.findAll();
// Returns ALL devices without auth
}
```
**After:**
```typescript
export async function GET() {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const allowedDeviceIds = userDb.getAllowedDeviceIds(userId, role, username);
const userDevices = allDevices.filter(device =>
allowedDeviceIds.includes(device.id)
);
// Returns only devices user can access
}
```
### 3. Enhanced `/api/locations` Endpoint
**Changes to `app/api/locations/route.ts`:**
- Replaced manual parent user lookup with centralized `getAllowedDeviceIds()`
- Ensures location data is filtered to only show locations from owned devices
- Simplified code by removing duplicate logic
**Before:**
```typescript
let targetUserId = userId;
if (currentUser?.role === 'VIEWER' && currentUser.parent_user_id) {
targetUserId = currentUser.parent_user_id;
}
const userDevices = deviceDb.findAll({ userId: targetUserId });
```
**After:**
```typescript
const userDeviceIds = userDb.getAllowedDeviceIds(userId, role, sessionUsername);
// Filter locations to only include user's devices
locations = locations.filter(loc => userDeviceIds.includes(loc.username));
```
## Testing Results
Created test script `scripts/test-device-access.js` to verify access control.
**Test Results:**
```
User: admin (ADMIN)
Can see devices: 10, 11, 12, 15 ✓ (ALL devices)
User: joachim (ADMIN)
Can see devices: 12, 15 ✓ (only owned devices)
User: hummel (VIEWER, parent: joachim)
Can see devices: 12, 15 ✓ (parent's devices)
User: joachiminfo (ADMIN, no devices)
Can see devices: NONE ✓ (no owned devices)
```
All tests passed! Each user sees only the devices they should have access to.
## Impact
### Security Improvements
- ✅ Users can no longer see devices they don't own
- ✅ Device ownership is enforced at the API level
- ✅ Location data is filtered by device ownership
- ✅ Centralized access control logic prevents future bugs
### User Experience
- No breaking changes for legitimate users
- Each user sees only their own data
- Super admin retains full visibility for system administration
- VIEWER users see their parent's devices as intended
### Files Modified
1. `lib/db.ts` - Added `getAllowedDeviceIds()` helper function
2. `app/api/devices/public/route.ts` - Added authentication and filtering
3. `app/api/locations/route.ts` - Updated to use centralized access control
4. `scripts/test-device-access.js` - New test script (can be deleted after verification)
## Deployment Notes
### Server Restart Required
After deploying these changes, the Next.js server must be restarted to pick up the new code:
```bash
pkill -f "next dev"
npm run dev
```
Or in production:
```bash
npm run build
pm2 restart location-tracker-app
```
### Database Schema
No database migrations required. The fix uses existing columns:
- `Device.ownerId` (already exists)
- `User.parent_user_id` (already exists)
- `User.role` (already exists)
### Backward Compatibility
The API response format remains unchanged. Frontend components (dashboard, map) require no modifications.
## Future Recommendations
### 1. Rename Endpoint
Consider renaming `/api/devices/public` to `/api/devices/my-devices` to better reflect its authenticated, filtered nature.
### 2. Add API Tests
Create automated tests for device access control:
- Test super admin access
- Test regular admin access
- Test VIEWER access with parent
- Test VIEWER access without parent
### 3. Audit Other Endpoints
Review other API endpoints for similar access control issues:
- `/api/users` - Should users see other users?
- `/api/mqtt/credentials` - Should credentials be filtered by user?
### 4. Add Logging
Consider adding audit logging for device access:
```typescript
console.log(`[Access Control] User ${username} (${role}) accessed devices: ${allowedDeviceIds.join(', ')}`);
```
## Verification Steps for Deployment
1. **Login as regular ADMIN user** (e.g., "joachim")
- Navigate to `/admin`
- Verify "Configured Devices" shows only devices you own
- Verify `/map` shows only your devices
2. **Login as VIEWER user** (e.g., "hummel")
- Navigate to `/admin`
- Verify you see parent user's devices
- Verify `/map` shows parent's devices
3. **Login as super admin** (username: "admin")
- Navigate to `/admin`
- Verify you see ALL devices
- Verify `/map` shows all devices
## Conclusion
The device access control security issue has been successfully fixed. Users now only see devices they own (or their parent's devices for VIEWER users), with the exception of the super admin who retains full system visibility.
The fix is backward-compatible, requires no database changes, and centralizes access control logic for easier maintenance.

View File

@@ -0,0 +1,34 @@
import { Hr, Link, Section, Text } from '@react-email/components';
import * as React from 'react';
export const EmailFooter = () => {
return (
<>
<Hr style={hr} />
<Section style={footer}>
<Text style={footerText}>
This email was sent from Location Tracker.
</Text>
<Text style={footerText}>
If you have questions, please contact your administrator.
</Text>
</Section>
</>
);
};
const hr = {
borderColor: '#eaeaea',
margin: '26px 0',
};
const footer = {
padding: '0 40px',
};
const footerText = {
color: '#6b7280',
fontSize: '12px',
lineHeight: '1.5',
margin: '0 0 8px',
};

View File

@@ -0,0 +1,34 @@
import { Heading, Section, Text } from '@react-email/components';
import * as React from 'react';
interface EmailHeaderProps {
title: string;
}
export const EmailHeader = ({ title }: EmailHeaderProps) => {
return (
<Section style={header}>
<Heading style={h1}>{title}</Heading>
<Text style={subtitle}>Location Tracker</Text>
</Section>
);
};
const header = {
padding: '20px 40px',
borderBottom: '1px solid #eaeaea',
};
const h1 = {
color: '#1f2937',
fontSize: '24px',
fontWeight: '600',
lineHeight: '1.3',
margin: '0 0 8px',
};
const subtitle = {
color: '#6b7280',
fontSize: '14px',
margin: '0',
};

View File

@@ -0,0 +1,40 @@
import {
Body,
Container,
Head,
Html,
Preview,
Section,
} from '@react-email/components';
import * as React from 'react';
interface EmailLayoutProps {
preview: string;
children: React.ReactNode;
}
export const EmailLayout = ({ preview, children }: EmailLayoutProps) => {
return (
<Html>
<Head />
<Preview>{preview}</Preview>
<Body style={main}>
<Container style={container}>{children}</Container>
</Body>
</Html>
);
};
const main = {
backgroundColor: '#f6f9fc',
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
};
const container = {
backgroundColor: '#ffffff',
margin: '0 auto',
padding: '20px 0 48px',
marginBottom: '64px',
maxWidth: '600px',
};

171
emails/mqtt-credentials.tsx Normal file
View File

@@ -0,0 +1,171 @@
import { Section, Text } from '@react-email/components';
import * as React from 'react';
import { EmailLayout } from './components/email-layout';
import { EmailHeader } from './components/email-header';
import { EmailFooter } from './components/email-footer';
interface MqttCredentialsEmailProps {
deviceName: string;
deviceId: string;
mqttUsername: string;
mqttPassword: string;
brokerUrl: string;
brokerHost?: string;
brokerPort?: string;
}
export const MqttCredentialsEmail = ({
deviceName = 'Device',
deviceId = '10',
mqttUsername = 'user_device10',
mqttPassword = 'password123',
brokerUrl = 'mqtt://localhost:1883',
brokerHost = 'localhost',
brokerPort = '1883',
}: MqttCredentialsEmailProps) => {
return (
<EmailLayout preview="MQTT Device Credentials">
<EmailHeader title="MQTT Device Credentials" />
<Section style={content}>
<Text style={paragraph}>
Your MQTT credentials for device <strong>{deviceName}</strong> (ID: {deviceId}):
</Text>
<Section style={credentialsBox}>
<Text style={credentialLabel}>MQTT Broker:</Text>
<Text style={credentialValue}>{brokerUrl}</Text>
<Text style={credentialLabel}>Host:</Text>
<Text style={credentialValue}>{brokerHost}</Text>
<Text style={credentialLabel}>Port:</Text>
<Text style={credentialValue}>{brokerPort}</Text>
<Text style={credentialLabel}>Username:</Text>
<Text style={credentialValue}>{mqttUsername}</Text>
<Text style={credentialLabel}>Password:</Text>
<Text style={credentialValue}>{mqttPassword}</Text>
<Text style={credentialLabel}>Topic Pattern:</Text>
<Text style={credentialValue}>owntracks/owntrack/{deviceId}</Text>
</Section>
<Section style={instructionsBox}>
<Text style={instructionsTitle}>OwnTracks App Setup:</Text>
<Text style={instructionStep}>1. Open OwnTracks app</Text>
<Text style={instructionStep}>2. Go to Settings Connection</Text>
<Text style={instructionStep}>3. Set Mode to "MQTT"</Text>
<Text style={instructionStep}>4. Enter the credentials above:</Text>
<Text style={instructionDetail}> Host: {brokerHost}</Text>
<Text style={instructionDetail}> Port: {brokerPort}</Text>
<Text style={instructionDetail}> Username: {mqttUsername}</Text>
<Text style={instructionDetail}> Password: {mqttPassword}</Text>
<Text style={instructionDetail}> Device ID: {deviceId}</Text>
<Text style={instructionStep}>5. Save settings</Text>
<Text style={instructionStep}>6. The app will connect automatically</Text>
</Section>
<Text style={warningText}>
Keep these credentials secure. Do not share them with unauthorized persons.
</Text>
<Text style={paragraph}>
If you have any questions or need assistance, please contact your administrator.
</Text>
<Text style={paragraph}>
Best regards,
<br />
Location Tracker Team
</Text>
</Section>
<EmailFooter />
</EmailLayout>
);
};
export default MqttCredentialsEmail;
const content = {
padding: '20px 40px',
};
const paragraph = {
color: '#374151',
fontSize: '16px',
lineHeight: '1.6',
margin: '0 0 16px',
};
const credentialsBox = {
backgroundColor: '#f9fafb',
border: '1px solid #e5e7eb',
borderRadius: '6px',
padding: '20px',
margin: '20px 0',
};
const credentialLabel = {
color: '#6b7280',
fontSize: '14px',
fontWeight: '600',
margin: '12px 0 4px',
};
const credentialValue = {
backgroundColor: '#ffffff',
border: '1px solid #d1d5db',
borderRadius: '4px',
color: '#111827',
fontSize: '14px',
fontFamily: 'monospace',
padding: '8px 12px',
display: 'block',
margin: '0 0 8px',
};
const instructionsBox = {
backgroundColor: '#eff6ff',
border: '1px solid #bfdbfe',
borderRadius: '6px',
padding: '20px',
margin: '20px 0',
};
const instructionsTitle = {
color: '#1e40af',
fontSize: '16px',
fontWeight: '600',
margin: '0 0 12px',
};
const instructionStep = {
color: '#1e3a8a',
fontSize: '14px',
lineHeight: '1.8',
margin: '4px 0',
fontWeight: '500',
};
const instructionDetail = {
color: '#3730a3',
fontSize: '13px',
lineHeight: '1.6',
margin: '2px 0',
fontFamily: 'monospace',
};
const warningText = {
backgroundColor: '#fef3c7',
border: '1px solid #fbbf24',
borderRadius: '6px',
color: '#92400e',
fontSize: '14px',
lineHeight: '1.6',
margin: '20px 0',
padding: '12px 16px',
};

105
emails/password-reset.tsx Normal file
View File

@@ -0,0 +1,105 @@
import { Button, Link, Section, Text } from '@react-email/components';
import * as React from 'react';
import { EmailLayout } from './components/email-layout';
import { EmailHeader } from './components/email-header';
import { EmailFooter } from './components/email-footer';
interface PasswordResetEmailProps {
username: string;
resetUrl: string;
expiresIn?: string;
}
export const PasswordResetEmail = ({
username = 'user',
resetUrl = 'http://localhost:3000/reset-password?token=xxx',
expiresIn = '1 hour',
}: PasswordResetEmailProps) => {
return (
<EmailLayout preview="Password Reset Request">
<EmailHeader title="Password Reset" />
<Section style={content}>
<Text style={paragraph}>Hi {username},</Text>
<Text style={paragraph}>
We received a request to reset your password for your Location Tracker account.
</Text>
<Text style={paragraph}>
Click the button below to reset your password:
</Text>
<Button style={button} href={resetUrl}>
Reset Password
</Button>
<Text style={paragraph}>
Or copy and paste this URL into your browser:{' '}
<Link href={resetUrl} style={link}>
{resetUrl}
</Link>
</Text>
<Text style={warningText}>
This link will expire in {expiresIn}. If you didn't request this password reset, please ignore this email or contact your administrator if you have concerns.
</Text>
<Text style={paragraph}>
For security reasons, this password reset link can only be used once.
</Text>
<Text style={paragraph}>
Best regards,
<br />
Location Tracker Team
</Text>
</Section>
<EmailFooter />
</EmailLayout>
);
};
export default PasswordResetEmail;
const content = {
padding: '20px 40px',
};
const paragraph = {
color: '#374151',
fontSize: '16px',
lineHeight: '1.6',
margin: '0 0 16px',
};
const button = {
backgroundColor: '#dc2626',
borderRadius: '6px',
color: '#fff',
display: 'inline-block',
fontSize: '16px',
fontWeight: '600',
lineHeight: '1',
padding: '12px 24px',
textDecoration: 'none',
textAlign: 'center' as const,
margin: '20px 0',
};
const link = {
color: '#2563eb',
textDecoration: 'underline',
};
const warningText = {
backgroundColor: '#fef3c7',
border: '1px solid #fbbf24',
borderRadius: '6px',
color: '#92400e',
fontSize: '14px',
lineHeight: '1.6',
margin: '20px 0',
padding: '12px 16px',
};

106
emails/welcome.tsx Normal file
View File

@@ -0,0 +1,106 @@
import { Button, Link, Section, Text } from '@react-email/components';
import * as React from 'react';
import { EmailLayout } from './components/email-layout';
import { EmailHeader } from './components/email-header';
import { EmailFooter } from './components/email-footer';
interface WelcomeEmailProps {
username: string;
loginUrl: string;
temporaryPassword?: string;
}
export const WelcomeEmail = ({
username = 'user',
loginUrl = 'http://localhost:3000/login',
temporaryPassword,
}: WelcomeEmailProps) => {
return (
<EmailLayout preview="Welcome to Location Tracker">
<EmailHeader title="Welcome!" />
<Section style={content}>
<Text style={paragraph}>Hi {username},</Text>
<Text style={paragraph}>
Welcome to Location Tracker! Your account has been created and you can now access the system.
</Text>
{temporaryPassword && (
<>
<Text style={paragraph}>
Your temporary password is: <strong style={code}>{temporaryPassword}</strong>
</Text>
<Text style={paragraph}>
Please change this password after your first login for security.
</Text>
</>
)}
<Button style={button} href={loginUrl}>
Login to Location Tracker
</Button>
<Text style={paragraph}>
Or copy and paste this URL into your browser:{' '}
<Link href={loginUrl} style={link}>
{loginUrl}
</Link>
</Text>
<Text style={paragraph}>
If you have any questions, please contact your administrator.
</Text>
<Text style={paragraph}>
Best regards,
<br />
Location Tracker Team
</Text>
</Section>
<EmailFooter />
</EmailLayout>
);
};
export default WelcomeEmail;
const content = {
padding: '20px 40px',
};
const paragraph = {
color: '#374151',
fontSize: '16px',
lineHeight: '1.6',
margin: '0 0 16px',
};
const button = {
backgroundColor: '#2563eb',
borderRadius: '6px',
color: '#fff',
display: 'inline-block',
fontSize: '16px',
fontWeight: '600',
lineHeight: '1',
padding: '12px 24px',
textDecoration: 'none',
textAlign: 'center' as const,
margin: '20px 0',
};
const link = {
color: '#2563eb',
textDecoration: 'underline',
};
const code = {
backgroundColor: '#f3f4f6',
borderRadius: '4px',
color: '#1f2937',
fontFamily: 'monospace',
fontSize: '14px',
padding: '2px 6px',
};

9
instrumentation.ts Normal file
View File

@@ -0,0 +1,9 @@
// Next.js Instrumentation Hook
// Wird beim Server-Start einmalig ausgeführt
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { initializeServices } = await import('./lib/startup');
initializeServices();
}
}

92
lib/auth.ts Normal file
View File

@@ -0,0 +1,92 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import Database from "better-sqlite3";
import path from "path";
// SQLite database connection
const dbPath = path.join(process.cwd(), 'data', 'database.sqlite');
function getDb() {
return new Database(dbPath, { readonly: true });
}
export const { handlers, signIn, signOut, auth } = NextAuth({
secret: process.env.AUTH_SECRET || "fallback-secret-for-development",
trustHost: true, // Allow any host (development & production)
providers: [
Credentials({
credentials: {
username: { label: "Username", type: "text" },
password: { label: "Password", type: "password" },
},
authorize: async (credentials) => {
if (!credentials?.username || !credentials?.password) {
return null;
}
try {
const db = getDb();
// Query user from database
const user = db.prepare('SELECT * FROM User WHERE username = ?')
.get(credentials.username as string) as any;
db.close();
if (!user) {
return null;
}
// Verify password
const isValid = await bcrypt.compare(
credentials.password as string,
user.passwordHash
);
if (!isValid) {
return null;
}
// Update last login
const updateDb = new Database(dbPath);
updateDb.prepare('UPDATE User SET lastLoginAt = datetime(\'now\') WHERE id = ?')
.run(user.id);
updateDb.close();
return {
id: user.id,
name: user.username,
email: user.email || undefined,
role: user.role,
};
} catch (error) {
console.error('Auth error:', error);
return null;
}
},
}),
],
pages: {
signIn: "/login",
},
callbacks: {
authorized: async ({ auth }) => {
return !!auth;
},
jwt: async ({ token, user }) => {
if (user) {
token.id = user.id;
token.role = (user as any).role;
}
return token;
},
session: async ({ session, token }) => {
if (session.user) {
(session.user as any).id = token.id;
(session.user as any).role = token.role;
}
return session;
},
},
});

83
lib/crypto-utils.ts Normal file
View File

@@ -0,0 +1,83 @@
/**
* Encryption utilities for sensitive data
* Uses AES-256-GCM for encryption
*/
import * as crypto from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
/**
* Get encryption key from environment
*/
function getEncryptionKey(): Buffer {
const key = process.env.ENCRYPTION_KEY;
if (!key || key.length !== 64) {
throw new Error('ENCRYPTION_KEY must be a 32-byte hex string (64 characters)');
}
return Buffer.from(key, 'hex');
}
/**
* Encrypt text using AES-256-GCM
* Returns base64 encoded string with format: iv:authTag:encrypted
*/
export function encrypt(text: string): string {
if (!text || text.trim().length === 0) {
throw new Error('Text to encrypt cannot be empty or null');
}
try {
const key = getEncryptionKey();
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(text, 'utf8', 'base64');
encrypted += cipher.final('base64');
const authTag = cipher.getAuthTag();
// Combine iv, authTag, and encrypted data
return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`;
} catch (error) {
console.error('[Crypto] Encryption failed:', error);
throw new Error('Failed to encrypt data');
}
}
/**
* Decrypt text encrypted with encrypt()
* Expects base64 string with format: iv:authTag:encrypted
*/
export function decrypt(encryptedText: string): string {
try {
const key = getEncryptionKey();
const parts = encryptedText.split(':');
if (parts.length !== 3) {
throw new Error('Invalid encrypted text format');
}
const iv = Buffer.from(parts[0], 'base64');
const authTag = Buffer.from(parts[1], 'base64');
const encrypted = parts[2];
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, 'base64', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error) {
console.error('[Crypto] Decryption failed:', error);
throw new Error('Failed to decrypt data');
}
}
/**
* Generate a random encryption key (32 bytes as hex string)
*/
export function generateEncryptionKey(): string {
return crypto.randomBytes(32).toString('hex');
}

532
lib/db.ts Normal file
View File

@@ -0,0 +1,532 @@
// Database helper for SQLite operations
import Database from "better-sqlite3";
import path from "path";
const dbPath = path.join(process.cwd(), 'data', 'database.sqlite');
const locationsDbPath = path.join(process.cwd(), 'data', 'locations.sqlite');
export function getDb() {
const db = new Database(dbPath);
// Enable WAL mode for better concurrency
db.pragma('journal_mode = WAL');
return db;
}
export function getLocationsDb() {
const db = new Database(locationsDbPath);
// Enable WAL mode for better concurrency
db.pragma('journal_mode = WAL');
return db;
}
// Device operations
export interface Device {
id: string;
name: string;
color: string;
ownerId: string | null;
isActive: number;
createdAt: string;
updatedAt: string;
description: string | null;
icon: string | null;
}
export const deviceDb = {
findAll: (options?: { userId?: string }): Device[] => {
const db = getDb();
let query = 'SELECT * FROM Device WHERE isActive = 1';
const params: any[] = [];
if (options?.userId) {
query += ' AND ownerId = ?';
params.push(options.userId);
}
const devices = db.prepare(query).all(...params) as Device[];
db.close();
return devices;
},
findById: (id: string): Device | null => {
const db = getDb();
const device = db.prepare('SELECT * FROM Device WHERE id = ?').get(id) as Device | undefined;
db.close();
return device || null;
},
create: (device: { id: string; name: string; color: string; ownerId: string | null; description?: string; icon?: string }): Device => {
const db = getDb();
const stmt = db.prepare(`
INSERT INTO Device (id, name, color, ownerId, isActive, description, icon, createdAt, updatedAt)
VALUES (?, ?, ?, ?, 1, ?, ?, datetime('now'), datetime('now'))
`);
stmt.run(
device.id,
device.name,
device.color,
device.ownerId,
device.description || null,
device.icon || null
);
const created = db.prepare('SELECT * FROM Device WHERE id = ?').get(device.id) as Device;
db.close();
return created;
},
update: (id: string, data: { name?: string; color?: string; description?: string; icon?: string }): Device | null => {
const db = getDb();
const updates: string[] = [];
const values: any[] = [];
if (data.name !== undefined) {
updates.push('name = ?');
values.push(data.name);
}
if (data.color !== undefined) {
updates.push('color = ?');
values.push(data.color);
}
if (data.description !== undefined) {
updates.push('description = ?');
values.push(data.description);
}
if (data.icon !== undefined) {
updates.push('icon = ?');
values.push(data.icon);
}
if (updates.length === 0) {
db.close();
return deviceDb.findById(id);
}
updates.push('updatedAt = datetime(\'now\')');
values.push(id);
const sql = `UPDATE Device SET ${updates.join(', ')} WHERE id = ?`;
db.prepare(sql).run(...values);
const updated = db.prepare('SELECT * FROM Device WHERE id = ?').get(id) as Device | undefined;
db.close();
return updated || null;
},
delete: (id: string): boolean => {
const db = getDb();
const result = db.prepare('UPDATE Device SET isActive = 0, updatedAt = datetime(\'now\') WHERE id = ?').run(id);
db.close();
return result.changes > 0;
},
};
// User operations
export interface User {
id: string;
username: string;
email: string | null;
passwordHash: string;
role: string;
parent_user_id: string | null;
createdAt: string;
updatedAt: string;
lastLoginAt: string | null;
}
export const userDb = {
findAll: (options?: { excludeUsername?: string; parentUserId?: string }): User[] => {
const db = getDb();
let query = 'SELECT * FROM User';
const params: any[] = [];
const conditions: string[] = [];
if (options?.excludeUsername) {
conditions.push('username != ?');
params.push(options.excludeUsername);
}
if (options?.parentUserId) {
conditions.push('parent_user_id = ?');
params.push(options.parentUserId);
}
if (conditions.length > 0) {
query += ' WHERE ' + conditions.join(' AND ');
}
const users = params.length > 0
? db.prepare(query).all(...params) as User[]
: db.prepare(query).all() as User[];
db.close();
return users;
},
findById: (id: string): User | null => {
const db = getDb();
const user = db.prepare('SELECT * FROM User WHERE id = ?').get(id) as User | undefined;
db.close();
return user || null;
},
findByUsername: (username: string): User | null => {
const db = getDb();
const user = db.prepare('SELECT * FROM User WHERE username = ?').get(username) as User | undefined;
db.close();
return user || null;
},
create: (user: { id: string; username: string; email: string | null; passwordHash: string; role: string; parent_user_id?: string | null }): User => {
const db = getDb();
const stmt = db.prepare(`
INSERT INTO User (id, username, email, passwordHash, role, parent_user_id, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
`);
stmt.run(
user.id,
user.username,
user.email,
user.passwordHash,
user.role,
user.parent_user_id || null
);
const created = db.prepare('SELECT * FROM User WHERE id = ?').get(user.id) as User;
db.close();
return created;
},
update: (id: string, data: { username?: string; email?: string | null; passwordHash?: string; role?: string }): User | null => {
const db = getDb();
const updates: string[] = [];
const values: any[] = [];
if (data.username !== undefined) {
updates.push('username = ?');
values.push(data.username);
}
if (data.email !== undefined) {
updates.push('email = ?');
values.push(data.email);
}
if (data.passwordHash !== undefined) {
updates.push('passwordHash = ?');
values.push(data.passwordHash);
}
if (data.role !== undefined) {
updates.push('role = ?');
values.push(data.role);
}
if (updates.length === 0) {
db.close();
return userDb.findById(id);
}
updates.push('updatedAt = datetime(\'now\')');
values.push(id);
const sql = `UPDATE User SET ${updates.join(', ')} WHERE id = ?`;
db.prepare(sql).run(...values);
const updated = db.prepare('SELECT * FROM User WHERE id = ?').get(id) as User | undefined;
db.close();
return updated || null;
},
delete: (id: string): boolean => {
const db = getDb();
const result = db.prepare('DELETE FROM User WHERE id = ?').run(id);
db.close();
return result.changes > 0;
},
/**
* Get list of device IDs that a user is allowed to access
* @param userId - The user's ID
* @param role - The user's role (ADMIN, VIEWER)
* @param username - The user's username (for super admin check)
* @returns Array of device IDs the user can access
*/
getAllowedDeviceIds: (userId: string, role: string, username: string): string[] => {
const db = getDb();
try {
// Super admin (username === "admin") can see ALL devices
if (username === 'admin') {
const allDevices = db.prepare('SELECT id FROM Device WHERE isActive = 1').all() as { id: string }[];
return allDevices.map(d => d.id);
}
// VIEWER users see their parent user's devices
if (role === 'VIEWER') {
const user = db.prepare('SELECT parent_user_id FROM User WHERE id = ?').get(userId) as { parent_user_id: string | null } | undefined;
if (user?.parent_user_id) {
const devices = db.prepare('SELECT id FROM Device WHERE ownerId = ? AND isActive = 1').all(user.parent_user_id) as { id: string }[];
return devices.map(d => d.id);
}
// If VIEWER has no parent, return empty array
return [];
}
// Regular ADMIN users see only their own devices
if (role === 'ADMIN') {
const devices = db.prepare('SELECT id FROM Device WHERE ownerId = ? AND isActive = 1').all(userId) as { id: string }[];
return devices.map(d => d.id);
}
// Default: no access
return [];
} finally {
db.close();
}
},
};
// Location operations (separate database for tracking data)
export interface Location {
id?: number;
latitude: number;
longitude: number;
timestamp: string;
user_id: number;
first_name: string | null;
last_name: string | null;
username: string | null;
marker_label: string | null;
display_time: string | null;
chat_id: number;
battery: number | null;
speed: number | null;
created_at?: string;
}
export interface LocationFilters {
username?: string;
user_id?: number;
timeRangeHours?: number;
startTime?: string; // ISO string for custom range start
endTime?: string; // ISO string for custom range end
limit?: number;
offset?: number;
}
export const locationDb = {
/**
* Insert a new location record (ignores duplicates)
*/
create: (location: Location): Location | null => {
const db = getLocationsDb();
const stmt = db.prepare(`
INSERT OR IGNORE INTO Location (
latitude, longitude, timestamp, user_id,
first_name, last_name, username, marker_label,
display_time, chat_id, battery, speed
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
location.latitude,
location.longitude,
location.timestamp,
location.user_id || 0,
location.first_name || null,
location.last_name || null,
location.username || null,
location.marker_label || null,
location.display_time || null,
location.chat_id || 0,
location.battery !== undefined && location.battery !== null ? Number(location.battery) : null,
location.speed !== undefined && location.speed !== null ? Number(location.speed) : null
);
// If changes is 0, it was a duplicate and ignored
if (result.changes === 0) {
db.close();
return null;
}
const created = db.prepare('SELECT * FROM Location WHERE id = ?').get(result.lastInsertRowid) as Location;
db.close();
return created;
},
/**
* Bulk insert multiple locations (ignores duplicates, returns count of actually inserted)
*/
createMany: (locations: Location[]): number => {
if (locations.length === 0) return 0;
const db = getLocationsDb();
const stmt = db.prepare(`
INSERT OR IGNORE INTO Location (
latitude, longitude, timestamp, user_id,
first_name, last_name, username, marker_label,
display_time, chat_id, battery, speed
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
let insertedCount = 0;
const insertMany = db.transaction((locations: Location[]) => {
for (const loc of locations) {
const batteryValue = loc.battery !== undefined && loc.battery !== null ? Number(loc.battery) : null;
const speedValue = loc.speed !== undefined && loc.speed !== null ? Number(loc.speed) : null;
// Debug log
console.log('[DB Insert Debug]', {
username: loc.username,
speed_in: loc.speed,
speed_out: speedValue,
battery_in: loc.battery,
battery_out: batteryValue
});
const result = stmt.run(
loc.latitude,
loc.longitude,
loc.timestamp,
loc.user_id || 0,
loc.first_name || null,
loc.last_name || null,
loc.username || null,
loc.marker_label || null,
loc.display_time || null,
loc.chat_id || 0,
batteryValue,
speedValue
);
insertedCount += result.changes;
}
});
insertMany(locations);
db.close();
return insertedCount;
},
/**
* Find locations with filters
*/
findMany: (filters: LocationFilters = {}): Location[] => {
const db = getLocationsDb();
const conditions: string[] = [];
const params: any[] = [];
// Filter by user_id (typically 0 for MQTT devices)
if (filters.user_id !== undefined) {
conditions.push('user_id = ?');
params.push(filters.user_id);
}
// Filter by username (device tracker ID)
if (filters.username) {
conditions.push('username = ?');
params.push(filters.username);
}
// Filter by time range - either custom range or quick filter
if (filters.startTime && filters.endTime) {
// Custom range: between startTime and endTime
conditions.push('timestamp BETWEEN ? AND ?');
params.push(filters.startTime, filters.endTime);
} else if (filters.timeRangeHours) {
// Quick filter: calculate cutoff in JavaScript for accuracy
const cutoffTime = new Date(Date.now() - filters.timeRangeHours * 60 * 60 * 1000).toISOString();
conditions.push('timestamp >= ?');
params.push(cutoffTime);
}
const whereClause = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '';
const limit = filters.limit || 1000;
const offset = filters.offset || 0;
const sql = `
SELECT * FROM Location
${whereClause}
ORDER BY timestamp DESC
LIMIT ? OFFSET ?
`;
params.push(limit, offset);
const locations = db.prepare(sql).all(...params) as Location[];
db.close();
return locations;
},
/**
* Get count of locations matching filters
*/
count: (filters: LocationFilters = {}): number => {
const db = getLocationsDb();
const conditions: string[] = [];
const params: any[] = [];
if (filters.user_id !== undefined) {
conditions.push('user_id = ?');
params.push(filters.user_id);
}
if (filters.username) {
conditions.push('username = ?');
params.push(filters.username);
}
// Filter by time range - either custom range or quick filter
if (filters.startTime && filters.endTime) {
// Custom range: between startTime and endTime
conditions.push('timestamp BETWEEN ? AND ?');
params.push(filters.startTime, filters.endTime);
} else if (filters.timeRangeHours) {
// Quick filter: calculate cutoff in JavaScript for accuracy
const cutoffTime = new Date(Date.now() - filters.timeRangeHours * 60 * 60 * 1000).toISOString();
conditions.push('timestamp >= ?');
params.push(cutoffTime);
}
const whereClause = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '';
const sql = `SELECT COUNT(*) as count FROM Location ${whereClause}`;
const result = db.prepare(sql).get(...params) as { count: number };
db.close();
return result.count;
},
/**
* Delete locations older than specified hours
* Returns number of deleted records
*/
deleteOlderThan: (hours: number): number => {
const db = getLocationsDb();
const result = db.prepare(`
DELETE FROM Location
WHERE timestamp < datetime('now', '-' || ? || ' hours')
`).run(hours);
db.close();
return result.changes;
},
/**
* Get database stats
*/
getStats: (): { total: number; oldest: string | null; newest: string | null; sizeKB: number } => {
const db = getLocationsDb();
const countResult = db.prepare('SELECT COUNT(*) as total FROM Location').get() as { total: number };
const timeResult = db.prepare('SELECT MIN(timestamp) as oldest, MAX(timestamp) as newest FROM Location').get() as { oldest: string | null; newest: string | null };
const sizeResult = db.prepare("SELECT page_count * page_size / 1024 as sizeKB FROM pragma_page_count(), pragma_page_size()").get() as { sizeKB: number };
db.close();
return {
total: countResult.total,
oldest: timeResult.oldest,
newest: timeResult.newest,
sizeKB: Math.round(sizeResult.sizeKB)
};
},
};

96
lib/demo-data.ts Normal file
View File

@@ -0,0 +1,96 @@
/**
* Demo GPS data for landing page showcase
* 3 devices moving through Munich with realistic routes
*/
export interface DemoLocation {
lat: number;
lng: number;
timestamp: string;
}
export interface DemoDevice {
id: string;
name: string;
color: string;
route: DemoLocation[];
}
// Device 1: Route through Munich city center (Marienplatz → Odeonsplatz → Englischer Garten)
const route1: [number, number][] = [
[48.1374, 11.5755], // Marienplatz
[48.1388, 11.5764], // Kaufingerstraße
[48.1402, 11.5775], // Stachus
[48.1425, 11.5788], // Lenbachplatz
[48.1448, 11.5798], // Odeonsplatz
[48.1472, 11.5810], // Ludwigstraße
[48.1495, 11.5823], // Siegestor
[48.1520, 11.5840], // Englischer Garten Süd
[48.1545, 11.5858], // Eisbach
[48.1570, 11.5880], // Kleinhesseloher See
];
// Device 2: Route to Olympiapark (Hauptbahnhof → Olympiapark)
const route2: [number, number][] = [
[48.1408, 11.5581], // Hauptbahnhof
[48.1435, 11.5545], // Karlstraße
[48.1465, 11.5510], // Brienner Straße
[48.1495, 11.5475], // Königsplatz
[48.1530, 11.5445], // Josephsplatz
[48.1565, 11.5420], // Nymphenburg
[48.1600, 11.5450], // Olympiapark Süd
[48.1635, 11.5480], // Olympiaturm
[48.1665, 11.5510], // Olympiastadion
[48.1690, 11.5540], // BMW Welt
];
// Device 3: Route along Isar river (Deutsches Museum → Flaucher)
const route3: [number, number][] = [
[48.1300, 11.5835], // Deutsches Museum
[48.1275, 11.5850], // Ludwigsbrücke
[48.1250, 11.5865], // Muffathalle
[48.1225, 11.5880], // Wittelsbacherbrücke
[48.1200, 11.5895], // Gärtnerplatz
[48.1175, 11.5910], // Reichenbachbrücke
[48.1150, 11.5925], // Isartor
[48.1125, 11.5940], // Flaucher Nord
[48.1100, 11.5955], // Flaucher
[48.1075, 11.5970], // Tierpark Hellabrunn
];
export const DEMO_DEVICES: DemoDevice[] = [
{
id: 'demo-1',
name: 'City Tour',
color: '#3b82f6', // Blue
route: route1.map((coords, idx) => ({
lat: coords[0],
lng: coords[1],
timestamp: new Date(Date.now() + idx * 1000).toISOString(),
})),
},
{
id: 'demo-2',
name: 'Olympiapark Route',
color: '#10b981', // Green
route: route2.map((coords, idx) => ({
lat: coords[0],
lng: coords[1],
timestamp: new Date(Date.now() + idx * 1000).toISOString(),
})),
},
{
id: 'demo-3',
name: 'Isar Tour',
color: '#f59e0b', // Orange
route: route3.map((coords, idx) => ({
lat: coords[0],
lng: coords[1],
timestamp: new Date(Date.now() + idx * 1000).toISOString(),
})),
},
];
// Calculate center of all routes for initial map view
export const DEMO_MAP_CENTER: [number, number] = [48.1485, 11.5680]; // Munich center
export const DEMO_MAP_ZOOM = 12;

16
lib/devices.ts Normal file
View File

@@ -0,0 +1,16 @@
import { Device } from '@/types/location';
export const DEVICES: Record<string, Device> = {
'10': { id: '10', name: 'Device A', color: '#e74c3c' },
'11': { id: '11', name: 'Device B', color: '#3498db' },
};
export const DEFAULT_DEVICE: Device = {
id: 'unknown',
name: 'Unknown Device',
color: '#95a5a6',
};
export function getDevice(id: string): Device {
return DEVICES[id] || DEFAULT_DEVICE;
}

57
lib/email-renderer.ts Normal file
View File

@@ -0,0 +1,57 @@
/**
* Renders React Email templates to HTML
*/
import { render } from '@react-email/components';
import WelcomeEmail from '@/emails/welcome';
import PasswordResetEmail from '@/emails/password-reset';
import MqttCredentialsEmail from '@/emails/mqtt-credentials';
export interface WelcomeEmailData {
username: string;
loginUrl: string;
temporaryPassword?: string;
}
export interface PasswordResetEmailData {
username: string;
resetUrl: string;
expiresIn?: string;
}
export interface MqttCredentialsEmailData {
deviceName: string;
deviceId: string;
mqttUsername: string;
mqttPassword: string;
brokerUrl: string;
brokerHost?: string;
brokerPort?: string;
}
export async function renderWelcomeEmail(data: WelcomeEmailData): Promise<string> {
return render(WelcomeEmail(data));
}
export async function renderPasswordResetEmail(data: PasswordResetEmailData): Promise<string> {
return render(PasswordResetEmail(data));
}
export async function renderMqttCredentialsEmail(data: MqttCredentialsEmailData): Promise<string> {
return render(MqttCredentialsEmail(data));
}
export async function renderEmailTemplate(
template: string,
data: any
): Promise<string> {
switch (template) {
case 'welcome':
return renderWelcomeEmail(data);
case 'password-reset':
return renderPasswordResetEmail(data);
case 'mqtt-credentials':
return renderMqttCredentialsEmail(data);
default:
throw new Error(`Unknown email template: ${template}`);
}
}

227
lib/email-service.ts Normal file
View File

@@ -0,0 +1,227 @@
/**
* Email service for sending emails via SMTP
* Supports hybrid configuration (DB + .env fallback)
*/
import nodemailer, { Transporter } from 'nodemailer';
import { SMTPConfig } from './types/smtp';
import { settingsDb } from './settings-db';
import {
renderWelcomeEmail,
renderPasswordResetEmail,
renderMqttCredentialsEmail,
WelcomeEmailData,
PasswordResetEmailData,
MqttCredentialsEmailData,
} from './email-renderer';
export class EmailService {
/**
* Cached SMTP transporter instance.
* Set to null initially and reused for subsequent emails to avoid reconnecting.
* Call resetTransporter() when SMTP configuration changes to invalidate cache.
*/
private transporter: Transporter | null = null;
/**
* Get SMTP configuration (DB first, then .env fallback)
*/
private async getConfig(): Promise<SMTPConfig> {
// Try database first
const dbConfig = settingsDb.getSMTPConfig();
if (dbConfig) {
console.log('[EmailService] Using SMTP config from database');
return dbConfig;
}
// Fallback to environment variables
console.log('[EmailService] Using SMTP config from environment');
const envConfig: SMTPConfig = {
host: process.env.SMTP_HOST || '',
port: parseInt(process.env.SMTP_PORT || '587', 10),
secure: process.env.SMTP_SECURE === 'true',
auth: {
user: process.env.SMTP_USER || '',
pass: process.env.SMTP_PASS || '',
},
from: {
email: process.env.SMTP_FROM_EMAIL || '',
name: process.env.SMTP_FROM_NAME || 'Location Tracker',
},
timeout: 10000,
};
// Validate env config
if (!envConfig.host || !envConfig.auth.user || !envConfig.auth.pass) {
throw new Error('SMTP configuration is incomplete. Please configure SMTP settings in admin panel or .env file.');
}
return envConfig;
}
/**
* Create and configure nodemailer transporter
*/
private async getTransporter(): Promise<Transporter> {
if (this.transporter) {
return this.transporter;
}
const config = await this.getConfig();
this.transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: {
user: config.auth.user,
pass: config.auth.pass,
},
connectionTimeout: config.timeout || 10000,
});
return this.transporter;
}
/**
* Send an email
*/
private async sendEmail(
to: string,
subject: string,
html: string
): Promise<void> {
try {
const config = await this.getConfig();
const transporter = await this.getTransporter();
const info = await transporter.sendMail({
from: `"${config.from.name}" <${config.from.email}>`,
to,
subject,
html,
replyTo: config.replyTo,
});
console.log('[EmailService] Email sent:', {
messageId: info.messageId,
to,
subject,
});
} catch (error) {
console.error('[EmailService] Failed to send email:', error);
throw new Error(`Failed to send email: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Send welcome email to new user
*/
async sendWelcomeEmail(data: WelcomeEmailData & { email: string }): Promise<void> {
const html = await renderWelcomeEmail({
username: data.username,
loginUrl: data.loginUrl,
temporaryPassword: data.temporaryPassword,
});
await this.sendEmail(
data.email,
'Welcome to Location Tracker',
html
);
}
/**
* Send password reset email
*/
async sendPasswordResetEmail(data: PasswordResetEmailData & { email: string }): Promise<void> {
const html = await renderPasswordResetEmail({
username: data.username,
resetUrl: data.resetUrl,
expiresIn: data.expiresIn || '1 hour',
});
await this.sendEmail(
data.email,
'Password Reset Request - Location Tracker',
html
);
}
/**
* Send MQTT credentials email
*/
async sendMqttCredentialsEmail(data: MqttCredentialsEmailData & { email: string }): Promise<void> {
const html = await renderMqttCredentialsEmail({
deviceName: data.deviceName,
deviceId: data.deviceId,
mqttUsername: data.mqttUsername,
mqttPassword: data.mqttPassword,
brokerUrl: data.brokerUrl,
brokerHost: data.brokerHost,
brokerPort: data.brokerPort,
});
await this.sendEmail(
data.email,
`MQTT Credentials - ${data.deviceName}`,
html
);
}
/**
* Test SMTP connection
* @throws Error with detailed message if connection fails
*/
async testConnection(config?: SMTPConfig): Promise<boolean> {
try {
let transporter: Transporter;
if (config) {
// Test provided config
transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: {
user: config.auth.user,
pass: config.auth.pass,
},
connectionTimeout: config.timeout || 10000,
});
} else {
// Test current config
transporter = await this.getTransporter();
}
await transporter.verify();
console.log('[EmailService] SMTP connection test successful');
return true;
} catch (error: any) {
console.error('[EmailService] SMTP connection test failed:', error);
// Provide more helpful error messages
if (error.code === 'EAUTH') {
throw new Error(
'Authentication failed. For Gmail, use an App Password (not your regular password). ' +
'Enable 2FA and generate an App Password at: https://myaccount.google.com/apppasswords'
);
} else if (error.code === 'ETIMEDOUT' || error.code === 'ECONNECTION') {
throw new Error('Connection timeout. Check your host, port, and firewall settings.');
} else if (error.code === 'ESOCKET') {
throw new Error('Connection failed. Verify your SMTP host and port are correct.');
} else {
throw new Error(error.message || 'SMTP connection test failed');
}
}
}
/**
* Reset the cached transporter (call when SMTP config changes)
*/
resetTransporter(): void {
this.transporter = null;
}
}
// Export singleton instance
export const emailService = new EmailService();

239
lib/mosquitto-sync.ts Normal file
View File

@@ -0,0 +1,239 @@
// Mosquitto configuration synchronization service
import { exec } from 'child_process';
import { promisify } from 'util';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as crypto from 'crypto';
import { mqttCredentialDb, mqttAclRuleDb, mqttSyncStatusDb } from './mqtt-db';
const execPromise = promisify(exec);
// Konfiguration aus Environment Variablen
const PASSWORD_FILE = process.env.MOSQUITTO_PASSWORD_FILE || '/mosquitto/config/password.txt';
const ACL_FILE = process.env.MOSQUITTO_ACL_FILE || '/mosquitto/config/acl.txt';
const MOSQUITTO_CONTAINER = process.env.MOSQUITTO_CONTAINER_NAME || 'mosquitto';
const ADMIN_USERNAME = process.env.MOSQUITTO_ADMIN_USERNAME || 'admin';
const ADMIN_PASSWORD = process.env.MOSQUITTO_ADMIN_PASSWORD || 'admin';
/**
* Hash ein Passwort im Mosquitto-kompatiblen Format (PBKDF2-SHA512)
* Format: $7$<iterations>$<base64_salt>$<base64_hash>
*
* Mosquitto verwendet PBKDF2 mit SHA-512, 101 Iterationen (Standard),
* 12-Byte Salt und 64-Byte Hash
*/
async function hashPassword(password: string): Promise<string> {
try {
// Mosquitto Standard-Parameter
const iterations = 101;
const saltLength = 12;
const hashLength = 64;
// Generiere zufälligen Salt
const salt = crypto.randomBytes(saltLength);
// PBKDF2 mit SHA-512
const hash = await new Promise<Buffer>((resolve, reject) => {
crypto.pbkdf2(password, salt, iterations, hashLength, 'sha512', (err, derivedKey) => {
if (err) reject(err);
else resolve(derivedKey);
});
});
// Base64-Kodierung (Standard Base64, nicht URL-safe)
const saltBase64 = salt.toString('base64');
const hashBase64 = hash.toString('base64');
// Mosquitto Format: $7$iterations$salt$hash
// $7$ = PBKDF2-SHA512 Identifier
const mosquittoHash = `$7$${iterations}$${saltBase64}$${hashBase64}`;
return mosquittoHash;
} catch (error) {
console.error('Failed to hash password:', error);
throw new Error('Password hashing failed');
}
}
/**
* Generiere Mosquitto Password File Entry
*/
async function generatePasswordEntry(username: string, password: string): Promise<string> {
const hash = await hashPassword(password);
return `${username}:${hash}`;
}
/**
* Generiere die Mosquitto Password Datei
*/
async function generatePasswordFile(): Promise<string> {
let content = '';
// Füge Admin User hinzu
const adminEntry = await generatePasswordEntry(ADMIN_USERNAME, ADMIN_PASSWORD);
content += `# Admin user\n${adminEntry}\n\n`;
// Füge Device Credentials hinzu
const credentials = mqttCredentialDb.findAllActive();
if (credentials.length > 0) {
content += '# Provisioned devices\n';
for (const cred of credentials) {
content += `${cred.mqtt_username}:${cred.mqtt_password_hash}\n`;
}
}
return content;
}
/**
* Generiere die Mosquitto ACL Datei
*/
function generateACLFile(): string {
let content = '';
// Füge Admin ACL hinzu
content += `# Admin user - full access\n`;
content += `user ${ADMIN_USERNAME}\n`;
content += `topic readwrite #\n\n`;
// Füge Device ACLs hinzu
const rules = mqttAclRuleDb.findAll();
if (rules.length > 0) {
content += '# Device permissions\n';
// Gruppiere Regeln nach device_id
const rulesByDevice = rules.reduce((acc, rule) => {
if (!acc[rule.device_id]) {
acc[rule.device_id] = [];
}
acc[rule.device_id].push(rule);
return acc;
}, {} as Record<string, typeof rules>);
// Schreibe ACL Regeln pro Device
for (const [deviceId, deviceRules] of Object.entries(rulesByDevice)) {
const credential = mqttCredentialDb.findByDeviceId(deviceId);
if (!credential) continue;
content += `# Device: ${deviceId}\n`;
content += `user ${credential.mqtt_username}\n`;
for (const rule of deviceRules) {
content += `topic ${rule.permission} ${rule.topic_pattern}\n`;
}
content += '\n';
}
}
return content;
}
/**
* Schreibe Password File
*/
async function writePasswordFile(content: string): Promise<void> {
const configDir = path.dirname(PASSWORD_FILE);
// Stelle sicher dass das Config-Verzeichnis existiert
await fs.mkdir(configDir, { recursive: true });
// Schreibe Datei mit sicheren Permissions (nur owner kann lesen/schreiben)
await fs.writeFile(PASSWORD_FILE, content, { mode: 0o600 });
console.log(`✓ Password file written: ${PASSWORD_FILE}`);
}
/**
* Schreibe ACL File
*/
async function writeACLFile(content: string): Promise<void> {
const configDir = path.dirname(ACL_FILE);
// Stelle sicher dass das Config-Verzeichnis existiert
await fs.mkdir(configDir, { recursive: true });
// Schreibe Datei mit sicheren Permissions
await fs.writeFile(ACL_FILE, content, { mode: 0o600 });
console.log(`✓ ACL file written: ${ACL_FILE}`);
}
/**
* Reload Mosquitto Konfiguration
* Sendet SIGHUP an Mosquitto Container
*/
async function reloadMosquitto(): Promise<boolean> {
try {
// Sende SIGHUP an mosquitto container um config zu reloaden
await execPromise(`docker exec ${MOSQUITTO_CONTAINER} kill -HUP 1`);
console.log('✓ Mosquitto configuration reloaded');
return true;
} catch (error) {
console.log('⚠ Could not reload Mosquitto automatically (requires docker socket permissions)');
console.log('→ Changes saved to config files - restart Mosquitto to apply: docker-compose restart mosquitto');
// Werfe keinen Fehler - Config-Dateien sind aktualisiert, werden beim nächsten Restart geladen
return false;
}
}
/**
* Sync alle MQTT Konfigurationen nach Mosquitto
*/
export async function syncMosquittoConfig(): Promise<{
success: boolean;
message: string;
reloaded: boolean;
}> {
try {
console.log('Starting Mosquitto sync...');
// Generiere Password File
const passwordContent = await generatePasswordFile();
await writePasswordFile(passwordContent);
// Generiere ACL File
const aclContent = generateACLFile();
await writeACLFile(aclContent);
// Versuche Mosquitto zu reloaden
const reloaded = await reloadMosquitto();
// Markiere als synced
mqttSyncStatusDb.markSynced();
return {
success: true,
message: reloaded
? 'Mosquitto configuration synced and reloaded successfully'
: 'Mosquitto configuration synced. Restart Mosquitto to apply changes.',
reloaded
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('Failed to sync Mosquitto config:', error);
// Markiere Sync als fehlgeschlagen
mqttSyncStatusDb.markSyncFailed(errorMessage);
return {
success: false,
message: `Failed to sync Mosquitto configuration: ${errorMessage}`,
reloaded: false
};
}
}
/**
* Hole Mosquitto Sync Status
*/
export function getMosquittoSyncStatus() {
return mqttSyncStatusDb.get();
}
/**
* Hash ein Passwort für MQTT Credentials
* Exportiere dies damit es in API Routes verwendet werden kann
*/
export { hashPassword };

361
lib/mqtt-db.ts Normal file
View File

@@ -0,0 +1,361 @@
// MQTT credentials and ACL database operations
import { getDb } from './db';
export interface MqttCredential {
id: number;
device_id: string;
mqtt_username: string;
mqtt_password_hash: string;
enabled: number;
created_at: string;
updated_at: string;
}
export interface MqttAclRule {
id: number;
device_id: string;
topic_pattern: string;
permission: 'read' | 'write' | 'readwrite';
created_at: string;
}
export interface MqttSyncStatus {
id: number;
pending_changes: number;
last_sync_at: string | null;
last_sync_status: string;
created_at: string;
updated_at: string;
}
export const mqttCredentialDb = {
/**
* Finde alle MQTT Credentials
*/
findAll: (): MqttCredential[] => {
const db = getDb();
const credentials = db.prepare('SELECT * FROM mqtt_credentials').all() as MqttCredential[];
db.close();
return credentials;
},
/**
* Finde MQTT Credential für ein Device
*/
findByDeviceId: (deviceId: string): MqttCredential | null => {
const db = getDb();
const credential = db.prepare('SELECT * FROM mqtt_credentials WHERE device_id = ?')
.get(deviceId) as MqttCredential | undefined;
db.close();
return credential || null;
},
/**
* Finde MQTT Credential nach Username
*/
findByUsername: (username: string): MqttCredential | null => {
const db = getDb();
const credential = db.prepare('SELECT * FROM mqtt_credentials WHERE mqtt_username = ?')
.get(username) as MqttCredential | undefined;
db.close();
return credential || null;
},
/**
* Erstelle neue MQTT Credentials für ein Device
*/
create: (data: {
device_id: string;
mqtt_username: string;
mqtt_password_hash: string;
enabled?: number;
}): MqttCredential => {
const db = getDb();
const stmt = db.prepare(`
INSERT INTO mqtt_credentials (device_id, mqtt_username, mqtt_password_hash, enabled)
VALUES (?, ?, ?, ?)
`);
const result = stmt.run(
data.device_id,
data.mqtt_username,
data.mqtt_password_hash,
data.enabled ?? 1
);
const created = db.prepare('SELECT * FROM mqtt_credentials WHERE id = ?')
.get(result.lastInsertRowid) as MqttCredential;
// Mark pending changes
mqttSyncStatusDb.markPendingChanges();
db.close();
return created;
},
/**
* Aktualisiere MQTT Credentials
*/
update: (deviceId: string, data: {
mqtt_password_hash?: string;
enabled?: number;
}): MqttCredential | null => {
const db = getDb();
const updates: string[] = [];
const values: any[] = [];
if (data.mqtt_password_hash !== undefined) {
updates.push('mqtt_password_hash = ?');
values.push(data.mqtt_password_hash);
}
if (data.enabled !== undefined) {
updates.push('enabled = ?');
values.push(data.enabled);
}
if (updates.length === 0) {
db.close();
return mqttCredentialDb.findByDeviceId(deviceId);
}
updates.push('updated_at = datetime(\'now\')');
values.push(deviceId);
const sql = `UPDATE mqtt_credentials SET ${updates.join(', ')} WHERE device_id = ?`;
db.prepare(sql).run(...values);
const updated = db.prepare('SELECT * FROM mqtt_credentials WHERE device_id = ?')
.get(deviceId) as MqttCredential | undefined;
// Mark pending changes
mqttSyncStatusDb.markPendingChanges();
db.close();
return updated || null;
},
/**
* Lösche MQTT Credentials für ein Device
*/
delete: (deviceId: string): boolean => {
const db = getDb();
const result = db.prepare('DELETE FROM mqtt_credentials WHERE device_id = ?').run(deviceId);
// Mark pending changes
mqttSyncStatusDb.markPendingChanges();
db.close();
return result.changes > 0;
},
/**
* Finde alle aktiven MQTT Credentials (für Mosquitto Sync)
*/
findAllActive: (): MqttCredential[] => {
const db = getDb();
const credentials = db.prepare('SELECT * FROM mqtt_credentials WHERE enabled = 1')
.all() as MqttCredential[];
db.close();
return credentials;
},
};
export const mqttAclRuleDb = {
/**
* Finde alle ACL Regeln für ein Device
*/
findByDeviceId: (deviceId: string): MqttAclRule[] => {
const db = getDb();
const rules = db.prepare('SELECT * FROM mqtt_acl_rules WHERE device_id = ?')
.all(deviceId) as MqttAclRule[];
db.close();
return rules;
},
/**
* Erstelle eine neue ACL Regel
*/
create: (data: {
device_id: string;
topic_pattern: string;
permission: 'read' | 'write' | 'readwrite';
}): MqttAclRule => {
const db = getDb();
const stmt = db.prepare(`
INSERT INTO mqtt_acl_rules (device_id, topic_pattern, permission)
VALUES (?, ?, ?)
`);
const result = stmt.run(
data.device_id,
data.topic_pattern,
data.permission
);
const created = db.prepare('SELECT * FROM mqtt_acl_rules WHERE id = ?')
.get(result.lastInsertRowid) as MqttAclRule;
// Mark pending changes
mqttSyncStatusDb.markPendingChanges();
db.close();
return created;
},
/**
* Erstelle Default ACL Regel für ein Device (owntracks/owntrack/[device-id]/#)
*/
createDefaultRule: (deviceId: string): MqttAclRule => {
return mqttAclRuleDb.create({
device_id: deviceId,
topic_pattern: `owntracks/owntrack/${deviceId}/#`,
permission: 'readwrite'
});
},
/**
* Aktualisiere eine ACL Regel
*/
update: (id: number, data: {
topic_pattern?: string;
permission?: 'read' | 'write' | 'readwrite';
}): MqttAclRule | null => {
const db = getDb();
const updates: string[] = [];
const values: any[] = [];
if (data.topic_pattern !== undefined) {
updates.push('topic_pattern = ?');
values.push(data.topic_pattern);
}
if (data.permission !== undefined) {
updates.push('permission = ?');
values.push(data.permission);
}
if (updates.length === 0) {
db.close();
const existing = db.prepare('SELECT * FROM mqtt_acl_rules WHERE id = ?')
.get(id) as MqttAclRule | undefined;
return existing || null;
}
values.push(id);
const sql = `UPDATE mqtt_acl_rules SET ${updates.join(', ')} WHERE id = ?`;
db.prepare(sql).run(...values);
const updated = db.prepare('SELECT * FROM mqtt_acl_rules WHERE id = ?')
.get(id) as MqttAclRule | undefined;
// Mark pending changes
mqttSyncStatusDb.markPendingChanges();
db.close();
return updated || null;
},
/**
* Lösche eine ACL Regel
*/
delete: (id: number): boolean => {
const db = getDb();
const result = db.prepare('DELETE FROM mqtt_acl_rules WHERE id = ?').run(id);
// Mark pending changes
mqttSyncStatusDb.markPendingChanges();
db.close();
return result.changes > 0;
},
/**
* Lösche alle ACL Regeln für ein Device
*/
deleteByDeviceId: (deviceId: string): number => {
const db = getDb();
const result = db.prepare('DELETE FROM mqtt_acl_rules WHERE device_id = ?').run(deviceId);
// Mark pending changes
if (result.changes > 0) {
mqttSyncStatusDb.markPendingChanges();
}
db.close();
return result.changes;
},
/**
* Finde alle ACL Regeln (für Mosquitto Sync)
*/
findAll: (): MqttAclRule[] => {
const db = getDb();
const rules = db.prepare(`
SELECT acl.* FROM mqtt_acl_rules acl
INNER JOIN mqtt_credentials cred ON acl.device_id = cred.device_id
WHERE cred.enabled = 1
`).all() as MqttAclRule[];
db.close();
return rules;
},
};
export const mqttSyncStatusDb = {
/**
* Hole den aktuellen Sync Status
*/
get: (): MqttSyncStatus | null => {
const db = getDb();
const status = db.prepare('SELECT * FROM mqtt_sync_status WHERE id = 1')
.get() as MqttSyncStatus | undefined;
db.close();
return status || null;
},
/**
* Markiere dass es ausstehende Änderungen gibt
*/
markPendingChanges: (): void => {
const db = getDb();
db.prepare(`
UPDATE mqtt_sync_status
SET pending_changes = pending_changes + 1,
updated_at = datetime('now')
WHERE id = 1
`).run();
db.close();
},
/**
* Markiere erfolgreichen Sync
*/
markSynced: (): void => {
const db = getDb();
db.prepare(`
UPDATE mqtt_sync_status
SET pending_changes = 0,
last_sync_at = datetime('now'),
last_sync_status = 'success',
updated_at = datetime('now')
WHERE id = 1
`).run();
db.close();
},
/**
* Markiere fehlgeschlagenen Sync
*/
markSyncFailed: (error: string): void => {
const db = getDb();
db.prepare(`
UPDATE mqtt_sync_status
SET last_sync_at = datetime('now'),
last_sync_status = ?,
updated_at = datetime('now')
WHERE id = 1
`).run(`error: ${error}`);
db.close();
},
};

225
lib/mqtt-subscriber.ts Normal file
View File

@@ -0,0 +1,225 @@
// MQTT Subscriber Service für OwnTracks Location Updates
import mqtt from 'mqtt';
import { locationDb, Location } from './db';
// OwnTracks Message Format
interface OwnTracksMessage {
_type: 'location' | 'transition' | 'waypoint' | 'lwt';
tid?: string; // Tracker ID
lat: number;
lon: number;
tst: number; // Timestamp (Unix epoch)
batt?: number; // Battery level (0-100)
vel?: number; // Velocity/Speed in km/h
acc?: number; // Accuracy
alt?: number; // Altitude
cog?: number; // Course over ground
t?: string; // Trigger (p=ping, c=region, b=beacon, u=manual, t=timer, v=monitoring)
}
class MQTTSubscriber {
private client: mqtt.MqttClient | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private isConnecting = false;
constructor(
private brokerUrl: string,
private username?: string,
private password?: string
) {}
/**
* Verbinde zum MQTT Broker und subscribiere Topics
*/
connect(): void {
if (this.isConnecting || this.client?.connected) {
console.log('Already connecting or connected to MQTT broker');
return;
}
this.isConnecting = true;
console.log(`Connecting to MQTT broker: ${this.brokerUrl}`);
const options: mqtt.IClientOptions = {
clean: true,
reconnectPeriod: 5000,
connectTimeout: 30000,
};
if (this.username && this.password) {
options.username = this.username;
options.password = this.password;
}
this.client = mqtt.connect(this.brokerUrl, options);
this.client.on('connect', () => {
this.isConnecting = false;
this.reconnectAttempts = 0;
console.log('✓ Connected to MQTT broker');
// Subscribiere owntracks Topic (alle Devices) mit QoS 1
this.client?.subscribe('owntracks/+/+', { qos: 1 }, (err) => {
if (err) {
console.error('Failed to subscribe to owntracks topic:', err);
} else {
console.log('✓ Subscribed to owntracks/+/+ with QoS 1');
}
});
});
this.client.on('message', (topic, message) => {
this.handleMessage(topic, message);
});
this.client.on('error', (error) => {
console.error('MQTT client error:', error);
this.isConnecting = false;
});
this.client.on('reconnect', () => {
this.reconnectAttempts++;
console.log(`Reconnecting to MQTT broker (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max reconnect attempts reached, giving up');
this.client?.end();
}
});
this.client.on('close', () => {
console.log('MQTT connection closed');
this.isConnecting = false;
});
this.client.on('offline', () => {
console.log('MQTT client offline');
});
}
/**
* Verarbeite eingehende MQTT Nachricht
*/
private handleMessage(topic: string, message: Buffer): void {
try {
// Parse Topic: owntracks/user/device
const parts = topic.split('/');
if (parts.length !== 3 || parts[0] !== 'owntracks') {
console.log(`Ignoring non-owntracks topic: ${topic}`);
return;
}
const [, user, device] = parts;
// Parse Message Payload
const payload = JSON.parse(message.toString()) as OwnTracksMessage;
// Nur location messages verarbeiten
if (payload._type !== 'location') {
console.log(`Ignoring non-location message type: ${payload._type}`);
return;
}
// Konvertiere zu Location Format
const location: Location = {
latitude: payload.lat,
longitude: payload.lon,
timestamp: new Date(payload.tst * 1000).toISOString(),
user_id: 0, // MQTT Devices haben user_id 0
username: device, // Device ID als username
first_name: null,
last_name: null,
marker_label: payload.tid || device,
display_time: null,
chat_id: 0,
battery: payload.batt ?? null,
speed: payload.vel ?? null,
};
// Speichere in Datenbank
const saved = locationDb.create(location);
if (saved) {
console.log(`✓ Location saved: ${device} at (${payload.lat}, ${payload.lon})`);
} else {
console.log(`⚠ Duplicate location ignored: ${device}`);
}
} catch (error) {
console.error('Failed to process MQTT message:', error);
console.error('Topic:', topic);
console.error('Message:', message.toString());
}
}
/**
* Disconnect vom MQTT Broker
*/
disconnect(): void {
if (this.client) {
console.log('Disconnecting from MQTT broker');
this.client.end();
this.client = null;
}
}
/**
* Check ob verbunden
*/
isConnected(): boolean {
return this.client?.connected ?? false;
}
/**
* Hole Client Status Info
*/
getStatus(): {
connected: boolean;
reconnectAttempts: number;
brokerUrl: string;
} {
return {
connected: this.isConnected(),
reconnectAttempts: this.reconnectAttempts,
brokerUrl: this.brokerUrl,
};
}
}
// Singleton Instance
let mqttSubscriber: MQTTSubscriber | null = null;
/**
* Initialisiere und starte MQTT Subscriber
*/
export function initMQTTSubscriber(): MQTTSubscriber {
if (mqttSubscriber) {
return mqttSubscriber;
}
const brokerUrl = process.env.MQTT_BROKER_URL || 'mqtt://localhost:1883';
const username = process.env.MQTT_USERNAME;
const password = process.env.MQTT_PASSWORD;
mqttSubscriber = new MQTTSubscriber(brokerUrl, username, password);
mqttSubscriber.connect();
return mqttSubscriber;
}
/**
* Hole existierende MQTT Subscriber Instance
*/
export function getMQTTSubscriber(): MQTTSubscriber | null {
return mqttSubscriber;
}
/**
* Stoppe MQTT Subscriber
*/
export function stopMQTTSubscriber(): void {
if (mqttSubscriber) {
mqttSubscriber.disconnect();
mqttSubscriber = null;
}
}

96
lib/password-reset-db.ts Normal file
View File

@@ -0,0 +1,96 @@
/**
* Database operations for password reset tokens
*/
import { getDb } from './db';
import { randomUUID } from 'crypto';
export interface PasswordResetToken {
token: string;
user_id: string;
expires_at: string;
used: number;
created_at: string;
}
export const passwordResetDb = {
/**
* Create a new password reset token
* Returns token string
*/
create: (userId: string, expiresInHours: number = 1): string => {
const db = getDb();
const token = randomUUID();
const expiresAt = new Date(Date.now() + expiresInHours * 60 * 60 * 1000).toISOString();
db.prepare(`
INSERT INTO password_reset_tokens (token, user_id, expires_at)
VALUES (?, ?, ?)
`).run(token, userId, expiresAt);
db.close();
return token;
},
/**
* Get token by token string
*/
findByToken: (token: string): PasswordResetToken | null => {
const db = getDb();
const result = db
.prepare('SELECT * FROM password_reset_tokens WHERE token = ?')
.get(token) as PasswordResetToken | undefined;
db.close();
return result || null;
},
/**
* Validate token (exists, not used, not expired)
*/
isValid: (token: string): boolean => {
const resetToken = passwordResetDb.findByToken(token);
if (!resetToken) return false;
if (resetToken.used) return false;
const now = new Date();
const expiresAt = new Date(resetToken.expires_at);
if (now > expiresAt) return false;
return true;
},
/**
* Mark token as used
*/
markUsed: (token: string): boolean => {
const db = getDb();
const result = db
.prepare('UPDATE password_reset_tokens SET used = 1 WHERE token = ?')
.run(token);
db.close();
return result.changes > 0;
},
/**
* Delete expired tokens (cleanup)
*/
deleteExpired: (): number => {
const db = getDb();
const result = db
.prepare("DELETE FROM password_reset_tokens WHERE expires_at < datetime('now')")
.run();
db.close();
return result.changes;
},
/**
* Delete all tokens for a user
*/
deleteByUserId: (userId: string): number => {
const db = getDb();
const result = db
.prepare('DELETE FROM password_reset_tokens WHERE user_id = ?')
.run(userId);
db.close();
return result.changes;
},
};

102
lib/settings-db.ts Normal file
View File

@@ -0,0 +1,102 @@
/**
* Database operations for app settings
*/
import { getDb } from './db';
import { SMTPConfig } from './types/smtp';
import { encrypt, decrypt } from './crypto-utils';
export interface Setting {
key: string;
value: string;
updated_at: string;
}
export const settingsDb = {
/**
* Get a setting by key
*/
get: (key: string): Setting | null => {
const db = getDb();
const setting = db
.prepare('SELECT * FROM settings WHERE key = ?')
.get(key) as Setting | undefined;
db.close();
return setting || null;
},
/**
* Set a setting value
*/
set: (key: string, value: string): void => {
const db = getDb();
db.prepare(
`INSERT INTO settings (key, value, updated_at)
VALUES (?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = datetime('now')`
).run(key, value);
db.close();
},
/**
* Delete a setting
*/
delete: (key: string): boolean => {
const db = getDb();
const result = db.prepare('DELETE FROM settings WHERE key = ?').run(key);
db.close();
return result.changes > 0;
},
/**
* Get SMTP config from database (password decrypted)
*/
getSMTPConfig: (): SMTPConfig | null => {
const setting = settingsDb.get('smtp_config');
if (!setting) return null;
try {
const config = JSON.parse(setting.value) as SMTPConfig;
// Decrypt password if present
if (config.auth?.pass) {
try {
config.auth.pass = decrypt(config.auth.pass);
} catch (decryptError) {
console.error('[SettingsDB] Failed to decrypt password:', decryptError);
throw decryptError;
}
}
return config;
} catch (error) {
console.error('[SettingsDB] Failed to parse SMTP config:', error);
return null;
}
},
/**
* Save SMTP config to database (password encrypted)
*/
setSMTPConfig: (config: SMTPConfig): void => {
// Encrypt password before saving
let encryptedPass: string;
try {
encryptedPass = encrypt(config.auth.pass);
} catch (encryptError) {
console.error('[SettingsDB] Failed to encrypt password:', encryptError);
throw encryptError;
}
const configToSave = {
...config,
auth: {
...config.auth,
pass: encryptedPass,
},
};
settingsDb.set('smtp_config', JSON.stringify(configToSave));
},
};

36
lib/startup.ts Normal file
View File

@@ -0,0 +1,36 @@
// Startup-Script für Server-Side Services
// Wird beim Start der Next.js App ausgeführt
import { initMQTTSubscriber } from './mqtt-subscriber';
let initialized = false;
/**
* Initialisiere alle Server-Side Services
*/
export function initializeServices() {
// Verhindere mehrfache Initialisierung
if (initialized) {
console.log('Services already initialized');
return;
}
console.log('Initializing server-side services...');
try {
// Starte MQTT Subscriber nur wenn MQTT_BROKER_URL konfiguriert ist
if (process.env.MQTT_BROKER_URL) {
console.log('Starting MQTT subscriber...');
initMQTTSubscriber();
console.log('✓ MQTT subscriber started');
} else {
console.log('⚠ MQTT_BROKER_URL not configured, skipping MQTT subscriber');
}
initialized = true;
console.log('✓ All services initialized');
} catch (error) {
console.error('Failed to initialize services:', error);
// Werfe keinen Fehler - App sollte trotzdem starten können
}
}

48
lib/types/smtp.ts Normal file
View File

@@ -0,0 +1,48 @@
/**
* SMTP Configuration types
*/
export interface SMTPConfig {
host: string;
port: number;
secure: boolean;
auth: {
user: string;
pass: string; // Encrypted in DB
};
from: {
email: string;
name: string;
};
replyTo?: string;
timeout?: number;
}
export interface SMTPConfigResponse {
config: SMTPConfig | null;
source: 'database' | 'env';
}
export interface SMTPTestRequest {
config: SMTPConfig;
testEmail: string;
}
export interface EmailTemplate {
name: string;
subject: string;
description: string;
}
export const EMAIL_TEMPLATES: EmailTemplate[] = [
{
name: 'welcome',
subject: 'Welcome to Location Tracker',
description: 'Sent when a new user is created',
},
{
name: 'password-reset',
subject: 'Password Reset Request',
description: 'Sent when user requests password reset',
},
];

65
middleware.ts Normal file
View File

@@ -0,0 +1,65 @@
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";
// Force Node.js runtime for SQLite compatibility
export const runtime = 'nodejs';
export default auth((req) => {
const { pathname } = req.nextUrl;
const session = req.auth;
// Check if accessing map route (requires authentication only)
if (pathname.startsWith('/map')) {
// Require authentication for map access
if (!session?.user) {
const loginUrl = new URL('/login', req.url);
loginUrl.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(loginUrl);
}
// All authenticated users can access the map
return NextResponse.next();
}
// Check if accessing admin routes
if (pathname.startsWith('/admin')) {
// Require authentication
if (!session?.user) {
const loginUrl = new URL('/login', req.url);
loginUrl.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(loginUrl);
}
const userRole = (session.user as any).role;
// Define VIEWER-accessible routes (read-only)
const viewerAllowedRoutes = [
'/admin', // Dashboard
'/admin/devices', // Devices list (read-only)
];
// Check if VIEWER is accessing allowed route
const isViewerAllowedRoute = viewerAllowedRoutes.some(route =>
pathname === route || pathname.startsWith(route + '/')
);
// VIEWER can only access dashboard and devices (read-only)
if (userRole === 'VIEWER' && !isViewerAllowedRoute) {
const unauthorizedUrl = new URL('/unauthorized', req.url);
unauthorizedUrl.searchParams.set('from', pathname);
return NextResponse.redirect(unauthorizedUrl);
}
// Non-ADMIN and non-VIEWER users are denied
if (userRole !== 'ADMIN' && userRole !== 'VIEWER') {
const unauthorizedUrl = new URL('/unauthorized', req.url);
unauthorizedUrl.searchParams.set('from', pathname);
return NextResponse.redirect(unauthorizedUrl);
}
}
return NextResponse.next();
});
export const config = {
matcher: ["/admin/:path*", "/map/:path*"],
};

View File

@@ -0,0 +1,44 @@
# Mosquitto Configuration für Location Tracker
# Listener auf allen Interfaces
listener 1883
protocol mqtt
# WebSocket Listener (optional)
listener 9001
protocol websockets
# Persistenz
persistence true
persistence_location /mosquitto/data/
# Logging
log_dest file /mosquitto/log/mosquitto.log
log_dest stdout
log_type error
log_type warning
log_type notice
log_type information
log_timestamp true
# Authentifizierung
# Startet initially mit anonymous access, wird durch Sync konfiguriert
allow_anonymous true
# password_file /mosquitto/config/password.txt
# Access Control List
# acl_file /mosquitto/config/acl.txt
# Connection Settings
max_connections -1
# QoS 1/2 Settings - optimiert für GPS Tracking
max_inflight_messages 100 # Erhöht von 20 - mehr parallele QoS 1/2 Messages
max_queued_messages 10000 # Erhöht von 1000 - größerer Buffer bei Offline-Clients
max_queued_bytes 0 # 0 = unlimited
# QoS 0 Settings
upgrade_outgoing_qos false # Respektiere Client QoS Level
# Retain Messages
retain_available true

4
next.config.js Normal file
View File

@@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
module.exports = nextConfig

6603
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

54
package.json Normal file
View File

@@ -0,0 +1,54 @@
{
"name": "location-tracker-app",
"version": "1.0.0",
"description": "Location Tracking Application with Next.js, SQLite and OwnTracks integration",
"main": "index.js",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"db:init": "node scripts/init-database.js && node scripts/init-locations-db.js",
"db:init:app": "node scripts/init-database.js",
"db:init:locations": "node scripts/init-locations-db.js",
"db:cleanup": "node scripts/cleanup-old-locations.js",
"db:cleanup:7d": "node scripts/cleanup-old-locations.js 168",
"db:cleanup:30d": "node scripts/cleanup-old-locations.js 720",
"test:location": "node scripts/add-test-location.js",
"email:dev": "email dev"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@react-email/components": "^0.5.7",
"@tailwindcss/postcss": "^4.1.17",
"@types/bcrypt": "^6.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.10.1",
"@types/react": "^19.2.4",
"@types/react-dom": "^19.2.3",
"bcrypt": "^6.0.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.4.1",
"leaflet": "^1.9.4",
"mqtt": "^5.14.1",
"next": "^16.0.3",
"next-auth": "^5.0.0-beta.30",
"nodemailer": "^7.0.10",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-email": "^4.3.2",
"react-leaflet": "^5.0.0",
"typescript": "^5.9.3",
"uuid": "^10.0.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/nodemailer": "^7.0.3",
"autoprefixer": "^10.4.22",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.17"
}
}

1
pictures/Readme.md Normal file
View File

@@ -0,0 +1 @@
Bilder Folder

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

103
scripts/add-mqtt-tables.js Normal file
View File

@@ -0,0 +1,103 @@
#!/usr/bin/env node
/**
* Migration script to add MQTT credentials and ACL tables
* This extends the existing database with MQTT provisioning capabilities
*/
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
const dataDir = path.join(__dirname, '..', 'data');
const dbPath = path.join(dataDir, 'database.sqlite');
// Check if database exists
if (!fs.existsSync(dbPath)) {
console.error('❌ Database not found. Run npm run db:init:app first');
process.exit(1);
}
// Open database
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
console.log('Starting MQTT tables migration...\n');
// Create mqtt_credentials table
db.exec(`
CREATE TABLE IF NOT EXISTS mqtt_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL UNIQUE,
mqtt_username TEXT NOT NULL UNIQUE,
mqtt_password_hash TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (device_id) REFERENCES Device(id) ON DELETE CASCADE,
CHECK (enabled IN (0, 1))
);
`);
console.log('✓ Created mqtt_credentials table');
// Create mqtt_acl_rules table
db.exec(`
CREATE TABLE IF NOT EXISTS mqtt_acl_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
topic_pattern TEXT NOT NULL,
permission TEXT NOT NULL CHECK(permission IN ('read', 'write', 'readwrite')),
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (device_id) REFERENCES Device(id) ON DELETE CASCADE
);
`);
console.log('✓ Created mqtt_acl_rules table');
// Create indexes for performance
db.exec(`
CREATE INDEX IF NOT EXISTS idx_mqtt_credentials_device
ON mqtt_credentials(device_id);
CREATE INDEX IF NOT EXISTS idx_mqtt_credentials_username
ON mqtt_credentials(mqtt_username);
CREATE INDEX IF NOT EXISTS idx_mqtt_acl_device
ON mqtt_acl_rules(device_id);
`);
console.log('✓ Created indexes');
// Create mqtt_sync_status table to track pending changes
db.exec(`
CREATE TABLE IF NOT EXISTS mqtt_sync_status (
id INTEGER PRIMARY KEY CHECK (id = 1),
pending_changes INTEGER DEFAULT 0,
last_sync_at TEXT,
last_sync_status TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
`);
console.log('✓ Created mqtt_sync_status table');
// Initialize sync status
const syncStatus = db.prepare('SELECT * FROM mqtt_sync_status WHERE id = 1').get();
if (!syncStatus) {
db.prepare(`
INSERT INTO mqtt_sync_status (id, pending_changes, last_sync_status)
VALUES (1, 0, 'never_synced')
`).run();
console.log('✓ Initialized sync status');
} else {
console.log('✓ Sync status already exists');
}
// Get stats
const mqttCredsCount = db.prepare('SELECT COUNT(*) as count FROM mqtt_credentials').get();
const aclRulesCount = db.prepare('SELECT COUNT(*) as count FROM mqtt_acl_rules').get();
console.log(`\n✓ MQTT tables migration completed successfully!`);
console.log(` MQTT Credentials: ${mqttCredsCount.count}`);
console.log(` ACL Rules: ${aclRulesCount.count}`);
db.close();

View File

@@ -0,0 +1,49 @@
#!/usr/bin/env node
/**
* Add parent_user_id column to User table
* This enables parent-child relationship for ADMIN -> VIEWER hierarchy
*/
const Database = require('better-sqlite3');
const path = require('path');
const dataDir = path.join(__dirname, '..', 'data');
const dbPath = path.join(dataDir, 'database.sqlite');
console.log('Adding parent_user_id column to User table...');
const db = new Database(dbPath);
try {
// Check if column already exists
const tableInfo = db.prepare("PRAGMA table_info(User)").all();
const hasParentColumn = tableInfo.some(col => col.name === 'parent_user_id');
if (hasParentColumn) {
console.log('⚠ Column parent_user_id already exists, skipping...');
} else {
// Add parent_user_id column
db.exec(`
ALTER TABLE User ADD COLUMN parent_user_id TEXT;
`);
console.log('✓ Added parent_user_id column');
// Create index for faster lookups
db.exec(`
CREATE INDEX IF NOT EXISTS idx_user_parent ON User(parent_user_id);
`);
console.log('✓ Created index on parent_user_id');
// Add foreign key constraint check
// Note: SQLite doesn't enforce foreign keys on ALTER TABLE,
// but we'll add the constraint in the application logic
console.log('✓ Parent-child relationship enabled');
}
db.close();
console.log('\n✓ Migration completed successfully!');
} catch (error) {
console.error('Error during migration:', error);
db.close();
process.exit(1);
}

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env node
/**
* Add a test location via command line
* Usage: node scripts/add-test-location.js <username> <lat> <lon>
* Example: node scripts/add-test-location.js 10 48.1351 11.582
*/
const Database = require('better-sqlite3');
const path = require('path');
const args = process.argv.slice(2);
if (args.length < 3) {
console.error('Usage: node scripts/add-test-location.js <username> <lat> <lon> [speed] [battery]');
console.error('Example: node scripts/add-test-location.js 10 48.1351 11.582 25 85');
process.exit(1);
}
const [username, lat, lon, speed, battery] = args;
const dbPath = path.join(__dirname, '..', 'data', 'locations.sqlite');
const db = new Database(dbPath);
try {
const stmt = db.prepare(`
INSERT INTO Location (
latitude, longitude, timestamp, user_id,
username, display_time, battery, speed, chat_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const now = new Date();
const timestamp = now.toISOString();
const displayTime = now.toLocaleString('de-DE', {
timeZone: 'Europe/Berlin',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
const result = stmt.run(
parseFloat(lat),
parseFloat(lon),
timestamp,
0,
username,
displayTime,
battery ? parseInt(battery) : null,
speed ? parseFloat(speed) : null,
0
);
console.log('✓ Test location added successfully!');
console.log(` Username: ${username}`);
console.log(` Coordinates: ${lat}, ${lon}`);
console.log(` Timestamp: ${timestamp}`);
if (speed) console.log(` Speed: ${speed} km/h`);
if (battery) console.log(` Battery: ${battery}%`);
console.log(` ID: ${result.lastInsertRowid}`);
} catch (error) {
console.error('Error:', error.message);
process.exit(1);
} finally {
db.close();
}

51
scripts/check-admin.js Normal file
View File

@@ -0,0 +1,51 @@
#!/usr/bin/env node
/**
* Check admin user and test password verification
*/
const Database = require('better-sqlite3');
const bcrypt = require('bcryptjs');
const path = require('path');
const dbPath = path.join(__dirname, '..', 'data', 'database.sqlite');
const db = new Database(dbPath);
// Get admin user
const user = db.prepare('SELECT * FROM User WHERE username = ?').get('admin');
if (!user) {
console.log('❌ Admin user not found!');
process.exit(1);
}
console.log('Admin user found:');
console.log(' ID:', user.id);
console.log(' Username:', user.username);
console.log(' Email:', user.email);
console.log(' Role:', user.role);
console.log(' Password Hash:', user.passwordHash.substring(0, 20) + '...');
// Test password verification
const testPassword = 'admin123';
console.log('\nTesting password verification...');
console.log(' Test password:', testPassword);
try {
const isValid = bcrypt.compareSync(testPassword, user.passwordHash);
console.log(' Result:', isValid ? '✅ VALID' : '❌ INVALID');
if (!isValid) {
console.log('\n⚠ Password verification failed!');
console.log('Recreating admin user with fresh hash...');
const newHash = bcrypt.hashSync(testPassword, 10);
db.prepare('UPDATE User SET passwordHash = ? WHERE username = ?').run(newHash, 'admin');
console.log('✅ Admin password reset successfully');
console.log('Try logging in again with: admin / admin123');
}
} catch (error) {
console.log(' Error:', error.message);
}
db.close();

View File

@@ -0,0 +1,63 @@
#!/usr/bin/env node
/**
* Check user password hash in database
* Usage: node scripts/check-user-password.js <username>
*/
const Database = require('better-sqlite3');
const bcrypt = require('bcryptjs');
const path = require('path');
const dbPath = path.join(process.cwd(), 'data', 'database.sqlite');
async function checkUserPassword(username) {
const db = new Database(dbPath, { readonly: true });
try {
const user = db.prepare('SELECT * FROM User WHERE username = ?').get(username);
if (!user) {
console.error(`❌ User "${username}" not found`);
process.exit(1);
}
console.log(`\n✓ User found: ${user.username}`);
console.log(` ID: ${user.id}`);
console.log(` Email: ${user.email || 'N/A'}`);
console.log(` Role: ${user.role}`);
console.log(` Created: ${user.createdAt}`);
console.log(` Updated: ${user.updatedAt}`);
console.log(` Last Login: ${user.lastLoginAt || 'Never'}`);
console.log(`\n Password Hash: ${user.passwordHash.substring(0, 60)}...`);
console.log(` Hash starts with: ${user.passwordHash.substring(0, 7)}`);
// Check if it's a valid bcrypt hash
const isBcrypt = user.passwordHash.startsWith('$2a$') ||
user.passwordHash.startsWith('$2b$') ||
user.passwordHash.startsWith('$2y$');
if (isBcrypt) {
console.log(` ✓ Hash format: Valid bcrypt hash`);
// Extract rounds
const rounds = parseInt(user.passwordHash.split('$')[2]);
console.log(` ✓ Bcrypt rounds: ${rounds}`);
} else {
console.log(` ❌ Hash format: NOT a valid bcrypt hash!`);
}
} catch (error) {
console.error('Error:', error);
process.exit(1);
} finally {
db.close();
}
}
const username = process.argv[2];
if (!username) {
console.error('Usage: node scripts/check-user-password.js <username>');
process.exit(1);
}
checkUserPassword(username);

View File

@@ -0,0 +1,76 @@
#!/usr/bin/env node
/**
* Cleanup old location data from locations.sqlite
*
* Usage:
* node scripts/cleanup-old-locations.js [hours]
*
* Examples:
* node scripts/cleanup-old-locations.js 168 # Delete older than 7 days
* node scripts/cleanup-old-locations.js 720 # Delete older than 30 days
*
* Default: Deletes data older than 7 days (168 hours)
*
* You can run this as a cron job:
* 0 2 * * * cd /path/to/poc-app && node scripts/cleanup-old-locations.js >> /var/log/location-cleanup.log 2>&1
*/
const Database = require('better-sqlite3');
const path = require('path');
const dbPath = path.join(__dirname, '..', 'data', 'locations.sqlite');
const DEFAULT_RETENTION_HOURS = 168; // 7 days
// Get retention period from command line or use default
const retentionHours = process.argv[2]
? parseInt(process.argv[2], 10)
: DEFAULT_RETENTION_HOURS;
if (isNaN(retentionHours) || retentionHours <= 0) {
console.error('Error: Invalid retention hours. Must be a positive number.');
process.exit(1);
}
try {
const db = new Database(dbPath);
// Get stats before cleanup
const beforeCount = db.prepare('SELECT COUNT(*) as count FROM Location').get();
const beforeSize = db.prepare("SELECT page_count * page_size / 1024 as sizeKB FROM pragma_page_count(), pragma_page_size()").get();
console.log(`\n🗑️ Location Data Cleanup`);
console.log(`================================`);
console.log(`Database: ${dbPath}`);
console.log(`Retention: ${retentionHours} hours (${Math.round(retentionHours / 24)} days)`);
console.log(`\nBefore cleanup:`);
console.log(` Records: ${beforeCount.count}`);
console.log(` Size: ${Math.round(beforeSize.sizeKB)} KB`);
// Delete old records
const result = db.prepare(`
DELETE FROM Location
WHERE timestamp < datetime('now', '-' || ? || ' hours')
`).run(retentionHours);
// Optimize database (reclaim space)
db.exec('VACUUM');
db.exec('ANALYZE');
// Get stats after cleanup
const afterCount = db.prepare('SELECT COUNT(*) as count FROM Location').get();
const afterSize = db.prepare("SELECT page_count * page_size / 1024 as sizeKB FROM pragma_page_count(), pragma_page_size()").get();
console.log(`\nAfter cleanup:`);
console.log(` Records: ${afterCount.count}`);
console.log(` Size: ${Math.round(afterSize.sizeKB)} KB`);
console.log(`\nResult:`);
console.log(` ✓ Deleted ${result.changes} old records`);
console.log(` ✓ Freed ${Math.round(beforeSize.sizeKB - afterSize.sizeKB)} KB`);
db.close();
console.log(`\n✓ Cleanup completed successfully\n`);
} catch (error) {
console.error(`\n❌ Cleanup failed:`, error.message);
process.exit(1);
}

147
scripts/init-database.js Normal file
View File

@@ -0,0 +1,147 @@
#!/usr/bin/env node
/**
* Initialize database.sqlite with User and Device tables
* This creates the schema for authentication and device management
*/
const Database = require('better-sqlite3');
const bcrypt = require('bcryptjs');
const path = require('path');
const fs = require('fs');
const dataDir = path.join(__dirname, '..', 'data');
const dbPath = path.join(dataDir, 'database.sqlite');
// Ensure data directory exists
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
console.log('✓ Created data directory');
}
// Create database
const db = new Database(dbPath);
// Enable WAL mode for better concurrency
db.pragma('journal_mode = WAL');
console.log('✓ Enabled WAL mode');
// Create User table
db.exec(`
CREATE TABLE IF NOT EXISTS User (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
email TEXT,
passwordHash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'VIEWER',
createdAt TEXT DEFAULT (datetime('now')),
updatedAt TEXT DEFAULT (datetime('now')),
lastLoginAt TEXT,
CHECK (role IN ('ADMIN', 'VIEWER'))
);
`);
console.log('✓ Created User table');
// Create Device table
db.exec(`
CREATE TABLE IF NOT EXISTS Device (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
color TEXT NOT NULL,
ownerId TEXT,
isActive INTEGER DEFAULT 1,
description TEXT,
icon TEXT,
createdAt TEXT DEFAULT (datetime('now')),
updatedAt TEXT DEFAULT (datetime('now')),
FOREIGN KEY (ownerId) REFERENCES User(id) ON DELETE SET NULL,
CHECK (isActive IN (0, 1))
);
`);
console.log('✓ Created Device table');
// Create indexes
db.exec(`
CREATE INDEX IF NOT EXISTS idx_user_username ON User(username);
CREATE INDEX IF NOT EXISTS idx_device_owner ON Device(ownerId);
CREATE INDEX IF NOT EXISTS idx_device_active ON Device(isActive);
`);
console.log('✓ Created indexes');
// Create Settings table for app configuration
db.exec(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
`);
console.log('✓ Created settings table');
// Create password reset tokens table
db.exec(`
CREATE TABLE IF NOT EXISTS password_reset_tokens (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at TEXT NOT NULL,
used INTEGER DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE
);
`);
console.log('✓ Created password_reset_tokens table');
// Create index for performance
db.exec(`
CREATE INDEX IF NOT EXISTS idx_reset_tokens_user_id
ON password_reset_tokens(user_id);
`);
console.log('✓ Created password reset tokens index');
// Check if admin user exists
const existingAdmin = db.prepare('SELECT * FROM User WHERE username = ?').get('admin');
if (!existingAdmin) {
// Create default admin user
const passwordHash = bcrypt.hashSync('admin123', 10);
db.prepare(`
INSERT INTO User (id, username, email, passwordHash, role)
VALUES (?, ?, ?, ?, ?)
`).run('admin-001', 'admin', 'admin@example.com', passwordHash, 'ADMIN');
console.log('✓ Created default admin user (username: admin, password: admin123)');
} else {
console.log('✓ Admin user already exists');
}
// Check if default devices exist
const deviceCount = db.prepare('SELECT COUNT(*) as count FROM Device').get();
if (deviceCount.count === 0) {
// Create default devices
db.prepare(`
INSERT INTO Device (id, name, color, ownerId, isActive, description)
VALUES (?, ?, ?, ?, ?, ?)
`).run('10', 'Device A', '#e74c3c', null, 1, 'Default OwnTracks device');
db.prepare(`
INSERT INTO Device (id, name, color, ownerId, isActive, description)
VALUES (?, ?, ?, ?, ?, ?)
`).run('11', 'Device B', '#3498db', null, 1, 'Default OwnTracks device');
console.log('✓ Created default devices (10, 11)');
} else {
console.log(`✓ Devices already exist (${deviceCount.count} devices)`);
}
// Get stats
const userCount = db.prepare('SELECT COUNT(*) as count FROM User').get();
const activeDeviceCount = db.prepare('SELECT COUNT(*) as count FROM Device WHERE isActive = 1').get();
console.log(`\n✓ Database initialized successfully!`);
console.log(` Path: ${dbPath}`);
console.log(` Users: ${userCount.count}`);
console.log(` Active Devices: ${activeDeviceCount.count}`);
console.log(` WAL mode: enabled`);
db.close();

View File

@@ -0,0 +1,79 @@
#!/usr/bin/env node
/**
* Initialize locations.sqlite database
* This creates the schema for location tracking data
*/
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
const dataDir = path.join(__dirname, '..', 'data');
const dbPath = path.join(dataDir, 'locations.sqlite');
// Ensure data directory exists
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
console.log('✓ Created data directory');
}
// Create database
const db = new Database(dbPath);
// Enable WAL mode for better concurrency and crash resistance
db.pragma('journal_mode = WAL');
console.log('✓ Enabled WAL mode');
// Create Location table
db.exec(`
CREATE TABLE IF NOT EXISTS Location (
id INTEGER PRIMARY KEY AUTOINCREMENT,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
timestamp TEXT NOT NULL,
user_id INTEGER DEFAULT 0,
first_name TEXT,
last_name TEXT,
username TEXT,
marker_label TEXT,
display_time TEXT,
chat_id INTEGER DEFAULT 0,
battery INTEGER,
speed REAL,
created_at TEXT DEFAULT (datetime('now')),
-- Index for fast filtering by timestamp and device
CHECK (latitude >= -90 AND latitude <= 90),
CHECK (longitude >= -180 AND longitude <= 180)
);
`);
console.log('✓ Created Location table');
// Create indexes for performance
db.exec(`
CREATE INDEX IF NOT EXISTS idx_location_timestamp
ON Location(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_location_username
ON Location(username);
CREATE INDEX IF NOT EXISTS idx_location_user_id
ON Location(user_id);
CREATE INDEX IF NOT EXISTS idx_location_composite
ON Location(user_id, username, timestamp DESC);
-- Prevent duplicates: unique combination of timestamp, username, and coordinates
CREATE UNIQUE INDEX IF NOT EXISTS idx_location_unique
ON Location(timestamp, username, latitude, longitude);
`);
console.log('✓ Created indexes (including unique constraint)');
// Get stats
const count = db.prepare('SELECT COUNT(*) as count FROM Location').get();
console.log(`\n✓ Database initialized successfully!`);
console.log(` Path: ${dbPath}`);
console.log(` Records: ${count.count}`);
console.log(` WAL mode: enabled`);
db.close();

View File

@@ -0,0 +1,75 @@
#!/usr/bin/env node
/**
* Migrate devices with NULL ownerId to admin user
* Usage: node scripts/migrate-device-ownership.js
*/
const Database = require('better-sqlite3');
const path = require('path');
const dbPath = path.join(process.cwd(), 'data', 'database.sqlite');
function migrateDeviceOwnership() {
const db = new Database(dbPath);
try {
// Find first admin user
const adminUser = db.prepare(`
SELECT * FROM User
WHERE role = 'ADMIN'
ORDER BY createdAt ASC
LIMIT 1
`).get();
if (!adminUser) {
console.error('❌ No admin user found in database');
process.exit(1);
}
console.log(`✓ Admin user found: ${adminUser.username} (${adminUser.id})`);
// Find devices with NULL ownerId
const devicesWithoutOwner = db.prepare(`
SELECT * FROM Device
WHERE ownerId IS NULL
`).all();
console.log(`\n📊 Found ${devicesWithoutOwner.length} devices without owner`);
if (devicesWithoutOwner.length === 0) {
console.log('✓ All devices already have an owner');
return;
}
// Update devices to assign to admin
const updateStmt = db.prepare(`
UPDATE Device
SET ownerId = ?, updatedAt = datetime('now')
WHERE ownerId IS NULL
`);
const result = updateStmt.run(adminUser.id);
console.log(`\n✅ Updated ${result.changes} devices`);
console.log(` Assigned to: ${adminUser.username} (${adminUser.id})`);
// Show updated devices
const updatedDevices = db.prepare(`
SELECT id, name, ownerId FROM Device
WHERE ownerId = ?
`).all(adminUser.id);
console.log('\n📋 Devices now owned by admin:');
updatedDevices.forEach(device => {
console.log(` - ${device.id}: ${device.name}`);
});
} catch (error) {
console.error('❌ Error:', error);
process.exit(1);
} finally {
db.close();
}
}
migrateDeviceOwnership();

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