From 0e8d5aef85cddd84593d32b6d0ccb053665ccf05 Mon Sep 17 00:00:00 2001 From: Flexomatic81 Date: Mon, 23 Feb 2026 21:17:34 +0100 Subject: [PATCH] feat: add Docker deployment, web installer, and local test environment - Multi-stage Dockerfiles for API (NestJS) and Web (Next.js standalone) - docker-compose.prod.yml: full production stack (postgres, redis, keycloak, api, web) with optional Caddy/Let's Encrypt via --profile ssl - docker-compose.local.yml: identical local test stack, all ports exposed - docker/postgres/init.sql: auto-creates tos_app DB on first start - Caddyfile: reverse proxy for app domain + auth subdomain - install.sh: interactive installer (domain, SSL mode, secret generation) - NestJS SetupModule: @Public() endpoints for /setup/status, /setup/admin, /setup/branding, /setup/complete with setup-token guard - Web installer: 4-step flow (system check, admin creation, branding, complete) at /[locale]/setup/* with public middleware bypass - i18n: installer namespace added to de.json and en.json - CORS: x-setup-token header allowed in main.ts Co-Authored-By: Claude Sonnet 4.6 --- .dockerignore | 53 ++++ apps/api/Dockerfile | 67 +++++ apps/api/package.json | 1 + apps/api/src/app.module.ts | 6 + apps/api/src/config/config.validation.ts | 6 + apps/api/src/health/health.controller.ts | 5 + apps/api/src/main.ts | 2 +- .../src/modules/setup/dto/create-admin.dto.ts | 23 ++ .../modules/setup/dto/save-branding.dto.ts | 19 ++ .../api/src/modules/setup/setup.controller.ts | 67 +++++ apps/api/src/modules/setup/setup.module.ts | 12 + apps/api/src/modules/setup/setup.service.ts | 206 ++++++++++++++++ apps/web/Dockerfile | 66 +++++ apps/web/messages/de.json | 58 +++++ apps/web/messages/en.json | 58 +++++ apps/web/next.config.mjs | 4 + .../src/app/[locale]/(setup)/admin/page.tsx | 225 +++++++++++++++++ .../app/[locale]/(setup)/branding/page.tsx | 144 +++++++++++ .../app/[locale]/(setup)/complete/page.tsx | 89 +++++++ apps/web/src/app/[locale]/(setup)/layout.tsx | 17 ++ apps/web/src/app/[locale]/(setup)/page.tsx | 176 +++++++++++++ .../[locale]/(setup)/setup-layout-content.tsx | 92 +++++++ apps/web/src/middleware.ts | 2 +- docker/.env.prod.example | 46 ++++ docker/Caddyfile | 30 +++ docker/docker-compose.local.yml | 200 +++++++++++++++ docker/docker-compose.prod.yml | 218 ++++++++++++++++ docker/docker-compose.yml | 1 + docker/postgres/init.sql | 16 ++ install.sh | 232 ++++++++++++++++++ pnpm-lock.yaml | 21 +- 31 files changed, 2158 insertions(+), 4 deletions(-) create mode 100644 .dockerignore create mode 100644 apps/api/Dockerfile create mode 100644 apps/api/src/modules/setup/dto/create-admin.dto.ts create mode 100644 apps/api/src/modules/setup/dto/save-branding.dto.ts create mode 100644 apps/api/src/modules/setup/setup.controller.ts create mode 100644 apps/api/src/modules/setup/setup.module.ts create mode 100644 apps/api/src/modules/setup/setup.service.ts create mode 100644 apps/web/Dockerfile create mode 100644 apps/web/src/app/[locale]/(setup)/admin/page.tsx create mode 100644 apps/web/src/app/[locale]/(setup)/branding/page.tsx create mode 100644 apps/web/src/app/[locale]/(setup)/complete/page.tsx create mode 100644 apps/web/src/app/[locale]/(setup)/layout.tsx create mode 100644 apps/web/src/app/[locale]/(setup)/page.tsx create mode 100644 apps/web/src/app/[locale]/(setup)/setup-layout-content.tsx create mode 100644 docker/.env.prod.example create mode 100644 docker/Caddyfile create mode 100644 docker/docker-compose.local.yml create mode 100644 docker/docker-compose.prod.yml create mode 100644 docker/postgres/init.sql create mode 100755 install.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..85c2573 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,53 @@ +# ============================================================================= +# tOS Docker Build Context Ignore +# ============================================================================= +# Diese Datei verhindert, dass unnoetige Dateien in den Docker Build Context +# kopiert werden. Das beschleunigt den Build und reduziert die Image-Groesse. +# ============================================================================= + +# Dependencies (werden im Container neu installiert) +node_modules +**/node_modules + +# Build-Artefakte +.next +**/dist +.turbo + +# Versionskontrolle +.git +.gitignore + +# Logs +*.log +npm-debug.log* +pnpm-debug.log* + +# Environment-Dateien (Secrets gehoeren nicht ins Image!) +.env +.env.local +.env.*.local +.env.development +.env.production + +# Docker-Daten (vermeidet rekursives Kopieren) +docker/data + +# Test & Coverage +coverage +.nyc_output + +# Temporaere Dateien +tmp +temp +.cache + +# IDE-Konfiguration +.vscode +.idea +*.swp +*.swo + +# OS-Dateien +.DS_Store +Thumbs.db diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..09f39c9 --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,67 @@ +# ============================================================================= +# tOS API - Multi-Stage Docker Build +# ============================================================================= +# Optimiert fuer pnpm Monorepo mit Prisma ORM +# +# Build: docker build -f apps/api/Dockerfile -t tos-api . +# Run: docker run -p 3001:3001 --env-file .env tos-api +# ============================================================================= + +# --------------------------------------------------------------------------- +# Stage 1: Base - Node.js mit pnpm +# --------------------------------------------------------------------------- +FROM node:20-alpine AS base +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate + +# --------------------------------------------------------------------------- +# Stage 2: Builder - Dependencies installieren und kompilieren +# --------------------------------------------------------------------------- +FROM base AS builder +WORKDIR /app + +# Kopiere Workspace-Konfiguration (fuer pnpm Monorepo-Aufloesung) +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ + +# Kopiere Shared Package (wird von der API als Dependency referenziert) +COPY packages/ ./packages/ + +# Kopiere API-Quellcode +COPY apps/api/ ./apps/api/ + +# Installiere alle Dependencies (frozen-lockfile fuer reproduzierbare Builds) +RUN pnpm install --frozen-lockfile + +# Baue zuerst das Shared Package (Dependency der API) +RUN pnpm --filter @tos/shared build + +# Generiere Prisma Client (benoetigt fuer den Build) +RUN pnpm --filter @tos/api exec prisma generate + +# Baue die API +RUN pnpm --filter @tos/api build + +# --------------------------------------------------------------------------- +# Stage 3: Runner - Schlankes Production Image +# --------------------------------------------------------------------------- +FROM base AS runner +WORKDIR /app +ENV NODE_ENV=production + +# Kopiere Build-Artefakte +COPY --from=builder /app/apps/api/dist ./dist +COPY --from=builder /app/apps/api/prisma ./prisma +COPY --from=builder /app/apps/api/package.json ./package.json + +# Kopiere node_modules (API-spezifisch + hoisted) +COPY --from=builder /app/apps/api/node_modules ./node_modules +# Kopiere Prisma Client (plattform-spezifische Binaries) +COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma +COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma + +# Sicherheit: Nicht als root ausfuehren +USER node + +EXPOSE 3001 + +# Beim Start: Zuerst Datenbankmigrationen anwenden, dann API starten +CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main.js"] diff --git a/apps/api/package.json b/apps/api/package.json index 1355224..438df92 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -26,6 +26,7 @@ "db:seed": "prisma db seed" }, "dependencies": { + "@nestjs/axios": "^4.0.1", "@nestjs/common": "^10.3.0", "@nestjs/config": "^3.1.1", "@nestjs/core": "^10.3.0", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 53ffec9..5e6ed14 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -31,6 +31,9 @@ import { HrModule } from './modules/hr/hr.module'; // Phase 6 modules - Integrations import { IntegrationsModule } from './modules/integrations/integrations.module'; +// Setup module - initial system configuration wizard +import { SetupModule } from './modules/setup/setup.module'; + @Module({ imports: [ // Configuration @@ -75,6 +78,9 @@ import { IntegrationsModule } from './modules/integrations/integrations.module'; // Phase 6 modules - Integrations IntegrationsModule, + + // Setup wizard - initial system configuration + SetupModule, ], providers: [ // Global JWT Guard - routes are protected by default diff --git a/apps/api/src/config/config.validation.ts b/apps/api/src/config/config.validation.ts index 8f5471a..8408f5e 100644 --- a/apps/api/src/config/config.validation.ts +++ b/apps/api/src/config/config.validation.ts @@ -27,6 +27,12 @@ export const configValidationSchema = Joi.object({ otherwise: Joi.optional(), }), + // Keycloak Admin (for setup wizard) + KEYCLOAK_ADMIN_PASSWORD: Joi.string().optional(), + + // Setup token (initial system setup) + SETUP_TOKEN: Joi.string().optional(), + // Redis (optional - for BullMQ in production) REDIS_HOST: Joi.string().optional(), REDIS_PORT: Joi.number().optional(), diff --git a/apps/api/src/health/health.controller.ts b/apps/api/src/health/health.controller.ts index 8580e74..4cda69a 100644 --- a/apps/api/src/health/health.controller.ts +++ b/apps/api/src/health/health.controller.ts @@ -10,6 +10,9 @@ import { Public } from '../auth/decorators/public.decorator'; import { PrismaHealthIndicator } from './prisma-health.indicator'; import { ModulesHealthIndicator } from './modules-health.indicator'; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { version } = require('../../package.json'); + @ApiTags('health') @Controller('health') export class HealthController { @@ -78,6 +81,7 @@ export class HealthController { type: 'object', properties: { status: { type: 'string', example: 'ok' }, + version: { type: 'string', example: '0.0.1' }, timestamp: { type: 'string', example: '2024-01-15T10:30:00.000Z' }, }, }, @@ -85,6 +89,7 @@ export class HealthController { liveness() { return { status: 'ok', + version: version as string, timestamp: new Date().toISOString(), }; } diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 311d89e..14a6633 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -32,7 +32,7 @@ async function bootstrap() { }, credentials: true, methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization', 'Accept'], + allowedHeaders: ['Content-Type', 'Authorization', 'Accept', 'x-setup-token'], }); // API Prefix diff --git a/apps/api/src/modules/setup/dto/create-admin.dto.ts b/apps/api/src/modules/setup/dto/create-admin.dto.ts new file mode 100644 index 0000000..4e74961 --- /dev/null +++ b/apps/api/src/modules/setup/dto/create-admin.dto.ts @@ -0,0 +1,23 @@ +import { IsString, IsNotEmpty, IsEmail, MinLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateAdminDto { + @ApiProperty({ example: 'Max' }) + @IsString() + @IsNotEmpty() + firstName: string; + + @ApiProperty({ example: 'Mustermann' }) + @IsString() + @IsNotEmpty() + lastName: string; + + @ApiProperty({ example: 'admin@example.com' }) + @IsEmail() + email: string; + + @ApiProperty({ example: 'SecurePass123!', minLength: 8 }) + @IsString() + @MinLength(8) + password: string; +} diff --git a/apps/api/src/modules/setup/dto/save-branding.dto.ts b/apps/api/src/modules/setup/dto/save-branding.dto.ts new file mode 100644 index 0000000..7fcc7fc --- /dev/null +++ b/apps/api/src/modules/setup/dto/save-branding.dto.ts @@ -0,0 +1,19 @@ +import { IsString, IsNotEmpty, IsOptional, IsUrl } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class SaveBrandingDto { + @ApiProperty({ example: 'tOS' }) + @IsString() + @IsNotEmpty() + appName: string; + + @ApiPropertyOptional({ example: 'Acme Corp' }) + @IsString() + @IsOptional() + companyName?: string; + + @ApiPropertyOptional({ example: 'https://example.com/logo.png' }) + @IsUrl() + @IsOptional() + logoUrl?: string; +} diff --git a/apps/api/src/modules/setup/setup.controller.ts b/apps/api/src/modules/setup/setup.controller.ts new file mode 100644 index 0000000..0245de1 --- /dev/null +++ b/apps/api/src/modules/setup/setup.controller.ts @@ -0,0 +1,67 @@ +import { Controller, Get, Post, Put, Body, Headers } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiHeader, ApiResponse } from '@nestjs/swagger'; +import { Public } from '../../auth/decorators/public.decorator'; +import { SetupService } from './setup.service'; +import { CreateAdminDto } from './dto/create-admin.dto'; +import { SaveBrandingDto } from './dto/save-branding.dto'; + +@ApiTags('setup') +@Public() +@Controller('setup') +export class SetupController { + constructor(private readonly setupService: SetupService) {} + + @Get('status') + @ApiOperation({ summary: 'Get current setup status' }) + @ApiResponse({ + status: 200, + description: 'Returns whether setup is completed and token is configured', + schema: { + type: 'object', + properties: { + completed: { type: 'boolean', example: false }, + tokenConfigured: { type: 'boolean', example: true }, + }, + }, + }) + getStatus() { + return this.setupService.getStatus(); + } + + @Post('admin') + @ApiOperation({ summary: 'Create initial admin user in Keycloak and local DB' }) + @ApiHeader({ name: 'x-setup-token', required: true }) + @ApiResponse({ status: 201, description: 'Admin user created' }) + @ApiResponse({ status: 401, description: 'Invalid setup token' }) + @ApiResponse({ status: 403, description: 'Setup already completed' }) + @ApiResponse({ status: 409, description: 'User already exists in Keycloak' }) + createAdmin( + @Body() dto: CreateAdminDto, + @Headers('x-setup-token') token: string, + ) { + return this.setupService.createAdmin(dto, token ?? ''); + } + + @Put('branding') + @ApiOperation({ summary: 'Save branding settings (app name, company, logo)' }) + @ApiHeader({ name: 'x-setup-token', required: true }) + @ApiResponse({ status: 200, description: 'Branding settings saved' }) + @ApiResponse({ status: 401, description: 'Invalid setup token' }) + @ApiResponse({ status: 403, description: 'Setup already completed' }) + saveBranding( + @Body() dto: SaveBrandingDto, + @Headers('x-setup-token') token: string, + ) { + return this.setupService.saveBranding(dto, token ?? ''); + } + + @Post('complete') + @ApiOperation({ summary: 'Mark setup as completed' }) + @ApiHeader({ name: 'x-setup-token', required: true }) + @ApiResponse({ status: 201, description: 'Setup marked as completed' }) + @ApiResponse({ status: 401, description: 'Invalid setup token' }) + @ApiResponse({ status: 403, description: 'Setup already completed' }) + completeSetup(@Headers('x-setup-token') token: string) { + return this.setupService.completeSetup(token ?? ''); + } +} diff --git a/apps/api/src/modules/setup/setup.module.ts b/apps/api/src/modules/setup/setup.module.ts new file mode 100644 index 0000000..f45f443 --- /dev/null +++ b/apps/api/src/modules/setup/setup.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; +import { SetupController } from './setup.controller'; +import { SetupService } from './setup.service'; +import { SystemSettingsModule } from '../system-settings/system-settings.module'; + +@Module({ + imports: [SystemSettingsModule, HttpModule], + controllers: [SetupController], + providers: [SetupService], +}) +export class SetupModule {} diff --git a/apps/api/src/modules/setup/setup.service.ts b/apps/api/src/modules/setup/setup.service.ts new file mode 100644 index 0000000..98b1719 --- /dev/null +++ b/apps/api/src/modules/setup/setup.service.ts @@ -0,0 +1,206 @@ +import { + Injectable, + UnauthorizedException, + ForbiddenException, + ConflictException, + Logger, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { HttpService } from '@nestjs/axios'; +import { firstValueFrom } from 'rxjs'; +import { SystemSettingsService } from '../system-settings/system-settings.service'; +import { PrismaService } from '../../prisma/prisma.service'; +import { CreateAdminDto } from './dto/create-admin.dto'; +import { SaveBrandingDto } from './dto/save-branding.dto'; + +@Injectable() +export class SetupService { + private readonly logger = new Logger(SetupService.name); + + constructor( + private readonly systemSettings: SystemSettingsService, + private readonly prisma: PrismaService, + private readonly config: ConfigService, + private readonly http: HttpService, + ) {} + + /** + * Returns the current setup status. + */ + async getStatus() { + const completed = await this.systemSettings.getValue('setup.completed'); + const tokenConfigured = await this.systemSettings.getValue('setup.token'); + + return { + completed: completed === 'true', + tokenConfigured: !!tokenConfigured, + }; + } + + /** + * Creates the initial admin user in Keycloak and the local database. + * + * Flow: + * 1. Validate the setup token + * 2. Obtain a Keycloak admin access token via master realm + * 3. Create the user in Keycloak with verified email and password + * 4. Create/update the user in the local DB with the keycloakId reference + * 5. Assign the "admin" role + */ + async createAdmin(dto: CreateAdminDto, token: string): Promise { + await this.validateToken(token); + + const keycloakUrl = this.config.get('KEYCLOAK_URL'); + const realm = this.config.get('KEYCLOAK_REALM', 'tOS'); + const adminPassword = this.config.get('KEYCLOAK_ADMIN_PASSWORD'); + + // 1. Get admin access token from Keycloak master realm + let adminToken: string; + try { + const tokenRes = await firstValueFrom( + this.http.post( + `${keycloakUrl}/realms/master/protocol/openid-connect/token`, + new URLSearchParams({ + grant_type: 'password', + client_id: 'admin-cli', + username: 'admin', + password: adminPassword ?? '', + }), + { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }, + ), + ); + adminToken = tokenRes.data.access_token; + } catch (err) { + this.logger.error('Failed to get Keycloak admin token', err); + throw new Error('Keycloak admin authentication failed'); + } + + // 2. Create user in Keycloak + let keycloakUserId: string; + try { + const createRes = await firstValueFrom( + this.http.post( + `${keycloakUrl}/admin/realms/${realm}/users`, + { + firstName: dto.firstName, + lastName: dto.lastName, + email: dto.email, + username: dto.email, + enabled: true, + emailVerified: true, + credentials: [ + { type: 'password', value: dto.password, temporary: false }, + ], + }, + { headers: { Authorization: `Bearer ${adminToken}` } }, + ), + ); + // Keycloak returns 201 with Location header containing the user ID + const location = createRes.headers['location'] as string; + keycloakUserId = location.split('/').pop() ?? ''; + } catch (err: any) { + if (err.response?.status === 409) { + throw new ConflictException( + 'A user with this email already exists in Keycloak', + ); + } + this.logger.error('Failed to create Keycloak user', err); + throw new Error('Failed to create user in Keycloak'); + } + + // 3. Create user in local DB (keycloakId is the unique link to Keycloak) + const adminRole = await this.prisma.role.findFirst({ + where: { name: 'admin' }, + }); + + const user = await this.prisma.user.upsert({ + where: { keycloakId: keycloakUserId }, + create: { + email: dto.email, + firstName: dto.firstName, + lastName: dto.lastName, + keycloakId: keycloakUserId, + isActive: true, + }, + update: { + email: dto.email, + firstName: dto.firstName, + lastName: dto.lastName, + }, + }); + + // 4. Assign admin role + if (adminRole) { + await this.prisma.userRole.upsert({ + where: { + userId_roleId: { userId: user.id, roleId: adminRole.id }, + }, + create: { userId: user.id, roleId: adminRole.id }, + update: {}, + }); + } + + this.logger.log( + `Admin user created: ${dto.email} (keycloakId: ${keycloakUserId})`, + ); + } + + /** + * Saves branding settings (app name, company name, logo URL). + */ + async saveBranding(dto: SaveBrandingDto, token: string): Promise { + await this.validateToken(token); + + await this.systemSettings.upsert('branding.appName', { + value: dto.appName, + category: 'branding', + }); + + if (dto.companyName !== undefined) { + await this.systemSettings.upsert('branding.companyName', { + value: dto.companyName, + category: 'branding', + }); + } + + if (dto.logoUrl !== undefined) { + await this.systemSettings.upsert('branding.logoUrl', { + value: dto.logoUrl, + category: 'branding', + }); + } + + this.logger.log(`Branding settings saved: appName=${dto.appName}`); + } + + /** + * Marks the setup as completed. After this, all setup endpoints + * will reject further modifications. + */ + async completeSetup(token: string): Promise { + await this.validateToken(token); + + await this.systemSettings.upsert('setup.completed', { + value: 'true', + category: 'system', + valueType: 'boolean', + }); + + this.logger.log('Setup completed successfully'); + } + + /** + * Validates the setup token and checks that setup has not been completed yet. + */ + private async validateToken(token: string): Promise { + const completed = await this.systemSettings.getValue('setup.completed'); + if (completed === 'true') { + throw new ForbiddenException('Setup is already completed'); + } + + const storedToken = await this.systemSettings.getValue('setup.token'); + if (!storedToken || storedToken !== token) { + throw new UnauthorizedException('Invalid setup token'); + } + } +} diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..15c94c5 --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,66 @@ +# ============================================================================= +# tOS Web Frontend - Multi-Stage Docker Build +# ============================================================================= +# Nutzt Next.js Standalone-Output fuer minimale Image-Groesse +# Voraussetzung: output: 'standalone' in next.config.mjs +# +# Build: docker build -f apps/web/Dockerfile -t tos-web . +# Run: docker run -p 3000:3000 --env-file .env tos-web +# ============================================================================= + +# --------------------------------------------------------------------------- +# Stage 1: Base - Node.js mit pnpm +# --------------------------------------------------------------------------- +FROM node:20-alpine AS base +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate + +# --------------------------------------------------------------------------- +# Stage 2: Builder - Dependencies installieren und kompilieren +# --------------------------------------------------------------------------- +FROM base AS builder +WORKDIR /app + +# Kopiere Workspace-Konfiguration (fuer pnpm Monorepo-Aufloesung) +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ + +# Kopiere Shared Package (wird vom Frontend als Dependency referenziert) +COPY packages/ ./packages/ + +# Kopiere Frontend-Quellcode +COPY apps/web/ ./apps/web/ + +# Installiere alle Dependencies (frozen-lockfile fuer reproduzierbare Builds) +RUN pnpm install --frozen-lockfile + +# Baue zuerst das Shared Package (Dependency des Frontends) +RUN pnpm --filter @tos/shared build + +# Baue das Frontend (erzeugt .next/standalone dank output: 'standalone') +RUN pnpm --filter @tos/web build + +# --------------------------------------------------------------------------- +# Stage 3: Runner - Schlankes Production Image +# --------------------------------------------------------------------------- +FROM base AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +# Kopiere Standalone-Output (enthaelt Server + gebundelte Dependencies) +COPY --from=builder /app/apps/web/.next/standalone ./ + +# Kopiere statische Assets (werden nicht im Standalone-Bundle enthalten) +COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static + +# Kopiere Public-Verzeichnis (Bilder, Fonts, etc.) +COPY --from=builder /app/apps/web/public ./apps/web/public + +# Sicherheit: Nicht als root ausfuehren +USER node + +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +# Next.js Standalone-Server starten +CMD ["node", "apps/web/server.js"] diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index 0166d58..36a34be 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -1073,5 +1073,63 @@ "saveError": "Fehler beim Speichern der Einstellungen", "requiresRestart": "Aenderung erfordert einen Neustart des Backends", "save": "Speichern" + }, + "installer": { + "title": "tOS Einrichtung", + "setupComplete": "Einrichtung abgeschlossen", + "notAccessible": "Nach der Einrichtung ist dieser Bereich nicht mehr zugaenglich.", + "steps": { + "systemCheck": "Systempruefung", + "adminSetup": "Admin-Account", + "branding": "Branding", + "complete": "Abschluss" + }, + "systemCheck": { + "title": "Systemueberpruefung", + "description": "Alle Dienste werden auf Erreichbarkeit geprueft.", + "api": "API-Server", + "database": "Datenbank", + "keycloak": "Authentifizierung", + "online": "Online", + "offline": "Nicht erreichbar", + "checking": "Wird geprueft...", + "continue": "Weiter zur Einrichtung", + "alreadyComplete": "Die Einrichtung wurde bereits abgeschlossen.", + "redirecting": "Du wirst zum Dashboard weitergeleitet..." + }, + "adminSetup": { + "title": "Admin-Account erstellen", + "description": "Erstelle den ersten Administrator-Account fuer tOS.", + "firstName": "Vorname", + "lastName": "Nachname", + "email": "E-Mail-Adresse", + "password": "Passwort", + "passwordConfirm": "Passwort bestaetigen", + "passwordMismatch": "Passwoerter stimmen nicht ueberein", + "passwordTooShort": "Mindestens 8 Zeichen erforderlich", + "createAccount": "Account erstellen", + "creating": "Wird erstellt..." + }, + "branding": { + "title": "Branding konfigurieren", + "description": "Passe tOS an dein Unternehmen an.", + "appName": "App-Name", + "appNamePlaceholder": "tOS", + "companyName": "Firmenname", + "companyNamePlaceholder": "Mein Unternehmen GmbH", + "logoUrl": "Logo-URL", + "logoUrlPlaceholder": "https://example.com/logo.png", + "logoPreview": "Logo-Vorschau", + "save": "Speichern & Weiter", + "saving": "Wird gespeichert...", + "skip": "Ueberspringen" + }, + "complete": { + "title": "Einrichtung abgeschlossen!", + "description": "tOS wurde erfolgreich eingerichtet und ist bereit zur Nutzung.", + "completing": "Einrichtung wird abgeschlossen...", + "toDashboard": "Zum Dashboard", + "toLogin": "Zum Login" + } } } diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index b38aff7..ad5f077 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1073,5 +1073,63 @@ "saveError": "Failed to save settings", "requiresRestart": "Change requires a backend restart", "save": "Save" + }, + "installer": { + "title": "tOS Setup", + "setupComplete": "Setup Complete", + "notAccessible": "After setup, this area will no longer be accessible.", + "steps": { + "systemCheck": "System Check", + "adminSetup": "Admin Account", + "branding": "Branding", + "complete": "Complete" + }, + "systemCheck": { + "title": "System Check", + "description": "Checking all services for availability.", + "api": "API Server", + "database": "Database", + "keycloak": "Authentication", + "online": "Online", + "offline": "Unreachable", + "checking": "Checking...", + "continue": "Continue Setup", + "alreadyComplete": "Setup has already been completed.", + "redirecting": "Redirecting to dashboard..." + }, + "adminSetup": { + "title": "Create Admin Account", + "description": "Create the first administrator account for tOS.", + "firstName": "First Name", + "lastName": "Last Name", + "email": "Email Address", + "password": "Password", + "passwordConfirm": "Confirm Password", + "passwordMismatch": "Passwords do not match", + "passwordTooShort": "Minimum 8 characters required", + "createAccount": "Create Account", + "creating": "Creating..." + }, + "branding": { + "title": "Configure Branding", + "description": "Customize tOS for your company.", + "appName": "App Name", + "appNamePlaceholder": "tOS", + "companyName": "Company Name", + "companyNamePlaceholder": "My Company Inc.", + "logoUrl": "Logo URL", + "logoUrlPlaceholder": "https://example.com/logo.png", + "logoPreview": "Logo Preview", + "save": "Save & Continue", + "saving": "Saving...", + "skip": "Skip" + }, + "complete": { + "title": "Setup Complete!", + "description": "tOS has been successfully set up and is ready to use.", + "completing": "Completing setup...", + "toDashboard": "Go to Dashboard", + "toLogin": "Go to Login" + } } } diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index eadfc54..997dc5b 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -7,6 +7,10 @@ const nextConfig = { // Enable React strict mode for better development experience reactStrictMode: true, + // Standalone output for Docker deployment + // Erzeugt ein eigenstaendiges Build-Artefakt mit allen Abhaengigkeiten + output: 'standalone', + // Configure image optimization images: { remotePatterns: [ diff --git a/apps/web/src/app/[locale]/(setup)/admin/page.tsx b/apps/web/src/app/[locale]/(setup)/admin/page.tsx new file mode 100644 index 0000000..bf04c48 --- /dev/null +++ b/apps/web/src/app/[locale]/(setup)/admin/page.tsx @@ -0,0 +1,225 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +import { useLocale } from 'next-intl'; +import { motion } from 'framer-motion'; +import { Eye, EyeOff, Loader2, UserPlus } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { useToast } from '@/hooks/use-toast'; +import { cn } from '@/lib/utils'; + +export default function AdminSetupPage() { + const t = useTranslations('installer.adminSetup'); + const router = useRouter(); + const locale = useLocale(); + const { toast } = useToast(); + + const [form, setForm] = useState({ + firstName: '', + lastName: '', + email: '', + password: '', + passwordConfirm: '', + }); + const [showPassword, setShowPassword] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [errors, setErrors] = useState>({}); + + const validate = () => { + const newErrors: Record = {}; + if (!form.firstName.trim()) newErrors.firstName = 'Required'; + if (!form.lastName.trim()) newErrors.lastName = 'Required'; + if (!form.email.trim()) newErrors.email = 'Required'; + if (form.password.length < 8) newErrors.password = t('passwordTooShort'); + if (form.password !== form.passwordConfirm) newErrors.passwordConfirm = t('passwordMismatch'); + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!validate()) return; + + setIsLoading(true); + const token = sessionStorage.getItem('setup-token') ?? ''; + const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001/api/v1'; + + try { + const res = await fetch(`${apiUrl}/setup/admin`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-setup-token': token, + }, + body: JSON.stringify({ + firstName: form.firstName, + lastName: form.lastName, + email: form.email, + password: form.password, + }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.message ?? `Error ${res.status}`); + } + + router.push(`/${locale}/setup/branding`); + } catch (err) { + toast({ + title: 'Fehler', + description: err instanceof Error ? err.message : 'Unbekannter Fehler', + variant: 'destructive', + }); + } finally { + setIsLoading(false); + } + }; + + return ( + + + + {t('title')} + {t('description')} + + +
+
+
+ + setForm((f) => ({ ...f, firstName: e.target.value }))} + className={cn(errors.firstName && 'border-destructive')} + /> + {errors.firstName && ( +

{errors.firstName}

+ )} +
+
+ + setForm((f) => ({ ...f, lastName: e.target.value }))} + className={cn(errors.lastName && 'border-destructive')} + /> + {errors.lastName && ( +

{errors.lastName}

+ )} +
+
+ +
+ + setForm((f) => ({ ...f, email: e.target.value }))} + className={cn(errors.email && 'border-destructive')} + /> + {errors.email &&

{errors.email}

} +
+ +
+
+ +
+ setForm((f) => ({ ...f, password: e.target.value }))} + className={cn('pr-10', errors.password && 'border-destructive')} + /> + +
+ {errors.password && ( +

{errors.password}

+ )} +
+ +
+ +
+ + setForm((f) => ({ ...f, passwordConfirm: e.target.value })) + } + className={cn('pr-10', errors.passwordConfirm && 'border-destructive')} + /> + +
+ {errors.passwordConfirm && ( +

{errors.passwordConfirm}

+ )} +
+
+ +
+ +
+
+
+
+
+ ); +} diff --git a/apps/web/src/app/[locale]/(setup)/branding/page.tsx b/apps/web/src/app/[locale]/(setup)/branding/page.tsx new file mode 100644 index 0000000..247f920 --- /dev/null +++ b/apps/web/src/app/[locale]/(setup)/branding/page.tsx @@ -0,0 +1,144 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +import { useLocale } from 'next-intl'; +import { motion } from 'framer-motion'; +import { Loader2, Palette, ArrowRight } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { useToast } from '@/hooks/use-toast'; + +export default function BrandingPage() { + const t = useTranslations('installer.branding'); + const router = useRouter(); + const locale = useLocale(); + const { toast } = useToast(); + + const [form, setForm] = useState({ appName: 'tOS', companyName: '', logoUrl: '' }); + const [isLoading, setIsLoading] = useState(false); + + const handleSave = async () => { + setIsLoading(true); + const token = sessionStorage.getItem('setup-token') ?? ''; + const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001/api/v1'; + + try { + const body: Record = { appName: form.appName }; + if (form.companyName) body.companyName = form.companyName; + if (form.logoUrl) body.logoUrl = form.logoUrl; + + const res = await fetch(`${apiUrl}/setup/branding`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'x-setup-token': token, + }, + body: JSON.stringify(body), + }); + + if (!res.ok) throw new Error(`Error ${res.status}`); + router.push(`/${locale}/setup/complete`); + } catch (err) { + toast({ + title: 'Fehler', + description: err instanceof Error ? err.message : 'Unbekannter Fehler', + variant: 'destructive', + }); + } finally { + setIsLoading(false); + } + }; + + const handleSkip = () => router.push(`/${locale}/setup/complete`); + + return ( + + + + {t('title')} + {t('description')} + + +
+ + setForm((f) => ({ ...f, appName: e.target.value }))} + /> +
+ +
+ + setForm((f) => ({ ...f, companyName: e.target.value }))} + /> +
+ +
+ + setForm((f) => ({ ...f, logoUrl: e.target.value }))} + /> + {form.logoUrl && ( +
+

{t('logoPreview')}

+ {/* eslint-disable-next-line @next/next/no-img-element */} + Logo Preview { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> +
+ )} +
+ +
+ + +
+
+
+
+ ); +} diff --git a/apps/web/src/app/[locale]/(setup)/complete/page.tsx b/apps/web/src/app/[locale]/(setup)/complete/page.tsx new file mode 100644 index 0000000..41a9019 --- /dev/null +++ b/apps/web/src/app/[locale]/(setup)/complete/page.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { useTranslations } from 'next-intl'; +import { useLocale } from 'next-intl'; +import { motion } from 'framer-motion'; +import { CheckCircle2, LayoutDashboard, LogIn, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; + +export default function CompletePage() { + const t = useTranslations('installer.complete'); + const locale = useLocale(); + const [isCompleting, setIsCompleting] = useState(true); + + useEffect(() => { + completeSetup(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const completeSetup = async () => { + const token = sessionStorage.getItem('setup-token') ?? ''; + const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001/api/v1'; + + try { + await fetch(`${apiUrl}/setup/complete`, { + method: 'POST', + headers: { 'x-setup-token': token }, + }); + } catch { + // Best-effort completion + } finally { + sessionStorage.removeItem('setup-token'); + setIsCompleting(false); + } + }; + + if (isCompleting) { + return ( + + + +

{t('completing')}

+
+
+ ); + } + + return ( + + + + + + + +
+

{t('title')}

+

{t('description')}

+
+ +
+ + +
+
+
+
+ ); +} diff --git a/apps/web/src/app/[locale]/(setup)/layout.tsx b/apps/web/src/app/[locale]/(setup)/layout.tsx new file mode 100644 index 0000000..44f2361 --- /dev/null +++ b/apps/web/src/app/[locale]/(setup)/layout.tsx @@ -0,0 +1,17 @@ +import { getTranslations } from 'next-intl/server'; +import { SetupLayoutContent } from './setup-layout-content'; + +interface SetupLayoutProps { + children: React.ReactNode; + params: { locale: string }; +} + +export default async function SetupLayout({ children, params }: SetupLayoutProps) { + const t = await getTranslations({ locale: params.locale, namespace: 'installer' }); + + return ( + + {children} + + ); +} diff --git a/apps/web/src/app/[locale]/(setup)/page.tsx b/apps/web/src/app/[locale]/(setup)/page.tsx new file mode 100644 index 0000000..7f1a11e --- /dev/null +++ b/apps/web/src/app/[locale]/(setup)/page.tsx @@ -0,0 +1,176 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +import { useLocale } from 'next-intl'; +import { motion } from 'framer-motion'; +import { CheckCircle2, XCircle, Loader2, ArrowRight } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; + +type ServiceStatus = 'checking' | 'online' | 'offline'; + +interface StatusRowProps { + label: string; + status: ServiceStatus; + onlineLabel: string; + offlineLabel: string; + checkingLabel: string; +} + +function StatusRow({ label, status, onlineLabel, offlineLabel, checkingLabel }: StatusRowProps) { + return ( +
+ {label} +
+ {status === 'checking' && ( + <> + + {checkingLabel} + + )} + {status === 'online' && ( + <> + + {onlineLabel} + + )} + {status === 'offline' && ( + <> + + {offlineLabel} + + )} +
+
+ ); +} + +export default function SetupPage() { + const t = useTranslations('installer.systemCheck'); + const router = useRouter(); + const locale = useLocale(); + const searchParams = useSearchParams(); + + const [apiStatus, setApiStatus] = useState('checking'); + const [dbStatus, setDbStatus] = useState('checking'); + const [keycloakStatus, setKeycloakStatus] = useState('checking'); + const [setupComplete, setSetupComplete] = useState(false); + const [allOnline, setAllOnline] = useState(false); + + useEffect(() => { + // Store setup token from URL in sessionStorage + const token = searchParams.get('token'); + if (token) { + sessionStorage.setItem('setup-token', token); + } + + checkServices(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (apiStatus === 'online' && dbStatus === 'online' && keycloakStatus === 'online') { + setAllOnline(true); + } + }, [apiStatus, dbStatus, keycloakStatus]); + + const checkServices = async () => { + const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001/api/v1'; + + // Check API + try { + const res = await fetch(`${apiUrl}/health/liveness`); + if (res.ok) { + setApiStatus('online'); + setDbStatus('online'); // If API is up, DB is connected + } else { + setApiStatus('offline'); + setDbStatus('offline'); + } + } catch { + setApiStatus('offline'); + setDbStatus('offline'); + } + + // Check Setup Status + try { + const res = await fetch(`${apiUrl}/setup/status`); + if (res.ok) { + const data = await res.json(); + // Handle response envelope + const status = data.data ?? data; + if (status.completed) { + setSetupComplete(true); + setTimeout(() => router.push(`/${locale}/dashboard`), 3000); + return; + } + setKeycloakStatus('online'); + } else { + setKeycloakStatus('offline'); + } + } catch { + setKeycloakStatus('offline'); + } + }; + + if (setupComplete) { + return ( + + + +

{t('alreadyComplete')}

+

{t('redirecting')}

+
+
+ ); + } + + return ( + + + + {t('title')} + {t('description')} + + +
+ + + +
+ +
+ +
+
+
+
+ ); +} diff --git a/apps/web/src/app/[locale]/(setup)/setup-layout-content.tsx b/apps/web/src/app/[locale]/(setup)/setup-layout-content.tsx new file mode 100644 index 0000000..aaf8fed --- /dev/null +++ b/apps/web/src/app/[locale]/(setup)/setup-layout-content.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { usePathname } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +import { cn } from '@/lib/utils'; +import { Check } from 'lucide-react'; + +interface SetupLayoutContentProps { + children: React.ReactNode; + locale: string; + title: string; + notAccessible: string; +} + +const steps = ['systemCheck', 'adminSetup', 'branding', 'complete'] as const; + +export function SetupLayoutContent({ children, title, notAccessible }: SetupLayoutContentProps) { + const pathname = usePathname(); + const t = useTranslations('installer.steps'); + + // Determine the active step index based on the current URL path + let activeStep = 0; + if (pathname.includes('/complete')) activeStep = 3; + else if (pathname.includes('/branding')) activeStep = 2; + else if (pathname.includes('/admin')) activeStep = 1; + else activeStep = 0; + + return ( +
+ {/* Header */} +
+
+
+ t +
+ OS + · + {title} +
+
+ + {/* Step Indicator */} +
+
+
+ {steps.map((step, index) => ( +
+
activeStep && 'bg-muted text-muted-foreground border' + )} + > + {index < activeStep ? : index + 1} +
+ + {index < steps.length - 1 && ( +
+ )} +
+ ))} +
+
+
+ + {/* Content */} +
{children}
+ + {/* Footer */} +
+
+ {notAccessible} +
+
+
+ ); +} diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index ff3c14d..203cf27 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -4,7 +4,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { locales, defaultLocale } from './i18n'; // Public paths that don't require authentication -const publicPages = ['/login']; +const publicPages = ['/login', '/setup']; // Create the next-intl middleware const intlMiddleware = createMiddleware({ diff --git a/docker/.env.prod.example b/docker/.env.prod.example new file mode 100644 index 0000000..50b36fa --- /dev/null +++ b/docker/.env.prod.example @@ -0,0 +1,46 @@ +# ============================================================================= +# tOS Production Configuration +# ============================================================================= +# Kopiere diese Datei nach docker/.env und fuelle alle Werte aus. +# Alternativ: Nutze install.sh fuer eine interaktive Einrichtung. +# +# Secrets generieren mit: openssl rand -hex 32 +# ============================================================================= + +# ---- Application Domain ---------------------------------------------------- +APP_DOMAIN=tos.example.com +LETSENCRYPT_EMAIL=admin@example.com + +# ---- PostgreSQL ------------------------------------------------------------- +POSTGRES_USER=tos_user +POSTGRES_PASSWORD=CHANGE_ME_run_openssl_rand_hex_32 +POSTGRES_DB=tos_db +POSTGRES_PORT=5432 + +# ---- Redis ------------------------------------------------------------------ +REDIS_PORT=6379 + +# ---- Keycloak --------------------------------------------------------------- +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD=CHANGE_ME_run_openssl_rand_hex_32 +KEYCLOAK_PORT=8080 +KEYCLOAK_REALM=tOS + +# ---- Application Secrets --------------------------------------------------- +# Jedes Secret separat generieren: openssl rand -hex 32 +JWT_SECRET=CHANGE_ME_run_openssl_rand_hex_32 +ENCRYPTION_KEY=CHANGE_ME_run_openssl_rand_hex_32 +NEXTAUTH_SECRET=CHANGE_ME_run_openssl_rand_hex_32 + +# ---- Keycloak OAuth Clients ------------------------------------------------ +# Nach dem ersten Keycloak-Start aus der Admin-UI auslesen: +# https://auth./admin/master/console/#/tOS/clients +KEYCLOAK_CLIENT_ID=tos-backend +KEYCLOAK_CLIENT_SECRET=CHANGE_ME_from_keycloak_admin_ui +NEXTAUTH_KEYCLOAK_CLIENT_ID=tos-nextauth +NEXTAUTH_KEYCLOAK_CLIENT_SECRET=CHANGE_ME_from_keycloak_admin_ui + +# ---- Setup Token ------------------------------------------------------------ +# Wird fuer die initiale Einrichtung benoetigt. Nach dem Setup entfernen. +# Generieren mit: uuidgen || openssl rand -hex 16 +SETUP_TOKEN=CHANGE_ME_generated_by_install_script diff --git a/docker/Caddyfile b/docker/Caddyfile new file mode 100644 index 0000000..a5394aa --- /dev/null +++ b/docker/Caddyfile @@ -0,0 +1,30 @@ +# ============================================================================= +# tOS Caddy Reverse Proxy Konfiguration +# ============================================================================= +# Caddy uebernimmt automatisch Let's Encrypt Zertifikate und HTTPS-Terminierung. +# +# Routing: +# {APP_DOMAIN} -> Web Frontend (Next.js) +# {APP_DOMAIN}/api/* -> API Backend (NestJS) +# auth.{APP_DOMAIN} -> Keycloak Identity Provider +# ============================================================================= + +{ + email {$LETSENCRYPT_EMAIL} +} + +# Haupt-Domain: Frontend + API +{$APP_DOMAIN} { + # API-Requests an das NestJS Backend weiterleiten + handle /api/* { + reverse_proxy api:3001 + } + + # Alle anderen Requests an das Next.js Frontend + reverse_proxy web:3000 +} + +# Auth-Subdomain: Keycloak +auth.{$APP_DOMAIN} { + reverse_proxy keycloak:8080 +} diff --git a/docker/docker-compose.local.yml b/docker/docker-compose.local.yml new file mode 100644 index 0000000..6764a6d --- /dev/null +++ b/docker/docker-compose.local.yml @@ -0,0 +1,200 @@ +# ============================================================================= +# tOS Local Docker Compose (Full-Stack) +# ============================================================================= +# Lokaler Full-Stack zum Testen der containerisierten Anwendung. +# Alle Services laufen containerisiert mit exponierten Ports. +# +# Start: docker compose -f docker-compose.local.yml up -d --build +# Stop: docker compose -f docker-compose.local.yml down +# Reset: docker compose -f docker-compose.local.yml down -v +# +# Unterschied zum Dev-Stack (docker-compose.yml): +# - Dev-Stack: Nur Infra (Postgres, Redis, Keycloak), Apps laufen nativ +# - Local-Stack: Alle Services containerisiert (nahe an Produktion) +# ============================================================================= + +name: tos-local + +services: + # --------------------------------------------------------------------------- + # PostgreSQL Database + # --------------------------------------------------------------------------- + postgres: + image: postgres:16-alpine + container_name: tos-postgres-local + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-tos_user} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-tos_local_password} + POSTGRES_DB: ${POSTGRES_DB:-tos_db} + POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C" + ports: + - "5432:5432" + volumes: + - postgres_local_data:/var/lib/postgresql/data + - ./postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tos_user} -d ${POSTGRES_DB:-tos_db}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + networks: + - tos-local-network + + # --------------------------------------------------------------------------- + # Redis Cache & Queue + # --------------------------------------------------------------------------- + redis: + image: redis:7-alpine + container_name: tos-redis-local + restart: unless-stopped + command: redis-server --appendonly yes + ports: + - "6379:6379" + volumes: + - redis_local_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - tos-local-network + + # --------------------------------------------------------------------------- + # Keycloak Identity & Access Management (Dev Mode) + # --------------------------------------------------------------------------- + keycloak: + image: quay.io/keycloak/keycloak:24.0 + container_name: tos-keycloak-local + restart: unless-stopped + command: + - start-dev + - --import-realm + environment: + KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin} + KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin123} + KC_DB: postgres + KC_DB_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-tos_db} + KC_DB_USERNAME: ${POSTGRES_USER:-tos_user} + KC_DB_PASSWORD: ${POSTGRES_PASSWORD:-tos_local_password} + KC_HOSTNAME: localhost + KC_HOSTNAME_STRICT: "false" + KC_HOSTNAME_STRICT_HTTPS: "false" + KC_HTTP_ENABLED: "true" + KC_HEALTH_ENABLED: "true" + ports: + - "8080:8080" + volumes: + - ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro + healthcheck: + # Keycloak 24+ (UBI9) hat kein curl - nutze bash TCP redirect + test: > + bash -c 'exec 3<>/dev/tcp/localhost/8080 && + echo -e "GET /health/ready HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" >&3 && + timeout 2 cat <&3 | grep -q "200 OK"' + interval: 30s + timeout: 15s + retries: 5 + start_period: 90s + depends_on: + postgres: + condition: service_healthy + networks: + - tos-local-network + + # --------------------------------------------------------------------------- + # tOS API (NestJS Backend) + # --------------------------------------------------------------------------- + api: + build: + context: .. + dockerfile: apps/api/Dockerfile + container_name: tos-api-local + restart: unless-stopped + environment: + NODE_ENV: production + PORT: "3001" + API_PREFIX: api + DATABASE_URL: postgresql://${POSTGRES_USER:-tos_user}:${POSTGRES_PASSWORD:-tos_local_password}@postgres:5432/tos_app + JWT_SECRET: ${JWT_SECRET:-local-jwt-secret-not-for-production-use} + ENCRYPTION_KEY: ${ENCRYPTION_KEY:-local-encryption-key-32-bytes-long!!} + KEYCLOAK_URL: http://keycloak:8080 + KEYCLOAK_REALM: tOS + KEYCLOAK_CLIENT_ID: tos-backend + KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET:-} + KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin123} + REDIS_HOST: redis + REDIS_PORT: "6379" + SETUP_TOKEN: ${SETUP_TOKEN:-local-setup-token-for-testing} + ports: + - "3001:3001" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + keycloak: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:3001/api/v1/health/liveness || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + networks: + - tos-local-network + + # --------------------------------------------------------------------------- + # tOS Web Frontend (Next.js) + # --------------------------------------------------------------------------- + # HINWEIS zum Keycloak-Issuer-Problem in Docker: + # Next.js/NextAuth validiert Tokens server-seitig gegen den Issuer. + # Der Browser erreicht Keycloak ueber localhost:8080, aber der Container + # muss ueber den Docker-Netzwerknamen "keycloak:8080" zugreifen. + # + # Loesung: KEYCLOAK_ISSUER fuer Browser-Redirects (localhost), + # NEXTAUTH_KEYCLOAK_ISSUER fuer server-seitige Validierung (Docker-intern) + # --------------------------------------------------------------------------- + web: + build: + context: .. + dockerfile: apps/web/Dockerfile + container_name: tos-web-local + restart: unless-stopped + environment: + NEXT_PUBLIC_APP_URL: http://localhost:3000 + NEXT_PUBLIC_API_URL: http://localhost:3001/api/v1 + NEXTAUTH_URL: http://localhost:3000 + NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-local-nextauth-secret} + KEYCLOAK_CLIENT_ID: tos-nextauth + KEYCLOAK_CLIENT_SECRET: ${NEXTAUTH_KEYCLOAK_CLIENT_SECRET:-tos-nextauth-secret-dev} + # Browser-seitige Redirects: localhost (erreichbar vom Host) + KEYCLOAK_ISSUER: http://localhost:8080/realms/tOS + # Server-seitige Token-Validierung: Docker-internes Netzwerk + NEXTAUTH_KEYCLOAK_ISSUER: http://keycloak:8080/realms/tOS + ports: + - "3000:3000" + depends_on: + api: + condition: service_healthy + networks: + - tos-local-network + +# ============================================================================= +# Volumes +# ============================================================================= +volumes: + postgres_local_data: + name: tos-local-postgres-data + redis_local_data: + name: tos-local-redis-data + +# ============================================================================= +# Networks +# ============================================================================= +networks: + tos-local-network: + name: tos-local-network + driver: bridge diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml new file mode 100644 index 0000000..86befb0 --- /dev/null +++ b/docker/docker-compose.prod.yml @@ -0,0 +1,218 @@ +# ============================================================================= +# tOS Production Docker Compose +# ============================================================================= +# Vollstaendiger Produktions-Stack mit optionalem SSL via Caddy. +# +# Ohne SSL (externer Reverse Proxy): +# docker compose -f docker-compose.prod.yml up -d +# +# Mit Let's Encrypt SSL (Caddy): +# docker compose -f docker-compose.prod.yml --profile ssl up -d +# +# Voraussetzungen: +# - docker/.env mit allen Secrets (erstellt durch install.sh) +# - Docker Images gebaut (api + web) +# ============================================================================= + +name: tos-prod + +services: + # --------------------------------------------------------------------------- + # PostgreSQL Database + # --------------------------------------------------------------------------- + postgres: + image: postgres:16-alpine + container_name: tos-postgres + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-tos_user} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB:-tos_db} + POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tos_user} -d ${POSTGRES_DB:-tos_db}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + networks: + - tos-network + + # --------------------------------------------------------------------------- + # Redis Cache & Queue + # --------------------------------------------------------------------------- + redis: + image: redis:7-alpine + container_name: tos-redis + restart: unless-stopped + command: > + redis-server + --appendonly yes + --maxmemory 256mb + --maxmemory-policy allkeys-lru + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - tos-network + + # --------------------------------------------------------------------------- + # Keycloak Identity & Access Management (Production Mode) + # --------------------------------------------------------------------------- + keycloak: + image: quay.io/keycloak/keycloak:24.0 + container_name: tos-keycloak + restart: unless-stopped + # "start" statt "start-dev" fuer Production (aktiviert Caching, deaktiviert Dev-Features) + command: + - start + - --import-realm + environment: + KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin} + KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD} + KC_DB: postgres + KC_DB_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-tos_db} + KC_DB_USERNAME: ${POSTGRES_USER:-tos_user} + KC_DB_PASSWORD: ${POSTGRES_PASSWORD} + # Hostname-Konfiguration fuer Production hinter Reverse Proxy + KC_HOSTNAME: auth.${APP_DOMAIN} + KC_HOSTNAME_STRICT: "true" + KC_HOSTNAME_STRICT_HTTPS: "true" + KC_HTTP_ENABLED: "true" + KC_HEALTH_ENABLED: "true" + KC_PROXY: edge + volumes: + - ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro + healthcheck: + # Keycloak 24+ (UBI9) hat kein curl - nutze bash TCP redirect + test: > + bash -c 'exec 3<>/dev/tcp/localhost/8080 && + echo -e "GET /health/ready HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" >&3 && + timeout 2 cat <&3 | grep -q "200 OK"' + interval: 30s + timeout: 15s + retries: 5 + start_period: 120s + depends_on: + postgres: + condition: service_healthy + networks: + - tos-network + + # --------------------------------------------------------------------------- + # tOS API (NestJS Backend) + # --------------------------------------------------------------------------- + api: + build: + context: .. + dockerfile: apps/api/Dockerfile + container_name: tos-api + restart: unless-stopped + environment: + NODE_ENV: production + PORT: "3001" + API_PREFIX: api + DATABASE_URL: postgresql://${POSTGRES_USER:-tos_user}:${POSTGRES_PASSWORD}@postgres:5432/tos_app + JWT_SECRET: ${JWT_SECRET} + ENCRYPTION_KEY: ${ENCRYPTION_KEY} + KEYCLOAK_URL: http://keycloak:8080 + KEYCLOAK_REALM: ${KEYCLOAK_REALM:-tOS} + KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID:-tos-backend} + KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET:-} + KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD} + REDIS_HOST: redis + REDIS_PORT: "6379" + SETUP_TOKEN: ${SETUP_TOKEN} + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + keycloak: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:3001/api/v1/health/liveness || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + networks: + - tos-network + + # --------------------------------------------------------------------------- + # tOS Web Frontend (Next.js) + # --------------------------------------------------------------------------- + web: + build: + context: .. + dockerfile: apps/web/Dockerfile + container_name: tos-web + restart: unless-stopped + environment: + NEXT_PUBLIC_APP_URL: https://${APP_DOMAIN} + NEXT_PUBLIC_API_URL: https://${APP_DOMAIN}/api/v1 + NEXTAUTH_URL: https://${APP_DOMAIN} + NEXTAUTH_SECRET: ${NEXTAUTH_SECRET} + KEYCLOAK_CLIENT_ID: ${NEXTAUTH_KEYCLOAK_CLIENT_ID:-tos-nextauth} + KEYCLOAK_CLIENT_SECRET: ${NEXTAUTH_KEYCLOAK_CLIENT_SECRET:-} + # Browser-seitige Redirects: oeffentliche URL + KEYCLOAK_ISSUER: https://auth.${APP_DOMAIN}/realms/${KEYCLOAK_REALM:-tOS} + depends_on: + api: + condition: service_healthy + networks: + - tos-network + + # --------------------------------------------------------------------------- + # Caddy Reverse Proxy (optional, nur mit --profile ssl) + # --------------------------------------------------------------------------- + caddy: + profiles: ["ssl"] + image: caddy:2-alpine + container_name: tos-caddy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + - "443:443/udp" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + environment: + APP_DOMAIN: ${APP_DOMAIN} + LETSENCRYPT_EMAIL: ${LETSENCRYPT_EMAIL:-} + depends_on: + - web + - api + - keycloak + networks: + - tos-network + +# ============================================================================= +# Volumes +# ============================================================================= +volumes: + postgres_data: + name: tos-postgres-data + redis_data: + name: tos-redis-data + caddy_data: + name: tos-caddy-data + caddy_config: + name: tos-caddy-config + +# ============================================================================= +# Networks +# ============================================================================= +networks: + tos-network: + name: tos-network + driver: bridge diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index f793fab..19c4922 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -27,6 +27,7 @@ services: - "${POSTGRES_PORT:-5432}:5432" volumes: - postgres_data:/var/lib/postgresql/data + - ./postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tos_user} -d ${POSTGRES_DB:-tos_db}"] interval: 10s diff --git a/docker/postgres/init.sql b/docker/postgres/init.sql new file mode 100644 index 0000000..21b9c8f --- /dev/null +++ b/docker/postgres/init.sql @@ -0,0 +1,16 @@ +-- ============================================================================= +-- tOS PostgreSQL Initialisierung +-- ============================================================================= +-- Diese Datei wird automatisch beim ersten Start des PostgreSQL-Containers +-- ausgefuehrt (via /docker-entrypoint-initdb.d/). +-- +-- Keycloak verwendet die Standard-Datenbank "tos_db" (POSTGRES_DB). +-- Die Applikation (NestJS/Prisma) benoetigt eine separate Datenbank "tos_app", +-- um Datenisolation zwischen IAM und Geschaeftslogik sicherzustellen. +-- ============================================================================= + +-- Erstelle die Applikations-Datenbank (getrennt von Keycloaks tos_db) +CREATE DATABASE tos_app; + +-- Stelle sicher, dass der Standard-User vollen Zugriff auf tos_app hat +GRANT ALL PRIVILEGES ON DATABASE tos_app TO tos_user; diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..e0cbc9f --- /dev/null +++ b/install.sh @@ -0,0 +1,232 @@ +#!/usr/bin/env bash +# ============================================================================= +# tOS Installation Script +# ============================================================================= +# Interaktiver Installationsassistent fuer das tOS Production Deployment. +# +# Ausfuehren mit: chmod +x install.sh && ./install.sh +# +# Das Script: +# 1. Prueft Voraussetzungen (Docker, Docker Compose, openssl) +# 2. Sammelt Konfiguration (Domain, SSL-Modus) +# 3. Generiert kryptographische Secrets +# 4. Startet alle Services +# 5. Wartet auf Bereitschaft und zeigt Setup-URL an +# ============================================================================= +set -euo pipefail + +# ANSI Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +BOLD='\033[1m' +NC='\033[0m' # No Color + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +banner() { + echo -e "${BLUE}${BOLD}" + echo " ████████╗ ██████╗ ███████╗" + echo " ██╔══╝██╔═══██╗██╔════╝" + echo " ██║ ██║ ██║███████╗" + echo " ██║ ██║ ██║╚════██║" + echo " ██║ ╚██████╔╝███████║" + echo " ╚═╝ ╚═════╝ ╚══════╝" + echo -e "${NC}" + echo -e "${BOLD} tOS - Enterprise Web Operating System${NC}" + echo -e " Installationsassistent" + echo "" +} + +check_prerequisites() { + echo -e "${BOLD}[1/4] Voraussetzungen pruefen...${NC}" + + if ! command -v docker &> /dev/null; then + echo -e "${RED}[FEHLER] Docker nicht gefunden. Bitte installiere Docker: https://docs.docker.com/get-docker/${NC}" + exit 1 + fi + echo -e "${GREEN}[OK] Docker gefunden: $(docker --version)${NC}" + + if ! docker compose version &> /dev/null; then + echo -e "${RED}[FEHLER] Docker Compose Plugin nicht gefunden. Bitte aktualisiere Docker.${NC}" + exit 1 + fi + echo -e "${GREEN}[OK] Docker Compose gefunden: $(docker compose version --short)${NC}" + + if ! command -v openssl &> /dev/null; then + echo -e "${RED}[FEHLER] openssl nicht gefunden. Bitte installiere openssl.${NC}" + exit 1 + fi + echo -e "${GREEN}[OK] openssl gefunden${NC}" + echo "" +} + +collect_configuration() { + echo -e "${BOLD}[2/4] Konfiguration${NC}" + + echo -e "Domain fuer tOS (z.B. tos.meinefirma.de):" + read -r -p " Domain: " APP_DOMAIN + APP_DOMAIN="${APP_DOMAIN:-tos.example.com}" + + echo "" + echo -e "SSL-Konfiguration:" + echo " [1] Let's Encrypt (Caddy - empfohlen fuer oeffentliche Domain)" + echo " [2] Externer Reverse Proxy (nginx, Apache, Cloudflare etc.)" + read -r -p " Auswahl [1/2]: " SSL_CHOICE + + SSL_MODE="external" + LETSENCRYPT_EMAIL="" + if [[ "${SSL_CHOICE}" == "1" ]]; then + SSL_MODE="letsencrypt" + read -r -p " E-Mail fuer Let's Encrypt: " LETSENCRYPT_EMAIL + fi + + echo "" +} + +generate_secrets() { + echo -e "${BOLD}[3/4] Secrets generieren...${NC}" + + POSTGRES_PASSWORD=$(openssl rand -hex 32) + JWT_SECRET=$(openssl rand -hex 32) + ENCRYPTION_KEY=$(openssl rand -hex 32) + NEXTAUTH_SECRET=$(openssl rand -hex 32) + KEYCLOAK_ADMIN_PASSWORD=$(openssl rand -base64 24 | tr -d '/+=' | head -c 32) + + # Generate SETUP_TOKEN + if command -v uuidgen &> /dev/null; then + SETUP_TOKEN=$(uuidgen | tr '[:upper:]' '[:lower:]') + else + SETUP_TOKEN=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || openssl rand -hex 16) + fi + + echo -e "${GREEN}[OK] Secrets generiert${NC}" + echo "" + + # docker/.env erstellen + cat > "${SCRIPT_DIR}/docker/.env" << EOF +# ============================================================================= +# tOS Production Configuration - Generated by install.sh on $(date) +# ============================================================================= + +# ---- Domain ----------------------------------------------------------------- +APP_DOMAIN=${APP_DOMAIN} +LETSENCRYPT_EMAIL=${LETSENCRYPT_EMAIL} + +# ---- PostgreSQL ------------------------------------------------------------- +POSTGRES_USER=tos_user +POSTGRES_PASSWORD=${POSTGRES_PASSWORD} +POSTGRES_DB=tos_db +POSTGRES_PORT=5432 + +# ---- Redis ------------------------------------------------------------------ +REDIS_PORT=6379 + +# ---- Keycloak --------------------------------------------------------------- +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN_PASSWORD} +KEYCLOAK_PORT=8080 +KEYCLOAK_REALM=tOS + +# ---- Application Secrets --------------------------------------------------- +JWT_SECRET=${JWT_SECRET} +ENCRYPTION_KEY=${ENCRYPTION_KEY} +NEXTAUTH_SECRET=${NEXTAUTH_SECRET} + +# ---- Keycloak OAuth Clients ------------------------------------------------ +KEYCLOAK_CLIENT_ID=tos-backend +KEYCLOAK_CLIENT_SECRET= +NEXTAUTH_KEYCLOAK_CLIENT_ID=tos-nextauth +NEXTAUTH_KEYCLOAK_CLIENT_SECRET= + +# ---- Setup Token (nach Einrichtung entfernen) ------------------------------- +SETUP_TOKEN=${SETUP_TOKEN} +EOF + + echo -e "${GREEN}[OK] Konfigurationsdatei erstellt: docker/.env${NC}" + echo "" +} + +start_services() { + echo -e "${BOLD}[4/4] Dienste starten...${NC}" + + cd "${SCRIPT_DIR}/docker" + + if [[ "${SSL_MODE}" == "letsencrypt" ]]; then + echo -e "${YELLOW}Starte alle Dienste mit Let's Encrypt (SSL)...${NC}" + docker compose -f docker-compose.prod.yml --profile ssl up -d --build + else + echo -e "${YELLOW}Starte alle Dienste ohne SSL (externer Reverse Proxy)...${NC}" + docker compose -f docker-compose.prod.yml up -d --build + echo -e "${YELLOW}Hinweis: Konfiguriere deinen Reverse Proxy fuer Ports 3000 (Web), 3001 (API) und 8080 (Keycloak).${NC}" + fi + + echo "" + echo -e "${YELLOW}Warte auf API-Bereitschaft (max. 3 Minuten)...${NC}" + API_URL="http://localhost:3001/api/v1/health/liveness" + for i in $(seq 1 36); do + if curl -sf "${API_URL}" > /dev/null 2>&1; then + echo "" + echo -e "${GREEN}[OK] API ist bereit!${NC}" + break + fi + if [[ $i -eq 36 ]]; then + echo "" + echo -e "${RED}[WARNUNG] API hat nicht innerhalb von 3 Minuten geantwortet.${NC}" + echo -e " Pruefe die Logs mit: docker compose -f docker/docker-compose.prod.yml logs api" + echo -e " Die Dienste laufen moeglicherweise noch hoch. Pruefe den Status mit:" + echo -e " docker compose -f docker/docker-compose.prod.yml ps" + break + fi + echo -n "." + sleep 5 + done + echo "" + cd "${SCRIPT_DIR}" +} + +print_completion() { + local SETUP_URL + if [[ "${SSL_MODE}" == "letsencrypt" ]]; then + SETUP_URL="https://${APP_DOMAIN}/setup?token=${SETUP_TOKEN}" + else + SETUP_URL="http://localhost:3000/setup?token=${SETUP_TOKEN}" + fi + + echo "" + echo -e "${GREEN}${BOLD}================================================================${NC}" + echo -e "${GREEN}${BOLD} Infrastruktur erfolgreich gestartet!${NC}" + echo -e "${GREEN}${BOLD}================================================================${NC}" + echo "" + echo -e " Fahre jetzt mit der Einrichtung im Browser fort:" + echo "" + echo -e " ${BOLD}${BLUE}${SETUP_URL}${NC}" + echo "" + echo -e "${YELLOW} WICHTIG: Notiere dir diesen Setup-Token:${NC}" + echo -e " ${BOLD}${SETUP_TOKEN}${NC}" + echo "" + echo -e " Der Token wird nur einmal angezeigt und ist in" + echo -e " docker/.env gespeichert (SETUP_TOKEN=...)." + echo "" + echo -e " Nach der Einrichtung kannst du SETUP_TOKEN aus" + echo -e " docker/.env entfernen." + echo "" + echo -e " Nuetzliche Befehle:" + echo -e " ${BOLD}docker compose -f docker/docker-compose.prod.yml logs -f${NC} # Alle Logs" + echo -e " ${BOLD}docker compose -f docker/docker-compose.prod.yml ps${NC} # Service Status" + echo -e " ${BOLD}docker compose -f docker/docker-compose.prod.yml down${NC} # Stoppen" + echo -e " ${BOLD}docker compose -f docker/docker-compose.prod.yml up -d${NC} # Starten" + echo "" +} + +main() { + banner + check_prerequisites + collect_configuration + generate_secrets + start_services + print_completion +} + +main "$@" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26d193a..7b91cc5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: apps/api: dependencies: + '@nestjs/axios': + specifier: ^4.0.1 + version: 4.0.1(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.4)(rxjs@7.8.2) '@nestjs/common': specifier: ^10.3.0 version: 10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -49,7 +52,7 @@ importers: version: 7.4.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2) '@nestjs/terminus': specifier: ^10.2.0 - version: 10.3.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@prisma/client@5.22.0(prisma@5.22.0))(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 10.3.0(@nestjs/axios@4.0.1(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.4)(rxjs@7.8.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@prisma/client@5.22.0(prisma@5.22.0))(reflect-metadata@0.2.2)(rxjs@7.8.2) '@prisma/client': specifier: ^5.8.0 version: 5.22.0(prisma@5.22.0) @@ -1111,6 +1114,13 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@nestjs/axios@4.0.1': + resolution: {integrity: sha512-68pFJgu+/AZbWkGu65Z3r55bTsCPlgyKaV4BSG8yUAD72q1PPuyVRgUwFv6BxdnibTUHlyxm06FmYWNC+bjN7A==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + axios: ^1.3.1 + rxjs: ^7.0.0 + '@nestjs/cli@10.4.9': resolution: {integrity: sha512-s8qYd97bggqeK7Op3iD49X2MpFtW4LVNLAwXFkfbRxKME6IYT7X0muNTJ2+QfI8hpbNx9isWkrLWIp+g5FOhiA==} engines: {node: '>= 16.14'} @@ -6948,6 +6958,12 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@nestjs/axios@4.0.1(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.4)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + axios: 1.13.4 + rxjs: 7.8.2 + '@nestjs/cli@10.4.9': dependencies: '@angular-devkit/core': 17.3.11(chokidar@3.6.0) @@ -7086,7 +7102,7 @@ snapshots: class-transformer: 0.5.1 class-validator: 0.14.3 - '@nestjs/terminus@10.3.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@prisma/client@5.22.0(prisma@5.22.0))(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/terminus@10.3.0(@nestjs/axios@4.0.1(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.4)(rxjs@7.8.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@prisma/client@5.22.0(prisma@5.22.0))(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -7095,6 +7111,7 @@ snapshots: reflect-metadata: 0.2.2 rxjs: 7.8.2 optionalDependencies: + '@nestjs/axios': 4.0.1(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.4)(rxjs@7.8.2) '@prisma/client': 5.22.0(prisma@5.22.0) '@nestjs/testing@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/platform-express@10.4.22)':