From 935c9ae6ace0cde99a21bcaeeaf37f7489069a3a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 07:49:07 +0200 Subject: [PATCH] Let people change their own password, and a location be switched off while editing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gaps, all of the "half-built" kind. Nobody could change a password. Fortify's updatePasswords feature was switched off, so an account created with a generated password kept it until someone opened a shell on the server — including the owner's own. Both settings pages have the form now, sharing one concern, because two copies of a password rule is how one of them ends up weaker. It goes through Fortify's own action rather than hashing here, and the current password is required: an unlocked machine should not be two keystrokes away from locking its owner out. The datacenter edit form had no active switch. The column and the scope existed, and the list page has a toggle — but the form you open to change the thing did not offer the one lifecycle action a location actually needs. Switching one off now says what it does NOT do: existing hosts keep running. Once, on the actual transition, and as the only message — the generic "saved" toast would otherwise replace it, and an already-inactive location would have announced its own deactivation every time its name was edited. The staff table's actions cell is empty for your own row, because you cannot revoke yourself. With a one-person team the column was therefore always blank and read as broken rather than as a rule. Co-Authored-By: Claude Opus 5 --- app/Livewire/Admin/EditDatacenter.php | 25 ++++++- app/Livewire/Admin/Settings.php | 2 + app/Livewire/Concerns/ChangesOwnPassword.php | 67 +++++++++++++++++++ app/Livewire/Settings.php | 2 + config/fortify.php | 5 +- lang/de/admin_settings.php | 9 +++ lang/de/datacenters.php | 3 + lang/en/admin_settings.php | 9 +++ lang/en/datacenters.php | 3 + .../livewire/admin/edit-datacenter.blade.php | 7 ++ .../views/livewire/admin/settings.blade.php | 29 ++++++++ resources/views/livewire/settings.blade.php | 23 +++++++ tests/Feature/Admin/ConsoleNetworkTest.php | 33 +++++++++ tests/Feature/Admin/DatacenterTest.php | 31 +++++++++ tests/Feature/Admin/UpdateButtonTest.php | 4 +- 15 files changed, 249 insertions(+), 3 deletions(-) create mode 100644 app/Livewire/Concerns/ChangesOwnPassword.php diff --git a/app/Livewire/Admin/EditDatacenter.php b/app/Livewire/Admin/EditDatacenter.php index 72c5241..f923360 100644 --- a/app/Livewire/Admin/EditDatacenter.php +++ b/app/Livewire/Admin/EditDatacenter.php @@ -26,6 +26,15 @@ class EditDatacenter extends ModalComponent /** The location present when the modal opened — a pre-curation free-form value. */ public string $originalLocation = ''; + /** + * Whether new hosts and orders may still be placed here. + * + * The column and the scope existed; the form did not offer it, so a + * datacenter could be created and never switched off again — the one + * lifecycle action a location actually needs. + */ + public bool $active = true; + public function mount(string $uuid): void { $this->authorize('datacenters.manage'); // modals are reachable without the route middleware @@ -35,6 +44,7 @@ class EditDatacenter extends ModalComponent $this->name = $dc->name; $this->location = (string) $dc->location; $this->originalLocation = (string) $dc->location; + $this->active = (bool) $dc->active; } public function save() @@ -53,14 +63,27 @@ class EditDatacenter extends ModalComponent $data = $this->validate([ 'name' => 'required|string|max:255', 'location' => ['nullable', Rule::in($allowed)], // array form — a legacy value may contain commas + 'active' => 'boolean', ]); + // Only on the transition, and compared against what is STORED: an + // already-inactive location would otherwise announce that it had just + // been switched off every time its name was edited. + $justDeactivated = $dc->active && ! $data['active']; + $hostsLeftRunning = $justDeactivated ? $dc->hosts()->count() : 0; + Datacenter::query()->where('uuid', $this->uuid)->update([ 'name' => $data['name'], 'location' => $data['location'] ?: null, + 'active' => $data['active'], ]); - $this->dispatch('notify', message: __('datacenters.updated')); + // One message, not two. Both go through the same toast, and the second + // replaces the first — so the generic "saved" would swallow the only + // sentence that says the hosts there are still running. + $this->dispatch('notify', message: $hostsLeftRunning > 0 + ? __('datacenters.deactivated_with_hosts', ['n' => $hostsLeftRunning]) + : __('datacenters.updated')); return $this->redirectRoute('admin.datacenters', navigate: true); } diff --git a/app/Livewire/Admin/Settings.php b/app/Livewire/Admin/Settings.php index 9aa7bd7..f435fc4 100644 --- a/app/Livewire/Admin/Settings.php +++ b/app/Livewire/Admin/Settings.php @@ -24,6 +24,8 @@ use Livewire\Component; #[Layout('layouts.admin')] class Settings extends Component { + use \App\Livewire\Concerns\ChangesOwnPassword; + // My account #[Validate('required|string|max:255')] public string $name = ''; diff --git a/app/Livewire/Concerns/ChangesOwnPassword.php b/app/Livewire/Concerns/ChangesOwnPassword.php new file mode 100644 index 0000000..aa18ccd --- /dev/null +++ b/app/Livewire/Concerns/ChangesOwnPassword.php @@ -0,0 +1,67 @@ +validate([ + 'currentPassword' => 'required|string', + 'newPassword' => 'required|string', + 'newPasswordConfirmation' => 'required|string', + ]); + + if (! Hash::check($this->currentPassword, auth()->user()->password)) { + $this->addError('currentPassword', __('admin_settings.password_wrong')); + + return; + } + + try { + app(UpdateUserPassword::class)->update(auth()->user(), [ + 'current_password' => $this->currentPassword, + 'password' => $this->newPassword, + 'password_confirmation' => $this->newPasswordConfirmation, + ]); + } catch (ValidationException $e) { + foreach ($e->errors() as $field => $messages) { + $this->addError(match ($field) { + 'current_password' => 'currentPassword', + 'password' => 'newPassword', + default => $field, + }, $messages[0]); + } + + return; + } + + $this->reset('currentPassword', 'newPassword', 'newPasswordConfirmation'); + $this->dispatch('notify', message: __('admin_settings.password_changed')); + } +} diff --git a/app/Livewire/Settings.php b/app/Livewire/Settings.php index 059cad8..e131dcc 100644 --- a/app/Livewire/Settings.php +++ b/app/Livewire/Settings.php @@ -11,6 +11,8 @@ use Livewire\WithFileUploads; class Settings extends Component { + use \App\Livewire\Concerns\ChangesOwnPassword; + use ResolvesCustomer, WithFileUploads; // Company / billing profile diff --git a/config/fortify.php b/config/fortify.php index b88737f..b13ee9c 100644 --- a/config/fortify.php +++ b/config/fortify.php @@ -173,7 +173,10 @@ return [ // Features::resetPasswords(), // Features::emailVerification(), // Features::updateProfileInformation(), - // Features::updatePasswords(), + // Everyone must be able to change their own password. It was off, so + // an account created with a generated password was stuck with it — and + // the only way to change one was a shell on the server. + Features::updatePasswords(), Features::twoFactorAuthentication([ 'confirm' => true, 'confirmPassword' => true, diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php index 3fe24c4..c82d861 100644 --- a/lang/de/admin_settings.php +++ b/lang/de/admin_settings.php @@ -103,4 +103,13 @@ return [ 'failed' => 'fehlgeschlagen', 'running' => 'läuft', ], + + 'password_title' => 'Passwort ändern', + 'password_sub' => 'Gilt sofort. Andere Sitzungen bleiben angemeldet.', + 'password_current' => 'Aktuelles Passwort', + 'password_new' => 'Neues Passwort', + 'password_repeat' => 'Wiederholen', + 'password_save' => 'Passwort ändern', + 'password_wrong' => 'Das aktuelle Passwort stimmt nicht.', + 'password_changed' => 'Passwort geändert.', ]; diff --git a/lang/de/datacenters.php b/lang/de/datacenters.php index 0ba0940..8ae58f6 100644 --- a/lang/de/datacenters.php +++ b/lang/de/datacenters.php @@ -28,4 +28,7 @@ return [ 'delete_confirm' => 'Endgültig löschen', 'delete_blocked_title' => 'Löschen nicht möglich', 'delete_blocked_body' => '„:name" hat noch :count Host(s). Entferne oder verschiebe zuerst die Hosts.', + 'active_label' => 'Für neue Platzierungen verfügbar', + 'active_hint' => 'Abgeschaltet werden hier keine neuen Hosts oder Bestellungen mehr platziert. Was bereits läuft, läuft weiter.', + 'deactivated_with_hosts' => 'Standort abgeschaltet — :n Host(s) laufen dort weiter.', ]; diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php index 21d6a59..f3acd9f 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -103,4 +103,13 @@ return [ 'failed' => 'failed', 'running' => 'running', ], + + 'password_title' => 'Change password', + 'password_sub' => 'Takes effect immediately. Other sessions stay signed in.', + 'password_current' => 'Current password', + 'password_new' => 'New password', + 'password_repeat' => 'Repeat', + 'password_save' => 'Change password', + 'password_wrong' => 'The current password is not correct.', + 'password_changed' => 'Password changed.', ]; diff --git a/lang/en/datacenters.php b/lang/en/datacenters.php index d7a0530..232c165 100644 --- a/lang/en/datacenters.php +++ b/lang/en/datacenters.php @@ -28,4 +28,7 @@ return [ 'delete_confirm' => 'Delete permanently', 'delete_blocked_title' => 'Cannot delete', 'delete_blocked_body' => '“:name” still has :count host(s). Remove or move the hosts first.', + 'active_label' => 'Available for new placements', + 'active_hint' => 'Switched off, no new hosts or orders are placed here. Anything already running keeps running.', + 'deactivated_with_hosts' => 'Location switched off — :n host(s) keep running there.', ]; diff --git a/resources/views/livewire/admin/edit-datacenter.blade.php b/resources/views/livewire/admin/edit-datacenter.blade.php index 5b4aee0..5165335 100644 --- a/resources/views/livewire/admin/edit-datacenter.blade.php +++ b/resources/views/livewire/admin/edit-datacenter.blade.php @@ -25,6 +25,13 @@ + {{-- The one lifecycle action a location needs, and the form did not offer + it: a datacenter could be created and never switched off again. --}} +
+ +

{{ __('datacenters.active_hint') }}

+
+
{{ __('datacenters.cancel') }} {{ __('datacenters.save') }} diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php index bdc66f1..a24a26f 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -196,6 +196,29 @@
@endif + + {{-- Own password. There was no way to change one at all: an account created + with a generated password kept it until someone opened a shell. --}} +
+
+

{{ __('admin_settings.password_title') }}

+

{{ __('admin_settings.password_sub') }}

+
+ +
+ + + +
+ +
+ {{ __('admin_settings.password_save') }} +
+
+

{{ __('admin_settings.account_title') }}

@@ -290,6 +313,12 @@ @endif + {{-- Your own row has no actions — you cannot revoke + yourself — and an empty cell reads as a bug rather + than as a rule. --}} + @if ($s['self']) + + @endif @unless ($s['self']) @endunless diff --git a/resources/views/livewire/settings.blade.php b/resources/views/livewire/settings.blade.php index 1b1415f..f87366a 100644 --- a/resources/views/livewire/settings.blade.php +++ b/resources/views/livewire/settings.blade.php @@ -25,6 +25,29 @@ {{-- Branding --}} + + {{-- Own password. There was no way to change one at all — Fortify's + updatePasswords feature was switched off. --}} +
+
+

{{ __('admin_settings.password_title') }}

+

{{ __('admin_settings.password_sub') }}

+
+ +
+ + + +
+ +
+ {{ __('admin_settings.password_save') }} +
+
+

{{ __('settings.branding_title') }}

diff --git a/tests/Feature/Admin/ConsoleNetworkTest.php b/tests/Feature/Admin/ConsoleNetworkTest.php index 9f8c219..697d82e 100644 --- a/tests/Feature/Admin/ConsoleNetworkTest.php +++ b/tests/Feature/Admin/ConsoleNetworkTest.php @@ -166,3 +166,36 @@ it('never emits a matcher that lets nobody in', function () { ->expectsOutputToContain('127.0.0.1') ->assertSuccessful(); }); + +it('lets an operator change their own password', function () { + // There was no way to do it at all: Fortify's updatePasswords feature was + // off, so an account created with a generated password kept it until + // someone opened a shell on the server. + $operator = operator('Owner'); + + Livewire::actingAs($operator) + ->test(AdminSettings::class) + ->set('currentPassword', 'password') + ->set('newPassword', 'ein-neues-langes-passwort') + ->set('newPasswordConfirmation', 'ein-neues-langes-passwort') + ->call('updateOwnPassword') + ->assertHasNoErrors(); + + expect(Illuminate\Support\Facades\Hash::check('ein-neues-langes-passwort', $operator->refresh()->password))->toBeTrue(); +}); + +it('refuses to change a password without the current one', function () { + // Otherwise an unlocked machine is enough to lock the owner out of their + // own account with two keystrokes. + $operator = operator('Owner'); + + Livewire::actingAs($operator) + ->test(AdminSettings::class) + ->set('currentPassword', 'falsch') + ->set('newPassword', 'ein-neues-langes-passwort') + ->set('newPasswordConfirmation', 'ein-neues-langes-passwort') + ->call('updateOwnPassword') + ->assertHasErrors('currentPassword'); + + expect(Illuminate\Support\Facades\Hash::check('password', $operator->refresh()->password))->toBeTrue(); +}); diff --git a/tests/Feature/Admin/DatacenterTest.php b/tests/Feature/Admin/DatacenterTest.php index fcc92b8..2dce82f 100644 --- a/tests/Feature/Admin/DatacenterTest.php +++ b/tests/Feature/Admin/DatacenterTest.php @@ -135,3 +135,34 @@ it('rejects creating a host in an inactive datacenter', function () { ->call('save') ->assertHasErrors(['datacenter']); }); + +it('can switch a location off, and does not evict what already runs there', function () { + // The column and the scope existed; the form did not offer them, so a + // datacenter could be created and never switched off again. + $dc = App\Models\Datacenter::factory()->create(['active' => true]); + App\Models\Host::factory()->create(['datacenter' => $dc->code]); + + Livewire::actingAs(operator('Owner')) + ->test(App\Livewire\Admin\EditDatacenter::class, ['uuid' => $dc->uuid]) + ->assertSet('active', true) + ->set('active', false) + ->call('save') + ->assertHasNoErrors(); + + expect($dc->refresh()->active)->toBeFalse() + // Switching off stops NEW placements; it does not evict what runs there. + ->and(App\Models\Host::query()->where('datacenter', $dc->code)->exists())->toBeTrue(); +}); + +it('warns about running hosts only when it actually switches a location off', function () { + // An already-inactive location announced that it had just been switched off + // every time its name was edited. + $dc = App\Models\Datacenter::factory()->create(['active' => false]); + App\Models\Host::factory()->create(['datacenter' => $dc->code]); + + Livewire::actingAs(operator('Owner')) + ->test(App\Livewire\Admin\EditDatacenter::class, ['uuid' => $dc->uuid]) + ->set('name', 'Neuer Name') + ->call('save') + ->assertDispatched('notify', message: __('datacenters.updated')); +}); diff --git a/tests/Feature/Admin/UpdateButtonTest.php b/tests/Feature/Admin/UpdateButtonTest.php index b116f75..5a26d30 100644 --- a/tests/Feature/Admin/UpdateButtonTest.php +++ b/tests/Feature/Admin/UpdateButtonTest.php @@ -38,10 +38,12 @@ it('never reports "up to date" when it simply does not know', function () { // how an installation sits three versions behind looking healthy. expect(app(UpdateChannel::class)->state()['available'])->toBeFalse(); + // Asserted on the state, not on the markup: "Aktuell" is also a substring + // of "Aktuelles Passwort" elsewhere on the same page. Livewire::actingAs(operator('Owner')) ->test(AdminSettings::class) ->assertSee(__('admin_settings.update_unknown')) - ->assertDontSee(__('admin_settings.update_current')); + ->assertViewHas('update', fn (array $u) => $u['behind'] === null && $u['available'] === false); }); it('reports an available update once the agent has looked', function () {