Feature: Wizard Schritt 5 — Domain-Setup mit Fortschrittsanzeige

- Neuer Schritt 5: SSL-Registrierung läuft im Hintergrund pro Domain
- Artisan-Command mailwolt:wizard-domains schreibt per-Domain Status-Dateien
- Wizard pollt alle 2s: pending → running → done/nodns/error
- "Zum Login" Button erscheint wenn alle Domains abgeschlossen
- Mail-Domain erhält ebenfalls SSL-Zertifikat (für STARTTLS/IMAPS)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main v1.1.147
boban 2026-04-23 02:29:52 +02:00
parent 35fe7c2c6f
commit 077a029ff4
3 changed files with 204 additions and 23 deletions

View File

@ -0,0 +1,86 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class WizardDomains extends Command
{
protected $signature = 'mailwolt:wizard-domains
{--ui= : UI-Domain}
{--mail= : Mail-Domain}
{--webmail= : Webmail-Domain}
{--ssl=1 : SSL automatisch (1/0)}';
protected $description = 'Wizard: Domains einrichten mit Status-Dateien';
private const STATE_DIR = '/var/lib/mailwolt/wizard';
public function handle(): int
{
$ui = $this->option('ui');
$mail = $this->option('mail');
$webmail = $this->option('webmail');
$ssl = (bool)(int)$this->option('ssl');
@mkdir(self::STATE_DIR, 0755, true);
// Start: alle auf pending
foreach (['ui', 'mail', 'webmail'] as $key) {
file_put_contents(self::STATE_DIR . "/{$key}", 'pending');
}
$domains = ['ui' => $ui, 'mail' => $mail, 'webmail' => $webmail];
$allOk = true;
foreach ($domains as $key => $domain) {
if (!$domain) {
file_put_contents(self::STATE_DIR . "/{$key}", 'skip');
continue;
}
file_put_contents(self::STATE_DIR . "/{$key}", 'running');
// DNS prüfen
$hasDns = checkdnsrr($domain, 'A') || checkdnsrr($domain, 'AAAA');
if (!$hasDns) {
file_put_contents(self::STATE_DIR . "/{$key}", 'nodns');
$allOk = false;
continue;
}
// SSL-Zertifikat anfordern
if ($ssl) {
$out = shell_exec(sprintf(
'sudo -n certbot certonly --nginx --non-interactive --agree-tos -m root@%s -d %s 2>&1',
escapeshellarg($domain),
escapeshellarg($domain)
));
$certOk = str_contains((string) $out, 'Successfully') || str_contains((string) $out, 'Certificate not yet due for renewal');
if (!$certOk) {
file_put_contents(self::STATE_DIR . "/{$key}", 'error');
$allOk = false;
continue;
}
}
file_put_contents(self::STATE_DIR . "/{$key}", 'done');
}
// Nginx neu konfigurieren (alle Domains auf einmal)
if ($allOk) {
$helper = '/usr/local/sbin/mailwolt-apply-domains';
shell_exec(sprintf(
'sudo -n %s --ui-host %s --webmail-host %s --mail-host %s --ssl-auto %d 2>&1',
escapeshellarg($helper),
escapeshellarg($ui),
escapeshellarg($webmail),
escapeshellarg($mail),
$ssl ? 1 : 0,
));
}
file_put_contents(self::STATE_DIR . '/done', $allOk ? '1' : '0');
return self::SUCCESS;
}
}

View File

@ -14,7 +14,7 @@ use Livewire\Component;
class Wizard extends Component
{
public int $step = 1;
public int $totalSteps = 4;
public int $totalSteps = 5;
// Schritt 1 — System
public string $instance_name = 'Mailwolt';
@ -32,6 +32,16 @@ class Wizard extends Component
public string $admin_password = '';
public string $admin_password_confirmation = '';
// Schritt 5 — Domain-Setup Status
public array $domainStatus = [
'ui' => 'pending',
'mail' => 'pending',
'webmail' => 'pending',
];
public bool $setupDone = false;
private const STATE_DIR = '/var/lib/mailwolt/wizard';
public function mount(): void
{
$this->instance_name = config('app.name', 'Mailwolt');
@ -82,16 +92,15 @@ class Wizard extends Component
$this->step = max($this->step - 1, 1);
}
public function finish(): mixed
public function finish(): void
{
// Schritt-3-Validierung nochmals sicherstellen
$this->validate([
'admin_name' => 'required|string|min:2|max:64',
'admin_email' => 'required|email|max:190',
'admin_password' => 'required|string|min:10|same:admin_password_confirmation',
]);
// Settings speichern
// Settings + .env speichern
Setting::setMany([
'locale' => $this->locale,
'timezone' => $this->timezone,
@ -101,7 +110,6 @@ class Wizard extends Component
'setup_completed' => '1',
]);
// .env aktualisieren
$this->writeEnv([
'APP_NAME' => $this->instance_name,
'APP_HOST' => $this->ui_domain,
@ -110,7 +118,7 @@ class Wizard extends Component
'WEBMAIL_DOMAIN' => $this->webmail_domain,
]);
// Admin anlegen oder aktualisieren
// Admin anlegen
$user = User::where('email', $this->admin_email)->first() ?? new User();
$user->name = $this->admin_name;
$user->email = $this->admin_email;
@ -118,6 +126,45 @@ class Wizard extends Component
$user->role = 'admin';
$user->save();
// Status-Verzeichnis leeren und Domain-Setup im Hintergrund starten
@mkdir(self::STATE_DIR, 0755, true);
@unlink(self::STATE_DIR . '/done');
foreach (['ui', 'mail', 'webmail'] as $k) {
file_put_contents(self::STATE_DIR . "/{$k}", 'pending');
}
$ssl = app()->isProduction() ? 1 : 0;
$artisan = base_path('artisan');
$cmd = sprintf(
'nohup php %s mailwolt:wizard-domains --ui=%s --mail=%s --webmail=%s --ssl=%d > /dev/null 2>&1 &',
escapeshellarg($artisan),
escapeshellarg($this->ui_domain),
escapeshellarg($this->mail_domain),
escapeshellarg($this->webmail_domain),
$ssl,
);
@shell_exec($cmd);
$this->step = 5;
}
public function pollSetup(): void
{
foreach (['ui', 'mail', 'webmail'] as $key) {
$file = self::STATE_DIR . "/{$key}";
$this->domainStatus[$key] = is_readable($file)
? trim(@file_get_contents($file))
: 'pending';
}
$done = @file_get_contents(self::STATE_DIR . '/done');
if ($done !== false) {
$this->setupDone = true;
}
}
public function goToLogin(): mixed
{
return redirect()->route('login')->with('setup_done', true);
}

View File

@ -15,6 +15,7 @@
</div>
{{-- ═══ Schritt-Indikator ═══ --}}
@if($step < 5)
<div style="display:flex;align-items:center;gap:0;margin-bottom:28px">
@foreach([1 => 'System', 2 => 'Domains', 3 => 'Admin', 4 => 'Fertig'] as $n => $label)
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:5px">
@ -37,6 +38,7 @@
@endif
@endforeach
</div>
@endif
{{-- ═══ Karte ═══ --}}
<div style="background:var(--mw-bg2);border:1px solid var(--mw-b2);border-radius:14px;padding:28px 24px">
@ -47,7 +49,6 @@
<div style="font-size:15px;font-weight:600;color:var(--mw-t1)">System-Einstellungen</div>
<div style="font-size:12px;color:var(--mw-t4);margin-top:3px">Grundkonfiguration für deine Mailwolt-Instanz</div>
</div>
<div style="display:flex;flex-direction:column;gap:16px">
<div>
<label class="mw-modal-label">Instanzname</label>
@ -78,12 +79,10 @@
<div style="font-size:15px;font-weight:600;color:var(--mw-t1)">Domains</div>
<div style="font-size:12px;color:var(--mw-t4);margin-top:3px">Domains müssen bereits auf diesen Server zeigen</div>
</div>
<div style="display:flex;align-items:flex-start;gap:8px;padding:10px 12px;border-radius:8px;background:rgba(251,191,36,.06);border:1px solid rgba(251,191,36,.2);margin-bottom:16px">
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" style="flex-shrink:0;margin-top:1px;color:#f59e0b"><path d="M7 1.5L12.5 11H1.5L7 1.5Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/><path d="M7 5.5v3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="7" cy="10" r=".7" fill="currentColor"/></svg>
<span style="font-size:11.5px;color:rgba(251,191,36,.85);line-height:1.5">DNS-Einträge zuerst setzen, dann hier eintragen. Kein <code style="font-size:10.5px">http://</code> am Anfang.</span>
</div>
<div style="display:flex;flex-direction:column;gap:16px">
<div>
<label class="mw-modal-label">UI Domain <span style="color:var(--mw-t4);font-weight:400">(Control Panel)</span></label>
@ -91,7 +90,7 @@
@error('ui_domain') <div class="mw-modal-error">{{ $message }}</div> @enderror
</div>
<div>
<label class="mw-modal-label">Mailserver Domain <span style="color:var(--mw-t4);font-weight:400">(MX / SMTP)</span></label>
<label class="mw-modal-label">Mailserver Domain <span style="color:var(--mw-t4);font-weight:400">(MX / SMTP / IMAP)</span></label>
<input type="text" wire:model="mail_domain" class="mw-modal-input" placeholder="mx.example.com">
@error('mail_domain') <div class="mw-modal-error">{{ $message }}</div> @enderror
</div>
@ -108,7 +107,6 @@
<div style="font-size:15px;font-weight:600;color:var(--mw-t1)">Admin-Account</div>
<div style="font-size:12px;color:var(--mw-t4);margin-top:3px">Dieser Account hat vollen Zugriff auf das Control Panel</div>
</div>
<div style="display:flex;flex-direction:column;gap:16px">
<div>
<label class="mw-modal-label">Name</label>
@ -137,7 +135,6 @@
<div style="font-size:15px;font-weight:600;color:var(--mw-t1)">Alles bereit</div>
<div style="font-size:12px;color:var(--mw-t4);margin-top:3px">Überprüfe die Einstellungen und schließe die Einrichtung ab</div>
</div>
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:4px">
@foreach([
['label' => 'Instanz', 'value' => $instance_name],
@ -153,11 +150,54 @@
</div>
@endforeach
</div>
{{-- ── Schritt 5: Domain-Setup ── --}}
@elseif($step === 5)
@if(!$setupDone)
<div wire:poll.2s="pollSetup"></div>
@endif
<div style="margin-bottom:20px">
<div style="font-size:15px;font-weight:600;color:var(--mw-t1)">Domains werden eingerichtet</div>
<div style="font-size:12px;color:var(--mw-t4);margin-top:3px">SSL-Zertifikate werden beantragt und Nginx wird konfiguriert</div>
</div>
@php
$labels = ['ui' => $ui_domain, 'mail' => $mail_domain, 'webmail' => $webmail_domain];
$statusConfig = [
'pending' => ['icon' => '…', 'color' => 'var(--mw-t5)', 'bg' => 'var(--mw-bg3)', 'label' => 'Warte …'],
'running' => ['icon' => '↻', 'color' => '#7dd3fc', 'bg' => 'rgba(14,165,233,.08)', 'label' => 'Wird registriert …', 'spin' => true],
'done' => ['icon' => '✓', 'color' => 'rgba(34,197,94,.9)', 'bg' => 'rgba(34,197,94,.07)', 'label' => 'Abgeschlossen'],
'nodns' => ['icon' => '!', 'color' => '#fbbf24', 'bg' => 'rgba(251,191,36,.07)', 'label' => 'Kein DNS-Eintrag gefunden'],
'error' => ['icon' => '✗', 'color' => '#f87171', 'bg' => 'rgba(239,68,68,.07)', 'label' => 'Fehler bei Registrierung'],
'skip' => ['icon' => '', 'color' => 'var(--mw-t5)', 'bg' => 'var(--mw-bg3)', 'label' => 'Übersprungen'],
];
@endphp
<div style="display:flex;flex-direction:column;gap:10px">
@foreach(['ui' => 'UI Domain', 'mail' => 'Mail Domain', 'webmail' => 'Webmail Domain'] as $key => $typeLabel)
@php
$st = $domainStatus[$key] ?? 'pending';
$cfg = $statusConfig[$st] ?? $statusConfig['pending'];
@endphp
<div style="display:flex;align-items:center;gap:12px;padding:12px 14px;background:{{ $cfg['bg'] }};border-radius:8px;border:1px solid var(--mw-b2)">
<div style="width:28px;height:28px;border-radius:50%;background:{{ $cfg['bg'] }};border:1px solid {{ $cfg['color'] }};display:flex;align-items:center;justify-content:center;font-size:13px;color:{{ $cfg['color'] }};flex-shrink:0;{{ isset($cfg['spin']) ? 'animation:spin .9s linear infinite' : '' }}">
{{ $cfg['icon'] }}
</div>
<div style="flex:1;min-width:0">
<div style="font-size:11px;color:var(--mw-t4)">{{ $typeLabel }}</div>
<div style="font-size:12.5px;color:var(--mw-t1);font-family:monospace;margin-top:1px">{{ $labels[$key] }}</div>
</div>
<span style="font-size:11.5px;color:{{ $cfg['color'] }}">{{ $cfg['label'] }}</span>
</div>
@endforeach
</div>
@endif
</div>
{{-- ═══ Navigation ═══ --}}
@if($step < 5)
<div style="display:flex;align-items:center;margin-top:20px;gap:10px">
@if($step > 1)
<button wire:click="back" class="mbx-btn-mute" style="font-size:12.5px">
@ -180,5 +220,13 @@
</button>
@endif
</div>
@elseif($step === 5 && $setupDone)
<div style="display:flex;justify-content:flex-end;margin-top:20px">
<button wire:click="goToLogin" class="mbx-btn-primary" style="font-size:12.5px;width:fit-content">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M2 6.5l2.5 2.5 5.5-5.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
Zum Login
</button>
</div>
@endif
</div>