fix(admin): shown-once temp password for invited staff; suppress announcements for cancelled windows at send time

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 16:23:21 +02:00
parent 718e8568c1
commit ae1f78f534
7 changed files with 63 additions and 1 deletions

View File

@ -37,6 +37,11 @@ class Settings extends Component
#[Validate('required|in:Owner,Admin,Support,Billing,Read-only')]
public string $staffRole = 'Support';
/** Shown once after inviting, since email delivery is still mocked. */
public ?string $invitedEmail = null;
public ?string $invitedPassword = null;
public function mount(): void
{
$this->name = auth()->user()->name;
@ -85,14 +90,20 @@ class Settings extends Component
return;
}
// Email/password-setup delivery is still mocked, so generate a temporary
// password and surface it once to the Owner to share securely — the
// account is usable immediately (a proper invite link follows with mail).
$temp = Str::password(14);
$user = User::create([
'name' => $data['staffName'],
'email' => $data['staffEmail'],
'password' => Hash::make(Str::random(40)), // invite/reset delivery is mocked
'password' => Hash::make($temp),
'is_admin' => true,
]);
$user->assignRole($data['staffRole']);
$this->invitedEmail = $data['staffEmail'];
$this->invitedPassword = $temp;
$this->reset('staffName', 'staffEmail');
$this->staffRole = 'Support';
$this->dispatch('notify', message: __('admin_settings.staff_invited'));

View File

@ -3,6 +3,7 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MaintenanceNotification extends Model
{
@ -12,4 +13,9 @@ class MaintenanceNotification extends Model
{
return ['sent_at' => 'datetime'];
}
public function window(): BelongsTo
{
return $this->belongsTo(MaintenanceWindow::class, 'maintenance_window_id');
}
}

View File

@ -16,6 +16,7 @@ use App\Services\Traefik\TraefikWriter;
use App\Services\Wireguard\LocalWireguardHub;
use App\Services\Wireguard\WireguardHub;
use App\Models\MaintenanceNotification;
use Illuminate\Mail\Events\MessageSending;
use Illuminate\Mail\Events\MessageSent;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
@ -45,6 +46,22 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
// Suppress a queued maintenance announcement if its window was cancelled
// before the job runs — otherwise a delayed announcement could arrive for
// an already-cancelled window. Returning false aborts the send.
Event::listen(MessageSending::class, function (MessageSending $event) {
$header = $event->message->getHeaders()->get('X-CP-Notification');
if ($header === null) {
return null;
}
$notification = MaintenanceNotification::query()->with('window')->find((int) $header->getBodyAsString());
if ($notification?->event === 'announcement' && $notification->window?->state === 'cancelled') {
return false; // window cancelled meanwhile — do not deliver
}
return null;
});
// Stamp a maintenance-notification ledger row as delivered only once the
// mail is actually sent (the X-CP-Notification header carries the id).
// Until then sent_at stays null → the row is a retryable marker.

View File

@ -17,6 +17,9 @@ return [
'invite' => 'Einladen',
'invite_hint' => 'Der Zugang wird angelegt; die Einladungs-/Passwort-Zustellung folgt mit der E-Mail-Anbindung.',
'staff_invited' => 'Mitarbeiter angelegt.',
'temp_title' => 'Zugangsdaten (einmalig sichtbar)',
'temp_hint' => 'Teilen Sie diese Daten sicher mit dem Mitarbeiter. Beim späteren E-Mail-Setup wird ein Einladungslink versendet.',
'temp_password' => 'Temporäres Passwort',
'col_person' => 'Person',
'col_actions' => 'Aktionen',

View File

@ -17,6 +17,9 @@ return [
'invite' => 'Invite',
'invite_hint' => 'The account is created; invitation/password delivery follows with the email integration.',
'staff_invited' => 'Staff member created.',
'temp_title' => 'Credentials (shown once)',
'temp_hint' => 'Share these securely with the staff member. A proper invitation link follows with the email integration.',
'temp_password' => 'Temporary password',
'col_person' => 'Person',
'col_actions' => 'Actions',

View File

@ -54,6 +54,17 @@
</form>
<p class="text-xs text-faint">{{ __('admin_settings.invite_hint') }}</p>
@if ($invitedPassword)
<div class="rounded-lg border border-warning-border bg-warning-bg p-4">
<p class="text-sm font-semibold text-ink">{{ __('admin_settings.temp_title') }}</p>
<p class="mt-1 text-xs text-body">{{ __('admin_settings.temp_hint') }}</p>
<dl class="mt-2 space-y-1 font-mono text-sm text-ink">
<div class="flex gap-2"><dt class="text-muted">{{ __('admin_settings.email') }}:</dt><dd>{{ $invitedEmail }}</dd></div>
<div class="flex gap-2"><dt class="text-muted">{{ __('admin_settings.temp_password') }}:</dt><dd class="select-all">{{ $invitedPassword }}</dd></div>
</dl>
</div>
@endif
{{-- Staff list --}}
<div class="overflow-hidden rounded-xl border border-line">
<div class="overflow-x-auto">

View File

@ -25,6 +25,17 @@ it('lets an Owner invite staff with a role', function () {
expect($sam)->not->toBeNull()->and($sam->hasRole('Support'))->toBeTrue();
});
it('surfaces a usable temporary password for the invited staff', function () {
$c = Livewire::actingAs(operator('Owner'))->test(Settings::class)
->set('staffName', 'Ivy')->set('staffEmail', 'ivy@ops.test')->set('staffRole', 'Support')
->call('inviteStaff');
$temp = $c->get('invitedPassword');
expect($temp)->not->toBeNull();
$ivy = User::query()->where('email', 'ivy@ops.test')->first();
expect(Illuminate\Support\Facades\Hash::check($temp, $ivy->password))->toBeTrue();
});
it('forbids a non-Owner from managing staff', function () {
Livewire::actingAs(operator('Admin'))->test(Settings::class)
->set('staffName', 'X')->set('staffEmail', 'x@ops.test')->set('staffRole', 'Support')