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:
53
.dockerignore
Normal file
53
.dockerignore
Normal 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
67
apps/api/Dockerfile
Normal 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"]
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
apps/web/Dockerfile
Normal file
66
apps/web/Dockerfile
Normal 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"]
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
225
apps/web/src/app/[locale]/(setup)/admin/page.tsx
Normal file
225
apps/web/src/app/[locale]/(setup)/admin/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
apps/web/src/app/[locale]/(setup)/branding/page.tsx
Normal file
144
apps/web/src/app/[locale]/(setup)/branding/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
apps/web/src/app/[locale]/(setup)/complete/page.tsx
Normal file
89
apps/web/src/app/[locale]/(setup)/complete/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
apps/web/src/app/[locale]/(setup)/layout.tsx
Normal file
17
apps/web/src/app/[locale]/(setup)/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
176
apps/web/src/app/[locale]/(setup)/page.tsx
Normal file
176
apps/web/src/app/[locale]/(setup)/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
apps/web/src/app/[locale]/(setup)/setup-layout-content.tsx
Normal file
92
apps/web/src/app/[locale]/(setup)/setup-layout-content.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
46
docker/.env.prod.example
Normal 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
30
docker/Caddyfile
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
200
docker/docker-compose.local.yml
Normal file
200
docker/docker-compose.local.yml
Normal 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
|
||||||
218
docker/docker-compose.prod.yml
Normal file
218
docker/docker-compose.prod.yml
Normal 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
|
||||||
@@ -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
16
docker/postgres/init.sql
Normal 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
232
install.sh
Executable 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
21
pnpm-lock.yaml
generated
@@ -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)':
|
||||||
|
|||||||
Reference in New Issue
Block a user