diff --git a/app/Actions/StartCustomerProvisioning.php b/app/Actions/StartCustomerProvisioning.php index b611b23..c1f7b01 100644 --- a/app/Actions/StartCustomerProvisioning.php +++ b/app/Actions/StartCustomerProvisioning.php @@ -46,7 +46,9 @@ class StartCustomerProvisioning 'pipeline' => 'customer', 'status' => ProvisioningRun::STATUS_PENDING, 'current_step' => 0, - 'context' => [], + // Snapshot resolved branding so retries apply identical inputs + // (unset customer → CluPilot defaults, resolved once here). + 'context' => ['branding' => $customer->brandingResolved()], ]); return [$order, $run]; diff --git a/app/Livewire/ConfirmCancelPackage.php b/app/Livewire/ConfirmCancelPackage.php new file mode 100644 index 0000000..55eaace --- /dev/null +++ b/app/Livewire/ConfirmCancelPackage.php @@ -0,0 +1,63 @@ +customer(); + $instance = $customer?->instances()->latest('id')->first(); + + if ($instance === null || $instance->status === 'cancellation_scheduled') { + return $this->redirectRoute('settings', navigate: true); + } + + // Typed confirmation must match the instance subdomain. + if (trim($this->confirmName) !== (string) $instance->subdomain) { + $this->addError('confirmName', __('settings.cancel_mismatch')); + + return; + } + + $instance->update([ + 'status' => 'cancellation_scheduled', + 'cancel_requested_at' => now(), + 'service_ends_at' => now()->endOfMonth(), // end of the billing term + ]); + + return $this->redirectRoute('settings', navigate: true); + } + + private function customer(): ?Customer + { + $user = auth()->user(); + if (! $user) { + return null; + } + + return Customer::query()->where('user_id', $user->id)->first() + ?? Customer::query()->where('email', $user->email)->first(); + } + + public function render() + { + $instance = $this->customer()?->instances()->latest('id')->first(); + + return view('livewire.confirm-cancel-package', [ + 'subdomain' => $instance?->subdomain ?? '', + ]); + } +} diff --git a/app/Livewire/ConfirmCloseAccount.php b/app/Livewire/ConfirmCloseAccount.php new file mode 100644 index 0000000..79a4c33 --- /dev/null +++ b/app/Livewire/ConfirmCloseAccount.php @@ -0,0 +1,67 @@ +customer(); + if ($customer === null) { + return $this->redirectRoute('settings', navigate: true); + } + + if ($this->hasActivePackage($customer)) { + $this->addError('confirmWord', __('settings.close_blocked')); + + return; + } + + if (mb_strtoupper(trim($this->confirmWord)) !== __('settings.close_keyword')) { + $this->addError('confirmWord', __('settings.close_mismatch')); + + return; + } + + $customer->update(['closed_at' => now(), 'status' => 'closed']); + + return $this->redirectRoute('settings', navigate: true); + } + + private function hasActivePackage(Customer $customer): bool + { + return $customer->instances() + ->whereNotIn('status', ['cancelled', 'deprovisioned']) + ->exists(); + } + + private function customer(): ?Customer + { + $user = auth()->user(); + if (! $user) { + return null; + } + + return Customer::query()->where('user_id', $user->id)->first() + ?? Customer::query()->where('email', $user->email)->first(); + } + + public function render() + { + $customer = $this->customer(); + + return view('livewire.confirm-close-account', [ + 'blocked' => $customer !== null && $this->hasActivePackage($customer), + ]); + } +} diff --git a/app/Livewire/Settings.php b/app/Livewire/Settings.php new file mode 100644 index 0000000..b5df2e3 --- /dev/null +++ b/app/Livewire/Settings.php @@ -0,0 +1,164 @@ +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->customer(); + 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->customer(); + 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', + ]); + + if ($this->logo !== null) { + // Replace any previous logo; validated MIME/size above. + if ($this->brandLogoPath !== null) { + Storage::disk('public')->delete($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, + ]); + + $this->dispatch('notify', message: __('settings.branding_saved')); + } + + public function removeLogo(): void + { + $c = $this->customer(); + 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')); + } + + private function customer(): ?Customer + { + $user = auth()->user(); + if (! $user) { + return null; + } + + return Customer::query()->where('user_id', $user->id)->first() + ?? Customer::query()->where('email', $user->email)->first(); + } + + #[Layout('layouts.portal-app')] + public function render() + { + $c = $this->customer(); + $instance = $c?->instances()->latest('id')->first(); + + return view('livewire.settings', [ + 'customer' => $c, + 'instance' => $instance, + 'branding' => $c?->brandingResolved(), + 'logoUrl' => $this->brandLogoPath ? Storage::disk('public')->url($this->brandLogoPath) : null, + 'hasActivePackage' => $instance !== null && ! in_array($instance->status, ['cancelled', 'deprovisioned'], true), + 'cancellationScheduled' => $instance !== null && $instance->status === 'cancellation_scheduled', + ]); + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php index 1ae7df5..b19b849 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -17,7 +17,44 @@ class Customer extends Model /** @use HasFactory<\Database\Factories\CustomerFactory> */ use HasFactory, HasUuid; - protected $fillable = ['user_id', 'name', 'email', 'locale', 'stripe_customer_id', 'status']; + protected $fillable = [ + 'user_id', 'name', 'contact_name', 'email', 'phone', 'vat_id', 'billing_address', + 'locale', 'stripe_customer_id', 'status', 'closed_at', + 'brand_display_name', 'brand_logo_path', 'brand_primary_color', 'brand_accent_color', + ]; + + protected function casts(): array + { + return ['closed_at' => 'datetime']; + } + + public function seats(): HasMany + { + return $this->hasMany(Seat::class); + } + + /** + * Resolve branding: customer values where set, else CluPilot defaults. Used + * for previews and snapshotted into the provisioning run so retries are + * deterministic. NULL is stored for "unset" — defaults are never copied in. + * + * @return array{display_name:string,logo_path:?string,primary_color:string,accent_color:string,is_default:bool} + */ + public function brandingResolved(): array + { + $defaults = (array) config('provisioning.branding_defaults'); + + return [ + 'display_name' => $this->brand_display_name ?: ($defaults['display_name'] ?? 'CluPilot'), + 'logo_path' => $this->brand_logo_path ?: ($defaults['logo_path'] ?? null), + 'primary_color' => $this->brand_primary_color ?: ($defaults['primary_color'] ?? '#f97316'), + 'accent_color' => $this->brand_accent_color ?: ($defaults['accent_color'] ?? '#c2560a'), + 'is_default' => $this->brand_display_name === null + && $this->brand_logo_path === null + && $this->brand_primary_color === null + && $this->brand_accent_color === null, + ]; + } public function user(): BelongsTo { diff --git a/app/Models/Instance.php b/app/Models/Instance.php index 1100229..6f2ac36 100644 --- a/app/Models/Instance.php +++ b/app/Models/Instance.php @@ -16,7 +16,7 @@ class Instance extends Model protected $fillable = [ 'customer_id', 'order_id', 'host_id', 'vmid', 'guest_ip', 'plan', 'quota_gb', 'disk_gb', 'ram_mb', 'cores', 'subdomain', 'custom_domain', 'nc_admin_ref', - 'route_written', 'cert_ok', 'status', + 'route_written', 'cert_ok', 'status', 'cancel_requested_at', 'service_ends_at', ]; protected $hidden = ['nc_admin_ref']; @@ -32,6 +32,8 @@ class Instance extends Model 'disk_gb' => 'integer', 'ram_mb' => 'integer', 'cores' => 'integer', + 'cancel_requested_at' => 'datetime', + 'service_ends_at' => 'datetime', ]; } diff --git a/app/Models/Seat.php b/app/Models/Seat.php new file mode 100644 index 0000000..eb3577b --- /dev/null +++ b/app/Models/Seat.php @@ -0,0 +1,28 @@ + */ + use HasFactory, HasUuid; + + public const ROLES = ['owner', 'admin', 'member', 'readonly']; + + protected $fillable = ['customer_id', 'email', 'name', 'role', 'status', 'invited_at']; + + protected function casts(): array + { + return ['invited_at' => 'datetime']; + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } +} diff --git a/config/provisioning.php b/config/provisioning.php index 2382c97..df1fa14 100644 --- a/config/provisioning.php +++ b/config/provisioning.php @@ -57,6 +57,15 @@ return [ 'enterprise' => ['quota_gb' => 2000, 'disk_gb' => 2100, 'ram_mb' => 32768, 'cores' => 16, 'seats' => 500, 'performance' => 'dedicated', 'price_cents' => 79900, 'template_vmid' => 9000], ], + // Default branding applied when a customer has not set their own (resolved by + // Customer::brandingResolved(); NULL customer fields fall back to these). + 'branding_defaults' => [ + 'display_name' => 'CluPilot Cloud', + 'logo_path' => null, // null → CluPilot logo used by the provisioner + 'primary_color' => '#f97316', + 'accent_color' => '#c2560a', + ], + // Extra storage add-on (per unit) and the add-on catalogue (labels in lang/*/billing.php). 'storage_addon' => ['gb' => 100, 'price_cents' => 1000], 'addons' => [ diff --git a/database/factories/SeatFactory.php b/database/factories/SeatFactory.php new file mode 100644 index 0000000..744a320 --- /dev/null +++ b/database/factories/SeatFactory.php @@ -0,0 +1,30 @@ + */ +class SeatFactory extends Factory +{ + protected $model = Seat::class; + + public function definition(): array + { + return [ + 'customer_id' => Customer::factory(), + 'email' => $this->faker->unique()->safeEmail(), + 'name' => $this->faker->name(), + 'role' => 'member', + 'status' => 'active', + 'invited_at' => now(), + ]; + } + + public function owner(): static + { + return $this->state(['role' => 'owner', 'status' => 'active']); + } +} diff --git a/database/migrations/2026_07_25_090005_add_profile_and_branding_to_customers.php b/database/migrations/2026_07_25_090005_add_profile_and_branding_to_customers.php new file mode 100644 index 0000000..1fa506a --- /dev/null +++ b/database/migrations/2026_07_25_090005_add_profile_and_branding_to_customers.php @@ -0,0 +1,39 @@ +string('contact_name')->nullable()->after('name'); + $table->string('phone')->nullable()->after('email'); + $table->string('vat_id')->nullable()->after('phone'); + $table->text('billing_address')->nullable()->after('vat_id'); + + // Branding — NULL means "use CluPilot defaults" (resolved deterministically). + $table->string('brand_display_name')->nullable()->after('billing_address'); + $table->string('brand_logo_path')->nullable()->after('brand_display_name'); + $table->string('brand_primary_color')->nullable()->after('brand_logo_path'); + $table->string('brand_accent_color')->nullable()->after('brand_primary_color'); + + // Account lifecycle: set when the customer closes their CluPilot account. + $table->timestamp('closed_at')->nullable()->after('status'); + }); + } + + public function down(): void + { + Schema::table('customers', function (Blueprint $table) { + $table->dropColumn([ + 'contact_name', 'phone', 'vat_id', 'billing_address', + 'brand_display_name', 'brand_logo_path', 'brand_primary_color', 'brand_accent_color', + 'closed_at', + ]); + }); + } +}; diff --git a/database/migrations/2026_07_25_090006_add_cancellation_to_instances.php b/database/migrations/2026_07_25_090006_add_cancellation_to_instances.php new file mode 100644 index 0000000..52471fc --- /dev/null +++ b/database/migrations/2026_07_25_090006_add_cancellation_to_instances.php @@ -0,0 +1,28 @@ +timestamp('cancel_requested_at')->nullable()->after('status'); + $table->timestamp('service_ends_at')->nullable()->after('cancel_requested_at'); + }); + } + + public function down(): void + { + Schema::table('instances', function (Blueprint $table) { + $table->dropColumn(['cancel_requested_at', 'service_ends_at']); + }); + } +}; diff --git a/database/migrations/2026_07_25_090007_create_seats_table.php b/database/migrations/2026_07_25_090007_create_seats_table.php new file mode 100644 index 0000000..7c6bb85 --- /dev/null +++ b/database/migrations/2026_07_25_090007_create_seats_table.php @@ -0,0 +1,34 @@ +id(); + $table->uuid('uuid')->unique(); + $table->foreignId('customer_id')->constrained()->cascadeOnDelete(); + $table->string('email'); + $table->string('name')->nullable(); + $table->string('role')->default('member'); // owner | admin | member | readonly + $table->string('status')->default('invited'); // invited | active | revoked + $table->timestamp('invited_at')->nullable(); + $table->timestamps(); + + $table->unique(['customer_id', 'email']); + }); + } + + public function down(): void + { + Schema::dropIfExists('seats'); + } +}; diff --git a/lang/de/dashboard.php b/lang/de/dashboard.php index 07525c3..920a497 100644 --- a/lang/de/dashboard.php +++ b/lang/de/dashboard.php @@ -14,6 +14,7 @@ return [ 'backups' => 'Backups', 'invoices' => 'Rechnungen', 'billing' => 'Paket & Addons', + 'settings' => 'Einstellungen', 'support' => 'Support', ], diff --git a/lang/de/settings.php b/lang/de/settings.php new file mode 100644 index 0000000..59d74e9 --- /dev/null +++ b/lang/de/settings.php @@ -0,0 +1,56 @@ + 'Einstellungen', + 'subtitle' => 'Firmendaten, Branding und Ihr Paket verwalten.', + 'save' => 'Speichern', + + 'company_title' => 'Firmendaten', + 'company_name' => 'Firmenname', + 'contact_name' => 'Ansprechpartner', + 'phone' => 'Telefon', + 'vat_id' => 'USt-IdNr.', + 'billing_address' => 'Rechnungsadresse', + 'profile_saved' => 'Firmendaten gespeichert.', + + 'branding_title' => 'Branding', + 'branding_sub' => 'Logo und Farben werden bei der Bereitstellung auf Ihre Cloud angewendet. Ohne Angabe nutzen wir die CluPilot-Standardwerte.', + 'brand_display_name' => 'Anzeigename', + 'brand_primary' => 'Primärfarbe', + 'brand_accent' => 'Akzentfarbe', + 'brand_logo' => 'Logo', + 'brand_logo_hint' => 'PNG oder WebP, max. 2 MB. Transparenter Hintergrund empfohlen.', + 'brand_logo_remove' => 'Logo entfernen', + 'brand_default' => 'Standard', + 'brand_using_default' => 'Es werden aktuell die CluPilot-Standardwerte verwendet.', + 'branding_saved' => 'Branding gespeichert.', + + 'package_title' => 'Paket', + 'package_active' => 'Aktives Paket: :plan.', + 'no_package' => 'Kein aktives Paket.', + 'cancel_cta' => 'Paket kündigen', + 'cancel_scheduled_title' => 'Kündigung vorgemerkt', + 'cancel_scheduled_body' => 'Ihr Paket endet am :date. Danach erhalten Sie Ihren Datenexport.', + + 'cancel_title' => 'Paket kündigen?', + 'cancel_body' => 'Ihr Paket wird zum Ende der Abrechnungsperiode gekündigt.', + 'cancel_point_term' => 'Wirksam zum Ende der Laufzeit — bis dahin bleibt alles verfügbar.', + 'cancel_point_export' => 'Zum Laufzeitende erhalten Sie einen vollständigen Datenexport.', + 'cancel_point_irreversible' => 'Die Kündigung ist nach Bestätigung verbindlich.', + 'cancel_confirm_label' => 'Zum Bestätigen „:name" eingeben:', + 'cancel_confirm' => 'Verbindlich kündigen', + 'cancel_mismatch' => 'Die Eingabe stimmt nicht mit Ihrer Cloud-Adresse überein.', + 'keep' => 'Abbrechen', + + 'close_account_title' => 'Konto schließen', + 'close_account_sub' => 'Ihr CluPilot-Konto dauerhaft schließen.', + 'close_cta' => 'Konto schließen', + 'close_title' => 'Konto endgültig schließen?', + 'close_body' => 'Ihr Zugang wird beendet. Rechnungsbelege bleiben gesetzlich aufbewahrt.', + 'close_confirm_label' => 'Zum Bestätigen „:word" eingeben:', + 'close_confirm' => 'Konto schließen', + 'close_keyword' => 'LÖSCHEN', + 'close_mismatch' => 'Bitte das Bestätigungswort korrekt eingeben.', + 'close_blocked_title' => 'Noch nicht möglich', + 'close_blocked' => 'Bitte kündigen Sie zuerst Ihr aktives Paket, bevor Sie das Konto schließen.', +]; diff --git a/lang/en/dashboard.php b/lang/en/dashboard.php index 242a24c..9d676f5 100644 --- a/lang/en/dashboard.php +++ b/lang/en/dashboard.php @@ -14,6 +14,7 @@ return [ 'backups' => 'Backups', 'invoices' => 'Invoices', 'billing' => 'Plan & add-ons', + 'settings' => 'Settings', 'support' => 'Support', ], diff --git a/lang/en/settings.php b/lang/en/settings.php new file mode 100644 index 0000000..8d16e99 --- /dev/null +++ b/lang/en/settings.php @@ -0,0 +1,56 @@ + 'Settings', + 'subtitle' => 'Manage your company details, branding and package.', + 'save' => 'Save', + + 'company_title' => 'Company details', + 'company_name' => 'Company name', + 'contact_name' => 'Contact person', + 'phone' => 'Phone', + 'vat_id' => 'VAT ID', + 'billing_address' => 'Billing address', + 'profile_saved' => 'Company details saved.', + + 'branding_title' => 'Branding', + 'branding_sub' => 'Your logo and colors are applied to your cloud during provisioning. If left empty we use the CluPilot defaults.', + 'brand_display_name' => 'Display name', + 'brand_primary' => 'Primary color', + 'brand_accent' => 'Accent color', + 'brand_logo' => 'Logo', + 'brand_logo_hint' => 'PNG or WebP, max 2 MB. Transparent background recommended.', + 'brand_logo_remove' => 'Remove logo', + 'brand_default' => 'Default', + 'brand_using_default' => 'Currently using the CluPilot defaults.', + 'branding_saved' => 'Branding saved.', + + 'package_title' => 'Package', + 'package_active' => 'Active package: :plan.', + 'no_package' => 'No active package.', + 'cancel_cta' => 'Cancel package', + 'cancel_scheduled_title' => 'Cancellation scheduled', + 'cancel_scheduled_body' => 'Your package ends on :date. You will then receive your data export.', + + 'cancel_title' => 'Cancel package?', + 'cancel_body' => 'Your package will be cancelled at the end of the billing period.', + 'cancel_point_term' => 'Effective at the end of the term — everything stays available until then.', + 'cancel_point_export' => 'At the end of the term you receive a full data export.', + 'cancel_point_irreversible' => 'Once confirmed, the cancellation is binding.', + 'cancel_confirm_label' => 'Type “:name” to confirm:', + 'cancel_confirm' => 'Cancel for good', + 'cancel_mismatch' => 'That does not match your cloud address.', + 'keep' => 'Keep', + + 'close_account_title' => 'Close account', + 'close_account_sub' => 'Permanently close your CluPilot account.', + 'close_cta' => 'Close account', + 'close_title' => 'Close account permanently?', + 'close_body' => 'Your access will end. Invoices are retained as legally required.', + 'close_confirm_label' => 'Type “:word” to confirm:', + 'close_confirm' => 'Close account', + 'close_keyword' => 'DELETE', + 'close_mismatch' => 'Please type the confirmation word correctly.', + 'close_blocked_title' => 'Not possible yet', + 'close_blocked' => 'Please cancel your active package before closing the account.', +]; diff --git a/resources/views/components/ui/icon.blade.php b/resources/views/components/ui/icon.blade.php index db6c28a..5a942ce 100644 --- a/resources/views/components/ui/icon.blade.php +++ b/resources/views/components/ui/icon.blade.php @@ -27,6 +27,7 @@ 'arrow-left' => '', 'rotate-ccw' => '', 'trash-2' => '', + 'settings' => '', ]; $body = $icons[$name] ?? ''; @endphp diff --git a/resources/views/layouts/portal-app.blade.php b/resources/views/layouts/portal-app.blade.php index 1d2d848..51bc39a 100644 --- a/resources/views/layouts/portal-app.blade.php +++ b/resources/views/layouts/portal-app.blade.php @@ -42,6 +42,7 @@ ['backups', 'database', 'backups'], ['invoices', 'receipt', 'invoices'], ['billing', 'box', 'billing'], + ['settings', 'settings', 'settings'], ['support', 'life-buoy', 'support'], ] as [$route, $icon, $key]) @@ -102,6 +103,7 @@ + @livewire('wire-elements-modal') @livewireScripts diff --git a/resources/views/livewire/confirm-cancel-package.blade.php b/resources/views/livewire/confirm-cancel-package.blade.php new file mode 100644 index 0000000..3abe4f2 --- /dev/null +++ b/resources/views/livewire/confirm-cancel-package.blade.php @@ -0,0 +1,30 @@ +
+
+ + + +
+

{{ __('settings.cancel_title') }}

+

{{ __('settings.cancel_body') }}

+
+
+ +
    +
  • {{ __('settings.cancel_point_term') }}
  • +
  • {{ __('settings.cancel_point_export') }}
  • +
  • {{ __('settings.cancel_point_irreversible') }}
  • +
+ +
+ + + @error('confirmName')

{{ $message }}

@enderror +
+ +
+ {{ __('settings.keep') }} + + {{ __('settings.cancel_confirm') }} + +
+
diff --git a/resources/views/livewire/confirm-close-account.blade.php b/resources/views/livewire/confirm-close-account.blade.php new file mode 100644 index 0000000..14dfac6 --- /dev/null +++ b/resources/views/livewire/confirm-close-account.blade.php @@ -0,0 +1,37 @@ +
+ @if ($blocked) +
+ + + +
+

{{ __('settings.close_blocked_title') }}

+

{{ __('settings.close_blocked') }}

+
+
+
+ {{ __('settings.keep') }} +
+ @else +
+ + + +
+

{{ __('settings.close_title') }}

+

{{ __('settings.close_body') }}

+
+
+
+ + + @error('confirmWord')

{{ $message }}

@enderror +
+
+ {{ __('settings.keep') }} + + {{ __('settings.close_confirm') }} + +
+ @endif +
diff --git a/resources/views/livewire/settings.blade.php b/resources/views/livewire/settings.blade.php new file mode 100644 index 0000000..1b1415f --- /dev/null +++ b/resources/views/livewire/settings.blade.php @@ -0,0 +1,124 @@ +
+
+

{{ __('settings.title') }}

+

{{ __('settings.subtitle') }}

+
+ + {{-- Company / billing profile --}} +
+

{{ __('settings.company_title') }}

+
+ + + + +
+
+ + + @error('billingAddress')

{{ $message }}

@enderror +
+
+ {{ __('settings.save') }} +
+
+ + {{-- Branding --}} +
+
+

{{ __('settings.branding_title') }}

+

{{ __('settings.branding_sub') }}

+
+ + + +
+
+ +
+ + +
+ @error('brandPrimary')

{{ $message }}

@enderror +
+
+ +
+ + +
+ @error('brandAccent')

{{ $message }}

@enderror +
+
+ +
+ +
+
+ @if ($logo) + + @elseif ($logoUrl) + + @else + {{ __('settings.brand_default') }} + @endif +
+
+ +

{{ __('settings.brand_logo_hint') }}

+ @if ($logoUrl) + + @endif +
+
+ @error('logo')

{{ $message }}

@enderror +
+ + @if ($branding['is_default'] ?? false) +

{{ __('settings.brand_using_default') }}

+ @endif + +
+ {{ __('settings.save') }} +
+ + + {{-- Package & account lifecycle --}} +
+

{{ __('settings.package_title') }}

+ + @if ($cancellationScheduled) +
+ +
+

{{ __('settings.cancel_scheduled_title') }}

+

{{ __('settings.cancel_scheduled_body', ['date' => $instance?->service_ends_at?->isoFormat('LL')]) }}

+
+
+ @elseif ($hasActivePackage) +
+

{{ __('settings.package_active', ['plan' => $instance ? __('billing.plan.'.$instance->plan) : '—']) }}

+ + {{ __('settings.cancel_cta') }} + +
+ @else +

{{ __('settings.no_package') }}

+ @endif + +
+
+
+

{{ __('settings.close_account_title') }}

+

{{ __('settings.close_account_sub') }}

+
+ + {{ __('settings.close_cta') }} + +
+
+
+
diff --git a/routes/web.php b/routes/web.php index ba9dce3..055c323 100644 --- a/routes/web.php +++ b/routes/web.php @@ -43,6 +43,7 @@ Route::middleware('auth')->group(function () { Route::get('/backups', Backups::class)->name('backups'); Route::get('/invoices', Invoices::class)->name('invoices'); Route::get('/billing', Billing::class)->name('billing'); + Route::get('/settings', \App\Livewire\Settings::class)->name('settings'); Route::get('/support', Support::class)->name('support'); // Return from an admin impersonation session (accessible as the customer user). diff --git a/tests/Feature/SettingsTest.php b/tests/Feature/SettingsTest.php new file mode 100644 index 0000000..f535d62 --- /dev/null +++ b/tests/Feature/SettingsTest.php @@ -0,0 +1,109 @@ +create(['email' => 's@set.test', 'is_admin' => false]); + $customer = Customer::factory()->create(['email' => 's@set.test', 'user_id' => $user->id, 'name' => 'Acme', 'status' => 'active']); + if ($withInstance) { + $order = Order::factory()->create(['customer_id' => $customer->id, 'plan' => 'team']); + Instance::factory()->create(['customer_id' => $customer->id, 'order_id' => $order->id, 'plan' => 'team', 'status' => 'active', 'subdomain' => 'acme']); + } + + return compact('user', 'customer'); +} + +it('gates settings to authenticated users', function () { + $this->get(route('settings'))->assertRedirect('/login'); +}); + +it('saves the company profile', function () { + ['user' => $user, 'customer' => $customer] = settingsSetup(); + + Livewire::actingAs($user)->test(Settings::class) + ->set('companyName', 'Acme GmbH') + ->set('vatId', 'ATU12345678') + ->set('billingAddress', "Hauptstraße 1\n1010 Wien") + ->call('saveProfile') + ->assertHasNoErrors(); + + $customer->refresh(); + expect($customer->name)->toBe('Acme GmbH') + ->and($customer->vat_id)->toBe('ATU12345678') + ->and($customer->billing_address)->toContain('Wien'); +}); + +it('saves branding and resolves defaults when unset', function () { + ['user' => $user, 'customer' => $customer] = settingsSetup(); + + // Unset → resolver reports defaults. + expect($customer->brandingResolved()['is_default'])->toBeTrue(); + + Livewire::actingAs($user)->test(Settings::class) + ->set('brandDisplayName', 'Acme Cloud') + ->set('brandPrimary', '#123456') + ->call('saveBranding') + ->assertHasNoErrors(); + + $customer->refresh(); + expect($customer->brand_display_name)->toBe('Acme Cloud') + ->and($customer->brandingResolved()['primary_color'])->toBe('#123456') + ->and($customer->brandingResolved()['is_default'])->toBeFalse(); +}); + +it('rejects an invalid brand colour', function () { + ['user' => $user] = settingsSetup(); + + Livewire::actingAs($user)->test(Settings::class) + ->set('brandPrimary', 'notacolor') + ->call('saveBranding') + ->assertHasErrors(['brandPrimary']); +}); + +it('schedules package cancellation only with the correct typed confirmation', function () { + ['user' => $user, 'customer' => $customer] = settingsSetup(); + $instance = $customer->instances()->first(); + + // Wrong confirmation → no change. + Livewire::actingAs($user)->test(ConfirmCancelPackage::class) + ->set('confirmName', 'wrong') + ->call('cancelPackage') + ->assertHasErrors(['confirmName']); + expect($instance->fresh()->status)->toBe('active'); + + // Correct confirmation → scheduled. + Livewire::actingAs($user)->test(ConfirmCancelPackage::class) + ->set('confirmName', 'acme') + ->call('cancelPackage'); + $instance->refresh(); + expect($instance->status)->toBe('cancellation_scheduled') + ->and($instance->service_ends_at)->not->toBeNull(); +}); + +it('blocks closing the account while a package is active', function () { + ['user' => $user, 'customer' => $customer] = settingsSetup(); + + Livewire::actingAs($user)->test(ConfirmCloseAccount::class) + ->set('confirmWord', __('settings.close_keyword')) + ->call('closeAccount') + ->assertHasErrors(['confirmWord']); + expect($customer->fresh()->closed_at)->toBeNull(); +}); + +it('closes the account when no package is active', function () { + ['user' => $user, 'customer' => $customer] = settingsSetup(withInstance: false); + + Livewire::actingAs($user)->test(ConfirmCloseAccount::class) + ->set('confirmWord', __('settings.close_keyword')) + ->call('closeAccount'); + + expect($customer->fresh()->closed_at)->not->toBeNull(); +});