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:
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
23
apps/api/src/modules/setup/dto/create-admin.dto.ts
Normal file
23
apps/api/src/modules/setup/dto/create-admin.dto.ts
Normal 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;
|
||||
}
|
||||
19
apps/api/src/modules/setup/dto/save-branding.dto.ts
Normal file
19
apps/api/src/modules/setup/dto/save-branding.dto.ts
Normal 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;
|
||||
}
|
||||
67
apps/api/src/modules/setup/setup.controller.ts
Normal file
67
apps/api/src/modules/setup/setup.controller.ts
Normal 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 ?? '');
|
||||
}
|
||||
}
|
||||
12
apps/api/src/modules/setup/setup.module.ts
Normal file
12
apps/api/src/modules/setup/setup.module.ts
Normal 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 {}
|
||||
206
apps/api/src/modules/setup/setup.service.ts
Normal file
206
apps/api/src/modules/setup/setup.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user