Init Mailwolt Installer
parent
94ae46d4d5
commit
8bd603733f
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
class SettingsSyncCommand extends Command
|
||||
{
|
||||
protected $signature = 'settings:sync';
|
||||
protected $description = 'Synchronize all DB settings into Redis';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$count = 0;
|
||||
$this->info('Syncing settings to Redis...');
|
||||
|
||||
Setting::chunk(200, function ($settings) use (&$count) {
|
||||
foreach ($settings as $setting) {
|
||||
$key = "settings:{$setting->group}:{$setting->key}";
|
||||
try {
|
||||
Redis::set($key, $setting->value);
|
||||
$count++;
|
||||
} catch (\Throwable $e) {
|
||||
$this->error("Failed to write {$key}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$this->info("✅ Synced {$count} settings to Redis.");
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\UI\Mail;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AliasController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return view('ui.mail.alias-index');
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\UI\Mail;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class MailboxController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return view('ui.mail.mailbox-index');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreDomainRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'domain' => ['required','string','max:191','unique:domains,domain'],
|
||||
'total_quota_mb' => ['required','integer','min:1'], // 0 = unlimitiert wäre riskant – ich empfehle >0
|
||||
// 'default_mailbox_quota_mb' => ['nullable','integer','min:1','lte:total_quota_mb'],
|
||||
'is_active' => ['sometimes','boolean'],
|
||||
'is_system' => ['sometimes','boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,8 @@ use App\Enums\Role;
|
|||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Livewire\Component;
|
||||
|
|
@ -32,29 +34,71 @@ class SignupForm extends Component
|
|||
public function register()
|
||||
{
|
||||
$this->validate();
|
||||
$isFirstUser = User::count() === 0;
|
||||
|
||||
User::create([
|
||||
'name' => $this->name,
|
||||
'email' => $this->email,
|
||||
'password' => Hash::make($this->password),
|
||||
'role' => Setting::signupAllowed() ? Role::Admin : Role::Member,
|
||||
]);
|
||||
|
||||
if ($isFirstUser) {
|
||||
Setting::updateOrCreate(['key' => 'signup_enabled'], ['value' => '0']);
|
||||
// Registrierung global gesperrt?
|
||||
if (!Setting::signupAllowed()) {
|
||||
$this->addError('email', 'Registrierung ist derzeit deaktiviert.');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->reset(['name','email','password','password_confirmation', 'accept']);
|
||||
// Redis-Lock verhindert Doppel-Admin bei parallelem Signup
|
||||
Cache::lock('signup:first-user', 10)->block(5, function () {
|
||||
DB::transaction(function () {
|
||||
|
||||
// innerh. Lock & TX nochmal prüfen
|
||||
$isFirstUser = !User::query()->exists();
|
||||
|
||||
User::create([
|
||||
'name' => $this->name,
|
||||
'email' => $this->email,
|
||||
'password' => Hash::make($this->password),
|
||||
'role' => $isFirstUser ? Role::Admin : Role::Member,
|
||||
]);
|
||||
|
||||
// nach erstem User: Signup deaktivieren
|
||||
if ($isFirstUser) {
|
||||
Setting::set('system.signup_enabled', 0); // Redis + DB
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Reset + UI
|
||||
$this->reset(['name','email','password','password_confirmation','accept']);
|
||||
$this->dispatch('toast',
|
||||
state: 'done',
|
||||
badge: 'Signup',
|
||||
domain: 'Registrierung erfolgreich',
|
||||
message: 'Dein Konto wurde angelegt. Bitte melde dich jetzt mit deinen Zugangsdaten an. <i class="ph ph-arrow-right" ></i> <a href="'.route('login').'" class="underline">Zum Login</a>',
|
||||
message: 'Dein Konto wurde angelegt. Bitte melde dich jetzt mit deinen Zugangsdaten an. <i class="ph ph-arrow-right"></i> <a href="'.route('login').'" class="underline">Zum Login</a>',
|
||||
duration: -1,
|
||||
);
|
||||
}
|
||||
|
||||
// public function register()
|
||||
// {
|
||||
// $this->validate();
|
||||
// $isFirstUser = User::count() === 0;
|
||||
//
|
||||
// User::create([
|
||||
// 'name' => $this->name,
|
||||
// 'email' => $this->email,
|
||||
// 'password' => Hash::make($this->password),
|
||||
// 'role' => Setting::signupAllowed() ? Role::Admin : Role::Member,
|
||||
// ]);
|
||||
//
|
||||
// if ($isFirstUser) {
|
||||
// Setting::updateOrCreate(['key' => 'signup_enabled'], ['value' => '0']);
|
||||
// }
|
||||
//
|
||||
// $this->reset(['name','email','password','password_confirmation', 'accept']);
|
||||
// $this->dispatch('toast',
|
||||
// state: 'done',
|
||||
// badge: 'Signup',
|
||||
// domain: 'Registrierung erfolgreich',
|
||||
// message: 'Dein Konto wurde angelegt. Bitte melde dich jetzt mit deinen Zugangsdaten an. <i class="ph ph-arrow-right" ></i> <a href="'.route('login').'" class="underline">Zum Login</a>',
|
||||
// duration: -1,
|
||||
// );
|
||||
// }
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.auth.signup-form');
|
||||
|
|
|
|||
|
|
@ -3,32 +3,123 @@
|
|||
namespace App\Livewire\Ui\Domain;
|
||||
|
||||
use App\Models\Domain;
|
||||
use App\Services\DnsRecordService;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class DomainDnsList extends Component
|
||||
{
|
||||
public Domain $domain;
|
||||
public array $records = [];
|
||||
/** System-Domain (is_system = true) */
|
||||
public ?Domain $systemDomain = null;
|
||||
|
||||
// public function mount(int $domainId): void
|
||||
// {
|
||||
// $this->domain = Domain::findOrFail($domainId);
|
||||
// $this->records = app(DnsRecordService::class)->buildForDomain($this->domain);
|
||||
// }
|
||||
/** Benutzer-Domains (is_system = false) */
|
||||
public $userDomains = [];
|
||||
public $tags = [];
|
||||
|
||||
#[On('domain-updated')]
|
||||
#[On('domain-created')]
|
||||
public function reloadDomains(): void
|
||||
{
|
||||
$this->loadDomains();
|
||||
}
|
||||
|
||||
public function loadDomains(): void
|
||||
{
|
||||
$this->systemDomain = Domain::where('is_system', true)->first();
|
||||
// $this->userDomains = Domain::where('is_system', false)->orderBy('domain')->get();
|
||||
$domains = Domain::where('is_system', false)
|
||||
->withCount([
|
||||
'mailUsers as mailboxes_count', // -> mail_users
|
||||
'mailAliases as aliases_count', // -> mail_aliases
|
||||
])
|
||||
->orderBy('domain')
|
||||
->get();
|
||||
|
||||
|
||||
$this->userDomains = $domains->map(function (Domain $d) {
|
||||
$tags = $d->tag_objects ?? []; // aus Model-Accessor (vorher gebaut)
|
||||
$d->setAttribute('visible_tags', array_slice($tags, 0, 2));
|
||||
$d->setAttribute('extra_tags', max(count($tags) - 2, 0));
|
||||
$d->setAttribute('domainActive', (bool)$d->is_active);
|
||||
$d->setAttribute('effective_reason', $d->is_active ? 'Aktiv' : 'Inaktiv');
|
||||
return $d;
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
public function openDnsModal(int $domainId): void
|
||||
{
|
||||
// wire-elements-modal: Modal öffnen und Parameter übergeben
|
||||
$this->dispatch('openModal', component: 'ui.domain.modal.domain-dns-modal', arguments: [
|
||||
'domainId' => $domainId,
|
||||
]);
|
||||
}
|
||||
|
||||
public function openEditModal(int $id): void
|
||||
{
|
||||
$domain = Domain::findOrFail($id);
|
||||
if ($domain->is_system) {
|
||||
$this->dispatch('toast', type:'forbidden', badge:'System-Domain', title: 'Domain', text: 'Diese Domain ist als System-Domain markiert und kann daher nicht geändert oder entfernt werden.', duration: 0,);
|
||||
return;
|
||||
}
|
||||
|
||||
// dein Modal
|
||||
$this->dispatch('openModal', component: 'ui.domain.modal.domain-edit-modal', arguments: ['domainId' => $id]);
|
||||
}
|
||||
|
||||
|
||||
public function openLimitsModal(int $id): void
|
||||
{
|
||||
$domain = Domain::findOrFail($id);
|
||||
if ($domain->is_system) {
|
||||
$this->dispatch('toast', type:'forbidden', badge:'System-Domain', title: 'Domain', text: 'Diese Domain ist als System-Domain markiert und kann daher nicht geändert oder entfernt werden.', duration: 0,);
|
||||
return;
|
||||
}
|
||||
|
||||
// dein Modal
|
||||
$this->dispatch('openModal', component: 'ui.domain.modal.domain-limits-modal', arguments: ['domainId' => $id]);
|
||||
}
|
||||
|
||||
public function openDeleteModal(int $id): void
|
||||
{
|
||||
$this->dispatch('openModal', component: 'ui.domain.modal.domain-delete-modal', arguments: [
|
||||
'domainId' => $id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function deleteDomain(int $id): void
|
||||
{
|
||||
$domain = Domain::findOrFail($id);
|
||||
|
||||
if ($domain->is_system || $id === $this->systemDomainId) {
|
||||
$this->dispatch('toast', type:'forbidden', badge:'System-Domain', title: 'Domain', text: 'Diese Domain ist als System-Domain markiert und kann daher nicht geändert oder entfernt werden.', duration: 0,);
|
||||
return;
|
||||
}
|
||||
|
||||
$domain->loadCount(['mailUsers as mailboxes_count','mailAliases as aliases_count']);
|
||||
if ($domain->mailboxes_count > 0 || $domain->aliases_count > 0) {
|
||||
$this->dispatch('toast', type:'forbidden', badge:'Domain',
|
||||
text:'Löschen blockiert: Es sind noch Postfächer/Aliasse vorhanden. Bitte zuerst entfernen.', duration:0);
|
||||
return;
|
||||
}
|
||||
|
||||
$domain->delete();
|
||||
|
||||
$this->reloadDomains();
|
||||
$this->dispatch('toast',
|
||||
type: 'done',
|
||||
badge: 'Domain',
|
||||
title: 'Domain gelöscht',
|
||||
text: 'Die Domain <b>' . e($domain->domain) . '</b> wurde erfolgreich entfernt.',
|
||||
duration: 6000
|
||||
);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$this->loadDomains();
|
||||
|
||||
return view('livewire.ui.domain.domain-dns-list', [
|
||||
'domains' => Domain::orderBy('domain')->get(),
|
||||
'systemDomain' => $this->systemDomain,
|
||||
'userDomains' => $this->userDomains,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,317 @@
|
|||
<?php
|
||||
|
||||
|
||||
namespace App\Livewire\Ui\Domain\Modal;
|
||||
|
||||
use App\Models\DkimKey;
|
||||
use App\Models\Domain;
|
||||
use App\Models\Setting;
|
||||
use App\Services\DkimService;
|
||||
use App\Services\DnsRecordService;
|
||||
use App\Services\TlsaService;
|
||||
use App\Services\MailStorage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Livewire\Attributes\On;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class DomainCreateModal extends ModalComponent
|
||||
{
|
||||
public string $domain = '';
|
||||
public ?string $description = null;
|
||||
public array $tags = [];
|
||||
|
||||
// Limits
|
||||
public int $max_aliases;
|
||||
public int $max_mailboxes;
|
||||
|
||||
// Quotas
|
||||
public int $default_quota_mb; // Standard pro Mailbox
|
||||
public ?int $max_quota_per_mailbox_mb = null; // optionales Limit pro Mailbox
|
||||
public int $total_quota_mb; // Domain-Gesamt (0 = unbegrenzt NICHT vorgesehen hier)
|
||||
|
||||
// Rate-Limit (Domain-weit)
|
||||
public ?int $rate_limit_per_hour = null; // null => kein Limit
|
||||
public bool $rate_limit_override = false; // Mailbox darf Domain-Limit überschreiben
|
||||
|
||||
public bool $active = true;
|
||||
|
||||
// DKIM
|
||||
public string $dkim_selector = 'dkim';
|
||||
public int $dkim_bits = 2048; // 1024/2048/3072/4096
|
||||
|
||||
// Anzeige
|
||||
public int $available_mib = 0;
|
||||
|
||||
public array $tagPalette = ['#22c55e', '#06b6d4', '#a855f7', '#f59e0b', '#ef4444', '#3b82f6'];
|
||||
|
||||
public function mount(MailStorage $pool): void
|
||||
{
|
||||
// Defaults
|
||||
$this->max_aliases = (int)config('mailpool.defaults.max_aliases', 400);
|
||||
$this->max_mailboxes = (int)config('mailpool.defaults.max_mailboxes', 10);
|
||||
$this->default_quota_mb = (int)config('mailpool.defaults.default_quota_mb', 3072);
|
||||
$this->max_quota_per_mailbox_mb = config('mailpool.defaults.max_quota_per_mailbox_mb'); // kann null sein
|
||||
$this->total_quota_mb = (int)config('mailpool.defaults.total_quota_mb', 10240);
|
||||
|
||||
$this->dkim_selector = (string)config('mailpool.defaults.dkim_selector', 'dkim');
|
||||
$this->dkim_bits = (int)config('mailpool.defaults.dkim_bits', 2048);
|
||||
|
||||
// Speicherpool-Grenze
|
||||
$this->available_mib = (int)$pool->remainingPoolMb();
|
||||
$this->total_quota_mb = min($this->total_quota_mb, $this->available_mib);
|
||||
|
||||
$this->tags = [['label' => '', 'color' => $this->tagPalette[0]]];
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'domain' => [
|
||||
'required','string','lowercase','max:255',
|
||||
'regex:/^(?!-)(?:[a-z0-9-]+\.)+[a-z]{2,}$/',
|
||||
Rule::unique('domains','domain'),
|
||||
|
||||
function ($attr, $value, $fail) {
|
||||
$value = \Illuminate\Support\Str::lower($value);
|
||||
$isReserved = \App\Models\Domain::query()
|
||||
->where('is_system', true)
|
||||
->whereRaw('LOWER(domain) = ?', [$value])
|
||||
->exists();
|
||||
|
||||
if ($isReserved) {
|
||||
$fail(__('validation.domain_reserved', ['domain' => $value]));
|
||||
$fail("Die Domain {$value} ist reserviert und kann nicht verwendet werden.");
|
||||
}
|
||||
},
|
||||
],
|
||||
'description' => ['nullable', 'string', 'max:500'],
|
||||
|
||||
'tags' => ['array', 'max:50'],
|
||||
'tags.*.label' => ['nullable', 'string', 'max:40'],
|
||||
'tags.*.color' => ['nullable', 'regex:/^#[0-9a-fA-F]{6}$/'],
|
||||
|
||||
'max_aliases' => ['required', 'integer', 'min:0', 'max:100000'],
|
||||
'max_mailboxes' => ['required', 'integer', 'min:0', 'max:100000'],
|
||||
|
||||
'default_quota_mb' => ['required', 'integer', 'min:0', 'max:2000000'],
|
||||
'max_quota_per_mailbox_mb' => ['nullable', 'integer', 'min:1', 'max:2000000'],
|
||||
'total_quota_mb' => ['required', 'integer', 'min:1', 'max:2000000'],
|
||||
|
||||
'rate_limit_per_hour' => ['nullable', 'integer', 'min:1', 'max:2000000'],
|
||||
'rate_limit_override' => ['boolean'],
|
||||
'active' => ['boolean'],
|
||||
|
||||
'dkim_selector' => ['required', 'string', 'max:50', 'regex:/^[a-z0-9\-]+$/i'],
|
||||
'dkim_bits' => ['required', Rule::in([1024, 2048, 3072, 4096])],
|
||||
];
|
||||
}
|
||||
|
||||
protected function messages(): array
|
||||
{
|
||||
return [
|
||||
'domain.required' => 'Bitte gib eine Domain an.',
|
||||
'domain.string' => 'Die Domain muss als Text eingegeben werden.',
|
||||
'domain.lowercase' => 'Die Domain darf keine Großbuchstaben enthalten.',
|
||||
'domain.max' => 'Die Domain darf maximal 255 Zeichen lang sein.',
|
||||
'domain.regex' => 'Die Domain ist ungültig. Beispiel: mail.example.com',
|
||||
'domain.unique' => 'Diese Domain ist bereits vorhanden.',
|
||||
'domain_reserved' => 'Die Domain :domain ist reserviert und kann nicht verwendet werden.',
|
||||
|
||||
'description.max' => 'Die Beschreibung darf maximal 500 Zeichen haben.',
|
||||
|
||||
'tags.array' => 'Tags müssen als Array übergeben werden.',
|
||||
'tags.max' => 'Es dürfen maximal 50 Tags verwendet werden.',
|
||||
'tags.*.label.max' => 'Ein Tag-Label darf maximal 40 Zeichen haben.',
|
||||
'tags.*.color.regex' => 'Die Farbe eines Tags muss als Hexwert angegeben werden.',
|
||||
|
||||
'max_aliases.required' => 'Bitte gib ein Alias-Limit an.',
|
||||
'max_aliases.integer' => 'Das Alias-Limit muss eine Zahl sein.',
|
||||
'max_aliases.max' => 'Das Alias-Limit ist zu hoch.',
|
||||
|
||||
'max_mailboxes.required' => 'Bitte gib ein Postfach-Limit an.',
|
||||
'max_mailboxes.integer' => 'Das Postfach-Limit muss eine Zahl sein.',
|
||||
'max_mailboxes.max' => 'Das Postfach-Limit ist zu hoch.',
|
||||
|
||||
'default_quota_mb.required' => 'Bitte gib eine Standard-Quota an.',
|
||||
'default_quota_mb.integer' => 'Die Standard-Quota muss eine Zahl sein.',
|
||||
'default_quota_mb.max' => 'Die Standard-Quota ist zu hoch.',
|
||||
|
||||
'max_quota_per_mailbox_mb.integer' => 'Die maximale Quota pro Postfach muss eine Zahl sein.',
|
||||
'max_quota_per_mailbox_mb.max' => 'Die maximale Quota pro Postfach ist zu hoch.',
|
||||
|
||||
'total_quota_mb.required' => 'Bitte gib die Gesamt-Quota an.',
|
||||
'total_quota_mb.integer' => 'Die Gesamt-Quota muss eine Zahl sein.',
|
||||
'total_quota_mb.max' => 'Die Gesamt-Quota ist zu hoch.',
|
||||
|
||||
'rate_limit_per_hour.integer' => 'Das Rate-Limit muss eine Zahl sein.',
|
||||
'rate_limit_per_hour.max' => 'Das Rate-Limit ist zu hoch.',
|
||||
|
||||
'dkim_selector.required' => 'Bitte gib einen DKIM-Selector an.',
|
||||
'dkim_selector.regex' => 'Der DKIM-Selector darf nur Buchstaben, Zahlen und Bindestriche enthalten.',
|
||||
'dkim_bits.required' => 'Bitte wähle eine Schlüssellänge.',
|
||||
'dkim_bits.in' => 'Ungültige DKIM-Schlüssellänge.',
|
||||
];
|
||||
}
|
||||
public function addTag(): void
|
||||
{
|
||||
$this->tags[] = ['label' => '', 'color' => $this->tagPalette[0]];
|
||||
}
|
||||
|
||||
public function removeTag(int $i): void
|
||||
{
|
||||
unset($this->tags[$i]);
|
||||
$this->tags = array_values($this->tags);
|
||||
}
|
||||
|
||||
public function pickTagColor(int $i, string $hex): void
|
||||
{
|
||||
if (!isset($this->tags[$i])) return;
|
||||
$hex = $this->normalizeHex($hex);
|
||||
if ($hex) $this->tags[$i]['color'] = $hex;
|
||||
}
|
||||
|
||||
private function normalizeHex(?string $hex): ?string
|
||||
{
|
||||
$hex = trim((string)$hex);
|
||||
if ($hex === '') return null;
|
||||
if ($hex[0] !== '#') $hex = "#{$hex}";
|
||||
return preg_match('/^#[0-9a-fA-F]{6}$/', $hex) ? strtolower($hex) : null;
|
||||
}
|
||||
|
||||
|
||||
private function assertNotReserved(string $fqdn): void
|
||||
{
|
||||
$zone = config('mailpool.platform_zone'); // z.B. sysmail.your-saas.tld
|
||||
$blocked = [
|
||||
'system.'.$fqdn, // falls du es doch schützen willst
|
||||
'bounce.'.$fqdn,
|
||||
'mx.'.$fqdn, // nur wenn du's global blocken willst
|
||||
];
|
||||
|
||||
foreach ($blocked as $bad) {
|
||||
if (Str::lower($fqdn) === Str::lower($bad)) {
|
||||
throw ValidationException::withMessages([
|
||||
'domain' => 'Diese Domain/Subdomain ist reserviert und kann nicht verwendet werden.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Plattform-Zone darf generell nicht als Benutzer-Domain eingetragen werden:
|
||||
if (Str::endsWith(Str::lower($fqdn), '.'.Str::lower($zone)) || Str::lower($fqdn) === Str::lower($zone)) {
|
||||
throw ValidationException::withMessages([
|
||||
'domain' => 'Domains innerhalb der System-Zone sind reserviert.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[On('domain:create')]
|
||||
public function save(MailStorage $pool): void
|
||||
{
|
||||
$this->domain = Str::lower(trim($this->domain));
|
||||
|
||||
$this->validate();
|
||||
|
||||
// $this->assertNotReserved($this->domain);
|
||||
|
||||
// Konsistenz-Checks der Eingaben (Create)
|
||||
if ($this->max_quota_per_mailbox_mb !== null &&
|
||||
$this->default_quota_mb > $this->max_quota_per_mailbox_mb) {
|
||||
throw ValidationException::withMessages([
|
||||
'default_quota_mb' => 'Die Standard-Quota darf die maximale Mailbox-Quota nicht überschreiten.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($this->default_quota_mb > $this->total_quota_mb) {
|
||||
throw ValidationException::withMessages([
|
||||
'default_quota_mb' => 'Die Standard-Quota darf die Domain-Gesamtquota nicht überschreiten.',
|
||||
]);
|
||||
}
|
||||
|
||||
// Pool-Kapazität
|
||||
$remaining = (int)$pool->remainingPoolMb();
|
||||
if ($this->total_quota_mb > $remaining) {
|
||||
throw ValidationException::withMessages([
|
||||
'total_quota_mb' => 'Nicht genügend Speicher im Pool. Maximal möglich: '
|
||||
. number_format($remaining) . ' MiB.',
|
||||
]);
|
||||
}
|
||||
|
||||
// Tags normalisieren
|
||||
$tagsOut = [];
|
||||
foreach ($this->tags as $t) {
|
||||
$label = trim((string)($t['label'] ?? ''));
|
||||
if ($label === '') continue;
|
||||
$color = $this->normalizeHex($t['color'] ?? '') ?? $this->tagPalette[0];
|
||||
$tagsOut[] = ['label' => $label, 'color' => $color];
|
||||
}
|
||||
|
||||
// Persist
|
||||
$domain = Domain::create([
|
||||
'domain' => $this->domain,
|
||||
'description' => $this->description,
|
||||
'tags' => $tagsOut,
|
||||
'is_active' => $this->active,
|
||||
'is_system' => false,
|
||||
|
||||
'max_aliases' => $this->max_aliases,
|
||||
'max_mailboxes' => $this->max_mailboxes,
|
||||
|
||||
'default_quota_mb' => $this->default_quota_mb,
|
||||
'max_quota_per_mailbox_mb' => $this->max_quota_per_mailbox_mb,
|
||||
'total_quota_mb' => $this->total_quota_mb,
|
||||
|
||||
'rate_limit_per_hour' => $this->rate_limit_per_hour,
|
||||
'rate_limit_override' => $this->rate_limit_override,
|
||||
]);
|
||||
|
||||
// DKIM + DNS
|
||||
$dkim = app(DkimService::class)->generateForDomain($domain, $this->dkim_bits, $this->dkim_selector);
|
||||
|
||||
DkimKey::create([
|
||||
'domain_id' => $domain->id,
|
||||
'selector' => $dkim['selector'],
|
||||
'private_key_pem' => $dkim['private_pem'],
|
||||
'public_key_txt' => preg_replace('/^v=DKIM1; k=rsa; p=/', '', $dkim['dns_txt']),
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
app(DnsRecordService::class)->provision(
|
||||
$domain,
|
||||
$dkim['selector'] ?? null,
|
||||
$dkim['dns_txt'] ?? null,
|
||||
[
|
||||
'spf_tail' => Setting::get('mailpool.spf_tail', '~all'),
|
||||
'spf_extra' => Setting::get('mailpool.spf_extra', []),
|
||||
'dmarc_policy' => Setting::get('mailpool.dmarc_policy', 'none'),
|
||||
'rua' => Setting::get('mailpool.rua', null),
|
||||
]
|
||||
);
|
||||
|
||||
app(TlsaService::class)->createForDomain($domain);
|
||||
|
||||
// UI
|
||||
$this->dispatch('domain-created');
|
||||
$this->dispatch('closeModal');
|
||||
$this->dispatch('toast',
|
||||
type: 'done',
|
||||
badge: 'Domain',
|
||||
title: 'Domain angelegt',
|
||||
text: 'Die Domain <b>' . e($this->domain) . '</b> wurde erfolgreich erstellt. DKIM, SPF und DMARC sind vorbereitet.',
|
||||
duration: 6000,
|
||||
);
|
||||
}
|
||||
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
return '3xl';
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.ui.domain.modal.domain-create-modal');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Ui\Domain\Modal;
|
||||
|
||||
use App\Models\Domain;
|
||||
use Livewire\Attributes\On;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class DomainDeleteModal extends ModalComponent
|
||||
{
|
||||
public int $domainId;
|
||||
public string $domain;
|
||||
public int $mailboxes = 0;
|
||||
public int $aliases = 0;
|
||||
|
||||
/** Sicherheits-Input (User muss Domain schreiben) */
|
||||
public string $confirm = '';
|
||||
|
||||
public function mount(int $domainId): void
|
||||
{
|
||||
$d = Domain::withCount(['mailUsers as mailboxes','mailAliases as aliases'])->findOrFail($domainId);
|
||||
|
||||
if ($d->is_system) {
|
||||
$this->dispatch('toast', type:'forbidden', badge:'System-Domain',
|
||||
text:'Diese Domain ist als System-Domain markiert und kann nicht gelöscht werden.', duration:6000);
|
||||
$this->dispatch('closeModal');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->domainId = $d->id;
|
||||
$this->domain = $d->domain;
|
||||
$this->mailboxes = (int) $d->mailboxes;
|
||||
$this->aliases = (int) $d->aliases;
|
||||
}
|
||||
|
||||
#[On('domain:delete')]
|
||||
public function delete(): void
|
||||
{
|
||||
$d = Domain::withCount(['mailUsers as mailboxes','mailAliases as aliases'])->findOrFail($this->domainId);
|
||||
|
||||
if ($d->is_system) {
|
||||
$this->dispatch('toast', type:'forbidden', badge:'System-Domain', text:'System-Domain kann nicht gelöscht werden.', duration:6000);
|
||||
$this->dispatch('closeModal'); return;
|
||||
}
|
||||
|
||||
if ($d->mailboxes > 0 || $d->aliases > 0) {
|
||||
$this->dispatch('toast', type:'forbidden', badge:'Domain',
|
||||
text:'Löschen blockiert: Es sind noch Postfächer oder Aliasse vorhanden.', duration:0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (trim(strtolower($this->confirm)) !== strtolower($d->domain)) {
|
||||
$this->dispatch('toast', type:'failed', badge:'Domain', text:'Bestätigung stimmt nicht.', duration:6000);
|
||||
return;
|
||||
}
|
||||
|
||||
$name = $d->domain;
|
||||
$d->delete();
|
||||
|
||||
$this->dispatch('domain-updated'); // damit die Liste neu lädt
|
||||
$this->dispatch('toast',
|
||||
type:'done', badge:'Domain', title:'Domain gelöscht',
|
||||
text:'Die Domain <b>'.e($name).'</b> wurde erfolgreich entfernt.', duration:6000);
|
||||
$this->dispatch('closeModal');
|
||||
}
|
||||
|
||||
public static function modalMaxWidth(): string { return 'md'; }
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.ui.domain.modal.domain-delete-modal');
|
||||
}
|
||||
}
|
||||
|
|
@ -2,18 +2,16 @@
|
|||
|
||||
namespace App\Livewire\Ui\Domain\Modal;
|
||||
|
||||
use App\Models\DkimKey;
|
||||
use App\Models\Domain;
|
||||
use App\Services\DnsRecordService;
|
||||
use App\Support\NetProbe;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class DomainDnsModal extends ModalComponent
|
||||
{
|
||||
public int $domainId;
|
||||
public string $domainName = '';
|
||||
public string $base = '';
|
||||
public string $zone = '';
|
||||
public string $ttl = '3600';
|
||||
public array $recordColors = [];
|
||||
|
||||
|
|
@ -40,10 +38,12 @@ class DomainDnsModal extends ModalComponent
|
|||
$d = Domain::findOrFail($domainId);
|
||||
$this->domainName = $d->domain;
|
||||
|
||||
$ipv4 = $this->detectIPv4();
|
||||
$ipv6 = $this->detectIPv6(); // kann null sein
|
||||
$this->base = env('BASE_DOMAIN', 'example.com');
|
||||
$mta = env('MTA_SUB', 'mx').'.'.$this->base; // mx.example.com
|
||||
$ips = NetProbe::resolve();
|
||||
$ipv4 = $ips['ipv4'];
|
||||
$ipv6 = $ips['ipv6'];
|
||||
|
||||
$this->zone = $this->extractZone($d->domain);
|
||||
$mta = env('MTA_SUB', 'mx').'.'.$this->zone; // mx.example.com
|
||||
|
||||
// --- Statische Infrastruktur (für alle Domains gleich) ---
|
||||
$this->static = [
|
||||
|
|
@ -78,6 +78,14 @@ class DomainDnsModal extends ModalComponent
|
|||
];
|
||||
}
|
||||
|
||||
private function extractZone(string $fqdn): string
|
||||
{
|
||||
$fqdn = strtolower(trim($fqdn, "."));
|
||||
$parts = explode('.', $fqdn);
|
||||
$n = count($parts);
|
||||
return $n >= 2 ? $parts[$n-2] . '.' . $parts[$n-1] : $fqdn; // nimmt die letzten 2 Labels
|
||||
}
|
||||
|
||||
private function detectIPv4(): string
|
||||
{
|
||||
// robust & ohne env
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Ui\Domain\Modal;
|
||||
|
||||
use App\Models\Domain;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class DomainEditModal extends ModalComponent
|
||||
{
|
||||
public $domain;
|
||||
public $description;
|
||||
public $is_active;
|
||||
public array $tags = [];
|
||||
|
||||
public array $tagPalette = ['#22c55e','#06b6d4','#a855f7','#f59e0b','#ef4444','#3b82f6'];
|
||||
|
||||
public function mount(int $domainId)
|
||||
{
|
||||
$d = Domain::findOrFail($domainId);
|
||||
if ($d->is_system) { $this->dispatch('toast', state:'error', text:'System-Domain kann nicht bearbeitet werden.'); $this->dispatch('closeModal'); return; }
|
||||
|
||||
$this->domain = $d->domain;
|
||||
$this->description = $d->description;
|
||||
$this->is_active = $d->is_active;
|
||||
|
||||
$this->tags = [];
|
||||
$raw = $d->tags ?? [];
|
||||
if (is_string($raw)) {
|
||||
$raw = array_values(array_filter(array_map('trim', explode(',', $raw))));
|
||||
foreach ($raw as $lbl) $this->tags[] = ['label'=>$lbl, 'color'=>$this->tagPalette[0]];
|
||||
} elseif (is_array($raw)) {
|
||||
foreach ($raw as $t) {
|
||||
$label = is_array($t) ? trim((string)($t['label'] ?? '')) : trim((string)$t);
|
||||
if ($label === '') continue;
|
||||
$color = $this->normalizeHex(is_array($t) ? ($t['color'] ?? '') : '') ?? $this->tagPalette[0];
|
||||
$this->tags[] = ['label'=>$label, 'color'=>$color];
|
||||
}
|
||||
}
|
||||
if (empty($this->tags)) $this->tags = [['label'=>'', 'color'=>$this->tagPalette[0]]];
|
||||
}
|
||||
|
||||
public function addTag(): void
|
||||
{
|
||||
$this->tags[] = ['label'=>'', 'color'=>$this->tagPalette[0]];
|
||||
}
|
||||
public function removeTag(int $i): void
|
||||
{
|
||||
unset($this->tags[$i]);
|
||||
$this->tags = array_values($this->tags);
|
||||
}
|
||||
public function pickTagColor(int $i, string $hex): void
|
||||
{
|
||||
if (!isset($this->tags[$i])) return;
|
||||
$hex = $this->normalizeHex($hex);
|
||||
if ($hex) $this->tags[$i]['color'] = $hex;
|
||||
}
|
||||
|
||||
private function normalizeHex(?string $hex): ?string
|
||||
{
|
||||
$hex = trim((string)$hex);
|
||||
if ($hex === '') return null;
|
||||
if ($hex[0] !== '#') $hex = "#{$hex}";
|
||||
return preg_match('/^#[0-9a-fA-F]{6}$/', $hex) ? strtolower($hex) : null;
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
$this->validate([
|
||||
'description' => 'nullable|string|max:500',
|
||||
'is_active' => 'boolean',
|
||||
'tags' => 'array|max:50',
|
||||
'tags.*.label' => 'nullable|string|max:40',
|
||||
'tags.*.color' => ['nullable','regex:/^#[0-9a-fA-F]{6}$/'],
|
||||
]);
|
||||
|
||||
$d = Domain::where('domain', $this->domain)->firstOrFail();
|
||||
if ($d->is_system) { $this->dispatch('toast', state:'error', text:'System-Domain kann nicht bearbeitet werden.'); return; }
|
||||
|
||||
$out = [];
|
||||
foreach ($this->tags as $t) {
|
||||
$label = trim((string)($t['label'] ?? ''));
|
||||
if ($label === '') continue;
|
||||
$color = $this->normalizeHex($t['color'] ?? '') ?? $this->tagPalette[0];
|
||||
$out[] = ['label'=>$label, 'color'=>$color];
|
||||
}
|
||||
|
||||
$d->update([
|
||||
'description' => $this->description,
|
||||
'is_active' => $this->is_active,
|
||||
'tags' => $out,
|
||||
]);
|
||||
|
||||
$this->dispatch('domain-updated');
|
||||
$this->dispatch('closeModal');
|
||||
$this->dispatch('toast',
|
||||
type: 'done',
|
||||
badge: 'Domain',
|
||||
title: 'Domain aktualisiert',
|
||||
text: 'Die Domain <b>' . e($d->domain) . '</b> wurde erfolgreich aktualisiert. Alle Änderungen sind sofort aktiv.',
|
||||
duration: 6000,
|
||||
);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.ui.domain.modal.domain-edit-modal', [
|
||||
'tagPalette' => $this->tagPalette,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,258 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Ui\Domain\Modal;
|
||||
|
||||
use App\Models\Domain;
|
||||
use App\Services\MailStorage;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class DomainLimitsModal extends ModalComponent
|
||||
{
|
||||
public int $domainId;
|
||||
|
||||
// Eingabe-Felder
|
||||
public int $max_aliases;
|
||||
public int $max_mailboxes;
|
||||
public int $default_quota_mb;
|
||||
public ?int $max_quota_per_mailbox_mb = null; // null = kein per-Mailbox-Maximum
|
||||
public int $total_quota_mb; // 0 = unbegrenzt
|
||||
public ?int $rate_limit_per_hour = null; // null = kein Limit
|
||||
public bool $rate_limit_override = false;
|
||||
|
||||
// intern für Checks
|
||||
private int $current_total_quota_mb = 0;
|
||||
|
||||
public function mount(int $domainId, MailStorage $pool): void
|
||||
{
|
||||
$d = Domain::findOrFail($domainId);
|
||||
if ($d->is_system) {
|
||||
$this->dispatch('toast', type: 'error', text: 'System-Domain Limits können hier nicht geändert werden.');
|
||||
$this->dispatch('closeModal');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->domainId = $domainId;
|
||||
$this->max_aliases = (int)$d->max_aliases;
|
||||
$this->max_mailboxes = (int)$d->max_mailboxes;
|
||||
$this->default_quota_mb = (int)$d->default_quota_mb;
|
||||
$this->max_quota_per_mailbox_mb = $d->max_quota_per_mailbox_mb !== null ? (int)$d->max_quota_per_mailbox_mb : null;
|
||||
$this->total_quota_mb = (int)$d->total_quota_mb;
|
||||
$this->rate_limit_per_hour = $d->rate_limit_per_hour !== null ? (int)$d->rate_limit_per_hour : null;
|
||||
$this->rate_limit_override = (bool)$d->rate_limit_override;
|
||||
|
||||
// Für Pool-Delta-Check merken
|
||||
$this->current_total_quota_mb = (int)$d->total_quota_mb;
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'max_aliases' => ['required', 'integer', 'min:0'],
|
||||
'max_mailboxes' => ['required', 'integer', 'min:0'],
|
||||
'default_quota_mb' => ['required', 'integer', 'min:0'],
|
||||
'max_quota_per_mailbox_mb' => ['nullable', 'integer', 'min:1'],
|
||||
'total_quota_mb' => ['required', 'integer', 'min:0'], // 0 = unbegrenzt
|
||||
'rate_limit_per_hour' => ['nullable', 'integer', 'min:1'], // null = kein Limit
|
||||
'rate_limit_override' => ['boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
public function save(MailStorage $pool): void
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
// 1) Innere Konsistenz der neuen Limits
|
||||
if ($this->max_quota_per_mailbox_mb !== null && $this->default_quota_mb > $this->max_quota_per_mailbox_mb) {
|
||||
throw ValidationException::withMessages([
|
||||
'default_quota_mb' => 'Die Standard-Quota darf die maximale Mailbox-Quota nicht überschreiten.',
|
||||
]);
|
||||
}
|
||||
if ($this->total_quota_mb > 0 && $this->default_quota_mb > $this->total_quota_mb) {
|
||||
throw ValidationException::withMessages([
|
||||
'default_quota_mb' => 'Die Standard-Quota darf die Domain-Gesamtquota nicht überschreiten.',
|
||||
]);
|
||||
}
|
||||
|
||||
// 2) Bestandsdaten laden (für Abwärts-Checks)
|
||||
$d = Domain::query()
|
||||
->withCount(['mailUsers', 'mailAliases'])
|
||||
->withSum('mailUsers as sum_user_quota_mb', 'quota_mb')
|
||||
->withMax('mailUsers as max_user_quota_mb', 'quota_mb')
|
||||
->withMax('mailUsers as max_user_rate', 'rate_limit_per_hour')
|
||||
->findOrFail($this->domainId);
|
||||
|
||||
$existingUsers = (int)$d->mail_users_count;
|
||||
$existingAliases = (int)$d->mail_aliases_count;
|
||||
$sumQuota = (int)($d->sum_user_quota_mb ?? 0);
|
||||
$maxUserQuota = (int)($d->max_user_quota_mb ?? 0);
|
||||
$maxUserRate = (int)($d->max_user_rate ?? 0);
|
||||
|
||||
// 3) Abwärts-Checks
|
||||
if ($this->max_mailboxes < $existingUsers) {
|
||||
throw ValidationException::withMessages([
|
||||
'max_mailboxes' => "Es existieren bereits {$existingUsers} Mailboxen. "
|
||||
. "Der Wert darf nicht unter diese Anzahl gesenkt werden.",
|
||||
]);
|
||||
}
|
||||
|
||||
if ($this->max_aliases < $existingAliases) {
|
||||
throw ValidationException::withMessages([
|
||||
'max_aliases' => "Es existieren bereits {$existingAliases} Aliasse. "
|
||||
. "Der Wert darf nicht unter diese Anzahl gesenkt werden.",
|
||||
]);
|
||||
}
|
||||
|
||||
if ($this->max_quota_per_mailbox_mb !== null && $this->max_quota_per_mailbox_mb < $maxUserQuota) {
|
||||
throw ValidationException::withMessages([
|
||||
'max_quota_per_mailbox_mb' => "Mindestens ein Postfach hat {$maxUserQuota} MiB. "
|
||||
. "Das Limit darf nicht darunter liegen.",
|
||||
]);
|
||||
}
|
||||
|
||||
if ($this->total_quota_mb > 0 && $this->total_quota_mb < $sumQuota) {
|
||||
throw ValidationException::withMessages([
|
||||
'total_quota_mb' => "Bestehende Postfächer summieren sich auf {$sumQuota} MiB. "
|
||||
. "Das Domain-Gesamtlimit darf nicht darunter liegen.",
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$this->rate_limit_override && $this->rate_limit_per_hour !== null && $maxUserRate > 0
|
||||
&& $this->rate_limit_per_hour < $maxUserRate) {
|
||||
throw ValidationException::withMessages([
|
||||
'rate_limit_per_hour' => "Mindestens ein Postfach hat ein höheres stündliches Limit ({$maxUserRate}). "
|
||||
. "Ohne Overrides darf der Domain-Wert nicht darunter liegen.",
|
||||
]);
|
||||
}
|
||||
|
||||
// 4) Pool-Check (nur bei Erhöhung der Domain-Gesamtquota)
|
||||
$delta = $this->total_quota_mb - $this->current_total_quota_mb;
|
||||
if ($delta > 0) {
|
||||
$remaining = (int)$pool->remainingPoolMb();
|
||||
if ($delta > $remaining) {
|
||||
throw ValidationException::withMessages([
|
||||
'total_quota_mb' => 'Nicht genügend Speicher im Pool. Erhöhung um '
|
||||
. number_format($delta) . ' MiB nicht möglich. Frei: ' . number_format($remaining) . ' MiB.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 5) Persist
|
||||
$d->max_aliases = $this->max_aliases;
|
||||
$d->max_mailboxes = $this->max_mailboxes;
|
||||
$d->default_quota_mb = $this->default_quota_mb;
|
||||
$d->max_quota_per_mailbox_mb = $this->max_quota_per_mailbox_mb;
|
||||
$d->total_quota_mb = $this->total_quota_mb;
|
||||
$d->rate_limit_per_hour = $this->rate_limit_per_hour;
|
||||
$d->rate_limit_override = $this->rate_limit_override;
|
||||
$d->save();
|
||||
|
||||
// 6) UI
|
||||
$this->dispatch('domain-updated');
|
||||
$this->dispatch('closeModal');
|
||||
$this->dispatch('toast',
|
||||
type: 'done',
|
||||
badge: 'Domain-Limits',
|
||||
title: 'Domain-Limits aktualisiert',
|
||||
text: 'Die Domain-Limits wurden erfolgreich aktualisiert.',
|
||||
duration: 6000,
|
||||
);
|
||||
}
|
||||
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
return '3xl';
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.ui.domain.modal.domain-limits-modal');
|
||||
}
|
||||
}
|
||||
//
|
||||
//namespace App\Livewire\Ui\Domain\Modal;
|
||||
//
|
||||
//use App\Models\Domain;
|
||||
//use App\Services\MailStorage;
|
||||
//use Illuminate\Validation\ValidationException;
|
||||
//use LivewireUI\Modal\ModalComponent;
|
||||
//
|
||||
//
|
||||
//class DomainLimitsModal extends ModalComponent
|
||||
//{
|
||||
// public int $domainId;
|
||||
// public int $max_aliases;
|
||||
// public int $max_mailboxes;
|
||||
// public int $default_quota_mb;
|
||||
// public ?int $max_quota_per_mailbox_mb = null;
|
||||
// public int $total_quota_mb;
|
||||
// public ?int $rate_limit_per_hour = null;
|
||||
// public bool $rate_limit_override = false;
|
||||
//
|
||||
// public function mount(int $domainId, MailStorage $pool)
|
||||
// {
|
||||
// $d = Domain::findOrFail($domainId);
|
||||
// if ($d->is_system) { $this->dispatch('toast', type:'error', text:'System-Domain Limits können hier nicht geändert werden.'); $this->dispatch('closeModal'); return; }
|
||||
//
|
||||
// $this->domainId = $domainId;
|
||||
// $this->max_aliases = (int)$d->max_aliases;
|
||||
// $this->max_mailboxes = (int)$d->max_mailboxes;
|
||||
// $this->default_quota_mb = (int)$d->default_quota_mb;
|
||||
// $this->max_quota_per_mailbox_mb = $d->max_quota_per_mailbox_mb;
|
||||
// $this->total_quota_mb = (int)$d->total_quota_mb;
|
||||
// $this->rate_limit_per_hour = $d->rate_limit_per_hour;
|
||||
// $this->rate_limit_override = (bool)$d->rate_limit_override;
|
||||
// }
|
||||
//
|
||||
// public function save(MailStorage $pool)
|
||||
// {
|
||||
// $this->validate([
|
||||
// 'max_aliases' => 'required|integer|min:0',
|
||||
// 'max_mailboxes' => 'required|integer|min:0',
|
||||
// 'default_quota_mb' => 'required|integer|min:0',
|
||||
// 'max_quota_per_mailbox_mb' => 'nullable|integer|min:0',
|
||||
// 'total_quota_mb' => 'required|integer|min:0',
|
||||
// 'rate_limit_per_hour' => 'nullable|integer|min:0',
|
||||
// 'rate_limit_override' => 'boolean',
|
||||
// ]);
|
||||
//
|
||||
// if ($this->max_quota_per_mailbox_mb !== null &&
|
||||
// $this->default_quota_mb > $this->max_quota_per_mailbox_mb) {
|
||||
// throw ValidationException::withMessages([
|
||||
// 'default_quota_mb' => 'Die Standard-Quota darf die maximale Mailbox-Quota nicht überschreiten.',
|
||||
// ]);
|
||||
// }
|
||||
//
|
||||
// $remaining = $pool->remainingPoolMb();
|
||||
// if ($this->total_quota_mb > $remaining) {
|
||||
// throw ValidationException::withMessages([
|
||||
// 'total_quota_mb' => 'Nicht genügend Speicher verfügbar. Maximal möglich: '.number_format($remaining).' MiB.',
|
||||
// ]);
|
||||
// }
|
||||
//
|
||||
// Domain::whereKey($this->domainId)->update([
|
||||
// 'max_aliases' => $this->max_aliases,
|
||||
// 'max_mailboxes' => $this->max_mailboxes,
|
||||
// 'default_quota_mb' => $this->default_quota_mb,
|
||||
// 'max_quota_per_mailbox_mb' => $this->max_quota_per_mailbox_mb,
|
||||
// 'total_quota_mb' => $this->total_quota_mb,
|
||||
// 'rate_limit_per_hour' => $this->rate_limit_per_hour,
|
||||
// 'rate_limit_override' => $this->rate_limit_override,
|
||||
// ]);
|
||||
//
|
||||
// $this->dispatch('domain-updated');
|
||||
// $this->dispatch('closeModal');
|
||||
// $this->dispatch('toast',
|
||||
// type: 'done',
|
||||
// badge: 'Domain-Limits',
|
||||
// title: 'Domain-Limits aktualisiert',
|
||||
// text: 'Die Domain-Limits wurde erfolgreich aktualisiert. Alle Änderungen sind sofort aktiv.',
|
||||
// duration: 6000,
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// public function render()
|
||||
// {
|
||||
// return view('livewire.ui.domain.modal.domain-limits-modal');
|
||||
// }
|
||||
//}
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Ui\Mail;
|
||||
|
||||
use App\Models\Domain;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class AliasList extends Component
|
||||
{
|
||||
public string $search = '';
|
||||
public bool $showSystemCard = false;
|
||||
|
||||
#[On('alias:created')]
|
||||
#[On('alias:updated')]
|
||||
#[On('alias:deleted')]
|
||||
public function refreshAliases(): void
|
||||
{
|
||||
$this->dispatch('$refresh');
|
||||
}
|
||||
|
||||
public function openAliasCreate(int $domainId): void
|
||||
{
|
||||
$this->dispatch('openModal', component: 'ui.mail.modal.alias-form-modal', arguments: [
|
||||
'domainId' => $domainId,
|
||||
]);
|
||||
}
|
||||
|
||||
public function openAliasEdit(int $aliasId): void
|
||||
{
|
||||
// nur Wert übergeben (LivewireUI Modal nimmt Positionsargumente)
|
||||
$this->dispatch('openModal', component: 'ui.mail.modal.alias-form-modal', arguments: [
|
||||
$aliasId,
|
||||
]);
|
||||
}
|
||||
|
||||
public function openAliasDelete(int $aliasId): void
|
||||
{
|
||||
$this->dispatch('openModal', component: 'ui.mail.modal.alias-delete-modal', arguments: [
|
||||
$aliasId,
|
||||
]);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$system = Domain::query()->where('is_system', true)->first();
|
||||
$term = trim($this->search);
|
||||
|
||||
$domains = Domain::query()
|
||||
->where('is_system', false)
|
||||
->withCount(['mailAliases'])
|
||||
->with([
|
||||
'mailAliases' => fn ($q) => $q
|
||||
->withCount('recipients')
|
||||
->with(['recipients' => fn($r) => $r->with('mailUser')])
|
||||
->orderBy('local'),
|
||||
])
|
||||
->when($term !== '', function ($q) use ($term) {
|
||||
$q->where(function ($w) use ($term) {
|
||||
$w->where('domain', 'like', "%{$term}%")
|
||||
->orWhereHas('mailAliases', function ($a) use ($term) {
|
||||
$a->where('local', 'like', "%{$term}%")
|
||||
->orWhereHas('recipients', function ($r) use ($term) {
|
||||
$r->where('email', 'like', "%{$term}%")
|
||||
->orWhereHas('mailUser', fn($mu) =>
|
||||
$mu->where('localpart', 'like', "%{$term}%"));
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
->orderBy('domain')
|
||||
->get();
|
||||
|
||||
// Vorbereiten der Alias-Daten (Logik aus dem Blade hierher)
|
||||
foreach ($domains as $domain) {
|
||||
$domainActive = (bool) ($domain->is_active ?? true);
|
||||
|
||||
foreach ($domain->mailAliases as $alias) {
|
||||
|
||||
$alias->effective_active = $domainActive && (bool) ($alias->is_active ?? true);
|
||||
|
||||
$alias->inactive_reason = null;
|
||||
if (!$domainActive) {
|
||||
$alias->inactive_reason = 'Domain inaktiv';
|
||||
} elseif (!($alias->is_active ?? true)) {
|
||||
$alias->inactive_reason = 'Alias inaktiv';
|
||||
}
|
||||
|
||||
$alias->isGroup = $alias->type === 'group';
|
||||
$alias->isSingle = !$alias->isGroup;
|
||||
$alias->recipientCount = $alias->recipients_count ?? $alias->recipients->count();
|
||||
$alias->maxChips = $alias->isGroup ? 2 : 1;
|
||||
$alias->extraRecipients= max(0, $alias->recipientCount - $alias->maxChips);
|
||||
$alias->shownRecipients= $alias->relationLoaded('recipients')
|
||||
? $alias->recipients->take($alias->maxChips)
|
||||
: collect();
|
||||
|
||||
// Quelle (immer)
|
||||
$alias->sourceEmail = "{$alias->local}@{$domain->domain}";
|
||||
|
||||
// Ziele (Text für Pfeil-Zeile)
|
||||
if ($alias->isSingle) {
|
||||
// 1. Ziel auflösen
|
||||
$first = $alias->relationLoaded('recipients') ? $alias->recipients->first() : null;
|
||||
if ($first) {
|
||||
if ($first->mail_user_id && $first->relationLoaded('mailUser') && $first->mailUser) {
|
||||
$alias->arrowTarget = "{$first->mailUser->localpart}@{$domain->domain}";
|
||||
} else {
|
||||
$alias->arrowTarget = $first->email ?: '—';
|
||||
}
|
||||
} else {
|
||||
$alias->arrowTarget = '—';
|
||||
}
|
||||
} else {
|
||||
// Gruppenlabel
|
||||
$label = trim((string)$alias->group_name) !== '' ? $alias->group_name : 'Gruppe';
|
||||
$alias->arrowTarget = "{$label} ({$alias->recipientCount})";
|
||||
}
|
||||
|
||||
// Kompletter Pfeil-Text
|
||||
$alias->arrowLine = "{$alias->sourceEmail} ⇒ {$alias->arrowTarget}";
|
||||
}
|
||||
}
|
||||
|
||||
return view('livewire.ui.mail.alias-list', [
|
||||
'domains' => $domains,
|
||||
'system' => $this->showSystemCard ? $system : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,341 @@
|
|||
<?php
|
||||
|
||||
|
||||
namespace App\Livewire\Ui\Mail;
|
||||
|
||||
use App\Models\Domain;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class MailboxList extends Component
|
||||
{
|
||||
public string $search = '';
|
||||
public bool $showSystemCard = false;
|
||||
|
||||
#[On('mailbox:updated')]
|
||||
#[On('mailbox:deleted')]
|
||||
#[On('mailbox:created')]
|
||||
public function refreshMailboxList(): void
|
||||
{
|
||||
$this->dispatch('$refresh');
|
||||
}
|
||||
|
||||
#[On('focus:domain')]
|
||||
public function focusDomain(int $id): void
|
||||
{
|
||||
// z. B. Domain nach oben holen / scrollen / highlighten
|
||||
// oder direkt den "+ Postfach" Dialog:
|
||||
// $this->openMailboxCreate($id);
|
||||
}
|
||||
|
||||
#[On('focus:user')]
|
||||
public function focusUser(int $id): void
|
||||
{
|
||||
// später: Benutzerseite / Filter setzen ...
|
||||
}
|
||||
|
||||
public function openMailboxCreate(int $domainId): void
|
||||
{
|
||||
$this->dispatch('openModal', component: 'ui.mail.modal.mailbox-create-modal', arguments: [
|
||||
'domainId' => $domainId,
|
||||
]);
|
||||
}
|
||||
|
||||
public function openMailboxEdit(int $domainId): void
|
||||
{
|
||||
// $domainId == mailbox_id
|
||||
$this->dispatch('openModal', component: 'ui.mail.modal.mailbox-edit-modal', arguments: [
|
||||
$domainId, // <— nur der Wert, kein Key!
|
||||
]);
|
||||
}
|
||||
|
||||
public function openMailboxDelete(int $domainId): void
|
||||
{
|
||||
$this->dispatch('openModal', component: 'ui.mail.modal.mailbox-delete-modal', arguments: [
|
||||
$domainId, // <— nur der Wert, kein Key!
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function render()
|
||||
{
|
||||
$system = Domain::query()->where('is_system', true)->first();
|
||||
$term = trim($this->search);
|
||||
$hasTerm = $term !== '';
|
||||
$needle = '%'.str_replace(['%','_'], ['\%','\_'], $term).'%'; // LIKE-sicher
|
||||
|
||||
$domains = Domain::query()
|
||||
->when($system, fn ($q) => $q->whereKeyNot($system->id))
|
||||
|
||||
// Domain selbst ODER MailUser/ Aliasse müssen matchen
|
||||
->when($hasTerm, function ($q) use ($needle) {
|
||||
$q->where(function ($w) use ($needle) {
|
||||
$w->where('domain', 'like', $needle)
|
||||
->orWhereHas('mailUsers', fn($u) => $u->where('localpart', 'like', $needle));
|
||||
});
|
||||
})
|
||||
|
||||
->withCount(['mailUsers'])
|
||||
|
||||
// Beziehungen zunächst gefiltert laden (damit "test" nur passende Mailboxen zeigt)
|
||||
->with([
|
||||
'mailUsers' => function ($q) use ($hasTerm, $needle) {
|
||||
if ($hasTerm) $q->where('localpart', 'like', $needle);
|
||||
$q->orderBy('localpart');
|
||||
},
|
||||
])
|
||||
|
||||
->orderBy('domain')
|
||||
->get();
|
||||
|
||||
// Domains, deren NAME den Suchbegriff trifft → ALLE Mailboxen/Aliasse zeigen
|
||||
if ($hasTerm) {
|
||||
$lower = Str::lower($term);
|
||||
foreach ($domains as $d) {
|
||||
if (Str::contains(Str::lower($d->domain), $lower)) {
|
||||
// volle Relationen nachladen (überschreibt die gefilterten)
|
||||
$d->setRelation('mailUsers', $d->mailUsers()->orderBy('localpart')->get());
|
||||
$d->setRelation('mailAliases', $d->mailAliases()->orderBy('local')->get());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Vorbereitung für Blade (unverändert, arbeitet auf den ggf. gefilterten Relationen)
|
||||
foreach ($domains as $d) {
|
||||
$prepared = [];
|
||||
$domainActive = (bool)($d->is_active ?? true);
|
||||
|
||||
foreach ($d->mailUsers as $u) {
|
||||
$quota = (int)($u->quota_mb ?? 0);
|
||||
$used = (int)($u->used_mb ?? 0);
|
||||
$usage = $quota > 0 ? min(100, (int)round($used / max(1, $quota) * 100)) : 0;
|
||||
|
||||
$mailboxActive = (bool)($u->is_active ?? true);
|
||||
$effective = $domainActive && $mailboxActive;
|
||||
|
||||
$reason = null;
|
||||
if (!$effective) {
|
||||
$reason = !$domainActive ? 'Domain inaktiv'
|
||||
: (!$mailboxActive ? 'Postfach inaktiv' : null);
|
||||
}
|
||||
|
||||
$prepared[] = [
|
||||
'id' => $u->id,
|
||||
'localpart' => (string)$u->localpart,
|
||||
'quota_mb' => $quota,
|
||||
'used_mb' => $used,
|
||||
'usage_percent' => $usage,
|
||||
'message_count' => (int)($u->message_count ?? $u->mails_count ?? 0),
|
||||
'is_active' => $mailboxActive,
|
||||
'is_effective_active' => $effective,
|
||||
'inactive_reason' => $reason,
|
||||
];
|
||||
}
|
||||
|
||||
// für Blade
|
||||
$d->prepared_mailboxes = $prepared;
|
||||
}
|
||||
|
||||
return view('livewire.ui.mail.mailbox-list', [
|
||||
'domains' => $domains,
|
||||
'system' => $this->showSystemCard ? $system : null,
|
||||
]);
|
||||
}
|
||||
|
||||
// public function render()
|
||||
// {
|
||||
// $system = Domain::query()->where('is_system', true)->first();
|
||||
// $term = trim($this->search);
|
||||
//
|
||||
// $domains = Domain::query()
|
||||
// ->when($system, fn ($q) => $q->where('id', '!=', $system->id))
|
||||
// ->withCount(['mailUsers','mailAliases'])
|
||||
// ->with([
|
||||
// 'mailUsers' => fn ($q) => $q->orderBy('localpart'),
|
||||
// 'mailAliases' => fn ($q) => $q->orderBy('local'),
|
||||
// ])
|
||||
// ->when($term !== '', function ($q) use ($term) {
|
||||
// $q->where(function ($w) use ($term) {
|
||||
// $w->where('domain', 'like', "%{$term}%")
|
||||
// ->orWhereHas('mailUsers', fn($u) =>
|
||||
// $u->where('localpart', 'like', "%{$term}%")
|
||||
// );
|
||||
// });
|
||||
// })
|
||||
// ->orderBy('domain')
|
||||
// ->get();
|
||||
//
|
||||
// // Vorbereitung für Blade (unverändert)
|
||||
// foreach ($domains as $d) {
|
||||
// $prepared = [];
|
||||
// $domainActive = (bool)($d->is_active ?? true);
|
||||
//
|
||||
// foreach ($d->mailUsers as $u) {
|
||||
// $quota = (int) ($u->quota_mb ?? 0);
|
||||
// $used = (int) ($u->used_mb ?? 0);
|
||||
// $usage = $quota > 0 ? min(100, (int) round($used / max(1,$quota) * 100)) : 0;
|
||||
//
|
||||
// $mailboxActive = (bool)($u->is_active ?? true);
|
||||
// $effective = $domainActive && $mailboxActive;
|
||||
//
|
||||
// $reason = null;
|
||||
// if (!$effective) {
|
||||
// $reason = !$domainActive ? 'Domain inaktiv'
|
||||
// : (!$mailboxActive ? 'Postfach inaktiv' : null);
|
||||
// }
|
||||
//
|
||||
// $prepared[] = [
|
||||
// 'id' => $u->id,
|
||||
// 'localpart' => (string) $u->localpart,
|
||||
// 'quota_mb' => $quota,
|
||||
// 'used_mb' => $used,
|
||||
// 'usage_percent' => $usage,
|
||||
// 'message_count' => (int) ($u->message_count ?? $u->mails_count ?? 0),
|
||||
// 'is_active' => $mailboxActive,
|
||||
// 'is_effective_active' => $effective,
|
||||
// 'inactive_reason' => $reason,
|
||||
// ];
|
||||
// }
|
||||
//
|
||||
// $d->prepared_mailboxes = $prepared;
|
||||
// }
|
||||
//
|
||||
// return view('livewire.ui.mail.mailbox-list', [
|
||||
// 'domains' => $domains,
|
||||
// 'system' => $this->showSystemCard ? $system : null,
|
||||
// ]);
|
||||
// }
|
||||
|
||||
// public function render()
|
||||
// {
|
||||
// $system = Domain::query()->where('is_system', true)->first();
|
||||
//
|
||||
// $term = trim($this->search);
|
||||
//
|
||||
// $domains = Domain::query()
|
||||
// ->when($system, fn ($q) => $q->where('id', '!=', $system->id))
|
||||
// ->withCount(['mailUsers','mailAliases'])
|
||||
// ->with([
|
||||
// 'mailUsers' => fn ($q) => $q->orderBy('localpart'),
|
||||
// 'mailAliases' => fn ($q) => $q->orderBy('source'),
|
||||
// ])
|
||||
// ->when($term !== '', function ($q) use ($term) {
|
||||
// $q->where(function ($w) use ($term) {
|
||||
// $w->where('domain', 'like', "%{$term}%")
|
||||
// ->orWhereHas('mailUsers', fn($u) =>
|
||||
// $u->where('localpart', 'like', "%{$term}%")
|
||||
// );
|
||||
// });
|
||||
// })
|
||||
// ->orderBy('domain')
|
||||
// ->get();
|
||||
//
|
||||
// // Für das Blade vorbereiten (ohne Relations zu mutieren)
|
||||
// foreach ($domains as $d) {
|
||||
// $prepared = [];
|
||||
// $domainActive = (bool)($d->is_active ?? true);
|
||||
//
|
||||
// foreach ($d->mailUsers as $u) {
|
||||
// $quota = (int) ($u->quota_mb ?? 0);
|
||||
// $used = (int) ($u->used_mb ?? 0);
|
||||
// $usage = $quota > 0 ? min(100, (int) round($used / max(1,$quota) * 100)) : 0;
|
||||
//
|
||||
// $mailboxActive = (bool)($u->is_active ?? true);
|
||||
// $effective = $domainActive && $mailboxActive;
|
||||
//
|
||||
// $reason = null;
|
||||
// if (!$effective) {
|
||||
// $reason = !$domainActive ? 'Domain inaktiv'
|
||||
// : (!$mailboxActive ? 'Postfach inaktiv' : null);
|
||||
// }
|
||||
//
|
||||
// $prepared[] = [
|
||||
// 'id' => $u->id,
|
||||
// 'localpart' => (string) $u->localpart,
|
||||
// 'quota_mb' => $quota,
|
||||
// 'used_mb' => $used,
|
||||
// 'usage_percent' => $usage,
|
||||
// 'message_count' => (int) ($u->message_count ?? $u->mails_count ?? 0),
|
||||
// 'is_active' => $mailboxActive, // ursprünglicher Flag (falls du ihn brauchst)
|
||||
// 'is_effective_active' => $effective, // ← NEU: Domain & Mailbox aktiv?
|
||||
// 'inactive_reason' => $reason, // ← NEU: warum gesperrt
|
||||
// ];
|
||||
// }
|
||||
//
|
||||
// $d->prepared_mailboxes = $prepared;
|
||||
// }
|
||||
//
|
||||
// return view('livewire.ui.mail.mailbox-list', [
|
||||
// 'domains' => $domains,
|
||||
// 'system' => $this->showSystemCard ? $system : null,
|
||||
// ]);
|
||||
// }
|
||||
}
|
||||
|
||||
|
||||
|
||||
//namespace App\Livewire\Ui\Mail;
|
||||
//
|
||||
//use App\Models\Domain;
|
||||
//use Livewire\Component;
|
||||
//
|
||||
//class MailboxList extends Component
|
||||
//{
|
||||
// public string $search = '';
|
||||
// public bool $showSystemCard = false; // optional: Info-Karte anzeigen
|
||||
//
|
||||
// public function openMailboxCreate(int $domainId): void
|
||||
// {
|
||||
// $this->dispatch('openModal', component: 'ui.mail.modal.mailbox-create-modal', arguments: [
|
||||
// 'domainId' => $domainId,
|
||||
// ]);
|
||||
// }
|
||||
//
|
||||
// public function render()
|
||||
// {
|
||||
// // System-Domain direkt aus der DB (bool Spalte: is_system)
|
||||
// $system = Domain::query()
|
||||
// ->where('is_system', true) // <-- deine DB-Flag-Spalte
|
||||
// ->first();
|
||||
//
|
||||
// // Benutzer-Domains (ohne System-Domain)
|
||||
// $domains = Domain::query()
|
||||
// ->when($system, fn($q) => $q->where('id', '!=', $system->id))
|
||||
// ->withCount(['mailUsers', 'mailAliases'])
|
||||
// ->with([
|
||||
// 'mailUsers' => fn($q) => $q->orderBy('localpart'),
|
||||
// 'mailAliases' => fn($q) => $q->orderBy('source'),
|
||||
// ])
|
||||
// ->when($this->search !== '', function ($q) {
|
||||
// $q->where('domain', 'like', "%{$this->search}%")
|
||||
// ->orWhereHas('mailUsers', fn($qq) => $qq->where('localpart', 'like', "%{$this->search}%"));
|
||||
// })
|
||||
// ->orderBy('domain') // falls bei dir anders: exakt die vorhandene Spalte eintragen
|
||||
// ->get();
|
||||
//
|
||||
// // Alle Mailboxen vorbereiten (keine Logik im Blade)
|
||||
// $domains->each(function ($domain) {
|
||||
// $domain->mailUsers->transform(function ($u) use ($domain) {
|
||||
// $quota = (int)($u->quota_mb ?? 0);
|
||||
// $used = (int)($u->used_mb ?? 0);
|
||||
// $usage = $quota > 0 ? min(100, round(($used / max(1, $quota)) * 100)) : 0;
|
||||
//
|
||||
// // neue Properties für das Blade
|
||||
// $u->email = $u->localpart ? "{$u->localpart}@{$domain->domain}" : '—';
|
||||
// $u->quota_mb = $quota;
|
||||
// $u->used_mb = $used;
|
||||
// $u->usage_percent = $usage;
|
||||
// $u->message_count = (int)($u->message_count ?? $u->mails_count ?? 0);
|
||||
//
|
||||
// return $u;
|
||||
// });
|
||||
// });
|
||||
//
|
||||
//
|
||||
// return view('livewire.ui.mail.mailbox-list', [
|
||||
// 'domains' => $domains,
|
||||
// 'system' => $this->showSystemCard ? $system : null, // read-only Karte optional
|
||||
// ]);
|
||||
// }
|
||||
//}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Ui\Mail\Modal;
|
||||
|
||||
use App\Models\MailAlias;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class AliasDeleteModal extends ModalComponent
|
||||
{
|
||||
|
||||
public MailAlias $alias;
|
||||
|
||||
// Anzeige / Bestätigung
|
||||
public string $aliasEmail = '';
|
||||
public string $confirm = '';
|
||||
public bool $confirmMatches = false; // <-- reaktiv, kein computed
|
||||
|
||||
// Meta
|
||||
public string $targetLabel = '';
|
||||
public int $recipientCount = 0;
|
||||
public int $extraRecipients = 0;
|
||||
public bool $isSingle = false;
|
||||
|
||||
public function mount(int $aliasId): void
|
||||
{
|
||||
$this->alias = MailAlias::with(['domain', 'recipients.mailUser'])->findOrFail($aliasId);
|
||||
|
||||
$this->aliasEmail = $this->alias->local . '@' . $this->alias->domain->domain;
|
||||
$this->recipientCount = $this->alias->recipients->count();
|
||||
$this->isSingle = ($this->alias->type ?? 'single') === 'single';
|
||||
|
||||
if ($this->isSingle && $this->recipientCount === 1) {
|
||||
$r = $this->alias->recipients->first();
|
||||
$this->targetLabel = $r->mailUser
|
||||
? $r->mailUser->localpart . '@' . $this->alias->domain->domain
|
||||
: (string) $r->email;
|
||||
} else {
|
||||
$group = trim((string) ($this->alias->group_name ?? 'Gruppe'));
|
||||
$this->targetLabel = $group . ' (' . $this->recipientCount . ')';
|
||||
}
|
||||
|
||||
$this->extraRecipients = max(0, $this->recipientCount - 3);
|
||||
|
||||
// initialer Vergleich (falls Autocomplete o.ä.)
|
||||
$this->recomputeConfirmMatch();
|
||||
}
|
||||
|
||||
public function updatedConfirm(): void
|
||||
{
|
||||
$this->resetErrorBag('confirm');
|
||||
$this->recomputeConfirmMatch();
|
||||
}
|
||||
|
||||
private function recomputeConfirmMatch(): void
|
||||
{
|
||||
$this->confirmMatches =
|
||||
mb_strtolower(trim($this->confirm)) === mb_strtolower($this->aliasEmail);
|
||||
}
|
||||
|
||||
#[On('alias:delete')]
|
||||
public function delete(): void
|
||||
{
|
||||
if (! $this->confirmMatches) {
|
||||
$this->dispatch('toast', [
|
||||
'type' => 'warn',
|
||||
'badge' => 'Alias',
|
||||
'title' => 'Eingabe erforderlich',
|
||||
'text' => 'Bitte gib die Alias-Adresse korrekt ein, um den Löschvorgang zu bestätigen.',
|
||||
'duration' => 6000,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$email = $this->aliasEmail;
|
||||
$this->alias->delete();
|
||||
|
||||
$this->dispatch('alias:deleted');
|
||||
$this->dispatch('closeModal');
|
||||
$this->dispatch('toast',
|
||||
type: 'done',
|
||||
badge: 'Alias',
|
||||
title: 'Alias gelöscht',
|
||||
text: 'Der Alias <b>' . e($email) . '</b> wurde entfernt.',
|
||||
duration: 6000
|
||||
);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.ui.mail.modal.alias-delete-modal');
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,544 @@
|
|||
<?php
|
||||
|
||||
|
||||
namespace App\Livewire\Ui\Mail\Modal;
|
||||
|
||||
use App\Models\Domain;
|
||||
use App\Models\MailAlias;
|
||||
use App\Models\MailUser;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Attributes\On;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class MailboxCreateModal extends ModalComponent
|
||||
{
|
||||
// optional vorselektierte Domain
|
||||
public ?int $domain_id = null;
|
||||
|
||||
// Anzeige
|
||||
public string $domain_name = '';
|
||||
/** @var array<int,array{id:int,domain:string}> */
|
||||
public array $domains = [];
|
||||
public string $email_preview = '';
|
||||
|
||||
// Formular
|
||||
public string $localpart = '';
|
||||
public ?string $display_name = null;
|
||||
public ?string $password = null;
|
||||
public int $quota_mb = 0;
|
||||
public ?int $rate_limit_per_hour = null;
|
||||
public bool $is_active = true;
|
||||
public bool $must_change_pw = true;
|
||||
|
||||
// Limits / Status
|
||||
public ?int $limit_max_mailboxes = null;
|
||||
public ?int $limit_default_quota_mb = null;
|
||||
public ?int $limit_max_quota_per_mb = null;
|
||||
public ?int $limit_total_quota_mb = null; // 0 = unlimitiert
|
||||
public ?int $limit_domain_rate_per_hour = null;
|
||||
public bool $allow_rate_limit_override = false;
|
||||
|
||||
public int $mailbox_count_used = 0;
|
||||
public int $domain_storage_used_mb = 0;
|
||||
|
||||
// Hints/Flags
|
||||
public string $quota_hint = '';
|
||||
public bool $rate_limit_readonly = false;
|
||||
public bool $no_mailbox_slots = false;
|
||||
public bool $no_storage_left = false;
|
||||
public bool $can_create = true;
|
||||
public string $block_reason = '';
|
||||
|
||||
/* ---------- Validation ---------- */
|
||||
protected function rules(): array
|
||||
{
|
||||
$maxPerMailbox = $this->limit_max_quota_per_mb ?? PHP_INT_MAX;
|
||||
$remainingByTotal = (is_null($this->limit_total_quota_mb) || (int)$this->limit_total_quota_mb === 0)
|
||||
? PHP_INT_MAX
|
||||
: max(0, (int)$this->limit_total_quota_mb - (int)$this->domain_storage_used_mb);
|
||||
$cap = min($maxPerMailbox, $remainingByTotal);
|
||||
|
||||
// alle Alias-Localparts der Domain (kleingeschrieben) – verhindert Kollision Mailbox vs. Alias
|
||||
$aliasLocals = $this->domain_id
|
||||
? MailAlias::query()
|
||||
->where('domain_id', $this->domain_id)
|
||||
->pluck('local')
|
||||
->map(fn($l) => Str::lower($l))
|
||||
->all()
|
||||
: [];
|
||||
|
||||
return [
|
||||
'domain_id' => ['required', Rule::exists('domains', 'id')],
|
||||
'localpart' => [
|
||||
'required', 'max:191', 'regex:/^[A-Za-z0-9._%+-]+$/',
|
||||
// darf nicht als Alias existieren (gleiches Domain-Scope)
|
||||
Rule::notIn($aliasLocals),
|
||||
// und auch kein bestehendes Postfach mit gleichem Localpart
|
||||
Rule::unique('mail_users', 'localpart')
|
||||
->where(fn($q) => $q->where('domain_id', $this->domain_id)),
|
||||
],
|
||||
'display_name' => ['nullable', 'max:191'],
|
||||
'password' => ['nullable', 'min:8'],
|
||||
'quota_mb' => ['required', 'integer', 'min:0', 'max:' . $cap],
|
||||
'rate_limit_per_hour' => ['nullable', 'integer', 'min:1'],
|
||||
'is_active' => ['boolean'],
|
||||
'must_change_pw' => ['boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
/* ---------- Lifecycle ---------- */
|
||||
public function mount(?int $domainId = null): void
|
||||
{
|
||||
// alle Nicht-System-Domains in Select
|
||||
$this->domains = Domain::query()
|
||||
->where('is_system', false)
|
||||
->orderBy('domain')->get(['id', 'domain'])->toArray();
|
||||
|
||||
// vorselektieren falls mitgegeben, sonst 1. Domain (falls vorhanden)
|
||||
$this->domain_id = $domainId ?: ($this->domains[0]['id'] ?? null);
|
||||
|
||||
// Limits + Anzeige laden
|
||||
$this->syncDomainContext();
|
||||
}
|
||||
|
||||
public function updatedDomainId(): void
|
||||
{
|
||||
$this->resetErrorBag(); // scoped unique etc.
|
||||
$this->syncDomainContext();
|
||||
// bei Domainwechsel Alias-Kollision neu prüfen
|
||||
$this->checkAliasCollisionLive();
|
||||
}
|
||||
|
||||
public function updatedLocalpart(): void
|
||||
{
|
||||
$this->localpart = strtolower(trim($this->localpart));
|
||||
$this->rebuildEmailPreview();
|
||||
$this->checkAliasCollisionLive();
|
||||
}
|
||||
|
||||
public function updatedQuotaMb(): void
|
||||
{
|
||||
$this->recomputeQuotaHints();
|
||||
$this->recomputeBlockers();
|
||||
}
|
||||
|
||||
/* ---------- Helpers ---------- */
|
||||
private function syncDomainContext(): void
|
||||
{
|
||||
if (!$this->domain_id) return;
|
||||
|
||||
$d = Domain::query()
|
||||
->withCount('mailUsers')
|
||||
->withSum('mailUsers as used_storage_mb', 'quota_mb')
|
||||
->findOrFail($this->domain_id);
|
||||
|
||||
$this->domain_name = $d->domain;
|
||||
$this->limit_max_mailboxes = (int)$d->max_mailboxes;
|
||||
$this->limit_default_quota_mb = (int)$d->default_quota_mb;
|
||||
$this->limit_max_quota_per_mb = $d->max_quota_per_mailbox_mb !== null ? (int)$d->max_quota_per_mailbox_mb : null;
|
||||
$this->limit_total_quota_mb = (int)$d->total_quota_mb; // 0 = unlimitiert
|
||||
$this->limit_domain_rate_per_hour = $d->rate_limit_per_hour !== null ? (int)$d->rate_limit_per_hour : null;
|
||||
$this->allow_rate_limit_override = (bool)$d->rate_limit_override;
|
||||
|
||||
$this->mailbox_count_used = (int)$d->mail_users_count;
|
||||
$this->domain_storage_used_mb = (int)($d->used_storage_mb ?? 0);
|
||||
|
||||
// Defaults
|
||||
$this->quota_mb = $this->limit_default_quota_mb ?? 0;
|
||||
if (!$this->allow_rate_limit_override) {
|
||||
$this->rate_limit_per_hour = $this->limit_domain_rate_per_hour;
|
||||
$this->rate_limit_readonly = true;
|
||||
} else {
|
||||
$this->rate_limit_per_hour = $this->limit_domain_rate_per_hour;
|
||||
$this->rate_limit_readonly = false;
|
||||
}
|
||||
|
||||
$this->rebuildEmailPreview();
|
||||
$this->recomputeQuotaHints();
|
||||
$this->recomputeBlockers();
|
||||
}
|
||||
|
||||
private function rebuildEmailPreview(): void
|
||||
{
|
||||
$this->email_preview = $this->localpart && $this->domain_name
|
||||
? ($this->localpart . '@' . $this->domain_name) : '';
|
||||
}
|
||||
|
||||
private function recomputeQuotaHints(): void
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
if (!is_null($this->limit_total_quota_mb) && (int)$this->limit_total_quota_mb > 0) {
|
||||
$remainingNow = max(0, (int)$this->limit_total_quota_mb - (int)$this->domain_storage_used_mb);
|
||||
$remainingAfter = max(0, $remainingNow - max(0, (int)$this->quota_mb));
|
||||
$parts[] = "Verbleibend jetzt: {$remainingNow} MiB";
|
||||
$parts[] = "nach Speichern: {$remainingAfter} MiB";
|
||||
}
|
||||
if (!is_null($this->limit_max_quota_per_mb)) $parts[] = "Max {$this->limit_max_quota_per_mb} MiB pro Postfach";
|
||||
if (!is_null($this->limit_default_quota_mb)) $parts[] = "Standard: {$this->limit_default_quota_mb} MiB";
|
||||
|
||||
$this->quota_hint = implode(' · ', $parts);
|
||||
}
|
||||
|
||||
private function recomputeBlockers(): void
|
||||
{
|
||||
// Slots
|
||||
$this->no_mailbox_slots = false;
|
||||
if (!is_null($this->limit_max_mailboxes)) {
|
||||
$free = (int)$this->limit_max_mailboxes - (int)$this->mailbox_count_used;
|
||||
if ($free <= 0) $this->no_mailbox_slots = true;
|
||||
}
|
||||
|
||||
// Speicher
|
||||
$this->no_storage_left = false;
|
||||
if (!is_null($this->limit_total_quota_mb) && (int)$this->limit_total_quota_mb > 0) {
|
||||
$remaining = (int)$this->limit_total_quota_mb - (int)$this->domain_storage_used_mb;
|
||||
if ($remaining <= 0) $this->no_storage_left = true;
|
||||
}
|
||||
|
||||
$reasons = [];
|
||||
if ($this->no_mailbox_slots) $reasons[] = 'Keine freien Postfach-Slots in dieser Domain.';
|
||||
if ($this->no_storage_left) $reasons[] = 'Kein Domain-Speicher mehr verfügbar.';
|
||||
$this->block_reason = implode(' ', $reasons);
|
||||
$this->can_create = !($this->no_mailbox_slots || $this->no_storage_left);
|
||||
}
|
||||
|
||||
/** Prüft live, ob bereits ein Alias gleichen Localparts existiert, und setzt eine Feldfehlermeldung. */
|
||||
private function checkAliasCollisionLive(): void
|
||||
{
|
||||
$this->resetErrorBag('localpart');
|
||||
|
||||
if ($this->aliasExistsForLocalpart($this->domain_id, $this->localpart)) {
|
||||
$this->addError('localpart', 'Dieser Name ist bereits als Alias in dieser Domain vergeben.');
|
||||
}
|
||||
}
|
||||
|
||||
/** true, wenn in der Domain ein Alias mit gleichem Localpart existiert (case-insensitiv) */
|
||||
private function aliasExistsForLocalpart(?int $domainId, ?string $local): bool
|
||||
{
|
||||
$local = Str::lower(trim((string)$local));
|
||||
if (!$domainId || $local === '') return false;
|
||||
|
||||
return MailAlias::query()
|
||||
->where('domain_id', $domainId)
|
||||
->whereRaw('LOWER(local) = ?', [$local])
|
||||
->exists();
|
||||
}
|
||||
|
||||
/* ---------- Save ---------- */
|
||||
#[On('mailbox:create')]
|
||||
public function save(): void
|
||||
{
|
||||
$this->recomputeBlockers();
|
||||
if (!$this->can_create) {
|
||||
$this->addError('domain_id', $this->block_reason ?: 'Erstellung aktuell nicht möglich.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Vorab-Hard-Check gegen Alias-Kollision (zusätzlich zur Validation)
|
||||
if ($this->aliasExistsForLocalpart($this->domain_id, $this->localpart)) {
|
||||
$this->addError('localpart', 'Diese Adresse ist bereits als Alias vorhanden.');
|
||||
return;
|
||||
}
|
||||
|
||||
$data = $this->validate();
|
||||
$email = $data['localpart'] . '@' . $this->domain_name;
|
||||
|
||||
try {
|
||||
$u = new MailUser();
|
||||
$u->domain_id = $data['domain_id'];
|
||||
$u->localpart = $data['localpart'];
|
||||
$u->email = $email;
|
||||
$u->display_name = $this->display_name ?: null;
|
||||
$u->password_hash = $this->password ? Hash::make($this->password) : null;
|
||||
$u->is_system = false;
|
||||
$u->is_active = (bool)$data['is_active'];
|
||||
$u->must_change_pw = (bool)$data['must_change_pw'];
|
||||
$u->quota_mb = (int)$data['quota_mb'];
|
||||
$u->rate_limit_per_hour = $data['rate_limit_per_hour'];
|
||||
$u->save();
|
||||
} catch (QueryException $e) {
|
||||
$msg = strtolower($e->getMessage());
|
||||
if (str_contains($msg, 'mail_users_domain_localpart_unique')) {
|
||||
$this->addError('localpart', 'Dieses Postfach existiert in dieser Domain bereits.');
|
||||
return;
|
||||
}
|
||||
if (str_contains($msg, 'mail_users_email_unique')) {
|
||||
$this->addError('localpart', 'Diese E-Mail-Adresse ist bereits vergeben.');
|
||||
return;
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$this->dispatch('mailbox:created');
|
||||
$this->dispatch('closeModal');
|
||||
$this->dispatch('toast',
|
||||
type: 'done',
|
||||
badge: 'Postfach',
|
||||
title: 'Postfach angelegt',
|
||||
text: 'Das Postfach <b>' . e($email) . '</b> wurde erfolgreich angelegt.',
|
||||
duration: 6000
|
||||
);
|
||||
}
|
||||
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
return '3xl';
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.ui.mail.modal.mailbox-create-modal');
|
||||
}
|
||||
}
|
||||
|
||||
//namespace App\Livewire\Ui\Mail\Modal;
|
||||
//
|
||||
//use App\Models\Domain;
|
||||
//use App\Models\MailUser;
|
||||
//use Illuminate\Database\QueryException;
|
||||
//use Illuminate\Support\Facades\Hash;
|
||||
//use Illuminate\Validation\Rule;
|
||||
//use Livewire\Attributes\On;
|
||||
//use LivewireUI\Modal\ModalComponent;
|
||||
//
|
||||
//class MailboxCreateModal extends ModalComponent
|
||||
//{
|
||||
// // optional vorselektierte Domain
|
||||
// public ?int $domain_id = null;
|
||||
//
|
||||
// // Anzeige
|
||||
// public string $domain_name = '';
|
||||
// /** @var array<int,array{id:int,domain:string}> */
|
||||
// public array $domains = [];
|
||||
// public string $email_preview = '';
|
||||
//
|
||||
// public string $localpart = '';
|
||||
// public ?string $display_name = null;
|
||||
// public ?string $password = null;
|
||||
// public int $quota_mb = 0;
|
||||
// public ?int $rate_limit_per_hour = null;
|
||||
// public bool $is_active = true;
|
||||
// public bool $must_change_pw = true;
|
||||
//
|
||||
// // Limits / Status
|
||||
// public ?int $limit_max_mailboxes = null;
|
||||
// public ?int $limit_default_quota_mb = null;
|
||||
// public ?int $limit_max_quota_per_mb = null;
|
||||
// public ?int $limit_total_quota_mb = null; // 0 = unlimitiert
|
||||
// public ?int $limit_domain_rate_per_hour = null;
|
||||
// public bool $allow_rate_limit_override = false;
|
||||
//
|
||||
// public int $mailbox_count_used = 0;
|
||||
// public int $domain_storage_used_mb = 0;
|
||||
//
|
||||
// // Hints/Flags
|
||||
// public string $quota_hint = '';
|
||||
// public bool $rate_limit_readonly = false;
|
||||
// public bool $no_mailbox_slots = false;
|
||||
// public bool $no_storage_left = false;
|
||||
// public bool $can_create = true;
|
||||
// public string $block_reason = '';
|
||||
//
|
||||
// /* ---------- Validation ---------- */
|
||||
// protected function rules(): array
|
||||
// {
|
||||
// $maxPerMailbox = $this->limit_max_quota_per_mb ?? PHP_INT_MAX;
|
||||
// $remainingByTotal = (is_null($this->limit_total_quota_mb) || (int)$this->limit_total_quota_mb === 0)
|
||||
// ? PHP_INT_MAX
|
||||
// : max(0, (int)$this->limit_total_quota_mb - (int)$this->domain_storage_used_mb);
|
||||
// $cap = min($maxPerMailbox, $remainingByTotal);
|
||||
//
|
||||
// return [
|
||||
// 'domain_id' => ['required', Rule::exists('domains', 'id')],
|
||||
// 'localpart' => [
|
||||
// 'required', 'max:191', 'regex:/^[A-Za-z0-9._%+-]+$/',
|
||||
// Rule::unique('mail_users', 'localpart')->where(fn($q) => $q->where('domain_id', $this->domain_id)),
|
||||
// ],
|
||||
// 'display_name' => ['nullable', 'max:191'],
|
||||
// 'password' => ['nullable', 'min:8'],
|
||||
// 'quota_mb' => ['required', 'integer', 'min:0', 'max:' . $cap],
|
||||
// 'rate_limit_per_hour' => ['nullable', 'integer', 'min:1'],
|
||||
// 'is_active' => ['boolean'],
|
||||
// 'must_change_pw' => ['boolean'],
|
||||
// ];
|
||||
// }
|
||||
//
|
||||
// /* ---------- Lifecycle ---------- */
|
||||
// public function mount(?int $domainId = null): void
|
||||
// {
|
||||
// // alle Nicht-System-Domains in Select
|
||||
// $this->domains = Domain::query()
|
||||
// ->where('is_system', false)
|
||||
// ->orderBy('domain')->get(['id', 'domain'])->toArray();
|
||||
//
|
||||
// // vorselektieren falls mitgegeben, sonst 1. Domain (falls vorhanden)
|
||||
// $this->domain_id = $domainId ?: ($this->domains[0]['id'] ?? null);
|
||||
//
|
||||
// // Limits + Anzeige laden
|
||||
// $this->syncDomainContext();
|
||||
// }
|
||||
//
|
||||
// public function updatedDomainId(): void
|
||||
// {
|
||||
// $this->resetErrorBag(); // scoped unique etc.
|
||||
// $this->syncDomainContext();
|
||||
// }
|
||||
//
|
||||
// public function updatedLocalpart(): void
|
||||
// {
|
||||
// $this->localpart = strtolower(trim($this->localpart));
|
||||
// $this->rebuildEmailPreview();
|
||||
// }
|
||||
//
|
||||
// public function updatedQuotaMb(): void
|
||||
// {
|
||||
// $this->recomputeQuotaHints();
|
||||
// $this->recomputeBlockers();
|
||||
// }
|
||||
//
|
||||
// /* ---------- Helpers ---------- */
|
||||
// private function syncDomainContext(): void
|
||||
// {
|
||||
// if (!$this->domain_id) return;
|
||||
//
|
||||
// $d = Domain::query()
|
||||
// ->withCount('mailUsers')
|
||||
// ->withSum('mailUsers as used_storage_mb', 'quota_mb')
|
||||
// ->findOrFail($this->domain_id);
|
||||
//
|
||||
// $this->domain_name = $d->domain;
|
||||
// $this->limit_max_mailboxes = (int)$d->max_mailboxes;
|
||||
// $this->limit_default_quota_mb = (int)$d->default_quota_mb;
|
||||
// $this->limit_max_quota_per_mb = $d->max_quota_per_mailbox_mb !== null ? (int)$d->max_quota_per_mailbox_mb : null;
|
||||
// $this->limit_total_quota_mb = (int)$d->total_quota_mb; // 0 = unlimitiert
|
||||
// $this->limit_domain_rate_per_hour = $d->rate_limit_per_hour !== null ? (int)$d->rate_limit_per_hour : null;
|
||||
// $this->allow_rate_limit_override = (bool)$d->rate_limit_override;
|
||||
//
|
||||
// $this->mailbox_count_used = (int)$d->mail_users_count;
|
||||
// $this->domain_storage_used_mb = (int)($d->used_storage_mb ?? 0);
|
||||
//
|
||||
// // Defaults
|
||||
// $this->quota_mb = $this->limit_default_quota_mb ?? 0;
|
||||
// if (!$this->allow_rate_limit_override) {
|
||||
// $this->rate_limit_per_hour = $this->limit_domain_rate_per_hour;
|
||||
// $this->rate_limit_readonly = true;
|
||||
// } else {
|
||||
// $this->rate_limit_per_hour = $this->limit_domain_rate_per_hour;
|
||||
// $this->rate_limit_readonly = false;
|
||||
// }
|
||||
//
|
||||
// $this->rebuildEmailPreview();
|
||||
// $this->recomputeQuotaHints();
|
||||
// $this->recomputeBlockers();
|
||||
// }
|
||||
//
|
||||
// private function rebuildEmailPreview(): void
|
||||
// {
|
||||
// $this->email_preview = $this->localpart && $this->domain_name
|
||||
// ? ($this->localpart . '@' . $this->domain_name) : '';
|
||||
// }
|
||||
//
|
||||
// private function recomputeQuotaHints(): void
|
||||
// {
|
||||
// $parts = [];
|
||||
//
|
||||
// if (!is_null($this->limit_total_quota_mb) && (int)$this->limit_total_quota_mb > 0) {
|
||||
// $remainingNow = max(0, (int)$this->limit_total_quota_mb - (int)$this->domain_storage_used_mb);
|
||||
// $remainingAfter = max(0, $remainingNow - max(0, (int)$this->quota_mb));
|
||||
// $parts[] = "Verbleibend jetzt: {$remainingNow} MiB";
|
||||
// $parts[] = "nach Speichern: {$remainingAfter} MiB";
|
||||
// }
|
||||
// if (!is_null($this->limit_max_quota_per_mb)) $parts[] = "Max {$this->limit_max_quota_per_mb} MiB pro Postfach";
|
||||
// if (!is_null($this->limit_default_quota_mb)) $parts[] = "Standard: {$this->limit_default_quota_mb} MiB";
|
||||
//
|
||||
// $this->quota_hint = implode(' · ', $parts);
|
||||
// }
|
||||
//
|
||||
// private function recomputeBlockers(): void
|
||||
// {
|
||||
// // Slots
|
||||
// $this->no_mailbox_slots = false;
|
||||
// if (!is_null($this->limit_max_mailboxes)) {
|
||||
// $free = (int)$this->limit_max_mailboxes - (int)$this->mailbox_count_used;
|
||||
// if ($free <= 0) $this->no_mailbox_slots = true;
|
||||
// }
|
||||
//
|
||||
// // Speicher
|
||||
// $this->no_storage_left = false;
|
||||
// if (!is_null($this->limit_total_quota_mb) && (int)$this->limit_total_quota_mb > 0) {
|
||||
// $remaining = (int)$this->limit_total_quota_mb - (int)$this->domain_storage_used_mb;
|
||||
// if ($remaining <= 0) $this->no_storage_left = true;
|
||||
// }
|
||||
//
|
||||
// $reasons = [];
|
||||
// if ($this->no_mailbox_slots) $reasons[] = 'Keine freien Postfach-Slots in dieser Domain.';
|
||||
// if ($this->no_storage_left) $reasons[] = 'Kein Domain-Speicher mehr verfügbar.';
|
||||
// $this->block_reason = implode(' ', $reasons);
|
||||
// $this->can_create = !($this->no_mailbox_slots || $this->no_storage_left);
|
||||
// }
|
||||
//
|
||||
// /* ---------- Save ---------- */
|
||||
// #[On('mailbox:create')]
|
||||
// public function save(): void
|
||||
// {
|
||||
// $this->recomputeBlockers();
|
||||
// if (!$this->can_create) {
|
||||
// $this->addError('domain_id', $this->block_reason ?: 'Erstellung aktuell nicht möglich.');
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// $data = $this->validate();
|
||||
// $email = $data['localpart'] . '@' . $this->domain_name;
|
||||
//
|
||||
// try {
|
||||
// $u = new MailUser();
|
||||
// $u->domain_id = $data['domain_id'];
|
||||
// $u->localpart = $data['localpart'];
|
||||
// $u->email = $email;
|
||||
// $u->display_name = $this->display_name ?: null;
|
||||
// $u->password_hash = $this->password ? Hash::make($this->password) : null;
|
||||
// $u->is_system = false;
|
||||
// $u->is_active = (bool)$data['is_active'];
|
||||
// $u->must_change_pw = (bool)$data['must_change_pw'];
|
||||
// $u->quota_mb = (int)$data['quota_mb'];
|
||||
// $u->rate_limit_per_hour = $data['rate_limit_per_hour'];
|
||||
// $u->save();
|
||||
// } catch (QueryException $e) {
|
||||
// $msg = strtolower($e->getMessage());
|
||||
// if (str_contains($msg, 'mail_users_domain_localpart_unique')) {
|
||||
// $this->addError('localpart', 'Dieses Postfach existiert in dieser Domain bereits.');
|
||||
// return;
|
||||
// }
|
||||
// if (str_contains($msg, 'mail_users_email_unique')) {
|
||||
// $this->addError('localpart', 'Diese E-Mail-Adresse ist bereits vergeben.');
|
||||
// return;
|
||||
// }
|
||||
// throw $e;
|
||||
// }
|
||||
//
|
||||
// $this->dispatch('mailbox:created');
|
||||
// $this->dispatch('closeModal');
|
||||
// $this->dispatch('toast',
|
||||
// type: 'done',
|
||||
// badge: 'Postfach',
|
||||
// title: 'Postfach angelegt',
|
||||
// text: 'Das Postfach <b>' . e($email) . '</b> wurde erfolgreich angelegt.',
|
||||
// duration: 6000
|
||||
// );
|
||||
//
|
||||
// }
|
||||
//
|
||||
// public static function modalMaxWidth(): string
|
||||
// {
|
||||
// return '3xl';
|
||||
// }
|
||||
//
|
||||
// public function render()
|
||||
// {
|
||||
// return view('livewire.ui.mail.modal.mailbox-create-modal');
|
||||
// }
|
||||
//}
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
<?php
|
||||
|
||||
|
||||
namespace App\Livewire\Ui\Mail\Modal;
|
||||
|
||||
use App\Models\MailUser;
|
||||
use Livewire\Attributes\On;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class MailboxDeleteModal extends ModalComponent
|
||||
{
|
||||
public int $mailboxId;
|
||||
public ?MailUser $mailbox = null;
|
||||
|
||||
// Optionen
|
||||
public bool $keep_server_mail = false; // Mails am Server belassen?
|
||||
public bool $export_zip = false; // ZIP-Export anstoßen?
|
||||
|
||||
public string $email = '';
|
||||
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
return 'md';
|
||||
}
|
||||
|
||||
public function mount(int $mailboxId): void
|
||||
{
|
||||
$this->mailboxId = $mailboxId;
|
||||
$this->mailbox = MailUser::with('domain')->findOrFail($mailboxId);
|
||||
|
||||
$this->email = $this->mailbox->localpart . '@' . $this->mailbox->domain->domain;
|
||||
}
|
||||
|
||||
#[On('mailbox:delete')]
|
||||
public function delete(): void
|
||||
{
|
||||
// Optional: Export/Keep behandeln (hier nur Platzhalter-Events, damit UI fertig ist)
|
||||
if ($this->export_zip) {
|
||||
$this->dispatch('mailbox:export-zip', id: $this->mailboxId);
|
||||
}
|
||||
if ($this->keep_server_mail) {
|
||||
$this->dispatch('mailbox:keep-server-mail', id: $this->mailboxId);
|
||||
}
|
||||
|
||||
$this->mailbox->delete();
|
||||
|
||||
$this->dispatch('mailbox:deleted');
|
||||
$this->dispatch('closeModal');
|
||||
$this->dispatch('toast',
|
||||
type: 'done',
|
||||
badge: 'Postfach',
|
||||
title: 'Postfach entfernt',
|
||||
text: 'Das Postfach <b>' . e($this->email) . '</b> wurde erfolgreich entfernt.',
|
||||
duration: 6000
|
||||
);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.ui.mail.modal.mailbox-delete-modal');
|
||||
}
|
||||
}
|
||||
|
||||
//namespace App\Livewire\Ui\Mail\Modal;
|
||||
//
|
||||
//use App\Models\MailUser;
|
||||
//use LivewireUI\Modal\ModalComponent;
|
||||
//
|
||||
//class MailboxDeleteModal extends ModalComponent
|
||||
//{
|
||||
// public int $domainId; // = mailbox_id
|
||||
// public string $email_display = '';
|
||||
//
|
||||
// // Info-Badges
|
||||
// public int $quota_mb = 0;
|
||||
// public int $message_count = 0;
|
||||
//
|
||||
// // Auswahl
|
||||
// public string $mode = 'purge'; // purge | keep | export_then_delete
|
||||
// public string $confirm = '';
|
||||
//
|
||||
// protected MailUser $mailbox;
|
||||
//
|
||||
// public function mount(int $domainId): void
|
||||
// {
|
||||
// $this->domainId = $domainId;
|
||||
//
|
||||
// $this->mailbox = MailUser::with('domain:id,domain')->findOrFail($this->domainId);
|
||||
//
|
||||
// $this->email_display = $this->mailbox->localpart . '@' . $this->mailbox->domain->domain;
|
||||
// $this->quota_mb = (int)$this->mailbox->quota_mb;
|
||||
// $this->message_count = (int)($this->mailbox->message_count ?? 0);
|
||||
// }
|
||||
//
|
||||
// public function delete(): void
|
||||
// {
|
||||
// if (trim(strtolower($this->confirm)) !== strtolower($this->email_display)) {
|
||||
// $this->addError('confirm', 'Bitte tippe die E-Mail exakt zur Bestätigung ein.');
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// // Hier nur Platzhalter – deine eigentliche Lösch-/Exportlogik einsetzen
|
||||
// switch ($this->mode) {
|
||||
// case 'purge':
|
||||
// $this->mailbox->delete();
|
||||
// break;
|
||||
// case 'keep':
|
||||
// // Account deaktivieren/löschen, Maildir auf dem Server behalten
|
||||
// $this->mailbox->delete();
|
||||
// break;
|
||||
// case 'export_then_delete':
|
||||
// // Job anstoßen, ZIP exportieren, danach löschen
|
||||
// // dispatch(new \App\Jobs\ExportMailboxZipAndDelete($this->mailbox->id));
|
||||
// $this->mailbox->delete();
|
||||
// break;
|
||||
// }
|
||||
//
|
||||
// $this->dispatch('mailbox:deleted');
|
||||
// $this->dispatch('closeModal');
|
||||
// }
|
||||
//
|
||||
// public static function modalMaxWidth(): string
|
||||
// {
|
||||
// return '3xl';
|
||||
// }
|
||||
//
|
||||
// public function render()
|
||||
// {
|
||||
// return view('livewire.ui.mail.modal.mailbox-delete-modal');
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//namespace App\Livewire\Ui\Mail\Modal;
|
||||
//
|
||||
//use App\Models\MailUser;
|
||||
//use LivewireUI\Modal\ModalComponent;
|
||||
//
|
||||
//class MailboxDeleteModal extends ModalComponent
|
||||
//{
|
||||
// public int $mailboxId;
|
||||
//
|
||||
// public string $email = '';
|
||||
// public ?int $quota_mb = null;
|
||||
// public int $message_count = 0;
|
||||
//
|
||||
// public string $confirm = '';
|
||||
// public string $mode = 'purge'; // purge | keep_maildir | export_zip
|
||||
//
|
||||
// public function mount(int $mailboxId): void
|
||||
// {
|
||||
// $u = MailUser::findOrFail($mailboxId);
|
||||
// $this->mailboxId = $u->id;
|
||||
// $this->email = $u->email;
|
||||
// $this->quota_mb = (int)$u->quota_mb;
|
||||
// $this->message_count = (int)($u->message_count ?? 0);
|
||||
// }
|
||||
//
|
||||
// public function delete(): void
|
||||
// {
|
||||
// $this->validate([
|
||||
// 'confirm' => ['required','same:email'],
|
||||
// 'mode' => ['required','in:purge,keep_maildir,export_zip'],
|
||||
// ]);
|
||||
//
|
||||
// $u = MailUser::findOrFail($this->mailboxId);
|
||||
//
|
||||
// // OPTION: Export anstoßen
|
||||
// if ($this->mode === 'export_zip') {
|
||||
// // TODO: z.B. Job dispatchen:
|
||||
// // MailboxExportJob::dispatch($u->id);
|
||||
// // oder Service: app(MailExportService::class)->exportAndStoreZip($u);
|
||||
// $this->dispatch('toast',
|
||||
// type: 'info',
|
||||
// badge: 'Export',
|
||||
// title: 'Mail-Export gestartet',
|
||||
// text: 'Der ZIP-Export wurde angestoßen. Du erhältst einen Download-Hinweis, sobald er fertig ist.',
|
||||
// duration: 6000,
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// // Account löschen:
|
||||
// // - purge: Maildir + Account entfernen
|
||||
// // - keep_maildir: NUR Account entfernen (Maildir bleibt – je nach MTA/Storage-Setup)
|
||||
// // Implementierung hängt von deiner Umgebung ab. Hier rufen wir Events,
|
||||
// // damit deine Listener/Services zielgerichtet handeln können.
|
||||
// if ($this->mode === 'purge') {
|
||||
// $this->dispatch('mailbox:purge', id: $u->id);
|
||||
// } elseif ($this->mode === 'keep_maildir') {
|
||||
// $this->dispatch('mailbox:delete-keep-storage', id: $u->id);
|
||||
// } else {
|
||||
// $this->dispatch('mailbox:export-and-delete', id: $u->id);
|
||||
// }
|
||||
//
|
||||
// // In jedem Fall den Datensatz entfernen:
|
||||
// $u->delete();
|
||||
//
|
||||
// $this->dispatch('mailbox:deleted');
|
||||
// $this->dispatch('closeModal');
|
||||
// $this->dispatch('toast',
|
||||
// type: 'done',
|
||||
// badge: 'Mailbox',
|
||||
// title: 'Postfach gelöscht',
|
||||
// text: 'Das Postfach wurde entfernt.',
|
||||
// duration: 4000,
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// public static function modalMaxWidth(): string
|
||||
// {
|
||||
// return '2xl';
|
||||
// }
|
||||
//
|
||||
// public function render()
|
||||
// {
|
||||
// return view('livewire.ui.mail.modal.mailbox-delete-modal');
|
||||
// }
|
||||
//}
|
||||
|
|
@ -0,0 +1,411 @@
|
|||
<?php
|
||||
|
||||
|
||||
namespace App\Livewire\Ui\Mail\Modal;
|
||||
|
||||
use App\Models\MailUser;
|
||||
use App\Models\Domain;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Livewire\Attributes\On;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class MailboxEditModal extends ModalComponent
|
||||
{
|
||||
public int $mailboxId;
|
||||
public ?MailUser $mailbox = null;
|
||||
|
||||
// Felder
|
||||
public ?string $display_name = null;
|
||||
public ?string $password = null;
|
||||
public int $quota_mb = 0;
|
||||
public ?int $rate_limit_per_hour = null;
|
||||
public bool $is_active = true;
|
||||
public bool $must_change_pw = true;
|
||||
|
||||
// UI
|
||||
public string $email_readonly = '';
|
||||
public string $quota_hint = '';
|
||||
public bool $rate_limit_readonly = false;
|
||||
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
return 'lg';
|
||||
}
|
||||
|
||||
public function mount(int $mailboxId): void
|
||||
{
|
||||
$this->mailboxId = $mailboxId;
|
||||
$this->mailbox = MailUser::with('domain')->findOrFail($mailboxId);
|
||||
|
||||
// Felder vorbelegen
|
||||
$this->display_name = $this->mailbox->display_name;
|
||||
$this->quota_mb = (int)$this->mailbox->quota_mb;
|
||||
$this->rate_limit_per_hour = $this->mailbox->rate_limit_per_hour;
|
||||
$this->is_active = (bool)$this->mailbox->is_active;
|
||||
$this->must_change_pw = (bool)$this->mailbox->must_change_pw;
|
||||
|
||||
$dom = $this->mailbox->domain;
|
||||
$this->email_readonly = $this->mailbox->localpart . '@' . $dom->domain;
|
||||
|
||||
// Quota-Hinweis + Rate-Limit-Readonly
|
||||
$this->buildHints($dom);
|
||||
}
|
||||
|
||||
private function buildHints(Domain $d): void
|
||||
{
|
||||
// verbleibend inkl. der bisherigen Quota dieser Mailbox (damit Edit nicht sofort blockiert)
|
||||
$usedWithoutThis = (int)$d->mailUsers()
|
||||
->where('id', '!=', $this->mailbox->id)
|
||||
->sum('quota_mb');
|
||||
|
||||
$remainingByTotal = is_null($d->total_quota_mb)
|
||||
? null
|
||||
: max(0, (int)$d->total_quota_mb - $usedWithoutThis);
|
||||
|
||||
$parts = [];
|
||||
if (!is_null($d->max_quota_per_mailbox_mb)) $parts[] = "Max {$d->max_quota_per_mailbox_mb} MiB pro Postfach";
|
||||
if (!is_null($remainingByTotal)) $parts[] = "Verbleibend jetzt: {$remainingByTotal} MiB";
|
||||
if (!is_null($d->default_quota_mb)) $parts[] = "Standard: {$d->default_quota_mb} MiB";
|
||||
$this->quota_hint = implode(' · ', $parts);
|
||||
|
||||
// Domain-Rate-Limit: Override?
|
||||
if (!$d->rate_limit_override) {
|
||||
$this->rate_limit_per_hour = $d->rate_limit_per_hour;
|
||||
$this->rate_limit_readonly = true;
|
||||
} else {
|
||||
if (is_null($this->rate_limit_per_hour)) $this->rate_limit_per_hour = $d->rate_limit_per_hour;
|
||||
$this->rate_limit_readonly = false;
|
||||
}
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
// defensiv: wenn mailbox oder domain noch nicht da → keine Caps
|
||||
$d = $this->mailbox?->domain;
|
||||
|
||||
$maxPerMailbox = $d?->max_quota_per_mailbox_mb ?? PHP_INT_MAX;
|
||||
|
||||
if ($d) {
|
||||
$usedWithoutThis = (int)$d->mailUsers()
|
||||
->where('id', '!=', $this->mailbox->id)
|
||||
->sum('quota_mb');
|
||||
|
||||
$remainingByTotal = is_null($d->total_quota_mb)
|
||||
? PHP_INT_MAX
|
||||
: max(0, (int)$d->total_quota_mb - $usedWithoutThis);
|
||||
|
||||
$cap = min($maxPerMailbox, $remainingByTotal);
|
||||
} else {
|
||||
$cap = PHP_INT_MAX;
|
||||
}
|
||||
|
||||
return [
|
||||
'display_name' => ['nullable', 'string', 'max:191'],
|
||||
'password' => ['nullable', 'string', 'min:8'],
|
||||
'quota_mb' => ['required', 'integer', 'min:0', 'max:' . $cap],
|
||||
'rate_limit_per_hour' => ['nullable', 'integer', 'min:1'],
|
||||
'is_active' => ['boolean'],
|
||||
'must_change_pw' => ['boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
#[On('mailbox:edit:save')]
|
||||
public function save(): void
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
$u = $this->mailbox; // schon geladen
|
||||
$d = $u->domain;
|
||||
|
||||
// Speichern
|
||||
$u->display_name = $this->display_name ?: null;
|
||||
if (!empty($this->password)) {
|
||||
$u->password_hash = Hash::make($this->password);
|
||||
$u->must_change_pw = true;
|
||||
}
|
||||
$u->quota_mb = (int)$this->quota_mb;
|
||||
$u->rate_limit_per_hour = $this->rate_limit_readonly ? $d->rate_limit_per_hour : $this->rate_limit_per_hour;
|
||||
$u->is_active = (bool)$this->is_active;
|
||||
$u->must_change_pw = (bool)$this->must_change_pw;
|
||||
$u->save();
|
||||
|
||||
$mailbox = $u->localpart . '@' . $d->domain;
|
||||
$this->dispatch('mailbox:updated');
|
||||
$this->dispatch('closeModal');
|
||||
$this->dispatch('toast',
|
||||
type: 'done',
|
||||
badge: 'Postfach',
|
||||
title: 'Postfach aktualisiert',
|
||||
text: 'Das Postfach <b>' . $mailbox . '</b> wurde erfolgreich aktualisiert.',
|
||||
duration: 6000
|
||||
);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.ui.mail.modal.mailbox-edit-modal');
|
||||
}
|
||||
}
|
||||
|
||||
//namespace App\Livewire\Ui\Mail\Modal;
|
||||
//
|
||||
//use App\Models\MailUser;
|
||||
//use LivewireUI\Modal\ModalComponent;
|
||||
//use Illuminate\Support\Facades\Hash;
|
||||
//use Illuminate\Validation\Rule;
|
||||
//
|
||||
//class MailboxEditModal extends ModalComponent
|
||||
//{
|
||||
// // Eingangs-Argument (du rufst openMailboxEdit($id))
|
||||
// public int $domainId; // = mailbox_id
|
||||
//
|
||||
// // Anzeige
|
||||
// public string $email_display = ''; // readonly im UI
|
||||
//
|
||||
// // Editierbare Felder
|
||||
// public ?string $display_name = null;
|
||||
// public ?string $password = null; // leer => unverändert
|
||||
// public int $quota_mb = 0;
|
||||
// public ?int $rate_limit_per_hour = null;
|
||||
// public bool $is_active = true;
|
||||
//
|
||||
// // Intern
|
||||
// protected MailUser $mailbox;
|
||||
//
|
||||
// // Hinweise
|
||||
// public string $quota_hint = '';
|
||||
//
|
||||
// public function mount(int $domainId): void
|
||||
// {
|
||||
// $this->domainId = $domainId;
|
||||
//
|
||||
// $this->mailbox = MailUser::query()
|
||||
// ->with('domain:id,domain,total_quota_mb,max_quota_per_mailbox_mb,default_quota_mb')
|
||||
// ->findOrFail($this->domainId);
|
||||
//
|
||||
// $this->email_display = $this->mailbox->localpart . '@' . $this->mailbox->domain->domain;
|
||||
// $this->display_name = $this->mailbox->display_name;
|
||||
// $this->quota_mb = (int)$this->mailbox->quota_mb;
|
||||
// $this->rate_limit_per_hour = $this->mailbox->rate_limit_per_hour;
|
||||
// $this->is_active = (bool)$this->mailbox->is_active;
|
||||
//
|
||||
// // Quota-Hinweis bauen
|
||||
// $d = $this->mailbox->domain;
|
||||
// $parts = [];
|
||||
// if (!is_null($d->total_quota_mb)) {
|
||||
// // verbleibend „nach Speichern“ kannst du bei Bedarf einsetzen, hier nur aktuell
|
||||
// $parts[] = 'Verbleibend jetzt: ' . number_format((int)$d->total_quota_mb, 0, ',', '.') . ' MiB';
|
||||
// }
|
||||
// if (!is_null($d->max_quota_per_mailbox_mb)) $parts[] = 'Max ' . $d->max_quota_per_mailbox_mb . ' MiB pro Postfach';
|
||||
// if (!is_null($d->default_quota_mb)) $parts[] = 'Standard: ' . $d->default_quota_mb . ' MiB';
|
||||
// $this->quota_hint = implode(' · ', $parts);
|
||||
// }
|
||||
//
|
||||
// protected function rules(): array
|
||||
// {
|
||||
// $d = $this->mailbox->domain;
|
||||
//
|
||||
// $maxPerMailbox = $d->max_quota_per_mailbox_mb ?: PHP_INT_MAX;
|
||||
//
|
||||
// return [
|
||||
// 'display_name' => ['nullable', 'string', 'max:191'],
|
||||
// 'password' => ['nullable', 'string', 'min:8'],
|
||||
// 'quota_mb' => ['required', 'integer', 'min:0', 'max:' . $maxPerMailbox],
|
||||
// 'rate_limit_per_hour' => ['nullable', 'integer', 'min:1'],
|
||||
// 'is_active' => ['boolean'],
|
||||
// ];
|
||||
// }
|
||||
//
|
||||
// public function save(): void
|
||||
// {
|
||||
// $data = $this->validate();
|
||||
//
|
||||
// // speichern
|
||||
// $this->mailbox->display_name = $data['display_name'] ?: null;
|
||||
// if (!empty($data['password'])) {
|
||||
// $this->mailbox->password_hash = Hash::make($data['password']);
|
||||
// }
|
||||
// $this->mailbox->quota_mb = (int)$data['quota_mb'];
|
||||
// $this->mailbox->rate_limit_per_hour = $data['rate_limit_per_hour'];
|
||||
// $this->mailbox->is_active = (bool)$data['is_active'];
|
||||
// $this->mailbox->save();
|
||||
//
|
||||
// $this->dispatch('mailbox:updated');
|
||||
// $this->dispatch('closeModal');
|
||||
// $this->dispatch('toast', [
|
||||
// 'state' => 'success',
|
||||
// 'text' => 'Postfach wurde erfolgreich aktualisiert.',
|
||||
// ]);
|
||||
// }
|
||||
//
|
||||
// public static function modalMaxWidth(): string
|
||||
// {
|
||||
// return '4xl';
|
||||
// }
|
||||
//
|
||||
// public function render()
|
||||
// {
|
||||
// return view('livewire.ui.mail.modal.mailbox-edit-modal');
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//namespace App\Livewire\Ui\Mail\Modal;
|
||||
//
|
||||
//use App\Models\Domain;
|
||||
//use App\Models\MailUser;
|
||||
//use Illuminate\Support\Facades\Hash;
|
||||
//use Illuminate\Validation\Rule;
|
||||
//use LivewireUI\Modal\ModalComponent;
|
||||
//
|
||||
//class MailboxEditModal extends ModalComponent
|
||||
//{
|
||||
// public int $mailboxId;
|
||||
//
|
||||
// // read-only Anzeige
|
||||
// public string $email = '';
|
||||
//
|
||||
// // editierbare Felder
|
||||
// public ?string $display_name = null;
|
||||
// public string $password = '';
|
||||
// public int $quota_mb = 0;
|
||||
// public ?int $rate_limit_per_hour = null;
|
||||
// public bool $is_active = true;
|
||||
//
|
||||
// // Domain/Limits
|
||||
// public int $domain_id;
|
||||
// public ?int $limit_default_quota_mb = null;
|
||||
// public ?int $limit_max_quota_per_mb = null; // pro Mailbox
|
||||
// public ?int $limit_total_quota_mb = null; // gesamt (0/NULL = unbegrenzt)
|
||||
// public ?int $limit_domain_rate_per_hour = null;
|
||||
// public bool $allow_rate_limit_override = false;
|
||||
//
|
||||
// // Status/Ausgabe
|
||||
// public int $domain_storage_used_mb = 0; // Summe Quotas aller Mailboxen
|
||||
// public int $this_orig_quota_mb = 0; // Original der aktuellen Mailbox
|
||||
// public string $quota_hint = '';
|
||||
// public bool $rate_limit_readonly = false;
|
||||
//
|
||||
// public function mount(int $mailboxId): void
|
||||
// {
|
||||
// $u = MailUser::with('domain')->findOrFail($mailboxId);
|
||||
// $d = $u->domain;
|
||||
//
|
||||
// $this->mailboxId = $u->id;
|
||||
// $this->domain_id = $d->id;
|
||||
//
|
||||
// $this->email = $u->email;
|
||||
// $this->display_name = $u->display_name;
|
||||
// $this->quota_mb = (int)$u->quota_mb;
|
||||
// $this->this_orig_quota_mb = (int)$u->quota_mb;
|
||||
// $this->rate_limit_per_hour = $u->rate_limit_per_hour;
|
||||
// $this->is_active = (bool)$u->is_active;
|
||||
//
|
||||
// // Limits aus Domain
|
||||
// $agg = Domain::query()
|
||||
// ->withSum('mailUsers as used_storage_mb', 'quota_mb')
|
||||
// ->findOrFail($d->id);
|
||||
//
|
||||
// $this->limit_default_quota_mb = $d->default_quota_mb;
|
||||
// $this->limit_max_quota_per_mb = $d->max_quota_per_mailbox_mb;
|
||||
// $this->limit_total_quota_mb = $d->total_quota_mb; // 0 = unbegrenzt
|
||||
// $this->limit_domain_rate_per_hour = $d->rate_limit_per_hour;
|
||||
// $this->allow_rate_limit_override = (bool)$d->rate_limit_override;
|
||||
//
|
||||
// $this->domain_storage_used_mb = (int)($agg->used_storage_mb ?? 0);
|
||||
//
|
||||
// // Rate-Limit-Feld ggf. readonly machen
|
||||
// if (!$this->allow_rate_limit_override) {
|
||||
// $this->rate_limit_per_hour = $this->limit_domain_rate_per_hour;
|
||||
// $this->rate_limit_readonly = true;
|
||||
// }
|
||||
//
|
||||
// $this->buildQuotaHint();
|
||||
// }
|
||||
//
|
||||
// protected function rules(): array
|
||||
// {
|
||||
// // „Rest gesamt“ = total - (used - this_orig) (wir dürfen die alte Quota dieser Mailbox neu verteilen)
|
||||
// $remainingByTotal = ($this->limit_total_quota_mb === null || $this->limit_total_quota_mb === 0)
|
||||
// ? PHP_INT_MAX
|
||||
// : max(0, (int)$this->limit_total_quota_mb - max(0, (int)$this->domain_storage_used_mb - $this->this_orig_quota_mb));
|
||||
//
|
||||
// $maxPerMailbox = $this->limit_max_quota_per_mb ?? PHP_INT_MAX;
|
||||
// $cap = min($maxPerMailbox, $remainingByTotal);
|
||||
//
|
||||
// return [
|
||||
// 'display_name' => ['nullable','string','max:191'],
|
||||
// 'password' => ['nullable','string','min:8'],
|
||||
// 'quota_mb' => ['required','integer','min:0','max:'.$cap],
|
||||
// 'rate_limit_per_hour' => ['nullable','integer','min:1'],
|
||||
// 'is_active' => ['boolean'],
|
||||
// ];
|
||||
// }
|
||||
//
|
||||
// public function updatedQuotaMb(): void
|
||||
// {
|
||||
// $this->buildQuotaHint();
|
||||
// }
|
||||
//
|
||||
// private function buildQuotaHint(): void
|
||||
// {
|
||||
// $parts = [];
|
||||
// if (!is_null($this->limit_total_quota_mb) && $this->limit_total_quota_mb > 0) {
|
||||
// $usedMinusThis = max(0, (int)$this->domain_storage_used_mb - $this->this_orig_quota_mb);
|
||||
// $nowRemaining = max(0, (int)$this->limit_total_quota_mb - $usedMinusThis);
|
||||
// $after = max(0, (int)$this->limit_total_quota_mb - $usedMinusThis - max(0,(int)$this->quota_mb));
|
||||
//
|
||||
// $parts[] = "Verbleibend jetzt: {$nowRemaining} MiB";
|
||||
// $parts[] = "nach Speichern: {$after} MiB";
|
||||
// }
|
||||
// if (!is_null($this->limit_max_quota_per_mb)) {
|
||||
// $parts[] = "Max {$this->limit_max_quota_per_mb} MiB pro Postfach";
|
||||
// }
|
||||
// if (!is_null($this->limit_default_quota_mb)) {
|
||||
// $parts[] = "Standard: {$this->limit_default_quota_mb} MiB";
|
||||
// }
|
||||
// $this->quota_hint = implode(' · ', $parts);
|
||||
// }
|
||||
//
|
||||
// public function save(): void
|
||||
// {
|
||||
// $data = $this->validate();
|
||||
//
|
||||
// // Falls Override verboten ist → Domain-Wert erzwingen
|
||||
// if (!$this->allow_rate_limit_override) {
|
||||
// $data['rate_limit_per_hour'] = $this->limit_domain_rate_per_hour;
|
||||
// }
|
||||
//
|
||||
// $u = MailUser::findOrFail($this->mailboxId);
|
||||
//
|
||||
// // Passwort optional ändern
|
||||
// if (!empty($data['password'] ?? '')) {
|
||||
// $u->password_hash = Hash::make($data['password']);
|
||||
// }
|
||||
//
|
||||
// $u->display_name = $data['display_name'] ?? null;
|
||||
// $u->quota_mb = (int)$data['quota_mb'];
|
||||
// $u->rate_limit_per_hour = $data['rate_limit_per_hour'] ?? null;
|
||||
// $u->is_active = (bool)$data['is_active'];
|
||||
// $u->save();
|
||||
//
|
||||
// $this->dispatch('mailbox:updated');
|
||||
// $this->dispatch('closeModal');
|
||||
// $this->dispatch('toast',
|
||||
// type: 'done',
|
||||
// badge: 'Mailbox',
|
||||
// title: 'Postfach aktualisiert',
|
||||
// text: 'Die Änderungen wurden gespeichert.',
|
||||
// duration: 4000,
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// public static function modalMaxWidth(): string
|
||||
// {
|
||||
// return '3xl';
|
||||
// }
|
||||
//
|
||||
// public function render()
|
||||
// {
|
||||
// return view('livewire.ui.mail.modal.mailbox-edit-modal');
|
||||
// }
|
||||
//}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Ui\Search\Modal;
|
||||
|
||||
use App\Models\Domain;
|
||||
use App\Models\MailUser;
|
||||
use App\Models\User; // falls du App\User/Models\User hast, ggf. anpassen
|
||||
use Livewire\Attributes\Computed;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class SearchPaletteModal extends ModalComponent
|
||||
{
|
||||
public string $q = '';
|
||||
|
||||
/** Konfiguration: neue Bereiche leicht ergänzbar */
|
||||
private array $sections = [
|
||||
// key => [label, limit]
|
||||
'domains' => ['Domains', 8],
|
||||
'mailboxes' => ['Postfächer',8],
|
||||
'users' => ['Benutzer', 8],
|
||||
];
|
||||
|
||||
#[Computed]
|
||||
public function results(): array
|
||||
{
|
||||
$term = trim($this->q);
|
||||
if ($term === '') {
|
||||
return ['domains'=>[],'mailboxes'=>[],'users'=>[]];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
|
||||
// Domains
|
||||
if (isset($this->sections['domains'])) {
|
||||
[$label, $limit] = $this->sections['domains'];
|
||||
$out['domains'] = Domain::query()
|
||||
->where('is_system', false)
|
||||
->where('domain', 'like', "%{$term}%")
|
||||
->orderBy('domain')
|
||||
->limit($limit)
|
||||
->get(['id','domain','is_active'])
|
||||
->map(fn($d)=>[
|
||||
'id' => $d->id,
|
||||
'title' => $d->domain,
|
||||
'sub' => $d->is_active ? 'aktiv' : 'inaktiv',
|
||||
// 'route' => route('domains.index'), // falls Route existiert
|
||||
'route' => '#', // falls Route existiert
|
||||
'type' => 'domain',
|
||||
])->all();
|
||||
}
|
||||
|
||||
// Mailboxen
|
||||
if (isset($this->sections['mailboxes'])) {
|
||||
[$label, $limit] = $this->sections['mailboxes'];
|
||||
|
||||
$out['mailboxes'] = \App\Models\MailUser::query()
|
||||
// INNER JOIN: nur Mailboxen mit gültiger Domain
|
||||
->join('domains', 'domains.id', '=', 'mail_users.domain_id')
|
||||
// System-Domains ausschließen
|
||||
->where('domains.is_system', false)
|
||||
// Suchterm über localpart, gespeicherte email ODER Domain
|
||||
->where(function ($w) use ($term) {
|
||||
$w->where('mail_users.localpart', 'like', "%{$term}%")
|
||||
->orWhere('mail_users.email', 'like', "%{$term}%")
|
||||
->orWhere('domains.domain', 'like', "%{$term}%");
|
||||
})
|
||||
->orderBy('domains.domain')
|
||||
->orderBy('mail_users.localpart')
|
||||
->limit($limit)
|
||||
->get([
|
||||
'mail_users.id',
|
||||
'mail_users.localpart',
|
||||
'mail_users.email',
|
||||
'mail_users.is_active',
|
||||
'domains.domain as dom',
|
||||
'domains.is_active as dom_active',
|
||||
])
|
||||
->map(function ($r) {
|
||||
// Email bevorzugt rohe DB-Spalte; sonst sauber zusammensetzen
|
||||
$rawEmail = $r->getRawOriginal('email');
|
||||
$title = $rawEmail ?: ($r->localpart && $r->dom ? "{$r->localpart}@{$r->dom}" : '');
|
||||
|
||||
// Falls aus irgendeinem Grund beides leer wäre, gar keinen Geister-„@“ anzeigen:
|
||||
if ($title === '') {
|
||||
return null; // überspringen
|
||||
}
|
||||
|
||||
$sub = ($r->dom_active ? '' : 'Domain inaktiv · ')
|
||||
. ($r->is_active ? 'aktiv' : 'inaktiv');
|
||||
|
||||
return [
|
||||
'id' => (int)$r->id,
|
||||
'title' => $title,
|
||||
'sub' => $sub,
|
||||
'route' => '#',
|
||||
'type' => 'mailbox',
|
||||
];
|
||||
})
|
||||
->filter() // nulls (leere Titel) raus
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
// Benutzer (optional)
|
||||
if (class_exists(User::class) && isset($this->sections['users'])) {
|
||||
[$label, $limit] = $this->sections['users'];
|
||||
$out['users'] = User::query()
|
||||
->where(function($w) use ($term){
|
||||
$w->where('name','like',"%{$term}%")
|
||||
->orWhere('email','like',"%{$term}%");
|
||||
})
|
||||
->orderBy('name')
|
||||
->limit($limit)
|
||||
->get(['id','name','email'])
|
||||
->map(fn($u)=>[
|
||||
'id' => $u->id,
|
||||
'title' => $u->name,
|
||||
'sub' => $u->email,
|
||||
'route' => '#', // ggf. Profilroute hinterlegen
|
||||
'type' => 'user',
|
||||
])->all();
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function go(string $type, int $id): void
|
||||
{
|
||||
// Schließe die Palette …
|
||||
$this->dispatch('closeModal');
|
||||
|
||||
// … und navigiere / öffne Kontext:
|
||||
// - Domain → scrolle/markiere Domainkarte
|
||||
// - Mailbox → öffne Bearbeiten-Modal
|
||||
// Passe an, was du bevorzugst:
|
||||
if ($type === 'domain') {
|
||||
$this->dispatch('focus:domain', id: $id);
|
||||
} elseif ($type === 'mailbox') {
|
||||
// direkt Edit-Modal auf
|
||||
$this->dispatch('openModal', component:'ui.mail.modal.mailbox-edit-modal', arguments: [$id]);
|
||||
} elseif ($type === 'user') {
|
||||
$this->dispatch('focus:user', id: $id);
|
||||
}
|
||||
}
|
||||
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
return '2xl';
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.ui.search.modal.search-palette-modal');
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Livewire\Ui\Security\Modal;
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\On;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
use Vectorface\GoogleAuthenticator;
|
||||
|
||||
|
|
@ -36,6 +37,7 @@ class TotpSetupModal extends ModalComponent
|
|||
$this->alreadyActive = (bool) ($user->two_factor_enabled ?? false);
|
||||
}
|
||||
|
||||
#[On('security:totp:enable')]
|
||||
public function verifyAndEnable(string $code): void
|
||||
{
|
||||
$code = preg_replace('/\D/', '', $code ?? '');
|
||||
|
|
|
|||
|
|
@ -2,24 +2,31 @@
|
|||
|
||||
namespace App\Livewire\Ui\System;
|
||||
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Component;
|
||||
|
||||
class GeneralForm extends Component
|
||||
{
|
||||
public array $locales;
|
||||
public array $timezones;
|
||||
|
||||
public string $instance_name = 'MailWolt'; // readonly Anzeige
|
||||
public string $locale = 'de';
|
||||
public string $timezone = 'Europe/Berlin';
|
||||
public string $locale;
|
||||
public string $timezone;
|
||||
public int $session_timeout = 120; // Minuten
|
||||
|
||||
// Beispieldaten – später aus Config/DB füllen
|
||||
public array $locales = [
|
||||
['value' => 'de', 'label' => 'Deutsch'],
|
||||
['value' => 'en', 'label' => 'English'],
|
||||
];
|
||||
|
||||
public array $timezones = [
|
||||
'Europe/Berlin','UTC','Europe/Vienna','Europe/Zurich','America/New_York','Asia/Tokyo'
|
||||
];
|
||||
public function mount(): void
|
||||
{
|
||||
$this->locales = config('system.locales', []);
|
||||
$this->timezones = config('system.timezones', []);
|
||||
|
||||
// prefill from settings
|
||||
$this->locale = \App\Models\Setting::get('system.locale', 'de');
|
||||
$this->timezone = \App\Models\Setting::get('system.timezone', 'Europe/Berlin');
|
||||
$this->session_timeout= (int)\App\Models\Setting::get('system.session_timeout', 120);
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
|
|
@ -34,13 +41,25 @@ class GeneralForm extends Component
|
|||
{
|
||||
$this->validate();
|
||||
|
||||
\App\Models\Setting::setMany([
|
||||
'system.locale' => $this->locale,
|
||||
'system.timezone' => $this->timezone,
|
||||
'system.session_timeout' => $this->session_timeout,
|
||||
]);
|
||||
|
||||
// TODO: persist to settings storage (DB/Config)
|
||||
// e.g. Settings::set('app.locale', $this->locale);
|
||||
// Settings::set('app.timezone', $this->timezone);
|
||||
// Settings::set('session.timeout', $this->session_timeout);
|
||||
|
||||
session()->flash('saved', true);
|
||||
$this->dispatch('toast', body: 'Einstellungen gespeichert.');
|
||||
$this->dispatch('toast',
|
||||
type: 'done',
|
||||
badge: 'Einstellungen',
|
||||
title: 'Gespeichert',
|
||||
text: 'Die allgemeinen Einstellungen wurden erfolgreich gespeichert.',
|
||||
duration: 6000,
|
||||
);
|
||||
// $this->dispatch('toast', body: 'Einstellungen gespeichert.');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -7,18 +7,57 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||
|
||||
class Domain extends Model
|
||||
{
|
||||
protected $fillable = ['domain','is_active','is_system'];
|
||||
protected $casts = ['is_active' => 'bool'];
|
||||
protected $fillable = [
|
||||
'domain','description','tags',
|
||||
'is_active','is_system',
|
||||
'max_aliases','max_mailboxes',
|
||||
'default_quota_mb','max_quota_per_mailbox_mb','total_quota_mb',
|
||||
'rate_limit_per_hour','rate_limit_override',
|
||||
];
|
||||
|
||||
public function aliases(): HasMany
|
||||
{
|
||||
return $this->hasMany(MailAlias::class);
|
||||
protected $casts = [
|
||||
'tags' => 'array',
|
||||
'is_active' => 'bool',
|
||||
'is_system' => 'bool',
|
||||
'max_aliases' => 'int',
|
||||
'max_mailboxes' => 'int',
|
||||
'default_quota_mb' => 'int',
|
||||
'max_quota_per_mailbox_mb' => 'int',
|
||||
'total_quota_mb' => 'int',
|
||||
'rate_limit_per_hour' => 'int',
|
||||
'rate_limit_override' => 'bool',
|
||||
];
|
||||
|
||||
protected $appends = ['dns_verified'];
|
||||
|
||||
public function mailUsers(): HasMany {
|
||||
return $this->hasMany(MailUser::class)->orderBy('localpart');
|
||||
}
|
||||
public function mailAliases(): HasMany {
|
||||
return $this->hasMany(MailAlias::class)->orderBy('local');
|
||||
}
|
||||
|
||||
public function users(): HasMany
|
||||
public function getDnsVerifiedAttribute(): bool
|
||||
{
|
||||
return $this->hasMany(MailUser::class);
|
||||
// Dummy-Regel: als verifiziert, wenn DKIM-Key existiert (nur Beispiel)
|
||||
return $this->dkimKeys()->where('is_active', true)->exists();
|
||||
}
|
||||
// Praktisch fürs Listing
|
||||
public function scopeWithMailStats($q) {
|
||||
return $q->withCount(['mailUsers','mailAliases'])
|
||||
->with(['mailUsers','mailAliases'])
|
||||
->orderBy('domain');
|
||||
}
|
||||
|
||||
// public function mailAliases(): HasMany
|
||||
// {
|
||||
// return $this->hasMany(MailAlias::class);
|
||||
// }
|
||||
//
|
||||
// public function mailUsers(): HasMany
|
||||
// {
|
||||
// return $this->hasMany(MailUser::class);
|
||||
// }
|
||||
|
||||
public function dkimKeys(): HasMany
|
||||
{
|
||||
|
|
@ -35,4 +74,24 @@ class Domain extends Model
|
|||
return $this->hasMany(DmarcRecord::class);
|
||||
}
|
||||
|
||||
public function tlsaRecords()
|
||||
{
|
||||
return $this->hasMany(TlsaRecord::class);
|
||||
}
|
||||
|
||||
public function getTagObjectsAttribute(): array
|
||||
{
|
||||
$raw = $this->tags ?? [];
|
||||
$items = [];
|
||||
foreach ($raw as $t) {
|
||||
if (!is_array($t)) continue;
|
||||
$label = trim((string)($t['label'] ?? '')); if ($label === '') continue;
|
||||
$color = $this->normalizeHex($t['color'] ?? '') ?? '#22c55e';
|
||||
$items[] = ['label'=>$label,'color'=>$color,
|
||||
'bg'=>$this->toRgba($color,0.12),'border'=>$this->toRgba($color,0.35)];
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
private function toRgba(string $hex, float $a): string { $hex=ltrim($hex,'#'); $r=hexdec(substr($hex,0,2)); $g=hexdec(substr($hex,2,2)); $b=hexdec(substr($hex,4,2)); return "rgba($r,$g,$b,$a)"; }
|
||||
private function normalizeHex(?string $hex): ?string { $hex=trim((string)$hex); if($hex==='')return null; if($hex[0]!=='#')$hex="#$hex"; return preg_match('/^#[0-9a-fA-F]{6}$/',$hex)?strtolower($hex):null; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,15 +4,40 @@ namespace App\Models;
|
|||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class MailAlias extends Model
|
||||
{
|
||||
protected $table = 'mail_aliases';
|
||||
protected $fillable = ['domain_id','local','type','group_name','is_active','notes'];
|
||||
protected $casts = ['is_active' => 'bool'];
|
||||
|
||||
protected $fillable = ['domain_id','source','destination','is_active'];
|
||||
protected $casts = ['is_active'=>'bool'];
|
||||
|
||||
public function domain(): BelongsTo {
|
||||
public function domain(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Domain::class);
|
||||
}
|
||||
|
||||
public function recipients(): HasMany
|
||||
{
|
||||
return $this->hasMany(MailAliasRecipient::class, 'alias_id');
|
||||
}
|
||||
|
||||
public function getAddressAttribute(): string
|
||||
{
|
||||
$domain = $this->relationLoaded('domain') ? $this->domain : $this->domain()->first();
|
||||
return "{$this->local}@{$domain->name}";
|
||||
}
|
||||
|
||||
// protected $table = 'mail_aliases';
|
||||
//
|
||||
// protected $fillable = ['domain_id','source','destination','is_active'];
|
||||
// protected $casts = ['is_active'=>'bool'];
|
||||
//
|
||||
// public function domain(): BelongsTo {
|
||||
// return $this->belongsTo(Domain::class);
|
||||
// }
|
||||
//
|
||||
public function getSourceAttribute(): string {
|
||||
return "{$this->source_local}@{$this->domain->name}";
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class MailAliasRecipient extends Model
|
||||
{
|
||||
protected $fillable = ['alias_id','mail_user_id','email','position'];
|
||||
|
||||
public function alias(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(MailAlias::class, 'alias_id');
|
||||
}
|
||||
|
||||
// <-- hier auf MailUser verweisen, nicht Mailbox
|
||||
public function mailUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(MailUser::class, 'mail_user_id');
|
||||
}
|
||||
|
||||
public function getLabelAttribute(): string
|
||||
{
|
||||
if ($this->relationLoaded('mailUser') && $this->mailUser) {
|
||||
// falls MailUser ein Attribut 'address' hat (z.B. office@pixio.at)
|
||||
return '@'.$this->mailUser->address;
|
||||
}
|
||||
return (string) $this->email;
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ class MailUser extends Model
|
|||
// protected $table = 'mail_users';
|
||||
|
||||
protected $fillable = [
|
||||
'domain_id','localpart','email','password_hash',
|
||||
'domain_id','localpart','email','display_name','password_hash',
|
||||
'is_active','must_change_pw','quota_mb','is_system'
|
||||
];
|
||||
|
||||
|
|
@ -32,6 +32,26 @@ class MailUser extends Model
|
|||
$this->attributes['password_hash'] = password_hash($plain, PASSWORD_BCRYPT);
|
||||
}
|
||||
|
||||
// Ausgabe: vollständige Adresse
|
||||
public function getEmailAttribute(): string {
|
||||
return "{$this->local_part}@{$this->domain->name}";
|
||||
}
|
||||
|
||||
public function getAddressAttribute(): string
|
||||
{
|
||||
$local = (string)($this->attributes['localpart'] ?? $this->localpart ?? '');
|
||||
// bevorzugt den in Queries selektierten Alias `dom`, sonst Relation, sonst Roh-Attribut
|
||||
$dom = (string)(
|
||||
$this->attributes['dom']
|
||||
?? ($this->relationLoaded('domain') ? ($this->domain->domain ?? '') : '')
|
||||
?? ($this->attributes['domain'] ?? '')
|
||||
);
|
||||
|
||||
if ($local !== '' && $dom !== '') return "{$local}@{$dom}";
|
||||
if (($this->attributes['email'] ?? null) !== null) return (string)$this->attributes['email'];
|
||||
return $dom !== '' ? "@{$dom}" : '';
|
||||
}
|
||||
|
||||
// Scopes
|
||||
public function scopeActive($q) { return $q->where('is_active', true); }
|
||||
|
||||
|
|
|
|||
|
|
@ -3,31 +3,109 @@
|
|||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
class Setting extends Model
|
||||
{
|
||||
protected $fillable = ['key','value'];
|
||||
public $incrementing = false;
|
||||
protected $keyType = 'string';
|
||||
protected $fillable = ['group', 'key', 'value'];
|
||||
protected $keyType = 'int';
|
||||
public $incrementing = true;
|
||||
|
||||
public static function get(string $key, $default=null) {
|
||||
$row = static::query()->find($key);
|
||||
if (!$row) return $default;
|
||||
$val = $row->value;
|
||||
$decoded = json_decode($val, true);
|
||||
return (json_last_error() === JSON_ERROR_NONE) ? $decoded : $val;
|
||||
}
|
||||
|
||||
public static function set(string $key, $value): void {
|
||||
$val = is_array($value) ? json_encode($value, JSON_UNESCAPED_SLASHES) : (string)$value;
|
||||
static::query()->updateOrCreate(['key'=>$key], ['value'=>$val]);
|
||||
}
|
||||
|
||||
public static function signupAllowed()
|
||||
/**
|
||||
* Get a setting: Redis → DB fallback → Redis rebuild
|
||||
*/
|
||||
public static function get(string $name, $default = null)
|
||||
{
|
||||
$value = self::where('key', 'signup_enabled')->value('value');
|
||||
return is_null($value) || (int) $value === 1;
|
||||
[$group, $key] = self::split($name);
|
||||
$redisKey = self::redisKey($group, $key);
|
||||
|
||||
// 1️⃣ Try Redis
|
||||
try {
|
||||
$cached = Redis::get($redisKey);
|
||||
if ($cached !== null) {
|
||||
$decoded = json_decode($cached, true);
|
||||
return json_last_error() === JSON_ERROR_NONE ? $decoded : $cached;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Redis down, fallback to DB
|
||||
}
|
||||
|
||||
// 2️⃣ Try DB
|
||||
$row = static::query()->where(compact('group', 'key'))->first();
|
||||
if (!$row) return $default;
|
||||
|
||||
$decoded = json_decode($row->value, true);
|
||||
$value = json_last_error() === JSON_ERROR_NONE ? $decoded : $row->value;
|
||||
|
||||
// 3️⃣ Writeback to Redis
|
||||
try {
|
||||
Redis::setex($redisKey, 3600, is_scalar($value) ? (string)$value : json_encode($value));
|
||||
} catch (\Throwable) {}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or update a setting: DB → Redis
|
||||
*/
|
||||
public static function set(string $name, $value): void
|
||||
{
|
||||
[$group, $key] = self::split($name);
|
||||
$redisKey = self::redisKey($group, $key);
|
||||
|
||||
$val = is_scalar($value)
|
||||
? (string)$value
|
||||
: json_encode($value, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
static::query()->updateOrCreate(compact('group', 'key'), ['value' => $val]);
|
||||
|
||||
try {
|
||||
Redis::set($redisKey, $val);
|
||||
} catch (\Throwable) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget a cached setting (Redis only)
|
||||
*/
|
||||
public static function forget(string $name): void
|
||||
{
|
||||
[$group, $key] = self::split($name);
|
||||
try {
|
||||
Redis::del(self::redisKey($group, $key));
|
||||
} catch (\Throwable) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Redis key
|
||||
*/
|
||||
protected static function redisKey(string $group, string $key): string
|
||||
{
|
||||
return "settings:{$group}:{$key}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Split "group.key" format
|
||||
*/
|
||||
protected static function split(string $name): array
|
||||
{
|
||||
if (str_contains($name, '.')) {
|
||||
[$group, $key] = explode('.', $name, 2);
|
||||
} else {
|
||||
$group = 'system';
|
||||
$key = $name;
|
||||
}
|
||||
return [$group, $key];
|
||||
}
|
||||
|
||||
public static function setMany(array $pairs): void
|
||||
{
|
||||
foreach ($pairs as $name => $value) {
|
||||
self::set($name, $value);
|
||||
}
|
||||
}
|
||||
|
||||
public static function signupAllowed(): bool
|
||||
{
|
||||
return (int) self::get('system.signup_enabled', 1) === 1;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,25 +19,56 @@ class AppServiceProvider extends ServiceProvider
|
|||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(SettingsRepository $settings): void
|
||||
public function boot(\App\Support\SettingsRepository $settings): void
|
||||
{
|
||||
try {
|
||||
$S = app(\App\Support\SettingsRepository::class);
|
||||
if ($tz = $S->get('app.timezone')) {
|
||||
|
||||
// 🕒 Zeitzone
|
||||
if ($tz = $S->get('system.timezone')) {
|
||||
config(['app.timezone' => $tz]);
|
||||
date_default_timezone_set($tz);
|
||||
}
|
||||
|
||||
// 🌐 Sprache
|
||||
if ($locale = $S->get('system.locale')) {
|
||||
app()->setLocale($locale);
|
||||
}
|
||||
|
||||
// 🌍 Domain / URL
|
||||
if ($domain = $S->get('app.domain')) {
|
||||
// Falls du APP_URL dynamisch überschreiben willst:
|
||||
$scheme = $S->get('app.force_https', true) ? 'https' : 'http';
|
||||
config(['app.url' => $scheme.'://'.$domain]);
|
||||
config(['app.url' => "{$scheme}://{$domain}"]);
|
||||
URL::forceRootUrl(config('app.url'));
|
||||
if ($scheme === 'https') {
|
||||
URL::forceScheme('https');
|
||||
}
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
// Im Bootstrap/Wartungsmodus still sein
|
||||
// Keine Exceptions beim Booten, z. B. wenn DB oder Redis noch nicht erreichbar sind
|
||||
// \Log::warning('settings.boot_failed', ['msg' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
// public function boot(SettingsRepository $settings): void
|
||||
// {
|
||||
// try {
|
||||
// $S = app(\App\Support\SettingsRepository::class);
|
||||
// if ($tz = $S->get('app.timezone')) {
|
||||
// config(['app.timezone' => $tz]);
|
||||
// date_default_timezone_set($tz);
|
||||
// }
|
||||
// if ($domain = $S->get('app.domain')) {
|
||||
// // Falls du APP_URL dynamisch überschreiben willst:
|
||||
// $scheme = $S->get('app.force_https', true) ? 'https' : 'http';
|
||||
// config(['app.url' => $scheme.'://'.$domain]);
|
||||
// URL::forceRootUrl(config('app.url'));
|
||||
// if ($scheme === 'https') {
|
||||
// URL::forceScheme('https');
|
||||
// }
|
||||
// }
|
||||
// } catch (\Throwable $e) {
|
||||
// // Im Bootstrap/Wartungsmodus still sein
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,140 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Domain;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use RuntimeException;
|
||||
|
||||
class DkimService
|
||||
{
|
||||
/** Erzeugt Keypair & gibt den TXT-Record (ohne Host) zurück. */
|
||||
public function generateForDomain(Domain $domainId, int $bits = 2048, string $selector = 'dkim'): array
|
||||
{
|
||||
$dirKey = $this->safeKey($domainId);
|
||||
$selKey = $this->safeKey($selector, 32);
|
||||
|
||||
$disk = Storage::disk('local');
|
||||
$baseRel = "dkim/{$dirKey}";
|
||||
$privRel = "{$baseRel}/{$selKey}.pem";
|
||||
$pubRel = "{$baseRel}/{$selKey}.pub";
|
||||
|
||||
// 1) Ordner sicherstellen
|
||||
$disk->makeDirectory($baseRel);
|
||||
|
||||
// 2) Keypair via PHP-OpenSSL (kein shell_exec)
|
||||
$res = openssl_pkey_new([
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
'private_key_bits' => $bits,
|
||||
]);
|
||||
if ($res === false) {
|
||||
throw new \RuntimeException('DKIM: openssl_pkey_new() fehlgeschlagen: ' . (openssl_error_string() ?: 'unbekannt'));
|
||||
}
|
||||
|
||||
$privateKey = '';
|
||||
if (!openssl_pkey_export($res, $privateKey)) {
|
||||
throw new \RuntimeException('DKIM: openssl_pkey_export() fehlgeschlagen: ' . (openssl_error_string() ?: 'unbekannt'));
|
||||
}
|
||||
|
||||
$details = openssl_pkey_get_details($res);
|
||||
if ($details === false || empty($details['key'])) {
|
||||
throw new \RuntimeException('DKIM: Public Key konnte nicht gelesen werden.');
|
||||
}
|
||||
$publicKeyPem = $details['key'];
|
||||
Log::debug('dkim.pem.first_line', ['line' => strtok($publicKeyPem, "\n")]);
|
||||
Log::debug('dkim.pem.len', ['len' => strlen($publicKeyPem)]);
|
||||
|
||||
// 3) Schreiben über Storage (legt Dateien an, keine zu langen Pfade in Komponenten)
|
||||
if (!$disk->put($privRel, $privateKey)) {
|
||||
throw new \RuntimeException("DKIM: Private-Key schreiben fehlgeschlagen: {$privRel}");
|
||||
}
|
||||
if (!$disk->put($pubRel, $publicKeyPem)) {
|
||||
throw new \RuntimeException("DKIM: Public-Key schreiben fehlgeschlagen: {$pubRel}");
|
||||
}
|
||||
|
||||
// 4) DNS-Record bauen
|
||||
// $p = preg_replace('/-----BEGIN PUBLIC KEY-----|-----END PUBLIC KEY-----|\s+/', '', $publicKeyPem);
|
||||
// $dnsTxt = "v=DKIM1; k=rsa; p={$p}";
|
||||
|
||||
$publicKeyBase64 = self::extractPublicKeyBase64($publicKeyPem);
|
||||
Log::debug('dkim.p.len', ['len' => strlen($publicKeyBase64)]);
|
||||
$dnsTxt = "v=DKIM1; k=rsa; p={$publicKeyBase64}";
|
||||
|
||||
// sanity: RSA2048 liegt typ. > 300 chars
|
||||
if (strlen($publicKeyBase64) < 300) {
|
||||
throw new \RuntimeException('DKIM: Public Key zu kurz – vermutlich Parsing-Fehler.');
|
||||
}
|
||||
return [
|
||||
'selector' => $selKey,
|
||||
'priv_path' => storage_path("app/{$privRel}"),
|
||||
'pub_path' => storage_path("app/{$pubRel}"),
|
||||
'public_pem' => $publicKeyPem,
|
||||
'private_pem' => $privateKey,
|
||||
'dns_name' => "{$selKey}._domainkey", // vor Domain hängen
|
||||
'dns_txt' => $dnsTxt,
|
||||
'bits' => $bits,
|
||||
];
|
||||
}
|
||||
|
||||
// // Pfade
|
||||
// $base = "dkim/{$domain->id}";
|
||||
// Storage::disk('local')->makeDirectory($base);
|
||||
//
|
||||
// $privPath = storage_path("app/{$base}/{$selector}.key");
|
||||
// $pubPath = storage_path("app/{$base}/{$selector}.pub");
|
||||
//
|
||||
// // openssl genrsa / rsa-privkey
|
||||
// $cmd = sprintf('openssl genrsa %d > %s && openssl rsa -in %s -pubout -out %s',
|
||||
// $bits, escapeshellarg($privPath), escapeshellarg($privPath), escapeshellarg($pubPath)
|
||||
// );
|
||||
// shell_exec($cmd);
|
||||
//
|
||||
// $pub = trim(file_get_contents($pubPath));
|
||||
// // Public Key extrahieren → DKIM TXT
|
||||
// $pub = preg_replace('/-----BEGIN PUBLIC KEY-----|-----END PUBLIC KEY-----|\s+/', '', $pub);
|
||||
//
|
||||
// $txt = "v=DKIM1; k=rsa; p={$pub}";
|
||||
// // Domain kann hier auch den Pfad/Selector speichern:
|
||||
// $domain->update([
|
||||
// 'dkim_selector' => $selector,
|
||||
// 'dkim_bits' => $bits,
|
||||
// 'dkim_key_path' => $privPath,
|
||||
// ]);
|
||||
//
|
||||
// return $txt;
|
||||
// }
|
||||
|
||||
protected function safeKey($value, int $max = 64): string
|
||||
{
|
||||
if (is_object($value)) {
|
||||
if (isset($value->id)) $value = $value->id;
|
||||
elseif (method_exists($value, 'getKey')) $value = $value->getKey();
|
||||
else $value = json_encode($value);
|
||||
}
|
||||
$raw = (string) $value;
|
||||
$san = preg_replace('/[^A-Za-z0-9._-]/', '_', $raw);
|
||||
if ($san === '' ) $san = 'unknown';
|
||||
if (strlen($san) > $max) {
|
||||
$san = substr($san, 0, $max - 13) . '_' . substr(sha1($raw), 0, 12);
|
||||
}
|
||||
return $san;
|
||||
}
|
||||
|
||||
protected static function extractPublicKeyBase64(string $pem): string
|
||||
{
|
||||
// Hole den Body zwischen den Headern (multiline, dotall)
|
||||
if (!preg_match('/^-+BEGIN PUBLIC KEY-+\r?\n(.+?)\r?\n-+END PUBLIC KEY-+\s*$/ms', $pem, $m)) {
|
||||
throw new \RuntimeException('DKIM: Ungültiges Public-Key-PEM (Header/Footers nicht gefunden).');
|
||||
}
|
||||
|
||||
// Whitespace entfernen → reines Base64
|
||||
$b64 = preg_replace('/\s+/', '', $m[1]);
|
||||
|
||||
if ($b64 === '' || base64_decode($b64, true) === false) {
|
||||
throw new \RuntimeException('DKIM: Public Key Base64 ist leer/ungültig.');
|
||||
}
|
||||
|
||||
return $b64;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,68 +2,350 @@
|
|||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\DkimKey;
|
||||
use App\Models\Domain;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class DnsRecordService
|
||||
{
|
||||
public function buildForDomain(Domain $domain): array
|
||||
/**
|
||||
* High-level: DKIM (optional), SPF & DMARC in DB anlegen/aktualisieren
|
||||
* und empfohlene DNS-Records (required/optional) zurückgeben.
|
||||
*
|
||||
* $opts:
|
||||
* - ipv4, ipv6
|
||||
* - spf_tail ("~all" | "-all")
|
||||
* - spf_extra (Array zusätzlicher Mechanismen, z.B. ["ip4:1.2.3.4"])
|
||||
* - dmarc_policy ("none"|"quarantine"|"reject")
|
||||
* - rua ("mailto:foo@bar.tld")
|
||||
*/
|
||||
public function provision(Domain $domain, ?string $dkimSelector = null, ?string $dkimTxt = null, array $opts = []): array
|
||||
{
|
||||
// Quelle der Hostnamen: ENV (du hast BASE_DOMAIN, UI_SUB, WEBMAIL_SUB, MTA_SUB)
|
||||
$baseDomain = env('BASE_DOMAIN', 'example.com');
|
||||
$uiHost = ($u = env('UI_SUB', 'ui')) ? "$u.$baseDomain" : $baseDomain;
|
||||
$webmail = ($w = env('WEBMAIL_SUB','webmail')) ? "$w.$baseDomain" : $baseDomain;
|
||||
$mtaHost = ($m = env('MTA_SUB','mx')) ? "$m.$baseDomain" : $baseDomain;
|
||||
// --- Defaults aus ENV/Config ---
|
||||
$opts = array_replace([
|
||||
'ipv4' => env('SERVER_PUBLIC_IPV4'),
|
||||
'ipv6' => env('SERVER_PUBLIC_IPV6'),
|
||||
'spf_tail' => config('mailpool.spf_tail', '~all'),
|
||||
'spf_extra' => [],
|
||||
'dmarc_policy' => config('mailpool.dmarc_policy', 'none'),
|
||||
'rua' => "mailto:dmarc@{$domain->domain}",
|
||||
], $opts);
|
||||
|
||||
$records = [];
|
||||
|
||||
// A/AAAA – nur als Anzeigehilfe (optional)
|
||||
$records[] = [
|
||||
'type' => 'A',
|
||||
'name' => $domain->domain,
|
||||
'value' => 'DEINE.SERVER.IP', // optional: aus Installer einsetzen
|
||||
'ttl' => 3600,
|
||||
];
|
||||
|
||||
// MX
|
||||
$records[] = [
|
||||
'type' => 'MX',
|
||||
'name' => $domain->domain,
|
||||
'value' => "10 $mtaHost.",
|
||||
'ttl' => 3600,
|
||||
];
|
||||
|
||||
// SPF
|
||||
$records[] = [
|
||||
'type' => 'TXT',
|
||||
'name' => $domain->domain,
|
||||
'value' => 'v=spf1 mx a -all',
|
||||
'ttl' => 3600,
|
||||
];
|
||||
|
||||
// DKIM (nimm den neuesten aktiven Key, falls vorhanden)
|
||||
/** @var DkimKey|null $dkim */
|
||||
$dkim = $domain->dkimKeys()->where('is_active', true)->latest()->first();
|
||||
if ($dkim) {
|
||||
$records[] = [
|
||||
'type' => 'TXT',
|
||||
'name' => "{$dkim->selector}._domainkey.{$domain->domain}",
|
||||
'value' => "v=DKIM1; k=rsa; p={$dkim->public_key_txt}",
|
||||
'ttl' => 3600,
|
||||
];
|
||||
// --- DKIM aus DB ziehen falls nicht übergeben ---
|
||||
if (!$dkimSelector || !$dkimTxt) {
|
||||
/** @var DkimKey|null $dk */
|
||||
$dk = $domain->dkimKeys()->where('is_active', true)->latest()->first();
|
||||
if ($dk) {
|
||||
$dkimSelector = $dk->selector;
|
||||
$dkimTxt = "v=DKIM1; k=rsa; p={$dk->public_key_txt}";
|
||||
}
|
||||
}
|
||||
|
||||
// DMARC (Default p=none – in UI änderbar)
|
||||
$records[] = [
|
||||
'type' => 'TXT',
|
||||
'name' => "_dmarc.{$domain->domain}",
|
||||
'value' => "v=DMARC1; p=none; rua=mailto:dmarc@{$domain->domain}; pct=100",
|
||||
'ttl' => 3600,
|
||||
];
|
||||
// --- SPF/DMARC in DB persistieren (oder aktualisieren) ---
|
||||
$spfTxt = $this->buildSpfTxt($domain, $opts);
|
||||
$dmarcTxt = $this->buildDmarcTxt($domain, $opts['dmarc_policy'], $opts['rua']);
|
||||
|
||||
// Optional: Webmail/UI CNAMEs
|
||||
$records[] = ['type'=>'CNAME','name'=>"webmail.{$domain->domain}",'value'=>"$webmail.",'ttl'=>3600];
|
||||
$records[] = ['type'=>'CNAME','name'=>"ui.{$domain->domain}", 'value'=>"$uiHost.", 'ttl'=>3600];
|
||||
// spf_records
|
||||
if (method_exists($domain, 'spf')) {
|
||||
$domain->spf()->updateOrCreate(
|
||||
['is_active' => true],
|
||||
['record_txt' => $spfTxt]
|
||||
);
|
||||
}
|
||||
|
||||
// dmarc_records
|
||||
if (method_exists($domain, 'dmarc')) {
|
||||
$domain->dmarc()->updateOrCreate(
|
||||
['policy' => $opts['dmarc_policy']],
|
||||
[
|
||||
'rua' => $opts['rua'],
|
||||
'pct' => 100,
|
||||
'record_txt' => $dmarcTxt,
|
||||
'is_active' => true,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// --- DNS-Empfehlungen berechnen ---
|
||||
$records = $this->buildForDomain($domain, array_merge($opts, [
|
||||
'dkim_selector' => $dkimSelector,
|
||||
'dkim_txt' => $dkimTxt,
|
||||
]));
|
||||
|
||||
// Optional in eine eigene dnsRecords()-Relation persistieren
|
||||
$this->persist($domain, $records);
|
||||
|
||||
return $records;
|
||||
}
|
||||
|
||||
/** SPF-String zusammenbauen (mx a [+extra] + tail) */
|
||||
public function buildSpfTxt(Domain $domain, array $opts = []): string
|
||||
{
|
||||
$tail = $opts['spf_tail'] ?? '~all';
|
||||
$extra = $opts['spf_extra'] ?? [];
|
||||
$parts = ['v=spf1', 'mx', 'a'];
|
||||
|
||||
// Falls Server-IP explizit – praktisch fürs On-Prem
|
||||
if (!empty($opts['ipv4'])) $parts[] = 'ip4:' . $opts['ipv4'];
|
||||
if (!empty($opts['ipv6'])) $parts[] = 'ip6:' . $opts['ipv6'];
|
||||
|
||||
foreach ($extra as $m) {
|
||||
$m = trim((string)$m);
|
||||
if ($m !== '') $parts[] = $m;
|
||||
}
|
||||
|
||||
$parts[] = $tail; // "~all" oder "-all"
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
/** DMARC-String bauen (p=policy; rua=mailto:..; pct=100) */
|
||||
public function buildDmarcTxt(Domain $domain, string $policy = 'none', string $rua = null): string
|
||||
{
|
||||
$rua = $rua ?: "mailto:dmarc@{$domain->domain}";
|
||||
return "v=DMARC1; p={$policy}; rua={$rua}; pct=100";
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// Ab hier deine vorhandenen Helfer (leicht erweitert):
|
||||
// -----------------------------------------------------------
|
||||
|
||||
public function buildForDomain(Domain $domain, array $opts = []): array
|
||||
{
|
||||
$baseDomain = env('BASE_DOMAIN', 'example.com');
|
||||
$uiSub = env('UI_SUB', 'ui');
|
||||
$webSub = env('WEBMAIL_SUB', 'webmail');
|
||||
$mxSub = env('MTA_SUB', 'mx');
|
||||
|
||||
$uiHost = $uiSub ? "{$uiSub}.{$baseDomain}" : $baseDomain;
|
||||
$webmail = $webSub ? "{$webSub}.{$baseDomain}" : $baseDomain;
|
||||
$mtaHost = $mxSub ? "{$mxSub}.{$baseDomain}" : $baseDomain;
|
||||
|
||||
$ipv4 = $opts['ipv4'] ?? null;
|
||||
$ipv6 = $opts['ipv6'] ?? null;
|
||||
|
||||
$spfTxt = $opts['spf_txt'] ?? $this->buildSpfTxt($domain, $opts);
|
||||
$dmarcTxt = $opts['dmarc_txt'] ?? $this->buildDmarcTxt($domain, $opts['dmarc_policy'] ?? 'none', $opts['rua'] ?? null);
|
||||
|
||||
$dkimSelector = $opts['dkim_selector'] ?? null;
|
||||
$dkimTxt = $opts['dkim_txt'] ?? null;
|
||||
|
||||
$R = fn($type,$name,$value,$ttl=3600) => compact('type','name','value','ttl');
|
||||
|
||||
if ($dkimSelector && is_string($dkimTxt)) {
|
||||
$dkimTxt = trim($dkimTxt);
|
||||
|
||||
// Falls nur Base64 geliefert: auf DKIM-Format heben
|
||||
if ($dkimTxt !== '' && !str_starts_with($dkimTxt, 'v=DKIM1')) {
|
||||
if (!preg_match('/^[A-Za-z0-9+\/=]+$/', $dkimTxt)) {
|
||||
Log::warning('DKIM TXT invalid chars', ['len'=>strlen($dkimTxt)]);
|
||||
$dkimTxt = ''; // hart ablehnen statt kaputt speichern
|
||||
} else {
|
||||
$dkimTxt = "v=DKIM1; k=rsa; p={$dkimTxt}";
|
||||
}
|
||||
}
|
||||
|
||||
if ($dkimTxt !== '') {
|
||||
$required[] = $R('TXT', "{$dkimSelector}._domainkey.{$domain->domain}", $dkimTxt);
|
||||
}
|
||||
}
|
||||
|
||||
$required = [
|
||||
$R('MX', $domain->domain, "10 {$mtaHost}."),
|
||||
$R('TXT', $domain->domain, $spfTxt),
|
||||
$R('TXT', "_dmarc.{$domain->domain}", $dmarcTxt),
|
||||
];
|
||||
|
||||
if ($dkimSelector && $dkimTxt) {
|
||||
$required[] = $R('TXT', "{$dkimSelector}._domainkey.{$domain->domain}", $dkimTxt);
|
||||
}
|
||||
|
||||
$optional = [
|
||||
$R('CAA', $domain->domain, '0 issue "letsencrypt.org"'),
|
||||
$R('CNAME', "webmail.{$domain->domain}", "{$webmail}."),
|
||||
$R('CNAME', "ui.{$domain->domain}", "{$uiHost}."),
|
||||
$R('SRV', "_submission._tcp.{$domain->domain}", "0 1 587 {$mtaHost}."),
|
||||
$R('SRV', "_imaps._tcp.{$domain->domain}", "0 1 993 {$mtaHost}."),
|
||||
$R('SRV', "_pop3s._tcp.{$domain->domain}", "0 1 995 {$mtaHost}."),
|
||||
$R('CNAME', "autoconfig.{$domain->domain}", "{$uiHost}."),
|
||||
$R('CNAME', "autodiscover.{$domain->domain}", "{$uiHost}."),
|
||||
];
|
||||
|
||||
if (method_exists($domain, 'tlsaRecords')) {
|
||||
$tlsa = $domain->tlsaRecords()
|
||||
->where('service', '_25._tcp')
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
if ($tlsa) {
|
||||
$optional[] = $R(
|
||||
'TLSA',
|
||||
"{$tlsa->service}.{$tlsa->host}",
|
||||
"{$tlsa->usage} {$tlsa->selector} {$tlsa->matching} {$tlsa->hash}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($ipv4) $optional[] = $R('A', $domain->domain, $ipv4);
|
||||
if ($ipv6) $optional[] = $R('AAAA', $domain->domain, $ipv6);
|
||||
|
||||
return ['required'=>$required,'optional'=>$optional];
|
||||
}
|
||||
|
||||
public function persist(Domain $domain, array $records): void
|
||||
{
|
||||
if (!method_exists($domain, 'dnsRecords')) return;
|
||||
|
||||
$upsert = function(array $rec) use ($domain) {
|
||||
$domain->dnsRecords()->updateOrCreate(
|
||||
['type'=>$rec['type'], 'name'=>$rec['name']],
|
||||
['value'=>$rec['value'], 'ttl'=>$rec['ttl'], 'is_managed'=>true]
|
||||
);
|
||||
};
|
||||
|
||||
foreach ($records['required'] ?? [] as $r) $upsert($r);
|
||||
foreach ($records['optional'] ?? [] as $r) $upsert($r);
|
||||
}
|
||||
|
||||
// public function buildForDomain(Domain $domain, array $opts = []): array
|
||||
// {
|
||||
// // ---- Aus ENV lesen (deine Installer-Variablen) ----
|
||||
// $baseDomain = env('BASE_DOMAIN', 'example.com');
|
||||
// $uiSub = env('UI_SUB', 'ui');
|
||||
// $webSub = env('WEBMAIL_SUB', 'webmail');
|
||||
// $mxSub = env('MTA_SUB', 'mx');
|
||||
//
|
||||
// // Ziel-Hosts (wohin die Kundendomain zeigen soll)
|
||||
// $uiHost = $uiSub ? "{$uiSub}.{$baseDomain}" : $baseDomain;
|
||||
// $webmail = $webSub ? "{$webSub}.{$baseDomain}" : $baseDomain;
|
||||
// $mtaHost = $mxSub ? "{$mxSub}.{$baseDomain}" : $baseDomain;
|
||||
//
|
||||
// // Public IPs (falls gesetzt; sonst leer -> nur Anzeige)
|
||||
// $ipv4 = $opts['ipv4'] ?? env('SERVER_PUBLIC_IPV4'); // z.B. vom Installer in .env geschrieben
|
||||
// $ipv6 = $opts['ipv6'] ?? env('SERVER_PUBLIC_IPV6');
|
||||
//
|
||||
// // Policies
|
||||
// $dmarcPolicy = $opts['dmarc_policy'] ?? 'none'; // none | quarantine | reject
|
||||
// $spfTail = $opts['spf_tail'] ?? '~all'; // ~all | -all (streng)
|
||||
//
|
||||
// // DKIM (neuester aktiver Key)
|
||||
// /** @var DkimKey|null $dkim */
|
||||
// $dkim = $domain->dkimKeys()->where('is_active', true)->latest()->first();
|
||||
// $dkimSelector = $dkim?->selector ?: 'dkim';
|
||||
// $dkimTxt = $dkim ? "v=DKIM1; k=rsa; p={$dkim->public_key_txt}" : null;
|
||||
//
|
||||
// // Helper
|
||||
// $R = fn(string $type, string $name, string $value, int $ttl = 3600) => [
|
||||
// 'type' => $type, 'name' => $name, 'value' => $value, 'ttl' => $ttl,
|
||||
// ];
|
||||
//
|
||||
// // ========== REQUIRED ==========
|
||||
// $required = [];
|
||||
//
|
||||
// // MX (zeigt auf dein globales MX-Host)
|
||||
// $required[] = $R('MX', $domain->domain, "10 {$mtaHost}.");
|
||||
//
|
||||
// // SPF: „mx“ + optional a (du hattest vorher -all; hier konfigurierbar)
|
||||
// $spf = trim("v=spf1 mx a {$spfTail}");
|
||||
// $required[] = $R('TXT', $domain->domain, $spf);
|
||||
//
|
||||
// // DKIM (nur wenn Key vorhanden)
|
||||
// if ($dkimTxt) {
|
||||
// $required[] = $R('TXT', "{$dkimSelector}._domainkey.{$domain->domain}", $dkimTxt);
|
||||
// }
|
||||
//
|
||||
// // DMARC (Default p=$dmarcPolicy + RUA)
|
||||
// $required[] = $R('TXT', "_dmarc.{$domain->domain}", "v=DMARC1; p={$dmarcPolicy}; rua=mailto:dmarc@{$domain->domain}; pct=100");
|
||||
//
|
||||
// // ========== OPTIONAL (empfohlen) ==========
|
||||
// $optional = [];
|
||||
//
|
||||
// // A/AAAA für Root – NUR falls du Root direkt terminierst (sonst weglassen)
|
||||
// if ($ipv4) $optional[] = $R('A', $domain->domain, $ipv4);
|
||||
// if ($ipv6) $optional[] = $R('AAAA', $domain->domain, $ipv6);
|
||||
//
|
||||
// // CAA für ACME/Let’s Encrypt
|
||||
// // 0 issue "letsencrypt.org" | optional: 0 iodef "mailto:admin@domain"
|
||||
// $optional[] = $R('CAA', $domain->domain, '0 issue "letsencrypt.org"');
|
||||
//
|
||||
// // CNAMEs für UI/Webmail in der Kundenzone -> zeigen auf deine globalen Hosts
|
||||
// $optional[] = $R('CNAME', "webmail.{$domain->domain}", "{$webmail}.");
|
||||
// $optional[] = $R('CNAME', "ui.{$domain->domain}", "{$uiHost}.");
|
||||
//
|
||||
// // SRV (nutzerfreundliche Autokonfigs)
|
||||
// // _submission._tcp STARTTLS Port 587
|
||||
// $optional[] = $R('SRV', "_submission._tcp.{$domain->domain}", "0 1 587 {$mtaHost}.");
|
||||
// // _imaps._tcp / _pop3s._tcp (falls aktiv)
|
||||
// $optional[] = $R('SRV', "_imaps._tcp.{$domain->domain}", "0 1 993 {$mtaHost}.");
|
||||
// $optional[] = $R('SRV', "_pop3s._tcp.{$domain->domain}", "0 1 995 {$mtaHost}.");
|
||||
//
|
||||
// // Autoconfig / Autodiscover (wenn du sie anbieten willst)
|
||||
// // CNAMEs auf deine UI/Webmail (oder A/AAAA, wenn du echte Subdomains je Kunde willst)
|
||||
// $optional[] = $R('CNAME', "autoconfig.{$domain->domain}", "{$uiHost}.");
|
||||
// $optional[] = $R('CNAME', "autodiscover.{$domain->domain}", "{$uiHost}.");
|
||||
//
|
||||
// return [
|
||||
// 'required' => $required,
|
||||
// 'optional' => $optional,
|
||||
// 'meta' => [
|
||||
// 'mx_target' => $mtaHost,
|
||||
// 'ui_target' => $uiHost,
|
||||
// 'webmail_target'=> $webmail,
|
||||
// 'dkim_selector' => $dkimSelector,
|
||||
// 'has_dkim' => (bool) $dkimTxt,
|
||||
// 'tips' => [
|
||||
// 'rDNS' => 'Reverse DNS der Server-IP sollte auf den MX-Host zeigen.',
|
||||
// ],
|
||||
// ],
|
||||
// ];
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * Optional: Empfohlene/benötigte Records in deiner DB speichern.
|
||||
// * Nutzt $domain->dnsRecords() falls vorhanden. Andernfalls einfach nicht verwenden.
|
||||
// */
|
||||
// public function persist(Domain $domain, array $records): void
|
||||
// {
|
||||
// if (!method_exists($domain, 'dnsRecords')) {
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// $upsert = function(array $rec) use ($domain) {
|
||||
// $domain->dnsRecords()->updateOrCreate(
|
||||
// ['type' => $rec['type'], 'name' => $rec['name']],
|
||||
// ['value' => $rec['value'], 'ttl' => $rec['ttl'], 'is_managed' => true]
|
||||
// );
|
||||
// };
|
||||
//
|
||||
// foreach (($records['required'] ?? []) as $r) $upsert($r);
|
||||
// foreach (($records['optional'] ?? []) as $r) $upsert($r);
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * Erzeugt empfohlene DNS-Records für eine neue Domain
|
||||
// * und speichert sie (falls Domain->dnsRecords() existiert).
|
||||
// */
|
||||
// public function createRecommendedRecords(
|
||||
// Domain $domain,
|
||||
// ?string $dkimSelector = null,
|
||||
// ?string $dkimTxt = null,
|
||||
// array $opts = []
|
||||
// ): array {
|
||||
// // Fallback, falls kein DKIM-Schlüssel übergeben wurde
|
||||
// if (!$dkimSelector || !$dkimTxt) {
|
||||
// $dkim = $domain->dkimKeys()->where('is_active', true)->latest()->first();
|
||||
// $dkimSelector ??= $dkim?->selector ?? 'dkim';
|
||||
// $dkimTxt ??= $dkim ? "v=DKIM1; k=rsa; p={$dkim->public_key_txt}" : null;
|
||||
// }
|
||||
//
|
||||
// // DNS-Empfehlungen generieren
|
||||
// $records = $this->buildForDomain($domain, array_merge($opts, [
|
||||
// 'dkim_selector' => $dkimSelector,
|
||||
// 'dkim_txt' => $dkimTxt,
|
||||
// ]));
|
||||
//
|
||||
// // Falls möglich -> in Datenbank persistieren
|
||||
// $this->persist($domain, $records);
|
||||
//
|
||||
// return $records;
|
||||
// }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Domain;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class MailStorage
|
||||
{
|
||||
/**
|
||||
* Basiswerte & abgeleitete Pools in MiB.
|
||||
* - percent_reserve enthält bereits die "Systemmail" (z. B. 5 % in deinen 15 %)
|
||||
* - fixed_reserve_mb ist eine fixe Sicherheitsmarge (z. B. 2048 MiB)
|
||||
* - committed_mb = Summe der Domain-Quotas (verplanter Speicher)
|
||||
*/
|
||||
public function stats(): array
|
||||
{
|
||||
$path = (string) config('mailpool.mail_data_path', '/var/mail');
|
||||
$totalMb = (int) floor((@disk_total_space($path) ?: 0) / 1048576);
|
||||
$freeMb = (int) floor((@disk_free_space($path) ?: 0) / 1048576);
|
||||
|
||||
$percentReserve = max(0, (int) config('mailpool.percent_reserve', 15)); // inkl. „Systemmail“
|
||||
$fixedReserveMb = max(0, (int) config('mailpool.fixed_reserve_mb', 2048));
|
||||
|
||||
// 1) Reserve auf *freiem* Platz (dein gewünschtes Verhalten)
|
||||
$percentReserveOnFreeMb = (int) round($freeMb * ($percentReserve / 100));
|
||||
$systemReserveFreeBaseMb = $fixedReserveMb + $percentReserveOnFreeMb;
|
||||
$freeAfterReserveMb = max(0, $freeMb - $systemReserveFreeBaseMb);
|
||||
|
||||
// 2) Reserve auf *Gesamt*platz (Kapazitätsbremse gegen Überbuchung)
|
||||
$percentReserveOnTotalMb = (int) round($totalMb * ($percentReserve / 100));
|
||||
$systemReserveTotalMb = $fixedReserveMb + $percentReserveOnTotalMb;
|
||||
|
||||
// bereits „verplant“: Summe der Domain-Quotas (achte auf Spaltennamen!)
|
||||
$committedMb = (int) Domain::where('is_active', true)->sum('total_quota_mb');
|
||||
|
||||
// Kapazität, die du *maximal* noch als Quotas verteilen darfst
|
||||
$capacityLeftMb = max(0, $totalMb - $systemReserveTotalMb - $committedMb);
|
||||
|
||||
// Für Eingabefelder konservativ das Minimum verwenden
|
||||
$remainingPoolMb = min($freeAfterReserveMb, $capacityLeftMb);
|
||||
|
||||
return [
|
||||
'path' => $path,
|
||||
'total_mb' => $totalMb,
|
||||
'free_mb' => $freeMb,
|
||||
|
||||
'fixed_reserve_mb' => $fixedReserveMb,
|
||||
'percent_reserve' => $percentReserve,
|
||||
|
||||
'percent_reserve_on_free_mb' => $percentReserveOnFreeMb,
|
||||
'free_after_reserve_mb' => $freeAfterReserveMb, // ≈ 70 GB bei deinen Werten
|
||||
|
||||
'percent_reserve_on_total_mb' => $percentReserveOnTotalMb,
|
||||
'system_reserve_total_mb' => $systemReserveTotalMb,
|
||||
|
||||
'committed_mb' => $committedMb,
|
||||
'capacity_left_mb' => $capacityLeftMb, // Kapazitätsbremse
|
||||
|
||||
'remaining_pool_mb' => $remainingPoolMb, // MIN(free-basiert, total-basiert)
|
||||
];
|
||||
}
|
||||
|
||||
/** Für UI: konservativer, gecachter Rest-Pool (in MiB) */
|
||||
public function remainingPoolMb(): int
|
||||
{
|
||||
return Cache::remember('mailpool.remaining', 10, fn () => $this->stats()['remaining_pool_mb']);
|
||||
}
|
||||
|
||||
/** Vorschlag für max. Eingabewert */
|
||||
public function suggestMaxAllocMb(): int
|
||||
{
|
||||
return $this->remainingPoolMb();
|
||||
}
|
||||
|
||||
/** Prüfen, ob X MiB sauber in den Pool passen */
|
||||
public function canAllocate(int $mb): bool
|
||||
{
|
||||
return $mb >= 0 && $mb <= $this->remainingPoolMb();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
use App\Models\Domain;
|
||||
use App\Models\TlsaRecord;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TlsaService
|
||||
{
|
||||
/**
|
||||
* Erstellt/aktualisiert TLSA für SMTP (_25._tcp) am MTA-Host.
|
||||
* host = <MTA_SUB>.<BASE_DOMAIN> (z.B. mx.example.com)
|
||||
*/
|
||||
public function createForDomain(Domain $domain): void
|
||||
{
|
||||
$mtaHost = (env('MTA_SUB', 'mx') ?: 'mx') . '.' . env('BASE_DOMAIN', 'example.com');
|
||||
$service = '_25._tcp';
|
||||
$certPath = "/etc/letsencrypt/live/{$mtaHost}/fullchain.pem";
|
||||
|
||||
if (!is_file($certPath)) {
|
||||
Log::warning("TLSA skipped: Zertifikat fehlt: {$certPath}");
|
||||
return;
|
||||
}
|
||||
|
||||
// SHA256 über SubjectPublicKeyInfo
|
||||
$cmd = "openssl x509 -in ".escapeshellarg($certPath)." -noout -pubkey"
|
||||
. " | openssl pkey -pubin -outform DER"
|
||||
. " | openssl dgst -sha256 | awk '{print \$2}'";
|
||||
$hash = trim((string)@shell_exec($cmd));
|
||||
|
||||
if ($hash === '') {
|
||||
Log::error("TLSA failed: Hash konnte nicht berechnet werden ({$mtaHost})");
|
||||
return;
|
||||
}
|
||||
|
||||
TlsaRecord::updateOrCreate(
|
||||
['domain_id' => $domain->id, 'service' => $service, 'host' => $mtaHost],
|
||||
[
|
||||
'usage' => 3, // DANE-EE
|
||||
'selector' => 1, // SPKI
|
||||
'matching' => 1, // SHA-256
|
||||
'hash' => $hash,
|
||||
'cert_path'=> $certPath,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class NetProbe
|
||||
{
|
||||
public static function resolve(bool $force = false): array
|
||||
{
|
||||
return [
|
||||
'ipv4' => self::getIPv4($force),
|
||||
'ipv6' => self::getIPv6($force),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getIPv4(bool $force = false): ?string
|
||||
{
|
||||
// 1) ENV zuerst
|
||||
$env = trim((string) env('SERVER_PUBLIC_IPV4', ''));
|
||||
if ($env !== '' && filter_var($env, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
return $env;
|
||||
}
|
||||
|
||||
// 2) Cache
|
||||
if (!$force) {
|
||||
$c = Cache::get('netprobe:ipv4');
|
||||
if ($c) return $c === 'none' ? null : $c;
|
||||
}
|
||||
|
||||
// 3) Probe (einmalig)
|
||||
$ip = self::probeIPv4();
|
||||
Cache::put('netprobe:ipv4', $ip ?: 'none', now()->addDay());
|
||||
|
||||
return $ip;
|
||||
}
|
||||
|
||||
public static function getIPv6(bool $force = false): ?string
|
||||
{
|
||||
// 1) ENV zuerst
|
||||
$env = trim((string) env('SERVER_PUBLIC_IPV6', ''));
|
||||
if ($env !== '' && filter_var($env, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
|
||||
return $env;
|
||||
}
|
||||
|
||||
// 2) Cache mit Sentinel „none“ (verhindert Endlossuche)
|
||||
if (!$force) {
|
||||
$c = Cache::get('netprobe:ipv6');
|
||||
if ($c) return $c === 'none' ? null : $c;
|
||||
}
|
||||
|
||||
// 3) Probe (einmalig)
|
||||
$ip = self::probeIPv6();
|
||||
Cache::put('netprobe:ipv6', $ip ?: 'none', now()->addDay());
|
||||
|
||||
return $ip;
|
||||
}
|
||||
|
||||
// — intern —
|
||||
|
||||
protected static function probeIPv4(): ?string
|
||||
{
|
||||
$out = @shell_exec("ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if(\$i==\"src\"){print \$(i+1); exit}}'");
|
||||
$ip = trim((string) $out);
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) return $ip;
|
||||
|
||||
$ip = trim($_SERVER['SERVER_ADDR'] ?? '');
|
||||
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) ? $ip : null;
|
||||
}
|
||||
|
||||
protected static function probeIPv6(): ?string
|
||||
{
|
||||
// Variante 1: „Standard“-Weg über Routing
|
||||
$out = @shell_exec("ip -6 route get 2001:4860:4860::8888 2>/dev/null | awk '{for(i=1;i<=NF;i++) if(\$i==\"src\"){print \$(i+1); exit}}'");
|
||||
$ip = trim((string) $out);
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) return $ip;
|
||||
|
||||
// Variante 2: irgendeine globale v6 vom Interface nehmen (keine ::1, keine link-local fe80:)
|
||||
$out = @shell_exec("ip -6 addr show scope global 2>/dev/null | awk '/inet6/ && $2 !~ /::1/ && $2 !~ /^fe80:/ {print $2; exit}'");
|
||||
$ip = trim((string) $out);
|
||||
if ($ip !== '') $ip = strtok($ip, '/'); // Prefix entfernen
|
||||
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? $ip : null;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -7,52 +7,29 @@ use Illuminate\Support\Facades\Cache;
|
|||
|
||||
class SettingsRepository
|
||||
{
|
||||
const CACHE_KEY = 'settings.all';
|
||||
|
||||
protected function store()
|
||||
public function get(string $name, $default = null)
|
||||
{
|
||||
// zieht z.B. 'redis' aus .env: CACHE_SETTINGS_STORE=redis
|
||||
$store = env('CACHE_SETTINGS_STORE', 'redis');
|
||||
return Cache::store($store);
|
||||
return Setting::get($name, $default);
|
||||
}
|
||||
|
||||
protected function loadAll(): array
|
||||
public function set(string $name, $value): void
|
||||
{
|
||||
try {
|
||||
/** @var array<string,string> $settings */
|
||||
$settings = $this->store()->rememberForever(self::CACHE_KEY, function (): array {
|
||||
return Setting::query()
|
||||
->pluck('value', 'key')
|
||||
->toArray();
|
||||
});
|
||||
|
||||
return $settings;
|
||||
} catch (\Throwable $e) {
|
||||
return Setting::query()->pluck('value', 'key')->toArray();
|
||||
}
|
||||
Setting::set($name, $value);
|
||||
}
|
||||
|
||||
public function get(string $key, $default = null)
|
||||
public function forget(string $name): void
|
||||
{
|
||||
$all = $this->loadAll();
|
||||
return array_key_exists($key, $all) ? $all[$key] : $default;
|
||||
Setting::forget($name);
|
||||
}
|
||||
|
||||
public function set(string $key, $value): void
|
||||
public function all(): array
|
||||
{
|
||||
Setting::query()->updateOrCreate(['key' => $key], ['value' => $value]);
|
||||
|
||||
// Cache refreshen
|
||||
try {
|
||||
$all = Setting::query()->pluck('value', 'key')->toArray();
|
||||
$this->store()->forever(self::CACHE_KEY, $all);
|
||||
} catch (\Throwable $e) {
|
||||
// Cache kaputt? Ignorieren, DB ist aktualisiert.
|
||||
}
|
||||
}
|
||||
|
||||
public function forgetCache(): void
|
||||
{
|
||||
try { $this->store()->forget(self::CACHE_KEY); } catch (\Throwable $e) {}
|
||||
return Setting::query()
|
||||
->select('group', 'key', 'value')
|
||||
->get()
|
||||
->mapWithKeys(function ($row) {
|
||||
return ["{$row->group}.{$row->key}" => $row->value];
|
||||
})
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ return [
|
|||
|
|
||||
*/
|
||||
|
||||
'timezone' => 'UTC',
|
||||
'timezone' => env('APP_TIMEZONE', 'UTC'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
@ -78,7 +78,7 @@ return [
|
|||
|
|
||||
*/
|
||||
|
||||
'locale' => env('APP_LOCALE', 'en'),
|
||||
'locale' => env('APP_LOCALE', 'de'),
|
||||
|
||||
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||
|
||||
|
|
@ -124,5 +124,4 @@ return [
|
|||
],
|
||||
|
||||
'version' => env('APP_VERSION', '1.0.0')
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
// config/mailpool.php
|
||||
return [
|
||||
'platform_zone' => env('BASE_DOMAIN', 'example.com'),
|
||||
'platform_system_zone' => env('MAILPOOL_PLATFORM_SYSTEM_ZONE', 'sysmail'),
|
||||
|
||||
'fixed_reserve_mb' => env('MAILPOOL_FIXED_RESERVE_MB', 2048), // 2 GB
|
||||
'percent_reserve' => env('MAILPOOL_PERCENT_RESERVE', 15), // 15 %
|
||||
'mail_data_path' => env('MAILPOOL_PATH', '/var/mail'),
|
||||
|
||||
'spf_tail' => env('MAILPOOL_SPF_TAIL', '~all'),
|
||||
'spf_extra' => array_filter(explode(',', env('MAILPOOL_SPF_EXTRA', ''))),
|
||||
'dmarc_policy' => env('MAILPOOL_DMARC_POLICY', 'none'),
|
||||
|
||||
'defaults' => [
|
||||
'max_aliases' => (int) env('MAILPOOL_DEFAULT_MAX_ALIASES', 400),
|
||||
'max_mailboxes' => (int) env('MAILPOOL_DEFAULT_MAX_MAILBOXES', 10),
|
||||
|
||||
'default_quota_mb' => (int) env('MAILPOOL_DEFAULT_MAILBOX_QUOTA_MB', 3072),
|
||||
'max_quota_per_mailbox_mb' => env('MAILPOOL_DEFAULT_MAX_MAILBOX_QUOTA_MB', null),
|
||||
'total_quota_mb' => (int) env('MAILPOOL_DEFAULT_DOMAIN_TOTAL_MB', 10240),
|
||||
|
||||
'rate_limit_per_hour' => env('MAILPOOL_DEFAULT_RATE_LIMIT_PER_HOUR', null),
|
||||
'rate_limit_override' => (bool) env('MAILPOOL_DEFAULT_RATE_OVERRIDE', false),
|
||||
],
|
||||
];
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
|
||||
# ACME
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /var/www/letsencrypt;
|
||||
allow all;
|
||||
}
|
||||
|
||||
# Wenn SSL da: redirect auf 443, sonst direkt App
|
||||
{% if ssl %}
|
||||
return 301 https://$host$request_uri;
|
||||
{% endif %}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl${NGINX_HTTP2_SUFFIX};
|
||||
listen [::]:443 ssl${NGINX_HTTP2_SUFFIX};
|
||||
|
||||
ssl_certificate ${UI_CERT};
|
||||
ssl_certificate_key ${UI_KEY};
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
|
||||
server_name _;
|
||||
|
||||
root ${APP_DIR}/public;
|
||||
index index.php index.html;
|
||||
|
||||
access_log /var/log/nginx/app_ssl_access.log;
|
||||
error_log /var/log/nginx/app_ssl_error.log;
|
||||
|
||||
client_max_body_size 25m;
|
||||
|
||||
location / { try_files $uri $uri/ /index.php?$query_string; }
|
||||
location ~ \.php$ {
|
||||
include snippets/fastcgi-php.conf;
|
||||
# Der pass (unix vs tcp) wird vom System gesetzt; Debian snippet kümmert sich
|
||||
fastcgi_pass unix:/run/php/php-fpm.sock;
|
||||
try_files $uri =404;
|
||||
}
|
||||
location ^~ /livewire/ { try_files $uri /index.php?$query_string; }
|
||||
location ~* \.(jpg|jpeg|png|gif|css|js|ico|svg)$ { expires 30d; access_log off; }
|
||||
|
||||
# WebSocket: Laravel Reverb
|
||||
location /ws/ {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_pass http://127.0.0.1:8080/;
|
||||
}
|
||||
|
||||
# Reverb HTTP API
|
||||
location /apps/ {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_pass http://127.0.0.1:8080/apps/;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'locales' => [
|
||||
['value' => 'de', 'label' => 'Deutsch'],
|
||||
['value' => 'en', 'label' => 'English'],
|
||||
],
|
||||
// keep this short list or pull a full IANA list if you want
|
||||
'timezones' => [
|
||||
// 🌍 UTC / Universal
|
||||
'UTC',
|
||||
|
||||
// 🇪🇺 Europa
|
||||
'Europe/Amsterdam',
|
||||
'Europe/Andorra',
|
||||
'Europe/Athens',
|
||||
'Europe/Belgrade',
|
||||
'Europe/Berlin',
|
||||
'Europe/Bratislava',
|
||||
'Europe/Brussels',
|
||||
'Europe/Bucharest',
|
||||
'Europe/Budapest',
|
||||
'Europe/Copenhagen',
|
||||
'Europe/Dublin',
|
||||
'Europe/Gibraltar',
|
||||
'Europe/Helsinki',
|
||||
'Europe/Istanbul',
|
||||
'Europe/Kiev',
|
||||
'Europe/Lisbon',
|
||||
'Europe/Ljubljana',
|
||||
'Europe/London',
|
||||
'Europe/Luxembourg',
|
||||
'Europe/Madrid',
|
||||
'Europe/Malta',
|
||||
'Europe/Monaco',
|
||||
'Europe/Moscow',
|
||||
'Europe/Oslo',
|
||||
'Europe/Paris',
|
||||
'Europe/Prague',
|
||||
'Europe/Riga',
|
||||
'Europe/Rome',
|
||||
'Europe/San_Marino',
|
||||
'Europe/Sarajevo',
|
||||
'Europe/Skopje',
|
||||
'Europe/Sofia',
|
||||
'Europe/Stockholm',
|
||||
'Europe/Tallinn',
|
||||
'Europe/Tirane',
|
||||
'Europe/Vaduz',
|
||||
'Europe/Vatican',
|
||||
'Europe/Vienna',
|
||||
'Europe/Vilnius',
|
||||
'Europe/Warsaw',
|
||||
'Europe/Zurich',
|
||||
|
||||
// 🇺🇸 Nordamerika
|
||||
'America/New_York',
|
||||
'America/Chicago',
|
||||
'America/Denver',
|
||||
'America/Los_Angeles',
|
||||
'America/Phoenix',
|
||||
'America/Anchorage',
|
||||
'America/Honolulu',
|
||||
'America/Toronto',
|
||||
'America/Vancouver',
|
||||
'America/Mexico_City',
|
||||
'America/Bogota',
|
||||
'America/Lima',
|
||||
'America/Caracas',
|
||||
|
||||
// 🌎 Südamerika
|
||||
'America/Argentina/Buenos_Aires',
|
||||
'America/Sao_Paulo',
|
||||
'America/Montevideo',
|
||||
'America/Asuncion',
|
||||
'America/La_Paz',
|
||||
'America/Lima',
|
||||
'America/Bogota',
|
||||
'America/Santiago',
|
||||
|
||||
// 🌍 Afrika
|
||||
'Africa/Abidjan',
|
||||
'Africa/Accra',
|
||||
'Africa/Addis_Ababa',
|
||||
'Africa/Algiers',
|
||||
'Africa/Cairo',
|
||||
'Africa/Casablanca',
|
||||
'Africa/Dakar',
|
||||
'Africa/Dar_es_Salaam',
|
||||
'Africa/Johannesburg',
|
||||
'Africa/Khartoum',
|
||||
'Africa/Lagos',
|
||||
'Africa/Nairobi',
|
||||
'Africa/Tunis',
|
||||
'Africa/Windhoek',
|
||||
|
||||
// 🇷🇺 Russland / Asien
|
||||
'Asia/Almaty',
|
||||
'Asia/Amman',
|
||||
'Asia/Baghdad',
|
||||
'Asia/Baku',
|
||||
'Asia/Bangkok',
|
||||
'Asia/Beirut',
|
||||
'Asia/Colombo',
|
||||
'Asia/Damascus',
|
||||
'Asia/Dhaka',
|
||||
'Asia/Dubai',
|
||||
'Asia/Hong_Kong',
|
||||
'Asia/Jakarta',
|
||||
'Asia/Jerusalem',
|
||||
'Asia/Kabul',
|
||||
'Asia/Karachi',
|
||||
'Asia/Kathmandu',
|
||||
'Asia/Kolkata',
|
||||
'Asia/Kuala_Lumpur',
|
||||
'Asia/Kuwait',
|
||||
'Asia/Macau',
|
||||
'Asia/Manila',
|
||||
'Asia/Muscat',
|
||||
'Asia/Nicosia',
|
||||
'Asia/Novosibirsk',
|
||||
'Asia/Qatar',
|
||||
'Asia/Riyadh',
|
||||
'Asia/Seoul',
|
||||
'Asia/Shanghai',
|
||||
'Asia/Singapore',
|
||||
'Asia/Taipei',
|
||||
'Asia/Tashkent',
|
||||
'Asia/Tehran',
|
||||
'Asia/Tokyo',
|
||||
'Asia/Ulaanbaatar',
|
||||
'Asia/Vientiane',
|
||||
'Asia/Yangon',
|
||||
|
||||
// 🇦🇺 Australien & Ozeanien
|
||||
'Australia/Adelaide',
|
||||
'Australia/Brisbane',
|
||||
'Australia/Darwin',
|
||||
'Australia/Hobart',
|
||||
'Australia/Melbourne',
|
||||
'Australia/Perth',
|
||||
'Australia/Sydney',
|
||||
'Pacific/Auckland',
|
||||
'Pacific/Fiji',
|
||||
'Pacific/Guam',
|
||||
'Pacific/Honolulu',
|
||||
'Pacific/Noumea',
|
||||
'Pacific/Port_Moresby',
|
||||
'Pacific/Samoa',
|
||||
'Pacific/Tahiti',
|
||||
|
||||
// 🇨🇳 Zentral-/Ostasien
|
||||
'Asia/Chongqing',
|
||||
'Asia/Harbin',
|
||||
'Asia/Makassar',
|
||||
'Asia/Urumqi',
|
||||
|
||||
// 🌍 Mittlerer Osten
|
||||
'Asia/Bahrain',
|
||||
'Asia/Doha',
|
||||
'Asia/Dubai',
|
||||
'Asia/Kuwait',
|
||||
'Asia/Muscat',
|
||||
'Asia/Qatar',
|
||||
'Asia/Riyadh',
|
||||
|
||||
// ❄️ Arktis / Antarktis
|
||||
'Antarctica/Casey',
|
||||
'Antarctica/Davis',
|
||||
'Antarctica/Mawson',
|
||||
'Antarctica/Palmer',
|
||||
'Antarctica/Rothera',
|
||||
'Antarctica/Syowa',
|
||||
'Antarctica/Troll',
|
||||
'Antarctica/Vostok',
|
||||
|
||||
// 🔁 Sonstige
|
||||
'Atlantic/Azores',
|
||||
'Atlantic/Bermuda',
|
||||
'Atlantic/Canary',
|
||||
'Atlantic/Cape_Verde',
|
||||
'Atlantic/Faroe',
|
||||
'Atlantic/Madeira',
|
||||
'Atlantic/Reykjavik',
|
||||
'Atlantic/South_Georgia',
|
||||
'Indian/Maldives',
|
||||
'Indian/Mauritius',
|
||||
'Indian/Reunion',
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -24,6 +24,8 @@ return new class extends Migration
|
|||
$table->boolean('two_factor_email_enabled')->default(false);
|
||||
$table->string('totp_secret')->nullable();
|
||||
$table->string('role', 32)->default('admin')->index();
|
||||
$table->string('locale', 10)->nullable()->index();
|
||||
$table->string('timezone', 64)->nullable();
|
||||
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
|
|
|
|||
|
|
@ -14,8 +14,17 @@ return new class extends Migration
|
|||
Schema::create('domains', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('domain', 191)->unique();
|
||||
$table->string('description', 500)->nullable();
|
||||
$table->string('tags', 500)->nullable();
|
||||
$table->boolean('is_active')->default(true)->index();
|
||||
$table->boolean('is_system')->default(false);
|
||||
$table->unsignedInteger('max_aliases')->default(400);
|
||||
$table->unsignedInteger('max_mailboxes')->default(10);
|
||||
$table->unsignedInteger('default_quota_mb')->default(3072);
|
||||
$table->unsignedInteger('max_quota_per_mailbox_mb')->default(10240);
|
||||
$table->unsignedBigInteger('total_quota_mb')->default(0);
|
||||
$table->unsignedInteger('rate_limit_per_hour')->nullable();
|
||||
$table->boolean('rate_limit_override')->default(false);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ return new class extends Migration
|
|||
|
||||
$table->string('localpart', 191);
|
||||
$table->string('email', 191)->unique('mail_users_email_unique'); // genau EIN Unique-Index
|
||||
$table->string('display_name', 191)->nullable();
|
||||
$table->string('password_hash')->nullable(); // system-Accounts dürfen null sein
|
||||
|
||||
$table->boolean('is_system')->default(false)->index(); // oft nach Systemkonten filtern
|
||||
|
|
@ -26,6 +27,7 @@ return new class extends Migration
|
|||
$table->boolean('must_change_pw')->default(true)->index();
|
||||
|
||||
$table->unsignedInteger('quota_mb')->default(0); // 0 = unlimited
|
||||
$table->unsignedInteger('rate_limit_per_hour')->nullable();
|
||||
$table->timestamp('last_login_at')->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
|
|
|
|||
|
|
@ -14,12 +14,14 @@ return new class extends Migration
|
|||
Schema::create('mail_aliases', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('domain_id')->constrained('domains')->cascadeOnDelete();
|
||||
$table->string('source', 191)->index(); // z.B. sales@example.com
|
||||
$table->string('destination'); // kommasepariert: "u1@ex.com,u2@ex.com"
|
||||
$table->string('local', 191); // z.B. "info"
|
||||
$table->enum('type', ['single','group'])->default('single');
|
||||
$table->string('group_name', 80)->nullable();
|
||||
$table->boolean('is_active')->default(true)->index();
|
||||
$table->text('notes')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['domain_id','source']);
|
||||
$table->unique(['domain_id', 'local']);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,9 +13,12 @@ return new class extends Migration
|
|||
{
|
||||
Schema::create('settings', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('key')->unique();
|
||||
$table->string('group')->default('system')->index();
|
||||
$table->string('key');
|
||||
$table->text('value')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['group', 'key']);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ return new class extends Migration
|
|||
public function up(): void
|
||||
{
|
||||
Schema::create('tlsa_records', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->id();
|
||||
$table->foreignId('domain_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('service')->default('_25._tcp');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
<?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::create('mail_alias_recipients', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
$table->foreignId('alias_id')
|
||||
->constrained('mail_aliases')
|
||||
->cascadeOnDelete();
|
||||
|
||||
// interner Empfänger (MailUser) ODER externer Empfänger (E-Mail)
|
||||
$table->foreignId('mail_user_id')
|
||||
->nullable()
|
||||
->constrained('mail_users') // <-- richtige Tabelle!
|
||||
->nullOnDelete();
|
||||
|
||||
$table->string('email', 320)->nullable(); // externer Empfänger
|
||||
$table->unsignedSmallInteger('position')->default(0);
|
||||
$table->timestamps();
|
||||
|
||||
// Duplikate vermeiden:
|
||||
$table->unique(['alias_id','mail_user_id']);
|
||||
$table->unique(['alias_id','email']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('mail_alias_recipients');
|
||||
}
|
||||
};
|
||||
|
|
@ -7,6 +7,7 @@ use App\Models\DmarcRecord;
|
|||
use App\Models\Domain;
|
||||
use App\Models\MailUser;
|
||||
use App\Models\SpfRecord;
|
||||
use App\Services\DnsRecordService;
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
|
|
@ -14,13 +15,13 @@ class SystemDomainSeeder extends Seeder
|
|||
{
|
||||
public function run(): void
|
||||
{
|
||||
$base = config('app.base_domain', env('BASE_DOMAIN', 'example.com'));
|
||||
$base = config('mailpool.platform_zone', 'example.com');
|
||||
if (!$base || $base === 'example.com') {
|
||||
$this->command->warn("BASE_DOMAIN ist 'example.com' – Seeder überspringt produktive Einträge.");
|
||||
return;
|
||||
}
|
||||
|
||||
$systemSub = env('SYSTEM_SUB', 'system');
|
||||
$systemSub = config('mailpool.platform_system_zone');
|
||||
$base = "{$systemSub}.{$base}";
|
||||
|
||||
// Domain anlegen/holen
|
||||
|
|
@ -33,13 +34,13 @@ class SystemDomainSeeder extends Seeder
|
|||
MailUser::firstOrCreate(
|
||||
['email' => "no-reply@{$base}"],
|
||||
[
|
||||
'domain_id' => $domain->id,
|
||||
'localpart' => 'no-reply',
|
||||
'password_hash' => null,
|
||||
'is_active' => true,
|
||||
'is_system' => true,
|
||||
'must_change_pw' => false,
|
||||
'quota_mb' => 0,
|
||||
'domain_id' => $domain->id,
|
||||
'localpart' => 'no-reply',
|
||||
'password_hash' => null,
|
||||
'is_active' => true,
|
||||
'is_system' => true,
|
||||
'must_change_pw' => false,
|
||||
'quota_mb' => 0,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -59,27 +60,21 @@ class SystemDomainSeeder extends Seeder
|
|||
$this->command->info("DKIM angelegt: Host = {$selector}._domainkey.{$base}");
|
||||
}
|
||||
|
||||
// SPF – einfachen Default bauen
|
||||
$serverIp = env('SERVER_IP'); // optional vom Installer rein schreiben
|
||||
$parts = ['v=spf1','mx','a'];
|
||||
if ($serverIp) $parts[] = "ip4:{$serverIp}";
|
||||
$parts[] = '-all';
|
||||
$spf = implode(' ', $parts);
|
||||
$dk = $domain->dkimKeys()->where('is_active', true)->latest()->first();
|
||||
$dkimTxt = $dk ? "v=DKIM1; k=rsa; p={$dk->public_key_txt}" : null;
|
||||
|
||||
SpfRecord::firstOrCreate(
|
||||
['domain_id' => $domain->id, 'record_txt' => $spf],
|
||||
['is_active' => true]
|
||||
app(DnsRecordService::class)->provision(
|
||||
$domain,
|
||||
dkimSelector: $dk?->selector,
|
||||
dkimTxt: $dkimTxt,
|
||||
opts: [
|
||||
'dmarc_policy' => 'none',
|
||||
'spf_tail' => '-all',
|
||||
// optional: 'ipv4' => $serverIp, 'ipv6' => ...
|
||||
]
|
||||
);
|
||||
|
||||
// DMARC – vorsichtig starten (p=none)
|
||||
$rua = "mailto:dmarc@{$base}";
|
||||
$dmarc = DmarcRecord::firstOrCreate(
|
||||
['domain_id' => $domain->id, 'policy' => 'none'],
|
||||
['rua' => $rua, 'pct' => 100, 'record_txt' => "v=DMARC1; p=none; rua={$rua}; pct=100", 'is_active' => true]
|
||||
);
|
||||
|
||||
$this->command->info("System-Domain '{$base}' fertig. SPF/DMARC/DKIM eingetragen.");
|
||||
$this->command->line("DNS-Hinweise:");
|
||||
$this->command->info("System-Domain '{$base}' fertig. SPF/DMARC/DKIM & DNS-Empfehlungen eingetragen.");
|
||||
$this->printDnsHints($domain);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
"": {
|
||||
"dependencies": {
|
||||
"@phosphor-icons/web": "^2.1.2",
|
||||
"@tailwindplus/elements": "^1.0.17",
|
||||
"jquery": "^3.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -1123,6 +1124,12 @@
|
|||
"vite": "^5.2.0 || ^6 || ^7"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindplus/elements": {
|
||||
"version": "1.0.17",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindplus/elements/-/elements-1.0.17.tgz",
|
||||
"integrity": "sha512-t44plxXLWqD2Cl3IOcoVzBrmtFl6jkG2QmtrTiJHRh4tRCQwbipn93yvAqhso1+gm6y6zaiKyb3CCQ94K3qy3g==",
|
||||
"license": "SEE LICENSE IN LICENSE.md"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@phosphor-icons/web": "^2.1.2",
|
||||
"@tailwindplus/elements": "^1.0.17",
|
||||
"jquery": "^3.7.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,18 @@
|
|||
|
||||
}
|
||||
|
||||
.safe-pads {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
}
|
||||
/* Falls nötig: dvh-Fallback */
|
||||
@supports (height: 100dvh) {
|
||||
.min-h-dvh { min-height: 100dvh; }
|
||||
.h-dvh { height: 100dvh; }
|
||||
}
|
||||
|
||||
@utility w-sb-col {
|
||||
width: var(--sidebar-collapsed);
|
||||
}
|
||||
|
|
@ -89,6 +101,7 @@ html[data-ui="booting"] * {
|
|||
}
|
||||
|
||||
/* Labels, Carets, Submenüs in der Sidebar während booting NICHT zeigen */
|
||||
html[data-ui="booting"] #main,
|
||||
html[data-ui="booting"] #sidebar .sidebar-label,
|
||||
html[data-ui="booting"] #sidebar .sidebar-caret,
|
||||
html[data-ui="booting"] #sidebar [data-submenu] {
|
||||
|
|
@ -164,17 +177,61 @@ html[data-ui="ready"] #sidebar {
|
|||
display: none !important;
|
||||
}
|
||||
|
||||
/* === App Backdrop (türkis/glass) === */
|
||||
.app-backdrop {
|
||||
/*html { background-color: #132835; }*/
|
||||
html { background-color: #0a0f18; }
|
||||
body { background: transparent; }
|
||||
|
||||
/* EIN Hintergrund für alles – fix hinter dem Inhalt */
|
||||
.app-backdrop:before{
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -10;
|
||||
z-index: 0; /* unter dem Inhalt, aber über html */
|
||||
pointer-events: none;
|
||||
background: radial-gradient(1600px 900px at 85% -15%, rgba(34, 211, 238, .28), transparent 62%),
|
||||
radial-gradient(1200px 800px at 10% 112%, rgba(16, 185, 129, .18), transparent 70%),
|
||||
linear-gradient(180deg, #0a0f16, #0f172a 52%, #0b1220 78%, #0a0f18);
|
||||
|
||||
/* iOS stabil: deckt große/kleine Viewports ab */
|
||||
height: 100lvh; /* large viewport height */
|
||||
min-height: 100svh; /* safe viewport height Fallback */
|
||||
|
||||
/* Dein gesamter Verlauf hier – durchgehend! */
|
||||
background-image:
|
||||
radial-gradient(120vmax 70vmax at 80% 40%, rgba(34,211,238,.28), transparent 45%),
|
||||
radial-gradient(110vmax 70vmax at 20% 60%, rgba(34,211,238,.28), transparent 45%),
|
||||
/*radial-gradient(110vmax 80vmax at 20% 62%, rgba(16,185,129,.18), transparent 45%),*/
|
||||
/*linear-gradient(180deg, rgba(8,47,73,.18), rgba(2,6,23,.28)), !* Farbbelag *!*/
|
||||
linear-gradient(180deg, #0a0f16, #0f172a 52%, #0b1220 78%, #0a0f18);
|
||||
|
||||
/*radial-gradient(120vmax 70vmax at 80% -10%, rgba(34,211,238,.28), transparent 62%),*/
|
||||
/*radial-gradient(110vmax 80vmax at 10% 112%, rgba(16,185,129,.18), transparent 70%),*/
|
||||
/*linear-gradient(180deg, #0a0f16, #0f172a 52%, #0b1220 78%, #0a0f18);*/
|
||||
/*radial-gradient(1600px 900px at 85% -15%, rgba(34,211,238,.28), transparent 62%),*/
|
||||
/* radial-gradient(1200px 800px at 10% 112%, rgba(16,185,129,.18), transparent 70%),*/
|
||||
/* linear-gradient(180deg, #0a0f16, #0f172a 52%, #0b1220 78%, #0a0f18);*/
|
||||
}
|
||||
|
||||
/* Inhalt bewusst über die Backdrop heben */
|
||||
#main{ position: relative; z-index: 1; }
|
||||
|
||||
/* (Optional) Safe-Area-Padding für iPhone-Notch */
|
||||
.safe-pads{
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
}
|
||||
|
||||
/* === App Backdrop (türkis/glass) === */
|
||||
/*.app-backdrop {*/
|
||||
/* position: fixed;*/
|
||||
/* inset: 0;*/
|
||||
/* z-index: -10;*/
|
||||
/* pointer-events: none;*/
|
||||
/* height: 100vh;*/
|
||||
/* background: radial-gradient(1600px 900px at 85% -15%, rgba(34, 211, 238, .28), transparent 62%),*/
|
||||
/* radial-gradient(1200px 800px at 10% 112%, rgba(16, 185, 129, .18), transparent 70%),*/
|
||||
/* linear-gradient(180deg, #0a0f16, #0f172a 52%, #0b1220 78%, #0a0f18);*/
|
||||
/*}*/
|
||||
|
||||
.primary-btn {
|
||||
@apply relative inline-flex items-center justify-center rounded-xl
|
||||
px-6 py-2.5 text-sm font-medium text-white transition-all
|
||||
|
|
@ -249,15 +306,25 @@ html[data-ui="ready"] #sidebar {
|
|||
--sb-w: 0;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
transition: width .2s ease, max-width .2s ease, transform .2s ease;
|
||||
}
|
||||
|
||||
/* off-canvas → kein Padding */
|
||||
}
|
||||
|
||||
/*#sidebar {*/
|
||||
/* width: var(--sbw, 16rem);*/
|
||||
/* max-width: var(--sbw, 16rem);*/
|
||||
/* transition: width .2s ease, max-width .2s ease, transform .2s ease;*/
|
||||
/*}*/
|
||||
|
||||
/* Sidebar selbst nimmt die variable Breite */
|
||||
#sidebar {
|
||||
width: var(--sbw, 16rem);
|
||||
max-width: var(--sbw, 16rem);
|
||||
transition: width .2s ease, max-width .2s ease, transform .2s ease;
|
||||
}
|
||||
/*#sidebar {*/
|
||||
/* width: var(--sbw, 16rem);*/
|
||||
/* max-width: var(--sbw, 16rem);*/
|
||||
/* transition: width .2s ease, max-width .2s ease, transform .2s ease;*/
|
||||
/*}*/
|
||||
|
||||
/* Rail: Label, Carets, Submenüs ausblenden (kein Springen) */
|
||||
#sidebar.u-collapsed .sidebar-label {
|
||||
|
|
@ -450,6 +517,46 @@ button {
|
|||
|
||||
}
|
||||
|
||||
html, body {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge */
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Pulse für "running" */
|
||||
@keyframes tgPulse {
|
||||
0%, 100% { box-shadow: 0 0 0 rgba(0,0,0,0) }
|
||||
50% { box-shadow: 0 0 40px rgba(34,211,238,.12) } /* cyan-400/12 */
|
||||
}
|
||||
.tg-pulse { animation: tgPulse 1.6s ease-in-out infinite; }
|
||||
|
||||
/* leichtes Shake für Fehler/forbidden */
|
||||
@keyframes tgShake {
|
||||
0% { transform: translateX(0) }
|
||||
25% { transform: translateX(-2px) }
|
||||
50% { transform: translateX(2px) }
|
||||
75% { transform: translateX(-1px) }
|
||||
100% { transform: translateX(0) }
|
||||
}
|
||||
.tg-shake { animation: tgShake .35s ease-in-out 1; }
|
||||
|
||||
@layer base {
|
||||
input[type=number]::-webkit-inner-spin-button,
|
||||
input[type=number]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input[type=number] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="email"],
|
||||
input[type="number"],
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
import './bootstrap';
|
||||
import './ui/command.js';
|
||||
|
||||
import '@tailwindplus/elements';
|
||||
|
||||
import "@phosphor-icons/web/duotone";
|
||||
import "@phosphor-icons/web/light";
|
||||
import "@phosphor-icons/web/regular";
|
||||
import "@phosphor-icons/web/bold";
|
||||
import './components/sidebar.js';
|
||||
// import '@plugins/Toastra';
|
||||
import '@plugins/GlassToastra/toastra.glass.js'
|
||||
|
||||
import './plugins/GlassToastra/toastra.glass.js'
|
||||
import './plugins/GlassToastra/livewire-adapter';
|
||||
|
||||
// import './utils/events.js';
|
||||
|
|
|
|||
|
|
@ -280,7 +280,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
sidebar.classList.add('translate-x-0');
|
||||
sidebar.classList.remove('-translate-x-full');
|
||||
} else {
|
||||
sidebar.classList.add('-translate-x-full');
|
||||
// sidebar.classList.add('-translate-x-full');
|
||||
sidebar.classList.remove('translate-x-0');
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
// resources/js/plugins/GlassToastra/livewire-adapter.js
|
||||
import { showToast } from '../../ui/toast';
|
||||
|
||||
document.addEventListener('livewire:init', () => {
|
||||
window.addEventListener('toast', (e) => showToast(e?.detail || {}));
|
||||
window.addEventListener('toast.update', (e) => showToast(e?.detail || {})); // gleiche id => ersetzt Karte
|
||||
window.addEventListener('toast.clear', (e) => window.toastraGlass?.clear(e?.detail?.position));
|
||||
});
|
||||
|
||||
// optional global
|
||||
window.showToast = showToast;
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
;(() => {
|
||||
// resources/js/plugins/GlassToastra/toastra.glass.js
|
||||
(() => {
|
||||
// ---- Config --------------------------------------------------------------
|
||||
const MAX_PER_POSITION = 3; // wie viele Toasts pro Ecke sichtbar
|
||||
const ROOT_PREFIX = 'toastra-root-'; // pro Position eigener Container
|
||||
|
|
@ -82,12 +83,29 @@
|
|||
return root;
|
||||
}
|
||||
|
||||
// function statusMap(state) {
|
||||
// switch (state) {
|
||||
// case 'done':
|
||||
// return { text: 'Erledigt', pill: 'bg-emerald-500/15 text-emerald-300 ring-1 ring-emerald-500/30', icon: 'ph ph-check-circle' };
|
||||
// case 'failed':
|
||||
// return { text: 'Fehlgeschlagen', pill: 'bg-rose-500/15 text-rose-300 ring-1 ring-rose-500/30', icon: 'ph ph-x-circle' };
|
||||
// case 'forbidden':
|
||||
// return { text: 'Verboten', pill: 'bg-fuchsia-500/15 text-fuchsia-300 ring-1 ring-fuchsia-500/30', icon: 'ph ph-shield-slash' };
|
||||
// case 'running':
|
||||
// return { text: 'Läuft…', pill: 'bg-cyan-500/15 text-cyan-300 ring-1 ring-cyan-500/30', icon: 'ph ph-arrow-clockwise animate-spin' };
|
||||
// default:
|
||||
// return { text: 'Wartet…', pill: 'bg-amber-500/15 text-amber-300 ring-1 ring-amber-500/30', icon: 'ph ph-pause-circle' };
|
||||
// }
|
||||
// }
|
||||
|
||||
function statusMap(state) {
|
||||
switch (state) {
|
||||
case 'done':
|
||||
return { text: 'Erledigt', pill: 'bg-emerald-500/15 text-emerald-300 ring-1 ring-emerald-500/30', icon: 'ph ph-check-circle' };
|
||||
case 'failed':
|
||||
return { text: 'Fehlgeschlagen', pill: 'bg-rose-500/15 text-rose-300 ring-1 ring-rose-500/30', icon: 'ph ph-x-circle' };
|
||||
case 'forbidden':
|
||||
return { text: 'Verboten', pill: 'bg-fuchsia-500/15 text-fuchsia-300 ring-1 ring-fuchsia-500/30', icon: 'ph ph-shield-slash' };
|
||||
case 'running':
|
||||
return { text: 'Läuft…', pill: 'bg-cyan-500/15 text-cyan-300 ring-1 ring-cyan-500/30', icon: 'ph ph-arrow-clockwise animate-spin' };
|
||||
default:
|
||||
|
|
@ -95,16 +113,17 @@
|
|||
}
|
||||
}
|
||||
|
||||
function buildHTML(o){
|
||||
function buildHTML(o) {
|
||||
const st = statusMap(o.state);
|
||||
const progress = (o.state === 'running' || o.state === 'queued')
|
||||
? `<div class="mt-3 h-1.5 w-full rounded-full bg-white/5 overflow-hidden">
|
||||
<div class="h-full w-1/2 bg-cyan-400/50 tg-progress"></div>
|
||||
</div>` : '';
|
||||
<div class="h-full w-1/2 bg-cyan-400/50 tg-progress"></div>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
const closeBtn = (o.close !== false)
|
||||
? `<button class="rounded-lg bg-white/10 hover:bg-white/15 border border-white/15 p-1.5 ml-2 flex-shrink-0"
|
||||
style="box-shadow:0 0 10px rgba(0,0,0,.25)" data-tg-close>
|
||||
style="box-shadow:0 0 10px rgba(0,0,0,.25)" data-tg-close>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M8 8l8 8M16 8l-8 8" stroke="rgba(229,231,235,.9)" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
|
@ -114,11 +133,19 @@
|
|||
return `
|
||||
<div class="glass-card border border-glass-border/70 rounded-2xl p-4 shadow-[0_10px_30px_rgba(0,0,0,.35)] pointer-events-auto tg-card">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
|
||||
<!-- Linke Seite -->
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
${o.badge ? `<span class="badge !bg-glass-light/50 !border-glass-border whitespace-nowrap">${String(o.badge).toUpperCase()}</span>` : ''}
|
||||
${o.domain ? `<div class="text-xs font-semibold text-gray-100/95 tracking-tight truncate">${o.domain}</div>` : ''}
|
||||
${o.badge
|
||||
? `<span class="px-2 py-0.5 rounded-md text-[11px] uppercase tracking-wide font-semibold
|
||||
bg-${o.state === 'forbidden' ? 'fuchsia' : o.state === 'failed' ? 'rose' : 'emerald'}-500/15
|
||||
text-${o.state === 'forbidden' ? 'fuchsia' : o.state === 'failed' ? 'rose' : 'emerald'}-300
|
||||
ring-1 ring-${o.state === 'forbidden' ? 'fuchsia' : o.state === 'failed' ? 'rose' : 'emerald'}-500/30">
|
||||
${String(o.badge).toUpperCase()}
|
||||
</span>`
|
||||
: ''}
|
||||
${o.domain
|
||||
? `<div class="text-xs font-semibold text-gray-100/95 tracking-tight truncate">${o.domain}</div>`
|
||||
: ''}
|
||||
</div>
|
||||
|
||||
<!-- Rechte Seite -->
|
||||
|
|
@ -140,6 +167,79 @@
|
|||
</div>`;
|
||||
}
|
||||
|
||||
// function buildHTML(o){
|
||||
// const st = statusMap(o.state);
|
||||
// const progress = (o.state === 'running' || o.state === 'queued')
|
||||
// ? `<div class="mt-3 h-1.5 w-full rounded-full bg-white/5 overflow-hidden">
|
||||
// <div class="h-full w-1/2 bg-cyan-400/50 tg-progress"></div>
|
||||
// </div>` : '';
|
||||
//
|
||||
// const closeBtn = (o.close !== false)
|
||||
// ? `<button class="rounded-lg bg-white/10 hover:bg-white/15 border border-white/15 p-1.5 ml-2 flex-shrink-0"
|
||||
// style="box-shadow:0 0 10px rgba(0,0,0,.25)" data-tg-close>
|
||||
// <svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
// <path d="M8 8l8 8M16 8l-8 8" stroke="rgba(229,231,235,.9)" stroke-linecap="round"/>
|
||||
// </svg>
|
||||
// </button>`
|
||||
// : '<div style="width:28px;height:28px"></div>';
|
||||
//
|
||||
// return `
|
||||
// <div class="glass-card border border-glass-border/70 rounded-2xl p-4 shadow-[0_10px_30px_rgba(0,0,0,.35)] pointer-events-auto tg-card ${st.anim}"
|
||||
// style="box-shadow: inset 6px 0 0 0 ${st.accentCss}, 0 10px 30px rgba(0,0,0,.35); background-image: linear-gradient(${st.tintCss}, ${st.tintCss});">
|
||||
// <div class="flex items-center justify-between gap-4">
|
||||
// <!-- Links -->
|
||||
// <div class="flex items-center gap-3 min-w-0">
|
||||
// ${o.badge ? `<span class="badge !bg-glass-light/50 !border-glass-border whitespace-nowrap">${String(o.badge).toUpperCase()}</span>` : ''}
|
||||
// ${o.domain ? `<div class="text-xs font-semibold text-gray-100/95 tracking-tight truncate">${o.domain}</div>` : ''}
|
||||
// </div>
|
||||
//
|
||||
// <!-- Rechts -->
|
||||
// <div class="flex items-center gap-2 flex-shrink-0">
|
||||
// <span class="text-[13px] text-gray-300/80">Status</span>
|
||||
// <span class="px-2.5 py-1 rounded-lg text-[12px] leading-none whitespace-nowrap ${st.pill}">
|
||||
// <i class="${st.icon} text-[14px] align-[-1px]"></i>
|
||||
// <span class="ml-1">${st.text}</span>
|
||||
// </span>
|
||||
// ${closeBtn}
|
||||
// </div>
|
||||
// </div>
|
||||
//
|
||||
// ${o.message ? `<p class="mt-3 text-[14px] text-gray-200/90 leading-relaxed">${o.message}</p>` : ''}
|
||||
//
|
||||
// ${progress}
|
||||
//
|
||||
// ${o.finalNote ? `<p class="mt-3 text-[12px] text-gray-400">${o.finalNote}</p>` : ''}
|
||||
// </div>
|
||||
// `;
|
||||
// // return `
|
||||
// // <div class="glass-card border border-glass-border/70 rounded-2xl p-4 shadow-[0_10px_30px_rgba(0,0,0,.35)] pointer-events-auto tg-card">
|
||||
// // <div class="flex items-center justify-between gap-4">
|
||||
// //
|
||||
// // <!-- Linke Seite -->
|
||||
// // <div class="flex items-center gap-3 min-w-0">
|
||||
// // ${o.badge ? `<span class="badge !bg-glass-light/50 !border-glass-border whitespace-nowrap">${String(o.badge).toUpperCase()}</span>` : ''}
|
||||
// // ${o.domain ? `<div class="text-xs font-semibold text-gray-100/95 tracking-tight truncate">${o.domain}</div>` : ''}
|
||||
// // </div>
|
||||
// //
|
||||
// // <!-- Rechte Seite -->
|
||||
// // <div class="flex items-center gap-2 flex-shrink-0">
|
||||
// // <span class="text-[13px] text-gray-300/80">Status</span>
|
||||
// // <span class="px-2.5 py-1 rounded-lg text-[12px] leading-none whitespace-nowrap ${st.pill}">
|
||||
// // <i class="${st.icon} text-[14px] align-[-1px]"></i>
|
||||
// // <span class="ml-1">${st.text}</span>
|
||||
// // </span>
|
||||
// // ${closeBtn}
|
||||
// // </div>
|
||||
// // </div>
|
||||
// //
|
||||
// // ${o.message ? `<p class="mt-3 text-[14px] text-gray-200/90 leading-relaxed">${o.message}</p>` : ''}
|
||||
// //
|
||||
// // ${progress}
|
||||
// //
|
||||
// // ${o.finalNote ? `<p class="mt-3 text-[12px] text-gray-400">${o.finalNote}</p>` : ''}
|
||||
// // </div>`;
|
||||
// }
|
||||
|
||||
function remove(wrapper) {
|
||||
if (!wrapper) return;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
(function(){
|
||||
// OS erkennen (sehr defensiv)
|
||||
const ua = navigator.userAgent || '';
|
||||
const platform = navigator.platform || '';
|
||||
const isMac = /Mac|Macintosh|Mac OS X/i.test(ua) || /Mac|iPhone|iPad|iPod/i.test(platform);
|
||||
|
||||
// Shortcut-Label setzen
|
||||
const kbd = document.getElementById('searchShortcutKbd');
|
||||
if (kbd) kbd.textContent = isMac ? '⌘ K' : 'Ctrl + K';
|
||||
|
||||
// Fallback: Tastenkombi abfangen, falls global nicht gebunden
|
||||
const open = () => {
|
||||
if (window.Livewire) {
|
||||
window.Livewire.dispatch('openModal', { component: 'ui.search.modal.search-palette-modal' });
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
const k = (e.key || '').toLowerCase();
|
||||
const meta = isMac ? e.metaKey : e.ctrlKey;
|
||||
if (meta && k === 'k' && !e.shiftKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
open();
|
||||
}
|
||||
});
|
||||
|
||||
// Optional: Button auch via JS öffnen können
|
||||
const btn = document.getElementById('openSearchPaletteBtn');
|
||||
if (btn) btn.addEventListener('click', open);
|
||||
})();
|
||||
|
||||
// document.addEventListener('keydown', (e) => {
|
||||
// const isMac = navigator.platform.toUpperCase().indexOf('MAC')>=0;
|
||||
// const meta = isMac ? e.metaKey : e.ctrlKey;
|
||||
// if (meta && e.key.toLowerCase() === 'k') {
|
||||
// e.preventDefault();
|
||||
// // LivewireUI Modal öffnen
|
||||
// Livewire.dispatch('openModal', { component: 'ui.search.modal.search-palette-modal' });
|
||||
// }
|
||||
// });
|
||||
|
|
@ -1,25 +1,28 @@
|
|||
// Minimal-API mit Fallbacks: GlassToastra → toastr → eigener Mini-Toast
|
||||
export function showToast({ type = 'success', text = '', title = '' } = {}) {
|
||||
const t = (type || 'success').toLowerCase();
|
||||
const msg = text || '';
|
||||
// Immer die Glas-Toasts verwenden (keine Fallbacks, kein toastr)
|
||||
export function showToast(payload = {}) {
|
||||
const {
|
||||
id, type, state, text, message, title, badge, domain,
|
||||
position, duration, close
|
||||
} = payload || {};
|
||||
|
||||
// 1) Dein Glas-Toast
|
||||
if (window.GlassToastra && typeof window.GlassToastra[t] === 'function') {
|
||||
window.GlassToastra[t](msg, title);
|
||||
// type/state auf deine 4 Zustände mappen
|
||||
const map = { success:'done', ok:'done', forbidden:'forbidden', error:'failed', danger:'failed', info:'queued', warning:'queued' };
|
||||
const stIn = String(state ?? type ?? 'done').toLowerCase();
|
||||
const st = ['done','failed','forbidden','running','queued'].includes(stIn) ? stIn : (map[stIn] || 'queued');
|
||||
|
||||
if (!window.toastraGlass || typeof window.toastraGlass.show !== 'function') {
|
||||
// Optional: console.warn('toastraGlass fehlt');
|
||||
return;
|
||||
}
|
||||
// 2) toastr
|
||||
if (window.toastr && typeof window.toastr[t] === 'function') {
|
||||
window.toastr.options = { timeOut: 3500, progressBar: true, closeButton: true };
|
||||
window.toastr[t](msg, title);
|
||||
return;
|
||||
}
|
||||
// 3) Fallback
|
||||
const box = document.createElement('div');
|
||||
box.className =
|
||||
'fixed top-4 right-4 z-[9999] rounded-xl bg-emerald-500/90 text-white ' +
|
||||
'px-4 py-3 backdrop-blur shadow-lg border border-white/10';
|
||||
box.textContent = msg;
|
||||
document.body.appendChild(box);
|
||||
setTimeout(() => box.remove(), 3500);
|
||||
|
||||
window.toastraGlass.show({
|
||||
id: id || ('toast-' + Date.now()), // gleiche id => ersetzt in-place
|
||||
state: st, // steuert Badge/Icon/Farben
|
||||
badge: badge ?? title ?? null, // linke kleine Kapsel
|
||||
domain: domain ?? null, // kleine Überschrift rechts
|
||||
message: (message ?? text ?? ''), // Haupttext
|
||||
position: position ?? 'bottom-right',
|
||||
duration: typeof duration === 'number' ? duration : 6000, // Standard 6s
|
||||
close: close !== false,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,71 @@
|
|||
import { showToast } from '../ui/toast.js'
|
||||
// import { showToast } from '../ui/toast.js'
|
||||
|
||||
// — Livewire-Hooks (global)
|
||||
// document.addEventListener('livewire:init', () => {
|
||||
// if (window.Livewire?.on) {
|
||||
// window.Livewire.on('toast', (payload = {}) => showToast(payload))
|
||||
// }
|
||||
// })
|
||||
|
||||
document.addEventListener('livewire:init', () => {
|
||||
if (window.Livewire?.on) {
|
||||
window.Livewire.on('toast', (payload = {}) => showToast(payload))
|
||||
// Neu: Livewire v3 Browser-Events
|
||||
window.addEventListener('toast', (e) => {
|
||||
const d = e?.detail || {};
|
||||
showToastGlass(d);
|
||||
});
|
||||
|
||||
// Optional: Update/Dismiss/Clear per Event
|
||||
window.addEventListener('toast.update', (e) => {
|
||||
const d = e?.detail || {};
|
||||
if (d.id) window.toastraGlass?.update(d.id, d);
|
||||
});
|
||||
window.addEventListener('toast.clear', (e) => {
|
||||
window.toastraGlass?.clear(e?.detail?.position);
|
||||
});
|
||||
});
|
||||
|
||||
// Adapter: normalisiert Payload und ruft toastraGlass
|
||||
function showToastGlass({
|
||||
id,
|
||||
type, state, // "success" | "warning" | "error" ODER "done" | "failed" | "running"
|
||||
text, message, title, // Textquellen
|
||||
badge, domain,
|
||||
position = 'bottom-right',
|
||||
duration = 0, // 0 = stehen lassen; bei done/failed auto auf 6000ms
|
||||
} = {}) {
|
||||
// Map: type -> state
|
||||
const t = (type || state || 'done').toLowerCase();
|
||||
const map = { success: 'done', ok: 'done', error: 'failed', danger: 'failed', info: 'queued', warning: 'queued' };
|
||||
const st = ['done','failed','running','queued'].includes(t) ? t : (map[t] || 'queued');
|
||||
|
||||
const msg = message || text || title || '';
|
||||
const _id = id || ('toast-' + Date.now());
|
||||
|
||||
if (window.toastraGlass?.show) {
|
||||
window.toastraGlass.show({
|
||||
id: _id,
|
||||
state: st, // queued|running|done|failed → färbt Badge/Icon
|
||||
badge, // z.B. "DNS", "Signup"
|
||||
domain, // optional: kleine Überschrift rechts
|
||||
message: msg,
|
||||
position,
|
||||
duration, // 0 = stehen lassen; sonst ms
|
||||
});
|
||||
} else if (window.toastr) {
|
||||
// Fallback: alte toastr API
|
||||
const level = (type || (st === 'failed' ? 'error' : st === 'done' ? 'success' : 'info'));
|
||||
window.toastr[level](msg, badge || domain || '');
|
||||
} else {
|
||||
// Minimal-Fallback
|
||||
const box = document.createElement('div');
|
||||
box.className = 'fixed top-4 right-4 z-[9999] rounded-xl bg-emerald-500/90 text-white px-4 py-3 backdrop-blur shadow-lg border border-white/10';
|
||||
box.textContent = msg || 'OK';
|
||||
document.body.appendChild(box);
|
||||
setTimeout(() => box.remove(), 3500);
|
||||
}
|
||||
})
|
||||
|
||||
return _id;
|
||||
}
|
||||
|
||||
// — Session-Flash vom Backend (einmal pro Page-Load)
|
||||
function bootstrapFlashFromLayout() {
|
||||
|
|
@ -36,7 +96,7 @@ function setupEchoToasts() {
|
|||
document.addEventListener('DOMContentLoaded', setupEchoToasts)
|
||||
|
||||
// — Optional: global machen, falls du manuell aus JS/Blade rufen willst
|
||||
window.showToast = showToast
|
||||
// window.showToast = showToast
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
@props([
|
||||
'text' => '',
|
||||
'class' => '',
|
||||
'label' => '',
|
||||
])
|
||||
<button
|
||||
x-data="{ copied: false }"
|
||||
@click="
|
||||
navigator.clipboard.writeText(@js($text));
|
||||
copied = true;
|
||||
setTimeout(() => copied = false, 2000);
|
||||
"
|
||||
class="inline-flex items-center text-base gap-1.5 rounded-lg border border-white/10 bg-white/5 p-1 #py-1 text-white/80 hover:text-white hover:border-white/20 transition {{ $class }}"
|
||||
>
|
||||
<template x-if="!copied">
|
||||
<span class="inline-flex items-center">
|
||||
<i class="ph ph-copy"></i> <small>{{ $label }}</small>
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="copied">
|
||||
<span class="inline-flex items-center">
|
||||
<i class="ph ph-checks text-emerald-400"></i> <small>{{ $label }}</small>
|
||||
</span>
|
||||
</template>
|
||||
</button>
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
{{-- Filter-Chip als Dropdown (native <details>) --}}
|
||||
@props(['label' => 'Filter'])
|
||||
<details class="group relative">
|
||||
<summary
|
||||
class="list-none flex items-center gap-1 h-12 px-3 rounded-xl
|
||||
bg-white/5 border border-white/10 text-white/80
|
||||
hover:border-white/20 cursor-pointer select-none">
|
||||
<span class="text-base">{{ $label }}</span>
|
||||
<i class="ph ph-caret-down text-[16px] transition-transform group-open:rotate-180"></i>
|
||||
</summary>
|
||||
<div
|
||||
class="absolute right-0 mt-2 z-20 rounded-2xl border border-white/10 bg-gray-900/95 backdrop-blur p-2 shadow-xl">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{{-- Option-Button Klasse --}}
|
||||
@once
|
||||
<style>
|
||||
.chip-opt{ @apply px-2.5 py-1.5 rounded-lg border border-white/10 bg-white/5 text-white/80 text-left hover:border-white/20; }
|
||||
.scrollbar-none::-webkit-scrollbar{ display:none; }
|
||||
.scrollbar-none{ -ms-overflow-style:none; scrollbar-width:none; }
|
||||
</style>
|
||||
@endonce
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<svg {{ $attributes->merge(['class' => 'size-6']) }} xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:serif="http://www.serif.com/" width="100%" height="100%" viewBox="0 0 1997 256" version="1.1" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g id="ArtBoard1" transform="matrix(1,0,0,1,-2830.73,-3936.92)">
|
||||
<rect x="2830.73" y="3936.93" width="1996.58" height="255.476" style="fill:none;"/>
|
||||
<g transform="matrix(0.266228,0,0,0.163123,2454.99,3276.68)">
|
||||
<path d="M1685.43,5163.02L1685.43,4727.16C1820.66,4903.71 1924.33,4957.04 2053.92,4957.04C2174.49,4957.04 2284.92,4903.71 2420.14,4727.16L2420.14,5163.02C2420.14,5210.84 2444.93,5251.3 2474.23,5251.3C2503.53,5251.3 2528.32,5210.84 2528.32,5163.02L2528.32,4537.73C2528.32,4489.91 2503.53,4449.45 2474.23,4449.45C2459.58,4449.45 2447.19,4460.49 2435.92,4475.2C2240.97,4754.74 2169.98,4769.45 2053.92,4769.45C1936.72,4767.62 1862.35,4751.06 1669.66,4475.2C1658.39,4460.49 1646,4449.45 1631.35,4449.45C1602.05,4449.45 1577.26,4489.91 1577.26,4537.73L1577.26,5163.02C1577.26,5210.84 1602.05,5251.3 1631.35,5251.3C1660.64,5251.3 1685.43,5210.84 1685.43,5163.02ZM1984.05,5163.02C1984.05,5225.55 2015.6,5277.05 2053.92,5277.05C2092.23,5277.05 2123.78,5225.55 2123.78,5163.02C2123.78,5100.49 2092.23,5049 2053.92,5049C2015.6,5049 1984.05,5100.49 1984.05,5163.02Z" style="fill-rule:nonzero;"/>
|
||||
<path d="M3001.6,4644.4C3010.62,4626.01 3027.52,4594.74 3044.42,4594.74C3061.32,4594.74 3078.23,4624.17 3088.37,4644.4L3350.93,5216.36C3361.07,5238.43 3376.84,5251.3 3393.75,5251.3C3423.04,5251.3 3447.84,5210.84 3447.84,5163.02C3447.84,5139.11 3441.07,5117.05 3432.06,5100.49L3158.23,4502.79C3132.32,4445.77 3089.49,4412.67 3044.42,4412.67C2995.97,4412.67 2952.02,4451.29 2926.1,4508.3L2652.28,5109.69C2644.39,5124.4 2641.01,5142.79 2641.01,5163.02C2641.01,5210.84 2665.8,5251.3 2695.1,5251.3C2710.87,5251.3 2726.65,5238.43 2736.79,5218.19L3001.6,4644.4Z" style="fill-rule:nonzero;"/>
|
||||
<path d="M3668.7,5163.02L3668.7,4537.73C3668.7,4489.91 3643.91,4449.45 3614.61,4449.45C3585.31,4449.45 3560.52,4489.91 3560.52,4537.73L3560.52,5163.02C3560.52,5210.84 3585.31,5251.3 3614.61,5251.3C3643.91,5251.3 3668.7,5210.84 3668.7,5163.02Z" style="fill-rule:nonzero;"/>
|
||||
<path d="M3935.76,5251.3L4608.49,5251.3C4637.79,5251.3 4662.58,5210.84 4662.58,5163.02C4662.58,5115.21 4637.79,5074.74 4608.49,5074.74L3932.38,5074.74C3909.85,5074.74 3889.56,5043.48 3889.56,5006.7L3889.56,4537.73C3889.56,4489.91 3863.64,4449.45 3835.47,4449.45C3806.17,4449.45 3781.38,4489.91 3781.38,4537.73L3781.38,5006.7C3781.38,5142.79 3852.38,5251.3 3935.76,5251.3Z" style="fill-rule:nonzero;"/>
|
||||
<path d="M4775.27,4537.73L4775.27,4850.38C4775.27,5032.45 4848.51,5251.3 4971.34,5251.3L5595.62,5251.3C5725.21,5251.3 5802.96,5036.12 5802.96,4850.38L5802.96,4537.73C5802.96,4489.91 5778.17,4449.45 5748.87,4449.45C5719.57,4449.45 5694.78,4489.91 5694.78,4537.73L5694.78,4850.38C5694.78,4938.65 5672.24,5074.74 5568.57,5074.74L5461.52,5074.74C5364.61,5074.74 5343.2,4936.81 5343.2,4850.38L5343.2,4537.73C5343.2,4489.91 5318.41,4449.45 5289.11,4449.45C5259.82,4449.45 5235.03,4489.91 5235.03,4537.73L5235.03,4850.38C5235.03,4925.78 5247.42,5006.7 5271.09,5074.74L5009.65,5074.74C4905.98,5074.74 4883.45,4938.65 4883.45,4850.38L4883.45,4537.73C4883.45,4489.91 4858.66,4449.45 4829.36,4449.45C4800.06,4449.45 4775.27,4489.91 4775.27,4537.73Z" style="fill-rule:nonzero;"/>
|
||||
<path d="M6832.9,4850.38C6832.9,4668.31 6759.66,4460.49 6636.83,4449.45L6122.99,4449.45C5993.4,4449.45 5915.65,4664.63 5915.65,4850.38C5915.65,5032.45 5988.89,5240.26 6111.72,5251.3L6625.56,5251.3C6755.15,5251.3 6832.9,5036.12 6832.9,4850.38ZM6601.9,5074.74L6146.65,5074.74C6042.98,5074.74 6020.44,4938.65 6020.44,4850.38C6020.44,4762.1 6042.98,4626.01 6146.65,4626.01L6601.9,4626.01C6705.57,4626.01 6728.11,4762.1 6728.11,4850.38C6728.11,4938.65 6705.57,5074.74 6601.9,5074.74Z" style="fill-rule:nonzero;"/>
|
||||
<path d="M7099.97,5251.3L7772.7,5251.3C7802,5251.3 7826.79,5210.84 7826.79,5163.02C7826.79,5115.21 7802,5074.74 7772.7,5074.74L7096.59,5074.74C7074.05,5074.74 7053.77,5043.48 7053.77,5006.7L7053.77,4537.73C7053.77,4489.91 7027.85,4449.45 6999.68,4449.45C6970.38,4449.45 6945.59,4489.91 6945.59,4537.73L6945.59,5006.7C6945.59,5142.79 7016.58,5251.3 7099.97,5251.3Z" style="fill-rule:nonzero;"/>
|
||||
<path d="M8627.98,4449.45L7852.71,4449.45C7823.41,4449.45 7798.62,4489.91 7798.62,4537.73C7798.62,4583.71 7821.15,4622.33 7848.2,4626.01L8186.26,4626.01L8186.26,5163.02C8186.26,5210.84 8211.05,5251.3 8240.34,5251.3C8269.64,5251.3 8294.43,5210.84 8294.43,5163.02L8294.43,4626.01L8627.98,4626.01C8657.28,4626.01 8682.07,4585.55 8682.07,4537.73C8682.07,4489.91 8657.28,4449.45 8627.98,4449.45Z" style="fill-rule:nonzero;"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
|
|
@ -5,23 +5,45 @@
|
|||
<header id="header" class="header w-full rounded-r-2xl rounded-l-none">
|
||||
<nav class="flex h-[71px] #h-[74px] items-center justify-between #p-3">
|
||||
<div class="#flex-1 md:w-3/5 w-full">
|
||||
<div class="flex items-center gap-5">
|
||||
<button id="sidebar-toggle-btn"
|
||||
class="action-button sidebar-toggle flex items-center justify-center p-1.5 shadow-2xl rounded">
|
||||
<svg class="size-5 group-[.expanded]/side:rotate-180"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5"
|
||||
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
<div class="relative flex items-center gap-5">
|
||||
<button class="sidebar-toggle translate-0 right-5 block sm:hidden text-white/60 hover:text-white text-2xl">
|
||||
<i class="ph ph-list"></i>
|
||||
</button>
|
||||
|
||||
{{-- <button id="sidebar-toggle-btn"--}}
|
||||
{{-- class="action-button sidebar-toggle flex items-center justify-center p-1.5 shadow-2xl rounded">--}}
|
||||
{{-- <svg class="size-5 group-[.expanded]/side:rotate-180"--}}
|
||||
{{-- fill="none" stroke="currentColor" stroke-width="1.5"--}}
|
||||
{{-- viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">--}}
|
||||
{{-- <path stroke-linecap="round" stroke-linejoin="round"--}}
|
||||
{{-- d="M9 5l7 7-7 7"></path>--}}
|
||||
{{-- </svg>--}}
|
||||
{{-- </button>--}}
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="font-bold text-2xl">@yield('header_title')</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2 w-full max-w-96">
|
||||
<a href="#" class="btn-ghost text-xs px-2 py-1"><i class="ph ph-plus mr-1"></i><span>Domain</span></a>
|
||||
<button
|
||||
id="openSearchPaletteBtn"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1.5
|
||||
text-white/75 hover:text-white hover:border-white/20"
|
||||
title="Suche öffnen"
|
||||
aria-label="Suche öffnen"
|
||||
wire:click="$dispatch('openModal',{component:'ui.search.modal.search-palette-modal'})"
|
||||
>
|
||||
<i class="ph ph-magnifying-glass text-[16px]"></i>
|
||||
<span id="searchShortcutKbd"
|
||||
class="rounded-md bg-white/5 border border-white/10 px-1.5 py-0.5 text-[11px] leading-none">
|
||||
<!-- wird per JS gesetzt -->
|
||||
</span>
|
||||
</button>
|
||||
<button onclick="Livewire.dispatch('openModal',{component:'ui.domain.modal.domain-create-modal'})">
|
||||
<i class="ph-duotone ph-globe-hemisphere-east"></i>
|
||||
</button>
|
||||
|
||||
@foreach($header as $section)
|
||||
<div class="#modal-button #relative #mr-2">
|
||||
<div class="#p-1.5">
|
||||
|
|
|
|||
|
|
@ -1,121 +1,3 @@
|
|||
{{--<div--}}
|
||||
{{-- id="sidebar"--}}
|
||||
{{-- class="group/side fixed z-50 drop-shadow-2xl--}}
|
||||
{{-- -translate-x-full sm:translate-x-0--}}
|
||||
{{-- sm:transition-[max-width,transform] sm:duration-300--}}
|
||||
{{-- sm:block sm:max-w-sb-col lg:max-w-sb-exp--}}
|
||||
{{-- group-[.t-collapsed]/side:sm:max-w-sb-col--}}
|
||||
{{-- group-[.ut-expanded]/side:sm:max-w-sb-exp--}}
|
||||
{{-- group-[.m-expanded]/side:translate-x-0 group-[.m-expanded]/side:block--}}
|
||||
{{-- group-[.u-collapsed]/side:sm:max-w-sb-col--}}
|
||||
{{-- h-full w-full">--}}
|
||||
{{--<div class="#flex p-2.5 w-full max-w-7xl h-full">--}}
|
||||
{{-- <div--}}
|
||||
{{-- class="side-bg flex flex-col rounded-lg shadow-2xl h-full border-white/10--}}
|
||||
{{-- bg-gradient-to-b from-[#0f172a]/90 via-[#1e293b]/70 to-[#0d9488]/30 backdrop-blur-xl">--}}
|
||||
|
||||
{{-- <div class="relative items-center justify-center h-16 gap-2 border-b hr px-2">--}}
|
||||
{{-- <button id="sidebar-toggle-btn"--}}
|
||||
{{-- class="absolute top-1/2 -translate-y-1/2 action-button sidebar-toggle flex items-center justify-center p-2 shadow-2xl rounded z-10 sm:hidden">--}}
|
||||
{{-- <svg class="size-6 group-[.expanded]/side:rotate-180"--}}
|
||||
{{-- fill="none" stroke="currentColor" stroke-width="1.5"--}}
|
||||
{{-- viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">--}}
|
||||
{{-- <path stroke-linecap="round" stroke-linejoin="round"--}}
|
||||
{{-- d="M9 5l7 7-7 7"></path>--}}
|
||||
{{-- </svg>--}}
|
||||
{{-- </button>--}}
|
||||
|
||||
{{-- <a href="{{ route('dashboard') }}"--}}
|
||||
{{-- class="sidebar-link group relative flex flex-row items-center">--}}
|
||||
{{-- <button class="w-full flex items-center sm:justify-normal justify-center rounded-md">--}}
|
||||
{{-- <span class="text-gray #group-[.active]:text-primary p-2">--}}
|
||||
{{-- <x-icons.icon-link class="!size-7 block"/>--}}
|
||||
{{-- </span>--}}
|
||||
{{-- <span--}}
|
||||
{{-- class="sidebar-menu-text text-xs ml-2 truncate tracking-wide group-[.collapsed]/side:sm:hidden">--}}
|
||||
{{-- <x-icons.icon-logo class="!size-28 max-h-16"/>--}}
|
||||
{{-- </span>--}}
|
||||
{{-- </button>--}}
|
||||
{{-- </a>--}}
|
||||
{{-- </div>--}}
|
||||
{{-- <nav class="flex-1 overflow-y-auto my-3 px-2.5 Space-y-4 no-scrollbar text-[rgba(var(--gray))]">--}}
|
||||
{{-- <ul class="Space-y-3">--}}
|
||||
|
||||
{{-- --}}{{-- Menu Sections --}}
|
||||
|
||||
{{-- @foreach($menu as $section)--}}
|
||||
{{-- @php--}}
|
||||
{{-- $isOpen = collect($section['items'] ?? [])->contains(--}}
|
||||
{{-- fn($i) => isset($i['route']) && request()->routeIs($i['route'])--}}
|
||||
{{-- );--}}
|
||||
{{-- $secIcon = $section['icon'] ?? 'ph-dots-three-outline';--}}
|
||||
|
||||
{{-- $isOpen = collect($section['items'] ?? [])->contains(--}}
|
||||
{{-- fn($i) => isset($i['route']) && request()->routeIs($i['route'])--}}
|
||||
{{-- );--}}
|
||||
{{-- @endphp--}}
|
||||
{{-- <li class="Space-y-1" x-data="{ open: {{ $isOpen ? 'true' : 'false' }} }">--}}
|
||||
{{-- <button type="button" @click="open = !open"--}}
|
||||
{{-- class="w-full flex items-center gap-2 px-2 py-2 rounded-lg hover:bg-white/5 text-slate-300 transition-colors">--}}
|
||||
{{-- <i class="ph-duotone {{ $secIcon }} text-xl shrink-0 opacity-80"></i>--}}
|
||||
|
||||
{{-- <span class="sidebar-menu-text text-xs font-semibold tracking-wide group-[.collapsed]/side:sm:hidden">--}}
|
||||
{{-- {{ $section['label'] ?? 'Sektion' }}--}}
|
||||
{{-- </span>--}}
|
||||
{{-- <span class="sidebar-menu-text text-xs font-semibold tracking-wide--}}
|
||||
{{-- group-[.u-collapsed]/side:hidden">--}}
|
||||
{{-- {{ $section['label'] ?? 'Sektion' }}--}}
|
||||
{{-- </span>--}}
|
||||
|
||||
{{-- <i class="ph-duotone ph-caret-down ml-auto text-sm transition-transform group-[.collapsed]/side:sm:hidden"--}}
|
||||
{{-- :class="{ '-rotate-180': open }" x-cloak></i>--}}
|
||||
{{-- </button>--}}
|
||||
|
||||
{{-- --}}{{-- WICHTIG: x-cloak + SSR hidden, wenn nicht offen --}}
|
||||
{{-- <ul x-show="open && !$root.closest('#sidebar').classList.contains('u-collapsed')"--}}
|
||||
{{-- x-collapse--}}
|
||||
{{-- x-cloak--}}
|
||||
{{-- >--}}
|
||||
{{-- @foreach(($section['items'] ?? []) as $item)--}}
|
||||
{{-- @php--}}
|
||||
{{-- $active = isset($item['route']) && request()->routeIs($item['route']);--}}
|
||||
{{-- $itemIcon = $item['icon'] ?? 'ph-dot-outline';--}}
|
||||
{{-- @endphp--}}
|
||||
|
||||
{{-- <li>--}}
|
||||
{{-- <a href="#"--}}
|
||||
{{-- class="sidebar-link group relative flex items-center gap-2 px-2 py-2 rounded-lg--}}
|
||||
{{-- border border-transparent transition-colors--}}
|
||||
{{-- hover:bg-gradient-to-t hover:from-white/10 hover:to-transparent--}}
|
||||
{{-- {{ $active ? 'bg-gradient-to-t from-white/10 to-transparent !border !border-white/10' : '' }}">--}}
|
||||
{{-- <i class="ph-duotone {{ $itemIcon }} text-base opacity-80"></i>--}}
|
||||
{{-- <span class="sidebar-menu-text text-xs truncate tracking-wide group-[.collapsed]/side:sm:hidden {{ $active ? 'font-semibold' : '' }}">--}}
|
||||
{{-- {{ $item['label'] ?? 'Eintrag' }}--}}
|
||||
{{-- </span>--}}
|
||||
{{-- </a>--}}
|
||||
{{-- </li>--}}
|
||||
{{-- @endforeach--}}
|
||||
{{-- </ul>--}}
|
||||
{{-- </li>--}}
|
||||
{{-- @endforeach--}}
|
||||
|
||||
{{-- </ul>--}}
|
||||
{{-- </nav>--}}
|
||||
{{-- <div class="p-3 border-t hr flex justify-end">--}}
|
||||
{{-- <button id="sidebar-toggle-btn"--}}
|
||||
{{-- class="action-button sidebar-toggle flex items-center justify-center p-2 shadow-2xl rounded">--}}
|
||||
{{-- <svg class="size-6 group-[.expanded]/side:rotate-180"--}}
|
||||
{{-- fill="none" stroke="currentColor" stroke-width="1.5"--}}
|
||||
{{-- viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">--}}
|
||||
{{-- <path stroke-linecap="round" stroke-linejoin="round"--}}
|
||||
{{-- d="M9 5l7 7-7 7"></path>--}}
|
||||
{{-- </svg>--}}
|
||||
{{-- </button>--}}
|
||||
{{-- </div>--}}
|
||||
{{-- </div>--}}
|
||||
{{-- </div>--}}
|
||||
{{--</div>--}}
|
||||
|
||||
{{-- resources/views/components/partials/sidebar.blade.php --}}
|
||||
|
||||
<div
|
||||
|
|
@ -123,57 +5,27 @@
|
|||
class="fixed top-0 left-0 z-50 #drop-shadow-2xl
|
||||
-translate-x-full sm:translate-x-0
|
||||
h-full w-full">
|
||||
|
||||
{{-- Kleine, robuste Styleschicht – kein Tailwind-Plugin nötig --}}
|
||||
{{-- <style>--}}
|
||||
{{-- /* Basisbreite über CSS-Var – smooth per transition */--}}
|
||||
{{-- #sidebar { --sb-w: 16rem; width: var(--sb-w); max-width: var(--sb-w); transition: width .2s ease, max-width .2s ease, transform .2s ease; }--}}
|
||||
{{-- /* Desktop: 16rem Standard, im Rail 5.2rem */--}}
|
||||
{{-- @media (min-width: 1024px) {--}}
|
||||
{{-- #sidebar { --sb-w: 16rem; }--}}
|
||||
{{-- #sidebar.u-collapsed { --sb-w: 5.2rem; }--}}
|
||||
{{-- }--}}
|
||||
{{-- /* Tablet: Standard Rail, Button kann erweitern */--}}
|
||||
{{-- @media (min-width: 640px) and (max-width: 1023.98px) {--}}
|
||||
{{-- #sidebar { --sb-w: 5.2rem; }--}}
|
||||
{{-- #sidebar.ut-expanded { --sb-w: 16rem; }--}}
|
||||
{{-- }--}}
|
||||
{{-- /* Mobile: offcanvas via translate; JS toggelt translate-x-0/-translate-x-full */--}}
|
||||
{{-- /* Rail-Modus: Text, Carets und Submenüs sicher verstecken */--}}
|
||||
{{-- #sidebar.u-collapsed .sidebar-label { display: none !important; }--}}
|
||||
{{-- #sidebar.u-collapsed .sidebar-caret { display: none !important; }--}}
|
||||
{{-- #sidebar.u-collapsed [data-submenu] { display: none !important; }--}}
|
||||
{{-- </style>--}}
|
||||
|
||||
<div class="p-2.5 w-full h-full">
|
||||
<div class="side-bg flex flex-col rounded-lg shadow-2xl h-full border-white/10
|
||||
bg-gradient-to-b from-[#0f172a]/90 via-[#1e293b]/70 to-[#0d9488]/30
|
||||
backdrop-blur-xl">
|
||||
backdrop-blur-xl border border-white/10">
|
||||
|
||||
{{-- Header --}}
|
||||
<div class="relative h-16 border-b hr px-2 flex items-center">
|
||||
{{-- Mobile Toggle (zeigt/verbirgt Sidebar auf <640px) --}}
|
||||
{{-- <button--}}
|
||||
{{-- type="button"--}}
|
||||
{{-- class="absolute left-2 top-1/2 -translate-y-1/2 action-button sidebar-toggle flex items-center justify-center p-2 shadow-2xl rounded z-10 sm:hidden"--}}
|
||||
{{-- aria-label="Sidebar umschalten">--}}
|
||||
{{-- <svg class="size-6" fill="none" stroke="currentColor" stroke-width="1.5"--}}
|
||||
{{-- viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">--}}
|
||||
{{-- <path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"></path>--}}
|
||||
{{-- </svg>--}}
|
||||
{{-- </button>--}}
|
||||
|
||||
<div class="relative h-16 border-b hr px-2 flex items-center justify-between">
|
||||
<a href="{{ route('ui.dashboard') }}"
|
||||
class="sidebar-link group relative flex flex-row items-center #mx-auto sm:mx-0">
|
||||
<span class="text-gray p-1.5">
|
||||
<x-icons.icon-logo-circle class="!size-9"/>
|
||||
</span>
|
||||
|
||||
<span class="sidebar-label text-xl ml-2 truncate tracking-wide font-space">
|
||||
MailWolt
|
||||
{{-- optional: „MailWolt“/Brand --}}
|
||||
<span class="sidebar-label text-xl ml-2 truncate tracking-wide font-bai transition-all">
|
||||
<x-icons.icon-logo-text class="!size-35 fill-white"/>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<button class="sidebar-toggle absolute translate-0 right-5 block sm:hidden text-white/60 hover:text-white text-2xl"><i
|
||||
class="ph ph-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Navigation --}}
|
||||
|
|
@ -192,7 +44,7 @@
|
|||
<li class="space-y-1" x-data="{ open: {{ $activeInside ? 'true' : 'false' }} }">
|
||||
<button
|
||||
type="button"
|
||||
class="js-sec-toggle w-full flex items-center gap-4 px-3 py-2 rounded-lg hover:bg-white/5 text-slate-300 transition-colors"
|
||||
class="js-sec-toggle w-full flex items-center gap-4 px-2.5 py-2 rounded-lg hover:bg-white/5 text-slate-300 transition-colors"
|
||||
@click="(!$root.closest('#sidebar').classList.contains('u-collapsed')) && (open = !open)">
|
||||
<i class="ph-duotone {{ $secIcon }} text-xl shrink-0 opacity-80"></i>
|
||||
<span class="sidebar-label text-xs font-semibold tracking-wide">
|
||||
|
|
@ -210,14 +62,15 @@
|
|||
$itemIcon = $item['icon'] ?? 'ph-dot-outline';
|
||||
@endphp
|
||||
<li>
|
||||
{{-- <a href="{{ isset($item['route']) ? route($item['route']) : '#' }}"--}}
|
||||
{{-- <a href="{{ isset($item['route']) ? route($item['route']) : '#' }}"--}}
|
||||
<a href="#"
|
||||
class="sidebar-link group relative flex items-center gap-2 px-2 py-2 rounded-lg
|
||||
border border-transparent transition-colors
|
||||
hover:bg-gradient-to-t hover:from-white/10 hover:to-transparent
|
||||
{{ $active ? 'bg-gradient-to-t from-white/10 to-transparent !border !border-white/10' : '' }}">
|
||||
<i class="ph-duotone {{ $itemIcon }} text-base opacity-80"></i>
|
||||
<span class="sidebar-label text-xs truncate tracking-wide {{ $active ? 'font-semibold' : '' }}">
|
||||
<span
|
||||
class="sidebar-label text-xs truncate tracking-wide {{ $active ? 'font-semibold' : '' }}">
|
||||
{{ $item['label'] ?? 'Eintrag' }}
|
||||
</span>
|
||||
</a>
|
||||
|
|
@ -236,7 +89,8 @@
|
|||
MailWolt <span class="font-semibold">{{ config('app.version', 'v1.0.0') }}</span>
|
||||
</div>
|
||||
|
||||
<button type="button" class="action-button sidebar-toggle flex items-center justify-center p-2 shadow-2xl rounded"
|
||||
<button type="button"
|
||||
class="action-button sidebar-toggle flex items-center justify-center p-2 shadow-2xl rounded"
|
||||
aria-label="Sidebar ein/ausklappen">
|
||||
<svg class="size-6" fill="none" stroke="currentColor" stroke-width="1.5"
|
||||
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
|
|
|
|||
|
|
@ -1,25 +1,23 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_','-',app()->getLocale()) }}" class="h-dvh">
|
||||
<html lang="{{ str_replace('_','-',app()->getLocale()) }}" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
{{-- <meta name="reverb"--}}
|
||||
{{-- data-host="{{ request()->getHost() }}"--}}
|
||||
{{-- data-port="{{ request()->isSecure() ? 443 : 80 }}"--}}
|
||||
{{-- data-scheme="{{ request()->isSecure() ? 'https' : 'http' }}"--}}
|
||||
{{-- data-path="{{ config('reverb.servers.reverb.path') }}"--}}
|
||||
{{-- data-key="{{ config('reverb.servers.reverb.key') }}">--}}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
{{-- <meta name="theme-color" content="#132835">--}}
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<title>@yield('title', config('app.name'))</title>
|
||||
<script>
|
||||
document.documentElement.setAttribute('data-ui','booting');
|
||||
</script>
|
||||
<script>document.documentElement.setAttribute('data-ui','booting');</script>
|
||||
@vite(['resources/css/app.css'])
|
||||
@livewireStyles
|
||||
</head>
|
||||
|
||||
<body class="min-h-dvh text-slate-100 font-bai">
|
||||
<div class="app-backdrop"></div>
|
||||
|
||||
{{--<body class="#min-h-screen text-slate-100 font-bai #safe-pads">--}}
|
||||
{{--<div class="app-backdrop fixed inset-0 -z-10 pointer-events-none"></div>--}}
|
||||
|
||||
<body class="text-slate-100 font-bai">
|
||||
<div class="app-backdrop"></div>
|
||||
<div id="main" class="main content group/side main-shell rail">
|
||||
<x-partials.sidebar/>
|
||||
|
||||
|
|
@ -27,7 +25,6 @@
|
|||
<div>
|
||||
<x-partials.header />
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
@yield('content')
|
||||
</div>
|
||||
|
|
@ -38,90 +35,44 @@
|
|||
@livewireScripts
|
||||
@livewire('wire-elements-modal')
|
||||
</body>
|
||||
{{--<body class="min-h-dvh text-slate-100">--}}
|
||||
{{--<div class="fixed inset-0 -z-10 pointer-events-none--}}
|
||||
{{-- [background:radial-gradient(1200px_800px_at_85%_-10%,rgba(139,215,255,.16),transparent_60%),radial-gradient(900px_700px_at_10%_110%,rgba(121,255,163,.10),transparent_60%),linear-gradient(180deg,#0b0f14,#111827_55%,#172130)]">--}}
|
||||
{{--</div>--}}
|
||||
{{--<div class="fixed inset-0 -z-10 pointer-events-none [background:radial-gradient(1600px_900px_at_85%_-15%,rgba(34,211,238,.28),transparent_62%),radial-gradient(1200px_800px_at_10%_112%,rgba(16,185,129,.18),transparent_70%),linear-gradient(180deg,#0a0f16,#0f172a_52%,#0b1220_78%,#0a0f18)]"></div>--}}
|
||||
{{--<div--}}
|
||||
{{-- class="p-2.5 pt-0--}}
|
||||
{{-- lg:ml-64--}}
|
||||
{{-- filter--}}
|
||||
{{-- group-[.u-collapsed]/side:sm:ml-20--}}
|
||||
{{-- sm:transition-[margin-left] sm:duration-300--}}
|
||||
{{-- sm:ml-20 group-[.collapsed]/side:sm:!ml-20--}}
|
||||
{{-- group-[.ut-expanded]/side:sm:blur-md--}}
|
||||
{{-- group-[.ut-expanded]/side:sm:pointer-events-none--}}
|
||||
{{-- group-[.ut-expanded]/side:sm:brightness-100--}}
|
||||
{{-- group-[.ut-expanded]/side:lg:filter-none--}}
|
||||
{{-- #group-[.m-collapsed]/side:!ml-0--}}
|
||||
{{-- ">--}}
|
||||
{{-- <div>--}}
|
||||
{{-- <header class="px-6 py-4 flex items-center justify-between opacity-80">--}}
|
||||
{{-- <div class="flex items-center gap-3">--}}
|
||||
{{-- <div class="h-8 w-8 rounded-xl bg-indigo-500/90 grid place-items-center font-semibold">FM</div>--}}
|
||||
{{-- <span class="font-semibold tracking-wide">MailWolt</span>--}}
|
||||
{{-- @env('local')--}}
|
||||
{{-- <span class="ml-2 text-[11px] px-2 py-0.5 rounded bg-slate-200/10 border border-white/10">dev</span>--}}
|
||||
{{-- @endenv--}}
|
||||
{{-- </div>--}}
|
||||
{{-- @isset($setupPhase)--}}
|
||||
{{-- <div class="text-xs text-slate-300/70">Setup-Phase: {{ $setupPhase }}</div>--}}
|
||||
{{-- @endisset--}}
|
||||
{{-- </header>--}}
|
||||
</html>
|
||||
{{--<!DOCTYPE html>--}}
|
||||
{{--<html lang="{{ str_replace('_','-',app()->getLocale()) }}" class="h-dvh">--}}
|
||||
{{--<head>--}}
|
||||
{{-- <meta charset="utf-8">--}}
|
||||
{{-- <meta name="viewport" content="width=device-width, initial-scale=1">--}}
|
||||
{{-- <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">--}}
|
||||
{{-- <meta name="theme-color" content="#0b1220" media="(prefers-color-scheme: light)">--}}
|
||||
{{-- <meta name="theme-color" content="#0b1220" media="(prefers-color-scheme: dark)">--}}
|
||||
{{-- <meta name="apple-mobile-web-app-capable" content="yes">--}}
|
||||
{{-- <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">--}}
|
||||
{{-- <title>@yield('title', config('app.name'))</title>--}}
|
||||
{{-- <script>--}}
|
||||
{{-- document.documentElement.setAttribute('data-ui','booting');--}}
|
||||
{{-- </script>--}}
|
||||
{{-- @vite(['resources/css/app.css'])--}}
|
||||
{{-- @livewireStyles--}}
|
||||
{{--</head>--}}
|
||||
|
||||
{{-- --}}{{----}}{{-- Seite: immer auf volle Höhe und zentriert --}}
|
||||
{{-- <main class="min-h-[calc(100dvh-64px)] grid place-items-center px-4">--}}
|
||||
{{-- <div id="toastra-root" class="absolute pointer-events-none"></div>--}}
|
||||
{{-- @yield('content')--}}
|
||||
{{-- </main>--}}
|
||||
{{--</div>--}}
|
||||
{{--<body class="min-h-screen text-slate-100 font-bai safe-pads">--}}
|
||||
{{--<div class="app-backdrop fixed inset-0 -z-10 pointer-events-none"></div>--}}
|
||||
|
||||
{{--<script>--}}
|
||||
{{-- window.addEventListener('toast', (e) => {--}}
|
||||
{{-- const {--}}
|
||||
{{-- state = 'queued',--}}
|
||||
{{-- badge = '',--}}
|
||||
{{-- domain = '',--}}
|
||||
{{-- message = '',--}}
|
||||
{{-- pos = 'top-right',--}}
|
||||
{{-- duration = 0, // <- pass it through--}}
|
||||
{{-- } = e.detail || {};--}}
|
||||
|
||||
{{-- toastraGlass.show({--}}
|
||||
{{-- state, badge, domain, message,--}}
|
||||
{{-- position: pos,--}}
|
||||
{{-- duration: Number(duration),--}}
|
||||
{{-- });--}}
|
||||
{{-- });--}}
|
||||
{{--</script>--}}
|
||||
{{--@vite(['resources/js/app.js'])--}}
|
||||
{{--@livewireScripts--}}
|
||||
{{--<div id="main" class="main content group/side main-shell rail">--}}
|
||||
{{--<div id="main" class="main content group/side main-shell rail min-h-screen">--}}
|
||||
{{-- <x-partials.sidebar/>--}}
|
||||
{{-- <div--}}
|
||||
{{-- class="p-2.5 pt-0--}}
|
||||
{{-- lg:ml-64--}}
|
||||
{{-- filter--}}
|
||||
{{-- group-[.u-collapsed]/side:sm:ml-20--}}
|
||||
{{-- sm:transition-[margin-left] sm:duration-300--}}
|
||||
{{-- sm:ml-20 group-[.collapsed]/side:sm:!ml-20--}}
|
||||
{{-- group-[.ut-expanded]/side:sm:blur-md--}}
|
||||
{{-- group-[.ut-expanded]/side:sm:pointer-events-none--}}
|
||||
{{-- group-[.ut-expanded]/side:sm:brightness-100--}}
|
||||
{{-- group-[.ut-expanded]/side:lg:filter-none--}}
|
||||
{{-- #group-[.m-collapsed]/side:!ml-0--}}
|
||||
{{-- ">--}}
|
||||
|
||||
{{-- <div class="px-2.5 pb-2.5 pt-0.5">--}}
|
||||
{{-- <div>--}}
|
||||
{{-- <x-partials.header/>--}}
|
||||
{{-- <x-partials.header />--}}
|
||||
{{-- </div>--}}
|
||||
|
||||
{{-- <div class="mt-5">--}}
|
||||
{{-- @yield("content")--}}
|
||||
{{-- @yield('content')--}}
|
||||
{{-- </div>--}}
|
||||
{{-- </div>--}}
|
||||
{{--</div>--}}
|
||||
|
||||
{{--@vite(['resources/js/app.js'])--}}
|
||||
{{--@livewireScripts--}}
|
||||
|
||||
{{--@livewire('wire-elements-modal')--}}
|
||||
{{--</body>--}}
|
||||
</html>
|
||||
{{--</html>--}}
|
||||
|
|
|
|||
|
|
@ -1,48 +1,184 @@
|
|||
<div class="bg-slate-800/40 rounded-2xl p-4 space-y-3">
|
||||
<!-- Header -->
|
||||
<div class="hidden md:grid grid-cols-4 text-sm text-slate-300 pb-1 border-b border-slate-700/50">
|
||||
<div>Domain</div>
|
||||
<div>Aktiv</div>
|
||||
<div>Typ</div>
|
||||
<div class="text-right">Aktionen</div>
|
||||
<div class="space-y-4">
|
||||
|
||||
<div class="">
|
||||
<div class="md:flex items-center justify-between gap-3">
|
||||
<h1 class="text-5xl #mb-6">Domains</h1>
|
||||
</div>
|
||||
|
||||
<div class="my-4 md:flex items-center justify-end gap-3">
|
||||
<input
|
||||
type="text"
|
||||
wire:model.live.debounce.300ms="search"
|
||||
class="md:max-w-96 w-full justify-end flex-1 rounded-xl bg-white/5 border border-white/10 px-4 py-2.5 text-white/90 placeholder:text-white/50"
|
||||
placeholder="Suchen (Alias) …">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Einträge -->
|
||||
@foreach($domains as $d)
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-4 items-center gap-2 md:gap-4 py-3 px-3 rounded-xl bg-slate-900/40 border border-slate-700/50 hover:border-slate-600/60 transition">
|
||||
{{-- Titel wie in Security --}}
|
||||
{{-- ================= System-Domain ================= --}}
|
||||
<div class="glass-card p-5 space-y-3">
|
||||
<p class="text-sm text-white/70">
|
||||
Diese Domain wird für <span class="text-white/90 font-medium">interne System-E-Mails</span>
|
||||
genutzt (Ausfälle, Quota-Warnungen, Zustellfehler). Ist keine echte Domain gesetzt, verwenden
|
||||
wir ein self-signed Zertifikat und lokales Zustellen. Für produktiven Versand muss die
|
||||
System-Domain <span class="text-white/90 font-medium">auf diese Server-IP zeigen</span>
|
||||
und per ACME/Let’s Encrypt ein Zertifikat erhalten.
|
||||
</p>
|
||||
|
||||
<!-- Domain -->
|
||||
<div class="text-slate-100 text-sm font-medium">
|
||||
{{ $d->domain }}
|
||||
@if($systemDomain)
|
||||
<div class="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 items-center gap-3">
|
||||
<div class=" flex items-center gap-1 text-white/90 font-medium truncate">
|
||||
<span class="relative flex size-2.5 mx-1">
|
||||
@if($systemDomain->is_active)
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-300 opacity-60"></span>
|
||||
<span class="relative inline-flex size-2.5 rounded-full bg-emerald-300 ring-2 ring-emerald-300/30"></span>
|
||||
@else
|
||||
<span class="relative inline-flex size-2.5 rounded-full bg-amber-400/90 ring-2 ring-amber-200/20"></span>
|
||||
@endif
|
||||
</span>
|
||||
{{ $systemDomain->domain }}
|
||||
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
{{-- <span class="px-2 py-0.5 rounded-full text-xs border--}}
|
||||
{{-- {{ $systemDomain->is_active ? 'border-emerald-400/30 text-emerald-300 bg-emerald-500/10'--}}
|
||||
{{-- : 'border-white/15 text-white/60 bg-white/5' }}">--}}
|
||||
{{-- {{ $systemDomain->is_active ? 'Aktiv' : 'Inaktiv' }}--}}
|
||||
{{-- </span>--}}
|
||||
<span class="px-2 py-0.5 rounded-full text-xs border border-white/15 text-white/70 bg-white/5">
|
||||
System
|
||||
</span>
|
||||
</div>
|
||||
<div class="md:col-span-2 md:text-right">
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-white/80 hover:text-white hover:border-white/20"
|
||||
wire:click="openDnsModal({{ $systemDomain->id }})">
|
||||
<i class="ph ph-globe-hemisphere-east text-[12px]"></i> DNS-Assistent
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-3 text-white/75">
|
||||
Keine System-Domain gefunden. Bitte im Setup anlegen.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- ================= Benutzer-Domains ================= --}}
|
||||
<div class="glass-card p-5 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="inline-flex items-center gap-2 rounded-full bg-white/5 border border-white/10 px-2.5 py-1">
|
||||
<i class="ph ph-globe-hemisphere-west text-white/70 text-[13px]"></i>
|
||||
<span class="text-[11px] uppercase tracking-wide text-white/70">Benutzer-Domains</span>
|
||||
</div>
|
||||
|
||||
<!-- Aktiv -->
|
||||
<div>
|
||||
<span class="px-2 py-1 rounded text-xs {{ $d->is_active ? 'bg-emerald-600/30 text-emerald-200' : 'bg-slate-600/30 text-slate-300' }}">
|
||||
{{ $d->is_active ? 'aktiv' : 'inaktiv' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Typ -->
|
||||
<div>
|
||||
<span class="px-2 py-1 rounded text-xs {{ $d->is_system ? 'bg-indigo-600/30 text-indigo-200' : 'bg-sky-600/30 text-sky-100' }}">
|
||||
{{ $d->is_system ? 'System' : 'Kunde' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Aktion -->
|
||||
<div class="text-right">
|
||||
<button
|
||||
onclick="Livewire.dispatch('openModal', {
|
||||
component: 'ui.domain.modal.domain-dns-modal',
|
||||
arguments: { domainId: {{ $d->id }} }
|
||||
})"
|
||||
class="inline-flex items-center justify-center px-3 py-1.5 rounded-lg bg-gradient-to-r from-teal-500 to-emerald-600 text-white text-xs font-medium hover:opacity-90">
|
||||
> DNS-Assistent
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-white/80 hover:text-white hover:border-white/20"
|
||||
{{-- class="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-3 py-1.5 text-white/80 hover:text-white hover:border-white/20"--}}
|
||||
onclick="Livewire.dispatch('openModal',{component:'ui.domain.modal.domain-create-modal'})">
|
||||
<i class="ph ph-plus text-[14px]"></i> Domain hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
@if($userDomains->count())
|
||||
<div class="space-y-3">
|
||||
@foreach($userDomains as $d)
|
||||
<div class="rounded-xl border border-white/10 bg-white/[0.04] p-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 col-start-1 col-end-3 items-center gap-3">
|
||||
{{-- Domain + Tags --}}
|
||||
<div class="text-white/90 font-medium truncate">
|
||||
<span class="inline-flex items-center gap-2" aria-live="polite">
|
||||
<span class="relative flex size-2.5 mx-1">
|
||||
@if($d->domainActive)
|
||||
<span
|
||||
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-300 opacity-60"></span>
|
||||
<span
|
||||
class="relative inline-flex size-2.5 rounded-full bg-emerald-300 ring-2 ring-emerald-300/30"></span>
|
||||
@else
|
||||
<span
|
||||
class="relative inline-flex size-2.5 rounded-full bg-amber-400/90 ring-2 ring-amber-200/20"></span>
|
||||
@endif
|
||||
</span>
|
||||
</span>
|
||||
{{ $d->domain }}
|
||||
|
||||
<div class="mt-1 flex items-center gap-1.5">
|
||||
{{-- Zähler-Badges --}}
|
||||
<span
|
||||
class="px-2 py-0.5 rounded-full text-xs border border-white/15 text-white/70 bg-white/5 whitespace-nowrap">
|
||||
<i class="ph ph-user-list text-[12px]"></i> {{ $d->mailboxes_count ?? 0 }} Postfächer
|
||||
</span>
|
||||
<span
|
||||
class="px-2 py-0.5 rounded-full text-xs border border-white/15 text-white/70 bg-white/5 whitespace-nowrap">
|
||||
<i class="ph ph-at text-[12px]"></i> {{ $d->aliases_count ?? 0 }} Aliasse
|
||||
</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Status + Zähler --}}
|
||||
<div class="flex flex-wrap items-center gap-2 col-span-1">
|
||||
|
||||
@if(!empty($d->visible_tags))
|
||||
<div class="mt-1 flex items-center gap-1.5">
|
||||
@foreach($d->visible_tags as $t)
|
||||
<span
|
||||
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[11px] font-medium"
|
||||
style="background: {{ $t['bg'] }}; border:1px solid {{ $t['border'] }}; color: {{ $t['color'] }};">
|
||||
<i class="ph ph-tag text-[12px]"></i>{{ $t['label'] }}
|
||||
</span>
|
||||
@endforeach
|
||||
@if($d->extra_tags > 0)
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-medium border border-white/10 text-white/70 bg-white/5">
|
||||
+{{ $d->extra_tags }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<hr class="md:hidden">
|
||||
{{-- Aktionen --}}
|
||||
<div class="md:col-span-1 md:text-right space-y-2">
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-white/80 hover:text-white hover:border-white/20"
|
||||
wire:click="openDnsModal({{ $d->id }})">
|
||||
<i class="ph ph-globe-hemisphere-east text-[12px]"></i> DNS
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-white/80 hover:text-white hover:border-white/20"
|
||||
wire:click="openEditModal({{ $d->id }})">
|
||||
<i class="ph ph-pencil-simple text-[12px]"></i> Bearbeiten
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-white/80 hover:text-white hover:border-white/20"
|
||||
wire:click="openLimitsModal({{ $d->id }})">
|
||||
<i class="ph ph-sliders-horizontal text-[12px]"></i> Limits
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-xs
|
||||
bg-rose-500/20 text-rose-200 border border-rose-400/40 hover:bg-rose-500/30 shrink-0"
|
||||
wire:click="openDeleteModal({{ $d->id }})">
|
||||
<i class="ph ph-trash-simple text-[12px]"></i> Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-6 text-white/75 text-center">
|
||||
Noch keine eigenen Domains hinzugefügt.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,273 @@
|
|||
@push('modal.header')
|
||||
<div class="px-5 pt-5 pb-3 border-b border-white/10
|
||||
backdrop-blur rounded-t-2xl relative">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">Domain hinzufügen</h2>
|
||||
<p class="text-[13px] text-slate-300/85">
|
||||
Lege Limits & Quotas fest. DKIM & empfohlene DNS-Records werden automatisch vorbereitet.
|
||||
</p>
|
||||
</div>
|
||||
<button wire:click="$dispatch('closeModal')" class="text-white/60 hover:text-white text-lg">
|
||||
<i class="ph ph-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endpush
|
||||
|
||||
<div class="p-6">
|
||||
<div class="space-y-4">
|
||||
{{-- SECTION: Basisdaten --}}
|
||||
<section class="rounded-2xl border border-slate-700/60 bg-white/[0.04] p-5 backdrop-blur">
|
||||
{{-- Domain --}}
|
||||
<div>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-200">
|
||||
<i class="ph ph-at text-slate-300"></i> Domain
|
||||
</label>
|
||||
<input type="text" placeholder="example.com" wire:model.defer="domain" class="mt-1 nx-input">
|
||||
@error('domain') <p class="mt-1 text-xs text-rose-400">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
{{-- Beschreibung --}}
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-slate-200">Beschreibung</label>
|
||||
<span
|
||||
class="px-2 py-0.5 rounded-full text-[11px] border border-slate-600/60 bg-slate-800/70 text-slate-200">optional</span>
|
||||
</div>
|
||||
<input type="text" placeholder="optional" wire:model.defer="description" class="mt-1 nx-input">
|
||||
@error('description') <p class="mt-1 text-xs text-rose-400">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- Tags --}}
|
||||
<section class="rounded-2xl border border-slate-700/60 bg-white/[0.04] p-5 backdrop-blur">
|
||||
<div class="#mt-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-slate-200">Tags</label>
|
||||
<span
|
||||
class="px-2 py-0.5 rounded-full text-[11px] border border-slate-600/60 bg-slate-800/70 text-slate-200">optional</span>
|
||||
</div>
|
||||
|
||||
{{-- Liste der Tags --}}
|
||||
<div class="mt-2 space-y-2">
|
||||
@foreach($tags as $i => $t)
|
||||
<div class="#rounded-xl #border #border-slate-700/60 #bg-white/[0.04] #p-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{{-- Label --}}
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-xs text-slate-300/80 mb-1">Label</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="z. B. Kunde, Produktion"
|
||||
wire:model.defer="tags.{{ $i }}.label"
|
||||
class="nx-input"
|
||||
>
|
||||
</div>
|
||||
|
||||
{{-- Farbe (HEX + Picker) --}}
|
||||
<div>
|
||||
<label class="block text-xs text-slate-300/80 mb-1">Farbe (HEX)</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="#22c55e"
|
||||
wire:model.lazy="tags.{{ $i }}.color"
|
||||
class="nx-input font-mono"
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
value="{{ $t['color'] ?? '#22c55e' }}"
|
||||
wire:change="pickTagColor({{ $i }}, $event.target.value)"
|
||||
class="size-8 min-w-8 rounded-md border border-white/15 bg-white/5 p-1 cursor-pointer"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Palette + Entfernen --}}
|
||||
<div class="mt-3 flex items-center justify-between gap-2">
|
||||
<div class="flex gap-2">
|
||||
<span class="text-xs text-slate-400">Palette:</span>
|
||||
@foreach($tagPalette as $hex)
|
||||
<button
|
||||
type="button"
|
||||
class="h-6 w-6 rounded-md border {{ ($t['color'] ?? '') === $hex ? 'ring-2 ring-white/80' : 'border-white/15' }}"
|
||||
style="background: {{ $hex }}"
|
||||
wire:click="pickTagColor({{ $i }}, '{{ $hex }}')"
|
||||
title="{{ $hex }}"
|
||||
></button>
|
||||
@endforeach
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
wire:click="removeTag({{ $i }})"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg p-2 bg-rose-500/20 text-rose-200 border border-rose-400/40 hover:bg-rose-500/30 shrink-0"
|
||||
>
|
||||
<i class="ph ph-trash-simple text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Tag hinzufügen --}}
|
||||
<div class="mt-8 text-end">
|
||||
<button
|
||||
type="button"
|
||||
wire:click="addTag"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-white/80 hover:text-white hover:border-white/20">
|
||||
<i class="ph ph-plus text-[14px]"></i> Tag hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@error('tags.*.label') <p class="mt-1 text-xs text-rose-400">{{ $message }}</p> @enderror
|
||||
@error('tags.*.color') <p class="mt-1 text-xs text-rose-400">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- SECTION: Limits (Anzahl) --}}
|
||||
<section class="rounded-2xl border border-slate-700/60 bg-white/[0.04] p-5 backdrop-blur">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label class="text-sm text-slate-200">Max. mögliche Aliasse</label>
|
||||
<input type="number" wire:model.defer="max_aliases" class="mt-1 nx-input">
|
||||
@error('max_aliases') <p class="mt-1 text-xs text-rose-400">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-slate-200">Max. mögliche Mailboxen</label>
|
||||
<input type="number" wire:model.defer="max_mailboxes" class="mt-1 nx-input">
|
||||
@error('max_mailboxes') <p class="mt-1 text-xs text-rose-400">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- SECTION: Quotas / Speicher --}}
|
||||
<section class="rounded-2xl border border-slate-700/60 bg-white/[0.04] p-5 backdrop-blur">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-slate-200">Standard-Quota je Mailbox (MiB)</label>
|
||||
<span
|
||||
class="px-2 py-0.5 rounded-full text-[11px] border border-indigo-500/30 bg-indigo-500/15 text-indigo-200">Voreinstellung</span>
|
||||
</div>
|
||||
<input type="number" wire:model.defer="default_quota_mb" class="mt-1 nx-input">
|
||||
<p class="mt-2 text-xs text-slate-300/90">Startwert für neue Postfächer (pro Postfach änderbar).</p>
|
||||
@error('default_quota_mb') <p class="mt-1 text-xs text-rose-400">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-slate-200">Speicher je Mailbox (MiB)</label>
|
||||
<span
|
||||
class="px-2 py-0.5 rounded-full text-[11px] border border-fuchsia-500/30 bg-fuchsia-500/15 text-fuchsia-200">Obergrenze</span>
|
||||
<span
|
||||
class="px-2 py-0.5 rounded-full text-[11px] border border-slate-600/60 bg-slate-800/70 text-slate-200">optional</span>
|
||||
</div>
|
||||
<input type="number" wire:model.defer="max_quota_per_mailbox_mb" class="mt-1 nx-input">
|
||||
<p class="mt-2 text-xs text-slate-300/90">
|
||||
Leer lassen = keine Obergrenze; limitiert nur durch die <span
|
||||
class="underline decoration-slate-600/70">Domain-Gesamtgröße</span>.
|
||||
</p>
|
||||
@error('max_quota_per_mailbox_mb') <p
|
||||
class="mt-1 text-xs text-rose-400">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label class="text-sm text-slate-200">Domain-Speicherplatz gesamt (MiB)</label>
|
||||
<input type="number" min="1" max="{{ $available_mib }}" wire:model.defer="total_quota_mb"
|
||||
class="mt-1 nx-input">
|
||||
<div class="mt-2 text-xs">
|
||||
<div
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-slate-700/70 bg-slate-900/70 px-2 py-1 text-slate-300/90">
|
||||
<i class="ph ph-info"></i>
|
||||
Verfügbar (nach Systemreserve): {{ number_format($available_mib) }} MiB
|
||||
</div>
|
||||
<p class="mt-2 text-slate-300/80">Beachte: Summe aller Postfach-Quotas darf diese Domain-Größe
|
||||
nicht überschreiten.</p>
|
||||
</div>
|
||||
@error('total_quota_mb') <p class="mt-1 text-xs text-rose-400">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- SECTION: Versandlimits & Status --}}
|
||||
<section class="rounded-2xl border border-slate-700/60 bg-white/[0.04] p-5 backdrop-blur">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-slate-200">Mails pro Stunde</label>
|
||||
<span
|
||||
class="px-2 py-0.5 rounded-full text-[11px] border border-slate-600/60 bg-slate-800/70 text-slate-200">optional</span>
|
||||
</div>
|
||||
<input type="number" placeholder="leer = kein Limit" wire:model.defer="rate_limit_per_hour"
|
||||
class="mt-1 nx-input">
|
||||
@error('rate_limit_per_hour') <p class="mt-1 text-xs text-rose-400">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div class="grid items-center gap-3">
|
||||
<label class="inline-flex items-center gap-3 select-none">
|
||||
<input type="checkbox" wire:model.defer="rate_limit_override"
|
||||
class="peer appearance-none w-5 h-5 rounded-md border border-emerald-500/50 bg-transparent checked:bg-emerald-500 checked:border-emerald-400 grid place-content-center transition">
|
||||
<span class="text-slate-200">Postfach-Overrides erlauben</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-3 select-none">
|
||||
<input type="checkbox" wire:model.defer="active"
|
||||
class="peer appearance-none w-5 h-5 rounded-md border border-emerald-500/50 bg-transparent checked:bg-emerald-500 checked:border-emerald-400 grid place-content-center transition">
|
||||
<span class="text-slate-200">Domain aktivieren</span>
|
||||
</label>
|
||||
|
||||
</div>
|
||||
|
||||
{{-- <div class="flex items-center gap-3">--}}
|
||||
{{-- <label class="inline-flex items-center gap-3 select-none">--}}
|
||||
{{-- <input type="checkbox" wire:model.defer="active"--}}
|
||||
{{-- class="peer appearance-none w-5 h-5 rounded-md border border-emerald-500/50 bg-transparent checked:bg-emerald-500 checked:border-emerald-400 grid place-content-center transition">--}}
|
||||
{{-- <span class="text-slate-200">Domain aktivieren</span>--}}
|
||||
{{-- </label>--}}
|
||||
{{-- </div>--}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- SECTION: DKIM --}}
|
||||
<section class="rounded-2xl border border-slate-700/60 bg-white/[0.04] p-5 backdrop-blur">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label class="text-sm text-slate-200">DKIM-Selector</label>
|
||||
<input type="text" placeholder="dkim" wire:model.defer="dkim_selector" class="mt-1 nx-input">
|
||||
@error('dkim_selector') <p class="mt-1 text-xs text-rose-400">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-slate-200">DKIM-Schlüssellänge (bits)</label>
|
||||
<select wire:model.defer="dkim_bits" class="mt-1 nx-input">
|
||||
<option value="1024">1024</option>
|
||||
<option value="2048">2048</option>
|
||||
<option value="3072">3072</option>
|
||||
<option value="4096">4096</option>
|
||||
</select>
|
||||
<p class="mt-2 text-xs text-slate-300/90">
|
||||
<i class="ph ph-warning-circle mr-1"></i>
|
||||
2048 ist meist ideal. 4096 erzeugt sehr lange TXT-Records – DNS-Provider prüfen.
|
||||
</p>
|
||||
@error('dkim_bits') <p class="mt-1 text-xs text-rose-400">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('modal.footer')
|
||||
<div class="px-5 py-3 border-t border-white/10 backdrop-blur rounded-b-2xl">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" wire:click="$dispatch('closeModal')"
|
||||
class="px-4 py-2 rounded-lg text-sm border border-white/10 text-white/70 hover:text-white hover:border-white/20">
|
||||
Abbrechen
|
||||
</button>
|
||||
|
||||
<button type="submit" wire:click="$dispatch('domain:create')"
|
||||
class="px-4 py-2 rounded-lg text-sm bg-emerald-500/20 text-emerald-300 border border-emerald-400/30 hover:bg-emerald-500/30">
|
||||
Domain hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endpush
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
@push('modal.header')
|
||||
<div class="px-5 pt-5 pb-3 border-b border-white/10
|
||||
backdrop-blur rounded-t-2xl relative">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">Domain löschen</h2>
|
||||
<button wire:click="$dispatch('closeModal')" class="text-white/60 hover:text-white text-lg">
|
||||
<i class="ph ph-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endpush
|
||||
|
||||
<div class="p-6 space-y-5">
|
||||
<div class="rounded-xl border border-rose-500/30 bg-rose-500/10 p-3 text-rose-200">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="ph ph-warning text-[18px]"></i>
|
||||
<span class="text-sm">Diese Aktion kann nicht rückgängig gemacht werden.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="text-white/85">
|
||||
Du bist im Begriff, die Domain <span class="font-semibold">{{ $domain }}</span> zu löschen.
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 text-sm">
|
||||
<span class="px-2 py-0.5 rounded-full border border-white/15 text-white/70 bg-white/5">
|
||||
<i class="ph ph-user-list text-[12px]"></i> {{ $mailboxes }} Postfächer
|
||||
</span>
|
||||
<span class="px-2 py-0.5 rounded-full border border-white/15 text-white/70 bg-white/5">
|
||||
<i class="ph ph-at text-[12px]"></i> {{ $aliases }} Aliasse
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if($mailboxes > 0 || $aliases > 0)
|
||||
<div class="text-sm rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-amber-200 mt-5">
|
||||
Löschen ist aktuell blockiert: Entferne zuerst alle Postfächer und Aliasse.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm text-white/70">Zur Bestätigung tippe den Domain-Namen:</label>
|
||||
<input type="text" wire:model.defer="confirm" placeholder="{{ $domain }}"
|
||||
class="w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm focus:border-white/20 focus:ring-0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('modal.footer')
|
||||
<div class="px-5 py-3 border-t border-white/10 backdrop-blur rounded-b-2xl">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" wire:click="$dispatch('closeModal')"
|
||||
class="px-4 py-2 rounded-lg text-sm border border-white/10 text-white/70 hover:text-white hover:border-white/20">
|
||||
Abbrechen
|
||||
</button>
|
||||
|
||||
@php $blocked = ($mailboxes > 0 || $aliases > 0); @endphp
|
||||
<button type="button" wire:click="$dispatch('domain:delete')"
|
||||
class="px-4 py-2 rounded-lg text-sm
|
||||
{{ $blocked ? 'border border-white/10 bg-white/5 text-white/40 cursor-not-allowed'
|
||||
: 'bg-rose-500/20 text-rose-200 border border-rose-400/40 hover:bg-rose-500/30' }}"
|
||||
@if($blocked) disabled @endif>
|
||||
<i class="ph ph-trash-simple"></i> Endgültig löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endpush
|
||||
|
|
@ -1,22 +1,22 @@
|
|||
{{-- resources/views/livewire/domains/dns-assistant-modal.blade.php --}}
|
||||
@push('modal.header')
|
||||
<div class="px-5 pt-5 pb-3 border-b border-white/10
|
||||
backdrop-blur rounded-t-2xl relative">
|
||||
<span class="absolute top-3 right-3 z-20 inline-flex items-center gap-2
|
||||
rounded-full border border-slate-700/70 bg-slate-800/70
|
||||
px-3 py-1 text-[11px] text-slate-200 shadow-sm">
|
||||
<i class="ph ph-clock text-slate-300"></i> TTL: {{ $ttl }}
|
||||
</span>
|
||||
<h2 class="text-[18px] font-semibold text-slate-100">DNS-Einträge</h2>
|
||||
<p class="text-[13px] text-slate-300/80">
|
||||
Setze die folgenden Records für
|
||||
<span class="text-sky-300 underline decoration-sky-500/40 underline-offset-2">{{ $zone }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
@endpush
|
||||
|
||||
<div class="relative p-5">
|
||||
<span
|
||||
class="absolute top-3 right-3 z-20 inline-flex items-center gap-2
|
||||
rounded-full border border-slate-700/70 bg-slate-800/70
|
||||
px-3 py-1 text-[11px] text-slate-200 shadow-sm backdrop-blur">
|
||||
<i class="ph ph-clock text-slate-300"></i>
|
||||
TTL: {{ $ttl }}
|
||||
</span>
|
||||
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<h2 class="text-[18px] font-semibold text-slate-100">DNS-Einträge</h2>
|
||||
<p class="text-[13px] text-slate-300/80">
|
||||
Setze die folgenden Records in deiner DNS-Zone für
|
||||
<span class="text-sky-300 underline decoration-sky-500/40 underline-offset-2">{{ $base }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{-- GRID: links (Mail-Records), rechts (Infrastruktur) --}}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
{{-- Mail-Records --}}
|
||||
|
|
@ -25,28 +25,24 @@
|
|||
<span class="px-2 py-0.5 rounded bg-slate-800/80 text-slate-200">Step 1</span>
|
||||
<span class="text-slate-300/80">Mail-Records</span>
|
||||
<span class="ml-auto px-2 py-0.5 rounded bg-indigo-600/20 text-indigo-200 border border-indigo-500/20">
|
||||
<i class="ph ph-seal-check mr-1"></i>System-Absenderdomain
|
||||
<i class="ph ph-seal-check mr-1"></i>Absenderdomain
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
@foreach ($dynamic as $r)
|
||||
<div class="rounded-xl border border-slate-700/50 bg-slate-900/60">
|
||||
<div class="rounded-xl border border-white/10 bg-white/[0.04] #rounded-xl #border #border-slate-700/50 #bg-slate-900/60">
|
||||
<div class="flex items-center justify-between px-4 py-2 text-[12px]">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-2 py-0.5 rounded bg-slate-800/80 text-slate-200 {{ $recordColors[$r['type']] ?? 'bg-slate-700/50 text-slate-300' }}">{{ $r['type'] }}</span>
|
||||
<span class="text-slate-200">{{ $r['name'] }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-slate-300/70">
|
||||
<button
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-md bg-slate-800/70 hover:bg-slate-800 text-slate-100"
|
||||
x-data @click="navigator.clipboard.writeText(@js($r['value']))">
|
||||
<i class="ph ph-copy"></i> Kopieren
|
||||
</button>
|
||||
<x-button.copy-btn :text="$r['value']" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4 pb-3">
|
||||
<pre class="text-[12px] leading-5 whitespace-pre-wrap break-all font-mono text-slate-100 bg-slate-900/70 rounded-lg p-3 border border-slate-800/60">{{ $r['value'] }}</pre>
|
||||
<pre class="text-[12px] w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm opacity-70 whitespace-pre-wrap break-all">{{ $r['value'] }}</pre>
|
||||
@if(!empty($r['helpUrl']))
|
||||
<a href="{{ $r['helpUrl'] }}" target="_blank" rel="noopener"
|
||||
class="mt-2 inline-flex items-center gap-1 text-[12px] text-sky-300 hover:text-sky-200">
|
||||
|
|
@ -69,34 +65,34 @@
|
|||
|
||||
<div class="space-y-4">
|
||||
@foreach ($static as $r)
|
||||
<div class="rounded-xl border border-slate-700/50 bg-slate-900/60">
|
||||
<div class="rounded-xl border border-white/10 bg-white/[0.04] #rounded-xl #border #border-slate-700/50 #bg-slate-900/60">
|
||||
<div class="flex items-center justify-between px-4 py-2 text-[12px]">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-2 py-0.5 rounded {{ $recordColors[$r['type']] ?? 'bg-slate-700/50 text-slate-300' }}">{{ $r['type'] }}</span>
|
||||
<span class="text-slate-200">{{ $r['name'] }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-slate-300/70">
|
||||
<button
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-md bg-slate-800/70 hover:bg-slate-800 text-slate-100"
|
||||
x-data @click="navigator.clipboard.writeText(@js($r['value']))">
|
||||
<i class="ph ph-copy"></i> Kopieren
|
||||
</button>
|
||||
<x-button.copy-btn :text="$r['value']" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4 pb-3">
|
||||
<pre class="text-[12px] leading-5 whitespace-pre-wrap break-all font-mono text-slate-100 bg-slate-900/70 rounded-lg p-3 border border-slate-800/60">{{ $r['value'] }}</pre>
|
||||
<pre class="text-[12px] w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm opacity-70 whitespace-pre-wrap break-all">{{ $r['value'] }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('modal.footer')
|
||||
<div class="px-5 py-3 border-t border-white/10 backdrop-blur rounded-b-2xl">
|
||||
<div class="flex justify-end">
|
||||
<button wire:click="$closeModal"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-gradient-to-r from-sky-700 to-emerald-700 text-white hover:opacity-95">
|
||||
<button wire:click="$dispatch('closeModal')"
|
||||
class="px-4 py-2 rounded-lg text-sm bg-emerald-500/20 text-emerald-300 border border-emerald-400/30 hover:bg-emerald-500/30">
|
||||
<i class="ph ph-check"></i> Fertig
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endpush
|
||||
|
|
|
|||
|
|
@ -0,0 +1,129 @@
|
|||
@push('modal.header')
|
||||
<div class="px-5 pt-5 pb-3 border-b border-white/10
|
||||
backdrop-blur rounded-t-2xl relative">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">Domain bearbeiten</h2>
|
||||
<button wire:click="$dispatch('closeModal')" class="text-white/60 hover:text-white text-lg">
|
||||
<i class="ph ph-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endpush
|
||||
|
||||
<div class="p-6 space-y-5">
|
||||
<p class="text-sm text-white/60 leading-relaxed">
|
||||
Hier kannst du die grundlegenden Eigenschaften der Domain ändern.
|
||||
DKIM- oder DNS-Einträge werden dabei nicht neu erzeugt.
|
||||
</p>
|
||||
<form wire:submit.prevent="save" id="domain-edit-form" class="space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="inline-flex items-center gap-3 select-none">
|
||||
<input type="checkbox" wire:model.defer="is_active"
|
||||
class="peer appearance-none w-5 h-5 rounded-md border border-emerald-500/50 bg-transparent checked:bg-emerald-500 checked:border-emerald-400 grid place-content-center transition">
|
||||
<span class="text-slate-200 text-sm">Domain aktiv</span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/70 mb-1">Domain</label>
|
||||
<input type="text" wire:model="domain" readonly
|
||||
class="w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm opacity-70 cursor-not-allowed">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/70 mb-1">Beschreibung</label>
|
||||
<textarea wire:model.defer="description"
|
||||
class="w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm resize-none focus:border-white/20 focus:ring-0"
|
||||
rows="2" placeholder="z. B. Hauptdomain für Kundenmails"></textarea>
|
||||
@error('description')
|
||||
<div class="text-rose-400 text-xs mt-1">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-slate-200">Tags</label>
|
||||
<span class="px-2 py-0.5 rounded-full text-[11px] border border-slate-600/60 bg-slate-800/70 text-slate-200">optional</span>
|
||||
</div>
|
||||
<div class="mt-2 space-y-2">
|
||||
@foreach($tags as $i => $t)
|
||||
<div class="rounded-xl border border-slate-700/60 bg-white/[0.04] p-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{{-- Label --}}
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-xs text-slate-300/80 mb-1">Label</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="z. B. Kunde, Produktion"
|
||||
wire:model.defer="tags.{{ $i }}.label"
|
||||
class="nx-input"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-slate-300/80 mb-1">Farbe (HEX)</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="#22c55e"
|
||||
wire:model.lazy="tags.{{ $i }}.color"
|
||||
class="nx-input font-mono"
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
value="{{ $t['color'] ?? '#22c55e' }}"
|
||||
wire:change="pickTagColor({{ $i }}, $event.target.value)"
|
||||
class="h-9 w-9 rounded-md border border-white/15 bg-white/5 p-1"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<span class="text-xs text-slate-400">Palette:</span>
|
||||
@foreach($tagPalette as $hex)
|
||||
<button
|
||||
type="button"
|
||||
class="h-6 w-6 rounded-md border {{ ($t['color'] ?? '') === $hex ? 'ring-2 ring-white/80' : 'border-white/15' }}"
|
||||
style="background: {{ $hex }}"
|
||||
wire:click="pickTagColor({{ $i }}, '{{ $hex }}')"
|
||||
title="{{ $hex }}"
|
||||
></button>
|
||||
@endforeach
|
||||
<button
|
||||
type="button"
|
||||
wire:click="removeTag({{ $i }})"
|
||||
class="ml-auto inline-flex items-center gap-1 rounded-md border border-white/10 px-2 py-1 text-xs text-white/70 hover:text-white hover:border-white/20"
|
||||
>
|
||||
<i class="ph ph-trash-simple text-[12px]"></i> Entfernen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
|
||||
<button
|
||||
type="button"
|
||||
wire:click="addTag"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-white/80 hover:text-white hover:border-white/20">
|
||||
<i class="ph ph-plus text-[14px]"></i> Tag hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@error('tags.*.label') <div class="text-rose-400 text-xs mt-1">{{ $message }}</div> @enderror
|
||||
@error('tags.*.color') <div class="text-rose-400 text-xs mt-1">{{ $message }}</div> @enderror
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@push('modal.footer')
|
||||
<div class="px-5 py-3 border-t border-white/10 backdrop-blur rounded-b-2xl">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" wire:click="$dispatch('closeModal')"
|
||||
class="px-4 py-2 rounded-lg text-sm border border-white/10 text-white/70 hover:text-white hover:border-white/20">
|
||||
Abbrechen
|
||||
</button>
|
||||
|
||||
<button type="submit" form="domain-edit-form"
|
||||
class="px-4 py-2 rounded-lg text-sm bg-emerald-500/20 text-emerald-300 border border-emerald-400/30 hover:bg-emerald-500/30">
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endpush
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
@push('modal.header')
|
||||
<div class="px-5 pt-5 pb-3 border-b border-white/10
|
||||
backdrop-blur rounded-t-2xl relative">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">Domain-Limits</h2>
|
||||
<button wire:click="$dispatch('closeModal')" class="text-white/60 hover:text-white text-lg">
|
||||
<i class="ph ph-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endpush
|
||||
|
||||
<div class="p-6 space-y-5">
|
||||
<form wire:submit.prevent="save" id="domain-limits-form" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/70 mb-1">Max. mögliche Aliasse</label>
|
||||
<input type="number" wire:model.defer="max_aliases"
|
||||
class="w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm focus:border-white/20 focus:ring-0">
|
||||
@error('max_aliases') <div class="text-rose-400 text-xs mt-1">{{ $message }}</div> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/70 mb-1">Max. mögliche Mailboxen</label>
|
||||
<input type="number" wire:model.defer="max_mailboxes"
|
||||
class="w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm focus:border-white/20 focus:ring-0">
|
||||
@error('max_mailboxes') <div class="text-rose-400 text-xs mt-1">{{ $message }}</div> @enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/70 mb-1">Standard-Quota je Mailbox (MiB)</label>
|
||||
<input type="number" wire:model.defer="default_quota_mb"
|
||||
class="w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm focus:border-white/20 focus:ring-0">
|
||||
@error('default_quota_mb') <div class="text-rose-400 text-xs mt-1">{{ $message }}</div> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/70 mb-1">Speicher je Mailbox (MiB, optional)</label>
|
||||
<input type="number" wire:model.defer="max_quota_per_mailbox_mb"
|
||||
class="w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm focus:border-white/20 focus:ring-0">
|
||||
@error('max_quota_per_mailbox_mb') <div class="text-rose-400 text-xs mt-1">{{ $message }}</div> @enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/70 mb-1">Domain-Speicherplatz gesamt (MiB)</label>
|
||||
<input type="number" wire:model.defer="total_quota_mb"
|
||||
class="w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm focus:border-white/20 focus:ring-0">
|
||||
@error('total_quota_mb') <div class="text-rose-400 text-xs mt-1">{{ $message }}</div> @enderror
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/70 mb-1">Mails pro Stunde (optional)</label>
|
||||
<input type="number" wire:model.defer="rate_limit_per_hour" placeholder="leer = kein Limit"
|
||||
class="w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm focus:border-white/20 focus:ring-0">
|
||||
@error('rate_limit_per_hour') <div class="text-rose-400 text-xs mt-1">{{ $message }}</div> @enderror
|
||||
</div>
|
||||
<div class="flex items-center gap-3 md:mt-7">
|
||||
<label class="inline-flex items-center gap-3 select-none">
|
||||
<input type="checkbox" wire:model.defer="rate_limit_override"
|
||||
class="peer appearance-none w-5 h-5 rounded-md border border-emerald-500/50 bg-transparent checked:bg-emerald-500 checked:border-emerald-400 grid place-content-center transition">
|
||||
<span class="text-slate-200">Postfach-Overrides erlauben</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@push('modal.footer')
|
||||
<div class="px-5 py-3 border-t border-white/10 backdrop-blur rounded-b-2xl">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" wire:click="$dispatch('closeModal')"
|
||||
class="px-4 py-2 rounded-lg text-sm border border-white/10 text-white/70 hover:text-white hover:border-white/20">
|
||||
Abbrechen
|
||||
</button>
|
||||
|
||||
<button type="submit" form="domain-limits-form"
|
||||
class="px-4 py-2 rounded-lg text-sm bg-emerald-500/20 text-emerald-300 border border-emerald-400/30 hover:bg-emerald-500/30">
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endpush
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
<div class="space-y-4">
|
||||
|
||||
{{-- Suche + Aktion --}}
|
||||
<div class="">
|
||||
<div class="md:flex items-center justify-between gap-3">
|
||||
<h1 class="text-5xl #mb-6">Aliase</h1>
|
||||
</div>
|
||||
|
||||
<div class="my-4 md:flex items-center justify-end gap-3">
|
||||
<input
|
||||
type="text"
|
||||
wire:model.live.debounce.300ms="search"
|
||||
class="md:max-w-96 w-full justify-end flex-1 rounded-xl bg-white/5 border border-white/10 px-4 py-2.5 text-white/90 placeholder:text-white/50"
|
||||
placeholder="Suchen (Alias) …">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($domains->count())
|
||||
<div class="space-y-3">
|
||||
|
||||
@foreach($domains as $domain)
|
||||
<section class="glass-card rounded-xl border border-white/10 p-3">
|
||||
|
||||
{{-- Domain-Kopf --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 items-center gap-3">
|
||||
{{-- <div--}}
|
||||
{{-- class="flex items-center gap-1 w-fit px-2 py-0.5 rounded-full text-xs border border-white/15 text-white font-bold bg-white/5">--}}
|
||||
{{-- <i class="ph ph-globe-hemisphere-east text-[14px]"></i> {{ $domain->domain }}--}}
|
||||
{{-- </div>--}}
|
||||
|
||||
<div class="shrink-0 flex items-center gap-2 flex-wrap md:flex-nowrap">
|
||||
<div
|
||||
class="flex items-center gap-1 w-fit px-2 py-0.5 rounded-full text-xs border border-white/15 text-white font-bold bg-white/5">
|
||||
<i class="ph ph-globe-hemisphere-east text-[14px]"></i> {{ $domain->domain }}
|
||||
</div>
|
||||
|
||||
{{-- <span--}}
|
||||
{{-- class="px-2 py-0.5 rounded-full text-xs border {{ ($domain->is_active ?? true) ? 'border-emerald-400/30 text-emerald-300 bg-emerald-500/10' : 'border-white/15 text-white/60 bg-white/5' }}">--}}
|
||||
{{-- {{ ($domain->is_active ?? true) ? 'Aktiv' : 'Inaktiv' }}--}}
|
||||
{{-- </span>--}}
|
||||
<span
|
||||
class="px-2 py-0.5 rounded-full text-xs border border-white/15 text-white/70 bg-white/5 whitespace-nowrap">
|
||||
⧉ {{ $domain->mail_aliases_count ?? $domain->mailAliases_count ?? $domain->mailAliases->count() }} Aliasse
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2 text-right space-x-1">
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-white/80 hover:text-white hover:border-white/20"
|
||||
wire:click="openAliasCreate({{ $domain->id }})">
|
||||
<i class="ph ph-plus text-[12px]"></i> Alias
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Aliasse --}}
|
||||
<div class="mt-4 overflow-hidden">
|
||||
@if($domain->mailAliases->isEmpty())
|
||||
<div class="px-3 py-3 text-center text-xs bg-white/[0.04] text-white/60 italic">Keine
|
||||
Aliasse
|
||||
</div>
|
||||
@else
|
||||
<ul class="divide-y divide-white/5 space-y-3">
|
||||
@foreach($domain->mailAliases as $alias)
|
||||
<li class="">
|
||||
<div
|
||||
class="rounded-xl border border-white/10 flex flex-col md:flex-row md:items-center gap-3 px-3 py-3 bg-white/[0.04]">
|
||||
{{-- Alias + Badges --}}
|
||||
<div class="min-w-0 flex-1">
|
||||
|
||||
{{-- Pfeil-Zeile --}}
|
||||
<div
|
||||
class="text-white/90 font-semibold truncate flex items-center gap-2">
|
||||
<span
|
||||
class="truncate text-xs px-2 py-0.5 rounded-md bg-white/5 text-white/80 border border-white/10">{{ $alias->sourceEmail }}</span>
|
||||
@if($alias->effective_active)
|
||||
<i class="ph ph-play text-emerald-400 animate-pulse text-xs"></i>
|
||||
@else
|
||||
<i class="ph ph-pause text-white/30 text-xs"></i>
|
||||
@endif
|
||||
<span
|
||||
class="truncate text-xs px-2 py-0.5 rounded-md bg-white/5 text-white/80 border border-white/10">{{ $alias->arrowTarget }}</span>
|
||||
</div>
|
||||
|
||||
|
||||
{{-- Badges --}}
|
||||
<div class="mt-2 flex items-center gap-2 flex-wrap md:flex-nowrap">
|
||||
|
||||
@if(!$alias->effective_active && $alias->inactive_reason)
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs border border-amber-400/30 text-amber-300 bg-amber-500/10 whitespace-nowrap">
|
||||
{{ $alias->inactive_reason }}
|
||||
</span>
|
||||
@else
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs border whitespace-nowrap border-emerald-400/30 text-emerald-300 bg-emerald-500/10">
|
||||
Aktiv
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@if($alias->isSingle)
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs border
|
||||
border-sky-400/30 text-sky-200 bg-sky-500/10 whitespace-nowrap">
|
||||
Single
|
||||
</span>
|
||||
@else
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs border
|
||||
border-fuchsia-400/30 text-fuchsia-200 bg-fuchsia-500/10 whitespace-nowrap">
|
||||
Gruppe
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs border border-white/15 text-white/70 bg-white/5 whitespace-nowrap">
|
||||
Empfänger: {{ $alias->recipientCount }}
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@if(!empty($alias->notes))
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs border border-white/15 text-white/60 bg-white/5 whitespace-nowrap">
|
||||
<i class="ph ph-note text-[12px] mr-1"></i> Notiz
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<hr class="md:hidden">
|
||||
|
||||
{{-- Aktionen --}}
|
||||
<div class="md:ml-3 flex items-center gap-2 md:justify-end">
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-white/80 hover:text-white hover:border-white/20"
|
||||
wire:click="openAliasEdit({{ $alias->id }})">
|
||||
<i class="ph ph-pencil-simple text-[12px]"></i> Bearbeiten
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs bg-rose-500/20 text-rose-200 border border-rose-400/40 hover:bg-rose-500/30"
|
||||
wire:click="openAliasDelete({{ $alias->id }})">
|
||||
<i class="ph ph-trash-simple text-[12px]"></i> Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
</section>
|
||||
@endforeach
|
||||
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-6 text-white/75 text-center">
|
||||
Noch keine Aliasse vorhanden.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -0,0 +1,330 @@
|
|||
<div class="space-y-4">
|
||||
|
||||
{{-- Suche + Aktion --}}
|
||||
<div class="">
|
||||
<div class="md:flex items-center justify-between gap-3">
|
||||
<h1 class="text-5xl #mb-6">Mailboxen</h1>
|
||||
</div>
|
||||
|
||||
<div class="my-4 md:flex items-center justify-end gap-3">
|
||||
<input
|
||||
type="text"
|
||||
wire:model.live.debounce.300ms="search"
|
||||
class="md:max-w-96 w-full justify-end flex-1 rounded-xl bg-white/5 border border-white/10 px-4 py-2.5 text-white/90 placeholder:text-white/50"
|
||||
placeholder="Suchen (Domain oder Postfach) …">
|
||||
</div>
|
||||
@if($domains->count())
|
||||
<div class="space-y-3">
|
||||
|
||||
@foreach($domains as $domain)
|
||||
<div class="glass-card rounded-xl border border-white/10 #bg-white/[0.04] p-3">
|
||||
{{-- Domain-Kopf --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 items-center gap-3">
|
||||
{{-- Badges: auf md niemals umbrechen --}}
|
||||
<div class="shrink-0 flex items-center gap-2 flex-wrap md:flex-nowrap">
|
||||
<div
|
||||
class="flex items-center gap-1 w-fit px-2 py-0.5 rounded-full text-xs border border-white/15 text-white font-bold bg-white/5">
|
||||
<i class="ph ph-globe-hemisphere-east text-[14px]"></i> {{ $domain->domain }}
|
||||
</div>
|
||||
|
||||
{{-- <span--}}
|
||||
{{-- class="px-2 py-0.5 rounded-full text-xs border {{ ($domain->is_active ?? true) ? 'border-emerald-400/30 text-emerald-300 bg-emerald-500/10' : 'border-white/15 text-white/60 bg-white/5' }}">--}}
|
||||
{{-- {{ ($domain->is_active ?? true) ? 'aktiv' : 'inaktiv' }}--}}
|
||||
{{-- </span>--}}
|
||||
|
||||
<span
|
||||
class="px-2 py-0.5 rounded-full text-xs border border-white/15 text-white/70 bg-white/5 whitespace-nowrap">
|
||||
<i class="ph ph-user-list text-[12px]"></i> {{ $domain->mail_users_count }} Postfächer
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2 text-right space-x-1">
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-white/80 hover:text-white hover:border-white/20"
|
||||
wire:click="openMailboxCreate({{ $domain->id }})">
|
||||
<i class="ph ph-plus text-[12px]"></i> Postfach
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{{-- Postfächer --}}
|
||||
{{-- <div class="mt-4 overflow-hidden">--}}
|
||||
{{-- <div class="divide-y divide-white/5 space-y-3">--}}
|
||||
{{-- @forelse($domain->prepared_mailboxes as $u)--}}
|
||||
|
||||
{{-- <div class="rounded-xl border border-white/10 flex flex-col md:flex-row md:items-center gap-3 px-3 py-3 bg-white/[0.04]">--}}
|
||||
{{-- <div class="min-w-0 flex-1">--}}
|
||||
{{-- <div class="text-white/90 font-medium truncate">--}}
|
||||
{{-- {{ $u['localpart'] !== '' ? ($u['localpart'].'@'.$domain->domain) : '—' }}--}}
|
||||
{{-- </div>--}}
|
||||
|
||||
{{-- <div class="mt-1 flex items-center gap-2 flex-wrap md:flex-nowrap">--}}
|
||||
{{-- <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs border whitespace-nowrap--}}
|
||||
{{-- {{ $u['is_effective_active'] ? 'border-emerald-400/30 text-emerald-300 bg-emerald-500/10'--}}
|
||||
{{-- : 'border-white/15 text-white/60 bg-white/5' }}">--}}
|
||||
{{-- {{ $u['is_effective_active'] ? 'aktiv' : 'inaktiv' }}--}}
|
||||
{{-- </span>--}}
|
||||
{{-- @if(!$u['is_effective_active'] && !empty($u['inactive_reason']))--}}
|
||||
{{-- <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs border border-amber-400/30 text-amber-300 bg-amber-500/10 whitespace-nowrap">--}}
|
||||
{{-- {{ $u['inactive_reason'] }}--}}
|
||||
{{-- </span>--}}
|
||||
{{-- @endif--}}
|
||||
|
||||
{{-- <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs border border-white/15 text-white/70 bg-white/5 whitespace-nowrap">--}}
|
||||
{{-- Max {{ $u['quota_mb'] }} MiB--}}
|
||||
{{-- </span>--}}
|
||||
{{-- <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs border border-white/15 text-white/70 bg-white/5 whitespace-nowrap">--}}
|
||||
{{-- Verbraucht: {{ $u['used_mb'] }} MiB ({{ $u['usage_percent'] }} %)--}}
|
||||
{{-- </span>--}}
|
||||
{{-- <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs border border-white/15 text-white/70 bg-white/5 whitespace-nowrap">--}}
|
||||
{{-- <i class="ph ph-at text-[12px] mr-1"></i> {{ $u['message_count'] }} E-Mails--}}
|
||||
{{-- </span>--}}
|
||||
{{-- </div>--}}
|
||||
{{-- <div class="mt-2 h-1.5 rounded-full bg-white/10 overflow-hidden {{ $u['is_effective_active'] ? '' : 'opacity-40' }}">--}}
|
||||
{{-- <div class="h-full rounded-full bg-emerald-400/50" style="width: {{ $u['usage_percent'] }}%"></div>--}}
|
||||
{{-- </div>--}}
|
||||
{{-- </div>--}}
|
||||
|
||||
{{-- <hr class="md:hidden">--}}
|
||||
|
||||
{{-- Aktionen--}}
|
||||
{{-- <div class="md:ml-3 flex items-center gap-2 md:justify-end">--}}
|
||||
{{-- Bearbeiten--}}
|
||||
{{-- <button--}}
|
||||
{{-- class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-white/80 hover:text-white hover:border-white/20"--}}
|
||||
{{-- wire:click="openMailboxEdit({{ $u['id'] }})">--}}
|
||||
{{-- <i class="ph ph-pencil-simple text-[12px]"></i> Bearbeiten--}}
|
||||
{{-- </button>--}}
|
||||
|
||||
{{-- Löschen--}}
|
||||
{{-- <button--}}
|
||||
{{-- class="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs bg-rose-500/20 text-rose-200 border border-rose-400/40 hover:bg-rose-500/30"--}}
|
||||
{{-- wire:click="openMailboxDelete({{ $u['id'] }})">--}}
|
||||
{{-- <i class="ph ph-trash-simple text-[12px]"></i> Löschen--}}
|
||||
{{-- </button>--}}
|
||||
{{-- </div>--}}
|
||||
{{-- </div>--}}
|
||||
|
||||
{{-- @empty--}}
|
||||
{{-- <div class="px-3 py-3 text-center text-xs bg-white/[0.04] text-white/60 italic">Keine Postfächer</div>--}}
|
||||
{{-- @endforelse--}}
|
||||
{{-- </div>--}}
|
||||
{{-- </div>--}}
|
||||
{{-- Postfächer (responsive: Cards < md, Table ≥ md) --}}
|
||||
<div class="mt-4">
|
||||
|
||||
{{-- MOBILE: Card-View --}}
|
||||
<div class="md:hidden space-y-3">
|
||||
@forelse($domain->prepared_mailboxes as $u)
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold text-white truncate">
|
||||
{{ $u['localpart'] ? ($u['localpart'].'@'.$domain->domain) : '—' }}
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2">
|
||||
<span class="px-2 py-0.5 rounded-full text-xs border
|
||||
{{ $u['is_effective_active'] ? 'border-emerald-400/30 text-emerald-300 bg-emerald-500/10'
|
||||
: 'border-white/15 text-white/60 bg-white/5' }}">
|
||||
{{ $u['is_effective_active'] ? 'aktiv' : 'inaktiv' }}
|
||||
</span>
|
||||
{{-- Optional: Grund anzeigen --}}
|
||||
@if(!$u['is_effective_active'] && !empty($u['inactive_reason']))
|
||||
<span
|
||||
class="px-2 py-0.5 rounded-full text-xs border border-amber-400/30 text-amber-300 bg-amber-500/10">
|
||||
{{ $u['inactive_reason'] }}
|
||||
</span>
|
||||
@endif
|
||||
|
||||
<span
|
||||
class="px-2 py-0.5 rounded-full text-xs border border-white/15 text-white/70 bg-white/5">
|
||||
{{ $u['quota_mb'] }} MiB
|
||||
</span>
|
||||
<span
|
||||
class="px-2 py-0.5 rounded-full text-xs border border-white/15 text-white/70 bg-white/5">
|
||||
Verbraucht: {{ $u['used_mb'] }} MiB ({{ $u['usage_percent'] }} %)
|
||||
</span>
|
||||
<span
|
||||
class="px-2 py-0.5 rounded-full text-xs border border-white/15 text-white/70 bg-white/5">
|
||||
<i class="ph ph-at mr-1 text-[12px]"></i> {{ $u['message_count'] }} E-Mails
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0 flex flex-col gap-2">
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-white/80 hover:text-white hover:border-white/20"
|
||||
wire:click="openMailboxEdit({{ $u['id'] }})">
|
||||
<i class="ph ph-pencil-simple text-[12px]"></i> Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs bg-rose-500/20 text-rose-200 border border-rose-400/40 hover:bg-rose-500/30"
|
||||
wire:click="openMailboxDelete({{ $u['id'] }})">
|
||||
<i class="ph ph-trash-simple text-[12px]"></i> Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Progress --}}
|
||||
<div
|
||||
class="mt-3 h-1.5 rounded-full bg-white/10 overflow-hidden {{ $u['is_effective_active'] ? '' : 'opacity-40' }}">
|
||||
<div class="h-full rounded-full bg-emerald-400/50"
|
||||
style="width: {{ $u['usage_percent'] }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="px-3 py-3 text-center text-xs bg-white/5 text-white/60 italic">Keine
|
||||
Postfächer
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
{{-- DESKTOP: Table-View --}}
|
||||
{{-- <div class="hidden md:block overflow-x-auto">--}}
|
||||
{{-- <table class="min-w-full text-sm">--}}
|
||||
{{-- <thead>--}}
|
||||
{{-- <tr class="text-white/60 bg-white/5">--}}
|
||||
{{-- <th class="px-3 py-2 text-left font-medium">E-Mail</th>--}}
|
||||
{{-- <th class="px-3 py-2 text-left font-medium">Status</th>--}}
|
||||
{{-- <th class="px-3 py-2 text-left font-medium">Quota</th>--}}
|
||||
{{-- <th class="px-3 py-2 text-left font-medium">Verbrauch</th>--}}
|
||||
{{-- <th class="px-3 py-2 text-left font-medium"><i class="ph ph-at"></i> E-Mails</th>--}}
|
||||
{{-- <th class="px-3 py-2 text-left font-medium w-64">Auslastung</th>--}}
|
||||
{{-- <th class="px-3 py-2 text-right font-medium">Aktionen</th>--}}
|
||||
{{-- </tr>--}}
|
||||
{{-- </thead>--}}
|
||||
{{-- <tbody class="divide-y divide-white/5">--}}
|
||||
{{-- @forelse($domain->prepared_mailboxes as $u)--}}
|
||||
{{-- <tr class="hover:bg-white/[0.04]">--}}
|
||||
{{-- <td class="px-3 py-2 font-medium text-white truncate max-w-[280px]">--}}
|
||||
{{-- {{ $u['localpart'] ? ($u['localpart'].'@'.$domain->domain) : '—' }}--}}
|
||||
{{-- </td>--}}
|
||||
{{-- <td class="px-3 py-2">--}}
|
||||
{{-- <span class="px-2 py-0.5 rounded-full text-xs border--}}
|
||||
{{-- {{ $u['is_effective_active'] ? 'border-emerald-400/30 text-emerald-300 bg-emerald-500/10'--}}
|
||||
{{-- : 'border-white/15 text-white/60 bg-white/5' }}">--}}
|
||||
{{-- {{ $u['is_effective_active'] ? 'aktiv' : 'inaktiv' }}--}}
|
||||
{{-- </span>--}}
|
||||
{{-- @if(!$u['is_effective_active'] && !empty($u['inactive_reason']))--}}
|
||||
{{-- <span class="ml-2 px-2 py-0.5 rounded-full text-xs border border-amber-400/30 text-amber-300 bg-amber-500/10">--}}
|
||||
{{-- {{ $u['inactive_reason'] }}--}}
|
||||
{{-- </span>--}}
|
||||
{{-- @endif--}}
|
||||
{{-- </td>--}}
|
||||
{{-- <td class="px-3 py-2 text-white/80">{{ $u['quota_mb'] }} MiB</td>--}}
|
||||
{{-- <td class="px-3 py-2 text-white/80">{{ $u['used_mb'] }} MiB ({{ $u['usage_percent'] }} %)</td>--}}
|
||||
{{-- <td class="px-3 py-2 text-white/80">{{ $u['message_count'] }}</td>--}}
|
||||
{{-- <td class="px-3 py-2">--}}
|
||||
{{-- <div class="h-1.5 rounded-full bg-white/10 overflow-hidden {{ $u['is_effective_active'] ? '' : 'opacity-40' }}">--}}
|
||||
{{-- <div class="h-full rounded-full bg-emerald-400/50" style="width: {{ $u['usage_percent'] }}%"></div>--}}
|
||||
{{-- </div>--}}
|
||||
{{-- </td>--}}
|
||||
{{-- <td class="px-3 py-2">--}}
|
||||
{{-- <div class="flex items-center gap-2 justify-end">--}}
|
||||
{{-- <button--}}
|
||||
{{-- class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-white/80 hover:text-white hover:border-white/20"--}}
|
||||
{{-- wire:click="openMailboxEdit({{ $u['id'] }})">--}}
|
||||
{{-- <i class="ph ph-pencil-simple text-[12px]"></i> Bearbeiten--}}
|
||||
{{-- </button>--}}
|
||||
{{-- <button--}}
|
||||
{{-- class="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs bg-rose-500/20 text-rose-200 border border-rose-400/40 hover:bg-rose-500/30"--}}
|
||||
{{-- wire:click="openMailboxDelete({{ $u['id'] }})">--}}
|
||||
{{-- <i class="ph ph-trash-simple text-[12px]"></i> Löschen--}}
|
||||
{{-- </button>--}}
|
||||
{{-- </div>--}}
|
||||
{{-- </td>--}}
|
||||
{{-- </tr>--}}
|
||||
{{-- @empty--}}
|
||||
{{-- <tr>--}}
|
||||
{{-- <td colspan="7" class="px-3 py-4 text-center text-xs text-white/60 italic">--}}
|
||||
{{-- Keine Postfächer--}}
|
||||
{{-- </td>--}}
|
||||
{{-- </tr>--}}
|
||||
{{-- @endforelse--}}
|
||||
{{-- </tbody>--}}
|
||||
{{-- </table>--}}
|
||||
{{-- </div>--}}
|
||||
|
||||
<div class="hidden md:block overflow-x-auto">
|
||||
<table class="min-w-full text-sm border-separate border-spacing-y-2">
|
||||
{{-- HEADER: komplette Kapsel, alle Ecken rund --}}
|
||||
<thead>
|
||||
<tr class="bg-white/[0.06] text-white/70">
|
||||
<th class="px-3 py-2 text-left font-medium rounded-l-xl">E-Mail</th>
|
||||
<th class="px-3 py-2 text-left font-medium">Status</th>
|
||||
<th class="px-3 py-2 text-left font-medium">Quota</th>
|
||||
<th class="px-3 py-2 text-left font-medium">Verbrauch</th>
|
||||
<th class="px-3 py-2 text-left font-medium"><i class="ph ph-at"></i> E-Mails
|
||||
</th>
|
||||
<th class="px-3 py-2 text-left font-medium">Auslastung</th>
|
||||
<th class="px-3 py-2 text-right font-medium rounded-r-xl">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
@forelse($domain->prepared_mailboxes as $u)
|
||||
{{-- ROW: Kapsel beim Hover, ohne Border/Ring --}}
|
||||
<tr class="bg-white/[0.03] hover:bg-white/[0.06] transition-colors duration-200">
|
||||
<td class="px-3 py-2 font-medium text-white truncate max-w-[280px] rounded-l-xl">
|
||||
{{ $u['localpart'] ? ($u['localpart'].'@'.$domain->domain) : '—' }}
|
||||
</td>
|
||||
|
||||
<td class="px-3 py-2">
|
||||
@if(!$u['is_effective_active'] && !empty($u['inactive_reason']))
|
||||
<span class="#ml-2 px-2 py-0.5 rounded-full text-xs border border-amber-400/30 text-amber-300 bg-amber-500/10">
|
||||
{{ $u['inactive_reason'] }}
|
||||
</span>
|
||||
@else
|
||||
<span class="px-2 py-0.5 rounded-full text-xs border border-emerald-400/30 text-emerald-300 bg-emerald-500/10">
|
||||
Aktiv
|
||||
</span>
|
||||
@endif
|
||||
|
||||
</td>
|
||||
|
||||
<td class="px-3 py-2 text-white/80">{{ $u['quota_mb'] }} MiB</td>
|
||||
<td class="px-3 py-2 text-white/80">{{ $u['used_mb'] }} MiB
|
||||
({{ $u['usage_percent'] }} %)
|
||||
</td>
|
||||
<td class="px-3 py-2 text-white/80">{{ $u['message_count'] }}</td>
|
||||
|
||||
<td class="px-3 py-2">
|
||||
<div
|
||||
class="h-1.5 rounded-full bg-white/10 overflow-hidden {{ $u['is_effective_active'] ? '' : 'opacity-40' }}">
|
||||
<div class="h-full rounded-full bg-emerald-400/50"
|
||||
style="width: {{ $u['usage_percent'] }}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="px-3 py-2 rounded-r-xl">
|
||||
<div class="flex items-center gap-2 justify-end">
|
||||
<button wire:click="openMailboxEdit({{ $u['id'] }})"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-white/80 hover:text-white hover:border-white/20">
|
||||
<i class="ph ph-pencil-simple text-[12px]"></i> Bearbeiten
|
||||
</button>
|
||||
<button wire:click="openMailboxDelete({{ $u['id'] }})"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs bg-rose-500/20 text-rose-200 border border-rose-400/40 hover:bg-rose-500/30">
|
||||
<i class="ph ph-trash-simple text-[12px]"></i> Löschen
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="7" class="px-3 py-4 text-center text-xs text-white/60 italic">
|
||||
Keine Postfächer
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-6 text-white/75 text-center">
|
||||
Noch keine Mailboxen vorhanden.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
@push('modal.header')
|
||||
<div class="px-5 pt-5 pb-3 border-b border-white/10 backdrop-blur rounded-t-2xl">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">Alias löschen</h2>
|
||||
<button wire:click="$dispatch('closeModal')" class="text-white/60 hover:text-white text-lg">
|
||||
<i class="ph ph-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endpush
|
||||
|
||||
<div class="p-6 space-y-5">
|
||||
{{-- Warnung --}}
|
||||
<div class="rounded-xl border border-rose-500/30 bg-rose-500/10 p-3 text-rose-200">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="ph ph-warning text-[18px]"></i>
|
||||
<span class="text-sm">Diese Aktion kann nicht rückgängig gemacht werden.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Alias-Info --}}
|
||||
<div class="space-y-2 text-white/85">
|
||||
<div>
|
||||
Du bist im Begriff, den Alias
|
||||
<span class="font-semibold text-white">{{ $aliasEmail }}</span>
|
||||
zu löschen.
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="px-2 py-0.5 rounded-full border border-white/15 text-white/70 bg-white/5">
|
||||
Typ: {{ $isSingle ? 'Single' : 'Gruppe' }}
|
||||
</span>
|
||||
<span class="px-2 py-0.5 rounded-full border border-white/15 text-white/70 bg-white/5">
|
||||
Weiterleitung: {{ $targetLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if($extraRecipients > 0)
|
||||
<div class="text-xs text-white/60">+{{ $extraRecipients }} weitere Empfänger</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Bestätigung --}}
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm text-white/70">
|
||||
Zur Bestätigung tippe die Alias-Adresse ein:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
wire:model.live="confirm"
|
||||
placeholder="{{ $aliasEmail }}"
|
||||
class="w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm focus:border-white/20 focus:ring-0"
|
||||
/>
|
||||
@error('confirm') <div class="text-rose-400 text-xs mt-1">{{ $message }}</div> @enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('modal.footer')
|
||||
<div x-data="{ valid: @entangle('confirmMatches') }"
|
||||
class="px-5 py-3 border-t border-white/10 backdrop-blur rounded-b-2xl">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" wire:click="$dispatch('closeModal')"
|
||||
class="px-4 py-2 rounded-lg text-sm border border-white/10 text-white/70 hover:text-white hover:border-white/20">
|
||||
Abbrechen
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
wire:click="$dispatch('alias:delete')"
|
||||
:disabled="!valid"
|
||||
:class="valid
|
||||
? 'bg-rose-500/20 text-rose-200 border border-rose-400/40 hover:bg-rose-500/30'
|
||||
: 'border border-white/10 bg-white/5 text-white/40 cursor-not-allowed'"
|
||||
class="px-4 py-2 rounded-lg text-sm transition-colors">
|
||||
<i class="ph ph-trash-simple"></i> Endgültig löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- <div class="px-5 py-3 border-t border-white/10 backdrop-blur rounded-b-2xl">--}}
|
||||
{{-- <div class="flex justify-end gap-2">--}}
|
||||
{{-- <button type="button" wire:click="$dispatch('closeModal')"--}}
|
||||
{{-- class="px-4 py-2 rounded-lg text-sm border border-white/10 text-white/70 hover:text-white hover:border-white/20">--}}
|
||||
{{-- Abbrechen--}}
|
||||
{{-- </button>--}}
|
||||
|
||||
{{-- <button--}}
|
||||
{{-- type="button"--}}
|
||||
{{-- wire:click="$dispatch('alias:delete')"--}}
|
||||
{{-- @disabled(! $confirmMatches)--}}
|
||||
{{-- class="px-4 py-2 rounded-lg text-sm--}}
|
||||
{{-- {{ $confirmMatches--}}
|
||||
{{-- ? 'bg-rose-500/20 text-rose-200 border border-rose-400/40 hover:bg-rose-500/30'--}}
|
||||
{{-- : 'border border-white/10 bg-white/5 text-white/40 cursor-not-allowed' }}">--}}
|
||||
{{-- <i class="ph ph-trash-simple"></i> Endgültig löschen--}}
|
||||
{{-- </button>--}}
|
||||
{{-- </div>--}}
|
||||
{{-- </div>--}}
|
||||
@endpush
|
||||
|
|
@ -0,0 +1,322 @@
|
|||
@push('modal.header')
|
||||
<div class="px-5 pt-5 pb-3 border-b border-white/10 backdrop-blur rounded-t-2xl">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">
|
||||
{{ $aliasId ? 'Alias bearbeiten' : 'Alias anlegen' }}
|
||||
</h2>
|
||||
<p class="text-[13px] text-white/60">
|
||||
{{ $aliasId ? 'Passe den Alias und seine Empfänger an.' : 'Lege Adresse und Empfänger fest.' }}
|
||||
</p>
|
||||
</div>
|
||||
<button wire:click="$dispatch('closeModal')" class="text-white/60 hover:text-white text-lg">
|
||||
<i class="ph ph-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endpush
|
||||
|
||||
<div class="p-6 space-y-5">
|
||||
{{-- alles in ein Formular, wie beim Limits-Modal --}}
|
||||
<form id="alias-form" wire:submit.prevent="save" class="space-y-4">
|
||||
{{-- Domain + Adresse + Status --}}
|
||||
|
||||
<div class="flex items-end">
|
||||
<label class="inline-flex items-center gap-3 select-none">
|
||||
<input type="checkbox" wire:model="is_active"
|
||||
class="peer appearance-none w-5 h-5 rounded-md border border-emerald-500/50 bg-transparent
|
||||
checked:bg-emerald-500 checked:border-emerald-400 grid place-content-center transition">
|
||||
<span class="text-slate-200">Alias aktivieren</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{{-- Row 1: Domain + Typ --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
|
||||
{{-- DOMAIN (TailwindPlus Elements) --}}
|
||||
<div class="relative" wire:ignore id="domain-select-{{ $this->getId() }}">
|
||||
<label class="block text-xs text-white/60 mb-1">Domain</label>
|
||||
|
||||
<el-select id="el-domain-{{ $this->getId() }}" name="domain_id" class="block w-full">
|
||||
{{-- Trigger --}}
|
||||
<button type="button"
|
||||
class="grid w-full grid-cols-1 cursor-default rounded-2xl bg-white/5 border border-white/10 px-3.5 h-11 text-white/90">
|
||||
<el-selectedcontent class="col-start-1 row-start-1 flex items-center gap-2 pr-6">
|
||||
<i class="ph ph-globe-hemisphere-west text-white/60 text-[16px]"></i>
|
||||
<span class="block truncate" data-selected-label>
|
||||
{{ optional($domain)->domain ?? 'Domain wählen' }}
|
||||
</span>
|
||||
</el-selectedcontent>
|
||||
<i class="ph ph-caret-down col-start-1 row-start-1 self-center justify-self-end text-white/50"></i>
|
||||
</button>
|
||||
|
||||
{{-- Optionen --}}
|
||||
<el-options anchor="bottom start" popover
|
||||
class="max-h-60 w-(--button-width) overflow-auto rounded-2xl bg-gray-900/95 backdrop-blur
|
||||
py-1 text-base outline-1 -outline-offset-1 outline-white/10 shadow-xl sm:text-sm
|
||||
data-leave:transition data-leave:duration-100 data-leave:ease-in data-closed:data-leave:opacity-0">
|
||||
@foreach($domains as $d)
|
||||
<el-option
|
||||
value="{{ $d->id }}"
|
||||
data-value="{{ $d->id }}"
|
||||
data-label="{{ $d->domain }}"
|
||||
@if($domain && $domain->id === $d->id) aria-selected="true" @endif
|
||||
class="group/option relative block cursor-default py-2 pr-9 pl-3 text-white/90 select-none
|
||||
hover:bg-white/5 focus:bg-white/10 focus:text-white focus:outline-hidden"
|
||||
onclick="(function(el){
|
||||
const root = el.closest('#domain-select-{{ $this->getId() }}');
|
||||
root.querySelectorAll('el-option').forEach(o => o.removeAttribute('aria-selected'));
|
||||
el.setAttribute('aria-selected','true');
|
||||
const labelEl = root.querySelector('[data-selected-label]');
|
||||
if (labelEl) { labelEl.textContent = el.dataset.label; }
|
||||
@this.set('domainId', parseInt(el.dataset.value, 10));
|
||||
root.querySelector('button')?.focus();
|
||||
})(this)">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="ph ph-globe-hemisphere-west text-white/60"></i>
|
||||
<span
|
||||
class="block truncate group-aria-selected/option:font-semibold">{{ $d->domain }}</span>
|
||||
</div>
|
||||
<span class="absolute inset-y-0 right-0 hidden items-center pr-3 text-emerald-300
|
||||
group-aria-selected/option:flex in-[el-selectedcontent]:hidden">
|
||||
<i class="ph ph-check text-[18px]"></i>
|
||||
</span>
|
||||
</el-option>
|
||||
@endforeach
|
||||
</el-options>
|
||||
</el-select>
|
||||
|
||||
@error('domainId')
|
||||
<div class="text-rose-300 text-xs mt-1">{{ $message }}</div> @enderror
|
||||
</div>
|
||||
|
||||
{{-- TYP (TailwindPlus Elements) --}}
|
||||
<div class="relative" wire:ignore id="type-select-{{ $this->getId() }}">
|
||||
<label class="block text-xs text-white/60 mb-1">Typ</label>
|
||||
|
||||
<el-select id="el-type-{{ $this->getId() }}" name="alias_type" class="block w-full">
|
||||
{{-- Trigger --}}
|
||||
<button type="button"
|
||||
class="grid w-full grid-cols-1 cursor-default rounded-2xl bg-white/5 border border-white/10 px-3.5 h-11 text-white/90">
|
||||
<el-selectedcontent class="col-start-1 row-start-1 flex items-center gap-2 pr-6">
|
||||
<i class="ph {{ $type === 'group' ? 'ph-users' : 'ph-user' }} text-white/60 text-[16px]"></i>
|
||||
<span class="block truncate" data-selected-label-type>
|
||||
{{ $type === 'group' ? 'Gruppe' : 'Single' }}
|
||||
</span>
|
||||
</el-selectedcontent>
|
||||
<i class="ph ph-caret-down col-start-1 row-start-1 self-center justify-self-end text-white/50"></i>
|
||||
</button>
|
||||
|
||||
{{-- Optionen --}}
|
||||
<el-options anchor="bottom start" popover
|
||||
class="max-h-60 w-(--button-width) overflow-auto rounded-2xl bg-gray-900/95 backdrop-blur
|
||||
py-1 text-base outline-1 -outline-offset-1 outline-white/10 shadow-xl sm:text-sm
|
||||
data-leave:transition data-leave:duration-100 data-leave:ease-in data-closed:data-leave:opacity-0">
|
||||
|
||||
{{-- Single --}}
|
||||
<el-option data-value="single" data-label="Single"
|
||||
@if($type === 'single') aria-selected="true" @endif
|
||||
class="group/option relative block cursor-default py-2 pr-9 pl-3 text-white/90 select-none
|
||||
hover:bg-white/5 focus:bg-white/10 focus:text-white focus:outline-hidden"
|
||||
onclick="(function(el){
|
||||
const root = el.closest('#type-select-{{ $this->getId() }}');
|
||||
root.querySelectorAll('el-option').forEach(o => o.removeAttribute('aria-selected'));
|
||||
el.setAttribute('aria-selected','true');
|
||||
const lbl = root.querySelector('[data-selected-label-type]');
|
||||
if (lbl) { lbl.textContent = el.dataset.label; }
|
||||
// Icon im Trigger wechseln
|
||||
const icon = root.querySelector('.ph-user, .ph-users');
|
||||
if (icon) { icon.className = 'ph ph-user text-white/60 text-[16px]'; }
|
||||
@this.set('type', 'single');
|
||||
root.querySelector('button')?.focus();
|
||||
})(this)">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="ph ph-user text-white/60"></i>
|
||||
<span class="block truncate group-aria-selected/option:font-semibold">Single</span>
|
||||
</div>
|
||||
<span class="absolute inset-y-0 right-0 hidden items-center pr-3 text-emerald-300
|
||||
group-aria-selected/option:flex in-[el-selectedcontent]:hidden">
|
||||
<i class="ph ph-check text-[18px]"></i>
|
||||
</span>
|
||||
</el-option>
|
||||
|
||||
{{-- Gruppe --}}
|
||||
<el-option data-value="group" data-label="Gruppe"
|
||||
@if($type === 'group') aria-selected="true" @endif
|
||||
class="group/option relative block cursor-default py-2 pr-9 pl-3 text-white/90 select-none
|
||||
hover:bg-white/5 focus:bg-white/10 focus:text-white focus:outline-hidden"
|
||||
onclick="(function(el){
|
||||
const root = el.closest('#type-select-{{ $this->getId() }}');
|
||||
root.querySelectorAll('el-option').forEach(o => o.removeAttribute('aria-selected'));
|
||||
el.setAttribute('aria-selected','true');
|
||||
const lbl = root.querySelector('[data-selected-label-type]');
|
||||
if (lbl) { lbl.textContent = el.dataset.label; }
|
||||
const icon = root.querySelector('.ph-user, .ph-users');
|
||||
if (icon) { icon.className = 'ph ph-users text-white/60 text-[16px]'; }
|
||||
@this.set('type', 'group');
|
||||
root.querySelector('button')?.focus();
|
||||
})(this)">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="ph ph-users text-white/60"></i>
|
||||
<span class="block truncate group-aria-selected/option:font-semibold">Gruppe</span>
|
||||
</div>
|
||||
<span class="absolute inset-y-0 right-0 hidden items-center pr-3 text-emerald-300
|
||||
group-aria-selected/option:flex in-[el-selectedcontent]:hidden">
|
||||
<i class="ph ph-check text-[18px]"></i>
|
||||
</span>
|
||||
</el-option>
|
||||
</el-options>
|
||||
</el-select>
|
||||
|
||||
@error('type')
|
||||
<div class="text-rose-300 text-xs mt-1">{{ $message }}</div> @enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Row 2: Adresse (volle Breite, gleiche Höhe) --}}
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Adresse</label>
|
||||
<div class="flex">
|
||||
<input type="text" wire:model.defer="local" placeholder="info"
|
||||
class="flex-1 rounded-l-2xl bg-white/5 border border-white/10 text-white px-3 h-11 text-sm focus:border-white/20 focus:ring-0">
|
||||
<span
|
||||
class="inline-flex items-center px-3 rounded-r-2xl border border-l-0 border-white/10 bg-white/5 text-white/70 text-sm h-11">
|
||||
@ {{ optional($domain)->domain }}
|
||||
</span>
|
||||
</div>
|
||||
@error('local')
|
||||
<div class="mt-1 text-xs text-rose-300">{{ $message }}</div> @enderror
|
||||
</div>
|
||||
|
||||
@if($type === 'group')
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Gruppenname (optional)</label>
|
||||
<input type="text" wire:model.defer="group_name"
|
||||
class="w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 h-11 text-sm focus:border-white/20 focus:ring-0"
|
||||
placeholder="z. B. Office Gruppe">
|
||||
@error('group_name') <div class="mt-1 text-xs text-rose-300">{{ $message }}</div> @enderror
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Empfänger --}}
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm text-white/80">Empfänger</h3>
|
||||
<div class="text-end">
|
||||
@if($type === 'single')
|
||||
<div class="text-[11px] text-white/50 mt-1">Bei „Single“ ist nur ein Empfänger erlaubt.</div>
|
||||
@else
|
||||
<button type="button"
|
||||
wire:click="addRecipientRow"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs
|
||||
text-white/80 hover:text-white hover:border-white/20 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
@disabled(! $canAddRecipient)>
|
||||
<i class="ph ph-plus"></i> Empfänger
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@error('recipients')
|
||||
<div class="text-xs text-rose-300">{{ $message }}</div>
|
||||
@enderror
|
||||
|
||||
{{-- WICHTIG: stabiler Container-Key um die Liste --}}
|
||||
<div class="space-y-4" wire:key="recipients-container">
|
||||
@foreach ($recipients as $idx => $r)
|
||||
<div class="rounded-xl border border-white/10 bg-white/[0.04] p-3"
|
||||
wire:key="recipient-{{ $r['id'] }}">
|
||||
{{-- Labels (dynamisches Grid – 3 oder 4 Spalten je nach Typ) --}}
|
||||
<div class="grid {{ $type === 'single' ? 'grid-cols-[5fr_auto_5fr]' : 'grid-cols-[5fr_auto_5fr_auto]' }} gap-3 mb-1 text-xs text-white/60">
|
||||
<div>Interner Empfänger (Postfach)</div>
|
||||
<div></div>
|
||||
<div>Externe E-Mail</div>
|
||||
@if($type === 'group')
|
||||
<div></div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Eingaben + oder + (optional) Löschen --}}
|
||||
<div class="grid {{ $type === 'single' ? 'grid-cols-[5fr_auto_5fr]' : 'grid-cols-[5fr_auto_5fr_auto]' }} gap-3 items-center">
|
||||
{{-- Interner Empfänger --}}
|
||||
<div>
|
||||
<select
|
||||
wire:model.live="recipients.{{ $idx }}.mail_user_id"
|
||||
class="w-full h-11 rounded-2xl bg-white/5 border border-white/10 text-white px-3 text-sm
|
||||
focus:border-white/20 focus:ring-0 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
@disabled($rowState[$idx]['disable_internal'] ?? false)>
|
||||
<option value="">—</option>
|
||||
@foreach($domainMailUsers as $mu)
|
||||
<option value="{{ $mu->id }}"
|
||||
@disabled(in_array($mu->id, $disabledMailboxIdsByRow[$idx] ?? [], true))>
|
||||
{{ $mu->localpart . '@' . optional($domain)->domain }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{-- ODER --}}
|
||||
<div class="flex items-center justify-center text-white/50 select-none">oder</div>
|
||||
|
||||
{{-- Externe E-Mail --}}
|
||||
<div>
|
||||
<input type="email"
|
||||
wire:model.live="recipients.{{ $idx }}.email"
|
||||
placeholder="user@example.com"
|
||||
class="w-full h-11 rounded-2xl bg-white/5 border border-white/10 text-white px-3 text-sm
|
||||
focus:border-white/20 focus:ring-0 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
@disabled($rowState[$idx]['disable_external'] ?? false)>
|
||||
@error("recipients.$idx.email")
|
||||
<div class="mt-1 text-xs text-rose-300">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
{{-- Löschen (nur bei Gruppe sichtbar) --}}
|
||||
@if($type === 'group')
|
||||
<div class="flex items-center justify-center">
|
||||
<button type="button"
|
||||
wire:click="removeRecipientRow({{ $idx }})"
|
||||
class="size-7 grid place-items-center rounded-lg bg-rose-500/20 text-rose-200
|
||||
border border-rose-400/40 hover:bg-rose-500/30
|
||||
disabled:opacity-40 disabled:cursor-not-allowed">
|
||||
<i class="ph ph-trash-simple text-base"></i>
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if($type === 'single' && count($recipients) > 1)
|
||||
<div class="text-xs text-amber-300">
|
||||
Hinweis: Bei „Single“ wird nur der erste Empfänger gespeichert.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Notizen (optional)</label>
|
||||
<textarea wire:model.defer="notes" rows="3"
|
||||
class="w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm focus:border-white/20 focus:ring-0"></textarea>
|
||||
@error('notes')
|
||||
<div class="mt-1 text-xs text-rose-300">{{ $message }}</div> @enderror
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@push('modal.footer')
|
||||
<div class="px-5 py-3 border-t border-white/10 backdrop-blur rounded-b-2xl">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" wire:click="$dispatch('closeModal')"
|
||||
class="px-4 py-2 rounded-lg text-sm border border-white/10 text-white/70 hover:text-white hover:border-white/20">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" form="alias-form"
|
||||
class="px-4 py-2 rounded-lg text-sm bg-emerald-500/20 text-emerald-300 border border-emerald-400/30 hover:bg-emerald-500/30">
|
||||
{{ $aliasId ? 'Speichern' : 'Anlegen' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endpush
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
@push('modal.header')
|
||||
<div class="px-5 pt-5 pb-3 border-b border-white/10
|
||||
backdrop-blur rounded-t-2xl relative">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">Neues Postfach</h2>
|
||||
<button wire:click="$dispatch('closeModal')" class="text-white/60 hover:text-white text-lg">
|
||||
<i class="ph ph-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endpush
|
||||
|
||||
<div class="p-6">
|
||||
@if(!$can_create)
|
||||
<div class="mb-5">
|
||||
<div class="rounded-xl border border-yellow-400/30 bg-yellow-500/10 text-yellow-200 text-sm px-3 py-2">
|
||||
<i class="ph ph-warning text-[16px] mr-1.5"></i>
|
||||
{{ $block_reason }}
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="space-y-4">
|
||||
{{-- Domain + Localpart --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div class="relative" wire:ignore>
|
||||
<label class="block text-xs text-white/60 mb-1">Domain</label>
|
||||
|
||||
<el-select
|
||||
id="domainSelect"
|
||||
name="domain_id"
|
||||
value="{{ (int)$domain_id }}"
|
||||
class="block w-full"
|
||||
wire:model.live="domain_id"
|
||||
onchange="$wire.set('domain_id', parseInt(this.value))"
|
||||
>
|
||||
{{-- Trigger / sichtbarer Button --}}
|
||||
<button type="button"
|
||||
class="grid w-full grid-cols-1 cursor-default rounded-2xl bg-white/5 border border-white/10 px-3.5 py-2.5 text-white/90
|
||||
">
|
||||
<el-selectedcontent class="col-start-1 row-start-1 flex items-center gap-2 pr-6">
|
||||
<i class="ph ph-globe-hemisphere-west text-white/60 text-[16px]"></i>
|
||||
<span class="block truncate">Domain wählen</span>
|
||||
</el-selectedcontent>
|
||||
<i class="ph ph-caret-down col-start-1 row-start-1 self-center justify-self-end text-white/50"></i>
|
||||
</button>
|
||||
|
||||
{{-- Dropdown / Options (Glas, rund) --}}
|
||||
<el-options anchor="bottom start" popover
|
||||
class="max-h-60 w-(--button-width) overflow-auto rounded-2xl bg-gray-900/95 backdrop-blur
|
||||
py-1 text-base outline-1 -outline-offset-1 outline-white/10 shadow-xl sm:text-sm
|
||||
data-leave:transition data-leave:duration-100 data-leave:ease-in data-closed:data-leave:opacity-0">
|
||||
@foreach($domains as $d)
|
||||
@php
|
||||
// Optional: Status fürs Label
|
||||
$label = $d['domain'];
|
||||
$locked = false; // falls du einzelne Domains sperren willst
|
||||
@endphp
|
||||
|
||||
<el-option value="{{ $d['id'] }}"
|
||||
class="group/option relative block cursor-default py-2 pr-9 pl-3 text-white/90 select-none
|
||||
hover:bg-white/5 focus:bg-white/10 focus:text-white focus:outline-hidden
|
||||
{{ $locked ? 'opacity-50 pointer-events-none' : '' }}">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="ph ph-globe-hemisphere-west text-white/60"></i>
|
||||
<span
|
||||
class="block truncate group-aria-selected/option:font-semibold">{{ $label }}</span>
|
||||
</div>
|
||||
|
||||
{{-- Haken beim aktiven Eintrag --}}
|
||||
<span
|
||||
class="absolute inset-y-0 right-0 hidden items-center pr-3 text-emerald-300
|
||||
group-aria-selected/option:flex in-[el-selectedcontent]:hidden">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" class="size-5">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 1 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"/>
|
||||
</svg>
|
||||
</span>
|
||||
</el-option>
|
||||
@endforeach
|
||||
</el-options>
|
||||
</el-select>
|
||||
|
||||
@error('domain_id')
|
||||
<div class="text-rose-300 text-xs mt-1">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Benutzername (linker Teil der E-Mail-Adresse)</label>
|
||||
<input type="text" wire:model.live="localpart" placeholder="z. B. office"
|
||||
class="w-full rounded-2xl bg-white/5 border border-white/10 px-3.5 py-2.5 text-white/90 placeholder:text-white/50">
|
||||
<div class="mt-1 text-[11px] text-white/50">Nur Buchstaben, Zahlen und „.-_+“</div>
|
||||
@error('localpart')
|
||||
<div class="text-rose-300 text-xs mt-1">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Anzeigename --}}
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Name (Anzeigename)</label>
|
||||
<input type="text" wire:model="display_name" placeholder="z. B. Max Mustermann"
|
||||
class="w-full rounded-2xl bg-white/5 border border-white/10 px-3.5 py-2.5 text-white/90 placeholder:text-white/50">
|
||||
@error('display_name')
|
||||
<div class="text-rose-300 text-xs mt-1">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
{{-- Adresse Preview --}}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-white/60">Adresse:</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-sm border border-white/10 bg-white/5 text-white/85">
|
||||
<i class="ph ph-at text-[14px]"></i>
|
||||
{{ $email_preview ?: '–' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-white/10"></div>
|
||||
|
||||
{{-- Passwort / Quota --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Passwort</label>
|
||||
<input type="password" wire:model="password" placeholder="mind. 8 Zeichen"
|
||||
class="w-full rounded-2xl bg-white/5 border border-white/10 px-3.5 py-2.5 text-white/90 placeholder:text-white/50">
|
||||
@error('password')
|
||||
<div class="text-rose-300 text-xs mt-1">{{ $message }}</div>@enderror
|
||||
<div class="mt-1 text-xs text-white/50">Leer lassen, wenn extern gesetzt wird.</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Quota (MiB)</label>
|
||||
<input type="number" min="0" wire:model.live="quota_mb"
|
||||
class="w-full rounded-2xl bg-white/5 border border-white/10 px-3.5 py-2.5 text-white/90">
|
||||
@error('quota_mb')
|
||||
<div class="text-rose-300 text-xs mt-1">{{ $message }}</div>@enderror
|
||||
<div class="mt-1 text-xs text-white/50">{{ $quota_hint }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Rate / Flags --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Mails pro Stunde (optional)</label>
|
||||
<input type="number" min="1" wire:model="rate_limit_per_hour"
|
||||
@if($rate_limit_readonly) readonly disabled @endif
|
||||
class="w-full rounded-2xl bg-white/5 border border-white/10 px-3.5 py-2.5 text-white/90 placeholder:text-white/50"
|
||||
placeholder="z. B. 120">
|
||||
@if($rate_limit_readonly)
|
||||
<div class="mt-1 text-xs text-white/50">Von der Domain vorgegeben (Override deaktiviert).</div>
|
||||
@endif
|
||||
@error('rate_limit_per_hour')
|
||||
<div class="text-rose-300 text-xs mt-1">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
<div class="pt-3.5 grid items-center justify-start md:justify-center gap-6">
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer select-none group">
|
||||
<input type="checkbox" wire:model="is_active" class="peer sr-only">
|
||||
<span class="w-5 h-5 flex items-center justify-center rounded-md border border-white/15 bg-white/5
|
||||
peer-checked:bg-emerald-500/20 peer-checked:border-emerald-400/40
|
||||
group-hover:border-white/25 transition">
|
||||
<i class="ph ph-check text-[12px] text-emerald-300 opacity-0 peer-checked:opacity-100 transition"></i>
|
||||
</span>
|
||||
<span class="text-white/80 text-sm">Postfach aktivieren</span>
|
||||
</label>
|
||||
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer select-none group">
|
||||
<input type="checkbox" wire:model="must_change_pw" class="peer sr-only">
|
||||
<span class="w-5 h-5 flex items-center justify-center rounded-md border border-white/15 bg-white/5
|
||||
peer-checked:bg-sky-500/20 peer-checked:border-sky-400/40
|
||||
group-hover:border-white/25 transition">
|
||||
<i class="ph ph-check text-[12px] text-sky-300 opacity-0 peer-checked:opacity-100 transition"></i>
|
||||
</span>
|
||||
<span class="text-white/80 text-sm">Passwort bei erstem Login ändern</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('modal.footer')
|
||||
<div class="px-5 py-3 border-t border-white/10 backdrop-blur rounded-b-2xl">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" wire:click="$dispatch('closeModal')"
|
||||
class="px-4 py-2 rounded-lg text-sm border border-white/10 text-white/70 hover:text-white hover:border-white/20">
|
||||
Abbrechen
|
||||
</button>
|
||||
|
||||
<button type="submit" wire:click="$dispatch('mailbox:create')"
|
||||
@disabled(!$can_create)
|
||||
class="inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm
|
||||
{{ $can_create
|
||||
? 'bg-emerald-500/20 text-emerald-200 border border-emerald-400/40 hover:bg-emerald-500/30'
|
||||
: 'bg-white/5 text-white/40 border border-white/10 cursor-not-allowed' }}">
|
||||
<i class="ph ph-check text-[16px]"></i> Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endpush
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
@push('modal.header')
|
||||
<div class="px-5 pt-5 pb-3 border-b border-white/10
|
||||
backdrop-blur rounded-t-2xl relative">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">Postfach löschen</h2>
|
||||
<div class="mt-0.5 text-white/70 text-sm flex items-center gap-1.5">
|
||||
<span>{{ $email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button wire:click="$dispatch('closeModal')" class="text-white/60 hover:text-white text-lg">
|
||||
<i class="ph ph-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endpush
|
||||
|
||||
<div class="p-6">
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-lg border border-rose-400/30 bg-rose-500/10 p-3 text-rose-200 text-sm">
|
||||
<b>Achtung:</b> Das Postfach wird gelöscht.
|
||||
@if(!$keep_server_mail)
|
||||
Alle E-Mails werden <u>entfernt</u>.
|
||||
@else
|
||||
E-Mails bleiben am Server erhalten.
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer select-none group">
|
||||
<input type="checkbox" class="peer sr-only" wire:model="keep_server_mail">
|
||||
<span class="w-5 h-5 flex items-center justify-center rounded-md border border-white/15 bg-white/5
|
||||
peer-checked:bg-emerald-500/20 peer-checked:border-emerald-400/40"></span>
|
||||
<span class="text-white/80 text-sm">E-Mails am Server belassen</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer select-none group">
|
||||
<input type="checkbox" class="peer sr-only" wire:model="export_zip">
|
||||
<span class="w-5 h-5 flex items-center justify-center rounded-md border border-white/15 bg-white/5
|
||||
peer-checked:bg-sky-500/20 peer-checked:border-sky-400/40"></span>
|
||||
<span class="text-white/80 text-sm">ZIP-Export der bisherigen E-Mails erstellen</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('modal.footer')
|
||||
<div class="px-5 py-3 border-t border-white/10 backdrop-blur rounded-b-2xl">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" wire:click="$dispatch('closeModal')"
|
||||
class="px-4 py-2 rounded-lg text-sm border border-white/10 text-white/70 hover:text-white hover:border-white/20">
|
||||
Abbrechen
|
||||
</button>
|
||||
|
||||
<button type="button"
|
||||
class="inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm bg-rose-500/20 text-rose-200 border border-rose-400/40 hover:bg-rose-500/30"
|
||||
wire:click="$dispatch('mailbox:delete')">
|
||||
<i class="ph ph-trash-simple text-[16px]"></i> Endgültig löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endpush
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
@push('modal.header')
|
||||
<div class="px-5 pt-5 pb-3 border-b border-white/10
|
||||
backdrop-blur rounded-t-2xl relative">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">Postfach bearbeiten</h2>
|
||||
<div class="mt-0.5 text-white/70 text-sm flex items-center gap-1.5">
|
||||
<span>{{ $email_readonly }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button wire:click="$dispatch('closeModal')" class="text-white/60 hover:text-white text-lg">
|
||||
<i class="ph ph-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endpush
|
||||
|
||||
<div class="p-6">
|
||||
<div class="space-y-4">
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer select-none group">
|
||||
<input type="checkbox" wire:model="is_active" class="peer sr-only">
|
||||
<span class="w-5 h-5 flex items-center justify-center rounded-md border border-white/15 bg-white/5
|
||||
peer-checked:bg-emerald-500/20 peer-checked:border-emerald-400/40">
|
||||
<i class="ph ph-check text-[12px] text-emerald-300 opacity-0 peer-checked:opacity-100"></i>
|
||||
</span>
|
||||
<span class="text-white/80 text-sm">Postfach aktivieren</span>
|
||||
</label>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Name (Anzeigename)</label>
|
||||
<input type="text" wire:model.defer="display_name" placeholder="z. B. Office"
|
||||
class="w-full rounded-2xl bg-white/5 border border-white/10 px-3.5 py-2.5 text-white/90">
|
||||
@error('display_name')
|
||||
<div class="text-rose-300 text-xs mt-1">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Passwort</label>
|
||||
<input type="password" wire:model.defer="password" placeholder="leer = unverändert"
|
||||
class="w-full rounded-2xl bg-white/5 border border-white/10 px-3.5 py-2.5 text-white/90">
|
||||
@error('password')
|
||||
<div class="text-rose-300 text-xs mt-1">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Quota (MiB)</label>
|
||||
<input type="number" min="0" wire:model.defer="quota_mb"
|
||||
class="w-full rounded-2xl bg-white/5 border border-white/10 px-3.5 py-2.5 text-white/90">
|
||||
@error('quota_mb')
|
||||
<div class="text-rose-300 text-xs mt-1">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/60 mb-1">Mails pro Stunde (optional)</label>
|
||||
<input type="number" min="1" wire:model.defer="rate_limit_per_hour" placeholder="z. B. 120"
|
||||
class="w-full rounded-2xl bg-white/5 border border-white/10 px-3.5 py-2.5 text-white/90">
|
||||
@error('rate_limit_per_hour')
|
||||
<div class="text-rose-300 text-xs mt-1">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-[12px] w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm #opacity-70">
|
||||
<div class="#mt-1 text-xs text-white/50">{{ $quota_hint }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('modal.footer')
|
||||
<div class="px-5 py-3 border-t border-white/10 backdrop-blur rounded-b-2xl">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" wire:click="$dispatch('closeModal')"
|
||||
class="px-4 py-2 rounded-lg text-sm border border-white/10 text-white/70 hover:text-white hover:border-white/20">
|
||||
Abbrechen
|
||||
</button>
|
||||
|
||||
<button type="submit" wire:click="$dispatch('mailbox:edit:save')"
|
||||
class="px-4 py-2 rounded-lg text-sm bg-emerald-500/20 text-emerald-300 border border-emerald-400/30 hover:bg-emerald-500/30">
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endpush
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
@push('modal.header')
|
||||
<div class="px-5 pt-5 pb-3 border-b border-white/10
|
||||
backdrop-blur rounded-t-2xl relative">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">Suche</h2>
|
||||
<div class="mt-1 text-[11px] text-white/50">Tipp: ⌘K / Ctrl+K öffnet die Suche überall.</div>
|
||||
</div>
|
||||
<button wire:click="$dispatch('closeModal')" class="text-white/60 hover:text-white text-lg">
|
||||
<i class="ph ph-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endpush
|
||||
|
||||
<div class="p-6">
|
||||
<div class="space-y-4">
|
||||
{{-- Input --}}
|
||||
<div class="">
|
||||
<div class="relative">
|
||||
<i class="ph ph-magnifying-glass text-white/50 absolute left-3 top-1/2 -translate-y-1/2"></i>
|
||||
<input type="text" autofocus
|
||||
placeholder="Suche nach Domain, Postfach oder Benutzer …"
|
||||
wire:model.live.debounce.300ms="q"
|
||||
class="w-full rounded-2xl bg-white/5 border border-white/10 pl-9 pr-3.5 py-2.5 text-white/90 placeholder:text-white/50">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Ergebnisse --}}
|
||||
<div class="space-y-4 max-h-[60vh] overflow-auto">
|
||||
@php $res = $this->results; @endphp
|
||||
|
||||
{{-- Domains --}}
|
||||
@if(!empty($res['domains']))
|
||||
<div>
|
||||
<div class="text-[11px] uppercase tracking-wide text-white/60 mb-1">Domains</div>
|
||||
<div class="rounded-xl border border-white/10 overflow-hidden divide-y divide-white/10">
|
||||
@foreach($res['domains'] as $r)
|
||||
<button type="button"
|
||||
class="w-full text-left px-3.5 py-2.5 hover:bg-white/5 flex items-center justify-between"
|
||||
wire:click="go('domain', {{ $r['id'] }})">
|
||||
<div class="min-w-0">
|
||||
<div class="text-white/90 truncate">{{ $r['title'] }}</div>
|
||||
<div class="text-white/50 text-xs truncate">{{ $r['sub'] }}</div>
|
||||
</div>
|
||||
<i class="ph ph-arrow-up-right text-white/50"></i>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Postfächer --}}
|
||||
@if(!empty($res['mailboxes']))
|
||||
<div>
|
||||
<div class="text-[11px] uppercase tracking-wide text-white/60 mb-1">Postfächer</div>
|
||||
<div class="rounded-xl border border-white/10 overflow-hidden divide-y divide-white/10">
|
||||
@foreach($res['mailboxes'] as $r)
|
||||
<button type="button"
|
||||
class="w-full text-left px-3.5 py-2.5 hover:bg-white/5 flex items-center justify-between"
|
||||
wire:click="go('mailbox', {{ $r['id'] }})">
|
||||
<div class="min-w-0">
|
||||
<div class="text-white/90 truncate">{{ $r['title'] }}</div>
|
||||
<div class="text-white/50 text-xs truncate">{{ $r['sub'] }}</div>
|
||||
</div>
|
||||
<i class="ph ph-pencil-simple text-white/50"></i>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Benutzer (optional) --}}
|
||||
@if(!empty($res['users']))
|
||||
<div>
|
||||
<div class="text-[11px] uppercase tracking-wide text-white/60 mb-1">Benutzer</div>
|
||||
<div class="rounded-xl border border-white/10 overflow-hidden divide-y divide-white/10">
|
||||
@foreach($res['users'] as $r)
|
||||
<button type="button"
|
||||
class="w-full text-left px-3.5 py-2.5 hover:bg-white/5 flex items-center justify-between"
|
||||
wire:click="go('user', {{ $r['id'] }})">
|
||||
<div class="min-w-0">
|
||||
<div class="text-white/90 truncate">{{ $r['title'] }}</div>
|
||||
<div class="text-white/50 text-xs truncate">{{ $r['sub'] }}</div>
|
||||
</div>
|
||||
<i class="ph ph-user text-white/50"></i>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(empty($res['domains']) && empty($res['mailboxes']) && empty($res['users']) && trim($q) !== '')
|
||||
{{-- EMPTY STATE (no results) --}}
|
||||
<div class="relative rounded-2xl border border-white/10 bg-white/[0.03] p-6 overflow-hidden">
|
||||
{{-- Rings background (SVG, dotted, fading outward) --}}
|
||||
<div class="pointer-events-none absolute inset-0 grid place-items-center">
|
||||
<svg class="w-[540px] max-w-[78vw] aspect-square opacity-70" viewBox="0 0 540 540" fill="none">
|
||||
@php
|
||||
$rings = [
|
||||
['r'=>70, 'o'=>.32],
|
||||
['r'=>120, 'o'=>.22],
|
||||
['r'=>175, 'o'=>.15],
|
||||
['r'=>230, 'o'=>.10],
|
||||
['r'=>280, 'o'=>.07],
|
||||
];
|
||||
@endphp
|
||||
@foreach($rings as $ring)
|
||||
<circle cx="270" cy="270" r="{{ $ring['r'] }}"
|
||||
stroke="rgba(255,255,255,{{ $ring['o'] }})"
|
||||
stroke-width="1.5"
|
||||
stroke-dasharray="2 6"
|
||||
vector-effect="non-scaling-stroke"/>
|
||||
@endforeach
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{{-- Foreground content (on top of rings & icons) --}}
|
||||
<div class="relative z-10">
|
||||
<div class="mx-auto max-w-xl text-center space-y-4">
|
||||
{{-- Center magnifier --}}
|
||||
<div class="mx-auto size-14 rounded-2xl grid place-items-center bg-white/[0.07] border border-white/10 shadow-[0_10px_40px_-10px_rgba(0,0,0,.5)]">
|
||||
<i class="ph ph-magnifying-glass text-white/80 text-xl"></i>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-white font-semibold text-xl">Keine Treffer</h3>
|
||||
<p class="text-white/70 mt-1">
|
||||
Wir konnten zu <span class="font-medium text-white">„{{ $q }}“</span> nichts finden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{-- Actions --}}
|
||||
<div class="flex items-center justify-center gap-2 pt-2">
|
||||
<button type="button"
|
||||
class="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-3.5 py-2 text-white/85 hover:text-white hover:border-white/20"
|
||||
wire:click="$set('q','')">
|
||||
<i class="ph ph-broom text-[16px]"></i> Suche leeren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -25,7 +25,6 @@
|
|||
class="w-full h-11 rounded-xl border border-white/10 bg-white/[0.04] px-3 text-white/90">
|
||||
<p class="mt-1 text-xs text-white/45">Wird in Übersichten und Benachrichtigungen angezeigt.</p>
|
||||
</div>
|
||||
|
||||
{{-- Benutzername --}}
|
||||
<div>
|
||||
<label class="block text-white/60 text-sm mb-1">Benutzername</label>
|
||||
|
|
|
|||
|
|
@ -1,51 +1,76 @@
|
|||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-xl font-semibold text-white/95">E-Mail-2FA einrichten</h3>
|
||||
<button class="text-white/60 hover:text-white" wire:click="$dispatch('closeModal')">
|
||||
<i class="ph ph-x text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-white/70 mb-6">
|
||||
Wir senden einen 6-stelligen Bestätigungscode an deine aktuelle Account-E-Mail.
|
||||
Gib ihn unten ein, um E-Mail-2FA zu aktivieren.
|
||||
</p>
|
||||
|
||||
{{-- Senden --}}
|
||||
<div class="rounded-xl border border-white/10 bg-white/[0.04] p-4 mb-5">
|
||||
<button
|
||||
type="button"
|
||||
wire:click="sendMail"
|
||||
@if($cooldown>0) disabled @endif
|
||||
class="primary-btn w-full justify-center disabled:opacity-50"
|
||||
>
|
||||
Code an E-Mail senden
|
||||
@if($cooldown>0)
|
||||
<span class="ml-2 text-white/70">({{ $cooldown }}s)</span>
|
||||
@endif
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Code --}}
|
||||
<div class="mb-6">
|
||||
<label class="block text-white/70 text-sm mb-1">6-stelliger Code</label>
|
||||
<input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
placeholder="••••••"
|
||||
wire:model.defer="code"
|
||||
class="w-full h-12 rounded-xl border border-white/10 bg-white/[0.05] px-3 text-white/90 tracking-[0.35em] text-center"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
@if($alreadyActive)
|
||||
<button wire:click="disable"
|
||||
class="rounded-xl border border-white/15 bg-white/[0.05] text-white/85 hover:border-rose-400/30 hover:text-rose-200 px-4 py-2">
|
||||
Deaktivieren
|
||||
@push('modal.header')
|
||||
<div class="px-5 pt-5 pb-3 border-b border-white/10
|
||||
backdrop-blur rounded-t-2xl relative">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">
|
||||
<i class="ph ph-shield-check text-white/70 text-lg"></i>
|
||||
E-Mail-2FA einrichten</h2>
|
||||
<button wire:click="$dispatch('closeModal')" class="text-white/60 hover:text-white text-lg">
|
||||
<i class="ph ph-x"></i>
|
||||
</button>
|
||||
@endif
|
||||
<button wire:click="verifyAndEnable" class="primary-btn">Bestätigen & aktivieren</button>
|
||||
</div>
|
||||
</div>
|
||||
@endpush
|
||||
|
||||
<div class="p-6">
|
||||
|
||||
<div class="space-y-4">
|
||||
<p class="text-white/70 text-sm #mb-6">
|
||||
Wir senden einen 6-stelligen Bestätigungscode an deine aktuelle Account-E-Mail.
|
||||
Gib ihn unten ein, um E-Mail-2FA zu aktivieren.
|
||||
</p>
|
||||
|
||||
{{-- Senden --}}
|
||||
<div class="text-center">
|
||||
<button
|
||||
type="button"
|
||||
wire:click="sendMail"
|
||||
@if($cooldown>0) disabled @endif
|
||||
class="px-4 py-2 rounded-lg text-sm
|
||||
bg-white/5 text-white/75 border border-white/15
|
||||
hover:bg-white/10 hover:text-white transition">
|
||||
|
||||
Code an E-Mail senden
|
||||
@if($cooldown>0)
|
||||
<span class="ml-2 text-white/70">({{ $cooldown }}s)</span>
|
||||
@endif
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Code --}}
|
||||
<div class="">
|
||||
<label class="block text-white/70 text-sm mb-1">6-stelliger Code</label>
|
||||
<input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
placeholder="••••••"
|
||||
wire:model.defer="code"
|
||||
class="w-full h-12 rounded-xl border border-white/10 bg-white/[0.05] px-3 text-white/90 tracking-[0.35em] text-center"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('modal.footer')
|
||||
<div class="px-5 py-3 border-t border-white/10 backdrop-blur rounded-b-2xl">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button type="button" wire:click="$dispatch('closeModal')"
|
||||
class="px-4 py-2 rounded-lg text-sm border border-white/10 text-white/70 hover:text-white hover:border-white/20">
|
||||
Abbrechen
|
||||
</button>
|
||||
|
||||
@if($alreadyActive)
|
||||
<button wire:click="disable"
|
||||
class="inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm bg-rose-500/20 text-rose-200 border border-rose-400/40 hover:bg-rose-500/30">
|
||||
Deaktivieren
|
||||
</button>
|
||||
@else
|
||||
<button wire:click="$dispatch('security:totp-email:enable')"
|
||||
class="px-4 py-2 rounded-lg text-sm bg-emerald-500/20 text-emerald-300 border border-emerald-400/30 hover:bg-emerald-500/30">
|
||||
Bestätigen & aktivieren
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endpush
|
||||
|
|
|
|||
|
|
@ -1,82 +1,93 @@
|
|||
<div class="p-6">
|
||||
{{-- Header --}}
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="inline-flex items-center gap-2">
|
||||
<i class="ph ph-shield-check text-white/70 text-lg"></i>
|
||||
<h3 class="text-base font-semibold tracking-tight text-white/95">Recovery-Codes</h3>
|
||||
@push('modal.header')
|
||||
<div class="px-5 pt-5 pb-3 border-b border-white/10
|
||||
backdrop-blur rounded-t-2xl relative">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">
|
||||
<i class="ph ph-shield-check text-white/70 text-lg"></i>
|
||||
Recovery-Codes</h2>
|
||||
<button wire:click="$dispatch('closeModal')" class="text-white/60 hover:text-white text-lg">
|
||||
<i class="ph ph-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button class="text-white/60 hover:text-white" wire:click="$dispatch('closeModal')">
|
||||
<i class="ph ph-x text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
@endpush
|
||||
|
||||
@if(empty($plainCodes))
|
||||
{{-- KEINE Klartextcodes sichtbar (entweder noch nie erzeugt ODER bereits vorhanden) --}}
|
||||
<div class="rounded-xl border border-white/10 bg-white/[0.04] p-4 mb-5">
|
||||
@if($hasExisting)
|
||||
<div class="flex items-start gap-3">
|
||||
<i class="ph ph-info text-white/60 text-lg"></i>
|
||||
<div class="p-6">
|
||||
<div class="space-y-4">
|
||||
@if(empty($plainCodes))
|
||||
<div class="rounded-xl border border-white/10 bg-white/[0.04] p-4 #mb-5">
|
||||
@if($hasExisting)
|
||||
<div class="flex items-start gap-3">
|
||||
<i class="ph ph-info text-white/60 text-lg"></i>
|
||||
<div class="text-sm text-white/80">
|
||||
Für deinen Account existieren bereits Recovery-Codes.
|
||||
Aus Sicherheitsgründen können sie <span class="font-semibold">nicht erneut angezeigt</span>
|
||||
werden.
|
||||
Du kannst sie jedoch <span class="font-semibold">neu erzeugen (rotieren)</span>.
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="text-sm text-white/80">
|
||||
Für deinen Account existieren bereits Recovery-Codes.
|
||||
Aus Sicherheitsgründen können sie <span class="font-semibold">nicht erneut angezeigt</span> werden.
|
||||
Du kannst sie jedoch <span class="font-semibold">neu erzeugen (rotieren)</span>.
|
||||
Recovery-Codes sind Einmal-Notfallcodes, falls du z. B. den Zugriff auf deine 2FA-App verlierst.
|
||||
Sie werden <span class="font-semibold">nur direkt nach der Erstellung</span> angezeigt – bitte
|
||||
speichere oder drucke sie sofort.
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="text-sm text-white/80">
|
||||
Recovery-Codes sind Einmal-Notfallcodes, falls du z. B. den Zugriff auf deine 2FA-App verlierst.
|
||||
Sie werden <span class="font-semibold">nur direkt nach der Erstellung</span> angezeigt – bitte
|
||||
speichere oder drucke sie sofort.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button wire:click="$dispatch('closeModal')"
|
||||
class="rounded-xl border border-white/10 bg-white/[0.06] text-white/80 hover:border-white/20 px-4 py-2">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button wire:click="generate" class="primary-btn">
|
||||
{{ $hasExisting ? 'Neu erstellen & anzeigen' : 'Erstellen & anzeigen' }}
|
||||
</button>
|
||||
</div>
|
||||
@else
|
||||
{{-- EINMALIGE ANZEIGE NACH GENERIERUNG --}}
|
||||
<div class="mb-3 text-sm text-white/70">
|
||||
Hier sind deine neuen Recovery-Codes. Sie werden <span class="font-semibold">nur jetzt</span> angezeigt.
|
||||
Bewahre sie offline & sicher auf.
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-white/10 bg-white/[0.04] p-4 mb-4">
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
@foreach($plainCodes as $c)
|
||||
<div class="rounded-lg border border-white/10 bg-white/[0.06] px-3 py-2 font-mono text-white/90 tracking-wider text-center">
|
||||
{{ chunk_split($c, 5, ' ') }}
|
||||
</div>
|
||||
@endforeach
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
x-data
|
||||
x-on:click="navigator.clipboard.writeText(@js(implode(PHP_EOL, $plainCodes)))"
|
||||
class="rounded-xl border border-white/10 bg-white/[0.06] text-white/80 hover:border-white/20 px-4 py-2">
|
||||
In Zwischenablage
|
||||
</button>
|
||||
|
||||
<button wire:click="downloadTxt"
|
||||
class="rounded-xl border border-white/10 bg-white/[0.06] text-white/80 hover:border-white/20 px-4 py-2">
|
||||
Als .txt speichern
|
||||
</button>
|
||||
|
||||
<button wire:click="$dispatch('closeModal')" class="primary-btn ml-auto">
|
||||
Fertig
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-xs text-white/50">
|
||||
Tipp: Aktiviere TOTP als Hauptmethode, nutze E-Mail-Codes als Fallback und drucke diese Codes aus.
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
<div class="#mb-3 text-sm text-white/70">
|
||||
Hier sind deine neuen Recovery-Codes. Sie werden <span class="font-semibold">nur jetzt</span> angezeigt.
|
||||
Bewahre sie offline & sicher auf.
|
||||
</div>
|
||||
<div class="rounded-xl border border-white/10 bg-white/[0.04] p-4">
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
@foreach($plainCodes as $c)
|
||||
<div
|
||||
class="rounded-lg border border-white/10 bg-white/[0.06] px-3 py-2 font-mono text-white/90 tracking-wider text-center">
|
||||
{{ chunk_split($c, 5, ' ') }}
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-white/50 rounded-xl border border-white/10 bg-white/[0.04] p-2">
|
||||
<b>Tipp</b>: Aktiviere TOTP als Hauptmethode, nutze E-Mail-Codes als Fallback und drucke diese Codes aus.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('modal.footer')
|
||||
<div class="px-5 py-3 border-t border-white/10 backdrop-blur rounded-b-2xl">
|
||||
@if(empty($plainCodes))
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button wire:click="$dispatch('closeModal')"
|
||||
class="px-4 py-2 rounded-lg text-sm border border-white/10 text-white/70 hover:text-white hover:border-white/20">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button wire:click="generate"
|
||||
class="px-4 py-2 rounded-lg text-sm bg-emerald-500/20 text-emerald-300 border border-emerald-400/30 hover:bg-emerald-500/30">
|
||||
{{ $hasExisting ? 'Neu erstellen & anzeigen' : 'Erstellen & anzeigen' }}
|
||||
</button>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<button
|
||||
x-data
|
||||
x-on:click="navigator.clipboard.writeText(@js(implode(PHP_EOL, $plainCodes)))"
|
||||
class="px-4 py-2 rounded-lg text-sm border border-white/10 text-white/70 hover:text-white hover:border-white/20">
|
||||
In Zwischenablage
|
||||
</button>
|
||||
|
||||
<button wire:click="downloadTxt"
|
||||
class="px-4 py-2 rounded-lg text-sm border border-white/10 text-white/70 hover:text-white hover:border-white/20">
|
||||
Als .txt speichern
|
||||
</button>
|
||||
|
||||
<button wire:click="$dispatch('closeModal')"
|
||||
class="px-4 py-2 rounded-lg text-sm bg-emerald-500/20 text-emerald-300 border border-emerald-400/30 hover:bg-emerald-500/30">
|
||||
Fertig
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endpush
|
||||
|
|
|
|||
|
|
@ -1,94 +1,87 @@
|
|||
@push('modal.header')
|
||||
<div class="px-5 pt-5 pb-3 border-b border-white/10
|
||||
backdrop-blur rounded-t-2xl relative">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">TOTP einrichten</h2>
|
||||
<button wire:click="$dispatch('closeModal')" class="text-white/60 hover:text-white text-lg">
|
||||
<i class="ph ph-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endpush
|
||||
|
||||
<div
|
||||
x-data="{
|
||||
code:['','','','','',''],
|
||||
focusNext(i){ if(this.code[i].length===1 && i<5) this.$refs['d'+(i+1)].focus() },
|
||||
paste(e){ const t=(e.clipboardData||window.clipboardData).getData('text').replace(/\D/g,'').slice(0,6); t.split('').forEach((c,i)=>this.code[i]=c) }
|
||||
}"
|
||||
class="p-6 sm:p-8"
|
||||
class="p-6"
|
||||
>
|
||||
{{-- Header --}}
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="text-2xl font-semibold text-white/95">TOTP einrichten</h3>
|
||||
<p class="mt-2 text-sm text-white/70">
|
||||
Scanne den QR-Code mit deiner Authenticator-App und gib den 6-stelligen Code zur Bestätigung ein.
|
||||
</p>
|
||||
<div class="space-y-4">
|
||||
<div class="text-sm text-white/70">
|
||||
Scanne den QR-Code mit deiner Authenticator-App und gib den 6-stelligen Code zur Bestätigung ein.
|
||||
</div>
|
||||
<button class="text-white/60 hover:text-white" wire:click="$dispatch('closeModal')">
|
||||
<i class="ph ph-x text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Step 1 --}}
|
||||
<div class="mt-4 mb-2 inline-flex items-center gap-2 rounded-full bg-white/5 border border-white/10 px-3 py-1">
|
||||
<span class="text-xs font-semibold text-white/90">Step 1</span>
|
||||
<span class="text-xs text-white/70">QR-Code scannen</span>
|
||||
</div>
|
||||
{{-- Step 1 --}}
|
||||
<div class="#mt-4 mb-2 inline-flex items-center gap-2 rounded-full bg-white/5 border border-white/10 px-3 py-1">
|
||||
<span class="text-xs font-semibold text-white/90">Step 1</span>
|
||||
<span class="text-xs text-white/70">QR-Code scannen</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 rounded-2xl border border-white/10 bg-white/[0.04] p-3">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 items-center">
|
||||
{{-- QR: perfectly square --}}
|
||||
<div class="flex justify-center #max-h-48">
|
||||
<img src="{{ $qrPng }}" alt="TOTP QR"
|
||||
class="w-48 h-fit rounded-xl border border-white/10 bg-black/20 p-2 object-contain" />
|
||||
</div>
|
||||
|
||||
{{-- Secret + copy (stacked, readable) --}}
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm font-medium text-white/85">Kannst du nicht scannen?</p>
|
||||
<p class="text-xs text-white/60">Gib stattdessen diesen Secret-Key ein:</p>
|
||||
|
||||
<div class="mt-2 #flex flex-col sm:flex-row sm:items-center gap-2">
|
||||
<input readonly value="{{ $secret }}"
|
||||
class="nx-input"/>
|
||||
<div class="#mt-3 rounded-2xl border border-white/10 bg-white/[0.04] p-3">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 items-center">
|
||||
{{-- QR: perfectly square --}}
|
||||
<div class="flex justify-center #max-h-48">
|
||||
<img src="{{ $qrPng }}" alt="TOTP QR"
|
||||
class="w-48 h-fit rounded-xl border border-white/10 bg-black/20 p-2 object-contain"/>
|
||||
</div>
|
||||
|
||||
{{-- Secret + copy (stacked, readable) --}}
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm font-medium text-white/85">Kannst du nicht scannen?</p>
|
||||
<p class="text-xs text-white/60">Gib stattdessen diesen Secret-Key ein:</p>
|
||||
|
||||
<div class="mt-2 #flex flex-col sm:flex-row sm:items-center gap-2">
|
||||
<input readonly value="{{ $secret }}"
|
||||
class="nx-input"/>
|
||||
</div>
|
||||
<x-button.copy-btn :text="$secret"/>
|
||||
</div>
|
||||
<button x-data x-on:click="navigator.clipboard.writeText('{{ $secret }}')"
|
||||
class="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white/80 hover:text-white hover:border-white/20 transition">
|
||||
<i class="ph ph-copy"></i> Kopieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Step 2 --}}
|
||||
<div class="mt-6 mb-2 inline-flex items-center gap-2 rounded-full bg-white/5 border border-white/10 px-3 py-1">
|
||||
<span class="text-xs font-semibold text-white/90">Step 2</span>
|
||||
<span class="text-xs text-white/70">Bestätigungscode eingeben</span>
|
||||
</div>
|
||||
{{-- Step 2 --}}
|
||||
<div
|
||||
class="#mt-6 #mb-2 inline-flex items-center gap-2 rounded-full bg-white/5 border border-white/10 px-3 py-1">
|
||||
<span class="text-xs font-semibold text-white/90">Step 2</span>
|
||||
<span class="text-xs text-white/70">Bestätigungscode eingeben</span>
|
||||
</div>
|
||||
|
||||
{{-- 6 small boxes, centered --}}
|
||||
<div x-on:paste.prevent="paste($event)"
|
||||
class="mt-3 flex justify-center gap-2 sm:gap-3">
|
||||
@for($i=0;$i<6;$i++)
|
||||
<input x-model="code[{{ $i }}]" x-ref="d{{ $i }}" maxlength="1"
|
||||
x-on:input="focusNext({{ $i }})"
|
||||
class="nx-input w-10 h-12 sm:w-11 sm:h-12 text-center text-lg" />
|
||||
@endfor
|
||||
</div>
|
||||
|
||||
{{-- Footer: hint + CTA --}}
|
||||
<div class="mt-6 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<p class="text-xs text-white/55 text-center sm:text-left">
|
||||
Achte darauf, dass die Gerätezeit korrekt ist.
|
||||
</p>
|
||||
{{-- <button class="--}}
|
||||
{{-- px-5 py-2.5 rounded-xl font-medium--}}
|
||||
{{-- text-white text-sm tracking-wide--}}
|
||||
{{-- border border-white/10 shadow-md--}}
|
||||
{{-- bg-[linear-gradient(90deg,rgba(34,211,238,0.5)_0%,rgba(16,185,129,0.5)_100%)]--}}
|
||||
{{-- hover:bg-[linear-gradient(90deg,rgba(34,211,238,0.7)_0%,rgba(16,185,129,0.7)_100%)]--}}
|
||||
{{-- backdrop-blur-md transition-all duration-200--}}
|
||||
{{--">--}}
|
||||
{{-- Bestätigen & aktivieren--}}
|
||||
{{-- </button>--}}
|
||||
|
||||
<button type="button" class="primary-btn">
|
||||
Bestätigen & aktivieren
|
||||
</button>
|
||||
{{-- <button x-on:click="$wire.verifyAndEnable(code.join(''))"--}}
|
||||
{{-- class="btn-ghost" >--}}
|
||||
{{-- class="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white/80 hover:text-white hover:border-white/20 transition">--}}
|
||||
{{-- Bestätigen & aktivieren--}}
|
||||
{{-- </button>--}}
|
||||
{{-- 6 small boxes, centered --}}
|
||||
<div x-on:paste.prevent="paste($event)"
|
||||
class="mt-3 flex justify-center gap-2 sm:gap-3">
|
||||
@for($i=0;$i<6;$i++)
|
||||
<input x-model="code[{{ $i }}]" x-ref="d{{ $i }}" maxlength="1"
|
||||
x-on:input="focusNext({{ $i }})"
|
||||
class="nx-input w-10 h-12 sm:w-11 sm:h-12 text-center text-lg"/>
|
||||
@endfor
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('modal.footer')
|
||||
<div class="px-5 py-3 border-t border-white/10 backdrop-blur rounded-b-2xl">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" wire:click="$dispatch('closeModal')"
|
||||
class="px-4 py-2 rounded-lg text-sm border border-white/10 text-white/70 hover:text-white hover:border-white/20">
|
||||
Abbrechen
|
||||
</button>
|
||||
|
||||
<button type="submit" wire:click="$dispatch('security:totp:enable')"
|
||||
class="px-4 py-2 rounded-lg text-sm bg-emerald-500/20 text-emerald-300 border border-emerald-400/30 hover:bg-emerald-500/30">
|
||||
Bestätigen & aktivieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endpush
|
||||
|
|
|
|||
|
|
@ -2,70 +2,7 @@
|
|||
@extends('layouts.app')
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-6xl mx-auto px-4 py-8">
|
||||
<h1 class="text-2xl font-semibold mb-6">Domains</h1>
|
||||
<div class="max-w-7xl mx-auto py-4">
|
||||
<livewire:ui.domain.domain-dns-list />
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
{{--@extends('layouts.app')--}}
|
||||
|
||||
{{--@section('content')--}}
|
||||
{{-- <div class="max-w-6xl mx-auto px-4 py-8" x-data="dnsAssistant()">--}}
|
||||
{{-- <h1 class="text-2xl font-semibold mb-6">Domains</h1>--}}
|
||||
|
||||
{{-- <div class="bg-slate-800/40 rounded-2xl p-4">--}}
|
||||
{{-- <table class="w-full text-sm">--}}
|
||||
{{-- <thead class="text-slate-300">--}}
|
||||
{{-- <tr>--}}
|
||||
{{-- <th class="text-left py-2">Domain</th>--}}
|
||||
{{-- <th class="text-left py-2">Aktiv</th>--}}
|
||||
{{-- <th class="text-left py-2">Typ</th>--}}
|
||||
{{-- <th class="py-2"></th>--}}
|
||||
{{-- </tr>--}}
|
||||
{{-- </thead>--}}
|
||||
{{-- <tbody class="divide-y divide-slate-700/60">--}}
|
||||
{{-- @foreach(\App\Models\Domain::orderBy('domain')->get() as $d)--}}
|
||||
{{-- <tr>--}}
|
||||
{{-- <td class="py-3">{{ $d->domain }}</td>--}}
|
||||
{{-- <td class="py-3">--}}
|
||||
{{-- <span class="px-2 py-1 rounded text-xs {{ $d->is_active ? 'bg-emerald-600/30 text-emerald-200' : 'bg-slate-600/30 text-slate-300' }}">--}}
|
||||
{{-- {{ $d->is_active ? 'aktiv' : 'inaktiv' }}--}}
|
||||
{{-- </span>--}}
|
||||
{{-- </td>--}}
|
||||
{{-- <td class="py-3">--}}
|
||||
{{-- <span class="px-2 py-1 rounded text-xs {{ $d->is_system ? 'bg-indigo-600/30 text-indigo-200' : 'bg-sky-600/30 text-sky-100' }}">--}}
|
||||
{{-- {{ $d->is_system ? 'System' : 'Kunde' }}--}}
|
||||
{{-- </span>--}}
|
||||
{{-- </td>--}}
|
||||
{{-- <td class="py-3 text-right">--}}
|
||||
{{-- <button--}}
|
||||
{{-- onclick="Livewire.dispatch('openModal', 'ui.domain.modal.domain-dns-modal', {{ json_encode(['domainId' => $d->id]) }})"--}}
|
||||
{{-- class="px-3 py-1.5 rounded-lg bg-gradient-to-r from-teal-500 to-emerald-600 text-white hover:opacity-90"--}}
|
||||
{{-- >--}}
|
||||
{{-- DNS-Assistent--}}
|
||||
{{-- </button>--}}
|
||||
{{-- </td>--}}
|
||||
{{-- </tr>--}}
|
||||
{{-- @endforeach--}}
|
||||
{{-- </tbody>--}}
|
||||
{{-- </table>--}}
|
||||
{{-- </div>--}}
|
||||
{{-- </div>--}}
|
||||
|
||||
{{-- <script>--}}
|
||||
{{-- function dnsAssistant() {--}}
|
||||
{{-- return {--}}
|
||||
{{-- visible: false,--}}
|
||||
{{-- loading: false,--}}
|
||||
{{-- payload: { domain: '', records: [] },--}}
|
||||
{{-- async open(id) {--}}
|
||||
{{-- this.visible = true; this.loading = true;--}}
|
||||
{{-- const res = await fetch(`{{ route('ui.domain.dns', ['domain' => 'ID_PLACEHOLDER']) }}`.replace('ID_PLACEHOLDER', id));--}}
|
||||
{{-- this.payload = await res.json();--}}
|
||||
{{-- this.loading = false;--}}
|
||||
{{-- }--}}
|
||||
{{-- }--}}
|
||||
{{-- }--}}
|
||||
{{-- </script>--}}
|
||||
{{--@endsection--}}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
{{-- resources/views/domains/index.blade.php --}}
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-7xl mx-auto py-4">
|
||||
<livewire:ui.mail.alias-list />
|
||||
</div>
|
||||
@endsection
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{{-- resources/views/domains/index.blade.php --}}
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-7xl mx-auto py-4">
|
||||
<livewire:ui.mail.mailbox-list />
|
||||
</div>
|
||||
@endsection
|
||||
|
|
@ -2,104 +2,67 @@
|
|||
<div>
|
||||
@isset($jsPath)<script>{!! file_get_contents($jsPath) !!}</script>@endisset
|
||||
@isset($cssPath)<style>{!! file_get_contents($cssPath) !!}</style>@endisset
|
||||
<span class="hidden sm:max-w-md md:max-w-xl lg:max-w-3xl xl:max-w-5xl 2xl:max-w-7xl"></span>
|
||||
<div x-data="LivewireUIModal()" x-on:close.stop="setShowPropertyTo(false)"
|
||||
x-on:keydown.escape.window="show && closeModalOnEscape()" x-show="show"
|
||||
class="fixed inset-0 z-50 overflow-y-auto" style="display:none;">
|
||||
|
||||
<span class="hidden sm:max-w-md md:max-w-xl lg:max-w-3xl xl:max-w-5xl 2xl:max-w-7xl"></span>
|
||||
|
||||
{{-- ❌ overflow hier entfernen (zieht sonst Artefakte) --}}
|
||||
<div x-data="LivewireUIModal()"
|
||||
x-on:close.stop="setShowPropertyTo(false)"
|
||||
x-on:keydown.escape.window="show && closeModalOnEscape()"
|
||||
x-show="show"
|
||||
class="fixed inset-0 z-50"
|
||||
style="display:none;">
|
||||
|
||||
<div class="flex min-h-dvh items-center justify-center px-4 py-10">
|
||||
|
||||
{{-- Overlay (neutral, not blue) --}}
|
||||
<div x-show="show" x-on:click="closeModalOnClickAway()"
|
||||
x-transition:enter="ease-out #duration-300"
|
||||
{{-- ✅ Overlay in ZWEI Ebenen: Dim + Blur (keine Ränder/Scroll hier) --}}
|
||||
<div x-show="show"
|
||||
x-on:click="closeModalOnClickAway()"
|
||||
x-transition:enter="ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 #transition-opacity">
|
||||
<div class="absolute inset-0 backdrop-blur-sm bg-black/65"></div>
|
||||
class="fixed inset-0">
|
||||
{{-- Dim-Layer: NUR Farbe --}}
|
||||
<div class="absolute inset-0 bg-black/60"></div>
|
||||
{{-- Blur-Layer: NUR Blur (GPU hint) --}}
|
||||
<div class="absolute inset-0 backdrop-blur-sm transform-gpu will-change-[backdrop-filter]"></div>
|
||||
</div>
|
||||
|
||||
{{-- Safari center helper --}}
|
||||
<span class="hidden #sm:max-w-sm #sm:max-w-md #sm:max-w-lg #sm:max-w-xl #sm:max-w-2xl"></span>
|
||||
<span class="hidden"></span>
|
||||
|
||||
<div
|
||||
x-show="show && showActiveComponent"
|
||||
x-bind:class="modalWidth" {{-- kommt aus dem Livewire-Modal-Script --}}
|
||||
class="inline-block w-full sm:max-w-md align-middle overflow-hidden rounded-2xl
|
||||
ring-1 ring-white/10 border border-white/10 shadow-2xl shadow-black/40
|
||||
bg-[radial-gradient(80%_60%_at_20%_0%,rgba(34,211,238,.12),transparent_60%),radial-gradient(70%_60%_at_100%_120%,rgba(16,185,129,.10),transparent_65%)]
|
||||
bg-slate-900/70 relative z-10"
|
||||
id="modal-container"
|
||||
x-trap.noscroll.inert="show && showActiveComponent"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div x-show="show && showActiveComponent"
|
||||
x-bind:class="modalWidth"
|
||||
id="modal-container"
|
||||
x-trap.noscroll.inert="show && showActiveComponent"
|
||||
aria-modal="true"
|
||||
class="inline-block w-full sm:max-w-md align-middle rounded-2xl
|
||||
ring-1 ring-white/10 border border-white/10 shadow-2xl shadow-black/40
|
||||
bg-[radial-gradient(120%_100%_at_20%_0%,rgba(34,211,238,.12),transparent_60%),radial-gradient(120%_100%_at_100%_120%,rgba(16,185,129,.10),transparent_65%)]
|
||||
bg-slate-900/70 relative z-10">
|
||||
|
||||
@forelse($components as $id => $component)
|
||||
<div x-show.immediate="activeComponent == '{{ $id }}'" x-ref="{{ $id }}" wire:key="{{ $id }}">
|
||||
@livewire($component['name'], $component['arguments'], key($id))
|
||||
<div class="max-h-[90vh] flex flex-col">
|
||||
<div class="flex-1 order-2 overflow-y-auto" id="modal-body">
|
||||
@forelse($components as $id => $component)
|
||||
<div x-show.immediate="activeComponent == '{{ $id }}'"
|
||||
x-ref="{{ $id }}"
|
||||
wire:key="{{ $id }}">
|
||||
@livewire($component['name'], $component['arguments'], key($id))
|
||||
</div>
|
||||
@empty @endforelse
|
||||
</div>
|
||||
@empty @endforelse
|
||||
|
||||
<div class="shrink-0 order-1" id="modal-header">
|
||||
@stack('modal.header')
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 order-3" id="modal-footer">
|
||||
@stack('modal.footer')
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{--<div>--}}
|
||||
{{-- @isset($jsPath)--}}
|
||||
{{-- <script>{!! file_get_contents($jsPath) !!}</script>--}}
|
||||
{{-- @endisset--}}
|
||||
{{-- @isset($cssPath)--}}
|
||||
{{-- <style>{!! file_get_contents($cssPath) !!}</style>--}}
|
||||
{{-- @endisset--}}
|
||||
|
||||
{{-- <div--}}
|
||||
{{-- x-data="LivewireUIModal()"--}}
|
||||
{{-- x-on:close.stop="setShowPropertyTo(false)"--}}
|
||||
{{-- x-on:keydown.escape.window="show && closeModalOnEscape()"--}}
|
||||
{{-- x-show="show"--}}
|
||||
{{-- class="fixed inset-0 z-10 overflow-y-auto"--}}
|
||||
{{-- style="display: none;"--}}
|
||||
{{-- >--}}
|
||||
{{-- <div class="flex items-end justify-center min-h-dvh px-4 pt-4 pb-10 text-center sm:block sm:p-0">--}}
|
||||
{{-- <div--}}
|
||||
{{-- x-show="show"--}}
|
||||
{{-- x-on:click="closeModalOnClickAway()"--}}
|
||||
{{-- x-transition:enter="ease-out duration-300"--}}
|
||||
{{-- x-transition:enter-start="opacity-0"--}}
|
||||
{{-- x-transition:enter-end="opacity-100"--}}
|
||||
{{-- x-transition:leave="ease-in duration-200"--}}
|
||||
{{-- x-transition:leave-start="opacity-100"--}}
|
||||
{{-- x-transition:leave-end="opacity-0"--}}
|
||||
{{-- class="fixed inset-0 transition-all transform"--}}
|
||||
{{-- >--}}
|
||||
{{-- <div class="absolute inset-0 bg-gray-500 opacity-75"></div>--}}
|
||||
{{-- </div>--}}
|
||||
|
||||
{{-- <span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>--}}
|
||||
|
||||
{{-- <div--}}
|
||||
{{-- x-show="show && showActiveComponent"--}}
|
||||
{{-- x-transition:enter="ease-out duration-300"--}}
|
||||
{{-- x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"--}}
|
||||
{{-- x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"--}}
|
||||
{{-- x-transition:leave="ease-in duration-200"--}}
|
||||
{{-- x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"--}}
|
||||
{{-- x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"--}}
|
||||
{{-- x-bind:class="modalWidth"--}}
|
||||
{{-- class="inline-block w-full align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:w-full"--}}
|
||||
{{-- id="modal-container"--}}
|
||||
{{-- x-trap.noscroll.inert="show && showActiveComponent"--}}
|
||||
{{-- aria-modal="true"--}}
|
||||
{{-- >--}}
|
||||
{{-- @forelse($components as $id => $component)--}}
|
||||
{{-- <div x-show.immediate="activeComponent == '{{ $id }}'" x-ref="{{ $id }}" wire:key="{{ $id }}">--}}
|
||||
{{-- @livewire($component['name'], $component['arguments'], key($id))--}}
|
||||
{{-- </div>--}}
|
||||
{{-- @empty--}}
|
||||
{{-- @endforelse--}}
|
||||
{{-- </div>--}}
|
||||
{{-- </div>--}}
|
||||
{{-- </div>--}}
|
||||
{{--</div>--}}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ use App\Http\Controllers\Api\TaskFeedController;
|
|||
use App\Http\Controllers\Auth\LoginController;
|
||||
use App\Http\Controllers\Setup\SetupWizard;
|
||||
use App\Http\Controllers\UI\Domain\DomainDnsController;
|
||||
use App\Http\Controllers\UI\Mail\AliasController;
|
||||
use App\Http\Controllers\UI\Mail\MailboxController;
|
||||
use App\Http\Controllers\UI\Security\RecoveryCodeDownloadController;
|
||||
use App\Http\Controllers\UI\Security\SecurityController;
|
||||
use App\Http\Controllers\UI\System\SettingsController;
|
||||
|
|
@ -43,14 +45,16 @@ Route::middleware(['auth'])
|
|||
Route::get('/audit-logs', [SecurityController::class, 'auditLogs'])->name('audit');
|
||||
});
|
||||
|
||||
Route::middleware(['auth'])
|
||||
->name('ui.domain.')
|
||||
->group(function () {
|
||||
Route::middleware(['auth'])->name('ui.domain.')->group(function () {
|
||||
Route::get('/domains', [DomainDnsController::class, 'index'])->name('index');
|
||||
// Route::get('/api/domains/{domain}/dns', [DomainDnsController::class, 'show'])
|
||||
// ->name('dns'); // JSON für das Modal
|
||||
});
|
||||
|
||||
Route::middleware(['auth'])->name('ui.mail.')->group(function () {
|
||||
Route::get('/mailboxes', [MailboxController::class, 'index'])->name('mailbox.index');
|
||||
Route::get('/aliases', [AliasController::class, 'index'])->name('aliases.index');
|
||||
});
|
||||
|
||||
|
||||
//Route::middleware(['auth'])
|
||||
// ->get('/security/recovery-codes/download', [RecoveryCodeDownloadController::class, 'download'])
|
||||
// ->name('security.recovery.download')
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
source ./lib.sh
|
||||
|
||||
log "Paketquellen aktualisieren…"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -y
|
||||
|
||||
# MariaDB include-Workaround
|
||||
mkdir -p /etc/mysql /etc/mysql/mariadb.conf.d
|
||||
[[ -f /etc/mysql/mariadb.cnf ]] || echo '!include /etc/mysql/mariadb.conf.d/*.cnf' > /etc/mysql/mariadb.cnf
|
||||
|
||||
log "Pakete installieren… (das dauert etwas)"
|
||||
apt-get -y -o Dpkg::Options::="--force-confdef" \
|
||||
-o Dpkg::Options::="--force-confold" install \
|
||||
postfix postfix-mysql \
|
||||
dovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd dovecot-mysql \
|
||||
mariadb-server mariadb-client \
|
||||
redis-server \
|
||||
rspamd \
|
||||
opendkim opendkim-tools \
|
||||
nginx \
|
||||
php php-fpm php-cli php-mbstring php-xml php-curl php-zip php-mysql php-redis php-gd unzip curl \
|
||||
composer git \
|
||||
certbot python3-certbot-nginx \
|
||||
fail2ban \
|
||||
ca-certificates rsyslog sudo openssl netcat-openbsd monit acl
|
||||
|
||||
# HTTP/2 prüfen
|
||||
NGINX_HTTP2_SUPPORTED=0
|
||||
if nginx -V 2>&1 | grep -q http_v2; then
|
||||
NGINX_HTTP2_SUPPORTED=1; log "Nginx: HTTP/2 verfügbar."
|
||||
else
|
||||
warn "Nginx http_v2 fehlt – versuche nginx-full…"
|
||||
apt-get install -y nginx-full || true; systemctl restart nginx || true
|
||||
nginx -V 2>&1 | grep -q http_v2 && NGINX_HTTP2_SUPPORTED=1 || warn "HTTP/2 weiterhin nicht verfügbar."
|
||||
fi
|
||||
export NGINX_HTTP2_SUFFIX=$([[ "$NGINX_HTTP2_SUPPORTED" = "1" ]] && echo " http2" || echo "")
|
||||
|
||||
# Verzeichnisse / User
|
||||
log "Verzeichnisse & Benutzer…"
|
||||
mkdir -p /etc/postfix/sql /etc/dovecot/conf.d /etc/rspamd/local.d /var/mail/vhosts
|
||||
id vmail >/dev/null 2>&1 || adduser --system --group --home /var/mail vmail
|
||||
chown -R vmail:vmail /var/mail
|
||||
|
||||
id "$APP_USER" >/dev/null 2>&1 || adduser --disabled-password --gecos "" "$APP_USER"
|
||||
usermod -a -G "$APP_GROUP" "$APP_USER"
|
||||
|
||||
# Redis absichern
|
||||
log "Redis absichern…"
|
||||
REDIS_CONF="/etc/redis/redis.conf"
|
||||
REDIS_PASS="${REDIS_PASS:-$(openssl rand -hex 16)}"
|
||||
sed -i 's/^\s*#\?\s*bind .*/bind 127.0.0.1/' "$REDIS_CONF"
|
||||
sed -i 's/^\s*#\?\s*protected-mode .*/protected-mode yes/' "$REDIS_CONF"
|
||||
if grep -qE '^\s*#?\s*requirepass ' "$REDIS_CONF"; then
|
||||
sed -i "s/^\s*#\?\s*requirepass .*/requirepass ${REDIS_PASS}/" "$REDIS_CONF"
|
||||
else
|
||||
printf "\nrequirepass %s\n" "${REDIS_PASS}" >> "$REDIS_CONF"
|
||||
fi
|
||||
systemctl enable --now redis-server
|
||||
systemctl restart redis-server
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
source ./lib.sh
|
||||
|
||||
# Stabile Pfade
|
||||
MAIL_SSL_DIR="/etc/ssl/mail"
|
||||
UI_SSL_DIR="/etc/ssl/ui"
|
||||
WEBMAIL_SSL_DIR="/etc/ssl/webmail"
|
||||
|
||||
ensure_dir root root 0755 "$MAIL_SSL_DIR"
|
||||
ensure_dir root root 0755 "$UI_SSL_DIR"
|
||||
ensure_dir root root 0755 "$WEBMAIL_SSL_DIR"
|
||||
ensure_dir root root 0755 "/var/www/letsencrypt"
|
||||
|
||||
# Self-signed Quick-Gen (wenn kein LE kommt)
|
||||
self_signed(){
|
||||
local dir="$1"
|
||||
local cert="${dir}/fullchain.pem" key="${dir}/privkey.pem"
|
||||
[[ -s "$cert" && -s "$key" ]] && return 0
|
||||
log "Self-signed für $dir …"
|
||||
openssl req -x509 -newkey rsa:2048 -sha256 -days 825 -nodes \
|
||||
-subj "/CN=${SERVER_PUBLIC_IPV4}/O=${APP_NAME}/C=DE" \
|
||||
-keyout "$key" -out "$cert" >/dev/null 2>&1
|
||||
chmod 600 "$key"; chmod 644 "$cert"
|
||||
}
|
||||
|
||||
self_signed "$MAIL_SSL_DIR"
|
||||
self_signed "$UI_SSL_DIR"
|
||||
self_signed "$WEBMAIL_SSL_DIR"
|
||||
|
||||
issue_cert(){
|
||||
local host="$1"
|
||||
if resolve_ok "$host"; then
|
||||
log "LE für $host …"
|
||||
certbot certonly --agree-tos -m "${LE_EMAIL}" \
|
||||
--non-interactive --webroot -w /var/www/letsencrypt -d "$host" \
|
||||
|| warn "LE fehlgeschlagen für $host – Self-signed bleibt aktiv."
|
||||
else
|
||||
warn "DNS zeigt nicht auf diese IP: $host – LE wird übersprungen."
|
||||
fi
|
||||
}
|
||||
|
||||
link_if_present(){
|
||||
local host="$1" target_dir="$2"
|
||||
local base="/etc/letsencrypt/live/$host"
|
||||
if [[ -f "$base/fullchain.pem" && -f "$base/privkey.pem" ]]; then
|
||||
ln -sf "$base/fullchain.pem" "$target_dir/fullchain.pem"
|
||||
ln -sf "$base/privkey.pem" "$target_dir/privkey.pem"
|
||||
log "TLS verlinkt: $target_dir -> $base"
|
||||
fi
|
||||
}
|
||||
|
||||
# Echte Domain? Dann versuchen
|
||||
if [[ "$BASE_DOMAIN" != "example.com" ]]; then
|
||||
issue_cert "$UI_HOST"
|
||||
issue_cert "$WEBMAIL_HOST"
|
||||
issue_cert "$MAIL_HOSTNAME"
|
||||
|
||||
link_if_present "$UI_HOST" "$UI_SSL_DIR"
|
||||
link_if_present "$WEBMAIL_HOST" "$WEBMAIL_SSL_DIR"
|
||||
link_if_present "$MAIL_HOSTNAME" "$MAIL_SSL_DIR"
|
||||
else
|
||||
warn "BASE_DOMAIN=example.com – bleibe bei Self-signed."
|
||||
fi
|
||||
|
||||
# LE-Deploy-Hook (Symlinks aktuell halten)
|
||||
install -d /etc/letsencrypt/renewal-hooks/deploy
|
||||
cat >/etc/letsencrypt/renewal-hooks/deploy/50-mailwolt-symlinks.sh <<'HOOK'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
UI_SSL_DIR="/etc/ssl/ui"
|
||||
WEBMAIL_SSL_DIR="/etc/ssl/webmail"
|
||||
MAIL_SSL_DIR="/etc/ssl/mail"
|
||||
UI_HOST="${UI_HOST}"
|
||||
WEBMAIL_HOST="${WEBMAIL_HOST}"
|
||||
MX_HOST="${MAIL_HOSTNAME}"
|
||||
|
||||
link_if() {
|
||||
local le_base="/etc/letsencrypt/live/$1" target_dir="$2"
|
||||
if [[ -f "$le_base/fullchain.pem" && -f "$le_base/privkey.pem" ]]; then
|
||||
install -d -m 0755 "$target_dir"
|
||||
ln -sf "$le_base/fullchain.pem" "$target_dir/fullchain.pem"
|
||||
ln -sf "$le_base/privkey.pem" "$target_dir/privkey.pem"
|
||||
echo "[+] Linked $target_dir -> $le_base"
|
||||
fi
|
||||
}
|
||||
link_if "$UI_HOST" "$UI_SSL_DIR"
|
||||
link_if "$WEBMAIL_HOST" "$WEBMAIL_SSL_DIR"
|
||||
link_if "$MX_HOST" "$MAIL_SSL_DIR"
|
||||
systemctl reload nginx || true
|
||||
HOOK
|
||||
chmod +x /etc/letsencrypt/renewal-hooks/deploy/50-mailwolt-symlinks.sh
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
source ./lib.sh
|
||||
|
||||
DB_NAME="${DB_NAME:-${APP_USER}}"
|
||||
DB_USER="${DB_USER:-${APP_USER}}"
|
||||
DB_PASS="${DB_PASS:-$(openssl rand -hex 16)}"
|
||||
|
||||
log "MariaDB vorbereiten…"
|
||||
systemctl enable --now mariadb
|
||||
|
||||
mysql -uroot <<SQL
|
||||
CREATE DATABASE IF NOT EXISTS ${DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE USER IF NOT EXISTS '${DB_USER}'@'localhost';
|
||||
CREATE USER IF NOT EXISTS '${DB_USER}'@'127.0.0.1';
|
||||
ALTER USER '${DB_USER}'@'localhost' IDENTIFIED BY '${DB_PASS}';
|
||||
ALTER USER '${DB_USER}'@'127.0.0.1' IDENTIFIED BY '${DB_PASS}';
|
||||
GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'localhost';
|
||||
GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'127.0.0.1';
|
||||
FLUSH PRIVILEGES;
|
||||
SQL
|
||||
|
||||
export DB_NAME DB_USER DB_PASS
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
source ./lib.sh
|
||||
|
||||
MAIL_SSL_DIR="/etc/ssl/mail"
|
||||
MAIL_CERT="${MAIL_SSL_DIR}/fullchain.pem"
|
||||
MAIL_KEY="${MAIL_SSL_DIR}/privkey.pem"
|
||||
|
||||
log "Postfix konfigurieren…"
|
||||
postconf -e "myhostname = ${MAIL_HOSTNAME}"
|
||||
postconf -e "myorigin = \$myhostname"
|
||||
postconf -e "mydestination = "
|
||||
postconf -e "inet_interfaces = all"
|
||||
postconf -e "inet_protocols = ipv4"
|
||||
postconf -e "smtpd_banner = \$myhostname ESMTP"
|
||||
|
||||
postconf -e "smtpd_tls_cert_file=${MAIL_CERT}"
|
||||
postconf -e "smtpd_tls_key_file=${MAIL_KEY}"
|
||||
postconf -e "smtpd_tls_security_level = may"
|
||||
postconf -e "smtp_tls_security_level = may"
|
||||
postconf -e "smtpd_tls_received_header = yes"
|
||||
postconf -e "smtpd_tls_protocols=!SSLv2,!SSLv3"
|
||||
postconf -e "smtpd_tls_mandatory_protocols=!SSLv2,!SSLv3"
|
||||
postconf -e "smtpd_tls_loglevel=1"
|
||||
postconf -e "smtp_tls_loglevel=1"
|
||||
|
||||
postconf -e "disable_vrfy_command = yes"
|
||||
postconf -e "smtpd_helo_required = yes"
|
||||
|
||||
postconf -e "milter_default_action = accept"
|
||||
postconf -e "milter_protocol = 6"
|
||||
postconf -e "smtpd_milters = inet:127.0.0.1:11332, inet:127.0.0.1:8891"
|
||||
postconf -e "non_smtpd_milters = inet:127.0.0.1:11332, inet:127.0.0.1:8891"
|
||||
|
||||
postconf -e "smtpd_sasl_type = dovecot"
|
||||
postconf -e "smtpd_sasl_path = private/auth"
|
||||
postconf -e "smtpd_sasl_auth_enable = yes"
|
||||
postconf -e "smtpd_sasl_security_options = noanonymous"
|
||||
|
||||
postconf -e "smtpd_recipient_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination"
|
||||
postconf -e "smtpd_relay_restrictions = permit_mynetworks, reject_unauth_destination"
|
||||
|
||||
postconf -M "smtp/inet=smtp inet n - n - - smtpd -o smtpd_peername_lookup=no -o smtpd_timeout=30s"
|
||||
postconf -M "submission/inet=submission inet n - n - - smtpd -o syslog_name=postfix/submission -o smtpd_peername_lookup=no -o smtpd_tls_security_level=encrypt -o smtpd_tls_auth_only=yes -o smtpd_sasl_auth_enable=yes -o smtpd_relay_restrictions=permit_sasl_authenticated,reject -o smtpd_recipient_restrictions=permit_sasl_authenticated,reject"
|
||||
postconf -M "smtps/inet=smtps inet n - n - - smtpd -o syslog_name=postfix/smtps -o smtpd_peername_lookup=no -o smtpd_tls_wrappermode=yes -o smtpd_tls_auth_only=yes -o smtpd_sasl_auth_enable=yes -o smtpd_relay_restrictions=permit_sasl_authenticated,reject -o smtpd_recipient_restrictions=permit_sasl_authenticated,reject"
|
||||
|
||||
install -d -o root -g postfix -m 750 /etc/postfix/sql
|
||||
|
||||
cat > /etc/postfix/sql/mysql-virtual-mailbox-maps.cf <<CONF
|
||||
hosts = 127.0.0.1
|
||||
user = ${DB_USER}
|
||||
password = ${DB_PASS}
|
||||
dbname = ${DB_NAME}
|
||||
query = SELECT 1 FROM mail_users u JOIN domains d ON d.id = u.domain_id WHERE u.email = '%s' AND u.is_active = 1 AND d.is_active = 1 LIMIT 1;
|
||||
CONF
|
||||
chown root:postfix /etc/postfix/sql/mysql-virtual-mailbox-maps.cf; chmod 640 /etc/postfix/sql/mysql-virtual-mailbox-maps.cf
|
||||
|
||||
cat > /etc/postfix/sql/mysql-virtual-alias-maps.cf <<CONF
|
||||
hosts = 127.0.0.1
|
||||
user = ${DB_USER}
|
||||
password = ${DB_PASS}
|
||||
dbname = ${DB_NAME}
|
||||
query = SELECT destination FROM mail_aliases a JOIN domains d ON d.id = a.domain_id WHERE a.source = '%s' AND a.is_active = 1 AND d.is_active = 1 LIMIT 1;
|
||||
CONF
|
||||
chown root:postfix /etc/postfix/sql/mysql-virtual-alias-maps.cf; chmod 640 /etc/postfix/sql/mysql-virtual-alias-maps.cf
|
||||
|
||||
systemctl enable postfix >/dev/null 2>&1 || true
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
source ./lib.sh
|
||||
|
||||
MAIL_SSL_DIR="/etc/ssl/mail"
|
||||
MAIL_CERT="${MAIL_SSL_DIR}/fullchain.pem"
|
||||
MAIL_KEY="${MAIL_SSL_DIR}/privkey.pem"
|
||||
|
||||
log "Dovecot konfigurieren…"
|
||||
|
||||
cat > /etc/dovecot/dovecot.conf <<'CONF'
|
||||
!include_try /etc/dovecot/conf.d/*.conf
|
||||
CONF
|
||||
|
||||
cat > /etc/dovecot/conf.d/10-mail.conf <<'CONF'
|
||||
protocols = imap pop3 lmtp
|
||||
mail_location = maildir:/var/mail/vhosts/%d/%n
|
||||
|
||||
namespace inbox {
|
||||
inbox = yes
|
||||
}
|
||||
|
||||
mail_privileged_group = mail
|
||||
CONF
|
||||
|
||||
cat > /etc/dovecot/conf.d/10-auth.conf <<'CONF'
|
||||
disable_plaintext_auth = yes
|
||||
auth_mechanisms = plain login
|
||||
!include_try auth-sql.conf.ext
|
||||
CONF
|
||||
|
||||
cat > /etc/dovecot/dovecot-sql.conf.ext <<CONF
|
||||
driver = mysql
|
||||
connect = host=127.0.0.1 dbname=${DB_NAME} user=${DB_USER} password=${DB_PASS}
|
||||
default_pass_scheme = BLF-CRYPT
|
||||
password_query = SELECT email AS user, password_hash AS password FROM mail_users WHERE email = '%u' AND is_active = 1 LIMIT 1;
|
||||
CONF
|
||||
chown root:dovecot /etc/dovecot/dovecot-sql.conf.ext; chmod 640 /etc/dovecot/dovecot-sql.conf.ext
|
||||
|
||||
cat > /etc/dovecot/conf.d/auth-sql.conf.ext <<'CONF'
|
||||
passdb {
|
||||
driver = sql
|
||||
args = /etc/dovecot/dovecot-sql.conf.ext
|
||||
}
|
||||
userdb {
|
||||
driver = static
|
||||
args = uid=vmail gid=vmail home=/var/mail/vhosts/%d/%n
|
||||
}
|
||||
CONF
|
||||
chown root:dovecot /etc/dovecot/conf.d/auth-sql.conf.ext; chmod 640 /etc/dovecot/conf.d/auth-sql.conf.ext
|
||||
|
||||
cat > /etc/dovecot/conf.d/10-master.conf <<'CONF'
|
||||
service lmtp {
|
||||
unix_listener /var/spool/postfix/private/dovecot-lmtp {
|
||||
mode = 0600
|
||||
user = postfix
|
||||
group = postfix
|
||||
}
|
||||
}
|
||||
service auth {
|
||||
unix_listener /var/spool/postfix/private/auth {
|
||||
mode = 0660
|
||||
user = postfix
|
||||
group = postfix
|
||||
}
|
||||
}
|
||||
service imap-login {
|
||||
inet_listener imap { port = 143 }
|
||||
inet_listener imaps { port = 993; ssl = yes }
|
||||
}
|
||||
service pop3-login {
|
||||
inet_listener pop3 { port = 110 }
|
||||
inet_listener pop3s { port = 995; ssl = yes }
|
||||
}
|
||||
CONF
|
||||
|
||||
DOVECOT_SSL_CONF="/etc/dovecot/conf.d/10-ssl.conf"
|
||||
grep -q '^ssl\s*=' "$DOVECOT_SSL_CONF" 2>/dev/null || echo "ssl = required" >> "$DOVECOT_SSL_CONF"
|
||||
if grep -q '^\s*ssl_cert\s*=' "$DOVECOT_SSL_CONF"; then
|
||||
sed -i "s|^\s*ssl_cert\s*=.*|ssl_cert = <${MAIL_CERT}|" "$DOVECOT_SSL_CONF"
|
||||
else
|
||||
echo "ssl_cert = <${MAIL_CERT}" >> "$DOVECOT_SSL_CONF"
|
||||
fi
|
||||
if grep -q '^\s*ssl_key\s*=' "$DOVECOT_SSL_CONF"; then
|
||||
sed -i "s|^\s*ssl_key\s*=.*|ssl_key = <${MAIL_KEY}|" "$DOVECOT_SSL_CONF"
|
||||
else
|
||||
echo "ssl_key = <${MAIL_KEY}" >> "$DOVECOT_SSL_CONF"
|
||||
fi
|
||||
|
||||
mkdir -p /var/spool/postfix/private
|
||||
chown postfix:postfix /var/spool/postfix /var/spool/postfix/private
|
||||
chmod 0755 /var/spool/postfix /var/spool/postfix/private
|
||||
|
||||
systemctl enable dovecot >/dev/null 2>&1 || true
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
source ./lib.sh
|
||||
|
||||
log "Rspamd + OpenDKIM aktivieren…"
|
||||
|
||||
cat > /etc/rspamd/local.d/worker-controller.inc <<'CONF'
|
||||
password = "admin";
|
||||
bind_socket = "127.0.0.1:11334";
|
||||
CONF
|
||||
systemctl enable --now rspamd || true
|
||||
|
||||
cat > /etc/opendkim.conf <<'CONF'
|
||||
Syslog yes
|
||||
UMask 002
|
||||
Mode sv
|
||||
Socket inet:8891@127.0.0.1
|
||||
Canonicalization relaxed/simple
|
||||
On-BadSignature accept
|
||||
On-Default accept
|
||||
On-KeyNotFound accept
|
||||
On-NoSignature accept
|
||||
LogWhy yes
|
||||
OversignHeaders From
|
||||
CONF
|
||||
systemctl enable --now opendkim || true
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
source ./lib.sh
|
||||
|
||||
NGINX_SITE="/etc/nginx/sites-available/${APP_USER}.conf"
|
||||
NGINX_SITE_LINK="/etc/nginx/sites-enabled/${APP_USER}.conf"
|
||||
TEMPLATE="$(cd .. && pwd)/config/nginx/site.conf.tmpl"
|
||||
|
||||
# Platzhalter für Template
|
||||
export APP_DIR
|
||||
export UI_SSL_DIR="/etc/ssl/ui"
|
||||
export UI_CERT="${UI_SSL_DIR}/fullchain.pem"
|
||||
export UI_KEY="${UI_SSL_DIR}/privkey.pem"
|
||||
export NGINX_HTTP2_SUFFIX
|
||||
|
||||
# Template -> Site
|
||||
log "Nginx konfigurieren…"
|
||||
envsubst '$APP_DIR $UI_CERT $UI_KEY $NGINX_HTTP2_SUFFIX' < "$TEMPLATE" > "$NGINX_SITE"
|
||||
|
||||
ln -sf "$NGINX_SITE" "$NGINX_SITE_LINK"
|
||||
if nginx -t; then
|
||||
systemctl enable --now nginx
|
||||
systemctl reload nginx || true
|
||||
else
|
||||
die "Nginx-Konfiguration fehlerhaft – /var/log/nginx prüfen."
|
||||
fi
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
source ./lib.sh
|
||||
|
||||
log "Laravel bereitstellen…"
|
||||
mkdir -p "$(dirname "$APP_DIR")"; chown -R "$APP_USER":"$APP_GROUP" "$(dirname "$APP_DIR")"
|
||||
|
||||
if [[ ! -d "${APP_DIR}/.git" ]]; then
|
||||
sudo -u "$APP_USER" -H bash -lc "git clone --depth=1 -b ${GIT_BRANCH} ${GIT_REPO} ${APP_DIR}"
|
||||
else
|
||||
sudo -u "$APP_USER" -H bash -lc "
|
||||
set -e
|
||||
cd ${APP_DIR}
|
||||
git fetch --depth=1 origin ${GIT_BRANCH} || true
|
||||
git checkout ${GIT_BRANCH} 2>/dev/null || git checkout -B ${GIT_BRANCH}
|
||||
if git merge-base --is-ancestor HEAD origin/${GIT_BRANCH}; then git pull --ff-only; else git reset --hard origin/${GIT_BRANCH}; git clean -fd; fi
|
||||
"
|
||||
fi
|
||||
|
||||
if [[ -f "${APP_DIR}/composer.json" ]]; then
|
||||
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && composer install --no-interaction --prefer-dist --no-dev"
|
||||
fi
|
||||
|
||||
# .env
|
||||
ENV_FILE="${APP_DIR}/.env"
|
||||
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && cp -n .env.example .env || true"
|
||||
grep -q '^APP_KEY=' "${ENV_FILE}" || sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan key:generate --force || true"
|
||||
|
||||
upsert_env(){
|
||||
local k="$1" v="$2"
|
||||
local ek ev; ek="$(printf '%s' "$k" | sed -e 's/[.[\*^$(){}+?|/]/\\&/g')"; ev="$(printf '%s' "$v" | sed -e 's/[&/]/\\&/g')"
|
||||
if grep -qE "^[#[:space:]]*${ek}=" "$ENV_FILE"; then
|
||||
sed -Ei "s|^[#[:space:]]*${ek}=.*|${k}=${ev}|g" "$ENV_FILE"
|
||||
else
|
||||
printf '%s=%s\n' "$k" "$v" >> "$ENV_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
# APP_URL/Host
|
||||
APP_HOST_VAL="$SERVER_PUBLIC_IPV4"
|
||||
UI_LE_CERT="/etc/letsencrypt/live/${UI_HOST}/fullchain.pem"
|
||||
UI_LE_KEY="/etc/letsencrypt/live/${UI_HOST}/privkey.pem"
|
||||
if [[ "$BASE_DOMAIN" != "example.com" && -n "$UI_HOST" ]] && ( resolve_ok "$UI_HOST" || [[ -f "$UI_LE_CERT" && -f "$UI_LE_KEY" ]] ); then
|
||||
APP_HOST_VAL="$UI_HOST"
|
||||
fi
|
||||
if [[ "$APP_HOST_VAL" = "$UI_HOST" && -f "$UI_LE_CERT" && -f "$UI_LE_KEY" ]]; then
|
||||
APP_URL_VAL="https://${UI_HOST}"
|
||||
else
|
||||
if [[ -f "/etc/ssl/ui/fullchain.pem" && -f "/etc/ssl/ui/privkey.pem" ]]; then
|
||||
APP_URL_VAL="https://${SERVER_PUBLIC_IPV4}"
|
||||
else
|
||||
APP_URL_VAL="http://${SERVER_PUBLIC_IPV4}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# .env schreiben
|
||||
upsert_env APP_URL "${APP_URL_VAL}"
|
||||
upsert_env APP_HOST "${APP_HOST_VAL}"
|
||||
upsert_env APP_NAME "${APP_NAME}"
|
||||
upsert_env APP_ENV "${APP_ENV}"
|
||||
upsert_env APP_DEBUG "${APP_DEBUG}"
|
||||
upsert_env APP_LOCALE "de"
|
||||
upsert_env APP_FALLBACK_LOCALE "en"
|
||||
|
||||
upsert_env SERVER_PUBLIC_IPV4 "${SERVER_PUBLIC_IPV4}"
|
||||
upsert_env SERVER_PUBLIC_IPV6 "${SERVER_PUBLIC_IPV6:-}"
|
||||
|
||||
upsert_env BASE_DOMAIN "${BASE_DOMAIN}"
|
||||
upsert_env UI_SUB "${UI_SUB}"
|
||||
upsert_env WEBMAIL_SUB "${WEBMAIL_SUB}"
|
||||
upsert_env SYSTEM_SUB "${SYSTEM_SUB}"
|
||||
upsert_env MTA_SUB "${MTA_SUB}"
|
||||
upsert_env LE_EMAIL "${LE_EMAIL}"
|
||||
|
||||
upsert_env DB_CONNECTION "mysql"
|
||||
upsert_env DB_HOST "127.0.0.1"
|
||||
upsert_env DB_PORT "3306"
|
||||
upsert_env DB_DATABASE "${DB_NAME}"
|
||||
upsert_env DB_USERNAME "${DB_USER}"
|
||||
upsert_env DB_PASSWORD "${DB_PASS}"
|
||||
|
||||
upsert_env CACHE_SETTINGS_STORE "redis"
|
||||
upsert_env CACHE_STORE "redis"
|
||||
upsert_env CACHE_DRIVER "redis"
|
||||
upsert_env CACHE_PREFIX "${APP_USER_PREFIX}_cache:"
|
||||
upsert_env SESSION_DRIVER "redis"
|
||||
upsert_env SESSION_SECURE_COOKIE "true"
|
||||
upsert_env SESSION_SAMESITE "lax"
|
||||
upsert_env REDIS_CLIENT "phpredis"
|
||||
upsert_env REDIS_HOST "127.0.0.1"
|
||||
upsert_env REDIS_PORT "6379"
|
||||
upsert_env REDIS_PASSWORD "${REDIS_PASS}"
|
||||
upsert_env REDIS_DB "0"
|
||||
upsert_env REDIS_CACHE_DB "1"
|
||||
upsert_env REDIS_CACHE_CONNECTION "cache"
|
||||
upsert_env REDIS_CACHE_LOCK_CONNECTION "default"
|
||||
|
||||
# Reverb / Queue (wie bei dir)
|
||||
upsert_env BROADCAST_DRIVER "reverb"
|
||||
upsert_env QUEUE_CONNECTION "redis"
|
||||
upsert_env LOG_CHANNEL "daily"
|
||||
upsert_env REVERB_APP_ID "${APP_USER_PREFIX}"
|
||||
upsert_env REVERB_APP_KEY "${APP_USER_PREFIX}_$(openssl rand -hex 16)"
|
||||
upsert_env REVERB_APP_SECRET "${APP_USER_PREFIX}_$(openssl rand -hex 32)"
|
||||
upsert_env REVERB_HOST "\${APP_HOST}"
|
||||
upsert_env REVERB_PORT "443"
|
||||
upsert_env REVERB_SCHEME "https"
|
||||
upsert_env REVERB_PATH "/ws"
|
||||
upsert_env REVERB_SCALING_ENABLED "true"
|
||||
upsert_env REVERB_SCALING_CHANNEL "reverb"
|
||||
upsert_env VITE_REVERB_APP_KEY "\${REVERB_APP_KEY}"
|
||||
upsert_env VITE_REVERB_HOST "\${REVERB_HOST}"
|
||||
upsert_env VITE_REVERB_PORT "\${REVERB_PORT}"
|
||||
upsert_env VITE_REVERB_SCHEME "\${REVERB_SCHEME}"
|
||||
upsert_env VITE_REVERB_PATH "\${REVERB_PATH}"
|
||||
upsert_env REVERB_SERVER_APP_KEY "\${REVERB_APP_KEY}"
|
||||
upsert_env REVERB_SERVER_HOST "127.0.0.1"
|
||||
upsert_env REVERB_SERVER_PORT "8080"
|
||||
upsert_env REVERB_SERVER_PATH ""
|
||||
upsert_env REVERB_SERVER_SCHEME "http"
|
||||
|
||||
# Optimize + Migrate + Seed
|
||||
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan optimize:clear && php artisan config:cache"
|
||||
sudo systemctl restart php*-fpm || true
|
||||
|
||||
sudo -u "$APP_USER" -H bash -lc "
|
||||
set -e
|
||||
cd ${APP_DIR}
|
||||
php artisan optimize:clear
|
||||
php artisan migrate --force
|
||||
php artisan config:cache
|
||||
php artisan optimize:clear
|
||||
"
|
||||
|
||||
if [[ "$BASE_DOMAIN" != "example.com" ]]; then
|
||||
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan db:seed --class=SystemDomainSeeder --no-interaction || true"
|
||||
else
|
||||
echo "[i] BASE_DOMAIN=example.com – Seeder übersprungen."
|
||||
fi
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
source ./lib.sh
|
||||
|
||||
log "systemd Units (Reverb/Schedule/Queue)…"
|
||||
|
||||
cat > /etc/systemd/system/${APP_USER}-ws.service <<EOF
|
||||
[Unit]
|
||||
Description=${APP_NAME} WebSocket Backend
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
[Service]
|
||||
Type=simple
|
||||
Environment=NODE_ENV=production WS_PORT=8080
|
||||
User=${APP_USER}
|
||||
Group=${APP_GROUP}
|
||||
WorkingDirectory=${APP_DIR}
|
||||
ExecStartPre=/usr/bin/bash -lc 'test -f .env'
|
||||
ExecStartPre=/usr/bin/bash -lc 'test -d vendor'
|
||||
ExecStart=/usr/bin/php artisan reverb:start --host=127.0.0.1 --port=8080 --no-interaction
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
StandardOutput=append:/var/log/${APP_USER}-ws.log
|
||||
StandardError=append:/var/log/${APP_USER}-ws.log
|
||||
KillSignal=SIGINT
|
||||
TimeoutStopSec=15
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
cat > /etc/systemd/system/${APP_USER}-schedule.service <<EOF
|
||||
[Unit]
|
||||
Description=${APP_NAME} Laravel Scheduler
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
[Service]
|
||||
Type=simple
|
||||
User=${APP_USER}
|
||||
Group=${APP_GROUP}
|
||||
WorkingDirectory=${APP_DIR}
|
||||
ExecStartPre=/usr/bin/bash -lc 'test -f .env'
|
||||
ExecStartPre=/usr/bin/bash -lc 'test -d vendor'
|
||||
ExecStart=/usr/bin/php artisan schedule:work
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
StandardOutput=append:/var/log/${APP_USER}-schedule.log
|
||||
StandardError=append:/var/log/${APP_USER}-schedule.log
|
||||
KillSignal=SIGINT
|
||||
TimeoutStopSec=15
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
cat > /etc/systemd/system/${APP_USER}-queue.service <<EOF
|
||||
[Unit]
|
||||
Description=${APP_NAME} Queue Worker
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
[Service]
|
||||
Type=simple
|
||||
User=${APP_USER}
|
||||
Group=${APP_GROUP}
|
||||
WorkingDirectory=${APP_DIR}
|
||||
ExecStartPre=/usr/bin/bash -lc 'test -f .env'
|
||||
ExecStartPre=/usr/bin/bash -lc 'test -d vendor'
|
||||
ExecStart=/usr/bin/php artisan queue:work --queue=default,notify --tries=1
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
StandardOutput=append:/var/log/${APP_USER}-queue.log
|
||||
StandardError=append:/var/log/${APP_USER}-queue.log
|
||||
KillSignal=SIGINT
|
||||
TimeoutStopSec=15
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
chown root:root /etc/systemd/system/${APP_USER}-*.service
|
||||
chmod 644 /etc/systemd/system/${APP_USER}-*.service
|
||||
touch /var/log/${APP_USER}-ws.log /var/log/${APP_USER}-schedule.log /var/log/${APP_USER}-queue.log
|
||||
chown ${APP_USER}:${APP_GROUP} /var/log/${APP_USER}-*.log
|
||||
chmod 664 /var/log/${APP_USER}-*.log
|
||||
|
||||
systemctl daemon-reload
|
||||
|
||||
# Reverb optional: nur starten, wenn Artisan-Command existiert
|
||||
if sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan list --no-ansi | grep -qE '(^| )reverb:start( |$)'"; then
|
||||
systemctl enable --now ${APP_USER}-ws
|
||||
else
|
||||
systemctl disable --now ${APP_USER}-ws >/dev/null 2>&1 || true
|
||||
fi
|
||||
systemctl enable --now ${APP_USER}-schedule
|
||||
systemctl enable --now ${APP_USER}-queue
|
||||
|
||||
systemctl reload nginx || true
|
||||
systemctl restart php*-fpm || true
|
||||
|
||||
db_ready(){
|
||||
mysql -u"${DB_USER}" -p"${DB_PASS}" -h 127.0.0.1 -D "${DB_NAME}" -e "SHOW TABLES LIKE 'migrations'\G" >/dev/null 2>&1
|
||||
}
|
||||
if db_ready; then
|
||||
systemctl reload postfix || true
|
||||
systemctl reload dovecot || true
|
||||
else
|
||||
echo "[i] DB noch nicht migriert – überspringe Postfix/Dovecot reload."
|
||||
fi
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
source ./lib.sh
|
||||
|
||||
log "Monit konfigurieren…"
|
||||
cat > /etc/monit/monitrc <<'EOF'
|
||||
set daemon 60
|
||||
set logfile syslog facility log_daemon
|
||||
|
||||
check process postfix with pidfile /var/spool/postfix/pid/master.pid
|
||||
start program = "/bin/systemctl start postfix"
|
||||
stop program = "/bin/systemctl stop postfix"
|
||||
if failed port 25 protocol smtp then restart
|
||||
if failed port 465 type tcp ssl then restart
|
||||
if failed port 587 type tcp then restart
|
||||
|
||||
check process dovecot with pidfile /var/run/dovecot/master.pid
|
||||
start program = "/bin/systemctl start dovecot"
|
||||
stop program = "/bin/systemctl stop dovecot"
|
||||
if failed port 143 type tcp then restart
|
||||
if failed port 993 type tcp ssl then restart
|
||||
if failed port 110 type tcp then restart
|
||||
if failed port 995 type tcp ssl then restart
|
||||
|
||||
check process mariadb with pidfile /var/run/mysqld/mysqld.pid
|
||||
start program = "/bin/systemctl start mariadb"
|
||||
stop program = "/bin/systemctl stop mariadb"
|
||||
if failed port 3306 type tcp then restart
|
||||
|
||||
check process redis-server with pidfile /run/redis/redis-server.pid
|
||||
start program = "/bin/systemctl start redis-server"
|
||||
stop program = "/bin/systemctl stop redis-server"
|
||||
if failed port 6379 type tcp then restart
|
||||
|
||||
check process rspamd with pidfile /run/rspamd/rspamd.pid
|
||||
start program = "/bin/systemctl start rspamd"
|
||||
stop program = "/bin/systemctl stop rspamd"
|
||||
if failed port 11332 type tcp then restart
|
||||
|
||||
check process opendkim with pidfile /run/opendkim/opendkim.pid
|
||||
start program = "/bin/systemctl start opendkim"
|
||||
stop program = "/bin/systemctl stop opendkim"
|
||||
if failed port 8891 type tcp then restart
|
||||
|
||||
check process nginx with pidfile /run/nginx.pid
|
||||
start program = "/bin/systemctl start nginx"
|
||||
stop program = "/bin/systemctl stop nginx"
|
||||
if failed port 80 type tcp then restart
|
||||
if failed port 443 type tcp ssl then restart
|
||||
|
||||
# Reverb WebSocket
|
||||
check process mailwolt-ws matching "reverb:start"
|
||||
start program = "/bin/systemctl start mailwolt-ws"
|
||||
stop program = "/bin/systemctl stop mailwolt-ws"
|
||||
if failed host 127.0.0.1 port 8080 type tcp for 2 cycles then restart
|
||||
if 5 restarts within 5 cycles then timeout
|
||||
EOF
|
||||
|
||||
chmod 600 /etc/monit/monitrc
|
||||
monit -t && systemctl enable --now monit
|
||||
monit reload
|
||||
monit summary || true
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
install -d /usr/local/bin
|
||||
cat >/usr/local/bin/mw-motd <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
NC="\033[0m"; CY="\033[1;36m"; GR="\033[1;32m"; YE="\033[1;33m"; RD="\033[1;31m"; GY="\033[0;90m"
|
||||
printf "\033[1;36m"
|
||||
cat <<'ASCII'
|
||||
:::: :::: ::: ::::::::::: ::: ::: ::: :::::::: ::: :::::::::::
|
||||
+:+:+: :+:+:+ :+: :+: :+: :+: :+: :+: :+: :+: :+: :+:
|
||||
+:+ +:+:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+
|
||||
+#+ +:+ +#+ +#++:++#++: +#+ +#+ +#+ +:+ +#+ +#+ +:+ +#+ +#+
|
||||
+#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+#+ +#+ +#+ +#+ +#+ +#+
|
||||
#+# #+# #+# #+# #+# #+# #+#+# #+#+# #+# #+# #+# #+#
|
||||
### ### ### ### ########### ########## ### ### ######## ########## ###
|
||||
ASCII
|
||||
printf "\033[0m\n"
|
||||
now="$(date '+%Y-%m-%d %H:%M:%S %Z')"
|
||||
fqdn="$(hostname -f 2>/dev/null || hostname)"
|
||||
ip_int="$(hostname -I 2>/dev/null | awk '{print $1}')"
|
||||
ip_ext=""; command -v curl >/dev/null 2>&1 && ip_ext="$(curl -s --max-time 1 https://ifconfig.me || true)"
|
||||
upt="$(uptime -p 2>/dev/null || true)"
|
||||
cores="$(nproc 2>/dev/null || echo -n '?')"
|
||||
mhz="$(LC_ALL=C lscpu 2>/dev/null | awk -F: '/MHz/{gsub(/ /,"",$2); printf("%.0f MHz",$2); exit}')"
|
||||
load="$(awk '{print $1" / "$2" / "$3}' /proc/loadavg 2>/dev/null)"
|
||||
mem_total="$(awk '/MemTotal/{printf "%.2f GB",$2/1024/1024}' /proc/meminfo)"
|
||||
mem_free="$(awk '/MemAvailable/{printf "%.2f GB",$2/1024/1024}' /proc/meminfo)"
|
||||
svc_status(){ systemctl is-active --quiet "$1" && echo -e "${GR}OK${NC}" || echo -e "${RD}FAIL${NC}"; }
|
||||
printf "${CY}Information as of:${NC} ${YE}%s${NC}\n" "$now"
|
||||
printf "${GY}FQDN :${NC} %s\n" "$fqdn"
|
||||
if [ -n "$ip_ext" ]; then printf "${GY}IP :${NC} %s ${GY}(external:${NC} %s${GY})${NC}\n" "${ip_int:-?}" "$ip_ext"; else printf "${GY}IP :${NC} %s\n" "${ip_int:-?}"; fi
|
||||
printf "${GY}Uptime :${NC} %s\n" "${upt:-?}"
|
||||
printf "${GY}Core(s) :${NC} %s core(s) at ${CY}%s${NC}\n" "$cores" "${mhz:-?}"
|
||||
printf "${GY}Load :${NC} %s (1 / 5 / 15)\n" "${load:-?}"
|
||||
printf "${GY}Memory :${NC} ${RD}%s${NC} ${GY}(free)${NC} / ${CY}%s${NC} ${GY}(total)${NC}\n" "${mem_free:-?}" "${mem_total:-?}"
|
||||
echo
|
||||
printf "${GY}Services :${NC} postfix: $(svc_status postfix) dovecot: $(svc_status dovecot) nginx: $(svc_status nginx) mariadb: $(svc_status mariadb) redis: $(svc_status redis)\n"
|
||||
SH
|
||||
chmod +x /usr/local/bin/mw-motd
|
||||
|
||||
if [[ -d /etc/update-motd.d ]]; then
|
||||
cat >/etc/update-motd.d/10-mailwolt <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
/usr/local/bin/mw-motd
|
||||
SH
|
||||
chmod +x /etc/update-motd.d/10-mailwolt
|
||||
[[ -f /etc/update-motd.d/50-motd-news ]] && chmod -x /etc/update-motd.d/50-motd-news || true
|
||||
[[ -f /etc/update-motd.d/80-livepatch ]] && chmod -x /etc/update-motd.d/80-livepatch || true
|
||||
else
|
||||
cat >/etc/profile.d/10-mailwolt-motd.sh <<'SH'
|
||||
case "$-" in *i*) /usr/local/bin/mw-motd ;; esac
|
||||
SH
|
||||
fi
|
||||
: > /etc/motd 2>/dev/null || true
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
source ./lib.sh
|
||||
|
||||
ok(){ echo -e " [${GREEN}OK${NC}]"; }
|
||||
fail(){ echo -e " [${RED}FAIL${NC}]"; }
|
||||
|
||||
echo "[+] Quick-Healthchecks…"
|
||||
printf " • MariaDB … " ; mysqladmin ping --silent >/dev/null 2>&1 && ok || fail
|
||||
printf " • Redis … " ; if command -v redis-cli >/dev/null 2>&1; then
|
||||
if [[ -n "${REDIS_PASS:-}" && "${REDIS_PASS}" != "null" ]]; then redis-cli -a "${REDIS_PASS}" ping 2>/dev/null | grep -q PONG && ok || fail
|
||||
else redis-cli ping 2>/dev/null | grep -q PONG && ok || fail; fi
|
||||
else fail; fi
|
||||
|
||||
# PHP-FPM
|
||||
detect_php_fpm_sock(){
|
||||
for v in 8.3 8.2 8.1 8.0 7.4; do s="/run/php/php${v}-fpm.sock"; [[ -S "$s" ]] && { echo "$s"; return; }; done
|
||||
[[ -S "/run/php/php-fpm.sock" ]] && { echo "/run/php/php-fpm.sock"; return; }
|
||||
echo "127.0.0.1:9000"
|
||||
}
|
||||
PHP_FPM_SOCK="$(detect_php_fpm_sock)"
|
||||
printf " • PHP-FPM … " ; if [[ "$PHP_FPM_SOCK" == 127.0.0.1:9000 ]]; then ss -ltn | grep -q ":9000 " && ok || fail; else [[ -S "$PHP_FPM_SOCK" ]] && ok || fail; fi
|
||||
|
||||
# App HTTP
|
||||
printf " • App … " ; if command -v curl >/dev/null 2>&1; then
|
||||
if [[ -f "/etc/ssl/ui/fullchain.pem" && -f "/etc/ssl/ui/privkey.pem" ]]; then curl -skI "https://127.0.0.1" >/dev/null 2>&1 && ok || fail; else curl -sI "http://127.0.0.1" >/dev/null 2>&1 && ok || fail; fi
|
||||
else echo -e " ${GREY}(curl fehlt)${NC}"; fi
|
||||
|
||||
check_port(){ local label="$1" cmd="$2"; printf " • %-5s … " "$label"; timeout 8s bash -lc "$cmd" >/dev/null 2>&1 && ok || fail; }
|
||||
check_port "25" 'printf "QUIT\r\n" | nc -w 3 127.0.0.1 25'
|
||||
check_port "465" 'printf "QUIT\r\n" | openssl s_client -connect 127.0.0.1:465 -quiet -ign_eof'
|
||||
check_port "587" 'printf "EHLO x\r\nSTARTTLS\r\nQUIT\r\n" | openssl s_client -starttls smtp -connect 127.0.0.1:587 -quiet -ign_eof'
|
||||
check_port "110" 'printf "QUIT\r\n" | nc -w 3 127.0.0.1 110'
|
||||
check_port "995" 'printf "QUIT\r\n" | openssl s_client -connect 127.0.0.1:995 -quiet -ign_eof'
|
||||
check_port "143" 'printf ". LOGOUT\r\n" | nc -w 3 127.0.0.1 143'
|
||||
check_port "993" 'printf ". LOGOUT\r\n" | openssl s_client -connect 127.0.0.1:993 -quiet -ign_eof'
|
||||
|
||||
# Abschluss
|
||||
echo -e "
|
||||
${GREEN}${BAR}${NC}
|
||||
${GREEN} ✔ ${APP_NAME} Bootstrap abgeschlossen${NC}
|
||||
${GREEN}${BAR}${NC}
|
||||
Aufruf UI: ${CYAN}$( [[ -f /etc/ssl/ui/fullchain.pem ]] && echo https || echo http )://${SERVER_PUBLIC_IPV4}${NC}
|
||||
UI Host bevorzugt: ${GREY}${UI_HOST}${NC}
|
||||
Mail-Host: ${GREY}${MAIL_HOSTNAME}${NC}
|
||||
Nginx Site: ${GREY}/etc/nginx/sites-available/${APP_USER}.conf${NC}
|
||||
TLS (stabile Pfade): ${GREY}/etc/ssl/{ui,webmail,mail}/{fullchain.pem,privkey.pem}${NC}
|
||||
${GREEN}${BAR}${NC}
|
||||
"
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
source ./lib.sh
|
||||
|
||||
require_root
|
||||
header
|
||||
|
||||
# ── Pflicht: Mailserver-FQDN ──────────────────────────────────────────────
|
||||
MAIL_FQDN="${MAIL_FQDN:-}"
|
||||
if [[ -z "${MAIL_FQDN}" ]]; then
|
||||
read -r -p "Mailserver FQDN (z.B. mx.example.com): " MAIL_FQDN
|
||||
fi
|
||||
[[ "$MAIL_FQDN" == *.* ]] || die "MAIL_FQDN (z.B. mx.example.com) ist Pflicht."
|
||||
|
||||
# Ableitungen
|
||||
MTA_SUB="${MAIL_FQDN%%.*}"
|
||||
BASE_DOMAIN="${MAIL_FQDN#*.}"
|
||||
UI_SUB="${UI_SUB:-ui}"
|
||||
WEBMAIL_SUB="${WEBMAIL_SUB:-webmail}"
|
||||
SYSTEM_SUB="${SYSTEM_SUB:-system}" # kommt aus Seeder (App), hier nur Derivate
|
||||
|
||||
export APP_NAME="${APP_NAME:-MailWolt}"
|
||||
export APP_ENV="${APP_ENV:-production}"
|
||||
export APP_DEBUG="${APP_DEBUG:-false}"
|
||||
|
||||
export BASE_DOMAIN UI_SUB WEBMAIL_SUB MTA_SUB SYSTEM_SUB
|
||||
export UI_HOST="${UI_SUB}.${BASE_DOMAIN}"
|
||||
export WEBMAIL_HOST="${WEBMAIL_SUB}.${BASE_DOMAIN}"
|
||||
export MAIL_HOSTNAME="${MAIL_FQDN}"
|
||||
export SYSTEM_HOSTNAME="${SYSTEM_SUB}.${BASE_DOMAIN}"
|
||||
|
||||
export SERVER_PUBLIC_IPV4="$(detect_ip)"
|
||||
export SERVER_PUBLIC_IPV6="$(detect_ipv6)"
|
||||
|
||||
# Zeitzone/Locale heuristisch (kannst du später per UI anpassen)
|
||||
export APP_TZ="$(detect_timezone)"
|
||||
export APP_LOCALE="$(guess_locale_from_tz "$APP_TZ")"
|
||||
|
||||
# LE-Kontakt
|
||||
export LE_EMAIL="${LE_EMAIL:-admin@${BASE_DOMAIN}}"
|
||||
|
||||
# Repo/Branch (für 80-app.sh)
|
||||
export GIT_REPO="${GIT_REPO:-https://git.nexlab.at/boban/mailwolt.git}"
|
||||
export GIT_BRANCH="${GIT_BRANCH:-main}"
|
||||
export APP_USER="${APP_USER:-mailwolt}"
|
||||
export APP_GROUP="${APP_GROUP:-www-data}"
|
||||
export APP_DIR="/var/www/${APP_USER}"
|
||||
export APP_USER_PREFIX="${APP_USER_PREFIX:-mw}"
|
||||
|
||||
# Run modules
|
||||
./10-provision.sh
|
||||
./20-ssl.sh
|
||||
./30-db.sh
|
||||
./40-postfix.sh
|
||||
./50-dovecot.sh
|
||||
./60-rspamd-opendkim.sh
|
||||
./70-nginx.sh
|
||||
./80-app.sh
|
||||
./90-services.sh
|
||||
./95-monit.sh
|
||||
./98-motd.sh
|
||||
./99-summary.sh
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ── Styling ────────────────────────────────────────────────────────────────
|
||||
GREEN="$(printf '\033[1;32m')"; YELLOW="$(printf '\033[1;33m')"
|
||||
RED="$(printf '\033[1;31m')"; CYAN="$(printf '\033[1;36m')"
|
||||
GREY="$(printf '\033[0;90m')"; NC="$(printf '\033[0m')"
|
||||
BAR="──────────────────────────────────────────────────────────────────────────────"
|
||||
|
||||
header(){ echo -e "${CYAN}${BAR}${NC}
|
||||
${CYAN} 888b d888 d8b 888 888 888 888 888 ${NC}
|
||||
${CYAN} 8888b d8888 Y8P 888 888 o 888 888 888 ${NC}
|
||||
${CYAN} 88888b.d88888 888 888 d8b 888 888 888 ${NC}
|
||||
${CYAN} 888Y88888P888 8888b. 888 888 888 d888b 888 .d88b. 888 888888 ${NC}
|
||||
${CYAN} 888 Y888P 888 '88b 888 888 888d88888b888 d88''88b 888 888 ${NC}
|
||||
${CYAN} 888 Y8P 888 .d888888 888 888 88888P Y88888 888 888 888 888 ${NC}
|
||||
${CYAN} 888 ' 888 888 888 888 888 8888P Y8888 Y88..88P 888 Y88b. ${NC}
|
||||
${CYAN} 888 888 'Y888888 888 888 888P Y888 'Y88P' 888 'Y888 ${NC}
|
||||
${CYAN}${BAR}${NC}\n"; }
|
||||
|
||||
log(){ echo -e "${GREEN}[+]${NC} $*"; }
|
||||
warn(){ echo -e "${YELLOW}[!]${NC} $*"; }
|
||||
err(){ echo -e "${RED}[x]${NC} $*"; }
|
||||
die(){ err "$*"; exit 1; }
|
||||
require_root(){ [[ "$(id -u)" -eq 0 ]] || die "Bitte als root ausführen."; }
|
||||
|
||||
detect_ip(){
|
||||
local ip
|
||||
ip="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}')" || true
|
||||
[[ -n "${ip:-}" ]] || ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
|
||||
[[ -n "${ip:-}" ]] || die "Konnte Server-IP nicht ermitteln."
|
||||
echo "$ip"
|
||||
}
|
||||
|
||||
detect_ipv6(){
|
||||
local ip6
|
||||
ip6="$(ip -6 addr show scope global 2>/dev/null | awk '/inet6/{print $2}' | cut -d/ -f1 | head -n1)" || true
|
||||
[[ -n "${ip6:-}" ]] || ip6="$(hostname -I 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i ~ /:/){print $i; exit}}')" || true
|
||||
echo "${ip6:-}"
|
||||
}
|
||||
|
||||
detect_timezone(){
|
||||
if command -v timedatectl >/dev/null 2>&1; then
|
||||
tz="$(timedatectl show -p Timezone --value 2>/dev/null | tr -d '[:space:]')" || true
|
||||
[[ -n "${tz:-}" && "$tz" == */* ]] && { echo "$tz"; return; }
|
||||
fi
|
||||
if [[ -r /etc/timezone ]]; then
|
||||
tz="$(sed -n '1p' /etc/timezone | tr -d '[:space:]')" || true
|
||||
[[ -n "${tz:-}" && "$tz" == */* ]] && { echo "$tz"; return; }
|
||||
fi
|
||||
if [[ -L /etc/localtime ]]; then
|
||||
target="$(readlink -f /etc/localtime 2>/dev/null || true)"
|
||||
target="${target#/usr/share/zoneinfo/}"
|
||||
[[ "$target" == */* ]] && { echo "$target"; return; }
|
||||
fi
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
tz="$(curl -fsSL --max-time 3 https://ipapi.co/timezone 2>/dev/null || true)"
|
||||
[[ -n "${tz:-}" && "$tz" == */* ]] && { echo "$tz"; return; }
|
||||
fi
|
||||
echo "UTC"
|
||||
}
|
||||
|
||||
guess_locale_from_tz(){
|
||||
local tz="${1:-UTC}"
|
||||
case "$tz" in
|
||||
Europe/Berlin|Europe/Vienna|Europe/Zurich|Europe/Luxembourg|Europe/Brussels|Europe/Amsterdam) echo "de";;
|
||||
*) echo "en";;
|
||||
esac
|
||||
}
|
||||
|
||||
resolve_ok(){
|
||||
local host="$1" ip="${SERVER_PUBLIC_IPV4:-}"
|
||||
[[ -n "$ip" ]] || return 1
|
||||
getent ahosts "$host" | awk '{print $1}' | sort -u | grep -q -F "$ip"
|
||||
}
|
||||
|
||||
ensure_dir(){ install -d -m "${3:-0755}" -o "${1:-root}" -g "${2:-root}" "${4}"; }
|
||||
Loading…
Reference in New Issue