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

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"
},
"dependencies": {
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.3.0",

View File

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

View File

@@ -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(),

View File

@@ -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(),
};
}

View File

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

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