# PRD — E-14: Header UI & Global Features (Search + 2FA)

**Epic:** E-14 — Header UI & Global Features — Search, 2FA, Real-time & AI Assistant
**Date:** 2026-03-20
**Branch:** feat-Communications

---

## Context

The HubWallet CRM currently has domain-scoped Select2 searches (per-form, not global) and a 2FA flag (`is_tfa_enabled`) that is stored but never enforced at login. This PRD covers the two stories with specified tasks — E-14-S01 (Global Search) and E-14-S02 (2FA). The Pusher real-time client and AI CRM assistant chatbot are part of the E-14 epic but are out of scope for this PRD.

---

## Existing Scaffolding (Do Not Rebuild)

| Area | What Exists | Location |
|---|---|---|
| Search | Domain-specific Select2 endpoints (searchLeads, searchMerchants) | `SalesRepController`, `MerchantHelpdeskTicketController` |
| 2FA flag | `is_tfa_enabled` tinyint column + UI checkbox | `users` table, `UserSettingsController` |
| 2FA UI | "Enable Two-step Verification" checkbox in profile settings | `profile-settings.blade.php` |
| SMS | Twilio SDK already installed (`twilio/sdk: ^8.11`) | `composer.json` |
| Header | `resources/views/admin/components/header.blade.php` | Top navbar with user dropdown |
| Layout | `resources/views/admin/layouts/layout.blade.php` | Master layout includes header |
| Auth | `app/Http/Controllers/Auth/LoginController.php` | Web login with reCAPTCHA, throttle |
| jQuery AJAX | IIFE + delegated events pattern | `public/admin-form-plugins/app.js` |
| Pusher PHP | `pusher/pusher-php-server: ^7.2` installed | `composer.json` |
| Laravel Echo | Commented out in `resources/js/bootstrap.js` | Needs uncomment when Pusher story is active |

---

## Story E-14-S01: Global Search

**Goal:** Global search across leads, merchants, tickets, and turbo apps with category filtering and live results.

### Gap Analysis

- No `GET /search` route exists in `routes/web.php` or `routes/api.php`
- Existing searches are Select2-specific (return `{results, pagination}`) — global search needs a different response shape
- No model has a unified search scope
- Header (`header.blade.php`) has no search input element

---

### T01 — Build Global Search API Endpoint

**New file:** `app/Http/Controllers/GlobalSearchController.php`

**Endpoint:**
```
GET /admin/search?q={term}&category={all|leads|merchants|tickets|turbo_apps}
```

**Rules:**
- Minimum 2-char term — return `{ results: [], total: 0 }` if shorter
- Scope all queries by `auth()->user()->company_id`
- Per-category limit: 5 results each (20 max total when `category=all`)
- Wrap in try/catch, log with `ErrorLog::Log($e)`
- Route name: `admin.global-search`

**Response shape:**
```json
{
  "results": [
    {
      "category": "leads",
      "id": 1,
      "label": "John Doe",
      "sub": "john@example.com",
      "url": "/admin/leads/1"
    }
  ],
  "total": 1
}
```

**Searchable fields per model:**

| Category | Model | Fields | Label | Sub | URL |
|---|---|---|---|---|---|
| `leads` | `Lead` | `first_name`, `last_name`, `email`, `phone`, `dba` | `full_name` accessor | `email` | `/admin/leads/{id}` |
| `merchants` | `Merchant` | `dba`, `mid` | `dba` | `mid` | `/admin/merchants/{id}` |
| `tickets` | `MerchantHelpdeskTicket` | `subject`, `external_id` | `subject` | `#id` | `/admin/merchant-helpdesk-tickets/{id}` |
| `turbo_apps` | `Lead` (filtered) | `dba`, `legal_name` where `turbo_app_status IS NOT NULL` | `dba` | `turbo_app_status` | `/admin/leads/{id}?tab=turbo_app` |

**Route addition in `routes/web.php`** (inside authenticated admin group):
```php
Route::get('search', [GlobalSearchController::class, 'search'])->name('admin.global-search');
```

---

### T02 — Build Global Search UI with Live Results

**File to modify:** `resources/views/admin/components/header.blade.php`

Add inside the navbar before the user dropdown:
```html
<div id="global-search-wrapper" class="global-search-wrapper position-relative">
    <div class="input-group input-group-sm">
        <select id="global-search-category" class="form-control form-control-sm global-search-category">
            <option value="all">All</option>
            <option value="leads">Leads</option>
            <option value="merchants">Merchants</option>
            <option value="tickets">Tickets</option>
            <option value="turbo_apps">Turbo Apps</option>
        </select>
        <input type="text" id="global-search-input" class="form-control" placeholder="Search…" autocomplete="off">
        <span id="global-search-spinner" class="input-group-text d-none">
            <i class="fa fa-spinner fa-spin"></i>
        </span>
    </div>
    <div id="global-search-results" class="global-search-results d-none"></div>
</div>
```

**New file:** `public/assets/js/global-search.js`

```javascript
(function ($) {
    'use strict';

    var _timer = null;
    var _endpoint = window._globalSearchUrl;

    $(document).on('input', '#global-search-input', function () {
        clearTimeout(_timer);
        var q = $(this).val().trim();
        if (q.length < 2) {
            $('#global-search-results').addClass('d-none').empty();
            return;
        }
        _timer = setTimeout(function () { _doSearch(q); }, 300);
    });

    $(document).on('change', '#global-search-category', function () {
        var q = $('#global-search-input').val().trim();
        if (q.length >= 2) { _doSearch(q); }
    });

    $(document).on('click', function (e) {
        if (!$(e.target).closest('#global-search-wrapper').length) {
            $('#global-search-results').addClass('d-none');
        }
    });

    function _doSearch(q) {
        $('#global-search-spinner').removeClass('d-none');
        $.ajax({
            url: _endpoint,
            data: { q: q, category: $('#global-search-category').val() },
            success: function (res) { _render(res.results); },
            error: function () { _renderError(); },
            complete: function () { $('#global-search-spinner').addClass('d-none'); }
        });
    }

    function _render(results) {
        var $box = $('#global-search-results').empty().removeClass('d-none');
        if (!results.length) {
            $box.html('<div class="gs-empty">No results found</div>');
            return;
        }
        var html = '';
        $.each(results, function (i, r) {
            html += '<a href="' + r.url + '" class="gs-item">'
                + '<span class="gs-badge badge badge-secondary">' + r.category + '</span>'
                + '<span class="gs-label">' + r.label + '</span>'
                + '<span class="gs-sub">' + r.sub + '</span>'
                + '</a>';
        });
        $box.html(html);
    }

    function _renderError() {
        $('#global-search-results').removeClass('d-none')
            .html('<div class="gs-empty text-danger">Error loading results</div>');
    }

})(jQuery);
```

**Layout changes in `resources/views/admin/layouts/layout.blade.php`:**
- Add before closing `</body>`: `<script>window._globalSearchUrl = '{{ route("admin.global-search") }}';</script>`
- Include `global-search.js` in the scripts section

**CSS** (add to admin stylesheet or inline in `resources/views/admin/components/head.blade.php`):
```css
.global-search-wrapper { min-width: 320px; }
.global-search-results {
    position: absolute; top: 100%; left: 0; right: 0; z-index: 9999;
    background: #fff; border: 1px solid #ddd; border-radius: 4px;
    box-shadow: 0 4px 12px rgba(0,0,0,.1); max-height: 400px; overflow-y: auto;
}
.gs-item {
    display: flex; align-items: center; gap: 8px;
    padding: 8px 12px; color: inherit; border-bottom: 1px solid #f0f0f0;
    text-decoration: none;
}
.gs-item:hover { background: #f8f9fa; }
.gs-badge { font-size: 10px; text-transform: uppercase; min-width: 70px; }
.gs-label { font-weight: 500; flex: 1; }
.gs-sub { font-size: 12px; color: #888; }
.gs-empty { padding: 12px; color: #999; text-align: center; font-size: 13px; }
```

---

### T03 — Make Search Results Clickable to Navigate to Record

Results are rendered as `<a href="...">` elements — standard browser navigation. The `url` field returned by the API carries the full admin path for each record type. No additional JS is required.

---

## Story E-14-S02: Two-Factor Authentication (2FA)

**Goal:** Two-factor authentication with TOTP QR code setup, login verification step, and SMS 2FA option.

### Gap Analysis

- `is_tfa_enabled` column exists but is **never checked at login** — the flag is stored but dormant
- No TOTP secret columns (`two_factor_secret`, `two_factor_confirmed_at`)
- No login interception point for 2FA pending verification
- No TOTP library installed
- Twilio SDK is installed and ready for SMS OTP delivery
- No OTP storage for SMS codes

### New Composer Packages Required

```bash
composer require pragmarx/google2fa-laravel bacon/bacon-qr-code
```

---

### Database Migration

**New file:** `database/migrations/YYYY_MM_DD_HHMMSS_add_2fa_fields_to_users_table.php`

```php
Schema::table('users', function (Blueprint $table) {
    $table->text('two_factor_secret')->nullable()->after('is_tfa_enabled');
    $table->text('two_factor_recovery_codes')->nullable()->after('two_factor_secret');
    $table->timestamp('two_factor_confirmed_at')->nullable()->after('two_factor_recovery_codes');
    $table->string('two_factor_sms_code', 10)->nullable()->after('two_factor_confirmed_at');
    $table->timestamp('two_factor_sms_expires_at')->nullable()->after('two_factor_sms_code');
    $table->tinyInteger('two_factor_method')->default(0)
        ->comment('0=totp, 1=sms')->after('two_factor_sms_expires_at');
});
```

Add all new columns to `User::$fillable`.

---

### T01 — Add 2FA Toggle with QR Code Setup in User Settings

**Files to modify:**
- `app/Http/Controllers/Manages/UserSettingsController.php`
- `resources/views/admin/manages/users/profile-settings.blade.php`

**New controller methods on `UserSettingsController`:**

| Method | Route | Description |
|---|---|---|
| `setup2fa(Request $r)` | `POST /admin/user-settings/2fa/setup` | Generate TOTP secret, store in session, return QR code data URI |
| `confirm2fa(Request $r)` | `POST /admin/user-settings/2fa/confirm` | Verify 6-digit code, persist secret + set `two_factor_confirmed_at`, enable `is_tfa_enabled` |
| `disable2fa(Request $r)` | `POST /admin/user-settings/2fa/disable` | Verify current password, clear all 2FA columns, set `is_tfa_enabled = 0` |
| `sendSmsCode(Request $r)` | `POST /admin/user-settings/2fa/sms/send` | Send 6-digit code via Twilio to user's phone |

**`setup2fa` logic:**
```php
$secret = Google2FA::generateSecretKey();
session(['2fa_setup_secret' => $secret]);
$qrCodeUrl = Google2FA::getQRCodeUrl(config('app.name'), auth()->user()->email, $secret);
$qrImage = QrCode::format('svg')->size(200)->generate($qrCodeUrl);
return response()->json(['qr' => base64_encode($qrImage), 'secret' => $secret]);
```

**`confirm2fa` logic:**
```php
$valid = Google2FA::verifyKey(session('2fa_setup_secret'), $request->code);
if (!$valid) return Helper::rj('Invalid code');
auth()->user()->update([
    'two_factor_secret'       => encrypt(session('2fa_setup_secret')),
    'two_factor_confirmed_at' => now(),
    'is_tfa_enabled'          => 1,
    'two_factor_method'       => $request->method, // 0=totp, 1=sms
]);
session()->forget('2fa_setup_secret');
return Helper::resp('2FA enabled');
```

**UI changes in `profile-settings.blade.php`:**

Replace the simple "Enable Two-step Verification" checkbox with a 2FA management card:
1. **Status badge** — "2FA Enabled" (green) or "2FA Disabled" (grey)
2. **"Set Up 2FA" button** — opens modal with:
   - Method toggle: `Authenticator App` | `SMS`
   - **TOTP tab:** QR code image + manual secret key text + 6-digit verify input + "Confirm" button
   - **SMS tab:** Displays phone number on file + "Send Test Code" button + 6-digit verify input
3. **"Disable 2FA" button** (visible only when enabled) — confirm current password modal

---

### T02 — Build TOTP Verification Step at Login

**New file:** `app/Http/Controllers/Auth/TwoFactorController.php`

Methods:
- `showVerifyForm()` — guard: redirect away if no `2fa_pending_user_id` in session; render 2FA code entry view
- `verify(Request $r)` — validate code (TOTP or SMS depending on `two_factor_method`), call `Auth::loginUsingId()`, clear session key, redirect to `HOME`
- `resend(Request $r)` — SMS only: call `TwoFactorSmsService::sendCode()`

**Modify `app/Http/Controllers/Auth/LoginController.php`:**

Override the `authenticated()` method (called after successful credential check):
```php
protected function authenticated(Request $request, $user)
{
    if ($user->is_tfa_enabled && $user->two_factor_confirmed_at) {
        Auth::logout();
        session(['2fa_pending_user_id' => $user->id]);

        if ($user->two_factor_method === 1) {
            app(TwoFactorSmsService::class)->sendCode($user);
        }

        return redirect()->route('admin.2fa.verify');
    }
}
```

**New view:** `resources/views/admin/auth/two-factor.blade.php`
- Extends `admin.layouts.layout-login`
- 6-digit OTP input (type=number, maxlength=6, auto-submit on 6th digit)
- "Resend code" link (SMS method only, AJAX call to resend route)
- Back to login link

**New routes** (outside authenticated middleware, in web.php):
```php
Route::get('admin/2fa/verify',  [TwoFactorController::class, 'showVerifyForm'])->name('admin.2fa.verify');
Route::post('admin/2fa/verify', [TwoFactorController::class, 'verify'])->name('admin.2fa.verify.post');
Route::post('admin/2fa/resend', [TwoFactorController::class, 'resend'])->name('admin.2fa.resend');
```

---

### T03 — Build SMS 2FA Option via Twilio

**New file:** `app/Services/TwoFactorSmsService.php`

```php
class TwoFactorSmsService
{
    public function sendCode(User $user): void
    {
        $code = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
        $user->update([
            'two_factor_sms_code'       => bcrypt($code),
            'two_factor_sms_expires_at' => now()->addMinutes(10),
        ]);
        // Send via Twilio
        $twilio = new Client(config('services.twilio.sid'), config('services.twilio.token'));
        $twilio->messages->create(
            '+' . $user->phone_country_code . $user->phone,
            ['from' => config('services.twilio.from'), 'body' => "Your login code: $code"]
        );
    }

    public function verifyCode(User $user, string $code): bool
    {
        if (!$user->two_factor_sms_expires_at || now()->isAfter($user->two_factor_sms_expires_at)) {
            return false; // expired
        }
        if (!Hash::check($code, $user->two_factor_sms_code)) {
            return false; // wrong code
        }
        $user->update(['two_factor_sms_code' => null, 'two_factor_sms_expires_at' => null]);
        return true;
    }
}
```

**Integration in `TwoFactorController::verify()`:**
```php
$user = User::findOrFail(session('2fa_pending_user_id'));
if ($user->two_factor_method === 1) {
    $valid = app(TwoFactorSmsService::class)->verifyCode($user, $request->code);
} else {
    $valid = Google2FA::verifyKey(decrypt($user->two_factor_secret), $request->code);
}
if (!$valid) return back()->withErrors(['code' => 'Invalid or expired code.']);
session()->forget('2fa_pending_user_id');
Auth::loginUsingId($user->id);
return redirect(config('app.home', '/admin'));
```

---

## Complete File Inventory

### Files to Modify

| File | Change |
|---|---|
| `routes/web.php` | Add global search route + 2FA routes |
| `app/Http/Controllers/Auth/LoginController.php` | Override `authenticated()` for 2FA interception |
| `app/Http/Controllers/Manages/UserSettingsController.php` | Add 4 new 2FA methods |
| `resources/views/admin/components/header.blade.php` | Add global search widget |
| `resources/views/admin/layouts/layout.blade.php` | Inject `window._globalSearchUrl`, include `global-search.js` |
| `resources/views/admin/manages/users/profile-settings.blade.php` | Replace checkbox with full 2FA setup UI |
| `app/Models/User.php` | Add new 2FA columns to `$fillable` |

### New Files to Create

| File | Purpose |
|---|---|
| `app/Http/Controllers/GlobalSearchController.php` | Global search API endpoint |
| `app/Http/Controllers/Auth/TwoFactorController.php` | 2FA verify + resend |
| `app/Services/TwoFactorSmsService.php` | Twilio OTP send/verify |
| `public/assets/js/global-search.js` | Search UI IIFE (debounce + render) |
| `resources/views/admin/auth/two-factor.blade.php` | 2FA code entry view |
| `database/migrations/..._add_2fa_fields_to_users_table.php` | New 2FA columns |

---

## Verification & Testing

### Global Search
1. Log in, type ≥2 chars in header search bar
2. Dropdown appears within 300ms with categorized results
3. Change category dropdown — results filter immediately
4. Click any result — navigates to correct record detail page
5. Type 1 char — no request sent, dropdown closes

### 2FA Setup (TOTP)
1. Go to Profile Settings → 2FA section → click "Set Up 2FA"
2. QR code modal appears; scan with Google Authenticator
3. Enter 6-digit code → confirm → `two_factor_confirmed_at` saved
4. Log out and log in again → redirected to `/admin/2fa/verify`
5. Enter correct TOTP code → lands on dashboard
6. Enter wrong code → error shown, stays on verify page

### 2FA Setup (SMS)
1. In profile settings, choose "SMS" method
2. Click "Send Test Code" → receive SMS on `user->phone`
3. Enter code → 2FA enabled with `two_factor_method = 1`
4. On next login → SMS sent automatically, enter code to proceed
5. Wait > 10 minutes → enter expired code → error shown

### Automated Tests
```bash
php artisan test --filter=GlobalSearchTest
php artisan test --filter=TwoFactorTest
```
