Let people change their own password, and a location be switched off while editing it
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
parent
bc8bbc56a5
commit
935c9ae6ac
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,8 @@ use Livewire\WithFileUploads;
|
|||
|
||||
class Settings extends Component
|
||||
{
|
||||
use \App\Livewire\Concerns\ChangesOwnPassword;
|
||||
|
||||
use ResolvesCustomer, WithFileUploads;
|
||||
|
||||
// Company / billing profile
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
Loading…
Reference in New Issue