# Forced Password Change for Newly Provisioned Store Owners

**Date:** 2026-06-01
**Status:** Approved (design)
**Scope:** Tenant store-owner admins (`Admin` model) only. Super-admin accounts are explicitly out of scope for this build (a possible fast-follow using the same pattern).

## Background

When a store is provisioned, the store owner's `Admin` account is created with a system-generated temporary password, which is emailed to them ([`TenantProvisioningService::provision()`](../../../app/Services/TenantProvisioningService.php)). Today nothing forces the owner to change that temp password — it remains valid indefinitely (it sits in an inbox forever), which is a security gap.

This feature forces a store owner to set their own password before they can operate the store admin.

### What this is NOT fixing

A separate report ("password change shows Saved but stays on the login page") was investigated and found to be **not a bug** — the user mistook the Filament **profile page** (which has Name/Email/Password fields) for the login page. Live testing confirmed the password change saves correctly and the session survives. The earlier "neither password works" was the browser's password manager auto-filling a generated strong password into the new-password field. No session/logout bug exists.

## Scope reality (verified)

For the tenant `Admin` model, the **only** path that sets a password the admin did not choose is **provisioning**. Specifically:

- No owner-created sub-admin accounts exist (no admin/staff resource under `app/Filament/Resources/`).
- No super-admin "reset this store's password" feature exists for tenant admins. (`AdminResource` manages the separate `SuperAdmin` model.)
- `/ecw-login` + the `_ecwt` magic token only *log the admin in*; they do not set a password.
- `Password::reset` ([`PasswordResetController`](../../../app/Http/Controllers/Auth/PasswordResetController.php)) is self-service — the user chooses their own password, so it must **clear** the flag, not set it.

## Decisions

- **Trigger:** store owners only, flagged at provisioning.
- **Enforcement:** hard gate — block all admin routes until the password is changed.
- **Existing accounts:** going-forward only. Existing admins are never gated.
- **Approach:** dedicated forced-change Filament page + panel middleware (Approach A).

## Design

### 1. Data model

- New tenant migration adds `must_change_password` to the `admins` table:
  - `boolean`, `NOT NULL`, `default false`.
- `Admin` model: add `must_change_password` to `$fillable` and cast as `boolean`.
- Apply with `php artisan tenants:migrate` so the column lands on all existing tenant databases. Existing admins keep `false` (untouched). The migration is idempotent enough to re-run safely (standard add-column).

### 2. Where the flag is set / cleared

**Set `true`:**
- In `TenantProvisioningService::provision()` when creating the `Admin` (the emailed temp password). Only set point for store owners.

**Clear to `false`:**
- The new `SetPassword` page handler — the **only** clear point.

**Not involved:** the `Password::reset` "forgot password" flow ([`PasswordResetController`](../../../app/Http/Controllers/Auth/PasswordResetController.php)) targets the storefront **customer** provider (`AUTH_PASSWORD_BROKER` defaults to `users`; routes are in the storefront section of `routes/tenant.php`), not the `admins` model. It is left untouched — admins have no self-service reset flow (no "Forgot password?" link on the admin login).

The normal Filament profile page needs no change — under the hard gate a flagged admin cannot reach it.

### 3. Gate middleware

- New middleware `App\Http\Middleware\RequirePasswordChange`.
- Registered in the Admin panel's **`authMiddleware`** array ([`AdminPanelProvider`](../../../app/Providers/Filament/AdminPanelProvider.php)) so it runs only for authenticated panel requests, after `Authenticate`.
- Logic:
  - Resolve the authenticated admin via `Filament::auth()->user()`.
  - If `must_change_password` is true **and** the current request is **not** the SetPassword page route **and not** the logout route → redirect to the SetPassword page.
  - Otherwise pass through.
- Exempting the SetPassword page route + logout prevents a redirect loop.

### 4. SetPassword page

- Custom Filament `Page` at path `set-password` (route under the admin panel, e.g. `/admin/set-password`). Hidden from navigation (`shouldRegisterNavigation() = false`).
- Form fields: **New password** + **Confirm new password** only. No current-password field (the admin just authenticated via form login, or arrived authenticated via magic link with no password to re-enter).
- Validation: Filament `Password::default()` rules plus an explicit **8-character minimum** (consistent with the standard set in commit `31943aa`), and confirmation match.
- Submit handler:
  1. Hash and save the new password on the authenticated `Admin`.
  2. Set `must_change_password = false`.
  3. Send a "Password updated" success notification.
  4. Redirect to the panel dashboard.
- The password save regenerates the session (observed cookie rotation in testing); the admin stays logged in.
- Intro copy at top of page: *"Choose a password to finish setting up your account."*

### 5. Edge cases

- **Magic-link entry while flagged:** middleware still gates to SetPassword; works because no current password is required.
- **Redirect-loop safety:** SetPassword route + logout exempt from the gate.
- **Direct deep-link while flagged:** middleware intercepts → SetPassword.
- **Existing/already-provisioned stores:** `false`, never gated.
- **Re-running `tenants:migrate`:** standard add-column; safe.

### 6. Testing (Pest, tenant context)

- Flagged admin hitting any admin route → redirected to SetPassword.
- Non-flagged admin → reaches dashboard normally.
- Valid new password → flag cleared, hash saved, lands on dashboard; new password authenticates.
- Weak / mismatched password → rejected, flag stays `true`.
- SetPassword page + logout reachable while flagged (no loop).
- `TenantProvisioningService::provision()` sets `must_change_password = true` on the created admin.
- End-to-end: provision → login with temp password → gated to SetPassword → set password → dashboard.

## Out of scope (possible follow-ups)

- Forcing the same flow on `SuperAdmin` accounts created/reset via `AdminResource`.
- Temp-password expiry / "link expired, request a new one" flows.
- Updating the welcome email copy to mention the forced first-login change (nice-to-have; not required for the gate to work).
