Compare commits
29 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
97cc0091c0 | |
|
|
eeae972d8b | |
|
|
16fa6d3f4e | |
|
|
04e28903ea | |
|
|
4efc014a15 | |
|
|
2cdcb5ec38 | |
|
|
c313582b0b | |
|
|
eff76f6568 | |
|
|
c48a5c7e02 | |
|
|
d190beadcf | |
|
|
f1c1cf55da | |
|
|
c94f90bcde | |
|
|
0fedff759c | |
|
|
dec95d5803 | |
|
|
1ae3c7c54a | |
|
|
fce4f43833 | |
|
|
6710827b81 | |
|
|
f20a30839f | |
|
|
3bb9afefc3 | |
|
|
5c8a03cc45 | |
|
|
05cc37271c | |
|
|
7a89493efd | |
|
|
afce3d9687 | |
|
|
0beb12569b | |
|
|
be3502f197 | |
|
|
315608a108 | |
|
|
30d2c28504 | |
|
|
b7e411be82 | |
|
|
93699108eb |
|
|
@ -17,7 +17,7 @@ class BackupRun extends Command
|
|||
$job->update(['status' => 'running']);
|
||||
|
||||
$policy = $job->policy;
|
||||
$tmpDir = sys_get_temp_dir() . '/mailwolt_backup_' . $job->id;
|
||||
$tmpDir = sys_get_temp_dir() . '/clubird_backup_' . $job->id;
|
||||
mkdir($tmpDir, 0700, true);
|
||||
|
||||
$sources = [];
|
||||
|
|
@ -37,7 +37,7 @@ class BackupRun extends Command
|
|||
}
|
||||
}
|
||||
|
||||
$this->finalize($job, $exitCode, implode("\n", array_slice($output, -30)), $artifact);
|
||||
$this->finalize($job, $exitCode, implode("\n", array_slice($output, -30)), $artifact);
|
||||
$this->cleanTmp($tmpDir);
|
||||
return $exitCode === 0 ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
|
|
@ -97,12 +97,12 @@ class BackupRun extends Command
|
|||
$outDir = storage_path('app/backups');
|
||||
if (!is_dir($outDir) && !mkdir($outDir, 0750, true) && !is_dir($outDir)) {
|
||||
// Final fallback: /tmp (always writable)
|
||||
$outDir = sys_get_temp_dir() . '/mailwolt_backups';
|
||||
$outDir = sys_get_temp_dir() . '/clubird_backups';
|
||||
mkdir($outDir, 0750, true);
|
||||
}
|
||||
|
||||
$stamp = now()->format('Y-m-d_H-i-s');
|
||||
$outFile = "{$outDir}/mailwolt_{$stamp}.tar.gz";
|
||||
$outFile = "{$outDir}/clubird_{$stamp}.tar.gz";
|
||||
$srcArgs = implode(' ', array_map('escapeshellarg', $sources));
|
||||
|
||||
$tarOutput = [];
|
||||
|
|
|
|||
|
|
@ -4,11 +4,20 @@ namespace App\Http\Controllers\Auth;
|
|||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
public function show()
|
||||
{
|
||||
return view('auth.login'); // enthält @livewire('login-form')
|
||||
return view('auth.login');
|
||||
}
|
||||
|
||||
public function logout(Request $request)
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
return redirect()->route('login');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ class InstallDkimKey implements ShouldQueue
|
|||
public int $dkimKeyId,
|
||||
public string $privPath,
|
||||
public string $dnsTxtContent,
|
||||
public string $selector = 'mwl1',
|
||||
public string $selector = 'clb1',
|
||||
) {}
|
||||
|
||||
public function handle(): void
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ class DkimStatus extends Component
|
|||
?: optional(
|
||||
$domain->dkimKeys()->where('is_active', true)->latest()->first()
|
||||
)->selector
|
||||
?: (string) config('mailpool.defaults.dkim_selector', 'mwl1');
|
||||
?: (string) config('mailpool.defaults.dkim_selector', 'clb1');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -46,7 +46,7 @@ class DkimStatus extends Component
|
|||
// public function regenerate(?string $selector = null): void
|
||||
// {
|
||||
// $selector = $selector
|
||||
// ?: ($this->selector ?: (string) config('mailpool.defaults.dkim_selector', 'mwl1'));
|
||||
// ?: ($this->selector ?: (string) config('mailpool.defaults.dkim_selector', 'clb1'));
|
||||
//
|
||||
// Log::info('DKIM regenerate() CLICKED', [
|
||||
// 'domain' => $this->domain->domain,
|
||||
|
|
@ -79,7 +79,7 @@ class DkimStatus extends Component
|
|||
public function regenerate(?string $selector = null): void
|
||||
{
|
||||
$selector = $selector
|
||||
?: ($this->selector ?: (string) config('mailpool.defaults.dkim_selector', 'mwl1'));
|
||||
?: ($this->selector ?: (string) config('mailpool.defaults.dkim_selector', 'clb1'));
|
||||
|
||||
Log::info('DKIM regenerate() CLICKED', [
|
||||
'domain' => $this->domain->domain,
|
||||
|
|
@ -126,7 +126,7 @@ class DkimStatus extends Component
|
|||
|
||||
public function render(): View
|
||||
{
|
||||
$sel = $this->selector ?: (string) config('mailpool.defaults.dkim_selector', 'mwl1');
|
||||
$sel = $this->selector ?: (string) config('mailpool.defaults.dkim_selector', 'clb1');
|
||||
$dkimOk = $this->isDkimReady($this->domain->domain, $sel);
|
||||
|
||||
return view('livewire.ui.domain.dkim-status', compact('dkimOk'));
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ class DomainCreateModal extends ModalComponent
|
|||
$this->max_quota_per_mailbox_mb = config('mailpool.defaults.max_quota_per_mailbox_mb', 3072);
|
||||
$this->total_quota_mb = (int)config('mailpool.defaults.total_quota_mb', 10240);
|
||||
|
||||
$this->dkim_selector = (string) config('mailpool.defaults.dkim_selector', 'mwl1');
|
||||
$this->dkim_selector = (string) config('mailpool.defaults.dkim_selector', 'clb1');
|
||||
$this->dkim_bits = (int) config('mailpool.defaults.dkim_bits', 2048);
|
||||
|
||||
// Speicherpool-Grenze
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ class DomainDnsModal extends ModalComponent
|
|||
$dmarc = "v=DMARC1; p=none; rua=mailto:dmarc@{$this->domainName}; pct=100";
|
||||
|
||||
$dkim = DB::table('dkim_keys')->where('domain_id', $d->id)->where('is_active', 1)->orderByDesc('id')->first();
|
||||
$selector = $dkim ? $dkim->selector : 'mwl1';
|
||||
$selector = $dkim ? $dkim->selector : 'clb1';
|
||||
$dkimHost = "{$selector}._domainkey.{$this->domainName}";
|
||||
$dkimTxt = $dkim && !str_starts_with(trim($dkim->public_key_txt), 'v=')
|
||||
? 'v=DKIM1; k=rsa; p=' . trim($dkim->public_key_txt)
|
||||
|
|
@ -674,7 +674,7 @@ class DomainDnsModal extends ModalComponent
|
|||
// $dmarc = "v=DMARC1; p=none; rua=mailto:dmarc@{$this->domainName}; pct=100";
|
||||
//
|
||||
// $dkim = DB::table('dkim_keys')->where('domain_id', $d->id)->where('is_active', 1)->orderByDesc('id')->first();
|
||||
// $selector = $dkim ? $dkim->selector : 'mwl1';
|
||||
// $selector = $dkim ? $dkim->selector : 'clb1';
|
||||
// $dkimHost = "{$selector}._domainkey.{$this->domainName}";
|
||||
// $dkimTxt = $dkim && !str_starts_with(trim($dkim->public_key_txt), 'v=')
|
||||
// ? 'v=DKIM1; k=rsa; p=' . trim($dkim->public_key_txt)
|
||||
|
|
@ -1059,7 +1059,7 @@ class DomainDnsModal extends ModalComponent
|
|||
//
|
||||
// $dkim = DB::table('dkim_keys')
|
||||
// ->where('domain_id', $d->id)->where('is_active', 1)->orderByDesc('id')->first();
|
||||
// $selector = $dkim ? $dkim->selector : 'mwl1';
|
||||
// $selector = $dkim ? $dkim->selector : 'clb1';
|
||||
// $dkimHost = "{$selector}._domainkey.{$this->domainName}";
|
||||
// $dkimTxt = $dkim && !str_starts_with(trim($dkim->public_key_txt), 'v=')
|
||||
// ? 'v=DKIM1; k=rsa; p=' . $dkim->public_key_txt
|
||||
|
|
@ -1179,7 +1179,7 @@ class DomainDnsModal extends ModalComponent
|
|||
//// $mxHost = config('clubird.mx_fqdn', 'mx.' . $this->domain->domain);
|
||||
//// $selector = optional(
|
||||
//// DkimKey::where('domain_id', $this->domain->id)->where('is_active', true)->first()
|
||||
//// )->selector ?? 'mwl1';
|
||||
//// )->selector ?? 'clb1';
|
||||
////
|
||||
//// $dkimTxt = optional(
|
||||
//// DkimKey::where('domain_id', $this->domain->id)->where('is_active', true)->first()
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ class DnsDkim extends Component
|
|||
{
|
||||
$domain = Domain::findOrFail($id);
|
||||
$selector = optional($domain->dkimKeys()->where('is_active', true)->latest()->first())->selector
|
||||
?: (string) config('mailpool.defaults.dkim_selector', 'mwl1');
|
||||
?: (string) config('mailpool.defaults.dkim_selector', 'clb1');
|
||||
|
||||
try {
|
||||
/** @var DkimService $svc */
|
||||
|
|
@ -61,7 +61,7 @@ class DnsDkim extends Component
|
|||
|
||||
public function render()
|
||||
{
|
||||
$defaultSelector = (string) config('mailpool.defaults.dkim_selector', 'mwl1');
|
||||
$defaultSelector = (string) config('mailpool.defaults.dkim_selector', 'clb1');
|
||||
|
||||
$mapped = Domain::where('is_server', false)
|
||||
->with(['dkimKeys' => fn($q) => $q->where('is_active', true)->latest()])
|
||||
|
|
|
|||
|
|
@ -29,6 +29,48 @@ class QuarantineList extends Component
|
|||
#[On('quarantine:updated')]
|
||||
public function refresh(): void {}
|
||||
|
||||
// ── Löschen (lokal via Cache, RSpamd hat keine per-entry API) ────────────
|
||||
|
||||
private function hiddenKey(): string
|
||||
{
|
||||
return 'quarantine.hidden.' . session()->getId();
|
||||
}
|
||||
|
||||
private function getHidden(): array
|
||||
{
|
||||
return \Cache::get($this->hiddenKey(), []);
|
||||
}
|
||||
|
||||
private function saveHidden(array $ids): void
|
||||
{
|
||||
\Cache::put($this->hiddenKey(), array_values(array_unique($ids)), now()->addDays(7));
|
||||
}
|
||||
|
||||
public function deleteEntry(string $id): void
|
||||
{
|
||||
$hidden = $this->getHidden();
|
||||
$hidden[] = $id;
|
||||
$this->saveHidden($hidden);
|
||||
}
|
||||
|
||||
public function deleteCurrentTab(): void
|
||||
{
|
||||
$all = $this->fetchHistory();
|
||||
$hidden = $this->getHidden();
|
||||
|
||||
$toHide = match($this->filter) {
|
||||
'suspicious' => array_filter($all, fn($m) => $m['action'] !== 'no action'),
|
||||
'all' => $all,
|
||||
default => array_filter($all, fn($m) => $m['action'] === $this->filter),
|
||||
};
|
||||
|
||||
foreach ($toHide as $m) {
|
||||
$hidden[] = $m['id'];
|
||||
}
|
||||
$this->saveHidden($hidden);
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function updatedFilter(): void { $this->resetPage(); }
|
||||
public function updatedSearch(): void { $this->resetPage(); }
|
||||
public function updatedPerPage(): void { $this->resetPage(); }
|
||||
|
|
@ -43,7 +85,11 @@ class QuarantineList extends Component
|
|||
|
||||
public function render()
|
||||
{
|
||||
$all = $this->fetchHistory();
|
||||
$hidden = $this->getHidden();
|
||||
$all = array_values(array_filter(
|
||||
$this->fetchHistory(),
|
||||
fn($m) => !in_array($m['id'], $hidden, true)
|
||||
));
|
||||
|
||||
$suspicious = array_values(array_filter($all, fn($m) => $m['action'] !== 'no action'));
|
||||
|
||||
|
|
|
|||
|
|
@ -2,90 +2,54 @@
|
|||
|
||||
namespace App\Livewire\Ui\Security\Modal;
|
||||
|
||||
use App\Services\TotpService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\On;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
use Vectorface\GoogleAuthenticator;
|
||||
|
||||
class TotpSetupModal extends ModalComponent
|
||||
{
|
||||
public string $secret;
|
||||
public string $otp = '';
|
||||
public string $qrPng; // PNG Data-URI
|
||||
public bool $alreadyActive = false;
|
||||
public string $step = 'scan';
|
||||
public string $code = '';
|
||||
public string $secret = '';
|
||||
public array $recoveryCodes = [];
|
||||
public string $qrSvg = '';
|
||||
|
||||
// << Wichtig: je Modal eigene Breite >>
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
// mögliche Werte: 'sm','md','lg','xl','2xl','3xl','4xl','5xl','6xl','7xl'
|
||||
return 'xl'; // kompakt für TOTP
|
||||
}
|
||||
public static function modalMaxWidth(): string { return 'md'; }
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
$ga = new GoogleAuthenticator();
|
||||
|
||||
// Falls User schon Secret hat: wiederverwenden, sonst neues anlegen
|
||||
$this->secret = $user->totp_secret ?: $ga->createSecret();
|
||||
|
||||
$issuer = config('app.name', 'MailWolt');
|
||||
// getQRCodeUrl(accountName, secret, issuer) => PNG Data-URI
|
||||
$this->qrPng = $ga->getQRCodeUrl($user->email, $this->secret, $issuer);
|
||||
|
||||
$this->alreadyActive = (bool) ($user->two_factor_enabled ?? false);
|
||||
$totp = app(TotpService::class);
|
||||
$this->secret = $totp->generateSecret();
|
||||
$this->qrSvg = $totp->qrCodeSvg(Auth::user(), $this->secret);
|
||||
}
|
||||
|
||||
#[On('security:totp:enable')]
|
||||
public function verifyAndEnable(string $code): void
|
||||
public function verify(): void
|
||||
{
|
||||
$code = preg_replace('/\D/', '', $code ?? '');
|
||||
if (strlen($code) !== 6) {
|
||||
$this->dispatch('toast', body: 'Bitte 6-stelligen Code eingeben.');
|
||||
$this->validate(['code' => 'required|digits:6']);
|
||||
|
||||
$totp = app(TotpService::class);
|
||||
|
||||
if (!$totp->verify($this->secret, $this->code)) {
|
||||
$this->addError('code', 'Ungültiger Code. Bitte erneut versuchen.');
|
||||
return;
|
||||
}
|
||||
|
||||
$ga = new GoogleAuthenticator();
|
||||
$ok = $ga->verifyCode($this->secret, $code, 2); // 2 × 30 s Toleranz
|
||||
|
||||
if (!$ok) {
|
||||
$this->dispatch('toast', body: 'Code ungültig. Versuche es erneut.');
|
||||
return;
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
$user->totp_secret = $this->secret;
|
||||
$user->two_factor_enabled = true;
|
||||
$user->save();
|
||||
|
||||
$this->dispatch('totp-enabled');
|
||||
$this->dispatch('toast', body: 'TOTP aktiviert.');
|
||||
$this->dispatch('closeModal');
|
||||
$this->recoveryCodes = $totp->enable(Auth::user(), $this->secret);
|
||||
$this->step = 'codes';
|
||||
}
|
||||
|
||||
public function disable(): void
|
||||
public function done(): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
$user->totp_secret = null;
|
||||
$user->two_factor_enabled = false;
|
||||
$user->save();
|
||||
|
||||
$this->dispatch('totp-disabled');
|
||||
$this->dispatch('toast', body: 'TOTP deaktiviert.');
|
||||
$this->dispatch('closeModal');
|
||||
$this->dispatch('toast', type: 'done', badge: '2FA',
|
||||
title: 'TOTP aktiviert',
|
||||
text: 'Zwei-Faktor-Authentifizierung ist jetzt aktiv.', duration: 5000);
|
||||
$this->dispatch('2fa-status-changed');
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public function saveAccount() { /* $this->validate(..); user->update([...]) */ }
|
||||
public function changePassword() { /* validate & set */ }
|
||||
public function changeEmail() { /* validate, send verify link, etc. */ }
|
||||
|
||||
public function openRecovery() { /* optional modal or page */ }
|
||||
public function logoutOthers() { /* … */ }
|
||||
public function logoutSession(string $id) { /* … */ }
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.ui.security.modal.totp-setup-modal');
|
||||
return view('livewire.ui.system.modal.totp-setup-modal');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Ui\System;
|
||||
|
||||
use App\Services\TotpService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.dvx')]
|
||||
#[Title('Profil · Mailwolt')]
|
||||
class ProfilePage extends Component
|
||||
{
|
||||
public string $name = '';
|
||||
public string $email = '';
|
||||
|
||||
public string $current_password = '';
|
||||
public string $new_password = '';
|
||||
public string $new_password_confirmation = '';
|
||||
|
||||
public bool $totpEnabled = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$u = Auth::user();
|
||||
$this->name = $u->name ?? '';
|
||||
$this->email = $u->email ?? '';
|
||||
$this->totpEnabled = app(TotpService::class)->isEnabled($u);
|
||||
}
|
||||
|
||||
#[On('2fa-status-changed')]
|
||||
public function refreshTotpStatus(): void
|
||||
{
|
||||
$this->totpEnabled = app(TotpService::class)->isEnabled(Auth::user());
|
||||
}
|
||||
|
||||
public function saveProfile(): void
|
||||
{
|
||||
$this->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
]);
|
||||
|
||||
$u = Auth::user();
|
||||
$u->name = $this->name;
|
||||
$u->save();
|
||||
|
||||
$this->dispatch('toast', type: 'done', badge: 'Profil',
|
||||
title: 'Gespeichert',
|
||||
text: 'Profilname wurde aktualisiert.', duration: 4000);
|
||||
}
|
||||
|
||||
public function changePassword(): void
|
||||
{
|
||||
if ($this->new_password === '') {
|
||||
$this->addError('new_password', 'Kein neues Passwort eingegeben.');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validate([
|
||||
'current_password' => 'required|string',
|
||||
'new_password' => 'required|string|min:8',
|
||||
'new_password_confirmation' => 'required|same:new_password',
|
||||
]);
|
||||
|
||||
$u = Auth::user();
|
||||
if (!Hash::check($this->current_password, $u->password)) {
|
||||
$this->addError('current_password', 'Aktuelles Passwort ist falsch.');
|
||||
return;
|
||||
}
|
||||
|
||||
$u->password = Hash::make($this->new_password);
|
||||
$u->save();
|
||||
|
||||
$this->reset(['current_password', 'new_password', 'new_password_confirmation']);
|
||||
|
||||
$this->dispatch('toast', type: 'done', badge: 'Profil',
|
||||
title: 'Passwort geändert',
|
||||
text: 'Dein Passwort wurde aktualisiert.', duration: 4000);
|
||||
}
|
||||
|
||||
public function disableTotp(): void
|
||||
{
|
||||
app(TotpService::class)->disable(Auth::user());
|
||||
session()->forget('2fa_verified');
|
||||
$this->totpEnabled = false;
|
||||
|
||||
$this->dispatch('toast', type: 'done', badge: '2FA',
|
||||
title: 'TOTP deaktiviert',
|
||||
text: 'Zwei-Faktor-Authentifizierung wurde deaktiviert.', duration: 5000);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.ui.system.profile-page');
|
||||
}
|
||||
}
|
||||
|
|
@ -229,6 +229,10 @@ class SettingsForm extends Component
|
|||
$warnings[] = 'System-Domain: ' . $sysmailErr;
|
||||
}
|
||||
|
||||
if ($this->mail_domain) {
|
||||
$this->applyPostfixHostname($this->mail_domain);
|
||||
}
|
||||
|
||||
$this->loadSslStatus();
|
||||
|
||||
if ($warnings) {
|
||||
|
|
@ -498,6 +502,15 @@ class SettingsForm extends Component
|
|||
file_put_contents($path, $content);
|
||||
}
|
||||
|
||||
private function applyPostfixHostname(string $mailHost): void
|
||||
{
|
||||
$safeHost = preg_replace('/[^a-zA-Z0-9.\-]/', '', $mailHost);
|
||||
if (!$safeHost) return;
|
||||
|
||||
$helper = '/usr/local/sbin/mailwolt-apply-hostname';
|
||||
@shell_exec(sprintf('sudo -n %s %s 2>/dev/null', escapeshellarg($helper), escapeshellarg($safeHost)));
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
if (!in_array($this->tab, self::VALID_TABS, true)) {
|
||||
|
|
|
|||
|
|
@ -144,13 +144,20 @@ class UpdateCard extends Component
|
|||
|
||||
$ver = $this->displayCurrent ?? 'aktuelle Version';
|
||||
$this->progressLine = 'Update abgeschlossen: ' . $ver;
|
||||
|
||||
$this->dispatch('toast', type: 'done', badge: 'System',
|
||||
title: 'Update abgeschlossen',
|
||||
text: "Mailwolt wurde auf {$ver} aktualisiert.",
|
||||
duration: 6000);
|
||||
$this->dispatch('closeModal');
|
||||
} elseif ($this->rc !== null && $this->rc !== 0 && !$this->postActionsDone) {
|
||||
$this->postActionsDone = true;
|
||||
@file_put_contents('/var/lib/mailwolt/update/state', 'complete');
|
||||
$this->errorLine = "Update fehlgeschlagen (rc={$this->rc}).";
|
||||
$this->dispatch('toast', type:'error', title:'Update fehlgeschlagen',
|
||||
text:$this->progressLine ?: 'Bitte Logs prüfen: /var/log/mailwolt-update.log',
|
||||
badge:'System', duration:0);
|
||||
$this->dispatch('toast', type: 'error', title: 'Update fehlgeschlagen',
|
||||
text: $this->progressLine ?: 'Bitte Logs prüfen: /var/log/mailwolt-update.log',
|
||||
badge: 'System', duration: 0);
|
||||
$this->dispatch('closeModal');
|
||||
}
|
||||
|
||||
$this->state = 'idle';
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ class UpdatePage extends Component
|
|||
|
||||
if ($this->rc === 0 && !$this->postActionsDone) {
|
||||
@shell_exec('nohup php /var/www/mailwolt/artisan optimize:clear >/dev/null 2>&1 &');
|
||||
@shell_exec('nohup php /var/www/mailwolt/artisan config:cache >/dev/null 2>&1 &');
|
||||
@shell_exec('nohup php /var/www/mailwolt/artisan health:collect >/dev/null 2>&1 &');
|
||||
@shell_exec('nohup php /var/www/mailwolt/artisan settings:sync >/dev/null 2>&1 &');
|
||||
$this->postActionsDone = true;
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ class DomainObserver
|
|||
{
|
||||
if ($domain->is_server) return;
|
||||
|
||||
$selector = (string) config('mailpool.defaults.dkim_selector', 'mwl1');
|
||||
$selector = (string) config('mailpool.defaults.dkim_selector', 'clb1');
|
||||
$bits = (int) config('mailpool.defaults.dkim_bits', 2048);
|
||||
|
||||
try {
|
||||
|
|
@ -56,7 +56,7 @@ class DomainObserver
|
|||
|
||||
// Selector VOR dem Delete einsammeln (Relation oder direkte Query)
|
||||
$selectors = DkimKey::where('domain_id', $domain->id)->pluck('selector')->all();
|
||||
$selectors = $selectors ?: ['mwl1'];
|
||||
$selectors = $selectors ?: ['clb1'];
|
||||
|
||||
foreach ($selectors as $sel) {
|
||||
$cmd = ['sudo','-n','/usr/local/sbin/mailwolt-remove-dkim', $domain->domain, $sel];
|
||||
|
|
@ -136,7 +136,7 @@ class DomainObserver
|
|||
// return;
|
||||
// }
|
||||
//
|
||||
// $selector = (string) config('mailpool.defaults.dkim_selector', 'mwl1');
|
||||
// $selector = (string) config('mailpool.defaults.dkim_selector', 'clb1');
|
||||
// $bits = (int) config('mailpool.defaults.dkim_bits', 2048);
|
||||
//
|
||||
// $res = app(\App\Services\DkimService::class)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ class DkimService
|
|||
/** Erzeugt Keypair & gibt den TXT-Record (ohne Host) zurück. */
|
||||
public function generateForDomain(Domain $domain, int $bits = 2048, string $selector = null): array
|
||||
{
|
||||
$selector = $selector ?: (string) config('mailpool.defaults.dkim_selector', 'mwl1');
|
||||
$selector = $selector ?: (string) config('mailpool.defaults.dkim_selector', 'clb1');
|
||||
|
||||
// $dirKey = $this->safeKey($domain->domain);
|
||||
// $selKey = $this->safeKey($selector, 32);
|
||||
|
|
@ -205,7 +205,7 @@ class DkimService
|
|||
? $domain->dkimKeys()->pluck('selector')->all()
|
||||
: \App\Models\DkimKey::whereHas('domain', fn($q) => $q->where('domain', $name))
|
||||
->pluck('selector')->all();
|
||||
$keys = $keys ?: ['mwl1'];
|
||||
$keys = $keys ?: ['clb1'];
|
||||
} else {
|
||||
$keys = [$selector];
|
||||
}
|
||||
|
|
@ -241,7 +241,7 @@ class DkimService
|
|||
// : \App\Models\DkimKey::whereHas('domain', fn($q) => $q->where('domain', $name))
|
||||
// ->pluck('selector')->all();
|
||||
//
|
||||
// $keys = $keys ?: ['mwl1']; // notfalls versuchen wir Standard
|
||||
// $keys = $keys ?: ['clb1']; // notfalls versuchen wir Standard
|
||||
// } else {
|
||||
// $keys = [$selector];
|
||||
// }
|
||||
|
|
@ -265,7 +265,7 @@ class DkimService
|
|||
// public function removeForDomain(Domain|string $domain, ?string $selector = null): void
|
||||
// {
|
||||
// $name = $domain instanceof \App\Models\Domain ? $domain->domain : $domain;
|
||||
// $selector = $selector ?: (string) config('mailpool.defaults.dkim_selector', 'mwl1');
|
||||
// $selector = $selector ?: (string) config('mailpool.defaults.dkim_selector', 'clb1');
|
||||
//
|
||||
// // Root-Helper ausführen
|
||||
// $p = Process::run([
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||
using: function () {
|
||||
// Webmail must be registered BEFORE web.php so its domain constraint
|
||||
// takes priority over the catch-all Route::get('/') in web.php.
|
||||
$wmHost = config('mailwolt.domain.webmail_host');
|
||||
$wmHost = config('clubird.domain.webmail_host');
|
||||
|
||||
if ($wmHost) {
|
||||
Route::middleware('web')
|
||||
|
|
@ -27,14 +27,23 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||
->prefix('webmail')
|
||||
->name('webmail.')
|
||||
->group(base_path('routes/webmail.php'));
|
||||
|
||||
// UI-Routes nur auf UI-Domain binden, damit webmail.* sie nicht beantwortet
|
||||
$uiHost = config('clubird.domain.ui') && config('clubird.domain.base')
|
||||
? config('clubird.domain.ui') . '.' . config('clubird.domain.base')
|
||||
: parse_url(config('app.url'), PHP_URL_HOST);
|
||||
|
||||
Route::middleware('web')
|
||||
->domain($uiHost)
|
||||
->group(base_path('routes/web.php'));
|
||||
} else {
|
||||
Route::middleware('web')
|
||||
->prefix('webmail')
|
||||
->name('ui.webmail.')
|
||||
->group(base_path('routes/webmail.php'));
|
||||
}
|
||||
|
||||
Route::middleware('web')->group(base_path('routes/web.php'));
|
||||
Route::middleware('web')->group(base_path('routes/web.php'));
|
||||
}
|
||||
},
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ return [
|
|||
'fallback_du' => (bool) env('MAILPOOL_FALLBACK_DU', true),
|
||||
|
||||
'defaults' => [
|
||||
'dkim_selector' => 'mwl1',
|
||||
'dkim_selector' => 'clb1',
|
||||
'dkim_bits' => 2048,
|
||||
|
||||
'max_aliases' => (int) env('MAILPOOL_DEFAULT_MAX_ALIASES', 400),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('two_factor_methods', function (Blueprint $table) {
|
||||
$table->text('secret')->nullable()->change();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('two_factor_methods', function (Blueprint $table) {
|
||||
$table->string('secret', 255)->nullable()->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -305,7 +305,7 @@ class SystemDomainSeeder extends Seeder
|
|||
// // DKIM – Key erzeugen, falls keiner aktiv existiert
|
||||
// if (!$systemDomain->dkimKeys()->where('is_active', true)->exists()) {
|
||||
// [$privPem, $pubTxt] = $this->generateDkimKeyPair();
|
||||
// $selector = 'mwl1'; // frei wählbar, später rotieren
|
||||
// $selector = 'clb1'; // frei wählbar, später rotieren
|
||||
//
|
||||
// DkimKey::create([
|
||||
// 'domain_id' => $systemDomain->id,
|
||||
|
|
@ -452,7 +452,7 @@ class SystemDomainSeeder extends Seeder
|
|||
// // DKIM – Key erzeugen, falls keiner aktiv existiert
|
||||
// if (!$systemDomain->dkimKeys()->where('is_active', true)->exists()) {
|
||||
// [$privPem, $pubTxt] = $this->generateDkimKeyPair();
|
||||
// $selector = 'mwl1'; // frei wählbar, später rotieren
|
||||
// $selector = 'clb1'; // frei wählbar, später rotieren
|
||||
//
|
||||
// DkimKey::create([
|
||||
// 'domain_id' => $systemDomain->id,
|
||||
|
|
@ -569,7 +569,7 @@ class SystemDomainSeeder extends Seeder
|
|||
// // DKIM – Key erzeugen, falls keiner aktiv existiert
|
||||
// if (! $domain->dkimKeys()->where('is_active', true)->exists()) {
|
||||
// [$privPem, $pubTxt] = $this->generateDkimKeyPair();
|
||||
// $selector = 'mwl1'; // frei wählbar, z. B. rotierend später
|
||||
// $selector = 'clb1'; // frei wählbar, z. B. rotierend später
|
||||
//
|
||||
// DkimKey::create([
|
||||
// 'domain_id' => $domain->id,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
@import "../fonts/BaiJamjuree/font.css";
|
||||
@import "../fonts/Space/font.css";
|
||||
@import "../fonts/Syne/font.css";
|
||||
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../vendor/wire-elements/modal/resources/views/*.blade.php';
|
||||
|
|
@ -16,6 +17,7 @@
|
|||
|
||||
--font-bai: 'Bai Jamjuree';
|
||||
--font-space: 'Space Age';
|
||||
--font-syne: 'Syne';
|
||||
|
||||
--color-glass-bg: rgba(17, 24, 39, 0.55);
|
||||
--color-glass-border: rgba(255, 255, 255, 0.08);
|
||||
|
|
@ -1041,8 +1043,8 @@ select {
|
|||
background: var(--mw-v); display: flex; align-items: center; justify-content: center;
|
||||
box-shadow: 0 0 12px rgba(124,58,237,.3);
|
||||
}
|
||||
.mw-sb-name { font-size: 13px; font-weight: 700; letter-spacing: .3px; color: var(--mw-t1); }
|
||||
.mw-sb-sub { font-size: 9.5px; color: var(--mw-t4); letter-spacing: .8px; text-transform: uppercase; margin-top: 1px; }
|
||||
.mw-sb-name { font-family: 'Syne', sans-serif; font-size: 15px; font-weight: 800; letter-spacing: -.2px; line-height: 1; color: var(--mw-t1); }
|
||||
.mw-sb-sub { font-size: 9px; color: var(--mw-t4); letter-spacing: 1.6px; text-transform: uppercase; margin-top: 3px; }
|
||||
|
||||
.mw-nav { flex: 1; padding: 10px 8px; overflow-y: auto; display: flex; flex-direction: column; gap: 1px; }
|
||||
.mw-nav::-webkit-scrollbar { display: none; }
|
||||
|
|
@ -1694,6 +1696,8 @@ select {
|
|||
box-shadow: 0 0 8px rgba(124,58,237,.3);
|
||||
}
|
||||
.mbx-btn-primary:hover { background: #6d28d9; }
|
||||
.mbx-btn-primary:disabled { opacity: .55; cursor: not-allowed; box-shadow: none; }
|
||||
.mbx-btn-primary:active:not(:disabled) { transform: scale(.97); }
|
||||
.mbx-btn-mute {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
background: var(--mw-bg3); border: 1px solid var(--mw-b1);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
@font-face {
|
||||
font-display: swap;
|
||||
font-family: 'Syne';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: url('Syne-Bold.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
font-family: 'Syne';
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
src: url('Syne-ExtraBold.ttf') format('truetype');
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
@include('errors._base', [
|
||||
'code' => 400,
|
||||
'type' => 'client',
|
||||
'title' => 'Ungültige Anfrage',
|
||||
'desc' => 'Die Anfrage enthält fehlerhafte oder unvollständige Daten.',
|
||||
'icon' => '<path d="M12 9v4m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>',
|
||||
])
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
@include('errors._base', [
|
||||
'code' => 401,
|
||||
'type' => 'client',
|
||||
'title' => 'Nicht angemeldet',
|
||||
'desc' => 'Bitte melde dich an, um auf diese Seite zuzugreifen.',
|
||||
'icon' => '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
])
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
@include('errors._base', [
|
||||
'code' => 403,
|
||||
'type' => 'client',
|
||||
'title' => 'Kein Zugriff',
|
||||
'desc' => 'Du hast keine Berechtigung, diese Seite aufzurufen.',
|
||||
'icon' => '<circle cx="12" cy="12" r="9"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>',
|
||||
])
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
@include('errors._base', [
|
||||
'code' => 404,
|
||||
'type' => 'client',
|
||||
'title' => 'Seite nicht gefunden',
|
||||
'desc' => 'Die aufgerufene Seite existiert nicht oder wurde verschoben.',
|
||||
'icon' => '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
|
||||
])
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
@include('errors._base', [
|
||||
'code' => 405,
|
||||
'type' => 'client',
|
||||
'title' => 'Methode nicht erlaubt',
|
||||
'desc' => 'Diese HTTP-Methode ist für die aufgerufene URL nicht zulässig.',
|
||||
'icon' => '<circle cx="12" cy="12" r="9"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>',
|
||||
])
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
@include('errors._base', [
|
||||
'code' => 419,
|
||||
'type' => 'client',
|
||||
'title' => 'Sitzung abgelaufen',
|
||||
'desc' => 'Deine Sitzung ist abgelaufen. Lade die Seite neu und versuche es erneut.',
|
||||
'icon' => '<circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 15"/>',
|
||||
])
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
@include('errors._base', [
|
||||
'code' => 422,
|
||||
'type' => 'client',
|
||||
'title' => 'Ungültige Eingabe',
|
||||
'desc' => 'Die übermittelten Daten konnten nicht verarbeitet werden.',
|
||||
'icon' => '<path d="M12 9v4m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>',
|
||||
])
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
@include('errors._base', [
|
||||
'code' => 429,
|
||||
'type' => 'client',
|
||||
'title' => 'Zu viele Anfragen',
|
||||
'desc' => 'Du hast das Anfrage-Limit erreicht. Bitte warte einen Moment.',
|
||||
'icon' => '<path d="M13 10V3L4 14h7v7l9-11h-7z"/>',
|
||||
])
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
@include('errors._base', [
|
||||
'code' => 500,
|
||||
'type' => 'server',
|
||||
'title' => 'Serverfehler',
|
||||
'desc' => 'Ein interner Fehler ist aufgetreten. Wir wurden benachrichtigt.',
|
||||
'icon' => '<path d="M13 10V3L4 14h7v7l9-11h-7z"/>',
|
||||
])
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
@include('errors._base', [
|
||||
'code' => 502,
|
||||
'type' => 'server',
|
||||
'title' => 'Bad Gateway',
|
||||
'desc' => 'Der Server hat eine ungültige Antwort erhalten. Bitte versuche es gleich erneut.',
|
||||
'icon' => '<path d="M12 9v4m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>',
|
||||
])
|
||||
|
|
@ -3,7 +3,6 @@
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="refresh" content="3">
|
||||
<title>Update läuft · CluBird</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
|
@ -86,5 +85,16 @@
|
|||
<h1>Update wird durchgeführt</h1>
|
||||
<p>Die Anwendung wird aktualisiert.<br>Diese Seite lädt automatisch neu.</p>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
function check(){
|
||||
fetch('/ping',{cache:'no-store'}).then(function(r){
|
||||
if(r.ok) window.location.reload();
|
||||
else setTimeout(check,3000);
|
||||
}).catch(function(){ setTimeout(check,3000); });
|
||||
}
|
||||
setTimeout(check,3000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
@include('errors._base', [
|
||||
'code' => 504,
|
||||
'type' => 'server',
|
||||
'title' => 'Gateway-Timeout',
|
||||
'desc' => 'Der Server antwortet nicht rechtzeitig. Bitte versuche es in Kürze erneut.',
|
||||
'icon' => '<circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 15"/>',
|
||||
])
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ $code }} · CluBird</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body {
|
||||
width: 100%; min-height: 100vh;
|
||||
background: #0f1117; color: #e2e4ea;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
display: flex; align-items: center; justify-content: center; padding: 16px;
|
||||
}
|
||||
.card {
|
||||
width: 100%; max-width: 480px; padding: 44px 40px;
|
||||
background: #181c24; border: 1px solid #252938;
|
||||
border-radius: 18px; text-align: center;
|
||||
animation: fadein .35s ease;
|
||||
}
|
||||
@keyframes fadein { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||||
.logo { display: inline-flex; align-items: center; gap: 14px; margin-bottom: 36px; }
|
||||
.logo-icon {
|
||||
position: relative; width: 42px; height: 42px;
|
||||
background: linear-gradient(135deg, #6366f1, #4338ca);
|
||||
border-radius: 12px; display: flex; align-items: center; justify-content: center;
|
||||
box-shadow: 0 0 22px rgba(99,102,241,.45);
|
||||
}
|
||||
.logo-icon::after {
|
||||
content: ''; position: absolute; inset: -4px; border-radius: 16px;
|
||||
border: 1px solid rgba(99,102,241,.3);
|
||||
animation: glow 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes glow { 0%, 100% { opacity: .3; } 50% { opacity: 1; } }
|
||||
.logo-text { font-size: 24px; font-weight: 700; letter-spacing: -.3px; }
|
||||
.logo-text span { color: #6366f1; }
|
||||
.err-icon {
|
||||
width: 56px; height: 56px; margin: 0 auto 20px;
|
||||
border-radius: 50%; display: flex; align-items: center; justify-content: center;
|
||||
background: {{ $type === 'server' ? 'rgba(239,68,68,.1)' : 'rgba(99,102,241,.12)' }};
|
||||
}
|
||||
.err-icon svg {
|
||||
width: 26px; height: 26px; fill: none; stroke-width: 1.8;
|
||||
stroke-linecap: round; stroke-linejoin: round;
|
||||
stroke: {{ $type === 'server' ? '#f87171' : '#818cf8' }};
|
||||
}
|
||||
.err-code {
|
||||
font-size: 64px; font-weight: 800; line-height: 1;
|
||||
margin-bottom: 12px; letter-spacing: -2px;
|
||||
color: {{ $type === 'server' ? '#f87171' : '#6366f1' }};
|
||||
}
|
||||
h1 { font-size: 17px; font-weight: 600; color: #e2e4ea; margin-bottom: 8px; letter-spacing: -.2px; }
|
||||
p { font-size: 13px; color: #4b5563; line-height: 1.65; margin-bottom: 28px; }
|
||||
.divider { border: none; border-top: 1px solid #252938; margin: 0 0 28px; }
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 9px 20px; border-radius: 9px;
|
||||
background: #1f2433; border: 1px solid #2d3348;
|
||||
font-size: 13px; font-weight: 500; text-decoration: none; cursor: pointer;
|
||||
transition: background .15s, border-color .15s;
|
||||
color: {{ $type === 'server' ? '#fca5a5' : '#a5b4fc' }};
|
||||
}
|
||||
.btn:hover { background: #252b3d; border-color: #3d4565; }
|
||||
.btn svg { width: 14px; height: 14px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.btn-primary {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 9px 20px; border-radius: 9px; margin-left: 8px;
|
||||
background: linear-gradient(135deg, #6366f1, #4338ca); border: none;
|
||||
color: #fff; font-size: 13px; font-weight: 500; text-decoration: none;
|
||||
box-shadow: 0 4px 14px rgba(99,102,241,.35); transition: opacity .15s;
|
||||
}
|
||||
.btn-primary:hover { opacity: .88; }
|
||||
.btn-primary svg { width: 14px; height: 14px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">
|
||||
<div class="logo-icon">
|
||||
<svg width="22" height="22" viewBox="0 0 28 28" fill="none">
|
||||
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<circle cx="19" cy="8" r="2" fill="white"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="logo-text">Clu<span>Bird</span></div>
|
||||
</div>
|
||||
|
||||
<div class="err-icon">
|
||||
<svg viewBox="0 0 24 24">{!! $icon !!}</svg>
|
||||
</div>
|
||||
|
||||
<div class="err-code">{{ $code }}</div>
|
||||
<h1>{{ $title }}</h1>
|
||||
<p>{{ $desc }}</p>
|
||||
<hr class="divider">
|
||||
|
||||
@if($type === 'server')
|
||||
<a class="btn" href="javascript:location.reload()">
|
||||
<svg viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-.1-6.87"/></svg>
|
||||
Neu laden
|
||||
</a>
|
||||
@else
|
||||
<a class="btn" href="javascript:history.back()">
|
||||
<svg viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
Zurück
|
||||
</a>
|
||||
@endif
|
||||
<a class="btn-primary" href="/">
|
||||
<svg viewBox="0 0 24 24"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
||||
Startseite
|
||||
</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -18,15 +18,17 @@
|
|||
<aside class="mw-sidebar" id="mw-sidebar">
|
||||
|
||||
<div class="mw-sb-head">
|
||||
<div class="mw-sb-mark" style="background:linear-gradient(135deg,#6366f1 0%,#4338ca 100%);box-shadow:0 0 10px rgba(99,102,241,.35);">
|
||||
<svg width="15" height="15" viewBox="0 0 28 28" fill="none">
|
||||
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.5" stroke-linecap="round" fill="none"/>
|
||||
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.5" stroke-linecap="round" fill="none"/>
|
||||
<circle cx="19" cy="8" r="2" fill="white"/>
|
||||
<div class="mw-sb-mark" style="background:linear-gradient(135deg,#6366f1 0%,#4338ca 100%);box-shadow:0 0 12px rgba(99,102,241,.35);">
|
||||
<svg width="18" height="18" viewBox="0 0 28 28" fill="none">
|
||||
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
|
||||
<path d="M15 9 C15 9 12 5 8 5 C9.5 7 10 9 11 10" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
|
||||
<path d="M24 15 C24 15 27 13 28 10 C26 11 24 12 23 13.5" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
|
||||
<circle cx="19" cy="8" r="1.5" fill="white" opacity=".95"/>
|
||||
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mw-sb-name"><span style="color:var(--mw-t1)">Clu</span><span style="color:#6366f1">Bird</span></div>
|
||||
<div class="mw-sb-name"><span style="color:#e8eaf6">Clu</span><span style="color:#6366f1">Bird</span></div>
|
||||
<div class="mw-sb-sub">Control Panel</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -148,11 +150,13 @@
|
|||
</nav>
|
||||
|
||||
<div class="mw-sb-foot">
|
||||
<div class="mw-avatar">{{ strtoupper(substr(auth()->user()->name ?? 'MW', 0, 2)) }}</div>
|
||||
<a href="{{ route('ui.profile') }}" style="display:flex;align-items:center;gap:8px;flex:1;min-width:0;overflow:hidden;text-decoration:none;border-radius:7px;padding:2px 4px;margin:-2px -4px;transition:background .15s" onmouseover="this.style.background='var(--mw-bg4)'" onmouseout="this.style.background=''">
|
||||
<div class="mw-avatar" style="{{ request()->routeIs('ui.profile') ? 'background:var(--mw-v)' : '' }}">{{ strtoupper(substr(auth()->user()->name ?? 'MW', 0, 2)) }}</div>
|
||||
<div style="min-width:0;flex:1;overflow:hidden;">
|
||||
<div style="font-size:12px;font-weight:500;color:var(--mw-t2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ auth()->user()->name ?? 'Admin' }}</div>
|
||||
<div style="font-size:10px;color:var(--mw-t4);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ auth()->user()->email ?? '' }}</div>
|
||||
</div>
|
||||
</a>
|
||||
<form method="POST" action="{{ route('ui.logout') }}" style="flex-shrink:0;">
|
||||
@csrf
|
||||
<button type="submit" style="background:none;border:none;cursor:pointer;color:var(--mw-t4);padding:4px;" title="Abmelden">
|
||||
|
|
@ -222,14 +226,18 @@
|
|||
@livewireScripts
|
||||
@livewire('wire-elements-modal')
|
||||
<script>
|
||||
// 503 (Wartungsmodus) → sofortiger Page-Reload statt Livewire-Fehlerdialog
|
||||
// 503 (Wartungsmodus) → Reload | 419 (Session abgelaufen) → Login
|
||||
;(function () {
|
||||
var _fetch = window.fetch
|
||||
window.fetch = function () {
|
||||
return _fetch.apply(this, arguments).then(function (resp) {
|
||||
if (resp.status === 503) {
|
||||
window.location.reload()
|
||||
return new Promise(function () {}) // Livewire-Chain abbrechen
|
||||
return new Promise(function () {})
|
||||
}
|
||||
if (resp.status === 419) {
|
||||
window.location.href = '{{ route('login') }}'
|
||||
return new Promise(function () {})
|
||||
}
|
||||
return resp
|
||||
})
|
||||
|
|
|
|||
|
|
@ -17,15 +17,21 @@
|
|||
{{-- Brand --}}
|
||||
<div style="text-align:center;margin-bottom:28px;">
|
||||
<div style="display:inline-flex;align-items:center;justify-content:center;
|
||||
width:52px;height:52px;border-radius:14px;background:var(--mw-v);
|
||||
margin-bottom:14px;box-shadow:0 4px 18px rgba(109,40,217,.25);">
|
||||
<svg width="22" height="22" viewBox="0 0 14 14" fill="none">
|
||||
<rect x=".5" y="2.5" width="13" height="9" rx="1.5" stroke="white" stroke-width="1.3"/>
|
||||
<path d="M.5 5l6.5 4 6.5-4" stroke="white" stroke-width="1.3" stroke-linecap="round"/>
|
||||
width:48px;height:48px;border-radius:13px;
|
||||
background:linear-gradient(135deg,#6366f1 0%,#4338ca 100%);
|
||||
margin-bottom:14px;box-shadow:0 0 20px rgba(99,102,241,.35);">
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
|
||||
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
|
||||
<path d="M15 9 C15 9 12 5 8 5 C9.5 7 10 9 11 10" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
|
||||
<path d="M24 15 C24 15 27 13 28 10 C26 11 24 12 23 13.5" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
|
||||
<circle cx="19" cy="8" r="1.5" fill="white" opacity=".95"/>
|
||||
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div style="font-size:20px;font-weight:700;letter-spacing:-.3px;color:var(--mw-t1);">Webmail</div>
|
||||
<div style="font-size:12px;color:var(--mw-t4);margin-top:3px;">{{ config('app.name') }}</div>
|
||||
<div style="font-family:'Syne',sans-serif;font-size:24px;font-weight:800;letter-spacing:-.3px;line-height:1;margin-bottom:4px">
|
||||
<span style="color:#e8eaf6">Clu</span><span style="color:#6366f1">Bird</span>
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--mw-t4);margin-top:5px;">Webmail</div>
|
||||
</div>
|
||||
|
||||
{{-- Card --}}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
<div style="width:100%;max-width:420px;padding:24px 16px">
|
||||
|
||||
{{-- Logo --}}
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:32px;justify-content:center">
|
||||
<div style="width:32px;height:32px;background:linear-gradient(135deg,#6366f1,#4f46e5);border-radius:8px;display:flex;align-items:center;justify-content:center;box-shadow:0 0 16px rgba(99,102,241,.4)">
|
||||
<svg width="16" height="16" viewBox="0 0 18 18" fill="none">
|
||||
<path d="M9 2L15.5 5.5v7L9 16 2.5 12.5v-7L9 2Z" stroke="white" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<path d="M9 6L12 7.5v3L9 12 6 10.5v-3L9 6Z" fill="white" opacity=".9"/>
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:32px;justify-content:center">
|
||||
<div style="width:36px;height:36px;background:linear-gradient(135deg,#6366f1 0%,#4338ca 100%);border-radius:10px;display:flex;align-items:center;justify-content:center;box-shadow:0 0 16px rgba(99,102,241,.35);flex-shrink:0">
|
||||
<svg width="21" height="21" viewBox="0 0 28 28" fill="none">
|
||||
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
|
||||
<path d="M15 9 C15 9 12 5 8 5 C9.5 7 10 9 11 10" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
|
||||
<path d="M24 15 C24 15 27 13 28 10 C26 11 24 12 23 13.5" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
|
||||
<circle cx="19" cy="8" r="1.5" fill="white" opacity=".95"/>
|
||||
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:14px;font-weight:700;letter-spacing:.3px;color:var(--mw-t1)">CLUBIRD</div>
|
||||
<div style="font-size:11px;color:var(--mw-t4);margin-top:-1px">Control Panel</div>
|
||||
<div style="font-family:'Syne',sans-serif;font-size:22px;font-weight:800;letter-spacing:-.3px;line-height:1"><span style="color:#e8eaf6">Clu</span><span style="color:#6366f1">Bird</span></div>
|
||||
<div style="font-size:9px;font-weight:500;color:var(--mw-t4);text-transform:uppercase;letter-spacing:1.8px;margin-top:2px">Mail Server Management</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
@if(!$dkimOk)
|
||||
<button
|
||||
wire:click="regenerate('{{ $selector ?? ($domain->dkimKeys()->where('is_active', true)->latest()->value('selector') ?? 'mwl1') }}')"
|
||||
wire:click="regenerate('{{ $selector ?? ($domain->dkimKeys()->where('is_active', true)->latest()->value('selector') ?? 'clb1') }}')"
|
||||
wire:loading.attr="disabled"
|
||||
class="inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium
|
||||
border border-slate-600/40 bg-slate-800/40 hover:bg-slate-800 disabled:opacity-50">
|
||||
|
|
|
|||
|
|
@ -20,6 +20,16 @@
|
|||
<option value="50">50 / Seite</option>
|
||||
<option value="100">100 / Seite</option>
|
||||
</select>
|
||||
@if($messages->total() > 0)
|
||||
<button wire:click="deleteCurrentTab"
|
||||
wire:confirm="Alle Einträge in diesem Tab ausblenden?"
|
||||
class="mw-btn-del" title="Tab leeren">
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M2 3.5h10M5.5 3.5V2.5h3v1M6 6v4M8 6v4M3 3.5l.7 8h6.6l.7-8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Tab leeren
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -94,10 +104,16 @@
|
|||
@endif
|
||||
</td>
|
||||
<td class="mq-num">{{ $msg['time'] > 0 ? date('d.m H:i', $msg['time']) : '—' }}</td>
|
||||
<td>
|
||||
<td style="display:flex;gap:4px;align-items:center;justify-content:flex-end">
|
||||
<button wire:click="openMessage('{{ $msg['id'] }}')" class="mq-btn-icon" title="Details">
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M7 6.5v4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="7" cy="4.5" r=".7" fill="currentColor"/></svg>
|
||||
</button>
|
||||
<button wire:click="deleteEntry('{{ $msg['id'] }}')" class="mq-btn-icon" title="Ausblenden"
|
||||
style="color:var(--mw-t4);hover:color:var(--rose-400)">
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M2 3.5h10M5.5 3.5V2.5h3v1M6 6v4M8 6v4M3 3.5l.7 8h6.6l.7-8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
|
|
|
|||
|
|
@ -18,18 +18,33 @@
|
|||
Keine aktiven Banns vorhanden.
|
||||
</div>
|
||||
@else
|
||||
<div style="padding:10px 12px">
|
||||
{{-- Spalten: Dot | IP | Dienst | Status+Zeit | Button --}}
|
||||
<div style="padding:6px 12px 10px">
|
||||
@foreach($rows as $r)
|
||||
<div class="f2b-row {{ $r['style'] }}">
|
||||
<div style="display:flex;align-items:center;gap:10px;min-width:0;flex:1">
|
||||
<div class="f2b-dot {{ $r['style'] }}"></div>
|
||||
<code style="font-size:12.5px;color:var(--mw-t1);letter-spacing:.02em;flex-shrink:0">{{ $r['ip'] }}</code>
|
||||
<span class="f2b-jail-badge" title="{{ $r['jail'] }}">{{ $r['service'] }}</span>
|
||||
<span style="font-size:10.5px;color:{{ $r['style'] === 'permanent' ? 'rgba(252,165,165,.8)' : 'rgba(252,211,77,.8)' }}">{{ $r['label'] }}</span>
|
||||
<div class="f2b-row {{ $r['style'] }}"
|
||||
style="display:grid;grid-template-columns:8px 140px 80px 1fr 28px;align-items:center;gap:0 10px">
|
||||
|
||||
{{-- Dot --}}
|
||||
<div class="f2b-dot {{ $r['style'] }}" style="justify-self:center"></div>
|
||||
|
||||
{{-- IP --}}
|
||||
<code style="font-size:12px;color:var(--mw-t1);letter-spacing:.02em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ $r['ip'] }}</code>
|
||||
|
||||
{{-- Dienst --}}
|
||||
<div style="display:flex;align-items:center;overflow:hidden">
|
||||
<span class="f2b-jail-badge" title="{{ $r['jail'] }}"
|
||||
style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $r['service'] }}</span>
|
||||
</div>
|
||||
|
||||
{{-- Status + Restzeit --}}
|
||||
<div style="display:flex;align-items:center;gap:6px;min-width:0">
|
||||
<span style="font-size:10.5px;white-space:nowrap;color:{{ $r['style'] === 'permanent' ? 'rgba(252,165,165,.8)' : 'rgba(252,211,77,.8)' }}">{{ $r['label'] }}</span>
|
||||
@if($r['remaining'] !== '')
|
||||
<span style="font-size:10.5px;color:var(--mw-t4)">· {{ $r['remaining'] }}</span>
|
||||
<span style="font-size:10.5px;color:var(--mw-t4);white-space:nowrap">{{ $r['remaining'] }}</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Button --}}
|
||||
<button wire:click="unban('{{ $r['ip'] }}','{{ $r['jail'] }}')"
|
||||
class="mbx-act-btn mbx-act-danger" title="Entbannen"
|
||||
wire:confirm="IP {{ $r['ip'] }} aus {{ $r['jail'] }} entbannen?">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
<x-slot:breadcrumb>Mein Profil</x-slot:breadcrumb>
|
||||
|
||||
<div style="max-width:720px;margin:0 auto;display:flex;flex-direction:column;gap:16px">
|
||||
|
||||
{{-- Profil --}}
|
||||
<div class="mbx-section">
|
||||
<div class="mbx-domain-head">
|
||||
<div class="mbx-domain-info">
|
||||
<span class="mbx-badge-mute">Profil</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:16px 18px;display:flex;flex-direction:column;gap:12px">
|
||||
<div>
|
||||
<label class="mw-modal-label">Name</label>
|
||||
<input type="text" wire:model.defer="name" class="mw-modal-input">
|
||||
@error('name') <div class="mw-modal-error">{{ $message }}</div> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="mw-modal-label">E-Mail</label>
|
||||
<input type="text" value="{{ $email }}" class="mw-modal-input" disabled style="opacity:.6">
|
||||
</div>
|
||||
<div>
|
||||
<button wire:click="saveProfile" class="mw-btn-primary" style="font-size:12px;padding:5px 14px">
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="none"><path d="M2 6l3 3 5-5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Passwort --}}
|
||||
<div class="mbx-section">
|
||||
<div class="mbx-domain-head">
|
||||
<div class="mbx-domain-info">
|
||||
<span class="mbx-badge-mute">Passwort ändern</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:16px 18px;display:flex;flex-direction:column;gap:12px">
|
||||
<div>
|
||||
<label class="mw-modal-label">Aktuelles Passwort</label>
|
||||
<input type="password" wire:model.defer="current_password" class="mw-modal-input" autocomplete="current-password">
|
||||
@error('current_password') <div class="mw-modal-error">{{ $message }}</div> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="mw-modal-label">Neues Passwort</label>
|
||||
<input type="password" wire:model.defer="new_password" class="mw-modal-input" autocomplete="new-password">
|
||||
@error('new_password') <div class="mw-modal-error">{{ $message }}</div> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="mw-modal-label">Bestätigung</label>
|
||||
<input type="password" wire:model.defer="new_password_confirmation" class="mw-modal-input" autocomplete="new-password">
|
||||
@error('new_password_confirmation') <div class="mw-modal-error">{{ $message }}</div> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<button wire:click="changePassword" class="mw-btn-primary" style="font-size:12px;padding:5px 14px">
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="none"><path d="M2 6l3 3 5-5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
Passwort setzen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 2FA --}}
|
||||
<div class="mbx-section">
|
||||
<div class="mbx-domain-head">
|
||||
<div class="mbx-domain-info">
|
||||
<span class="mbx-badge-mute">Zwei-Faktor-Authentifizierung</span>
|
||||
</div>
|
||||
@if($totpEnabled)
|
||||
<span class="mbx-badge-ok">Aktiv</span>
|
||||
@else
|
||||
<span class="mbx-badge-warn">Nicht eingerichtet</span>
|
||||
@endif
|
||||
</div>
|
||||
<div style="padding:14px 18px;display:flex;flex-direction:column;gap:12px">
|
||||
|
||||
<div style="display:flex;gap:10px;align-items:flex-start;padding:10px 12px;background:var(--mw-bg4);border:1px solid var(--mw-b2);border-radius:7px">
|
||||
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" style="flex-shrink:0;margin-top:1px;color:var(--mw-t4)">
|
||||
<circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/>
|
||||
<path d="M6.5 6v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
<circle cx="6.5" cy="4" r=".7" fill="currentColor"/>
|
||||
</svg>
|
||||
<div style="font-size:11.5px;color:var(--mw-t3);line-height:1.6">
|
||||
Bei aktiviertem 2FA wird beim Login nach dem Passwort ein
|
||||
<strong style="color:var(--mw-t2)">6-stelliger TOTP-Code</strong>
|
||||
aus deiner Authenticator-App verlangt.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($totpEnabled)
|
||||
<div style="font-size:12px;color:var(--mw-t3)">
|
||||
Dein Konto ist mit TOTP geschützt (Google Authenticator, Authy oder kompatible App).
|
||||
</div>
|
||||
<div>
|
||||
<button wire:click="disableTotp"
|
||||
wire:confirm="2FA wirklich deaktivieren? Dein Konto wird dann nur mit Passwort geschützt."
|
||||
class="mw-btn-del" style="font-size:12px;padding:5px 12px">
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="none"><path d="M2 2l8 8M10 2l-8 8" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||
TOTP deaktivieren
|
||||
</button>
|
||||
</div>
|
||||
@else
|
||||
<div style="font-size:12px;color:var(--mw-t3)">
|
||||
Schütze dein Konto mit einem Einmalcode-Generator (TOTP). Einzurichten in unter einer Minute.
|
||||
</div>
|
||||
<div>
|
||||
<button wire:click="$dispatch('openModal',{component:'ui.system.modal.totp-setup-modal'})"
|
||||
class="mw-btn-primary" style="font-size:12px;padding:5px 14px">
|
||||
<svg width="11" height="11" viewBox="0 0 11 11" fill="none"><path d="M5.5 1v9M1 5.5h9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
TOTP einrichten
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
|
@ -14,7 +14,8 @@
|
|||
<div class="mbx-page-actions">
|
||||
@if($tab === 'domains')
|
||||
<button wire:click="saveDomains" wire:loading.attr="disabled" wire:target="saveDomains" class="mbx-btn-primary">
|
||||
<svg width="12" height="12" viewBox="0 0 14 14" fill="none"><path d="M2 2h8l2 2v8a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1Z" stroke="currentColor" stroke-width="1.2"/><path d="M5 2v3h4V2" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
|
||||
<svg wire:loading.remove wire:target="saveDomains" width="12" height="12" viewBox="0 0 14 14" fill="none"><path d="M2 2h8l2 2v8a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1Z" stroke="currentColor" stroke-width="1.2"/><path d="M5 2v3h4V2" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
|
||||
<svg wire:loading wire:target="saveDomains" width="12" height="12" viewBox="0 0 12 12" fill="none" style="animation:spin .7s linear infinite"><circle cx="6" cy="6" r="4.5" stroke="currentColor" stroke-width="1.5" stroke-dasharray="14 7" stroke-linecap="round"/></svg>
|
||||
<span wire:loading.remove wire:target="saveDomains">Domains speichern</span>
|
||||
<span wire:loading wire:target="saveDomains">Speichert…</span>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ use App\Http\Controllers\UI\Security\SecurityController;
|
|||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/ping', fn() => response('ok', 200))->name('ping');
|
||||
|
||||
Route::get('/', function () {
|
||||
try {
|
||||
$setupDone = \App\Models\Setting::get('setup_completed', '0') === '1';
|
||||
|
|
@ -95,6 +97,9 @@ Route::middleware(['auth.user', 'require2fa'])->name('ui.')->group(function () {
|
|||
// });
|
||||
// });
|
||||
|
||||
#PROFILE ROUTE
|
||||
Route::get('/profile', \App\Livewire\Ui\System\ProfilePage::class)->name('profile');
|
||||
|
||||
#LOGOUT ROUTE
|
||||
Route::post('/logout', [LoginController::class, 'logout'])->name('logout');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ use Illuminate\Support\Facades\Route;
|
|||
// Domain-wrapping and naming are applied in bootstrap/app.php.
|
||||
// This file only defines the routes themselves.
|
||||
|
||||
// Root → redirect to UI admin login; webmail login is at /login
|
||||
Route::get('/', fn() => redirect()->away(config('app.url') . '/login'))->name('root');
|
||||
// Root → redirect to webmail login
|
||||
Route::get('/', fn() => redirect()->to(route('ui.webmail.login')))->name('root');
|
||||
Route::get('/login', Login::class)->name('login');
|
||||
Route::get('/inbox', Inbox::class)->name('inbox');
|
||||
Route::get('/compose', Compose::class)->name('compose');
|
||||
|
|
|
|||
|
|
@ -270,11 +270,36 @@ if [ -d "${STATE_DIR}" ]; then
|
|||
fi
|
||||
|
||||
# --- Phase 4: Postfix + Dovecot mit Mail-Domain-Zertifikat konfigurieren ---
|
||||
if [ -n "${MAIL_HOST}" ]; then
|
||||
# Hostname immer setzen, unabhängig vom Zertifikat
|
||||
if command -v postconf >/dev/null 2>&1; then
|
||||
postconf -e "myhostname = ${MAIL_HOST}"
|
||||
postconf -e "myorigin = ${MAIL_HOST}"
|
||||
postconf -e "mydestination = ${MAIL_HOST}, localhost.localdomain, localhost"
|
||||
fi
|
||||
|
||||
# /etc/aliases bereinigen: root → /dev/null verhindert Bounce-Schleife
|
||||
if [ -f /etc/aliases ]; then
|
||||
# Vorhandene root/clamav-Zeilen ersetzen oder am Ende ergänzen
|
||||
if grep -q '^root:' /etc/aliases; then
|
||||
sed -i 's|^root:.*|root: /dev/null|' /etc/aliases
|
||||
else
|
||||
echo 'root: /dev/null' >> /etc/aliases
|
||||
fi
|
||||
if grep -q '^clamav:' /etc/aliases; then
|
||||
sed -i 's|^clamav:.*|clamav: /dev/null|' /etc/aliases
|
||||
else
|
||||
echo 'clamav: /dev/null' >> /etc/aliases
|
||||
fi
|
||||
command -v newaliases >/dev/null 2>&1 && newaliases
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "${MAIL_HOST}" ] && [ "${MAIL_HAS_CERT}" = "1" ]; then
|
||||
MAIL_CERT="/etc/letsencrypt/live/${MAIL_HOST}/fullchain.pem"
|
||||
MAIL_KEY="/etc/letsencrypt/live/${MAIL_HOST}/privkey.pem"
|
||||
|
||||
# Postfix
|
||||
# Postfix TLS
|
||||
if command -v postconf >/dev/null 2>&1; then
|
||||
postconf -e "smtpd_tls_cert_file = ${MAIL_CERT}"
|
||||
postconf -e "smtpd_tls_key_file = ${MAIL_KEY}"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
#!/usr/bin/env bash
|
||||
# Setzt Postfix myhostname/myorigin und bereinigt /etc/aliases.
|
||||
# Wird von Mailwolt aufgerufen wenn die Mail-Domain gespeichert wird.
|
||||
set -euo pipefail
|
||||
|
||||
MAIL_HOST="${1:-}"
|
||||
if [ -z "${MAIL_HOST}" ]; then
|
||||
echo "Usage: mailwolt-apply-hostname <mail-host>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Nur sichere Hostnamen zulassen
|
||||
if ! echo "${MAIL_HOST}" | grep -qE '^[a-zA-Z0-9][a-zA-Z0-9.\-]+$'; then
|
||||
echo "Ungültiger Hostname: ${MAIL_HOST}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if command -v postconf >/dev/null 2>&1; then
|
||||
postconf -e "myhostname = ${MAIL_HOST}"
|
||||
postconf -e "myorigin = ${MAIL_HOST}"
|
||||
postconf -e "mydestination = ${MAIL_HOST}, localhost.localdomain, localhost"
|
||||
systemctl reload postfix 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [ -f /etc/aliases ]; then
|
||||
if grep -q '^root:' /etc/aliases; then
|
||||
sed -i 's|^root:.*|root: /dev/null|' /etc/aliases
|
||||
else
|
||||
echo 'root: /dev/null' >> /etc/aliases
|
||||
fi
|
||||
if grep -q '^clamav:' /etc/aliases; then
|
||||
sed -i 's|^clamav:.*|clamav: /dev/null|' /etc/aliases
|
||||
else
|
||||
echo 'clamav: /dev/null' >> /etc/aliases
|
||||
fi
|
||||
command -v newaliases >/dev/null 2>&1 && newaliases
|
||||
fi
|
||||
|
||||
echo "mailwolt-apply-hostname fertig"
|
||||
|
|
@ -6,8 +6,8 @@ APP_DIR="/var/www/mailwolt"
|
|||
APP_USER="www-data"
|
||||
OUT_DIR="${APP_DIR}/storage/app/backups"
|
||||
STAMP="$(date +%Y-%m-%d_%H-%M-%S)"
|
||||
OUT_FILE="${OUT_DIR}/mailwolt_${STAMP}.tar.gz"
|
||||
TMP_DIR="$(mktemp -d /tmp/mailwolt_backup_XXXXXX)"
|
||||
OUT_FILE="${OUT_DIR}/clubird_${STAMP}.tar.gz"
|
||||
TMP_DIR="$(mktemp -d /tmp/clubird_backup_XXXXXX)"
|
||||
|
||||
cleanup(){ rm -rf "$TMP_DIR"; }
|
||||
trap cleanup EXIT
|
||||
|
|
|
|||
|
|
@ -202,9 +202,10 @@ ensure_system(){
|
|||
fix_permissions
|
||||
|
||||
# Scripts nach sbin
|
||||
_sbin_install "${APP_DIR}/scripts/mailwolt-apply-domains" /usr/local/sbin/mailwolt-apply-domains
|
||||
_sbin_install "${APP_DIR}/scripts/mailwolt-fetch-tags" /usr/local/sbin/mailwolt-fetch-tags
|
||||
_sbin_install "${APP_DIR}/scripts/update.sh" /usr/local/sbin/mailwolt-update
|
||||
_sbin_install "${APP_DIR}/scripts/mailwolt-apply-domains" /usr/local/sbin/mailwolt-apply-domains
|
||||
_sbin_install "${APP_DIR}/scripts/mailwolt-apply-hostname" /usr/local/sbin/mailwolt-apply-hostname
|
||||
_sbin_install "${APP_DIR}/scripts/mailwolt-fetch-tags" /usr/local/sbin/mailwolt-fetch-tags
|
||||
_sbin_install "${APP_DIR}/scripts/update.sh" /usr/local/sbin/mailwolt-update
|
||||
_sbin_install "${APP_DIR}/scripts/mailwolt-clamav" /usr/local/sbin/mailwolt-clamav
|
||||
_sbin_install "${APP_DIR}/scripts/mailwolt-backup" /usr/local/sbin/mailwolt-backup
|
||||
_sbin_install "${APP_DIR}/scripts/mailwolt-sandbox-sync" /usr/local/sbin/mailwolt-sandbox-sync
|
||||
|
|
@ -216,12 +217,22 @@ ensure_system(){
|
|||
_sudoers_add "$MW_SUDO" 'www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-clamav'
|
||||
_sudoers_add "$MW_SUDO" 'www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-backup'
|
||||
_sudoers_add "$MW_SUDO" 'www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-sandbox-sync *'
|
||||
_sudoers_add "$MW_SUDO" 'www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-apply-hostname *'
|
||||
_sudoers_add "$MW_SUDO" 'www-data ALL=(root) NOPASSWD: /usr/bin/openssl x509 -enddate -noout -in /etc/letsencrypt/live/*/fullchain.pem'
|
||||
# Legacy-Files ebenfalls nachrüsten (falls vorhanden)
|
||||
for f in /etc/sudoers.d/mailwolt-certbot /etc/sudoers.d/mailwolt-dkim; do
|
||||
[[ -f "$f" ]] || continue
|
||||
_sudoers_add "$f" 'www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-backup'
|
||||
done
|
||||
# RSpamd: Loopback als lokale Adresse eintragen (verhindert 127.0.0.1-Einträge in History)
|
||||
local rspamd_opts="/etc/rspamd/local.d/options.inc"
|
||||
if [[ -d /etc/rspamd/local.d ]] && ! grep -q "127.0.0.1" "${rspamd_opts}" 2>/dev/null; then
|
||||
printf 'local_addrs = [127.0.0.1, ::1, 192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12, fd00::/8, 169.254.0.0/16, fe80::/10];\n' \
|
||||
> "${rspamd_opts}"
|
||||
systemctl is-active --quiet rspamd && systemctl reload rspamd || true
|
||||
echo "[✓] RSpamd: local_addrs mit Loopback gesetzt."
|
||||
fi
|
||||
|
||||
# Laravel Scheduler cron (schedule:run muss jede Minute laufen)
|
||||
local cron_file="/etc/cron.d/mailwolt"
|
||||
local cron_line="* * * * * www-data php ${APP_DIR}/artisan schedule:run >> /dev/null 2>&1"
|
||||
|
|
@ -340,6 +351,24 @@ JAILEOF
|
|||
echo "[✓] Fail2Ban Jails installiert (dovecot, postfix, nginx)."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Postfix: myhostname + $myhostname in mydestination eintragen damit System-Mails
|
||||
# (cron, PHP mail()) nicht als Relay-Versuch gewertet werden
|
||||
local POSTCONF=/usr/sbin/postconf
|
||||
local POSTFIX=/usr/sbin/postfix
|
||||
if [[ -x "$POSTCONF" ]]; then
|
||||
local pf_host
|
||||
pf_host=$("$POSTCONF" -h myhostname 2>/dev/null || true)
|
||||
local current_dest
|
||||
current_dest=$("$POSTCONF" -h mydestination 2>/dev/null || true)
|
||||
local need_update=0
|
||||
[[ -n "$pf_host" ]] && ! echo "$current_dest" | grep -qF "$pf_host" && need_update=1
|
||||
if [[ "$need_update" -eq 1 ]]; then
|
||||
"$POSTCONF" -e "mydestination = $pf_host, \$myhostname, localhost.localdomain, localhost"
|
||||
[[ -x "$POSTFIX" ]] && "$POSTFIX" reload >/dev/null 2>&1 || true
|
||||
echo "[✓] Postfix: $pf_host zu mydestination hinzugefügt (System-Mails lokal zugestellt)."
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# -------- Guards --------------------------------------------------------------
|
||||
|
|
@ -533,11 +562,6 @@ sudo -u "${APP_USER}" php "${APP_DIR}/artisan" optimize:clear 2>&1 || true
|
|||
sudo -u "${APP_USER}" php "${APP_DIR}/artisan" optimize 2>&1 || true
|
||||
echo "[✓] Cache aufgebaut."
|
||||
|
||||
# -------- Wartungsmodus beenden ----------------------------------------------
|
||||
artisan_up
|
||||
MAINTENANCE_ACTIVE=0
|
||||
echo "[✓] Update abgeschlossen: ${OLD_VER} → ${NEW_VER} (${OLD_REV:0:7} → ${NEW_REV:0:7})"
|
||||
|
||||
# -------- .env: REVERB-Werte auf Domain-Basis normalisieren -------------------
|
||||
migrate_env_reverb() {
|
||||
local env_file="${APP_DIR}/.env"
|
||||
|
|
@ -597,4 +621,9 @@ migrate_env_reverb() {
|
|||
}
|
||||
migrate_env_reverb
|
||||
|
||||
ensure_system
|
||||
ensure_system
|
||||
|
||||
# -------- Wartungsmodus beenden (als letzter Schritt — erst wenn alles fertig) ---
|
||||
artisan_up
|
||||
MAINTENANCE_ACTIVE=0
|
||||
echo "[✓] Update abgeschlossen: ${OLD_VER} → ${NEW_VER} (${OLD_REV:0:7} → ${NEW_REV:0:7})"
|
||||
Loading…
Reference in New Issue