261 lines
9.1 KiB
PHP
261 lines
9.1 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire;
|
|
|
|
use App\Livewire\Concerns\ResolvesCustomer;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Livewire\Attributes\Layout;
|
|
use Livewire\Attributes\On;
|
|
use Livewire\Attributes\Validate;
|
|
use Livewire\Component;
|
|
use Livewire\WithFileUploads;
|
|
|
|
class Settings extends Component
|
|
{
|
|
use \App\Livewire\Concerns\ChangesOwnPassword;
|
|
use \App\Livewire\Concerns\ConfirmsPassword;
|
|
|
|
/** Shown once, right after setting two-factor up. */
|
|
public ?array $recoveryCodes = null;
|
|
|
|
public string $twoFactorCode = '';
|
|
|
|
/**
|
|
* Start two-factor setup: generate the secret, then show the QR code.
|
|
*
|
|
* Not confirmed yet — Fortify keeps `two_factor_confirmed_at` null until a
|
|
* code from the app has been accepted, so someone who scans nothing and
|
|
* closes the tab is not left locked out of their own account.
|
|
*/
|
|
public function enableTwoFactor(): void
|
|
{
|
|
$this->requireConfirmedPassword();
|
|
|
|
app(\Laravel\Fortify\Actions\EnableTwoFactorAuthentication::class)(auth()->user());
|
|
}
|
|
|
|
/** Accept a code from the app, which is what actually turns it on. */
|
|
public function confirmTwoFactor(): void
|
|
{
|
|
$this->requireConfirmedPassword();
|
|
|
|
try {
|
|
app(\Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication::class)(
|
|
auth()->user(),
|
|
$this->twoFactorCode,
|
|
);
|
|
} catch (\Illuminate\Validation\ValidationException $e) {
|
|
$this->addError('twoFactorCode', $e->errors()['code'][0] ?? __('settings.twofa_code_wrong'));
|
|
|
|
return;
|
|
}
|
|
|
|
$this->twoFactorCode = '';
|
|
// Shown once, here, because this is the only moment they exist in a
|
|
// form anyone will read. They stay retrievable afterwards, but nobody
|
|
// writes down what they were not shown.
|
|
$this->recoveryCodes = json_decode(decrypt(auth()->user()->two_factor_recovery_codes), true);
|
|
$this->dispatch('notify', message: __('settings.twofa_on'));
|
|
}
|
|
|
|
public function regenerateRecoveryCodes(): void
|
|
{
|
|
$this->requireConfirmedPassword();
|
|
|
|
app(\Laravel\Fortify\Actions\GenerateNewRecoveryCodes::class)(auth()->user());
|
|
$this->recoveryCodes = json_decode(decrypt(auth()->user()->refresh()->two_factor_recovery_codes), true);
|
|
}
|
|
|
|
public function disableTwoFactor(): void
|
|
{
|
|
$this->requireConfirmedPassword();
|
|
|
|
app(\Laravel\Fortify\Actions\DisableTwoFactorAuthentication::class)(auth()->user());
|
|
$this->recoveryCodes = null;
|
|
$this->dispatch('notify', message: __('settings.twofa_off'));
|
|
}
|
|
|
|
/**
|
|
* The disable button opens ConfirmDisableTwoFactor instead of calling
|
|
* disableTwoFactor() directly (R23); its confirm button dispatches back
|
|
* here, and disableTwoFactor() still runs its own password check.
|
|
*/
|
|
#[On('twofa-disable-confirmed')]
|
|
public function onTwoFaDisableConfirmed(): void
|
|
{
|
|
$this->disableTwoFactor();
|
|
}
|
|
|
|
/**
|
|
* Every two-factor action goes through this.
|
|
*
|
|
* Server-side, on each call, and not merely by hiding the buttons: a
|
|
* Livewire action is reachable by anyone who can post to /livewire/update,
|
|
* and "the form was not on screen" has never stopped anybody.
|
|
*/
|
|
private function requireConfirmedPassword(): void
|
|
{
|
|
abort_unless($this->passwordRecentlyConfirmed(), 403);
|
|
}
|
|
|
|
use ResolvesCustomer, WithFileUploads;
|
|
|
|
// Company / billing profile
|
|
#[Validate('required|string|max:255')]
|
|
public string $companyName = '';
|
|
|
|
#[Validate('nullable|string|max:255')]
|
|
public string $contactName = '';
|
|
|
|
#[Validate('nullable|string|max:64')]
|
|
public string $phone = '';
|
|
|
|
#[Validate('nullable|string|max:64')]
|
|
public string $vatId = '';
|
|
|
|
#[Validate('nullable|string|max:2000')]
|
|
public string $billingAddress = '';
|
|
|
|
// Branding
|
|
#[Validate('nullable|string|max:255')]
|
|
public string $brandDisplayName = '';
|
|
|
|
#[Validate('nullable|regex:/^#[0-9a-fA-F]{6}$/')]
|
|
public string $brandPrimary = '';
|
|
|
|
#[Validate('nullable|regex:/^#[0-9a-fA-F]{6}$/')]
|
|
public string $brandAccent = '';
|
|
|
|
/** New logo upload (validated on save). */
|
|
public $logo = null;
|
|
|
|
public ?string $brandLogoPath = null;
|
|
|
|
public function mount(): void
|
|
{
|
|
$c = $this->customer();
|
|
if ($c === null) {
|
|
return;
|
|
}
|
|
$this->companyName = $c->name ?? '';
|
|
$this->contactName = $c->contact_name ?? '';
|
|
$this->phone = $c->phone ?? '';
|
|
$this->vatId = $c->vat_id ?? '';
|
|
$this->billingAddress = $c->billing_address ?? '';
|
|
$this->brandDisplayName = $c->brand_display_name ?? '';
|
|
$this->brandPrimary = $c->brand_primary_color ?? '';
|
|
$this->brandAccent = $c->brand_accent_color ?? '';
|
|
$this->brandLogoPath = $c->brand_logo_path;
|
|
}
|
|
|
|
public function saveProfile(): void
|
|
{
|
|
$c = $this->requireCustomer();
|
|
if ($c === null) {
|
|
return;
|
|
}
|
|
|
|
$this->validateOnly('companyName');
|
|
$data = $this->validate([
|
|
'companyName' => 'required|string|max:255',
|
|
'contactName' => 'nullable|string|max:255',
|
|
'phone' => 'nullable|string|max:64',
|
|
'vatId' => 'nullable|string|max:64',
|
|
'billingAddress' => 'nullable|string|max:2000',
|
|
]);
|
|
|
|
$c->update([
|
|
'name' => $data['companyName'],
|
|
'contact_name' => $data['contactName'] ?: null,
|
|
'phone' => $data['phone'] ?: null,
|
|
'vat_id' => $data['vatId'] ?: null,
|
|
'billing_address' => $data['billingAddress'] ?: null,
|
|
]);
|
|
|
|
$this->dispatch('notify', message: __('settings.profile_saved'));
|
|
}
|
|
|
|
public function saveBranding(): void
|
|
{
|
|
$c = $this->requireCustomer();
|
|
if ($c === null) {
|
|
return;
|
|
}
|
|
|
|
$this->validate([
|
|
'brandDisplayName' => 'nullable|string|max:255',
|
|
'brandPrimary' => 'nullable|regex:/^#[0-9a-fA-F]{6}$/',
|
|
'brandAccent' => 'nullable|regex:/^#[0-9a-fA-F]{6}$/',
|
|
'logo' => 'nullable|image|mimes:png,webp|max:2048',
|
|
]);
|
|
|
|
// Store the new upload, but keep the old file until the DB row that
|
|
// references it is updated — delete the old one only after that commits,
|
|
// so a failed update never orphans a file or dangles a reference.
|
|
$oldToDelete = null;
|
|
if ($this->logo !== null) {
|
|
$oldToDelete = $this->brandLogoPath;
|
|
$this->brandLogoPath = $this->logo->store('branding', 'public');
|
|
$this->logo = null;
|
|
}
|
|
|
|
$c->update([
|
|
'brand_display_name' => $this->brandDisplayName ?: null,
|
|
'brand_primary_color' => $this->brandPrimary ?: null,
|
|
'brand_accent_color' => $this->brandAccent ?: null,
|
|
'brand_logo_path' => $this->brandLogoPath,
|
|
]);
|
|
|
|
if ($oldToDelete !== null && $oldToDelete !== $this->brandLogoPath) {
|
|
Storage::disk('public')->delete($oldToDelete);
|
|
}
|
|
|
|
$this->dispatch('notify', message: __('settings.branding_saved'));
|
|
}
|
|
|
|
public function removeLogo(): void
|
|
{
|
|
$c = $this->requireCustomer();
|
|
if ($c === null) {
|
|
return;
|
|
}
|
|
if ($this->brandLogoPath !== null) {
|
|
Storage::disk('public')->delete($this->brandLogoPath);
|
|
}
|
|
$this->brandLogoPath = null;
|
|
$c->update(['brand_logo_path' => null]);
|
|
$this->dispatch('notify', message: __('settings.branding_saved'));
|
|
}
|
|
|
|
#[Layout('layouts.portal-app')]
|
|
public function render()
|
|
{
|
|
$c = $this->customer();
|
|
// Prefer the active/cancelling instance for the package section so the
|
|
// controls line up with what cancellation actually targets.
|
|
$active = $c?->instances()->where('status', 'active')->latest('id')->first();
|
|
$scheduled = $c?->instances()->where('status', 'cancellation_scheduled')->latest('id')->first();
|
|
$instance = $active ?? $scheduled ?? $c?->instances()->latest('id')->first();
|
|
|
|
$user = auth()->user();
|
|
|
|
return view('livewire.settings', [
|
|
// Never the secret itself — only whether it exists, and the SVG
|
|
// Fortify renders from it. The secret in a Livewire property would
|
|
// travel to the browser and back in the component snapshot.
|
|
'twoFactorPending' => $user->two_factor_secret !== null && $user->two_factor_confirmed_at === null,
|
|
'twoFactorOn' => $user->two_factor_confirmed_at !== null,
|
|
'twoFactorQr' => $user->two_factor_secret !== null && $user->two_factor_confirmed_at === null
|
|
? $user->twoFactorQrCodeSvg()
|
|
: null,
|
|
'passwordConfirmed' => $this->passwordRecentlyConfirmed(),
|
|
'customer' => $c,
|
|
'instance' => $instance,
|
|
'branding' => $c?->brandingResolved(),
|
|
'logoUrl' => $this->brandLogoPath ? Storage::disk('public')->url($this->brandLogoPath) : null,
|
|
'hasActivePackage' => $active !== null,
|
|
'cancellationScheduled' => $active === null && $scheduled !== null,
|
|
]);
|
|
}
|
|
}
|