CluPilotCloud/app/Livewire/Settings.php

384 lines
14 KiB
PHP

<?php
namespace App\Livewire;
use App\Actions\VerifyCustomerVatId;
use App\Actions\WithdrawContract;
use App\Livewire\Concerns\ChangesOwnPassword;
use App\Livewire\Concerns\ConfirmsPassword;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Customer;
use App\Services\Billing\CustomDomainAccess;
use App\Services\Billing\WithdrawalRight;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication;
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Livewire\WithFileUploads;
use RuntimeException;
class Settings extends Component
{
use ChangesOwnPassword;
use 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(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(ConfirmTwoFactorAuthentication::class)(
auth()->user(),
$this->twoFactorCode,
);
} catch (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(GenerateNewRecoveryCodes::class)(auth()->user());
$this->recoveryCodes = json_decode(decrypt(auth()->user()->refresh()->two_factor_recovery_codes), true);
}
public function disableTwoFactor(): void
{
$this->requireConfirmedPassword();
app(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 = '';
/**
* Consumer or business — the answer given at sign-up, correctable here.
*
* Empty means nobody has ever recorded one, which is a real state and not a
* third kind of customer: every customer created from a Stripe event under
* an address they did not register with arrives that way. Shown so it can
* be answered, and never silently filled in from the VAT field beneath it.
*/
#[Validate('nullable|string|max:16')]
public string $customerType = '';
#[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->customerType = $c->customer_type ?? '';
$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',
// Only one of the two real answers may be stored. An empty string
// leaves the record as it was — the form can be saved by somebody
// who never touched this field — but no third value can ever get in.
'customerType' => ['nullable', Rule::in(Customer::TYPES)],
'vatId' => 'nullable|string|max:64',
'billingAddress' => 'nullable|string|max:2000',
]);
$c->update([
'name' => $data['companyName'],
'contact_name' => $data['contactName'] ?: null,
'phone' => $data['phone'] ?: null,
// Never cleared back to "unrecorded" from here. Once somebody has
// answered, the answer stands until it is replaced by the other one:
// a form submitted with the field blank must not quietly undo a
// recorded consumer into an unknown.
'customer_type' => $data['customerType'] ?: $c->customer_type,
'vat_id' => $data['vatId'] ?: null,
'billing_address' => $data['billingAddress'] ?: null,
]);
$this->dispatch('notify', message: $this->verifyVatId($c));
}
/**
* Check the number against the register, and say what came back.
*
* Here, at the moment it is entered, because that is the only moment somebody
* is looking at the field and can correct a typo. Reverse charge is only
* lawful against a number that is actually registered, so an unchecked number
* is charged the domestic rate — which means an EU business that mistyped
* theirs pays VAT it cannot reclaim at home until somebody notices.
*
* The save has already happened by the time this runs and cannot be undone by
* it. A register that is down must not cost the customer their address change,
* and a number the register rejects is still the number they typed — theirs to
* correct, not ours to discard.
*/
private function verifyVatId(Customer $customer): string
{
$check = app(VerifyCustomerVatId::class)($customer);
if ($check === null) {
return __('settings.profile_saved');
}
if ($check->isValid()) {
return __('settings.vat_verified');
}
// Told apart on purpose. "We could not check right now" is our problem and
// resolves itself on the next sweep; "the register does not know this
// number" is something only the customer can fix.
return __($check->isUnavailable() ? 'settings.vat_unchecked' : 'settings.vat_unknown');
}
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'));
}
/**
* The consumer exercises the fourteen-day right of withdrawal.
*
* Raised by ConfirmWithdraw rather than called from the card (R23): this
* ends the service the same afternoon and cannot be taken back, so it is
* confirmed in a dialog this product draws.
*
* Every check that matters is on the server, inside WithdrawContract, and
* not in the markup that leads here. A Livewire action is reachable by
* anybody who can POST to /livewire/update — a business customer who has
* never seen this card can still name the method — so the refusal is made
* where the mutation is, once, in the customer's own words.
*/
#[On('withdrawal-confirmed')]
public function withdraw(): void
{
$customer = $this->requireCustomer();
if ($customer === null) {
return;
}
$contract = app(CustomDomainAccess::class)->contractOf($customer);
if ($contract === null) {
$this->dispatch('notify', message: __('withdrawal.refusal_no_contract'));
return;
}
try {
app(WithdrawContract::class)($contract, WithdrawContract::CHANNEL_PORTAL);
} catch (RuntimeException $e) {
// The sentence WithdrawalRight refused with, which is already the
// one the customer would be shown — a business customer, an expired
// window, a second click on a withdrawal that has happened.
$this->dispatch('notify', message: $e->getMessage());
return;
}
$this->dispatch('notify', message: __('withdrawal.done'));
}
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();
// Resolved once here so the card and the dialog it opens cannot state
// different deadlines for the same contract. A business customer gets
// `applies = false` and the card never renders — but the refusal that
// matters is the one in withdraw(), on the server.
$contract = app(CustomDomainAccess::class)->contractOf($c);
$withdrawal = WithdrawalRight::for($contract);
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,
'withdrawal' => $withdrawal,
// Only the two answers, so the form cannot offer "unrecorded" as a
// thing somebody can choose. An unrecorded customer sees neither
// selected, which is the truth about their record.
'customerTypes' => Customer::TYPES,
]);
}
}