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