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:
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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
"@nestjs/common": "^10.3.0",
|
||||
"@nestjs/config": "^3.1.1",
|
||||
"@nestjs/core": "^10.3.0",
|
||||
|
||||
@@ -31,6 +31,9 @@ import { HrModule } from './modules/hr/hr.module';
|
||||
// Phase 6 modules - Integrations
|
||||
import { IntegrationsModule } from './modules/integrations/integrations.module';
|
||||
|
||||
// Setup module - initial system configuration wizard
|
||||
import { SetupModule } from './modules/setup/setup.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// Configuration
|
||||
@@ -75,6 +78,9 @@ import { IntegrationsModule } from './modules/integrations/integrations.module';
|
||||
|
||||
// Phase 6 modules - Integrations
|
||||
IntegrationsModule,
|
||||
|
||||
// Setup wizard - initial system configuration
|
||||
SetupModule,
|
||||
],
|
||||
providers: [
|
||||
// Global JWT Guard - routes are protected by default
|
||||
|
||||
@@ -27,6 +27,12 @@ export const configValidationSchema = Joi.object({
|
||||
otherwise: Joi.optional(),
|
||||
}),
|
||||
|
||||
// Keycloak Admin (for setup wizard)
|
||||
KEYCLOAK_ADMIN_PASSWORD: Joi.string().optional(),
|
||||
|
||||
// Setup token (initial system setup)
|
||||
SETUP_TOKEN: Joi.string().optional(),
|
||||
|
||||
// Redis (optional - for BullMQ in production)
|
||||
REDIS_HOST: Joi.string().optional(),
|
||||
REDIS_PORT: Joi.number().optional(),
|
||||
|
||||
@@ -10,6 +10,9 @@ import { Public } from '../auth/decorators/public.decorator';
|
||||
import { PrismaHealthIndicator } from './prisma-health.indicator';
|
||||
import { ModulesHealthIndicator } from './modules-health.indicator';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { version } = require('../../package.json');
|
||||
|
||||
@ApiTags('health')
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@@ -78,6 +81,7 @@ export class HealthController {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: { type: 'string', example: 'ok' },
|
||||
version: { type: 'string', example: '0.0.1' },
|
||||
timestamp: { type: 'string', example: '2024-01-15T10:30:00.000Z' },
|
||||
},
|
||||
},
|
||||
@@ -85,6 +89,7 @@ export class HealthController {
|
||||
liveness() {
|
||||
return {
|
||||
status: 'ok',
|
||||
version: version as string,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ async function bootstrap() {
|
||||
},
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'Accept'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'Accept', 'x-setup-token'],
|
||||
});
|
||||
|
||||
// API Prefix
|
||||
|
||||
23
apps/api/src/modules/setup/dto/create-admin.dto.ts
Normal file
23
apps/api/src/modules/setup/dto/create-admin.dto.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { IsString, IsNotEmpty, IsEmail, MinLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class CreateAdminDto {
|
||||
@ApiProperty({ example: 'Max' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
firstName: string;
|
||||
|
||||
@ApiProperty({ example: 'Mustermann' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
lastName: string;
|
||||
|
||||
@ApiProperty({ example: 'admin@example.com' })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: 'SecurePass123!', minLength: 8 })
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password: string;
|
||||
}
|
||||
19
apps/api/src/modules/setup/dto/save-branding.dto.ts
Normal file
19
apps/api/src/modules/setup/dto/save-branding.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsUrl } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class SaveBrandingDto {
|
||||
@ApiProperty({ example: 'tOS' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
appName: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Acme Corp' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
companyName?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'https://example.com/logo.png' })
|
||||
@IsUrl()
|
||||
@IsOptional()
|
||||
logoUrl?: string;
|
||||
}
|
||||
67
apps/api/src/modules/setup/setup.controller.ts
Normal file
67
apps/api/src/modules/setup/setup.controller.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Controller, Get, Post, Put, Body, Headers } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiHeader, ApiResponse } from '@nestjs/swagger';
|
||||
import { Public } from '../../auth/decorators/public.decorator';
|
||||
import { SetupService } from './setup.service';
|
||||
import { CreateAdminDto } from './dto/create-admin.dto';
|
||||
import { SaveBrandingDto } from './dto/save-branding.dto';
|
||||
|
||||
@ApiTags('setup')
|
||||
@Public()
|
||||
@Controller('setup')
|
||||
export class SetupController {
|
||||
constructor(private readonly setupService: SetupService) {}
|
||||
|
||||
@Get('status')
|
||||
@ApiOperation({ summary: 'Get current setup status' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Returns whether setup is completed and token is configured',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
completed: { type: 'boolean', example: false },
|
||||
tokenConfigured: { type: 'boolean', example: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
getStatus() {
|
||||
return this.setupService.getStatus();
|
||||
}
|
||||
|
||||
@Post('admin')
|
||||
@ApiOperation({ summary: 'Create initial admin user in Keycloak and local DB' })
|
||||
@ApiHeader({ name: 'x-setup-token', required: true })
|
||||
@ApiResponse({ status: 201, description: 'Admin user created' })
|
||||
@ApiResponse({ status: 401, description: 'Invalid setup token' })
|
||||
@ApiResponse({ status: 403, description: 'Setup already completed' })
|
||||
@ApiResponse({ status: 409, description: 'User already exists in Keycloak' })
|
||||
createAdmin(
|
||||
@Body() dto: CreateAdminDto,
|
||||
@Headers('x-setup-token') token: string,
|
||||
) {
|
||||
return this.setupService.createAdmin(dto, token ?? '');
|
||||
}
|
||||
|
||||
@Put('branding')
|
||||
@ApiOperation({ summary: 'Save branding settings (app name, company, logo)' })
|
||||
@ApiHeader({ name: 'x-setup-token', required: true })
|
||||
@ApiResponse({ status: 200, description: 'Branding settings saved' })
|
||||
@ApiResponse({ status: 401, description: 'Invalid setup token' })
|
||||
@ApiResponse({ status: 403, description: 'Setup already completed' })
|
||||
saveBranding(
|
||||
@Body() dto: SaveBrandingDto,
|
||||
@Headers('x-setup-token') token: string,
|
||||
) {
|
||||
return this.setupService.saveBranding(dto, token ?? '');
|
||||
}
|
||||
|
||||
@Post('complete')
|
||||
@ApiOperation({ summary: 'Mark setup as completed' })
|
||||
@ApiHeader({ name: 'x-setup-token', required: true })
|
||||
@ApiResponse({ status: 201, description: 'Setup marked as completed' })
|
||||
@ApiResponse({ status: 401, description: 'Invalid setup token' })
|
||||
@ApiResponse({ status: 403, description: 'Setup already completed' })
|
||||
completeSetup(@Headers('x-setup-token') token: string) {
|
||||
return this.setupService.completeSetup(token ?? '');
|
||||
}
|
||||
}
|
||||
12
apps/api/src/modules/setup/setup.module.ts
Normal file
12
apps/api/src/modules/setup/setup.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { SetupController } from './setup.controller';
|
||||
import { SetupService } from './setup.service';
|
||||
import { SystemSettingsModule } from '../system-settings/system-settings.module';
|
||||
|
||||
@Module({
|
||||
imports: [SystemSettingsModule, HttpModule],
|
||||
controllers: [SetupController],
|
||||
providers: [SetupService],
|
||||
})
|
||||
export class SetupModule {}
|
||||
206
apps/api/src/modules/setup/setup.service.ts
Normal file
206
apps/api/src/modules/setup/setup.service.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
ForbiddenException,
|
||||
ConflictException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { SystemSettingsService } from '../system-settings/system-settings.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { CreateAdminDto } from './dto/create-admin.dto';
|
||||
import { SaveBrandingDto } from './dto/save-branding.dto';
|
||||
|
||||
@Injectable()
|
||||
export class SetupService {
|
||||
private readonly logger = new Logger(SetupService.name);
|
||||
|
||||
constructor(
|
||||
private readonly systemSettings: SystemSettingsService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly http: HttpService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns the current setup status.
|
||||
*/
|
||||
async getStatus() {
|
||||
const completed = await this.systemSettings.getValue('setup.completed');
|
||||
const tokenConfigured = await this.systemSettings.getValue('setup.token');
|
||||
|
||||
return {
|
||||
completed: completed === 'true',
|
||||
tokenConfigured: !!tokenConfigured,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the initial admin user in Keycloak and the local database.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Validate the setup token
|
||||
* 2. Obtain a Keycloak admin access token via master realm
|
||||
* 3. Create the user in Keycloak with verified email and password
|
||||
* 4. Create/update the user in the local DB with the keycloakId reference
|
||||
* 5. Assign the "admin" role
|
||||
*/
|
||||
async createAdmin(dto: CreateAdminDto, token: string): Promise<void> {
|
||||
await this.validateToken(token);
|
||||
|
||||
const keycloakUrl = this.config.get<string>('KEYCLOAK_URL');
|
||||
const realm = this.config.get<string>('KEYCLOAK_REALM', 'tOS');
|
||||
const adminPassword = this.config.get<string>('KEYCLOAK_ADMIN_PASSWORD');
|
||||
|
||||
// 1. Get admin access token from Keycloak master realm
|
||||
let adminToken: string;
|
||||
try {
|
||||
const tokenRes = await firstValueFrom(
|
||||
this.http.post(
|
||||
`${keycloakUrl}/realms/master/protocol/openid-connect/token`,
|
||||
new URLSearchParams({
|
||||
grant_type: 'password',
|
||||
client_id: 'admin-cli',
|
||||
username: 'admin',
|
||||
password: adminPassword ?? '',
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
|
||||
),
|
||||
);
|
||||
adminToken = tokenRes.data.access_token;
|
||||
} catch (err) {
|
||||
this.logger.error('Failed to get Keycloak admin token', err);
|
||||
throw new Error('Keycloak admin authentication failed');
|
||||
}
|
||||
|
||||
// 2. Create user in Keycloak
|
||||
let keycloakUserId: string;
|
||||
try {
|
||||
const createRes = await firstValueFrom(
|
||||
this.http.post(
|
||||
`${keycloakUrl}/admin/realms/${realm}/users`,
|
||||
{
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName,
|
||||
email: dto.email,
|
||||
username: dto.email,
|
||||
enabled: true,
|
||||
emailVerified: true,
|
||||
credentials: [
|
||||
{ type: 'password', value: dto.password, temporary: false },
|
||||
],
|
||||
},
|
||||
{ headers: { Authorization: `Bearer ${adminToken}` } },
|
||||
),
|
||||
);
|
||||
// Keycloak returns 201 with Location header containing the user ID
|
||||
const location = createRes.headers['location'] as string;
|
||||
keycloakUserId = location.split('/').pop() ?? '';
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 409) {
|
||||
throw new ConflictException(
|
||||
'A user with this email already exists in Keycloak',
|
||||
);
|
||||
}
|
||||
this.logger.error('Failed to create Keycloak user', err);
|
||||
throw new Error('Failed to create user in Keycloak');
|
||||
}
|
||||
|
||||
// 3. Create user in local DB (keycloakId is the unique link to Keycloak)
|
||||
const adminRole = await this.prisma.role.findFirst({
|
||||
where: { name: 'admin' },
|
||||
});
|
||||
|
||||
const user = await this.prisma.user.upsert({
|
||||
where: { keycloakId: keycloakUserId },
|
||||
create: {
|
||||
email: dto.email,
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName,
|
||||
keycloakId: keycloakUserId,
|
||||
isActive: true,
|
||||
},
|
||||
update: {
|
||||
email: dto.email,
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName,
|
||||
},
|
||||
});
|
||||
|
||||
// 4. Assign admin role
|
||||
if (adminRole) {
|
||||
await this.prisma.userRole.upsert({
|
||||
where: {
|
||||
userId_roleId: { userId: user.id, roleId: adminRole.id },
|
||||
},
|
||||
create: { userId: user.id, roleId: adminRole.id },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Admin user created: ${dto.email} (keycloakId: ${keycloakUserId})`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves branding settings (app name, company name, logo URL).
|
||||
*/
|
||||
async saveBranding(dto: SaveBrandingDto, token: string): Promise<void> {
|
||||
await this.validateToken(token);
|
||||
|
||||
await this.systemSettings.upsert('branding.appName', {
|
||||
value: dto.appName,
|
||||
category: 'branding',
|
||||
});
|
||||
|
||||
if (dto.companyName !== undefined) {
|
||||
await this.systemSettings.upsert('branding.companyName', {
|
||||
value: dto.companyName,
|
||||
category: 'branding',
|
||||
});
|
||||
}
|
||||
|
||||
if (dto.logoUrl !== undefined) {
|
||||
await this.systemSettings.upsert('branding.logoUrl', {
|
||||
value: dto.logoUrl,
|
||||
category: 'branding',
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.log(`Branding settings saved: appName=${dto.appName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the setup as completed. After this, all setup endpoints
|
||||
* will reject further modifications.
|
||||
*/
|
||||
async completeSetup(token: string): Promise<void> {
|
||||
await this.validateToken(token);
|
||||
|
||||
await this.systemSettings.upsert('setup.completed', {
|
||||
value: 'true',
|
||||
category: 'system',
|
||||
valueType: 'boolean',
|
||||
});
|
||||
|
||||
this.logger.log('Setup completed successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the setup token and checks that setup has not been completed yet.
|
||||
*/
|
||||
private async validateToken(token: string): Promise<void> {
|
||||
const completed = await this.systemSettings.getValue('setup.completed');
|
||||
if (completed === 'true') {
|
||||
throw new ForbiddenException('Setup is already completed');
|
||||
}
|
||||
|
||||
const storedToken = await this.systemSettings.getValue('setup.token');
|
||||
if (!storedToken || storedToken !== token) {
|
||||
throw new UnauthorizedException('Invalid setup token');
|
||||
}
|
||||
}
|
||||
}
|
||||
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",
|
||||
"requiresRestart": "Aenderung erfordert einen Neustart des Backends",
|
||||
"save": "Speichern"
|
||||
},
|
||||
"installer": {
|
||||
"title": "tOS Einrichtung",
|
||||
"setupComplete": "Einrichtung abgeschlossen",
|
||||
"notAccessible": "Nach der Einrichtung ist dieser Bereich nicht mehr zugaenglich.",
|
||||
"steps": {
|
||||
"systemCheck": "Systempruefung",
|
||||
"adminSetup": "Admin-Account",
|
||||
"branding": "Branding",
|
||||
"complete": "Abschluss"
|
||||
},
|
||||
"systemCheck": {
|
||||
"title": "Systemueberpruefung",
|
||||
"description": "Alle Dienste werden auf Erreichbarkeit geprueft.",
|
||||
"api": "API-Server",
|
||||
"database": "Datenbank",
|
||||
"keycloak": "Authentifizierung",
|
||||
"online": "Online",
|
||||
"offline": "Nicht erreichbar",
|
||||
"checking": "Wird geprueft...",
|
||||
"continue": "Weiter zur Einrichtung",
|
||||
"alreadyComplete": "Die Einrichtung wurde bereits abgeschlossen.",
|
||||
"redirecting": "Du wirst zum Dashboard weitergeleitet..."
|
||||
},
|
||||
"adminSetup": {
|
||||
"title": "Admin-Account erstellen",
|
||||
"description": "Erstelle den ersten Administrator-Account fuer tOS.",
|
||||
"firstName": "Vorname",
|
||||
"lastName": "Nachname",
|
||||
"email": "E-Mail-Adresse",
|
||||
"password": "Passwort",
|
||||
"passwordConfirm": "Passwort bestaetigen",
|
||||
"passwordMismatch": "Passwoerter stimmen nicht ueberein",
|
||||
"passwordTooShort": "Mindestens 8 Zeichen erforderlich",
|
||||
"createAccount": "Account erstellen",
|
||||
"creating": "Wird erstellt..."
|
||||
},
|
||||
"branding": {
|
||||
"title": "Branding konfigurieren",
|
||||
"description": "Passe tOS an dein Unternehmen an.",
|
||||
"appName": "App-Name",
|
||||
"appNamePlaceholder": "tOS",
|
||||
"companyName": "Firmenname",
|
||||
"companyNamePlaceholder": "Mein Unternehmen GmbH",
|
||||
"logoUrl": "Logo-URL",
|
||||
"logoUrlPlaceholder": "https://example.com/logo.png",
|
||||
"logoPreview": "Logo-Vorschau",
|
||||
"save": "Speichern & Weiter",
|
||||
"saving": "Wird gespeichert...",
|
||||
"skip": "Ueberspringen"
|
||||
},
|
||||
"complete": {
|
||||
"title": "Einrichtung abgeschlossen!",
|
||||
"description": "tOS wurde erfolgreich eingerichtet und ist bereit zur Nutzung.",
|
||||
"completing": "Einrichtung wird abgeschlossen...",
|
||||
"toDashboard": "Zum Dashboard",
|
||||
"toLogin": "Zum Login"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1073,5 +1073,63 @@
|
||||
"saveError": "Failed to save settings",
|
||||
"requiresRestart": "Change requires a backend restart",
|
||||
"save": "Save"
|
||||
},
|
||||
"installer": {
|
||||
"title": "tOS Setup",
|
||||
"setupComplete": "Setup Complete",
|
||||
"notAccessible": "After setup, this area will no longer be accessible.",
|
||||
"steps": {
|
||||
"systemCheck": "System Check",
|
||||
"adminSetup": "Admin Account",
|
||||
"branding": "Branding",
|
||||
"complete": "Complete"
|
||||
},
|
||||
"systemCheck": {
|
||||
"title": "System Check",
|
||||
"description": "Checking all services for availability.",
|
||||
"api": "API Server",
|
||||
"database": "Database",
|
||||
"keycloak": "Authentication",
|
||||
"online": "Online",
|
||||
"offline": "Unreachable",
|
||||
"checking": "Checking...",
|
||||
"continue": "Continue Setup",
|
||||
"alreadyComplete": "Setup has already been completed.",
|
||||
"redirecting": "Redirecting to dashboard..."
|
||||
},
|
||||
"adminSetup": {
|
||||
"title": "Create Admin Account",
|
||||
"description": "Create the first administrator account for tOS.",
|
||||
"firstName": "First Name",
|
||||
"lastName": "Last Name",
|
||||
"email": "Email Address",
|
||||
"password": "Password",
|
||||
"passwordConfirm": "Confirm Password",
|
||||
"passwordMismatch": "Passwords do not match",
|
||||
"passwordTooShort": "Minimum 8 characters required",
|
||||
"createAccount": "Create Account",
|
||||
"creating": "Creating..."
|
||||
},
|
||||
"branding": {
|
||||
"title": "Configure Branding",
|
||||
"description": "Customize tOS for your company.",
|
||||
"appName": "App Name",
|
||||
"appNamePlaceholder": "tOS",
|
||||
"companyName": "Company Name",
|
||||
"companyNamePlaceholder": "My Company Inc.",
|
||||
"logoUrl": "Logo URL",
|
||||
"logoUrlPlaceholder": "https://example.com/logo.png",
|
||||
"logoPreview": "Logo Preview",
|
||||
"save": "Save & Continue",
|
||||
"saving": "Saving...",
|
||||
"skip": "Skip"
|
||||
},
|
||||
"complete": {
|
||||
"title": "Setup Complete!",
|
||||
"description": "tOS has been successfully set up and is ready to use.",
|
||||
"completing": "Completing setup...",
|
||||
"toDashboard": "Go to Dashboard",
|
||||
"toLogin": "Go to Login"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ const nextConfig = {
|
||||
// Enable React strict mode for better development experience
|
||||
reactStrictMode: true,
|
||||
|
||||
// Standalone output for Docker deployment
|
||||
// Erzeugt ein eigenstaendiges Build-Artefakt mit allen Abhaengigkeiten
|
||||
output: 'standalone',
|
||||
|
||||
// Configure image optimization
|
||||
images: {
|
||||
remotePatterns: [
|
||||
|
||||
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';
|
||||
|
||||
// Public paths that don't require authentication
|
||||
const publicPages = ['/login'];
|
||||
const publicPages = ['/login', '/setup'];
|
||||
|
||||
// Create the next-intl middleware
|
||||
const intlMiddleware = createMiddleware({
|
||||
|
||||
Reference in New Issue
Block a user