# Apply Admin Theme Template Remotely — Dashboard Webhook Plan

> **Project:** Agency Pulse Laravel (`agency_pulse`)
> **Status:** IMPLEMENTED — Laravel side complete (Phase A + B). WP side (Phase C) pending.
> **Date:** 2026-06-24
> **Implemented:** 2026-06-24
> **Depends on:** `AAMO_TEMPLATE_LIBRARY_LARAVEL.md` (template library must exist
>   first — the `admin_organizer_templates` table and model are prerequisites)
> **Counterpart (WP side):** `AAMO_APPLY_TEMPLATE_REMOTELY_WP_PLAN.md`
>   in `Agency-Portal/src/wp-content/plugins/agency-pulse-admin-menu/build-docs/`

---

## Overview

Adds an **"Apply Admin Theme Template"** action to every connected-site row in the
`apwp-ao` section of the user's dashboard. An admin can pick any of their
saved templates from a dropdown and push it to the target WordPress site in
a single click — without touching the WP admin panel.

The browser talks only to Laravel. Laravel fetches the template payload from
its own database, signs it, and forwards it to the connected WordPress site
via an outgoing HMAC-signed webhook — the same pattern already used by the
**Set Message** feature (`DASHBOARD_WIDGET_WEBHOOK_LARAVEL_PLAN.md`).

---

## Architecture — Request Flow

```
Browser (user dashboard)
   │
   │ 1. User clicks "Apply Admin Theme Template" on a slot row
   │    → modal opens, select populated from embedded JSON
   │
   │ 2. POST /account/ao-slots/{slot}/apply-template
   │    body: { "slug": "agency_default" }
   │    (standard Breeze session + CSRF)
   ▼
AoApplyTemplateController
   │  a. Verify slot is owned by auth user + is_active + has domain_url
   │  b. Load AdminOrganizerTemplate by (user_id, slug)  ← DB query
   │  c. Compute pk = HMAC-SHA256(user.ao_plugin_key, PLUGIN_JWT_SECRET)
   │  d. signature = HMAC-SHA256(payload_json, pk)
   │
   │ 3. POST {domain_url}/wp-json/apwp-ao/v1/apply-template
   │    headers: X-APWP-Signature, X-APWP-Timestamp
   │    body: { "payload": <template payload object> }
   ▼
WordPress REST endpoint
   │  a. Verify HMAC signature (same pk derivation)
   │  b. Call import_from_json(payload)  ← existing WP function
   │  c. Return { "success": true }
   │
   ▼
Laravel returns JSON to browser
   │
   ▼
Modal shows "Template applied!" then closes
```

---

## Phase 1 — Laravel Controller and Route

### New controller

File: `app/Http/Controllers/AoApplyTemplateController.php`

```php
<?php

namespace App\Http\Controllers;

use App\Models\AdminOrganizerTemplate;
use App\Models\PluginAoDomain;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class AoApplyTemplateController extends Controller
{
    /**
     * POST /account/ao-slots/{slot}/apply-template
     *
     * Fetches a saved template by slug, then pushes its payload to the
     * connected WordPress site via a signed webhook.
     */
    public function update(Request $request, PluginAoDomain $slot): JsonResponse
    {
        if ($error = $this->checkSlotAccess($slot)) {
            return $error;
        }

        $validated = $request->validate([
            'slug' => 'required|string|max:191',
        ]);

        // Load the template — must belong to the authenticated user.
        $template = AdminOrganizerTemplate::where('user_id', auth()->id())
            ->where('slug', $validated['slug'])
            ->first();

        if (! $template) {
            return response()->json(['message' => 'Template not found.'], 404);
        }

        // Build the outgoing body.
        // payload is stored as a JSON string; send it decoded so the WP
        // endpoint receives a plain object (consistent with ao-backup show()).
        $payloadDecoded = json_decode($template->payload, true);
        $body           = json_encode(['payload' => $payloadDecoded]);

        $pk        = $this->computePk($slot->user);
        $timestamp = time();
        $signature = hash_hmac('sha256', $body, $pk);

        $resolvedUrl = $this->resolveWpUrl($slot->domain_url);
        $fullUrl     = $resolvedUrl . '/wp-json/apwp-ao/v1/apply-template';

        Log::info('[ao-apply-template] request context', [
            'slot_id'           => $slot->id,
            'template_slug'     => $template->slug,
            'domain_url_stored' => $slot->domain_url,
            'resolved_url'      => $resolvedUrl,
            'full_url'          => $fullUrl,
            'body_length'       => strlen($body),
            'pk_prefix'         => substr($pk, 0, 8) . '…',
            'signature_prefix'  => substr($signature, 0, 12) . '…',
        ]);

        try {
            $response = Http::timeout(15)
                ->withHeaders([
                    'Content-Type'     => 'application/json',
                    'X-APWP-Signature' => 'sha256=' . $signature,
                    'X-APWP-Timestamp' => (string) $timestamp,
                ])
                ->withBody($body, 'application/json')
                ->post($fullUrl);

            Log::info('[ao-apply-template] WP response', [
                'slot_id'     => $slot->id,
                'http_status' => $response->status(),
                'body'        => $response->body(),
            ]);

        } catch (\Throwable $e) {
            Log::error('[ao-apply-template] network exception', [
                'slot_id'         => $slot->id,
                'exception_class' => get_class($e),
                'message'         => $e->getMessage(),
                'full_url'        => $fullUrl,
            ]);
            return response()->json(['message' => 'Could not reach WordPress site.'], 502);
        }

        if (! $response->successful()) {
            $wpBody = $response->json();
            Log::warning('[ao-apply-template] WP returned non-2xx', [
                'slot_id'     => $slot->id,
                'http_status' => $response->status(),
                'wp_message'  => $wpBody['message'] ?? null,
            ]);
            return response()->json([
                'message' => $wpBody['message'] ?? ('WordPress returned status ' . $response->status()),
            ], $response->status() === 403 ? 403 : 502);
        }

        return response()->json(['message' => 'Template applied successfully.']);
    }

    // ── helpers (identical to AoDashboardMessageController) ──────────────

    private function checkSlotAccess(PluginAoDomain $slot): ?JsonResponse
    {
        if ($slot->user_id !== auth()->id()) {
            return response()->json(['message' => 'Forbidden.'], 403);
        }
        if (! $slot->is_active) {
            return response()->json(['message' => 'Slot is not active.'], 422);
        }
        if (empty($slot->domain_url)) {
            Log::warning('[ao-apply-template] domain_url is empty', ['slot_id' => $slot->id]);
            return response()->json(['message' => 'Site URL not available. Reconnect the plugin.'], 422);
        }
        return null;
    }

    private function computePk($user): string
    {
        return hash_hmac('sha256', $user->ao_plugin_key ?? '', env('PLUGIN_JWT_SECRET', config('app.key')));
    }

    private function resolveWpUrl(string $domainUrl): string
    {
        $internalHost = env('APWP_WP_INTERNAL_HOST');
        if (empty($internalHost)) {
            return rtrim($domainUrl, '/');
        }
        $parsed = parse_url($domainUrl);
        $path   = rtrim($parsed['path'] ?? '', '/');
        return rtrim($internalHost, '/') . $path;
    }
}
```

### New route

In `routes/web.php`, inside the authenticated (`auth`) middleware group,
alongside the two existing `ao-slots` routes:

```php
Route::post('/account/ao-slots/{slot}/apply-template',
    [\App\Http\Controllers\AoApplyTemplateController::class, 'update'])
    ->name('account.ao_slots.apply_template');
```

Add the import at the top of `routes/web.php`:

```php
use App\Http\Controllers\AoApplyTemplateController;
```

---

## Phase 2 — Dashboard Blade Changes

### 2a — Load templates in the `@php` block

In `resources/views/dashboard.blade.php`, in the existing `@php` block that
computes `$aoActiveSlots` (around line 185), add after the `$aoActiveSlots`
assignment:

```php
// Templates available to apply remotely — only loaded when user has access.
$aoTemplates = ($aoHasActiveAccess && $aoUser)
    ? \App\Models\AdminOrganizerTemplate::where('user_id', $aoUser->id)
        ->orderBy('name')
        ->get(['id', 'name', 'slug', 'description'])
    : collect();
```

### 2b — "Apply Admin Theme Template" button in each slot row

Inside the `@foreach($aoActiveSlots as $aoSlot)` loop (around line 233),
add a new button **between** the "Set Message" button and the "Release" button.
Only render it when the slot has a URL and the user has at least one saved
template:

```blade
@if($aoSlot->domain_url && $aoTemplates->isNotEmpty())
    <button
        type="button"
        class="js-ao-apply-template text-xs text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300 hover:underline mr-3"
        data-slot-id="{{ $aoSlot->id }}"
        data-slot-label="{{ $aoSlot->domain_label ?? 'Site #' . ($loop->index + 1) }}"
    >Apply Admin Theme Template</button>
@endif
```

Full updated `<li>` structure after the change:

```blade
<li class="flex items-center justify-between text-sm bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-600 rounded px-3 py-2">
    <span class="text-gray-800 dark:text-gray-200 font-medium">
        {{ $aoSlot->domain_label ?? 'Site #' . ($loop->index + 1) }}
    </span>
    <span class="text-xs text-gray-500 dark:text-gray-400 mx-4">
        Last connected: {{ $aoSlot->last_connected_at ? $aoSlot->last_connected_at->diffForHumans() : 'Never' }}
    </span>
    @if($aoSlot->domain_url)
        <button type="button"
            class="js-ao-set-message text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 hover:underline mr-3"
            data-slot-id="{{ $aoSlot->id }}"
            data-slot-label="{{ $aoSlot->domain_label ?? 'Site #' . ($loop->index + 1) }}"
        >Set Message</button>
    @endif
    @if($aoSlot->domain_url && $aoTemplates->isNotEmpty())
        <button type="button"
            class="js-ao-apply-template text-xs text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300 hover:underline mr-3"
            data-slot-id="{{ $aoSlot->id }}"
            data-slot-label="{{ $aoSlot->domain_label ?? 'Site #' . ($loop->index + 1) }}"
        >Apply Admin Theme Template</button>
    @endif
    <button type="button"
        class="js-release-ao-slot text-xs text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 hover:underline"
        data-slot-id="{{ $aoSlot->id }}"
    >Release</button>
</li>
```

### 2c — Apply Admin Theme Template modal HTML

Add after the existing `#ao-dm-modal` block (around line 430):

```blade
{{-- Apply Admin Theme Template modal (shared, one per page) --}}
@if($aoHasActiveAccess && $aoTemplates->isNotEmpty())
<div id="ao-apply-template-modal"
     class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
     style="display:none!important">
    <div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
        <h3 id="ao-apply-template-title" class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
            Apply Admin Theme Template
        </h3>
        <div class="mb-4">
            <label for="ao-apply-template-select"
                class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
                Select template
            </label>
            <select id="ao-apply-template-select"
                class="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100">
                {{-- Options are populated by JS from $aoTemplates --}}
            </select>
            <p id="ao-apply-template-desc"
               class="text-xs text-gray-500 dark:text-gray-400 mt-1 min-h-4 italic"></p>
        </div>
        <p id="ao-apply-template-status"
           class="text-xs text-gray-500 dark:text-gray-400 mb-4 min-h-4"
           aria-live="polite"></p>
        <div class="flex justify-end gap-3">
            <button type="button" id="ao-apply-template-cancel"
                class="px-4 py-2 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
                Cancel
            </button>
            <button type="button" id="ao-apply-template-confirm"
                class="px-4 py-2 text-sm bg-indigo-600 text-white rounded hover:bg-indigo-700 disabled:opacity-50">
                Apply Admin Theme Template
            </button>
        </div>
    </div>
</div>
@endif
```

### 2d — JavaScript block

Add a new `<script>` block, conditioned on the user having active access,
at least one slot with a URL, and at least one saved template.
Place it after the existing `@if($aoHasActiveAccess && $aoActiveSlots->...)`
release-slot script block:

```blade
@if($aoHasActiveAccess && $aoActiveSlots->where('domain_url', '!=', null)->isNotEmpty() && $aoTemplates->isNotEmpty())
<script>
(function () {
    var csrfToken   = '{{ csrf_token() }}';
    var modal       = document.getElementById('ao-apply-template-modal');
    var titleEl     = document.getElementById('ao-apply-template-title');
    var selectEl    = document.getElementById('ao-apply-template-select');
    var descEl      = document.getElementById('ao-apply-template-desc');
    var statusEl    = document.getElementById('ao-apply-template-status');
    var confirmBtn  = document.getElementById('ao-apply-template-confirm');
    var cancelBtn   = document.getElementById('ao-apply-template-cancel');
    var activeSlotId = null;

    // Templates embedded server-side — no extra request needed.
    var templates = @json($aoTemplates);

    // Populate the select once (options are static for the page lifetime).
    templates.forEach(function (t) {
        var opt = document.createElement('option');
        opt.value       = t.slug;
        opt.textContent = t.name;
        opt.dataset.desc = t.description || '';
        selectEl.appendChild(opt);
    });

    function updateDesc() {
        var selected = selectEl.options[selectEl.selectedIndex];
        descEl.textContent = selected ? (selected.dataset.desc || '') : '';
    }

    selectEl.addEventListener('change', updateDesc);
    updateDesc();

    function showModal(label) {
        titleEl.textContent = 'Apply Admin Theme Template — ' + label;
        statusEl.textContent = '';
        statusEl.className   = 'text-xs mb-4 min-h-4 text-gray-500 dark:text-gray-400';
        confirmBtn.disabled  = false;
        confirmBtn.textContent = 'Apply Admin Theme Template';
        modal.style.removeProperty('display');
    }

    function hideModal() {
        modal.style.display = 'none';
        activeSlotId = null;
        statusEl.textContent = '';
    }

    function setStatus(msg, isError) {
        statusEl.textContent = msg;
        statusEl.className   = 'text-xs mb-4 min-h-4 ' +
            (isError ? 'text-red-600 dark:text-red-400' : 'text-gray-500 dark:text-gray-400');
    }

    document.querySelectorAll('.js-ao-apply-template').forEach(function (btn) {
        btn.addEventListener('click', function () {
            activeSlotId = btn.dataset.slotId;
            var label    = btn.dataset.slotLabel || 'Site';
            showModal(label);
        });
    });

    confirmBtn.addEventListener('click', function () {
        if (!activeSlotId) return;
        var slug = selectEl.value;
        if (!slug) { setStatus('Please select a template.', true); return; }

        confirmBtn.disabled    = true;
        confirmBtn.textContent = 'Applying…';
        setStatus('', false);

        fetch('/account/ao-slots/' + activeSlotId + '/apply-template', {
            method:  'POST',
            headers: {
                'Content-Type':  'application/json',
                'Accept':        'application/json',
                'X-CSRF-TOKEN':  csrfToken,
            },
            body: JSON.stringify({ slug: slug }),
        })
        .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, data: d }; }); })
        .then(function (result) {
            confirmBtn.textContent = 'Apply Admin Theme Template';
            confirmBtn.disabled    = false;
            if (result.ok) {
                setStatus(result.data.message || 'Template applied!', false);
                setTimeout(hideModal, 1800);
            } else {
                setStatus(result.data.message || 'Apply failed.', true);
            }
        })
        .catch(function () {
            confirmBtn.textContent = 'Apply Admin Theme Template';
            confirmBtn.disabled    = false;
            setStatus('Could not reach server.', true);
        });
    });

    cancelBtn.addEventListener('click', hideModal);

    modal.addEventListener('click', function (e) {
        if (e.target === modal) hideModal();
    });
}());
</script>
@endif
```

---

## Security Notes

- **No credentials in the browser.** The signing key (`pk`) never leaves
  Laravel. The browser only sends a template slug, not a payload.
- **Template ownership enforced on Laravel.** The controller looks up the
  template with `where('user_id', auth()->id())`, so a user can never push
  another user's template.
- **Slot ownership enforced on Laravel.** `checkSlotAccess()` verifies
  `$slot->user_id === auth()->id()` before making any outgoing request.
- **Replay protection on WP.** The WP endpoint rejects requests where
  `X-APWP-Timestamp` is more than 5 minutes old. See
  `AAMO_APPLY_TEMPLATE_REMOTELY_WP_PLAN.md` for the full verification spec.
- **CSRF.** The `POST /account/ao-slots/{slot}/apply-template` route is
  inside the standard Breeze auth + CSRF middleware group.

---

## Files Changed

| File | Change |
|---|---|
| `app/Http/Controllers/AoApplyTemplateController.php` | **New** — controller |
| `routes/web.php` | Modified — one POST route added (no `use` import needed — route uses inline class reference) |
| `resources/views/dashboard.blade.php` | Modified — `@php` block, slot row button, modal HTML, JS block |

---

## Implementation Notes

### Deviations from the plan

**`routes/web.php` — no top-level `use` import added.**
The plan called for adding `use App\Http\Controllers\AoApplyTemplateController;`
at the top of the file. The route was instead registered using the same inline
fully-qualified class reference style already used by the adjacent `ao-slots`
routes in that file:
```php
Route::post('/account/ao-slots/{slot}/apply-template',
    [\App\Http\Controllers\AoApplyTemplateController::class, 'update'])
    ->name('account.ao_slots.apply_template');
```
This is consistent with the rest of the file and avoids a standalone import
for a single use.

**Blade `@if` guards include `isset($aoTemplates)`.**
The modal HTML block, and the JS `@if` condition, both add an `isset($aoTemplates)`
check in addition to the `$aoTemplates->isNotEmpty()` check. This is a defensive
measure in case the view is rendered in a context where the `@php` block above
has not executed (e.g. a future partial extraction). The plan did not explicitly
include this guard but it is harmless.

**No deviations to controller logic.** `AoApplyTemplateController` was
implemented exactly as designed: slug validation → user-scoped template lookup →
`json_decode` payload → sign body → `Http::post()` with `X-APWP-Signature` and
`X-APWP-Timestamp` headers → relay WP response back to browser.

---

## Implementation Phases

### Phase A — Laravel controller + route

1. Create `AoApplyTemplateController.php` from the code in Phase 1.
2. Add the route and `use` import to `routes/web.php`.

**Acceptance:**
- `POST /account/ao-slots/{slot}/apply-template` with a valid session
  and an existing template slug returns a JSON response (200 from WP or
  appropriate error).
- Request without a session returns 401/redirect.
- Request with a slug that doesn't belong to the user returns 404.
- Request for a slot owned by a different user returns 403.

---

### Phase B — Dashboard blade changes

1. Add `$aoTemplates` to the `@php` block.
2. Add the "Apply Admin Theme Template" button to each slot row (inside the loop).
3. Add the `#ao-apply-template-modal` HTML block after `#ao-dm-modal`.
4. Add the JS block after the release-slot script.

**Acceptance:**
- When the user has templates and connected sites, an "Apply Admin Theme Template"
  button appears on each site row that has a `domain_url`.
- Clicking the button opens the modal with the correct site name in the title.
- The `<select>` is pre-populated with all user templates; selecting one
  shows its description below the select.
- Clicking "Apply Admin Theme Template" sends the POST, shows "Applying…", and on
  success shows "Template applied!" then closes the modal.
- Clicking Cancel or the backdrop closes the modal.
- When the user has **no** saved templates the button does not render and
  the modal markup is not emitted.


