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 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 21:17:34 +01:00
parent b1238b7bb8
commit 0e8d5aef85
31 changed files with 2158 additions and 4 deletions

53
.dockerignore Normal file
View File

@@ -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

67
apps/api/Dockerfile Normal file
View File

@@ -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"]

View File

@@ -26,6 +26,7 @@
"db:seed": "prisma db seed" "db:seed": "prisma db seed"
}, },
"dependencies": { "dependencies": {
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^10.3.0", "@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1", "@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.3.0", "@nestjs/core": "^10.3.0",

View File

@@ -31,6 +31,9 @@ import { HrModule } from './modules/hr/hr.module';
// Phase 6 modules - Integrations // Phase 6 modules - Integrations
import { IntegrationsModule } from './modules/integrations/integrations.module'; import { IntegrationsModule } from './modules/integrations/integrations.module';
// Setup module - initial system configuration wizard
import { SetupModule } from './modules/setup/setup.module';
@Module({ @Module({
imports: [ imports: [
// Configuration // Configuration
@@ -75,6 +78,9 @@ import { IntegrationsModule } from './modules/integrations/integrations.module';
// Phase 6 modules - Integrations // Phase 6 modules - Integrations
IntegrationsModule, IntegrationsModule,
// Setup wizard - initial system configuration
SetupModule,
], ],
providers: [ providers: [
// Global JWT Guard - routes are protected by default // Global JWT Guard - routes are protected by default

View File

@@ -27,6 +27,12 @@ export const configValidationSchema = Joi.object({
otherwise: Joi.optional(), 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 (optional - for BullMQ in production)
REDIS_HOST: Joi.string().optional(), REDIS_HOST: Joi.string().optional(),
REDIS_PORT: Joi.number().optional(), REDIS_PORT: Joi.number().optional(),

View File

@@ -10,6 +10,9 @@ import { Public } from '../auth/decorators/public.decorator';
import { PrismaHealthIndicator } from './prisma-health.indicator'; import { PrismaHealthIndicator } from './prisma-health.indicator';
import { ModulesHealthIndicator } from './modules-health.indicator'; import { ModulesHealthIndicator } from './modules-health.indicator';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { version } = require('../../package.json');
@ApiTags('health') @ApiTags('health')
@Controller('health') @Controller('health')
export class HealthController { export class HealthController {
@@ -78,6 +81,7 @@ export class HealthController {
type: 'object', type: 'object',
properties: { properties: {
status: { type: 'string', example: 'ok' }, status: { type: 'string', example: 'ok' },
version: { type: 'string', example: '0.0.1' },
timestamp: { type: 'string', example: '2024-01-15T10:30:00.000Z' }, timestamp: { type: 'string', example: '2024-01-15T10:30:00.000Z' },
}, },
}, },
@@ -85,6 +89,7 @@ export class HealthController {
liveness() { liveness() {
return { return {
status: 'ok', status: 'ok',
version: version as string,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
} }

View File

@@ -32,7 +32,7 @@ async function bootstrap() {
}, },
credentials: true, credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'Accept'], allowedHeaders: ['Content-Type', 'Authorization', 'Accept', 'x-setup-token'],
}); });
// API Prefix // API Prefix

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 ?? '');
}
}

View File

@@ -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 {}

View File

@@ -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<void> {
await this.validateToken(token);
const keycloakUrl = this.config.get<string>('KEYCLOAK_URL');
const realm = this.config.get<string>('KEYCLOAK_REALM', 'tOS');
const adminPassword = this.config.get<string>('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<void> {
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<void> {
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<void> {
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');
}
}
}

66
apps/web/Dockerfile Normal file
View File

@@ -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"]

View File

@@ -1073,5 +1073,63 @@
"saveError": "Fehler beim Speichern der Einstellungen", "saveError": "Fehler beim Speichern der Einstellungen",
"requiresRestart": "Aenderung erfordert einen Neustart des Backends", "requiresRestart": "Aenderung erfordert einen Neustart des Backends",
"save": "Speichern" "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"
}
} }
} }

View File

@@ -1073,5 +1073,63 @@
"saveError": "Failed to save settings", "saveError": "Failed to save settings",
"requiresRestart": "Change requires a backend restart", "requiresRestart": "Change requires a backend restart",
"save": "Save" "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"
}
} }
} }

View File

@@ -7,6 +7,10 @@ const nextConfig = {
// Enable React strict mode for better development experience // Enable React strict mode for better development experience
reactStrictMode: true, reactStrictMode: true,
// Standalone output for Docker deployment
// Erzeugt ein eigenstaendiges Build-Artefakt mit allen Abhaengigkeiten
output: 'standalone',
// Configure image optimization // Configure image optimization
images: { images: {
remotePatterns: [ remotePatterns: [

View File

@@ -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<Record<string, string>>({});
const validate = () => {
const newErrors: Record<string, string> = {};
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 (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Card>
<CardHeader>
<CardTitle>{t('title')}</CardTitle>
<CardDescription>{t('description')}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="firstName">{t('firstName')}</Label>
<Input
id="firstName"
value={form.firstName}
onChange={(e) => setForm((f) => ({ ...f, firstName: e.target.value }))}
className={cn(errors.firstName && 'border-destructive')}
/>
{errors.firstName && (
<p className="text-xs text-destructive">{errors.firstName}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="lastName">{t('lastName')}</Label>
<Input
id="lastName"
value={form.lastName}
onChange={(e) => setForm((f) => ({ ...f, lastName: e.target.value }))}
className={cn(errors.lastName && 'border-destructive')}
/>
{errors.lastName && (
<p className="text-xs text-destructive">{errors.lastName}</p>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">{t('email')}</Label>
<Input
id="email"
type="email"
value={form.email}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
className={cn(errors.email && 'border-destructive')}
/>
{errors.email && <p className="text-xs text-destructive">{errors.email}</p>}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="password">{t('password')}</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
value={form.password}
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
className={cn('pr-10', errors.password && 'border-destructive')}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3"
onClick={() => setShowPassword((s) => !s)}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
{errors.password && (
<p className="text-xs text-destructive">{errors.password}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="passwordConfirm">{t('passwordConfirm')}</Label>
<div className="relative">
<Input
id="passwordConfirm"
type={showConfirm ? 'text' : 'password'}
value={form.passwordConfirm}
onChange={(e) =>
setForm((f) => ({ ...f, passwordConfirm: e.target.value }))
}
className={cn('pr-10', errors.passwordConfirm && 'border-destructive')}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3"
onClick={() => setShowConfirm((s) => !s)}
>
{showConfirm ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
{errors.passwordConfirm && (
<p className="text-xs text-destructive">{errors.passwordConfirm}</p>
)}
</div>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('creating')}
</>
) : (
<>
<UserPlus className="mr-2 h-4 w-4" />
{t('createAccount')}
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
</motion.div>
);
}

View File

@@ -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<string, string> = { 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 (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Card>
<CardHeader>
<CardTitle>{t('title')}</CardTitle>
<CardDescription>{t('description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="appName">{t('appName')}</Label>
<Input
id="appName"
placeholder={t('appNamePlaceholder')}
value={form.appName}
onChange={(e) => setForm((f) => ({ ...f, appName: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="companyName">{t('companyName')}</Label>
<Input
id="companyName"
placeholder={t('companyNamePlaceholder')}
value={form.companyName}
onChange={(e) => setForm((f) => ({ ...f, companyName: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="logoUrl">{t('logoUrl')}</Label>
<Input
id="logoUrl"
type="url"
placeholder={t('logoUrlPlaceholder')}
value={form.logoUrl}
onChange={(e) => setForm((f) => ({ ...f, logoUrl: e.target.value }))}
/>
{form.logoUrl && (
<div className="mt-3 p-3 border rounded-lg bg-muted/30">
<p className="text-xs text-muted-foreground mb-2">{t('logoPreview')}</p>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={form.logoUrl}
alt="Logo Preview"
className="h-12 max-w-48 object-contain"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
)}
</div>
<div className="flex justify-between pt-2">
<Button variant="ghost" onClick={handleSkip}>
{t('skip')}
</Button>
<Button onClick={handleSave} disabled={isLoading || !form.appName.trim()}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('saving')}
</>
) : (
<>
<Palette className="mr-2 h-4 w-4" />
{t('save')}
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</div>
</CardContent>
</Card>
</motion.div>
);
}

View File

@@ -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 (
<Card>
<CardContent className="py-16 flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-muted-foreground">{t('completing')}</p>
</CardContent>
</Card>
);
}
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4 }}
>
<Card>
<CardContent className="py-12 flex flex-col items-center gap-6 text-center">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', delay: 0.1 }}
>
<CheckCircle2 className="h-20 w-20 text-green-500" />
</motion.div>
<div>
<h2 className="text-2xl font-bold">{t('title')}</h2>
<p className="mt-2 text-muted-foreground">{t('description')}</p>
</div>
<div className="flex gap-3">
<Button asChild variant="outline">
<Link href={`/${locale}/login`}>
<LogIn className="mr-2 h-4 w-4" />
{t('toLogin')}
</Link>
</Button>
<Button asChild>
<Link href={`/${locale}/dashboard`}>
<LayoutDashboard className="mr-2 h-4 w-4" />
{t('toDashboard')}
</Link>
</Button>
</div>
</CardContent>
</Card>
</motion.div>
);
}

View File

@@ -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 (
<SetupLayoutContent locale={params.locale} title={t('title')} notAccessible={t('notAccessible')}>
{children}
</SetupLayoutContent>
);
}

View File

@@ -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 (
<div className="flex items-center justify-between py-3 border-b last:border-0">
<span className="font-medium">{label}</span>
<div className="flex items-center gap-2">
{status === 'checking' && (
<>
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">{checkingLabel}</span>
</>
)}
{status === 'online' && (
<>
<CheckCircle2 className="h-4 w-4 text-green-500" />
<span className="text-sm text-green-600 font-medium">{onlineLabel}</span>
</>
)}
{status === 'offline' && (
<>
<XCircle className="h-4 w-4 text-destructive" />
<span className="text-sm text-destructive font-medium">{offlineLabel}</span>
</>
)}
</div>
</div>
);
}
export default function SetupPage() {
const t = useTranslations('installer.systemCheck');
const router = useRouter();
const locale = useLocale();
const searchParams = useSearchParams();
const [apiStatus, setApiStatus] = useState<ServiceStatus>('checking');
const [dbStatus, setDbStatus] = useState<ServiceStatus>('checking');
const [keycloakStatus, setKeycloakStatus] = useState<ServiceStatus>('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 (
<Card>
<CardContent className="py-12 text-center">
<CheckCircle2 className="mx-auto h-12 w-12 text-green-500 mb-4" />
<p className="font-medium">{t('alreadyComplete')}</p>
<p className="text-sm text-muted-foreground mt-1">{t('redirecting')}</p>
</CardContent>
</Card>
);
}
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Card>
<CardHeader>
<CardTitle>{t('title')}</CardTitle>
<CardDescription>{t('description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<StatusRow
label={t('api')}
status={apiStatus}
onlineLabel={t('online')}
offlineLabel={t('offline')}
checkingLabel={t('checking')}
/>
<StatusRow
label={t('database')}
status={dbStatus}
onlineLabel={t('online')}
offlineLabel={t('offline')}
checkingLabel={t('checking')}
/>
<StatusRow
label={t('keycloak')}
status={keycloakStatus}
onlineLabel={t('online')}
offlineLabel={t('offline')}
checkingLabel={t('checking')}
/>
</div>
<div className="flex justify-end pt-4">
<Button onClick={() => router.push(`/${locale}/setup/admin`)} disabled={!allOnline}>
{t('continue')}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
</motion.div>
);
}

View File

@@ -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 (
<div className="min-h-screen bg-background flex flex-col">
{/* Header */}
<header className="border-b bg-card">
<div className="container mx-auto px-4 py-4 flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground font-bold text-sm">
t
</div>
<span className="text-xl font-bold">OS</span>
<span className="text-muted-foreground mx-2">·</span>
<span className="text-muted-foreground">{title}</span>
</div>
</header>
{/* Step Indicator */}
<div className="border-b bg-muted/30">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center gap-2">
{steps.map((step, index) => (
<div key={step} className="flex items-center gap-2">
<div
className={cn(
'flex h-7 w-7 items-center justify-center rounded-full text-xs font-medium transition-colors',
index < activeStep && 'bg-primary text-primary-foreground',
index === activeStep &&
'bg-primary text-primary-foreground ring-2 ring-primary ring-offset-2',
index > activeStep && 'bg-muted text-muted-foreground border'
)}
>
{index < activeStep ? <Check className="h-3.5 w-3.5" /> : index + 1}
</div>
<span
className={cn(
'text-sm hidden sm:block',
index === activeStep ? 'font-medium text-foreground' : 'text-muted-foreground'
)}
>
{t(step)}
</span>
{index < steps.length - 1 && (
<div
className={cn(
'h-px w-8 sm:w-16 mx-1',
index < activeStep ? 'bg-primary' : 'bg-border'
)}
/>
)}
</div>
))}
</div>
</div>
</div>
{/* Content */}
<main className="flex-1 container mx-auto px-4 py-8 max-w-2xl">{children}</main>
{/* Footer */}
<footer className="border-t py-4">
<div className="container mx-auto px-4 text-center text-xs text-muted-foreground">
{notAccessible}
</div>
</footer>
</div>
);
}

View File

@@ -4,7 +4,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { locales, defaultLocale } from './i18n'; import { locales, defaultLocale } from './i18n';
// Public paths that don't require authentication // Public paths that don't require authentication
const publicPages = ['/login']; const publicPages = ['/login', '/setup'];
// Create the next-intl middleware // Create the next-intl middleware
const intlMiddleware = createMiddleware({ const intlMiddleware = createMiddleware({

46
docker/.env.prod.example Normal file
View File

@@ -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.<APP_DOMAIN>/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

30
docker/Caddyfile Normal file
View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -27,6 +27,7 @@ services:
- "${POSTGRES_PORT:-5432}:5432" - "${POSTGRES_PORT:-5432}:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
- ./postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tos_user} -d ${POSTGRES_DB:-tos_db}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tos_user} -d ${POSTGRES_DB:-tos_db}"]
interval: 10s interval: 10s

16
docker/postgres/init.sql Normal file
View File

@@ -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;

232
install.sh Executable file
View File

@@ -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 "$@"

21
pnpm-lock.yaml generated
View File

@@ -23,6 +23,9 @@ importers:
apps/api: apps/api:
dependencies: 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': '@nestjs/common':
specifier: ^10.3.0 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) 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) 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': '@nestjs/terminus':
specifier: ^10.2.0 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': '@prisma/client':
specifier: ^5.8.0 specifier: ^5.8.0
version: 5.22.0(prisma@5.22.0) version: 5.22.0(prisma@5.22.0)
@@ -1111,6 +1114,13 @@ packages:
'@napi-rs/wasm-runtime@0.2.12': '@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} 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': '@nestjs/cli@10.4.9':
resolution: {integrity: sha512-s8qYd97bggqeK7Op3iD49X2MpFtW4LVNLAwXFkfbRxKME6IYT7X0muNTJ2+QfI8hpbNx9isWkrLWIp+g5FOhiA==} resolution: {integrity: sha512-s8qYd97bggqeK7Op3iD49X2MpFtW4LVNLAwXFkfbRxKME6IYT7X0muNTJ2+QfI8hpbNx9isWkrLWIp+g5FOhiA==}
engines: {node: '>= 16.14'} engines: {node: '>= 16.14'}
@@ -6948,6 +6958,12 @@ snapshots:
'@tybys/wasm-util': 0.10.1 '@tybys/wasm-util': 0.10.1
optional: true 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': '@nestjs/cli@10.4.9':
dependencies: dependencies:
'@angular-devkit/core': 17.3.11(chokidar@3.6.0) '@angular-devkit/core': 17.3.11(chokidar@3.6.0)
@@ -7086,7 +7102,7 @@ snapshots:
class-transformer: 0.5.1 class-transformer: 0.5.1
class-validator: 0.14.3 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: 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/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) '@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 reflect-metadata: 0.2.2
rxjs: 7.8.2 rxjs: 7.8.2
optionalDependencies: 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) '@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)': '@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)':