Compare commits
173 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 | |
|
|
ff28b98555 | |
|
|
3e157f4e56 | |
|
|
d84537b343 | |
|
|
21015e69a3 | |
|
|
fc0ede4840 | |
|
|
aaeea2bb86 | |
|
|
f266228fcf | |
|
|
41de455826 | |
|
|
49463bf34d | |
|
|
eb77ec3342 | |
|
|
ff6002ca0b | |
|
|
1f013b6dcd | |
|
|
dcb4456ba6 | |
|
|
2fb66df19e | |
|
|
f756789cc4 | |
|
|
32cf27bcd2 | |
|
|
74600185ba | |
|
|
919c2013f9 | |
|
|
e23d9058ee | |
|
|
b48c74b676 | |
|
|
e49b1f6c4c | |
|
|
f473ec66d4 | |
|
|
22b6a9e0f2 | |
|
|
93bcb56605 | |
|
|
b6475fea75 | |
|
|
14901a40e3 | |
|
|
20c9100226 | |
|
|
5dc55a9d1c | |
|
|
fc7e1ed0f9 | |
|
|
5f71a8d5e9 | |
|
|
ea7dc3b94d | |
|
|
4671909e14 | |
|
|
653bf55415 | |
|
|
978fad64f4 | |
|
|
23d6d9cb46 | |
|
|
d3012792bc | |
|
|
bbc7cc6c34 | |
|
|
8ea925e4ae | |
|
|
c276a17a98 | |
|
|
597773123f | |
|
|
afad3c3471 | |
|
|
8a546f4f3d | |
|
|
486166c05a | |
|
|
f375ea0215 | |
|
|
fa8e9e7e88 | |
|
|
742c39f91a | |
|
|
2ec10a8a81 | |
|
|
fb6573598d | |
|
|
6bb2d09621 | |
|
|
cdfdcc0756 | |
|
|
c6d6798898 | |
|
|
496912f5ca | |
|
|
2c82555235 | |
|
|
eb17d056b7 | |
|
|
96477ede1c | |
|
|
8ff63dd966 | |
|
|
50a68047fc | |
|
|
a2ba9a7c03 | |
|
|
ea37abacf8 | |
|
|
32a3b51b2a | |
|
|
0375aed2ce | |
|
|
fd8d81fecb | |
|
|
fcbe944e32 | |
|
|
598f0edacf | |
|
|
00f715480f | |
|
|
9673e717d1 | |
|
|
bf76e93103 | |
|
|
dbd7848f68 | |
|
|
cae269fae1 | |
|
|
77fad53655 | |
|
|
de314adebe | |
|
|
7e7d5c1069 | |
|
|
aaccb4bb32 | |
|
|
7ccc7921c1 | |
|
|
a96d8f690a | |
|
|
b0daf839d9 | |
|
|
ce66a84c8d | |
|
|
fa65bc1c2e | |
|
|
5f627dcb0e | |
|
|
3f6f787fd3 | |
|
|
2331ef74f6 | |
|
|
029d959d51 | |
|
|
d0e1d5613c | |
|
|
68e7635f6d | |
|
|
673e9ec1df | |
|
|
0fc6ee81d6 | |
|
|
79b1eeb93c | |
|
|
dc5d8582e1 | |
|
|
a9ad26757b | |
|
|
1bcd93908e | |
|
|
7d738392b2 | |
|
|
6fa77f9f9b | |
|
|
b721b7b0df | |
|
|
ad85bc0326 | |
|
|
7b1274f349 | |
|
|
cbdab12c19 | |
|
|
b83af57e56 | |
|
|
0635d1d9fe | |
|
|
a771f97516 | |
|
|
9d40597842 | |
|
|
140d737231 | |
|
|
09f9cf3553 | |
|
|
1d18e80749 | |
|
|
190b627fd4 | |
|
|
a119686bfa | |
|
|
b107e9a580 | |
|
|
a7f6d8e242 | |
|
|
3e12d7fd70 | |
|
|
c16315711d | |
|
|
3c6325db32 | |
|
|
a51f271069 | |
|
|
e0128312cf | |
|
|
c834b5f85d | |
|
|
7454638506 | |
|
|
f49b92074e | |
|
|
29566499f9 | |
|
|
81f1c9512a | |
|
|
1e053c2bb6 | |
|
|
2369a29161 | |
|
|
26eb3abdcd | |
|
|
4fc31726be | |
|
|
8fa28a4d84 | |
|
|
60ebd0ed16 | |
|
|
093b5a9eea | |
|
|
becc1bd737 | |
|
|
b59e4c53b4 | |
|
|
29ef2b6a2c | |
|
|
1b79454df5 | |
|
|
38d6fdba6f | |
|
|
996c9c19fe | |
|
|
871d69cc2d | |
|
|
f12c412bbe | |
|
|
7f56926b70 | |
|
|
904d60ed2b | |
|
|
8fd9fccc70 | |
|
|
227c623578 | |
|
|
d60d228012 | |
|
|
96e2b4d5ab | |
|
|
5ec7084cbd | |
|
|
240c672198 | |
|
|
bd7bb50a52 | |
|
|
17ed5b18d9 | |
|
|
096e983313 | |
|
|
880b99f1e0 |
|
|
@ -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 = [];
|
||||
|
|
@ -28,7 +28,7 @@ class BackupRun extends Command
|
|||
$script = '/usr/local/sbin/mailwolt-backup';
|
||||
if (file_exists($script)) {
|
||||
$output = [];
|
||||
exec("bash " . escapeshellarg($script) . ' 2>&1', $output, $exitCode);
|
||||
exec('sudo -n ' . escapeshellarg($script) . ' 2>&1', $output, $exitCode);
|
||||
|
||||
$artifact = null;
|
||||
foreach ($output as $line) {
|
||||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -6,62 +6,125 @@ use Illuminate\Console\Command;
|
|||
|
||||
class CheckUpdates extends Command
|
||||
{
|
||||
protected $signature = 'mailwolt:check-updates';
|
||||
protected $description = 'Check for newer MailWolt releases via git tags';
|
||||
protected $signature = 'clubird:check-updates';
|
||||
protected $aliases = ['mailwolt:check-updates'];
|
||||
protected $description = 'Check for newer CluBird releases via git tags';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$currentNorm = $this->readInstalledVersionNorm();
|
||||
$currentRaw = $this->readInstalledVersionRaw() ?? ($currentNorm ? 'v'.$currentNorm : null);
|
||||
|
||||
$appPath = base_path();
|
||||
$appPath = base_path();
|
||||
$remoteFile = '/var/lib/mailwolt/version_remote';
|
||||
|
||||
// www-data hat keine git-Credentials für private Repos.
|
||||
// Fallback-Kette (erste funktionierende Methode gewinnt):
|
||||
// 1. mailwolt-update --check-only (verfügbar wenn update.sh neu genug, sudoers: mailwolt-update)
|
||||
// 2. mailwolt-fetch-tags (verfügbar ab v1.1.268)
|
||||
// Sicherheitscheck: --check-only nur aufrufen wenn das Skript das Flag auch kennt.
|
||||
// Fallback-Kette für Remote-Tags (erste funktionierende Methode gewinnt):
|
||||
// 1. mailwolt-update --check-only (sudoers: mailwolt-update)
|
||||
// 2. mailwolt-fetch-tags (sudoers: mailwolt-fetch-tags)
|
||||
// 3. Gitea/GitHub API (kein Auth nötig wenn Repo öffentlich)
|
||||
// 4. git ls-remote (klappt wenn Credentials im git-Config)
|
||||
// 5. git fetch --tags direkt
|
||||
$updateBin = '/usr/local/sbin/mailwolt-update';
|
||||
$fetchHelper = '/usr/local/sbin/mailwolt-fetch-tags';
|
||||
|
||||
$fetched = false;
|
||||
|
||||
if (file_exists($updateBin) && str_contains((string) @file_get_contents($updateBin), '--check-only')) {
|
||||
shell_exec('sudo -n ' . escapeshellarg($updateBin) . ' --check-only 2>/dev/null');
|
||||
} elseif (file_exists($fetchHelper)) {
|
||||
shell_exec('sudo -n ' . escapeshellarg($fetchHelper) . ' 2>/dev/null');
|
||||
@exec('sudo -n ' . escapeshellarg($updateBin) . ' --check-only 2>/dev/null', $_, $rc);
|
||||
$fetched = ($rc === 0);
|
||||
}
|
||||
|
||||
// Version_remote wurde von einem der Helfer geschrieben; frisch genug wenn < 2h alt
|
||||
$remoteFile = '/var/lib/mailwolt/version_remote';
|
||||
if (!$fetched && file_exists($fetchHelper)) {
|
||||
@exec('sudo -n ' . escapeshellarg($fetchHelper) . ' 2>/dev/null', $_, $rc);
|
||||
$fetched = ($rc === 0);
|
||||
}
|
||||
|
||||
// Gitea/GitHub API – funktioniert ohne Credentials wenn Repo öffentlich
|
||||
if (!$fetched) {
|
||||
$remoteUrl = trim((string) @shell_exec('git -C ' . escapeshellarg($appPath) . ' remote get-url origin 2>/dev/null'));
|
||||
if (preg_match('~https?://([^/]+)/([^/]+/[^/]+?)(?:\.git)?$~', $remoteUrl, $rm)) {
|
||||
$host = $rm[1];
|
||||
$project = $rm[2];
|
||||
// Gitea API
|
||||
$apiUrl = "https://{$host}/api/v1/repos/{$project}/tags?limit=50";
|
||||
$ctx = stream_context_create(['http' => ['timeout' => 5, 'ignore_errors' => true]]);
|
||||
$json = @file_get_contents($apiUrl, false, $ctx);
|
||||
if ($json) {
|
||||
$tags = json_decode($json, true);
|
||||
if (is_array($tags)) {
|
||||
$versions = [];
|
||||
foreach ($tags as $t) {
|
||||
$name = $t['name'] ?? '';
|
||||
if (preg_match('/^v[\d.]+$/', $name)) {
|
||||
$versions[] = $name;
|
||||
}
|
||||
}
|
||||
usort($versions, 'version_compare');
|
||||
$latest = end($versions);
|
||||
if ($latest) {
|
||||
@mkdir(dirname($remoteFile), 0755, true);
|
||||
file_put_contents($remoteFile, $latest);
|
||||
$fetched = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// git ls-remote (klappt wenn Credentials im git-Config hinterlegt sind)
|
||||
if (!$fetched) {
|
||||
$lsOut = [];
|
||||
@exec('git -C ' . escapeshellarg($appPath) . ' ls-remote --tags origin \'v*\' 2>/dev/null', $lsOut);
|
||||
$lsVersions = [];
|
||||
foreach ($lsOut as $line) {
|
||||
if (preg_match('~refs/tags/(v[\d.]+)$~', $line, $m)) {
|
||||
$lsVersions[] = $m[1];
|
||||
}
|
||||
}
|
||||
if (!empty($lsVersions)) {
|
||||
usort($lsVersions, 'version_compare');
|
||||
$latest = end($lsVersions);
|
||||
@mkdir(dirname($remoteFile), 0755, true);
|
||||
file_put_contents($remoteFile, $latest);
|
||||
$fetched = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Direkter git fetch als letzter Fallback
|
||||
if (!$fetched) {
|
||||
@exec('git -C ' . escapeshellarg($appPath) . ' fetch --tags origin 2>/dev/null', $_, $rc);
|
||||
}
|
||||
|
||||
// version_remote von Helfer geschrieben; als frisch wenn < 2h alt
|
||||
$remoteRaw = '';
|
||||
if (file_exists($remoteFile) && (time() - filemtime($remoteFile)) < 7200) {
|
||||
$remoteRaw = trim((string) file_get_contents($remoteFile));
|
||||
}
|
||||
|
||||
$latestTagRaw = $remoteRaw !== '' ? $remoteRaw
|
||||
: trim((string) shell_exec("git -C " . escapeshellarg($appPath) . " tag -l 'v*' --sort=-v:refname 2>/dev/null | head -n1"));
|
||||
// Lokale Tags (nach fetch hoffentlich aktuell)
|
||||
$out = [];
|
||||
@exec("git -C " . escapeshellarg($appPath) . " tag -l 'v*' --sort=-v:refname 2>/dev/null", $out);
|
||||
$latestTagRaw = $remoteRaw !== '' ? $remoteRaw : trim($out[0] ?? '');
|
||||
|
||||
$latestNorm = $this->normalizeVersion($latestTagRaw);
|
||||
|
||||
// Nichts gefunden -> alles leeren
|
||||
if (!$latestNorm) {
|
||||
cache()->forget('updates:latest');
|
||||
cache()->forget('updates:latest_raw');
|
||||
cache()->forget('mailwolt.update_available'); // legacy
|
||||
cache()->forget('mailwolt.update_available');
|
||||
$this->warn('Keine Release-Tags gefunden.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// Nur wenn wirklich neuer als installiert -> Keys setzen
|
||||
if ($currentNorm && version_compare($latestNorm, $currentNorm, '>')) {
|
||||
cache()->forever('updates:latest', $latestNorm);
|
||||
cache()->forever('updates:latest_raw', $latestTagRaw ?: ('v'.$latestNorm));
|
||||
cache()->forever('mailwolt.update_available', $latestNorm); // legacy-kompat
|
||||
cache()->forever('mailwolt.update_available', $latestNorm);
|
||||
$this->info("Update verfügbar: {$latestTagRaw} (installiert: ".($currentRaw ?? $currentNorm).")");
|
||||
} else {
|
||||
// Kein Update -> Keys löschen
|
||||
cache()->forget('updates:latest');
|
||||
cache()->forget('updates:latest_raw');
|
||||
cache()->forget('mailwolt.update_available'); // legacy
|
||||
cache()->forget('mailwolt.update_available');
|
||||
$this->info("Aktuell (installiert: ".($currentRaw ?? $currentNorm ?? 'unbekannt').").");
|
||||
}
|
||||
|
||||
|
|
@ -73,28 +136,35 @@ class CheckUpdates extends Command
|
|||
|
||||
private function readInstalledVersionNorm(): ?string
|
||||
{
|
||||
// Lokal: git describe gibt immer den aktuellen Stand
|
||||
if (app()->isLocal()) {
|
||||
$tag = @trim((string) shell_exec('git -C ' . escapeshellarg(base_path()) . ' describe --tags --abbrev=0 2>/dev/null'));
|
||||
$v = $this->normalizeVersion($tag);
|
||||
if ($v) return $v;
|
||||
// version_raw ist die zuverlässigste Quelle – wird vom Update-Script geschrieben
|
||||
$rawVer = $this->normalizeVersion($this->readInstalledVersionRaw() ?? '');
|
||||
|
||||
$fileVer = null;
|
||||
foreach (['/var/lib/mailwolt/version', base_path('VERSION')] as $p) {
|
||||
$raw = @trim(@file_get_contents($p) ?: '');
|
||||
if ($raw !== '') { $fileVer = $this->normalizeVersion($raw); break; }
|
||||
}
|
||||
|
||||
$paths = [
|
||||
'/var/lib/mailwolt/version',
|
||||
base_path('VERSION'),
|
||||
];
|
||||
foreach ($paths as $p) {
|
||||
$raw = @trim(@file_get_contents($p) ?: '');
|
||||
if ($raw !== '') return $this->normalizeVersion($raw);
|
||||
// version_raw bevorzugen (echter installierter Stand)
|
||||
// Nur wenn version_raw fehlt: version-Datei oder git-Tag als Fallback
|
||||
if ($rawVer) {
|
||||
return $rawVer;
|
||||
}
|
||||
$raw = $this->readInstalledVersionRaw();
|
||||
return $raw ? $this->normalizeVersion($raw) : null;
|
||||
|
||||
// git-Tag als letzter Fallback (funktioniert auf Dev-Servern)
|
||||
$out = [];
|
||||
@exec('git -C ' . escapeshellarg(base_path()) . ' describe --tags --abbrev=0 2>/dev/null', $out);
|
||||
$gitVer = $this->normalizeVersion(trim($out[0] ?? ''));
|
||||
|
||||
if ($gitVer && (!$fileVer || version_compare($gitVer, $fileVer, '>'))) {
|
||||
return $gitVer;
|
||||
}
|
||||
return $fileVer ?: null;
|
||||
}
|
||||
|
||||
private function readInstalledVersionRaw(): ?string
|
||||
{
|
||||
$p = '/var/lib/mailwolt/version_raw'; // vom Wrapper (z.B. "v1.0.25" oder "v1.0.25-3-gabcd")
|
||||
$p = '/var/lib/mailwolt/version_raw';
|
||||
$raw = @trim(@file_get_contents($p) ?: '');
|
||||
return $raw !== '' ? $raw : null;
|
||||
}
|
||||
|
|
@ -104,8 +174,8 @@ class CheckUpdates extends Command
|
|||
if (!$v) return null;
|
||||
$v = trim($v);
|
||||
if ($v === '') return null;
|
||||
$v = ltrim($v, "vV \t\n\r\0\x0B"); // führendes v entfernen
|
||||
$v = preg_replace('/-.*$/', '', $v); // Build-/dirty-Suffix abschneiden
|
||||
$v = ltrim($v, "vV \t\n\r\0\x0B");
|
||||
$v = preg_replace('/-.*$/', '', $v);
|
||||
return $v !== '' ? $v : null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,12 +6,13 @@ use Illuminate\Console\Command;
|
|||
|
||||
class MailwoltRestart extends Command
|
||||
{
|
||||
protected $signature = 'mailwolt:restart-services';
|
||||
protected $signature = 'clubird:restart-services';
|
||||
protected $aliases = ['mailwolt:restart-services'];
|
||||
protected $description = 'Restart or reload MailWolt-related system services';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$units = config('mailwolt.units', []);
|
||||
$units = config('clubird.units', []);
|
||||
|
||||
foreach ($units as $u) {
|
||||
$base = (string)($u['name'] ?? '');
|
||||
|
|
@ -45,12 +46,12 @@ class MailwoltRestart extends Command
|
|||
|
||||
//class MailwoltRestart extends Command
|
||||
//{
|
||||
// protected $signature = 'mailwolt:restart-services';
|
||||
// protected $signature = 'clubird:restart-services';
|
||||
// protected $description = 'Restart or reload MailWolt-related system services';
|
||||
//
|
||||
// public function handle(): int
|
||||
// {
|
||||
// $units = config('mailwolt.units', []);
|
||||
// $units = config('clubird.units', []);
|
||||
// $allowed = ['reload','restart','try-reload-or-restart'];
|
||||
//
|
||||
// foreach ($units as $u) {
|
||||
|
|
@ -90,12 +91,12 @@ class MailwoltRestart extends Command
|
|||
//
|
||||
//class MailwoltRestart extends Command
|
||||
//{
|
||||
// protected $signature = 'mailwolt:restart-services';
|
||||
// protected $signature = 'clubird:restart-services';
|
||||
// protected $description = 'Restart or reload MailWolt-related system services';
|
||||
//
|
||||
// public function handle(): int
|
||||
// {
|
||||
// $units = config('mailwolt.units', []);
|
||||
// $units = config('clubird.units', []);
|
||||
//
|
||||
// foreach ($units as $u) {
|
||||
// $unit = rtrim($u['name'] ?? '', '.service') . '.service';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class MigrateConfigNames extends Command
|
||||
{
|
||||
protected $signature = 'clubird:migrate-config-names {--dry-run : Nur anzeigen was gemacht würde}';
|
||||
protected $description = 'Benennt server-seitige Config-Dateien von mailwolt-* auf generische Namen um';
|
||||
|
||||
private array $renames = [
|
||||
'/etc/rspamd/local.d/mailwolt-actions.conf' => '/etc/rspamd/local.d/actions.conf',
|
||||
'/etc/dovecot/conf.d/99-mailwolt-tls.conf' => '/etc/dovecot/conf.d/99-tls.conf',
|
||||
'/etc/postfix/mailwolt-tls.cf' => '/etc/postfix/tls.cf',
|
||||
'/etc/fail2ban/jail.d/mailwolt-whitelist.local' => '/etc/fail2ban/jail.d/whitelist.local',
|
||||
'/etc/fail2ban/jail.d/00-mailwolt-defaults.local'=> '/etc/fail2ban/jail.d/00-defaults.local',
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dry = $this->option('dry-run');
|
||||
|
||||
foreach ($this->renames as $old => $new) {
|
||||
if (!file_exists($old)) {
|
||||
$this->line(" <fg=gray>übersprungen</> {$old} (nicht vorhanden)");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file_exists($new) && !is_link($new)) {
|
||||
$this->line(" <fg=yellow>bereits vorhanden</> {$new}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dry) {
|
||||
$this->line(" <fg=cyan>[dry-run]</> mv {$old} → {$new}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Als root direkt umbenennen; als www-data via sudo (sudo mv muss in sudoers sein)
|
||||
if (posix_getuid() === 0) {
|
||||
$ok = @rename($old, $new);
|
||||
$errMsg = error_get_last()['message'] ?? '';
|
||||
} else {
|
||||
@exec('sudo -n mv ' . escapeshellarg($old) . ' ' . escapeshellarg($new) . ' 2>&1', $out, $rc);
|
||||
$ok = ($rc === 0);
|
||||
$errMsg = implode(' ', $out);
|
||||
}
|
||||
|
||||
if ($ok) {
|
||||
$this->info(" umbenannt: {$old} → {$new}");
|
||||
} else {
|
||||
$this->error(" FEHLER bei {$old}: {$errMsg}");
|
||||
$this->line(" → manuell: <fg=yellow>mv {$old} {$new}</>");
|
||||
}
|
||||
}
|
||||
|
||||
if (!$dry) {
|
||||
$this->info('Fertig. Dienste ggf. neu starten: rspamd, dovecot, postfix, fail2ban');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class MigrateDovecot24 extends Command
|
||||
{
|
||||
protected $signature = 'clubird:migrate-dovecot24 {--dry-run : Nur anzeigen was geändert würde}';
|
||||
protected $description = 'Migriert Dovecot-Config von 2.3 auf 2.4 Syntax';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dry = $this->option('dry-run');
|
||||
|
||||
// 1) dovecot.conf: dovecot_config_version als erste Zeile
|
||||
$this->fixDovecotConf($dry);
|
||||
|
||||
// 2) 10-auth.conf: disable_plaintext_auth → auth_allow_cleartext
|
||||
$this->fixAuthConf($dry);
|
||||
|
||||
// 3) 10-mail.conf: mail_location → mail_driver + mail_path
|
||||
$this->fixMailConf($dry);
|
||||
|
||||
// 4) 10-master.conf: einzeilige Blöcke aufsplitten
|
||||
$this->fixMasterConf($dry);
|
||||
|
||||
// 5) 10-ssl.conf: ssl_cert/ssl_key → ssl_server_cert_file/ssl_server_key_file
|
||||
$this->fixSslConf($dry);
|
||||
|
||||
// 6) auth-sql.conf.ext: neue passdb/userdb-Syntax
|
||||
$this->fixAuthSqlConf($dry);
|
||||
|
||||
if (!$dry) {
|
||||
$this->info('Fertig. Starte dovecot neu…');
|
||||
@exec('systemctl restart dovecot 2>&1', $out, $rc);
|
||||
if ($rc === 0) {
|
||||
$this->info('Dovecot erfolgreich gestartet.');
|
||||
} else {
|
||||
$this->error('Dovecot-Neustart fehlgeschlagen: ' . implode("\n", $out));
|
||||
$this->line('Prüfen mit: doveconf -n 2>&1');
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function fixDovecotConf(bool $dry): void
|
||||
{
|
||||
$file = '/etc/dovecot/dovecot.conf';
|
||||
$content = @file_get_contents($file);
|
||||
if ($content === false) { $this->warn("Nicht gefunden: {$file}"); return; }
|
||||
|
||||
if (str_starts_with(trim($content), 'dovecot_config_version')) {
|
||||
// Falsche Version ersetzen
|
||||
$new = preg_replace('/^dovecot_config_version\s*=\s*\S+/m', 'dovecot_config_version = 2.4.1', $content);
|
||||
} else {
|
||||
$new = "dovecot_config_version = 2.4.1\n" . $content;
|
||||
}
|
||||
|
||||
if ($new === $content) { $this->line(" ok (unverändert): {$file}"); return; }
|
||||
|
||||
if ($dry) { $this->line(" [dry-run] würde dovecot_config_version ergänzen in {$file}"); return; }
|
||||
file_put_contents($file, $new);
|
||||
$this->info(" aktualisiert: {$file}");
|
||||
}
|
||||
|
||||
private function fixMailConf(bool $dry): void
|
||||
{
|
||||
$file = '/etc/dovecot/conf.d/10-mail.conf';
|
||||
$content = @file_get_contents($file);
|
||||
if ($content === false) { $this->warn("Nicht gefunden: {$file}"); return; }
|
||||
|
||||
$new = preg_replace_callback(
|
||||
'/^mail_location\s*=\s*(\w+):(.+)$/m',
|
||||
function ($m) {
|
||||
return "mail_driver = {$m[1]}\nmail_path = {$m[2]}";
|
||||
},
|
||||
$content
|
||||
);
|
||||
|
||||
if ($new === $content) { $this->line(" ok (unverändert): {$file}"); return; }
|
||||
|
||||
if ($dry) { $this->line(" [dry-run] würde mail_location aufteilen in {$file}"); return; }
|
||||
file_put_contents($file, $new);
|
||||
$this->info(" aktualisiert: {$file}");
|
||||
}
|
||||
|
||||
private function fixMasterConf(bool $dry): void
|
||||
{
|
||||
$file = '/etc/dovecot/conf.d/10-master.conf';
|
||||
$content = @file_get_contents($file);
|
||||
if ($content === false) { $this->warn("Nicht gefunden: {$file}"); return; }
|
||||
|
||||
// Einzeilige Blöcke "name { key = val }" → mehrzeilig
|
||||
$new = preg_replace_callback(
|
||||
'/^(\s*)(\S+)\s*\{\s*([^}]+)\s*\}(\s*)$/m',
|
||||
function ($m) {
|
||||
$indent = $m[1];
|
||||
$name = $m[2];
|
||||
$inner = trim($m[3]);
|
||||
$pairs = preg_split('/\s+(?=\w+=)/', $inner);
|
||||
$body = implode("\n{$indent} ", array_map('trim', $pairs));
|
||||
return "{$indent}{$name} {\n{$indent} {$body}\n{$indent}}";
|
||||
},
|
||||
$content
|
||||
);
|
||||
|
||||
if ($new === $content) { $this->line(" ok (unverändert): {$file}"); return; }
|
||||
|
||||
if ($dry) { $this->line(" [dry-run] würde einzeilige Blöcke aufsplitten in {$file}"); return; }
|
||||
file_put_contents($file, $new);
|
||||
$this->info(" aktualisiert: {$file}");
|
||||
}
|
||||
|
||||
private function fixSslConf(bool $dry): void
|
||||
{
|
||||
$file = '/etc/dovecot/conf.d/10-ssl.conf';
|
||||
$content = @file_get_contents($file);
|
||||
if ($content === false) { $this->warn("Nicht gefunden: {$file}"); return; }
|
||||
|
||||
// ssl_cert = </path> → ssl_server_cert_file = /path
|
||||
$new = preg_replace('/^ssl_cert\s*=\s*<(.+)$/m', 'ssl_server_cert_file = $1', $content);
|
||||
$new = preg_replace('/^ssl_key\s*=\s*<(.+)$/m', 'ssl_server_key_file = $1', $new);
|
||||
// Direkte Pfade ohne < (falls schon teilweise migriert)
|
||||
$new = preg_replace('/^ssl_cert\s*=\s*(?!<)(.+)$/m', 'ssl_server_cert_file = $1', $new);
|
||||
$new = preg_replace('/^ssl_key\s*=\s*(?!<)(.+)$/m', 'ssl_server_key_file = $1', $new);
|
||||
|
||||
// dovecot_storage_version in dovecot.conf (wenn noch nicht vorhanden)
|
||||
$mainConf = '/etc/dovecot/dovecot.conf';
|
||||
$mainContent = (string) @file_get_contents($mainConf);
|
||||
if (!str_contains($mainContent, 'dovecot_storage_version')) {
|
||||
if (!$dry) {
|
||||
file_put_contents($mainConf, $mainContent . "\ndovecot_storage_version = 2.4.1\n");
|
||||
$this->info(" dovecot_storage_version ergänzt in {$mainConf}");
|
||||
} else {
|
||||
$this->line(" [dry-run] würde dovecot_storage_version ergänzen in {$mainConf}");
|
||||
}
|
||||
}
|
||||
|
||||
if ($new === $content) { $this->line(" ok (unverändert): {$file}"); return; }
|
||||
|
||||
if ($dry) { $this->line(" [dry-run] würde ssl_cert/ssl_key umbenennen in {$file}"); return; }
|
||||
file_put_contents($file, $new);
|
||||
$this->info(" aktualisiert: {$file}");
|
||||
}
|
||||
|
||||
private function fixAuthConf(bool $dry): void
|
||||
{
|
||||
$file = '/etc/dovecot/conf.d/10-auth.conf';
|
||||
$content = @file_get_contents($file);
|
||||
if ($content === false) { $this->warn("Nicht gefunden: {$file}"); return; }
|
||||
|
||||
$new = str_replace('disable_plaintext_auth = yes', 'auth_allow_cleartext = no', $content);
|
||||
$new = str_replace('disable_plaintext_auth = no', 'auth_allow_cleartext = yes', $new);
|
||||
|
||||
if ($new === $content) { $this->line(" ok (unverändert): {$file}"); return; }
|
||||
|
||||
if ($dry) { $this->line(" [dry-run] würde disable_plaintext_auth ersetzen in {$file}"); return; }
|
||||
file_put_contents($file, $new);
|
||||
$this->info(" aktualisiert: {$file}");
|
||||
}
|
||||
|
||||
private function fixAuthSqlConf(bool $dry): void
|
||||
{
|
||||
$file = '/etc/dovecot/conf.d/auth-sql.conf.ext';
|
||||
$sqlConf = '/etc/dovecot/dovecot-sql.conf.ext';
|
||||
|
||||
// Alte Werte aus dovecot-sql.conf.ext lesen
|
||||
$sqlRaw = (string) @file_get_contents($sqlConf);
|
||||
$host = $this->parseSqlValue($sqlRaw, 'host') ?: '127.0.0.1';
|
||||
$dbname = $this->parseSqlValue($sqlRaw, 'dbname') ?: 'mailwolt';
|
||||
$user = $this->parseSqlValue($sqlRaw, 'user') ?: 'mailwolt';
|
||||
$pass = $this->parseSqlValue($sqlRaw, 'password') ?: '';
|
||||
$scheme = $this->parseSqlValue($sqlRaw, 'default_pass_scheme') ?: 'BLF-CRYPT';
|
||||
|
||||
// password_query extrahieren (auch aus Kommentaren)
|
||||
$query = $this->parsePasswordQuery($sqlRaw);
|
||||
|
||||
$new = "mysql {$host} {\n"
|
||||
. " dbname = {$dbname}\n"
|
||||
. " user = {$user}\n"
|
||||
. " password = {$pass}\n"
|
||||
. "}\n\n"
|
||||
. "passdb sql {\n"
|
||||
. " default_password_scheme = {$scheme}\n"
|
||||
. " query = {$query}\n"
|
||||
. "}\n\n"
|
||||
. "userdb static {\n"
|
||||
. " fields {\n"
|
||||
. " uid = vmail\n"
|
||||
. " gid = vmail\n"
|
||||
. " home = /var/mail/vhosts/%{user|domain}/%{user|username}\n"
|
||||
. " }\n"
|
||||
. "}\n";
|
||||
|
||||
if ($dry) {
|
||||
$this->line(" [dry-run] würde {$file} neu schreiben:");
|
||||
$this->line($new);
|
||||
return;
|
||||
}
|
||||
|
||||
file_put_contents($file, $new);
|
||||
$this->info(" neu geschrieben: {$file}");
|
||||
}
|
||||
|
||||
private function parseSqlValue(string $content, string $key): ?string
|
||||
{
|
||||
// Aus connect-Zeile: connect = host=x dbname=y user=z password=w
|
||||
if (preg_match('/^connect\s*=\s*(.+)/m', $content, $m)) {
|
||||
$connect = $m[1];
|
||||
if (preg_match('/' . preg_quote($key, '/') . '\s*=\s*(\S+)/i', $connect, $m2)) {
|
||||
return trim($m2[1]);
|
||||
}
|
||||
}
|
||||
// Direkte Zeile: key = value
|
||||
if (preg_match('/^' . preg_quote($key, '/') . '\s*=\s*(.+)/m', $content, $m)) {
|
||||
return trim($m[1]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function parsePasswordQuery(string $content): string
|
||||
{
|
||||
// Auskommentierte oder aktive password_query / query Zeile
|
||||
if (preg_match('/^#?\s*password_query\s*=\s*(.+)/m', $content, $m)) {
|
||||
$q = trim($m[1]);
|
||||
// %u → %{user}
|
||||
$q = str_replace("'%u'", "'%{user}'", $q);
|
||||
return rtrim($q, ';');
|
||||
}
|
||||
return "SELECT email AS user, password_hash AS password FROM mail_users WHERE email = '%{user}' AND is_active = 1 LIMIT 1";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class MigrateEnvReverb extends Command
|
||||
{
|
||||
protected $signature = 'clubird:migrate-env-reverb';
|
||||
protected $aliases = ['mailwolt:migrate-env-reverb'];
|
||||
protected $description = 'Migriert veraltete REVERB_* .env-Werte auf Domain-Basis';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$env = base_path('.env');
|
||||
if (!file_exists($env)) {
|
||||
$this->warn('.env nicht gefunden.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$content = file_get_contents($env);
|
||||
|
||||
// APP_HOST ermitteln: .env → APP_URL → DB-Setting ui_domain
|
||||
$appHost = $this->extractVar($content, 'APP_HOST');
|
||||
if (!$appHost || preg_match('/^\d+\.\d+\.\d+\.\d+$/', $appHost)) {
|
||||
$appUrl = $this->extractVar($content, 'APP_URL');
|
||||
$appHost = parse_url($appUrl ?: '', PHP_URL_HOST) ?: '';
|
||||
}
|
||||
if (!$appHost || preg_match('/^\d+\.\d+\.\d+\.\d+$/', $appHost)) {
|
||||
try {
|
||||
$appHost = (string) \App\Models\Setting::get('ui_domain', '');
|
||||
} catch (\Throwable) {
|
||||
$appHost = '';
|
||||
}
|
||||
}
|
||||
|
||||
if (!$appHost || preg_match('/^\d+\.\d+\.\d+\.\d+$/', $appHost)) {
|
||||
$this->warn('Kein gültiger Hostname gefunden – Migration übersprungen.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$scheme = $this->detectScheme($appHost, $content);
|
||||
$correctPort = $scheme === 'https' ? '443' : '80';
|
||||
$viteHost = $this->extractVar($content, 'VITE_REVERB_HOST');
|
||||
$currentPort = $this->extractVar($content, 'REVERB_PORT');
|
||||
|
||||
$hostOk = ($viteHost === '${REVERB_HOST}' || $viteHost === $appHost);
|
||||
$portOk = ($currentPort === $correctPort);
|
||||
|
||||
if ($hostOk && $portOk) {
|
||||
$this->info('REVERB-Werte bereits korrekt.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$port = $correctPort;
|
||||
|
||||
$fixes = [
|
||||
'REVERB_HOST' => '${APP_HOST}',
|
||||
'REVERB_PORT' => $port,
|
||||
'REVERB_SCHEME' => $scheme,
|
||||
'REVERB_PATH' => '/ws',
|
||||
'REVERB_SERVER_HOST' => '127.0.0.1',
|
||||
'REVERB_SERVER_PORT' => '8080',
|
||||
'REVERB_SERVER_SCHEME' => 'http',
|
||||
'REVERB_SERVER_PATH' => '',
|
||||
'VITE_REVERB_HOST' => '${REVERB_HOST}',
|
||||
'VITE_REVERB_PORT' => '${REVERB_PORT}',
|
||||
'VITE_REVERB_SCHEME' => '${REVERB_SCHEME}',
|
||||
'VITE_REVERB_PATH' => '${REVERB_PATH}',
|
||||
];
|
||||
|
||||
foreach ($fixes as $key => $val) {
|
||||
if (preg_match("/^{$key}=/m", $content)) {
|
||||
$content = preg_replace("/^{$key}=.*/m", "{$key}={$val}", $content);
|
||||
} else {
|
||||
$content .= "\n{$key}={$val}";
|
||||
}
|
||||
}
|
||||
|
||||
// APP_HOST setzen falls fehlend
|
||||
if (!$this->extractVar($content, 'APP_HOST')) {
|
||||
$content .= "\nAPP_HOST={$appHost}";
|
||||
}
|
||||
|
||||
file_put_contents($env, $content);
|
||||
$this->info("REVERB .env migriert für Host: {$appHost}");
|
||||
|
||||
// Assets neu bauen damit wsHost korrekt eingebacken wird
|
||||
$buildLog = base_path('../mailwolt-frontend-build.log');
|
||||
exec('cd ' . escapeshellarg(base_path()) . ' && npm run build --silent 2>/dev/null', $out, $rc);
|
||||
if ($rc !== 0) {
|
||||
$this->warn('npm run build fehlgeschlagen – bitte manuell ausführen.');
|
||||
} else {
|
||||
$this->info('Assets neu gebaut.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function detectScheme(string $host, string $envContent): string
|
||||
{
|
||||
// 1. APP_URL in .env bereits https?
|
||||
$appUrl = $this->extractVar($envContent, 'APP_URL');
|
||||
if (str_starts_with($appUrl, 'https://')) return 'https';
|
||||
|
||||
// 2. nginx-Konfiguration prüfen (world-readable)
|
||||
foreach (glob('/etc/nginx/sites-enabled/*') ?: [] as $f) {
|
||||
$c = @file_get_contents($f) ?: '';
|
||||
if (str_contains($c, $host) && str_contains($c, 'ssl_certificate')) return 'https';
|
||||
}
|
||||
|
||||
// 3. letsencrypt-Pfade (falls doch lesbar)
|
||||
if (file_exists("/etc/letsencrypt/renewal/{$host}.conf")
|
||||
|| is_dir("/etc/letsencrypt/live/{$host}")
|
||||
|| file_exists("/etc/letsencrypt/live/{$host}/fullchain.pem")) {
|
||||
return 'https';
|
||||
}
|
||||
|
||||
return 'http';
|
||||
}
|
||||
|
||||
private function extractVar(string $content, string $key): string
|
||||
{
|
||||
preg_match("/^{$key}=(.*)$/m", $content, $m);
|
||||
return trim($m[1] ?? '', " \t\"'");
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
//
|
||||
//class ProvisionCert extends Command
|
||||
//{
|
||||
//// protected $signature = 'mailwolt:provision-cert
|
||||
//// protected $signature = 'clubird:provision-cert
|
||||
//// {domain : z.B. mail.example.com}
|
||||
//// {--email= : E-Mail für Let\'s Encrypt}
|
||||
//// {--self-signed : Statt LE ein self-signed Zertifikat erzeugen}';
|
||||
|
|
|
|||
|
|
@ -7,11 +7,12 @@ use Illuminate\Console\Command;
|
|||
|
||||
class WizardDomains extends Command
|
||||
{
|
||||
protected $signature = 'mailwolt:wizard-domains
|
||||
protected $signature = 'clubird:wizard-domains
|
||||
{--ui= : UI-Domain}
|
||||
{--mail= : Mail-Domain}
|
||||
{--webmail= : Webmail-Domain}
|
||||
{--ssl=1 : SSL automatisch (1/0)}';
|
||||
protected $aliases = ['mailwolt:wizard-domains'];
|
||||
|
||||
protected $description = 'Wizard: Domains einrichten mit Status-Dateien';
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,10 +26,10 @@ class ValidateHost
|
|||
return true;
|
||||
}
|
||||
|
||||
$base = config('mailwolt.domain.base');
|
||||
$uiSub = config('mailwolt.domain.ui');
|
||||
$mtaSub = config('mailwolt.domain.mail');
|
||||
$wmHost = config('mailwolt.domain.webmail_host');
|
||||
$base = config('clubird.domain.base');
|
||||
$uiSub = config('clubird.domain.ui');
|
||||
$mtaSub = config('clubird.domain.mail');
|
||||
$wmHost = config('clubird.domain.webmail_host');
|
||||
|
||||
$allowed = array_filter([
|
||||
$wmHost,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ClamavEnable implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $timeout = 120;
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
exec('sudo -n /usr/local/sbin/mailwolt-clamav enable 2>&1', $out, $rc);
|
||||
\Log::info('ClamavEnable job', ['rc' => $rc, 'out' => $out]);
|
||||
if ($rc !== 0) {
|
||||
throw new \RuntimeException('mailwolt-clamav enable failed (rc=' . $rc . '): ' . implode(' ', $out));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ class ProvisionCertJob implements ShouldQueue
|
|||
if ($this->useLetsEncrypt) {
|
||||
$this->emit($task, 'running', 'Let’s Encrypt wird ausgeführt…', $mode);
|
||||
|
||||
$exit = Artisan::call('mailwolt:provision-cert', [
|
||||
$exit = Artisan::call('clubird:provision-cert', [
|
||||
'domain' => $this->domain,
|
||||
'--email' => $this->email ?? '',
|
||||
]);
|
||||
|
|
@ -83,14 +83,14 @@ class ProvisionCertJob implements ShouldQueue
|
|||
|
||||
// Fallback → self-signed
|
||||
$mode = 'self-signed';
|
||||
$exit = Artisan::call('mailwolt:provision-cert', [
|
||||
$exit = Artisan::call('clubird:provision-cert', [
|
||||
'domain' => $this->domain,
|
||||
'--self-signed' => true,
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$this->emit($task, 'running', 'Self-Signed Zertifikat wird erstellt…', $mode);
|
||||
$exit = Artisan::call('mailwolt:provision-cert', [
|
||||
$exit = Artisan::call('clubird:provision-cert', [
|
||||
'domain' => $this->domain,
|
||||
'--self-signed' => true,
|
||||
]);
|
||||
|
|
@ -120,7 +120,7 @@ class ProvisionCertJob implements ShouldQueue
|
|||
// $task->update(['message' => 'Let’s Encrypt wird ausgeführt…']);
|
||||
// $this->syncCache($task);
|
||||
//
|
||||
// $exit = Artisan::call('mailwolt:provision-cert', [
|
||||
// $exit = Artisan::call('clubird:provision-cert', [
|
||||
// 'domain' => $this->domain,
|
||||
// '--email' => $this->email ?? '',
|
||||
// ]);
|
||||
|
|
@ -131,7 +131,7 @@ class ProvisionCertJob implements ShouldQueue
|
|||
// $this->syncCache($task);
|
||||
//
|
||||
// // Fallback: Self-Signed
|
||||
// $exit = Artisan::call('mailwolt:provision-cert', [
|
||||
// $exit = Artisan::call('clubird:provision-cert', [
|
||||
// 'domain' => $this->domain,
|
||||
// '--self-signed' => true,
|
||||
// ]);
|
||||
|
|
@ -140,7 +140,7 @@ class ProvisionCertJob implements ShouldQueue
|
|||
// $task->update(['message' => 'Self-Signed wird erstellt…']);
|
||||
// $this->syncCache($task);
|
||||
//
|
||||
// $exit = Artisan::call('mailwolt:provision-cert', [
|
||||
// $exit = Artisan::call('clubird:provision-cert', [
|
||||
// 'domain' => $this->domain,
|
||||
// '--self-signed' => true,
|
||||
// ]);
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ class Wizard extends Component
|
|||
$ssl = $this->skipSsl ? 0 : 1;
|
||||
$artisan = base_path('artisan');
|
||||
$cmd = sprintf(
|
||||
'nohup php %s mailwolt:wizard-domains --ui=%s --mail=%s --webmail=%s --ssl=%d > /dev/null 2>&1 &',
|
||||
'nohup php %s clubird:wizard-domains --ui=%s --mail=%s --webmail=%s --ssl=%d > /dev/null 2>&1 &',
|
||||
escapeshellarg($artisan),
|
||||
escapeshellarg($this->ui_domain),
|
||||
escapeshellarg($this->mail_domain),
|
||||
|
|
@ -197,7 +197,7 @@ class Wizard extends Component
|
|||
$ssl = $this->skipSsl ? 0 : 1;
|
||||
$artisan = base_path('artisan');
|
||||
$cmd = sprintf(
|
||||
'nohup php %s mailwolt:wizard-domains --ui=%s --mail=%s --webmail=%s --ssl=%d > /dev/null 2>&1 &',
|
||||
'nohup php %s clubird:wizard-domains --ui=%s --mail=%s --webmail=%s --ssl=%d > /dev/null 2>&1 &',
|
||||
escapeshellarg($artisan),
|
||||
escapeshellarg($this->ui_domain),
|
||||
escapeshellarg($this->mail_domain),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -62,9 +62,13 @@ class DomainDnsModal extends ModalComponent
|
|||
$ipv6 = $ips['ipv6'];
|
||||
|
||||
$this->zone = $this->extractZone($d->domain);
|
||||
$mta_sub = env('MTA_SUB');
|
||||
$base = env('BASE_DOMAIN');
|
||||
$mailServerFqdn = "{$mta_sub}.{$base}";
|
||||
|
||||
// Setting::get('mail_domain') liefert den vollen MTA-FQDN (z.B. mail.example.com)
|
||||
// Fallback auf config-Werte, die env() sicher kapseln
|
||||
$mailDomainSetting = strtolower(trim((string) \App\Models\Setting::get('mail_domain', '')));
|
||||
$mta_sub = config('mailpool.mta_sub', 'mail');
|
||||
$base = config('mailpool.platform_zone', 'example.com');
|
||||
$mailServerFqdn = $mailDomainSetting ?: "{$mta_sub}.{$base}";
|
||||
|
||||
// --- Infrastruktur (global) ---
|
||||
$this->static = [
|
||||
|
|
@ -88,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)
|
||||
|
|
@ -167,7 +171,7 @@ class DomainDnsModal extends ModalComponent
|
|||
// --- Komfort / Web ---
|
||||
[
|
||||
'type' => 'CNAME',
|
||||
'name' => env('WEBMAIL_SUB') . ".{$this->domainName}",
|
||||
'name' => (config('clubird.domain.webmail') ?: env('WEBMAIL_SUB', 'webmail')) . ".{$this->domainName}",
|
||||
'value' => "{$mailServerFqdn}.",
|
||||
'helpLabel' => 'Webmail Alias',
|
||||
'helpUrl' => 'https://en.wikipedia.org/wiki/CNAME_record',
|
||||
|
|
@ -670,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)
|
||||
|
|
@ -1055,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
|
||||
|
|
@ -1172,10 +1176,10 @@ class DomainDnsModal extends ModalComponent
|
|||
////
|
||||
//// // Placeholder-Werte, sofern du sie anderswo speicherst gern ersetzen:
|
||||
//// $serverIp = config('app.server_ip', 'DEINE.SERVER.IP');
|
||||
//// $mxHost = config('mailwolt.mx_fqdn', 'mx.' . $this->domain->domain);
|
||||
//// $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()
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use App\Models\SandboxRoute;
|
|||
use App\Models\Setting as SettingModel;
|
||||
use App\Support\CacheVer;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
|
|
@ -20,13 +21,7 @@ class Dashboard extends Component
|
|||
{
|
||||
public function render()
|
||||
{
|
||||
$cached = Cache::get(CacheVer::k('health:services'), []);
|
||||
$rows = $cached['rows'] ?? [];
|
||||
$services = array_map(fn($r) => [
|
||||
'name' => $r['label'] ?? ucfirst($r['name']),
|
||||
'type' => $r['hint'] ?? '',
|
||||
'status' => ($r['ok'] ?? false) ? 'online' : 'offline',
|
||||
], $rows);
|
||||
$services = $this->loadServices();
|
||||
|
||||
[$cpu, $cpuCores, $cpuMhz] = $this->cpu();
|
||||
[$ramPercent, $ramUsed, $ramTotal] = $this->ram();
|
||||
|
|
@ -36,7 +31,15 @@ class Dashboard extends Component
|
|||
|
||||
$servicesActive = count(array_filter($services, fn($s) => $s['status'] === 'online'));
|
||||
|
||||
$sslConfigured = SettingModel::get('ssl_configured', '1') === '1';
|
||||
// Echte Zertifikatsdateien prüfen — nicht nur DB-Wert (kann veraltet sein)
|
||||
$uiDomain = (string) SettingModel::get('ui_domain', '');
|
||||
$sslConfigured = !$uiDomain
|
||||
|| file_exists("/etc/letsencrypt/renewal/{$uiDomain}.conf")
|
||||
|| is_dir("/etc/letsencrypt/live/{$uiDomain}");
|
||||
// DB-Wert nachziehen damit Banner nach einmaligem Check dauerhaft weg ist
|
||||
if ($sslConfigured && SettingModel::get('ssl_configured', '0') !== '1') {
|
||||
SettingModel::set('ssl_configured', '1');
|
||||
}
|
||||
|
||||
return view('livewire.ui.nx.dashboard', [
|
||||
'sslConfigured' => $sslConfigured,
|
||||
|
|
@ -64,12 +67,82 @@ class Dashboard extends Component
|
|||
'diskUsedGb' => $diskUsedGb,
|
||||
'diskFreeGb' => $diskFreeGb,
|
||||
'diskTotalGb' => $diskTotalGb,
|
||||
'updateLatest' => Cache::get('updates:latest_raw') ?: (Cache::get('updates:latest') ? 'v' . Cache::get('updates:latest') : null),
|
||||
...$this->mailSecurity(),
|
||||
'ports' => $this->ports(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
private function loadServices(): array
|
||||
{
|
||||
$allCards = config('woltguard.cards', []);
|
||||
$dashKeys = config('woltguard.dashboard', array_keys($allCards));
|
||||
|
||||
// 1) Monit-Cache für nicht-optionale Dienste
|
||||
$cached = Cache::get(CacheVer::k('health:services'), []);
|
||||
$monitRows = $cached['rows'] ?? [];
|
||||
$monitIndex = [];
|
||||
foreach ($monitRows as $r) {
|
||||
$monitIndex[strtolower($r['name'] ?? '')] = $r;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($dashKeys as $key) {
|
||||
$card = $allCards[$key] ?? null;
|
||||
if (!$card) continue;
|
||||
$optional = $card['optional'] ?? false;
|
||||
|
||||
// Optionale Dienste (z.B. ClamAV) immer live prüfen – Monit-Cache kann veraltet sein
|
||||
if (!$optional && isset($monitIndex[strtolower($card['label'] ?? '')])) {
|
||||
$rows[] = $monitIndex[strtolower($card['label'])];
|
||||
continue;
|
||||
}
|
||||
|
||||
$isOk = false;
|
||||
foreach ($card['sources'] as $src) {
|
||||
if ($this->probeSource($src)) { $isOk = true; break; }
|
||||
}
|
||||
if (!$isOk && $optional) continue;
|
||||
$rows[] = ['label' => $card['label'], 'hint' => $card['hint'], 'ok' => $isOk];
|
||||
}
|
||||
|
||||
// Fallback: wenn gar keine Daten, alle live proben
|
||||
if (empty($rows) && !empty($monitRows)) {
|
||||
$rows = $monitRows;
|
||||
}
|
||||
|
||||
return array_map(fn($r) => [
|
||||
'name' => $r['label'] ?? ucfirst($r['name'] ?? ''),
|
||||
'type' => $r['hint'] ?? '',
|
||||
'status' => ($r['ok'] ?? false) ? 'online' : 'offline',
|
||||
], $rows);
|
||||
}
|
||||
|
||||
private function probeSource(string $src): bool
|
||||
{
|
||||
if (str_starts_with($src, 'systemd:')) {
|
||||
$unit = substr($src, 8);
|
||||
$exit = null;
|
||||
@exec("systemctl is-active --quiet " . escapeshellarg($unit) . " 2>/dev/null", $_, $exit);
|
||||
return $exit === 0;
|
||||
}
|
||||
if (str_starts_with($src, 'tcp:')) {
|
||||
[, $host, $port] = explode(':', $src, 3);
|
||||
$fp = @fsockopen($host, (int)$port, $e1, $e2, 1);
|
||||
if (is_resource($fp)) { fclose($fp); return true; }
|
||||
return false;
|
||||
}
|
||||
if (str_starts_with($src, 'socket:')) {
|
||||
return @file_exists(substr($src, 7));
|
||||
}
|
||||
if ($src === 'db') {
|
||||
try { \Illuminate\Support\Facades\DB::connection()->getPdo(); return true; }
|
||||
catch (\Throwable) { return false; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function ports(): array
|
||||
{
|
||||
$check = [25, 465, 587, 110, 143, 993, 995, 80, 443];
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ namespace App\Livewire\Ui\Nx\Domain;
|
|||
|
||||
use App\Models\Domain;
|
||||
use App\Services\DkimService;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Attributes\Title;
|
||||
|
|
@ -39,27 +38,12 @@ 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 */
|
||||
$svc = app(DkimService::class);
|
||||
$res = $svc->generateForDomain($domain, 2048, $selector);
|
||||
$priv = $res['priv_path'] ?? storage_path("app/private/dkim/{$domain->domain}/{$selector}.private");
|
||||
$txt = storage_path("app/private/dkim/{$domain->domain}/{$selector}.txt");
|
||||
|
||||
if (!is_readable($txt) && !empty($res['dns_txt'])) {
|
||||
file_put_contents($txt, $res['dns_txt']);
|
||||
}
|
||||
|
||||
$proc = Process::run(['sudo', '-n', '/usr/local/sbin/mailwolt-install-dkim',
|
||||
$domain->domain, $selector, $priv, $txt]);
|
||||
|
||||
if (!$proc->successful()) {
|
||||
throw new \RuntimeException($proc->errorOutput());
|
||||
}
|
||||
|
||||
Process::run(['sudo', '-n', '/usr/bin/systemctl', 'reload', 'opendkim']);
|
||||
$svc->generateForDomain($domain, 2048, $selector);
|
||||
|
||||
$this->dispatch('toast', type: 'done', badge: 'DKIM',
|
||||
title: 'DKIM erneuert', text: "Schlüssel für <b>{$domain->domain}</b> wurde neu generiert.", duration: 5000);
|
||||
|
|
@ -71,13 +55,13 @@ class DnsDkim extends Component
|
|||
|
||||
private function dkimReady(string $domain, string $selector): bool
|
||||
{
|
||||
return Process::run(['sudo', '-n', '/usr/bin/test', '-s',
|
||||
"/etc/opendkim/keys/{$domain}/{$selector}.private"])->successful();
|
||||
$path = storage_path("app/private/dkim/{$domain}/{$selector}.private");
|
||||
return is_file($path) && filesize($path) > 0;
|
||||
}
|
||||
|
||||
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'));
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,181 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Ui\Security;
|
||||
|
||||
use App\Support\CacheVer;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.dvx')]
|
||||
#[Title('Virenschutz · Mailwolt')]
|
||||
class ClamavManager extends Component
|
||||
{
|
||||
public bool $running = false;
|
||||
public bool $enabled = false;
|
||||
public string $dbDate = '—';
|
||||
public string $dbVersion = '—';
|
||||
public ?int $ramMb = null;
|
||||
public string $lastError = '';
|
||||
public string $lastSuccess = '';
|
||||
public bool $starting = false;
|
||||
public int $startSecs = 0;
|
||||
|
||||
private const STARTING_FLAG = '/tmp/mw-clamav-starting';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->refresh();
|
||||
$this->restoreStartingState();
|
||||
}
|
||||
|
||||
private function restoreStartingState(): void
|
||||
{
|
||||
if (!file_exists(self::STARTING_FLAG)) return;
|
||||
if ($this->running) {
|
||||
@unlink(self::STARTING_FLAG);
|
||||
return;
|
||||
}
|
||||
$elapsed = time() - (int) @file_get_contents(self::STARTING_FLAG);
|
||||
if ($elapsed > 180) {
|
||||
@unlink(self::STARTING_FLAG);
|
||||
return;
|
||||
}
|
||||
$this->starting = true;
|
||||
$this->startSecs = max(0, $elapsed);
|
||||
}
|
||||
|
||||
public function refresh(): void
|
||||
{
|
||||
$this->lastError = '';
|
||||
$this->lastSuccess = '';
|
||||
$this->running = $this->serviceActive();
|
||||
$this->enabled = $this->serviceEnabled();
|
||||
$this->ramMb = $this->running ? $this->readRamMb() : null;
|
||||
[$this->dbDate, $this->dbVersion] = $this->readDbInfo();
|
||||
}
|
||||
|
||||
public function enable(): void
|
||||
{
|
||||
$this->lastError = '';
|
||||
$this->lastSuccess = '';
|
||||
[$ok, $msg] = $this->runCmd('enable');
|
||||
if (!$ok) {
|
||||
$this->lastError = $msg;
|
||||
return;
|
||||
}
|
||||
file_put_contents(self::STARTING_FLAG, time());
|
||||
$this->enabled = true;
|
||||
$this->starting = true;
|
||||
$this->startSecs = 0;
|
||||
}
|
||||
|
||||
public function pollStatus(): void
|
||||
{
|
||||
if (!$this->starting) return;
|
||||
$this->startSecs += 3;
|
||||
$this->running = $this->serviceActive();
|
||||
if ($this->running) {
|
||||
@unlink(self::STARTING_FLAG);
|
||||
$this->starting = false;
|
||||
$this->startSecs = 0;
|
||||
$this->enabled = $this->serviceEnabled();
|
||||
$this->ramMb = $this->readRamMb();
|
||||
$this->lastSuccess = 'ClamAV ist aktiv und läuft.';
|
||||
Cache::forget(CacheVer::k('health:services'));
|
||||
}
|
||||
}
|
||||
|
||||
public function disable(): void
|
||||
{
|
||||
[$ok, $msg] = $this->runCmd('disable');
|
||||
if ($ok) $this->lastSuccess = 'ClamAV wurde gestoppt und deaktiviert.';
|
||||
else $this->lastError = $msg;
|
||||
$this->running = $this->serviceActive();
|
||||
$this->enabled = $this->serviceEnabled();
|
||||
Cache::forget(CacheVer::k('health:services'));
|
||||
}
|
||||
|
||||
public function updateDb(): void
|
||||
{
|
||||
[$ok, $msg] = $this->runCmd('freshclam');
|
||||
if ($ok) $this->lastSuccess = 'Virensignaturen wurden aktualisiert.';
|
||||
else $this->lastError = $msg;
|
||||
[$this->dbDate, $this->dbVersion] = $this->readDbInfo();
|
||||
}
|
||||
|
||||
private function runCmd(string $action): array
|
||||
{
|
||||
$out = []; $rc = null;
|
||||
exec('sudo -n /usr/local/sbin/mailwolt-clamav ' . escapeshellarg($action) . ' 2>&1', $out, $rc);
|
||||
\Log::info('ClamAV ' . $action, ['rc' => $rc, 'out' => $out]);
|
||||
$msg = implode(' ', $out) ?: 'Unbekannter Fehler (rc=' . $rc . ')';
|
||||
return [$rc === 0, $msg];
|
||||
}
|
||||
|
||||
private function isInstalled(): bool
|
||||
{
|
||||
return file_exists('/usr/sbin/clamd')
|
||||
|| file_exists('/lib/systemd/system/clamav-daemon.service')
|
||||
|| file_exists('/usr/lib/systemd/system/clamav-daemon.service');
|
||||
}
|
||||
|
||||
private function serviceActive(): bool
|
||||
{
|
||||
$exit = null;
|
||||
@exec('systemctl is-active --quiet clamav-daemon 2>/dev/null', $_, $exit);
|
||||
return $exit === 0;
|
||||
}
|
||||
|
||||
private function serviceEnabled(): bool
|
||||
{
|
||||
$exit = null;
|
||||
@exec('systemctl is-enabled --quiet clamav-daemon 2>/dev/null', $_, $exit);
|
||||
return $exit === 0;
|
||||
}
|
||||
|
||||
private function readRamMb(): ?int
|
||||
{
|
||||
$out = [];
|
||||
@exec("ps -C clamd -o rss= 2>/dev/null", $out);
|
||||
$kb = array_sum(array_map('intval', array_filter($out)));
|
||||
return $kb > 0 ? (int) round($kb / 1024) : null;
|
||||
}
|
||||
|
||||
private function readDbInfo(): array
|
||||
{
|
||||
$paths = [
|
||||
'/var/lib/clamav/main.cvd',
|
||||
'/var/lib/clamav/main.cld',
|
||||
'/var/lib/clamav/daily.cvd',
|
||||
'/var/lib/clamav/daily.cld',
|
||||
];
|
||||
$latest = 0;
|
||||
foreach ($paths as $p) {
|
||||
if (@file_exists($p)) {
|
||||
$mtime = @filemtime($p);
|
||||
if ($mtime > $latest) $latest = $mtime;
|
||||
}
|
||||
}
|
||||
if ($latest === 0) return ['—', '—'];
|
||||
$date = date('d.m.Y H:i', $latest);
|
||||
|
||||
$ver = '—';
|
||||
$out = [];
|
||||
@exec('sigtool --info /var/lib/clamav/daily.cld 2>/dev/null | grep "^Version:" | head -1', $out);
|
||||
if (empty($out)) {
|
||||
@exec('sigtool --info /var/lib/clamav/daily.cvd 2>/dev/null | grep "^Version:" | head -1', $out);
|
||||
}
|
||||
if (!empty($out[0])) {
|
||||
$ver = trim(str_replace('Version:', '', $out[0]));
|
||||
}
|
||||
|
||||
return [$date, $ver];
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.ui.security.clamav-manager');
|
||||
}
|
||||
}
|
||||
|
|
@ -15,22 +15,9 @@ class Fail2banBanlist extends Component
|
|||
public ?string $jail = null;
|
||||
|
||||
/**
|
||||
* Struktur für Blade (reine Ausgabe, keine Logik im Blade):
|
||||
* [
|
||||
* [
|
||||
* 'ip' => '1.2.3.4',
|
||||
* 'jail' => 'recidive',
|
||||
* 'permanent' => false,
|
||||
* 'label' => 'Temporär', // oder 'Permanent'
|
||||
* 'box' => 'border-amber-400/20 bg-white/3', // Kartenstil
|
||||
* 'badge' => 'border-amber-400/30 bg-amber-500/10 text-amber-200',
|
||||
* 'btn' => 'border-rose-400/30 bg-rose-500/10 text-rose-200 hover:border-rose-400/50',
|
||||
* ],
|
||||
* ...
|
||||
* ]
|
||||
*
|
||||
* @var array<int,array{
|
||||
* ip:string,jail:string,permanent:bool,label:string,box:string,badge:string,btn:string
|
||||
* ip:string,jail:string,service:string,permanent:bool,label:string,
|
||||
* remaining:string,box:string,badge:string,dot:string,btn:string
|
||||
* }>
|
||||
*/
|
||||
public array $rows = [];
|
||||
|
|
@ -75,7 +62,9 @@ class Fail2banBanlist extends Component
|
|||
continue;
|
||||
}
|
||||
|
||||
$permanent = $this->isPermanent($j, $ip);
|
||||
$banInfo = $this->getBanInfo($j, $ip);
|
||||
$permanent = $banInfo['permanent'];
|
||||
$remaining = $banInfo['remaining'];
|
||||
|
||||
if ($permanent) {
|
||||
$box = 'border-rose-400/30 bg-rose-500/5';
|
||||
|
|
@ -94,9 +83,11 @@ class Fail2banBanlist extends Component
|
|||
$rows[] = [
|
||||
'ip' => $ip,
|
||||
'jail' => $j,
|
||||
'service' => $this->serviceLabel($j),
|
||||
'permanent' => $permanent,
|
||||
'style' => $style,
|
||||
'label' => $label,
|
||||
'remaining' => $remaining,
|
||||
'box' => $box,
|
||||
'badge' => $badge,
|
||||
'dot' => $dot,
|
||||
|
|
@ -140,35 +131,63 @@ class Fail2banBanlist extends Component
|
|||
|
||||
/* ================= helpers ================= */
|
||||
|
||||
/** Prüft via SQLite, ob der **letzte** Ban für (jail, ip) permanent ist (bantime < 0). */
|
||||
private function isPermanent(string $jail, string $ip): bool
|
||||
/** Gibt permanent-Flag und verbleibende Zeit für (jail, ip) zurück. */
|
||||
private function getBanInfo(string $jail, string $ip): array
|
||||
{
|
||||
$db = $this->getDbFile();
|
||||
if ($db === '' || !is_readable($db)) {
|
||||
// Fallback: Blacklist-Jail ist per Design permanent
|
||||
return $jail === 'mailwolt-blacklist';
|
||||
$fallbackPermanent = ($jail === 'mailwolt-blacklist');
|
||||
|
||||
$cmd = sprintf('sudo -n /usr/local/sbin/clubird-f2b-baninfo %s %s 2>&1',
|
||||
escapeshellarg($jail), escapeshellarg($ip));
|
||||
$out = trim((string)@shell_exec($cmd));
|
||||
|
||||
if ($out !== '') {
|
||||
[$timeofban, $bantime] = array_pad(explode('|', $out), 2, '0');
|
||||
$timeofban = (int)$timeofban;
|
||||
$bantime = (int)$bantime;
|
||||
|
||||
if ($bantime < 0) {
|
||||
return ['permanent' => true, 'remaining' => ''];
|
||||
}
|
||||
|
||||
$remaining = ($timeofban + $bantime) - time();
|
||||
if ($remaining > 0) {
|
||||
return ['permanent' => false, 'remaining' => $this->formatRemaining($remaining)];
|
||||
}
|
||||
}
|
||||
|
||||
$q = <<<SQL
|
||||
WITH last AS (
|
||||
SELECT MAX(timeofban) AS t
|
||||
FROM bans
|
||||
WHERE jail = '$jail' AND ip = '$ip'
|
||||
)
|
||||
SELECT bantime
|
||||
FROM bans, last
|
||||
WHERE jail = '$jail' AND ip = '$ip' AND timeofban = last.t
|
||||
LIMIT 1;
|
||||
SQL;
|
||||
// Fallback: DB-Eintrag ist abgelaufen (fail2ban-Neustart setzt Timer intern neu,
|
||||
// ohne die DB zu aktualisieren) → konfigurierte Jail-Banzeit als Näherung
|
||||
$cfgBantime = (int)trim($this->f2b('get ' . escapeshellarg($jail) . ' bantime'));
|
||||
if ($cfgBantime < 0) {
|
||||
return ['permanent' => true, 'remaining' => ''];
|
||||
}
|
||||
if ($cfgBantime > 0) {
|
||||
return ['permanent' => false, 'remaining' => '≤ ' . $this->formatRemaining($cfgBantime)];
|
||||
}
|
||||
|
||||
$cmd = sprintf(
|
||||
'sudo -n /usr/bin/sqlite3 -readonly %s %s 2>&1',
|
||||
escapeshellarg($db),
|
||||
escapeshellarg($q)
|
||||
);
|
||||
$out = trim((string)@shell_exec($cmd));
|
||||
if ($out === '') return ($jail === 'mailwolt-blacklist'); // Fallback
|
||||
return ((int)$out) < 0;
|
||||
return ['permanent' => $fallbackPermanent, 'remaining' => ''];
|
||||
}
|
||||
|
||||
private function formatRemaining(int $seconds): string
|
||||
{
|
||||
if ($seconds <= 0) return 'läuft ab';
|
||||
if ($seconds < 60) return "noch {$seconds} Sek.";
|
||||
if ($seconds < 3600) return 'noch ' . (int)ceil($seconds / 60) . ' Min.';
|
||||
if ($seconds < 86400) return 'noch ' . round($seconds / 3600, 1) . ' Std.';
|
||||
return 'noch ' . (int)ceil($seconds / 86400) . ' Tage';
|
||||
}
|
||||
|
||||
private function serviceLabel(string $jail): string
|
||||
{
|
||||
return match(true) {
|
||||
$jail === 'sshd' => 'SSH',
|
||||
$jail === 'dovecot' => 'IMAP/POP3',
|
||||
str_starts_with($jail, 'postfix') => 'SMTP',
|
||||
str_starts_with($jail, 'nginx') => 'Web',
|
||||
$jail === 'recidive' => 'Recidive',
|
||||
str_contains($jail, 'blacklist') => 'Blacklist',
|
||||
default => $jail,
|
||||
};
|
||||
}
|
||||
|
||||
/** Liste aller Jails */
|
||||
|
|
@ -188,13 +207,4 @@ SQL;
|
|||
return (string) @shell_exec('sudo -n /usr/bin/fail2ban-client '.$args.' 2>&1');
|
||||
}
|
||||
|
||||
/** Pfad zur Fail2Ban-SQLite-DB holen */
|
||||
private function getDbFile(): string
|
||||
{
|
||||
$out = $this->f2b('get dbfile');
|
||||
$lines = array_values(array_filter(array_map('trim', preg_split('/\r?\n/', $out))));
|
||||
$path = end($lines) ?: '';
|
||||
$path = preg_replace('/^`?-?\s*/', '', $path);
|
||||
return $path ?: '/var/lib/fail2ban/fail2ban.sqlite3';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ bantime.factor = {$s->bantime_factor}
|
|||
bantime.maxtime = {$s->max_bantime}
|
||||
CONF;
|
||||
|
||||
$this->writeRootFileViaTee('/etc/fail2ban/jail.d/00-mailwolt-defaults.local', $content);
|
||||
$this->writeRootFileViaTee('/etc/fail2ban/jail.d/00-defaults.local', $content);
|
||||
}
|
||||
|
||||
protected function writeWhitelistConfig(): void
|
||||
|
|
@ -146,7 +146,7 @@ CONF;
|
|||
|
||||
$content = "[DEFAULT]\nignoreip = {$ignore}\n";
|
||||
|
||||
$this->writeRootFileViaTee('/etc/fail2ban/jail.d/mailwolt-whitelist.local', $content);
|
||||
$this->writeRootFileViaTee('/etc/fail2ban/jail.d/whitelist.local', $content);
|
||||
}
|
||||
|
||||
/* ---------------- Helper ---------------- */
|
||||
|
|
@ -320,7 +320,7 @@ CONF;
|
|||
//bantime.maxtime = {$s->max_bantime}
|
||||
//CONF;
|
||||
//
|
||||
// $this->writeRootFileViaTee('/etc/fail2ban/jail.d/00-mailwolt-defaults.local', $content);
|
||||
// $this->writeRootFileViaTee('/etc/fail2ban/jail.d/00-defaults.local', $content);
|
||||
// }
|
||||
//
|
||||
// protected function writeWhitelistConfig(): void
|
||||
|
|
@ -330,7 +330,7 @@ CONF;
|
|||
//
|
||||
// $content = "[DEFAULT]\nignoreip = {$ignore}\n";
|
||||
//
|
||||
// $this->writeRootFileViaTee('/etc/fail2ban/jail.d/mailwolt-whitelist.local', $content);
|
||||
// $this->writeRootFileViaTee('/etc/fail2ban/jail.d/whitelist.local', $content);
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
|
|
@ -493,7 +493,7 @@ CONF;
|
|||
//bantime.factor = {$s->bantime_factor}
|
||||
//bantime.maxtime = {$s->max_bantime}
|
||||
//CONF;
|
||||
// file_put_contents('/etc/fail2ban/jail.d/00-mailwolt-defaults.local', $content);
|
||||
// file_put_contents('/etc/fail2ban/jail.d/00-defaults.local', $content);
|
||||
// }
|
||||
//
|
||||
// protected function writeWhitelistConfig(): void
|
||||
|
|
@ -501,7 +501,7 @@ CONF;
|
|||
// $ips = Fail2banIpList::where('type','whitelist')->pluck('ip')->toArray();
|
||||
// $ignore = implode(' ', array_unique(array_filter($ips)));
|
||||
// $content = "[DEFAULT]\nignoreip = {$ignore}\n";
|
||||
// file_put_contents('/etc/fail2ban/jail.d/mailwolt-whitelist.local', $content);
|
||||
// file_put_contents('/etc/fail2ban/jail.d/whitelist.local', $content);
|
||||
// }
|
||||
//
|
||||
// private function writeRootFileViaTee(string $target, string $content): void
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ class Fail2banIpModal extends ModalComponent
|
|||
$ignore = implode(' ', array_unique(array_filter($ips)));
|
||||
$content = "[DEFAULT]\nignoreip = {$ignore}\n";
|
||||
|
||||
$this->writeRootFileViaTee('/etc/fail2ban/jail.d/mailwolt-whitelist.local', $content);
|
||||
$this->writeRootFileViaTee('/etc/fail2ban/jail.d/whitelist.local', $content);
|
||||
}
|
||||
|
||||
private function writeRootFileViaTee(string $target, string $content): void
|
||||
|
|
@ -293,7 +293,7 @@ class Fail2banIpModal extends ModalComponent
|
|||
// Fail2banIpList::create(['ip' => $ip, 'type' => $this->type]);
|
||||
//
|
||||
// if ($this->type === 'whitelist') {
|
||||
// $this->writeWhitelistConfig(); // schreibt /etc/fail2ban/jail.d/mailwolt-whitelist.local
|
||||
// $this->writeWhitelistConfig(); // schreibt /etc/fail2ban/jail.d/whitelist.local
|
||||
// $this->reloadFail2ban(); // f2b neu laden
|
||||
// } else {
|
||||
// // Blacklist = sofort bannen im dedizierten Jail
|
||||
|
|
@ -356,7 +356,7 @@ class Fail2banIpModal extends ModalComponent
|
|||
// $content = "[DEFAULT]\nignoreip = {$ignore}\n";
|
||||
//
|
||||
// // sicher in Root-Pfad schreiben (sudo tee)
|
||||
// $this->writeRootFileViaTee('/etc/fail2ban/jail.d/mailwolt-whitelist.local', $content);
|
||||
// $this->writeRootFileViaTee('/etc/fail2ban/jail.d/whitelist.local', $content);
|
||||
// }
|
||||
//
|
||||
// private function writeRootFileViaTee(string $target, string $content): void
|
||||
|
|
@ -540,7 +540,7 @@ class Fail2banIpModal extends ModalComponent
|
|||
// $ignore = implode(' ', array_unique(array_filter($ips)));
|
||||
// $content = "[DEFAULT]\nignoreip = {$ignore}\n";
|
||||
//
|
||||
// $file = '/etc/fail2ban/jail.d/mailwolt-whitelist.local';
|
||||
// $file = '/etc/fail2ban/jail.d/whitelist.local';
|
||||
// $tmp = $file.'.tmp';
|
||||
// @file_put_contents($tmp, $content, LOCK_EX);
|
||||
// @chmod($tmp, 0644);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ class RspamdForm extends Component
|
|||
|
||||
private function writeRspamdConfig(): void
|
||||
{
|
||||
$target = '/etc/rspamd/local.d/mailwolt-actions.conf';
|
||||
$target = '/etc/rspamd/local.d/actions.conf';
|
||||
$content = <<<CONF
|
||||
actions {
|
||||
reject = {$this->reject_score};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Livewire\Ui\Security;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
|
|
@ -12,9 +13,26 @@ class SslCertificatesTable extends Component
|
|||
{
|
||||
public array $certs = [];
|
||||
|
||||
// Domains (aus Einstellungen)
|
||||
public string $uiDomain = '';
|
||||
public string $webmailDomain = '';
|
||||
public string $mailDomain = '';
|
||||
|
||||
// Provisioning
|
||||
public bool $sslProvisioning = false;
|
||||
public bool $sslDone = false;
|
||||
public array $sslProgress = ['ui' => 'pending', 'webmail' => 'pending', 'mail' => 'pending'];
|
||||
|
||||
private const SSL_STATE_DIR = '/var/lib/mailwolt/wizard';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->uiDomain = (string) Setting::get('ui_domain', '');
|
||||
$this->webmailDomain = (string) Setting::get('webmail_domain', '');
|
||||
$this->mailDomain = (string) Setting::get('mail_domain', '');
|
||||
|
||||
$this->certs = $this->loadCertificates();
|
||||
$this->restoreSslProvisioningState();
|
||||
}
|
||||
|
||||
public function refresh(): void
|
||||
|
|
@ -24,6 +42,55 @@ class SslCertificatesTable extends Component
|
|||
text: 'Zertifikatsliste wurde neu geladen.', duration: 3000);
|
||||
}
|
||||
|
||||
public function startSslProvisioning(): void
|
||||
{
|
||||
if (! ($this->uiDomain && $this->webmailDomain && $this->mailDomain)) {
|
||||
$this->dispatch('toast', type: 'warn', badge: 'SSL',
|
||||
title: 'Domains fehlen',
|
||||
text: 'Bitte erst alle Domains unter Einstellungen speichern.', duration: 6000);
|
||||
return;
|
||||
}
|
||||
|
||||
@mkdir(self::SSL_STATE_DIR, 0755, true);
|
||||
@unlink(self::SSL_STATE_DIR . '/done');
|
||||
foreach (['ui', 'mail', 'webmail'] as $k) {
|
||||
file_put_contents(self::SSL_STATE_DIR . "/{$k}", 'pending');
|
||||
}
|
||||
|
||||
$this->sslProgress = ['ui' => 'pending', 'webmail' => 'pending', 'mail' => 'pending'];
|
||||
$this->sslDone = false;
|
||||
$this->sslProvisioning = true;
|
||||
|
||||
$artisan = base_path('artisan');
|
||||
$cmd = sprintf(
|
||||
'nohup php %s clubird:wizard-domains --ui=%s --mail=%s --webmail=%s --ssl=1 > /dev/null 2>&1 &',
|
||||
escapeshellarg($artisan),
|
||||
escapeshellarg($this->uiDomain),
|
||||
escapeshellarg($this->mailDomain),
|
||||
escapeshellarg($this->webmailDomain),
|
||||
);
|
||||
@shell_exec($cmd);
|
||||
}
|
||||
|
||||
public function pollSsl(): void
|
||||
{
|
||||
if (! $this->sslProvisioning || $this->sslDone) return;
|
||||
|
||||
foreach (['ui', 'mail', 'webmail'] as $key) {
|
||||
$file = self::SSL_STATE_DIR . "/{$key}";
|
||||
$this->sslProgress[$key] = is_readable($file)
|
||||
? trim((string) @file_get_contents($file))
|
||||
: 'pending';
|
||||
}
|
||||
|
||||
$done = @file_get_contents(self::SSL_STATE_DIR . '/done');
|
||||
if ($done !== false) {
|
||||
$this->sslDone = true;
|
||||
$this->sslProvisioning = false;
|
||||
$this->certs = $this->loadCertificates();
|
||||
}
|
||||
}
|
||||
|
||||
public function renew(string $name): void
|
||||
{
|
||||
$safe = preg_replace('/[^a-z0-9._-]/i', '', $name);
|
||||
|
|
@ -44,6 +111,21 @@ class SslCertificatesTable extends Component
|
|||
}
|
||||
}
|
||||
|
||||
private function restoreSslProvisioningState(): void
|
||||
{
|
||||
$doneFile = self::SSL_STATE_DIR . '/done';
|
||||
if (! file_exists($doneFile)) return;
|
||||
|
||||
$this->sslDone = true;
|
||||
$this->sslProvisioning = false;
|
||||
foreach (['ui', 'mail', 'webmail'] as $key) {
|
||||
$file = self::SSL_STATE_DIR . "/{$key}";
|
||||
if (is_readable($file)) {
|
||||
$this->sslProgress[$key] = trim((string) @file_get_contents($file));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function loadCertificates(): array
|
||||
{
|
||||
$out = (string) @shell_exec('sudo -n /usr/bin/certbot certificates 2>&1');
|
||||
|
|
@ -51,7 +133,7 @@ class SslCertificatesTable extends Component
|
|||
if (str_contains($out, 'No certificates found')) return [];
|
||||
|
||||
$certs = [];
|
||||
$blocks = preg_split('/\n(?=Certificate Name:)/m', $out);
|
||||
$blocks = preg_split('/\n(?=\s*Certificate Name:)/m', $out);
|
||||
|
||||
foreach ($blocks as $block) {
|
||||
if (!preg_match('/Certificate Name:\s*(.+)/i', $block, $nameM)) continue;
|
||||
|
|
@ -63,6 +145,13 @@ class SslCertificatesTable extends Component
|
|||
$expiryRaw = trim($expiryM[1] ?? '');
|
||||
$daysLeft = null;
|
||||
$expired = false;
|
||||
$expiryDate = null;
|
||||
|
||||
// Datum extrahieren (Format: "2026-07-24 10:30:00+00:00 (VALID: 88 days)")
|
||||
if (preg_match('/(\d{4}-\d{2}-\d{2})/', $expiryRaw, $dateM)) {
|
||||
$ts = strtotime($dateM[1]);
|
||||
$expiryDate = $ts ? date('d.m.Y', $ts) : null;
|
||||
}
|
||||
|
||||
if (preg_match('/VALID: (\d+) days/i', $expiryRaw, $dM)) {
|
||||
$daysLeft = (int) $dM[1];
|
||||
|
|
@ -75,12 +164,12 @@ class SslCertificatesTable extends Component
|
|||
$domains = $domainsRaw !== '' ? array_values(array_filter(explode(' ', $domainsRaw))) : [];
|
||||
|
||||
$certs[] = [
|
||||
'name' => trim($nameM[1]),
|
||||
'domains' => $domains,
|
||||
'expiry' => $expiryRaw,
|
||||
'days_left' => $daysLeft,
|
||||
'expired' => $expired,
|
||||
'cert_path' => trim($certM[1] ?? ''),
|
||||
'name' => trim($nameM[1]),
|
||||
'domains' => $domains,
|
||||
'expiry_date' => $expiryDate,
|
||||
'days_left' => $daysLeft,
|
||||
'expired' => $expired,
|
||||
'cert_path' => trim($certM[1] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ class TlsCiphersForm extends Component
|
|||
|
||||
private function writePostfixConfig(): void
|
||||
{
|
||||
$target = '/etc/postfix/mailwolt-tls.cf';
|
||||
$target = '/etc/postfix/tls.cf';
|
||||
$content = "smtpd_tls_protocols = {$this->postfix_protocols}\n"
|
||||
. "smtp_tls_protocols = {$this->postfix_protocols}\n"
|
||||
. "smtpd_tls_ciphers = {$this->postfix_ciphers}\n"
|
||||
|
|
@ -106,7 +106,7 @@ class TlsCiphersForm extends Component
|
|||
|
||||
private function writeDovecotConfig(): void
|
||||
{
|
||||
$target = '/etc/dovecot/conf.d/99-mailwolt-tls.conf';
|
||||
$target = '/etc/dovecot/conf.d/99-tls.conf';
|
||||
$content = "ssl_min_protocol = {$this->dovecot_min_proto}\n"
|
||||
. "ssl_cipher_list = {$this->dovecot_ciphers}\n";
|
||||
$this->tee($target, $content);
|
||||
|
|
|
|||
|
|
@ -129,10 +129,10 @@ class DomainsSslForm extends Component
|
|||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->base_domain = (string) config('mailwolt.domain.base', '');
|
||||
$this->ui_sub = (string) config('mailwolt.domain.ui', '');
|
||||
$this->webmail_sub = (string) config('mailwolt.domain.webmail', '');
|
||||
$this->mta_sub = (string) config('mailwolt.domain.mail', '');
|
||||
$this->base_domain = (string) config('clubird.domain.base', '');
|
||||
$this->ui_sub = (string) config('clubird.domain.ui', '');
|
||||
$this->webmail_sub = (string) config('clubird.domain.webmail', '');
|
||||
$this->mta_sub = (string) config('clubird.domain.mail', '');
|
||||
|
||||
$this->loadMtaStsFromFileIfPossible();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ class DomainsSslForm extends Component
|
|||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->mail_domain_readonly = (string) config('mailwolt.domain.mail', 'mx');
|
||||
$this->mail_domain_readonly = (string) config('clubird.domain.mail', 'mx');
|
||||
$this->ui_domain = Setting::get('ui_domain', $this->ui_domain);
|
||||
$this->webmail_domain = Setting::get('webmail_domain', $this->webmail_domain);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ class InstallerPage extends Component
|
|||
|
||||
if ($this->rc === 0 && !$this->postActionsDone) {
|
||||
@shell_exec('nohup php /var/www/mailwolt/artisan health:collect >/dev/null 2>&1 &');
|
||||
@shell_exec('nohup php /var/www/mailwolt/artisan db:seed --class="Database\\\\Seeders\\\\SystemDomainSeeder" --force >/dev/null 2>&1 &');
|
||||
$this->postActionsDone = true;
|
||||
|
||||
$this->dispatch('toast', type: 'done', badge: 'Installer',
|
||||
|
|
|
|||
|
|
@ -34,12 +34,24 @@ class UpdateModal extends ModalComponent
|
|||
$st = @trim(@file_get_contents(self::STATE_DIR.'/state') ?: '');
|
||||
$rcRaw = @trim(@file_get_contents(self::STATE_DIR.'/rc') ?: '');
|
||||
|
||||
// Log einlesen
|
||||
$lines = @file(self::LOG, FILE_IGNORE_NEW_LINES) ?: [];
|
||||
|
||||
// Nur als "done" gelten wenn [DONE]-Marker im Log steht —
|
||||
// verhindert dass das Modal fertig zeigt während das Script noch läuft
|
||||
$logDone = in_array('[DONE]', array_map('trim', $lines), true);
|
||||
|
||||
if ($st === 'done' && !$logDone) {
|
||||
$st = 'running'; // Noch warten bis Log vollständig
|
||||
}
|
||||
|
||||
$this->state = $st ?: 'unknown';
|
||||
$this->rc = is_numeric($rcRaw) ? (int)$rcRaw : null;
|
||||
|
||||
// Log einlesen
|
||||
$lines = @file(self::LOG, FILE_IGNORE_NEW_LINES) ?: [];
|
||||
$this->tail = array_slice($lines, -30);
|
||||
$this->tail = array_slice(
|
||||
array_filter($lines, fn($l) => trim($l) !== '[DONE]'),
|
||||
-30
|
||||
);
|
||||
|
||||
$last = trim($this->tail ? end($this->tail) : '');
|
||||
$last = preg_replace('/^\[\w\]\s*/', '', $last);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -7,12 +7,18 @@ use App\Models\Setting;
|
|||
use Illuminate\Support\Facades\Artisan;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.dvx')]
|
||||
#[Title('Einstellungen · Mailwolt')]
|
||||
class SettingsForm extends Component
|
||||
{
|
||||
#[Url]
|
||||
public string $tab = 'general';
|
||||
|
||||
private const VALID_TABS = ['general', 'domains', 'backup', 'notifications'];
|
||||
|
||||
// Allgemein
|
||||
public string $instance_name = '';
|
||||
public string $locale = 'de';
|
||||
|
|
@ -24,14 +30,15 @@ class SettingsForm extends Component
|
|||
public string $mail_domain = '';
|
||||
public string $webmail_domain = '';
|
||||
|
||||
// SSL provisioning
|
||||
public bool $sslProvisioning = false;
|
||||
public bool $sslDone = false;
|
||||
public array $sslProgress = ['ui' => 'pending', 'webmail' => 'pending', 'mail' => 'pending'];
|
||||
public array $sslCerts = [];
|
||||
// SSL-Status (read-only, für Anzeige in Einstellungen)
|
||||
public array $sslCerts = [];
|
||||
|
||||
private const SSL_STATE_DIR = '/var/lib/mailwolt/wizard';
|
||||
|
||||
// Benachrichtigungen
|
||||
public string $notify_sender_name = '';
|
||||
public string $notify_admin_email = '';
|
||||
|
||||
// Sicherheit
|
||||
public int $rate_limit = 5;
|
||||
public int $password_min = 10;
|
||||
|
|
@ -55,8 +62,10 @@ class SettingsForm extends Component
|
|||
'locale' => 'required|string|max:10',
|
||||
'timezone' => 'required|string|max:64',
|
||||
'session_timeout' => 'required|integer|min:5|max:1440',
|
||||
'rate_limit' => 'required|integer|min:1|max:100',
|
||||
'password_min' => 'required|integer|min:6|max:128',
|
||||
'notify_sender_name' => 'nullable|string|max:80',
|
||||
'notify_admin_email' => 'nullable|email|max:190',
|
||||
'rate_limit' => 'required|integer|min:1|max:100',
|
||||
'password_min' => 'required|integer|min:6|max:128',
|
||||
'backup_enabled' => 'boolean',
|
||||
'backup_preset' => 'required|in:hourly,daily,weekly,monthly,custom',
|
||||
'backup_time' => 'required_unless:backup_preset,hourly,custom|regex:/^\d{1,2}:\d{2}$/',
|
||||
|
|
@ -132,8 +141,12 @@ class SettingsForm extends Component
|
|||
}
|
||||
|
||||
$this->loadSslStatus();
|
||||
$this->rate_limit = (int) Setting::get('rate_limit', $this->rate_limit);
|
||||
$this->password_min = (int) Setting::get('password_min', $this->password_min);
|
||||
$defaultSenderName = ($this->instance_name ?: 'Mailwolt') . ' Benachrichtigung';
|
||||
$defaultAdminEmail = auth()->user()?->system_notify_email ?? '';
|
||||
$this->notify_sender_name = (string) Setting::get('notify_sender_name', $defaultSenderName);
|
||||
$this->notify_admin_email = (string) Setting::get('notify_admin_email', $defaultAdminEmail);
|
||||
$this->rate_limit = (int) Setting::get('rate_limit', $this->rate_limit);
|
||||
$this->password_min = (int) Setting::get('password_min', $this->password_min);
|
||||
|
||||
// Backup aus BackupPolicy laden
|
||||
$policy = BackupPolicy::first();
|
||||
|
|
@ -154,13 +167,20 @@ class SettingsForm extends Component
|
|||
$this->validate();
|
||||
|
||||
Setting::setMany([
|
||||
'locale' => $this->locale,
|
||||
'timezone' => $this->timezone,
|
||||
'session_timeout' => $this->session_timeout,
|
||||
'rate_limit' => $this->rate_limit,
|
||||
'password_min' => $this->password_min,
|
||||
'locale' => $this->locale,
|
||||
'timezone' => $this->timezone,
|
||||
'session_timeout' => $this->session_timeout,
|
||||
'notify_sender_name' => $this->notify_sender_name,
|
||||
'notify_admin_email' => $this->notify_admin_email,
|
||||
'rate_limit' => $this->rate_limit,
|
||||
'password_min' => $this->password_min,
|
||||
]);
|
||||
|
||||
// MAIL_FROM_NAME in .env synchronisieren
|
||||
if ($this->notify_sender_name) {
|
||||
$this->writeEnv(['MAIL_FROM_NAME' => $this->notify_sender_name]);
|
||||
}
|
||||
|
||||
// Backup-Policy speichern
|
||||
$cron = $this->buildCron();
|
||||
BackupPolicy::updateOrCreate([], [
|
||||
|
|
@ -183,7 +203,6 @@ class SettingsForm extends Component
|
|||
{
|
||||
$this->validate($this->domainRules(), $this->domainMessages());
|
||||
|
||||
// Normalize: strip schema + trailing slashes
|
||||
$this->ui_domain = $this->cleanDomain($this->ui_domain);
|
||||
$this->mail_domain = $this->cleanDomain($this->mail_domain);
|
||||
$this->webmail_domain = $this->cleanDomain($this->webmail_domain);
|
||||
|
|
@ -194,19 +213,88 @@ class SettingsForm extends Component
|
|||
'webmail_domain' => $this->webmail_domain,
|
||||
]);
|
||||
|
||||
// Immer .env aktualisieren — unabhängig von DNS oder Nginx
|
||||
$this->syncDomainsToEnv();
|
||||
|
||||
// Nginx-Konfiguration (ohne certbot) — nur wenn alle Domains gesetzt sind
|
||||
$warnings = [];
|
||||
|
||||
if ($this->ui_domain && $this->mail_domain && $this->webmail_domain) {
|
||||
$this->applyDomains(false);
|
||||
$nginxOk = $this->applyDomains(false);
|
||||
if ($nginxOk === false) {
|
||||
$warnings[] = 'Nginx konnte nicht neu geladen werden — DNS noch nicht aktiv oder Helper fehlt.';
|
||||
}
|
||||
}
|
||||
|
||||
$sysmailErr = $this->syncSysmailDomain();
|
||||
if ($sysmailErr) {
|
||||
$warnings[] = 'System-Domain: ' . $sysmailErr;
|
||||
}
|
||||
|
||||
if ($this->mail_domain) {
|
||||
$this->applyPostfixHostname($this->mail_domain);
|
||||
}
|
||||
|
||||
$this->loadSslStatus();
|
||||
|
||||
$this->dispatch('toast', type: 'done', badge: 'Domains',
|
||||
title: 'Domains gespeichert',
|
||||
text: 'Konfiguration wurde übernommen.', duration: 4000);
|
||||
if ($warnings) {
|
||||
$this->dispatch('toast', type: 'warn', badge: 'Domains',
|
||||
title: 'Domains gespeichert — mit Hinweisen',
|
||||
text: implode(' · ', $warnings),
|
||||
duration: 0);
|
||||
} else {
|
||||
$this->dispatch('toast', type: 'done', badge: 'Domains',
|
||||
title: 'Domains gespeichert',
|
||||
text: 'Konfiguration wurde übernommen.',
|
||||
duration: 4000);
|
||||
}
|
||||
}
|
||||
|
||||
private function syncSysmailDomain(): ?string
|
||||
{
|
||||
// BASE_DOMAIN aus config, env oder mail_domain ableiten
|
||||
$platformBase = strtolower((string) config('mailpool.platform_zone', ''));
|
||||
if (!$platformBase || $platformBase === 'example.com') {
|
||||
$platformBase = strtolower((string) env('BASE_DOMAIN', ''));
|
||||
}
|
||||
if (!$platformBase || $platformBase === 'example.com') {
|
||||
// Fallback: aus mail_domain den Hostnamen ohne ersten Subdomain-Teil
|
||||
$mailDomain = strtolower(trim((string) Setting::get('mail_domain', '')));
|
||||
if ($mailDomain) {
|
||||
$parts = explode('.', $mailDomain);
|
||||
$platformBase = count($parts) > 2
|
||||
? implode('.', array_slice($parts, 1))
|
||||
: $mailDomain;
|
||||
}
|
||||
}
|
||||
if (!$platformBase || $platformBase === 'example.com') return null;
|
||||
|
||||
$systemSub = strtolower(config('mailpool.platform_system_zone', 'sysmail'));
|
||||
$expectedFqdn = "{$systemSub}.{$platformBase}";
|
||||
|
||||
$existing = \App\Models\Domain::where('is_system', true)
|
||||
->where(fn($q) => $q->whereNull('is_server')->orWhere('is_server', false))
|
||||
->first();
|
||||
|
||||
if ($existing && $existing->domain === $expectedFqdn) return null;
|
||||
|
||||
if ($existing && $existing->domain !== $expectedFqdn) {
|
||||
\App\Models\MailUser::where('domain_id', $existing->id)->forceDelete();
|
||||
$existing->forceDelete();
|
||||
}
|
||||
|
||||
try {
|
||||
Artisan::call('db:seed', [
|
||||
'--class' => 'Database\\Seeders\\SystemDomainSeeder',
|
||||
'--force' => true,
|
||||
]);
|
||||
\Illuminate\Support\Facades\Log::info('SystemDomainSeeder ok', ['fqdn' => $expectedFqdn]);
|
||||
return null;
|
||||
} catch (\Throwable $e) {
|
||||
\Illuminate\Support\Facades\Log::error('SystemDomainSeeder failed', [
|
||||
'fqdn' => $expectedFqdn,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private function cleanDomain(string $value): string
|
||||
|
|
@ -216,74 +304,10 @@ class SettingsForm extends Component
|
|||
return rtrim($value, '/');
|
||||
}
|
||||
|
||||
public function openSslModal(): void
|
||||
{
|
||||
if (! ($this->ui_domain && $this->mail_domain && $this->webmail_domain)) {
|
||||
$this->dispatch('toast', type: 'warn', badge: 'SSL',
|
||||
title: 'Domains fehlen',
|
||||
text: 'Bitte erst alle drei Domains speichern.', duration: 5000);
|
||||
return;
|
||||
}
|
||||
$this->dispatch('openModal', component: 'ui.system.modal.ssl-provision-modal');
|
||||
}
|
||||
|
||||
#[\Livewire\Attributes\On('ssl:provision')]
|
||||
public function provisionSsl(): void
|
||||
{
|
||||
$this->applyDomains(true);
|
||||
}
|
||||
|
||||
public function startSslProvisioning(): void
|
||||
{
|
||||
if (! ($this->ui_domain && $this->webmail_domain)) {
|
||||
$this->dispatch('toast', type: 'warn', badge: 'SSL',
|
||||
title: 'Domains fehlen',
|
||||
text: 'Bitte erst UI-Domain und Webmail-Domain speichern.', duration: 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
@mkdir(self::SSL_STATE_DIR, 0755, true);
|
||||
@unlink(self::SSL_STATE_DIR . '/done');
|
||||
foreach (['ui', 'mail', 'webmail'] as $k) {
|
||||
file_put_contents(self::SSL_STATE_DIR . "/{$k}", 'pending');
|
||||
}
|
||||
|
||||
$this->sslProgress = ['ui' => 'pending', 'webmail' => 'pending', 'mail' => 'pending'];
|
||||
$this->sslDone = false;
|
||||
$this->sslProvisioning = true;
|
||||
|
||||
$artisan = base_path('artisan');
|
||||
$cmd = sprintf(
|
||||
'nohup php %s mailwolt:wizard-domains --ui=%s --mail=%s --webmail=%s --ssl=1 > /dev/null 2>&1 &',
|
||||
escapeshellarg($artisan),
|
||||
escapeshellarg($this->ui_domain),
|
||||
escapeshellarg($this->mail_domain),
|
||||
escapeshellarg($this->webmail_domain),
|
||||
);
|
||||
@shell_exec($cmd);
|
||||
}
|
||||
|
||||
public function pollSsl(): void
|
||||
{
|
||||
if (! $this->sslProvisioning || $this->sslDone) return;
|
||||
|
||||
foreach (['ui', 'mail', 'webmail'] as $key) {
|
||||
$file = self::SSL_STATE_DIR . "/{$key}";
|
||||
$this->sslProgress[$key] = is_readable($file)
|
||||
? trim((string) @file_get_contents($file))
|
||||
: 'pending';
|
||||
}
|
||||
|
||||
$done = @file_get_contents(self::SSL_STATE_DIR . '/done');
|
||||
if ($done !== false) {
|
||||
$this->sslDone = true;
|
||||
$this->sslProvisioning = false;
|
||||
$this->loadSslStatus();
|
||||
}
|
||||
}
|
||||
|
||||
public function loadSslStatus(): void
|
||||
{
|
||||
clearstatcache(true);
|
||||
|
||||
$domains = [
|
||||
'ui' => $this->ui_domain,
|
||||
'webmail' => $this->webmail_domain,
|
||||
|
|
@ -296,19 +320,30 @@ class SettingsForm extends Component
|
|||
$certs[$key] = ['domain' => '', 'has_cert' => false, 'expiry' => null, 'status' => 'nodomain'];
|
||||
continue;
|
||||
}
|
||||
$certFile = "/etc/letsencrypt/live/{$domain}/fullchain.pem";
|
||||
if (! file_exists($certFile)) {
|
||||
|
||||
$certFile = "/etc/letsencrypt/live/{$domain}/fullchain.pem";
|
||||
$certDir = "/etc/letsencrypt/live/{$domain}";
|
||||
// renewal/ ist immer 755 und world-readable — zuverlässigster Existenzcheck.
|
||||
// is_dir() als Fallback falls renewal-Datei fehlt (manuell ausgestellte Certs).
|
||||
$renewalConf = "/etc/letsencrypt/renewal/{$domain}.conf";
|
||||
$certExists = file_exists($renewalConf) || is_dir($certDir);
|
||||
|
||||
if (! $certExists) {
|
||||
$certs[$key] = ['domain' => $domain, 'has_cert' => false, 'expiry' => null, 'status' => 'missing'];
|
||||
continue;
|
||||
}
|
||||
$expiry = trim((string) @shell_exec("openssl x509 -enddate -noout -in " . escapeshellarg($certFile) . " 2>/dev/null | sed 's/notAfter=//'"));
|
||||
|
||||
// sudo nötig: archive/ ist 700 (Symlink-Ziel), voller Pfad für sudoers-Matching
|
||||
$expiry = trim((string) @shell_exec(
|
||||
"sudo -n /usr/bin/openssl x509 -enddate -noout -in " . escapeshellarg($certFile) . " 2>/dev/null | sed 's/notAfter=//'"
|
||||
));
|
||||
$expiryTs = $expiry ? strtotime($expiry) : null;
|
||||
$daysLeft = $expiryTs ? (int) round(($expiryTs - time()) / 86400) : null;
|
||||
$certStatus = match (true) {
|
||||
$daysLeft === null => 'ok',
|
||||
$daysLeft <= 0 => 'expired',
|
||||
$daysLeft <= 14 => 'expiring',
|
||||
default => 'ok',
|
||||
$daysLeft === null => 'ok',
|
||||
$daysLeft <= 0 => 'expired',
|
||||
$daysLeft <= 14 => 'expiring',
|
||||
default => 'ok',
|
||||
};
|
||||
$certs[$key] = [
|
||||
'domain' => $domain,
|
||||
|
|
@ -369,7 +404,7 @@ class SettingsForm extends Component
|
|||
}
|
||||
}
|
||||
|
||||
private function applyDomains(bool $sslAuto = false): void
|
||||
private function applyDomains(bool $sslAuto = false): bool
|
||||
{
|
||||
// DNS prüfen — Warnung anzeigen, aber Nginx trotzdem versuchen
|
||||
$unreachable = [];
|
||||
|
|
@ -380,12 +415,8 @@ class SettingsForm extends Component
|
|||
}
|
||||
|
||||
if ($unreachable) {
|
||||
$this->dispatch('toast', type: 'warn', badge: 'DNS',
|
||||
title: 'DNS noch nicht erreichbar',
|
||||
text: 'Kein DNS-Eintrag für: ' . implode(', ', $unreachable) . '. Domains wurden trotzdem gespeichert — Nginx-Konfiguration bitte nach DNS-Setup erneut speichern.',
|
||||
duration: 9000,
|
||||
);
|
||||
return;
|
||||
Log::info('mailwolt-apply-domains skipped (DNS not ready)', ['unreachable' => $unreachable]);
|
||||
return false;
|
||||
}
|
||||
|
||||
$helper = '/usr/local/sbin/mailwolt-apply-domains';
|
||||
|
|
@ -405,46 +436,47 @@ class SettingsForm extends Component
|
|||
|
||||
if ($ok) {
|
||||
Setting::set('ssl_configured', '1');
|
||||
$this->dispatch('toast', type: 'done', badge: 'Nginx',
|
||||
title: 'Nginx aktualisiert',
|
||||
text: 'Nginx-Konfiguration wurde neu geladen.',
|
||||
duration: 5000,
|
||||
);
|
||||
} else {
|
||||
$lines = array_filter(
|
||||
explode("\n", (string) $output),
|
||||
fn($l) => preg_match('/\[!\]|error|failed|fehler|nginx\s+-t/i', trim($l))
|
||||
);
|
||||
$detail = implode(' · ', array_map('trim', array_slice(array_values($lines), 0, 3)));
|
||||
|
||||
$this->dispatch('toast', type: 'warn', badge: 'Nginx',
|
||||
title: 'Nginx-Konfiguration fehlgeschlagen',
|
||||
text: $detail ?: 'Nginx konnte nicht neu geladen werden. Domains gespeichert. Siehe Laravel-Log.',
|
||||
duration: 10000,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
Log::warning('mailwolt-apply-domains failed', ['output' => $output]);
|
||||
return false;
|
||||
}
|
||||
|
||||
private function syncDomainsToEnv(): void
|
||||
{
|
||||
$base = rtrim((string) config('mailwolt.domain.base', ''), '.');
|
||||
// BASE_DOMAIN aus mail_domain ableiten (mx.pxo.at → pxo.at)
|
||||
$derivedBase = '';
|
||||
if ($this->mail_domain) {
|
||||
$parts = explode('.', strtolower(trim($this->mail_domain)));
|
||||
$derivedBase = count($parts) > 2
|
||||
? implode('.', array_slice($parts, 1))
|
||||
: implode('.', $parts);
|
||||
}
|
||||
|
||||
$base = $derivedBase ?: rtrim((string) config('clubird.domain.base', ''), '.');
|
||||
|
||||
$sub = function (string $full) use ($base): string {
|
||||
$full = strtolower(trim($full));
|
||||
if ($base && str_ends_with($full, '.' . $base)) {
|
||||
return substr($full, 0, -(strlen($base) + 1));
|
||||
}
|
||||
// Kein passender Base — ersten Label nehmen
|
||||
$parts = explode('.', $full);
|
||||
return count($parts) > 1 ? $parts[0] : '';
|
||||
};
|
||||
|
||||
$this->writeEnv([
|
||||
$env = [
|
||||
'WEBMAIL_SUB' => $this->webmail_domain ? $sub($this->webmail_domain) : '',
|
||||
'WEBMAIL_DOMAIN' => $this->webmail_domain ?: '',
|
||||
'UI_SUB' => $this->ui_domain ? $sub($this->ui_domain) : '',
|
||||
'MTA_SUB' => $this->mail_domain ? $sub($this->mail_domain) : '',
|
||||
]);
|
||||
];
|
||||
|
||||
if ($derivedBase && $derivedBase !== 'example.com') {
|
||||
$env['BASE_DOMAIN'] = $derivedBase;
|
||||
}
|
||||
|
||||
$this->writeEnv($env);
|
||||
|
||||
Artisan::call('config:clear');
|
||||
Artisan::call('route:clear');
|
||||
|
|
@ -470,9 +502,23 @@ 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()
|
||||
{
|
||||
$timezones = \DateTimeZone::listIdentifiers(\DateTimeZone::ALL);
|
||||
return view('livewire.ui.system.settings-form', compact('timezones'));
|
||||
if (!in_array($this->tab, self::VALID_TABS, true)) {
|
||||
$this->tab = 'allgemein';
|
||||
}
|
||||
|
||||
$timezones = \DateTimeZone::listIdentifiers(\DateTimeZone::ALL);
|
||||
$backupMeta = BackupPolicy::first();
|
||||
return view('livewire.ui.system.settings-form', compact('timezones', 'backupMeta'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,8 @@ class UpdateCard extends Component
|
|||
$this->recompute();
|
||||
$this->progressLine = '';
|
||||
|
||||
if ($this->running) {
|
||||
if ($this->running || $this->lowState === 'done') {
|
||||
// Update läuft oder abgeschlossen aber Post-Actions noch nicht ausgeführt
|
||||
$this->state = 'running';
|
||||
$this->dispatch('openModal', component: 'ui.system.modal.update-modal');
|
||||
}
|
||||
|
|
@ -66,24 +67,45 @@ class UpdateCard extends Component
|
|||
|
||||
public function runUpdate(): void
|
||||
{
|
||||
// evtl. alte Einträge aufräumen
|
||||
// State-Dateien VOR dem Start zurücksetzen — verhindert dass pollUpdate()
|
||||
// das "done" vom letzten Update liest und sofort als "fertig" wertet.
|
||||
@mkdir('/var/lib/mailwolt/update', 0755, true);
|
||||
@file_put_contents('/var/lib/mailwolt/update/state', 'starting');
|
||||
@unlink('/var/lib/mailwolt/update/rc');
|
||||
|
||||
Cache::forget('mailwolt.update_available');
|
||||
Cache::put($this->cacheStartedAtKey, time(), now()->addHour());
|
||||
$this->dispatch('openModal', component: 'ui.system.modal.update-modal');
|
||||
@shell_exec('nohup sudo -n /usr/local/sbin/mailwolt-update >/dev/null 2>&1 &');
|
||||
|
||||
// Sofort ins Running gehen
|
||||
$this->latest = null;
|
||||
$this->latest = null;
|
||||
$this->displayLatest = null;
|
||||
$this->hasUpdate = false;
|
||||
$this->state = 'running';
|
||||
$this->running = true;
|
||||
$this->errorLine = null;
|
||||
$this->hasUpdate = false;
|
||||
$this->state = 'running';
|
||||
$this->running = true;
|
||||
$this->rc = null;
|
||||
$this->lowState = 'starting';
|
||||
$this->errorLine = null;
|
||||
$this->progressLine = 'Update gestartet …';
|
||||
$this->postActionsDone = false;
|
||||
|
||||
$this->recomputeUi();
|
||||
}
|
||||
|
||||
public function openLogs(): void
|
||||
{
|
||||
$this->dispatch('openModal', component: 'ui.system.modal.update-modal');
|
||||
}
|
||||
|
||||
public function refreshVersions(): void
|
||||
{
|
||||
if ($this->state === 'running') return;
|
||||
$this->reloadVersionsAndStatus();
|
||||
$this->recompute();
|
||||
$this->recomputeUi();
|
||||
}
|
||||
|
||||
public function pollUpdate(): void
|
||||
{
|
||||
$this->refreshLowLevelState();
|
||||
|
|
@ -112,22 +134,30 @@ class UpdateCard 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 optimize >/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 &');
|
||||
@shell_exec('nohup php /var/www/mailwolt/artisan spamav:collect >/dev/null 2>&1 &');
|
||||
@shell_exec('nohup php /var/www/mailwolt/artisan rbl:probe --force >/dev/null 2>&1 &');
|
||||
$this->postActionsDone = true;
|
||||
@file_put_contents('/var/lib/mailwolt/update/state', 'complete');
|
||||
|
||||
$ver = $this->displayCurrent ?? 'aktuelle Version';
|
||||
$this->progressLine = 'Update abgeschlossen: ' . $ver;
|
||||
// Optional: NICHT sofort reloaden – Nutzer entscheidet
|
||||
// $this->dispatch('reload-page', delay: 6000);
|
||||
|
||||
$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';
|
||||
|
|
@ -200,7 +230,7 @@ class UpdateCard extends Component
|
|||
// Update-Checker schreibt:
|
||||
// - updates:latest (normiert)
|
||||
// - updates:latest_raw (original)
|
||||
$latNorm = Cache::get('updates:latest');
|
||||
$latNorm = $this->normalizeVersion(Cache::get('updates:latest') ?? '');
|
||||
$latRaw = Cache::get('updates:latest_raw');
|
||||
|
||||
// Legacy-Fallback
|
||||
|
|
@ -226,7 +256,9 @@ class UpdateCard extends Component
|
|||
|
||||
$this->displayCurrent = $curNorm ? 'v' . $curNorm : null;
|
||||
|
||||
if (!$this->displayLatest && $latNorm) {
|
||||
if (!$this->hasUpdate) {
|
||||
$this->displayLatest = null;
|
||||
} elseif (!$this->displayLatest && $latNorm) {
|
||||
$this->displayLatest = 'v' . $latNorm;
|
||||
}
|
||||
}
|
||||
|
|
@ -306,11 +338,10 @@ class UpdateCard extends Component
|
|||
|
||||
$this->lowState = $state !== '' ? $state : null;
|
||||
|
||||
// running: solange NICHT 'done'
|
||||
$this->running = ($this->lowState !== 'done');
|
||||
$this->running = ($this->lowState === 'running');
|
||||
|
||||
// rc erst freigeben, wenn wirklich done
|
||||
$this->rc = ($this->lowState === 'done' && is_numeric($rcRaw)) ? (int)$rcRaw : null;
|
||||
// rc freigeben wenn 'done' (Post-Actions ausstehend) oder 'complete' (bereits gelaufen)
|
||||
$this->rc = (in_array($this->lowState, ['done', 'complete']) && is_numeric($rcRaw)) ? (int)$rcRaw : null;
|
||||
|
||||
$this->progressLine = $this->tailUpdateLog();
|
||||
}
|
||||
|
|
@ -318,27 +349,24 @@ class UpdateCard extends Component
|
|||
protected function readCurrentVersion(): ?string
|
||||
{
|
||||
// 1) normierte Version vom Wrapper
|
||||
$v = @trim(@file_get_contents(self::VERSION_FILE) ?: '');
|
||||
if ($v !== '') return $v;
|
||||
$fileVer = $this->normalizeVersion(@trim(@file_get_contents(self::VERSION_FILE) ?: ''));
|
||||
|
||||
// 2) raw -> normieren
|
||||
// 2) git-Tag (bevorzugt wenn neuer als Datei – z.B. auf Dev-Server)
|
||||
$out = [];
|
||||
@exec('git -C ' . escapeshellarg(base_path()) . ' describe --tags --abbrev=0 2>/dev/null', $out);
|
||||
$gitVer = $this->normalizeVersion(trim($out[0] ?? ''));
|
||||
|
||||
if ($gitVer && (!$fileVer || version_compare($gitVer, $fileVer, '>'))) {
|
||||
return $gitVer;
|
||||
}
|
||||
if ($fileVer) return $fileVer;
|
||||
|
||||
// 3) raw -> normieren
|
||||
$raw = @trim(@file_get_contents(self::VERSION_FILE_RAW) ?: '');
|
||||
if ($raw !== '') return $this->normalizeVersion($raw);
|
||||
|
||||
// 3) build.info
|
||||
$build = @file_get_contents(self::BUILD_INFO);
|
||||
if ($build) {
|
||||
foreach (preg_split('/\R+/', $build) as $line) {
|
||||
if (str_starts_with($line, 'version=')) {
|
||||
$v = $this->normalizeVersion(trim(substr($line, 8)));
|
||||
if ($v) return $v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Fallback auf config(app.version)
|
||||
$v = $this->normalizeVersion(config('app.version') ?: '');
|
||||
return $v ?: null;
|
||||
return $this->normalizeVersion(config('app.version') ?: '') ?: null;
|
||||
}
|
||||
|
||||
protected function normalizeVersion(?string $v): ?string
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ class UpdatePage extends Component
|
|||
|
||||
public function checkForUpdates(): void
|
||||
{
|
||||
@shell_exec('php ' . base_path('artisan') . ' mailwolt:check-updates 2>&1');
|
||||
@shell_exec('php ' . base_path('artisan') . ' clubird:check-updates 2>&1');
|
||||
$this->reloadVersionsAndStatus();
|
||||
$this->recompute();
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
@ -169,7 +170,7 @@ class UpdatePage extends Component
|
|||
{
|
||||
$this->current = $this->readCurrentVersion();
|
||||
|
||||
$latNorm = Cache::get('updates:latest');
|
||||
$latNorm = $this->normalizeVersion(Cache::get('updates:latest') ?? '');
|
||||
$latRaw = Cache::get('updates:latest_raw');
|
||||
|
||||
if (!$latNorm && ($legacy = Cache::get('mailwolt.update_available'))) {
|
||||
|
|
@ -194,7 +195,9 @@ class UpdatePage extends Component
|
|||
|
||||
$this->displayCurrent = $curNorm ? 'v' . $curNorm : null;
|
||||
|
||||
if (!$this->displayLatest && $latNorm) {
|
||||
if (!$this->hasUpdate) {
|
||||
$this->displayLatest = null;
|
||||
} elseif (!$this->displayLatest && $latNorm) {
|
||||
$this->displayLatest = 'v' . $latNorm;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,11 +17,17 @@ 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);
|
||||
|
||||
// Service erledigt: Key generieren, DB (upsert) pflegen, Helper ausführen, OpenDKIM reloaden
|
||||
app(\App\Services\DkimService::class)->generateForDomain($domain, $bits, $selector);
|
||||
try {
|
||||
app(\App\Services\DkimService::class)->generateForDomain($domain, $bits, $selector);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('DKIM install skipped (sudo/helper not ready)', [
|
||||
'domain' => $domain->domain,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
// DNS-Records: aktiven Key aus DB lesen und provisionieren
|
||||
$active = $domain->dkimKeys()->where('is_active', true)->latest()->first();
|
||||
|
|
@ -50,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];
|
||||
|
|
@ -130,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)
|
||||
|
|
|
|||
|
|
@ -40,7 +40,16 @@ class AppServiceProvider extends ServiceProvider
|
|||
} catch (\Throwable) {}
|
||||
});
|
||||
|
||||
config(['app.version' => trim(@file_get_contents('/var/lib/mailwolt/version')) ?: 'dev']);
|
||||
$fileVer = trim(@file_get_contents('/var/lib/mailwolt/version') ?: '');
|
||||
$out = []; @exec('git -C ' . escapeshellarg(base_path()) . ' describe --tags --abbrev=0 2>/dev/null', $out);
|
||||
$gitVer = ltrim(trim($out[0] ?? ''), 'vV');
|
||||
$appVer = ($gitVer && (!$fileVer || version_compare($gitVer, $fileVer, '>'))) ? $gitVer : ($fileVer ?: 'dev');
|
||||
// Versions-Datei selbst aktualisieren wenn veraltet
|
||||
if ($appVer !== 'dev' && $appVer !== $fileVer) {
|
||||
@mkdir('/var/lib/mailwolt', 0755, true);
|
||||
@file_put_contents('/var/lib/mailwolt/version', $appVer);
|
||||
}
|
||||
config(['app.version' => $appVer]);
|
||||
if (file_exists(base_path('.git/HEAD'))) {
|
||||
$ref = trim(file_get_contents(base_path('.git/HEAD')));
|
||||
if (str_starts_with($ref, 'ref:')) {
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -69,18 +69,21 @@ class SandboxService
|
|||
$content .= "\n";
|
||||
}
|
||||
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'mw_sandbox_');
|
||||
try {
|
||||
file_put_contents($file, $content);
|
||||
file_put_contents($tmp, $content);
|
||||
} catch (\Throwable $e) {
|
||||
@unlink($tmp);
|
||||
return ['ok' => false, 'message' => 'Fehler beim Schreiben: ' . $e->getMessage()];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
$code = 0;
|
||||
exec('postmap ' . escapeshellarg($file) . ' 2>&1', $out, $code);
|
||||
exec('sudo -n /usr/local/sbin/mailwolt-sandbox-sync ' . escapeshellarg($tmp) . ' 2>&1', $out, $code);
|
||||
@unlink($tmp);
|
||||
|
||||
if ($code !== 0) {
|
||||
return ['ok' => false, 'message' => 'postmap Fehler: ' . implode(' ', $out)];
|
||||
return ['ok' => false, 'message' => 'Sync-Fehler: ' . implode(' ', $out)];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'message' => 'Transport-Datei synchronisiert (' . count($lines) . ' Einträge).'];
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
return [
|
||||
'platform_zone' => env('BASE_DOMAIN', 'example.com'),
|
||||
'platform_system_zone' => env('SYSMAIL_SUB', 'sysmail'),
|
||||
'mta_sub' => env('MTA_SUB', 'mail'),
|
||||
|
||||
'fixed_reserve_mb' => env('MAILPOOL_FIXED_RESERVE_MB', 2048), // 2 GB
|
||||
'percent_reserve' => env('MAILPOOL_PERCENT_RESERVE', 10), // 10 %
|
||||
|
|
@ -18,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),
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ return [
|
|||
'label' => 'Sicherheit', 'icon' => 'ph-shield', 'items' => [
|
||||
['label' => 'TLS & Ciphers', 'route' => 'ui.security.tls'],
|
||||
['label' => 'Ratelimits', 'route' => 'ui.security.abuse'],
|
||||
['label' => 'Virenschutz', 'route' => 'ui.security.clamav'],
|
||||
['label' => 'Audit-Logs', 'route' => 'ui.security.audit'],
|
||||
],
|
||||
],
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@
|
|||
|
||||
|
||||
return [
|
||||
// Nur diese Keys erscheinen im Dashboard-Widget
|
||||
'dashboard' => [
|
||||
'postfix', 'dovecot', 'rspamd', 'clamav', 'db', 'redis', 'nginx', 'mw-ws', 'fail2ban',
|
||||
],
|
||||
|
||||
'cards' => [
|
||||
// Mail
|
||||
'postfix' => [
|
||||
|
|
@ -18,9 +23,10 @@ return [
|
|||
],
|
||||
'clamav' => [
|
||||
'label' => 'ClamAV', 'hint' => 'Virenscanner',
|
||||
'optional' => true,
|
||||
'sources' => [
|
||||
'systemd:clamav-daemon', 'systemd:clamav-daemon@scan', 'systemd:clamd',
|
||||
'socket:/run/clamav/clamd.ctl', 'pid:/run/clamav/clamd.pid', 'tcp:127.0.0.1:3310',
|
||||
// Nur systemd – Socket-Datei bleibt nach dem Stoppen erhalten (false positive)
|
||||
'systemd:clamav-daemon',
|
||||
],
|
||||
],
|
||||
|
||||
|
|
@ -64,6 +70,10 @@ return [
|
|||
],
|
||||
|
||||
// Sonstiges
|
||||
'monit' => [
|
||||
'label' => 'Monit', 'hint' => 'Prozess-Monitoring',
|
||||
'sources' => ['systemd:monit', 'tcp:127.0.0.1:2812'],
|
||||
],
|
||||
'fail2ban' => [
|
||||
'label' => 'Fail2Ban', 'hint' => 'SSH / Mail Protection',
|
||||
'sources' => ['systemd:fail2ban'],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -140,7 +140,7 @@ class SystemDomainSeeder extends Seeder
|
|||
$this->command->line("System-Domain angelegt: {$systemDomain->domain}");
|
||||
}
|
||||
|
||||
$noReply = MailUser::firstOrCreate(
|
||||
$noReply = MailUser::updateOrCreate(
|
||||
['domain_id' => $systemDomain->id, 'localpart' => 'no-reply'],
|
||||
[
|
||||
'email' => 'no-reply@' . $systemDomain->domain,
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -591,6 +591,7 @@ www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-apply-domains
|
|||
www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-fetch-tags
|
||||
www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-update
|
||||
www-data ALL=(root) NOPASSWD: /usr/bin/certbot
|
||||
www-data ALL=(root) NOPASSWD: /usr/bin/openssl x509 -enddate -noout -in /etc/letsencrypt/live/*/fullchain.pem
|
||||
SUDOERS
|
||||
chmod 440 /etc/sudoers.d/mailwolt-certbot
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,418 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>CluBird — Logo</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Syne:wght@700;800&family=DM+Sans:wght@400;500&display=swap');
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #07080e;
|
||||
--surface: #0b0d16;
|
||||
--border: #181c2e;
|
||||
--border2: #252840;
|
||||
--acc: #6366f1;
|
||||
--acc-h: #4f46e5;
|
||||
--acc-l: #818cf8;
|
||||
--acc-bg: #1a1a3a;
|
||||
--acc-bd: #2d2f5a;
|
||||
--t1: #e8eaf6;
|
||||
--t2: #9a9ec8;
|
||||
--t3: #4a5070;
|
||||
--t4: #252840;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--t1);
|
||||
font-family: 'DM Sans', system-ui, sans-serif;
|
||||
padding: 40px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--t3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section { margin-bottom: 48px; }
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 28px 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-light {
|
||||
background: #f0f2ff;
|
||||
border: 1px solid #d4d8ff;
|
||||
border-radius: 14px;
|
||||
padding: 28px 32px;
|
||||
}
|
||||
|
||||
.card-label {
|
||||
font-size: 9px;
|
||||
color: var(--t3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* ── Logo Lockup ── */
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
flex-shrink: 0;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.5px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.wordmark .clu { color: var(--t1); }
|
||||
.wordmark .bird { color: var(--acc); }
|
||||
.wordmark-light .clu { color: #0f1120; }
|
||||
.wordmark-light .bird { color: var(--acc); }
|
||||
|
||||
.tagline {
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
color: var(--t3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.8px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
/* ── Divider ── */
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 40px;
|
||||
background: var(--border);
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
/* ── Family ── */
|
||||
.family {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 20px 28px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.family-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.plus {
|
||||
font-size: 16px;
|
||||
color: var(--border2);
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.family-name {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.family-sub {
|
||||
font-size: 8.5px;
|
||||
color: var(--t3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ── 1. HAUPTLOGO ── -->
|
||||
<div class="section">
|
||||
<h2>Hauptlogo — Dark Mode</h2>
|
||||
<div class="row">
|
||||
|
||||
<!-- Groß -->
|
||||
<div>
|
||||
<div class="card-label">Groß</div>
|
||||
<div class="card">
|
||||
<div class="logo">
|
||||
<div class="logo-icon" style="width:48px;height:48px;background:linear-gradient(135deg,#6366f1 0%,#4338ca 100%);box-shadow:0 0 20px rgba(99,102,241,.35);">
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
|
||||
<!-- Körper -->
|
||||
<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"/>
|
||||
<!-- Flügel links oben -->
|
||||
<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" stroke-linejoin="round" fill="none"/>
|
||||
<!-- Flügel rechts -->
|
||||
<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" stroke-linejoin="round" fill="none"/>
|
||||
<!-- Auge -->
|
||||
<circle cx="19" cy="8" r="1.5" fill="white" opacity=".95"/>
|
||||
<!-- Schwanz / Brust -->
|
||||
<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="wordmark" style="font-size:28px;">
|
||||
<span class="clu">Clu</span><span class="bird">Bird</span>
|
||||
</div>
|
||||
<div class="tagline">Mail Server Management</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Standard -->
|
||||
<div>
|
||||
<div class="card-label">Standard</div>
|
||||
<div class="card">
|
||||
<div class="logo">
|
||||
<div class="logo-icon" style="width:36px;height:36px;background:linear-gradient(135deg,#6366f1 0%,#4338ca 100%);box-shadow:0 0 14px rgba(99,102,241,.3);">
|
||||
<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 class="wordmark" style="font-size:21px;">
|
||||
<span class="clu">Clu</span><span class="bird">Bird</span>
|
||||
</div>
|
||||
<div class="tagline">by Aziros</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Klein / Sidebar -->
|
||||
<div>
|
||||
<div class="card-label">Klein / Sidebar</div>
|
||||
<div class="card">
|
||||
<div class="logo">
|
||||
<div class="logo-icon" style="width:28px;height:28px;background:linear-gradient(135deg,#6366f1 0%,#4338ca 100%);border-radius:7px;">
|
||||
<svg width="16" height="16" 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" opacity=".95"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="wordmark" style="font-size:16px;">
|
||||
<span class="clu">Clu</span><span class="bird">Bird</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 2. LIGHT MODE ── -->
|
||||
<div class="section">
|
||||
<h2>Light Mode</h2>
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="card-label">Standard Light</div>
|
||||
<div class="card-light">
|
||||
<div class="logo">
|
||||
<div class="logo-icon" style="width:36px;height:36px;background:linear-gradient(135deg,#6366f1 0%,#4338ca 100%);border-radius:10px;box-shadow:0 4px 12px rgba(99,102,241,.25);">
|
||||
<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 class="wordmark wordmark-light" style="font-size:21px;">
|
||||
<span class="clu">Clu</span><span class="bird">Bird</span>
|
||||
</div>
|
||||
<div class="tagline" style="color:#8892aa;">Mail Server Management</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 3. ICON VARIANTEN ── -->
|
||||
<div class="section">
|
||||
<h2>Icon-Only — alle Größen</h2>
|
||||
<div class="card" style="display:flex;gap:20px;align-items:flex-end;width:fit-content;">
|
||||
|
||||
<!-- 64px -->
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:8px;">
|
||||
<div style="width:64px;height:64px;border-radius:16px;background:linear-gradient(135deg,#6366f1,#4338ca);display:flex;align-items:center;justify-content:center;box-shadow:0 0 24px rgba(99,102,241,.4);">
|
||||
<svg width="38" height="38" 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>
|
||||
<span style="font-size:9px;color:var(--t3);">64px</span>
|
||||
</div>
|
||||
|
||||
<!-- 48px -->
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:8px;">
|
||||
<div style="width:48px;height:48px;border-radius:12px;background:linear-gradient(135deg,#6366f1,#4338ca);display:flex;align-items:center;justify-content:center;box-shadow:0 0 16px 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>
|
||||
<span style="font-size:9px;color:var(--t3);">48px</span>
|
||||
</div>
|
||||
|
||||
<!-- 36px -->
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:8px;">
|
||||
<div style="width:36px;height:36px;border-radius:9px;background:linear-gradient(135deg,#6366f1,#4338ca);display:flex;align-items:center;justify-content:center;">
|
||||
<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"/>
|
||||
<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>
|
||||
<span style="font-size:9px;color:var(--t3);">36px</span>
|
||||
</div>
|
||||
|
||||
<!-- 24px -->
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:8px;">
|
||||
<div style="width:24px;height:24px;border-radius:6px;background:linear-gradient(135deg,#6366f1,#4338ca);display:flex;align-items:center;justify-content:center;">
|
||||
<svg width="14" height="14" 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"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span style="font-size:9px;color:var(--t3);">24px</span>
|
||||
</div>
|
||||
|
||||
<!-- Favicon -->
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:8px;">
|
||||
<div style="width:16px;height:16px;border-radius:4px;background:linear-gradient(135deg,#6366f1,#4338ca);display:flex;align-items:center;justify-content:center;">
|
||||
<svg width="10" height="10" 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="3" 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="3" stroke-linecap="round" fill="none"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span style="font-size:9px;color:var(--t3);">Favicon</span>
|
||||
</div>
|
||||
|
||||
<!-- Rund -->
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:8px;">
|
||||
<div style="width:48px;height:48px;border-radius:50%;background:linear-gradient(135deg,#6366f1,#4338ca);display:flex;align-items:center;justify-content:center;box-shadow:0 0 16px 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>
|
||||
<span style="font-size:9px;color:var(--t3);">Rund</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 4. WORDMARK VARIANTEN ── -->
|
||||
<div class="section">
|
||||
<h2>Wordmark</h2>
|
||||
<div class="card" style="display:flex;flex-direction:column;gap:16px;width:fit-content;">
|
||||
<div class="wordmark" style="font-size:32px;"><span class="clu">Clu</span><span class="bird">Bird</span></div>
|
||||
<div class="wordmark" style="font-size:22px;"><span class="clu">Clu</span><span class="bird">Bird</span></div>
|
||||
<div style="font-family:'Syne',sans-serif;font-size:18px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:var(--t1);">CLU<span style="color:var(--acc);">BIRD</span></div>
|
||||
<div style="font-family:'Syne',sans-serif;font-size:16px;font-weight:800;color:var(--t1);">clu<span style="color:var(--acc);">bird</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 5. PRODUKTFAMILIE ── -->
|
||||
<div class="section">
|
||||
<h2>Aziros Produktfamilie</h2>
|
||||
<div class="family">
|
||||
|
||||
<!-- CluPilot -->
|
||||
<div class="family-item">
|
||||
<div style="width:32px;height:32px;border-radius:8px;background:linear-gradient(135deg,#6366f1,#4338ca);display:flex;align-items:center;justify-content:center;box-shadow:0 0 10px rgba(99,102,241,.3);">
|
||||
<span style="font-size:11px;font-weight:800;color:#fff;font-family:'Syne',sans-serif;">CP</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="family-name"><span style="color:var(--t1);">Clu</span><span style="color:var(--acc);">Pilot</span></div>
|
||||
<div class="family-sub">Infrastruktur</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="plus">+</div>
|
||||
|
||||
<!-- CluBird -->
|
||||
<div class="family-item">
|
||||
<div style="width:32px;height:32px;border-radius:8px;background:linear-gradient(135deg,#6366f1,#4338ca);display:flex;align-items:center;justify-content:center;box-shadow:0 0 10px rgba(99,102,241,.3);">
|
||||
<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"/>
|
||||
<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="family-name"><span style="color:var(--t1);">Clu</span><span style="color:var(--acc);">Bird</span></div>
|
||||
<div class="family-sub">Mail Management</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="plus">=</div>
|
||||
|
||||
<!-- Aziros Suite -->
|
||||
<div>
|
||||
<div style="font-family:'Syne',sans-serif;font-size:15px;font-weight:800;color:var(--t1);">Aziros <span style="color:var(--acc);">Suite</span></div>
|
||||
<div class="family-sub">Infrastructure + Mail</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" width="28" height="28">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#6366f1"/>
|
||||
<stop offset="100%" stop-color="#4338ca"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="28" height="28" rx="7" fill="url(#bg)"/>
|
||||
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="3" 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="3" stroke-linecap="round" fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 595 B |
|
|
@ -0,0 +1,12 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" width="28" height="28">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#6366f1"/>
|
||||
<stop offset="100%" stop-color="#4338ca"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="28" height="28" rx="7" fill="url(#bg)"/>
|
||||
<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"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 645 B |
|
|
@ -0,0 +1,14 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" width="28" height="28">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#6366f1"/>
|
||||
<stop offset="100%" stop-color="#4338ca"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="28" height="28" rx="7" fill="url(#bg)"/>
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 913 B |
|
|
@ -0,0 +1,20 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 48" width="220" height="48">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#6366f1"/>
|
||||
<stop offset="100%" stop-color="#4338ca"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Icon -->
|
||||
<rect width="48" height="48" rx="12" fill="url(#bg)"/>
|
||||
<path d="M9 35 C9 35 14 18 26 16 C33 14.5 40 19.5 41.5 27" stroke="white" stroke-width="2.8" stroke-linecap="round" fill="none"/>
|
||||
<path d="M26 16 C26 16 21 8.5 14 8.5 C16.5 12 17 16 19 17.5" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
|
||||
<path d="M41.5 27 C41.5 27 46.5 23.5 48 18.5 C45.5 19.5 41.5 21.5 40 24" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
|
||||
<circle cx="33" cy="14" r="2.5" fill="white" opacity=".95"/>
|
||||
<path d="M9 35 C11.5 39.5 16 42 21 42 C26 42 31 39.5 33.5 35" stroke="white" stroke-width="2.8" stroke-linecap="round" fill="none"/>
|
||||
<!-- Wordmark -->
|
||||
<text x="62" y="31" font-family="system-ui,-apple-system,sans-serif" font-size="24" font-weight="800" letter-spacing="-0.5" fill="#e8eaf6">Clu</text>
|
||||
<text x="98" y="31" font-family="system-ui,-apple-system,sans-serif" font-size="24" font-weight="800" letter-spacing="-0.5" fill="#6366f1">Bird</text>
|
||||
<!-- Tagline -->
|
||||
<text x="62" y="43" font-family="system-ui,-apple-system,sans-serif" font-size="9" font-weight="500" letter-spacing="1.6" fill="#4a5070">MAIL SERVER MANAGEMENT</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
|
@ -0,0 +1,16 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 130 28" width="130" height="28">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#6366f1"/>
|
||||
<stop offset="100%" stop-color="#4338ca"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Icon -->
|
||||
<rect width="28" height="28" rx="7" fill="url(#bg)"/>
|
||||
<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"/>
|
||||
<!-- Wordmark -->
|
||||
<text x="37" y="19" font-family="system-ui,-apple-system,sans-serif" font-size="16" font-weight="800" letter-spacing="-0.3" fill="#e8eaf6">Clu</text>
|
||||
<text x="60" y="19" font-family="system-ui,-apple-system,sans-serif" font-size="16" font-weight="800" letter-spacing="-0.3" fill="#6366f1">Bird</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 988 B |
|
|
@ -1,8 +1,9 @@
|
|||
@import 'tailwindcss';
|
||||
@import '../js/plugins/GlassToastra/style.css';
|
||||
@import '../js/plugins/Toastra/src/message.css';
|
||||
|
||||
@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);
|
||||
|
|
@ -1701,6 +1705,13 @@ select {
|
|||
color: var(--mw-t3); cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.mbx-btn-mute:hover { border-color: var(--mw-b2); color: var(--mw-t2); }
|
||||
.mbx-btn-danger {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
background: transparent; border: 1px solid var(--mw-rdbd, rgba(239,68,68,.3));
|
||||
border-radius: 6px; padding: 6px 11px; font-size: 12px; font-weight: 500;
|
||||
color: var(--mw-rd, #f87171); cursor: pointer; white-space: nowrap; transition: all .1s;
|
||||
}
|
||||
.mbx-btn-danger:hover { background: var(--mw-rdbg, rgba(239,68,68,.08)); border-color: #ef4444; color: #fca5a5; }
|
||||
|
||||
/* Domain sections */
|
||||
.mbx-sections { display: flex; flex-direction: column; gap: 14px; }
|
||||
|
|
|
|||
|
|
@ -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,11 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" width="28" height="28">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#6366f1"/>
|
||||
<stop offset="100%" stop-color="#4338ca"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="28" height="28" rx="7" fill="url(#bg)"/>
|
||||
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="3" 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="3" stroke-linecap="round" fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 595 B |
|
|
@ -0,0 +1,12 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" width="28" height="28">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#6366f1"/>
|
||||
<stop offset="100%" stop-color="#4338ca"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="28" height="28" rx="7" fill="url(#bg)"/>
|
||||
<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"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 645 B |
|
|
@ -0,0 +1,14 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" width="28" height="28">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#6366f1"/>
|
||||
<stop offset="100%" stop-color="#4338ca"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="28" height="28" rx="7" fill="url(#bg)"/>
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 913 B |
|
|
@ -0,0 +1,20 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 48" width="220" height="48">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#6366f1"/>
|
||||
<stop offset="100%" stop-color="#4338ca"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Icon -->
|
||||
<rect width="48" height="48" rx="12" fill="url(#bg)"/>
|
||||
<path d="M9 35 C9 35 14 18 26 16 C33 14.5 40 19.5 41.5 27" stroke="white" stroke-width="2.8" stroke-linecap="round" fill="none"/>
|
||||
<path d="M26 16 C26 16 21 8.5 14 8.5 C16.5 12 17 16 19 17.5" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
|
||||
<path d="M41.5 27 C41.5 27 46.5 23.5 48 18.5 C45.5 19.5 41.5 21.5 40 24" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
|
||||
<circle cx="33" cy="14" r="2.5" fill="white" opacity=".95"/>
|
||||
<path d="M9 35 C11.5 39.5 16 42 21 42 C26 42 31 39.5 33.5 35" stroke="white" stroke-width="2.8" stroke-linecap="round" fill="none"/>
|
||||
<!-- Wordmark -->
|
||||
<text x="62" y="31" font-family="system-ui,-apple-system,sans-serif" font-size="24" font-weight="800" letter-spacing="-0.5" fill="#e8eaf6">Clu</text>
|
||||
<text x="98" y="31" font-family="system-ui,-apple-system,sans-serif" font-size="24" font-weight="800" letter-spacing="-0.5" fill="#6366f1">Bird</text>
|
||||
<!-- Tagline -->
|
||||
<text x="62" y="43" font-family="system-ui,-apple-system,sans-serif" font-size="9" font-weight="500" letter-spacing="1.6" fill="#4a5070">MAIL SERVER MANAGEMENT</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
|
@ -0,0 +1,16 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 130 28" width="130" height="28">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#6366f1"/>
|
||||
<stop offset="100%" stop-color="#4338ca"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Icon -->
|
||||
<rect width="28" height="28" rx="7" fill="url(#bg)"/>
|
||||
<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"/>
|
||||
<!-- Wordmark -->
|
||||
<text x="37" y="19" font-family="system-ui,-apple-system,sans-serif" font-size="16" font-weight="800" letter-spacing="-0.3" fill="#e8eaf6">Clu</text>
|
||||
<text x="60" y="19" font-family="system-ui,-apple-system,sans-serif" font-size="16" font-weight="800" letter-spacing="-0.3" fill="#6366f1">Bird</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 988 B |
|
|
@ -9,7 +9,7 @@ import "@phosphor-icons/web/regular";
|
|||
import "@phosphor-icons/web/bold";
|
||||
import './components/sidebar.js';
|
||||
|
||||
import './plugins/GlassToastra/toastra.glass.js'
|
||||
import './plugins/GlassToastra/livewire-adapter';
|
||||
import './plugins/Toastra/index.js';
|
||||
import './plugins/Toastra/livewire-adapter';
|
||||
|
||||
import './utils/events.js';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
// Maps Livewire `toast` events → window.toastra.notify()
|
||||
const TYPE_MAP = {
|
||||
done: 'success',
|
||||
warn: 'warning',
|
||||
warning: 'warning',
|
||||
error: 'error',
|
||||
info: 'info',
|
||||
default: 'default',
|
||||
update: 'update',
|
||||
};
|
||||
|
||||
function dispatchToast(detail) {
|
||||
if (!window.toastra) return;
|
||||
|
||||
const { type = 'info', badge, title, text, duration } = detail || {};
|
||||
|
||||
window.toastra.notify({
|
||||
type: TYPE_MAP[type] ?? 'info',
|
||||
badge: badge ?? '',
|
||||
title: title ?? '',
|
||||
text: text ?? '',
|
||||
duration: duration !== undefined ? duration : 4000,
|
||||
close: true,
|
||||
progressbar: true,
|
||||
icon: true,
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('livewire:init', () => {
|
||||
window.addEventListener('toast', (e) => dispatchToast(e?.detail || {}));
|
||||
window.addEventListener('toast.clear', () => { /* Toastra hat kein clear-API */ });
|
||||
window.addEventListener('toast.reload',(e) => setTimeout(() => window.location.reload(), e?.detail?.delay || 0));
|
||||
});
|
||||
|
||||
window.showToast = dispatchToast;
|
||||
|
|
@ -27,7 +27,7 @@
|
|||
.notification {
|
||||
position: fixed;
|
||||
z-index: 999999;
|
||||
max-width: 350px;
|
||||
max-width: 320px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
|
@ -64,11 +64,11 @@
|
|||
}
|
||||
|
||||
.notification-title {
|
||||
padding-right: 30px;
|
||||
padding-right: 24px;
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
min-width: 1.7rem;
|
||||
min-width: 1.4rem;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
padding: 2px;
|
||||
|
|
@ -77,8 +77,8 @@
|
|||
|
||||
.notification-dismiss-btn {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0 6px -2px black;
|
||||
|
|
@ -90,7 +90,7 @@
|
|||
}
|
||||
|
||||
.notification-dismiss-btn svg {
|
||||
width: 1.5rem;
|
||||
width: 1.1rem;
|
||||
}
|
||||
|
||||
/*.default-mode {*/
|
||||
|
|
@ -272,11 +272,10 @@
|
|||
position: relative;
|
||||
margin: 5px;
|
||||
width: fit-content;
|
||||
border-radius: 10px;
|
||||
opacity: 0.95;
|
||||
border-radius: 13px;
|
||||
opacity: 0.98;
|
||||
overflow: hidden;
|
||||
transition: opacity 500ms ease-in-out,
|
||||
transform 500ms;
|
||||
transition: opacity 500ms ease-in-out, transform 500ms;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -293,7 +292,7 @@
|
|||
}
|
||||
|
||||
.notification-message {
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
|
|
@ -305,7 +304,7 @@
|
|||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 15px 15px;
|
||||
padding: 12px 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
|
|
@ -603,75 +602,87 @@
|
|||
/* background: linear-gradient(180deg, var(--accent), rgba(var(--toast-red), .6));*/
|
||||
/* border-radius: 3px 0 0 3px;*/
|
||||
/*}*/
|
||||
/* ===== Toast · Glass v2 (Badge) ===== */
|
||||
/* ===== Toast · Glass v2 – mw dark theme ===== */
|
||||
.notification.glass-mode .notification-container {
|
||||
backdrop-filter: blur(14px);
|
||||
background: rgba(17, 24, 39, .52); /* dunkleres Glas */
|
||||
border: 1px solid rgba(255,255,255,.09);
|
||||
background: color-mix(in srgb, var(--mw-bg3, #111420) 88%, transparent);
|
||||
border: 1px solid var(--mw-b1, #1c2035);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,.28);
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,.40);
|
||||
}
|
||||
|
||||
.notification.glass-mode .notification-block {
|
||||
padding: 14px 16px 16px 16px;
|
||||
gap: 12px;
|
||||
padding: 12px 14px 13px 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Icon als soft pill */
|
||||
.notification.glass-mode .notification-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
border-radius: 9px;
|
||||
background: rgba(255,255,255,.06);
|
||||
border: 1px solid rgba(255,255,255,.08);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.06);
|
||||
}
|
||||
|
||||
.notification.glass-mode .notification-icon svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
/* Headline-Zeile: Badge + Title */
|
||||
.notification.glass-mode .notification-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-right: 36px; /* Platz für Close */
|
||||
font-weight: 700;
|
||||
letter-spacing: .2px;
|
||||
color: #e5e7eb; /* text-gray-200 */
|
||||
gap: 6px;
|
||||
padding-right: 26px;
|
||||
line-height: 1.2;
|
||||
color: var(--mw-t1, #edf0fc);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Badge aus data-badge rendern */
|
||||
.notification.glass-mode .notification-container[data-badge] .notification-title::before {
|
||||
content: attr(data-badge);
|
||||
font-size: 10px;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .6px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid rgba(255,255,255,.10);
|
||||
background: rgba(31,41,55,.55); /* dunkles Glas */
|
||||
color: #cbd5e1; /* text-gray-300 */
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.08);
|
||||
letter-spacing: .5px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--mw-b2, #242840);
|
||||
background: var(--mw-bg4, #161a28);
|
||||
color: var(--mw-t3, #737b96);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.06);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Subtext */
|
||||
.notification.glass-mode .notification-message {
|
||||
margin-top: 6px;
|
||||
line-height: 1.35;
|
||||
color: #cbd5e1; /* text-gray-300 */
|
||||
margin-top: 5px;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: var(--mw-t2, #9aa3be);
|
||||
}
|
||||
|
||||
/* Close-Button dezenter */
|
||||
.notification.glass-mode .notification-dismiss-btn {
|
||||
top: 10px; right: 10px;
|
||||
width: 30px; height: 30px;
|
||||
top: 7px; right: 7px;
|
||||
width: 22px; height: 22px;
|
||||
display: grid; place-items: center;
|
||||
background: rgba(255,255,255,.06);
|
||||
border: 1px solid rgba(255,255,255,.10);
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,.25);
|
||||
background: var(--mw-bg4, #161a28);
|
||||
border: 1px solid var(--mw-b2, #242840);
|
||||
box-shadow: none;
|
||||
}
|
||||
.notification.glass-mode .notification-dismiss-btn:hover {
|
||||
background: rgba(255,255,255,.12);
|
||||
background: var(--mw-b1, #1c2035);
|
||||
}
|
||||
.notification.glass-mode .notification-dismiss-btn svg {
|
||||
width: 11px; height: 11px;
|
||||
stroke: var(--mw-t3, #737b96);
|
||||
}
|
||||
|
||||
/* Progressbar dünn & soft */
|
||||
|
|
@ -705,9 +716,21 @@
|
|||
background: rgba(var(--toast-accent-green), .10);
|
||||
border-color: rgba(var(--toast-accent-green), .25);
|
||||
}
|
||||
.notification-toast-success .notification-title { color: #bbf7d0; } /* green-200 */
|
||||
.notification-toast-success .notification-title { color: #86efac; } /* green-300 */
|
||||
.notification-toast-success .notification-title-text { color: #86efac; }
|
||||
.notification-toast-success .notification-progressbar {
|
||||
background: rgba(var(--toast-accent-green), .9);
|
||||
background: rgba(var(--toast-accent-green), .8);
|
||||
}
|
||||
|
||||
/* Warning */
|
||||
.notification-toast-warning .notification-icon {
|
||||
background: rgba(var(--toast-accent-amber), .10);
|
||||
border-color: rgba(var(--toast-accent-amber), .25);
|
||||
}
|
||||
.notification-toast-warning .notification-title { color: #fcd34d; } /* amber-300 */
|
||||
.notification-toast-warning .notification-title-text { color: #fcd34d; }
|
||||
.notification-toast-warning .notification-progressbar {
|
||||
background: rgba(var(--toast-accent-amber), .8);
|
||||
}
|
||||
|
||||
/* Error */
|
||||
|
|
@ -715,9 +738,10 @@
|
|||
background: rgba(var(--toast-accent-red), .10);
|
||||
border-color: rgba(var(--toast-accent-red), .28);
|
||||
}
|
||||
.notification-toast-error .notification-title { color: #fecaca; } /* red-200 */
|
||||
.notification-toast-error .notification-title { color: #fca5a5; } /* red-300 */
|
||||
.notification-toast-error .notification-title-text { color: #fca5a5; }
|
||||
.notification-toast-error .notification-progressbar {
|
||||
background: rgba(var(--toast-accent-red), .9);
|
||||
background: rgba(var(--toast-accent-red), .8);
|
||||
}
|
||||
|
||||
/* Info */
|
||||
|
|
@ -725,9 +749,10 @@
|
|||
background: rgba(var(--toast-accent-gray), .10);
|
||||
border-color: rgba(var(--toast-accent-gray), .25);
|
||||
}
|
||||
.notification-toast-info .notification-title { color:#e5e7eb; }
|
||||
.notification-toast-info .notification-title { color: var(--mw-t2, #9aa3be); }
|
||||
.notification-toast-info .notification-title-text { color: var(--mw-t2, #9aa3be); }
|
||||
.notification-toast-info .notification-progressbar {
|
||||
background: rgba(var(--toast-accent-gray), .85);
|
||||
background: rgba(var(--toast-accent-gray), .7);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -735,43 +760,44 @@
|
|||
.notification-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem; /* Space between badge and title */
|
||||
gap: 5px;
|
||||
line-height: 1.2;
|
||||
margin-bottom: .15rem;
|
||||
margin-bottom: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The badge itself (glass-styled) */
|
||||
.notification-badge {
|
||||
font-size: 10px;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .6px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid rgba(255,255,255,.10);
|
||||
background: rgba(31,41,55,.55);
|
||||
color: #cbd5e1; /* gray-300 */
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.08);
|
||||
letter-spacing: .5px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--mw-b2, #242840);
|
||||
background: var(--mw-bg4, #161a28);
|
||||
color: var(--mw-t3, #737b96);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.06);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* keep your bold style on the title text itself */
|
||||
.notification-title-text b {
|
||||
color: #e5e7eb; /* gray-200 */
|
||||
font-weight: 800;
|
||||
letter-spacing: .3px;
|
||||
/* title text — haupttext, größer als badge */
|
||||
.notification-title-text {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--mw-t1, #edf0fc);
|
||||
letter-spacing: .1px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Subtitle (domain) on its own line, slightly larger and brighter */
|
||||
/* Subtitle */
|
||||
.notification-subtitle {
|
||||
margin-top: .25rem;
|
||||
font-weight: 700;
|
||||
color: #e5e7eb; /* gray-200 */
|
||||
margin-top: 2px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 500;
|
||||
color: var(--mw-t2, #9aa3be);
|
||||
}
|
||||
|
||||
/* Glass container (you already had this, listing again for completeness) */
|
||||
.notification.glass-mode .notification-container {
|
||||
backdrop-filter: blur(10px);
|
||||
background: rgba(31,41,55,.45);
|
||||
border: 1px solid rgba(255,255,255,.08);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 8px 30px rgba(0,0,0,.25);
|
||||
}
|
||||
/* Glass container duplicate removed – see above */
|
||||
|
|
|
|||
|
|
@ -96,11 +96,11 @@ function Toastra() {
|
|||
notificationTitle.appendChild(badgeEl);
|
||||
}
|
||||
|
||||
// Title text (keep your current behavior)
|
||||
// Title text
|
||||
if (options.title && options.title.length) {
|
||||
const titleText = document.createElement("span");
|
||||
titleText.className = "notification-title";
|
||||
titleText.innerHTML = "<b>" + options.title + "</b>";
|
||||
titleText.className = "notification-title-text";
|
||||
titleText.innerHTML = options.title;
|
||||
notificationTitle.appendChild(titleText);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Anmelden · Mailwolt</title>
|
||||
<title>Anmelden · CluBird</title>
|
||||
@vite(['resources/css/app.css'])
|
||||
@livewireStyles
|
||||
</head>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
{{-- </div>--}}
|
||||
|
||||
{{-- <p class="nx-subtle mb-7">--}}
|
||||
{{-- Erstelle ein Konto, um mit MailWolt zu starten.--}}
|
||||
{{-- Erstelle ein Konto, um mit CluBird zu starten.--}}
|
||||
{{-- </p>--}}
|
||||
|
||||
{{-- <livewire:auth.signup-form />--}}
|
||||
|
|
|
|||
|
|
@ -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"/>',
|
||||
])
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Update läuft · 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 .4s ease;
|
||||
}
|
||||
@keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
||||
.logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.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; }
|
||||
.spinner {
|
||||
width: 54px; height: 54px;
|
||||
margin: 0 auto 28px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #252938;
|
||||
border-top-color: #6366f1;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
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; }
|
||||
</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="spinner"></div>
|
||||
<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>
|
||||
|
|
@ -3,8 +3,8 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Mailwolt — Self-Hosted Mail Server Control Panel</title>
|
||||
<meta name="description" content="Mailwolt ist ein modernes Control Panel für selbst gehostete Mailserver. Mailboxen, Domains, Sicherheit, API und Webhooks — alles in einer Oberfläche.">
|
||||
<title>CluBird — Self-Hosted Mail Server Control Panel</title>
|
||||
<meta name="description" content="CluBird ist ein modernes Control Panel für selbst gehostete Mailserver. Mailboxen, Domains, Sicherheit, API und Webhooks — alles in einer Oberfläche.">
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{
|
||||
|
|
@ -179,7 +179,7 @@ body.light .mockup{box-shadow:0 20px 60px rgba(0,0,0,.12)}
|
|||
<path d="M9 6L12 7.5v3L9 12 6 10.5v-3L9 6Z" fill="white" opacity=".9"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="nav-logo-text">MAILWOLT</span>
|
||||
<span class="nav-logo-text">CLUBIRD</span>
|
||||
</div>
|
||||
<div class="nav-links">
|
||||
<a href="#features">Features</a>
|
||||
|
|
@ -205,7 +205,7 @@ body.light .mockup{box-shadow:0 20px 60px rgba(0,0,0,.12)}
|
|||
Self-Hosted · Open Source · Production Ready
|
||||
</div>
|
||||
<h1>Dein Mailserver.<br><span class="gradient-text">Vollständig unter Kontrolle.</span></h1>
|
||||
<p>Mailwolt ist ein modernes Control Panel für selbst gehostete Mailserver. Verwalte Mailboxen, Domains, Aliases, SSL-Zertifikate und mehr — mit einer sauberen, schnellen Oberfläche.</p>
|
||||
<p>CluBird ist ein modernes Control Panel für selbst gehostete Mailserver. Verwalte Mailboxen, Domains, Aliases, SSL-Zertifikate und mehr — mit einer sauberen, schnellen Oberfläche.</p>
|
||||
<div class="hero-actions">
|
||||
<a href="{{ route('login') }}" class="btn btn-primary btn-lg">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M7 1v12M1 7h12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
|
|
@ -317,7 +317,7 @@ body.light .mockup{box-shadow:0 20px 60px rgba(0,0,0,.12)}
|
|||
<div style="margin-bottom:48px">
|
||||
<div class="section-eyebrow">Features</div>
|
||||
<h2 class="section-title">Alles was du brauchst.<br>Nichts was du nicht brauchst.</h2>
|
||||
<p class="section-sub">Mailwolt bündelt alle Werkzeuge um einen Mailserver professionell zu betreiben — ohne sich in Details zu verlieren.</p>
|
||||
<p class="section-sub">CluBird bündelt alle Werkzeuge um einen Mailserver professionell zu betreiben — ohne sich in Details zu verlieren.</p>
|
||||
</div>
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
|
|
@ -433,7 +433,7 @@ body.light .mockup{box-shadow:0 20px 60px rgba(0,0,0,.12)}
|
|||
<div style="margin-bottom:48px">
|
||||
<div class="section-eyebrow">Sicherheit</div>
|
||||
<h2 class="section-title">Sicherheit by Default.</h2>
|
||||
<p class="section-sub">Mailwolt liefert alle nötigen Werkzeuge um einen gehärteten Mailserver zu betreiben — ohne Expertenwissen voraussetzen zu müssen.</p>
|
||||
<p class="section-sub">CluBird liefert alle nötigen Werkzeuge um einen gehärteten Mailserver zu betreiben — ohne Expertenwissen voraussetzen zu müssen.</p>
|
||||
</div>
|
||||
<div class="security-grid">
|
||||
<div class="sec-card">
|
||||
|
|
@ -528,7 +528,7 @@ body.light .mockup{box-shadow:0 20px 60px rgba(0,0,0,.12)}
|
|||
<div class="cta-glow"></div>
|
||||
<div class="container" style="position:relative;z-index:1">
|
||||
<h2 class="section-title" style="text-align:center;margin-bottom:16px">Bereit loszulegen?</h2>
|
||||
<p style="text-align:center;font-size:16px;color:var(--t3);margin-bottom:36px">Installiere Mailwolt in wenigen Minuten und behalte die volle Kontrolle über deinen Mailserver.</p>
|
||||
<p style="text-align:center;font-size:16px;color:var(--t3);margin-bottom:36px">Installiere CluBird in wenigen Minuten und behalte die volle Kontrolle über deinen Mailserver.</p>
|
||||
<div style="display:flex;justify-content:center;gap:12px;flex-wrap:wrap">
|
||||
<a href="{{ route('login') }}" class="btn btn-primary btn-lg">
|
||||
Demo starten →
|
||||
|
|
@ -549,7 +549,7 @@ body.light .mockup{box-shadow:0 20px 60px rgba(0,0,0,.12)}
|
|||
<div class="nav-logo-icon">
|
||||
<svg width="12" height="12" 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"/></svg>
|
||||
</div>
|
||||
<span class="nav-logo-text" style="font-size:12px">MAILWOLT</span>
|
||||
<span class="nav-logo-text" style="font-size:12px">CLUBIRD</span>
|
||||
<span style="font-size:11px;color:var(--t4);margin-left:4px">© {{ date('Y') }}</span>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>@yield('title', config('app.name'))</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{{ asset('images/clubird/favicon.svg') }}">
|
||||
@vite(['resources/css/app.css'])
|
||||
@livewireStyles
|
||||
</head>
|
||||
|
|
@ -17,14 +18,17 @@
|
|||
<aside class="mw-sidebar" id="mw-sidebar">
|
||||
|
||||
<div class="mw-sb-head">
|
||||
<div class="mw-sb-mark">
|
||||
<svg width="15" height="15" 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 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">MAILWOLT</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>
|
||||
|
|
@ -91,6 +95,11 @@
|
|||
Rspamd
|
||||
<div class="mw-nav-dot"></div>
|
||||
</a>
|
||||
<a href="{{ route('ui.security.clamav') }}" class="mw-nav-item {{ request()->routeIs('ui.security.clamav') ? 'active' : '' }}">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M7 1.5C4 1.5 1.5 4 1.5 7S4 12.5 7 12.5 12.5 10 12.5 7 10 1.5 7 1.5Z" stroke="currentColor" stroke-width="1.2"/><path d="M5 7l1.5 1.5L9.5 5.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
Virenschutz
|
||||
<div class="mw-nav-dot"></div>
|
||||
</a>
|
||||
<a href="{{ route('ui.security.audit') }}" class="mw-nav-item {{ request()->routeIs('ui.security.audit') ? 'active' : '' }}">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M1 3.5h12M1 7h12M1 10.5h12" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
|
||||
Audit-Logs
|
||||
|
|
@ -141,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">
|
||||
|
|
@ -214,5 +225,24 @@
|
|||
@vite(['resources/js/app.js'])
|
||||
@livewireScripts
|
||||
@livewire('wire-elements-modal')
|
||||
<script>
|
||||
// 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 () {})
|
||||
}
|
||||
if (resp.status === 419) {
|
||||
window.location.href = '{{ route('login') }}'
|
||||
return new Promise(function () {})
|
||||
}
|
||||
return resp
|
||||
})
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Einrichtung · Mailwolt</title>
|
||||
<title>Einrichtung · CluBird</title>
|
||||
@vite(['resources/css/app.css'])
|
||||
@livewireStyles
|
||||
</head>
|
||||
|
|
|
|||
|
|
@ -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)">MAILWOLT</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>
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@
|
|||
</div>
|
||||
|
||||
<p class="nx-subtle mb-7">
|
||||
Erstelle ein Konto, um mit MailWolt zu starten.
|
||||
Erstelle ein Konto, um mit CluBird zu starten.
|
||||
</p>
|
||||
|
||||
<form wire:submit.prevent="register" class="space-y-5">
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
<path d="M9 6L12 7.5v3L9 12 6 10.5v-3L9 6Z" fill="white" opacity=".9"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span style="font-size:16px;font-weight:700;color:var(--mw-t1);letter-spacing:1px">MAILWOLT</span>
|
||||
<span style="font-size:16px;font-weight:700;color:var(--mw-t1);letter-spacing:1px">CLUBIRD</span>
|
||||
</div>
|
||||
<div style="font-size:13px;color:var(--mw-t4)">Zwei-Faktor-Authentifizierung</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:14px;font-weight:700;letter-spacing:.3px;color:var(--mw-t1)">MAILWOLT</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">Ersteinrichtung</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -47,12 +47,12 @@
|
|||
@if($step === 1)
|
||||
<div style="margin-bottom:20px">
|
||||
<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 style="font-size:12px;color:var(--mw-t4);margin-top:3px">Grundkonfiguration für deine CluBird-Instanz</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:16px">
|
||||
<div>
|
||||
<label class="mw-modal-label">Instanzname</label>
|
||||
<input type="text" wire:model="instance_name" class="mw-modal-input" placeholder="Mailwolt">
|
||||
<input type="text" wire:model="instance_name" class="mw-modal-input" placeholder="CluBird">
|
||||
@error('instance_name') <div class="mw-modal-error">{{ $message }}</div> @enderror
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -71,6 +71,14 @@
|
|||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
@script
|
||||
<script>
|
||||
try {
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
if (tz) $wire.set('timezone', tz);
|
||||
} catch(e) {}
|
||||
</script>
|
||||
@endscript
|
||||
</div>
|
||||
|
||||
{{-- ── Schritt 2: Domains ── --}}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@
|
|||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M7 1.5L12.5 4v4.5c0 2.8-2.3 4.5-5.5 5C3.8 13 1.5 11.3 1.5 8.5V4L7 1.5Z" stroke="#22c55e" stroke-width="1.2" stroke-linejoin="round"/><path d="M4.5 7l2 2 3-3" stroke="#22c55e" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mw-sc-title">WoltGuard</div>
|
||||
<div class="mw-sc-title">CluGuard</div>
|
||||
<div class="mw-sc-sub">System-Wächter</div>
|
||||
</div>
|
||||
<span class="mw-sc-badge mw-badge-ok">alle Dienste OK</span>
|
||||
|
|
@ -141,11 +141,19 @@
|
|||
</div>
|
||||
<div>
|
||||
<div class="mw-sc-title">Updates</div>
|
||||
<div class="mw-sc-sub">{{ config('app.version', 'vdev') }}</div>
|
||||
<div class="mw-sc-sub">v{{ ltrim(config('app.version', 'dev'), 'vV') }}</div>
|
||||
</div>
|
||||
<span class="mw-sc-badge mw-badge-mute">aktuell</span>
|
||||
@if($updateLatest)
|
||||
<span class="mw-sc-badge mw-badge-warn">Update verfügbar</span>
|
||||
@else
|
||||
<span class="mw-sc-badge mw-badge-mute">aktuell</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="mw-sc-body">System ist auf dem neuesten Stand.</div>
|
||||
@if($updateLatest)
|
||||
<div class="mw-sc-body">Neue Version verfügbar: <strong>{{ $updateLatest }}</strong></div>
|
||||
@else
|
||||
<div class="mw-sc-body">System ist auf dem neuesten Stand.</div>
|
||||
@endif
|
||||
<div class="mw-sc-progress"><div class="mw-sc-progress-fill" style="width:100%"></div></div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
<x-slot:breadcrumb>Übersicht</x-slot:breadcrumb>
|
||||
|
||||
@php
|
||||
$_wmSub = config('mailwolt.domain.webmail');
|
||||
$_wmBase = config('mailwolt.domain.base');
|
||||
$_wmSub = config('clubird.domain.webmail');
|
||||
$_wmBase = config('clubird.domain.base');
|
||||
$_wmUrl = ($_wmSub && $_wmBase)
|
||||
? 'https://'.$_wmSub.'.'.$_wmBase.'/inbox'
|
||||
: url('/webmail/inbox');
|
||||
|
|
@ -87,9 +87,6 @@ $_wmUrl = ($_wmSub && $_wmBase)
|
|||
</td>
|
||||
<td class="mbx-td">
|
||||
<div class="mbx-actions">
|
||||
<a href="{{ $_wmUrl }}" target="_blank" class="mbx-act-btn" title="Webmail öffnen">
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none"><rect x=".5" y="2.5" width="13" height="9" rx="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M.5 5l6.5 4 6.5-4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
|
||||
</a>
|
||||
<button wire:click="openDns({{ $systemDomain->id }})" class="mbx-act-btn" title="DNS-Assistent">
|
||||
<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="M4.5 9.5C5 8 6 7 7 7s2 1 2.5 2.5" stroke="currentColor" stroke-width="1.1" stroke-linecap="round"/><circle cx="7" cy="4.5" r=".7" fill="currentColor"/></svg>
|
||||
</button>
|
||||
|
|
@ -238,7 +235,7 @@ $_wmUrl = ($_wmSub && $_wmBase)
|
|||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="mbx-empty-state">
|
||||
<div class="mbx-empty-state" style="{{ $systemDomain ? 'margin-top:14px' : '' }}">
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" style="color:var(--mw-t4);margin-bottom:8px">
|
||||
<circle cx="14" cy="14" r="11" stroke="currentColor" stroke-width="1.3"/>
|
||||
<ellipse cx="14" cy="14" rx="4.5" ry="11" stroke="currentColor" stroke-width="1.3"/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,177 @@
|
|||
<x-slot:breadcrumbParent>Sicherheit</x-slot:breadcrumbParent>
|
||||
<x-slot:breadcrumb>Virenschutz</x-slot:breadcrumb>
|
||||
|
||||
<div>
|
||||
|
||||
<div class="mbx-page-header">
|
||||
<div class="mbx-page-title">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M8 1.5C8 1.5 3 3.5 3 7.5v3.5l5 2.5 5-2.5V7.5C13 3.5 8 1.5 8 1.5Z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>
|
||||
<path d="M5.5 8l1.5 1.5L10 6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Virenschutz (ClamAV)
|
||||
</div>
|
||||
<div class="mbx-page-actions">
|
||||
<button wire:click="refresh" wire:loading.attr="disabled" wire:target="refresh" class="mbx-btn-mute">
|
||||
<svg width="12" height="12" viewBox="0 0 14 14" fill="none" wire:loading.class="animate-spin" wire:target="refresh"><path d="M12 7A5 5 0 1 1 7 2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><path d="M7 2l2-2v4H5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($lastSuccess)
|
||||
<div style="margin-bottom:16px;padding:10px 14px;background:rgba(52,211,153,.08);border:1px solid rgba(52,211,153,.25);border-radius:8px;display:flex;align-items:center;gap:8px">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" style="color:#34d399;flex-shrink:0"><circle cx="7" cy="7" r="6" stroke="currentColor" stroke-width="1.3"/><path d="M4.5 7l2 2 3-3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<span style="font-size:12.5px;color:#6ee7b7;font-weight:500">{{ $lastSuccess }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@if($lastError)
|
||||
<div style="margin-bottom:16px;padding:10px 14px;background:rgba(239,68,68,.08);border:1px solid rgba(239,68,68,.25);border-radius:8px;display:flex;align-items:center;gap:8px">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" style="color:#ef4444;flex-shrink:0"><circle cx="7" cy="7" r="6" stroke="currentColor" stroke-width="1.3"/><path d="M7 4v3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="7" cy="10" r=".6" fill="currentColor"/></svg>
|
||||
<span style="font-size:12.5px;color:#fca5a5;white-space:pre-wrap">{{ $lastError }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="sec-layout">
|
||||
|
||||
{{-- ═══ Main ═══ --}}
|
||||
<div style="min-width:0;display:flex;flex-direction:column;gap:16px">
|
||||
|
||||
{{-- Status-Karte --}}
|
||||
<div class="mbx-section">
|
||||
|
||||
<div class="mbx-domain-head">
|
||||
<div class="mbx-domain-info">
|
||||
<span class="mbx-badge-mute">Status</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:16px 18px">
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:16px">
|
||||
|
||||
{{-- Status-Zeile --}}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 14px;background:var(--mw-bg4);border:1px solid var(--mw-b2);border-radius:8px">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
@if($running)
|
||||
<div style="width:8px;height:8px;border-radius:50%;background:#34d399;box-shadow:0 0 6px rgba(52,211,153,.4);flex-shrink:0"></div>
|
||||
<div>
|
||||
<div style="font-size:13px;font-weight:500;color:var(--mw-t1)">ClamAV läuft</div>
|
||||
<div style="font-size:11.5px;color:var(--mw-t4);margin-top:1px">
|
||||
Aktiv{{ $enabled ? ', Autostart ein' : '' }}
|
||||
@if($ramMb) · {{ $ramMb }} MB RAM @endif
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div style="width:8px;height:8px;border-radius:50%;background:#6b7280;flex-shrink:0"></div>
|
||||
<div>
|
||||
<div style="font-size:13px;font-weight:500;color:var(--mw-t2)">ClamAV inaktiv</div>
|
||||
<div style="font-size:11.5px;color:var(--mw-t4);margin-top:1px">Installiert, aber nicht aktiv{{ $enabled ? '' : ' · Autostart aus' }}</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
@if($starting)
|
||||
<div wire:poll.3s.keep-alive="pollStatus" style="display:flex;align-items:center;gap:8px;padding:6px 12px;background:rgba(167,139,250,.08);border:1px solid rgba(167,139,250,.25);border-radius:8px">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" style="color:#a78bfa;animation:spin 1s linear infinite;flex-shrink:0"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
<span style="font-size:12px;color:#c4b5fd;font-weight:500">Wird gestartet…</span>
|
||||
<span style="font-size:11px;color:rgba(196,181,253,.5)">{{ $startSecs }}s</span>
|
||||
</div>
|
||||
@elseif($running)
|
||||
<button wire:click="disable" wire:loading.attr="disabled" wire:target="disable"
|
||||
class="mbx-btn-danger"
|
||||
onclick="return confirm('ClamAV wirklich stoppen und deaktivieren?')">
|
||||
<svg width="12" height="12" viewBox="0 0 14 14" fill="none"><rect x="2" y="2" width="10" height="10" rx="2" stroke="currentColor" stroke-width="1.3"/></svg>
|
||||
<span wire:loading.remove wire:target="disable">Deaktivieren</span>
|
||||
<span wire:loading wire:target="disable">Bitte warten…</span>
|
||||
</button>
|
||||
@else
|
||||
<button wire:click="enable" wire:loading.attr="disabled" wire:target="enable"
|
||||
class="mbx-btn-primary">
|
||||
<svg width="12" height="12" viewBox="0 0 14 14" fill="none"><path d="M7 2v10M2 7h10" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||
<span wire:loading.remove wire:target="enable">Aktivieren & starten</span>
|
||||
<span wire:loading wire:target="enable">Startet…</span>
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Signaturen --}}
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
|
||||
<div style="padding:10px 14px;background:var(--mw-bg4);border:1px solid var(--mw-b2);border-radius:8px">
|
||||
<div style="font-size:11px;color:var(--mw-t4);margin-bottom:3px">Signatur-Datenbank</div>
|
||||
<div style="font-size:13px;font-weight:500;color:var(--mw-t1)">{{ $dbDate }}</div>
|
||||
@if($dbVersion !== '—')<div style="font-size:11.5px;color:var(--mw-t4);margin-top:1px">Version {{ $dbVersion }}</div>@endif
|
||||
</div>
|
||||
<div style="padding:10px 14px;background:var(--mw-bg4);border:1px solid var(--mw-b2);border-radius:8px;display:flex;align-items:center;justify-content:space-between">
|
||||
<div>
|
||||
<div style="font-size:11px;color:var(--mw-t4);margin-bottom:3px">Signaturen aktualisieren</div>
|
||||
<div style="font-size:12px;color:var(--mw-t3)">Läuft automatisch täglich</div>
|
||||
</div>
|
||||
<button wire:click="updateDb" wire:loading.attr="disabled" wire:target="updateDb" class="mbx-btn-mute" style="flex-shrink:0">
|
||||
<svg width="11" height="11" viewBox="0 0 14 14" fill="none" wire:loading.class="animate-spin" wire:target="updateDb"><path d="M12 7A5 5 0 1 1 7 2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><path d="M7 2l2-2v4H5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<span wire:loading.remove wire:target="updateDb">Jetzt</span>
|
||||
<span wire:loading wire:target="updateDb">…</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{{-- ═══ Sidebar ═══ --}}
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
|
||||
{{-- RAM-Hinweis --}}
|
||||
<div style="padding:12px 14px;background:rgba(245,158,11,.07);border:1px solid rgba(245,158,11,.25);border-radius:10px;display:flex;gap:10px;align-items:flex-start">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="color:#f59e0b;flex-shrink:0;margin-top:1px"><path d="M8 1.5L13.5 12H2.5L8 1.5Z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/><path d="M8 6v3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="8" cy="10.5" r=".6" fill="currentColor"/></svg>
|
||||
<div>
|
||||
<div style="font-size:12px;font-weight:600;color:#fcd34d;margin-bottom:3px">Hoher RAM-Verbrauch</div>
|
||||
<div style="font-size:11.5px;color:rgba(253,230,138,.7);line-height:1.5">
|
||||
ClamAV lädt die komplette Viren-Datenbank (~500 MB) in den Arbeitsspeicher.
|
||||
Empfohlen auf Servern mit mindestens 2 GB RAM.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Info --}}
|
||||
<div class="mbx-section">
|
||||
<div class="mbx-domain-head">
|
||||
<div class="mbx-domain-info">
|
||||
<span class="mbx-badge-mute">Info</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:14px 16px;display:flex;flex-direction:column;gap:8px">
|
||||
<div style="font-size:12px;color:var(--mw-t3);line-height:1.6">
|
||||
ClamAV prüft in Kombination mit Rspamd eingehende E-Mails auf Schadsoftware.
|
||||
Infizierte Anhänge werden abgewiesen oder in Quarantäne verschoben.
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px;margin-top:2px">
|
||||
<div style="display:flex;justify-content:space-between;font-size:12px;padding:6px 0;border-bottom:1px solid var(--mw-b1)">
|
||||
<span style="color:var(--mw-t4)">Empfohlen bei</span>
|
||||
<span style="color:var(--mw-t2);font-weight:500">≥ 2 GB RAM</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:12px;padding:6px 0;border-bottom:1px solid var(--mw-b1)">
|
||||
<span style="color:var(--mw-t4)">RAM-Verbrauch</span>
|
||||
<span style="color:var(--mw-t2);font-weight:500">~500–700 MB</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:12px;padding:6px 0;border-bottom:1px solid var(--mw-b1)">
|
||||
<span style="color:var(--mw-t4)">Integration</span>
|
||||
<span style="color:var(--mw-t2);font-weight:500">Rspamd → Milter</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:12px;padding:6px 0">
|
||||
<span style="color:var(--mw-t4)">Signaturen</span>
|
||||
<span style="color:var(--mw-t2);font-weight:500">Täglich automatisch</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -18,15 +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">
|
||||
<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">{{ $r['jail'] }}</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);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?">
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@
|
|||
<x-slot:breadcrumb>SSL/TLS</x-slot:breadcrumb>
|
||||
|
||||
<div>
|
||||
@if($sslProvisioning)
|
||||
<div wire:poll.2s="pollSsl"></div>
|
||||
@endif
|
||||
|
||||
<div class="mbx-page-header">
|
||||
<div class="mbx-page-title">
|
||||
|
|
@ -24,93 +27,192 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mbx-sections">
|
||||
<div class="mbx-section">
|
||||
<div class="mbx-domain-head">
|
||||
<div class="mbx-domain-info">
|
||||
<span class="mbx-badge-mute">Let's Encrypt Zertifikate</span>
|
||||
<div class="sec-layout">
|
||||
|
||||
{{-- ═══ Links (breit): Ausgestellte Zertifikate ═══ --}}
|
||||
<div style="min-width:0">
|
||||
<div class="mbx-sections">
|
||||
<div class="mbx-section">
|
||||
<div class="mbx-domain-head">
|
||||
<div class="mbx-domain-info">
|
||||
<span class="mbx-badge-mute">Ausgestellte Zertifikate</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@php $unavailable = !empty($certs) && isset($certs[0]['_error']); @endphp
|
||||
@if($unavailable)
|
||||
<div style="padding:24px 16px;text-align:center">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" style="color:rgba(239,68,68,.5);margin:0 auto 8px;display:block"><rect x="2" y="8" width="20" height="13" rx="2.5" stroke="currentColor" stroke-width="1.4"/><path d="M7 8V6a5 5 0 0 1 10 0v2" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
|
||||
<div style="font-size:12.5px;color:var(--mw-t3)">certbot nicht erreichbar</div>
|
||||
<div style="font-size:11px;color:var(--mw-t4);margin-top:4px">sudo-Recht für <code style="font-size:10px">/usr/bin/certbot</code> fehlt</div>
|
||||
</div>
|
||||
@elseif(empty($realCerts))
|
||||
<div style="padding:28px 16px;text-align:center">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" style="color:var(--mw-t4);margin:0 auto 10px;display:block"><rect x="2" y="8" width="20" height="13" rx="2.5" stroke="currentColor" stroke-width="1.4"/><path d="M7 8V6a5 5 0 0 1 10 0v2" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
|
||||
<div style="font-size:12.5px;color:var(--mw-t3)">Noch keine Zertifikate</div>
|
||||
<div style="font-size:11px;color:var(--mw-t4);margin-top:4px">Rechts auf "Einrichten" klicken</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="mbx-table-wrap">
|
||||
<table class="mbx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="mbx-th">Name / Pfad</th>
|
||||
<th class="mbx-th">Domains</th>
|
||||
<th class="mbx-th" style="width:120px">Gültig bis</th>
|
||||
<th class="mbx-th" style="width:90px">Status</th>
|
||||
<th class="mbx-th mbx-th-right" style="width:50px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($realCerts as $cert)
|
||||
<tr class="mbx-tr">
|
||||
<td class="mbx-td">
|
||||
<span class="dom-name">{{ $cert['name'] }}</span>
|
||||
@if($cert['cert_path'])
|
||||
<div style="font-size:10px;color:var(--mw-t4);margin-top:2px;font-family:monospace">{{ $cert['cert_path'] }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="mbx-td">
|
||||
<div class="ssl-domains">
|
||||
@foreach($cert['domains'] as $d)
|
||||
<span class="ssl-domain-pill">{{ $d }}</span>
|
||||
@endforeach
|
||||
</div>
|
||||
</td>
|
||||
<td class="mbx-td">
|
||||
@if($cert['expiry_date'])
|
||||
<span style="font-size:12px;color:var(--mw-t2)">{{ $cert['expiry_date'] }}</span>
|
||||
@if($cert['days_left'] !== null)
|
||||
<div style="font-size:10.5px;color:var(--mw-t4);margin-top:1px">{{ $cert['days_left'] }} Tage</div>
|
||||
@endif
|
||||
@elseif($cert['days_left'] !== null)
|
||||
<span style="color:var(--mw-t3)">{{ $cert['days_left'] }} Tage</span>
|
||||
@else
|
||||
<span style="color:var(--mw-t4)">—</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="mbx-td">
|
||||
@if($cert['expired'])
|
||||
<span class="mbx-badge-warn">Abgelaufen</span>
|
||||
@elseif($cert['days_left'] !== null && $cert['days_left'] < 14)
|
||||
<span class="mbx-badge-warn">Bald fällig</span>
|
||||
@else
|
||||
<span class="mbx-badge-ok">Gültig</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="mbx-td">
|
||||
<div class="mbx-actions">
|
||||
<button wire:click="renew('{{ $cert['name'] }}')"
|
||||
class="mbx-act-btn" title="Zertifikat erneuern"
|
||||
wire:confirm="Zertifikat {{ $cert['name'] }} jetzt erneuern?">
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none"><path d="M12 7A5 5 0 1 1 9 2.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/><path d="M9 .5l2.5 2L9 4.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@php $unavailable = !empty($certs) && isset($certs[0]['_error']); @endphp
|
||||
@if($unavailable)
|
||||
<div style="padding:24px 16px;text-align:center">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" style="color:rgba(239,68,68,.5);margin:0 auto 8px;display:block">
|
||||
<rect x="2" y="8" width="20" height="13" rx="2.5" stroke="currentColor" stroke-width="1.4"/>
|
||||
<path d="M7 8V6a5 5 0 0 1 10 0v2" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<div style="font-size:12.5px;color:var(--mw-t3)">certbot nicht erreichbar</div>
|
||||
<div style="font-size:11px;color:var(--mw-t4);margin-top:4px">sudo-Recht für <code style="font-size:10px">/usr/bin/certbot</code> fehlt oder certbot nicht installiert</div>
|
||||
</div>
|
||||
@elseif(empty($realCerts))
|
||||
<div style="padding:28px 16px;text-align:center">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" style="color:var(--mw-t4);margin:0 auto 10px;display:block">
|
||||
<rect x="2" y="8" width="20" height="13" rx="2.5" stroke="currentColor" stroke-width="1.4"/>
|
||||
<path d="M7 8V6a5 5 0 0 1 10 0v2" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<div style="font-size:12.5px;color:var(--mw-t3)">Noch keine Zertifikate ausgestellt</div>
|
||||
<div style="font-size:11px;color:var(--mw-t4);margin-top:4px">certbot ist installiert — führe <code style="font-size:10px">certbot certonly</code> aus um ein Zertifikat zu erstellen</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="mbx-table-wrap">
|
||||
<table class="mbx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="mbx-th">Name</th>
|
||||
<th class="mbx-th">Domains</th>
|
||||
<th class="mbx-th" style="width:110px">Ablauf</th>
|
||||
<th class="mbx-th" style="width:90px">Status</th>
|
||||
<th class="mbx-th mbx-th-right" style="width:70px">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($realCerts as $cert)
|
||||
<tr class="mbx-tr">
|
||||
<td class="mbx-td">
|
||||
<span class="dom-name">{{ $cert['name'] }}</span>
|
||||
@if($cert['cert_path'])
|
||||
<div style="font-size:10px;color:var(--mw-t4);margin-top:2px;font-family:monospace">{{ $cert['cert_path'] }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="mbx-td">
|
||||
<div class="ssl-domains">
|
||||
@foreach($cert['domains'] as $d)
|
||||
<span class="ssl-domain-pill">{{ $d }}</span>
|
||||
@endforeach
|
||||
</div>
|
||||
</td>
|
||||
<td class="mbx-td mbx-td-muted">
|
||||
@if($cert['days_left'] !== null)
|
||||
{{ $cert['days_left'] }} Tage
|
||||
@else
|
||||
<span style="color:var(--mw-t4)">—</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="mbx-td">
|
||||
@if($cert['expired'])
|
||||
<span class="mbx-badge-warn">Abgelaufen</span>
|
||||
@elseif($cert['days_left'] !== null && $cert['days_left'] < 14)
|
||||
<span class="mbx-badge-warn">Bald fällig</span>
|
||||
@else
|
||||
<span class="mbx-badge-ok">Gültig</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="mbx-td">
|
||||
<div class="mbx-actions">
|
||||
<button wire:click="renew('{{ $cert['name'] }}')"
|
||||
class="mbx-act-btn" title="Zertifikat erneuern"
|
||||
wire:confirm="Zertifikat {{ $cert['name'] }} jetzt erneuern?">
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none"><path d="M12 7A5 5 0 1 1 9 2.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/><path d="M9 .5l2.5 2L9 4.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ═══ Rechts (schmal): Let's Encrypt einrichten ═══ --}}
|
||||
<div style="min-width:0">
|
||||
<div class="mbx-sections">
|
||||
<div class="mbx-section">
|
||||
<div class="mbx-domain-head">
|
||||
<div class="mbx-domain-info">
|
||||
<span class="mbx-badge-mute">Let's Encrypt einrichten</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:12px 14px;display:flex;flex-direction:column;gap:10px">
|
||||
|
||||
{{-- Nur Schloss + Domain --}}
|
||||
@php
|
||||
$domainMap = [
|
||||
['domain' => $uiDomain],
|
||||
['domain' => $webmailDomain],
|
||||
['domain' => $mailDomain],
|
||||
];
|
||||
@endphp
|
||||
<div style="display:flex;flex-direction:column;gap:5px">
|
||||
@foreach($domainMap as $info)
|
||||
@php
|
||||
$d = $info['domain'];
|
||||
$certOk = $d && (file_exists("/etc/letsencrypt/renewal/{$d}.conf") || is_dir("/etc/letsencrypt/live/{$d}"));
|
||||
@endphp
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:7px 10px;border-radius:6px;background:var(--mw-bg3);border:1px solid var(--mw-b2)">
|
||||
@if($certOk)
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" style="flex-shrink:0;color:#22c55e"><rect x="1.5" y="6" width="11" height="7.5" rx="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M4.5 6V4.5a2.5 2.5 0 0 1 5 0V6" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/><path d="M5 9.5l1.3 1.3 2.7-2.7" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
@elseif($d)
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" style="flex-shrink:0;color:#f87171"><rect x="1.5" y="6" width="11" height="7.5" rx="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M4.5 6V4.5a2.5 2.5 0 0 1 5 0V6" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
|
||||
@else
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" style="flex-shrink:0;color:var(--mw-t4)"><rect x="1.5" y="6" width="11" height="7.5" rx="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M4.5 6V4.5a2.5 2.5 0 0 1 5 0V6" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
|
||||
@endif
|
||||
<span style="font-size:11px;color:var(--mw-t3);font-family:monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ $d ?: '—' }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Fortschritt --}}
|
||||
@if($sslProvisioning || $sslDone)
|
||||
<div style="display:flex;flex-direction:column;gap:5px;padding:9px 11px;border-radius:7px;background:rgba(99,102,241,.06);border:1px solid rgba(99,102,241,.2)">
|
||||
<div style="font-size:11px;font-weight:600;color:var(--mw-t2);margin-bottom:1px">
|
||||
{{ $sslProvisioning ? 'Läuft…' : 'Abgeschlossen' }}
|
||||
</div>
|
||||
@foreach(['ui' => 'UI', 'webmail' => 'Webmail', 'mail' => 'Mailserver'] as $key => $label)
|
||||
@php $st = $sslProgress[$key] ?? 'pending'; @endphp
|
||||
<div style="display:flex;align-items:center;gap:7px">
|
||||
@if($st === 'done')
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" style="flex-shrink:0;color:#22c55e"><circle cx="6" cy="6" r="5" stroke="currentColor" stroke-width="1.1"/><path d="M3.5 6l1.8 1.8 3.2-3.2" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
@elseif($st === 'error' || $st === 'nodns')
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" style="flex-shrink:0;color:#f87171"><circle cx="6" cy="6" r="5" stroke="currentColor" stroke-width="1.1"/><path d="M4 4l4 4M8 4l-4 4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
|
||||
@elseif($st === 'running')
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" style="flex-shrink:0;color:#818cf8;animation:spin 1s linear infinite"><circle cx="6" cy="6" r="4.5" stroke="currentColor" stroke-width="1.3" stroke-dasharray="14 7" stroke-linecap="round"/></svg>
|
||||
@else
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" style="flex-shrink:0;color:var(--mw-t4)"><circle cx="6" cy="6" r="4.5" stroke="currentColor" stroke-width="1.1" stroke-dasharray="3 3"/></svg>
|
||||
@endif
|
||||
@php
|
||||
$stSuffix = $st==='nodns' ? ' – kein DNS' : ($st==='error' ? ' – Fehler' : '');
|
||||
$stColor = in_array($st,['error','nodns']) ? '#f87171' : ($st==='done' ? '#22c55e' : 'var(--mw-t4)');
|
||||
@endphp
|
||||
<span style="font-size:10.5px;color:{{ $stColor }}">{{ $label }}{{ $stSuffix }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Button --}}
|
||||
@if(!$uiDomain || !$webmailDomain || !$mailDomain)
|
||||
<p style="font-size:11px;color:var(--mw-t4);margin:0">
|
||||
Erst alle Domains unter <a href="{{ route('ui.system.settings') }}" style="color:var(--mw-v)">Einstellungen</a> speichern.
|
||||
</p>
|
||||
@else
|
||||
<button wire:click="startSslProvisioning"
|
||||
wire:loading.attr="disabled"
|
||||
wire:target="startSslProvisioning"
|
||||
@disabled($sslProvisioning)
|
||||
class="{{ $sslDone ? 'mw-btn-secondary' : 'mw-btn-primary' }}"
|
||||
style="width:100%;justify-content:center">
|
||||
<svg width="12" height="12" viewBox="0 0 14 14" fill="none"><rect x="1.5" y="6" width="11" height="7.5" rx="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M4.5 6V4.5a2.5 2.5 0 0 1 5 0V6" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
|
||||
<span wire:loading.remove wire:target="startSslProvisioning">
|
||||
@if($sslProvisioning) Läuft…
|
||||
@elseif($sslDone) Erneut einrichten
|
||||
@else Zertifikate einrichten
|
||||
@endif
|
||||
</span>
|
||||
<span wire:loading wire:target="startSslProvisioning">Startet…</span>
|
||||
</button>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<label class="block text-white/60 text-sm mb-1">Sprache</label>
|
||||
<select wire:model.defer="locale"
|
||||
class="w-full h-11 rounded-xl border border-white/10 bg-white/[0.04] px-3 text-white/90">
|
||||
@foreach (config('mailwolt.language') as $key => $lang)
|
||||
@foreach (config('clubird.language') as $key => $lang)
|
||||
<option value="{{ $lang['locale'] }}">{{ $lang['label'] }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
|
|
|
|||
|
|
@ -16,16 +16,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{{-- Progress --}}
|
||||
<div class="mb-3">
|
||||
<div class="h-2 bg-white/10 rounded overflow-hidden">
|
||||
<div class="h-full bg-emerald-400 transition-all"
|
||||
style="width: {{ $percent }}%"></div>
|
||||
</div>
|
||||
<div class="text-[11px] text-white/50 mt-1">{{ $percent }}%</div>
|
||||
</div>
|
||||
|
||||
{{-- Log Tail --}}
|
||||
<div class="rounded-lg bg-black/40 border border-white/10 p-2 max-h-64 overflow-auto text-[12px] font-mono text-white/80">
|
||||
@foreach($tail as $l)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -12,33 +12,57 @@
|
|||
Einstellungen
|
||||
</div>
|
||||
<div class="mbx-page-actions">
|
||||
<button wire:click="save" 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>
|
||||
<span wire:loading.remove wire:target="save">Speichern</span>
|
||||
<span wire:loading wire:target="save">Speichert…</span>
|
||||
</button>
|
||||
@if($tab === 'domains')
|
||||
<button wire:click="saveDomains" wire:loading.attr="disabled" wire:target="saveDomains" class="mbx-btn-primary">
|
||||
<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>
|
||||
@else
|
||||
<button wire:click="save" 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>
|
||||
<span wire:loading.remove wire:target="save">Speichern</span>
|
||||
<span wire:loading wire:target="save">Speichert…</span>
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sec-layout">
|
||||
{{-- Tab-Navigation --}}
|
||||
<div style="display:flex;gap:2px;border-bottom:1px solid var(--mw-b2);margin-bottom:20px">
|
||||
@php
|
||||
$tabs = [
|
||||
'general' => ['label' => 'Allgemein', 'icon' => '<circle cx="8" cy="8" r="2.2" stroke="currentColor" stroke-width="1.3"/><path d="M8 1.5v1.3M8 13.2v1.3M1.5 8h1.3M13.2 8h1.3M3.2 3.2l.9.9M11.9 11.9l.9.9M3.2 12.8l.9-.9M11.9 4.1l.9-.9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>'],
|
||||
'domains' => ['label' => 'Domains & SSL','icon' => '<circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.3"/><path d="M8 2C6 4.5 5 6.2 5 8s1 3.5 3 6M8 2c2 2.5 3 4.2 3 6s-1 3.5-3 6M2 8h12" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>'],
|
||||
'backup' => ['label' => 'Sicherung', 'icon' => '<ellipse cx="8" cy="4" rx="5" ry="2" stroke="currentColor" stroke-width="1.3"/><path d="M3 4v4c0 1.1 2.24 2 5 2s5-.9 5-2V4" stroke="currentColor" stroke-width="1.3"/><path d="M3 8v4c0 1.1 2.24 2 5 2s5-.9 5-2V8" stroke="currentColor" stroke-width="1.3"/>'],
|
||||
'notifications' => ['label' => 'Meldungen', 'icon' => '<path d="M8 14a1.5 1.5 0 0 0 3 0M8 2a4 4 0 0 1 4 4v3l1 1H3l1-1V6a4 4 0 0 1 4-4Z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>'],
|
||||
];
|
||||
@endphp
|
||||
@foreach($tabs as $key => $cfg)
|
||||
<button wire:click="$set('tab','{{ $key }}')"
|
||||
style="display:inline-flex;align-items:center;gap:6px;padding:9px 14px;font-size:12.5px;font-weight:500;border:none;background:transparent;cursor:pointer;border-bottom:2px solid {{ $tab === $key ? 'var(--mw-v)' : 'transparent' }};color:{{ $tab === $key ? 'var(--mw-v2)' : 'var(--mw-t4)' }};margin-bottom:-1px;transition:color .15s">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none">{!! $cfg['icon'] !!}</svg>
|
||||
{{ $cfg['label'] }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- ═══ Main ═══ --}}
|
||||
{{-- ═══ Tab: Allgemein ═══ --}}
|
||||
@if($tab === 'general')
|
||||
<div class="sec-layout">
|
||||
<div style="min-width:0">
|
||||
<div class="mbx-sections">
|
||||
|
||||
{{-- Allgemein --}}
|
||||
<div class="mbx-section">
|
||||
<div class="mbx-domain-head">
|
||||
<div class="mbx-domain-info">
|
||||
<span class="mbx-badge-mute">Allgemein</span>
|
||||
</div>
|
||||
<div class="mbx-domain-info"><span class="mbx-badge-mute">Allgemein</span></div>
|
||||
</div>
|
||||
<div style="padding:16px 18px">
|
||||
<div class="mw-modal-grid2">
|
||||
<div>
|
||||
<label class="mw-modal-label">Instanzname</label>
|
||||
<input type="text" value="{{ $instance_name }}" class="mw-modal-input" readonly
|
||||
style="opacity:.55;cursor:default">
|
||||
<input type="text" value="{{ $instance_name }}" class="mw-modal-input" readonly style="opacity:.55;cursor:default">
|
||||
<div class="mw-modal-hint">Wird aus config/app.php gelesen (APP_NAME)</div>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -67,153 +91,11 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 2FA --}}
|
||||
<livewire:ui.system.two-fa-status />
|
||||
|
||||
{{-- Backup --}}
|
||||
<div class="mbx-section" id="backup">
|
||||
<div class="mbx-domain-head">
|
||||
<div class="mbx-domain-info">
|
||||
<span class="mbx-badge-mute">Backup-Zeitplan</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
@if($backup_enabled)
|
||||
<span class="mbx-badge-ok">Aktiv</span>
|
||||
@else
|
||||
<span class="mbx-badge-warn">Deaktiviert</span>
|
||||
@endif
|
||||
<a href="{{ route('ui.system.backups') }}" class="mbx-btn-mute" style="font-size:11.5px;padding:4px 12px;text-decoration:none">Verlauf</a>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:16px 18px;display:flex;flex-direction:column;gap:14px">
|
||||
|
||||
{{-- Enable toggle --}}
|
||||
<label class="sec-check">
|
||||
<input type="checkbox" wire:model.live="backup_enabled">
|
||||
<span style="font-size:12.5px;color:var(--mw-t2)">Automatisches Backup aktivieren</span>
|
||||
</label>
|
||||
|
||||
{{-- Was sichern --}}
|
||||
<div>
|
||||
<label class="mw-modal-label" style="margin-bottom:8px;display:block">Was sichern</label>
|
||||
<div class="bk-src-grid">
|
||||
<label class="bk-src-card">
|
||||
<input type="checkbox" wire:model.live="backup_include_db">
|
||||
<span class="bk-src-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><ellipse cx="7" cy="3.5" rx="5" ry="2" stroke="currentColor" stroke-width="1.2"/><path d="M2 3.5v7c0 1.1 2.24 2 5 2s5-.9 5-2v-7" stroke="currentColor" stroke-width="1.2"/><path d="M2 7c0 1.1 2.24 2 5 2s5-.9 5-2" stroke="currentColor" stroke-width="1.2"/></svg>
|
||||
</span>
|
||||
<span class="bk-src-name">Datenbank</span>
|
||||
<span class="bk-src-hint">MySQL-Dump</span>
|
||||
</label>
|
||||
<label class="bk-src-card">
|
||||
<input type="checkbox" wire:model.live="backup_include_maildirs">
|
||||
<span class="bk-src-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x=".5" y="2.5" width="13" height="9" rx="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M.5 5l6.5 4.5L13.5 5" stroke="currentColor" stroke-width="1.2"/></svg>
|
||||
</span>
|
||||
<span class="bk-src-name">E-Mails</span>
|
||||
<span class="bk-src-hint">Maildirs</span>
|
||||
</label>
|
||||
<label class="bk-src-card">
|
||||
<input type="checkbox" wire:model.live="backup_include_configs">
|
||||
<span class="bk-src-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M7 1.5L12.5 4v4.8c0 2.8-2.3 4.8-5.5 5.2C3.8 13.6 1.5 11.6 1.5 8.8V4L7 1.5Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
|
||||
</span>
|
||||
<span class="bk-src-name">Konfiguration</span>
|
||||
<span class="bk-src-hint">postfix, dovecot, SSL</span>
|
||||
</label>
|
||||
</div>
|
||||
@if($backup_include_maildirs)
|
||||
<div style="margin-top:8px">
|
||||
<input type="text" wire:model.defer="backup_mail_dir"
|
||||
class="mw-modal-input" placeholder="/var/mail/vhosts">
|
||||
<div class="mw-modal-hint">Maildir-Pfad – leer lassen für automatische Erkennung</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if($backup_enabled)
|
||||
|
||||
{{-- Preset buttons --}}
|
||||
<div>
|
||||
<label class="mw-modal-label">Häufigkeit</label>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap">
|
||||
@foreach(['hourly' => 'Stündlich', 'daily' => 'Täglich', 'weekly' => 'Wöchentlich', 'monthly' => 'Monatlich', 'custom' => 'Benutzerdefiniert'] as $val => $label)
|
||||
<button type="button" wire:click="$set('backup_preset','{{ $val }}')"
|
||||
style="padding:5px 14px;border-radius:6px;font-size:12px;cursor:pointer;transition:all .1s;
|
||||
{{ $backup_preset === $val
|
||||
? 'background:var(--mw-vbg);border:1px solid var(--mw-vbd);color:var(--mw-v2);font-weight:600'
|
||||
: 'background:transparent;border:1px solid var(--mw-b2);color:var(--mw-t4)' }}">
|
||||
{{ $label }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Time / day pickers --}}
|
||||
@if($backup_preset !== 'hourly' && $backup_preset !== 'custom')
|
||||
<div class="mw-modal-grid2">
|
||||
<div>
|
||||
<label class="mw-modal-label">Uhrzeit</label>
|
||||
<input type="time" wire:model.live="backup_time" class="mw-modal-input">
|
||||
</div>
|
||||
|
||||
@if($backup_preset === 'weekly')
|
||||
<div>
|
||||
<label class="mw-modal-label">Wochentag</label>
|
||||
<select wire:model.live="backup_weekday" class="mw-modal-input">
|
||||
@foreach(['0'=>'Sonntag','1'=>'Montag','2'=>'Dienstag','3'=>'Mittwoch','4'=>'Donnerstag','5'=>'Freitag','6'=>'Samstag'] as $v => $d)
|
||||
<option value="{{ $v }}">{{ $d }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
@elseif($backup_preset === 'monthly')
|
||||
<div>
|
||||
<label class="mw-modal-label">Tag des Monats</label>
|
||||
<select wire:model.live="backup_monthday" class="mw-modal-input">
|
||||
@for($d = 1; $d <= 28; $d++)
|
||||
<option value="{{ $d }}">{{ $d }}.</option>
|
||||
@endfor
|
||||
</select>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($backup_preset === 'custom')
|
||||
<div>
|
||||
<label class="mw-modal-label">Cron-Ausdruck</label>
|
||||
<input type="text" wire:model.live="backup_cron" class="mw-modal-input"
|
||||
placeholder="0 3 * * *" style="font-family:monospace">
|
||||
<div class="mw-modal-hint">Standard Cron-Syntax: Minute Stunde Tag Monat Wochentag</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Cron-Vorschau --}}
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--mw-bg4);border:1px solid var(--mw-b2);border-radius:7px">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><circle cx="6" cy="6" r="5" stroke="var(--mw-t4)" stroke-width="1.2"/><path d="M6 3.5v3l1.5 1.5" stroke="var(--mw-t4)" stroke-width="1.2" stroke-linecap="round"/></svg>
|
||||
<span style="font-size:11px;color:var(--mw-t4)">Cron:</span>
|
||||
<code style="font-family:monospace;font-size:11px;color:var(--mw-v2)">{{ $backup_cron }}</code>
|
||||
</div>
|
||||
|
||||
{{-- Aufbewahrung --}}
|
||||
<div>
|
||||
<label class="mw-modal-label">Aufbewahrung (Anzahl Backups)</label>
|
||||
<input type="number" wire:model.defer="backup_retention" class="mw-modal-input"
|
||||
min="1" max="365" style="max-width:120px">
|
||||
<div class="mw-modal-hint">Ältere Backups werden automatisch gelöscht. Empfehlung: 7–14.</div>
|
||||
</div>
|
||||
|
||||
@endif
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Sicherheit --}}
|
||||
<div class="mbx-section">
|
||||
<div class="mbx-domain-head">
|
||||
<div class="mbx-domain-info">
|
||||
<span class="mbx-badge-mute">Sicherheit</span>
|
||||
</div>
|
||||
<div class="mbx-domain-info"><span class="mbx-badge-mute">Sicherheit</span></div>
|
||||
</div>
|
||||
<div style="padding:16px 18px;display:flex;flex-direction:column;gap:14px">
|
||||
<div class="mw-modal-grid2">
|
||||
|
|
@ -234,174 +116,11 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ═══ Sidebar ═══ --}}
|
||||
<div>
|
||||
<div class="mbx-sections">
|
||||
|
||||
{{-- Domains --}}
|
||||
<div class="mbx-section">
|
||||
<div class="mbx-domain-head">
|
||||
<div class="mbx-domain-info">
|
||||
<span class="mbx-badge-mute">Domains</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:14px 16px;display:flex;flex-direction:column;gap:12px">
|
||||
|
||||
<div style="display:flex;align-items:flex-start;gap:8px;padding:10px 12px;border-radius:8px;background:rgba(251,191,36,.07);border:1px solid rgba(251,191,36,.25)">
|
||||
<svg width="14" height="14" 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,.9);line-height:1.5">
|
||||
Änderungen hier wirken sich auf das gesamte Routing aus — UI, Webmail und Mailserver sind betroffen. Nur ändern wenn DNS bereits korrekt konfiguriert ist.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mw-modal-label">UI Domain</label>
|
||||
<input type="text" wire:model="ui_domain" class="mw-modal-input"
|
||||
placeholder="mail.example.com">
|
||||
@error('ui_domain') <div class="mw-modal-error">{{ $message }}</div> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="mw-modal-label">Mailserver Domain</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>
|
||||
<div>
|
||||
<label class="mw-modal-label">Webmail Domain</label>
|
||||
<input type="text" wire:model="webmail_domain" class="mw-modal-input"
|
||||
placeholder="webmail.example.com">
|
||||
@error('webmail_domain') <div class="mw-modal-error">{{ $message }}</div> @enderror
|
||||
</div>
|
||||
<button wire:click="saveDomains"
|
||||
wire:loading.attr="disabled"
|
||||
wire:target="saveDomains"
|
||||
class="mbx-btn-primary" style="width:100%;justify-content:center;font-size:12px">
|
||||
<span wire:loading.remove wire:target="saveDomains" style="display:inline-flex;align-items:center;gap:5px">
|
||||
<svg width="11" height="11" 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>Domains speichern
|
||||
</span>
|
||||
<span wire:loading wire:target="saveDomains">Speichert…</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- SSL-Zertifikate --}}
|
||||
<div class="mbx-section">
|
||||
<div class="mbx-domain-head">
|
||||
<div class="mbx-domain-info">
|
||||
<span class="mbx-badge-mute">SSL-Zertifikate</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:14px 16px;display:flex;flex-direction:column;gap:12px">
|
||||
|
||||
{{-- Cert-Status-Tabelle --}}
|
||||
@php
|
||||
$certLabels = ['ui' => 'UI', 'webmail' => 'Webmail', 'mail' => 'Mailserver'];
|
||||
@endphp
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
@foreach(['ui','webmail','mail'] as $key)
|
||||
@php $c = $sslCerts[$key] ?? null; @endphp
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:9px 11px;border-radius:7px;background:var(--mw-bg3);border:1px solid var(--mw-b2)">
|
||||
{{-- Icon --}}
|
||||
@if(!$c || $c['status'] === 'nodomain')
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" style="flex-shrink:0;color:var(--mw-t4)"><rect x="1.5" y="6" width="11" height="7.5" rx="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M4.5 6V4.5a2.5 2.5 0 0 1 5 0V6" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
|
||||
@elseif($c['status'] === 'ok')
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" style="flex-shrink:0;color:#22c55e"><rect x="1.5" y="6" width="11" height="7.5" rx="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M4.5 6V4.5a2.5 2.5 0 0 1 5 0V6" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/><path d="M5 9.5l1.3 1.3 2.7-2.7" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
@elseif($c['status'] === 'expiring')
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" style="flex-shrink:0;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.5v2.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/><circle cx="7" cy="9.5" r=".6" fill="currentColor"/></svg>
|
||||
@else
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" style="flex-shrink:0;color:#f87171"><circle cx="7" cy="7" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M5 5l4 4M9 5l-4 4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
|
||||
@endif
|
||||
{{-- Label + Domain --}}
|
||||
<div style="flex:1;min-width:0">
|
||||
<span style="font-size:11px;font-weight:500;color:var(--mw-t3)">{{ $certLabels[$key] }}</span>
|
||||
<span style="font-size:10.5px;color:var(--mw-t4);margin-left:6px;font-family:monospace">{{ $c['domain'] ?? '—' }}</span>
|
||||
</div>
|
||||
{{-- Status --}}
|
||||
@if(!$c || $c['status'] === 'nodomain')
|
||||
<span style="font-size:10.5px;color:var(--mw-t4)">Keine Domain</span>
|
||||
@elseif($c['status'] === 'missing')
|
||||
<span style="font-size:10.5px;color:#f87171">Fehlt</span>
|
||||
@elseif($c['status'] === 'expired')
|
||||
<span style="font-size:10.5px;color:#f87171">Abgelaufen</span>
|
||||
@elseif($c['status'] === 'expiring')
|
||||
<span style="font-size:10.5px;color:#f59e0b">Läuft ab in {{ $c['days'] }} Tagen</span>
|
||||
@elseif($c['status'] === 'ok')
|
||||
<span style="font-size:10.5px;color:#22c55e">Gültig bis {{ $c['expiry'] }}</span>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Fortschritt während Provisioning --}}
|
||||
@if($sslProvisioning || $sslDone)
|
||||
@if($sslProvisioning)
|
||||
<div wire:poll.2s="pollSsl"></div>
|
||||
@endif
|
||||
<div style="display:flex;flex-direction:column;gap:6px;padding:10px 12px;border-radius:8px;background:rgba(99,102,241,.06);border:1px solid rgba(99,102,241,.2)">
|
||||
<div style="font-size:11px;font-weight:600;color:var(--mw-t2);margin-bottom:2px">
|
||||
{{ $sslProvisioning ? 'Zertifikate werden eingerichtet…' : 'Einrichtung abgeschlossen' }}
|
||||
</div>
|
||||
@foreach(['ui' => 'UI', 'webmail' => 'Webmail', 'mail' => 'Mailserver'] as $key => $label)
|
||||
@php $st = $sslProgress[$key] ?? 'pending'; @endphp
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
@if($st === 'done')
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" style="flex-shrink:0;color:#22c55e"><circle cx="6" cy="6" r="5" stroke="currentColor" stroke-width="1.1"/><path d="M3.5 6l1.8 1.8 3.2-3.2" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
@elseif($st === 'error' || $st === 'nodns')
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" style="flex-shrink:0;color:#f87171"><circle cx="6" cy="6" r="5" stroke="currentColor" stroke-width="1.1"/><path d="M4 4l4 4M8 4l-4 4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
|
||||
@elseif($st === 'skip')
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" style="flex-shrink:0;color:var(--mw-t4)"><circle cx="6" cy="6" r="5" stroke="currentColor" stroke-width="1.1"/><path d="M4 6h4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
|
||||
@elseif($st === 'running')
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" style="flex-shrink:0;color:#818cf8;animation:spin 1s linear infinite"><circle cx="6" cy="6" r="4.5" stroke="currentColor" stroke-width="1.3" stroke-dasharray="16 8" stroke-linecap="round"/></svg>
|
||||
@else
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" style="flex-shrink:0;color:var(--mw-t4)"><circle cx="6" cy="6" r="4.5" stroke="currentColor" stroke-width="1.1" stroke-dasharray="3 3"/></svg>
|
||||
@endif
|
||||
<span style="font-size:11px;color:{{ in_array($st,['error','nodns']) ? '#f87171' : ($st==='done' ? '#22c55e' : 'var(--mw-t3)') }}">
|
||||
{{ $label }}
|
||||
@if($st === 'nodns') <span style="font-size:10px"> — kein DNS-Eintrag</span>
|
||||
@elseif($st === 'error') <span style="font-size:10px"> — fehlgeschlagen</span>
|
||||
@elseif($st === 'skip') <span style="font-size:10px;color:var(--mw-t4)"> — kein Zertifikat nötig</span>
|
||||
@elseif($st === 'running') <span style="font-size:10px;color:var(--mw-t4)"> — läuft…</span>
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Buttons --}}
|
||||
@if(app()->isProduction())
|
||||
<button wire:click="startSslProvisioning"
|
||||
wire:loading.attr="disabled"
|
||||
wire:target="startSslProvisioning"
|
||||
@disabled($sslProvisioning)
|
||||
class="mbx-btn-primary" style="width:100%;justify-content:center;font-size:12px">
|
||||
<svg width="11" height="11" viewBox="0 0 14 14" fill="none"><rect x="1.5" y="6" width="11" height="7.5" rx="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M4.5 6V4.5a2.5 2.5 0 0 1 5 0V6" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
|
||||
<span wire:loading.remove wire:target="startSslProvisioning">
|
||||
{{ $sslProvisioning ? 'Läuft…' : 'Let\'s Encrypt Zertifikate einrichten' }}
|
||||
</span>
|
||||
<span wire:loading wire:target="startSslProvisioning">Startet…</span>
|
||||
</button>
|
||||
@else
|
||||
<button wire:click="openSslModal"
|
||||
class="mbx-btn-mute" style="width:100%;justify-content:center;font-size:11.5px">
|
||||
<svg width="11" height="11" viewBox="0 0 14 14" fill="none"><rect x="1.5" y="6" width="11" height="7.5" rx="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M4.5 6V4.5a2.5 2.5 0 0 1 5 0V6" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
|
||||
SSL manuell erzwingen (certbot)
|
||||
</button>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Info --}}
|
||||
<div class="mbx-section">
|
||||
<div class="mbx-domain-head">
|
||||
<div class="mbx-domain-info">
|
||||
<span class="mbx-badge-mute">System-Info</span>
|
||||
</div>
|
||||
<div class="mbx-domain-info"><span class="mbx-badge-mute">System-Info</span></div>
|
||||
</div>
|
||||
<div style="padding:14px 16px;font-size:11.5px;color:var(--mw-t3);display:flex;flex-direction:column;gap:8px">
|
||||
<div style="display:flex;justify-content:space-between">
|
||||
|
|
@ -426,10 +145,322 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- ═══ Tab: Domains & SSL ═══ --}}
|
||||
@if($tab === 'domains')
|
||||
<div class="sec-layout">
|
||||
<div style="min-width:0">
|
||||
<div class="mbx-sections">
|
||||
<div class="mbx-section">
|
||||
<div class="mbx-domain-head">
|
||||
<div class="mbx-domain-info"><span class="mbx-badge-mute">Domains</span></div>
|
||||
</div>
|
||||
<div style="padding:14px 16px;display:flex;flex-direction:column;gap:12px">
|
||||
|
||||
<div style="display:flex;align-items:flex-start;gap:8px;padding:10px 12px;border-radius:8px;background:rgba(251,191,36,.07);border:1px solid rgba(251,191,36,.25)">
|
||||
<svg width="14" height="14" 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,.9);line-height:1.5">
|
||||
Änderungen hier wirken sich auf das gesamte Routing aus — UI, Webmail und Mailserver sind betroffen. Nur ändern wenn DNS bereits korrekt konfiguriert ist.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mw-modal-label">UI Domain</label>
|
||||
<input type="text" wire:model="ui_domain" class="mw-modal-input" placeholder="mail.example.com">
|
||||
@error('ui_domain') <div class="mw-modal-error">{{ $message }}</div> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="mw-modal-label">Mailserver Domain</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>
|
||||
<div>
|
||||
<label class="mw-modal-label">Webmail Domain</label>
|
||||
<input type="text" wire:model="webmail_domain" class="mw-modal-input" placeholder="webmail.example.com">
|
||||
@error('webmail_domain') <div class="mw-modal-error">{{ $message }}</div> @enderror
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mbx-sections">
|
||||
<div class="mbx-section">
|
||||
<div class="mbx-domain-head">
|
||||
<div class="mbx-domain-info"><span class="mbx-badge-mute">SSL-Zertifikate</span></div>
|
||||
</div>
|
||||
<div style="padding:10px 14px;display:flex;flex-direction:column;gap:5px">
|
||||
@foreach(['ui','webmail','mail'] as $key)
|
||||
@php
|
||||
$c = $sslCerts[$key] ?? null;
|
||||
$ok = $c && in_array($c['status'], ['ok','expiring']);
|
||||
$domain = $c['domain'] ?? '';
|
||||
@endphp
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:6px 10px;border-radius:6px;background:var(--mw-bg3);border:1px solid var(--mw-b2)">
|
||||
@if($ok)
|
||||
<svg width="12" height="12" viewBox="0 0 14 14" fill="none" style="flex-shrink:0;color:#22c55e"><rect x="1.5" y="6" width="11" height="7.5" rx="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M4.5 6V4.5a2.5 2.5 0 0 1 5 0V6" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/><path d="M5 9.5l1.3 1.3 2.7-2.7" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
@elseif($domain)
|
||||
<svg width="12" height="12" viewBox="0 0 14 14" fill="none" style="flex-shrink:0;color:#f87171"><rect x="1.5" y="6" width="11" height="7.5" rx="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M4.5 6V4.5a2.5 2.5 0 0 1 5 0V6" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
|
||||
@else
|
||||
<svg width="12" height="12" viewBox="0 0 14 14" fill="none" style="flex-shrink:0;color:var(--mw-t4)"><rect x="1.5" y="6" width="11" height="7.5" rx="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M4.5 6V4.5a2.5 2.5 0 0 1 5 0V6" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
|
||||
@endif
|
||||
<span style="font-size:11px;color:var(--mw-t3);font-family:monospace">{{ $domain ?: '—' }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
<a href="{{ route('ui.security.ssl') }}" style="display:flex;align-items:center;gap:5px;font-size:11px;color:var(--mw-v);padding:5px 2px;text-decoration:none;margin-top:2px">
|
||||
<svg width="10" height="10" viewBox="0 0 14 14" fill="none"><rect x="1.5" y="6" width="11" height="7.5" rx="1.5" stroke="currentColor" stroke-width="1.3"/><path d="M4.5 6V4.5a2.5 2.5 0 0 1 5 0V6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||
Zertifikate verwalten →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- ═══ Tab: Sicherung ═══ --}}
|
||||
@if($tab === 'backup')
|
||||
<div class="sec-layout">
|
||||
<div style="min-width:0"><div class="mbx-sections">
|
||||
<div class="mbx-section" id="backup">
|
||||
<div class="mbx-domain-head">
|
||||
<div class="mbx-domain-info"><span class="mbx-badge-mute">Backup-Zeitplan</span></div>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
@if($backup_enabled)
|
||||
<span class="mbx-badge-ok">Aktiv</span>
|
||||
@else
|
||||
<span class="mbx-badge-warn">Deaktiviert</span>
|
||||
@endif
|
||||
<a href="{{ route('ui.system.backups') }}" class="mbx-btn-mute" style="font-size:11.5px;padding:4px 12px;text-decoration:none">Verlauf</a>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:16px 18px;display:flex;flex-direction:column;gap:14px">
|
||||
|
||||
<label class="sec-check">
|
||||
<input type="checkbox" wire:model.live="backup_enabled">
|
||||
<span style="font-size:12.5px;color:var(--mw-t2)">Automatisches Backup aktivieren</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label class="mw-modal-label" style="margin-bottom:8px;display:block">Was sichern</label>
|
||||
<div class="bk-src-grid">
|
||||
<label class="bk-src-card">
|
||||
<input type="checkbox" wire:model.live="backup_include_db">
|
||||
<span class="bk-src-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><ellipse cx="7" cy="3.5" rx="5" ry="2" stroke="currentColor" stroke-width="1.2"/><path d="M2 3.5v7c0 1.1 2.24 2 5 2s5-.9 5-2v-7" stroke="currentColor" stroke-width="1.2"/><path d="M2 7c0 1.1 2.24 2 5 2s5-.9 5-2" stroke="currentColor" stroke-width="1.2"/></svg>
|
||||
</span>
|
||||
<span class="bk-src-name">Datenbank</span>
|
||||
<span class="bk-src-hint">MySQL-Dump</span>
|
||||
</label>
|
||||
<label class="bk-src-card">
|
||||
<input type="checkbox" wire:model.live="backup_include_maildirs">
|
||||
<span class="bk-src-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x=".5" y="2.5" width="13" height="9" rx="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M.5 5l6.5 4.5L13.5 5" stroke="currentColor" stroke-width="1.2"/></svg>
|
||||
</span>
|
||||
<span class="bk-src-name">E-Mails</span>
|
||||
<span class="bk-src-hint">Maildirs</span>
|
||||
</label>
|
||||
<label class="bk-src-card">
|
||||
<input type="checkbox" wire:model.live="backup_include_configs">
|
||||
<span class="bk-src-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M7 1.5L12.5 4v4.8c0 2.8-2.3 4.8-5.5 5.2C3.8 13.6 1.5 11.6 1.5 8.8V4L7 1.5Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
|
||||
</span>
|
||||
<span class="bk-src-name">Konfiguration</span>
|
||||
<span class="bk-src-hint">postfix, dovecot, SSL</span>
|
||||
</label>
|
||||
</div>
|
||||
@if($backup_include_maildirs)
|
||||
<div style="margin-top:8px">
|
||||
<input type="text" wire:model.defer="backup_mail_dir" class="mw-modal-input" placeholder="/var/mail/vhosts">
|
||||
<div class="mw-modal-hint">Maildir-Pfad – leer lassen für automatische Erkennung</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if($backup_enabled)
|
||||
|
||||
<div>
|
||||
<label class="mw-modal-label">Häufigkeit</label>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap">
|
||||
@foreach(['hourly' => 'Stündlich', 'daily' => 'Täglich', 'weekly' => 'Wöchentlich', 'monthly' => 'Monatlich', 'custom' => 'Benutzerdefiniert'] as $val => $label)
|
||||
<button type="button" wire:click="$set('backup_preset','{{ $val }}')"
|
||||
style="padding:5px 14px;border-radius:6px;font-size:12px;cursor:pointer;transition:all .1s;
|
||||
{{ $backup_preset === $val
|
||||
? 'background:var(--mw-vbg);border:1px solid var(--mw-vbd);color:var(--mw-v2);font-weight:600'
|
||||
: 'background:transparent;border:1px solid var(--mw-b2);color:var(--mw-t4)' }}">
|
||||
{{ $label }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($backup_preset !== 'hourly' && $backup_preset !== 'custom')
|
||||
<div class="mw-modal-grid2">
|
||||
<div>
|
||||
<label class="mw-modal-label">Uhrzeit</label>
|
||||
<input type="time" wire:model.live="backup_time" class="mw-modal-input">
|
||||
</div>
|
||||
@if($backup_preset === 'weekly')
|
||||
<div>
|
||||
<label class="mw-modal-label">Wochentag</label>
|
||||
<select wire:model.live="backup_weekday" class="mw-modal-input">
|
||||
@foreach(['0'=>'Sonntag','1'=>'Montag','2'=>'Dienstag','3'=>'Mittwoch','4'=>'Donnerstag','5'=>'Freitag','6'=>'Samstag'] as $v => $d)
|
||||
<option value="{{ $v }}">{{ $d }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
@elseif($backup_preset === 'monthly')
|
||||
<div>
|
||||
<label class="mw-modal-label">Tag des Monats</label>
|
||||
<select wire:model.live="backup_monthday" class="mw-modal-input">
|
||||
@for($d = 1; $d <= 28; $d++)
|
||||
<option value="{{ $d }}">{{ $d }}.</option>
|
||||
@endfor
|
||||
</select>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($backup_preset === 'custom')
|
||||
<div>
|
||||
<label class="mw-modal-label">Cron-Ausdruck</label>
|
||||
<input type="text" wire:model.live="backup_cron" class="mw-modal-input" placeholder="0 3 * * *" style="font-family:monospace">
|
||||
<div class="mw-modal-hint">Standard Cron-Syntax: Minute Stunde Tag Monat Wochentag</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--mw-bg4);border:1px solid var(--mw-b2);border-radius:7px">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><circle cx="6" cy="6" r="5" stroke="var(--mw-t4)" stroke-width="1.2"/><path d="M6 3.5v3l1.5 1.5" stroke="var(--mw-t4)" stroke-width="1.2" stroke-linecap="round"/></svg>
|
||||
<span style="font-size:11px;color:var(--mw-t4)">Cron:</span>
|
||||
<code style="font-family:monospace;font-size:11px;color:var(--mw-v2)">{{ $backup_cron }}</code>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mw-modal-label">Aufbewahrung (Anzahl Backups)</label>
|
||||
<input type="number" wire:model.defer="backup_retention" class="mw-modal-input" min="1" max="365" style="max-width:120px">
|
||||
<div class="mw-modal-hint">Ältere Backups werden automatisch gelöscht. Empfehlung: 7–14.</div>
|
||||
</div>
|
||||
|
||||
@endif
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
|
||||
{{-- Sidebar: Sicherung --}}
|
||||
<div><div class="mbx-sections">
|
||||
<div class="mbx-section">
|
||||
<div class="mbx-domain-head">
|
||||
<div class="mbx-domain-info"><span class="mbx-badge-mute">Letztes Backup</span></div>
|
||||
</div>
|
||||
<div style="padding:12px 14px;display:flex;flex-direction:column;gap:8px;font-size:11.5px">
|
||||
@if($backupMeta && $backupMeta->last_run_at)
|
||||
@php
|
||||
$statusColor = $backupMeta->last_status === 'ok' ? '#22c55e' : '#f87171';
|
||||
$statusLabel = $backupMeta->last_status === 'ok' ? 'Erfolgreich' : 'Fehler';
|
||||
$sizeKb = $backupMeta->last_size_bytes ? round($backupMeta->last_size_bytes / 1024) : null;
|
||||
@endphp
|
||||
<div style="display:flex;justify-content:space-between">
|
||||
<span style="color:var(--mw-t4)">Status</span>
|
||||
<span style="color:{{ $statusColor }}">{{ $statusLabel }}</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between">
|
||||
<span style="color:var(--mw-t4)">Zuletzt</span>
|
||||
<span style="color:var(--mw-t2)">{{ \Carbon\Carbon::parse($backupMeta->last_run_at)->format('d.m.Y H:i') }}</span>
|
||||
</div>
|
||||
@if($sizeKb)
|
||||
<div style="display:flex;justify-content:space-between">
|
||||
<span style="color:var(--mw-t4)">Größe</span>
|
||||
<span style="color:var(--mw-t2)">{{ $sizeKb < 1024 ? $sizeKb.' KB' : round($sizeKb/1024,1).' MB' }}</span>
|
||||
</div>
|
||||
@endif
|
||||
<div style="display:flex;justify-content:space-between">
|
||||
<span style="color:var(--mw-t4)">Aufbewahrung</span>
|
||||
<span style="color:var(--mw-t2)">{{ $backupMeta->retention_count }} Stück</span>
|
||||
</div>
|
||||
@else
|
||||
<span style="color:var(--mw-t4)">Noch kein Backup ausgeführt.</span>
|
||||
@endif
|
||||
<a href="{{ route('ui.system.backups') }}" style="display:flex;align-items:center;gap:5px;font-size:11px;color:var(--mw-v);padding:4px 0;text-decoration:none;margin-top:2px">
|
||||
<svg width="10" height="10" viewBox="0 0 14 14" fill="none"><ellipse cx="7" cy="3.5" rx="5" ry="2" stroke="currentColor" stroke-width="1.3"/><path d="M2 3.5v7c0 1.1 2.24 2 5 2s5-.9 5-2v-7" stroke="currentColor" stroke-width="1.3"/></svg>
|
||||
Backup-Verlauf →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- ═══ Tab: Meldungen ═══ --}}
|
||||
@if($tab === 'notifications')
|
||||
<div class="sec-layout">
|
||||
<div style="min-width:0"><div class="mbx-sections">
|
||||
<div class="mbx-section">
|
||||
<div class="mbx-domain-head">
|
||||
<div class="mbx-domain-info"><span class="mbx-badge-mute">Benachrichtigungen</span></div>
|
||||
</div>
|
||||
<div style="padding:16px 18px;display:flex;flex-direction:column;gap:14px">
|
||||
<div class="mw-modal-grid2">
|
||||
<div>
|
||||
<label class="mw-modal-label">Absendername</label>
|
||||
<input type="text" wire:model.defer="notify_sender_name" class="mw-modal-input" placeholder="CluBird Benachrichtigung">
|
||||
<div class="mw-modal-hint">Wird als Absender in System-Mails angezeigt</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mw-modal-label">Admin-E-Mail</label>
|
||||
<input type="email" wire:model.defer="notify_admin_email" class="mw-modal-input" placeholder="admin@example.com">
|
||||
<div class="mw-modal-hint">Empfänger für Update- & Systemmeldungen</div>
|
||||
</div>
|
||||
</div>
|
||||
@php
|
||||
$sysmailFqdn = \App\Models\Setting::get('notify_from_address')
|
||||
?: ('no-reply@' . config('mailpool.platform_system_zone','sysmail') . '.' . config('mailpool.platform_zone',''));
|
||||
@endphp
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:9px 12px;border-radius:7px;background:var(--mw-bg3);border:1px solid var(--mw-b2)">
|
||||
<svg width="12" height="12" viewBox="0 0 14 14" fill="none" style="flex-shrink:0;color:var(--mw-t4)"><rect x=".5" y="2.5" width="13" height="9" rx="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M.5 5l6.5 4 6.5-4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
|
||||
<span style="font-size:11px;color:var(--mw-t4)">Absenderadresse:</span>
|
||||
<span style="font-family:monospace;font-size:11px;color:var(--mw-t2)">{{ $sysmailFqdn }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
|
||||
{{-- Sidebar: Meldungen --}}
|
||||
<div><div class="mbx-sections">
|
||||
<div class="mbx-section">
|
||||
<div class="mbx-domain-head">
|
||||
<div class="mbx-domain-info"><span class="mbx-badge-mute">Meldungstypen</span></div>
|
||||
</div>
|
||||
<div style="padding:12px 14px;display:flex;flex-direction:column;gap:6px;font-size:11.5px">
|
||||
@foreach([
|
||||
['Update verfügbar', 'Neue App-Version erkannt'],
|
||||
['Update abgeschlossen', 'Nach erfolgreichem Update'],
|
||||
['Backup-Fehler', 'Wenn ein Backup fehlschlägt'],
|
||||
['SSL läuft ab', 'Zertifikat < 14 Tage gültig'],
|
||||
] as [$title, $desc])
|
||||
<div style="display:flex;align-items:flex-start;gap:8px;padding:7px 10px;border-radius:6px;background:var(--mw-bg3);border:1px solid var(--mw-b2)">
|
||||
<svg width="10" height="10" viewBox="0 0 14 14" fill="none" style="flex-shrink:0;margin-top:2px;color:var(--mw-v)"><circle cx="7" cy="7" r="5.5" stroke="currentColor" stroke-width="1.3"/><path d="M7 4.5v3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="7" cy="9.5" r=".6" fill="currentColor"/></svg>
|
||||
<div>
|
||||
<div style="color:var(--mw-t2);font-weight:500">{{ $title }}</div>
|
||||
<div style="color:var(--mw-t4);font-size:11px;margin-top:1px">{{ $desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,54 +1,48 @@
|
|||
<div
|
||||
class="glass-card rounded-2xl p-4 border border-white/10 bg-white/5 max-h-fit"
|
||||
@if($state === 'running') wire:poll.3s="pollUpdate" @endif
|
||||
@if($state === 'running') wire:poll.3s="pollUpdate"
|
||||
@else wire:poll.2m="refreshVersions"
|
||||
@endif
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
{{-- Shield + Spinner --}}
|
||||
<div class="relative shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="shieldGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#4ade80"/>
|
||||
<stop offset="1" stop-color="#15803d"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="shine" cx="30%" cy="20%" r="70%">
|
||||
<stop offset="0%" stop-color="rgba(255,255,255,0.35)"/>
|
||||
<stop offset="100%" stop-color="rgba(255,255,255,0)"/>
|
||||
</radialGradient>
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="#22c55e" flood-opacity="0.6"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<path d="M32 6l20 8v12c0 13.5-8.7 22.7-20 27-11.3-4.3-20-13.5-20-27V14l20-8z"
|
||||
fill="url(#shieldGradient)" filter="url(#glow)"/>
|
||||
<path d="M32 6l20 8v12c0 13.5-8.7 22.7-20 27-11.3-4.3-20-13.5-20-27V14l20-8z"
|
||||
fill="url(#shine)"/>
|
||||
<i class="ph-bold ph-arrows-clockwise absolute top-1/2 left-1/2 -translate-1/2 text-2xl {{ $state === 'running' ? 'animate-spin' : '' }}"></i>
|
||||
<div class="flex items-start gap-3">
|
||||
{{-- CluBird Icon --}}
|
||||
<div class="relative shrink-0 flex items-center justify-center rounded-xl size-12"
|
||||
style="background: linear-gradient(135deg, #6366f1, #4338ca); box-shadow: 0 0 18px rgba(99,102,241,0.35)">
|
||||
<svg viewBox="0 0 28 28" width="22" height="22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<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>
|
||||
@if($state === 'running')
|
||||
<div class="absolute inset-0 rounded-xl border-2 border-indigo-400/60 animate-ping" style="animation-duration:1.8s"></div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-white/90 font-semibold">{{ config('app.name') }} Update</div>
|
||||
<div class="text-[13px] font-semibold" style="color:var(--mw-t1)">
|
||||
<span style="color:var(--mw-t1)">Clu</span><span style="color:#6366f1">Bird</span>
|
||||
<span class="font-normal" style="color:var(--mw-t3)">Update</span>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-white/70">
|
||||
<div class="text-[11px] mt-0.5" style="color:var(--mw-t4)">
|
||||
@if($displayCurrent)
|
||||
aktuell: <span class="text-white/90">{{ $displayCurrent }}</span>
|
||||
aktuell: <span style="color:var(--mw-t2)">{{ $displayCurrent }}</span>
|
||||
@else
|
||||
aktuell: <span class="text-white/60">–</span>
|
||||
aktuell: <span style="color:var(--mw-t5)">–</span>
|
||||
@endif
|
||||
|
||||
@if($displayLatest)
|
||||
<span class="mx-1 text-white/30">•</span>
|
||||
verfügbar: <span class="text-emerald-200">{{ $displayLatest }}</span>
|
||||
<span class="mx-1" style="color:var(--mw-b2)">•</span>
|
||||
verfügbar: <span class="text-indigo-300">{{ $displayLatest }}</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if($progressLine || $errorLine)
|
||||
<div class="mt-1 text-[11px] leading-tight">
|
||||
<div class="mt-1 text-[10px] leading-tight">
|
||||
@if($progressLine)
|
||||
<div class="text-white/60">{{ $progressLine }}</div>
|
||||
<div style="color:var(--mw-t4)">{{ $progressLine }}</div>
|
||||
@endif
|
||||
@if($errorLine)
|
||||
<div class="text-rose-300">{{ $errorLine }}</div>
|
||||
|
|
@ -57,124 +51,45 @@
|
|||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-end gap-1">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full text-[11px] border px-3 py-1 w-fit {{ $badgeClass }}">
|
||||
<i class="ph {{ $badgeIcon }} text-[12px]"></i>
|
||||
<div class="flex flex-col items-end gap-1.5 shrink-0">
|
||||
<span class="inline-flex items-center gap-1 rounded-full text-[10px] border px-2.5 py-0.5 {{ $badgeClass }}">
|
||||
<i class="ph {{ $badgeIcon }} text-[11px]"></i>
|
||||
{{ $badgeText }}
|
||||
</span>
|
||||
|
||||
@if($showButton)
|
||||
<button wire:click="runUpdate"
|
||||
@disabled($buttonDisabled)
|
||||
class="inline-flex items-center gap-1.5 rounded-full text-[11px] px-3 py-1 w-fit
|
||||
text-emerald-200 bg-emerald-500/10 border border-emerald-400/30
|
||||
hover:bg-emerald-500/15 hover:border-emerald-300/50
|
||||
disabled:opacity-60">
|
||||
<i class="ph ph-arrow-fat-lines-up text-[14px]"></i>
|
||||
class="inline-flex items-center gap-1 rounded-full text-[10px] px-2.5 py-0.5 border
|
||||
text-indigo-300 border-indigo-500/40 bg-indigo-500/10
|
||||
hover:bg-indigo-500/20 hover:border-indigo-400/60
|
||||
disabled:opacity-50 transition-colors">
|
||||
<i class="ph ph-arrow-fat-lines-up text-[12px]"></i>
|
||||
{{ $buttonLabel }}
|
||||
</button>
|
||||
@endif
|
||||
|
||||
<button wire:click="openLogs"
|
||||
class="inline-flex items-center gap-1 rounded-full text-[10px] px-2.5 py-0.5 border
|
||||
border-white/10 bg-white/5 hover:bg-white/10 transition-colors"
|
||||
style="color:var(--mw-t4)">
|
||||
<i class="ph ph-list-bullets text-[11px]"></i>
|
||||
Logs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Progress-Balken nur während running --}}
|
||||
@if($state === 'running')
|
||||
<div class="mt-3 h-1.5 rounded bg-white/10 overflow-hidden" aria-hidden="true">
|
||||
<div class="h-full bg-emerald-400/70 animate-[mwprogress_1.4s_infinite]" style="width:40%"></div>
|
||||
<div class="mt-2.5 h-1 rounded-full overflow-hidden" style="background:var(--mw-b1)">
|
||||
<div class="h-full rounded-full bg-indigo-400/70 animate-[mwprogress_1.4s_ease-in-out_infinite]" style="width:40%"></div>
|
||||
</div>
|
||||
<style>
|
||||
@keyframes mwprogress {
|
||||
0% { transform: translateX(-100%) }
|
||||
100% { transform: translateX(260%) }
|
||||
0% { transform: translateX(-150%) }
|
||||
100% { transform: translateX(350%) }
|
||||
}
|
||||
</style>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{--<div--}}
|
||||
{{-- class="glass-card rounded-2xl p-4 border border-white/10 bg-white/5 max-h-fit"--}}
|
||||
{{-- @if($state === 'running') wire:poll.3s="pollUpdate" @endif--}}
|
||||
{{-->--}}
|
||||
{{-- <div class="flex items-start gap-2">--}}
|
||||
{{-- --}}{{-- Shield + Update-Icon --}}
|
||||
{{-- <div class="relative shrink-0">--}}
|
||||
{{-- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">--}}
|
||||
{{-- <defs>--}}
|
||||
{{-- <linearGradient id="shieldGradient" x1="0" y1="0" x2="0" y2="1">--}}
|
||||
{{-- <stop offset="0" stop-color="#4ade80"/>--}}
|
||||
{{-- <stop offset="1" stop-color="#15803d"/>--}}
|
||||
{{-- </linearGradient>--}}
|
||||
{{-- <radialGradient id="shine" cx="30%" cy="20%" r="70%">--}}
|
||||
{{-- <stop offset="0%" stop-color="rgba(255,255,255,0.35)"/>--}}
|
||||
{{-- <stop offset="100%" stop-color="rgba(255,255,255,0)"/>--}}
|
||||
{{-- </radialGradient>--}}
|
||||
{{-- <filter id="glow" x="-20%" y="-20%" width="140%" height="140%">--}}
|
||||
{{-- <feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="#22c55e" flood-opacity="0.6"/>--}}
|
||||
{{-- </filter>--}}
|
||||
{{-- </defs>--}}
|
||||
|
||||
{{-- <!-- Schild -->--}}
|
||||
{{-- <path d="M32 6l20 8v12c0 13.5-8.7 22.7-20 27-11.3-4.3-20-13.5-20-27V14l20-8z"--}}
|
||||
{{-- fill="url(#shieldGradient)" filter="url(#glow)"/>--}}
|
||||
{{-- <path d="M32 6l20 8v12c0 13.5-8.7 22.7-20 27-11.3-4.3-20-13.5-20-27V14l20-8z"--}}
|
||||
{{-- fill="url(#shine)"/>--}}
|
||||
{{-- <i class="ph-bold ph-arrows-clockwise absolute top-1/2 left-1/2 -translate-1/2 text-2xl {{ $state === 'running' ? 'animate-spin' : '' }}"></i>--}}
|
||||
{{-- </svg>--}}
|
||||
{{-- </div>--}}
|
||||
|
||||
{{-- <div class="min-w-0 flex-1">--}}
|
||||
{{-- <div class="flex items-start justify-between gap-3">--}}
|
||||
{{-- <div class="min-w-0">--}}
|
||||
{{-- <div class="text-white/90 font-semibold">{{ config('app.name') }} Update</div>--}}
|
||||
|
||||
{{-- <div class="text-xs text-white/70">--}}
|
||||
{{-- @if($displayCurrent)--}}
|
||||
{{-- aktuell: <span class="text-white/90">{{ $displayCurrent }}</span>--}}
|
||||
{{-- @else--}}
|
||||
{{-- aktuell: <span class="text-white/60">–</span>--}}
|
||||
{{-- @endif--}}
|
||||
|
||||
{{-- @if($displayLatest)--}}
|
||||
{{-- <span class="mx-1 text-white/30">•</span>--}}
|
||||
{{-- verfügbar: <span class="text-emerald-200">{{ $displayLatest }}</span>--}}
|
||||
{{-- @endif--}}
|
||||
{{-- </div>--}}
|
||||
{{-- </div>--}}
|
||||
|
||||
{{-- <div class="flex flex-col items-end gap-1">--}}
|
||||
{{-- <span class="inline-flex items-center gap-1.5 rounded-full text-[11px] border px-3 py-1 w-fit--}}
|
||||
{{-- {{ $this->hasUpdate--}}
|
||||
{{-- ? 'text-yellow-200 bg-yellow-500/10 border-yellow-400/30'--}}
|
||||
{{-- : 'text-emerald-200 bg-emerald-500/10 border-emerald-400/30' }}">--}}
|
||||
{{-- <i class="ph {{ $this->hasUpdate ? 'ph-arrow-fat-line-up' : 'ph-check-circle' }} text-[12px]"></i>--}}
|
||||
{{-- {{ $this->hasUpdate ? 'Update verfügbar' : 'Aktuell' }}--}}
|
||||
{{-- </span>--}}
|
||||
{{-- @if($this->hasUpdate)--}}
|
||||
{{-- <button wire:click="runUpdate"--}}
|
||||
{{-- @disabled($state==='running')--}}
|
||||
{{-- class="inline-flex items-center gap-1.5 rounded-full text-[11px] px-3 py-1 w-fit--}}
|
||||
{{-- text-emerald-200 bg-emerald-500/10 border border-emerald-400/30--}}
|
||||
{{-- hover:bg-emerald-500/15 hover:border-emerald-300/50--}}
|
||||
{{-- disabled:opacity-60">--}}
|
||||
{{-- <i class="ph ph-arrow-fat-lines-up text-[14px]"></i>--}}
|
||||
{{-- Jetzt aktualisieren--}}
|
||||
{{-- </button>--}}
|
||||
{{-- @endif--}}
|
||||
{{-- </div>--}}
|
||||
{{-- </div>--}}
|
||||
{{-- --}}{{-- Fortschritt nur während running --}}
|
||||
{{-- @if($state === 'running')--}}
|
||||
{{-- <div class="mt-3 h-1.5 rounded bg-white/10 overflow-hidden">--}}
|
||||
{{-- <div class="h-full bg-emerald-400/70 animate-[mwprogress_1.4s_infinite]" style="width:40%"></div>--}}
|
||||
{{-- </div>--}}
|
||||
{{-- <style>--}}
|
||||
{{-- @keyframes mwprogress {--}}
|
||||
{{-- 0% { transform: translateX(-100%) }--}}
|
||||
{{-- 100% { transform: translateX(260%) }--}}
|
||||
{{-- }--}}
|
||||
{{-- </style>--}}
|
||||
{{-- @endif--}}
|
||||
{{-- </div>--}}
|
||||
{{-- </div>--}}
|
||||
{{--</div>--}}
|
||||
|
|
|
|||