From 69c2057252bae6ef78f8f862ee592537a2c5f873 Mon Sep 17 00:00:00 2001 From: Bilal Teke Date: Mon, 20 Apr 2026 14:34:07 +0200 Subject: [PATCH] v4 --- .env.example | 15 + .eslintrc.json | 3 +- Dockerfile | 6 + README.md | 303 +++++++++++++----- app/api/auth/[...nextauth]/route.ts | 6 + app/api/config/[[...path]]/route.ts | 247 ++++++++++++++ app/api/config/test-service/route.ts | 31 ++ app/api/setup/route.ts | 64 ++++ app/api/status/route.ts | 21 ++ app/layout.tsx | 3 +- app/login/page.tsx | 21 ++ app/setup/page.tsx | 19 ++ auth.ts | 100 ++++++ docker-compose.yml | 18 +- package-lock.json | 264 ++++++++++++++- package.json | 8 +- postcss.config.js => postcss.config.mjs | 4 +- prisma/dev.db | Bin 0 -> 16384 bytes prisma/schema.prisma | 16 + proxy.ts | 11 + public/.gitkeep | 1 + scripts/generate-password-hash.js | 31 ++ src/app/api/config/route.ts | 90 ------ src/app/providers.tsx | 8 + src/components/LoginForm.tsx | 94 ++++++ src/components/SetupForm.tsx | 127 ++++++++ src/components/admin/AdminPage.tsx | 250 ++++++++++++--- src/components/admin/BackupSection.tsx | 211 ++++++++++++ src/components/admin/ImportExportSection.tsx | 155 +++++++++ src/data/services.ts | 3 +- src/lib/config.ts | 27 +- src/lib/config/backup-config.ts | 180 +++++++++++ src/lib/config/import-export.ts | 47 +++ src/lib/config/load-config.ts | 17 +- src/lib/config/paths.ts | 40 +++ src/lib/config/save-config.ts | 39 +-- src/lib/db/prisma.ts | 12 + src/lib/db/user.ts | 40 +++ .../route.ts => lib/monitor/check-service.ts} | 40 +-- src/types/service.ts | 3 +- types/next-auth.d.ts | 19 ++ 41 files changed, 2293 insertions(+), 301 deletions(-) create mode 100644 app/api/auth/[...nextauth]/route.ts create mode 100644 app/api/config/[[...path]]/route.ts create mode 100644 app/api/config/test-service/route.ts create mode 100644 app/api/setup/route.ts create mode 100644 app/api/status/route.ts create mode 100644 app/login/page.tsx create mode 100644 app/setup/page.tsx create mode 100644 auth.ts rename postcss.config.js => postcss.config.mjs (83%) create mode 100644 prisma/dev.db create mode 100644 prisma/schema.prisma create mode 100644 proxy.ts create mode 100644 public/.gitkeep create mode 100644 scripts/generate-password-hash.js delete mode 100644 src/app/api/config/route.ts create mode 100644 src/app/providers.tsx create mode 100644 src/components/LoginForm.tsx create mode 100644 src/components/SetupForm.tsx create mode 100644 src/components/admin/BackupSection.tsx create mode 100644 src/components/admin/ImportExportSection.tsx create mode 100644 src/lib/config/backup-config.ts create mode 100644 src/lib/config/import-export.ts create mode 100644 src/lib/config/paths.ts create mode 100644 src/lib/db/prisma.ts create mode 100644 src/lib/db/user.ts rename src/{app/api/status/route.ts => lib/monitor/check-service.ts} (69%) create mode 100644 types/next-auth.d.ts diff --git a/.env.example b/.env.example index f84fb23..cfc3839 100644 --- a/.env.example +++ b/.env.example @@ -4,5 +4,20 @@ PORT=3000 # Hostname for the server HOSTNAME=0.0.0.0 +# NextAuth Configuration +# Secret for signing tokens (generate with: openssl rand -base64 32) +NEXTAUTH_SECRET=your-generated-secret-key + +# Data directory for persistent storage (config, backups, database) +DATA_DIR=./data + +# Admin username for login +ADMIN_USERNAME=admin + +# Admin password hash (generated with bcryptjs) +# To generate a hash, run: node -e "console.log(require('bcryptjs').hashSync('your-password', 10))" +# Example hash for password 'admin': $2a$10$your-bcrypt-hash-here +ADMIN_PASSWORD_HASH=$2a$10$your-bcrypt-hash-here + # Optional: Polling interval for status checks (in milliseconds) # STATUS_POLL_INTERVAL=30000 \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json index d0e56f5..16e2919 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -2,6 +2,7 @@ "extends": "next/core-web-vitals", "rules": { "react-hooks/rules-of-hooks": "error", - "react-hooks/exhaustive-deps": "warn" + "react-hooks/exhaustive-deps": "warn", + "@next/next/no-html-link-for-pages": "off" } } diff --git a/Dockerfile b/Dockerfile index 00c31c0..62cea1a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,9 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . +# Generate Prisma client +RUN npx prisma generate + # Next.js collects completely anonymous telemetry data about general usage. # Learn more here: https://nextjs.org/telemetry # Uncomment the following line in case you want to disable telemetry during the build. @@ -44,6 +47,9 @@ COPY --from=builder /app/public ./public RUN mkdir .next RUN chown nextjs:nodejs .next +# Create data directory for persistent storage +RUN mkdir -p /app/data && chown nextjs:nodejs /app/data + # Automatically leverage output traces to reduce image size # https://nextjs.org/docs/advanced-features/output-file-tracing COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ diff --git a/README.md b/README.md index 27d7dd6..3707e63 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Homelab Dashboard -Ein modernes, responsives Dashboard für die Verwaltung von Homelab-Diensten, gebaut mit **Next.js 15**, **TypeScript** und **Tailwind CSS**. +Ein modernes, responsives Dashboard für die Verwaltung von Homelab-Diensten, gebaut mit **Next.js 15**, **TypeScript**, **Tailwind CSS** und **NextAuth.js**. ## Features @@ -9,12 +9,26 @@ Ein modernes, responsives Dashboard für die Verwaltung von Homelab-Diensten, ge - Responsive Grid-Layout für alle Bildschirmgrößen - Smooth Animations und Übergänge +🔐 **Sichere Admin-Authentifizierung** +- Session-basierte Authentifizierung mit NextAuth.js +- Benutzername/Passwort Login +- Sichere Passwort-Hashing mit bcryptjs +- Automatisches Logout bei Inaktivität + 🎯 **Service-Management** - Service-Karten mit Name, Beschreibung und Status - Farbcodierter Status-Indikator (Online, Warnung, Offline) - Direkte Links zu Services - Pulsierender Status-Punkt für visuelle Rückmeldung +⚙️ **Admin-Funktionen** +- Globale Konfigurationsoptionen (Polling, Theme, Logging) +- Service-Editor mit Healthcheck-Konfiguration +- Server-seitige Test-Funktion für Dienste +- Automatische und manuelle Backups +- Konfiguration Import/Export +- Dirty-State-Erkennung mit Undo-Funktionalität + 📱 **Mobile-First Design** - Optimiert für Smartphones, Tablets und Desktop - Touch-freundliche Buttons @@ -26,91 +40,177 @@ Ein modernes, responsives Dashboard für die Verwaltung von Homelab-Diensten, ge - Node.js 18+ installiert - npm oder yarn -### Schritt 1: Dependencies installieren +### Schritt 1: Abhängigkeiten installieren ```bash npm install ``` -### Schritt 2: Entwicklungsserver starten +### Schritt 1.1: Prisma Client generieren + +```bash +npm run prisma:generate +``` + +### Schritt 1.2: Datenbank-Schema anwenden + +```bash +npm run prisma:db-push +``` + +### Schritt 2: Umgebungsvariablen konfigurieren + +Kopiere `.env.example` zu `.env`: + +```bash +cp .env.example .env +``` + +Bearbeite `.env` und setze die erforderlichen Variablen: + +```env +PORT=3000 +HOSTNAME=0.0.0.0 + +# NextAuth Secret (generieren mit: openssl rand -base64 32) +NEXTAUTH_SECRET=generate-with-openssl-rand-base64-32 + +# SQLite database URL für persistenten Benutzerzugriff +DATABASE_URL=file:./dev.db + +# Admin-Anmeldedaten (optional, solange noch kein erster Benutzer angelegt wurde) +ADMIN_USERNAME=admin +ADMIN_PASSWORD_HASH= +``` + +### Schritt 3: Admin-Passwort-Hash generieren + +Um den Passwort-Hash zu generieren, führe aus: + +```bash +node scripts/generate-password-hash.js +``` + +Das Script fordert dich auf, ein Passwort einzugeben und gibt einen bcryptjs-Hash aus. + +Kopiere den Hash in deine `.env` Datei als `ADMIN_PASSWORD_HASH`. + +### Schritt 4: Entwicklungsserver starten ```bash npm run dev ``` -Der Server läuft dann unter `http://localhost:3000` +Der Server läuft unter `http://localhost:3000` + +## Login & Admin-Zugang + +1. Navigiere zu `http://localhost:3000/setup`, wenn noch kein Administrator angelegt wurde. +2. Erstelle den ersten Admin-Benutzer. +3. Danach melde dich unter `http://localhost:3000/login` an. +4. Nach erfolgreichem Login wird zu `/admin` weitergeleitet + +## Admin-Interface + +Im Admin-Bereich kannst du: + +- **Globale Einstellungen** bearbeiten (Polling-Intervall, Theme, Logging-Level) +- **Services** verwalten (Hinzufügen, Bearbeiten, Löschen) +- **Healthchecks** konfigurieren (Timeout, akzeptierte Status-Codes) +- **Services testen** (direkt von der Admin-UI aus) +- **Backups** erstellen und wiederherstellen +- **Konfiguration** exportieren und importieren ## Projekt-Struktur ``` homelab-dashboard/ ├── app/ -│ ├── components/ -│ │ ├── Header.tsx # Header-Komponente -│ │ └── ServiceCard.tsx # Service-Karten-Komponente -│ ├── globals.css # Globale Tailwind Styles -│ ├── layout.tsx # Root Layout -│ └── page.tsx # Startseite -├── lib/ -│ └── data.ts # Mock-Daten und Service-Typen -├── tailwind.config.ts # Tailwind Konfiguration -├── postcss.config.js # PostCSS Konfiguration -├── next.config.js # Next.js Konfiguration -├── tsconfig.json # TypeScript Konfiguration -└── package.json # Abhängigkeiten +│ ├── api/ +│ │ ├── auth/[...nextauth]/ # NextAuth API Route +│ │ ├── config/[[...path]]/ # Config Management API +│ │ └── status/ # Service Status Check API +│ ├── admin/ # Admin-Interface +│ ├── login/ # Login-Seite +│ ├── layout.tsx # Root Layout +│ └── page.tsx # Dashboard +├── auth.ts # NextAuth Konfiguration +├── middleware.ts # Auth-Middleware +├── src/ +│ ├── app/ +│ │ ├── api/ # API-Routen +│ │ └── components/admin/ # Admin-Komponenten +│ ├── lib/ +│ │ ├── config/ # Konfigurationsverwaltung +│ │ ├── monitor/ # Service-Monitoring +│ │ └── auth/ # Auth-Hilfsfunktionen +│ └── types/ # TypeScript Typen +├── scripts/ +│ └── generate-password-hash.js # Passwort-Hash Generator +├── tailwind.config.ts # Tailwind Konfiguration +├── tsconfig.json # TypeScript Konfiguration +└── package.json # Abhängigkeiten ``` -## Dienste hinzufügen/bearbeiten - -Bearbeite die Datei [src/data/services.ts](src/data/services.ts): - -```typescript -export const services: Service[] = [ - { - id: 'unique-id', - name: 'Dein Service', - description: 'Was macht dieser Service?', - category: 'Infrastruktur', // oder Smart Home, Medien, Dokumente - status: 'online', // online | warning | offline - url: 'http://localhost:8000', - icon: '🖥️', // Optional - ein Emoji für visuelles Feedback - }, - // ... weitere Services -]; -``` - -### Verfügbare Kategorien - -- **Infrastruktur**: Server, Netzwerk, Monitoring -- **Smart Home**: Home Automation und IoT -- **Medien**: Medienserver und Downloading -- **Dokumente**: Dateispeicher und Code-Repositories - ## Verfügbare Commands -- `npm run dev` - Entwicklungsserver starten -- `npm run build` - Produktions-Build erstellen -- `npm start` - Produktions-Server starten -- `npm run lint` - Linter ausführen +```bash +npm run dev # Entwicklungsserver starten +npm run build # Produktions-Build erstellen +npm start # Produktions-Server starten +npm run lint # ESLint ausführen +``` -## Status-Indikatoren +## Umgebungsvariablen -| Status | Farbe | Beschreibung | -|--------|-------|-------------| -| Online | 🟢 Grün | Service läuft einwandfrei | +| Variable | Beschreibung | Beispiel | +|----------|-------------|----------| +| `PORT` | Port für den Server | `3000` | +| `HOSTNAME` | Server-Hostname | `0.0.0.0` | +| `NEXTAUTH_SECRET` | Secret für JWT-Signing | `openssl rand -base64 32` | +| `DATABASE_URL` | SQLite-Verbindungs-URL für Prisma | `file:./dev.db` | +| `ADMIN_USERNAME` | Admin-Benutzername | `admin` | +| `ADMIN_PASSWORD_HASH` | Bcryptjs-Hash des Admin-Passworts | `$2a$10$...` | + +## Sicherheit + +- ✅ Session-basierte Authentifizierung (nicht Token-basiert) +- ✅ Sichere Passwort-Hashing mit bcryptjs +- ✅ Geschützte API-Routen (nur für authentifizierte Benutzer) +- ✅ CSRF-Schutz durch NextAuth +- ✅ Sichere HTTP-Only Cookies +- ✅ Keine Speicherung sensibler Daten im Frontend + +### Für Produktionsumgebungen beachte: + +1. **Starkes Passwort wählen** (mindestens 12 Zeichen, Umlaute, Sonderzeichen) +2. **NEXTAUTH_SECRET** mit `openssl rand -base64 32` generieren +3. **HTTPS aktivieren** in der Produktionsumgebung +4. **Admin-Seite mit zusätzlichem Firewall-Schutz** (Optional) +5. **Regelmäßige Backups** durchführen + +## Konfigurationsdateien + +### `config.json` | Warnung | 🟡 Gelb | Service läuft, aber mit Performance-Problemen | | Offline | 🔴 Rot | Service ist nicht erreichbar | ## Docker Deployment -### Lokaler Start ohne Docker +### Vorbereitung -```bash -npm install -npm run dev -``` +1. **Umgebungsvariablen setzen**: + ```bash + cp .env.example .env + # Bearbeite .env mit deinen Werten + ``` -### Lokaler Start mit Docker +2. **Datenverzeichnis erstellen** (optional, wird automatisch erstellt): + ```bash + mkdir -p data + ``` + +### Lokaler Docker-Test ```bash # Build und starte mit docker-compose @@ -118,42 +218,87 @@ docker-compose up --build # Oder manuell: docker build -t homelab-dashboard . -docker run -p 3000:3000 homelab-dashboard +docker run -p 3000:3000 \ + -e NEXTAUTH_SECRET=your-secret \ + -e DATABASE_URL=file:/app/data/database.db \ + -e DATA_DIR=/app/data \ + -v $(pwd)/data:/app/data \ + homelab-dashboard ``` -### Build-Befehl +### Produktions-Deployment ```bash +# Build für Produktion npm run build -``` -### Run-Befehl - -```bash -npm start -``` - -### Docker Compose Nutzung - -```bash -# Start im Hintergrund +# Mit Docker Compose starten docker-compose up -d -# Stoppen +# Container stoppen docker-compose down - -# Logs anzeigen -docker-compose logs -f homelab-dashboard ``` -### Unraid Volume-Mappings +### Unraid-Installation -Für Unraid empfiehlt es sich, die Konfigurationsdateien als Volumes zu mounten: +1. **Docker-Tab in Unraid öffnen** +2. **Neuen Container hinzufügen**: + - **Name**: homelab-dashboard + - **Repository**: dein-registry/homelab-dashboard:latest (oder build lokal) + - **Docker Hub Search**: Suche nach deinem Image -- **config.json**: `/mnt/user/appdata/homelab-dashboard/config.json` → `/app/config.json` -- **services.json**: `/mnt/user/appdata/homelab-dashboard/services.json` → `/app/src/data/services.json` +3. **Port-Mapping**: + - **Container Port**: 3000 + - **Host Port**: 3000 (oder gewünschter Port) -Dadurch können Konfigurationen außerhalb des Containers verwaltet werden. +4. **Volume-Mappings** (für persistente Daten): + - **Container Path**: /app/data + - **Host Path**: /mnt/user/appdata/homelab-dashboard/data + +5. **Environment Variables**: + - **NEXTAUTH_SECRET**: Dein generierter Secret (openssl rand -base64 32) + - **DATABASE_URL**: file:/app/data/database.db + - **DATA_DIR**: /app/data + - **NODE_ENV**: production + +6. **Container starten** + +### Persistente Daten + +Unter `/app/data` werden gespeichert: +- `config.json` - App-Konfiguration +- `services.json` - Service-Konfiguration +- `backups/` - Backup-Dateien +- `database.db` - SQLite-Datenbank für Benutzer + +### Docker Compose für Unraid-ähnliche Umgebungen + +```yaml +version: '3.8' +services: + homelab-dashboard: + image: homelab-dashboard:latest + container_name: homelab-dashboard + restart: unless-stopped + ports: + - "3000:3000" + environment: + - NODE_ENV=production + - PORT=3000 + - HOSTNAME=0.0.0.0 + - NEXTAUTH_SECRET=your-nextauth-secret + - DATABASE_URL=file:/app/data/database.db + - DATA_DIR=/app/data + volumes: + - /mnt/user/appdata/homelab-dashboard/data:/app/data +``` + +### Troubleshooting + +- **Container startet nicht**: Logs prüfen mit `docker-compose logs` +- **Daten nicht persistent**: Volume-Mapping überprüfen +- **Authentifizierung fehlt**: NEXTAUTH_SECRET setzen +- **Datenbank-Fehler**: DATABASE_URL und DATA_DIR prüfen ## Konfiguration diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..648e664 --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,6 @@ +import NextAuth from 'next-auth'; +import { authOptions } from '@/auth'; + +const handler = NextAuth(authOptions); + +export { handler as GET, handler as POST }; diff --git a/app/api/config/[[...path]]/route.ts b/app/api/config/[[...path]]/route.ts new file mode 100644 index 0000000..1dd04b0 --- /dev/null +++ b/app/api/config/[[...path]]/route.ts @@ -0,0 +1,247 @@ +import { auth } from '@/auth'; +import { NextResponse } from 'next/server'; +import { createBackup, listBackups, restoreBackup } from '@/src/lib/config/backup-config'; +import { backupConfig, exportConfig, importConfig, loadFullConfig, saveAppConfig, saveServices } from '@/src/lib/config'; +import { appConfigSchema, servicesArraySchema } from '@/src/lib/config/schema'; +import { CONFIG_FILE_PATH } from '@/src/lib/config/paths'; +import { resolveProjectPath } from '@/src/lib/config/load-config'; +import { promises as fs } from 'fs'; +import path from 'path'; + +const CONFIG_PATH = CONFIG_FILE_PATH; + +function jsonError(message: string, status: number) { + return NextResponse.json({ error: message }, { status }); +} + +function normalizePath(pathname: string) { + return pathname.replace(/\/+$/, ''); +} + +async function requireAuth() { + const session = await auth(); + if (!session) { + return null; + } + return session; +} + +export async function GET(request: Request) { + const pathname = new URL(request.url).pathname; + const cleanPath = normalizePath(pathname); + + if (cleanPath === '/api/config') { + const session = await requireAuth(); + if (!session) { + return jsonError('Unauthorized', 401); + } + + try { + const result = await loadFullConfig(); + return NextResponse.json(result); + } catch (error) { + console.error('GET /api/config failed:', error); + return jsonError('Failed to load configuration', 500); + } + } + + if (cleanPath === '/api/config/backups') { + const session = await requireAuth(); + if (!session) { + return jsonError('Unauthorized', 401); + } + + try { + const backups = await listBackups(); + return NextResponse.json({ backups }); + } catch (error) { + console.error('GET /api/config/backups failed:', error); + return jsonError('Failed to list backups', 500); + } + } + + if (cleanPath === '/api/config/export') { + const session = await requireAuth(); + if (!session) { + return jsonError('Unauthorized', 401); + } + + try { + const payload = await exportConfig(); + const body = JSON.stringify(payload, null, 2); + return new NextResponse(body, { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Content-Disposition': `attachment; filename="homelab-config-export-${new Date().toISOString().replace(/[:.]/g, '-')}.json"`, + }, + }); + } catch (error) { + console.error('GET /api/config/export failed:', error); + return jsonError('Failed to export configuration', 500); + } + } + + return jsonError('Not found', 404); +} + +export async function POST(request: Request) { + const session = await requireAuth(); + if (!session) { + return jsonError('Unauthorized', 401); + } + + const pathname = new URL(request.url).pathname; + const cleanPath = normalizePath(pathname); + + if (cleanPath === '/api/config/backup') { + try { + const backupFile = await createBackup(); + return NextResponse.json({ + success: true, + message: 'Backup created successfully', + backupFilename: path.basename(backupFile), + }); + } catch (error) { + console.error('POST /api/config/backup failed:', error); + return jsonError( + error instanceof Error ? error.message : 'Failed to create backup', + 500 + ); + } + } + + if (cleanPath === '/api/config/import') { + let payload: unknown; + + try { + const contentType = request.headers.get('content-type') || ''; + if (contentType.includes('multipart/form-data')) { + const formData = await request.formData(); + const file = formData.get('file'); + if (!(file instanceof File)) { + return jsonError('No file uploaded', 400); + } + + const fileText = await file.text(); + payload = JSON.parse(fileText); + } else { + payload = await request.json(); + } + } catch (error) { + return jsonError( + 'Request body must be valid JSON or a valid JSON upload', + 400 + ); + } + + try { + await importConfig(payload); + return NextResponse.json({ + success: true, + message: 'Configuration imported successfully', + }); + } catch (error) { + console.error('POST /api/config/import failed:', error); + return jsonError( + error instanceof Error ? error.message : 'Failed to import configuration', + 400 + ); + } + } + + if (cleanPath === '/api/config/restore') { + let payload: any; + try { + payload = await request.json(); + } catch (error) { + return jsonError('Request body must be valid JSON', 400); + } + + const { backupFilename } = payload; + + if (!backupFilename || typeof backupFilename !== 'string') { + return jsonError('backupFilename is required and must be a string', 400); + } + + if ( + !backupFilename.startsWith('config-backup-') || + !backupFilename.endsWith('.json') || + backupFilename.includes('/') || + backupFilename.includes('\\') + ) { + return jsonError('Invalid backup filename format', 400); + } + + try { + await restoreBackup(backupFilename); + return NextResponse.json({ + success: true, + message: 'Configuration restored successfully', + }); + } catch (error) { + console.error('POST /api/config/restore failed:', error); + return jsonError( + error instanceof Error ? error.message : 'Failed to restore backup', + 500 + ); + } + } + + return jsonError('Not found', 404); +} + +export async function PUT(request: Request) { + const session = await requireAuth(); + if (!session) { + return jsonError('Unauthorized', 401); + } + + const pathname = new URL(request.url).pathname; + const cleanPath = normalizePath(pathname); + + if (cleanPath !== '/api/config') { + return jsonError('Not found', 404); + } + + let payload: any; + + try { + payload = await request.json(); + } catch (error) { + return jsonError('Request body must be valid JSON', 400); + } + + const appConfigResult = appConfigSchema.safeParse(payload.appConfig); + if (!appConfigResult.success) { + return jsonError( + `App config validation failed: ${appConfigResult.error.message}`, + 400 + ); + } + + const servicesResult = servicesArraySchema.safeParse(payload.services); + if (!servicesResult.success) { + return jsonError( + `Services validation failed: ${servicesResult.error.message}`, + 400 + ); + } + + try { + await fs.access(CONFIG_PATH); + await backupConfig(); + } catch (error) { + // ignore missing config file + } + + try { + const servicesPath = resolveProjectPath(appConfigResult.data.servicesFile); + await saveAppConfig(appConfigResult.data, CONFIG_PATH); + await saveServices(servicesResult.data, servicesPath); + return NextResponse.json({ success: true }); + } catch (error) { + console.error('PUT /api/config failed:', error); + return jsonError('Failed to save configuration', 500); + } +} diff --git a/app/api/config/test-service/route.ts b/app/api/config/test-service/route.ts new file mode 100644 index 0000000..7fa9bbe --- /dev/null +++ b/app/api/config/test-service/route.ts @@ -0,0 +1,31 @@ +import { auth } from '@/auth'; +import { NextRequest, NextResponse } from 'next/server'; +import { checkService, type ServiceCheckResult } from '@/src/lib/monitor/check-service'; +import { serviceSchema } from '@/src/lib/config/schema'; + +function jsonError(message: string, status: number) { + return NextResponse.json({ error: message }, { status }); +} + +export async function POST(request: NextRequest) { + const session = await auth(); + if (!session) { + return jsonError('Unauthorized', 401); + } + + try { + const body = await request.json(); + const service = serviceSchema.parse(body); + const result: ServiceCheckResult = await checkService(service); + + return NextResponse.json(result); + } catch (error) { + console.error('Error testing service:', error); + + if (error instanceof Error) { + return jsonError(`Failed to test service: ${error.message}`, 400); + } + + return jsonError('Failed to test service', 500); + } +} diff --git a/app/api/setup/route.ts b/app/api/setup/route.ts new file mode 100644 index 0000000..09a9f41 --- /dev/null +++ b/app/api/setup/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from 'next/server'; +import { hash } from 'bcryptjs'; +import { z } from 'zod'; +import { createInitialAdmin, hasAnyUser } from '@/src/lib/db/user'; + +const setupSchema = z.object({ + username: z + .string() + .trim() + .min(3, 'Der Benutzername muss mindestens 3 Zeichen lang sein.') + .max(50, 'Der Benutzername darf höchstens 50 Zeichen lang sein.') + .regex(/^[a-zA-Z0-9._-]+$/, 'Der Benutzername enthält ungültige Zeichen.'), + password: z + .string() + .min(8, 'Das Passwort muss mindestens 8 Zeichen lang sein.') + .max(128, 'Das Passwort darf höchstens 128 Zeichen lang sein.'), +}); + +export async function POST(request: Request) { + try { + if (await hasAnyUser()) { + return NextResponse.json({ message: 'Ein Administrator existiert bereits.' }, { status: 403 }); + } + + let body: unknown; + + try { + body = await request.json(); + } catch (error) { + return NextResponse.json( + { message: 'Der Request-Body muss gültiges JSON sein.' }, + { status: 400 } + ); + } + + const result = setupSchema.safeParse(body); + + if (!result.success) { + return NextResponse.json( + { message: result.error.issues[0]?.message || 'Ungültige Setup-Daten.' }, + { status: 400 } + ); + } + + const passwordHash = await hash(result.data.password, 10); + + try { + await createInitialAdmin(result.data.username, passwordHash); + return NextResponse.json({ message: 'Administrator wurde erfolgreich angelegt.' }, { status: 201 }); + } catch (error) { + if (error instanceof Error && error.message === 'INITIAL_ADMIN_ALREADY_EXISTS') { + return NextResponse.json({ message: 'Ein Administrator existiert bereits.' }, { status: 403 }); + } + + throw error; + } + } catch (error) { + console.error('POST /api/setup failed:', error); + return NextResponse.json( + { message: 'Setup konnte nicht abgeschlossen werden.' }, + { status: 500 } + ); + } +} diff --git a/app/api/status/route.ts b/app/api/status/route.ts new file mode 100644 index 0000000..a5f0f26 --- /dev/null +++ b/app/api/status/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server'; +import { loadFullConfig } from '@/src/lib/config'; +import { checkService } from '@/src/lib/monitor/check-service'; +import type { Service } from '@/src/lib/config'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + try { + const { services } = await loadFullConfig(); + const results = await Promise.all(services.map((service: Service) => checkService(service))); + + return NextResponse.json(results); + } catch (error) { + console.error('Error checking services:', error); + return NextResponse.json( + { error: 'Failed to check services' }, + { status: 500 } + ); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 64a0760..f46051b 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,4 +1,5 @@ import { Metadata as NextMetadata } from 'next'; +import { Providers } from '@/src/app/providers'; export const metadata: NextMetadata = { manifest: '/manifest.json', @@ -12,7 +13,7 @@ export default function RootLayout({ return ( - {children} + {children} ); diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..a27bca9 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,21 @@ +import { redirect } from 'next/navigation'; +import { hasAnyUser } from '@/src/lib/db/user'; +import LoginForm from '@/src/components/LoginForm'; + +export const dynamic = 'force-dynamic'; + +export default async function LoginPage() { + let hasUser = false; + + try { + hasUser = await hasAnyUser(); + } catch (error) { + console.warn('Failed to determine user setup status on /login:', error); + } + + if (!hasUser && !process.env.ADMIN_PASSWORD_HASH) { + redirect('/setup'); + } + + return ; +} diff --git a/app/setup/page.tsx b/app/setup/page.tsx new file mode 100644 index 0000000..9a5365b --- /dev/null +++ b/app/setup/page.tsx @@ -0,0 +1,19 @@ +import { redirect } from 'next/navigation'; +import { hasAnyUser } from '@/src/lib/db/user'; +import SetupForm from '@/src/components/SetupForm'; + +export const dynamic = 'force-dynamic'; + +export default async function SetupPage() { + try { + const hasUser = await hasAnyUser(); + + if (hasUser) { + redirect('/login'); + } + } catch (error) { + console.warn('Failed to determine user setup status on /setup:', error); + } + + return ; +} diff --git a/auth.ts b/auth.ts new file mode 100644 index 0000000..61a08f6 --- /dev/null +++ b/auth.ts @@ -0,0 +1,100 @@ +import { compare } from 'bcryptjs'; +import { getServerSession, type NextAuthOptions } from 'next-auth'; +import Credentials from 'next-auth/providers/credentials'; +import { getUserByUsername, hasAnyUser } from '@/src/lib/db/user'; + +export const authOptions: NextAuthOptions = { + providers: [ + Credentials({ + credentials: { + username: { label: 'Benutzername', type: 'text' }, + password: { label: 'Passwort', type: 'password' }, + }, + async authorize(credentials) { + if (!credentials?.username || !credentials?.password) { + return null; + } + + const username = credentials.username.trim(); + const password = credentials.password; + if (process.env.DATABASE_URL) { + try { + const dbUser = await getUserByUsername(username); + + if (dbUser) { + const isPasswordValid = await compare(password, dbUser.passwordHash); + if (!isPasswordValid) { + return null; + } + + return { + id: String(dbUser.id), + name: username, + email: `${username}@homelab.local`, + username, + }; + } + + if (await hasAnyUser()) { + return null; + } + } catch (error) { + console.error('Database authentication error:', error); + return null; + } + } + + const adminUsername = process.env.ADMIN_USERNAME || 'admin'; + const adminPasswordHash = process.env.ADMIN_PASSWORD_HASH; + + if (!adminPasswordHash || username !== adminUsername) { + return null; + } + + try { + const isPasswordValid = await compare(password, adminPasswordHash); + + if (!isPasswordValid) { + return null; + } + + return { + id: '1', + name: 'Admin', + email: 'admin@homelab.local', + username: adminUsername, + }; + } catch (error) { + console.error('Password comparison error:', error); + return null; + } + }, + }), + ], + pages: { + signIn: '/login', + }, + callbacks: { + async jwt({ token, user }) { + if (user && user.username) { + token.username = user.username; + } + return token; + }, + async session({ session, token }) { + if (session.user) { + session.user.username = token.username; + } + return session; + }, + }, + session: { + strategy: 'jwt', + maxAge: 24 * 60 * 60, + }, + secret: process.env.NEXTAUTH_SECRET, +}; + +export function auth() { + return getServerSession(authOptions); +} diff --git a/docker-compose.yml b/docker-compose.yml index 3a0a927..45f1fbb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,13 +4,21 @@ services: homelab-dashboard: build: . container_name: homelab-dashboard + restart: unless-stopped ports: - "3000:3000" - restart: unless-stopped - volumes: - - ./config.json:/app/config.json:ro - - ./src/data/services.json:/app/src/data/services.json:ro environment: - NODE_ENV=production - PORT=3000 - - HOSTNAME=0.0.0.0 \ No newline at end of file + - HOSTNAME=0.0.0.0 + - NEXTAUTH_SECRET=${NEXTAUTH_SECRET} + - DATABASE_URL=file:/app/data/database.db + - DATA_DIR=/app/data + volumes: + - ./data:/app/data + networks: + - homelab + +networks: + homelab: + driver: bridge \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 55b8eb1..f1bfc37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,10 @@ "name": "homelab-dashboard", "version": "0.1.0", "dependencies": { + "@prisma/client": "^5.22.0", + "bcryptjs": "^3.0.3", "next": "^16.2.3", + "next-auth": "^4.24.14", "react": "^18.3.1", "react-dom": "^18.3.1", "zod": "^4.3.6" @@ -23,6 +26,7 @@ "eslint": "^8.56.0", "eslint-config-next": "^15.0.3", "postcss": "^8.4.32", + "prisma": "^5.22.0", "tailwindcss": "^3.4.1", "typescript": "^5.3.3" } @@ -40,6 +44,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@emnapi/core": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", @@ -992,6 +1005,83 @@ "node": ">=12.4.0" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@prisma/client": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", + "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", + "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", + "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/fetch-engine": "5.22.0", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", + "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", + "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", + "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1994,6 +2084,15 @@ "node": ">=6.0.0" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2265,6 +2364,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3381,7 +3489,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -4257,6 +4364,15 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4425,6 +4541,18 @@ "loose-envify": "cli.js" } }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4598,6 +4726,38 @@ } } }, + "node_modules/next-auth": { + "version": "4.24.14", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.14.tgz", + "integrity": "sha512-YRz6xFDXKUwiXSMMChbrBEWyFktZ1qZXEgeSHQQ3nsy08B4c/xLk6REeutRsIFwkjY/1+ShHnu07DN3JeJguig==", + "license": "ISC", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@panva/hkdf": "^1.0.2", + "cookie": "^0.7.0", + "jose": "^4.15.5", + "oauth": "^0.9.15", + "openid-client": "^5.4.0", + "preact": "^10.6.3", + "preact-render-to-string": "^5.1.19", + "uuid": "^8.3.2" + }, + "peerDependencies": { + "@auth/core": "0.34.3", + "next": "^12.2.5 || ^13 || ^14 || ^15 || ^16", + "nodemailer": "^7.0.7", + "react": "^17.0.2 || ^18 || ^19", + "react-dom": "^17.0.2 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@auth/core": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -4672,6 +4832,12 @@ "node": ">=0.10.0" } }, + "node_modules/oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -4805,6 +4971,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oidc-token-hash": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", + "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", + "license": "MIT", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -4815,6 +4990,30 @@ "wrappy": "1" } }, + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "license": "MIT", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5167,6 +5366,28 @@ "dev": true, "license": "MIT" }, + "node_modules/preact": { + "version": "10.29.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz", + "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz", + "integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==", + "license": "MIT", + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -5177,6 +5398,32 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", + "license": "MIT" + }, + "node_modules/prisma": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", + "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/engines": "5.22.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -6383,6 +6630,15 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6505,6 +6761,12 @@ "dev": true, "license": "ISC" }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index a9fd107..76eab65 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,15 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint app src lib --ext .ts,.tsx" + "lint": "eslint app src lib --ext .ts,.tsx", + "prisma:generate": "prisma generate", + "prisma:db-push": "prisma db push" }, "dependencies": { + "@prisma/client": "^5.22.0", + "bcryptjs": "^3.0.3", "next": "^16.2.3", + "next-auth": "^4.24.14", "react": "^18.3.1", "react-dom": "^18.3.1", "zod": "^4.3.6" @@ -24,6 +29,7 @@ "eslint": "^8.56.0", "eslint-config-next": "^15.0.3", "postcss": "^8.4.32", + "prisma": "^5.22.0", "tailwindcss": "^3.4.1", "typescript": "^5.3.3" } diff --git a/postcss.config.js b/postcss.config.mjs similarity index 83% rename from postcss.config.js rename to postcss.config.mjs index bad971d..2ef30fc 100644 --- a/postcss.config.js +++ b/postcss.config.mjs @@ -4,6 +4,6 @@ const config = { tailwindcss: {}, autoprefixer: {}, }, -} +}; -export default config \ No newline at end of file +export default config; diff --git a/prisma/dev.db b/prisma/dev.db new file mode 100644 index 0000000000000000000000000000000000000000..b4d0cce23ed7d27736ac73e4208911b636941d2c GIT binary patch literal 16384 zcmeI%O;5rw7zgkT2qYw;7sB;PPY{S6`~ps>x;O@mE`ifBS&c+qbQ_{a;>8c-2ejiY z5s4pQ`8VmBu6??vzwOej-?PTCq{n$U^<%o!&NN-uu81^E%cz=Dbyp7i`flK#Hlv-t zeVwQY*-~FS&6mDag8~5vKmY;|fB*y_009U<00QX{@Un%%-W8n8@gLq7JZL zBwrVDHahHPw+|A=wZ-XRZZ%1%p(1OmrCz9%%lc<;y=Ia~QZv7z*ZDE37D*L04w%YY z@LTSX-4SHFmPK91Y#Yvy?)i`mS9DBUtOl&mn@e7`)k_>JLOf|{=5sARlkv@eWyw^&Qg=IYRo0s#m> z00Izz00bZa0SG_<0uX>eiUn}~Pw~se)*t`@2tWV=5P$##AOHafKmY<;fnw6)|3AJ5 Z2tWV=5P$##AOHafKmY;|fIzARegHzii4gz* literal 0 HcmV?d00001 diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..99798ad --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,16 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + username String @unique + passwordHash String + role String + createdAt DateTime @default(now()) +} diff --git a/proxy.ts b/proxy.ts new file mode 100644 index 0000000..d01d576 --- /dev/null +++ b/proxy.ts @@ -0,0 +1,11 @@ +import { withAuth } from 'next-auth/middleware'; + +export default withAuth({ + pages: { + signIn: '/login', + }, +}); + +export const config = { + matcher: ['/admin/:path*'], +}; diff --git a/public/.gitkeep b/public/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/public/.gitkeep @@ -0,0 +1 @@ + diff --git a/scripts/generate-password-hash.js b/scripts/generate-password-hash.js new file mode 100644 index 0000000..0c810a7 --- /dev/null +++ b/scripts/generate-password-hash.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node + +/** + * Generate a bcryptjs hash for a password. + * Used to create ADMIN_PASSWORD_HASH for environment configuration. + * + * Usage: node scripts/generate-password-hash.js + */ + +const bcryptjs = require('bcryptjs'); +const readline = require('readline'); + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout +}); + +rl.question('Enter the password to hash: ', (password) => { + if (!password) { + console.error('Error: Password cannot be empty'); + rl.close(); + process.exit(1); + } + + const hash = bcryptjs.hashSync(password, 10); + console.log('\n✓ Password hash generated successfully:\n'); + console.log(hash); + console.log('\nAdd this value to your .env file as ADMIN_PASSWORD_HASH\n'); + + rl.close(); +}); diff --git a/src/app/api/config/route.ts b/src/app/api/config/route.ts deleted file mode 100644 index 793e53a..0000000 --- a/src/app/api/config/route.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { NextResponse } from 'next/server'; -import { backupConfig, loadFullConfig, saveAppConfig, saveServices } from '@/src/lib/config'; -import { appConfigSchema, servicesArraySchema } from '@/src/lib/config/schema'; -import { resolveProjectPath } from '@/src/lib/config/load-config'; -import { promises as fs } from 'fs'; -import path from 'path'; - -const ADMIN_TOKEN = process.env.ADMIN_TOKEN; -const CONFIG_PATH = path.join(process.cwd(), 'config.json'); - -function jsonError(message: string, status: number) { - return NextResponse.json({ error: message }, { status }); -} - -function getBearerToken(request: Request): string { - const authorization = request.headers.get('authorization') || ''; - return authorization.startsWith('Bearer ') ? authorization.slice(7) : ''; -} - -function isAuthorized(request: Request): boolean { - if (!ADMIN_TOKEN) { - return false; - } - - const token = getBearerToken(request); - return token.length > 0 && token === ADMIN_TOKEN; -} - -export async function GET(request: Request) { - if (!ADMIN_TOKEN) { - return jsonError('Server is not configured for admin access', 500); - } - - if (!isAuthorized(request)) { - return jsonError('Invalid or missing admin token', 401); - } - - try { - const result = await loadFullConfig(); - return NextResponse.json(result); - } catch (error) { - console.error('GET /api/config failed:', error); - return jsonError('Failed to load configuration', 500); - } -} - -export async function PUT(request: Request) { - if (!ADMIN_TOKEN) { - return jsonError('Server is not configured for admin access', 500); - } - - if (!isAuthorized(request)) { - return jsonError('Invalid or missing admin token', 401); - } - - let payload: any; - - try { - payload = await request.json(); - } catch (error) { - return jsonError('Request body must be valid JSON', 400); - } - - const appConfigResult = appConfigSchema.safeParse(payload.appConfig); - if (!appConfigResult.success) { - return jsonError(`App config validation failed: ${appConfigResult.error.message}`, 400); - } - - const servicesResult = servicesArraySchema.safeParse(payload.services); - if (!servicesResult.success) { - return jsonError(`Services validation failed: ${servicesResult.error.message}`, 400); - } - - try { - await fs.access(CONFIG_PATH); - await backupConfig(); - } catch (error) { - // ignore missing config file - } - - try { - const servicesPath = resolveProjectPath(appConfigResult.data.servicesFile); - await saveAppConfig(appConfigResult.data, CONFIG_PATH); - await saveServices(servicesResult.data, servicesPath); - return NextResponse.json({ success: true }); - } catch (error) { - console.error('PUT /api/config failed:', error); - return jsonError('Failed to save configuration', 500); - } -} diff --git a/src/app/providers.tsx b/src/app/providers.tsx new file mode 100644 index 0000000..ac2e29a --- /dev/null +++ b/src/app/providers.tsx @@ -0,0 +1,8 @@ +'use client'; + +import { SessionProvider } from 'next-auth/react'; +import type { ReactNode } from 'react'; + +export function Providers({ children }: { children: ReactNode }) { + return {children}; +} \ No newline at end of file diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx new file mode 100644 index 0000000..985bb44 --- /dev/null +++ b/src/components/LoginForm.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { signIn } from 'next-auth/react'; +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; + +export default function LoginForm() { + const router = useRouter(); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setIsLoading(true); + + try { + const result = await signIn('credentials', { + username, + password, + redirect: false, + }); + + if (!result?.ok) { + setError('Benutzername oder Passwort ist falsch.'); + setPassword(''); + } else { + router.push('/admin'); + } + } catch (err) { + setError('Ein Fehler ist aufgetreten. Bitte versuche es später erneut.'); + console.error('Login error:', err); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+
+

Homelab Admin

+

Melde dich an, um die Konfiguration zu verwalten.

+
+ + {error && ( +
+ {error} +
+ )} + +
+ + + +
+ +

Internes Admin-Interface für Homelab-Dashboard

+
+
+
+ ); +} diff --git a/src/components/SetupForm.tsx b/src/components/SetupForm.tsx new file mode 100644 index 0000000..55e65e6 --- /dev/null +++ b/src/components/SetupForm.tsx @@ -0,0 +1,127 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; + +export default function SetupForm() { + const router = useRouter(); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setError(null); + const normalizedUsername = username.trim(); + + if (normalizedUsername.length < 3) { + setError('Der Benutzername muss mindestens 3 Zeichen lang sein.'); + return; + } + + if (password.length < 8) { + setError('Das Passwort muss mindestens 8 Zeichen lang sein.'); + return; + } + + if (password !== confirmPassword) { + setError('Die Passwörter stimmen nicht überein.'); + return; + } + + setIsLoading(true); + + try { + const response = await fetch('/api/setup', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username: normalizedUsername, password }), + }); + + if (!response.ok) { + const data = await response.json().catch(() => null); + setError(data?.message || 'Setup konnte nicht abgeschlossen werden.'); + return; + } + + router.push('/login'); + } catch (err) { + setError('Ein unerwarteter Fehler ist aufgetreten.'); + console.error(err); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+
+

Erstes Setup

+

Erstelle den ersten Administrator für dein Homelab-Dashboard.

+
+ + {error && ( +
+ {error} +
+ )} + +
+ + + + +
+
+
+
+ ); +} diff --git a/src/components/admin/AdminPage.tsx b/src/components/admin/AdminPage.tsx index a659257..e9c93f9 100644 --- a/src/components/admin/AdminPage.tsx +++ b/src/components/admin/AdminPage.tsx @@ -1,7 +1,11 @@ 'use client'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { useSession, signOut } from 'next-auth/react'; import type { AppConfig, Service } from '@/src/lib/config'; +import type { ServiceCheckResult } from '@/src/types/service'; +import BackupSection from './BackupSection'; +import ImportExportSection from './ImportExportSection'; const defaultService: Service = { id: '', @@ -20,31 +24,48 @@ const themes = ['auto', 'light', 'dark'] as const; const loggingLevels = ['error', 'warn', 'info', 'debug'] as const; export default function AdminPage() { + const { status } = useSession(); const [appConfig, setAppConfig] = useState(null); const [services, setServices] = useState([]); + const [originalAppConfig, setOriginalAppConfig] = useState(null); + const [originalServices, setOriginalServices] = useState([]); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [message, setMessage] = useState(null); const [error, setError] = useState(null); - const [adminToken, setAdminToken] = useState(''); const [configLoaded, setConfigLoaded] = useState(false); + const [testResults, setTestResults] = useState>({}); + const [testingServices, setTestingServices] = useState>(new Set()); + + const configsAreEqual = (config1: AppConfig | null, services1: Service[], config2: AppConfig | null, services2: Service[]): boolean => { + if (!config1 || !config2) return config1 === config2; + return JSON.stringify({ config: config1, services: services1 }) === JSON.stringify({ config: config2, services: services2 }); + }; + + const isDirty = useMemo(() => { + if (!configLoaded) return false; + return !configsAreEqual(appConfig, services, originalAppConfig, originalServices); + }, [appConfig, services, originalAppConfig, originalServices, configLoaded]); + + useEffect(() => { + if (!isDirty) return; + + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + event.preventDefault(); + event.returnValue = ''; + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + return () => window.removeEventListener('beforeunload', handleBeforeUnload); + }, [isDirty]); const loadConfig = async () => { - if (!adminToken.trim()) { - setError('Admin-Token ist erforderlich, um die Konfiguration zu laden.'); - return; - } - setLoading(true); setError(null); setMessage(null); try { - const response = await fetch('/api/config', { - headers: { - Authorization: `Bearer ${adminToken}`, - }, - }); + const response = await fetch('/api/config'); if (!response.ok) { const result = await response.json().catch(() => null); throw new Error(result?.error || `Failed to load config: ${response.statusText}`); @@ -52,6 +73,8 @@ export default function AdminPage() { const data = await response.json(); setAppConfig(data.appConfig); setServices(data.services); + setOriginalAppConfig(data.appConfig); + setOriginalServices(data.services); setConfigLoaded(true); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load config'); @@ -113,6 +136,45 @@ export default function AdminPage() { .map((item) => Number(item)); }; + const testService = async (service: Service) => { + setTestingServices((prev) => new Set(prev).add(service.id)); + setError(null); + + try { + const response = await fetch('/api/config/test-service', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(service), + }); + + if (!response.ok) { + const result = await response.json().catch(() => null); + throw new Error(result?.error || `Failed to test service: ${response.statusText}`); + } + + const result: ServiceCheckResult = await response.json(); + setTestResults((prev) => ({ ...prev, [service.id]: result })); + } catch (err) { + const errorResult: ServiceCheckResult = { + id: service.id, + status: 'offline', + responseTimeMs: 0, + httpStatus: null, + checkedAt: new Date().toISOString(), + message: err instanceof Error ? err.message : 'Test fehlgeschlagen', + }; + setTestResults((prev) => ({ ...prev, [service.id]: errorResult })); + } finally { + setTestingServices((prev) => { + const newSet = new Set(prev); + newSet.delete(service.id); + return newSet; + }); + } + }; + const validateForm = () => { if (!appConfig) { setError('Konfiguration wurde nicht geladen.'); @@ -124,11 +186,6 @@ export default function AdminPage() { return false; } - if (!adminToken) { - setError('Admin-Token ist erforderlich, um Änderungen zu speichern.'); - return false; - } - const normalizedCategories = appConfig.categories.map((category) => category.trim()).filter(Boolean); if (normalizedCategories.length === 0) { setError('Mindestens eine gültige Kategorie ist erforderlich.'); @@ -183,6 +240,13 @@ export default function AdminPage() { return true; }; + const discardChanges = () => { + setAppConfig(originalAppConfig); + setServices(originalServices); + setMessage(null); + setError(null); + }; + const saveConfig = async () => { setSaving(true); setMessage(null); @@ -204,7 +268,6 @@ export default function AdminPage() { method: 'PUT', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${adminToken}`, }, body: JSON.stringify({ appConfig: { @@ -237,6 +300,13 @@ export default function AdminPage() { [services] ); + // Load config on mount + useEffect(() => { + if (status === 'authenticated' && !configLoaded) { + loadConfig().catch((err) => console.error('Failed to load config:', err)); + } + }, [status, configLoaded]); + return (
@@ -248,36 +318,33 @@ export default function AdminPage() { Hier kannst du globale Einstellungen und Services bearbeiten.

-
- - setAdminToken(event.target.value)} - className="w-full rounded-2xl border border-slate-300 bg-slate-50 px-4 py-2 text-sm dark:border-slate-700 dark:bg-slate-800" - placeholder="Bearer-Token eingeben" - /> +
+
- {!configLoaded && !loading ? ( -
- Gib dein Admin-Token ein, um die Konfiguration zu laden. -
- ) : loading ? ( + {loading && !configLoaded ? (
Lade Konfiguration…
- ) : ( + ) : configLoaded ? ( <> {error && (
@@ -433,26 +500,115 @@ export default function AdminPage() { />
+ + {/* Test Section */} +
+
+
+

Healthcheck testen

+

+ Testet die Monitor-URL mit den konfigurierten Parametern +

+
+ +
+ + {testResults[service.id] && ( +
+
+
+ Status: + + {testResults[service.id]!.status} + +
+
+ HTTP-Status: + + {testResults[service.id]!.httpStatus ?? 'N/A'} + +
+
+ Antwortzeit: + + {testResults[service.id]!.responseTimeMs}ms + +
+
+ Geprüft: + + {new Date(testResults[service.id]!.checkedAt).toLocaleTimeString()} + +
+
+ {testResults[service.id]!.message && ( +
+ {testResults[service.id]!.message} +
+ )} +
+ )} +
))} -
-
- Änderungen werden auf die lokale Konfigurationsdatei geschrieben. + + + +
+
+
+
+ {isDirty ? ( +
+ + Ungespeicherte Änderungen +
+ ) : ( + Änderungen werden auf die lokale Konfigurationsdatei geschrieben. + )} +
+
+
+ {isDirty && ( + + )} + +
-
- )} + ) : null}
); diff --git a/src/components/admin/BackupSection.tsx b/src/components/admin/BackupSection.tsx new file mode 100644 index 0000000..a344186 --- /dev/null +++ b/src/components/admin/BackupSection.tsx @@ -0,0 +1,211 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; + +interface BackupInfo { + filename: string; + timestamp: string; + size: number; + createdAt: Date; +} + +interface BackupSectionProps { + onConfigChanged: () => void; +} + +export default function BackupSection({ onConfigChanged }: BackupSectionProps) { + const [backups, setBackups] = useState([]); + const [loading, setLoading] = useState(false); + const [creating, setCreating] = useState(false); + const [restoring, setRestoring] = useState(null); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + + const loadBackups = useCallback(async () => { + setLoading(true); + setError(null); + + try { + const response = await fetch('/api/config/backups'); + + if (!response.ok) { + const result = await response.json().catch(() => null); + throw new Error(result?.error || `Failed to load backups: ${response.statusText}`); + } + + const data = await response.json(); + setBackups(data.backups.map((backup: any) => ({ + ...backup, + createdAt: new Date(backup.createdAt), + }))); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load backups'); + } finally { + setLoading(false); + } + }, []); + + const createBackup = async () => { + setCreating(true); + setMessage(null); + setError(null); + + try { + const response = await fetch('/api/config/backup', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const result = await response.json().catch(() => null); + throw new Error(result?.error || `Failed to create backup: ${response.statusText}`); + } + + const result = await response.json(); + setMessage(result.message || 'Backup erfolgreich erstellt.'); + await loadBackups(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create backup'); + } finally { + setCreating(false); + } + }; + + const restoreBackup = async (backupFilename: string) => { + if (!confirm(`Möchten Sie wirklich die Konfiguration aus dem Backup "${backupFilename}" wiederherstellen? Die aktuelle Konfiguration wird automatisch gesichert.`)) { + return; + } + + setRestoring(backupFilename); + setMessage(null); + setError(null); + + try { + const response = await fetch('/api/config/restore', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ backupFilename }), + }); + + if (!response.ok) { + const result = await response.json().catch(() => null); + throw new Error(result?.error || `Failed to restore backup: ${response.statusText}`); + } + + const result = await response.json(); + setMessage(result.message || 'Konfiguration erfolgreich wiederhergestellt.'); + onConfigChanged(); + await loadBackups(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to restore backup'); + } finally { + setRestoring(null); + } + }; + + const formatDate = (date: Date) => { + return date.toLocaleString('de-DE', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + }; + + const formatFileSize = (bytes: number) => { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; + + useEffect(() => { + loadBackups(); + }, [loadBackups]); + + return ( +
+
+
+

Backups

+

+ Verwalte Sicherungen deiner Konfiguration. +

+
+
+ + +
+
+ + {error && ( +
+ {error} +
+ )} + + {message && ( +
+ {message} +
+ )} + +
+ {backups.length === 0 ? ( +
+ {loading ? 'Lade Backups…' : 'Keine Backups vorhanden.'} +
+ ) : ( + backups.map((backup) => ( +
+
+
+ {backup.filename} +
+
+ {formatDate(backup.createdAt)} • {formatFileSize(backup.size)} +
+
+ +
+ )) + )} +
+ +
+ Backups werden automatisch vor jeder Konfigurationsänderung erstellt. Die letzten 20 Backups werden behalten. +
+
+ ); +} \ No newline at end of file diff --git a/src/components/admin/ImportExportSection.tsx b/src/components/admin/ImportExportSection.tsx new file mode 100644 index 0000000..a8c1513 --- /dev/null +++ b/src/components/admin/ImportExportSection.tsx @@ -0,0 +1,155 @@ +'use client'; + +import { type ChangeEvent, useState } from 'react'; + +interface ImportExportSectionProps { + onConfigChanged: () => void; +} + +export default function ImportExportSection({ onConfigChanged }: ImportExportSectionProps) { + const [selectedFile, setSelectedFile] = useState(null); + const [fileName, setFileName] = useState(''); + const [importing, setImporting] = useState(false); + const [exporting, setExporting] = useState(false); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + + const handleFileChange = (event: ChangeEvent) => { + const file = event.target.files?.[0] || null; + setSelectedFile(file); + setFileName(file?.name ?? ''); + setMessage(null); + setError(null); + }; + + const exportConfig = async () => { + setExporting(true); + setMessage(null); + setError(null); + + try { + const response = await fetch('/api/config/export'); + + if (!response.ok) { + const result = await response.json().catch(() => null); + throw new Error(result?.error || `Export fehlgeschlagen: ${response.statusText}`); + } + + const blob = await response.blob(); + const downloadUrl = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = downloadUrl; + anchor.download = `homelab-config-export-${new Date().toISOString().replace(/[:.]/g, '-')}.json`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(downloadUrl); + + setMessage('Konfiguration erfolgreich exportiert.'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Export fehlgeschlagen'); + } finally { + setExporting(false); + } + }; + + const importConfig = async () => { + if (!selectedFile) { + setError('Bitte wähle eine JSON-Datei zum Import aus.'); + return; + } + + if (!confirm('Die aktuelle Konfiguration wird überschrieben. Möchtest du fortfahren?')) { + return; + } + + setImporting(true); + setMessage(null); + setError(null); + + try { + const fileText = await selectedFile.text(); + const payload = JSON.parse(fileText); + + const response = await fetch('/api/config/import', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + const result = await response.json(); + if (!response.ok) { + throw new Error(result?.error || 'Import fehlgeschlagen'); + } + + setMessage(result.message || 'Konfiguration erfolgreich importiert.'); + setSelectedFile(null); + setFileName(''); + onConfigChanged(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Import fehlgeschlagen'); + } finally { + setImporting(false); + } + }; + + return ( +
+
+
+

Import / Export

+

+ Exportiere die aktuelle Konfiguration oder importiere eine gültige JSON-Datei. +

+
+ +
+ + {error && ( +
+ {error} +
+ )} + + {message && ( +
+ {message} +
+ )} + +
+ + +
+ +

+ Der Import validiert die Datei serverseitig. Vorherige Konfiguration wird automatisch gesichert. +

+
+ ); +} diff --git a/src/data/services.ts b/src/data/services.ts index d90769a..bda8852 100644 --- a/src/data/services.ts +++ b/src/data/services.ts @@ -1,5 +1,5 @@ import 'server-only'; -import { config } from '@/src/lib/config'; +import { getConfigSync } from '@/src/lib/config'; import { servicesArraySchema } from '@/src/lib/config/schema'; import { resolveProjectPath } from '@/src/lib/config/load-config'; import type { Service } from '@/src/lib/config'; @@ -13,6 +13,7 @@ function loadServicesSync(): Service[] { if (!cachedServices) { try { const fs = require('fs'); + const config = getConfigSync(); const servicesPath = resolveProjectPath(config.servicesFile); const servicesData = fs.readFileSync(servicesPath, 'utf8'); cachedServices = servicesArraySchema.parse(JSON.parse(servicesData)); diff --git a/src/lib/config.ts b/src/lib/config.ts index ce92dc9..6726419 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,6 +1,9 @@ import 'server-only'; import { appConfigSchema, servicesArraySchema, type AppConfig, type Services } from './config/schema'; -import { resolveProjectPath } from './config/load-config'; +import { CONFIG_FILE_PATH, SERVICES_FILE_PATH } from './config/paths'; +import path from 'path'; + +const DEFAULT_SERVICES_FILE = path.relative(process.cwd(), SERVICES_FILE_PATH); // Legacy export for backward compatibility export type { AppConfig }; @@ -13,9 +16,7 @@ function loadConfigSync(): AppConfig { if (!cachedConfig) { try { const fs = require('fs'); - const path = require('path'); - const configPath = path.join(process.cwd(), 'config.json'); - const configData = fs.readFileSync(configPath, 'utf8'); + const configData = fs.readFileSync(CONFIG_FILE_PATH, 'utf8'); cachedConfig = appConfigSchema.parse(JSON.parse(configData)); } catch (error) { console.warn('Failed to load config synchronously, using defaults'); @@ -23,7 +24,7 @@ function loadConfigSync(): AppConfig { title: 'Homelab Dashboard', subtitle: 'Live-Status aller verwalteten Dienste', description: 'Live-Monitoring-Dashboard für dein Homelab', - servicesFile: 'src/data/services.json', + servicesFile: DEFAULT_SERVICES_FILE, refreshInterval: 30000, categories: ['Infrastruktur', 'Smart Home', 'Medien', 'Dokumente'], theme: 'auto', @@ -38,9 +39,7 @@ function loadServicesSync(): Services { if (!cachedServices) { try { const fs = require('fs'); - const config = loadConfigSync(); - const servicesPath = resolveProjectPath(config.servicesFile); - const servicesData = fs.readFileSync(servicesPath, 'utf8'); + const servicesData = fs.readFileSync(SERVICES_FILE_PATH, 'utf8'); cachedServices = servicesArraySchema.parse(JSON.parse(servicesData)); } catch (error) { console.warn('Failed to load services synchronously, using empty array'); @@ -50,11 +49,17 @@ function loadServicesSync(): Services { return cachedServices; } -// Export cached versions for backward compatibility -export const config: AppConfig = loadConfigSync(); -export const services: Services = loadServicesSync(); +// Legacy accessors for backward compatibility +export function getConfigSync(): AppConfig { + return loadConfigSync(); +} + +export function getServicesSync(): Services { + return loadServicesSync(); +} // Export new async functions export { loadAppConfig, loadServices, loadFullConfig } from './config/load-config'; export { saveAppConfig, saveServices, backupConfig } from './config/save-config'; +export { exportConfig, importConfig, type ConfigExportPayload } from './config/import-export'; export type { Service, Healthcheck } from './config/schema'; diff --git a/src/lib/config/backup-config.ts b/src/lib/config/backup-config.ts new file mode 100644 index 0000000..a33be86 --- /dev/null +++ b/src/lib/config/backup-config.ts @@ -0,0 +1,180 @@ +import 'server-only'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { appConfigSchema, servicesArraySchema, type AppConfig, type Services } from './schema'; +import { loadFullConfig, resolveProjectPath } from './load-config'; +import { BACKUP_DIR_PATH, CONFIG_FILE_PATH, ensureDataDir } from './paths'; + +export interface BackupInfo { + filename: string; + timestamp: string; + size: number; + createdAt: Date; +} + +export interface BackupData { + timestamp: string; + appConfig: AppConfig; + services: Services; +} + +async function backupCurrentConfigIfPossible(backupDir?: string): Promise { + try { + await createBackup(backupDir); + } catch (error) { + console.warn('Skipping pre-restore backup because the current configuration could not be backed up:', error); + } +} + +/** + * Erstellt ein Backup der aktuellen Konfiguration + */ +export async function createBackup(backupDir?: string): Promise { + await ensureDataDir(); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const backupPath = backupDir || BACKUP_DIR_PATH; + const backupFile = path.join(backupPath, `config-backup-${timestamp}.json`); + + try { + // Load current config + const { appConfig, services } = await loadFullConfig(); + + // Create backup data + const backupData: BackupData = { + timestamp, + appConfig, + services, + }; + + // Ensure directory exists + await fs.mkdir(backupPath, { recursive: true }); + + // Write backup + const backupJson = JSON.stringify(backupData, null, 2); + await fs.writeFile(backupFile, backupJson, 'utf8'); + + // Clean up old backups (keep last 20) + await cleanupOldBackups(backupPath); + + return backupFile; + } catch (error) { + console.error('Failed to create backup:', error); + throw new Error(`Backup failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +/** + * Lädt eine Liste aller verfügbaren Backups + */ +export async function listBackups(backupDir?: string): Promise { + await ensureDataDir(); + const backupPath = backupDir || BACKUP_DIR_PATH; + + try { + // Ensure directory exists + await fs.mkdir(backupPath, { recursive: true }); + + const files = await fs.readdir(backupPath); + const backupFiles = files.filter(file => file.startsWith('config-backup-') && file.endsWith('.json')); + + const backups: BackupInfo[] = []; + + for (const file of backupFiles) { + try { + const filePath = path.join(backupPath, file); + const stats = await fs.stat(filePath); + + // Extract timestamp from filename + const timestampMatch = file.match(/config-backup-(.+)\.json/); + if (!timestampMatch) continue; + + const timestamp = timestampMatch[1]; + const createdAt = stats.mtime; + + backups.push({ + filename: file, + timestamp, + size: stats.size, + createdAt, + }); + } catch (error) { + console.warn(`Failed to process backup file ${file}:`, error); + } + } + + // Sort by creation date (newest first) + return backups.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + } catch (error) { + console.error('Failed to list backups:', error); + throw new Error(`Failed to list backups: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +/** + * Stellt ein Backup als aktuelle Konfiguration wieder her + */ +export async function restoreBackup(backupFilename: string, backupDir?: string): Promise { + await ensureDataDir(); + const backupPath = backupDir || BACKUP_DIR_PATH; + + if (backupFilename.includes('/') || backupFilename.includes('\\')) { + throw new Error('Invalid backup filename'); + } + + const backupFile = path.join(backupPath, backupFilename); + + try { + // Read and parse backup file + const backupContent = await fs.readFile(backupFile, 'utf8'); + const backupData: BackupData = JSON.parse(backupContent); + + // Validate backup data + const appConfigResult = appConfigSchema.safeParse(backupData.appConfig); + if (!appConfigResult.success) { + throw new Error(`Invalid app config in backup: ${appConfigResult.error.message}`); + } + + const servicesResult = servicesArraySchema.safeParse(backupData.services); + if (!servicesResult.success) { + throw new Error(`Invalid services in backup: ${servicesResult.error.message}`); + } + + await backupCurrentConfigIfPossible(backupPath); + + // Save restored config + const { saveAppConfig, saveServices } = await import('./save-config'); + const configPath = CONFIG_FILE_PATH; + const servicesPath = resolveProjectPath(appConfigResult.data.servicesFile); + + await saveAppConfig(appConfigResult.data, configPath); + await saveServices(servicesResult.data, servicesPath); + + } catch (error) { + console.error('Failed to restore backup:', error); + throw new Error(`Restore failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +/** + * Bereinigt alte Backups (behält die letzten 20) + */ +async function cleanupOldBackups(backupDir: string): Promise { + try { + const backups = await listBackups(backupDir); + if (backups.length <= 20) return; + + const toDelete = backups.slice(20); // Keep first 20 (newest) + + for (const backup of toDelete) { + try { + const filePath = path.join(backupDir, backup.filename); + await fs.unlink(filePath); + console.info(`Deleted old backup: ${backup.filename}`); + } catch (error) { + console.warn(`Failed to delete old backup ${backup.filename}:`, error); + } + } + } catch (error) { + console.warn('Failed to cleanup old backups:', error); + } +} diff --git a/src/lib/config/import-export.ts b/src/lib/config/import-export.ts new file mode 100644 index 0000000..a373cc8 --- /dev/null +++ b/src/lib/config/import-export.ts @@ -0,0 +1,47 @@ +import 'server-only'; +import { appConfigSchema, servicesArraySchema, type AppConfig, type Services } from './schema'; +import { createBackup } from './backup-config'; +import { loadFullConfig } from './load-config'; +import { saveAppConfig, saveServices } from './save-config'; +import { CONFIG_FILE_PATH } from './paths'; +import { resolveProjectPath } from './load-config'; + +export interface ConfigExportPayload { + appConfig: AppConfig; + services: Services; +} + +async function backupCurrentConfigIfPossible(): Promise { + try { + await createBackup(); + } catch (error) { + console.warn('Skipping pre-write backup because the current configuration could not be backed up:', error); + } +} + +export async function exportConfig(): Promise { + const { appConfig, services } = await loadFullConfig(); + return { appConfig, services }; +} + +export async function importConfig(payload: unknown): Promise { + const data = payload as { appConfig: unknown; services: unknown }; + + const appConfigResult = appConfigSchema.safeParse(data.appConfig); + if (!appConfigResult.success) { + throw new Error(`App config validation failed: ${appConfigResult.error.message}`); + } + + const servicesResult = servicesArraySchema.safeParse(data.services); + if (!servicesResult.success) { + throw new Error(`Services validation failed: ${servicesResult.error.message}`); + } + + const configPath = CONFIG_FILE_PATH; + const servicesPath = resolveProjectPath(appConfigResult.data.servicesFile); + + await backupCurrentConfigIfPossible(); + + await saveAppConfig(appConfigResult.data, configPath); + await saveServices(servicesResult.data, servicesPath); +} diff --git a/src/lib/config/load-config.ts b/src/lib/config/load-config.ts index 9f0973d..f2d35b3 100644 --- a/src/lib/config/load-config.ts +++ b/src/lib/config/load-config.ts @@ -2,13 +2,16 @@ import 'server-only'; import { promises as fs } from 'fs'; import path from 'path'; import { appConfigSchema, servicesArraySchema, type AppConfig, type Services } from './schema'; +import { CONFIG_FILE_PATH, SERVICES_FILE_PATH, ensureDataDir } from './paths'; + +const DEFAULT_SERVICES_FILE = path.relative(process.cwd(), SERVICES_FILE_PATH); // Default configurations const defaultAppConfig: AppConfig = { title: 'Homelab Dashboard', subtitle: 'Live-Status aller verwalteten Dienste', description: 'Live-Monitoring-Dashboard für dein Homelab', - servicesFile: 'src/data/services.json', + servicesFile: DEFAULT_SERVICES_FILE, refreshInterval: 30000, categories: ['Infrastruktur', 'Smart Home', 'Medien', 'Dokumente'], theme: 'auto', @@ -33,7 +36,8 @@ function resolveProjectPath(relativePath: string): string { * Lädt und validiert die App-Konfiguration */ export async function loadAppConfig(configPath?: string): Promise { - const filePath = configPath || path.join(process.cwd(), 'config.json'); + await ensureDataDir(); + const filePath = configPath || CONFIG_FILE_PATH; try { const configData = await fs.readFile(filePath, 'utf8'); @@ -44,8 +48,6 @@ export async function loadAppConfig(configPath?: string): Promise { return validatedConfig; } catch (error) { - console.warn(`Failed to load app config from ${filePath}:`, error); - // Return defaults if file doesn't exist or is invalid if (error instanceof Error && error.message.includes('ENOENT')) { console.info('Using default app configuration'); @@ -58,6 +60,7 @@ export async function loadAppConfig(configPath?: string): Promise { return defaultAppConfig; } + console.warn(`Failed to load app config from ${filePath}:`, error); throw error; } } @@ -66,7 +69,8 @@ export async function loadAppConfig(configPath?: string): Promise { * Lädt und validiert die Services-Konfiguration */ export async function loadServices(servicesPath?: string): Promise { - const filePath = servicesPath || resolveProjectPath('src/data/services.json'); + await ensureDataDir(); + const filePath = servicesPath || SERVICES_FILE_PATH; try { const servicesData = await fs.readFile(filePath, 'utf8'); @@ -77,8 +81,6 @@ export async function loadServices(servicesPath?: string): Promise { return validatedServices; } catch (error) { - console.warn(`Failed to load services from ${filePath}:`, error); - // Return empty array if file doesn't exist if (error instanceof Error && error.message.includes('ENOENT')) { console.info('No services configuration found, using empty array'); @@ -91,6 +93,7 @@ export async function loadServices(servicesPath?: string): Promise { return defaultServices; } + console.warn(`Failed to load services from ${filePath}:`, error); throw error; } } diff --git a/src/lib/config/paths.ts b/src/lib/config/paths.ts new file mode 100644 index 0000000..55e0d71 --- /dev/null +++ b/src/lib/config/paths.ts @@ -0,0 +1,40 @@ +import 'server-only'; +import path from 'path'; + +/** + * Zentrales Datenverzeichnis für persistente Daten + * Standard: /app/data in Docker, oder ./data lokal + */ +export const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data'); + +/** + * Pfad zur App-Konfigurationsdatei + */ +export const CONFIG_FILE_PATH = path.join(DATA_DIR, 'config.json'); + +/** + * Pfad zur Services-Konfigurationsdatei + */ +export const SERVICES_FILE_PATH = path.join(DATA_DIR, 'services.json'); + +/** + * Pfad zum Backup-Verzeichnis + */ +export const BACKUP_DIR_PATH = path.join(DATA_DIR, 'backups'); + +/** + * Pfad zur SQLite-Datenbank + */ +export const DATABASE_FILE_PATH = path.join(DATA_DIR, 'database.db'); + +/** + * Stellt sicher, dass das Datenverzeichnis existiert + */ +export async function ensureDataDir(): Promise { + const fs = await import('fs/promises'); + try { + await fs.access(DATA_DIR); + } catch { + await fs.mkdir(DATA_DIR, { recursive: true }); + } +} diff --git a/src/lib/config/save-config.ts b/src/lib/config/save-config.ts index 2f52570..7f7eee9 100644 --- a/src/lib/config/save-config.ts +++ b/src/lib/config/save-config.ts @@ -2,13 +2,15 @@ import 'server-only'; import { promises as fs } from 'fs'; import path from 'path'; import { appConfigSchema, servicesArraySchema, type AppConfig, type Services } from './schema'; -import { resolveProjectPath } from './load-config'; +import { CONFIG_FILE_PATH, SERVICES_FILE_PATH, ensureDataDir } from './paths'; +import { createBackup } from './backup-config'; /** * Speichert die App-Konfiguration */ export async function saveAppConfig(config: AppConfig, configPath?: string): Promise { - const filePath = configPath || path.join(process.cwd(), 'config.json'); + await ensureDataDir(); + const filePath = configPath || CONFIG_FILE_PATH; try { // Validate before saving @@ -32,7 +34,8 @@ export async function saveAppConfig(config: AppConfig, configPath?: string): Pro * Speichert die Services-Konfiguration */ export async function saveServices(services: Services, servicesPath?: string): Promise { - const filePath = servicesPath || resolveProjectPath('src/data/services.json'); + await ensureDataDir(); + const filePath = servicesPath || SERVICES_FILE_PATH; try { // Validate before saving @@ -54,34 +57,8 @@ export async function saveServices(services: Services, servicesPath?: string): P /** * Erstellt ein Backup der aktuellen Konfiguration + * @deprecated Use createBackup from backup-config.ts instead */ export async function backupConfig(backupDir?: string): Promise { - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const backupPath = backupDir || path.join(process.cwd(), 'backups'); - const backupFile = path.join(backupPath, `config-backup-${timestamp}.json`); - - try { - // Load current config - const { loadFullConfig } = await import('./load-config'); - const { appConfig, services } = await loadFullConfig(); - - // Create backup data - const backupData = { - timestamp, - appConfig, - services, - }; - - // Ensure directory exists - await fs.mkdir(backupPath, { recursive: true }); - - // Write backup - const backupJson = JSON.stringify(backupData, null, 2); - await fs.writeFile(backupFile, backupJson, 'utf8'); - - return backupFile; - } catch (error) { - console.error('Failed to create backup:', error); - throw new Error(`Backup failed: ${error instanceof Error ? error.message : 'Unknown error'}`); - } + return createBackup(backupDir); } diff --git a/src/lib/db/prisma.ts b/src/lib/db/prisma.ts new file mode 100644 index 0000000..3c98180 --- /dev/null +++ b/src/lib/db/prisma.ts @@ -0,0 +1,12 @@ +import { PrismaClient } from '@prisma/client'; + +declare global { + // eslint-disable-next-line no-var + var prisma: PrismaClient | undefined; +} + +export const prisma = globalThis.prisma ?? new PrismaClient(); + +if (process.env.NODE_ENV !== 'production') { + globalThis.prisma = prisma; +} diff --git a/src/lib/db/user.ts b/src/lib/db/user.ts new file mode 100644 index 0000000..4b3f2d7 --- /dev/null +++ b/src/lib/db/user.ts @@ -0,0 +1,40 @@ +import { prisma } from './prisma'; + +export async function getUserByUsername(username: string) { + return prisma.user.findUnique({ + where: { username }, + }); +} + +export async function createUser(username: string, passwordHash: string, role = 'admin') { + return prisma.user.create({ + data: { + username, + passwordHash, + role, + }, + }); +} + +export async function hasAnyUser() { + const count = await prisma.user.count(); + return count > 0; +} + +export async function createInitialAdmin(username: string, passwordHash: string) { + return prisma.$transaction(async (tx: any) => { + const existingUsers = await tx.user.count(); + + if (existingUsers > 0) { + throw new Error('INITIAL_ADMIN_ALREADY_EXISTS'); + } + + return tx.user.create({ + data: { + username, + passwordHash, + role: 'admin', + }, + }); + }); +} diff --git a/src/app/api/status/route.ts b/src/lib/monitor/check-service.ts similarity index 69% rename from src/app/api/status/route.ts rename to src/lib/monitor/check-service.ts index df8c678..0ff767b 100644 --- a/src/app/api/status/route.ts +++ b/src/lib/monitor/check-service.ts @@ -1,18 +1,15 @@ -import { NextResponse } from 'next/server'; -import { loadFullConfig } from '@/src/lib/config'; import type { Service } from '@/src/lib/config'; -interface ServiceCheckResult { +export interface ServiceCheckResult { id: string; status: 'online' | 'warning' | 'offline'; responseTimeMs: number; httpStatus: number | null; checkedAt: string; + message?: string; } -export const dynamic = 'force-dynamic'; - -async function checkService(service: Service): Promise { +export async function checkService(service: Service): Promise { const startTime = Date.now(); let timeoutId: ReturnType | undefined; @@ -51,31 +48,28 @@ async function checkService(service: Service): Promise { checkedAt: new Date().toISOString(), }; } catch (error) { + const responseTime = Date.now() - startTime; + let message = 'Unknown error'; + + if (error instanceof Error) { + if (error.name === 'AbortError') { + message = `Timeout after ${service.healthcheck?.timeoutMs || 5000}ms`; + } else { + message = error.message; + } + } + return { id: service.id, status: 'offline', - responseTimeMs: Date.now() - startTime, + responseTimeMs: responseTime, httpStatus: null, checkedAt: new Date().toISOString(), + message, }; } finally { if (timeoutId) { clearTimeout(timeoutId); } } -} - -export async function GET() { - try { - const { services } = await loadFullConfig(); - const results = await Promise.all(services.map((service: Service) => checkService(service))); - - return NextResponse.json(results); - } catch (error) { - console.error('Error checking services:', error); - return NextResponse.json( - { error: 'Failed to check services' }, - { status: 500 } - ); - } -} +} \ No newline at end of file diff --git a/src/types/service.ts b/src/types/service.ts index 97b4f83..ee54d28 100644 --- a/src/types/service.ts +++ b/src/types/service.ts @@ -16,7 +16,8 @@ export interface Service { export interface ServiceCheckResult { id: string; status: ServiceStatus; - responseTimeMs: number | null; + responseTimeMs: number; httpStatus: number | null; checkedAt: string; + message?: string; } \ No newline at end of file diff --git a/types/next-auth.d.ts b/types/next-auth.d.ts new file mode 100644 index 0000000..df91495 --- /dev/null +++ b/types/next-auth.d.ts @@ -0,0 +1,19 @@ +import type { DefaultSession, DefaultUser } from 'next-auth'; + +declare module 'next-auth' { + interface User extends DefaultUser { + username?: string; + } + + interface Session { + user: { + username?: string; + } & DefaultSession['user']; + } +} + +declare module 'next-auth/jwt' { + interface JWT { + username?: string; + } +} \ No newline at end of file