# Employee WhatsApp Login — Phase 2 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:** Implement first-login OTP + forced password change for employees created via WhatsApp.

**Architecture:** Add a `must_change_password` boolean to `users`, set it on employee creation, return a distinct `403 account_unverified` (with OTP) on unverified employee WhatsApp login, expose the flag in auth resources, add a `POST /api/v1/auth/force-change-password` endpoint, and protect all other authenticated routes with a new `EnsurePasswordChanged` middleware.

**Tech Stack:** Laravel 11, Pest PHP, Laravel Sanctum, Spatie Permission, Cache-based OTP.

---

## File Map

| File | Responsibility |
|------|----------------|
| `database/migrations/2026_07_04_000001_add_must_change_password_to_users_table.php` | Adds `must_change_password` boolean column. |
| `app/Models/User.php` | Casts `must_change_password` to boolean. |
| `app/Http/Controllers/Api/Owner/EmployeeController.php` | Sets `must_change_password => true` when creating employee user. |
| `app/Http/Resources/LoginResource.php` | Exposes `must_change_password` in login/verify responses. |
| `app/Http/Resources/UserResource.php` | Exposes `must_change_password` in user payloads (profile, etc.). |
| `app/Http/Controllers/Api/AuthController.php` | Triggers OTP on unverified employee WhatsApp login; adds `forceChangePassword` action. |
| `app/Http/Middleware/EnsurePasswordChanged.php` | Blocks authenticated routes when `must_change_password` is true. |
| `bootstrap/app.php` | Registers `password_changed` middleware alias. |
| `routes/api.php` | Adds force-change-password route and applies password-changed middleware group. |
| `tests/Feature/Owner/EmployeeStoreTest.php` | Asserts new employees get `must_change_password=true`. |
| `tests/Feature/Auth/LoginTest.php` | Tests unverified employee WhatsApp login returns 403 + OTP. |
| `tests/Feature/Auth/VerificationTest.php` | Tests post-OTP response includes `must_change_password=true`. |
| `tests/Feature/Auth/ForceChangePasswordTest.php` | Tests force-change-password endpoint and middleware behavior. |

---

### Task 1: Add `must_change_password` column

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

- [ ] **Step 1: Create 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('users', function (Blueprint $table) {
            $table->boolean('must_change_password')->default(false)->after('is_verified');
        });
    }

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

- [ ] **Step 2: Run migration**

Run: `php artisan migrate`
Expected: Migration completes with no errors.

- [ ] **Step 3: Commit**

```bash
git add database/migrations/2026_07_04_000001_add_must_change_password_to_users_table.php
git commit -m "feat(employees): add must_change_password column to users"
```

---

### Task 2: Cast `must_change_password` on User model

**Files:**
- Modify: `app/Models/User.php:37-43`

- [ ] **Step 1: Add cast**

Replace the `casts()` method with:

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

- [ ] **Step 2: Write a quick model cast test**

Add to `tests/Feature/Owner/EmployeeStoreTest.php` (will be consolidated in Task 10):

```php
it('creates an employee with must_change_password true', function () {
    $response = $this->withToken($this->ownerToken)->postJson('/api/v1/employees', [
        'name' => 'Flagged Employee',
        'whatsapp_number' => '+12025552000',
        'password' => 'Password123!',
        'building_id' => $this->building->id,
    ]);

    $response->assertCreated();

    $employee = Employee::find($response->json('data.id'));
    expect($employee->user->must_change_password)->toBeTrue()
        ->and($employee->user->is_verified)->toBeFalse();
});
```

- [ ] **Step 3: Run the new test**

Run: `php artisan test tests/Feature/Owner/EmployeeStoreTest.php --filter=must_change_password`
Expected: FAIL (employee controller doesn't set the flag yet).

- [ ] **Step 4: Commit test**

```bash
git add tests/Feature/Owner/EmployeeStoreTest.php
git commit -m "test(employees): add must_change_password expectation"
```

---

### Task 3: Set `must_change_password => true` in EmployeeController::store

**Files:**
- Modify: `app/Http/Controllers/Api/Owner/EmployeeController.php:74-80`

- [ ] **Step 1: Update user creation**

Change the `User::create` call in `store()` to include the flag:

```php
$user = User::create([
    'name' => $data['name'],
    'email' => $data['email'] ?? null,
    'whatsapp_number' => $data['whatsapp_number'] ?? null,
    'password' => Hash::make($data['password']),
    'is_verified' => false,
    'must_change_password' => true,
]);
```

- [ ] **Step 2: Run the test from Task 2**

Run: `php artisan test tests/Feature/Owner/EmployeeStoreTest.php --filter=must_change_password`
Expected: PASS.

- [ ] **Step 3: Commit**

```bash
git add app/Http/Controllers/Api/Owner/EmployeeController.php
git commit -m "feat(employees): set must_change_password true on creation"
```

---

### Task 4: Expose `must_change_password` in LoginResource and UserResource

**Files:**
- Modify: `app/Http/Resources/LoginResource.php:18-30`
- Modify: `app/Http/Resources/UserResource.php:10-20`

- [ ] **Step 1: Update LoginResource**

```php
public function toArray(Request $request): array
{
    $user = $this->resource;

    return [
        'user' => UserResource::make($user),
        'permissions' => $user->getPermissionNames()->values(),
        'token' => $this->token,
        'logo_url' => $user->isOwner()
            ? $user->owner?->getFirstMediaUrl('logo')
            : null,
        'must_change_password' => $user->must_change_password,
    ];
}
```

- [ ] **Step 2: Update UserResource**

```php
public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        'email' => $this->email,
        'whatsapp_number' => $this->whatsapp_number,
        'must_change_password' => $this->must_change_password,
        'roles' => $this->whenLoaded('roles', fn () => $this->roles->pluck('name')),
        'created_at' => $this->created_at,
    ];
}
```

- [ ] **Step 3: Verify existing login tests still pass**

Run: `php artisan test tests/Feature/Auth/LoginTest.php`
Expected: PASS (existing assertions don't conflict with new fields).

- [ ] **Step 4: Commit**

```bash
git add app/Http/Resources/LoginResource.php app/Http/Resources/UserResource.php
git commit -m "feat(auth): expose must_change_password in login and user resources"
```

---

### Task 5: Unverified employee WhatsApp login triggers OTP

**Files:**
- Modify: `app/Http/Controllers/Api/AuthController.php:32-49`

- [ ] **Step 1: Update login logic**

Replace the `login()` method with:

```php
public function login(LoginRequest $request): JsonResponse
{
    $field = $request->has('email') ? 'email' : 'whatsapp_number';
    $value = $request->validated($field);
    $password = $request->validated('password');

    if ($field === 'email') {
        $user = User::where('email', $value)->first();
    } else {
        $user = User::where('whatsapp_number', $value)->first();
    }

    if (! $user || ! Hash::check($password, $user->password)) {
        return response()->json(['message' => 'Invalid credentials.'], 401);
    }

    if ($field === 'whatsapp_number' && $user->isEmployee() && ! $user->is_verified) {
        $otp = OtpService::send($user->whatsapp_number);

        $payload = [
            'message' => 'Account not verified.',
            'code' => 'account_unverified',
            'whatsapp_number' => $user->whatsapp_number,
        ];

        if (in_array(config('app.env'), ['local', 'testing'], true)) {
            $payload['otp'] = $otp;
        }

        return response()->json($payload, 403);
    }

    if (! $user->is_verified) {
        return response()->json(['message' => 'Invalid credentials.'], 401);
    }

    return AuthService::issueAuthToken($user)->response();
}
```

- [ ] **Step 2: Add failing test for new behavior**

Add to `tests/Feature/Auth/LoginTest.php`:

```php
it('sends otp and returns account_unverified for unverified employee whatsapp login', function () {
    $user = User::factory()->create([
        'email' => null,
        'whatsapp_number' => '+12025552000',
        'password' => Hash::make('temppass123'),
        'is_verified' => false,
    ]);
    $user->assignRole('employee');

    $response = $this->postJson('/api/v1/login', [
        'whatsapp_number' => '+12025552000',
        'password' => 'temppass123',
    ]);

    $response->assertForbidden()
        ->assertJsonPath('message', 'Account not verified.')
        ->assertJsonPath('code', 'account_unverified')
        ->assertJsonPath('whatsapp_number', '+12025552000')
        ->assertJsonPath('otp', fn ($otp) => is_string($otp) && strlen($otp) === 6);
});

it('returns invalid credentials for unverified customer whatsapp login', function () {
    $user = User::factory()->create([
        'email' => null,
        'whatsapp_number' => '+12025552001',
        'password' => Hash::make('temppass123'),
        'is_verified' => false,
    ]);
    $user->assignRole('customer');

    $this->postJson('/api/v1/login', [
        'whatsapp_number' => '+12025552001',
        'password' => 'temppass123',
    ])->assertUnauthorized()
        ->assertJsonPath('message', 'Invalid credentials.');
});
```

- [ ] **Step 3: Run the new tests**

Run: `php artisan test tests/Feature/Auth/LoginTest.php --filter="otp|unverified customer whatsapp"`
Expected: PASS.

- [ ] **Step 4: Run full login suite**

Run: `php artisan test tests/Feature/Auth/LoginTest.php`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add app/Http/Controllers/Api/AuthController.php tests/Feature/Auth/LoginTest.php
git commit -m "feat(auth): send OTP on unverified employee WhatsApp login"
```

---

### Task 6: Add force-change-password endpoint

**Files:**
- Modify: `app/Http/Controllers/Api/AuthController.php`

- [ ] **Step 1: Add forceChangePassword action**

Add this method after `updatePassword()`:

```php
public function forceChangePassword(Request $request): JsonResponse
{
    $request->validate([
        'password' => ['required', 'string', 'confirmed', Password::defaults()],
    ]);

    $user = auth()->user();

    $user->update([
        'password' => Hash::make($request->password),
        'must_change_password' => false,
    ]);

    $user->tokens()->delete();

    return AuthService::issueAuthToken($user)->response();
}
```

- [ ] **Step 2: Add route**

In `routes/api.php`, inside the `Route::middleware(['auth:api', 'verified'])->group(...)` block, add:

```php
Route::post('/auth/force-change-password', [AuthController::class, 'forceChangePassword'])
    ->name('auth.force-change-password');
```

(The `password_changed` middleware group will be applied in Task 9, with this route excluded.)

- [ ] **Step 3: Add tests**

Create `tests/Feature/Auth/ForceChangePasswordTest.php`:

```php
<?php

use App\Facades\OtpService;
use App\Models\Employee;
use App\Models\Owner;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Hash;

beforeEach(function () {
    $this->seed(RolesAndPermissionsSeeder::class);

    $this->ownerUser = User::factory()->create([
        'password' => Hash::make('ownerpass123'),
        'is_verified' => true,
    ]);
    $this->ownerUser->assignRole('owner');
    Owner::create(['id' => $this->ownerUser->id, 'phone' => '+12025550100', 'status' => 'active']);

    $this->employeeUser = User::factory()->create([
        'email' => null,
        'whatsapp_number' => '+12025552000',
        'password' => Hash::make('temppass123'),
        'is_verified' => false,
        'must_change_password' => true,
    ]);
    $this->employeeUser->assignRole('employee');
    Employee::create(['id' => $this->employeeUser->id, 'owner_id' => $this->ownerUser->owner->id, 'status' => 'active']);
});

it('allows an employee to force change password without current_password', function () {
    $otp = OtpService::send('+12025552000');

    $verifyResponse = $this->postJson('/api/v1/verify-account', [
        'whatsapp_number' => '+12025552000',
        'otp' => $otp,
    ])->assertOk();

    $token = $verifyResponse->json('data.token');
    expect($verifyResponse->json('data.must_change_password'))->toBeTrue();

    $response = $this->withToken($token)->postJson('/api/v1/auth/force-change-password', [
        'password' => 'NewPassword123!',
        'password_confirmation' => 'NewPassword123!',
    ]);

    $response->assertOk()
        ->assertJsonPath('data.must_change_password', false);

    expect(Hash::check('NewPassword123!', $this->employeeUser->fresh()->password))->toBeTrue()
        ->and($this->employeeUser->fresh()->must_change_password)->toBeFalse();

    $this->postJson('/api/v1/login', [
        'whatsapp_number' => '+12025552000',
        'password' => 'NewPassword123!',
    ])->assertOk()
        ->assertJsonPath('data.must_change_password', false);
});

it('revokes old tokens after force password change', function () {
    $otp = OtpService::send('+12025552000');

    $verifyResponse = $this->postJson('/api/v1/verify-account', [
        'whatsapp_number' => '+12025552000',
        'otp' => $otp,
    ])->assertOk();

    $oldToken = $verifyResponse->json('data.token');

    $newResponse = $this->withToken($oldToken)->postJson('/api/v1/auth/force-change-password', [
        'password' => 'NewPassword123!',
        'password_confirmation' => 'NewPassword123!',
    ])->assertOk();

    $this->withToken($oldToken)
        ->getJson('/api/v1/profile')
        ->assertUnauthorized();

    $this->withToken($newResponse->json('data.token'))
        ->getJson('/api/v1/profile')
        ->assertOk();
});

it('validates password confirmation on force change password', function () {
    $otp = OtpService::send('+12025552000');

    $verifyResponse = $this->postJson('/api/v1/verify-account', [
        'whatsapp_number' => '+12025552000',
        'otp' => $otp,
    ])->assertOk();

    $this->withToken($verifyResponse->json('data.token'))
        ->postJson('/api/v1/auth/force-change-password', [
            'password' => 'NewPassword123!',
            'password_confirmation' => 'DifferentPassword123!',
        ])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['password']);
});
```

- [ ] **Step 4: Run tests**

Run: `php artisan test tests/Feature/Auth/ForceChangePasswordTest.php`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add app/Http/Controllers/Api/AuthController.php routes/api.php tests/Feature/Auth/ForceChangePasswordTest.php
git commit -m "feat(auth): add force-change-password endpoint for employees"
```

---

### Task 7: Ensure post-OTP verification returns `must_change_password`

**Files:**
- Modify: `tests/Feature/Auth/VerificationTest.php`

- [ ] **Step 1: Add test**

Add to `tests/Feature/Auth/VerificationTest.php`:

```php
it('returns must_change_password true after employee verifies account', function () {
    $user = User::factory()->create([
        'email' => null,
        'whatsapp_number' => '+12025552000',
        'password' => Hash::make('temppass123'),
        'is_verified' => false,
        'must_change_password' => true,
    ]);
    $user->assignRole('employee');

    $otp = app(OtpService::class)->send('+12025552000');

    $response = $this->postJson('/api/v1/verify-account', [
        'whatsapp_number' => '+12025552000',
        'otp' => $otp,
    ]);

    $response->assertOk()
        ->assertJsonPath('data.user.must_change_password', true)
        ->assertJsonPath('data.must_change_password', true);
});
```

- [ ] **Step 2: Run test**

Run: `php artisan test tests/Feature/Auth/VerificationTest.php --filter="must_change_password true after employee verifies"`
Expected: PASS (LoginResource already includes the flag).

- [ ] **Step 3: Commit**

```bash
git add tests/Feature/Auth/VerificationTest.php
git commit -m "test(auth): verify must_change_password returned after OTP"
```

---

### Task 8: Create EnsurePasswordChanged middleware

**Files:**
- Create: `app/Http/Middleware/EnsurePasswordChanged.php`

- [ ] **Step 1: Create middleware**

```php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class EnsurePasswordChanged
{
    public function handle(Request $request, Closure $next): Response
    {
        $user = $request->user();

        if ($user && $user->must_change_password) {
            return response()->json([
                'message' => 'Password change required.',
                'code' => 'password_change_required',
            ], 403);
        }

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

- [ ] **Step 2: Register alias in bootstrap/app.php**

Modify `bootstrap/app.php` imports and alias array:

```php
use App\Http\Middleware\EnsurePasswordChanged;
use App\Http\Middleware\EnsureUserIsVerified;
use App\Http\Middleware\SecurityHeadersMiddleware;
```

```php
$middleware->alias([
    'role' => RoleMiddleware::class,
    'permission' => PermissionMiddleware::class,
    'role_or_permission' => RoleOrPermissionMiddleware::class,
    'verified' => EnsureUserIsVerified::class,
    'password_changed' => EnsurePasswordChanged::class,
]);
```

- [ ] **Step 3: Commit middleware and registration**

```bash
git add app/Http/Middleware/EnsurePasswordChanged.php bootstrap/app.php
git commit -m "feat(auth): add EnsurePasswordChanged middleware"
```

---

### Task 9: Apply password_changed middleware group

**Files:**
- Modify: `routes/api.php:72-194`

- [ ] **Step 1: Update route group**

Change the existing verified group from:

```php
Route::middleware(['auth:api', 'verified'])->group(function () {
```

to:

```php
Route::middleware(['auth:api', 'verified', 'password_changed'])->group(function () {
```

- [ ] **Step 2: Exclude force-change-password and logout from password_changed**

Inside the group, update logout and add the new route:

```php
Route::post('/logout', [AuthController::class, 'logout'])
    ->name('logout')
    ->withoutMiddleware(['password_changed']);

Route::post('/auth/force-change-password', [AuthController::class, 'forceChangePassword'])
    ->name('auth.force-change-password')
    ->withoutMiddleware(['password_changed']);
```

- [ ] **Step 3: Add middleware test**

Add to `tests/Feature/Auth/ForceChangePasswordTest.php`:

```php
it('blocks protected routes when must_change_password is true', function () {
    $otp = OtpService::send('+12025552000');

    $verifyResponse = $this->postJson('/api/v1/verify-account', [
        'whatsapp_number' => '+12025552000',
        'otp' => $otp,
    ])->assertOk();

    $token = $verifyResponse->json('data.token');

    $this->withToken($token)
        ->getJson('/api/v1/profile')
        ->assertForbidden()
        ->assertJsonPath('code', 'password_change_required');

    $this->withToken($token)
        ->getJson('/api/v1/employees')
        ->assertForbidden()
        ->assertJsonPath('code', 'password_change_required');
});

it('allows protected routes after force password change', function () {
    $otp = OtpService::send('+12025552000');

    $verifyResponse = $this->postJson('/api/v1/verify-account', [
        'whatsapp_number' => '+12025552000',
        'otp' => $otp,
    ])->assertOk();

    $token = $verifyResponse->json('data.token');

    $newResponse = $this->withToken($token)->postJson('/api/v1/auth/force-change-password', [
        'password' => 'NewPassword123!',
        'password_confirmation' => 'NewPassword123!',
    ])->assertOk();

    $this->withToken($newResponse->json('data.token'))
        ->getJson('/api/v1/profile')
        ->assertOk();
});
```

- [ ] **Step 4: Run auth test suites**

Run: `php artisan test tests/Feature/Auth/`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add routes/api.php tests/Feature/Auth/ForceChangePasswordTest.php
git commit -m "feat(routes): apply password_changed middleware to protected routes"
```

---

### Task 10: Expand employee store/update tests

**Files:**
- Modify: `tests/Feature/Owner/EmployeeStoreTest.php`
- Modify: `tests/Feature/Owner/EmployeeUpdateTest.php`

- [ ] **Step 1: Update existing whatsapp-only test**

In `tests/Feature/Owner/EmployeeStoreTest.php`, update the existing test `it('allows creating an employee with whatsapp only and no email')` to also assert `must_change_password`:

```php
it('allows creating an employee with whatsapp only and no email', function () {
    $response = $this->withToken($this->ownerToken)->postJson('/api/v1/employees', [
        'name' => 'WhatsApp Employee',
        'whatsapp_number' => '+12025550500',
        'password' => 'Password123!',
        'building_id' => $this->building->id,
    ]);

    $response->assertCreated();

    $employee = Employee::find($response->json('data.id'));
    expect($employee->user->email)->toBeNull()
        ->and($employee->user->whatsapp_number)->toBe('+12025550500')
        ->and($employee->user->is_verified)->toBeFalse()
        ->and($employee->user->must_change_password)->toBeTrue();
});
```

- [ ] **Step 2: Remove the temporary test from Task 2**

Remove `it('creates an employee with must_change_password true')` added in Task 2. The existing `it('allows creating an employee with whatsapp only and no email')` now covers both `is_verified` and `must_change_password`, so the temporary test is redundant.

- [ ] **Step 3: Run employee store tests**

Run: `php artisan test tests/Feature/Owner/EmployeeStoreTest.php`
Expected: PASS.

- [ ] **Step 4: Verify update unique-ignore-self still works**

The existing `tests/Feature/Owner/EmployeeUpdateTest.php` already covers duplicate WhatsApp and owner update. Just run it:

Run: `php artisan test tests/Feature/Owner/EmployeeUpdateTest.php`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add tests/Feature/Owner/EmployeeStoreTest.php
git commit -m "test(employees): assert must_change_password on whatsapp-only creation"
```

---

### Task 11: Full regression run

- [ ] **Step 1: Run all Feature tests**

Run: `php artisan test tests/Feature/`
Expected: PASS.

- [ ] **Step 2: Run PHPStan (if configured)**

Run: `vendor/bin/phpstan analyse --memory-limit=1G`
Expected: No new errors.

- [ ] **Step 3: Final commit if any fixes were needed**

If no fixes, no commit needed. If fixes, commit them with a clear message.

---

## Spec Coverage Check

| Spec Requirement | Task |
|------------------|------|
| Add `must_change_password` column | Task 1 |
| Cast `must_change_password` on User | Task 2 |
| Set flag on employee creation | Task 3 |
| Unverified employee WhatsApp login sends OTP + 403 | Task 5 |
| Include `must_change_password` in LoginResource | Task 4 |
| Include `must_change_password` in UserResource | Task 4 |
| Force-change-password endpoint | Task 6 |
| Post-OTP verification returns flag | Task 7 |
| EnsurePasswordChanged middleware | Tasks 8-9 |
| Tests for all scenarios | Tasks 2, 5, 6, 7, 9, 10 |
