From 09359ca86ab7ebe00a56aaa9737a46ccef6d86e9 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Wed, 8 Jul 2026 07:51:08 +0000 Subject: [PATCH] Add per-store managers for Woo Business accounts. 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 --- .../Controllers/Auth/SsoLoginController.php | 2 + .../Woo/Concerns/ResolvesWooContext.php | 37 +++- .../Woo/ConnectWordPressController.php | 7 +- app/Http/Controllers/Woo/ProController.php | 10 + .../Controllers/Woo/SettingsController.php | 30 ++- app/Http/Controllers/Woo/StoreController.php | 9 +- .../Controllers/Woo/StoreMemberController.php | 119 ++++++++++ .../Controllers/Woo/StoreSwitchController.php | 6 +- app/Http/Middleware/SetActingAccount.php | 44 +++- .../Middleware/SetWooStoreMemberContext.php | 44 ++++ app/Models/User.php | 29 +++ app/Models/WooStoreMember.php | 34 +++ app/Providers/AppServiceProvider.php | 7 +- app/Services/Identity/IdentityTeamClient.php | 64 ++++++ app/Services/Woo/SubscriptionService.php | 5 + app/Services/Woo/TeamMemberProvisioner.php | 56 +++++ app/Services/Woo/WooStoreMemberResolver.php | 69 ++++++ app/Support/CurrentWooStore.php | 45 ++-- bootstrap/app.php | 1 + config/woo.php | 4 + ..._150000_create_woo_store_members_table.php | 27 +++ resources/views/woo/settings.blade.php | 2 + .../settings/_store-team-section.blade.php | 79 +++++++ resources/views/woo/stores/index.blade.php | 8 +- routes/web.php | 3 + tests/Feature/WooStoreTeamTest.php | 208 ++++++++++++++++++ 26 files changed, 921 insertions(+), 28 deletions(-) create mode 100644 app/Http/Controllers/Woo/StoreMemberController.php create mode 100644 app/Http/Middleware/SetWooStoreMemberContext.php create mode 100644 app/Models/WooStoreMember.php create mode 100644 app/Services/Identity/IdentityTeamClient.php create mode 100644 app/Services/Woo/TeamMemberProvisioner.php create mode 100644 app/Services/Woo/WooStoreMemberResolver.php create mode 100644 database/migrations/2026_07_08_150000_create_woo_store_members_table.php create mode 100644 resources/views/woo/settings/_store-team-section.blade.php create mode 100644 tests/Feature/WooStoreTeamTest.php diff --git a/app/Http/Controllers/Auth/SsoLoginController.php b/app/Http/Controllers/Auth/SsoLoginController.php index 3a95133..13a3d8b 100644 --- a/app/Http/Controllers/Auth/SsoLoginController.php +++ b/app/Http/Controllers/Auth/SsoLoginController.php @@ -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); } diff --git a/app/Http/Controllers/Woo/Concerns/ResolvesWooContext.php b/app/Http/Controllers/Woo/Concerns/ResolvesWooContext.php index 86ca774..b821afe 100644 --- a/app/Http/Controllers/Woo/Concerns/ResolvesWooContext.php +++ b/app/Http/Controllers/Woo/Concerns/ResolvesWooContext.php @@ -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|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; } } diff --git a/app/Http/Controllers/Woo/ConnectWordPressController.php b/app/Http/Controllers/Woo/ConnectWordPressController.php index 5162901..8723b68 100644 --- a/app/Http/Controllers/Woo/ConnectWordPressController.php +++ b/app/Http/Controllers/Woo/ConnectWordPressController.php @@ -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'] ?? ''); diff --git a/app/Http/Controllers/Woo/ProController.php b/app/Http/Controllers/Woo/ProController.php index 8a984ae..63743b4 100644 --- a/app/Http/Controllers/Woo/ProController.php +++ b/app/Http/Controllers/Woo/ProController.php @@ -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); + } } diff --git a/app/Http/Controllers/Woo/SettingsController.php b/app/Http/Controllers/Woo/SettingsController.php index 4703fcd..9baea2c 100644 --- a/app/Http/Controllers/Woo/SettingsController.php +++ b/app/Http/Controllers/Woo/SettingsController.php @@ -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, + ]); } } diff --git a/app/Http/Controllers/Woo/StoreController.php b/app/Http/Controllers/Woo/StoreController.php index 1b04c42..9acdc66 100644 --- a/app/Http/Controllers/Woo/StoreController.php +++ b/app/Http/Controllers/Woo/StoreController.php @@ -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]); diff --git a/app/Http/Controllers/Woo/StoreMemberController.php b/app/Http/Controllers/Woo/StoreMemberController.php new file mode 100644 index 0000000..219d8aa --- /dev/null +++ b/app/Http/Controllers/Woo/StoreMemberController.php @@ -0,0 +1,119 @@ +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; + } +} diff --git a/app/Http/Controllers/Woo/StoreSwitchController.php b/app/Http/Controllers/Woo/StoreSwitchController.php index df699e4..fd1ce4d 100644 --- a/app/Http/Controllers/Woo/StoreSwitchController.php +++ b/app/Http/Controllers/Woo/StoreSwitchController.php @@ -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(); } diff --git a/app/Http/Middleware/SetActingAccount.php b/app/Http/Middleware/SetActingAccount.php index a402359..09dd5a5 100644 --- a/app/Http/Middleware/SetActingAccount.php +++ b/app/Http/Middleware/SetActingAccount.php @@ -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; + } } diff --git a/app/Http/Middleware/SetWooStoreMemberContext.php b/app/Http/Middleware/SetWooStoreMemberContext.php new file mode 100644 index 0000000..e936d21 --- /dev/null +++ b/app/Http/Middleware/SetWooStoreMemberContext.php @@ -0,0 +1,44 @@ +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); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 813524a..84ff90e 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Illuminate\Support\Collection; use Laravel\Sanctum\HasApiTokens; /** Thin local mirror of the platform identity (auth.ladill.com owns users). */ @@ -33,6 +34,34 @@ class User extends Authenticatable return $this->hasMany(WooStore::class); } + public function wooMemberships(): HasMany + { + return $this->hasMany(WooStoreMember::class, 'user_ref', 'public_id'); + } + + public function canAccessAccount(int $accountId): bool + { + if ($accountId === $this->id) { + return true; + } + + $account = self::find($accountId); + + return $account !== null + && $this->wooMemberships()->where('owner_ref', $account->public_id)->exists(); + } + + /** @return Collection */ + public function accessibleAccounts(): Collection + { + $ownerRefs = $this->wooMemberships()->pluck('owner_ref')->all(); + + return collect([$this]) + ->merge(self::whereIn('public_id', $ownerRefs)->get()) + ->unique('id') + ->values(); + } + public function avatarUrl(): ?string { $url = trim((string) $this->avatar_url); diff --git a/app/Models/WooStoreMember.php b/app/Models/WooStoreMember.php new file mode 100644 index 0000000..243f6d7 --- /dev/null +++ b/app/Models/WooStoreMember.php @@ -0,0 +1,34 @@ +belongsTo(WooStore::class, 'woo_store_id'); + } + + public function hasRole(string ...$roles): bool + { + return in_array($this->role, $roles, true); + } + + public function scopeOwned(Builder $query, string $ownerRef): Builder + { + return $query->where('owner_ref', $ownerRef); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 5c30484..77b9475 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -17,16 +17,17 @@ class AppServiceProvider extends ServiceProvider public function boot(): void { - View::composer(['partials.sidebar', 'partials.topbar', 'partials.mobile-bottom-nav', 'partials.store-switcher-menu'], function ($view) { + View::composer(['partials.sidebar', 'partials.topbar', 'partials.mobile-bottom-nav', 'partials.store-switcher-menu', 'partials.store-switcher'], function ($view) { $account = ladill_account() ?? auth()->user(); $svc = app(SubscriptionService::class); $stores = app(CurrentWooStore::class); + $storeIds = request()->attributes->get('woo.storeIds'); $view->with('isPro', $account ? $svc->isPro($account) : false); $view->with('hasPaidPlan', $account ? $svc->hasPaidPlan($account) : false); $view->with('isEnterprise', $account ? $svc->isEnterprise($account) : false); - $view->with('wooActiveStores', $account ? $stores->activeStores($account) : collect()); - $view->with('currentWooStore', $account ? $stores->resolve($account) : null); + $view->with('wooActiveStores', $account ? $stores->activeStores($account, $storeIds) : collect()); + $view->with('currentWooStore', $account ? $stores->resolve($account, $storeIds) : null); }); View::composer(['partials.topbar'], function ($view) { diff --git a/app/Services/Identity/IdentityTeamClient.php b/app/Services/Identity/IdentityTeamClient.php new file mode 100644 index 0000000..f0f88b7 --- /dev/null +++ b/app/Services/Identity/IdentityTeamClient.php @@ -0,0 +1,64 @@ + $apps + * @param array $metadata + * + * @return array + */ + public function inviteAppMember( + string $ownerPublicId, + string $email, + array $apps, + array $metadata = [], + string $role = 'member', + ): array { + $response = $this->request('post', '/identity/team/invite', [ + 'owner' => $ownerPublicId, + 'email' => strtolower(trim($email)), + 'apps' => $apps, + 'role' => $role, + 'metadata' => $metadata, + ]); + + return (array) $response->json('data', []); + } + + /** @return list> */ + public function teamAccess(string $userPublicId): array + { + $response = $this->request('get', '/identity/team/access', [ + 'user' => $userPublicId, + ]); + + return (array) $response->json('data', []); + } + + private function request(string $method, string $path, array $query = []): Response + { + $url = rtrim((string) config('identity.api_url'), '/').$path; + $key = config('identity.api_key'); + + if ($url === '' || ! is_string($key) || $key === '') { + throw new RuntimeException('Identity API is not configured.'); + } + + $response = Http::withToken($key) + ->timeout(10) + ->{$method}($url, $query); + + if (! $response->successful()) { + throw new RuntimeException($response->json('message') ?: 'Identity API request failed.'); + } + + return $response; + } +} diff --git a/app/Services/Woo/SubscriptionService.php b/app/Services/Woo/SubscriptionService.php index 10225d3..379a473 100644 --- a/app/Services/Woo/SubscriptionService.php +++ b/app/Services/Woo/SubscriptionService.php @@ -123,6 +123,11 @@ class SubscriptionService return $this->productCount($user) < $this->maxProducts($user); } + public function canManageStoreTeam(User $user): bool + { + return $this->isEnterprise($user); + } + public function canIngestProduct(User $user, WooStore $store, int $externalId): bool { if ($this->isPro($user)) { diff --git a/app/Services/Woo/TeamMemberProvisioner.php b/app/Services/Woo/TeamMemberProvisioner.php new file mode 100644 index 0000000..a178db8 --- /dev/null +++ b/app/Services/Woo/TeamMemberProvisioner.php @@ -0,0 +1,56 @@ +where('user_ref', strtolower((string) $user->email)) + ->update(['user_ref' => $user->public_id]); + + try { + $access = $this->identity->teamAccess($user->public_id); + } catch (\Throwable) { + return; + } + + foreach ($access as $grant) { + if (($grant['self'] ?? false) || ($grant['full_access'] ?? false)) { + continue; + } + + $apps = (array) ($grant['apps'] ?? []); + if (! in_array('woo', $apps, true)) { + continue; + } + + $meta = (array) data_get($grant, 'metadata.woo', []); + $storeId = (int) ($meta['store_id'] ?? 0); + + if ($storeId <= 0) { + continue; + } + + WooStoreMember::updateOrCreate( + [ + 'owner_ref' => (string) ($grant['account'] ?? $user->public_id), + 'user_ref' => $user->public_id, + 'woo_store_id' => $storeId, + ], + [ + 'role' => (string) ($meta['role'] ?? WooStoreMember::ROLE_MANAGER), + ], + ); + } + } +} diff --git a/app/Services/Woo/WooStoreMemberResolver.php b/app/Services/Woo/WooStoreMemberResolver.php new file mode 100644 index 0000000..044a67a --- /dev/null +++ b/app/Services/Woo/WooStoreMemberResolver.php @@ -0,0 +1,69 @@ +public_id === $ownerRef; + } + + public function memberFor(User $actor, string $ownerRef): ?WooStoreMember + { + if ($this->isAccountOwner($actor, $ownerRef)) { + return null; + } + + return WooStoreMember::query() + ->where('owner_ref', $ownerRef) + ->where('user_ref', $actor->public_id) + ->first(); + } + + /** @return list */ + public function accessibleStoreIds(User $actor, string $ownerRef): ?array + { + if ($this->isAccountOwner($actor, $ownerRef)) { + return null; + } + + $ids = WooStoreMember::query() + ->where('owner_ref', $ownerRef) + ->where('user_ref', $actor->public_id) + ->pluck('woo_store_id') + ->map(fn ($id) => (int) $id) + ->unique() + ->values() + ->all(); + + abort_if($ids === [], 403, 'You do not have access to this Woo Manager account.'); + + return $ids; + } + + public function canAccessStore(User $actor, string $ownerRef, int $storeId): bool + { + $scope = $this->accessibleStoreIds($actor, $ownerRef); + + return $scope === null || in_array($storeId, $scope, true); + } + + public function canManageStoreTeam(?WooStoreMember $member, string $ownerRef, User $actor): bool + { + return $this->isAccountOwner($actor, $ownerRef); + } + + /** @return Collection */ + public function membershipsFor(User $actor): Collection + { + return WooStoreMember::query() + ->where('user_ref', $actor->public_id) + ->with('store') + ->get(); + } +} diff --git a/app/Support/CurrentWooStore.php b/app/Support/CurrentWooStore.php index 8e09f06..c4ab626 100644 --- a/app/Support/CurrentWooStore.php +++ b/app/Support/CurrentWooStore.php @@ -10,27 +10,40 @@ class CurrentWooStore { public const SESSION_KEY = 'woo.current_store_id'; - public function activeStores(User $user): Collection + /** @param list|null $allowedStoreIds */ + public function activeStores(User $account, ?array $allowedStoreIds = null): Collection { - return WooStore::query() - ->where('user_id', $user->id) + $query = WooStore::query() + ->where('user_id', $account->id) ->where('status', WooStore::STATUS_ACTIVE) ->orderBy('site_name') - ->orderBy('site_url') - ->get(); + ->orderBy('site_url'); + + if ($allowedStoreIds !== null) { + $query->whereIn('id', $allowedStoreIds); + } + + return $query->get(); } - public function resolve(?User $user): ?WooStore + /** @param list|null $allowedStoreIds */ + public function resolve(?User $account, ?array $allowedStoreIds = null): ?WooStore { - if (! $user) { + if (! $account) { return null; } - $stores = $this->activeStores($user); + $stores = $this->activeStores($account, $allowedStoreIds); if ($stores->isEmpty()) { return null; } + if ($allowedStoreIds !== null && count($allowedStoreIds) === 1) { + session([self::SESSION_KEY => $stores->first()->id]); + + return $stores->first(); + } + $selectedId = (int) session(self::SESSION_KEY, 0); $store = $stores->firstWhere('id', $selectedId); if ($store) { @@ -43,13 +56,19 @@ class CurrentWooStore return $first; } - public function switch(User $user, int $storeId): WooStore + /** @param list|null $allowedStoreIds */ + public function switch(User $account, int $storeId, ?array $allowedStoreIds = null): WooStore { - $store = WooStore::query() - ->where('user_id', $user->id) + $query = WooStore::query() + ->where('user_id', $account->id) ->whereKey($storeId) - ->where('status', WooStore::STATUS_ACTIVE) - ->firstOrFail(); + ->where('status', WooStore::STATUS_ACTIVE); + + if ($allowedStoreIds !== null) { + $query->whereIn('id', $allowedStoreIds); + } + + $store = $query->firstOrFail(); session([self::SESSION_KEY => $store->id]); diff --git a/bootstrap/app.php b/bootstrap/app.php index 18611f1..500a68a 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -23,6 +23,7 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->web(append: [ \App\Http\Middleware\InjectBootSplash::class, \App\Http\Middleware\SetActingAccount::class, + \App\Http\Middleware\SetWooStoreMemberContext::class, ]); $middleware->alias([ 'platform.session' => \App\Http\Middleware\EnsurePlatformSession::class, diff --git a/config/woo.php b/config/woo.php index 52e3a08..8417722 100644 --- a/config/woo.php +++ b/config/woo.php @@ -35,6 +35,10 @@ return [ 'max_products' => (int) env('WOO_FREE_MAX_PRODUCTS', 20), ], + 'roles' => [ + 'manager' => 'Store manager — assigned store only', + ], + 'upgrade_banner' => [ 'title' => 'Unlock Woo Manager Pro or Business', 'description' => 'Multiple stores and unlimited products — from GHS 49/mo.', diff --git a/database/migrations/2026_07_08_150000_create_woo_store_members_table.php b/database/migrations/2026_07_08_150000_create_woo_store_members_table.php new file mode 100644 index 0000000..805e418 --- /dev/null +++ b/database/migrations/2026_07_08_150000_create_woo_store_members_table.php @@ -0,0 +1,27 @@ +id(); + $table->string('owner_ref')->index(); + $table->string('user_ref')->index(); + $table->foreignId('woo_store_id')->constrained('woo_stores')->cascadeOnDelete(); + $table->string('role', 30)->default('manager'); + $table->timestamps(); + + $table->unique(['owner_ref', 'user_ref', 'woo_store_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('woo_store_members'); + } +}; diff --git a/resources/views/woo/settings.blade.php b/resources/views/woo/settings.blade.php index 866acad..2c1c268 100644 --- a/resources/views/woo/settings.blade.php +++ b/resources/views/woo/settings.blade.php @@ -2,6 +2,8 @@ Settings + @include('woo.settings._store-team-section') +

See which WooCommerce marketing and shipping extensions are installed on your store.

+ @if (! empty($canManageStoreTeam) && ($hasStoreTeamFeatures ?? false)) +
+ @csrf +
+ + +
+
+ + +

Managers only see orders, products, and settings for this store.

+
+
+ +
+
+ +
+

Store managers

+ @if (($members ?? collect())->isEmpty()) +

No store managers yet. Invite a client above.

+ @else +
+ + + + + + + + + + + @foreach ($members as $member) + @php + $display = str_contains($member->user_ref, '@') + ? $member->user_ref + : (\App\Models\User::where('public_id', $member->user_ref)->value('email') ?? $member->user_ref); + @endphp + + + + + + + @endforeach + +
MemberStoreRole
{{ $display }}{{ $member->store?->site_name ?? $member->store?->site_url ?? '—' }}{{ $roles[$member->role] ?? $member->role }} + @if ($member->user_ref !== auth()->user()->public_id && $member->user_ref !== strtolower((string) auth()->user()->email)) +
+ @csrf + @method('DELETE') + +
+ @endif +
+
+ @endif +
+ @else +

+ Per-store managers require + Ladill Woo Manager Business. +

+ @endif +
+@endif diff --git a/resources/views/woo/stores/index.blade.php b/resources/views/woo/stores/index.blade.php index 5fd3d52..0dcb701 100644 --- a/resources/views/woo/stores/index.blade.php +++ b/resources/views/woo/stores/index.blade.php @@ -14,7 +14,7 @@ @endif - @if($stores->isEmpty()) + @if($stores->isEmpty() && ($isStoreOwner ?? true))
@@ -50,20 +50,22 @@

- @if($store->status === \App\Models\WooStore::STATUS_ACTIVE) + @if(($isStoreOwner ?? true) && $store->status === \App\Models\WooStore::STATUS_ACTIVE) @if($currentStore?->id === $store->id) Managing now - @else + @elseif(($wooActiveStores ?? collect())->count() > 1)
@csrf
@endif + @if($isStoreOwner ?? true)
@csrf @method('delete')
+ @endif @endif
diff --git a/routes/web.php b/routes/web.php index 4cf0751..4c1fee8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -11,6 +11,7 @@ use App\Http\Controllers\Woo\IntegrationController; use App\Http\Controllers\Woo\OrderController; use App\Http\Controllers\Woo\ProductController; use App\Http\Controllers\Woo\ProController; +use App\Http\Controllers\Woo\StoreMemberController; use App\Http\Controllers\Woo\SettingsController; use App\Http\Controllers\Woo\StoreController; use App\Http\Controllers\Woo\StoreSwitchController; @@ -65,6 +66,8 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/integrations', [IntegrationController::class, 'index'])->name('woo.integrations.index'); Route::post('/integrations/refresh', [IntegrationController::class, 'refresh'])->name('woo.integrations.refresh'); Route::get('/settings', [SettingsController::class, 'edit'])->name('woo.settings'); + Route::post('/settings/team', [StoreMemberController::class, 'store'])->name('woo.team.store'); + Route::delete('/settings/team/{member}', [StoreMemberController::class, 'destroy'])->name('woo.team.destroy'); Route::post('/ai/chat', [AiController::class, 'chat'])->middleware('throttle:30,1')->name('woo.ai.chat'); diff --git a/tests/Feature/WooStoreTeamTest.php b/tests/Feature/WooStoreTeamTest.php new file mode 100644 index 0000000..69786ba --- /dev/null +++ b/tests/Feature/WooStoreTeamTest.php @@ -0,0 +1,208 @@ +withoutMiddleware(EnsurePlatformSession::class); + $this->withoutVite(); + + config([ + 'woo.pro.enabled' => true, + 'billing.api_url' => 'https://billing.test', + 'billing.api_key' => 'test-key', + 'identity.api_url' => 'https://identity.test/api', + 'identity.api_key' => 'test-key', + ]); + } + + private function owner(): User + { + return User::factory()->create(); + } + + private function manager(): User + { + return User::factory()->create(); + } + + private function activateBusiness(User $user): void + { + ProSubscription::create([ + 'user_id' => $user->id, + 'plan' => ProSubscription::PLAN_ENTERPRISE, + 'status' => ProSubscription::STATUS_ACTIVE, + 'price_minor' => 14900, + 'currency' => 'GHS', + 'auto_renew' => true, + 'started_at' => now(), + 'current_period_end' => now()->addMonth(), + ]); + } + + public function test_pro_account_cannot_invite_store_manager(): void + { + $owner = $this->owner(); + ProSubscription::create([ + 'user_id' => $owner->id, + 'plan' => ProSubscription::PLAN_PRO, + 'status' => ProSubscription::STATUS_ACTIVE, + 'price_minor' => 4900, + 'currency' => 'GHS', + 'auto_renew' => true, + 'started_at' => now(), + 'current_period_end' => now()->addMonth(), + ]); + + $store = WooStore::create([ + 'user_id' => $owner->id, + 'site_url' => 'https://shop-a.example.com', + 'site_name' => 'Shop A', + 'status' => WooStore::STATUS_ACTIVE, + 'connected_at' => now(), + ]); + + $this->actingAs($owner) + ->post(route('woo.team.store'), [ + 'email' => 'client@example.com', + 'woo_store_id' => $store->id, + ]) + ->assertRedirect(route('woo.pro.index')); + + $this->assertDatabaseCount('woo_store_members', 0); + } + + public function test_business_owner_can_invite_store_manager(): void + { + $owner = $this->owner(); + $this->activateBusiness($owner); + + $store = WooStore::create([ + 'user_id' => $owner->id, + 'site_url' => 'https://shop-a.example.com', + 'site_name' => 'Shop A', + 'status' => WooStore::STATUS_ACTIVE, + 'connected_at' => now(), + ]); + + Http::fake([ + 'identity.test/*' => Http::response(['data' => []], 200), + ]); + + $this->actingAs($owner) + ->from(route('woo.settings')) + ->post(route('woo.team.store'), [ + 'email' => 'client@example.com', + 'woo_store_id' => $store->id, + ]) + ->assertRedirect(route('woo.settings').'#store-team'); + + $this->assertDatabaseHas('woo_store_members', [ + 'owner_ref' => $owner->public_id, + 'user_ref' => 'client@example.com', + 'woo_store_id' => $store->id, + 'role' => WooStoreMember::ROLE_MANAGER, + ]); + } + + public function test_store_manager_only_sees_assigned_store(): void + { + $owner = $this->owner(); + $this->activateBusiness($owner); + $manager = $this->manager(); + + $storeA = WooStore::create([ + 'user_id' => $owner->id, + 'site_url' => 'https://shop-a.example.com', + 'site_name' => 'Shop A', + 'status' => WooStore::STATUS_ACTIVE, + 'connected_at' => now(), + ]); + $storeB = WooStore::create([ + 'user_id' => $owner->id, + 'site_url' => 'https://shop-b.example.com', + 'site_name' => 'Shop B', + 'status' => WooStore::STATUS_ACTIVE, + 'connected_at' => now(), + ]); + + WooStoreMember::create([ + 'owner_ref' => $owner->public_id, + 'user_ref' => $manager->public_id, + 'woo_store_id' => $storeA->id, + 'role' => WooStoreMember::ROLE_MANAGER, + ]); + + WooOrder::create([ + 'woo_store_id' => $storeA->id, + 'external_order_id' => 1, + 'order_number' => '1001', + 'customer_name' => 'Customer A', + 'line_items' => [], + 'currency' => 'GHS', + 'total_minor' => 1000, + ]); + WooOrder::create([ + 'woo_store_id' => $storeB->id, + 'external_order_id' => 2, + 'order_number' => '2002', + 'customer_name' => 'Customer B', + 'line_items' => [], + 'currency' => 'GHS', + 'total_minor' => 2000, + ]); + + $this->actingAs($manager) + ->withSession(['ladill_account' => $owner->id]) + ->get(route('woo.orders.index')) + ->assertOk() + ->assertSee('1001') + ->assertSee('Customer A') + ->assertDontSee('2002') + ->assertDontSee('Customer B'); + } + + public function test_store_manager_cannot_disconnect_store(): void + { + $owner = $this->owner(); + $this->activateBusiness($owner); + $manager = $this->manager(); + + $store = WooStore::create([ + 'user_id' => $owner->id, + 'site_url' => 'https://shop-a.example.com', + 'site_name' => 'Shop A', + 'status' => WooStore::STATUS_ACTIVE, + 'connected_at' => now(), + ]); + + WooStoreMember::create([ + 'owner_ref' => $owner->public_id, + 'user_ref' => $manager->public_id, + 'woo_store_id' => $store->id, + 'role' => WooStoreMember::ROLE_MANAGER, + ]); + + $this->actingAs($manager) + ->withSession(['ladill_account' => $owner->id]) + ->delete(route('woo.stores.destroy', $store)) + ->assertForbidden(); + + $this->assertSame(WooStore::STATUS_ACTIVE, $store->fresh()->status); + } +}