feat: Auto-open encryption key modal after login (#54)

## 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.
This commit is contained in:
Víctor Falcón 2026-01-11 20:29:49 +01:00 committed by GitHub
parent e7402ab918
commit d16282dbad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 101 additions and 2 deletions

View File

@ -38,6 +38,7 @@ class DashboardController extends Controller
'categories' => $categories,
'accounts' => $accounts,
'banks' => $banks,
'showEncryptionPrompt' => session('show_encryption_prompt', false),
]);
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Http\Responses;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
use Laravel\Fortify\Fortify;
class LoginResponse implements LoginResponseContract
{
/**
* Create an HTTP response that represents the object.
*/
public function toResponse($request): JsonResponse|RedirectResponse
{
// Flash a session variable to indicate the user just logged in
session()->flash('show_encryption_prompt', true);
return $request->wantsJson()
? response()->json(['two_factor' => false])
: redirect()->intended(Fortify::redirects('login'));
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Http\Responses;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Laravel\Fortify\Contracts\TwoFactorLoginResponse as TwoFactorLoginResponseContract;
use Laravel\Fortify\Fortify;
class TwoFactorLoginResponse implements TwoFactorLoginResponseContract
{
/**
* Create an HTTP response that represents the object.
*/
public function toResponse($request): JsonResponse|RedirectResponse
{
// Flash a session variable to indicate the user just logged in
session()->flash('show_encryption_prompt', true);
return $request->wantsJson()
? new JsonResponse('', 204)
: redirect()->intended(Fortify::redirects('login'));
}
}

View File

@ -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);
}
/**

View File

@ -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<SharedData>();
const { props } = usePage<DashboardProps>();
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 (
<AppSidebarLayout breadcrumbs={breadcrumbs}>
<Head title="Dashboard" />
{encryptedMessageData && (
<UnlockMessageDialog
open={showUnlockDialog}
onOpenChange={setShowUnlockDialog}
onUnlock={handleUnlock}
encryptedContent={encryptedMessageData.encrypted_content}
iv={encryptedMessageData.iv}
salt={encryptedMessageData.salt}
/>
)}
<div className="space-y-6 p-6">
<HeadingSmall
title="Dashboard"