Feature: API-Key/Webhook responsive div-grid, Sandbox-Icon in Domains, Search-fix

- API-Key-Tabelle: unified CSS-Grid div-layout (kein separates mobile/desktop HTML mehr),
  Scopes auf max. 2 Badges + +N Modal, Lösch-Bestätigung via Livewire-Modal
- Webhook-Tabelle: selbes div-grid Pattern, Status/HTTP inline auf Mobile
- Globale Suche: go()-Methode fixed (forceClose + setTimeout 350ms gegen resetState-Race)
- Domains: Sandbox-Icon ersetzt Globus durch gelbes Warndreieck wenn Sandbox aktiv
- Sandbox: SandboxRoute-Model, SandboxService, Migration, Routen-Verwaltung
- CSS: mw-kl-*/mw-whl-* Grid-Klassen, minmax(0,1fr) Fix für Text-Truncation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main v1.1.161
boban 2026-04-23 19:48:06 +02:00
parent fc8dbf894a
commit 38d22c85ed
25 changed files with 1310 additions and 311 deletions

102
CLAUDE.md Normal file
View File

@ -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.

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,42 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Models\PersonalAccessToken;
use Illuminate\Support\Facades\Auth;
use LivewireUI\Modal\ModalComponent;
class ApiKeyDeleteModal extends ModalComponent
{
public int $tokenId;
public string $tokenName = '';
public function mount(int $tokenId): void
{
$token = PersonalAccessToken::where('tokenable_id', Auth::id())
->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 <b>{$this->tokenName}</b> wurde entfernt.", duration: 4000);
$this->dispatch('token-deleted');
$this->closeModal();
}
public function render()
{
return view('livewire.ui.system.modal.api-key-delete-modal');
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Models\PersonalAccessToken;
use Illuminate\Support\Facades\Auth;
use LivewireUI\Modal\ModalComponent;
class ApiKeyScopesModal extends ModalComponent
{
public string $tokenName = '';
public array $scopes = [];
public function mount(int $tokenId): void
{
$token = PersonalAccessToken::where('tokenable_id', Auth::id())
->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');
}
}

View File

@ -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()

View File

@ -0,0 +1,68 @@
<?php
namespace App\Livewire\Ui\System;
use App\Models\SandboxRoute;
use App\Services\SandboxService;
use Livewire\Attributes\Computed;
use Livewire\Component;
class SandboxRoutes extends Component
{
public string $newType = 'domain';
public string $newTarget = '';
public ?string $syncMessage = null;
public bool $syncOk = false;
public function boot(SandboxService $sandboxService): void
{
$this->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');
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Collection;
class SandboxRoute extends Model
{
protected $fillable = ['type', 'target', 'is_active'];
protected $casts = [
'is_active' => '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();
}
}

View File

@ -0,0 +1,88 @@
<?php
namespace App\Services;
use App\Models\SandboxRoute;
class SandboxService
{
/**
* Enable (or create) a sandbox route.
*/
public function enable(string $type, ?string $target): SandboxRoute
{
return SandboxRoute::updateOrCreate(
['type' => $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).'];
}
}

View File

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('sandbox_routes', function (Blueprint $table) {
$table->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');
}
};

View File

@ -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;

View File

@ -1,107 +1,119 @@
{{--resources/views/components/partials/header.blade.php--}}
<div
class="#sticky top-0 w-full border-b hr #rounded-lg max-w-9xl mx-auto">
<div class="w-full border-b hr">
<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="relative flex items-center gap-5">
<button class="sidebar-toggle translate-0 right-5 block s#m:hidden text-white/60 hover:text-white text-2xl">
<i class="ph ph-list"></i>
</button>
<div class="flex items-center gap-3">
<h1 class="font-bold text-2xl">@yield('header_title')</h1>
</div>
</div>
<nav class="flex h-[71px] items-center justify-between gap-3 px-4">
{{-- Links: Toggle + Seitentitel --}}
<div class="flex min-w-0 flex-1 items-center gap-3">
<button class="sidebar-toggle shrink-0 text-white/60 hover:text-white text-2xl">
<i class="ph ph-list"></i>
</button>
<h1 class="truncate font-bold text-xl sm:text-2xl">@yield('header_title')</h1>
</div>
<div class="flex items-center justify-end gap-2 w-full max-w-96">
{{-- Rechts: Suche + Aktionen --}}
<div class="flex shrink-0 items-center gap-1.5 sm:gap-2">
{{-- Suchbutton --}}
<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"
onclick="Livewire.dispatch('openModal',{component:'ui.search.modal.search-palette-modal'})"
class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-2.5 py-1.5 text-white/75 hover:text-white hover:border-white/20 sm:px-3"
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>
<i class="ph ph-magnifying-glass text-base"></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 -->
class="hidden sm:inline-block rounded-md bg-white/5 border border-white/10 px-1.5 py-0.5 text-[11px] leading-none">
</span>
</button>
<button onclick="Livewire.dispatch('openModal',{component:'ui.domain.modal.domain-create-modal'})">
{{-- Domain erstellen --}}
<button
type="button"
onclick="Livewire.dispatch('openModal',{component:'ui.domain.modal.domain-create-modal'})"
class="btn-ghost text-lg !p-2"
title="Domain erstellen"
>
<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">
@if(!$section['image'])
<div x-data="{ openMessages: false }" class="relative flex items-center">
<button @click="openMessages = !openMessages" class="btn-ghost text-base !p-2"><i class="ph-duotone ph-bell #mr-1"></i></button>
<div
x-show="openMessages"
@click.away="openMessages = false"
x-cloak
class="popup absolute top-16 right-0 w-48"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95"
>
<div class="flex items-center justify-center h-40">
Keine Nachrichten
</div>
</div>
@if(!$section['image'])
{{-- Benachrichtigungen --}}
<div x-data="{ openMessages: false }" class="relative flex items-center">
<button
@click="openMessages = !openMessages"
class="btn-ghost text-base !p-2"
title="Benachrichtigungen"
>
<i class="ph-duotone ph-bell"></i>
</button>
<div
x-show="openMessages"
@click.away="openMessages = false"
x-cloak
class="popup absolute top-[calc(100%+0.5rem)] right-0 w-56"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
>
<div class="flex items-center justify-center h-40 text-sm text-white/50">
Keine Nachrichten
</div>
@else
<div x-data="{ openMenu: false }" class="relative flex items-center">
<button @click="openMenu = !openMenu"
class="flex items-center focus:outline-none">
<img class="size-8 rounded-full object-cover shadow-2xl"
src="https://i.pravatar.cc/100"
alt="Avatar"/>
<svg
class="size-4 ml-2 text-gray-500 transform transition-transform duration-200"
:class="{'rotate-180': open}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div
x-show="openMenu"
@click.away="openMenu = false"
x-cloak
class="popup absolute top-16 right-0 w-48"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95"
>
@foreach($section['sub'] as $sub)
<a href="{{ route($sub['route']) }}"
class="block px-4 py-2 text-xs #text-gray-700 #hover:bg-gray-100 #hover:text-black #hover:bg-gradient-to-r #hover:from-[rgba(var(--accent))] #hover:to-transparent">
<div class="flex items-center gap-3">
<span>Logo</span>
{{-- <span><x-dynamic-component :component="$sub['icon']"--}}
{{-- class="!size-4"/></span>--}}
<span>{{ $sub['title'] }}</span>
</div>
</a>
@endforeach
</div>
</div>
@endif
</div>
</div>
</div>
@else
{{-- Avatar-Menü --}}
<div x-data="{ openMenu: false }" class="relative flex items-center">
<button
@click="openMenu = !openMenu"
class="flex items-center gap-1.5 focus:outline-none"
>
<img
class="size-8 rounded-full object-cover shadow-2xl"
src="https://i.pravatar.cc/100"
alt="Avatar"
/>
<svg
class="size-4 text-white/40 transition-transform duration-200"
:class="{ 'rotate-180': openMenu }"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div
x-show="openMenu"
@click.away="openMenu = false"
x-cloak
class="popup absolute top-[calc(100%+0.5rem)] right-0 w-48"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
>
@foreach($section['sub'] as $sub)
<a href="{{ route($sub['route']) }}" class="block px-4 py-2 text-xs hover:bg-white/5">
<div class="flex items-center gap-3">
<i class="ph {{ $sub['icon'] ?? 'ph-circle' }} text-sm text-white/50"></i>
<span>{{ $sub['title'] }}</span>
</div>
</a>
@endforeach
</div>
</div>
@endif
@endforeach
</div>
</nav>
</header>

View File

@ -160,23 +160,43 @@
<div class="mw-main">
<header class="mw-topbar">
<div class="mw-breadcrumb">
<button class="mw-hamburger" style="display:none" onclick="document.getElementById('mw-sidebar').classList.add('open');document.getElementById('mw-overlay').classList.add('open');">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none"><path d="M2 4h14M2 9h14M2 14h14" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
{{-- Links: Hamburger + Titel --}}
<div class="mw-tb-left">
<button class="mw-hamburger" onclick="document.getElementById('mw-sidebar').classList.add('open');document.getElementById('mw-overlay').classList.add('open');" aria-label="Menü">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M2 4h12M2 8h12M2 12h12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</button>
@hasSection('breadcrumb-parent')@yield('breadcrumb-parent')@else{{ $breadcrumbParent ?? 'Dashboard' }}@endif
<svg width="11" height="11" viewBox="0 0 11 11" fill="none"><path d="M3.5 2l4 3.5-4 3.5" stroke="var(--mw-b3)" stroke-width="1.3" stroke-linecap="round"/></svg>
<b>@hasSection('breadcrumb')@yield('breadcrumb')@else{{ $breadcrumb ?? 'Übersicht' }}@endif</b>
<div class="mw-tb-title">
<span class="mw-tb-parent">@hasSection('breadcrumb-parent')@yield('breadcrumb-parent')@else{{ $breadcrumbParent ?? 'Dashboard' }}@endif</span>
<svg class="mw-tb-sep" width="10" height="10" viewBox="0 0 10 10" fill="none"><path d="M3 2l3.5 3L3 8" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
<span class="mw-tb-current">@hasSection('breadcrumb')@yield('breadcrumb')@else{{ $breadcrumb ?? 'Übersicht' }}@endif</span>
</div>
</div>
<div class="mw-topbar-right">
<div class="mw-live"><div class="mw-live-dot"></div>Live</div>
<button wire:click="$dispatch('openModal',{component:'ui.search.modal.search-palette-modal'})" class="mw-search">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><circle cx="5" cy="5" r="3.8" stroke="currentColor" stroke-width="1.2"/><path d="M8 8l2.5 2.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
Suche <kbd>⌘K</kbd>
{{-- Rechts: Actions --}}
<div class="mw-tb-right">
{{-- Live-Status --}}
<div class="mw-tb-live">
<span class="mw-tb-live-dot"></span>
<span class="mw-tb-live-label">Live</span>
</div>
{{-- Suche --}}
<button class="mw-tb-search" onclick="Livewire.dispatch('openModal',{component:'ui.search.modal.search-palette-modal'})">
<svg width="13" height="13" viewBox="0 0 12 12" fill="none"><circle cx="5" cy="5" r="3.5" stroke="currentColor" stroke-width="1.3"/><path d="M7.8 7.8l2.2 2.2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
<span class="mw-tb-search-text">Suche</span>
<kbd class="mw-tb-kbd">⌘K</kbd>
</button>
<div class="mw-ip">{{ gethostname() ?: '—' }}</div>
<button class="mw-btn-primary" onclick="Livewire.dispatch('openModal',{component:'ui.domain.modal.domain-create-modal'})">+ Domain</button>
{{-- Domain erstellen --}}
<button class="mw-tb-add" onclick="Livewire.dispatch('openModal',{component:'ui.domain.modal.domain-create-modal'})">
<svg width="11" height="11" viewBox="0 0 11 11" fill="none"><path d="M5.5 1v9M1 5.5h9" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
<span class="mw-tb-add-text">Domain</span>
</button>
</div>
</header>
<div class="mw-content">

View File

@ -213,8 +213,25 @@
</div>
<span class="mw-sc-badge mw-badge-mute">{{ $alertCount }} offen</span>
</div>
<div class="mw-sc-body" style="font-style:italic;color:var(--mw-t4);">
{{ $alertCount === 0 ? 'Keine Warnungen.' : ($alertCount . ' aktive Warnungen.') }}
<div class="mw-sc-body">
@if($sandboxAlerts->isEmpty())
<div style="font-style:italic;color:var(--mw-t4);font-size:12px;">Keine Warnungen.</div>
@else
<div style="display:flex;flex-direction:column;gap:5px;">
@foreach($sandboxAlerts as $sr)
<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;background:rgba(234,179,8,.07);border:1px solid rgba(234,179,8,.2);border-radius:6px;">
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" style="flex-shrink:0;color:#fbbf24"><path d="M5.5 1L10.5 10H.5L5.5 1Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/><path d="M5.5 4.5v2.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
<span style="font-size:11.5px;color:#fbbf24;font-weight:500;">
@if($sr->type === 'global') Sandbox global aktiv
@elseif($sr->type === 'domain') Sandbox aktiv: {{ $sr->target }}
@else Sandbox aktiv: {{ $sr->target }}
@endif
</span>
<a href="{{ route('ui.system.sandbox') }}" style="margin-left:auto;font-size:10.5px;color:rgba(251,191,36,.6);text-decoration:none;white-space:nowrap;">verwalten </a>
</div>
@endforeach
</div>
@endif
</div>
</div>

View File

@ -138,6 +138,15 @@ $_wmUrl = ($_wmSub && $_wmBase)
<td class="mbx-td">
<div style="display:flex;align-items:center;gap:8px;min-width:0">
@if(\App\Models\SandboxRoute::isActiveForDomain($d->domain))
<div class="dom-icon" style="color:#fbbf24;background:rgba(234,179,8,.1);border-color:rgba(234,179,8,.3);flex-shrink:0">
<svg width="12" height="12" viewBox="0 0 14 14" fill="none">
<path d="M7 2L13 12H1L7 2Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
<path d="M7 6v3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
<circle cx="7" cy="10.5" r=".6" fill="currentColor"/>
</svg>
</div>
@else
<div class="dom-icon">
<svg width="12" height="12" viewBox="0 0 14 14" fill="none">
<circle cx="7" cy="7" r="5.5" stroke="currentColor" stroke-width="1.2"/>
@ -145,6 +154,7 @@ $_wmUrl = ($_wmSub && $_wmBase)
<path d="M1.5 5.5h11M1.5 8.5h11" stroke="currentColor" stroke-width="1.2"/>
</svg>
</div>
@endif
<div style="min-width:0">
<div class="dom-name">{{ $d->domain }}</div>
@if(!empty($d->description))

View File

@ -2,7 +2,8 @@
<x-slot:breadcrumb>API Keys</x-slot:breadcrumb>
<div x-data="{activeGroup: 'mailboxes'}"
x-on:token-created.window="$dispatch('openModal', {component: 'ui.system.modal.api-key-show-modal', arguments: {plainText: $event.detail.plainText}})">
x-on:token-created.window="$wire.$refresh()"
x-on:token-deleted.window="$wire.$refresh()">
<div class="mbx-page-header">
<div class="mbx-page-title">
@ -23,7 +24,7 @@
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 340px;gap:14px;align-items:start">
<div class="mw-apikey-layout">
{{-- ═══ Left: Keys + Endpoint-Docs ═══ --}}
<div class="mbx-sections">
@ -46,62 +47,68 @@
<div style="font-size:11.5px;color:var(--mw-t4)">Erstelle deinen ersten Key um externe Anwendungen zu verbinden.</div>
</div>
@else
<div class="mbx-table-wrap">
<table class="mbx-table">
<thead>
<tr>
<th class="mbx-th">Name</th>
<th class="mbx-th">Scopes</th>
<th class="mbx-th" style="width:86px">Modus</th>
<th class="mbx-th" style="width:120px">Zuletzt genutzt</th>
<th class="mbx-th" style="width:110px">Erstellt</th>
<th class="mbx-th mbx-th-right" style="width:50px"></th>
</tr>
</thead>
<tbody>
@foreach($tokens as $token)
<tr class="mbx-tr">
<td class="mbx-td">
<div style="display:flex;align-items:center;gap:8px">
<div style="width:26px;height:26px;border-radius:7px;background:var(--mw-bg4);border:1px solid var(--mw-b2);display:flex;align-items:center;justify-content:center;flex-shrink:0">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none">
<path d="M6 10a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z" stroke="var(--mw-t3)" stroke-width="1.3"/>
<path d="M9.5 8.5L14 13" stroke="var(--mw-t3)" stroke-width="1.3" stroke-linecap="round"/>
</svg>
</div>
<span style="font-size:12.5px;font-weight:500;color:var(--mw-t1)">{{ $token->name }}</span>
</div>
</td>
<td class="mbx-td">
<div style="display:flex;flex-wrap:wrap;gap:3px">
@foreach($token->abilities as $scope)
<span style="font-family:monospace;font-size:9.5px;padding:1px 5px;background:var(--mw-bg4);border:1px solid var(--mw-b2);border-radius:4px;color:var(--mw-t4)">{{ $scope }}</span>
@endforeach
</div>
</td>
<td class="mbx-td">
@if($token->sandbox)
<span style="font-size:10px;padding:2px 7px;border-radius:5px;background:rgba(251,191,36,.1);border:1px solid rgba(251,191,36,.25);color:#f59e0b;font-weight:500">Sandbox</span>
@else
<span style="font-size:10px;padding:2px 7px;border-radius:5px;background:rgba(16,185,129,.08);border:1px solid rgba(16,185,129,.2);color:#34d399;font-weight:500">Live</span>
@endif
</td>
<td class="mbx-td mbx-td-muted" style="font-size:11px">{{ $token->last_used_at?->diffForHumans() ?? '—' }}</td>
<td class="mbx-td mbx-td-muted" style="font-size:11px">{{ $token->created_at->format('d.m.Y') }}</td>
<td class="mbx-td">
<div class="mbx-actions">
<button wire:click="deleteToken({{ $token->id }})"
wire:confirm="API Key '{{ $token->name }}' wirklich löschen?"
class="mbx-act-btn mbx-act-danger" title="Löschen">
<svg width="12" height="12" viewBox="0 0 13 13" fill="none"><path d="M2 3h9M5 3V2h3v1M3.5 3l.5 8h5l.5-8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
{{-- Header --}}
<div class="mw-kl-head">
<span>Name</span>
<span>Scopes</span>
<span>Modus</span>
<span>Erstellt</span>
<span></span>
</div>
{{-- Rows --}}
@foreach($tokens as $token)
@php
$abilities = $token->abilities;
$visible = array_slice($abilities, 0, 2);
$rest = count($abilities) - 2;
@endphp
<div class="mw-kl-row">
{{-- Name --}}
<div class="mw-kl-name" style="display:flex;align-items:center;gap:8px;overflow:hidden">
<div style="width:26px;height:26px;border-radius:7px;background:var(--mw-bg4);border:1px solid var(--mw-b2);display:flex;align-items:center;justify-content:center;flex-shrink:0">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none">
<path d="M6 10a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z" stroke="var(--mw-t3)" stroke-width="1.3"/>
<path d="M9.5 8.5L14 13" stroke="var(--mw-t3)" stroke-width="1.3" stroke-linecap="round"/>
</svg>
</div>
<span style="font-size:12.5px;font-weight:500;color:var(--mw-t1);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1">{{ $token->name }}</span>
</div>
{{-- Scopes --}}
<div class="mw-kl-scopes" style="display:flex;flex-wrap:nowrap;gap:3px;align-items:center;overflow:hidden">
@foreach($visible as $scope)
@php $short = collect(explode(':', $scope))->map(fn($p) => $p[0])->join(':'); @endphp
<span title="{{ $scope }}" style="font-family:monospace;font-size:9.5px;padding:1px 5px;background:var(--mw-bg4);border:1px solid var(--mw-b2);border-radius:4px;color:var(--mw-t4)">{{ $short }}</span>
@endforeach
@if($rest > 0)
<button wire:click="$dispatch('openModal',{component:'ui.system.modal.api-key-scopes-modal',arguments:{tokenId:{{ $token->id }}}})"
style="font-family:monospace;font-size:9.5px;padding:1px 6px;background:var(--mw-bg4);border:1px solid var(--mw-b2);border-radius:4px;color:var(--mw-v2);cursor:pointer;white-space:nowrap;flex-shrink:0">
+{{ $rest }}
</button>
@endif
<span class="mw-kl-date-inline" style="font-size:10px;color:var(--mw-t5);margin-left:2px">· {{ $token->created_at->format('d.m.Y') }}</span>
</div>
{{-- Modus --}}
<div class="mw-kl-modus">
@if($token->sandbox)
<span style="font-size:10px;padding:2px 7px;border-radius:5px;background:rgba(251,191,36,.1);border:1px solid rgba(251,191,36,.25);color:#f59e0b;font-weight:500;white-space:nowrap">Sandbox</span>
@else
<span style="font-size:10px;padding:2px 7px;border-radius:5px;background:rgba(16,185,129,.08);border:1px solid rgba(16,185,129,.2);color:#34d399;font-weight:500;white-space:nowrap">Live</span>
@endif
</div>
{{-- Date (desktop only) --}}
<div class="mw-kl-date" style="font-size:11px;color:var(--mw-t4)">{{ $token->created_at->format('d.m.Y') }}</div>
{{-- Actions --}}
<div class="mw-kl-actions">
<button wire:click="$dispatch('openModal',{component:'ui.system.modal.api-key-delete-modal',arguments:{tokenId:{{ $token->id }}}})"
class="mbx-act-btn mbx-act-danger" title="Löschen">
<svg width="12" height="12" viewBox="0 0 13 13" fill="none"><path d="M2 3h9M5 3V2h3v1M3.5 3l.5 8h5l.5-8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
</div>
@endforeach
@endif
</div>
@ -293,4 +300,5 @@ Content-Type: application/json</pre>
</div>
</div>

View File

@ -18,33 +18,37 @@
@error('name') <div class="mw-modal-error">{{ $message }}</div> @enderror
</div>
<div style="margin-top:16px">
<div style="margin-top:14px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
<label class="mw-modal-label" style="margin:0">Berechtigungen (Scopes)</label>
<label class="mw-modal-label" style="margin:0">Berechtigungen</label>
<button type="button" wire:click="toggleAll"
style="background:none;border:none;font-size:11px;color:var(--mw-v);cursor:pointer;padding:0">
{{ count($selected) === count($scopes) ? 'Alle abwählen' : 'Alle wählen' }}
</button>
</div>
<div style="display:flex;flex-direction:column;gap:6px;padding:12px;background:var(--mw-bg4);border:1px solid var(--mw-b2);border-radius:8px">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;padding:12px;background:var(--mw-bg4);border:1px solid var(--mw-b2);border-radius:8px">
@foreach($scopes as $key => $label)
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<label style="display:flex;align-items:center;gap:7px;cursor:pointer;padding:5px 8px;border-radius:5px;transition:background .1s"
onmouseover="this.style.background='var(--mw-bg3)'"
onmouseout="this.style.background='transparent'">
<input type="checkbox" wire:model="selected" value="{{ $key }}"
style="width:13px;height:13px;accent-color:var(--mw-v)">
<span style="font-size:12px;color:var(--mw-t2);font-family:monospace">{{ $key }}</span>
<span style="font-size:11px;color:var(--mw-t4)"> {{ $label }}</span>
style="width:13px;height:13px;flex-shrink:0;accent-color:var(--mw-v)">
<div>
<div style="font-size:11px;color:var(--mw-t2);font-family:monospace;line-height:1.3">{{ $key }}</div>
<div style="font-size:10px;color:var(--mw-t4);line-height:1.3">{{ $label }}</div>
</div>
</label>
@endforeach
</div>
@error('selected') <div class="mw-modal-error">{{ $message }}</div> @enderror
</div>
<div style="margin-top:14px;padding:12px;background:rgba(251,191,36,.06);border:1px solid rgba(251,191,36,.2);border-radius:8px">
<label style="display:flex;align-items:flex-start;gap:8px;cursor:pointer">
<input type="checkbox" wire:model="sandbox" style="width:13px;height:13px;margin-top:1px;accent-color:#f59e0b">
<div style="margin-top:12px">
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;padding:10px 12px;background:rgba(251,191,36,.05);border:1px solid rgba(251,191,36,.18);border-radius:8px">
<input type="checkbox" wire:model="sandbox" style="width:13px;height:13px;flex-shrink:0;accent-color:#f59e0b">
<div>
<div style="font-size:12.5px;color:var(--mw-t1);font-weight:500">Sandbox-Modus</div>
<div style="font-size:11px;color:var(--mw-t4);margin-top:2px">Schreiboperationen werden simuliert keine Änderungen in der Datenbank. Ideal für Tests und Entwicklung.</div>
<div style="font-size:12px;color:var(--mw-t1);font-weight:500">Sandbox-Modus</div>
<div style="font-size:10.5px;color:var(--mw-t4);margin-top:1px">Schreiboperationen werden simuliert keine Änderungen in der DB.</div>
</div>
</label>
</div>

View File

@ -0,0 +1,30 @@
<div class="mw-modal-frame">
<div class="mw-modal-head">
<div style="display:flex;align-items:center;gap:7px">
<span class="mbx-badge-mute">API Key</span>
<span style="font-size:12px;color:var(--mw-t3)"> Löschen</span>
</div>
<button wire:click="$dispatch('closeModal')" class="mw-modal-close">
<svg width="13" height="13" viewBox="0 0 13 13" fill="none"><path d="M1 1l11 11M12 1L1 12" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
</button>
</div>
<div class="mw-modal-body">
<div style="padding:12px 14px;border-radius:8px;border:1px solid rgba(239,68,68,.25);background:rgba(239,68,68,.06)">
<div style="font-size:12.5px;font-weight:500;color:var(--mw-t1);font-family:monospace">{{ $tokenName }}</div>
<div style="font-size:11px;color:rgba(252,165,165,.8);margin-top:4px">
Dieser API Key wird dauerhaft gelöscht. Anwendungen die ihn nutzen verlieren sofort den Zugriff.
</div>
</div>
</div>
<div class="mw-modal-foot">
<button wire:click="$dispatch('closeModal')" class="mw-btn-cancel">Abbrechen</button>
<button wire:click="delete" class="mw-btn-del">
<svg width="12" height="12" viewBox="0 0 13 13" fill="none"><path d="M2 3h9M5 3V2h3v1M3.5 3l.5 8h5l.5-8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
Löschen
</button>
</div>
</div>

View File

@ -0,0 +1,29 @@
<div class="mw-modal-frame">
<div class="mw-modal-head">
<div style="display:flex;align-items:center;gap:7px">
<span class="mbx-badge-mute">{{ $tokenName }}</span>
<span style="font-size:12px;color:var(--mw-t3)"> Scopes</span>
</div>
<button wire:click="$dispatch('closeModal')" class="mw-modal-close">
<svg width="13" height="13" viewBox="0 0 13 13" fill="none"><path d="M1 1l11 11M12 1L1 12" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
</button>
</div>
<div class="mw-modal-body">
<div style="display:flex;flex-direction:column;gap:5px">
@foreach($scopes as $scope)
@php $short = collect(explode(':', $scope))->map(fn($p) => $p[0])->join(':'); @endphp
<div style="display:flex;align-items:center;gap:10px;padding:8px 10px;background:var(--mw-bg4);border:1px solid var(--mw-b2);border-radius:7px">
<code style="font-family:monospace;font-size:11px;padding:1px 7px;background:var(--mw-bg3);border:1px solid var(--mw-b2);border-radius:4px;color:var(--mw-t4);flex-shrink:0">{{ $short }}</code>
<span style="font-size:11.5px;color:var(--mw-t3)">{{ $scope }}</span>
</div>
@endforeach
</div>
</div>
<div class="mw-modal-foot">
<button wire:click="$dispatch('closeModal')" class="mw-btn-cancel">Schließen</button>
</div>
</div>

View File

@ -5,7 +5,7 @@
<span class="mbx-badge-ok">Key erstellt</span>
<span style="font-size:12px;color:var(--mw-t3)"> einmalig anzeigen</span>
</div>
<button wire:click="$dispatch('closeModal')" class="mw-modal-close">
<button wire:click="dismiss" class="mw-modal-close">
<svg width="13" height="13" viewBox="0 0 13 13" fill="none"><path d="M1 1l11 11M12 1L1 12" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
</button>
</div>
@ -37,7 +37,7 @@
</div>
<div class="mw-modal-foot">
<button wire:click="$dispatch('closeModal')" class="mbx-btn-primary">Fertig</button>
<button wire:click="dismiss" class="mw-btn-primary">Fertig</button>
</div>
</div>

View File

@ -30,11 +30,16 @@
</div>
</div>
{{-- Sandbox-Routing Regeln --}}
<livewire:ui.system.sandbox-routes />
<div style="height:1px;background:var(--mw-b1);margin-bottom:14px"></div>
{{-- Mail-Client Layout --}}
<div style="display:grid;grid-template-columns:320px 1fr;gap:0;border:1px solid var(--mw-b1);border-radius:10px;overflow:hidden;background:var(--mw-bg2);min-height:520px">
<div class="mw-sandbox-client">
{{-- ═══ Left: Mail-Liste ═══ --}}
<div style="border-right:1px solid var(--mw-b1);overflow-y:auto;max-height:680px">
<div class="mw-sandbox-list">
@if($mails->isEmpty())
<div style="padding:48px 16px;text-align:center">
@ -83,7 +88,7 @@
</div>
{{-- ═══ Right: Mail-Detail ═══ --}}
<div style="overflow-y:auto;max-height:680px">
<div class="mw-sandbox-detail">
@if(!$selected)
<div style="display:flex;align-items:center;justify-content:center;height:100%;min-height:400px;flex-direction:column;gap:12px">
@ -189,7 +194,7 @@
<span style="font-size:11px;color:var(--mw-t4);margin-left:6px"> Sandbox-Transport aktivieren</span>
</div>
</div>
<div style="padding:16px 18px;display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px">
<div class="mw-postfix-grid">
<div>
<div style="font-size:11px;color:var(--mw-t4);margin-bottom:6px;font-weight:500">1. master.cf Transport anlegen</div>

View File

@ -0,0 +1,113 @@
<div style="margin-bottom:14px">
{{-- ── Header ── --}}
<div class="mbx-domain-head" style="margin-bottom:10px">
<div class="mbx-domain-info">
<span style="font-size:10.5px;padding:2px 8px;border-radius:4px;background:rgba(234,179,8,.12);border:1px solid rgba(234,179,8,.3);color:#fbbf24;font-weight:600;">
Sandbox-Routing
</span>
<span style="font-size:11px;color:var(--mw-t4);margin-left:6px"> Transport-Regeln für Postfix</span>
</div>
<button wire:click="syncPostfix" class="mw-btn-secondary"
style="display:flex;align-items:center;gap:6px;font-size:12px;padding:5px 12px;white-space:nowrap">
<svg width="12" height="12" viewBox="0 0 14 14" fill="none">
<path d="M12.5 7A5.5 5.5 0 1 1 7 1.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
<path d="M7 .5l2.5 1.5L7 3.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Postfix-Sync
</button>
</div>
{{-- ── Sync-Message ── --}}
@if($syncMessage)
<div style="margin-bottom:10px;padding:7px 12px;border-radius:6px;font-size:11.5px;display:flex;align-items:center;gap:7px;
{{ $syncOk
? 'background:rgba(34,197,94,.07);border:1px solid rgba(34,197,94,.25);color:var(--mw-gr)'
: 'background:rgba(239,68,68,.07);border:1px solid rgba(239,68,68,.25);color:#f87171' }}">
@if($syncOk)
<svg width="11" height="11" viewBox="0 0 11 11" fill="none"><path d="M1.5 5.5l3 3 5-5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
@else
<svg width="11" height="11" viewBox="0 0 11 11" fill="none"><path d="M1.5 1.5l8 8M9.5 1.5l-8 8" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
@endif
{{ $syncMessage }}
</div>
@endif
{{-- ── Add-Form ── --}}
<div style="display:flex;gap:8px;align-items:center;margin-bottom:12px;flex-wrap:wrap">
<select wire:model.live="newType"
style="font-size:12px;padding:5px 8px;border-radius:6px;background:var(--mw-bg3);border:1px solid var(--mw-b2);color:var(--mw-t2);cursor:pointer;outline:none;height:30px;flex-shrink:0">
<option value="global">global</option>
<option value="domain">domain</option>
<option value="address">address</option>
</select>
@if($newType !== 'global')
<input wire:model="newTarget" type="text"
placeholder="{{ $newType === 'domain' ? 'example.com' : 'user@example.com' }}"
style="font-size:12px;padding:5px 10px;border-radius:6px;background:var(--mw-bg3);border:1px solid var(--mw-b2);color:var(--mw-t1);outline:none;width:220px;height:30px;flex-shrink:0"
wire:keydown.enter="addRoute">
@endif
<button wire:click="addRoute" class="mw-btn-primary" style="font-size:12px;padding:5px 14px;height:30px;white-space:nowrap;flex-shrink:0">
+ Hinzufügen
</button>
</div>
{{-- ── Route List ── --}}
@if($this->routes->isEmpty())
<div style="padding:20px 16px;text-align:center;color:var(--mw-t4);font-size:12px;background:var(--mw-bg3);border:1px solid var(--mw-b1);border-radius:8px;">
Keine Sandbox-Routen konfiguriert.
</div>
@else
<div style="border:1px solid var(--mw-b1);border-radius:8px;overflow:hidden">
@foreach($this->routes as $route)
<div style="display:flex;align-items:center;gap:8px;padding:8px 12px;
{{ !$loop->last ? 'border-bottom:1px solid var(--mw-b1);' : '' }}
background:{{ $route->is_active ? 'var(--mw-bg3)' : 'var(--mw-bg4)' }}">
{{-- Type badge --}}
@if($route->type === 'global')
<span style="font-size:9.5px;padding:1px 6px;border-radius:4px;background:rgba(239,68,68,.12);border:1px solid rgba(239,68,68,.25);color:#f87171;font-weight:600;flex-shrink:0">global</span>
@elseif($route->type === 'domain')
<span style="font-size:9.5px;padding:1px 6px;border-radius:4px;background:rgba(124,58,237,.12);border:1px solid rgba(124,58,237,.25);color:var(--mw-v2);font-weight:600;flex-shrink:0">domain</span>
@else
<span style="font-size:9.5px;padding:1px 6px;border-radius:4px;background:rgba(59,130,246,.12);border:1px solid rgba(59,130,246,.25);color:#60a5fa;font-weight:600;flex-shrink:0">address</span>
@endif
{{-- Target --}}
<span style="font-size:12px;color:{{ $route->is_active ? 'var(--mw-t1)' : 'var(--mw-t4)' }};font-family:monospace;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
{{ $route->target ?? '* (alle)' }}
</span>
{{-- Active indicator --}}
@if($route->is_active)
<span style="font-size:9.5px;padding:1px 6px;border-radius:4px;background:rgba(34,197,94,.1);border:1px solid rgba(34,197,94,.2);color:var(--mw-gr);font-weight:600;flex-shrink:0">aktiv</span>
@else
<span style="font-size:9.5px;padding:1px 6px;border-radius:4px;background:var(--mw-bg4);border:1px solid var(--mw-b2);color:var(--mw-t4);font-weight:600;flex-shrink:0">inaktiv</span>
@endif
{{-- Toggle button --}}
<button wire:click="toggleRoute({{ $route->id }})" class="mbx-act-btn"
title="{{ $route->is_active ? 'Deaktivieren' : 'Aktivieren' }}"
style="flex-shrink:0">
@if($route->is_active)
<svg width="12" height="12" viewBox="0 0 14 14" fill="none"><rect x="1" y="4" width="12" height="6" rx="3" stroke="currentColor" stroke-width="1.2"/><circle cx="10" cy="7" r="2" fill="currentColor"/></svg>
@else
<svg width="12" height="12" viewBox="0 0 14 14" fill="none"><rect x="1" y="4" width="12" height="6" rx="3" stroke="currentColor" stroke-width="1.2"/><circle cx="4" cy="7" r="2" fill="currentColor" opacity=".4"/></svg>
@endif
</button>
{{-- Delete button --}}
<button wire:click="removeRoute({{ $route->id }})"
wire:confirm="Route '{{ $route->target ?? 'global' }}' wirklich löschen?"
class="mbx-act-btn mbx-act-danger" title="Löschen" style="flex-shrink:0">
<svg width="12" height="12" viewBox="0 0 13 13" fill="none">
<path d="M2 3h9M5 3V2h3v1M3.5 3l.5 8h5l.5-8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
@endforeach
</div>
@endif
</div>

View File

@ -23,7 +23,7 @@
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 300px;gap:14px;align-items:start">
<div class="mw-wh-layout">
{{-- ═══ Left ═══ --}}
<div class="mbx-sections">
@ -45,83 +45,102 @@
<div style="font-size:11.5px;color:var(--mw-t4)">Verbinde externe Systeme sie werden bei Events automatisch benachrichtigt.</div>
</div>
@else
<div class="mbx-table-wrap">
<table class="mbx-table">
<thead>
<tr>
<th class="mbx-th">Webhook</th>
<th class="mbx-th">Events</th>
<th class="mbx-th" style="width:80px">Status</th>
<th class="mbx-th" style="width:90px">HTTP</th>
<th class="mbx-th" style="width:130px">Zuletzt ausgelöst</th>
<th class="mbx-th mbx-th-right" style="width:80px">Aktionen</th>
</tr>
</thead>
<tbody>
@foreach($webhooks as $wh)
<tr class="mbx-tr">
<td class="mbx-td">
<div style="display:flex;align-items:center;gap:8px">
<div style="width:8px;height:8px;border-radius:50%;flex-shrink:0;background:{{ $wh->is_active ? '#34d399' : 'var(--mw-t4)' }};box-shadow:{{ $wh->is_active ? '0 0 6px rgba(52,211,153,.5)' : 'none' }}"></div>
<div>
<div style="font-size:12.5px;font-weight:500;color:var(--mw-t1)">{{ $wh->name }}</div>
<div style="font-family:monospace;font-size:10.5px;color:var(--mw-t4);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:260px">{{ $wh->url }}</div>
</div>
</div>
</td>
<td class="mbx-td">
<div style="display:flex;flex-wrap:wrap;gap:3px">
@foreach($wh->events as $ev)
<span style="font-size:9.5px;padding:1px 5px;background:var(--mw-bg4);border:1px solid var(--mw-b2);border-radius:4px;color:var(--mw-t4);font-family:monospace">{{ $ev }}</span>
@endforeach
</div>
</td>
<td class="mbx-td">
@if($wh->is_active)
<span class="mbx-badge-ok">Aktiv</span>
@else
<span class="mbx-badge-warn">Pausiert</span>
@endif
</td>
<td class="mbx-td">
@if($wh->last_status === null)
<span style="font-size:11px;color:var(--mw-t4)"></span>
@elseif($wh->last_status >= 200 && $wh->last_status < 300)
<span style="font-family:monospace;font-size:11px;color:#34d399;font-weight:600">{{ $wh->last_status }}</span>
@elseif($wh->last_status === 0)
<span style="font-family:monospace;font-size:11px;color:#f87171;font-weight:600">Timeout</span>
@else
<span style="font-family:monospace;font-size:11px;color:#f87171;font-weight:600">{{ $wh->last_status }}</span>
@endif
</td>
<td class="mbx-td mbx-td-muted" style="font-size:11px">
{{ $wh->last_triggered_at?->diffForHumans() ?? '—' }}
</td>
<td class="mbx-td">
<div class="mbx-actions">
<button wire:click="$dispatch('openModal',{component:'ui.system.modal.webhook-edit-modal',arguments:{webhookId:{{ $wh->id }}}})"
class="mbx-act-btn" title="Bearbeiten">
<svg width="12" height="12" viewBox="0 0 13 13" fill="none"><path d="M9 2l2 2-7 7H2V9l7-7Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
</button>
<button wire:click="toggleActive({{ $wh->id }})"
class="mbx-act-btn" title="{{ $wh->is_active ? 'Pausieren' : 'Aktivieren' }}">
@if($wh->is_active)
<svg width="12" height="12" viewBox="0 0 13 13" fill="none"><rect x="3" y="2" width="2.5" height="9" rx="1" stroke="currentColor" stroke-width="1.2"/><rect x="7.5" y="2" width="2.5" height="9" rx="1" stroke="currentColor" stroke-width="1.2"/></svg>
@else
<svg width="12" height="12" viewBox="0 0 13 13" fill="none"><path d="M4 2.5l7 4-7 4V2.5Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
@endif
</button>
<button wire:click="$dispatch('openModal',{component:'ui.system.modal.webhook-delete-modal',arguments:{webhookId:{{ $wh->id }}}})"
class="mbx-act-btn mbx-act-danger" title="Löschen">
<svg width="12" height="12" viewBox="0 0 13 13" fill="none"><path d="M2 3h9M5 3V2h3v1M3.5 3l.5 8h5l.5-8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
{{-- Header --}}
<div class="mw-whl-head">
<span>Webhook</span>
<span>Events</span>
<span>Status</span>
<span>HTTP</span>
<span>Aktionen</span>
</div>
{{-- Rows --}}
@foreach($webhooks as $wh)
@php
$evVisible = array_slice($wh->events, 0, 2);
$evRest = count($wh->events) - 2;
@endphp
<div class="mw-whl-row">
{{-- Webhook name + url --}}
<div class="mw-whl-webhook" style="display:flex;align-items:center;gap:8px;min-width:0">
<div style="width:8px;height:8px;border-radius:50%;flex-shrink:0;background:{{ $wh->is_active ? '#34d399' : 'var(--mw-t4)' }};box-shadow:{{ $wh->is_active ? '0 0 6px rgba(52,211,153,.5)' : 'none' }}"></div>
<div style="min-width:0">
<div style="font-size:12.5px;font-weight:500;color:var(--mw-t1);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
{{ $wh->name }}
<span class="mw-whl-status-sm">
@if($wh->is_active)
<span class="mbx-badge-ok" style="font-size:9px;margin-left:4px">Aktiv</span>
@else
<span class="mbx-badge-warn" style="font-size:9px;margin-left:4px">Pausiert</span>
@endif
</span>
<span class="mw-whl-http-sm" style="font-family:monospace;font-size:9.5px;margin-left:4px">
@if($wh->last_status !== null)
@if($wh->last_status >= 200 && $wh->last_status < 300)
<span style="color:#34d399;font-weight:600">{{ $wh->last_status }}</span>
@else
<span style="color:#f87171;font-weight:600">{{ $wh->last_status === 0 ? 'T/O' : $wh->last_status }}</span>
@endif
@endif
</span>
</div>
<div style="font-family:monospace;font-size:10px;color:var(--mw-t5);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ $wh->url }}</div>
</div>
</div>
{{-- Events --}}
<div class="mw-whl-events" style="display:flex;flex-wrap:wrap;gap:3px;align-items:center">
@foreach($evVisible as $ev)
<span style="font-size:9.5px;padding:1px 5px;background:var(--mw-bg4);border:1px solid var(--mw-b2);border-radius:4px;color:var(--mw-t4);font-family:monospace;white-space:nowrap">{{ $ev }}</span>
@endforeach
@if($evRest > 0)
<span style="font-size:9.5px;padding:1px 6px;background:var(--mw-bg4);border:1px solid var(--mw-b2);border-radius:4px;color:var(--mw-t4);white-space:nowrap">+{{ $evRest }}</span>
@endif
</div>
{{-- Status --}}
<div class="mw-whl-status">
@if($wh->is_active)
<span class="mbx-badge-ok">Aktiv</span>
@else
<span class="mbx-badge-warn">Pausiert</span>
@endif
</div>
{{-- HTTP --}}
<div class="mw-whl-http">
@if($wh->last_status === null)
<span style="font-size:11px;color:var(--mw-t4)"></span>
@elseif($wh->last_status >= 200 && $wh->last_status < 300)
<span style="font-family:monospace;font-size:11px;color:#34d399;font-weight:600">{{ $wh->last_status }}</span>
@elseif($wh->last_status === 0)
<span style="font-family:monospace;font-size:11px;color:#f87171;font-weight:600">Timeout</span>
@else
<span style="font-family:monospace;font-size:11px;color:#f87171;font-weight:600">{{ $wh->last_status }}</span>
@endif
</div>
{{-- Actions --}}
<div class="mw-whl-actions">
<div class="mbx-actions">
<button wire:click="$dispatch('openModal',{component:'ui.system.modal.webhook-edit-modal',arguments:{webhookId:{{ $wh->id }}}})"
class="mbx-act-btn" title="Bearbeiten">
<svg width="12" height="12" viewBox="0 0 13 13" fill="none"><path d="M9 2l2 2-7 7H2V9l7-7Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
</button>
<button wire:click="toggleActive({{ $wh->id }})"
class="mbx-act-btn" title="{{ $wh->is_active ? 'Pausieren' : 'Aktivieren' }}">
@if($wh->is_active)
<svg width="12" height="12" viewBox="0 0 13 13" fill="none"><rect x="3" y="2" width="2.5" height="9" rx="1" stroke="currentColor" stroke-width="1.2"/><rect x="7.5" y="2" width="2.5" height="9" rx="1" stroke="currentColor" stroke-width="1.2"/></svg>
@else
<svg width="12" height="12" viewBox="0 0 13 13" fill="none"><path d="M4 2.5l7 4-7 4V2.5Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
@endif
</button>
<button wire:click="$dispatch('openModal',{component:'ui.system.modal.webhook-delete-modal',arguments:{webhookId:{{ $wh->id }}}})"
class="mbx-act-btn mbx-act-danger" title="Löschen">
<svg width="12" height="12" viewBox="0 0 13 13" fill="none"><path d="M2 3h9M5 3V2h3v1M3.5 3l.5 8h5l.5-8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
</div>
</div>
@endforeach
@endif
</div>

View File

@ -28,14 +28,31 @@
<span class="hidden"></span>
<div x-show="show && showActiveComponent"
x-bind:class="modalWidth"
id="modal-container"
x-trap.noscroll.inert="show && showActiveComponent"
aria-modal="true"
class="mw-modal-box inline-block w-full sm:max-w-md relative z-10">
{{-- <div x-show="show && showActiveComponent"--}}
{{-- x-bind:class="modalWidth"--}}
{{-- id="modal-container"--}}
{{-- x-trap.noscroll.inert="show && showActiveComponent"--}}
{{-- aria-modal="true"--}}
{{-- class="mw-modal-box inline-block w-full sm:max-w-md relative z-10">--}}
<div class="mw-modal-inner">
<span class="hidden"></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="mw-modal-box relative inline-block w-full align-bottom rounded-lg text-left shadow-xl transform transition-all sm:my-8 sm:align-middle sm:w-full"
id="modal-container"
x-trap.inert="show && showActiveComponent"
aria-modal="true"
>
<div class="mw-modal-inner">
@forelse($components as $id => $component)
<div x-show.immediate="activeComponent == '{{ $id }}'"
x-ref="{{ $id }}"