From 1d4e98eb0672fe2b79a856e443d8ca4dd151b6f8 Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 16:25:10 +0200 Subject: [PATCH] Rebuild the customer's settings page as four tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was one column 768 pixels wide inside a 1240-pixel shell — a third of the window empty beside it — holding everything a customer might ever change: 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 knowing how far down it lived. Four tabs, by what somebody came to do: their details, security, the appearance of their cloud, the contract. The same tab bar the console uses, so one vocabulary holds on both sides of the login, and the choice lives in the query string — `#[Url(history: true)]`, no `except`, so the parameter is there from the first render and a link to this page can name the part it means. An unknown tab out of the address falls back to the first rather than rendering a settings page with no section on it. The width is used now. Inside a tab the short cards sit side by side instead of each taking a full row to hold three lines: the customer type beside the company form (it is one question, and beside the VAT field it was mistaken for it), the password beside two-factor, the logo beside the colours, the contract beside the closing of the account. Also: the subtitle listed the old sections by name. Co-Authored-By: Claude Opus 5 --- app/Livewire/Settings.php | 44 ++ lang/de/settings.php | 12 +- lang/en/settings.php | 12 +- resources/views/livewire/settings.blade.php | 593 +++++++++++--------- tests/Feature/Billing/WithdrawalTest.php | 8 +- tests/Feature/SettingsTest.php | 68 +++ 6 files changed, 462 insertions(+), 275 deletions(-) diff --git a/app/Livewire/Settings.php b/app/Livewire/Settings.php index 336216f..a3a00ad 100644 --- a/app/Livewire/Settings.php +++ b/app/Livewire/Settings.php @@ -19,16 +19,52 @@ 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; @@ -158,6 +194,13 @@ class Settings extends Component 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; @@ -358,6 +401,7 @@ class Settings extends Component $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. diff --git a/lang/de/settings.php b/lang/de/settings.php index 3b79ce7..3cd6a1f 100644 --- a/lang/de/settings.php +++ b/lang/de/settings.php @@ -1,8 +1,18 @@ 'Konto', + 'company_sub' => 'Diese Angaben stehen auf Ihren Rechnungen.', + // The tab bar. The list in App\Livewire\Settings::TABS decides which + // exist; these only name them. + 'tab' => [ + 'profile' => 'Ihre Daten', + 'security' => 'Sicherheit', + 'branding' => 'Erscheinungsbild', + 'contract' => 'Vertrag', + ], 'title' => 'Einstellungen', - 'subtitle' => 'Firmendaten, Branding und Ihr Paket verwalten.', + 'subtitle' => 'Alles, was zu Ihrem Konto gehört — nach Themen getrennt.', 'save' => 'Speichern', 'company_title' => 'Firmendaten', diff --git a/lang/en/settings.php b/lang/en/settings.php index 7a41a3b..803d952 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -1,8 +1,18 @@ 'Account', + 'company_sub' => 'These details appear on your invoices.', + // The tab bar. The list in App\Livewire\Settings::TABS decides which + // exist; these only name them. + 'tab' => [ + 'profile' => 'Your details', + 'security' => 'Security', + 'branding' => 'Appearance', + 'contract' => 'Contract', + ], 'title' => 'Settings', - 'subtitle' => 'Manage your company details, branding and package.', + 'subtitle' => 'Everything that belongs to your account, grouped by subject.', 'save' => 'Save', 'company_title' => 'Company details', diff --git a/resources/views/livewire/settings.blade.php b/resources/views/livewire/settings.blade.php index 7920ca4..f1f8aef 100644 --- a/resources/views/livewire/settings.blade.php +++ b/resources/views/livewire/settings.blade.php @@ -1,299 +1,350 @@ -
-
-

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

-

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

+{{-- + The customer's own settings. + + It was one column 768 pixels wide inside a 1240-pixel shell — a third of the + window empty beside it — holding everything a customer might ever change: + the company address, the password, two-factor, the signed-in devices, the + branding, the contract and the button that closes the account. Two thousand + pixels of scroll, and the way to a particular thing was knowing how far down + it lived. + + Four tabs, by what somebody came to do. Inside a tab the short cards sit side + by side instead of each taking a full row to hold three lines. The choice is + in the query string (see the #[Url] attribute on the component), so a reload, + a bookmark and the back button all land where they were. +--}} +
+ +
+

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

+

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

+

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

+
+ + {{-- The same tab bar the console uses, so one vocabulary holds on both sides + of the login. --}} +
+ @foreach ($tabs as $name) + + @endforeach
- {{-- Company / billing profile --}} -
-

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

-
- - - - -
- {{-- Beside the VAT number, deliberately, because that is the field - people mistake it for. A business without a number is an ordinary - small business and a consumer with one does not exist, so the two - answer different questions: this one decides the fourteen-day right - of withdrawal and whether reverse charge can ever apply. Nothing is - preselected for a customer nobody has asked yet. --}} -
- {{ __('settings.customer_type') }} -
- @foreach ($customerTypes as $type) - - @endforeach -
-

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

- @error('customerType')

{{ $message }}

@enderror -
-
- - - @error('billingAddress')

{{ $message }}

@enderror -
-
- {{ __('settings.save') }} -
-
- - {{-- Branding --}} - - {{-- Own password. There was no way to change one at all — Fortify's - updatePasswords feature was switched off. --}} -
-
-

{{ __('admin_settings.password_title') }}

-

{{ __('admin_settings.password_sub') }}

-
- -
- - - -
- -
- {{ __('admin_settings.password_save') }} -
-
- - - {{-- Two-factor. Every action is re-checked server-side against a recent - password confirmation — hiding the buttons stops nobody who can post - to /livewire/update. --}} -
-
-
-
-

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

- - - {{ $twoFactorOn ? __('settings.twofa_state_on') : __('settings.twofa_state_off') }} - + {{-- ── Ihre Daten ───────────────────────────────────────────────────── --}} + @if ($tab === 'profile') +
+
+
+

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

+

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

-

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

-
-
- @if (! $passwordConfirmed) - {{-- The gate. A signed-in session is not enough to change how the - account is protected — the risk is an unlocked machine. --}} - -

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

-
-
- -
- - {{ __('settings.twofa_confirm_button') }} - +
+ + + + +
+ +
+ + + @error('billingAddress')

{{ $message }}

@enderror +
+ +
+ {{ __('settings.save') }} +
+
+ + {{-- Beside the form rather than inside it: it is one question, it + decides the fourteen-day right of withdrawal and whether reverse + charge can ever apply, and people mistake it for the VAT field + when the two sit in the same row. Nothing is preselected for a + customer nobody has asked yet. --}} +
+ {{ __('settings.customer_type') }} +
+ @foreach ($customerTypes as $type) + + @endforeach +
+

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

+ @error('customerType')

{{ $message }}

@enderror +
+ + @endif + + {{-- ── Sicherheit ───────────────────────────────────────────────────── --}} + @if ($tab === 'security') +
+ + {{-- Own password. There was no way to change one at all — Fortify's + updatePasswords feature was switched off. --}} +
+
+

{{ __('admin_settings.password_title') }}

+

{{ __('admin_settings.password_sub') }}

+
+ + +
+ + +
+ +
+ {{ __('admin_settings.password_save') }}
- @elseif ($twoFactorOn) -
- - {{ __('settings.twofa_new_codes') }} - - - {{ __('settings.twofa_disable') }} - -
- @elseif ($twoFactorPending) -
-
{!! $twoFactorQr !!}
-
-

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

-
- -
- - {{ __('settings.twofa_activate') }} - -
-
- @else - - {{ __('settings.twofa_enable') }} - - @endif - @if ($recoveryCodes) - {{-- Shown once, on purpose: nobody writes down what they were never - shown, and these are the way back in when the phone is gone. --}} -
-

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

-

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

-
    - @foreach ($recoveryCodes as $code)
  • {{ $code }}
  • @endforeach -
-
- @endif -
- - {{-- Directly under two-factor, because the two answer the same question: - who can get into this account, and what can I do about it. --}} -
- @livewire('sessions') -
- -
-
-

{{ __('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) -
- + {{-- Two-factor. Every action is re-checked server-side against a + recent password confirmation — hiding the buttons stops nobody + who can post to /livewire/update. --}} +
-

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

-

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

+
+

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

+ + + {{ $twoFactorOn ? __('settings.twofa_state_on') : __('settings.twofa_state_off') }} + +
+

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

+
+ + @if (! $passwordConfirmed) + {{-- The gate. A signed-in session is not enough to change how + the account is protected — the risk is an unlocked + machine. --}} +
+

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

+
+
+ +
+ + {{ __('settings.twofa_confirm_button') }} + +
+
+ @elseif ($twoFactorOn) +
+ + {{ __('settings.twofa_new_codes') }} + + + {{ __('settings.twofa_disable') }} + +
+ @elseif ($twoFactorPending) +
+
{!! $twoFactorQr !!}
+
+

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

+
+ +
+ + {{ __('settings.twofa_activate') }} + +
+
+ @else + + {{ __('settings.twofa_enable') }} + + @endif + + @if ($recoveryCodes) + {{-- Shown once, on purpose: nobody writes down what they were + never shown, and these are the way back in when the phone + is gone. --}} +
+

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

+

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

+
    + @foreach ($recoveryCodes as $code)
  • {{ $code }}
  • @endforeach +
+
+ @endif +
+ + {{-- Under both, across the width: it answers the same question they + do — who can get into this account — and it is a table. --}} +
+ @livewire('sessions') +
+
+ @endif + + {{-- ── Erscheinungsbild ─────────────────────────────────────────────── --}} + @if ($tab === 'branding') +
+
+
+

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

+

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

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

{{ $message }}

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

{{ $message }}

@enderror +
+
+ +
+ {{ __('settings.save') }}
- @elseif ($hasActivePackage) -
-

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

- - {{ __('settings.cancel_cta') }} - + +
+ +
+ @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
- @else -

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

- @endif + + @endif - {{-- The fourteen-day right of withdrawal. Shown only where it exists: - a business customer has none and never sees this block — and the - server refuses them again in Settings::withdraw(), because a card - that is not rendered has never stopped anybody who can post to - /livewire/update. + {{-- ── Vertrag ──────────────────────────────────────────────────────── --}} + @if ($tab === 'contract') +
- Separate from the cancellation above it, and not a variant of it. - A cancellation ends a contract that was validly concluded and keeps - the term the customer paid for; a withdrawal unwinds the contract - itself, ends the service the same day and sends the money back. --}} - @if ($withdrawal->applies && $withdrawal->open) -
-
-
+
+

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

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

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

+

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

+
+
+ @elseif ($hasActivePackage) +
+

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

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

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

+ @endif + + {{-- The fourteen-day right of withdrawal. Shown only where it + exists: a business customer has none and never sees this + block — and the server refuses them again in + Settings::withdraw(), because a card that is not rendered has + never stopped anybody who can post to /livewire/update. + + Separate from the cancellation above it, and not a variant of + it. A cancellation ends a contract that was validly concluded + and keeps the term the customer paid for; a withdrawal + unwinds the contract itself, ends the service the same day + and sends the money back. --}} + @if ($withdrawal->applies && $withdrawal->open) +

{{ __('withdrawal.card_title') }}

-

+

{{ __('withdrawal.card_sub', [ 'date' => $withdrawal->endsAt->local()->isoFormat('LL'), 'days' => $withdrawal->daysLeft(), ]) }}

+ + {{ __('withdrawal.cta') }} + +
+ @endif +
+ +
+
+
+

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

+

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

- {{ __('withdrawal.cta') }} + x-on:click="$dispatch('openModal', { component: 'confirm-close-account' })"> + {{ __('settings.close_cta') }}
-
- @endif -
-
-
-

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

-

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

+ {{-- The deadlines, said where somebody asks the question. + "Nach fünf Tagen gelöscht" on the verification page reads as + if it applied to every account; it applies to a registration + nobody confirmed. The other rule — a year without a package — + was nowhere at all. Both come from the commands that enforce + them, so the page cannot drift from what actually happens. --}} +
+

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

+
    +
  • {{ __('settings.lifecycle_unverified', ['days' => App\Console\Commands\PruneUnverifiedAccounts::AFTER_DAYS]) }}
  • +
  • {{ __('settings.lifecycle_dormant', ['warn' => App\Console\Commands\PruneDormantAccounts::WARN_DAYS_BEFORE]) }}
  • +
  • {{ __('settings.lifecycle_kept') }}
  • +
+ + {{ __('settings.lifecycle_terms') }} +
- - {{ __('settings.close_cta') }} - -
- - {{-- The deadlines, said where somebody asks the question. - "Nach fünf Tagen gelöscht" on the verification page reads as if - it applied to every account; it applies to a registration nobody - confirmed. The other rule — a year without a package — was - nowhere at all. Both come from the commands that enforce them, so - the page cannot drift from what actually happens. --}} -
-

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

-
    -
  • {{ __('settings.lifecycle_unverified', ['days' => App\Console\Commands\PruneUnverifiedAccounts::AFTER_DAYS]) }}
  • -
  • {{ __('settings.lifecycle_dormant', ['warn' => App\Console\Commands\PruneDormantAccounts::WARN_DAYS_BEFORE]) }}
  • -
  • {{ __('settings.lifecycle_kept') }}
  • -
- - {{ __('settings.lifecycle_terms') }} -
-
+ @endif
diff --git a/tests/Feature/Billing/WithdrawalTest.php b/tests/Feature/Billing/WithdrawalTest.php index 3026313..324b04a 100644 --- a/tests/Feature/Billing/WithdrawalTest.php +++ b/tests/Feature/Billing/WithdrawalTest.php @@ -539,9 +539,13 @@ it('shows the card to a consumer and never to a business', function () { $user = User::factory()->create(['email' => $contract->customer->email]); $contract->customer->update(['user_id' => $user->id]); - Livewire::actingAs($user)->test(Settings::class)->assertSee(__('withdrawal.cta')); + // On the contract tab: the settings page is four tabs now, and a withdrawal + // belongs with the contract rather than with the company address. + Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract']) + ->assertSee(__('withdrawal.cta')); $contract->customer->update(['customer_type' => Customer::TYPE_BUSINESS]); - Livewire::actingAs($user)->test(Settings::class)->assertDontSee(__('withdrawal.cta')); + Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract']) + ->assertDontSee(__('withdrawal.cta')); }); diff --git a/tests/Feature/SettingsTest.php b/tests/Feature/SettingsTest.php index 130b510..0061bdf 100644 --- a/tests/Feature/SettingsTest.php +++ b/tests/Feature/SettingsTest.php @@ -169,3 +169,71 @@ it('closes the account when no package is active', function () { expect($customer->fresh()->closed_at)->not->toBeNull(); }); + +// ---- Four tabs, and the choice in the address ---- + +it('opens on the customer own details and says so in the address', function () { + // The page was one column 768px wide inside a 1240px shell, holding the + // company address, the password, two-factor, the devices, the branding and + // the contract — two thousand pixels of scroll, and finding a thing meant + // knowing how far down it lived. + $user = App\Models\User::factory()->create(['email_verified_at' => now()]); + + Livewire::actingAs($user)->test(App\Livewire\Settings::class) + ->assertSet('tab', 'profile') + ->assertSee(__('settings.company_title')) + // Not the other tabs' content. + ->assertDontSee(__('settings.branding_title')) + ->assertDontSee(__('settings.package_title')); +}); + +it('lands on the tab the address names', function () { + $user = App\Models\User::factory()->create(['email_verified_at' => now()]); + + Livewire::actingAs($user)->test(App\Livewire\Settings::class, ['tab' => 'branding']) + ->assertSet('tab', 'branding') + ->assertSee(__('settings.branding_title')) + ->assertDontSee(__('settings.company_title')); +}); + +it('falls back to the first tab when the address names one nobody has', function () { + // A tab name out of the URL is a string a stranger typed; the alternative + // is a settings page with no section on it at all. + $user = App\Models\User::factory()->create(['email_verified_at' => now()]); + + Livewire::actingAs($user)->test(App\Livewire\Settings::class, ['tab' => 'gibt-es-nicht']) + ->assertSet('tab', 'profile') + ->assertSee(__('settings.company_title')); +}); + +it('writes the tab into the query string, so a link can name one', function () { + // history: true and no `except` — the parameter is there from the first + // render. Hidden on the default, a link to this page could not say which + // tab it meant unless the tab happened not to be the first. + $component = Illuminate\Support\Facades\File::get(app_path('Livewire/Settings.php')); + + expect($component)->toContain('#[Url(history: true)]') + // The property and its default, matched without spelling a PHP + // variable inside a quoted string. + ->and($component)->toContain('public string $tab') + ->and($component)->toContain("= 'profile';") + ->and($component)->not->toContain('except:'); +}); + +it('keeps every tab reachable from the bar', function () { + // The list IS the schema: it validates the query string, builds the bar and + // decides which section renders. + $user = App\Models\User::factory()->create(['email_verified_at' => now()]); + $page = Livewire::actingAs($user)->test(App\Livewire\Settings::class); + + foreach (App\Livewire\Settings::TABS as $tab) { + $page->assertSee(__('settings.tab.'.$tab)); + } +}); + +it('uses the width it is given', function () { + // 768px in a 1240px shell left a third of the window empty beside a column + // nobody could read the end of. + expect(Illuminate\Support\Facades\File::get(resource_path('views/livewire/settings.blade.php'))) + ->not->toContain('max-w-3xl'); +});