diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..7247cfa --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -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 diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml new file mode 100644 index 0000000..8315c46 --- /dev/null +++ b/.forgejo/workflows/deploy.yml @@ -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 ---'" diff --git a/Summarize.md b/Summarize.md index da5a218..4ebafca 100644 --- a/Summarize.md +++ b/Summarize.md @@ -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 diff --git a/config/grafana/provisioning/datasources/datasources.yml b/config/grafana/provisioning/datasources/datasources.yml new file mode 100644 index 0000000..3581867 --- /dev/null +++ b/config/grafana/provisioning/datasources/datasources.yml @@ -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 diff --git a/config/loki/loki.yml b/config/loki/loki.yml new file mode 100644 index 0000000..2d75c2f --- /dev/null +++ b/config/loki/loki.yml @@ -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 diff --git a/config/postgres/init/01-init-extensions.sql b/config/postgres/init/01-init-extensions.sql new file mode 100644 index 0000000..2c85932 --- /dev/null +++ b/config/postgres/init/01-init-extensions.sql @@ -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 +$$; diff --git a/config/prometheus/.gitkeep b/config/prometheus/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/config/prometheus/prometheus.yml b/config/prometheus/prometheus.yml new file mode 100644 index 0000000..edb798b --- /dev/null +++ b/config/prometheus/prometheus.yml @@ -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"] diff --git a/config/promtail/promtail.yml b/config/promtail/promtail.yml new file mode 100644 index 0000000..b55aeec --- /dev/null +++ b/config/promtail/promtail.yml @@ -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 diff --git a/config/tempo/tempo.yml b/config/tempo/tempo.yml new file mode 100644 index 0000000..49d695f --- /dev/null +++ b/config/tempo/tempo.yml @@ -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 diff --git a/config/traefik/.gitkeep b/config/traefik/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/config/traefik/dynamic/middlewares.yml b/config/traefik/dynamic/middlewares.yml new file mode 100644 index 0000000..9e2c67e --- /dev/null +++ b/config/traefik/dynamic/middlewares.yml @@ -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 diff --git a/config/traefik/dynamic/tls.yml b/config/traefik/dynamic/tls.yml new file mode 100644 index 0000000..8fed996 --- /dev/null +++ b/config/traefik/dynamic/tls.yml @@ -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" diff --git a/docker-compose.observability.yml b/docker-compose.observability.yml new file mode 100644 index 0000000..6338a6e --- /dev/null +++ b/docker-compose.observability.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b092fc2 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/packages/core-service/.dockerignore b/packages/core-service/.dockerignore new file mode 100644 index 0000000..a335510 --- /dev/null +++ b/packages/core-service/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +coverage +.env +*.md +.git +.gitignore diff --git a/packages/core-service/Dockerfile b/packages/core-service/Dockerfile new file mode 100644 index 0000000..83f288c --- /dev/null +++ b/packages/core-service/Dockerfile @@ -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"] diff --git a/packages/core-service/nest-cli.json b/packages/core-service/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/packages/core-service/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/packages/core-service/package.json b/packages/core-service/package.json new file mode 100644 index 0000000..43517c4 --- /dev/null +++ b/packages/core-service/package.json @@ -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": { + "^@/(.*)$": "/$1" + } + } +} diff --git a/packages/core-service/prisma/.gitkeep b/packages/core-service/prisma/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/packages/core-service/prisma/core.schema.prisma b/packages/core-service/prisma/core.schema.prisma new file mode 100644 index 0000000..94ff324 --- /dev/null +++ b/packages/core-service/prisma/core.schema.prisma @@ -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") +} diff --git a/packages/core-service/prisma/tenant.schema.prisma b/packages/core-service/prisma/tenant.schema.prisma new file mode 100644 index 0000000..ed495d0 --- /dev/null +++ b/packages/core-service/prisma/tenant.schema.prisma @@ -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 +} diff --git a/packages/core-service/src/app.module.ts b/packages/core-service/src/app.module.ts new file mode 100644 index 0000000..c86c293 --- /dev/null +++ b/packages/core-service/src/app.module.ts @@ -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 {} diff --git a/packages/core-service/src/common/decorators/.gitkeep b/packages/core-service/src/common/decorators/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/packages/core-service/src/common/decorators/current-user.decorator.ts b/packages/core-service/src/common/decorators/current-user.decorator.ts new file mode 100644 index 0000000..b77a12c --- /dev/null +++ b/packages/core-service/src/common/decorators/current-user.decorator.ts @@ -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(); + const user = request.user as JwtPayload; + return data ? user?.[data] : user; + }, +); diff --git a/packages/core-service/src/common/decorators/index.ts b/packages/core-service/src/common/decorators/index.ts new file mode 100644 index 0000000..7af6862 --- /dev/null +++ b/packages/core-service/src/common/decorators/index.ts @@ -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'; diff --git a/packages/core-service/src/common/decorators/public.decorator.ts b/packages/core-service/src/common/decorators/public.decorator.ts new file mode 100644 index 0000000..8fe5adf --- /dev/null +++ b/packages/core-service/src/common/decorators/public.decorator.ts @@ -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); diff --git a/packages/core-service/src/common/decorators/roles.decorator.ts b/packages/core-service/src/common/decorators/roles.decorator.ts new file mode 100644 index 0000000..621f724 --- /dev/null +++ b/packages/core-service/src/common/decorators/roles.decorator.ts @@ -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); diff --git a/packages/core-service/src/common/filters/.gitkeep b/packages/core-service/src/common/filters/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/packages/core-service/src/common/filters/global-exception.filter.ts b/packages/core-service/src/common/filters/global-exception.filter.ts new file mode 100644 index 0000000..baa76c2 --- /dev/null +++ b/packages/core-service/src/common/filters/global-exception.filter.ts @@ -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(); + const request = ctx.getRequest(); + + 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; + 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); + } +} diff --git a/packages/core-service/src/common/guards/.gitkeep b/packages/core-service/src/common/guards/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/packages/core-service/src/common/guards/jwt-auth.guard.ts b/packages/core-service/src/common/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..d7bd762 --- /dev/null +++ b/packages/core-service/src/common/guards/jwt-auth.guard.ts @@ -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 { + // @Public() Routen ueberspringen + const isPublic = this.reflector.getAllAndOverride(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(err: Error | null, user: T, info: Error | undefined): T { + if (err || !user) { + throw err || new UnauthorizedException('Zugriff verweigert'); + } + return user; + } +} diff --git a/packages/core-service/src/common/guards/roles.guard.ts b/packages/core-service/src/common/guards/roles.guard.ts new file mode 100644 index 0000000..9f68b2b --- /dev/null +++ b/packages/core-service/src/common/guards/roles.guard.ts @@ -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( + 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); + } +} diff --git a/packages/core-service/src/common/interceptors/.gitkeep b/packages/core-service/src/common/interceptors/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/packages/core-service/src/config/.gitkeep b/packages/core-service/src/config/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/packages/core-service/src/config/env.validation.ts b/packages/core-service/src/config/env.validation.ts new file mode 100644 index 0000000..65dc142 --- /dev/null +++ b/packages/core-service/src/config/env.validation.ts @@ -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, +): 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; +} diff --git a/packages/core-service/src/core/auth/.gitkeep b/packages/core-service/src/core/auth/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/packages/core-service/src/core/auth/auth.controller.ts b/packages/core-service/src/core/auth/auth.controller.ts new file mode 100644 index 0000000..a71493d --- /dev/null +++ b/packages/core-service/src/core/auth/auth.controller.ts @@ -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 + }); + } +} diff --git a/packages/core-service/src/core/auth/auth.module.ts b/packages/core-service/src/core/auth/auth.module.ts new file mode 100644 index 0000000..23a19aa --- /dev/null +++ b/packages/core-service/src/core/auth/auth.module.ts @@ -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( + 'JWT_PRIVATE_KEY_PATH', + '/app/keys/jwt-private.pem', + ); + const publicKeyPath = config.get( + '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('JWT_ISSUER', 'insight-platform'), + expiresIn: config.get('JWT_ACCESS_TOKEN_EXPIRY', '15m'), + }, + verifyOptions: { + algorithms: ['RS256'], + issuer: config.get('JWT_ISSUER', 'insight-platform'), + }, + }; + }, + }), + ], + controllers: [AuthController], + providers: [AuthService, JwtStrategy, TotpService], + exports: [AuthService, JwtModule], +}) +export class AuthModule {} diff --git a/packages/core-service/src/core/auth/auth.service.ts b/packages/core-service/src/core/auth/auth.service.ts new file mode 100644 index 0000000..82bd4c1 --- /dev/null +++ b/packages/core-service/src/core/auth/auth.service.ts @@ -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 { + // 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 { + try { + const payload = this.jwt.verify(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 { + // 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(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, + ): Promise { + const accessJti = uuidv4(); + const refreshJti = uuidv4(); + + const accessToken = this.jwt.sign({ + ...payload, + jti: accessJti, + }); + + const refreshExpiry = this.config.get( + '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 }; + } +} diff --git a/packages/core-service/src/core/auth/dto/login.dto.ts b/packages/core-service/src/core/auth/dto/login.dto.ts new file mode 100644 index 0000000..bceaba4 --- /dev/null +++ b/packages/core-service/src/core/auth/dto/login.dto.ts @@ -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; +} diff --git a/packages/core-service/src/core/auth/strategies/jwt.strategy.ts b/packages/core-service/src/core/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..97d133b --- /dev/null +++ b/packages/core-service/src/core/auth/strategies/jwt.strategy.ts @@ -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( + '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('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, + }; + } +} diff --git a/packages/core-service/src/core/auth/totp.service.ts b/packages/core-service/src/core/auth/totp.service.ts new file mode 100644 index 0000000..d43cb47 --- /dev/null +++ b/packages/core-service/src/core/auth/totp.service.ts @@ -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 { + 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; + } + } +} diff --git a/packages/core-service/src/core/modules/.gitkeep b/packages/core-service/src/core/modules/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/packages/core-service/src/core/tenants/.gitkeep b/packages/core-service/src/core/tenants/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/packages/core-service/src/core/tenants/dto/add-member.dto.ts b/packages/core-service/src/core/tenants/dto/add-member.dto.ts new file mode 100644 index 0000000..dc0f046 --- /dev/null +++ b/packages/core-service/src/core/tenants/dto/add-member.dto.ts @@ -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; +} diff --git a/packages/core-service/src/core/tenants/dto/create-tenant.dto.ts b/packages/core-service/src/core/tenants/dto/create-tenant.dto.ts new file mode 100644 index 0000000..3eeea69 --- /dev/null +++ b/packages/core-service/src/core/tenants/dto/create-tenant.dto.ts @@ -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; +} diff --git a/packages/core-service/src/core/tenants/dto/update-tenant.dto.ts b/packages/core-service/src/core/tenants/dto/update-tenant.dto.ts new file mode 100644 index 0000000..bf86633 --- /dev/null +++ b/packages/core-service/src/core/tenants/dto/update-tenant.dto.ts @@ -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; +} diff --git a/packages/core-service/src/core/tenants/tenants.controller.ts b/packages/core-service/src/core/tenants/tenants.controller.ts new file mode 100644 index 0000000..bb77761 --- /dev/null +++ b/packages/core-service/src/core/tenants/tenants.controller.ts @@ -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); + } +} diff --git a/packages/core-service/src/core/tenants/tenants.module.ts b/packages/core-service/src/core/tenants/tenants.module.ts new file mode 100644 index 0000000..522f740 --- /dev/null +++ b/packages/core-service/src/core/tenants/tenants.module.ts @@ -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 {} diff --git a/packages/core-service/src/core/tenants/tenants.service.ts b/packages/core-service/src/core/tenants/tenants.service.ts new file mode 100644 index 0000000..9dff481 --- /dev/null +++ b/packages/core-service/src/core/tenants/tenants.service.ts @@ -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 }, + }); + } +} diff --git a/packages/core-service/src/core/users/.gitkeep b/packages/core-service/src/core/users/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/packages/core-service/src/core/users/dto/create-user.dto.ts b/packages/core-service/src/core/users/dto/create-user.dto.ts new file mode 100644 index 0000000..f355ec0 --- /dev/null +++ b/packages/core-service/src/core/users/dto/create-user.dto.ts @@ -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; +} diff --git a/packages/core-service/src/core/users/dto/update-user.dto.ts b/packages/core-service/src/core/users/dto/update-user.dto.ts new file mode 100644 index 0000000..d73f6b6 --- /dev/null +++ b/packages/core-service/src/core/users/dto/update-user.dto.ts @@ -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; +} diff --git a/packages/core-service/src/core/users/users.controller.ts b/packages/core-service/src/core/users/users.controller.ts new file mode 100644 index 0000000..83b1da8 --- /dev/null +++ b/packages/core-service/src/core/users/users.controller.ts @@ -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); + } +} diff --git a/packages/core-service/src/core/users/users.module.ts b/packages/core-service/src/core/users/users.module.ts new file mode 100644 index 0000000..513776d --- /dev/null +++ b/packages/core-service/src/core/users/users.module.ts @@ -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 {} diff --git a/packages/core-service/src/core/users/users.service.ts b/packages/core-service/src/core/users/users.service.ts new file mode 100644 index 0000000..920ec3e --- /dev/null +++ b/packages/core-service/src/core/users/users.service.ts @@ -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('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), + }, + }; + } +} diff --git a/packages/core-service/src/health/health.controller.ts b/packages/core-service/src/health/health.controller.ts new file mode 100644 index 0000000..4979fa8 --- /dev/null +++ b/packages/core-service/src/health/health.controller.ts @@ -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 { + 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 { + try { + await this.prisma.$queryRaw`SELECT 1`; + return true; + } catch { + return false; + } + } + + private async checkRedis(): Promise { + try { + const pong = await this.redis.ping(); + return pong === 'PONG'; + } catch { + return false; + } + } +} diff --git a/packages/core-service/src/health/health.module.ts b/packages/core-service/src/health/health.module.ts new file mode 100644 index 0000000..181df98 --- /dev/null +++ b/packages/core-service/src/health/health.module.ts @@ -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 {} diff --git a/packages/core-service/src/main.ts b/packages/core-service/src/main.ts new file mode 100644 index 0000000..3f2a509 --- /dev/null +++ b/packages/core-service/src/main.ts @@ -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 { + 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(); diff --git a/packages/core-service/src/prisma/.gitkeep b/packages/core-service/src/prisma/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/packages/core-service/src/prisma/prisma.module.ts b/packages/core-service/src/prisma/prisma.module.ts new file mode 100644 index 0000000..5321f9f --- /dev/null +++ b/packages/core-service/src/prisma/prisma.module.ts @@ -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 {} diff --git a/packages/core-service/src/prisma/prisma.service.ts b/packages/core-service/src/prisma/prisma.service.ts new file mode 100644 index 0000000..48da17f --- /dev/null +++ b/packages/core-service/src/prisma/prisma.service.ts @@ -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 { + this.logger.log('Verbinde mit PostgreSQL (platform_core)...'); + await this.$connect(); + this.logger.log('PostgreSQL Verbindung hergestellt.'); + } + + async onModuleDestroy(): Promise { + this.logger.log('Trenne PostgreSQL Verbindung...'); + await this.$disconnect(); + } +} diff --git a/packages/core-service/src/prisma/tenant-prisma.service.ts b/packages/core-service/src/prisma/tenant-prisma.service.ts new file mode 100644 index 0000000..693e826 --- /dev/null +++ b/packages/core-service/src/prisma/tenant-prisma.service.ts @@ -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 { + 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 { + 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 { + 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 { + for (const [slug, entry] of this.clients.entries()) { + await entry.client.$disconnect(); + this.logger.log(`Tenant-DB Verbindung geschlossen: tenant_${slug}`); + } + this.clients.clear(); + } +} diff --git a/packages/core-service/src/redis/redis.module.ts b/packages/core-service/src/redis/redis.module.ts new file mode 100644 index 0000000..b9cfabf --- /dev/null +++ b/packages/core-service/src/redis/redis.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { RedisService } from './redis.service'; + +@Global() +@Module({ + providers: [RedisService], + exports: [RedisService], +}) +export class RedisModule {} diff --git a/packages/core-service/src/redis/redis.service.ts b/packages/core-service/src/redis/redis.service.ts new file mode 100644 index 0000000..eed4bb9 --- /dev/null +++ b/packages/core-service/src/redis/redis.service.ts @@ -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 { + const host = this.config.get('REDIS_HOST', 'redis'); + const port = this.config.get('REDIS_PORT', 6379); + const password = this.config.get('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 { + this.logger.log('Trenne Redis Verbindung...'); + await this.client.quit(); + } + + /** + * Ping - Verbindungstest + */ + async ping(): Promise { + return this.client.ping(); + } + + /** + * Token in die Blocklist aufnehmen (JWT Revocation). + * TTL = Restlaufzeit des Tokens. + */ + async blockToken(jti: string, ttlSeconds: number): Promise { + await this.client.set(`blocked:${jti}`, '1', 'EX', ttlSeconds); + } + + /** + * Pruefen ob ein Token blockiert ist. + */ + async isTokenBlocked(jti: string): Promise { + 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 { + 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 { + 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 { + 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 { + return this.client.get(key); + } + + async set(key: string, value: string, ttlSeconds?: number): Promise { + if (ttlSeconds) { + await this.client.set(key, value, 'EX', ttlSeconds); + } else { + await this.client.set(key, value); + } + } + + async del(key: string): Promise { + await this.client.del(key); + } +} diff --git a/packages/core-service/tsconfig.build.json b/packages/core-service/tsconfig.build.json new file mode 100644 index 0000000..2fe1df2 --- /dev/null +++ b/packages/core-service/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] +} diff --git a/packages/core-service/tsconfig.json b/packages/core-service/tsconfig.json new file mode 100644 index 0000000..5479b57 --- /dev/null +++ b/packages/core-service/tsconfig.json @@ -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"] +} diff --git a/packages/frontend/.dockerignore b/packages/frontend/.dockerignore new file mode 100644 index 0000000..2dd83c2 --- /dev/null +++ b/packages/frontend/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.env +*.md +.git +.gitignore diff --git a/packages/frontend/Dockerfile b/packages/frontend/Dockerfile new file mode 100644 index 0000000..a9615db --- /dev/null +++ b/packages/frontend/Dockerfile @@ -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;"] diff --git a/packages/frontend/index.html b/packages/frontend/index.html new file mode 100644 index 0000000..a066c70 --- /dev/null +++ b/packages/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + INSIGHT Platform + + +
+ + + diff --git a/packages/frontend/nginx.conf b/packages/frontend/nginx.conf new file mode 100644 index 0000000..1e61cda --- /dev/null +++ b/packages/frontend/nginx.conf @@ -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; +} diff --git a/packages/frontend/package.json b/packages/frontend/package.json new file mode 100644 index 0000000..9cd95dd --- /dev/null +++ b/packages/frontend/package.json @@ -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" + } +} diff --git a/packages/frontend/src/admin/.gitkeep b/packages/frontend/src/admin/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/packages/frontend/src/admin/AdminTenantsPage.tsx b/packages/frontend/src/admin/AdminTenantsPage.tsx new file mode 100644 index 0000000..30e13c5 --- /dev/null +++ b/packages/frontend/src/admin/AdminTenantsPage.tsx @@ -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({ + queryKey: ['admin', 'tenants'], + queryFn: async () => { + const response = await api.get('/tenants'); + return response.data; + }, + }); + + if (isLoading) return

Laden...

; + if (error) return

Fehler beim Laden der Mandanten

; + + return ( +
+
+

Mandantenverwaltung

+ + {data?.meta.total ?? 0} Mandanten gesamt + +
+ +
+ + + + + + + + + + + + {data?.data.map((tenant) => ( + + + + + + + + ))} + +
NameSlugMitgliederStatusErstellt
+ {tenant.name} + + + {tenant.slug} + + + {tenant.memberCount} + + + {tenant.isActive ? 'Aktiv' : 'Inaktiv'} + + {new Date(tenant.createdAt).toLocaleDateString('de-DE')} +
+
+
+ ); +} diff --git a/packages/frontend/src/admin/AdminUsersPage.tsx b/packages/frontend/src/admin/AdminUsersPage.tsx new file mode 100644 index 0000000..2f510b3 --- /dev/null +++ b/packages/frontend/src/admin/AdminUsersPage.tsx @@ -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({ + queryKey: ['admin', 'users'], + queryFn: async () => { + const response = await api.get('/users'); + return response.data; + }, + }); + + if (isLoading) return

Laden...

; + if (error) return

Fehler beim Laden der Benutzer

; + + return ( +
+
+

Benutzerverwaltung

+ + {data?.meta.total ?? 0} Benutzer gesamt + +
+ +
+ + + + + + + + + + + + {data?.data.map((user) => ( + + + + + + + + ))} + +
NameE-MailRolleStatusLetzter Login
+ {user.firstName} {user.lastName} + + {user.email} + + + {user.role} + + + + {user.isActive ? 'Aktiv' : 'Inaktiv'} + + {user.lastLogin ? new Date(user.lastLogin).toLocaleDateString('de-DE') : 'Nie'} +
+
+
+ ); +} diff --git a/packages/frontend/src/api/client.ts b/packages/frontend/src/api/client.ts new file mode 100644 index 0000000..77f704e --- /dev/null +++ b/packages/frontend/src/api/client.ts @@ -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; diff --git a/packages/frontend/src/auth/.gitkeep b/packages/frontend/src/auth/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/packages/frontend/src/auth/AuthContext.tsx b/packages/frontend/src/auth/AuthContext.tsx new file mode 100644 index 0000000..de423c7 --- /dev/null +++ b/packages/frontend/src/auth/AuthContext.tsx @@ -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; + logout: () => Promise; +} + +interface LoginResult { + success: boolean; + requiresTwoFactor?: boolean; + error?: string; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(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('/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 => { + 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 ( + + {children} + + ); +} + +export function useAuth(): AuthContextType { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth muss innerhalb von AuthProvider verwendet werden'); + } + return context; +} diff --git a/packages/frontend/src/auth/LoginPage.module.css b/packages/frontend/src/auth/LoginPage.module.css new file mode 100644 index 0000000..37206ab --- /dev/null +++ b/packages/frontend/src/auth/LoginPage.module.css @@ -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; +} diff --git a/packages/frontend/src/auth/LoginPage.tsx b/packages/frontend/src/auth/LoginPage.tsx new file mode 100644 index 0000000..1574ddb --- /dev/null +++ b/packages/frontend/src/auth/LoginPage.tsx @@ -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 ( +
+
+
+

INSIGHT

+

Business Platform

+
+ +
+ {error &&
{error}
} + +
+ + setEmail(e.target.value)} + placeholder="ihre@email.de" + required + autoFocus + disabled={showTotp} + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="Passwort eingeben" + required + minLength={8} + disabled={showTotp} + /> +
+ + {showTotp && ( +
+ + setTotpCode(e.target.value)} + placeholder="6-stelliger Code" + maxLength={6} + pattern="[0-9]{6}" + required + autoFocus + /> + Code aus Ihrer Authenticator-App eingeben +
+ )} + + +
+
+
+ ); +} diff --git a/packages/frontend/src/components/HelpPanel/.gitkeep b/packages/frontend/src/components/HelpPanel/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/packages/frontend/src/components/HelpTooltip/.gitkeep b/packages/frontend/src/components/HelpTooltip/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/packages/frontend/src/index.css b/packages/frontend/src/index.css new file mode 100644 index 0000000..0f227f8 --- /dev/null +++ b/packages/frontend/src/index.css @@ -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; +} diff --git a/packages/frontend/src/main.tsx b/packages/frontend/src/main.tsx new file mode 100644 index 0000000..7ebb229 --- /dev/null +++ b/packages/frontend/src/main.tsx @@ -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( + + + + + + + + + , +); diff --git a/packages/frontend/src/shell/.gitkeep b/packages/frontend/src/shell/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/packages/frontend/src/shell/App.tsx b/packages/frontend/src/shell/App.tsx new file mode 100644 index 0000000..86b7ac9 --- /dev/null +++ b/packages/frontend/src/shell/App.tsx @@ -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 ( +
+

Laden...

+
+ ); + } + + if (!isAuthenticated) { + return ; + } + + return <>{children}; +} + +export function App() { + return ( + + {/* Oeffentliche Routen */} + } /> + + {/* Geschuetzte Routen */} + + + + } + > + } /> + } /> + } /> + + + {/* Fallback */} + } /> + + ); +} diff --git a/packages/frontend/src/shell/AppLayout.module.css b/packages/frontend/src/shell/AppLayout.module.css new file mode 100644 index 0000000..97bc883 --- /dev/null +++ b/packages/frontend/src/shell/AppLayout.module.css @@ -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; +} diff --git a/packages/frontend/src/shell/AppLayout.tsx b/packages/frontend/src/shell/AppLayout.tsx new file mode 100644 index 0000000..4b5ce29 --- /dev/null +++ b/packages/frontend/src/shell/AppLayout.tsx @@ -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 ( +
+ {/* Sidebar */} + + + {/* Main Content */} +
+ +
+
+ ); +} diff --git a/packages/frontend/src/shell/DashboardPage.tsx b/packages/frontend/src/shell/DashboardPage.tsx new file mode 100644 index 0000000..b6df335 --- /dev/null +++ b/packages/frontend/src/shell/DashboardPage.tsx @@ -0,0 +1,31 @@ +import { useAuth } from '../auth/AuthContext'; + +export function DashboardPage() { + const { user } = useAuth(); + + return ( +
+

+ Dashboard +

+ +
+

+ Willkommen, {user?.firstName}! +

+

+ INSIGHT Platform - Sprint 1 Alpha +

+

+ Rolle: {user?.role} +

+
+
+ ); +} diff --git a/packages/frontend/tsconfig.json b/packages/frontend/tsconfig.json new file mode 100644 index 0000000..518e042 --- /dev/null +++ b/packages/frontend/tsconfig.json @@ -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"] +} diff --git a/packages/frontend/vite-env.d.ts b/packages/frontend/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/packages/frontend/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts new file mode 100644 index 0000000..541e01f --- /dev/null +++ b/packages/frontend/vite.config.ts @@ -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, + }, +});