# Profile Page 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:** Add a "My Account" profile page, reachable from the More dropdown, where the logged-in admin can edit name & email, change their password, and upload an avatar.

**Architecture:** A new Laravel `ProfileController` exposes three bearer-authenticated endpoints (`PUT /api/profile`, `PUT /api/profile/password`, `POST /api/profile/avatar`) backed by a new nullable `avatar` column on `users`. The frontend adds a `Profile` component (plain-JS window-export pattern like other pages), a `/profile` route, a "Profile" item in the More dropdown, and three `RvlApi` helpers. On success the frontend writes the returned user to `localStorage.authUser` and calls `auth.refreshUser()`.

**Tech Stack:** Laravel 11 (PHPUnit feature tests, sqlite `:memory:`), React 18 + React Router v6 (no JSX imports — global window scope), inline-style + CSS-vars styling.

---

## File Structure

**Backend (`bni_game_backend/`):**
- Create: `database/migrations/2026_06_04_000001_add_avatar_to_users_table.php` — adds nullable `avatar` column.
- Modify: `app/Models/User.php` — add `avatar` to fillable, add `avatar_url` accessor + append it.
- Create: `app/Http/Controllers/ProfileController.php` — `update`, `updatePassword`, `updateAvatar`.
- Modify: `app/Http/Controllers/AuthController.php` — include `avatar_url` in login + me responses.
- Modify: `routes/api.php` — register the three profile routes inside the `auth.bearer` group.
- Create: `tests/Feature/ProfileTest.php` — feature tests for all three endpoints.

**Frontend (`bni_game_frontend/`):**
- Modify: `src/api.js` — add `updateProfile`, `updatePassword`, `uploadAvatar` to `window.RvlApi`.
- Create: `src/profile.jsx` — the `Profile` component (window export).
- Modify: `src/main.jsx` — import `./profile.jsx` before `./app.jsx`.
- Modify: `src/shell.jsx` — add `{ id: "profile", label: "Profile" }` to `NAV_MORE`.
- Modify: `src/app.jsx` — add `ProfilePage` wrapper + `/profile` route.

---

## PART A — BACKEND

### Task 1: Add `avatar` column to users

**Files:**
- Create: `bni_game_backend/database/migrations/2026_06_04_000001_add_avatar_to_users_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('users', function (Blueprint $table) {
            $table->string('avatar')->nullable()->after('email');
        });
    }

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

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

Run: `cd bni_game_backend && php artisan migrate`
Expected: Output shows `2026_06_04_000001_add_avatar_to_users_table ... DONE`.

- [ ] **Step 3: Commit**

```bash
cd bni_game_backend
git add database/migrations/2026_06_04_000001_add_avatar_to_users_table.php
git commit -m "feat: add avatar column to users table"
```

---

### Task 2: User model — fillable + avatar_url accessor

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

- [ ] **Step 1: Add `avatar` to the `#[Fillable(...)]` attribute**

Change the line:
```php
#[Fillable(['name', 'email', 'password'])]
```
to:
```php
#[Fillable(['name', 'email', 'password', 'avatar'])]
```

- [ ] **Step 2: Add the `avatar_url` accessor and append it**

Add `use Illuminate\Support\Facades\Storage;` to the top `use` block, then add these members inside the `User` class body (after the `casts()` method):

```php
protected $appends = ['avatar_url'];

public function getAvatarUrlAttribute(): ?string
{
    $path = $this->avatar;
    if (! $path || trim($path) === '') return null;
    return url('/api/img/'.ltrim($path, '/'));
}
```

- [ ] **Step 3: Commit**

```bash
cd bni_game_backend
git add app/Models/User.php
git commit -m "feat: expose avatar_url accessor on User model"
```

---

### Task 3: ProfileController + routes (with feature tests)

**Files:**
- Create: `bni_game_backend/app/Http/Controllers/ProfileController.php`
- Modify: `bni_game_backend/routes/api.php`
- Create: `bni_game_backend/tests/Feature/ProfileTest.php`

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

Create `bni_game_backend/tests/Feature/ProfileTest.php`:

```php
<?php

namespace Tests\Feature;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Http\UploadedFile;
use Tests\TestCase;

class ProfileTest extends TestCase
{
    use RefreshDatabase;

    /** Create a user and return [user, bearerToken]. */
    private function userWithToken(array $attrs = []): array
    {
        $user  = User::factory()->create($attrs);
        $token = Str::random(60);
        DB::table('user_tokens')->insert([
            'user_id'    => $user->id,
            'token_hash' => hash('sha256', $token),
            'created_at' => now(),
        ]);
        return [$user, $token];
    }

    public function test_update_profile_changes_name_and_email(): void
    {
        [$user, $token] = $this->userWithToken(['name' => 'Old', 'email' => 'old@x.com']);

        $res = $this->withHeader('Authorization', "Bearer $token")
            ->putJson('/api/profile', ['name' => 'New Name', 'email' => 'new@x.com']);

        $res->assertOk()->assertJsonPath('user.name', 'New Name')
            ->assertJsonPath('user.email', 'new@x.com');
        $this->assertDatabaseHas('users', ['id' => $user->id, 'email' => 'new@x.com']);
    }

    public function test_update_profile_rejects_duplicate_email(): void
    {
        User::factory()->create(['email' => 'taken@x.com']);
        [, $token] = $this->userWithToken(['email' => 'mine@x.com']);

        $this->withHeader('Authorization', "Bearer $token")
            ->putJson('/api/profile', ['name' => 'A', 'email' => 'taken@x.com'])
            ->assertStatus(422);
    }

    public function test_update_profile_allows_keeping_own_email(): void
    {
        [, $token] = $this->userWithToken(['name' => 'A', 'email' => 'mine@x.com']);

        $this->withHeader('Authorization', "Bearer $token")
            ->putJson('/api/profile', ['name' => 'B', 'email' => 'mine@x.com'])
            ->assertOk()->assertJsonPath('user.name', 'B');
    }

    public function test_update_password_requires_correct_current_password(): void
    {
        [, $token] = $this->userWithToken(['password' => Hash::make('secret123')]);

        $this->withHeader('Authorization', "Bearer $token")
            ->putJson('/api/profile/password', [
                'current_password' => 'wrong',
                'password' => 'newpass123',
                'password_confirmation' => 'newpass123',
            ])->assertStatus(422);
    }

    public function test_update_password_succeeds_with_correct_current_password(): void
    {
        [$user, $token] = $this->userWithToken(['password' => Hash::make('secret123')]);

        $this->withHeader('Authorization', "Bearer $token")
            ->putJson('/api/profile/password', [
                'current_password' => 'secret123',
                'password' => 'newpass123',
                'password_confirmation' => 'newpass123',
            ])->assertOk();

        $this->assertTrue(Hash::check('newpass123', $user->fresh()->password));
    }

    public function test_update_avatar_stores_file_and_returns_url(): void
    {
        Storage::fake('public');
        [$user, $token] = $this->userWithToken();

        $res = $this->withHeader('Authorization', "Bearer $token")
            ->postJson('/api/profile/avatar', [
                'image' => UploadedFile::fake()->image('me.png'),
            ]);

        $res->assertOk()->assertJsonPath('user.id', $user->id);
        $this->assertNotNull($user->fresh()->avatar);
        Storage::disk('public')->assertExists($user->fresh()->avatar);
    }

    public function test_profile_endpoints_require_auth(): void
    {
        $this->putJson('/api/profile', ['name' => 'X', 'email' => 'x@x.com'])
            ->assertStatus(401);
    }
}
```

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

Run: `cd bni_game_backend && php artisan test --filter=ProfileTest`
Expected: FAIL — routes `/api/profile*` don't exist yet (404/405 assertion failures).

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

Create `bni_game_backend/app/Http/Controllers/ProfileController.php`:

```php
<?php

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;

class ProfileController extends Controller
{
    public function update(Request $request): JsonResponse
    {
        $user = $this->userFromToken($request);
        if (! $user) return response()->json(['message' => 'Unauthenticated.'], 401);

        $data = $request->validate([
            'name'  => ['required', 'string', 'max:255'],
            'email' => ['required', 'email', Rule::unique('users', 'email')->ignore($user->id)],
        ]);

        $user->update($data);

        return response()->json(['user' => $this->present($user)]);
    }

    public function updatePassword(Request $request): JsonResponse
    {
        $user = $this->userFromToken($request);
        if (! $user) return response()->json(['message' => 'Unauthenticated.'], 401);

        $data = $request->validate([
            'current_password' => ['required', 'string'],
            'password'         => ['required', 'string', 'min:8', 'confirmed'],
        ]);

        if (! Hash::check($data['current_password'], $user->password)) {
            return response()->json([
                'message' => 'Current password is incorrect.',
                'errors'  => ['current_password' => ['Current password is incorrect.']],
            ], 422);
        }

        $user->update(['password' => $data['password']]); // 'hashed' cast hashes it
        return response()->json(['message' => 'Password updated.']);
    }

    public function updateAvatar(Request $request): JsonResponse
    {
        $user = $this->userFromToken($request);
        if (! $user) return response()->json(['message' => 'Unauthenticated.'], 401);

        $request->validate([
            'image' => ['required', 'file', 'image', 'mimes:png,jpg,jpeg,webp', 'max:2048'],
        ]);

        if ($user->avatar) {
            Storage::disk('public')->delete($user->avatar);
        }
        $path = $request->file('image')->store('user-avatars', 'public');
        $user->update(['avatar' => $path]);

        return response()->json(['user' => $this->present($user)]);
    }

    private function present(User $user): array
    {
        return [
            'id'         => $user->id,
            'name'       => $user->name,
            'email'      => $user->email,
            'avatar_url' => $user->avatar_url,
        ];
    }

    private function userFromToken(Request $request): ?User
    {
        $token = $request->bearerToken();
        if (! $token) return null;

        $row = DB::table('user_tokens')
            ->where('token_hash', hash('sha256', $token))
            ->first();

        return $row ? User::find($row->user_id) : null;
    }
}
```

- [ ] **Step 4: Register the routes**

In `bni_game_backend/routes/api.php`, add the import near the other controller imports:
```php
use App\Http\Controllers\ProfileController;
```
Then inside the `Route::middleware('auth.bearer')->group(function () {` block, right after the `/logout` line, add:
```php
    // Profile (logged-in user account)
    Route::put('/profile', [ProfileController::class, 'update']);
    Route::put('/profile/password', [ProfileController::class, 'updatePassword']);
    Route::post('/profile/avatar', [ProfileController::class, 'updateAvatar']);
```

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

Run: `cd bni_game_backend && php artisan test --filter=ProfileTest`
Expected: PASS — all 7 tests green.

- [ ] **Step 6: Commit**

```bash
cd bni_game_backend
git add app/Http/Controllers/ProfileController.php routes/api.php tests/Feature/ProfileTest.php
git commit -m "feat: profile update, password, and avatar endpoints"
```

---

### Task 4: Include avatar_url in login + me responses

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

- [ ] **Step 1: Add a regression test for avatar_url in /api/me**

Append this method to `bni_game_backend/tests/Feature/ProfileTest.php` (inside the class):

```php
    public function test_me_response_includes_avatar_url_key(): void
    {
        [, $token] = $this->userWithToken();

        $this->withHeader('Authorization', "Bearer $token")
            ->getJson('/api/me')
            ->assertOk()
            ->assertJsonStructure(['user' => ['id', 'name', 'email', 'avatar_url']]);
    }
```

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

Run: `cd bni_game_backend && php artisan test --filter=test_me_response_includes_avatar_url_key`
Expected: FAIL — `me()` returns only `id, name, email` (missing `avatar_url`).

- [ ] **Step 3: Update AuthController to return avatar_url**

In `bni_game_backend/app/Http/Controllers/AuthController.php`, change the login response array:
```php
        return response()->json([
            'user'  => $user->only(['id', 'name', 'email']),
            'token' => $token,
        ]);
```
to:
```php
        return response()->json([
            'user'  => $user->only(['id', 'name', 'email', 'avatar_url']),
            'token' => $token,
        ]);
```
And change the `me()` return line:
```php
        return response()->json(['user' => $user->only(['id', 'name', 'email'])]);
```
to:
```php
        return response()->json(['user' => $user->only(['id', 'name', 'email', 'avatar_url'])]);
```
(`avatar_url` is an appended accessor, so `only()` includes it.)

- [ ] **Step 4: Run the full ProfileTest to verify it passes**

Run: `cd bni_game_backend && php artisan test --filter=ProfileTest`
Expected: PASS — all 8 tests green.

- [ ] **Step 5: Commit**

```bash
cd bni_game_backend
git add app/Http/Controllers/AuthController.php tests/Feature/ProfileTest.php
git commit -m "feat: include avatar_url in login and me responses"
```

---

## PART B — FRONTEND

### Task 5: Add RvlApi profile helpers

**Files:**
- Modify: `bni_game_frontend/src/api.js`

- [ ] **Step 1: Add the three helpers**

In `bni_game_frontend/src/api.js`, inside the `window.RvlApi = { ... }` object, add these entries just after the `saveSiteSettings` line (before the closing `};`):

```js
  // Profile (logged-in user account)
  updateProfile: (payload) => request("/api/profile", { method: "PUT", body: JSON.stringify(payload) }).then(r => r.user),
  updatePassword: (payload) => request("/api/profile/password", { method: "PUT", body: JSON.stringify(payload) }),
  uploadAvatar: async (file) => {
    const fd = new FormData(); fd.append("image", file);
    const token = localStorage.getItem("authToken");
    const res = await fetch(`${API_BASE}/api/profile/avatar`, {
      method: "POST", body: fd,
      headers: { Accept: "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
    });
    const data = await res.json().catch(() => ({}));
    if (!res.ok) { const e = new Error(data?.message || "Upload failed"); e.status = res.status; e.payload = data; throw e; }
    return data.user;
  },
```

- [ ] **Step 2: Verify the file still parses (build check)**

Run: `cd bni_game_frontend && npx vite build 2>&1 | tail -5`
Expected: Build completes without syntax errors (a successful "built in" line). If the project has no build step configured for quick checks, instead run `node --check` is not valid for JSX — rely on the dev server in Task 9.

- [ ] **Step 3: Commit**

```bash
cd bni_game_frontend
git add src/api.js
git commit -m "feat: add profile API helpers to RvlApi"
```

---

### Task 6: Create the Profile component

**Files:**
- Create: `bni_game_frontend/src/profile.jsx`
- Modify: `bni_game_frontend/src/main.jsx`

- [ ] **Step 1: Create `bni_game_frontend/src/profile.jsx`**

```jsx
// =============================================================================
// PROFILE — logged-in admin account page (view + edit)
// =============================================================================

function Profile({ user, onUpdated }) {
  const fileRef = React.useRef(null);

  const [name, setName]   = useState(user?.name || "");
  const [email, setEmail] = useState(user?.email || "");
  const [savingProfile, setSavingProfile] = useState(false);
  const [profileMsg, setProfileMsg] = useState(null); // {type, text}

  const [curPwd, setCurPwd]       = useState("");
  const [newPwd, setNewPwd]       = useState("");
  const [confirmPwd, setConfirmPwd] = useState("");
  const [savingPwd, setSavingPwd] = useState(false);
  const [pwdMsg, setPwdMsg] = useState(null);

  const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url || null);
  const [uploadingAvatar, setUploadingAvatar] = useState(false);
  const [avatarMsg, setAvatarMsg] = useState(null);

  const initials = (name || "Admin").trim().split(/\s+/).filter(Boolean)
    .slice(0, 2).map(w => w[0].toUpperCase()).join("") || "?";

  const firstError = (err) => {
    const errs = err?.payload?.errors;
    if (errs) { const k = Object.keys(errs)[0]; if (k) return errs[k][0]; }
    return err?.message || "Something went wrong.";
  };

  const persist = (updatedUser) => {
    try { localStorage.setItem("authUser", JSON.stringify(updatedUser)); } catch {}
    onUpdated?.(updatedUser);
  };

  const saveProfile = async (e) => {
    e.preventDefault();
    setSavingProfile(true); setProfileMsg(null);
    try {
      const updated = await RvlApi.updateProfile({ name, email });
      persist(updated);
      setProfileMsg({ type: "ok", text: "Profile updated." });
    } catch (err) {
      setProfileMsg({ type: "err", text: firstError(err) });
    } finally {
      setSavingProfile(false);
    }
  };

  const savePassword = async (e) => {
    e.preventDefault();
    if (newPwd !== confirmPwd) {
      setPwdMsg({ type: "err", text: "New passwords do not match." });
      return;
    }
    setSavingPwd(true); setPwdMsg(null);
    try {
      await RvlApi.updatePassword({
        current_password: curPwd,
        password: newPwd,
        password_confirmation: confirmPwd,
      });
      setPwdMsg({ type: "ok", text: "Password changed." });
      setCurPwd(""); setNewPwd(""); setConfirmPwd("");
    } catch (err) {
      setPwdMsg({ type: "err", text: firstError(err) });
    } finally {
      setSavingPwd(false);
    }
  };

  const onAvatarPick = async (e) => {
    const file = e.target.files?.[0];
    e.target.value = "";
    if (!file) return;
    setUploadingAvatar(true); setAvatarMsg(null);
    try {
      const updated = await RvlApi.uploadAvatar(file);
      setAvatarUrl(updated.avatar_url || null);
      persist(updated);
      setAvatarMsg({ type: "ok", text: "Photo updated." });
    } catch (err) {
      setAvatarMsg({ type: "err", text: firstError(err) });
    } finally {
      setUploadingAvatar(false);
    }
  };

  const card = {
    background: "var(--bg-card)", border: "1px solid var(--line)",
    borderRadius: 16, padding: 24, marginBottom: 20,
  };
  const label = { display: "block", fontSize: 12, fontWeight: 700, color: "var(--ink-dim)", marginBottom: 6, textTransform: "uppercase", letterSpacing: ".04em" };
  const input = {
    width: "100%", padding: "10px 12px", fontSize: 14,
    background: "var(--bg)", border: "1px solid var(--line-bright)",
    borderRadius: 10, color: "var(--ink)", boxSizing: "border-box",
  };
  const btn = (busy) => ({
    padding: "10px 20px", fontSize: 13, fontWeight: 700, borderRadius: 999,
    border: 0, cursor: busy ? "default" : "pointer", opacity: busy ? 0.6 : 1,
    background: "var(--crimson)", color: "#fff",
  });
  const msgStyle = (m) => ({
    marginTop: 12, fontSize: 13, fontWeight: 600,
    color: m.type === "ok" ? "var(--gold)" : "var(--crimson)",
  });

  return (
    <div className="rise" style={{ maxWidth: 720 }}>
      <div className="kicker" style={{ marginBottom: 4 }}>Admin Console</div>
      <h1 className="display" style={{ fontSize: 34, margin: "0 0 22px" }}>My Account</h1>

      {/* HERO + AVATAR */}
      <div style={{ ...card, display: "flex", alignItems: "center", gap: 20 }}>
        <button
          onClick={() => fileRef.current?.click()}
          title="Change photo"
          style={{
            width: 84, height: 84, borderRadius: "50%", overflow: "hidden",
            border: "2px solid var(--line-bright)", cursor: "pointer", padding: 0,
            background: "var(--crimson)", color: "#fff", flexShrink: 0,
            display: "flex", alignItems: "center", justifyContent: "center",
            fontSize: 28, fontWeight: 800,
          }}
        >
          {avatarUrl
            ? <img src={avatarUrl} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }}/>
            : initials}
        </button>
        <div>
          <div style={{ fontSize: 20, fontWeight: 800, color: "var(--ink)" }}>{name || "Admin"}</div>
          <div style={{ fontSize: 13, color: "var(--ink-dim)" }}>{email}</div>
          <div style={{ fontSize: 12, color: "var(--ink-dim)", marginTop: 4 }}>Administrator</div>
          <button onClick={() => fileRef.current?.click()} disabled={uploadingAvatar}
            style={{ marginTop: 8, fontSize: 12, fontWeight: 700, color: "var(--crimson)",
                     background: "none", border: 0, cursor: "pointer", padding: 0 }}>
            {uploadingAvatar ? "Uploading…" : "Change photo"}
          </button>
          {avatarMsg && <div style={msgStyle(avatarMsg)}>{avatarMsg.text}</div>}
        </div>
        <input ref={fileRef} type="file" accept="image/png,image/jpeg,image/webp"
               onChange={onAvatarPick} style={{ display: "none" }}/>
      </div>

      {/* ACCOUNT DETAILS */}
      <form style={card} onSubmit={saveProfile}>
        <h2 style={{ fontSize: 16, margin: "0 0 16px", color: "var(--ink)" }}>Account details</h2>
        <div style={{ marginBottom: 14 }}>
          <label style={label}>Name</label>
          <input style={input} value={name} onChange={e => setName(e.target.value)} required/>
        </div>
        <div style={{ marginBottom: 4 }}>
          <label style={label}>Email</label>
          <input style={input} type="email" value={email} onChange={e => setEmail(e.target.value)} required/>
        </div>
        {profileMsg && <div style={msgStyle(profileMsg)}>{profileMsg.text}</div>}
        <div style={{ marginTop: 18 }}>
          <button type="submit" style={btn(savingProfile)} disabled={savingProfile}>
            {savingProfile ? "Saving…" : "Save changes"}
          </button>
        </div>
      </form>

      {/* CHANGE PASSWORD */}
      <form style={card} onSubmit={savePassword}>
        <h2 style={{ fontSize: 16, margin: "0 0 16px", color: "var(--ink)" }}>Change password</h2>
        <div style={{ marginBottom: 14 }}>
          <label style={label}>Current password</label>
          <input style={input} type="password" value={curPwd} onChange={e => setCurPwd(e.target.value)} required/>
        </div>
        <div style={{ marginBottom: 14 }}>
          <label style={label}>New password</label>
          <input style={input} type="password" value={newPwd} onChange={e => setNewPwd(e.target.value)} required minLength={8}/>
        </div>
        <div style={{ marginBottom: 4 }}>
          <label style={label}>Confirm new password</label>
          <input style={input} type="password" value={confirmPwd} onChange={e => setConfirmPwd(e.target.value)} required minLength={8}/>
        </div>
        {pwdMsg && <div style={msgStyle(pwdMsg)}>{pwdMsg.text}</div>}
        <div style={{ marginTop: 18 }}>
          <button type="submit" style={btn(savingPwd)} disabled={savingPwd}>
            {savingPwd ? "Saving…" : "Update password"}
          </button>
        </div>
      </form>
    </div>
  );
}

Object.assign(window, { Profile });
```

- [ ] **Step 2: Wire it into the bundle**

In `bni_game_frontend/src/main.jsx`, add this import line immediately before `import './app.jsx';`:
```js
import './profile.jsx';
```

- [ ] **Step 3: Commit**

```bash
cd bni_game_frontend
git add src/profile.jsx src/main.jsx
git commit -m "feat: add Profile account page component"
```

---

### Task 7: Add "Profile" to the More dropdown

**Files:**
- Modify: `bni_game_frontend/src/shell.jsx`

- [ ] **Step 1: Add the Profile nav item**

In `bni_game_frontend/src/shell.jsx`, change the `NAV_MORE` array:
```js
  const NAV_MORE = [
    { id: "history",    label: "History" },
    { id: "h2h",        label: "Head-to-Head" },
    { id: "masters",    label: "Masters" },
  ];
```
to:
```js
  const NAV_MORE = [
    { id: "history",    label: "History" },
    { id: "h2h",        label: "Head-to-Head" },
    { id: "masters",    label: "Masters" },
    { id: "profile",    label: "Profile" },
  ];
```

(No other change needed — the dropdown already calls `onNav(n.id)`, and `ProtectedLayout.handleNav` navigates to `/profile`.)

- [ ] **Step 2: Commit**

```bash
cd bni_game_frontend
git add src/shell.jsx
git commit -m "feat: add Profile entry to More dropdown"
```

---

### Task 8: Register the /profile route

**Files:**
- Modify: `bni_game_frontend/src/app.jsx`

- [ ] **Step 1: Add the ProfilePage wrapper**

In `bni_game_frontend/src/app.jsx`, add this wrapper component right after the `RecapPage` function definition (near the other `*Page` wrappers around line 343):

```jsx
function ProfilePage() {
  const { refreshUser } = useOutletContext();
  const auth = useAuthUser();
  return <Profile user={auth} onUpdated={() => refreshUser?.()}/>;
}
```

Note: the Outlet context currently provides `{ activeWeek, setActiveWeek, scoresRev, bumpScores }` but NOT `refreshUser` or the user. Steps 2–3 add them.

- [ ] **Step 2: Expose user + refreshUser through the Outlet context**

In `bni_game_frontend/src/app.jsx`, find the `ProtectedLayout` signature and add `refreshUser`/user access. Change:
```jsx
function ProtectedLayout({ auth, scoresRev, bumpScores, tweaks, setTweak, confettiTrigger, setConfettiTrigger, setLogoutConfirm }) {
```
(no signature change needed — `auth` is already passed). Then change the Outlet line:
```jsx
        <Outlet context={{ activeWeek, setActiveWeek, scoresRev, bumpScores }}/>
```
to:
```jsx
        <Outlet context={{ activeWeek, setActiveWeek, scoresRev, bumpScores, refreshUser: auth.refreshUser, authUser: auth.authUser }}/>
```

- [ ] **Step 3: Simplify ProfilePage to read from context (replace Step 1's wrapper)**

Replace the `ProfilePage` wrapper from Step 1 with this version that uses the context directly (avoids a non-existent `useAuthUser` hook):

```jsx
function ProfilePage() {
  const { authUser, refreshUser } = useOutletContext();
  return <Profile user={authUser} onUpdated={() => refreshUser?.()}/>;
}
```

- [ ] **Step 4: Register the route**

In `bni_game_frontend/src/app.jsx`, in the `<Routes>` protected block, add this line after the `/masters` route:
```jsx
          <Route path="/profile" element={<ProfilePage/>}/>
```

- [ ] **Step 5: Commit**

```bash
cd bni_game_frontend
git add src/app.jsx
git commit -m "feat: register /profile route and expose user in outlet context"
```

---

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

**Files:** none (verification only)

- [ ] **Step 1: Start backend + frontend**

Run (backend): `cd bni_game_backend && php artisan serve`
Run (frontend, separate terminal): `cd bni_game_frontend && npm run dev`
Expected: both start without errors; open the printed local URL and log in.

- [ ] **Step 2: Verify navigation**

Click **More → Profile**. Expected: URL becomes `/profile` and the "My Account" page renders with the current name/email pre-filled.

- [ ] **Step 3: Verify account edit**

Change the name, click **Save changes**. Expected: "Profile updated." message; the top-nav avatar initials and hero update. Reload the page — the new name persists (came from `localStorage.authUser` / `/api/me`).

- [ ] **Step 4: Verify password change**

Enter a wrong current password → "Current password is incorrect." Then enter the correct current password + matching new passwords → "Password changed." Log out and back in with the new password to confirm.

- [ ] **Step 5: Verify avatar upload**

Click the avatar, pick a PNG/JPG. Expected: "Photo updated." and the image appears in the hero. Reload — image persists.

- [ ] **Step 6: Final commit (if any tweaks were needed)**

```bash
git add -A && git commit -m "chore: profile page verification tweaks"
```
(Skip if nothing changed.)

---

## Self-Review Notes

- **Spec coverage:** More-dropdown entry (Task 7), `/profile` route (Task 8), avatar column (Task 1), `ProfileController` 3 endpoints (Task 3), login+me avatar_url (Task 4), Profile component with 3 sections (Task 6), RvlApi helpers (Task 5). All spec sections covered.
- **Type consistency:** Endpoints return `{ user: {id,name,email,avatar_url} }` (or `{message}` for password); `RvlApi.updateProfile`/`uploadAvatar` return `r.user`; component reads `updated.avatar_url`/`updated.name`. Password endpoint returns no user — component does not read one. Consistent.
- **Auth in tests:** uses real `user_tokens` rows (no Sanctum) matching `AuthenticateBearer` middleware.
