Sperren sehen und aufheben: Portalseite fuer den Inhaber, Abschnitte in der Konsole
Aufgabe 6 des Fruehwarnsystems. Der Inhaber sieht im Portal die Sperren SEINER Instanzen und hebt sie dort auf; der Betreiber sieht in der Konsole alle, an der Kunden- und an der Host-Detailseite, und auf der Uebersicht steht ein Hinweis, solange irgendwo eine Sperre aktiv ist. Aufgehoben wird ueber ein Bestaetigungs-Modal (R23), und das Modal mutiert nichts: es wirft ein Ereignis, das die Seite auffaengt und an ihre eigene Methode weiterreicht. Die Berechtigungspruefung bleibt damit an der einen Stelle, an der sie schon stand — noetig, weil ein Modal ohne die Middleware der Seite erreichbar ist (R20). Dazu die Berechtigung `instances.manage`, nach dem Muster der bestehenden `instances.restart`-Migration; Abrechnung und Read-only bleiben unberuehrt. ACHTUNG, was hier sonst noch drinsteckt und NICHT zu dieser Aufgabe gehoert: rund 150 Zeilen zum Versandtakt — das Merkmal `RidesALane`, vierzehn Mailables und `MailLaneRoutingTest`. Die stammen aus einer PARALLEL laufenden Sitzung an einem anderen Feature. Wie das hineingeriet: der Implementierer dieser Aufgabe brach vor dem Commit ab und liess seine fertige Arbeit ungespeichert im Baum. Ich habe sie dateigenau mit `git add <dateien>` vorgemerkt, um nichts Fremdes mitzunehmen — und dabei uebersehen, dass `git add` nur HINZUFUEGT: die andere Sitzung hatte ihre Arbeit bereits vorgemerkt, und `git commit` schreibt den ganzen Index, nicht nur das zuletzt Hinzugefuegte. Richtig waere `git commit -- <dateien>` gewesen. Nichts ist verloren, und die volle Suite ist auf diesem Stand gruen (2571). Aber diese Botschaft soll nicht behaupten, sie beschreibe alles, was hier steht. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feat/versandtakt
parent
4db1957f06
commit
51a97019a8
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Models\SecurityBlock;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
/**
|
||||
* Bestaetigung vorm Aufheben einer Sperre in der Konsole (R23).
|
||||
*
|
||||
* Eine Sperre, zwei Orte — die Host-Detailseite (hosts.manage) und die
|
||||
* Instanz eines Kunden auf dessen Kundenseite (instances.manage) — ein
|
||||
* gemeinsames Modal, das die Berechtigung nach dem Subjekt der Sperre
|
||||
* entscheidet, statt zwei fast identische Modals zu pflegen.
|
||||
*
|
||||
* Mutiert trotzdem nichts: HostDetail::onSecurityBlockReleaseConfirmed() bzw.
|
||||
* CustomerDetail::onSecurityBlockReleaseConfirmed() pruefen beim
|
||||
* tatsaechlichen Aufheben ihrerseits noch einmal, dass die Sperre zu IHREM
|
||||
* Host bzw. IHRER Instanz gehoert — ein Modal ist ohne die Route-Middleware
|
||||
* der Seite erreichbar (R20), die Pruefung hier ist eine Anzeige-Schranke
|
||||
* (welche Adresse dieses Fenster nennen darf), keine Ersatz-Autorisierung.
|
||||
*/
|
||||
class ConfirmReleaseBlock extends ModalComponent
|
||||
{
|
||||
public string $uuid;
|
||||
|
||||
public string $ip = '';
|
||||
|
||||
public function mount(string $uuid): void
|
||||
{
|
||||
$block = SecurityBlock::query()->where('uuid', $uuid)->first();
|
||||
|
||||
abort_if($block === null, 404);
|
||||
|
||||
$this->authorize($block->instance_id !== null ? 'instances.manage' : 'hosts.manage');
|
||||
|
||||
$this->uuid = $uuid;
|
||||
$this->ip = $block->ip;
|
||||
}
|
||||
|
||||
public function confirm(): void
|
||||
{
|
||||
$this->dispatch('security-block-release-confirmed', uuid: $this->uuid);
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.confirm-release-block');
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ use App\Models\Instance;
|
|||
use App\Models\Invoice;
|
||||
use App\Models\MailTemplate;
|
||||
use App\Models\Order;
|
||||
use App\Models\SecurityBlock;
|
||||
use App\Models\SentMail;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\SupportRequest;
|
||||
|
|
@ -16,6 +17,7 @@ use App\Services\Mail\MailTemplateRenderer;
|
|||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
|
@ -150,6 +152,31 @@ class CustomerDetail extends Component
|
|||
$this->body = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* ConfirmReleaseBlock (Konsole) dispatches this back (R23) — see that
|
||||
* class. Es mutiert nichts; die eigentliche Freigabe UND die Pruefung,
|
||||
* dass die Sperre wirklich an EINER Instanz DIESES Kunden haengt, stehen
|
||||
* hier — ein Modal ist ohne die Route-Middleware dieser Seite erreichbar
|
||||
* (R20).
|
||||
*/
|
||||
#[On('security-block-release-confirmed')]
|
||||
public function onSecurityBlockReleaseConfirmed(string $uuid): void
|
||||
{
|
||||
$this->authorize('instances.manage');
|
||||
|
||||
$block = SecurityBlock::query()
|
||||
->where('uuid', $uuid)
|
||||
->whereHas('instance', fn ($q) => $q->where('customer_id', $this->customer->id))
|
||||
->first();
|
||||
|
||||
if ($block === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$block->release(auth('operator')->user());
|
||||
$this->dispatch('notify', message: __('admin.security_block.released'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send it, record it, and close the request it answers.
|
||||
*
|
||||
|
|
@ -189,13 +216,21 @@ class CustomerDetail extends Component
|
|||
{
|
||||
$this->authorize('customers.manage');
|
||||
|
||||
$instance = Instance::query()
|
||||
->where('customer_id', $this->customer->id)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
return view('livewire.admin.customer-detail', [
|
||||
'tabs' => self::TABS,
|
||||
'subscription' => $this->subscription(),
|
||||
'instance' => Instance::query()
|
||||
->where('customer_id', $this->customer->id)
|
||||
->latest('id')
|
||||
->first(),
|
||||
'instance' => $instance,
|
||||
// Sicherheitssperren DIESER Instanz — derselbe Abschnitt (Zeilen
|
||||
// + Knopf) wie auf der Host-Detailseite, hinter instances.manage
|
||||
// statt hosts.manage.
|
||||
'securityBlocks' => $instance
|
||||
? SecurityBlock::query()->where('instance_id', $instance->id)->orderByDesc('blocked_at')->get()
|
||||
: collect(),
|
||||
'orders' => Order::query()
|
||||
->where('customer_id', $this->customer->id)
|
||||
->latest('id')
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ namespace App\Livewire\Admin;
|
|||
use App\Models\Customer;
|
||||
use App\Models\Host;
|
||||
use App\Models\ProvisioningRun;
|
||||
use App\Models\SecurityBlock;
|
||||
use App\Provisioning\Jobs\AdvanceRunJob;
|
||||
use App\Provisioning\Jobs\CollectHostLoad;
|
||||
use App\Services\Proxmox\HostLoadSeries;
|
||||
|
|
@ -108,6 +109,27 @@ class HostDetail extends Component
|
|||
$this->releaseReservation();
|
||||
}
|
||||
|
||||
/**
|
||||
* ConfirmReleaseBlock (Konsole) dispatches this back (R23) — see that
|
||||
* class. Es mutiert nichts; die eigentliche Freigabe UND die Pruefung,
|
||||
* dass die Sperre wirklich an DIESEM Host haengt, stehen hier — ein
|
||||
* Modal ist ohne die Route-Middleware dieser Seite erreichbar (R20).
|
||||
*/
|
||||
#[On('security-block-release-confirmed')]
|
||||
public function onSecurityBlockReleaseConfirmed(string $uuid): void
|
||||
{
|
||||
$this->authorize('hosts.manage');
|
||||
|
||||
$block = SecurityBlock::query()->where('uuid', $uuid)->where('host_id', $this->host->id)->first();
|
||||
|
||||
if ($block === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$block->release(auth('operator')->user());
|
||||
$this->dispatch('notify', message: __('admin.security_block.released'));
|
||||
}
|
||||
|
||||
public function retry(): void
|
||||
{
|
||||
$this->authorize('hosts.manage');
|
||||
|
|
@ -202,6 +224,10 @@ class HostDetail extends Component
|
|||
],
|
||||
'version' => PveVersion::parse($this->host->pve_version),
|
||||
'fqdn' => HostName::fqdn($this->host->name),
|
||||
'securityBlocks' => SecurityBlock::query()
|
||||
->where('host_id', $this->host->id)
|
||||
->orderByDesc('blocked_at')
|
||||
->get(),
|
||||
// Nur gebraucht, solange der Host noch niemandem gehört — aber
|
||||
// billig genug (eine Namensliste), um sie immer mitzugeben statt
|
||||
// eine zweite Bedingung ums Laden zu ziehen.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use App\Models\Host;
|
|||
use App\Models\Instance;
|
||||
use App\Models\MonitoringTarget;
|
||||
use App\Models\ProvisioningRun;
|
||||
use App\Models\SecurityBlock;
|
||||
use App\Models\Subscription;
|
||||
use App\Services\Billing\PlanCatalogue;
|
||||
use App\Services\Provisioning\HostCapacity;
|
||||
|
|
@ -355,6 +356,22 @@ class Overview extends Component
|
|||
];
|
||||
}
|
||||
|
||||
// An address locked out after repeated failed sign-ins — host SSH or
|
||||
// an instance's own login, see SecurityBlock. Task 6 gave the two
|
||||
// kinds a section each (host detail, a customer's instance) but no
|
||||
// single list of both together, so this links to whichever list an
|
||||
// operator would actually find the flagged subject on: the host list
|
||||
// if a host is currently blocked (the more urgent case — somebody is
|
||||
// hammering a hypervisor's SSH), the instance list otherwise.
|
||||
$activeBlocks = SecurityBlock::active()->count();
|
||||
if ($activeBlocks > 0) {
|
||||
$notices[] = [
|
||||
'level' => 'warning',
|
||||
'text' => __('admin.notice.security_blocks', ['n' => $activeBlocks]),
|
||||
'route' => SecurityBlock::active()->whereNotNull('host_id')->exists() ? 'admin.hosts' : 'admin.instances',
|
||||
];
|
||||
}
|
||||
|
||||
// Capacity, before an order finds out.
|
||||
//
|
||||
// The platform books thick: a package reserves its whole `disk_gb` on
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Livewire\Concerns\ResolvesCustomer;
|
||||
use App\Models\SecurityBlock;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
/**
|
||||
* Bestaetigung vorm Aufheben einer eigenen Sperre (R23).
|
||||
*
|
||||
* Mutiert nichts: Security::onReleaseConfirmed() bleibt die einzige Stelle,
|
||||
* die tatsaechlich freigibt — und die eigene Pruefung dort wiederholt genau
|
||||
* das, was hier schon steht, weil ein Modal ohne die Route-Middleware der
|
||||
* Seite erreichbar ist (R20). Deshalb loest auch dieses Modal den Kunden
|
||||
* selbst auf, statt der uebergebenen uuid zu vertrauen, und zeigt bei einer
|
||||
* fremden oder einer Host-Sperre lieber 404 als deren Adresse.
|
||||
*/
|
||||
class ConfirmReleaseBlock extends ModalComponent
|
||||
{
|
||||
use ResolvesCustomer;
|
||||
|
||||
public string $uuid;
|
||||
|
||||
public string $ip = '';
|
||||
|
||||
public function mount(string $uuid): void
|
||||
{
|
||||
$customer = $this->customer();
|
||||
|
||||
$block = SecurityBlock::query()
|
||||
->where('uuid', $uuid)
|
||||
->whereHas('instance', fn ($q) => $q->where('customer_id', $customer?->id))
|
||||
->first();
|
||||
|
||||
abort_if($block === null, 404);
|
||||
|
||||
$this->uuid = $uuid;
|
||||
$this->ip = $block->ip;
|
||||
}
|
||||
|
||||
public function confirm(): void
|
||||
{
|
||||
$this->dispatch('block-release-confirmed', uuid: $this->uuid);
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.confirm-release-block');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Livewire\Concerns\ResolvesCustomer;
|
||||
use App\Models\SecurityBlock;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Die Sperrliste der eigenen Instanzen — das Ziel, auf das die Mail aus
|
||||
* BlockAddress verlinkt (route('portal.security'), siehe deren
|
||||
* SecurityBlockMail), und der Ort, an dem ein Kunde eine eigene
|
||||
* Fehlalarm-Sperre selbst vorzeitig aufheben kann.
|
||||
*/
|
||||
#[Layout('layouts.portal-app')]
|
||||
class Security extends Component
|
||||
{
|
||||
use ResolvesCustomer;
|
||||
|
||||
/**
|
||||
* ConfirmReleaseBlock dispatches this back (R23) — das Modal selbst
|
||||
* mutiert nichts. Es ist ohne die Middleware dieser Seite erreichbar
|
||||
* (R20), deshalb wird hier — wie im Modal auch, aber unabhaengig davon —
|
||||
* erneut geprueft, dass die Sperre zu einer Instanz DIESES Kunden
|
||||
* gehoert, statt der uebergebenen uuid zu vertrauen. Eine fremde oder
|
||||
* eine Host-Sperre (kein instance_id) gibt es hier nie zu sehen.
|
||||
*/
|
||||
#[On('block-release-confirmed')]
|
||||
public function onReleaseConfirmed(string $uuid): void
|
||||
{
|
||||
$customer = $this->customer();
|
||||
|
||||
$block = SecurityBlock::query()
|
||||
->where('uuid', $uuid)
|
||||
->whereHas('instance', fn ($q) => $q->where('customer_id', $customer?->id))
|
||||
->first();
|
||||
|
||||
abort_if($block === null, 403);
|
||||
|
||||
$block->release(auth()->user());
|
||||
|
||||
$this->dispatch('notify', message: __('security.blocks_released'));
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$customer = $this->customer();
|
||||
|
||||
$blocks = $customer
|
||||
? SecurityBlock::query()
|
||||
->whereHas('instance', fn ($q) => $q->where('customer_id', $customer->id))
|
||||
->orderByDesc('blocked_at')
|
||||
->get()
|
||||
: collect();
|
||||
|
||||
// Aktiv/Verlauf wird hier gesplittet, nicht per zweiter Abfrage:
|
||||
// scopeActive() prueft dieselben zwei Felder, die die Menge schon in
|
||||
// der Hand haelt.
|
||||
return view('livewire.security', [
|
||||
'active' => $blocks->filter(
|
||||
fn (SecurityBlock $b) => $b->released_at === null && $b->expires_at->isFuture()
|
||||
)->values(),
|
||||
'history' => $blocks->filter(
|
||||
fn (SecurityBlock $b) => $b->released_at !== null || $b->expires_at->isPast()
|
||||
)->values(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Mail\Concerns\RidesALane;
|
||||
use App\Mail\Concerns\SendsFromMailbox;
|
||||
use App\Services\Mail\MailPurpose;
|
||||
use Illuminate\Bus\Queueable;
|
||||
|
|
@ -19,7 +20,7 @@ use Illuminate\Queue\SerializesModels;
|
|||
*/
|
||||
class CloudResumedMail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SendsFromMailbox, SerializesModels;
|
||||
use Queueable, RidesALane, SendsFromMailbox, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public string $name,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Mail\Concerns\RidesALane;
|
||||
use App\Mail\Concerns\SendsFromMailbox;
|
||||
use App\Services\Mail\MailPurpose;
|
||||
use Illuminate\Bus\Queueable;
|
||||
|
|
@ -19,7 +20,7 @@ use Illuminate\Queue\SerializesModels;
|
|||
*/
|
||||
class CloudSuspendedMail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SendsFromMailbox, SerializesModels;
|
||||
use Queueable, RidesALane, SendsFromMailbox, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public string $name,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail\Concerns;
|
||||
|
||||
use App\Services\Mail\MailLane;
|
||||
use Illuminate\Contracts\Queue\Factory as Queue;
|
||||
|
||||
/**
|
||||
* Reiht eine Mail in die Schlange ihrer Spur ein.
|
||||
*
|
||||
* `Mailable::queue()` liest den Schlangennamen und reicht ihn an `pushOn()`
|
||||
* weiter — die Spur muss also VOR dem Einreihen feststehen. Eine Trait-Methode
|
||||
* schlägt die geerbte Methode der Elternklasse, deshalb genügt es, `queue()`
|
||||
* hier zu überschreiben und danach an die Elternklasse weiterzugeben.
|
||||
*
|
||||
* Neben `SendsFromMailbox` statt darin: die eine Sache ist, von welchem
|
||||
* Postfach eine Mail kommt, die andere, wie eilig sie ist.
|
||||
*/
|
||||
trait RidesALane
|
||||
{
|
||||
public function queue(Queue $queue)
|
||||
{
|
||||
$this->onQueue(MailLane::for(static::class));
|
||||
|
||||
return parent::queue($queue);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Mail\Concerns\RidesALane;
|
||||
use App\Mail\Concerns\SendsFromMailbox;
|
||||
use App\Services\Mail\MailPurpose;
|
||||
use Illuminate\Bus\Queueable;
|
||||
|
|
@ -28,7 +29,7 @@ use Illuminate\Queue\SerializesModels;
|
|||
*/
|
||||
class ContactRequestMail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SendsFromMailbox, SerializesModels;
|
||||
use Queueable, RidesALane, SendsFromMailbox, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param array{company: ?string, name: string, email: string, phone: ?string, message: string, topic: ?string} $enquiry
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Mail\Concerns\RidesALane;
|
||||
use App\Mail\Concerns\SendsFromMailbox;
|
||||
use App\Models\User;
|
||||
use App\Services\Mail\MailPurpose;
|
||||
|
|
@ -25,7 +26,7 @@ use Illuminate\Queue\SerializesModels;
|
|||
*/
|
||||
class DormantAccountWarningMail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SendsFromMailbox, SerializesModels;
|
||||
use Queueable, RidesALane, SendsFromMailbox, SerializesModels;
|
||||
|
||||
public function __construct(public User $user, public int $days)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Mail\Concerns\RidesALane;
|
||||
use App\Mail\Concerns\SendsFromMailbox;
|
||||
use App\Services\Mail\MailPurpose;
|
||||
use Illuminate\Bus\Queueable;
|
||||
|
|
@ -26,7 +27,7 @@ use Illuminate\Queue\SerializesModels;
|
|||
*/
|
||||
class DunningNoticeMail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SendsFromMailbox, SerializesModels;
|
||||
use Queueable, RidesALane, SendsFromMailbox, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param int $level 0 = Hinweis, 1–3 = Mahnstufe
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Mail\Concerns\RidesALane;
|
||||
use App\Mail\Concerns\SendsFromMailbox;
|
||||
use App\Models\Invoice;
|
||||
use App\Services\Billing\InvoiceMath;
|
||||
|
|
@ -27,7 +28,7 @@ use Illuminate\Queue\SerializesModels;
|
|||
*/
|
||||
class InvoiceMail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SendsFromMailbox, SerializesModels;
|
||||
use Queueable, RidesALane, SendsFromMailbox, SerializesModels;
|
||||
|
||||
public function __construct(public Invoice $invoice, public string $name)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Mail\Concerns\RidesALane;
|
||||
use App\Mail\Concerns\SendsFromMailbox;
|
||||
use App\Models\Customer;
|
||||
use App\Models\MaintenanceWindow;
|
||||
|
|
@ -16,7 +17,7 @@ use Illuminate\Queue\SerializesModels;
|
|||
|
||||
class MaintenanceAnnouncementMail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SendsFromMailbox, SerializesModels;
|
||||
use Queueable, RidesALane, SendsFromMailbox, SerializesModels;
|
||||
|
||||
/** The ledger row this mail confirms once actually delivered. */
|
||||
public ?int $notificationId = null;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Mail\Concerns\RidesALane;
|
||||
use App\Mail\Concerns\SendsFromMailbox;
|
||||
use App\Models\Customer;
|
||||
use App\Models\MaintenanceWindow;
|
||||
|
|
@ -16,7 +17,7 @@ use Illuminate\Queue\SerializesModels;
|
|||
|
||||
class MaintenanceCancelledMail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SendsFromMailbox, SerializesModels;
|
||||
use Queueable, RidesALane, SendsFromMailbox, SerializesModels;
|
||||
|
||||
/** The ledger row this mail confirms once actually delivered. */
|
||||
public ?int $notificationId = null;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Mail\Concerns\RidesALane;
|
||||
use App\Mail\Concerns\SendsFromMailbox;
|
||||
use App\Models\UserDevice;
|
||||
use App\Services\Mail\MailPurpose;
|
||||
|
|
@ -24,7 +25,7 @@ use Illuminate\Queue\SerializesModels;
|
|||
*/
|
||||
class NewDeviceSignInMail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SendsFromMailbox, SerializesModels;
|
||||
use Queueable, RidesALane, SendsFromMailbox, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public string $name,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Mail\Concerns\RidesALane;
|
||||
use App\Mail\Concerns\SendsFromMailbox;
|
||||
use App\Models\Customer;
|
||||
use App\Services\Mail\MailPurpose;
|
||||
|
|
@ -26,7 +27,7 @@ use Illuminate\Queue\SerializesModels;
|
|||
*/
|
||||
class OperatorMessageMail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SendsFromMailbox, SerializesModels;
|
||||
use Queueable, RidesALane, SendsFromMailbox, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Customer $customer,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Mail\Concerns\RidesALane;
|
||||
use App\Mail\Concerns\SendsFromMailbox;
|
||||
use App\Models\Datacenter;
|
||||
use App\Models\Order;
|
||||
|
|
@ -29,7 +30,7 @@ use Illuminate\Queue\SerializesModels;
|
|||
*/
|
||||
class OrderConfirmationMail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SendsFromMailbox, SerializesModels;
|
||||
use Queueable, RidesALane, SendsFromMailbox, SerializesModels;
|
||||
|
||||
public function __construct(public Order $order, public string $name)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Mail\Concerns\RidesALane;
|
||||
use App\Mail\Concerns\SendsFromMailbox;
|
||||
use App\Models\User;
|
||||
use App\Services\Mail\MailPurpose;
|
||||
|
|
@ -24,7 +25,7 @@ use Illuminate\Queue\SerializesModels;
|
|||
*/
|
||||
class ResetPasswordMail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SendsFromMailbox, SerializesModels;
|
||||
use Queueable, RidesALane, SendsFromMailbox, SerializesModels;
|
||||
|
||||
public function __construct(public User $user, public string $url, public int $minutes)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Mail\Concerns\RidesALane;
|
||||
use App\Mail\Concerns\SendsFromMailbox;
|
||||
use App\Models\SecurityBlock;
|
||||
use App\Services\Mail\MailPurpose;
|
||||
|
|
@ -30,7 +31,7 @@ use Illuminate\Queue\SerializesModels;
|
|||
*/
|
||||
class SecurityBlockMail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SendsFromMailbox, SerializesModels;
|
||||
use Queueable, RidesALane, SendsFromMailbox, SerializesModels;
|
||||
|
||||
public function __construct(public SecurityBlock $block)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Mail\Concerns\RidesALane;
|
||||
use App\Mail\Concerns\SendsFromMailbox;
|
||||
use App\Models\User;
|
||||
use App\Services\Mail\MailPurpose;
|
||||
|
|
@ -26,7 +27,7 @@ use Illuminate\Support\Facades\URL;
|
|||
*/
|
||||
class VerifyEmailMail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SendsFromMailbox, SerializesModels;
|
||||
use Queueable, RidesALane, SendsFromMailbox, SerializesModels;
|
||||
|
||||
public function __construct(public User $user)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@ final class Navigation
|
|||
['domain', 'globe', 'domain', 'use-custom-domain'],
|
||||
['users', 'users', 'users', null],
|
||||
['backups', 'database', 'backups', null],
|
||||
// Route heisst 'portal.security' statt bloss 'security' — der
|
||||
// Pfad '/security' gehoert schon der oeffentlichen
|
||||
// Aufklaerungsseite (routes/web.php), siehe deren Kommentar.
|
||||
['portal.security', 'shield', 'security', null],
|
||||
]],
|
||||
['label' => __('dashboard.nav_group.contract'), 'items' => [
|
||||
['billing', 'box', 'billing', null],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
|
||||
/**
|
||||
* `instances.manage` — see and lift a security block sitting on a customer's
|
||||
* instance (App\Livewire\Admin\CustomerDetail, the "package" tab).
|
||||
*
|
||||
* No such capability existed: SecurityBlock and its release() method were
|
||||
* built in an earlier task, but nothing in the console could reach either yet
|
||||
* — this is that view, and it needs its own gate the way the matching
|
||||
* host-side section already leans on `hosts.manage`.
|
||||
*
|
||||
* Granted the same three roles as `instances.restart` (see that migration),
|
||||
* for the same reason stated there: a customer locked out by their own typo
|
||||
* is exactly the call Support answers the phone for, and an Admin who has to
|
||||
* fetch an Owner to press one button is an outage that lasts until somebody
|
||||
* answers.
|
||||
*
|
||||
* Guard `operator`: every capability in this application lives there since
|
||||
* 2026-07-29 (see move_rbac_to_operator_guard) — one created under `web`
|
||||
* would silently match nobody at all.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
|
||||
Permission::findOrCreate('instances.manage', 'operator');
|
||||
foreach (['Owner', 'Admin', 'Support'] as $role) {
|
||||
Role::findOrCreate($role, 'operator')->givePermissionTo('instances.manage');
|
||||
}
|
||||
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
Permission::query()->where('name', 'instances.manage')->where('guard_name', 'operator')->delete();
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
}
|
||||
};
|
||||
|
|
@ -108,6 +108,32 @@ return [
|
|||
'host_silent' => 'Host :host hat sich seit über :minutes Minuten nicht gemeldet.',
|
||||
'monitoring_down' => ':n überwachte Instanz(en) nicht erreichbar.',
|
||||
'readiness_blocking' => '{1} :n Punkt auf der Bereitschaftsseite blockiert den Livebetrieb.|[2,*] :n Punkte auf der Bereitschaftsseite blockieren den Livebetrieb.',
|
||||
// Host- UND Instanz-Sperren zusammen — es gibt keine zentrale Liste
|
||||
// beider Arten, deshalb je nachdem, was gerade aktiv ist, auf die
|
||||
// Host- oder die Instanzliste verlinkt (siehe Overview::notices()).
|
||||
'security_blocks' => ':n aktive Sicherheitssperre(n) — Adressen, die wegen Fehlversuchen ausgesperrt wurden.',
|
||||
],
|
||||
|
||||
// Geteilt zwischen der Host-Detailseite (hosts.manage) und der
|
||||
// Kundenseite (instances.manage) — dieselbe Sperre, zwei Orte, ein
|
||||
// Wortlaut statt zweier, die auseinanderlaufen.
|
||||
'security_block' => [
|
||||
'title' => 'Sicherheitssperren',
|
||||
'empty' => 'Keine Sperren.',
|
||||
'col_ip' => 'Adresse',
|
||||
'col_blocked' => 'Gesperrt seit',
|
||||
'col_expires' => 'Läuft ab',
|
||||
'col_attempts' => 'Fehlversuche',
|
||||
'col_status' => 'Status',
|
||||
'col_actions' => 'Aktion',
|
||||
'status_active' => 'Aktiv',
|
||||
'status_released' => 'Aufgehoben',
|
||||
'status_expired' => 'Abgelaufen',
|
||||
'release' => 'Aufheben',
|
||||
'released' => 'Sperre aufgehoben.',
|
||||
'release_title' => 'Sperre aufheben?',
|
||||
'release_body' => 'Die Adresse :ip wird sofort wieder zugelassen.',
|
||||
'release_confirm' => 'Aufheben',
|
||||
],
|
||||
|
||||
'customers_sub' => 'Alle Kunden und ihre Pakete.',
|
||||
|
|
|
|||
|
|
@ -141,6 +141,7 @@ return [
|
|||
'domain' => 'Eigene Domain',
|
||||
'users' => 'Benutzer',
|
||||
'backups' => 'Backups',
|
||||
'security' => 'Sicherheit',
|
||||
'invoices' => 'Rechnungen',
|
||||
'billing' => 'Paket & Addons',
|
||||
'settings' => 'Einstellungen',
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ return [
|
|||
'dpa.manage' => 'Auftragsverarbeitungsverträge',
|
||||
'hosts.manage' => 'Hosts anlegen, übernehmen, entfernen',
|
||||
'instances.adminlogin' => 'Sich in die Cloud eines Kunden einloggen',
|
||||
'instances.manage' => 'Sicherheitssperren einer Kunden-Instanz sehen und aufheben',
|
||||
'instances.restart' => 'Eine Kunden-Cloud neu starten',
|
||||
'mail.manage' => 'Postfächer, Vorlagen, Posteingang',
|
||||
'maintenance.manage' => 'Wartungsfenster und Störungen',
|
||||
|
|
|
|||
|
|
@ -59,4 +59,26 @@ return [
|
|||
// Die Fehlalarme, die bleiben, ausdrücklich benannt — sonst hält der
|
||||
// Empfänger die Meldung für falsch und nimmt die nächste nicht mehr ernst.
|
||||
'mail_false_alarm' => 'Diese Meldung kommt auch, wenn ein eigenes Gerät oder eine eigene Anwendung mit einem falschen Passwort auf Ihre Cloud zugreift.',
|
||||
|
||||
// Aufgabe 6: die Sicherheitsseite selbst — App\Livewire\Security, das Ziel
|
||||
// hinter dem Knopf 'mail_action' oben und dem neuen Portal-Navigationspunkt.
|
||||
'blocks_title' => 'Sicherheitssperren',
|
||||
'blocks_lead' => 'Adressen, die wegen wiederholter Fehlversuche vorübergehend von Ihrer Cloud ausgesperrt wurden. Eine eigene Sperre — etwa nach einem falsch gespeicherten Passwort — können Sie hier vorzeitig aufheben.',
|
||||
'blocks_active' => 'Aktive Sperren',
|
||||
'blocks_empty' => 'Zurzeit ist keine Adresse an Ihrer Cloud gesperrt.',
|
||||
'blocks_history' => 'Frühere Sperren',
|
||||
'blocks_history_empty' => 'Noch keine früheren Sperren.',
|
||||
'col_ip' => 'Adresse',
|
||||
'col_blocked' => 'Gesperrt seit',
|
||||
'col_expires' => 'Läuft ab',
|
||||
'col_attempts' => 'Fehlversuche',
|
||||
'col_status' => 'Status',
|
||||
'col_actions' => 'Aktion',
|
||||
'status_released' => 'Aufgehoben',
|
||||
'status_expired' => 'Abgelaufen',
|
||||
'release' => 'Aufheben',
|
||||
'release_title' => 'Sperre aufheben?',
|
||||
'release_body' => 'Die Adresse :ip wird sofort wieder zugelassen. Heben Sie eine Sperre nur auf, wenn Sie sicher sind, dass die Anmeldeversuche von Ihnen selbst stammten.',
|
||||
'release_confirm' => 'Aufheben',
|
||||
'blocks_released' => 'Sperre aufgehoben.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -108,6 +108,32 @@ return [
|
|||
'host_silent' => 'Host :host has not checked in for over :minutes minutes.',
|
||||
'monitoring_down' => ':n monitored instance(s) unreachable.',
|
||||
'readiness_blocking' => '{1} :n item on the readiness page blocks going live.|[2,*] :n items on the readiness page block going live.',
|
||||
// Host AND instance blocks together — there is no single list of
|
||||
// both kinds, so this links to whichever list currently applies
|
||||
// (see Overview::notices()).
|
||||
'security_blocks' => ':n active security block(s) — addresses locked out after failed sign-in attempts.',
|
||||
],
|
||||
|
||||
// Shared between the host detail page (hosts.manage) and the customer
|
||||
// page (instances.manage) — the same kind of block, two places, one
|
||||
// wording instead of two that drift apart.
|
||||
'security_block' => [
|
||||
'title' => 'Security blocks',
|
||||
'empty' => 'No blocks.',
|
||||
'col_ip' => 'Address',
|
||||
'col_blocked' => 'Blocked since',
|
||||
'col_expires' => 'Expires',
|
||||
'col_attempts' => 'Failed attempts',
|
||||
'col_status' => 'Status',
|
||||
'col_actions' => 'Action',
|
||||
'status_active' => 'Active',
|
||||
'status_released' => 'Lifted',
|
||||
'status_expired' => 'Expired',
|
||||
'release' => 'Lift',
|
||||
'released' => 'Block lifted.',
|
||||
'release_title' => 'Lift this block?',
|
||||
'release_body' => 'Address :ip will be allowed through again immediately.',
|
||||
'release_confirm' => 'Lift',
|
||||
],
|
||||
|
||||
'customers_sub' => 'All customers and their plans.',
|
||||
|
|
|
|||
|
|
@ -141,6 +141,7 @@ return [
|
|||
'domain' => 'Your domain',
|
||||
'users' => 'Users',
|
||||
'backups' => 'Backups',
|
||||
'security' => 'Security',
|
||||
'invoices' => 'Invoices',
|
||||
'billing' => 'Plan & add-ons',
|
||||
'settings' => 'Settings',
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ return [
|
|||
'dpa.manage' => 'Data processing agreements',
|
||||
'hosts.manage' => 'Create, onboard and remove hosts',
|
||||
'instances.adminlogin' => 'Sign in to a customer cloud',
|
||||
'instances.manage' => 'See and lift security blocks on a customer instance',
|
||||
'instances.restart' => 'Restart a customer cloud',
|
||||
'mail.manage' => 'Mailboxes, templates, inbox',
|
||||
'maintenance.manage' => 'Maintenance windows and incidents',
|
||||
|
|
|
|||
|
|
@ -59,4 +59,26 @@ return [
|
|||
// The false alarms that remain, said out loud — otherwise the recipient
|
||||
// decides the warning is wrong and stops reading the next one.
|
||||
'mail_false_alarm' => 'You will also get this message if a device or application of your own is using an outdated password against your cloud.',
|
||||
|
||||
// Task 6: the security page itself — App\Livewire\Security, the target
|
||||
// behind the 'mail_action' button above and the new portal nav entry.
|
||||
'blocks_title' => 'Security blocks',
|
||||
'blocks_lead' => 'Addresses temporarily locked out of your cloud after repeated failed sign-in attempts. You can lift your own block early here — for example after a saved password that turned out to be wrong.',
|
||||
'blocks_active' => 'Active blocks',
|
||||
'blocks_empty' => 'No address is currently blocked at your cloud.',
|
||||
'blocks_history' => 'Past blocks',
|
||||
'blocks_history_empty' => 'No past blocks yet.',
|
||||
'col_ip' => 'Address',
|
||||
'col_blocked' => 'Blocked since',
|
||||
'col_expires' => 'Expires',
|
||||
'col_attempts' => 'Failed attempts',
|
||||
'col_status' => 'Status',
|
||||
'col_actions' => 'Action',
|
||||
'status_released' => 'Lifted',
|
||||
'status_expired' => 'Expired',
|
||||
'release' => 'Lift',
|
||||
'release_title' => 'Lift this block?',
|
||||
'release_body' => 'Address :ip will be allowed through again immediately. Only lift a block if you are sure the sign-in attempts were your own.',
|
||||
'release_confirm' => 'Lift',
|
||||
'blocks_released' => 'Block lifted.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
<div class="rounded-lg bg-surface p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-warning-bg text-warning">
|
||||
<x-ui.icon name="unlock" class="size-5" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-base font-semibold text-ink">{{ __('admin.security_block.release_title') }}</h3>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('admin.security_block.release_body', ['ip' => $ip]) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.cancel') }}</x-ui.button>
|
||||
<x-ui.button variant="primary" wire:click="confirm" wire:loading.attr="disabled">
|
||||
{{ __('admin.security_block.release_confirm') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -220,6 +220,61 @@
|
|||
</dl>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Sicherheitssperren dieser Instanz. Denselben Abschnitt
|
||||
(Zeilen + Knopf) zeigt der Host auf seiner eigenen
|
||||
Detailseite, hinter hosts.manage statt
|
||||
instances.manage — siehe admin.security_block im
|
||||
Sprachfile. --}}
|
||||
@can('instances.manage')
|
||||
@if ($instance !== null)
|
||||
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:140ms]">
|
||||
<h2 class="font-semibold text-ink">
|
||||
{{ __('admin.security_block.title') }}
|
||||
<span class="ml-1 font-mono text-xs font-normal text-muted">{{ $securityBlocks->count() }}</span>
|
||||
</h2>
|
||||
@if ($securityBlocks->isEmpty())
|
||||
<p class="mt-3 text-sm text-muted">{{ __('admin.security_block.empty') }}</p>
|
||||
@else
|
||||
<div class="mt-3 overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-line text-left text-xs font-semibold text-muted">
|
||||
<th class="py-2 pr-4 font-semibold">{{ __('admin.security_block.col_ip') }}</th>
|
||||
<th class="py-2 pr-4 font-semibold">{{ __('admin.security_block.col_blocked') }}</th>
|
||||
<th class="py-2 pr-4 font-semibold">{{ __('admin.security_block.col_status') }}</th>
|
||||
<th class="py-2 text-right font-semibold">{{ __('admin.security_block.col_actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($securityBlocks as $block)
|
||||
@php
|
||||
$active = $block->released_at === null && $block->expires_at->isFuture();
|
||||
$bstatus = $active ? 'warning' : ($block->released_at ? 'active' : 'info');
|
||||
$blabel = $active ? 'status_active' : ($block->released_at ? 'status_released' : 'status_expired');
|
||||
@endphp
|
||||
<tr wire:key="instance-block-{{ $block->uuid }}" class="border-b border-line last:border-0">
|
||||
<td class="py-2.5 pr-4 font-mono text-ink">{{ $block->ip }}</td>
|
||||
<td class="py-2.5 pr-4 font-mono text-xs text-muted">{{ $block->blocked_at->local()->isoFormat('DD.MM. HH:mm') }}</td>
|
||||
<td class="py-2.5 pr-4"><x-ui.badge :status="$bstatus">{{ __('admin.security_block.'.$blabel) }}</x-ui.badge></td>
|
||||
<td class="py-2.5 text-right">
|
||||
@if ($active)
|
||||
<button type="button"
|
||||
x-on:click="$dispatch('openModal', { component: 'admin.confirm-release-block', arguments: { uuid: '{{ $block->uuid }}' } })"
|
||||
class="rounded-md border border-line px-2.5 py-1.5 text-xs font-semibold text-muted hover:border-accent-border hover:text-accent-text">
|
||||
{{ __('admin.security_block.release') }}
|
||||
</button>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -279,6 +279,57 @@
|
|||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Sicherheitssperren dieses Hosts. Denselben Abschnitt (Zeilen + Knopf)
|
||||
zeigt die Instanz auf der Kundenseite, hinter instances.manage statt
|
||||
hosts.manage — siehe admin.security_block im Sprachfile. --}}
|
||||
@can('hosts.manage')
|
||||
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:110ms]">
|
||||
<h2 class="mb-3 text-sm font-semibold text-ink">
|
||||
{{ __('admin.security_block.title') }}
|
||||
<span class="ml-1 font-mono text-xs font-normal text-muted">{{ $securityBlocks->count() }}</span>
|
||||
</h2>
|
||||
@if ($securityBlocks->isEmpty())
|
||||
<p class="text-sm text-muted">{{ __('admin.security_block.empty') }}</p>
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-line text-left text-xs font-semibold text-muted">
|
||||
<th class="py-2 pr-4 font-semibold">{{ __('admin.security_block.col_ip') }}</th>
|
||||
<th class="py-2 pr-4 font-semibold">{{ __('admin.security_block.col_blocked') }}</th>
|
||||
<th class="py-2 pr-4 font-semibold">{{ __('admin.security_block.col_status') }}</th>
|
||||
<th class="py-2 text-right font-semibold">{{ __('admin.security_block.col_actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($securityBlocks as $block)
|
||||
@php
|
||||
$active = $block->released_at === null && $block->expires_at->isFuture();
|
||||
$bstatus = $active ? 'warning' : ($block->released_at ? 'active' : 'info');
|
||||
$blabel = $active ? 'status_active' : ($block->released_at ? 'status_released' : 'status_expired');
|
||||
@endphp
|
||||
<tr wire:key="host-block-{{ $block->uuid }}" class="border-b border-line last:border-0">
|
||||
<td class="py-2.5 pr-4 font-mono text-ink">{{ $block->ip }}</td>
|
||||
<td class="py-2.5 pr-4 font-mono text-xs text-muted">{{ $block->blocked_at->local()->isoFormat('DD.MM. HH:mm') }}</td>
|
||||
<td class="py-2.5 pr-4"><x-ui.badge :status="$bstatus">{{ __('admin.security_block.'.$blabel) }}</x-ui.badge></td>
|
||||
<td class="py-2.5 text-right">
|
||||
@if ($active)
|
||||
<button type="button"
|
||||
x-on:click="$dispatch('openModal', { component: 'admin.confirm-release-block', arguments: { uuid: '{{ $block->uuid }}' } })"
|
||||
class="rounded-md border border-line px-2.5 py-1.5 text-xs font-semibold text-muted hover:border-accent-border hover:text-accent-text">
|
||||
{{ __('admin.security_block.release') }}
|
||||
</button>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endcan
|
||||
|
||||
@if ($run && $run->status === 'failed')
|
||||
<div class="flex items-start gap-3 rounded-lg border border-danger-border bg-danger-bg p-4 animate-rise">
|
||||
<x-ui.icon name="alert-triangle" class="size-5 shrink-0 text-danger" />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
<div class="rounded-lg bg-surface p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-warning-bg text-warning">
|
||||
<x-ui.icon name="unlock" class="size-5" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-base font-semibold text-ink">{{ __('security.release_title') }}</h3>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('security.release_body', ['ip' => $ip]) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.cancel') }}</x-ui.button>
|
||||
<x-ui.button variant="primary" wire:click="confirm" wire:loading.attr="disabled">
|
||||
{{ __('security.release_confirm') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
<div class="space-y-6">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('security.blocks_title') }}</h1>
|
||||
<p class="mt-1 max-w-[65ch] text-sm text-muted">{{ __('security.blocks_lead') }}</p>
|
||||
</div>
|
||||
|
||||
{{-- Aktive Sperren --}}
|
||||
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<div class="border-b border-line px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-ink">{{ __('security.blocks_active') }}</h2>
|
||||
</div>
|
||||
@if ($active->isEmpty())
|
||||
<p class="px-5 py-6 text-sm text-muted">{{ __('security.blocks_empty') }}</p>
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-line bg-surface-2 text-left text-xs font-semibold text-muted">
|
||||
<th class="px-4 py-3 font-semibold">{{ __('security.col_ip') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('security.col_blocked') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('security.col_expires') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('security.col_attempts') }}</th>
|
||||
<th class="px-4 py-3 text-right font-semibold">{{ __('security.col_actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($active as $block)
|
||||
<tr wire:key="active-{{ $block->uuid }}" class="border-b border-line last:border-0 hover:bg-surface-hover">
|
||||
<td class="px-4 py-3 font-mono text-body">{{ $block->ip }}</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $block->blocked_at->local()->isoFormat('DD.MM. HH:mm') }}</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $block->expires_at->local()->isoFormat('DD.MM. HH:mm') }}</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $block->attempts }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
{{-- Oeffnet ConfirmReleaseBlock statt hier selbst
|
||||
freizugeben — Bestaetigung im Modal, nie ein
|
||||
nativer Browser-Dialog (R23). --}}
|
||||
<button type="button"
|
||||
x-on:click="$dispatch('openModal', { component: 'confirm-release-block', arguments: { uuid: '{{ $block->uuid }}' } })"
|
||||
class="rounded-md border border-line px-2.5 py-1.5 text-xs font-semibold text-muted hover:border-accent-border hover:text-accent-text">
|
||||
<x-ui.icon name="unlock" class="size-4" />{{ __('security.release') }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Fruehere Sperren: abgelaufen von selbst oder vorzeitig aufgehoben. --}}
|
||||
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<div class="border-b border-line px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-ink">{{ __('security.blocks_history') }}</h2>
|
||||
</div>
|
||||
@if ($history->isEmpty())
|
||||
<p class="px-5 py-6 text-sm text-muted">{{ __('security.blocks_history_empty') }}</p>
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-line bg-surface-2 text-left text-xs font-semibold text-muted">
|
||||
<th class="px-4 py-3 font-semibold">{{ __('security.col_ip') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('security.col_blocked') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('security.col_status') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($history as $block)
|
||||
<tr wire:key="history-{{ $block->uuid }}" class="border-b border-line last:border-0">
|
||||
<td class="px-4 py-3 font-mono text-body">{{ $block->ip }}</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $block->blocked_at->local()->isoFormat('DD.MM. HH:mm') }}</td>
|
||||
<td class="px-4 py-3">
|
||||
@if ($block->released_at)
|
||||
<x-ui.badge status="active">{{ __('security.status_released') }}</x-ui.badge>
|
||||
@else
|
||||
<x-ui.badge status="info">{{ __('security.status_expired') }}</x-ui.badge>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -23,6 +23,7 @@ use App\Livewire\CustomDomain;
|
|||
use App\Livewire\Dashboard;
|
||||
use App\Livewire\Invoices;
|
||||
use App\Livewire\Order;
|
||||
use App\Livewire\Security;
|
||||
use App\Livewire\Support;
|
||||
use App\Livewire\Users;
|
||||
use App\Models\Customer;
|
||||
|
|
@ -354,10 +355,9 @@ $portal = function () {
|
|||
Route::get('/backups', Backups::class)->name('backups');
|
||||
Route::get('/invoices', Invoices::class)->name('invoices');
|
||||
|
||||
// Platzhalter fuer Aufgabe 6, die hier die eigentliche Sicherheitsseite
|
||||
// (Sperrliste der eigenen Instanzen) anlegt. Ohne einen benannten
|
||||
// 'portal.security' bricht schon heute MailPreviewTest, weil
|
||||
// SecurityBlockMail::content() dorthin verlinkt (route('portal.security')).
|
||||
// Die Sperrliste der eigenen Instanzen — das Ziel, auf das die Mail aus
|
||||
// BlockAddress verlinkt (SecurityBlockMail::content() ruft
|
||||
// route('portal.security')).
|
||||
//
|
||||
// NICHT '/security': die öffentliche Seite (oben, Zeile ~254) meldet
|
||||
// denselben Pfad an, und $appHost/$siteHost sind hier beide leer (jede
|
||||
|
|
@ -366,7 +366,9 @@ $portal = function () {
|
|||
// Routen über Methode+Domain+URI, NICHT über den Namen. Zwei GET-Routen
|
||||
// auf demselben Pfad ohne Domain überschreiben sich also gegenseitig,
|
||||
// unabhängig vom Namen — die zweite gewinnt lautlos, ganz ohne Fehler.
|
||||
Route::get('/security-blocks', fn () => redirect()->route('dashboard'))->name('portal.security');
|
||||
// Der PFAD bleibt deshalb '/security-blocks', der NAME 'portal.security'
|
||||
// (die vertraglich fixierte Schnittstelle, die die Mail benutzt).
|
||||
Route::get('/security-blocks', Security::class)->name('portal.security');
|
||||
|
||||
// The customer's own invoice as a PDF, rendered on demand from the frozen
|
||||
// document — the same renderer the console uses, because there is only one
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@ it('moves every permission and role to the operator guard, leaving none behind',
|
|||
expect(Permission::where('guard_name', 'web')->count())->toBe(0)
|
||||
->and(Role::where('guard_name', 'web')->count())->toBe(0)
|
||||
// 17 from the original seed, plus customers.grant_plan,
|
||||
// instances.restart and dpa.manage — the later ones created straight
|
||||
// onto this guard, because since this migration `web` is where a
|
||||
// permission goes to match nobody at all.
|
||||
->and(Permission::where('guard_name', 'operator')->count())->toBe(20)
|
||||
// instances.restart, dpa.manage and instances.manage — the later
|
||||
// ones created straight onto this guard, because since this
|
||||
// migration `web` is where a permission goes to match nobody at all.
|
||||
->and(Permission::where('guard_name', 'operator')->count())->toBe(21)
|
||||
->and(Role::where('guard_name', 'operator')->count())->toBe(6);
|
||||
});
|
||||
|
||||
|
|
@ -159,7 +159,7 @@ it('preflights every customer conflict before mutating anything, listing all of
|
|||
// server, which is the whole reason this has to be checked up front.
|
||||
// Every capability this installation has, pushed back to `web` above to
|
||||
// stage the pre-migration shape — the figure grows with each new one.
|
||||
expect(Permission::where('guard_name', 'web')->count())->toBe(20)
|
||||
expect(Permission::where('guard_name', 'web')->count())->toBe(21)
|
||||
->and(Permission::where('guard_name', 'operator')->count())->toBe(0)
|
||||
->and(Role::where('guard_name', 'web')->count())->toBe(6)
|
||||
->and(Role::where('guard_name', 'operator')->count())->toBe(0)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
use App\Mail\InvoiceMail;
|
||||
use App\Mail\ResetPasswordMail;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Order;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\IssueInvoice;
|
||||
use App\Services\Mail\MailLane;
|
||||
use App\Support\CompanyProfile;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
/**
|
||||
* Die Spur entsteht beim Einreihen, nicht beim Senden.
|
||||
*
|
||||
* Der Arbeiter liest die Schlangen in der Reihenfolge ihrer Dringlichkeit —
|
||||
* deshalb steht ein Kennwort-Zurücksetzen nie hinter zweihundert Rechnungen.
|
||||
* Das trägt nur, wenn die Rechnung wirklich in einer anderen Schlange liegt.
|
||||
*
|
||||
* Werkzeug: Queue::fake() statt Mail::fake(). Mail::fake() ersetzt den
|
||||
* ganzen Mailer — der Aufruf, der onQueue() setzt (Mailer::queue(), der
|
||||
* dann Mailable::queue() aufruft), fände dann nie statt, und der Test würde
|
||||
* grün, ohne die Spur je berechnet zu haben. Queue::fake() lässt genau
|
||||
* diesen Aufruf laufen und fängt erst dahinter ab, an der Queue-Fabrik —
|
||||
* die Stelle, an der pushOn() tatsächlich ankommt.
|
||||
*/
|
||||
function invoiceForTest(): Invoice
|
||||
{
|
||||
CompanyProfile::put([
|
||||
'name' => 'CluPilot Cloud e.U.',
|
||||
'address' => 'Dreherstraße 66/1/8',
|
||||
'postcode' => '1110',
|
||||
'city' => 'Wien',
|
||||
'vat_id' => 'ATU00000000',
|
||||
]);
|
||||
|
||||
$customer = Customer::factory()->create(['name' => 'Muster GmbH']);
|
||||
$orders = collect([Order::factory()->create([
|
||||
'customer_id' => $customer->id, 'amount_cents' => 1900, 'currency' => 'EUR', 'status' => 'paid',
|
||||
])]);
|
||||
|
||||
return app(IssueInvoice::class)->forOrders($customer, $orders);
|
||||
}
|
||||
|
||||
it('reiht jede Mail in die Schlange ihrer Spur ein', function () {
|
||||
Queue::fake();
|
||||
|
||||
Mail::to('kunde@example.test')->queue(new InvoiceMail(invoiceForTest(), 'Muster'));
|
||||
|
||||
Queue::assertPushedOn(MailLane::CALM, Illuminate\Mail\SendQueuedMailable::class);
|
||||
});
|
||||
|
||||
it('reiht eine Direkt-Mail in die Direkt-Spur ein', function () {
|
||||
Queue::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
Mail::to('kunde@example.test')->queue(new ResetPasswordMail($user, 'https://example.test/reset', 60));
|
||||
|
||||
Queue::assertPushedOn(MailLane::DIRECT, Illuminate\Mail\SendQueuedMailable::class);
|
||||
});
|
||||
|
||||
it('folgt einer verschobenen Zuordnung', function () {
|
||||
Queue::fake();
|
||||
MailLane::assign(InvoiceMail::class, MailLane::URGENT);
|
||||
|
||||
Mail::to('kunde@example.test')->queue(new InvoiceMail(invoiceForTest(), 'Muster'));
|
||||
|
||||
Queue::assertPushedOn(MailLane::URGENT, Illuminate\Mail\SendQueuedMailable::class);
|
||||
});
|
||||
|
||||
it('lässt keine Mailklasse ohne Spur', function () {
|
||||
$missing = collect(glob(app_path('Mail/*.php')))
|
||||
->map(fn (string $path) => 'App\\Mail\\'.basename($path, '.php'))
|
||||
->reject(fn (string $class) => in_array(App\Mail\Concerns\RidesALane::class, class_uses_recursive($class), true))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect($missing)->toBe([]);
|
||||
});
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Admin\ConfirmReleaseBlock;
|
||||
use App\Livewire\Admin\CustomerDetail;
|
||||
use App\Livewire\Admin\HostDetail;
|
||||
use App\Livewire\Admin\Overview;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Host;
|
||||
use App\Models\Instance;
|
||||
use App\Models\Operator;
|
||||
use App\Models\SecurityBlock;
|
||||
use Livewire\Livewire;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
/**
|
||||
* Task 6, Schritt 4: dieselben Sperr-Zeilen in der Konsole, hinter der
|
||||
* jeweiligen Berechtigung — hosts.manage auf der Host-Detailseite,
|
||||
* instances.manage auf der Kundenseite (Instanz eines Kunden). Beide teilen
|
||||
* sich EIN Modal (App\Livewire\Admin\ConfirmReleaseBlock), das nach dem
|
||||
* Subjekt der Sperre autorisiert.
|
||||
*/
|
||||
it('zeigt einem Betreiber mit hosts.manage die Sperre eines Hosts und laesst ihn sie aufheben', function () {
|
||||
$host = Host::factory()->active()->create();
|
||||
$block = SecurityBlock::factory()->forHost($host)->create(['ip' => '203.0.113.20']);
|
||||
|
||||
Livewire::actingAs(operator('Admin'), 'operator')
|
||||
->test(HostDetail::class, ['host' => $host])
|
||||
->assertSee('203.0.113.20')
|
||||
->call('onSecurityBlockReleaseConfirmed', $block->uuid)
|
||||
->assertDispatched('notify');
|
||||
|
||||
expect($block->fresh()->released_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('verweigert das Aufheben einer Host-Sperre ohne hosts.manage', function () {
|
||||
$host = Host::factory()->active()->create();
|
||||
$block = SecurityBlock::factory()->forHost($host)->create();
|
||||
|
||||
Livewire::actingAs(operator('Read-only'), 'operator')
|
||||
->test(HostDetail::class, ['host' => $host])
|
||||
->call('onSecurityBlockReleaseConfirmed', $block->uuid)
|
||||
->assertForbidden();
|
||||
|
||||
expect($block->fresh()->released_at)->toBeNull();
|
||||
});
|
||||
|
||||
it('laesst eine Host-Sperre nicht ueber die Instanz-Seite eines Kunden aufheben', function () {
|
||||
// Zwei getrennte Subjekte, zwei getrennte Pruefungen: CustomerDetail darf
|
||||
// nur Sperren an EIGENEN Instanzen aufheben, nie eine Host-Sperre — auch
|
||||
// nicht mit instances.manage.
|
||||
$host = Host::factory()->active()->create();
|
||||
$block = SecurityBlock::factory()->forHost($host)->create();
|
||||
$customer = Customer::factory()->create();
|
||||
Instance::factory()->for($customer)->create();
|
||||
|
||||
Livewire::actingAs(operator('Admin'), 'operator')
|
||||
->test(CustomerDetail::class, ['uuid' => $customer->uuid])
|
||||
->call('onSecurityBlockReleaseConfirmed', $block->uuid);
|
||||
|
||||
expect($block->fresh()->released_at)->toBeNull();
|
||||
});
|
||||
|
||||
it('zeigt einem Betreiber mit instances.manage die Sperre einer Kunden-Instanz und laesst ihn sie aufheben', function () {
|
||||
$customer = Customer::factory()->create();
|
||||
$instance = Instance::factory()->for($customer)->create();
|
||||
$block = SecurityBlock::factory()->for($instance)->create(['ip' => '198.51.100.42']);
|
||||
|
||||
Livewire::actingAs(operator('Support'), 'operator')
|
||||
->test(CustomerDetail::class, ['uuid' => $customer->uuid])
|
||||
->call('onSecurityBlockReleaseConfirmed', $block->uuid)
|
||||
->assertDispatched('notify');
|
||||
|
||||
expect($block->fresh()->released_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('verweigert das Aufheben einer Instanz-Sperre ohne instances.manage', function () {
|
||||
// Keine der fuenf mitgelieferten Rollen eignet sich hier: Owner, Admin und
|
||||
// Support tragen instances.manage genau wie customers.manage (dieselbe
|
||||
// Begruendung wie bei instances.restart), Billing und Read-only haben
|
||||
// nicht einmal customers.manage und koennten die Seite gar nicht oeffnen.
|
||||
// Also die eigene, engere Rolle, die die Rollen-Konsole genau dafuer
|
||||
// vorsieht (Muster: tests/Feature/Admin/RoleManagementTest.php).
|
||||
$role = Role::findOrCreate('Kundendienst-eng', 'operator');
|
||||
$role->syncPermissions(['console.view', 'customers.manage']);
|
||||
$support = Operator::factory()->create(['password' => 'password']);
|
||||
$support->assignRole($role);
|
||||
|
||||
$customer = Customer::factory()->create();
|
||||
$instance = Instance::factory()->for($customer)->create();
|
||||
$block = SecurityBlock::factory()->for($instance)->create();
|
||||
|
||||
Livewire::actingAs($support, 'operator')
|
||||
->test(CustomerDetail::class, ['uuid' => $customer->uuid])
|
||||
->call('onSecurityBlockReleaseConfirmed', $block->uuid)
|
||||
->assertForbidden();
|
||||
|
||||
expect($block->fresh()->released_at)->toBeNull();
|
||||
});
|
||||
|
||||
it('autorisiert das geteilte Konsolen-Modal nach dem Subjekt der Sperre', function () {
|
||||
$host = Host::factory()->active()->create();
|
||||
$hostBlock = SecurityBlock::factory()->forHost($host)->create();
|
||||
|
||||
$customer = Customer::factory()->create();
|
||||
$instance = Instance::factory()->for($customer)->create();
|
||||
$instanceBlock = SecurityBlock::factory()->for($instance)->create();
|
||||
|
||||
// hosts.manage oeffnet die Host-Sperre, aber nicht die Instanz-Sperre.
|
||||
Livewire::actingAs(operator('Admin'), 'operator')
|
||||
->test(ConfirmReleaseBlock::class, ['uuid' => $hostBlock->uuid])
|
||||
->assertSee($hostBlock->ip);
|
||||
|
||||
Livewire::actingAs(operator('Read-only'), 'operator')
|
||||
->test(ConfirmReleaseBlock::class, ['uuid' => $instanceBlock->uuid])
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('zeigt auf der Uebersicht einen Hinweis, solange irgendwo eine Sperre aktiv ist', function () {
|
||||
Livewire::actingAs(operator('Owner'), 'operator')
|
||||
->test(Overview::class)
|
||||
->assertDontSee(__('admin.notice.security_blocks', ['n' => 1]));
|
||||
|
||||
$instance = Instance::factory()->create();
|
||||
SecurityBlock::factory()->for($instance)->create();
|
||||
|
||||
Livewire::actingAs(operator('Owner'), 'operator')
|
||||
->test(Overview::class)
|
||||
->assertSee(__('admin.notice.security_blocks', ['n' => 1]));
|
||||
});
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
<?php // tests/Feature/Security/SecurityPageTest.php
|
||||
|
||||
use App\Livewire\Security;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Instance;
|
||||
use App\Models\SecurityBlock;
|
||||
use Livewire\Livewire;
|
||||
|
||||
// Abweichung vom Auftragszettel: Customer::factory()->create() verknuepft
|
||||
// keinen User (user_id bleibt NULL, siehe CustomerFactory) — jeder bestehende
|
||||
// Test, der einen Kunden anmeldet, ruft dafuer ensureUser() (Muster:
|
||||
// tests/Feature/CustomerTwoFactorTest.php::portalUser()). Der RUECKGABEWERT
|
||||
// wird gebraucht, nicht $customer->user danach: ensureUser() selbst liest
|
||||
// $this->user als ersten Schritt ("if ($this->user) return $this->user;"),
|
||||
// bevor user_id gesetzt ist — das cacht die Beziehung als null, und ein
|
||||
// spaeteres update() raeumt einen bereits geladenen Beziehungs-Cache nicht
|
||||
// weg. $customer->user bliebe also null, obwohl user_id laengst gesetzt ist.
|
||||
|
||||
it('zeigt dem Inhaber die Sperren seiner eigenen Instanz', function () {
|
||||
$customer = Customer::factory()->create();
|
||||
$user = $customer->ensureUser();
|
||||
$instance = Instance::factory()->for($customer)->create();
|
||||
$block = SecurityBlock::factory()->for($instance)->create(['ip' => '203.0.113.7']);
|
||||
|
||||
Livewire::actingAs($user)->test(Security::class)->assertSee('203.0.113.7');
|
||||
});
|
||||
|
||||
it('zeigt einem Inhaber die Sperren eines FREMDEN Kunden nicht', function () {
|
||||
$meine = Customer::factory()->create();
|
||||
$user = $meine->ensureUser();
|
||||
$fremde = Instance::factory()->create();
|
||||
SecurityBlock::factory()->for($fremde)->create(['ip' => '198.51.100.9']);
|
||||
|
||||
Livewire::actingAs($user)->test(Security::class)->assertDontSee('198.51.100.9');
|
||||
});
|
||||
|
||||
it('laesst einen Inhaber eine fremde Sperre nicht aufheben', function () {
|
||||
$meine = Customer::factory()->create();
|
||||
$user = $meine->ensureUser();
|
||||
$fremd = SecurityBlock::factory()->for(Instance::factory()->create())->create();
|
||||
|
||||
Livewire::actingAs($user)->test(Security::class)
|
||||
->call('onReleaseConfirmed', $fremd->uuid)
|
||||
->assertForbidden();
|
||||
|
||||
expect($fremd->fresh()->released_at)->toBeNull();
|
||||
});
|
||||
|
||||
it('hebt eine eigene Sperre auf und traegt ein, wer es war', function () {
|
||||
$customer = Customer::factory()->create();
|
||||
$user = $customer->ensureUser();
|
||||
$instance = Instance::factory()->for($customer)->create();
|
||||
$block = SecurityBlock::factory()->for($instance)->create();
|
||||
|
||||
Livewire::actingAs($user)->test(Security::class)
|
||||
->call('onReleaseConfirmed', $block->uuid);
|
||||
|
||||
expect($block->fresh()->released_at)->not->toBeNull()
|
||||
->and($block->fresh()->released_by_id)->toBe($user->id);
|
||||
});
|
||||
Loading…
Reference in New Issue