From 72f9d7eff7ce45bdb3ae95cd0840f744b4cc1a27 Mon Sep 17 00:00:00 2001 From: Dennis Date: Mon, 28 Sep 2026 13:08:34 +0200 Subject: [PATCH 1/2] Split the profile page into tabs The profile page had grown too long, so it now uses Filament tabs: Account, Preferences, Security (two-factor), MCP access and Social login. The MCP and Social login tabs only show when those features are enabled, and the active tab is kept in the URL. The locale and date locale selects are now searchable. Co-Authored-By: Claude --- app/Livewire/Profile.php | 179 ++++++++++++------ lang/en/profile.php | 6 + lang/es/profile.php | 6 + lang/hu/profile.php | 6 + lang/it/profile.php | 8 +- lang/nl/profile.php | 6 + resources/views/livewire/profile.blade.php | 147 +------------- .../livewire/profile/delete-account.blade.php | 10 + .../livewire/profile/mcp-tokens.blade.php | 43 +++++ .../livewire/profile/social-login.blade.php | 8 + .../livewire/profile/two-factor.blade.php | 67 +++++++ tests/Feature/Livewire/ProfileTabsTest.php | 58 ++++++ 12 files changed, 339 insertions(+), 205 deletions(-) create mode 100644 resources/views/livewire/profile/delete-account.blade.php create mode 100644 resources/views/livewire/profile/mcp-tokens.blade.php create mode 100644 resources/views/livewire/profile/social-login.blade.php create mode 100644 resources/views/livewire/profile/two-factor.blade.php create mode 100644 tests/Feature/Livewire/ProfileTabsTest.php diff --git a/app/Livewire/Profile.php b/app/Livewire/Profile.php index 2f86f874..f983516f 100644 --- a/app/Livewire/Profile.php +++ b/app/Livewire/Profile.php @@ -15,15 +15,18 @@ use Illuminate\Support\Facades\Http; use Filament\Forms\Components\Select; use Filament\Forms\Components\Toggle; -use Filament\Schemas\Components\Grid; +use Filament\Schemas\Components\Tabs; +use Filament\Schemas\Components\View; use Filament\Support\Enums\Alignment; use Filament\Forms\Contracts\HasForms; +use Filament\Schemas\Components\Group; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Contracts\HasTable; use App\Notifications\VerifyEmailChange; use Filament\Forms\Components\TextInput; use Filament\Notifications\Notification; -use Filament\Schemas\Components\Section; +use Filament\Schemas\Components\Actions; +use Filament\Schemas\Components\Tabs\Tab; use Illuminate\Database\Eloquent\Builder; use Filament\Actions\Contracts\HasActions; use Filament\Forms\Components\CheckboxList; @@ -71,31 +74,38 @@ public function mount(): void protected function getFormSchema(): array { return [ - Section::make(trans('auth.profile')) - ->columns() - ->schema([ - TextInput::make('name')->label(trans('auth.name'))->required(), - TextInput::make('username') - ->label(trans('profile.username')) - ->helperText(trans('profile.username_description')) - ->required() - ->rules([ - 'alpha_dash' - ]) - ->unique(table: User::class, column: 'username', ignorable: auth()->user()), - TextInput::make('email') - ->label(trans('auth.email')) - ->required() - ->email() - ->unique(table: User::class, column: 'email', ignorable: auth()->user()), - Select::make('locale')->label(trans('auth.locale'))->options($this->locales)->placeholder(trans('auth.locale_null_value')), - Select::make('date_locale')->label(trans('auth.date_locale'))->options($this->locales)->placeholder(trans('auth.date_locale_null_value')), - ])->collapsible(), - - Grid::make(2) - ->schema([ - Section::make(trans('profile.notifications')) - ->columnSpan(1) + Tabs::make('profile') + ->persistTabInQueryString() + ->tabs([ + Tab::make(trans('profile.tabs.account')) + ->key('account', isInheritable: false) + ->icon('heroicon-o-user') + ->columns() + ->schema([ + TextInput::make('name')->label(trans('auth.name'))->required(), + TextInput::make('username') + ->label(trans('profile.username')) + ->helperText(trans('profile.username_description')) + ->required() + ->rules([ + 'alpha_dash' + ]) + ->unique(table: User::class, column: 'username', ignorable: auth()->user()), + TextInput::make('email') + ->label(trans('auth.email')) + ->required() + ->email() + ->unique(table: User::class, column: 'email', ignorable: auth()->user()), + Select::make('locale')->label(trans('auth.locale'))->options($this->locales)->placeholder(trans('auth.locale_null_value'))->searchable(), + Select::make('date_locale')->label(trans('auth.date_locale'))->options($this->locales)->placeholder(trans('auth.date_locale_null_value'))->searchable(), + $this->getSaveFormActions(), + View::make('livewire.profile.delete-account')->columnSpanFull(), + ]), + + Tab::make(trans('profile.tabs.preferences')) + ->key('preferences', isInheritable: false) + ->icon('heroicon-o-adjustments-horizontal') + ->columns() ->schema([ CheckboxList::make('notification_settings') ->label(trans('profile.notification_settings')) @@ -103,31 +113,88 @@ protected function getFormSchema(): array 'receive_mention_notifications' => trans('profile.receive_mention_notifications'), 'receive_comment_reply_notifications' => trans('profile.receive_comment_reply_notifications'), ]), - ])->collapsible(), - Section::make(trans('profile.settings')) - ->columnSpan(1) + Group::make([ + Select::make('per_page_setting') + ->label(trans('profile.per-page-setting')) + ->multiple() + ->options([ + 5 => '5', + 10 => '10', + 15 => '15', + 25 => '25', + 50 => '50', + ]) + ->required() + ->helperText(trans('profile.per-page-setting-helper')) + ->rules(['array', 'in:5,10,15,25,50']), + + Toggle::make('hide_from_leaderboard') + ->label(trans('profile.hide-from-leaderboard')) + ->helperText(trans('profile.hide-from-leaderboard-helper')), + ]), + + $this->getSaveFormActions(), + ]), + + Tab::make(trans('profile.tabs.security')) + ->key('security', isInheritable: false) + ->icon('heroicon-o-shield-check') ->schema([ - Select::make('per_page_setting') - ->label(trans('profile.per-page-setting')) - ->multiple() - ->options([ - 5 => '5', - 10 => '10', - 15 => '15', - 25 => '25', - 50 => '50', - ]) - ->required() - ->helperText(trans('profile.per-page-setting-helper')) - ->rules(['array', 'in:5,10,15,25,50']), + View::make('livewire.profile.two-factor') + ->viewData(fn (): array => $this->getTwoFactorViewData()), + ]), + + Tab::make(trans('profile.mcp.heading')) + ->key('mcp', isInheritable: false) + ->icon('heroicon-o-command-line') + ->visible(fn (): bool => app(GeneralSettings::class)->enable_mcp) + ->schema([ + View::make('livewire.profile.mcp-tokens') + ->viewData(fn (): array => [ + 'mcpTokens' => $this->user->tokens()->latest()->get(), + ]), + ]), - Toggle::make('hide_from_leaderboard') - ->label(trans('profile.hide-from-leaderboard')) - ->helperText(trans('profile.hide-from-leaderboard-helper')) - ])->collapsible(), - ]) + Tab::make(trans('profile.social-login')) + ->key('social-login', isInheritable: false) + ->icon('heroicon-o-link') + ->visible(fn (): bool => SsoProvider::isEnabled()) + ->schema([ + View::make('livewire.profile.social-login'), + ]), + ]), + ]; + } + + /** + * The save button shown at the bottom of each tab that holds form fields. + */ + protected function getSaveFormActions(): Actions + { + return Actions::make([ + Action::make('save') + ->label(trans('profile.save')) + ->submit('submit'), + ])->columnSpanFull(); + } + + /** + * @return array{twoFactorEnabled: bool, twoFactorConfirmed: bool, twoFactorQrCode: ?string, twoFactorSetupKey: ?string, recoveryCodes: array} + */ + protected function getTwoFactorViewData(): array + { + $twoFactorEnabled = ! is_null($this->user->two_factor_secret); + $twoFactorConfirmed = ! is_null($this->user->two_factor_confirmed_at); + return [ + 'twoFactorEnabled' => $twoFactorEnabled, + 'twoFactorConfirmed' => $twoFactorConfirmed, + 'twoFactorQrCode' => $twoFactorEnabled ? $this->user->twoFactorQrCodeSvg() : null, + 'twoFactorSetupKey' => $twoFactorEnabled + ? Fortify::currentEncrypter()->decrypt($this->user->two_factor_secret) + : null, + 'recoveryCodes' => $twoFactorConfirmed ? $this->user->recoveryCodes() : [], ]; } @@ -436,21 +503,7 @@ public function getLocalesProperty(): array public function render() { - $twoFactorEnabled = ! is_null($this->user->two_factor_secret); - $twoFactorConfirmed = ! is_null($this->user->two_factor_confirmed_at); - - return view('livewire.profile', [ - 'hasSsoLoginAvailable' => SsoProvider::isEnabled(), - 'twoFactorEnabled' => $twoFactorEnabled, - 'twoFactorConfirmed' => $twoFactorConfirmed, - 'twoFactorQrCode' => $twoFactorEnabled ? $this->user->twoFactorQrCodeSvg() : null, - 'twoFactorSetupKey' => $twoFactorEnabled - ? Fortify::currentEncrypter()->decrypt($this->user->two_factor_secret) - : null, - 'recoveryCodes' => $twoFactorConfirmed ? $this->user->recoveryCodes() : [], - 'mcpEnabled' => app(GeneralSettings::class)->enable_mcp, - 'mcpTokens' => $this->user->tokens()->latest()->get(), - ]); + return view('livewire.profile'); } protected function getTableQuery(): Builder diff --git a/lang/en/profile.php b/lang/en/profile.php index 61885cad..fe158df4 100644 --- a/lang/en/profile.php +++ b/lang/en/profile.php @@ -66,4 +66,10 @@ 'token_created_notification' => 'Token created.', 'token_revoked_notification' => 'Token revoked.', ], + + 'tabs' => [ + 'account' => 'Account', + 'preferences' => 'Preferences', + 'security' => 'Security', + ], ]; diff --git a/lang/es/profile.php b/lang/es/profile.php index eaa0aae0..c56f472b 100644 --- a/lang/es/profile.php +++ b/lang/es/profile.php @@ -21,4 +21,10 @@ 'per-page-setting' => 'Configuración por página', 'per-page-setting-helper' => 'Determina cuántas páginas deben estar disponibles para los elementos en la página "Mis elementos", por ejemplo.', 'settings' => 'Configuraciones', + + 'tabs' => [ + 'account' => 'Cuenta', + 'preferences' => 'Preferencias', + 'security' => 'Seguridad', + ], ]; diff --git a/lang/hu/profile.php b/lang/hu/profile.php index 915b4fbc..8f98ab0e 100644 --- a/lang/hu/profile.php +++ b/lang/hu/profile.php @@ -23,4 +23,10 @@ 'settings' => 'Beállítások', 'hide-from-leaderboard' => 'Elrejtés a ranglistáról', 'hide-from-leaderboard-helper' => 'Eltüntet téged a nyilvános ranglistáról.', + + 'tabs' => [ + 'account' => 'Fiók', + 'preferences' => 'Beállítások', + 'security' => 'Biztonság', + ], ]; diff --git a/lang/it/profile.php b/lang/it/profile.php index 2ce2adcd..d5a0b898 100644 --- a/lang/it/profile.php +++ b/lang/it/profile.php @@ -20,5 +20,11 @@ 'social-login-description' => 'Qui troverai le tue credenziali d\'accesso sociali utilizzate per accedere al tuo account.', 'per-page-setting' => 'Impostazioni pagina per pagina', 'per-page-setting-helper' => 'Determina quanti elementi saranno disponibili per la pagina "Il mio" per esempio.', - 'settings' => 'Impostazioni' + 'settings' => 'Impostazioni', + + 'tabs' => [ + 'account' => 'Account', + 'preferences' => 'Preferenze', + 'security' => 'Sicurezza', + ], ]; diff --git a/lang/nl/profile.php b/lang/nl/profile.php index 10579cd3..7ea88922 100644 --- a/lang/nl/profile.php +++ b/lang/nl/profile.php @@ -24,4 +24,10 @@ 'hide-from-leaderboard' => 'Verbergen op ranglijst', 'hide-from-leaderboard-helper' => 'Verberg jezelf op de publieke ranglijst.', 'view-public-profile' => 'Bekijk publieke profiel', + + 'tabs' => [ + 'account' => 'Account', + 'preferences' => 'Voorkeuren', + 'security' => 'Beveiliging', + ], ]; diff --git a/resources/views/livewire/profile.blade.php b/resources/views/livewire/profile.blade.php index 2a64f1e6..5bef0e47 100644 --- a/resources/views/livewire/profile.blade.php +++ b/resources/views/livewire/profile.blade.php @@ -1,147 +1,12 @@
-
- {{ $this->form }} - -
-
- - {{ trans('profile.save') }} - - - {{ $this->viewProfileAction }} - {{ $this->logoutAction }} -
- -
- {{ $this->deleteAction }} -
-
-
- - {{-- Two-factor authentication --}} -
-
-

{{ trans('profile.two_factor.heading') }}

- - @if($twoFactorConfirmed) - - {{ trans('profile.two_factor.status_enabled') }} - - @else - - {{ trans('profile.two_factor.status_disabled') }} - - @endif -
- -

{{ trans('profile.two_factor.description') }}

- - @if(! $twoFactorEnabled) - {{-- Disabled: offer to enable --}} -
- {{ $this->enableTwoFactorAction }} -
- @elseif(! $twoFactorConfirmed) - {{-- Enrolling: show QR + setup key, ask for a confirmation code --}} -
-

{{ trans('profile.two_factor.setup_instructions') }}

- -
- {!! $twoFactorQrCode !!} -
- -
- {{ trans('profile.two_factor.setup_key') }}: - {{ $twoFactorSetupKey }} -
- -
- {{ $this->confirmTwoFactorAction }} - {{ $this->cancelTwoFactorAction }} -
-
- @else - {{-- Enabled + confirmed: show recovery codes and management --}} -
-

{{ trans('profile.two_factor.recovery_codes_description') }}

- - - -
- @foreach($recoveryCodes as $code) -
{{ $code }}
- @endforeach -
- -
- {{ $this->regenerateRecoveryCodesAction }} - {{ $this->disableTwoFactorAction }} -
-
- @endif +
+ {{ $this->viewProfileAction }} + {{ $this->logoutAction }}
- {{-- MCP access tokens --}} - @if($mcpEnabled) -
-

{{ trans('profile.mcp.heading') }}

- -

- {{ trans('profile.mcp.description') }} - {{ trans('profile.mcp.read_docs') }} -

- -
- {{ trans('profile.mcp.endpoint') }}: - {{ url('mcp') }} -
- - @if($newMcpToken) -
-

{{ trans('profile.mcp.new_token') }}

- {{ $newMcpToken }} -
- @endif - - @if($mcpTokens->isEmpty()) -

{{ trans('profile.mcp.no_tokens') }}

- @else -
    - @foreach($mcpTokens as $token) -
  • -
    -

    {{ $token->name }}

    -

    - {{ $token->last_used_at ? trans('profile.mcp.last_used', ['date' => $token->last_used_at->diffForHumans()]) : trans('profile.mcp.never_used') }} -

    -
    - - {{ ($this->revokeMcpTokenAction)(['token' => $token->id]) }} -
  • - @endforeach -
- @endif - -
- {{ $this->createMcpTokenAction }} -
-
- @endif - - @if($hasSsoLoginAvailable) -
-

{{ trans('profile.social-login') }}

-

{{ trans('profile.social-login-description') }}

-
- - {{ $this->table }} - @endif +
+ {{ $this->form }} +
diff --git a/resources/views/livewire/profile/delete-account.blade.php b/resources/views/livewire/profile/delete-account.blade.php new file mode 100644 index 00000000..fa2a02cf --- /dev/null +++ b/resources/views/livewire/profile/delete-account.blade.php @@ -0,0 +1,10 @@ +
+
+

{{ trans('profile.delete-account') }}

+

{{ trans('profile.delete-account-warning') }}

+
+ +
+ {{ $this->deleteAction }} +
+
diff --git a/resources/views/livewire/profile/mcp-tokens.blade.php b/resources/views/livewire/profile/mcp-tokens.blade.php new file mode 100644 index 00000000..906f01f8 --- /dev/null +++ b/resources/views/livewire/profile/mcp-tokens.blade.php @@ -0,0 +1,43 @@ +
+

{{ trans('profile.mcp.heading') }}

+ +

+ {{ trans('profile.mcp.description') }} + {{ trans('profile.mcp.read_docs') }} +

+ +
+ {{ trans('profile.mcp.endpoint') }}: + {{ url('mcp') }} +
+ + @if($this->newMcpToken) +
+

{{ trans('profile.mcp.new_token') }}

+ {{ $this->newMcpToken }} +
+ @endif + + @if($mcpTokens->isEmpty()) +

{{ trans('profile.mcp.no_tokens') }}

+ @else +
    + @foreach($mcpTokens as $token) +
  • +
    +

    {{ $token->name }}

    +

    + {{ $token->last_used_at ? trans('profile.mcp.last_used', ['date' => $token->last_used_at->diffForHumans()]) : trans('profile.mcp.never_used') }} +

    +
    + + {{ ($this->revokeMcpTokenAction)(['token' => $token->id]) }} +
  • + @endforeach +
+ @endif + +
+ {{ $this->createMcpTokenAction }} +
+
diff --git a/resources/views/livewire/profile/social-login.blade.php b/resources/views/livewire/profile/social-login.blade.php new file mode 100644 index 00000000..cf834a8f --- /dev/null +++ b/resources/views/livewire/profile/social-login.blade.php @@ -0,0 +1,8 @@ +
+
+

{{ trans('profile.social-login') }}

+

{{ trans('profile.social-login-description') }}

+
+ + {{ $this->table }} +
diff --git a/resources/views/livewire/profile/two-factor.blade.php b/resources/views/livewire/profile/two-factor.blade.php new file mode 100644 index 00000000..30048780 --- /dev/null +++ b/resources/views/livewire/profile/two-factor.blade.php @@ -0,0 +1,67 @@ +
+
+

{{ trans('profile.two_factor.heading') }}

+ + @if($twoFactorConfirmed) + + {{ trans('profile.two_factor.status_enabled') }} + + @else + + {{ trans('profile.two_factor.status_disabled') }} + + @endif +
+ +

{{ trans('profile.two_factor.description') }}

+ + @if(! $twoFactorEnabled) + {{-- Disabled: offer to enable --}} +
+ {{ $this->enableTwoFactorAction }} +
+ @elseif(! $twoFactorConfirmed) + {{-- Enrolling: show QR + setup key, ask for a confirmation code --}} +
+

{{ trans('profile.two_factor.setup_instructions') }}

+ +
+ {!! $twoFactorQrCode !!} +
+ +
+ {{ trans('profile.two_factor.setup_key') }}: + {{ $twoFactorSetupKey }} +
+ +
+ {{ $this->confirmTwoFactorAction }} + {{ $this->cancelTwoFactorAction }} +
+
+ @else + {{-- Enabled + confirmed: show recovery codes and management --}} +
+

{{ trans('profile.two_factor.recovery_codes_description') }}

+ + + +
+ @foreach($recoveryCodes as $code) +
{{ $code }}
+ @endforeach +
+ +
+ {{ $this->regenerateRecoveryCodesAction }} + {{ $this->disableTwoFactorAction }} +
+
+ @endif +
diff --git a/tests/Feature/Livewire/ProfileTabsTest.php b/tests/Feature/Livewire/ProfileTabsTest.php new file mode 100644 index 00000000..42aeafa3 --- /dev/null +++ b/tests/Feature/Livewire/ProfileTabsTest.php @@ -0,0 +1,58 @@ + GeneralSettings::fake(['enable_mcp' => true])); + +it('splits the profile into tabs', function () { + createAndLoginUser(); + + Livewire::test(Profile::class) + ->assertSeeText(trans('profile.tabs.account')) + ->assertSeeText(trans('profile.tabs.preferences')) + ->assertSeeText(trans('profile.tabs.security')) + ->assertSeeText(trans('profile.mcp.heading')) + ->assertSeeText(trans('profile.two_factor.heading')) + ->assertSeeText(trans('profile.delete-account')); +}); + +it('hides the mcp tab when mcp is disabled', function () { + GeneralSettings::fake(['enable_mcp' => false]); + + createAndLoginUser(); + + Livewire::test(Profile::class) + ->assertSeeText(trans('profile.tabs.security')) + ->assertDontSeeText(trans('profile.mcp.heading')); +}); + +it('saves fields from both the account and preferences tabs', function () { + $user = createAndLoginUser(); + + Livewire::test(Profile::class) + ->fillForm([ + 'name' => 'Jane Doe', + 'hide_from_leaderboard' => true, + ]) + ->set('per_page_setting', ['10', '25']) + ->call('submit') + ->assertHasNoFormErrors() + ->assertNotified('Profile'); + + $user->refresh(); + + expect($user->name)->toBe('Jane Doe') + ->and($user->per_page_setting)->toEqual(['10', '25']) + ->and($user->hide_from_leaderboard)->toBeTrue(); +}); + +it('makes the locale fields searchable', function () { + createAndLoginUser(); + + Livewire::test(Profile::class) + ->assertFormFieldExists('locale', fn (Select $field): bool => $field->isSearchable()) + ->assertFormFieldExists('date_locale', fn (Select $field): bool => $field->isSearchable()); +}); From bdafb42c1a223be94ce4a36572c99cfcb29d9543 Mon Sep 17 00:00:00 2001 From: Dennis Date: Mon, 28 Sep 2026 13:08:34 +0200 Subject: [PATCH 2/2] Stop the layout from cutting off the bottom border of cards overflow-x-hidden on the main element also turns overflow-y into auto, which clipped the ring at the bottom of the last card on a page. overflow-x-clip clips horizontally the same way but leaves the vertical axis visible. Co-Authored-By: Claude --- resources/views/components/app.blade.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/views/components/app.blade.php b/resources/views/components/app.blade.php index 4d8dafba..49eefcba 100644 --- a/resources/views/components/app.blade.php +++ b/resources/views/components/app.blade.php @@ -65,8 +65,8 @@ function updateTheme() {
@include('partials.navbar') - {{-- The horizontal padding leaves room for card shadows, which overflow-x-hidden would otherwise clip. --}} -
+ {{-- overflow-x-clip (not hidden) keeps overflow-y visible, so card rings at the bottom aren't cut off. The horizontal padding leaves room for card shadows on the sides. --}} +
    @foreach(array_filter($breadcrumbs) as $breadcrumb)