first commit
This commit is contained in:
13
.dockerignore
Normal file
13
.dockerignore
Normal 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
51
.env.example
Normal 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
55
.gitignore
vendored
Normal 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
52
Dockerfile
Normal 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
559
MQTT_INTEGRATION.md
Normal 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
349
N8N_INTEGRATION.md
Normal 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
338
OWNTRACKS_SETUP.md
Normal 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
875
README.md
Normal 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 📋 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
595
app/admin/devices/page.tsx
Normal 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
171
app/admin/emails/page.tsx
Normal 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
106
app/admin/layout.tsx
Normal 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
638
app/admin/mqtt/page.tsx
Normal 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/<DeviceID> (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
519
app/admin/page.tsx
Normal 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
411
app/admin/settings/page.tsx
Normal 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
461
app/admin/setup/page.tsx
Normal 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
543
app/admin/users/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
app/api/admin/emails/preview/route.ts
Normal file
60
app/api/admin/emails/preview/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
106
app/api/admin/emails/send-test/route.ts
Normal file
106
app/api/admin/emails/send-test/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
149
app/api/admin/settings/smtp/route.ts
Normal file
149
app/api/admin/settings/smtp/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
78
app/api/admin/settings/smtp/test/route.ts
Normal file
78
app/api/admin/settings/smtp/test/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
3
app/api/auth/[...nextauth]/route.ts
Normal file
3
app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { handlers } from "@/lib/auth";
|
||||||
|
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
77
app/api/auth/forgot-password/route.ts
Normal file
77
app/api/auth/forgot-password/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
121
app/api/auth/register/route.ts
Normal file
121
app/api/auth/register/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
106
app/api/auth/reset-password/route.ts
Normal file
106
app/api/auth/reset-password/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
129
app/api/devices/[id]/route.ts
Normal file
129
app/api/devices/[id]/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
app/api/devices/public/route.ts
Normal file
44
app/api/devices/public/route.ts
Normal 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
120
app/api/devices/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
73
app/api/locations/cleanup/route.ts
Normal file
73
app/api/locations/cleanup/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
app/api/locations/ingest/route.ts
Normal file
101
app/api/locations/ingest/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
app/api/locations/optimize/route.ts
Normal file
62
app/api/locations/optimize/route.ts
Normal 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
230
app/api/locations/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
app/api/locations/stats/route.ts
Normal file
77
app/api/locations/stats/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
app/api/locations/sync/route.ts
Normal file
86
app/api/locations/sync/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
app/api/locations/test/route.ts
Normal file
91
app/api/locations/test/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
146
app/api/mqtt/acl/[id]/route.ts
Normal file
146
app/api/mqtt/acl/[id]/route.ts
Normal 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
104
app/api/mqtt/acl/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
143
app/api/mqtt/credentials/[device_id]/route.ts
Normal file
143
app/api/mqtt/credentials/[device_id]/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
126
app/api/mqtt/credentials/route.ts
Normal file
126
app/api/mqtt/credentials/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
90
app/api/mqtt/send-credentials/route.ts
Normal file
90
app/api/mqtt/send-credentials/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/api/mqtt/sync/route.ts
Normal file
50
app/api/mqtt/sync/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/api/system/status/route.ts
Normal file
42
app/api/system/status/route.ts
Normal 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
209
app/api/users/[id]/route.ts
Normal 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
155
app/api/users/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
113
app/forgot-password/page.tsx
Normal file
113
app/forgot-password/page.tsx
Normal 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
26
app/globals.css
Normal 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
23
app/layout.tsx
Normal 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
146
app/login/page.tsx
Normal 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
174
app/map/page.tsx
Normal 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
253
app/page.tsx
Normal 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">
|
||||||
|
© {new Date().getFullYear()} Location Tracker. Built with Next.js 14 and MQTT.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
app/register/page.tsx
Normal file
166
app/register/page.tsx
Normal 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
216
app/reset-password/page.tsx
Normal 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
74
app/unauthorized/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
components/AuthProvider.tsx
Normal file
11
components/AuthProvider.tsx
Normal 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
157
components/demo/DemoMap.tsx
Normal 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='© <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
394
components/map/MapView.tsx
Normal 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='© <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='© <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
0
data/.gitkeep
Normal file
58
data/README.md
Normal file
58
data/README.md
Normal 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
56
docker-compose.yml
Normal 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
144
docs/SMTP-SETUP.md
Normal 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
|
||||||
555
docs/plans/2025-11-17-smtp-integration-design.md
Normal file
555
docs/plans/2025-11-17-smtp-integration-design.md
Normal 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
|
||||||
3453
docs/plans/2025-11-17-smtp-integration.md
Normal file
3453
docs/plans/2025-11-17-smtp-integration.md
Normal file
File diff suppressed because it is too large
Load Diff
208
docs/plans/2025-11-23-device-access-control-security-fix.md
Normal file
208
docs/plans/2025-11-23-device-access-control-security-fix.md
Normal 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.
|
||||||
34
emails/components/email-footer.tsx
Normal file
34
emails/components/email-footer.tsx
Normal 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',
|
||||||
|
};
|
||||||
34
emails/components/email-header.tsx
Normal file
34
emails/components/email-header.tsx
Normal 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',
|
||||||
|
};
|
||||||
40
emails/components/email-layout.tsx
Normal file
40
emails/components/email-layout.tsx
Normal 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
171
emails/mqtt-credentials.tsx
Normal 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
105
emails/password-reset.tsx
Normal 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
106
emails/welcome.tsx
Normal 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
9
instrumentation.ts
Normal 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
92
lib/auth.ts
Normal 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
83
lib/crypto-utils.ts
Normal 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
532
lib/db.ts
Normal 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
96
lib/demo-data.ts
Normal 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
16
lib/devices.ts
Normal 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
57
lib/email-renderer.ts
Normal 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
227
lib/email-service.ts
Normal 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
239
lib/mosquitto-sync.ts
Normal 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
361
lib/mqtt-db.ts
Normal 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
225
lib/mqtt-subscriber.ts
Normal 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
96
lib/password-reset-db.ts
Normal 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
102
lib/settings-db.ts
Normal 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
36
lib/startup.ts
Normal 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
48
lib/types/smtp.ts
Normal 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
65
middleware.ts
Normal 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*"],
|
||||||
|
};
|
||||||
44
mosquitto/config/mosquitto.conf
Normal file
44
mosquitto/config/mosquitto.conf
Normal 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
4
next.config.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {}
|
||||||
|
|
||||||
|
module.exports = nextConfig
|
||||||
6603
package-lock.json
generated
Normal file
6603
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
54
package.json
Normal file
54
package.json
Normal 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
1
pictures/Readme.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Bilder Folder
|
||||||
BIN
pictures/n8n-MQTT-GPS-Tracking.png
Normal file
BIN
pictures/n8n-MQTT-GPS-Tracking.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
103
scripts/add-mqtt-tables.js
Normal file
103
scripts/add-mqtt-tables.js
Normal 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();
|
||||||
49
scripts/add-parent-user-column.js
Normal file
49
scripts/add-parent-user-column.js
Normal 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);
|
||||||
|
}
|
||||||
68
scripts/add-test-location.js
Normal file
68
scripts/add-test-location.js
Normal 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
51
scripts/check-admin.js
Normal 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();
|
||||||
63
scripts/check-user-password.js
Normal file
63
scripts/check-user-password.js
Normal 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);
|
||||||
76
scripts/cleanup-old-locations.js
Normal file
76
scripts/cleanup-old-locations.js
Normal 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
147
scripts/init-database.js
Normal 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();
|
||||||
79
scripts/init-locations-db.js
Normal file
79
scripts/init-locations-db.js
Normal 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();
|
||||||
75
scripts/migrate-device-ownership.js
Normal file
75
scripts/migrate-device-ownership.js
Normal 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
Reference in New Issue
Block a user