diff --git a/Modules/Core/Filament/Admin/Pages/Settings.php b/Modules/Core/Filament/Admin/Pages/Settings.php index 969a4c298..bba784cc3 100644 --- a/Modules/Core/Filament/Admin/Pages/Settings.php +++ b/Modules/Core/Filament/Admin/Pages/Settings.php @@ -309,9 +309,15 @@ protected function getFormSchema(): array RichEditor::make('settings.default_invoice_terms') ->label(trans('ip.default_terms')) ->toolbarButtons([ - 'bold', - 'italic', - ]), + ['undo', 'redo'], + ['bold', 'italic', 'underline', 'strike', 'subscript', 'superscript', 'link', 'textColor', 'highlight'], + ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'paragraph', 'small', 'lead'], + ['alignStart', 'alignCenter', 'alignEnd', 'alignJustify'], + ['blockquote', 'code', 'codeBlock', 'bulletList', 'orderedList', 'horizontalRule', 'details'], + ['table', 'grid'], + ['clearFormatting'], + ]) + ->columnSpanFull(), Select::make('settings.invoice_default_payment_method') ->label(trans('ip.default_payment_method')) @@ -408,9 +414,15 @@ protected function getFormSchema(): array RichEditor::make('settings.pdf_invoice_footer') ->label(trans('ip.pdf_invoice_footer')) ->toolbarButtons([ - 'bold', - 'italic', - ]), + ['undo', 'redo'], + ['bold', 'italic', 'underline', 'strike', 'subscript', 'superscript', 'link', 'textColor', 'highlight'], + ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'paragraph', 'small', 'lead'], + ['alignStart', 'alignCenter', 'alignEnd', 'alignJustify'], + ['blockquote', 'code', 'codeBlock', 'bulletList', 'orderedList', 'horizontalRule', 'details'], + ['table', 'grid'], + ['clearFormatting'], + ]) + ->columnSpanFull(), ]), Section::make(trans('ip.qr_code_settings')) diff --git a/Modules/Core/Filament/Company/Pages/CompanySettings.php b/Modules/Core/Filament/Company/Pages/CompanySettings.php index bc0138fd7..3ebbdcbb6 100644 --- a/Modules/Core/Filament/Company/Pages/CompanySettings.php +++ b/Modules/Core/Filament/Company/Pages/CompanySettings.php @@ -6,6 +6,7 @@ use Filament\Actions\Action; use Filament\Forms\Components\ColorPicker; use Filament\Forms\Components\FileUpload; +use Filament\Forms\Components\RichEditor; use Filament\Forms\Components\Select; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; @@ -290,9 +291,17 @@ protected function getFormSchema(): array ->options([]) ->placeholder(trans('ip.none')), - Textarea::make(Setting::KEY_INVOICE_PDF_FOOTER) + RichEditor::make(Setting::KEY_INVOICE_PDF_FOOTER) ->label(trans('ip.pdf_invoice_footer')) - ->rows(3) + ->toolbarButtons([ + ['undo', 'redo'], + ['bold', 'italic', 'underline', 'strike', 'subscript', 'superscript', 'link', 'textColor', 'highlight'], + ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'paragraph', 'small', 'lead'], + ['alignStart', 'alignCenter', 'alignEnd', 'alignJustify'], + ['blockquote', 'code', 'codeBlock', 'bulletList', 'orderedList', 'horizontalRule', 'details'], + ['table', 'grid'], + ['clearFormatting'], + ]) ->columnSpanFull(), ]), @@ -309,13 +318,31 @@ protected function getFormSchema(): array ]), Section::make(trans('ip.other_settings'))->columns(2)->schema([ - Textarea::make(Setting::KEY_INVOICE_DEFAULT_TERMS) + RichEditor::make(Setting::KEY_INVOICE_DEFAULT_TERMS) ->label(trans('ip.default_terms')) - ->rows(3), + ->toolbarButtons([ + ['undo', 'redo'], + ['bold', 'italic', 'underline', 'strike', 'subscript', 'superscript', 'link', 'textColor', 'highlight'], + ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'paragraph', 'small', 'lead'], + ['alignStart', 'alignCenter', 'alignEnd', 'alignJustify'], + ['blockquote', 'code', 'codeBlock', 'bulletList', 'orderedList', 'horizontalRule', 'details'], + ['table', 'grid'], + ['clearFormatting'], + ]) + ->columnSpanFull(), - Textarea::make(Setting::KEY_INVOICE_DEFAULT_FOOTER) + RichEditor::make(Setting::KEY_INVOICE_DEFAULT_FOOTER) ->label(trans('ip.default_invoice_footer')) - ->rows(3), + ->toolbarButtons([ + ['undo', 'redo'], + ['bold', 'italic', 'underline', 'strike', 'subscript', 'superscript', 'link', 'textColor', 'highlight'], + ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'paragraph', 'small', 'lead'], + ['alignStart', 'alignCenter', 'alignEnd', 'alignJustify'], + ['blockquote', 'code', 'codeBlock', 'bulletList', 'orderedList', 'horizontalRule', 'details'], + ['table', 'grid'], + ['clearFormatting'], + ]) + ->columnSpanFull(), ]), ]), diff --git a/Modules/Core/Tests/Feature/CompanySettingsTest.php b/Modules/Core/Tests/Feature/CompanySettingsTest.php index 39e048135..84bc8e3bd 100644 --- a/Modules/Core/Tests/Feature/CompanySettingsTest.php +++ b/Modules/Core/Tests/Feature/CompanySettingsTest.php @@ -81,7 +81,10 @@ public function it_persists_a_long_text_setting(): void ->assertHasNoErrors(); /* Assert */ - $this->assertSame($text, Setting::getForCompany($this->company->id, Setting::KEY_INVOICE_DEFAULT_TERMS)); + $this->assertSame( + '
' . $text . '
', + Setting::getForCompany($this->company->id, Setting::KEY_INVOICE_DEFAULT_TERMS) + ); } #[Test] diff --git a/Modules/Invoices/Filament/Company/Resources/Invoices/Schemas/InvoiceForm.php b/Modules/Invoices/Filament/Company/Resources/Invoices/Schemas/InvoiceForm.php index 7c420e08b..2d44e9fc6 100644 --- a/Modules/Invoices/Filament/Company/Resources/Invoices/Schemas/InvoiceForm.php +++ b/Modules/Invoices/Filament/Company/Resources/Invoices/Schemas/InvoiceForm.php @@ -8,6 +8,7 @@ use Filament\Forms\Components\MarkdownEditor; use Filament\Forms\Components\Placeholder; use Filament\Forms\Components\Repeater; +use Filament\Forms\Components\RichEditor; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Infolists\Components\TextEntry; @@ -313,8 +314,16 @@ public static function configure(Schema $schema): Schema Section::make(trans('ip.invoice_terms')) ->collapsed() ->schema([ - MarkdownEditor::make('invoice_terms') - ->toolbarButtons(['bold', 'italic']) + RichEditor::make('invoice_terms') + ->toolbarButtons([ + ['undo', 'redo'], + ['bold', 'italic', 'underline', 'strike', 'subscript', 'superscript', 'link', 'textColor', 'highlight'], + ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'paragraph', 'small', 'lead'], + ['alignStart', 'alignCenter', 'alignEnd', 'alignJustify'], + ['blockquote', 'code', 'codeBlock', 'bulletList', 'orderedList', 'horizontalRule', 'details'], + ['table', 'grid'], + ['clearFormatting'], + ]) ->label(trans('ip.invoice_terms')) ->default(function (string $operation) { if ($operation !== 'create') { diff --git a/Modules/Invoices/Observers/InvoiceObserver.php b/Modules/Invoices/Observers/InvoiceObserver.php index bbbf7160c..eeb986886 100644 --- a/Modules/Invoices/Observers/InvoiceObserver.php +++ b/Modules/Invoices/Observers/InvoiceObserver.php @@ -2,6 +2,7 @@ namespace Modules\Invoices\Observers; +use Illuminate\Support\Str; use Modules\Core\Observers\AbstractObserver; use Modules\Invoices\Models\Invoice; use RuntimeException; @@ -16,6 +17,13 @@ class InvoiceObserver extends AbstractObserver */ public function saving(Invoice $invoice): void { + // The Terms and Footer fields are edited with a RichEditor, which + // outputs raw HTML the client controls. Sanitize before it ever + // reaches the database, since both are later rendered unescaped in + // the PDF/guest views. + $invoice->terms = $this->sanitizeRichText($invoice->terms); + $invoice->footer = $this->sanitizeRichText($invoice->footer); + if ($invoice->invoice_number !== null) { $query = Invoice::withoutGlobalScopes() ->where('company_id', $invoice->company_id) @@ -41,6 +49,21 @@ public function saving(Invoice $invoice): void } } + /** + * Sanitize RichEditor HTML, normalizing a content-free value (e.g. an + * emptied editor's "") back to null. + */ + private function sanitizeRichText(?string $html): ?string + { + if ($html === null) { + return null; + } + + $sanitized = Str::sanitizeHtml($html); + + return filled(trim(strip_tags($sanitized))) ? $sanitized : null; + } + /** * Prevent deleting an invoice while its credit notes still refer to it. */ diff --git a/Modules/Invoices/Tests/Feature/InvoiceDefaultTermsPrefillTest.php b/Modules/Invoices/Tests/Feature/InvoiceDefaultTermsPrefillTest.php index 4884f0ec4..c34283fec 100644 --- a/Modules/Invoices/Tests/Feature/InvoiceDefaultTermsPrefillTest.php +++ b/Modules/Invoices/Tests/Feature/InvoiceDefaultTermsPrefillTest.php @@ -32,7 +32,7 @@ public function it_prefills_invoice_terms_from_the_company_setting(): void ->test(CreateInvoice::class); /* Assert */ - $component->assertFormSet(['invoice_terms' => 'Payment due within 30 days.']); + $component->assertFormSet(['invoice_terms' => 'Payment due within 30 days.
']); } #[Test] @@ -43,7 +43,7 @@ public function it_leaves_invoice_terms_empty_when_no_company_setting_exists(): ->test(CreateInvoice::class); /* Assert */ - $component->assertFormSet(['invoice_terms' => null]); + $component->assertFormSet(['invoice_terms' => '']); } #[Test] @@ -58,7 +58,7 @@ public function it_does_not_leak_another_companys_default_terms(): void ->test(CreateInvoice::class); /* Assert */ - $component->assertFormSet(['invoice_terms' => null]); + $component->assertFormSet(['invoice_terms' => '']); } #[Test] @@ -73,6 +73,6 @@ public function the_prefilled_terms_can_be_overridden_before_saving(): void ->fillForm(['invoice_terms' => 'Custom terms for this invoice.']); /* Assert */ - $component->assertFormSet(['invoice_terms' => 'Custom terms for this invoice.']); + $component->assertFormSet(['invoice_terms' => 'Custom terms for this invoice.
']); } } diff --git a/Modules/Invoices/Tests/Feature/InvoicesTest.php b/Modules/Invoices/Tests/Feature/InvoicesTest.php index d1ce931f3..1b5bc67f5 100644 --- a/Modules/Invoices/Tests/Feature/InvoicesTest.php +++ b/Modules/Invoices/Tests/Feature/InvoicesTest.php @@ -440,7 +440,7 @@ public function it_persists_the_notes_and_invoice_terms_fields_on_create(): void $this->assertDatabaseHas('invoices', [ 'invoice_number' => 'INV-000042', 'summary' => 'Created via test', - 'terms' => 'Net 30, late fees apply.', + 'terms' => 'Net 30, late fees apply.
', ]); } @@ -720,7 +720,64 @@ public function it_persists_the_invoice_terms_field_on_update(): void ->assertSuccessful() ->assertHasNoErrors(); - $this->assertDatabaseHas('invoices', ['id' => $invoice->id, 'terms' => 'Net 30, late fees apply.']); + $this->assertDatabaseHas('invoices', ['id' => $invoice->id, 'terms' => 'Net 30, late fees apply.
']); + } + + #[Test] + #[Group('crud')] + public function it_persists_rich_text_formatting_in_the_invoice_terms_field(): void + { + /* Arrange */ + $customer = Relation::factory()->for($this->company)->customer()->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); + $invoice = Invoice::factory()->for($this->company)->create([ + 'customer_id' => $customer->id, + 'numbering_id' => $documentGroup->id, + 'user_id' => $this->user->id, + 'terms' => null, + ]); + + /* Act */ + $component = Livewire::actingAs($this->user) + ->test(EditInvoice::class, ['record' => $invoice->id]) + ->fillForm(['invoice_terms' => 'Net 30. See our policy.
']) + ->call('save'); + + /* Assert */ + $component + ->assertSuccessful() + ->assertHasNoErrors(); + + $this->assertStringContainsString('Net 30', $invoice->fresh()->terms); + $this->assertStringContainsString('our policy', $invoice->fresh()->terms); + } + + #[Test] + #[Group('crud')] + public function it_strips_script_tags_from_the_invoice_terms_field_on_save(): void + { + /* Arrange */ + $customer = Relation::factory()->for($this->company)->customer()->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); + $invoice = Invoice::factory()->for($this->company)->create([ + 'customer_id' => $customer->id, + 'numbering_id' => $documentGroup->id, + 'user_id' => $this->user->id, + 'terms' => null, + ]); + + /* Act */ + $component = Livewire::actingAs($this->user) + ->test(EditInvoice::class, ['record' => $invoice->id]) + ->fillForm(['invoice_terms' => 'Terms
']) + ->call('save'); + + /* Assert */ + $component + ->assertSuccessful() + ->assertHasNoErrors(); + + $this->assertStringNotContainsString('