Add per-store managers for Woo Business accounts.
Deploy Ladill Woo Manager / deploy (push) Successful in 39s

Agencies can invite store-scoped managers via Identity, with access limited to one WooCommerce store while owners retain full multi-store control.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-08 07:51:08 +00:00
co-authored by Cursor
parent 1b0831b176
commit 09359ca86a
26 changed files with 921 additions and 28 deletions
@@ -131,6 +131,8 @@ class SsoLoginController extends Controller
$request->session()->regenerate();
$request->session()->forget('sso.attempts');
app(\App\Services\Woo\TeamMemberProvisioner::class)->sync($user);
return $this->finishCallback($request, $intended, null, $popup);
}
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Woo\Concerns;
use App\Models\User;
use App\Models\WooStore;
use App\Models\WooStoreMember;
use App\Support\CurrentWooStore;
use Illuminate\Http\Request;
@@ -14,9 +15,33 @@ trait ResolvesWooContext
return ladill_account() ?? $request->user();
}
protected function actor(Request $request): User
{
return $request->user();
}
protected function wooMember(Request $request): ?WooStoreMember
{
return $request->attributes->get('woo.member');
}
/** @return list<int>|null */
protected function storeScope(Request $request): ?array
{
return $request->attributes->get('woo.storeIds');
}
protected function isStoreOwner(Request $request): bool
{
return $this->wooMember($request) === null;
}
protected function currentStore(Request $request): ?WooStore
{
return app(CurrentWooStore::class)->resolve($this->accountUser($request));
return app(CurrentWooStore::class)->resolve(
$this->accountUser($request),
$this->storeScope($request),
);
}
protected function currentStoreOrFail(Request $request): WooStore
@@ -29,10 +54,18 @@ trait ResolvesWooContext
protected function authorizedStore(Request $request, int $storeId): WooStore
{
return WooStore::query()
$store = WooStore::query()
->where('user_id', $this->accountUser($request)->id)
->whereKey($storeId)
->where('status', WooStore::STATUS_ACTIVE)
->firstOrFail();
abort_unless(
app(\App\Services\Woo\WooStoreMemberResolver::class)
->canAccessStore($this->actor($request), (string) $this->accountUser($request)->public_id, $store->id),
404,
);
return $store;
}
}
@@ -4,7 +4,6 @@ namespace App\Http\Controllers\Woo;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Woo\Concerns\ResolvesWooContext;
use App\Http\Controllers\Woo\Concerns\ResolvesWooContext;
use App\Models\WooStore;
use App\Services\Woo\InstallTokenService;
use App\Services\Woo\SubscriptionService;
@@ -24,6 +23,8 @@ class ConnectWordPressController extends Controller
public function start(Request $request): RedirectResponse|View
{
abort_if($this->wooMember($request), 403);
$validated = $request->validate([
'site_url' => ['required', 'url', 'max:500'],
'return_url' => ['required', 'url', 'max:2048'],
@@ -53,12 +54,14 @@ class ConnectWordPressController extends Controller
public function complete(Request $request): RedirectResponse|View
{
abort_if($this->wooMember($request), 403);
$pending = (array) $request->session()->pull('woo.connect', []);
if ($pending === []) {
return redirect()->route('woo.dashboard');
}
$user = $request->user();
$user = $this->accountUser($request);
abort_if(! $user, 401);
$siteUrl = (string) ($pending['site_url'] ?? '');
@@ -21,6 +21,7 @@ class ProController extends Controller
public function index(Request $request): View
{
$this->guardBillingOwner($request);
$user = $this->accountUser($request);
$planKey = $this->subscriptions->planKey($user);
@@ -44,6 +45,7 @@ class ProController extends Controller
public function subscribe(Request $request): RedirectResponse
{
$this->guardBillingOwner($request);
[$ok, $message] = $this->subscriptions->subscribe($this->accountUser($request));
return redirect()->route('woo.pro.index')->with($ok ? 'success' : 'error', $message);
@@ -51,6 +53,7 @@ class ProController extends Controller
public function subscribeEnterprise(Request $request): RedirectResponse
{
$this->guardBillingOwner($request);
[$ok, $message] = $this->subscriptions->subscribeEnterprise($this->accountUser($request));
return redirect()->route('woo.pro.index')->with($ok ? 'success' : 'error', $message);
@@ -58,6 +61,7 @@ class ProController extends Controller
public function subscribePrepaid(Request $request, BillingClient $billing): RedirectResponse
{
$this->guardBillingOwner($request);
$validated = $request->validate([
'plan' => ['required', 'in:pro,enterprise'],
'months' => ['required', 'integer', 'in:6,12,24'],
@@ -137,8 +141,14 @@ class ProController extends Controller
public function cancel(Request $request): RedirectResponse
{
$this->guardBillingOwner($request);
[$ok, $message] = $this->subscriptions->cancel($this->accountUser($request));
return redirect()->route('woo.pro.index')->with($ok ? 'success' : 'error', $message);
}
private function guardBillingOwner(Request $request): void
{
abort_if($this->wooMember($request), 403);
}
}
@@ -3,12 +3,40 @@
namespace App\Http\Controllers\Woo;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Woo\Concerns\ResolvesWooContext;
use App\Models\WooStore;
use App\Models\WooStoreMember;
use App\Services\Woo\SubscriptionService;
use Illuminate\View\View;
class SettingsController extends Controller
{
use ResolvesWooContext;
public function __construct(private SubscriptionService $subscriptions) {}
public function edit(): View
{
return view('woo.settings');
$account = ladill_account() ?? auth()->user();
$ownerRef = (string) $account?->public_id;
$storeScope = request()->attributes->get('woo.storeIds');
$stores = $account
? WooStore::query()
->where('user_id', $account->id)
->where('status', WooStore::STATUS_ACTIVE)
->when($storeScope !== null, fn ($q) => $q->whereIn('id', $storeScope))
->orderBy('site_name')
->get()
: collect();
return view('woo.settings', [
'teamStores' => $stores,
'members' => $ownerRef !== ''
? WooStoreMember::owned($ownerRef)->with('store')->orderBy('created_at')->get()
: collect(),
'roles' => config('woo.roles', []),
'hasStoreTeamFeatures' => $account ? $this->subscriptions->canManageStoreTeam($account) : false,
]);
}
}
+7 -2
View File
@@ -23,23 +23,28 @@ class StoreController extends Controller
public function index(Request $request): View
{
$user = $this->accountUser($request);
$storeScope = $this->storeScope($request);
$stores = WooStore::query()
->where('user_id', $user->id)
->when($storeScope !== null, fn ($q) => $q->whereIn('id', $storeScope))
->latest('updated_at')
->get();
return view('woo.stores.index', [
'stores' => $stores,
'currentStore' => $this->currentStore->resolve($user),
'currentStore' => $this->currentStore->resolve($user, $storeScope),
'hasPaidPlan' => $this->subscriptions->hasPaidPlan($user),
'storeCount' => $this->subscriptions->connectedStoreCount($user),
'storeLimit' => $this->subscriptions->maxStores($user),
'canConnectMore' => $this->subscriptions->canConnectStore($user),
'canConnectMore' => $this->isStoreOwner($request) && $this->subscriptions->canConnectStore($user),
'isStoreOwner' => $this->isStoreOwner($request),
]);
}
public function destroy(Request $request, WooStore $store): RedirectResponse
{
abort_if(! $this->isStoreOwner($request), 403);
abort_if($store->user_id !== $this->accountUser($request)->id, 403);
$store->update(['status' => WooStore::STATUS_DISCONNECTED]);
@@ -0,0 +1,119 @@
<?php
namespace App\Http\Controllers\Woo;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Woo\Concerns\ResolvesWooContext;
use App\Models\WooStore;
use App\Models\WooStoreMember;
use App\Services\Identity\IdentityTeamClient;
use App\Services\Woo\SubscriptionService;
use App\Services\Woo\WooStoreMemberResolver;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class StoreMemberController extends Controller
{
use ResolvesWooContext;
public function __construct(
private SubscriptionService $subscriptions,
private WooStoreMemberResolver $members,
) {}
public function store(Request $request, IdentityTeamClient $identity): RedirectResponse
{
if ($redirect = $this->guardManage($request)) {
return $redirect;
}
$owner = (string) $this->accountUser($request)->public_id;
$validated = $request->validate([
'email' => ['required', 'email', 'max:255'],
'woo_store_id' => [
'required',
'integer',
Rule::exists('woo_stores', 'id')->where(fn ($q) => $q
->where('user_id', $this->accountUser($request)->id)
->where('status', WooStore::STATUS_ACTIVE)),
],
]);
$email = strtolower(trim($validated['email']));
$actor = $this->actor($request);
if ($email === strtolower((string) $actor->email)) {
return back()->withErrors(['email' => 'You cannot invite yourself.']);
}
try {
$identity->inviteAppMember(
$owner,
$email,
['woo'],
[
'woo' => [
'role' => WooStoreMember::ROLE_MANAGER,
'store_id' => (int) $validated['woo_store_id'],
],
],
);
} catch (\Throwable $e) {
return back()->withInput()->with('error', $e->getMessage());
}
WooStoreMember::updateOrCreate(
[
'owner_ref' => $owner,
'user_ref' => $email,
'woo_store_id' => (int) $validated['woo_store_id'],
],
[
'role' => WooStoreMember::ROLE_MANAGER,
],
);
return redirect()
->route('woo.settings')
->withFragment('store-team')
->with('success', 'Invitation sent to '.$email.'.');
}
public function destroy(Request $request, WooStoreMember $member): RedirectResponse
{
if ($redirect = $this->guardManage($request)) {
return $redirect;
}
abort_unless($member->owner_ref === (string) $this->accountUser($request)->public_id, 404);
$actor = $this->actor($request);
if ($member->user_ref === $actor->public_id || $member->user_ref === strtolower((string) $actor->email)) {
return redirect()->route('woo.settings')->withFragment('store-team')->with('error', 'You cannot remove yourself.');
}
$member->delete();
return redirect()
->route('woo.settings')
->withFragment('store-team')
->with('success', 'Store manager removed.');
}
private function guardManage(Request $request): ?RedirectResponse
{
if (! $this->members->canManageStoreTeam($this->wooMember($request), (string) $this->accountUser($request)->public_id, $this->actor($request))) {
abort(403);
}
$account = $this->accountUser($request);
if (! $this->subscriptions->canManageStoreTeam($account)) {
return redirect()->route('woo.pro.index')
->with('upsell', 'Per-store managers require Ladill Woo Manager Business.');
}
return null;
}
}
@@ -18,7 +18,11 @@ class StoreSwitchController extends Controller
'store' => ['required', 'integer'],
]);
$stores->switch($this->accountUser($request), (int) $validated['store']);
$stores->switch(
$this->accountUser($request),
(int) $validated['store'],
$this->storeScope($request),
);
return redirect()->back();
}
+43 -1
View File
@@ -2,8 +2,11 @@
namespace App\Http\Middleware;
use App\Models\User;
use App\Models\WooStore;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\Response;
class SetActingAccount
@@ -11,9 +14,48 @@ class SetActingAccount
public function handle(Request $request, Closure $next): Response
{
if ($user = $request->user()) {
$request->attributes->set('actingAccount', $user);
$accountId = (int) $request->session()->get('ladill_account', $user->id);
if (! $user->canAccessAccount($accountId)) {
$accountId = $user->id;
$request->session()->put('ladill_account', $accountId);
}
$accountId = $this->preferEmployerAccount($request, $user, $accountId);
$account = $accountId === $user->id ? $user : (User::find($accountId) ?? $user);
$request->attributes->set('actingAccount', $account);
View::share('actingAccount', $account);
View::share('accessibleAccounts', $user->accessibleAccounts());
}
return $next($request);
}
private function preferEmployerAccount(Request $request, User $user, int $accountId): int
{
if ($accountId !== $user->id) {
return $accountId;
}
if (WooStore::query()->where('user_id', $user->id)->where('status', WooStore::STATUS_ACTIVE)->exists()) {
return $accountId;
}
$membership = $user->wooMemberships()->first();
if (! $membership) {
return $accountId;
}
$employer = User::where('public_id', $membership->owner_ref)->first();
if ($employer && $user->canAccessAccount($employer->id)) {
$request->session()->put('ladill_account', $employer->id);
return $employer->id;
}
return $accountId;
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Http\Middleware;
use App\Models\User;
use App\Services\Woo\SubscriptionService;
use App\Services\Woo\WooStoreMemberResolver;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\Response;
class SetWooStoreMemberContext
{
public function __construct(
private WooStoreMemberResolver $members,
private SubscriptionService $subscriptions,
) {}
public function handle(Request $request, Closure $next): Response
{
if (! $user = $request->user()) {
return $next($request);
}
$account = ladill_account() ?? $user;
$ownerRef = (string) $account->public_id;
$member = $this->members->memberFor($user, $ownerRef);
$storeIds = $this->members->accessibleStoreIds($user, $ownerRef);
$request->attributes->set('woo.member', $member);
$request->attributes->set('woo.storeIds', $storeIds);
$canManageTeam = $this->members->canManageStoreTeam($member, $ownerRef, $user)
&& $this->subscriptions->canManageStoreTeam($account);
View::share('wooMember', $member);
View::share('wooStoreScope', $storeIds);
View::share('canManageStoreTeam', $canManageTeam);
View::share('isStoreOwner', $member === null);
return $next($request);
}
}