From 429e5ece4db858f7caf72111b86644afab47cea1 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 26 Aug 2025 20:10:49 +0530 Subject: [PATCH 01/31] feat: add two-factor authentication --- .gitignore | 1 + .../Auth/AuthenticatedSessionController.php | 14 +- .../Auth/ConfirmablePasswordController.php | 41 ---- .../ConfirmsTwoFactorAuthentication.php | 71 ++++++ .../TwoFactorAuthenticationController.php | 47 ++++ app/Http/Requests/Auth/LoginRequest.php | 14 +- app/Models/User.php | 3 +- app/Providers/FortifyServiceProvider.php | 39 ++++ bootstrap/providers.php | 1 + composer.json | 1 + config/fortify.php | 159 +++++++++++++ ..._add_two_factor_columns_to_users_table.php | 42 ++++ package.json | 4 +- .../components/two-factor-recovery-codes.tsx | 97 ++++++++ .../js/components/two-factor-setup-modal.tsx | 210 ++++++++++++++++++ resources/js/components/ui/badge.tsx | 4 +- resources/js/components/ui/input-otp.tsx | 75 +++++++ resources/js/hooks/use-two-factor-auth.tsx | 73 ++++++ resources/js/layouts/settings/layout.tsx | 5 + resources/js/pages/auth/confirm-password.tsx | 2 +- .../js/pages/auth/two-factor-challenge.tsx | 131 +++++++++++ resources/js/pages/settings/two-factor.tsx | 94 ++++++++ resources/js/types/index.d.ts | 21 ++ routes/auth.php | 7 - routes/settings.php | 4 + 25 files changed, 1102 insertions(+), 58 deletions(-) delete mode 100644 app/Http/Controllers/Auth/ConfirmablePasswordController.php create mode 100644 app/Http/Controllers/Concerns/ConfirmsTwoFactorAuthentication.php create mode 100644 app/Http/Controllers/Settings/TwoFactorAuthenticationController.php create mode 100644 app/Providers/FortifyServiceProvider.php create mode 100644 config/fortify.php create mode 100644 database/migrations/2025_08_26_100418_add_two_factor_columns_to_users_table.php create mode 100644 resources/js/components/two-factor-recovery-codes.tsx create mode 100644 resources/js/components/two-factor-setup-modal.tsx create mode 100644 resources/js/components/ui/input-otp.tsx create mode 100644 resources/js/hooks/use-two-factor-auth.tsx create mode 100644 resources/js/pages/auth/two-factor-challenge.tsx create mode 100644 resources/js/pages/settings/two-factor.tsx diff --git a/.gitignore b/.gitignore index c625a11f7..afac65771 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ /storage/*.key /storage/pail /vendor +.DS_Store .env .env.backup .env.production diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php index b4a48d946..7958747e4 100644 --- a/app/Http/Controllers/Auth/AuthenticatedSessionController.php +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -10,6 +10,7 @@ use Illuminate\Support\Facades\Route; use Inertia\Inertia; use Inertia\Response; +use Laravel\Fortify\Features; class AuthenticatedSessionController extends Controller { @@ -29,7 +30,18 @@ public function create(Request $request): Response */ public function store(LoginRequest $request): RedirectResponse { - $request->authenticate(); + $user = $request->validateCredentials(); + + if (Features::enabled(Features::twoFactorAuthentication()) && $user->hasEnabledTwoFactorAuthentication()) { + $request->session()->put([ + 'login.id' => $user->getKey(), + 'login.remember' => $request->boolean('remember'), + ]); + + return redirect()->route('two-factor.login'); + } + + Auth::login($user, $request->boolean('remember')); $request->session()->regenerate(); diff --git a/app/Http/Controllers/Auth/ConfirmablePasswordController.php b/app/Http/Controllers/Auth/ConfirmablePasswordController.php deleted file mode 100644 index c729706d6..000000000 --- a/app/Http/Controllers/Auth/ConfirmablePasswordController.php +++ /dev/null @@ -1,41 +0,0 @@ -validate([ - 'email' => $request->user()->email, - 'password' => $request->password, - ])) { - throw ValidationException::withMessages([ - 'password' => __('auth.password'), - ]); - } - - $request->session()->put('auth.password_confirmed_at', time()); - - return redirect()->intended(route('dashboard', absolute: false)); - } -} diff --git a/app/Http/Controllers/Concerns/ConfirmsTwoFactorAuthentication.php b/app/Http/Controllers/Concerns/ConfirmsTwoFactorAuthentication.php new file mode 100644 index 000000000..cee9ab8ed --- /dev/null +++ b/app/Http/Controllers/Concerns/ConfirmsTwoFactorAuthentication.php @@ -0,0 +1,71 @@ +twoFactorAuthenticationDisabled($request)) { + $request->session()->put('two_factor_empty_at', $currentTime); + } + + // If was previously totally disabled this session but is now confirming, notate time... + if ($this->hasJustBegunConfirmingTwoFactorAuthentication($request)) { + $request->session()->put('two_factor_confirming_at', $currentTime); + } + + // If the profile is reloaded and is not confirmed but was previously in confirming state, disable... + if ($this->neverFinishedConfirmingTwoFactorAuthentication($request, $currentTime)) { + app(DisableTwoFactorAuthentication::class)(Auth::user()); + + $request->session()->put('two_factor_empty_at', $currentTime); + $request->session()->remove('two_factor_confirming_at'); + } + } + + /** + * Determine if two-factor authentication is totally disabled. + */ + protected function twoFactorAuthenticationDisabled(Request $request): bool + { + return is_null($request->user()->two_factor_secret) && + is_null($request->user()->two_factor_confirmed_at); + } + + /** + * Determine if two-factor authentication is just now being confirmed within the last request cycle. + */ + protected function hasJustBegunConfirmingTwoFactorAuthentication(Request $request): bool + { + return ! is_null($request->user()->two_factor_secret) && + is_null($request->user()->two_factor_confirmed_at) && + $request->session()->has('two_factor_empty_at') && + is_null($request->session()->get('two_factor_confirming_at')); + } + + /** + * Determine if two-factor authentication was never totally confirmed once confirmation started. + */ + protected function neverFinishedConfirmingTwoFactorAuthentication(Request $request, int $currentTime): bool + { + return ! array_key_exists('code', $request->session()->getOldInput()) && + is_null($request->user()->two_factor_confirmed_at) && + $request->session()->get('two_factor_confirming_at', 0) != $currentTime; + } +} diff --git a/app/Http/Controllers/Settings/TwoFactorAuthenticationController.php b/app/Http/Controllers/Settings/TwoFactorAuthenticationController.php new file mode 100644 index 000000000..be26c0f70 --- /dev/null +++ b/app/Http/Controllers/Settings/TwoFactorAuthenticationController.php @@ -0,0 +1,47 @@ +validateTwoFactorAuthenticationState($request); + + return Inertia::render('settings/two-factor', [ + 'requiresConfirmation' => Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'), + 'twoFactorEnabled' => $request->user()->hasEnabledTwoFactorAuthentication(), + ]); + } +} diff --git a/app/Http/Requests/Auth/LoginRequest.php b/app/Http/Requests/Auth/LoginRequest.php index feef49848..ecb66a643 100644 --- a/app/Http/Requests/Auth/LoginRequest.php +++ b/app/Http/Requests/Auth/LoginRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests\Auth; +use App\Models\User; use Illuminate\Auth\Events\Lockout; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Facades\Auth; @@ -32,15 +33,18 @@ public function rules(): array } /** - * Attempt to authenticate the request's credentials. + * Validate the request's credentials and return the user without logging them in. * * @throws \Illuminate\Validation\ValidationException */ - public function authenticate(): void + public function validateCredentials(): User { $this->ensureIsNotRateLimited(); - if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) { + /** @var User $user */ + $user = Auth::getProvider()->retrieveByCredentials($this->only('email', 'password')); + + if (! $user || ! Auth::getProvider()->validateCredentials($user, $this->only('password'))) { RateLimiter::hit($this->throttleKey()); throw ValidationException::withMessages([ @@ -49,6 +53,8 @@ public function authenticate(): void } RateLimiter::clear($this->throttleKey()); + + return $user; } /** @@ -75,7 +81,7 @@ public function ensureIsNotRateLimited(): void } /** - * Get the rate limiting throttle key for the request. + * Get the rate-limiting throttle key for the request. */ public function throttleKey(): string { diff --git a/app/Models/User.php b/app/Models/User.php index 749c7b77d..dea2e926e 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -6,11 +6,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +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. diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php new file mode 100644 index 000000000..397338582 --- /dev/null +++ b/app/Providers/FortifyServiceProvider.php @@ -0,0 +1,39 @@ +by($request->session()->get('login.id')); + }); + } +} diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 38b258d18..0ad9c5732 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -2,4 +2,5 @@ return [ App\Providers\AppServiceProvider::class, + App\Providers\FortifyServiceProvider::class, ]; diff --git a/composer.json b/composer.json index a53557207..0b9703e0a 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "require": { "php": "^8.2", "inertiajs/inertia-laravel": "^2.0", + "laravel/fortify": "^1.29", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1", "laravel/wayfinder": "^0.1.9" diff --git a/config/fortify.php b/config/fortify.php new file mode 100644 index 000000000..df49e4f8c --- /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' => '/dashboard', + + /* + |-------------------------------------------------------------------------- + | 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_08_26_100418_add_two_factor_columns_to_users_table.php b/database/migrations/2025_08_26_100418_add_two_factor_columns_to_users_table.php new file mode 100644 index 000000000..45739efa6 --- /dev/null +++ b/database/migrations/2025_08_26_100418_add_two_factor_columns_to_users_table.php @@ -0,0 +1,42 @@ +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', + ]); + }); + } +}; diff --git a/package.json b/package.json index 04e6b6527..4d6bb6a34 100644 --- a/package.json +++ b/package.json @@ -35,10 +35,11 @@ "@radix-ui/react-navigation-menu": "^1.2.5", "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.2", - "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.8", + "@reactuses/core": "^6.0.6", "@tailwindcss/vite": "^4.1.11", "@types/react": "^19.0.3", "@types/react-dom": "^19.0.2", @@ -47,6 +48,7 @@ "clsx": "^2.1.1", "concurrently": "^9.0.1", "globals": "^15.14.0", + "input-otp": "^1.4.2", "laravel-vite-plugin": "^2.0", "lucide-react": "^0.475.0", "react": "^19.0.0", diff --git a/resources/js/components/two-factor-recovery-codes.tsx b/resources/js/components/two-factor-recovery-codes.tsx new file mode 100644 index 000000000..53a2ea520 --- /dev/null +++ b/resources/js/components/two-factor-recovery-codes.tsx @@ -0,0 +1,97 @@ +import { regenerateRecoveryCodes } from '@/routes/two-factor'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { useTwoFactorAuth } from '@/hooks/use-two-factor-auth'; +import { Form } from '@inertiajs/react'; +import { Eye, EyeOff, LockKeyhole, RefreshCw } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; + +export default function TwoFactorRecoveryCodes() { + const { recoveryCodesList, fetchRecoveryCodes } = useTwoFactorAuth(); + const [isRecoveryCodesVisible, setIsRecoveryCodesVisible] = useState(false); + const recoveryCodeSectionRef = useRef(null); + + const toggleRecoveryCodesVisibility = async () => { + if (!isRecoveryCodesVisible && !recoveryCodesList.length) { + await fetchRecoveryCodes(); + } + + setIsRecoveryCodesVisible(!isRecoveryCodesVisible); + + if (!isRecoveryCodesVisible) { + setTimeout(() => { + recoveryCodeSectionRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, 0); + } + }; + + useEffect(() => { + if (!recoveryCodesList.length) { + fetchRecoveryCodes(); + } + }, [recoveryCodesList.length, fetchRecoveryCodes]); + + return ( + + + + + 2FA Recovery Codes + + + Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager. + + + +
+ + + {isRecoveryCodesVisible && ( +
+ {({ processing }) => ( + + )} +
+ )} +
+
+
+
+ {!recoveryCodesList.length ? ( +
+ {Array.from({ length: 8 }, (_, n) => ( +
+ ))} +
+ ) : ( + recoveryCodesList.map((code, index) => ( +
+ {code} +
+ )) + )} +
+

+ Each can be used once to access your account and will be removed after use. If you need more, click{' '} + Regenerate Codes above. +

+
+
+ + + ); +} diff --git a/resources/js/components/two-factor-setup-modal.tsx b/resources/js/components/two-factor-setup-modal.tsx new file mode 100644 index 000000000..827f70daa --- /dev/null +++ b/resources/js/components/two-factor-setup-modal.tsx @@ -0,0 +1,210 @@ +import InputError from '@/components/input-error'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp'; +import { useTwoFactorAuth } from '@/hooks/use-two-factor-auth'; +import { confirm } from '@/routes/two-factor'; +import { Form } from '@inertiajs/react'; +import { useClipboard } from '@reactuses/core'; +import { REGEXP_ONLY_DIGITS } from 'input-otp'; +import { Check, Copy, Loader2, ScanLine } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +interface TwoFactorSetupModalProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + requiresConfirmation: boolean; + twoFactorEnabled: boolean; +} + +export default function TwoFactorSetupModal({ isOpen, onOpenChange, requiresConfirmation, twoFactorEnabled }: TwoFactorSetupModalProps) { + const { qrCodeSvg, manualSetupKey, clearSetupData, fetchSetupData } = useTwoFactorAuth(); + const [copiedText, copy] = useClipboard(); + + const [showVerificationStep, setShowVerificationStep] = useState(false); + const [code, setCode] = useState(''); + const codeValue = useMemo(() => code, [code]); + + const pinInputContainerRef = useRef(null); + + const modalConfig = useMemo<{ title: string; description: string; buttonText: string }>(() => { + if (twoFactorEnabled) { + return { + title: 'You have enabled two factor authentication.', + description: 'Two factor authentication is now enabled, scan the QR code or enter the setup key in authenticator app.', + buttonText: 'Close', + }; + } + + if (showVerificationStep) { + return { + title: 'Verify Authentication Code', + description: 'Enter the 6-digit code from your authenticator app', + buttonText: 'Continue', + }; + } + + return { + title: 'Turn on 2-step Verification', + description: 'To finish enabling two factor authentication, scan the QR code or enter the setup key in authenticator app', + buttonText: 'Continue', + }; + }, [twoFactorEnabled, showVerificationStep]); + + const handleModalNextStep = () => { + if (requiresConfirmation) { + setShowVerificationStep(true); + setTimeout(() => { + pinInputContainerRef.current?.querySelector('input')?.focus(); + }, 0); + return; + } + clearSetupData(); + onOpenChange(false); + }; + + const resetModalState = useCallback(() => { + if (twoFactorEnabled) { + clearSetupData(); + } + setShowVerificationStep(false); + setCode(''); + }, [twoFactorEnabled, clearSetupData]); + + useEffect(() => { + if (!isOpen) { + resetModalState(); + return; + } + + if (!qrCodeSvg) { + fetchSetupData().then(); + } + }, [isOpen, qrCodeSvg, fetchSetupData, resetModalState]); + + return ( + + + +
+
+
+ {Array.from({ length: 5 }, (_, i) => ( +
+ ))} +
+
+ {Array.from({ length: 5 }, (_, i) => ( +
+ ))} +
+ +
+
+ {modalConfig.title} + {modalConfig.description} + + +
+ {!showVerificationStep ? ( + <> +
+
+ {!qrCodeSvg ? ( +
+ +
+ ) : ( +
+
+
+ )} +
+
+ +
+ +
+ +
+
+ or, enter the code manually +
+ +
+
+ {!manualSetupKey ? ( +
+ +
+ ) : ( + <> + + + + )} +
+
+ + ) : ( +
setCode('')} onSuccess={() => onOpenChange(false)} resetOnError> + {({ processing, errors }: { processing: boolean; errors?: { confirmTwoFactorAuthentication?: { code?: string } } }) => ( + <> + +
+
+ + + {Array.from({ length: 6 }, (_, index) => ( + + ))} + + + +
+ +
+ + +
+
+ + )} +
+ )} +
+ +
+ ); +} diff --git a/resources/js/components/ui/badge.tsx b/resources/js/components/ui/badge.tsx index 268ea771c..02054139a 100644 --- a/resources/js/components/ui/badge.tsx +++ b/resources/js/components/ui/badge.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const badgeVariants = cva( - "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-auto", + "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", { variants: { variant: { @@ -14,7 +14,7 @@ const badgeVariants = cva( secondary: "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", destructive: - "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40", + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", }, diff --git a/resources/js/components/ui/input-otp.tsx b/resources/js/components/ui/input-otp.tsx new file mode 100644 index 000000000..310175285 --- /dev/null +++ b/resources/js/components/ui/input-otp.tsx @@ -0,0 +1,75 @@ +import * as React from "react" +import { OTPInput, OTPInputContext } from "input-otp" +import { MinusIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function InputOTP({ + className, + containerClassName, + ...props +}: React.ComponentProps & { + containerClassName?: string +}) { + return ( + + ) +} + +function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function InputOTPSlot({ + index, + className, + ...props +}: React.ComponentProps<"div"> & { + index: number +}) { + const inputOTPContext = React.useContext(OTPInputContext) + const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {} + + return ( +
+ {char} + {hasFakeCaret && ( +
+
+
+ )} +
+ ) +} + +function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) { + return ( +
+ +
+ ) +} + +export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } diff --git a/resources/js/hooks/use-two-factor-auth.tsx b/resources/js/hooks/use-two-factor-auth.tsx new file mode 100644 index 000000000..af41e24b7 --- /dev/null +++ b/resources/js/hooks/use-two-factor-auth.tsx @@ -0,0 +1,73 @@ +import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor'; +import { type TwoFactorSecretKey, type TwoFactorSetupData } from '@/types'; +import { useCallback, useMemo, useState } from 'react'; + +const fetchJson = async (url: string): Promise => { + const response = await fetch(url, { + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.status}`); + } + + return response.json(); +}; + +export const useTwoFactorAuth = () => { + const [qrCodeSvg, setQrCodeSvg] = useState(null); + const [manualSetupKey, setManualSetupKey] = useState(null); + const [recoveryCodesList, setRecoveryCodesList] = useState([]); + + const hasSetupData = useMemo( + () => qrCodeSvg !== null && manualSetupKey !== null, + [qrCodeSvg, manualSetupKey] + ); + + const fetchQrCode = async (): Promise => { + const { svg } = await fetchJson(qrCode.url()); + setQrCodeSvg(svg); + }; + + const fetchSetupKey = async (): Promise => { + const { secretKey: key } = await fetchJson(secretKey.url()); + setManualSetupKey(key); + }; + + const clearSetupData = useCallback((): void => { + setManualSetupKey(null); + setQrCodeSvg(null); + }, []); + + const fetchRecoveryCodes = async (): Promise => { + try { + const codes = await fetchJson(recoveryCodes.url()); + setRecoveryCodesList(codes); + } catch (error) { + console.error('Failed to fetch recovery codes:', error); + setRecoveryCodesList([]); + } + }; + + const fetchSetupData = useCallback(async (): Promise => { + try { + await Promise.all([fetchQrCode(), fetchSetupKey()]); + } catch (error) { + console.error('Failed to fetch setup data:', error); + setQrCodeSvg(null); + setManualSetupKey(null); + } + }, []); + + return { + qrCodeSvg, + manualSetupKey, + recoveryCodesList, + hasSetupData, + clearSetupData, + fetchQrCode, + fetchSetupKey, + fetchSetupData, + fetchRecoveryCodes, + }; +}; diff --git a/resources/js/layouts/settings/layout.tsx b/resources/js/layouts/settings/layout.tsx index 14f0a861c..f0b3635c0 100644 --- a/resources/js/layouts/settings/layout.tsx +++ b/resources/js/layouts/settings/layout.tsx @@ -4,6 +4,7 @@ import { Separator } from '@/components/ui/separator'; import { cn } from '@/lib/utils'; import { appearance } from '@/routes'; import { edit as editPassword } from '@/routes/password'; +import { show } from '@/routes/two-factor'; import { edit } from '@/routes/profile'; import { type NavItem } from '@/types'; import { Link } from '@inertiajs/react'; @@ -20,6 +21,10 @@ const sidebarNavItems: NavItem[] = [ href: editPassword(), icon: null, }, + { + title: 'Two-Factor Auth', + href: show(), + }, { title: 'Appearance', href: appearance(), diff --git a/resources/js/pages/auth/confirm-password.tsx b/resources/js/pages/auth/confirm-password.tsx index e771960bc..d137d1200 100644 --- a/resources/js/pages/auth/confirm-password.tsx +++ b/resources/js/pages/auth/confirm-password.tsx @@ -1,4 +1,4 @@ -import { store } from '@/actions/App/Http/Controllers/Auth/ConfirmablePasswordController'; +import { store } from '@/routes/password/confirm' import InputError from '@/components/input-error'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; diff --git a/resources/js/pages/auth/two-factor-challenge.tsx b/resources/js/pages/auth/two-factor-challenge.tsx new file mode 100644 index 000000000..320f907fc --- /dev/null +++ b/resources/js/pages/auth/two-factor-challenge.tsx @@ -0,0 +1,131 @@ +import { store } from '@/routes/two-factor/login'; +import InputError from '@/components/input-error'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp'; +import AuthLayout from '@/layouts/auth-layout'; +import { Form, Head } from '@inertiajs/react'; +import { REGEXP_ONLY_DIGITS } from 'input-otp'; +import { computed, ref, useMemo, useState } from 'react'; + +interface AuthConfigContent { + title: string; + description: string; + toggleText: string; +} + +export default function TwoFactorChallenge() { + const [showRecoveryInput, setShowRecoveryInput] = useState(false); + const [code, setCode] = useState([]); + + const codeValue = useMemo(() => code.join(''), [code]); + + const authConfigContent = useMemo(() => { + if (showRecoveryInput) { + return { + title: 'Recovery Code', + description: 'Please confirm access to your account by entering one of your emergency recovery codes.', + toggleText: 'login using an authentication code', + }; + } + + return { + title: 'Authentication Code', + description: 'Enter the authentication code provided by your authenticator application.', + toggleText: 'login using a recovery code', + }; + }, [showRecoveryInput]); + + const toggleRecoveryMode = (clearErrors: () => void): void => { + setShowRecoveryInput(!showRecoveryInput); + clearErrors(); + setCode([]); + }; + + return ( + + + +
+ {!showRecoveryInput ? ( +
setCode([])} + > + {({ errors, processing, clearErrors }) => ( + <> + +
+
+ setCode(value.split('').map(Number))} + disabled={processing} + pattern={REGEXP_ONLY_DIGITS} + > + + {Array.from({ length: 6 }, (_, index) => ( + + ))} + + +
+ +
+ +
+ or you can + +
+ + )} +
+ ) : ( +
+ {({ errors, processing, clearErrors }) => ( + <> + + + + +
+ or you can + +
+ + )} + + )} +
+
+ ); +} \ No newline at end of file diff --git a/resources/js/pages/settings/two-factor.tsx b/resources/js/pages/settings/two-factor.tsx new file mode 100644 index 000000000..836c74586 --- /dev/null +++ b/resources/js/pages/settings/two-factor.tsx @@ -0,0 +1,94 @@ +import HeadingSmall from '@/components/heading-small'; +import TwoFactorRecoveryCodes from '@/components/two-factor-recovery-codes'; +import TwoFactorSetupModal from '@/components/two-factor-setup-modal'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { useTwoFactorAuth } from '@/hooks/use-two-factor-auth'; +import AppLayout from '@/layouts/app-layout'; +import SettingsLayout from '@/layouts/settings/layout'; +import { disable, enable, show } from '@/routes/two-factor'; +import { type BreadcrumbItem } from '@/types'; +import { Form, Head } from '@inertiajs/react'; +import { ShieldBan, ShieldCheck } from 'lucide-react'; +import { useState } from 'react'; + +interface TwoFactorProps { + requiresConfirmation?: boolean; + twoFactorEnabled?: boolean; +} + +const breadcrumbs: BreadcrumbItem[] = [ + { + title: 'Two-Factor Authentication', + href: show.url(), + }, +]; + +export default function TwoFactor({ requiresConfirmation = false, twoFactorEnabled = false }: TwoFactorProps) { + const { hasSetupData } = useTwoFactorAuth(); + const [showSetupModal, setShowSetupModal] = useState(false); + + return ( + + + +
+ + + {!twoFactorEnabled ? ( +
+ Disabled +

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

+ +
+ {hasSetupData ? ( + + ) : ( +
setShowSetupModal(true)}> + {({ processing }) => ( + + )} +
+ )} +
+
+ ) : ( +
+ Enabled +

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

+ + + +
+
+ {({ processing }) => ( + + )} +
+
+
+ )} + + +
+
+
+ ); +} diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index 42f88e8d9..6f7d4cb6b 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -36,7 +36,28 @@ export interface User { email: string; avatar?: string; email_verified_at: string | null; + two_factor_enabled?: boolean; created_at: string; updated_at: string; [key: string]: unknown; // This allows for additional properties... } + +export interface TwoFactorSetupData { + svg: string; + url: string; +} + +export interface TwoFactorSecretKey { + secretKey: string; +} + +export interface TwoFactorAuthenticationError { + code?: string; +} + +export interface FormErrors { + [key: string]: string | FormErrors | undefined; + confirmTwoFactorAuthentication?: TwoFactorAuthenticationError; + code?: string; + recovery_code?: string; +} diff --git a/routes/auth.php b/routes/auth.php index 1351b3fb0..b1cf4426b 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -1,7 +1,6 @@ middleware('throttle:6,1') ->name('verification.send'); - Route::get('confirm-password', [ConfirmablePasswordController::class, 'show']) - ->name('password.confirm'); - - Route::post('confirm-password', [ConfirmablePasswordController::class, 'store']) - ->middleware('throttle:6,1'); - Route::post('logout', [AuthenticatedSessionController::class, 'destroy']) ->name('logout'); }); diff --git a/routes/settings.php b/routes/settings.php index 203b7cded..d6866ce1e 100644 --- a/routes/settings.php +++ b/routes/settings.php @@ -2,6 +2,7 @@ use App\Http\Controllers\Settings\PasswordController; use App\Http\Controllers\Settings\ProfileController; +use App\Http\Controllers\Settings\TwoFactorAuthenticationController; use Illuminate\Support\Facades\Route; use Inertia\Inertia; @@ -21,4 +22,7 @@ Route::get('settings/appearance', function () { return Inertia::render('settings/appearance'); })->name('appearance'); + + Route::get('settings/two-factor', [TwoFactorAuthenticationController::class, 'show']) + ->name('two-factor.show'); }); From a822d3d788ee83742104f6897e8f7644372fcf83 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 26 Aug 2025 20:18:08 +0530 Subject: [PATCH 02/31] feat: update package-lock --- package-lock.json | 207 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 206 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 09de92080..d66611cfb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,10 +16,11 @@ "@radix-ui/react-navigation-menu": "^1.2.5", "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.2", - "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.8", + "@reactuses/core": "^6.0.6", "@tailwindcss/vite": "^4.1.11", "@types/react": "^19.0.3", "@types/react-dom": "^19.0.2", @@ -28,6 +29,7 @@ "clsx": "^2.1.1", "concurrently": "^9.0.1", "globals": "^15.14.0", + "input-otp": "^1.4.2", "laravel-vite-plugin": "^2.0", "lucide-react": "^0.475.0", "react": "^19.0.0", @@ -271,6 +273,14 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz", + "integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -1053,6 +1063,11 @@ "integrity": "sha512-S/21Lzl7lci7LrRo/VsN5AXT02AMf7rs+OPTyt3VPgffBB1wTrzwsPr28sCU0gcR/APhfC1eVIUwpLbAvBmyKw==", "dev": true }, + "node_modules/@microsoft/fetch-event-source": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz", + "integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2068,6 +2083,28 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/@reactuses/core": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/@reactuses/core/-/core-6.0.6.tgz", + "integrity": "sha512-2tBQUHfMmGHhLlZNhb9uC/Ai82UnqRJC7N5V3ArMVbffkson3IPgQtPPdWIdBXTjvBnVlIRaMH11WA/W7Ovcag==", + "dependencies": { + "@microsoft/fetch-event-source": "^2.0.1", + "@testing-library/dom": "^10.4.0", + "js-cookie": "^3.0.5", + "lodash-es": "^4.17.21", + "screenfull": "^5.0.0", + "use-sync-external-store": "^1.2.0" + }, + "peerDependencies": { + "qrcode": "^1.5", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "qrcode": { + "optional": true + } + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -2525,6 +2562,60 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.4.5", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.0.4", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.4.5", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.0.4", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.0", + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.12.tgz", @@ -2593,6 +2684,29 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3018,6 +3132,14 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -3582,6 +3704,14 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", @@ -3607,6 +3737,11 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -4549,6 +4684,15 @@ "node": ">=0.8.19" } }, + "node_modules/input-otp": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz", + "integrity": "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==", + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -4974,6 +5118,14 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5323,6 +5475,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5357,6 +5514,14 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", @@ -5870,6 +6035,35 @@ } } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -6252,6 +6446,17 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==" }, + "node_modules/screenfull": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/screenfull/-/screenfull-5.2.0.tgz", + "integrity": "sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==", + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", From ee5f641549065719a47b6fa6ab9270dd10a60cef Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 26 Aug 2025 20:45:56 +0530 Subject: [PATCH 03/31] feat: add tests --- tests/Feature/Auth/AuthenticationTest.php | 58 ++++- .../Feature/Auth/PasswordConfirmationTest.php | 29 +-- tests/Feature/Auth/TwoFactorChallengeTest.php | 93 ++++++++ .../Settings/TwoFactorAuthenticationTest.php | 209 ++++++++++++++++++ 4 files changed, 361 insertions(+), 28 deletions(-) create mode 100644 tests/Feature/Auth/TwoFactorChallengeTest.php create mode 100644 tests/Feature/Settings/TwoFactorAuthenticationTest.php diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index a53c560a8..3936b37c6 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 Tests\TestCase; class AuthenticationTest extends TestCase @@ -12,7 +13,7 @@ class AuthenticationTest extends TestCase public function test_login_screen_can_be_rendered() { - $response = $this->get('/login'); + $response = $this->get(route('login')); $response->assertStatus(200); } @@ -21,7 +22,7 @@ public function test_users_can_authenticate_using_the_login_screen() { $user = User::factory()->create(); - $response = $this->post('/login', [ + $response = $this->post(route('login'), [ 'email' => $user->email, 'password' => 'password', ]); @@ -30,11 +31,54 @@ public function test_users_can_authenticate_using_the_login_screen() $response->assertRedirect(route('dashboard', absolute: false)); } + public function test_users_with_two_factor_enabled_are_redirected_to_two_factor_challenge() + { + 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 = $this->post(route('login'), [ + 'email' => $user->email, + 'password' => 'password', + ]); + + $response->assertRedirect(route('two-factor.login')); + $response->assertSessionHas('login.id', $user->id); + $this->assertGuest(); + } + + public function test_users_without_two_factor_enabled_login_normally() + { + $user = User::factory()->create(); + + $response = $this->post(route('login'), [ + 'email' => $user->email, + 'password' => 'password', + ]); + + $this->assertAuthenticated(); + $response->assertRedirect(route('dashboard', absolute: false)); + $response->assertSessionMissing('login.id'); + } + public function test_users_can_not_authenticate_with_invalid_password() { $user = User::factory()->create(); - $this->post('/login', [ + $this->post(route('login'), [ 'email' => $user->email, 'password' => 'wrong-password', ]); @@ -46,10 +90,10 @@ public function test_users_can_logout() { $user = User::factory()->create(); - $response = $this->actingAs($user)->post('/logout'); + $response = $this->actingAs($user)->post(route('logout')); $this->assertGuest(); - $response->assertRedirect('/'); + $response->assertRedirect(route('home')); } public function test_users_are_rate_limited() @@ -57,7 +101,7 @@ public function test_users_are_rate_limited() $user = User::factory()->create(); for ($i = 0; $i < 5; $i++) { - $this->post('/login', [ + $this->post(route('login'), [ 'email' => $user->email, 'password' => 'wrong-password', ])->assertStatus(302)->assertSessionHasErrors([ @@ -65,7 +109,7 @@ public function test_users_are_rate_limited() ]); } - $response = $this->post('/login', [ + $response = $this->post(route('login'), [ 'email' => $user->email, 'password' => 'wrong-password', ]); diff --git a/tests/Feature/Auth/PasswordConfirmationTest.php b/tests/Feature/Auth/PasswordConfirmationTest.php index d2072ffd4..347fbe1ab 100644 --- a/tests/Feature/Auth/PasswordConfirmationTest.php +++ b/tests/Feature/Auth/PasswordConfirmationTest.php @@ -4,6 +4,7 @@ use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Inertia\Testing\AssertableInertia as Assert; use Tests\TestCase; class PasswordConfirmationTest extends TestCase @@ -14,31 +15,17 @@ public function test_confirm_password_screen_can_be_rendered() { $user = User::factory()->create(); - $response = $this->actingAs($user)->get('/confirm-password'); + $response = $this->actingAs($user)->get(route('password.confirm')); $response->assertStatus(200); + $response->assertInertia(fn (Assert $page) => $page + ->component('auth/confirm-password') + ); } - public function test_password_can_be_confirmed() + public function test_password_confirmation_requires_authentication() { - $user = User::factory()->create(); - - $response = $this->actingAs($user)->post('/confirm-password', [ - 'password' => 'password', - ]); - - $response->assertRedirect(); - $response->assertSessionHasNoErrors(); - } - - public function test_password_is_not_confirmed_with_invalid_password() - { - $user = User::factory()->create(); - - $response = $this->actingAs($user)->post('/confirm-password', [ - 'password' => 'wrong-password', - ]); - - $response->assertSessionHasErrors(); + $response = $this->get(route('password.confirm')); + $response->assertRedirect(route('login')); } } diff --git a/tests/Feature/Auth/TwoFactorChallengeTest.php b/tests/Feature/Auth/TwoFactorChallengeTest.php new file mode 100644 index 000000000..2652a36ef --- /dev/null +++ b/tests/Feature/Auth/TwoFactorChallengeTest.php @@ -0,0 +1,93 @@ +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_inertia_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(); + + $this->post(route('login'), [ + 'email' => $user->email, + 'password' => 'password', + ]); + + $this->get(route('two-factor.login')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('auth/two-factor-challenge') + ); + } + + 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(); + + $this->post(route('login'), [ + 'email' => $user->email, + 'password' => 'password', + ]); + + foreach (range(0, 4) as $ignored) { + $this->post(route('two-factor.login.store'), [ + 'code' => '000000', + ])->assertSessionHasErrors('code'); + } + + $response = $this->post(route('two-factor.login.store'), [ + 'code' => '000000', + ]); + + $response->assertTooManyRequests(); + } +} diff --git a/tests/Feature/Settings/TwoFactorAuthenticationTest.php b/tests/Feature/Settings/TwoFactorAuthenticationTest.php new file mode 100644 index 000000000..882d77daf --- /dev/null +++ b/tests/Feature/Settings/TwoFactorAuthenticationTest.php @@ -0,0 +1,209 @@ +markTestSkipped('Two factor authentication is not enabled.'); + } + + Features::twoFactorAuthentication([ + 'confirm' => true, + 'confirmPassword' => true, + ]); + + $user = User::factory()->create(); + + $this->actingAs($user) + ->withSession(['auth.password_confirmed_at' => time()]) + ->get(route('two-factor.show')) + ->assertInertia(fn (Assert $page) => $page + ->component('settings/two-factor') + ->where('twoFactorEnabled', false) + ); + } + + public function test_two_factor_settings_page_requires_password_confirmation() + { + if (! Features::canManageTwoFactorAuthentication()) { + $this->markTestSkipped('Two factor authentication is not enabled.'); + } + + $user = User::factory()->create(); + + Features::twoFactorAuthentication([ + 'confirm' => true, + 'confirmPassword' => true, + ]); + + $response = $this->actingAs($user) + ->get(route('two-factor.show')); + + $response->assertRedirect(route('password.confirm')); + } + + public function test_two_factor_settings_page_does_not_requires_password_confirmation() + { + if (! Features::canManageTwoFactorAuthentication()) { + $this->markTestSkipped('Two factor authentication is not enabled.'); + } + + $user = User::factory()->create(); + + Features::twoFactorAuthentication([ + 'confirm' => true, + 'confirmPassword' => false, + ]); + + $this->actingAs($user) + ->get(route('two-factor.show')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('settings/two-factor') + ); + } + + public function test_two_factor_settings_page_returns_forbidden_when_two_factor_is_disabled() + { + if (! Features::canManageTwoFactorAuthentication()) { + $this->markTestSkipped('Two factor authentication is not enabled.'); + } + + config(['fortify.features' => []]); + + $user = User::factory()->create(); + + $this->actingAs($user) + ->withSession(['auth.password_confirmed_at' => time()]) + ->get(route('two-factor.show')) + ->assertForbidden(); + } + + public function test_sets_confirming_session_when_enabling_two_factor_with_confirmation() + { + if (! Features::canManageTwoFactorAuthentication()) { + $this->markTestSkipped('Two factor authentication is not enabled.'); + } + + Features::twoFactorAuthentication([ + 'confirm' => true, + 'confirmPassword' => false, + ]); + + $user = User::factory()->create(); + + $this->actingAs($user) + ->withSession(['auth.password_confirmed_at' => time()]) + ->withSession(['two_factor_empty_at' => time() - 10]) + ->post(route('two-factor.enable')); + + $this->get(route('two-factor.show')) + ->assertOk(); + + $this->assertNotNull(session('two_factor_confirming_at')); + } + + public function test_user_can_view_setting_page_when_confirm_disabled() + { + if (! Features::canManageTwoFactorAuthentication()) { + $this->markTestSkipped('Two factor authentication is not enabled.'); + } + + Features::twoFactorAuthentication([ + 'confirm' => false, + 'confirmPassword' => false, + ]); + + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('two-factor.show')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('settings/two-factor') + ->where('requiresConfirmation', false) + ); + } + + public function test_sets_empty_session_when_transitioning_to_disabled_state() + { + if (! Features::canManageTwoFactorAuthentication()) { + $this->markTestSkipped('Two factor authentication is not enabled.'); + } + + Features::twoFactorAuthentication([ + 'confirm' => true, + 'confirmPassword' => false, + ]); + + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('two-factor.show')) + ->assertSessionHas('two_factor_empty_at'); + } + + public function test_removes_confirming_session_when_cleanup_triggered() + { + if (! Features::canManageTwoFactorAuthentication()) { + $this->markTestSkipped('Two factor authentication is not enabled.'); + } + + Features::twoFactorAuthentication([ + 'confirm' => true, + 'confirmPassword' => false, + ]); + + $user = User::factory()->create(); + $user->forceFill([ + 'two_factor_secret' => encrypt('test-secret'), + 'two_factor_recovery_codes' => encrypt(json_encode(['code1', 'code2'])), + ])->save(); + + $this->actingAs($user) + ->withSession(['two_factor_confirming_at' => time() - 100]) + ->get(route('two-factor.show')) + ->assertSessionMissing('two_factor_confirming_at') + ->assertSessionHas('two_factor_empty_at'); + } + + public function test_disables_two_factor_when_confirmation_abandoned_between_requests() + { + if (! Features::canManageTwoFactorAuthentication()) { + $this->markTestSkipped('Two factor authentication is not enabled.'); + } + + Features::twoFactorAuthentication([ + 'confirm' => true, + 'confirmPassword' => false, + ]); + + $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) + ->withSession(['two_factor_confirming_at' => time() - 100]) + ->get(route('two-factor.show')); + + $this->assertDatabaseHas('users', [ + 'id' => $user->id, + 'two_factor_secret' => null, + 'two_factor_recovery_codes' => null, + ]); + } +} From 29c0df1a01d99c7478035ba5b0217ab85025476d Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 26 Aug 2025 21:28:21 +0530 Subject: [PATCH 04/31] linting changes --- .../components/two-factor-recovery-codes.tsx | 14 ++------- .../js/pages/auth/two-factor-challenge.tsx | 29 +++++-------------- resources/js/pages/settings/two-factor.tsx | 13 +++++---- 3 files changed, 18 insertions(+), 38 deletions(-) diff --git a/resources/js/components/two-factor-recovery-codes.tsx b/resources/js/components/two-factor-recovery-codes.tsx index 53a2ea520..8f25da596 100644 --- a/resources/js/components/two-factor-recovery-codes.tsx +++ b/resources/js/components/two-factor-recovery-codes.tsx @@ -1,7 +1,7 @@ -import { regenerateRecoveryCodes } from '@/routes/two-factor'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { useTwoFactorAuth } from '@/hooks/use-two-factor-auth'; +import { regenerateRecoveryCodes } from '@/routes/two-factor'; import { Form } from '@inertiajs/react'; import { Eye, EyeOff, LockKeyhole, RefreshCw } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; @@ -50,11 +50,7 @@ export default function TwoFactorRecoveryCodes() { {isRecoveryCodesVisible && ( -
+ {({ processing }) => (
) : ( - recoveryCodesList.map((code, index) => ( -
- {code} -
- )) + recoveryCodesList.map((code, index) =>
{code}
) )}

diff --git a/resources/js/pages/auth/two-factor-challenge.tsx b/resources/js/pages/auth/two-factor-challenge.tsx index 320f907fc..54e66d276 100644 --- a/resources/js/pages/auth/two-factor-challenge.tsx +++ b/resources/js/pages/auth/two-factor-challenge.tsx @@ -1,12 +1,12 @@ -import { store } from '@/routes/two-factor/login'; import InputError from '@/components/input-error'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp'; import AuthLayout from '@/layouts/auth-layout'; +import { store } from '@/routes/two-factor/login'; import { Form, Head } from '@inertiajs/react'; import { REGEXP_ONLY_DIGITS } from 'input-otp'; -import { computed, ref, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; interface AuthConfigContent { title: string; @@ -17,7 +17,7 @@ interface AuthConfigContent { export default function TwoFactorChallenge() { const [showRecoveryInput, setShowRecoveryInput] = useState(false); const [code, setCode] = useState([]); - + const codeValue = useMemo(() => code.join(''), [code]); const authConfigContent = useMemo(() => { @@ -48,12 +48,7 @@ export default function TwoFactorChallenge() {

{!showRecoveryInput ? ( - setCode([])} - > + setCode([])}> {({ errors, processing, clearErrors }) => ( <> @@ -92,20 +87,10 @@ export default function TwoFactorChallenge() { )} ) : ( -
+ {({ errors, processing, clearErrors }) => ( <> - +
); -} \ No newline at end of file +} diff --git a/resources/js/pages/settings/two-factor.tsx b/resources/js/pages/settings/two-factor.tsx index 836c74586..0512970ba 100644 --- a/resources/js/pages/settings/two-factor.tsx +++ b/resources/js/pages/settings/two-factor.tsx @@ -46,13 +46,15 @@ export default function TwoFactor({ requiresConfirmation = false, twoFactorEnabl
{hasSetupData ? ( ) : ( setShowSetupModal(true)}> {({ processing }) => ( )} @@ -63,8 +65,8 @@ export default function TwoFactor({ requiresConfirmation = false, twoFactorEnabl
Enabled

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

@@ -73,7 +75,8 @@ export default function TwoFactor({ requiresConfirmation = false, twoFactorEnabl
{({ processing }) => ( )}
From bf996b7de3327d8e637b86be8a44e03be7422640 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Fri, 29 Aug 2025 18:14:13 +0530 Subject: [PATCH 05/31] feat: enhance two-factor authentication implementation and improve code consistency --- .../Auth/AuthenticatedSessionController.php | 2 +- .../TwoFactorAuthenticationController.php | 4 +- app/Providers/FortifyServiceProvider.php | 8 +- ..._add_two_factor_columns_to_users_table.php | 14 +- .../components/two-factor-recovery-codes.tsx | 4 +- .../js/components/two-factor-setup-modal.tsx | 12 +- resources/js/hooks/use-two-factor-auth.tsx | 69 ++++++++-- .../js/pages/auth/two-factor-challenge.tsx | 2 +- resources/js/pages/settings/two-factor.tsx | 122 ++++++++++-------- tests/Feature/Auth/AuthenticationTest.php | 2 +- tests/Feature/Auth/TwoFactorChallengeTest.php | 6 +- .../Settings/TwoFactorAuthenticationTest.php | 28 ++-- 12 files changed, 155 insertions(+), 118 deletions(-) diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php index 7958747e4..80da6826b 100644 --- a/app/Http/Controllers/Auth/AuthenticatedSessionController.php +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -38,7 +38,7 @@ public function store(LoginRequest $request): RedirectResponse 'login.remember' => $request->boolean('remember'), ]); - return redirect()->route('two-factor.login'); + return to_route('two-factor.login'); } Auth::login($user, $request->boolean('remember')); diff --git a/app/Http/Controllers/Settings/TwoFactorAuthenticationController.php b/app/Http/Controllers/Settings/TwoFactorAuthenticationController.php index be26c0f70..f2801cd11 100644 --- a/app/Http/Controllers/Settings/TwoFactorAuthenticationController.php +++ b/app/Http/Controllers/Settings/TwoFactorAuthenticationController.php @@ -34,14 +34,14 @@ public function show(Request $request): Response abort_if( ! Features::enabled(Features::twoFactorAuthentication()), HttpResponse::HTTP_FORBIDDEN, - 'Two factor authentication is disabled.' + 'Two-factor authentication is disabled.' ); $this->validateTwoFactorAuthenticationState($request); return Inertia::render('settings/two-factor', [ - 'requiresConfirmation' => Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'), 'twoFactorEnabled' => $request->user()->hasEnabledTwoFactorAuthentication(), + 'requiresConfirmation' => Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'), ]); } } diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 397338582..eead2ab5d 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -24,13 +24,9 @@ public function register(): void */ public function boot(): void { - Fortify::twoFactorChallengeView(function () { - return Inertia::render('auth/two-factor-challenge'); - }); + Fortify::twoFactorChallengeView(fn() => Inertia::render('auth/two-factor-challenge')); - Fortify::confirmPasswordView(function () { - return Inertia::render('auth/confirm-password'); - }); + Fortify::confirmPasswordView(fn() => Inertia::render('auth/confirm-password')); RateLimiter::for('two-factor', function (Request $request) { return Limit::perMinute(5)->by($request->session()->get('login.id')); diff --git a/database/migrations/2025_08_26_100418_add_two_factor_columns_to_users_table.php b/database/migrations/2025_08_26_100418_add_two_factor_columns_to_users_table.php index 45739efa6..187d974d6 100644 --- a/database/migrations/2025_08_26_100418_add_two_factor_columns_to_users_table.php +++ b/database/migrations/2025_08_26_100418_add_two_factor_columns_to_users_table.php @@ -12,17 +12,9 @@ public function up(): void { Schema::table('users', function (Blueprint $table) { - $table->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(); + $table->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(); }); } diff --git a/resources/js/components/two-factor-recovery-codes.tsx b/resources/js/components/two-factor-recovery-codes.tsx index 8f25da596..8f30d9622 100644 --- a/resources/js/components/two-factor-recovery-codes.tsx +++ b/resources/js/components/two-factor-recovery-codes.tsx @@ -1,13 +1,13 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { useTwoFactorAuth } from '@/hooks/use-two-factor-auth'; +import { useTwoFactorAuthContext } from '@/hooks/use-two-factor-auth'; import { regenerateRecoveryCodes } from '@/routes/two-factor'; import { Form } from '@inertiajs/react'; import { Eye, EyeOff, LockKeyhole, RefreshCw } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; export default function TwoFactorRecoveryCodes() { - const { recoveryCodesList, fetchRecoveryCodes } = useTwoFactorAuth(); + const { recoveryCodesList, fetchRecoveryCodes } = useTwoFactorAuthContext(); const [isRecoveryCodesVisible, setIsRecoveryCodesVisible] = useState(false); const recoveryCodeSectionRef = useRef(null); diff --git a/resources/js/components/two-factor-setup-modal.tsx b/resources/js/components/two-factor-setup-modal.tsx index 827f70daa..2f717e4ef 100644 --- a/resources/js/components/two-factor-setup-modal.tsx +++ b/resources/js/components/two-factor-setup-modal.tsx @@ -2,7 +2,7 @@ import InputError from '@/components/input-error'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp'; -import { useTwoFactorAuth } from '@/hooks/use-two-factor-auth'; +import { useTwoFactorAuthContext } from '@/hooks/use-two-factor-auth'; import { confirm } from '@/routes/two-factor'; import { Form } from '@inertiajs/react'; import { useClipboard } from '@reactuses/core'; @@ -18,7 +18,7 @@ interface TwoFactorSetupModalProps { } export default function TwoFactorSetupModal({ isOpen, onOpenChange, requiresConfirmation, twoFactorEnabled }: TwoFactorSetupModalProps) { - const { qrCodeSvg, manualSetupKey, clearSetupData, fetchSetupData } = useTwoFactorAuth(); + const { qrCodeSvg, manualSetupKey, clearSetupData, fetchSetupData } = useTwoFactorAuthContext(); const [copiedText, copy] = useClipboard(); const [showVerificationStep, setShowVerificationStep] = useState(false); @@ -30,8 +30,8 @@ export default function TwoFactorSetupModal({ isOpen, onOpenChange, requiresConf const modalConfig = useMemo<{ title: string; description: string; buttonText: string }>(() => { if (twoFactorEnabled) { return { - title: 'You have enabled two factor authentication.', - description: 'Two factor authentication is now enabled, scan the QR code or enter the setup key in authenticator app.', + 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', }; } @@ -45,8 +45,8 @@ export default function TwoFactorSetupModal({ isOpen, onOpenChange, requiresConf } return { - title: 'Turn on 2-step Verification', - description: 'To finish enabling two factor authentication, scan the QR code or enter the setup key in authenticator app', + 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', }; }, [twoFactorEnabled, showVerificationStep]); diff --git a/resources/js/hooks/use-two-factor-auth.tsx b/resources/js/hooks/use-two-factor-auth.tsx index af41e24b7..bb37010c5 100644 --- a/resources/js/hooks/use-two-factor-auth.tsx +++ b/resources/js/hooks/use-two-factor-auth.tsx @@ -1,6 +1,6 @@ import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor'; import { type TwoFactorSecretKey, type TwoFactorSetupData } from '@/types'; -import { useCallback, useMemo, useState } from 'react'; +import { createContext, useCallback, useContext, useMemo, useState } from 'react'; const fetchJson = async (url: string): Promise => { const response = await fetch(url, { @@ -24,22 +24,32 @@ export const useTwoFactorAuth = () => { [qrCodeSvg, manualSetupKey] ); - const fetchQrCode = async (): Promise => { - const { svg } = await fetchJson(qrCode.url()); - setQrCodeSvg(svg); - }; + const fetchQrCode = useCallback(async (): Promise => { + try { + const { svg } = await fetchJson(qrCode.url()); + setQrCodeSvg(svg); + } catch (error) { + console.error('Failed to fetch QR code:', error); + setQrCodeSvg(null); + } + }, []); - const fetchSetupKey = async (): Promise => { - const { secretKey: key } = await fetchJson(secretKey.url()); - setManualSetupKey(key); - }; + const fetchSetupKey = useCallback(async (): Promise => { + try { + const { secretKey: key } = await fetchJson(secretKey.url()); + setManualSetupKey(key); + } catch (error) { + console.error('Failed to fetch setup key:', error); + setManualSetupKey(null); + } + }, []); const clearSetupData = useCallback((): void => { setManualSetupKey(null); setQrCodeSvg(null); }, []); - const fetchRecoveryCodes = async (): Promise => { + const fetchRecoveryCodes = useCallback(async (): Promise => { try { const codes = await fetchJson(recoveryCodes.url()); setRecoveryCodesList(codes); @@ -47,7 +57,7 @@ export const useTwoFactorAuth = () => { console.error('Failed to fetch recovery codes:', error); setRecoveryCodesList([]); } - }; + }, []); const fetchSetupData = useCallback(async (): Promise => { try { @@ -57,9 +67,9 @@ export const useTwoFactorAuth = () => { setQrCodeSvg(null); setManualSetupKey(null); } - }, []); + }, [fetchQrCode, fetchSetupKey]); - return { + return useMemo(() => ({ qrCodeSvg, manualSetupKey, recoveryCodesList, @@ -69,5 +79,36 @@ export const useTwoFactorAuth = () => { fetchSetupKey, fetchSetupData, fetchRecoveryCodes, - }; + }), [ + qrCodeSvg, + manualSetupKey, + recoveryCodesList, + hasSetupData, + clearSetupData, + fetchQrCode, + fetchSetupKey, + fetchSetupData, + fetchRecoveryCodes, + ]); +}; + +type TwoFactorAuthContextType = ReturnType; + +const TwoFactorAuthContext = createContext(null); + +export const TwoFactorAuthProvider = ({ children }: { children: React.ReactNode }) => { + const twoFactorAuth = useTwoFactorAuth(); + return ( + + {children} + + ); +}; + +export const useTwoFactorAuthContext = () => { + const context = useContext(TwoFactorAuthContext); + if (!context) { + throw new Error('useTwoFactorAuthContext must be used within TwoFactorAuthProvider'); + } + return context; }; diff --git a/resources/js/pages/auth/two-factor-challenge.tsx b/resources/js/pages/auth/two-factor-challenge.tsx index 54e66d276..226842adc 100644 --- a/resources/js/pages/auth/two-factor-challenge.tsx +++ b/resources/js/pages/auth/two-factor-challenge.tsx @@ -44,7 +44,7 @@ export default function TwoFactorChallenge() { return ( - +
{!showRecoveryInput ? ( diff --git a/resources/js/pages/settings/two-factor.tsx b/resources/js/pages/settings/two-factor.tsx index 0512970ba..5e0f2a721 100644 --- a/resources/js/pages/settings/two-factor.tsx +++ b/resources/js/pages/settings/two-factor.tsx @@ -3,7 +3,7 @@ import TwoFactorRecoveryCodes from '@/components/two-factor-recovery-codes'; import TwoFactorSetupModal from '@/components/two-factor-setup-modal'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import { useTwoFactorAuth } from '@/hooks/use-two-factor-auth'; +import { TwoFactorAuthProvider, useTwoFactorAuthContext } from '@/hooks/use-two-factor-auth'; import AppLayout from '@/layouts/app-layout'; import SettingsLayout from '@/layouts/settings/layout'; import { disable, enable, show } from '@/routes/two-factor'; @@ -24,73 +24,81 @@ const breadcrumbs: BreadcrumbItem[] = [ }, ]; -export default function TwoFactor({ requiresConfirmation = false, twoFactorEnabled = false }: TwoFactorProps) { - const { hasSetupData } = useTwoFactorAuth(); +function TwoFactorContent({ requiresConfirmation = false, twoFactorEnabled = false }: TwoFactorProps) { + const { hasSetupData } = useTwoFactorAuthContext(); const [showSetupModal, setShowSetupModal] = useState(false); return ( - - - -
- +
+ - {!twoFactorEnabled ? ( -
- Disabled -

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

+ {!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. +

-
- {hasSetupData ? ( - + ) : ( +
setShowSetupModal(true)}> + {({ processing }) => ( + - ) : ( - setShowSetupModal(true)}> - {({ processing }) => ( - - )} -
)} -
-
- ) : ( -
- Enabled -

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

- - + + )} +
+
+ ) : ( +
+ 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. +

-
-
- {({ processing }) => ( - - )} -
-
-
- )} + - +
+
+ {({ processing }) => ( + + )} +
+
+ )} + + +
+ ); +} + +export default function TwoFactor(props: TwoFactorProps) { + return ( + + + + + + ); diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index d03b49234..4ebec9e09 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -34,7 +34,7 @@ public function test_users_can_authenticate_using_the_login_screen() public function test_users_with_two_factor_enabled_are_redirected_to_two_factor_challenge() { if (! Features::canManageTwoFactorAuthentication()) { - $this->markTestSkipped('Two factor authentication is not enabled.'); + $this->markTestSkipped('Two-factor authentication is not enabled.'); } Features::twoFactorAuthentication([ diff --git a/tests/Feature/Auth/TwoFactorChallengeTest.php b/tests/Feature/Auth/TwoFactorChallengeTest.php index 2652a36ef..454865cd5 100644 --- a/tests/Feature/Auth/TwoFactorChallengeTest.php +++ b/tests/Feature/Auth/TwoFactorChallengeTest.php @@ -15,7 +15,7 @@ class TwoFactorChallengeTest extends TestCase public function test_two_factor_challenge_redirects_when_not_authenticated(): void { if (! Features::canManageTwoFactorAuthentication()) { - $this->markTestSkipped('Two factor authentication is not enabled.'); + $this->markTestSkipped('Two-factor authentication is not enabled.'); } $response = $this->get(route('two-factor.login')); @@ -26,7 +26,7 @@ public function test_two_factor_challenge_redirects_when_not_authenticated(): vo public function test_two_factor_challenge_renders_correct_inertia_component(): void { if (! Features::canManageTwoFactorAuthentication()) { - $this->markTestSkipped('Two factor authentication is not enabled.'); + $this->markTestSkipped('Two-factor authentication is not enabled.'); } Features::twoFactorAuthentication([ @@ -57,7 +57,7 @@ public function test_two_factor_challenge_renders_correct_inertia_component(): v public function test_two_factor_authentication_is_rate_limited(): void { if (! Features::enabled(Features::twoFactorAuthentication())) { - $this->markTestSkipped('Two factor authentication is not enabled.'); + $this->markTestSkipped('Two-factor authentication is not enabled.'); } Features::twoFactorAuthentication([ diff --git a/tests/Feature/Settings/TwoFactorAuthenticationTest.php b/tests/Feature/Settings/TwoFactorAuthenticationTest.php index 882d77daf..3877c4332 100644 --- a/tests/Feature/Settings/TwoFactorAuthenticationTest.php +++ b/tests/Feature/Settings/TwoFactorAuthenticationTest.php @@ -15,7 +15,7 @@ class TwoFactorAuthenticationTest extends TestCase public function test_two_factor_settings_page_is_displayed() { if (! Features::canManageTwoFactorAuthentication()) { - $this->markTestSkipped('Two factor authentication is not enabled.'); + $this->markTestSkipped('Two-factor authentication is not enabled.'); } Features::twoFactorAuthentication([ @@ -37,7 +37,7 @@ public function test_two_factor_settings_page_is_displayed() public function test_two_factor_settings_page_requires_password_confirmation() { if (! Features::canManageTwoFactorAuthentication()) { - $this->markTestSkipped('Two factor authentication is not enabled.'); + $this->markTestSkipped('Two-factor authentication is not enabled.'); } $user = User::factory()->create(); @@ -53,10 +53,10 @@ public function test_two_factor_settings_page_requires_password_confirmation() $response->assertRedirect(route('password.confirm')); } - public function test_two_factor_settings_page_does_not_requires_password_confirmation() + public function test_two_factor_settings_page_does_not_requires_password_confirmation_if_that_feature_is_disabled() { if (! Features::canManageTwoFactorAuthentication()) { - $this->markTestSkipped('Two factor authentication is not enabled.'); + $this->markTestSkipped('Two-factor authentication is not enabled.'); } $user = User::factory()->create(); @@ -77,7 +77,7 @@ public function test_two_factor_settings_page_does_not_requires_password_confirm public function test_two_factor_settings_page_returns_forbidden_when_two_factor_is_disabled() { if (! Features::canManageTwoFactorAuthentication()) { - $this->markTestSkipped('Two factor authentication is not enabled.'); + $this->markTestSkipped('Two-factor authentication is not enabled.'); } config(['fortify.features' => []]); @@ -90,10 +90,10 @@ public function test_two_factor_settings_page_returns_forbidden_when_two_factor_ ->assertForbidden(); } - public function test_sets_confirming_session_when_enabling_two_factor_with_confirmation() + public function test_controller_sets_confirming_data_when_enabling_two_factor_with_confirmation() { if (! Features::canManageTwoFactorAuthentication()) { - $this->markTestSkipped('Two factor authentication is not enabled.'); + $this->markTestSkipped('Two-factor authentication is not enabled.'); } Features::twoFactorAuthentication([ @@ -117,7 +117,7 @@ public function test_sets_confirming_session_when_enabling_two_factor_with_confi public function test_user_can_view_setting_page_when_confirm_disabled() { if (! Features::canManageTwoFactorAuthentication()) { - $this->markTestSkipped('Two factor authentication is not enabled.'); + $this->markTestSkipped('Two-factor authentication is not enabled.'); } Features::twoFactorAuthentication([ @@ -136,10 +136,10 @@ public function test_user_can_view_setting_page_when_confirm_disabled() ); } - public function test_sets_empty_session_when_transitioning_to_disabled_state() + public function test_controller_sets_empty_session_data_when_transitioning_to_disabled_state() { if (! Features::canManageTwoFactorAuthentication()) { - $this->markTestSkipped('Two factor authentication is not enabled.'); + $this->markTestSkipped('Two-factor authentication is not enabled.'); } Features::twoFactorAuthentication([ @@ -154,10 +154,10 @@ public function test_sets_empty_session_when_transitioning_to_disabled_state() ->assertSessionHas('two_factor_empty_at'); } - public function test_removes_confirming_session_when_cleanup_triggered() + public function test_controller_removes_confirming_session_data_when_cleanup_triggered() { if (! Features::canManageTwoFactorAuthentication()) { - $this->markTestSkipped('Two factor authentication is not enabled.'); + $this->markTestSkipped('Two-factor authentication is not enabled.'); } Features::twoFactorAuthentication([ @@ -178,10 +178,10 @@ public function test_removes_confirming_session_when_cleanup_triggered() ->assertSessionHas('two_factor_empty_at'); } - public function test_disables_two_factor_when_confirmation_abandoned_between_requests() + public function test_two_factor_authentication_disabled_when_confirmation_abandoned_between_requests() { if (! Features::canManageTwoFactorAuthentication()) { - $this->markTestSkipped('Two factor authentication is not enabled.'); + $this->markTestSkipped('Two-factor authentication is not enabled.'); } Features::twoFactorAuthentication([ From 38766c1b4ea35e5bd98f88005a334815603dc012 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Mon, 1 Sep 2025 13:51:58 +0530 Subject: [PATCH 06/31] Update two-factor authentication implementation and refactor related components --- .../TwoFactorAuthenticationController.php | 16 +-- .../TwoFactorAuthenticationRequest.php | 30 ++++ composer.json | 2 +- .../components/two-factor-recovery-codes.tsx | 11 +- .../js/components/two-factor-setup-modal.tsx | 27 +++- resources/js/hooks/use-two-factor-auth.tsx | 23 +--- resources/js/layouts/settings/layout.tsx | 3 +- resources/js/pages/settings/two-factor.tsx | 129 +++++++++--------- 8 files changed, 128 insertions(+), 113 deletions(-) create mode 100644 app/Http/Requests/Settings/TwoFactorAuthenticationRequest.php diff --git a/app/Http/Controllers/Settings/TwoFactorAuthenticationController.php b/app/Http/Controllers/Settings/TwoFactorAuthenticationController.php index f2801cd11..5203fcf49 100644 --- a/app/Http/Controllers/Settings/TwoFactorAuthenticationController.php +++ b/app/Http/Controllers/Settings/TwoFactorAuthenticationController.php @@ -2,10 +2,8 @@ namespace App\Http\Controllers\Settings; -use App\Http\Controllers\Concerns\ConfirmsTwoFactorAuthentication; use App\Http\Controllers\Controller; -use Illuminate\Http\Request; -use Illuminate\Http\Response as HttpResponse; +use App\Http\Requests\Settings\TwoFactorAuthenticationRequest; use Illuminate\Routing\Controllers\HasMiddleware; use Illuminate\Routing\Controllers\Middleware; use Inertia\Inertia; @@ -14,8 +12,6 @@ class TwoFactorAuthenticationController extends Controller implements HasMiddleware { - use ConfirmsTwoFactorAuthentication; - /** * Get the middleware that should be assigned to the controller. */ @@ -29,15 +25,9 @@ public static function middleware(): array /** * Show the user's two-factor authentication settings page. */ - public function show(Request $request): Response + public function show(TwoFactorAuthenticationRequest $request): Response { - abort_if( - ! Features::enabled(Features::twoFactorAuthentication()), - HttpResponse::HTTP_FORBIDDEN, - 'Two-factor authentication is disabled.' - ); - - $this->validateTwoFactorAuthenticationState($request); + $request->ensureStateIsValid(); return Inertia::render('settings/two-factor', [ 'twoFactorEnabled' => $request->user()->hasEnabledTwoFactorAuthentication(), diff --git a/app/Http/Requests/Settings/TwoFactorAuthenticationRequest.php b/app/Http/Requests/Settings/TwoFactorAuthenticationRequest.php new file mode 100644 index 000000000..9db81d217 --- /dev/null +++ b/app/Http/Requests/Settings/TwoFactorAuthenticationRequest.php @@ -0,0 +1,30 @@ +|string> + */ + public function rules(): array + { + return []; + } +} diff --git a/composer.json b/composer.json index 0b9703e0a..8f0e9dc1f 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,7 @@ "require": { "php": "^8.2", "inertiajs/inertia-laravel": "^2.0", - "laravel/fortify": "^1.29", + "laravel/fortify": "^1.30", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1", "laravel/wayfinder": "^0.1.9" diff --git a/resources/js/components/two-factor-recovery-codes.tsx b/resources/js/components/two-factor-recovery-codes.tsx index 8f30d9622..b4def4893 100644 --- a/resources/js/components/two-factor-recovery-codes.tsx +++ b/resources/js/components/two-factor-recovery-codes.tsx @@ -1,13 +1,16 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { useTwoFactorAuthContext } from '@/hooks/use-two-factor-auth'; import { regenerateRecoveryCodes } from '@/routes/two-factor'; import { Form } from '@inertiajs/react'; import { Eye, EyeOff, LockKeyhole, RefreshCw } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; -export default function TwoFactorRecoveryCodes() { - const { recoveryCodesList, fetchRecoveryCodes } = useTwoFactorAuthContext(); +interface TwoFactorRecoveryCodesProps { + recoveryCodesList: string[]; + fetchRecoveryCodes: () => Promise; +} + +export default function TwoFactorRecoveryCodes({ recoveryCodesList, fetchRecoveryCodes }: TwoFactorRecoveryCodesProps) { const [isRecoveryCodesVisible, setIsRecoveryCodesVisible] = useState(false); const recoveryCodeSectionRef = useRef(null); @@ -21,7 +24,7 @@ export default function TwoFactorRecoveryCodes() { if (!isRecoveryCodesVisible) { setTimeout(() => { recoveryCodeSectionRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, 0); + }); } }; diff --git a/resources/js/components/two-factor-setup-modal.tsx b/resources/js/components/two-factor-setup-modal.tsx index 2f717e4ef..abab1cf9e 100644 --- a/resources/js/components/two-factor-setup-modal.tsx +++ b/resources/js/components/two-factor-setup-modal.tsx @@ -2,7 +2,6 @@ import InputError from '@/components/input-error'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp'; -import { useTwoFactorAuthContext } from '@/hooks/use-two-factor-auth'; import { confirm } from '@/routes/two-factor'; import { Form } from '@inertiajs/react'; import { useClipboard } from '@reactuses/core'; @@ -12,13 +11,25 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; interface TwoFactorSetupModalProps { isOpen: boolean; - onOpenChange: (open: boolean) => void; + onClose: () => void; requiresConfirmation: boolean; twoFactorEnabled: boolean; + qrCodeSvg: string | null; + manualSetupKey: string | null; + clearSetupData: () => void; + fetchSetupData: () => Promise; } -export default function TwoFactorSetupModal({ isOpen, onOpenChange, requiresConfirmation, twoFactorEnabled }: TwoFactorSetupModalProps) { - const { qrCodeSvg, manualSetupKey, clearSetupData, fetchSetupData } = useTwoFactorAuthContext(); +export default function TwoFactorSetupModal({ + isOpen, + onClose, + requiresConfirmation, + twoFactorEnabled, + qrCodeSvg, + manualSetupKey, + clearSetupData, + fetchSetupData, +}: TwoFactorSetupModalProps) { const [copiedText, copy] = useClipboard(); const [showVerificationStep, setShowVerificationStep] = useState(false); @@ -57,10 +68,11 @@ export default function TwoFactorSetupModal({ isOpen, onOpenChange, requiresConf setTimeout(() => { pinInputContainerRef.current?.querySelector('input')?.focus(); }, 0); + return; } clearSetupData(); - onOpenChange(false); + onClose(); }; const resetModalState = useCallback(() => { @@ -74,6 +86,7 @@ export default function TwoFactorSetupModal({ isOpen, onOpenChange, requiresConf useEffect(() => { if (!isOpen) { resetModalState(); + return; } @@ -83,7 +96,7 @@ export default function TwoFactorSetupModal({ isOpen, onOpenChange, requiresConf }, [isOpen, qrCodeSvg, fetchSetupData, resetModalState]); return ( - + !open && onClose()}>
@@ -162,7 +175,7 @@ export default function TwoFactorSetupModal({ isOpen, onOpenChange, requiresConf
) : ( -
setCode('')} onSuccess={() => onOpenChange(false)} resetOnError> + setCode('')} onSuccess={() => onClose()} resetOnError> {({ processing, errors }: { processing: boolean; errors?: { confirmTwoFactorAuthentication?: { code?: string } } }) => ( <> diff --git a/resources/js/hooks/use-two-factor-auth.tsx b/resources/js/hooks/use-two-factor-auth.tsx index bb37010c5..7770302e2 100644 --- a/resources/js/hooks/use-two-factor-auth.tsx +++ b/resources/js/hooks/use-two-factor-auth.tsx @@ -1,6 +1,6 @@ import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor'; import { type TwoFactorSecretKey, type TwoFactorSetupData } from '@/types'; -import { createContext, useCallback, useContext, useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; const fetchJson = async (url: string): Promise => { const response = await fetch(url, { @@ -91,24 +91,3 @@ export const useTwoFactorAuth = () => { fetchRecoveryCodes, ]); }; - -type TwoFactorAuthContextType = ReturnType; - -const TwoFactorAuthContext = createContext(null); - -export const TwoFactorAuthProvider = ({ children }: { children: React.ReactNode }) => { - const twoFactorAuth = useTwoFactorAuth(); - return ( - - {children} - - ); -}; - -export const useTwoFactorAuthContext = () => { - const context = useContext(TwoFactorAuthContext); - if (!context) { - throw new Error('useTwoFactorAuthContext must be used within TwoFactorAuthProvider'); - } - return context; -}; diff --git a/resources/js/layouts/settings/layout.tsx b/resources/js/layouts/settings/layout.tsx index f0b3635c0..85269b588 100644 --- a/resources/js/layouts/settings/layout.tsx +++ b/resources/js/layouts/settings/layout.tsx @@ -24,6 +24,7 @@ const sidebarNavItems: NavItem[] = [ { title: 'Two-Factor Auth', href: show(), + icon: null, }, { title: 'Appearance', @@ -57,7 +58,7 @@ export default function SettingsLayout({ children }: PropsWithChildren) { 'bg-muted': currentPath === (typeof item.href === 'string' ? item.href : item.href.url), })} > - + {item.icon && } {item.title} diff --git a/resources/js/pages/settings/two-factor.tsx b/resources/js/pages/settings/two-factor.tsx index 5e0f2a721..6eb990fc5 100644 --- a/resources/js/pages/settings/two-factor.tsx +++ b/resources/js/pages/settings/two-factor.tsx @@ -3,7 +3,7 @@ import TwoFactorRecoveryCodes from '@/components/two-factor-recovery-codes'; import TwoFactorSetupModal from '@/components/two-factor-setup-modal'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import { TwoFactorAuthProvider, useTwoFactorAuthContext } from '@/hooks/use-two-factor-auth'; +import { useTwoFactorAuth } from '@/hooks/use-two-factor-auth'; import AppLayout from '@/layouts/app-layout'; import SettingsLayout from '@/layouts/settings/layout'; import { disable, enable, show } from '@/routes/two-factor'; @@ -24,81 +24,80 @@ const breadcrumbs: BreadcrumbItem[] = [ }, ]; -function TwoFactorContent({ requiresConfirmation = false, twoFactorEnabled = false }: TwoFactorProps) { - const { hasSetupData } = useTwoFactorAuthContext(); +export default function TwoFactor({ requiresConfirmation = false, twoFactorEnabled = false }: TwoFactorProps) { + const { hasSetupData, qrCodeSvg, manualSetupKey, clearSetupData, fetchSetupData, recoveryCodesList, fetchRecoveryCodes } = useTwoFactorAuth(); const [showSetupModal, setShowSetupModal] = useState(false); return ( -
- + + + +
+ - {!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. -

+ {!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. +

-
- {hasSetupData ? ( - - ) : ( - setShowSetupModal(true)}> - {({ processing }) => ( - + ) : ( + setShowSetupModal(true)}> + {({ processing }) => ( + + )} + )} - - )} -
-
- ) : ( -
- 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. -

+
+
+ ) : ( +
+ 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. +

- + -
-
- {({ processing }) => ( - - )} -
-
-
- )} +
+
+ {({ processing }) => ( + + )} +
+
+
+ )} - -
- ); -} - -export default function TwoFactor(props: TwoFactorProps) { - return ( - - - - - - + setShowSetupModal(false)} + requiresConfirmation={requiresConfirmation} + twoFactorEnabled={twoFactorEnabled} + qrCodeSvg={qrCodeSvg} + manualSetupKey={manualSetupKey} + clearSetupData={clearSetupData} + fetchSetupData={fetchSetupData} + /> +
); From 39bd4bd432a81cdd4b907d2952d5a86fb88b224f Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Mon, 1 Sep 2025 14:10:22 +0530 Subject: [PATCH 07/31] Update two-factor authentication implementation and refactor related components --- .../ConfirmsTwoFactorAuthentication.php | 71 ------------------- 1 file changed, 71 deletions(-) delete mode 100644 app/Http/Controllers/Concerns/ConfirmsTwoFactorAuthentication.php diff --git a/app/Http/Controllers/Concerns/ConfirmsTwoFactorAuthentication.php b/app/Http/Controllers/Concerns/ConfirmsTwoFactorAuthentication.php deleted file mode 100644 index cee9ab8ed..000000000 --- a/app/Http/Controllers/Concerns/ConfirmsTwoFactorAuthentication.php +++ /dev/null @@ -1,71 +0,0 @@ -twoFactorAuthenticationDisabled($request)) { - $request->session()->put('two_factor_empty_at', $currentTime); - } - - // If was previously totally disabled this session but is now confirming, notate time... - if ($this->hasJustBegunConfirmingTwoFactorAuthentication($request)) { - $request->session()->put('two_factor_confirming_at', $currentTime); - } - - // If the profile is reloaded and is not confirmed but was previously in confirming state, disable... - if ($this->neverFinishedConfirmingTwoFactorAuthentication($request, $currentTime)) { - app(DisableTwoFactorAuthentication::class)(Auth::user()); - - $request->session()->put('two_factor_empty_at', $currentTime); - $request->session()->remove('two_factor_confirming_at'); - } - } - - /** - * Determine if two-factor authentication is totally disabled. - */ - protected function twoFactorAuthenticationDisabled(Request $request): bool - { - return is_null($request->user()->two_factor_secret) && - is_null($request->user()->two_factor_confirmed_at); - } - - /** - * Determine if two-factor authentication is just now being confirmed within the last request cycle. - */ - protected function hasJustBegunConfirmingTwoFactorAuthentication(Request $request): bool - { - return ! is_null($request->user()->two_factor_secret) && - is_null($request->user()->two_factor_confirmed_at) && - $request->session()->has('two_factor_empty_at') && - is_null($request->session()->get('two_factor_confirming_at')); - } - - /** - * Determine if two-factor authentication was never totally confirmed once confirmation started. - */ - protected function neverFinishedConfirmingTwoFactorAuthentication(Request $request, int $currentTime): bool - { - return ! array_key_exists('code', $request->session()->getOldInput()) && - is_null($request->user()->two_factor_confirmed_at) && - $request->session()->get('two_factor_confirming_at', 0) != $currentTime; - } -} From 07d1019d5ae514d08d71250d467e33ec25d48843 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Mon, 1 Sep 2025 18:12:54 +0530 Subject: [PATCH 08/31] Formatting --- app/Providers/FortifyServiceProvider.php | 4 +- resources/js/app.tsx | 2 +- .../js/components/two-factor-setup-modal.tsx | 15 ++- resources/js/components/ui/input-otp.tsx | 92 +++++++++---------- resources/js/hooks/use-two-factor-auth.tsx | 40 +++----- resources/js/layouts/settings/layout.tsx | 2 +- resources/js/pages/auth/confirm-password.tsx | 2 +- .../js/pages/auth/two-factor-challenge.tsx | 20 ++-- resources/js/pages/settings/two-factor.tsx | 13 +-- routes/settings.php | 2 +- 10 files changed, 85 insertions(+), 107 deletions(-) diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index eead2ab5d..8c33b7733 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -24,9 +24,9 @@ public function register(): void */ public function boot(): void { - Fortify::twoFactorChallengeView(fn() => Inertia::render('auth/two-factor-challenge')); + Fortify::twoFactorChallengeView(fn () => Inertia::render('auth/two-factor-challenge')); - Fortify::confirmPasswordView(fn() => Inertia::render('auth/confirm-password')); + Fortify::confirmPasswordView(fn () => Inertia::render('auth/confirm-password')); RateLimiter::for('two-factor', function (Request $request) { return Limit::perMinute(5)->by($request->session()->get('login.id')); diff --git a/resources/js/app.tsx b/resources/js/app.tsx index ae0997825..58f0d62cd 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -8,7 +8,7 @@ import { initializeTheme } from './hooks/use-appearance'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; createInertiaApp({ - title: (title) => title ? `${title} - ${appName}` : appName, + title: (title) => (title ? `${title} - ${appName}` : appName), resolve: (name) => resolvePageComponent(`./pages/${name}.tsx`, import.meta.glob('./pages/**/*.tsx')), setup({ el, App, props }) { const root = createRoot(el); diff --git a/resources/js/components/two-factor-setup-modal.tsx b/resources/js/components/two-factor-setup-modal.tsx index abab1cf9e..b1d40b3ec 100644 --- a/resources/js/components/two-factor-setup-modal.tsx +++ b/resources/js/components/two-factor-setup-modal.tsx @@ -31,10 +31,10 @@ export default function TwoFactorSetupModal({ fetchSetupData, }: TwoFactorSetupModalProps) { const [copiedText, copy] = useClipboard(); + const OTP_MAX_LENGTH = 6; const [showVerificationStep, setShowVerificationStep] = useState(false); const [code, setCode] = useState(''); - const codeValue = useMemo(() => code, [code]); const pinInputContainerRef = useRef(null); @@ -175,21 +175,20 @@ export default function TwoFactorSetupModal({
) : ( -
setCode('')} onSuccess={() => onClose()} resetOnError> + onClose()} resetOnError transform={(data) => ({ ...data, code })}> {({ processing, errors }: { processing: boolean; errors?: { confirmTwoFactorAuthentication?: { code?: string } } }) => ( <> -
setCode(value)} disabled={processing} pattern={REGEXP_ONLY_DIGITS} > - {Array.from({ length: 6 }, (_, index) => ( + {Array.from({ length: OTP_MAX_LENGTH }, (_, index) => ( ))} @@ -207,7 +206,7 @@ export default function TwoFactorSetupModal({ > Back -
diff --git a/resources/js/components/ui/input-otp.tsx b/resources/js/components/ui/input-otp.tsx index 310175285..f7891c9cc 100644 --- a/resources/js/components/ui/input-otp.tsx +++ b/resources/js/components/ui/input-otp.tsx @@ -1,55 +1,46 @@ import * as React from "react" import { OTPInput, OTPInputContext } from "input-otp" -import { MinusIcon } from "lucide-react" +import { Minus } from "lucide-react" import { cn } from "@/lib/utils" -function InputOTP({ - className, - containerClassName, - ...props -}: React.ComponentProps & { - containerClassName?: string -}) { - return ( - - ) -} +const InputOTP = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, containerClassName, ...props }, ref) => ( + +)) +InputOTP.displayName = "InputOTP" -function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} +const InputOTPGroup = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> +>(({ className, ...props }, ref) => ( +
+)) +InputOTPGroup.displayName = "InputOTPGroup" -function InputOTPSlot({ - index, - className, - ...props -}: React.ComponentProps<"div"> & { - index: number -}) { +const InputOTPSlot = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> & { index: number } +>(({ index, className, ...props }, ref) => { const inputOTPContext = React.useContext(OTPInputContext) - const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {} + const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index] return (
-
+
)}
) -} +}) +InputOTPSlot.displayName = "InputOTPSlot" -function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) { - return ( -
- -
- ) -} +const InputOTPSeparator = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> +>(({ ...props }, ref) => ( +
+ +
+)) +InputOTPSeparator.displayName = "InputOTPSeparator" export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } diff --git a/resources/js/hooks/use-two-factor-auth.tsx b/resources/js/hooks/use-two-factor-auth.tsx index 7770302e2..4f9cfad8f 100644 --- a/resources/js/hooks/use-two-factor-auth.tsx +++ b/resources/js/hooks/use-two-factor-auth.tsx @@ -19,10 +19,7 @@ export const useTwoFactorAuth = () => { const [manualSetupKey, setManualSetupKey] = useState(null); const [recoveryCodesList, setRecoveryCodesList] = useState([]); - const hasSetupData = useMemo( - () => qrCodeSvg !== null && manualSetupKey !== null, - [qrCodeSvg, manualSetupKey] - ); + const hasSetupData = useMemo(() => qrCodeSvg !== null && manualSetupKey !== null, [qrCodeSvg, manualSetupKey]); const fetchQrCode = useCallback(async (): Promise => { try { @@ -69,25 +66,18 @@ export const useTwoFactorAuth = () => { } }, [fetchQrCode, fetchSetupKey]); - return useMemo(() => ({ - qrCodeSvg, - manualSetupKey, - recoveryCodesList, - hasSetupData, - clearSetupData, - fetchQrCode, - fetchSetupKey, - fetchSetupData, - fetchRecoveryCodes, - }), [ - qrCodeSvg, - manualSetupKey, - recoveryCodesList, - hasSetupData, - clearSetupData, - fetchQrCode, - fetchSetupKey, - fetchSetupData, - fetchRecoveryCodes, - ]); + return useMemo( + () => ({ + qrCodeSvg, + manualSetupKey, + recoveryCodesList, + hasSetupData, + clearSetupData, + fetchQrCode, + fetchSetupKey, + fetchSetupData, + fetchRecoveryCodes, + }), + [qrCodeSvg, manualSetupKey, recoveryCodesList, hasSetupData, clearSetupData, fetchQrCode, fetchSetupKey, fetchSetupData, fetchRecoveryCodes], + ); }; diff --git a/resources/js/layouts/settings/layout.tsx b/resources/js/layouts/settings/layout.tsx index 85269b588..9d38f11b2 100644 --- a/resources/js/layouts/settings/layout.tsx +++ b/resources/js/layouts/settings/layout.tsx @@ -4,8 +4,8 @@ import { Separator } from '@/components/ui/separator'; import { cn } from '@/lib/utils'; import { appearance } from '@/routes'; import { edit as editPassword } from '@/routes/password'; -import { show } from '@/routes/two-factor'; import { edit } from '@/routes/profile'; +import { show } from '@/routes/two-factor'; import { type NavItem } from '@/types'; import { Link } from '@inertiajs/react'; import { type PropsWithChildren } from 'react'; diff --git a/resources/js/pages/auth/confirm-password.tsx b/resources/js/pages/auth/confirm-password.tsx index d137d1200..842f6136b 100644 --- a/resources/js/pages/auth/confirm-password.tsx +++ b/resources/js/pages/auth/confirm-password.tsx @@ -1,9 +1,9 @@ -import { store } from '@/routes/password/confirm' import InputError from '@/components/input-error'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import AuthLayout from '@/layouts/auth-layout'; +import { store } from '@/routes/password/confirm'; import { Form, Head } from '@inertiajs/react'; import { LoaderCircle } from 'lucide-react'; diff --git a/resources/js/pages/auth/two-factor-challenge.tsx b/resources/js/pages/auth/two-factor-challenge.tsx index 226842adc..43aea1d65 100644 --- a/resources/js/pages/auth/two-factor-challenge.tsx +++ b/resources/js/pages/auth/two-factor-challenge.tsx @@ -15,10 +15,9 @@ interface AuthConfigContent { } export default function TwoFactorChallenge() { + const OTP_MAX_LENGTH = 6; const [showRecoveryInput, setShowRecoveryInput] = useState(false); - const [code, setCode] = useState([]); - - const codeValue = useMemo(() => code.join(''), [code]); + const [code, setCode] = useState(''); const authConfigContent = useMemo(() => { if (showRecoveryInput) { @@ -39,7 +38,7 @@ export default function TwoFactorChallenge() { const toggleRecoveryMode = (clearErrors: () => void): void => { setShowRecoveryInput(!showRecoveryInput); clearErrors(); - setCode([]); + setCode(''); }; return ( @@ -48,21 +47,20 @@ export default function TwoFactorChallenge() {
{!showRecoveryInput ? ( - setCode([])}> + ({ ...data, code })}> {({ errors, processing, clearErrors }) => ( <> -
setCode(value.split('').map(Number))} + maxLength={OTP_MAX_LENGTH} + value={code} + onChange={(value) => setCode(value)} disabled={processing} pattern={REGEXP_ONLY_DIGITS} > - {Array.from({ length: 6 }, (_, index) => ( + {Array.from({ length: OTP_MAX_LENGTH }, (_, index) => ( ))} @@ -70,7 +68,7 @@ export default function TwoFactorChallenge() {
-
diff --git a/resources/js/pages/settings/two-factor.tsx b/resources/js/pages/settings/two-factor.tsx index 6eb990fc5..b4921fe38 100644 --- a/resources/js/pages/settings/two-factor.tsx +++ b/resources/js/pages/settings/two-factor.tsx @@ -39,8 +39,8 @@ export default function TwoFactor({ requiresConfirmation = false, twoFactorEnabl
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. + 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.

@@ -65,14 +65,11 @@ export default function TwoFactor({ requiresConfirmation = false, twoFactorEnabl
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. + 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.

- +
diff --git a/routes/settings.php b/routes/settings.php index d6866ce1e..a8a914c75 100644 --- a/routes/settings.php +++ b/routes/settings.php @@ -23,6 +23,6 @@ return Inertia::render('settings/appearance'); })->name('appearance'); - Route::get('settings/two-factor', [TwoFactorAuthenticationController::class, 'show']) + Route::get('settings/two-factor', [TwoFactorAuthenticationController::class, 'show']) ->name('two-factor.show'); }); From ee8bcf3d34c1d785617eeca9ff394b8f152d7f25 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Mon, 1 Sep 2025 18:59:23 +0530 Subject: [PATCH 09/31] Formatting Recovery Code --- app/Providers/FortifyServiceProvider.php | 1 - .../components/two-factor-recovery-codes.tsx | 96 +++++++++++++------ 2 files changed, 67 insertions(+), 30 deletions(-) diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 8c33b7733..c13f6ee17 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -25,7 +25,6 @@ public function register(): void public function boot(): void { Fortify::twoFactorChallengeView(fn () => Inertia::render('auth/two-factor-challenge')); - Fortify::confirmPasswordView(fn () => Inertia::render('auth/confirm-password')); RateLimiter::for('two-factor', function (Request $request) { diff --git a/resources/js/components/two-factor-recovery-codes.tsx b/resources/js/components/two-factor-recovery-codes.tsx index b4def4893..40d682212 100644 --- a/resources/js/components/two-factor-recovery-codes.tsx +++ b/resources/js/components/two-factor-recovery-codes.tsx @@ -2,31 +2,37 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { regenerateRecoveryCodes } from '@/routes/two-factor'; import { Form } from '@inertiajs/react'; -import { Eye, EyeOff, LockKeyhole, RefreshCw } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; +import { Eye, EyeOff, LockKeyhole, LucideIcon, RefreshCw } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; interface TwoFactorRecoveryCodesProps { recoveryCodesList: string[]; fetchRecoveryCodes: () => Promise; } -export default function TwoFactorRecoveryCodes({ recoveryCodesList, fetchRecoveryCodes }: TwoFactorRecoveryCodesProps) { - const [isRecoveryCodesVisible, setIsRecoveryCodesVisible] = useState(false); - const recoveryCodeSectionRef = useRef(null); +export default function TwoFactorRecoveryCodes({ + recoveryCodesList, + fetchRecoveryCodes, +}: TwoFactorRecoveryCodesProps) { + const [isCodesVisible, setIsCodesVisible] = useState(false); + const codesSectionRef = useRef(null); - const toggleRecoveryCodesVisibility = async () => { - if (!isRecoveryCodesVisible && !recoveryCodesList.length) { + const toggleCodesVisibility = useCallback(async () => { + if (!isCodesVisible && !recoveryCodesList.length) { await fetchRecoveryCodes(); } - setIsRecoveryCodesVisible(!isRecoveryCodesVisible); + setIsCodesVisible(!isCodesVisible); - if (!isRecoveryCodesVisible) { + if (!isCodesVisible) { setTimeout(() => { - recoveryCodeSectionRef.current?.scrollIntoView({ behavior: 'smooth' }); + codesSectionRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'nearest' + }); }); } - }; + }, [isCodesVisible, recoveryCodesList.length, fetchRecoveryCodes]); useEffect(() => { if (!recoveryCodesList.length) { @@ -34,29 +40,44 @@ export default function TwoFactorRecoveryCodes({ recoveryCodesList, fetchRecover } }, [recoveryCodesList.length, fetchRecoveryCodes]); + const IconComponent: LucideIcon = isCodesVisible ? EyeOff : Eye; + return ( - + - Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager. + Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.
- - {isRecoveryCodesVisible && ( + {isCodesVisible && ( {({ processing }) => ( - )} @@ -64,26 +85,43 @@ export default function TwoFactorRecoveryCodes({ recoveryCodesList, fetchRecover )}
-
+
{!recoveryCodesList.length ? ( -
- {Array.from({ length: 8 }, (_, n) => ( -
+
+ {Array.from({ length: 8 }, (_, index) => ( + ) : ( - recoveryCodesList.map((code, index) =>
{code}
) + recoveryCodesList.map((code, index) => ( +
+ {code} +
+ )) )}
-

- Each can be used once to access your account and will be removed after use. If you need more, click{' '} - Regenerate Codes above. -

+
+

+ 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 94f117fab73938d8bf771883fd0d74ce42f41d25 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Mon, 1 Sep 2025 19:40:13 +0530 Subject: [PATCH 10/31] refactor --- .../js/components/two-factor-setup-modal.tsx | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/resources/js/components/two-factor-setup-modal.tsx b/resources/js/components/two-factor-setup-modal.tsx index b1d40b3ec..4bb63f22f 100644 --- a/resources/js/components/two-factor-setup-modal.tsx +++ b/resources/js/components/two-factor-setup-modal.tsx @@ -6,9 +6,11 @@ import { confirm } from '@/routes/two-factor'; import { Form } from '@inertiajs/react'; import { useClipboard } from '@reactuses/core'; import { REGEXP_ONLY_DIGITS } from 'input-otp'; -import { Check, Copy, Loader2, ScanLine } from 'lucide-react'; +import { Check, Copy, Loader2, LucideIcon, ScanLine } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +const OTP_MAX_LENGTH = 6; + interface TwoFactorSetupModalProps { isOpen: boolean; onClose: () => void; @@ -31,7 +33,6 @@ export default function TwoFactorSetupModal({ fetchSetupData, }: TwoFactorSetupModalProps) { const [copiedText, copy] = useClipboard(); - const OTP_MAX_LENGTH = 6; const [showVerificationStep, setShowVerificationStep] = useState(false); const [code, setCode] = useState(''); @@ -62,39 +63,37 @@ export default function TwoFactorSetupModal({ }; }, [twoFactorEnabled, showVerificationStep]); - const handleModalNextStep = () => { + const handleModalNextStep = useCallback(() => { if (requiresConfirmation) { setShowVerificationStep(true); setTimeout(() => { pinInputContainerRef.current?.querySelector('input')?.focus(); }, 0); - return; } clearSetupData(); onClose(); - }; + }, [requiresConfirmation, clearSetupData, onClose]); const resetModalState = useCallback(() => { + setShowVerificationStep(false); + setCode(''); if (twoFactorEnabled) { clearSetupData(); } - setShowVerificationStep(false); - setCode(''); }, [twoFactorEnabled, clearSetupData]); useEffect(() => { if (!isOpen) { resetModalState(); - return; } - if (!qrCodeSvg) { - fetchSetupData().then(); + fetchSetupData(); } }, [isOpen, qrCodeSvg, fetchSetupData, resetModalState]); + const CopyIcon: LucideIcon = copiedText === manualSetupKey ? Check : Copy; return ( !open && onClose()}> @@ -167,7 +166,7 @@ export default function TwoFactorSetupModal({ onClick={() => copy(manualSetupKey)} className="relative block h-auto border-l border-border px-3 hover:bg-muted" > - {copiedText === manualSetupKey ? : } + )} @@ -206,7 +205,7 @@ export default function TwoFactorSetupModal({ > Back -
From d1a9b23cd00085b8f5567d8e03db2f4c1ef449f6 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Mon, 1 Sep 2025 19:47:23 +0530 Subject: [PATCH 11/31] Fix Test --- config/fortify.php | 2 +- resources/js/pages/auth/two-factor-challenge.tsx | 9 ++------- tests/Feature/Auth/PasswordConfirmationTest.php | 3 ++- tests/Feature/Auth/PasswordResetTest.php | 15 +++++++++++---- .../Settings/TwoFactorAuthenticationTest.php | 2 ++ 5 files changed, 18 insertions(+), 13 deletions(-) diff --git a/config/fortify.php b/config/fortify.php index df49e4f8c..f9c657224 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/js/pages/auth/two-factor-challenge.tsx b/resources/js/pages/auth/two-factor-challenge.tsx index 43aea1d65..7632cdd0e 100644 --- a/resources/js/pages/auth/two-factor-challenge.tsx +++ b/resources/js/pages/auth/two-factor-challenge.tsx @@ -8,18 +8,13 @@ import { Form, Head } from '@inertiajs/react'; import { REGEXP_ONLY_DIGITS } from 'input-otp'; import { useMemo, useState } from 'react'; -interface AuthConfigContent { - title: string; - description: string; - toggleText: string; -} +const OTP_MAX_LENGTH = 6; export default function TwoFactorChallenge() { - const OTP_MAX_LENGTH = 6; const [showRecoveryInput, setShowRecoveryInput] = useState(false); const [code, setCode] = useState(''); - const authConfigContent = useMemo(() => { + const authConfigContent = useMemo<{ title: string; description: string; toggleText: string }>(() => { if (showRecoveryInput) { return { title: 'Recovery Code', diff --git a/tests/Feature/Auth/PasswordConfirmationTest.php b/tests/Feature/Auth/PasswordConfirmationTest.php index 347fbe1ab..e499fc84b 100644 --- a/tests/Feature/Auth/PasswordConfirmationTest.php +++ b/tests/Feature/Auth/PasswordConfirmationTest.php @@ -19,13 +19,14 @@ public function test_confirm_password_screen_can_be_rendered() $response->assertStatus(200); $response->assertInertia(fn (Assert $page) => $page - ->component('auth/confirm-password') + ->component('auth/ConfirmPassword') ); } public function test_password_confirmation_requires_authentication() { $response = $this->get(route('password.confirm')); + $response->assertRedirect(route('login')); } } diff --git a/tests/Feature/Auth/PasswordResetTest.php b/tests/Feature/Auth/PasswordResetTest.php index 6737c2a76..ea968ab82 100644 --- a/tests/Feature/Auth/PasswordResetTest.php +++ b/tests/Feature/Auth/PasswordResetTest.php @@ -38,10 +38,17 @@ public function test_reset_password_screen_can_be_rendered() $this->post(route('password.email'), ['email' => $user->email]); - Notification::assertSentTo($user, ResetPassword::class, function ($notification) { - $response = $this->get(route('password.reset', $notification->token)); - - $response->assertStatus(200); + Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) { + $response = $this->get(route('password.reset', $notification->token).'?email='.$user->email); + + $response->assertStatus(200) + ->assertInertia(fn ($page) => $page + ->component('auth/reset-password') + ->has('email') + ->has('token') + ->where('email', $user->email) + ->where('token', $notification->token) + ); return true; }); diff --git a/tests/Feature/Settings/TwoFactorAuthenticationTest.php b/tests/Feature/Settings/TwoFactorAuthenticationTest.php index 3877c4332..4efd6521c 100644 --- a/tests/Feature/Settings/TwoFactorAuthenticationTest.php +++ b/tests/Feature/Settings/TwoFactorAuthenticationTest.php @@ -166,6 +166,7 @@ public function test_controller_removes_confirming_session_data_when_cleanup_tri ]); $user = User::factory()->create(); + $user->forceFill([ 'two_factor_secret' => encrypt('test-secret'), 'two_factor_recovery_codes' => encrypt(json_encode(['code1', 'code2'])), @@ -190,6 +191,7 @@ public function test_two_factor_authentication_disabled_when_confirmation_abando ]); $user = User::factory()->create(); + $user->forceFill([ 'two_factor_secret' => encrypt('test-secret'), 'two_factor_recovery_codes' => encrypt(json_encode(['code1', 'code2'])), From e2972bb950b55e14238db370e7fe1f26ac283230 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Mon, 1 Sep 2025 19:51:55 +0530 Subject: [PATCH 12/31] Formatting --- config/fortify.php | 2 +- .../components/two-factor-recovery-codes.tsx | 42 +++++-------------- .../Feature/Auth/PasswordConfirmationTest.php | 2 +- 3 files changed, 12 insertions(+), 34 deletions(-) diff --git a/config/fortify.php b/config/fortify.php index f9c657224..df49e4f8c 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/js/components/two-factor-recovery-codes.tsx b/resources/js/components/two-factor-recovery-codes.tsx index 40d682212..62c848c03 100644 --- a/resources/js/components/two-factor-recovery-codes.tsx +++ b/resources/js/components/two-factor-recovery-codes.tsx @@ -10,10 +10,7 @@ interface TwoFactorRecoveryCodesProps { fetchRecoveryCodes: () => Promise; } -export default function TwoFactorRecoveryCodes({ - recoveryCodesList, - fetchRecoveryCodes, -}: TwoFactorRecoveryCodesProps) { +export default function TwoFactorRecoveryCodes({ recoveryCodesList, fetchRecoveryCodes }: TwoFactorRecoveryCodesProps) { const [isCodesVisible, setIsCodesVisible] = useState(false); const codesSectionRef = useRef(null); @@ -28,7 +25,7 @@ export default function TwoFactorRecoveryCodes({ setTimeout(() => { codesSectionRef.current?.scrollIntoView({ behavior: 'smooth', - block: 'nearest' + block: 'nearest', }); }); } @@ -50,17 +47,12 @@ export default function TwoFactorRecoveryCodes({ 2FA Recovery Codes - Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager. + Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.
- @@ -68,16 +60,8 @@ export default function TwoFactorRecoveryCodes({ {isCodesVisible && ( {({ processing }) => ( - )} @@ -86,9 +70,7 @@ export default function TwoFactorRecoveryCodes({
@@ -101,11 +83,7 @@ export default function TwoFactorRecoveryCodes({ {!recoveryCodesList.length ? (
{Array.from({ length: 8 }, (_, index) => ( -

- 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. + 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.

diff --git a/tests/Feature/Auth/PasswordConfirmationTest.php b/tests/Feature/Auth/PasswordConfirmationTest.php index e499fc84b..495c3d044 100644 --- a/tests/Feature/Auth/PasswordConfirmationTest.php +++ b/tests/Feature/Auth/PasswordConfirmationTest.php @@ -19,7 +19,7 @@ public function test_confirm_password_screen_can_be_rendered() $response->assertStatus(200); $response->assertInertia(fn (Assert $page) => $page - ->component('auth/ConfirmPassword') + ->component('auth/confirm-password') ); } From ab5140c0e4b206aada40016bdb73af83f0760140 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Wed, 3 Sep 2025 21:01:59 +0530 Subject: [PATCH 13/31] Simplify Inertia Form --- package-lock.json | 46 +++++++++++-------- package.json | 2 +- .../js/components/two-factor-setup-modal.tsx | 3 +- .../js/pages/auth/two-factor-challenge.tsx | 3 +- 4 files changed, 31 insertions(+), 23 deletions(-) diff --git a/package-lock.json b/package-lock.json index d66611cfb..7e349e3bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "": { "dependencies": { "@headlessui/react": "^2.2.0", - "@inertiajs/react": "^2.1.0", + "@inertiajs/react": "^2.1.4", "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-collapsible": "^1.1.3", @@ -985,22 +985,24 @@ } }, "node_modules/@inertiajs/core": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.1.2.tgz", - "integrity": "sha512-fS3bDanwIZMEhtndhs1NvDvFN7y9Nx+FPkuBLSjIvYXFVmwieZmj+q2SYLXVl/jKt0qg69GwfLVrNm+gFiFbMg==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.1.4.tgz", + "integrity": "sha512-Mvq9itSwlAatS2zc6I+Pyu2bLnmgEm/fHwDd1AQ8xZUCLBKW5IaE2Y8hKKcuKcACU2Nu68jASq+hP5Inq7WXAQ==", "dependencies": { - "axios": "^1.8.2", - "es-toolkit": "^1.34.1", + "@types/lodash-es": "^4.17.12", + "axios": "^1.11.0", + "lodash-es": "^4.17.21", "qs": "^6.9.0" } }, "node_modules/@inertiajs/react": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@inertiajs/react/-/react-2.1.2.tgz", - "integrity": "sha512-hh3dQxoEumdjSRyMajYkEnG3fb3xkyexBD8tTSjo5OeulE/VteEjS7ZM8tNseM7ya/jb3G6ccoc5MSlYEh6atg==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@inertiajs/react/-/react-2.1.4.tgz", + "integrity": "sha512-vgvlMY/rh3sWsZRnJoeE4Sdu5basBu1z4CHk4KWzlmFWYzV+gGxIVRTgsdWn6rbfaUqD03FSG5WYrpYe94WOow==", "dependencies": { - "@inertiajs/core": "2.1.2", - "es-toolkit": "^1.33.0" + "@inertiajs/core": "2.1.4", + "@types/lodash-es": "^4.17.12", + "lodash-es": "^4.17.21" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -2755,6 +2757,19 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/node": { "version": "22.17.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.2.tgz", @@ -3942,15 +3957,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es-toolkit": { - "version": "1.39.9", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.9.tgz", - "integrity": "sha512-9OtbkZmTA2Qc9groyA1PUNeb6knVTkvB2RSdr/LcJXDL8IdEakaxwXLHXa7VX/Wj0GmdMJPR3WhnPGhiP3E+qg==", - "workspaces": [ - "docs", - "benchmarks" - ] - }, "node_modules/esbuild": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", diff --git a/package.json b/package.json index 4d6bb6a34..bb8e75fb9 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ }, "dependencies": { "@headlessui/react": "^2.2.0", - "@inertiajs/react": "^2.1.0", + "@inertiajs/react": "^2.1.4", "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-collapsible": "^1.1.3", diff --git a/resources/js/components/two-factor-setup-modal.tsx b/resources/js/components/two-factor-setup-modal.tsx index 4bb63f22f..23f27fe07 100644 --- a/resources/js/components/two-factor-setup-modal.tsx +++ b/resources/js/components/two-factor-setup-modal.tsx @@ -174,13 +174,14 @@ export default function TwoFactorSetupModal({
) : ( - onClose()} resetOnError transform={(data) => ({ ...data, code })}> + onClose()} resetOnError resetOnSuccess> {({ processing, errors }: { processing: boolean; errors?: { confirmTwoFactorAuthentication?: { code?: string } } }) => ( <>
setCode(value)} disabled={processing} diff --git a/resources/js/pages/auth/two-factor-challenge.tsx b/resources/js/pages/auth/two-factor-challenge.tsx index 7632cdd0e..e2e627513 100644 --- a/resources/js/pages/auth/two-factor-challenge.tsx +++ b/resources/js/pages/auth/two-factor-challenge.tsx @@ -42,12 +42,13 @@ export default function TwoFactorChallenge() {
{!showRecoveryInput ? ( - ({ ...data, code })}> + {({ errors, processing, clearErrors }) => ( <>
setCode(value)} From b612ae540bbc3eeb246ba70a4ffc8b8e0fd849ed Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Fri, 5 Sep 2025 15:31:52 +0530 Subject: [PATCH 14/31] Refactor Test --- tests/Feature/Auth/PasswordResetTest.php | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/tests/Feature/Auth/PasswordResetTest.php b/tests/Feature/Auth/PasswordResetTest.php index ea968ab82..6737c2a76 100644 --- a/tests/Feature/Auth/PasswordResetTest.php +++ b/tests/Feature/Auth/PasswordResetTest.php @@ -38,17 +38,10 @@ public function test_reset_password_screen_can_be_rendered() $this->post(route('password.email'), ['email' => $user->email]); - Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) { - $response = $this->get(route('password.reset', $notification->token).'?email='.$user->email); - - $response->assertStatus(200) - ->assertInertia(fn ($page) => $page - ->component('auth/reset-password') - ->has('email') - ->has('token') - ->where('email', $user->email) - ->where('token', $notification->token) - ); + Notification::assertSentTo($user, ResetPassword::class, function ($notification) { + $response = $this->get(route('password.reset', $notification->token)); + + $response->assertStatus(200); return true; }); From 5c38d1869a2a770959e38d31fb457e57394f0b82 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Fri, 5 Sep 2025 23:50:57 +0530 Subject: [PATCH 15/31] Fix Issue --- .../components/two-factor-recovery-codes.tsx | 26 +- .../js/components/two-factor-setup-modal.tsx | 264 ++++++++++-------- ...factor-auth.tsx => use-two-factor-auth.ts} | 29 +- .../js/pages/auth/two-factor-challenge.tsx | 3 +- 4 files changed, 171 insertions(+), 151 deletions(-) rename resources/js/hooks/{use-two-factor-auth.tsx => use-two-factor-auth.ts} (82%) diff --git a/resources/js/components/two-factor-recovery-codes.tsx b/resources/js/components/two-factor-recovery-codes.tsx index 62c848c03..a197287ab 100644 --- a/resources/js/components/two-factor-recovery-codes.tsx +++ b/resources/js/components/two-factor-recovery-codes.tsx @@ -2,7 +2,7 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { regenerateRecoveryCodes } from '@/routes/two-factor'; import { Form } from '@inertiajs/react'; -import { Eye, EyeOff, LockKeyhole, LucideIcon, RefreshCw } from 'lucide-react'; +import { Eye, EyeOff, LockKeyhole, RefreshCw } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; interface TwoFactorRecoveryCodesProps { @@ -11,17 +11,17 @@ interface TwoFactorRecoveryCodesProps { } export default function TwoFactorRecoveryCodes({ recoveryCodesList, fetchRecoveryCodes }: TwoFactorRecoveryCodesProps) { - const [isCodesVisible, setIsCodesVisible] = useState(false); + const [codesAeVisible, setCodesAeVisible] = useState(false); const codesSectionRef = useRef(null); const toggleCodesVisibility = useCallback(async () => { - if (!isCodesVisible && !recoveryCodesList.length) { + if (!codesAeVisible && !recoveryCodesList.length) { await fetchRecoveryCodes(); } - setIsCodesVisible(!isCodesVisible); + setCodesAeVisible(!codesAeVisible); - if (!isCodesVisible) { + if (!codesAeVisible) { setTimeout(() => { codesSectionRef.current?.scrollIntoView({ behavior: 'smooth', @@ -29,7 +29,7 @@ export default function TwoFactorRecoveryCodes({ recoveryCodesList, fetchRecover }); }); } - }, [isCodesVisible, recoveryCodesList.length, fetchRecoveryCodes]); + }, [codesAeVisible, recoveryCodesList.length, fetchRecoveryCodes]); useEffect(() => { if (!recoveryCodesList.length) { @@ -37,7 +37,7 @@ export default function TwoFactorRecoveryCodes({ recoveryCodesList, fetchRecover } }, [recoveryCodesList.length, fetchRecoveryCodes]); - const IconComponent: LucideIcon = isCodesVisible ? EyeOff : Eye; + const RecoveryCodeIconComponent = codesAeVisible ? EyeOff : Eye; return ( @@ -52,12 +52,12 @@ export default function TwoFactorRecoveryCodes({ recoveryCodesList, fetchRecover
- - {isCodesVisible && ( + {codesAeVisible && ( {({ processing }) => (
+
+
+ {Array.from({ length: 5 }, (_, i) => ( +
+ ))} +
+
+ {Array.from({ length: 5 }, (_, i) => ( +
+ ))} +
+ +
+
+ ); +} + +function TwoFactorSetupStep({ + qrCodeSvg, + manualSetupKey, + buttonText, + onNextStep, +}: { + qrCodeSvg: string | null; + manualSetupKey: string | null; + buttonText: string; + onNextStep: () => void; +}) { + const [copiedText, copy] = useClipboard(); + const CopyIcon: LucideIcon = copiedText === manualSetupKey ? Check : Copy; + + return ( + <> +
+
+ {!qrCodeSvg ? ( +
+ +
+ ) : ( +
+
+
+ )} +
+
+ +
+ +
+ +
+
+ or, enter the code manually +
+ +
+
+ {!manualSetupKey ? ( +
+ +
+ ) : ( + <> + + + + )} +
+
+ + ); +} + +function TwoFactorVerificationStep({ onClose, onBack }: { onClose: () => void; onBack: () => void }) { + const [code, setCode] = useState(''); + const pinInputContainerRef = useRef(null); + + useEffect(() => { + setTimeout(() => { + pinInputContainerRef.current?.querySelector('input')?.focus(); + }, 0); + }, []); + + return ( + onClose()} resetOnError resetOnSuccess> + {({ processing, errors }: { processing: boolean; errors?: { confirmTwoFactorAuthentication?: { code?: string } } }) => ( + <> +
+
+ + + {Array.from({ length: OTP_MAX_LENGTH }, (_, index) => ( + + ))} + + + +
+ +
+ + +
+
+ + )} + + ); +} interface TwoFactorSetupModalProps { isOpen: boolean; @@ -32,12 +165,7 @@ export default function TwoFactorSetupModal({ clearSetupData, fetchSetupData, }: TwoFactorSetupModalProps) { - const [copiedText, copy] = useClipboard(); - const [showVerificationStep, setShowVerificationStep] = useState(false); - const [code, setCode] = useState(''); - - const pinInputContainerRef = useRef(null); const modalConfig = useMemo<{ title: string; description: string; buttonText: string }>(() => { if (twoFactorEnabled) { @@ -66,9 +194,6 @@ export default function TwoFactorSetupModal({ const handleModalNextStep = useCallback(() => { if (requiresConfirmation) { setShowVerificationStep(true); - setTimeout(() => { - pinInputContainerRef.current?.querySelector('input')?.focus(); - }, 0); return; } clearSetupData(); @@ -77,7 +202,6 @@ export default function TwoFactorSetupModal({ const resetModalState = useCallback(() => { setShowVerificationStep(false); - setCode(''); if (twoFactorEnabled) { clearSetupData(); } @@ -93,127 +217,25 @@ export default function TwoFactorSetupModal({ } }, [isOpen, qrCodeSvg, fetchSetupData, resetModalState]); - const CopyIcon: LucideIcon = copiedText === manualSetupKey ? Check : Copy; return ( !open && onClose()}> -
-
-
- {Array.from({ length: 5 }, (_, i) => ( -
- ))} -
-
- {Array.from({ length: 5 }, (_, i) => ( -
- ))} -
- -
-
+ {modalConfig.title} {modalConfig.description} -
+
{!showVerificationStep ? ( - <> -
-
- {!qrCodeSvg ? ( -
- -
- ) : ( -
-
-
- )} -
-
- -
- -
- -
-
- or, enter the code manually -
- -
-
- {!manualSetupKey ? ( -
- -
- ) : ( - <> - - - - )} -
-
- + ) : ( -
onClose()} resetOnError resetOnSuccess> - {({ processing, errors }: { processing: boolean; errors?: { confirmTwoFactorAuthentication?: { code?: string } } }) => ( - <> -
-
- setCode(value)} - disabled={processing} - pattern={REGEXP_ONLY_DIGITS} - > - - {Array.from({ length: OTP_MAX_LENGTH }, (_, index) => ( - - ))} - - - -
- -
- - -
-
- - )} -
+ setShowVerificationStep(false)} /> )}
diff --git a/resources/js/hooks/use-two-factor-auth.tsx b/resources/js/hooks/use-two-factor-auth.ts similarity index 82% rename from resources/js/hooks/use-two-factor-auth.tsx rename to resources/js/hooks/use-two-factor-auth.ts index 4f9cfad8f..8b3d10f13 100644 --- a/resources/js/hooks/use-two-factor-auth.tsx +++ b/resources/js/hooks/use-two-factor-auth.ts @@ -2,7 +2,9 @@ import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor'; import { type TwoFactorSecretKey, type TwoFactorSetupData } from '@/types'; import { useCallback, useMemo, useState } from 'react'; -const fetchJson = async (url: string): Promise => { +export const OTP_MAX_LENGTH = 6; + +const fetchJson = async (url: string): Promise => { const response = await fetch(url, { headers: { Accept: 'application/json' }, }); @@ -66,18 +68,15 @@ export const useTwoFactorAuth = () => { } }, [fetchQrCode, fetchSetupKey]); - return useMemo( - () => ({ - qrCodeSvg, - manualSetupKey, - recoveryCodesList, - hasSetupData, - clearSetupData, - fetchQrCode, - fetchSetupKey, - fetchSetupData, - fetchRecoveryCodes, - }), - [qrCodeSvg, manualSetupKey, recoveryCodesList, hasSetupData, clearSetupData, fetchQrCode, fetchSetupKey, fetchSetupData, fetchRecoveryCodes], - ); + return { + qrCodeSvg, + manualSetupKey, + recoveryCodesList, + hasSetupData, + clearSetupData, + fetchQrCode, + fetchSetupKey, + fetchSetupData, + fetchRecoveryCodes, + }; }; diff --git a/resources/js/pages/auth/two-factor-challenge.tsx b/resources/js/pages/auth/two-factor-challenge.tsx index e2e627513..b0181d83a 100644 --- a/resources/js/pages/auth/two-factor-challenge.tsx +++ b/resources/js/pages/auth/two-factor-challenge.tsx @@ -2,14 +2,13 @@ import InputError from '@/components/input-error'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp'; +import { OTP_MAX_LENGTH } from '@/hooks/use-two-factor-auth'; import AuthLayout from '@/layouts/auth-layout'; import { store } from '@/routes/two-factor/login'; import { Form, Head } from '@inertiajs/react'; import { REGEXP_ONLY_DIGITS } from 'input-otp'; import { useMemo, useState } from 'react'; -const OTP_MAX_LENGTH = 6; - export default function TwoFactorChallenge() { const [showRecoveryInput, setShowRecoveryInput] = useState(false); const [code, setCode] = useState(''); From 5397f80b72f110a57ab51daece155a18dbd379be Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Sat, 6 Sep 2025 00:08:53 +0530 Subject: [PATCH 16/31] Remove explicit typing --- resources/js/components/two-factor-setup-modal.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/resources/js/components/two-factor-setup-modal.tsx b/resources/js/components/two-factor-setup-modal.tsx index ec9b95fa9..67687f06c 100644 --- a/resources/js/components/two-factor-setup-modal.tsx +++ b/resources/js/components/two-factor-setup-modal.tsx @@ -7,7 +7,7 @@ import { confirm } from '@/routes/two-factor'; import { Form } from '@inertiajs/react'; import { useClipboard } from '@reactuses/core'; import { REGEXP_ONLY_DIGITS } from 'input-otp'; -import { Check, Copy, Loader2, LucideIcon, ScanLine } from 'lucide-react'; +import { Check, Copy, Loader2, ScanLine } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; function GridScanIcon() { @@ -42,7 +42,7 @@ function TwoFactorSetupStep({ onNextStep: () => void; }) { const [copiedText, copy] = useClipboard(); - const CopyIcon: LucideIcon = copiedText === manualSetupKey ? Check : Copy; + const IconComponent = copiedText === manualSetupKey ? Check : Copy; return ( <> @@ -86,7 +86,7 @@ function TwoFactorSetupStep({ className="h-full w-full bg-background p-3 text-foreground outline-none" /> )} From db3dc9719fe9a567454df462795ab746bf7dc28e Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 9 Sep 2025 00:36:48 +0530 Subject: [PATCH 17/31] formating --- .../components/two-factor-recovery-codes.tsx | 3 +- .../js/components/two-factor-setup-modal.tsx | 2 +- resources/js/hooks/use-two-factor-auth.ts | 5 +- resources/js/pages/settings/two-factor.tsx | 27 ++-- tests/Feature/Auth/AuthenticationTest.php | 24 +--- .../Feature/Auth/PasswordConfirmationTest.php | 1 + tests/Feature/Auth/TwoFactorChallengeTest.php | 41 +----- .../Settings/TwoFactorAuthenticationTest.php | 127 +----------------- 8 files changed, 21 insertions(+), 209 deletions(-) diff --git a/resources/js/components/two-factor-recovery-codes.tsx b/resources/js/components/two-factor-recovery-codes.tsx index a197287ab..e1d928bc3 100644 --- a/resources/js/components/two-factor-recovery-codes.tsx +++ b/resources/js/components/two-factor-recovery-codes.tsx @@ -61,8 +61,7 @@ export default function TwoFactorRecoveryCodes({ recoveryCodesList, fetchRecover
{({ processing }) => ( )}
diff --git a/resources/js/components/two-factor-setup-modal.tsx b/resources/js/components/two-factor-setup-modal.tsx index 67687f06c..7a0ce27b4 100644 --- a/resources/js/components/two-factor-setup-modal.tsx +++ b/resources/js/components/two-factor-setup-modal.tsx @@ -134,7 +134,7 @@ function TwoFactorVerificationStep({ onClose, onBack }: { onClose: () => void; o Back
diff --git a/resources/js/hooks/use-two-factor-auth.ts b/resources/js/hooks/use-two-factor-auth.ts index 8b3d10f13..1dbbcee2c 100644 --- a/resources/js/hooks/use-two-factor-auth.ts +++ b/resources/js/hooks/use-two-factor-auth.ts @@ -1,6 +1,6 @@ import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor'; import { type TwoFactorSecretKey, type TwoFactorSetupData } from '@/types'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useState } from 'react'; export const OTP_MAX_LENGTH = 6; @@ -21,8 +21,6 @@ export const useTwoFactorAuth = () => { const [manualSetupKey, setManualSetupKey] = useState(null); const [recoveryCodesList, setRecoveryCodesList] = useState([]); - const hasSetupData = useMemo(() => qrCodeSvg !== null && manualSetupKey !== null, [qrCodeSvg, manualSetupKey]); - const fetchQrCode = useCallback(async (): Promise => { try { const { svg } = await fetchJson(qrCode.url()); @@ -72,7 +70,6 @@ export const useTwoFactorAuth = () => { qrCodeSvg, manualSetupKey, recoveryCodesList, - hasSetupData, clearSetupData, fetchQrCode, fetchSetupKey, diff --git a/resources/js/pages/settings/two-factor.tsx b/resources/js/pages/settings/two-factor.tsx index b4921fe38..b77606401 100644 --- a/resources/js/pages/settings/two-factor.tsx +++ b/resources/js/pages/settings/two-factor.tsx @@ -25,7 +25,7 @@ const breadcrumbs: BreadcrumbItem[] = [ ]; export default function TwoFactor({ requiresConfirmation = false, twoFactorEnabled = false }: TwoFactorProps) { - const { hasSetupData, qrCodeSvg, manualSetupKey, clearSetupData, fetchSetupData, recoveryCodesList, fetchRecoveryCodes } = useTwoFactorAuth(); + const { qrCodeSvg, manualSetupKey, clearSetupData, fetchSetupData, recoveryCodesList, fetchRecoveryCodes } = useTwoFactorAuth(); const [showSetupModal, setShowSetupModal] = useState(false); return ( @@ -44,21 +44,13 @@ export default function TwoFactor({ requiresConfirmation = false, twoFactorEnabl

- {hasSetupData ? ( - - ) : ( -
setShowSetupModal(true)}> - {({ processing }) => ( - - )} -
- )} +
setShowSetupModal(true)}> + {({ processing }) => ( + + )} +
) : ( @@ -75,8 +67,7 @@ export default function TwoFactor({ requiresConfirmation = false, twoFactorEnabl
{({ processing }) => ( )}
diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index 4ebec9e09..7c2a12367 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 Illuminate\Support\Facades\RateLimiter; use Laravel\Fortify\Features; use Tests\TestCase; @@ -60,20 +61,6 @@ public function test_users_with_two_factor_enabled_are_redirected_to_two_factor_ $this->assertGuest(); } - public function test_users_without_two_factor_enabled_login_normally() - { - $user = User::factory()->create(); - - $response = $this->post(route('login'), [ - 'email' => $user->email, - 'password' => 'password', - ]); - - $this->assertAuthenticated(); - $response->assertRedirect(route('dashboard', absolute: false)); - $response->assertSessionMissing('login.id'); - } - public function test_users_can_not_authenticate_with_invalid_password() { $user = User::factory()->create(); @@ -100,14 +87,7 @@ public function test_users_are_rate_limited() { $user = User::factory()->create(); - for ($i = 0; $i < 5; $i++) { - $this->post(route('login.store'), [ - 'email' => $user->email, - 'password' => 'wrong-password', - ])->assertStatus(302)->assertSessionHasErrors([ - 'email' => 'These credentials do not match our records.', - ]); - } + RateLimiter::increment(implode('|', [$user->email, '127.0.0.1']), amount: 10); $response = $this->post(route('login.store'), [ 'email' => $user->email, diff --git a/tests/Feature/Auth/PasswordConfirmationTest.php b/tests/Feature/Auth/PasswordConfirmationTest.php index 495c3d044..5191f9c20 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() $response = $this->actingAs($user)->get(route('password.confirm')); $response->assertStatus(200); + $response->assertInertia(fn (Assert $page) => $page ->component('auth/confirm-password') ); diff --git a/tests/Feature/Auth/TwoFactorChallengeTest.php b/tests/Feature/Auth/TwoFactorChallengeTest.php index 454865cd5..b8d6b803e 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_inertia_component(): void + public function test_two_factor_challenge_can_be_rendered(): void { if (! Features::canManageTwoFactorAuthentication()) { $this->markTestSkipped('Two-factor authentication is not enabled.'); @@ -53,41 +53,4 @@ public function test_two_factor_challenge_renders_correct_inertia_component(): v ->component('auth/two-factor-challenge') ); } - - 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(); - - $this->post(route('login'), [ - 'email' => $user->email, - 'password' => 'password', - ]); - - foreach (range(0, 4) as $ignored) { - $this->post(route('two-factor.login.store'), [ - 'code' => '000000', - ])->assertSessionHasErrors('code'); - } - - $response = $this->post(route('two-factor.login.store'), [ - 'code' => '000000', - ]); - - $response->assertTooManyRequests(); - } } diff --git a/tests/Feature/Settings/TwoFactorAuthenticationTest.php b/tests/Feature/Settings/TwoFactorAuthenticationTest.php index 4efd6521c..12dca797c 100644 --- a/tests/Feature/Settings/TwoFactorAuthenticationTest.php +++ b/tests/Feature/Settings/TwoFactorAuthenticationTest.php @@ -12,7 +12,7 @@ class TwoFactorAuthenticationTest extends TestCase { use RefreshDatabase; - public function test_two_factor_settings_page_is_displayed() + public function test_two_factor_settings_page_can_be_rendered() { if (! Features::canManageTwoFactorAuthentication()) { $this->markTestSkipped('Two-factor authentication is not enabled.'); @@ -34,7 +34,7 @@ public function test_two_factor_settings_page_is_displayed() ); } - public function test_two_factor_settings_page_requires_password_confirmation() + public function test_two_factor_settings_page_requires_password_confirmation_when_enabled() { if (! Features::canManageTwoFactorAuthentication()) { $this->markTestSkipped('Two-factor authentication is not enabled.'); @@ -53,7 +53,7 @@ public function test_two_factor_settings_page_requires_password_confirmation() $response->assertRedirect(route('password.confirm')); } - public function test_two_factor_settings_page_does_not_requires_password_confirmation_if_that_feature_is_disabled() + public function test_two_factor_settings_page_does_not_requires_password_confirmation_when_disabled() { if (! Features::canManageTwoFactorAuthentication()) { $this->markTestSkipped('Two-factor authentication is not enabled.'); @@ -74,7 +74,7 @@ public function test_two_factor_settings_page_does_not_requires_password_confirm ); } - public function test_two_factor_settings_page_returns_forbidden_when_two_factor_is_disabled() + public function test_two_factor_settings_page_returns_forbidden_response_when_two_factor_is_disabled() { if (! Features::canManageTwoFactorAuthentication()) { $this->markTestSkipped('Two-factor authentication is not enabled.'); @@ -89,123 +89,4 @@ public function test_two_factor_settings_page_returns_forbidden_when_two_factor_ ->get(route('two-factor.show')) ->assertForbidden(); } - - public function test_controller_sets_confirming_data_when_enabling_two_factor_with_confirmation() - { - if (! Features::canManageTwoFactorAuthentication()) { - $this->markTestSkipped('Two-factor authentication is not enabled.'); - } - - Features::twoFactorAuthentication([ - 'confirm' => true, - 'confirmPassword' => false, - ]); - - $user = User::factory()->create(); - - $this->actingAs($user) - ->withSession(['auth.password_confirmed_at' => time()]) - ->withSession(['two_factor_empty_at' => time() - 10]) - ->post(route('two-factor.enable')); - - $this->get(route('two-factor.show')) - ->assertOk(); - - $this->assertNotNull(session('two_factor_confirming_at')); - } - - public function test_user_can_view_setting_page_when_confirm_disabled() - { - if (! Features::canManageTwoFactorAuthentication()) { - $this->markTestSkipped('Two-factor authentication is not enabled.'); - } - - Features::twoFactorAuthentication([ - 'confirm' => false, - 'confirmPassword' => false, - ]); - - $user = User::factory()->create(); - - $this->actingAs($user) - ->get(route('two-factor.show')) - ->assertOk() - ->assertInertia(fn (Assert $page) => $page - ->component('settings/two-factor') - ->where('requiresConfirmation', false) - ); - } - - public function test_controller_sets_empty_session_data_when_transitioning_to_disabled_state() - { - if (! Features::canManageTwoFactorAuthentication()) { - $this->markTestSkipped('Two-factor authentication is not enabled.'); - } - - Features::twoFactorAuthentication([ - 'confirm' => true, - 'confirmPassword' => false, - ]); - - $user = User::factory()->create(); - - $this->actingAs($user) - ->get(route('two-factor.show')) - ->assertSessionHas('two_factor_empty_at'); - } - - public function test_controller_removes_confirming_session_data_when_cleanup_triggered() - { - if (! Features::canManageTwoFactorAuthentication()) { - $this->markTestSkipped('Two-factor authentication is not enabled.'); - } - - Features::twoFactorAuthentication([ - 'confirm' => true, - 'confirmPassword' => false, - ]); - - $user = User::factory()->create(); - - $user->forceFill([ - 'two_factor_secret' => encrypt('test-secret'), - 'two_factor_recovery_codes' => encrypt(json_encode(['code1', 'code2'])), - ])->save(); - - $this->actingAs($user) - ->withSession(['two_factor_confirming_at' => time() - 100]) - ->get(route('two-factor.show')) - ->assertSessionMissing('two_factor_confirming_at') - ->assertSessionHas('two_factor_empty_at'); - } - - public function test_two_factor_authentication_disabled_when_confirmation_abandoned_between_requests() - { - if (! Features::canManageTwoFactorAuthentication()) { - $this->markTestSkipped('Two-factor authentication is not enabled.'); - } - - Features::twoFactorAuthentication([ - 'confirm' => true, - 'confirmPassword' => false, - ]); - - $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) - ->withSession(['two_factor_confirming_at' => time() - 100]) - ->get(route('two-factor.show')); - - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'two_factor_secret' => null, - 'two_factor_recovery_codes' => null, - ]); - } } From 791f3d666589a60ac6e564d74309ffe6680eaa93 Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Tue, 9 Sep 2025 09:38:47 -0400 Subject: [PATCH 18/31] cursor pointer for buttons styled as links --- resources/js/pages/auth/two-factor-challenge.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/js/pages/auth/two-factor-challenge.tsx b/resources/js/pages/auth/two-factor-challenge.tsx index b0181d83a..d5cdd76f5 100644 --- a/resources/js/pages/auth/two-factor-challenge.tsx +++ b/resources/js/pages/auth/two-factor-challenge.tsx @@ -70,7 +70,7 @@ export default function TwoFactorChallenge() { or you can + +
+ or you can + +
+ + )} + + ) : (
{({ errors, processing, clearErrors }) => ( <> @@ -66,29 +89,6 @@ export default function TwoFactorChallenge() { -
- or you can - -
- - )} -
- ) : ( -
- {({ errors, processing, clearErrors }) => ( - <> - - - -
or you can )} @@ -55,19 +56,17 @@ export default function TwoFactor({ requiresConfirmation = false, twoFactorEnabl
) : (
- Enabled + Disabled

- 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. + 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.

- - -
-
+
+ setShowSetupModal(true)}> {({ processing }) => ( - )} From 8710fe4e2bf4e60054d81a288e3ed387f331f056 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 9 Sep 2025 20:43:23 +0530 Subject: [PATCH 21/31] fix typo --- .../components/two-factor-recovery-codes.tsx | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/resources/js/components/two-factor-recovery-codes.tsx b/resources/js/components/two-factor-recovery-codes.tsx index 9faa06c0e..86f1db76f 100644 --- a/resources/js/components/two-factor-recovery-codes.tsx +++ b/resources/js/components/two-factor-recovery-codes.tsx @@ -11,17 +11,17 @@ interface TwoFactorRecoveryCodesProps { } export default function TwoFactorRecoveryCodes({ recoveryCodesList, fetchRecoveryCodes }: TwoFactorRecoveryCodesProps) { - const [codesAeVisible, setCodesAeVisible] = useState(false); + const [codesAreVisible, setCodesAreVisible] = useState(false); const codesSectionRef = useRef(null); const toggleCodesVisibility = useCallback(async () => { - if (!codesAeVisible && !recoveryCodesList.length) { + if (!codesAreVisible && !recoveryCodesList.length) { await fetchRecoveryCodes(); } - setCodesAeVisible(!codesAeVisible); + setCodesAreVisible(!codesAreVisible); - if (!codesAeVisible) { + if (!codesAreVisible) { setTimeout(() => { codesSectionRef.current?.scrollIntoView({ behavior: 'smooth', @@ -29,7 +29,7 @@ export default function TwoFactorRecoveryCodes({ recoveryCodesList, fetchRecover }); }); } - }, [codesAeVisible, recoveryCodesList.length, fetchRecoveryCodes]); + }, [codesAreVisible, recoveryCodesList.length, fetchRecoveryCodes]); useEffect(() => { if (!recoveryCodesList.length) { @@ -37,7 +37,7 @@ export default function TwoFactorRecoveryCodes({ recoveryCodesList, fetchRecover } }, [recoveryCodesList.length, fetchRecoveryCodes]); - const RecoveryCodeIconComponent = codesAeVisible ? EyeOff : Eye; + const RecoveryCodeIconComponent = codesAreVisible ? EyeOff : Eye; return ( @@ -52,12 +52,12 @@ export default function TwoFactorRecoveryCodes({ recoveryCodesList, fetchRecover
- - {codesAeVisible && ( + {codesAreVisible && (
{({ processing }) => (
Date: Tue, 9 Sep 2025 20:57:59 +0530 Subject: [PATCH 22/31] formatting --- resources/js/hooks/use-clipboard.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/resources/js/hooks/use-clipboard.ts b/resources/js/hooks/use-clipboard.ts index b9b9c1249..03a0ca977 100644 --- a/resources/js/hooks/use-clipboard.ts +++ b/resources/js/hooks/use-clipboard.ts @@ -1,5 +1,4 @@ -/** Credits to https://github.com/juliencrn/usehooks-ts */ - +// Credit: https://usehooks-ts.com/ import { useCallback, useState } from 'react'; type CopiedValue = string | null; From a3dbdbaf2baa3be39fcd42b21066b7432f0e25e1 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 9 Sep 2025 21:10:07 +0530 Subject: [PATCH 23/31] Add hasSetupData to avoid enabling 2FA again --- resources/js/hooks/use-two-factor-auth.ts | 5 ++++- resources/js/pages/settings/two-factor.tsx | 24 ++++++++++++++-------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/resources/js/hooks/use-two-factor-auth.ts b/resources/js/hooks/use-two-factor-auth.ts index 1dbbcee2c..8b3d10f13 100644 --- a/resources/js/hooks/use-two-factor-auth.ts +++ b/resources/js/hooks/use-two-factor-auth.ts @@ -1,6 +1,6 @@ import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor'; import { type TwoFactorSecretKey, type TwoFactorSetupData } from '@/types'; -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; export const OTP_MAX_LENGTH = 6; @@ -21,6 +21,8 @@ export const useTwoFactorAuth = () => { const [manualSetupKey, setManualSetupKey] = useState(null); const [recoveryCodesList, setRecoveryCodesList] = useState([]); + const hasSetupData = useMemo(() => qrCodeSvg !== null && manualSetupKey !== null, [qrCodeSvg, manualSetupKey]); + const fetchQrCode = useCallback(async (): Promise => { try { const { svg } = await fetchJson(qrCode.url()); @@ -70,6 +72,7 @@ export const useTwoFactorAuth = () => { qrCodeSvg, manualSetupKey, recoveryCodesList, + hasSetupData, clearSetupData, fetchQrCode, fetchSetupKey, diff --git a/resources/js/pages/settings/two-factor.tsx b/resources/js/pages/settings/two-factor.tsx index e57788236..56b5995c2 100644 --- a/resources/js/pages/settings/two-factor.tsx +++ b/resources/js/pages/settings/two-factor.tsx @@ -25,7 +25,7 @@ const breadcrumbs: BreadcrumbItem[] = [ ]; export default function TwoFactor({ requiresConfirmation = false, twoFactorEnabled = false }: TwoFactorProps) { - const { qrCodeSvg, manualSetupKey, clearSetupData, fetchSetupData, recoveryCodesList, fetchRecoveryCodes } = useTwoFactorAuth(); + const { qrCodeSvg, hasSetupData, manualSetupKey, clearSetupData, fetchSetupData, recoveryCodesList, fetchRecoveryCodes } = useTwoFactorAuth(); const [showSetupModal, setShowSetupModal] = useState(false); return ( @@ -63,13 +63,21 @@ export default function TwoFactor({ requiresConfirmation = false, twoFactorEnabl

- setShowSetupModal(true)}> - {({ processing }) => ( - - )} - + {hasSetupData ? ( + + ) : ( +
setShowSetupModal(true)}> + {({ processing }) => ( + + )} +
+ )}
)} From 4aea6c69752e2722bb75deef5ff393644db4da48 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 9 Sep 2025 22:09:50 +0530 Subject: [PATCH 24/31] Refactor two-factor authentication types to local scope --- resources/js/hooks/use-two-factor-auth.ts | 10 +++++++++- resources/js/types/index.d.ts | 20 -------------------- 2 files changed, 9 insertions(+), 21 deletions(-) diff --git a/resources/js/hooks/use-two-factor-auth.ts b/resources/js/hooks/use-two-factor-auth.ts index 8b3d10f13..0c8cd1aa5 100644 --- a/resources/js/hooks/use-two-factor-auth.ts +++ b/resources/js/hooks/use-two-factor-auth.ts @@ -1,7 +1,15 @@ import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor'; -import { type TwoFactorSecretKey, type TwoFactorSetupData } from '@/types'; import { useCallback, useMemo, useState } from 'react'; +interface TwoFactorSetupData { + svg: string; + url: string; +} + +interface TwoFactorSecretKey { + secretKey: string; +} + export const OTP_MAX_LENGTH = 6; const fetchJson = async (url: string): Promise => { diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index 6f7d4cb6b..2f10844c7 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -41,23 +41,3 @@ export interface User { updated_at: string; [key: string]: unknown; // This allows for additional properties... } - -export interface TwoFactorSetupData { - svg: string; - url: string; -} - -export interface TwoFactorSecretKey { - secretKey: string; -} - -export interface TwoFactorAuthenticationError { - code?: string; -} - -export interface FormErrors { - [key: string]: string | FormErrors | undefined; - confirmTwoFactorAuthentication?: TwoFactorAuthenticationError; - code?: string; - recovery_code?: string; -} From 6260c6b439c0ab41e61dacece7d951198a0e16e3 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 9 Sep 2025 22:17:51 +0530 Subject: [PATCH 25/31] formatting --- resources/js/components/two-factor-setup-modal.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/resources/js/components/two-factor-setup-modal.tsx b/resources/js/components/two-factor-setup-modal.tsx index 91156c467..8d77b2902 100644 --- a/resources/js/components/two-factor-setup-modal.tsx +++ b/resources/js/components/two-factor-setup-modal.tsx @@ -212,6 +212,7 @@ export default function TwoFactorSetupModal({ useEffect(() => { if (!isOpen) { resetModalState(); + return; } From cf5971da982aac6c08f819bac5879c0d9c6fd72e Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 9 Sep 2025 22:18:56 +0530 Subject: [PATCH 26/31] formatting --- resources/js/hooks/use-clipboard.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/resources/js/hooks/use-clipboard.ts b/resources/js/hooks/use-clipboard.ts index 03a0ca977..eef514b0e 100644 --- a/resources/js/hooks/use-clipboard.ts +++ b/resources/js/hooks/use-clipboard.ts @@ -11,16 +11,19 @@ export function useClipboard(): [CopiedValue, CopyFn] { const copy: CopyFn = useCallback(async (text) => { if (!navigator?.clipboard) { console.warn('Clipboard not supported'); + return false; } try { await navigator.clipboard.writeText(text); setCopiedText(text); + return true; } catch (error) { console.warn('Copy failed', error); setCopiedText(null); + return false; } }, []); From 85e3005e21cc36c618260f97bf4801b1d4935bc7 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Wed, 10 Sep 2025 19:45:43 +0530 Subject: [PATCH 27/31] formatting --- resources/js/hooks/use-two-factor-auth.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/resources/js/hooks/use-two-factor-auth.ts b/resources/js/hooks/use-two-factor-auth.ts index 0c8cd1aa5..c67545fd2 100644 --- a/resources/js/hooks/use-two-factor-auth.ts +++ b/resources/js/hooks/use-two-factor-auth.ts @@ -34,9 +34,11 @@ export const useTwoFactorAuth = () => { const fetchQrCode = useCallback(async (): Promise => { try { const { svg } = await fetchJson(qrCode.url()); + setQrCodeSvg(svg); } catch (error) { console.error('Failed to fetch QR code:', error); + setQrCodeSvg(null); } }, []); @@ -44,9 +46,11 @@ export const useTwoFactorAuth = () => { const fetchSetupKey = useCallback(async (): Promise => { try { const { secretKey: key } = await fetchJson(secretKey.url()); + setManualSetupKey(key); } catch (error) { console.error('Failed to fetch setup key:', error); + setManualSetupKey(null); } }, []); @@ -59,9 +63,11 @@ export const useTwoFactorAuth = () => { const fetchRecoveryCodes = useCallback(async (): Promise => { try { const codes = await fetchJson(recoveryCodes.url()); + setRecoveryCodesList(codes); } catch (error) { console.error('Failed to fetch recovery codes:', error); + setRecoveryCodesList([]); } }, []); @@ -71,6 +77,7 @@ export const useTwoFactorAuth = () => { await Promise.all([fetchQrCode(), fetchSetupKey()]); } catch (error) { console.error('Failed to fetch setup data:', error); + setQrCodeSvg(null); setManualSetupKey(null); } From f779357ac4baade6683b7f2f3380391042d5db6d Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Wed, 10 Sep 2025 12:02:36 -0400 Subject: [PATCH 28/31] share more common elements, de-dupe --- .../js/pages/auth/two-factor-challenge.tsx | 78 ++++++++----------- 1 file changed, 34 insertions(+), 44 deletions(-) diff --git a/resources/js/pages/auth/two-factor-challenge.tsx b/resources/js/pages/auth/two-factor-challenge.tsx index 822cc1eb2..f11f0e83b 100644 --- a/resources/js/pages/auth/two-factor-challenge.tsx +++ b/resources/js/pages/auth/two-factor-challenge.tsx @@ -40,33 +40,21 @@ export default function TwoFactorChallenge() {
- {showRecoveryInput ? ( -
- {({ errors, processing, clearErrors }) => ( - <> - - - - -
- or you can - -
- - )} - - ) : ( -
- {({ errors, processing, clearErrors }) => ( - <> + + {({ errors, processing, clearErrors }) => ( + <> + {showRecoveryInput ? ( + <> + + + + ) : (
- -
- or you can - -
- - )} - - )} + )} + + + +
+ or you can + +
+ + )} +
); From e18dbbe17aaaba59686bd996d052b554235feac8 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Thu, 11 Sep 2025 22:37:05 +0530 Subject: [PATCH 29/31] Add an error state in use-two-factor-auth.ts --- resources/js/hooks/use-two-factor-auth.ts | 33 ++++++++++++++--------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/resources/js/hooks/use-two-factor-auth.ts b/resources/js/hooks/use-two-factor-auth.ts index c67545fd2..6d0efaf68 100644 --- a/resources/js/hooks/use-two-factor-auth.ts +++ b/resources/js/hooks/use-two-factor-auth.ts @@ -28,6 +28,11 @@ export const useTwoFactorAuth = () => { const [qrCodeSvg, setQrCodeSvg] = useState(null); const [manualSetupKey, setManualSetupKey] = useState(null); const [recoveryCodesList, setRecoveryCodesList] = useState([]); + const [errors, setErrors] = useState<{ + qrCode?: string; + setupKey?: string; + recoveryCodes?: string; + }>({}); const hasSetupData = useMemo(() => qrCodeSvg !== null && manualSetupKey !== null, [qrCodeSvg, manualSetupKey]); @@ -36,9 +41,8 @@ export const useTwoFactorAuth = () => { const { svg } = await fetchJson(qrCode.url()); setQrCodeSvg(svg); - } catch (error) { - console.error('Failed to fetch QR code:', error); - + } catch { + setErrors((prev) => ({ ...prev, qrCode: 'Failed to fetch QR code' })); setQrCodeSvg(null); } }, []); @@ -48,26 +52,29 @@ export const useTwoFactorAuth = () => { const { secretKey: key } = await fetchJson(secretKey.url()); setManualSetupKey(key); - } catch (error) { - console.error('Failed to fetch setup key:', error); - + } catch { + setErrors((prev) => ({ ...prev, setupKey: 'Failed to fetch a setup key' })); setManualSetupKey(null); } }, []); + const clearErrors = useCallback((): void => { + setErrors({}); + }, []); + const clearSetupData = useCallback((): void => { setManualSetupKey(null); setQrCodeSvg(null); - }, []); + clearErrors(); + }, [clearErrors]); const fetchRecoveryCodes = useCallback(async (): Promise => { try { const codes = await fetchJson(recoveryCodes.url()); setRecoveryCodesList(codes); - } catch (error) { - console.error('Failed to fetch recovery codes:', error); - + } catch { + setErrors((prev) => ({ ...prev, recoveryCodes: 'Failed to fetch recovery codes' })); setRecoveryCodesList([]); } }, []); @@ -75,9 +82,7 @@ export const useTwoFactorAuth = () => { const fetchSetupData = useCallback(async (): Promise => { try { await Promise.all([fetchQrCode(), fetchSetupKey()]); - } catch (error) { - console.error('Failed to fetch setup data:', error); - + } catch { setQrCodeSvg(null); setManualSetupKey(null); } @@ -88,6 +93,8 @@ export const useTwoFactorAuth = () => { manualSetupKey, recoveryCodesList, hasSetupData, + errors, + clearErrors, clearSetupData, fetchQrCode, fetchSetupKey, From 416b73108c6ff3af359d2da08ada0cad2da6fe5d Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Fri, 12 Sep 2025 00:37:20 +0530 Subject: [PATCH 30/31] Refactor error handling in use-two-factor-auth.ts to use an array for errors instead of an object --- resources/js/hooks/use-two-factor-auth.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/resources/js/hooks/use-two-factor-auth.ts b/resources/js/hooks/use-two-factor-auth.ts index 6d0efaf68..c45218d1e 100644 --- a/resources/js/hooks/use-two-factor-auth.ts +++ b/resources/js/hooks/use-two-factor-auth.ts @@ -28,11 +28,7 @@ export const useTwoFactorAuth = () => { const [qrCodeSvg, setQrCodeSvg] = useState(null); const [manualSetupKey, setManualSetupKey] = useState(null); const [recoveryCodesList, setRecoveryCodesList] = useState([]); - const [errors, setErrors] = useState<{ - qrCode?: string; - setupKey?: string; - recoveryCodes?: string; - }>({}); + const [errors, setErrors] = useState([]); const hasSetupData = useMemo(() => qrCodeSvg !== null && manualSetupKey !== null, [qrCodeSvg, manualSetupKey]); @@ -42,7 +38,7 @@ export const useTwoFactorAuth = () => { setQrCodeSvg(svg); } catch { - setErrors((prev) => ({ ...prev, qrCode: 'Failed to fetch QR code' })); + setErrors((prev) => [...prev, 'Failed to fetch QR code']); setQrCodeSvg(null); } }, []); @@ -53,13 +49,13 @@ export const useTwoFactorAuth = () => { setManualSetupKey(key); } catch { - setErrors((prev) => ({ ...prev, setupKey: 'Failed to fetch a setup key' })); + setErrors((prev) => [...prev, 'Failed to fetch setup key']); setManualSetupKey(null); } }, []); const clearErrors = useCallback((): void => { - setErrors({}); + setErrors([]); }, []); const clearSetupData = useCallback((): void => { @@ -74,7 +70,7 @@ export const useTwoFactorAuth = () => { setRecoveryCodesList(codes); } catch { - setErrors((prev) => ({ ...prev, recoveryCodes: 'Failed to fetch recovery codes' })); + setErrors((prev) => [...prev, 'Failed to fetch recovery codes']); setRecoveryCodesList([]); } }, []); From bfd7e8c86335d2ae6e11e605b87e564211fa0902 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Wed, 17 Sep 2025 00:44:30 +0530 Subject: [PATCH 31/31] Improve two-factor authentication error handling and UI updates --- app/Http/Requests/Auth/LoginRequest.php | 2 +- resources/js/components/delete-user.tsx | 8 +- .../components/two-factor-recovery-codes.tsx | 76 ++++++++----- .../js/components/two-factor-setup-modal.tsx | 104 +++++++++++------- resources/js/hooks/use-two-factor-auth.ts | 8 +- resources/js/pages/settings/password.tsx | 4 +- resources/js/pages/settings/profile.tsx | 4 +- resources/js/pages/settings/two-factor.tsx | 6 +- 8 files changed, 132 insertions(+), 80 deletions(-) diff --git a/app/Http/Requests/Auth/LoginRequest.php b/app/Http/Requests/Auth/LoginRequest.php index ecb66a643..d426f112c 100644 --- a/app/Http/Requests/Auth/LoginRequest.php +++ b/app/Http/Requests/Auth/LoginRequest.php @@ -41,7 +41,7 @@ public function validateCredentials(): User { $this->ensureIsNotRateLimited(); - /** @var User $user */ + /** @var User|null $user */ $user = Auth::getProvider()->retrieveByCredentials($this->only('email', 'password')); if (! $user || ! Auth::getProvider()->validateCredentials($user, $this->only('password'))) { diff --git a/resources/js/components/delete-user.tsx b/resources/js/components/delete-user.tsx index 17f4df976..122695e38 100644 --- a/resources/js/components/delete-user.tsx +++ b/resources/js/components/delete-user.tsx @@ -22,7 +22,9 @@ export default function DeleteUser() { - + Are you sure you want to delete your account? @@ -67,7 +69,9 @@ export default function DeleteUser() { + diff --git a/resources/js/components/two-factor-recovery-codes.tsx b/resources/js/components/two-factor-recovery-codes.tsx index 86f1db76f..d490973eb 100644 --- a/resources/js/components/two-factor-recovery-codes.tsx +++ b/resources/js/components/two-factor-recovery-codes.tsx @@ -1,18 +1,21 @@ +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { regenerateRecoveryCodes } from '@/routes/two-factor'; import { Form } from '@inertiajs/react'; -import { Eye, EyeOff, LockKeyhole, RefreshCw } from 'lucide-react'; +import { AlertCircleIcon, Eye, EyeOff, LockKeyhole, RefreshCw } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; interface TwoFactorRecoveryCodesProps { recoveryCodesList: string[]; fetchRecoveryCodes: () => Promise; + errors: string[]; } -export default function TwoFactorRecoveryCodes({ recoveryCodesList, fetchRecoveryCodes }: TwoFactorRecoveryCodesProps) { +export default function TwoFactorRecoveryCodes({ recoveryCodesList, fetchRecoveryCodes, errors }: TwoFactorRecoveryCodesProps) { const [codesAreVisible, setCodesAreVisible] = useState(false); const codesSectionRef = useRef(null); + const canRegenerateCodes = recoveryCodesList.length > 0 && codesAreVisible; const toggleCodesVisibility = useCallback(async () => { if (!codesAreVisible && !recoveryCodesList.length) { @@ -57,7 +60,7 @@ export default function TwoFactorRecoveryCodes({ recoveryCodesList, fetchRecover {codesAreVisible ? 'Hide' : 'View'} Recovery Codes - {codesAreVisible && ( + {canRegenerateCodes && (
{({ processing }) => ( -
+
+ +
-
-
- or, enter the code manually -
+
+
+ or, enter the code manually +
-
-
- {!manualSetupKey ? ( -
- +
+
+ {!manualSetupKey ? ( +
+ +
+ ) : ( + <> + + + + )}
- ) : ( - <> - - - - )} -
-
+
+ + )} ); } @@ -153,6 +170,7 @@ interface TwoFactorSetupModalProps { manualSetupKey: string | null; clearSetupData: () => void; fetchSetupData: () => Promise; + errors: string[]; } export default function TwoFactorSetupModal({ @@ -164,6 +182,7 @@ export default function TwoFactorSetupModal({ manualSetupKey, clearSetupData, fetchSetupData, + errors, }: TwoFactorSetupModalProps) { const [showVerificationStep, setShowVerificationStep] = useState(false); @@ -239,6 +258,7 @@ export default function TwoFactorSetupModal({ manualSetupKey={manualSetupKey} buttonText={modalConfig.buttonText} onNextStep={handleModalNextStep} + errors={errors} /> )}
diff --git a/resources/js/hooks/use-two-factor-auth.ts b/resources/js/hooks/use-two-factor-auth.ts index c45218d1e..c19567b0f 100644 --- a/resources/js/hooks/use-two-factor-auth.ts +++ b/resources/js/hooks/use-two-factor-auth.ts @@ -49,7 +49,7 @@ export const useTwoFactorAuth = () => { setManualSetupKey(key); } catch { - setErrors((prev) => [...prev, 'Failed to fetch setup key']); + setErrors((prev) => [...prev, 'Failed to fetch a setup key']); setManualSetupKey(null); } }, []); @@ -66,6 +66,7 @@ export const useTwoFactorAuth = () => { const fetchRecoveryCodes = useCallback(async (): Promise => { try { + clearErrors(); const codes = await fetchJson(recoveryCodes.url()); setRecoveryCodesList(codes); @@ -73,16 +74,17 @@ export const useTwoFactorAuth = () => { setErrors((prev) => [...prev, 'Failed to fetch recovery codes']); setRecoveryCodesList([]); } - }, []); + }, [clearErrors]); const fetchSetupData = useCallback(async (): Promise => { try { + clearErrors(); await Promise.all([fetchQrCode(), fetchSetupKey()]); } catch { setQrCodeSvg(null); setManualSetupKey(null); } - }, [fetchQrCode, fetchSetupKey]); + }, [clearErrors, fetchQrCode, fetchSetupKey]); return { qrCodeSvg, diff --git a/resources/js/pages/settings/password.tsx b/resources/js/pages/settings/password.tsx index 495be9118..2d28f7ccb 100644 --- a/resources/js/pages/settings/password.tsx +++ b/resources/js/pages/settings/password.tsx @@ -100,7 +100,9 @@ export default function Password() {
- + - + (false); return ( @@ -42,7 +43,7 @@ export default function TwoFactor({ requiresConfirmation = false, twoFactorEnabl retrieve from the TOTP-supported application on your phone.

- +
@@ -91,6 +92,7 @@ export default function TwoFactor({ requiresConfirmation = false, twoFactorEnabl manualSetupKey={manualSetupKey} clearSetupData={clearSetupData} fetchSetupData={fetchSetupData} + errors={errors} />