feat(portal): settings page — company profile, branding (logo+colors), cancel package, close account

- customers gain profile + branding + closed_at; instances gain cancellation
  fields; branding resolver (NULL -> CluPilot defaults) snapshotted into the
  provisioning run context
- cancel package: term-end, irreversible, typed-confirm modal (R5)
- close account: guarded (no active package), typed-confirm modal (R5)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 14:36:52 +02:00
parent ef110b06db
commit df10448e5d
23 changed files with 924 additions and 3 deletions

View File

@ -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];

View File

@ -0,0 +1,63 @@
<?php
namespace App\Livewire;
use App\Models\Customer;
use App\Models\Instance;
use LivewireUI\Modal\ModalComponent;
/**
* Cancel the customer's package (R5). Per the founder's decision: effective at
* the end of the billing term, irreversible once confirmed; at term end the
* customer receives a finished data export, then the instance is deprovisioned
* (both mocked for now). Requires typing the instance subdomain to confirm.
*/
class ConfirmCancelPackage extends ModalComponent
{
public string $confirmName = '';
public function cancelPackage()
{
$customer = $this->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 ?? '',
]);
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace App\Livewire;
use App\Models\Customer;
use LivewireUI\Modal\ModalComponent;
/**
* Close the whole CluPilot account (R5). Guarded: only allowed when no active
* package remains an active subscription must be cancelled first. Sets
* closed_at; financial records are retained. Requires typing "LOESCHEN".
*/
class ConfirmCloseAccount extends ModalComponent
{
public string $confirmWord = '';
public function closeAccount()
{
$customer = $this->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),
]);
}
}

164
app/Livewire/Settings.php Normal file
View File

@ -0,0 +1,164 @@
<?php
namespace App\Livewire;
use App\Models\Customer;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Livewire\WithFileUploads;
class Settings extends Component
{
use 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->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',
]);
}
}

View File

@ -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
{

View File

@ -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',
];
}

28
app/Models/Seat.php Normal file
View File

@ -0,0 +1,28 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Seat extends Model
{
/** @use HasFactory<\Database\Factories\SeatFactory> */
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);
}
}

View File

@ -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' => [

View File

@ -0,0 +1,30 @@
<?php
namespace Database\Factories;
use App\Models\Customer;
use App\Models\Seat;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<Seat> */
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']);
}
}

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('customers', function (Blueprint $table) {
// Company / billing profile (edited on the customer Settings page).
$table->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',
]);
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Package cancellation: effective at end of billing term, irreversible once set.
* At service_ends_at the data export is delivered and the instance is deprovisioned
* (both mocked for now). status gains 'cancellation_scheduled'.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('instances', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Seats = the people who use a customer's cloud, managed from the portal.
* Counted against the plan's seat allowance. Invites are mocked for now.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('seats', function (Blueprint $table) {
$table->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');
}
};

View File

@ -14,6 +14,7 @@ return [
'backups' => 'Backups',
'invoices' => 'Rechnungen',
'billing' => 'Paket & Addons',
'settings' => 'Einstellungen',
'support' => 'Support',
],

56
lang/de/settings.php Normal file
View File

@ -0,0 +1,56 @@
<?php
return [
'title' => '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.',
];

View File

@ -14,6 +14,7 @@ return [
'backups' => 'Backups',
'invoices' => 'Invoices',
'billing' => 'Plan & add-ons',
'settings' => 'Settings',
'support' => 'Support',
],

56
lang/en/settings.php Normal file
View File

@ -0,0 +1,56 @@
<?php
return [
'title' => '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.',
];

View File

@ -27,6 +27,7 @@
'arrow-left' => '<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>',
'rotate-ccw' => '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>',
'trash-2' => '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>',
'settings' => '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
];
$body = $icons[$name] ?? '';
@endphp

View File

@ -42,6 +42,7 @@
['backups', 'database', 'backups'],
['invoices', 'receipt', 'invoices'],
['billing', 'box', 'billing'],
['settings', 'settings', 'settings'],
['support', 'life-buoy', 'support'],
] as [$route, $icon, $key])
<x-ui.nav-item :href="route($route)" :active="request()->routeIs($route)">
@ -102,6 +103,7 @@
<span x-text="msg"></span>
</div>
@livewire('wire-elements-modal')
@livewireScripts
</body>
</html>

View File

@ -0,0 +1,30 @@
<div class="rounded-xl bg-surface p-6">
<div class="flex items-start gap-3">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-warning-bg text-warning">
<x-ui.icon name="alert-triangle" class="size-5" />
</span>
<div class="min-w-0">
<h3 class="text-base font-semibold text-ink">{{ __('settings.cancel_title') }}</h3>
<p class="mt-1 text-sm text-muted">{{ __('settings.cancel_body') }}</p>
</div>
</div>
<ul class="mt-4 space-y-1.5 text-sm text-body">
<li class="flex items-start gap-2"><x-ui.icon name="check" class="mt-0.5 size-4 shrink-0 text-muted" />{{ __('settings.cancel_point_term') }}</li>
<li class="flex items-start gap-2"><x-ui.icon name="check" class="mt-0.5 size-4 shrink-0 text-muted" />{{ __('settings.cancel_point_export') }}</li>
<li class="flex items-start gap-2"><x-ui.icon name="alert-triangle" class="mt-0.5 size-4 shrink-0 text-warning" />{{ __('settings.cancel_point_irreversible') }}</li>
</ul>
<div class="mt-4">
<label class="text-sm text-body">{{ __('settings.cancel_confirm_label', ['name' => $subdomain]) }}</label>
<input type="text" wire:model="confirmName" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink" />
@error('confirmName')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<div class="mt-6 flex justify-end gap-3">
<x-ui.button variant="secondary" x-on:click="$dispatch('closeModal')">{{ __('settings.keep') }}</x-ui.button>
<x-ui.button variant="danger" wire:click="cancelPackage" wire:loading.attr="disabled">
{{ __('settings.cancel_confirm') }}
</x-ui.button>
</div>
</div>

View File

@ -0,0 +1,37 @@
<div class="rounded-xl bg-surface p-6">
@if ($blocked)
<div class="flex items-start gap-3">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-warning-bg text-warning">
<x-ui.icon name="alert-triangle" class="size-5" />
</span>
<div class="min-w-0">
<h3 class="text-base font-semibold text-ink">{{ __('settings.close_blocked_title') }}</h3>
<p class="mt-1 text-sm text-muted">{{ __('settings.close_blocked') }}</p>
</div>
</div>
<div class="mt-6 flex justify-end">
<x-ui.button variant="secondary" x-on:click="$dispatch('closeModal')">{{ __('settings.keep') }}</x-ui.button>
</div>
@else
<div class="flex items-start gap-3">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-danger-bg text-danger">
<x-ui.icon name="alert-triangle" class="size-5" />
</span>
<div class="min-w-0">
<h3 class="text-base font-semibold text-ink">{{ __('settings.close_title') }}</h3>
<p class="mt-1 text-sm text-muted">{{ __('settings.close_body') }}</p>
</div>
</div>
<div class="mt-4">
<label class="text-sm text-body">{{ __('settings.close_confirm_label', ['word' => __('settings.close_keyword')]) }}</label>
<input type="text" wire:model="confirmWord" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink" />
@error('confirmWord')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<div class="mt-6 flex justify-end gap-3">
<x-ui.button variant="secondary" x-on:click="$dispatch('closeModal')">{{ __('settings.keep') }}</x-ui.button>
<x-ui.button variant="danger" wire:click="closeAccount" wire:loading.attr="disabled">
{{ __('settings.close_confirm') }}
</x-ui.button>
</div>
@endif
</div>

View File

@ -0,0 +1,124 @@
<div class="mx-auto max-w-3xl space-y-6">
<div class="animate-rise">
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('settings.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('settings.subtitle') }}</p>
</div>
{{-- Company / billing profile --}}
<form wire:submit="saveProfile" class="space-y-4 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
<h2 class="font-semibold text-ink">{{ __('settings.company_title') }}</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<x-ui.input name="companyName" wire:model="companyName" :label="__('settings.company_name')" />
<x-ui.input name="contactName" wire:model="contactName" :label="__('settings.contact_name')" />
<x-ui.input name="phone" wire:model="phone" :label="__('settings.phone')" />
<x-ui.input name="vatId" wire:model="vatId" :label="__('settings.vat_id')" />
</div>
<div>
<label class="text-sm font-medium text-body" for="billingAddress">{{ __('settings.billing_address') }}</label>
<textarea id="billingAddress" wire:model="billingAddress" rows="3"
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink"></textarea>
@error('billingAddress')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<div class="flex justify-end">
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled" wire:target="saveProfile">{{ __('settings.save') }}</x-ui.button>
</div>
</form>
{{-- Branding --}}
<form wire:submit="saveBranding" class="space-y-4 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:120ms]">
<div>
<h2 class="font-semibold text-ink">{{ __('settings.branding_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('settings.branding_sub') }}</p>
</div>
<x-ui.input name="brandDisplayName" wire:model="brandDisplayName" :label="__('settings.brand_display_name')" :placeholder="$branding['display_name'] ?? ''" />
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="text-sm font-medium text-body">{{ __('settings.brand_primary') }}</label>
<div class="mt-1.5 flex items-center gap-2">
<input type="color" wire:model="brandPrimary" value="{{ $brandPrimary ?: ($branding['primary_color'] ?? '#f97316') }}" class="h-9 w-12 rounded border border-line-strong bg-surface" aria-label="{{ __('settings.brand_primary') }}" />
<input type="text" wire:model="brandPrimary" placeholder="{{ $branding['primary_color'] ?? '#f97316' }}" class="w-28 rounded-md border border-line-strong bg-surface px-2.5 py-1.5 font-mono text-sm text-ink" />
</div>
@error('brandPrimary')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<div>
<label class="text-sm font-medium text-body">{{ __('settings.brand_accent') }}</label>
<div class="mt-1.5 flex items-center gap-2">
<input type="color" wire:model="brandAccent" value="{{ $brandAccent ?: ($branding['accent_color'] ?? '#c2560a') }}" class="h-9 w-12 rounded border border-line-strong bg-surface" aria-label="{{ __('settings.brand_accent') }}" />
<input type="text" wire:model="brandAccent" placeholder="{{ $branding['accent_color'] ?? '#c2560a' }}" class="w-28 rounded-md border border-line-strong bg-surface px-2.5 py-1.5 font-mono text-sm text-ink" />
</div>
@error('brandAccent')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
</div>
<div>
<label class="text-sm font-medium text-body">{{ __('settings.brand_logo') }}</label>
<div class="mt-1.5 flex flex-wrap items-center gap-4">
<div class="grid size-16 place-items-center overflow-hidden rounded-lg border border-line bg-surface-2">
@if ($logo)
<img src="{{ $logo->temporaryUrl() }}" alt="" class="max-h-16 max-w-16 object-contain" />
@elseif ($logoUrl)
<img src="{{ $logoUrl }}" alt="" class="max-h-16 max-w-16 object-contain" />
@else
<span class="text-xs text-faint">{{ __('settings.brand_default') }}</span>
@endif
</div>
<div class="flex flex-col gap-1.5">
<input type="file" wire:model="logo" accept="image/png,image/webp" class="text-sm text-body file:mr-3 file:rounded-md file:border-0 file:bg-surface-2 file:px-3 file:py-1.5 file:text-sm file:font-semibold file:text-body" />
<p class="text-xs text-faint">{{ __('settings.brand_logo_hint') }}</p>
@if ($logoUrl)
<button type="button" wire:click="removeLogo" class="self-start text-xs font-semibold text-danger hover:underline">{{ __('settings.brand_logo_remove') }}</button>
@endif
</div>
</div>
@error('logo')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
@if ($branding['is_default'] ?? false)
<p class="text-xs text-faint">{{ __('settings.brand_using_default') }}</p>
@endif
<div class="flex justify-end">
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled" wire:target="saveBranding,logo">{{ __('settings.save') }}</x-ui.button>
</div>
</form>
{{-- Package & account lifecycle --}}
<div class="space-y-4 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:180ms]">
<h2 class="font-semibold text-ink">{{ __('settings.package_title') }}</h2>
@if ($cancellationScheduled)
<div class="flex items-start gap-3 rounded-lg border border-warning-border bg-warning-bg p-4">
<x-ui.icon name="alert-triangle" class="size-5 shrink-0 text-warning" />
<div>
<p class="text-sm font-semibold text-ink">{{ __('settings.cancel_scheduled_title') }}</p>
<p class="mt-0.5 text-sm text-body">{{ __('settings.cancel_scheduled_body', ['date' => $instance?->service_ends_at?->isoFormat('LL')]) }}</p>
</div>
</div>
@elseif ($hasActivePackage)
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="text-sm text-muted">{{ __('settings.package_active', ['plan' => $instance ? __('billing.plan.'.$instance->plan) : '—']) }}</p>
<x-ui.button variant="secondary" size="sm"
x-on:click="$dispatch('openModal', { component: 'confirm-cancel-package' })">
{{ __('settings.cancel_cta') }}
</x-ui.button>
</div>
@else
<p class="text-sm text-muted">{{ __('settings.no_package') }}</p>
@endif
<div class="border-t border-line pt-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p class="text-sm font-semibold text-ink">{{ __('settings.close_account_title') }}</p>
<p class="mt-0.5 text-sm text-muted">{{ __('settings.close_account_sub') }}</p>
</div>
<x-ui.button variant="secondary" size="sm"
x-on:click="$dispatch('openModal', { component: 'confirm-close-account' })">
{{ __('settings.close_cta') }}
</x-ui.button>
</div>
</div>
</div>
</div>

View File

@ -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).

View File

@ -0,0 +1,109 @@
<?php
use App\Livewire\ConfirmCancelPackage;
use App\Livewire\ConfirmCloseAccount;
use App\Livewire\Settings;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\Order;
use App\Models\User;
use Livewire\Livewire;
function settingsSetup(bool $withInstance = true): array
{
$user = User::factory()->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();
});