# Force Password Change Module

## 1. Pengenalan

Konsep: **admin bagi password sementara/manual → user wajib tukar sendiri sebelum boleh guna sistem**. Module ni menyelesaikan risiko bahawa admin *tahu* password user lain (backdoor tak sengaja).

> **Istilah "Locked" dalam UI** = `must_change_password = true` (wajib tukar password), bukan login lockout/account lockout. Account lockout belum diimplementasikan.

Direka dalam **3 lapisan**:

| Lapisan | Komponen | Sifat |
|---|---|---|
| Policy | `config/cms.php` (4 key) | Single source of truth — on/off, whitelist, grace period, cut-off |
| Enforcement | `AdminMiddleware` | Reaktif — setiap request, block selagi flag `must_change_password = true` |
| Follow-up | `AdminPasswordChangeReminder` + `SendPasswordReminders` command | Proaktif — capai user yang langsung tak buka sistem |

## 2. Problem Statement

Admin cipta user dengan password yang admin taip/sendiri generate → **admin dah tahu password tu**. Lepas tu ada 3 pilihan berisiko:

- ✗ Biarkan → admin kekal boleh masuk akaun orang lain
- ✗ Paksa tukar tapi tak enforce → user tak pernah tukar, risiko kekal
- ✓ **Paksa tukar (module ni)** → flag `must_change_password` di-arm, user terkunci sehingga tukar sendiri

```
admin cipta user (temporary/manual)
        │
        ▼
must_change_password = true   ← LOCKED (tiada akses kecuali profil & logout)
        │
        │  user login → AdminMiddleware redirect ke admin.profile.edit
        ▼
user tukar password sendiri   → flag cleared → program unlock automatik
        │
        ▼
      BEBAS (macam biasa)
```

## 3. Database

Dua column dalam `backend_users`:

| Column | Jenis | Default | Fungsi |
|---|---|---|---|
| `must_change_password` | boolean | `false` | Flag utama — di-arm bila admin tentukan password user, di-clear bila user tukar sendiri |
| `must_change_password_at` | timestamp, nullable | `NULL` | **Mula grace period** — dipakai schedule untuk kira bila hantar reminder |

**Nota migration:**

- Kedua-dua column **di-fold** ke dalam `database/migrations/2026_04_14_210645_create_backend_users_table.php` — `must_change_password` (boolean) dan `must_change_password_at` (timestamp). Migration berasingan sebelum ni (`2026_10_15_000001_add_must_change_password...` dan `2026_10_16_000001_add_must_change_password_at...`) telah dipadam selepas fold, termasuk record `migrations` diorang.
- DB sedia ada yang dah run alter migration sebelum fold: column kekal ada (fold tak menyentuh row sedia ada); backfill sekali-semasa (locked → ambil `updated_at`) dah pun dilakukan ketika alter tu berjalan.

**Kenapa column dedicated, bukan `updated_at`?** `updated_at` disentuh oleh **APA-APA** update pada row (admin edit role, user save profil tanpa password, dll) → jam grace period senyap-senyap reset dan reminder tertangguh tanpa sebab. `must_change_password_at` hanya disentuh bila flag di-arm/clear.

**Titik arm / clear:**

| Peristiwa | Flag | `must_change_password_at` |
|---|---|---|
| `store()` mode `temporary` / `manual` | `true` | `now()` |
| `store()` mode `invite` | `false` | `NULL` |
| Admin `update()` user + isi password baru | `true` | `now()` |
| `updateProfile()` — user tukar sendiri | `false` | `NULL` |
| `unlockPasswordChange()` — admin escape hatch | `false` | `NULL` |
| `resetPassword()` — user set via link reset | `false` | `NULL` |

## 4. Config — `config/cms.php`

| Key | Nilai default | Fungsi |
|---|---|---|
| `force_password_change_enabled` | `true` | **Master switch** — `false` matikan enforcement seluruhnya (debug / service account) |
| `force_password_change_allowed` | 4 wildcard (bawah) | Whitelist route yang locked user masih boleh akses |
| `force_password_change_reminder_days` | `3` | Grace period — reminder mula selepas ni hari |
| `force_password_change_reminder_max_days` | `14` | Cut-off — berhenti email selepas ni hari (0 = selamanya) |

**Whitelist dan SEBAB setiap wildcard dipilih:**

| Entry | Sebab |
|---|---|
| `admin.profile.*` | **Pintu keluar** — tanpa ni, redirect ke `admin.profile.edit` sendiri pun kena block → infinite redirect loop |
| `admin.logout` | **Escape hatch** — user boleh keluar kalau rasa stuck |
| `admin.verification.*` | Elak **deadlock 2 lapis** — user terkunci serentak (locked + unverified email) tak boleh verify → tak boleh keluar. Tapi `notice`/`send` je dalam group admin |
| `verification.verify` | **Defensive** — route verify sebenar (`routes/web.php`) public/luar group admin hari ini, entry ni cover kalau route dipindah masuk group admin kelak |

**Kenapa config-driven > hardcoded:**

- Satu tempat nak ubah — tak payah sentuh kod middleware
- `reminder_days` **dikongsi** schedule (`SendPasswordReminders`) + teks email (`AdminPasswordChangeReminder`) — tukar sekali, effect merentas
- Middleware turut **merge safety net** (`admin.profile.*` + `admin.logout`) walaupun config rosak → invariant anti-loop tetap terjaga

⚠️ **Amaran wildcard** (ada dalam comment config): route baharu bernamespace `admin.profile.*` **auto-allowed** tanpa review — jangan letak endpoint sensitif dalam namespace tu. Sebaliknya, rename `admin.profile.edit` akan pecahkan redirect — ubah config sekali dengan route.

**Nota:** edit config perlu `php artisan config:clear` (atau Flush melalui Purge Cache module) supaya kesan ambil effect — config mungkin di-cache.

## 5. Middleware — `AdminMiddleware`

**Location:** `app/Http/Middleware/AdminMiddleware.php` (alias `admin` dalam `bootstrap/app.php`)

### Urutan 3 check (dan kenapa penting)

```
1. auth guard  → belum login? redirect admin.login
2. is_active   → akaun dinonaktifkan? logout + invalidate session (SEBELUM flag check —
                 akaun mati tak layak tukar password, terus keluar)
3. must_change_password (+ enabled switch) → lock check terakhir
```

### AFTER (kod sekarang)

```php
if ($admin && $admin->must_change_password && config('cms.force_password_change_enabled', true)) {
    // Safety net: pintu keluar sentiasa dibenarkan walaupun config rosak
    $allowedRoutes = array_merge(
        ['admin.profile.*', 'admin.logout'],
        (array) config('cms.force_password_change_allowed', [])
    );

    if (! $request->routeIs($allowedRoutes)) {
        // AJAX/fetch: 403 JSON terus (bukan HTML tergantung)
        if ($request->expectsJson()) {
            return response()->json(['message' => 'You must change your password before continuing.'], 403);
        }

        // Toast sekali sahaja per session
        if (! $request->session()->has('force_password_change_warned')) {
            flash()->warning('You must change your password before continuing.');
            $request->session()->put('force_password_change_warned', true);
        }

        return redirect()->route('admin.profile.edit');
    }
}
```

### BEFORE vs AFTER

| Aspek | BEFORE (lama) | AFTER (sekarang) |
|---|---|---|
| Matching | `routeIs()` wildcard ✅ | `routeIs()` wildcard + **safety-net merge** |
| Default config | Salinan penuh array dalam middleware (2 source of truth) | Config = source of truth; middleware merge invariant minimum je |
| AJAX | Dapat redirect 302 HTML | **403 JSON** |
| On/off switch | Tiada | `force_password_change_enabled` |

### Table perbandingan tingkah laku

| Scenario | `in_array()` exact match (dulu) | `routeIs()` wildcard (sekarang) |
|---|---|---|
| Route baru `admin.profile.xxx` | Terkunci (lupa tambah config) | ✅ Auto-covered |
| Rename route dalam wildcard group | Senyap-senyap tak match | ✅ Masih match (wildcard) |
| Route luar whitelist | Redirect ✅ | Redirect ✅ |

### Session flag `force_password_change_warned`

Toast redirect berulang-kali (user klik link lain) → toast akan **bertimbun** kalau flash setiap kali. Flag ni jadikan flash **sekali sahaja** per session. Dibuang oleh `updateProfile()` bila password berjaya ditukar.

### Pembahagian: banner vs toast

| | Banner (persistent) | Toast (transient) |
|---|---|---|
| Sumber | DB flag `must_change_password` | Session flag `force_password_change_warned` |
| Lokasi | `backend/profile/edit.blade.php` — orange box atas form | Sekali lepas redirect pertama |
| Hidup | Selagi flag true | Sekali per session |

## 6. Notification — `AdminPasswordChangeReminder`

**Location:** `app/Notifications/AdminPasswordChangeReminder.php`

- `via(['mail'])` — email sahaja
- **Sync, bukan queue** — sengaja TAK `implements ShouldQueue`. `QUEUE_CONNECTION=database` tapi queue worker tak berjalan default → kalau di-queue, email senyap duduk dalam table `jobs`. Volume kecil (sekali sehari) → sync lebih reliable.
- Template DB: `admin.password_reminder` melalui `EmailTemplateService`
- Data template: `name`, `email`, `days`, `action_url`
- `$days = max(1, (int) config('cms.force_password_change_reminder_days', 3))` — clamp elak teks "0/negatif hari"
- `action_url` = `route('admin.profile.edit')` — terus ke pintu keluar
- Jika template tiada/inactive/rosak, notification fallback kepada `MailMessage` asal dalam kod
- Reminder ini **tidak** direkod dalam `email_logs` (log hanya untuk manual send)

**Kenapa lapisan proaktif?** Middleware hanya reaktif — berkesan hanya bila user *ada* dalam sistem. Kalau user terkunci dan langsung tak buka sistem, dia tak nampak banner/redirect → email ni yang tarik dia balik.

## 7. Schedule — `routes/console.php` + `SendPasswordReminders`

`routes/console.php` hanya daftar:

```php
Schedule::command('send-password-reminders')->daily();
```

Logic penuh dalam **`app/Console/Commands/SendPasswordReminders.php`** (dijadikan command supaya boleh diuji via `$this->artisan(...)`). Query dengan **5 syarat**:

```php
BackendUser::where('must_change_password', true)      // 1. masih locked
    ->where('is_active', true)                         // 2. user aktif je (tak spam yang mati)
    ->whereNotNull('must_change_password_at')          // 3. ada timestamp dedicated
    ->where('must_change_password_at', '<=', now()->subDays($days))    // 4. grace period dah cukup (3 hari)
    ->when($maxDays > 0, fn ($q) => $q
        ->where('must_change_password_at', '>=', now()->subDays($maxDays))) // 5. belum lebih cut-off (14 hari)
```

**Skedul & operasi (Windows/Laragon — tiada cron):**

| Cara | Status |
|---|---|
| `php artisan schedule:work` dalam terminal (biar terbuka) | ✅ **Cara disyorkan** — tiada window tambahan |
| Windows Task Scheduler jalankan `schedule:run` setiap minit | ⚠️ Pilihan — **kena wrapper HIDDEN** (VBS/wscript). Target `.bat` terus akan buka window cmd setiap minit sebab `Logon Mode: Interactive only` |

Task Scheduler `cms-laravel-schedule` (dan `storage\framework\schedule-run.bat`) yang pernah didaftarkan **telah dipadam** — ia menyebabkan window cmd muncul tiap minit. Jadi reminder hanya berjalan bila `schedule:work` aktif.

Lepas cut-off 14 hari: reminder **berhenti** — follow-up kena buat manual oleh admin (lihat badge "Locked" dalam Backend User index).

## 8. Routes

| Method | URI | Name | Permission |
|---|---|---|---|
| GET | `/admin/profile/edit` | `admin.profile.edit` | Tiada (pattern profile — semua admin) |
| PUT | `/admin/profile` | `admin.profile.update` | Tiada (update `auth()->user()` je) |
| POST | `/admin/backend-user/{id}/unlock-password-change` | `backend-user.unlock-password-change` | `backend-user.update` |
| GET | `/admin/email/verify` | `admin.verification.notice` | Tiada (dalam whitelist) |
| POST | `/admin/email/verification-notification` | `admin.verification.send` | Tiada + `throttle:6,1` |
| GET | `/email/verify/{id}/{hash}` | `verification.verify` | **Public** + `signed` middleware (luar group admin) |

## 9. Controller — `BackendUserController`

**Location:** `app/Http/Controllers/Backend/BackendUserController.php`

### `store()` — 3 mode password

| `password_mode` | `is_active` | `must_change_password` | Email |
|---|---|---|---|
| `temporary` | `true` | **`true`** + `at = now()` | `AdminTemporaryPassword` (password di-email + fallback flash kat skrin) |
| `manual` | ikut input | **`true`** + `at = now()` | Tiada (admin bagi sendiri) |
| `invite` | `false` | `false` (password placeholder rawak, tak didedahkan) | `AdminActivateAccount` (activation link) |

### `update()` (admin edit user)
Bila admin isi `password` baru → flag **di-arm semula** + `at = now()` — paksa user tukar bila login seterusnya.

### `updateProfile()` (user tukar sendiri)
Bila `password` diisi → clear flag + `at = NULL` + `session()->forget('force_password_change_warned')` → redirect `admin.home`. **Tanpa password, flag kekal** (test sahkan).

### `unlockPasswordChange($id)` — escape hatch support
Clear flag target user. Handle kedua-dua: `expectsJson()` → JSON `{"must_change_password": false}`, selainnya → flash + `back()`. Button dalam `backendUser/index.blade.php` (guarded `@can('backend-user.update')`) guna SweetAlert confirmation `Clear forced password change?`; confirm submit, cancel tidak buat apa-apa. Ia tidak reset password dan tidak aktifkan akaun.

### `index()` — filter & badge
- `?locked=1` → `where('must_change_password', true)` (`$lockedOnly`)
- `lockedCount` → badge "Locked (n)" untuk jumpa user terkunci cepat
- Row locked → orange badge "Must Change Password"

### Juga: `AuthenticatedSessionController::resetPassword()`
User set password sendiri melalui link reset → flag + timestamp di-clear + auto-activate.

## 10. Lifecycle Diagram

```
┌─────────────────────────────────────────────────────────────────────┐
│ admin cipta user (temporary / manual)                               │
│   → must_change_password = true, must_change_password_at = now()    │
└──────────────────────────────┬──────────────────────────────────────┘
                               ▼
                    user login (flag true)
                               │
              ┌────────────────┴────────────────┐
              ▼                                 ▼
      request BIASA                    admin.profile.* / logout /
              │                        admin.verification.* / verification.verify
              ▼                                 │
   redirect profile.edit                        │
   + toast (sekali/session)                     │
   + banner orange (persistent)                 │
              │                                 │
              └────────────┬────────────────────┘
                           ▼
              user isi password baru (PUT admin.profile.update)
                           │
                           ▼
        flag = false, at = NULL, session warned = forget
                           │
                           ▼
                     ★ BEBAS ★
                           ▲
        ┌──────────────────┼──────────────────────┐
        │ CABANG 1         │ CABANG 2             │ CABANG 3
        │ cron reminder    │ admin unlock         │ user guna link
        │ send-password-   │ POST unlock-         │ reset password
        │ reminders daily: │ password-change      │ (resetPassword)
        │ • locked ≥3 hari │ → flag = false       │ → flag = false
        │ • aktif          │   at = NULL          │   at = NULL
        │ • ≤14 hari       │ (tanpa tukar pwd)    │ (auto-activate)
        │ → email reminder │                      │
        └──────────────────┴──────────────────────┘
```

## 11. Testing

**`tests/Feature/ForcePasswordChangeTest.php`** — **17 tests / 49 assertions**. Jalankan: `php artisan test --filter=ForcePasswordChangeTest`.

| Konsep | Test |
|---|---|
| Locked → redirect ke profil | `test_locked_user_is_redirected_from_home_to_profile_edit`, `..._from_backend_user_index` |
| Whitelist route boleh akses | `test_locked_user_can_access_profile_edit` |
| Unlocked bebas | `test_unlocked_user_can_access_home` |
| Tukar password clear flag **+ timestamp** | `test_changing_password_clears_must_change_password_flag` |
| Save profil tanpa password kekal locked | `test_saving_profile_without_password_keeps_the_flag` |
| Badge & filter index + SweetAlert unlock markup | `test_backend_user_index_renders_lock_badge_and_filter` |
| Form create password policy + confirm field | `test_backend_user_create_form_renders_password_policy_fields` |
| Mismatch confirmation error pada field confirm | `test_password_confirmation_mismatch_is_reported_on_confirmation_field` |
| Badge edit page | `test_backend_user_edit_renders_lock_badge` |
| Admin unlock clear flag **+ timestamp** | `test_unlock_route_clears_flag_for_target_user` |
| Mode invite tak arm flag (+ timestamp null) | `test_invite_mode_creates_inactive_user_without_flag` |
| Mode manual arm flag **+ timestamp** | `test_manual_mode_arms_the_flag` |
| **Master switch off** → locked bebas | `test_disabled_switch_lets_locked_user_access_home` |
| **AJAX** locked → 403 JSON | `test_locked_user_ajax_request_gets_403_json` |
| Admin update user + password → arm timestamp | `test_admin_setting_password_arms_change_timestamp` |
| **Reminder query 5 syarat** (eligible/recent/stale/inactive/locked-baru) | `test_reminder_command_only_targets_eligible_users` |

Jalankan sekali: `php artisan test` (42 passed, 1 failed, 154 assertions). ⚠️ `ExampleTest` gagal — **pre-existing**, sebab `RefreshDatabase` dikomen dalam file tu tapi `/` query table (sqlite `:memory:` kosong); bukan regression module ni. `AdminAuthThrottleTest` (3 tests / 19 assertions) mengesahkan throttle login/forgot/reset.

### Limitasi diketahui

- **Account lockout belum ada** — `locked` hanya bermaksud `must_change_password`, bukan akaun dikunci selepas gagal login.
- **MFA belum ada** — column `two_factor_code` / `two_factor_expires_at` wujud tetapi flow login MFA belum dibina.
- **`MAIL_MAILER=log`** dalam `.env` dev — reminder pergi `storage/logs/laravel.log`, bukan inbox sebenar. Untuk uji real: tukar ke `smtp`.
- Reminder **tak auto-jalan** melainkan `php artisan schedule:work` dibuka dalam terminal (Task Scheduler dibuang — ia buka window cmd setiap minit).
- Selepas cut-off 14 hari takde auto-escalation — follow-up manual melalui badge "Locked".

## 12. Fail Berkaitan

| Fail | Peranan |
|---|---|
| `config/cms.php` | Policy — 4 key (switch, whitelist, grace, cut-off) |
| `app/Http/Middleware/AdminMiddleware.php` | Enforcement — 3 check + whitelist + JSON 403 |
| `app/Http/Controllers/Backend/BackendUserController.php` | Arm (`store`/`update`), clear (`updateProfile`/`unlock`), filter index |
| `app/Http/Controllers/Backend/Auth/AuthenticatedSessionController.php` | Clear flag melalui reset-password flow |
| `app/Console/Commands/SendPasswordReminders.php` | Logic reminder — 5 syarat query |
| `routes/console.php` | `Schedule::command('send-password-reminders')->daily()` |
| `app/Notifications/AdminPasswordChangeReminder.php` | Email peringatan (sync, clamp hari) |
| `database/migrations/2026_04_14_210645_create_backend_users_table.php` | `must_change_password` + `must_change_password_at` (kedua-dua folded) |
| `app/Models/Backend/BackendUser.php` | Fillable + cast datetime |
| `resources/views/backend/profile/edit.blade.php` | Banner orange persistent |
| `resources/views/backend/module/backendUser/index.blade.php` | Badge, filter `?locked=1`, button unlock |
| `php artisan schedule:work` (manual terminal) | Scheduler Windows (tiada cron) — Task Scheduler dibuang sebab window cmd flash tiap minit |
| `tests/Feature/ForcePasswordChangeTest.php` | 17 tests / 49 assertions |
| `tests/Feature/AdminAuthThrottleTest.php` | 3 tests / 19 assertions |



