diff --git a/README.md b/README.md index 546bda8..65b5052 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ A WordPress plugin that allows multiple authors to edit a single post via shared - **Co-author management** -- Add or remove co-authors from any post via the block editor sidebar panel. - **Shared invite links** -- Generate a shareable URL that lets any registered user join as a co-author. Links expire after 24 hours and are automatically revoked when the post is published; existing co-authors keep their access. - **Capability-aware** -- Co-authors can edit and read their assigned posts without gaining broader site permissions. +- **Post-publish edit gate** -- Co-authors lose edit access once the post is published. Site editors and admins can opt in per-post to keep co-author access after publish. - **Multisite support** -- Invited users are automatically added to the site with a subscriber role so they can access the editor. - **Author preservation** -- When a post's author is reassigned, the previous author is automatically kept as a co-author. @@ -24,6 +25,11 @@ The only things they **cannot** do: - Reassign post authorship (requires `edit_others_posts`, enforced by WordPress core) - Delete the post (the `map_meta_cap` filter explicitly excludes `delete_post`) +- Change the per-post "Allow co-authors to edit after publish" setting (requires `edit_others_posts`) + +## Editing after publish + +By default, publishing a post revokes co-authors' edit access — only the post author and site editors/admins can continue editing. Site editors and administrators can flip this per-post via the **"Allow co-authors to edit after publish"** toggle in the Co-Authors sidebar panel. Co-authors and Author-role post authors do not see this toggle. ## Requirements diff --git a/build/index.asset.php b/build/index.asset.php index 12c23c6..76a92fd 100644 --- a/build/index.asset.php +++ b/build/index.asset.php @@ -1 +1 @@ - array('react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-core-data', 'wp-data', 'wp-editor', 'wp-element', 'wp-i18n', 'wp-plugins'), 'version' => 'fa1237d525aae3cf7d2d'); + array('react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-core-data', 'wp-data', 'wp-editor', 'wp-element', 'wp-i18n', 'wp-plugins'), 'version' => '6ebc5c489779f8ecfec0'); diff --git a/build/index.js b/build/index.js index 4a1ac66..a8c6031 100644 --- a/build/index.js +++ b/build/index.js @@ -1,2 +1,2 @@ (()=>{"use strict";var t={n:e=>{var s=e&&e.__esModule?()=>e.default:()=>e;return t.d(s,{a:s}),s},d:(e,s)=>{for(var a in s)t.o(s,a)&&!t.o(e,a)&&Object.defineProperty(e,a,{enumerable:!0,get:s[a]})},o:(t,e)=>Object.prototype.hasOwnProperty.call(t,e)};const e=window.wp.plugins,s=window.wp.element,a=window.wp.editor,n=window.wp.data,i=window.wp.coreData,o=window.wp.i18n,r=window.wp.apiFetch;var l=t.n(r);const c=window.wp.components,u=window.ReactJSXRuntime,h="/multi-author-posts/v1";function p({author:t,canManage:e,onRemove:s}){return(0,u.jsxs)(c.__experimentalHStack,{className:"map-author-card",justify:"space-between",children:[(0,u.jsxs)(c.__experimentalHStack,{spacing:2,children:[(0,u.jsx)("img",{src:t.avatar,alt:t.name,className:"map-author-avatar",width:36,height:36}),(0,u.jsx)("span",{className:"map-author-name",children:t.name})]}),e&&(0,u.jsx)(c.Button,{variant:"tertiary",isDestructive:!0,isSmall:!0,onClick:()=>s(t.id),"aria-label":/* translators: %s: author display name */ /* translators: %s: author display name */ -(0,o.sprintf)((0,o.__)("Remove %s","multi-author-posts"),t.name),children:(0,o.__)("Remove","multi-author-posts")})]})}function d({postId:t,onAdd:e}){const[a,n]=(0,s.useState)(""),[i,r]=(0,s.useState)([]),[p,d]=(0,s.useState)(!1);return(0,s.useEffect)(()=>{if(a.length<2)return r([]),void d(!1);d(!0);const e=new AbortController;return l()({path:`${h}/posts/${t}/suggested-authors?search=${encodeURIComponent(a)}`,signal:e.signal}).then(t=>{r(t||[]),d(!1)}).catch(t=>{"AbortError"!==t.name&&d(!1)}),()=>e.abort()},[a,t]),(0,u.jsxs)(c.__experimentalVStack,{spacing:1,className:"map-direct-add",children:[(0,u.jsx)(c.SearchControl,{label:(0,o.__)("Add existing author","multi-author-posts"),value:a,onChange:n,placeholder:(0,o.__)("Search by name or email…","multi-author-posts")}),p&&(0,u.jsx)(c.Spinner,{}),i.length>0&&(0,u.jsx)("ul",{className:"map-suggestions",children:i.map(s=>(0,u.jsx)("li",{className:"map-suggestion-item",children:(0,u.jsxs)(c.__experimentalHStack,{justify:"space-between",children:[(0,u.jsxs)(c.__experimentalHStack,{spacing:2,children:[(0,u.jsx)("img",{src:s.avatar,alt:s.name,width:28,height:28}),(0,u.jsx)("span",{children:(0,u.jsx)(c.TextHighlight,{text:s.name,highlight:a})})]}),(0,u.jsx)(c.Button,{variant:"secondary",isSmall:!0,onClick:()=>(s=>{l()({path:`${h}/posts/${t}/co-authors`,method:"POST",data:{user_id:s.id}}).then(t=>{e(t),n(""),r([])})})(s),children:(0,o.__)("Add","multi-author-posts")})]})},s.id))}),a.length>=2&&!p&&0===i.length&&(0,u.jsx)("p",{className:"map-no-suggestions",children:(0,o.__)("No matching authors found.","multi-author-posts")})]})}function m({postId:t}){const[e,a]=(0,s.useState)(null),[n,i]=(0,s.useState)(!1),[r,p]=(0,s.useState)(!0),[d,m]=(0,s.useState)(!1);(0,s.useEffect)(()=>{l()({path:`${h}/posts/${t}/invite`}).then(t=>{i(!!t?.active),p(!1)}).catch(()=>p(!1))},[t]);const _=(0,s.useCallback)(()=>{p(!0),l()({path:`${h}/posts/${t}/invite`,method:"POST"}).then(t=>{a(t?.invite_url??null),i(!!t?.invite_url),p(!1)})},[t]),g=(0,s.useCallback)(()=>{p(!0),l()({path:`${h}/posts/${t}/invite`,method:"DELETE"}).then(()=>{a(null),i(!1),p(!1)})},[t]),x=(0,s.useCallback)(()=>{e&&navigator.clipboard?.writeText(e).then(()=>{m(!0),setTimeout(()=>m(!1),2e3)})},[e]);return r?(0,u.jsx)(c.Spinner,{}):(0,u.jsxs)(c.__experimentalVStack,{spacing:2,className:"map-invite-section",children:[(0,u.jsx)("strong",{children:(0,o.__)("Shared invite link","multi-author-posts")}),(0,u.jsx)("p",{className:"map-invite-description",children:(0,o.__)("Anyone with this link who is registered on the network can join as a co-author. The link is valid for 24 hours and is only shown once — copy it now.","multi-author-posts")}),e&&(0,u.jsxs)(c.__experimentalVStack,{spacing:2,children:[(0,u.jsx)(c.TextControl,{label:(0,o.__)("Invite link","multi-author-posts"),value:e,readOnly:!0,onClick:t=>t.target.select()}),(0,u.jsx)(c.__experimentalHStack,{justify:"flex-start",spacing:2,children:(0,u.jsx)(c.Button,{variant:"secondary",onClick:x,disabled:d,children:d?(0,o.__)("Copied!","multi-author-posts"):(0,o.__)("Copy link","multi-author-posts")})})]}),!e&&n&&(0,u.jsx)("p",{className:"map-invite-active",children:(0,o.__)("An invite link is active. Regenerate to issue a new one (the previous link will stop working) or revoke it.","multi-author-posts")}),(0,u.jsxs)(c.__experimentalHStack,{justify:"flex-start",spacing:2,children:[(0,u.jsx)(c.Button,{variant:"secondary",onClick:_,children:n?(0,o.__)("Regenerate invite link","multi-author-posts"):(0,o.__)("Generate invite link","multi-author-posts")}),n&&(0,u.jsx)(c.Button,{variant:"tertiary",isDestructive:!0,onClick:g,children:(0,o.__)("Revoke","multi-author-posts")})]})]})}(0,e.registerPlugin)("multi-author-posts",{render:function(){const t=(0,n.useSelect)(t=>t(a.store).getCurrentPostId()),e=(0,n.useSelect)(t=>t(a.store).getEditedPostAttribute("author")),r=(0,n.useSelect)(t=>t(i.store).getCurrentUser()?.id),[_,g]=(0,s.useState)([]),[x,j]=(0,s.useState)(!0),[v,w]=(0,s.useState)(null),S=!!r&&(r===e||_.some(t=>t.id===r));(0,s.useEffect)(()=>{t&&(j(!0),l()({path:`${h}/posts/${t}/co-authors`}).then(t=>{g(t||[]),j(!1)}).catch(t=>{w(t?.message??(0,o.__)("Could not load co-authors.","multi-author-posts")),j(!1)}))},[t]);const k=(0,s.useCallback)(e=>{l()({path:`${h}/posts/${t}/co-authors/${e}`,method:"DELETE"}).then(()=>g(t=>t.filter(t=>t.id!==e))).catch(t=>w(t?.message??(0,o.__)("Could not remove co-author.","multi-author-posts")))},[t]);return t?(0,u.jsxs)(a.PluginDocumentSettingPanel,{name:"multi-author-posts-panel",title:(0,o.__)("Co-Authors","multi-author-posts"),icon:"groups",children:[v&&(0,u.jsx)(c.Notice,{status:"error",isDismissible:!0,onRemove:()=>w(null),children:v}),x?(0,u.jsx)(c.Spinner,{}):(0,u.jsxs)(c.__experimentalVStack,{spacing:4,children:[0===_.length?(0,u.jsx)("p",{className:"map-empty",children:(0,o.__)("No co-authors yet.","multi-author-posts")}):(0,u.jsx)(c.__experimentalVStack,{spacing:2,children:_.map(t=>(0,u.jsx)(p,{author:t,canManage:S,onRemove:k},t.id))}),S&&(0,u.jsxs)(u.Fragment,{children:[(0,u.jsx)(d,{postId:t,onAdd:g}),(0,u.jsx)(m,{postId:t})]})]})]}):null},icon:"groups"})})(); \ No newline at end of file +(0,o.sprintf)((0,o.__)("Remove %s","multi-author-posts"),t.name),children:(0,o.__)("Remove","multi-author-posts")})]})}function d({postId:t,onAdd:e}){const[a,n]=(0,s.useState)(""),[i,r]=(0,s.useState)([]),[p,d]=(0,s.useState)(!1);return(0,s.useEffect)(()=>{if(a.length<2)return r([]),void d(!1);d(!0);const e=new AbortController;return l()({path:`${h}/posts/${t}/suggested-authors?search=${encodeURIComponent(a)}`,signal:e.signal}).then(t=>{r(t||[]),d(!1)}).catch(t=>{"AbortError"!==t.name&&d(!1)}),()=>e.abort()},[a,t]),(0,u.jsxs)(c.__experimentalVStack,{spacing:1,className:"map-direct-add",children:[(0,u.jsx)(c.SearchControl,{label:(0,o.__)("Add existing author","multi-author-posts"),value:a,onChange:n,placeholder:(0,o.__)("Search by name or email…","multi-author-posts")}),p&&(0,u.jsx)(c.Spinner,{}),i.length>0&&(0,u.jsx)("ul",{className:"map-suggestions",children:i.map(s=>(0,u.jsx)("li",{className:"map-suggestion-item",children:(0,u.jsxs)(c.__experimentalHStack,{justify:"space-between",children:[(0,u.jsxs)(c.__experimentalHStack,{spacing:2,children:[(0,u.jsx)("img",{src:s.avatar,alt:s.name,width:28,height:28}),(0,u.jsx)("span",{children:(0,u.jsx)(c.TextHighlight,{text:s.name,highlight:a})})]}),(0,u.jsx)(c.Button,{variant:"secondary",isSmall:!0,onClick:()=>(s=>{l()({path:`${h}/posts/${t}/co-authors`,method:"POST",data:{user_id:s.id}}).then(t=>{e(t),n(""),r([])})})(s),children:(0,o.__)("Add","multi-author-posts")})]})},s.id))}),a.length>=2&&!p&&0===i.length&&(0,u.jsx)("p",{className:"map-no-suggestions",children:(0,o.__)("No matching authors found.","multi-author-posts")})]})}function m({postId:t}){const[e,a]=(0,s.useState)(!1),[n,i]=(0,s.useState)(!1),[r,p]=(0,s.useState)(!0),[d,m]=(0,s.useState)(!1);(0,s.useEffect)(()=>{l()({path:`${h}/posts/${t}/settings`}).then(t=>{a(!!t?.allow_post_publish_edit),i(!!t?.can_edit_settings),p(!1)}).catch(()=>p(!1))},[t]);const _=(0,s.useCallback)(e=>{a(e),m(!0),l()({path:`${h}/posts/${t}/settings`,method:"PUT",data:{allow_post_publish_edit:e}}).then(t=>{a(!!t?.allow_post_publish_edit),m(!1)}).catch(()=>{a(!e),m(!1)})},[t]);return r||!n?null:(0,u.jsx)(c.__experimentalVStack,{spacing:2,className:"map-settings-section",children:(0,u.jsx)(c.ToggleControl,{label:(0,o.__)("Allow co-authors to edit after publish","multi-author-posts"),help:(0,o.__)("When off, co-authors lose edit access once the post is published. The post author and site editors keep full access either way.","multi-author-posts"),checked:e,disabled:d,onChange:_,__nextHasNoMarginBottom:!0})})}function _({postId:t}){const[e,a]=(0,s.useState)(null),[n,i]=(0,s.useState)(!1),[r,p]=(0,s.useState)(!0),[d,m]=(0,s.useState)(!1);(0,s.useEffect)(()=>{l()({path:`${h}/posts/${t}/invite`}).then(t=>{i(!!t?.active),p(!1)}).catch(()=>p(!1))},[t]);const _=(0,s.useCallback)(()=>{p(!0),l()({path:`${h}/posts/${t}/invite`,method:"POST"}).then(t=>{a(t?.invite_url??null),i(!!t?.invite_url),p(!1)})},[t]),g=(0,s.useCallback)(()=>{p(!0),l()({path:`${h}/posts/${t}/invite`,method:"DELETE"}).then(()=>{a(null),i(!1),p(!1)})},[t]),x=(0,s.useCallback)(()=>{e&&navigator.clipboard?.writeText(e).then(()=>{m(!0),setTimeout(()=>m(!1),2e3)})},[e]);return r?(0,u.jsx)(c.Spinner,{}):(0,u.jsxs)(c.__experimentalVStack,{spacing:2,className:"map-invite-section",children:[(0,u.jsx)("strong",{children:(0,o.__)("Shared invite link","multi-author-posts")}),(0,u.jsx)("p",{className:"map-invite-description",children:(0,o.__)("Anyone with this link who is registered on the network can join as a co-author. The link is valid for 24 hours and is only shown once — copy it now.","multi-author-posts")}),e&&(0,u.jsxs)(c.__experimentalVStack,{spacing:2,children:[(0,u.jsx)(c.TextControl,{label:(0,o.__)("Invite link","multi-author-posts"),value:e,readOnly:!0,onClick:t=>t.target.select()}),(0,u.jsx)(c.__experimentalHStack,{justify:"flex-start",spacing:2,children:(0,u.jsx)(c.Button,{variant:"secondary",onClick:x,disabled:d,children:d?(0,o.__)("Copied!","multi-author-posts"):(0,o.__)("Copy link","multi-author-posts")})})]}),!e&&n&&(0,u.jsx)("p",{className:"map-invite-active",children:(0,o.__)("An invite link is active. Regenerate to issue a new one (the previous link will stop working) or revoke it.","multi-author-posts")}),(0,u.jsxs)(c.__experimentalHStack,{justify:"flex-start",spacing:2,children:[(0,u.jsx)(c.Button,{variant:"secondary",onClick:_,children:n?(0,o.__)("Regenerate invite link","multi-author-posts"):(0,o.__)("Generate invite link","multi-author-posts")}),n&&(0,u.jsx)(c.Button,{variant:"tertiary",isDestructive:!0,onClick:g,children:(0,o.__)("Revoke","multi-author-posts")})]})]})}(0,e.registerPlugin)("multi-author-posts",{render:function(){const t=(0,n.useSelect)(t=>t(a.store).getCurrentPostId()),e=(0,n.useSelect)(t=>t(a.store).getEditedPostAttribute("author")),r=(0,n.useSelect)(t=>t(i.store).getCurrentUser()?.id),[g,x]=(0,s.useState)([]),[j,w]=(0,s.useState)(!0),[v,S]=(0,s.useState)(null),k=!!r&&(r===e||g.some(t=>t.id===r));(0,s.useEffect)(()=>{t&&(w(!0),l()({path:`${h}/posts/${t}/co-authors`}).then(t=>{x(t||[]),w(!1)}).catch(t=>{S(t?.message??(0,o.__)("Could not load co-authors.","multi-author-posts")),w(!1)}))},[t]);const f=(0,s.useCallback)(e=>{l()({path:`${h}/posts/${t}/co-authors/${e}`,method:"DELETE"}).then(()=>x(t=>t.filter(t=>t.id!==e))).catch(t=>S(t?.message??(0,o.__)("Could not remove co-author.","multi-author-posts")))},[t]);return t?(0,u.jsxs)(a.PluginDocumentSettingPanel,{name:"multi-author-posts-panel",title:(0,o.__)("Co-Authors","multi-author-posts"),icon:"groups",children:[v&&(0,u.jsx)(c.Notice,{status:"error",isDismissible:!0,onRemove:()=>S(null),children:v}),j?(0,u.jsx)(c.Spinner,{}):(0,u.jsxs)(c.__experimentalVStack,{spacing:4,children:[0===g.length?(0,u.jsx)("p",{className:"map-empty",children:(0,o.__)("No co-authors yet.","multi-author-posts")}):(0,u.jsx)(c.__experimentalVStack,{spacing:2,children:g.map(t=>(0,u.jsx)(p,{author:t,canManage:k,onRemove:f},t.id))}),k&&(0,u.jsxs)(u.Fragment,{children:[(0,u.jsx)(d,{postId:t,onAdd:x}),(0,u.jsx)(m,{postId:t}),(0,u.jsx)(_,{postId:t})]})]})]}):null},icon:"groups"})})(); \ No newline at end of file diff --git a/includes/class-map-capabilities.php b/includes/class-map-capabilities.php index 323d7b9..c0f333c 100644 --- a/includes/class-map-capabilities.php +++ b/includes/class-map-capabilities.php @@ -44,13 +44,28 @@ public static function handle_co_author_caps( array $caps, string $cap, int $use return $caps; } - if ( Co_Authors::is_co_author( $post_id, $user_id ) ) { - // Returning an empty array grants the capability unconditionally, - // which is intentional here: co-authors are explicitly trusted for - // this specific post regardless of their site role. - return array(); + if ( ! Co_Authors::is_co_author( $post_id, $user_id ) ) { + return $caps; + } + + // Edit access is revoked on publish unless an editor/admin has opted in + // for this specific post. read_post stays unconditional — published + // posts are public anyway, and co-authors of unpublished posts retain + // the read access they were granted. + if ( 'edit_post' === $cap ) { + $post = get_post( $post_id ); + if ( + $post + && 'publish' === $post->post_status + && ! Co_Authors::allows_post_publish_access( $post_id ) + ) { + return $caps; + } } - return $caps; + // Returning an empty array grants the capability unconditionally, + // which is intentional here: co-authors are explicitly trusted for + // this specific post regardless of their site role. + return array(); } } diff --git a/includes/class-map-co-authors.php b/includes/class-map-co-authors.php index d8b2cf9..3e03903 100644 --- a/includes/class-map-co-authors.php +++ b/includes/class-map-co-authors.php @@ -12,7 +12,8 @@ */ class Co_Authors { - const META_KEY = '_map_co_author'; + const META_KEY = '_map_co_author'; + const POST_PUBLISH_ACCESS_META_KEY = '_map_co_author_post_publish_access'; /** * Register post meta on init. @@ -42,6 +43,65 @@ public static function register_meta(): void { }, ) ); + + register_post_meta( + '', + self::POST_PUBLISH_ACCESS_META_KEY, + array( + 'type' => 'boolean', + 'description' => 'Whether co-authors retain edit access after the post is published.', + 'single' => true, + 'show_in_rest' => false, + 'auth_callback' => function ( $allowed, $meta_key, $post_id ) { + return self::current_user_can_manage_settings( (int) $post_id ); + }, + ) + ); + } + + /** + * Whether co-authors retain edit access on a post once it is published. + * + * Defaults to false: publishing strips co-author edit access unless an + * editor/admin opts in for the specific post. + * + * @param int $post_id Post ID. + */ + public static function allows_post_publish_access( int $post_id ): bool { + return (bool) get_post_meta( $post_id, self::POST_PUBLISH_ACCESS_META_KEY, true ); + } + + /** + * Set the post-publish co-author access flag. + * + * @param int $post_id Post ID. + * @param bool $allowed Whether co-authors retain edit access after publish. + */ + public static function set_post_publish_access( int $post_id, bool $allowed ): void { + if ( $allowed ) { + update_post_meta( $post_id, self::POST_PUBLISH_ACCESS_META_KEY, '1' ); + } else { + delete_post_meta( $post_id, self::POST_PUBLISH_ACCESS_META_KEY ); + } + } + + /** + * Whether the current user can change co-author settings for a post. + * + * Gated on the post-type-specific edit_others_posts capability so that + * co-authors and lone post authors (e.g. an Author role) cannot toggle + * settings — only real site editors and admins. + * + * @param int $post_id Post ID. + */ + public static function current_user_can_manage_settings( int $post_id ): bool { + $post = get_post( $post_id ); + if ( ! $post ) { + return false; + } + $pto = get_post_type_object( $post->post_type ); + $cap = $pto ? $pto->cap->edit_others_posts : 'edit_others_posts'; + return current_user_can( $cap ); } /** diff --git a/includes/class-map-rest-api.php b/includes/class-map-rest-api.php index 47fe665..282f8ab 100644 --- a/includes/class-map-rest-api.php +++ b/includes/class-map-rest-api.php @@ -18,6 +18,8 @@ * GET /posts//invite Return whether a shared invite is active (manage caps required) * POST /posts//invite Generate / refresh invite URL — plaintext returned once (manage caps required) * DELETE /posts//invite Revoke invite URL (manage caps required) + * GET /posts//settings Return per-post co-author settings (edit_post required) + * PUT /posts//settings Update per-post co-author settings (edit_others_posts required) */ class Rest_API { @@ -102,6 +104,35 @@ public static function register_routes(): void { ) ); + // Per-post co-author settings (e.g. allow editing after publish). + register_rest_route( + self::NAMESPACE, + '/posts/(?P\d+)/settings', + array( + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( __CLASS__, 'get_settings' ), + 'permission_callback' => array( __CLASS__, 'can_edit_post' ), + 'args' => self::post_id_arg(), + ), + array( + 'methods' => \WP_REST_Server::EDITABLE, + 'callback' => array( __CLASS__, 'update_settings' ), + 'permission_callback' => array( __CLASS__, 'can_manage_settings' ), + 'args' => array_merge( + self::post_id_arg(), + array( + 'allow_post_publish_edit' => array( + 'description' => 'Whether co-authors retain edit access after the post is published.', + 'type' => 'boolean', + 'required' => true, + ), + ) + ), + ), + ) + ); + // Search site authors / editors for direct-add. register_rest_route( self::NAMESPACE, @@ -179,6 +210,30 @@ public static function can_manage_co_authors( \WP_REST_Request $request ) { return true; } + /** + * Require post-type-specific edit_others_posts capability. + * + * Used to gate per-post co-author settings — co-authors and lone post + * authors without edit_others_posts cannot toggle these. + * + * @param \WP_REST_Request $request REST request. + * @return bool|\WP_Error + */ + public static function can_manage_settings( \WP_REST_Request $request ) { + $post = self::get_post_from_request( $request ); + if ( is_wp_error( $post ) ) { + return $post; + } + if ( ! Co_Authors::current_user_can_manage_settings( $post->ID ) ) { + return new \WP_Error( + 'rest_forbidden', + __( 'You do not have permission to change co-author settings for this post.', 'multi-author-posts' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + return true; + } + // ------------------------------------------------------------------------- // Route handlers // ------------------------------------------------------------------------- @@ -285,6 +340,45 @@ public static function revoke_invite( \WP_REST_Request $request ): \WP_REST_Resp return new \WP_REST_Response( array( 'revoked' => true ), 200 ); } + /** + * GET /posts//settings + * + * Returns the per-post co-author settings, plus a flag indicating whether + * the current user can change them. + * + * @param \WP_REST_Request $request REST request. + * @return \WP_REST_Response + */ + public static function get_settings( \WP_REST_Request $request ): \WP_REST_Response { + $post_id = (int) $request->get_param( 'post_id' ); + return new \WP_REST_Response( + array( + 'allow_post_publish_edit' => Co_Authors::allows_post_publish_access( $post_id ), + 'can_edit_settings' => Co_Authors::current_user_can_manage_settings( $post_id ), + ), + 200 + ); + } + + /** + * PUT /posts//settings + * + * @param \WP_REST_Request $request REST request. + * @return \WP_REST_Response + */ + public static function update_settings( \WP_REST_Request $request ): \WP_REST_Response { + $post_id = (int) $request->get_param( 'post_id' ); + $value = (bool) $request->get_param( 'allow_post_publish_edit' ); + Co_Authors::set_post_publish_access( $post_id, $value ); + return new \WP_REST_Response( + array( + 'allow_post_publish_edit' => Co_Authors::allows_post_publish_access( $post_id ), + 'can_edit_settings' => Co_Authors::current_user_can_manage_settings( $post_id ), + ), + 200 + ); + } + /** * GET /posts//suggested-authors?search= * diff --git a/src/components/MultiAuthorPlugin.js b/src/components/MultiAuthorPlugin.js index 6733abc..fb6faeb 100644 --- a/src/components/MultiAuthorPlugin.js +++ b/src/components/MultiAuthorPlugin.js @@ -9,6 +9,7 @@ import { Button, TextControl, TextHighlight, + ToggleControl, Spinner, Notice, SearchControl, @@ -142,6 +143,72 @@ function DirectAdd( { postId, onAdd } ) { ); } +/** + * Per-post co-author settings (currently just: allow editing after publish). + * + * Visible to anyone who can see the panel, but only editors/admins can toggle. + * Other users see the read-only state via a disabled control. + */ +function SettingsSection( { postId } ) { + const [ allowPostPublishEdit, setAllowPostPublishEdit ] = useState( false ); + const [ canEditSettings, setCanEditSettings ] = useState( false ); + const [ isLoading, setIsLoading ] = useState( true ); + const [ isSaving, setIsSaving ] = useState( false ); + + useEffect( () => { + apiFetch( { path: `${ NAMESPACE }/posts/${ postId }/settings` } ) + .then( ( data ) => { + setAllowPostPublishEdit( !! data?.allow_post_publish_edit ); + setCanEditSettings( !! data?.can_edit_settings ); + setIsLoading( false ); + } ) + .catch( () => setIsLoading( false ) ); + }, [ postId ] ); + + const handleToggle = useCallback( + ( next ) => { + setAllowPostPublishEdit( next ); + setIsSaving( true ); + apiFetch( { + path: `${ NAMESPACE }/posts/${ postId }/settings`, + method: 'PUT', + data: { allow_post_publish_edit: next }, + } ) + .then( ( data ) => { + setAllowPostPublishEdit( !! data?.allow_post_publish_edit ); + setIsSaving( false ); + } ) + .catch( () => { + // Revert on failure. + setAllowPostPublishEdit( ! next ); + setIsSaving( false ); + } ); + }, + [ postId ] + ); + + if ( isLoading || ! canEditSettings ) return null; + + return ( + + + + ); +} + /** * Invite-link section (shown only when the current user can manage co-authors). */ @@ -351,6 +418,7 @@ export default function MultiAuthorPlugin() { postId={ postId } onAdd={ setCoAuthors } /> + ) } diff --git a/tests/Test_Capabilities.php b/tests/Test_Capabilities.php index 440b4b0..5e53187 100644 --- a/tests/Test_Capabilities.php +++ b/tests/Test_Capabilities.php @@ -23,7 +23,12 @@ public function set_up(): void { parent::set_up(); $this->author_id = self::factory()->user->create( array( 'role' => 'author' ) ); $this->subscriber_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); - $this->post_id = self::factory()->post->create( array( 'post_author' => $this->author_id ) ); + // Default to a draft so the publish-gate doesn't interfere with tests + // that aren't specifically about post-publish behavior. + $this->post_id = self::factory()->post->create( array( + 'post_author' => $this->author_id, + 'post_status' => 'draft', + ) ); } public function test_co_author_can_edit_post(): void { @@ -62,4 +67,44 @@ public function test_co_author_does_not_gain_global_edit_posts_cap(): void { public function test_original_author_can_still_edit_post(): void { $this->assertTrue( user_can( $this->author_id, 'edit_post', $this->post_id ) ); } + + public function test_co_author_loses_edit_access_when_post_is_published(): void { + Co_Authors::add_co_author( $this->post_id, $this->subscriber_id ); + wp_update_post( array( 'ID' => $this->post_id, 'post_status' => 'publish' ) ); + + $this->assertFalse( user_can( $this->subscriber_id, 'edit_post', $this->post_id ) ); + } + + public function test_co_author_keeps_edit_access_after_publish_when_opt_in_enabled(): void { + Co_Authors::add_co_author( $this->post_id, $this->subscriber_id ); + Co_Authors::set_post_publish_access( $this->post_id, true ); + wp_update_post( array( 'ID' => $this->post_id, 'post_status' => 'publish' ) ); + + $this->assertTrue( user_can( $this->subscriber_id, 'edit_post', $this->post_id ) ); + } + + public function test_co_author_can_still_read_published_post(): void { + Co_Authors::add_co_author( $this->post_id, $this->subscriber_id ); + wp_update_post( array( 'ID' => $this->post_id, 'post_status' => 'publish' ) ); + + // read_post is unaffected by the post-publish edit gate. + $this->assertTrue( user_can( $this->subscriber_id, 'read_post', $this->post_id ) ); + } + + public function test_post_author_keeps_edit_access_after_publish(): void { + // The post's own author retains edit_post via core WP rules, + // regardless of the co-author post-publish flag. + wp_update_post( array( 'ID' => $this->post_id, 'post_status' => 'publish' ) ); + $this->assertTrue( user_can( $this->author_id, 'edit_post', $this->post_id ) ); + } + + public function test_disabling_post_publish_access_removes_edit_after_publish(): void { + Co_Authors::add_co_author( $this->post_id, $this->subscriber_id ); + Co_Authors::set_post_publish_access( $this->post_id, true ); + wp_update_post( array( 'ID' => $this->post_id, 'post_status' => 'publish' ) ); + $this->assertTrue( user_can( $this->subscriber_id, 'edit_post', $this->post_id ) ); + + Co_Authors::set_post_publish_access( $this->post_id, false ); + $this->assertFalse( user_can( $this->subscriber_id, 'edit_post', $this->post_id ) ); + } } diff --git a/tests/Test_Co_Authors.php b/tests/Test_Co_Authors.php index 5669de8..87887bf 100644 --- a/tests/Test_Co_Authors.php +++ b/tests/Test_Co_Authors.php @@ -131,6 +131,27 @@ public function test_remove_leaves_others_in_place(): void { $this->assertContains( $second_user, $co_authors ); } + public function test_post_publish_access_defaults_to_false(): void { + $this->assertFalse( Co_Authors::allows_post_publish_access( $this->post_id ) ); + } + + public function test_set_post_publish_access_round_trips(): void { + Co_Authors::set_post_publish_access( $this->post_id, true ); + $this->assertTrue( Co_Authors::allows_post_publish_access( $this->post_id ) ); + + Co_Authors::set_post_publish_access( $this->post_id, false ); + $this->assertFalse( Co_Authors::allows_post_publish_access( $this->post_id ) ); + } + + public function test_current_user_can_manage_settings_requires_edit_others_posts(): void { + $editor = self::factory()->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $editor ); + $this->assertTrue( Co_Authors::current_user_can_manage_settings( $this->post_id ) ); + + wp_set_current_user( $this->author_id ); + $this->assertFalse( Co_Authors::current_user_can_manage_settings( $this->post_id ) ); + } + public function test_multiple_co_authors(): void { $second_user = self::factory()->user->create( array( 'role' => 'subscriber' ) ); Co_Authors::add_co_author( $this->post_id, $this->user_id ); diff --git a/tests/Test_Rest_API.php b/tests/Test_Rest_API.php index 342114c..81c5b5b 100644 --- a/tests/Test_Rest_API.php +++ b/tests/Test_Rest_API.php @@ -27,7 +27,12 @@ public function set_up(): void { $this->author_id = self::factory()->user->create( array( 'role' => 'author' ) ); $this->editor_id = self::factory()->user->create( array( 'role' => 'editor' ) ); $this->subscriber_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); - $this->post_id = self::factory()->post->create( array( 'post_author' => $this->author_id ) ); + // Default to a draft so the post-publish co-author gate doesn't + // interfere with REST tests that aren't about publish behavior. + $this->post_id = self::factory()->post->create( array( + 'post_author' => $this->author_id, + 'post_status' => 'draft', + ) ); } // ------------------------------------------------------------------------- @@ -313,6 +318,93 @@ public function test_suggested_authors_returns_results_for_valid_search(): void $this->assertContains( $target, $ids ); } + // ------------------------------------------------------------------------- + // Settings (post-publish co-author access) + // ------------------------------------------------------------------------- + + public function test_get_settings_returns_default_off_for_post_author(): void { + wp_set_current_user( $this->author_id ); + + $request = new WP_REST_Request( 'GET', '/multi-author-posts/v1/posts/' . $this->post_id . '/settings' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertFalse( $data['allow_post_publish_edit'] ); + // The post author here is the Author role, which lacks edit_others_posts. + $this->assertFalse( $data['can_edit_settings'] ); + } + + public function test_get_settings_reports_can_edit_settings_for_editor(): void { + wp_set_current_user( $this->editor_id ); + + $request = new WP_REST_Request( 'GET', '/multi-author-posts/v1/posts/' . $this->post_id . '/settings' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertTrue( $response->get_data()['can_edit_settings'] ); + } + + public function test_get_settings_visible_to_co_authors(): void { + Co_Authors::add_co_author( $this->post_id, $this->subscriber_id ); + wp_set_current_user( $this->subscriber_id ); + + $request = new WP_REST_Request( 'GET', '/multi-author-posts/v1/posts/' . $this->post_id . '/settings' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertFalse( $response->get_data()['can_edit_settings'] ); + } + + public function test_update_settings_as_editor(): void { + wp_set_current_user( $this->editor_id ); + + $request = new WP_REST_Request( 'PUT', '/multi-author-posts/v1/posts/' . $this->post_id . '/settings' ); + $request->set_param( 'allow_post_publish_edit', true ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertTrue( $response->get_data()['allow_post_publish_edit'] ); + $this->assertTrue( Co_Authors::allows_post_publish_access( $this->post_id ) ); + } + + public function test_update_settings_forbidden_for_post_author_without_edit_others_posts(): void { + // Author role: has edit_post for own posts, but NOT edit_others_posts. + wp_set_current_user( $this->author_id ); + + $request = new WP_REST_Request( 'PUT', '/multi-author-posts/v1/posts/' . $this->post_id . '/settings' ); + $request->set_param( 'allow_post_publish_edit', true ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 403, $response->get_status() ); + $this->assertFalse( Co_Authors::allows_post_publish_access( $this->post_id ) ); + } + + public function test_update_settings_forbidden_for_co_author(): void { + Co_Authors::add_co_author( $this->post_id, $this->subscriber_id ); + wp_set_current_user( $this->subscriber_id ); + + $request = new WP_REST_Request( 'PUT', '/multi-author-posts/v1/posts/' . $this->post_id . '/settings' ); + $request->set_param( 'allow_post_publish_edit', true ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 403, $response->get_status() ); + $this->assertFalse( Co_Authors::allows_post_publish_access( $this->post_id ) ); + } + + public function test_update_settings_can_disable_after_enabling(): void { + wp_set_current_user( $this->editor_id ); + Co_Authors::set_post_publish_access( $this->post_id, true ); + + $request = new WP_REST_Request( 'PUT', '/multi-author-posts/v1/posts/' . $this->post_id . '/settings' ); + $request->set_param( 'allow_post_publish_edit', false ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertFalse( $response->get_data()['allow_post_publish_edit'] ); + $this->assertFalse( Co_Authors::allows_post_publish_access( $this->post_id ) ); + } + public function test_editor_can_manage_co_authors_via_edit_others_posts(): void { // An editor (who has edit_others_posts) can manage co-authors even // though they are not the post author. diff --git a/tests/js/MultiAuthorPlugin.test.js b/tests/js/MultiAuthorPlugin.test.js index f6b1b91..77a4b38 100644 --- a/tests/js/MultiAuthorPlugin.test.js +++ b/tests/js/MultiAuthorPlugin.test.js @@ -62,11 +62,17 @@ function setupUseSelect( { currentUserId = AUTHOR_ID, postAuthorId = AUTHOR_ID } describe( 'MultiAuthorPlugin', () => { beforeEach( () => { setupUseSelect(); - // Default: empty co-authors list, no invite URL. + // Default: empty co-authors list, no invite URL, settings not editable. apiFetch.mockImplementation( ( { path } ) => { if ( path.includes( '/invite' ) ) { return Promise.resolve( { active: false } ); } + if ( path.includes( '/settings' ) ) { + return Promise.resolve( { + allow_post_publish_edit: false, + can_edit_settings: false, + } ); + } return Promise.resolve( [] ); } ); } ); @@ -184,6 +190,83 @@ describe( 'MultiAuthorPlugin', () => { expect( screen.getByRole( 'searchbox' ) ).toBeInTheDocument(); } ); + it( 'hides the post-publish-edit toggle when current user cannot edit settings', async () => { + // Default mock returns can_edit_settings: false. + render( ); + await waitFor( () => + expect( + screen.getByRole( 'button', { name: /generate invite link/i } ) + ).toBeInTheDocument() + ); + + expect( + screen.queryByRole( 'checkbox', { name: /allow co-authors to edit after publish/i } ) + ).toBeNull(); + } ); + + it( 'shows the post-publish-edit toggle to editors and reflects the current value', async () => { + apiFetch.mockImplementation( ( { path } ) => { + if ( path.includes( '/invite' ) ) return Promise.resolve( { active: false } ); + if ( path.includes( '/settings' ) ) { + return Promise.resolve( { + allow_post_publish_edit: true, + can_edit_settings: true, + } ); + } + return Promise.resolve( [] ); + } ); + + render( ); + + await waitFor( () => + expect( + screen.getByRole( 'checkbox', { name: /allow co-authors to edit after publish/i } ) + ).toBeChecked() + ); + } ); + + it( 'persists the toggle change via PUT /settings', async () => { + apiFetch.mockImplementation( ( { path, method } ) => { + if ( path.includes( '/invite' ) ) return Promise.resolve( { active: false } ); + if ( path.includes( '/settings' ) && method === 'PUT' ) { + return Promise.resolve( { + allow_post_publish_edit: true, + can_edit_settings: true, + } ); + } + if ( path.includes( '/settings' ) ) { + return Promise.resolve( { + allow_post_publish_edit: false, + can_edit_settings: true, + } ); + } + return Promise.resolve( [] ); + } ); + + const user = userEvent.setup(); + render( ); + + await waitFor( () => + expect( + screen.getByRole( 'checkbox', { name: /allow co-authors to edit after publish/i } ) + ).not.toBeChecked() + ); + + await act( async () => { + await user.click( + screen.getByRole( 'checkbox', { name: /allow co-authors to edit after publish/i } ) + ); + } ); + + expect( apiFetch ).toHaveBeenCalledWith( + expect.objectContaining( { + method: 'PUT', + path: expect.stringContaining( '/settings' ), + data: { allow_post_publish_edit: true }, + } ) + ); + } ); + it( 'calls DELETE when Remove button is clicked', async () => { apiFetch.mockImplementation( ( { path, method } ) => { if ( method === 'DELETE' ) return Promise.resolve( {} );