feat: implement Sprint 1 Alpha - full stack with Docker, NestJS, React

Docker Infrastructure:
- docker-compose.yml with Traefik 3, PostgreSQL 16, PgBouncer, Redis 7, step-ca
- docker-compose.observability.yml with Prometheus, Grafana, Loki, Tempo, Promtail
- Traefik dynamic config (TLS, security headers, CORS, compression)
- PostgreSQL init script (uuid-ossp, pgcrypto, pg_trgm extensions)
- Grafana auto-provisioned datasources (Prometheus, Loki, Tempo)

NestJS Core-Service:
- Auth module: Login (email/password), TOTP 2FA, JWT RS256, token refresh/revocation
- Users module: CRUD, bcrypt cost 12, pagination, role-based access
- Tenants module: CRUD, member management, slug validation
- Prisma schemas: core (Users, AuthProviders, Tenants, Modules, AuditLog)
                  tenant (Contacts, Activities - CRM reference for Sprint 2)
- TenantPrismaService: Dynamic per-tenant DB connections with caching
- RedisService: Token blocklist, refresh token families, generic cache
- Global JwtAuthGuard with @Public() decorator, RolesGuard, GlobalExceptionFilter
- Health endpoint with DB + Redis status checks
- Swagger API documentation (dev only)
- Multi-stage Dockerfile (dev + production)

React Frontend:
- Vite 6 + React 18 + TypeScript strict
- AuthContext with silent refresh (access token in memory, NOT localStorage)
- Login page with TOTP 2FA support
- App shell with sidebar navigation
- Admin pages: Users + Tenants management tables
- API client with automatic token refresh interceptor
- Multi-stage Dockerfile (dev + nginx production)

CI/CD Pipelines:
- ci.yml: Lint, type-check, test, build on all branches
- deploy.yml: Docker build, push to Forgejo registry, SSH deploy

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-08 15:33:36 +01:00
parent 34129401a3
commit 10f291cdda
93 changed files with 4972 additions and 8 deletions

82
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,82 @@
# ============================================================
# INSIGHT MVP - CI Pipeline (Lint, Type-Check, Test, Build)
# ============================================================
# Wird bei jedem Push und Pull Request ausgefuehrt.
# ============================================================
name: CI
on:
push:
branches: [main, develop, 'feature/**', 'fix/**', 'hotfix/**']
pull_request:
branches: [main, develop]
jobs:
# --------------------------------------------------------
# Core-Service: Lint, Type-Check, Test, Build
# --------------------------------------------------------
core-service:
name: Core-Service CI
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/core-service
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Generate Prisma Client
run: npx prisma generate --schema=prisma/core.schema.prisma
- name: Lint
run: npm run lint:check
- name: Type-Check
run: npm run typecheck
- name: Test
run: npm test -- --passWithNoTests
- name: Build
run: npm run build
# --------------------------------------------------------
# Frontend: Lint, Type-Check, Build
# --------------------------------------------------------
frontend:
name: Frontend CI
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/frontend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint:check
- name: Type-Check
run: npm run typecheck
- name: Build
run: npm run build

View file

@ -0,0 +1,108 @@
# ============================================================
# INSIGHT MVP - Deploy Pipeline
# ============================================================
# Baut Docker-Images, pusht sie in die Forgejo Registry
# und deployed auf den insight-dev-01 Server.
#
# Wird nur bei Push auf 'main' oder 'develop' ausgefuehrt.
# ============================================================
name: Deploy
on:
push:
branches: [main, develop]
jobs:
# --------------------------------------------------------
# Docker Images bauen und in Registry pushen
# --------------------------------------------------------
build-and-push:
name: Build & Push Images
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Determine Tag
id: tag
run: |
if [ "${{ github.ref_name }}" = "main" ]; then
echo "tag=latest" >> $GITHUB_OUTPUT
else
echo "tag=develop" >> $GITHUB_OUTPUT
fi
- name: Login to Container Registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
docker login git.xinion.lan -u ${{ secrets.REGISTRY_USER }} --password-stdin
# Core-Service Image
- name: Build Core-Service
run: |
docker build \
-t git.xinion.lan/gitadmin/insight-core:${{ steps.tag.outputs.tag }} \
-f packages/core-service/Dockerfile \
--target production \
packages/core-service
- name: Push Core-Service
run: docker push git.xinion.lan/gitadmin/insight-core:${{ steps.tag.outputs.tag }}
# Frontend Image
- name: Build Frontend
run: |
docker build \
-t git.xinion.lan/gitadmin/insight-frontend:${{ steps.tag.outputs.tag }} \
-f packages/frontend/Dockerfile \
--target production \
packages/frontend
- name: Push Frontend
run: docker push git.xinion.lan/gitadmin/insight-frontend:${{ steps.tag.outputs.tag }}
# --------------------------------------------------------
# Auf Server deployen
# --------------------------------------------------------
deploy:
name: Deploy to Server
runs-on: ubuntu-latest
needs: build-and-push
steps:
- name: Deploy via SSH
run: |
# SSH-Key vorbereiten
mkdir -p ~/.ssh
echo "${{ secrets.SSH_DEPLOY_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts
# Deploy-Befehle auf dem Server ausfuehren
ssh -i ~/.ssh/deploy_key ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} << 'DEPLOY'
cd ~/insight
# Registry Login
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
docker login git.xinion.lan -u ${{ secrets.REGISTRY_USER }} --password-stdin
# Neue Images pullen
docker compose pull core frontend
# Services mit neuem Image starten
docker compose up -d core frontend
# Health-Check warten
sleep 10
curl -f http://localhost:3000/health || echo "WARNUNG: Health-Check fehlgeschlagen"
# Alte Images aufraeumen
docker image prune -f
DEPLOY
- name: Verify Deployment
run: |
ssh -i ~/.ssh/deploy_key ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} \
"docker compose ps && echo '--- Deployment erfolgreich ---'"

View file

@ -53,21 +53,184 @@
- `develop`: Kein direkter Push, 1 Approval erforderlich
7. **Forgejo Setup-Anleitung erstellt** (`docs/FORGEJO_SETUP.md`)
#### 3. Server-Setup (insight-dev-01)
**Was wurde auf dem Entwicklungsserver (172.20.10.59) gemacht:**
1. **SSH Public Keys hinterlegt** in `/home/deploy/.ssh/authorized_keys`
- Deploy-Key (`insight-deploy@xinion.lan`) - fuer manuellen Zugriff
- CI/CD-Key (`insight-cicd@xinion.lan`) - fuer Forgejo Actions Pipeline
2. **SSH-Zugang getestet** - Key-basierter Login als `deploy` funktioniert
#### 4. Docker Compose & Service-Konfiguration
**Erstellte Dateien:**
1. **`docker-compose.yml`** - Alle Basis-Services:
- Traefik 3 (API Gateway, Reverse Proxy, TLS, Rate Limiting)
- PostgreSQL 16-alpine (mit Performance-Tuning fuer 8GB RAM)
- PgBouncer (Connection Pooling, Transaction Mode)
- Redis 7-alpine (Cache, Sessions, Token-Revocation)
- step-ca (Interne Certificate Authority fuer mTLS)
- Core-Service (NestJS) mit Traefik-Labels
- Frontend (React) mit Traefik-Labels
- 3 isolierte Docker-Netzwerke (insight-web, insight-db, insight-cache)
- Health-Checks fuer alle Services
2. **`docker-compose.observability.yml`** - Monitoring-Stack:
- Prometheus (Metrics-Sammlung, 30 Tage Retention)
- Grafana (Dashboards, automatisch provisionierte Datenquellen)
- Loki (Log-Aggregation)
- Promtail (Docker Log-Collector)
- Tempo (Distributed Tracing, OTLP gRPC)
- cAdvisor (Container-Metriken)
- PostgreSQL Exporter (DB-Metriken)
3. **Konfigurationsdateien:**
- `config/traefik/dynamic/tls.yml` - TLS-Konfiguration
- `config/traefik/dynamic/middlewares.yml` - Security-Headers, CORS, Compression
- `config/prometheus/prometheus.yml` - Scrape-Konfiguration
- `config/loki/loki.yml` - Log-Storage-Konfiguration
- `config/promtail/promtail.yml` - Docker-Log-Collector
- `config/tempo/tempo.yml` - Tracing-Backend
- `config/grafana/provisioning/datasources/datasources.yml` - Auto-Provisioning
- `config/postgres/init/01-init-extensions.sql` - DB-Extensions (uuid-ossp, pgcrypto, pg_trgm)
#### 5. NestJS Core-Service Implementierung
**Projekt-Setup:**
- `package.json` mit allen Dependencies (NestJS 10, Prisma 6, Passport, JWT, bcrypt, TOTP)
- `tsconfig.json` mit strict: true, noImplicitAny, strictNullChecks
- `Dockerfile` (Multi-Stage: base, deps, development, build, production)
- `nest-cli.json` Konfiguration
**Implementierte Module:**
1. **Auth-Modul** (`src/core/auth/`)
- `AuthService`: Login (E-Mail/Passwort), Token-Refresh, Logout, Token-Revocation
- `AuthController`: POST /login, /refresh, /logout
- `JwtStrategy`: RS256 Passport-Strategy
- `TotpService`: TOTP 2FA (Google Authenticator kompatibel)
- `LoginDto`: Validierung mit class-validator
- Account-Lockout nach 5 Fehlversuchen (15 Min Sperre)
- Refresh-Token als HttpOnly/Secure/SameSite=Strict Cookie
- Token-Rotation mit Redis-basierter Familien-Erkennung
2. **Users-Modul** (`src/core/users/`)
- `UsersService`: CRUD, Bcrypt Cost 12, Passwort-Hashing
- `UsersController`: GET /me, GET /users, POST /users, PATCH /users/:id
- DTOs: CreateUserDto, UpdateUserDto
- Paginierung mit Meta-Informationen
3. **Tenants-Modul** (`src/core/tenants/`)
- `TenantsService`: CRUD, Member-Management
- `TenantsController`: CRUD + POST /:id/members, DELETE /:id/members/:userId
- DTOs: CreateTenantDto, UpdateTenantDto, AddMemberDto
- Slug-Validierung (URL-freundlich)
4. **Infrastruktur-Module:**
- `PrismaService`: PostgreSQL-Verbindung (platform_core)
- `TenantPrismaService`: Dynamische Tenant-DB-Verbindungen mit Caching
- `RedisService`: Token-Blocklist, Refresh-Token-Familien, generischer Cache
- `HealthController`: GET /health (DB + Redis Status)
5. **Common (Guards, Decorators, Filter):**
- `@Public()` Decorator fuer oeffentliche Routen
- `@Roles()` Decorator fuer rollenbasierte Zugriffskontrolle
- `@CurrentUser()` Decorator fuer User-Extraktion aus JWT
- `JwtAuthGuard` (global) mit Token-Revocation-Check
- `RolesGuard` fuer Rollen-Pruefung
- `GlobalExceptionFilter` fuer strukturierte Fehlerantworten
6. **Config:**
- `validateConfig()` mit class-validator fuer Umgebungsvariablen
#### 6. Prisma-Schemas
1. **`core.schema.prisma`** (platform_core Datenbank):
- `User` - Plattform-Benutzer (mit Login-Tracking, 2FA)
- `AuthProvider` - Multi-Provider Auth (LOCAL, MS_SSO, M2M)
- `Tenant` - Mandanten mit JSON-Settings
- `TenantMembership` - User-Tenant-Zuordnung (M:N)
- `Module` - Verfuegbare Plattform-Module
- `TenantModule` - Module pro Tenant
- `AuditLog` - Plattform-weites Audit-Log
2. **`tenant.schema.prisma`** (tenant_{slug} Datenbanken):
- `Contact` - CRM-Kontakte (Person/Organisation)
- `Activity` - CRM-Aktivitaeten (Notiz, Anruf, E-Mail, Meeting, Task)
- Referenz-Schema fuer Sprint 2 (CRM-Modul)
#### 7. React Frontend-Shell
**Projekt-Setup:**
- `package.json` mit React 18, Vite 6, React Router 6, TanStack Query 5, Axios
- `tsconfig.json` mit strict TypeScript
- `vite.config.ts` mit API-Proxy und Path-Aliases
- `Dockerfile` (Multi-Stage: development mit Vite, production mit Nginx)
- `nginx.conf` (SPA-Routing, Security-Headers, Caching)
**Implementierte Komponenten:**
1. **Auth-System** (`src/auth/`)
- `AuthContext` + `useAuth()` Hook: Login, Logout, Silent Refresh
- `LoginPage`: E-Mail/Passwort + optionaler TOTP 2FA-Code
- Access-Token NUR im Memory (kein localStorage!)
- Automatischer Silent Refresh via HttpOnly Cookie
2. **API-Client** (`src/api/client.ts`)
- Axios-Instanz mit automatischem Token-Handling
- Request-Interceptor fuer Authorization-Header
- Response-Interceptor fuer automatisches Token-Refresh bei 401
3. **App-Shell** (`src/shell/`)
- `App`: React Router mit PrivateRoute-Guard
- `AppLayout`: Sidebar-Navigation + Outlet
- `DashboardPage`: Willkommens-Seite
4. **Admin-Bereich** (`src/admin/`)
- `AdminUsersPage`: Benutzer-Tabelle mit Paginierung
- `AdminTenantsPage`: Mandanten-Tabelle mit Member-Count
5. **Styling:**
- CSS Custom Properties (Farben, Layout, Schatten, Radien)
- CSS Modules fuer komponentenspezifische Styles
- Responsive Sidebar-Layout
#### 8. CI/CD Pipelines
1. **`.forgejo/workflows/ci.yml`** - Continuous Integration:
- Trigger: Push auf alle Branches + Pull Requests
- Core-Service: npm ci, Prisma Generate, Lint, Type-Check, Test, Build
- Frontend: npm ci, Lint, Type-Check, Build
2. **`.forgejo/workflows/deploy.yml`** - Deployment:
- Trigger: Push auf main/develop
- Build Docker-Images (Core + Frontend)
- Push in Forgejo Container Registry
- SSH-Deploy auf insight-dev-01
- Health-Check Verifizierung
---
### Naechste Schritte
- [ ] SSH Deploy Keys auf insight-dev-01 Server hinterlegen
- [ ] `docker-compose.yml` erstellen (alle Basis-Services)
- [ ] `docker-compose.observability.yml` erstellen
- [ ] NestJS Core-Service implementieren (Auth, Users, Tenants)
- [ ] Prisma-Schemas erstellen (core + tenant)
- [ ] React Frontend-Shell implementieren
- [ ] CI/CD Pipelines (.forgejo/workflows/) definieren
- [x] SSH Deploy Keys auf insight-dev-01 Server hinterlegen
- [x] `docker-compose.yml` erstellen (alle Basis-Services)
- [x] `docker-compose.observability.yml` erstellen
- [x] NestJS Core-Service implementieren (Auth, Users, Tenants)
- [x] Prisma-Schemas erstellen (core + tenant)
- [x] React Frontend-Shell implementieren
- [x] CI/CD Pipelines (.forgejo/workflows/) definieren
- [ ] Docker + Docker Compose auf insight-dev-01 installieren
- [ ] .env-Datei auf Server anlegen (echte Passwoerter)
- [ ] JWT RS256 Schluessel generieren (fuer Token-Signierung)
- [ ] Erste Prisma-Migration ausfuehren
- [ ] Platform-Admin User anlegen (Seed)
- [ ] Erster End-to-End Test (Login -> Dashboard)
---
### Offene Fragen / Abhaengigkeiten
- DNS-Eintrag `insight-dev.xinion.lan` muss auf 172.20.10.59 zeigen
- Deploy Keys muessen auf insight-dev-01 in authorized_keys hinterlegt werden

View file

@ -0,0 +1,47 @@
# ============================================================
# Grafana - Datenquellen (automatisch provisioniert)
# ============================================================
apiVersion: 1
datasources:
# Prometheus - Metriken
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false
# Loki - Logs
- name: Loki
type: loki
access: proxy
url: http://loki:3100
editable: false
jsonData:
derivedFields:
- datasourceUid: tempo
matcherRegex: "traceId=(\\w+)"
name: TraceID
url: "$${__value.raw}"
# Tempo - Traces
- name: Tempo
type: tempo
access: proxy
uid: tempo
url: http://tempo:3200
editable: false
jsonData:
tracesToLogs:
datasourceUid: loki
tags: ['service']
mappedTags: [{ key: 'service.name', value: 'service' }]
mapTagNamesEnabled: true
filterByTraceID: true
tracesToMetrics:
datasourceUid: prometheus
tags: [{ key: 'service.name', value: 'service' }]
serviceMap:
datasourceUid: prometheus

37
config/loki/loki.yml Normal file
View file

@ -0,0 +1,37 @@
# ============================================================
# Loki - Log-Aggregation Konfiguration
# ============================================================
auth_enabled: false
server:
http_listen_port: 3100
common:
path_prefix: /loki
storage:
filesystem:
chunks_directory: /loki/chunks
rules_directory: /loki/rules
replication_factor: 1
ring:
kvstore:
store: inmemory
schema_config:
configs:
- from: 2024-01-01
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
limits_config:
retention_period: 30d
reject_old_samples: true
reject_old_samples_max_age: 168h
analytics:
reporting_enabled: false

View file

@ -0,0 +1,22 @@
-- ============================================================
-- PostgreSQL Initialisierung
-- ============================================================
-- Wird automatisch beim ersten Start ausgefuehrt.
-- Erstellt benoetigte Extensions fuer die platform_core DB.
-- ============================================================
-- UUID-Generierung (v4)
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Kryptographische Funktionen (fuer Token-Hashing etc.)
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- Trigram-Index fuer Volltextsuche
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
-- Bestaetigungsmeldung
DO $$
BEGIN
RAISE NOTICE 'INSIGHT: PostgreSQL Extensions erfolgreich installiert.';
END
$$;

View file

@ -0,0 +1,40 @@
# ============================================================
# Prometheus - Konfiguration
# ============================================================
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_timeout: 10s
scrape_configs:
# Traefik Metriken
- job_name: "traefik"
static_configs:
- targets: ["traefik:8082"]
# Core-Service Metriken (NestJS)
- job_name: "core-service"
metrics_path: /metrics
static_configs:
- targets: ["core:3000"]
# PostgreSQL Exporter
- job_name: "postgres"
static_configs:
- targets: ["postgres-exporter:9187"]
# cAdvisor (Container-Metriken)
- job_name: "cadvisor"
static_configs:
- targets: ["cadvisor:8080"]
# Redis (wenn Redis Exporter hinzugefuegt wird)
# - job_name: "redis"
# static_configs:
# - targets: ["redis-exporter:9121"]
# Prometheus Self-Monitoring
- job_name: "prometheus"
static_configs:
- targets: ["localhost:9090"]

View file

@ -0,0 +1,51 @@
# ============================================================
# Promtail - Log-Collector Konfiguration
# ============================================================
# Sammelt Docker Container Logs und sendet sie an Loki.
# ============================================================
server:
http_listen_port: 9080
grpc_listen_port: 0
positions:
filename: /tmp/positions.yaml
clients:
- url: http://loki:3100/loki/api/v1/push
scrape_configs:
# Docker Container Logs
- job_name: docker
docker_sd_configs:
- host: unix:///var/run/docker.sock
refresh_interval: 5s
filters:
- name: label
values: ["com.docker.compose.project=insight"]
relabel_configs:
# Container-Name als Label
- source_labels: ['__meta_docker_container_name']
regex: '/(.*)'
target_label: container
# Compose-Service-Name als Label
- source_labels: ['__meta_docker_container_label_com_docker_compose_service']
target_label: service
# Log-Stream (stdout/stderr)
- source_labels: ['__meta_docker_container_log_stream']
target_label: stream
pipeline_stages:
# JSON-Logs parsen (NestJS)
- json:
expressions:
level: level
message: message
timestamp: timestamp
context: context
- labels:
level:
context:
- timestamp:
source: timestamp
format: RFC3339

41
config/tempo/tempo.yml Normal file
View file

@ -0,0 +1,41 @@
# ============================================================
# Tempo - Distributed Tracing Konfiguration
# ============================================================
server:
http_listen_port: 3200
distributor:
receivers:
otlp:
protocols:
grpc:
endpoint: "0.0.0.0:4317"
http:
endpoint: "0.0.0.0:4318"
storage:
trace:
backend: local
local:
path: /var/tempo/traces
wal:
path: /var/tempo/wal
metrics_generator:
registry:
external_labels:
source: tempo
cluster: insight-dev
storage:
path: /var/tempo/generator/wal
remote_write:
- url: http://prometheus:9090/api/v1/write
send_exemplars: true
overrides:
defaults:
metrics_generator:
processors:
- service-graphs
- span-metrics

View file

@ -0,0 +1,52 @@
# ============================================================
# Traefik - Globale Middlewares
# ============================================================
http:
middlewares:
# Security-Headers fuer alle Responses
security-headers:
headers:
browserXssFilter: true
contentTypeNosniff: true
frameDeny: true
stsIncludeSubdomains: true
stsPreload: true
stsSeconds: 31536000
customFrameOptionsValue: "SAMEORIGIN"
referrerPolicy: "strict-origin-when-cross-origin"
contentSecurityPolicy: >-
default-src 'self';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob:;
font-src 'self';
connect-src 'self' wss://insight-dev.xinion.lan;
frame-ancestors 'self';
# CORS fuer API
cors-api:
headers:
accessControlAllowMethods:
- GET
- POST
- PUT
- PATCH
- DELETE
- OPTIONS
accessControlAllowHeaders:
- Content-Type
- Authorization
- X-Tenant-ID
- X-Request-ID
accessControlAllowOriginList:
- "https://insight-dev.xinion.lan"
accessControlMaxAge: 86400
accessControlAllowCredentials: true
addVaryHeader: true
# Kompression
gzip-compress:
compress:
excludedContentTypes:
- text/event-stream

View file

@ -0,0 +1,24 @@
# ============================================================
# Traefik - Dynamische TLS-Konfiguration
# ============================================================
# Fuer die Alpha-Phase verwenden wir ein selbst-signiertes
# Zertifikat. Spaeter wird step-ca als ACME-Provider genutzt.
# ============================================================
tls:
options:
default:
minVersion: VersionTLS12
cipherSuites:
- TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
- TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
- TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
- TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
sniStrict: false
stores:
default:
defaultGeneratedCert:
resolver: default
domain:
main: "insight-dev.xinion.lan"

View file

@ -0,0 +1,185 @@
# ============================================================
# INSIGHT MVP - Docker Compose (Observability-Stack)
# ============================================================
# Ergaenzt docker-compose.yml um Monitoring, Logging & Tracing.
#
# Nutzung:
# docker compose -f docker-compose.yml -f docker-compose.observability.yml up -d
#
# Grafana (nur via SSH-Tunnel):
# ssh -L 3001:localhost:3001 -i .keys/deploy_ed25519 deploy@172.20.10.59
# Browser: http://localhost:3001
# ============================================================
networks:
insight-web:
external: true
insight-db:
external: true
volumes:
prometheus-data:
name: insight-prometheus-data
grafana-data:
name: insight-grafana-data
loki-data:
name: insight-loki-data
tempo-data:
name: insight-tempo-data
services:
# --------------------------------------------------------
# Prometheus - Metrics-Sammlung & -Speicherung
# --------------------------------------------------------
prometheus:
image: prom/prometheus:latest
container_name: insight-prometheus
restart: unless-stopped
command:
- "--config.file=/etc/prometheus/prometheus.yml"
- "--storage.tsdb.path=/prometheus"
- "--storage.tsdb.retention.time=30d"
- "--web.enable-lifecycle"
volumes:
- ./config/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus-data:/prometheus
networks:
- insight-web
ports:
- "127.0.0.1:9090:9090"
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:9090/-/ready"]
interval: 30s
timeout: 5s
retries: 3
# --------------------------------------------------------
# Grafana - Dashboards & Alerting
# --------------------------------------------------------
grafana:
image: grafana/grafana:latest
container_name: insight-grafana
restart: unless-stopped
environment:
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?GRAFANA_ADMIN_PASSWORD muss gesetzt sein}
GF_SERVER_ROOT_URL: "http://localhost:3001"
GF_SERVER_HTTP_PORT: 3001
# Datenquellen per Provisioning
GF_PATHS_PROVISIONING: /etc/grafana/provisioning
# Keine anonyme Nutzung
GF_AUTH_ANONYMOUS_ENABLED: "false"
# Logging
GF_LOG_LEVEL: info
volumes:
- grafana-data:/var/lib/grafana
- ./config/grafana/provisioning:/etc/grafana/provisioning:ro
networks:
- insight-web
ports:
- "127.0.0.1:3001:3001"
depends_on:
prometheus:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:3001/api/health || exit 1"]
interval: 30s
timeout: 5s
retries: 3
# --------------------------------------------------------
# Loki - Log-Aggregation
# --------------------------------------------------------
loki:
image: grafana/loki:latest
container_name: insight-loki
restart: unless-stopped
command: -config.file=/etc/loki/loki.yml
volumes:
- ./config/loki/loki.yml:/etc/loki/loki.yml:ro
- loki-data:/loki
networks:
- insight-web
ports:
- "127.0.0.1:3100:3100"
healthcheck:
test: ["CMD-SHELL", "wget --spider -q http://localhost:3100/ready || exit 1"]
interval: 30s
timeout: 5s
retries: 3
# --------------------------------------------------------
# Promtail - Log-Collector (liest Docker Logs)
# --------------------------------------------------------
promtail:
image: grafana/promtail:latest
container_name: insight-promtail
restart: unless-stopped
command: -config.file=/etc/promtail/promtail.yml
volumes:
- ./config/promtail/promtail.yml:/etc/promtail/promtail.yml:ro
- /var/log:/var/log:ro
- /var/lib/docker/containers:/var/lib/docker/containers:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- insight-web
depends_on:
- loki
# --------------------------------------------------------
# Tempo - Distributed Tracing
# --------------------------------------------------------
tempo:
image: grafana/tempo:latest
container_name: insight-tempo
restart: unless-stopped
command: -config.file=/etc/tempo/tempo.yml
volumes:
- ./config/tempo/tempo.yml:/etc/tempo/tempo.yml:ro
- tempo-data:/var/tempo
networks:
- insight-web
ports:
- "127.0.0.1:3200:3200" # Tempo API
- "127.0.0.1:4317:4317" # OTLP gRPC
healthcheck:
test: ["CMD-SHELL", "wget --spider -q http://localhost:3200/ready || exit 1"]
interval: 30s
timeout: 5s
retries: 3
# --------------------------------------------------------
# cAdvisor - Container-Metriken
# --------------------------------------------------------
cadvisor:
image: gcr.io/cadvisor/cadvisor:latest
container_name: insight-cadvisor
restart: unless-stopped
privileged: true
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
- /dev/disk/:/dev/disk:ro
networks:
- insight-web
ports:
- "127.0.0.1:8081:8080"
# --------------------------------------------------------
# PostgreSQL Exporter - DB-Metriken fuer Prometheus
# --------------------------------------------------------
postgres-exporter:
image: prometheuscommunity/postgres-exporter:latest
container_name: insight-postgres-exporter
restart: unless-stopped
environment:
DATA_SOURCE_NAME: "postgresql://${DB_USER:-insight}:${DB_PASSWORD}@postgres:5432/${DB_NAME:-platform_core}?sslmode=disable"
networks:
- insight-web
- insight-db
ports:
- "127.0.0.1:9187:9187"
depends_on:
- postgres

307
docker-compose.yml Normal file
View file

@ -0,0 +1,307 @@
# ============================================================
# INSIGHT MVP - Docker Compose (Basis-Services)
# ============================================================
# Startet alle Kerndienste der INSIGHT-Plattform.
# Observability-Stack separat: docker-compose.observability.yml
#
# Nutzung:
# docker compose up -d
# docker compose logs -f core
# docker compose ps
# ============================================================
networks:
insight-web:
driver: bridge
name: insight-web
insight-db:
driver: bridge
name: insight-db
internal: true
insight-cache:
driver: bridge
name: insight-cache
internal: true
volumes:
postgres-data:
name: insight-postgres-data
redis-data:
name: insight-redis-data
step-ca-data:
name: insight-step-ca-data
traefik-certs:
name: insight-traefik-certs
services:
# --------------------------------------------------------
# Traefik - API Gateway / Reverse Proxy
# --------------------------------------------------------
traefik:
image: traefik:3
container_name: insight-traefik
restart: unless-stopped
command:
# API & Dashboard
- "--api.dashboard=true"
- "--api.insecure=true"
# Entrypoints
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
# HTTP -> HTTPS Redirect
- "--entrypoints.web.http.redirections.entryPoint.to=websecure"
- "--entrypoints.web.http.redirections.entryPoint.scheme=https"
# Docker Provider
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.docker.network=insight-web"
# File Provider (fuer dynamische Konfiguration)
- "--providers.file.directory=/etc/traefik/dynamic"
- "--providers.file.watch=true"
# TLS (self-signed fuer Dev)
- "--entrypoints.websecure.http.tls=true"
# Logging
- "--log.level=INFO"
- "--accesslog=true"
- "--accesslog.format=json"
# Metrics fuer Prometheus
- "--metrics.prometheus=true"
- "--metrics.prometheus.entryPoint=metrics"
- "--entrypoints.metrics.address=:8082"
ports:
- "80:80"
- "443:443"
- "8080:8080" # Dashboard (nur intern)
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./config/traefik/dynamic:/etc/traefik/dynamic:ro
- traefik-certs:/certs
networks:
- insight-web
labels:
- "traefik.enable=true"
# Dashboard Route (nur intern)
- "traefik.http.routers.dashboard.rule=Host(`traefik.insight-dev.xinion.lan`)"
- "traefik.http.routers.dashboard.service=api@internal"
- "traefik.http.routers.dashboard.entrypoints=websecure"
healthcheck:
test: ["CMD", "traefik", "healthcheck"]
interval: 30s
timeout: 5s
retries: 3
# --------------------------------------------------------
# PostgreSQL - Datenbank
# --------------------------------------------------------
postgres:
image: postgres:16-alpine
container_name: insight-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${DB_USER:-insight}
POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD muss gesetzt sein}
POSTGRES_DB: ${DB_NAME:-platform_core}
# Performance-Tuning fuer 8GB RAM VM
POSTGRES_INITDB_ARGS: "--data-checksums"
volumes:
- postgres-data:/var/lib/postgresql/data
- ./config/postgres/init:/docker-entrypoint-initdb.d:ro
networks:
- insight-db
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-insight} -d ${DB_NAME:-platform_core}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
# Performance-Tuning via Command
command:
- "postgres"
- "-c"
- "shared_buffers=2GB"
- "-c"
- "effective_cache_size=6GB"
- "-c"
- "work_mem=16MB"
- "-c"
- "maintenance_work_mem=512MB"
- "-c"
- "max_connections=200"
- "-c"
- "log_min_duration_statement=500"
- "-c"
- "log_statement=ddl"
# --------------------------------------------------------
# PgBouncer - Connection Pooling
# --------------------------------------------------------
pgbouncer:
image: edoburu/pgbouncer:latest
container_name: insight-pgbouncer
restart: unless-stopped
environment:
DATABASE_URL: "postgres://${DB_USER:-insight}:${DB_PASSWORD}@postgres:5432/${DB_NAME:-platform_core}"
POOL_MODE: transaction
MAX_CLIENT_CONN: 500
DEFAULT_POOL_SIZE: 25
MIN_POOL_SIZE: 5
RESERVE_POOL_SIZE: 5
SERVER_RESET_QUERY: "DISCARD ALL"
SERVER_CHECK_DELAY: 30
SERVER_CHECK_QUERY: "SELECT 1"
AUTH_TYPE: scram-sha-256
networks:
- insight-db
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 6432"]
interval: 10s
timeout: 5s
retries: 3
# --------------------------------------------------------
# Redis - Cache, Sessions, Event Bus
# --------------------------------------------------------
redis:
image: redis:7-alpine
container_name: insight-redis
restart: unless-stopped
command: >
redis-server
--requirepass ${REDIS_PASSWORD:-}
--maxmemory 512mb
--maxmemory-policy allkeys-lru
--appendonly yes
--appendfsync everysec
--save 60 1000
--save 300 100
volumes:
- redis-data:/data
networks:
- insight-cache
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-}", "ping"]
interval: 10s
timeout: 5s
retries: 3
# --------------------------------------------------------
# step-ca - Interne Certificate Authority (mTLS)
# --------------------------------------------------------
step-ca:
image: smallstep/step-ca:latest
container_name: insight-step-ca
restart: unless-stopped
environment:
DOCKER_STEPCA_INIT_NAME: "INSIGHT Internal CA"
DOCKER_STEPCA_INIT_DNS_NAMES: "step-ca,localhost"
DOCKER_STEPCA_INIT_REMOTE_MANAGEMENT: "true"
DOCKER_STEPCA_INIT_ACME: "true"
volumes:
- step-ca-data:/home/step
networks:
- insight-web
- insight-db
- insight-cache
healthcheck:
test: ["CMD", "step", "ca", "health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
# --------------------------------------------------------
# Core-Service - NestJS Backend
# --------------------------------------------------------
core:
build:
context: ./packages/core-service
dockerfile: Dockerfile
target: development
container_name: insight-core
restart: unless-stopped
environment:
NODE_ENV: ${NODE_ENV:-development}
APP_PORT: ${APP_PORT:-3000}
APP_URL: ${APP_URL:-https://insight-dev.xinion.lan}
FRONTEND_URL: ${FRONTEND_URL:-https://insight-dev.xinion.lan}
LOG_LEVEL: ${LOG_LEVEL:-info}
# Database (via PgBouncer)
DATABASE_URL: "postgresql://${DB_USER:-insight}:${DB_PASSWORD}@pgbouncer:6432/${DB_NAME:-platform_core}"
# Database (direkt fuer Migrations)
DATABASE_URL_DIRECT: "postgresql://${DB_USER:-insight}:${DB_PASSWORD}@postgres:5432/${DB_NAME:-platform_core}"
# Redis
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
# JWT
JWT_PRIVATE_KEY_PATH: ${JWT_PRIVATE_KEY_PATH:-/app/keys/jwt-private.pem}
JWT_PUBLIC_KEY_PATH: ${JWT_PUBLIC_KEY_PATH:-/app/keys/jwt-public.pem}
JWT_ACCESS_TOKEN_EXPIRY: ${JWT_ACCESS_TOKEN_EXPIRY:-15m}
JWT_REFRESH_TOKEN_EXPIRY: ${JWT_REFRESH_TOKEN_EXPIRY:-7d}
JWT_ISSUER: ${JWT_ISSUER:-insight-platform}
# Bcrypt
BCRYPT_COST: ${BCRYPT_COST:-12}
# CORS
CORS_ORIGINS: ${CORS_ORIGINS:-https://insight-dev.xinion.lan}
# Rate Limiting
THROTTLE_TTL: ${THROTTLE_TTL:-60000}
THROTTLE_LIMIT: ${THROTTLE_LIMIT:-200}
networks:
- insight-web
- insight-db
- insight-cache
depends_on:
pgbouncer:
condition: service_healthy
redis:
condition: service_healthy
labels:
- "traefik.enable=true"
# API Routing
- "traefik.http.routers.core-api.rule=Host(`insight-dev.xinion.lan`) && PathPrefix(`/api`)"
- "traefik.http.routers.core-api.entrypoints=websecure"
- "traefik.http.routers.core-api.service=core-api"
- "traefik.http.services.core-api.loadbalancer.server.port=3000"
# Health-Endpunkt (ohne Auth)
- "traefik.http.routers.core-health.rule=Host(`insight-dev.xinion.lan`) && Path(`/health`)"
- "traefik.http.routers.core-health.entrypoints=websecure"
- "traefik.http.routers.core-health.service=core-api"
# Rate Limiting Middleware
- "traefik.http.middlewares.api-ratelimit.ratelimit.average=100"
- "traefik.http.middlewares.api-ratelimit.ratelimit.burst=50"
- "traefik.http.routers.core-api.middlewares=api-ratelimit"
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"]
interval: 15s
timeout: 5s
retries: 3
start_period: 30s
# --------------------------------------------------------
# Frontend - React App (via Nginx)
# --------------------------------------------------------
frontend:
build:
context: ./packages/frontend
dockerfile: Dockerfile
target: development
container_name: insight-frontend
restart: unless-stopped
networks:
- insight-web
labels:
- "traefik.enable=true"
# Frontend Routing (Catch-All nach API)
- "traefik.http.routers.frontend.rule=Host(`insight-dev.xinion.lan`)"
- "traefik.http.routers.frontend.entrypoints=websecure"
- "traefik.http.routers.frontend.service=frontend"
- "traefik.http.routers.frontend.priority=1"
- "traefik.http.services.frontend.loadbalancer.server.port=8080"
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/ || exit 1"]
interval: 30s
timeout: 5s
retries: 3

View file

@ -0,0 +1,7 @@
node_modules
dist
coverage
.env
*.md
.git
.gitignore

View file

@ -0,0 +1,56 @@
# ============================================================
# INSIGHT Core-Service - Multi-Stage Dockerfile
# ============================================================
# --- Base Stage ---
FROM node:20-alpine AS base
WORKDIR /app
RUN apk add --no-cache openssl
# --- Dependencies Stage ---
FROM base AS deps
COPY package.json package-lock.json* ./
RUN npm ci --ignore-scripts
# Prisma Generate braucht die Schema-Dateien
COPY prisma ./prisma
RUN npx prisma generate --schema=prisma/core.schema.prisma
# --- Development Stage ---
FROM base AS development
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate --schema=prisma/core.schema.prisma
EXPOSE 3000
CMD ["npm", "run", "start:dev"]
# --- Build Stage ---
FROM base AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# --- Production Stage ---
FROM base AS production
WORKDIR /app
ENV NODE_ENV=production
# Nur Produktions-Dependencies
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev --ignore-scripts
# Prisma Client generieren
COPY prisma ./prisma
RUN npx prisma generate --schema=prisma/core.schema.prisma
# Kompilierter Code
COPY --from=build /app/dist ./dist
# Non-root User
RUN addgroup -g 1001 -S nodejs && \
adduser -S nestjs -u 1001 -G nodejs
USER nestjs
EXPOSE 3000
CMD ["node", "dist/main"]

View file

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View file

@ -0,0 +1,93 @@
{
"name": "@insight/core-service",
"version": "0.1.0",
"description": "INSIGHT MVP - Core Service (NestJS Backend)",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,test}/**/*.ts\" --fix",
"lint:check": "eslint \"{src,test}/**/*.ts\"",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:e2e": "jest --config ./test/jest-e2e.json",
"typecheck": "tsc --noEmit",
"prisma:generate": "prisma generate --schema=prisma/core.schema.prisma",
"prisma:migrate": "prisma migrate dev --schema=prisma/core.schema.prisma",
"prisma:migrate:deploy": "prisma migrate deploy --schema=prisma/core.schema.prisma",
"prisma:studio": "prisma studio --schema=prisma/core.schema.prisma"
},
"dependencies": {
"@nestjs/common": "^10.4.0",
"@nestjs/config": "^3.2.0",
"@nestjs/core": "^10.4.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.0",
"@nestjs/schedule": "^4.1.0",
"@nestjs/swagger": "^7.4.0",
"@nestjs/throttler": "^6.2.0",
"@prisma/client": "^6.4.0",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"cookie-parser": "^1.4.7",
"helmet": "^8.0.0",
"ioredis": "^5.4.1",
"otplib": "^12.0.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"uuid": "^10.0.0"
},
"devDependencies": {
"@nestjs/cli": "^10.4.0",
"@nestjs/schematics": "^10.1.0",
"@nestjs/testing": "^10.4.0",
"@types/bcrypt": "^5.0.2",
"@types/cookie-parser": "^1.4.7",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.12",
"@types/node": "^22.0.0",
"@types/passport-jwt": "^4.0.1",
"@types/qrcode": "^1.5.5",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"eslint": "^9.0.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.0",
"jest": "^29.7.0",
"prettier": "^3.3.0",
"prisma": "^6.4.0",
"source-map-support": "^0.5.21",
"ts-jest": "^29.2.0",
"ts-loader": "^9.5.0",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.6.0"
},
"jest": {
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": ["**/*.(t|j)s"],
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/$1"
}
}
}

View file

@ -0,0 +1,181 @@
// ============================================================
// INSIGHT MVP - Core Schema (platform_core Datenbank)
// ============================================================
// Zentrale Plattform-Tabellen: Users, Tenants, Auth, Modules
// Kein raw SQL - nur Prisma!
// ============================================================
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DATABASE_URL_DIRECT")
}
// --------------------------------------------------------
// User - Plattform-Benutzer
// --------------------------------------------------------
model User {
id String @id @default(uuid()) @db.Uuid
email String @unique @db.VarChar(255)
firstName String @map("first_name") @db.VarChar(100)
lastName String @map("last_name") @db.VarChar(100)
role String @default("USER") @db.VarChar(50) // PLATFORM_ADMIN, TENANT_ADMIN, USER
isActive Boolean @default(true) @map("is_active")
// 2FA
twoFactorEnabled Boolean @default(false) @map("two_factor_enabled")
// Login-Tracking
lastLogin DateTime? @map("last_login")
failedLoginAttempts Int @default(0) @map("failed_login_attempts")
lastFailedLogin DateTime? @map("last_failed_login")
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relationen
authProvider AuthProvider[]
tenantMemberships TenantMembership[]
auditLogs AuditLog[]
@@map("users")
}
// --------------------------------------------------------
// AuthProvider - Authentifizierungs-Provider pro User
// --------------------------------------------------------
// Unterstuetzt mehrere Auth-Methoden pro User:
// LOCAL (Passwort), MS_SSO (spaeter), M2M (Machine-to-Machine)
model AuthProvider {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
provider String @db.VarChar(50) // LOCAL, MS_SSO, M2M
providerId String? @map("provider_id") @db.VarChar(255) // Externe ID (z.B. MS Object ID)
passwordHash String? @map("password_hash") @db.VarChar(255)
totpSecret String? @map("totp_secret") @db.VarChar(255) // Verschluesselt
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relationen
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, provider])
@@map("auth_providers")
}
// --------------------------------------------------------
// Tenant - Mandant
// --------------------------------------------------------
model Tenant {
id String @id @default(uuid()) @db.Uuid
name String @db.VarChar(200)
slug String @unique @db.VarChar(50) // URL-freundlich, fuer DB-Name
isActive Boolean @default(true) @map("is_active")
// Mandant-Einstellungen (JSON)
settings Json @default("{}")
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relationen
members TenantMembership[]
modules TenantModule[]
@@map("tenants")
}
// --------------------------------------------------------
// TenantMembership - User-Tenant-Zuordnung (M:N)
// --------------------------------------------------------
model TenantMembership {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
tenantRole String @default("MEMBER") @map("tenant_role") @db.VarChar(50) // ADMIN, MEMBER, VIEWER
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relationen
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@unique([userId, tenantId])
@@map("tenant_memberships")
}
// --------------------------------------------------------
// Module - Verfuegbare Plattform-Module
// --------------------------------------------------------
model Module {
id String @id @default(uuid()) @db.Uuid
key String @unique @db.VarChar(50) // z.B. "crm", "project", "docs"
name String @db.VarChar(100)
description String? @db.Text
version String @default("1.0.0") @db.VarChar(20)
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relationen
tenantModules TenantModule[]
@@map("modules")
}
// --------------------------------------------------------
// TenantModule - Welcher Tenant welche Module nutzt
// --------------------------------------------------------
model TenantModule {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
moduleId String @map("module_id") @db.Uuid
isActive Boolean @default(true) @map("is_active")
// Modul-spezifische Konfiguration pro Tenant
config Json @default("{}")
activatedAt DateTime @default(now()) @map("activated_at")
// Relationen
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade)
@@unique([tenantId, moduleId])
@@map("tenant_modules")
}
// --------------------------------------------------------
// AuditLog - Plattform-weites Audit-Log
// --------------------------------------------------------
model AuditLog {
id String @id @default(uuid()) @db.Uuid
userId String? @map("user_id") @db.Uuid
action String @db.VarChar(100) // z.B. "user.login", "tenant.create"
entity String @db.VarChar(100) // z.B. "User", "Tenant"
entityId String? @map("entity_id") @db.VarChar(255)
details Json? // Zusaetzliche Informationen
ipAddress String? @map("ip_address") @db.VarChar(45)
userAgent String? @map("user_agent") @db.Text
createdAt DateTime @default(now()) @map("created_at")
// Relationen
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([userId])
@@index([action])
@@index([entity, entityId])
@@index([createdAt])
@@map("audit_logs")
}

View file

@ -0,0 +1,111 @@
// ============================================================
// INSIGHT MVP - Tenant Schema (tenant_{slug} Datenbanken)
// ============================================================
// Jeder Mandant hat eine eigene Datenbank mit diesen Tabellen.
// Schema wird per Prisma Migrate auf neue Tenant-DBs angewandt.
//
// HINWEIS: Dieses Schema wird derzeit als Referenz gefuehrt.
// Die tatsaechliche Migration auf Tenant-DBs erfolgt
// in Sprint 2+ wenn das CRM-Modul implementiert wird.
// ============================================================
generator client {
provider = "prisma-client-js"
output = "../node_modules/.prisma/tenant-client"
}
datasource db {
provider = "postgresql"
url = env("TENANT_DATABASE_URL")
}
// --------------------------------------------------------
// Contact - CRM-Kontakte (Personen & Organisationen)
// --------------------------------------------------------
model Contact {
id String @id @default(uuid()) @db.Uuid
type ContactType @default(PERSON)
// Person
firstName String? @map("first_name") @db.VarChar(100)
lastName String? @map("last_name") @db.VarChar(100)
// Organisation
companyName String? @map("company_name") @db.VarChar(200)
// Kontaktdaten
email String? @db.VarChar(255)
phone String? @db.VarChar(50)
mobile String? @db.VarChar(50)
website String? @db.VarChar(500)
// Adresse
street String? @db.VarChar(200)
zip String? @db.VarChar(20)
city String? @db.VarChar(100)
state String? @db.VarChar(100)
country String? @default("DE") @db.VarChar(2)
// Zusaetzlich
notes String? @db.Text
tags String[] @default([])
isActive Boolean @default(true) @map("is_active")
// Wer hat erstellt/bearbeitet (User-IDs aus platform_core)
createdBy String @map("created_by") @db.Uuid
updatedBy String? @map("updated_by") @db.Uuid
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relationen
activities Activity[]
@@index([email])
@@index([companyName])
@@index([lastName, firstName])
@@map("contacts")
}
enum ContactType {
PERSON
ORGANIZATION
}
// --------------------------------------------------------
// Activity - CRM-Aktivitaeten (Notizen, Anrufe, E-Mails)
// --------------------------------------------------------
model Activity {
id String @id @default(uuid()) @db.Uuid
contactId String @map("contact_id") @db.Uuid
type ActivityType
subject String @db.VarChar(500)
description String? @db.Text
// Terminierung
scheduledAt DateTime? @map("scheduled_at")
completedAt DateTime? @map("completed_at")
// Wer hat erstellt (User-ID aus platform_core)
createdBy String @map("created_by") @db.Uuid
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relationen
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
@@index([contactId])
@@index([type])
@@index([scheduledAt])
@@map("activities")
}
enum ActivityType {
NOTE
CALL
EMAIL
MEETING
TASK
}

View file

@ -0,0 +1,57 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { ScheduleModule } from '@nestjs/schedule';
import { HealthModule } from './health/health.module';
import { PrismaModule } from './prisma/prisma.module';
import { RedisModule } from './redis/redis.module';
import { AuthModule } from './core/auth/auth.module';
import { UsersModule } from './core/users/users.module';
import { TenantsModule } from './core/tenants/tenants.module';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { validateConfig } from './config/env.validation';
@Module({
imports: [
// Konfiguration (.env)
ConfigModule.forRoot({
isGlobal: true,
validate: validateConfig,
}),
// Rate Limiting
ThrottlerModule.forRoot([
{
ttl: parseInt(process.env.THROTTLE_TTL ?? '60000', 10),
limit: parseInt(process.env.THROTTLE_LIMIT ?? '200', 10),
},
]),
// Cron-Jobs
ScheduleModule.forRoot(),
// Infrastruktur-Module
PrismaModule,
RedisModule,
// Feature-Module
HealthModule,
AuthModule,
UsersModule,
TenantsModule,
],
providers: [
// Global Guards: Alle Routen sind standardmaessig geschuetzt
// Oeffentliche Routen muessen mit @Public() dekoriert werden
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
})
export class AppModule {}

View file

@ -0,0 +1,35 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { Request } from 'express';
export interface JwtPayload {
sub: string; // User-ID
email: string;
role: string;
tenantId?: string;
tenantSlug?: string;
jti: string; // Token-ID fuer Revocation
iat: number;
exp: number;
}
/**
* @CurrentUser() - Extrahiert den authentifizierten User aus dem Request.
*
* Beispiel:
* @Get('profile')
* getProfile(@CurrentUser() user: JwtPayload) {
* return user;
* }
*
* @Get('profile/id')
* getProfileId(@CurrentUser('sub') userId: string) {
* return userId;
* }
*/
export const CurrentUser = createParamDecorator(
(data: keyof JwtPayload | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest<Request>();
const user = request.user as JwtPayload;
return data ? user?.[data] : user;
},
);

View file

@ -0,0 +1,3 @@
export { Public, IS_PUBLIC_KEY } from './public.decorator';
export { Roles, ROLES_KEY } from './roles.decorator';
export { CurrentUser, type JwtPayload } from './current-user.decorator';

View file

@ -0,0 +1,16 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
/**
* @Public() - Markiert eine Route als oeffentlich zugaenglich.
*
* Standardmaessig sind ALLE Routen durch den JwtAuthGuard geschuetzt.
* Nur explizit mit @Public() dekorierte Routen sind ohne Token erreichbar.
*
* Beispiel:
* @Get('health')
* @Public()
* healthCheck() { ... }
*/
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View file

@ -0,0 +1,13 @@
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
/**
* @Roles() - Beschraenkt den Zugriff auf bestimmte Plattform-Rollen.
*
* Beispiel:
* @Roles('PLATFORM_ADMIN', 'TENANT_ADMIN')
* @Get('admin/users')
* listUsers() { ... }
*/
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);

View file

@ -0,0 +1,88 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
interface ErrorResponse {
statusCode: number;
message: string;
error: string;
timestamp: string;
path: string;
requestId?: string;
}
/**
* GlobalExceptionFilter - Faengt alle unbehandelten Exceptions.
*
* - Strukturierte Fehlerantworten im JSON-Format
* - Logging aller Fehler
* - Keine internen Details in Produktions-Fehlern
*/
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let statusCode: number;
let message: string;
let error: string;
if (exception instanceof HttpException) {
statusCode = exception.getStatus();
const exceptionResponse = exception.getResponse();
if (typeof exceptionResponse === 'string') {
message = exceptionResponse;
error = exception.name;
} else if (typeof exceptionResponse === 'object') {
const resp = exceptionResponse as Record<string, unknown>;
message = Array.isArray(resp.message)
? resp.message.join(', ')
: (resp.message as string) || exception.message;
error = (resp.error as string) || exception.name;
} else {
message = exception.message;
error = exception.name;
}
} else if (exception instanceof Error) {
statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
message =
process.env.NODE_ENV === 'production'
? 'Interner Serverfehler'
: exception.message;
error = 'InternalServerError';
// Stack-Trace loggen fuer unerwartete Fehler
this.logger.error(
`Unbehandelter Fehler: ${exception.message}`,
exception.stack,
);
} else {
statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
message = 'Unbekannter Fehler';
error = 'UnknownError';
this.logger.error('Unbekannter Fehler:', exception);
}
const errorResponse: ErrorResponse = {
statusCode,
message,
error,
timestamp: new Date().toISOString(),
path: request.url,
requestId: request.headers['x-request-id'] as string | undefined,
};
response.status(statusCode).json(errorResponse);
}
}

View file

@ -0,0 +1,65 @@
import {
Injectable,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { RedisService } from '../../redis/redis.service';
import { JwtPayload } from '../decorators/current-user.decorator';
/**
* JwtAuthGuard - Globaler Guard fuer JWT-Authentifizierung.
*
* - Standardmaessig aktiv auf ALLEN Routen
* - @Public() dekorierte Routen werden uebersprungen
* - Prueft zusaetzlich ob der Token revoked wurde (Redis Blocklist)
*/
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(
private readonly reflector: Reflector,
private readonly redis: RedisService,
) {
super();
}
async canActivate(context: ExecutionContext): Promise<boolean> {
// @Public() Routen ueberspringen
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
// JWT validieren (Passport Strategy)
const canActivate = await super.canActivate(context);
if (!canActivate) {
return false;
}
// Token-Revocation pruefen (Redis Blocklist)
const request = context.switchToHttp().getRequest();
const user = request.user as JwtPayload;
if (user?.jti) {
const isBlocked = await this.redis.isTokenBlocked(user.jti);
if (isBlocked) {
throw new UnauthorizedException('Token wurde widerrufen');
}
}
return true;
}
handleRequest<T>(err: Error | null, user: T, info: Error | undefined): T {
if (err || !user) {
throw err || new UnauthorizedException('Zugriff verweigert');
}
return user;
}
}

View file

@ -0,0 +1,37 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
import { JwtPayload } from '../decorators/current-user.decorator';
/**
* RolesGuard - Prueft ob der User die erforderliche Rolle hat.
*
* Wird zusammen mit @Roles() verwendet:
* @Roles('PLATFORM_ADMIN')
* @UseGuards(RolesGuard)
* @Get('admin/dashboard')
*/
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest();
const user = request.user as JwtPayload;
if (!user?.role) {
return false;
}
return requiredRoles.includes(user.role);
}
}

View file

@ -0,0 +1,98 @@
import { plainToInstance } from 'class-transformer';
import {
IsEnum,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
Min,
Max,
validateSync,
} from 'class-validator';
enum Environment {
Development = 'development',
Production = 'production',
Test = 'test',
}
class EnvironmentVariables {
@IsEnum(Environment)
NODE_ENV: Environment = Environment.Development;
@IsNumber()
@Min(1)
@Max(65535)
APP_PORT = 3000;
@IsString()
@IsNotEmpty()
APP_URL = 'https://insight-dev.xinion.lan';
// Datenbank
@IsString()
@IsNotEmpty()
DATABASE_URL!: string;
@IsString()
@IsOptional()
DATABASE_URL_DIRECT?: string;
// Redis
@IsString()
REDIS_HOST = 'redis';
@IsNumber()
REDIS_PORT = 6379;
@IsString()
@IsOptional()
REDIS_PASSWORD?: string;
// JWT
@IsString()
@IsNotEmpty()
JWT_PRIVATE_KEY_PATH = '/app/keys/jwt-private.pem';
@IsString()
@IsNotEmpty()
JWT_PUBLIC_KEY_PATH = '/app/keys/jwt-public.pem';
@IsString()
JWT_ACCESS_TOKEN_EXPIRY = '15m';
@IsString()
JWT_REFRESH_TOKEN_EXPIRY = '7d';
@IsString()
JWT_ISSUER = 'insight-platform';
// Bcrypt
@IsNumber()
@Min(10)
@Max(14)
BCRYPT_COST = 12;
// Rate Limiting
@IsNumber()
THROTTLE_TTL = 60000;
@IsNumber()
THROTTLE_LIMIT = 200;
}
export function validateConfig(
config: Record<string, unknown>,
): EnvironmentVariables {
const validatedConfig = plainToInstance(EnvironmentVariables, config, {
enableImplicitConversion: true,
});
const errors = validateSync(validatedConfig, {
skipMissingProperties: false,
});
if (errors.length > 0) {
throw new Error(`Config validation error: ${errors.toString()}`);
}
return validatedConfig;
}

View file

@ -0,0 +1,121 @@
import {
Controller,
Post,
Body,
Res,
Req,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { Public } from '../../common/decorators/public.decorator';
import { CurrentUser, JwtPayload } from '../../common/decorators/current-user.decorator';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
@ApiTags('Authentifizierung')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
/**
* POST /api/v1/auth/login
* Login mit E-Mail + Passwort (+ optionaler TOTP-Code).
*/
@Post('login')
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Login mit E-Mail und Passwort' })
async login(
@Body() dto: LoginDto,
@Res({ passthrough: true }) res: Response,
) {
const result = await this.authService.login(dto);
// 2FA erforderlich - kein Token setzen
if (result.requiresTwoFactor) {
return {
requiresTwoFactor: true,
message: 'Bitte 2FA-Code eingeben',
};
}
// Refresh-Token als HttpOnly Cookie setzen (NICHT im localStorage!)
// Regel: Kein localStorage fuer Tokens
this.setRefreshTokenCookie(res, result.accessToken);
return {
accessToken: result.accessToken,
user: result.user,
};
}
/**
* POST /api/v1/auth/refresh
* Token-Refresh via HttpOnly Cookie.
*/
@Post('refresh')
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Access-Token erneuern (Silent Refresh)' })
async refresh(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
) {
const refreshToken = req.cookies?.refresh_token as string | undefined;
if (!refreshToken) {
res.status(HttpStatus.UNAUTHORIZED).json({
message: 'Kein Refresh-Token vorhanden',
});
return;
}
const tokens = await this.authService.refreshTokens(refreshToken);
this.setRefreshTokenCookie(res, tokens.refreshToken);
return {
accessToken: tokens.accessToken,
};
}
/**
* POST /api/v1/auth/logout
* Logout: Tokens invalidieren, Cookie loeschen.
*/
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiBearerAuth('access-token')
@ApiOperation({ summary: 'Logout und Token-Invalidierung' })
async logout(
@CurrentUser() user: JwtPayload,
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
) {
const refreshToken = req.cookies?.refresh_token as string | undefined;
await this.authService.logout(user, refreshToken);
// Refresh-Token Cookie loeschen
res.clearCookie('refresh_token', {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/api/v1/auth',
});
return { message: 'Erfolgreich abgemeldet' };
}
/**
* Setzt das Refresh-Token als HttpOnly, Secure, SameSite=Strict Cookie.
*/
private setRefreshTokenCookie(res: Response, refreshToken: string): void {
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: true, // Nur HTTPS
sameSite: 'strict',
path: '/api/v1/auth', // Nur fuer Auth-Endpunkte
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 Tage
});
}
}

View file

@ -0,0 +1,47 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import * as fs from 'fs';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { TotpService } from './totp.service';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => {
const privateKeyPath = config.get<string>(
'JWT_PRIVATE_KEY_PATH',
'/app/keys/jwt-private.pem',
);
const publicKeyPath = config.get<string>(
'JWT_PUBLIC_KEY_PATH',
'/app/keys/jwt-public.pem',
);
return {
privateKey: fs.readFileSync(privateKeyPath, 'utf8'),
publicKey: fs.readFileSync(publicKeyPath, 'utf8'),
signOptions: {
algorithm: 'RS256',
issuer: config.get<string>('JWT_ISSUER', 'insight-platform'),
expiresIn: config.get<string>('JWT_ACCESS_TOKEN_EXPIRY', '15m'),
},
verifyOptions: {
algorithms: ['RS256'],
issuer: config.get<string>('JWT_ISSUER', 'insight-platform'),
},
};
},
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, TotpService],
exports: [AuthService, JwtModule],
})
export class AuthModule {}

View file

@ -0,0 +1,264 @@
import {
Injectable,
UnauthorizedException,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';
import { v4 as uuidv4 } from 'uuid';
import { PrismaService } from '../../prisma/prisma.service';
import { RedisService } from '../../redis/redis.service';
import { TotpService } from './totp.service';
import { LoginDto } from './dto/login.dto';
import { JwtPayload } from '../../common/decorators/current-user.decorator';
interface TokenPair {
accessToken: string;
refreshToken: string;
}
interface LoginResponse {
accessToken: string;
user: {
id: string;
email: string;
firstName: string;
lastName: string;
role: string;
};
requiresTwoFactor?: boolean;
}
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
private readonly redis: RedisService,
private readonly config: ConfigService,
private readonly totp: TotpService,
) {}
/**
* Login mit E-Mail und Passwort.
* Gibt AccessToken + Refresh-Token (HttpOnly Cookie) zurueck.
*/
async login(dto: LoginDto): Promise<LoginResponse> {
// User finden
const user = await this.prisma.user.findUnique({
where: { email: dto.email.toLowerCase() },
include: {
authProvider: true,
tenantMemberships: {
include: { tenant: true },
where: { isActive: true },
take: 1,
},
},
});
if (!user || !user.isActive) {
// Generische Fehlermeldung (kein Hinweis ob User existiert)
throw new UnauthorizedException('Ungueltige Anmeldedaten');
}
// Passwort pruefen (nur fuer lokale Auth)
const localAuth = user.authProvider.find((ap) => ap.provider === 'LOCAL');
if (!localAuth?.passwordHash) {
throw new UnauthorizedException('Ungueltige Anmeldedaten');
}
const passwordValid = await bcrypt.compare(
dto.password,
localAuth.passwordHash,
);
if (!passwordValid) {
// Failed Login zaehlen
await this.prisma.user.update({
where: { id: user.id },
data: {
failedLoginAttempts: { increment: 1 },
lastFailedLogin: new Date(),
},
});
throw new UnauthorizedException('Ungueltige Anmeldedaten');
}
// Account-Sperre pruefen (nach 5 Fehlversuchen)
if (user.failedLoginAttempts >= 5) {
const lockoutEnd = user.lastFailedLogin
? new Date(user.lastFailedLogin.getTime() + 15 * 60 * 1000) // 15 Min Sperre
: null;
if (lockoutEnd && lockoutEnd > new Date()) {
throw new ForbiddenException(
'Account temporaer gesperrt. Versuchen Sie es in 15 Minuten erneut.',
);
}
}
// 2FA pruefen
if (user.twoFactorEnabled) {
if (!dto.totpCode) {
return {
accessToken: '',
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
},
requiresTwoFactor: true,
};
}
const totpValid = this.totp.verify(
dto.totpCode,
localAuth.totpSecret ?? '',
);
if (!totpValid) {
throw new UnauthorizedException('Ungueltiger 2FA-Code');
}
}
// Erfolgreicher Login: Counter zuruecksetzen
await this.prisma.user.update({
where: { id: user.id },
data: {
failedLoginAttempts: 0,
lastLogin: new Date(),
},
});
// Tenant-Info
const primaryMembership = user.tenantMemberships[0];
// Tokens generieren
const tokens = await this.generateTokenPair({
sub: user.id,
email: user.email,
role: user.role,
tenantId: primaryMembership?.tenant.id,
tenantSlug: primaryMembership?.tenant.slug,
});
this.logger.log(`Login erfolgreich: ${user.email}`);
return {
accessToken: tokens.accessToken,
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
},
};
}
/**
* Refresh-Token gegen neues Token-Paar tauschen.
*/
async refreshTokens(refreshToken: string): Promise<TokenPair> {
try {
const payload = this.jwt.verify<JwtPayload>(refreshToken);
// Refresh-Token-Familie pruefen (Token-Rotation)
const isValid = await this.redis.isRefreshTokenFamilyValid(
payload.sub,
payload.jti,
);
if (!isValid) {
// Moeglicherweise Refresh-Token-Diebstahl: alle invalidieren
this.logger.warn(
`Verdaechtiger Refresh-Token Wiederverwendung fuer User ${payload.sub}`,
);
await this.redis.invalidateAllRefreshTokens(payload.sub);
throw new UnauthorizedException('Refresh Token ungueltig');
}
// Alten Refresh-Token invalidieren
await this.redis.blockToken(payload.jti, 7 * 24 * 60 * 60);
// Neue Tokens generieren
return this.generateTokenPair({
sub: payload.sub,
email: payload.email,
role: payload.role,
tenantId: payload.tenantId,
tenantSlug: payload.tenantSlug,
});
} catch (error) {
if (error instanceof UnauthorizedException) throw error;
throw new UnauthorizedException('Refresh Token ungueltig oder abgelaufen');
}
}
/**
* Logout: Access- und Refresh-Token invalidieren.
*/
async logout(accessToken: JwtPayload, refreshToken?: string): Promise<void> {
// Access-Token blocken (Restlaufzeit)
const ttl = accessToken.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await this.redis.blockToken(accessToken.jti, ttl);
}
// Refresh-Token blocken
if (refreshToken) {
try {
const refreshPayload = this.jwt.verify<JwtPayload>(refreshToken);
const refreshTtl =
refreshPayload.exp - Math.floor(Date.now() / 1000);
if (refreshTtl > 0) {
await this.redis.blockToken(refreshPayload.jti, refreshTtl);
}
} catch {
// Refresh-Token ist bereits abgelaufen - ignorieren
}
}
this.logger.log(`Logout: User ${accessToken.sub}`);
}
/**
* Token-Paar generieren (Access + Refresh).
*/
private async generateTokenPair(
payload: Omit<JwtPayload, 'jti' | 'iat' | 'exp'>,
): Promise<TokenPair> {
const accessJti = uuidv4();
const refreshJti = uuidv4();
const accessToken = this.jwt.sign({
...payload,
jti: accessJti,
});
const refreshExpiry = this.config.get<string>(
'JWT_REFRESH_TOKEN_EXPIRY',
'7d',
);
const refreshToken = this.jwt.sign(
{
...payload,
jti: refreshJti,
},
{ expiresIn: refreshExpiry },
);
// Refresh-Token-Familie in Redis registrieren
await this.redis.setRefreshTokenFamily(
payload.sub,
refreshJti,
7 * 24 * 60 * 60, // 7 Tage
);
return { accessToken, refreshToken };
}
}

View file

@ -0,0 +1,30 @@
import { IsEmail, IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class LoginDto {
@ApiProperty({
example: 'admin@xinion.de',
description: 'E-Mail-Adresse des Benutzers',
})
@IsEmail({}, { message: 'Bitte gueltige E-Mail-Adresse angeben' })
@IsNotEmpty({ message: 'E-Mail darf nicht leer sein' })
email!: string;
@ApiProperty({
example: 'SicheresPasswort123!',
description: 'Passwort (mindestens 8 Zeichen)',
})
@IsString()
@IsNotEmpty({ message: 'Passwort darf nicht leer sein' })
@MinLength(8, { message: 'Passwort muss mindestens 8 Zeichen lang sein' })
password!: string;
@ApiProperty({
example: '123456',
description: 'TOTP 2FA-Code (nur wenn 2FA aktiviert)',
required: false,
})
@IsOptional()
@IsString()
totpCode?: string;
}

View file

@ -0,0 +1,47 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import * as fs from 'fs';
import { JwtPayload } from '../../../common/decorators/current-user.decorator';
/**
* JwtStrategy - Passport-Strategy fuer RS256 JWT-Validierung.
*
* Extrahiert den Token aus dem Authorization-Header (Bearer Token).
* Validiert Signatur (RS256), Issuer und Expiration automatisch.
*/
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
const publicKeyPath = config.get<string>(
'JWT_PUBLIC_KEY_PATH',
'/app/keys/jwt-public.pem',
);
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: fs.readFileSync(publicKeyPath, 'utf8'),
algorithms: ['RS256'],
issuer: config.get<string>('JWT_ISSUER', 'insight-platform'),
});
}
/**
* Wird nach erfolgreicher JWT-Validierung aufgerufen.
* Der Return-Wert landet in request.user.
*/
validate(payload: JwtPayload): JwtPayload {
return {
sub: payload.sub,
email: payload.email,
role: payload.role,
tenantId: payload.tenantId,
tenantSlug: payload.tenantSlug,
jti: payload.jti,
iat: payload.iat,
exp: payload.exp,
};
}
}

View file

@ -0,0 +1,54 @@
import { Injectable, Logger } from '@nestjs/common';
import { authenticator } from 'otplib';
import * as QRCode from 'qrcode';
/**
* TotpService - TOTP 2FA (Time-based One-Time Password).
*
* Verwendet den Google Authenticator kompatiblen TOTP-Algorithmus.
* Secrets werden verschluesselt in der Datenbank gespeichert.
*/
@Injectable()
export class TotpService {
private readonly logger = new Logger(TotpService.name);
constructor() {
// TOTP Konfiguration
authenticator.options = {
step: 30, // 30 Sekunden
window: 1, // +/- 1 Schritt Toleranz
digits: 6,
};
}
/**
* Neues TOTP-Secret generieren.
*/
generateSecret(): string {
return authenticator.generateSecret();
}
/**
* QR-Code als Data-URL generieren (fuer Authenticator-App Setup).
*/
async generateQrCode(email: string, secret: string): Promise<string> {
const otpauthUrl = authenticator.keyuri(
email,
'INSIGHT Platform',
secret,
);
return QRCode.toDataURL(otpauthUrl);
}
/**
* TOTP-Code verifizieren.
*/
verify(token: string, secret: string): boolean {
try {
return authenticator.verify({ token, secret });
} catch {
this.logger.warn('TOTP-Verifizierung fehlgeschlagen');
return false;
}
}
}

View file

@ -0,0 +1,19 @@
import { IsNotEmpty, IsOptional, IsString, IsUUID, IsIn } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class AddMemberDto {
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
@IsUUID()
@IsNotEmpty()
userId!: string;
@ApiProperty({
example: 'MEMBER',
enum: ['ADMIN', 'MEMBER', 'VIEWER'],
required: false,
})
@IsOptional()
@IsString()
@IsIn(['ADMIN', 'MEMBER', 'VIEWER'])
role?: string;
}

View file

@ -0,0 +1,37 @@
import {
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
IsObject,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateTenantDto {
@ApiProperty({ example: 'ACME Corporation' })
@IsString()
@IsNotEmpty()
@MaxLength(200)
name!: string;
@ApiProperty({
example: 'acme-corp',
description: 'URL-freundlicher Kurzname (a-z, 0-9, Bindestrich)',
})
@IsString()
@IsNotEmpty()
@MaxLength(50)
@Matches(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/, {
message: 'Slug: nur Kleinbuchstaben, Zahlen und Bindestriche erlaubt',
})
slug!: string;
@ApiProperty({
example: { locale: 'de-DE', timezone: 'Europe/Berlin' },
required: false,
})
@IsOptional()
@IsObject()
settings?: Record<string, unknown>;
}

View file

@ -0,0 +1,29 @@
import {
IsBoolean,
IsObject,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class UpdateTenantDto {
@ApiProperty({ example: 'ACME Corp. GmbH', required: false })
@IsOptional()
@IsString()
@MaxLength(200)
name?: string;
@ApiProperty({ example: true, required: false })
@IsOptional()
@IsBoolean()
isActive?: boolean;
@ApiProperty({
example: { locale: 'de-DE', timezone: 'Europe/Berlin' },
required: false,
})
@IsOptional()
@IsObject()
settings?: Record<string, unknown>;
}

View file

@ -0,0 +1,110 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { TenantsService } from './tenants.service';
import { CreateTenantDto } from './dto/create-tenant.dto';
import { UpdateTenantDto } from './dto/update-tenant.dto';
import { AddMemberDto } from './dto/add-member.dto';
import { Roles } from '../../common/decorators/roles.decorator';
import { RolesGuard } from '../../common/guards/roles.guard';
@ApiTags('Mandanten')
@ApiBearerAuth('access-token')
@Controller('tenants')
export class TenantsController {
constructor(private readonly tenantsService: TenantsService) {}
/**
* GET /api/v1/tenants
* Alle Mandanten auflisten (nur PLATFORM_ADMIN).
*/
@Get()
@Roles('PLATFORM_ADMIN')
@UseGuards(RolesGuard)
@ApiOperation({ summary: 'Alle Mandanten auflisten (Admin)' })
async findAll(
@Query('page') page?: number,
@Query('limit') limit?: number,
) {
return this.tenantsService.findAll(page ?? 1, limit ?? 20);
}
/**
* GET /api/v1/tenants/:id
* Mandant nach ID (nur PLATFORM_ADMIN).
*/
@Get(':id')
@Roles('PLATFORM_ADMIN')
@UseGuards(RolesGuard)
@ApiOperation({ summary: 'Mandant nach ID abrufen (Admin)' })
async findById(@Param('id', ParseUUIDPipe) id: string) {
return this.tenantsService.findById(id);
}
/**
* POST /api/v1/tenants
* Neuen Mandant anlegen (nur PLATFORM_ADMIN).
*/
@Post()
@Roles('PLATFORM_ADMIN')
@UseGuards(RolesGuard)
@ApiOperation({ summary: 'Neuen Mandanten anlegen (Admin)' })
async create(@Body() dto: CreateTenantDto) {
return this.tenantsService.create(dto);
}
/**
* PATCH /api/v1/tenants/:id
* Mandant aktualisieren (nur PLATFORM_ADMIN).
*/
@Patch(':id')
@Roles('PLATFORM_ADMIN')
@UseGuards(RolesGuard)
@ApiOperation({ summary: 'Mandant aktualisieren (Admin)' })
async update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateTenantDto,
) {
return this.tenantsService.update(id, dto);
}
/**
* POST /api/v1/tenants/:id/members
* User einem Mandant zuweisen (nur PLATFORM_ADMIN).
*/
@Post(':id/members')
@Roles('PLATFORM_ADMIN')
@UseGuards(RolesGuard)
@ApiOperation({ summary: 'Benutzer zu Mandant hinzufuegen (Admin)' })
async addMember(
@Param('id', ParseUUIDPipe) tenantId: string,
@Body() dto: AddMemberDto,
) {
return this.tenantsService.addMember(tenantId, dto.userId, dto.role);
}
/**
* DELETE /api/v1/tenants/:id/members/:userId
* User aus Mandant entfernen (nur PLATFORM_ADMIN).
*/
@Delete(':id/members/:userId')
@Roles('PLATFORM_ADMIN')
@UseGuards(RolesGuard)
@ApiOperation({ summary: 'Benutzer aus Mandant entfernen (Admin)' })
async removeMember(
@Param('id', ParseUUIDPipe) tenantId: string,
@Param('userId', ParseUUIDPipe) userId: string,
) {
return this.tenantsService.removeMember(tenantId, userId);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { TenantsController } from './tenants.controller';
import { TenantsService } from './tenants.service';
@Module({
controllers: [TenantsController],
providers: [TenantsService],
exports: [TenantsService],
})
export class TenantsModule {}

View file

@ -0,0 +1,166 @@
import {
Injectable,
ConflictException,
NotFoundException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateTenantDto } from './dto/create-tenant.dto';
import { UpdateTenantDto } from './dto/update-tenant.dto';
@Injectable()
export class TenantsService {
private readonly logger = new Logger(TenantsService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Neuen Tenant (Mandant) anlegen.
*/
async create(dto: CreateTenantDto) {
// Slug-Duplikat pruefen
const existing = await this.prisma.tenant.findUnique({
where: { slug: dto.slug },
});
if (existing) {
throw new ConflictException(`Tenant-Slug "${dto.slug}" bereits vergeben`);
}
const tenant = await this.prisma.tenant.create({
data: {
name: dto.name,
slug: dto.slug,
isActive: true,
settings: dto.settings ?? {},
},
});
this.logger.log(`Tenant erstellt: ${tenant.name} (${tenant.slug})`);
// TODO: Tenant-Datenbank erstellen (tenant_{slug})
// Das wird spaeter per Prisma Migrate automatisiert
return tenant;
}
/**
* Tenant nach ID finden.
*/
async findById(id: string) {
const tenant = await this.prisma.tenant.findUnique({
where: { id },
include: {
_count: {
select: { members: true },
},
},
});
if (!tenant) {
throw new NotFoundException('Mandant nicht gefunden');
}
return {
...tenant,
memberCount: tenant._count.members,
};
}
/**
* Alle Tenants auflisten.
*/
async findAll(page = 1, limit = 20) {
const skip = (page - 1) * limit;
const [tenants, total] = await Promise.all([
this.prisma.tenant.findMany({
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
_count: {
select: { members: true },
},
},
}),
this.prisma.tenant.count(),
]);
return {
data: tenants.map((t) => ({
id: t.id,
name: t.name,
slug: t.slug,
isActive: t.isActive,
memberCount: t._count.members,
createdAt: t.createdAt,
})),
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Tenant aktualisieren.
*/
async update(id: string, dto: UpdateTenantDto) {
const tenant = await this.prisma.tenant.findUnique({ where: { id } });
if (!tenant) {
throw new NotFoundException('Mandant nicht gefunden');
}
return this.prisma.tenant.update({
where: { id },
data: {
name: dto.name,
isActive: dto.isActive,
settings: dto.settings,
},
});
}
/**
* User einem Tenant zuweisen.
*/
async addMember(tenantId: string, userId: string, role = 'MEMBER') {
// Pruefen ob bereits Mitglied
const existing = await this.prisma.tenantMembership.findUnique({
where: {
userId_tenantId: { userId, tenantId },
},
});
if (existing) {
throw new ConflictException('Benutzer ist bereits Mitglied');
}
return this.prisma.tenantMembership.create({
data: {
userId,
tenantId,
tenantRole: role,
isActive: true,
},
include: {
user: { select: { email: true, firstName: true, lastName: true } },
tenant: { select: { name: true, slug: true } },
},
});
}
/**
* User aus Tenant entfernen (Soft-Delete).
*/
async removeMember(tenantId: string, userId: string) {
return this.prisma.tenantMembership.update({
where: {
userId_tenantId: { userId, tenantId },
},
data: { isActive: false },
});
}
}

View file

@ -0,0 +1,46 @@
import {
IsEmail,
IsNotEmpty,
IsOptional,
IsString,
MinLength,
MaxLength,
IsIn,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateUserDto {
@ApiProperty({ example: 'max.mustermann@xinion.de' })
@IsEmail({}, { message: 'Bitte gueltige E-Mail-Adresse angeben' })
@IsNotEmpty()
email!: string;
@ApiProperty({ example: 'SicheresPasswort123!' })
@IsString()
@IsNotEmpty()
@MinLength(8, { message: 'Passwort muss mindestens 8 Zeichen lang sein' })
@MaxLength(128, { message: 'Passwort darf maximal 128 Zeichen lang sein' })
password!: string;
@ApiProperty({ example: 'Max' })
@IsString()
@IsNotEmpty()
@MaxLength(100)
firstName!: string;
@ApiProperty({ example: 'Mustermann' })
@IsString()
@IsNotEmpty()
@MaxLength(100)
lastName!: string;
@ApiProperty({
example: 'USER',
enum: ['PLATFORM_ADMIN', 'TENANT_ADMIN', 'USER'],
required: false,
})
@IsOptional()
@IsString()
@IsIn(['PLATFORM_ADMIN', 'TENANT_ADMIN', 'USER'])
role?: string;
}

View file

@ -0,0 +1,21 @@
import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class UpdateUserDto {
@ApiProperty({ example: 'Max', required: false })
@IsOptional()
@IsString()
@MaxLength(100)
firstName?: string;
@ApiProperty({ example: 'Mustermann', required: false })
@IsOptional()
@IsString()
@MaxLength(100)
lastName?: string;
@ApiProperty({ example: true, required: false })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View file

@ -0,0 +1,89 @@
import {
Controller,
Get,
Post,
Patch,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { Roles } from '../../common/decorators/roles.decorator';
import { CurrentUser, JwtPayload } from '../../common/decorators/current-user.decorator';
import { RolesGuard } from '../../common/guards/roles.guard';
@ApiTags('Benutzer')
@ApiBearerAuth('access-token')
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
/**
* GET /api/v1/users/me
* Eigenes Profil abrufen.
*/
@Get('me')
@ApiOperation({ summary: 'Eigenes Profil abrufen' })
async getProfile(@CurrentUser('sub') userId: string) {
return this.usersService.findById(userId);
}
/**
* GET /api/v1/users
* Alle User auflisten (nur PLATFORM_ADMIN).
*/
@Get()
@Roles('PLATFORM_ADMIN')
@UseGuards(RolesGuard)
@ApiOperation({ summary: 'Alle Benutzer auflisten (Admin)' })
async findAll(
@Query('page') page?: number,
@Query('limit') limit?: number,
) {
return this.usersService.findAll(page ?? 1, limit ?? 20);
}
/**
* GET /api/v1/users/:id
* User nach ID (nur PLATFORM_ADMIN).
*/
@Get(':id')
@Roles('PLATFORM_ADMIN')
@UseGuards(RolesGuard)
@ApiOperation({ summary: 'Benutzer nach ID abrufen (Admin)' })
async findById(@Param('id', ParseUUIDPipe) id: string) {
return this.usersService.findById(id);
}
/**
* POST /api/v1/users
* Neuen User anlegen (nur PLATFORM_ADMIN).
*/
@Post()
@Roles('PLATFORM_ADMIN')
@UseGuards(RolesGuard)
@ApiOperation({ summary: 'Neuen Benutzer anlegen (Admin)' })
async create(@Body() dto: CreateUserDto) {
return this.usersService.create(dto);
}
/**
* PATCH /api/v1/users/:id
* User aktualisieren (nur PLATFORM_ADMIN).
*/
@Patch(':id')
@Roles('PLATFORM_ADMIN')
@UseGuards(RolesGuard)
@ApiOperation({ summary: 'Benutzer aktualisieren (Admin)' })
async update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateUserDto,
) {
return this.usersService.update(id, dto);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

View file

@ -0,0 +1,174 @@
import {
Injectable,
ConflictException,
NotFoundException,
Logger,
} from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
@Injectable()
export class UsersService {
private readonly logger = new Logger(UsersService.name);
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
) {}
/**
* Neuen User anlegen (mit lokalem Auth-Provider).
*/
async create(dto: CreateUserDto) {
// Email-Duplikat pruefen
const existing = await this.prisma.user.findUnique({
where: { email: dto.email.toLowerCase() },
});
if (existing) {
throw new ConflictException('E-Mail-Adresse bereits vergeben');
}
// Passwort hashen (Bcrypt Cost 12)
const bcryptCost = this.config.get<number>('BCRYPT_COST', 12);
const passwordHash = await bcrypt.hash(dto.password, bcryptCost);
// User + AuthProvider in einer Transaktion anlegen
const user = await this.prisma.user.create({
data: {
email: dto.email.toLowerCase(),
firstName: dto.firstName,
lastName: dto.lastName,
role: dto.role ?? 'USER',
isActive: true,
authProvider: {
create: {
provider: 'LOCAL',
passwordHash,
},
},
},
include: {
authProvider: {
select: { provider: true, createdAt: true },
},
},
});
this.logger.log(`User erstellt: ${user.email} (${user.role})`);
// Passwort-Hash nicht zurueckgeben
return {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
isActive: user.isActive,
createdAt: user.createdAt,
};
}
/**
* User nach ID finden.
*/
async findById(id: string) {
const user = await this.prisma.user.findUnique({
where: { id },
include: {
tenantMemberships: {
include: { tenant: { select: { id: true, name: true, slug: true } } },
where: { isActive: true },
},
},
});
if (!user) {
throw new NotFoundException('Benutzer nicht gefunden');
}
return {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
isActive: user.isActive,
twoFactorEnabled: user.twoFactorEnabled,
tenants: user.tenantMemberships.map((m) => ({
id: m.tenant.id,
name: m.tenant.name,
slug: m.tenant.slug,
role: m.tenantRole,
})),
lastLogin: user.lastLogin,
createdAt: user.createdAt,
};
}
/**
* User aktualisieren.
*/
async update(id: string, dto: UpdateUserDto) {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) {
throw new NotFoundException('Benutzer nicht gefunden');
}
const updated = await this.prisma.user.update({
where: { id },
data: {
firstName: dto.firstName,
lastName: dto.lastName,
isActive: dto.isActive,
},
});
return {
id: updated.id,
email: updated.email,
firstName: updated.firstName,
lastName: updated.lastName,
role: updated.role,
isActive: updated.isActive,
};
}
/**
* Alle User auflisten (fuer Admin).
*/
async findAll(page = 1, limit = 20) {
const skip = (page - 1) * limit;
const [users, total] = await Promise.all([
this.prisma.user.findMany({
skip,
take: limit,
orderBy: { createdAt: 'desc' },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
role: true,
isActive: true,
lastLogin: true,
createdAt: true,
},
}),
this.prisma.user.count(),
]);
return {
data: users,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
}

View file

@ -0,0 +1,72 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { Public } from '../common/decorators/public.decorator';
import { PrismaService } from '../prisma/prisma.service';
import { RedisService } from '../redis/redis.service';
interface HealthResponse {
status: 'ok' | 'error';
timestamp: string;
version: string;
services: {
database: 'up' | 'down';
redis: 'up' | 'down';
};
}
@ApiTags('Health')
@Controller('health')
export class HealthController {
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
) {}
@Get()
@Public()
@ApiOperation({ summary: 'Health-Check fuer alle Services' })
async check(): Promise<HealthResponse> {
const [dbStatus, redisStatus] = await Promise.allSettled([
this.checkDatabase(),
this.checkRedis(),
]);
const allUp =
dbStatus.status === 'fulfilled' &&
dbStatus.value &&
redisStatus.status === 'fulfilled' &&
redisStatus.value;
return {
status: allUp ? 'ok' : 'error',
timestamp: new Date().toISOString(),
version: '0.1.0',
services: {
database:
dbStatus.status === 'fulfilled' && dbStatus.value ? 'up' : 'down',
redis:
redisStatus.status === 'fulfilled' && redisStatus.value
? 'up'
: 'down',
},
};
}
private async checkDatabase(): Promise<boolean> {
try {
await this.prisma.$queryRaw`SELECT 1`;
return true;
} catch {
return false;
}
}
private async checkRedis(): Promise<boolean> {
try {
const pong = await this.redis.ping();
return pong === 'PONG';
} catch {
return false;
}
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
import { PrismaModule } from '../prisma/prisma.module';
import { RedisModule } from '../redis/redis.module';
@Module({
imports: [PrismaModule, RedisModule],
controllers: [HealthController],
})
export class HealthModule {}

View file

@ -0,0 +1,82 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import * as cookieParser from 'cookie-parser';
import helmet from 'helmet';
import { AppModule } from './app.module';
async function bootstrap(): Promise<void> {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule, {
logger: ['error', 'warn', 'log', 'debug', 'verbose'],
});
// Security
app.use(helmet());
app.use(cookieParser());
// CORS
const corsOrigins = process.env.CORS_ORIGINS?.split(',') ?? [
'https://insight-dev.xinion.lan',
];
app.enableCors({
origin: corsOrigins,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: [
'Content-Type',
'Authorization',
'X-Tenant-ID',
'X-Request-ID',
],
});
// Global Validation Pipe (Sicherheitsregel: whitelist + forbidNonWhitelisted)
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { enableImplicitConversion: true },
}),
);
// Global Prefix
app.setGlobalPrefix('api/v1', {
exclude: ['health'],
});
// Swagger (nur Development)
if (process.env.NODE_ENV !== 'production') {
const config = new DocumentBuilder()
.setTitle('INSIGHT Platform API')
.setDescription('Multi-Tenant Business Platform API')
.setVersion('0.1.0')
.addBearerAuth(
{
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'Access Token (RS256)',
},
'access-token',
)
.addCookieAuth('refresh_token', {
type: 'apiKey',
in: 'cookie',
description: 'HttpOnly Refresh Token',
})
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
logger.log('Swagger UI: /api/docs');
}
const port = process.env.APP_PORT ?? 3000;
await app.listen(port);
logger.log(`Core-Service laeuft auf Port ${port}`);
logger.log(`Umgebung: ${process.env.NODE_ENV ?? 'development'}`);
}
bootstrap();

View file

@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
import { TenantPrismaService } from './tenant-prisma.service';
@Global()
@Module({
providers: [PrismaService, TenantPrismaService],
exports: [PrismaService, TenantPrismaService],
})
export class PrismaModule {}

View file

@ -0,0 +1,32 @@
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
private readonly logger = new Logger(PrismaService.name);
constructor() {
super({
log: [
{ emit: 'event', level: 'query' },
{ emit: 'stdout', level: 'info' },
{ emit: 'stdout', level: 'warn' },
{ emit: 'stdout', level: 'error' },
],
});
}
async onModuleInit(): Promise<void> {
this.logger.log('Verbinde mit PostgreSQL (platform_core)...');
await this.$connect();
this.logger.log('PostgreSQL Verbindung hergestellt.');
}
async onModuleDestroy(): Promise<void> {
this.logger.log('Trenne PostgreSQL Verbindung...');
await this.$disconnect();
}
}

View file

@ -0,0 +1,103 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
/**
* TenantPrismaService - Verwaltet dynamische Datenbankverbindungen pro Mandant.
*
* Jeder Tenant hat eine eigene Datenbank (tenant_{slug}).
* Verbindungen werden gecacht und bei Inaktivitaet automatisch geschlossen.
*/
@Injectable()
export class TenantPrismaService {
private readonly logger = new Logger(TenantPrismaService.name);
private readonly clients = new Map<
string,
{ client: PrismaClient; lastUsed: number }
>();
// Maximale Inaktivitaetszeit in Millisekunden (30 Minuten)
private readonly MAX_IDLE_TIME = 30 * 60 * 1000;
/**
* Gibt einen PrismaClient fuer den angegebenen Tenant zurueck.
* Erstellt eine neue Verbindung oder nutzt eine gecachte.
*/
async getClient(tenantSlug: string): Promise<PrismaClient> {
const existing = this.clients.get(tenantSlug);
if (existing) {
existing.lastUsed = Date.now();
return existing.client;
}
const dbName = `tenant_${tenantSlug}`;
const baseUrl = process.env.DATABASE_URL_DIRECT ?? process.env.DATABASE_URL;
if (!baseUrl) {
throw new Error('DATABASE_URL ist nicht konfiguriert');
}
// URL modifizieren: Datenbankname ersetzen
const url = new URL(baseUrl);
url.pathname = `/${dbName}`;
const client = new PrismaClient({
datasources: {
db: { url: url.toString() },
},
});
await client.$connect();
this.logger.log(`Tenant-DB Verbindung hergestellt: ${dbName}`);
this.clients.set(tenantSlug, {
client,
lastUsed: Date.now(),
});
return client;
}
/**
* Schliesst eine bestimmte Tenant-Verbindung.
*/
async disconnectTenant(tenantSlug: string): Promise<void> {
const existing = this.clients.get(tenantSlug);
if (existing) {
await existing.client.$disconnect();
this.clients.delete(tenantSlug);
this.logger.log(`Tenant-DB Verbindung geschlossen: tenant_${tenantSlug}`);
}
}
/**
* Schliesst alle inaktiven Verbindungen.
* Wird periodisch vom CleanupService aufgerufen.
*/
async cleanupIdleConnections(): Promise<number> {
const now = Date.now();
let closed = 0;
for (const [slug, entry] of this.clients.entries()) {
if (now - entry.lastUsed > this.MAX_IDLE_TIME) {
await entry.client.$disconnect();
this.clients.delete(slug);
this.logger.log(
`Idle Tenant-DB Verbindung geschlossen: tenant_${slug}`,
);
closed++;
}
}
return closed;
}
/**
* Schliesst alle Tenant-Verbindungen (Shutdown).
*/
async disconnectAll(): Promise<void> {
for (const [slug, entry] of this.clients.entries()) {
await entry.client.$disconnect();
this.logger.log(`Tenant-DB Verbindung geschlossen: tenant_${slug}`);
}
this.clients.clear();
}
}

View file

@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { RedisService } from './redis.service';
@Global()
@Module({
providers: [RedisService],
exports: [RedisService],
})
export class RedisModule {}

View file

@ -0,0 +1,144 @@
import {
Injectable,
OnModuleInit,
OnModuleDestroy,
Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
/**
* RedisService - Zentraler Redis-Client fuer Cache, Sessions und Token-Revocation.
*
* Verwendungszwecke:
* - Token-Blocklist (JWT Revocation)
* - Session-Cache
* - Rate-Limit-Counter
* - Pub/Sub Event Bus (spaeter)
*/
@Injectable()
export class RedisService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(RedisService.name);
private client!: Redis;
constructor(private readonly config: ConfigService) {}
async onModuleInit(): Promise<void> {
const host = this.config.get<string>('REDIS_HOST', 'redis');
const port = this.config.get<number>('REDIS_PORT', 6379);
const password = this.config.get<string>('REDIS_PASSWORD');
this.client = new Redis({
host,
port,
password: password || undefined,
maxRetriesPerRequest: 3,
retryStrategy: (times: number) => {
if (times > 10) {
this.logger.error(
'Redis: Maximale Verbindungsversuche erreicht. Gebe auf.',
);
return null;
}
return Math.min(times * 200, 5000);
},
lazyConnect: true,
});
this.client.on('error', (err: Error) => {
this.logger.error(`Redis Fehler: ${err.message}`);
});
this.client.on('connect', () => {
this.logger.log('Redis Verbindung hergestellt.');
});
await this.client.connect();
}
async onModuleDestroy(): Promise<void> {
this.logger.log('Trenne Redis Verbindung...');
await this.client.quit();
}
/**
* Ping - Verbindungstest
*/
async ping(): Promise<string> {
return this.client.ping();
}
/**
* Token in die Blocklist aufnehmen (JWT Revocation).
* TTL = Restlaufzeit des Tokens.
*/
async blockToken(jti: string, ttlSeconds: number): Promise<void> {
await this.client.set(`blocked:${jti}`, '1', 'EX', ttlSeconds);
}
/**
* Pruefen ob ein Token blockiert ist.
*/
async isTokenBlocked(jti: string): Promise<boolean> {
const result = await this.client.get(`blocked:${jti}`);
return result !== null;
}
/**
* Refresh-Token-Familie speichern (fuer Token-Rotation-Detection).
*/
async setRefreshTokenFamily(
userId: string,
familyId: string,
ttlSeconds: number,
): Promise<void> {
await this.client.set(
`refresh_family:${userId}:${familyId}`,
'1',
'EX',
ttlSeconds,
);
}
/**
* Pruefen ob Refresh-Token-Familie gueltig ist.
*/
async isRefreshTokenFamilyValid(
userId: string,
familyId: string,
): Promise<boolean> {
const result = await this.client.get(
`refresh_family:${userId}:${familyId}`,
);
return result !== null;
}
/**
* Alle Refresh-Token-Familien eines Users invalidieren (Logout-All).
*/
async invalidateAllRefreshTokens(userId: string): Promise<void> {
const keys = await this.client.keys(`refresh_family:${userId}:*`);
if (keys.length > 0) {
await this.client.del(...keys);
}
}
/**
* Generischer Get/Set/Del fuer Cache-Operationen.
*/
async get(key: string): Promise<string | null> {
return this.client.get(key);
}
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
if (ttlSeconds) {
await this.client.set(key, value, 'EX', ttlSeconds);
} else {
await this.client.set(key, value);
}
}
async del(key: string): Promise<void> {
await this.client.del(key);
}
}

View file

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
}

View file

@ -0,0 +1,29 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2022",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
}

View file

@ -0,0 +1,6 @@
node_modules
dist
.env
*.md
.git
.gitignore

View file

@ -0,0 +1,34 @@
# ============================================================
# INSIGHT Frontend - Multi-Stage Dockerfile
# ============================================================
# --- Base Stage ---
FROM node:20-alpine AS base
WORKDIR /app
# --- Dependencies Stage ---
FROM base AS deps
COPY package.json package-lock.json* ./
RUN npm ci
# --- Development Stage ---
FROM base AS development
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
EXPOSE 8080
CMD ["npm", "run", "dev"]
# --- Build Stage ---
FROM base AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# --- Production Stage ---
FROM nginx:alpine AS production
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8080
CMD ["nginx", "-g", "daemon off;"]

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>INSIGHT Platform</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,33 @@
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA: Alle Routen auf index.html weiterleiten
location / {
try_files $uri $uri/ /index.html;
}
# Security Headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Caching fuer Assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Kein Caching fuer index.html
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# Gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 1000;
}

View file

@ -0,0 +1,34 @@
{
"name": "@insight/frontend",
"version": "0.1.0",
"description": "INSIGHT MVP - Frontend (React + Vite)",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --fix",
"lint:check": "eslint . --ext ts,tsx",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-router-dom": "^6.26.0",
"axios": "^1.7.0",
"@tanstack/react-query": "^5.56.0"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"@vitejs/plugin-react": "^4.3.0",
"eslint": "^9.0.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.0",
"typescript": "^5.6.0",
"vite": "^6.0.0"
}
}

View file

@ -0,0 +1,96 @@
import { useQuery } from '@tanstack/react-query';
import api from '../api/client';
interface Tenant {
id: string;
name: string;
slug: string;
isActive: boolean;
memberCount: number;
createdAt: string;
}
interface TenantsResponse {
data: Tenant[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
export function AdminTenantsPage() {
const { data, isLoading, error } = useQuery<TenantsResponse>({
queryKey: ['admin', 'tenants'],
queryFn: async () => {
const response = await api.get<TenantsResponse>('/tenants');
return response.data;
},
});
if (isLoading) return <p>Laden...</p>;
if (error) return <p style={{ color: 'var(--color-error)' }}>Fehler beim Laden der Mandanten</p>;
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>Mandantenverwaltung</h1>
<span style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem' }}>
{data?.meta.total ?? 0} Mandanten gesamt
</span>
</div>
<div style={{
background: 'var(--color-bg-card)',
borderRadius: 'var(--radius-md)',
boxShadow: 'var(--shadow-sm)',
border: '1px solid var(--color-border)',
overflow: 'hidden',
}}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--color-border)', background: 'var(--color-bg)' }}>
<th style={{ padding: '0.75rem 1rem', textAlign: 'left', fontSize: '0.75rem', textTransform: 'uppercase', color: 'var(--color-text-muted)' }}>Name</th>
<th style={{ padding: '0.75rem 1rem', textAlign: 'left', fontSize: '0.75rem', textTransform: 'uppercase', color: 'var(--color-text-muted)' }}>Slug</th>
<th style={{ padding: '0.75rem 1rem', textAlign: 'left', fontSize: '0.75rem', textTransform: 'uppercase', color: 'var(--color-text-muted)' }}>Mitglieder</th>
<th style={{ padding: '0.75rem 1rem', textAlign: 'left', fontSize: '0.75rem', textTransform: 'uppercase', color: 'var(--color-text-muted)' }}>Status</th>
<th style={{ padding: '0.75rem 1rem', textAlign: 'left', fontSize: '0.75rem', textTransform: 'uppercase', color: 'var(--color-text-muted)' }}>Erstellt</th>
</tr>
</thead>
<tbody>
{data?.data.map((tenant) => (
<tr key={tenant.id} style={{ borderBottom: '1px solid var(--color-border)' }}>
<td style={{ padding: '0.75rem 1rem', fontSize: '0.875rem', fontWeight: 500 }}>
{tenant.name}
</td>
<td style={{ padding: '0.75rem 1rem', fontSize: '0.875rem' }}>
<code style={{ background: 'var(--color-bg)', padding: '0.125rem 0.375rem', borderRadius: 'var(--radius-sm)', fontSize: '0.8125rem' }}>
{tenant.slug}
</code>
</td>
<td style={{ padding: '0.75rem 1rem', fontSize: '0.875rem' }}>
{tenant.memberCount}
</td>
<td style={{ padding: '0.75rem 1rem' }}>
<span style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
background: tenant.isActive ? 'var(--color-success)' : 'var(--color-error)',
marginRight: '0.375rem',
}} />
<span style={{ fontSize: '0.875rem' }}>{tenant.isActive ? 'Aktiv' : 'Inaktiv'}</span>
</td>
<td style={{ padding: '0.75rem 1rem', fontSize: '0.875rem', color: 'var(--color-text-muted)' }}>
{new Date(tenant.createdAt).toLocaleDateString('de-DE')}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,106 @@
import { useQuery } from '@tanstack/react-query';
import api from '../api/client';
interface User {
id: string;
email: string;
firstName: string;
lastName: string;
role: string;
isActive: boolean;
lastLogin: string | null;
createdAt: string;
}
interface UsersResponse {
data: User[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
export function AdminUsersPage() {
const { data, isLoading, error } = useQuery<UsersResponse>({
queryKey: ['admin', 'users'],
queryFn: async () => {
const response = await api.get<UsersResponse>('/users');
return response.data;
},
});
if (isLoading) return <p>Laden...</p>;
if (error) return <p style={{ color: 'var(--color-error)' }}>Fehler beim Laden der Benutzer</p>;
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>Benutzerverwaltung</h1>
<span style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem' }}>
{data?.meta.total ?? 0} Benutzer gesamt
</span>
</div>
<div style={{
background: 'var(--color-bg-card)',
borderRadius: 'var(--radius-md)',
boxShadow: 'var(--shadow-sm)',
border: '1px solid var(--color-border)',
overflow: 'hidden',
}}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--color-border)', background: 'var(--color-bg)' }}>
<th style={{ padding: '0.75rem 1rem', textAlign: 'left', fontSize: '0.75rem', textTransform: 'uppercase', color: 'var(--color-text-muted)' }}>Name</th>
<th style={{ padding: '0.75rem 1rem', textAlign: 'left', fontSize: '0.75rem', textTransform: 'uppercase', color: 'var(--color-text-muted)' }}>E-Mail</th>
<th style={{ padding: '0.75rem 1rem', textAlign: 'left', fontSize: '0.75rem', textTransform: 'uppercase', color: 'var(--color-text-muted)' }}>Rolle</th>
<th style={{ padding: '0.75rem 1rem', textAlign: 'left', fontSize: '0.75rem', textTransform: 'uppercase', color: 'var(--color-text-muted)' }}>Status</th>
<th style={{ padding: '0.75rem 1rem', textAlign: 'left', fontSize: '0.75rem', textTransform: 'uppercase', color: 'var(--color-text-muted)' }}>Letzter Login</th>
</tr>
</thead>
<tbody>
{data?.data.map((user) => (
<tr key={user.id} style={{ borderBottom: '1px solid var(--color-border)' }}>
<td style={{ padding: '0.75rem 1rem', fontSize: '0.875rem' }}>
{user.firstName} {user.lastName}
</td>
<td style={{ padding: '0.75rem 1rem', fontSize: '0.875rem', color: 'var(--color-text-secondary)' }}>
{user.email}
</td>
<td style={{ padding: '0.75rem 1rem' }}>
<span style={{
display: 'inline-block',
padding: '0.125rem 0.5rem',
borderRadius: '9999px',
fontSize: '0.75rem',
fontWeight: 500,
background: user.role === 'PLATFORM_ADMIN' ? '#dbeafe' : '#f3f4f6',
color: user.role === 'PLATFORM_ADMIN' ? '#1e40af' : '#374151',
}}>
{user.role}
</span>
</td>
<td style={{ padding: '0.75rem 1rem' }}>
<span style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
background: user.isActive ? 'var(--color-success)' : 'var(--color-error)',
marginRight: '0.375rem',
}} />
<span style={{ fontSize: '0.875rem' }}>{user.isActive ? 'Aktiv' : 'Inaktiv'}</span>
</td>
<td style={{ padding: '0.75rem 1rem', fontSize: '0.875rem', color: 'var(--color-text-muted)' }}>
{user.lastLogin ? new Date(user.lastLogin).toLocaleDateString('de-DE') : 'Nie'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,76 @@
import axios from 'axios';
/**
* Axios-Client mit automatischem Token-Handling.
*
* SICHERHEITSREGEL: Access-Token wird NUR im Memory gehalten (Variable).
* Kein localStorage! Refresh-Token kommt als HttpOnly Cookie.
*/
// Access-Token im Memory (nicht localStorage!)
let accessToken: string | null = null;
export const setAccessToken = (token: string | null): void => {
accessToken = token;
};
export const getAccessToken = (): string | null => {
return accessToken;
};
// API-Client
const api = axios.create({
baseURL: '/api/v1',
withCredentials: true, // HttpOnly Cookie mitsenden
headers: {
'Content-Type': 'application/json',
},
});
// Request-Interceptor: Access-Token anfuegen
api.interceptors.request.use((config) => {
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
});
// Response-Interceptor: Bei 401 automatisch Token erneuern (Silent Refresh)
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// Bei 401 und noch kein Retry versucht
if (
error.response?.status === 401 &&
!originalRequest._retry &&
originalRequest.url !== '/auth/refresh' &&
originalRequest.url !== '/auth/login'
) {
originalRequest._retry = true;
try {
// Silent Refresh via HttpOnly Cookie
const { data } = await axios.post<{ accessToken: string }>(
'/api/v1/auth/refresh',
{},
{ withCredentials: true },
);
setAccessToken(data.accessToken);
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
return api(originalRequest);
} catch {
// Refresh fehlgeschlagen: Logout
setAccessToken(null);
window.location.href = '/login';
return Promise.reject(error);
}
}
return Promise.reject(error);
},
);
export default api;

View file

@ -0,0 +1,130 @@
import {
createContext,
useContext,
useState,
useCallback,
useEffect,
type ReactNode,
} from 'react';
import api, { setAccessToken } from '../api/client';
interface User {
id: string;
email: string;
firstName: string;
lastName: string;
role: string;
}
interface AuthContextType {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (email: string, password: string, totpCode?: string) => Promise<LoginResult>;
logout: () => Promise<void>;
}
interface LoginResult {
success: boolean;
requiresTwoFactor?: boolean;
error?: string;
}
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Beim Start: Silent Refresh versuchen
useEffect(() => {
const initAuth = async () => {
try {
const { data } = await api.post<{ accessToken: string }>(
'/auth/refresh',
);
setAccessToken(data.accessToken);
// User-Profil laden
const profileResponse = await api.get<User>('/users/me');
setUser(profileResponse.data);
} catch {
// Nicht eingeloggt - normal
setAccessToken(null);
setUser(null);
} finally {
setIsLoading(false);
}
};
initAuth();
}, []);
const login = useCallback(
async (
email: string,
password: string,
totpCode?: string,
): Promise<LoginResult> => {
try {
const { data } = await api.post<{
accessToken?: string;
user?: User;
requiresTwoFactor?: boolean;
}>('/auth/login', { email, password, totpCode });
if (data.requiresTwoFactor) {
return { success: false, requiresTwoFactor: true };
}
if (data.accessToken && data.user) {
setAccessToken(data.accessToken);
setUser(data.user);
return { success: true };
}
return { success: false, error: 'Unerwartete Antwort vom Server' };
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } };
return {
success: false,
error: error.response?.data?.message ?? 'Login fehlgeschlagen',
};
}
},
[],
);
const logout = useCallback(async () => {
try {
await api.post('/auth/logout');
} catch {
// Fehler ignorieren
} finally {
setAccessToken(null);
setUser(null);
}
}, []);
return (
<AuthContext.Provider
value={{
user,
isAuthenticated: !!user,
isLoading,
login,
logout,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextType {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth muss innerhalb von AuthProvider verwendet werden');
}
return context;
}

View file

@ -0,0 +1,106 @@
.container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #1a56db 0%, #1e3a5f 100%);
}
.card {
background: var(--color-bg-card);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
padding: 2.5rem;
width: 100%;
max-width: 400px;
}
.logo {
text-align: center;
margin-bottom: 2rem;
}
.logo h1 {
font-size: 2rem;
font-weight: 700;
color: var(--color-primary);
letter-spacing: 2px;
}
.logo p {
color: var(--color-text-secondary);
font-size: 0.875rem;
margin-top: 0.25rem;
}
.form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.field label {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text);
}
.field input {
padding: 0.625rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.9375rem;
transition: border-color 0.15s;
}
.field input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
.field input:disabled {
background: var(--color-bg);
color: var(--color-text-muted);
}
.field small {
color: var(--color-text-muted);
font-size: 0.75rem;
}
.button {
padding: 0.75rem;
background: var(--color-primary);
color: white;
border: none;
border-radius: var(--radius-sm);
font-size: 1rem;
font-weight: 600;
transition: background 0.15s;
margin-top: 0.5rem;
}
.button:hover:not(:disabled) {
background: var(--color-primary-hover);
}
.button:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.error {
background: #fef2f2;
color: var(--color-error);
padding: 0.75rem;
border-radius: var(--radius-sm);
font-size: 0.875rem;
border: 1px solid #fecaca;
}

View file

@ -0,0 +1,113 @@
import { useState, type FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from './AuthContext';
import styles from './LoginPage.module.css';
export function LoginPage() {
const navigate = useNavigate();
const { login } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [totpCode, setTotpCode] = useState('');
const [showTotp, setShowTotp] = useState(false);
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
setIsSubmitting(true);
try {
const result = await login(
email,
password,
showTotp ? totpCode : undefined,
);
if (result.requiresTwoFactor) {
setShowTotp(true);
setIsSubmitting(false);
return;
}
if (result.success) {
navigate('/');
} else {
setError(result.error ?? 'Login fehlgeschlagen');
}
} finally {
setIsSubmitting(false);
}
};
return (
<div className={styles.container}>
<div className={styles.card}>
<div className={styles.logo}>
<h1>INSIGHT</h1>
<p>Business Platform</p>
</div>
<form onSubmit={handleSubmit} className={styles.form}>
{error && <div className={styles.error}>{error}</div>}
<div className={styles.field}>
<label htmlFor="email">E-Mail</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="ihre@email.de"
required
autoFocus
disabled={showTotp}
/>
</div>
<div className={styles.field}>
<label htmlFor="password">Passwort</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Passwort eingeben"
required
minLength={8}
disabled={showTotp}
/>
</div>
{showTotp && (
<div className={styles.field}>
<label htmlFor="totp">2FA-Code</label>
<input
id="totp"
type="text"
value={totpCode}
onChange={(e) => setTotpCode(e.target.value)}
placeholder="6-stelliger Code"
maxLength={6}
pattern="[0-9]{6}"
required
autoFocus
/>
<small>Code aus Ihrer Authenticator-App eingeben</small>
</div>
)}
<button
type="submit"
className={styles.button}
disabled={isSubmitting}
>
{isSubmitting ? 'Anmeldung...' : 'Anmelden'}
</button>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,75 @@
/* ============================================================
INSIGHT MVP - Globale Styles
============================================================ */
:root {
/* Farben - Corporate Design */
--color-primary: #1a56db;
--color-primary-hover: #1e40af;
--color-primary-light: #dbeafe;
--color-secondary: #6b7280;
--color-success: #059669;
--color-warning: #d97706;
--color-error: #dc2626;
/* Graustufen */
--color-bg: #f9fafb;
--color-bg-card: #ffffff;
--color-border: #e5e7eb;
--color-text: #111827;
--color-text-secondary: #6b7280;
--color-text-muted: #9ca3af;
/* Layout */
--sidebar-width: 240px;
--header-height: 56px;
/* Schatten */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
/* Radien */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color: var(--color-text);
background-color: var(--color-bg);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
color: var(--color-primary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
button {
cursor: pointer;
font-family: inherit;
}
input,
textarea,
select {
font-family: inherit;
font-size: inherit;
}

View file

@ -0,0 +1,29 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from './auth/AuthContext';
import { App } from './shell/App';
import './index.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 5 * 60 * 1000, // 5 Minuten
refetchOnWindowFocus: false,
},
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<App />
</AuthProvider>
</QueryClientProvider>
</BrowserRouter>
</React.StrictMode>,
);

View file

@ -0,0 +1,51 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from '../auth/AuthContext';
import { LoginPage } from '../auth/LoginPage';
import { AppLayout } from './AppLayout';
import { DashboardPage } from './DashboardPage';
import { AdminUsersPage } from '../admin/AdminUsersPage';
import { AdminTenantsPage } from '../admin/AdminTenantsPage';
function PrivateRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<p>Laden...</p>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
export function App() {
return (
<Routes>
{/* Oeffentliche Routen */}
<Route path="/login" element={<LoginPage />} />
{/* Geschuetzte Routen */}
<Route
path="/"
element={
<PrivateRoute>
<AppLayout />
</PrivateRoute>
}
>
<Route index element={<DashboardPage />} />
<Route path="admin/users" element={<AdminUsersPage />} />
<Route path="admin/tenants" element={<AdminTenantsPage />} />
</Route>
{/* Fallback */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}

View file

@ -0,0 +1,105 @@
.layout {
display: flex;
min-height: 100vh;
}
.sidebar {
width: var(--sidebar-width);
background: #1e293b;
color: white;
display: flex;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 100;
}
.brand {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.brand h2 {
font-size: 1.25rem;
font-weight: 700;
letter-spacing: 2px;
color: #60a5fa;
}
.nav {
flex: 1;
padding: 1rem 0;
overflow-y: auto;
}
.navSection {
padding: 1rem 1.5rem 0.5rem;
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 1px;
color: rgba(255, 255, 255, 0.4);
font-weight: 600;
}
.navLink {
display: block;
padding: 0.625rem 1.5rem;
color: rgba(255, 255, 255, 0.7);
text-decoration: none;
font-size: 0.875rem;
transition: all 0.15s;
border-left: 3px solid transparent;
}
.navLink:hover {
color: white;
background: rgba(255, 255, 255, 0.05);
text-decoration: none;
}
.active {
color: white;
background: rgba(96, 165, 250, 0.15);
border-left-color: #60a5fa;
}
.userInfo {
padding: 1rem 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.userName {
font-size: 0.875rem;
font-weight: 600;
}
.userEmail {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
margin-top: 0.125rem;
}
.logoutBtn {
margin-top: 0.75rem;
width: 100%;
padding: 0.5rem;
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: var(--radius-sm);
font-size: 0.8125rem;
transition: all 0.15s;
}
.logoutBtn:hover {
background: rgba(255, 255, 255, 0.15);
color: white;
}
.main {
flex: 1;
margin-left: var(--sidebar-width);
padding: 2rem;
}

View file

@ -0,0 +1,74 @@
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/AuthContext';
import styles from './AppLayout.module.css';
export function AppLayout() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const handleLogout = async () => {
await logout();
navigate('/login');
};
return (
<div className={styles.layout}>
{/* Sidebar */}
<aside className={styles.sidebar}>
<div className={styles.brand}>
<h2>INSIGHT</h2>
</div>
<nav className={styles.nav}>
<NavLink
to="/"
end
className={({ isActive }) =>
`${styles.navLink} ${isActive ? styles.active : ''}`
}
>
Dashboard
</NavLink>
{/* Admin-Bereich (nur fuer PLATFORM_ADMIN) */}
{user?.role === 'PLATFORM_ADMIN' && (
<>
<div className={styles.navSection}>Administration</div>
<NavLink
to="/admin/users"
className={({ isActive }) =>
`${styles.navLink} ${isActive ? styles.active : ''}`
}
>
Benutzer
</NavLink>
<NavLink
to="/admin/tenants"
className={({ isActive }) =>
`${styles.navLink} ${isActive ? styles.active : ''}`
}
>
Mandanten
</NavLink>
</>
)}
</nav>
<div className={styles.userInfo}>
<div className={styles.userName}>
{user?.firstName} {user?.lastName}
</div>
<div className={styles.userEmail}>{user?.email}</div>
<button className={styles.logoutBtn} onClick={handleLogout}>
Abmelden
</button>
</div>
</aside>
{/* Main Content */}
<main className={styles.main}>
<Outlet />
</main>
</div>
);
}

View file

@ -0,0 +1,31 @@
import { useAuth } from '../auth/AuthContext';
export function DashboardPage() {
const { user } = useAuth();
return (
<div>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, marginBottom: '1.5rem' }}>
Dashboard
</h1>
<div style={{
background: 'var(--color-bg-card)',
borderRadius: 'var(--radius-md)',
padding: '1.5rem',
boxShadow: 'var(--shadow-sm)',
border: '1px solid var(--color-border)',
}}>
<h2 style={{ fontSize: '1.125rem', marginBottom: '1rem' }}>
Willkommen, {user?.firstName}!
</h2>
<p style={{ color: 'var(--color-text-secondary)' }}>
INSIGHT Platform - Sprint 1 Alpha
</p>
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem', marginTop: '0.5rem' }}>
Rolle: {user?.role}
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src", "vite-env.d.ts"]
}

1
packages/frontend/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -0,0 +1,26 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 8080,
host: true,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: true,
},
});