mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 00:36:39 +02:00
feat: implement Sprint 1 Alpha - full stack with Docker, NestJS, React
Docker Infrastructure:
- docker-compose.yml with Traefik 3, PostgreSQL 16, PgBouncer, Redis 7, step-ca
- docker-compose.observability.yml with Prometheus, Grafana, Loki, Tempo, Promtail
- Traefik dynamic config (TLS, security headers, CORS, compression)
- PostgreSQL init script (uuid-ossp, pgcrypto, pg_trgm extensions)
- Grafana auto-provisioned datasources (Prometheus, Loki, Tempo)
NestJS Core-Service:
- Auth module: Login (email/password), TOTP 2FA, JWT RS256, token refresh/revocation
- Users module: CRUD, bcrypt cost 12, pagination, role-based access
- Tenants module: CRUD, member management, slug validation
- Prisma schemas: core (Users, AuthProviders, Tenants, Modules, AuditLog)
tenant (Contacts, Activities - CRM reference for Sprint 2)
- TenantPrismaService: Dynamic per-tenant DB connections with caching
- RedisService: Token blocklist, refresh token families, generic cache
- Global JwtAuthGuard with @Public() decorator, RolesGuard, GlobalExceptionFilter
- Health endpoint with DB + Redis status checks
- Swagger API documentation (dev only)
- Multi-stage Dockerfile (dev + production)
React Frontend:
- Vite 6 + React 18 + TypeScript strict
- AuthContext with silent refresh (access token in memory, NOT localStorage)
- Login page with TOTP 2FA support
- App shell with sidebar navigation
- Admin pages: Users + Tenants management tables
- API client with automatic token refresh interceptor
- Multi-stage Dockerfile (dev + nginx production)
CI/CD Pipelines:
- ci.yml: Lint, type-check, test, build on all branches
- deploy.yml: Docker build, push to Forgejo registry, SSH deploy
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
34129401a3
commit
10f291cdda
93 changed files with 4972 additions and 8 deletions
82
.forgejo/workflows/ci.yml
Normal file
82
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# ============================================================
|
||||
# INSIGHT MVP - CI Pipeline (Lint, Type-Check, Test, Build)
|
||||
# ============================================================
|
||||
# Wird bei jedem Push und Pull Request ausgefuehrt.
|
||||
# ============================================================
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop, 'feature/**', 'fix/**', 'hotfix/**']
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
jobs:
|
||||
# --------------------------------------------------------
|
||||
# Core-Service: Lint, Type-Check, Test, Build
|
||||
# --------------------------------------------------------
|
||||
core-service:
|
||||
name: Core-Service CI
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/core-service
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Generate Prisma Client
|
||||
run: npx prisma generate --schema=prisma/core.schema.prisma
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint:check
|
||||
|
||||
- name: Type-Check
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Test
|
||||
run: npm test -- --passWithNoTests
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
# --------------------------------------------------------
|
||||
# Frontend: Lint, Type-Check, Build
|
||||
# --------------------------------------------------------
|
||||
frontend:
|
||||
name: Frontend CI
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/frontend
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint:check
|
||||
|
||||
- name: Type-Check
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
108
.forgejo/workflows/deploy.yml
Normal file
108
.forgejo/workflows/deploy.yml
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# ============================================================
|
||||
# INSIGHT MVP - Deploy Pipeline
|
||||
# ============================================================
|
||||
# Baut Docker-Images, pusht sie in die Forgejo Registry
|
||||
# und deployed auf den insight-dev-01 Server.
|
||||
#
|
||||
# Wird nur bei Push auf 'main' oder 'develop' ausgefuehrt.
|
||||
# ============================================================
|
||||
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
|
||||
jobs:
|
||||
# --------------------------------------------------------
|
||||
# Docker Images bauen und in Registry pushen
|
||||
# --------------------------------------------------------
|
||||
build-and-push:
|
||||
name: Build & Push Images
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Determine Tag
|
||||
id: tag
|
||||
run: |
|
||||
if [ "${{ github.ref_name }}" = "main" ]; then
|
||||
echo "tag=latest" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "tag=develop" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Login to Container Registry
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
|
||||
docker login git.xinion.lan -u ${{ secrets.REGISTRY_USER }} --password-stdin
|
||||
|
||||
# Core-Service Image
|
||||
- name: Build Core-Service
|
||||
run: |
|
||||
docker build \
|
||||
-t git.xinion.lan/gitadmin/insight-core:${{ steps.tag.outputs.tag }} \
|
||||
-f packages/core-service/Dockerfile \
|
||||
--target production \
|
||||
packages/core-service
|
||||
|
||||
- name: Push Core-Service
|
||||
run: docker push git.xinion.lan/gitadmin/insight-core:${{ steps.tag.outputs.tag }}
|
||||
|
||||
# Frontend Image
|
||||
- name: Build Frontend
|
||||
run: |
|
||||
docker build \
|
||||
-t git.xinion.lan/gitadmin/insight-frontend:${{ steps.tag.outputs.tag }} \
|
||||
-f packages/frontend/Dockerfile \
|
||||
--target production \
|
||||
packages/frontend
|
||||
|
||||
- name: Push Frontend
|
||||
run: docker push git.xinion.lan/gitadmin/insight-frontend:${{ steps.tag.outputs.tag }}
|
||||
|
||||
# --------------------------------------------------------
|
||||
# Auf Server deployen
|
||||
# --------------------------------------------------------
|
||||
deploy:
|
||||
name: Deploy to Server
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-and-push
|
||||
|
||||
steps:
|
||||
- name: Deploy via SSH
|
||||
run: |
|
||||
# SSH-Key vorbereiten
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.SSH_DEPLOY_KEY }}" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts
|
||||
|
||||
# Deploy-Befehle auf dem Server ausfuehren
|
||||
ssh -i ~/.ssh/deploy_key ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} << 'DEPLOY'
|
||||
cd ~/insight
|
||||
|
||||
# Registry Login
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
|
||||
docker login git.xinion.lan -u ${{ secrets.REGISTRY_USER }} --password-stdin
|
||||
|
||||
# Neue Images pullen
|
||||
docker compose pull core frontend
|
||||
|
||||
# Services mit neuem Image starten
|
||||
docker compose up -d core frontend
|
||||
|
||||
# Health-Check warten
|
||||
sleep 10
|
||||
curl -f http://localhost:3000/health || echo "WARNUNG: Health-Check fehlgeschlagen"
|
||||
|
||||
# Alte Images aufraeumen
|
||||
docker image prune -f
|
||||
DEPLOY
|
||||
|
||||
- name: Verify Deployment
|
||||
run: |
|
||||
ssh -i ~/.ssh/deploy_key ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} \
|
||||
"docker compose ps && echo '--- Deployment erfolgreich ---'"
|
||||
179
Summarize.md
179
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
|
||||
|
|
|
|||
47
config/grafana/provisioning/datasources/datasources.yml
Normal file
47
config/grafana/provisioning/datasources/datasources.yml
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# ============================================================
|
||||
# Grafana - Datenquellen (automatisch provisioniert)
|
||||
# ============================================================
|
||||
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
# Prometheus - Metriken
|
||||
- name: Prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://prometheus:9090
|
||||
isDefault: true
|
||||
editable: false
|
||||
|
||||
# Loki - Logs
|
||||
- name: Loki
|
||||
type: loki
|
||||
access: proxy
|
||||
url: http://loki:3100
|
||||
editable: false
|
||||
jsonData:
|
||||
derivedFields:
|
||||
- datasourceUid: tempo
|
||||
matcherRegex: "traceId=(\\w+)"
|
||||
name: TraceID
|
||||
url: "$${__value.raw}"
|
||||
|
||||
# Tempo - Traces
|
||||
- name: Tempo
|
||||
type: tempo
|
||||
access: proxy
|
||||
uid: tempo
|
||||
url: http://tempo:3200
|
||||
editable: false
|
||||
jsonData:
|
||||
tracesToLogs:
|
||||
datasourceUid: loki
|
||||
tags: ['service']
|
||||
mappedTags: [{ key: 'service.name', value: 'service' }]
|
||||
mapTagNamesEnabled: true
|
||||
filterByTraceID: true
|
||||
tracesToMetrics:
|
||||
datasourceUid: prometheus
|
||||
tags: [{ key: 'service.name', value: 'service' }]
|
||||
serviceMap:
|
||||
datasourceUid: prometheus
|
||||
37
config/loki/loki.yml
Normal file
37
config/loki/loki.yml
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# ============================================================
|
||||
# Loki - Log-Aggregation Konfiguration
|
||||
# ============================================================
|
||||
|
||||
auth_enabled: false
|
||||
|
||||
server:
|
||||
http_listen_port: 3100
|
||||
|
||||
common:
|
||||
path_prefix: /loki
|
||||
storage:
|
||||
filesystem:
|
||||
chunks_directory: /loki/chunks
|
||||
rules_directory: /loki/rules
|
||||
replication_factor: 1
|
||||
ring:
|
||||
kvstore:
|
||||
store: inmemory
|
||||
|
||||
schema_config:
|
||||
configs:
|
||||
- from: 2024-01-01
|
||||
store: tsdb
|
||||
object_store: filesystem
|
||||
schema: v13
|
||||
index:
|
||||
prefix: index_
|
||||
period: 24h
|
||||
|
||||
limits_config:
|
||||
retention_period: 30d
|
||||
reject_old_samples: true
|
||||
reject_old_samples_max_age: 168h
|
||||
|
||||
analytics:
|
||||
reporting_enabled: false
|
||||
22
config/postgres/init/01-init-extensions.sql
Normal file
22
config/postgres/init/01-init-extensions.sql
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
-- ============================================================
|
||||
-- PostgreSQL Initialisierung
|
||||
-- ============================================================
|
||||
-- Wird automatisch beim ersten Start ausgefuehrt.
|
||||
-- Erstellt benoetigte Extensions fuer die platform_core DB.
|
||||
-- ============================================================
|
||||
|
||||
-- UUID-Generierung (v4)
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- Kryptographische Funktionen (fuer Token-Hashing etc.)
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
-- Trigram-Index fuer Volltextsuche
|
||||
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
|
||||
|
||||
-- Bestaetigungsmeldung
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE 'INSIGHT: PostgreSQL Extensions erfolgreich installiert.';
|
||||
END
|
||||
$$;
|
||||
40
config/prometheus/prometheus.yml
Normal file
40
config/prometheus/prometheus.yml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# ============================================================
|
||||
# Prometheus - Konfiguration
|
||||
# ============================================================
|
||||
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
scrape_timeout: 10s
|
||||
|
||||
scrape_configs:
|
||||
# Traefik Metriken
|
||||
- job_name: "traefik"
|
||||
static_configs:
|
||||
- targets: ["traefik:8082"]
|
||||
|
||||
# Core-Service Metriken (NestJS)
|
||||
- job_name: "core-service"
|
||||
metrics_path: /metrics
|
||||
static_configs:
|
||||
- targets: ["core:3000"]
|
||||
|
||||
# PostgreSQL Exporter
|
||||
- job_name: "postgres"
|
||||
static_configs:
|
||||
- targets: ["postgres-exporter:9187"]
|
||||
|
||||
# cAdvisor (Container-Metriken)
|
||||
- job_name: "cadvisor"
|
||||
static_configs:
|
||||
- targets: ["cadvisor:8080"]
|
||||
|
||||
# Redis (wenn Redis Exporter hinzugefuegt wird)
|
||||
# - job_name: "redis"
|
||||
# static_configs:
|
||||
# - targets: ["redis-exporter:9121"]
|
||||
|
||||
# Prometheus Self-Monitoring
|
||||
- job_name: "prometheus"
|
||||
static_configs:
|
||||
- targets: ["localhost:9090"]
|
||||
51
config/promtail/promtail.yml
Normal file
51
config/promtail/promtail.yml
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# ============================================================
|
||||
# Promtail - Log-Collector Konfiguration
|
||||
# ============================================================
|
||||
# Sammelt Docker Container Logs und sendet sie an Loki.
|
||||
# ============================================================
|
||||
|
||||
server:
|
||||
http_listen_port: 9080
|
||||
grpc_listen_port: 0
|
||||
|
||||
positions:
|
||||
filename: /tmp/positions.yaml
|
||||
|
||||
clients:
|
||||
- url: http://loki:3100/loki/api/v1/push
|
||||
|
||||
scrape_configs:
|
||||
# Docker Container Logs
|
||||
- job_name: docker
|
||||
docker_sd_configs:
|
||||
- host: unix:///var/run/docker.sock
|
||||
refresh_interval: 5s
|
||||
filters:
|
||||
- name: label
|
||||
values: ["com.docker.compose.project=insight"]
|
||||
relabel_configs:
|
||||
# Container-Name als Label
|
||||
- source_labels: ['__meta_docker_container_name']
|
||||
regex: '/(.*)'
|
||||
target_label: container
|
||||
# Compose-Service-Name als Label
|
||||
- source_labels: ['__meta_docker_container_label_com_docker_compose_service']
|
||||
target_label: service
|
||||
# Log-Stream (stdout/stderr)
|
||||
- source_labels: ['__meta_docker_container_log_stream']
|
||||
target_label: stream
|
||||
|
||||
pipeline_stages:
|
||||
# JSON-Logs parsen (NestJS)
|
||||
- json:
|
||||
expressions:
|
||||
level: level
|
||||
message: message
|
||||
timestamp: timestamp
|
||||
context: context
|
||||
- labels:
|
||||
level:
|
||||
context:
|
||||
- timestamp:
|
||||
source: timestamp
|
||||
format: RFC3339
|
||||
41
config/tempo/tempo.yml
Normal file
41
config/tempo/tempo.yml
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# ============================================================
|
||||
# Tempo - Distributed Tracing Konfiguration
|
||||
# ============================================================
|
||||
|
||||
server:
|
||||
http_listen_port: 3200
|
||||
|
||||
distributor:
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc:
|
||||
endpoint: "0.0.0.0:4317"
|
||||
http:
|
||||
endpoint: "0.0.0.0:4318"
|
||||
|
||||
storage:
|
||||
trace:
|
||||
backend: local
|
||||
local:
|
||||
path: /var/tempo/traces
|
||||
wal:
|
||||
path: /var/tempo/wal
|
||||
|
||||
metrics_generator:
|
||||
registry:
|
||||
external_labels:
|
||||
source: tempo
|
||||
cluster: insight-dev
|
||||
storage:
|
||||
path: /var/tempo/generator/wal
|
||||
remote_write:
|
||||
- url: http://prometheus:9090/api/v1/write
|
||||
send_exemplars: true
|
||||
|
||||
overrides:
|
||||
defaults:
|
||||
metrics_generator:
|
||||
processors:
|
||||
- service-graphs
|
||||
- span-metrics
|
||||
52
config/traefik/dynamic/middlewares.yml
Normal file
52
config/traefik/dynamic/middlewares.yml
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# ============================================================
|
||||
# Traefik - Globale Middlewares
|
||||
# ============================================================
|
||||
|
||||
http:
|
||||
middlewares:
|
||||
# Security-Headers fuer alle Responses
|
||||
security-headers:
|
||||
headers:
|
||||
browserXssFilter: true
|
||||
contentTypeNosniff: true
|
||||
frameDeny: true
|
||||
stsIncludeSubdomains: true
|
||||
stsPreload: true
|
||||
stsSeconds: 31536000
|
||||
customFrameOptionsValue: "SAMEORIGIN"
|
||||
referrerPolicy: "strict-origin-when-cross-origin"
|
||||
contentSecurityPolicy: >-
|
||||
default-src 'self';
|
||||
script-src 'self' 'unsafe-inline';
|
||||
style-src 'self' 'unsafe-inline';
|
||||
img-src 'self' data: blob:;
|
||||
font-src 'self';
|
||||
connect-src 'self' wss://insight-dev.xinion.lan;
|
||||
frame-ancestors 'self';
|
||||
|
||||
# CORS fuer API
|
||||
cors-api:
|
||||
headers:
|
||||
accessControlAllowMethods:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- PATCH
|
||||
- DELETE
|
||||
- OPTIONS
|
||||
accessControlAllowHeaders:
|
||||
- Content-Type
|
||||
- Authorization
|
||||
- X-Tenant-ID
|
||||
- X-Request-ID
|
||||
accessControlAllowOriginList:
|
||||
- "https://insight-dev.xinion.lan"
|
||||
accessControlMaxAge: 86400
|
||||
accessControlAllowCredentials: true
|
||||
addVaryHeader: true
|
||||
|
||||
# Kompression
|
||||
gzip-compress:
|
||||
compress:
|
||||
excludedContentTypes:
|
||||
- text/event-stream
|
||||
24
config/traefik/dynamic/tls.yml
Normal file
24
config/traefik/dynamic/tls.yml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# ============================================================
|
||||
# Traefik - Dynamische TLS-Konfiguration
|
||||
# ============================================================
|
||||
# Fuer die Alpha-Phase verwenden wir ein selbst-signiertes
|
||||
# Zertifikat. Spaeter wird step-ca als ACME-Provider genutzt.
|
||||
# ============================================================
|
||||
|
||||
tls:
|
||||
options:
|
||||
default:
|
||||
minVersion: VersionTLS12
|
||||
cipherSuites:
|
||||
- TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
|
||||
- TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
|
||||
- TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
|
||||
- TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
|
||||
sniStrict: false
|
||||
|
||||
stores:
|
||||
default:
|
||||
defaultGeneratedCert:
|
||||
resolver: default
|
||||
domain:
|
||||
main: "insight-dev.xinion.lan"
|
||||
185
docker-compose.observability.yml
Normal file
185
docker-compose.observability.yml
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
# ============================================================
|
||||
# INSIGHT MVP - Docker Compose (Observability-Stack)
|
||||
# ============================================================
|
||||
# Ergaenzt docker-compose.yml um Monitoring, Logging & Tracing.
|
||||
#
|
||||
# Nutzung:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.observability.yml up -d
|
||||
#
|
||||
# Grafana (nur via SSH-Tunnel):
|
||||
# ssh -L 3001:localhost:3001 -i .keys/deploy_ed25519 deploy@172.20.10.59
|
||||
# Browser: http://localhost:3001
|
||||
# ============================================================
|
||||
|
||||
networks:
|
||||
insight-web:
|
||||
external: true
|
||||
insight-db:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
prometheus-data:
|
||||
name: insight-prometheus-data
|
||||
grafana-data:
|
||||
name: insight-grafana-data
|
||||
loki-data:
|
||||
name: insight-loki-data
|
||||
tempo-data:
|
||||
name: insight-tempo-data
|
||||
|
||||
services:
|
||||
# --------------------------------------------------------
|
||||
# Prometheus - Metrics-Sammlung & -Speicherung
|
||||
# --------------------------------------------------------
|
||||
prometheus:
|
||||
image: prom/prometheus:latest
|
||||
container_name: insight-prometheus
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- "--config.file=/etc/prometheus/prometheus.yml"
|
||||
- "--storage.tsdb.path=/prometheus"
|
||||
- "--storage.tsdb.retention.time=30d"
|
||||
- "--web.enable-lifecycle"
|
||||
volumes:
|
||||
- ./config/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- prometheus-data:/prometheus
|
||||
networks:
|
||||
- insight-web
|
||||
ports:
|
||||
- "127.0.0.1:9090:9090"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:9090/-/ready"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
# --------------------------------------------------------
|
||||
# Grafana - Dashboards & Alerting
|
||||
# --------------------------------------------------------
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
container_name: insight-grafana
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
|
||||
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?GRAFANA_ADMIN_PASSWORD muss gesetzt sein}
|
||||
GF_SERVER_ROOT_URL: "http://localhost:3001"
|
||||
GF_SERVER_HTTP_PORT: 3001
|
||||
# Datenquellen per Provisioning
|
||||
GF_PATHS_PROVISIONING: /etc/grafana/provisioning
|
||||
# Keine anonyme Nutzung
|
||||
GF_AUTH_ANONYMOUS_ENABLED: "false"
|
||||
# Logging
|
||||
GF_LOG_LEVEL: info
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
- ./config/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
networks:
|
||||
- insight-web
|
||||
ports:
|
||||
- "127.0.0.1:3001:3001"
|
||||
depends_on:
|
||||
prometheus:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:3001/api/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
# --------------------------------------------------------
|
||||
# Loki - Log-Aggregation
|
||||
# --------------------------------------------------------
|
||||
loki:
|
||||
image: grafana/loki:latest
|
||||
container_name: insight-loki
|
||||
restart: unless-stopped
|
||||
command: -config.file=/etc/loki/loki.yml
|
||||
volumes:
|
||||
- ./config/loki/loki.yml:/etc/loki/loki.yml:ro
|
||||
- loki-data:/loki
|
||||
networks:
|
||||
- insight-web
|
||||
ports:
|
||||
- "127.0.0.1:3100:3100"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget --spider -q http://localhost:3100/ready || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
# --------------------------------------------------------
|
||||
# Promtail - Log-Collector (liest Docker Logs)
|
||||
# --------------------------------------------------------
|
||||
promtail:
|
||||
image: grafana/promtail:latest
|
||||
container_name: insight-promtail
|
||||
restart: unless-stopped
|
||||
command: -config.file=/etc/promtail/promtail.yml
|
||||
volumes:
|
||||
- ./config/promtail/promtail.yml:/etc/promtail/promtail.yml:ro
|
||||
- /var/log:/var/log:ro
|
||||
- /var/lib/docker/containers:/var/lib/docker/containers:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
networks:
|
||||
- insight-web
|
||||
depends_on:
|
||||
- loki
|
||||
|
||||
# --------------------------------------------------------
|
||||
# Tempo - Distributed Tracing
|
||||
# --------------------------------------------------------
|
||||
tempo:
|
||||
image: grafana/tempo:latest
|
||||
container_name: insight-tempo
|
||||
restart: unless-stopped
|
||||
command: -config.file=/etc/tempo/tempo.yml
|
||||
volumes:
|
||||
- ./config/tempo/tempo.yml:/etc/tempo/tempo.yml:ro
|
||||
- tempo-data:/var/tempo
|
||||
networks:
|
||||
- insight-web
|
||||
ports:
|
||||
- "127.0.0.1:3200:3200" # Tempo API
|
||||
- "127.0.0.1:4317:4317" # OTLP gRPC
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget --spider -q http://localhost:3200/ready || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
# --------------------------------------------------------
|
||||
# cAdvisor - Container-Metriken
|
||||
# --------------------------------------------------------
|
||||
cadvisor:
|
||||
image: gcr.io/cadvisor/cadvisor:latest
|
||||
container_name: insight-cadvisor
|
||||
restart: unless-stopped
|
||||
privileged: true
|
||||
volumes:
|
||||
- /:/rootfs:ro
|
||||
- /var/run:/var/run:ro
|
||||
- /sys:/sys:ro
|
||||
- /var/lib/docker/:/var/lib/docker:ro
|
||||
- /dev/disk/:/dev/disk:ro
|
||||
networks:
|
||||
- insight-web
|
||||
ports:
|
||||
- "127.0.0.1:8081:8080"
|
||||
|
||||
# --------------------------------------------------------
|
||||
# PostgreSQL Exporter - DB-Metriken fuer Prometheus
|
||||
# --------------------------------------------------------
|
||||
postgres-exporter:
|
||||
image: prometheuscommunity/postgres-exporter:latest
|
||||
container_name: insight-postgres-exporter
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATA_SOURCE_NAME: "postgresql://${DB_USER:-insight}:${DB_PASSWORD}@postgres:5432/${DB_NAME:-platform_core}?sslmode=disable"
|
||||
networks:
|
||||
- insight-web
|
||||
- insight-db
|
||||
ports:
|
||||
- "127.0.0.1:9187:9187"
|
||||
depends_on:
|
||||
- postgres
|
||||
307
docker-compose.yml
Normal file
307
docker-compose.yml
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
# ============================================================
|
||||
# INSIGHT MVP - Docker Compose (Basis-Services)
|
||||
# ============================================================
|
||||
# Startet alle Kerndienste der INSIGHT-Plattform.
|
||||
# Observability-Stack separat: docker-compose.observability.yml
|
||||
#
|
||||
# Nutzung:
|
||||
# docker compose up -d
|
||||
# docker compose logs -f core
|
||||
# docker compose ps
|
||||
# ============================================================
|
||||
|
||||
networks:
|
||||
insight-web:
|
||||
driver: bridge
|
||||
name: insight-web
|
||||
insight-db:
|
||||
driver: bridge
|
||||
name: insight-db
|
||||
internal: true
|
||||
insight-cache:
|
||||
driver: bridge
|
||||
name: insight-cache
|
||||
internal: true
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
name: insight-postgres-data
|
||||
redis-data:
|
||||
name: insight-redis-data
|
||||
step-ca-data:
|
||||
name: insight-step-ca-data
|
||||
traefik-certs:
|
||||
name: insight-traefik-certs
|
||||
|
||||
services:
|
||||
# --------------------------------------------------------
|
||||
# Traefik - API Gateway / Reverse Proxy
|
||||
# --------------------------------------------------------
|
||||
traefik:
|
||||
image: traefik:3
|
||||
container_name: insight-traefik
|
||||
restart: unless-stopped
|
||||
command:
|
||||
# API & Dashboard
|
||||
- "--api.dashboard=true"
|
||||
- "--api.insecure=true"
|
||||
# Entrypoints
|
||||
- "--entrypoints.web.address=:80"
|
||||
- "--entrypoints.websecure.address=:443"
|
||||
# HTTP -> HTTPS Redirect
|
||||
- "--entrypoints.web.http.redirections.entryPoint.to=websecure"
|
||||
- "--entrypoints.web.http.redirections.entryPoint.scheme=https"
|
||||
# Docker Provider
|
||||
- "--providers.docker=true"
|
||||
- "--providers.docker.exposedbydefault=false"
|
||||
- "--providers.docker.network=insight-web"
|
||||
# File Provider (fuer dynamische Konfiguration)
|
||||
- "--providers.file.directory=/etc/traefik/dynamic"
|
||||
- "--providers.file.watch=true"
|
||||
# TLS (self-signed fuer Dev)
|
||||
- "--entrypoints.websecure.http.tls=true"
|
||||
# Logging
|
||||
- "--log.level=INFO"
|
||||
- "--accesslog=true"
|
||||
- "--accesslog.format=json"
|
||||
# Metrics fuer Prometheus
|
||||
- "--metrics.prometheus=true"
|
||||
- "--metrics.prometheus.entryPoint=metrics"
|
||||
- "--entrypoints.metrics.address=:8082"
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "8080:8080" # Dashboard (nur intern)
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./config/traefik/dynamic:/etc/traefik/dynamic:ro
|
||||
- traefik-certs:/certs
|
||||
networks:
|
||||
- insight-web
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# Dashboard Route (nur intern)
|
||||
- "traefik.http.routers.dashboard.rule=Host(`traefik.insight-dev.xinion.lan`)"
|
||||
- "traefik.http.routers.dashboard.service=api@internal"
|
||||
- "traefik.http.routers.dashboard.entrypoints=websecure"
|
||||
healthcheck:
|
||||
test: ["CMD", "traefik", "healthcheck"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
# --------------------------------------------------------
|
||||
# PostgreSQL - Datenbank
|
||||
# --------------------------------------------------------
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: insight-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USER:-insight}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD muss gesetzt sein}
|
||||
POSTGRES_DB: ${DB_NAME:-platform_core}
|
||||
# Performance-Tuning fuer 8GB RAM VM
|
||||
POSTGRES_INITDB_ARGS: "--data-checksums"
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
- ./config/postgres/init:/docker-entrypoint-initdb.d:ro
|
||||
networks:
|
||||
- insight-db
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-insight} -d ${DB_NAME:-platform_core}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
# Performance-Tuning via Command
|
||||
command:
|
||||
- "postgres"
|
||||
- "-c"
|
||||
- "shared_buffers=2GB"
|
||||
- "-c"
|
||||
- "effective_cache_size=6GB"
|
||||
- "-c"
|
||||
- "work_mem=16MB"
|
||||
- "-c"
|
||||
- "maintenance_work_mem=512MB"
|
||||
- "-c"
|
||||
- "max_connections=200"
|
||||
- "-c"
|
||||
- "log_min_duration_statement=500"
|
||||
- "-c"
|
||||
- "log_statement=ddl"
|
||||
|
||||
# --------------------------------------------------------
|
||||
# PgBouncer - Connection Pooling
|
||||
# --------------------------------------------------------
|
||||
pgbouncer:
|
||||
image: edoburu/pgbouncer:latest
|
||||
container_name: insight-pgbouncer
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATABASE_URL: "postgres://${DB_USER:-insight}:${DB_PASSWORD}@postgres:5432/${DB_NAME:-platform_core}"
|
||||
POOL_MODE: transaction
|
||||
MAX_CLIENT_CONN: 500
|
||||
DEFAULT_POOL_SIZE: 25
|
||||
MIN_POOL_SIZE: 5
|
||||
RESERVE_POOL_SIZE: 5
|
||||
SERVER_RESET_QUERY: "DISCARD ALL"
|
||||
SERVER_CHECK_DELAY: 30
|
||||
SERVER_CHECK_QUERY: "SELECT 1"
|
||||
AUTH_TYPE: scram-sha-256
|
||||
networks:
|
||||
- insight-db
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 6432"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
# --------------------------------------------------------
|
||||
# Redis - Cache, Sessions, Event Bus
|
||||
# --------------------------------------------------------
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: insight-redis
|
||||
restart: unless-stopped
|
||||
command: >
|
||||
redis-server
|
||||
--requirepass ${REDIS_PASSWORD:-}
|
||||
--maxmemory 512mb
|
||||
--maxmemory-policy allkeys-lru
|
||||
--appendonly yes
|
||||
--appendfsync everysec
|
||||
--save 60 1000
|
||||
--save 300 100
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
networks:
|
||||
- insight-cache
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-}", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
# --------------------------------------------------------
|
||||
# step-ca - Interne Certificate Authority (mTLS)
|
||||
# --------------------------------------------------------
|
||||
step-ca:
|
||||
image: smallstep/step-ca:latest
|
||||
container_name: insight-step-ca
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DOCKER_STEPCA_INIT_NAME: "INSIGHT Internal CA"
|
||||
DOCKER_STEPCA_INIT_DNS_NAMES: "step-ca,localhost"
|
||||
DOCKER_STEPCA_INIT_REMOTE_MANAGEMENT: "true"
|
||||
DOCKER_STEPCA_INIT_ACME: "true"
|
||||
volumes:
|
||||
- step-ca-data:/home/step
|
||||
networks:
|
||||
- insight-web
|
||||
- insight-db
|
||||
- insight-cache
|
||||
healthcheck:
|
||||
test: ["CMD", "step", "ca", "health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
# --------------------------------------------------------
|
||||
# Core-Service - NestJS Backend
|
||||
# --------------------------------------------------------
|
||||
core:
|
||||
build:
|
||||
context: ./packages/core-service
|
||||
dockerfile: Dockerfile
|
||||
target: development
|
||||
container_name: insight-core
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: ${NODE_ENV:-development}
|
||||
APP_PORT: ${APP_PORT:-3000}
|
||||
APP_URL: ${APP_URL:-https://insight-dev.xinion.lan}
|
||||
FRONTEND_URL: ${FRONTEND_URL:-https://insight-dev.xinion.lan}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
# Database (via PgBouncer)
|
||||
DATABASE_URL: "postgresql://${DB_USER:-insight}:${DB_PASSWORD}@pgbouncer:6432/${DB_NAME:-platform_core}"
|
||||
# Database (direkt fuer Migrations)
|
||||
DATABASE_URL_DIRECT: "postgresql://${DB_USER:-insight}:${DB_PASSWORD}@postgres:5432/${DB_NAME:-platform_core}"
|
||||
# Redis
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||
# JWT
|
||||
JWT_PRIVATE_KEY_PATH: ${JWT_PRIVATE_KEY_PATH:-/app/keys/jwt-private.pem}
|
||||
JWT_PUBLIC_KEY_PATH: ${JWT_PUBLIC_KEY_PATH:-/app/keys/jwt-public.pem}
|
||||
JWT_ACCESS_TOKEN_EXPIRY: ${JWT_ACCESS_TOKEN_EXPIRY:-15m}
|
||||
JWT_REFRESH_TOKEN_EXPIRY: ${JWT_REFRESH_TOKEN_EXPIRY:-7d}
|
||||
JWT_ISSUER: ${JWT_ISSUER:-insight-platform}
|
||||
# Bcrypt
|
||||
BCRYPT_COST: ${BCRYPT_COST:-12}
|
||||
# CORS
|
||||
CORS_ORIGINS: ${CORS_ORIGINS:-https://insight-dev.xinion.lan}
|
||||
# Rate Limiting
|
||||
THROTTLE_TTL: ${THROTTLE_TTL:-60000}
|
||||
THROTTLE_LIMIT: ${THROTTLE_LIMIT:-200}
|
||||
networks:
|
||||
- insight-web
|
||||
- insight-db
|
||||
- insight-cache
|
||||
depends_on:
|
||||
pgbouncer:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# API Routing
|
||||
- "traefik.http.routers.core-api.rule=Host(`insight-dev.xinion.lan`) && PathPrefix(`/api`)"
|
||||
- "traefik.http.routers.core-api.entrypoints=websecure"
|
||||
- "traefik.http.routers.core-api.service=core-api"
|
||||
- "traefik.http.services.core-api.loadbalancer.server.port=3000"
|
||||
# Health-Endpunkt (ohne Auth)
|
||||
- "traefik.http.routers.core-health.rule=Host(`insight-dev.xinion.lan`) && Path(`/health`)"
|
||||
- "traefik.http.routers.core-health.entrypoints=websecure"
|
||||
- "traefik.http.routers.core-health.service=core-api"
|
||||
# Rate Limiting Middleware
|
||||
- "traefik.http.middlewares.api-ratelimit.ratelimit.average=100"
|
||||
- "traefik.http.middlewares.api-ratelimit.ratelimit.burst=50"
|
||||
- "traefik.http.routers.core-api.middlewares=api-ratelimit"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
# --------------------------------------------------------
|
||||
# Frontend - React App (via Nginx)
|
||||
# --------------------------------------------------------
|
||||
frontend:
|
||||
build:
|
||||
context: ./packages/frontend
|
||||
dockerfile: Dockerfile
|
||||
target: development
|
||||
container_name: insight-frontend
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- insight-web
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# Frontend Routing (Catch-All nach API)
|
||||
- "traefik.http.routers.frontend.rule=Host(`insight-dev.xinion.lan`)"
|
||||
- "traefik.http.routers.frontend.entrypoints=websecure"
|
||||
- "traefik.http.routers.frontend.service=frontend"
|
||||
- "traefik.http.routers.frontend.priority=1"
|
||||
- "traefik.http.services.frontend.loadbalancer.server.port=8080"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080/ || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
7
packages/core-service/.dockerignore
Normal file
7
packages/core-service/.dockerignore
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules
|
||||
dist
|
||||
coverage
|
||||
.env
|
||||
*.md
|
||||
.git
|
||||
.gitignore
|
||||
56
packages/core-service/Dockerfile
Normal file
56
packages/core-service/Dockerfile
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# ============================================================
|
||||
# INSIGHT Core-Service - Multi-Stage Dockerfile
|
||||
# ============================================================
|
||||
|
||||
# --- Base Stage ---
|
||||
FROM node:20-alpine AS base
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache openssl
|
||||
|
||||
# --- Dependencies Stage ---
|
||||
FROM base AS deps
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --ignore-scripts
|
||||
# Prisma Generate braucht die Schema-Dateien
|
||||
COPY prisma ./prisma
|
||||
RUN npx prisma generate --schema=prisma/core.schema.prisma
|
||||
|
||||
# --- Development Stage ---
|
||||
FROM base AS development
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npx prisma generate --schema=prisma/core.schema.prisma
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "start:dev"]
|
||||
|
||||
# --- Build Stage ---
|
||||
FROM base AS build
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# --- Production Stage ---
|
||||
FROM base AS production
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Nur Produktions-Dependencies
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --omit=dev --ignore-scripts
|
||||
|
||||
# Prisma Client generieren
|
||||
COPY prisma ./prisma
|
||||
RUN npx prisma generate --schema=prisma/core.schema.prisma
|
||||
|
||||
# Kompilierter Code
|
||||
COPY --from=build /app/dist ./dist
|
||||
|
||||
# Non-root User
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nestjs -u 1001 -G nodejs
|
||||
USER nestjs
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "dist/main"]
|
||||
8
packages/core-service/nest-cli.json
Normal file
8
packages/core-service/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
93
packages/core-service/package.json
Normal file
93
packages/core-service/package.json
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
{
|
||||
"name": "@insight/core-service",
|
||||
"version": "0.1.0",
|
||||
"description": "INSIGHT MVP - Core Service (NestJS Backend)",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,test}/**/*.ts\" --fix",
|
||||
"lint:check": "eslint \"{src,test}/**/*.ts\"",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prisma:generate": "prisma generate --schema=prisma/core.schema.prisma",
|
||||
"prisma:migrate": "prisma migrate dev --schema=prisma/core.schema.prisma",
|
||||
"prisma:migrate:deploy": "prisma migrate deploy --schema=prisma/core.schema.prisma",
|
||||
"prisma:studio": "prisma studio --schema=prisma/core.schema.prisma"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.4.0",
|
||||
"@nestjs/config": "^3.2.0",
|
||||
"@nestjs/core": "^10.4.0",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.4.0",
|
||||
"@nestjs/schedule": "^4.1.0",
|
||||
"@nestjs/swagger": "^7.4.0",
|
||||
"@nestjs/throttler": "^6.2.0",
|
||||
"@prisma/client": "^6.4.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"helmet": "^8.0.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"otplib": "^12.0.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"uuid": "^10.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.0",
|
||||
"@nestjs/schematics": "^10.1.0",
|
||||
"@nestjs/testing": "^10.4.0",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/cookie-parser": "^1.4.7",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.0",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.3.0",
|
||||
"prisma": "^6.4.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-jest": "^29.2.0",
|
||||
"ts-loader": "^9.5.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.6.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": ["**/*.(t|j)s"],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node",
|
||||
"moduleNameMapper": {
|
||||
"^@/(.*)$": "<rootDir>/$1"
|
||||
}
|
||||
}
|
||||
}
|
||||
181
packages/core-service/prisma/core.schema.prisma
Normal file
181
packages/core-service/prisma/core.schema.prisma
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
// ============================================================
|
||||
// INSIGHT MVP - Core Schema (platform_core Datenbank)
|
||||
// ============================================================
|
||||
// Zentrale Plattform-Tabellen: Users, Tenants, Auth, Modules
|
||||
// Kein raw SQL - nur Prisma!
|
||||
// ============================================================
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
directUrl = env("DATABASE_URL_DIRECT")
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// User - Plattform-Benutzer
|
||||
// --------------------------------------------------------
|
||||
model User {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
email String @unique @db.VarChar(255)
|
||||
firstName String @map("first_name") @db.VarChar(100)
|
||||
lastName String @map("last_name") @db.VarChar(100)
|
||||
role String @default("USER") @db.VarChar(50) // PLATFORM_ADMIN, TENANT_ADMIN, USER
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
|
||||
// 2FA
|
||||
twoFactorEnabled Boolean @default(false) @map("two_factor_enabled")
|
||||
|
||||
// Login-Tracking
|
||||
lastLogin DateTime? @map("last_login")
|
||||
failedLoginAttempts Int @default(0) @map("failed_login_attempts")
|
||||
lastFailedLogin DateTime? @map("last_failed_login")
|
||||
|
||||
// Timestamps
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relationen
|
||||
authProvider AuthProvider[]
|
||||
tenantMemberships TenantMembership[]
|
||||
auditLogs AuditLog[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// AuthProvider - Authentifizierungs-Provider pro User
|
||||
// --------------------------------------------------------
|
||||
// Unterstuetzt mehrere Auth-Methoden pro User:
|
||||
// LOCAL (Passwort), MS_SSO (spaeter), M2M (Machine-to-Machine)
|
||||
model AuthProvider {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
provider String @db.VarChar(50) // LOCAL, MS_SSO, M2M
|
||||
providerId String? @map("provider_id") @db.VarChar(255) // Externe ID (z.B. MS Object ID)
|
||||
passwordHash String? @map("password_hash") @db.VarChar(255)
|
||||
totpSecret String? @map("totp_secret") @db.VarChar(255) // Verschluesselt
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relationen
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, provider])
|
||||
@@map("auth_providers")
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Tenant - Mandant
|
||||
// --------------------------------------------------------
|
||||
model Tenant {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String @db.VarChar(200)
|
||||
slug String @unique @db.VarChar(50) // URL-freundlich, fuer DB-Name
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
|
||||
// Mandant-Einstellungen (JSON)
|
||||
settings Json @default("{}")
|
||||
|
||||
// Timestamps
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relationen
|
||||
members TenantMembership[]
|
||||
modules TenantModule[]
|
||||
|
||||
@@map("tenants")
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// TenantMembership - User-Tenant-Zuordnung (M:N)
|
||||
// --------------------------------------------------------
|
||||
model TenantMembership {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
tenantId String @map("tenant_id") @db.Uuid
|
||||
tenantRole String @default("MEMBER") @map("tenant_role") @db.VarChar(50) // ADMIN, MEMBER, VIEWER
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relationen
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, tenantId])
|
||||
@@map("tenant_memberships")
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Module - Verfuegbare Plattform-Module
|
||||
// --------------------------------------------------------
|
||||
model Module {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
key String @unique @db.VarChar(50) // z.B. "crm", "project", "docs"
|
||||
name String @db.VarChar(100)
|
||||
description String? @db.Text
|
||||
version String @default("1.0.0") @db.VarChar(20)
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relationen
|
||||
tenantModules TenantModule[]
|
||||
|
||||
@@map("modules")
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// TenantModule - Welcher Tenant welche Module nutzt
|
||||
// --------------------------------------------------------
|
||||
model TenantModule {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
tenantId String @map("tenant_id") @db.Uuid
|
||||
moduleId String @map("module_id") @db.Uuid
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
|
||||
// Modul-spezifische Konfiguration pro Tenant
|
||||
config Json @default("{}")
|
||||
|
||||
activatedAt DateTime @default(now()) @map("activated_at")
|
||||
|
||||
// Relationen
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([tenantId, moduleId])
|
||||
@@map("tenant_modules")
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// AuditLog - Plattform-weites Audit-Log
|
||||
// --------------------------------------------------------
|
||||
model AuditLog {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String? @map("user_id") @db.Uuid
|
||||
action String @db.VarChar(100) // z.B. "user.login", "tenant.create"
|
||||
entity String @db.VarChar(100) // z.B. "User", "Tenant"
|
||||
entityId String? @map("entity_id") @db.VarChar(255)
|
||||
details Json? // Zusaetzliche Informationen
|
||||
ipAddress String? @map("ip_address") @db.VarChar(45)
|
||||
userAgent String? @map("user_agent") @db.Text
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
// Relationen
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([userId])
|
||||
@@index([action])
|
||||
@@index([entity, entityId])
|
||||
@@index([createdAt])
|
||||
@@map("audit_logs")
|
||||
}
|
||||
111
packages/core-service/prisma/tenant.schema.prisma
Normal file
111
packages/core-service/prisma/tenant.schema.prisma
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// ============================================================
|
||||
// INSIGHT MVP - Tenant Schema (tenant_{slug} Datenbanken)
|
||||
// ============================================================
|
||||
// Jeder Mandant hat eine eigene Datenbank mit diesen Tabellen.
|
||||
// Schema wird per Prisma Migrate auf neue Tenant-DBs angewandt.
|
||||
//
|
||||
// HINWEIS: Dieses Schema wird derzeit als Referenz gefuehrt.
|
||||
// Die tatsaechliche Migration auf Tenant-DBs erfolgt
|
||||
// in Sprint 2+ wenn das CRM-Modul implementiert wird.
|
||||
// ============================================================
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
output = "../node_modules/.prisma/tenant-client"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("TENANT_DATABASE_URL")
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Contact - CRM-Kontakte (Personen & Organisationen)
|
||||
// --------------------------------------------------------
|
||||
model Contact {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
type ContactType @default(PERSON)
|
||||
|
||||
// Person
|
||||
firstName String? @map("first_name") @db.VarChar(100)
|
||||
lastName String? @map("last_name") @db.VarChar(100)
|
||||
|
||||
// Organisation
|
||||
companyName String? @map("company_name") @db.VarChar(200)
|
||||
|
||||
// Kontaktdaten
|
||||
email String? @db.VarChar(255)
|
||||
phone String? @db.VarChar(50)
|
||||
mobile String? @db.VarChar(50)
|
||||
website String? @db.VarChar(500)
|
||||
|
||||
// Adresse
|
||||
street String? @db.VarChar(200)
|
||||
zip String? @db.VarChar(20)
|
||||
city String? @db.VarChar(100)
|
||||
state String? @db.VarChar(100)
|
||||
country String? @default("DE") @db.VarChar(2)
|
||||
|
||||
// Zusaetzlich
|
||||
notes String? @db.Text
|
||||
tags String[] @default([])
|
||||
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
|
||||
// Wer hat erstellt/bearbeitet (User-IDs aus platform_core)
|
||||
createdBy String @map("created_by") @db.Uuid
|
||||
updatedBy String? @map("updated_by") @db.Uuid
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relationen
|
||||
activities Activity[]
|
||||
|
||||
@@index([email])
|
||||
@@index([companyName])
|
||||
@@index([lastName, firstName])
|
||||
@@map("contacts")
|
||||
}
|
||||
|
||||
enum ContactType {
|
||||
PERSON
|
||||
ORGANIZATION
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Activity - CRM-Aktivitaeten (Notizen, Anrufe, E-Mails)
|
||||
// --------------------------------------------------------
|
||||
model Activity {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
contactId String @map("contact_id") @db.Uuid
|
||||
type ActivityType
|
||||
subject String @db.VarChar(500)
|
||||
description String? @db.Text
|
||||
|
||||
// Terminierung
|
||||
scheduledAt DateTime? @map("scheduled_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
|
||||
// Wer hat erstellt (User-ID aus platform_core)
|
||||
createdBy String @map("created_by") @db.Uuid
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relationen
|
||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([contactId])
|
||||
@@index([type])
|
||||
@@index([scheduledAt])
|
||||
@@map("activities")
|
||||
}
|
||||
|
||||
enum ActivityType {
|
||||
NOTE
|
||||
CALL
|
||||
EMAIL
|
||||
MEETING
|
||||
TASK
|
||||
}
|
||||
57
packages/core-service/src/app.module.ts
Normal file
57
packages/core-service/src/app.module.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { RedisModule } from './redis/redis.module';
|
||||
import { AuthModule } from './core/auth/auth.module';
|
||||
import { UsersModule } from './core/users/users.module';
|
||||
import { TenantsModule } from './core/tenants/tenants.module';
|
||||
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
||||
import { validateConfig } from './config/env.validation';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// Konfiguration (.env)
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
validate: validateConfig,
|
||||
}),
|
||||
|
||||
// Rate Limiting
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
ttl: parseInt(process.env.THROTTLE_TTL ?? '60000', 10),
|
||||
limit: parseInt(process.env.THROTTLE_LIMIT ?? '200', 10),
|
||||
},
|
||||
]),
|
||||
|
||||
// Cron-Jobs
|
||||
ScheduleModule.forRoot(),
|
||||
|
||||
// Infrastruktur-Module
|
||||
PrismaModule,
|
||||
RedisModule,
|
||||
|
||||
// Feature-Module
|
||||
HealthModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
TenantsModule,
|
||||
],
|
||||
providers: [
|
||||
// Global Guards: Alle Routen sind standardmaessig geschuetzt
|
||||
// Oeffentliche Routen muessen mit @Public() dekoriert werden
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: JwtAuthGuard,
|
||||
},
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: ThrottlerGuard,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string; // User-ID
|
||||
email: string;
|
||||
role: string;
|
||||
tenantId?: string;
|
||||
tenantSlug?: string;
|
||||
jti: string; // Token-ID fuer Revocation
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @CurrentUser() - Extrahiert den authentifizierten User aus dem Request.
|
||||
*
|
||||
* Beispiel:
|
||||
* @Get('profile')
|
||||
* getProfile(@CurrentUser() user: JwtPayload) {
|
||||
* return user;
|
||||
* }
|
||||
*
|
||||
* @Get('profile/id')
|
||||
* getProfileId(@CurrentUser('sub') userId: string) {
|
||||
* return userId;
|
||||
* }
|
||||
*/
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: keyof JwtPayload | undefined, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest<Request>();
|
||||
const user = request.user as JwtPayload;
|
||||
return data ? user?.[data] : user;
|
||||
},
|
||||
);
|
||||
3
packages/core-service/src/common/decorators/index.ts
Normal file
3
packages/core-service/src/common/decorators/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { Public, IS_PUBLIC_KEY } from './public.decorator';
|
||||
export { Roles, ROLES_KEY } from './roles.decorator';
|
||||
export { CurrentUser, type JwtPayload } from './current-user.decorator';
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
interface ErrorResponse {
|
||||
statusCode: number;
|
||||
message: string;
|
||||
error: string;
|
||||
timestamp: string;
|
||||
path: string;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GlobalExceptionFilter - Faengt alle unbehandelten Exceptions.
|
||||
*
|
||||
* - Strukturierte Fehlerantworten im JSON-Format
|
||||
* - Logging aller Fehler
|
||||
* - Keine internen Details in Produktions-Fehlern
|
||||
*/
|
||||
@Catch()
|
||||
export class GlobalExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(GlobalExceptionFilter.name);
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
let statusCode: number;
|
||||
let message: string;
|
||||
let error: string;
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
statusCode = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
|
||||
if (typeof exceptionResponse === 'string') {
|
||||
message = exceptionResponse;
|
||||
error = exception.name;
|
||||
} else if (typeof exceptionResponse === 'object') {
|
||||
const resp = exceptionResponse as Record<string, unknown>;
|
||||
message = Array.isArray(resp.message)
|
||||
? resp.message.join(', ')
|
||||
: (resp.message as string) || exception.message;
|
||||
error = (resp.error as string) || exception.name;
|
||||
} else {
|
||||
message = exception.message;
|
||||
error = exception.name;
|
||||
}
|
||||
} else if (exception instanceof Error) {
|
||||
statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
message =
|
||||
process.env.NODE_ENV === 'production'
|
||||
? 'Interner Serverfehler'
|
||||
: exception.message;
|
||||
error = 'InternalServerError';
|
||||
|
||||
// Stack-Trace loggen fuer unerwartete Fehler
|
||||
this.logger.error(
|
||||
`Unbehandelter Fehler: ${exception.message}`,
|
||||
exception.stack,
|
||||
);
|
||||
} else {
|
||||
statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
message = 'Unbekannter Fehler';
|
||||
error = 'UnknownError';
|
||||
this.logger.error('Unbekannter Fehler:', exception);
|
||||
}
|
||||
|
||||
const errorResponse: ErrorResponse = {
|
||||
statusCode,
|
||||
message,
|
||||
error,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
requestId: request.headers['x-request-id'] as string | undefined,
|
||||
};
|
||||
|
||||
response.status(statusCode).json(errorResponse);
|
||||
}
|
||||
}
|
||||
65
packages/core-service/src/common/guards/jwt-auth.guard.ts
Normal file
65
packages/core-service/src/common/guards/jwt-auth.guard.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import {
|
||||
Injectable,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||
import { RedisService } from '../../redis/redis.service';
|
||||
import { JwtPayload } from '../decorators/current-user.decorator';
|
||||
|
||||
/**
|
||||
* JwtAuthGuard - Globaler Guard fuer JWT-Authentifizierung.
|
||||
*
|
||||
* - Standardmaessig aktiv auf ALLEN Routen
|
||||
* - @Public() dekorierte Routen werden uebersprungen
|
||||
* - Prueft zusaetzlich ob der Token revoked wurde (Redis Blocklist)
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly redis: RedisService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
// @Public() Routen ueberspringen
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// JWT validieren (Passport Strategy)
|
||||
const canActivate = await super.canActivate(context);
|
||||
if (!canActivate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Token-Revocation pruefen (Redis Blocklist)
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user as JwtPayload;
|
||||
|
||||
if (user?.jti) {
|
||||
const isBlocked = await this.redis.isTokenBlocked(user.jti);
|
||||
if (isBlocked) {
|
||||
throw new UnauthorizedException('Token wurde widerrufen');
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
handleRequest<T>(err: Error | null, user: T, info: Error | undefined): T {
|
||||
if (err || !user) {
|
||||
throw err || new UnauthorizedException('Zugriff verweigert');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
37
packages/core-service/src/common/guards/roles.guard.ts
Normal file
37
packages/core-service/src/common/guards/roles.guard.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||
import { JwtPayload } from '../decorators/current-user.decorator';
|
||||
|
||||
/**
|
||||
* RolesGuard - Prueft ob der User die erforderliche Rolle hat.
|
||||
*
|
||||
* Wird zusammen mit @Roles() verwendet:
|
||||
* @Roles('PLATFORM_ADMIN')
|
||||
* @UseGuards(RolesGuard)
|
||||
* @Get('admin/dashboard')
|
||||
*/
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<string[]>(
|
||||
ROLES_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
if (!requiredRoles || requiredRoles.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user as JwtPayload;
|
||||
|
||||
if (!user?.role) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return requiredRoles.includes(user.role);
|
||||
}
|
||||
}
|
||||
98
packages/core-service/src/config/env.validation.ts
Normal file
98
packages/core-service/src/config/env.validation.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { plainToInstance } from 'class-transformer';
|
||||
import {
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
Max,
|
||||
validateSync,
|
||||
} from 'class-validator';
|
||||
|
||||
enum Environment {
|
||||
Development = 'development',
|
||||
Production = 'production',
|
||||
Test = 'test',
|
||||
}
|
||||
|
||||
class EnvironmentVariables {
|
||||
@IsEnum(Environment)
|
||||
NODE_ENV: Environment = Environment.Development;
|
||||
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(65535)
|
||||
APP_PORT = 3000;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
APP_URL = 'https://insight-dev.xinion.lan';
|
||||
|
||||
// Datenbank
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
DATABASE_URL!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
DATABASE_URL_DIRECT?: string;
|
||||
|
||||
// Redis
|
||||
@IsString()
|
||||
REDIS_HOST = 'redis';
|
||||
|
||||
@IsNumber()
|
||||
REDIS_PORT = 6379;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
REDIS_PASSWORD?: string;
|
||||
|
||||
// JWT
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
JWT_PRIVATE_KEY_PATH = '/app/keys/jwt-private.pem';
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
JWT_PUBLIC_KEY_PATH = '/app/keys/jwt-public.pem';
|
||||
|
||||
@IsString()
|
||||
JWT_ACCESS_TOKEN_EXPIRY = '15m';
|
||||
|
||||
@IsString()
|
||||
JWT_REFRESH_TOKEN_EXPIRY = '7d';
|
||||
|
||||
@IsString()
|
||||
JWT_ISSUER = 'insight-platform';
|
||||
|
||||
// Bcrypt
|
||||
@IsNumber()
|
||||
@Min(10)
|
||||
@Max(14)
|
||||
BCRYPT_COST = 12;
|
||||
|
||||
// Rate Limiting
|
||||
@IsNumber()
|
||||
THROTTLE_TTL = 60000;
|
||||
|
||||
@IsNumber()
|
||||
THROTTLE_LIMIT = 200;
|
||||
}
|
||||
|
||||
export function validateConfig(
|
||||
config: Record<string, unknown>,
|
||||
): EnvironmentVariables {
|
||||
const validatedConfig = plainToInstance(EnvironmentVariables, config, {
|
||||
enableImplicitConversion: true,
|
||||
});
|
||||
const errors = validateSync(validatedConfig, {
|
||||
skipMissingProperties: false,
|
||||
});
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Config validation error: ${errors.toString()}`);
|
||||
}
|
||||
return validatedConfig;
|
||||
}
|
||||
121
packages/core-service/src/core/auth/auth.controller.ts
Normal file
121
packages/core-service/src/core/auth/auth.controller.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
Res,
|
||||
Req,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Request, Response } from 'express';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
import { CurrentUser, JwtPayload } from '../../common/decorators/current-user.decorator';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
|
||||
@ApiTags('Authentifizierung')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/login
|
||||
* Login mit E-Mail + Passwort (+ optionaler TOTP-Code).
|
||||
*/
|
||||
@Post('login')
|
||||
@Public()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Login mit E-Mail und Passwort' })
|
||||
async login(
|
||||
@Body() dto: LoginDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const result = await this.authService.login(dto);
|
||||
|
||||
// 2FA erforderlich - kein Token setzen
|
||||
if (result.requiresTwoFactor) {
|
||||
return {
|
||||
requiresTwoFactor: true,
|
||||
message: 'Bitte 2FA-Code eingeben',
|
||||
};
|
||||
}
|
||||
|
||||
// Refresh-Token als HttpOnly Cookie setzen (NICHT im localStorage!)
|
||||
// Regel: Kein localStorage fuer Tokens
|
||||
this.setRefreshTokenCookie(res, result.accessToken);
|
||||
|
||||
return {
|
||||
accessToken: result.accessToken,
|
||||
user: result.user,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/refresh
|
||||
* Token-Refresh via HttpOnly Cookie.
|
||||
*/
|
||||
@Post('refresh')
|
||||
@Public()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Access-Token erneuern (Silent Refresh)' })
|
||||
async refresh(
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const refreshToken = req.cookies?.refresh_token as string | undefined;
|
||||
if (!refreshToken) {
|
||||
res.status(HttpStatus.UNAUTHORIZED).json({
|
||||
message: 'Kein Refresh-Token vorhanden',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const tokens = await this.authService.refreshTokens(refreshToken);
|
||||
this.setRefreshTokenCookie(res, tokens.refreshToken);
|
||||
|
||||
return {
|
||||
accessToken: tokens.accessToken,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/logout
|
||||
* Logout: Tokens invalidieren, Cookie loeschen.
|
||||
*/
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiBearerAuth('access-token')
|
||||
@ApiOperation({ summary: 'Logout und Token-Invalidierung' })
|
||||
async logout(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const refreshToken = req.cookies?.refresh_token as string | undefined;
|
||||
await this.authService.logout(user, refreshToken);
|
||||
|
||||
// Refresh-Token Cookie loeschen
|
||||
res.clearCookie('refresh_token', {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
path: '/api/v1/auth',
|
||||
});
|
||||
|
||||
return { message: 'Erfolgreich abgemeldet' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt das Refresh-Token als HttpOnly, Secure, SameSite=Strict Cookie.
|
||||
*/
|
||||
private setRefreshTokenCookie(res: Response, refreshToken: string): void {
|
||||
res.cookie('refresh_token', refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: true, // Nur HTTPS
|
||||
sameSite: 'strict',
|
||||
path: '/api/v1/auth', // Nur fuer Auth-Endpunkte
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 Tage
|
||||
});
|
||||
}
|
||||
}
|
||||
47
packages/core-service/src/core/auth/auth.module.ts
Normal file
47
packages/core-service/src/core/auth/auth.module.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import * as fs from 'fs';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { TotpService } from './totp.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => {
|
||||
const privateKeyPath = config.get<string>(
|
||||
'JWT_PRIVATE_KEY_PATH',
|
||||
'/app/keys/jwt-private.pem',
|
||||
);
|
||||
const publicKeyPath = config.get<string>(
|
||||
'JWT_PUBLIC_KEY_PATH',
|
||||
'/app/keys/jwt-public.pem',
|
||||
);
|
||||
|
||||
return {
|
||||
privateKey: fs.readFileSync(privateKeyPath, 'utf8'),
|
||||
publicKey: fs.readFileSync(publicKeyPath, 'utf8'),
|
||||
signOptions: {
|
||||
algorithm: 'RS256',
|
||||
issuer: config.get<string>('JWT_ISSUER', 'insight-platform'),
|
||||
expiresIn: config.get<string>('JWT_ACCESS_TOKEN_EXPIRY', '15m'),
|
||||
},
|
||||
verifyOptions: {
|
||||
algorithms: ['RS256'],
|
||||
issuer: config.get<string>('JWT_ISSUER', 'insight-platform'),
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy, TotpService],
|
||||
exports: [AuthService, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
264
packages/core-service/src/core/auth/auth.service.ts
Normal file
264
packages/core-service/src/core/auth/auth.service.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
import {
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
ForbiddenException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { RedisService } from '../../redis/redis.service';
|
||||
import { TotpService } from './totp.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { JwtPayload } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
interface TokenPair {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
accessToken: string;
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
role: string;
|
||||
};
|
||||
requiresTwoFactor?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly redis: RedisService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly totp: TotpService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Login mit E-Mail und Passwort.
|
||||
* Gibt AccessToken + Refresh-Token (HttpOnly Cookie) zurueck.
|
||||
*/
|
||||
async login(dto: LoginDto): Promise<LoginResponse> {
|
||||
// User finden
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { email: dto.email.toLowerCase() },
|
||||
include: {
|
||||
authProvider: true,
|
||||
tenantMemberships: {
|
||||
include: { tenant: true },
|
||||
where: { isActive: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
// Generische Fehlermeldung (kein Hinweis ob User existiert)
|
||||
throw new UnauthorizedException('Ungueltige Anmeldedaten');
|
||||
}
|
||||
|
||||
// Passwort pruefen (nur fuer lokale Auth)
|
||||
const localAuth = user.authProvider.find((ap) => ap.provider === 'LOCAL');
|
||||
if (!localAuth?.passwordHash) {
|
||||
throw new UnauthorizedException('Ungueltige Anmeldedaten');
|
||||
}
|
||||
|
||||
const passwordValid = await bcrypt.compare(
|
||||
dto.password,
|
||||
localAuth.passwordHash,
|
||||
);
|
||||
if (!passwordValid) {
|
||||
// Failed Login zaehlen
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
failedLoginAttempts: { increment: 1 },
|
||||
lastFailedLogin: new Date(),
|
||||
},
|
||||
});
|
||||
throw new UnauthorizedException('Ungueltige Anmeldedaten');
|
||||
}
|
||||
|
||||
// Account-Sperre pruefen (nach 5 Fehlversuchen)
|
||||
if (user.failedLoginAttempts >= 5) {
|
||||
const lockoutEnd = user.lastFailedLogin
|
||||
? new Date(user.lastFailedLogin.getTime() + 15 * 60 * 1000) // 15 Min Sperre
|
||||
: null;
|
||||
|
||||
if (lockoutEnd && lockoutEnd > new Date()) {
|
||||
throw new ForbiddenException(
|
||||
'Account temporaer gesperrt. Versuchen Sie es in 15 Minuten erneut.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2FA pruefen
|
||||
if (user.twoFactorEnabled) {
|
||||
if (!dto.totpCode) {
|
||||
return {
|
||||
accessToken: '',
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
role: user.role,
|
||||
},
|
||||
requiresTwoFactor: true,
|
||||
};
|
||||
}
|
||||
|
||||
const totpValid = this.totp.verify(
|
||||
dto.totpCode,
|
||||
localAuth.totpSecret ?? '',
|
||||
);
|
||||
if (!totpValid) {
|
||||
throw new UnauthorizedException('Ungueltiger 2FA-Code');
|
||||
}
|
||||
}
|
||||
|
||||
// Erfolgreicher Login: Counter zuruecksetzen
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
failedLoginAttempts: 0,
|
||||
lastLogin: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Tenant-Info
|
||||
const primaryMembership = user.tenantMemberships[0];
|
||||
|
||||
// Tokens generieren
|
||||
const tokens = await this.generateTokenPair({
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
tenantId: primaryMembership?.tenant.id,
|
||||
tenantSlug: primaryMembership?.tenant.slug,
|
||||
});
|
||||
|
||||
this.logger.log(`Login erfolgreich: ${user.email}`);
|
||||
|
||||
return {
|
||||
accessToken: tokens.accessToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
role: user.role,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh-Token gegen neues Token-Paar tauschen.
|
||||
*/
|
||||
async refreshTokens(refreshToken: string): Promise<TokenPair> {
|
||||
try {
|
||||
const payload = this.jwt.verify<JwtPayload>(refreshToken);
|
||||
|
||||
// Refresh-Token-Familie pruefen (Token-Rotation)
|
||||
const isValid = await this.redis.isRefreshTokenFamilyValid(
|
||||
payload.sub,
|
||||
payload.jti,
|
||||
);
|
||||
if (!isValid) {
|
||||
// Moeglicherweise Refresh-Token-Diebstahl: alle invalidieren
|
||||
this.logger.warn(
|
||||
`Verdaechtiger Refresh-Token Wiederverwendung fuer User ${payload.sub}`,
|
||||
);
|
||||
await this.redis.invalidateAllRefreshTokens(payload.sub);
|
||||
throw new UnauthorizedException('Refresh Token ungueltig');
|
||||
}
|
||||
|
||||
// Alten Refresh-Token invalidieren
|
||||
await this.redis.blockToken(payload.jti, 7 * 24 * 60 * 60);
|
||||
|
||||
// Neue Tokens generieren
|
||||
return this.generateTokenPair({
|
||||
sub: payload.sub,
|
||||
email: payload.email,
|
||||
role: payload.role,
|
||||
tenantId: payload.tenantId,
|
||||
tenantSlug: payload.tenantSlug,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedException) throw error;
|
||||
throw new UnauthorizedException('Refresh Token ungueltig oder abgelaufen');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout: Access- und Refresh-Token invalidieren.
|
||||
*/
|
||||
async logout(accessToken: JwtPayload, refreshToken?: string): Promise<void> {
|
||||
// Access-Token blocken (Restlaufzeit)
|
||||
const ttl = accessToken.exp - Math.floor(Date.now() / 1000);
|
||||
if (ttl > 0) {
|
||||
await this.redis.blockToken(accessToken.jti, ttl);
|
||||
}
|
||||
|
||||
// Refresh-Token blocken
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const refreshPayload = this.jwt.verify<JwtPayload>(refreshToken);
|
||||
const refreshTtl =
|
||||
refreshPayload.exp - Math.floor(Date.now() / 1000);
|
||||
if (refreshTtl > 0) {
|
||||
await this.redis.blockToken(refreshPayload.jti, refreshTtl);
|
||||
}
|
||||
} catch {
|
||||
// Refresh-Token ist bereits abgelaufen - ignorieren
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`Logout: User ${accessToken.sub}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Token-Paar generieren (Access + Refresh).
|
||||
*/
|
||||
private async generateTokenPair(
|
||||
payload: Omit<JwtPayload, 'jti' | 'iat' | 'exp'>,
|
||||
): Promise<TokenPair> {
|
||||
const accessJti = uuidv4();
|
||||
const refreshJti = uuidv4();
|
||||
|
||||
const accessToken = this.jwt.sign({
|
||||
...payload,
|
||||
jti: accessJti,
|
||||
});
|
||||
|
||||
const refreshExpiry = this.config.get<string>(
|
||||
'JWT_REFRESH_TOKEN_EXPIRY',
|
||||
'7d',
|
||||
);
|
||||
const refreshToken = this.jwt.sign(
|
||||
{
|
||||
...payload,
|
||||
jti: refreshJti,
|
||||
},
|
||||
{ expiresIn: refreshExpiry },
|
||||
);
|
||||
|
||||
// Refresh-Token-Familie in Redis registrieren
|
||||
await this.redis.setRefreshTokenFamily(
|
||||
payload.sub,
|
||||
refreshJti,
|
||||
7 * 24 * 60 * 60, // 7 Tage
|
||||
);
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
}
|
||||
30
packages/core-service/src/core/auth/dto/login.dto.ts
Normal file
30
packages/core-service/src/core/auth/dto/login.dto.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { IsEmail, IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty({
|
||||
example: 'admin@xinion.de',
|
||||
description: 'E-Mail-Adresse des Benutzers',
|
||||
})
|
||||
@IsEmail({}, { message: 'Bitte gueltige E-Mail-Adresse angeben' })
|
||||
@IsNotEmpty({ message: 'E-Mail darf nicht leer sein' })
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'SicheresPasswort123!',
|
||||
description: 'Passwort (mindestens 8 Zeichen)',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'Passwort darf nicht leer sein' })
|
||||
@MinLength(8, { message: 'Passwort muss mindestens 8 Zeichen lang sein' })
|
||||
password!: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: '123456',
|
||||
description: 'TOTP 2FA-Code (nur wenn 2FA aktiviert)',
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
totpCode?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import * as fs from 'fs';
|
||||
import { JwtPayload } from '../../../common/decorators/current-user.decorator';
|
||||
|
||||
/**
|
||||
* JwtStrategy - Passport-Strategy fuer RS256 JWT-Validierung.
|
||||
*
|
||||
* Extrahiert den Token aus dem Authorization-Header (Bearer Token).
|
||||
* Validiert Signatur (RS256), Issuer und Expiration automatisch.
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(config: ConfigService) {
|
||||
const publicKeyPath = config.get<string>(
|
||||
'JWT_PUBLIC_KEY_PATH',
|
||||
'/app/keys/jwt-public.pem',
|
||||
);
|
||||
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: fs.readFileSync(publicKeyPath, 'utf8'),
|
||||
algorithms: ['RS256'],
|
||||
issuer: config.get<string>('JWT_ISSUER', 'insight-platform'),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wird nach erfolgreicher JWT-Validierung aufgerufen.
|
||||
* Der Return-Wert landet in request.user.
|
||||
*/
|
||||
validate(payload: JwtPayload): JwtPayload {
|
||||
return {
|
||||
sub: payload.sub,
|
||||
email: payload.email,
|
||||
role: payload.role,
|
||||
tenantId: payload.tenantId,
|
||||
tenantSlug: payload.tenantSlug,
|
||||
jti: payload.jti,
|
||||
iat: payload.iat,
|
||||
exp: payload.exp,
|
||||
};
|
||||
}
|
||||
}
|
||||
54
packages/core-service/src/core/auth/totp.service.ts
Normal file
54
packages/core-service/src/core/auth/totp.service.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { authenticator } from 'otplib';
|
||||
import * as QRCode from 'qrcode';
|
||||
|
||||
/**
|
||||
* TotpService - TOTP 2FA (Time-based One-Time Password).
|
||||
*
|
||||
* Verwendet den Google Authenticator kompatiblen TOTP-Algorithmus.
|
||||
* Secrets werden verschluesselt in der Datenbank gespeichert.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TotpService {
|
||||
private readonly logger = new Logger(TotpService.name);
|
||||
|
||||
constructor() {
|
||||
// TOTP Konfiguration
|
||||
authenticator.options = {
|
||||
step: 30, // 30 Sekunden
|
||||
window: 1, // +/- 1 Schritt Toleranz
|
||||
digits: 6,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Neues TOTP-Secret generieren.
|
||||
*/
|
||||
generateSecret(): string {
|
||||
return authenticator.generateSecret();
|
||||
}
|
||||
|
||||
/**
|
||||
* QR-Code als Data-URL generieren (fuer Authenticator-App Setup).
|
||||
*/
|
||||
async generateQrCode(email: string, secret: string): Promise<string> {
|
||||
const otpauthUrl = authenticator.keyuri(
|
||||
email,
|
||||
'INSIGHT Platform',
|
||||
secret,
|
||||
);
|
||||
return QRCode.toDataURL(otpauthUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* TOTP-Code verifizieren.
|
||||
*/
|
||||
verify(token: string, secret: string): boolean {
|
||||
try {
|
||||
return authenticator.verify({ token, secret });
|
||||
} catch {
|
||||
this.logger.warn('TOTP-Verifizierung fehlgeschlagen');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
19
packages/core-service/src/core/tenants/dto/add-member.dto.ts
Normal file
19
packages/core-service/src/core/tenants/dto/add-member.dto.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { IsNotEmpty, IsOptional, IsString, IsUUID, IsIn } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class AddMemberDto {
|
||||
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
userId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'MEMBER',
|
||||
enum: ['ADMIN', 'MEMBER', 'VIEWER'],
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['ADMIN', 'MEMBER', 'VIEWER'])
|
||||
role?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import {
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
IsObject,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class CreateTenantDto {
|
||||
@ApiProperty({ example: 'ACME Corporation' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(200)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'acme-corp',
|
||||
description: 'URL-freundlicher Kurzname (a-z, 0-9, Bindestrich)',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(50)
|
||||
@Matches(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/, {
|
||||
message: 'Slug: nur Kleinbuchstaben, Zahlen und Bindestriche erlaubt',
|
||||
})
|
||||
slug!: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: { locale: 'de-DE', timezone: 'Europe/Berlin' },
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
settings?: Record<string, unknown>;
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import {
|
||||
IsBoolean,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateTenantDto {
|
||||
@ApiProperty({ example: 'ACME Corp. GmbH', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
name?: string;
|
||||
|
||||
@ApiProperty({ example: true, required: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
example: { locale: 'de-DE', timezone: 'Europe/Berlin' },
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
settings?: Record<string, unknown>;
|
||||
}
|
||||
110
packages/core-service/src/core/tenants/tenants.controller.ts
Normal file
110
packages/core-service/src/core/tenants/tenants.controller.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { TenantsService } from './tenants.service';
|
||||
import { CreateTenantDto } from './dto/create-tenant.dto';
|
||||
import { UpdateTenantDto } from './dto/update-tenant.dto';
|
||||
import { AddMemberDto } from './dto/add-member.dto';
|
||||
import { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../../common/guards/roles.guard';
|
||||
|
||||
@ApiTags('Mandanten')
|
||||
@ApiBearerAuth('access-token')
|
||||
@Controller('tenants')
|
||||
export class TenantsController {
|
||||
constructor(private readonly tenantsService: TenantsService) {}
|
||||
|
||||
/**
|
||||
* GET /api/v1/tenants
|
||||
* Alle Mandanten auflisten (nur PLATFORM_ADMIN).
|
||||
*/
|
||||
@Get()
|
||||
@Roles('PLATFORM_ADMIN')
|
||||
@UseGuards(RolesGuard)
|
||||
@ApiOperation({ summary: 'Alle Mandanten auflisten (Admin)' })
|
||||
async findAll(
|
||||
@Query('page') page?: number,
|
||||
@Query('limit') limit?: number,
|
||||
) {
|
||||
return this.tenantsService.findAll(page ?? 1, limit ?? 20);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/tenants/:id
|
||||
* Mandant nach ID (nur PLATFORM_ADMIN).
|
||||
*/
|
||||
@Get(':id')
|
||||
@Roles('PLATFORM_ADMIN')
|
||||
@UseGuards(RolesGuard)
|
||||
@ApiOperation({ summary: 'Mandant nach ID abrufen (Admin)' })
|
||||
async findById(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.tenantsService.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/tenants
|
||||
* Neuen Mandant anlegen (nur PLATFORM_ADMIN).
|
||||
*/
|
||||
@Post()
|
||||
@Roles('PLATFORM_ADMIN')
|
||||
@UseGuards(RolesGuard)
|
||||
@ApiOperation({ summary: 'Neuen Mandanten anlegen (Admin)' })
|
||||
async create(@Body() dto: CreateTenantDto) {
|
||||
return this.tenantsService.create(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/tenants/:id
|
||||
* Mandant aktualisieren (nur PLATFORM_ADMIN).
|
||||
*/
|
||||
@Patch(':id')
|
||||
@Roles('PLATFORM_ADMIN')
|
||||
@UseGuards(RolesGuard)
|
||||
@ApiOperation({ summary: 'Mandant aktualisieren (Admin)' })
|
||||
async update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateTenantDto,
|
||||
) {
|
||||
return this.tenantsService.update(id, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/tenants/:id/members
|
||||
* User einem Mandant zuweisen (nur PLATFORM_ADMIN).
|
||||
*/
|
||||
@Post(':id/members')
|
||||
@Roles('PLATFORM_ADMIN')
|
||||
@UseGuards(RolesGuard)
|
||||
@ApiOperation({ summary: 'Benutzer zu Mandant hinzufuegen (Admin)' })
|
||||
async addMember(
|
||||
@Param('id', ParseUUIDPipe) tenantId: string,
|
||||
@Body() dto: AddMemberDto,
|
||||
) {
|
||||
return this.tenantsService.addMember(tenantId, dto.userId, dto.role);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/tenants/:id/members/:userId
|
||||
* User aus Mandant entfernen (nur PLATFORM_ADMIN).
|
||||
*/
|
||||
@Delete(':id/members/:userId')
|
||||
@Roles('PLATFORM_ADMIN')
|
||||
@UseGuards(RolesGuard)
|
||||
@ApiOperation({ summary: 'Benutzer aus Mandant entfernen (Admin)' })
|
||||
async removeMember(
|
||||
@Param('id', ParseUUIDPipe) tenantId: string,
|
||||
@Param('userId', ParseUUIDPipe) userId: string,
|
||||
) {
|
||||
return this.tenantsService.removeMember(tenantId, userId);
|
||||
}
|
||||
}
|
||||
10
packages/core-service/src/core/tenants/tenants.module.ts
Normal file
10
packages/core-service/src/core/tenants/tenants.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TenantsController } from './tenants.controller';
|
||||
import { TenantsService } from './tenants.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TenantsController],
|
||||
providers: [TenantsService],
|
||||
exports: [TenantsService],
|
||||
})
|
||||
export class TenantsModule {}
|
||||
166
packages/core-service/src/core/tenants/tenants.service.ts
Normal file
166
packages/core-service/src/core/tenants/tenants.service.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import {
|
||||
Injectable,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { CreateTenantDto } from './dto/create-tenant.dto';
|
||||
import { UpdateTenantDto } from './dto/update-tenant.dto';
|
||||
|
||||
@Injectable()
|
||||
export class TenantsService {
|
||||
private readonly logger = new Logger(TenantsService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Neuen Tenant (Mandant) anlegen.
|
||||
*/
|
||||
async create(dto: CreateTenantDto) {
|
||||
// Slug-Duplikat pruefen
|
||||
const existing = await this.prisma.tenant.findUnique({
|
||||
where: { slug: dto.slug },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException(`Tenant-Slug "${dto.slug}" bereits vergeben`);
|
||||
}
|
||||
|
||||
const tenant = await this.prisma.tenant.create({
|
||||
data: {
|
||||
name: dto.name,
|
||||
slug: dto.slug,
|
||||
isActive: true,
|
||||
settings: dto.settings ?? {},
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Tenant erstellt: ${tenant.name} (${tenant.slug})`);
|
||||
|
||||
// TODO: Tenant-Datenbank erstellen (tenant_{slug})
|
||||
// Das wird spaeter per Prisma Migrate automatisiert
|
||||
|
||||
return tenant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tenant nach ID finden.
|
||||
*/
|
||||
async findById(id: string) {
|
||||
const tenant = await this.prisma.tenant.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
_count: {
|
||||
select: { members: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
throw new NotFoundException('Mandant nicht gefunden');
|
||||
}
|
||||
|
||||
return {
|
||||
...tenant,
|
||||
memberCount: tenant._count.members,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Tenants auflisten.
|
||||
*/
|
||||
async findAll(page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [tenants, total] = await Promise.all([
|
||||
this.prisma.tenant.findMany({
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
_count: {
|
||||
select: { members: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.tenant.count(),
|
||||
]);
|
||||
|
||||
return {
|
||||
data: tenants.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
slug: t.slug,
|
||||
isActive: t.isActive,
|
||||
memberCount: t._count.members,
|
||||
createdAt: t.createdAt,
|
||||
})),
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tenant aktualisieren.
|
||||
*/
|
||||
async update(id: string, dto: UpdateTenantDto) {
|
||||
const tenant = await this.prisma.tenant.findUnique({ where: { id } });
|
||||
if (!tenant) {
|
||||
throw new NotFoundException('Mandant nicht gefunden');
|
||||
}
|
||||
|
||||
return this.prisma.tenant.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: dto.name,
|
||||
isActive: dto.isActive,
|
||||
settings: dto.settings,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* User einem Tenant zuweisen.
|
||||
*/
|
||||
async addMember(tenantId: string, userId: string, role = 'MEMBER') {
|
||||
// Pruefen ob bereits Mitglied
|
||||
const existing = await this.prisma.tenantMembership.findUnique({
|
||||
where: {
|
||||
userId_tenantId: { userId, tenantId },
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException('Benutzer ist bereits Mitglied');
|
||||
}
|
||||
|
||||
return this.prisma.tenantMembership.create({
|
||||
data: {
|
||||
userId,
|
||||
tenantId,
|
||||
tenantRole: role,
|
||||
isActive: true,
|
||||
},
|
||||
include: {
|
||||
user: { select: { email: true, firstName: true, lastName: true } },
|
||||
tenant: { select: { name: true, slug: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* User aus Tenant entfernen (Soft-Delete).
|
||||
*/
|
||||
async removeMember(tenantId: string, userId: string) {
|
||||
return this.prisma.tenantMembership.update({
|
||||
where: {
|
||||
userId_tenantId: { userId, tenantId },
|
||||
},
|
||||
data: { isActive: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
46
packages/core-service/src/core/users/dto/create-user.dto.ts
Normal file
46
packages/core-service/src/core/users/dto/create-user.dto.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import {
|
||||
IsEmail,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
IsIn,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class CreateUserDto {
|
||||
@ApiProperty({ example: 'max.mustermann@xinion.de' })
|
||||
@IsEmail({}, { message: 'Bitte gueltige E-Mail-Adresse angeben' })
|
||||
@IsNotEmpty()
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ example: 'SicheresPasswort123!' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(8, { message: 'Passwort muss mindestens 8 Zeichen lang sein' })
|
||||
@MaxLength(128, { message: 'Passwort darf maximal 128 Zeichen lang sein' })
|
||||
password!: string;
|
||||
|
||||
@ApiProperty({ example: 'Max' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
firstName!: string;
|
||||
|
||||
@ApiProperty({ example: 'Mustermann' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
lastName!: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'USER',
|
||||
enum: ['PLATFORM_ADMIN', 'TENANT_ADMIN', 'USER'],
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['PLATFORM_ADMIN', 'TENANT_ADMIN', 'USER'])
|
||||
role?: string;
|
||||
}
|
||||
21
packages/core-service/src/core/users/dto/update-user.dto.ts
Normal file
21
packages/core-service/src/core/users/dto/update-user.dto.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateUserDto {
|
||||
@ApiProperty({ example: 'Max', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
firstName?: string;
|
||||
|
||||
@ApiProperty({ example: 'Mustermann', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
lastName?: string;
|
||||
|
||||
@ApiProperty({ example: true, required: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
89
packages/core-service/src/core/users/users.controller.ts
Normal file
89
packages/core-service/src/core/users/users.controller.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { UsersService } from './users.service';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { CurrentUser, JwtPayload } from '../../common/decorators/current-user.decorator';
|
||||
import { RolesGuard } from '../../common/guards/roles.guard';
|
||||
|
||||
@ApiTags('Benutzer')
|
||||
@ApiBearerAuth('access-token')
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
/**
|
||||
* GET /api/v1/users/me
|
||||
* Eigenes Profil abrufen.
|
||||
*/
|
||||
@Get('me')
|
||||
@ApiOperation({ summary: 'Eigenes Profil abrufen' })
|
||||
async getProfile(@CurrentUser('sub') userId: string) {
|
||||
return this.usersService.findById(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/users
|
||||
* Alle User auflisten (nur PLATFORM_ADMIN).
|
||||
*/
|
||||
@Get()
|
||||
@Roles('PLATFORM_ADMIN')
|
||||
@UseGuards(RolesGuard)
|
||||
@ApiOperation({ summary: 'Alle Benutzer auflisten (Admin)' })
|
||||
async findAll(
|
||||
@Query('page') page?: number,
|
||||
@Query('limit') limit?: number,
|
||||
) {
|
||||
return this.usersService.findAll(page ?? 1, limit ?? 20);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/users/:id
|
||||
* User nach ID (nur PLATFORM_ADMIN).
|
||||
*/
|
||||
@Get(':id')
|
||||
@Roles('PLATFORM_ADMIN')
|
||||
@UseGuards(RolesGuard)
|
||||
@ApiOperation({ summary: 'Benutzer nach ID abrufen (Admin)' })
|
||||
async findById(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.usersService.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/users
|
||||
* Neuen User anlegen (nur PLATFORM_ADMIN).
|
||||
*/
|
||||
@Post()
|
||||
@Roles('PLATFORM_ADMIN')
|
||||
@UseGuards(RolesGuard)
|
||||
@ApiOperation({ summary: 'Neuen Benutzer anlegen (Admin)' })
|
||||
async create(@Body() dto: CreateUserDto) {
|
||||
return this.usersService.create(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/users/:id
|
||||
* User aktualisieren (nur PLATFORM_ADMIN).
|
||||
*/
|
||||
@Patch(':id')
|
||||
@Roles('PLATFORM_ADMIN')
|
||||
@UseGuards(RolesGuard)
|
||||
@ApiOperation({ summary: 'Benutzer aktualisieren (Admin)' })
|
||||
async update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateUserDto,
|
||||
) {
|
||||
return this.usersService.update(id, dto);
|
||||
}
|
||||
}
|
||||
10
packages/core-service/src/core/users/users.module.ts
Normal file
10
packages/core-service/src/core/users/users.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { UsersController } from './users.controller';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Module({
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
174
packages/core-service/src/core/users/users.service.ts
Normal file
174
packages/core-service/src/core/users/users.service.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import {
|
||||
Injectable,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
private readonly logger = new Logger(UsersService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Neuen User anlegen (mit lokalem Auth-Provider).
|
||||
*/
|
||||
async create(dto: CreateUserDto) {
|
||||
// Email-Duplikat pruefen
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
where: { email: dto.email.toLowerCase() },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException('E-Mail-Adresse bereits vergeben');
|
||||
}
|
||||
|
||||
// Passwort hashen (Bcrypt Cost 12)
|
||||
const bcryptCost = this.config.get<number>('BCRYPT_COST', 12);
|
||||
const passwordHash = await bcrypt.hash(dto.password, bcryptCost);
|
||||
|
||||
// User + AuthProvider in einer Transaktion anlegen
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
email: dto.email.toLowerCase(),
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName,
|
||||
role: dto.role ?? 'USER',
|
||||
isActive: true,
|
||||
authProvider: {
|
||||
create: {
|
||||
provider: 'LOCAL',
|
||||
passwordHash,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
authProvider: {
|
||||
select: { provider: true, createdAt: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`User erstellt: ${user.email} (${user.role})`);
|
||||
|
||||
// Passwort-Hash nicht zurueckgeben
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
role: user.role,
|
||||
isActive: user.isActive,
|
||||
createdAt: user.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* User nach ID finden.
|
||||
*/
|
||||
async findById(id: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
tenantMemberships: {
|
||||
include: { tenant: { select: { id: true, name: true, slug: true } } },
|
||||
where: { isActive: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('Benutzer nicht gefunden');
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
role: user.role,
|
||||
isActive: user.isActive,
|
||||
twoFactorEnabled: user.twoFactorEnabled,
|
||||
tenants: user.tenantMemberships.map((m) => ({
|
||||
id: m.tenant.id,
|
||||
name: m.tenant.name,
|
||||
slug: m.tenant.slug,
|
||||
role: m.tenantRole,
|
||||
})),
|
||||
lastLogin: user.lastLogin,
|
||||
createdAt: user.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* User aktualisieren.
|
||||
*/
|
||||
async update(id: string, dto: UpdateUserDto) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('Benutzer nicht gefunden');
|
||||
}
|
||||
|
||||
const updated = await this.prisma.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName,
|
||||
isActive: dto.isActive,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
email: updated.email,
|
||||
firstName: updated.firstName,
|
||||
lastName: updated.lastName,
|
||||
role: updated.role,
|
||||
isActive: updated.isActive,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle User auflisten (fuer Admin).
|
||||
*/
|
||||
async findAll(page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
this.prisma.user.findMany({
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
lastLogin: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.user.count(),
|
||||
]);
|
||||
|
||||
return {
|
||||
data: users,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
72
packages/core-service/src/health/health.controller.ts
Normal file
72
packages/core-service/src/health/health.controller.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { Public } from '../common/decorators/public.decorator';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RedisService } from '../redis/redis.service';
|
||||
|
||||
interface HealthResponse {
|
||||
status: 'ok' | 'error';
|
||||
timestamp: string;
|
||||
version: string;
|
||||
services: {
|
||||
database: 'up' | 'down';
|
||||
redis: 'up' | 'down';
|
||||
};
|
||||
}
|
||||
|
||||
@ApiTags('Health')
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly redis: RedisService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@Public()
|
||||
@ApiOperation({ summary: 'Health-Check fuer alle Services' })
|
||||
async check(): Promise<HealthResponse> {
|
||||
const [dbStatus, redisStatus] = await Promise.allSettled([
|
||||
this.checkDatabase(),
|
||||
this.checkRedis(),
|
||||
]);
|
||||
|
||||
const allUp =
|
||||
dbStatus.status === 'fulfilled' &&
|
||||
dbStatus.value &&
|
||||
redisStatus.status === 'fulfilled' &&
|
||||
redisStatus.value;
|
||||
|
||||
return {
|
||||
status: allUp ? 'ok' : 'error',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '0.1.0',
|
||||
services: {
|
||||
database:
|
||||
dbStatus.status === 'fulfilled' && dbStatus.value ? 'up' : 'down',
|
||||
redis:
|
||||
redisStatus.status === 'fulfilled' && redisStatus.value
|
||||
? 'up'
|
||||
: 'down',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async checkDatabase(): Promise<boolean> {
|
||||
try {
|
||||
await this.prisma.$queryRaw`SELECT 1`;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async checkRedis(): Promise<boolean> {
|
||||
try {
|
||||
const pong = await this.redis.ping();
|
||||
return pong === 'PONG';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
10
packages/core-service/src/health/health.module.ts
Normal file
10
packages/core-service/src/health/health.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { RedisModule } from '../redis/redis.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, RedisModule],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
82
packages/core-service/src/main.ts
Normal file
82
packages/core-service/src/main.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe, Logger } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import * as cookieParser from 'cookie-parser';
|
||||
import helmet from 'helmet';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const logger = new Logger('Bootstrap');
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: ['error', 'warn', 'log', 'debug', 'verbose'],
|
||||
});
|
||||
|
||||
// Security
|
||||
app.use(helmet());
|
||||
app.use(cookieParser());
|
||||
|
||||
// CORS
|
||||
const corsOrigins = process.env.CORS_ORIGINS?.split(',') ?? [
|
||||
'https://insight-dev.xinion.lan',
|
||||
];
|
||||
app.enableCors({
|
||||
origin: corsOrigins,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: [
|
||||
'Content-Type',
|
||||
'Authorization',
|
||||
'X-Tenant-ID',
|
||||
'X-Request-ID',
|
||||
],
|
||||
});
|
||||
|
||||
// Global Validation Pipe (Sicherheitsregel: whitelist + forbidNonWhitelisted)
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
transformOptions: { enableImplicitConversion: true },
|
||||
}),
|
||||
);
|
||||
|
||||
// Global Prefix
|
||||
app.setGlobalPrefix('api/v1', {
|
||||
exclude: ['health'],
|
||||
});
|
||||
|
||||
// Swagger (nur Development)
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('INSIGHT Platform API')
|
||||
.setDescription('Multi-Tenant Business Platform API')
|
||||
.setVersion('0.1.0')
|
||||
.addBearerAuth(
|
||||
{
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description: 'Access Token (RS256)',
|
||||
},
|
||||
'access-token',
|
||||
)
|
||||
.addCookieAuth('refresh_token', {
|
||||
type: 'apiKey',
|
||||
in: 'cookie',
|
||||
description: 'HttpOnly Refresh Token',
|
||||
})
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api/docs', app, document);
|
||||
logger.log('Swagger UI: /api/docs');
|
||||
}
|
||||
|
||||
const port = process.env.APP_PORT ?? 3000;
|
||||
await app.listen(port);
|
||||
logger.log(`Core-Service laeuft auf Port ${port}`);
|
||||
logger.log(`Umgebung: ${process.env.NODE_ENV ?? 'development'}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
10
packages/core-service/src/prisma/prisma.module.ts
Normal file
10
packages/core-service/src/prisma/prisma.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
import { TenantPrismaService } from './tenant-prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService, TenantPrismaService],
|
||||
exports: [PrismaService, TenantPrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
32
packages/core-service/src/prisma/prisma.service.ts
Normal file
32
packages/core-service/src/prisma/prisma.service.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
private readonly logger = new Logger(PrismaService.name);
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
log: [
|
||||
{ emit: 'event', level: 'query' },
|
||||
{ emit: 'stdout', level: 'info' },
|
||||
{ emit: 'stdout', level: 'warn' },
|
||||
{ emit: 'stdout', level: 'error' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
this.logger.log('Verbinde mit PostgreSQL (platform_core)...');
|
||||
await this.$connect();
|
||||
this.logger.log('PostgreSQL Verbindung hergestellt.');
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
this.logger.log('Trenne PostgreSQL Verbindung...');
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
103
packages/core-service/src/prisma/tenant-prisma.service.ts
Normal file
103
packages/core-service/src/prisma/tenant-prisma.service.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* TenantPrismaService - Verwaltet dynamische Datenbankverbindungen pro Mandant.
|
||||
*
|
||||
* Jeder Tenant hat eine eigene Datenbank (tenant_{slug}).
|
||||
* Verbindungen werden gecacht und bei Inaktivitaet automatisch geschlossen.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TenantPrismaService {
|
||||
private readonly logger = new Logger(TenantPrismaService.name);
|
||||
private readonly clients = new Map<
|
||||
string,
|
||||
{ client: PrismaClient; lastUsed: number }
|
||||
>();
|
||||
|
||||
// Maximale Inaktivitaetszeit in Millisekunden (30 Minuten)
|
||||
private readonly MAX_IDLE_TIME = 30 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Gibt einen PrismaClient fuer den angegebenen Tenant zurueck.
|
||||
* Erstellt eine neue Verbindung oder nutzt eine gecachte.
|
||||
*/
|
||||
async getClient(tenantSlug: string): Promise<PrismaClient> {
|
||||
const existing = this.clients.get(tenantSlug);
|
||||
if (existing) {
|
||||
existing.lastUsed = Date.now();
|
||||
return existing.client;
|
||||
}
|
||||
|
||||
const dbName = `tenant_${tenantSlug}`;
|
||||
const baseUrl = process.env.DATABASE_URL_DIRECT ?? process.env.DATABASE_URL;
|
||||
if (!baseUrl) {
|
||||
throw new Error('DATABASE_URL ist nicht konfiguriert');
|
||||
}
|
||||
|
||||
// URL modifizieren: Datenbankname ersetzen
|
||||
const url = new URL(baseUrl);
|
||||
url.pathname = `/${dbName}`;
|
||||
|
||||
const client = new PrismaClient({
|
||||
datasources: {
|
||||
db: { url: url.toString() },
|
||||
},
|
||||
});
|
||||
|
||||
await client.$connect();
|
||||
this.logger.log(`Tenant-DB Verbindung hergestellt: ${dbName}`);
|
||||
|
||||
this.clients.set(tenantSlug, {
|
||||
client,
|
||||
lastUsed: Date.now(),
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schliesst eine bestimmte Tenant-Verbindung.
|
||||
*/
|
||||
async disconnectTenant(tenantSlug: string): Promise<void> {
|
||||
const existing = this.clients.get(tenantSlug);
|
||||
if (existing) {
|
||||
await existing.client.$disconnect();
|
||||
this.clients.delete(tenantSlug);
|
||||
this.logger.log(`Tenant-DB Verbindung geschlossen: tenant_${tenantSlug}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schliesst alle inaktiven Verbindungen.
|
||||
* Wird periodisch vom CleanupService aufgerufen.
|
||||
*/
|
||||
async cleanupIdleConnections(): Promise<number> {
|
||||
const now = Date.now();
|
||||
let closed = 0;
|
||||
|
||||
for (const [slug, entry] of this.clients.entries()) {
|
||||
if (now - entry.lastUsed > this.MAX_IDLE_TIME) {
|
||||
await entry.client.$disconnect();
|
||||
this.clients.delete(slug);
|
||||
this.logger.log(
|
||||
`Idle Tenant-DB Verbindung geschlossen: tenant_${slug}`,
|
||||
);
|
||||
closed++;
|
||||
}
|
||||
}
|
||||
|
||||
return closed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schliesst alle Tenant-Verbindungen (Shutdown).
|
||||
*/
|
||||
async disconnectAll(): Promise<void> {
|
||||
for (const [slug, entry] of this.clients.entries()) {
|
||||
await entry.client.$disconnect();
|
||||
this.logger.log(`Tenant-DB Verbindung geschlossen: tenant_${slug}`);
|
||||
}
|
||||
this.clients.clear();
|
||||
}
|
||||
}
|
||||
9
packages/core-service/src/redis/redis.module.ts
Normal file
9
packages/core-service/src/redis/redis.module.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Global, Module } from '@nestjs/common';
|
||||
import { RedisService } from './redis.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [RedisService],
|
||||
exports: [RedisService],
|
||||
})
|
||||
export class RedisModule {}
|
||||
144
packages/core-service/src/redis/redis.service.ts
Normal file
144
packages/core-service/src/redis/redis.service.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import {
|
||||
Injectable,
|
||||
OnModuleInit,
|
||||
OnModuleDestroy,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
/**
|
||||
* RedisService - Zentraler Redis-Client fuer Cache, Sessions und Token-Revocation.
|
||||
*
|
||||
* Verwendungszwecke:
|
||||
* - Token-Blocklist (JWT Revocation)
|
||||
* - Session-Cache
|
||||
* - Rate-Limit-Counter
|
||||
* - Pub/Sub Event Bus (spaeter)
|
||||
*/
|
||||
@Injectable()
|
||||
export class RedisService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(RedisService.name);
|
||||
private client!: Redis;
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
const host = this.config.get<string>('REDIS_HOST', 'redis');
|
||||
const port = this.config.get<number>('REDIS_PORT', 6379);
|
||||
const password = this.config.get<string>('REDIS_PASSWORD');
|
||||
|
||||
this.client = new Redis({
|
||||
host,
|
||||
port,
|
||||
password: password || undefined,
|
||||
maxRetriesPerRequest: 3,
|
||||
retryStrategy: (times: number) => {
|
||||
if (times > 10) {
|
||||
this.logger.error(
|
||||
'Redis: Maximale Verbindungsversuche erreicht. Gebe auf.',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return Math.min(times * 200, 5000);
|
||||
},
|
||||
lazyConnect: true,
|
||||
});
|
||||
|
||||
this.client.on('error', (err: Error) => {
|
||||
this.logger.error(`Redis Fehler: ${err.message}`);
|
||||
});
|
||||
|
||||
this.client.on('connect', () => {
|
||||
this.logger.log('Redis Verbindung hergestellt.');
|
||||
});
|
||||
|
||||
await this.client.connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
this.logger.log('Trenne Redis Verbindung...');
|
||||
await this.client.quit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ping - Verbindungstest
|
||||
*/
|
||||
async ping(): Promise<string> {
|
||||
return this.client.ping();
|
||||
}
|
||||
|
||||
/**
|
||||
* Token in die Blocklist aufnehmen (JWT Revocation).
|
||||
* TTL = Restlaufzeit des Tokens.
|
||||
*/
|
||||
async blockToken(jti: string, ttlSeconds: number): Promise<void> {
|
||||
await this.client.set(`blocked:${jti}`, '1', 'EX', ttlSeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pruefen ob ein Token blockiert ist.
|
||||
*/
|
||||
async isTokenBlocked(jti: string): Promise<boolean> {
|
||||
const result = await this.client.get(`blocked:${jti}`);
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh-Token-Familie speichern (fuer Token-Rotation-Detection).
|
||||
*/
|
||||
async setRefreshTokenFamily(
|
||||
userId: string,
|
||||
familyId: string,
|
||||
ttlSeconds: number,
|
||||
): Promise<void> {
|
||||
await this.client.set(
|
||||
`refresh_family:${userId}:${familyId}`,
|
||||
'1',
|
||||
'EX',
|
||||
ttlSeconds,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pruefen ob Refresh-Token-Familie gueltig ist.
|
||||
*/
|
||||
async isRefreshTokenFamilyValid(
|
||||
userId: string,
|
||||
familyId: string,
|
||||
): Promise<boolean> {
|
||||
const result = await this.client.get(
|
||||
`refresh_family:${userId}:${familyId}`,
|
||||
);
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Refresh-Token-Familien eines Users invalidieren (Logout-All).
|
||||
*/
|
||||
async invalidateAllRefreshTokens(userId: string): Promise<void> {
|
||||
const keys = await this.client.keys(`refresh_family:${userId}:*`);
|
||||
if (keys.length > 0) {
|
||||
await this.client.del(...keys);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generischer Get/Set/Del fuer Cache-Operationen.
|
||||
*/
|
||||
async get(key: string): Promise<string | null> {
|
||||
return this.client.get(key);
|
||||
}
|
||||
|
||||
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
|
||||
if (ttlSeconds) {
|
||||
await this.client.set(key, value, 'EX', ttlSeconds);
|
||||
} else {
|
||||
await this.client.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
async del(key: string): Promise<void> {
|
||||
await this.client.del(key);
|
||||
}
|
||||
}
|
||||
4
packages/core-service/tsconfig.build.json
Normal file
4
packages/core-service/tsconfig.build.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
|
||||
}
|
||||
29
packages/core-service/tsconfig.json
Normal file
29
packages/core-service/tsconfig.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2022",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "test"]
|
||||
}
|
||||
6
packages/frontend/.dockerignore
Normal file
6
packages/frontend/.dockerignore
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
node_modules
|
||||
dist
|
||||
.env
|
||||
*.md
|
||||
.git
|
||||
.gitignore
|
||||
34
packages/frontend/Dockerfile
Normal file
34
packages/frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# ============================================================
|
||||
# INSIGHT Frontend - Multi-Stage Dockerfile
|
||||
# ============================================================
|
||||
|
||||
# --- Base Stage ---
|
||||
FROM node:20-alpine AS base
|
||||
WORKDIR /app
|
||||
|
||||
# --- Dependencies Stage ---
|
||||
FROM base AS deps
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
# --- Development Stage ---
|
||||
FROM base AS development
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
EXPOSE 8080
|
||||
CMD ["npm", "run", "dev"]
|
||||
|
||||
# --- Build Stage ---
|
||||
FROM base AS build
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# --- Production Stage ---
|
||||
FROM nginx:alpine AS production
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 8080
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
13
packages/frontend/index.html
Normal file
13
packages/frontend/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>INSIGHT Platform</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
33
packages/frontend/nginx.conf
Normal file
33
packages/frontend/nginx.conf
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA: Alle Routen auf index.html weiterleiten
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Security Headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Caching fuer Assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Kein Caching fuer index.html
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
# Gzip
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml;
|
||||
gzip_min_length 1000;
|
||||
}
|
||||
34
packages/frontend/package.json
Normal file
34
packages/frontend/package.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"name": "@insight/frontend",
|
||||
"version": "0.1.0",
|
||||
"description": "INSIGHT MVP - Frontend (React + Vite)",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --fix",
|
||||
"lint:check": "eslint . --ext ts,tsx",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"axios": "^1.7.0",
|
||||
"@tanstack/react-query": "^5.56.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
96
packages/frontend/src/admin/AdminTenantsPage.tsx
Normal file
96
packages/frontend/src/admin/AdminTenantsPage.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import api from '../api/client';
|
||||
|
||||
interface Tenant {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
isActive: boolean;
|
||||
memberCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface TenantsResponse {
|
||||
data: Tenant[];
|
||||
meta: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function AdminTenantsPage() {
|
||||
const { data, isLoading, error } = useQuery<TenantsResponse>({
|
||||
queryKey: ['admin', 'tenants'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get<TenantsResponse>('/tenants');
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) return <p>Laden...</p>;
|
||||
if (error) return <p style={{ color: 'var(--color-error)' }}>Fehler beim Laden der Mandanten</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
|
||||
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>Mandantenverwaltung</h1>
|
||||
<span style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem' }}>
|
||||
{data?.meta.total ?? 0} Mandanten gesamt
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'var(--color-bg-card)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
boxShadow: 'var(--shadow-sm)',
|
||||
border: '1px solid var(--color-border)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--color-border)', background: 'var(--color-bg)' }}>
|
||||
<th style={{ padding: '0.75rem 1rem', textAlign: 'left', fontSize: '0.75rem', textTransform: 'uppercase', color: 'var(--color-text-muted)' }}>Name</th>
|
||||
<th style={{ padding: '0.75rem 1rem', textAlign: 'left', fontSize: '0.75rem', textTransform: 'uppercase', color: 'var(--color-text-muted)' }}>Slug</th>
|
||||
<th style={{ padding: '0.75rem 1rem', textAlign: 'left', fontSize: '0.75rem', textTransform: 'uppercase', color: 'var(--color-text-muted)' }}>Mitglieder</th>
|
||||
<th style={{ padding: '0.75rem 1rem', textAlign: 'left', fontSize: '0.75rem', textTransform: 'uppercase', color: 'var(--color-text-muted)' }}>Status</th>
|
||||
<th style={{ padding: '0.75rem 1rem', textAlign: 'left', fontSize: '0.75rem', textTransform: 'uppercase', color: 'var(--color-text-muted)' }}>Erstellt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data?.data.map((tenant) => (
|
||||
<tr key={tenant.id} style={{ borderBottom: '1px solid var(--color-border)' }}>
|
||||
<td style={{ padding: '0.75rem 1rem', fontSize: '0.875rem', fontWeight: 500 }}>
|
||||
{tenant.name}
|
||||
</td>
|
||||
<td style={{ padding: '0.75rem 1rem', fontSize: '0.875rem' }}>
|
||||
<code style={{ background: 'var(--color-bg)', padding: '0.125rem 0.375rem', borderRadius: 'var(--radius-sm)', fontSize: '0.8125rem' }}>
|
||||
{tenant.slug}
|
||||
</code>
|
||||
</td>
|
||||
<td style={{ padding: '0.75rem 1rem', fontSize: '0.875rem' }}>
|
||||
{tenant.memberCount}
|
||||
</td>
|
||||
<td style={{ padding: '0.75rem 1rem' }}>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: tenant.isActive ? 'var(--color-success)' : 'var(--color-error)',
|
||||
marginRight: '0.375rem',
|
||||
}} />
|
||||
<span style={{ fontSize: '0.875rem' }}>{tenant.isActive ? 'Aktiv' : 'Inaktiv'}</span>
|
||||
</td>
|
||||
<td style={{ padding: '0.75rem 1rem', fontSize: '0.875rem', color: 'var(--color-text-muted)' }}>
|
||||
{new Date(tenant.createdAt).toLocaleDateString('de-DE')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
packages/frontend/src/admin/AdminUsersPage.tsx
Normal file
106
packages/frontend/src/admin/AdminUsersPage.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import api from '../api/client';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
role: string;
|
||||
isActive: boolean;
|
||||
lastLogin: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface UsersResponse {
|
||||
data: User[];
|
||||
meta: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function AdminUsersPage() {
|
||||
const { data, isLoading, error } = useQuery<UsersResponse>({
|
||||
queryKey: ['admin', 'users'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get<UsersResponse>('/users');
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) return <p>Laden...</p>;
|
||||
if (error) return <p style={{ color: 'var(--color-error)' }}>Fehler beim Laden der Benutzer</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
|
||||
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>Benutzerverwaltung</h1>
|
||||
<span style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem' }}>
|
||||
{data?.meta.total ?? 0} Benutzer gesamt
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: 'var(--color-bg-card)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
boxShadow: 'var(--shadow-sm)',
|
||||
border: '1px solid var(--color-border)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--color-border)', background: 'var(--color-bg)' }}>
|
||||
<th style={{ padding: '0.75rem 1rem', textAlign: 'left', fontSize: '0.75rem', textTransform: 'uppercase', color: 'var(--color-text-muted)' }}>Name</th>
|
||||
<th style={{ padding: '0.75rem 1rem', textAlign: 'left', fontSize: '0.75rem', textTransform: 'uppercase', color: 'var(--color-text-muted)' }}>E-Mail</th>
|
||||
<th style={{ padding: '0.75rem 1rem', textAlign: 'left', fontSize: '0.75rem', textTransform: 'uppercase', color: 'var(--color-text-muted)' }}>Rolle</th>
|
||||
<th style={{ padding: '0.75rem 1rem', textAlign: 'left', fontSize: '0.75rem', textTransform: 'uppercase', color: 'var(--color-text-muted)' }}>Status</th>
|
||||
<th style={{ padding: '0.75rem 1rem', textAlign: 'left', fontSize: '0.75rem', textTransform: 'uppercase', color: 'var(--color-text-muted)' }}>Letzter Login</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data?.data.map((user) => (
|
||||
<tr key={user.id} style={{ borderBottom: '1px solid var(--color-border)' }}>
|
||||
<td style={{ padding: '0.75rem 1rem', fontSize: '0.875rem' }}>
|
||||
{user.firstName} {user.lastName}
|
||||
</td>
|
||||
<td style={{ padding: '0.75rem 1rem', fontSize: '0.875rem', color: 'var(--color-text-secondary)' }}>
|
||||
{user.email}
|
||||
</td>
|
||||
<td style={{ padding: '0.75rem 1rem' }}>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '0.125rem 0.5rem',
|
||||
borderRadius: '9999px',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
background: user.role === 'PLATFORM_ADMIN' ? '#dbeafe' : '#f3f4f6',
|
||||
color: user.role === 'PLATFORM_ADMIN' ? '#1e40af' : '#374151',
|
||||
}}>
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '0.75rem 1rem' }}>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: user.isActive ? 'var(--color-success)' : 'var(--color-error)',
|
||||
marginRight: '0.375rem',
|
||||
}} />
|
||||
<span style={{ fontSize: '0.875rem' }}>{user.isActive ? 'Aktiv' : 'Inaktiv'}</span>
|
||||
</td>
|
||||
<td style={{ padding: '0.75rem 1rem', fontSize: '0.875rem', color: 'var(--color-text-muted)' }}>
|
||||
{user.lastLogin ? new Date(user.lastLogin).toLocaleDateString('de-DE') : 'Nie'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
packages/frontend/src/api/client.ts
Normal file
76
packages/frontend/src/api/client.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import axios from 'axios';
|
||||
|
||||
/**
|
||||
* Axios-Client mit automatischem Token-Handling.
|
||||
*
|
||||
* SICHERHEITSREGEL: Access-Token wird NUR im Memory gehalten (Variable).
|
||||
* Kein localStorage! Refresh-Token kommt als HttpOnly Cookie.
|
||||
*/
|
||||
|
||||
// Access-Token im Memory (nicht localStorage!)
|
||||
let accessToken: string | null = null;
|
||||
|
||||
export const setAccessToken = (token: string | null): void => {
|
||||
accessToken = token;
|
||||
};
|
||||
|
||||
export const getAccessToken = (): string | null => {
|
||||
return accessToken;
|
||||
};
|
||||
|
||||
// API-Client
|
||||
const api = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
withCredentials: true, // HttpOnly Cookie mitsenden
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request-Interceptor: Access-Token anfuegen
|
||||
api.interceptors.request.use((config) => {
|
||||
if (accessToken) {
|
||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Response-Interceptor: Bei 401 automatisch Token erneuern (Silent Refresh)
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// Bei 401 und noch kein Retry versucht
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
!originalRequest._retry &&
|
||||
originalRequest.url !== '/auth/refresh' &&
|
||||
originalRequest.url !== '/auth/login'
|
||||
) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
// Silent Refresh via HttpOnly Cookie
|
||||
const { data } = await axios.post<{ accessToken: string }>(
|
||||
'/api/v1/auth/refresh',
|
||||
{},
|
||||
{ withCredentials: true },
|
||||
);
|
||||
|
||||
setAccessToken(data.accessToken);
|
||||
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
|
||||
return api(originalRequest);
|
||||
} catch {
|
||||
// Refresh fehlgeschlagen: Logout
|
||||
setAccessToken(null);
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export default api;
|
||||
130
packages/frontend/src/auth/AuthContext.tsx
Normal file
130
packages/frontend/src/auth/AuthContext.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import api, { setAccessToken } from '../api/client';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
login: (email: string, password: string, totpCode?: string) => Promise<LoginResult>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface LoginResult {
|
||||
success: boolean;
|
||||
requiresTwoFactor?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Beim Start: Silent Refresh versuchen
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
try {
|
||||
const { data } = await api.post<{ accessToken: string }>(
|
||||
'/auth/refresh',
|
||||
);
|
||||
setAccessToken(data.accessToken);
|
||||
|
||||
// User-Profil laden
|
||||
const profileResponse = await api.get<User>('/users/me');
|
||||
setUser(profileResponse.data);
|
||||
} catch {
|
||||
// Nicht eingeloggt - normal
|
||||
setAccessToken(null);
|
||||
setUser(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initAuth();
|
||||
}, []);
|
||||
|
||||
const login = useCallback(
|
||||
async (
|
||||
email: string,
|
||||
password: string,
|
||||
totpCode?: string,
|
||||
): Promise<LoginResult> => {
|
||||
try {
|
||||
const { data } = await api.post<{
|
||||
accessToken?: string;
|
||||
user?: User;
|
||||
requiresTwoFactor?: boolean;
|
||||
}>('/auth/login', { email, password, totpCode });
|
||||
|
||||
if (data.requiresTwoFactor) {
|
||||
return { success: false, requiresTwoFactor: true };
|
||||
}
|
||||
|
||||
if (data.accessToken && data.user) {
|
||||
setAccessToken(data.accessToken);
|
||||
setUser(data.user);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
return { success: false, error: 'Unerwartete Antwort vom Server' };
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } };
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message ?? 'Login fehlgeschlagen',
|
||||
};
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await api.post('/auth/logout');
|
||||
} catch {
|
||||
// Fehler ignorieren
|
||||
} finally {
|
||||
setAccessToken(null);
|
||||
setUser(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
isAuthenticated: !!user,
|
||||
isLoading,
|
||||
login,
|
||||
logout,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextType {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth muss innerhalb von AuthProvider verwendet werden');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
106
packages/frontend/src/auth/LoginPage.module.css
Normal file
106
packages/frontend/src/auth/LoginPage.module.css
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
.container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #1a56db 0%, #1e3a5f 100%);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--color-bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: 2.5rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.logo h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.logo p {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.field input {
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.9375rem;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.field input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-light);
|
||||
}
|
||||
|
||||
.field input:disabled {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.field small {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 0.75rem;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
transition: background 0.15s;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.button:hover:not(:disabled) {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #fef2f2;
|
||||
color: var(--color-error);
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.875rem;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
113
packages/frontend/src/auth/LoginPage.tsx
Normal file
113
packages/frontend/src/auth/LoginPage.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { useState, type FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from './AuthContext';
|
||||
import styles from './LoginPage.module.css';
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuth();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
const [showTotp, setShowTotp] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const result = await login(
|
||||
email,
|
||||
password,
|
||||
showTotp ? totpCode : undefined,
|
||||
);
|
||||
|
||||
if (result.requiresTwoFactor) {
|
||||
setShowTotp(true);
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
navigate('/');
|
||||
} else {
|
||||
setError(result.error ?? 'Login fehlgeschlagen');
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.logo}>
|
||||
<h1>INSIGHT</h1>
|
||||
<p>Business Platform</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className={styles.form}>
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="email">E-Mail</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="ihre@email.de"
|
||||
required
|
||||
autoFocus
|
||||
disabled={showTotp}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="password">Passwort</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Passwort eingeben"
|
||||
required
|
||||
minLength={8}
|
||||
disabled={showTotp}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showTotp && (
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="totp">2FA-Code</label>
|
||||
<input
|
||||
id="totp"
|
||||
type="text"
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value)}
|
||||
placeholder="6-stelliger Code"
|
||||
maxLength={6}
|
||||
pattern="[0-9]{6}"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<small>Code aus Ihrer Authenticator-App eingeben</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.button}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Anmeldung...' : 'Anmelden'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
packages/frontend/src/index.css
Normal file
75
packages/frontend/src/index.css
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/* ============================================================
|
||||
INSIGHT MVP - Globale Styles
|
||||
============================================================ */
|
||||
|
||||
:root {
|
||||
/* Farben - Corporate Design */
|
||||
--color-primary: #1a56db;
|
||||
--color-primary-hover: #1e40af;
|
||||
--color-primary-light: #dbeafe;
|
||||
--color-secondary: #6b7280;
|
||||
--color-success: #059669;
|
||||
--color-warning: #d97706;
|
||||
--color-error: #dc2626;
|
||||
|
||||
/* Graustufen */
|
||||
--color-bg: #f9fafb;
|
||||
--color-bg-card: #ffffff;
|
||||
--color-border: #e5e7eb;
|
||||
--color-text: #111827;
|
||||
--color-text-secondary: #6b7280;
|
||||
--color-text-muted: #9ca3af;
|
||||
|
||||
/* Layout */
|
||||
--sidebar-width: 240px;
|
||||
--header-height: 56px;
|
||||
|
||||
/* Schatten */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
|
||||
/* Radien */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-bg);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
29
packages/frontend/src/main.tsx
Normal file
29
packages/frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AuthProvider } from './auth/AuthContext';
|
||||
import { App } from './shell/App';
|
||||
import './index.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
staleTime: 5 * 60 * 1000, // 5 Minuten
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
51
packages/frontend/src/shell/App.tsx
Normal file
51
packages/frontend/src/shell/App.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { LoginPage } from '../auth/LoginPage';
|
||||
import { AppLayout } from './AppLayout';
|
||||
import { DashboardPage } from './DashboardPage';
|
||||
import { AdminUsersPage } from '../admin/AdminUsersPage';
|
||||
import { AdminTenantsPage } from '../admin/AdminTenantsPage';
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||
<p>Laden...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<Routes>
|
||||
{/* Oeffentliche Routen */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* Geschuetzte Routen */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<AppLayout />
|
||||
</PrivateRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="admin/users" element={<AdminUsersPage />} />
|
||||
<Route path="admin/tenants" element={<AdminTenantsPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Fallback */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
105
packages/frontend/src/shell/AppLayout.module.css
Normal file
105
packages/frontend/src/shell/AppLayout.module.css
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
.layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
background: #1e293b;
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.brand {
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.brand h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.nav {
|
||||
flex: 1;
|
||||
padding: 1rem 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.navSection {
|
||||
padding: 1rem 1.5rem 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.navLink {
|
||||
display: block;
|
||||
padding: 0.625rem 1.5rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.15s;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.navLink:hover {
|
||||
color: white;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.active {
|
||||
color: white;
|
||||
background: rgba(96, 165, 250, 0.15);
|
||||
border-left-color: #60a5fa;
|
||||
}
|
||||
|
||||
.userInfo {
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.userName {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.userEmail {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.logoutBtn {
|
||||
margin-top: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.8125rem;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.logoutBtn:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
margin-left: var(--sidebar-width);
|
||||
padding: 2rem;
|
||||
}
|
||||
74
packages/frontend/src/shell/AppLayout.tsx
Normal file
74
packages/frontend/src/shell/AppLayout.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import styles from './AppLayout.module.css';
|
||||
|
||||
export function AppLayout() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.layout}>
|
||||
{/* Sidebar */}
|
||||
<aside className={styles.sidebar}>
|
||||
<div className={styles.brand}>
|
||||
<h2>INSIGHT</h2>
|
||||
</div>
|
||||
|
||||
<nav className={styles.nav}>
|
||||
<NavLink
|
||||
to="/"
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
`${styles.navLink} ${isActive ? styles.active : ''}`
|
||||
}
|
||||
>
|
||||
Dashboard
|
||||
</NavLink>
|
||||
|
||||
{/* Admin-Bereich (nur fuer PLATFORM_ADMIN) */}
|
||||
{user?.role === 'PLATFORM_ADMIN' && (
|
||||
<>
|
||||
<div className={styles.navSection}>Administration</div>
|
||||
<NavLink
|
||||
to="/admin/users"
|
||||
className={({ isActive }) =>
|
||||
`${styles.navLink} ${isActive ? styles.active : ''}`
|
||||
}
|
||||
>
|
||||
Benutzer
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/admin/tenants"
|
||||
className={({ isActive }) =>
|
||||
`${styles.navLink} ${isActive ? styles.active : ''}`
|
||||
}
|
||||
>
|
||||
Mandanten
|
||||
</NavLink>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className={styles.userInfo}>
|
||||
<div className={styles.userName}>
|
||||
{user?.firstName} {user?.lastName}
|
||||
</div>
|
||||
<div className={styles.userEmail}>{user?.email}</div>
|
||||
<button className={styles.logoutBtn} onClick={handleLogout}>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className={styles.main}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
packages/frontend/src/shell/DashboardPage.tsx
Normal file
31
packages/frontend/src/shell/DashboardPage.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { useAuth } from '../auth/AuthContext';
|
||||
|
||||
export function DashboardPage() {
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, marginBottom: '1.5rem' }}>
|
||||
Dashboard
|
||||
</h1>
|
||||
|
||||
<div style={{
|
||||
background: 'var(--color-bg-card)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: '1.5rem',
|
||||
boxShadow: 'var(--shadow-sm)',
|
||||
border: '1px solid var(--color-border)',
|
||||
}}>
|
||||
<h2 style={{ fontSize: '1.125rem', marginBottom: '1rem' }}>
|
||||
Willkommen, {user?.firstName}!
|
||||
</h2>
|
||||
<p style={{ color: 'var(--color-text-secondary)' }}>
|
||||
INSIGHT Platform - Sprint 1 Alpha
|
||||
</p>
|
||||
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem', marginTop: '0.5rem' }}>
|
||||
Rolle: {user?.role}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
packages/frontend/tsconfig.json
Normal file
29
packages/frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "vite-env.d.ts"]
|
||||
}
|
||||
1
packages/frontend/vite-env.d.ts
vendored
Normal file
1
packages/frontend/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
26
packages/frontend/vite.config.ts
Normal file
26
packages/frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 8080,
|
||||
host: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue