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
parent
718e8568c1
commit
ae1f78f534
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in New Issue