diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b7eb4dc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,102 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +# Development (starts PHP server + queue + logs + Vite concurrently) +composer dev + +# Build assets for production +npm run build + +# Vite dev server only +npm run dev + +# Run all tests +composer test +php artisan test + +# Run a single test file +php artisan test tests/Feature/ExampleTest.php + +# Run a single test by name +php artisan test --filter=TestName + +# Migrations +php artisan migrate +``` + +## Architecture + +### Design +- Tailwind CSS v4 +- Modals sind immer vom Livewire Modals +- Kein Inline-Style nur wenn wirklich notwendig. +- Immer prüfen das das Style nicht kaputt ist und wenn Icons in Buttons mit Text sind, sind diese immer nebeneinadner Nie übereinander. + +### What this is +Mailwolt is a self-hosted mail server administration panel (German UI). It manages domains, mailboxes, aliases, DNS records (DKIM/DMARC/SPF/TLSA), TLS, Fail2ban, IMAP, and email quarantine/queues. It also has a mail sandbox for testing Postfix transports. + +### Layout & Routing +- **`resources/views/layouts/dvx.blade.php`** is the actual app layout (not `app.blade.php`). Livewire full-page components use `#[Layout('layouts.dvx')]`. +- Routes are in `routes/web.php` — each page maps directly to a Livewire full-page component via `->name()`. +- Navigation structure is driven by `config/ui-menu.php`. +- Für den Style wird Tailwind CSS v4 + +### Livewire component structure +``` +app/Livewire/Ui/ +├── Nx/ — Current production UI (Dashboard, DomainList, MailboxList, AliasList, etc.) +├── Domain/ — Domain modals and DKIM/DNS views +├── Mail/ — Mailbox/alias modals, queue, quarantine +├── Security/ — Fail2ban, SSL, RSpamd, TLS, audit logs +├── System/ — Settings, users, API keys, webhooks, sandbox, backups +├── Search/ — Global search palette modal +└── Webmail/ — Webmail-specific components +``` + +Full-page components live in `Ui/Nx/` and `Ui/System/`, `Ui/Security/`, etc. Modals are always in a `Modal/` subfolder and extend `LivewireUI\Modal\ModalComponent`. + +### Modals (wire-elements/modal v2) +- Open from **outside** a Livewire component: `onclick="Livewire.dispatch('openModal', {component:'ui.system.modal.my-modal', arguments:{key:value}})"` +- Open from **inside** a Livewire component: `$this->dispatch('openModal', component: '...', arguments: [...])` +- **Never** use `wire:click="$dispatch('openModal',...)"` outside a Livewire component context — it won't work. +- Modal argument keys must match the `mount(int $keyName)` parameter names exactly. +- To prevent closing on backdrop/Escape, override in the modal class: + ```php + public static function closeModalOnClickAway(): bool { return false; } + public static function closeModalOnEscape(): bool { return false; } + public static function closeModalOnEscapeIsForceful(): bool { return false; } + ``` +- To force-close the entire modal stack: `$this->forceClose()->closeModal()` + +### Livewire dependency injection +- **Never** use constructor injection in Livewire components — Livewire calls `new Component()` with no args. +- Use `boot(MyService $service)` instead: this is called on every request and supports DI. + +### CSS design system +The app uses a custom `mw-*` variable and class system defined in `resources/css/app.css` (Tailwind CSS v4, no `tailwind.config.js`): + +**CSS variables:** +- `--mw-bg`, `--mw-bg3`, `--mw-bg4` — background layers +- `--mw-b1`, `--mw-b2`, `--mw-b3` — border shades +- `--mw-t1` through `--mw-t5` — text shades (t1 = primary, t4/t5 = muted) +- `--mw-v`, `--mw-v2`, `--mw-vbg` — purple accent (primary brand color) +- `--mw-gr` — green (success) + +**Reusable component classes:** `.mw-btn-primary`, `.mw-btn-secondary`, `.mw-btn-cancel`, `.mw-btn-save`, `.mw-btn-del`, `.mbx-act-btn`, `.mbx-act-danger`, `.mw-modal-frame`, `.mw-modal-head`, `.mw-modal-body`, `.mw-modal-foot`, `.mw-modal-label`, `.mw-modal-input`, `.mw-modal-error`, `.mbx-badge-mute`, `.mbx-badge-ok`, `.mbx-badge-warn`. + +### Services +`app/Services/` contains: `DkimService`, `DnsRecordService`, `ImapService`, `MailStorage`, `SandboxMailParser`, `SandboxService`, `TlsaService`, `TotpService`, `WebhookService`. + +### API +REST API under `/api/v1/` uses Laravel Sanctum. Token abilities map to scopes like `mailboxes:read`, `domains:write`, etc. Defined in `routes/api.php`. + +### Sandbox mail system +The mail sandbox intercepts Postfix mail via a pipe transport (`php artisan sandbox:receive`). `SandboxRoute` model controls which domains/addresses are intercepted. `SandboxService::syncTransportFile()` writes `/etc/postfix/transport.sandbox` and runs `postmap`. + +### Queue & real-time +- Queue driver: database (configurable). Jobs in `app/Jobs/`. +- Real-time updates use Laravel Reverb + Pusher.js. Livewire polls (`wire:poll.5s`) are used as fallback on some pages. diff --git a/app/Livewire/Ui/Nx/Dashboard.php b/app/Livewire/Ui/Nx/Dashboard.php index 19701c1..e3ca906 100644 --- a/app/Livewire/Ui/Nx/Dashboard.php +++ b/app/Livewire/Ui/Nx/Dashboard.php @@ -6,6 +6,7 @@ use App\Models\BackupJob; use App\Models\BackupPolicy; use App\Models\Domain; use App\Models\MailUser; +use App\Models\SandboxRoute; use App\Models\Setting as SettingModel; use App\Support\CacheVer; use Illuminate\Support\Facades\Cache; @@ -43,7 +44,8 @@ class Dashboard extends Component 'mailboxCount' => MailUser::where('is_system', false)->where('is_active', true)->count(), 'servicesActive' => $servicesActive, 'servicesTotal' => count($services), - 'alertCount' => 0, + 'alertCount' => SandboxRoute::where('is_active', true)->count(), + 'sandboxAlerts' => SandboxRoute::activeRoutes(), 'backup' => $this->backupData(), 'mailHostname' => gethostname() ?: 'mailserver', 'services' => $services, diff --git a/app/Livewire/Ui/Search/Modal/SearchPaletteModal.php b/app/Livewire/Ui/Search/Modal/SearchPaletteModal.php index 9714d26..7e17212 100644 --- a/app/Livewire/Ui/Search/Modal/SearchPaletteModal.php +++ b/app/Livewire/Ui/Search/Modal/SearchPaletteModal.php @@ -126,21 +126,20 @@ class SearchPaletteModal extends ModalComponent 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); + $component = 'ui.domain.modal.domain-edit-modal'; + $arguments = ['domainId' => $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); + $component = 'ui.mail.modal.mailbox-edit-modal'; + $arguments = ['mailboxId' => $id]; + } else { + return; } + + // Search palette schließen, dann nach dem State-Reset (300 ms) das Ziel-Modal öffnen + $this->forceClose()->closeModal(); + $payload = json_encode(['component' => $component, 'arguments' => $arguments]); + $this->js("setTimeout(()=>Livewire.dispatch('openModal',{$payload}),350)"); } public static function modalMaxWidth(): string diff --git a/app/Livewire/Ui/System/Modal/ApiKeyCreateModal.php b/app/Livewire/Ui/System/Modal/ApiKeyCreateModal.php index a8ffe0c..2246e38 100644 --- a/app/Livewire/Ui/System/Modal/ApiKeyCreateModal.php +++ b/app/Livewire/Ui/System/Modal/ApiKeyCreateModal.php @@ -20,21 +20,22 @@ class ApiKeyCreateModal extends ModalComponent 'domains:write' => 'Domains schreiben', ]; + public static function closeModalOnClickAway(): bool { return false; } + public static function closeModalOnEscape(): bool { return false; } + public static function closeModalOnEscapeIsForceful(): bool { return false; } + public function create(): void { $this->validate([ - 'name' => 'required|string|max:80', - 'selected' => 'required|array|min:1', + 'name' => 'required|string|max:80', + 'selected' => 'required|array|min:1', 'selected.*' => 'in:' . implode(',', array_keys(self::$availableScopes)), ], [ 'selected.required' => 'Bitte mindestens einen Scope auswählen.', 'selected.min' => 'Bitte mindestens einen Scope auswählen.', ]); - $token = Auth::user()->createToken( - $this->name, - $this->selected, - ); + $token = Auth::user()->createToken($this->name, $this->selected); $pat = $token->accessToken; if ($this->sandbox) { @@ -43,7 +44,10 @@ class ApiKeyCreateModal extends ModalComponent } $this->dispatch('token-created', plainText: $token->plainTextToken); - $this->closeModal(); + $this->dispatch('openModal', + component: 'ui.system.modal.api-key-show-modal', + arguments: ['plainText' => $token->plainTextToken], + ); } public function toggleAll(): void diff --git a/app/Livewire/Ui/System/Modal/ApiKeyDeleteModal.php b/app/Livewire/Ui/System/Modal/ApiKeyDeleteModal.php new file mode 100644 index 0000000..c834737 --- /dev/null +++ b/app/Livewire/Ui/System/Modal/ApiKeyDeleteModal.php @@ -0,0 +1,42 @@ +where('tokenable_type', Auth::user()::class) + ->findOrFail($tokenId); + + $this->tokenId = $tokenId; + $this->tokenName = $token->name; + } + + public function delete(): void + { + PersonalAccessToken::where('tokenable_id', Auth::id()) + ->where('tokenable_type', Auth::user()::class) + ->findOrFail($this->tokenId) + ->delete(); + + $this->dispatch('toast', type: 'done', badge: 'API Key', + title: 'Gelöscht', text: "Key {$this->tokenName} wurde entfernt.", duration: 4000); + + $this->dispatch('token-deleted'); + $this->closeModal(); + } + + public function render() + { + return view('livewire.ui.system.modal.api-key-delete-modal'); + } +} diff --git a/app/Livewire/Ui/System/Modal/ApiKeyScopesModal.php b/app/Livewire/Ui/System/Modal/ApiKeyScopesModal.php new file mode 100644 index 0000000..07bfa5e --- /dev/null +++ b/app/Livewire/Ui/System/Modal/ApiKeyScopesModal.php @@ -0,0 +1,28 @@ +where('tokenable_type', Auth::user()::class) + ->findOrFail($tokenId); + + $this->tokenName = $token->name; + $this->scopes = $token->abilities; + } + + public function render() + { + return view('livewire.ui.system.modal.api-key-scopes-modal'); + } +} diff --git a/app/Livewire/Ui/System/Modal/ApiKeyShowModal.php b/app/Livewire/Ui/System/Modal/ApiKeyShowModal.php index af5e208..76b155b 100644 --- a/app/Livewire/Ui/System/Modal/ApiKeyShowModal.php +++ b/app/Livewire/Ui/System/Modal/ApiKeyShowModal.php @@ -13,9 +13,18 @@ class ApiKeyShowModal extends ModalComponent $this->plainText = $plainText; } + public static function closeModalOnClickAway(): bool { return false; } + public static function closeModalOnEscape(): bool { return false; } + public static function closeModalOnEscapeIsForceful(): bool { return false; } + + public function dismiss(): void + { + $this->forceClose()->closeModal(); + } + public static function modalMaxWidth(): string { - return 'md'; + return '2xl'; } public function render() diff --git a/app/Livewire/Ui/System/SandboxRoutes.php b/app/Livewire/Ui/System/SandboxRoutes.php new file mode 100644 index 0000000..c71d6fd --- /dev/null +++ b/app/Livewire/Ui/System/SandboxRoutes.php @@ -0,0 +1,68 @@ +sandboxService = $sandboxService; + } + + public function addRoute(): void + { + if ($this->newType !== 'global') { + $this->validate(['newTarget' => 'required|string|max:255']); + } + + $target = $this->newType === 'global' ? null : trim($this->newTarget); + $this->sandboxService->enable($this->newType, $target); + $this->sandboxService->syncTransportFile(); + $this->dispatch('sandbox-route-changed'); + $this->newTarget = ''; + } + + public function removeRoute(int $id): void + { + $this->sandboxService->delete($id); + $this->sandboxService->syncTransportFile(); + $this->dispatch('sandbox-route-changed'); + } + + public function toggleRoute(int $id): void + { + $route = SandboxRoute::findOrFail($id); + $route->is_active = !$route->is_active; + $route->save(); + $this->sandboxService->syncTransportFile(); + $this->dispatch('sandbox-route-changed'); + } + + public function syncPostfix(): void + { + $result = $this->sandboxService->syncTransportFile(); + $this->syncOk = $result['ok']; + $this->syncMessage = $result['message']; + } + + #[Computed] + public function routes() + { + return SandboxRoute::orderBy('type')->orderBy('target')->get(); + } + + public function render() + { + return view('livewire.ui.system.sandbox-routes'); + } +} diff --git a/app/Models/SandboxRoute.php b/app/Models/SandboxRoute.php new file mode 100644 index 0000000..5b92664 --- /dev/null +++ b/app/Models/SandboxRoute.php @@ -0,0 +1,63 @@ + 'boolean', + ]; + + /** + * All active routes ordered by type and target. + */ + public static function activeRoutes(): Collection + { + return static::where('is_active', true) + ->orderBy('type') + ->orderBy('target') + ->get(); + } + + /** + * Check whether sandbox is active for a given domain. + * True if global sandbox is active OR domain-specific rule is active. + */ + public static function isActiveForDomain(string $domain): bool + { + return static::where('is_active', true) + ->where(function ($q) use ($domain) { + $q->where('type', 'global') + ->orWhere(function ($q2) use ($domain) { + $q2->where('type', 'domain')->where('target', $domain); + }); + }) + ->exists(); + } + + /** + * Check whether sandbox is active for a given address. + * True if global is active OR domain matches OR address-specific rule exists. + */ + public static function isActiveForAddress(string $address): bool + { + $domain = substr(strrchr($address, '@'), 1) ?: ''; + + return static::where('is_active', true) + ->where(function ($q) use ($address, $domain) { + $q->where('type', 'global') + ->orWhere(function ($q2) use ($domain) { + $q2->where('type', 'domain')->where('target', $domain); + }) + ->orWhere(function ($q3) use ($address) { + $q3->where('type', 'address')->where('target', $address); + }); + }) + ->exists(); + } +} diff --git a/app/Services/SandboxService.php b/app/Services/SandboxService.php new file mode 100644 index 0000000..f136e53 --- /dev/null +++ b/app/Services/SandboxService.php @@ -0,0 +1,88 @@ + $type, 'target' => $target], + ['is_active' => true] + ); + } + + /** + * Disable a sandbox route by id. + */ + public function disable(int $id): void + { + SandboxRoute::findOrFail($id)->update(['is_active' => false]); + } + + /** + * Delete a sandbox route by id. + */ + public function delete(int $id): void + { + SandboxRoute::findOrFail($id)->delete(); + } + + /** + * Write /etc/postfix/transport.sandbox and run postmap. + * Returns ['ok' => bool, 'message' => string]. + */ + public function syncTransportFile(): array + { + $file = config('sandbox.transport_file', '/etc/postfix/transport.sandbox'); + + $routes = SandboxRoute::where('is_active', true) + ->orderBy('type') + ->orderBy('target') + ->get(); + + $lines = []; + $hasGlobal = false; + + foreach ($routes as $route) { + if ($route->type === 'global') { + $hasGlobal = true; + } elseif ($route->type === 'domain') { + $lines[] = $route->target . ' sandbox:'; + } elseif ($route->type === 'address') { + $lines[] = $route->target . ' sandbox:'; + } + } + + // Global catch-all goes at the end + if ($hasGlobal) { + $lines[] = '* sandbox:'; + } + + $content = implode("\n", $lines); + if ($lines) { + $content .= "\n"; + } + + try { + file_put_contents($file, $content); + } catch (\Throwable $e) { + return ['ok' => false, 'message' => 'Fehler beim Schreiben: ' . $e->getMessage()]; + } + + $out = []; + $code = 0; + exec('postmap ' . escapeshellarg($file) . ' 2>&1', $out, $code); + + if ($code !== 0) { + return ['ok' => false, 'message' => 'postmap Fehler: ' . implode(' ', $out)]; + } + + return ['ok' => true, 'message' => 'Transport-Datei synchronisiert (' . count($lines) . ' Einträge).']; + } +} diff --git a/database/migrations/2026_04_23_000001_create_sandbox_routes_table.php b/database/migrations/2026_04_23_000001_create_sandbox_routes_table.php new file mode 100644 index 0000000..8a33eff --- /dev/null +++ b/database/migrations/2026_04_23_000001_create_sandbox_routes_table.php @@ -0,0 +1,25 @@ +id(); + $table->enum('type', ['global', 'domain', 'address'])->default('domain'); + $table->string('target')->nullable(); // domain or email, null = global + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->unique(['type', 'target']); + }); + } + + public function down(): void + { + Schema::dropIfExists('sandbox_routes'); + } +}; diff --git a/resources/css/app.css b/resources/css/app.css index 20b1721..a99b10f 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -5,6 +5,7 @@ @import "../fonts/Space/font.css"; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; +@source '../../vendor/wire-elements/modal/resources/views/*.blade.php'; @source '../../storage/framework/views/*.php'; @source '../**/*.blade.php'; @source '../**/*.js'; @@ -99,6 +100,19 @@ .mw-divider { @apply border-t border-white/10 my-4; } + + /* ── Header utility classes ── */ + .btn-ghost { + @apply inline-flex items-center justify-center rounded-lg + text-white/60 hover:text-white hover:bg-white/5 + transition-colors duration-150 cursor-pointer; + padding: 0.4rem; + } + + .popup { + @apply rounded-xl border border-white/10 shadow-xl z-50 + bg-[#0f172a]/95 backdrop-blur-xl text-white/80; + } } /* ============ BOOT-STATE: keine Sprünge ============ */ @@ -1093,40 +1107,125 @@ select { /* ── Main ── */ .mw-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; } -/* ── Topbar ── */ +/* ════════════════════════════════════════ + Topbar — Redesign +════════════════════════════════════════ */ .mw-topbar { - height: 50px; padding: 0 22px; border-bottom: 1px solid var(--mw-b1); - background: var(--mw-bg); display: flex; align-items: center; - justify-content: space-between; flex-shrink: 0; + height: 54px; + padding: 0 20px; + border-bottom: 1px solid var(--mw-b1); + background: var(--mw-bg); + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + gap: 8px; } -.mw-breadcrumb { display: flex; align-items: center; gap: 5px; font-size: 13px; color: var(--mw-t4); } -.mw-breadcrumb b { color: var(--mw-t2); font-weight: 500; } -.mw-topbar-right { display: flex; align-items: center; gap: 8px; } -.mw-live { display: flex; align-items: center; gap: 5px; font-size: 11.5px; color: var(--mw-t3); } -.mw-live-dot { - width: 6px; height: 6px; border-radius: 50%; background: var(--mw-gr); - box-shadow: 0 0 5px rgba(34,197,94,.5); animation: mw-pulse 2s infinite; +/* ── Links ── */ +.mw-tb-left { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + flex: 1; } -@keyframes mw-pulse { 0%,100%{opacity:1} 50%{opacity:.3} } -.mw-search { - display: flex; align-items: center; gap: 6px; background: var(--mw-bg3); - border: 1px solid var(--mw-b1); border-radius: 6px; padding: 5px 10px; - font-size: 12px; color: var(--mw-t4); cursor: pointer; +.mw-hamburger { + display: none; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 32px; height: 32px; + border-radius: 8px; + background: none; + border: none; + color: var(--mw-t3); + cursor: pointer; + transition: background .15s, color .15s; } -.mw-search kbd { font-size: 9.5px; opacity: .5; margin-left: 2px; } -.mw-ip { +.mw-hamburger:hover { background: var(--mw-bg3); color: var(--mw-t1); } + +.mw-tb-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + min-width: 0; + overflow: hidden; +} +.mw-tb-parent { color: var(--mw-t4); white-space: nowrap; flex-shrink: 0; } +.mw-tb-sep { color: var(--mw-b3); flex-shrink: 0; } +.mw-tb-current { color: var(--mw-t2); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +/* ── Rechts ── */ +.mw-tb-right { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +/* Live-Dot */ +.mw-tb-live { + display: flex; align-items: center; gap: 5px; + padding: 4px 9px; + border-radius: 99px; + background: rgba(34,197,94,.08); + border: 1px solid rgba(34,197,94,.18); + font-size: 11px; font-weight: 500; color: #4ade80; +} +.mw-tb-live-dot { + width: 6px; height: 6px; border-radius: 50%; + background: #22c55e; + box-shadow: 0 0 6px rgba(34,197,94,.7); + animation: mw-pulse 2s infinite; +} +@keyframes mw-pulse { 0%,100%{opacity:1} 50%{opacity:.35} } + +/* Suchbutton */ +.mw-tb-search { + display: flex; align-items: center; gap: 7px; + height: 32px; padding: 0 12px; + background: var(--mw-bg3); + border: 1px solid var(--mw-b2); + border-radius: 8px; + font-size: 12px; color: var(--mw-t4); + cursor: pointer; + transition: border-color .15s, color .15s; + white-space: nowrap; +} +.mw-tb-search:hover { border-color: var(--mw-b3); color: var(--mw-t2); } +.mw-tb-kbd { + display: inline-flex; align-items: center; justify-content: center; + padding: 2px 5px; + background: var(--mw-b1); border: 1px solid var(--mw-b2); + border-radius: 4px; font-size: 10px; color: var(--mw-t5); + font-family: ui-monospace, monospace; +} + +/* Hostname-Badge */ +.mw-tb-host { + display: flex; align-items: center; gap: 5px; + height: 32px; padding: 0 10px; + background: var(--mw-bg3); border: 1px solid var(--mw-b2); + border-radius: 8px; font-family: ui-monospace, monospace; font-size: 11px; color: var(--mw-t4); - background: var(--mw-bg3); border: 1px solid var(--mw-b1); border-radius: 5px; padding: 4px 9px; + white-space: nowrap; } -.mw-btn-primary { - display: inline-flex; align-items: center; gap: 5px; - background: var(--mw-v); border: none; border-radius: 6px; padding: 6px 14px; - font-size: 12.5px; font-weight: 600; color: #fff; cursor: pointer; - box-shadow: 0 0 10px rgba(124,58,237,.35); + +/* + Domain Button */ +.mw-tb-add { + display: flex; align-items: center; gap: 6px; + height: 32px; padding: 0 14px; + background: var(--mw-v); + border: none; border-radius: 8px; + font-size: 12.5px; font-weight: 600; color: #fff; + cursor: pointer; flex-shrink: 0; + box-shadow: 0 2px 12px rgba(124,58,237,.35); + transition: background .15s, box-shadow .15s; } -.mw-btn-primary:hover { background: #6d28d9; } +.mw-tb-add:hover { background: #6d28d9; box-shadow: 0 2px 16px rgba(124,58,237,.5); } /* ── Content ── */ .mw-content { flex: 1; overflow-y: auto; padding: 20px 22px 30px; } @@ -1346,13 +1445,16 @@ select { /* ── Responsive ── */ @media (max-width: 1024px) { .mw-content { padding: 16px 18px 24px; } - .mw-topbar { padding: 0 18px; } + .mw-topbar { padding: 0 16px; } } @media (max-width: 900px) { .mw-bottom-grid { grid-template-columns: 1fr; } .mw-right-col { flex-direction: row; } + /* Hostname auf Tablet kompakter */ + .mw-tb-host { font-size: 10.5px; padding: 0 8px; } } @media (max-width: 768px) { + /* Sidebar off-canvas */ .mw-sidebar { position: fixed; left: 0; top: 0; z-index: 50; transform: translateX(-100%); transition: transform .25s; @@ -1363,10 +1465,10 @@ select { background: rgba(0,0,0,.55); z-index: 40; } .mw-sidebar-overlay.open { display: block; } - .mw-hamburger { - display: flex !important; background: none; border: none; - color: var(--mw-t3); cursor: pointer; padding: 4px; margin-right: 4px; - } + /* Hamburger einblenden */ + .mw-hamburger { display: flex; } + /* Parent bleibt sichtbar (3 Buchstaben) */ + /* Layout */ .mw-metric-grid { grid-template-columns: repeat(2, 1fr); } .mw-status-grid { grid-template-columns: repeat(2, 1fr); } .mw-shell { display: block; height: 100vh; } @@ -1376,7 +1478,176 @@ select { @media (max-width: 480px) { .mw-metric-grid { grid-template-columns: 1fr; } .mw-status-grid { grid-template-columns: 1fr; } - .mw-ip { display: none; } + .mw-topbar { padding: 0 12px; gap: 5px; } + .mw-tb-parent { display: none; } + .mw-tb-sep { display: none; } + .mw-tb-search-text { display: none; } + .mw-tb-kbd { display: none; } + .mw-tb-search { padding: 0 9px; } + .mw-tb-add-text { display: none; } + .mw-tb-add { padding: 0 9px; } +} + +/* ── API Key Layout ── */ +.mw-apikey-layout { + display: grid; + grid-template-columns: 1fr 340px; + gap: 14px; + align-items: start; +} +.mw-apikey-layout > * { min-width: 0; } +@media (max-width: 900px) { + .mw-apikey-layout { grid-template-columns: 1fr; } +} +/* ── Responsive list helpers ── */ +@media (max-width: 640px) { + .mw-col-hide-sm { display: none !important; } + .mw-only-desktop { display: none !important; } +} +@media (min-width: 641px) { + .mw-only-mobile { display: none !important; } +} + +/* ── API Key div-list ── */ +.mw-kl-head, +.mw-kl-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 104px 80px 90px 40px; + gap: 8px; + padding: 8px 14px; + align-items: center; + min-width: 0; +} +.mw-kl-head { + font-size: 9.5px; font-weight: 600; text-transform: uppercase; + letter-spacing: .5px; color: var(--mw-t4); + border-bottom: 1px solid var(--mw-b1); +} +.mw-kl-row { + border-bottom: 1px solid var(--mw-b1); + transition: background .1s; +} +.mw-kl-row:last-child { border-bottom: none; } +.mw-kl-row:hover { background: rgba(255,255,255,.015); } +.mw-kl-row > * { min-width: 0; overflow: hidden; } +.mw-kl-date-inline { display: none; } +.mw-kl-head span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +@media (max-width: 900px) and (min-width: 641px) { + .mw-kl-head, + .mw-kl-row { grid-template-columns: minmax(0, 1fr) 120px 80px 40px; } + .mw-kl-date { display: none; } + .mw-kl-date-inline { display: inline; } +} + +@media (max-width: 640px) { + .mw-kl-head { display: none; } + .mw-kl-row { + grid-template-columns: 1fr auto; + grid-template-rows: auto auto; + gap: 5px 8px; + padding: 10px 12px; + } + .mw-kl-name { grid-column: 1; grid-row: 1; } + .mw-kl-scopes { grid-column: 1; grid-row: 2; flex-wrap: wrap; overflow: visible; } + .mw-kl-modus { grid-column: 2; grid-row: 1; } + .mw-kl-date { display: none; } + .mw-kl-date-inline { display: inline; } + .mw-kl-actions { grid-column: 2; grid-row: 2; justify-self: end; } +} + +/* ── Webhook div-list ── */ +.mw-whl-head, +.mw-whl-row { + display: grid; + grid-template-columns: 1fr 124px 72px 54px 82px; + gap: 8px; + padding: 8px 14px; + align-items: center; + min-width: 0; +} +.mw-whl-head { + font-size: 9.5px; font-weight: 600; text-transform: uppercase; + letter-spacing: .5px; color: var(--mw-t4); + border-bottom: 1px solid var(--mw-b1); +} +.mw-whl-row { + border-bottom: 1px solid var(--mw-b1); + transition: background .1s; +} +.mw-whl-row:last-child { border-bottom: none; } +.mw-whl-row:hover { background: rgba(255,255,255,.015); } +.mw-whl-row > * { min-width: 0; } +.mw-whl-status-sm, .mw-whl-http-sm { display: none; } + +@media (max-width: 640px) { + .mw-whl-head { display: none; } + .mw-whl-row { + grid-template-columns: 1fr auto; + grid-template-rows: auto auto; + gap: 5px 8px; + padding: 10px 12px; + } + .mw-whl-webhook { grid-column: 1; grid-row: 1; } + .mw-whl-events { grid-column: 1; grid-row: 2; } + .mw-whl-status { display: none; } + .mw-whl-http { display: none; } + .mw-whl-date { display: none; } + .mw-whl-actions { + grid-column: 2; grid-row: 1 / 3; + display: flex; flex-direction: column; gap: 4px; align-self: start; + } + .mw-whl-status-sm, .mw-whl-http-sm { display: inline; } +} + +/* ── Webhook-Tabelle Layout ── */ +.mw-wh-layout { + display: grid; + grid-template-columns: 1fr 300px; + gap: 14px; + align-items: start; +} +.mw-wh-layout > * { min-width: 0; } +@media (max-width: 900px) { + .mw-wh-layout { grid-template-columns: 1fr; } +} + +/* ── Sandbox Layout ── */ +.mw-sandbox-client { + display: grid; + grid-template-columns: 300px 1fr; + gap: 0; + border: 1px solid var(--mw-b1); + border-radius: 10px; + overflow: hidden; + background: var(--mw-bg2); + min-height: 520px; +} +.mw-sandbox-list { + border-right: 1px solid var(--mw-b1); + overflow-y: auto; + max-height: 680px; +} +.mw-sandbox-detail { + overflow-y: auto; + max-height: 680px; + min-height: 400px; +} +.mw-postfix-grid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 16px; + padding: 16px 18px; +} +@media (max-width: 900px) { + .mw-sandbox-client { grid-template-columns: 1fr; } + .mw-sandbox-list { border-right: none; border-bottom: 1px solid var(--mw-b1); max-height: 260px; } + .mw-sandbox-detail { max-height: none; } + .mw-postfix-grid { grid-template-columns: 1fr; } +} +@media (max-width: 640px) { + .mw-sandbox-client { min-height: 0; } + .mw-postfix-grid { grid-template-columns: 1fr; } } .mw-svc-status-off { color: var(--mw-rd) !important; opacity: 1; } @@ -1743,6 +2014,20 @@ textarea.mw-modal-input { height: auto; line-height: 1.55; padding: 8px 10px; } } /* Buttons */ +.mw-btn-primary { + display: inline-flex; align-items: center; gap: 6px; + padding: 6px 16px; border-radius: 7px; font-size: 13px; font-weight: 500; cursor: pointer; + background: var(--mw-v); border: 1px solid var(--mw-v); color: #fff; + box-shadow: 0 2px 8px rgba(124,58,237,.3); +} +.mw-btn-primary:hover { background: #6d28d9; border-color: #6d28d9; } +.mw-btn-secondary { + display: inline-flex; align-items: center; gap: 6px; + padding: 6px 14px; border-radius: 7px; font-size: 13px; font-weight: 500; cursor: pointer; + background: var(--mw-bg3); border: 1px solid var(--mw-b2); color: var(--mw-t2); + transition: background .15s, border-color .15s; +} +.mw-btn-secondary:hover { background: var(--mw-bg4); border-color: var(--mw-b3); color: var(--mw-t1); } .mw-btn-cancel { display: inline-flex; align-items: center; gap: 5px; padding: 6px 14px; border-radius: 7px; font-size: 13px; cursor: pointer; diff --git a/resources/views/components/partials/header.blade.php b/resources/views/components/partials/header.blade.php index 91db085..8603290 100644 --- a/resources/views/components/partials/header.blade.php +++ b/resources/views/components/partials/header.blade.php @@ -1,107 +1,119 @@ {{--resources/views/components/partials/header.blade.php--}} -
+
diff --git a/resources/views/layouts/dvx.blade.php b/resources/views/layouts/dvx.blade.php index 457aebf..5d5f285 100644 --- a/resources/views/layouts/dvx.blade.php +++ b/resources/views/layouts/dvx.blade.php @@ -160,23 +160,43 @@
-
- - @hasSection('breadcrumb-parent')@yield('breadcrumb-parent')@else{{ $breadcrumbParent ?? 'Dashboard' }}@endif - - @hasSection('breadcrumb')@yield('breadcrumb')@else{{ $breadcrumb ?? 'Übersicht' }}@endif +
+ @hasSection('breadcrumb-parent')@yield('breadcrumb-parent')@else{{ $breadcrumbParent ?? 'Dashboard' }}@endif + + @hasSection('breadcrumb')@yield('breadcrumb')@else{{ $breadcrumb ?? 'Übersicht' }}@endif +
-
-
Live
- -
{{ gethostname() ?: '—' }}
- + + {{-- Domain erstellen --}} + +
+
diff --git a/resources/views/livewire/ui/nx/dashboard.blade.php b/resources/views/livewire/ui/nx/dashboard.blade.php index c99669b..ee73994 100644 --- a/resources/views/livewire/ui/nx/dashboard.blade.php +++ b/resources/views/livewire/ui/nx/dashboard.blade.php @@ -213,8 +213,25 @@
{{ $alertCount }} offen
-
- {{ $alertCount === 0 ? 'Keine Warnungen.' : ($alertCount . ' aktive Warnungen.') }} +
+ @if($sandboxAlerts->isEmpty()) +
Keine Warnungen.
+ @else +
+ @foreach($sandboxAlerts as $sr) +
+ + + @if($sr->type === 'global') Sandbox global aktiv + @elseif($sr->type === 'domain') Sandbox aktiv: {{ $sr->target }} + @else Sandbox aktiv: {{ $sr->target }} + @endif + + verwalten → +
+ @endforeach +
+ @endif
diff --git a/resources/views/livewire/ui/nx/domain/domain-list.blade.php b/resources/views/livewire/ui/nx/domain/domain-list.blade.php index 8c70da2..4d57eac 100644 --- a/resources/views/livewire/ui/nx/domain/domain-list.blade.php +++ b/resources/views/livewire/ui/nx/domain/domain-list.blade.php @@ -138,6 +138,15 @@ $_wmUrl = ($_wmSub && $_wmBase)
+ @if(\App\Models\SandboxRoute::isActiveForDomain($d->domain)) +
+ + + + + +
+ @else
@@ -145,6 +154,7 @@ $_wmUrl = ($_wmSub && $_wmBase)
+ @endif
{{ $d->domain }}
@if(!empty($d->description)) diff --git a/resources/views/livewire/ui/system/api-key-table.blade.php b/resources/views/livewire/ui/system/api-key-table.blade.php index b53d092..6c70ae3 100644 --- a/resources/views/livewire/ui/system/api-key-table.blade.php +++ b/resources/views/livewire/ui/system/api-key-table.blade.php @@ -2,7 +2,8 @@ API Keys
+ x-on:token-created.window="$wire.$refresh()" + x-on:token-deleted.window="$wire.$refresh()">
@@ -23,7 +24,7 @@
-
+
{{-- ═══ Left: Keys + Endpoint-Docs ═══ --}}
@@ -46,62 +47,68 @@
Erstelle deinen ersten Key um externe Anwendungen zu verbinden.
@else -
- - - - - - - - - - - - - @foreach($tokens as $token) - - - - - - - - - @endforeach - -
NameScopesModusZuletzt genutztErstellt
-
-
- - - - -
- {{ $token->name }} -
-
-
- @foreach($token->abilities as $scope) - {{ $scope }} - @endforeach -
-
- @if($token->sandbox) - Sandbox - @else - Live - @endif - {{ $token->last_used_at?->diffForHumans() ?? '—' }}{{ $token->created_at->format('d.m.Y') }} -
- -
-
+ + {{-- Header --}} +
+ Name + Scopes + Modus + Erstellt +
+ + {{-- Rows --}} + @foreach($tokens as $token) + @php + $abilities = $token->abilities; + $visible = array_slice($abilities, 0, 2); + $rest = count($abilities) - 2; + @endphp +
+ {{-- Name --}} +
+
+ + + + +
+ {{ $token->name }} +
+ {{-- Scopes --}} +
+ @foreach($visible as $scope) + @php $short = collect(explode(':', $scope))->map(fn($p) => $p[0])->join(':'); @endphp + {{ $short }} + @endforeach + @if($rest > 0) + + @endif + · {{ $token->created_at->format('d.m.Y') }} +
+ {{-- Modus --}} +
+ @if($token->sandbox) + Sandbox + @else + Live + @endif +
+ {{-- Date (desktop only) --}} +
{{ $token->created_at->format('d.m.Y') }}
+ {{-- Actions --}} +
+ +
+
+ @endforeach + @endif
@@ -293,4 +300,5 @@ Content-Type: application/json
+
diff --git a/resources/views/livewire/ui/system/modal/api-key-create-modal.blade.php b/resources/views/livewire/ui/system/modal/api-key-create-modal.blade.php index 0af684f..3358495 100644 --- a/resources/views/livewire/ui/system/modal/api-key-create-modal.blade.php +++ b/resources/views/livewire/ui/system/modal/api-key-create-modal.blade.php @@ -18,33 +18,37 @@ @error('name')
{{ $message }}
@enderror
-
+
- +
-
+
@foreach($scopes as $key => $label) -
@error('selected')
{{ $message }}
@enderror
-
-
-
@@ -37,7 +37,7 @@
- +
diff --git a/resources/views/livewire/ui/system/sandbox-mailbox.blade.php b/resources/views/livewire/ui/system/sandbox-mailbox.blade.php index 6275144..afe0352 100644 --- a/resources/views/livewire/ui/system/sandbox-mailbox.blade.php +++ b/resources/views/livewire/ui/system/sandbox-mailbox.blade.php @@ -30,11 +30,16 @@
+ {{-- Sandbox-Routing Regeln --}} + + +
+ {{-- Mail-Client Layout --}} -
+
{{-- ═══ Left: Mail-Liste ═══ --}} -
+
@if($mails->isEmpty())
@@ -83,7 +88,7 @@
{{-- ═══ Right: Mail-Detail ═══ --}} -
+
@if(!$selected)
@@ -189,7 +194,7 @@ — Sandbox-Transport aktivieren
-
+
1. master.cf — Transport anlegen
diff --git a/resources/views/livewire/ui/system/sandbox-routes.blade.php b/resources/views/livewire/ui/system/sandbox-routes.blade.php new file mode 100644 index 0000000..9c28243 --- /dev/null +++ b/resources/views/livewire/ui/system/sandbox-routes.blade.php @@ -0,0 +1,113 @@ +
+ + {{-- ── Header ── --}} +
+
+ + Sandbox-Routing + + — Transport-Regeln für Postfix +
+ +
+ + {{-- ── Sync-Message ── --}} + @if($syncMessage) +
+ @if($syncOk) + + @else + + @endif + {{ $syncMessage }} +
+ @endif + + {{-- ── Add-Form ── --}} +
+ + @if($newType !== 'global') + + @endif + +
+ + {{-- ── Route List ── --}} + @if($this->routes->isEmpty()) +
+ Keine Sandbox-Routen konfiguriert. +
+ @else +
+ @foreach($this->routes as $route) +
+ + {{-- Type badge --}} + @if($route->type === 'global') + global + @elseif($route->type === 'domain') + domain + @else + address + @endif + + {{-- Target --}} + + {{ $route->target ?? '* (alle)' }} + + + {{-- Active indicator --}} + @if($route->is_active) + aktiv + @else + inaktiv + @endif + + {{-- Toggle button --}} + + + {{-- Delete button --}} + + +
+ @endforeach +
+ @endif + +
diff --git a/resources/views/livewire/ui/system/webhook-table.blade.php b/resources/views/livewire/ui/system/webhook-table.blade.php index 9aa7c12..a997499 100644 --- a/resources/views/livewire/ui/system/webhook-table.blade.php +++ b/resources/views/livewire/ui/system/webhook-table.blade.php @@ -23,7 +23,7 @@
-
+
{{-- ═══ Left ═══ --}}
@@ -45,83 +45,102 @@
Verbinde externe Systeme — sie werden bei Events automatisch benachrichtigt.
@else -
- - - - - - - - - - - - - @foreach($webhooks as $wh) - - - - - - - - - @endforeach - -
WebhookEventsStatusHTTPZuletzt ausgelöstAktionen
-
-
-
-
{{ $wh->name }}
-
{{ $wh->url }}
-
-
-
-
- @foreach($wh->events as $ev) - {{ $ev }} - @endforeach -
-
- @if($wh->is_active) - Aktiv - @else - Pausiert - @endif - - @if($wh->last_status === null) - - @elseif($wh->last_status >= 200 && $wh->last_status < 300) - {{ $wh->last_status }} - @elseif($wh->last_status === 0) - Timeout - @else - {{ $wh->last_status }} - @endif - - {{ $wh->last_triggered_at?->diffForHumans() ?? '—' }} - -
- - - -
-
+ + {{-- Header --}} +
+ Webhook + Events + Status + HTTP + Aktionen
+ + {{-- Rows --}} + @foreach($webhooks as $wh) + @php + $evVisible = array_slice($wh->events, 0, 2); + $evRest = count($wh->events) - 2; + @endphp +
+ {{-- Webhook name + url --}} +
+
+
+
+ {{ $wh->name }} + + @if($wh->is_active) + Aktiv + @else + Pausiert + @endif + + + @if($wh->last_status !== null) + @if($wh->last_status >= 200 && $wh->last_status < 300) + {{ $wh->last_status }} + @else + {{ $wh->last_status === 0 ? 'T/O' : $wh->last_status }} + @endif + @endif + +
+
{{ $wh->url }}
+
+
+ {{-- Events --}} +
+ @foreach($evVisible as $ev) + {{ $ev }} + @endforeach + @if($evRest > 0) + +{{ $evRest }} + @endif +
+ {{-- Status --}} +
+ @if($wh->is_active) + Aktiv + @else + Pausiert + @endif +
+ {{-- HTTP --}} +
+ @if($wh->last_status === null) + + @elseif($wh->last_status >= 200 && $wh->last_status < 300) + {{ $wh->last_status }} + @elseif($wh->last_status === 0) + Timeout + @else + {{ $wh->last_status }} + @endif +
+ {{-- Actions --}} +
+
+ + + +
+
+
+ @endforeach + @endif
diff --git a/resources/views/vendor/wire-elements-modal/modal.blade.php b/resources/views/vendor/wire-elements-modal/modal.blade.php index 2c44f3f..544f5bd 100644 --- a/resources/views/vendor/wire-elements-modal/modal.blade.php +++ b/resources/views/vendor/wire-elements-modal/modal.blade.php @@ -28,14 +28,31 @@ -