mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 02:17:03 +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
|
- `develop`: Kein direkter Push, 1 Approval erforderlich
|
||||||
7. **Forgejo Setup-Anleitung erstellt** (`docs/FORGEJO_SETUP.md`)
|
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
|
### Naechste Schritte
|
||||||
|
|
||||||
- [ ] SSH Deploy Keys auf insight-dev-01 Server hinterlegen
|
- [x] SSH Deploy Keys auf insight-dev-01 Server hinterlegen
|
||||||
- [ ] `docker-compose.yml` erstellen (alle Basis-Services)
|
- [x] `docker-compose.yml` erstellen (alle Basis-Services)
|
||||||
- [ ] `docker-compose.observability.yml` erstellen
|
- [x] `docker-compose.observability.yml` erstellen
|
||||||
- [ ] NestJS Core-Service implementieren (Auth, Users, Tenants)
|
- [x] NestJS Core-Service implementieren (Auth, Users, Tenants)
|
||||||
- [ ] Prisma-Schemas erstellen (core + tenant)
|
- [x] Prisma-Schemas erstellen (core + tenant)
|
||||||
- [ ] React Frontend-Shell implementieren
|
- [x] React Frontend-Shell implementieren
|
||||||
- [ ] CI/CD Pipelines (.forgejo/workflows/) definieren
|
- [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
|
### Offene Fragen / Abhaengigkeiten
|
||||||
|
|
||||||
- DNS-Eintrag `insight-dev.xinion.lan` muss auf 172.20.10.59 zeigen
|
- 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