From 8d45dfecf5ffca156d5177a2c264d57814d3a252 Mon Sep 17 00:00:00 2001 From: raftaar1191 Date: Wed, 17 Jun 2026 22:17:49 +0530 Subject: [PATCH] feat(providers): add BuddyBossProfileTypeProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gates resources by one or more BuddyBoss profile types (member types). Options are sourced from bp_get_member_types() and labels come from $type->labels['singular_name'] with a slug fallback. Access is granted when the requesting user's profile-type list (bp_get_member_type($uid, false)) intersects any selected slug. The provider depends on the BuddyBoss Platform plugin. Every method guards its API calls behind is_available() (function_exists check on the two BuddyBoss entry points), so: - The React /providers payload reports available: false when BuddyBoss is inactive — ProviderDropdown already hides unavailable entries. - get_options() short-circuits to [] when unavailable so no stale slugs leak into the UI. - user_has_access() denies when unavailable — never grants access against a missing API. Files: - src/BuddyBossProfileTypeProvider.php — new provider class with two filters (wpb_access_control_bb_profile_type_options, wpb_access_control_bb_profile_type_has_access). - src/AccessControlManager.php — registered as a default provider. - js/components/RoleOptionsPanel.js — description string for bb_profile_type; existing checkbox panel renders the new options. - tests/Unit/BuddyBossProfileTypeProviderTest.php — 22 new tests covering identity, is_available, get_options (unavailability guard, label fallback, alphabetical sort, filter override) and user_has_access (unavailability guard, empty options, no user types, intersect match, filter override, strict comparison). - README.md / CHANGELOG.md — documented new provider and filters. PHPUnit: 138 tests / 195 assertions all green. JS assets rebuilt via wp-scripts. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 5 + README.md | 20 + assets/build/index.asset.php | 2 +- assets/build/index.js | 2 +- js/components/RoleOptionsPanel.js | 2 + src/AccessControlManager.php | 1 + src/BuddyBossProfileTypeProvider.php | 190 ++++++++++ .../Unit/BuddyBossProfileTypeProviderTest.php | 341 ++++++++++++++++++ 8 files changed, 561 insertions(+), 2 deletions(-) create mode 100644 src/BuddyBossProfileTypeProvider.php create mode 100644 tests/Unit/BuddyBossProfileTypeProviderTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 63378f9..d70a792 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 1.4.0 + +- feat(providers): add `BuddyBossProfileTypeProvider` — gate a resource by one or more BuddyBoss profile types (member types). Options are listed via `bp_get_member_types()` and curated via the `wpb_access_control_bb_profile_type_options` filter +- feat(AccessControl): the React UI's "Who can access" dropdown lists **BuddyBoss Profile Type** when the BuddyBoss Platform plugin is active; the entry is hidden automatically when BuddyBoss is inactive (existing `available` flag wired through `RulesController` and `ProviderDropdown`) + ## 1.3.0 - feat(providers): add `WpCapabilityProvider` — gate a resource by one or more WordPress capability slugs; options are discovered dynamically across every role returned by `wp_roles()` and curated via the `wpb_access_control_wp_capability_options` filter diff --git a/README.md b/README.md index f27c6ac..02a23c6 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,7 @@ The component has four states driven by a single **"Who can access"** dropdown: | **Everyone (no restriction)** | Nothing — all users can access | | **WordPress Role** | Checkboxes for each WordPress role | | **WordPress Capability** | Checkboxes for each WordPress capability (discovered across all roles) | +| **BuddyBoss Profile Type** | Checkboxes for each BuddyBoss profile type — hidden automatically when BuddyBoss Platform is inactive | | **Users** | Search-as-you-type field + selected-user tags | Custom providers registered via the filter also appear in the dropdown. If @@ -719,6 +720,7 @@ correct controls dynamically without hard-coding provider IDs. | `wp_role` | `WpRoleProvider` | Restricts by WordPress user role. Administrator is always bypassed. | | `wp_user` | `WpUserProvider` | Restricts to specific WordPress users by ID. | | `wp_capability` | `WpCapabilityProvider` | Restricts by one or more WordPress capability slugs. Users holding **any** of the selected capabilities pass. | +| `bb_profile_type` | `BuddyBossProfileTypeProvider` | Restricts by one or more BuddyBoss profile types (member types). Reports `available: false` when BuddyBoss Platform is not active — the React dropdown hides the option automatically. | ### `WpRoleProvider` filters @@ -760,6 +762,24 @@ returns true for **any** selected capability. | `wpb_access_control_wp_capability_options` | `(array $options): array` | Add or remove selectable capability options (e.g. to surface a custom cap that no role holds yet) | | `wpb_access_control_wp_capability_has_access` | `(bool $result, int $user_id, array $selected): bool` | Override the final capability-based decision | +### `BuddyBossProfileTypeProvider` + +Requires the [BuddyBoss Platform](https://www.buddyboss.com/platform/) plugin +to be active. When inactive the provider reports `is_available() === false` +and the React dropdown hides the option automatically; saved rules of type +`bb_profile_type` deny by default while BuddyBoss is missing. + +Options are profile-type slugs (the same identifiers BuddyBoss exposes via +`bp_get_member_types()`). Both admin-created types (Profile Types CPT) and +code-registered types via `bp_register_member_type()` appear in the list. +Access is granted when the requesting user is assigned to **any** of the +selected profile types (via `bp_get_member_type()`). + +| Filter | Signature | Description | +|--------|-----------|-------------| +| `wpb_access_control_bb_profile_type_options` | `(array $options): array` | Add or remove selectable profile-type options | +| `wpb_access_control_bb_profile_type_has_access` | `(bool $result, int $user_id, array $selected): bool` | Override the final profile-type-based decision | + --- ## Important Notes diff --git a/assets/build/index.asset.php b/assets/build/index.asset.php index 7e9ddb8..c211c77 100644 --- a/assets/build/index.asset.php +++ b/assets/build/index.asset.php @@ -1 +1 @@ - array('react-jsx-runtime', 'wp-api-fetch', 'wp-element'), 'version' => 'a5bfff25a721fa37903f'); + array('react-jsx-runtime', 'wp-api-fetch', 'wp-element'), 'version' => 'db414737ada472bc3fef'); diff --git a/assets/build/index.js b/assets/build/index.js index 6d8abff..cccd6ac 100644 --- a/assets/build/index.js +++ b/assets/build/index.js @@ -1 +1 @@ -(()=>{"use strict";var e={n:s=>{var a=s&&s.__esModule?()=>s.default:()=>s;return e.d(a,{a}),a},d:(s,a)=>{for(var c in a)e.o(a,c)&&!e.o(s,c)&&Object.defineProperty(s,c,{enumerable:!0,get:a[c]})},o:(e,s)=>Object.prototype.hasOwnProperty.call(e,s)};const s=window.wp.element,a=window.wp.apiFetch;var c=e.n(a);const n=window.ReactJSXRuntime,l=[{value:"",label:"No user access added by admin"},{value:"everyone",label:"Everyone (no restriction)"}];function t({providers:e,value:s,onChange:a}){const c=e.filter(e=>!1!==e.available);return(0,n.jsxs)("select",{className:"wpb-ac__select",value:s,onChange:e=>a(e.target.value),children:[l.map(e=>(0,n.jsx)("option",{value:e.value,children:e.label},e.value)),c.map(e=>(0,n.jsx)("option",{value:e.id,children:e.label},e.id))]})}const i={wp_role:"Select which WordPress Role values may access this resource. Leave all unchecked to deny everyone (except administrators).",wp_capability:"Select which WordPress capabilities grant access. Users holding any of the checked capabilities are allowed. Administrators always have access."};function o({providerId:e,options:s,selectedOptions:a,onToggle:c}){const l=i[e]||null;return(0,n.jsxs)("div",{className:"wpb-ac__options-panel",children:[l&&(0,n.jsx)("p",{className:"wpb-ac__panel-description",children:l}),(0,n.jsx)("ul",{className:"wpb-ac__checkbox-list",children:s.map(e=>(0,n.jsx)("li",{className:"wpb-ac__checkbox-item",children:(0,n.jsxs)("label",{children:[(0,n.jsx)("input",{type:"checkbox",value:e.id,checked:a.includes(e.id),onChange:()=>c(e.id)}),e.label]})},e.id))})]})}function r({restApiRoot:e,selectedUsers:a,onAdd:l,onRemove:t}){const[i,o]=(0,s.useState)(""),[r,d]=(0,s.useState)([]),[p,u]=(0,s.useState)(!1),m=(0,s.useRef)(null),h=(0,s.useRef)(null);(0,s.useEffect)(()=>{const e=e=>{h.current&&!h.current.contains(e.target)&&u(!1)};return document.addEventListener("mousedown",e),()=>document.removeEventListener("mousedown",e)},[]);const b=(0,s.useCallback)(s=>{if(!s.trim())return d([]),void u(!1);c()({url:`${e}/wpb-ac/v1/users?search=${encodeURIComponent(s)}`}).then(e=>{d(e||[]),u(!0)}).catch(()=>d([]))},[e]),w=(0,s.useCallback)(e=>{const s=e.target.value;o(s),clearTimeout(m.current),m.current=setTimeout(()=>b(s),300)},[b]),_=(0,s.useCallback)(e=>{l({id:e.id,login:e.login,display_name:e.display_name}),o(""),d([]),u(!1)},[l]),v=r.filter(e=>!a.find(s=>s.id===e.id));return(0,n.jsxs)("div",{className:"wpb-ac__user-panel",ref:h,children:[(0,n.jsx)("p",{className:"wpb-ac__panel-description",children:"Search by username or email and select one or more users. Administrators always have access regardless of this list."}),(0,n.jsxs)("div",{className:"wpb-ac__search-wrapper",children:[(0,n.jsx)("input",{type:"text",className:"wpb-ac__search-input",placeholder:"Search by username or email...",value:i,onChange:w,onFocus:()=>v.length&&u(!0)}),p&&v.length>0&&(0,n.jsx)("ul",{className:"wpb-ac__search-dropdown",children:v.map(e=>(0,n.jsxs)("li",{className:"wpb-ac__search-option",onMouseDown:()=>_(e),children:[e.display_name," (",e.login,")"]},e.id))})]}),a.length>0&&(0,n.jsx)("div",{className:"wpb-ac__user-tags",children:a.map(e=>(0,n.jsxs)("span",{className:"wpb-ac__user-tag",children:[e.display_name," (",e.login,")",(0,n.jsx)("button",{type:"button",className:"wpb-ac__tag-remove",onClick:()=>t(e.id),"aria-label":`Remove ${e.display_name}`,children:"×"})]},e.id))})]})}const d=function({namespace:e,resourceKey:a,restApiRoot:l,nonce:i,title:d="Access Control",description:p="Control which users are allowed to connect to this MCP server. Administrators always have access regardless of this setting.",saveLabel:u="Save Access Control",onSave:m,hideSaveButton:h=!1,hideHeader:b=!1,onChange:w}){const[_,v]=(0,s.useState)(!0),[g,j]=(0,s.useState)(!1),[x,y]=(0,s.useState)(null),[f,N]=(0,s.useState)([]),[C,S]=(0,s.useState)(""),[k,$]=(0,s.useState)([]),[R,A]=(0,s.useState)([]),E=e.split("/").map(encodeURIComponent).join("%2F"),L=(0,s.useCallback)(e=>{const s=e.map(e=>`include[]=${e}`).join("&");c()({url:`${l}/wp/v2/users?${s}&per_page=100`}).then(e=>{A(e.map(e=>({id:String(e.id),login:e.slug,display_name:e.name})))}).catch(()=>{A(e.map(e=>({id:e,login:e,display_name:e})))})},[l]);(0,s.useEffect)(()=>{if(!a)return void v(!1);v(!0),y(null),N([]),S(""),$([]),A([]);let e=!1;return Promise.all([c()({url:`${l}/wpb-ac/v1/providers`}),c()({url:`${l}/wpb-ac/v1/rules/${E}/${a}`})]).then(([s,a])=>{if(e)return;N(s);const c=a.key||"",n=a.value||[];S(c),$(n),"wp_user"===c&&n.length>0&&L(n)}).catch(()=>{}).finally(()=>{e||v(!1)}),()=>{e=!0}},[l,E,a]);const P=(0,s.useCallback)(e=>{S(e),$([]),A([]),y(null)},[]),U=(0,s.useCallback)(e=>{$(s=>s.includes(e)?s.filter(s=>s!==e):[...s,e])},[]),T=(0,s.useCallback)(e=>{A(s=>s.find(s=>s.id===e.id)?s:[...s,e]),$(s=>s.includes(e.id)?s:[...s,e.id])},[]),F=(0,s.useCallback)(e=>{A(s=>s.filter(s=>s.id!==e)),$(s=>s.filter(s=>s!==e))},[]),I=(0,s.useCallback)(async()=>{j(!0),y(null);try{""===C?await c()({url:`${l}/wpb-ac/v1/rules/${E}/${a}`,method:"DELETE"}):await c()({url:`${l}/wpb-ac/v1/rules/${E}/${a}`,method:"PUT",data:{ac_key:C,ac_options:k}}),y({type:"success",message:"Access control saved."}),m?.(C,k)}catch(e){y({type:"error",message:e?.message||"Failed to save."})}finally{j(!1)}},[C,k,E,a,l,m]);(0,s.useEffect)(()=>{_||w?.(C,k)},[C,k,_]);const O=f.find(e=>e.id===C)||null,M=O&&"wp_user"!==O.id&&O.options?.length>0,K="wp_user"===C;return _?(0,n.jsx)("div",{className:"wpb-ac wpb-ac--loading",children:"Loading…"}):(0,n.jsxs)("div",{className:"wpb-ac",children:[!b&&(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)("h2",{className:"wpb-ac__title",children:d}),(0,n.jsx)("p",{className:"wpb-ac__description",children:p})]}),(0,n.jsxs)("div",{className:"wpb-ac__row",children:[(0,n.jsx)("div",{className:"wpb-ac__label",children:"Who can access"}),(0,n.jsx)("div",{className:"wpb-ac__control",children:(0,n.jsx)(t,{providers:f,value:C,onChange:P})})]}),M&&(0,n.jsxs)("div",{className:"wpb-ac__row",children:[(0,n.jsx)("div",{className:"wpb-ac__label",children:O.label}),(0,n.jsx)("div",{className:"wpb-ac__control",children:(0,n.jsx)(o,{providerId:C,options:O.options,selectedOptions:k,onToggle:U})})]}),K&&(0,n.jsxs)("div",{className:"wpb-ac__row",children:[(0,n.jsx)("div",{className:"wpb-ac__label",children:O?.label||"Users"}),(0,n.jsx)("div",{className:"wpb-ac__control",children:(0,n.jsx)(r,{restApiRoot:l,selectedUsers:R,onAdd:T,onRemove:F})})]}),x&&(0,n.jsx)("p",{className:`wpb-ac__notice wpb-ac__notice--${x.type}`,children:x.message}),!h&&(0,n.jsx)("div",{className:"wpb-ac__footer",children:(0,n.jsx)("button",{type:"button",className:"wpb-ac__save-btn",onClick:I,disabled:g,children:g?"Saving…":u})})]})};if("undefined"!=typeof window){const e=document.getElementById("wpb-access-control");if(e){const a=window.wpbAcConfig||{};a.nonce&&c().use(c().createNonceMiddleware(a.nonce)),(0,s.render)((0,n.jsx)(d,{namespace:a.namespace||"",resourceKey:a.resourceKey||"",restApiRoot:a.restApiRoot||"/wp-json",nonce:a.nonce||"",title:a.title,description:a.description,saveLabel:a.saveLabel}),e)}}})(); \ No newline at end of file +(()=>{"use strict";var e={n:s=>{var a=s&&s.__esModule?()=>s.default:()=>s;return e.d(a,{a}),a},d:(s,a)=>{for(var c in a)e.o(a,c)&&!e.o(s,c)&&Object.defineProperty(s,c,{enumerable:!0,get:a[c]})},o:(e,s)=>Object.prototype.hasOwnProperty.call(e,s)};const s=window.wp.element,a=window.wp.apiFetch;var c=e.n(a);const n=window.ReactJSXRuntime,l=[{value:"",label:"No user access added by admin"},{value:"everyone",label:"Everyone (no restriction)"}];function t({providers:e,value:s,onChange:a}){const c=e.filter(e=>!1!==e.available);return(0,n.jsxs)("select",{className:"wpb-ac__select",value:s,onChange:e=>a(e.target.value),children:[l.map(e=>(0,n.jsx)("option",{value:e.value,children:e.label},e.value)),c.map(e=>(0,n.jsx)("option",{value:e.id,children:e.label},e.id))]})}const i={wp_role:"Select which WordPress Role values may access this resource. Leave all unchecked to deny everyone (except administrators).",wp_capability:"Select which WordPress capabilities grant access. Users holding any of the checked capabilities are allowed. Administrators always have access.",bb_profile_type:"Select which BuddyBoss profile types grant access. Users assigned to any of the checked profile types are allowed. Administrators always have access."};function o({providerId:e,options:s,selectedOptions:a,onToggle:c}){const l=i[e]||null;return(0,n.jsxs)("div",{className:"wpb-ac__options-panel",children:[l&&(0,n.jsx)("p",{className:"wpb-ac__panel-description",children:l}),(0,n.jsx)("ul",{className:"wpb-ac__checkbox-list",children:s.map(e=>(0,n.jsx)("li",{className:"wpb-ac__checkbox-item",children:(0,n.jsxs)("label",{children:[(0,n.jsx)("input",{type:"checkbox",value:e.id,checked:a.includes(e.id),onChange:()=>c(e.id)}),e.label]})},e.id))})]})}function r({restApiRoot:e,selectedUsers:a,onAdd:l,onRemove:t}){const[i,o]=(0,s.useState)(""),[r,d]=(0,s.useState)([]),[p,u]=(0,s.useState)(!1),h=(0,s.useRef)(null),m=(0,s.useRef)(null);(0,s.useEffect)(()=>{const e=e=>{m.current&&!m.current.contains(e.target)&&u(!1)};return document.addEventListener("mousedown",e),()=>document.removeEventListener("mousedown",e)},[]);const b=(0,s.useCallback)(s=>{if(!s.trim())return d([]),void u(!1);c()({url:`${e}/wpb-ac/v1/users?search=${encodeURIComponent(s)}`}).then(e=>{d(e||[]),u(!0)}).catch(()=>d([]))},[e]),w=(0,s.useCallback)(e=>{const s=e.target.value;o(s),clearTimeout(h.current),h.current=setTimeout(()=>b(s),300)},[b]),_=(0,s.useCallback)(e=>{l({id:e.id,login:e.login,display_name:e.display_name}),o(""),d([]),u(!1)},[l]),v=r.filter(e=>!a.find(s=>s.id===e.id));return(0,n.jsxs)("div",{className:"wpb-ac__user-panel",ref:m,children:[(0,n.jsx)("p",{className:"wpb-ac__panel-description",children:"Search by username or email and select one or more users. Administrators always have access regardless of this list."}),(0,n.jsxs)("div",{className:"wpb-ac__search-wrapper",children:[(0,n.jsx)("input",{type:"text",className:"wpb-ac__search-input",placeholder:"Search by username or email...",value:i,onChange:w,onFocus:()=>v.length&&u(!0)}),p&&v.length>0&&(0,n.jsx)("ul",{className:"wpb-ac__search-dropdown",children:v.map(e=>(0,n.jsxs)("li",{className:"wpb-ac__search-option",onMouseDown:()=>_(e),children:[e.display_name," (",e.login,")"]},e.id))})]}),a.length>0&&(0,n.jsx)("div",{className:"wpb-ac__user-tags",children:a.map(e=>(0,n.jsxs)("span",{className:"wpb-ac__user-tag",children:[e.display_name," (",e.login,")",(0,n.jsx)("button",{type:"button",className:"wpb-ac__tag-remove",onClick:()=>t(e.id),"aria-label":`Remove ${e.display_name}`,children:"×"})]},e.id))})]})}const d=function({namespace:e,resourceKey:a,restApiRoot:l,nonce:i,title:d="Access Control",description:p="Control which users are allowed to connect to this MCP server. Administrators always have access regardless of this setting.",saveLabel:u="Save Access Control",onSave:h,hideSaveButton:m=!1,hideHeader:b=!1,onChange:w}){const[_,v]=(0,s.useState)(!0),[g,y]=(0,s.useState)(!1),[j,x]=(0,s.useState)(null),[f,N]=(0,s.useState)([]),[C,S]=(0,s.useState)(""),[k,$]=(0,s.useState)([]),[A,R]=(0,s.useState)([]),E=e.split("/").map(encodeURIComponent).join("%2F"),L=(0,s.useCallback)(e=>{const s=e.map(e=>`include[]=${e}`).join("&");c()({url:`${l}/wp/v2/users?${s}&per_page=100`}).then(e=>{R(e.map(e=>({id:String(e.id),login:e.slug,display_name:e.name})))}).catch(()=>{R(e.map(e=>({id:e,login:e,display_name:e})))})},[l]);(0,s.useEffect)(()=>{if(!a)return void v(!1);v(!0),x(null),N([]),S(""),$([]),R([]);let e=!1;return Promise.all([c()({url:`${l}/wpb-ac/v1/providers`}),c()({url:`${l}/wpb-ac/v1/rules/${E}/${a}`})]).then(([s,a])=>{if(e)return;N(s);const c=a.key||"",n=a.value||[];S(c),$(n),"wp_user"===c&&n.length>0&&L(n)}).catch(()=>{}).finally(()=>{e||v(!1)}),()=>{e=!0}},[l,E,a]);const U=(0,s.useCallback)(e=>{S(e),$([]),R([]),x(null)},[]),P=(0,s.useCallback)(e=>{$(s=>s.includes(e)?s.filter(s=>s!==e):[...s,e])},[]),T=(0,s.useCallback)(e=>{R(s=>s.find(s=>s.id===e.id)?s:[...s,e]),$(s=>s.includes(e.id)?s:[...s,e.id])},[]),F=(0,s.useCallback)(e=>{R(s=>s.filter(s=>s.id!==e)),$(s=>s.filter(s=>s!==e))},[]),I=(0,s.useCallback)(async()=>{y(!0),x(null);try{""===C?await c()({url:`${l}/wpb-ac/v1/rules/${E}/${a}`,method:"DELETE"}):await c()({url:`${l}/wpb-ac/v1/rules/${E}/${a}`,method:"PUT",data:{ac_key:C,ac_options:k}}),x({type:"success",message:"Access control saved."}),h?.(C,k)}catch(e){x({type:"error",message:e?.message||"Failed to save."})}finally{y(!1)}},[C,k,E,a,l,h]);(0,s.useEffect)(()=>{_||w?.(C,k)},[C,k,_]);const O=f.find(e=>e.id===C)||null,B=O&&"wp_user"!==O.id&&O.options?.length>0,M="wp_user"===C;return _?(0,n.jsx)("div",{className:"wpb-ac wpb-ac--loading",children:"Loading…"}):(0,n.jsxs)("div",{className:"wpb-ac",children:[!b&&(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)("h2",{className:"wpb-ac__title",children:d}),(0,n.jsx)("p",{className:"wpb-ac__description",children:p})]}),(0,n.jsxs)("div",{className:"wpb-ac__row",children:[(0,n.jsx)("div",{className:"wpb-ac__label",children:"Who can access"}),(0,n.jsx)("div",{className:"wpb-ac__control",children:(0,n.jsx)(t,{providers:f,value:C,onChange:U})})]}),B&&(0,n.jsxs)("div",{className:"wpb-ac__row",children:[(0,n.jsx)("div",{className:"wpb-ac__label",children:O.label}),(0,n.jsx)("div",{className:"wpb-ac__control",children:(0,n.jsx)(o,{providerId:C,options:O.options,selectedOptions:k,onToggle:P})})]}),M&&(0,n.jsxs)("div",{className:"wpb-ac__row",children:[(0,n.jsx)("div",{className:"wpb-ac__label",children:O?.label||"Users"}),(0,n.jsx)("div",{className:"wpb-ac__control",children:(0,n.jsx)(r,{restApiRoot:l,selectedUsers:A,onAdd:T,onRemove:F})})]}),j&&(0,n.jsx)("p",{className:`wpb-ac__notice wpb-ac__notice--${j.type}`,children:j.message}),!m&&(0,n.jsx)("div",{className:"wpb-ac__footer",children:(0,n.jsx)("button",{type:"button",className:"wpb-ac__save-btn",onClick:I,disabled:g,children:g?"Saving…":u})})]})};if("undefined"!=typeof window){const e=document.getElementById("wpb-access-control");if(e){const a=window.wpbAcConfig||{};a.nonce&&c().use(c().createNonceMiddleware(a.nonce)),(0,s.render)((0,n.jsx)(d,{namespace:a.namespace||"",resourceKey:a.resourceKey||"",restApiRoot:a.restApiRoot||"/wp-json",nonce:a.nonce||"",title:a.title,description:a.description,saveLabel:a.saveLabel}),e)}}})(); \ No newline at end of file diff --git a/js/components/RoleOptionsPanel.js b/js/components/RoleOptionsPanel.js index 5a8fc35..543853f 100644 --- a/js/components/RoleOptionsPanel.js +++ b/js/components/RoleOptionsPanel.js @@ -7,6 +7,8 @@ const PROVIDER_DESCRIPTIONS = { 'Select which WordPress Role values may access this resource. Leave all unchecked to deny everyone (except administrators).', wp_capability: 'Select which WordPress capabilities grant access. Users holding any of the checked capabilities are allowed. Administrators always have access.', + bb_profile_type: + 'Select which BuddyBoss profile types grant access. Users assigned to any of the checked profile types are allowed. Administrators always have access.', }; /** diff --git a/src/AccessControlManager.php b/src/AccessControlManager.php index 5082354..643b641 100644 --- a/src/AccessControlManager.php +++ b/src/AccessControlManager.php @@ -107,6 +107,7 @@ public function load_providers(): void { new WpRoleProvider(), new WpUserProvider(), new WpCapabilityProvider(), + new BuddyBossProfileTypeProvider(), ); /** diff --git a/src/BuddyBossProfileTypeProvider.php b/src/BuddyBossProfileTypeProvider.php new file mode 100644 index 0000000..af96c2b --- /dev/null +++ b/src/BuddyBossProfileTypeProvider.php @@ -0,0 +1,190 @@ + $type_object ]`. Human label lives in + * `$type_object->labels['singular_name']`. + * - `bp_get_member_type( int $user_id, false )` — return the array of + * profile-type slugs assigned to a user, or `false` when the user has + * none. Handles `$user_id = 0` gracefully. + * + * @package WPBoilerplate\AccessControl + * @since 1.4.0 + */ + +namespace WPBoilerplate\AccessControl; + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +/** + * Provider that gates access by BuddyBoss profile type (member type). + * + * @since 1.4.0 + */ +class BuddyBossProfileTypeProvider extends AbstractProvider { + + /** + * {@inheritdoc} + * + * @since 1.4.0 + * + * @return string + */ + public function get_id(): string { + return 'bb_profile_type'; + } + + /** + * {@inheritdoc} + * + * @since 1.4.0 + * + * @return string + */ + public function get_label(): string { + return __( 'BuddyBoss Profile Type', 'wpb-access-control' ); + } + + /** + * Return whether BuddyBoss Platform exposes its member-types API. + * + * The REST `/providers` endpoint forwards this flag to the React UI, + * which hides the dropdown option when false. + * + * @since 1.4.0 + * + * @return bool + */ + public function is_available(): bool { + return function_exists( 'bp_get_member_type' ) + && function_exists( 'bp_get_member_types' ); + } + + /** + * Return every available BuddyBoss profile type as a selectable option. + * + * Returns an empty list when BuddyBoss is inactive so the UI surfaces an + * empty checkbox panel instead of stale slugs. Options are + * alphabetically sorted by label for a stable UI order. + * + * @since 1.4.0 + * + * @return array + */ + public function get_options(): array { + if ( ! $this->is_available() ) { + return array(); + } + + $types = \bp_get_member_types( array(), 'objects' ); + + $options = array(); + if ( is_array( $types ) ) { + foreach ( $types as $slug => $type ) { + $label = ''; + if ( is_object( $type ) && isset( $type->labels ) && is_array( $type->labels ) ) { + if ( ! empty( $type->labels['singular_name'] ) ) { + $label = (string) $type->labels['singular_name']; + } + } + if ( '' === $label ) { + $label = (string) $slug; + } + + $options[] = array( + 'id' => (string) $slug, + 'label' => $label, + ); + } + } + + usort( + $options, + static function ( array $a, array $b ): int { + return strcasecmp( $a['label'], $b['label'] ); + } + ); + + /** + * Filter the BuddyBoss profile-type options shown in the access control UI. + * + * Each entry is `[ 'id' => 'type_slug', 'label' => 'Human Name' ]`. Use this + * filter to surface programmatically-registered types that should remain + * hidden from the admin UI, or to inject a synthetic option. + * + * @since 1.4.0 + * + * @param array $options List of profile-type options. + */ + return (array) apply_filters( 'wpb_access_control_bb_profile_type_options', $options ); + } + + /** + * Return true when the user is assigned to at least one of the allowed profile types. + * + * Administrators bypass this check via AccessControlManager and will never + * reach this method. + * + * @since 1.4.0 + * + * @param int $user_id WordPress user ID. + * @param string[] $selected_options Profile-type slugs the admin has allowed. + * + * @return bool + */ + public function user_has_access( int $user_id, array $selected_options ): bool { + if ( ! $this->is_available() ) { + return false; + } + + if ( empty( $selected_options ) ) { + return false; + } + + $user_types = \bp_get_member_type( $user_id, false ); + + $result = false; + if ( is_array( $user_types ) && ! empty( $user_types ) ) { + $result = ! empty( array_intersect( $selected_options, $user_types ) ); + } + + /** + * Filter the final access decision for a BuddyBoss profile-type check. + * + * @since 1.4.0 + * + * @param bool $has_access Result before the filter. + * @param int $user_id User being checked. + * @param string[] $selected_options Allowed profile-type slugs. + */ + return (bool) apply_filters( 'wpb_access_control_bb_profile_type_has_access', $result, $user_id, $selected_options ); + } +} diff --git a/tests/Unit/BuddyBossProfileTypeProviderTest.php b/tests/Unit/BuddyBossProfileTypeProviderTest.php new file mode 100644 index 0000000..c796438 --- /dev/null +++ b/tests/Unit/BuddyBossProfileTypeProviderTest.php @@ -0,0 +1,341 @@ +returnArg(); + } + + protected function tearDown(): void { + Monkey\tearDown(); + parent::tearDown(); + } + + private function provider(): BuddyBossProfileTypeProvider { + return new BuddyBossProfileTypeProvider(); + } + + /** + * Return a provider whose is_available() returns the given value. + * + * Avoids monkey-patching PHP's function_exists() so we can verify the + * guard behaviour in get_options() / user_has_access() deterministically. + * + * @param bool $available + * + * @return BuddyBossProfileTypeProvider + */ + private function provider_with_availability( bool $available ): BuddyBossProfileTypeProvider { + return new class( $available ) extends BuddyBossProfileTypeProvider { + private bool $available; + public function __construct( bool $available ) { + $this->available = $available; + } + public function is_available(): bool { + return $this->available; + } + }; + } + + private function type_object( string $singular_name ): object { + $obj = new \stdClass(); + $obj->labels = array( 'singular_name' => $singular_name ); + return $obj; + } + + // ------------------------------------------------------------------------- + // Identity / metadata + // ------------------------------------------------------------------------- + + public function test_get_id_returns_bb_profile_type(): void { + $this->assertSame( 'bb_profile_type', $this->provider()->get_id() ); + } + + public function test_get_label_returns_buddyboss_profile_type_string(): void { + $this->assertSame( 'BuddyBoss Profile Type', $this->provider()->get_label() ); + } + + // ------------------------------------------------------------------------- + // is_available() + // ------------------------------------------------------------------------- + + public function test_is_available_returns_true_when_both_buddyboss_functions_exist(): void { + Functions\when( 'bp_get_member_type' )->justReturn( false ); + Functions\when( 'bp_get_member_types' )->justReturn( array() ); + + $this->assertTrue( $this->provider()->is_available() ); + } + + // ------------------------------------------------------------------------- + // get_options() — availability guard + // ------------------------------------------------------------------------- + + public function test_get_options_returns_empty_array_when_buddyboss_is_inactive(): void { + // Filter must not be applied when BuddyBoss is missing — caller + // shouldn't be able to inject options into a disabled provider. + Filters\expectApplied( 'wpb_access_control_bb_profile_type_options' )->never(); + + $this->assertSame( array(), $this->provider_with_availability( false )->get_options() ); + } + + // ------------------------------------------------------------------------- + // get_options() — happy path & shape + // ------------------------------------------------------------------------- + + public function test_get_options_uses_singular_name_label_from_type_object(): void { + Functions\when( 'bp_get_member_type' )->justReturn( false ); + Functions\when( 'bp_get_member_types' )->justReturn( + array( + 'customer' => $this->type_object( 'Customer' ), + ) + ); + Filters\expectApplied( 'wpb_access_control_bb_profile_type_options' )->once()->andReturnFirstArg(); + + $this->assertSame( + array( array( 'id' => 'customer', 'label' => 'Customer' ) ), + $this->provider()->get_options() + ); + } + + public function test_get_options_falls_back_to_slug_when_singular_name_missing(): void { + Functions\when( 'bp_get_member_type' )->justReturn( false ); + $broken_a = new \stdClass(); + $broken_a->labels = array(); + $broken_b = new \stdClass(); // no labels property at all + Functions\when( 'bp_get_member_types' )->justReturn( + array( + 'vendor' => $broken_a, + 'partner' => $broken_b, + ) + ); + Filters\expectApplied( 'wpb_access_control_bb_profile_type_options' )->andReturnFirstArg(); + + $options = $this->provider()->get_options(); + + $ids = array_column( $options, 'id' ); + $labels = array_column( $options, 'label' ); + + // Both fall back to the slug; sort order is by label (case-insensitive) + // → 'partner' < 'vendor'. + $this->assertSame( array( 'partner', 'vendor' ), $ids ); + $this->assertSame( array( 'partner', 'vendor' ), $labels ); + } + + public function test_get_options_falls_back_to_slug_when_singular_name_is_empty_string(): void { + Functions\when( 'bp_get_member_type' )->justReturn( false ); + $type = new \stdClass(); + $type->labels = array( 'singular_name' => '' ); + Functions\when( 'bp_get_member_types' )->justReturn( array( 'vendor' => $type ) ); + Filters\expectApplied( 'wpb_access_control_bb_profile_type_options' )->andReturnFirstArg(); + + $this->assertSame( + array( array( 'id' => 'vendor', 'label' => 'vendor' ) ), + $this->provider()->get_options() + ); + } + + public function test_get_options_sorts_alphabetically_case_insensitive_by_label(): void { + Functions\when( 'bp_get_member_type' )->justReturn( false ); + Functions\when( 'bp_get_member_types' )->justReturn( + array( + 'zeta' => $this->type_object( 'zeta' ), + 'alpha' => $this->type_object( 'Alpha' ), + 'mu' => $this->type_object( 'mu' ), + ) + ); + Filters\expectApplied( 'wpb_access_control_bb_profile_type_options' )->andReturnFirstArg(); + + $labels = array_column( $this->provider()->get_options(), 'label' ); + + $this->assertSame( array( 'Alpha', 'mu', 'zeta' ), $labels ); + } + + public function test_get_options_returns_empty_when_bp_get_member_types_returns_non_array(): void { + Functions\when( 'bp_get_member_type' )->justReturn( false ); + Functions\when( 'bp_get_member_types' )->justReturn( null ); + Filters\expectApplied( 'wpb_access_control_bb_profile_type_options' )->once()->andReturn( array() ); + + $this->assertSame( array(), $this->provider()->get_options() ); + } + + public function test_get_options_filter_can_replace_the_entire_list(): void { + Functions\when( 'bp_get_member_type' )->justReturn( false ); + Functions\when( 'bp_get_member_types' )->justReturn( + array( 'customer' => $this->type_object( 'Customer' ) ) + ); + Filters\expectApplied( 'wpb_access_control_bb_profile_type_options' ) + ->once() + ->andReturn( array( array( 'id' => 'override', 'label' => 'Override' ) ) ); + + $this->assertSame( + array( array( 'id' => 'override', 'label' => 'Override' ) ), + $this->provider()->get_options() + ); + } + + public function test_get_options_casts_non_array_filter_return_back_to_array(): void { + Functions\when( 'bp_get_member_type' )->justReturn( false ); + Functions\when( 'bp_get_member_types' )->justReturn( array() ); + Filters\expectApplied( 'wpb_access_control_bb_profile_type_options' )->andReturn( null ); + + $this->assertSame( array(), $this->provider()->get_options() ); + } + + // ------------------------------------------------------------------------- + // user_has_access() — availability guard + // ------------------------------------------------------------------------- + + public function test_user_has_access_returns_false_when_buddyboss_is_inactive(): void { + // bp_get_member_type must NOT be called when the plugin is missing. + Filters\expectApplied( 'wpb_access_control_bb_profile_type_has_access' )->never(); + + $this->assertFalse( + $this->provider_with_availability( false )->user_has_access( 7, array( 'customer' ) ) + ); + } + + // ------------------------------------------------------------------------- + // user_has_access() — empty options short-circuit + // ------------------------------------------------------------------------- + + public function test_user_has_access_returns_false_when_selected_options_empty(): void { + Functions\when( 'bp_get_member_type' )->justReturn( false ); + Functions\when( 'bp_get_member_types' )->justReturn( array() ); + Filters\expectApplied( 'wpb_access_control_bb_profile_type_has_access' )->never(); + + $this->assertFalse( $this->provider()->user_has_access( 7, array() ) ); + } + + // ------------------------------------------------------------------------- + // user_has_access() — no user types (BuddyBoss returns false) + // ------------------------------------------------------------------------- + + public function test_user_has_access_returns_false_when_user_has_no_profile_types(): void { + Functions\when( 'bp_get_member_types' )->justReturn( array() ); + Functions\expect( 'bp_get_member_type' )->once()->with( 7, false )->andReturn( false ); + Filters\expectApplied( 'wpb_access_control_bb_profile_type_has_access' ) + ->once() + ->with( false, 7, array( 'customer' ) ) + ->andReturn( false ); + + $this->assertFalse( $this->provider()->user_has_access( 7, array( 'customer' ) ) ); + } + + public function test_user_has_access_returns_false_when_user_types_is_empty_array(): void { + Functions\when( 'bp_get_member_types' )->justReturn( array() ); + Functions\expect( 'bp_get_member_type' )->once()->with( 7, false )->andReturn( array() ); + Filters\expectApplied( 'wpb_access_control_bb_profile_type_has_access' ) + ->once() + ->with( false, 7, array( 'customer' ) ) + ->andReturn( false ); + + $this->assertFalse( $this->provider()->user_has_access( 7, array( 'customer' ) ) ); + } + + // ------------------------------------------------------------------------- + // user_has_access() — intersect match (true cases) + // ------------------------------------------------------------------------- + + public function test_user_has_access_returns_true_when_user_type_matches_single_selected(): void { + Functions\when( 'bp_get_member_types' )->justReturn( array() ); + Functions\expect( 'bp_get_member_type' )->once()->with( 7, false )->andReturn( array( 'customer' ) ); + Filters\expectApplied( 'wpb_access_control_bb_profile_type_has_access' ) + ->once() + ->with( true, 7, array( 'customer' ) ) + ->andReturn( true ); + + $this->assertTrue( $this->provider()->user_has_access( 7, array( 'customer' ) ) ); + } + + public function test_user_has_access_returns_true_when_one_of_multiple_user_types_matches(): void { + Functions\when( 'bp_get_member_types' )->justReturn( array() ); + Functions\expect( 'bp_get_member_type' )->once()->with( 7, false )->andReturn( array( 'subscriber', 'vendor' ) ); + Filters\expectApplied( 'wpb_access_control_bb_profile_type_has_access' ) + ->once() + ->with( true, 7, array( 'customer', 'vendor' ) ) + ->andReturn( true ); + + $this->assertTrue( $this->provider()->user_has_access( 7, array( 'customer', 'vendor' ) ) ); + } + + // ------------------------------------------------------------------------- + // user_has_access() — no intersection (filter-driven cases) + // ------------------------------------------------------------------------- + + public function test_user_has_access_returns_false_when_no_user_type_matches_and_filter_keeps_false(): void { + Functions\when( 'bp_get_member_types' )->justReturn( array() ); + Functions\expect( 'bp_get_member_type' )->once()->with( 7, false )->andReturn( array( 'subscriber' ) ); + Filters\expectApplied( 'wpb_access_control_bb_profile_type_has_access' ) + ->once() + ->with( false, 7, array( 'customer', 'vendor' ) ) + ->andReturn( false ); + + $this->assertFalse( $this->provider()->user_has_access( 7, array( 'customer', 'vendor' ) ) ); + } + + public function test_user_has_access_filter_can_override_false_to_true(): void { + Functions\when( 'bp_get_member_types' )->justReturn( array() ); + Functions\expect( 'bp_get_member_type' )->once()->with( 7, false )->andReturn( false ); + Filters\expectApplied( 'wpb_access_control_bb_profile_type_has_access' ) + ->once() + ->with( false, 7, array( 'customer' ) ) + ->andReturn( true ); + + $this->assertTrue( $this->provider()->user_has_access( 7, array( 'customer' ) ) ); + } + + public function test_user_has_access_filter_truthy_value_is_cast_to_bool(): void { + Functions\when( 'bp_get_member_types' )->justReturn( array() ); + Functions\expect( 'bp_get_member_type' )->once()->andReturn( false ); + Filters\expectApplied( 'wpb_access_control_bb_profile_type_has_access' ) + ->once() + ->andReturn( 1 ); + + $this->assertTrue( $this->provider()->user_has_access( 1, array( 'customer' ) ) ); + } + + public function test_user_has_access_filter_falsy_value_is_cast_to_bool(): void { + Functions\when( 'bp_get_member_types' )->justReturn( array() ); + Functions\expect( 'bp_get_member_type' )->once()->andReturn( array( 'customer' ) ); + Filters\expectApplied( 'wpb_access_control_bb_profile_type_has_access' ) + ->once() + ->andReturn( '' ); + + $this->assertFalse( $this->provider()->user_has_access( 1, array( 'customer' ) ) ); + } + + public function test_user_has_access_uses_strict_intersect_comparison(): void { + Functions\when( 'bp_get_member_types' )->justReturn( array() ); + Functions\expect( 'bp_get_member_type' )->once()->with( 7, false )->andReturn( array( 'Customer' ) ); + Filters\expectApplied( 'wpb_access_control_bb_profile_type_has_access' ) + ->once() + ->andReturn( false ); + + // Profile-type slugs are case-sensitive — 'Customer' should not match 'customer'. + $this->assertFalse( $this->provider()->user_has_access( 7, array( 'customer' ) ) ); + } +}