520 lines
20 KiB
PHP
520 lines
20 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 App\Services\Legal\ProcessingAgreement;
|
|
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\Url;
|
|
use Livewire\Attributes\Validate;
|
|
use Livewire\Component;
|
|
use Livewire\WithFileUploads;
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* The customer's own settings.
|
|
*
|
|
* It was one column, 768 pixels wide inside a 1240-pixel shell — a third of the
|
|
* window left empty beside it — and everything a customer might ever change was
|
|
* stacked in it: the company address, their password, two-factor, the devices
|
|
* they are signed in on, the branding of their cloud, the contract, and the
|
|
* button that closes the account. Two thousand pixels of scroll, and the way to
|
|
* a particular thing was to know how far down it lived.
|
|
*
|
|
* Four tabs now, by what somebody came to do: their data, how the account is
|
|
* protected, how the cloud looks, and the contract. The choice lives in the
|
|
* query string, so a reload, a bookmark and the back button all land where they
|
|
* were — and a link to this page can name the part it means.
|
|
*/
|
|
class Settings extends Component
|
|
{
|
|
use ChangesOwnPassword;
|
|
use ConfirmsPassword;
|
|
|
|
/**
|
|
* The tabs, in the order they are shown.
|
|
*
|
|
* The list IS the schema: it validates the query string, builds the tab bar
|
|
* and decides which section renders. A fourth place to add a tab is a fourth
|
|
* place to forget one.
|
|
*/
|
|
public const TABS = ['profile', 'security', 'branding', 'contract'];
|
|
|
|
/**
|
|
* Which one is open, taken from and written back to the address.
|
|
*
|
|
* `history: true`, so each tab is a step the back button can take, and no
|
|
* `except`, so the parameter is there from the first render — hiding it on
|
|
* the default reads as "the tab is not in the URL", which is exactly how it
|
|
* was reported on the console's settings page.
|
|
*/
|
|
#[Url(history: true)]
|
|
public string $tab = 'profile';
|
|
|
|
/** 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 = '';
|
|
|
|
/**
|
|
* The billing address, field by field.
|
|
*
|
|
* It was one textarea, so what landed in it was whatever somebody typed — a
|
|
* postcode on the street line, a city with no postcode, no country at all.
|
|
* An invoice has to name its recipient precisely enough to be a document.
|
|
*
|
|
* `customers.billing_address` still exists and is written from these on
|
|
* every save: everything that already reads it (IssueInvoice, the console's
|
|
* customer page) keeps working, and a record nobody has re-saved keeps the
|
|
* block it always had rather than being guessed apart.
|
|
*/
|
|
#[Validate('nullable|string|max:255')]
|
|
public string $billingStreet = '';
|
|
|
|
#[Validate('nullable|string|max:32')]
|
|
public string $billingPostcode = '';
|
|
|
|
#[Validate('nullable|string|max:255')]
|
|
public string $billingCity = '';
|
|
|
|
#[Validate('nullable|string|max:255')]
|
|
public string $billingCountry = '';
|
|
|
|
// 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
|
|
{
|
|
// A tab name out of the URL is a string a stranger typed. Anything
|
|
// unknown falls back to the first rather than rendering a settings page
|
|
// with no section on it at all.
|
|
if (! in_array($this->tab, self::TABS, true)) {
|
|
$this->tab = self::TABS[0];
|
|
}
|
|
|
|
$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->billingStreet = $c->billing_street ?? '';
|
|
$this->billingPostcode = $c->billing_postcode ?? '';
|
|
$this->billingCity = $c->billing_city ?? '';
|
|
$this->billingCountry = $c->billing_country ?? '';
|
|
$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',
|
|
'billingStreet' => 'nullable|string|max:255',
|
|
'billingPostcode' => 'nullable|string|max:32',
|
|
'billingCity' => 'nullable|string|max:255',
|
|
'billingCountry' => 'nullable|string|max:255',
|
|
]);
|
|
|
|
$c->update([
|
|
'name' => $data['companyName'],
|
|
'contact_name' => $data['contactName'] ?: null,
|
|
'phone' => $data['phone'] ?: null,
|
|
// FIXED once it is on record, and this is the line that fixes it.
|
|
//
|
|
// It decides the fourteen-day right of withdrawal, and a business
|
|
// that could set itself to "Privatperson" here would be a business
|
|
// that can withdraw from a contract it has no right to withdraw
|
|
// from. That is why registration asks the question and why this form
|
|
// cannot answer it a second time. A record that has none — created
|
|
// before the question existed — may still be given one, once.
|
|
'customer_type' => $c->customer_type ?: ($data['customerType'] ?: null),
|
|
'vat_id' => $data['vatId'] ?: null,
|
|
'billing_street' => $data['billingStreet'] ?: null,
|
|
'billing_postcode' => $data['billingPostcode'] ?: null,
|
|
'billing_city' => $data['billingCity'] ?: null,
|
|
'billing_country' => $data['billingCountry'] ?: null,
|
|
// The composed block, rewritten from the fields. Everything that
|
|
// reads an address today reads this one.
|
|
'billing_address' => self::composeAddress($data) ?: $c->billing_address,
|
|
]);
|
|
|
|
$this->dispatch('notify', message: $this->verifyVatId($c));
|
|
}
|
|
|
|
/**
|
|
* The four fields as the block every reader already expects.
|
|
*
|
|
* Street, then postcode and city on one line, then the country — the shape
|
|
* an address takes on an Austrian invoice. Empty in, empty out: a customer
|
|
* who has filled in nothing must not be given a document with two blank
|
|
* lines and a comma on it.
|
|
*
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
public static function composeAddress(array $data): string
|
|
{
|
|
$city = trim(trim((string) ($data['billingPostcode'] ?? '')).' '.trim((string) ($data['billingCity'] ?? '')));
|
|
|
|
$lines = array_filter([
|
|
trim((string) ($data['billingStreet'] ?? '')),
|
|
$city,
|
|
trim((string) ($data['billingCountry'] ?? '')),
|
|
], fn (string $line) => $line !== '');
|
|
|
|
return implode("\n", $lines);
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
/**
|
|
* Accept the processing agreement in force.
|
|
*
|
|
* One press, no dialogue: this is an agreement, not a deletion, and putting
|
|
* a confirmation in front of it would only make the record harder to obtain.
|
|
* The evidence — the moment, the address, the login — is taken by the
|
|
* service, so nothing here can record an acceptance from an address it
|
|
* invented.
|
|
*/
|
|
public function acceptProcessingAgreement(): void
|
|
{
|
|
$c = $this->requireCustomer();
|
|
|
|
if ($c === null) {
|
|
return;
|
|
}
|
|
|
|
if (app(ProcessingAgreement::class)->accept($c) === null) {
|
|
// Nothing published yet. The card does not render in that case, so
|
|
// reaching this line means somebody posted to it directly.
|
|
return;
|
|
}
|
|
|
|
$this->dispatch('notify', message: __('dpa.accepted_notice'));
|
|
}
|
|
|
|
#[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', [
|
|
'tabs' => self::TABS,
|
|
// 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,
|
|
// The processing agreement in force and this customer's acceptance
|
|
// of THAT version — an acceptance of a superseded one is history,
|
|
// not an answer (see ProcessingAgreement).
|
|
'dpa' => ($dpaService = app(ProcessingAgreement::class))->current(),
|
|
'dpaAcceptance' => $dpaService->acceptanceOf($c),
|
|
]);
|
|
}
|
|
}
|