diff --git a/app/Livewire/Admin/ConfirmReleaseBlock.php b/app/Livewire/Admin/ConfirmReleaseBlock.php new file mode 100644 index 0000000..569a394 --- /dev/null +++ b/app/Livewire/Admin/ConfirmReleaseBlock.php @@ -0,0 +1,51 @@ +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'); + } +} diff --git a/app/Livewire/Admin/CustomerDetail.php b/app/Livewire/Admin/CustomerDetail.php index a43cd40..ea52c60 100644 --- a/app/Livewire/Admin/CustomerDetail.php +++ b/app/Livewire/Admin/CustomerDetail.php @@ -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') diff --git a/app/Livewire/Admin/HostDetail.php b/app/Livewire/Admin/HostDetail.php index a8fc908..88cc3d7 100644 --- a/app/Livewire/Admin/HostDetail.php +++ b/app/Livewire/Admin/HostDetail.php @@ -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. diff --git a/app/Livewire/Admin/Overview.php b/app/Livewire/Admin/Overview.php index b3dd5bb..6fa29ec 100644 --- a/app/Livewire/Admin/Overview.php +++ b/app/Livewire/Admin/Overview.php @@ -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 diff --git a/app/Livewire/ConfirmReleaseBlock.php b/app/Livewire/ConfirmReleaseBlock.php new file mode 100644 index 0000000..c94a1ee --- /dev/null +++ b/app/Livewire/ConfirmReleaseBlock.php @@ -0,0 +1,52 @@ +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'); + } +} diff --git a/app/Livewire/Security.php b/app/Livewire/Security.php new file mode 100644 index 0000000..a0b6dfd --- /dev/null +++ b/app/Livewire/Security.php @@ -0,0 +1,70 @@ +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(), + ]); + } +} diff --git a/app/Mail/CloudResumedMail.php b/app/Mail/CloudResumedMail.php index 57537a3..66ffd7c 100644 --- a/app/Mail/CloudResumedMail.php +++ b/app/Mail/CloudResumedMail.php @@ -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, diff --git a/app/Mail/CloudSuspendedMail.php b/app/Mail/CloudSuspendedMail.php index 5849d8d..f2d3b01 100644 --- a/app/Mail/CloudSuspendedMail.php +++ b/app/Mail/CloudSuspendedMail.php @@ -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, diff --git a/app/Mail/Concerns/RidesALane.php b/app/Mail/Concerns/RidesALane.php new file mode 100644 index 0000000..282d05c --- /dev/null +++ b/app/Mail/Concerns/RidesALane.php @@ -0,0 +1,27 @@ +onQueue(MailLane::for(static::class)); + + return parent::queue($queue); + } +} diff --git a/app/Mail/ContactRequestMail.php b/app/Mail/ContactRequestMail.php index af17457..0d3c081 100644 --- a/app/Mail/ContactRequestMail.php +++ b/app/Mail/ContactRequestMail.php @@ -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 diff --git a/app/Mail/DormantAccountWarningMail.php b/app/Mail/DormantAccountWarningMail.php index 537a150..efdc418 100644 --- a/app/Mail/DormantAccountWarningMail.php +++ b/app/Mail/DormantAccountWarningMail.php @@ -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) { diff --git a/app/Mail/DunningNoticeMail.php b/app/Mail/DunningNoticeMail.php index bdcfe53..037de2b 100644 --- a/app/Mail/DunningNoticeMail.php +++ b/app/Mail/DunningNoticeMail.php @@ -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 diff --git a/app/Mail/InvoiceMail.php b/app/Mail/InvoiceMail.php index fbeba21..a897639 100644 --- a/app/Mail/InvoiceMail.php +++ b/app/Mail/InvoiceMail.php @@ -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) { diff --git a/app/Mail/MaintenanceAnnouncementMail.php b/app/Mail/MaintenanceAnnouncementMail.php index 8dbc7bb..4002564 100644 --- a/app/Mail/MaintenanceAnnouncementMail.php +++ b/app/Mail/MaintenanceAnnouncementMail.php @@ -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; diff --git a/app/Mail/MaintenanceCancelledMail.php b/app/Mail/MaintenanceCancelledMail.php index 05ac6ff..b83887e 100644 --- a/app/Mail/MaintenanceCancelledMail.php +++ b/app/Mail/MaintenanceCancelledMail.php @@ -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; diff --git a/app/Mail/NewDeviceSignInMail.php b/app/Mail/NewDeviceSignInMail.php index 2fb26e7..7634ec1 100644 --- a/app/Mail/NewDeviceSignInMail.php +++ b/app/Mail/NewDeviceSignInMail.php @@ -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, diff --git a/app/Mail/OperatorMessageMail.php b/app/Mail/OperatorMessageMail.php index d8df2f1..7016784 100644 --- a/app/Mail/OperatorMessageMail.php +++ b/app/Mail/OperatorMessageMail.php @@ -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, diff --git a/app/Mail/OrderConfirmationMail.php b/app/Mail/OrderConfirmationMail.php index 1a6882e..0e67669 100644 --- a/app/Mail/OrderConfirmationMail.php +++ b/app/Mail/OrderConfirmationMail.php @@ -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) { diff --git a/app/Mail/ResetPasswordMail.php b/app/Mail/ResetPasswordMail.php index d4137b8..37828cd 100644 --- a/app/Mail/ResetPasswordMail.php +++ b/app/Mail/ResetPasswordMail.php @@ -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) { diff --git a/app/Mail/SecurityBlockMail.php b/app/Mail/SecurityBlockMail.php index 6a2b3d7..72c38a5 100644 --- a/app/Mail/SecurityBlockMail.php +++ b/app/Mail/SecurityBlockMail.php @@ -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) { diff --git a/app/Mail/VerifyEmailMail.php b/app/Mail/VerifyEmailMail.php index f623ded..af0d525 100644 --- a/app/Mail/VerifyEmailMail.php +++ b/app/Mail/VerifyEmailMail.php @@ -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) { diff --git a/app/Support/Navigation.php b/app/Support/Navigation.php index f6b61ea..9bae82a 100644 --- a/app/Support/Navigation.php +++ b/app/Support/Navigation.php @@ -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], diff --git a/database/migrations/2026_08_03_150000_add_instances_manage_capability.php b/database/migrations/2026_08_03_150000_add_instances_manage_capability.php new file mode 100644 index 0000000..dee876e --- /dev/null +++ b/database/migrations/2026_08_03_150000_add_instances_manage_capability.php @@ -0,0 +1,47 @@ +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(); + } +}; diff --git a/lang/de/admin.php b/lang/de/admin.php index 103bd4d..3b4d39c 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -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.', diff --git a/lang/de/dashboard.php b/lang/de/dashboard.php index 6c75aec..3ad692c 100644 --- a/lang/de/dashboard.php +++ b/lang/de/dashboard.php @@ -141,6 +141,7 @@ return [ 'domain' => 'Eigene Domain', 'users' => 'Benutzer', 'backups' => 'Backups', + 'security' => 'Sicherheit', 'invoices' => 'Rechnungen', 'billing' => 'Paket & Addons', 'settings' => 'Einstellungen', diff --git a/lang/de/roles.php b/lang/de/roles.php index 268318f..e9d1147 100644 --- a/lang/de/roles.php +++ b/lang/de/roles.php @@ -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', diff --git a/lang/de/security.php b/lang/de/security.php index c8ed2f9..d4e8fc8 100644 --- a/lang/de/security.php +++ b/lang/de/security.php @@ -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.', ]; diff --git a/lang/en/admin.php b/lang/en/admin.php index 65eca87..2bfb723 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -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.', diff --git a/lang/en/dashboard.php b/lang/en/dashboard.php index 213e2b2..7ba9f09 100644 --- a/lang/en/dashboard.php +++ b/lang/en/dashboard.php @@ -141,6 +141,7 @@ return [ 'domain' => 'Your domain', 'users' => 'Users', 'backups' => 'Backups', + 'security' => 'Security', 'invoices' => 'Invoices', 'billing' => 'Plan & add-ons', 'settings' => 'Settings', diff --git a/lang/en/roles.php b/lang/en/roles.php index cba9a4e..e68ae2c 100644 --- a/lang/en/roles.php +++ b/lang/en/roles.php @@ -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', diff --git a/lang/en/security.php b/lang/en/security.php index e6fd425..6062240 100644 --- a/lang/en/security.php +++ b/lang/en/security.php @@ -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.', ]; diff --git a/resources/views/livewire/admin/confirm-release-block.blade.php b/resources/views/livewire/admin/confirm-release-block.blade.php new file mode 100644 index 0000000..8705027 --- /dev/null +++ b/resources/views/livewire/admin/confirm-release-block.blade.php @@ -0,0 +1,17 @@ +
{{ __('admin.security_block.release_body', ['ip' => $ip]) }}
+{{ __('admin.security_block.empty') }}
+ @else +| {{ __('admin.security_block.col_ip') }} | +{{ __('admin.security_block.col_blocked') }} | +{{ __('admin.security_block.col_status') }} | +{{ __('admin.security_block.col_actions') }} | +
|---|---|---|---|
| {{ $block->ip }} | +{{ $block->blocked_at->local()->isoFormat('DD.MM. HH:mm') }} | ++ @if ($active) + + @endif + | +
{{ __('admin.security_block.empty') }}
+ @else +| {{ __('admin.security_block.col_ip') }} | +{{ __('admin.security_block.col_blocked') }} | +{{ __('admin.security_block.col_status') }} | +{{ __('admin.security_block.col_actions') }} | +
|---|---|---|---|
| {{ $block->ip }} | +{{ $block->blocked_at->local()->isoFormat('DD.MM. HH:mm') }} | ++ @if ($active) + + @endif + | +
{{ __('security.release_body', ['ip' => $ip]) }}
+{{ __('security.blocks_lead') }}
+{{ __('security.blocks_empty') }}
+ @else +| {{ __('security.col_ip') }} | +{{ __('security.col_blocked') }} | +{{ __('security.col_expires') }} | +{{ __('security.col_attempts') }} | +{{ __('security.col_actions') }} | +
|---|---|---|---|---|
| {{ $block->ip }} | +{{ $block->blocked_at->local()->isoFormat('DD.MM. HH:mm') }} | +{{ $block->expires_at->local()->isoFormat('DD.MM. HH:mm') }} | +{{ $block->attempts }} | ++ {{-- Oeffnet ConfirmReleaseBlock statt hier selbst + freizugeben — Bestaetigung im Modal, nie ein + nativer Browser-Dialog (R23). --}} + + | +
{{ __('security.blocks_history_empty') }}
+ @else +| {{ __('security.col_ip') }} | +{{ __('security.col_blocked') }} | +{{ __('security.col_status') }} | +
|---|---|---|
| {{ $block->ip }} | +{{ $block->blocked_at->local()->isoFormat('DD.MM. HH:mm') }} | +
+ @if ($block->released_at)
+ |
+