# Forced Password Change (Store Owners) Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Force a newly provisioned store owner to set their own password before they can use the store admin, replacing the emailed temporary password.

**Architecture:** Add a `must_change_password` flag to the tenant `admins` table, set it `true` at provisioning, and hard-gate the Filament admin panel with a middleware that redirects flagged admins to a dedicated `SetPassword` page until they change it. Going-forward only; store owners only.

**Tech Stack:** Laravel 13, Filament 5.6, Livewire 4, stancl/tenancy 3.10, PHPUnit (sqlite `:memory:` in tests).

**Spec:** `docs/superpowers/specs/2026-06-01-force-password-change-design.md`

---

## File Structure

- **Create** `database/migrations/tenant/2026_06_01_000001_add_must_change_password_to_admins_table.php` — adds the boolean column to every tenant `admins` table.
- **Modify** `app/Models/Admin.php` — make the column fillable + cast to boolean.
- **Create** `app/Http/Middleware/RequirePasswordChange.php` — the gate.
- **Modify** `app/Providers/Filament/AdminPanelProvider.php` — register the middleware in `authMiddleware`.
- **Create** `app/Filament/Pages/SetPassword.php` — the forced-change page (auto-discovered by the panel).
- **Create** `resources/views/filament/pages/set-password.blade.php` — the page view.
- **Modify** `app/Services/TenantProvisioningService.php` — set the flag when creating the owner admin.
- **Create** `tests/Unit/Models/AdminMustChangePasswordTest.php` — model cast/fillable.
- **Create** `tests/Unit/RequirePasswordChangeMiddlewareTest.php` — gate branching.
- **Create** `tests/Feature/Filament/SetPasswordPageTest.php` — page save behaviour (Livewire).

### Testing note (read before Task 5/6)

This codebase has **no tenant-database integration test harness** — existing tenant-model tests (e.g. `tests/Unit/Models/ExpenseTest.php`) test pure in-memory logic, and `TenantProvisioningService::provision()` creates a real tenant DB + runs `tenants:migrate`, which is not runnable under sqlite `:memory:`. Therefore:

- The model, middleware, and `SetPassword` page are covered by **automated tests** (they work against the default sqlite connection when tenancy is not initialized — tenant models fall back to the default connection).
- **Provisioning setting the flag** and the **full end-to-end gate** are covered by a **documented manual verification** (Task 6), not an automated provisioning test. This matches the existing convention and avoids writing tests the harness cannot run.

---

## Task 1: Migration — add `must_change_password` to `admins`

**Files:**
- Create: `database/migrations/tenant/2026_06_01_000001_add_must_change_password_to_admins_table.php`

- [ ] **Step 1: Write the migration**

```php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('admins', function (Blueprint $table) {
            $table->boolean('must_change_password')->default(false)->after('password');
        });
    }

    public function down(): void
    {
        Schema::table('admins', function (Blueprint $table) {
            $table->dropColumn('must_change_password');
        });
    }
};
```

- [ ] **Step 2: Apply to all existing tenant databases**

Run: `php artisan tenants:migrate`
Expected: migration runs for each tenant with no errors. (Existing admins keep the default `false`.)

- [ ] **Step 3: Commit**

```bash
git add database/migrations/tenant/2026_06_01_000001_add_must_change_password_to_admins_table.php
git commit -m "feat(admin): add must_change_password column to tenant admins table"
```

---

## Task 2: Admin model — fillable + boolean cast

**Files:**
- Modify: `app/Models/Admin.php`
- Test: `tests/Unit/Models/AdminMustChangePasswordTest.php`

- [ ] **Step 1: Write the failing test**

```php
<?php

declare(strict_types=1);

namespace Tests\Unit\Models;

use App\Models\Admin;
use Tests\TestCase;

class AdminMustChangePasswordTest extends TestCase
{
    public function test_must_change_password_is_cast_to_boolean(): void
    {
        $this->assertTrue((new Admin(['must_change_password' => 1]))->must_change_password);
        $this->assertFalse((new Admin(['must_change_password' => 0]))->must_change_password);
        $this->assertIsBool((new Admin(['must_change_password' => 1]))->must_change_password);
    }

    public function test_must_change_password_is_mass_assignable(): void
    {
        $admin = new Admin(['must_change_password' => true]);
        $this->assertTrue($admin->must_change_password);
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `php artisan test --filter=AdminMustChangePasswordTest`
Expected: FAIL — `must_change_password` is not fillable yet, so it is not set (assertion on `true` fails).

- [ ] **Step 3: Modify the model**

In `app/Models/Admin.php`, update `$fillable` and `casts()`:

```php
    protected $fillable = ['name', 'email', 'password', 'must_change_password'];

    protected $hidden = ['password', 'remember_token'];

    protected function casts(): array
    {
        return [
            'password'             => 'hashed',
            'must_change_password' => 'boolean',
        ];
    }
```

- [ ] **Step 4: Run test to verify it passes**

Run: `php artisan test --filter=AdminMustChangePasswordTest`
Expected: PASS (2 tests).

- [ ] **Step 5: Commit**

```bash
git add app/Models/Admin.php tests/Unit/Models/AdminMustChangePasswordTest.php
git commit -m "feat(admin): make must_change_password fillable and boolean-cast"
```

---

## Task 3: `RequirePasswordChange` middleware + panel registration

**Files:**
- Create: `app/Http/Middleware/RequirePasswordChange.php`
- Modify: `app/Providers/Filament/AdminPanelProvider.php`
- Test: `tests/Unit/RequirePasswordChangeMiddlewareTest.php`

Behaviour: on an authenticated **GET** request, if the admin's `must_change_password` is true and the request is not already the set-password page, redirect to `/admin/set-password`. Only GET requests are gated, so Livewire form POSTs (the set-password form submission) and the POST logout are never interfered with. A relative redirect (`/admin/set-password`) is used so it works on the tenant's own domain and is trivially testable.

- [ ] **Step 1: Write the failing test**

```php
<?php

declare(strict_types=1);

namespace Tests\Unit;

use App\Http\Middleware\RequirePasswordChange;
use App\Models\Admin;
use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
use Tests\TestCase;

class RequirePasswordChangeMiddlewareTest extends TestCase
{
    private RequirePasswordChange $middleware;

    protected function setUp(): void
    {
        parent::setUp();
        $this->middleware = new RequirePasswordChange();
    }

    private function call(Request $request): SymfonyResponse
    {
        return $this->middleware->handle($request, fn ($r) => new Response('OK', 200));
    }

    private function loginAdmin(bool $mustChange): void
    {
        $admin = new Admin(['name' => 'T', 'email' => 't@e.com', 'must_change_password' => $mustChange]);
        Auth::guard('admins')->setUser($admin);
    }

    public function test_flagged_admin_get_is_redirected_to_set_password(): void
    {
        $this->loginAdmin(true);
        $response = $this->call(Request::create('/admin/products', 'GET'));

        $this->assertInstanceOf(RedirectResponse::class, $response);
        $this->assertSame('/admin/set-password', $response->getTargetUrl());
    }

    public function test_flagged_admin_on_set_password_page_passes_through(): void
    {
        $this->loginAdmin(true);
        $response = $this->call(Request::create('/admin/set-password', 'GET'));

        $this->assertSame('OK', $response->getContent());
    }

    public function test_flagged_admin_post_passes_through(): void
    {
        $this->loginAdmin(true);
        $response = $this->call(Request::create('/admin/logout', 'POST'));

        $this->assertSame('OK', $response->getContent());
    }

    public function test_unflagged_admin_passes_through(): void
    {
        $this->loginAdmin(false);
        $response = $this->call(Request::create('/admin/products', 'GET'));

        $this->assertSame('OK', $response->getContent());
    }

    public function test_guest_passes_through(): void
    {
        $response = $this->call(Request::create('/admin/products', 'GET'));

        $this->assertSame('OK', $response->getContent());
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `php artisan test --filter=RequirePasswordChangeMiddlewareTest`
Expected: FAIL — class `App\Http\Middleware\RequirePasswordChange` does not exist.

- [ ] **Step 3: Create the middleware**

```php
<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use App\Models\Admin;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;

class RequirePasswordChange
{
    public function handle(Request $request, Closure $next): Response
    {
        $admin = Auth::guard('admins')->user();

        if (
            $admin instanceof Admin
            && $admin->must_change_password
            && $request->isMethod('GET')
            && ! $request->is('admin/set-password')
        ) {
            return redirect('/admin/set-password');
        }

        return $next($request);
    }
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `php artisan test --filter=RequirePasswordChangeMiddlewareTest`
Expected: PASS (5 tests).

- [ ] **Step 5: Register the middleware in the Admin panel**

In `app/Providers/Filament/AdminPanelProvider.php`, add the import near the other `use` statements:

```php
use App\Http\Middleware\RequirePasswordChange;
```

Then add it to the `authMiddleware` array (it must run only for authenticated requests, after `Authenticate`):

```php
            ->authMiddleware([
                Authenticate::class,
                RequirePasswordChange::class,
            ]);
```

- [ ] **Step 6: Run the whole suite to confirm nothing broke**

Run: `php artisan test`
Expected: PASS (all existing tests + the new ones).

- [ ] **Step 7: Commit**

```bash
git add app/Http/Middleware/RequirePasswordChange.php app/Providers/Filament/AdminPanelProvider.php tests/Unit/RequirePasswordChangeMiddlewareTest.php
git commit -m "feat(admin): gate panel behind RequirePasswordChange middleware"
```

---

## Task 4: `SetPassword` Filament page

**Files:**
- Create: `app/Filament/Pages/SetPassword.php`
- Create: `resources/views/filament/pages/set-password.blade.php`
- Test: `tests/Feature/Filament/SetPasswordPageTest.php`

The page collects a new password + confirmation (no current-password field — the admin just authenticated, or arrived via magic link). On save it stores the new password (raw value; the model's `hashed` cast hashes it once), clears the flag, and redirects to the dashboard. It is auto-discovered via the panel's `discoverPages()`.

- [ ] **Step 1: Write the failing test**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Filament;

use App\Filament\Pages\SetPassword;
use App\Models\Admin;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
use Livewire\Livewire;
use Tests\TestCase;

class SetPasswordPageTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();

        // Build the tenant `admins` table on the default sqlite connection
        // (tenancy is not initialized in tests, so Admin uses the default connection).
        Schema::create('admins', function ($table) {
            $table->id();
            $table->string('name');
            $table->string('email')->unique();
            $table->string('password');
            $table->boolean('must_change_password')->default(false);
            $table->rememberToken();
            $table->timestamps();
        });

        Filament::setCurrentPanel(Filament::getPanel('admin'));
    }

    private function makeFlaggedAdmin(): Admin
    {
        return Admin::create([
            'name'                 => 'Owner',
            'email'                => 'owner@example.com',
            'password'             => 'temp-password-123',
            'must_change_password' => true,
        ]);
    }

    public function test_saving_a_new_password_clears_the_flag_and_updates_the_hash(): void
    {
        $admin = $this->makeFlaggedAdmin();
        Auth::guard('admins')->login($admin);

        Livewire::test(SetPassword::class)
            ->fillForm([
                'password'             => 'BrandNewPass123!',
                'passwordConfirmation' => 'BrandNewPass123!',
            ])
            ->call('save')
            ->assertHasNoFormErrors();

        $admin->refresh();
        $this->assertFalse($admin->must_change_password);
        $this->assertTrue(Hash::check('BrandNewPass123!', $admin->password));
    }

    public function test_short_password_is_rejected_and_flag_stays_set(): void
    {
        $admin = $this->makeFlaggedAdmin();
        Auth::guard('admins')->login($admin);

        Livewire::test(SetPassword::class)
            ->fillForm([
                'password'             => 'short',
                'passwordConfirmation' => 'short',
            ])
            ->call('save')
            ->assertHasFormErrors(['password']);

        $admin->refresh();
        $this->assertTrue($admin->must_change_password);
    }

    public function test_mismatched_confirmation_is_rejected(): void
    {
        $admin = $this->makeFlaggedAdmin();
        Auth::guard('admins')->login($admin);

        Livewire::test(SetPassword::class)
            ->fillForm([
                'password'             => 'BrandNewPass123!',
                'passwordConfirmation' => 'DifferentPass123!',
            ])
            ->call('save')
            ->assertHasFormErrors(['password']);

        $admin->refresh();
        $this->assertTrue($admin->must_change_password);
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `php artisan test --filter=SetPasswordPageTest`
Expected: FAIL — class `App\Filament\Pages\SetPassword` does not exist.

- [ ] **Step 3: Create the page**

```php
<?php

declare(strict_types=1);

namespace App\Filament\Pages;

use App\Models\Admin;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rules\Password;

class SetPassword extends Page implements HasForms
{
    use InteractsWithForms;

    protected string $view = 'filament.pages.set-password';

    protected static ?string $slug = 'set-password';

    public ?array $data = [];

    public static function shouldRegisterNavigation(): bool
    {
        return false;
    }

    public function getTitle(): string
    {
        return 'Set your password';
    }

    public function mount(): void
    {
        $this->form->fill();
    }

    public function form(Schema $schema): Schema
    {
        return $schema
            ->statePath('data')
            ->components([
                TextInput::make('password')
                    ->label('New password')
                    ->password()
                    ->revealable()
                    ->required()
                    ->minLength(8)
                    ->rule(Password::default())
                    ->same('passwordConfirmation')
                    ->autocomplete('new-password'),

                TextInput::make('passwordConfirmation')
                    ->label('Confirm new password')
                    ->password()
                    ->revealable()
                    ->required()
                    ->dehydrated(false)
                    ->autocomplete('new-password'),
            ]);
    }

    protected function getFormActions(): array
    {
        return [
            Action::make('save')
                ->label('Set password')
                ->submit('save'),
        ];
    }

    public function save(): void
    {
        $data = $this->form->getState();

        /** @var Admin $admin */
        $admin = Auth::guard('admins')->user();

        // Raw value; the Admin model's `hashed` cast hashes it once on save.
        $admin->forceFill([
            'password'             => $data['password'],
            'must_change_password' => false,
        ])->save();

        Notification::make()
            ->title('Password updated')
            ->success()
            ->send();

        $this->redirect('/admin');
    }
}
```

- [ ] **Step 4: Create the view**

`resources/views/filament/pages/set-password.blade.php`:

```blade
<x-filament-panels::page>
    <p class="text-sm text-gray-500 dark:text-gray-400">
        Choose a password to finish setting up your account.
    </p>

    <form wire:submit="save">
        {{ $this->form }}

        <div class="mt-6 flex justify-end">
            <x-filament::button type="submit">
                Set password
            </x-filament::button>
        </div>
    </form>

    <x-filament-actions::modals />
</x-filament-panels::page>
```

- [ ] **Step 5: Run test to verify it passes**

Run: `php artisan test --filter=SetPasswordPageTest`
Expected: PASS (3 tests).

- [ ] **Step 6: Run the whole suite**

Run: `php artisan test`
Expected: PASS.

- [ ] **Step 7: Commit**

```bash
git add app/Filament/Pages/SetPassword.php resources/views/filament/pages/set-password.blade.php tests/Feature/Filament/SetPasswordPageTest.php
git commit -m "feat(admin): add forced SetPassword page"
```

---

## Task 5: Provisioning sets the flag

**Files:**
- Modify: `app/Services/TenantProvisioningService.php:59-63`

- [ ] **Step 1: Set the flag on the created owner admin**

In `app/Services/TenantProvisioningService.php`, update the `Admin::create(...)` call:

```php
            Admin::create([
                'name'                 => $data['admin_name'],
                'email'                => $data['admin_email'],
                'password'             => $data['admin_password'],
                'must_change_password' => true,
            ]);
```

- [ ] **Step 2: Static check**

Run: `vendor/bin/pint --test app/Services/TenantProvisioningService.php`
Expected: PASS (no style violations). If it reports issues, run `vendor/bin/pint app/Services/TenantProvisioningService.php` and re-check.

- [ ] **Step 3: Commit**

```bash
git add app/Services/TenantProvisioningService.php
git commit -m "feat(provisioning): flag newly provisioned owner to change password"
```

> Note: a full automated provisioning test is not included — `provision()` creates a real tenant database via `tenants:migrate`, which the sqlite `:memory:` test harness cannot run (see "Testing note" above). This one-line change is verified end-to-end in Task 6.

---

## Task 6: Manual end-to-end verification

No code changes. Verify the feature on the local dev environment (`ecstores.test`) or by provisioning a throwaway store.

- [ ] **Step 1: Confirm the column exists on a tenant**

Run: `php artisan tinker`
Then: `\App\Models\Tenant::first()->run(fn () => \Illuminate\Support\Facades\Schema::hasColumn('admins', 'must_change_password'));`
Expected: `true`.

- [ ] **Step 2: Provision a fresh store and confirm the flag is set**

Provision a new store (via the normal provisioning API/flow), then in tinker:
`\App\Models\Tenant::find('<new-slug>')->run(fn () => \App\Models\Admin::value('must_change_password'));`
Expected: `1` (true).

- [ ] **Step 3: Verify the gate**

Log into the new store's `/admin` with the emailed temp password.
Expected: immediately redirected to `/admin/set-password`; navigating to any other admin URL (e.g. `/admin/products`) bounces back to `/admin/set-password`.

- [ ] **Step 4: Verify the change clears the gate**

On `/admin/set-password`, set a new password.
Expected: "Password updated" notification, redirected to the dashboard, and full panel access restored. Logging out and back in with the new password works; the temp password no longer works.

- [ ] **Step 5: Verify existing stores are unaffected**

Log into a store that existed before this change.
Expected: normal dashboard access, no redirect to set-password.

---

## Self-review checklist (completed by plan author)

- **Spec coverage:** data model (Task 1-2), set flag at provisioning (Task 5), clear flag on SetPassword (Task 4), hard-gate middleware (Task 3), SetPassword page (Task 4), going-forward only (Task 1 default + Task 6 Step 5), edge cases — magic-link/GET-only gating, redirect-loop exemption, existing stores (Task 3 + Task 6). Forgot-password flow intentionally untouched (storefront-only) — no task needed.
- **Placeholders:** none — every step contains concrete code/commands.
- **Type/name consistency:** `must_change_password` (column + property), `RequirePasswordChange` (middleware), `SetPassword` (page, slug `set-password`, route path `/admin/set-password`), form fields `password` / `passwordConfirmation` are consistent across all tasks.
