From 419a37396b875481258ddf420f0d55d8492ec628 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 2 Sep 2025 13:30:12 +0530 Subject: [PATCH 01/43] Add Fortify --- app/Models/User.php | 5 +- app/Providers/FortifyServiceProvider.php | 36 ++++ bootstrap/providers.php | 1 + composer.json | 1 + config/fortify.php | 159 ++++++++++++++++++ ..._add_two_factor_columns_to_users_table.php | 34 ++++ 6 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 app/Providers/FortifyServiceProvider.php create mode 100644 config/fortify.php create mode 100644 database/migrations/2025_09_02_075243_add_two_factor_columns_to_users_table.php diff --git a/app/Models/User.php b/app/Models/User.php index 3cb5ccb1..63db3931 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,11 +7,12 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Str; +use Laravel\Fortify\TwoFactorAuthenticatable; class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable; + use HasFactory, Notifiable, TwoFactorAuthenticatable; /** * The attributes that are mass assignable. @@ -55,7 +56,7 @@ public function initials(): string return Str::of($this->name) ->explode(' ') ->take(2) - ->map(fn ($word) => Str::substr($word, 0, 1)) + ->map(fn($word) => Str::substr($word, 0, 1)) ->implode(''); } } diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php new file mode 100644 index 00000000..0b488106 --- /dev/null +++ b/app/Providers/FortifyServiceProvider.php @@ -0,0 +1,36 @@ +by($request->session()->get('login.id')); + }); + } +} diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 7d50f931..ea71344f 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -2,5 +2,6 @@ return [ App\Providers\AppServiceProvider::class, + App\Providers\FortifyServiceProvider::class, App\Providers\VoltServiceProvider::class, ]; diff --git a/composer.json b/composer.json index 032cdfac..0e686d58 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ "license": "MIT", "require": { "php": "^8.2", + "laravel/fortify": "^1.30", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1", "livewire/flux": "^2.1.1", diff --git a/config/fortify.php b/config/fortify.php new file mode 100644 index 00000000..cfe82722 --- /dev/null +++ b/config/fortify.php @@ -0,0 +1,159 @@ + 'web', + + /* + |-------------------------------------------------------------------------- + | Fortify Password Broker + |-------------------------------------------------------------------------- + | + | Here you may specify which password broker Fortify can use when a user + | is resetting their password. This configured value should match one + | of your password brokers setup in your "auth" configuration file. + | + */ + + 'passwords' => 'users', + + /* + |-------------------------------------------------------------------------- + | Username / Email + |-------------------------------------------------------------------------- + | + | This value defines which model attribute should be considered as your + | application's "username" field. Typically, this might be the email + | address of the users but you are free to change this value here. + | + | Out of the box, Fortify expects forgot password and reset password + | requests to have a field named 'email'. If the application uses + | another name for the field you may define it below as needed. + | + */ + + 'username' => 'email', + + 'email' => 'email', + + /* + |-------------------------------------------------------------------------- + | Lowercase Usernames + |-------------------------------------------------------------------------- + | + | This value defines whether usernames should be lowercased before saving + | them in the database, as some database system string fields are case + | sensitive. You may disable this for your application if necessary. + | + */ + + 'lowercase_usernames' => true, + + /* + |-------------------------------------------------------------------------- + | Home Path + |-------------------------------------------------------------------------- + | + | Here you may configure the path where users will get redirected during + | authentication or password reset when the operations are successful + | and the user is authenticated. You are free to change this value. + | + */ + + 'home' => '/home', + + /* + |-------------------------------------------------------------------------- + | Fortify Routes Prefix / Subdomain + |-------------------------------------------------------------------------- + | + | Here you may specify which prefix Fortify will assign to all the routes + | that it registers with the application. If necessary, you may change + | subdomain under which all of the Fortify routes will be available. + | + */ + + 'prefix' => '', + + 'domain' => null, + + /* + |-------------------------------------------------------------------------- + | Fortify Routes Middleware + |-------------------------------------------------------------------------- + | + | Here you may specify which middleware Fortify will assign to the routes + | that it registers with the application. If necessary, you may change + | these middleware but typically this provided default is preferred. + | + */ + + 'middleware' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Rate Limiting + |-------------------------------------------------------------------------- + | + | By default, Fortify will throttle logins to five requests per minute for + | every email and IP address combination. However, if you would like to + | specify a custom rate limiter to call then you may specify it here. + | + */ + + 'limiters' => [ + 'login' => 'login', + 'two-factor' => 'two-factor', + ], + + /* + |-------------------------------------------------------------------------- + | Register View Routes + |-------------------------------------------------------------------------- + | + | Here you may specify if the routes returning views should be disabled as + | you may not need them when building your own application. This may be + | especially true if you're writing a custom single-page application. + | + */ + + 'views' => true, + + /* + |-------------------------------------------------------------------------- + | Features + |-------------------------------------------------------------------------- + | + | Some of the Fortify features are optional. You may disable the features + | by removing them from this array. You're free to only remove some of + | these features or you can even remove all of these if you need to. + | + */ + + 'features' => [ + Features::registration(), + Features::resetPasswords(), + // Features::emailVerification(), + Features::updateProfileInformation(), + Features::updatePasswords(), + Features::twoFactorAuthentication([ + 'confirm' => true, + 'confirmPassword' => true, + // 'window' => 0, + ]), + ], + +]; diff --git a/database/migrations/2025_09_02_075243_add_two_factor_columns_to_users_table.php b/database/migrations/2025_09_02_075243_add_two_factor_columns_to_users_table.php new file mode 100644 index 00000000..187d974d --- /dev/null +++ b/database/migrations/2025_09_02_075243_add_two_factor_columns_to_users_table.php @@ -0,0 +1,34 @@ +text('two_factor_secret')->after('password')->nullable(); + $table->text('two_factor_recovery_codes')->after('two_factor_secret')->nullable(); + $table->timestamp('two_factor_confirmed_at')->after('two_factor_recovery_codes')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn([ + 'two_factor_secret', + 'two_factor_recovery_codes', + 'two_factor_confirmed_at', + ]); + }); + } +}; From 4cfad5bba47b557c487c9910d9f3bcdcc6604e5c Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Wed, 3 Sep 2025 18:47:21 +0530 Subject: [PATCH 02/43] wip --- app/Models/User.php | 2 +- app/Providers/FortifyServiceProvider.php | 7 +- config/fortify.php | 12 +- .../views/components/input-otp.blade.php | 156 +++++++++++ .../components/settings/layout.blade.php | 1 + resources/views/flux/icon/copy.blade.php | 42 +++ resources/views/flux/icon/eye-off.blade.php | 44 +++ .../views/flux/icon/loader-circle.blade.php | 41 +++ .../flux/icon/lock-keyhole-open.blade.php | 43 +++ resources/views/flux/icon/scan-line.blade.php | 45 +++ .../views/flux/icon/shield-check.blade.php | 42 +++ .../livewire/auth/confirm-password.blade.php | 3 +- resources/views/livewire/auth/login.blade.php | 35 ++- .../auth/two-factor-challenge.blade.php | 59 ++++ .../livewire/settings/two-factor.blade.php | 262 ++++++++++++++++++ resources/views/settings/two-factor.blade.php | 3 + routes/auth.php | 1 + routes/web.php | 2 + tests/Feature/Auth/EmailVerificationTest.php | 2 - .../Auth/TwoFactorAuthenticationTest.php | 214 ++++++++++++++ 20 files changed, 996 insertions(+), 20 deletions(-) create mode 100644 resources/views/components/input-otp.blade.php create mode 100644 resources/views/flux/icon/copy.blade.php create mode 100644 resources/views/flux/icon/eye-off.blade.php create mode 100644 resources/views/flux/icon/loader-circle.blade.php create mode 100644 resources/views/flux/icon/lock-keyhole-open.blade.php create mode 100644 resources/views/flux/icon/scan-line.blade.php create mode 100644 resources/views/flux/icon/shield-check.blade.php create mode 100644 resources/views/livewire/auth/two-factor-challenge.blade.php create mode 100644 resources/views/livewire/settings/two-factor.blade.php create mode 100644 resources/views/settings/two-factor.blade.php create mode 100644 tests/Feature/Auth/TwoFactorAuthenticationTest.php diff --git a/app/Models/User.php b/app/Models/User.php index 63db3931..a56996b9 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -56,7 +56,7 @@ public function initials(): string return Str::of($this->name) ->explode(' ') ->take(2) - ->map(fn($word) => Str::substr($word, 0, 1)) + ->map(fn ($word) => Str::substr($word, 0, 1)) ->implode(''); } } diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 0b488106..7d17a2bb 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -2,16 +2,11 @@ namespace App\Providers; -use App\Actions\Fortify\CreateNewUser; -use App\Actions\Fortify\ResetUserPassword; -use App\Actions\Fortify\UpdateUserPassword; -use App\Actions\Fortify\UpdateUserProfileInformation; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Illuminate\Support\Str; -use Laravel\Fortify\Actions\RedirectIfTwoFactorAuthenticatable; use Laravel\Fortify\Fortify; class FortifyServiceProvider extends ServiceProvider @@ -29,6 +24,8 @@ public function register(): void */ public function boot(): void { + Fortify::twoFactorChallengeView(fn () => view('livewire.auth.two-factor-challenge')); + RateLimiter::for('two-factor', function (Request $request) { return Limit::perMinute(5)->by($request->session()->get('login.id')); }); diff --git a/config/fortify.php b/config/fortify.php index cfe82722..e378577c 100644 --- a/config/fortify.php +++ b/config/fortify.php @@ -73,7 +73,7 @@ | */ - 'home' => '/home', + 'home' => '/dashbaord', /* |-------------------------------------------------------------------------- @@ -144,11 +144,11 @@ */ 'features' => [ - Features::registration(), - Features::resetPasswords(), - // Features::emailVerification(), - Features::updateProfileInformation(), - Features::updatePasswords(), + // Features::registration(), + // Features::resetPasswords(), + // // Features::emailVerification(), + // Features::updateProfileInformation(), + // Features::updatePasswords(), Features::twoFactorAuthentication([ 'confirm' => true, 'confirmPassword' => true, diff --git a/resources/views/components/input-otp.blade.php b/resources/views/components/input-otp.blade.php new file mode 100644 index 00000000..3c4107f1 --- /dev/null +++ b/resources/views/components/input-otp.blade.php @@ -0,0 +1,156 @@ +@props([ + 'digits' => 6, + 'eventCallback' => null, + 'name' => 'code', +]) + +
+ +
+ @for ($x = 1; $x <= $digits; $x++) + + @endfor +
+ + except(['eventCallback', 'digits', 'name']) }} + type="hidden" + class="hidden" + x-ref="code" + name="{{ $name }}" + minlength="{{ $digits }}" + maxlength="{{ $digits }}" /> +
diff --git a/resources/views/components/settings/layout.blade.php b/resources/views/components/settings/layout.blade.php index 05c0637d..f3119efb 100644 --- a/resources/views/components/settings/layout.blade.php +++ b/resources/views/components/settings/layout.blade.php @@ -3,6 +3,7 @@ {{ __('Profile') }} {{ __('Password') }} + {{ __('Two-factor Authentication') }} {{ __('Appearance') }} diff --git a/resources/views/flux/icon/copy.blade.php b/resources/views/flux/icon/copy.blade.php new file mode 100644 index 00000000..077ee6fb --- /dev/null +++ b/resources/views/flux/icon/copy.blade.php @@ -0,0 +1,42 @@ +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + diff --git a/resources/views/flux/icon/eye-off.blade.php b/resources/views/flux/icon/eye-off.blade.php new file mode 100644 index 00000000..94abf4f2 --- /dev/null +++ b/resources/views/flux/icon/eye-off.blade.php @@ -0,0 +1,44 @@ +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + + diff --git a/resources/views/flux/icon/loader-circle.blade.php b/resources/views/flux/icon/loader-circle.blade.php new file mode 100644 index 00000000..24ff26e5 --- /dev/null +++ b/resources/views/flux/icon/loader-circle.blade.php @@ -0,0 +1,41 @@ +{{-- Credit: Lucide (htt}ps://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + diff --git a/resources/views/flux/icon/lock-keyhole-open.blade.php b/resources/views/flux/icon/lock-keyhole-open.blade.php new file mode 100644 index 00000000..bf1ba2bd --- /dev/null +++ b/resources/views/flux/icon/lock-keyhole-open.blade.php @@ -0,0 +1,43 @@ +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php + if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); + } + + $classes = Flux::classes('shrink-0')->add( + match ($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }, + ); + + $strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, + }; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + diff --git a/resources/views/flux/icon/scan-line.blade.php b/resources/views/flux/icon/scan-line.blade.php new file mode 100644 index 00000000..9677303d --- /dev/null +++ b/resources/views/flux/icon/scan-line.blade.php @@ -0,0 +1,45 @@ +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + + + diff --git a/resources/views/flux/icon/shield-check.blade.php b/resources/views/flux/icon/shield-check.blade.php new file mode 100644 index 00000000..a57b970a --- /dev/null +++ b/resources/views/flux/icon/shield-check.blade.php @@ -0,0 +1,42 @@ +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + diff --git a/resources/views/livewire/auth/confirm-password.blade.php b/resources/views/livewire/auth/confirm-password.blade.php index 41e7034d..0742a09d 100644 --- a/resources/views/livewire/auth/confirm-password.blade.php +++ b/resources/views/livewire/auth/confirm-password.blade.php @@ -1,9 +1,8 @@ ensureIsNotRateLimited(); - if (! Auth::attempt(['email' => $this->email, 'password' => $this->password], $this->remember)) { - RateLimiter::hit($this->throttleKey()); + $user = $this->validateCredentials(); - throw ValidationException::withMessages([ - 'email' => __('auth.failed'), + if (Features::enabled(Features::twoFactorAuthentication()) && $user->hasEnabledTwoFactorAuthentication()) { + Session::put([ + 'login.id' => $user->getKey(), + 'login.remember' => $this->remember, ]); + + $this->redirect(route('two-factor.login'), navigate: true); + return; } + Auth::login($user, $this->remember); + RateLimiter::clear($this->throttleKey()); Session::regenerate(); $this->redirectIntended(default: route('dashboard', absolute: false), navigate: true); } + /** + * Validate the request's credentials and return the user without logging them in. + */ + protected function validateCredentials(): User + { + /** @var User $user */ + $user = Auth::getProvider()->retrieveByCredentials(['email' => $this->email, 'password' => $this->password]); + + if (! $user || ! Auth::getProvider()->validateCredentials($user, ['password' => $this->password])) { + RateLimiter::hit($this->throttleKey()); + + throw ValidationException::withMessages([ + 'email' => __('auth.failed'), + ]); + } + + return $user; + } + /** * Ensure the authentication request is not rate limited. */ diff --git a/resources/views/livewire/auth/two-factor-challenge.blade.php b/resources/views/livewire/auth/two-factor-challenge.blade.php new file mode 100644 index 00000000..7a832acb --- /dev/null +++ b/resources/views/livewire/auth/two-factor-challenge.blade.php @@ -0,0 +1,59 @@ + +
+
+
+ +
+
+ +
+ +
+ @csrf + +
+
+
+ +
+ @error('code') +

{{ $message }}

+ @enderror +
+
+
+ +
+ @error('recovery_code') +

{{ $message }}

+ @enderror +
+ + + {{ __('Continue') }} + +
+ +
+ or you can +
+ login + using a recovery code + login + using an authentication code +
+
+
+
+
+
diff --git a/resources/views/livewire/settings/two-factor.blade.php b/resources/views/livewire/settings/two-factor.blade.php new file mode 100644 index 00000000..0c837d9e --- /dev/null +++ b/resources/views/livewire/settings/two-factor.blade.php @@ -0,0 +1,262 @@ +user()->two_factor_confirmed_at)) { + app(DisableTwoFactorAuthentication::class)(auth()->user()); + } else { + $this->confirmed = true; + $this->showRecoveryCodes = false; + } + } + + public function enable() + { + app(EnableTwoFactorAuthentication::class)(auth()->user()); + $this->qr = auth()->user()->twoFactorQrCodeSvg(); + $this->secret = decrypt(auth()->user()->two_factor_secret); + $this->codes = json_decode(decrypt(auth()->user()->two_factor_recovery_codes), true); + $this->enabled = true; + } + + + public function regenerateCodes(GenerateNewRecoveryCodes $generate) + { + $generate(Auth::user()); + } + + #[On('submitCode')] + public function submitCode($code = null) + { + if ($code) { + $this->authCode = $code; + } + + $this->validate(); + app(ConfirmTwoFactorAuthentication::class)(auth()->user(), $this->authCode); + $this->qr = null; + $this->secret = null; + } + + public function disable() + { + app(DisableTwoFactorAuthentication::class)(auth()->user()); + + $this->enabled = false; + $this->confirmed = false; + } + + +} + +?> + +
+ @include('partials.settings-heading') + +
+ @if(!$confirmed) +
+ Disabled +

When you enable 2FA, you’ll be prompted for + a secure code during login, which can be retrieved from your phone's Google Authenticator app.

+ +
+ + {{ __('Enable') }} + +
+
+
+ + +
+
+
+
+ @for($i = 1; $i <= 5; $i++) +
+ @endfor +
+
+ @for($i = 1; $i <= 5; $i++) +
+ @endfor +
+ +
+
+
+

+ {{ __('Turn on 2-step Verification') }} + {{ __('Verify Authentication Code') }} +

+

+ {{ __('Open your authenticator app and choose Scan QR code') }} + {{ __('Enter the 6-digit code from your authenticator app') }} +

+
+
+
+
+
+ +
+
+
+ {!! $qr !!} +
+
+
+
+ +
+ {{ __('Continue') }} +
+
+
+ {{ __('or, enter the code manually') }} +
+
+
+
+ +
+ @if($enabled) + + + @endif +
+
+
+ +
+ + @error('code') +

{{ $message }}

+ @enderror +
+ {{ __('Back') }} + + {{ __('Confirm') }} +
+
+
+
+ @else +
+
+ Enabled +
+

With two factor authentication enabled, you’ll be prompted for a secure, random token during login, + which you can retrieve from your Google Authenticator app.

+ +
+ + 2FA Recovery Codes + + Recovery codes let you regain access if you lose your 2FA device. Store them in a secure + password manager. + + +
+
+
+ + View My Recovery Codes + + Hide Recovery Codes +
+ + {{ __('Regenerate Codes') }} + +
+
+
+ @foreach (json_decode(decrypt(auth()->user()->two_factor_recovery_codes), true) as $code) +
{{ $code }}
+ @endforeach +
+

+ You have {{ count(json_decode(decrypt(auth()->user()->two_factor_recovery_codes))) }} + recovery codes left. Each can be used once to access your account and will be removed + after use. If you need more, click Regenerate Codes + above.

+
+
+
+
+ + {{ __('Disable 2FA') }} + +
+
+ + @endif +
+ +
+
diff --git a/resources/views/settings/two-factor.blade.php b/resources/views/settings/two-factor.blade.php new file mode 100644 index 00000000..49448f19 --- /dev/null +++ b/resources/views/settings/two-factor.blade.php @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/routes/auth.php b/routes/auth.php index 62e63525..92ee5591 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -31,5 +31,6 @@ ->name('password.confirm'); }); + Route::post('logout', App\Livewire\Actions\Logout::class) ->name('logout'); diff --git a/routes/web.php b/routes/web.php index e8b9c25c..ac60e67c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -17,6 +17,8 @@ Volt::route('settings/profile', 'settings.profile')->name('settings.profile'); Volt::route('settings/password', 'settings.password')->name('settings.password'); Volt::route('settings/appearance', 'settings.appearance')->name('settings.appearance'); + + Volt::route('settings/two-factor', 'settings.two-factor')->name('settings.two-factor'); }); require __DIR__.'/auth.php'; diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php index c520cc54..bdce4154 100644 --- a/tests/Feature/Auth/EmailVerificationTest.php +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -7,8 +7,6 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\URL; -use Livewire\Livewire; -use Livewire\Volt\Volt; use Tests\TestCase; class EmailVerificationTest extends TestCase diff --git a/tests/Feature/Auth/TwoFactorAuthenticationTest.php b/tests/Feature/Auth/TwoFactorAuthenticationTest.php new file mode 100644 index 00000000..7eb6d112 --- /dev/null +++ b/tests/Feature/Auth/TwoFactorAuthenticationTest.php @@ -0,0 +1,214 @@ +markTestSkipped('Two factor authentication is not enabled.'); + } + } + + public function test_two_factor_settings_page_can_be_accessed() + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->get('/settings/two-factor'); + + $response->assertStatus(200); + } + + public function test_two_factor_authentication_can_be_enabled() + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->post('/user/two-factor-authentication'); + + $user->refresh(); + + $this->assertNotNull($user->two_factor_secret); + $this->assertNull($user->two_factor_confirmed_at); + } + + public function test_two_factor_authentication_can_be_confirmed() + { + $user = User::factory()->create(); + + // Enable 2FA + $this->actingAs($user) + ->post('/user/two-factor-authentication'); + + $user->refresh(); + + // Get the secret and generate a valid code + $secret = decrypt($user->two_factor_secret); + $google2fa = app('pragmarx.google2fa'); + $validCode = $google2fa->getCurrentOtp($secret); + + // Confirm 2FA with valid code + $this->actingAs($user) + ->post('/user/confirmed-two-factor-authentication', [ + 'code' => $validCode, + ]); + + $user->refresh(); + + $this->assertNotNull($user->two_factor_confirmed_at); + $this->assertNotNull($user->two_factor_recovery_codes); + } + + public function test_two_factor_authentication_can_be_disabled() + { + $user = User::factory()->create([ + 'two_factor_secret' => encrypt('test-secret'), + 'two_factor_confirmed_at' => now(), + 'two_factor_recovery_codes' => encrypt(json_encode(['recovery-code'])), + ]); + + $this->actingAs($user) + ->delete('/user/two-factor-authentication'); + + $user->refresh(); + + $this->assertNull($user->two_factor_secret); + $this->assertNull($user->two_factor_confirmed_at); + $this->assertNull($user->two_factor_recovery_codes); + } + + public function test_recovery_codes_can_be_regenerated() + { + $user = User::factory()->create([ + 'two_factor_secret' => encrypt('test-secret'), + 'two_factor_confirmed_at' => now(), + 'two_factor_recovery_codes' => encrypt(json_encode(['old-recovery-code'])), + ]); + + $originalRecoveryCodes = $user->two_factor_recovery_codes; + + $this->actingAs($user) + ->post('/user/two-factor-recovery-codes'); + + $user->refresh(); + + $this->assertNotEquals($originalRecoveryCodes, $user->two_factor_recovery_codes); + } + + public function test_user_with_two_factor_enabled_is_redirected_to_challenge_on_login() + { + $user = User::factory()->create([ + 'two_factor_secret' => encrypt('test-secret'), + 'two_factor_confirmed_at' => now(), + 'two_factor_recovery_codes' => encrypt(json_encode(['recovery-code'])), + ]); + + $response = $this->post('/login', [ + 'email' => $user->email, + 'password' => 'password', + ]); + + $response->assertRedirect('/two-factor-challenge'); + $this->assertEquals($user->id, Session::get('login.id')); + } + + public function test_user_can_login_with_two_factor_code() + { + $user = User::factory()->create([ + 'two_factor_secret' => encrypt('test-secret'), + 'two_factor_confirmed_at' => now(), + 'two_factor_recovery_codes' => encrypt(json_encode(['recovery-code'])), + ]); + + // Set up session as if user just entered credentials + Session::put([ + 'login.id' => $user->id, + 'login.remember' => false, + ]); + + // Generate valid 2FA code + $secret = decrypt($user->two_factor_secret); + $google2fa = app('pragmarx.google2fa'); + $validCode = $google2fa->getCurrentOtp($secret); + + $response = $this->post('/two-factor-challenge', [ + 'code' => $validCode, + ]); + + $response->assertRedirect('/dashboard'); + $this->assertAuthenticatedAs($user); + } + + public function test_user_can_login_with_recovery_code() + { + $recoveryCode = 'recovery-code-123'; + $user = User::factory()->create([ + 'two_factor_secret' => encrypt('test-secret'), + 'two_factor_confirmed_at' => now(), + 'two_factor_recovery_codes' => encrypt(json_encode([$recoveryCode, 'another-code'])), + ]); + + // Set up session as if user just entered credentials + Session::put([ + 'login.id' => $user->id, + 'login.remember' => false, + ]); + + $response = $this->post('/two-factor-challenge', [ + 'recovery_code' => $recoveryCode, + ]); + + $response->assertRedirect('/dashboard'); + $this->assertAuthenticatedAs($user); + + // Verify recovery code was consumed + $user->refresh(); + $recoveryCodes = json_decode(decrypt($user->two_factor_recovery_codes), true); + $this->assertNotContains($recoveryCode, $recoveryCodes); + } + + public function test_invalid_two_factor_code_fails_authentication() + { + $user = User::factory()->create([ + 'two_factor_secret' => encrypt('test-secret'), + 'two_factor_confirmed_at' => now(), + ]); + + Session::put([ + 'login.id' => $user->id, + 'login.remember' => false, + ]); + + $response = $this->post('/two-factor-challenge', [ + 'code' => '000000', + ]); + + $response->assertSessionHasErrors(['code']); + $this->assertGuest(); + } + + public function test_user_without_two_factor_enabled_can_login_normally() + { + $user = User::factory()->create(); + + $response = $this->post('/login', [ + 'email' => $user->email, + 'password' => 'password', + ]); + + $response->assertRedirect('/dashboard'); + $this->assertAuthenticatedAs($user); + } +} From 0357084ee89cdc9f25b9afcf7f0a32e64d021ba0 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Thu, 4 Sep 2025 00:05:31 +0530 Subject: [PATCH 03/43] Support Fortify Options --- app/Providers/FortifyServiceProvider.php | 3 +- config/fortify.php | 4 +- resources/views/flux/icon/eye.blade.php | 42 ++ .../views/flux/icon/lock-keyhole.blade.php | 43 ++ .../views/flux/icon/refresh-cw.blade.php | 44 ++ .../views/flux/icon/shield-ban.blade.php | 42 ++ .../livewire/auth/confirm-password.blade.php | 6 +- .../auth/two-factor-challenge.blade.php | 14 +- .../livewire/settings/two-factor.blade.php | 544 ++++++++++++------ resources/views/settings/two-factor.blade.php | 3 - routes/auth.php | 3 +- routes/web.php | 6 +- 12 files changed, 552 insertions(+), 202 deletions(-) create mode 100644 resources/views/flux/icon/eye.blade.php create mode 100644 resources/views/flux/icon/lock-keyhole.blade.php create mode 100644 resources/views/flux/icon/refresh-cw.blade.php create mode 100644 resources/views/flux/icon/shield-ban.blade.php delete mode 100644 resources/views/settings/two-factor.blade.php diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 7d17a2bb..5ce88f56 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -6,8 +6,8 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; -use Illuminate\Support\Str; use Laravel\Fortify\Fortify; +use Livewire\Volt\Volt; class FortifyServiceProvider extends ServiceProvider { @@ -24,6 +24,7 @@ public function register(): void */ public function boot(): void { + Fortify::ignoreRoutes(); Fortify::twoFactorChallengeView(fn () => view('livewire.auth.two-factor-challenge')); RateLimiter::for('two-factor', function (Request $request) { diff --git a/config/fortify.php b/config/fortify.php index e378577c..4143bd35 100644 --- a/config/fortify.php +++ b/config/fortify.php @@ -73,7 +73,7 @@ | */ - 'home' => '/dashbaord', + 'home' => '/dashboard', /* |-------------------------------------------------------------------------- @@ -146,7 +146,7 @@ 'features' => [ // Features::registration(), // Features::resetPasswords(), - // // Features::emailVerification(), + // Features::emailVerification(), // Features::updateProfileInformation(), // Features::updatePasswords(), Features::twoFactorAuthentication([ diff --git a/resources/views/flux/icon/eye.blade.php b/resources/views/flux/icon/eye.blade.php new file mode 100644 index 00000000..90c28607 --- /dev/null +++ b/resources/views/flux/icon/eye.blade.php @@ -0,0 +1,42 @@ +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + diff --git a/resources/views/flux/icon/lock-keyhole.blade.php b/resources/views/flux/icon/lock-keyhole.blade.php new file mode 100644 index 00000000..ba89d7ff --- /dev/null +++ b/resources/views/flux/icon/lock-keyhole.blade.php @@ -0,0 +1,43 @@ +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + diff --git a/resources/views/flux/icon/refresh-cw.blade.php b/resources/views/flux/icon/refresh-cw.blade.php new file mode 100644 index 00000000..ae36ca2d --- /dev/null +++ b/resources/views/flux/icon/refresh-cw.blade.php @@ -0,0 +1,44 @@ +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + + diff --git a/resources/views/flux/icon/shield-ban.blade.php b/resources/views/flux/icon/shield-ban.blade.php new file mode 100644 index 00000000..abb8ea5d --- /dev/null +++ b/resources/views/flux/icon/shield-ban.blade.php @@ -0,0 +1,42 @@ +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + diff --git a/resources/views/livewire/auth/confirm-password.blade.php b/resources/views/livewire/auth/confirm-password.blade.php index 0742a09d..e7a6e7aa 100644 --- a/resources/views/livewire/auth/confirm-password.blade.php +++ b/resources/views/livewire/auth/confirm-password.blade.php @@ -1,5 +1,6 @@ ['required', 'string'], ]); - if (! Auth::guard('web')->validate([ + if (!Auth::guard('web')->validate([ 'email' => Auth::user()->email, 'password' => $this->password, ])) { @@ -30,7 +31,6 @@ public function confirmPassword(): void $this->redirectIntended(default: route('dashboard', absolute: false), navigate: true); } }; ?> -
- +
diff --git a/resources/views/livewire/auth/two-factor-challenge.blade.php b/resources/views/livewire/auth/two-factor-challenge.blade.php index 7a832acb..e6fbe85c 100644 --- a/resources/views/livewire/auth/two-factor-challenge.blade.php +++ b/resources/views/livewire/auth/two-factor-challenge.blade.php @@ -4,7 +4,7 @@ showRecoveryInput: {{ $errors->has('recovery_code') ? 'true' : 'false' }}, code: '', recovery_code: '' - }" class="relative w-full h-auto"> + }" class="relative w-full h-auto" >
@@ -12,7 +12,7 @@
- + @csrf
@@ -25,7 +25,7 @@

{{ $message }}

@enderror
-
+
- or you can + {{ __('or you can') }}
login - using a recovery code + @click="showRecoveryInput = true; code = ''; recovery_code = ''; $dispatch('clear-auth-2fa-auth-code'); $nextTick(() => $refs.recovery_code?.focus())">{{ __('login using a recovery code') }} login - using an authentication code + @click="showRecoveryInput = false; code = ''; recovery_code = ''; $dispatch('clear-auth-2fa-auth-code'); $nextTick(() => $dispatch('focus-auth-2fa-auth-code'))">{{ __('login using an authentication code') }}
diff --git a/resources/views/livewire/settings/two-factor.blade.php b/resources/views/livewire/settings/two-factor.blade.php index 0c837d9e..11004647 100644 --- a/resources/views/livewire/settings/two-factor.blade.php +++ b/resources/views/livewire/settings/two-factor.blade.php @@ -1,97 +1,306 @@ user()->two_factor_confirmed_at)) { - app(DisableTwoFactorAuthentication::class)(auth()->user()); - } else { - $this->confirmed = true; - $this->showRecoveryCodes = false; + if ($this->twoFactorEnabled) { + return [ + 'title' => __('Two-Factor Authentication Enabled'), + 'description' => __('Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.'), + 'buttonText' => __('Close') + ]; + } + + if ($this->showVerificationStep) { + return [ + 'title' => __('Verify Authentication Code'), + 'description' => __('Enter the 6-digit code from your authenticator app'), + 'buttonText' => __('Continue') + ]; } + + return [ + 'title' => __('Enable Two-Factor Authentication'), + 'description' => __('To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app'), + 'buttonText' => __('Continue') + ]; } - public function enable() + public function mount(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): void { - app(EnableTwoFactorAuthentication::class)(auth()->user()); - $this->qr = auth()->user()->twoFactorQrCodeSvg(); - $this->secret = decrypt(auth()->user()->two_factor_secret); - $this->codes = json_decode(decrypt(auth()->user()->two_factor_recovery_codes), true); - $this->enabled = true; + abort_unless(Features::enabled(Features::twoFactorAuthentication()), Response::HTTP_FORBIDDEN); + + if (Fortify::confirmsTwoFactorAuthentication() && is_null(auth()->user()->two_factor_confirmed_at)) { + $disableTwoFactorAuthentication(auth()->user()); + } + + $this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication(); + $this->requiresConfirmation = Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'); + + if ($this->twoFactorEnabled) { + $this->loadRecoveryCodes(); + } } + public function enable(EnableTwoFactorAuthentication $enableTwoFactorAuthentication): void + { + $enableTwoFactorAuthentication(auth()->user()); + $this->fetchSetupData(); + if (!$this->requiresConfirmation) { + $this->twoFactorEnabled = true; + } + $this->dispatch('show-two-factor-modal'); + } - public function regenerateCodes(GenerateNewRecoveryCodes $generate) + public function fetchSetupData(): void { - $generate(Auth::user()); + $user = auth()->user(); + $this->qrCodeSvg = $user->twoFactorQrCodeSvg(); + $this->manualSetupKey = decrypt($user->two_factor_secret); } - #[On('submitCode')] - public function submitCode($code = null) + public function proceedToVerification(): void { - if ($code) { - $this->authCode = $code; + if ($this->requiresConfirmation) { + $this->showVerificationStep = true; + $this->resetErrorBag(); + } else { + $this->closeModal(); } + } + + public function backToSetup(): void + { + $this->showVerificationStep = false; + $this->authCode = ''; + $this->resetErrorBag(); + } + public function confirmTwoFactor(ConfirmTwoFactorAuthentication $confirmTwoFactorAuthentication): void + { $this->validate(); - app(ConfirmTwoFactorAuthentication::class)(auth()->user(), $this->authCode); - $this->qr = null; - $this->secret = null; + $confirmTwoFactorAuthentication(auth()->user(), $this->authCode); + $this->twoFactorEnabled = true; + $this->loadRecoveryCodes(); + $this->closeModal(); + $this->dispatch('two-factor-enabled'); } - public function disable() + public function regenerateRecoveryCodes(GenerateNewRecoveryCodes $generateNewRecoveryCodes): void + { + $generateNewRecoveryCodes(Auth::user()); + $this->loadRecoveryCodes(); + } + + public function disable(): void { app(DisableTwoFactorAuthentication::class)(auth()->user()); + $this->twoFactorEnabled = false; + $this->clearSetupData(); + } + + public function closeModal(): void + { + $this->showVerificationStep = false; + $this->authCode = ''; + $this->resetErrorBag(); - $this->enabled = false; - $this->confirmed = false; + if ($this->twoFactorEnabled) { + $this->clearSetupData(); + } + + $this->dispatch('hide-two-factor-modal'); } + public function clearSetupData(): void + { + $this->qrCodeSvg = ''; + $this->manualSetupKey = ''; + $this->recoveryCodes = []; + } -} + public function toggleRecoveryCodes(): void + { + if (!$this->recoveryCodes) { + $this->loadRecoveryCodes(); + } + $this->showRecoveryCodes = !$this->showRecoveryCodes; + } -?> + public function fetchRecoveryCodes(): void + { + if (!$this->recoveryCodes) { + $this->loadRecoveryCodes(); + } + } + + private function loadRecoveryCodes(): void + { + $this->recoveryCodes = json_decode(decrypt(auth()->user()->two_factor_recovery_codes), true); + } +} ?>
@include('partials.settings-heading') - -
- @if(!$confirmed) -
- Disabled -

When you enable 2FA, you’ll be prompted for - a secure code during login, which can be retrieved from your phone's Google Authenticator app.

- -
- - {{ __('Enable') }} - + +
+ @if(!$twoFactorEnabled) +
+ {{ __('Disabled') }} + + {{ __('When you enable two-factor authentication, you will be prompted for a secure pin during login. This pin can be retrieved from a TOTP-supported application on your phone.') }} + + +
+ + {{ __('Enable 2FA') }} + {{ __('Enabling...') }} + +
+
+ @else +
+
+ {{ __('Enabled') }} +
+ + {{ __('With two-factor authentication enabled, you will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.') }} + + +
+
+
+ + + {{ __('2FA Recovery Codes') }} + +
+ + {{ __('Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.') }} + +
+
+
+ + + {{ __('Hide Recovery Codes') }} + + + {{ __('Regenerate Codes') }} + {{ __('Regenerating...') }} + +
+
+
+
+ + @foreach($recoveryCodes as $index => $code) +
+
{{ $code }}
+ @endforeach + +
+ + {!! __('Each recovery code can be used once to access your account and will be removed after use. If you need more, click :regenerate above.', ['regenerate' => __('Regenerate Codes')]) !!} + + +
+
- +
+ +
+ + {{ __('Disable 2FA') }} + {{ __('Disabling...') }} + +
+ @endif - -
+ +
+
+
-
-

- {{ __('Turn on 2-step Verification') }} - {{ __('Verify Authentication Code') }} -

-

- {{ __('Open your authenticator app and choose Scan QR code') }} - {{ __('Enter the 6-digit code from your authenticator app') }} -

+
+ {{ $this->modalConfig['title'] }} + {{ $this->modalConfig['description'] }}
-
-
+
+ + @if(!$showVerificationStep) +
+
-
- -
-
-
- {!! $qr !!} + class="border border-stone-200 dark:border-stone-700 rounded-lg relative overflow-hidden w-64 aspect-square"> + @if(empty($qrCodeSvg)) +
+
-
+ @else +
+ {!! $qrCodeSvg !!} +
+ @endif
-
- {{ __('Continue') }} -
-
-
- {{ __('or, enter the code manually') }} +
+ + {{ $this->modalConfig['buttonText'] }} +
-
-
-
- + +
+
+
+ + {{ __('or, enter the code manually') }} + +
+ +
+
+ @if(empty($manualSetupKey)) +
+ +
+ @else + + + @endif
- @if($enabled) - - - @endif
- -
- - @error('code') -

{{ $message }}

- @enderror -
- {{ __('Back') }} - - {{ __('Confirm') }} + @else +
+
+ + @error('authCode') +

{{ $message }}

+ @enderror
-
-
- - @else -
-
- Enabled -
-

With two factor authentication enabled, you’ll be prompted for a secure, random token during login, - which you can retrieve from your Google Authenticator app.

-
- - 2FA Recovery Codes - - Recovery codes let you regain access if you lose your 2FA device. Store them in a secure - password manager. - - -
-
-
- - View My Recovery Codes - - Hide Recovery Codes -
- - {{ __('Regenerate Codes') }} +
+ + {{ __('Back') }} + + + {{ __('Confirm') }} + {{ __('Confirming...') }} -
-
-
- @foreach (json_decode(decrypt(auth()->user()->two_factor_recovery_codes), true) as $code) -
{{ $code }}
- @endforeach -
-

- You have {{ count(json_decode(decrypt(auth()->user()->two_factor_recovery_codes))) }} - recovery codes left. Each can be used once to access your account and will be removed - after use. If you need more, click Regenerate Codes - above.

-
-
- - {{ __('Disable 2FA') }} - -
+ @endif
- - @endif +
-
diff --git a/resources/views/settings/two-factor.blade.php b/resources/views/settings/two-factor.blade.php deleted file mode 100644 index 49448f19..00000000 --- a/resources/views/settings/two-factor.blade.php +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/routes/auth.php b/routes/auth.php index 92ee5591..e37a6b68 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -27,10 +27,9 @@ ->middleware(['signed', 'throttle:6,1']) ->name('verification.verify'); - Volt::route('confirm-password', 'auth.confirm-password') + Volt::route('user/confirm-password', 'auth.confirm-password') ->name('password.confirm'); }); - Route::post('logout', App\Livewire\Actions\Logout::class) ->name('logout'); diff --git a/routes/web.php b/routes/web.php index ac60e67c..eb8d8ed9 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ name('dashboard'); Route::middleware(['auth'])->group(function () { - Route::redirect('settings', 'settings/profile'); + Route::redirect('settings', 'settings/profile')->middleware(['password.confirm']); Volt::route('settings/profile', 'settings.profile')->name('settings.profile'); Volt::route('settings/password', 'settings.password')->name('settings.password'); Volt::route('settings/appearance', 'settings.appearance')->name('settings.appearance'); - Volt::route('settings/two-factor', 'settings.two-factor')->name('settings.two-factor'); + Volt::route('settings/two-factor', 'settings.two-factor') + ->name('settings.two-factor'); }); require __DIR__.'/auth.php'; From d965439bdc18ff437bcee0645183b6cfa120e0e8 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Fri, 5 Sep 2025 01:53:24 +0530 Subject: [PATCH 04/43] FIx Tests --- app/Providers/FortifyServiceProvider.php | 1 - routes/web.php | 9 +- .../Auth/TwoFactorAuthenticationTest.php | 214 ------------------ 3 files changed, 7 insertions(+), 217 deletions(-) delete mode 100644 tests/Feature/Auth/TwoFactorAuthenticationTest.php diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 5ce88f56..990a210c 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -24,7 +24,6 @@ public function register(): void */ public function boot(): void { - Fortify::ignoreRoutes(); Fortify::twoFactorChallengeView(fn () => view('livewire.auth.two-factor-challenge')); RateLimiter::for('two-factor', function (Request $request) { diff --git a/routes/web.php b/routes/web.php index eb8d8ed9..4f7c29bb 100644 --- a/routes/web.php +++ b/routes/web.php @@ -13,14 +13,19 @@ ->name('dashboard'); Route::middleware(['auth'])->group(function () { - Route::redirect('settings', 'settings/profile')->middleware(['password.confirm']); + Route::redirect('settings', 'settings/profile'); Volt::route('settings/profile', 'settings.profile')->name('settings.profile'); Volt::route('settings/password', 'settings.password')->name('settings.password'); Volt::route('settings/appearance', 'settings.appearance')->name('settings.appearance'); + $twoFactorMiddleware = Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword') + ? ['password.confirm'] + : []; + Volt::route('settings/two-factor', 'settings.two-factor') + ->middleware($twoFactorMiddleware) ->name('settings.two-factor'); }); -require __DIR__.'/auth.php'; +require __DIR__ . '/auth.php'; diff --git a/tests/Feature/Auth/TwoFactorAuthenticationTest.php b/tests/Feature/Auth/TwoFactorAuthenticationTest.php deleted file mode 100644 index 7eb6d112..00000000 --- a/tests/Feature/Auth/TwoFactorAuthenticationTest.php +++ /dev/null @@ -1,214 +0,0 @@ -markTestSkipped('Two factor authentication is not enabled.'); - } - } - - public function test_two_factor_settings_page_can_be_accessed() - { - $user = User::factory()->create(); - - $response = $this->actingAs($user)->get('/settings/two-factor'); - - $response->assertStatus(200); - } - - public function test_two_factor_authentication_can_be_enabled() - { - $user = User::factory()->create(); - - $this->actingAs($user) - ->post('/user/two-factor-authentication'); - - $user->refresh(); - - $this->assertNotNull($user->two_factor_secret); - $this->assertNull($user->two_factor_confirmed_at); - } - - public function test_two_factor_authentication_can_be_confirmed() - { - $user = User::factory()->create(); - - // Enable 2FA - $this->actingAs($user) - ->post('/user/two-factor-authentication'); - - $user->refresh(); - - // Get the secret and generate a valid code - $secret = decrypt($user->two_factor_secret); - $google2fa = app('pragmarx.google2fa'); - $validCode = $google2fa->getCurrentOtp($secret); - - // Confirm 2FA with valid code - $this->actingAs($user) - ->post('/user/confirmed-two-factor-authentication', [ - 'code' => $validCode, - ]); - - $user->refresh(); - - $this->assertNotNull($user->two_factor_confirmed_at); - $this->assertNotNull($user->two_factor_recovery_codes); - } - - public function test_two_factor_authentication_can_be_disabled() - { - $user = User::factory()->create([ - 'two_factor_secret' => encrypt('test-secret'), - 'two_factor_confirmed_at' => now(), - 'two_factor_recovery_codes' => encrypt(json_encode(['recovery-code'])), - ]); - - $this->actingAs($user) - ->delete('/user/two-factor-authentication'); - - $user->refresh(); - - $this->assertNull($user->two_factor_secret); - $this->assertNull($user->two_factor_confirmed_at); - $this->assertNull($user->two_factor_recovery_codes); - } - - public function test_recovery_codes_can_be_regenerated() - { - $user = User::factory()->create([ - 'two_factor_secret' => encrypt('test-secret'), - 'two_factor_confirmed_at' => now(), - 'two_factor_recovery_codes' => encrypt(json_encode(['old-recovery-code'])), - ]); - - $originalRecoveryCodes = $user->two_factor_recovery_codes; - - $this->actingAs($user) - ->post('/user/two-factor-recovery-codes'); - - $user->refresh(); - - $this->assertNotEquals($originalRecoveryCodes, $user->two_factor_recovery_codes); - } - - public function test_user_with_two_factor_enabled_is_redirected_to_challenge_on_login() - { - $user = User::factory()->create([ - 'two_factor_secret' => encrypt('test-secret'), - 'two_factor_confirmed_at' => now(), - 'two_factor_recovery_codes' => encrypt(json_encode(['recovery-code'])), - ]); - - $response = $this->post('/login', [ - 'email' => $user->email, - 'password' => 'password', - ]); - - $response->assertRedirect('/two-factor-challenge'); - $this->assertEquals($user->id, Session::get('login.id')); - } - - public function test_user_can_login_with_two_factor_code() - { - $user = User::factory()->create([ - 'two_factor_secret' => encrypt('test-secret'), - 'two_factor_confirmed_at' => now(), - 'two_factor_recovery_codes' => encrypt(json_encode(['recovery-code'])), - ]); - - // Set up session as if user just entered credentials - Session::put([ - 'login.id' => $user->id, - 'login.remember' => false, - ]); - - // Generate valid 2FA code - $secret = decrypt($user->two_factor_secret); - $google2fa = app('pragmarx.google2fa'); - $validCode = $google2fa->getCurrentOtp($secret); - - $response = $this->post('/two-factor-challenge', [ - 'code' => $validCode, - ]); - - $response->assertRedirect('/dashboard'); - $this->assertAuthenticatedAs($user); - } - - public function test_user_can_login_with_recovery_code() - { - $recoveryCode = 'recovery-code-123'; - $user = User::factory()->create([ - 'two_factor_secret' => encrypt('test-secret'), - 'two_factor_confirmed_at' => now(), - 'two_factor_recovery_codes' => encrypt(json_encode([$recoveryCode, 'another-code'])), - ]); - - // Set up session as if user just entered credentials - Session::put([ - 'login.id' => $user->id, - 'login.remember' => false, - ]); - - $response = $this->post('/two-factor-challenge', [ - 'recovery_code' => $recoveryCode, - ]); - - $response->assertRedirect('/dashboard'); - $this->assertAuthenticatedAs($user); - - // Verify recovery code was consumed - $user->refresh(); - $recoveryCodes = json_decode(decrypt($user->two_factor_recovery_codes), true); - $this->assertNotContains($recoveryCode, $recoveryCodes); - } - - public function test_invalid_two_factor_code_fails_authentication() - { - $user = User::factory()->create([ - 'two_factor_secret' => encrypt('test-secret'), - 'two_factor_confirmed_at' => now(), - ]); - - Session::put([ - 'login.id' => $user->id, - 'login.remember' => false, - ]); - - $response = $this->post('/two-factor-challenge', [ - 'code' => '000000', - ]); - - $response->assertSessionHasErrors(['code']); - $this->assertGuest(); - } - - public function test_user_without_two_factor_enabled_can_login_normally() - { - $user = User::factory()->create(); - - $response = $this->post('/login', [ - 'email' => $user->email, - 'password' => 'password', - ]); - - $response->assertRedirect('/dashboard'); - $this->assertAuthenticatedAs($user); - } -} From e72ce330cda7749bae0e75843e74793db2c1dfd4 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Fri, 5 Sep 2025 13:07:01 +0530 Subject: [PATCH 05/43] Add Tests --- tests/Feature/Auth/TwoFactorChallengeTest.php | 82 ++++ .../Settings/TwoFactorSettingsTest.php | 450 ++++++++++++++++++ 2 files changed, 532 insertions(+) create mode 100644 tests/Feature/Auth/TwoFactorChallengeTest.php create mode 100644 tests/Feature/Settings/TwoFactorSettingsTest.php diff --git a/tests/Feature/Auth/TwoFactorChallengeTest.php b/tests/Feature/Auth/TwoFactorChallengeTest.php new file mode 100644 index 00000000..ab4a2d9f --- /dev/null +++ b/tests/Feature/Auth/TwoFactorChallengeTest.php @@ -0,0 +1,82 @@ +markTestSkipped('Two-factor authentication is not enabled.'); + } + + $response = $this->get(route('two-factor.login')); + + $response->assertRedirect(route('login')); + } + + public function test_two_factor_challenge_renders_correct_livewire_component(): void + { + if (!Features::canManageTwoFactorAuthentication()) { + $this->markTestSkipped('Two-factor authentication is not enabled.'); + } + + Features::twoFactorAuthentication([ + 'confirm' => true, + 'confirmPassword' => true, + ]); + + $user = User::factory()->create(); + + $user->forceFill([ + 'two_factor_secret' => encrypt('test-secret'), + 'two_factor_recovery_codes' => encrypt(json_encode(['code1', 'code2'])), + 'two_factor_confirmed_at' => now(), + ])->save(); + + Volt::test('auth.login') + ->set('email', $user->email) + ->set('password', 'password') + ->call('login') + ->assertRedirect(route('two-factor.login')) + ->assertOk(); + } + + public function test_two_factor_authentication_is_rate_limited(): void + { + if (!Features::enabled(Features::twoFactorAuthentication())) { + $this->markTestSkipped('Two-factor authentication is not enabled.'); + } + + Features::twoFactorAuthentication([ + 'confirm' => true, + 'confirmPassword' => true, + ]); + + $user = User::factory()->create(); + + $user->forceFill([ + 'two_factor_secret' => encrypt(implode(range('A', 'P'))), + 'two_factor_recovery_codes' => encrypt(json_encode(['recovery-code-1', 'recovery-code-2'])), + 'two_factor_confirmed_at' => now(), + ])->save(); + + collect(range(1, 5))->each(function () use ($user) { + $this->post(route('two-factor.login.store'), ['code' => '21212']) + ->assertRedirect(route('two-factor.login')) + ->assertSessionHasErrors('code'); + }); + + $this->post(route('two-factor.login.store'), ['code' => '000000']) + ->assertTooManyRequests(); + } +} diff --git a/tests/Feature/Settings/TwoFactorSettingsTest.php b/tests/Feature/Settings/TwoFactorSettingsTest.php new file mode 100644 index 00000000..38c12ec8 --- /dev/null +++ b/tests/Feature/Settings/TwoFactorSettingsTest.php @@ -0,0 +1,450 @@ +markTestSkipped('Two-factor authentication is not enabled.'); + } + + // Enable two-factor authentication with confirmation + Features::twoFactorAuthentication([ + 'confirm' => true, + 'confirmPassword' => true, + ]); + } + + public function test_two_factor_settings_page_can_be_rendered(): void + { + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('settings.two-factor'); + + $component->assertOk() + ->assertSee('Two Factor Authentication') + ->assertSee('Disabled'); + } + + public function test_two_factor_settings_page_returns_forbidden_when_feature_disabled(): void + { + $this->markTestSkipped('Feature disabling test requires app restart to take effect properly.'); + } + + public function test_user_can_enable_two_factor_authentication(): void + { + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('settings.two-factor') + ->call('enable'); + + $component->assertSet('twoFactorEnabled', false); // Still false until confirmed + + // Check that QR code and manual setup key are populated + $this->assertNotEmpty($component->get('qrCodeSvg')); + $this->assertNotEmpty($component->get('manualSetupKey')); + + $component->assertDispatched('show-two-factor-modal'); + + // Verify two-factor data is stored in database + $user->refresh(); + $this->assertNotNull($user->two_factor_secret); + $this->assertNotNull($user->two_factor_recovery_codes); + $this->assertNull($user->two_factor_confirmed_at); // Not confirmed yet + } + + public function test_user_can_enable_two_factor_without_confirmation_when_disabled(): void + { + $this->markTestSkipped('Configuration changes need app restart to take effect in component.'); + } + + public function test_user_can_proceed_to_verification_step(): void + { + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('settings.two-factor') + ->call('enable') + ->call('proceedToVerification'); + + $component->assertSet('showVerificationStep', true) + ->assertSet('authCode', ''); + } + + public function test_user_can_go_back_to_setup_from_verification(): void + { + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('settings.two-factor') + ->call('enable') + ->call('proceedToVerification') + ->set('authCode', '123456') + ->call('backToSetup'); + + $component->assertSet('showVerificationStep', false) + ->assertSet('authCode', ''); + } + + public function test_user_can_confirm_two_factor_with_valid_code(): void + { + $user = User::factory()->create(); + + // First enable 2FA to generate secret + $this->actingAs($user); + + $component = Volt::test('settings.two-factor') + ->call('enable'); + + // Get the secret to generate a valid TOTP code + $user->refresh(); + $secret = decrypt($user->two_factor_secret); + + // Generate a valid TOTP code using the secret + $google2fa = app(\PragmaRX\Google2FA\Google2FA::class); + $validCode = $google2fa->getCurrentOtp($secret); + + $component->call('proceedToVerification') + ->set('authCode', $validCode) + ->call('confirmTwoFactor'); + + $component->assertSet('twoFactorEnabled', true) + ->assertSet('showVerificationStep', false) + ->assertDispatched('two-factor-enabled') + ->assertDispatched('hide-two-factor-modal'); + + // Verify user is now fully enabled with 2FA + $user->refresh(); + $this->assertNotNull($user->two_factor_confirmed_at); + } + + public function test_user_cannot_confirm_two_factor_with_invalid_code(): void + { + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('settings.two-factor') + ->call('enable') + ->call('proceedToVerification') + ->set('authCode', '000000') + ->call('confirmTwoFactor'); + + // Should have validation or authentication error + $this->assertTrue($component->errors()->has('authCode') || $component->errors()->isNotEmpty()); + + // Verify user is not confirmed + $user->refresh(); + $this->assertNull($user->two_factor_confirmed_at); + } + + public function test_auth_code_validation_rules(): void + { + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('settings.two-factor') + ->call('enable') + ->call('proceedToVerification'); + + // Test required validation + $component->set('authCode', '') + ->call('confirmTwoFactor') + ->assertHasErrors(['authCode' => 'required']); + + // Test minimum length validation + $component->set('authCode', '123') + ->call('confirmTwoFactor') + ->assertHasErrors(['authCode' => 'min']); + + // Test maximum length validation + $component->set('authCode', '1234567') + ->call('confirmTwoFactor') + ->assertHasErrors(['authCode' => 'max']); + } + + public function test_user_can_view_recovery_codes_when_two_factor_enabled(): void + { + $user = $this->createUserWithTwoFactorEnabled(); + + $this->actingAs($user); + + $component = Volt::test('settings.two-factor') + ->call('fetchRecoveryCodes'); + + $recoveryCodes = $component->get('recoveryCodes'); + $this->assertNotEmpty($recoveryCodes); + $this->assertCount(8, $recoveryCodes); // Default recovery codes count + } + + public function test_user_can_regenerate_recovery_codes(): void + { + $user = $this->createUserWithTwoFactorEnabled(); + + $this->actingAs($user); + + $component = Volt::test('settings.two-factor') + ->call('fetchRecoveryCodes'); + + $originalCodes = $component->get('recoveryCodes'); + + $component->call('regenerateRecoveryCodes') + ->call('fetchRecoveryCodes'); + + $newCodes = $component->get('recoveryCodes'); + + $this->assertNotEquals($originalCodes, $newCodes); + $this->assertCount(8, $newCodes); + } + + public function test_user_can_toggle_recovery_codes_visibility(): void + { + $user = $this->createUserWithTwoFactorEnabled(); + + $this->actingAs($user); + + $component = Volt::test('settings.two-factor'); + + $component->assertSet('showRecoveryCodes', false); + + $component->call('toggleRecoveryCodes'); + $component->assertSet('showRecoveryCodes', true); + $this->assertNotEmpty($component->get('recoveryCodes')); + + $component->call('toggleRecoveryCodes'); + $component->assertSet('showRecoveryCodes', false); + } + + public function test_user_can_disable_two_factor_authentication(): void + { + $user = $this->createUserWithTwoFactorEnabled(); + + $this->actingAs($user); + + $component = Volt::test('settings.two-factor') + ->call('disable'); + + $component->assertSet('twoFactorEnabled', false) + ->assertSet('qrCodeSvg', '') + ->assertSet('manualSetupKey', '') + ->assertSet('recoveryCodes', []); + + // Verify database cleanup + $user->refresh(); + $this->assertNull($user->two_factor_secret); + $this->assertNull($user->two_factor_recovery_codes); + $this->assertNull($user->two_factor_confirmed_at); + } + + public function test_modal_config_property_returns_correct_data_for_disabled_state(): void + { + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('settings.two-factor'); + + $modalConfig = $component->get('modalConfig'); + + $this->assertEquals('Enable Two-Factor Authentication', $modalConfig['title']); + $this->assertStringContainsString('To finish enabling', $modalConfig['description']); + $this->assertEquals('Continue', $modalConfig['buttonText']); + } + + public function test_modal_config_property_returns_correct_data_for_enabled_state(): void + { + $user = $this->createUserWithTwoFactorEnabled(); + + $this->actingAs($user); + + $component = Volt::test('settings.two-factor'); + + $modalConfig = $component->get('modalConfig'); + + $this->assertEquals('Two-Factor Authentication Enabled', $modalConfig['title']); + $this->assertStringContainsString('Two-factor authentication is now enabled', $modalConfig['description']); + $this->assertEquals('Close', $modalConfig['buttonText']); + } + + public function test_modal_config_property_returns_correct_data_for_verification_step(): void + { + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('settings.two-factor') + ->call('enable') + ->call('proceedToVerification'); + + $modalConfig = $component->get('modalConfig'); + + $this->assertEquals('Verify Authentication Code', $modalConfig['title']); + $this->assertStringContainsString('Enter the 6-digit code', $modalConfig['description']); + $this->assertEquals('Continue', $modalConfig['buttonText']); + } + + public function test_user_can_close_modal(): void + { + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('settings.two-factor') + ->call('enable') + ->call('proceedToVerification') + ->set('authCode', '123456') + ->call('closeModal'); + + $component->assertSet('showVerificationStep', false) + ->assertSet('authCode', '') + ->assertDispatched('hide-two-factor-modal'); + } + + public function test_close_modal_clears_setup_data_when_two_factor_enabled(): void + { + $user = $this->createUserWithTwoFactorEnabled(); + + $this->actingAs($user); + + $component = Volt::test('settings.two-factor') + ->call('enable') // This populates setup data + ->call('closeModal'); + + $component->assertSet('qrCodeSvg', '') + ->assertSet('manualSetupKey', '') + ->assertSet('recoveryCodes', []); + } + + public function test_setup_data_is_cleared_properly(): void + { + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('settings.two-factor') + ->call('enable'); + + // Verify setup data is populated + $this->assertNotEmpty($component->get('qrCodeSvg')); + $this->assertNotEmpty($component->get('manualSetupKey')); + + $component->call('clearSetupData'); + + $component->assertSet('qrCodeSvg', '') + ->assertSet('manualSetupKey', '') + ->assertSet('recoveryCodes', []); + } + + public function test_component_properly_mounts_with_two_factor_disabled(): void + { + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('settings.two-factor'); + + $component->assertSet('twoFactorEnabled', false) + ->assertSet('requiresConfirmation', true) + ->assertSet('showVerificationStep', false) + ->assertSet('showRecoveryCodes', false); + } + + public function test_component_properly_mounts_with_two_factor_enabled(): void + { + $user = $this->createUserWithTwoFactorEnabled(); + + $this->actingAs($user); + + $component = Volt::test('settings.two-factor'); + + $component->assertSet('twoFactorEnabled', true) + ->assertSet('requiresConfirmation', true); + $this->assertNotEmpty($component->get('recoveryCodes')); + } + + public function test_component_cleans_up_unconfirmed_two_factor_on_mount(): void + { + $user = User::factory()->create(); + + // Simulate user who started 2FA setup but never confirmed + $user->forceFill([ + 'two_factor_secret' => encrypt('test-secret'), + 'two_factor_recovery_codes' => encrypt(json_encode(['code1', 'code2'])), + 'two_factor_confirmed_at' => null, + ])->save(); + + // Mount component (should trigger cleanup) + $this->actingAs($user); + + $component = Volt::test('settings.two-factor'); + + $component->assertSet('twoFactorEnabled', false); + + // Verify cleanup happened + $user->refresh(); + $this->assertNull($user->two_factor_secret); + $this->assertNull($user->two_factor_recovery_codes); + $this->assertNull($user->two_factor_confirmed_at); + } + + public function test_fetch_setup_data_populates_qr_code_and_manual_key(): void + { + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('settings.two-factor') + ->call('enable') + ->call('fetchSetupData'); + + $this->assertNotEmpty($component->get('qrCodeSvg')); + $this->assertNotEmpty($component->get('manualSetupKey')); + + // Verify QR code is actually SVG + $qrCode = $component->get('qrCodeSvg'); + $this->assertStringContainsString('assertStringContainsString('', $qrCode); + } + + protected function createUserWithTwoFactorEnabled(): User + { + $user = User::factory()->create(); + + // Enable two-factor authentication for the user + $user->forceFill([ + 'two_factor_secret' => encrypt('test-secret-key-for-2fa-auth'), + 'two_factor_recovery_codes' => encrypt(json_encode([ + 'recovery-code-1', + 'recovery-code-2', + 'recovery-code-3', + 'recovery-code-4', + 'recovery-code-5', + 'recovery-code-6', + 'recovery-code-7', + 'recovery-code-8' + ])), + 'two_factor_confirmed_at' => now(), + ])->save(); + + return $user; + } +} \ No newline at end of file From 6172b7471d2ff6952d7f99633be7c11b86f56855 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Fri, 5 Sep 2025 13:08:36 +0530 Subject: [PATCH 06/43] Test Fix --- app/Providers/FortifyServiceProvider.php | 1 - routes/web.php | 2 +- tests/Feature/Auth/AuthenticationTest.php | 44 ++ tests/Feature/Auth/TwoFactorChallengeTest.php | 9 +- .../Settings/TwoFactorAuthenticationTest.php | 135 ++++++ .../Settings/TwoFactorSettingsTest.php | 450 ------------------ 6 files changed, 184 insertions(+), 457 deletions(-) create mode 100644 tests/Feature/Settings/TwoFactorAuthenticationTest.php delete mode 100644 tests/Feature/Settings/TwoFactorSettingsTest.php diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 990a210c..a59c6945 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -7,7 +7,6 @@ use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Laravel\Fortify\Fortify; -use Livewire\Volt\Volt; class FortifyServiceProvider extends ServiceProvider { diff --git a/routes/web.php b/routes/web.php index 4f7c29bb..ce5b0f2d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -28,4 +28,4 @@ ->name('settings.two-factor'); }); -require __DIR__ . '/auth.php'; +require __DIR__.'/auth.php'; diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index 80aa4a87..e3fdd196 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -4,6 +4,7 @@ use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Laravel\Fortify\Features; use Livewire\Volt\Volt as LivewireVolt; use Tests\TestCase; @@ -58,4 +59,47 @@ public function test_users_can_logout(): void $this->assertGuest(); } + + public function test_users_with_two_factor_enabled_are_redirected_to_two_factor_challenge(): void + { + if (! Features::canManageTwoFactorAuthentication()) { + $this->markTestSkipped('Two-factor authentication is not enabled.'); + } + + Features::twoFactorAuthentication([ + 'confirm' => true, + 'confirmPassword' => true, + ]); + + $user = User::factory()->create(); + + $user->forceFill([ + 'two_factor_secret' => encrypt('test-secret'), + 'two_factor_recovery_codes' => encrypt(json_encode(['code1', 'code2'])), + 'two_factor_confirmed_at' => now(), + ])->save(); + + $response = LivewireVolt::test('auth.login') + ->set('email', $user->email) + ->set('password', 'password') + ->call('login'); + + $response->assertRedirect(route('two-factor.login')); + $response->assertSessionHas('login.id', $user->id); + $this->assertGuest(); + } + + public function test_users_without_two_factor_enabled_login_normally(): void + { + $user = User::factory()->create(); + + $response = LivewireVolt::test('auth.login') + ->set('email', $user->email) + ->set('password', 'password') + ->call('login'); + + $this->assertAuthenticated(); + $response->assertRedirect(route('dashboard', absolute: false)); + $response->assertSessionMissing('login.id'); + } } diff --git a/tests/Feature/Auth/TwoFactorChallengeTest.php b/tests/Feature/Auth/TwoFactorChallengeTest.php index ab4a2d9f..3d6906be 100644 --- a/tests/Feature/Auth/TwoFactorChallengeTest.php +++ b/tests/Feature/Auth/TwoFactorChallengeTest.php @@ -4,7 +4,6 @@ use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\RateLimiter; use Laravel\Fortify\Features; use Livewire\Volt\Volt; use Tests\TestCase; @@ -15,7 +14,7 @@ class TwoFactorChallengeTest extends TestCase public function test_two_factor_challenge_redirects_when_not_authenticated(): void { - if (!Features::canManageTwoFactorAuthentication()) { + if (! Features::canManageTwoFactorAuthentication()) { $this->markTestSkipped('Two-factor authentication is not enabled.'); } @@ -26,7 +25,7 @@ public function test_two_factor_challenge_redirects_when_not_authenticated(): vo public function test_two_factor_challenge_renders_correct_livewire_component(): void { - if (!Features::canManageTwoFactorAuthentication()) { + if (! Features::canManageTwoFactorAuthentication()) { $this->markTestSkipped('Two-factor authentication is not enabled.'); } @@ -53,7 +52,7 @@ public function test_two_factor_challenge_renders_correct_livewire_component(): public function test_two_factor_authentication_is_rate_limited(): void { - if (!Features::enabled(Features::twoFactorAuthentication())) { + if (! Features::enabled(Features::twoFactorAuthentication())) { $this->markTestSkipped('Two-factor authentication is not enabled.'); } @@ -70,7 +69,7 @@ public function test_two_factor_authentication_is_rate_limited(): void 'two_factor_confirmed_at' => now(), ])->save(); - collect(range(1, 5))->each(function () use ($user) { + collect(range(1, 5))->each(function () { $this->post(route('two-factor.login.store'), ['code' => '21212']) ->assertRedirect(route('two-factor.login')) ->assertSessionHasErrors('code'); diff --git a/tests/Feature/Settings/TwoFactorAuthenticationTest.php b/tests/Feature/Settings/TwoFactorAuthenticationTest.php new file mode 100644 index 00000000..86149d81 --- /dev/null +++ b/tests/Feature/Settings/TwoFactorAuthenticationTest.php @@ -0,0 +1,135 @@ +markTestSkipped('Two-factor authentication is not enabled.'); + } + + Features::twoFactorAuthentication([ + 'confirm' => true, + 'confirmPassword' => true, + ]); + } + + public function test_two_factor_settings_page_is_displayed(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->withSession(['auth.password_confirmed_at' => time()]) + ->get(route('settings.two-factor')) + ->assertOk() + ->assertSee('Two Factor Authentication') + ->assertSee('Disabled'); + } + + public function test_two_factor_settings_page_requires_password_confirmation(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->get(route('settings.two-factor')); + + $response->assertRedirect(route('password.confirm')); + } + + public function test_two_factor_settings_page_returns_forbidden_when_two_factor_is_disabled(): void + { + config(['fortify.features' => []]); + + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->withSession(['auth.password_confirmed_at' => time()]) + ->get(route('settings.two-factor')); + + $response->assertForbidden(); + } + + public function test_enable_two_factor_sets_up_confirmation_flow_when_confirmation_required(): void + { + Features::twoFactorAuthentication([ + 'confirm' => true, + 'confirmPassword' => false, + ]); + + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('settings.two-factor') + ->call('enable'); + + $component->assertSet('twoFactorEnabled', false); + $this->assertNotEmpty($component->get('qrCodeSvg')); + $this->assertNotEmpty($component->get('manualSetupKey')); + + $user->refresh(); + $this->assertNotNull($user->two_factor_secret); + $this->assertNotNull($user->two_factor_recovery_codes); + $this->assertNull($user->two_factor_confirmed_at); + } + + public function test_enable_two_factor_immediately_enables_when_confirmation_not_required(): void + { + Features::twoFactorAuthentication([ + 'confirm' => false, + 'confirmPassword' => false, + ]); + + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('settings.two-factor') + ->call('enable') + ->assertSet('twoFactorEnabled', true); + + $this->assertNotEmpty($component->get('qrCodeSvg')); + $this->assertNotEmpty($component->get('manualSetupKey')); + + $user->refresh(); + $this->assertNotNull($user->two_factor_secret); + $this->assertNotNull($user->two_factor_recovery_codes); + } + + public function test_two_factor_authentication_disabled_when_confirmation_abandoned_between_requests(): void + { + $user = User::factory()->create(); + + $user->forceFill([ + 'two_factor_secret' => encrypt('test-secret'), + 'two_factor_recovery_codes' => encrypt(json_encode(['code1', 'code2'])), + 'two_factor_confirmed_at' => null, + ])->save(); + + $this->actingAs($user); + + $component = Volt::test('settings.two-factor'); + + $component->assertSet('twoFactorEnabled', false); + + $this->assertDatabaseHas('users', [ + 'id' => $user->id, + 'two_factor_secret' => null, + 'two_factor_recovery_codes' => null, + ]); + } +} diff --git a/tests/Feature/Settings/TwoFactorSettingsTest.php b/tests/Feature/Settings/TwoFactorSettingsTest.php deleted file mode 100644 index 38c12ec8..00000000 --- a/tests/Feature/Settings/TwoFactorSettingsTest.php +++ /dev/null @@ -1,450 +0,0 @@ -markTestSkipped('Two-factor authentication is not enabled.'); - } - - // Enable two-factor authentication with confirmation - Features::twoFactorAuthentication([ - 'confirm' => true, - 'confirmPassword' => true, - ]); - } - - public function test_two_factor_settings_page_can_be_rendered(): void - { - $user = User::factory()->create(); - - $this->actingAs($user); - - $component = Volt::test('settings.two-factor'); - - $component->assertOk() - ->assertSee('Two Factor Authentication') - ->assertSee('Disabled'); - } - - public function test_two_factor_settings_page_returns_forbidden_when_feature_disabled(): void - { - $this->markTestSkipped('Feature disabling test requires app restart to take effect properly.'); - } - - public function test_user_can_enable_two_factor_authentication(): void - { - $user = User::factory()->create(); - - $this->actingAs($user); - - $component = Volt::test('settings.two-factor') - ->call('enable'); - - $component->assertSet('twoFactorEnabled', false); // Still false until confirmed - - // Check that QR code and manual setup key are populated - $this->assertNotEmpty($component->get('qrCodeSvg')); - $this->assertNotEmpty($component->get('manualSetupKey')); - - $component->assertDispatched('show-two-factor-modal'); - - // Verify two-factor data is stored in database - $user->refresh(); - $this->assertNotNull($user->two_factor_secret); - $this->assertNotNull($user->two_factor_recovery_codes); - $this->assertNull($user->two_factor_confirmed_at); // Not confirmed yet - } - - public function test_user_can_enable_two_factor_without_confirmation_when_disabled(): void - { - $this->markTestSkipped('Configuration changes need app restart to take effect in component.'); - } - - public function test_user_can_proceed_to_verification_step(): void - { - $user = User::factory()->create(); - - $this->actingAs($user); - - $component = Volt::test('settings.two-factor') - ->call('enable') - ->call('proceedToVerification'); - - $component->assertSet('showVerificationStep', true) - ->assertSet('authCode', ''); - } - - public function test_user_can_go_back_to_setup_from_verification(): void - { - $user = User::factory()->create(); - - $this->actingAs($user); - - $component = Volt::test('settings.two-factor') - ->call('enable') - ->call('proceedToVerification') - ->set('authCode', '123456') - ->call('backToSetup'); - - $component->assertSet('showVerificationStep', false) - ->assertSet('authCode', ''); - } - - public function test_user_can_confirm_two_factor_with_valid_code(): void - { - $user = User::factory()->create(); - - // First enable 2FA to generate secret - $this->actingAs($user); - - $component = Volt::test('settings.two-factor') - ->call('enable'); - - // Get the secret to generate a valid TOTP code - $user->refresh(); - $secret = decrypt($user->two_factor_secret); - - // Generate a valid TOTP code using the secret - $google2fa = app(\PragmaRX\Google2FA\Google2FA::class); - $validCode = $google2fa->getCurrentOtp($secret); - - $component->call('proceedToVerification') - ->set('authCode', $validCode) - ->call('confirmTwoFactor'); - - $component->assertSet('twoFactorEnabled', true) - ->assertSet('showVerificationStep', false) - ->assertDispatched('two-factor-enabled') - ->assertDispatched('hide-two-factor-modal'); - - // Verify user is now fully enabled with 2FA - $user->refresh(); - $this->assertNotNull($user->two_factor_confirmed_at); - } - - public function test_user_cannot_confirm_two_factor_with_invalid_code(): void - { - $user = User::factory()->create(); - - $this->actingAs($user); - - $component = Volt::test('settings.two-factor') - ->call('enable') - ->call('proceedToVerification') - ->set('authCode', '000000') - ->call('confirmTwoFactor'); - - // Should have validation or authentication error - $this->assertTrue($component->errors()->has('authCode') || $component->errors()->isNotEmpty()); - - // Verify user is not confirmed - $user->refresh(); - $this->assertNull($user->two_factor_confirmed_at); - } - - public function test_auth_code_validation_rules(): void - { - $user = User::factory()->create(); - - $this->actingAs($user); - - $component = Volt::test('settings.two-factor') - ->call('enable') - ->call('proceedToVerification'); - - // Test required validation - $component->set('authCode', '') - ->call('confirmTwoFactor') - ->assertHasErrors(['authCode' => 'required']); - - // Test minimum length validation - $component->set('authCode', '123') - ->call('confirmTwoFactor') - ->assertHasErrors(['authCode' => 'min']); - - // Test maximum length validation - $component->set('authCode', '1234567') - ->call('confirmTwoFactor') - ->assertHasErrors(['authCode' => 'max']); - } - - public function test_user_can_view_recovery_codes_when_two_factor_enabled(): void - { - $user = $this->createUserWithTwoFactorEnabled(); - - $this->actingAs($user); - - $component = Volt::test('settings.two-factor') - ->call('fetchRecoveryCodes'); - - $recoveryCodes = $component->get('recoveryCodes'); - $this->assertNotEmpty($recoveryCodes); - $this->assertCount(8, $recoveryCodes); // Default recovery codes count - } - - public function test_user_can_regenerate_recovery_codes(): void - { - $user = $this->createUserWithTwoFactorEnabled(); - - $this->actingAs($user); - - $component = Volt::test('settings.two-factor') - ->call('fetchRecoveryCodes'); - - $originalCodes = $component->get('recoveryCodes'); - - $component->call('regenerateRecoveryCodes') - ->call('fetchRecoveryCodes'); - - $newCodes = $component->get('recoveryCodes'); - - $this->assertNotEquals($originalCodes, $newCodes); - $this->assertCount(8, $newCodes); - } - - public function test_user_can_toggle_recovery_codes_visibility(): void - { - $user = $this->createUserWithTwoFactorEnabled(); - - $this->actingAs($user); - - $component = Volt::test('settings.two-factor'); - - $component->assertSet('showRecoveryCodes', false); - - $component->call('toggleRecoveryCodes'); - $component->assertSet('showRecoveryCodes', true); - $this->assertNotEmpty($component->get('recoveryCodes')); - - $component->call('toggleRecoveryCodes'); - $component->assertSet('showRecoveryCodes', false); - } - - public function test_user_can_disable_two_factor_authentication(): void - { - $user = $this->createUserWithTwoFactorEnabled(); - - $this->actingAs($user); - - $component = Volt::test('settings.two-factor') - ->call('disable'); - - $component->assertSet('twoFactorEnabled', false) - ->assertSet('qrCodeSvg', '') - ->assertSet('manualSetupKey', '') - ->assertSet('recoveryCodes', []); - - // Verify database cleanup - $user->refresh(); - $this->assertNull($user->two_factor_secret); - $this->assertNull($user->two_factor_recovery_codes); - $this->assertNull($user->two_factor_confirmed_at); - } - - public function test_modal_config_property_returns_correct_data_for_disabled_state(): void - { - $user = User::factory()->create(); - - $this->actingAs($user); - - $component = Volt::test('settings.two-factor'); - - $modalConfig = $component->get('modalConfig'); - - $this->assertEquals('Enable Two-Factor Authentication', $modalConfig['title']); - $this->assertStringContainsString('To finish enabling', $modalConfig['description']); - $this->assertEquals('Continue', $modalConfig['buttonText']); - } - - public function test_modal_config_property_returns_correct_data_for_enabled_state(): void - { - $user = $this->createUserWithTwoFactorEnabled(); - - $this->actingAs($user); - - $component = Volt::test('settings.two-factor'); - - $modalConfig = $component->get('modalConfig'); - - $this->assertEquals('Two-Factor Authentication Enabled', $modalConfig['title']); - $this->assertStringContainsString('Two-factor authentication is now enabled', $modalConfig['description']); - $this->assertEquals('Close', $modalConfig['buttonText']); - } - - public function test_modal_config_property_returns_correct_data_for_verification_step(): void - { - $user = User::factory()->create(); - - $this->actingAs($user); - - $component = Volt::test('settings.two-factor') - ->call('enable') - ->call('proceedToVerification'); - - $modalConfig = $component->get('modalConfig'); - - $this->assertEquals('Verify Authentication Code', $modalConfig['title']); - $this->assertStringContainsString('Enter the 6-digit code', $modalConfig['description']); - $this->assertEquals('Continue', $modalConfig['buttonText']); - } - - public function test_user_can_close_modal(): void - { - $user = User::factory()->create(); - - $this->actingAs($user); - - $component = Volt::test('settings.two-factor') - ->call('enable') - ->call('proceedToVerification') - ->set('authCode', '123456') - ->call('closeModal'); - - $component->assertSet('showVerificationStep', false) - ->assertSet('authCode', '') - ->assertDispatched('hide-two-factor-modal'); - } - - public function test_close_modal_clears_setup_data_when_two_factor_enabled(): void - { - $user = $this->createUserWithTwoFactorEnabled(); - - $this->actingAs($user); - - $component = Volt::test('settings.two-factor') - ->call('enable') // This populates setup data - ->call('closeModal'); - - $component->assertSet('qrCodeSvg', '') - ->assertSet('manualSetupKey', '') - ->assertSet('recoveryCodes', []); - } - - public function test_setup_data_is_cleared_properly(): void - { - $user = User::factory()->create(); - - $this->actingAs($user); - - $component = Volt::test('settings.two-factor') - ->call('enable'); - - // Verify setup data is populated - $this->assertNotEmpty($component->get('qrCodeSvg')); - $this->assertNotEmpty($component->get('manualSetupKey')); - - $component->call('clearSetupData'); - - $component->assertSet('qrCodeSvg', '') - ->assertSet('manualSetupKey', '') - ->assertSet('recoveryCodes', []); - } - - public function test_component_properly_mounts_with_two_factor_disabled(): void - { - $user = User::factory()->create(); - - $this->actingAs($user); - - $component = Volt::test('settings.two-factor'); - - $component->assertSet('twoFactorEnabled', false) - ->assertSet('requiresConfirmation', true) - ->assertSet('showVerificationStep', false) - ->assertSet('showRecoveryCodes', false); - } - - public function test_component_properly_mounts_with_two_factor_enabled(): void - { - $user = $this->createUserWithTwoFactorEnabled(); - - $this->actingAs($user); - - $component = Volt::test('settings.two-factor'); - - $component->assertSet('twoFactorEnabled', true) - ->assertSet('requiresConfirmation', true); - $this->assertNotEmpty($component->get('recoveryCodes')); - } - - public function test_component_cleans_up_unconfirmed_two_factor_on_mount(): void - { - $user = User::factory()->create(); - - // Simulate user who started 2FA setup but never confirmed - $user->forceFill([ - 'two_factor_secret' => encrypt('test-secret'), - 'two_factor_recovery_codes' => encrypt(json_encode(['code1', 'code2'])), - 'two_factor_confirmed_at' => null, - ])->save(); - - // Mount component (should trigger cleanup) - $this->actingAs($user); - - $component = Volt::test('settings.two-factor'); - - $component->assertSet('twoFactorEnabled', false); - - // Verify cleanup happened - $user->refresh(); - $this->assertNull($user->two_factor_secret); - $this->assertNull($user->two_factor_recovery_codes); - $this->assertNull($user->two_factor_confirmed_at); - } - - public function test_fetch_setup_data_populates_qr_code_and_manual_key(): void - { - $user = User::factory()->create(); - - $this->actingAs($user); - - $component = Volt::test('settings.two-factor') - ->call('enable') - ->call('fetchSetupData'); - - $this->assertNotEmpty($component->get('qrCodeSvg')); - $this->assertNotEmpty($component->get('manualSetupKey')); - - // Verify QR code is actually SVG - $qrCode = $component->get('qrCodeSvg'); - $this->assertStringContainsString('assertStringContainsString('', $qrCode); - } - - protected function createUserWithTwoFactorEnabled(): User - { - $user = User::factory()->create(); - - // Enable two-factor authentication for the user - $user->forceFill([ - 'two_factor_secret' => encrypt('test-secret-key-for-2fa-auth'), - 'two_factor_recovery_codes' => encrypt(json_encode([ - 'recovery-code-1', - 'recovery-code-2', - 'recovery-code-3', - 'recovery-code-4', - 'recovery-code-5', - 'recovery-code-6', - 'recovery-code-7', - 'recovery-code-8' - ])), - 'two_factor_confirmed_at' => now(), - ])->save(); - - return $user; - } -} \ No newline at end of file From f5c606379c8d6784b426a07ca9e63d7ac50d1082 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Fri, 5 Sep 2025 20:49:07 +0530 Subject: [PATCH 07/43] Refactor Component --- config/fortify.php | 2 +- .../components/settings/layout.blade.php | 4 +- resources/views/flux/icon/copy.blade.php | 42 -- resources/views/flux/icon/eye-off.blade.php | 44 -- resources/views/flux/icon/eye.blade.php | 42 -- .../flux/icon/lock-keyhole-open.blade.php | 43 -- .../views/flux/icon/lock-keyhole.blade.php | 43 -- .../views/flux/icon/refresh-cw.blade.php | 44 -- resources/views/flux/icon/scan-line.blade.php | 45 -- .../views/flux/icon/shield-ban.blade.php | 42 -- .../views/flux/icon/shield-check.blade.php | 42 -- resources/views/livewire/auth/login.blade.php | 2 +- .../livewire/settings/two-factor.blade.php | 528 +++++++----------- .../two-factor/recovery-codes.blade.php | 108 ++++ routes/web.php | 4 +- .../Settings/TwoFactorAuthenticationTest.php | 10 +- 16 files changed, 339 insertions(+), 706 deletions(-) delete mode 100644 resources/views/flux/icon/copy.blade.php delete mode 100644 resources/views/flux/icon/eye-off.blade.php delete mode 100644 resources/views/flux/icon/eye.blade.php delete mode 100644 resources/views/flux/icon/lock-keyhole-open.blade.php delete mode 100644 resources/views/flux/icon/lock-keyhole.blade.php delete mode 100644 resources/views/flux/icon/refresh-cw.blade.php delete mode 100644 resources/views/flux/icon/scan-line.blade.php delete mode 100644 resources/views/flux/icon/shield-ban.blade.php delete mode 100644 resources/views/flux/icon/shield-check.blade.php create mode 100644 resources/views/livewire/settings/two-factor/recovery-codes.blade.php diff --git a/config/fortify.php b/config/fortify.php index 4143bd35..4dac209e 100644 --- a/config/fortify.php +++ b/config/fortify.php @@ -150,7 +150,7 @@ // Features::updateProfileInformation(), // Features::updatePasswords(), Features::twoFactorAuthentication([ - 'confirm' => true, + 'confirm' => false, 'confirmPassword' => true, // 'window' => 0, ]), diff --git a/resources/views/components/settings/layout.blade.php b/resources/views/components/settings/layout.blade.php index f3119efb..220994c9 100644 --- a/resources/views/components/settings/layout.blade.php +++ b/resources/views/components/settings/layout.blade.php @@ -3,7 +3,9 @@ {{ __('Profile') }} {{ __('Password') }} - {{ __('Two-factor Authentication') }} + @if (Laravel\Fortify\Features::canManageTwoFactorAuthentication()) + {{ __('Two-factor Authentication') }} + @endif {{ __('Appearance') }}
diff --git a/resources/views/flux/icon/copy.blade.php b/resources/views/flux/icon/copy.blade.php deleted file mode 100644 index 077ee6fb..00000000 --- a/resources/views/flux/icon/copy.blade.php +++ /dev/null @@ -1,42 +0,0 @@ -{{-- Credit: Lucide (https://lucide.dev) --}} - -@props([ - 'variant' => 'outline', -]) - -@php -if ($variant === 'solid') { - throw new \Exception('The "solid" variant is not supported in Lucide.'); -} - -$classes = Flux::classes('shrink-0') - ->add(match($variant) { - 'outline' => '[:where(&)]:size-6', - 'solid' => '[:where(&)]:size-6', - 'mini' => '[:where(&)]:size-5', - 'micro' => '[:where(&)]:size-4', - }); - -$strokeWidth = match ($variant) { - 'outline' => 2, - 'mini' => 2.25, - 'micro' => 2.5, -}; -@endphp - -class($classes) }} - data-flux-icon - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="{{ $strokeWidth }}" - stroke-linecap="round" - stroke-linejoin="round" - aria-hidden="true" - data-slot="icon" -> - - - diff --git a/resources/views/flux/icon/eye-off.blade.php b/resources/views/flux/icon/eye-off.blade.php deleted file mode 100644 index 94abf4f2..00000000 --- a/resources/views/flux/icon/eye-off.blade.php +++ /dev/null @@ -1,44 +0,0 @@ -{{-- Credit: Lucide (https://lucide.dev) --}} - -@props([ - 'variant' => 'outline', -]) - -@php -if ($variant === 'solid') { - throw new \Exception('The "solid" variant is not supported in Lucide.'); -} - -$classes = Flux::classes('shrink-0') - ->add(match($variant) { - 'outline' => '[:where(&)]:size-6', - 'solid' => '[:where(&)]:size-6', - 'mini' => '[:where(&)]:size-5', - 'micro' => '[:where(&)]:size-4', - }); - -$strokeWidth = match ($variant) { - 'outline' => 2, - 'mini' => 2.25, - 'micro' => 2.5, -}; -@endphp - -class($classes) }} - data-flux-icon - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="{{ $strokeWidth }}" - stroke-linecap="round" - stroke-linejoin="round" - aria-hidden="true" - data-slot="icon" -> - - - - - diff --git a/resources/views/flux/icon/eye.blade.php b/resources/views/flux/icon/eye.blade.php deleted file mode 100644 index 90c28607..00000000 --- a/resources/views/flux/icon/eye.blade.php +++ /dev/null @@ -1,42 +0,0 @@ -{{-- Credit: Lucide (https://lucide.dev) --}} - -@props([ - 'variant' => 'outline', -]) - -@php -if ($variant === 'solid') { - throw new \Exception('The "solid" variant is not supported in Lucide.'); -} - -$classes = Flux::classes('shrink-0') - ->add(match($variant) { - 'outline' => '[:where(&)]:size-6', - 'solid' => '[:where(&)]:size-6', - 'mini' => '[:where(&)]:size-5', - 'micro' => '[:where(&)]:size-4', - }); - -$strokeWidth = match ($variant) { - 'outline' => 2, - 'mini' => 2.25, - 'micro' => 2.5, -}; -@endphp - -class($classes) }} - data-flux-icon - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="{{ $strokeWidth }}" - stroke-linecap="round" - stroke-linejoin="round" - aria-hidden="true" - data-slot="icon" -> - - - diff --git a/resources/views/flux/icon/lock-keyhole-open.blade.php b/resources/views/flux/icon/lock-keyhole-open.blade.php deleted file mode 100644 index bf1ba2bd..00000000 --- a/resources/views/flux/icon/lock-keyhole-open.blade.php +++ /dev/null @@ -1,43 +0,0 @@ -{{-- Credit: Lucide (https://lucide.dev) --}} - -@props([ - 'variant' => 'outline', -]) - -@php - if ($variant === 'solid') { - throw new \Exception('The "solid" variant is not supported in Lucide.'); - } - - $classes = Flux::classes('shrink-0')->add( - match ($variant) { - 'outline' => '[:where(&)]:size-6', - 'solid' => '[:where(&)]:size-6', - 'mini' => '[:where(&)]:size-5', - 'micro' => '[:where(&)]:size-4', - }, - ); - - $strokeWidth = match ($variant) { - 'outline' => 2, - 'mini' => 2.25, - 'micro' => 2.5, - }; -@endphp - -class($classes) }} - data-flux-icon - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="{{ $strokeWidth }}" - stroke-linecap="round" - stroke-linejoin="round" - aria-hidden="true" - data-slot="icon" -> - - - diff --git a/resources/views/flux/icon/lock-keyhole.blade.php b/resources/views/flux/icon/lock-keyhole.blade.php deleted file mode 100644 index ba89d7ff..00000000 --- a/resources/views/flux/icon/lock-keyhole.blade.php +++ /dev/null @@ -1,43 +0,0 @@ -{{-- Credit: Lucide (https://lucide.dev) --}} - -@props([ - 'variant' => 'outline', -]) - -@php -if ($variant === 'solid') { - throw new \Exception('The "solid" variant is not supported in Lucide.'); -} - -$classes = Flux::classes('shrink-0') - ->add(match($variant) { - 'outline' => '[:where(&)]:size-6', - 'solid' => '[:where(&)]:size-6', - 'mini' => '[:where(&)]:size-5', - 'micro' => '[:where(&)]:size-4', - }); - -$strokeWidth = match ($variant) { - 'outline' => 2, - 'mini' => 2.25, - 'micro' => 2.5, -}; -@endphp - -class($classes) }} - data-flux-icon - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="{{ $strokeWidth }}" - stroke-linecap="round" - stroke-linejoin="round" - aria-hidden="true" - data-slot="icon" -> - - - - diff --git a/resources/views/flux/icon/refresh-cw.blade.php b/resources/views/flux/icon/refresh-cw.blade.php deleted file mode 100644 index ae36ca2d..00000000 --- a/resources/views/flux/icon/refresh-cw.blade.php +++ /dev/null @@ -1,44 +0,0 @@ -{{-- Credit: Lucide (https://lucide.dev) --}} - -@props([ - 'variant' => 'outline', -]) - -@php -if ($variant === 'solid') { - throw new \Exception('The "solid" variant is not supported in Lucide.'); -} - -$classes = Flux::classes('shrink-0') - ->add(match($variant) { - 'outline' => '[:where(&)]:size-6', - 'solid' => '[:where(&)]:size-6', - 'mini' => '[:where(&)]:size-5', - 'micro' => '[:where(&)]:size-4', - }); - -$strokeWidth = match ($variant) { - 'outline' => 2, - 'mini' => 2.25, - 'micro' => 2.5, -}; -@endphp - -class($classes) }} - data-flux-icon - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="{{ $strokeWidth }}" - stroke-linecap="round" - stroke-linejoin="round" - aria-hidden="true" - data-slot="icon" -> - - - - - diff --git a/resources/views/flux/icon/scan-line.blade.php b/resources/views/flux/icon/scan-line.blade.php deleted file mode 100644 index 9677303d..00000000 --- a/resources/views/flux/icon/scan-line.blade.php +++ /dev/null @@ -1,45 +0,0 @@ -{{-- Credit: Lucide (https://lucide.dev) --}} - -@props([ - 'variant' => 'outline', -]) - -@php -if ($variant === 'solid') { - throw new \Exception('The "solid" variant is not supported in Lucide.'); -} - -$classes = Flux::classes('shrink-0') - ->add(match($variant) { - 'outline' => '[:where(&)]:size-6', - 'solid' => '[:where(&)]:size-6', - 'mini' => '[:where(&)]:size-5', - 'micro' => '[:where(&)]:size-4', - }); - -$strokeWidth = match ($variant) { - 'outline' => 2, - 'mini' => 2.25, - 'micro' => 2.5, -}; -@endphp - -class($classes) }} - data-flux-icon - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="{{ $strokeWidth }}" - stroke-linecap="round" - stroke-linejoin="round" - aria-hidden="true" - data-slot="icon" -> - - - - - - diff --git a/resources/views/flux/icon/shield-ban.blade.php b/resources/views/flux/icon/shield-ban.blade.php deleted file mode 100644 index abb8ea5d..00000000 --- a/resources/views/flux/icon/shield-ban.blade.php +++ /dev/null @@ -1,42 +0,0 @@ -{{-- Credit: Lucide (https://lucide.dev) --}} - -@props([ - 'variant' => 'outline', -]) - -@php -if ($variant === 'solid') { - throw new \Exception('The "solid" variant is not supported in Lucide.'); -} - -$classes = Flux::classes('shrink-0') - ->add(match($variant) { - 'outline' => '[:where(&)]:size-6', - 'solid' => '[:where(&)]:size-6', - 'mini' => '[:where(&)]:size-5', - 'micro' => '[:where(&)]:size-4', - }); - -$strokeWidth = match ($variant) { - 'outline' => 2, - 'mini' => 2.25, - 'micro' => 2.5, -}; -@endphp - -class($classes) }} - data-flux-icon - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="{{ $strokeWidth }}" - stroke-linecap="round" - stroke-linejoin="round" - aria-hidden="true" - data-slot="icon" -> - - - diff --git a/resources/views/flux/icon/shield-check.blade.php b/resources/views/flux/icon/shield-check.blade.php deleted file mode 100644 index a57b970a..00000000 --- a/resources/views/flux/icon/shield-check.blade.php +++ /dev/null @@ -1,42 +0,0 @@ -{{-- Credit: Lucide (https://lucide.dev) --}} - -@props([ - 'variant' => 'outline', -]) - -@php -if ($variant === 'solid') { - throw new \Exception('The "solid" variant is not supported in Lucide.'); -} - -$classes = Flux::classes('shrink-0') - ->add(match($variant) { - 'outline' => '[:where(&)]:size-6', - 'solid' => '[:where(&)]:size-6', - 'mini' => '[:where(&)]:size-5', - 'micro' => '[:where(&)]:size-4', - }); - -$strokeWidth = match ($variant) { - 'outline' => 2, - 'mini' => 2.25, - 'micro' => 2.5, -}; -@endphp - -class($classes) }} - data-flux-icon - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="{{ $strokeWidth }}" - stroke-linecap="round" - stroke-linejoin="round" - aria-hidden="true" - data-slot="icon" -> - - - diff --git a/resources/views/livewire/auth/login.blade.php b/resources/views/livewire/auth/login.blade.php index c4bdc730..38712714 100644 --- a/resources/views/livewire/auth/login.blade.php +++ b/resources/views/livewire/auth/login.blade.php @@ -33,7 +33,7 @@ public function login(): void $user = $this->validateCredentials(); - if (Features::enabled(Features::twoFactorAuthentication()) && $user->hasEnabledTwoFactorAuthentication()) { + if (Features::canManageTwoFactorAuthentication() && $user->hasEnabledTwoFactorAuthentication()) { Session::put([ 'login.id' => $user->getKey(), 'login.remember' => $this->remember, diff --git a/resources/views/livewire/settings/two-factor.blade.php b/resources/views/livewire/settings/two-factor.blade.php index 11004647..dfd293bd 100644 --- a/resources/views/livewire/settings/two-factor.blade.php +++ b/resources/views/livewire/settings/two-factor.blade.php @@ -1,30 +1,77 @@ user()->two_factor_confirmed_at)) { + $disableTwoFactorAuthentication(auth()->user()); + } + + $this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication(); + $this->requiresConfirmation = Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'); + } + + public function enable(EnableTwoFactorAuthentication $enableTwoFactorAuthentication): void + { + $user = auth()->user(); + $enableTwoFactorAuthentication($user); + if (!$this->requiresConfirmation) { + $this->twoFactorEnabled = true; + } + + $this->loadTwoFactorData(); + $this->showModal = true; + } + + public function disable(): void + { + app(DisableTwoFactorAuthentication::class)(auth()->user()); + $this->twoFactorEnabled = false; + } + + private function loadTwoFactorData(): void + { + $user = auth()->user(); + + $this->qrCodeSvg = $user->twoFactorQrCodeSvg(); + $this->manualSetupKey = decrypt($user->two_factor_secret); + } + public function getModalConfigProperty(): array { if ($this->twoFactorEnabled) { @@ -50,40 +97,7 @@ public function getModalConfigProperty(): array ]; } - public function mount(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): void - { - abort_unless(Features::enabled(Features::twoFactorAuthentication()), Response::HTTP_FORBIDDEN); - - if (Fortify::confirmsTwoFactorAuthentication() && is_null(auth()->user()->two_factor_confirmed_at)) { - $disableTwoFactorAuthentication(auth()->user()); - } - - $this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication(); - $this->requiresConfirmation = Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'); - - if ($this->twoFactorEnabled) { - $this->loadRecoveryCodes(); - } - } - - public function enable(EnableTwoFactorAuthentication $enableTwoFactorAuthentication): void - { - $enableTwoFactorAuthentication(auth()->user()); - $this->fetchSetupData(); - if (!$this->requiresConfirmation) { - $this->twoFactorEnabled = true; - } - $this->dispatch('show-two-factor-modal'); - } - - public function fetchSetupData(): void - { - $user = auth()->user(); - $this->qrCodeSvg = $user->twoFactorQrCodeSvg(); - $this->manualSetupKey = decrypt($user->two_factor_secret); - } - - public function proceedToVerification(): void + public function handleNextAction(): void { if ($this->requiresConfirmation) { $this->showVerificationStep = true; @@ -93,7 +107,7 @@ public function proceedToVerification(): void } } - public function backToSetup(): void + public function resetVerification(): void { $this->showVerificationStep = false; $this->authCode = ''; @@ -104,64 +118,24 @@ public function confirmTwoFactor(ConfirmTwoFactorAuthentication $confirmTwoFacto { $this->validate(); $confirmTwoFactorAuthentication(auth()->user(), $this->authCode); - $this->twoFactorEnabled = true; - $this->loadRecoveryCodes(); $this->closeModal(); - $this->dispatch('two-factor-enabled'); - } - - public function regenerateRecoveryCodes(GenerateNewRecoveryCodes $generateNewRecoveryCodes): void - { - $generateNewRecoveryCodes(Auth::user()); - $this->loadRecoveryCodes(); - } - - public function disable(): void - { - app(DisableTwoFactorAuthentication::class)(auth()->user()); - $this->twoFactorEnabled = false; - $this->clearSetupData(); + $this->twoFactorEnabled = true; } public function closeModal(): void { $this->showVerificationStep = false; $this->authCode = ''; - $this->resetErrorBag(); - - if ($this->twoFactorEnabled) { - $this->clearSetupData(); - } - - $this->dispatch('hide-two-factor-modal'); - } - - public function clearSetupData(): void - { $this->qrCodeSvg = ''; $this->manualSetupKey = ''; - $this->recoveryCodes = []; - } - - public function toggleRecoveryCodes(): void - { - if (!$this->recoveryCodes) { - $this->loadRecoveryCodes(); - } - $this->showRecoveryCodes = !$this->showRecoveryCodes; - } + $this->resetErrorBag(); + $this->showModal = false; - public function fetchRecoveryCodes(): void - { - if (!$this->recoveryCodes) { - $this->loadRecoveryCodes(); + if (!$this->requiresConfirmation) { + $this->twoFactorEnabled = true; } } - private function loadRecoveryCodes(): void - { - $this->recoveryCodes = json_decode(decrypt(auth()->user()->two_factor_recovery_codes), true); - } } ?>
@@ -170,118 +144,45 @@ private function loadRecoveryCodes(): void :subheading="__('Manage your two-factor authentication settings')">
@if(!$twoFactorEnabled) -
- {{ __('Disabled') }} +
+
+ {{ __('Disabled') }} +
+ {{ __('When you enable two-factor authentication, you will be prompted for a secure pin during login. This pin can be retrieved from a TOTP-supported application on your phone.') }} -
- - {{ __('Enable 2FA') }} - {{ __('Enabling...') }} - -
+ + {{ __('Enable 2FA') }} + {{ __('Enabling...') }} +
@else -
-
+
+
{{ __('Enabled') }}
+ {{ __('With two-factor authentication enabled, you will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.') }} -
-
-
- - - {{ __('2FA Recovery Codes') }} - -
- - {{ __('Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.') }} - -
-
-
- - - {{ __('Hide Recovery Codes') }} - - - {{ __('Regenerate Codes') }} - {{ __('Regenerating...') }} - -
-
-
-
- - @foreach($recoveryCodes as $index => $code) -
-
{{ $code }}
- @endforeach - -
- - {!! __('Each recovery code can be used once to access your account and will be removed after use. If you need more, click :regenerate above.', ['regenerate' => __('Regenerate Codes')]) !!} - - -
-
-
-
- -
+ @if($twoFactorEnabled) + + @endif +
{{ $code }}
@endif +
+ - -
-
+ +
+
+
+
-
-
- @for($i = 1; $i <= 5; $i++) -
- @endfor -
-
- @for($i = 1; $i <= 5; $i++) -
- @endfor -
- -
+ class="flex items-stretch absolute inset-0 w-full h-full divide-x [&>div]:flex-1 divide-stone-200 dark:divide-stone-300 justify-around opacity-50"> + @for($i = 1; $i <= 5; $i++) +
+ @endfor
-
- {{ $this->modalConfig['title'] }} - {{ $this->modalConfig['description'] }} +
+ @for($i = 1; $i <= 5; $i++) +
+ @endfor
+
+
+
+ {{ $this->modalConfig['title'] }} + {{ $this->modalConfig['description'] }} +
+
- @if(!$showVerificationStep) -
-
+ @if(!$showVerificationStep) +
+
+
+ @if(empty($qrCodeSvg))
- @if(empty($qrCodeSvg)) -
- -
- @else -
- {!! $qrCodeSvg !!} -
- @endif + class="bg-white dark:bg-stone-700 animate-pulse flex items-center justify-center absolute inset-0"> +
-
+ @else +
+ {!! $qrCodeSvg !!} +
+ @endif +
+
-
- - {{ $this->modalConfig['buttonText'] }} - -
+
+ + {{ $this->modalConfig['buttonText'] }} + +
-
-
-
- - {{ __('or, enter the code manually') }} - -
+
+
+
+ + {{ __('or, enter the code manually') }} + +
-
+ } catch (e) { + console.warn('Could not copy to clipboard'); + } + } + }"> +
+ @if(empty($manualSetupKey))
- @if(empty($manualSetupKey)) -
- -
- @else - - - @endif + class="w-full flex items-center justify-center bg-stone-100 dark:bg-stone-700 p-3"> +
-
+ @else + + + @endif
- @else -
-
- - @error('authCode') -

{{ $message }}

- @enderror -
+
+
+ @else +
+
+ + @error('authCode') +

{{ $message }}

+ @enderror +
-
- - {{ __('Back') }} - - - {{ __('Confirm') }} - {{ __('Confirming...') }} - -
-
- @endif +
+ + {{ __('Back') }} + + + {{ __('Confirm') }} + {{ __('Confirming...') }} + +
- + @endif
- +
diff --git a/resources/views/livewire/settings/two-factor/recovery-codes.blade.php b/resources/views/livewire/settings/two-factor/recovery-codes.blade.php new file mode 100644 index 00000000..c51db68d --- /dev/null +++ b/resources/views/livewire/settings/two-factor/recovery-codes.blade.php @@ -0,0 +1,108 @@ +loadRecoveryCodes(); + } + + public function regenerateRecoveryCodes(GenerateNewRecoveryCodes $generateNewRecoveryCodes): void + { + $generateNewRecoveryCodes(auth()->user()); + $this->loadRecoveryCodes(); + } + + private function loadRecoveryCodes(): void + { + $user = auth()->user(); + if ($user && $user->hasEnabledTwoFactorAuthentication() && $user->two_factor_recovery_codes) { + $this->recoveryCodes = json_decode(decrypt($user->two_factor_recovery_codes), true); + } + } +}; ?> + +
+ @if(!empty($recoveryCodes)) +
+
+ + {{ __('2FA Recovery Codes') }} +
+ + {{ __('Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.') }} + +
+
+
+ + + {{ __('Hide Recovery Codes') }} + + + {{ __('Regenerate Codes') }} + {{ __('Regenerating...') }} + +
+
+
+
+ @foreach($recoveryCodes as $code) +
+ {{ $code }} +
+ @endforeach +
+ + {!! __('Each recovery code can be used once to access your account and will be removed after use. If you need more, click :regenerate above.', ['regenerate' => __('Regenerate Codes')]) !!} + +
+
+
+ @endif +
+ diff --git a/routes/web.php b/routes/web.php index ce5b0f2d..00d4ac91 100644 --- a/routes/web.php +++ b/routes/web.php @@ -19,7 +19,7 @@ Volt::route('settings/password', 'settings.password')->name('settings.password'); Volt::route('settings/appearance', 'settings.appearance')->name('settings.appearance'); - $twoFactorMiddleware = Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword') + $twoFactorMiddleware = Features::canManageTwoFactorAuthentication() && Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword') ? ['password.confirm'] : []; @@ -28,4 +28,4 @@ ->name('settings.two-factor'); }); -require __DIR__.'/auth.php'; +require __DIR__ . '/auth.php'; diff --git a/tests/Feature/Settings/TwoFactorAuthenticationTest.php b/tests/Feature/Settings/TwoFactorAuthenticationTest.php index 86149d81..89442f6b 100644 --- a/tests/Feature/Settings/TwoFactorAuthenticationTest.php +++ b/tests/Feature/Settings/TwoFactorAuthenticationTest.php @@ -4,8 +4,6 @@ use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Routing\Route; -use Illuminate\Support\Facades\Artisan; use Laravel\Fortify\Features; use Livewire\Volt\Volt; use Tests\TestCase; @@ -78,6 +76,9 @@ public function test_enable_two_factor_sets_up_confirmation_flow_when_confirmati ->call('enable'); $component->assertSet('twoFactorEnabled', false); + $component->assertSet('showModal', true); + + // Test that the QR code and setup key are loaded in the same component $this->assertNotEmpty($component->get('qrCodeSvg')); $this->assertNotEmpty($component->get('manualSetupKey')); @@ -100,10 +101,13 @@ public function test_enable_two_factor_immediately_enables_when_confirmation_not $component = Volt::test('settings.two-factor') ->call('enable') - ->assertSet('twoFactorEnabled', true); + ->assertSet('twoFactorEnabled', true) + ->assertSet('showModal', true); + // Test that the QR code and setup key are loaded in the same component $this->assertNotEmpty($component->get('qrCodeSvg')); $this->assertNotEmpty($component->get('manualSetupKey')); + $this->assertFalse($component->get('requiresConfirmation')); $user->refresh(); $this->assertNotNull($user->two_factor_secret); From 3609fd8806317e97593b4ae2a044a0e583e8941f Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Mon, 8 Sep 2025 20:48:38 +0530 Subject: [PATCH 08/43] Refactor Code --- config/fortify.php | 2 +- .../livewire/settings/two-factor.blade.php | 44 +++++++++---------- .../two-factor/recovery-codes.blade.php | 14 +++--- .../Settings/TwoFactorAuthenticationTest.php | 4 +- 4 files changed, 30 insertions(+), 34 deletions(-) diff --git a/config/fortify.php b/config/fortify.php index 4dac209e..4143bd35 100644 --- a/config/fortify.php +++ b/config/fortify.php @@ -150,7 +150,7 @@ // Features::updateProfileInformation(), // Features::updatePasswords(), Features::twoFactorAuthentication([ - 'confirm' => false, + 'confirm' => true, 'confirmPassword' => true, // 'window' => 0, ]), diff --git a/resources/views/livewire/settings/two-factor.blade.php b/resources/views/livewire/settings/two-factor.blade.php index dfd293bd..874490c0 100644 --- a/resources/views/livewire/settings/two-factor.blade.php +++ b/resources/views/livewire/settings/two-factor.blade.php @@ -11,7 +11,6 @@ use Laravel\Fortify\Actions\DisableTwoFactorAuthentication; use Laravel\Fortify\Actions\EnableTwoFactorAuthentication; use Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication; -use Illuminate\Support\Facades\Auth; use Symfony\Component\HttpFoundation\Response; new class extends Component { @@ -21,14 +20,14 @@ #[Locked] public bool $requiresConfirmation; - public bool $showModal = false; - #[Locked] public string $qrCodeSvg = ''; #[Locked] public string $manualSetupKey = ''; + public bool $showModal = false; + public bool $showVerificationStep = false; #[Validate('required|string|min:6|max:6')] @@ -48,28 +47,26 @@ public function mount(DisableTwoFactorAuthentication $disableTwoFactorAuthentica public function enable(EnableTwoFactorAuthentication $enableTwoFactorAuthentication): void { - $user = auth()->user(); - $enableTwoFactorAuthentication($user); + $enableTwoFactorAuthentication(auth()->user()); if (!$this->requiresConfirmation) { $this->twoFactorEnabled = true; } - $this->loadTwoFactorData(); $this->showModal = true; } - public function disable(): void + public function disable(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): void { - app(DisableTwoFactorAuthentication::class)(auth()->user()); + $disableTwoFactorAuthentication(auth()->user()); $this->twoFactorEnabled = false; } - private function loadTwoFactorData(): void + public function confirmTwoFactor(ConfirmTwoFactorAuthentication $confirmTwoFactorAuthentication): void { - $user = auth()->user(); - - $this->qrCodeSvg = $user->twoFactorQrCodeSvg(); - $this->manualSetupKey = decrypt($user->two_factor_secret); + $this->validate(); + $confirmTwoFactorAuthentication(auth()->user(), $this->authCode); + $this->closeModal(); + $this->twoFactorEnabled = true; } public function getModalConfigProperty(): array @@ -114,14 +111,6 @@ public function resetVerification(): void $this->resetErrorBag(); } - public function confirmTwoFactor(ConfirmTwoFactorAuthentication $confirmTwoFactorAuthentication): void - { - $this->validate(); - $confirmTwoFactorAuthentication(auth()->user(), $this->authCode); - $this->closeModal(); - $this->twoFactorEnabled = true; - } - public function closeModal(): void { $this->showVerificationStep = false; @@ -136,6 +125,13 @@ public function closeModal(): void } } + private function loadTwoFactorData(): void + { + $user = auth()->user(); + + $this->qrCodeSvg = $user->twoFactorQrCodeSvg(); + $this->manualSetupKey = decrypt($user->two_factor_secret); + } } ?>
@@ -220,7 +216,7 @@ class="flex flex-col items-stretch absolute w-full h-full divide-y [&>div]:flex-
@endfor
- +
@@ -281,7 +277,7 @@ class="relative bg-white dark:bg-stone-800 px-2 text-sm text-stone-600 dark:text } }">
+ class="w-full rounded-xl flex items-stretch border dark:border-stone-700"> @if(empty($manualSetupKey))
@@ -334,7 +330,7 @@ class="flex-1" variant="primary" class="flex-1" wire:click="confirmTwoFactor" - wire:loading.attr="disabled" + wire:loading.delay.attr="disabled" wire:target="confirmTwoFactor" > {{ __('Hide Recovery Codes') }} - - {{ __('Regenerate Codes') }} + {{ __('Regenerate Codes') }} {{ __('Regenerating...') }} + wire:target="regenerateRecoveryCodes">{{ __('Regenerating Codes...') }} +
@foreach($recoveryCodes as $code) -
{{ $code }}
diff --git a/tests/Feature/Settings/TwoFactorAuthenticationTest.php b/tests/Feature/Settings/TwoFactorAuthenticationTest.php index 89442f6b..57a739a1 100644 --- a/tests/Feature/Settings/TwoFactorAuthenticationTest.php +++ b/tests/Feature/Settings/TwoFactorAuthenticationTest.php @@ -77,8 +77,7 @@ public function test_enable_two_factor_sets_up_confirmation_flow_when_confirmati $component->assertSet('twoFactorEnabled', false); $component->assertSet('showModal', true); - - // Test that the QR code and setup key are loaded in the same component + $this->assertNotEmpty($component->get('qrCodeSvg')); $this->assertNotEmpty($component->get('manualSetupKey')); @@ -104,7 +103,6 @@ public function test_enable_two_factor_immediately_enables_when_confirmation_not ->assertSet('twoFactorEnabled', true) ->assertSet('showModal', true); - // Test that the QR code and setup key are loaded in the same component $this->assertNotEmpty($component->get('qrCodeSvg')); $this->assertNotEmpty($component->get('manualSetupKey')); $this->assertFalse($component->get('requiresConfirmation')); From 2683ee4aa8f5252f49eba420e346e227881558ef Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 9 Sep 2025 02:19:37 +0530 Subject: [PATCH 09/43] formatting --- .../views/components/input-otp.blade.php | 194 +++++++----------- .../views/flux/icon/loader-circle.blade.php | 41 ---- .../livewire/auth/confirm-password.blade.php | 5 +- .../livewire/settings/two-factor.blade.php | 36 ++-- tests/Feature/Auth/AuthenticationTest.php | 14 -- .../Feature/Auth/PasswordConfirmationTest.php | 1 + tests/Feature/Auth/TwoFactorChallengeTest.php | 33 +-- .../Settings/TwoFactorAuthenticationTest.php | 57 +---- 8 files changed, 92 insertions(+), 289 deletions(-) delete mode 100644 resources/views/flux/icon/loader-circle.blade.php diff --git a/resources/views/components/input-otp.blade.php b/resources/views/components/input-otp.blade.php index 3c4107f1..239fe36e 100644 --- a/resources/views/components/input-otp.blade.php +++ b/resources/views/components/input-otp.blade.php @@ -5,130 +5,80 @@ ])
+ } + }, + generateCode() { + let code = ''; + for (let i = 1; i <= this.total_digits; i++) { + code += this.$refs['input' + i].value; + } + return code; + }, +}" x-init="setTimeout(() => { + $refs.input1.focus(); +}, 100);" @focus-auth-2fa-auth-code.window="$refs.input1.focus()" + @clear-auth-2fa-auth-code.window="for (let i = 1; i <= total_digits; i++) { $refs['input' + i].value = ''; } $refs.code.value = ''; $refs.input1.focus();" + class="relative">
@for ($x = 1; $x <= $digits; $x++) @@ -138,15 +88,15 @@ class="relative"> pattern="[0-9]" maxlength="1" autocomplete="off" - @paste="handlePaste" - @keydown="handleKeydown({{ $x }}, $event)" + @paste="pasteValue" + @keydown="moveCursorNext({{ $x }}, {{ $digits }}, $event)" @focus="$el.select()" - @input="sanitizeInput($el)" + @input="$el.value = $el.value.replace(/[^0-9]/g, '').slice(0, 1)" class="flex h-10 w-10 items-center justify-center border border-zinc-300 bg-accent-foreground text-center text-sm font-medium text-accent-content transition-colors placeholder:text-zinc-500 focus:border-accent focus:border-2 focus:outline-none focus:relative focus:z-10 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-700 dark:focus:border-accent @if($x == 1) rounded-l-md @endif @if($x == $digits) rounded-r-md @endif @if($x > 1) -ml-px @endif" /> @endfor
- except(['eventCallback', 'digits', 'name']) }} + except(['eventCallback', 'digits']) }} type="hidden" class="hidden" x-ref="code" diff --git a/resources/views/flux/icon/loader-circle.blade.php b/resources/views/flux/icon/loader-circle.blade.php deleted file mode 100644 index 24ff26e5..00000000 --- a/resources/views/flux/icon/loader-circle.blade.php +++ /dev/null @@ -1,41 +0,0 @@ -{{-- Credit: Lucide (htt}ps://lucide.dev) --}} - -@props([ - 'variant' => 'outline', -]) - -@php -if ($variant === 'solid') { - throw new \Exception('The "solid" variant is not supported in Lucide.'); -} - -$classes = Flux::classes('shrink-0') - ->add(match($variant) { - 'outline' => '[:where(&)]:size-6', - 'solid' => '[:where(&)]:size-6', - 'mini' => '[:where(&)]:size-5', - 'micro' => '[:where(&)]:size-4', - }); - -$strokeWidth = match ($variant) { - 'outline' => 2, - 'mini' => 2.25, - 'micro' => 2.5, -}; -@endphp - -class($classes) }} - data-flux-icon - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="{{ $strokeWidth }}" - stroke-linecap="round" - stroke-linejoin="round" - aria-hidden="true" - data-slot="icon" -> - - diff --git a/resources/views/livewire/auth/confirm-password.blade.php b/resources/views/livewire/auth/confirm-password.blade.php index e7a6e7aa..c0225811 100644 --- a/resources/views/livewire/auth/confirm-password.blade.php +++ b/resources/views/livewire/auth/confirm-password.blade.php @@ -3,7 +3,6 @@ use Illuminate\Validation\ValidationException; use Livewire\Attributes\Layout; use Livewire\Volt\Component; -use Laravel\Fortify\Actions\ConfirmPassword; new #[Layout('components.layouts.auth')] class extends Component { public string $password = ''; @@ -17,7 +16,7 @@ public function confirmPassword(): void 'password' => ['required', 'string'], ]); - if (!Auth::guard('web')->validate([ + if (! Auth::guard('web')->validate([ 'email' => Auth::user()->email, 'password' => $this->password, ])) { @@ -38,7 +37,7 @@ public function confirmPassword(): void /> - +
diff --git a/resources/views/livewire/settings/two-factor.blade.php b/resources/views/livewire/settings/two-factor.blade.php index 874490c0..b71aeec4 100644 --- a/resources/views/livewire/settings/two-factor.blade.php +++ b/resources/views/livewire/settings/two-factor.blade.php @@ -30,8 +30,8 @@ public bool $showVerificationStep = false; - #[Validate('required|string|min:6|max:6')] - public string $authCode = ''; + #[Validate('required|string|min:6|max:6', onUpdate: false)] + public string $code = ''; public function mount(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): void { @@ -64,7 +64,7 @@ public function disable(DisableTwoFactorAuthentication $disableTwoFactorAuthenti public function confirmTwoFactor(ConfirmTwoFactorAuthentication $confirmTwoFactorAuthentication): void { $this->validate(); - $confirmTwoFactorAuthentication(auth()->user(), $this->authCode); + $confirmTwoFactorAuthentication(auth()->user(), $this->code); $this->closeModal(); $this->twoFactorEnabled = true; } @@ -107,14 +107,14 @@ public function handleNextAction(): void public function resetVerification(): void { $this->showVerificationStep = false; - $this->authCode = ''; + $this->code = ''; $this->resetErrorBag(); } public function closeModal(): void { $this->showVerificationStep = false; - $this->authCode = ''; + $this->code = ''; $this->qrCodeSvg = ''; $this->manualSetupKey = ''; $this->resetErrorBag(); @@ -154,11 +154,8 @@ private function loadTwoFactorData(): void icon="shield-check" icon:variant="outline" wire:click="enable" - wire:loading.attr="disabled" - wire:target="enable" > - {{ __('Enable 2FA') }} - {{ __('Enabling...') }} + {{ __('Enable 2FA') }}
@else @@ -180,11 +177,7 @@ private function loadTwoFactorData(): void icon="shield-exclamation" icon:variant="outline" wire:click="disable" - wire:loading.attr="disabled" - wire:target="disable" - > - {{ __('Disable 2FA') }} - {{ __('Disabling...') }} + >{{ __('Disable 2FA') }}
@@ -194,7 +187,7 @@ private function loadTwoFactorData(): void @@ -309,11 +302,11 @@ class="text-green-500">
- @error('authCode') + @error('code')

{{ $message }}

@enderror
@@ -330,13 +323,8 @@ class="flex-1" variant="primary" class="flex-1" wire:click="confirmTwoFactor" - wire:loading.delay.attr="disabled" - wire:target="confirmTwoFactor" > - {{ __('Confirm') }} - {{ __('Confirming...') }} + {{ __('Confirm') }}
diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index e3fdd196..05450f45 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -88,18 +88,4 @@ public function test_users_with_two_factor_enabled_are_redirected_to_two_factor_ $response->assertSessionHas('login.id', $user->id); $this->assertGuest(); } - - public function test_users_without_two_factor_enabled_login_normally(): void - { - $user = User::factory()->create(); - - $response = LivewireVolt::test('auth.login') - ->set('email', $user->email) - ->set('password', 'password') - ->call('login'); - - $this->assertAuthenticated(); - $response->assertRedirect(route('dashboard', absolute: false)); - $response->assertSessionMissing('login.id'); - } } diff --git a/tests/Feature/Auth/PasswordConfirmationTest.php b/tests/Feature/Auth/PasswordConfirmationTest.php index de243d36..31a595bb 100644 --- a/tests/Feature/Auth/PasswordConfirmationTest.php +++ b/tests/Feature/Auth/PasswordConfirmationTest.php @@ -18,6 +18,7 @@ public function test_confirm_password_screen_can_be_rendered(): void $response = $this->actingAs($user)->get(route('password.confirm')); $response->assertStatus(200); + } public function test_password_can_be_confirmed(): void diff --git a/tests/Feature/Auth/TwoFactorChallengeTest.php b/tests/Feature/Auth/TwoFactorChallengeTest.php index 3d6906be..48e9f7ac 100644 --- a/tests/Feature/Auth/TwoFactorChallengeTest.php +++ b/tests/Feature/Auth/TwoFactorChallengeTest.php @@ -12,7 +12,7 @@ class TwoFactorChallengeTest extends TestCase { use RefreshDatabase; - public function test_two_factor_challenge_redirects_when_not_authenticated(): void + public function test_two_factor_challenge_redirects_to_login_when_not_authenticated(): void { if (! Features::canManageTwoFactorAuthentication()) { $this->markTestSkipped('Two-factor authentication is not enabled.'); @@ -23,7 +23,7 @@ public function test_two_factor_challenge_redirects_when_not_authenticated(): vo $response->assertRedirect(route('login')); } - public function test_two_factor_challenge_renders_correct_livewire_component(): void + public function test_two_factor_challenge_can_be_rendered(): void { if (! Features::canManageTwoFactorAuthentication()) { $this->markTestSkipped('Two-factor authentication is not enabled.'); @@ -49,33 +49,4 @@ public function test_two_factor_challenge_renders_correct_livewire_component(): ->assertRedirect(route('two-factor.login')) ->assertOk(); } - - public function test_two_factor_authentication_is_rate_limited(): void - { - if (! Features::enabled(Features::twoFactorAuthentication())) { - $this->markTestSkipped('Two-factor authentication is not enabled.'); - } - - Features::twoFactorAuthentication([ - 'confirm' => true, - 'confirmPassword' => true, - ]); - - $user = User::factory()->create(); - - $user->forceFill([ - 'two_factor_secret' => encrypt(implode(range('A', 'P'))), - 'two_factor_recovery_codes' => encrypt(json_encode(['recovery-code-1', 'recovery-code-2'])), - 'two_factor_confirmed_at' => now(), - ])->save(); - - collect(range(1, 5))->each(function () { - $this->post(route('two-factor.login.store'), ['code' => '21212']) - ->assertRedirect(route('two-factor.login')) - ->assertSessionHasErrors('code'); - }); - - $this->post(route('two-factor.login.store'), ['code' => '000000']) - ->assertTooManyRequests(); - } } diff --git a/tests/Feature/Settings/TwoFactorAuthenticationTest.php b/tests/Feature/Settings/TwoFactorAuthenticationTest.php index 57a739a1..746d9fa4 100644 --- a/tests/Feature/Settings/TwoFactorAuthenticationTest.php +++ b/tests/Feature/Settings/TwoFactorAuthenticationTest.php @@ -26,7 +26,7 @@ protected function setUp(): void ]); } - public function test_two_factor_settings_page_is_displayed(): void + public function test_two_factor_settings_page_can_be_rendered(): void { $user = User::factory()->create(); @@ -38,7 +38,7 @@ public function test_two_factor_settings_page_is_displayed(): void ->assertSee('Disabled'); } - public function test_two_factor_settings_page_requires_password_confirmation(): void + public function test_two_factor_settings_page_requires_password_confirmation_when_enabled(): void { $user = User::factory()->create(); @@ -48,7 +48,7 @@ public function test_two_factor_settings_page_requires_password_confirmation(): $response->assertRedirect(route('password.confirm')); } - public function test_two_factor_settings_page_returns_forbidden_when_two_factor_is_disabled(): void + public function test_two_factor_settings_page_returns_forbidden_response_when_two_factor_is_disabled(): void { config(['fortify.features' => []]); @@ -61,57 +61,6 @@ public function test_two_factor_settings_page_returns_forbidden_when_two_factor_ $response->assertForbidden(); } - public function test_enable_two_factor_sets_up_confirmation_flow_when_confirmation_required(): void - { - Features::twoFactorAuthentication([ - 'confirm' => true, - 'confirmPassword' => false, - ]); - - $user = User::factory()->create(); - - $this->actingAs($user); - - $component = Volt::test('settings.two-factor') - ->call('enable'); - - $component->assertSet('twoFactorEnabled', false); - $component->assertSet('showModal', true); - - $this->assertNotEmpty($component->get('qrCodeSvg')); - $this->assertNotEmpty($component->get('manualSetupKey')); - - $user->refresh(); - $this->assertNotNull($user->two_factor_secret); - $this->assertNotNull($user->two_factor_recovery_codes); - $this->assertNull($user->two_factor_confirmed_at); - } - - public function test_enable_two_factor_immediately_enables_when_confirmation_not_required(): void - { - Features::twoFactorAuthentication([ - 'confirm' => false, - 'confirmPassword' => false, - ]); - - $user = User::factory()->create(); - - $this->actingAs($user); - - $component = Volt::test('settings.two-factor') - ->call('enable') - ->assertSet('twoFactorEnabled', true) - ->assertSet('showModal', true); - - $this->assertNotEmpty($component->get('qrCodeSvg')); - $this->assertNotEmpty($component->get('manualSetupKey')); - $this->assertFalse($component->get('requiresConfirmation')); - - $user->refresh(); - $this->assertNotNull($user->two_factor_secret); - $this->assertNotNull($user->two_factor_recovery_codes); - } - public function test_two_factor_authentication_disabled_when_confirmation_abandoned_between_requests(): void { $user = User::factory()->create(); From 07c24147aa100cb51be971ff0f15bac9512690aa Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 9 Sep 2025 13:50:24 +0530 Subject: [PATCH 10/43] Refactoring --- .../views/components/input-otp.blade.php | 101 ++++++++++-------- .../auth/two-factor-challenge.blade.php | 28 +++-- .../livewire/settings/two-factor.blade.php | 7 +- routes/web.php | 2 +- 4 files changed, 80 insertions(+), 58 deletions(-) diff --git a/resources/views/components/input-otp.blade.php b/resources/views/components/input-otp.blade.php index 239fe36e..3cd19b1c 100644 --- a/resources/views/components/input-otp.blade.php +++ b/resources/views/components/input-otp.blade.php @@ -7,79 +7,92 @@
+ clearAllInputs() { + for (let i = 1; i <= this.total_digits; i++) { + this.getInputRef(i).value = ''; + } + this.$refs.code.value = ''; + this.$refs.input1.focus(); + } +}" +x-init="setTimeout(() => { $refs.input1.focus(); }, 100);" +@focus-auth-2fa-auth-code.window="$refs.input1.focus()" +@clear-auth-2fa-auth-code.window="clearAllInputs()" +class="relative">
@for ($x = 1; $x <= $digits; $x++) has('recovery_code') ? 'true' : 'false' }}, code: '', recovery_code: '' - }" class="relative w-full h-auto" > + }" class="relative w-full h-auto">
- +
- +
@@ -19,25 +21,29 @@
+ @input="code = $event.target.value"/>
@error('code') -

{{ $message }}

+ + {{ $message }} + @enderror
+ x-bind:required="showRecoveryInput" autocomplete="one-time-code" + x-model="recovery_code"/>
@error('recovery_code') -

{{ $message }}

+ + {{ $message }} + @enderror
+ x-bind:class="{ 'opacity-50 cursor-default pointer-events-none': showRecoveryInput ? recovery_code.length === 0 : code.length < 6 }"> {{ __('Continue') }}
@@ -46,9 +52,9 @@ {{ __('or you can') }}
{{ __('login using a recovery code') }} + @click="showRecoveryInput = true; code = ''; recovery_code = ''; $dispatch('clear-auth-2fa-auth-code'); $nextTick(() => $refs.recovery_code?.focus())">{{ __('login using a recovery code') }} {{ __('login using an authentication code') }} + @click="showRecoveryInput = false; code = ''; recovery_code = ''; $dispatch('clear-auth-2fa-auth-code'); $nextTick(() => $dispatch('focus-auth-2fa-auth-code'))">{{ __('login using an authentication code') }}
diff --git a/resources/views/livewire/settings/two-factor.blade.php b/resources/views/livewire/settings/two-factor.blade.php index b71aeec4..b6481500 100644 --- a/resources/views/livewire/settings/two-factor.blade.php +++ b/resources/views/livewire/settings/two-factor.blade.php @@ -187,7 +187,7 @@ private function loadTwoFactorData(): void @@ -307,7 +307,9 @@ class="text-green-500"> autocomplete="one-time-code" /> @error('code') -

{{ $message }}

+ + {{ $message }} + @enderror @@ -323,6 +325,7 @@ class="flex-1" variant="primary" class="flex-1" wire:click="confirmTwoFactor" + x-bind:disabled="$wire.code.length < 6" > {{ __('Confirm') }} diff --git a/routes/web.php b/routes/web.php index 00d4ac91..fcfcbed7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -25,7 +25,7 @@ Volt::route('settings/two-factor', 'settings.two-factor') ->middleware($twoFactorMiddleware) - ->name('settings.two-factor'); + ->name('two-factor.show'); }); require __DIR__ . '/auth.php'; From 41e079a472845baf4eeb927b8ef177042ba273b7 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 9 Sep 2025 13:57:57 +0530 Subject: [PATCH 11/43] Fix test --- resources/views/components/settings/layout.blade.php | 2 +- tests/Feature/Settings/TwoFactorAuthenticationTest.php | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/resources/views/components/settings/layout.blade.php b/resources/views/components/settings/layout.blade.php index 220994c9..a1ea8019 100644 --- a/resources/views/components/settings/layout.blade.php +++ b/resources/views/components/settings/layout.blade.php @@ -4,7 +4,7 @@ {{ __('Profile') }} {{ __('Password') }} @if (Laravel\Fortify\Features::canManageTwoFactorAuthentication()) - {{ __('Two-factor Authentication') }} + {{ __('Two-factor Authentication') }} @endif {{ __('Appearance') }} diff --git a/tests/Feature/Settings/TwoFactorAuthenticationTest.php b/tests/Feature/Settings/TwoFactorAuthenticationTest.php index 746d9fa4..d726331c 100644 --- a/tests/Feature/Settings/TwoFactorAuthenticationTest.php +++ b/tests/Feature/Settings/TwoFactorAuthenticationTest.php @@ -32,7 +32,7 @@ public function test_two_factor_settings_page_can_be_rendered(): void $this->actingAs($user) ->withSession(['auth.password_confirmed_at' => time()]) - ->get(route('settings.two-factor')) + ->get(route('two-factor.show')) ->assertOk() ->assertSee('Two Factor Authentication') ->assertSee('Disabled'); @@ -43,7 +43,7 @@ public function test_two_factor_settings_page_requires_password_confirmation_whe $user = User::factory()->create(); $response = $this->actingAs($user) - ->get(route('settings.two-factor')); + ->get(route('two-factor.show')); $response->assertRedirect(route('password.confirm')); } @@ -56,7 +56,7 @@ public function test_two_factor_settings_page_returns_forbidden_response_when_tw $response = $this->actingAs($user) ->withSession(['auth.password_confirmed_at' => time()]) - ->get(route('settings.two-factor')); + ->get(route('two-factor.show')); $response->assertForbidden(); } From da7d2987da2389ac99893a7a123323c13e19e31c Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 9 Sep 2025 14:50:30 +0530 Subject: [PATCH 12/43] Formatting --- resources/views/livewire/auth/confirm-password.blade.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/resources/views/livewire/auth/confirm-password.blade.php b/resources/views/livewire/auth/confirm-password.blade.php index c0225811..1895b3a6 100644 --- a/resources/views/livewire/auth/confirm-password.blade.php +++ b/resources/views/livewire/auth/confirm-password.blade.php @@ -16,7 +16,7 @@ public function confirmPassword(): void 'password' => ['required', 'string'], ]); - if (! Auth::guard('web')->validate([ + if (! Auth::guard('web')->validate([ 'email' => Auth::user()->email, 'password' => $this->password, ])) { @@ -30,6 +30,7 @@ public function confirmPassword(): void $this->redirectIntended(default: route('dashboard', absolute: false), navigate: true); } }; ?> +
Date: Tue, 9 Sep 2025 15:50:12 +0530 Subject: [PATCH 13/43] formatting --- .../views/components/input-otp.blade.php | 193 ++++++++++-------- .../livewire/auth/confirm-password.blade.php | 1 + resources/views/livewire/auth/login.blade.php | 2 +- .../livewire/settings/two-factor.blade.php | 8 +- .../two-factor/recovery-codes.blade.php | 15 +- 5 files changed, 123 insertions(+), 96 deletions(-) diff --git a/resources/views/components/input-otp.blade.php b/resources/views/components/input-otp.blade.php index 3cd19b1c..0f97d8c2 100644 --- a/resources/views/components/input-otp.blade.php +++ b/resources/views/components/input-otp.blade.php @@ -5,115 +5,144 @@ ])
+
@for ($x = 1; $x <= $digits; $x++) - + @endfor
- except(['eventCallback', 'digits']) }} - type="hidden" - class="hidden" - x-ref="code" - name="{{ $name }}" - minlength="{{ $digits }}" - maxlength="{{ $digits }}" /> + except(['eventCallback', 'digits']) }} + type="hidden" + class="hidden" + x-ref="code" + name="{{ $name }}" + minlength="{{ $digits }}" + maxlength="{{ $digits }}" + />
diff --git a/resources/views/livewire/auth/confirm-password.blade.php b/resources/views/livewire/auth/confirm-password.blade.php index 1895b3a6..41e7034d 100644 --- a/resources/views/livewire/auth/confirm-password.blade.php +++ b/resources/views/livewire/auth/confirm-password.blade.php @@ -1,5 +1,6 @@ redirect(route('two-factor.login'), navigate: true); + return; } @@ -56,7 +57,6 @@ public function login(): void */ protected function validateCredentials(): User { - /** @var User $user */ $user = Auth::getProvider()->retrieveByCredentials(['email' => $this->email, 'password' => $this->password]); if (! $user || ! Auth::getProvider()->validateCredentials($user, ['password' => $this->password])) { diff --git a/resources/views/livewire/settings/two-factor.blade.php b/resources/views/livewire/settings/two-factor.blade.php index b6481500..44afa246 100644 --- a/resources/views/livewire/settings/two-factor.blade.php +++ b/resources/views/livewire/settings/two-factor.blade.php @@ -99,9 +99,11 @@ public function handleNextAction(): void if ($this->requiresConfirmation) { $this->showVerificationStep = true; $this->resetErrorBag(); - } else { - $this->closeModal(); + + return; } + + $this->closeModal(); } public function resetVerification(): void @@ -209,7 +211,7 @@ class="flex flex-col items-stretch absolute w-full h-full divide-y [&>div]:flex-
@endfor
- +
diff --git a/resources/views/livewire/settings/two-factor/recovery-codes.blade.php b/resources/views/livewire/settings/two-factor/recovery-codes.blade.php index e1e82c33..524916a1 100644 --- a/resources/views/livewire/settings/two-factor/recovery-codes.blade.php +++ b/resources/views/livewire/settings/two-factor/recovery-codes.blade.php @@ -24,13 +24,13 @@ public function regenerateRecoveryCodes(GenerateNewRecoveryCodes $generateNewRec private function loadRecoveryCodes(): void { $user = auth()->user(); - if ($user && $user->hasEnabledTwoFactorAuthentication() && $user->two_factor_recovery_codes) { + if ($user->hasEnabledTwoFactorAuthentication() && $user->two_factor_recovery_codes) { $this->recoveryCodes = json_decode(decrypt($user->two_factor_recovery_codes), true); } } }; ?> -
@if(!empty($recoveryCodes))
@@ -66,18 +66,13 @@ private function loadRecoveryCodes(): void > {{ __('Hide Recovery Codes') }} - - {{ __('Regenerate Codes') }} - {{ __('Regenerating Codes...') }} + {{ __('Regenerate Codes') }}
@@ -100,7 +95,7 @@ class="relative overflow-hidden" @endforeach
- {!! __('Each recovery code can be used once to access your account and will be removed after use. If you need more, click :regenerate above.', ['regenerate' => __('Regenerate Codes')]) !!} + {{ __('Each recovery code can be used once to access your account and will be removed after use. If you need more, click Regenerate Codes above.') }}
From 49acd684469e866ac527289c6a93796f88b5743d Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 9 Sep 2025 16:04:29 +0530 Subject: [PATCH 14/43] formatting --- .../livewire/settings/two-factor.blade.php | 43 +++++-------------- .../two-factor/recovery-codes.blade.php | 1 - .../Feature/Auth/PasswordConfirmationTest.php | 1 - 3 files changed, 10 insertions(+), 35 deletions(-) diff --git a/resources/views/livewire/settings/two-factor.blade.php b/resources/views/livewire/settings/two-factor.blade.php index 44afa246..158e05d3 100644 --- a/resources/views/livewire/settings/two-factor.blade.php +++ b/resources/views/livewire/settings/two-factor.blade.php @@ -146,17 +146,10 @@ private function loadTwoFactorData(): void
{{ __('Disabled') }}
- {{ __('When you enable two-factor authentication, you will be prompted for a secure pin during login. This pin can be retrieved from a TOTP-supported application on your phone.') }} - - + {{ __('Enable 2FA') }} @@ -165,28 +158,22 @@ private function loadTwoFactorData(): void
{{ __('Enabled') }}
- {{ __('With two-factor authentication enabled, you will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.') }} - @if($twoFactorEnabled) @endif
- {{ __('Disable 2FA') }} + + {{ __('Disable 2FA') }}
@endif -
@@ -237,7 +223,6 @@ class="bg-white dark:bg-stone-700 animate-pulse flex items-center justify-center @endif
-
-
+
@if(empty($manualSetupKey))
@else - - diff --git a/resources/views/livewire/settings/two-factor/recovery-codes.blade.php b/resources/views/livewire/settings/two-factor/recovery-codes.blade.php index 524916a1..9ada44e7 100644 --- a/resources/views/livewire/settings/two-factor/recovery-codes.blade.php +++ b/resources/views/livewire/settings/two-factor/recovery-codes.blade.php @@ -74,7 +74,6 @@ private function loadRecoveryCodes(): void > {{ __('Regenerate Codes') }} -
actingAs($user)->get(route('password.confirm')); $response->assertStatus(200); - } public function test_password_can_be_confirmed(): void From f33fa41b89b435ecb18ab1cffda52fc3cf6b4f39 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 9 Sep 2025 23:00:56 +0530 Subject: [PATCH 15/43] refactoring --- resources/views/livewire/settings/two-factor.blade.php | 2 +- routes/web.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/views/livewire/settings/two-factor.blade.php b/resources/views/livewire/settings/two-factor.blade.php index 158e05d3..bb60fdcf 100644 --- a/resources/views/livewire/settings/two-factor.blade.php +++ b/resources/views/livewire/settings/two-factor.blade.php @@ -30,7 +30,7 @@ public bool $showVerificationStep = false; - #[Validate('required|string|min:6|max:6', onUpdate: false)] + #[Validate('required|string|size:6', onUpdate: false)] public string $code = ''; public function mount(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): void diff --git a/routes/web.php b/routes/web.php index fcfcbed7..badfd01f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -28,4 +28,4 @@ ->name('two-factor.show'); }); -require __DIR__ . '/auth.php'; +require __DIR__.'/auth.php'; From ac5fa582f171466ae1a0252bd3213d2a5840f32f Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 9 Sep 2025 23:22:48 +0530 Subject: [PATCH 16/43] Refactor input-otp-component it to use the $nextTick --- .../views/components/input-otp.blade.php | 32 +++++++------------ 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/resources/views/components/input-otp.blade.php b/resources/views/components/input-otp.blade.php index 0f97d8c2..5e90f0ec 100644 --- a/resources/views/components/input-otp.blade.php +++ b/resources/views/components/input-otp.blade.php @@ -1,19 +1,17 @@ @props([ 'digits' => 6, - 'eventCallback' => null, 'name' => 'code', ])
+ @focus-auth-2fa-auth-code.window="$refs.input1 && $refs.input1.focus()" + @clear-auth-2fa-auth-code.window="clearAll()" + class="relative">
@for ($x = 1; $x <= $digits; $x++) @@ -137,7 +127,7 @@ class="flex h-10 w-10 items-center justify-center border border-zinc-300 bg-acce
except(['eventCallback', 'digits']) }} + {{ $attributes->except(['digits']) }} type="hidden" class="hidden" x-ref="code" From d919841dcb9f3ef11dcdb5002973a74385c6291e Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 9 Sep 2025 23:41:17 +0530 Subject: [PATCH 17/43] simplify input-otp component --- resources/views/components/input-otp.blade.php | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/resources/views/components/input-otp.blade.php b/resources/views/components/input-otp.blade.php index 5e90f0ec..6da2aba6 100644 --- a/resources/views/components/input-otp.blade.php +++ b/resources/views/components/input-otp.blade.php @@ -34,9 +34,6 @@ this.$refs.code.dispatchEvent(new Event('input', { bubbles: true })); this.$refs.code.dispatchEvent(new Event('change', { bubbles: true })); }, - onComplete() { - this.updateHiddenField(); - }, handleNumberKey(index, key) { this.getInput(index).value = key; @@ -46,9 +43,6 @@ $nextTick(() => { this.updateHiddenField(); - if (index === this.totalDigits && this.isComplete()) { - this.onComplete(); - } }); }, handleBackspace(index) { @@ -90,7 +84,6 @@ if (numericOnly.length >= this.totalDigits) { this.updateHiddenField(); - this.onComplete(); } }, clearAll() { @@ -118,7 +111,7 @@ class="relative"> @keydown="handleKeyDown({{ $x }}, $event)" @focus="$el.select()" @input="$el.value = $el.value.replace(/[^0-9]/g, '').slice(0, 1)" - class="flex h-10 w-10 items-center justify-center border border-zinc-300 bg-accent-foreground text-center text-sm font-medium text-accent-content transition-colors placeholder:text-zinc-500 focus:border-accent focus:border-2 focus:outline-none focus:relative focus:z-10 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-700 dark:focus:border-accent + class="flex h-10 w-10 items-center justify-center border border-zinc-300 bg-accent-foreground text-center text-sm font-medium text-accent-content transition-colors focus:border-accent focus:border-2 focus:outline-none focus:relative focus:z-10 dark:border-zinc-700 dark:focus:border-accent @if($x == 1) rounded-l-md @endif @if($x == $digits) rounded-r-md @endif @if($x > 1) -ml-px @endif" @@ -129,7 +122,6 @@ class="flex h-10 w-10 items-center justify-center border border-zinc-300 bg-acce except(['digits']) }} type="hidden" - class="hidden" x-ref="code" name="{{ $name }}" minlength="{{ $digits }}" From 567f2c47af8db734aac3e9e70366eb57413795d9 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Wed, 10 Sep 2025 13:46:28 +0530 Subject: [PATCH 18/43] Change conditional signature for better clarity --- .../views/components/input-otp.blade.php | 6 +- .../livewire/settings/two-factor.blade.php | 104 +++++++++--------- .../two-factor/recovery-codes.blade.php | 2 +- 3 files changed, 55 insertions(+), 57 deletions(-) diff --git a/resources/views/components/input-otp.blade.php b/resources/views/components/input-otp.blade.php index 6da2aba6..ca7bee11 100644 --- a/resources/views/components/input-otp.blade.php +++ b/resources/views/components/input-otp.blade.php @@ -112,9 +112,9 @@ class="relative"> @focus="$el.select()" @input="$el.value = $el.value.replace(/[^0-9]/g, '').slice(0, 1)" class="flex h-10 w-10 items-center justify-center border border-zinc-300 bg-accent-foreground text-center text-sm font-medium text-accent-content transition-colors focus:border-accent focus:border-2 focus:outline-none focus:relative focus:z-10 dark:border-zinc-700 dark:focus:border-accent - @if($x == 1) rounded-l-md @endif - @if($x == $digits) rounded-r-md @endif - @if($x > 1) -ml-px @endif" + @if ($x == 1) rounded-l-md @endif + @if ($x == $digits) rounded-r-md @endif + @if ($x > 1) -ml-px @endif" /> @endfor
diff --git a/resources/views/livewire/settings/two-factor.blade.php b/resources/views/livewire/settings/two-factor.blade.php index bb60fdcf..919e43ef 100644 --- a/resources/views/livewire/settings/two-factor.blade.php +++ b/resources/views/livewire/settings/two-factor.blade.php @@ -141,19 +141,7 @@ private function loadTwoFactorData(): void
- @if(!$twoFactorEnabled) -
-
- {{ __('Disabled') }} -
- - {{ __('When you enable two-factor authentication, you will be prompted for a secure pin during login. This pin can be retrieved from a TOTP-supported application on your phone.') }} - - - {{ __('Enable 2FA') }} - -
- @else + @if ($twoFactorEnabled)
{{ __('Enabled') }} @@ -161,9 +149,7 @@ private function loadTwoFactorData(): void {{ __('With two-factor authentication enabled, you will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.') }} - @if($twoFactorEnabled) - - @endif +
@@ -171,6 +157,18 @@ private function loadTwoFactorData(): void
+ @else +
+
+ {{ __('Disabled') }} +
+ + {{ __('When you enable two-factor authentication, you will be prompted for a secure pin during login. This pin can be retrieved from a TOTP-supported application on your phone.') }} + + + {{ __('Enable 2FA') }} + +
@endif
@@ -206,12 +204,46 @@ class="flex flex-col items-stretch absolute w-full h-full divide-y [&>div]:flex- {{ $this->modalConfig['description'] }}
- @if(!$showVerificationStep) + @if ($showVerificationStep) +
+
+ + @error('code') + + {{ $message }} + + @enderror +
+ +
+ + {{ __('Back') }} + + + {{ __('Confirm') }} + +
+
+ @else
- @if(empty($qrCodeSvg)) + @if (empty($qrCodeSvg))
@@ -256,7 +288,7 @@ class="relative bg-white dark:bg-stone-800 px-2 text-sm text-stone-600 dark:text } }">
- @if(empty($manualSetupKey)) + @if (empty($manualSetupKey))
@@ -276,40 +308,6 @@ class="text-green-500">
- @else -
-
- - @error('code') - - {{ $message }} - - @enderror -
- -
- - {{ __('Back') }} - - - {{ __('Confirm') }} - -
-
@endif
diff --git a/resources/views/livewire/settings/two-factor/recovery-codes.blade.php b/resources/views/livewire/settings/two-factor/recovery-codes.blade.php index 9ada44e7..0f30dc36 100644 --- a/resources/views/livewire/settings/two-factor/recovery-codes.blade.php +++ b/resources/views/livewire/settings/two-factor/recovery-codes.blade.php @@ -32,7 +32,7 @@ private function loadRecoveryCodes(): void
- @if(!empty($recoveryCodes)) + @if (filled($recoveryCodes))
From 002c3df5a48b5807ab55de0e69e1303b450d6e3b Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Wed, 17 Sep 2025 02:02:36 +0530 Subject: [PATCH 19/43] Refactor input-otp component for improved clarity --- .../views/components/input-otp.blade.php | 62 ++++++++++--------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/resources/views/components/input-otp.blade.php b/resources/views/components/input-otp.blade.php index ca7bee11..a5e3e6fc 100644 --- a/resources/views/components/input-otp.blade.php +++ b/resources/views/components/input-otp.blade.php @@ -5,37 +5,30 @@
+ @focus-2fa-auth-code.window="$refs.input1?.focus()" + @clear-2fa-auth-code.window="clearAll()" + class="relative">
@for ($x = 1; $x <= $digits; $x++) @@ -111,10 +111,12 @@ class="relative"> @keydown="handleKeyDown({{ $x }}, $event)" @focus="$el.select()" @input="$el.value = $el.value.replace(/[^0-9]/g, '').slice(0, 1)" - class="flex h-10 w-10 items-center justify-center border border-zinc-300 bg-accent-foreground text-center text-sm font-medium text-accent-content transition-colors focus:border-accent focus:border-2 focus:outline-none focus:relative focus:z-10 dark:border-zinc-700 dark:focus:border-accent - @if ($x == 1) rounded-l-md @endif - @if ($x == $digits) rounded-r-md @endif - @if ($x > 1) -ml-px @endif" + @class([ + 'flex h-10 w-10 items-center justify-center border border-zinc-300 bg-accent-foreground text-center text-sm font-medium text-accent-content transition-colors focus:border-accent focus:border-2 focus:outline-none focus:relative focus:z-10 dark:border-zinc-700 dark:focus:border-accent', + 'rounded-l-md' => $x == 1, + 'rounded-r-md' => $x == $digits, + '-ml-px' => $x > 1, + ]) /> @endfor
From e39369654517092991189e400b5240ba002c7007 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Wed, 17 Sep 2025 02:02:48 +0530 Subject: [PATCH 20/43] Refactor two-factor authentication views for improved clarity and functionality --- .../views/components/input-otp.blade.php | 11 ++-- .../components/settings/layout.blade.php | 2 +- .../auth/two-factor-challenge.blade.php | 61 +++++++++++++------ .../livewire/settings/two-factor.blade.php | 54 +++++++++------- .../two-factor/recovery-codes.blade.php | 4 +- 5 files changed, 81 insertions(+), 51 deletions(-) diff --git a/resources/views/components/input-otp.blade.php b/resources/views/components/input-otp.blade.php index a5e3e6fc..04b76ebb 100644 --- a/resources/views/components/input-otp.blade.php +++ b/resources/views/components/input-otp.blade.php @@ -2,7 +2,6 @@ 'digits' => 6, 'name' => 'code', ]) -
{{ __('Profile') }} {{ __('Password') }} @if (Laravel\Fortify\Features::canManageTwoFactorAuthentication()) - {{ __('Two-factor Authentication') }} + {{ __('Two-factor Auth') }} @endif {{ __('Appearance') }} diff --git a/resources/views/livewire/auth/two-factor-challenge.blade.php b/resources/views/livewire/auth/two-factor-challenge.blade.php index 2ada13fe..5e703210 100644 --- a/resources/views/livewire/auth/two-factor-challenge.blade.php +++ b/resources/views/livewire/auth/two-factor-challenge.blade.php @@ -1,10 +1,25 @@
-
+
@@ -14,14 +29,18 @@ :description="__('Please confirm access to your account by entering one of your emergency recovery codes.')"/>
-
+ @csrf
- +
@error('code') @@ -31,9 +50,14 @@
- +
@error('recovery_code') @@ -42,19 +66,20 @@ @enderror
- + {{ __('Continue') }}
- {{ __('or you can') }} + {{ __('or you can') }}
- {{ __('login using a recovery code') }} - {{ __('login using an authentication code') }} + {{ __('login using a recovery code') }} + {{ __('login using an authentication code') }}
diff --git a/resources/views/livewire/settings/two-factor.blade.php b/resources/views/livewire/settings/two-factor.blade.php index 919e43ef..c9f69868 100644 --- a/resources/views/livewire/settings/two-factor.blade.php +++ b/resources/views/livewire/settings/two-factor.blade.php @@ -1,16 +1,13 @@ user()); - if (!$this->requiresConfirmation) { - $this->twoFactorEnabled = true; + if (! $this->requiresConfirmation) { + $this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication(); } $this->loadTwoFactorData(); $this->showModal = true; @@ -75,7 +72,7 @@ public function getModalConfigProperty(): array return [ 'title' => __('Two-Factor Authentication Enabled'), 'description' => __('Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.'), - 'buttonText' => __('Close') + 'buttonText' => __('Close'), ]; } @@ -83,14 +80,14 @@ public function getModalConfigProperty(): array return [ 'title' => __('Verify Authentication Code'), 'description' => __('Enter the 6-digit code from your authenticator app'), - 'buttonText' => __('Continue') + 'buttonText' => __('Continue'), ]; } return [ 'title' => __('Enable Two-Factor Authentication'), 'description' => __('To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app'), - 'buttonText' => __('Continue') + 'buttonText' => __('Continue'), ]; } @@ -122,8 +119,8 @@ public function closeModal(): void $this->resetErrorBag(); $this->showModal = false; - if (!$this->requiresConfirmation) { - $this->twoFactorEnabled = true; + if (! $this->requiresConfirmation) { + $this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication(); } } @@ -131,7 +128,7 @@ private function loadTwoFactorData(): void { $user = auth()->user(); - $this->qrCodeSvg = $user->twoFactorQrCodeSvg(); + $this->qrCodeSvg = $user?->twoFactorQrCodeSvg(); $this->manualSetupKey = decrypt($user->two_factor_secret); } } ?> @@ -165,7 +162,11 @@ private function loadTwoFactorData(): void {{ __('When you enable two-factor authentication, you will be prompted for a secure pin during login. This pin can be retrieved from a TOTP-supported application on your phone.') }} - + {{ __('Enable 2FA') }}
@@ -279,7 +280,7 @@ class="relative bg-white dark:bg-stone-800 px-2 text-sm text-stone-600 dark:text copied: false, async copy() { try { - await navigator.clipboard.writeText('{{ $manualSetupKey }}'); + await navigator.clipboard.writeText($wire.$manualSetupKey); this.copied = true; setTimeout(() => this.copied = false, 1500); } catch (e) { @@ -294,14 +295,21 @@ class="w-full flex items-center justify-center bg-stone-100 dark:bg-stone-700 p-
@else - - @endif
diff --git a/resources/views/livewire/settings/two-factor/recovery-codes.blade.php b/resources/views/livewire/settings/two-factor/recovery-codes.blade.php index 0f30dc36..479aa238 100644 --- a/resources/views/livewire/settings/two-factor/recovery-codes.blade.php +++ b/resources/views/livewire/settings/two-factor/recovery-codes.blade.php @@ -1,10 +1,8 @@ Date: Wed, 17 Sep 2025 17:17:42 +0530 Subject: [PATCH 21/43] Add error handling in the components --- .../livewire/settings/two-factor.blade.php | 63 ++++++----- .../two-factor/recovery-codes.blade.php | 100 ++++++++++-------- 2 files changed, 90 insertions(+), 73 deletions(-) diff --git a/resources/views/livewire/settings/two-factor.blade.php b/resources/views/livewire/settings/two-factor.blade.php index c9f69868..71b4d99f 100644 --- a/resources/views/livewire/settings/two-factor.blade.php +++ b/resources/views/livewire/settings/two-factor.blade.php @@ -128,8 +128,14 @@ private function loadTwoFactorData(): void { $user = auth()->user(); - $this->qrCodeSvg = $user?->twoFactorQrCodeSvg(); - $this->manualSetupKey = decrypt($user->two_factor_secret); + try { + $this->qrCodeSvg = $user?->twoFactorQrCodeSvg(); + $this->manualSetupKey = decrypt($user->two_factor_secret); + } catch (Exception) { + $this->addError('setupData', 'Failed to fetch setup data.'); + $this->qrCodeSvg = ''; + $this->manualSetupKey = ''; + } } } ?> @@ -240,7 +246,9 @@ class="flex-1"
@else -
+ @error('setupData') + + @enderror
@@ -254,10 +262,11 @@ class="bg-white dark:bg-stone-700 animate-pulse flex items-center justify-center {!! $qrCodeSvg !!}
@endif -
+
modalConfig['buttonText'] }}
-
-
-
- - {{ __('or, enter the code manually') }} - -
-
+
+
+ + {{ __('or, enter the code manually') }} + +
+
@if (empty($manualSetupKey))
+ class="border-l border-stone-200 dark:border-stone-600 px-3 transition-colors cursor-pointer">
-
@endif
diff --git a/resources/views/livewire/settings/two-factor/recovery-codes.blade.php b/resources/views/livewire/settings/two-factor/recovery-codes.blade.php index 479aa238..72bfd5ce 100644 --- a/resources/views/livewire/settings/two-factor/recovery-codes.blade.php +++ b/resources/views/livewire/settings/two-factor/recovery-codes.blade.php @@ -23,47 +23,52 @@ private function loadRecoveryCodes(): void { $user = auth()->user(); if ($user->hasEnabledTwoFactorAuthentication() && $user->two_factor_recovery_codes) { - $this->recoveryCodes = json_decode(decrypt($user->two_factor_recovery_codes), true); + try { + $this->recoveryCodes = json_decode(decrypt($user->two_factor_recovery_codes), true); + } catch (Exception) { + $this->addError('recoveryCodes', 'Failed to load recovery codes'); + $this->recoveryCodes = []; + } } } }; ?>
- @if (filled($recoveryCodes)) -
-
- - {{ __('2FA Recovery Codes') }} -
- - {{ __('Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.') }} - +
+
+ + {{ __('2FA Recovery Codes') }}
-
-
- - - {{ __('Hide Recovery Codes') }} - + + {{ __('Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.') }} + +
+
+
+ + + {{ __('Hide Recovery Codes') }} + + @if(filled($recoveryCodes)) {{ __('Regenerate Codes') }} -
-
-
+ @endif +
+
+
+ @error('recoveryCodes') + + @enderror + @if(filled($recoveryCodes))
@@ -94,9 +104,9 @@ class="relative overflow-hidden" {{ __('Each recovery code can be used once to access your account and will be removed after use. If you need more, click Regenerate Codes above.') }} -
+ @endif
- @endif +
From 78b997136ea31de7f97e7da729afe63bc50564ac Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Wed, 17 Sep 2025 19:04:03 +0530 Subject: [PATCH 22/43] Refactor confirm password functionality to use fortify confirm password --- app/Providers/FortifyServiceProvider.php | 1 + .../livewire/auth/confirm-password.blade.php | 86 ++++++------------- routes/auth.php | 3 - .../Feature/Auth/PasswordConfirmationTest.php | 29 ------- 4 files changed, 29 insertions(+), 90 deletions(-) diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index a59c6945..b99dcc74 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -24,6 +24,7 @@ public function register(): void public function boot(): void { Fortify::twoFactorChallengeView(fn () => view('livewire.auth.two-factor-challenge')); + Fortify::confirmPasswordView(fn () => view('livewire.auth.confirm-password')); RateLimiter::for('two-factor', function (Request $request) { return Limit::perMinute(5)->by($request->session()->get('login.id')); diff --git a/resources/views/livewire/auth/confirm-password.blade.php b/resources/views/livewire/auth/confirm-password.blade.php index 2943d840..66e894fd 100644 --- a/resources/views/livewire/auth/confirm-password.blade.php +++ b/resources/views/livewire/auth/confirm-password.blade.php @@ -1,60 +1,30 @@ -validate([ - 'password' => ['required', 'string'], - ]); - - if (! Auth::guard('web')->validate([ - 'email' => Auth::user()->email, - 'password' => $this->password, - ])) { - throw ValidationException::withMessages([ - 'password' => __('auth.password'), - ]); - } - - session(['auth.password_confirmed_at' => time()]); - - $this->redirectIntended(default: route('dashboard', absolute: false), navigate: true); - } -}; ?> - -
- - - - - -
- - +
+ - - {{ __('Confirm') }} - - -
+ + + +
+ @csrf + + + + + + {{ __('Confirm') }} + + +
+ diff --git a/routes/auth.php b/routes/auth.php index e37a6b68..57826226 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -26,9 +26,6 @@ Route::get('verify-email/{id}/{hash}', VerifyEmailController::class) ->middleware(['signed', 'throttle:6,1']) ->name('verification.verify'); - - Volt::route('user/confirm-password', 'auth.confirm-password') - ->name('password.confirm'); }); Route::post('logout', App\Livewire\Actions\Logout::class) diff --git a/tests/Feature/Auth/PasswordConfirmationTest.php b/tests/Feature/Auth/PasswordConfirmationTest.php index de243d36..1b53b110 100644 --- a/tests/Feature/Auth/PasswordConfirmationTest.php +++ b/tests/Feature/Auth/PasswordConfirmationTest.php @@ -4,7 +4,6 @@ use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; -use Livewire\Volt\Volt; use Tests\TestCase; class PasswordConfirmationTest extends TestCase @@ -19,32 +18,4 @@ public function test_confirm_password_screen_can_be_rendered(): void $response->assertStatus(200); } - - public function test_password_can_be_confirmed(): void - { - $user = User::factory()->create(); - - $this->actingAs($user); - - $response = Volt::test('auth.confirm-password') - ->set('password', 'password') - ->call('confirmPassword'); - - $response - ->assertHasNoErrors() - ->assertRedirect(route('dashboard', absolute: false)); - } - - public function test_password_is_not_confirmed_with_invalid_password(): void - { - $user = User::factory()->create(); - - $this->actingAs($user); - - $response = Volt::test('auth.confirm-password') - ->set('password', 'wrong-password') - ->call('confirmPassword'); - - $response->assertHasErrors(['password']); - } } From c5d5f9cd2284dd5b139b82e0e01fa5a7a44cd00e Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Wed, 17 Sep 2025 20:09:03 +0530 Subject: [PATCH 23/43] Use regex instead of checking number --- resources/views/components/input-otp.blade.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/views/components/input-otp.blade.php b/resources/views/components/input-otp.blade.php index 04b76ebb..6c49da42 100644 --- a/resources/views/components/input-otp.blade.php +++ b/resources/views/components/input-otp.blade.php @@ -57,7 +57,7 @@ }, handleKeyDown(index, event) { const key = event.key; - if (key >= '0' && key <= '9') { + if (/^[0-9]$/.test(key)) { event.preventDefault(); this.handleNumberKey(index, key); return; @@ -111,7 +111,7 @@ class="relative"> @focus="$el.select()" @input="$el.value = $el.value.replace(/[^0-9]/g, '').slice(0, 1)" @class([ - 'flex h-10 w-10 items-center justify-center border border-zinc-300 bg-accent-foreground text-center text-sm font-medium text-accent-content transition-colors focus:border-accent focus:border-2 focus:outline-none focus:relative focus:z-10 dark:border-zinc-700 dark:focus:border-accent', + 'flex size-10 items-center justify-center border border-zinc-300 bg-accent-foreground text-center text-sm font-medium text-accent-content transition-colors focus:border-accent focus:border-2 focus:outline-none focus:relative focus:z-10 dark:border-zinc-700 dark:focus:border-accent', 'rounded-l-md' => $x == 1, 'rounded-r-md' => $x == $digits, '-ml-px' => $x > 1, From 6026b37447c8f736d00146ca193c148b7d384abc Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Thu, 18 Sep 2025 23:32:53 +0530 Subject: [PATCH 24/43] Remove unnecessary calculation of next index in input-otp component --- resources/views/components/input-otp.blade.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/resources/views/components/input-otp.blade.php b/resources/views/components/input-otp.blade.php index 6c49da42..e306d85f 100644 --- a/resources/views/components/input-otp.blade.php +++ b/resources/views/components/input-otp.blade.php @@ -79,8 +79,6 @@ .forEach(index => { this.setValue(index, numericOnly[index - 1]); }); - const nextIndex = Math.min(digitsToFill + 1, this.totalDigits); - if (numericOnly.length >= this.totalDigits) { this.updateHiddenField(); } From 970ed4e35df7ed0313b8774ea49ed59030c31707 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Fri, 19 Sep 2025 01:44:14 +0530 Subject: [PATCH 25/43] Formatting --- resources/views/livewire/settings/two-factor.blade.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/resources/views/livewire/settings/two-factor.blade.php b/resources/views/livewire/settings/two-factor.blade.php index 71b4d99f..a578cc7b 100644 --- a/resources/views/livewire/settings/two-factor.blade.php +++ b/resources/views/livewire/settings/two-factor.blade.php @@ -154,8 +154,12 @@ private function loadTwoFactorData(): void
- + {{ __('Disable 2FA') }}
From ecebcf915f13707e53246475108d1a353e2c883f Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Fri, 19 Sep 2025 16:37:07 -0400 Subject: [PATCH 26/43] use when instead of assigning variable in routes file --- routes/web.php | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/routes/web.php b/routes/web.php index 5e27be31..1fec66d4 100644 --- a/routes/web.php +++ b/routes/web.php @@ -19,12 +19,15 @@ Volt::route('settings/password', 'settings.password')->name('password.edit'); Volt::route('settings/appearance', 'settings.appearance')->name('appearance.edit'); - $twoFactorMiddleware = Features::canManageTwoFactorAuthentication() && Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword') - ? ['password.confirm'] - : []; - Volt::route('settings/two-factor', 'settings.two-factor') - ->middleware($twoFactorMiddleware) + ->middleware( + when( + Features::canManageTwoFactorAuthentication() + && Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword'), + ['password.confirm'], + [], + ), + ) ->name('two-factor.show'); }); From 10944f6fde8dff8424fcee702078f2ea82b43b5a Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Fri, 19 Sep 2025 17:12:43 -0400 Subject: [PATCH 27/43] formatting --- .../views/components/input-otp.blade.php | 9 +- .../livewire/auth/confirm-password.blade.php | 2 - resources/views/livewire/auth/login.blade.php | 20 +- .../auth/two-factor-challenge.blade.php | 30 +-- .../livewire/settings/two-factor.blade.php | 180 +++++++++--------- .../two-factor/recovery-codes.blade.php | 31 ++- 6 files changed, 137 insertions(+), 135 deletions(-) diff --git a/resources/views/components/input-otp.blade.php b/resources/views/components/input-otp.blade.php index e306d85f..df68d04f 100644 --- a/resources/views/components/input-otp.blade.php +++ b/resources/views/components/input-otp.blade.php @@ -2,6 +2,7 @@ 'digits' => 6, 'name' => 'code', ]) +
- + class="relative" +>
@for ($x = 1; $x <= $digits; $x++) @input="$el.value = $el.value.replace(/[^0-9]/g, '').slice(0, 1)" @class([ 'flex size-10 items-center justify-center border border-zinc-300 bg-accent-foreground text-center text-sm font-medium text-accent-content transition-colors focus:border-accent focus:border-2 focus:outline-none focus:relative focus:z-10 dark:border-zinc-700 dark:focus:border-accent', - 'rounded-l-md' => $x == 1, - 'rounded-r-md' => $x == $digits, + 'rounded-l-md' => $x === 1, + 'rounded-r-md' => $x === $digits, '-ml-px' => $x > 1, ]) /> diff --git a/resources/views/livewire/auth/confirm-password.blade.php b/resources/views/livewire/auth/confirm-password.blade.php index 66e894fd..a6a45b31 100644 --- a/resources/views/livewire/auth/confirm-password.blade.php +++ b/resources/views/livewire/auth/confirm-password.blade.php @@ -5,13 +5,11 @@ :description="__('This is a secure area of the application. Please confirm your password before continuing.')" /> -
@csrf - validate(); @@ -52,9 +49,6 @@ public function login(): void $this->redirectIntended(default: route('dashboard', absolute: false), navigate: true); } - /** - * Validate the request's credentials and return the user without logging them in. - */ protected function validateCredentials(): User { $user = Auth::getProvider()->retrieveByCredentials(['email' => $this->email, 'password' => $this->password]); @@ -70,9 +64,6 @@ protected function validateCredentials(): User return $user; } - /** - * Ensure the authentication request is not rate limited. - */ protected function ensureIsNotRateLimited(): void { if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) { @@ -91,9 +82,6 @@ protected function ensureIsNotRateLimited(): void ]); } - /** - * Get the authentication rate limiting throttle key. - */ protected function throttleKey(): string { return Str::transliterate(Str::lower($this->email).'|'.request()->ip()); @@ -103,11 +91,9 @@ protected function throttleKey(): string
- - -
@if (Route::has('password.request')) - + {{ __('Forgot your password?') }} @endif
-
@@ -148,7 +132,7 @@ protected function throttleKey(): string @if (Route::has('register')) -
+
{{ __('Don\'t have an account?') }} {{ __('Sign up') }}
diff --git a/resources/views/livewire/auth/two-factor-challenge.blade.php b/resources/views/livewire/auth/two-factor-challenge.blade.php index 5e703210..7a103947 100644 --- a/resources/views/livewire/auth/two-factor-challenge.blade.php +++ b/resources/views/livewire/auth/two-factor-challenge.blade.php @@ -19,14 +19,18 @@ }" x-cloak class="relative w-full h-auto" - > + >
- +
- +
@@ -42,10 +46,11 @@ class="relative w-full h-auto" x-model="code" />
+ @error('code') - - {{ $message }} - + + {{ $message }} + @enderror
@@ -59,10 +64,11 @@ class="relative w-full h-auto" x-model="recovery_code" />
+ @error('recovery_code') - - {{ $message }} - + + {{ $message }} + @enderror
@@ -77,7 +83,7 @@ class="w-full"
{{ __('or you can') }} -
+
{{ __('login using a recovery code') }} {{ __('login using an authentication code') }}
diff --git a/resources/views/livewire/settings/two-factor.blade.php b/resources/views/livewire/settings/two-factor.blade.php index a578cc7b..0cdaa49d 100644 --- a/resources/views/livewire/settings/two-factor.blade.php +++ b/resources/views/livewire/settings/two-factor.blade.php @@ -10,7 +10,7 @@ use Livewire\Volt\Component; use Symfony\Component\HttpFoundation\Response; -new class extends Component { +new class extends Compone1nt { #[Locked] public bool $twoFactorEnabled; @@ -45,9 +45,11 @@ public function mount(DisableTwoFactorAuthentication $disableTwoFactorAuthentica public function enable(EnableTwoFactorAuthentication $enableTwoFactorAuthentication): void { $enableTwoFactorAuthentication(auth()->user()); + if (! $this->requiresConfirmation) { $this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication(); } + $this->loadTwoFactorData(); $this->showModal = true; } @@ -141,9 +143,12 @@ private function loadTwoFactorData(): void
@include('partials.settings-heading') - -
+ + +
@if ($twoFactorEnabled)
@@ -176,13 +181,15 @@ private function loadTwoFactorData(): void variant="primary" icon="shield-check" icon:variant="outline" - wire:click="enable"> + wire:click="enable" + > {{ __('Enable 2FA') }}
@endif
+
-
-
-
- @for($i = 1; $i <= 5; $i++) +
+
+
+ @for ($i = 1; $i <= 5; $i++)
@endfor
-
- @for($i = 1; $i <= 5; $i++) +
+ @for ($i = 1; $i <= 5; $i++)
@endfor
-
+
{{ $this->modalConfig['title'] }} {{ $this->modalConfig['description'] }}
@@ -225,9 +228,9 @@ class="flex flex-col items-stretch absolute w-full h-full divide-y [&>div]:flex- autocomplete="one-time-code" /> @error('code') - - {{ $message }} - + + {{ $message }} + @enderror
@@ -251,82 +254,81 @@ class="flex-1"
@else @error('setupData') - + @enderror -
-
- @if (empty($qrCodeSvg)) -
- -
- @else -
- {!! $qrCodeSvg !!} -
- @endif -
+ +
+
+ @empty($qrCodeSvg) +
+ +
+ @else +
+ {!! $qrCodeSvg !!} +
+ @endempty
-
- - {{ $this->modalConfig['buttonText'] }} - +
+
+ + {{ $this->modalConfig['buttonText'] }} + +
+
+
+
+ + {{ __('or, enter the code manually') }} +
-
-
+
+
+ @empty($manualSetupKey)
- - {{ __('or, enter the code manually') }} - -
-
-
- @if (empty($manualSetupKey)) -
- -
- @else - - - @endif -
+ class="flex items-center justify-center w-full p-3 bg-stone-100 dark:bg-stone-700"> + +
+ @else + + + @endempty
+
@endif
diff --git a/resources/views/livewire/settings/two-factor/recovery-codes.blade.php b/resources/views/livewire/settings/two-factor/recovery-codes.blade.php index 72bfd5ce..d30bfe72 100644 --- a/resources/views/livewire/settings/two-factor/recovery-codes.blade.php +++ b/resources/views/livewire/settings/two-factor/recovery-codes.blade.php @@ -22,6 +22,7 @@ public function regenerateRecoveryCodes(GenerateNewRecoveryCodes $generateNewRec private function loadRecoveryCodes(): void { $user = auth()->user(); + if ($user->hasEnabledTwoFactorAuthentication() && $user->two_factor_recovery_codes) { try { $this->recoveryCodes = json_decode(decrypt($user->two_factor_recovery_codes), true); @@ -33,8 +34,11 @@ private function loadRecoveryCodes(): void } }; ?> -
+
@@ -57,6 +61,7 @@ private function loadRecoveryCodes(): void > {{ __('View Recovery Codes') }} + {{ __('Hide Recovery Codes') }} - @if(filled($recoveryCodes)) + + @if (filled($recoveryCodes)) @enderror - @if(filled($recoveryCodes)) -
+ + @if (filled($recoveryCodes)) +
@foreach($recoveryCodes as $code) -
+
{{ $code }}
@endforeach @@ -109,4 +121,3 @@ class="relative overflow-hidden"
- From 2ff6a8e982ad3eaed15e88a15b32f649eb3274e8 Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Fri, 19 Sep 2025 17:13:52 -0400 Subject: [PATCH 28/43] formatting --- resources/views/components/input-otp.blade.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/resources/views/components/input-otp.blade.php b/resources/views/components/input-otp.blade.php index df68d04f..9e7420fa 100644 --- a/resources/views/components/input-otp.blade.php +++ b/resources/views/components/input-otp.blade.php @@ -58,6 +58,7 @@ }, handleKeyDown(index, event) { const key = event.key; + if (/^[0-9]$/.test(key)) { event.preventDefault(); this.handleNumberKey(index, key); @@ -75,11 +76,13 @@ const pastedText = (event.clipboardData || window.clipboardData).getData('text'); const numericOnly = pastedText.replace(/[^0-9]/g, ''); const digitsToFill = Math.min(numericOnly.length, this.totalDigits); + this.digitIndices .slice(0, digitsToFill) .forEach(index => { this.setValue(index, numericOnly[index - 1]); }); + if (numericOnly.length >= this.totalDigits) { this.updateHiddenField(); } From e84725184e9190db380dc346d48af92e7ff59386 Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Fri, 19 Sep 2025 17:14:41 -0400 Subject: [PATCH 29/43] formatting --- .../views/components/input-otp.blade.php | 169 +++++++++--------- 1 file changed, 85 insertions(+), 84 deletions(-) diff --git a/resources/views/components/input-otp.blade.php b/resources/views/components/input-otp.blade.php index 9e7420fa..9432cfec 100644 --- a/resources/views/components/input-otp.blade.php +++ b/resources/views/components/input-otp.blade.php @@ -3,101 +3,102 @@ 'name' => 'code', ]) -
@for ($x = 1; $x <= $digits; $x++) From ac02b1b378e81305eab5c032e9a5b01a032aa2dc Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Fri, 19 Sep 2025 17:18:26 -0400 Subject: [PATCH 30/43] undo typo --- resources/views/livewire/settings/two-factor.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/livewire/settings/two-factor.blade.php b/resources/views/livewire/settings/two-factor.blade.php index 0cdaa49d..0f14a5ed 100644 --- a/resources/views/livewire/settings/two-factor.blade.php +++ b/resources/views/livewire/settings/two-factor.blade.php @@ -10,7 +10,7 @@ use Livewire\Volt\Component; use Symfony\Component\HttpFoundation\Response; -new class extends Compone1nt { +new class extends Component { #[Locked] public bool $twoFactorEnabled; From d41ddc1138809f9e10b406781804f78e257da604 Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Fri, 19 Sep 2025 17:18:32 -0400 Subject: [PATCH 31/43] formatting --- .../views/livewire/auth/two-factor-challenge.blade.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/resources/views/livewire/auth/two-factor-challenge.blade.php b/resources/views/livewire/auth/two-factor-challenge.blade.php index 7a103947..d9eae055 100644 --- a/resources/views/livewire/auth/two-factor-challenge.blade.php +++ b/resources/views/livewire/auth/two-factor-challenge.blade.php @@ -1,6 +1,8 @@
Date: Fri, 19 Sep 2025 17:21:34 -0400 Subject: [PATCH 32/43] formatting --- .../views/livewire/auth/two-factor-challenge.blade.php | 1 + resources/views/livewire/settings/two-factor.blade.php | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/resources/views/livewire/auth/two-factor-challenge.blade.php b/resources/views/livewire/auth/two-factor-challenge.blade.php index d9eae055..7e944b34 100644 --- a/resources/views/livewire/auth/two-factor-challenge.blade.php +++ b/resources/views/livewire/auth/two-factor-challenge.blade.php @@ -26,6 +26,7 @@ class="relative w-full h-auto" :description="__('Enter the authentication code provided by your authenticator application.')" />
+
{{ $this->modalConfig['description'] }}
+ @if ($showVerificationStep)
@@ -304,8 +305,7 @@ class="flex items-center space-x-2" >
@empty($manualSetupKey) -
+
@else @@ -317,7 +317,8 @@ class="w-full p-3 bg-transparent outline-none text-stone-900 dark:text-stone-100 />