feat: add Docker deployment, web installer, and local test environment

- Multi-stage Dockerfiles for API (NestJS) and Web (Next.js standalone)
- docker-compose.prod.yml: full production stack (postgres, redis, keycloak,
  api, web) with optional Caddy/Let's Encrypt via --profile ssl
- docker-compose.local.yml: identical local test stack, all ports exposed
- docker/postgres/init.sql: auto-creates tos_app DB on first start
- Caddyfile: reverse proxy for app domain + auth subdomain
- install.sh: interactive installer (domain, SSL mode, secret generation)
- NestJS SetupModule: @Public() endpoints for /setup/status, /setup/admin,
  /setup/branding, /setup/complete with setup-token guard
- Web installer: 4-step flow (system check, admin creation, branding, complete)
  at /[locale]/setup/* with public middleware bypass
- i18n: installer namespace added to de.json and en.json
- CORS: x-setup-token header allowed in main.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 21:17:34 +01:00
parent b1238b7bb8
commit 0e8d5aef85
31 changed files with 2158 additions and 4 deletions

66
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,66 @@
# =============================================================================
# tOS Web Frontend - Multi-Stage Docker Build
# =============================================================================
# Nutzt Next.js Standalone-Output fuer minimale Image-Groesse
# Voraussetzung: output: 'standalone' in next.config.mjs
#
# Build: docker build -f apps/web/Dockerfile -t tos-web .
# Run: docker run -p 3000:3000 --env-file .env tos-web
# =============================================================================
# ---------------------------------------------------------------------------
# Stage 1: Base - Node.js mit pnpm
# ---------------------------------------------------------------------------
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
# ---------------------------------------------------------------------------
# Stage 2: Builder - Dependencies installieren und kompilieren
# ---------------------------------------------------------------------------
FROM base AS builder
WORKDIR /app
# Kopiere Workspace-Konfiguration (fuer pnpm Monorepo-Aufloesung)
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
# Kopiere Shared Package (wird vom Frontend als Dependency referenziert)
COPY packages/ ./packages/
# Kopiere Frontend-Quellcode
COPY apps/web/ ./apps/web/
# Installiere alle Dependencies (frozen-lockfile fuer reproduzierbare Builds)
RUN pnpm install --frozen-lockfile
# Baue zuerst das Shared Package (Dependency des Frontends)
RUN pnpm --filter @tos/shared build
# Baue das Frontend (erzeugt .next/standalone dank output: 'standalone')
RUN pnpm --filter @tos/web build
# ---------------------------------------------------------------------------
# Stage 3: Runner - Schlankes Production Image
# ---------------------------------------------------------------------------
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Kopiere Standalone-Output (enthaelt Server + gebundelte Dependencies)
COPY --from=builder /app/apps/web/.next/standalone ./
# Kopiere statische Assets (werden nicht im Standalone-Bundle enthalten)
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
# Kopiere Public-Verzeichnis (Bilder, Fonts, etc.)
COPY --from=builder /app/apps/web/public ./apps/web/public
# Sicherheit: Nicht als root ausfuehren
USER node
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Next.js Standalone-Server starten
CMD ["node", "apps/web/server.js"]

View File

@@ -1073,5 +1073,63 @@
"saveError": "Fehler beim Speichern der Einstellungen",
"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"
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,225 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useLocale } from 'next-intl';
import { motion } from 'framer-motion';
import { Eye, EyeOff, Loader2, UserPlus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { useToast } from '@/hooks/use-toast';
import { cn } from '@/lib/utils';
export default function AdminSetupPage() {
const t = useTranslations('installer.adminSetup');
const router = useRouter();
const locale = useLocale();
const { toast } = useToast();
const [form, setForm] = useState({
firstName: '',
lastName: '',
email: '',
password: '',
passwordConfirm: '',
});
const [showPassword, setShowPassword] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const validate = () => {
const newErrors: Record<string, string> = {};
if (!form.firstName.trim()) newErrors.firstName = 'Required';
if (!form.lastName.trim()) newErrors.lastName = 'Required';
if (!form.email.trim()) newErrors.email = 'Required';
if (form.password.length < 8) newErrors.password = t('passwordTooShort');
if (form.password !== form.passwordConfirm) newErrors.passwordConfirm = t('passwordMismatch');
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
setIsLoading(true);
const token = sessionStorage.getItem('setup-token') ?? '';
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001/api/v1';
try {
const res = await fetch(`${apiUrl}/setup/admin`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-setup-token': token,
},
body: JSON.stringify({
firstName: form.firstName,
lastName: form.lastName,
email: form.email,
password: form.password,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.message ?? `Error ${res.status}`);
}
router.push(`/${locale}/setup/branding`);
} catch (err) {
toast({
title: 'Fehler',
description: err instanceof Error ? err.message : 'Unbekannter Fehler',
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Card>
<CardHeader>
<CardTitle>{t('title')}</CardTitle>
<CardDescription>{t('description')}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="firstName">{t('firstName')}</Label>
<Input
id="firstName"
value={form.firstName}
onChange={(e) => setForm((f) => ({ ...f, firstName: e.target.value }))}
className={cn(errors.firstName && 'border-destructive')}
/>
{errors.firstName && (
<p className="text-xs text-destructive">{errors.firstName}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="lastName">{t('lastName')}</Label>
<Input
id="lastName"
value={form.lastName}
onChange={(e) => setForm((f) => ({ ...f, lastName: e.target.value }))}
className={cn(errors.lastName && 'border-destructive')}
/>
{errors.lastName && (
<p className="text-xs text-destructive">{errors.lastName}</p>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">{t('email')}</Label>
<Input
id="email"
type="email"
value={form.email}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
className={cn(errors.email && 'border-destructive')}
/>
{errors.email && <p className="text-xs text-destructive">{errors.email}</p>}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="password">{t('password')}</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
value={form.password}
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
className={cn('pr-10', errors.password && 'border-destructive')}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3"
onClick={() => setShowPassword((s) => !s)}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
{errors.password && (
<p className="text-xs text-destructive">{errors.password}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="passwordConfirm">{t('passwordConfirm')}</Label>
<div className="relative">
<Input
id="passwordConfirm"
type={showConfirm ? 'text' : 'password'}
value={form.passwordConfirm}
onChange={(e) =>
setForm((f) => ({ ...f, passwordConfirm: e.target.value }))
}
className={cn('pr-10', errors.passwordConfirm && 'border-destructive')}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3"
onClick={() => setShowConfirm((s) => !s)}
>
{showConfirm ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
{errors.passwordConfirm && (
<p className="text-xs text-destructive">{errors.passwordConfirm}</p>
)}
</div>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('creating')}
</>
) : (
<>
<UserPlus className="mr-2 h-4 w-4" />
{t('createAccount')}
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
</motion.div>
);
}

View File

@@ -0,0 +1,144 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useLocale } from 'next-intl';
import { motion } from 'framer-motion';
import { Loader2, Palette, ArrowRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { useToast } from '@/hooks/use-toast';
export default function BrandingPage() {
const t = useTranslations('installer.branding');
const router = useRouter();
const locale = useLocale();
const { toast } = useToast();
const [form, setForm] = useState({ appName: 'tOS', companyName: '', logoUrl: '' });
const [isLoading, setIsLoading] = useState(false);
const handleSave = async () => {
setIsLoading(true);
const token = sessionStorage.getItem('setup-token') ?? '';
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001/api/v1';
try {
const body: Record<string, string> = { appName: form.appName };
if (form.companyName) body.companyName = form.companyName;
if (form.logoUrl) body.logoUrl = form.logoUrl;
const res = await fetch(`${apiUrl}/setup/branding`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-setup-token': token,
},
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Error ${res.status}`);
router.push(`/${locale}/setup/complete`);
} catch (err) {
toast({
title: 'Fehler',
description: err instanceof Error ? err.message : 'Unbekannter Fehler',
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};
const handleSkip = () => router.push(`/${locale}/setup/complete`);
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Card>
<CardHeader>
<CardTitle>{t('title')}</CardTitle>
<CardDescription>{t('description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="appName">{t('appName')}</Label>
<Input
id="appName"
placeholder={t('appNamePlaceholder')}
value={form.appName}
onChange={(e) => setForm((f) => ({ ...f, appName: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="companyName">{t('companyName')}</Label>
<Input
id="companyName"
placeholder={t('companyNamePlaceholder')}
value={form.companyName}
onChange={(e) => setForm((f) => ({ ...f, companyName: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="logoUrl">{t('logoUrl')}</Label>
<Input
id="logoUrl"
type="url"
placeholder={t('logoUrlPlaceholder')}
value={form.logoUrl}
onChange={(e) => setForm((f) => ({ ...f, logoUrl: e.target.value }))}
/>
{form.logoUrl && (
<div className="mt-3 p-3 border rounded-lg bg-muted/30">
<p className="text-xs text-muted-foreground mb-2">{t('logoPreview')}</p>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={form.logoUrl}
alt="Logo Preview"
className="h-12 max-w-48 object-contain"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
)}
</div>
<div className="flex justify-between pt-2">
<Button variant="ghost" onClick={handleSkip}>
{t('skip')}
</Button>
<Button onClick={handleSave} disabled={isLoading || !form.appName.trim()}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('saving')}
</>
) : (
<>
<Palette className="mr-2 h-4 w-4" />
{t('save')}
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</div>
</CardContent>
</Card>
</motion.div>
);
}

View File

@@ -0,0 +1,89 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { useLocale } from 'next-intl';
import { motion } from 'framer-motion';
import { CheckCircle2, LayoutDashboard, LogIn, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
export default function CompletePage() {
const t = useTranslations('installer.complete');
const locale = useLocale();
const [isCompleting, setIsCompleting] = useState(true);
useEffect(() => {
completeSetup();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const completeSetup = async () => {
const token = sessionStorage.getItem('setup-token') ?? '';
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001/api/v1';
try {
await fetch(`${apiUrl}/setup/complete`, {
method: 'POST',
headers: { 'x-setup-token': token },
});
} catch {
// Best-effort completion
} finally {
sessionStorage.removeItem('setup-token');
setIsCompleting(false);
}
};
if (isCompleting) {
return (
<Card>
<CardContent className="py-16 flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-muted-foreground">{t('completing')}</p>
</CardContent>
</Card>
);
}
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4 }}
>
<Card>
<CardContent className="py-12 flex flex-col items-center gap-6 text-center">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', delay: 0.1 }}
>
<CheckCircle2 className="h-20 w-20 text-green-500" />
</motion.div>
<div>
<h2 className="text-2xl font-bold">{t('title')}</h2>
<p className="mt-2 text-muted-foreground">{t('description')}</p>
</div>
<div className="flex gap-3">
<Button asChild variant="outline">
<Link href={`/${locale}/login`}>
<LogIn className="mr-2 h-4 w-4" />
{t('toLogin')}
</Link>
</Button>
<Button asChild>
<Link href={`/${locale}/dashboard`}>
<LayoutDashboard className="mr-2 h-4 w-4" />
{t('toDashboard')}
</Link>
</Button>
</div>
</CardContent>
</Card>
</motion.div>
);
}

View File

@@ -0,0 +1,17 @@
import { getTranslations } from 'next-intl/server';
import { SetupLayoutContent } from './setup-layout-content';
interface SetupLayoutProps {
children: React.ReactNode;
params: { locale: string };
}
export default async function SetupLayout({ children, params }: SetupLayoutProps) {
const t = await getTranslations({ locale: params.locale, namespace: 'installer' });
return (
<SetupLayoutContent locale={params.locale} title={t('title')} notAccessible={t('notAccessible')}>
{children}
</SetupLayoutContent>
);
}

View File

@@ -0,0 +1,176 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useLocale } from 'next-intl';
import { motion } from 'framer-motion';
import { CheckCircle2, XCircle, Loader2, ArrowRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
type ServiceStatus = 'checking' | 'online' | 'offline';
interface StatusRowProps {
label: string;
status: ServiceStatus;
onlineLabel: string;
offlineLabel: string;
checkingLabel: string;
}
function StatusRow({ label, status, onlineLabel, offlineLabel, checkingLabel }: StatusRowProps) {
return (
<div className="flex items-center justify-between py-3 border-b last:border-0">
<span className="font-medium">{label}</span>
<div className="flex items-center gap-2">
{status === 'checking' && (
<>
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">{checkingLabel}</span>
</>
)}
{status === 'online' && (
<>
<CheckCircle2 className="h-4 w-4 text-green-500" />
<span className="text-sm text-green-600 font-medium">{onlineLabel}</span>
</>
)}
{status === 'offline' && (
<>
<XCircle className="h-4 w-4 text-destructive" />
<span className="text-sm text-destructive font-medium">{offlineLabel}</span>
</>
)}
</div>
</div>
);
}
export default function SetupPage() {
const t = useTranslations('installer.systemCheck');
const router = useRouter();
const locale = useLocale();
const searchParams = useSearchParams();
const [apiStatus, setApiStatus] = useState<ServiceStatus>('checking');
const [dbStatus, setDbStatus] = useState<ServiceStatus>('checking');
const [keycloakStatus, setKeycloakStatus] = useState<ServiceStatus>('checking');
const [setupComplete, setSetupComplete] = useState(false);
const [allOnline, setAllOnline] = useState(false);
useEffect(() => {
// Store setup token from URL in sessionStorage
const token = searchParams.get('token');
if (token) {
sessionStorage.setItem('setup-token', token);
}
checkServices();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (apiStatus === 'online' && dbStatus === 'online' && keycloakStatus === 'online') {
setAllOnline(true);
}
}, [apiStatus, dbStatus, keycloakStatus]);
const checkServices = async () => {
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001/api/v1';
// Check API
try {
const res = await fetch(`${apiUrl}/health/liveness`);
if (res.ok) {
setApiStatus('online');
setDbStatus('online'); // If API is up, DB is connected
} else {
setApiStatus('offline');
setDbStatus('offline');
}
} catch {
setApiStatus('offline');
setDbStatus('offline');
}
// Check Setup Status
try {
const res = await fetch(`${apiUrl}/setup/status`);
if (res.ok) {
const data = await res.json();
// Handle response envelope
const status = data.data ?? data;
if (status.completed) {
setSetupComplete(true);
setTimeout(() => router.push(`/${locale}/dashboard`), 3000);
return;
}
setKeycloakStatus('online');
} else {
setKeycloakStatus('offline');
}
} catch {
setKeycloakStatus('offline');
}
};
if (setupComplete) {
return (
<Card>
<CardContent className="py-12 text-center">
<CheckCircle2 className="mx-auto h-12 w-12 text-green-500 mb-4" />
<p className="font-medium">{t('alreadyComplete')}</p>
<p className="text-sm text-muted-foreground mt-1">{t('redirecting')}</p>
</CardContent>
</Card>
);
}
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Card>
<CardHeader>
<CardTitle>{t('title')}</CardTitle>
<CardDescription>{t('description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<StatusRow
label={t('api')}
status={apiStatus}
onlineLabel={t('online')}
offlineLabel={t('offline')}
checkingLabel={t('checking')}
/>
<StatusRow
label={t('database')}
status={dbStatus}
onlineLabel={t('online')}
offlineLabel={t('offline')}
checkingLabel={t('checking')}
/>
<StatusRow
label={t('keycloak')}
status={keycloakStatus}
onlineLabel={t('online')}
offlineLabel={t('offline')}
checkingLabel={t('checking')}
/>
</div>
<div className="flex justify-end pt-4">
<Button onClick={() => router.push(`/${locale}/setup/admin`)} disabled={!allOnline}>
{t('continue')}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
</motion.div>
);
}

View File

@@ -0,0 +1,92 @@
'use client';
import { usePathname } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { cn } from '@/lib/utils';
import { Check } from 'lucide-react';
interface SetupLayoutContentProps {
children: React.ReactNode;
locale: string;
title: string;
notAccessible: string;
}
const steps = ['systemCheck', 'adminSetup', 'branding', 'complete'] as const;
export function SetupLayoutContent({ children, title, notAccessible }: SetupLayoutContentProps) {
const pathname = usePathname();
const t = useTranslations('installer.steps');
// Determine the active step index based on the current URL path
let activeStep = 0;
if (pathname.includes('/complete')) activeStep = 3;
else if (pathname.includes('/branding')) activeStep = 2;
else if (pathname.includes('/admin')) activeStep = 1;
else activeStep = 0;
return (
<div className="min-h-screen bg-background flex flex-col">
{/* Header */}
<header className="border-b bg-card">
<div className="container mx-auto px-4 py-4 flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground font-bold text-sm">
t
</div>
<span className="text-xl font-bold">OS</span>
<span className="text-muted-foreground mx-2">·</span>
<span className="text-muted-foreground">{title}</span>
</div>
</header>
{/* Step Indicator */}
<div className="border-b bg-muted/30">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center gap-2">
{steps.map((step, index) => (
<div key={step} className="flex items-center gap-2">
<div
className={cn(
'flex h-7 w-7 items-center justify-center rounded-full text-xs font-medium transition-colors',
index < activeStep && 'bg-primary text-primary-foreground',
index === activeStep &&
'bg-primary text-primary-foreground ring-2 ring-primary ring-offset-2',
index > activeStep && 'bg-muted text-muted-foreground border'
)}
>
{index < activeStep ? <Check className="h-3.5 w-3.5" /> : index + 1}
</div>
<span
className={cn(
'text-sm hidden sm:block',
index === activeStep ? 'font-medium text-foreground' : 'text-muted-foreground'
)}
>
{t(step)}
</span>
{index < steps.length - 1 && (
<div
className={cn(
'h-px w-8 sm:w-16 mx-1',
index < activeStep ? 'bg-primary' : 'bg-border'
)}
/>
)}
</div>
))}
</div>
</div>
</div>
{/* Content */}
<main className="flex-1 container mx-auto px-4 py-8 max-w-2xl">{children}</main>
{/* Footer */}
<footer className="border-t py-4">
<div className="container mx-auto px-4 text-center text-xs text-muted-foreground">
{notAccessible}
</div>
</footer>
</div>
);
}

View File

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