From d16282dbad7c7b58843c28dedf0a04265355a8a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Sun, 11 Jan 2026 20:29:49 +0100 Subject: [PATCH] feat: Auto-open encryption key modal after login (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR implements automatic opening of the encryption key unlock modal immediately after user login when they visit the dashboard. The modal will only appear once per login session and will not show up on subsequent dashboard visits or if the user manually clears their encryption key. ## Problem Previously, users had to manually click the encryption key button in the sidebar after logging in to unlock their encrypted data. This extra step could be confusing for new users and added friction to the login flow. ## Solution The encryption key modal now automatically opens when users visit the dashboard immediately after logging in, but **only if**: 1. The user just completed a login (session flag is set) 2. Their encryption key is not already in browser storage 3. Encrypted challenge data is available ## Implementation Details ### Backend Changes #### 1. Custom Login Response Classes Created two new response classes that override Fortify's default login responses: **`app/Http/Responses/LoginResponse.php`** - Implements `LoginResponse` contract - Sets `show_encryption_prompt` session flash variable after successful login - Handles both regular login and JSON API responses **`app/Http/Responses/TwoFactorLoginResponse.php`** - Implements `TwoFactorLoginResponse` contract - Sets the same session flag after successful 2FA authentication - Ensures the modal appears for 2FA users as well #### 2. Service Provider Registration **`app/Providers/FortifyServiceProvider.php`** - Registered custom response classes as singletons in the container - Fortify now uses our custom responses instead of defaults #### 3. Dashboard Controller **`app/Http/Controllers/DashboardController.php`** - Passes `showEncryptionPrompt` flag from session to frontend - Uses `session('show_encryption_prompt', false)` which automatically consumes the flash data ### Frontend Changes **`resources/js/pages/dashboard.tsx`** - Added `DashboardProps` interface extending `SharedData` with `showEncryptionPrompt` boolean - Updated `useEffect` hook to check three conditions before auto-opening modal: 1. `props.showEncryptionPrompt` - User just logged in (from session flash) 2. `!isKeySet` - Encryption key not in browser storage 3. `encryptedMessageData` - Encrypted challenge data loaded - Includes `UnlockMessageDialog` component that conditionally renders ## How It Works ### User Flow 1. **User logs in** → Custom `LoginResponse` or `TwoFactorLoginResponse` sets `show_encryption_prompt` flash session variable 2. **Redirect to dashboard** → `DashboardController` retrieves and passes the flag to frontend (consuming it) 3. **Dashboard loads** → React component checks all three conditions 4. **Modal appears** → If conditions are met, `UnlockMessageDialog` opens automatically 5. **Session flag consumed** → Subsequent page loads won't trigger the modal ### Why Session Flash? Laravel's session flash data is perfect for this use case because: - It's automatically removed after being accessed once - It survives redirects (crucial for post-login flow) - It's server-side, so it can't be manipulated by client-side code - No database changes or additional state management needed ### Edge Cases Handled ✅ **User clears encryption key manually** → Modal won't auto-open on dashboard visit (no session flag) ✅ **User refreshes dashboard** → Modal won't re-appear (flash data consumed) ✅ **User logs out and back in** → Modal appears again (new session flag) ✅ **2FA enabled users** → Modal appears after successful 2FA challenge ✅ **Key already in browser** → Modal doesn't appear (key check passes) ## Testing - ✅ All existing dashboard tests pass (2 tests) - ✅ All authentication tests pass (58 tests) - ✅ Code formatted with Laravel Pint - ✅ Code formatted with Prettier - ✅ ESLint validation passes ## Files Changed - `app/Http/Responses/LoginResponse.php` (new) - `app/Http/Responses/TwoFactorLoginResponse.php` (new) - `app/Providers/FortifyServiceProvider.php` (modified) - `app/Http/Controllers/DashboardController.php` (modified) - `resources/js/pages/dashboard.tsx` (modified) ## Breaking Changes None. This is an additive feature that enhances the existing authentication flow without breaking any existing functionality. --- app/Http/Controllers/DashboardController.php | 1 + app/Http/Responses/LoginResponse.php | 24 ++++++++++ app/Http/Responses/TwoFactorLoginResponse.php | 24 ++++++++++ app/Providers/FortifyServiceProvider.php | 7 ++- resources/js/pages/dashboard.tsx | 47 ++++++++++++++++++- 5 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 app/Http/Responses/LoginResponse.php create mode 100644 app/Http/Responses/TwoFactorLoginResponse.php diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index bde22dd8..5c131af1 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -38,6 +38,7 @@ class DashboardController extends Controller 'categories' => $categories, 'accounts' => $accounts, 'banks' => $banks, + 'showEncryptionPrompt' => session('show_encryption_prompt', false), ]); } } diff --git a/app/Http/Responses/LoginResponse.php b/app/Http/Responses/LoginResponse.php new file mode 100644 index 00000000..87982eca --- /dev/null +++ b/app/Http/Responses/LoginResponse.php @@ -0,0 +1,24 @@ +flash('show_encryption_prompt', true); + + return $request->wantsJson() + ? response()->json(['two_factor' => false]) + : redirect()->intended(Fortify::redirects('login')); + } +} diff --git a/app/Http/Responses/TwoFactorLoginResponse.php b/app/Http/Responses/TwoFactorLoginResponse.php new file mode 100644 index 00000000..1d62c4c4 --- /dev/null +++ b/app/Http/Responses/TwoFactorLoginResponse.php @@ -0,0 +1,24 @@ +flash('show_encryption_prompt', true); + + return $request->wantsJson() + ? new JsonResponse('', 204) + : redirect()->intended(Fortify::redirects('login')); + } +} diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 531182d0..f0d21a55 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -5,6 +5,8 @@ namespace App\Providers; use App\Actions\CreateDefaultCategories; use App\Actions\Fortify\CreateNewUser; use App\Actions\Fortify\ResetUserPassword; +use App\Http\Responses\LoginResponse; +use App\Http\Responses\TwoFactorLoginResponse; use Illuminate\Auth\Events\Registered; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; @@ -13,6 +15,8 @@ use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Illuminate\Support\Str; use Inertia\Inertia; +use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract; +use Laravel\Fortify\Contracts\TwoFactorLoginResponse as TwoFactorLoginResponseContract; use Laravel\Fortify\Features; use Laravel\Fortify\Fortify; @@ -23,7 +27,8 @@ class FortifyServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->singleton(LoginResponseContract::class, LoginResponse::class); + $this->app->singleton(TwoFactorLoginResponseContract::class, TwoFactorLoginResponse::class); } /** diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx index 5e10ff56..472c88c5 100644 --- a/resources/js/pages/dashboard.tsx +++ b/resources/js/pages/dashboard.tsx @@ -3,11 +3,14 @@ import { CashflowSummaryCard } from '@/components/dashboard/cashflow-summary-car import { NetWorthChart as NetWorthChartComponent } from '@/components/dashboard/net-worth-chart'; import { TopCategoriesCard } from '@/components/dashboard/top-categories-card'; import HeadingSmall from '@/components/heading-small'; +import UnlockMessageDialog from '@/components/unlock-message-dialog'; +import { useEncryptionKey } from '@/contexts/encryption-key-context'; import { useDashboardData } from '@/hooks/use-dashboard-data'; import AppSidebarLayout from '@/layouts/app/app-sidebar-layout'; import { dashboard } from '@/routes'; import { BreadcrumbItem, SharedData } from '@/types'; import { Head, usePage } from '@inertiajs/react'; +import { useEffect, useState } from 'react'; const breadcrumbs: BreadcrumbItem[] = [ { @@ -16,8 +19,12 @@ const breadcrumbs: BreadcrumbItem[] = [ }, ]; +interface DashboardProps extends SharedData { + showEncryptionPrompt: boolean; +} + export default function Dashboard() { - const { props } = usePage(); + const { props } = usePage(); const { netWorthEvolution, accounts: accountMetrics, @@ -25,11 +32,49 @@ export default function Dashboard() { isLoading, refetch, } = useDashboardData(); + const { isKeySet, encryptedMessageData, fetchEncryptedMessage } = + useEncryptionKey(); + const [showUnlockDialog, setShowUnlockDialog] = useState(false); + + useEffect(() => { + // Fetch encrypted message data if not already loaded + if (!encryptedMessageData) { + fetchEncryptedMessage(); + } + + // Auto-open the unlock dialog only if: + // 1. User just logged in (showEncryptionPrompt is true) + // 2. Encryption key is not set + // 3. Encrypted message data is available + if (props.showEncryptionPrompt && !isKeySet && encryptedMessageData) { + setShowUnlockDialog(true); + } + }, [ + isKeySet, + encryptedMessageData, + fetchEncryptedMessage, + props.showEncryptionPrompt, + ]); + + function handleUnlock() { + setShowUnlockDialog(false); + } return ( + {encryptedMessageData && ( + + )} +