# Forgot Password (User-Only) — Plan & Build Record

> **Status (2026-05-17):** ⏳ Planned.
>
> **Goal:** A public page where a **regular user** (role `user`, not `superadmin`) enters **only their email address** and receives a password-reset link via email. Clicking the link takes them to a new-password form. After resetting, they're sent back to the user login page.
>
> **Out of scope:**
> - **Superadmin must NOT be able to use this flow.** `mpsjajournal@gmail.com` (the only superadmin per memory `project_superadmin_account.md`) is excluded by design — if its inbox is ever compromised, a self-service reset link = full admin takeover. Superadmin password resets happen out-of-band (direct DB / dev intervention).
> - Admin-side forgot password is NOT built.
>
> **Dependency:** Requires the SMTP configuration feature in [`email-configuration-plan.md`](./email-configuration-plan.md) to be shipped first, otherwise emails go to `storage/logs/laravel.log` only.
>
> **Pattern reference:** Mirrors the admin-auth port already done in `backendwork.md` Phase 1 (`admin/auth/login.blade.php` ported from `pages-sign-in.html`). Reuses the AdminKit `pages-reset-password.html` template for the user-facing reset views.

---

## 1. Routes

All in the existing **public** (non-`auth`-gated) area of `routes/web.php`. No role middleware on the form pages themselves — anyone can request a reset — but the controller enforces the user-only rule internally.

```php
Route::prefix('user')->name('user.')->group(function () {
    // ... existing user routes ...

    Route::get( '/forgot-password',        [ForgotPasswordController::class, 'showRequestForm'])->name('password.request');
    Route::post('/forgot-password',        [ForgotPasswordController::class, 'sendResetLink'])
         ->middleware('throttle:5,15')   // 5 attempts per 15 min per IP
         ->name('password.email');

    Route::get( '/reset-password/{token}', [ForgotPasswordController::class, 'showResetForm'])->name('password.reset');
    Route::post('/reset-password',         [ForgotPasswordController::class, 'reset'])->name('password.update');
});
```

Route names use Laravel's standard `password.*` convention so any future Mailable that calls `route('password.reset', $token)` works without changes.

---

## 2. Controller

`app/Http/Controllers/User/ForgotPasswordController.php` — four actions. Uses Laravel's built-in `Password` broker so we don't reinvent token generation, expiry, or hashing.

| Method | Verb + path | Job |
|---|---|---|
| `showRequestForm()` | `GET /user/forgot-password` | Render the one-field email form. |
| `sendResetLink()` | `POST /user/forgot-password` | Validate email; reject if user is `superadmin`; issue reset token; dispatch `PasswordResetMail`. |
| `showResetForm()` | `GET /user/reset-password/{token}` | Render the new-password form, carrying token + email in hidden fields. |
| `reset()` | `POST /user/reset-password` | Validate token + email + new password; update `users.password`; invalidate token; redirect to login with success flash. |

### `sendResetLink()` — the critical action

```php
public function sendResetLink(Request $request)
{
    $v = Validator::make($request->all(), [
        'email' => 'required|email',
    ]);
    if ($v->fails()) {
        return back()->withErrors($v)->withInput();
    }

    $user = User::where('email', $request->email)->first();

    // ── Superadmin exclusion (security) ──────────────────────────
    // If the email belongs to the superadmin, respond with the SAME
    // generic message a normal user sees. Never confirm to the
    // outside world that this email is the superadmin's.
    if ($user && $user->hasRole('superadmin')) {
        return back()->with('status', __('If the email is registered, a reset link has been sent.'));
    }

    // Standard Laravel broker — handles token generation, throttling, expiry.
    $status = Password::sendResetLink(
        ['email' => $request->email]
    );

    // Always show the same generic status (anti-enumeration).
    return back()->with('status', __('If the email is registered, a reset link has been sent.'));
}
```

> **Decision needed:** anti-enumeration vs. friendly errors — see §7.

---

## 3. Mailable

`app/Mail/PasswordResetMail.php`, generated by:

```bash
php artisan make:mail PasswordResetMail --markdown=emails.auth.reset-password
```

Hook it into the password broker so `Password::sendResetLink()` uses our class instead of Laravel's default notification. The simplest way is overriding `User::sendPasswordResetNotification()`:

```php
// app/Models/User.php
public function sendPasswordResetNotification($token): void
{
    // Skip — never email the superadmin a reset link, even if reached.
    if ($this->hasRole('superadmin')) {
        return;
    }

    \Mail::to($this->email)->send(new \App\Mail\PasswordResetMail($this, $token));
}
```

Two layers of superadmin protection: controller-level (§2) and model-level (here). Defense in depth — if either is bypassed by a future refactor, the other still holds.

### Mail body (markdown template)

`resources/views/emails/auth/reset-password.blade.php`:

```blade
@component('mail::message')
# Reset your JotiJournal password

Hi {{ $user->name }},

You requested a password reset. Click the button below to choose a new password.
This link expires in **60 minutes**.

@component('mail::button', ['url' => route('user.password.reset', ['token' => $token]) . '?email=' . urlencode($user->email)])
Reset Password
@endcomponent

If you did not request this, you can safely ignore this email.

Thanks,<br>
{{ config('app.name') }}
@endcomponent
```

Token expiry (60 min) is configured in `config/auth.php` → `passwords.users.expire`. Verify default before shipping.

---

## 4. Views

Two public Blade files, both extending the user-facing layout (`layouts/front` per existing convention).

| View | Purpose | Fields |
|---|---|---|
| `resources/views/users/auth/forgot-password.blade.php` | Request-link form | `email` (the only field, per spec) |
| `resources/views/users/auth/reset-password.blade.php` | New-password form | `token` (hidden), `email` (hidden, prefilled from query), `password`, `password_confirmation` |

Template source: the AdminKit `pages-reset-password.html` was already ported once for admin auth — use it as the visual reference, but apply the user-site CSS so it matches the public look.

**Login-page wiring:** add a "Forgot password?" link under the submit button in `resources/views/users/login.blade.php`:

```blade
<a href="{{ route('user.password.request') }}" class="float-end small">Forgot password?</a>
```

---

## 5. Schema / Migration check

Laravel 11's default `0001_01_01_000000_create_users_table.php` migration creates the `password_reset_tokens` table. Before building, verify:

```bash
php artisan migrate:status | grep password_reset_tokens
# or
mysql jotici -e "DESCRIBE password_reset_tokens;"
```

If missing (legacy import from `jotici (1).sql` may have skipped it), add:

```php
Schema::create('password_reset_tokens', function (Blueprint $t) {
    $t->string('email')->primary();
    $t->string('token');
    $t->timestamp('created_at')->nullable();
});
```

---

## 6. Security Notes

| Concern | Mitigation |
|---|---|
| Superadmin reset path | Excluded at controller (`role:superadmin` check) **and** at model (`sendPasswordResetNotification` short-circuits). Double-gated. |
| Email enumeration | Generic response message regardless of whether the email exists or belongs to superadmin (see §7). |
| Brute-force on the request form | `throttle:5,15` middleware on the POST route (5 attempts per 15 min per IP). |
| Brute-force on the reset form | Laravel's broker enforces token single-use + 60-min expiry by default. |
| Token leaking via Referer | Reset link contains the token in the URL — acceptable risk per Laravel convention. Ensure the reset form posts back same-origin so the token never leaves the site. |
| Password strength | `password` validation rule: `required|confirmed|min:8|mixed|letters|numbers` (Laravel 11 `Password::defaults()` if configured). |
| Logging out other sessions on reset | Recommended — call `Auth::logoutOtherDevices($newPassword)` after `reset()` succeeds, if the user is logged in elsewhere. |

---

## 7. Open Decision — Anti-Enumeration vs. Friendly Errors

When a user types an email that isn't in the database, two valid behaviors:

| Approach | UX | Security |
|---|---|---|
| **(a) Generic message always** — "If the email is registered, a reset link has been sent." | Slightly confusing if the user typos their email — they'll never know the typo happened. | Attackers can't probe whether a given email is a registered user. |
| **(b) Specific error** — "No account found with that email." | Friendlier for legitimate users with typos. | Anyone can probe the user base for valid emails by feeding the form a list. |

JotiJournal is a journal-publication site, not a high-value target, so (b) is *defensible*. But (a) is the standard Laravel default and adds almost no friction. **Recommendation: (a)**, the version drafted in §2.

---

## 8. Build Sequence (Checklist)

- [ ] **Phase 0 — Decisions**
  - [ ] Confirm anti-enumeration approach (§7)
- [ ] **Phase 1 — Prerequisites**
  - [ ] SMTP config shipped (see `email-configuration-plan.md`) — verify a test email actually arrives
  - [ ] Confirm `password_reset_tokens` table exists (§5)
- [ ] **Phase 2 — Backend**
  - [ ] `app/Mail/PasswordResetMail.php` + markdown view
  - [ ] Override `User::sendPasswordResetNotification()` (model-level superadmin block)
  - [ ] `app/Http/Controllers/User/ForgotPasswordController.php` — 4 actions
  - [ ] Routes registered + `throttle:5,15` on POST
- [ ] **Phase 3 — Frontend**
  - [ ] `users/auth/forgot-password.blade.php` (single email field)
  - [ ] `users/auth/reset-password.blade.php` (token + email hidden + new password fields)
  - [ ] "Forgot password?" link added to `users/login.blade.php`
- [ ] **Phase 4 — Manual test**
  - [ ] Submit a known user email → email arrives → link works → password updated → can log in with new password
  - [ ] Submit the superadmin's email (`mpsjajournal@gmail.com`) → generic success message shown but NO email sent (verify `storage/logs/laravel.log` is silent + inbox is empty)
  - [ ] Submit a non-existent email → generic success message, no email
  - [ ] Submit 6 times in quick succession → 6th is throttled
  - [ ] Click an expired token (wait 61 min or manually age the row) → reset rejected

---

## 9. Open Questions

1. After successful reset, **auto-login** the user or **redirect to login** with a success flash? Auto-login is friendlier; redirect-to-login is the Laravel default and slightly more secure (forces a fresh credential entry).
2. Should the reset link expiry be the Laravel default 60 min, or shorter (15 min) for this site?
3. Do we want a "your password was just reset" confirmation email sent **after** a successful reset (so the real user notices if an attacker reset their password)? Out of scope for v1 but worth recording.
