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')); } /** * 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, ]); } }