Let people change their own password, and a location be switched off while editing it
tests / pest (push) Successful in 7m29s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Successful in 4s Details

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 <noreply@anthropic.com>
feat/portal-design tested-20260727-0604-935c9ae
Claude 2026-07-27 07:49:07 +02:00
parent bc8bbc56a5
commit 935c9ae6ac
15 changed files with 249 additions and 3 deletions

View File

@ -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);
}

View File

@ -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 = '';

View File

@ -0,0 +1,67 @@
<?php
namespace App\Livewire\Concerns;
use App\Actions\Fortify\UpdateUserPassword;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
/**
* Changing your own password, from wherever you are signed in.
*
* 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. Shared by the console and the customer portal because
* the rules are the same in both, and having two copies of a password rule is
* how one of them ends up weaker.
*
* Goes through Fortify's own action rather than hashing here, so the password
* rules stay in the single place that already defines them.
*/
trait ChangesOwnPassword
{
public string $currentPassword = '';
public string $newPassword = '';
public string $newPasswordConfirmation = '';
public function updateOwnPassword(): void
{
// The current password is asked for, and checked here as well as in the
// action: an attacker on an unlocked machine should not be able to lock
// the owner out of their own account with two keystrokes.
$this->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'));
}
}

View File

@ -11,6 +11,8 @@ use Livewire\WithFileUploads;
class Settings extends Component
{
use \App\Livewire\Concerns\ChangesOwnPassword;
use ResolvesCustomer, WithFileUploads;
// Company / billing profile

View File

@ -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,

View File

@ -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.',
];

View File

@ -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.',
];

View File

@ -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.',
];

View File

@ -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.',
];

View File

@ -25,6 +25,13 @@
</div>
</div>
{{-- The one lifecycle action a location needs, and the form did not offer
it: a datacenter could be created and never switched off again. --}}
<div class="mt-5 rounded-lg border border-line bg-surface-2 p-4">
<x-ui.checkbox name="active" wire:model="active" :label="__('datacenters.active_label')" />
<p class="mt-1.5 pl-7 text-xs text-muted">{{ __('datacenters.active_hint') }}</p>
</div>
<div class="mt-6 flex justify-end gap-3">
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('datacenters.cancel') }}</x-ui.button>
<x-ui.button variant="primary" wire:click="save" wire:loading.attr="disabled">{{ __('datacenters.save') }}</x-ui.button>

View File

@ -196,6 +196,29 @@
</div>
@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. --}}
<form wire:submit="updateOwnPassword" class="space-y-4 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:90ms]">
<div>
<h2 class="font-semibold text-ink">{{ __('admin_settings.password_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.password_sub') }}</p>
</div>
<div class="grid gap-4 sm:grid-cols-3">
<x-ui.input name="currentPassword" type="password" autocomplete="current-password"
:label="__('admin_settings.password_current')" wire:model="currentPassword" />
<x-ui.input name="newPassword" type="password" autocomplete="new-password"
:label="__('admin_settings.password_new')" wire:model="newPassword" />
<x-ui.input name="newPasswordConfirmation" type="password" autocomplete="new-password"
:label="__('admin_settings.password_repeat')" wire:model="newPasswordConfirmation" />
</div>
<div class="flex justify-end">
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled">{{ __('admin_settings.password_save') }}</x-ui.button>
</div>
</form>
<form wire:submit="saveAccount" class="space-y-4 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
<h2 class="font-semibold text-ink">{{ __('admin_settings.account_title') }}</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
@ -290,6 +313,12 @@
@endif
</td>
<td class="px-4 py-3 text-right">
{{-- 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'])
<span class="text-xs text-faint"></span>
@endif
@unless ($s['self'])
<button type="button" wire:click="revokeStaff({{ $s['id'] }})" class="rounded-md border border-line px-2.5 py-1.5 text-xs font-semibold text-muted hover:border-danger hover:text-danger">{{ __('admin_settings.revoke') }}</button>
@endunless

View File

@ -25,6 +25,29 @@
</form>
{{-- Branding --}}
{{-- Own password. There was no way to change one at all Fortify's
updatePasswords feature was switched off. --}}
<form wire:submit="updateOwnPassword" class="space-y-4 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:90ms]">
<div>
<h2 class="font-semibold text-ink">{{ __('admin_settings.password_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.password_sub') }}</p>
</div>
<div class="grid gap-4 sm:grid-cols-3">
<x-ui.input name="currentPassword" type="password" autocomplete="current-password"
:label="__('admin_settings.password_current')" wire:model="currentPassword" />
<x-ui.input name="newPassword" type="password" autocomplete="new-password"
:label="__('admin_settings.password_new')" wire:model="newPassword" />
<x-ui.input name="newPasswordConfirmation" type="password" autocomplete="new-password"
:label="__('admin_settings.password_repeat')" wire:model="newPasswordConfirmation" />
</div>
<div class="flex justify-end">
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="updateOwnPassword">{{ __('admin_settings.password_save') }}</x-ui.button>
</div>
</form>
<form wire:submit="saveBranding" class="space-y-4 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:120ms]">
<div>
<h2 class="font-semibold text-ink">{{ __('settings.branding_title') }}</h2>

View File

@ -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();
});

View File

@ -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'));
});

View File

@ -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 () {