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'); +});